前端极简黑白重设计,并纳入客户端注册、站点设置与后台相关改动
- storefront 全站改为极简黑白配色:直角、细线分隔、卡片 hover 浅底、深色模式(跟随系统+手动切换) - 抽出 storefront/partials/card 复用卡片 - 一并提交此前暂存的客户端注册 API、站点设置、后台页面与测试 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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 相关配置
|
||||
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\StoreClient;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class ClientController extends Controller
|
||||
{
|
||||
public function register(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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', '账户资料已更新');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\WebAdmin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\StoreSettings;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Throwable;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class SettingsController extends Controller
|
||||
{
|
||||
public function __construct(private readonly StoreSettings $settings)
|
||||
{
|
||||
}
|
||||
|
||||
public function show(): View
|
||||
{
|
||||
return view('admin.settings', [
|
||||
'settings' => $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', '站点设置已更新');
|
||||
}
|
||||
}
|
||||
@@ -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'],
|
||||
];
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class StoreClient extends Model
|
||||
{
|
||||
protected $table = 'store_clients';
|
||||
|
||||
protected $fillable = [
|
||||
'site_url',
|
||||
'site_name',
|
||||
'access_token',
|
||||
'status',
|
||||
'user_count',
|
||||
'typecho_version',
|
||||
'php_version',
|
||||
'plugin_version',
|
||||
'last_ip',
|
||||
'last_user_agent',
|
||||
'registered_at',
|
||||
'last_seen_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'user_count' => '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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class StoreSetting extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'key',
|
||||
'value',
|
||||
];
|
||||
}
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Services\StoreSettings;
|
||||
use Illuminate\Support\Facades\View;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
@@ -19,6 +21,14 @@ class AppServiceProvider extends ServiceProvider
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
//
|
||||
View::composer('*', function ($view) {
|
||||
$settings = app(StoreSettings::class)->all();
|
||||
|
||||
if (!empty($settings['site_name'])) {
|
||||
config(['app.name' => $settings['site_name']]);
|
||||
}
|
||||
|
||||
$view->with('storeSettings', $settings);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\StoreSetting;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
class StoreSettings
|
||||
{
|
||||
private ?array $resolved = null;
|
||||
|
||||
public function all(): array
|
||||
{
|
||||
if ($this->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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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' => '主题列表保持同样的展示逻辑,方便在统一视觉下浏览不同类型的扩展内容。',
|
||||
],
|
||||
];
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration {
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('store_clients', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('store_settings', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('key', 120)->unique();
|
||||
$table->text('value')->default('');
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('store_settings');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
@extends('admin.layout', [
|
||||
'title' => 'Tstore Admin · 账户安全',
|
||||
'pageTitle' => '账户安全',
|
||||
'pageSubtitle' => '修改当前管理员的用户名、邮箱和登录密码。',
|
||||
'pageBadge' => 'Account',
|
||||
])
|
||||
|
||||
@section('content')
|
||||
<div class="split">
|
||||
<section class="panel soft">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h2>账户资料</h2>
|
||||
<p>这里可以直接修改当前登录管理员的名称和邮箱地址。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="post" action="{{ route('webadmin.account.profile') }}" class="grid">
|
||||
@csrf
|
||||
@method('PUT')
|
||||
|
||||
<div class="field">
|
||||
<label>用户名</label>
|
||||
<input class="input" name="name" value="{{ old('name', $user->name) }}" placeholder="输入管理员名称" required>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>邮箱</label>
|
||||
<input class="input" type="email" name="email" value="{{ old('email', $user->email) }}" placeholder="输入管理员邮箱" required>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button class="btn" type="submit">更新资料</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="kv-grid">
|
||||
<div class="kv-card">
|
||||
<span class="kv-label">当前邮箱</span>
|
||||
<span class="kv-value">{{ $user->email }}</span>
|
||||
</div>
|
||||
<div class="kv-card">
|
||||
<span class="kv-label">当前用户名</span>
|
||||
<span class="kv-value">{{ $user->name ?: '未设置' }}</span>
|
||||
</div>
|
||||
<div class="kv-card">
|
||||
<span class="kv-label">创建时间</span>
|
||||
<span class="kv-value">{{ optional($user->created_at)->format('Y-m-d H:i') ?: '-' }}</span>
|
||||
</div>
|
||||
<div class="kv-card">
|
||||
<span class="kv-label">最近更新</span>
|
||||
<span class="kv-value">{{ optional($user->updated_at)->format('Y-m-d H:i') ?: '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h2>修改密码</h2>
|
||||
<p>输入当前密码后设置新密码。为了避免误操作,新密码必须至少 8 位,并重复确认一次。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="post" action="{{ route('webadmin.account.password') }}" class="grid">
|
||||
@csrf
|
||||
@method('PUT')
|
||||
|
||||
<div class="field">
|
||||
<label>当前密码</label>
|
||||
<input class="input" type="password" name="current_password" placeholder="输入当前密码" required>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>新密码</label>
|
||||
<input class="input" type="password" name="new_password" placeholder="至少 8 位" required>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>确认新密码</label>
|
||||
<input class="input" type="password" name="new_password_confirmation" placeholder="再次输入新密码" required>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button class="btn" type="submit">更新密码</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -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')
|
||||
<style>
|
||||
.category-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:16px}
|
||||
.category-card{padding:18px;border-radius:24px;border:1px solid var(--line);background:linear-gradient(180deg,#fff 0,#f8fbfc 100%);box-shadow:var(--shadow-md)}
|
||||
.category-card-head{display:flex;justify-content:space-between;gap:14px;align-items:flex-start;margin-bottom:14px}
|
||||
.category-card-head h3{margin:0;font-size:18px;line-height:1.18}
|
||||
.category-card-meta{margin-top:6px;color:var(--ink-soft);font-size:13px}
|
||||
.category-card-form{display:grid;gap:12px}
|
||||
.category-card-form .form-grid{grid-template-columns:repeat(2,minmax(0,1fr))}
|
||||
.category-card-form textarea{min-height:86px}
|
||||
.category-card-actions{display:flex;justify-content:space-between;gap:10px;flex-wrap:wrap;align-items:center}
|
||||
.category-card-actions .form-actions{margin-top:0}
|
||||
@media (max-width:860px){.category-grid,.category-card-form .form-grid{grid-template-columns:1fr}}
|
||||
</style>
|
||||
@endpush
|
||||
|
||||
@section('content')
|
||||
<div class="grid">
|
||||
<div class="panel">
|
||||
<section class="stats">
|
||||
<div class="stat"><span class="label">当前结果</span><span class="value">{{ $categories->total() }}</span><span class="hint">符合筛选条件的分类</span></div>
|
||||
<div class="stat"><span class="label">当前类型</span><span class="value">{{ $currentType === '' ? '全部' : strtoupper($currentType) }}</span><span class="hint">正在查看的分类类型</span></div>
|
||||
<div class="stat"><span class="label">页码</span><span class="value">{{ $categories->currentPage() }}/{{ max($categories->lastPage(), 1) }}</span><span class="hint">分页浏览</span></div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h2>筛选与查询</h2>
|
||||
<p>{{ $listHint }}</p>
|
||||
<p>按类型和关键词快速收拢分类范围,避免后台列表过长。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="get" class="toolbar">
|
||||
<div class="filters">
|
||||
<div class="field">
|
||||
<label>类型</label>
|
||||
<select name="type" class="select">
|
||||
<option value="">全部</option>
|
||||
<option value="plugin" @selected($filters['type']==='plugin')>插件</option>
|
||||
<option value="theme" @selected($filters['type']==='theme')>主题</option>
|
||||
<option value="plugin" @selected($filters['type'] === 'plugin')>插件</option>
|
||||
<option value="theme" @selected($filters['type'] === 'theme')>主题</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field" style="min-width:280px">
|
||||
<div class="field" style="flex:1 1 280px">
|
||||
<label>关键词</label>
|
||||
<input class="input" type="text" name="keyword" value="{{ $filters['keyword'] }}" placeholder="搜索 name / slug / description">
|
||||
<input class="input" type="text" name="keyword" value="{{ $filters['keyword'] }}" placeholder="搜索名称、slug 或描述">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button class="btn" type="submit">筛选</button>
|
||||
<button class="btn" type="submit">应用筛选</button>
|
||||
<a class="btn secondary" href="{{ route('webadmin.categories') }}">重置</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="split">
|
||||
<div class="panel">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h2>{{ $entityLabel }}列表</h2>
|
||||
<p>支持就地编辑名称、slug、类型、排序与描述。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="list">
|
||||
@forelse ($categories as $category)
|
||||
<div class="row-card">
|
||||
<form method="post" action="{{ route('webadmin.categories.update', $category->id) }}" class="grid">
|
||||
@csrf @method('PUT')
|
||||
<div class="form-grid">
|
||||
<div class="field"><label>名称</label><input class="input" name="name" value="{{ $category->name }}" required></div>
|
||||
<div class="field"><label>Slug</label><input class="input" name="slug" value="{{ $category->slug }}" required></div>
|
||||
<div class="field"><label>类型</label>
|
||||
<select class="select" name="type">
|
||||
<option value="plugin" @selected($category->type==='plugin')>插件</option>
|
||||
<option value="theme" @selected($category->type==='theme')>主题</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field"><label>排序</label><input class="input" type="number" name="sort_order" value="{{ $category->sort_order }}"></div>
|
||||
</div>
|
||||
<div class="field"><label>描述</label><textarea name="description">{{ $category->description }}</textarea></div>
|
||||
<div class="tags">
|
||||
<span class="chip">关联扩展 {{ $category->packages_count }}</span>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button class="btn small" type="submit">保存分类</button>
|
||||
</div>
|
||||
</form>
|
||||
<div class="form-actions">
|
||||
<form method="post" action="{{ route('webadmin.categories.destroy', $category->id) }}" onsubmit="return confirm('确认删除这个分类吗?');">
|
||||
@csrf @method('DELETE')
|
||||
<button class="btn danger small" type="submit">删除分类</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<div class="empty">当前还没有分类。</div>
|
||||
@endforelse
|
||||
</div>
|
||||
<div style="margin-top:14px">{{ $categories->links() }}</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="stack">
|
||||
<section class="panel soft">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h2>新建{{ $entityLabel }}</h2>
|
||||
<p>{{ $createHint }}</p>
|
||||
<p>先把常用分类建好,下面的小卡片再负责维护已有分类。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="post" action="{{ route('webadmin.categories.store') }}" class="grid">
|
||||
@csrf
|
||||
|
||||
<div class="form-grid">
|
||||
<div class="field">
|
||||
<label>类型</label>
|
||||
<select class="select" name="type" required>
|
||||
<option value="plugin" @selected($currentType === 'plugin')>插件</option>
|
||||
<option value="theme" @selected($currentType === 'theme')>主题</option>
|
||||
<option value="plugin" @selected(old('type', $currentType ?: 'plugin') === 'plugin')>插件</option>
|
||||
<option value="theme" @selected(old('type', $currentType) === 'theme')>主题</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>排序</label>
|
||||
<input class="input" name="sort_order" type="number" value="0">
|
||||
<input class="input" name="sort_order" type="number" value="{{ old('sort_order', 0) }}">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>名称</label>
|
||||
<input class="input" name="name" placeholder="SEO" required>
|
||||
<input class="input" name="name" value="{{ old('name') }}" placeholder="SEO" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Slug</label>
|
||||
<input class="input" name="slug" placeholder="seo" required>
|
||||
<input class="input" name="slug" value="{{ old('slug') }}" placeholder="seo" required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>描述</label>
|
||||
<textarea name="description" placeholder="补充说明这个分类的用途"></textarea>
|
||||
<textarea name="description" placeholder="补充这个分类主要归类哪些扩展">{{ old('description') }}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button class="btn" type="submit">创建{{ $entityLabel }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h2>{{ $entityLabel }}列表</h2>
|
||||
<p>改成更紧凑的小卡片,方便快速浏览并就地修改。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="category-grid">
|
||||
@forelse ($categories as $category)
|
||||
<article class="category-card">
|
||||
<div class="category-card-head">
|
||||
<div>
|
||||
<h3>{{ $category->name }}</h3>
|
||||
<div class="category-card-meta">{{ $category->slug }}</div>
|
||||
</div>
|
||||
<div class="tags" style="margin-top:0">
|
||||
<span class="chip">{{ $typeLabels[$category->type] ?? $category->type }}</span>
|
||||
<span class="chip muted">关联扩展 {{ $category->packages_count }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="post" action="{{ route('webadmin.categories.update', $category->id) }}" class="category-card-form" id="category-update-{{ $category->id }}">
|
||||
@csrf
|
||||
@method('PUT')
|
||||
|
||||
<div class="form-grid">
|
||||
<div class="field">
|
||||
<label>名称</label>
|
||||
<input class="input" name="name" value="{{ $category->name }}" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Slug</label>
|
||||
<input class="input" name="slug" value="{{ $category->slug }}" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>类型</label>
|
||||
<select class="select" name="type">
|
||||
<option value="plugin" @selected($category->type === 'plugin')>插件</option>
|
||||
<option value="theme" @selected($category->type === 'theme')>主题</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>排序</label>
|
||||
<input class="input" type="number" name="sort_order" value="{{ $category->sort_order }}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>描述</label>
|
||||
<textarea name="description">{{ $category->description }}</textarea>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
<div class="category-card-actions">
|
||||
<div class="form-actions">
|
||||
<button class="btn small" type="submit" form="category-update-{{ $category->id }}">保存分类</button>
|
||||
</div>
|
||||
<form method="post" action="{{ route('webadmin.categories.destroy', $category->id) }}" onsubmit="return confirm('确认删除这个分类吗?');">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button class="btn danger small" type="submit">删除分类</button>
|
||||
</form>
|
||||
</div>
|
||||
</article>
|
||||
@empty
|
||||
<div class="empty">当前还没有分类。</div>
|
||||
@endforelse
|
||||
</div>
|
||||
|
||||
{{ $categories->links() }}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@@ -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')
|
||||
<div class="grid">
|
||||
<section class="stats">
|
||||
<div class="stat"><span class="label">已注册站点</span><span class="value">{{ $stats['registered_sites'] }}</span><span class="hint">完成 token 注册</span></div>
|
||||
<div class="stat"><span class="label">在线站点</span><span class="value">{{ $stats['online_sites'] }}</span><span class="hint">{{ $onlineWindowMinutes }} 分钟内有心跳</span></div>
|
||||
<div class="stat"><span class="label">离线站点</span><span class="value">{{ max($stats['registered_sites'] - $stats['online_sites'], 0) }}</span><span class="hint">超出在线窗口</span></div>
|
||||
<div class="stat"><span class="label">追踪用户数</span><span class="value">{{ $stats['tracked_users'] }}</span><span class="hint">所有站点上报总和</span></div>
|
||||
<div class="stat"><span class="label">当前筛选</span><span class="value">{{ $statusLabel }}</span><span class="hint">站点状态过滤</span></div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="toolbar">
|
||||
<div class="section-title" style="margin-bottom:0">
|
||||
<div>
|
||||
<h2>站点列表</h2>
|
||||
<p>插件端注册成功后会出现在这里,之后每次状态同步都会刷新最近心跳和用户数。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="get" class="filters">
|
||||
<div class="field">
|
||||
<label>状态</label>
|
||||
<select class="select" name="status">
|
||||
<option value="">全部</option>
|
||||
<option value="online" @selected($filters['status'] === 'online')>在线</option>
|
||||
<option value="offline" @selected($filters['status'] === 'offline')>离线</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field" style="flex:1 1 260px">
|
||||
<label>搜索</label>
|
||||
<input class="input" type="text" name="keyword" value="{{ $filters['keyword'] }}" placeholder="站点名、URL、Typecho 或 PHP 版本">
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button class="btn" type="submit">应用筛选</button>
|
||||
<a class="btn secondary" href="{{ route('webadmin.clients') }}">重置</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="table-wrap">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>站点</th>
|
||||
<th>状态</th>
|
||||
<th>用户数</th>
|
||||
<th>环境</th>
|
||||
<th>注册时间</th>
|
||||
<th>最近同步</th>
|
||||
<th>Token 尾缀</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@forelse ($clients as $client)
|
||||
<tr>
|
||||
<td>
|
||||
<strong>{{ $client->site_name ?: '未命名站点' }}</strong>
|
||||
<div class="muted">{{ $client->site_url }}</div>
|
||||
</td>
|
||||
<td><span class="chip {{ $client->is_online ? 'ok' : 'muted' }}">{{ $client->is_online ? '在线' : '离线' }}</span></td>
|
||||
<td>{{ $client->user_count }}</td>
|
||||
<td>
|
||||
<div>Typecho {{ $client->typecho_version ?: '-' }}</div>
|
||||
<div class="muted">PHP {{ $client->php_version ?: '-' }} · 插件 {{ $client->plugin_version ?: '-' }}</div>
|
||||
</td>
|
||||
<td>{{ optional($client->registered_at)->format('Y-m-d H:i:s') ?: '-' }}</td>
|
||||
<td>{{ optional($client->last_seen_at)->format('Y-m-d H:i:s') ?: '尚未同步' }}</td>
|
||||
<td><code>...{{ $client->token_suffix ?: '--------' }}</code></td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="7">
|
||||
<div class="empty">当前还没有站点完成注册。</div>
|
||||
</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{{ $clients->links() }}
|
||||
</section>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -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')
|
||||
<div class="grid">
|
||||
<div class="stats">
|
||||
<div class="stat"><span class="label">扩展总数</span><span class="value">{{ $stats['packages'] }}</span><span class="hint">插件 + 主题</span></div>
|
||||
<div class="stat"><span class="label">插件</span><span class="value">{{ $stats['plugins'] }}</span><span class="hint">plugin</span></div>
|
||||
<div class="stat"><span class="label">主题</span><span class="value">{{ $stats['themes'] }}</span><span class="hint">theme</span></div>
|
||||
<div class="stat"><span class="label">版本记录</span><span class="value">{{ $stats['versions'] }}</span><span class="hint">已发布与手动录入版本</span></div>
|
||||
<section class="stats">
|
||||
<div class="stat"><span class="label">扩展总数</span><span class="value">{{ $stats['packages'] }}</span><span class="hint">插件与主题总量</span></div>
|
||||
<div class="stat"><span class="label">插件</span><span class="value">{{ $stats['plugins'] }}</span><span class="hint">plugin package</span></div>
|
||||
<div class="stat"><span class="label">主题</span><span class="value">{{ $stats['themes'] }}</span><span class="hint">theme package</span></div>
|
||||
<div class="stat"><span class="label">版本记录</span><span class="value">{{ $stats['versions'] }}</span><span class="hint">历史发布与手动录入</span></div>
|
||||
<div class="stat"><span class="label">下载总量</span><span class="value">{{ $stats['downloads'] }}</span><span class="hint">累计下载次数</span></div>
|
||||
<div class="stat"><span class="label">分类数</span><span class="value">{{ $stats['categories'] }}</span><span class="hint">插件 / 主题分类</span></div>
|
||||
</div>
|
||||
<div class="stat"><span class="label">分类总数</span><span class="value">{{ $stats['categories'] }}</span><span class="hint">插件与主题分类</span></div>
|
||||
<div class="stat"><span class="label">已注册站点</span><span class="value">{{ $stats['registered_sites'] }}</span><span class="hint">完成注册的客户端</span></div>
|
||||
<div class="stat"><span class="label">在线站点</span><span class="value">{{ $stats['online_sites'] }}</span><span class="hint">最近心跳仍在线</span></div>
|
||||
<div class="stat"><span class="label">追踪用户数</span><span class="value">{{ $stats['tracked_users'] }}</span><span class="hint">所有站点上报总和</span></div>
|
||||
</section>
|
||||
|
||||
<div class="cards">
|
||||
<div class="panel">
|
||||
<div class="split">
|
||||
<section class="panel">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h2>最近更新的扩展</h2>
|
||||
<p>可以直接进入详情页编辑信息、上传 zip,或查看当前最新稳定版本。</p>
|
||||
<p>这里优先看扩展元数据、分类和当前发布状态,方便继续进入详情页维护。</p>
|
||||
</div>
|
||||
<a class="btn secondary small" href="{{ route('webadmin.packages') }}">查看全部</a>
|
||||
</div>
|
||||
|
||||
<div class="list">
|
||||
@forelse ($recentPackages as $package)
|
||||
<div class="row-card">
|
||||
<article class="row-card">
|
||||
<div class="row-top">
|
||||
<div>
|
||||
<h3>{{ $package->name }}</h3>
|
||||
<p class="muted">{{ $package->type }} · {{ $package->slug }}</p>
|
||||
</div>
|
||||
<span class="chip {{ $package->status === 'published' ? 'ok' : ($package->status === 'draft' ? 'muted' : 'warn') }}">{{ $package->status }}</span>
|
||||
<span class="chip {{ $statusTones[$package->status] ?? 'muted' }}">{{ $statusLabels[$package->status] ?? $package->status }}</span>
|
||||
</div>
|
||||
<p class="muted">{{ $package->summary ?: '暂无摘要' }}</p>
|
||||
<p class="muted">{{ $package->summary ?: '暂无摘要。' }}</p>
|
||||
<div class="tags">
|
||||
@foreach ($package->categories as $category)
|
||||
<span class="chip">{{ $category->name }}</span>
|
||||
@endforeach
|
||||
@if ($package->latestStableVersion)
|
||||
<span class="chip ok">最新稳定版 v{{ $package->latestStableVersion->version }}</span>
|
||||
<span class="chip ok">稳定版 v{{ $package->latestStableVersion->version }}</span>
|
||||
@endif
|
||||
<span class="chip muted">更新于 {{ optional($package->updated_at)->format('Y-m-d H:i') ?: '-' }}</span>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<a class="btn secondary small" href="{{ route('webadmin.packages.show', [$package->type, $package->slug]) }}">查看详情</a>
|
||||
<a class="btn ghost small" href="{{ route('webadmin.packages.show', [$package->type, $package->slug]) }}">查看详情</a>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
@empty
|
||||
<div class="empty">当前还没有扩展数据。</div>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="panel">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h2>当前后台能力</h2>
|
||||
<p>这一版已经能覆盖联调闭环里最关键的管理动作。</p>
|
||||
<div class="stack">
|
||||
<section class="panel soft">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h2>最近版本发布</h2>
|
||||
<p>快速核对最近写入的版本号、兼容区间和稳定状态。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="list">
|
||||
<div class="mini-card"><h3>扩展管理</h3><div class="muted">支持创建 package、编辑基础信息、切换发布状态。</div></div>
|
||||
<div class="mini-card"><h3>分类管理</h3><div class="muted">支持新建、编辑、删除分类,并查看分类关联的扩展数量。</div></div>
|
||||
<div class="mini-card"><h3>版本管理</h3><div class="muted">支持手动录入版本、zip 上传发布、删除历史版本。</div></div>
|
||||
<div class="mini-card"><h3>下一步建议</h3><div class="muted">可以继续补截图管理、审核日志、下载统计细分和更严格的发布校验。</div></div>
|
||||
</div>
|
||||
|
||||
<div class="list">
|
||||
@forelse ($recentVersions as $version)
|
||||
<article class="row-card">
|
||||
<div class="row-top">
|
||||
<div>
|
||||
<h3>v{{ $version->version }}</h3>
|
||||
<p class="muted">{{ $version->package?->name ?: '未知扩展' }}</p>
|
||||
</div>
|
||||
<div class="tags" style="margin-top:0">
|
||||
<span class="chip {{ $version->is_stable ? 'ok' : 'warn' }}">{{ $version->is_stable ? '稳定版' : '预发布' }}</span>
|
||||
@if ($version->is_latest)
|
||||
<span class="chip">最新</span>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
<div class="tags">
|
||||
<span class="chip muted">Typecho {{ $version->typecho_min ?: '-' }} ~ {{ $version->typecho_max ?: '-' }}</span>
|
||||
<span class="chip muted">PHP {{ $version->php_min ?: '-' }} ~ {{ $version->php_max ?: '-' }}</span>
|
||||
</div>
|
||||
<p class="muted">发布时间:{{ optional($version->published_at)->format('Y-m-d H:i') ?: '-' }}</p>
|
||||
</article>
|
||||
@empty
|
||||
<div class="empty">当前还没有版本记录。</div>
|
||||
@endforelse
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel soft">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h2>最近接入站点</h2>
|
||||
<p>确认插件端是否完成注册、是否在线,以及最近一次同步时间。</p>
|
||||
</div>
|
||||
<a class="btn secondary small" href="{{ route('webadmin.clients') }}">全部站点</a>
|
||||
</div>
|
||||
|
||||
<div class="list">
|
||||
@forelse ($recentClients as $client)
|
||||
<article class="row-card">
|
||||
<div class="row-top">
|
||||
<div>
|
||||
<h3>{{ $client->site_name ?: '未命名站点' }}</h3>
|
||||
<p class="muted">{{ $client->site_url }}</p>
|
||||
</div>
|
||||
<span class="chip {{ $client->is_online ? 'ok' : 'muted' }}">{{ $client->is_online ? '在线' : '离线' }}</span>
|
||||
</div>
|
||||
<div class="tags">
|
||||
<span class="chip">用户 {{ $client->user_count }}</span>
|
||||
<span class="chip muted">Typecho {{ $client->typecho_version ?: '-' }}</span>
|
||||
<span class="chip muted">PHP {{ $client->php_version ?: '-' }}</span>
|
||||
</div>
|
||||
<p class="muted">最近同步:{{ optional($client->last_seen_at)->format('Y-m-d H:i:s') ?: '尚未同步' }}</p>
|
||||
</article>
|
||||
@empty
|
||||
<div class="empty">当前还没有站点完成注册。</div>
|
||||
@endforelse
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
@@ -5,71 +13,298 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{ $title ?? 'Tstore Admin' }}</title>
|
||||
<style>
|
||||
:root{--bg:#f3f7fb;--panel:#ffffff;--panel-2:#f7fbff;--text:#16324f;--muted:#607489;--line:#dce6f1;--brand:#2f6ea6;--brand-2:#4f90c8;--ok:#1f8b4c;--warn:#b36b00;--danger:#c14f4f;--shadow:0 12px 30px rgba(16,36,64,.08)}
|
||||
*{box-sizing:border-box}body{margin:0;background:linear-gradient(180deg,#f6f9fc 0,#eef4f9 100%);color:var(--text);font:14px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif}
|
||||
a{color:inherit}.page{display:grid;grid-template-columns:260px minmax(0,1fr);min-height:100vh}
|
||||
.sidebar{background:linear-gradient(180deg,#16324f 0,#1d456b 100%);color:#fff;padding:24px 18px;position:sticky;top:0;height:100vh}
|
||||
.brand{font-size:22px;font-weight:800;letter-spacing:.02em;margin-bottom:6px}.brand-sub{font-size:12px;color:rgba(230,240,255,.72);margin-bottom:24px}
|
||||
.nav{display:grid;gap:10px}.nav a{display:flex;align-items:center;padding:11px 14px;border-radius:14px;text-decoration:none;color:rgba(241,247,255,.9);font-weight:700;background:transparent;border:1px solid transparent}.nav a.active,.nav a:hover{background:rgba(255,255,255,.10);border-color:rgba(255,255,255,.08)}
|
||||
.sidebar-foot{position:absolute;left:18px;right:18px;bottom:20px;background:rgba(255,255,255,.08);border:1px solid rgba(255,255,255,.08);border-radius:16px;padding:14px}.sidebar-foot p{margin:0;color:rgba(235,243,255,.84);font-size:12px;line-height:1.7}.sidebar-foot .muted-mini{display:block;margin-top:8px;color:rgba(224,236,251,.68);font-size:11px}
|
||||
.content{padding:28px}.topbar{display:flex;justify-content:space-between;gap:16px;align-items:center;margin-bottom:20px}.title h1{margin:0 0 4px;font-size:28px;line-height:1.15}.title p{margin:0;color:var(--muted)}
|
||||
.top-actions{display:flex;gap:10px;flex-wrap:wrap}.btn,.btn-link button{display:inline-flex;align-items:center;justify-content:center;gap:8px;padding:10px 14px;border-radius:12px;border:1px solid transparent;background:linear-gradient(135deg,var(--brand),var(--brand-2));color:#fff;text-decoration:none;font-weight:700;cursor:pointer;box-shadow:0 10px 22px rgba(47,110,166,.18)}
|
||||
.btn.secondary{background:#fff;color:var(--brand);border-color:#cfe0f1;box-shadow:none}.btn.small,.btn-link button.small{padding:8px 12px;font-size:12px;border-radius:10px}.btn.ghost,.btn-link button.ghost{background:#fff;border-color:var(--line);color:var(--text);box-shadow:none}.btn.danger,.btn-link button.danger{background:#fff2f2;border-color:#f1cccc;color:var(--danger);box-shadow:none}
|
||||
.btn-link{display:inline}.btn-link form{display:inline}
|
||||
.flash{margin-bottom:16px;padding:12px 14px;border-radius:14px;font-weight:700}.flash.success{background:#edf9f0;color:var(--ok);border:1px solid #cfead8}.flash.error{background:#fff4f3;color:var(--danger);border:1px solid #f1d2cf}
|
||||
.panel{background:rgba(255,255,255,.92);border:1px solid var(--line);border-radius:22px;padding:18px;box-shadow:var(--shadow)}
|
||||
.grid{display:grid;gap:18px}.stats{display:grid;grid-template-columns:repeat(6,minmax(0,1fr));gap:16px}.stat{background:linear-gradient(180deg,#fff 0,#f9fbfd 100%);border:1px solid var(--line);border-radius:20px;padding:18px}.stat .label{display:block;font-size:12px;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;margin-bottom:8px}.stat .value{display:block;font-size:28px;font-weight:800;line-height:1.1}.stat .hint{display:block;margin-top:8px;color:var(--muted);font-size:12px}
|
||||
.cards{display:grid;grid-template-columns:1.2fr .8fr;gap:18px}.section-title{display:flex;justify-content:space-between;align-items:center;gap:12px;margin-bottom:14px}.section-title h2{margin:0;font-size:18px}.section-title p{margin:0;color:var(--muted);font-size:13px}
|
||||
.table-wrap{overflow:auto;border:1px solid var(--line);border-radius:18px;background:#fff}.table{width:100%;border-collapse:separate;border-spacing:0}.table th,.table td{padding:14px 16px;border-bottom:1px solid #ecf1f6;text-align:left;vertical-align:top}.table th{font-size:12px;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;background:#f8fbfd}.table tr:hover td{background:#fbfdff}
|
||||
.chip{display:inline-flex;align-items:center;padding:4px 10px;border-radius:999px;font-size:12px;font-weight:700;background:#edf4fb;color:#2d689d;border:1px solid #d8e7f5}.chip.ok{background:#edf9f0;color:var(--ok);border-color:#cfead8}.chip.warn{background:#fff8eb;color:var(--warn);border-color:#f2dfb1}.chip.muted{background:#f3f6f9;color:#667a90;border-color:#e0e7ef}
|
||||
.stack{display:grid;gap:14px}.mini-card{border:1px solid var(--line);border-radius:18px;padding:16px;background:linear-gradient(180deg,#fff 0,#fbfdff 100%)}.mini-card h3{margin:0 0 6px;font-size:16px}.muted{color:var(--muted)}
|
||||
.toolbar{display:flex;justify-content:space-between;gap:12px;align-items:flex-end;flex-wrap:wrap;margin-bottom:16px}.filters{display:flex;gap:10px;flex-wrap:wrap}.field{display:grid;gap:6px}.field label{font-size:12px;color:var(--muted);font-weight:700}.input,.select,textarea{width:100%;padding:11px 13px;border:1px solid #cfdae8;border-radius:12px;background:#f9fbfd;color:var(--text);outline:none}.input:focus,.select:focus,textarea:focus{border-color:#77aee6;box-shadow:0 0 0 4px rgba(89,156,226,.14);background:#fff}textarea{min-height:96px;resize:vertical}
|
||||
.form-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}.form-grid.full{grid-template-columns:1fr}.form-actions{display:flex;gap:10px;flex-wrap:wrap;margin-top:14px}
|
||||
.list{display:grid;gap:12px}.row-card{border:1px solid var(--line);border-radius:18px;padding:16px;background:linear-gradient(180deg,#fff 0,#fbfdff 100%)}.row-top{display:flex;justify-content:space-between;gap:16px;align-items:flex-start}.row-top h3{margin:0;font-size:18px}.row-top p{margin:6px 0 0}.tags{display:flex;gap:8px;flex-wrap:wrap;margin-top:10px}.split{display:grid;grid-template-columns:1.2fr .8fr;gap:18px}.empty{padding:36px 18px;border:1px dashed var(--line);border-radius:18px;text-align:center;background:#fbfdff;color:var(--muted)}
|
||||
@media (max-width:1200px){.stats{grid-template-columns:repeat(3,minmax(0,1fr))}.cards,.split{grid-template-columns:1fr}}@media (max-width:860px){.page{grid-template-columns:1fr}.sidebar{position:static;height:auto}.content{padding:18px}.stats{grid-template-columns:repeat(2,minmax(0,1fr))}.form-grid{grid-template-columns:1fr}}@media (max-width:560px){.stats{grid-template-columns:1fr}.topbar{flex-direction:column;align-items:flex-start}.filters{width:100%}.field{width:100%}}
|
||||
:root{--bg:#edf4f7;--bg-deep:#102637;--bg-mid:#174c66;--panel:#ffffff;--panel-soft:#f7fbfc;--line:#d6e3ea;--ink:#132736;--ink-soft:#607484;--accent:#1c7d85;--accent-deep:#0f5060;--accent-soft:#e6f4f5;--warm:#c49152;--ok:#1c8a56;--warn:#b87218;--danger:#c05050;--shadow-lg:0 28px 58px rgba(10,26,38,.12);--shadow-md:0 16px 34px rgba(13,29,40,.08)}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;min-height:100vh;background:radial-gradient(circle at top left,rgba(196,145,82,.14),transparent 24%),radial-gradient(circle at bottom right,rgba(28,125,133,.12),transparent 28%),linear-gradient(180deg,#f7fafb 0,#edf4f7 100%);color:var(--ink);font:14px/1.65 "Segoe UI Variable","PingFang SC","Microsoft YaHei UI",sans-serif}
|
||||
a{color:inherit;text-decoration:none}
|
||||
button,input,select,textarea{font:inherit}
|
||||
code{padding:2px 6px;border-radius:8px;background:#edf4f6;color:var(--accent-deep)}
|
||||
.page{display:grid;grid-template-columns:280px minmax(0,1fr);min-height:100vh}
|
||||
.sidebar{position:sticky;top:0;height:100vh;overflow:hidden;padding:24px 18px 22px;background:linear-gradient(180deg,#102536 0,#143b52 48%,#1c6f79 100%);color:#fff}
|
||||
.sidebar:before{content:'';position:absolute;inset:0;background:linear-gradient(rgba(255,255,255,.06) 1px,transparent 1px),linear-gradient(90deg,rgba(255,255,255,.05) 1px,transparent 1px);background-size:30px 30px;mask-image:linear-gradient(180deg,rgba(0,0,0,.38),transparent 82%)}
|
||||
.sidebar > *{position:relative;z-index:1}
|
||||
.sidebar-top{display:flex;align-items:flex-start;justify-content:space-between;gap:14px}
|
||||
.brand{display:flex;align-items:center;gap:14px;min-width:0}
|
||||
.brand-mark{display:grid;place-items:center;width:48px;height:48px;border-radius:16px;background:rgba(255,255,255,.12);border:1px solid rgba(255,255,255,.14);font-size:18px;font-weight:900;letter-spacing:.08em}
|
||||
.brand-copy strong{display:block;font-size:21px;line-height:1.1}
|
||||
.brand-copy span{display:block;margin-top:5px;color:rgba(232,240,244,.7);font-size:12px;letter-spacing:.16em;text-transform:uppercase}
|
||||
.sidebar-toggle{display:none;align-items:center;justify-content:center;min-height:42px;padding:0 14px;border-radius:14px;border:1px solid rgba(255,255,255,.16);background:rgba(255,255,255,.08);color:#fff;font-weight:900;cursor:pointer}
|
||||
.sidebar-body{display:block}
|
||||
.sidebar-group{margin-top:28px}
|
||||
.sidebar-label{display:block;margin-bottom:12px;color:rgba(228,238,243,.66);font-size:11px;font-weight:900;letter-spacing:.16em;text-transform:uppercase}
|
||||
.nav{display:grid;gap:8px}
|
||||
.nav a{display:flex;align-items:center;gap:10px;min-height:46px;padding:0 14px;border-radius:16px;color:rgba(240,246,249,.88);font-weight:800;border:1px solid transparent;background:rgba(255,255,255,.03);transition:background .18s ease,border-color .18s ease,transform .18s ease}
|
||||
.nav a:hover,.nav a.active{background:rgba(255,255,255,.12);border-color:rgba(255,255,255,.12);transform:translateX(2px)}
|
||||
.sidebar-meta{display:grid;gap:10px;margin-top:28px}
|
||||
.meta-card{padding:14px 16px;border-radius:18px;background:rgba(255,255,255,.08);border:1px solid rgba(255,255,255,.10)}
|
||||
.meta-label{display:block;font-size:11px;letter-spacing:.14em;text-transform:uppercase;color:rgba(233,240,244,.68)}
|
||||
.meta-value{display:block;margin-top:6px;font-size:16px;font-weight:900;line-height:1.35;word-break:break-word}
|
||||
.content-shell{min-width:0}
|
||||
.content-inner{width:min(1600px,100%);margin:0 auto;padding:30px 32px 42px}
|
||||
.topbar{display:flex;justify-content:space-between;gap:18px;align-items:flex-end;margin-bottom:22px}
|
||||
.page-badge{display:inline-flex;align-items:center;padding:6px 12px;border-radius:999px;background:var(--accent-soft);color:var(--accent-deep);font-size:11px;font-weight:900;letter-spacing:.16em;text-transform:uppercase}
|
||||
.headline-block h1{margin:14px 0 0;font-size:38px;line-height:1.02;letter-spacing:-.05em}
|
||||
.headline-block p{margin:10px 0 0;color:var(--ink-soft);max-width:760px}
|
||||
.top-actions{display:flex;gap:10px;flex-wrap:wrap}
|
||||
.btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;min-height:44px;padding:0 16px;border-radius:14px;border:1px solid transparent;background:linear-gradient(135deg,var(--accent-deep),var(--accent));color:#fff;font-weight:900;cursor:pointer;box-shadow:0 14px 28px rgba(11,79,91,.18)}
|
||||
.btn.secondary{background:#fff;color:var(--accent-deep);border-color:#d1e4e7;box-shadow:none}
|
||||
.btn.ghost{background:rgba(255,255,255,.76);color:var(--ink);border-color:var(--line);box-shadow:none}
|
||||
.btn.danger{background:#fff1f1;color:var(--danger);border-color:#f0cccc;box-shadow:none}
|
||||
.btn.small{min-height:38px;padding:0 12px;border-radius:12px;font-size:12px}
|
||||
.inline-form{display:inline}
|
||||
.flash-stack{display:grid;gap:12px;margin-bottom:18px}
|
||||
.flash{padding:12px 14px;border-radius:16px;border:1px solid var(--line);background:#fff;font-weight:800;box-shadow:var(--shadow-md)}
|
||||
.flash.success{background:#edf9f1;border-color:#d2eadb;color:var(--ok)}
|
||||
.flash.error{background:#fff4f3;border-color:#f1d5d2;color:var(--danger)}
|
||||
.grid{display:grid;gap:20px}
|
||||
.stack{display:grid;gap:20px}
|
||||
.split{display:grid;grid-template-columns:minmax(0,1.18fr) minmax(320px,.82fr);gap:20px}
|
||||
.panel{padding:22px;border-radius:28px;border:1px solid var(--line);background:linear-gradient(180deg,rgba(255,255,255,.96),rgba(247,251,252,.98));box-shadow:var(--shadow-lg)}
|
||||
.panel.soft{background:linear-gradient(180deg,#fbfdfe 0,#f3f8fa 100%)}
|
||||
.section-title{display:flex;justify-content:space-between;gap:14px;align-items:flex-end;margin-bottom:18px}
|
||||
.section-title h2{margin:0;font-size:24px;line-height:1.1;letter-spacing:-.03em}
|
||||
.section-title p{margin:8px 0 0;color:var(--ink-soft)}
|
||||
.stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:16px}
|
||||
.stat{position:relative;overflow:hidden;padding:18px;border-radius:22px;border:1px solid var(--line);background:linear-gradient(180deg,#fff 0,#f7fbfc 100%);box-shadow:var(--shadow-md)}
|
||||
.stat:after{content:'';position:absolute;right:-28px;top:-24px;width:96px;height:96px;border-radius:28px;background:linear-gradient(135deg,rgba(28,125,133,.12),rgba(28,125,133,0))}
|
||||
.stat .label{display:block;font-size:11px;font-weight:900;letter-spacing:.16em;text-transform:uppercase;color:var(--ink-soft)}
|
||||
.stat .value{display:block;margin-top:10px;font-size:30px;line-height:1.05;font-weight:900}
|
||||
.stat .hint{display:block;margin-top:8px;color:var(--ink-soft);font-size:13px}
|
||||
.table-wrap{overflow:auto;border:1px solid var(--line);border-radius:22px;background:#fff}
|
||||
.table{width:100%;border-collapse:separate;border-spacing:0}
|
||||
.table th,.table td{padding:15px 16px;border-bottom:1px solid #ebf1f4;text-align:left;vertical-align:top}
|
||||
.table th{background:#f7fafb;color:var(--ink-soft);font-size:11px;font-weight:900;letter-spacing:.14em;text-transform:uppercase}
|
||||
.table tr:last-child td{border-bottom:none}
|
||||
.table tr:hover td{background:#fbfdfe}
|
||||
.list{display:grid;gap:14px}
|
||||
.row-card{padding:18px;border-radius:22px;border:1px solid var(--line);background:linear-gradient(180deg,#fff 0,#f8fbfc 100%);box-shadow:var(--shadow-md)}
|
||||
.row-top{display:flex;justify-content:space-between;gap:16px;align-items:flex-start}
|
||||
.row-top h3{margin:0;font-size:18px;line-height:1.18}
|
||||
.row-top p{margin:6px 0 0}
|
||||
.muted{color:var(--ink-soft)}
|
||||
.tags{display:flex;gap:8px;flex-wrap:wrap;margin-top:12px}
|
||||
.chip{display:inline-flex;align-items:center;padding:6px 11px;border-radius:999px;border:1px solid #d7e7eb;background:#eef6f7;color:#215b68;font-size:12px;font-weight:900}
|
||||
.chip.ok{background:#edf9f1;border-color:#d2eadb;color:var(--ok)}
|
||||
.chip.warn{background:#fff7ea;border-color:#eed8b7;color:var(--warn)}
|
||||
.chip.muted{background:#f3f6f8;border-color:#e1e8ec;color:#667b8b}
|
||||
.toolbar{display:flex;justify-content:space-between;gap:14px;align-items:flex-end;flex-wrap:wrap}
|
||||
.filters{display:flex;gap:12px;flex-wrap:wrap;flex:1 1 auto}
|
||||
.field{display:grid;gap:7px}
|
||||
.field label{font-size:12px;font-weight:900;letter-spacing:.08em;text-transform:uppercase;color:var(--ink-soft)}
|
||||
.input,.select,textarea{width:100%;min-width:0;padding:12px 14px;border-radius:16px;border:1px solid #cfdee5;background:#fbfcfd;color:var(--ink);outline:none;transition:border-color .18s ease,box-shadow .18s ease,background .18s ease}
|
||||
.input:focus,.select:focus,textarea:focus{background:#fff;border-color:#72acb6;box-shadow:0 0 0 4px rgba(28,125,133,.12)}
|
||||
textarea{min-height:110px;resize:vertical}
|
||||
.form-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}
|
||||
.form-grid.compact{grid-template-columns:repeat(3,minmax(0,1fr))}
|
||||
.form-actions{display:flex;gap:10px;flex-wrap:wrap}
|
||||
.checkbox-row{display:flex;gap:14px;flex-wrap:wrap;align-items:center}
|
||||
.checkbox-row label{display:inline-flex;align-items:center;gap:8px;color:var(--ink);font-weight:700}
|
||||
.empty{padding:36px 18px;border-radius:22px;border:1px dashed #c8d7dd;background:#fbfdfe;color:var(--ink-soft);text-align:center}
|
||||
.kv-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}
|
||||
.kv-card{padding:16px;border-radius:18px;border:1px solid var(--line);background:#fbfdfe}
|
||||
.kv-label{display:block;font-size:11px;font-weight:900;letter-spacing:.14em;text-transform:uppercase;color:var(--ink-soft)}
|
||||
.kv-value{display:block;margin-top:6px;font-size:15px;font-weight:800;line-height:1.45;word-break:break-word}
|
||||
.entity-head{display:flex;gap:16px;align-items:flex-start;margin-bottom:18px}
|
||||
.entity-icon{display:grid;place-items:center;width:74px;height:74px;border-radius:24px;border:1px solid var(--line);background:linear-gradient(135deg,#eef6f7,#dce9ec);color:var(--accent-deep);font-size:24px;font-weight:900;overflow:hidden}
|
||||
.entity-icon img{width:100%;height:100%;object-fit:cover}
|
||||
.entity-copy h2{margin:0;font-size:24px;line-height:1.08}
|
||||
.entity-copy p{margin:10px 0 0;color:var(--ink-soft)}
|
||||
.preview-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}
|
||||
.preview-card{padding:10px;border-radius:18px;border:1px solid var(--line);background:#fff;box-shadow:var(--shadow-md)}
|
||||
.preview-shot{margin:0;border-radius:16px;overflow:hidden;border:1px solid var(--line);background:#fff}
|
||||
.preview-shot img{width:100%;height:124px;object-fit:cover}
|
||||
.preview-shot figcaption{padding:10px 12px;color:var(--ink-soft);font-size:13px}
|
||||
.preview-tools{display:flex;justify-content:space-between;gap:10px;align-items:center;flex-wrap:wrap;margin-top:10px}
|
||||
nav[role="navigation"]{margin-top:20px}
|
||||
nav[role="navigation"] > div:first-child{display:none}
|
||||
nav[role="navigation"] > div:last-child{display:flex;justify-content:center}
|
||||
nav[role="navigation"] a,nav[role="navigation"] span{display:inline-flex;align-items:center;justify-content:center;min-width:40px;height:40px;margin:0 4px;padding:0 14px;border-radius:999px;border:1px solid var(--line);background:#fff;color:var(--ink);box-shadow:0 10px 20px rgba(16,35,49,.06)}
|
||||
nav[role="navigation"] span[aria-current="page"]{background:linear-gradient(135deg,var(--accent-deep),var(--accent));border-color:transparent;color:#fff}
|
||||
nav[role="navigation"] svg{display:none}
|
||||
.guest-mode{background:radial-gradient(circle at top left,rgba(196,145,82,.18),transparent 26%),radial-gradient(circle at bottom right,rgba(28,125,133,.16),transparent 28%),linear-gradient(180deg,#f6f9fa 0,#eef4f6 100%)}
|
||||
.guest-shell{min-height:100vh;display:grid;place-items:center;padding:24px}
|
||||
.guest-stage{display:grid;grid-template-columns:minmax(0,1.05fr) minmax(420px,.95fr);width:min(1260px,100%);border-radius:34px;overflow:hidden;border:1px solid #d5e3e8;box-shadow:0 32px 72px rgba(12,26,37,.14);background:#fff}
|
||||
.guest-hero{padding:34px;background:linear-gradient(145deg,#102536 0,#16445c 52%,#1c7d85 100%);color:#fff;position:relative;overflow:hidden}
|
||||
.guest-hero:after{content:'';position:absolute;right:-70px;bottom:-90px;width:220px;height:220px;border-radius:56px;background:linear-gradient(135deg,rgba(255,255,255,.18),rgba(255,255,255,0));transform:rotate(16deg)}
|
||||
.guest-hero > *{position:relative;z-index:1}
|
||||
.guest-hero .page-badge{background:rgba(255,255,255,.12);color:#fff}
|
||||
.guest-hero h1{margin:18px 0 0;font-size:44px;line-height:1.02;letter-spacing:-.05em}
|
||||
.guest-hero p{margin:12px 0 0;color:rgba(230,239,244,.84);max-width:560px}
|
||||
.auth-points{display:grid;gap:12px;margin-top:28px}
|
||||
.auth-point{padding:16px 18px;border-radius:20px;background:rgba(255,255,255,.09);border:1px solid rgba(255,255,255,.10)}
|
||||
.auth-point strong{display:block;font-size:16px}
|
||||
.auth-point span{display:block;margin-top:6px;color:rgba(230,239,244,.78)}
|
||||
.guest-note{margin-top:20px;color:rgba(230,239,244,.78)}
|
||||
.guest-panel{padding:34px;background:linear-gradient(180deg,#fff 0,#f7fbfc 100%)}
|
||||
.login-sheet{display:grid;gap:18px}
|
||||
@media (max-width:1240px){.page,.guest-stage,.split{grid-template-columns:1fr}.sidebar{position:static;height:auto;overflow:visible;padding:18px}.sidebar-toggle{display:inline-flex}.sidebar-body{display:none;width:100%;padding-top:18px}.sidebar.is-open .sidebar-body{display:block}.content-inner{padding:22px}.guest-hero,.guest-panel{padding:26px}}
|
||||
@media (max-width:860px){.topbar{flex-direction:column;align-items:flex-start}.form-grid,.form-grid.compact,.kv-grid,.preview-grid{grid-template-columns:1fr}.stats{grid-template-columns:repeat(2,minmax(0,1fr))}.filters,.form-actions,.checkbox-row{width:100%}.filters{flex-direction:column}.field{width:100%}}
|
||||
@media (max-width:560px){.guest-shell{padding:12px}.guest-hero h1,.headline-block h1{font-size:34px}.stats{grid-template-columns:1fr}.panel,.guest-hero,.guest-panel{padding:20px}.content-inner{padding:16px}.top-actions{width:100%}.top-actions > *{flex:1 1 auto}}
|
||||
</style>
|
||||
@stack('styles')
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<aside class="sidebar">
|
||||
<div class="brand">Tstore Admin</div>
|
||||
<div class="brand-sub">Laravel management console</div>
|
||||
<nav class="nav">
|
||||
<a href="{{ route('webadmin.home') }}" class="{{ request()->routeIs('webadmin.home') ? 'active' : '' }}">概览</a>
|
||||
<a href="{{ route('webadmin.packages') }}" class="{{ request()->routeIs('webadmin.packages*') && !request('type') ? 'active' : '' }}">扩展管理</a>
|
||||
<a href="{{ route('webadmin.categories') }}" class="{{ request()->routeIs('webadmin.categories*') ? 'active' : '' }}">分类管理</a>
|
||||
<a href="{{ route('webadmin.packages', ['type' => 'plugin']) }}" class="{{ request()->routeIs('webadmin.packages*') && request('type') === 'plugin' ? 'active' : '' }}">插件列表</a>
|
||||
<a href="{{ route('webadmin.packages', ['type' => 'theme']) }}" class="{{ request()->routeIs('webadmin.packages*') && request('type') === 'theme' ? 'active' : '' }}">主题列表</a>
|
||||
</nav>
|
||||
<div class="sidebar-foot">
|
||||
<p>当前后台已接入账号登录,并支持分类维护、包管理、zip 上传发布、手动版本管理和版本删除。适合先维护 package 与分类,再进入详情页发布 zip 做联调。</p>
|
||||
<span class="muted-mini">建议:先登录后台账号,维护 package 和分类,再进入详情页完成 zip 发布。</span>
|
||||
</div>
|
||||
</aside>
|
||||
<main class="content">
|
||||
<div class="topbar">
|
||||
<div class="title">
|
||||
<h1>{{ $pageTitle ?? 'Tstore Admin' }}</h1>
|
||||
<p>{{ $pageSubtitle ?? '统一管理扩展、分类、版本与发布流程。' }}</p>
|
||||
</div>
|
||||
<div class="top-actions">
|
||||
<a class="btn secondary" href="{{ route('webadmin.home') }}">后台首页</a>
|
||||
<form method="post" action="{{ route('webadmin.logout') }}">@csrf<button class="btn ghost">退出</button></form>
|
||||
</div>
|
||||
</div>
|
||||
<body @class(['guest-mode' => $layoutMode === 'guest'])>
|
||||
@if ($layoutMode === 'guest')
|
||||
<div class="guest-shell">
|
||||
<div class="guest-stage">
|
||||
<section class="guest-hero">
|
||||
<a class="brand" href="{{ route('storefront.home') }}">
|
||||
<span class="brand-mark">TS</span>
|
||||
<span class="brand-copy">
|
||||
<strong>{{ $storeSettings['site_name'] ?? config('app.name', 'Tstore') }}</strong>
|
||||
<span>Service Console</span>
|
||||
</span>
|
||||
</a>
|
||||
<span class="page-badge">{{ $pageBadge }}</span>
|
||||
<h1>{{ $pageTitle }}</h1>
|
||||
<p>{{ $pageSubtitle }}</p>
|
||||
<div class="auth-points">
|
||||
<div class="auth-point">
|
||||
<strong>公开展示与后台维护拆开</strong>
|
||||
<span>前台负责扩展展示,后台负责发布、分类和站点接入。</span>
|
||||
</div>
|
||||
<div class="auth-point">
|
||||
<strong>登录后统一处理服务端数据</strong>
|
||||
<span>版本、分类、站点注册状态和后台发布入口都集中在一个控制台里。</span>
|
||||
</div>
|
||||
<div class="auth-point">
|
||||
<strong>默认先进入本地控制台</strong>
|
||||
<span>如果你已经初始化管理员账号,可以直接登录继续维护服务端。</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="guest-note">公开站点:<a href="{{ route('storefront.home') }}">返回首页</a></div>
|
||||
</section>
|
||||
<main class="guest-panel">
|
||||
@if (session('success') || session('error') || $errors->any())
|
||||
<div class="flash-stack">
|
||||
@if (session('success'))
|
||||
<div class="flash success">{{ session('success') }}</div>
|
||||
@endif
|
||||
@if (session('error'))
|
||||
<div class="flash error">{{ session('error') }}</div>
|
||||
@endif
|
||||
@if ($errors->any())
|
||||
<div class="flash error">{{ $errors->first() }}</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if (session('success'))
|
||||
<div class="flash success">{{ session('success') }}</div>
|
||||
@endif
|
||||
@if (session('error'))
|
||||
<div class="flash error">{{ session('error') }}</div>
|
||||
@endif
|
||||
@if ($errors->any())
|
||||
<div class="flash error">{{ $errors->first() }}</div>
|
||||
@endif
|
||||
@yield('content')
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<div class="page">
|
||||
<aside class="sidebar" data-sidebar>
|
||||
<div class="sidebar-top">
|
||||
<a class="brand" href="{{ route('webadmin.home') }}">
|
||||
<span class="brand-mark">TS</span>
|
||||
<span class="brand-copy">
|
||||
<strong>{{ $storeSettings['site_name'] ?? config('app.name', 'Tstore') }}</strong>
|
||||
<span>Admin Console</span>
|
||||
</span>
|
||||
</a>
|
||||
<button class="sidebar-toggle" type="button" data-sidebar-toggle aria-expanded="false" aria-controls="admin-sidebar-body">菜单</button>
|
||||
</div>
|
||||
|
||||
@yield('content')
|
||||
</main>
|
||||
</div>
|
||||
<div class="sidebar-body" id="admin-sidebar-body">
|
||||
<div class="sidebar-group">
|
||||
<span class="sidebar-label">控制台</span>
|
||||
<nav class="nav">
|
||||
<a href="{{ route('webadmin.home') }}" class="{{ request()->routeIs('webadmin.home') ? 'active' : '' }}">概览</a>
|
||||
<a href="{{ route('webadmin.packages') }}" class="{{ request()->routeIs('webadmin.packages*') && $routeType === '' ? 'active' : '' }}">扩展管理</a>
|
||||
<a href="{{ route('webadmin.categories') }}" class="{{ request()->routeIs('webadmin.categories*') ? 'active' : '' }}">分类管理</a>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-group">
|
||||
<span class="sidebar-label">按类型</span>
|
||||
<nav class="nav">
|
||||
<a href="{{ route('webadmin.packages', ['type' => 'plugin']) }}" class="{{ request()->routeIs('webadmin.packages*') && $routeType === 'plugin' ? 'active' : '' }}">插件列表</a>
|
||||
<a href="{{ route('webadmin.packages', ['type' => 'theme']) }}" class="{{ request()->routeIs('webadmin.packages*') && $routeType === 'theme' ? 'active' : '' }}">主题列表</a>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-meta">
|
||||
<div class="meta-card">
|
||||
<span class="meta-label">当前用户</span>
|
||||
<span class="meta-value">{{ auth()->user()?->email ?: '管理员' }}</span>
|
||||
</div>
|
||||
<div class="meta-card">
|
||||
<span class="meta-label">运行环境</span>
|
||||
<span class="meta-value">{{ strtoupper(app()->environment()) }}</span>
|
||||
</div>
|
||||
<div class="meta-card">
|
||||
<span class="meta-label">站点域名</span>
|
||||
<span class="meta-value">{{ $siteHost }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="content-shell">
|
||||
<div class="content-inner">
|
||||
<div class="topbar">
|
||||
<div class="headline-block">
|
||||
<span class="page-badge">{{ $pageBadge }}</span>
|
||||
<h1>{{ $pageTitle }}</h1>
|
||||
<p>{{ $pageSubtitle }}</p>
|
||||
</div>
|
||||
<div class="top-actions">
|
||||
<a class="btn secondary" href="{{ route('storefront.home') }}">公开前台</a>
|
||||
<a class="btn ghost" href="{{ route('webadmin.account') }}">账户安全</a>
|
||||
<a class="btn ghost" href="{{ route('webadmin.settings') }}">站点设置</a>
|
||||
<a class="btn ghost" href="{{ route('webadmin.clients') }}">站点接入</a>
|
||||
<form class="inline-form" method="post" action="{{ route('webadmin.logout') }}">
|
||||
@csrf
|
||||
<button class="btn ghost" type="submit">退出登录</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (session('success') || session('error') || $errors->any())
|
||||
<div class="flash-stack">
|
||||
@if (session('success'))
|
||||
<div class="flash success">{{ session('success') }}</div>
|
||||
@endif
|
||||
@if (session('error'))
|
||||
<div class="flash error">{{ session('error') }}</div>
|
||||
@endif
|
||||
@if ($errors->any())
|
||||
<div class="flash error">{{ $errors->first() }}</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@yield('content')
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
@endif
|
||||
<script>
|
||||
(() => {
|
||||
const sidebar = document.querySelector('[data-sidebar]');
|
||||
const toggle = document.querySelector('[data-sidebar-toggle]');
|
||||
|
||||
if (!sidebar || !toggle) {
|
||||
return;
|
||||
}
|
||||
|
||||
const syncSidebarState = () => {
|
||||
if (window.innerWidth > 1240) {
|
||||
sidebar.classList.remove('is-open');
|
||||
toggle.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
};
|
||||
|
||||
toggle.addEventListener('click', () => {
|
||||
const open = sidebar.classList.toggle('is-open');
|
||||
toggle.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||
});
|
||||
|
||||
window.addEventListener('resize', syncSidebarState);
|
||||
syncSidebarState();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,31 +1,34 @@
|
||||
@extends('admin.layout', [
|
||||
'title' => 'Tstore Admin · 后台登录',
|
||||
'pageTitle' => '后台登录',
|
||||
'pageSubtitle' => '使用后台账号密码登录,不再直接依赖 token。',
|
||||
'pageSubtitle' => '使用管理员账号进入服务端控制台,继续维护扩展发布、分类和站点接入。',
|
||||
'pageBadge' => 'Admin Login',
|
||||
'layoutMode' => 'guest',
|
||||
])
|
||||
|
||||
@section('content')
|
||||
<div class="panel" style="max-width:620px">
|
||||
<div class="login-sheet">
|
||||
<form method="post" action="{{ route('webadmin.login.submit') }}" class="grid">
|
||||
@csrf
|
||||
<input type="hidden" name="redirect" value="{{ $redirect }}">
|
||||
|
||||
<div class="field">
|
||||
<label>邮箱</label>
|
||||
<input class="input" type="email" name="email" value="{{ old('email', 'admin@tstore.local') }}" placeholder="admin@tstore.local" required>
|
||||
<input class="input" type="email" name="email" value="{{ old('email') }}" placeholder="输入管理员邮箱" required>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>密码</label>
|
||||
<input class="input" type="password" name="password" placeholder="输入后台密码" required>
|
||||
</div>
|
||||
<label><input type="checkbox" name="remember" value="1"> 记住登录状态</label>
|
||||
|
||||
<div class="checkbox-row">
|
||||
<label><input type="checkbox" name="remember" value="1" @checked(old('remember'))> 保持登录状态</label>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button class="btn" type="submit">进入后台</button>
|
||||
</div>
|
||||
<div class="mini-card">
|
||||
<h3>默认管理员</h3>
|
||||
<div class="muted">邮箱:admin@tstore.local</div>
|
||||
<div class="muted">密码:Admin@123456</div>
|
||||
<div class="muted">可以在 .env 中通过 ADMIN_EMAIL / ADMIN_PASSWORD 修改。</div>
|
||||
<a class="btn ghost" href="{{ route('storefront.home') }}">返回前台</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -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')
|
||||
<div class="grid">
|
||||
<div class="panel">
|
||||
<section class="stats">
|
||||
<div class="stat"><span class="label">当前结果</span><span class="value">{{ $packages->total() }}</span><span class="hint">符合筛选条件的{{ $entityLabel }}</span></div>
|
||||
<div class="stat"><span class="label">当前类型</span><span class="value">{{ $currentType === '' ? '全部' : strtoupper($currentType) }}</span><span class="hint">{{ $entityLabel }}视角</span></div>
|
||||
<div class="stat"><span class="label">当前状态</span><span class="value">{{ $currentStatusLabel }}</span><span class="hint">已应用状态筛选</span></div>
|
||||
<div class="stat"><span class="label">页码</span><span class="value">{{ $packages->currentPage() }}/{{ max($packages->lastPage(), 1) }}</span><span class="hint">分页浏览</span></div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h2>筛选与查询</h2>
|
||||
<p>{{ $filterHint }}</p>
|
||||
<p>先缩小范围,再进入详情页处理版本发布或资料修改。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="get" class="toolbar">
|
||||
<div class="filters">
|
||||
<div class="field">
|
||||
<label>类型</label>
|
||||
<select name="type" class="select">
|
||||
<option value="">全部</option>
|
||||
<option value="plugin" @selected($filters['type']==='plugin')>插件</option>
|
||||
<option value="theme" @selected($filters['type']==='theme')>主题</option>
|
||||
<option value="plugin" @selected($filters['type'] === 'plugin')>插件</option>
|
||||
<option value="theme" @selected($filters['type'] === 'theme')>主题</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>状态</label>
|
||||
<select name="status" class="select">
|
||||
<option value="">全部</option>
|
||||
<option value="draft" @selected($filters['status']==='draft')>draft</option>
|
||||
<option value="published" @selected($filters['status']==='published')>published</option>
|
||||
<option value="hidden" @selected($filters['status']==='hidden')>hidden</option>
|
||||
<option value="deprecated" @selected($filters['status']==='deprecated')>deprecated</option>
|
||||
@foreach ($statusLabels as $status => $label)
|
||||
<option value="{{ $status }}" @selected($filters['status'] === $status)>{{ $label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div class="field" style="min-width:280px">
|
||||
<div class="field" style="flex:1 1 280px">
|
||||
<label>关键词</label>
|
||||
<input class="input" type="text" name="keyword" value="{{ $filters['keyword'] }}" placeholder="搜索 name / slug / summary">
|
||||
<input class="input" type="text" name="keyword" value="{{ $filters['keyword'] }}" placeholder="搜索名称、slug 或摘要">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button class="btn" type="submit">筛选</button>
|
||||
<button class="btn" type="submit">应用筛选</button>
|
||||
<a class="btn secondary" href="{{ route('webadmin.packages') }}">重置</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="split">
|
||||
<div class="panel">
|
||||
<div class="stack">
|
||||
<section class="panel">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h2>{{ $entityPluralLabel }}</h2>
|
||||
<p>{{ $listHint }}</p>
|
||||
<p>保持列表信息密度,重点展示状态、当前版本、分类和最近更新时间。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-wrap">
|
||||
<table class="table">
|
||||
<thead>
|
||||
@@ -82,6 +91,7 @@
|
||||
<th>状态</th>
|
||||
<th>最新版本</th>
|
||||
<th>分类</th>
|
||||
<th>更新时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -91,108 +101,134 @@
|
||||
<td>
|
||||
<strong>{{ $package->name }}</strong>
|
||||
<div class="muted">{{ $package->slug }}</div>
|
||||
<div class="muted">{{ $package->summary ?: '暂无摘要' }}</div>
|
||||
<div class="muted">{{ $package->summary ?: '暂无摘要。' }}</div>
|
||||
</td>
|
||||
<td>{{ $package->type }}</td>
|
||||
<td><span class="chip {{ $package->status === 'published' ? 'ok' : ($package->status === 'draft' ? 'muted' : 'warn') }}">{{ $package->status }}</span></td>
|
||||
<td>{{ strtoupper($package->type) }}</td>
|
||||
<td><span class="chip {{ $statusTones[$package->status] ?? 'muted' }}">{{ $statusLabels[$package->status] ?? $package->status }}</span></td>
|
||||
<td>{{ $package->latestStableVersion?->version ?: ($package->latest_version ?: '-') }}</td>
|
||||
<td>
|
||||
<div class="tags">
|
||||
@foreach ($package->categories as $category)
|
||||
<span class="chip">{{ $category->slug }}</span>
|
||||
@endforeach
|
||||
@forelse ($package->categories as $category)
|
||||
<span class="chip">{{ $category->name }}</span>
|
||||
@empty
|
||||
<span class="muted">未分类</span>
|
||||
@endforelse
|
||||
</div>
|
||||
</td>
|
||||
<td>{{ optional($package->updated_at)->format('Y-m-d H:i') ?: '-' }}</td>
|
||||
<td>
|
||||
<div class="form-actions">
|
||||
<a class="btn secondary small" href="{{ route('webadmin.packages.show', [$package->type, $package->slug]) }}">详情 / 发布</a>
|
||||
<a class="btn ghost small" href="{{ route('webadmin.packages.show', [$package->type, $package->slug]) }}">详情</a>
|
||||
<form method="post" action="{{ route('webadmin.packages.status', [$package->type, $package->slug]) }}">
|
||||
@csrf @method('PATCH')
|
||||
@csrf
|
||||
@method('PATCH')
|
||||
<input type="hidden" name="status" value="{{ $package->status === 'published' ? 'hidden' : 'published' }}">
|
||||
<button class="btn ghost small" type="submit">{{ $package->status === 'published' ? '隐藏' : '发布' }}</button>
|
||||
<button class="btn secondary small" type="submit">{{ $package->status === 'published' ? '隐藏' : '发布' }}</button>
|
||||
</form>
|
||||
<form method="post" action="{{ route('webadmin.packages.destroy', [$package->type, $package->slug]) }}" onsubmit="return confirm('确认删除这个{{ $entityLabel }}吗?版本、截图和分类关联都会一起删除。');">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button class="btn danger small" type="submit">删除</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr><td colspan="6"><div class="empty">{{ $emptyHint }}</div></td></tr>
|
||||
<tr>
|
||||
<td colspan="7">
|
||||
<div class="empty">当前还没有{{ $entityLabel }}。</div>
|
||||
</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div style="margin-top:14px">{{ $packages->links() }}</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
{{ $packages->links() }}
|
||||
</section>
|
||||
|
||||
<section class="panel soft">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h2>{{ $newEntityLabel }}</h2>
|
||||
<p>{{ $createHint }}</p>
|
||||
<p>先创建扩展,再进入详情页继续补充版本、截图和发布信息。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="post" action="{{ route('webadmin.packages.store') }}" class="grid">
|
||||
@csrf
|
||||
|
||||
<div class="form-grid">
|
||||
<div class="field">
|
||||
<label>类型</label>
|
||||
<select class="select" name="type" required>
|
||||
<option value="plugin" @selected($currentType === 'plugin')>插件</option>
|
||||
<option value="theme" @selected($currentType === 'theme')>主题</option>
|
||||
<option value="plugin" @selected(old('type', $currentType ?: 'plugin') === 'plugin')>插件</option>
|
||||
<option value="theme" @selected(old('type', $currentType) === 'theme')>主题</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Slug</label>
|
||||
<input class="input" name="slug" placeholder="HelloStore" required>
|
||||
<input class="input" name="slug" value="{{ old('slug') }}" placeholder="HelloStore" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>名称</label>
|
||||
<input class="input" name="name" placeholder="Hello Store" required>
|
||||
<input class="input" name="name" value="{{ old('name') }}" placeholder="Hello Store" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>状态</label>
|
||||
<select class="select" name="status">
|
||||
<option value="published">published</option>
|
||||
<option value="draft">draft</option>
|
||||
<option value="hidden">hidden</option>
|
||||
<option value="deprecated">deprecated</option>
|
||||
@foreach ($statusLabels as $status => $label)
|
||||
<option value="{{ $status }}" @selected(old('status', 'published') === $status)>{{ $label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>作者</label>
|
||||
<input class="input" name="author" placeholder="LT083">
|
||||
<input class="input" name="author" value="{{ old('author') }}" placeholder="LT083">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>License</label>
|
||||
<input class="input" name="license" placeholder="MIT">
|
||||
<input class="input" name="license" value="{{ old('license') }}" placeholder="MIT">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>排序</label>
|
||||
<input class="input" type="number" name="sort_order" value="{{ old('sort_order', 0) }}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>摘要</label>
|
||||
<input class="input" name="summary" placeholder="一句话说明这个{{ $entityLabel }}是做什么的">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>描述</label>
|
||||
<textarea name="description" placeholder="详细描述这个{{ $entityLabel }}的用途与定位"></textarea>
|
||||
</div>
|
||||
<div class="form-grid">
|
||||
<div class="field">
|
||||
<label>主页</label>
|
||||
<input class="input" name="homepage" placeholder="https://example.com/package">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>图标 URL</label>
|
||||
<input class="input" name="icon_url" placeholder="https://example.com/icon.png">
|
||||
<input class="input" name="homepage" value="{{ old('homepage') }}" placeholder="https://example.com/package">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>分类 slug,多个用空格分隔</label>
|
||||
<input class="input" name="categories_text" placeholder="seo performance">
|
||||
<label>图标 URL</label>
|
||||
<input class="input" name="icon_url" value="{{ old('icon_url') }}" placeholder="https://example.com/icon.png">
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>摘要</label>
|
||||
<input class="input" name="summary" value="{{ old('summary') }}" placeholder="一句话说明这个{{ $entityLabel }}是做什么的">
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>描述</label>
|
||||
<textarea name="description" placeholder="补充更完整的定位与功能说明">{{ old('description') }}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>分类名称或 slug,优先用逗号分隔</label>
|
||||
<input class="input" name="categories_text" value="{{ old('categories_text') }}" placeholder="性能优化, seo">
|
||||
</div>
|
||||
|
||||
<div class="checkbox-row">
|
||||
<label><input type="checkbox" name="is_featured" value="1" @checked(old('is_featured'))> 设为推荐扩展</label>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button class="btn" type="submit">创建{{ $entityLabel }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@@ -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')
|
||||
<div class="grid">
|
||||
<section class="stats">
|
||||
<div class="stat"><span class="label">扩展类型</span><span class="value">{{ strtoupper($package->type) }}</span><span class="hint">{{ $typeLabel }} package</span></div>
|
||||
<div class="stat"><span class="label">当前状态</span><span class="value">{{ $statusLabels[$package->status] ?? $package->status }}</span><span class="hint">可在下方直接调整</span></div>
|
||||
<div class="stat"><span class="label">当前主版本</span><span class="value">{{ $currentVersion?->version ?: '-' }}</span><span class="hint">最新或稳定版本</span></div>
|
||||
<div class="stat"><span class="label">版本数量</span><span class="value">{{ $package->versions->count() }}</span><span class="hint">历史记录总数</span></div>
|
||||
<div class="stat"><span class="label">截图数量</span><span class="value">{{ $package->screenshots->count() }}</span><span class="hint">当前展示素材</span></div>
|
||||
<div class="stat"><span class="label">下载次数</span><span class="value">{{ $package->download_count }}</span><span class="hint">累计下载总量</span></div>
|
||||
</section>
|
||||
|
||||
<div class="split">
|
||||
<div class="panel">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h2>{{ $entityLabel }}信息</h2>
|
||||
<p>编辑基础元数据、分类、图标与发布状态。</p>
|
||||
<section class="panel">
|
||||
<div class="entity-head">
|
||||
<div class="entity-icon">
|
||||
@if ($package->icon_url)
|
||||
<img src="{{ $package->icon_url }}" alt="{{ $package->name }}">
|
||||
@else
|
||||
{{ $package->type === 'plugin' ? 'P' : 'T' }}
|
||||
@endif
|
||||
</div>
|
||||
<div class="entity-copy">
|
||||
<h2>基础资料</h2>
|
||||
<p>这里维护扩展展示信息、排序、推荐状态以及分类关联。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="post" action="{{ route('webadmin.packages.update', [$package->type, $package->slug]) }}" class="grid">
|
||||
@csrf @method('PUT')
|
||||
@csrf
|
||||
@method('PUT')
|
||||
|
||||
<div class="form-grid">
|
||||
<div class="field"><label>类型</label><input class="input" name="type" value="{{ $package->type }}" readonly></div>
|
||||
<div class="field"><label>Slug</label><input class="input" name="slug" value="{{ $package->slug }}" readonly></div>
|
||||
<div class="field"><label>名称</label><input class="input" name="name" value="{{ $package->name }}" required></div>
|
||||
<div class="field"><label>状态</label>
|
||||
<div class="field">
|
||||
<label>类型</label>
|
||||
<input class="input" name="type" value="{{ $package->type }}" readonly>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Slug</label>
|
||||
<input class="input" name="slug" value="{{ $package->slug }}" readonly>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>名称</label>
|
||||
<input class="input" name="name" value="{{ old('name', $package->name) }}" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>状态</label>
|
||||
<select class="select" name="status">
|
||||
@foreach (['draft','published','hidden','deprecated'] as $status)
|
||||
<option value="{{ $status }}" @selected($package->status === $status)>{{ $status }}</option>
|
||||
@foreach ($statusLabels as $status => $label)
|
||||
<option value="{{ $status }}" @selected(old('status', $package->status) === $status)>{{ $label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div class="field"><label>作者</label><input class="input" name="author" value="{{ $package->author }}"></div>
|
||||
<div class="field"><label>License</label><input class="input" name="license" value="{{ $package->license }}"></div>
|
||||
<div class="field"><label>主页</label><input class="input" name="homepage" value="{{ $package->homepage }}"></div>
|
||||
<div class="field"><label>图标 URL</label><input class="input" name="icon_url" value="{{ $package->icon_url }}"></div>
|
||||
<div class="field">
|
||||
<label>作者</label>
|
||||
<input class="input" name="author" value="{{ old('author', $package->author) }}">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>License</label>
|
||||
<input class="input" name="license" value="{{ old('license', $package->license) }}">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>排序</label>
|
||||
<input class="input" type="number" name="sort_order" value="{{ old('sort_order', $package->sort_order ?? 0) }}">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>主页</label>
|
||||
<input class="input" name="homepage" value="{{ old('homepage', $package->homepage) }}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field"><label>摘要</label><input class="input" name="summary" value="{{ $package->summary }}"></div>
|
||||
<div class="field"><label>描述</label><textarea name="description">{{ $package->description }}</textarea></div>
|
||||
<div class="field"><label>分类 slug,多个用逗号或空格分隔</label><input class="input" name="categories_text" value="{{ $package->categories->pluck('slug')->implode(', ') }}"></div>
|
||||
|
||||
<div class="field">
|
||||
<label>图标 URL</label>
|
||||
<input class="input" name="icon_url" value="{{ old('icon_url', $package->icon_url) }}">
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>摘要</label>
|
||||
<input class="input" name="summary" value="{{ old('summary', $package->summary) }}">
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>描述</label>
|
||||
<textarea name="description">{{ old('description', $package->description) }}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>分类名称或 slug,优先用逗号分隔</label>
|
||||
<input class="input" name="categories_text" value="{{ old('categories_text', $package->categories->map(fn ($category) => $category->name ?: $category->slug)->implode(', ')) }}">
|
||||
</div>
|
||||
|
||||
<div class="checkbox-row">
|
||||
<label><input type="checkbox" name="is_featured" value="1" @checked(old('is_featured', $package->is_featured))> 设为推荐扩展</label>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button class="btn" type="submit">保存{{ $entityLabel }}信息</button>
|
||||
<a class="btn secondary" href="{{ route('webadmin.packages', ['type' => $package->type]) }}">返回{{ $entityLabel }}列表</a>
|
||||
<button class="btn" type="submit">保存{{ $typeLabel }}信息</button>
|
||||
<a class="btn ghost" href="{{ route('webadmin.packages', ['type' => $package->type]) }}">返回{{ $typeLabel }}列表</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h2>当前状态</h2>
|
||||
<p>快速查看分类、版本、下载与截图信息。</p>
|
||||
<div class="form-actions" style="margin-top:16px">
|
||||
<form method="post" action="{{ route('webadmin.packages.destroy', [$package->type, $package->slug]) }}" onsubmit="return confirm('确认删除这个{{ $typeLabel }}吗?版本、截图和分类关联都会一起删除。');">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button class="btn danger" type="submit">删除{{ $typeLabel }}</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="stack">
|
||||
<section class="panel soft">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h2>当前资料</h2>
|
||||
<p>快速核对基础信息、时间戳和前台展示状态。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="list">
|
||||
<div class="mini-card"><h3>状态</h3><div class="tags"><span class="chip {{ $package->status === 'published' ? 'ok' : 'warn' }}">{{ $package->status }}</span><span class="chip">下载 {{ $package->download_count }}</span><span class="chip {{ $package->is_featured ? 'ok' : 'muted' }}">{{ $package->is_featured ? '推荐' : '普通' }}</span></div></div>
|
||||
<div class="mini-card"><h3>分类</h3><div class="tags">@forelse($package->categories as $category)<span class="chip">{{ $category->name }}</span>@empty<span class="muted">尚未关联分类</span>@endforelse</div></div>
|
||||
<div class="mini-card"><h3>截图</h3><div class="muted">{{ $package->screenshots->count() }} 张</div></div>
|
||||
<div class="mini-card"><h3>最新版本</h3><div class="muted">{{ $package->latest_version ?: '尚未发布' }}</div></div>
|
||||
</div>
|
||||
|
||||
<div class="kv-grid">
|
||||
<div class="kv-card">
|
||||
<span class="kv-label">Slug</span>
|
||||
<span class="kv-value">{{ $package->slug }}</span>
|
||||
</div>
|
||||
<div class="kv-card">
|
||||
<span class="kv-label">状态</span>
|
||||
<span class="kv-value"><span class="chip {{ $statusTone[$package->status] ?? 'muted' }}">{{ $statusLabels[$package->status] ?? $package->status }}</span></span>
|
||||
</div>
|
||||
<div class="kv-card">
|
||||
<span class="kv-label">作者</span>
|
||||
<span class="kv-value">{{ $package->author ?: '未填写' }}</span>
|
||||
</div>
|
||||
<div class="kv-card">
|
||||
<span class="kv-label">主页</span>
|
||||
<span class="kv-value">{{ $package->homepage ?: '未填写' }}</span>
|
||||
</div>
|
||||
<div class="kv-card">
|
||||
<span class="kv-label">创建时间</span>
|
||||
<span class="kv-value">{{ optional($package->created_at)->format('Y-m-d H:i') ?: '-' }}</span>
|
||||
</div>
|
||||
<div class="kv-card">
|
||||
<span class="kv-label">更新时间</span>
|
||||
<span class="kv-value">{{ optional($package->updated_at)->format('Y-m-d H:i') ?: '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tags">
|
||||
@foreach ($package->categories as $category)
|
||||
<span class="chip">{{ $category->name }}</span>
|
||||
@endforeach
|
||||
@if ($package->categories->isEmpty())
|
||||
<span class="muted">尚未关联分类</span>
|
||||
@endif
|
||||
<span class="chip {{ $package->is_featured ? 'ok' : 'muted' }}">{{ $package->is_featured ? '推荐扩展' : '普通扩展' }}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel soft">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h2>截图管理</h2>
|
||||
<p>手动录入详情页预览截图,第一张会优先作为前台主预览展示。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="post" action="{{ route('webadmin.packages.screenshots.store', [$package->type, $package->slug]) }}" class="grid">
|
||||
@csrf
|
||||
|
||||
<div class="field">
|
||||
<label>图片 URL</label>
|
||||
<input class="input" name="image_url" value="{{ old('image_url') }}" placeholder="https://example.com/screenshots/home.webp" required>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>截图说明</label>
|
||||
<input class="input" name="caption" value="{{ old('caption') }}" placeholder="首页、设置页、文章页等">
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>排序</label>
|
||||
<input class="input" type="number" name="sort_order" value="{{ old('sort_order', 0) }}">
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button class="btn" type="submit">添加截图</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@if ($package->screenshots->isNotEmpty())
|
||||
<div class="preview-grid" style="margin-top:18px">
|
||||
@foreach ($package->screenshots as $shot)
|
||||
<article class="preview-card">
|
||||
<figure class="preview-shot">
|
||||
<img src="{{ $shot->image_url }}" alt="{{ $shot->caption ?: $package->name }}">
|
||||
<figcaption>{{ $shot->caption ?: $package->name . ' 截图' }}</figcaption>
|
||||
</figure>
|
||||
<div class="preview-tools">
|
||||
<span class="chip muted">排序 {{ $shot->sort_order }}</span>
|
||||
<form method="post" action="{{ route('webadmin.packages.screenshots.destroy', [$package->type, $package->slug, $shot->id]) }}" onsubmit="return confirm('确认删除这张截图吗?');">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button class="btn danger small" type="submit">删除</button>
|
||||
</form>
|
||||
</div>
|
||||
</article>
|
||||
@endforeach
|
||||
</div>
|
||||
@else
|
||||
<div class="empty">当前还没有截图。</div>
|
||||
@endif
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="split">
|
||||
<div class="panel">
|
||||
<section class="panel">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h2>版本记录</h2>
|
||||
<p>查看版本状态,也可以直接删除历史版本。</p>
|
||||
<p>统一查看版本号、兼容区间、发布时间与稳定状态。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-wrap">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>版本</th>
|
||||
<th>稳定版</th>
|
||||
<th>最新</th>
|
||||
<th>状态</th>
|
||||
<th>兼容性</th>
|
||||
<th>发布时间</th>
|
||||
<th>操作</th>
|
||||
@@ -86,94 +261,173 @@
|
||||
@forelse ($package->versions as $version)
|
||||
<tr>
|
||||
<td>
|
||||
<strong>{{ $version->version }}</strong>
|
||||
<div class="muted">{{ $version->package_url ?: '未设置 package_url' }}</div>
|
||||
<strong>v{{ $version->version }}</strong>
|
||||
<div class="muted">{{ $version->package_url ?: '未生成下载地址' }}</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="tags" style="margin-top:0">
|
||||
<span class="chip {{ $version->is_stable ? 'ok' : 'warn' }}">{{ $version->is_stable ? '稳定版' : '预发布' }}</span>
|
||||
<span class="chip {{ $version->is_latest ? 'ok' : 'muted' }}">{{ $version->is_latest ? '最新' : '历史' }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td><span class="chip {{ $version->is_stable ? 'ok' : 'warn' }}">{{ $version->is_stable ? '是' : '否' }}</span></td>
|
||||
<td><span class="chip {{ $version->is_latest ? 'ok' : 'muted' }}">{{ $version->is_latest ? '最新' : '历史' }}</span></td>
|
||||
<td>
|
||||
<div class="muted">Typecho {{ $version->typecho_min ?: '-' }} ~ {{ $version->typecho_max ?: '-' }}</div>
|
||||
<div class="muted">PHP {{ $version->php_min ?: '-' }} ~ {{ $version->php_max ?: '-' }}</div>
|
||||
</td>
|
||||
<td>{{ optional($version->published_at)->format('Y-m-d H:i') }}</td>
|
||||
<td>{{ optional($version->published_at)->format('Y-m-d H:i') ?: '-' }}</td>
|
||||
<td>
|
||||
<form method="post" action="{{ route('webadmin.packages.versions.destroy', [$package->type, $package->slug, $version->id]) }}" onsubmit="return confirm('确认删除这个版本吗?');">
|
||||
@csrf @method('DELETE')
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button class="btn danger small" type="submit">删除</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr><td colspan="6"><div class="empty">当前还没有版本记录。</div></td></tr>
|
||||
<tr>
|
||||
<td colspan="5">
|
||||
<div class="empty">当前还没有版本记录。</div>
|
||||
</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="panel">
|
||||
<section class="panel soft">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h2>zip 上传发布</h2>
|
||||
<p>优先读取 zip 根目录的 manifest.json;如果没有 manifest.json,则使用下方手动填写的版本和兼容性字段。</p>
|
||||
<h2>zip 发布</h2>
|
||||
<p>上传 zip 后补充版本字段,服务端会校验目录结构并生成可供 API 返回的版本元数据。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="post" action="{{ route('webadmin.packages.publish', [$package->type, $package->slug]) }}" class="grid" enctype="multipart/form-data">
|
||||
@csrf
|
||||
<div class="field"><label>zip 文件</label><input class="input" type="file" name="package_file" accept=".zip" required></div>
|
||||
<div class="field"><label>版本号(无 manifest 时必填)</label><input class="input" name="version" value="{{ old('version') }}" placeholder="1.0.0"></div>
|
||||
<div class="field"><label>发布说明(可选)</label><textarea name="changelog" placeholder="补充这次发布说明">{{ old('changelog') }}</textarea></div>
|
||||
<div class="form-grid">
|
||||
<div class="field"><label>Typecho Min</label><input class="input" name="typecho_min" value="{{ old('typecho_min', '1.2.0') }}" placeholder="1.2.0"></div>
|
||||
<div class="field"><label>Typecho Max</label><input class="input" name="typecho_max" value="{{ old('typecho_max') }}" placeholder="1.3.*"></div>
|
||||
<div class="field"><label>PHP Min</label><input class="input" name="php_min" value="{{ old('php_min', '7.4') }}" placeholder="7.4"></div>
|
||||
<div class="field"><label>PHP Max</label><input class="input" name="php_max" value="{{ old('php_max') }}" placeholder="8.3"></div>
|
||||
<div class="field"><label>PHP Extensions</label><input class="input" name="php_extensions" value="{{ is_array(old('php_extensions')) ? implode(',', old('php_extensions')) : old('php_extensions') }}" placeholder="curl,json"></div>
|
||||
<div class="field"><label>发布时间(可选)</label><input class="input" type="datetime-local" name="published_at" value="{{ old('published_at') }}"></div>
|
||||
|
||||
<div class="field">
|
||||
<label>zip 文件</label>
|
||||
<input class="input" type="file" name="package_file" accept=".zip" required>
|
||||
</div>
|
||||
<div class="form-grid">
|
||||
<div class="field"><label>发布选项</label>
|
||||
<div class="tags">
|
||||
<label><input type="checkbox" name="is_stable" value="1" @checked(old('is_stable', 1))> 设为稳定版</label>
|
||||
<label><input type="checkbox" name="mark_as_latest" value="1" @checked(old('mark_as_latest', 1))> 设为最新</label>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>版本号</label>
|
||||
<input class="input" name="version" value="{{ old('version') }}" placeholder="如 zip 不含 manifest,可在这里手动填写 1.0.0">
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>发布说明</label>
|
||||
<textarea name="changelog" placeholder="补充这次发布的主要变化">{{ old('changelog') }}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-grid compact">
|
||||
<div class="field">
|
||||
<label>Typecho Min</label>
|
||||
<input class="input" name="typecho_min" value="{{ old('typecho_min', '1.2.0') }}" placeholder="1.2.0">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Typecho Max</label>
|
||||
<input class="input" name="typecho_max" value="{{ old('typecho_max') }}" placeholder="1.3.*">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>PHP Min</label>
|
||||
<input class="input" name="php_min" value="{{ old('php_min', '7.4') }}" placeholder="7.4">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>PHP Max</label>
|
||||
<input class="input" name="php_max" value="{{ old('php_max') }}" placeholder="8.3">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>PHP Extensions</label>
|
||||
<input class="input" name="php_extensions" value="{{ is_array(old('php_extensions')) ? implode(',', old('php_extensions')) : old('php_extensions') }}" placeholder="curl,json">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>发布时间</label>
|
||||
<input class="input" type="datetime-local" name="published_at" value="{{ old('published_at') }}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-actions"><button class="btn" type="submit">上传并发布</button></div>
|
||||
|
||||
<div class="checkbox-row">
|
||||
<label><input type="checkbox" name="is_stable" value="1" @checked(old('is_stable', 1))> 设为稳定版</label>
|
||||
<label><input type="checkbox" name="mark_as_latest" value="1" @checked(old('mark_as_latest', 1))> 设为最新版本</label>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button class="btn" type="submit">上传并发布</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<section class="panel">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h2>手动添加版本</h2>
|
||||
<p>调试阶段可以快速手动录入版本元数据。</p>
|
||||
<p>调试或补录历史版本时,可以直接写入元数据而不经过 zip 发布流程。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="post" action="{{ route('webadmin.packages.versions.store', [$package->type, $package->slug]) }}" class="grid">
|
||||
@csrf
|
||||
<div class="form-grid">
|
||||
<div class="field"><label>版本号</label><input class="input" name="version" placeholder="1.0.0" required></div>
|
||||
<div class="field"><label>发布时间</label><input class="input" type="datetime-local" name="published_at"></div>
|
||||
<div class="field"><label>Typecho Min</label><input class="input" name="typecho_min" placeholder="1.2.0"></div>
|
||||
<div class="field"><label>Typecho Max</label><input class="input" name="typecho_max" placeholder="1.3.*"></div>
|
||||
<div class="field"><label>PHP Min</label><input class="input" name="php_min" placeholder="7.4"></div>
|
||||
<div class="field"><label>PHP Max</label><input class="input" name="php_max" placeholder="8.3"></div>
|
||||
<div class="field"><label>Package URL</label><input class="input" name="package_url" placeholder="https://..."></div>
|
||||
<div class="field"><label>Size</label><input class="input" name="package_size" type="number" placeholder="238000"></div>
|
||||
<div class="field"><label>SHA256</label><input class="input" name="sha256" placeholder="可选,默认会补 64 位 0"></div>
|
||||
<div class="field"><label>PHP Extensions</label><input class="input" name="php_extensions" placeholder="curl,json"></div>
|
||||
|
||||
<div class="form-grid compact">
|
||||
<div class="field">
|
||||
<label>版本号</label>
|
||||
<input class="input" name="version" value="{{ old('version') }}" placeholder="1.0.0" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>发布时间</label>
|
||||
<input class="input" type="datetime-local" name="published_at" value="{{ old('published_at') }}">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Typecho Min</label>
|
||||
<input class="input" name="typecho_min" value="{{ old('typecho_min') }}" placeholder="1.2.0">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Typecho Max</label>
|
||||
<input class="input" name="typecho_max" value="{{ old('typecho_max') }}" placeholder="1.3.*">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>PHP Min</label>
|
||||
<input class="input" name="php_min" value="{{ old('php_min') }}" placeholder="7.4">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>PHP Max</label>
|
||||
<input class="input" name="php_max" value="{{ old('php_max') }}" placeholder="8.3">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Package URL</label>
|
||||
<input class="input" name="package_url" value="{{ old('package_url') }}" placeholder="https://...">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Size</label>
|
||||
<input class="input" type="number" name="package_size" value="{{ old('package_size') }}" placeholder="238000">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>SHA256</label>
|
||||
<input class="input" name="sha256" value="{{ old('sha256') }}" placeholder="可选,默认会补 64 位 0">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>PHP Extensions</label>
|
||||
<input class="input" name="php_extensions" value="{{ old('php_extensions') }}" placeholder="curl,json">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field"><label>发布说明</label><textarea name="changelog" placeholder="输入更新内容"></textarea></div>
|
||||
<div class="form-grid">
|
||||
<label><input type="checkbox" name="is_stable" value="1"> 设为稳定版</label>
|
||||
<label><input type="checkbox" name="is_latest" value="1"> 设为最新版本</label>
|
||||
|
||||
<div class="field">
|
||||
<label>发布说明</label>
|
||||
<textarea name="changelog" placeholder="输入更新内容">{{ old('changelog') }}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="checkbox-row">
|
||||
<label><input type="checkbox" name="is_stable" value="1" @checked(old('is_stable'))> 设为稳定版</label>
|
||||
<label><input type="checkbox" name="is_latest" value="1" @checked(old('is_latest'))> 设为最新版本</label>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button class="btn" type="submit">添加版本</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
@extends('admin.layout', [
|
||||
'title' => 'Tstore Admin · 站点设置',
|
||||
'pageTitle' => '站点设置',
|
||||
'pageSubtitle' => '配置前台站点名称、标语,以及首页主要展示文案。',
|
||||
'pageBadge' => 'Settings',
|
||||
])
|
||||
|
||||
@section('content')
|
||||
<div class="grid">
|
||||
<section class="panel soft">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h2>站点基础信息</h2>
|
||||
<p>这些字段会影响前台标题、品牌名称和首页头部展示。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="post" action="{{ route('webadmin.settings.update') }}" class="grid">
|
||||
@csrf
|
||||
@method('PUT')
|
||||
|
||||
<div class="form-grid">
|
||||
<div class="field">
|
||||
<label>站点名称</label>
|
||||
<input class="input" name="site_name" value="{{ old('site_name', $settings['site_name']) }}" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>站点标语</label>
|
||||
<input class="input" name="site_tagline" value="{{ old('site_tagline', $settings['site_tagline']) }}" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>首页标题副标题</label>
|
||||
<input class="input" name="home_title_suffix" value="{{ old('home_title_suffix', $settings['home_title_suffix']) }}" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>首页眉标</label>
|
||||
<input class="input" name="home_eyebrow" value="{{ old('home_eyebrow', $settings['home_eyebrow']) }}" required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field" style="max-width:420px">
|
||||
<label>首页侧栏眉标</label>
|
||||
<input class="input" name="home_aside_kicker" value="{{ old('home_aside_kicker', $settings['home_aside_kicker']) }}" required>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>首页主标题</label>
|
||||
<input class="input" name="home_headline" value="{{ old('home_headline', $settings['home_headline']) }}" required>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>首页主说明</label>
|
||||
<textarea name="home_lede" required>{{ old('home_lede', $settings['home_lede']) }}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>首页侧栏标题</label>
|
||||
<input class="input" name="home_aside_title" value="{{ old('home_aside_title', $settings['home_aside_title']) }}" required>
|
||||
</div>
|
||||
|
||||
<div class="split">
|
||||
<section class="panel soft">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h2>首页说明卡片</h2>
|
||||
<p>右侧三块说明文字都可以在这里调整。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
<div class="field">
|
||||
<label>卡片一标题</label>
|
||||
<input class="input" name="home_feature_one_title" value="{{ old('home_feature_one_title', $settings['home_feature_one_title']) }}" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>卡片一说明</label>
|
||||
<textarea name="home_feature_one_body" required>{{ old('home_feature_one_body', $settings['home_feature_one_body']) }}</textarea>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>卡片二标题</label>
|
||||
<input class="input" name="home_feature_two_title" value="{{ old('home_feature_two_title', $settings['home_feature_two_title']) }}" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>卡片二说明</label>
|
||||
<textarea name="home_feature_two_body" required>{{ old('home_feature_two_body', $settings['home_feature_two_body']) }}</textarea>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>卡片三标题</label>
|
||||
<input class="input" name="home_feature_three_title" value="{{ old('home_feature_three_title', $settings['home_feature_three_title']) }}" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>卡片三说明</label>
|
||||
<textarea name="home_feature_three_body" required>{{ old('home_feature_three_body', $settings['home_feature_three_body']) }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel soft">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h2>列表区域标题</h2>
|
||||
<p>首页的推荐插件和推荐主题标题、说明都能单独设置。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
<div class="field">
|
||||
<label>插件区标题</label>
|
||||
<input class="input" name="home_plugins_title" value="{{ old('home_plugins_title', $settings['home_plugins_title']) }}" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>插件区说明</label>
|
||||
<textarea name="home_plugins_subtitle" required>{{ old('home_plugins_subtitle', $settings['home_plugins_subtitle']) }}</textarea>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>主题区标题</label>
|
||||
<input class="input" name="home_themes_title" value="{{ old('home_themes_title', $settings['home_themes_title']) }}" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>主题区说明</label>
|
||||
<textarea name="home_themes_subtitle" required>{{ old('home_themes_subtitle', $settings['home_themes_subtitle']) }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button class="btn" type="submit">保存站点设置</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -1,102 +1,63 @@
|
||||
@extends('storefront.layout', ['title' => 'Tstore · 扩展展示站'])
|
||||
@extends('storefront.layout', ['title' => (config('app.name', 'Tstore')) . ' · ' . ($storeSettings['home_title_suffix'] ?? '扩展展示站')])
|
||||
|
||||
@section('hero')
|
||||
<div class="hero-grid">
|
||||
<div class="hero-card">
|
||||
<h1>精选 Typecho 插件与主题展示</h1>
|
||||
<p>这里是展示前台,只负责展示站点里的插件、主题、分类、截图和版本信息,不直接暴露下载地址。</p>
|
||||
<div class="hero-meta">
|
||||
<div class="hero-stat"><strong>{{ $stats['packages'] }}</strong><span>已发布扩展</span></div>
|
||||
<div class="hero-stat"><strong>{{ $stats['plugins'] }}</strong><span>插件</span></div>
|
||||
<div class="hero-stat"><strong>{{ $stats['themes'] }}</strong><span>主题</span></div>
|
||||
<div class="hero-stat"><strong>{{ $stats['categories'] }}</strong><span>分类</span></div>
|
||||
<section class="hero">
|
||||
<div class="container">
|
||||
<span class="eyebrow">{{ $storeSettings['home_eyebrow'] ?? 'Public Showcase' }}</span>
|
||||
<h1 class="headline">{{ $storeSettings['home_headline'] ?? '更干净的 Typecho 扩展展示站' }}</h1>
|
||||
<p class="lede">{{ $storeSettings['home_lede'] ?? '前台只负责展示插件与主题的资料、分类、兼容性和截图,真正的发布、上传和站点接入都由服务端统一维护,信息层次更清楚。' }}</p>
|
||||
<div class="hero-actions">
|
||||
<a class="btn" href="{{ route('storefront.plugins') }}">浏览插件</a>
|
||||
<a class="btn ghost" href="{{ route('storefront.themes') }}">浏览主题</a>
|
||||
</div>
|
||||
<div class="stats">
|
||||
<div class="stat"><strong>{{ $stats['packages'] }}</strong><span>已发布扩展</span></div>
|
||||
<div class="stat"><strong>{{ $stats['plugins'] }}</strong><span>插件</span></div>
|
||||
<div class="stat"><strong>{{ $stats['themes'] }}</strong><span>主题</span></div>
|
||||
<div class="stat"><strong>{{ $stats['categories'] }}</strong><span>分类</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hero-card">
|
||||
<h3 style="margin:0 0 10px;font-size:20px">当前站点定位</h3>
|
||||
<p>前台面向访客展示内容,后台则面向管理员,通过账号密码登录后维护扩展、分类、版本和 zip 发布。</p>
|
||||
<div class="card-actions" style="margin-top:18px">
|
||||
<a class="btn" href="{{ route('storefront.plugins') }}">查看插件</a>
|
||||
<a class="btn secondary" href="{{ route('storefront.themes') }}">查看主题</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@endsection
|
||||
|
||||
@section('content')
|
||||
<div class="panel">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<h2>推荐插件</h2>
|
||||
<p>展示基础信息、版本兼容性与分类,不直接提供 zip 下载地址。</p>
|
||||
</div>
|
||||
<a class="btn secondary" href="{{ route('storefront.plugins') }}">查看全部插件</a>
|
||||
</div>
|
||||
<div class="grid">
|
||||
@forelse($featuredPlugins as $package)
|
||||
<div class="card">
|
||||
<div class="card-top">
|
||||
@if($package->icon_url)
|
||||
<img class="icon" src="{{ $package->icon_url }}" alt="{{ $package->name }}">
|
||||
@else
|
||||
<div class="icon"></div>
|
||||
@endif
|
||||
<div>
|
||||
<div class="title">{{ $package->name }}</div>
|
||||
<div class="muted">{{ $package->author ?: '未知作者' }} · {{ $package->latestStableVersion?->version ?: $package->latest_version }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="summary">{{ $package->summary ?: '暂无摘要' }}</div>
|
||||
<div class="tags">
|
||||
@foreach($package->categories as $category)
|
||||
<span class="chip">{{ $category->name }}</span>
|
||||
@endforeach
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<a class="btn secondary" href="{{ route('storefront.show', [$package->type, $package->slug]) }}">查看详情</a>
|
||||
</div>
|
||||
<section class="section">
|
||||
<div class="container">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<span class="section-kicker">Plugins</span>
|
||||
<h2>{{ $storeSettings['home_plugins_title'] ?? '推荐插件' }}</h2>
|
||||
<p>{{ $storeSettings['home_plugins_subtitle'] ?? '优先展示适合作为目录首页入口的插件,突出摘要、作者、分类和版本信息。' }}</p>
|
||||
</div>
|
||||
@empty
|
||||
<div class="empty">当前没有推荐插件。</div>
|
||||
@endforelse
|
||||
<a class="btn ghost sm" href="{{ route('storefront.plugins') }}">查看全部</a>
|
||||
</div>
|
||||
<div class="grid">
|
||||
@forelse($featuredPlugins as $package)
|
||||
@include('storefront.partials.card', ['package' => $package, 'typeLabel' => '插件', 'fallback' => 'P'])
|
||||
@empty
|
||||
<div class="empty">当前还没有推荐插件。</div>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="panel section">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<h2>推荐主题</h2>
|
||||
<p>适合做展示站首页,突出视觉风格、分类和版本信息。</p>
|
||||
</div>
|
||||
<a class="btn secondary" href="{{ route('storefront.themes') }}">查看全部主题</a>
|
||||
</div>
|
||||
<div class="grid">
|
||||
@forelse($featuredThemes as $package)
|
||||
<div class="card">
|
||||
<div class="card-top">
|
||||
@if($package->icon_url)
|
||||
<img class="icon" src="{{ $package->icon_url }}" alt="{{ $package->name }}">
|
||||
@else
|
||||
<div class="icon"></div>
|
||||
@endif
|
||||
<div>
|
||||
<div class="title">{{ $package->name }}</div>
|
||||
<div class="muted">{{ $package->author ?: '未知作者' }} · {{ $package->latestStableVersion?->version ?: $package->latest_version }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="summary">{{ $package->summary ?: '暂无摘要' }}</div>
|
||||
<div class="tags">
|
||||
@foreach($package->categories as $category)
|
||||
<span class="chip">{{ $category->name }}</span>
|
||||
@endforeach
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<a class="btn secondary" href="{{ route('storefront.show', [$package->type, $package->slug]) }}">查看详情</a>
|
||||
</div>
|
||||
<section class="section">
|
||||
<div class="container">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<span class="section-kicker">Themes</span>
|
||||
<h2>{{ $storeSettings['home_themes_title'] ?? '推荐主题' }}</h2>
|
||||
<p>{{ $storeSettings['home_themes_subtitle'] ?? '主题列表保持同样的展示逻辑,方便在统一视觉下浏览不同类型的扩展内容。' }}</p>
|
||||
</div>
|
||||
@empty
|
||||
<div class="empty">当前没有推荐主题。</div>
|
||||
@endforelse
|
||||
<a class="btn ghost sm" href="{{ route('storefront.themes') }}">查看全部</a>
|
||||
</div>
|
||||
<div class="grid">
|
||||
@forelse($featuredThemes as $package)
|
||||
@include('storefront.partials.card', ['package' => $package, 'typeLabel' => '主题', 'fallback' => 'T'])
|
||||
@empty
|
||||
<div class="empty">当前还没有推荐主题。</div>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@endsection
|
||||
|
||||
@@ -3,42 +3,194 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{ $title ?? 'Tstore' }}</title>
|
||||
<title>{{ $title ?? ($storeSettings['site_name'] ?? config('app.name', 'Tstore')) }}</title>
|
||||
<script>
|
||||
(function(){try{var t=localStorage.getItem('tstore-theme');if(t){document.documentElement.setAttribute('data-theme',t);}}catch(e){}})();
|
||||
</script>
|
||||
<style>
|
||||
:root{--bg:#0f172a;--panel:#ffffff;--soft:#f5f8fc;--line:#dbe5f0;--text:#16324f;--muted:#607489;--brand:#2f6ea6;--brand-2:#61a6e3;--shadow:0 18px 48px rgba(15,23,42,.08)}
|
||||
*{box-sizing:border-box}body{margin:0;font:14px/1.7 -apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;background:#f4f8fb;color:var(--text)}a{text-decoration:none;color:inherit}
|
||||
.container{max-width:1180px;margin:0 auto;padding:0 20px}.hero{background:linear-gradient(135deg,#10233a 0%,#16324f 42%,#24527c 100%);color:#fff;padding:24px 0 84px}.nav{display:flex;justify-content:space-between;align-items:center;gap:16px;padding:18px 0}.brand{font-size:22px;font-weight:800}.nav-links{display:flex;gap:10px;flex-wrap:wrap}.nav-links a{padding:10px 14px;border-radius:999px;color:rgba(240,247,255,.88)}.nav-links a:hover,.nav-links a.active{background:rgba(255,255,255,.12)}
|
||||
.hero-grid{display:grid;grid-template-columns:1.15fr .85fr;gap:18px;align-items:center;padding-top:10px}.hero-card{background:rgba(255,255,255,.1);border:1px solid rgba(255,255,255,.12);border-radius:26px;padding:26px;backdrop-filter:blur(10px)}.hero h1{margin:0 0 12px;font-size:44px;line-height:1.08;letter-spacing:-.03em}.hero p{margin:0;color:rgba(230,239,249,.9);max-width:680px}.hero-meta{display:flex;gap:12px;flex-wrap:wrap;margin-top:20px}.hero-stat{min-width:120px;padding:14px 16px;border-radius:18px;background:rgba(255,255,255,.12)}.hero-stat strong{display:block;font-size:24px}.hero-stat span{display:block;font-size:12px;color:rgba(231,239,248,.78);margin-top:4px}
|
||||
.shell{margin-top:-52px;padding-bottom:48px}.panel{background:#fff;border:1px solid var(--line);border-radius:24px;padding:22px;box-shadow:var(--shadow)}.section{margin-top:18px}.section-head{display:flex;justify-content:space-between;align-items:end;gap:16px;margin-bottom:14px}.section-head h2{margin:0;font-size:24px}.section-head p{margin:6px 0 0;color:var(--muted)}
|
||||
.toolbar{display:flex;justify-content:space-between;gap:12px;align-items:flex-end;flex-wrap:wrap;margin-bottom:16px}.filters{display:flex;gap:10px;flex-wrap:wrap}.field{display:grid;gap:6px}.field label{font-size:12px;color:var(--muted);font-weight:700}.input,.select{padding:11px 13px;border:1px solid #cfdae8;border-radius:12px;background:#f9fbfd;min-width:180px}.input:focus,.select:focus{outline:none;border-color:#77aee6;box-shadow:0 0 0 4px rgba(89,156,226,.12);background:#fff}
|
||||
.btn{display:inline-flex;align-items:center;justify-content:center;padding:11px 15px;border-radius:12px;background:linear-gradient(135deg,var(--brand),var(--brand-2));color:#fff;font-weight:700;border:none;cursor:pointer}.btn.secondary{background:#fff;color:var(--brand);border:1px solid #cfe0f1}
|
||||
.grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:18px}.card{background:linear-gradient(180deg,#fff 0,#fbfdff 100%);border:1px solid var(--line);border-radius:22px;padding:18px;box-shadow:0 12px 28px rgba(15,23,42,.05)}.card-top{display:flex;gap:14px;align-items:flex-start}.icon{width:64px;height:64px;border-radius:18px;background:#edf4fb;object-fit:cover;border:1px solid #d9e6f3}.title{font-size:18px;font-weight:800;margin:0}.muted{color:var(--muted)}.summary{margin:14px 0;color:#4a6176;min-height:72px}.tags{display:flex;gap:8px;flex-wrap:wrap;margin-top:12px}.chip{display:inline-flex;align-items:center;padding:4px 10px;border-radius:999px;font-size:12px;font-weight:700;background:#edf4fb;color:#2d689d;border:1px solid #d8e7f5}.card-actions{display:flex;gap:10px;flex-wrap:wrap;margin-top:16px}
|
||||
.stats-row{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:16px}.stat{padding:18px;border-radius:22px;background:linear-gradient(180deg,#ffffff 0,#f8fbfe 100%);border:1px solid var(--line)}.stat strong{display:block;font-size:30px;line-height:1.1}.stat span{display:block;margin-top:6px;color:var(--muted)}
|
||||
.detail-grid{display:grid;grid-template-columns:1.2fr .8fr;gap:18px}.detail-card{background:#fff;border:1px solid var(--line);border-radius:22px;padding:20px;box-shadow:var(--shadow)}.detail-card h3{margin:0 0 12px;font-size:20px}.version-list{display:grid;gap:10px}.version{border:1px solid var(--line);border-radius:16px;padding:14px;background:#fbfdff}.version strong{font-size:16px}
|
||||
.gallery{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.shot{border:1px solid var(--line);border-radius:16px;overflow:hidden;background:#fff}.shot img{width:100%;height:180px;object-fit:cover;display:block}.shot div{padding:10px 12px;color:var(--muted);font-size:13px}
|
||||
.empty{padding:36px 18px;border:1px dashed var(--line);border-radius:18px;text-align:center;background:#fbfdff;color:var(--muted)}
|
||||
@media (max-width:960px){.hero-grid,.detail-grid{grid-template-columns:1fr}.grid,.stats-row{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (max-width:640px){.grid,.stats-row,.gallery{grid-template-columns:1fr}.hero h1{font-size:34px}.shell{margin-top:-36px}.nav{flex-direction:column;align-items:flex-start}}
|
||||
:root{
|
||||
--bg:#ffffff;--ink:#111111;--ink-soft:#6b6b6b;--ink-mute:#9a9a9a;
|
||||
--line:#e7e7e7;--line-strong:#111111;--hover:#f5f5f5;--panel:#ffffff;
|
||||
}
|
||||
:root[data-theme="dark"]{
|
||||
--bg:#0a0a0a;--ink:#f2f2f2;--ink-soft:#a3a3a3;--ink-mute:#6f6f6f;
|
||||
--line:#262626;--line-strong:#f2f2f2;--hover:#171717;--panel:#0f0f0f;
|
||||
}
|
||||
@media (prefers-color-scheme:dark){
|
||||
:root:not([data-theme="light"]){
|
||||
--bg:#0a0a0a;--ink:#f2f2f2;--ink-soft:#a3a3a3;--ink-mute:#6f6f6f;
|
||||
--line:#262626;--line-strong:#f2f2f2;--hover:#171717;--panel:#0f0f0f;
|
||||
}
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;font:15px/1.7 "Inter","PingFang SC","Microsoft YaHei UI",-apple-system,sans-serif;color:var(--ink);background:var(--bg);-webkit-font-smoothing:antialiased;transition:background .2s,color .2s}
|
||||
a{color:inherit;text-decoration:none}
|
||||
img{display:block;max-width:100%}
|
||||
code{padding:2px 6px;background:var(--hover);border:1px solid var(--line);font-size:.9em}
|
||||
.container{width:min(1080px,calc(100% - 48px));margin:0 auto}
|
||||
|
||||
/* 顶栏 */
|
||||
.topbar{border-bottom:1px solid var(--line)}
|
||||
.topbar-inner{display:flex;justify-content:space-between;align-items:center;height:64px;gap:12px}
|
||||
.brand{display:flex;align-items:center;gap:10px;font-weight:600;font-size:16px}
|
||||
.brand-mark{display:grid;place-items:center;width:24px;height:24px;background:var(--ink);color:var(--bg);font-size:11px;font-weight:700;letter-spacing:.02em}
|
||||
.brand-copy strong{display:block;font-size:16px;line-height:1.1;font-weight:600}
|
||||
.nav{display:flex;align-items:center;gap:4px;flex-wrap:wrap}
|
||||
.nav a{padding:7px 12px;color:var(--ink-soft);font-size:14px;font-weight:500;transition:background .15s,color .15s}
|
||||
.nav a:hover{background:var(--hover);color:var(--ink)}
|
||||
.nav a.active{color:var(--ink);background:var(--hover)}
|
||||
.theme-toggle{display:grid;place-items:center;width:34px;height:34px;margin-left:6px;border:1px solid var(--line);background:transparent;color:var(--ink-soft);cursor:pointer;font-size:15px;line-height:1;transition:border-color .15s,color .15s}
|
||||
.theme-toggle:hover{border-color:var(--ink);color:var(--ink)}
|
||||
|
||||
/* hero */
|
||||
.hero{padding:80px 0 52px;border-bottom:1px solid var(--line)}
|
||||
.eyebrow{display:inline-block;font-size:12px;font-weight:600;letter-spacing:.14em;text-transform:uppercase;color:var(--ink-mute)}
|
||||
.headline{margin:18px 0 0;font-size:40px;line-height:1.12;letter-spacing:-.025em;font-weight:700}
|
||||
.lede{margin:18px 0 0;max-width:44em;color:var(--ink-soft);font-size:16px;line-height:1.8}
|
||||
.hero-actions{display:flex;gap:10px;margin-top:32px;flex-wrap:wrap}
|
||||
|
||||
/* 按钮 */
|
||||
.btn{display:inline-flex;align-items:center;justify-content:center;height:40px;padding:0 18px;font-size:14px;font-weight:600;border:1px solid var(--line-strong);background:var(--ink);color:var(--bg);cursor:pointer;transition:opacity .15s,border-color .15s,background .15s}
|
||||
.btn:hover{opacity:.82}
|
||||
.btn.ghost{background:var(--panel);color:var(--ink);border-color:var(--line)}
|
||||
.btn.ghost:hover{border-color:var(--ink);opacity:1}
|
||||
.btn.sm{height:34px;padding:0 14px;font-size:13px}
|
||||
|
||||
/* 统计横排 */
|
||||
.stats{display:flex;gap:52px;margin-top:44px;flex-wrap:wrap}
|
||||
.stat strong{display:block;font-size:26px;font-weight:700;letter-spacing:-.015em}
|
||||
.stat span{display:block;margin-top:3px;font-size:13px;color:var(--ink-mute)}
|
||||
|
||||
/* 区块 */
|
||||
.section{padding:56px 0;border-bottom:1px solid var(--line)}
|
||||
.section:last-of-type{border-bottom:0}
|
||||
.section-head{display:flex;justify-content:space-between;align-items:flex-end;gap:16px;margin-bottom:32px}
|
||||
.section-head h2{margin:0;font-size:22px;font-weight:700;letter-spacing:-.015em}
|
||||
.section-head p{margin:7px 0 0;color:var(--ink-soft);font-size:14px;max-width:46em}
|
||||
.section-kicker{display:block;font-size:12px;font-weight:600;letter-spacing:.14em;text-transform:uppercase;color:var(--ink-mute);margin-bottom:10px}
|
||||
|
||||
/* 卡片网格 */
|
||||
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:16px}
|
||||
.card{display:flex;flex-direction:column;padding:22px;border:1px solid var(--line);background:var(--panel);transition:border-color .15s,background .15s}
|
||||
.card:hover{border-color:var(--ink);background:var(--hover)}
|
||||
.card-top{display:flex;gap:12px;align-items:center}
|
||||
.card-icon{width:44px;height:44px;border:1px solid var(--line);object-fit:cover;flex:none;background:var(--hover)}
|
||||
.card-placeholder{display:grid;place-items:center;font-weight:700;color:var(--ink-soft)}
|
||||
.card-title{margin:0;font-size:16px;font-weight:600}
|
||||
.card-meta{margin:3px 0 0;font-size:13px;color:var(--ink-mute)}
|
||||
.card-summary{margin:18px 0 0;color:var(--ink-soft);font-size:14px;line-height:1.75;flex:1}
|
||||
.tags{display:flex;gap:6px;flex-wrap:wrap;margin-top:18px}
|
||||
.tag{font-size:12px;font-weight:500;padding:3px 9px;border:1px solid var(--line);color:var(--ink-soft)}
|
||||
.tag.solid{background:var(--ink);color:var(--bg);border-color:var(--ink)}
|
||||
.card-foot{display:flex;justify-content:space-between;align-items:center;margin-top:20px;padding-top:18px;border-top:1px solid var(--line)}
|
||||
.card-foot .dl{font-size:13px;color:var(--ink-mute)}
|
||||
|
||||
/* 筛选表单 */
|
||||
.filter-bar{display:flex;gap:14px;align-items:flex-end;flex-wrap:wrap;margin-top:28px}
|
||||
.field{display:grid;gap:6px}
|
||||
.field label{font-size:12px;font-weight:600;color:var(--ink-mute);letter-spacing:.06em;text-transform:uppercase}
|
||||
.input,.select{height:40px;min-width:180px;width:100%;padding:0 12px;border:1px solid var(--line);background:var(--panel);color:var(--ink);font-size:14px;outline:none;transition:border-color .15s}
|
||||
.input:focus,.select:focus{border-color:var(--ink)}
|
||||
.input::placeholder{color:var(--ink-mute)}
|
||||
|
||||
/* 详情页 */
|
||||
.detail-head{display:flex;gap:18px;align-items:flex-start}
|
||||
.detail-icon{width:72px;height:72px;object-fit:cover;border:1px solid var(--line);background:var(--hover);flex:none}
|
||||
.detail-placeholder{display:grid;place-items:center;font-size:26px;font-weight:700;color:var(--ink-soft)}
|
||||
.detail-layout{display:grid;grid-template-columns:minmax(0,1.4fr) minmax(300px,1fr);gap:32px}
|
||||
.stack{display:grid;gap:28px}
|
||||
.info-card + .info-card{padding-top:28px;border-top:1px solid var(--line)}
|
||||
.info-card h3{margin:0 0 18px;font-size:18px;font-weight:700}
|
||||
.rich-text{color:var(--ink-soft);line-height:1.9;white-space:pre-wrap}
|
||||
.info-list{display:grid;gap:16px}
|
||||
.info-item strong{display:block;font-size:12px;color:var(--ink-mute);letter-spacing:.06em;text-transform:uppercase}
|
||||
.info-item span,.info-item a{display:block;margin-top:4px;color:var(--ink);word-break:break-word}
|
||||
|
||||
/* 版本记录 */
|
||||
.version-list{display:grid;gap:0}
|
||||
.version-item{padding:18px 0;border-top:1px solid var(--line)}
|
||||
.version-item:first-child{border-top:0;padding-top:0}
|
||||
.version-head{display:flex;justify-content:space-between;gap:12px;align-items:flex-start}
|
||||
.version-title{font-size:15px;font-weight:700}
|
||||
.version-meta{margin-top:4px;color:var(--ink-mute);font-size:13px}
|
||||
|
||||
/* 截图 */
|
||||
.shots{display:grid;gap:16px}
|
||||
.shot-feature,.shot-card{margin:0;border:1px solid var(--line);overflow:hidden;background:var(--panel)}
|
||||
.shot-feature img{width:100%;height:360px;object-fit:cover;border-bottom:1px solid var(--line)}
|
||||
.shot-feature figcaption{display:grid;gap:5px;padding:14px 16px}
|
||||
.shot-feature strong{font-size:15px;font-weight:600}
|
||||
.shot-feature span{color:var(--ink-mute);font-size:13px;line-height:1.6}
|
||||
.shot-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:14px}
|
||||
.shot-card img{width:100%;height:180px;object-fit:cover;border-bottom:1px solid var(--line)}
|
||||
.shot-card figcaption{padding:10px 14px;color:var(--ink-mute);font-size:13px}
|
||||
|
||||
/* 空状态 */
|
||||
.empty{padding:44px 22px;border:1px dashed var(--line);color:var(--ink-mute);text-align:center;font-size:14px}
|
||||
|
||||
/* footer */
|
||||
.footer{padding:36px 0}
|
||||
.footer-inner{display:flex;justify-content:space-between;gap:12px;flex-wrap:wrap;color:var(--ink-mute);font-size:13px}
|
||||
.footer-inner strong{color:var(--ink);font-weight:600}
|
||||
|
||||
/* 分页 */
|
||||
nav[role="navigation"]{margin-top:36px}
|
||||
nav[role="navigation"] > div:first-child{display:none}
|
||||
nav[role="navigation"] > div:last-child{display:flex;justify-content:center;flex-wrap:wrap;gap:6px}
|
||||
nav[role="navigation"] a,nav[role="navigation"] span{display:inline-flex;align-items:center;justify-content:center;min-width:38px;height:38px;padding:0 12px;border:1px solid var(--line);background:var(--panel);color:var(--ink);font-size:14px}
|
||||
nav[role="navigation"] a:hover{border-color:var(--ink)}
|
||||
nav[role="navigation"] span[aria-current="page"]{background:var(--ink);border-color:var(--ink);color:var(--bg)}
|
||||
nav[role="navigation"] svg{display:none}
|
||||
|
||||
@media (max-width:820px){.detail-layout{grid-template-columns:1fr}}
|
||||
@media (max-width:640px){
|
||||
.container{width:min(100% - 32px,1080px)}
|
||||
.hero{padding:56px 0 44px}
|
||||
.headline{font-size:31px}
|
||||
.stats{gap:32px;margin-top:36px}
|
||||
.section{padding:44px 0}
|
||||
.section-head{flex-direction:column;align-items:flex-start}
|
||||
.shot-feature img{height:240px}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="hero">
|
||||
<div class="container">
|
||||
<div class="nav">
|
||||
<div class="brand"><a href="{{ route('storefront.home') }}">Tstore</a></div>
|
||||
<div class="nav-links">
|
||||
<a href="{{ route('storefront.home') }}" class="{{ request()->routeIs('storefront.home') ? 'active' : '' }}">首页</a>
|
||||
<a href="{{ route('storefront.plugins') }}" class="{{ request()->routeIs('storefront.plugins') ? 'active' : '' }}">插件</a>
|
||||
<a href="{{ route('storefront.themes') }}" class="{{ request()->routeIs('storefront.themes') ? 'active' : '' }}">主题</a>
|
||||
<a href="{{ route('webadmin.login') }}">后台登录</a>
|
||||
</div>
|
||||
</div>
|
||||
@yield('hero')
|
||||
<header class="topbar">
|
||||
<div class="container topbar-inner">
|
||||
<a class="brand" href="{{ route('storefront.home') }}">
|
||||
<span class="brand-mark">{{ mb_substr($storeSettings['site_name'] ?? 'T', 0, 1) }}</span>
|
||||
<span class="brand-copy"><strong>{{ $storeSettings['site_name'] ?? config('app.name', 'Tstore') }}</strong></span>
|
||||
</a>
|
||||
<nav class="nav">
|
||||
<a href="{{ route('storefront.home') }}" class="{{ request()->routeIs('storefront.home') ? 'active' : '' }}">首页</a>
|
||||
<a href="{{ route('storefront.plugins') }}" class="{{ request()->routeIs('storefront.plugins') ? 'active' : '' }}">插件</a>
|
||||
<a href="{{ route('storefront.themes') }}" class="{{ request()->routeIs('storefront.themes') ? 'active' : '' }}">主题</a>
|
||||
<button type="button" class="theme-toggle" id="themeToggle" aria-label="切换深浅色">◐</button>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
<main class="shell">
|
||||
<div class="container">
|
||||
@yield('content')
|
||||
</div>
|
||||
@yield('hero')
|
||||
<main>
|
||||
@yield('content')
|
||||
</main>
|
||||
<footer class="footer">
|
||||
<div class="container footer-inner">
|
||||
<div><strong>{{ $storeSettings['site_name'] ?? config('app.name', 'Tstore') }}</strong> · {{ $storeSettings['site_tagline'] ?? 'Typecho Extension Directory' }}</div>
|
||||
<div>Copyright © {{ date('Y') }} {{ $storeSettings['site_name'] ?? config('app.name', 'Tstore') }}. 版权所有。</div>
|
||||
</div>
|
||||
</footer>
|
||||
<script>
|
||||
(function(){
|
||||
var btn=document.getElementById('themeToggle');
|
||||
if(!btn)return;
|
||||
var mql=window.matchMedia('(prefers-color-scheme:dark)');
|
||||
function current(){var s=document.documentElement.getAttribute('data-theme');return s?s:(mql.matches?'dark':'light');}
|
||||
btn.addEventListener('click',function(){
|
||||
var next=current()==='dark'?'light':'dark';
|
||||
document.documentElement.setAttribute('data-theme',next);
|
||||
try{localStorage.setItem('tstore-theme',next);}catch(e){}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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')
|
||||
<div class="hero-grid">
|
||||
<div class="hero-card">
|
||||
<h1>{{ $type === 'plugin' ? '插件目录' : '主题目录' }}</h1>
|
||||
<p>支持按分类和关键词浏览 {{ $type === 'plugin' ? '插件' : '主题' }},这里只展示信息,不直接提供下载地址。</p>
|
||||
</div>
|
||||
<div class="hero-card">
|
||||
<form method="get" class="toolbar" style="margin:0">
|
||||
<div class="filters" style="width:100%">
|
||||
<div class="field" style="flex:1 1 220px">
|
||||
<label>关键词</label>
|
||||
<input class="input" type="text" name="keyword" value="{{ $filters['keyword'] }}" placeholder="搜索名称、slug 或摘要">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>分类</label>
|
||||
<select class="select" name="category">
|
||||
<option value="">全部</option>
|
||||
@foreach($categories as $category)
|
||||
<option value="{{ $category->slug }}" @selected($filters['category'] === $category->slug)>{{ $category->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>排序</label>
|
||||
<select class="select" name="sort">
|
||||
<option value="latest" @selected($filters['sort'] === 'latest')>最新更新</option>
|
||||
<option value="name" @selected($filters['sort'] === 'name')>按名称</option>
|
||||
</select>
|
||||
</div>
|
||||
<section class="hero">
|
||||
<div class="container">
|
||||
<span class="eyebrow">{{ $typeLabel }}</span>
|
||||
<h1 class="headline">{{ $typeLabel }}目录</h1>
|
||||
<p class="lede">统一浏览 {{ $typeLabel }} 的摘要、分类、作者和版本,筛选逻辑保持轻量,适合做公开展示与目录导航。</p>
|
||||
<form method="get" class="filter-bar">
|
||||
<div class="field" style="flex:2 1 260px">
|
||||
<label>关键词</label>
|
||||
<input class="input" type="text" name="keyword" value="{{ $filters['keyword'] }}" placeholder="搜索名称、slug 或摘要">
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<button class="btn" type="submit">筛选</button>
|
||||
<div class="field" style="flex:1 1 180px">
|
||||
<label>分类</label>
|
||||
<select class="select" name="category">
|
||||
<option value="">全部</option>
|
||||
@foreach($categories as $category)
|
||||
<option value="{{ $category->slug }}" @selected($filters['category'] === $category->slug)>{{ $category->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div class="field" style="flex:1 1 160px">
|
||||
<label>排序</label>
|
||||
<select class="select" name="sort">
|
||||
<option value="latest" @selected($filters['sort'] === 'latest')>最近更新</option>
|
||||
<option value="name" @selected($filters['sort'] === 'name')>按名称</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="btn" type="submit">应用筛选</button>
|
||||
<a class="btn ghost" href="{{ route($type === 'plugin' ? 'storefront.plugins' : 'storefront.themes') }}">重置</a>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@endsection
|
||||
|
||||
@section('content')
|
||||
<div class="panel">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<h2>{{ $type === 'plugin' ? '插件列表' : '主题列表' }}</h2>
|
||||
<p>共 {{ $packages->total() }} 项结果</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid">
|
||||
@forelse($packages as $package)
|
||||
<div class="card">
|
||||
<div class="card-top">
|
||||
@if($package->icon_url)
|
||||
<img class="icon" src="{{ $package->icon_url }}" alt="{{ $package->name }}">
|
||||
@else
|
||||
<div class="icon"></div>
|
||||
@endif
|
||||
<div>
|
||||
<div class="title">{{ $package->name }}</div>
|
||||
<div class="muted">{{ $package->author ?: '未知作者' }} · {{ $package->latestStableVersion?->version ?: $package->latest_version }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="summary">{{ $package->summary ?: '暂无摘要' }}</div>
|
||||
<div class="tags">
|
||||
@foreach($package->categories as $category)
|
||||
<span class="chip">{{ $category->name }}</span>
|
||||
@endforeach
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<a class="btn secondary" href="{{ route('storefront.show', [$package->type, $package->slug]) }}">查看详情</a>
|
||||
</div>
|
||||
<section class="section">
|
||||
<div class="container">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<span class="section-kicker">Catalog</span>
|
||||
<h2>{{ $typeLabel }}列表</h2>
|
||||
<p>共找到 {{ $packages->total() }} 个结果 · 当前分类 {{ $selectedCategory ?: '全部' }} · {{ $sortLabel }}。</p>
|
||||
</div>
|
||||
@empty
|
||||
<div class="empty">当前没有符合条件的{{ $type === 'plugin' ? '插件' : '主题' }}。</div>
|
||||
@endforelse
|
||||
</div>
|
||||
<div class="grid">
|
||||
@forelse($packages as $package)
|
||||
@include('storefront.partials.card', ['package' => $package, 'typeLabel' => $typeLabel, 'fallback' => $fallback])
|
||||
@empty
|
||||
<div class="empty">当前没有符合条件的{{ $typeLabel }}。</div>
|
||||
@endforelse
|
||||
</div>
|
||||
{{ $packages->onEachSide(1)->links() }}
|
||||
</div>
|
||||
<div style="margin-top:18px">{{ $packages->links() }}</div>
|
||||
</div>
|
||||
</section>
|
||||
@endsection
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
<article class="card">
|
||||
<div class="card-top">
|
||||
@if($package->icon_url)
|
||||
<img class="card-icon" src="{{ $package->icon_url }}" alt="{{ $package->name }}">
|
||||
@else
|
||||
<div class="card-icon card-placeholder">{{ $fallback }}</div>
|
||||
@endif
|
||||
<div>
|
||||
<h3 class="card-title">{{ $package->name }}</h3>
|
||||
<p class="card-meta">{{ $package->author ?: '未知作者' }} · v{{ $package->latestStableVersion?->version ?: $package->latest_version ?: '-' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="card-summary">{{ $package->summary ?: '暂无摘要。' }}</p>
|
||||
<div class="tags">
|
||||
@foreach($package->categories as $category)
|
||||
<span class="tag">{{ $category->name }}</span>
|
||||
@endforeach
|
||||
@if($package->is_featured)
|
||||
<span class="tag solid">推荐</span>
|
||||
@endif
|
||||
</div>
|
||||
<div class="card-foot">
|
||||
<span class="dl">下载 {{ $package->download_count }}</span>
|
||||
<a class="btn ghost sm" href="{{ route($package->type === 'plugin' ? 'storefront.plugin.show' : 'storefront.theme.show', ['slug' => $package->slug]) }}">查看详情</a>
|
||||
</div>
|
||||
</article>
|
||||
@@ -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')
|
||||
<div class="hero-grid">
|
||||
<div class="hero-card">
|
||||
<h1>{{ $detail['name'] }}</h1>
|
||||
<p>{{ $detail['summary'] ?: '暂无摘要' }}</p>
|
||||
<div class="hero-meta">
|
||||
<div class="hero-stat"><strong>{{ $detail['type'] }}</strong><span>类型</span></div>
|
||||
<div class="hero-stat"><strong>{{ $detail['download_count'] }}</strong><span>累计下载</span></div>
|
||||
<div class="hero-stat"><strong>{{ count($detail['versions']) }}</strong><span>版本数</span></div>
|
||||
<div class="hero-stat"><strong>{{ count($detail['categories']) }}</strong><span>分类</span></div>
|
||||
<section class="hero">
|
||||
<div class="container">
|
||||
<span class="eyebrow">{{ $typeLabel }}</span>
|
||||
<div class="detail-head" style="margin-top:16px">
|
||||
@if($detail['icon_url'])
|
||||
<img class="detail-icon" src="{{ $detail['icon_url'] }}" alt="{{ $detail['name'] }}">
|
||||
@else
|
||||
<div class="detail-icon detail-placeholder">{{ $package->type === 'plugin' ? 'P' : 'T' }}</div>
|
||||
@endif
|
||||
<div>
|
||||
<h1 class="headline" style="margin:0;font-size:30px">{{ $detail['name'] }}</h1>
|
||||
<p class="lede" style="margin-top:10px">{{ $detail['summary'] ?: '这个' . $typeLabel . '当前还没有摘要,详情页会继续保留版本、兼容性和截图信息。' }}</p>
|
||||
<div class="tags">
|
||||
@foreach($package->categories as $category)
|
||||
<span class="tag">{{ $category->name }}</span>
|
||||
@endforeach
|
||||
@if($detail['is_featured'])
|
||||
<span class="tag solid">推荐</span>
|
||||
@endif
|
||||
<span class="tag">{{ $detail['author'] ?: '未知作者' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hero-actions">
|
||||
<a class="btn ghost" href="{{ route($listRoute) }}">返回{{ $typeLabel }}目录</a>
|
||||
@if($detail['homepage'])
|
||||
<a class="btn" href="{{ $detail['homepage'] }}" target="_blank" rel="noreferrer">访问主页</a>
|
||||
@endif
|
||||
</div>
|
||||
<div class="stats">
|
||||
<div class="stat"><strong>{{ strtoupper($detail['type']) }}</strong><span>扩展类型</span></div>
|
||||
<div class="stat"><strong>{{ $detail['download_count'] }}</strong><span>累计下载</span></div>
|
||||
<div class="stat"><strong>{{ count($detail['versions']) }}</strong><span>版本记录</span></div>
|
||||
<div class="stat"><strong>{{ $latestVersion['version'] ?? '-' }}</strong><span>当前主版本</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hero-card">
|
||||
<h3 style="margin:0 0 10px;font-size:20px">说明</h3>
|
||||
<p>这是前台展示页,只展示扩展信息、版本兼容性和截图,不直接暴露下载地址。需要维护请进入后台。</p>
|
||||
<div class="card-actions" style="margin-top:18px">
|
||||
<a class="btn secondary" href="{{ route('webadmin.login') }}">后台登录</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@endsection
|
||||
|
||||
@section('content')
|
||||
<div class="detail-grid">
|
||||
<div class="detail-card">
|
||||
<h3>扩展说明</h3>
|
||||
<div class="muted">作者:{{ $detail['author'] ?: '未知作者' }}</div>
|
||||
<div class="muted">主页:{{ $detail['homepage'] ?: '未提供' }}</div>
|
||||
<div class="tags">
|
||||
@foreach($package->categories as $category)
|
||||
<span class="chip">{{ $category->name }}</span>
|
||||
@endforeach
|
||||
</div>
|
||||
<div style="margin-top:16px;white-space:pre-wrap">{{ $detail['description'] ?: '暂无详细描述。' }}</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-card">
|
||||
<h3>版本信息</h3>
|
||||
<div class="version-list">
|
||||
@forelse($detail['versions'] as $version)
|
||||
<div class="version">
|
||||
<strong>v{{ $version['version'] }}</strong>
|
||||
<div class="muted">{{ $version['is_latest'] ? '最新版本' : '历史版本' }} · {{ $version['is_stable'] ? '稳定版' : '预发布' }}</div>
|
||||
<div class="muted">Typecho {{ $version['compatibility']['typecho_min'] ?: '-' }} ~ {{ $version['compatibility']['typecho_max'] ?: '-' }}</div>
|
||||
<div class="muted">PHP {{ $version['compatibility']['php_min'] ?: '-' }} ~ {{ $version['compatibility']['php_max'] ?: '-' }}</div>
|
||||
@if(!empty($version['changelog']))
|
||||
<div style="margin-top:8px">{{ $version['changelog'] }}</div>
|
||||
@php
|
||||
$screenshots = $detail['screenshots'];
|
||||
$featuredScreenshot = $screenshots[0] ?? null;
|
||||
$secondaryScreenshots = array_slice($screenshots, 1);
|
||||
@endphp
|
||||
<section class="section">
|
||||
<div class="container">
|
||||
<div class="detail-layout">
|
||||
<div class="stack">
|
||||
<article class="info-card">
|
||||
<span class="section-kicker">Preview</span>
|
||||
<h3>预览截图</h3>
|
||||
@if($featuredScreenshot)
|
||||
<div class="shots">
|
||||
<figure class="shot-feature">
|
||||
<img src="{{ $featuredScreenshot['url'] }}" alt="{{ $featuredScreenshot['caption'] ?: $detail['name'] }}">
|
||||
<figcaption>
|
||||
<strong>{{ $featuredScreenshot['caption'] ?: $detail['name'] . ' 主预览图' }}</strong>
|
||||
<span>第一张截图作为主视觉展示,后台可继续追加更多截图并调整排序。</span>
|
||||
</figcaption>
|
||||
</figure>
|
||||
@if(count($secondaryScreenshots))
|
||||
<div class="shot-grid">
|
||||
@foreach($secondaryScreenshots as $shot)
|
||||
<figure class="shot-card">
|
||||
<img src="{{ $shot['url'] }}" alt="{{ $shot['caption'] ?: $detail['name'] }}">
|
||||
<figcaption>{{ $shot['caption'] ?: $detail['name'] . ' 截图' }}</figcaption>
|
||||
</figure>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@else
|
||||
<div class="empty">当前还没有预览截图。</div>
|
||||
@endif
|
||||
</div>
|
||||
@empty
|
||||
<div class="empty">当前没有版本记录。</div>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if(count($detail['screenshots']))
|
||||
<div class="panel section">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<h2>截图展示</h2>
|
||||
<p>只展示视觉效果,不提供下载入口。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gallery">
|
||||
@foreach($detail['screenshots'] as $shot)
|
||||
<div class="shot">
|
||||
<img src="{{ $shot['url'] }}" alt="{{ $shot['caption'] ?: $detail['name'] }}">
|
||||
<div>{{ $shot['caption'] ?: '扩展截图' }}</div>
|
||||
</article>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="panel section">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<h2>更多{{ $package->type === 'plugin' ? '插件' : '主题' }}</h2>
|
||||
<p>继续浏览同类内容。</p>
|
||||
<aside class="stack">
|
||||
<article class="info-card">
|
||||
<h3>{{ $typeLabel }}说明</h3>
|
||||
<div class="rich-text">{{ $detail['description'] ?: '当前还没有更详细的介绍。' }}</div>
|
||||
</article>
|
||||
|
||||
<article class="info-card">
|
||||
<h3>资料与兼容性</h3>
|
||||
<div class="info-list">
|
||||
<div class="info-item"><strong>作者与协议</strong><span>{{ $detail['author'] ?: '未知作者' }} · {{ $detail['license'] ?: '未声明 License' }}</span></div>
|
||||
<div class="info-item"><strong>最近更新</strong><span>{{ optional($package->updated_at)->format('Y-m-d H:i') ?: '未记录' }}</span></div>
|
||||
@if($detail['homepage'])
|
||||
<div class="info-item"><strong>主页</strong><a href="{{ $detail['homepage'] }}" target="_blank" rel="noreferrer">{{ $detail['homepage'] }}</a></div>
|
||||
@endif
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="info-card">
|
||||
<h3>版本记录</h3>
|
||||
<div class="version-list">
|
||||
@forelse($detail['versions'] as $version)
|
||||
<div class="version-item">
|
||||
<div class="version-head">
|
||||
<div>
|
||||
<div class="version-title">v{{ $version['version'] }}</div>
|
||||
<div class="version-meta">发布于 {{ $version['published_at'] ? \Illuminate\Support\Carbon::parse($version['published_at'])->format('Y-m-d H:i') : '未标记时间' }}</div>
|
||||
</div>
|
||||
<div class="tags" style="margin-top:0">
|
||||
<span class="tag {{ $version['is_stable'] ? 'solid' : '' }}">{{ $version['is_stable'] ? '稳定版' : '预发布' }}</span>
|
||||
@if($version['is_latest'])
|
||||
<span class="tag">最新</span>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
<div class="tags">
|
||||
<span class="tag">Typecho {{ $version['compatibility']['typecho_min'] ?: '-' }} ~ {{ $version['compatibility']['typecho_max'] ?: '-' }}</span>
|
||||
<span class="tag">PHP {{ $version['compatibility']['php_min'] ?: '-' }} ~ {{ $version['compatibility']['php_max'] ?: '-' }}</span>
|
||||
@if(!empty($version['compatibility']['php_extensions']))
|
||||
<span class="tag">扩展 {{ collect($version['compatibility']['php_extensions'])->join(', ') }}</span>
|
||||
@endif
|
||||
</div>
|
||||
@if(!empty($version['changelog']))
|
||||
<div class="rich-text" style="margin-top:12px;font-size:14px">{{ $version['changelog'] }}</div>
|
||||
@endif
|
||||
</div>
|
||||
@empty
|
||||
<div class="empty">当前没有版本记录。</div>
|
||||
@endforelse
|
||||
</div>
|
||||
</article>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid">
|
||||
@forelse($related as $item)
|
||||
<div class="card">
|
||||
<div class="title">{{ $item->name }}</div>
|
||||
<div class="muted">{{ $item->summary ?: '暂无摘要' }}</div>
|
||||
<div class="card-actions">
|
||||
<a class="btn secondary" href="{{ route('storefront.show', [$item->type, $item->slug]) }}">查看详情</a>
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<div class="empty">当前没有更多相关内容。</div>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@endsection
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use App\Http\Controllers\Api\ClientController;
|
||||
use App\Http\Controllers\Api\RepoController;
|
||||
use App\Http\Controllers\Api\CategoryController;
|
||||
|
||||
Route::prefix('v1/client')->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'])
|
||||
|
||||
+21
-1
@@ -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');
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Package;
|
||||
use App\Models\StoreClient;
|
||||
use App\Models\Version;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ClientRegistrationApiTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_client_can_register_and_sync_status(): void
|
||||
{
|
||||
$register = $this->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');
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Tests\TestCase;
|
||||
|
||||
class WebAdminAccountPageTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_authenticated_admin_can_view_account_page(): void
|
||||
{
|
||||
$user = User::factory()->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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\StoreClient;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class WebAdminClientsPageTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_authenticated_admin_can_view_clients_page(): void
|
||||
{
|
||||
$user = User::factory()->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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Package;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class WebAdminPackageCategoriesTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_admin_can_update_package_categories_and_missing_categories_are_created(): void
|
||||
{
|
||||
$user = User::factory()->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' => '内容增强',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Category;
|
||||
use App\Models\Package;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Tests\TestCase;
|
||||
|
||||
class WebAdminPackageDeletionTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_admin_can_delete_package_and_related_data(): void
|
||||
{
|
||||
$user = User::factory()->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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Package;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class WebAdminPackageScreenshotsTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_admin_can_add_and_delete_package_screenshot(): void
|
||||
{
|
||||
$user = User::factory()->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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class WebAdminSettingsPageTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_authenticated_admin_can_update_storefront_settings(): void
|
||||
{
|
||||
$user = User::factory()->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('<title>Team Store · 自定义副标题</title>', false);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user