From 497a9d713dbbc31fafa3926856b2155382451413 Mon Sep 17 00:00:00 2001 From: limitcool Date: Wed, 1 Apr 2026 13:22:19 +0800 Subject: [PATCH] feat: ship public ops features and cache docker builds --- .gitea/workflows/backend-docker.yml | 35 +- .gitignore | 8 + admin/src/App.tsx | 36 + admin/src/components/app-shell.tsx | 21 + admin/src/lib/api.ts | 110 +++ admin/src/lib/types.ts | 133 ++++ admin/src/pages/backups-page.tsx | 248 +++++++ admin/src/pages/categories-page.tsx | 402 +++++++++++ admin/src/pages/media-page.tsx | 218 +++++- admin/src/pages/posts-page.tsx | 106 +-- admin/src/pages/site-settings-page.tsx | 175 ++++- admin/src/pages/subscriptions-page.tsx | 13 +- admin/src/pages/tags-page.tsx | 402 +++++++++++ backend/.dockerignore | 2 + backend/.gitignore | 3 +- backend/Cargo.lock | 589 +++++++++++++++- backend/Cargo.toml | 1 + backend/Dockerfile | 19 +- backend/assets/static/404.html | 40 ++ backend/migration/src/lib.rs | 8 + ..._security_and_web_push_to_site_settings.rs | 59 ++ ...ification_channel_type_to_site_settings.rs | 51 ++ ..._runtime_security_keys_to_site_settings.rs | 71 ++ ..._add_taxonomy_metadata_and_media_assets.rs | 161 +++++ backend/src/app.rs | 1 + backend/src/controllers/admin_api.rs | 143 +++- backend/src/controllers/admin_ops.rs | 33 +- backend/src/controllers/admin_taxonomy.rs | 465 +++++++++++++ backend/src/controllers/category.rs | 123 +++- backend/src/controllers/comment.rs | 3 + backend/src/controllers/mod.rs | 1 + backend/src/controllers/post.rs | 132 ++++ backend/src/controllers/search.rs | 221 +++++- backend/src/controllers/site_settings.rs | 90 +++ backend/src/controllers/subscription.rs | 109 ++- backend/src/controllers/tag.rs | 243 ++++++- backend/src/models/_entities/categories.rs | 7 + backend/src/models/_entities/media_assets.rs | 25 + backend/src/models/_entities/mod.rs | 1 + backend/src/models/_entities/prelude.rs | 1 + backend/src/models/_entities/site_settings.rs | 14 + backend/src/models/_entities/tags.rs | 7 + backend/src/models/media_assets.rs | 23 + backend/src/models/mod.rs | 1 + backend/src/services/backups.rs | 640 ++++++++++++++++++ backend/src/services/comment_guard.rs | 12 +- backend/src/services/media_assets.rs | 125 ++++ backend/src/services/mod.rs | 4 + backend/src/services/notifications.rs | 25 +- backend/src/services/subscriptions.rs | 199 +++++- backend/src/services/turnstile.rs | 182 +++++ backend/src/services/web_push.rs | 122 ++++ backend/target-codex-ai-fix/.rustc_info.json | 1 - backend/target-codex-ai-fix/CACHEDIR.TAG | 3 - deploy/docker/README.md | 10 + deploy/docker/compose.package.yml | 11 + deploy/docker/config.yaml.example | 5 + frontend/package.json | 1 + frontend/pnpm-lock.yaml | 72 +- frontend/public/termi-web-push-sw.js | 51 ++ frontend/src/components/Comments.astro | 142 +++- .../src/components/ParagraphComments.astro | 135 +++- .../src/components/SubscriptionPopup.astro | 261 ++++++- frontend/src/env.d.ts | 3 + frontend/src/lib/api/client.ts | 201 +++++- frontend/src/lib/i18n/messages.ts | 16 + frontend/src/lib/types/index.ts | 15 + frontend/src/lib/utils/turnstile.ts | 110 +++ frontend/src/lib/utils/web-push.ts | 112 +++ frontend/src/pages/_img.ts | 2 +- frontend/src/pages/articles/[slug].astro | 6 +- frontend/src/pages/articles/index.astro | 437 +++--------- frontend/src/pages/categories/index.astro | 42 +- frontend/src/pages/search/index.astro | 118 +++- frontend/src/pages/tags/index.astro | 36 +- 75 files changed, 6985 insertions(+), 668 deletions(-) create mode 100644 admin/src/pages/backups-page.tsx create mode 100644 admin/src/pages/categories-page.tsx create mode 100644 admin/src/pages/tags-page.tsx create mode 100644 backend/assets/static/404.html create mode 100644 backend/migration/src/m20260401_000030_add_public_security_and_web_push_to_site_settings.rs create mode 100644 backend/migration/src/m20260401_000031_add_notification_channel_type_to_site_settings.rs create mode 100644 backend/migration/src/m20260401_000032_add_runtime_security_keys_to_site_settings.rs create mode 100644 backend/migration/src/m20260401_000033_add_taxonomy_metadata_and_media_assets.rs create mode 100644 backend/src/controllers/admin_taxonomy.rs create mode 100644 backend/src/models/_entities/media_assets.rs create mode 100644 backend/src/models/media_assets.rs create mode 100644 backend/src/services/backups.rs create mode 100644 backend/src/services/media_assets.rs create mode 100644 backend/src/services/turnstile.rs create mode 100644 backend/src/services/web_push.rs delete mode 100644 backend/target-codex-ai-fix/.rustc_info.json delete mode 100644 backend/target-codex-ai-fix/CACHEDIR.TAG create mode 100644 frontend/public/termi-web-push-sw.js create mode 100644 frontend/src/lib/utils/turnstile.ts create mode 100644 frontend/src/lib/utils/web-push.ts diff --git a/.gitea/workflows/backend-docker.yml b/.gitea/workflows/backend-docker.yml index b4389bd..26cc541 100644 --- a/.gitea/workflows/backend-docker.yml +++ b/.gitea/workflows/backend-docker.yml @@ -163,11 +163,20 @@ jobs: exit 1 fi - - name: Cleanup docker cache + - name: Setup docker buildx shell: bash - run: docker system prune -af --volumes || true + run: | + set -euo pipefail - - name: Build image + if docker buildx inspect gitea-builder >/dev/null 2>&1; then + docker buildx use gitea-builder + else + docker buildx create --name gitea-builder --driver docker-container --use + fi + + docker buildx inspect --bootstrap + + - name: Build and push image shell: bash env: COMPONENT: ${{ matrix.component }} @@ -195,27 +204,19 @@ jobs: BUILD_ARGS+=(--build-arg "VITE_ADMIN_BASENAME=${ADMIN_VITE_BASENAME}") fi - docker build \ + docker buildx build \ --file "${DOCKERFILE}" \ "${BUILD_ARGS[@]}" \ + --build-arg BUILDKIT_INLINE_CACHE=1 \ + --cache-from "type=registry,ref=${IMAGE_BASE}:${TAG_BRANCH}" \ + --cache-from "type=registry,ref=${IMAGE_BASE}:${TAG_LATEST}" \ + --cache-to "type=inline" \ --tag "${IMAGE_BASE}:${TAG_LATEST}" \ --tag "${IMAGE_BASE}:${TAG_BRANCH}" \ --tag "${IMAGE_BASE}:${TAG_SHA}" \ + --push \ "${CONTEXT_DIR}" - - name: Push image - shell: bash - env: - IMAGE_BASE: ${{ steps.meta.outputs.image_base }} - TAG_LATEST: ${{ steps.meta.outputs.tag_latest }} - TAG_BRANCH: ${{ steps.meta.outputs.tag_branch }} - TAG_SHA: ${{ steps.meta.outputs.tag_sha }} - run: | - set -euo pipefail - docker push "${IMAGE_BASE}:${TAG_LATEST}" - docker push "${IMAGE_BASE}:${TAG_BRANCH}" - docker push "${IMAGE_BASE}:${TAG_SHA}" - - name: Output image tags shell: bash env: diff --git a/.gitignore b/.gitignore index 0d4e44b..0ef4fe3 100644 --- a/.gitignore +++ b/.gitignore @@ -24,5 +24,13 @@ backend-start.log deploy/docker/.env deploy/docker/config.yaml admin/tmp-playwright.* +admin/.vite/ +test-results/ +playwright-report/ +blob-report/ +*-playwright.err.log +*-playwright.out.log +backend-restart.err.log +backend-restart.out.log lighthouse-*/ lighthouse-*.json diff --git a/admin/src/App.tsx b/admin/src/App.tsx index dee2d42..bb51203 100644 --- a/admin/src/App.tsx +++ b/admin/src/App.tsx @@ -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() { } /> + + + + } + /> + + + + } + /> + + + + } + /> request('/api/admin/dashboard'), analytics: () => request('/api/admin/analytics'), + listCategories: () => request('/api/admin/categories'), + createCategory: (payload: TaxonomyPayload) => + request('/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(`/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(`/api/admin/categories/${id}`, { + method: 'DELETE', + }), + listTags: () => request('/api/admin/tags'), + createTag: (payload: TaxonomyPayload) => + request('/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(`/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(`/api/admin/tags/${id}`, { + method: 'DELETE', + }), getSiteSettings: () => request('/api/admin/site-settings'), updateSiteSettings: (payload: SiteSettingsPayload) => request('/api/admin/site-settings', { @@ -334,6 +405,24 @@ export const adminApi = { body: formData, }) }, + updateMediaObjectMetadata: (payload: MediaAssetMetadataPayload) => + request('/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('/api/admin/site-backup/export'), + importSiteBackup: (payload: SiteBackupImportPayload) => + request('/api/admin/site-backup/import', { + method: 'POST', + body: JSON.stringify(payload), + }), generatePostMetadata: (markdown: string) => request('/api/admin/ai/post-metadata', { method: 'POST', @@ -387,6 +476,27 @@ export const adminApi = { preview: query?.preview ?? true, }), ), + listPostsPage: (query?: PostListQuery) => + request( + 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(`/api/posts/slug/${encodeURIComponent(slug)}?preview=true&include_private=true`), createPost: (payload: CreatePostPayload) => diff --git a/admin/src/lib/types.ts b/admin/src/lib/types.ts index ac372f6..8843fb4 100644 --- a/admin/src/lib/types.ts +++ b/admin/src/lib/types.ts @@ -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 + categories: Record[] + tags: Record[] + reviews: Record[] + friend_links: Record[] + media_assets: Record[] + storage_manifest?: Record[] | 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 { diff --git a/admin/src/pages/backups-page.tsx b/admin/src/pages/backups-page.tsx new file mode 100644 index 0000000..56168c1 --- /dev/null +++ b/admin/src/pages/backups-page.tsx @@ -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(null) + const [selectedBackup, setSelectedBackup] = useState(null) + const [lastImportResult, setLastImportResult] = useState(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 ( +
+
+
+ 备份 / 恢复 +
+

全站内容备份

+

+ 导出站点内容、配置、分类标签元数据和媒体元数据。当前不包含对象存储二进制文件,只会附带对象清单。 +

+
+
+
+ +
+ + + 导出备份 + + 一键导出当前内容与配置,建议定期下载到本地或同步到私有仓库 / 对象存储归档目录。 + + + +
+

导出内容包含:

+
    +
  • 站点设置与运行时开关
  • +
  • Markdown 文章源文件
  • +
  • 分类 / 标签扩展元数据
  • +
  • 评测、友链、媒体元数据
  • +
  • 对象存储文件清单(不含二进制)
  • +
+
+ + +
+
+ + + + 导入恢复 + + 支持 merge / replace 两种模式;replace 会覆盖当前 markdown 内容与对应元数据,请先确认当前环境是否允许回滚。 + + + +
+ + { + 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。') + } + }} + /> +
+ +
+

导入风险提示

+
    +
  • replace 会覆盖当前 markdown 源文件,并重建分类 / 标签 / 媒体元数据。
  • +
  • 备份不会恢复对象存储二进制文件,请确保原桶仍可访问,或另行回传图片。
  • +
  • 建议先导出当前环境,再执行恢复操作。
  • +
+
+ + {selectedBackup ? ( +
+
+ 版本 {selectedBackup.version} + 导出时间 {selectedBackup.exported_at} + {selectedBackup.includes_storage_binaries ? '包含二进制' : '仅对象清单'} +
+
+
文章:{backupStats?.posts ?? 0}
+
分类:{backupStats?.categories ?? 0}
+
标签:{backupStats?.tags ?? 0}
+
评测:{backupStats?.reviews ?? 0}
+
友链:{backupStats?.friendLinks ?? 0}
+
媒体元数据:{backupStats?.mediaAssets ?? 0}
+
对象清单:{backupStats?.storageManifest ?? 0}
+
+

{selectedBackup.warning}

+
+ ) : ( +
+ {selectedFile ? '当前文件未通过 JSON 校验。' : '选择一个备份 JSON 后,这里会显示导入概览。'} +
+ )} + +
+ + +
+
+
+
+ + {lastImportResult ? ( + + + 最近一次恢复结果 + 模式:{lastImportResult.mode} + + +
站点设置:{lastImportResult.site_settings_restored ? '已恢复' : '未恢复'}
+
文章写入:{lastImportResult.posts_written}
+
分类更新:{lastImportResult.categories_upserted}
+
标签更新:{lastImportResult.tags_upserted}
+
评测更新:{lastImportResult.reviews_upserted}
+
友链更新:{lastImportResult.friend_links_upserted}
+
媒体元数据:{lastImportResult.media_assets_upserted}
+
对象清单:{lastImportResult.storage_manifest_items}
+
{lastImportResult.warning}
+
+
+ ) : null} +
+ ) +} diff --git a/admin/src/pages/categories-page.tsx b/admin/src/pages/categories-page.tsx new file mode 100644 index 0000000..9d7b4cf --- /dev/null +++ b/admin/src/pages/categories-page.tsx @@ -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([]) + const [selectedId, setSelectedId] = useState(null) + const [form, setForm] = useState(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 ( +
+ + +
+ ) + } + + return ( +
+
+
+ 分类管理 +
+

分类目录

+

+ 现在可以给分类补充描述、封面、强调色和 SEO 字段,前台分类页会直接消费这些元数据。 +

+
+
+ +
+ + +
+
+ +
+ + + 分类列表 + 左侧列表支持按分类名、slug、简介和 SEO 标题检索。 + + + setSearchTerm(event.target.value)} + /> + + {filteredItems.length ? ( +
+ {filteredItems.map((item) => ( + + ))} +
+ ) : ( +
+ 暂无匹配分类。 +
+ )} +
+
+ + + +
+
+ +
+
+ {selectedItem ? '编辑分类' : '新建分类'} + + 除了名称 / slug 外,还可以维护前台展示描述和 SEO 元数据。 + +
+
+
+ +
+ + setForm((current) => ({ ...current, name: event.target.value }))} + placeholder="输入分类名称" + /> + + + setForm((current) => ({ ...current, slug: event.target.value }))} + placeholder="frontend-engineering" + /> + + + + setForm((current) => ({ ...current, coverImage: event.target.value })) + } + placeholder="https://cdn.example.com/covers/frontend.jpg" + /> + + +
+ + setForm((current) => ({ ...current, accentColor: event.target.value })) + } + placeholder="#3b82f6" + /> + + setForm((current) => ({ ...current, accentColor: event.target.value })) + } + className="h-10 w-14 rounded-xl border border-input bg-background px-1" + /> +
+
+
+ + +