diff --git a/AGENTS.md b/AGENTS.md index 4c9956e..7e13257 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,41 +1,46 @@ # Repository Guidelines ## Project Structure & Module Organization -- `app/` contains core backend code (`Http/Controllers`, `Services`, `Models`, `Support`, `Enums`). -- `routes/web.php` defines HTTP routes; `routes/console.php` holds CLI routes. -- `resources/views` stores Blade templates, while `resources/js` and `resources/css` are Vite frontend entry points. -- `database/migrations`, `database/seeders`, and `database/factories` manage schema and seed data. -- `tests/Feature` covers HTTP/integration behavior; `tests/Unit` covers isolated domain logic. -- `public/` is web root; generated assets are bundled by Vite. +- `app/` contains application logic (controllers, models, services). +- `resources/views/` stores Blade templates for public pages and admin UI. +- `routes/web.php` defines frontend/admin routes. +- `database/migrations/` contains schema and data migration files. +- `tests/Feature/` and `tests/Unit/` hold feature and unit tests. +- `public/` serves static assets; uploaded files are exposed via `public/storage`. ## Build, Test, and Development Commands -- `composer install` — install PHP dependencies. -- `npm install` — install frontend build tooling. -- `composer setup` — one-shot project bootstrap (`.env`, key, migration, frontend build). -- `composer dev` — run local development stack (Laravel server, queue listener, logs, Vite). -- `php artisan serve` — run backend only. -- `npm run dev` / `npm run build` — start Vite dev server or build production assets. -- `composer test` (or `php artisan test`) — clear config and run test suites. +- `composer install` installs PHP dependencies. +- `npm install` installs frontend dependencies. +- `composer run dev` starts local Laravel + Vite development workflow. +- `php artisan migrate` runs database migrations. +- `composer test` clears config and runs test suite. +- `php artisan storage:link` creates the `/storage` public symlink for uploads. ## Coding Style & Naming Conventions -- Follow `.editorconfig`: UTF-8, LF, 4-space indent (YAML: 2 spaces), trim trailing whitespace. -- PHP follows PSR-12 and Laravel conventions; format with `./vendor/bin/pint` before PR. -- Use PSR-4 namespaces under `App\` with clear suffixes (`*Controller`, `*Request`, `*Service`). -- Use `StudlyCase` for classes, `camelCase` for methods/variables, and `snake_case` for DB columns/migration names. +- Follow PSR-12 for PHP; use 4-space indentation. +- Use strict types (`declare(strict_types=1);`) in PHP files. +- Class names: `PascalCase`; methods/variables: `camelCase`; constants: `UPPER_SNAKE_CASE`. +- Blade components should be reusable and grouped by domain (e.g. `resources/views/components/portal/`). +- Keep controller actions focused; extract reusable logic to private methods/services. ## Testing Guidelines -- Framework: PHPUnit 11 via Laravel test runner. -- Place tests in `tests/Feature` or `tests/Unit`; filename pattern: `*Test.php`. -- Add/adjust tests for every behavioral change; prioritize Feature tests for route/controller updates. -- Keep tests deterministic; use factories/seed data and avoid external service dependencies. +- Write unit tests for pure logic in `tests/Unit/`. +- Write feature tests for routes/forms/permissions in `tests/Feature/`. +- Name tests by behavior, e.g. `test_admin_can_update_home_modules`. +- Run `composer test` before opening a PR. ## Commit & Pull Request Guidelines -- Current history uses short summaries (e.g., `init`, `添加 1`); keep commits concise, imperative, and scoped. -- Prefer format like `feat: add admin article filter` or `fix: validate model score bounds`. -- PRs should include: purpose, key changes, test evidence (`composer test` output), and related issue IDs. -- Include screenshots/GIFs for UI changes under `resources/views` or frontend assets. +- Use clear, scoped commit messages, e.g. `feat(admin): add structured home module settings`. +- Keep commits focused; avoid mixing unrelated refactors. +- PRs should include: + - What changed and why + - Screenshots/GIFs for UI updates + - Migration/rollback notes if schema changes + - Test coverage notes or manual verification steps ## Security & Configuration Tips -- Never commit secrets from `.env`; keep `.env.example` updated for new config keys. -- Use least-privilege credentials for local/dev databases. -- Validate and authorize all admin-side inputs using Form Requests and middleware. +- Never commit `.env` or secrets. +- Ensure `APP_URL` is correct in each environment. +- For markdown/image uploads, prefer relative `/storage/...` paths to avoid host lock-in. +- Validate all admin inputs (URL type, route whitelist, required fields) before persistence. + diff --git a/app/Http/Controllers/Admin/SiteSettingController.php b/app/Http/Controllers/Admin/SiteSettingController.php index e890b6f..e7a2f6f 100644 --- a/app/Http/Controllers/Admin/SiteSettingController.php +++ b/app/Http/Controllers/Admin/SiteSettingController.php @@ -5,72 +5,291 @@ declare(strict_types=1); namespace App\Http\Controllers\Admin; use App\Http\Controllers\Controller; -use App\Models\SiteSetting; +use App\Models\HomeModule; +use App\Models\HomeModuleItem; use Illuminate\Contracts\View\View; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Cache; +use Illuminate\Validation\Rule; class SiteSettingController extends Controller { public function index(): View { - $defaults = $this->defaultModules(); - $stored = SiteSetting::query()->where('setting_key', 'home_modules')->value('setting_value'); + $modules = HomeModule::query() + ->with(['items' => fn ($query) => $query->orderBy('sort_order')->orderBy('id')]) + ->orderBy('sort_order') + ->orderBy('id') + ->get(); - $modules = $defaults; - if (is_array($stored)) { - $storedByKey = collect($stored)->keyBy('key'); - $modules = array_map(function (array $module) use ($storedByKey): array { - $saved = $storedByKey->get($module['key']); - if (is_array($saved)) { - $module['enabled'] = (bool) ($saved['enabled'] ?? true); - $module['limit'] = (int) ($saved['limit'] ?? $module['limit']); - } + if ($modules->isEmpty()) { + $this->ensureDefaultModules(); - return $module; - }, $defaults); + $modules = HomeModule::query() + ->with(['items' => fn ($query) => $query->orderBy('sort_order')->orderBy('id')]) + ->orderBy('sort_order') + ->orderBy('id') + ->get(); } return view('admin.settings.index', [ 'modules' => $modules, + 'routeOptions' => $this->routeOptions(), ]); } public function update(Request $request): RedirectResponse { - $payload = []; - foreach ($this->defaultModules() as $module) { - $key = $module['key']; - $payload[] = [ - 'key' => $key, - 'label' => $module['label'], - 'enabled' => $request->boolean("modules.{$key}.enabled", true), - 'limit' => max(1, min(30, (int) $request->input("modules.{$key}.limit", $module['limit']))), - ]; + $moduleData = $request->input('modules', []); + if (!is_array($moduleData) || $moduleData === []) { + return redirect() + ->route('admin.settings.index') + ->with('status', '未提交有效的模块配置。'); } - SiteSetting::query()->updateOrCreate( - ['setting_key' => 'home_modules'], - ['setting_value' => $payload], - ); + foreach ($moduleData as $moduleId => $moduleInput) { + if (!is_numeric($moduleId) || !is_array($moduleInput)) { + continue; + } + + $module = HomeModule::query()->find((int) $moduleId); + if (! $module instanceof HomeModule) { + continue; + } + + $validatedModule = validator($moduleInput, [ + 'name' => ['required', 'string', 'max:120'], + 'title' => ['nullable', 'string', 'max:160'], + 'subtitle' => ['nullable', 'string', 'max:255'], + 'enabled' => ['nullable', 'boolean'], + 'sort_order' => ['nullable', 'integer', 'min:0', 'max:9999'], + 'limit' => ['nullable', 'integer', 'min:1', 'max:30'], + 'more_link_type' => ['nullable', Rule::in(['route', 'url'])], + 'more_link_target' => ['nullable', 'string', 'max:255'], + 'extra.side_title' => ['nullable', 'string', 'max:120'], + 'extra.side_subtitle' => ['nullable', 'string', 'max:255'], + ])->validate(); + + $module->fill([ + 'name' => $validatedModule['name'], + 'title' => $validatedModule['title'] ?? null, + 'subtitle' => $validatedModule['subtitle'] ?? null, + 'enabled' => (bool) ($validatedModule['enabled'] ?? false), + 'sort_order' => (int) ($validatedModule['sort_order'] ?? $module->sort_order), + 'limit' => (int) ($validatedModule['limit'] ?? $module->limit), + 'more_link_type' => $validatedModule['more_link_type'] ?? null, + 'more_link_target' => $validatedModule['more_link_target'] ?? null, + 'extra' => [ + 'side_title' => data_get($validatedModule, 'extra.side_title'), + 'side_subtitle' => data_get($validatedModule, 'extra.side_subtitle'), + ], + ]); + + $this->validateModuleLink($module->module_key, $module->more_link_type, $module->more_link_target); + $module->save(); + + $itemsInput = $moduleInput['items'] ?? []; + if (!is_array($itemsInput)) { + continue; + } + + foreach ($itemsInput as $itemId => $itemInput) { + if (!is_numeric($itemId) || !is_array($itemInput)) { + continue; + } + + $item = HomeModuleItem::query() + ->where('home_module_id', $module->id) + ->find((int) $itemId); + + if (! $item instanceof HomeModuleItem) { + continue; + } + + $validatedItem = validator($itemInput, [ + 'title' => ['nullable', 'string', 'max:160'], + 'subtitle' => ['nullable', 'string', 'max:255'], + 'image_path' => ['nullable', 'string', 'max:255'], + 'link_type' => ['nullable', Rule::in(['route', 'url'])], + 'link_target' => ['nullable', 'string', 'max:255'], + 'sort_order' => ['nullable', 'integer', 'min:0', 'max:9999'], + 'enabled' => ['nullable', 'boolean'], + ])->validate(); + + $linkType = $validatedItem['link_type'] ?? 'route'; + $linkTarget = $validatedItem['link_target'] ?? null; + + $this->validateItemRules($module->module_key, $validatedItem); + $this->validateItemLink($linkType, $linkTarget); + + $item->fill([ + 'title' => $validatedItem['title'] ?? null, + 'subtitle' => $validatedItem['subtitle'] ?? null, + 'image_path' => $validatedItem['image_path'] ?? null, + 'link_type' => $linkType, + 'link_target' => $linkTarget, + 'sort_order' => (int) ($validatedItem['sort_order'] ?? $item->sort_order), + 'enabled' => (bool) ($validatedItem['enabled'] ?? false), + ])->save(); + } + } + + $this->clearHomeCaches(); return redirect() ->route('admin.settings.index') - ->with('status', '首页模块配置已更新'); + ->with('status', '首页模块配置已更新。'); + } + + public function storeItem(Request $request, HomeModule $module): RedirectResponse + { + $validated = $request->validate([ + 'title' => ['nullable', 'string', 'max:160'], + 'subtitle' => ['nullable', 'string', 'max:255'], + 'image_path' => ['nullable', 'string', 'max:255'], + 'link_type' => ['required', Rule::in(['route', 'url'])], + 'link_target' => ['nullable', 'string', 'max:255'], + 'sort_order' => ['nullable', 'integer', 'min:0', 'max:9999'], + 'enabled' => ['nullable', 'boolean'], + ]); + + $this->validateItemRules($module->module_key, $validated); + $this->validateItemLink($validated['link_type'], $validated['link_target'] ?? null); + + $module->items()->create([ + 'title' => $validated['title'] ?? null, + 'subtitle' => $validated['subtitle'] ?? null, + 'image_path' => $validated['image_path'] ?? null, + 'link_type' => $validated['link_type'], + 'link_target' => $validated['link_target'] ?? null, + 'sort_order' => (int) ($validated['sort_order'] ?? (($module->items()->max('sort_order') ?? 0) + 10)), + 'enabled' => (bool) ($validated['enabled'] ?? true), + ]); + + $this->clearHomeCaches(); + + return redirect() + ->route('admin.settings.index') + ->with('status', '模块条目已新增。'); + } + + public function destroyItem(HomeModule $module, HomeModuleItem $item): RedirectResponse + { + if ($item->home_module_id !== $module->id) { + abort(404); + } + + $item->delete(); + $this->clearHomeCaches(); + + return redirect() + ->route('admin.settings.index') + ->with('status', '模块条目已删除。'); } /** - * @return array + * @return array */ - private function defaultModules(): array + private function routeOptions(): array { return [ - ['key' => 'hot_tools', 'label' => '热门工具', 'enabled' => true, 'limit' => 18], - ['key' => 'latest_tools', 'label' => '最新收录', 'enabled' => true, 'limit' => 18], - ['key' => 'category_sections', 'label' => '分类分块', 'enabled' => true, 'limit' => 18], - ['key' => 'channel_cards', 'label' => '频道卡片区', 'enabled' => true, 'limit' => 1], - ['key' => 'promo_banners', 'label' => '横幅推荐区', 'enabled' => true, 'limit' => 1], + ['name' => '工具集首页', 'value' => 'tools.index'], + ['name' => '工具列表页', 'value' => 'tools.list'], + ['name' => '模型推荐', 'value' => 'models.index'], + ['name' => '文章资讯', 'value' => 'news.index'], + ['name' => '教程学习', 'value' => 'guides.index'], ]; } + + private function ensureDefaultModules(): void + { + $defaults = [ + ['key' => 'channel_cards', 'name' => '频道卡片', 'title' => '频道入口', 'sort' => 20, 'limit' => 5], + ['key' => 'promo_banners', 'name' => '横幅推荐', 'title' => '横幅推荐', 'sort' => 30, 'limit' => 2], + ['key' => 'hot_tools', 'name' => '热门工具', 'title' => '热门工具', 'sort' => 40, 'limit' => 18], + ['key' => 'latest_tools', 'name' => '最新收录', 'title' => '最新收录', 'sort' => 50, 'limit' => 18], + ['key' => 'category_sections', 'name' => '分类分块', 'title' => '分类分块', 'sort' => 60, 'limit' => 18], + ]; + + foreach ($defaults as $default) { + HomeModule::query()->firstOrCreate([ + 'module_key' => $default['key'], + ], [ + 'name' => $default['name'], + 'title' => $default['title'], + 'enabled' => true, + 'sort_order' => $default['sort'], + 'limit' => $default['limit'], + ]); + } + } + + private function validateModuleLink(string $moduleKey, ?string $type, ?string $target): void + { + if (!in_array($moduleKey, ['hot_tools', 'latest_tools', 'category_sections'], true)) { + return; + } + + if ($type === null && $target === null) { + return; + } + + if ($type === 'route') { + if (! in_array($target, array_column($this->routeOptions(), 'value'), true)) { + abort(422, '模块“更多链接”路由不在允许范围内。'); + } + + return; + } + + if ($type === 'url' && $target !== null && ! $this->isValidUrlOrPath($target)) { + abort(422, '模块“更多链接”URL格式不正确。'); + } + } + + private function validateItemRules(string $moduleKey, array $item): void + { + if (! in_array($moduleKey, ['channel_cards', 'promo_banners'], true)) { + return; + } + + $title = trim((string) ($item['title'] ?? '')); + $imagePath = trim((string) ($item['image_path'] ?? '')); + $linkTarget = trim((string) ($item['link_target'] ?? '')); + + if ($title === '' || $imagePath === '' || $linkTarget === '') { + abort(422, '频道卡片/横幅推荐条目必须填写标题、图片、链接。'); + } + } + + private function validateItemLink(string $type, ?string $target): void + { + if ($type === 'route') { + if (! in_array((string) $target, array_column($this->routeOptions(), 'value'), true)) { + abort(422, '条目路由不在允许范围内。'); + } + + return; + } + + if ($target === null || ! $this->isValidUrlOrPath($target)) { + abort(422, '条目URL格式不正确。'); + } + } + + private function isValidUrlOrPath(string $value): bool + { + if (str_starts_with($value, '/')) { + return true; + } + + return (bool) filter_var($value, FILTER_VALIDATE_URL); + } + + private function clearHomeCaches(): void + { + Cache::flush(); + } } diff --git a/app/Http/Controllers/Admin/UploadController.php b/app/Http/Controllers/Admin/UploadController.php index 30a5ed1..6beeaed 100644 --- a/app/Http/Controllers/Admin/UploadController.php +++ b/app/Http/Controllers/Admin/UploadController.php @@ -177,3 +177,4 @@ class UploadController extends Controller return $binary; } } + diff --git a/app/Http/Controllers/Site/ToolController.php b/app/Http/Controllers/Site/ToolController.php index 901fd4a..8712e9b 100644 --- a/app/Http/Controllers/Site/ToolController.php +++ b/app/Http/Controllers/Site/ToolController.php @@ -8,7 +8,7 @@ use App\Http\Controllers\Controller; use App\Models\AiModel; use App\Models\Article; use App\Models\Category; -use App\Models\SiteSetting; +use App\Models\HomeModule; use App\Models\Tool; use App\Support\MarkdownRenderer; use Illuminate\Contracts\View\View; @@ -28,7 +28,7 @@ class ToolController extends Controller $querySignature = sha1((string) $request->getQueryString()); $activeTab = $this->resolveTab($request); - $payload = Cache::remember("tools_portal_v2_{$querySignature}", now()->addMinutes(10), function () use ($request, $activeTab): array { + $payload = Cache::remember("tools_portal_v3_home_{$querySignature}", now()->addMinutes(10), function () use ($request, $activeTab): array { $builder = Tool::query() ->published() ->with('category'); @@ -49,8 +49,15 @@ class ToolController extends Controller $portalTools = $builder->limit(360)->get(); $toolsByCategory = $portalTools->groupBy(fn (Tool $tool): string => $tool->category?->slug ?? 'uncategorized'); - $categorySections = $categories->map(function (Category $category) use ($toolsByCategory): array { - $sectionTools = $toolsByCategory->get($category->slug, collect())->take(18); + $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, @@ -60,44 +67,6 @@ class ToolController extends Controller ]; })->values(); - $moduleConfig = SiteSetting::query() - ->where('setting_key', 'home_modules') - ->value('setting_value'); - - $modules = collect([ - 'hot_tools' => ['enabled' => true, 'limit' => 18], - 'latest_tools' => ['enabled' => true, 'limit' => 18], - 'category_sections' => ['enabled' => true, 'limit' => 18], - 'channel_cards' => ['enabled' => true, 'limit' => 1], - 'promo_banners' => ['enabled' => true, 'limit' => 1], - ]); - - if (is_array($moduleConfig)) { - foreach ($moduleConfig as $module) { - if (!is_array($module) || empty($module['key'])) { - continue; - } - - $key = (string) $module['key']; - if ($modules->has($key)) { - $modules[$key] = [ - 'enabled' => (bool) ($module['enabled'] ?? true), - 'limit' => max(1, min(30, (int) ($module['limit'] ?? 18))), - ]; - } - } - } - - $hotToolsLimit = (int) ($modules['hot_tools']['limit'] ?? 18); - $latestToolsLimit = (int) ($modules['latest_tools']['limit'] ?? 18); - $sectionLimit = (int) ($modules['category_sections']['limit'] ?? 18); - - $categorySections = $categorySections->map(function (array $section) use ($sectionLimit): array { - $section['tools'] = $section['tools']->take($sectionLimit); - - return $section; - }); - return [ 'categories' => $categories, 'categorySections' => $categorySections, @@ -107,7 +76,7 @@ class ToolController extends Controller 'activeTab' => $activeTab, 'tabOptions' => $this->tabOptions(), 'toolStats' => $this->buildToolStats(), - 'modules' => $modules, + 'modules' => $moduleMap, ]; }); @@ -299,7 +268,7 @@ class ToolController extends Controller '搜索' => '知识检索', '自动化' => '流程自动化', 'agent' => 'Agent 工作流', - 'api' => '开放 API', + 'api' => '开放API', ]; $tags = []; @@ -331,4 +300,244 @@ class ToolController extends Controller 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; + } } diff --git a/app/Models/HomeModule.php b/app/Models/HomeModule.php new file mode 100644 index 0000000..6a2a46b --- /dev/null +++ b/app/Models/HomeModule.php @@ -0,0 +1,45 @@ + 'boolean', + 'sort_order' => 'integer', + 'limit' => 'integer', + 'extra' => 'array', + ]; + } + + public function items(): HasMany + { + return $this->hasMany(HomeModuleItem::class) + ->orderBy('sort_order') + ->orderBy('id'); + } +} + diff --git a/app/Models/HomeModuleItem.php b/app/Models/HomeModuleItem.php new file mode 100644 index 0000000..24af5da --- /dev/null +++ b/app/Models/HomeModuleItem.php @@ -0,0 +1,42 @@ + 'boolean', + 'sort_order' => 'integer', + 'extra' => 'array', + ]; + } + + public function module(): BelongsTo + { + return $this->belongsTo(HomeModule::class, 'home_module_id'); + } +} + diff --git a/database/migrations/2026_02_12_210000_create_home_modules_table.php b/database/migrations/2026_02_12_210000_create_home_modules_table.php new file mode 100644 index 0000000..266c889 --- /dev/null +++ b/database/migrations/2026_02_12_210000_create_home_modules_table.php @@ -0,0 +1,33 @@ +id(); + $table->string('module_key', 80)->unique(); + $table->string('name', 120); + $table->string('title', 160)->nullable(); + $table->string('subtitle', 255)->nullable(); + $table->boolean('enabled')->default(true); + $table->unsignedInteger('sort_order')->default(0); + $table->unsignedInteger('limit')->default(18); + $table->string('more_link_type', 20)->nullable(); + $table->string('more_link_target', 255)->nullable(); + $table->json('extra')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('home_modules'); + } +}; + diff --git a/database/migrations/2026_02_12_210100_create_home_module_items_table.php b/database/migrations/2026_02_12_210100_create_home_module_items_table.php new file mode 100644 index 0000000..e7e2bcd --- /dev/null +++ b/database/migrations/2026_02_12_210100_create_home_module_items_table.php @@ -0,0 +1,36 @@ +id(); + $table->foreignId('home_module_id')->constrained('home_modules')->cascadeOnDelete(); + $table->string('item_key', 80)->nullable(); + $table->string('title', 160)->nullable(); + $table->string('subtitle', 255)->nullable(); + $table->string('image_path', 255)->nullable(); + $table->string('link_type', 20)->default('route'); + $table->string('link_target', 255)->nullable(); + $table->unsignedInteger('sort_order')->default(0); + $table->boolean('enabled')->default(true); + $table->json('extra')->nullable(); + $table->timestamps(); + + $table->index(['home_module_id', 'enabled']); + $table->index(['home_module_id', 'sort_order']); + }); + } + + public function down(): void + { + Schema::dropIfExists('home_module_items'); + } +}; + diff --git a/database/migrations/2026_02_12_210200_seed_home_modules_from_settings.php b/database/migrations/2026_02_12_210200_seed_home_modules_from_settings.php new file mode 100644 index 0000000..719a415 --- /dev/null +++ b/database/migrations/2026_02_12_210200_seed_home_modules_from_settings.php @@ -0,0 +1,68 @@ + ['name' => '热门工具', 'sort' => 40, 'limit' => 18], + 'latest_tools' => ['name' => '最新收录', 'sort' => 50, 'limit' => 18], + 'category_sections' => ['name' => '分类分块', 'sort' => 60, 'limit' => 18], + 'channel_cards' => ['name' => '频道卡片', 'sort' => 20, 'limit' => 5], + 'promo_banners' => ['name' => '横幅推荐', 'sort' => 30, 'limit' => 2], + ]; + + foreach ($defaults as $key => $meta) { + HomeModule::query()->firstOrCreate([ + 'module_key' => $key, + ], [ + 'name' => $meta['name'], + 'title' => $meta['name'], + 'enabled' => true, + 'sort_order' => $meta['sort'], + 'limit' => $meta['limit'], + ]); + } + + $legacy = SiteSetting::query()->where('setting_key', 'home_modules')->value('setting_value'); + if (!is_array($legacy)) { + return; + } + + foreach ($legacy as $module) { + if (!is_array($module) || empty($module['key'])) { + continue; + } + + $key = (string) $module['key']; + $existing = HomeModule::query()->where('module_key', $key)->first(); + if (! $existing instanceof HomeModule) { + continue; + } + + $existing->fill([ + 'enabled' => (bool) ($module['enabled'] ?? true), + 'limit' => max(1, min(30, (int) ($module['limit'] ?? $existing->limit))), + 'name' => (string) ($module['label'] ?? $existing->name), + 'title' => (string) ($module['label'] ?? $existing->title), + ])->save(); + } + } + + public function down(): void + { + HomeModule::query()->whereIn('module_key', [ + 'hot_tools', + 'latest_tools', + 'category_sections', + 'channel_cards', + 'promo_banners', + ])->delete(); + } +}; + diff --git a/resources/views/admin/settings/index.blade.php b/resources/views/admin/settings/index.blade.php index d5d0415..85133ac 100644 --- a/resources/views/admin/settings/index.blade.php +++ b/resources/views/admin/settings/index.blade.php @@ -2,48 +2,345 @@ @section('title', '首页模块配置') +@section('head') + +@endsection + @section('content') -
+

