From 04a2034975b7b5cef0c063154ec9912661336d9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=AA=E5=AD=90?= Date: Tue, 22 Sep 2026 08:35:49 +0800 Subject: [PATCH] =?UTF-8?q?=E5=89=8D=E7=AB=AF=E6=9E=81=E7=AE=80=E9=BB=91?= =?UTF-8?q?=E7=99=BD=E9=87=8D=E8=AE=BE=E8=AE=A1,=E5=B9=B6=E7=BA=B3?= =?UTF-8?q?=E5=85=A5=E5=AE=A2=E6=88=B7=E7=AB=AF=E6=B3=A8=E5=86=8C=E3=80=81?= =?UTF-8?q?=E7=AB=99=E7=82=B9=E8=AE=BE=E7=BD=AE=E4=B8=8E=E5=90=8E=E5=8F=B0?= =?UTF-8?q?=E7=9B=B8=E5=85=B3=E6=94=B9=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - storefront 全站改为极简黑白配色:直角、细线分隔、卡片 hover 浅底、深色模式(跟随系统+手动切换) - 抽出 storefront/partials/card 复用卡片 - 一并提交此前暂存的客户端注册 API、站点设置、后台页面与测试 Co-Authored-By: Claude Opus 4.8 --- README.md | 6 +- app/Http/Controllers/Api/ClientController.php | 197 ++++++++ app/Http/Controllers/Api/RepoController.php | 110 ++++- .../Controllers/Web/StorefrontController.php | 21 +- .../Controllers/WebAdmin/AuthController.php | 53 +++ .../WebAdmin/DashboardController.php | 109 ++++- .../WebAdmin/SettingsController.php | 60 +++ .../Requests/Admin/StoreCategoryRequest.php | 2 +- app/Models/StoreClient.php | 59 +++ app/Models/StoreSetting.php | 13 + app/Providers/AppServiceProvider.php | 12 +- app/Services/AdminPackageService.php | 96 +++- app/Services/StoreSettings.php | 85 ++++ app/Services/VersionPublishService.php | 7 +- config/store.php | 21 + ...3_20_150001_create_store_clients_table.php | 32 ++ ..._20_180001_create_store_settings_table.php | 23 + resources/views/admin/account.blade.php | 90 ++++ .../views/admin/categories/index.blade.php | 207 +++++---- resources/views/admin/clients/index.blade.php | 95 ++++ resources/views/admin/dashboard.blade.php | 139 ++++-- resources/views/admin/layout.blade.php | 359 ++++++++++++--- resources/views/admin/login.blade.php | 23 +- .../views/admin/packages/index.blade.php | 190 ++++---- resources/views/admin/packages/show.blade.php | 434 ++++++++++++++---- resources/views/admin/settings.blade.php | 133 ++++++ resources/views/storefront/home.blade.php | 139 ++---- resources/views/storefront/layout.blade.php | 212 +++++++-- resources/views/storefront/packages.blade.php | 125 +++-- .../views/storefront/partials/card.blade.php | 26 ++ resources/views/storefront/show.blade.php | 217 +++++---- routes/api.php | 7 + routes/web.php | 22 +- tests/Feature/ClientRegistrationApiTest.php | 184 ++++++++ tests/Feature/VersionPublishServiceTest.php | 46 ++ tests/Feature/WebAdminAccountPageTest.php | 94 ++++ tests/Feature/WebAdminClientsPageTest.php | 71 +++ .../Feature/WebAdminPackageCategoriesTest.php | 111 +++++ tests/Feature/WebAdminPackageDeletionTest.php | 94 ++++ .../WebAdminPackageScreenshotsTest.php | 125 +++++ tests/Feature/WebAdminSettingsPageTest.php | 56 +++ 41 files changed, 3447 insertions(+), 658 deletions(-) create mode 100644 app/Http/Controllers/Api/ClientController.php create mode 100644 app/Http/Controllers/WebAdmin/SettingsController.php create mode 100644 app/Models/StoreClient.php create mode 100644 app/Models/StoreSetting.php create mode 100644 app/Services/StoreSettings.php create mode 100644 database/migrations/2026_03_20_150001_create_store_clients_table.php create mode 100644 database/migrations/2026_03_20_180001_create_store_settings_table.php create mode 100644 resources/views/admin/account.blade.php create mode 100644 resources/views/admin/clients/index.blade.php create mode 100644 resources/views/admin/settings.blade.php create mode 100644 resources/views/storefront/partials/card.blade.php create mode 100644 tests/Feature/ClientRegistrationApiTest.php create mode 100644 tests/Feature/WebAdminAccountPageTest.php create mode 100644 tests/Feature/WebAdminClientsPageTest.php create mode 100644 tests/Feature/WebAdminPackageCategoriesTest.php create mode 100644 tests/Feature/WebAdminPackageDeletionTest.php create mode 100644 tests/Feature/WebAdminPackageScreenshotsTest.php create mode 100644 tests/Feature/WebAdminSettingsPageTest.php diff --git a/README.md b/README.md index 5474f42..58ffe5c 100644 --- a/README.md +++ b/README.md @@ -127,7 +127,7 @@ docker compose up -d --build - `.env` 中的 `APP_URL` - `.env` 中的 `SESSION_DRIVER`、`CACHE_STORE`、`QUEUE_CONNECTION` - `.env` 中的 `STORE_ADMIN_TOKEN` -- `.env` 中的 `STORE_PLUGIN_ACCESS_TOKEN` +- `.env` 中的 `STORE_PLUGIN_ACCESS_TOKEN`(仅私有分发时需要) - `.env` 中的 `ADMIN_EMAIL` - `.env` 中的 `ADMIN_PASSWORD` @@ -195,7 +195,7 @@ CACHE_STORE=file QUEUE_CONNECTION=sync STORE_ADMIN_TOKEN=replace-with-a-long-random-token -STORE_PLUGIN_ACCESS_TOKEN=replace-with-a-long-random-token +STORE_PLUGIN_ACCESS_TOKEN= ADMIN_NAME=Tstore Admin ADMIN_EMAIL=admin@example.com @@ -205,7 +205,7 @@ ADMIN_PASSWORD=change-this-password 说明: - `STORE_ADMIN_TOKEN` 用于 `/api/admin/*` 管理接口鉴权 -- `STORE_PLUGIN_ACCESS_TOKEN` 用于下载接口鉴权 +- `STORE_PLUGIN_ACCESS_TOKEN` 是可选项,仅在私有分发或需要限制下载时使用;公开商店建议留空 - 网页后台 `/admin/login` 使用 `users` 表中的账号密码登录 - 如果生产环境使用 Redis,可以把 `SESSION_DRIVER`、`CACHE_STORE`、`QUEUE_CONNECTION` 改为 Redis 相关配置 diff --git a/app/Http/Controllers/Api/ClientController.php b/app/Http/Controllers/Api/ClientController.php new file mode 100644 index 0000000..807ca8f --- /dev/null +++ b/app/Http/Controllers/Api/ClientController.php @@ -0,0 +1,197 @@ +validate([ + 'site_url' => ['required', 'url', 'max:512'], + 'site_name' => ['nullable', 'string', 'max:255'], + 'typecho_version' => ['nullable', 'string', 'max:32'], + 'php_version' => ['nullable', 'string', 'max:32'], + 'plugin_version' => ['nullable', 'string', 'max:32'], + 'user_count' => ['nullable', 'integer', 'min:0'], + ]); + + $siteUrl = $this->normalizeSiteUrl((string) $validated['site_url']); + $now = now(); + + $client = StoreClient::query()->firstOrNew([ + 'site_url' => $siteUrl, + ]); + + if (!$client->exists) { + $client->registered_at = $now; + } + + $client->site_name = $this->truncate((string) ($validated['site_name'] ?? ''), 255); + $client->access_token = $this->issueAccessToken(); + $client->status = 'online'; + $client->user_count = max((int) ($validated['user_count'] ?? 0), 0); + $client->typecho_version = $this->truncate((string) ($validated['typecho_version'] ?? ''), 32); + $client->php_version = $this->truncate((string) ($validated['php_version'] ?? ''), 32); + $client->plugin_version = $this->truncate((string) ($validated['plugin_version'] ?? ''), 32); + $client->last_ip = $this->truncate((string) ($request->ip() ?? ''), 45); + $client->last_user_agent = $this->truncate((string) ($request->userAgent() ?? ''), 512); + $client->last_seen_at = $now; + $client->save(); + + return response()->json([ + 'code' => 0, + 'message' => 'ok', + 'data' => $this->clientPayload($client, true), + ]); + } + + public function heartbeat(Request $request): JsonResponse + { + $client = $this->resolveClient($request); + if (!$client) { + return $this->unauthorizedResponse(); + } + + $validated = $request->validate([ + 'site_name' => ['nullable', 'string', 'max:255'], + 'typecho_version' => ['nullable', 'string', 'max:32'], + 'php_version' => ['nullable', 'string', 'max:32'], + 'plugin_version' => ['nullable', 'string', 'max:32'], + 'user_count' => ['nullable', 'integer', 'min:0'], + ]); + + if (array_key_exists('site_name', $validated)) { + $client->site_name = $this->truncate((string) $validated['site_name'], 255); + } + if (array_key_exists('typecho_version', $validated)) { + $client->typecho_version = $this->truncate((string) $validated['typecho_version'], 32); + } + if (array_key_exists('php_version', $validated)) { + $client->php_version = $this->truncate((string) $validated['php_version'], 32); + } + if (array_key_exists('plugin_version', $validated)) { + $client->plugin_version = $this->truncate((string) $validated['plugin_version'], 32); + } + if (array_key_exists('user_count', $validated)) { + $client->user_count = max((int) $validated['user_count'], 0); + } + + $client->status = 'online'; + $client->last_ip = $this->truncate((string) ($request->ip() ?? ''), 45); + $client->last_user_agent = $this->truncate((string) ($request->userAgent() ?? ''), 512); + $client->last_seen_at = now(); + $client->save(); + + return response()->json([ + 'code' => 0, + 'message' => 'ok', + 'data' => $this->clientPayload($client, false), + ]); + } + + public function status(Request $request): JsonResponse + { + $client = $this->resolveClient($request); + if (!$client) { + return $this->unauthorizedResponse(); + } + + $client->status = 'online'; + $client->last_ip = $this->truncate((string) ($request->ip() ?? ''), 45); + $client->last_user_agent = $this->truncate((string) ($request->userAgent() ?? ''), 512); + $client->last_seen_at = now(); + $client->save(); + + return response()->json([ + 'code' => 0, + 'message' => 'ok', + 'data' => $this->clientPayload($client, false), + ]); + } + + private function resolveClient(Request $request): ?StoreClient + { + $token = $this->extractToken($request); + if ($token === '') { + return null; + } + + return StoreClient::query()->where('access_token', $token)->first(); + } + + private function extractToken(Request $request): string + { + return trim((string) ( + $request->bearerToken() + ?: $request->header('X-Store-Plugin-Token') + ?: $request->query('access_token', '') + )); + } + + private function unauthorizedResponse(): JsonResponse + { + return response()->json([ + 'code' => 401, + 'message' => 'client unauthorized', + 'data' => null, + ], 401); + } + + private function issueAccessToken(): string + { + do { + $token = Str::random(96); + } while (StoreClient::query()->where('access_token', $token)->exists()); + + return $token; + } + + private function normalizeSiteUrl(string $url): string + { + $url = trim($url); + if ($url === '') { + return ''; + } + + return rtrim($url, '/'); + } + + private function truncate(string $value, int $length): string + { + return mb_substr(trim($value), 0, $length); + } + + private function clientPayload(StoreClient $client, bool $includeToken): array + { + $payload = [ + 'client_id' => $client->id, + 'site_url' => $client->site_url, + 'site_name' => $client->site_name, + 'status' => $client->status, + 'user_count' => (int) $client->user_count, + 'typecho_version' => $client->typecho_version, + 'php_version' => $client->php_version, + 'plugin_version' => $client->plugin_version, + 'registered_at' => optional($client->registered_at)->toAtomString(), + 'last_seen_at' => optional($client->last_seen_at)->toAtomString(), + 'server_time' => now()->toAtomString(), + 'stats' => [ + 'registered_sites' => StoreClient::query()->count(), + 'online_sites' => StoreClient::query()->online()->count(), + 'tracked_users' => (int) StoreClient::query()->sum('user_count'), + ], + ]; + + if ($includeToken) { + $payload['access_token'] = $client->access_token; + } + + return $payload; + } +} diff --git a/app/Http/Controllers/Api/RepoController.php b/app/Http/Controllers/Api/RepoController.php index d2b3652..c3a0681 100644 --- a/app/Http/Controllers/Api/RepoController.php +++ b/app/Http/Controllers/Api/RepoController.php @@ -5,6 +5,7 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; use App\Models\DownloadLog; use App\Models\Package; +use App\Models\StoreClient; use App\Services\RepoFormatter; use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; @@ -166,7 +167,7 @@ class RepoController extends Controller 'package' => [ 'size' => (int) $latest->package_size, 'sha256' => $latest->sha256, - 'download_url' => $latest->package_url, + 'download_url' => $this->buildDownloadUrl($request, $type, $slug, $latest->version), ], ]; } @@ -187,12 +188,14 @@ class RepoController extends Controller public function download(Request $request, string $type, string $slug, string $version): JsonResponse|BinaryFileResponse|RedirectResponse { $pluginToken = (string) config('store.plugin_access_token', ''); - $requestToken = (string) ($request->header('X-Store-Plugin-Token') ?: $request->query('access_token', '')); + $requestToken = $this->extractRequestToken($request); + $client = $this->resolveClientByToken($requestToken); + $legacyTokenMatched = $pluginToken !== '' && $requestToken !== '' && hash_equals($pluginToken, $requestToken); - if ($pluginToken !== '' && $requestToken !== $pluginToken) { + if (!$legacyTokenMatched && !$client) { return response()->json([ 'code' => 403, - 'message' => 'download forbidden', + 'message' => 'client token required', 'data' => null, ], 403); } @@ -228,12 +231,20 @@ class RepoController extends Controller $redirect = (string) $request->query('redirect', '0') === '1'; if ($redirect) { + if ($client) { + $client->status = 'online'; + $client->last_ip = (string) ($request->ip() ?? ''); + $client->last_user_agent = (string) ($request->userAgent() ?? ''); + $client->last_seen_at = now(); + $client->save(); + } + DownloadLog::create([ 'package_id' => $package->id, 'version_id' => $versionModel->id, - 'site_url' => (string) $request->query('site_url', ''), - 'typecho_version' => (string) $request->query('typecho_version', ''), - 'php_version' => (string) $request->query('php_version', ''), + 'site_url' => $client?->site_url ?: (string) $request->query('site_url', ''), + 'typecho_version' => $client?->typecho_version ?: (string) $request->query('typecho_version', ''), + 'php_version' => $client?->php_version ?: (string) $request->query('php_version', ''), 'ip' => (string) $request->ip(), 'user_agent' => (string) $request->userAgent(), 'created_at' => now(), @@ -306,12 +317,89 @@ class RepoController extends Controller private function buildDownloadUrl(Request $request, string $type, string $slug, string $version): string { $path = '/api/v1/repo/download/' . $type . '/' . $slug . '/' . $version . '?redirect=1'; - $appUrl = rtrim((string) config('app.url', ''), '/'); + $baseUrl = $this->resolveDownloadBaseUrl($request); - if ($appUrl !== '') { - return $appUrl . $path; + if ($baseUrl !== '') { + return $baseUrl . $path; } - return $request->getSchemeAndHttpHost() . $path; + return $path; + } + + private function resolveDownloadBaseUrl(Request $request): string + { + $appUrl = $this->normalizeBaseUrl((string) config('app.url', '')); + if ($this->isPublicHttpsBaseUrl($appUrl)) { + return $appUrl; + } + + $requestRoot = $this->normalizeBaseUrl($request->root()); + if ($requestRoot !== '') { + return $requestRoot; + } + + return $appUrl; + } + + private function normalizeBaseUrl(string $url): string + { + return rtrim(trim($url), '/'); + } + + private function isPublicHttpsBaseUrl(string $url): bool + { + if ($url === '') { + return false; + } + + $parts = parse_url($url); + if (!is_array($parts)) { + return false; + } + + $scheme = strtolower((string) ($parts['scheme'] ?? '')); + $host = strtolower((string) ($parts['host'] ?? '')); + + if ($scheme !== 'https' || $host === '') { + return false; + } + + if (filter_var($host, FILTER_VALIDATE_IP)) { + return filter_var( + $host, + FILTER_VALIDATE_IP, + FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE + ) !== false; + } + + if (in_array($host, ['localhost', 'host.docker.internal'], true)) { + return false; + } + + foreach (['.localhost', '.local', '.internal', '.test'] as $suffix) { + if (str_ends_with($host, $suffix)) { + return false; + } + } + + return true; + } + + private function extractRequestToken(Request $request): string + { + return trim((string) ( + $request->bearerToken() + ?: $request->header('X-Store-Plugin-Token') + ?: $request->query('access_token', '') + )); + } + + private function resolveClientByToken(string $token): ?StoreClient + { + if ($token === '') { + return null; + } + + return StoreClient::query()->where('access_token', $token)->first(); } } diff --git a/app/Http/Controllers/Web/StorefrontController.php b/app/Http/Controllers/Web/StorefrontController.php index 0204e39..83b21b2 100644 --- a/app/Http/Controllers/Web/StorefrontController.php +++ b/app/Http/Controllers/Web/StorefrontController.php @@ -86,6 +86,16 @@ class StorefrontController extends Controller ]); } + public function showPlugin(string $slug): View + { + return $this->show('plugin', $slug); + } + + public function showTheme(string $slug): View + { + return $this->show('theme', $slug); + } + public function show(string $type, string $slug): View { abort_unless(in_array($type, ['plugin', 'theme'], true), 404); @@ -103,20 +113,9 @@ class StorefrontController extends Controller $detail = RepoFormatter::packageDetail($package, false); - $related = Package::query() - ->with(['categories', 'latestStableVersion']) - ->where('status', 'published') - ->where('type', $type) - ->where('id', '!=', $package->id) - ->orderByDesc('is_featured') - ->orderByDesc('updated_at') - ->limit(4) - ->get(); - return view('storefront.show', [ 'package' => $package, 'detail' => $detail, - 'related' => $related, ]); } } diff --git a/app/Http/Controllers/WebAdmin/AuthController.php b/app/Http/Controllers/WebAdmin/AuthController.php index ad949b1..6346f79 100644 --- a/app/Http/Controllers/WebAdmin/AuthController.php +++ b/app/Http/Controllers/WebAdmin/AuthController.php @@ -50,4 +50,57 @@ class AuthController extends Controller return redirect()->route('webadmin.login')->with('success', '已退出后台'); } + + public function showAccount(Request $request): View + { + return view('admin.account', [ + 'user' => $request->user(), + ]); + } + + public function updatePassword(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'current_password' => ['required', 'current_password'], + 'new_password' => ['required', 'string', 'min:8', 'confirmed', 'different:current_password'], + ], [ + 'current_password.required' => '请输入当前密码', + 'current_password.current_password' => '当前密码不正确', + 'new_password.required' => '请输入新密码', + 'new_password.min' => '新密码至少需要 8 位', + 'new_password.confirmed' => '两次输入的新密码不一致', + 'new_password.different' => '新密码不能与当前密码相同', + ]); + + $user = $request->user(); + $user->forceFill([ + 'password' => $validated['new_password'], + ])->save(); + + $request->session()->regenerate(); + + return redirect()->route('webadmin.account')->with('success', '密码已更新'); + } + + public function updateProfile(Request $request): RedirectResponse + { + $user = $request->user(); + + $validated = $request->validate([ + 'name' => ['required', 'string', 'max:120'], + 'email' => ['required', 'email', 'max:255', 'unique:users,email,' . $user->id], + ], [ + 'name.required' => '请输入用户名', + 'email.required' => '请输入邮箱地址', + 'email.email' => '邮箱格式不正确', + 'email.unique' => '该邮箱已被其它用户使用', + ]); + + $user->forceFill([ + 'name' => trim($validated['name']), + 'email' => trim($validated['email']), + ])->save(); + + return redirect()->route('webadmin.account')->with('success', '账户资料已更新'); + } } diff --git a/app/Http/Controllers/WebAdmin/DashboardController.php b/app/Http/Controllers/WebAdmin/DashboardController.php index 566c812..f2cb962 100644 --- a/app/Http/Controllers/WebAdmin/DashboardController.php +++ b/app/Http/Controllers/WebAdmin/DashboardController.php @@ -8,11 +8,13 @@ use App\Http\Requests\Admin\StoreCategoryRequest; use App\Http\Requests\Admin\StorePackageRequest; use App\Models\Category; use App\Models\Package; +use App\Models\StoreClient; use App\Models\Version; use App\Services\AdminPackageService; use App\Services\VersionPublishService; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; +use Illuminate\Support\Str; use Illuminate\View\View; class DashboardController extends Controller @@ -32,7 +34,7 @@ class DashboardController extends Controller 'versions' => Version::count(), 'downloads' => (int) Package::sum('download_count'), 'categories' => Category::count(), - ]; + ] + $this->buildClientStats(); $recentPackages = Package::query() ->with(['categories', 'latestStableVersion']) @@ -46,7 +48,57 @@ class DashboardController extends Controller ->limit(8) ->get(); - return view('admin.dashboard', compact('stats', 'recentPackages', 'recentVersions')); + $recentClients = StoreClient::query() + ->orderByDesc('last_seen_at') + ->orderByDesc('registered_at') + ->limit(6) + ->get(); + + return view('admin.dashboard', compact('stats', 'recentPackages', 'recentVersions', 'recentClients')); + } + + public function clients(Request $request): View + { + $query = StoreClient::query(); + $status = (string) $request->query('status', ''); + $keyword = trim((string) $request->query('keyword', '')); + $onlineThreshold = now()->subMinutes((int) config('store.client_online_window_minutes', 15)); + + if ($status === 'online') { + $query->whereNotNull('last_seen_at') + ->where('last_seen_at', '>=', $onlineThreshold); + } elseif ($status === 'offline') { + $query->where(function ($q) use ($onlineThreshold) { + $q->whereNull('last_seen_at') + ->orWhere('last_seen_at', '<', $onlineThreshold); + }); + } + + if ($keyword !== '') { + $query->where(function ($q) use ($keyword) { + $q->where('site_url', 'like', '%' . $keyword . '%') + ->orWhere('site_name', 'like', '%' . $keyword . '%') + ->orWhere('typecho_version', 'like', '%' . $keyword . '%') + ->orWhere('php_version', 'like', '%' . $keyword . '%') + ->orWhere('plugin_version', 'like', '%' . $keyword . '%'); + }); + } + + $clients = $query + ->orderByDesc('last_seen_at') + ->orderByDesc('registered_at') + ->paginate(20) + ->withQueryString(); + + return view('admin.clients.index', [ + 'clients' => $clients, + 'stats' => $this->buildClientStats(), + 'onlineWindowMinutes' => (int) config('store.client_online_window_minutes', 15), + 'filters' => [ + 'status' => $status, + 'keyword' => $keyword, + ], + ]); } public function packages(Request $request): View @@ -112,6 +164,15 @@ class DashboardController extends Controller return redirect()->route('webadmin.packages')->with('success', '状态已更新'); } + public function destroyPackage(string $type, string $slug): RedirectResponse + { + $package = $this->findPackage($type, $slug); + $label = $package->name; + $this->service->deletePackage($package); + + return redirect()->route('webadmin.packages', ['type' => $type])->with('success', '扩展已删除:' . $label); + } + public function categories(Request $request): View { $query = Category::query()->withCount('packages'); @@ -176,6 +237,34 @@ class DashboardController extends Controller ]); } + public function storeScreenshot(Request $request, string $type, string $slug): RedirectResponse + { + $package = $this->findPackage($type, $slug); + + $validated = $request->validate([ + 'image_url' => ['required', 'url', 'max:1024'], + 'caption' => ['nullable', 'string', 'max:255'], + 'sort_order' => ['nullable', 'integer'], + ]); + + $package->screenshots()->create([ + 'image_url' => $validated['image_url'], + 'caption' => trim((string) ($validated['caption'] ?? '')), + 'sort_order' => (int) ($validated['sort_order'] ?? 0), + ]); + + return redirect()->route('webadmin.packages.show', [$type, $slug])->with('success', '截图已添加'); + } + + public function destroyScreenshot(string $type, string $slug, int $id): RedirectResponse + { + $package = $this->findPackage($type, $slug); + $screenshot = $package->screenshots()->whereKey($id)->firstOrFail(); + $screenshot->delete(); + + return redirect()->route('webadmin.packages.show', [$type, $slug])->with('success', '截图已删除'); + } + public function storeVersion(Request $request, string $type, string $slug): RedirectResponse { $package = $this->findPackage($type, $slug); @@ -261,10 +350,24 @@ class DashboardController extends Controller { $raw = (string) $request->input('categories_text', ''); - return collect(preg_split('/[,,\s]+/u', $raw)) + $segments = preg_match('/[,,、;\r\n]+/u', $raw) + ? preg_split('/[,,、;\r\n]+/u', $raw) + : preg_split('/\s+/u', $raw); + + return collect($segments) ->map(fn ($item) => trim((string) $item)) ->filter() + ->unique(fn (string $item) => mb_strtolower($item, 'UTF-8')) ->values() ->all(); } + + private function buildClientStats(): array + { + return [ + 'registered_sites' => StoreClient::query()->count(), + 'online_sites' => StoreClient::query()->online()->count(), + 'tracked_users' => (int) StoreClient::query()->sum('user_count'), + ]; + } } diff --git a/app/Http/Controllers/WebAdmin/SettingsController.php b/app/Http/Controllers/WebAdmin/SettingsController.php new file mode 100644 index 0000000..db4bb2d --- /dev/null +++ b/app/Http/Controllers/WebAdmin/SettingsController.php @@ -0,0 +1,60 @@ + $this->settings->all(), + ]); + } + + public function update(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'site_name' => ['required', 'string', 'max:120'], + 'site_tagline' => ['required', 'string', 'max:160'], + 'home_title_suffix' => ['required', 'string', 'max:120'], + 'home_eyebrow' => ['required', 'string', 'max:80'], + 'home_headline' => ['required', 'string', 'max:160'], + 'home_lede' => ['required', 'string', 'max:1000'], + 'home_aside_kicker' => ['required', 'string', 'max:80'], + 'home_aside_title' => ['required', 'string', 'max:160'], + 'home_feature_one_title' => ['required', 'string', 'max:120'], + 'home_feature_one_body' => ['required', 'string', 'max:255'], + 'home_feature_two_title' => ['required', 'string', 'max:120'], + 'home_feature_two_body' => ['required', 'string', 'max:255'], + 'home_feature_three_title' => ['required', 'string', 'max:120'], + 'home_feature_three_body' => ['required', 'string', 'max:255'], + 'home_plugins_title' => ['required', 'string', 'max:120'], + 'home_plugins_subtitle' => ['required', 'string', 'max:255'], + 'home_themes_title' => ['required', 'string', 'max:120'], + 'home_themes_subtitle' => ['required', 'string', 'max:255'], + ]); + + $payload = collect($validated) + ->map(fn ($value) => trim((string) $value)) + ->all(); + + try { + $this->settings->setMany($payload); + } catch (Throwable $e) { + return back()->withInput()->with('error', '站点设置保存失败:' . $e->getMessage()); + } + + return redirect()->route('webadmin.settings')->with('success', '站点设置已更新'); + } +} diff --git a/app/Http/Requests/Admin/StoreCategoryRequest.php b/app/Http/Requests/Admin/StoreCategoryRequest.php index 3c85cba..7430784 100644 --- a/app/Http/Requests/Admin/StoreCategoryRequest.php +++ b/app/Http/Requests/Admin/StoreCategoryRequest.php @@ -17,7 +17,7 @@ class StoreCategoryRequest extends FormRequest return [ 'type' => ['required', Rule::in(['plugin', 'theme'])], 'name' => ['required', 'string', 'max:64'], - 'slug' => ['required', 'string', 'max:64', 'regex:/^[a-z0-9-]+$/'], + 'slug' => ['required', 'string', 'max:64', 'regex:/^[\pL\pN][\pL\pN_-]*$/u'], 'description' => ['nullable', 'string', 'max:255'], 'sort_order' => ['nullable', 'integer'], ]; diff --git a/app/Models/StoreClient.php b/app/Models/StoreClient.php new file mode 100644 index 0000000..2ac7933 --- /dev/null +++ b/app/Models/StoreClient.php @@ -0,0 +1,59 @@ + 'integer', + 'registered_at' => 'datetime', + 'last_seen_at' => 'datetime', + ]; + + public function scopeOnline(Builder $query): Builder + { + return $query->whereNotNull('last_seen_at') + ->where('last_seen_at', '>=', now()->subMinutes((int) config('store.client_online_window_minutes', 15))); + } + + public function getIsOnlineAttribute(): bool + { + if (!$this->last_seen_at) { + return false; + } + + return $this->last_seen_at->gte( + now()->subMinutes((int) config('store.client_online_window_minutes', 15)) + ); + } + + public function getTokenSuffixAttribute(): string + { + $token = trim((string) $this->access_token); + if ($token === '') { + return ''; + } + + return substr($token, -8); + } +} diff --git a/app/Models/StoreSetting.php b/app/Models/StoreSetting.php new file mode 100644 index 0000000..e3d3753 --- /dev/null +++ b/app/Models/StoreSetting.php @@ -0,0 +1,13 @@ +all(); + + if (!empty($settings['site_name'])) { + config(['app.name' => $settings['site_name']]); + } + + $view->with('storeSettings', $settings); + }); } } diff --git a/app/Services/AdminPackageService.php b/app/Services/AdminPackageService.php index 46eed22..2227e05 100644 --- a/app/Services/AdminPackageService.php +++ b/app/Services/AdminPackageService.php @@ -7,6 +7,8 @@ use App\Models\Package; use App\Models\Version; use Illuminate\Support\Arr; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\File; +use Illuminate\Support\Str; class AdminPackageService { @@ -114,6 +116,19 @@ class AdminPackageService }); } + public function deletePackage(Package $package): void + { + $storageDir = storage_path('app/packages/' . $package->type . '/' . $package->slug); + + DB::transaction(function () use ($package) { + $package->delete(); + }); + + if (is_dir($storageDir)) { + File::deleteDirectory($storageDir); + } + } + public function refreshLatestVersion(Package $package): void { $latestStable = $package->versions() @@ -141,19 +156,88 @@ class AdminPackageService private function syncCategories(Package $package, array $categorySlugs): void { - $categorySlugs = array_values(array_unique(array_filter(array_map('strval', $categorySlugs)))); + $categorySlugs = collect($categorySlugs) + ->map(fn ($item) => trim((string) $item)) + ->filter() + ->unique(fn (string $item) => mb_strtolower($item, 'UTF-8')) + ->values() + ->all(); if (empty($categorySlugs)) { $package->categories()->sync([]); return; } - $categoryIds = Category::query() + $categories = Category::query() ->where('type', $package->type) - ->whereIn('slug', $categorySlugs) - ->pluck('id') - ->all(); + ->get(['id', 'type', 'slug', 'name']); - $package->categories()->sync($categoryIds); + $categoryIds = []; + + foreach ($categorySlugs as $token) { + $normalizedToken = mb_strtolower($token, 'UTF-8'); + + $existing = $categories->first(function (Category $category) use ($token, $normalizedToken) { + return $category->name === $token + || mb_strtolower($category->slug, 'UTF-8') === $normalizedToken + || mb_strtolower($category->name, 'UTF-8') === $normalizedToken; + }); + + if ($existing) { + $categoryIds[] = $existing->id; + continue; + } + + $slug = $this->generateCategorySlug($token, $categories); + $category = Category::query()->create([ + 'type' => $package->type, + 'slug' => $slug, + 'name' => $this->generateCategoryName($token), + 'description' => '', + 'sort_order' => 0, + ]); + + $categories->push($category); + $categoryIds[] = $category->id; + } + + $package->categories()->sync(array_values(array_unique($categoryIds))); + } + + private function generateCategorySlug(string $token, $categories): string + { + $base = (string) Str::of($token) + ->trim() + ->replace('_', '-') + ->replaceMatches('/\s+/u', '-') + ->replaceMatches('/[^\pL\pN-]+/u', '-') + ->replaceMatches('/-+/u', '-') + ->lower() + ->trim('-'); + + if ($base === '') { + $base = 'category'; + } + + $slug = $base; + $suffix = 2; + + while ($categories->contains(fn (Category $category) => mb_strtolower($category->slug, 'UTF-8') === mb_strtolower($slug, 'UTF-8'))) { + $slug = $base . '-' . $suffix; + $suffix++; + } + + return $slug; + } + + private function generateCategoryName(string $token): string + { + if (preg_match('/^[a-z0-9_-]+$/i', $token) === 1) { + return (string) Str::of($token) + ->replace(['-', '_'], ' ') + ->title(); + } + + return trim($token); } } diff --git a/app/Services/StoreSettings.php b/app/Services/StoreSettings.php new file mode 100644 index 0000000..be0a257 --- /dev/null +++ b/app/Services/StoreSettings.php @@ -0,0 +1,85 @@ +resolved !== null) { + return $this->resolved; + } + + $defaults = $this->defaults(); + + if (!$this->tableExists()) { + return $this->resolved = $defaults; + } + + $stored = StoreSetting::query() + ->pluck('value', 'key') + ->map(fn ($value) => (string) $value) + ->all(); + + return $this->resolved = array_replace($defaults, $stored); + } + + public function get(string $key, mixed $default = null): mixed + { + $settings = $this->all(); + + return $settings[$key] ?? $default; + } + + public function defaults(): array + { + return (array) config('store.settings_defaults', []); + } + + public function keys(): array + { + return array_keys($this->defaults()); + } + + public function setMany(array $values): void + { + if (!$this->tableExists()) { + throw new RuntimeException('store_settings table is missing. Please run php artisan migrate.'); + } + + $now = now(); + $rows = collect($values) + ->only($this->keys()) + ->map(fn ($value, $key) => [ + 'key' => $key, + 'value' => (string) $value, + 'created_at' => $now, + 'updated_at' => $now, + ]) + ->values() + ->all(); + + if ($rows === []) { + return; + } + + StoreSetting::query()->upsert($rows, ['key'], ['value', 'updated_at']); + $this->resolved = null; + } + + private function tableExists(): bool + { + try { + return Schema::hasTable('store_settings'); + } catch (Throwable) { + return false; + } + } +} diff --git a/app/Services/VersionPublishService.php b/app/Services/VersionPublishService.php index deb021d..5030ee9 100644 --- a/app/Services/VersionPublishService.php +++ b/app/Services/VersionPublishService.php @@ -155,7 +155,7 @@ class VersionPublishService throw new RuntimeException('manifest slug does not match package slug'); } - if (!preg_match('/^[A-Za-z][A-Za-z0-9]*$/', (string) ($manifest['slug'] ?? ''))) { + if (!$this->isValidSlug((string) ($manifest['slug'] ?? ''))) { throw new RuntimeException('manifest slug format invalid'); } @@ -213,6 +213,11 @@ class VersionPublishService return $base === '' ? $path : $base . $path; } + private function isValidSlug(string $slug): bool + { + return preg_match('/^[A-Za-z][A-Za-z0-9_-]{0,63}$/', $slug) === 1; + } + private function assertTopLevelStructure(array $topLevel, array $allowedTopLevel, string $message): void { $topLevelNames = array_keys($topLevel); diff --git a/config/store.php b/config/store.php index e6d6268..364a8f4 100644 --- a/config/store.php +++ b/config/store.php @@ -3,4 +3,25 @@ return [ 'admin_token' => env('STORE_ADMIN_TOKEN', ''), 'plugin_access_token' => env('STORE_PLUGIN_ACCESS_TOKEN', ''), + 'client_online_window_minutes' => (int) env('STORE_CLIENT_ONLINE_WINDOW_MINUTES', 15), + 'settings_defaults' => [ + 'site_name' => env('STORE_SITE_NAME', env('APP_NAME', 'Tstore')), + 'site_tagline' => env('STORE_SITE_TAGLINE', 'Typecho Extension Directory'), + 'home_title_suffix' => '扩展展示站', + 'home_eyebrow' => 'Public Showcase', + 'home_headline' => '更干净的 Typecho 扩展展示站', + 'home_lede' => '前台只负责展示插件与主题的资料、分类、兼容性和截图,真正的发布、上传和站点接入都由服务端统一维护,信息层次更清楚。', + 'home_aside_kicker' => 'How It Works', + 'home_aside_title' => '公开浏览,受控发布', + 'home_feature_one_title' => '前台聚焦展示', + 'home_feature_one_body' => '访客只看到扩展资料、截图、版本和兼容性,不直接暴露 zip 下载地址。', + 'home_feature_two_title' => '服务端统一维护', + 'home_feature_two_body' => '包信息、分类、版本、站点接入和 zip 发布流程都在服务端统一处理。', + 'home_feature_three_title' => '适合做目录站', + 'home_feature_three_body' => '既可以作为展示门户,也方便后续接入更多审核、统计和运营功能。', + 'home_plugins_title' => '推荐插件', + 'home_plugins_subtitle' => '优先展示适合作为目录首页入口的插件,突出摘要、作者、分类和版本信息。', + 'home_themes_title' => '推荐主题', + 'home_themes_subtitle' => '主题列表保持同样的展示逻辑,方便在统一视觉下浏览不同类型的扩展内容。', + ], ]; diff --git a/database/migrations/2026_03_20_150001_create_store_clients_table.php b/database/migrations/2026_03_20_150001_create_store_clients_table.php new file mode 100644 index 0000000..e778f31 --- /dev/null +++ b/database/migrations/2026_03_20_150001_create_store_clients_table.php @@ -0,0 +1,32 @@ +id(); + $table->string('site_url', 512)->unique(); + $table->string('site_name', 255)->default(''); + $table->string('access_token', 120)->unique(); + $table->string('status', 32)->default('online'); + $table->unsignedInteger('user_count')->default(0); + $table->string('typecho_version', 32)->default(''); + $table->string('php_version', 32)->default(''); + $table->string('plugin_version', 32)->default(''); + $table->string('last_ip', 45)->default(''); + $table->string('last_user_agent', 512)->default(''); + $table->dateTime('registered_at')->useCurrent(); + $table->dateTime('last_seen_at')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('store_clients'); + } +}; diff --git a/database/migrations/2026_03_20_180001_create_store_settings_table.php b/database/migrations/2026_03_20_180001_create_store_settings_table.php new file mode 100644 index 0000000..cc16023 --- /dev/null +++ b/database/migrations/2026_03_20_180001_create_store_settings_table.php @@ -0,0 +1,23 @@ +id(); + $table->string('key', 120)->unique(); + $table->text('value')->default(''); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('store_settings'); + } +}; diff --git a/resources/views/admin/account.blade.php b/resources/views/admin/account.blade.php new file mode 100644 index 0000000..acd83a9 --- /dev/null +++ b/resources/views/admin/account.blade.php @@ -0,0 +1,90 @@ +@extends('admin.layout', [ + 'title' => 'Tstore Admin · 账户安全', + 'pageTitle' => '账户安全', + 'pageSubtitle' => '修改当前管理员的用户名、邮箱和登录密码。', + 'pageBadge' => 'Account', +]) + +@section('content') +
+
+
+
+

