init
This commit is contained in:
65
app/Http/Controllers/Site/GuideController.php
Normal file
65
app/Http/Controllers/Site/GuideController.php
Normal file
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Site;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\AiModel;
|
||||
use App\Models\Category;
|
||||
use App\Models\Guide;
|
||||
use App\Models\Tool;
|
||||
use App\Support\MarkdownRenderer;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class GuideController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly MarkdownRenderer $markdownRenderer,
|
||||
) {
|
||||
}
|
||||
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$builder = Guide::query()->published()->with('category');
|
||||
|
||||
if ($request->filled('q')) {
|
||||
$builder->whereFullText(['title', 'excerpt', 'body'], (string) $request->string('q'));
|
||||
}
|
||||
|
||||
if ($request->filled('difficulty')) {
|
||||
$builder->where('difficulty', (string) $request->string('difficulty'));
|
||||
}
|
||||
|
||||
return view('public.guides.index', [
|
||||
'items' => $builder->latest('published_at')->paginate(15)->withQueryString(),
|
||||
'categories' => Category::query()->where('type', 'guide')->where('is_active', true)->orderBy('name')->get(),
|
||||
'filters' => $request->only(['q', 'difficulty']),
|
||||
'sidebarTools' => Tool::published()->latest('published_at')->limit(6)->get(),
|
||||
'sidebarModels' => AiModel::published()->orderByDesc('total_score')->limit(6)->get(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function byTopic(string $slug, Request $request): View
|
||||
{
|
||||
$request->merge(['difficulty' => $slug]);
|
||||
|
||||
return $this->index($request);
|
||||
}
|
||||
|
||||
public function show(string $slug): View
|
||||
{
|
||||
/** @var Guide $guide */
|
||||
$guide = Guide::query()
|
||||
->published()
|
||||
->with('category')
|
||||
->where('slug', $slug)
|
||||
->firstOrFail();
|
||||
|
||||
return view('public.guides.show', [
|
||||
'item' => $guide,
|
||||
'bodyHtml' => $this->markdownRenderer->render($guide->body),
|
||||
]);
|
||||
}
|
||||
}
|
||||
53
app/Http/Controllers/Site/HomeController.php
Normal file
53
app/Http/Controllers/Site/HomeController.php
Normal file
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Site;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\AiModel;
|
||||
use App\Models\Article;
|
||||
use App\Models\Category;
|
||||
use App\Models\Guide;
|
||||
use App\Models\Tool;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class HomeController extends Controller
|
||||
{
|
||||
public function __invoke(): View
|
||||
{
|
||||
$payload = Cache::remember('home_page_payload_v2', now()->addMinutes(10), function (): array {
|
||||
$latestTools = Tool::published()->latest('published_at')->limit(10)->get();
|
||||
$latestModels = AiModel::published()->orderByDesc('total_score')->limit(10)->get();
|
||||
$latestArticles = Article::published()->latest('published_at')->limit(10)->get();
|
||||
$latestGuides = Guide::published()->latest('published_at')->limit(10)->get();
|
||||
|
||||
return [
|
||||
'stats' => [
|
||||
'tools' => Tool::published()->count(),
|
||||
'models' => AiModel::published()->count(),
|
||||
'articles' => Article::published()->count(),
|
||||
'guides' => Guide::published()->count(),
|
||||
],
|
||||
'featuredTools' => $latestTools->take(4),
|
||||
'featuredModels' => $latestModels->take(4),
|
||||
'toolList' => $latestTools->take(8),
|
||||
'modelList' => $latestModels->take(8),
|
||||
'articleList' => $latestArticles->take(8),
|
||||
'guideList' => $latestGuides->take(8),
|
||||
'hotKeywords' => [
|
||||
'AI导航', '工作流自动化', '多模态模型', 'AIGC 设计', 'AI 编程助手', '提示词模板', '行业快讯', '模型评测',
|
||||
],
|
||||
'moduleCategories' => [
|
||||
'tools' => Category::query()->where('type', 'tool')->where('is_active', true)->orderBy('name')->limit(8)->get(),
|
||||
'models' => Category::query()->where('type', 'model')->where('is_active', true)->orderBy('name')->limit(8)->get(),
|
||||
'news' => Category::query()->where('type', 'news')->where('is_active', true)->orderBy('name')->limit(8)->get(),
|
||||
'guides' => Category::query()->where('type', 'guide')->where('is_active', true)->orderBy('name')->limit(8)->get(),
|
||||
],
|
||||
];
|
||||
});
|
||||
|
||||
return view('public.home', $payload);
|
||||
}
|
||||
}
|
||||
77
app/Http/Controllers/Site/ModelController.php
Normal file
77
app/Http/Controllers/Site/ModelController.php
Normal file
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Site;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\AiModel;
|
||||
use App\Models\Article;
|
||||
use App\Models\Category;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class ModelController extends Controller
|
||||
{
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$builder = AiModel::query()->published()->with('category');
|
||||
|
||||
if ($request->filled('q')) {
|
||||
$builder->whereFullText(['name', 'summary', 'description'], (string) $request->string('q'));
|
||||
}
|
||||
|
||||
if ($request->filled('modality')) {
|
||||
$builder->where('modality', (string) $request->string('modality'));
|
||||
}
|
||||
|
||||
if ($request->filled('deployment')) {
|
||||
$builder->where('deployment_mode', (string) $request->string('deployment'));
|
||||
}
|
||||
|
||||
return view('public.models.index', [
|
||||
'items' => $builder->orderByDesc('total_score')->paginate(18)->withQueryString(),
|
||||
'categories' => Category::query()->where('type', 'model')->where('is_active', true)->orderBy('name')->get(),
|
||||
'filters' => $request->only(['q', 'modality', 'deployment']),
|
||||
'sidebarTools' => \App\Models\Tool::published()->latest('published_at')->limit(6)->get(),
|
||||
'sidebarNews' => Article::published()->latest('published_at')->limit(6)->get(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function byUseCase(string $slug, Request $request): View
|
||||
{
|
||||
$request->merge(['modality' => $slug]);
|
||||
|
||||
return $this->index($request);
|
||||
}
|
||||
|
||||
public function show(string $slug): View
|
||||
{
|
||||
/** @var AiModel $model */
|
||||
$model = AiModel::query()
|
||||
->published()
|
||||
->with(['category', 'source', 'alternative'])
|
||||
->where('slug', $slug)
|
||||
->firstOrFail();
|
||||
|
||||
$relatedModels = AiModel::query()
|
||||
->published()
|
||||
->whereKeyNot($model->id)
|
||||
->when($model->modality !== null, fn (Builder $query): Builder => $query->where('modality', $model->modality))
|
||||
->orderByDesc('total_score')
|
||||
->limit(6)
|
||||
->get();
|
||||
|
||||
return view('public.models.show', [
|
||||
'item' => $model,
|
||||
'relatedModels' => $relatedModels,
|
||||
'showRiskNotice' => $this->containsRiskKeyword($model->summary.' '.$model->description),
|
||||
]);
|
||||
}
|
||||
|
||||
private function containsRiskKeyword(string $text): bool
|
||||
{
|
||||
return str_contains($text, '医疗') || str_contains($text, '法律') || str_contains($text, '投资');
|
||||
}
|
||||
}
|
||||
66
app/Http/Controllers/Site/NewsController.php
Normal file
66
app/Http/Controllers/Site/NewsController.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Site;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\AiModel;
|
||||
use App\Models\Article;
|
||||
use App\Models\Category;
|
||||
use App\Models\Guide;
|
||||
use App\Support\MarkdownRenderer;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class NewsController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly MarkdownRenderer $markdownRenderer,
|
||||
) {
|
||||
}
|
||||
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$builder = Article::query()->published()->with('category');
|
||||
|
||||
if ($request->filled('q')) {
|
||||
$builder->whereFullText(['title', 'excerpt', 'body'], (string) $request->string('q'));
|
||||
}
|
||||
|
||||
if ($request->filled('category')) {
|
||||
$builder->whereHas('category', function ($query) use ($request): void {
|
||||
$query->where('slug', (string) $request->string('category'));
|
||||
});
|
||||
}
|
||||
|
||||
return view('public.news.index', [
|
||||
'items' => $builder->latest('published_at')->paginate(15)->withQueryString(),
|
||||
'categories' => Category::query()->where('type', 'news')->where('is_active', true)->orderBy('name')->get(),
|
||||
'filters' => $request->only(['q', 'category']),
|
||||
'sidebarModels' => AiModel::published()->orderByDesc('total_score')->limit(6)->get(),
|
||||
'sidebarGuides' => Guide::published()->latest('published_at')->limit(6)->get(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function show(string $slug): View
|
||||
{
|
||||
/** @var Article $article */
|
||||
$article = Article::query()
|
||||
->published()
|
||||
->with(['category', 'source'])
|
||||
->where('slug', $slug)
|
||||
->firstOrFail();
|
||||
|
||||
return view('public.news.show', [
|
||||
'item' => $article,
|
||||
'bodyHtml' => $this->markdownRenderer->render($article->body),
|
||||
'showRiskNotice' => $this->containsRiskKeyword($article->title.' '.$article->body),
|
||||
]);
|
||||
}
|
||||
|
||||
private function containsRiskKeyword(string $text): bool
|
||||
{
|
||||
return str_contains($text, '医疗') || str_contains($text, '法律') || str_contains($text, '投资');
|
||||
}
|
||||
}
|
||||
56
app/Http/Controllers/Site/SeoController.php
Normal file
56
app/Http/Controllers/Site/SeoController.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Site;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\AiModel;
|
||||
use App\Models\Article;
|
||||
use App\Models\Guide;
|
||||
use App\Models\Tool;
|
||||
use Illuminate\Http\Response;
|
||||
|
||||
class SeoController extends Controller
|
||||
{
|
||||
public function robots(): Response
|
||||
{
|
||||
$content = "User-agent: *\n";
|
||||
$content .= "Allow: /\n";
|
||||
$content .= "Disallow: /admin\n";
|
||||
$content .= 'Sitemap: '.url('/sitemap.xml')."\n";
|
||||
|
||||
return response($content, 200, ['Content-Type' => 'text/plain; charset=UTF-8']);
|
||||
}
|
||||
|
||||
public function sitemap(): Response
|
||||
{
|
||||
$urls = [
|
||||
['loc' => url('/'), 'lastmod' => now()->toDateString(), 'priority' => '1.0'],
|
||||
['loc' => route('tools.index'), 'lastmod' => now()->toDateString(), 'priority' => '0.9'],
|
||||
['loc' => route('models.index'), 'lastmod' => now()->toDateString(), 'priority' => '0.9'],
|
||||
['loc' => route('news.index'), 'lastmod' => now()->toDateString(), 'priority' => '0.8'],
|
||||
['loc' => route('guides.index'), 'lastmod' => now()->toDateString(), 'priority' => '0.8'],
|
||||
];
|
||||
|
||||
foreach (Tool::query()->published()->latest('updated_at')->limit(500)->get(['slug', 'updated_at']) as $item) {
|
||||
$urls[] = ['loc' => route('tools.show', $item->slug), 'lastmod' => $item->updated_at?->toDateString(), 'priority' => '0.7'];
|
||||
}
|
||||
|
||||
foreach (AiModel::query()->published()->latest('updated_at')->limit(500)->get(['slug', 'updated_at']) as $item) {
|
||||
$urls[] = ['loc' => route('models.show', $item->slug), 'lastmod' => $item->updated_at?->toDateString(), 'priority' => '0.7'];
|
||||
}
|
||||
|
||||
foreach (Article::query()->published()->latest('updated_at')->limit(500)->get(['slug', 'updated_at']) as $item) {
|
||||
$urls[] = ['loc' => route('news.show', $item->slug), 'lastmod' => $item->updated_at?->toDateString(), 'priority' => '0.6'];
|
||||
}
|
||||
|
||||
foreach (Guide::query()->published()->latest('updated_at')->limit(500)->get(['slug', 'updated_at']) as $item) {
|
||||
$urls[] = ['loc' => route('guides.show', $item->slug), 'lastmod' => $item->updated_at?->toDateString(), 'priority' => '0.6'];
|
||||
}
|
||||
|
||||
$xml = view('public.sitemap', ['urls' => $urls])->render();
|
||||
|
||||
return response($xml, 200, ['Content-Type' => 'application/xml; charset=UTF-8']);
|
||||
}
|
||||
}
|
||||
245
app/Http/Controllers/Site/ToolController.php
Normal file
245
app/Http/Controllers/Site/ToolController.php
Normal file
@@ -0,0 +1,245 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Site;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\AiModel;
|
||||
use App\Models\Article;
|
||||
use App\Models\Category;
|
||||
use App\Models\Tool;
|
||||
use App\Support\MarkdownRenderer;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class ToolController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly MarkdownRenderer $markdownRenderer,
|
||||
) {
|
||||
}
|
||||
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$querySignature = sha1((string) $request->getQueryString());
|
||||
$activeTab = $this->resolveTab($request);
|
||||
|
||||
$payload = Cache::remember("tools_index_v4_{$querySignature}", now()->addMinutes(10), function () use ($request, $activeTab): array {
|
||||
$builder = Tool::query()
|
||||
->published()
|
||||
->with(['category', 'alternative']);
|
||||
|
||||
$this->applyFilters($builder, $request);
|
||||
$this->applyTabSorting($builder, $activeTab);
|
||||
|
||||
$categoryNavigation = Category::query()
|
||||
->where('type', 'tool')
|
||||
->where('is_active', true)
|
||||
->withCount([
|
||||
'tools as published_tools_count' => fn (Builder $query): Builder => $query->published(),
|
||||
])
|
||||
->orderByDesc('published_tools_count')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
$totalPublished = Tool::query()->published()->count();
|
||||
$totalApiSupported = Tool::query()->published()->where('has_api', true)->count();
|
||||
$totalFreeAvailable = Tool::query()->published()->whereIn('pricing_type', ['free', 'freemium'])->count();
|
||||
$updatedInSevenDays = Tool::query()->published()->where('updated_at', '>=', now()->subDays(7))->count();
|
||||
|
||||
return [
|
||||
'items' => $builder->paginate(24)->withQueryString(),
|
||||
'categories' => $categoryNavigation,
|
||||
'filters' => $request->only(['q', 'category', 'pricing', 'api', 'tab']),
|
||||
'activeTab' => $activeTab,
|
||||
'tabOptions' => $this->tabOptions(),
|
||||
'toolStats' => [
|
||||
'total' => $totalPublished,
|
||||
'api' => $totalApiSupported,
|
||||
'free' => $totalFreeAvailable,
|
||||
'updated_7d' => $updatedInSevenDays,
|
||||
],
|
||||
'featuredTools' => Tool::query()->published()->with('category')->orderByDesc('published_at')->limit(8)->get(),
|
||||
'sidebarModels' => AiModel::published()->orderByDesc('total_score')->limit(8)->get(),
|
||||
'sidebarNews' => Article::published()->latest('published_at')->limit(8)->get(),
|
||||
];
|
||||
});
|
||||
|
||||
return view('public.tools.index', $payload);
|
||||
}
|
||||
|
||||
public function byCategory(string $slug, Request $request): View
|
||||
{
|
||||
$request->merge(['category' => $slug]);
|
||||
|
||||
return $this->index($request);
|
||||
}
|
||||
|
||||
public function show(string $slug): View
|
||||
{
|
||||
/** @var Tool $tool */
|
||||
$tool = Tool::query()
|
||||
->published()
|
||||
->with(['category', 'source', 'alternative'])
|
||||
->where('slug', $slug)
|
||||
->firstOrFail();
|
||||
|
||||
$relatedTools = Tool::query()
|
||||
->published()
|
||||
->whereKeyNot($tool->id)
|
||||
->when($tool->category_id !== null, fn (Builder $query): Builder => $query->where('category_id', $tool->category_id))
|
||||
->orderByDesc('published_at')
|
||||
->limit(8)
|
||||
->get();
|
||||
|
||||
$latestTools = Tool::query()
|
||||
->published()
|
||||
->whereKeyNot($tool->id)
|
||||
->with('category')
|
||||
->latest('published_at')
|
||||
->limit(10)
|
||||
->get();
|
||||
|
||||
return view('public.tools.show', [
|
||||
'item' => $tool,
|
||||
'relatedTools' => $relatedTools,
|
||||
'latestTools' => $latestTools,
|
||||
'capabilityTags' => $this->extractCapabilityTags($tool),
|
||||
'descriptionHtml' => $this->markdownRenderer->render($tool->description),
|
||||
'showRiskNotice' => $this->containsRiskKeyword($tool->summary.' '.$tool->description),
|
||||
]);
|
||||
}
|
||||
|
||||
private function resolveTab(Request $request): string
|
||||
{
|
||||
$tab = (string) $request->string('tab');
|
||||
$allowedTabs = array_column($this->tabOptions(), 'key');
|
||||
|
||||
return in_array($tab, $allowedTabs, true) ? $tab : 'recommended';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{key: string, label: string}>
|
||||
*/
|
||||
private function tabOptions(): array
|
||||
{
|
||||
return [
|
||||
['key' => 'recommended', 'label' => '综合推荐'],
|
||||
['key' => 'latest', 'label' => '最新收录'],
|
||||
['key' => 'api', 'label' => 'API 优先'],
|
||||
['key' => 'free', 'label' => '免费优先'],
|
||||
];
|
||||
}
|
||||
|
||||
private function applyTabSorting(Builder $builder, string $activeTab): void
|
||||
{
|
||||
if ($activeTab === 'api') {
|
||||
$builder->where('has_api', true);
|
||||
}
|
||||
|
||||
if ($activeTab === 'free') {
|
||||
$builder->whereIn('pricing_type', ['free', 'freemium']);
|
||||
}
|
||||
|
||||
if ($activeTab === 'latest') {
|
||||
$builder->orderByDesc('published_at');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$builder
|
||||
->orderBy('is_stale')
|
||||
->orderByDesc('has_api')
|
||||
->orderByDesc('published_at');
|
||||
}
|
||||
|
||||
private function applyFilters(Builder $builder, Request $request): void
|
||||
{
|
||||
if ($request->filled('q')) {
|
||||
$keyword = trim((string) $request->string('q'));
|
||||
$builder->whereFullText(['name', 'summary', 'description'], $keyword);
|
||||
}
|
||||
|
||||
if ($request->filled('category')) {
|
||||
$builder->whereHas('category', function (Builder $categoryQuery) use ($request): void {
|
||||
$categoryQuery->where('slug', (string) $request->string('category'));
|
||||
});
|
||||
}
|
||||
|
||||
if ($request->filled('pricing')) {
|
||||
$builder->where('pricing_type', (string) $request->string('pricing'));
|
||||
}
|
||||
|
||||
if ($request->filled('api')) {
|
||||
$builder->where('has_api', $request->boolean('api'));
|
||||
}
|
||||
}
|
||||
|
||||
private function containsRiskKeyword(string $text): bool
|
||||
{
|
||||
return str_contains($text, '医疗')
|
||||
|| str_contains($text, '法律')
|
||||
|| str_contains($text, '投资')
|
||||
|| str_contains($text, 'medical')
|
||||
|| str_contains($text, 'legal')
|
||||
|| str_contains($text, 'investment');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function extractCapabilityTags(Tool $tool): array
|
||||
{
|
||||
$text = mb_strtolower($tool->name.' '.$tool->summary.' '.$tool->description);
|
||||
|
||||
$tagMap = [
|
||||
'写作' => '内容写作',
|
||||
'文案' => '文案生成',
|
||||
'翻译' => '多语翻译',
|
||||
'图像' => '图像生成',
|
||||
'图片' => '图像处理',
|
||||
'视频' => '视频生成',
|
||||
'语音' => '语音处理',
|
||||
'音频' => '音频处理',
|
||||
'客服' => '智能客服',
|
||||
'代码' => '代码助手',
|
||||
'编程' => '开发辅助',
|
||||
'搜索' => '知识检索',
|
||||
'自动化' => '流程自动化',
|
||||
'agent' => 'Agent 工作流',
|
||||
'api' => '开放 API',
|
||||
];
|
||||
|
||||
$tags = [];
|
||||
foreach ($tagMap as $keyword => $label) {
|
||||
if (str_contains($text, $keyword)) {
|
||||
$tags[] = $label;
|
||||
}
|
||||
}
|
||||
|
||||
if ($tool->has_api) {
|
||||
$tags[] = 'API 集成';
|
||||
}
|
||||
|
||||
if ($tool->pricing_type === 'free') {
|
||||
$tags[] = '完全免费';
|
||||
}
|
||||
|
||||
if ($tool->pricing_type === 'freemium') {
|
||||
$tags[] = '免费增值';
|
||||
}
|
||||
|
||||
if ($tool->pricing_type === 'paid') {
|
||||
$tags[] = '商业付费';
|
||||
}
|
||||
|
||||
if ($tool->category?->name !== null) {
|
||||
$tags[] = $tool->category->name;
|
||||
}
|
||||
|
||||
return array_values(array_unique(array_slice($tags, 0, 8)));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user