2026-02-11 17:28:36 +08:00
|
|
|
<?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;
|
2026-02-12 15:37:49 +08:00
|
|
|
use App\Models\HomeModule;
|
2026-02-11 17:28:36 +08:00
|
|
|
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);
|
|
|
|
|
|
2026-02-12 15:37:49 +08:00
|
|
|
$payload = Cache::remember("tools_portal_v3_home_{$querySignature}", now()->addMinutes(10), function () use ($request, $activeTab): array {
|
2026-02-11 17:28:36 +08:00
|
|
|
$builder = Tool::query()
|
|
|
|
|
->published()
|
2026-02-12 10:57:53 +08:00
|
|
|
->with('category');
|
2026-02-11 17:28:36 +08:00
|
|
|
|
2026-02-12 10:57:53 +08:00
|
|
|
$this->applyFilters($builder, $request, false);
|
2026-02-11 17:28:36 +08:00
|
|
|
$this->applyTabSorting($builder, $activeTab);
|
|
|
|
|
|
2026-02-12 10:57:53 +08:00
|
|
|
$categories = Category::query()
|
2026-02-11 17:28:36 +08:00
|
|
|
->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();
|
|
|
|
|
|
2026-02-12 10:57:53 +08:00
|
|
|
$portalTools = $builder->limit(360)->get();
|
|
|
|
|
$toolsByCategory = $portalTools->groupBy(fn (Tool $tool): string => $tool->category?->slug ?? 'uncategorized');
|
|
|
|
|
|
2026-02-12 15:37:49 +08:00
|
|
|
$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);
|
2026-02-12 10:57:53 +08:00
|
|
|
|
|
|
|
|
return [
|
|
|
|
|
'slug' => $category->slug,
|
|
|
|
|
'name' => $category->name,
|
|
|
|
|
'count' => (int) ($category->published_tools_count ?? 0),
|
|
|
|
|
'tools' => $sectionTools,
|
|
|
|
|
];
|
|
|
|
|
})->values();
|
2026-02-11 17:28:36 +08:00
|
|
|
|
|
|
|
|
return [
|
2026-02-12 10:57:53 +08:00
|
|
|
'categories' => $categories,
|
|
|
|
|
'categorySections' => $categorySections,
|
2026-02-12 13:06:12 +08:00
|
|
|
'hotTools' => $portalTools->take($hotToolsLimit),
|
|
|
|
|
'latestTools' => Tool::query()->published()->with('category')->latest('published_at')->limit($latestToolsLimit)->get(),
|
2026-02-12 10:57:53 +08:00
|
|
|
'filters' => $request->only(['q', 'pricing', 'api', 'tab']),
|
2026-02-11 17:28:36 +08:00
|
|
|
'activeTab' => $activeTab,
|
|
|
|
|
'tabOptions' => $this->tabOptions(),
|
2026-02-12 10:57:53 +08:00
|
|
|
'toolStats' => $this->buildToolStats(),
|
2026-02-12 15:37:49 +08:00
|
|
|
'modules' => $moduleMap,
|
2026-02-11 17:28:36 +08:00
|
|
|
];
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return view('public.tools.index', $payload);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-12 10:57:53 +08:00
|
|
|
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(),
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-11 17:28:36 +08:00
|
|
|
public function byCategory(string $slug, Request $request): View
|
|
|
|
|
{
|
|
|
|
|
$request->merge(['category' => $slug]);
|
|
|
|
|
|
2026-02-12 10:57:53 +08:00
|
|
|
return $this->list($request);
|
2026-02-11 17:28:36 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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' => '最新收录'],
|
2026-02-12 10:57:53 +08:00
|
|
|
['key' => 'api', 'label' => 'API优先'],
|
2026-02-11 17:28:36 +08:00
|
|
|
['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');
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-12 10:57:53 +08:00
|
|
|
private function applyFilters(Builder $builder, Request $request, bool $withCategory): void
|
2026-02-11 17:28:36 +08:00
|
|
|
{
|
|
|
|
|
if ($request->filled('q')) {
|
|
|
|
|
$keyword = trim((string) $request->string('q'));
|
|
|
|
|
$builder->whereFullText(['name', 'summary', 'description'], $keyword);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-12 10:57:53 +08:00
|
|
|
if ($withCategory && $request->filled('category')) {
|
2026-02-11 17:28:36 +08:00
|
|
|
$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'));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-12 10:57:53 +08:00
|
|
|
/**
|
|
|
|
|
* @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(),
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-11 17:28:36 +08:00
|
|
|
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 工作流',
|
2026-02-12 15:37:49 +08:00
|
|
|
'api' => '开放API',
|
2026-02-11 17:28:36 +08:00
|
|
|
];
|
|
|
|
|
|
|
|
|
|
$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)));
|
|
|
|
|
}
|
2026-02-12 15:37:49 +08:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @return array<int, array<string, mixed>>
|
|
|
|
|
*/
|
|
|
|
|
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<int, array<string, mixed>>
|
|
|
|
|
*/
|
|
|
|
|
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<int, array<string, mixed>> $loaded
|
|
|
|
|
* @param array<int, array<string, mixed>> $defaults
|
|
|
|
|
* @return array<int, array<string, mixed>>
|
|
|
|
|
*/
|
|
|
|
|
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;
|
|
|
|
|
}
|
2026-02-11 17:28:36 +08:00
|
|
|
}
|