账户资料

+

这里可以直接修改当前登录管理员的名称和邮箱地址。

+
+
+ +
+ @csrf + @method('PUT') + +
+ + +
+ +
+ + +
+ +
+ +
+
+ +
+
+ 当前邮箱 + {{ $user->email }} +
+
+ 当前用户名 + {{ $user->name ?: '未设置' }} +
+
+ 创建时间 + {{ optional($user->created_at)->format('Y-m-d H:i') ?: '-' }} +
+
+ 最近更新 + {{ optional($user->updated_at)->format('Y-m-d H:i') ?: '-' }} +
+
+
+ +
+
+
+

修改密码

+

输入当前密码后设置新密码。为了避免误操作,新密码必须至少 8 位,并重复确认一次。

+
+
+ +
+ @csrf + @method('PUT') + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+
+
+
+@endsection diff --git a/resources/views/admin/categories/index.blade.php b/resources/views/admin/categories/index.blade.php index 4ff6f3a..58d6cf6 100644 --- a/resources/views/admin/categories/index.blade.php +++ b/resources/views/admin/categories/index.blade.php @@ -1,138 +1,191 @@ -@extends('admin.layout', [ - 'title' => 'Tstore Admin · 分类管理', - 'pageTitle' => ($filters['type'] === 'plugin' ? '插件分类' : ($filters['type'] === 'theme' ? '主题分类' : '分类管理')), - 'pageSubtitle' => ($filters['type'] === 'plugin' - ? '维护插件分类,为插件 package 提供归类。' - : ($filters['type'] === 'theme' - ? '维护主题分类,为主题 package 提供归类。' - : '统一维护插件与主题分类,为 package 提供归类。')), -]) - @php $currentType = $filters['type'] ?? ''; $entityLabel = $currentType === 'plugin' ? '插件分类' : ($currentType === 'theme' ? '主题分类' : '分类'); - $listHint = $currentType === 'plugin' ? '按类型和关键词筛选插件分类。' - : ($currentType === 'theme' ? '按类型和关键词筛选主题分类。' : '按类型和关键词筛选分类列表。'); - $createHint = $currentType === 'plugin' ? '新建一个插件分类,用于给插件 package 归类。' - : ($currentType === 'theme' ? '新建一个主题分类,用于给主题 package 归类。' : '新建一个分类,用于给 package 归类。'); + $typeLabels = [ + 'plugin' => '插件', + 'theme' => '主题', + ]; @endphp +@extends('admin.layout', [ + 'title' => 'Tstore Admin · 分类管理', + 'pageTitle' => $entityLabel, + 'pageSubtitle' => '维护扩展分类、排序与描述,供前台展示和后台筛选共用。', + 'pageBadge' => 'Categories', +]) + +@push('styles') + +@endpush + @section('content')
-
+
+
当前结果{{ $categories->total() }}符合筛选条件的分类
+
当前类型{{ $currentType === '' ? '全部' : strtoupper($currentType) }}正在查看的分类类型
+
页码{{ $categories->currentPage() }}/{{ max($categories->lastPage(), 1) }}分页浏览
+
+ +

