Files
termi-blog/admin/src/pages/post-polish-page.tsx
limitcool 7de4ddc3ee
All checks were successful
docker-images / build-and-push (admin, admin, termi-astro-admin, admin/Dockerfile) (push) Successful in 43s
docker-images / build-and-push (backend, backend, termi-astro-backend, backend/Dockerfile) (push) Successful in 25m9s
docker-images / build-and-push (frontend, frontend, termi-astro-frontend, frontend/Dockerfile) (push) Successful in 51s
feat: refresh content workflow and verification settings
2026-04-01 18:47:17 +08:00

303 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Bot, CheckCheck, RefreshCcw, WandSparkles } from 'lucide-react'
import { startTransition, useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner'
import {
configureMonaco,
editorTheme,
sharedOptions,
} from '@/components/markdown-workbench'
import { LazyDiffEditor } from '@/components/lazy-monaco'
import { MarkdownPreview } from '@/components/markdown-preview'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { adminApi, ApiError } from '@/lib/api'
import { computeDiffHunks, applySelectedDiffHunks } from '@/lib/markdown-merge'
import {
loadDraftWindowSnapshot,
savePolishWindowResult,
type DraftWindowSnapshot,
} from '@/lib/post-draft-window'
type PolishTarget = 'editor' | 'create'
function getDraftKey() {
if (typeof window === 'undefined') {
return null
}
return new URLSearchParams(window.location.search).get('draftKey')
}
function getTarget(): PolishTarget {
if (typeof window === 'undefined') {
return 'editor'
}
const value = new URLSearchParams(window.location.search).get('target')
return value === 'create' ? 'create' : 'editor'
}
function buildApplyMessage(draftKey: string, markdown: string, target: PolishTarget) {
return {
type: 'termi-admin-post-polish-apply',
draftKey,
markdown,
target,
}
}
export function PostPolishPage() {
const draftKey = getDraftKey()
const target = getTarget()
const [snapshot, setSnapshot] = useState<DraftWindowSnapshot | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [polishing, setPolishing] = useState(false)
const [polishedMarkdown, setPolishedMarkdown] = useState('')
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
useEffect(() => {
const draft = loadDraftWindowSnapshot(draftKey)
if (!draft) {
setError('没有找到要润色的草稿快照,请从文章编辑页重新打开 AI 润色窗口。')
} else {
startTransition(() => {
setSnapshot(draft)
})
}
setLoading(false)
}, [draftKey])
const originalMarkdown = snapshot?.markdown ?? ''
const hunks = useMemo(
() => (polishedMarkdown ? computeDiffHunks(originalMarkdown, polishedMarkdown) : []),
[originalMarkdown, polishedMarkdown],
)
const mergedMarkdown = useMemo(
() => applySelectedDiffHunks(originalMarkdown, hunks, selectedIds),
[hunks, originalMarkdown, selectedIds],
)
const applyAll = () => {
setSelectedIds(new Set(hunks.map((hunk) => hunk.id)))
}
const keepOriginal = () => {
setSelectedIds(new Set())
}
const applyToParent = () => {
if (!draftKey) {
toast.error('当前窗口缺少草稿标识,无法回填。')
return
}
const result = savePolishWindowResult(draftKey, mergedMarkdown, target)
window.opener?.postMessage(buildApplyMessage(draftKey, mergedMarkdown, target), window.location.origin)
toast.success('已把 AI 润色结果回填到原编辑器。')
return result
}
return (
<div className="min-h-screen bg-background px-4 py-6 text-foreground lg:px-6">
<div className="mx-auto max-w-[1560px] space-y-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div className="space-y-3">
<Badge variant="secondary">AI </Badge>
<div>
<h1 className="text-3xl font-semibold tracking-tight">
{snapshot?.title || 'AI 润色与选择性合并'}
</h1>
<p className="mt-2 text-sm leading-6 text-muted-foreground">
稿 AI 稿
</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-3">
<Button
variant="outline"
disabled={!snapshot || polishing}
onClick={async () => {
if (!snapshot) {
return
}
try {
setPolishing(true)
const result = await adminApi.polishPostMarkdown(snapshot.markdown)
const nextHunks = computeDiffHunks(snapshot.markdown, result.polished_markdown)
startTransition(() => {
setPolishedMarkdown(result.polished_markdown)
setSelectedIds(new Set(nextHunks.map((hunk) => hunk.id)))
})
toast.success(`AI 已生成润色稿,共识别 ${nextHunks.length} 个改动块。`)
} catch (requestError) {
toast.error(requestError instanceof ApiError ? requestError.message : 'AI 润色失败。')
} finally {
setPolishing(false)
}
}}
>
<Bot className="h-4 w-4" />
{polishing ? '润色中...' : polishedMarkdown ? '重新生成润色稿' : '生成 AI 润色稿'}
</Button>
<Button variant="outline" disabled={!hunks.length} onClick={applyAll}>
<CheckCheck className="h-4 w-4" />
</Button>
<Button variant="outline" disabled={!hunks.length} onClick={keepOriginal}>
<RefreshCcw className="h-4 w-4" />
</Button>
<Button disabled={!hunks.length} onClick={applyToParent}>
<WandSparkles className="h-4 w-4" />
</Button>
</div>
</div>
{loading ? (
<Card>
<CardContent className="py-12 text-sm text-muted-foreground">稿...</CardContent>
</Card>
) : error ? (
<Card>
<CardHeader>
<CardTitle>AI </CardTitle>
<CardDescription>{error}</CardDescription>
</CardHeader>
</Card>
) : snapshot ? (
<div className="grid gap-6 xl:grid-cols-[1.14fr_0.86fr]">
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle> vs </CardTitle>
<CardDescription> AI </CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-wrap items-center gap-3">
<Badge variant="secondary"> {hunks.length}</Badge>
<Badge variant="success"> {selectedIds.size}</Badge>
<Badge variant="outline"> {target === 'create' ? '新建草稿' : '现有文章'}</Badge>
</div>
<div className="overflow-hidden rounded-[28px] border border-slate-800 bg-[#1e1e1e]">
<div className="flex items-center justify-between border-b border-slate-800 bg-[#141414] px-4 py-2 text-[11px] uppercase tracking-[0.18em] text-slate-400">
<span>稿</span>
<span></span>
</div>
<div className="h-[560px]">
<LazyDiffEditor
height="100%"
language="markdown"
original={originalMarkdown}
modified={mergedMarkdown}
originalModelPath={`${snapshot.path}#ai-original`}
modifiedModelPath={`${snapshot.path}#ai-merged`}
keepCurrentOriginalModel
keepCurrentModifiedModel
theme={editorTheme}
beforeMount={configureMonaco}
options={{
...sharedOptions,
originalEditable: false,
readOnly: true,
renderSideBySide: true,
}}
/>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<div className="h-[420px] overflow-hidden rounded-[28px] border border-slate-200 bg-white">
<MarkdownPreview markdown={mergedMarkdown || originalMarkdown} />
</div>
</CardContent>
</Card>
</div>
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>
diff
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{!polishedMarkdown ? (
<div className="rounded-3xl border border-dashed border-border/70 px-5 py-10 text-sm text-muted-foreground">
AI 稿
</div>
) : hunks.length ? (
hunks.map((hunk, index) => {
const accepted = selectedIds.has(hunk.id)
return (
<div
key={hunk.id}
className={`rounded-3xl border p-4 transition ${
accepted
? 'border-emerald-500/30 bg-emerald-500/10'
: 'border-border/70 bg-background/60'
}`}
>
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-sm font-medium"> {index + 1}</p>
<p className="mt-1 text-xs leading-5 text-muted-foreground">
{hunk.originalStart}-{Math.max(hunk.originalEnd, hunk.originalStart - 1)}
稿 {hunk.modifiedStart}-{Math.max(hunk.modifiedEnd, hunk.modifiedStart - 1)}
</p>
</div>
<Button
size="sm"
variant={accepted ? 'default' : 'outline'}
onClick={() => {
setSelectedIds((current) => {
const next = new Set(current)
if (next.has(hunk.id)) {
next.delete(hunk.id)
} else {
next.add(hunk.id)
}
return next
})
}}
>
{accepted ? '已采用' : '采用这块'}
</Button>
</div>
<p className="mt-3 rounded-2xl border border-border/60 bg-background/70 px-3 py-2 text-xs leading-6 text-muted-foreground">
{hunk.preview}
</p>
</div>
)
})
) : (
<div className="rounded-3xl border border-border/70 px-5 py-10 text-sm text-muted-foreground">
AI
</div>
)}
</CardContent>
</Card>
</div>
</div>
) : null}
</div>
</div>
)
}