前端极简黑白重设计,并纳入客户端注册、站点设置与后台相关改动

- storefront 全站改为极简黑白配色:直角、细线分隔、卡片 hover 浅底、深色模式(跟随系统+手动切换)
- 抽出 storefront/partials/card 复用卡片
- 一并提交此前暂存的客户端注册 API、站点设置、后台页面与测试

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
浪子
2026-09-22 08:35:49 +08:00
parent 62cffc6f5f
commit 04a2034975
41 changed files with 3447 additions and 658 deletions
@@ -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;
}
}
+99 -11
View File
@@ -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'],
];
+59
View File
@@ -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);
}
}
+13
View File
@@ -0,0 +1,13 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class StoreSetting extends Model
{
protected $fillable = [
'key',
'value',
];
}
+11 -1
View File
@@ -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);
});
}
}
+90 -6
View File
@@ -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);
}
}
+85
View File
@@ -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;
}
}
}
+6 -1
View File
@@ -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);