筛选与查询

-

{{ $listHint }}

+

按类型和关键词快速收拢分类范围,避免后台列表过长。

+
-
+
- +
- + 重置
-
+
-
-
-
-
-

{{ $entityLabel }}列表

-

支持就地编辑名称、slug、类型、排序与描述。

-
-
-
- @forelse ($categories as $category) -
-
- @csrf @method('PUT') -
-
-
-
- -
-
-
-
-
- 关联扩展 {{ $category->packages_count }} -
-
- -
-
-
-
- @csrf @method('DELETE') - -
-
-
- @empty -
当前还没有分类。
- @endforelse -
-
{{ $categories->links() }}
-
- -
+
+

新建{{ $entityLabel }}

-

{{ $createHint }}

+

先把常用分类建好,下面的小卡片再负责维护已有分类。

+
@csrf +
- +
- +
- +
+
- +
+
-
+ + +
+
+
+

{{ $entityLabel }}列表

+

改成更紧凑的小卡片,方便快速浏览并就地修改。

+
+
+ +
+ @forelse ($categories as $category) +
+
+
+

{{ $category->name }}

+
{{ $category->slug }}
+
+
+ {{ $typeLabels[$category->type] ?? $category->type }} + 关联扩展 {{ $category->packages_count }} +
+
+ +
+ @csrf + @method('PUT') + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ +
+
+ +
+
+ @csrf + @method('DELETE') + +
+
+
+ @empty +
当前还没有分类。
+ @endforelse +
+ + {{ $categories->links() }} +
@endsection diff --git a/resources/views/admin/clients/index.blade.php b/resources/views/admin/clients/index.blade.php new file mode 100644 index 0000000..d8325bf --- /dev/null +++ b/resources/views/admin/clients/index.blade.php @@ -0,0 +1,95 @@ +@php + $statusLabel = $filters['status'] === 'online' ? '在线' : ($filters['status'] === 'offline' ? '离线' : '全部'); +@endphp + +@extends('admin.layout', [ + 'title' => 'Tstore Admin · 站点接入', + 'pageTitle' => '站点接入', + 'pageSubtitle' => '查看哪些 Typecho 站点已注册、是否在线,以及最近一次上报状态。', + 'pageBadge' => 'Clients', +]) + +@section('content') +
+
+
已注册站点{{ $stats['registered_sites'] }}完成 token 注册
+
在线站点{{ $stats['online_sites'] }}{{ $onlineWindowMinutes }} 分钟内有心跳
+
离线站点{{ max($stats['registered_sites'] - $stats['online_sites'], 0) }}超出在线窗口
+
追踪用户数{{ $stats['tracked_users'] }}所有站点上报总和
+
当前筛选{{ $statusLabel }}站点状态过滤
+
+ +
+
+
+
+

