'array', 'is_sticky' => 'boolean', 'sticky_level' => 'integer', 'close_comment' => 'boolean', 'published_at' => 'datetime', ]; public const STICKY_NONE = 0; public const STICKY_LIGHT = 1; public const STICKY_STRONG = 2; public const STICKY_HEADLINE = 3; /** * @return array */ public static function stickyLevelOptions(): array { return [ self::STICKY_NONE => '不置顶', self::STICKY_LIGHT => '轻置顶', self::STICKY_STRONG => '强置顶', self::STICKY_HEADLINE => '置顶头条', ]; } public function stickyLabel(): ?string { $level = (int) $this->sticky_level; return $level > 0 ? (self::stickyLevelOptions()[$level] ?? '置顶') : null; } public function isPinned(): bool { return (int) $this->sticky_level > 0 || $this->is_sticky; } protected static function booted(): void { static::saving(function (Post $post): void { $level = (int) ($post->sticky_level ?? 0); $post->sticky_level = max(self::STICKY_NONE, min(self::STICKY_HEADLINE, $level)); if ($post->is_sticky && $post->sticky_level < self::STICKY_LIGHT) { $post->sticky_level = self::STICKY_LIGHT; } $post->is_sticky = $post->sticky_level > self::STICKY_NONE; }); } public function scopeOrderedForList(Builder $query): Builder { return $query->orderByDesc('sticky_level')->latest('published_at'); } public function category(): BelongsTo { return $this->belongsTo(Category::class); } public function author(): BelongsTo { return $this->belongsTo(User::class, 'user_id'); } public function coverMedia(): BelongsTo { return $this->belongsTo(MediaLibrary::class, 'cover_media_id'); } public function coverImageUrl(?string $conversion = 'card'): ?string { $libraryUrl = $this->coverMedia?->url; if (filled($libraryUrl)) { return (string) $libraryUrl; } $cover = $this->getFirstMedia('cover'); if ($cover === null) { return null; } return $cover->getUrl($conversion) ?: $cover->getUrl(); } /** * 封面回退链:显式封面 → 附件/正文里的第一张图。 * 都没有时返回 null,由调用方决定兜底(占位图或不显示)。 */ public function coverOrFirstImageUrl(): ?string { if ($url = $this->coverImageUrl()) { return $url; } // 附件里的第一张图片 $attachment = $this->getMedia('attachments') ->first(fn ($m) => str_starts_with((string) $m->mime_type, 'image/')); if ($attachment) { return $attachment->getUrl(); } // 正文 HTML/Markdown 里的第一张图片地址 $content = (string) $this->content; if (preg_match('/]+src=["\']([^"\']+)["\']/i', $content, $m)) { return $m[1]; } if (preg_match('/!\[[^\]]*\]\((https?:\/\/[^)\s]+)[^)]*\)/i', $content, $m)) { return $m[1]; } return null; } public function comments(): HasMany { return $this->hasMany(Comment::class); } public function scopePublished(Builder $query): Builder { return $query->where('status', 'published') ->whereNotNull('published_at') ->where('published_at', '<=', now()); } public function scopeSearch(Builder $query, ?string $keyword): Builder { if (! $keyword) { return $query; } return $query->where(function (Builder $q) use ($keyword) { $q->where('title', 'like', "%{$keyword}%") ->orWhere('content', 'like', "%{$keyword}%") ->orWhere('excerpt', 'like', "%{$keyword}%") ->orWhere('keywords', 'like', "%{$keyword}%"); }); } public function registerMediaCollections(): void { $this->addMediaCollection('attachments')->useDisk(MediaDisk::name()); $this->addMediaCollection('cover')->useDisk(MediaDisk::name()) ->singleFile() ->registerMediaConversions(function () { $this->addMediaConversion('card')->width(1200)->height(630)->sharpen(1); }); } public function registerMediaConversions(?Media $media = null): void { [$tw, $th] = array_pad(explode('x', strtolower((string) blog_setting('attachments_thumbs_size', '500x500'))), 2, '500'); $this->addMediaConversion('thumb') ->width((int) $tw) ->height((int) $th) ->performOnCollections('attachments'); } public function getUrlAttribute(): string { return route('posts.show', $this->id); } /** * 作为可购买实体(单篇解锁)的展示约定。 */ public function payableLabel(): string { return '文章:'.$this->title; } public function payableUrl(): ?string { return route('filament.admin.resources.posts.edit', $this); } public function getExcerptOrFallbackAttribute(): string { if ($this->excerpt) { return $this->excerpt; } // 会员专享文章不落入内容摘要,避免泄漏到 paywall/SEO meta if (! empty(($this->meta ?? [])['members_only'])) { return ''; } $text = (new \App\Blog\Support\SablogHtmlNormalizer)->plainText((string) $this->content); // 付费块不进入摘要,避免付费内容泄漏到列表页/SEO meta $text = preg_replace('/\[paid\].*?\[\/paid\]/s', '', $text) ?? $text; $text = preg_replace('/!\[([^\]]*)\]\((?:media|attach):\d+\)/i', '$1', $text) ?? $text; $text = preg_replace('/\[([^\]]*)\]\((?:media|attach):\d+\)/i', '$1', $text) ?? $text; $text = preg_replace('/\[[^\]]*\]/', '', $text) ?? $text; return mb_substr(trim($text), 0, 200); } }