getQueryString()); $activeTab = $this->resolveTab($request); $payload = Cache::remember("tools_portal_v3_home_{$querySignature}", now()->addMinutes(10), function () use ($request, $activeTab): array { $builder = Tool::query() ->published() ->with('category'); $this->applyFilters($builder, $request, false); $this->applyTabSorting($builder, $activeTab); $categories = 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(); $portalTools = $builder->limit(360)->get(); $toolsByCategory = $portalTools->groupBy(fn (Tool $tool): string => $tool->category?->slug ?? 'uncategorized'); $modules = $this->loadHomeModules(); $moduleMap = collect($modules)->keyBy('module_key'); $hotToolsLimit = (int) data_get($moduleMap, 'hot_tools.limit', 18); $latestToolsLimit = (int) data_get($moduleMap, 'latest_tools.limit', 18); $sectionLimit = (int) data_get($moduleMap, 'category_sections.limit', 18); $categorySections = $categories->map(function (Category $category) use ($toolsByCategory, $sectionLimit): array { $sectionTools = $toolsByCategory->get($category->slug, collect())->take($sectionLimit); return [ 'slug' => $category->slug, 'name' => $category->name, 'count' => (int) ($category->published_tools_count ?? 0), 'tools' => $sectionTools, ]; })->values(); return [ 'categories' => $categories, 'categorySections' => $categorySections, 'hotTools' => $portalTools->take($hotToolsLimit), 'latestTools' => Tool::query()->published()->with('category')->latest('published_at')->limit($latestToolsLimit)->get(), 'filters' => $request->only(['q', 'pricing', 'api', 'tab']), 'activeTab' => $activeTab, 'tabOptions' => $this->tabOptions(), 'toolStats' => $this->buildToolStats(), 'modules' => $moduleMap, ]; }); return view('public.tools.index', $payload); } public function list(Request $request): View { $activeTab = $this->resolveTab($request); $builder = Tool::query() ->published() ->with('category'); $this->applyFilters($builder, $request, true); $this->applyTabSorting($builder, $activeTab); $categories = 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(); return view('public.tools.list', [ 'items' => $builder->paginate(36)->withQueryString(), 'categories' => $categories, 'filters' => $request->only(['q', 'category', 'pricing', 'api', 'tab']), 'activeTab' => $activeTab, 'tabOptions' => $this->tabOptions(), 'toolStats' => $this->buildToolStats(), 'sidebarModels' => AiModel::query()->published()->orderByDesc('total_score')->limit(8)->get(), 'sidebarNews' => Article::query()->published()->latest('published_at')->limit(8)->get(), ]); } public function byCategory(string $slug, Request $request): View { $request->merge(['category' => $slug]); return $this->list($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 */ 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, bool $withCategory): void { if ($request->filled('q')) { $keyword = trim((string) $request->string('q')); $builder->whereFullText(['name', 'summary', 'description'], $keyword); } if ($withCategory && $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')); } } /** * @return array{total:int, api:int, free:int, updated_7d:int} */ private function buildToolStats(): array { return [ 'total' => Tool::query()->published()->count(), 'api' => Tool::query()->published()->where('has_api', true)->count(), 'free' => Tool::query()->published()->whereIn('pricing_type', ['free', 'freemium'])->count(), 'updated_7d' => Tool::query()->published()->where('updated_at', '>=', now()->subDays(7))->count(), ]; } 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 */ 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))); } /** * @return array> */ private function loadHomeModules(): array { $defaults = $this->defaultHomeModules(); $records = HomeModule::query() ->with(['items' => fn ($query) => $query->where('enabled', true)->orderBy('sort_order')->orderBy('id')]) ->orderBy('sort_order') ->orderBy('id') ->get(); if ($records->isEmpty()) { return $defaults; } $filled = []; foreach ($records as $record) { $default = collect($defaults)->firstWhere('module_key', $record->module_key); if (!is_array($default)) { continue; } $items = $record->items->map(function ($item): array { return [ 'id' => $item->id, 'title' => (string) ($item->title ?? ''), 'subtitle' => (string) ($item->subtitle ?? ''), 'image_path' => (string) ($item->image_path ?? ''), 'link_type' => (string) ($item->link_type ?? 'route'), 'link_target' => (string) ($item->link_target ?? ''), 'url' => $this->resolveLink((string) ($item->link_type ?? 'route'), (string) ($item->link_target ?? '')), ]; })->values()->all(); $filled[] = [ 'module_key' => $record->module_key, 'name' => $record->name, 'title' => $record->title ?: $default['title'], 'subtitle' => $record->subtitle ?: $default['subtitle'], 'enabled' => (bool) $record->enabled, 'sort_order' => (int) $record->sort_order, 'limit' => max(1, min(30, (int) $record->limit)), 'more_link_type' => $record->more_link_type, 'more_link_target' => $record->more_link_target, 'more_url' => $this->resolveLink((string) $record->more_link_type, (string) $record->more_link_target), 'extra' => is_array($record->extra) ? $record->extra : [], 'items' => $items, ]; } if ($filled === []) { return $defaults; } return $this->ensureModuleDefaults($filled, $defaults); } /** * @return array> */ private function defaultHomeModules(): array { return [ [ 'module_key' => 'channel_cards', 'name' => '频道卡片', 'title' => '频道入口', 'subtitle' => '模型、资讯、教程等入口合集', 'enabled' => true, 'sort_order' => 20, 'limit' => 5, 'more_link_type' => null, 'more_link_target' => null, 'more_url' => null, 'extra' => [ 'side_title' => '频道导航', 'side_subtitle' => '快速直达', ], 'items' => [ [ 'title' => '每日快讯', 'subtitle' => 'AI 资讯', 'image_path' => '', 'link_type' => 'route', 'link_target' => 'news.index', 'url' => route('news.index'), ], [ 'title' => '最新模型', 'subtitle' => '模型推荐', 'image_path' => '', 'link_type' => 'route', 'link_target' => 'models.index', 'url' => route('models.index'), ], [ 'title' => '热门教程', 'subtitle' => '教程学习', 'image_path' => '', 'link_type' => 'route', 'link_target' => 'guides.index', 'url' => route('guides.index'), ], [ 'title' => '工具列表', 'subtitle' => '查看全部', 'image_path' => '', 'link_type' => 'route', 'link_target' => 'tools.list', 'url' => route('tools.list'), ], ], ], [ 'module_key' => 'promo_banners', 'name' => '横幅推荐', 'title' => '精选推荐', 'subtitle' => '运营位横幅可配置', 'enabled' => true, 'sort_order' => 30, 'limit' => 2, 'more_link_type' => null, 'more_link_target' => null, 'more_url' => null, 'extra' => [], 'items' => [ [ 'title' => '超全图像视频模板一键复制', 'subtitle' => '全球顶尖模型', 'image_path' => '', 'link_type' => 'route', 'link_target' => 'tools.list', 'url' => route('tools.list'), ], [ 'title' => '一站式 AI 创作平台', 'subtitle' => '免费试用', 'image_path' => '', 'link_type' => 'route', 'link_target' => 'tools.list', 'url' => route('tools.list'), ], ], ], [ 'module_key' => 'hot_tools', 'name' => '热门工具', 'title' => '热门工具', 'subtitle' => '根据推荐排序筛选', 'enabled' => true, 'sort_order' => 40, 'limit' => 18, 'more_link_type' => 'route', 'more_link_target' => 'tools.list', 'more_url' => route('tools.list', ['tab' => 'recommended']), 'extra' => ['side_title' => '热门工具'], 'items' => [], ], [ 'module_key' => 'latest_tools', 'name' => '最新收录', 'title' => '最新收录', 'subtitle' => '按发布时间倒序', 'enabled' => true, 'sort_order' => 50, 'limit' => 18, 'more_link_type' => 'route', 'more_link_target' => 'tools.list', 'more_url' => route('tools.list', ['tab' => 'latest']), 'extra' => ['side_title' => '最新收录'], 'items' => [], ], [ 'module_key' => 'category_sections', 'name' => '分类分块', 'title' => '分类分块', 'subtitle' => '按分类浏览工具', 'enabled' => true, 'sort_order' => 60, 'limit' => 18, 'more_link_type' => null, 'more_link_target' => null, 'more_url' => null, 'extra' => ['side_title' => '分类导航'], 'items' => [], ], ]; } /** * @param array> $loaded * @param array> $defaults * @return array> */ private function ensureModuleDefaults(array $loaded, array $defaults): array { $loadedByKey = collect($loaded)->keyBy('module_key'); $merged = []; foreach ($defaults as $default) { $existing = $loadedByKey->get($default['module_key']); if (!is_array($existing)) { $merged[] = $default; continue; } $merged[] = array_merge($default, $existing, [ 'items' => $existing['items'] ?? $default['items'], 'extra' => array_merge($default['extra'] ?? [], $existing['extra'] ?? []), ]); } usort($merged, fn (array $a, array $b): int => (int) $a['sort_order'] <=> (int) $b['sort_order']); return $merged; } private function resolveLink(string $type, string $target): ?string { if ($type === 'route') { if ($target === '' || !\Illuminate\Support\Facades\Route::has($target)) { return null; } return route($target); } if ($target === '') { return null; } if (str_starts_with($target, '/')) { return $target; } return filter_var($target, FILTER_VALIDATE_URL) ? $target : null; } }