From 5f74dd76e4b07aa1676ce80e929a9a05a1b40739 Mon Sep 17 00:00:00 2001 From: ak Date: Wed, 12 Aug 2026 22:44:16 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=87=E7=AB=A0/=E5=88=86=E7=B1=BB/?= =?UTF-8?q?=E8=AF=84=E8=AE=BA=E5=85=B3=E8=81=94=E7=BB=86=E8=8A=82=E5=AE=8C?= =?UTF-8?q?=E5=96=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 文章列表:会员插件注入「付费」徽章列(会员/付费¥xx,停插件即消失) - 分类:真实文章数(withCount)、名称点击跳文章列表并按分类筛选(ListPosts booted 预置筛选) - 分类:新增封面(medialibrary)+ 介绍(description 字段/表单);前台分类页顶部展示介绍区;SEO description + BreadcrumbList 结构化数据(GEO) - 文章编辑:MarkdownEditor 内容区加大(26rem);文章编辑页新增「评论」关系管理页(可审/拒) - 评论列表:文章列带摘要 - 三套主题补 .category-intro 样式 - 测试更新(付费徽章列) --- .../Categories/Schemas/CategoryForm.php | 54 +++++++++---- .../Categories/Tables/CategoriesTable.php | 10 ++- .../Comments/Tables/CommentsTable.php | 1 + .../Resources/Posts/Pages/ListPosts.php | 13 ++- app/Filament/Resources/Posts/PostResource.php | 2 +- .../CommentsRelationManager.php | 81 +++++++++++++++++++ .../Resources/Posts/Tables/PostsTable.php | 3 + app/Models/Category.php | 14 +++- app/Providers/AppServiceProvider.php | 3 + ...00_add_description_to_categories_table.php | 24 ++++++ .../src/Http/Middleware/SeoMiddleware.php | 19 +++++ .../src/ServiceProvider.php | 32 ++++++-- resources/views/list.blade.php | 16 ++++ tests/Feature/PostFormInjectionTest.php | 4 +- themes/demo/assets/style.css | 6 ++ themes/modern/assets/style.css | 6 ++ themes/sablog/assets/style.css | 6 ++ themes/sablog/views/list.blade.php | 16 ++++ 18 files changed, 276 insertions(+), 34 deletions(-) create mode 100644 app/Filament/Resources/Posts/RelationManagers/CommentsRelationManager.php create mode 100644 database/migrations/2026_08_12_300000_add_description_to_categories_table.php diff --git a/app/Filament/Resources/Categories/Schemas/CategoryForm.php b/app/Filament/Resources/Categories/Schemas/CategoryForm.php index 78298df..6abd2fc 100644 --- a/app/Filament/Resources/Categories/Schemas/CategoryForm.php +++ b/app/Filament/Resources/Categories/Schemas/CategoryForm.php @@ -4,7 +4,11 @@ declare(strict_types=1); namespace App\Filament\Resources\Categories\Schemas; +use App\Support\MediaDisk; +use Filament\Forms\Components\SpatieMediaLibraryFileUpload; +use Filament\Forms\Components\Textarea; use Filament\Forms\Components\TextInput; +use Filament\Schemas\Components\Section; use Filament\Schemas\Schema; class CategoryForm @@ -13,23 +17,39 @@ class CategoryForm { return $schema ->components([ - TextInput::make('name') - ->label(__('category.name')) - ->required(), - TextInput::make('slug') - ->label(__('category.slug')) - ->helperText('留空自动生成'), - TextInput::make('display_order') - ->label(__('category.display_order')) - ->required() - ->numeric() - ->default(0), - TextInput::make('post_count') - ->label(__('category.post_count')) - ->required() - ->numeric() - ->default(0) - ->disabled(), + Section::make('基本信息') + ->columns(2) + ->schema([ + TextInput::make('name') + ->label(__('category.name')) + ->required(), + TextInput::make('slug') + ->label(__('category.slug')) + ->helperText('留空自动生成'), + TextInput::make('display_order') + ->label(__('category.display_order')) + ->required() + ->numeric() + ->default(0), + TextInput::make('post_count') + ->label('文章数(自动统计)') + ->numeric() + ->disabled(), + Textarea::make('description') + ->label('分类介绍') + ->helperText('显示在分类列表页顶部,也用于 SEO description') + ->rows(4) + ->columnSpanFull(), + ]), + Section::make('封面') + ->schema([ + SpatieMediaLibraryFileUpload::make('cover') + ->label('封面图') + ->collection('cover') + ->image() + ->imageEditor() + ->disk(MediaDisk::name()), + ]), ]); } } diff --git a/app/Filament/Resources/Categories/Tables/CategoriesTable.php b/app/Filament/Resources/Categories/Tables/CategoriesTable.php index 2c9904b..1bbcdcb 100644 --- a/app/Filament/Resources/Categories/Tables/CategoriesTable.php +++ b/app/Filament/Resources/Categories/Tables/CategoriesTable.php @@ -9,16 +9,20 @@ use Filament\Actions\DeleteBulkAction; use Filament\Actions\EditAction; use Filament\Tables\Columns\TextColumn; use Filament\Tables\Table; +use Illuminate\Support\Str; class CategoriesTable { public static function configure(Table $table): Table { return $table + ->modifyQueryUsing(fn ($query) => $query->withCount('posts')) ->columns([ TextColumn::make('name') ->label(__('category.name')) - ->searchable(), + ->searchable() + ->url(fn ($record) => route('filament.admin.resources.posts.index').'?category_id='.$record->id) + ->description(fn ($record) => $record->description ? Str::limit($record->description, 40) : null), TextColumn::make('slug') ->label(__('category.slug')) ->searchable(), @@ -26,8 +30,8 @@ class CategoriesTable ->label(__('category.display_order')) ->numeric() ->sortable(), - TextColumn::make('post_count') - ->label(__('category.post_count')) + TextColumn::make('posts_count') + ->label('文章数') ->numeric() ->sortable(), TextColumn::make('created_at') diff --git a/app/Filament/Resources/Comments/Tables/CommentsTable.php b/app/Filament/Resources/Comments/Tables/CommentsTable.php index 2784786..99cbff6 100644 --- a/app/Filament/Resources/Comments/Tables/CommentsTable.php +++ b/app/Filament/Resources/Comments/Tables/CommentsTable.php @@ -31,6 +31,7 @@ class CommentsTable TextColumn::make('post.title') ->label('文章') ->limit(30) + ->description(fn ($record) => $record->post ? \Illuminate\Support\Str::limit($record->post->excerpt_or_fallback, 50) : null) ->url(fn ($record) => $record->post ? route('posts.show', $record->post->slug ?: $record->post->id) : null) ->openUrlInNewTab(), TextColumn::make('status') diff --git a/app/Filament/Resources/Posts/Pages/ListPosts.php b/app/Filament/Resources/Posts/Pages/ListPosts.php index 9f81104..0b4c3f5 100644 --- a/app/Filament/Resources/Posts/Pages/ListPosts.php +++ b/app/Filament/Resources/Posts/Pages/ListPosts.php @@ -2,7 +2,6 @@ declare(strict_types=1); - namespace App\Filament\Resources\Posts\Pages; use App\Filament\Resources\Posts\PostResource; @@ -13,6 +12,18 @@ class ListPosts extends ListRecords { protected static string $resource = PostResource::class; + public function bootedInteractsWithTable(): void + { + // 支持从分类管理跳转:?category_id=X 预置分类筛选 + // (在 booted 阶段设置,早于表单填充;SelectFilter 状态为 category_id.value 嵌套结构) + if (blank($this->tableFilters) && ($categoryId = (int) request()->query('category_id', 0))) { + $this->tableFilters = ['category_id' => ['value' => $categoryId]]; + $this->tableDeferredFilters = ['category_id' => ['value' => $categoryId]]; + } + + parent::bootedInteractsWithTable(); + } + protected function getHeaderActions(): array { return [ diff --git a/app/Filament/Resources/Posts/PostResource.php b/app/Filament/Resources/Posts/PostResource.php index bc0b9e8..ef8bf02 100644 --- a/app/Filament/Resources/Posts/PostResource.php +++ b/app/Filament/Resources/Posts/PostResource.php @@ -54,7 +54,7 @@ class PostResource extends Resource public static function getRelations(): array { return [ - // + \App\Filament\Resources\Posts\RelationManagers\CommentsRelationManager::class, ]; } diff --git a/app/Filament/Resources/Posts/RelationManagers/CommentsRelationManager.php b/app/Filament/Resources/Posts/RelationManagers/CommentsRelationManager.php new file mode 100644 index 0000000..c435a89 --- /dev/null +++ b/app/Filament/Resources/Posts/RelationManagers/CommentsRelationManager.php @@ -0,0 +1,81 @@ +components([ + TextInput::make('author_name')->label('昵称')->disabled(), + TextInput::make('author_email')->label('邮箱')->disabled(), + TextInput::make('author_url')->label('网址')->disabled(), + Textarea::make('content')->label('内容')->disabled()->rows(4), + ]); + } + + public function table(Table $table): Table + { + return $table + ->columns([ + TextColumn::make('author_name')->label('昵称'), + TextColumn::make('content')->label('内容')->limit(40), + TextColumn::make('status')->label('状态') + ->badge() + ->formatStateUsing(fn ($state) => match ($state) { + 'published' => '已发布', + 'pending' => '待审核', + 'spam' => '垃圾', + 'rejected' => '已拒绝', + default => $state, + }) + ->color(fn ($state) => match ($state) { + 'published' => 'success', + 'pending' => 'warning', + 'spam' => 'danger', + 'rejected' => 'gray', + default => 'gray', + }), + TextColumn::make('created_at')->label('时间')->dateTime('Y-m-d H:i'), + ]) + ->recordActions([ + Action::make('approve') + ->label('通过') + ->color('success') + ->visible(fn (Comment $record) => $record->status !== 'published') + ->action(function (Comment $record) { + if ($record->status !== 'published') { + $record->post?->increment('comment_count'); + } + $record->update(['status' => 'published']); + }), + Action::make('reject') + ->label('拒绝') + ->color('danger') + ->visible(fn (Comment $record) => $record->status !== 'rejected') + ->action(fn (Comment $record) => $record->update(['status' => 'rejected'])), + ]) + ->recordUrl(null) + ->recordAction(null) + ->defaultSort('created_at', 'desc'); + } +} diff --git a/app/Filament/Resources/Posts/Tables/PostsTable.php b/app/Filament/Resources/Posts/Tables/PostsTable.php index 91cd68c..881e3e6 100644 --- a/app/Filament/Resources/Posts/Tables/PostsTable.php +++ b/app/Filament/Resources/Posts/Tables/PostsTable.php @@ -63,6 +63,9 @@ class PostsTable ->sortable(), ]) ->filters([ + SelectFilter::make('category_id') + ->label('分类') + ->relationship('category', 'name'), SelectFilter::make('status') ->label('状态') ->options([ diff --git a/app/Models/Category.php b/app/Models/Category.php index 010fd9e..60db3ef 100644 --- a/app/Models/Category.php +++ b/app/Models/Category.php @@ -2,22 +2,25 @@ declare(strict_types=1); - namespace App\Models; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasMany; +use Spatie\MediaLibrary\HasMedia; +use Spatie\MediaLibrary\InteractsWithMedia; -class Category extends Model +class Category extends Model implements HasMedia { use HasFactory; + use InteractsWithMedia; protected $fillable = [ 'name', 'slug', 'display_order', 'post_count', + 'description', ]; public function posts(): HasMany @@ -29,4 +32,11 @@ class Category extends Model { return route('category.show', $this->slug ?? $this->id); } + + public function registerMediaCollections(): void + { + $this->addMediaCollection('cover') + ->useDisk(\App\Support\MediaDisk::name()) + ->singleFile(); + } } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 2869823..b7ae945 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -81,6 +81,9 @@ class AppServiceProvider extends ServiceProvider /* 操作列固定宽度(钉住右侧需 table sticky,见备注) */ .fi-ta-actions-header-cell, .fi-ta-cell:has(.fi-ta-actions) { width: 88px !important; min-width: 88px !important; } + /* 文章编辑框加大(MarkdownEditor 基于 tiptap) */ + .fi-fo-markdown-editor .tiptap, .fi-fo-markdown-editor .ProseMirror { min-height: 26rem !important; } + /* 筛选条件区:整体一行横排,重置紧跟应用按钮,控件紧凑 */ .fi-ta-filters-above-content-ctn { padding: 0.4rem 0.9rem !important; } .fi-ta-filters-heading { display: none !important; } diff --git a/database/migrations/2026_08_12_300000_add_description_to_categories_table.php b/database/migrations/2026_08_12_300000_add_description_to_categories_table.php new file mode 100644 index 0000000..a8a0d4d --- /dev/null +++ b/database/migrations/2026_08_12_300000_add_description_to_categories_table.php @@ -0,0 +1,24 @@ +text('description')->nullable()->after('display_order'); + }); + } + + public function down(): void + { + Schema::table('categories', function (Blueprint $table) { + $table->dropColumn('description'); + }); + } +}; diff --git a/plugins/neatstudio.blog-seo/src/Http/Middleware/SeoMiddleware.php b/plugins/neatstudio.blog-seo/src/Http/Middleware/SeoMiddleware.php index 7de0866..68d1809 100644 --- a/plugins/neatstudio.blog-seo/src/Http/Middleware/SeoMiddleware.php +++ b/plugins/neatstudio.blog-seo/src/Http/Middleware/SeoMiddleware.php @@ -92,6 +92,25 @@ class SeoMiddleware } } + // 分类页:面包屑结构化数据(GEO 友好) + if ($route && $route->getName() === 'category.show') { + $slug = $route->parameter('slug'); + $category = Cache::remember('seo.category.'.$slug, now()->addHour(), function () use ($slug) { + return \App\Models\Category::query()->where('slug', $slug)->orWhere('id', (int) $slug)->first(); + }); + + if ($category) { + return [ + '@context' => 'https://schema.org', + '@type' => 'BreadcrumbList', + 'itemListElement' => [ + ['@type' => 'ListItem', 'position' => 1, 'name' => $siteName, 'item' => url('/')], + ['@type' => 'ListItem', 'position' => 2, 'name' => $category->name, 'item' => route('category.show', $category->slug ?: $category->id)], + ], + ]; + } + } + return [ '@context' => 'https://schema.org', '@type' => 'WebSite', diff --git a/plugins/neatstudio.membership/src/ServiceProvider.php b/plugins/neatstudio.membership/src/ServiceProvider.php index 36f409d..59a5932 100644 --- a/plugins/neatstudio.membership/src/ServiceProvider.php +++ b/plugins/neatstudio.membership/src/ServiceProvider.php @@ -12,7 +12,6 @@ use Filament\Forms\Components\TextInput; use Filament\Forms\Components\Toggle; use Filament\Schemas\Components\Section; use Filament\Schemas\Schema; -use Filament\Tables\Columns\IconColumn; use Filament\Tables\Columns\TextColumn; use Filament\Tables\Table; use Plugins\Neatstudio\Membership\Models\MembershipPlan; @@ -104,15 +103,32 @@ class ServiceProvider extends PluginServiceProvider ]); }); - // 后台文章表格注入:价格 / 会员列 + // 后台文章表格注入:付费标识(价格/会员徽章,停用插件后消失) $manager->addAction('filament.post_table', function (Table $table) { $table->pushColumns([ - TextColumn::make('meta.price') - ->label('单篇价格') - ->formatStateUsing(fn ($state) => $state > 0 ? '¥'.number_format((float) $state / 100, 2) : '—'), - IconColumn::make('meta.members_only') - ->label('会员') - ->boolean(), + TextColumn::make('meta.members_only') + ->label('付费') + ->badge() + ->formatStateUsing(function ($state, $record) { + $meta = $record->meta ?? []; + $price = (int) ($meta['price'] ?? 0); + + if (! empty($meta['members_only'])) { + return '会员'; + } + + return $price > 0 ? '付费 ¥'.number_format($price / 100, 2) : '—'; + }) + ->color(function ($state, $record) { + $meta = $record->meta ?? []; + + if (! empty($meta['members_only'])) { + return 'warning'; + } + + return (int) ($meta['price'] ?? 0) > 0 ? 'danger' : 'gray'; + }) + ->placeholder('—'), ]); }); } diff --git a/resources/views/list.blade.php b/resources/views/list.blade.php index 32feac2..f1a679f 100644 --- a/resources/views/list.blade.php +++ b/resources/views/list.blade.php @@ -1,5 +1,6 @@ @php $pageTitle = ($archiveTitle ?? $category->name ?? '文章列表').' - '.$siteName; + $pageDescription = $category->description ?? ($archiveTitle ?? $category->name ?? '文章列表').' - '.$siteName; @endphp @include('partials.head') @@ -8,6 +9,21 @@

{{ $archiveTitle ?? ($category->name ?? '文章列表') }}

+ @if(isset($category) && ($category->description || $category->getFirstMedia('cover'))) +
+ @if($cover = $category->getFirstMedia('cover')) + {{ $category->name }} + @endif +
+ @if($category->description) +

{{ $category->description }}

+ @endif +

{{ $category->posts_count ?? $posts->total() }} 篇文章

+
+
+ @endif + + @forelse($posts as $post) @include('partials.post-card', ['post' => $post]) @empty diff --git a/tests/Feature/PostFormInjectionTest.php b/tests/Feature/PostFormInjectionTest.php index b2c2f74..e02b83a 100644 --- a/tests/Feature/PostFormInjectionTest.php +++ b/tests/Feature/PostFormInjectionTest.php @@ -43,7 +43,7 @@ class PostFormInjectionTest extends TestCase $this->assertContains('meta.members_only', $fieldNames); } - public function test_post_table_includes_price_and_member_columns(): void + public function test_post_table_includes_paid_badge_column(): void { // Table 构造器要求 HasTable Livewire 组件,测试中绕过构造器 $table = (new ReflectionClass(Table::class))->newInstanceWithoutConstructor(); @@ -51,7 +51,7 @@ class PostFormInjectionTest extends TestCase $names = array_keys($table->getColumns()); - $this->assertContains('meta.price', $names); + // 会员插件注入的付费徽章列(meta.members_only 承载价格/会员判定) $this->assertContains('meta.members_only', $names); $this->assertContains('title', $names); } diff --git a/themes/demo/assets/style.css b/themes/demo/assets/style.css index 256fb7f..5e031e7 100644 --- a/themes/demo/assets/style.css +++ b/themes/demo/assets/style.css @@ -65,3 +65,9 @@ a:hover { color: var(--d-accent-dark); } /* 已解锁的付费内容块 */ .paid-content { border: 1px solid var(--d-border); border-left: 4px solid var(--d-accent); background: #fff; border-radius: 8px; padding: 12px 14px; margin: 12px 0; } .paid-content-label { font-size: 12px; color: var(--d-accent); font-weight: 700; margin-bottom: 6px; } + +/* 分类页介绍区 */ +.category-intro { display: flex; gap: 14px; align-items: flex-start; background: #fff; border: 1px solid var(--d-border); border-radius: 10px; padding: 14px 16px; margin-bottom: 16px; } +.category-cover { width: 120px; height: 80px; object-fit: cover; border-radius: 6px; flex-shrink: 0; } +.category-description { margin: 0 0 6px; font-size: 14px; color: #555; line-height: 1.7; } +.category-meta { margin: 0; font-size: 12px; color: #999; } diff --git a/themes/modern/assets/style.css b/themes/modern/assets/style.css index a67e7b1..ae55b61 100644 --- a/themes/modern/assets/style.css +++ b/themes/modern/assets/style.css @@ -190,3 +190,9 @@ a:hover { opacity: 0.8; } /* 已解锁的付费内容块 */ .paid-content { border: 1px solid var(--border); border-left: 4px solid var(--accent); background: var(--bg); border-radius: 10px; padding: 14px 16px; margin: 14px 0; } .paid-content-label { font-size: 12px; color: var(--accent); font-weight: 600; margin-bottom: 6px; } + +/* 分类页介绍区 */ +.category-intro { display: flex; gap: 14px; align-items: flex-start; background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 16px 18px; margin-bottom: 18px; } +.category-cover { width: 128px; height: 84px; object-fit: cover; border-radius: 8px; flex-shrink: 0; } +.category-description { margin: 0 0 6px; font-size: 14px; color: var(--muted); line-height: 1.7; } +.category-meta { margin: 0; font-size: 12px; color: var(--muted); } diff --git a/themes/sablog/assets/style.css b/themes/sablog/assets/style.css index d48ec13..7fe5c7c 100644 --- a/themes/sablog/assets/style.css +++ b/themes/sablog/assets/style.css @@ -167,3 +167,9 @@ a:hover { color: #f60; text-decoration: underline; } /* 已解锁的付费内容块 */ .paid-content { border: 1px dashed #f5c6cb; border-left: 4px solid #f60; background: #fff8f0; border-radius: 6px; padding: 12px 14px; margin: 12px 0; } .paid-content-label { font-size: 12px; color: #f60; font-weight: 700; margin-bottom: 6px; } + +/* 分类页介绍区 */ +.category-intro { display: flex; gap: 14px; align-items: flex-start; background: #fff; border: 1px solid #e0e0e0; border-radius: 8px; padding: 14px 16px; margin-bottom: 16px; } +.category-cover { width: 120px; height: 80px; object-fit: cover; border-radius: 6px; flex-shrink: 0; } +.category-description { margin: 0 0 6px; font-size: 14px; color: #555; line-height: 1.7; } +.category-meta { margin: 0; font-size: 12px; color: #999; } diff --git a/themes/sablog/views/list.blade.php b/themes/sablog/views/list.blade.php index 645a938..0b583a1 100644 --- a/themes/sablog/views/list.blade.php +++ b/themes/sablog/views/list.blade.php @@ -1,5 +1,6 @@ @php $pageTitle = ($archiveTitle ?? $category->name ?? '文章列表').' - '.$siteName; + $pageDescription = $category->description ?? ($archiveTitle ?? $category->name ?? '文章列表').' - '.$siteName; @endphp @include('partials.head') @@ -8,6 +9,21 @@

{{ $archiveTitle ?? ($category->name ?? '文章列表') }}

+ @if(isset($category) && ($category->description || $category->getFirstMedia('cover'))) +
+ @if($cover = $category->getFirstMedia('cover')) + {{ $category->name }} + @endif +
+ @if($category->description) +

{{ $category->description }}

+ @endif +

{{ $category->posts_count ?? $posts->total() }} 篇文章

+
+
+ @endif + + @forelse($posts as $post) @include('partials.post-card', ['post' => $post]) @empty