站点列表

+

插件端注册成功后会出现在这里,之后每次状态同步都会刷新最近心跳和用户数。

+
+
+ +
+
+ + +
+
+ + +
+
+ + 重置 +
+
+
+ +
+ + + + + + + + + + + + + + @forelse ($clients as $client) + + + + + + + + + + @empty + + + + @endforelse + +
站点状态用户数环境注册时间最近同步Token 尾缀
+ {{ $client->site_name ?: '未命名站点' }} +
{{ $client->site_url }}
+
{{ $client->is_online ? '在线' : '离线' }}{{ $client->user_count }} +
Typecho {{ $client->typecho_version ?: '-' }}
+
PHP {{ $client->php_version ?: '-' }} · 插件 {{ $client->plugin_version ?: '-' }}
+
{{ optional($client->registered_at)->format('Y-m-d H:i:s') ?: '-' }}{{ optional($client->last_seen_at)->format('Y-m-d H:i:s') ?: '尚未同步' }}...{{ $client->token_suffix ?: '--------' }}
+
当前还没有站点完成注册。
+
+
+ + {{ $clients->links() }} +
+
+@endsection diff --git a/resources/views/admin/dashboard.blade.php b/resources/views/admin/dashboard.blade.php index a591a64..59f776a 100644 --- a/resources/views/admin/dashboard.blade.php +++ b/resources/views/admin/dashboard.blade.php @@ -1,71 +1,146 @@ +@php + $statusLabels = [ + 'draft' => '草稿', + 'published' => '发布中', + 'hidden' => '隐藏', + 'deprecated' => '弃用', + ]; + $statusTones = [ + 'draft' => 'muted', + 'published' => 'ok', + 'hidden' => 'warn', + 'deprecated' => 'warn', + ]; +@endphp + @extends('admin.layout', [ - 'title' => 'Tstore Admin · 概览', - 'pageTitle' => '概览', - 'pageSubtitle' => '先看整体状态,再进入扩展管理、分类管理和版本发布流程。', + 'title' => 'Tstore Admin · 控制台概览', + 'pageTitle' => '控制台概览', + 'pageSubtitle' => '先看整体状态,再继续维护扩展、版本发布和站点接入。', + 'pageBadge' => 'Overview', ]) @section('content')
-
-
扩展总数{{ $stats['packages'] }}插件 + 主题
-
插件{{ $stats['plugins'] }}plugin
-
主题{{ $stats['themes'] }}theme
-
版本记录{{ $stats['versions'] }}已发布与手动录入版本
+
+
扩展总数{{ $stats['packages'] }}插件与主题总量
+
插件{{ $stats['plugins'] }}plugin package
+
主题{{ $stats['themes'] }}theme package
+
版本记录{{ $stats['versions'] }}历史发布与手动录入
下载总量{{ $stats['downloads'] }}累计下载次数
-
分类数{{ $stats['categories'] }}插件 / 主题分类
-
+
分类总数{{ $stats['categories'] }}插件与主题分类
+
已注册站点{{ $stats['registered_sites'] }}完成注册的客户端
+
在线站点{{ $stats['online_sites'] }}最近心跳仍在线
+
追踪用户数{{ $stats['tracked_users'] }}所有站点上报总和
+ -
-
+
+

最近更新的扩展

-

可以直接进入详情页编辑信息、上传 zip,或查看当前最新稳定版本。

+

这里优先看扩展元数据、分类和当前发布状态,方便继续进入详情页维护。

查看全部
+
@forelse ($recentPackages as $package) -
+

{{ $package->name }}

{{ $package->type }} · {{ $package->slug }}

- {{ $package->status }} + {{ $statusLabels[$package->status] ?? $package->status }}
-

{{ $package->summary ?: '暂无摘要' }}

+

{{ $package->summary ?: '暂无摘要。' }}

@foreach ($package->categories as $category) {{ $category->name }} @endforeach @if ($package->latestStableVersion) - 最新稳定版 v{{ $package->latestStableVersion->version }} + 稳定版 v{{ $package->latestStableVersion->version }} @endif + 更新于 {{ optional($package->updated_at)->format('Y-m-d H:i') ?: '-' }}
-
+ @empty
当前还没有扩展数据。
@endforelse
-
+ -
-
-
-

当前后台能力

-

这一版已经能覆盖联调闭环里最关键的管理动作。

+
+
+
+
+

最近版本发布

+

快速核对最近写入的版本号、兼容区间和稳定状态。

+
-
-
-

扩展管理

支持创建 package、编辑基础信息、切换发布状态。
-

分类管理

支持新建、编辑、删除分类,并查看分类关联的扩展数量。
-

版本管理

支持手动录入版本、zip 上传发布、删除历史版本。
-

下一步建议

可以继续补截图管理、审核日志、下载统计细分和更严格的发布校验。
-
+ +
+ @forelse ($recentVersions as $version) +
+
+
+

v{{ $version->version }}

+

{{ $version->package?->name ?: '未知扩展' }}

+
+
+ {{ $version->is_stable ? '稳定版' : '预发布' }} + @if ($version->is_latest) + 最新 + @endif +
+
+
+ Typecho {{ $version->typecho_min ?: '-' }} ~ {{ $version->typecho_max ?: '-' }} + PHP {{ $version->php_min ?: '-' }} ~ {{ $version->php_max ?: '-' }} +
+

发布时间:{{ optional($version->published_at)->format('Y-m-d H:i') ?: '-' }}

+
+ @empty +
当前还没有版本记录。
+ @endforelse +
+ + +
+
+
+

最近接入站点

+

确认插件端是否完成注册、是否在线,以及最近一次同步时间。

+
+ 全部站点 +
+ +
+ @forelse ($recentClients as $client) +
+
+
+

{{ $client->site_name ?: '未命名站点' }}

+

{{ $client->site_url }}

+
+ {{ $client->is_online ? '在线' : '离线' }} +
+
+ 用户 {{ $client->user_count }} + Typecho {{ $client->typecho_version ?: '-' }} + PHP {{ $client->php_version ?: '-' }} +
+

最近同步:{{ optional($client->last_seen_at)->format('Y-m-d H:i:s') ?: '尚未同步' }}

+
+ @empty +
当前还没有站点完成注册。
+ @endforelse +
+
diff --git a/resources/views/admin/layout.blade.php b/resources/views/admin/layout.blade.php index 48124cb..cab2f2b 100644 --- a/resources/views/admin/layout.blade.php +++ b/resources/views/admin/layout.blade.php @@ -1,3 +1,11 @@ +@php + $layoutMode = $layoutMode ?? 'app'; + $pageTitle = $pageTitle ?? 'Tstore Admin'; + $pageSubtitle = $pageSubtitle ?? '统一管理扩展、版本与站点接入。'; + $pageBadge = $pageBadge ?? ($layoutMode === 'guest' ? 'Tstore Service' : '管理后台'); + $routeType = (string) (request()->route('type') ?: request()->query('type', '')); + $siteHost = parse_url(url('/'), PHP_URL_HOST) ?: 'local'; +@endphp @@ -5,71 +13,298 @@ {{ $title ?? 'Tstore Admin' }} + @stack('styles') - -
- -
-
-
-

{{ $pageTitle ?? 'Tstore Admin' }}

-

{{ $pageSubtitle ?? '统一管理扩展、分类、版本与发布流程。' }}

-
-
- 后台首页 -
@csrf
-
-
+ $layoutMode === 'guest'])> +@if ($layoutMode === 'guest') +
+
+
+ + TS + + {{ $storeSettings['site_name'] ?? config('app.name', 'Tstore') }} + Service Console + + + {{ $pageBadge }} +

{{ $pageTitle }}

+

{{ $pageSubtitle }}

+
+
+ 公开展示与后台维护拆开 + 前台负责扩展展示,后台负责发布、分类和站点接入。 +
+
+ 登录后统一处理服务端数据 + 版本、分类、站点注册状态和后台发布入口都集中在一个控制台里。 +
+
+ 默认先进入本地控制台 + 如果你已经初始化管理员账号,可以直接登录继续维护服务端。 +
+
+
公开站点:返回首页
+
+
+ @if (session('success') || session('error') || $errors->any()) +
+ @if (session('success')) +
{{ session('success') }}
+ @endif + @if (session('error')) +
{{ session('error') }}
+ @endif + @if ($errors->any()) +
{{ $errors->first() }}
+ @endif +
+ @endif - @if (session('success')) -
{{ session('success') }}
- @endif - @if (session('error')) -
{{ session('error') }}
- @endif - @if ($errors->any()) -
{{ $errors->first() }}
- @endif + @yield('content') +
+
+
+@else +
-
+ + + +
+
+
+
+ {{ $pageBadge }} +

{{ $pageTitle }}

+

{{ $pageSubtitle }}

