04a2034975
- storefront 全站改为极简黑白配色:直角、细线分隔、卡片 hover 浅底、深色模式(跟随系统+手动切换) - 抽出 storefront/partials/card 复用卡片 - 一并提交此前暂存的客户端注册 API、站点设置、后台页面与测试 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
374 lines
14 KiB
PHP
374 lines
14 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers\WebAdmin;
|
|
|
|
use App\Http\Controllers\Controller;
|
|
use App\Http\Requests\Admin\PublishZipVersionRequest;
|
|
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
|
|
{
|
|
public function __construct(
|
|
private readonly AdminPackageService $service,
|
|
private readonly VersionPublishService $publishService,
|
|
) {
|
|
}
|
|
|
|
public function home(): View
|
|
{
|
|
$stats = [
|
|
'packages' => Package::count(),
|
|
'plugins' => Package::where('type', 'plugin')->count(),
|
|
'themes' => Package::where('type', 'theme')->count(),
|
|
'versions' => Version::count(),
|
|
'downloads' => (int) Package::sum('download_count'),
|
|
'categories' => Category::count(),
|
|
] + $this->buildClientStats();
|
|
|
|
$recentPackages = Package::query()
|
|
->with(['categories', 'latestStableVersion'])
|
|
->latest('updated_at')
|
|
->limit(6)
|
|
->get();
|
|
|
|
$recentVersions = Version::query()
|
|
->with('package')
|
|
->latest('published_at')
|
|
->limit(8)
|
|
->get();
|
|
|
|
$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
|
|
{
|
|
$query = Package::query()->with(['categories', 'latestStableVersion']);
|
|
|
|
if ($type = $request->query('type')) {
|
|
$query->where('type', $type);
|
|
}
|
|
|
|
if ($status = $request->query('status')) {
|
|
$query->where('status', $status);
|
|
}
|
|
|
|
if ($keyword = trim((string) $request->query('keyword', ''))) {
|
|
$query->where(function ($q) use ($keyword) {
|
|
$q->where('name', 'like', '%' . $keyword . '%')
|
|
->orWhere('slug', 'like', '%' . $keyword . '%')
|
|
->orWhere('summary', 'like', '%' . $keyword . '%');
|
|
});
|
|
}
|
|
|
|
$packages = $query->orderByDesc('sort_order')->orderByDesc('updated_at')->paginate(15)->withQueryString();
|
|
|
|
return view('admin.packages.index', [
|
|
'packages' => $packages,
|
|
'filters' => [
|
|
'type' => (string) $request->query('type', ''),
|
|
'status' => (string) $request->query('status', ''),
|
|
'keyword' => (string) $request->query('keyword', ''),
|
|
],
|
|
]);
|
|
}
|
|
|
|
public function storePackage(StorePackageRequest $request): RedirectResponse
|
|
{
|
|
$data = $request->validated();
|
|
$data['categories'] = $this->normalizeCategories($request);
|
|
$package = $this->service->createPackage($data);
|
|
|
|
return redirect()->route('webadmin.packages.show', [$package->type, $package->slug])->with('success', '扩展已创建');
|
|
}
|
|
|
|
public function updatePackage(StorePackageRequest $request, string $type, string $slug): RedirectResponse
|
|
{
|
|
$package = $this->findPackage($type, $slug);
|
|
$data = $request->validated();
|
|
$data['categories'] = $this->normalizeCategories($request);
|
|
$this->service->updatePackage($package, $data);
|
|
|
|
return redirect()->route('webadmin.packages.show', [$type, $slug])->with('success', '扩展已更新');
|
|
}
|
|
|
|
public function updatePackageStatus(Request $request, string $type, string $slug): RedirectResponse
|
|
{
|
|
$validated = $request->validate([
|
|
'status' => ['required', 'in:draft,published,hidden,deprecated'],
|
|
]);
|
|
|
|
$package = $this->findPackage($type, $slug);
|
|
$this->service->updateStatus($package, $validated['status']);
|
|
|
|
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');
|
|
|
|
if ($type = $request->query('type')) {
|
|
$query->where('type', $type);
|
|
}
|
|
|
|
if ($keyword = trim((string) $request->query('keyword', ''))) {
|
|
$query->where(function ($q) use ($keyword) {
|
|
$q->where('name', 'like', '%' . $keyword . '%')
|
|
->orWhere('slug', 'like', '%' . $keyword . '%')
|
|
->orWhere('description', 'like', '%' . $keyword . '%');
|
|
});
|
|
}
|
|
|
|
$categories = $query->orderBy('type')->orderByDesc('sort_order')->paginate(20)->withQueryString();
|
|
|
|
return view('admin.categories.index', [
|
|
'categories' => $categories,
|
|
'filters' => [
|
|
'type' => (string) $request->query('type', ''),
|
|
'keyword' => (string) $request->query('keyword', ''),
|
|
],
|
|
]);
|
|
}
|
|
|
|
public function storeCategory(StoreCategoryRequest $request): RedirectResponse
|
|
{
|
|
Category::create($request->validated());
|
|
|
|
return redirect()->route('webadmin.categories')->with('success', '分类已创建');
|
|
}
|
|
|
|
public function updateCategory(StoreCategoryRequest $request, int $id): RedirectResponse
|
|
{
|
|
$category = Category::findOrFail($id);
|
|
$category->update($request->validated());
|
|
|
|
return redirect()->route('webadmin.categories')->with('success', '分类已更新');
|
|
}
|
|
|
|
public function destroyCategory(int $id): RedirectResponse
|
|
{
|
|
$category = Category::findOrFail($id);
|
|
$category->packages()->detach();
|
|
$category->delete();
|
|
|
|
return redirect()->route('webadmin.categories')->with('success', '分类已删除');
|
|
}
|
|
|
|
public function showPackage(string $type, string $slug): View
|
|
{
|
|
$package = Package::query()
|
|
->with(['categories', 'screenshots', 'versions' => fn ($q) => $q->latest('published_at')])
|
|
->where('type', $type)
|
|
->where('slug', $slug)
|
|
->firstOrFail();
|
|
|
|
return view('admin.packages.show', [
|
|
'package' => $package,
|
|
]);
|
|
}
|
|
|
|
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);
|
|
|
|
$validated = $request->validate([
|
|
'version' => ['required', 'string', 'max:32'],
|
|
'changelog' => ['nullable', 'string'],
|
|
'typecho_min' => ['nullable', 'string', 'max:32'],
|
|
'typecho_max' => ['nullable', 'string', 'max:32'],
|
|
'php_min' => ['nullable', 'string', 'max:32'],
|
|
'php_max' => ['nullable', 'string', 'max:32'],
|
|
'package_url' => ['nullable', 'url', 'max:512'],
|
|
'package_size' => ['nullable', 'integer', 'min:0'],
|
|
'sha256' => ['nullable', 'string', 'max:64'],
|
|
'is_stable' => ['nullable', 'boolean'],
|
|
'is_latest' => ['nullable', 'boolean'],
|
|
'published_at' => ['nullable', 'date'],
|
|
'php_extensions' => ['nullable', 'string'],
|
|
]);
|
|
|
|
$payload = [
|
|
'version' => $validated['version'],
|
|
'changelog' => $validated['changelog'] ?? '',
|
|
'typecho_min' => $validated['typecho_min'] ?? '1.2.0',
|
|
'typecho_max' => $validated['typecho_max'] ?? '',
|
|
'php_min' => $validated['php_min'] ?? '7.4',
|
|
'php_max' => $validated['php_max'] ?? '',
|
|
'php_extensions' => collect(explode(',', (string) ($validated['php_extensions'] ?? '')))
|
|
->map(fn ($item) => trim($item))
|
|
->filter()
|
|
->values()
|
|
->all(),
|
|
'package_url' => $validated['package_url'] ?? '',
|
|
'package_size' => (int) ($validated['package_size'] ?? 0),
|
|
'sha256' => $validated['sha256'] ?? str_repeat('0', 64),
|
|
'is_stable' => (bool) ($validated['is_stable'] ?? false),
|
|
'mark_as_latest' => (bool) ($validated['is_latest'] ?? false),
|
|
'published_at' => $validated['published_at'] ?? now(),
|
|
];
|
|
|
|
$version = $this->service->createVersion($package, $payload);
|
|
|
|
return redirect()->route('webadmin.packages.show', [$type, $slug])->with('success', '版本已添加:v' . $version->version);
|
|
}
|
|
|
|
public function publishVersion(PublishZipVersionRequest $request, string $type, string $slug): RedirectResponse
|
|
{
|
|
$package = $this->findPackage($type, $slug);
|
|
$validated = $request->validated();
|
|
|
|
try {
|
|
$version = $this->publishService->publishFromZip($package, $request->file('package_file'), $validated);
|
|
} catch (\Throwable $e) {
|
|
return back()->withInput()->with('error', 'zip 发布失败:' . $e->getMessage());
|
|
}
|
|
|
|
$msg = 'zip 发布成功:v' . $version->version;
|
|
$msg .= $version->is_stable ? ' · stable' : ' · prerelease';
|
|
$msg .= $version->is_latest ? ' · latest' : '';
|
|
|
|
return redirect()->route('webadmin.packages.show', [$type, $slug])->with('success', $msg);
|
|
}
|
|
|
|
public function destroyVersion(string $type, string $slug, int $id): RedirectResponse
|
|
{
|
|
$package = $this->findPackage($type, $slug);
|
|
$version = $package->versions()->where('id', $id)->firstOrFail();
|
|
$label = $version->version;
|
|
$this->service->deleteVersion($version);
|
|
|
|
return redirect()->route('webadmin.packages.show', [$type, $slug])->with('success', '版本已删除:v' . $label);
|
|
}
|
|
|
|
private function findPackage(string $type, string $slug): Package
|
|
{
|
|
return Package::query()
|
|
->where('type', $type)
|
|
->where('slug', $slug)
|
|
->firstOrFail();
|
|
}
|
|
|
|
private function normalizeCategories(Request $request): array
|
|
{
|
|
$raw = (string) $request->input('categories_text', '');
|
|
|
|
$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'),
|
|
];
|
|
}
|
|
}
|