Files
termi-blog/admin/src/pages/posts-page.tsx
limitcool 9665c933b5
Some checks failed
docker-images / resolve-build-targets (push) Successful in 7s
ui-regression / playwright-regression (push) Failing after 13m4s
docker-images / build-and-push (admin) (push) Successful in 1m17s
docker-images / build-and-push (backend) (push) Successful in 28m13s
docker-images / build-and-push (frontend) (push) Successful in 47s
docker-images / submit-indexnow (push) Successful in 13s
feat: update tag and timeline share panel copy for clarity and conciseness
style: enhance global CSS for better responsiveness of terminal chips and navigation pills

test: remove inline subscription test and add maintenance mode access code test

feat: implement media library picker dialog for selecting images from the media library

feat: add media URL controls for uploading and managing media assets

feat: add migration for music_enabled and maintenance_mode settings in site settings

feat: implement maintenance mode functionality with access control

feat: create maintenance page with access code input and error handling

chore: add TypeScript declaration for QR code module
2026-04-02 23:05:49 +08:00

3550 lines
135 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string>
}
type MetadataProposalField = 'title' | 'slug' | 'description' | 'category' | 'tags'
type MetadataProposalSelection = Record<MetadataProposalField, boolean>
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<HTMLInputElement | null>(null)
const folderImportInputRef = useRef<HTMLInputElement | null>(null)
const [posts, setPosts] = useState<PostRecord[]>([])
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<PostFormState | null>(null)
const [metadataDialog, setMetadataDialog] = useState<MetadataDialogState | null>(null)
const [editorMode, setEditorMode] = useState<MarkdownWorkbenchMode>('workspace')
const [editorPanels, setEditorPanels] = useState<MarkdownWorkbenchPanel[]>(defaultWorkbenchPanels)
const [createDialogOpen, setCreateDialogOpen] = useState(false)
const [createMode, setCreateMode] = useState<MarkdownWorkbenchMode>('workspace')
const [createPanels, setCreatePanels] = useState<MarkdownWorkbenchPanel[]>(defaultWorkbenchPanels)
const [createForm, setCreateForm] = useState<CreatePostFormState>(defaultCreateForm)
const [editorPolish, setEditorPolish] = useState<PolishSessionState | null>(null)
const [createPolish, setCreatePolish] = useState<PolishSessionState | null>(null)
const [searchTerm, setSearchTerm] = useState('')
const [typeFilter, setTypeFilter] = useState('all')
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 editorPolishDraftKeyRef = useRef<string | null>(null)
const createPolishDraftKeyRef = useRef<string | null>(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<DraftWindowSnapshot, 'createdAt'> | 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<DraftWindowSnapshot, 'createdAt'> => {
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<DraftWindowSnapshot, 'createdAt'>,
extraQuery?: Record<string, string>,
) => {
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<PolishWindowResult> & { 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<string>
generating: boolean
onGenerate: () => void
onToggle: (id: string) => void
onApply: () => void
onReset: () => void
onAcceptAll: () => void
title: string
description: string
modelKey: string
}) => (
<div className="grid h-full gap-4 bg-[#111111] p-4 xl:grid-cols-[1.08fr_0.92fr]">
<div className="overflow-hidden rounded-3xl border border-slate-800 bg-[#161616]">
<div className="flex items-center justify-between border-b border-slate-800 bg-[#141414] px-4 py-3">
<div>
<p className="text-sm font-medium text-slate-100">{title}</p>
<p className="mt-1 text-xs text-slate-400">{description}</p>
</div>
<div className="flex flex-wrap gap-2">
<Button
size="sm"
variant="outline"
onClick={onGenerate}
disabled={generating}
className="border-slate-700 bg-[#202020] text-slate-100 hover:bg-[#292929]"
>
<WandSparkles className="mr-1 h-4 w-4" />
{generating ? '润色中...' : hunks.length ? '重新润色' : '生成润色稿'}
</Button>
<Button
size="sm"
variant="outline"
onClick={onAcceptAll}
disabled={!hunks.length}
className="border-slate-700 bg-[#202020] text-slate-100 hover:bg-[#292929]"
>
</Button>
<Button
size="sm"
variant="outline"
onClick={onReset}
disabled={!hunks.length}
className="border-slate-700 bg-[#202020] text-slate-100 hover:bg-[#292929]"
>
</Button>
<Button size="sm" onClick={onApply} disabled={!hunks.length}>
</Button>
</div>
</div>
<div className="h-[calc(100%-61px)]">
<LazyDiffEditor
height="100%"
language="markdown"
original={sourceMarkdown}
modified={mergedMarkdown || sourceMarkdown}
originalModelPath={`inplace-polish-original-${modelKey}`}
modifiedModelPath={`inplace-polish-merged-${modelKey}`}
keepCurrentOriginalModel
keepCurrentModifiedModel
theme={editorTheme}
beforeMount={configureMonaco}
options={{
...sharedOptions,
originalEditable: false,
readOnly: true,
renderSideBySide: true,
}}
/>
</div>
</div>
<div className="grid min-h-0 gap-4 xl:grid-rows-[1.08fr_0.92fr]">
<div className="overflow-hidden rounded-3xl border border-slate-800 bg-[#161616]">
<div className="border-b border-slate-800 bg-[#141414] px-4 py-3">
<p className="text-sm font-medium text-slate-100"></p>
<p className="mt-1 text-xs text-slate-400">
Monaco AI
</p>
</div>
<div className="h-[calc(100%-61px)] overflow-y-auto p-4">
{!hunks.length ? (
<div className="rounded-2xl border border-dashed border-slate-700 px-4 py-8 text-sm text-slate-400">
AI 稿
</div>
) : (
<div className="space-y-3">
{hunks.map((hunk, index) => {
const accepted = selectedIds.has(hunk.id)
return (
<button
key={hunk.id}
type="button"
onClick={() => onToggle(hunk.id)}
className={cn(
'w-full rounded-2xl border px-4 py-4 text-left transition',
accepted
? 'border-emerald-500/40 bg-emerald-500/12'
: 'border-slate-700 bg-[#1b1b1b] hover:border-slate-500',
)}
>
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-medium text-slate-100">
{index + 1}
</p>
<p className="mt-1 text-xs text-slate-400">
{accepted ? '已采用这块改动' : '点击后采用这块改动'}
</p>
</div>
<Badge variant={accepted ? 'success' : 'secondary'}>
{accepted ? '采用中' : '保留原文'}
</Badge>
</div>
<p className="mt-3 line-clamp-4 text-xs leading-6 text-slate-300">
{hunk.preview}
</p>
</button>
)
})}
</div>
)}
</div>
</div>
<div className="overflow-hidden rounded-3xl border border-slate-200 bg-white">
<MarkdownPreview markdown={mergedMarkdown || sourceMarkdown} />
</div>
</div>
</div>
),
[],
)
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 (
<div className="space-y-6">
<input
ref={importInputRef}
type="file"
accept=".md,.markdown,text/markdown,text/plain"
multiple
className="hidden"
onChange={(event) => {
void importMarkdownFiles(event.target.files)
}}
/>
<input
ref={folderImportInputRef}
type="file"
multiple
className="hidden"
onChange={(event) => {
void importMarkdownFiles(event.target.files)
}}
/>
<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">
使 VS Code MarkdownAI AI diff AI Monaco
</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-3">
<Button variant="outline" onClick={openCreateDialog} data-testid="posts-open-create">
<FilePlus2 className="h-4 w-4" />
稿
</Button>
<Button
variant="outline"
onClick={() => importInputRef.current?.click()}
disabled={importing}
>
<FileUp className="h-4 w-4" />
{importing ? '导入中...' : '导入 Markdown'}
</Button>
<Button
variant="outline"
onClick={() => folderImportInputRef.current?.click()}
disabled={importing}
>
<FolderOpen className="h-4 w-4" />
{importing ? '导入中...' : '导入文件夹'}
</Button>
<Button variant="secondary" onClick={() => void loadPosts(true)} disabled={refreshing}>
<RefreshCcw className="h-4 w-4" />
{refreshing ? '刷新中...' : '刷新'}
</Button>
</div>
</div>
<div className="space-y-5">
<Card className="rounded-[2rem]">
<CardHeader className="gap-4 border-b border-border/70 bg-background/70 pb-5">
<div className="flex items-start justify-between gap-3">
<div>
<CardTitle></CardTitle>
<CardDescription>
</CardDescription>
</div>
<Badge variant="outline">{paginatedPosts.length} / {totalPosts}</Badge>
</div>
<div className="grid gap-3">
<div className="flex flex-col gap-3 lg:flex-row">
<Input
data-testid="posts-search"
className="flex-1"
placeholder="搜索标题、slug、分类、标签或摘要"
value={searchTerm}
onChange={(event) => setSearchTerm(event.target.value)}
/>
{searchTerm ? (
<Button variant="outline" onClick={() => setSearchTerm('')}>
<X className="h-4 w-4" />
</Button>
) : null}
</div>
<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>
<option value="note"></option>
<option value="page"></option>
<option value="snippet"></option>
</Select>
<Select
value={pinnedFilter}
onChange={(event) => setPinnedFilter(event.target.value)}
>
<option value="all"></option>
<option value="pinned"></option>
<option value="regular"></option>
</Select>
<Select
value={String(pageSize)}
onChange={(event) => setPageSize(Number(event.target.value))}
>
{POSTS_PAGE_SIZE_OPTIONS.map((option) => (
<option key={option} value={option}>
{option}
</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"> {totalPosts}</Badge>
<Badge variant="outline"> {pinnedPostCount}</Badge>
<Badge variant="outline">
{safeCurrentPage} / {totalPages}
</Badge>
<Badge variant="outline">
{editor ? '正在编辑' : createDialogOpen ? '正在新建' : '列表浏览'}
</Badge>
</div>
</CardHeader>
<CardContent className="pt-5 xl:flex xl:min-h-0 xl:flex-col xl:overflow-hidden">
{loading ? (
<Skeleton className="h-[620px] rounded-3xl" />
) : (
<div className="space-y-4 xl:flex xl:min-h-0 xl:flex-1 xl:flex-col xl:overflow-hidden">
<div className="space-y-2 xl:min-h-0 xl:flex-1 xl:overflow-y-auto xl:pr-1">
{paginatedPosts.map((post) => {
const active = post.slug === slug
return (
<button
key={post.id}
type="button"
data-testid={`post-item-${post.slug}`}
onClick={() => navigate(`/posts/${post.slug}`)}
className={cn(
'w-full rounded-[1.45rem] border px-4 py-3.5 text-left transition-all',
active
? 'border-primary/30 bg-primary/10 shadow-[0_18px_42px_rgba(37,99,235,0.16)]'
: 'border-border/70 bg-background/60 hover:border-border hover:bg-accent/40',
)}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="truncate font-medium">
{post.title ?? '未命名文章'}
</span>
<Badge variant="secondary">{formatPostType(post.post_type)}</Badge>
{post.pinned ? <Badge variant="success"></Badge> : null}
</div>
<p className="mt-1 truncate font-mono text-[11px] text-muted-foreground">
{post.slug}
</p>
</div>
<span className="shrink-0 text-[11px] text-muted-foreground">
{formatDateTime(post.updated_at)}
</span>
</div>
<p className="mt-2 line-clamp-2 text-sm leading-6 text-muted-foreground">
{post.description ?? '暂无摘要。'}
</p>
<div className="mt-2.5 flex flex-wrap gap-2">
<Badge variant="outline">{post.category ?? '未分类'}</Badge>
</div>
</button>
)
})}
{!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>
{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} {totalPosts}
</p>
<div className="flex flex-wrap items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage((current) => Math.max(1, current - 1))}
disabled={safeCurrentPage === 1}
>
<ChevronLeft className="h-4 w-4" />
</Button>
{paginationItems.map((page) => (
<Button
key={page}
variant={page === safeCurrentPage ? 'secondary' : 'outline'}
size="sm"
onClick={() => setCurrentPage(page)}
>
{page}
</Button>
))}
<Button
variant="outline"
size="sm"
onClick={() =>
setCurrentPage((current) => Math.min(totalPages, current + 1))
}
disabled={safeCurrentPage === totalPages}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
</div>
) : null}
</div>
)}
</CardContent>
</Card>
{editorLoading && slug ? (
<Skeleton className="h-[1400px] rounded-3xl" />
) : editor ? (
<div className="fixed inset-0 z-40 overflow-y-auto bg-slate-950/60 px-4 py-5 backdrop-blur-sm xl:px-8 xl:py-8">
<div className="mx-auto grid max-w-[1820px] gap-5 xl:grid-cols-[minmax(0,1fr)_284px] 2xl:grid-cols-[minmax(0,1fr)_300px]">
<Card className="xl:col-span-2 overflow-hidden rounded-[2rem] border-border/70 bg-background/92 shadow-[0_32px_100px_rgba(15,23,42,0.24)]">
<div className="flex flex-col gap-4 px-5 py-5 lg:flex-row lg:items-center lg:justify-between">
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="secondary"></Badge>
<Badge variant="outline">Esc </Badge>
{markdownDirty ? <Badge variant="warning"></Badge> : null}
</div>
<div>
<h3 className="text-2xl font-semibold tracking-tight"></h3>
<p className="mt-2 text-sm leading-6 text-muted-foreground">
AI Monaco
</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button variant="outline" onClick={closeEditorDialog} data-testid="post-editor-close">
<ArrowLeft className="h-4 w-4" />
</Button>
<Button variant="ghost" onClick={closeEditorDialog}>
<X className="h-4 w-4" />
</Button>
</div>
</div>
</Card>
<div className="order-2 space-y-5 xl:sticky xl:top-28 xl:self-start">
<Card className="overflow-hidden rounded-[2rem] border-border/70 bg-background/80">
<CardHeader className="gap-4 border-b border-border/70 bg-background/72">
<div className="flex flex-wrap items-center gap-2">
<CardTitle className="text-xl">{editor.title || editor.slug}</CardTitle>
<Badge variant="secondary">{formatPostType(editor.postType)}</Badge>
<Badge variant="outline">{formatPostStatus(editor.status)}</Badge>
<Badge variant="outline">{formatPostVisibility(editor.visibility)}</Badge>
{editor.pinned ? <Badge variant="success"></Badge> : null}
{editor.noindex ? <Badge variant="warning">noindex</Badge> : null}
{markdownDirty ? <Badge variant="warning"></Badge> : null}
</div>
<CardDescription className="font-mono text-xs">{editor.slug}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-wrap gap-2">
<Badge variant="success">+{compareStats.additions}</Badge>
<Badge variant="danger">-{compareStats.deletions}</Badge>
<Badge variant="outline">{editor.markdown.length} </Badge>
<Badge variant="outline">{editor.markdown.split(/\r?\n/).length} </Badge>
</div>
<div className="rounded-2xl border border-border/70 bg-background/70 p-4">
<p className="text-sm text-muted-foreground">
{formatDateTime(editor.createdAt)} · {formatDateTime(editor.updatedAt)}
</p>
</div>
</CardContent>
</Card>
<Card className="rounded-[2rem]">
<CardHeader className="pb-4">
<CardTitle className="text-base"></CardTitle>
<CardDescription> Medium </CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<FormField label="标题">
<Input
data-testid="post-editor-title"
value={editor.title}
onChange={(event) =>
setEditor((current) =>
current ? { ...current, title: event.target.value } : current,
)
}
/>
</FormField>
<FormField
label="Slug"
hint="为避免 Markdown 路径漂移,当前不允许直接修改 slug。"
>
<Input value={editor.slug} disabled />
</FormField>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-1 2xl:grid-cols-2">
<FormField label="分类">
<Input
value={editor.category}
onChange={(event) =>
setEditor((current) =>
current ? { ...current, category: event.target.value } : current,
)
}
/>
</FormField>
<FormField label="文章类型">
<Select
value={editor.postType}
onChange={(event) =>
setEditor((current) =>
current ? { ...current, postType: event.target.value } : current,
)
}
>
<option value="article"></option>
<option value="note"></option>
<option value="page"></option>
<option value="snippet"></option>
</Select>
</FormField>
</div>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-1 2xl:grid-cols-2">
<FormField label="发布状态">
<Select
value={editor.status}
onChange={(event) =>
setEditor((current) =>
current ? { ...current, status: event.target.value } : current,
)
}
>
<option value="draft">稿</option>
<option value="published"></option>
<option value="offline">线</option>
</Select>
</FormField>
<FormField label="可见性">
<Select
value={editor.visibility}
onChange={(event) =>
setEditor((current) =>
current ? { ...current, visibility: event.target.value } : current,
)
}
>
<option value="public"></option>
<option value="unlisted"></option>
<option value="private"></option>
</Select>
</FormField>
</div>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-1 2xl:grid-cols-2">
<FormField label="定时发布">
<Input
type="datetime-local"
value={editor.publishAt}
onChange={(event) =>
setEditor((current) =>
current ? { ...current, publishAt: event.target.value } : current,
)
}
/>
</FormField>
<FormField label="下线时间">
<Input
type="datetime-local"
value={editor.unpublishAt}
onChange={(event) =>
setEditor((current) =>
current ? { ...current, unpublishAt: event.target.value } : current,
)
}
/>
</FormField>
</div>
<FormField label="标签" hint="多个标签请用英文逗号分隔。">
<Input
value={editor.tags}
onChange={(event) =>
setEditor((current) =>
current ? { ...current, tags: event.target.value } : current,
)
}
/>
</FormField>
<FormField label="摘要">
<Textarea
value={editor.description}
onChange={(event) =>
setEditor((current) =>
current ? { ...current, description: event.target.value } : current,
)
}
/>
</FormField>
</CardContent>
</Card>
<Card className="rounded-[2rem]">
<CardHeader className="pb-4">
<CardTitle className="text-base"></CardTitle>
<CardDescription>
Ghost
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<FormField label="封面图 URL" hint="可直接填写外链,也可以上传 / 抓取到媒体库后自动回填。">
<div className="space-y-3">
<Input
value={editor.image}
onChange={(event) =>
setEditor((current) =>
current ? { ...current, image: event.target.value } : current,
)
}
/>
<MediaUrlControls
value={editor.image}
onChange={(image) =>
setEditor((current) => (current ? { ...current, image } : current))
}
prefix="post-covers/"
contextLabel="文章封面上传"
mode="cover"
remoteTitle={editor.title || editor.slug || '文章封面'}
dataTestIdPrefix="post-editor-cover"
/>
</div>
</FormField>
<div className="flex flex-wrap items-center gap-3">
<Button
variant="outline"
onClick={() => void generateEditorCover()}
disabled={generatingEditorCover}
>
<WandSparkles className="h-4 w-4" />
{generatingEditorCover
? '生成封面中...'
: editor.image.trim()
? '重新生成 AI 封面'
: 'AI 生成封面'}
</Button>
{editorCoverPreviewUrl ? (
<Button variant="outline" asChild>
<a href={editorCoverPreviewUrl} target="_blank" rel="noreferrer">
</a>
</Button>
) : null}
</div>
{editorCoverPreviewUrl ? (
<div className="overflow-hidden rounded-[1.4rem] border border-border/70 bg-background/70">
<div className="border-b border-border/70 px-4 py-3">
<p className="text-[11px] uppercase tracking-[0.22em] text-muted-foreground">
</p>
</div>
<img
src={editorCoverPreviewUrl}
alt={editor.title || editor.slug}
className="aspect-[16/9] w-full object-cover"
/>
</div>
) : null}
<FormField label="推文多图" hint="每行一个图片 URL适合 tweet / 动态类内容。">
<Textarea
value={editor.imagesText}
onChange={(event) =>
setEditor((current) =>
current ? { ...current, imagesText: event.target.value } : current,
)
}
/>
</FormField>
<FormField label="Canonical URL" hint="留空则使用默认文章地址。">
<Input
value={editor.canonicalUrl}
onChange={(event) =>
setEditor((current) =>
current ? { ...current, canonicalUrl: event.target.value } : current,
)
}
/>
</FormField>
<FormField label="OG 图 URL" hint="留空则前台自动生成 SVG 分享图。">
<Input
value={editor.ogImage}
onChange={(event) =>
setEditor((current) =>
current ? { ...current, ogImage: event.target.value } : current,
)
}
/>
</FormField>
<FormField label="旧地址重定向" hint="每行一个旧 slug不带 /articles/ 前缀。">
<Textarea
value={editor.redirectFromText}
onChange={(event) =>
setEditor((current) =>
current ? { ...current, redirectFromText: event.target.value } : current,
)
}
/>
</FormField>
<FormField label="强制跳转目标" hint="适合旧文跳新文;留空表示当前 slug 为主地址。">
<Input
value={editor.redirectTo}
onChange={(event) =>
setEditor((current) =>
current ? { ...current, redirectTo: event.target.value } : current,
)
}
/>
</FormField>
<label className="flex items-start gap-3 rounded-2xl border border-border/70 bg-background/60 p-4">
<input
type="checkbox"
checked={editor.noindex}
onChange={(event) =>
setEditor((current) =>
current ? { ...current, noindex: event.target.checked } : current,
)
}
className="mt-1 h-4 w-4 rounded border-input text-primary focus:ring-ring"
/>
<div>
<div className="font-medium">noindex</div>
<div className="text-sm text-muted-foreground">
访
</div>
</div>
</label>
<label className="flex items-start gap-3 rounded-2xl border border-border/70 bg-background/60 p-4">
<input
type="checkbox"
checked={editor.pinned}
onChange={(event) =>
setEditor((current) =>
current ? { ...current, pinned: event.target.checked } : current,
)
}
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">
</p>
</div>
</label>
</CardContent>
</Card>
</div>
<Card className="order-1 min-w-0 overflow-hidden rounded-[2rem]">
<CardHeader className="flex flex-col gap-5 border-b border-border/70 bg-background/70 xl:flex-row xl:items-start xl:justify-between">
<div>
<div className="flex flex-wrap items-center gap-2">
<CardTitle></CardTitle>
<Badge variant="outline">
{formatWorkbenchStateLabel(editorMode, editorPanels)}
</Badge>
</div>
<CardDescription>
AI Monaco
</CardDescription>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
variant="outline"
onClick={() => void generateEditorMetadataProposal()}
disabled={saving || generatingEditorMetadataProposal}
>
<Bot className="h-4 w-4" />
{generatingEditorMetadataProposal ? '分析中...' : 'AI 元信息'}
</Button>
<Button variant="outline" onClick={openEditorPreviewWindow}>
<ExternalLink className="h-4 w-4" />
</Button>
<Button variant="outline" onClick={openEditorCompareWindow}>
<GitCompareArrows className="h-4 w-4" />
</Button>
<Button variant="outline" onClick={openEditorPolishWindow}>
<WandSparkles className="h-4 w-4" />
</Button>
<Button
variant="outline"
onClick={() => void localizeEditorMarkdownImages()}
disabled={saving || localizingEditorImages}
>
<Download className="h-4 w-4" />
{localizingEditorImages ? '本地化中...' : '正文图本地化'}
</Button>
<Button
variant="outline"
onClick={() => {
downloadMarkdownFile(`${editor.slug}.md`, buildDraftMarkdownForWindow(editor))
toast.success('Markdown 已导出。')
}}
>
<Download className="h-4 w-4" />
</Button>
<Button
variant="outline"
onClick={() =>
setEditor((current) =>
current
? {
...current,
title: current.savedMeta.title,
description: current.savedMeta.description,
category: current.savedMeta.category,
postType: current.savedMeta.postType,
image: current.savedMeta.image,
imagesText: current.savedMeta.imagesText,
pinned: current.savedMeta.pinned,
tags: current.savedMeta.tags,
markdown: current.savedMarkdown,
}
: current,
)
}
disabled={!markdownDirty}
>
<RotateCcw className="h-4 w-4" />
</Button>
<Button onClick={() => void saveEditor()} disabled={saving} data-testid="post-editor-save">
<Save className="h-4 w-4" />
{saving ? '保存中...' : '保存'}
</Button>
<Button
data-testid="post-editor-delete"
variant="danger"
onClick={async () => {
if (!window.confirm(`确定删除“${editor.title || editor.slug}”吗?`)) {
return
}
try {
setDeleting(true)
await adminApi.deletePost(editor.slug)
toast.success('文章已删除。')
await loadPosts(false)
navigate('/posts', { replace: true })
} catch (error) {
toast.error(error instanceof ApiError ? error.message : '无法删除文章。')
} finally {
setDeleting(false)
}
}}
disabled={deleting}
>
<Trash2 className="h-4 w-4" />
{deleting ? '删除中...' : '删除'}
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4 pt-5">
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 rounded-[1.5rem] border border-border/70 bg-background/55 px-4 py-3 text-sm text-muted-foreground">
<span>稿</span>
<span> / Diff / AI </span>
</div>
<MarkdownWorkbench
value={editor.markdown}
originalValue={editor.savedMarkdown}
diffValue={buildDraftMarkdownForWindow(editor)}
path={editor.path}
workspaceHeightClassName="h-[clamp(620px,74dvh,920px)]"
mode={editorMode}
visiblePanels={editorPanels}
availablePanels={['edit', 'preview', 'diff']}
preview={<MarkdownPreview markdown={editor.markdown} />}
polishPanel={renderPolishPanel({
sourceMarkdown:
editorPolish?.sourceMarkdown ?? buildDraftMarkdownForWindow(editor),
mergedMarkdown: editorPolish ? editorMergedMarkdown : '',
hunks: editorPolishHunks,
selectedIds: editorPolish?.selectedIds ?? new Set<string>(),
generating: generatingEditorPolish,
onGenerate: () => void generateEditorPolish(),
onToggle: toggleEditorPolishHunk,
onApply: applyEditorPolish,
onReset: resetEditorPolish,
onAcceptAll: acceptAllEditorPolish,
title: '当前文章 AI 润色工作台',
description:
'左侧是整篇文章的合并结果,右侧按改动块选择要保留的润色内容。',
modelKey: `editor-${editor.slug}`,
})}
originalLabel="已保存版本"
modifiedLabel="当前草稿"
onModeChange={setEditorMode}
onVisiblePanelsChange={setEditorPanels}
onChange={(next) =>
setEditor((current) => (current ? { ...current, markdown: next } : current))
}
/>
</CardContent>
</Card>
</div>
</div>
) : createDialogOpen ? (
<div className="fixed inset-0 z-40 overflow-y-auto bg-slate-950/60 px-4 py-5 backdrop-blur-sm xl:px-8 xl:py-8">
<div className="mx-auto grid max-w-[1820px] gap-5 xl:grid-cols-[minmax(0,1fr)_284px] 2xl:grid-cols-[minmax(0,1fr)_300px]">
<Card className="xl:col-span-2 overflow-hidden rounded-[2rem] border-border/70 bg-background/92 shadow-[0_32px_100px_rgba(15,23,42,0.24)]">
<div className="flex flex-col gap-4 px-5 py-5 lg:flex-row lg:items-center lg:justify-between">
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="secondary"></Badge>
<Badge variant="outline">Esc </Badge>
{createMarkdownDirty ? <Badge variant="warning">稿</Badge> : null}
</div>
<div>
<h3 className="text-2xl font-semibold tracking-tight"></h3>
<p className="mt-2 text-sm leading-6 text-muted-foreground">
AI Monaco
</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button variant="outline" onClick={closeCreateDialog}>
<ArrowLeft className="h-4 w-4" />
</Button>
<Button variant="ghost" onClick={closeCreateDialog}>
<X className="h-4 w-4" />
</Button>
</div>
</div>
</Card>
<div className="order-2 space-y-5 xl:sticky xl:top-28 xl:self-start">
<Card className="overflow-hidden rounded-[2rem] border-border/70 bg-background/80">
<CardHeader className="gap-4 border-b border-border/70 bg-background/72">
<div className="flex flex-wrap items-center gap-2">
<CardTitle className="text-xl"></CardTitle>
{createMarkdownDirty ? <Badge variant="warning">稿</Badge> : null}
</div>
<CardDescription>
Medium
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-wrap gap-2">
<Badge variant="outline">{createForm.markdown.length} </Badge>
<Badge variant="outline">{createForm.markdown.split(/\r?\n/).length} </Badge>
<Badge variant="secondary">AI </Badge>
<Badge variant="outline">AI </Badge>
<Badge variant="outline">{formatPostStatus(createForm.status)}</Badge>
<Badge variant="outline">{formatPostVisibility(createForm.visibility)}</Badge>
</div>
<div className="rounded-2xl border border-border/70 bg-background/70 p-4 text-sm leading-6 text-muted-foreground">
AI slug稿
</div>
</CardContent>
</Card>
<Card className="rounded-[2rem]">
<CardHeader className="pb-4">
<CardTitle className="text-base"></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<FormField label="标题">
<Input
data-testid="post-create-title"
value={createForm.title}
onChange={(event) =>
setCreateForm((current) => ({ ...current, title: event.target.value }))
}
/>
</FormField>
<FormField label="Slug" hint="留空则根据标题自动生成。">
<Input
data-testid="post-create-slug"
value={createForm.slug}
onChange={(event) =>
setCreateForm((current) => ({ ...current, slug: event.target.value }))
}
/>
</FormField>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-1 2xl:grid-cols-2">
<FormField label="分类">
<Input
value={createForm.category}
onChange={(event) =>
setCreateForm((current) => ({ ...current, category: event.target.value }))
}
/>
</FormField>
<FormField label="文章类型">
<Select
value={createForm.postType}
onChange={(event) =>
setCreateForm((current) => ({ ...current, postType: event.target.value }))
}
>
<option value="article"></option>
<option value="note"></option>
<option value="page"></option>
<option value="snippet"></option>
</Select>
</FormField>
</div>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-1 2xl:grid-cols-2">
<FormField label="发布状态">
<Select
value={createForm.status}
onChange={(event) =>
setCreateForm((current) => ({ ...current, status: event.target.value }))
}
>
<option value="draft">稿</option>
<option value="published"></option>
<option value="offline">线</option>
</Select>
</FormField>
<FormField label="可见性">
<Select
value={createForm.visibility}
onChange={(event) =>
setCreateForm((current) => ({ ...current, visibility: event.target.value }))
}
>
<option value="public"></option>
<option value="unlisted"></option>
<option value="private"></option>
</Select>
</FormField>
</div>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-1 2xl:grid-cols-2">
<FormField label="定时发布">
<Input
type="datetime-local"
value={createForm.publishAt}
onChange={(event) =>
setCreateForm((current) => ({ ...current, publishAt: event.target.value }))
}
/>
</FormField>
<FormField label="下线时间">
<Input
type="datetime-local"
value={createForm.unpublishAt}
onChange={(event) =>
setCreateForm((current) => ({ ...current, unpublishAt: event.target.value }))
}
/>
</FormField>
</div>
<FormField label="标签" hint="多个标签请用英文逗号分隔。">
<Input
value={createForm.tags}
onChange={(event) =>
setCreateForm((current) => ({ ...current, tags: event.target.value }))
}
/>
</FormField>
<FormField label="摘要">
<Textarea
value={createForm.description}
onChange={(event) =>
setCreateForm((current) => ({
...current,
description: event.target.value,
}))
}
/>
</FormField>
</CardContent>
</Card>
<Card className="rounded-[2rem]">
<CardHeader className="pb-4">
<CardTitle className="text-base"></CardTitle>
<CardDescription>
Ghost
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<FormField label="封面图 URL" hint="可直接填写外链,也可以上传 / 抓取到媒体库后自动回填。">
<div className="space-y-3">
<Input
value={createForm.image}
onChange={(event) =>
setCreateForm((current) => ({ ...current, image: event.target.value }))
}
/>
<MediaUrlControls
value={createForm.image}
onChange={(image) =>
setCreateForm((current) => ({ ...current, image }))
}
prefix="post-covers/"
contextLabel="新建文章封面上传"
mode="cover"
remoteTitle={createForm.title || createForm.slug || '文章封面'}
dataTestIdPrefix="post-create-cover"
/>
</div>
</FormField>
<div className="flex flex-wrap items-center gap-3">
<Button
variant="outline"
onClick={() => void generateCreateCover()}
disabled={generatingCreateCover}
>
<WandSparkles className="h-4 w-4" />
{generatingCreateCover
? '生成封面中...'
: createForm.image.trim()
? '重新生成 AI 封面'
: 'AI 生成封面'}
</Button>
{createCoverPreviewUrl ? (
<Button variant="outline" asChild>
<a href={createCoverPreviewUrl} target="_blank" rel="noreferrer">
</a>
</Button>
) : null}
</div>
{createCoverPreviewUrl ? (
<div className="overflow-hidden rounded-[1.4rem] border border-border/70 bg-background/70">
<div className="border-b border-border/70 px-4 py-3">
<p className="text-[11px] uppercase tracking-[0.22em] text-muted-foreground">
</p>
</div>
<img
src={createCoverPreviewUrl}
alt={createForm.title || createForm.slug || '新建草稿封面'}
className="aspect-[16/9] w-full object-cover"
/>
</div>
) : null}
<FormField label="推文多图" hint="每行一个图片 URL创建后前台会按多图渲染。">
<Textarea
value={createForm.imagesText}
onChange={(event) =>
setCreateForm((current) => ({ ...current, imagesText: event.target.value }))
}
/>
</FormField>
<FormField label="Canonical URL" hint="留空时使用默认文章地址。">
<Input
value={createForm.canonicalUrl}
onChange={(event) =>
setCreateForm((current) => ({ ...current, canonicalUrl: event.target.value }))
}
/>
</FormField>
<FormField label="OG 图 URL" hint="留空则由前台自动生成。">
<Input
value={createForm.ogImage}
onChange={(event) =>
setCreateForm((current) => ({ ...current, ogImage: event.target.value }))
}
/>
</FormField>
<FormField label="旧地址重定向" hint="每行一个旧 slug。">
<Textarea
value={createForm.redirectFromText}
onChange={(event) =>
setCreateForm((current) => ({
...current,
redirectFromText: event.target.value,
}))
}
/>
</FormField>
<FormField label="强制跳转目标" hint="可选:创建即作为跳转占位。">
<Input
value={createForm.redirectTo}
onChange={(event) =>
setCreateForm((current) => ({ ...current, redirectTo: event.target.value }))
}
/>
</FormField>
<label className="flex items-start gap-3 rounded-2xl border border-border/70 bg-background/60 p-4">
<input
type="checkbox"
checked={createForm.noindex}
onChange={(event) =>
setCreateForm((current) => ({ ...current, noindex: event.target.checked }))
}
className="mt-1 h-4 w-4 rounded border-input text-primary focus:ring-ring"
/>
<div>
<div className="font-medium"> noindex</div>
<div className="text-sm text-muted-foreground">
sitemap / RSS
</div>
</div>
</label>
<label className="flex items-start gap-3 rounded-2xl border border-border/70 bg-background/60 p-4">
<input
type="checkbox"
checked={createForm.pinned}
onChange={(event) =>
setCreateForm((current) => ({ ...current, pinned: 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">
</p>
</div>
</label>
</CardContent>
</Card>
</div>
<Card className="order-1 min-w-0 overflow-hidden rounded-[2rem]">
<CardHeader className="flex flex-col gap-5 border-b border-border/70 bg-background/70 xl:flex-row xl:items-start xl:justify-between">
<div>
<div className="flex flex-wrap items-center gap-2">
<CardTitle>稿</CardTitle>
<Badge variant="outline">
{formatWorkbenchStateLabel(createMode, createPanels)}
</Badge>
</div>
<CardDescription>
AI Monaco 稿
</CardDescription>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button variant="outline" onClick={openCreatePreviewWindow}>
<ExternalLink className="h-4 w-4" />
</Button>
<Button variant="outline" onClick={openCreateCompareWindow}>
<GitCompareArrows className="h-4 w-4" />
</Button>
<Button variant="outline" onClick={openCreatePolishWindow}>
<WandSparkles className="h-4 w-4" />
</Button>
<Button
variant="outline"
onClick={() => void localizeCreateMarkdownImages()}
disabled={creating || localizingCreateImages}
>
<Download className="h-4 w-4" />
{localizingCreateImages ? '本地化中...' : '正文图本地化'}
</Button>
<Button
variant="outline"
onClick={() => {
const exportName = (createForm.slug.trim() || 'untitled-post').replace(
/\s+/g,
'-',
)
downloadMarkdownFile(`${exportName}.md`, buildCreateMarkdownForWindow(createForm))
toast.success('草稿 Markdown 已导出。')
}}
>
<Download className="h-4 w-4" />
</Button>
<Button
variant="outline"
onClick={() => void generateCreateMetadata()}
disabled={creating || generatingMetadata}
>
<Bot className="h-4 w-4" />
{generatingMetadata ? '生成中...' : 'AI 元信息'}
</Button>
<Button
variant="outline"
onClick={() => {
setCreateForm(defaultCreateForm)
setCreatePolish(null)
setCreateMode('workspace')
setCreatePanels(defaultWorkbenchPanels)
}}
disabled={!createMarkdownDirty}
>
<RotateCcw className="h-4 w-4" />
</Button>
<Button
data-testid="post-create-submit"
onClick={async () => {
if (!createForm.title.trim()) {
toast.error('创建文章时必须填写标题。')
return
}
try {
setCreating(true)
const created = await adminApi.createPost(buildCreatePayload(createForm))
toast.success('草稿已创建。')
setCreateDialogOpen(false)
setCreateForm(defaultCreateForm)
setCreatePolish(null)
setCreateMode('workspace')
setCreatePanels(defaultWorkbenchPanels)
await loadPosts(false)
navigate(`/posts/${created.slug}`)
} catch (error) {
toast.error(error instanceof ApiError ? error.message : '无法创建草稿。')
} finally {
setCreating(false)
}
}}
disabled={creating}
>
<PencilLine className="h-4 w-4" />
{creating ? '创建中...' : '创建'}
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4 pt-5">
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 rounded-[1.5rem] border border-border/70 bg-background/55 px-4 py-3 text-sm text-muted-foreground">
<span></span>
<span>稿 / Diff / AI </span>
</div>
<MarkdownWorkbench
value={createForm.markdown}
originalValue={buildCreateMarkdownForWindow(defaultCreateForm)}
diffValue={buildCreateMarkdownForWindow(createForm)}
path={buildVirtualPostPath(createForm.slug)}
workspaceHeightClassName="h-[clamp(620px,74dvh,920px)]"
mode={createMode}
visiblePanels={createPanels}
preview={<MarkdownPreview markdown={createForm.markdown} />}
availablePanels={['edit', 'preview', 'diff']}
polishPanel={renderPolishPanel({
sourceMarkdown:
createPolish?.sourceMarkdown ?? buildCreateMarkdownForWindow(createForm),
mergedMarkdown: createPolish ? createMergedMarkdown : '',
hunks: createPolishHunks,
selectedIds: createPolish?.selectedIds ?? new Set<string>(),
generating: generatingCreatePolish,
onGenerate: () => void generateCreatePolish(),
onToggle: toggleCreatePolishHunk,
onApply: applyCreatePolish,
onReset: resetCreatePolish,
onAcceptAll: acceptAllCreatePolish,
title: '新建草稿 AI 润色工作台',
description:
'润色会同时覆盖元信息和正文,你可以像合并补丁一样逐块决定是否采用。',
modelKey: `create-${createForm.slug.trim() || 'new-post'}`,
})}
originalLabel="初始模板"
modifiedLabel="当前草稿"
onModeChange={setCreateMode}
onVisiblePanelsChange={setCreatePanels}
onChange={(next) =>
setCreateForm((current) => ({
...current,
markdown: next,
}))
}
/>
</CardContent>
</Card>
</div>
</div>
) : null}
</div>
{metadataDialog ? (
<div className="fixed inset-0 z-50 bg-slate-950/70 px-4 py-5 backdrop-blur-sm xl:px-8 xl:py-8">
<div className="mx-auto flex h-full w-full max-w-7xl flex-col overflow-hidden rounded-[32px] border border-border/70 bg-background shadow-[0_40px_120px_rgba(15,23,42,0.45)]">
<div className="flex flex-col gap-4 border-b border-border/70 bg-background/95 px-6 py-5 xl:flex-row xl:items-start xl:justify-between">
<div className="space-y-3">
<Badge variant="secondary">AI </Badge>
<div>
<h3 className="text-2xl font-semibold tracking-tight">
{metadataDialog.target === 'editor'
? '文章元信息对比与回填'
: '新建草稿元信息对比与回填'}
</h3>
<p className="mt-2 max-w-3xl text-sm leading-7 text-muted-foreground">
AI
</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-3">
<Button variant="outline" onClick={acceptAllMetadata}>
</Button>
<Button variant="outline" onClick={resetMetadataProposal}>
</Button>
<Button
onClick={applyMetadataProposal}
disabled={
!countSelectedMetadataFields(
metadataDialog.proposal.selected,
metadataDialogFields,
)
}
>
<Save className="h-4 w-4" />
</Button>
<Button
variant="ghost"
onClick={() => setMetadataDialog(null)}
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
<div className="grid min-h-0 flex-1 gap-0 xl:grid-cols-[320px_minmax(0,1fr)]">
<aside className="border-b border-border/70 bg-muted/20 xl:border-b-0 xl:border-r">
<div className="space-y-4 p-6">
<div className="rounded-[1.75rem] border border-border/70 bg-background/80 p-4">
<p className="text-[11px] uppercase tracking-[0.22em] text-muted-foreground">
</p>
<p className="mt-3 text-base font-semibold">
{metadataDialog.title}
</p>
</div>
<div className="grid gap-3 sm:grid-cols-3 xl:grid-cols-1">
<div className="rounded-2xl border border-border/70 bg-background/80 p-4">
<p className="text-[11px] uppercase tracking-[0.22em] text-muted-foreground">
</p>
<p className="mt-3 text-3xl font-semibold">
{countChangedMetadataFields(
metadataDialog.proposal,
metadataDialogFields,
)}
</p>
</div>
<div className="rounded-2xl border border-border/70 bg-background/80 p-4">
<p className="text-[11px] uppercase tracking-[0.22em] text-muted-foreground">
</p>
<p className="mt-3 text-3xl font-semibold">
{countSelectedMetadataFields(
metadataDialog.proposal.selected,
metadataDialogFields,
)}
</p>
</div>
<div className="rounded-2xl border border-border/70 bg-background/80 p-4">
<p className="text-[11px] uppercase tracking-[0.22em] text-muted-foreground">
</p>
<p className="mt-3 text-sm leading-6 text-muted-foreground">
{metadataDialog.target === 'editor'
? '标题、摘要、分类、标签支持单独回填;现有文章的 slug 继续锁定,避免 Markdown 路径漂移。'
: '标题、slug、摘要、分类和标签都支持单独回填你也可以先修改 AI 建议再应用。'}
</p>
</div>
</div>
</div>
</aside>
<div className="min-h-0 overflow-y-auto bg-background">
<div className="space-y-4 p-6">
{metadataDialogFields.map((field) => {
const changed = metadataFieldChanged(metadataDialog.proposal, field)
const selected = metadataDialog.proposal.selected[field]
const currentValue = metadataDialog.proposal.current[field]
const suggestedValue = metadataDialog.proposal.suggested[field]
const isTagsField = field === 'tags'
const isDescriptionField = field === 'description'
const suggestedText = Array.isArray(suggestedValue)
? suggestedValue.join(', ')
: suggestedValue
return (
<section
key={field}
className={cn(
'overflow-hidden rounded-[1.9rem] border transition-colors',
selected && changed
? 'border-emerald-500/40 bg-emerald-500/5'
: 'border-border/70 bg-background/80',
)}
>
<div className="flex flex-col gap-3 border-b border-border/70 px-5 py-4 xl:flex-row xl:items-center xl:justify-between">
<div className="flex flex-wrap items-center gap-3">
<h4 className="text-base font-semibold">
{metadataFieldLabel(field)}
</h4>
<Badge
variant={
changed
? selected
? 'success'
: 'warning'
: 'outline'
}
>
{changed
? selected
? '准备合并'
: '保留当前'
: '无变化'}
</Badge>
</div>
<Button
variant={selected && changed ? 'default' : 'outline'}
size="sm"
onClick={() => toggleMetadataField(field)}
disabled={!changed}
>
{selected && changed ? '已选择这项' : changed ? '合并这项' : '无需处理'}
</Button>
</div>
<div className="grid gap-4 p-5 xl:grid-cols-2">
<div className="rounded-2xl border border-border/70 bg-muted/20 p-4">
<p className="text-[11px] uppercase tracking-[0.22em] text-muted-foreground">
</p>
{isTagsField ? (
Array.isArray(currentValue) && currentValue.length ? (
<div className="mt-3 flex flex-wrap gap-2">
{currentValue.map((tag) => (
<Badge key={`${field}-current-${tag}`} variant="outline">
{tag}
</Badge>
))}
</div>
) : (
<p className="mt-3 text-sm text-muted-foreground"></p>
)
) : (
<p className="mt-3 whitespace-pre-wrap break-words text-sm leading-6">
{typeof currentValue === 'string' && currentValue.trim()
? currentValue
: '未填写'}
</p>
)}
</div>
<div className="rounded-2xl border border-border/70 bg-background p-4">
<p className="text-[11px] uppercase tracking-[0.22em] text-muted-foreground">
AI
</p>
{isTagsField ? (
<div className="mt-3 space-y-3">
<Input
value={suggestedText}
onChange={(event) =>
updateMetadataSuggestedField(field, event.target.value)
}
placeholder="多个标签请用英文逗号分隔"
/>
{Array.isArray(suggestedValue) && suggestedValue.length ? (
<div className="flex flex-wrap gap-2">
{suggestedValue.map((tag) => (
<Badge
key={`${field}-suggested-${tag}`}
variant={selected && changed ? 'success' : 'secondary'}
>
{tag}
</Badge>
))}
</div>
) : (
<p className="text-sm text-muted-foreground"></p>
)}
</div>
) : isDescriptionField ? (
<Textarea
className="mt-3 min-h-28"
value={typeof suggestedValue === 'string' ? suggestedValue : ''}
onChange={(event) =>
updateMetadataSuggestedField(field, event.target.value)
}
placeholder="可以继续润色 AI 生成的摘要"
/>
) : (
<Input
className="mt-3"
value={typeof suggestedValue === 'string' ? suggestedValue : ''}
onChange={(event) =>
updateMetadataSuggestedField(field, event.target.value)
}
placeholder={`填写${metadataFieldLabel(field)}`}
/>
)}
{!isTagsField && !isDescriptionField ? (
<p className="mt-3 whitespace-pre-wrap break-words text-sm leading-6 text-muted-foreground">
{typeof suggestedValue === 'string' && suggestedValue.trim()
? suggestedValue
: '未填写'}
</p>
) : null}
{isDescriptionField ? (
<p className="mt-3 text-sm leading-6 text-muted-foreground">
{typeof suggestedValue === 'string' && suggestedValue.trim()
? suggestedValue
: '未填写'}
</p>
) : null}
</div>
</div>
</section>
)
})}
</div>
</div>
</div>
</div>
</div>
) : null}
</div>
)
}