+
+
+ 公开前台 + 账户安全 + 站点设置 + 站点接入 +
+ @csrf + +
+
+
+ + @if (session('success') || session('error') || $errors->any()) +
+ @if (session('success')) +
{{ session('success') }}
+ @endif + @if (session('error')) +
{{ session('error') }}
+ @endif + @if ($errors->any()) +
{{ $errors->first() }}
+ @endif +
+ @endif + + @yield('content') +
+
+
+@endif + diff --git a/resources/views/admin/login.blade.php b/resources/views/admin/login.blade.php index 6d10fab..6134294 100644 --- a/resources/views/admin/login.blade.php +++ b/resources/views/admin/login.blade.php @@ -1,31 +1,34 @@ @extends('admin.layout', [ 'title' => 'Tstore Admin · 后台登录', 'pageTitle' => '后台登录', - 'pageSubtitle' => '使用后台账号密码登录,不再直接依赖 token。', + 'pageSubtitle' => '使用管理员账号进入服务端控制台,继续维护扩展发布、分类和站点接入。', + 'pageBadge' => 'Admin Login', + 'layoutMode' => 'guest', ]) @section('content') -
+ diff --git a/resources/views/admin/packages/index.blade.php b/resources/views/admin/packages/index.blade.php index 86a0385..1a7a4b0 100644 --- a/resources/views/admin/packages/index.blade.php +++ b/resources/views/admin/packages/index.blade.php @@ -1,78 +1,87 @@ -@extends('admin.layout', [ - 'title' => 'Tstore Admin · 插件 / 主题管理', - 'pageTitle' => $filters['type'] === 'plugin' ? '插件列表' : ($filters['type'] === 'theme' ? '主题列表' : '扩展列表'), - 'pageSubtitle' => $filters['type'] === 'plugin' - ? '查看、筛选、维护和发布插件,统一处理插件版本与状态。' - : ($filters['type'] === 'theme' - ? '查看、筛选、维护和发布主题,统一处理主题版本与状态。' - : '查看全部插件与主题,统一管理扩展列表、详情页与发布版本。'), -]) - @php + $statusLabels = [ + 'draft' => '草稿', + 'published' => '发布中', + 'hidden' => '隐藏', + 'deprecated' => '弃用', + ]; + $statusTones = [ + 'draft' => 'muted', + 'published' => 'ok', + 'hidden' => 'warn', + 'deprecated' => 'warn', + ]; $currentType = $filters['type'] ?? ''; $entityLabel = $currentType === 'plugin' ? '插件' : ($currentType === 'theme' ? '主题' : '扩展'); $entityPluralLabel = $currentType === 'plugin' ? '插件列表' : ($currentType === 'theme' ? '主题列表' : '扩展列表'); - $newEntityLabel = $currentType === 'plugin' ? '新建插件' : ($currentType === 'theme' ? '新建主题' : '新建扩展'); - $filterHint = $currentType === 'plugin' ? '按类型、状态和关键词快速定位插件。' - : ($currentType === 'theme' ? '按类型、状态和关键词快速定位主题。' : '按类型、状态和关键词快速定位扩展。'); - $listHint = $currentType === 'plugin' ? '支持查看插件详情、编辑元数据、上传 zip 发布版本,以及切换插件状态。' - : ($currentType === 'theme' ? '支持查看主题详情、编辑元数据、上传 zip 发布版本,以及切换主题状态。' - : '支持查看详情、编辑元数据、上传 zip 发布版本,以及切换扩展状态。'); - $createHint = $currentType === 'plugin' ? '先创建插件 package,再进入详情页补充版本,或直接上传 zip 发布。' - : ($currentType === 'theme' ? '先创建主题 package,再进入详情页补充版本,或直接上传 zip 发布。' - : '先创建 package,再进入详情页补充版本,或直接上传 zip 发布。'); - $emptyHint = '当前还没有' . $entityLabel . '。'; + $newEntityLabel = '新建' . $entityLabel; + $currentStatusLabel = $filters['status'] !== '' ? ($statusLabels[$filters['status']] ?? $filters['status']) : '全部状态'; @endphp +@extends('admin.layout', [ + 'title' => 'Tstore Admin · 扩展管理', + 'pageTitle' => $entityPluralLabel, + 'pageSubtitle' => '统一维护扩展元数据、分类关联、发布状态与版本入口。', + 'pageBadge' => 'Packages', +]) + @section('content')
-
+
+
当前结果{{ $packages->total() }}符合筛选条件的{{ $entityLabel }}
+
当前类型{{ $currentType === '' ? '全部' : strtoupper($currentType) }}{{ $entityLabel }}视角
+
当前状态{{ $currentStatusLabel }}已应用状态筛选
+
页码{{ $packages->currentPage() }}/{{ max($packages->lastPage(), 1) }}分页浏览
+
+ +

筛选与查询

-

{{ $filterHint }}

+

先缩小范围,再进入详情页处理版本发布或资料修改。

+
-
+
- +
- + 重置
-
+
-
-
+
+

{{ $entityPluralLabel }}

-

{{ $listHint }}

+

保持列表信息密度,重点展示状态、当前版本、分类和最近更新时间。

+
@@ -82,6 +91,7 @@ + @@ -91,108 +101,134 @@ - - + + + @empty - + + + @endforelse
状态 最新版本 分类更新时间 操作
{{ $package->name }}
{{ $package->slug }}
-
{{ $package->summary ?: '暂无摘要' }}
+
{{ $package->summary ?: '暂无摘要。' }}
{{ $package->type }}{{ $package->status }}{{ strtoupper($package->type) }}{{ $statusLabels[$package->status] ?? $package->status }} {{ $package->latestStableVersion?->version ?: ($package->latest_version ?: '-') }}
- @foreach ($package->categories as $category) - {{ $category->slug }} - @endforeach + @forelse ($package->categories as $category) + {{ $category->name }} + @empty + 未分类 + @endforelse
{{ optional($package->updated_at)->format('Y-m-d H:i') ?: '-' }}
- 详情 / 发布 + 详情
- @csrf @method('PATCH') + @csrf + @method('PATCH') - + +
+
+ @csrf + @method('DELETE') +
{{ $emptyHint }}
+
当前还没有{{ $entityLabel }}。
+
-
{{ $packages->links() }}
-
-
+ {{ $packages->links() }} + + +

{{ $newEntityLabel }}

-

{{ $createHint }}

+

先创建扩展,再进入详情页继续补充版本、截图和发布信息。

+
@csrf +
- +
- +
- +
- + +
+
+ +
-
-
- - -
-
- - -
-
- -
-
- - +
+
- - + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+
-
+
@endsection diff --git a/resources/views/admin/packages/show.blade.php b/resources/views/admin/packages/show.blade.php index dc2a044..4c94fd2 100644 --- a/resources/views/admin/packages/show.blade.php +++ b/resources/views/admin/packages/show.blade.php @@ -1,82 +1,257 @@ +@php + $typeLabel = $package->type === 'plugin' ? '插件' : '主题'; + $statusLabels = [ + 'draft' => '草稿', + 'published' => '发布中', + 'hidden' => '隐藏', + 'deprecated' => '弃用', + ]; + $statusTone = [ + 'draft' => 'muted', + 'published' => 'ok', + 'hidden' => 'warn', + 'deprecated' => 'warn', + ]; + $currentVersion = $package->versions->firstWhere('is_latest', true) + ?: $package->versions->firstWhere('is_stable', true) + ?: $package->versions->first(); +@endphp + @extends('admin.layout', [ 'title' => 'Tstore Admin · ' . $package->name, 'pageTitle' => $package->name, - 'pageSubtitle' => ($package->type === 'plugin' ? '插件' : ($package->type === 'theme' ? '主题' : $package->type)) . ' · ' . $package->slug, + 'pageSubtitle' => $typeLabel . ' · ' . $package->slug, + 'pageBadge' => ucfirst($package->type), ]) -@php - $entityLabel = $package->type === 'plugin' ? '插件' : ($package->type === 'theme' ? '主题' : '扩展'); -@endphp - @section('content')
+
+
扩展类型{{ strtoupper($package->type) }}{{ $typeLabel }} package
+
当前状态{{ $statusLabels[$package->status] ?? $package->status }}可在下方直接调整
+
当前主版本{{ $currentVersion?->version ?: '-' }}最新或稳定版本
+
版本数量{{ $package->versions->count() }}历史记录总数
+
截图数量{{ $package->screenshots->count() }}当前展示素材
+
下载次数{{ $package->download_count }}累计下载总量
+
+
-
-
-
-

{{ $entityLabel }}信息

-

编辑基础元数据、分类、图标与发布状态。

+
+
+
+ @if ($package->icon_url) + {{ $package->name }} + @else + {{ $package->type === 'plugin' ? 'P' : 'T' }} + @endif +
+
+

基础资料

+

这里维护扩展展示信息、排序、推荐状态以及分类关联。

+
- @csrf @method('PUT') + @csrf + @method('PUT') +
-
-
-
-
+
+ + +
+
+ + +
+
+ + +
+
+
-
-
-
-
+
+ + +
+
+ + +
+
+ + +
+
+ + +
-
-
-
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+
- - 返回{{ $entityLabel }}列表 + + 返回{{ $typeLabel }}列表
-
-
-
-
-

当前状态

-

快速查看分类、版本、下载与截图信息。

+
+
+ @csrf + @method('DELETE') + +
+
+
+ +
+
+
+
+

当前资料

+

快速核对基础信息、时间戳和前台展示状态。

+
-
-
-

状态

{{ $package->status }}下载 {{ $package->download_count }}{{ $package->is_featured ? '推荐' : '普通' }}
-

分类

@forelse($package->categories as $category){{ $category->name }}@empty尚未关联分类@endforelse
-

截图

{{ $package->screenshots->count() }} 张
-

最新版本

{{ $package->latest_version ?: '尚未发布' }}
-
+ +
+
+ Slug + {{ $package->slug }} +
+
+ 状态 + {{ $statusLabels[$package->status] ?? $package->status }} +
+
+ 作者 + {{ $package->author ?: '未填写' }} +
+
+ 主页 + {{ $package->homepage ?: '未填写' }} +
+
+ 创建时间 + {{ optional($package->created_at)->format('Y-m-d H:i') ?: '-' }} +
+
+ 更新时间 + {{ optional($package->updated_at)->format('Y-m-d H:i') ?: '-' }} +
+
+ +
+ @foreach ($package->categories as $category) + {{ $category->name }} + @endforeach + @if ($package->categories->isEmpty()) + 尚未关联分类 + @endif + {{ $package->is_featured ? '推荐扩展' : '普通扩展' }} +
+ + +
+
+
+

截图管理

+

手动录入详情页预览截图,第一张会优先作为前台主预览展示。

+
+
+ +
+ @csrf + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+
+ + @if ($package->screenshots->isNotEmpty()) +
+ @foreach ($package->screenshots as $shot) +
+
+ {{ $shot->caption ?: $package->name }} +
{{ $shot->caption ?: $package->name . ' 截图' }}
+
+
+ 排序 {{ $shot->sort_order }} +
+ @csrf + @method('DELETE') + +
+
+
+ @endforeach +
+ @else +
当前还没有截图。
+ @endif +
-
+

版本记录

-

查看版本状态,也可以直接删除历史版本。

+

统一查看版本号、兼容区间、发布时间与稳定状态。

+
- - + @@ -86,94 +261,173 @@ @forelse ($package->versions as $version) + - - - + @empty - + + + @endforelse
版本稳定版最新状态 兼容性 发布时间 操作
- {{ $version->version }} -
{{ $version->package_url ?: '未设置 package_url' }}
+ v{{ $version->version }} +
{{ $version->package_url ?: '未生成下载地址' }}
+
+
+ {{ $version->is_stable ? '稳定版' : '预发布' }} + {{ $version->is_latest ? '最新' : '历史' }} +
{{ $version->is_stable ? '是' : '否' }}{{ $version->is_latest ? '最新' : '历史' }}
Typecho {{ $version->typecho_min ?: '-' }} ~ {{ $version->typecho_max ?: '-' }}
PHP {{ $version->php_min ?: '-' }} ~ {{ $version->php_max ?: '-' }}
{{ optional($version->published_at)->format('Y-m-d H:i') }}{{ optional($version->published_at)->format('Y-m-d H:i') ?: '-' }}
- @csrf @method('DELETE') + @csrf + @method('DELETE')
当前还没有版本记录。
+
当前还没有版本记录。
+
-
+ -
+
-

zip 上传发布

-

优先读取 zip 根目录的 manifest.json;如果没有 manifest.json,则使用下方手动填写的版本和兼容性字段。

+

zip 发布

+

上传 zip 后补充版本字段,服务端会校验目录结构并生成可供 API 返回的版本元数据。

+
@csrf -
-
-
-
-
-
-
-
-
-
+ +
+ +
-
-
-
- - -
+ +
+ + +
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
-
+ +
+ + +
+ +
+ +
-
+
-
+

手动添加版本

-

调试阶段可以快速手动录入版本元数据。

+

调试或补录历史版本时,可以直接写入元数据而不经过 zip 发布流程。

+
@csrf -
-
-
-
-
-
-
-
-
-
-
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
-
-
- - + +
+ +
+ +
+ + +
+
-
+
@endsection diff --git a/resources/views/admin/settings.blade.php b/resources/views/admin/settings.blade.php new file mode 100644 index 0000000..13ca776 --- /dev/null +++ b/resources/views/admin/settings.blade.php @@ -0,0 +1,133 @@ +@extends('admin.layout', [ + 'title' => 'Tstore Admin · 站点设置', + 'pageTitle' => '站点设置', + 'pageSubtitle' => '配置前台站点名称、标语,以及首页主要展示文案。', + 'pageBadge' => 'Settings', +]) + +@section('content') +
+
+
+
+

站点基础信息

+

这些字段会影响前台标题、品牌名称和首页头部展示。

