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
303 lines
12 KiB
TypeScript
303 lines
12 KiB
TypeScript
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>
|
||
)
|
||
}
|