Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
43dc8f9856 | ||
|
|
6f13d13d5a | ||
|
|
f1b9d1e1c3 | ||
|
|
8f1ff6e3f9 | ||
|
|
5f74dd76e4 | ||
|
|
a22175d5b7 | ||
|
|
c12eb38cca | ||
|
|
1d0fe3f8b6 | ||
|
|
0c5b19b4eb | ||
|
|
2c3e18fee6 |
@@ -76,3 +76,22 @@ WORKERMAN_QUEUE_CONNECTION=database
|
||||
WORKERMAN_QUEUES=default,ai
|
||||
WORKERMAN_MAX_TRIES=3
|
||||
WORKERMAN_TIMEOUT=60
|
||||
|
||||
# ---- 附件 S3 兼容存储(R2 / COS / OSS / MinIO),不配置则回退本地 public ----
|
||||
UPLOAD_DISK=uploads
|
||||
S3_ACCESS_KEY=
|
||||
S3_SECRET_KEY=
|
||||
S3_REGION=auto
|
||||
S3_BUCKET=
|
||||
S3_ENDPOINT=
|
||||
S3_USE_PATH_STYLE_ENDPOINT=false
|
||||
S3_URL=
|
||||
|
||||
# ---- LLM(OpenAI 兼容:DeepSeek / 通义 / 自建代理),AI 评论审核与润色 ----
|
||||
LLM_BASE_URL=https://api.openai.com/v1
|
||||
LLM_API_KEY=
|
||||
LLM_MODEL=gpt-4o-mini
|
||||
|
||||
# ---- 插件/主题市场(可选,本地 ZIP 安装无需配置)----
|
||||
MARKET_URL=
|
||||
MARKET_TOKEN=
|
||||
|
||||
@@ -24,3 +24,8 @@ Homestead.yaml
|
||||
Thumbs.db
|
||||
|
||||
/storage/media-library/temp/
|
||||
|
||||
# Workerman 进程文件(统一放 storage/framework 与 storage/logs,历史根目录文件一并排除)
|
||||
/storage/framework/workerman.*
|
||||
/workerman.*.pid
|
||||
/workerman.*.status
|
||||
|
||||
@@ -104,6 +104,14 @@ php artisan workerman:serve stop
|
||||
- 失败重试:`WORKERMAN_MAX_TRIES`(默认 3),超时 `WORKERMAN_TIMEOUT`(默认 60s),重试耗尽进 `failed_jobs` 表
|
||||
- 降级路径:`php artisan queue:work` 照常可用(同一队列)
|
||||
|
||||
## 外链中转(/go,内置插件)
|
||||
|
||||
内置插件 `neatstudio.link-tracker`(外链中转):
|
||||
- 正文外链、评论正文 URL、评论作者网址、友链统一改写为 `/go/{hash}`:302 跳转 + **点击统计**、可**停用拦截**(返回 410)、隐藏真实链接、为未来广告位预留
|
||||
- 站内链接/相对路径/mailto/tel/锚点不中转;同一 URL 复用同一短码
|
||||
- 后台「管理 → 外链中转」可查看点击量、启停、删除
|
||||
- **插件化设计**:核心提供 `tracked_url()` 钩子(`link.redirect` 过滤器)+ 正文 `post.rendered` 过滤器接入点,停用插件后链接恢复直连、不影响内容显示
|
||||
|
||||
## 前台缓存
|
||||
|
||||
- 匿名 GET 页面整页缓存(首页/列表/文章/归档/标签等),TTL 默认 300 秒(`PAGE_CACHE_TTL`),命中时零数据库查询
|
||||
@@ -127,6 +135,9 @@ php artisan test # 84 个用例:迁移/老路由 301/主题/插件/会员
|
||||
|
||||
| 文档 | 内容 |
|
||||
|------|------|
|
||||
| [docs/install.md](docs/install.md) | 安装指南(环境/配置/验收清单) |
|
||||
| [docs/import.md](docs/import.md) | Sablog 老数据导入(命令/映射/附件/301 兼容) |
|
||||
| [docs/deploy.md](docs/deploy.md) | 部署(nginx/systemd/workerman/cron/上线清单) |
|
||||
| [docs/development.md](docs/development.md) | 开发规范(代码/插件/主题/队列/测试/提交) |
|
||||
| [docs/tutorial-plugin.md](docs/tutorial-plugin.md) | 插件开发教程(10 分钟上手,配套 demo) |
|
||||
| [docs/tutorial-theme.md](docs/tutorial-theme.md) | 主题开发教程(10 分钟上手,配套 demo) |
|
||||
@@ -134,7 +145,6 @@ php artisan test # 84 个用例:迁移/老路由 301/主题/插件/会员
|
||||
| [docs/themes.md](docs/themes.md) | 主题 API 详解(视图解析/注入点/变量) |
|
||||
| [docs/architecture.md](docs/architecture.md) | 架构与设计决策 |
|
||||
| [docs/api.md](docs/api.md) | 对外 API(小程序/第三方) |
|
||||
| [docs/deploy.md](docs/deploy.md) | 部署(nginx/systemd/workerman/cron) |
|
||||
|
||||
**上手示例**:
|
||||
- 插件:`plugins/demo.hello-world/`(短代码 / 钩子 / 路由 / 后台页 / 迁移 / 表单注入,全演示)
|
||||
|
||||
@@ -22,7 +22,10 @@ class WorkermanServe extends Command
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
// 进程文件统一放 storage/framework/,不污染项目根目录
|
||||
Worker::$pidFile = storage_path('framework/workerman.pid');
|
||||
Worker::$statusFile = storage_path('framework/workerman.status');
|
||||
Worker::$logFile = storage_path('logs/workerman.log');
|
||||
|
||||
$this->info('Workerman 启动中('.config('workerman.name').')...');
|
||||
|
||||
|
||||
@@ -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,6 +17,9 @@ class CategoryForm
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
Section::make('基本信息')
|
||||
->columns(2)
|
||||
->schema([
|
||||
TextInput::make('name')
|
||||
->label(__('category.name'))
|
||||
->required(),
|
||||
@@ -25,11 +32,24 @@ class CategoryForm
|
||||
->numeric()
|
||||
->default(0),
|
||||
TextInput::make('post_count')
|
||||
->label(__('category.post_count'))
|
||||
->required()
|
||||
->label('文章数(自动统计)')
|
||||
->numeric()
|
||||
->default(0)
|
||||
->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()),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
@@ -47,6 +51,8 @@ class CategoriesTable
|
||||
->recordActions([
|
||||
EditAction::make(),
|
||||
])
|
||||
->recordUrl(null)
|
||||
->recordAction(null)
|
||||
->toolbarActions([
|
||||
BulkActionGroup::make([
|
||||
DeleteBulkAction::make(),
|
||||
|
||||
@@ -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')
|
||||
@@ -73,7 +74,9 @@ class CommentsTable
|
||||
->color('success')
|
||||
->visible(fn (Comment $record) => $record->status !== 'published')
|
||||
->action(function (Comment $record): void {
|
||||
$record->update(['status' => 'published']);
|
||||
$record->update(['status' => 'published'])
|
||||
->recordUrl(null)
|
||||
->recordAction(null);
|
||||
$record->post?->increment('comment_count');
|
||||
}),
|
||||
Action::make('spam')
|
||||
|
||||
@@ -53,6 +53,8 @@ class LinksTable
|
||||
->recordActions([
|
||||
EditAction::make(),
|
||||
])
|
||||
->recordUrl(null)
|
||||
->recordAction(null)
|
||||
->toolbarActions([
|
||||
BulkActionGroup::make([
|
||||
DeleteBulkAction::make(),
|
||||
|
||||
@@ -60,6 +60,8 @@ class MediaTable
|
||||
->recordActions([
|
||||
\Filament\Actions\EditAction::make(),
|
||||
])
|
||||
->recordUrl(null)
|
||||
->recordAction(null)
|
||||
->toolbarActions([
|
||||
BulkActionGroup::make([
|
||||
DeleteBulkAction::make(),
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -54,7 +54,7 @@ class PostResource extends Resource
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
//
|
||||
\App\Filament\Resources\Posts\RelationManagers\CommentsRelationManager::class,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\Posts\RelationManagers;
|
||||
|
||||
use App\Models\Comment;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Resources\RelationManagers\RelationManager;
|
||||
use Filament\Tables\Actions\Action;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class CommentsRelationManager extends RelationManager
|
||||
{
|
||||
protected static string $relationship = 'comments';
|
||||
|
||||
protected static ?string $title = '相关评论';
|
||||
|
||||
protected static ?string $modelLabel = '评论';
|
||||
|
||||
protected static ?string $pluralModelLabel = '评论';
|
||||
|
||||
public function form(\Filament\Schemas\Schema $schema): \Filament\Schemas\Schema
|
||||
{
|
||||
return $schema
|
||||
->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');
|
||||
}
|
||||
}
|
||||
@@ -63,6 +63,9 @@ class PostsTable
|
||||
->sortable(),
|
||||
])
|
||||
->filters([
|
||||
SelectFilter::make('category_id')
|
||||
->label('分类')
|
||||
->relationship('category', 'name'),
|
||||
SelectFilter::make('status')
|
||||
->label('状态')
|
||||
->options([
|
||||
@@ -85,6 +88,8 @@ class PostsTable
|
||||
ViewAction::make(),
|
||||
EditAction::make(),
|
||||
])
|
||||
->recordUrl(null)
|
||||
->recordAction(null)
|
||||
->toolbarActions([
|
||||
BulkActionGroup::make([
|
||||
DeleteBulkAction::make(),
|
||||
|
||||
@@ -55,6 +55,8 @@ class UsersTable
|
||||
->recordActions([
|
||||
EditAction::make(),
|
||||
])
|
||||
->recordUrl(null)
|
||||
->recordAction(null)
|
||||
->toolbarActions([
|
||||
BulkActionGroup::make([
|
||||
DeleteBulkAction::make(),
|
||||
|
||||
+12
-2
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,15 +66,36 @@ class AppServiceProvider extends ServiceProvider
|
||||
.fi-sidebar-nav .fi-sidebar-group-label { font-size: 0.68rem; letter-spacing: 0.04em; }
|
||||
.fi-sidebar-brand { padding-block: 0.9rem; }
|
||||
|
||||
/* 表格行紧凑:行高由选择列/表头驱动,数据单元格默认无 padding 不要加 */
|
||||
.fi-ta-header-cell { padding-block: calc(var(--spacing) * 2.4) !important; }
|
||||
.fi-ta-selection-cell { padding-block: calc(var(--spacing) * 2) !important; }
|
||||
.fi-ta-header-toolbar { padding-block: 0.6rem !important; }
|
||||
/* 表格行紧凑:数据行高度由 .fi-ta-text / .fi-ta-icon 的 padding-block 驱动;操作列按钮禁止换行堆叠 */
|
||||
.fi-ta-text, .fi-ta-icon { padding-block: calc(var(--spacing) * 1.75) !important; }
|
||||
.fi-ta-actions { flex-wrap: nowrap !important; min-height: 0 !important; }
|
||||
.fi-ta-header-cell { padding-block: calc(var(--spacing) * 4) !important; }
|
||||
.fi-ta-selection-cell { padding-block: calc(var(--spacing) * 1.5) !important; }
|
||||
.fi-ta-header-toolbar { padding-block: 0.5rem !important; }
|
||||
|
||||
/* 筛选条件区(Above 布局)收紧为一行:隐藏标题、压缩留白 */
|
||||
.fi-ta-filters-above-content-ctn { padding: 0.5rem 1rem !important; }
|
||||
/* 标题列过长单行省略,避免换行撑高行 */
|
||||
.fi-ta-cell-title .fi-ta-text { display: block !important; white-space: nowrap !important; overflow: hidden !important; text-overflow: ellipsis !important; }
|
||||
.fi-ta-cell-title .fi-ta-text .fi-ta-text-item,
|
||||
.fi-ta-cell-title .fi-ta-text .fi-ta-text-description { display: inline !important; margin: 0 !important; }
|
||||
|
||||
/* 操作列固定宽度(钉住右侧需 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; }
|
||||
.fi-ta-filters-header { margin-bottom: 0.25rem !important; }
|
||||
.fi-ta-filters { display: flex !important; flex-wrap: wrap; align-items: center; gap: 0.5rem 0.9rem; }
|
||||
.fi-ta-filters-header { margin: 0 !important; order: 99 !important; }
|
||||
.fi-ta-filters .fi-grid { display: flex !important; flex-wrap: wrap; align-items: center; gap: 0.5rem 0.9rem; }
|
||||
.fi-ta-filters .fi-fo-field { display: flex !important; align-items: center; gap: 0.4rem; }
|
||||
.fi-ta-filters .fi-fo-field-label { margin-bottom: 0 !important; }
|
||||
.fi-ta-filters .fi-input-wrp { min-height: 1.5rem !important; border-radius: 0.4rem !important; padding-block: 0 !important; }
|
||||
.fi-ta-filters .fi-select-input, .fi-ta-filters .fi-select-native { font-size: 0.8rem !important; min-height: 0 !important; height: auto !important; padding-block: 0.15rem !important; }
|
||||
.fi-ta-filters-actions-ctn { display: inline-flex !important; }
|
||||
.fi-ta-filters-actions-ctn .fi-btn { padding: 0.2rem 0.7rem !important; font-size: 0.8rem !important; }
|
||||
</style>
|
||||
HTML)
|
||||
);
|
||||
|
||||
@@ -42,3 +42,32 @@ if (! function_exists('blog_setting')) {
|
||||
return \App\Models\Setting::get($key, $default);
|
||||
}
|
||||
}
|
||||
|
||||
if (! function_exists('tracked_url')) {
|
||||
/**
|
||||
* 外链追踪钩子:核心视图统一调用,由插件(外链中转)改写为 /go/{hash};
|
||||
* 插件停用时原样返回。站内链接等由插件自行判断。
|
||||
*/
|
||||
function tracked_url(string $url): string
|
||||
{
|
||||
return app(\App\Blog\Support\PluginManager::class)->applyFilters('link.redirect', $url);
|
||||
}
|
||||
}
|
||||
|
||||
if (! function_exists('comment_body')) {
|
||||
/**
|
||||
* 评论正文:转义 + 自动链接 URL(经 tracked_url 走中转,nofollow)。
|
||||
*/
|
||||
function comment_body(string $text): string
|
||||
{
|
||||
$text = e($text);
|
||||
|
||||
$text = preg_replace_callback(
|
||||
'#(https?://[^\s<>"\'()]+)#i',
|
||||
fn (array $m) => '<a href="'.e(tracked_url($m[1])).'" target="_blank" rel="nofollow noopener">'.$m[1].'</a>',
|
||||
$text
|
||||
);
|
||||
|
||||
return nl2br($text);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ return [
|
||||
'neatstudio.ai-moderation',
|
||||
'neatstudio.payment',
|
||||
'neatstudio.membership',
|
||||
'neatstudio.link-tracker',
|
||||
],
|
||||
|
||||
/*
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('categories', function (Blueprint $table) {
|
||||
$table->text('description')->nullable()->after('display_order');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('categories', function (Blueprint $table) {
|
||||
$table->dropColumn('description');
|
||||
});
|
||||
}
|
||||
};
|
||||
+29
-6
@@ -40,11 +40,27 @@ MARKET_TOKEN=
|
||||
|
||||
## 生产迁移顺序
|
||||
|
||||
1. `php artisan migrate --seed`
|
||||
2. 导入老数据:`php artisan sablog:import --database=sablog ... --attachments-dir=... --sync-attachments --convert-markdown`
|
||||
3. 配置 S3 后同步附件:`php artisan attachments:sync-s3 --legacy-dir=...`
|
||||
4. 生成主题资产(可选):`php artisan theme:publish`
|
||||
5. 启动 Workerman + 队列 + 调度器
|
||||
1. `composer install --no-dev --optimize-autoloader`
|
||||
2. `.env` 生产配置(见上;`APP_ENV=production`、`APP_DEBUG=false`、`APP_URL` 用正式域名)
|
||||
3. `php artisan config:cache && php artisan route:cache && php artisan view:cache`
|
||||
4. `php artisan migrate --seed`
|
||||
5. 导入老数据(如需):见 `docs/import.md`
|
||||
6. 配置 S3 后同步附件(如需):`php artisan attachments:sync-s3 --legacy-dir=...`
|
||||
7. 生成主题资产:`php artisan theme:publish`
|
||||
8. 启动队列消费者(workerman 或 queue:work,见下)+ 配置 cron 调度器
|
||||
9. 后台改默认管理员密码;确认内置插件启用状态(SEO/AI/支付/会员/外链中转)
|
||||
|
||||
## 上线前检查清单
|
||||
|
||||
- [ ] `APP_ENV=production`、`APP_DEBUG=false`、`APP_URL` 正式域名
|
||||
- [ ] `php artisan config:cache` 已执行(改了 .env 后需重新执行)
|
||||
- [ ] 队列消费者进程在跑(AI 审核依赖;systemd 配置见下)
|
||||
- [ ] cron 已配置 `schedule:run`(每日备份依赖)
|
||||
- [ ] 管理员密码已改(默认 `admin@laralog.test` / `password`)
|
||||
- [ ] 支付:确认「支付设置」里沙箱开关符合预期(默认沙箱,真实渠道需公网回调)
|
||||
- [ ] LLM 配置有效(不配则评论走人工审核流程)
|
||||
- [ ] S3 已配置或确认回退本地磁盘(见 `docs/install.md`)
|
||||
- [ ] `storage/`、`bootstrap/cache/` 目录可写
|
||||
|
||||
## Nginx(前端由 Laravel 接管,老 URL 由应用层 301)
|
||||
|
||||
@@ -115,7 +131,14 @@ systemctl enable --now laralog-workerman
|
||||
* * * * * cd /var/www/laralog && php artisan schedule:run >> /dev/null 2>&1
|
||||
```
|
||||
|
||||
调度器内注册的内容:`spatie/laravel-backup` 备份、缓存清理等(按需在 `routes/console.php` 增删)。
|
||||
调度器内注册的内容(`routes/console.php`):
|
||||
|
||||
- 每日 03:00:数据库备份(`backup:run --only-db`,保留 7 天,日志 `storage/logs/backup.log`)
|
||||
- 每日 04:00:清理前台页面缓存
|
||||
|
||||
按需在 `routes/console.php` 增删。
|
||||
|
||||
> Workerman 进程文件(pid/status/log)统一写在 `storage/framework/` 与 `storage/logs/`,不会出现在项目根目录;已由 .gitignore 排除。
|
||||
|
||||
## 队列消费:workerman 与 queue:work 等价
|
||||
|
||||
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
# Sablog 数据导入指南
|
||||
|
||||
把 SaBlog-X 1.6 老站数据完整迁入 LaraLog。**全部脚本化、可重复执行**,保留原始 ID,老 URL 自动 301 兼容(不产生 SEO 404)。
|
||||
|
||||
## 前置准备
|
||||
|
||||
1. 老库可访问:MySQL 8(或可导出的老 MySQL 5.x,先在 MySQL 8 建好导入目标)
|
||||
2. 老附件目录:sablog 的 `attachments/`(在 Web 服务器上,含编号子目录)
|
||||
3. 已按 `docs/install.md` 完成安装(`migrate --seed` 后导入)
|
||||
|
||||
> 建议:先在**测试环境**用 `--dry-run` 风格(见下文幂等说明)完整跑一遍,确认数量后再上生产。
|
||||
|
||||
## 一键导入
|
||||
|
||||
```bash
|
||||
php artisan sablog:import \
|
||||
--host=127.0.0.1 --port=3306 \
|
||||
--database=sablog --username=root --password=老库密码 \
|
||||
--prefix=sablog_ \
|
||||
--attachments-dir=/var/www/old/attachments \
|
||||
--sync-attachments \
|
||||
--convert-markdown
|
||||
```
|
||||
|
||||
### 参数说明
|
||||
|
||||
| 参数 | 默认 | 说明 |
|
||||
|------|------|------|
|
||||
| `--driver` | mysql | 老库驱动(测试可用 `sqlite`) |
|
||||
| `--host` / `--port` | 127.0.0.1 / 3306 | 老库地址 |
|
||||
| `--database` | (必填) | 老库名 |
|
||||
| `--username` / `--password` | root / 空 | 老库账号 |
|
||||
| `--prefix` | `sablog_` | 老表前缀(老站可能自定义过) |
|
||||
| `--attachments-dir` | — | 老附件目录,配合 `--sync-attachments` 用 |
|
||||
| `--sync-attachments` | 关 | 同步附件文件到新存储(本地中转或直接上 S3) |
|
||||
| `--convert-markdown` | 关 | 导入时把 HTML 正文转为 Markdown |
|
||||
| `--fresh` | 关 | 清空目标表后重新导入 |
|
||||
|
||||
## 数据映射
|
||||
|
||||
| 老表 | 新表 | 说明 |
|
||||
|------|------|------|
|
||||
| `users` | `users` | **ID 保留**;老 MD5 密码进 `legacy_md5`,登录时自动升级为 bcrypt |
|
||||
| `articles` | `posts` | **articleid 保留为 id**;`[attach=xx]` 按内容格式处理(见下) |
|
||||
| `categories` | `categories` | cid 保留;导入后自动重算文章数 |
|
||||
| `comments` | `comments` | commentid 保留;visible 语义映射为状态 |
|
||||
| `links` | `links` | linkid 保留 |
|
||||
| `attachments` | medialibrary `media` | 文件 + `legacy_attachmentid` 映射(老 `[attach=xx]` 按此解析) |
|
||||
| `settings` | `settings` | 键值映射为站点设置(可在后台「博客设置」继续改) |
|
||||
| `tags` | spatie tags | 老 aids 文本解析为独立标签 + 关联 |
|
||||
|
||||
**不迁移**:trackback/引用通告(垃圾来源,接口已关闭)、搜索缓存表、验证码(由 AI 审核 + 蜜罐 + 频控取代)。
|
||||
|
||||
## 附件短代码的处理
|
||||
|
||||
| 老内容 | 导入后 |
|
||||
|--------|--------|
|
||||
| HTML 正文 `[attach=12]` | 原样保留,**渲染时按 legacy id 解析**为附件链接/图片 |
|
||||
| Markdown 正文(--convert-markdown) | 自动转成 `{{attach:12}}` 令牌(`[]` 是 Markdown 保留字符) |
|
||||
|
||||
两种格式都能正常显示老附件,无需手工替换。
|
||||
|
||||
## 老 URL 兼容(301,无需 Nginx 配置)
|
||||
|
||||
- 伪静态:`/show-12-1.html` → 新文章页;`/category-3-1.html` → 新分类页;`/archives-202008-1.html` → 新归档
|
||||
- 查询串:`/?action=show&id=12`、`/?action=search&keyword=x`、`/?action=tags&id=1` …全部映射
|
||||
- 稳定路径:`/rss.xml`、`/sitemap.xml`、`/robots.txt` 保持原样
|
||||
- 老入口:`attachment.php?id=` → 301 到 S3/本地媒体地址;`tburl.php`/`trackback.php` → 410
|
||||
|
||||
## 附件上 S3
|
||||
|
||||
```bash
|
||||
# 导入后再把老附件批量上传(Multipart、幂等、可断点续传)
|
||||
php artisan attachments:sync-s3 --legacy-dir=/var/www/old/attachments
|
||||
|
||||
# 个别文件校验失败需要强制重传
|
||||
php artisan attachments:sync-s3 --legacy-dir=... --force
|
||||
```
|
||||
|
||||
> 未配置 S3 时附件留在本地 public 磁盘,网站可正常展示;配好 S3 后再执行同步即可无缝切换。
|
||||
|
||||
## 内容转换(可选)
|
||||
|
||||
```bash
|
||||
php artisan content:convert --all # 全部 HTML 文章转 Markdown
|
||||
php artisan content:convert 12 # 单篇
|
||||
php artisan content:convert --all --dry-run # 先预览
|
||||
```
|
||||
|
||||
## 幂等与重跑
|
||||
|
||||
- 导入按**原始 ID** `updateOrInsert`:重复执行不会产生重复数据,只会覆盖更新
|
||||
- 需要完全重来:`--fresh` 清空目标表后导入
|
||||
- `attachments:sync-s3` 自带去重:已上传的文件跳过(除非 `--force`)
|
||||
|
||||
## 导入后检查清单
|
||||
|
||||
- [ ] 文章总数、评论总数与老站一致(后台仪表盘 / `SELECT COUNT(*)`)
|
||||
- [ ] 抽查老 URL:`/show-12-1.html` 301 到新文章页
|
||||
- [ ] 抽查带 `[attach=xx]` 的文章:附件正常显示
|
||||
- [ ] 老账号用**原密码**登录成功(密码自动升级为 bcrypt)
|
||||
- [ ] 分类文章数、文章评论数正确(导入脚本已自动重算)
|
||||
- [ ] RSS/Sitemap 正常输出
|
||||
- [ ] 主题切换后前台布局正常
|
||||
|
||||
## 常见问题
|
||||
|
||||
| 现象 | 处理 |
|
||||
|------|------|
|
||||
| 报「找不到老表」 | 检查 `--prefix`(老站表前缀可能不是 `sablog_`)与老库名 |
|
||||
| 附件显示不出来 | 确认 `--attachments-dir` 路径正确;看 `attachments:sync-s3` 输出报告 |
|
||||
| 老密码登录失败 | 老库密码若是自定义加密(非 MD5),需要按 `User::verifyPassword` 扩展 |
|
||||
| 中文乱码 | 老库导出时用 utf8mb4;`--driver=sqlite` 只用于测试 |
|
||||
@@ -0,0 +1,94 @@
|
||||
# 安装指南
|
||||
|
||||
从零安装 LaraLog。已有 SaBlog-X 老数据的先看本页,数据迁移见 `docs/import.md`,上线见 `docs/deploy.md`。
|
||||
|
||||
## 环境要求
|
||||
|
||||
| 组件 | 要求 |
|
||||
|------|------|
|
||||
| PHP | **8.2+**(需 pcntl 扩展用于 Workerman;GD/Imagick 用于缩略图) |
|
||||
| MySQL | **8.x** |
|
||||
| Composer | 2.x |
|
||||
| 可选 | Node.js(前台资源构建)、S3 兼容存储(R2/COS/OSS/MinIO)、OpenAI 兼容 LLM |
|
||||
|
||||
## 1. 获取代码与依赖
|
||||
|
||||
```bash
|
||||
git clone <repo> laralog
|
||||
cd laralog
|
||||
composer install --no-dev # 开发环境不加 --no-dev
|
||||
cp .env.example .env
|
||||
php artisan key:generate
|
||||
```
|
||||
|
||||
`.env.example` 已包含全部配置键,关键项:
|
||||
|
||||
```dotenv
|
||||
APP_URL=http://laralog.test # 生产改正式域名(影响生成的全部链接)
|
||||
DB_CONNECTION=mysql
|
||||
DB_HOST=127.0.0.1
|
||||
DB_DATABASE=laralog
|
||||
DB_USERNAME=root
|
||||
DB_PASSWORD=
|
||||
|
||||
# 以下为可选能力(不配也能跑,见注释):
|
||||
# S3_* 附件存储(不配置回退本地 public)
|
||||
# LLM_* AI 评论审核/润色
|
||||
# MARKET_* 插件/主题远程市场
|
||||
```
|
||||
|
||||
## 2. 建库与初始化
|
||||
|
||||
```bash
|
||||
# 先在 MySQL 建库
|
||||
mysql -uroot -p -e "CREATE DATABASE laralog CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"
|
||||
|
||||
# 迁移 + 种子(创建管理员)
|
||||
php artisan migrate --seed
|
||||
```
|
||||
|
||||
**后台登录**:`http://{APP_URL}/admin`,默认账号 `admin@laralog.test` / `password`(**上线后第一时间改密码**)。
|
||||
|
||||
> 内置插件(SEO/AI/支付/会员/外链中转)随迁移自动建表并默认启用(config/plugins.php),后台「插件管理」可启停。
|
||||
|
||||
## 3. 开发环境运行
|
||||
|
||||
### Herd / Valet(推荐,macOS)
|
||||
|
||||
```bash
|
||||
# Herd:站点指向本目录即可,直接访问 http://laralog.test
|
||||
```
|
||||
|
||||
### 内置服务器
|
||||
|
||||
```bash
|
||||
php artisan serve # http://127.0.0.1:8000
|
||||
```
|
||||
|
||||
### 队列消费者(AI 审核/润色才需要)
|
||||
|
||||
```bash
|
||||
php artisan dev:watch # 开发期:监听代码变化自动重启 workerman(fswatch 或 PHP 轮询兜底)
|
||||
# 或简单模式
|
||||
php artisan queue:work --queue=default,ai
|
||||
```
|
||||
|
||||
## 4. 验收清单
|
||||
|
||||
- [ ] 前台首页/文章/分类/归档/标签/搜索/友链/登录注册正常
|
||||
- [ ] 后台 `/admin` 登录成功,文章/评论/设置/主题/插件页面可访问
|
||||
- [ ] 主题切换正常(后台「主题管理」,内置 sablog/modern/demo 三套)
|
||||
- [ ] 发一条评论:评论区显示(若有 AI 配置,进审核队列)
|
||||
- [ ] 支付:后台「支付设置」默认沙箱模式,会员套餐可走模拟支付闭环
|
||||
- [ ] 文章写 `[hello 世界]` 变问候语(demo 插件启用后,`/hello-world` 可访问)
|
||||
|
||||
## 5. 常见问题
|
||||
|
||||
| 现象 | 处理 |
|
||||
|------|------|
|
||||
| 页面 500 / 空白 | `storage/`、`bootstrap/cache/` 目录需可写;`php artisan config:clear` |
|
||||
| 中文乱码 | 数据库/连接字符集 utf8mb4 |
|
||||
| 后台样式错乱 | `php artisan filament:assets`(重新发布后台资源) |
|
||||
| 文章编辑框太小 | 已内置加大样式;仍不够可调整 `app/Providers/AppServiceProvider.php` 中 `.fi-fo-markdown-editor` 的 min-height |
|
||||
| 附件上传失败 | 未配 S3 时走本地 public 磁盘;配 S3 后确认 `S3_*` 键与桶权限 |
|
||||
| 插件停用后功能消失 | 属预期:付费列/外链中转等由插件注入,后台「插件管理」重新启用即可 |
|
||||
@@ -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',
|
||||
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
// 兼容已由核心迁移建过表的旧库
|
||||
if (Schema::hasTable('redirects')) {
|
||||
return;
|
||||
}
|
||||
|
||||
Schema::create('redirects', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('hash', 16)->unique();
|
||||
$table->text('target_url');
|
||||
$table->unsignedBigInteger('clicks')->default(0);
|
||||
$table->string('status', 20)->default('active'); // active / disabled
|
||||
$table->timestamp('last_clicked_at')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index('target_url');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('redirects');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"title": "外链中转",
|
||||
"version": "1.0.0",
|
||||
"description": "站外链接改写为 /go/{hash}:302 跳转 + 点击统计 + 可停用拦截 + 隐藏真实链接,为广告位预留",
|
||||
"usage": "启用后自动生效:\n1. 正文外链、评论正文 URL、评论作者网址、友链统一改写为 /go/{hash}\n2. 点击 /go/{hash} 302 跳转并计数;后台「管理 → 外链中转」查看点击量/启停\n3. 停用插件后链接恢复直连(不影响正文显示)",
|
||||
"author": "LaraLog",
|
||||
"type": "core",
|
||||
"provider": "Plugins\\Neatstudio\\LinkTracker\\ServiceProvider",
|
||||
"filament_resources": [
|
||||
"Plugins\\Neatstudio\\LinkTracker\\Filament\\Resources\\RedirectResource"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Plugins\Neatstudio\LinkTracker\Http\GoController;
|
||||
|
||||
Route::get('/go/{hash}', [GoController::class, 'show'])->name('go.show');
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugins\Neatstudio\LinkTracker\Filament\Resources\Pages;
|
||||
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
use Plugins\Neatstudio\LinkTracker\Filament\Resources\RedirectResource;
|
||||
|
||||
class ListRedirects extends ListRecords
|
||||
{
|
||||
protected static string $resource = RedirectResource::class;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugins\Neatstudio\LinkTracker\Filament\Resources;
|
||||
|
||||
use BackedEnum;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Table;
|
||||
use Plugins\Neatstudio\LinkTracker\Filament\Resources\Pages\ListRedirects;
|
||||
use Plugins\Neatstudio\LinkTracker\Models\Redirect;
|
||||
|
||||
class RedirectResource extends Resource
|
||||
{
|
||||
protected static \UnitEnum|string|null $navigationGroup = '管理';
|
||||
|
||||
protected static ?string $model = Redirect::class;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedArrowUturnRight;
|
||||
|
||||
protected static ?string $navigationLabel = '外链中转';
|
||||
|
||||
protected static ?string $pluralModelLabel = '外链中转';
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema;
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
TextColumn::make('hash')
|
||||
->label('短码')
|
||||
->badge()
|
||||
->color('gray'),
|
||||
TextColumn::make('target_url')
|
||||
->label('目标地址')
|
||||
->limit(50)
|
||||
->copyable()
|
||||
->searchable(),
|
||||
TextColumn::make('clicks')
|
||||
->label('点击量')
|
||||
->numeric()
|
||||
->sortable(),
|
||||
TextColumn::make('status')
|
||||
->label('状态')
|
||||
->badge()
|
||||
->formatStateUsing(fn ($state) => $state === 'active' ? '启用' : '停用')
|
||||
->color(fn ($state) => $state === 'active' ? 'success' : 'danger'),
|
||||
TextColumn::make('last_clicked_at')
|
||||
->label('最后点击')
|
||||
->dateTime('Y-m-d H:i')
|
||||
->placeholder('—')
|
||||
->sortable(),
|
||||
TextColumn::make('created_at')
|
||||
->label('创建时间')
|
||||
->dateTime('Y-m-d H:i')
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
])
|
||||
->filters([
|
||||
\Filament\Tables\Filters\SelectFilter::make('status')
|
||||
->label('状态')
|
||||
->options(['active' => '启用', 'disabled' => '停用']),
|
||||
], \Filament\Tables\Enums\FiltersLayout::AboveContent)
|
||||
->recordActions([
|
||||
Action::make('toggle')
|
||||
->label(fn (Redirect $record) => $record->isActive() ? '停用' : '启用')
|
||||
->color(fn (Redirect $record) => $record->isActive() ? 'danger' : 'success')
|
||||
->action(fn (Redirect $record) => $record->update(['status' => $record->isActive() ? 'disabled' : 'active'])),
|
||||
DeleteAction::make(),
|
||||
])
|
||||
->recordUrl(null)
|
||||
->recordAction(null)
|
||||
->defaultSort('clicks', 'desc');
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListRedirects::route('/'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugins\Neatstudio\LinkTracker\Http;
|
||||
|
||||
use Plugins\Neatstudio\LinkTracker\Models\Redirect;
|
||||
|
||||
class GoController
|
||||
{
|
||||
/**
|
||||
* 外链中转跳转:计数 + 302。停用的链接返回 410(可作二次拦截)。
|
||||
*/
|
||||
public function show(string $hash)
|
||||
{
|
||||
$redirect = Redirect::query()->where('hash', $hash)->firstOrFail();
|
||||
|
||||
if (! $redirect->isActive()) {
|
||||
abort(410);
|
||||
}
|
||||
|
||||
$redirect->hit();
|
||||
|
||||
return redirect()->away($redirect->target_url, 302);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugins\Neatstudio\LinkTracker\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Redirect extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'hash', 'target_url', 'clicks', 'status', 'last_clicked_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'last_clicked_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function isActive(): bool
|
||||
{
|
||||
return $this->status === 'active';
|
||||
}
|
||||
|
||||
public function hit(): void
|
||||
{
|
||||
$this->increment('clicks');
|
||||
$this->update(['last_clicked_at' => now()]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugins\Neatstudio\LinkTracker;
|
||||
|
||||
use App\Blog\Support\PluginManager;
|
||||
use App\Blog\Support\PluginServiceProvider;
|
||||
use Plugins\Neatstudio\LinkTracker\Services\UrlRedirector;
|
||||
|
||||
class ServiceProvider extends PluginServiceProvider
|
||||
{
|
||||
protected function boot(PluginManager $manager): void
|
||||
{
|
||||
$this->loadRoutes(__DIR__.'/../routes/web.php');
|
||||
|
||||
// 核心视图(评论正文/作者网址/友链)通过 tracked_url() 走此过滤器改写
|
||||
$manager->addFilter('link.redirect', function (string $url) {
|
||||
return app(UrlRedirector::class)->goUrl($url);
|
||||
}, 10);
|
||||
|
||||
// 正文外链改写:优先级 20,晚于会员付费内容过滤(10),保证 paywall 也覆盖
|
||||
$manager->addFilter('post.rendered', function (string $html, \App\Models\Post $post) {
|
||||
return app(UrlRedirector::class)->rewriteExternalLinks($html);
|
||||
}, 20);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugins\Neatstudio\LinkTracker\Services;
|
||||
|
||||
use Illuminate\Database\UniqueConstraintViolationException;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Plugins\Neatstudio\LinkTracker\Models\Redirect;
|
||||
|
||||
/**
|
||||
* 外链中转:把站外链接改写为 /go/{hash},用于点击统计、二次拦截与未来广告位。
|
||||
* 站内链接、相对路径、mailto/tel/锚点不中转。
|
||||
*/
|
||||
class UrlRedirector
|
||||
{
|
||||
public function goUrl(string $url): string
|
||||
{
|
||||
$url = trim((string) $url);
|
||||
|
||||
if (! $this->isExternal($url)) {
|
||||
return $url;
|
||||
}
|
||||
|
||||
return route('go.show', $this->hashFor($url));
|
||||
}
|
||||
|
||||
public function isExternal(string $url): bool
|
||||
{
|
||||
if (! preg_match('#^https?://#i', $url)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$host = parse_url($url, PHP_URL_HOST);
|
||||
|
||||
if (! $host) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$internalHost = parse_url((string) config('app.url'), PHP_URL_HOST);
|
||||
|
||||
return strcasecmp($host, (string) $internalHost) !== 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 取 URL 对应的短 hash(确定性:同一 URL 复用同一条记录,并发安全)。
|
||||
*/
|
||||
public function hashFor(string $url): string
|
||||
{
|
||||
return Cache::remember('go.hash.'.md5($url), now()->addDay(), function () use ($url) {
|
||||
$hash = substr(hash('sha256', $url), 0, 10);
|
||||
|
||||
for ($i = 0; $i < 3; $i++) {
|
||||
try {
|
||||
Redirect::create(['hash' => $hash, 'target_url' => $url]);
|
||||
|
||||
return $hash;
|
||||
} catch (UniqueConstraintViolationException) {
|
||||
$existing = Redirect::where('target_url', $url)->first();
|
||||
|
||||
if ($existing) {
|
||||
return $existing->hash;
|
||||
}
|
||||
|
||||
$hash = substr(hash('sha256', $url.$i), 0, 10);
|
||||
}
|
||||
}
|
||||
|
||||
return $hash;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 HTML 里的站外 <a href> 改写为 /go 中转地址(站内/相对/锚点/mailto 不变)。
|
||||
*/
|
||||
public function rewriteExternalLinks(string $html): string
|
||||
{
|
||||
return preg_replace_callback(
|
||||
'/<a\s+([^>]*?href=["\'])([^"\']+)(["\'][^>]*)>(.*?)<\/a>/is',
|
||||
function (array $m): string {
|
||||
$url = html_entity_decode(trim($m[2]));
|
||||
$go = $this->goUrl($url);
|
||||
|
||||
if ($go === $url) {
|
||||
return $m[0];
|
||||
}
|
||||
|
||||
return '<a '.$m[1].e($go).$m[3].'>'.$m[4].'</a>';
|
||||
},
|
||||
$html
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,8 @@ class MembershipPlanResource extends Resource
|
||||
->recordActions([
|
||||
\Filament\Actions\EditAction::make(),
|
||||
])
|
||||
->recordUrl(null)
|
||||
->recordAction(null)
|
||||
->toolbarActions([
|
||||
\Filament\Actions\BulkActionGroup::make([
|
||||
\Filament\Actions\DeleteBulkAction::make(),
|
||||
|
||||
@@ -63,6 +63,8 @@ class SubscriptionResource extends Resource
|
||||
->recordActions([
|
||||
\Filament\Actions\EditAction::make(),
|
||||
])
|
||||
->recordUrl(null)
|
||||
->recordAction(null)
|
||||
->defaultSort('created_at', 'desc');
|
||||
}
|
||||
|
||||
|
||||
@@ -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('—'),
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<ul class="links-list">
|
||||
@forelse($links as $link)
|
||||
<li>
|
||||
<a href="{{ $link->url }}" target="_blank" rel="noopener nofollow">{{ $link->name }}</a>
|
||||
<a href="{{ tracked_url($link->url) }}" target="_blank" rel="noopener nofollow">{{ $link->name }}</a>
|
||||
@if($link->note)<span class="link-note">— {{ $link->note }}</span>@endif
|
||||
</li>
|
||||
@empty
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
@php
|
||||
$pageTitle = ($archiveTitle ?? $category->name ?? '文章列表').' - '.$siteName;
|
||||
$pageDescription = $category->description ?? ($archiveTitle ?? $category->name ?? '文章列表').' - '.$siteName;
|
||||
@endphp
|
||||
@include('partials.head')
|
||||
<body>
|
||||
@@ -8,6 +9,21 @@
|
||||
<main class="content">
|
||||
<h1 class="list-title">{{ $archiveTitle ?? ($category->name ?? '文章列表') }}</h1>
|
||||
|
||||
@if(isset($category) && ($category->description || $category->getFirstMedia('cover')))
|
||||
<div class="category-intro">
|
||||
@if($cover = $category->getFirstMedia('cover'))
|
||||
<img src="{{ $cover->getUrl() }}" alt="{{ $category->name }}" class="category-cover">
|
||||
@endif
|
||||
<div>
|
||||
@if($category->description)
|
||||
<p class="category-description">{{ $category->description }}</p>
|
||||
@endif
|
||||
<p class="category-meta">{{ $category->posts_count ?? $posts->total() }} 篇文章</p>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
|
||||
@forelse($posts as $post)
|
||||
@include('partials.post-card', ['post' => $post])
|
||||
@empty
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
<h3 class="widget-title">友情链接</h3>
|
||||
<ul class="widget-list">
|
||||
@forelse($links as $link)
|
||||
<li><a href="{{ $link->url }}" target="_blank" rel="noopener">{{ $link->name }}</a></li>
|
||||
<li><a href="{{ tracked_url($link->url) }}" target="_blank" rel="noopener">{{ $link->name }}</a></li>
|
||||
@empty
|
||||
<li>暂无链接</li>
|
||||
@endforelse
|
||||
|
||||
@@ -68,11 +68,11 @@
|
||||
<div class="comment-head">
|
||||
<strong>{{ $comment->author_name }}</strong>
|
||||
@if($comment->author_url)
|
||||
<span>· <a href="{{ $comment->author_url }}" target="_blank" rel="noopener nofollow">访问主页</a></span>
|
||||
<span>· <a href="{{ tracked_url($comment->author_url) }}" target="_blank" rel="noopener nofollow">访问主页</a></span>
|
||||
@endif
|
||||
<span class="comment-time">· {{ $comment->created_at->format(blog_setting('comment_timeformat', 'Y-m-d H:i')) }}</span>
|
||||
</div>
|
||||
<div class="comment-body">{!! nl2br(e($comment->content)) !!}</div>
|
||||
<div class="comment-body">{!! comment_body($comment->content) !!}</div>
|
||||
</div>
|
||||
@empty
|
||||
<p class="empty">暂无评论</p>
|
||||
|
||||
+18
-5
@@ -1,8 +1,21 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Foundation\Inspiring;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
declare(strict_types=1);
|
||||
|
||||
Artisan::command('inspire', function () {
|
||||
$this->comment(Inspiring::quote());
|
||||
})->purpose('Display an inspiring quote');
|
||||
use Illuminate\Support\Facades\Schedule;
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| 调度器(生产需配置 cron:* * * * * php artisan schedule:run)
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
// 数据库每日备份(保留 7 天;spatie/laravel-backup)
|
||||
Schedule::command('backup:run --only-db')
|
||||
->dailyAt('03:00')
|
||||
->withoutOverlapping()
|
||||
->appendOutputTo(storage_path('logs/backup.log'));
|
||||
|
||||
// 每日清理前台页面缓存(避免旧缓存长期驻留)
|
||||
Schedule::call(fn () => \App\Blog\Support\PageCacheMiddleware::flush())
|
||||
->dailyAt('04:00');
|
||||
|
||||
+5
-4
@@ -38,6 +38,7 @@ Route::post('/posts/{post}/comments', [CommentController::class, 'store'])->name
|
||||
// 浏览量统计(独立端点,避开整页缓存,保证实时)
|
||||
Route::get('/track-view/{post}', fn (\App\Models\Post $post) => response($post->increment('views') ? '' : '', 204))->name('track-view');
|
||||
|
||||
|
||||
Route::get('/category/{slug}.shtml', [CategoryController::class, 'show'])->where('slug', '[^/]+')->name('category.show');
|
||||
Route::get('/category/{slug}', fn (string $slug) => redirect()->route('category.show', $slug, 301))->where('slug', '[^/]+');
|
||||
|
||||
@@ -65,15 +66,15 @@ Route::get('/comments', fn () => redirect()->route('comments', [], 301));
|
||||
|
||||
Route::get('/login.shtml', [AuthController::class, 'showLogin'])->name('login');
|
||||
Route::get('/login', fn () => redirect()->route('login', [], 301));
|
||||
Route::post('/login.shtml', [AuthController::class, 'login']);
|
||||
Route::post('/login', [AuthController::class, 'login']);
|
||||
Route::post('/login.shtml', [AuthController::class, 'login'])->middleware('throttle:6,1');
|
||||
Route::post('/login', [AuthController::class, 'login'])->middleware('throttle:6,1');
|
||||
Route::post('/logout.shtml', [AuthController::class, 'logout'])->name('logout');
|
||||
Route::post('/logout', [AuthController::class, 'logout']);
|
||||
|
||||
Route::get('/register.shtml', [AuthController::class, 'showRegister'])->name('register');
|
||||
Route::get('/register', fn () => redirect()->route('register', [], 301));
|
||||
Route::post('/register.shtml', [AuthController::class, 'register']);
|
||||
Route::post('/register', [AuthController::class, 'register']);
|
||||
Route::post('/register.shtml', [AuthController::class, 'register'])->middleware('throttle:6,1');
|
||||
Route::post('/register', [AuthController::class, 'register'])->middleware('throttle:6,1');
|
||||
|
||||
Route::get('/profile.shtml', [AuthController::class, 'profile'])->middleware('auth')->name('profile');
|
||||
Route::get('/profile', fn () => redirect()->route('profile', [], 301))->middleware('auth');
|
||||
|
||||
@@ -55,6 +55,7 @@ class AdminPagesTest extends TestCase
|
||||
$this->actingAs($this->admin)->get('/admin/plugins')->assertOk();
|
||||
$this->actingAs($this->admin)->get('/admin/payments')->assertOk();
|
||||
$this->actingAs($this->admin)->get('/admin/ai-settings')->assertOk();
|
||||
$this->actingAs($this->admin)->get('/admin/redirects')->assertOk();
|
||||
}
|
||||
|
||||
public function test_guest_is_redirected_to_login(): void
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Blog\Services\PostContentRenderer;
|
||||
use App\Models\Post;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Plugins\Neatstudio\LinkTracker\Models\Redirect;
|
||||
use Tests\TestCase;
|
||||
|
||||
class GoRedirectTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_tracked_url_rewrites_external_but_keeps_internal(): void
|
||||
{
|
||||
// 外链中转插件默认启用,link.redirect 过滤器生效
|
||||
$this->assertStringContainsString('/go/', tracked_url('https://example.com/path'));
|
||||
$this->assertSame('http://laralog.test/posts/1', tracked_url('http://laralog.test/posts/1'));
|
||||
$this->assertSame('/posts/1', tracked_url('/posts/1'));
|
||||
$this->assertSame('mailto:a@b.com', tracked_url('mailto:a@b.com'));
|
||||
$this->assertSame('#anchor', tracked_url('#anchor'));
|
||||
}
|
||||
|
||||
public function test_tracked_url_returns_original_without_plugin_filter(): void
|
||||
{
|
||||
// 无插件注册 link.redirect 过滤器时(插件停用/未装),核心视图原样返回外链
|
||||
$manager = new \App\Blog\Support\PluginManager();
|
||||
|
||||
$this->assertSame('https://example.com/path', $manager->applyFilters('link.redirect', 'https://example.com/path'));
|
||||
}
|
||||
|
||||
public function test_go_redirect_counts_clicks(): void
|
||||
{
|
||||
$redirect = Redirect::create([
|
||||
'hash' => 'abc123def0',
|
||||
'target_url' => 'https://example.com/target',
|
||||
]);
|
||||
|
||||
$this->get('/go/abc123def0')
|
||||
->assertRedirect('https://example.com/target');
|
||||
|
||||
$redirect->refresh();
|
||||
$this->assertSame(1, $redirect->clicks);
|
||||
$this->assertNotNull($redirect->last_clicked_at);
|
||||
}
|
||||
|
||||
public function test_disabled_redirect_returns_410(): void
|
||||
{
|
||||
Redirect::create([
|
||||
'hash' => 'blocked0000',
|
||||
'target_url' => 'https://example.com/bad',
|
||||
'status' => 'disabled',
|
||||
]);
|
||||
|
||||
$this->get('/go/blocked0000')->assertStatus(410);
|
||||
}
|
||||
|
||||
public function test_same_url_reuses_same_hash(): void
|
||||
{
|
||||
$a = tracked_url('https://example.com/dup');
|
||||
$b = tracked_url('https://example.com/dup');
|
||||
|
||||
$this->assertSame($a, $b);
|
||||
$this->assertSame(1, Redirect::query()->where('target_url', 'https://example.com/dup')->count());
|
||||
}
|
||||
|
||||
public function test_post_render_rewrites_external_links(): void
|
||||
{
|
||||
$post = Post::create([
|
||||
'title' => '外链测试',
|
||||
'slug' => 'go-test',
|
||||
'content' => "正文 <a href=\"https://example.com/x\" target=\"_blank\">外链</a> 和 <a href=\"http://laralog.test/posts/1\">站内</a>",
|
||||
'content_format' => 'html',
|
||||
'status' => 'published',
|
||||
'published_at' => now(),
|
||||
]);
|
||||
|
||||
$html = app(PostContentRenderer::class)->render($post);
|
||||
|
||||
$this->assertStringContainsString('href="http://laralog.test/go/', $html);
|
||||
$this->assertStringContainsString('href="http://laralog.test/posts/1"', $html);
|
||||
$this->assertStringNotContainsString('href="https://example.com/x"', $html);
|
||||
}
|
||||
|
||||
public function test_comment_body_autolinks_and_redirects(): void
|
||||
{
|
||||
$html = comment_body('看看 https://example.com/note 这个链接');
|
||||
|
||||
$this->assertStringContainsString('href="http://laralog.test/go/', $html);
|
||||
$this->assertStringContainsString('rel="nofollow noopener"', $html);
|
||||
$this->assertStringNotContainsString('href="https://example.com/note"', $html);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
@php
|
||||
$pageTitle = ($archiveTitle ?? $category->name ?? '文章列表').' - '.$siteName;
|
||||
$pageDescription = $category->description ?? ($archiveTitle ?? $category->name ?? '文章列表').' - '.$siteName;
|
||||
@endphp
|
||||
@include('partials.head')
|
||||
<body>
|
||||
@@ -8,6 +9,21 @@
|
||||
<main id="content">
|
||||
<h1 class="list-title">{{ $archiveTitle ?? ($category->name ?? '文章列表') }}</h1>
|
||||
|
||||
@if(isset($category) && ($category->description || $category->getFirstMedia('cover')))
|
||||
<div class="category-intro">
|
||||
@if($cover = $category->getFirstMedia('cover'))
|
||||
<img src="{{ $cover->getUrl() }}" alt="{{ $category->name }}" class="category-cover">
|
||||
@endif
|
||||
<div>
|
||||
@if($category->description)
|
||||
<p class="category-description">{{ $category->description }}</p>
|
||||
@endif
|
||||
<p class="category-meta">{{ $category->posts_count ?? $posts->total() }} 篇文章</p>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
|
||||
@forelse($posts as $post)
|
||||
@include('partials.post-card', ['post' => $post])
|
||||
@empty
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
<h3>友情链接</h3>
|
||||
<ul>
|
||||
@forelse($links as $link)
|
||||
<li><a href="{{ $link->url }}" target="_blank" rel="noopener">{{ $link->name }}</a></li>
|
||||
<li><a href="{{ tracked_url($link->url) }}" target="_blank" rel="noopener">{{ $link->name }}</a></li>
|
||||
@empty
|
||||
<li>暂无链接</li>
|
||||
@endforelse
|
||||
|
||||
@@ -66,10 +66,10 @@
|
||||
<div class="comment" id="comment-{{ $comment->id }}">
|
||||
<div class="comment-head">
|
||||
<strong>{{ $comment->author_name }}</strong>
|
||||
@if($comment->author_url)<span>· <a href="{{ $comment->author_url }}" target="_blank" rel="noopener nofollow">访问主页</a></span>@endif
|
||||
@if($comment->author_url)<span>· <a href="{{ tracked_url($comment->author_url) }}" target="_blank" rel="noopener nofollow">访问主页</a></span>@endif
|
||||
<span class="comment-time">· {{ $comment->created_at->format(blog_setting('comment_timeformat', 'Y-m-d H:i')) }}</span>
|
||||
</div>
|
||||
<div class="comment-body">{!! nl2br(e($comment->content)) !!}</div>
|
||||
<div class="comment-body">{!! comment_body($comment->content) !!}</div>
|
||||
</div>
|
||||
@empty
|
||||
<p class="empty">暂无评论</p>
|
||||
|
||||
Reference in New Issue
Block a user