+
+
+ +
+ @csrf + @method('PUT') + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+
+
+
+

首页说明卡片

+

右侧三块说明文字都可以在这里调整。

+
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+
+
+

列表区域标题

+

首页的推荐插件和推荐主题标题、说明都能单独设置。

+
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ +
+ +
+
+
+
+@endsection diff --git a/resources/views/storefront/home.blade.php b/resources/views/storefront/home.blade.php index 93a1c35..3d0cc65 100644 --- a/resources/views/storefront/home.blade.php +++ b/resources/views/storefront/home.blade.php @@ -1,102 +1,63 @@ -@extends('storefront.layout', ['title' => 'Tstore · 扩展展示站']) +@extends('storefront.layout', ['title' => (config('app.name', 'Tstore')) . ' · ' . ($storeSettings['home_title_suffix'] ?? '扩展展示站')]) @section('hero') -
-
-

精选 Typecho 插件与主题展示

-

这里是展示前台,只负责展示站点里的插件、主题、分类、截图和版本信息,不直接暴露下载地址。

-
-
{{ $stats['packages'] }}已发布扩展
-
{{ $stats['plugins'] }}插件
-
{{ $stats['themes'] }}主题
-
{{ $stats['categories'] }}分类
+
+
+ {{ $storeSettings['home_eyebrow'] ?? 'Public Showcase' }} +

{{ $storeSettings['home_headline'] ?? '更干净的 Typecho 扩展展示站' }}

+

{{ $storeSettings['home_lede'] ?? '前台只负责展示插件与主题的资料、分类、兼容性和截图,真正的发布、上传和站点接入都由服务端统一维护,信息层次更清楚。' }}

+ +
+
{{ $stats['packages'] }}已发布扩展
+
{{ $stats['plugins'] }}插件
+
{{ $stats['themes'] }}主题
+
{{ $stats['categories'] }}分类
-
-

当前站点定位

-

前台面向访客展示内容,后台则面向管理员,通过账号密码登录后维护扩展、分类、版本和 zip 发布。

- -
-
+ @endsection @section('content') -
-
-
-

推荐插件

-

展示基础信息、版本兼容性与分类,不直接提供 zip 下载地址。

-
- 查看全部插件 -
-
- @forelse($featuredPlugins as $package) -
-
- @if($package->icon_url) - {{ $package->name }} - @else -
- @endif -
-
{{ $package->name }}
-
{{ $package->author ?: '未知作者' }} · {{ $package->latestStableVersion?->version ?: $package->latest_version }}
-
-
-
{{ $package->summary ?: '暂无摘要' }}
-
- @foreach($package->categories as $category) - {{ $category->name }} - @endforeach -
- +
+
+
+
+ Plugins +

{{ $storeSettings['home_plugins_title'] ?? '推荐插件' }}

+

{{ $storeSettings['home_plugins_subtitle'] ?? '优先展示适合作为目录首页入口的插件,突出摘要、作者、分类和版本信息。' }}

- @empty -
当前没有推荐插件。
- @endforelse + 查看全部 +
+
+ @forelse($featuredPlugins as $package) + @include('storefront.partials.card', ['package' => $package, 'typeLabel' => '插件', 'fallback' => 'P']) + @empty +
当前还没有推荐插件。
+ @endforelse +
-
+ -
-
-
-

推荐主题

-

适合做展示站首页,突出视觉风格、分类和版本信息。

-
- 查看全部主题 -
-
- @forelse($featuredThemes as $package) -
-
- @if($package->icon_url) - {{ $package->name }} - @else -
- @endif -
-
{{ $package->name }}
-
{{ $package->author ?: '未知作者' }} · {{ $package->latestStableVersion?->version ?: $package->latest_version }}
-
-
-
{{ $package->summary ?: '暂无摘要' }}
-
- @foreach($package->categories as $category) - {{ $category->name }} - @endforeach -
- +
+
+
+
+ Themes +

{{ $storeSettings['home_themes_title'] ?? '推荐主题' }}

+

{{ $storeSettings['home_themes_subtitle'] ?? '主题列表保持同样的展示逻辑,方便在统一视觉下浏览不同类型的扩展内容。' }}

- @empty -
当前没有推荐主题。
- @endforelse + 查看全部 +
+
+ @forelse($featuredThemes as $package) + @include('storefront.partials.card', ['package' => $package, 'typeLabel' => '主题', 'fallback' => 'T']) + @empty +
当前还没有推荐主题。
+ @endforelse +
-
+ @endsection diff --git a/resources/views/storefront/layout.blade.php b/resources/views/storefront/layout.blade.php index c50a7a7..bf4bc23 100644 --- a/resources/views/storefront/layout.blade.php +++ b/resources/views/storefront/layout.blade.php @@ -3,42 +3,194 @@ - {{ $title ?? 'Tstore' }} + {{ $title ?? ($storeSettings['site_name'] ?? config('app.name', 'Tstore')) }} + -
-
- - @yield('hero') +
+
-
-
- @yield('content') -
+@yield('hero') +
+ @yield('content')
+
+ +
+ diff --git a/resources/views/storefront/packages.blade.php b/resources/views/storefront/packages.blade.php index 5c6d242..6c9fcab 100644 --- a/resources/views/storefront/packages.blade.php +++ b/resources/views/storefront/packages.blade.php @@ -1,79 +1,70 @@ -@extends('storefront.layout', ['title' => 'Tstore · ' . ($type === 'plugin' ? '插件' : '主题')]) +@extends('storefront.layout', ['title' => (config('app.name', 'Tstore')) . ' · ' . ($type === 'plugin' ? '插件目录' : '主题目录')]) + +@php + $typeLabel = $type === 'plugin' ? '插件' : '主题'; + $fallback = $type === 'plugin' ? 'P' : 'T'; + $selectedCategory = ''; + foreach ($categories as $category) { + if ($filters['category'] === $category->slug) { + $selectedCategory = $category->name; + break; + } + } + $sortLabel = $filters['sort'] === 'name' ? '名称排序' : '最近更新'; +@endphp @section('hero') -
-
-

{{ $type === 'plugin' ? '插件目录' : '主题目录' }}

-

支持按分类和关键词浏览 {{ $type === 'plugin' ? '插件' : '主题' }},这里只展示信息,不直接提供下载地址。

-
-
-
-
-
- - -
-
- - -
-
- - -
+
+
+ {{ $typeLabel }} +

{{ $typeLabel }}目录

+

统一浏览 {{ $typeLabel }} 的摘要、分类、作者和版本,筛选逻辑保持轻量,适合做公开展示与目录导航。

+ +
+ +
-
- +
+ +
+
+ + +
+ + 重置
-
+
@endsection @section('content') -
-
-
-

{{ $type === 'plugin' ? '插件列表' : '主题列表' }}

-

共 {{ $packages->total() }} 项结果

-
-
-
- @forelse($packages as $package) -
-
- @if($package->icon_url) - {{ $package->name }} - @else -
- @endif -
-
{{ $package->name }}
-
{{ $package->author ?: '未知作者' }} · {{ $package->latestStableVersion?->version ?: $package->latest_version }}
-
-
-
{{ $package->summary ?: '暂无摘要' }}
-
- @foreach($package->categories as $category) - {{ $category->name }} - @endforeach -
- +
+
+
+
+ Catalog +

{{ $typeLabel }}列表

+

共找到 {{ $packages->total() }} 个结果 · 当前分类 {{ $selectedCategory ?: '全部' }} · {{ $sortLabel }}。

- @empty -
当前没有符合条件的{{ $type === 'plugin' ? '插件' : '主题' }}。
- @endforelse +
+
+ @forelse($packages as $package) + @include('storefront.partials.card', ['package' => $package, 'typeLabel' => $typeLabel, 'fallback' => $fallback]) + @empty +
当前没有符合条件的{{ $typeLabel }}。
+ @endforelse +
+ {{ $packages->onEachSide(1)->links() }}
-
{{ $packages->links() }}
-
+ @endsection diff --git a/resources/views/storefront/partials/card.blade.php b/resources/views/storefront/partials/card.blade.php new file mode 100644 index 0000000..a6e6c58 --- /dev/null +++ b/resources/views/storefront/partials/card.blade.php @@ -0,0 +1,26 @@ +
+
+ @if($package->icon_url) + {{ $package->name }} + @else +
{{ $fallback }}
+ @endif +
+

{{ $package->name }}

+

{{ $package->author ?: '未知作者' }} · v{{ $package->latestStableVersion?->version ?: $package->latest_version ?: '-' }}

+
+
+

{{ $package->summary ?: '暂无摘要。' }}

+
+ @foreach($package->categories as $category) + {{ $category->name }} + @endforeach + @if($package->is_featured) + 推荐 + @endif +
+
+ 下载 {{ $package->download_count }} + 查看详情 +
+
diff --git a/resources/views/storefront/show.blade.php b/resources/views/storefront/show.blade.php index b60da05..ff77a4d 100644 --- a/resources/views/storefront/show.blade.php +++ b/resources/views/storefront/show.blade.php @@ -1,99 +1,144 @@ -@extends('storefront.layout', ['title' => 'Tstore · ' . $package->name]) +@extends('storefront.layout', ['title' => (config('app.name', 'Tstore')) . ' · ' . $package->name]) + +@php + $typeLabel = $package->type === 'plugin' ? '插件' : '主题'; + $listRoute = $package->type === 'plugin' ? 'storefront.plugins' : 'storefront.themes'; + $latestVersion = collect($detail['versions'])->firstWhere('is_latest', true) + ?: collect($detail['versions'])->firstWhere('is_stable', true) + ?: ($detail['versions'][0] ?? null); +@endphp @section('hero') -
-
-

{{ $detail['name'] }}

-

{{ $detail['summary'] ?: '暂无摘要' }}

-
-
{{ $detail['type'] }}类型
-
{{ $detail['download_count'] }}累计下载
-
{{ count($detail['versions']) }}版本数
-
{{ count($detail['categories']) }}分类
+
+
+ {{ $typeLabel }} +
+ @if($detail['icon_url']) + {{ $detail['name'] }} + @else +
{{ $package->type === 'plugin' ? 'P' : 'T' }}
+ @endif +
+

{{ $detail['name'] }}

+

{{ $detail['summary'] ?: '这个' . $typeLabel . '当前还没有摘要,详情页会继续保留版本、兼容性和截图信息。' }}

+
+ @foreach($package->categories as $category) + {{ $category->name }} + @endforeach + @if($detail['is_featured']) + 推荐 + @endif + {{ $detail['author'] ?: '未知作者' }} +
+
+
+
+ 返回{{ $typeLabel }}目录 + @if($detail['homepage']) + 访问主页 + @endif +
+
+
{{ strtoupper($detail['type']) }}扩展类型
+
{{ $detail['download_count'] }}累计下载
+
{{ count($detail['versions']) }}版本记录
+
{{ $latestVersion['version'] ?? '-' }}当前主版本
-
-

说明

-

这是前台展示页,只展示扩展信息、版本兼容性和截图,不直接暴露下载地址。需要维护请进入后台。

- -
-
+ @endsection @section('content') -
-
-

扩展说明

-
作者:{{ $detail['author'] ?: '未知作者' }}
-
主页:{{ $detail['homepage'] ?: '未提供' }}
-
- @foreach($package->categories as $category) - {{ $category->name }} - @endforeach -
-
{{ $detail['description'] ?: '暂无详细描述。' }}
-
- -
-

版本信息

-
- @forelse($detail['versions'] as $version) -
- v{{ $version['version'] }} -
{{ $version['is_latest'] ? '最新版本' : '历史版本' }} · {{ $version['is_stable'] ? '稳定版' : '预发布' }}
-
Typecho {{ $version['compatibility']['typecho_min'] ?: '-' }} ~ {{ $version['compatibility']['typecho_max'] ?: '-' }}
-
PHP {{ $version['compatibility']['php_min'] ?: '-' }} ~ {{ $version['compatibility']['php_max'] ?: '-' }}
- @if(!empty($version['changelog'])) -
{{ $version['changelog'] }}
+@php + $screenshots = $detail['screenshots']; + $featuredScreenshot = $screenshots[0] ?? null; + $secondaryScreenshots = array_slice($screenshots, 1); +@endphp +
+
+
+
+
+ Preview +

预览截图

+ @if($featuredScreenshot) +
+
+ {{ $featuredScreenshot['caption'] ?: $detail['name'] }} +
+ {{ $featuredScreenshot['caption'] ?: $detail['name'] . ' 主预览图' }} + 第一张截图作为主视觉展示,后台可继续追加更多截图并调整排序。 +
+
+ @if(count($secondaryScreenshots)) +
+ @foreach($secondaryScreenshots as $shot) +
+ {{ $shot['caption'] ?: $detail['name'] }} +
{{ $shot['caption'] ?: $detail['name'] . ' 截图' }}
+
+ @endforeach +
+ @endif +
+ @else +
当前还没有预览截图。
@endif -
- @empty -
当前没有版本记录。
- @endforelse -
-
-
- -@if(count($detail['screenshots'])) -
-
-
-

截图展示

-

只展示视觉效果,不提供下载入口。

-
-
- -
-@endif -
-
-
-

更多{{ $package->type === 'plugin' ? '插件' : '主题' }}

-

继续浏览同类内容。

