310 lines
25 KiB
TypeScript
310 lines
25 KiB
TypeScript
'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, { detailButtonClass, detailPrimaryClass } from './DetailProductForm'
|
||
import DetailOutlineEditor from './DetailOutlineEditor'
|
||
import DetailScreenEditor from './DetailScreenEditor'
|
||
import { actionKey, assetUrl, DetailClientError, 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 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)
|
||
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() {
|
||
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() {
|
||
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 = project || await saveDocument()
|
||
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) { 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()); 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>}
|
||
<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 && !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 || !config?.configured || !!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 || !config?.configured || !!unknownJob} onClick={approveAndGenerate}><Check className="h-4 w-4" aria-hidden="true" />确认大纲并生成</button></div></> : <><DetailScreenEditor screens={draft.screens} configured={config?.configured === true} 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 || !config?.configured || !!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()); setDirty(false); setError('') }}>放弃未保存修改</button></div>}
|
||
</div>
|
||
</div>
|
||
</main>
|
||
}
|