首页模块配置(AI工具集)

- @if(session('status')) -
{{ session('status') }}
- @endif +

支持配置模块标题、副标题、图片、链接、排序与展示数量。保存后首页立即生效。

-
+ @csrf @method('put') +
@foreach($modules as $module) -
-
-
-
{{ $module['label'] }}
-
key: {{ $module['key'] }}
-
- -
- - +
+
+
+
- - +

{{ $module->name }}

+
key: {{ $module->module_key }}
+
-
- @endforeach -
- +
+
+ + id}.name", $module->name) }}" required> +
+
+ + id}.title", $module->title) }}"> +
+
+ + id}.subtitle", $module->subtitle) }}"> +
+
+ + id}.sort_order", $module->sort_order) }}"> +
+ +
+ + id}.limit", $module->limit) }}"> +
+
+ + @php($moduleLinkType = old("modules.{$module->id}.more_link_type", $module->more_link_type)) + +
+
+ + id}.more_link_target", $module->more_link_target) }}" placeholder="route 名称或 URL / 站内路径"> +
+
+ + id}.extra.side_title", data_get($module->extra, 'side_title')) }}"> +
+
+ + id}.extra.side_subtitle", data_get($module->extra, 'side_subtitle')) }}"> +
+
+ + @if(in_array($module->module_key, ['channel_cards', 'promo_banners'], true)) +
+
+
模块条目
+ 条目要求:标题、图片、链接必填 +
+ +
+ @foreach($module->items as $item) +
+
+ item: {{ $item->id }} + +
+
+
+ + id}.items.{$item->id}.sort_order", $item->sort_order) }}"> +
+
+ +
+
+ + id}.items.{$item->id}.title", $item->title) }}"> +
+
+ + id}.items.{$item->id}.subtitle", $item->subtitle) }}"> +
+ +
+ +
+ id}.items.{$item->id}.image_path", $item->image_path) }}" id="module-item-image-{{ $item->id }}" placeholder="/storage/markdown-images/..."> + +
+
+
+ + @php($itemLinkType = old("modules.{$module->id}.items.{$item->id}.link_type", $item->link_type)) + +
+
+ + id}.items.{$item->id}.link_target", $item->link_target) }}" placeholder="如 tools.list 或 https://..."> +
+ +
+ @if(!empty($item->image_path)) + {{ $item->title ?: '预览图' }} + @else +
暂无图片
+ @endif +
+
+ +
+
+
+ @endforeach +
+ @endif + + @endforeach +
+ +
+
+ +@foreach($modules as $module) + @if(in_array($module->module_key, ['channel_cards', 'promo_banners'], true)) +
+
+

