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'))
+
 }})
+ @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'))
+
 }})
+ @endif
+
+ @if($category->description)
+
{{ $category->description }}
+ @endif
+
{{ $category->posts_count ?? $posts->total() }} 篇文章
+
+
+ @endif
+
+
@forelse($posts as $post)
@include('partials.post-card', ['post' => $post])
@empty