feat: ship public ops features and cache docker builds
Some checks failed
docker-images / build-and-push (admin, admin, termi-astro-admin, admin/Dockerfile) (push) Failing after 13s
docker-images / build-and-push (frontend, frontend, termi-astro-frontend, frontend/Dockerfile) (push) Has been cancelled
docker-images / build-and-push (backend, backend, termi-astro-backend, backend/Dockerfile) (push) Has been cancelled

This commit is contained in:
2026-04-01 13:22:19 +08:00
parent 669b79cc95
commit 497a9d713d
75 changed files with 6985 additions and 668 deletions

View File

@@ -38,6 +38,18 @@ const PostsPage = lazy(async () => {
const mod = await import('@/pages/posts-page')
return { default: mod.PostsPage }
})
const CategoriesPage = lazy(async () => {
const mod = await import('@/pages/categories-page')
return { default: mod.CategoriesPage }
})
const TagsPage = lazy(async () => {
const mod = await import('@/pages/tags-page')
return { default: mod.TagsPage }
})
const BackupsPage = lazy(async () => {
const mod = await import('@/pages/backups-page')
return { default: mod.BackupsPage }
})
const RevisionsPage = lazy(async () => {
const mod = await import('@/pages/revisions-page')
return { default: mod.RevisionsPage }
@@ -251,6 +263,30 @@ function AppRoutes() {
</LazyRoute>
}
/>
<Route
path="categories"
element={
<LazyRoute>
<CategoriesPage />
</LazyRoute>
}
/>
<Route
path="tags"
element={
<LazyRoute>
<TagsPage />
</LazyRoute>
}
/>
<Route
path="backups"
element={
<LazyRoute>
<BackupsPage />
</LazyRoute>
}
/>
<Route
path="revisions"
element={

View File

@@ -2,7 +2,9 @@ import {
BarChart3,
BellRing,
BookOpenText,
Download,
ExternalLink,
Folders,
History,
Image as ImageIcon,
LayoutDashboard,
@@ -13,6 +15,7 @@ import {
ScrollText,
Settings,
Sparkles,
Tags,
} from 'lucide-react'
import type { ReactNode } from 'react'
import { NavLink } from 'react-router-dom'
@@ -42,6 +45,24 @@ const primaryNav = [
description: 'Markdown 内容管理',
icon: ScrollText,
},
{
to: '/categories',
label: '分类',
description: '分类目录与聚合统计',
icon: Folders,
},
{
to: '/tags',
label: '标签',
description: '标签库与引用整理',
icon: Tags,
},
{
to: '/backups',
label: '备份',
description: '全站导出与恢复',
icon: Download,
},
{
to: '/revisions',
label: '版本',

View File

@@ -7,6 +7,7 @@ import type {
AdminMediaBatchDeleteResponse,
AdminMediaDeleteResponse,
AdminMediaListResponse,
AdminMediaMetadataResponse,
AdminMediaReplaceResponse,
AdminMediaUploadResponse,
AdminPostCoverImageRequest,
@@ -20,6 +21,7 @@ import type {
AdminSessionResponse,
AdminSiteSettingsResponse,
AuditLogRecord,
CategoryRecord,
CommentListQuery,
CommentBlacklistRecord,
CommentPersonaAnalysisLogRecord,
@@ -33,7 +35,9 @@ import type {
MarkdownDeleteResponse,
MarkdownDocumentResponse,
MarkdownImportResponse,
MediaAssetMetadataPayload,
NotificationDeliveryRecord,
PostPageResponse,
PostListQuery,
PostRevisionDetail,
PostRevisionRecord,
@@ -41,11 +45,16 @@ import type {
ReviewRecord,
RestoreRevisionResponse,
SiteSettingsPayload,
SiteBackupDocument,
SiteBackupImportPayload,
SiteBackupImportResponse,
SubscriptionDigestResponse,
SubscriptionListResponse,
SubscriptionPayload,
SubscriptionRecord,
SubscriptionUpdatePayload,
TagRecord,
TaxonomyPayload,
UpdateCommentPayload,
UpdatePostPayload,
UpdateReviewPayload,
@@ -241,6 +250,68 @@ export const adminApi = {
}),
dashboard: () => request<AdminDashboardResponse>('/api/admin/dashboard'),
analytics: () => request<AdminAnalyticsResponse>('/api/admin/analytics'),
listCategories: () => request<CategoryRecord[]>('/api/admin/categories'),
createCategory: (payload: TaxonomyPayload) =>
request<CategoryRecord>('/api/admin/categories', {
method: 'POST',
body: JSON.stringify({
name: payload.name,
slug: payload.slug,
description: payload.description,
cover_image: payload.coverImage,
accent_color: payload.accentColor,
seo_title: payload.seoTitle,
seo_description: payload.seoDescription,
}),
}),
updateCategory: (id: number, payload: TaxonomyPayload) =>
request<CategoryRecord>(`/api/admin/categories/${id}`, {
method: 'PATCH',
body: JSON.stringify({
name: payload.name,
slug: payload.slug,
description: payload.description,
cover_image: payload.coverImage,
accent_color: payload.accentColor,
seo_title: payload.seoTitle,
seo_description: payload.seoDescription,
}),
}),
deleteCategory: (id: number) =>
request<void>(`/api/admin/categories/${id}`, {
method: 'DELETE',
}),
listTags: () => request<TagRecord[]>('/api/admin/tags'),
createTag: (payload: TaxonomyPayload) =>
request<TagRecord>('/api/admin/tags', {
method: 'POST',
body: JSON.stringify({
name: payload.name,
slug: payload.slug,
description: payload.description,
cover_image: payload.coverImage,
accent_color: payload.accentColor,
seo_title: payload.seoTitle,
seo_description: payload.seoDescription,
}),
}),
updateTag: (id: number, payload: TaxonomyPayload) =>
request<TagRecord>(`/api/admin/tags/${id}`, {
method: 'PATCH',
body: JSON.stringify({
name: payload.name,
slug: payload.slug,
description: payload.description,
cover_image: payload.coverImage,
accent_color: payload.accentColor,
seo_title: payload.seoTitle,
seo_description: payload.seoDescription,
}),
}),
deleteTag: (id: number) =>
request<void>(`/api/admin/tags/${id}`, {
method: 'DELETE',
}),
getSiteSettings: () => request<AdminSiteSettingsResponse>('/api/admin/site-settings'),
updateSiteSettings: (payload: SiteSettingsPayload) =>
request<AdminSiteSettingsResponse>('/api/admin/site-settings', {
@@ -334,6 +405,24 @@ export const adminApi = {
body: formData,
})
},
updateMediaObjectMetadata: (payload: MediaAssetMetadataPayload) =>
request<AdminMediaMetadataResponse>('/api/admin/storage/media/metadata', {
method: 'PATCH',
body: JSON.stringify({
key: payload.key,
title: payload.title,
alt_text: payload.altText,
caption: payload.caption,
tags: payload.tags,
notes: payload.notes,
}),
}),
exportSiteBackup: () => request<SiteBackupDocument>('/api/admin/site-backup/export'),
importSiteBackup: (payload: SiteBackupImportPayload) =>
request<SiteBackupImportResponse>('/api/admin/site-backup/import', {
method: 'POST',
body: JSON.stringify(payload),
}),
generatePostMetadata: (markdown: string) =>
request<AdminPostMetadataResponse>('/api/admin/ai/post-metadata', {
method: 'POST',
@@ -387,6 +476,27 @@ export const adminApi = {
preview: query?.preview ?? true,
}),
),
listPostsPage: (query?: PostListQuery) =>
request<PostPageResponse>(
appendQueryParams('/api/posts/page', {
slug: query?.slug,
category: query?.category,
tag: query?.tag,
search: query?.search,
type: query?.postType,
pinned: query?.pinned,
status: query?.status,
visibility: query?.visibility,
listed_only: query?.listedOnly,
include_private: query?.includePrivate ?? true,
include_redirects: query?.includeRedirects ?? true,
preview: query?.preview ?? true,
page: query?.page,
page_size: query?.pageSize,
sort_by: query?.sortBy,
sort_order: query?.sortOrder,
}),
),
getPostBySlug: (slug: string) =>
request<PostRecord>(`/api/posts/slug/${encodeURIComponent(slug)}?preview=true&include_private=true`),
createPost: (payload: CreatePostPayload) =>

View File

@@ -301,6 +301,14 @@ export interface AdminSiteSettingsResponse {
music_playlist: MusicTrack[]
ai_enabled: boolean
paragraph_comments_enabled: boolean
comment_turnstile_enabled: boolean
subscription_turnstile_enabled: boolean
web_push_enabled: boolean
turnstile_site_key: string | null
turnstile_secret_key: string | null
web_push_vapid_public_key: string | null
web_push_vapid_private_key: string | null
web_push_vapid_subject: string | null
ai_provider: string | null
ai_api_base: string | null
ai_api_key: string | null
@@ -327,6 +335,7 @@ export interface AdminSiteSettingsResponse {
seo_default_og_image: string | null
seo_default_twitter_handle: string | null
notification_webhook_url: string | null
notification_channel_type: 'webhook' | 'ntfy' | string
notification_comment_enabled: boolean
notification_friend_link_enabled: boolean
subscription_popup_enabled: boolean
@@ -366,6 +375,14 @@ export interface SiteSettingsPayload {
musicPlaylist?: MusicTrack[]
aiEnabled?: boolean
paragraphCommentsEnabled?: boolean
commentTurnstileEnabled?: boolean
subscriptionTurnstileEnabled?: boolean
webPushEnabled?: boolean
turnstileSiteKey?: string | null
turnstileSecretKey?: string | null
webPushVapidPublicKey?: string | null
webPushVapidPrivateKey?: string | null
webPushVapidSubject?: string | null
aiProvider?: string | null
aiApiBase?: string | null
aiApiKey?: string | null
@@ -389,6 +406,7 @@ export interface SiteSettingsPayload {
seoDefaultOgImage?: string | null
seoDefaultTwitterHandle?: string | null
notificationWebhookUrl?: string | null
notificationChannelType?: 'webhook' | 'ntfy' | string | null
notificationCommentEnabled?: boolean
notificationFriendLinkEnabled?: boolean
subscriptionPopupEnabled?: boolean
@@ -398,6 +416,44 @@ export interface SiteSettingsPayload {
searchSynonyms?: string[]
}
export interface CategoryRecord {
id: number
name: string
slug: string
count: number
description: string | null
cover_image: string | null
accent_color: string | null
seo_title: string | null
seo_description: string | null
created_at: string
updated_at: string
}
export interface TagRecord {
id: number
name: string
slug: string
count: number
description: string | null
cover_image: string | null
accent_color: string | null
seo_title: string | null
seo_description: string | null
created_at: string
updated_at: string
}
export interface TaxonomyPayload {
name: string
slug?: string | null
description?: string | null
coverImage?: string | null
accentColor?: string | null
seoTitle?: string | null
seoDescription?: string | null
}
export interface AdminAiReindexResponse {
indexed_chunks: number
last_indexed_at: string | null
@@ -432,6 +488,11 @@ export interface AdminMediaObjectResponse {
url: string
size_bytes: number
last_modified: string | null
title: string | null
alt_text: string | null
caption: string | null
tags: string[]
notes: string | null
}
export interface AdminMediaListResponse {
@@ -466,6 +527,64 @@ export interface AdminMediaReplaceResponse {
url: string
}
export interface MediaAssetMetadataPayload {
key: string
title?: string | null
altText?: string | null
caption?: string | null
tags?: string[]
notes?: string | null
}
export interface AdminMediaMetadataResponse {
saved: boolean
key: string
title: string | null
alt_text: string | null
caption: string | null
tags: string[]
notes: string | null
}
export interface SiteBackupDocument {
version: string
exported_at: string
includes_storage_binaries: boolean
warning: string
site_settings: Record<string, unknown>
categories: Record<string, unknown>[]
tags: Record<string, unknown>[]
reviews: Record<string, unknown>[]
friend_links: Record<string, unknown>[]
media_assets: Record<string, unknown>[]
storage_manifest?: Record<string, unknown>[] | null
posts: Array<{
slug: string
file_name: string
markdown: string
}>
}
export interface SiteBackupImportPayload {
backup: SiteBackupDocument
mode?: 'merge' | 'replace' | string
}
export interface SiteBackupImportResponse {
imported: boolean
mode: string
site_settings_restored: boolean
posts_written: number
categories_upserted: number
tags_upserted: number
reviews_upserted: number
friend_links_upserted: number
media_assets_upserted: number
storage_manifest_items: number
includes_storage_binaries: boolean
warning: string
}
export interface CommentBlacklistRecord {
id: number
matcher_type: 'ip' | 'email' | 'user_agent' | string
@@ -603,6 +722,20 @@ export interface PostListQuery {
includePrivate?: boolean
includeRedirects?: boolean
preview?: boolean
page?: number
pageSize?: number
sortBy?: 'created_at' | 'updated_at' | 'title' | string
sortOrder?: 'asc' | 'desc' | string
}
export interface PostPageResponse {
items: PostRecord[]
page: number
page_size: number
total: number
total_pages: number
sort_by: string
sort_order: string
}
export interface CreatePostPayload {

View File

@@ -0,0 +1,248 @@
import { Download, RefreshCcw, Upload } from 'lucide-react'
import { useMemo, useState } from 'react'
import { toast } from 'sonner'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
import { adminApi, ApiError } from '@/lib/api'
import type { SiteBackupDocument, SiteBackupImportResponse } from '@/lib/types'
function downloadJson(filename: string, payload: unknown) {
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
link.remove()
URL.revokeObjectURL(url)
}
export function BackupsPage() {
const [exporting, setExporting] = useState(false)
const [importing, setImporting] = useState(false)
const [importMode, setImportMode] = useState<'merge' | 'replace'>('merge')
const [selectedFile, setSelectedFile] = useState<File | null>(null)
const [selectedBackup, setSelectedBackup] = useState<SiteBackupDocument | null>(null)
const [lastImportResult, setLastImportResult] = useState<SiteBackupImportResponse | null>(null)
const backupStats = useMemo(() => {
if (!selectedBackup) {
return null
}
return {
posts: selectedBackup.posts.length,
categories: selectedBackup.categories.length,
tags: selectedBackup.tags.length,
reviews: selectedBackup.reviews.length,
friendLinks: selectedBackup.friend_links.length,
mediaAssets: selectedBackup.media_assets.length,
storageManifest: selectedBackup.storage_manifest?.length ?? 0,
}
}, [selectedBackup])
return (
<div className="space-y-6">
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
<div className="space-y-3">
<Badge variant="secondary"> / </Badge>
<div>
<h2 className="text-3xl font-semibold tracking-tight"></h2>
<p className="mt-2 max-w-3xl text-sm leading-7 text-muted-foreground">
</p>
</div>
</div>
</div>
<div className="grid gap-6 xl:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)]">
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>
/
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="rounded-3xl border border-border/70 bg-background/50 p-4 text-sm leading-7 text-muted-foreground">
<p></p>
<ul className="mt-2 list-disc space-y-1 pl-5">
<li></li>
<li>Markdown </li>
<li> / </li>
<li></li>
<li></li>
</ul>
</div>
<Button
disabled={exporting}
onClick={async () => {
try {
setExporting(true)
const backup = await adminApi.exportSiteBackup()
const exportedAt = backup.exported_at.replaceAll(':', '-').replaceAll('.', '-')
downloadJson(`termi-backup-${exportedAt}.json`, backup)
toast.success('备份已导出到本地。')
} catch (error) {
toast.error(error instanceof ApiError ? error.message : '导出备份失败。')
} finally {
setExporting(false)
}
}}
>
<Download className="h-4 w-4" />
{exporting ? '导出中...' : '下载备份 JSON'}
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>
merge / replace replace markdown
</CardDescription>
</CardHeader>
<CardContent className="space-y-5">
<div className="grid gap-4 md:grid-cols-[220px_minmax(0,1fr)]">
<Select
value={importMode}
onChange={(event) => setImportMode(event.target.value as 'merge' | 'replace')}
>
<option value="merge">merge</option>
<option value="replace">replace</option>
</Select>
<Input
type="file"
accept="application/json"
onChange={async (event) => {
const file = event.target.files?.item(0) ?? null
setSelectedFile(file)
setLastImportResult(null)
if (!file) {
setSelectedBackup(null)
return
}
try {
const parsed = JSON.parse(await file.text()) as SiteBackupDocument
setSelectedBackup(parsed)
} catch {
setSelectedBackup(null)
toast.error('备份文件不是合法的 JSON。')
}
}}
/>
</div>
<div className="rounded-3xl border border-dashed border-border/70 bg-background/40 p-4 text-sm text-muted-foreground">
<p className="font-medium text-foreground"></p>
<ul className="mt-2 list-disc space-y-1 pl-5 leading-6">
<li>replace markdown / / </li>
<li>访</li>
<li></li>
</ul>
</div>
{selectedBackup ? (
<div className="rounded-3xl border border-border/70 bg-background/50 p-4">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline"> {selectedBackup.version}</Badge>
<Badge variant="outline"> {selectedBackup.exported_at}</Badge>
<Badge variant="secondary">{selectedBackup.includes_storage_binaries ? '包含二进制' : '仅对象清单'}</Badge>
</div>
<div className="mt-4 grid gap-3 sm:grid-cols-2 xl:grid-cols-3 text-sm text-muted-foreground">
<div>{backupStats?.posts ?? 0}</div>
<div>{backupStats?.categories ?? 0}</div>
<div>{backupStats?.tags ?? 0}</div>
<div>{backupStats?.reviews ?? 0}</div>
<div>{backupStats?.friendLinks ?? 0}</div>
<div>{backupStats?.mediaAssets ?? 0}</div>
<div>{backupStats?.storageManifest ?? 0}</div>
</div>
<p className="mt-4 text-sm leading-6 text-muted-foreground">{selectedBackup.warning}</p>
</div>
) : (
<div className="rounded-3xl border border-dashed border-border/70 bg-background/40 px-5 py-8 text-center text-sm text-muted-foreground">
{selectedFile ? '当前文件未通过 JSON 校验。' : '选择一个备份 JSON 后,这里会显示导入概览。'}
</div>
)}
<div className="flex flex-wrap items-center gap-3">
<Button
disabled={!selectedBackup || importing}
variant={importMode === 'replace' ? 'danger' : 'default'}
onClick={async () => {
if (!selectedBackup) {
return
}
if (
importMode === 'replace' &&
!window.confirm('replace 会覆盖当前内容,确认继续吗?')
) {
return
}
try {
setImporting(true)
const result = await adminApi.importSiteBackup({
backup: selectedBackup,
mode: importMode,
})
setLastImportResult(result)
toast.success('备份已导入。')
} catch (error) {
toast.error(error instanceof ApiError ? error.message : '导入备份失败。')
} finally {
setImporting(false)
}
}}
>
<Upload className="h-4 w-4" />
{importing ? '导入中...' : importMode === 'replace' ? '执行覆盖恢复' : '执行合并恢复'}
</Button>
<Button
variant="outline"
onClick={() => {
setSelectedFile(null)
setSelectedBackup(null)
setLastImportResult(null)
}}
>
<RefreshCcw className="h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
</div>
{lastImportResult ? (
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>{lastImportResult.mode}</CardDescription>
</CardHeader>
<CardContent className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4 text-sm text-muted-foreground">
<div>{lastImportResult.site_settings_restored ? '已恢复' : '未恢复'}</div>
<div>{lastImportResult.posts_written}</div>
<div>{lastImportResult.categories_upserted}</div>
<div>{lastImportResult.tags_upserted}</div>
<div>{lastImportResult.reviews_upserted}</div>
<div>{lastImportResult.friend_links_upserted}</div>
<div>{lastImportResult.media_assets_upserted}</div>
<div>{lastImportResult.storage_manifest_items}</div>
<div className="sm:col-span-2 xl:col-span-4">{lastImportResult.warning}</div>
</CardContent>
</Card>
) : null}
</div>
)
}

View File

@@ -0,0 +1,402 @@
import { Folders, Plus, RefreshCcw, Save, Trash2 } from 'lucide-react'
import { startTransition, useCallback, useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner'
import { FormField } from '@/components/form-field'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Skeleton } from '@/components/ui/skeleton'
import { Textarea } from '@/components/ui/textarea'
import { adminApi, ApiError } from '@/lib/api'
import { emptyToNull, formatDateTime } from '@/lib/admin-format'
import type { CategoryRecord, TaxonomyPayload } from '@/lib/types'
type CategoryFormState = {
name: string
slug: string
description: string
coverImage: string
accentColor: string
seoTitle: string
seoDescription: string
}
const defaultCategoryForm: CategoryFormState = {
name: '',
slug: '',
description: '',
coverImage: '',
accentColor: '',
seoTitle: '',
seoDescription: '',
}
function toFormState(item: CategoryRecord): CategoryFormState {
return {
name: item.name,
slug: item.slug,
description: item.description ?? '',
coverImage: item.cover_image ?? '',
accentColor: item.accent_color ?? '',
seoTitle: item.seo_title ?? '',
seoDescription: item.seo_description ?? '',
}
}
function toPayload(form: CategoryFormState): TaxonomyPayload {
return {
name: form.name.trim(),
slug: emptyToNull(form.slug),
description: emptyToNull(form.description),
coverImage: emptyToNull(form.coverImage),
accentColor: emptyToNull(form.accentColor),
seoTitle: emptyToNull(form.seoTitle),
seoDescription: emptyToNull(form.seoDescription),
}
}
export function CategoriesPage() {
const [items, setItems] = useState<CategoryRecord[]>([])
const [selectedId, setSelectedId] = useState<number | null>(null)
const [form, setForm] = useState<CategoryFormState>(defaultCategoryForm)
const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false)
const [saving, setSaving] = useState(false)
const [deleting, setDeleting] = useState(false)
const [searchTerm, setSearchTerm] = useState('')
const loadCategories = useCallback(async (showToast = false) => {
try {
if (showToast) {
setRefreshing(true)
}
const next = await adminApi.listCategories()
startTransition(() => {
setItems(next)
})
if (showToast) {
toast.success('分类列表已刷新。')
}
} catch (error) {
if (error instanceof ApiError && error.status === 401) {
return
}
toast.error(error instanceof ApiError ? error.message : '无法加载分类列表。')
} finally {
setLoading(false)
setRefreshing(false)
}
}, [])
useEffect(() => {
void loadCategories(false)
}, [loadCategories])
const filteredItems = useMemo(() => {
const keyword = searchTerm.trim().toLowerCase()
if (!keyword) {
return items
}
return items.filter((item) =>
[item.name, item.slug, item.description ?? '', item.seo_title ?? '']
.join('\n')
.toLowerCase()
.includes(keyword),
)
}, [items, searchTerm])
const selectedItem = useMemo(
() => items.find((item) => item.id === selectedId) ?? null,
[items, selectedId],
)
const resetForm = useCallback(() => {
setSelectedId(null)
setForm(defaultCategoryForm)
}, [])
const handleSave = useCallback(async () => {
if (!form.name.trim()) {
toast.error('请先填写分类名称。')
return
}
try {
setSaving(true)
if (selectedId) {
const updated = await adminApi.updateCategory(selectedId, toPayload(form))
startTransition(() => {
setItems((current) => current.map((item) => (item.id === updated.id ? updated : item)))
setSelectedId(updated.id)
setForm(toFormState(updated))
})
toast.success('分类已更新。')
} else {
const created = await adminApi.createCategory(toPayload(form))
startTransition(() => {
setItems((current) => [created, ...current])
setSelectedId(created.id)
setForm(toFormState(created))
})
toast.success('分类已创建。')
}
} catch (error) {
toast.error(error instanceof ApiError ? error.message : '保存分类失败。')
} finally {
setSaving(false)
}
}, [form, selectedId])
const handleDelete = useCallback(async () => {
if (!selectedItem) {
return
}
if (!window.confirm(`确认删除分类「${selectedItem.name}」吗?相关文章会同步移除该分类引用。`)) {
return
}
try {
setDeleting(true)
await adminApi.deleteCategory(selectedItem.id)
startTransition(() => {
setItems((current) => current.filter((item) => item.id !== selectedItem.id))
})
toast.success('分类已删除。')
resetForm()
} catch (error) {
toast.error(error instanceof ApiError ? error.message : '删除分类失败。')
} finally {
setDeleting(false)
}
}, [resetForm, selectedItem])
if (loading) {
return (
<div className="space-y-6">
<Skeleton className="h-40 rounded-3xl" />
<Skeleton className="h-[720px] rounded-3xl" />
</div>
)
}
return (
<div className="space-y-6">
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
<div className="space-y-3">
<Badge variant="secondary"></Badge>
<div>
<h2 className="text-3xl font-semibold tracking-tight"></h2>
<p className="mt-2 max-w-3xl text-sm leading-7 text-muted-foreground">
SEO
</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-3">
<Button variant="outline" onClick={resetForm}>
<Plus className="h-4 w-4" />
</Button>
<Button variant="secondary" onClick={() => void loadCategories(true)} disabled={refreshing}>
<RefreshCcw className="h-4 w-4" />
{refreshing ? '刷新中...' : '刷新'}
</Button>
</div>
</div>
<div className="grid gap-6 xl:grid-cols-[0.92fr_1.08fr]">
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>slug SEO </CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Input
placeholder="按分类名 / slug / 描述搜索"
value={searchTerm}
onChange={(event) => setSearchTerm(event.target.value)}
/>
{filteredItems.length ? (
<div className="space-y-3">
{filteredItems.map((item) => (
<button
key={item.id}
type="button"
onClick={() => {
setSelectedId(item.id)
setForm(toFormState(item))
}}
className={`w-full rounded-3xl border px-4 py-4 text-left transition ${
selectedId === item.id
? 'border-primary/30 bg-primary/10 shadow-[0_12px_30px_rgba(37,99,235,0.12)]'
: 'border-border/70 bg-background/60 hover:border-border'
}`}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 space-y-2">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{item.name}</span>
<Badge variant="outline">{item.slug}</Badge>
{item.accent_color ? (
<span
className="inline-flex h-5 w-5 rounded-full border border-border/80"
style={{ backgroundColor: item.accent_color }}
/>
) : null}
</div>
<p className="text-sm text-muted-foreground">
{item.description || `${item.count} 篇文章正在使用这个分类`}
</p>
</div>
<Badge variant={item.count > 0 ? 'success' : 'secondary'}>{item.count}</Badge>
</div>
</button>
))}
</div>
) : (
<div className="rounded-3xl border border-dashed border-border/70 bg-background/40 px-5 py-10 text-center text-sm text-muted-foreground">
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<div className="flex items-center gap-3">
<div className="flex h-11 w-11 items-center justify-center rounded-2xl border border-primary/20 bg-primary/10 text-primary">
<Folders className="h-5 w-5" />
</div>
<div>
<CardTitle>{selectedItem ? '编辑分类' : '新建分类'}</CardTitle>
<CardDescription>
/ slug SEO
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid gap-4 lg:grid-cols-2">
<FormField label="分类名称" hint="例如:前端工程、随笔、工具链。">
<Input
value={form.name}
onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))}
placeholder="输入分类名称"
/>
</FormField>
<FormField label="分类 slug" hint="留空时自动从英文名称生成;中文建议手填。">
<Input
value={form.slug}
onChange={(event) => setForm((current) => ({ ...current, slug: event.target.value }))}
placeholder="frontend-engineering"
/>
</FormField>
<FormField label="封面图 URL" hint="可选,用于前台分类头图。">
<Input
value={form.coverImage}
onChange={(event) =>
setForm((current) => ({ ...current, coverImage: event.target.value }))
}
placeholder="https://cdn.example.com/covers/frontend.jpg"
/>
</FormField>
<FormField label="强调色" hint="可选,用于前台分类详情强调色。">
<div className="flex items-center gap-3">
<Input
value={form.accentColor}
onChange={(event) =>
setForm((current) => ({ ...current, accentColor: event.target.value }))
}
placeholder="#3b82f6"
/>
<input
type="color"
value={form.accentColor || '#2563eb'}
onChange={(event) =>
setForm((current) => ({ ...current, accentColor: event.target.value }))
}
className="h-10 w-14 rounded-xl border border-input bg-background px-1"
/>
</div>
</FormField>
</div>
<FormField label="分类描述" hint="会展示在前台分类卡片和分类详情区域。">
<Textarea
value={form.description}
onChange={(event) =>
setForm((current) => ({ ...current, description: event.target.value }))
}
rows={4}
placeholder="介绍这个分类主要收录哪些内容。"
/>
</FormField>
<div className="grid gap-4 lg:grid-cols-2">
<FormField label="SEO 标题" hint="留空时前台继续回退到常规标题。">
<Input
value={form.seoTitle}
onChange={(event) =>
setForm((current) => ({ ...current, seoTitle: event.target.value }))
}
placeholder="前端工程专题 - Termi"
/>
</FormField>
<FormField label="SEO 描述" hint="搜索引擎摘要或社交分享描述。">
<Textarea
value={form.seoDescription}
onChange={(event) =>
setForm((current) => ({ ...current, seoDescription: event.target.value }))
}
rows={4}
placeholder="这个分类汇总了工程化、构建链路与调优经验。"
/>
</FormField>
</div>
<div className="grid gap-4 rounded-3xl border border-border/70 bg-background/50 p-4 md:grid-cols-3">
<div>
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground"></p>
<p className="mt-2 text-2xl font-semibold text-foreground">{selectedItem?.count ?? 0}</p>
</div>
<div>
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground"></p>
<p className="mt-2 text-sm text-muted-foreground">{formatDateTime(selectedItem?.created_at)}</p>
</div>
<div>
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground"></p>
<p className="mt-2 text-sm text-muted-foreground">{formatDateTime(selectedItem?.updated_at)}</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-3">
<Button onClick={() => void handleSave()} disabled={saving}>
<Save className="h-4 w-4" />
{saving ? '保存中...' : selectedItem ? '保存分类' : '创建分类'}
</Button>
<Button variant="outline" onClick={resetForm}>
</Button>
<Button
variant="ghost"
onClick={() => void handleDelete()}
disabled={!selectedItem || deleting}
className="text-rose-600 hover:text-rose-600"
>
<Trash2 className="h-4 w-4" />
{deleting ? '删除中...' : '删除分类'}
</Button>
</div>
</CardContent>
</Card>
</div>
</div>
)
}

View File

@@ -4,6 +4,7 @@ import {
Image as ImageIcon,
RefreshCcw,
Replace,
Save,
Square,
Trash2,
Upload,
@@ -24,6 +25,8 @@ import {
normalizeCoverImageWithPrompt,
} from '@/lib/image-compress'
import type { AdminMediaObjectResponse } from '@/lib/types'
import { FormField } from '@/components/form-field'
import { Textarea } from '@/components/ui/textarea'
function formatBytes(value: number) {
if (!Number.isFinite(value) || value <= 0) {
@@ -39,6 +42,47 @@ function formatBytes(value: number) {
return `${size >= 10 || unitIndex === 0 ? size.toFixed(0) : size.toFixed(1)} ${units[unitIndex]}`
}
type MediaMetadataFormState = {
title: string
altText: string
caption: string
tags: string
notes: string
}
const defaultMetadataForm: MediaMetadataFormState = {
title: '',
altText: '',
caption: '',
tags: '',
notes: '',
}
function toMetadataForm(item: AdminMediaObjectResponse | null): MediaMetadataFormState {
if (!item) {
return defaultMetadataForm
}
return {
title: item.title ?? '',
altText: item.alt_text ?? '',
caption: item.caption ?? '',
tags: item.tags.join(', '),
notes: item.notes ?? '',
}
}
function parseTagList(value: string) {
return Array.from(
new Set(
value
.split(',')
.map((item) => item.trim())
.filter(Boolean),
),
)
}
export function MediaPage() {
const [items, setItems] = useState<AdminMediaObjectResponse[]>([])
const [loading, setLoading] = useState(true)
@@ -54,6 +98,9 @@ export function MediaPage() {
const [bucket, setBucket] = useState<string | null>(null)
const [uploadFiles, setUploadFiles] = useState<File[]>([])
const [selectedKeys, setSelectedKeys] = useState<string[]>([])
const [activeKey, setActiveKey] = useState<string | null>(null)
const [metadataForm, setMetadataForm] = useState<MediaMetadataFormState>(defaultMetadataForm)
const [metadataSaving, setMetadataSaving] = useState(false)
const [compressBeforeUpload, setCompressBeforeUpload] = useState(true)
const [compressQuality, setCompressQuality] = useState('0.82')
@@ -90,6 +137,25 @@ export function MediaPage() {
)
}, [items])
useEffect(() => {
if (!items.length) {
setActiveKey(null)
setMetadataForm(defaultMetadataForm)
return
}
setActiveKey((current) => (current && items.some((item) => item.key === current) ? current : items[0].key))
}, [items])
const activeItem = useMemo(
() => items.find((item) => item.key === activeKey) ?? null,
[activeKey, items],
)
useEffect(() => {
setMetadataForm(toMetadataForm(activeItem))
}, [activeItem])
const filteredItems = useMemo(() => {
const keyword = searchTerm.trim().toLowerCase()
if (!keyword) {
@@ -266,6 +332,140 @@ export function MediaPage() {
</CardContent>
</Card>
{activeItem ? (
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>
{activeItem.key}alt /
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid gap-4 lg:grid-cols-[minmax(0,1.2fr)_minmax(320px,0.8fr)]">
<div className="space-y-4">
<div className="grid gap-4 lg:grid-cols-2">
<FormField label="标题" hint="媒体资源的人类可读名称。">
<Input
value={metadataForm.title}
onChange={(event) =>
setMetadataForm((current) => ({ ...current, title: event.target.value }))
}
placeholder="文章封面 / 站点横幅"
/>
</FormField>
<FormField label="Alt 文本" hint="用于 img alt 和无障碍描述。">
<Input
value={metadataForm.altText}
onChange={(event) =>
setMetadataForm((current) => ({ ...current, altText: event.target.value }))
}
placeholder="夜色下的终端风格博客封面"
/>
</FormField>
</div>
<FormField label="标签" hint="多个标签用英文逗号分隔。">
<Input
value={metadataForm.tags}
onChange={(event) =>
setMetadataForm((current) => ({ ...current, tags: event.target.value }))
}
placeholder="cover, astro, terminal"
/>
</FormField>
<FormField label="Caption" hint="适合前台图注、图片说明。">
<Textarea
value={metadataForm.caption}
onChange={(event) =>
setMetadataForm((current) => ({ ...current, caption: event.target.value }))
}
rows={4}
placeholder="这张图通常用于文章列表和详情页头图。"
/>
</FormField>
<FormField label="内部备注" hint="仅后台使用,例如素材来源、版权或推荐用途。">
<Textarea
value={metadataForm.notes}
onChange={(event) =>
setMetadataForm((current) => ({ ...current, notes: event.target.value }))
}
rows={4}
placeholder="来源Unsplash / 站点截图 / AI 生成"
/>
</FormField>
<div className="flex flex-wrap items-center gap-3">
<Button
disabled={metadataSaving}
onClick={async () => {
if (!activeItem) {
return
}
try {
setMetadataSaving(true)
const result = await adminApi.updateMediaObjectMetadata({
key: activeItem.key,
title: metadataForm.title || null,
altText: metadataForm.altText || null,
caption: metadataForm.caption || null,
tags: parseTagList(metadataForm.tags),
notes: metadataForm.notes || null,
})
startTransition(() => {
setItems((current) =>
current.map((item) =>
item.key === result.key
? {
...item,
title: result.title,
alt_text: result.alt_text,
caption: result.caption,
tags: result.tags,
notes: result.notes,
}
: item,
),
)
})
toast.success('媒体元数据已保存。')
} catch (error) {
toast.error(error instanceof ApiError ? error.message : '保存媒体元数据失败。')
} finally {
setMetadataSaving(false)
}
}}
>
<Save className="h-4 w-4" />
{metadataSaving ? '保存中...' : '保存元数据'}
</Button>
<Button variant="outline" onClick={() => setMetadataForm(toMetadataForm(activeItem))}>
</Button>
</div>
</div>
<div className="space-y-4 rounded-3xl border border-border/70 bg-background/50 p-4">
<div className="aspect-[16/9] overflow-hidden rounded-2xl border border-border/70 bg-muted/30">
<img
src={activeItem.url}
alt={metadataForm.altText || activeItem.key}
className="h-full w-full object-cover"
/>
</div>
<div className="space-y-2 text-sm text-muted-foreground">
<p className="break-all font-medium text-foreground">{activeItem.key}</p>
<p>{formatBytes(activeItem.size_bytes)} · {activeItem.last_modified ?? '未知修改时间'}</p>
<p>{metadataForm.altText || '尚未填写 alt 文本'}</p>
</div>
</div>
</div>
</CardContent>
</Card>
) : null}
{loading ? (
<Skeleton className="h-[520px] rounded-3xl" />
) : (
@@ -275,7 +475,10 @@ export function MediaPage() {
const replaceInputId = `replace-media-${index}`
return (
<Card key={item.key} className="overflow-hidden">
<Card
key={item.key}
className={`overflow-hidden ${activeKey === item.key ? 'ring-1 ring-primary/40' : ''}`}
>
<div className="relative aspect-[16/9] overflow-hidden bg-muted/30">
<img src={item.url} alt={item.key} className="h-full w-full object-cover" />
<button
@@ -300,8 +503,21 @@ export function MediaPage() {
<span>{formatBytes(item.size_bytes)}</span>
{item.last_modified ? <span>{item.last_modified}</span> : null}
</div>
{item.title ? <p className="text-sm text-foreground">{item.title}</p> : null}
{item.tags.length ? (
<div className="flex flex-wrap gap-2">
{item.tags.slice(0, 4).map((tag) => (
<Badge key={`${item.key}-${tag}`} variant="outline">
{tag}
</Badge>
))}
</div>
) : null}
</div>
<div className="flex flex-wrap gap-2">
<Button size="sm" variant="outline" onClick={() => setActiveKey(item.key)}>
</Button>
<Button
size="sm"
variant="outline"

View File

@@ -820,6 +820,26 @@ export function PostsPage() {
const [pinnedFilter, setPinnedFilter] = useState('all')
const [currentPage, setCurrentPage] = useState(1)
const [pageSize, setPageSize] = useState<number>(POSTS_PAGE_SIZE_OPTIONS[0])
const [sortKey, setSortKey] = useState('updated_at_desc')
const [totalPosts, setTotalPosts] = useState(0)
const [totalPages, setTotalPages] = useState(1)
const { sortBy, sortOrder } = useMemo(() => {
switch (sortKey) {
case 'created_at_asc':
return { sortBy: 'created_at', sortOrder: 'asc' }
case 'created_at_desc':
return { sortBy: 'created_at', sortOrder: 'desc' }
case 'title_asc':
return { sortBy: 'title', sortOrder: 'asc' }
case 'title_desc':
return { sortBy: 'title', sortOrder: 'desc' }
case 'updated_at_asc':
return { sortBy: 'updated_at', sortOrder: 'asc' }
default:
return { sortBy: 'updated_at', sortOrder: 'desc' }
}
}, [sortKey])
const loadPosts = useCallback(async (showToast = false) => {
try {
@@ -827,9 +847,28 @@ export function PostsPage() {
setRefreshing(true)
}
const next = await adminApi.listPosts()
const next = await adminApi.listPostsPage({
search: searchTerm.trim() || undefined,
postType: typeFilter === 'all' ? undefined : typeFilter,
pinned:
pinnedFilter === 'all'
? undefined
: pinnedFilter === 'pinned',
includePrivate: true,
includeRedirects: true,
preview: true,
page: currentPage,
pageSize,
sortBy,
sortOrder,
})
startTransition(() => {
setPosts(next)
setPosts(next.items)
setTotalPosts(next.total)
setTotalPages(next.total_pages)
if (next.page !== currentPage) {
setCurrentPage(next.page)
}
})
if (showToast) {
@@ -844,7 +883,7 @@ export function PostsPage() {
setLoading(false)
setRefreshing(false)
}
}, [])
}, [currentPage, pageSize, pinnedFilter, searchTerm, sortBy, sortOrder, typeFilter])
const loadEditor = useCallback(
async (nextSlug: string) => {
@@ -931,49 +970,17 @@ export function PostsPage() {
}
}, [createDialogOpen, metadataDialog, navigate, slug])
const normalizedSearchTerm = searchTerm.trim().toLowerCase()
const filteredPosts = useMemo(() => {
return posts.filter((post) => {
const matchesSearch =
!normalizedSearchTerm ||
[
post.title ?? '',
post.slug,
post.category ?? '',
post.description ?? '',
post.post_type ?? '',
postTagsToList(post.tags).join(' '),
]
.join('\n')
.toLowerCase()
.includes(normalizedSearchTerm)
const matchesType = typeFilter === 'all' || (post.post_type ?? 'article') === typeFilter
const pinnedValue = Boolean(post.pinned)
const matchesPinned =
pinnedFilter === 'all' ||
(pinnedFilter === 'pinned' && pinnedValue) ||
(pinnedFilter === 'regular' && !pinnedValue)
return matchesSearch && matchesType && matchesPinned
})
}, [normalizedSearchTerm, pinnedFilter, posts, typeFilter])
useEffect(() => {
setCurrentPage(1)
}, [pageSize, pinnedFilter, searchTerm, typeFilter])
}, [pageSize, pinnedFilter, searchTerm, sortKey, typeFilter])
const totalPages = Math.max(1, Math.ceil(filteredPosts.length / pageSize))
const safeCurrentPage = Math.min(currentPage, totalPages)
useEffect(() => {
setCurrentPage((current) => Math.min(current, totalPages))
}, [totalPages])
const paginatedPosts = useMemo(() => {
const startIndex = (safeCurrentPage - 1) * pageSize
return filteredPosts.slice(startIndex, startIndex + pageSize)
}, [filteredPosts, pageSize, safeCurrentPage])
const paginatedPosts = posts
const paginationItems = useMemo(() => {
const maxVisiblePages = 5
@@ -988,8 +995,8 @@ export function PostsPage() {
return Array.from({ length: endPage - startPage + 1 }, (_, index) => startPage + index)
}, [safeCurrentPage, totalPages])
const pageStart = filteredPosts.length ? (safeCurrentPage - 1) * pageSize + 1 : 0
const pageEnd = filteredPosts.length ? Math.min(safeCurrentPage * pageSize, filteredPosts.length) : 0
const pageStart = totalPosts ? (safeCurrentPage - 1) * pageSize + 1 : 0
const pageEnd = totalPosts ? Math.min(safeCurrentPage * pageSize, totalPosts) : 0
const pinnedPostCount = useMemo(
() => posts.filter((post) => Boolean(post.pinned)).length,
[posts],
@@ -1904,7 +1911,7 @@ export function PostsPage() {
</CardDescription>
</div>
<Badge variant="outline">{filteredPosts.length} / {posts.length}</Badge>
<Badge variant="outline">{paginatedPosts.length} / {totalPosts}</Badge>
</div>
<div className="grid gap-3">
<div className="flex flex-col gap-3 lg:flex-row">
@@ -1921,7 +1928,7 @@ export function PostsPage() {
</Button>
) : null}
</div>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<Select value={typeFilter} onChange={(event) => setTypeFilter(event.target.value)}>
<option value="all"></option>
<option value="article"></option>
@@ -1947,11 +1954,18 @@ export function PostsPage() {
</option>
))}
</Select>
<Select value={sortKey} onChange={(event) => setSortKey(event.target.value)}>
<option value="updated_at_desc"></option>
<option value="created_at_desc"></option>
<option value="created_at_asc"></option>
<option value="title_asc"> A Z</option>
<option value="title_desc"> Z A</option>
</Select>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Badge variant="secondary"> {filteredPosts.length}</Badge>
<Badge variant="outline"> {pinnedPostCount}</Badge>
<Badge variant="secondary"> {totalPosts}</Badge>
<Badge variant="outline"> {pinnedPostCount}</Badge>
<Badge variant="outline">
{safeCurrentPage} / {totalPages}
</Badge>
@@ -2008,18 +2022,18 @@ export function PostsPage() {
)
})}
{!filteredPosts.length ? (
{!totalPosts ? (
<div className="rounded-[1.8rem] border border-dashed border-border/80 px-5 py-12 text-center text-sm text-muted-foreground">
</div>
) : null}
</div>
{filteredPosts.length ? (
{totalPosts ? (
<div className="rounded-[1.5rem] border border-border/70 bg-background/65 px-4 py-3">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<p className="text-sm text-muted-foreground">
{pageStart} - {pageEnd} {filteredPosts.length}
{pageStart} - {pageEnd} {totalPosts}
</p>
<div className="flex flex-wrap items-center gap-2">
<Button

View File

@@ -65,6 +65,11 @@ const MEDIA_STORAGE_PROVIDER_OPTIONS = [
{ value: 'minio', label: 'MinIO' },
] as const
const NOTIFICATION_CHANNEL_OPTIONS = [
{ value: 'webhook', label: 'Webhook' },
{ value: 'ntfy', label: 'ntfy' },
] as const
function isCloudflareProvider(provider: string | null | undefined) {
const normalized = provider?.trim().toLowerCase()
return normalized === 'cloudflare' || normalized === 'cloudflare-workers-ai' || normalized === 'workers-ai'
@@ -89,6 +94,11 @@ function normalizeSettingsResponse(
...input,
ai_providers: aiProviders,
search_synonyms: searchSynonyms,
turnstile_site_key: input.turnstile_site_key ?? null,
turnstile_secret_key: input.turnstile_secret_key ?? null,
web_push_vapid_public_key: input.web_push_vapid_public_key ?? null,
web_push_vapid_private_key: input.web_push_vapid_private_key ?? null,
web_push_vapid_subject: input.web_push_vapid_subject ?? null,
ai_active_provider_id:
input.ai_active_provider_id ?? aiProviders[0]?.id ?? null,
}
@@ -133,6 +143,14 @@ function toPayload(form: AdminSiteSettingsResponse): SiteSettingsPayload {
musicPlaylist: form.music_playlist,
aiEnabled: form.ai_enabled,
paragraphCommentsEnabled: form.paragraph_comments_enabled,
commentTurnstileEnabled: form.comment_turnstile_enabled,
subscriptionTurnstileEnabled: form.subscription_turnstile_enabled,
webPushEnabled: form.web_push_enabled,
turnstileSiteKey: form.turnstile_site_key,
turnstileSecretKey: form.turnstile_secret_key,
webPushVapidPublicKey: form.web_push_vapid_public_key,
webPushVapidPrivateKey: form.web_push_vapid_private_key,
webPushVapidSubject: form.web_push_vapid_subject,
aiProvider: form.ai_provider,
aiApiBase: form.ai_api_base,
aiApiKey: form.ai_api_key,
@@ -156,6 +174,7 @@ function toPayload(form: AdminSiteSettingsResponse): SiteSettingsPayload {
seoDefaultOgImage: form.seo_default_og_image,
seoDefaultTwitterHandle: form.seo_default_twitter_handle,
notificationWebhookUrl: form.notification_webhook_url,
notificationChannelType: form.notification_channel_type,
notificationCommentEnabled: form.notification_comment_enabled,
notificationFriendLinkEnabled: form.notification_friend_link_enabled,
subscriptionPopupEnabled: form.subscription_popup_enabled,
@@ -624,6 +643,40 @@ export function SiteSettingsPage() {
</div>
</label>
<div className="grid gap-4 md:grid-cols-2">
<label className="flex items-start gap-3 rounded-2xl border border-border/70 bg-background/60 p-4">
<input
type="checkbox"
checked={form.web_push_enabled}
onChange={(event) => updateField('web_push_enabled', event.target.checked)}
className="mt-1 h-4 w-4 rounded border-input text-primary focus:ring-ring"
/>
<div>
<div className="font-medium"></div>
<p className="mt-1 text-sm leading-6 text-muted-foreground">
VAPID
</p>
</div>
</label>
<label className="flex items-start gap-3 rounded-2xl border border-border/70 bg-background/60 p-4">
<input
type="checkbox"
checked={form.subscription_turnstile_enabled}
onChange={(event) =>
updateField('subscription_turnstile_enabled', event.target.checked)
}
className="mt-1 h-4 w-4 rounded border-input text-primary focus:ring-ring"
/>
<div>
<div className="font-medium"> Turnstile</div>
<p className="mt-1 text-sm leading-6 text-muted-foreground">
Cloudflare Turnstile key
</p>
</div>
</label>
</div>
<div className="grid gap-4 lg:grid-cols-2">
<Field label="弹窗标题" hint="建议直接传达价值,例如“订阅更新”或“别错过新文章”。">
<Input
@@ -663,6 +716,77 @@ export function SiteSettingsPage() {
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle> / </CardTitle>
<CardDescription>
退
</CardDescription>
</CardHeader>
<CardContent className="grid gap-6 lg:grid-cols-2">
<Field
label="Turnstile Site Key"
hint="评论区和订阅弹窗共用这一套站点 key保存后前台会在运行时读取。"
>
<Input
value={form.turnstile_site_key ?? ''}
onChange={(event) => updateField('turnstile_site_key', event.target.value)}
placeholder="0x4AAAA..."
/>
</Field>
<Field
label="Turnstile Secret Key"
hint="后端验证 token 使用;留空可清除数据库配置并回退到环境变量。"
>
<Input
value={form.turnstile_secret_key ?? ''}
onChange={(event) => updateField('turnstile_secret_key', event.target.value)}
placeholder="ts-secret-key"
/>
</Field>
<Field
label="Web Push VAPID Public Key"
hint="浏览器订阅按钮会把这把 public key 下发到前台。"
>
<Textarea
value={form.web_push_vapid_public_key ?? ''}
onChange={(event) =>
updateField('web_push_vapid_public_key', event.target.value)
}
placeholder="BEl6..."
rows={3}
/>
</Field>
<Field
label="Web Push VAPID Private Key"
hint="后端发送浏览器推送时签名使用。"
>
<Textarea
value={form.web_push_vapid_private_key ?? ''}
onChange={(event) =>
updateField('web_push_vapid_private_key', event.target.value)
}
placeholder="5aQ..."
rows={3}
/>
</Field>
<div className="lg:col-span-2">
<Field
label="Web Push Subject"
hint="推荐填写 mailto:you@example.com留空时会优先回退到环境变量再退回站点 URL / 默认值。"
>
<Input
value={form.web_push_vapid_subject ?? ''}
onChange={(event) =>
updateField('web_push_vapid_subject', event.target.value)
}
placeholder="mailto:admin@example.com"
/>
</Field>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>SEO</CardTitle>
@@ -685,13 +809,39 @@ export function SiteSettingsPage() {
}
/>
</Field>
<div className="lg:col-span-2">
<Field label="Webhook URL" hint="评论和友链申请会向这个地址推送 JSON。">
<div className="grid gap-4 lg:col-span-2 md:grid-cols-[220px_minmax(0,1fr)]">
<Field label="通知渠道" hint="可选 Webhook 或 ntfy。">
<Select
value={form.notification_channel_type}
onChange={(event) =>
updateField('notification_channel_type', event.target.value)
}
>
{NOTIFICATION_CHANNEL_OPTIONS.map((item) => (
<option key={item.value} value={item.value}>
{item.label}
</option>
))}
</Select>
</Field>
<Field
label="通知目标"
hint={
form.notification_channel_type === 'ntfy'
? '支持 topic 名称或完整 ntfy URL。'
: '评论和友链申请会向这个地址推送 JSON。'
}
>
<Input
value={form.notification_webhook_url ?? ''}
onChange={(event) =>
updateField('notification_webhook_url', event.target.value)
}
placeholder={
form.notification_channel_type === 'ntfy'
? 'blog-admin 或 https://ntfy.example.com/blog-admin'
: 'https://example.com/hooks/termi'
}
/>
</Field>
</div>
@@ -708,7 +858,7 @@ export function SiteSettingsPage() {
<div>
<div className="font-medium"></div>
<p className="mt-1 text-sm leading-6 text-muted-foreground">
Webhook
</p>
</div>
</label>
@@ -724,7 +874,7 @@ export function SiteSettingsPage() {
<div>
<div className="font-medium"></div>
<p className="mt-1 text-sm leading-6 text-muted-foreground">
Webhook
</p>
</div>
</label>
@@ -775,6 +925,23 @@ export function SiteSettingsPage() {
</p>
</div>
</label>
<label className="flex items-start gap-3 rounded-2xl border border-border/70 bg-background/60 p-4">
<input
type="checkbox"
checked={form.comment_turnstile_enabled}
onChange={(event) =>
updateField('comment_turnstile_enabled', event.target.checked)
}
className="mt-1 h-4 w-4 rounded border-input text-primary focus:ring-ring"
/>
<div>
<div className="font-medium"> Turnstile</div>
<p className="mt-1 text-sm leading-6 text-muted-foreground">
使 Cloudflare Turnstile key / secret退
</p>
</div>
</label>
</CardContent>
</Card>

View File

@@ -27,6 +27,7 @@ const CHANNEL_OPTIONS = [
{ value: 'discord', label: 'Discord Webhook' },
{ value: 'telegram', label: 'Telegram Bot API' },
{ value: 'ntfy', label: 'ntfy' },
{ value: 'web_push', label: 'Web Push / Browser Push' },
] as const
const DEFAULT_FILTERS = {
@@ -174,7 +175,7 @@ export function SubscriptionsPage() {
<div>
<h2 className="text-3xl font-semibold tracking-tight"> / / </h2>
<p className="mt-2 max-w-3xl text-sm leading-7 text-muted-foreground">
Webhook / Discord / Telegram / ntfy retry pending
Webhook / Discord / Telegram / ntfy / Web Push retry pending
</p>
</div>
</div>
@@ -251,7 +252,15 @@ export function SubscriptionsPage() {
<Input
value={form.target}
onChange={(event) => setForm((current) => ({ ...current, target: event.target.value }))}
placeholder={form.channelType === 'email' ? 'name@example.com' : 'https://...'}
placeholder={
form.channelType === 'email'
? 'name@example.com'
: form.channelType === 'ntfy'
? 'topic-name 或 https://ntfy.example.com/topic'
: form.channelType === 'web_push'
? 'https://push-service/...'
: 'https://...'
}
/>
</div>
<div className="space-y-2">

View File

@@ -0,0 +1,402 @@
import { Plus, RefreshCcw, Save, Tags, Trash2 } from 'lucide-react'
import { startTransition, useCallback, useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner'
import { FormField } from '@/components/form-field'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Skeleton } from '@/components/ui/skeleton'
import { Textarea } from '@/components/ui/textarea'
import { adminApi, ApiError } from '@/lib/api'
import { emptyToNull, formatDateTime } from '@/lib/admin-format'
import type { TagRecord, TaxonomyPayload } from '@/lib/types'
type TagFormState = {
name: string
slug: string
description: string
coverImage: string
accentColor: string
seoTitle: string
seoDescription: string
}
const defaultTagForm: TagFormState = {
name: '',
slug: '',
description: '',
coverImage: '',
accentColor: '',
seoTitle: '',
seoDescription: '',
}
function toFormState(item: TagRecord): TagFormState {
return {
name: item.name,
slug: item.slug,
description: item.description ?? '',
coverImage: item.cover_image ?? '',
accentColor: item.accent_color ?? '',
seoTitle: item.seo_title ?? '',
seoDescription: item.seo_description ?? '',
}
}
function toPayload(form: TagFormState): TaxonomyPayload {
return {
name: form.name.trim(),
slug: emptyToNull(form.slug),
description: emptyToNull(form.description),
coverImage: emptyToNull(form.coverImage),
accentColor: emptyToNull(form.accentColor),
seoTitle: emptyToNull(form.seoTitle),
seoDescription: emptyToNull(form.seoDescription),
}
}
export function TagsPage() {
const [items, setItems] = useState<TagRecord[]>([])
const [selectedId, setSelectedId] = useState<number | null>(null)
const [form, setForm] = useState<TagFormState>(defaultTagForm)
const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false)
const [saving, setSaving] = useState(false)
const [deleting, setDeleting] = useState(false)
const [searchTerm, setSearchTerm] = useState('')
const loadTags = useCallback(async (showToast = false) => {
try {
if (showToast) {
setRefreshing(true)
}
const next = await adminApi.listTags()
startTransition(() => {
setItems(next)
})
if (showToast) {
toast.success('标签列表已刷新。')
}
} catch (error) {
if (error instanceof ApiError && error.status === 401) {
return
}
toast.error(error instanceof ApiError ? error.message : '无法加载标签列表。')
} finally {
setLoading(false)
setRefreshing(false)
}
}, [])
useEffect(() => {
void loadTags(false)
}, [loadTags])
const filteredItems = useMemo(() => {
const keyword = searchTerm.trim().toLowerCase()
if (!keyword) {
return items
}
return items.filter((item) =>
[item.name, item.slug, item.description ?? '', item.seo_title ?? '']
.join('\n')
.toLowerCase()
.includes(keyword),
)
}, [items, searchTerm])
const selectedItem = useMemo(
() => items.find((item) => item.id === selectedId) ?? null,
[items, selectedId],
)
const resetForm = useCallback(() => {
setSelectedId(null)
setForm(defaultTagForm)
}, [])
const handleSave = useCallback(async () => {
if (!form.name.trim()) {
toast.error('请先填写标签名称。')
return
}
try {
setSaving(true)
if (selectedId) {
const updated = await adminApi.updateTag(selectedId, toPayload(form))
startTransition(() => {
setItems((current) => current.map((item) => (item.id === updated.id ? updated : item)))
setSelectedId(updated.id)
setForm(toFormState(updated))
})
toast.success('标签已更新。')
} else {
const created = await adminApi.createTag(toPayload(form))
startTransition(() => {
setItems((current) => [created, ...current])
setSelectedId(created.id)
setForm(toFormState(created))
})
toast.success('标签已创建。')
}
} catch (error) {
toast.error(error instanceof ApiError ? error.message : '保存标签失败。')
} finally {
setSaving(false)
}
}, [form, selectedId])
const handleDelete = useCallback(async () => {
if (!selectedItem) {
return
}
if (!window.confirm(`确认删除标签「${selectedItem.name}」吗?相关文章会同步移除该标签引用。`)) {
return
}
try {
setDeleting(true)
await adminApi.deleteTag(selectedItem.id)
startTransition(() => {
setItems((current) => current.filter((item) => item.id !== selectedItem.id))
})
toast.success('标签已删除。')
resetForm()
} catch (error) {
toast.error(error instanceof ApiError ? error.message : '删除标签失败。')
} finally {
setDeleting(false)
}
}, [resetForm, selectedItem])
if (loading) {
return (
<div className="space-y-6">
<Skeleton className="h-40 rounded-3xl" />
<Skeleton className="h-[720px] rounded-3xl" />
</div>
)
}
return (
<div className="space-y-6">
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
<div className="space-y-3">
<Badge variant="secondary"></Badge>
<div>
<h2 className="text-3xl font-semibold tracking-tight"></h2>
<p className="mt-2 max-w-3xl text-sm leading-7 text-muted-foreground">
SEO 便
</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-3">
<Button variant="outline" onClick={resetForm}>
<Plus className="h-4 w-4" />
</Button>
<Button variant="secondary" onClick={() => void loadTags(true)} disabled={refreshing}>
<RefreshCcw className="h-4 w-4" />
{refreshing ? '刷新中...' : '刷新'}
</Button>
</div>
</div>
<div className="grid gap-6 xl:grid-cols-[0.92fr_1.08fr]">
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>slug SEO </CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Input
placeholder="按标签名 / slug / 描述搜索"
value={searchTerm}
onChange={(event) => setSearchTerm(event.target.value)}
/>
{filteredItems.length ? (
<div className="space-y-3">
{filteredItems.map((item) => (
<button
key={item.id}
type="button"
onClick={() => {
setSelectedId(item.id)
setForm(toFormState(item))
}}
className={`w-full rounded-3xl border px-4 py-4 text-left transition ${
selectedId === item.id
? 'border-primary/30 bg-primary/10 shadow-[0_12px_30px_rgba(37,99,235,0.12)]'
: 'border-border/70 bg-background/60 hover:border-border'
}`}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 space-y-2">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{item.name}</span>
<Badge variant="outline">#{item.slug}</Badge>
{item.accent_color ? (
<span
className="inline-flex h-5 w-5 rounded-full border border-border/80"
style={{ backgroundColor: item.accent_color }}
/>
) : null}
</div>
<p className="text-sm text-muted-foreground">
{item.description || `${item.count} 篇文章引用了这个标签`}
</p>
</div>
<Badge variant={item.count > 0 ? 'success' : 'secondary'}>{item.count}</Badge>
</div>
</button>
))}
</div>
) : (
<div className="rounded-3xl border border-dashed border-border/70 bg-background/40 px-5 py-10 text-center text-sm text-muted-foreground">
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<div className="flex items-center gap-3">
<div className="flex h-11 w-11 items-center justify-center rounded-2xl border border-primary/20 bg-primary/10 text-primary">
<Tags className="h-5 w-5" />
</div>
<div>
<CardTitle>{selectedItem ? '编辑标签' : '新建标签'}</CardTitle>
<CardDescription>
SEO
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid gap-4 lg:grid-cols-2">
<FormField label="标签名称" hint="例如astro、rust、workflow。">
<Input
value={form.name}
onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))}
placeholder="输入标签名称"
/>
</FormField>
<FormField label="标签 slug" hint="留空时自动从英文名称生成;中文建议手填。">
<Input
value={form.slug}
onChange={(event) => setForm((current) => ({ ...current, slug: event.target.value }))}
placeholder="astro"
/>
</FormField>
<FormField label="封面图 URL" hint="可选,用于前台标签头图。">
<Input
value={form.coverImage}
onChange={(event) =>
setForm((current) => ({ ...current, coverImage: event.target.value }))
}
placeholder="https://cdn.example.com/covers/astro.jpg"
/>
</FormField>
<FormField label="强调色" hint="可选,用于标签专题头部强调色。">
<div className="flex items-center gap-3">
<Input
value={form.accentColor}
onChange={(event) =>
setForm((current) => ({ ...current, accentColor: event.target.value }))
}
placeholder="#14b8a6"
/>
<input
type="color"
value={form.accentColor || '#14b8a6'}
onChange={(event) =>
setForm((current) => ({ ...current, accentColor: event.target.value }))
}
className="h-10 w-14 rounded-xl border border-input bg-background px-1"
/>
</div>
</FormField>
</div>
<FormField label="标签描述" hint="用于前台标签卡片与专题说明。">
<Textarea
value={form.description}
onChange={(event) =>
setForm((current) => ({ ...current, description: event.target.value }))
}
rows={4}
placeholder="介绍这个标签常见主题、适合谁看。"
/>
</FormField>
<div className="grid gap-4 lg:grid-cols-2">
<FormField label="SEO 标题" hint="留空时前台继续使用标签名拼接默认标题。">
<Input
value={form.seoTitle}
onChange={(event) =>
setForm((current) => ({ ...current, seoTitle: event.target.value }))
}
placeholder="Astro 相关文章 - Termi"
/>
</FormField>
<FormField label="SEO 描述" hint="搜索引擎摘要与分享描述。">
<Textarea
value={form.seoDescription}
onChange={(event) =>
setForm((current) => ({ ...current, seoDescription: event.target.value }))
}
rows={4}
placeholder="围绕 Astro、内容站与渲染策略的文章汇总。"
/>
</FormField>
</div>
<div className="grid gap-4 rounded-3xl border border-border/70 bg-background/50 p-4 md:grid-cols-3">
<div>
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground"></p>
<p className="mt-2 text-2xl font-semibold text-foreground">{selectedItem?.count ?? 0}</p>
</div>
<div>
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground"></p>
<p className="mt-2 text-sm text-muted-foreground">{formatDateTime(selectedItem?.created_at)}</p>
</div>
<div>
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground"></p>
<p className="mt-2 text-sm text-muted-foreground">{formatDateTime(selectedItem?.updated_at)}</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-3">
<Button onClick={() => void handleSave()} disabled={saving}>
<Save className="h-4 w-4" />
{saving ? '保存中...' : selectedItem ? '保存标签' : '创建标签'}
</Button>
<Button variant="outline" onClick={resetForm}>
</Button>
<Button
variant="ghost"
onClick={() => void handleDelete()}
disabled={!selectedItem || deleting}
className="text-rose-600 hover:text-rose-600"
>
<Trash2 className="h-4 w-4" />
{deleting ? '删除中...' : '删除标签'}
</Button>
</div>
</CardContent>
</Card>
</div>
</div>
)
}