新增条目 - {{ $module->name }}

+
+
+
+ @csrf +
+ + +
+
+ + +
+
+ +
+ + +
+
+
+ + +
+ +
+ + +
+
+ + +
+
+ +
+ +
+
可选内部路由: + @foreach($routeOptions as $option) + {{ $option['value'] }} + @endforeach +
+ +
+
+
+
+ @endif +@endforeach + +
+
+

内部路由参考

+
+
+
+ @foreach($routeOptions as $option) +
+
+ {{ $option['name'] }} + {{ $option['value'] }} +
+
+ @endforeach +
+
+
@endsection +@section('scripts') + + +@endsection diff --git a/resources/views/components/portal/top-nav.blade.php b/resources/views/components/portal/top-nav.blade.php index e6a3332..cf82324 100644 --- a/resources/views/components/portal/top-nav.blade.php +++ b/resources/views/components/portal/top-nav.blade.php @@ -10,9 +10,9 @@ @php $tabs = [ ['key' => 'tools', 'label' => 'AI工具集', 'url' => route('tools.index')], - ['key' => 'models', 'label' => 'AI应用集', 'url' => route('models.index')], - ['key' => 'news', 'label' => '每日AI资讯', 'url' => route('news.index')], - ['key' => 'guides', 'label' => 'AI教程资源', 'url' => route('guides.index')], + ['key' => 'models', 'label' => '模型推荐', 'url' => route('models.index')], + ['key' => 'news', 'label' => '文章资讯', 'url' => route('news.index')], + ['key' => 'guides', 'label' => '教程学习', 'url' => route('guides.index')], ['key' => 'tool-list', 'label' => '工具列表', 'url' => route('tools.list')], ]; @endphp @@ -25,3 +25,4 @@
{{ $statusLabel }} {{ $statusValue }}
+ diff --git a/resources/views/layouts/admin.blade.php b/resources/views/layouts/admin.blade.php index 3c38122..50d9eb3 100644 --- a/resources/views/layouts/admin.blade.php +++ b/resources/views/layouts/admin.blade.php @@ -139,14 +139,22 @@ const uploadEndpoint = '{{ route('admin.uploads.markdown-image') }}'; const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''; - const insertTextAtCursor = (textarea, text) => { - const start = textarea.selectionStart ?? textarea.value.length; - const end = textarea.selectionEnd ?? textarea.value.length; - textarea.value = textarea.value.substring(0, start) + text + textarea.value.substring(end); + const insertTextAtCursor = (field, text) => { + if (!(field instanceof HTMLInputElement) && !(field instanceof HTMLTextAreaElement)) { + return; + } + + const start = field.selectionStart ?? field.value.length; + const end = field.selectionEnd ?? field.value.length; + field.value = field.value.substring(0, start) + text + field.value.substring(end); const nextPos = start + text.length; - textarea.selectionStart = nextPos; - textarea.selectionEnd = nextPos; - textarea.focus(); + + if (typeof field.selectionStart === 'number' && typeof field.selectionEnd === 'number') { + field.selectionStart = nextPos; + field.selectionEnd = nextPos; + } + + field.focus(); }; const setButtonLoading = (button, loading) => { @@ -205,8 +213,9 @@ const attachUploadButton = (button) => { button.addEventListener('click', function () { const selector = button.getAttribute('data-target'); - const textarea = selector ? document.querySelector(selector) : null; - if (!textarea) { + const targetField = selector ? document.querySelector(selector) : null; + + if (!(targetField instanceof HTMLInputElement) && !(targetField instanceof HTMLTextAreaElement)) { return; } @@ -222,7 +231,10 @@ setButtonLoading(button, true); try { const payload = await uploadImage(file); - insertTextAtCursor(textarea, buildMarkdownImageText(payload, file.name || 'image')); + const imageText = targetField instanceof HTMLTextAreaElement + ? buildMarkdownImageText(payload, file.name || 'image') + : payload.url; + insertTextAtCursor(targetField, imageText); } catch (_) { alert('图片上传失败,请稍后重试'); } finally { diff --git a/resources/views/public/tools/index.blade.php b/resources/views/public/tools/index.blade.php index 2800ea3..61e6ed2 100644 --- a/resources/views/public/tools/index.blade.php +++ b/resources/views/public/tools/index.blade.php @@ -2,472 +2,72 @@ @section('page_class', 'page-tools') @section('title', 'AI工具集 - AIWeb') -@section('meta_description', 'AI工具集首页,按分类分块浏览工具,左侧菜单可定位到对应区块。') +@section('meta_description', 'AI工具集首页,按分类分块浏览工具,左侧菜单可定位到对应模块。') @section('canonical', route('tools.index')) @section('head') @endsection @@ -475,27 +75,35 @@ @section('content') @php $icons = ['bi-stars', 'bi-pencil', 'bi-image', 'bi-camera-video', 'bi-briefcase', 'bi-cpu', 'bi-chat-dots', 'bi-code-slash', 'bi-kanban']; + $channelModule = $modules['channel_cards'] ?? null; + $bannerModule = $modules['promo_banners'] ?? null; + $hotModule = $modules['hot_tools'] ?? null; + $latestModule = $modules['latest_tools'] ?? null; + $categoryModule = $modules['category_sections'] ?? null; + $moduleOrder = collect($modules)->sortBy('sort_order')->pluck('module_key')->values(); @endphp
- @endsection @section('scripts') @@ -637,16 +235,6 @@ return; } - const linksById = new Map(); - links.forEach((link) => { - const id = link.getAttribute('href')?.replace('#', ''); - if (id) { - const group = linksById.get(id) || []; - group.push(link); - linksById.set(id, group); - } - }); - const setActive = (activeId) => { links.forEach((link) => { const id = link.getAttribute('href')?.replace('#', '') || ''; diff --git a/routes/web.php b/routes/web.php index b04174c..896720e 100644 --- a/routes/web.php +++ b/routes/web.php @@ -102,6 +102,8 @@ Route::prefix('admin')->name('admin.')->group(function (): void { Route::get('/settings', [AdminSiteSettingController::class, 'index'])->name('settings.index'); Route::put('/settings', [AdminSiteSettingController::class, 'update'])->name('settings.update'); + Route::post('/settings/{module}/items', [AdminSiteSettingController::class, 'storeItem'])->name('settings.items.store'); + Route::delete('/settings/{module}/items/{item}', [AdminSiteSettingController::class, 'destroyItem'])->name('settings.items.destroy'); Route::get('/feedback', [AdminFeedbackController::class, 'index'])->name('feedback.index'); Route::put('/feedback/{feedback}', [AdminFeedbackController::class, 'updateStatus'])->name('feedback.status');