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
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:
@@ -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={
|
||||
|
||||
@@ -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: '版本',
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
248
admin/src/pages/backups-page.tsx
Normal file
248
admin/src/pages/backups-page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
402
admin/src/pages/categories-page.tsx
Normal file
402
admin/src/pages/categories-page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
402
admin/src/pages/tags-page.tsx
Normal file
402
admin/src/pages/tags-page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user