'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(null) const [projects, setProjects] = useState([]) const [project, setProject] = useState(null) const [draft, setDraft] = useState(newDetailDocument) const [assets, setAssets] = useState([]) const [jobs, setJobs] = useState([]) const [matches, setMatches] = useState(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(null) const busyRef = useRef(false) const pendingCanvasRef = useRef(new Map>()) const idempotencyRef = useRef(new Map()) 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(`/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 const projectId = currentProjectId async function poll() { try { const detail = await detailRequest(`/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) { 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 { 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 = {}) { 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() 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

正在载入详情页工作台…

if (locked) return
{departureDialog}

详情页生成暂不可用

{error || '此功能暂仅向超级管理员开放'}

const latestJob = jobs.reduce((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
{departureDialog}

详情页生成

内测
{dirty && 有未保存修改}
{(error || notice) &&
{error || notice}{error && }
} {!config?.configured &&

模型服务尚未配置。可先准备项目资料。

}
{activeJob &&
} {unknownJob &&

模型结果待确认

{unknownJob.error || '请求已经发出,结果尚未确认。请勿重复生成。'}

{unknownJob.requestId &&

请求编号:{unknownJob.requestId}

}
} {latestFailure && !activeJob && !unknownJob &&

{jobLabel[latestFailure.kind]}未完成:{latestFailure.error || '请检查项目资料后重试'}

} {!draft.analysis && !draft.approvedAt ? <>{matches &&

匹配素材

{!matches.length &&

没有找到当前可用的素材,可直接上传产品图。

}{matches.map((match) =>

{match.variant || match.skuCode || match.productModel}

{match.assets.map((asset) => )}
)}
}
: !draft.approvedAt && draft.analysis ? <> changeDocument({ ...draft, analysis })} />
: <> !!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 &&
}} {!!exportJobs.length && } {dirty && !unavailable &&
}
}