gongxiangp2/components/detail-generator/DetailGeneratorWorkspace.tsx

317 lines
25 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.

'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { ArrowLeft, Check, Download, Loader2, Plus, Save, WandSparkles } from 'lucide-react'
import Link from 'next/link'
import type { DetailAsset, DetailAssetKind, DetailBrandProfileRecord, DetailConfig, DetailDocument, DetailExportFormat, DetailJob, DetailProject, DetailScreen } from '@/lib/detail-generator/types'
import { reflowDetailScreen } from '@/lib/detail-generator/layout'
import DetailProductForm, { DetailModelOptions, detailButtonClass, detailPrimaryClass } from './DetailProductForm'
import DetailOutlineEditor from './DetailOutlineEditor'
import DetailScreenEditor from './DetailScreenEditor'
import { actionKey, assetUrl, DetailClientError, detailModelsAvailable, detailRequest, newDetailDocument } from './client'
import { useUnsavedDetailGuard } from './useUnsavedDetailGuard'
interface ProjectDetail { project: DetailProject; assets: DetailAsset[]; jobs: DetailJob[] }
interface MaterialMatch { materialId: string; productModel: string; skuCode: string; variant: string; assets: { id: string; source: 'asset' | 'sku'; name: string; previewUrl: string }[] }
const activeStatuses = new Set(['queued', 'running'])
const jobLabel = { analyze: '分析资料', generate: '生成详情页', regenerate: '重新生成此屏', export: '导出' }
const exportLabel = { png: '分屏 PNG', jpg: '分屏 JPG', 'long-png': '完整长图', psd: '分层 PSD' }
export default function DetailGeneratorWorkspace() {
const [config, setConfig] = useState<DetailConfig | null>(null)
const [projects, setProjects] = useState<DetailProject[]>([])
const [project, setProject] = useState<DetailProject | null>(null)
const [draft, setDraft] = useState<DetailDocument>(newDetailDocument)
const [assets, setAssets] = useState<DetailAsset[]>([])
const [jobs, setJobs] = useState<DetailJob[]>([])
const [matches, setMatches] = useState<MaterialMatch[] | null>(null)
const [dirty, setDirty] = useState(false)
const departureDialog = useUnsavedDetailGuard(dirty)
const [busy, setBusy] = useState(false)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [notice, setNotice] = useState('')
const [locked, setLocked] = useState(false)
const selectedRef = useRef<string | null>(null)
const busyRef = useRef(false)
const pendingCanvasRef = useRef(new Map<string, Map<string, string>>())
const idempotencyRef = useRef(new Map<string, string>())
const activeJob = jobs.find((job) => activeStatuses.has(job.status))
const activeJobId = activeJob?.id
const currentProjectId = project?.id
const unknownJob = jobs.find((job) => job.status === 'unknown')
const unavailable = busy || !!activeJob || locked
const analysisAvailable = detailModelsAvailable(config, draft.models, ['analysis', 'copy'])
const imageAvailable = detailModelsAvailable(config, draft.models, ['image'])
const updateProject = useCallback((next: DetailProject) => {
pendingCanvasRef.current.clear()
setProject(next)
selectedRef.current = next.id
setDraft(next.document)
setDirty(false)
setProjects((current) => [next, ...current.filter((item) => item.id !== next.id)])
}, [])
const reportError = useCallback((cause: unknown) => {
if (cause instanceof DetailClientError && [401, 403].includes(cause.status)) setLocked(true)
setError(cause instanceof Error ? cause.message : '操作未完成,请稍后重试')
}, [])
const selectProject = useCallback(async (id: string) => {
setBusy(true)
setError('')
setNotice('')
setMatches(null)
try {
const detail = await detailRequest<ProjectDetail>(`/projects/${encodeURIComponent(id)}`)
updateProject(detail.project)
setAssets(detail.assets)
setJobs(detail.jobs)
const url = new URL(window.location.href)
url.searchParams.set('project', id)
window.history.replaceState(null, '', url)
} catch (cause) { reportError(cause) } finally { setBusy(false) }
}, [reportError, updateProject])
useEffect(() => {
let cancelled = false
async function load() {
try {
const result = await detailRequest<{ config: DetailConfig; projects: DetailProject[] }>('')
if (cancelled) return
setConfig(result.config)
if (!selectedRef.current) setDraft(newDetailDocument(result.config.defaults))
setProjects(result.projects)
const requested = new URLSearchParams(window.location.search).get('project')
if (requested && result.projects.some((item) => item.id === requested)) await selectProject(requested)
} catch (cause) { if (!cancelled) reportError(cause) } finally { if (!cancelled) setLoading(false) }
}
void load()
return () => { cancelled = true }
}, [reportError, selectProject])
useEffect(() => {
if (!activeJobId || !currentProjectId || locked) return
let cancelled = false
let timer: ReturnType<typeof setTimeout>
const projectId = currentProjectId
async function poll() {
try {
const detail = await detailRequest<ProjectDetail>(`/projects/${encodeURIComponent(projectId)}`)
if (cancelled || selectedRef.current !== projectId) return
updateProject(detail.project)
setAssets(detail.assets)
setJobs(detail.jobs)
if (detail.jobs.some((job) => activeStatuses.has(job.status))) timer = setTimeout(poll, 2500)
} catch (cause) {
if (cancelled) return
reportError(cause)
if (!(cause instanceof DetailClientError && [401, 403].includes(cause.status))) timer = setTimeout(poll, 5000)
}
}
timer = setTimeout(poll, 1000)
return () => { cancelled = true; clearTimeout(timer) }
}, [activeJobId, currentProjectId, locked, reportError, updateProject])
function changeDocument(next: DetailDocument) {
if (JSON.stringify(next) === JSON.stringify(draft)) return
setDraft(next); setDirty(true); setNotice(''); setError('')
}
async function run(action: () => Promise<void>) {
if (unavailable || busyRef.current) return
busyRef.current = true
setBusy(true)
setError('')
setNotice('')
try { await action() } catch (cause) { reportError(cause) } finally { busyRef.current = false; setBusy(false) }
}
async function saveDocument(document = draft): Promise<DetailProject> {
const normalized = { ...document, screens: document.screens.map(flushCanvasScreen), name: document.name.trim() || document.product.name.trim() || document.product.identifier.trim() || '未命名项目' }
const result = project
? await detailRequest<{ project: DetailProject }>(`/projects/${encodeURIComponent(project.id)}`, { method: 'PATCH', body: JSON.stringify({ document: normalized, revision: project.revision }) })
: await detailRequest<{ project: DetailProject }>('', { method: 'POST', body: JSON.stringify({ document: normalized }) })
updateProject(result.project)
return result.project
}
async function queueJob(saved: DetailProject, path: string, identity: string, extra: Record<string, unknown> = {}) {
const key = `${saved.id}:${identity}`
const idempotencyKey = idempotencyRef.current.get(key) || actionKey()
idempotencyRef.current.set(key, idempotencyKey)
const result = await detailRequest<{ job: DetailJob }>(`/projects/${encodeURIComponent(saved.id)}${path}`, { method: 'POST', body: JSON.stringify({ ...extra, idempotencyKey }) })
idempotencyRef.current.delete(key)
setJobs((current) => [result.job, ...current.filter((item) => item.id !== result.job.id)])
}
function analyze() {
if (!analysisAvailable) return
void run(async () => {
if (!draft.product.name.trim()) throw new Error('请填写产品名称')
if (!draft.product.facts.trim() && !draft.product.documentAssetIds.length) throw new Error('请填写真实产品信息或上传产品资料')
if (!draft.product.assetIds.length) throw new Error('请上传产品图,或查找并选用已有素材')
if (!draft.brand.name.trim()) throw new Error('请选择或填写品牌')
const saved = await saveDocument({ ...draft, analysis: null, approvedAt: null, screens: [] })
await queueJob(saved, '/analyze', 'analyze')
})
}
function approveAndGenerate() {
if (!imageAvailable) return
void run(async () => {
if (!draft.analysis?.screens.length) throw new Error('请至少保留一屏大纲')
if (draft.analysis.screens.some((screen) => !screen.title.trim() || !screen.imagePrompt.trim())) throw new Error('请补全各屏标题与画面要求')
const saved = dirty || !project ? await saveDocument() : project
const result = await detailRequest<{ project: DetailProject }>(`/projects/${encodeURIComponent(saved.id)}/approve`, { method: 'POST', body: JSON.stringify({ analysis: draft.analysis, revision: saved.revision }) })
updateProject(result.project)
await queueJob(result.project, '/generate', 'generate')
})
}
function upload(files: File[], kind: DetailAssetKind) {
void run(async () => {
let saved = dirty || !project ? await saveDocument() : project
for (const file of files) {
const form = new FormData()
form.set('file', file)
form.set('kind', kind)
form.set('revision', String(saved.revision))
const result = await detailRequest<{ asset: DetailAsset; project: DetailProject }>(`/projects/${encodeURIComponent(saved.id)}/assets`, { method: 'POST', body: form })
saved = result.project
updateProject(saved)
setAssets((current) => [...current, result.asset])
}
})
}
function removeAsset(id: string) {
const document = structuredClone(draft)
document.product.assetIds = document.product.assetIds.filter((item) => item !== id)
document.product.referenceAssetIds = document.product.referenceAssetIds.filter((item) => item !== id)
document.product.documentAssetIds = document.product.documentAssetIds.filter((item) => item !== id)
if (document.brand.logoAssetId === id) delete document.brand.logoAssetId
changeDocument(document)
}
function findMaterials() {
void run(async () => {
const query = new URLSearchParams({ identifierType: draft.product.identifierType, identifier: draft.product.identifier })
const result = await detailRequest<{ matches: MaterialMatch[] }>(`/materials?${query}`)
setMatches(result.matches)
})
}
function importMaterial(match: MaterialMatch, asset: MaterialMatch['assets'][number]) {
void run(async () => {
const saved = dirty || !project ? await saveDocument() : project
const result = await detailRequest<{ project: DetailProject; asset: DetailAsset }>(`/projects/${encodeURIComponent(saved.id)}/materials`, { method: 'POST', body: JSON.stringify({ materialId: match.materialId, source: asset.source, sourceId: asset.id, revision: saved.revision }) })
updateProject(result.project)
setAssets((current) => [...current, result.asset])
setNotice('已添加产品图')
})
}
function changeScreen(screen: DetailScreen) {
const previous = draft.screens.find((current) => current.id === screen.id)
const pending = pendingCanvasRef.current.get(screen.id)
// Retain native edits through a save click until the server accepts them.
// A subsequent inspector edit intentionally supersedes that layer instead.
for (const layer of screen.texts) {
if (layer.text !== previous?.texts.find((current) => current.id === layer.id)?.text && pending?.has(layer.id) && pending.get(layer.id) !== layer.text) pending.delete(layer.id)
}
if (JSON.stringify(screen) === JSON.stringify(draft.screens.find((current) => current.id === screen.id))) return
try {
const reflowed = reflowDetailScreen(screen)
changeDocument({ ...draft, screens: draft.screens.map((item) => item.id === screen.id ? reflowed : item) })
} catch (cause) {
setDraft({ ...draft, screens: draft.screens.map((item) => item.id === screen.id ? screen : item) })
setDirty(true)
reportError(cause)
}
}
function saveScreen(screen: DetailScreen) {
void run(async () => {
if (!project) return
const latestScreen = flushCanvasScreen(screen)
const result = await detailRequest<{ project: DetailProject }>(`/projects/${encodeURIComponent(project.id)}/screens/${encodeURIComponent(screen.id)}`, { method: 'PATCH', body: JSON.stringify({ screen: latestScreen, revision: project.revision }) })
updateProject(result.project)
setNotice('修改已保存')
})
}
function replaceImage(screen: DetailScreen, file: File) {
void run(async () => {
if (!project) return
const form = new FormData()
form.set('file', file)
form.set('kind', 'product')
form.set('purpose', 'screen-replacement')
form.set('revision', String(project.revision))
const uploaded = await detailRequest<{ asset: DetailAsset; project: DetailProject }>(`/projects/${encodeURIComponent(project.id)}/assets`, { method: 'POST', body: form })
setAssets((current) => [...current, uploaded.asset])
const image = screen.image ? { ...screen.image, assetId: uploaded.asset.id } : { assetId: uploaded.asset.id, x: 0, y: 300, width: screen.width, height: screen.height - 300 }
// Keep edited text in the draft if attaching the uploaded image conflicts.
setProject(uploaded.project)
setProjects((current) => [uploaded.project, ...current.filter((item) => item.id !== uploaded.project.id)])
const result = await detailRequest<{ project: DetailProject }>(`/projects/${encodeURIComponent(project.id)}/screens/${encodeURIComponent(screen.id)}`, { method: 'PATCH', body: JSON.stringify({ screen: { ...screen, image }, revision: uploaded.project.revision }) })
updateProject(result.project)
setNotice('图片已替换')
})
}
function regenerate(screenId: string) { if (imageAvailable) void run(async () => { if (project) await queueJob(project, `/screens/${encodeURIComponent(screenId)}`, `regenerate:${screenId}`) }) }
function recordPendingText(screenId: string, layerId: string, text: string) {
const pending = pendingCanvasRef.current.get(screenId) || new Map<string, string>()
pending.set(layerId, text)
pendingCanvasRef.current.set(screenId, pending)
}
function flushCanvasScreen(screen: DetailScreen): DetailScreen {
const pending = pendingCanvasRef.current.get(screen.id)
if (!pending) return screen
const texts = screen.texts.map((layer) => pending.has(layer.id) ? { ...layer, text: pending.get(layer.id)! } : layer)
return reflowDetailScreen({ ...screen, texts, title: texts[0]?.text ?? screen.title, body: texts[1]?.text ?? screen.body })
}
function saveBrand() {
void run(async () => {
const { logoAssetId: _logo, ...profile } = draft.brand
const saved = config?.brandProfiles.find((item) => item.profile.name === profile.name)
const result = await detailRequest<{ profile: DetailBrandProfileRecord }>('/brand-profiles', { method: 'PUT', body: JSON.stringify({ profile, ...(saved ? { revision: saved.revision } : {}) }) })
setConfig((current) => current ? { ...current, brandProfiles: [result.profile, ...current.brandProfiles.filter((item) => item.id !== result.profile.id)] } : current)
setNotice('品牌草案已保存')
})
}
function exportProject(format: DetailExportFormat) { void run(async () => { if (project) await queueJob(project, '/exports', `export:${format}`, { format }) }) }
function newProject() {
pendingCanvasRef.current.clear()
setProject(null); selectedRef.current = null; setDraft(newDetailDocument(config?.defaults)); setAssets([]); setJobs([]); setDirty(false); setMatches(null); setError(''); setNotice('')
window.history.replaceState(null, '', '/assets/detail-generator')
}
if (loading) return <main className="px-4 py-6 sm:px-6"><p role="status" className="text-sm text-[#66758b]">正在载入详情页工作台…</p></main>
if (locked) return <main className="mx-auto max-w-3xl px-4 py-8">{departureDialog}<h1 className="text-xl font-semibold">详情页生成暂不可用</h1><p role="alert" className="mt-3 text-sm text-[#66758b]">{error || '此功能暂仅向超级管理员开放'}</p><Link href="/assets/material-library" className={`${detailButtonClass} mt-5`}><ArrowLeft className="h-4 w-4" aria-hidden="true" />素材资产库</Link></main>
const latestJob = jobs.reduce<DetailJob | undefined>((latest, job) => !latest || job.createdAt > latest.createdAt ? job : latest, undefined)
const latestFailure = latestJob?.status === 'failed' ? latestJob : undefined
const exportJobs = jobs.filter((job) => job.kind === 'export' && job.status === 'succeeded' && typeof job.checkpoint.exportAssetId === 'string').slice(0, 3)
return <main className="detail-generator-workspace mx-auto max-w-[1600px] px-4 py-5 selection:bg-[#dbeafe] selection:text-[#101828] sm:px-6">
{departureDialog}
<div className="mb-5 flex flex-wrap items-center justify-between gap-3"><div className="flex items-center gap-3"><h1 className="text-xl font-bold tracking-[-0.02em]">详情页生成</h1><span className="rounded-md bg-[#edf5ff] px-2 py-1 text-xs font-medium text-[#176fc1]">内测</span></div><div className="flex items-center gap-2">{dirty && <span role="status" className="hidden text-xs text-[#66758b] sm:inline">有未保存修改</span>}<button type="button" className={detailButtonClass} disabled={unavailable || !dirty} onClick={() => void run(async () => { await saveDocument(); setNotice('项目已保存') })}><Save className="h-4 w-4" aria-hidden="true" />保存</button><button type="button" className={detailButtonClass} disabled={unavailable || dirty} onClick={newProject}><Plus className="h-4 w-4" aria-hidden="true" />新建项目</button></div></div>
{(error || notice) && <div role={error ? 'alert' : 'status'} className={`mb-4 flex flex-wrap items-center gap-3 rounded-lg px-3 py-2.5 text-sm ${error ? 'bg-[#fff0ee] text-[#b42318]' : 'bg-[#e9f8f2] text-[#087052]'}`}><span className="min-w-0 flex-1 break-words">{error || notice}</span>{error && <button type="button" onClick={() => setError('')} className="min-h-9 rounded-md px-2 font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]">关闭</button>}</div>}
{!config?.configured && <p role="status" className="mb-4 text-sm text-[#854a0e]">模型服务尚未配置。可先准备项目资料。</p>}
{config?.configured && (!analysisAvailable || !imageAvailable) && <p role="status" className="mb-4 text-sm text-[#854a0e]">项目模型当前不可用。可修改模型选择,已有内容仍可编辑和导出。</p>}
<div className="grid min-w-0 gap-5 lg:grid-cols-[200px_minmax(0,1fr)]">
<aside aria-label="我的详情页项目" className="min-w-0"><h2 className="mb-2 text-sm font-semibold text-[#4f6077]">我的项目</h2><div className="flex gap-2 overflow-x-auto pb-2 lg:max-h-[calc(100vh-210px)] lg:flex-col lg:overflow-y-auto lg:pb-0">{!projects.length && <p className="py-3 text-sm text-[#66758b]">还没有项目</p>}{projects.map((item) => <button type="button" key={item.id} disabled={unavailable || dirty} aria-current={project?.id === item.id ? 'page' : undefined} onClick={() => void selectProject(item.id)} className={`min-h-14 w-40 shrink-0 rounded-lg px-3 py-2 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] disabled:cursor-not-allowed disabled:opacity-60 lg:w-full ${project?.id === item.id ? 'bg-[#e6f4ff] text-[#0050cb]' : 'text-[#344054] hover:bg-[#eef2f6]'}`}><span className="block truncate text-sm font-medium" title={item.document.name}>{item.document.name}</span><span className="mt-1 block truncate text-xs text-[#66758b]">{item.document.brand.name || '品牌未填写'}{item.document.screens.length ? ` · ${item.document.screens.length} 屏` : ' · 草稿'}</span></button>)}</div></aside>
<div className="min-w-0 rounded-xl border border-[#e2e8f0] bg-white p-4 sm:p-5">
{activeJob && <div role="status" aria-live="polite" className="mb-5 flex items-center gap-2 border-b border-[#e2e8f0] pb-4 text-sm text-[#176fc1]"><Loader2 className="h-4 w-4 motion-safe:animate-spin" aria-hidden="true" />{jobLabel[activeJob.kind]}{activeJob.status === 'queued' ? ' · 等待执行' : ' · 进行中'}{activeJob.kind === 'generate' && draft.analysis ? ` · ${draft.screens.filter((screen) => screen.image).length}/${draft.analysis.screens.length} 屏` : ''}</div>}
{unknownJob && <div role="alert" className="mb-5 rounded-lg bg-[#fffaeb] p-3 text-sm text-[#854a0e]"><p className="font-semibold">模型结果待确认</p><p className="mt-1">{unknownJob.error || '请求已经发出,结果尚未确认。请勿重复生成。'}</p>{unknownJob.requestId && <p className="mt-2 break-all text-xs">请求编号:{unknownJob.requestId}</p>}</div>}
{latestFailure && !activeJob && !unknownJob && <p role="alert" className="mb-4 rounded-lg bg-[#fff0ee] p-3 text-sm text-[#b42318]">{jobLabel[latestFailure.kind]}未完成:{latestFailure.error || '请检查项目资料后重试'}</p>}
{draft.analysis && <div className="mb-5"><DetailModelOptions document={draft} config={config} busy={unavailable} onChange={changeDocument} /></div>}
{!draft.analysis && !draft.approvedAt ? <><DetailProductForm document={draft} config={config} assets={assets} busy={unavailable} onChange={changeDocument} onUpload={upload} onRemoveAsset={removeAsset} onFindMaterials={findMaterials} onSaveBrand={saveBrand} />{matches && <section aria-label="匹配的产品素材" className="mt-5 border-t border-[#e2e8f0] pt-4"><div className="mb-3 flex items-center justify-between"><h2 className="text-sm font-semibold">匹配素材</h2><button type="button" onClick={() => setMatches(null)} className="min-h-9 rounded-md px-2 text-sm text-[#66758b] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]">收起</button></div>{!matches.length && <p className="text-sm text-[#66758b]">没有找到当前可用的素材,可直接上传产品图。</p>}{matches.map((match) => <div key={`${match.materialId}:${match.skuCode}`} className="mb-4"><p className="mb-2 text-sm font-medium">{match.variant || match.skuCode || match.productModel}</p><div className="flex flex-wrap gap-2">{match.assets.map((asset) => <button type="button" key={`${asset.source}:${asset.id}`} disabled={unavailable} onClick={() => importMaterial(match, asset)} className="w-28 rounded-lg border border-[#e2e8f0] p-2 text-left hover:border-[#1677ff] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] disabled:opacity-50"><img src={asset.previewUrl} alt={asset.name} className="h-20 w-full object-contain" /><span className="mt-1 block truncate text-xs text-[#4f6077]">{asset.name}</span><span className="mt-1 block text-xs font-medium text-[#176fc1]">使用图片</span></button>)}</div></div>)}</section>}<div className="mt-6 flex justify-end border-t border-[#e2e8f0] pt-4"><button type="button" className={detailPrimaryClass} disabled={unavailable || !analysisAvailable || !!unknownJob} onClick={analyze}>{busy ? <Loader2 className="h-4 w-4 motion-safe:animate-spin" aria-hidden="true" /> : <WandSparkles className="h-4 w-4" aria-hidden="true" />}分析卖点与大纲</button></div></> : !draft.approvedAt && draft.analysis ? <><DetailOutlineEditor analysis={draft.analysis} busy={unavailable} onChange={(analysis) => changeDocument({ ...draft, analysis })} /><div className="mt-5 flex flex-wrap items-center justify-between gap-3 border-t border-[#e2e8f0] pt-4"><button type="button" className={detailButtonClass} disabled={unavailable} onClick={() => changeDocument({ ...draft, analysis: null })}><ArrowLeft className="h-4 w-4" aria-hidden="true" />修改资料</button><button type="button" className={detailPrimaryClass} disabled={unavailable || !imageAvailable || !!unknownJob} onClick={approveAndGenerate}><Check className="h-4 w-4" aria-hidden="true" />确认大纲并生成</button></div></> : <><DetailScreenEditor screens={draft.screens} configured={imageAvailable} complete={draft.screens.length === draft.analysis?.screens.length && draft.screens.every((screen) => !!screen.image)} busy={unavailable || !!unknownJob} dirty={dirty} onChange={changeScreen} onUnsavedEdit={() => setDirty(true)} onPendingTextChange={recordPendingText} onSave={saveScreen} onReplaceImage={replaceImage} onRegenerate={regenerate} onExport={exportProject} />{draft.screens.filter((screen) => screen.image).length < (draft.analysis?.screens.length || 1) && !activeJob && <div className="mt-4 flex justify-center"><button type="button" className={detailPrimaryClass} disabled={unavailable || !imageAvailable || !!unknownJob} onClick={() => void run(async () => { if (project) await queueJob(project, '/generate', 'generate') })}><WandSparkles className="h-4 w-4" aria-hidden="true" />{draft.screens.some((screen) => screen.image) ? '继续生成剩余屏' : '生成详情页'}</button></div>}</>}
{!!exportJobs.length && <div aria-label="导出文件" className="mt-5 flex flex-wrap gap-2 border-t border-[#e2e8f0] pt-4">{exportJobs.map((job) => <a key={job.id} href={assetUrl(String(job.checkpoint.exportAssetId))} download className={detailButtonClass}><Download className="h-4 w-4" aria-hidden="true" />下载{exportLabel[job.payload.format as DetailExportFormat] || '导出文件'}</a>)}</div>}
{dirty && !unavailable && <div className="mt-4 flex justify-end"><button type="button" className="min-h-9 rounded-md px-2 text-xs text-[#66758b] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]" onClick={() => { pendingCanvasRef.current.clear(); setDraft(project?.document || newDetailDocument(config?.defaults)); setDirty(false); setError('') }}>放弃未保存修改</button></div>}
</div>
</div>
</main>
}