import { ArrowLeft, Bot, ChevronLeft, ChevronRight, Download, ExternalLink, FilePlus2, FileUp, FolderOpen, GitCompareArrows, PencilLine, RefreshCcw, RotateCcw, Save, Trash2, WandSparkles, X, } from 'lucide-react' import { startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useNavigate, useParams } from 'react-router-dom' import { toast } from 'sonner' import { FormField } from '@/components/form-field' import { LazyDiffEditor } from '@/components/lazy-monaco' import { MediaUrlControls } from '@/components/media-url-controls' import { MarkdownPreview } from '@/components/markdown-preview' import { MarkdownWorkbench, configureMonaco, editorTheme, sharedOptions, type MarkdownWorkbenchMode, type MarkdownWorkbenchPanel, } from '@/components/markdown-workbench' 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 { Skeleton } from '@/components/ui/skeleton' import { Textarea } from '@/components/ui/textarea' import { adminApi, ApiError } from '@/lib/api' import { emptyToNull, formatDateTime, formatPostStatus, formatPostType, formatPostVisibility, postTagsToList, } from '@/lib/admin-format' import { buildMarkdownDocument, parseMarkdownDocument } from '@/lib/markdown-document' import { countLineDiff, normalizeMarkdown } from '@/lib/markdown-diff' import { applySelectedDiffHunks, computeDiffHunks, type DiffHunk } from '@/lib/markdown-merge' import { consumePolishWindowResult, readPolishWindowResult, saveDraftWindowSnapshot, type DraftWindowSnapshot, type PolishWindowResult, } from '@/lib/post-draft-window' import { buildFrontendUrl } from '@/lib/frontend-url' import { cn } from '@/lib/utils' import type { AdminPostMetadataResponse, CreatePostPayload, PostRecord, } from '@/lib/types' type PostFormState = { id: number title: string slug: string description: string category: string postType: string image: string imagesText: string pinned: boolean status: string visibility: string publishAt: string unpublishAt: string canonicalUrl: string noindex: boolean ogImage: string redirectFromText: string redirectTo: string tags: string markdown: string savedMarkdown: string path: string createdAt: string updatedAt: string savedMeta: { title: string description: string category: string postType: string image: string imagesText: string pinned: boolean status: string visibility: string publishAt: string unpublishAt: string canonicalUrl: string noindex: boolean ogImage: string redirectFromText: string redirectTo: string tags: string } } type CreatePostFormState = { title: string slug: string description: string category: string postType: string image: string imagesText: string pinned: boolean status: string visibility: string publishAt: string unpublishAt: string canonicalUrl: string noindex: boolean ogImage: string redirectFromText: string redirectTo: string tags: string markdown: string } type PolishSessionState = { sourceMarkdown: string polishedMarkdown: string selectedIds: Set } type MetadataProposalField = 'title' | 'slug' | 'description' | 'category' | 'tags' type MetadataProposalSelection = Record type MetadataSnapshot = { title: string slug: string description: string category: string tags: string[] } type MetadataProposalState = { current: MetadataSnapshot suggested: MetadataSnapshot selected: MetadataProposalSelection } type MetadataDialogTarget = 'editor' | 'create' type MetadataDialogState = { target: MetadataDialogTarget title: string path: string proposal: MetadataProposalState } type MetadataDraftSource = Pick< CreatePostFormState, 'title' | 'slug' | 'description' | 'category' | 'tags' > const editorMetadataProposalFields: MetadataProposalField[] = [ 'title', 'description', 'category', 'tags', ] const createMetadataProposalFields: MetadataProposalField[] = [ 'title', 'slug', 'description', 'category', 'tags', ] const defaultCreateForm: CreatePostFormState = { title: '', slug: '', description: '', category: '', postType: 'article', image: '', imagesText: '', pinned: false, status: 'draft', visibility: 'public', publishAt: '', unpublishAt: '', canonicalUrl: '', noindex: false, ogImage: '', redirectFromText: '', redirectTo: '', tags: '', markdown: '# 未命名文章\n', } const defaultWorkbenchPanels: MarkdownWorkbenchPanel[] = ['edit'] const orderedWorkbenchPanels: MarkdownWorkbenchPanel[] = ['edit', 'preview', 'diff'] const POSTS_PAGE_SIZE_OPTIONS = [12, 24, 48] as const const ADMIN_BASENAME = ((import.meta.env.VITE_ADMIN_BASENAME as string | undefined)?.trim() || '').replace(/\/$/, '') const POLISH_RESULT_STORAGE_PREFIX = 'termi-admin-post-polish-result:' function buildAdminRoute(path: string) { const normalizedPath = path.startsWith('/') ? path : `/${path}` return `${ADMIN_BASENAME}${normalizedPath}` || normalizedPath } function formatWorkbenchPanelLabel(panel: MarkdownWorkbenchPanel) { switch (panel) { case 'preview': return '预览' case 'diff': return '对比' case 'edit': default: return '编辑' } } function normalizeWorkbenchPanels(panels: MarkdownWorkbenchPanel[]) { const nextPanels = orderedWorkbenchPanels.filter((panel) => panels.includes(panel)) return nextPanels.length ? nextPanels : [...defaultWorkbenchPanels] } function formatWorkbenchStateLabel( mode: MarkdownWorkbenchMode, panels: MarkdownWorkbenchPanel[], ) { if (mode === 'polish') { return 'AI 润色' } return `已打开:${normalizeWorkbenchPanels(panels) .map((panel) => formatWorkbenchPanelLabel(panel)) .join(' / ')}` } function buildVirtualPostPath(slug: string) { const normalizedSlug = slug.trim() || 'new-post' return `article://posts/${normalizedSlug}` } function buildInlineImagePrefix(value: string) { const normalized = value .trim() .toLowerCase() .replace(/[^a-z0-9-]+/g, '-') .replace(/^-+|-+$/g, '') .slice(0, 64) return `post-inline-images/${normalized || 'draft'}` } function parseImageList(value: string) { return value .split('\n') .map((item) => item.trim()) .filter(Boolean) } function parseTagList(value: string) { return value .split(',') .map((item) => item.trim()) .filter(Boolean) } function resolveCoverPreviewUrl(value: string) { const trimmed = value.trim() if (!trimmed) { return '' } if (/^(?:https?:\/\/|data:|blob:)/i.test(trimmed)) { return trimmed } if (typeof window === 'undefined') { return trimmed } if (trimmed.startsWith('/')) { return buildFrontendUrl(trimmed) } return trimmed } function normalizeMetadataText(value: string) { return value.trim() } function normalizeMetadataTags(tags: string[]) { return tags .map((item) => item.trim()) .filter(Boolean) } function createEmptyMetadataSelection(): MetadataProposalSelection { return { title: false, slug: false, description: false, category: false, tags: false, } } function metadataTagsEqual(left: string[], right: string[]) { const normalizedLeft = normalizeMetadataTags(left) const normalizedRight = normalizeMetadataTags(right) return ( normalizedLeft.length === normalizedRight.length && normalizedLeft.every((item, index) => item === normalizedRight[index]) ) } function metadataProposalFieldsForTarget(target: MetadataDialogTarget) { return target === 'editor' ? editorMetadataProposalFields : createMetadataProposalFields } function buildMetadataSnapshot(form: MetadataDraftSource): MetadataSnapshot { return { title: form.title, slug: form.slug, description: form.description, category: form.category, tags: parseTagList(form.tags), } } function buildMetadataSelection( current: MetadataSnapshot, suggested: MetadataSnapshot, fields: MetadataProposalField[], ): MetadataProposalSelection { const selection = createEmptyMetadataSelection() fields.forEach((field) => { switch (field) { case 'title': selection.title = normalizeMetadataText(current.title) !== normalizeMetadataText(suggested.title) break case 'slug': selection.slug = normalizeMetadataText(current.slug) !== normalizeMetadataText(suggested.slug) break case 'description': selection.description = normalizeMetadataText(current.description) !== normalizeMetadataText(suggested.description) break case 'category': selection.category = normalizeMetadataText(current.category) !== normalizeMetadataText(suggested.category) break case 'tags': selection.tags = !metadataTagsEqual(current.tags, suggested.tags) break default: break } }) return selection } function buildMetadataProposal( form: MetadataDraftSource, generated: AdminPostMetadataResponse, target: MetadataDialogTarget, ): MetadataProposalState { const fields = metadataProposalFieldsForTarget(target) const current = buildMetadataSnapshot(form) const suggested: MetadataSnapshot = { title: generated.title.trim(), slug: generated.slug.trim(), description: generated.description.trim(), category: generated.category.trim(), tags: normalizeMetadataTags(generated.tags), } return { current, suggested, selected: buildMetadataSelection(current, suggested, fields), } } function metadataFieldLabel(field: MetadataProposalField) { switch (field) { case 'title': return '标题' case 'slug': return 'Slug' case 'description': return '摘要' case 'category': return '分类' case 'tags': return '标签' default: return field } } function metadataFieldChanged( proposal: MetadataProposalState, field: MetadataProposalField, ) { switch (field) { case 'title': return ( normalizeMetadataText(proposal.current.title) !== normalizeMetadataText(proposal.suggested.title) ) case 'slug': return ( normalizeMetadataText(proposal.current.slug) !== normalizeMetadataText(proposal.suggested.slug) ) case 'description': return ( normalizeMetadataText(proposal.current.description) !== normalizeMetadataText(proposal.suggested.description) ) case 'category': return ( normalizeMetadataText(proposal.current.category) !== normalizeMetadataText(proposal.suggested.category) ) case 'tags': return !metadataTagsEqual(proposal.current.tags, proposal.suggested.tags) default: return false } } function countSelectedMetadataFields( selection: MetadataProposalSelection, fields: MetadataProposalField[], ) { return fields.filter((field) => selection[field]).length } function countChangedMetadataFields( proposal: MetadataProposalState, fields: MetadataProposalField[], ) { return fields.filter((field) => metadataFieldChanged(proposal, field)).length } function stripFrontmatter(markdown: string) { const normalized = markdown.replace(/\r\n/g, '\n') if (!normalized.startsWith('---\n')) { return normalized.trimStart() } const endIndex = normalized.indexOf('\n---\n', 4) if (endIndex === -1) { return normalized.trimStart() } return normalized.slice(endIndex + 5).trimStart() } function extractPostStatus(markdown: string) { const normalized = markdown.replace(/\r\n/g, '\n') if (!normalized.startsWith('---\n')) { return 'published' } const endIndex = normalized.indexOf('\n---\n', 4) if (endIndex === -1) { return 'published' } const frontmatter = normalized.slice(4, endIndex) const statusMatch = frontmatter.match(/^status:\s*(.+)\s*$/m) if (statusMatch?.[1]) { return statusMatch[1].replace(/^['"]|['"]$/g, '').trim() || 'published' } const publishedMatch = frontmatter.match(/^published:\s*(true|false)\s*$/m) if (publishedMatch) { return publishedMatch[1] === 'false' ? 'draft' : 'published' } return 'published' } function buildMarkdownForSave(form: PostFormState) { const lines = [ '---', `title: ${JSON.stringify(form.title.trim() || form.slug)}`, `slug: ${form.slug}`, ] if (form.description.trim()) { lines.push(`description: ${JSON.stringify(form.description.trim())}`) } if (form.category.trim()) { lines.push(`category: ${JSON.stringify(form.category.trim())}`) } lines.push(`post_type: ${JSON.stringify(form.postType.trim() || 'article')}`) lines.push(`pinned: ${form.pinned ? 'true' : 'false'}`) lines.push(`status: ${JSON.stringify(form.status.trim() || extractPostStatus(form.markdown))}`) lines.push(`visibility: ${JSON.stringify(form.visibility.trim() || 'public')}`) lines.push(`noindex: ${form.noindex ? 'true' : 'false'}`) if (form.publishAt.trim()) { lines.push(`publish_at: ${JSON.stringify(form.publishAt.trim())}`) } if (form.unpublishAt.trim()) { lines.push(`unpublish_at: ${JSON.stringify(form.unpublishAt.trim())}`) } if (form.image.trim()) { lines.push(`image: ${JSON.stringify(form.image.trim())}`) } const images = parseImageList(form.imagesText) if (images.length) { lines.push('images:') images.forEach((image) => { lines.push(` - ${JSON.stringify(image)}`) }) } const tags = form.tags .split(',') .map((item) => item.trim()) .filter(Boolean) if (tags.length) { lines.push('tags:') tags.forEach((tag) => { lines.push(` - ${JSON.stringify(tag)}`) }) } if (form.canonicalUrl.trim()) { lines.push(`canonical_url: ${JSON.stringify(form.canonicalUrl.trim())}`) } if (form.ogImage.trim()) { lines.push(`og_image: ${JSON.stringify(form.ogImage.trim())}`) } const redirectFrom = parseImageList(form.redirectFromText) if (redirectFrom.length) { lines.push('redirect_from:') redirectFrom.forEach((item) => { lines.push(` - ${JSON.stringify(item)}`) }) } if (form.redirectTo.trim()) { lines.push(`redirect_to: ${JSON.stringify(form.redirectTo.trim())}`) } return `${lines.join('\n')}\n---\n\n${stripFrontmatter(form.markdown).trim()}\n` } function buildEditorState(post: PostRecord, markdown: string, path: string): PostFormState { const tags = postTagsToList(post.tags).join(', ') const imagesText = (post.images ?? []).join('\n') return { id: post.id, title: post.title ?? '', slug: post.slug, description: post.description ?? '', category: post.category ?? '', postType: post.post_type ?? 'article', image: post.image ?? '', imagesText, pinned: Boolean(post.pinned), status: post.status ?? extractPostStatus(markdown), visibility: post.visibility ?? 'public', publishAt: post.publish_at ?? '', unpublishAt: post.unpublish_at ?? '', canonicalUrl: post.canonical_url ?? '', noindex: Boolean(post.noindex), ogImage: post.og_image ?? '', redirectFromText: (post.redirect_from ?? []).join('\n'), redirectTo: post.redirect_to ?? '', tags, markdown, savedMarkdown: markdown, path, createdAt: post.created_at, updatedAt: post.updated_at, savedMeta: { title: post.title ?? '', description: post.description ?? '', category: post.category ?? '', postType: post.post_type ?? 'article', image: post.image ?? '', imagesText, pinned: Boolean(post.pinned), status: post.status ?? extractPostStatus(markdown), visibility: post.visibility ?? 'public', publishAt: post.publish_at ?? '', unpublishAt: post.unpublish_at ?? '', canonicalUrl: post.canonical_url ?? '', noindex: Boolean(post.noindex), ogImage: post.og_image ?? '', redirectFromText: (post.redirect_from ?? []).join('\n'), redirectTo: post.redirect_to ?? '', tags, }, } } function hasMetadataDraftChanges(form: PostFormState) { return ( form.title !== form.savedMeta.title || form.description !== form.savedMeta.description || form.category !== form.savedMeta.category || form.postType !== form.savedMeta.postType || form.image !== form.savedMeta.image || form.imagesText !== form.savedMeta.imagesText || form.pinned !== form.savedMeta.pinned || form.status !== form.savedMeta.status || form.visibility !== form.savedMeta.visibility || form.publishAt !== form.savedMeta.publishAt || form.unpublishAt !== form.savedMeta.unpublishAt || form.canonicalUrl !== form.savedMeta.canonicalUrl || form.noindex !== form.savedMeta.noindex || form.ogImage !== form.savedMeta.ogImage || form.redirectFromText !== form.savedMeta.redirectFromText || form.redirectTo !== form.savedMeta.redirectTo || form.tags !== form.savedMeta.tags ) } function buildDraftMarkdownForWindow(form: PostFormState) { return hasMetadataDraftChanges(form) ? buildMarkdownForSave(form) : form.markdown } function buildCreatePayload(form: CreatePostFormState): CreatePostPayload { return { title: form.title.trim(), slug: emptyToNull(form.slug), description: emptyToNull(form.description), content: form.markdown, category: emptyToNull(form.category), tags: form.tags .split(',') .map((item) => item.trim()) .filter(Boolean), postType: emptyToNull(form.postType) ?? 'article', image: emptyToNull(form.image), images: parseImageList(form.imagesText), pinned: form.pinned, status: emptyToNull(form.status) ?? 'draft', visibility: emptyToNull(form.visibility) ?? 'public', publishAt: emptyToNull(form.publishAt), unpublishAt: emptyToNull(form.unpublishAt), canonicalUrl: emptyToNull(form.canonicalUrl), noindex: form.noindex, ogImage: emptyToNull(form.ogImage), redirectFrom: parseImageList(form.redirectFromText), redirectTo: emptyToNull(form.redirectTo), } } function buildCreateMarkdownForWindow(form: CreatePostFormState) { return buildMarkdownDocument( { title: form.title.trim(), slug: form.slug.trim() || 'new-post', description: form.description.trim(), category: form.category.trim(), postType: form.postType.trim() || 'article', image: form.image.trim(), images: parseImageList(form.imagesText), pinned: form.pinned, status: form.status.trim() || 'draft', visibility: form.visibility.trim() || 'public', publishAt: form.publishAt.trim(), unpublishAt: form.unpublishAt.trim(), canonicalUrl: form.canonicalUrl.trim(), noindex: form.noindex, ogImage: form.ogImage.trim(), redirectFrom: parseImageList(form.redirectFromText), redirectTo: form.redirectTo.trim(), tags: form.tags .split(',') .map((item) => item.trim()) .filter(Boolean), }, stripFrontmatter(form.markdown), ) } function applyPolishedEditorState(form: PostFormState, markdown: string): PostFormState { const parsed = parseMarkdownDocument(markdown) const nextMarkdown = buildMarkdownDocument( { title: parsed.meta.title || form.title, slug: form.slug, description: parsed.meta.description || form.description, category: parsed.meta.category || form.category, postType: parsed.meta.postType || form.postType, image: parsed.meta.image || form.image, images: parsed.meta.images.length ? parsed.meta.images : parseImageList(form.imagesText), pinned: parsed.meta.pinned, status: parsed.meta.status || form.status, visibility: parsed.meta.visibility || form.visibility, publishAt: parsed.meta.publishAt || form.publishAt, unpublishAt: parsed.meta.unpublishAt || form.unpublishAt, canonicalUrl: parsed.meta.canonicalUrl || form.canonicalUrl, noindex: parsed.meta.noindex, ogImage: parsed.meta.ogImage || form.ogImage, redirectFrom: parsed.meta.redirectFrom.length ? parsed.meta.redirectFrom : parseImageList(form.redirectFromText), redirectTo: parsed.meta.redirectTo || form.redirectTo, tags: parsed.meta.tags.length ? parsed.meta.tags : form.tags .split(',') .map((item) => item.trim()) .filter(Boolean), }, parsed.body, ) return { ...form, title: parsed.meta.title || form.title, description: parsed.meta.description || form.description, category: parsed.meta.category || form.category, postType: parsed.meta.postType || form.postType, image: parsed.meta.image || form.image, imagesText: parsed.meta.images.length ? parsed.meta.images.join('\n') : form.imagesText, pinned: parsed.meta.pinned, status: parsed.meta.status || form.status, visibility: parsed.meta.visibility || form.visibility, publishAt: parsed.meta.publishAt || form.publishAt, unpublishAt: parsed.meta.unpublishAt || form.unpublishAt, canonicalUrl: parsed.meta.canonicalUrl || form.canonicalUrl, noindex: parsed.meta.noindex, ogImage: parsed.meta.ogImage || form.ogImage, redirectFromText: parsed.meta.redirectFrom.length ? parsed.meta.redirectFrom.join('\n') : form.redirectFromText, redirectTo: parsed.meta.redirectTo || form.redirectTo, tags: parsed.meta.tags.length ? parsed.meta.tags.join(', ') : form.tags, markdown: nextMarkdown, } } function applyPolishedCreateState(form: CreatePostFormState, markdown: string): CreatePostFormState { const parsed = parseMarkdownDocument(markdown) return { ...form, title: parsed.meta.title || form.title, slug: parsed.meta.slug || form.slug, description: parsed.meta.description || form.description, category: parsed.meta.category || form.category, postType: parsed.meta.postType || form.postType, image: parsed.meta.image || form.image, imagesText: parsed.meta.images.length ? parsed.meta.images.join('\n') : form.imagesText, pinned: parsed.meta.pinned, status: parsed.meta.status || form.status, visibility: parsed.meta.visibility || form.visibility, publishAt: parsed.meta.publishAt || form.publishAt, unpublishAt: parsed.meta.unpublishAt || form.unpublishAt, canonicalUrl: parsed.meta.canonicalUrl || form.canonicalUrl, noindex: parsed.meta.noindex, ogImage: parsed.meta.ogImage || form.ogImage, redirectFromText: parsed.meta.redirectFrom.length ? parsed.meta.redirectFrom.join('\n') : form.redirectFromText, redirectTo: parsed.meta.redirectTo || form.redirectTo, tags: parsed.meta.tags.length ? parsed.meta.tags.join(', ') : form.tags, markdown: parsed.body || stripFrontmatter(markdown), } } function downloadMarkdownFile(filename: string, content: string) { const blob = new Blob([content], { type: 'text/markdown;charset=utf-8' }) const url = URL.createObjectURL(blob) const anchor = document.createElement('a') anchor.href = url anchor.download = filename anchor.click() URL.revokeObjectURL(url) } export function PostsPage() { const navigate = useNavigate() const { slug } = useParams() const importInputRef = useRef(null) const folderImportInputRef = useRef(null) const [posts, setPosts] = useState([]) const [loading, setLoading] = useState(true) const [refreshing, setRefreshing] = useState(false) const [editorLoading, setEditorLoading] = useState(false) const [saving, setSaving] = useState(false) const [creating, setCreating] = useState(false) const [deleting, setDeleting] = useState(false) const [importing, setImporting] = useState(false) const [generatingMetadata, setGeneratingMetadata] = useState(false) const [generatingEditorMetadataProposal, setGeneratingEditorMetadataProposal] = useState(false) const [generatingEditorCover, setGeneratingEditorCover] = useState(false) const [generatingCreateCover, setGeneratingCreateCover] = useState(false) const [localizingEditorImages, setLocalizingEditorImages] = useState(false) const [localizingCreateImages, setLocalizingCreateImages] = useState(false) const [generatingEditorPolish, setGeneratingEditorPolish] = useState(false) const [generatingCreatePolish, setGeneratingCreatePolish] = useState(false) const [editor, setEditor] = useState(null) const [metadataDialog, setMetadataDialog] = useState(null) const [editorMode, setEditorMode] = useState('workspace') const [editorPanels, setEditorPanels] = useState(defaultWorkbenchPanels) const [createDialogOpen, setCreateDialogOpen] = useState(false) const [createMode, setCreateMode] = useState('workspace') const [createPanels, setCreatePanels] = useState(defaultWorkbenchPanels) const [createForm, setCreateForm] = useState(defaultCreateForm) const [editorPolish, setEditorPolish] = useState(null) const [createPolish, setCreatePolish] = useState(null) const [searchTerm, setSearchTerm] = useState('') const [typeFilter, setTypeFilter] = useState('all') const [pinnedFilter, setPinnedFilter] = useState('all') const [currentPage, setCurrentPage] = useState(1) const [pageSize, setPageSize] = useState(POSTS_PAGE_SIZE_OPTIONS[0]) const [sortKey, setSortKey] = useState('updated_at_desc') const [totalPosts, setTotalPosts] = useState(0) const [totalPages, setTotalPages] = useState(1) const editorPolishDraftKeyRef = useRef(null) const createPolishDraftKeyRef = useRef(null) 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 { if (showToast) { setRefreshing(true) } 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.items) setTotalPosts(next.total) setTotalPages(next.total_pages) if (next.page !== currentPage) { setCurrentPage(next.page) } }) 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) } }, [currentPage, pageSize, pinnedFilter, searchTerm, sortBy, sortOrder, typeFilter]) const loadEditor = useCallback( async (nextSlug: string) => { try { setEditorLoading(true) const [post, markdown] = await Promise.all([ adminApi.getPostBySlug(nextSlug), adminApi.getPostMarkdown(nextSlug), ]) startTransition(() => { setEditor(buildEditorState(post, markdown.markdown, markdown.path)) setMetadataDialog(null) setEditorMode('workspace') setEditorPanels(defaultWorkbenchPanels) setEditorPolish(null) }) } catch (error) { toast.error(error instanceof ApiError ? error.message : '无法打开这篇文章。') navigate('/posts', { replace: true }) } finally { setEditorLoading(false) } }, [navigate], ) useEffect(() => { void loadPosts(false) }, [loadPosts]) useEffect(() => { if (folderImportInputRef.current) { folderImportInputRef.current.setAttribute('webkitdirectory', '') folderImportInputRef.current.setAttribute('directory', '') } }, []) useEffect(() => { setEditorMode('workspace') setEditorPanels(defaultWorkbenchPanels) editorPolishDraftKeyRef.current = null if (!slug) { setEditor(null) setMetadataDialog(null) setEditorPolish(null) return } setCreateDialogOpen(false) void loadEditor(slug) }, [loadEditor, slug]) useEffect(() => { if (!createDialogOpen) { createPolishDraftKeyRef.current = null } }, [createDialogOpen]) useEffect(() => { if (!metadataDialog && !slug && !createDialogOpen) { return } const previousOverflow = document.body.style.overflow const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') { if (metadataDialog) { setMetadataDialog(null) return } if (slug) { navigate('/posts', { replace: true }) return } if (createDialogOpen) { setCreateDialogOpen(false) } } } document.body.style.overflow = 'hidden' window.addEventListener('keydown', handleKeyDown) return () => { document.body.style.overflow = previousOverflow window.removeEventListener('keydown', handleKeyDown) } }, [createDialogOpen, metadataDialog, navigate, slug]) useEffect(() => { setCurrentPage(1) }, [pageSize, pinnedFilter, searchTerm, sortKey, typeFilter]) const safeCurrentPage = Math.min(currentPage, totalPages) useEffect(() => { setCurrentPage((current) => Math.min(current, totalPages)) }, [totalPages]) const paginatedPosts = posts const paginationItems = useMemo(() => { const maxVisiblePages = 5 const halfWindow = Math.floor(maxVisiblePages / 2) let startPage = Math.max(1, safeCurrentPage - halfWindow) let endPage = Math.min(totalPages, startPage + maxVisiblePages - 1) if (endPage - startPage + 1 < maxVisiblePages) { startPage = Math.max(1, endPage - maxVisiblePages + 1) } return Array.from({ length: endPage - startPage + 1 }, (_, index) => startPage + index) }, [safeCurrentPage, totalPages]) 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], ) const markdownDirty = useMemo(() => { if (!editor) { return false } return ( normalizeMarkdown(buildDraftMarkdownForWindow(editor)) !== normalizeMarkdown(editor.savedMarkdown) ) }, [editor]) const createMarkdownDirty = useMemo( () => normalizeMarkdown(buildCreateMarkdownForWindow(createForm)) !== normalizeMarkdown(buildCreateMarkdownForWindow(defaultCreateForm)), [createForm], ) const buildEditorDraftSnapshot = useCallback((): Omit | null => { if (!editor) { return null } return { title: editor.title.trim() || editor.slug, slug: editor.slug, path: editor.path, markdown: buildDraftMarkdownForWindow(editor), savedMarkdown: editor.savedMarkdown, } }, [editor]) const buildCreateDraftSnapshot = useCallback((): Omit => { const fallbackSlug = createForm.slug.trim() || 'new-post' return { title: createForm.title.trim() || createForm.slug.trim() || '新建草稿', slug: fallbackSlug, path: buildVirtualPostPath(fallbackSlug), markdown: buildCreateMarkdownForWindow(createForm), savedMarkdown: buildCreateMarkdownForWindow(defaultCreateForm), } }, [createForm]) const openDraftWorkbenchWindow = useCallback( ( path: string, snapshot: Omit, extraQuery?: Record, ) => { const draftKey = saveDraftWindowSnapshot(snapshot) const url = new URL(buildAdminRoute(path), window.location.origin) url.searchParams.set('draftKey', draftKey) Object.entries(extraQuery ?? {}).forEach(([key, value]) => { if (value) { url.searchParams.set(key, value) } }) const popup = window.open( url.toString(), '_blank', 'popup=yes,width=1560,height=980,resizable=yes,scrollbars=yes', ) if (!popup) { toast.error('浏览器拦截了独立工作台窗口,请允许当前站点打开新窗口后重试。') return null } popup.focus() return draftKey }, [], ) const applyExternalPolishResult = useCallback( (result: PolishWindowResult) => { if (result.target === 'editor') { if (!editor) { return false } startTransition(() => { setEditor((current) => current ? applyPolishedEditorState(current, result.markdown) : current, ) setEditorPolish(null) setEditorMode('workspace') }) toast.success('独立 AI 润色结果已回填到当前文章。') return true } if (!createDialogOpen) { return false } startTransition(() => { setCreateForm((current) => applyPolishedCreateState(current, result.markdown)) setCreatePolish(null) setCreateMode('workspace') }) toast.success('独立 AI 润色结果已回填到新建草稿。') return true }, [createDialogOpen, editor], ) const flushPendingPolishResult = useCallback( (draftKey: string | null) => { const pending = readPolishWindowResult(draftKey) if (!pending || !applyExternalPolishResult(pending)) { return false } consumePolishWindowResult(draftKey) return true }, [applyExternalPolishResult], ) useEffect(() => { const tryFlushAll = () => { if (flushPendingPolishResult(editorPolishDraftKeyRef.current)) { editorPolishDraftKeyRef.current = null } if (flushPendingPolishResult(createPolishDraftKeyRef.current)) { createPolishDraftKeyRef.current = null } } const handleMessage = (event: MessageEvent) => { if (event.origin !== window.location.origin || !event.data) { return } const payload = event.data as Partial & { type?: string } if ( payload.type !== 'termi-admin-post-polish-apply' || typeof payload.draftKey !== 'string' || typeof payload.markdown !== 'string' ) { return } const result: PolishWindowResult = { draftKey: payload.draftKey, markdown: payload.markdown, target: payload.target === 'create' ? 'create' : 'editor', createdAt: typeof payload.createdAt === 'number' ? payload.createdAt : Date.now(), } if (!applyExternalPolishResult(result)) { return } consumePolishWindowResult(result.draftKey) if (result.target === 'editor') { editorPolishDraftKeyRef.current = null } else { createPolishDraftKeyRef.current = null } } const handleStorage = (event: StorageEvent) => { if (!event.key?.startsWith(POLISH_RESULT_STORAGE_PREFIX)) { return } tryFlushAll() } window.addEventListener('message', handleMessage) window.addEventListener('storage', handleStorage) window.addEventListener('focus', tryFlushAll) tryFlushAll() return () => { window.removeEventListener('message', handleMessage) window.removeEventListener('storage', handleStorage) window.removeEventListener('focus', tryFlushAll) } }, [applyExternalPolishResult, flushPendingPolishResult]) const compareStats = useMemo(() => { if (!editor) { return { additions: 0, deletions: 0, } } return countLineDiff(editor.savedMarkdown, buildDraftMarkdownForWindow(editor)) }, [editor]) const editorCoverPreviewUrl = useMemo( () => resolveCoverPreviewUrl(editor?.image ?? ''), [editor?.image], ) const createCoverPreviewUrl = useMemo( () => resolveCoverPreviewUrl(createForm.image), [createForm.image], ) const saveEditor = useCallback(async () => { if (!editor) { return } if (!editor.title.trim()) { toast.error('保存前必须填写标题。') return } try { setSaving(true) const persistedMarkdown = buildMarkdownForSave(editor) const updatedPost = await adminApi.updatePost(editor.id, { title: editor.title.trim(), slug: editor.slug, description: emptyToNull(editor.description), category: emptyToNull(editor.category), tags: editor.tags .split(',') .map((item) => item.trim()) .filter(Boolean), postType: emptyToNull(editor.postType) ?? 'article', image: emptyToNull(editor.image), images: parseImageList(editor.imagesText), pinned: editor.pinned, status: emptyToNull(editor.status) ?? 'draft', visibility: emptyToNull(editor.visibility) ?? 'public', publishAt: emptyToNull(editor.publishAt), unpublishAt: emptyToNull(editor.unpublishAt), canonicalUrl: emptyToNull(editor.canonicalUrl), noindex: editor.noindex, ogImage: emptyToNull(editor.ogImage), redirectFrom: parseImageList(editor.redirectFromText), redirectTo: emptyToNull(editor.redirectTo), }) const updatedMarkdown = await adminApi.updatePostMarkdown(editor.slug, persistedMarkdown) startTransition(() => { setEditor(buildEditorState(updatedPost, updatedMarkdown.markdown, updatedMarkdown.path)) setEditorPolish(null) }) await loadPosts(false) toast.success('文章已保存。') } catch (error) { toast.error(error instanceof ApiError ? error.message : '无法保存文章。') } finally { setSaving(false) } }, [editor, loadPosts]) const importMarkdownFiles = useCallback( async (fileList: FileList | null) => { const files = Array.from(fileList ?? []).filter((file) => /\.(md|markdown)$/i.test(file.webkitRelativePath || file.name), ) if (!files.length) { return } try { setImporting(true) const result = await adminApi.importPosts(files) await loadPosts(false) toast.success(`已导入 ${result.count} 篇 Markdown。`) const firstSlug = result.slugs[0] if (firstSlug) { navigate(`/posts/${firstSlug}`) } } catch (error) { toast.error(error instanceof ApiError ? error.message : '导入 Markdown 失败。') } finally { setImporting(false) if (importInputRef.current) { importInputRef.current.value = '' } if (folderImportInputRef.current) { folderImportInputRef.current.value = '' } } }, [loadPosts, navigate], ) const generateCreateMetadata = useCallback(async () => { const sourceMarkdown = buildCreateMarkdownForWindow(createForm) if (!stripFrontmatter(sourceMarkdown).trim()) { toast.error('先写一点正文,再让 AI 帮你补元数据。') return } try { setGeneratingMetadata(true) const generated = await adminApi.generatePostMetadata(sourceMarkdown) const nextProposal = buildMetadataProposal(createForm, generated, 'create') const changedCount = countChangedMetadataFields( nextProposal, createMetadataProposalFields, ) startTransition(() => { setMetadataDialog({ target: 'create', title: createForm.title.trim() || createForm.slug.trim() || '新建草稿', path: buildVirtualPostPath(createForm.slug), proposal: nextProposal, }) }) if (changedCount) { toast.success(`AI 已生成 ${changedCount} 项元数据建议,可以先对比再回填。`) } else { toast.success('AI 已完成分析,这一版元数据和当前内容基本一致。') } } catch (error) { toast.error(error instanceof ApiError ? error.message : 'AI 元数据生成失败。') } finally { setGeneratingMetadata(false) } }, [createForm]) const generateEditorMetadataProposal = useCallback(async () => { if (!editor) { return } const sourceMarkdown = buildDraftMarkdownForWindow(editor) if (!stripFrontmatter(sourceMarkdown).trim()) { toast.error('先准备一点正文,再让 AI 给这篇旧文章补元数据。') return } try { setGeneratingEditorMetadataProposal(true) const generated = await adminApi.generatePostMetadata(sourceMarkdown) const nextProposal = buildMetadataProposal(editor, generated, 'editor') const changedCount = countChangedMetadataFields( nextProposal, editorMetadataProposalFields, ) startTransition(() => { setMetadataDialog({ target: 'editor', title: editor.title.trim() || editor.slug, path: editor.path, proposal: nextProposal, }) }) if (changedCount) { toast.success(`AI 已生成 ${changedCount} 项元数据建议,可以逐项合并。`) } else { toast.success('AI 已完成分析,这一版元数据和当前内容基本一致。') } } catch (error) { toast.error(error instanceof ApiError ? error.message : 'AI 元数据提案生成失败。') } finally { setGeneratingEditorMetadataProposal(false) } }, [editor]) const generateEditorCover = useCallback(async () => { if (!editor) { return } try { setGeneratingEditorCover(true) const result = await adminApi.generatePostCoverImage({ title: editor.title, description: emptyToNull(editor.description), category: emptyToNull(editor.category), tags: parseTagList(editor.tags), postType: emptyToNull(editor.postType) ?? 'article', slug: editor.slug, markdown: buildDraftMarkdownForWindow(editor), }) startTransition(() => { setEditor((current) => current ? { ...current, image: result.image_url } : current, ) }) toast.success('AI 封面图已生成,并回填到封面图 URL。') } catch (error) { toast.error(error instanceof ApiError ? error.message : 'AI 封面图生成失败。') } finally { setGeneratingEditorCover(false) } }, [editor]) const generateCreateCover = useCallback(async () => { try { setGeneratingCreateCover(true) const result = await adminApi.generatePostCoverImage({ title: createForm.title, description: emptyToNull(createForm.description), category: emptyToNull(createForm.category), tags: parseTagList(createForm.tags), postType: emptyToNull(createForm.postType) ?? 'article', slug: emptyToNull(createForm.slug), markdown: buildCreateMarkdownForWindow(createForm), }) startTransition(() => { setCreateForm((current) => ({ ...current, image: result.image_url })) }) toast.success('AI 封面图已生成,并回填到封面图 URL。') } catch (error) { toast.error(error instanceof ApiError ? error.message : 'AI 封面图生成失败。') } finally { setGeneratingCreateCover(false) } }, [createForm]) const localizeEditorMarkdownImages = useCallback(async () => { if (!editor) { return } const sourceMarkdown = buildDraftMarkdownForWindow(editor) if (!stripFrontmatter(sourceMarkdown).trim()) { toast.error('先准备一点正文,再执行正文图片本地化。') return } try { setLocalizingEditorImages(true) const result = await adminApi.localizePostMarkdownImages({ markdown: sourceMarkdown, prefix: buildInlineImagePrefix(editor.slug), }) if (!result.localized_count && !result.failed_count) { toast.message('正文里没有检测到需要本地化的远程图片。') return } startTransition(() => { setEditor((current) => current ? applyPolishedEditorState(current, result.markdown) : current, ) }) if (result.localized_count && result.failed_count) { toast.warning( `已替换 ${result.localized_count} 处正文图片,另有 ${result.failed_count} 个链接处理失败。`, ) } else if (result.localized_count) { toast.success(`已把 ${result.localized_count} 处正文图片替换为媒体库地址。`) } else { toast.warning(`没有成功替换正文图片,失败 ${result.failed_count} 个链接。`) } } catch (error) { toast.error(error instanceof ApiError ? error.message : '正文图片本地化失败。') } finally { setLocalizingEditorImages(false) } }, [editor]) const localizeCreateMarkdownImages = useCallback(async () => { const sourceMarkdown = buildCreateMarkdownForWindow(createForm) if (!stripFrontmatter(sourceMarkdown).trim()) { toast.error('先准备一点正文,再执行正文图片本地化。') return } try { setLocalizingCreateImages(true) const result = await adminApi.localizePostMarkdownImages({ markdown: sourceMarkdown, prefix: buildInlineImagePrefix(createForm.slug || createForm.title), }) if (!result.localized_count && !result.failed_count) { toast.message('正文里没有检测到需要本地化的远程图片。') return } startTransition(() => { setCreateForm((current) => applyPolishedCreateState(current, result.markdown)) }) if (result.localized_count && result.failed_count) { toast.warning( `已替换 ${result.localized_count} 处正文图片,另有 ${result.failed_count} 个链接处理失败。`, ) } else if (result.localized_count) { toast.success(`已把 ${result.localized_count} 处正文图片替换为媒体库地址。`) } else { toast.warning(`没有成功替换正文图片,失败 ${result.failed_count} 个链接。`) } } catch (error) { toast.error(error instanceof ApiError ? error.message : '正文图片本地化失败。') } finally { setLocalizingCreateImages(false) } }, [createForm]) const openEditorPreviewWindow = useCallback(() => { const snapshot = buildEditorDraftSnapshot() if (!snapshot) { toast.error('请先打开一篇文章,再启动独立预览窗口。') return } openDraftWorkbenchWindow(`/posts/${encodeURIComponent(snapshot.slug)}/preview`, snapshot) }, [buildEditorDraftSnapshot, openDraftWorkbenchWindow]) const openEditorCompareWindow = useCallback(() => { const snapshot = buildEditorDraftSnapshot() if (!snapshot) { toast.error('请先打开一篇文章,再启动独立对比窗口。') return } openDraftWorkbenchWindow(`/posts/${encodeURIComponent(snapshot.slug)}/compare`, snapshot) }, [buildEditorDraftSnapshot, openDraftWorkbenchWindow]) const openEditorPolishWindow = useCallback(() => { const snapshot = buildEditorDraftSnapshot() if (!snapshot) { toast.error('请先打开一篇文章,再启动独立 AI 润色工作台。') return } const draftKey = openDraftWorkbenchWindow('/posts/polish', snapshot, { target: 'editor', }) if (draftKey) { editorPolishDraftKeyRef.current = draftKey } }, [buildEditorDraftSnapshot, openDraftWorkbenchWindow]) const openCreatePreviewWindow = useCallback(() => { openDraftWorkbenchWindow('/posts/preview', buildCreateDraftSnapshot()) }, [buildCreateDraftSnapshot, openDraftWorkbenchWindow]) const openCreateCompareWindow = useCallback(() => { openDraftWorkbenchWindow('/posts/compare', buildCreateDraftSnapshot()) }, [buildCreateDraftSnapshot, openDraftWorkbenchWindow]) const openCreatePolishWindow = useCallback(() => { const draftKey = openDraftWorkbenchWindow('/posts/polish', buildCreateDraftSnapshot(), { target: 'create', }) if (draftKey) { createPolishDraftKeyRef.current = draftKey } }, [buildCreateDraftSnapshot, openDraftWorkbenchWindow]) const editorPolishHunks = useMemo( () => editorPolish ? computeDiffHunks(editorPolish.sourceMarkdown, editorPolish.polishedMarkdown) : [], [editorPolish], ) const createPolishHunks = useMemo( () => createPolish ? computeDiffHunks(createPolish.sourceMarkdown, createPolish.polishedMarkdown) : [], [createPolish], ) const editorMergedMarkdown = useMemo( () => editorPolish ? applySelectedDiffHunks( editorPolish.sourceMarkdown, editorPolishHunks, editorPolish.selectedIds, ) : '', [editorPolish, editorPolishHunks], ) const createMergedMarkdown = useMemo( () => createPolish ? applySelectedDiffHunks( createPolish.sourceMarkdown, createPolishHunks, createPolish.selectedIds, ) : '', [createPolish, createPolishHunks], ) const generateEditorPolish = useCallback(async () => { if (!editor) { return } const sourceMarkdown = buildDraftMarkdownForWindow(editor) try { setGeneratingEditorPolish(true) setEditorMode('polish') const result = await adminApi.polishPostMarkdown(sourceMarkdown) const nextHunks = computeDiffHunks(sourceMarkdown, result.polished_markdown) startTransition(() => { setEditorPolish({ sourceMarkdown, polishedMarkdown: result.polished_markdown, selectedIds: new Set(nextHunks.map((hunk) => hunk.id)), }) }) toast.success(`AI 已生成润色稿,共识别 ${nextHunks.length} 个改动块。`) } catch (error) { toast.error(error instanceof ApiError ? error.message : 'AI 润色失败。') } finally { setGeneratingEditorPolish(false) } }, [editor]) const generateCreatePolish = useCallback(async () => { const sourceMarkdown = buildCreateMarkdownForWindow(createForm) try { setGeneratingCreatePolish(true) setCreateMode('polish') const result = await adminApi.polishPostMarkdown(sourceMarkdown) const nextHunks = computeDiffHunks(sourceMarkdown, result.polished_markdown) startTransition(() => { setCreatePolish({ sourceMarkdown, polishedMarkdown: result.polished_markdown, selectedIds: new Set(nextHunks.map((hunk) => hunk.id)), }) }) toast.success(`AI 已生成润色稿,共识别 ${nextHunks.length} 个改动块。`) } catch (error) { toast.error(error instanceof ApiError ? error.message : 'AI 润色失败。') } finally { setGeneratingCreatePolish(false) } }, [createForm]) const renderPolishPanel = useCallback( ({ sourceMarkdown, mergedMarkdown, hunks, selectedIds, generating, onGenerate, onToggle, onApply, onReset, onAcceptAll, title, description, modelKey, }: { sourceMarkdown: string mergedMarkdown: string hunks: DiffHunk[] selectedIds: Set generating: boolean onGenerate: () => void onToggle: (id: string) => void onApply: () => void onReset: () => void onAcceptAll: () => void title: string description: string modelKey: string }) => (

{title}

{description}

选择要合并的改动块

现在不是弹窗对比了,直接在同一个 Monaco 工作区里挑选要保留的 AI 修改。

{!hunks.length ? (
先生成一版 AI 润色稿,这里才会出现可选改动块。
) : (
{hunks.map((hunk, index) => { const accepted = selectedIds.has(hunk.id) return ( ) })}
)}
), [], ) const toggleEditorPolishHunk = useCallback((id: string) => { setEditorPolish((current) => { if (!current) { return current } const next = new Set(current.selectedIds) if (next.has(id)) { next.delete(id) } else { next.add(id) } return { ...current, selectedIds: next } }) }, []) const toggleCreatePolishHunk = useCallback((id: string) => { setCreatePolish((current) => { if (!current) { return current } const next = new Set(current.selectedIds) if (next.has(id)) { next.delete(id) } else { next.add(id) } return { ...current, selectedIds: next } }) }, []) const acceptAllEditorPolish = useCallback(() => { setEditorPolish((current) => current ? { ...current, selectedIds: new Set(editorPolishHunks.map((hunk) => hunk.id)) } : current, ) }, [editorPolishHunks]) const acceptAllCreatePolish = useCallback(() => { setCreatePolish((current) => current ? { ...current, selectedIds: new Set(createPolishHunks.map((hunk) => hunk.id)) } : current, ) }, [createPolishHunks]) const resetEditorPolish = useCallback(() => { setEditorPolish((current) => (current ? { ...current, selectedIds: new Set() } : current)) }, []) const resetCreatePolish = useCallback(() => { setCreatePolish((current) => (current ? { ...current, selectedIds: new Set() } : current)) }, []) const applyEditorPolish = useCallback(() => { if (!editor || !editorPolish) { return } startTransition(() => { setEditor(applyPolishedEditorState(editor, editorMergedMarkdown)) setEditorPolish(null) setEditorMode('workspace') }) toast.success('AI 润色结果已合并到当前文章。') }, [editor, editorMergedMarkdown, editorPolish]) const applyCreatePolish = useCallback(() => { if (!createPolish) { return } startTransition(() => { setCreateForm((current) => applyPolishedCreateState(current, createMergedMarkdown)) setCreatePolish(null) setCreateMode('workspace') }) toast.success('AI 润色结果已合并到新建草稿。') }, [createMergedMarkdown, createPolish]) const toggleMetadataField = useCallback((field: MetadataProposalField) => { setMetadataDialog((current) => { if (!current || !metadataFieldChanged(current.proposal, field)) { return current } return { ...current, proposal: { ...current.proposal, selected: { ...current.proposal.selected, [field]: !current.proposal.selected[field], }, }, } }) }, []) const updateMetadataSuggestedField = useCallback( (field: MetadataProposalField, value: string) => { setMetadataDialog((current) => { if (!current) { return current } const nextSuggested = { ...current.proposal.suggested, title: field === 'title' ? value : current.proposal.suggested.title, slug: field === 'slug' ? value : current.proposal.suggested.slug, description: field === 'description' ? value : current.proposal.suggested.description, category: field === 'category' ? value : current.proposal.suggested.category, tags: field === 'tags' ? normalizeMetadataTags(parseTagList(value)) : current.proposal.suggested.tags, } const nextProposal: MetadataProposalState = { ...current.proposal, suggested: nextSuggested, selected: { ...current.proposal.selected, }, } if (!metadataFieldChanged(nextProposal, field)) { nextProposal.selected[field] = false } return { ...current, proposal: nextProposal, } }) }, [], ) const acceptAllMetadata = useCallback(() => { setMetadataDialog((current) => current ? { ...current, proposal: { ...current.proposal, selected: buildMetadataSelection( current.proposal.current, current.proposal.suggested, metadataProposalFieldsForTarget(current.target), ), }, } : current, ) }, []) const resetMetadataProposal = useCallback(() => { setMetadataDialog((current) => current ? { ...current, proposal: { ...current.proposal, selected: createEmptyMetadataSelection(), }, } : current, ) }, []) const applyMetadataProposal = useCallback(() => { if (!metadataDialog) { return } const fields = metadataProposalFieldsForTarget(metadataDialog.target) if (!countSelectedMetadataFields(metadataDialog.proposal.selected, fields)) { toast.error('先勾选至少一项要合并的元数据。') return } const { selected, suggested } = metadataDialog.proposal startTransition(() => { if (metadataDialog.target === 'editor') { setEditor((current) => current ? { ...current, title: selected.title ? suggested.title : current.title, description: selected.description ? suggested.description : current.description, category: selected.category ? suggested.category : current.category, tags: selected.tags ? suggested.tags.join(', ') : current.tags, } : current, ) } else { setCreateForm((current) => ({ ...current, title: selected.title ? suggested.title : current.title, slug: selected.slug ? suggested.slug : current.slug, description: selected.description ? suggested.description : current.description, category: selected.category ? suggested.category : current.category, tags: selected.tags ? suggested.tags.join(', ') : current.tags, })) } setMetadataDialog(null) }) toast.success( metadataDialog.target === 'editor' ? '选中的 AI 元数据建议已合并到当前草稿。' : '选中的 AI 元数据建议已回填到新建草稿。', ) }, [metadataDialog]) const metadataDialogFields = metadataDialog ? metadataProposalFieldsForTarget(metadataDialog.target) : editorMetadataProposalFields const closeEditorDialog = useCallback(() => { navigate('/posts', { replace: true }) }, [navigate]) const closeCreateDialog = useCallback(() => { setCreateDialogOpen(false) }, []) const openCreateDialog = useCallback(() => { navigate('/posts', { replace: true }) setCreateMode('workspace') setCreatePanels(defaultWorkbenchPanels) setCreateDialogOpen(true) }, [navigate]) return (
{ void importMarkdownFiles(event.target.files) }} /> { void importMarkdownFiles(event.target.files) }} />
文章

内容库

现在可以直接在后台使用 VS Code 风格编辑器维护 Markdown,支持整文件夹导入、AI 元数据对比回填、AI 封面图生成,并把预览、diff 和 AI 润色全部收进同一个 Monaco 工作台里。

文章列表 保持列表浏览,搜索、筛选、翻页都在这里完成;新建和编辑统一在页内窗口里处理。
{paginatedPosts.length} / {totalPosts}
setSearchTerm(event.target.value)} /> {searchTerm ? ( ) : null}
匹配 {totalPosts} 当前页置顶 {pinnedPostCount} 第 {safeCurrentPage} / {totalPages} 页 {editor ? '正在编辑' : createDialogOpen ? '正在新建' : '列表浏览'}
{loading ? ( ) : (
{paginatedPosts.map((post) => { const active = post.slug === slug return ( ) })} {!totalPosts ? (
当前筛选条件下没有匹配的文章。
) : null}
{totalPosts ? (

当前显示第 {pageStart} - {pageEnd} 条,共 {totalPosts} 条结果。

{paginationItems.map((page) => ( ))}
) : null}
)}
{editorLoading && slug ? ( ) : editor ? (
页内编辑窗口 Esc 关闭 {markdownDirty ? 未保存 : null}

编辑文章

文章列表保留在背后,随时可以返回;面板切换和 AI 润色统一放在 Monaco 顶栏里。

{editor.title || editor.slug} {formatPostType(editor.postType)} {formatPostStatus(editor.status)} {formatPostVisibility(editor.visibility)} {editor.pinned ? 置顶 : null} {editor.noindex ? noindex : null} {markdownDirty ? 未保存 : null}
{editor.slug}
+{compareStats.additions} -{compareStats.deletions} {editor.markdown.length} 字符 {editor.markdown.split(/\r?\n/).length} 行

创建于 {formatDateTime(editor.createdAt)} · 更新于 {formatDateTime(editor.updatedAt)}

文章属性 把 Medium 那种最常改的字段集中在一个安静的检查器里。 setEditor((current) => current ? { ...current, title: event.target.value } : current, ) } />
setEditor((current) => current ? { ...current, category: event.target.value } : current, ) } />
setEditor((current) => current ? { ...current, publishAt: event.target.value } : current, ) } /> setEditor((current) => current ? { ...current, unpublishAt: event.target.value } : current, ) } />
setEditor((current) => current ? { ...current, tags: event.target.value } : current, ) } />