+
-
- @forelse($related as $item) -
-
{{ $item->name }}
-
{{ $item->summary ?: '暂无摘要' }}
- -
- @empty -
当前没有更多相关内容。
- @endforelse -
-
+ @endsection diff --git a/routes/api.php b/routes/api.php index 5340f9f..2ba2e49 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1,9 +1,16 @@ group(function () { + Route::post('/register', [ClientController::class, 'register']); + Route::post('/heartbeat', [ClientController::class, 'heartbeat']); + Route::get('/status', [ClientController::class, 'status']); +}); + Route::prefix('v1/repo')->group(function () { Route::get('/index', [RepoController::class, 'index']); Route::get('/packages/{type}/{slug}', [RepoController::class, 'detail']) diff --git a/routes/web.php b/routes/web.php index a1ea59f..90710a9 100644 --- a/routes/web.php +++ b/routes/web.php @@ -3,12 +3,23 @@ use App\Http\Controllers\Web\StorefrontController; use App\Http\Controllers\WebAdmin\AuthController; use App\Http\Controllers\WebAdmin\DashboardController; +use App\Http\Controllers\WebAdmin\SettingsController; use Illuminate\Support\Facades\Route; Route::get('/', [StorefrontController::class, 'home'])->name('storefront.home'); Route::get('/plugins', [StorefrontController::class, 'packages'])->defaults('type', 'plugin')->name('storefront.plugins'); Route::get('/themes', [StorefrontController::class, 'packages'])->defaults('type', 'theme')->name('storefront.themes'); -Route::get('/packages/{type}/{slug}', [StorefrontController::class, 'show'])->name('storefront.show'); +Route::get('/plugin/{slug}', [StorefrontController::class, 'showPlugin'])->name('storefront.plugin.show'); +Route::get('/theme/{slug}', [StorefrontController::class, 'showTheme'])->name('storefront.theme.show'); +Route::get('/packages/{type}/{slug}', function (string $type, string $slug) { + abort_unless(in_array($type, ['plugin', 'theme'], true), 404); + + return redirect()->route( + $type === 'plugin' ? 'storefront.plugin.show' : 'storefront.theme.show', + ['slug' => $slug], + 301 + ); +})->name('storefront.show.legacy'); Route::get('/admin/login', [AuthController::class, 'showLogin'])->name('webadmin.login'); Route::post('/admin/login', [AuthController::class, 'login'])->name('webadmin.login.submit'); @@ -16,12 +27,21 @@ Route::post('/admin/login', [AuthController::class, 'login'])->name('webadmin.lo Route::middleware('store.webadmin')->group(function () { Route::get('/admin', [DashboardController::class, 'home'])->name('webadmin.home'); Route::post('/admin/logout', [AuthController::class, 'logout'])->name('webadmin.logout'); + Route::get('/admin/account', [AuthController::class, 'showAccount'])->name('webadmin.account'); + Route::put('/admin/account/profile', [AuthController::class, 'updateProfile'])->name('webadmin.account.profile'); + Route::put('/admin/account/password', [AuthController::class, 'updatePassword'])->name('webadmin.account.password'); + Route::get('/admin/settings', [SettingsController::class, 'show'])->name('webadmin.settings'); + Route::put('/admin/settings', [SettingsController::class, 'update'])->name('webadmin.settings.update'); + Route::get('/admin/clients', [DashboardController::class, 'clients'])->name('webadmin.clients'); Route::get('/admin/packages', [DashboardController::class, 'packages'])->name('webadmin.packages'); Route::post('/admin/packages', [DashboardController::class, 'storePackage'])->name('webadmin.packages.store'); Route::put('/admin/packages/{type}/{slug}', [DashboardController::class, 'updatePackage'])->name('webadmin.packages.update'); Route::patch('/admin/packages/{type}/{slug}/status', [DashboardController::class, 'updatePackageStatus'])->name('webadmin.packages.status'); + Route::delete('/admin/packages/{type}/{slug}', [DashboardController::class, 'destroyPackage'])->name('webadmin.packages.destroy'); Route::get('/admin/packages/{type}/{slug}', [DashboardController::class, 'showPackage'])->name('webadmin.packages.show'); + Route::post('/admin/packages/{type}/{slug}/screenshots', [DashboardController::class, 'storeScreenshot'])->name('webadmin.packages.screenshots.store'); + Route::delete('/admin/packages/{type}/{slug}/screenshots/{id}', [DashboardController::class, 'destroyScreenshot'])->name('webadmin.packages.screenshots.destroy'); Route::post('/admin/packages/{type}/{slug}/versions', [DashboardController::class, 'storeVersion'])->name('webadmin.packages.versions.store'); Route::post('/admin/packages/{type}/{slug}/publish', [DashboardController::class, 'publishVersion'])->name('webadmin.packages.publish'); Route::delete('/admin/packages/{type}/{slug}/versions/{id}', [DashboardController::class, 'destroyVersion'])->name('webadmin.packages.versions.destroy'); diff --git a/tests/Feature/ClientRegistrationApiTest.php b/tests/Feature/ClientRegistrationApiTest.php new file mode 100644 index 0000000..d310d2b --- /dev/null +++ b/tests/Feature/ClientRegistrationApiTest.php @@ -0,0 +1,184 @@ +postJson('/api/v1/client/register', [ + 'site_url' => 'https://demo.example.com/', + 'site_name' => 'Demo Blog', + 'typecho_version' => '1.3.0', + 'php_version' => '8.2.16', + 'plugin_version' => '1.0.0', + 'user_count' => 3, + ]); + + $register->assertOk() + ->assertJsonPath('code', 0) + ->assertJsonPath('data.site_url', 'https://demo.example.com') + ->assertJsonPath('data.user_count', 3); + + $token = (string) $register->json('data.access_token'); + + $heartbeat = $this->withToken($token)->postJson('/api/v1/client/heartbeat', [ + 'site_name' => 'Demo Blog', + 'user_count' => 5, + 'plugin_version' => '1.0.1', + ]); + + $heartbeat->assertOk() + ->assertJsonPath('code', 0) + ->assertJsonPath('data.user_count', 5) + ->assertJsonPath('data.plugin_version', '1.0.1') + ->assertJsonPath('data.stats.registered_sites', 1) + ->assertJsonPath('data.stats.tracked_users', 5); + + $this->withToken($token)->getJson('/api/v1/client/status') + ->assertOk() + ->assertJsonPath('code', 0) + ->assertJsonPath('data.site_url', 'https://demo.example.com') + ->assertJsonMissingPath('data.access_token'); + + $client = StoreClient::query()->where('site_url', 'https://demo.example.com')->firstOrFail(); + $previousSeenAt = $client->last_seen_at; + + sleep(1); + + $this->withToken($token)->getJson('/api/v1/client/status') + ->assertOk() + ->assertJsonPath('code', 0); + + $client->refresh(); + + $this->assertNotNull($client->last_seen_at); + $this->assertTrue($client->last_seen_at->gt($previousSeenAt)); + $this->assertSame('online', $client->status); + } + + public function test_download_endpoint_accepts_registered_client_token(): void + { + config(['app.url' => 'http://127.0.0.1:8000']); + + $package = Package::query()->create([ + 'type' => 'plugin', + 'slug' => 'DemoPlugin', + 'name' => 'Demo Plugin', + 'summary' => 'Summary', + 'description' => 'Description', + 'author' => 'Author', + 'homepage' => '', + 'icon_url' => '', + 'license' => '', + 'status' => 'published', + 'is_featured' => false, + 'sort_order' => 0, + 'download_count' => 0, + 'latest_version' => '1.2.3', + ]); + + Version::query()->create([ + 'package_id' => $package->id, + 'version' => '1.2.3', + 'changelog' => 'Initial release', + 'typecho_min' => '1.2.0', + 'typecho_max' => '', + 'php_min' => '8.0', + 'php_max' => '', + 'php_extensions' => '[]', + 'package_url' => 'https://cdn.example.com/DemoPlugin-1.2.3.zip', + 'package_size' => 1234, + 'sha256' => str_repeat('a', 64), + 'is_stable' => true, + 'is_latest' => true, + 'download_count' => 0, + 'published_at' => now(), + ]); + + $this->getJson('/api/v1/repo/download/plugin/DemoPlugin/latest') + ->assertStatus(403) + ->assertJsonPath('message', 'client token required'); + + $register = $this->postJson('/api/v1/client/register', [ + 'site_url' => 'https://demo.example.com', + 'site_name' => 'Demo Blog', + 'user_count' => 2, + ]); + + $token = (string) $register->json('data.access_token'); + + $this->withToken($token) + ->getJson('https://store.typecho.team/api/v1/repo/download/plugin/DemoPlugin/latest') + ->assertOk() + ->assertJsonPath('code', 0) + ->assertJsonPath('data.package.sha256', str_repeat('a', 64)) + ->assertJsonPath('data.version', '1.2.3') + ->assertJsonPath('data.package.download_url', 'https://store.typecho.team/api/v1/repo/download/plugin/DemoPlugin/1.2.3?redirect=1'); + } + + public function test_check_updates_returns_request_host_download_url_when_app_url_is_local(): void + { + config(['app.url' => 'http://127.0.0.1:8000']); + + $package = Package::query()->create([ + 'type' => 'plugin', + 'slug' => 'DemoPlugin', + 'name' => 'Demo Plugin', + 'summary' => 'Summary', + 'description' => 'Description', + 'author' => 'Author', + 'homepage' => '', + 'icon_url' => '', + 'license' => '', + 'status' => 'published', + 'is_featured' => false, + 'sort_order' => 0, + 'download_count' => 0, + 'latest_version' => '1.2.3', + ]); + + Version::query()->create([ + 'package_id' => $package->id, + 'version' => '1.2.3', + 'changelog' => 'Initial release', + 'typecho_min' => '1.2.0', + 'typecho_max' => '', + 'php_min' => '8.0', + 'php_max' => '', + 'php_extensions' => '[]', + 'package_url' => 'http://127.0.0.1:8000/api/v1/repo/download/plugin/DemoPlugin/1.2.3?redirect=1', + 'package_size' => 1234, + 'sha256' => str_repeat('a', 64), + 'is_stable' => true, + 'is_latest' => true, + 'download_count' => 0, + 'published_at' => now(), + ]); + + $response = $this->postJson('https://store.typecho.team/api/v1/repo/updates/check', [ + 'installed' => [ + [ + 'type' => 'plugin', + 'slug' => 'DemoPlugin', + 'version' => '1.0.0', + ], + ], + 'typecho_version' => '1.3.0', + 'php_version' => '8.2.16', + ]); + + $response->assertOk() + ->assertJsonPath('code', 0) + ->assertJsonCount(1, 'data.updates') + ->assertJsonPath('data.updates.0.package.download_url', 'https://store.typecho.team/api/v1/repo/download/plugin/DemoPlugin/1.2.3?redirect=1'); + } +} diff --git a/tests/Feature/VersionPublishServiceTest.php b/tests/Feature/VersionPublishServiceTest.php index 53a3bc7..59646c3 100644 --- a/tests/Feature/VersionPublishServiceTest.php +++ b/tests/Feature/VersionPublishServiceTest.php @@ -96,6 +96,52 @@ class VersionPublishServiceTest extends TestCase app(VersionPublishService::class)->publishFromZip($package, $uploadedFile, []); } + public function test_manifest_validation_allows_lowercase_theme_slug(): void + { + $package = Package::query()->create([ + 'type' => 'theme', + 'slug' => 'clarity', + 'name' => 'Clarity', + 'summary' => '', + 'description' => '', + 'author' => '', + 'homepage' => '', + 'icon_url' => '', + 'license' => '', + 'status' => 'draft', + 'is_featured' => false, + 'sort_order' => 0, + 'download_count' => 0, + 'latest_version' => '', + ]); + + $manifest = [ + 'schema_version' => '1.0', + 'type' => 'theme', + 'slug' => 'clarity', + 'name' => 'Clarity', + 'author' => 'Author', + 'version' => '1.5.0', + 'compatibility' => [ + 'typecho_min' => '1.2.0', + 'php_min' => '8.0', + ], + 'install' => [ + 'root_dir' => 'clarity', + 'target_dir' => 'usr/themes/clarity', + ], + 'package' => [ + 'sha256' => str_repeat('a', 64), + ], + ]; + + $method = new \ReflectionMethod(VersionPublishService::class, 'validateManifest'); + $method->setAccessible(true); + $method->invoke(app(VersionPublishService::class), $manifest, $package, str_repeat('a', 64)); + + $this->assertTrue(true); + } + protected function tearDown(): void { foreach ($this->temporaryZipPaths as $path) { diff --git a/tests/Feature/WebAdminAccountPageTest.php b/tests/Feature/WebAdminAccountPageTest.php new file mode 100644 index 0000000..d2e8553 --- /dev/null +++ b/tests/Feature/WebAdminAccountPageTest.php @@ -0,0 +1,94 @@ +create([ + 'name' => 'Security Admin', + 'email' => 'security@example.com', + ]); + + $this->actingAs($user) + ->get('/admin/account') + ->assertOk() + ->assertSee('账户安全') + ->assertSee('Security Admin') + ->assertSee('security@example.com') + ->assertSee('修改密码'); + } + + public function test_authenticated_admin_can_update_password(): void + { + $user = User::factory()->create([ + 'email' => 'security@example.com', + 'password' => 'OldPassword123', + ]); + + $this->actingAs($user) + ->put('/admin/account/password', [ + 'current_password' => 'OldPassword123', + 'new_password' => 'NewPassword456', + 'new_password_confirmation' => 'NewPassword456', + ]) + ->assertRedirect('/admin/account') + ->assertSessionHas('success', '密码已更新'); + + $user->refresh(); + + $this->assertTrue(Hash::check('NewPassword456', $user->password)); + $this->assertFalse(Hash::check('OldPassword123', $user->password)); + } + + public function test_password_update_requires_correct_current_password(): void + { + $user = User::factory()->create([ + 'password' => 'OldPassword123', + ]); + + $this->actingAs($user) + ->from('/admin/account') + ->put('/admin/account/password', [ + 'current_password' => 'WrongPassword999', + 'new_password' => 'NewPassword456', + 'new_password_confirmation' => 'NewPassword456', + ]) + ->assertRedirect('/admin/account') + ->assertSessionHasErrors('current_password'); + + $user->refresh(); + + $this->assertTrue(Hash::check('OldPassword123', $user->password)); + $this->assertFalse(Hash::check('NewPassword456', $user->password)); + } + + public function test_authenticated_admin_can_update_profile(): void + { + $user = User::factory()->create([ + 'name' => 'Old Admin', + 'email' => 'old@example.com', + ]); + + $this->actingAs($user) + ->put('/admin/account/profile', [ + 'name' => 'New Admin', + 'email' => 'new@example.com', + ]) + ->assertRedirect('/admin/account') + ->assertSessionHas('success', '账户资料已更新'); + + $user->refresh(); + + $this->assertSame('New Admin', $user->name); + $this->assertSame('new@example.com', $user->email); + } +} diff --git a/tests/Feature/WebAdminClientsPageTest.php b/tests/Feature/WebAdminClientsPageTest.php new file mode 100644 index 0000000..8b8360b --- /dev/null +++ b/tests/Feature/WebAdminClientsPageTest.php @@ -0,0 +1,71 @@ +create(); + + StoreClient::query()->create([ + 'site_url' => 'https://demo.example.com', + 'site_name' => 'Demo Blog', + 'access_token' => str_repeat('a', 96), + 'status' => 'online', + 'user_count' => 12, + 'typecho_version' => '1.3.0', + 'php_version' => '8.2.16', + 'plugin_version' => '1.0.0', + 'registered_at' => now()->subDay(), + 'last_seen_at' => now(), + ]); + + $this->actingAs($user) + ->get('/admin/clients') + ->assertOk() + ->assertSee('站点接入') + ->assertSee('Demo Blog') + ->assertSee('https://demo.example.com') + ->assertSee('用户数') + ->assertSee('在线'); + } + + public function test_clients_page_can_filter_offline_sites(): void + { + $user = User::factory()->create(); + + StoreClient::query()->create([ + 'site_url' => 'https://online.example.com', + 'site_name' => 'Online', + 'access_token' => str_repeat('b', 96), + 'status' => 'online', + 'user_count' => 3, + 'registered_at' => now()->subHours(2), + 'last_seen_at' => now(), + ]); + + StoreClient::query()->create([ + 'site_url' => 'https://offline.example.com', + 'site_name' => 'Offline', + 'access_token' => str_repeat('c', 96), + 'status' => 'online', + 'user_count' => 5, + 'registered_at' => now()->subHours(5), + 'last_seen_at' => now()->subHour(), + ]); + + $this->actingAs($user) + ->get('/admin/clients?status=offline') + ->assertOk() + ->assertSee('Offline') + ->assertDontSee('Online'); + } +} diff --git a/tests/Feature/WebAdminPackageCategoriesTest.php b/tests/Feature/WebAdminPackageCategoriesTest.php new file mode 100644 index 0000000..52b5190 --- /dev/null +++ b/tests/Feature/WebAdminPackageCategoriesTest.php @@ -0,0 +1,111 @@ +create(); + + $package = Package::query()->create([ + 'type' => 'plugin', + 'slug' => 'HelloStore', + 'name' => 'Hello Store', + 'summary' => '', + 'description' => '', + 'author' => '', + 'homepage' => '', + 'icon_url' => '', + 'license' => '', + 'status' => 'published', + 'is_featured' => false, + 'sort_order' => 0, + 'download_count' => 0, + 'latest_version' => '', + ]); + + $this->actingAs($user) + ->put('/admin/packages/plugin/HelloStore', [ + 'type' => 'plugin', + 'slug' => 'HelloStore', + 'name' => 'Hello Store', + 'status' => 'published', + 'categories_text' => 'SEO performance_cache', + ]) + ->assertRedirect('/admin/packages/plugin/HelloStore') + ->assertSessionHas('success', '扩展已更新'); + + $package->refresh()->load('categories'); + + $this->assertSame(['performance-cache', 'seo'], $package->categories->pluck('slug')->sort()->values()->all()); + + $this->assertDatabaseHas('store_categories', [ + 'type' => 'plugin', + 'slug' => 'seo', + 'name' => 'Seo', + ]); + + $this->assertDatabaseHas('store_categories', [ + 'type' => 'plugin', + 'slug' => 'performance-cache', + 'name' => 'Performance Cache', + ]); + } + + public function test_admin_can_update_package_categories_with_chinese_names(): void + { + $user = User::factory()->create(); + + $package = Package::query()->create([ + 'type' => 'plugin', + 'slug' => 'HelloWorld', + 'name' => 'Hello World', + 'summary' => '', + 'description' => '', + 'author' => '', + 'homepage' => '', + 'icon_url' => '', + 'license' => '', + 'status' => 'published', + 'is_featured' => false, + 'sort_order' => 0, + 'download_count' => 0, + 'latest_version' => '', + ]); + + $this->actingAs($user) + ->put('/admin/packages/plugin/HelloWorld', [ + 'type' => 'plugin', + 'slug' => 'HelloWorld', + 'name' => 'Hello World', + 'status' => 'published', + 'categories_text' => '性能优化, 内容增强', + ]) + ->assertRedirect('/admin/packages/plugin/HelloWorld') + ->assertSessionHas('success', '扩展已更新'); + + $package->refresh()->load('categories'); + + $this->assertSame(['内容增强', '性能优化'], $package->categories->pluck('name')->sort()->values()->all()); + + $this->assertDatabaseHas('store_categories', [ + 'type' => 'plugin', + 'slug' => '性能优化', + 'name' => '性能优化', + ]); + + $this->assertDatabaseHas('store_categories', [ + 'type' => 'plugin', + 'slug' => '内容增强', + 'name' => '内容增强', + ]); + } +} diff --git a/tests/Feature/WebAdminPackageDeletionTest.php b/tests/Feature/WebAdminPackageDeletionTest.php new file mode 100644 index 0000000..92aec3a --- /dev/null +++ b/tests/Feature/WebAdminPackageDeletionTest.php @@ -0,0 +1,94 @@ +create(); + + $package = Package::query()->create([ + 'type' => 'plugin', + 'slug' => 'DeleteMe', + 'name' => 'Delete Me', + 'summary' => '', + 'description' => '', + 'author' => '', + 'homepage' => '', + 'icon_url' => '', + 'license' => '', + 'status' => 'published', + 'is_featured' => false, + 'sort_order' => 0, + 'download_count' => 0, + 'latest_version' => '1.0.0', + ]); + + $category = Category::query()->create([ + 'type' => 'plugin', + 'slug' => 'utility', + 'name' => 'Utility', + 'description' => '', + 'sort_order' => 0, + ]); + + $package->categories()->attach($category->id); + + $version = $package->versions()->create([ + 'version' => '1.0.0', + 'changelog' => '', + 'typecho_min' => '1.2.0', + 'typecho_max' => '', + 'php_min' => '8.0', + 'php_max' => '', + 'php_extensions' => '[]', + 'package_url' => 'https://cdn.example.com/DeleteMe-1.0.0.zip', + 'package_size' => 1234, + 'sha256' => str_repeat('b', 64), + 'is_stable' => true, + 'is_latest' => true, + 'download_count' => 0, + 'published_at' => now(), + ]); + + $screenshot = $package->screenshots()->create([ + 'image_url' => 'https://cdn.example.com/DeleteMe-home.webp', + 'caption' => 'Home', + 'sort_order' => 1, + ]); + + $packageDir = storage_path('app/packages/plugin/DeleteMe'); + File::ensureDirectoryExists($packageDir); + File::put($packageDir . '/1.0.0.zip', 'demo'); + + $this->actingAs($user) + ->delete('/admin/packages/plugin/DeleteMe') + ->assertRedirect('/admin/packages?type=plugin') + ->assertSessionHas('success', '扩展已删除:Delete Me'); + + $this->assertDatabaseMissing('store_packages', [ + 'id' => $package->id, + ]); + $this->assertDatabaseMissing('store_versions', [ + 'id' => $version->id, + ]); + $this->assertDatabaseMissing('store_screenshots', [ + 'id' => $screenshot->id, + ]); + $this->assertDatabaseMissing('store_package_categories', [ + 'package_id' => $package->id, + 'category_id' => $category->id, + ]); + $this->assertFalse(File::isDirectory($packageDir)); + } +} diff --git a/tests/Feature/WebAdminPackageScreenshotsTest.php b/tests/Feature/WebAdminPackageScreenshotsTest.php new file mode 100644 index 0000000..5516483 --- /dev/null +++ b/tests/Feature/WebAdminPackageScreenshotsTest.php @@ -0,0 +1,125 @@ +create(); + + $package = Package::query()->create([ + 'type' => 'theme', + 'slug' => 'Mango', + 'name' => 'Mango', + 'summary' => '', + 'description' => '', + 'author' => '', + 'homepage' => '', + 'icon_url' => '', + 'license' => '', + 'status' => 'published', + 'is_featured' => false, + 'sort_order' => 0, + 'download_count' => 0, + 'latest_version' => '', + ]); + + $this->actingAs($user) + ->post('/admin/packages/theme/Mango/screenshots', [ + 'image_url' => 'https://cdn.example.com/mango/home.webp', + 'caption' => '首页预览', + 'sort_order' => 3, + ]) + ->assertRedirect('/admin/packages/theme/Mango') + ->assertSessionHas('success', '截图已添加'); + + $this->assertDatabaseHas('store_screenshots', [ + 'package_id' => $package->id, + 'image_url' => 'https://cdn.example.com/mango/home.webp', + 'caption' => '首页预览', + 'sort_order' => 3, + ]); + + $shotId = (int) $package->screenshots()->value('id'); + + $this->actingAs($user) + ->delete('/admin/packages/theme/Mango/screenshots/' . $shotId) + ->assertRedirect('/admin/packages/theme/Mango') + ->assertSessionHas('success', '截图已删除'); + + $this->assertDatabaseMissing('store_screenshots', [ + 'id' => $shotId, + ]); + } + + public function test_storefront_detail_page_uses_screenshot_preview_section_instead_of_related_cards(): void + { + $package = Package::query()->create([ + 'type' => 'theme', + 'slug' => 'Mango', + 'name' => 'Mango', + 'summary' => 'A clean theme.', + 'description' => 'Theme description.', + 'author' => 'Team Store', + 'homepage' => 'https://example.com/mango', + 'icon_url' => '', + 'license' => 'MIT', + 'status' => 'published', + 'is_featured' => false, + 'sort_order' => 0, + 'download_count' => 12, + 'latest_version' => '1.5.0', + ]); + + $package->screenshots()->createMany([ + [ + 'image_url' => 'https://cdn.example.com/mango/hero.webp', + 'caption' => '首页展示', + 'sort_order' => 1, + ], + [ + 'image_url' => 'https://cdn.example.com/mango/post.webp', + 'caption' => '文章页展示', + 'sort_order' => 2, + ], + ]); + + $this->get('/theme/Mango') + ->assertOk() + ->assertSee('预览截图') + ->assertSee('首页展示') + ->assertSee('文章页展示') + ->assertDontSee('主题继续浏览'); + } + + public function test_legacy_package_detail_route_redirects_to_short_theme_route(): void + { + $package = Package::query()->create([ + 'type' => 'theme', + 'slug' => 'Mango', + 'name' => 'Mango', + 'summary' => '', + 'description' => '', + 'author' => '', + 'homepage' => '', + 'icon_url' => '', + 'license' => '', + 'status' => 'published', + 'is_featured' => false, + 'sort_order' => 0, + 'download_count' => 0, + 'latest_version' => '1.5.0', + ]); + + $this->get('/packages/theme/' . $package->slug) + ->assertRedirect('/theme/' . $package->slug); + } +} diff --git a/tests/Feature/WebAdminSettingsPageTest.php b/tests/Feature/WebAdminSettingsPageTest.php new file mode 100644 index 0000000..ef5bcd9 --- /dev/null +++ b/tests/Feature/WebAdminSettingsPageTest.php @@ -0,0 +1,56 @@ +create(); + + $payload = [ + 'site_name' => 'Team Store', + 'site_tagline' => 'Custom Extension Catalog', + 'home_title_suffix' => '自定义副标题', + 'home_eyebrow' => 'Custom Eyebrow', + 'home_headline' => '自定义首页标题', + 'home_lede' => '这里是自定义首页说明。', + 'home_aside_kicker' => 'Custom Aside', + 'home_aside_title' => '自定义侧栏标题', + 'home_feature_one_title' => '卡片一', + 'home_feature_one_body' => '卡片一说明', + 'home_feature_two_title' => '卡片二', + 'home_feature_two_body' => '卡片二说明', + 'home_feature_three_title' => '卡片三', + 'home_feature_three_body' => '卡片三说明', + 'home_plugins_title' => '插件精选', + 'home_plugins_subtitle' => '插件区域说明', + 'home_themes_title' => '主题精选', + 'home_themes_subtitle' => '主题区域说明', + ]; + + $this->actingAs($user) + ->put('/admin/settings', $payload) + ->assertRedirect('/admin/settings') + ->assertSessionHas('success', '站点设置已更新'); + + $this->assertDatabaseHas('store_settings', [ + 'key' => 'site_name', + 'value' => 'Team Store', + ]); + + $this->get('/') + ->assertOk() + ->assertSee('Team Store') + ->assertSee('自定义首页标题') + ->assertSee('插件精选') + ->assertSee('主题精选') + ->assertSee('Team Store · 自定义副标题', false); + } +}