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

- 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
+184
View File
@@ -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) {
+94
View File
@@ -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);
}
}
+71
View File
@@ -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);
}
}