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
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
3550 lines
135 KiB
TypeScript
3550 lines
135 KiB
TypeScript
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 风格编辑器维护 Markdown,支持整文件夹导入、AI 元数据对比回填、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>
|
||
)
|
||
}
|