diff --git a/frontend/src/app/history/components.tsx b/frontend/src/app/history/components.tsx index f6160d2..6f6466f 100644 --- a/frontend/src/app/history/components.tsx +++ b/frontend/src/app/history/components.tsx @@ -116,6 +116,14 @@ export function HistoryTable({ tasks }: { tasks: TaskListItem[] }) { > 查看 + {task.status !== 'failed' && ( + + 排图 + + )} {task.status === 'approved' && } diff --git a/frontend/src/app/tasks/[id]/images/page.tsx b/frontend/src/app/tasks/[id]/images/page.tsx index a076f29..892ac74 100644 --- a/frontend/src/app/tasks/[id]/images/page.tsx +++ b/frontend/src/app/tasks/[id]/images/page.tsx @@ -1,16 +1,22 @@ 'use client'; -// 屏4 挑图:0/N异步电影 + 平铺选图 + 单批重试(一期=整体重生) + 能离开 +// 屏4 挑图:0/N异步电影 + 按A/B/C分组选图 + 点图放大 + 单批重试 + 能离开 +import { useState } from 'react'; import { useRouter, useParams } from 'next/navigation'; import { ProgressBar } from '@/components/layout/ProgressBar'; import { FlywheelBannerFromSse } from '@/components/FlywheelBanner'; import { ImageProgressCardSkeleton } from '@/components/tasks/ImageProgressCard'; import { ImageProgressHeader } from '@/components/tasks/ImageProgressHeader'; import { ImageActionBar } from '@/components/tasks/ImageActionBar'; +import { ImageLightbox } from '@/components/tasks/ImageLightbox'; +import { ImageStrategyGroup, ROLE_LABEL } from '@/components/tasks/ImageStrategyGroup'; +import { RegenControls, RegenTarget } from '@/components/tasks/RegenControls'; import { SseStatusBar } from '@/components/tasks/SseStatusBar'; import { FatalErrorBanner } from '@/components/tasks/FatalErrorBanner'; +import { RejectReasonBanner } from '@/components/tasks/RejectReasonBanner'; import { useSse } from '@/hooks/useSse'; import { useGenerationStore } from '@/stores/generationStore'; import { useTaskStore } from '@/stores/taskStore'; +import { ImageCandidate } from '@/types/index'; import { api } from '@/lib/api'; export default function ImageSelectionPage() { @@ -21,17 +27,45 @@ export default function ImageSelectionPage() { const { imageCandidates, imageDone, imageTotal, failedBatches, flywheelInjected } = useGenerationStore(); const { selectedImageIds, toggleImageSelection } = useTaskStore(); const { connectionStatus, reconnectAttempt } = useSse(taskId); + const [zoom, setZoom] = useState(null); + const [regenTarget, setRegenTarget] = useState(null); const pendingCount = imageTotal - imageDone; const allDone = imageTotal > 0 && pendingCount === 0; + // 按套(A/B/C)分组展示;R2 同套同角色去重,只留最新一张(candidate_id 最大=最新重生) + function latestByRole(list: ImageCandidate[]): ImageCandidate[] { + const byRole = new Map(); + for (const c of list) { + const prev = byRole.get(c.role); + if (!prev || c.candidate_id > prev.candidate_id) byRole.set(c.role, c); + } + return Array.from(byRole.values()); + } + const groups: Array<['A' | 'B' | 'C', ImageCandidate[]]> = (['A', 'B', 'C'] as const).map( + (s) => [s, latestByRole(imageCandidates.filter((c) => c.strategy === s))] + ); + async function handleRetry(_batchRole: string) { - // 一期 regenerate = 整体重生,契约无 batch 字段,不发 body - // 二期按批重生时再扩展,_batchRole 目前仅作入参占位(UI 提示用) + // 一期 regenerate = 整体重生(R2 已扩展按角色单批重生,见 handleRegen) if (!taskId) return; await api.post(`/api/v1/tasks/${taskId}/regenerate`); } + // R2 单张/单套重生 + 可选人工提示词 + async function handleRegen(target: RegenTarget, customPrompt: string) { + if (!taskId) return; + await api.post(`/api/v1/tasks/${taskId}/regenerate`, { + strategy: target.strategy, + role: target.role, + custom_prompt: customPrompt || undefined, + }); + } + + function openRegen(strategy: string, role: string | undefined, label: string) { + setRegenTarget({ strategy: strategy as 'A' | 'B' | 'C', role, label }); + } + async function handleProceedToConfirm() { if (!taskId || selectedImageIds.length === 0) return; for (const candidateId of selectedImageIds) { @@ -43,6 +77,7 @@ export default function ImageSelectionPage() { return (
+ {flywheelInjected && ( - {/* 图片网格(谁好谁先冒) */} -
- {imageCandidates.length === 0 - ? Array.from({ length: 5 }).map((_, i) => ) - : imageCandidates.map((c) => ( -
toggleImageSelection(c.candidate_id)} - className={`relative rounded-xl overflow-hidden border cursor-pointer transition-all - ${selectedImageIds.includes(c.candidate_id) ? 'border-brand-orange ring-2 ring-brand-orange' : 'border-border-default hover:border-brand-orange/50'}`} - aria-label={`图片 ${c.role},${selectedImageIds.includes(c.candidate_id) ? '已选中' : '未选中'}`} - > - {c.role} -

{c.role}

-
- ))} -
+ {imageCandidates.length === 0 ? ( +
+ {Array.from({ length: 6 }).map((_, i) => )} +
+ ) : ( + groups.map(([s, list]) => + list.length > 0 ? ( + + ) : null + ) + )} {/* 失败批次提示 */} {failedBatches.length > 0 && allDone && (
有 {failedBatches.length} 批没出来。 {failedBatches.filter((b) => b.retryable).map((b) => ( + className="ml-2 underline" aria-label={`重试${ROLE_LABEL[b.batch] || b.batch}`}> + ⟳ 重试{ROLE_LABEL[b.batch] || b.batch} + ))}
)} @@ -83,6 +123,16 @@ export default function ImageSelectionPage() { )}
+ setZoom(null)} + /> + setRegenTarget(null)} + onSubmit={handleRegen} + /> ); } diff --git a/frontend/src/app/tasks/[id]/text/page.tsx b/frontend/src/app/tasks/[id]/text/page.tsx index d96c4d5..6266a38 100644 --- a/frontend/src/app/tasks/[id]/text/page.tsx +++ b/frontend/src/app/tasks/[id]/text/page.tsx @@ -7,6 +7,7 @@ import { FlywheelBannerFromSse } from '@/components/FlywheelBanner'; import { TextCandidateCard, TextCandidateCardSkeleton } from '@/components/tasks/TextCandidateCard'; import { SseStatusBar } from '@/components/tasks/SseStatusBar'; import { FatalErrorBanner } from '@/components/tasks/FatalErrorBanner'; +import { RejectReasonBanner } from '@/components/tasks/RejectReasonBanner'; import { useSse } from '@/hooks/useSse'; import { useGenerationStore } from '@/stores/generationStore'; import { useTaskStore } from '@/stores/taskStore'; @@ -17,7 +18,7 @@ export default function TextSelectionPage() { const taskId = params?.id ? Number(params.id) : null; const router = useRouter(); - const { textCandidates, textDone, textTotal, flywheelInjected } = useGenerationStore(); + const { textCandidates, textDone, textTotal, flywheelInjected, patchTextCandidateContent } = useGenerationStore(); const { selectedTextIds, toggleTextSelection, setCurrentTask } = useTaskStore(); const { connectionStatus, reconnectAttempt } = useSse(taskId); @@ -25,6 +26,15 @@ export default function TextSelectionPage() { if (taskId) setCurrentTask(taskId, 'generating'); }, [taskId]); + async function handleSaveEdit(candidateId: number, content: string) { + if (!taskId) return; + // D1 改稿=飞轮最强信号 text_edit,后端回写 + record_signal + await api.post(`/api/v1/tasks/${taskId}/text-candidates/${candidateId}/edit`, { + candidate_id: candidateId, content, + }); + patchTextCandidateContent(candidateId, content); + } + async function handleProceedToImages() { if (!taskId || selectedTextIds.length === 0) return; for (const candidateId of selectedTextIds) { @@ -39,6 +49,7 @@ export default function TextSelectionPage() { return (
+ {flywheelInjected && ( )} @@ -72,6 +83,7 @@ export default function TextSelectionPage() { candidate={c} selected={selectedTextIds.includes(c.candidate_id)} onToggle={toggleTextSelection} + onSaveEdit={handleSaveEdit} /> ))}
diff --git a/frontend/src/app/tasks/new/page.tsx b/frontend/src/app/tasks/new/page.tsx index 361005c..6208e09 100644 --- a/frontend/src/app/tasks/new/page.tsx +++ b/frontend/src/app/tasks/new/page.tsx @@ -10,6 +10,7 @@ import { FlywheelBanner } from '@/components/FlywheelBanner'; import { ConfigPreviewPanel } from '@/components/tasks/ConfigPreviewPanel'; import { RecentTasksBar } from '@/components/tasks/RecentTasksBar'; import { NewTaskForm } from '@/components/tasks/NewTaskForm'; +import { ImportTextModal } from '@/components/tasks/ImportTextModal'; import { api } from '@/lib/api'; import { Product, CreateTaskRequest } from '@/types/index'; import { PaginatedResponse } from '@/types/index'; @@ -32,6 +33,7 @@ export default function NewTaskPage() { }); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(''); + const [importOpen, setImportOpen] = useState(false); useEffect(() => { loadProducts(); }, []); @@ -45,17 +47,22 @@ export default function NewTaskPage() { } } - async function handleSubmit(e: React.FormEvent, track: 'ai' | 'import') { - e.preventDefault(); - if (!selectedProduct) { setError('请选择产品'); return; } - if (!form.theme.trim()) { setError('请填写今天主题'); return; } + function validate(): boolean { + if (!selectedProduct) { setError('请选择产品'); return false; } + if (!form.theme.trim()) { setError('请填写今天主题'); return false; } // 禁降级:产品入镜但该产品没传参考图 → 拦在前端,引导去上传 if (form.need_product_image && !selectedProduct.image_path) { setError('该产品还没上传参考图,无法生成产品入镜内容。请先到「配置-产品库」上传产品图,或关闭下方「产品入镜」开关。'); - return; + return false; } - setSubmitting(true); setError(''); + return true; + } + + async function handleSubmit(e: React.FormEvent, track: 'ai' | 'import') { + e.preventDefault(); + if (!validate() || !selectedProduct) return; + setSubmitting(true); try { const req: CreateTaskRequest = { product_id: selectedProduct.id, ...form, track }; const data = await api.post<{ id: number }>('/api/v1/tasks', req); @@ -68,6 +75,43 @@ export default function NewTaskPage() { } } + // 轨B:先校验产品/主题,通过后开弹窗让用户贴文案 + function openImport(e: React.FormEvent) { + e.preventDefault(); + if (validate()) setImportOpen(true); + } + + // 弹窗确认:建 import 任务 → 逐条导入候选池 → 跳 text 页 + async function handleImportConfirm(contents: string[]) { + if (!selectedProduct) return; + setSubmitting(true); + try { + const req: CreateTaskRequest = { product_id: selectedProduct.id, ...form, track: 'import' }; + const data = await api.post<{ id: number }>('/api/v1/tasks', req); + // 任务已建。逐条导入;某条失败也不丢任务——跳进已建任务的 text 页, + // 半成品文案在那里可见可续,避免任务 ID 丢失变成找不回的孤儿任务。 + let imported = 0; + try { + for (const content of contents) { + await api.post(`/api/v1/tasks/${data.id}/import-text`, { content }); + imported += 1; + } + } catch (impErr) { + const ae = impErr as ApiError; + setError(`已导入 ${imported}/${contents.length} 条后中断(${ae?.message || '导入失败'}),已为你保留任务 #${data.id},可在文案页补齐。`); + } + setImportOpen(false); + router.push(`/tasks/${data.id}/text`); + } catch (err) { + // 建任务本身失败(还没有任务 ID) + const apiErr = err as ApiError; + setError(apiErr?.message || getErrorAction(apiErr?.code).message); + setImportOpen(false); + } finally { + setSubmitting(false); + } + } + return (
@@ -86,7 +130,7 @@ export default function NewTaskPage() { submitting={submitting} error={error} onSubmitAi={(e) => handleSubmit(e, 'ai')} - onSubmitImport={(e) => handleSubmit(e, 'import')} + onSubmitImport={openImport} />
@@ -94,6 +138,13 @@ export default function NewTaskPage() { {/* 右侧配置预览面板 */} {selectedProduct && } + + setImportOpen(false)} + onConfirm={handleImportConfirm} + /> ); } diff --git a/frontend/src/components/AuthGuard.tsx b/frontend/src/components/AuthGuard.tsx index 2cc4f1a..73fe6ed 100644 --- a/frontend/src/components/AuthGuard.tsx +++ b/frontend/src/components/AuthGuard.tsx @@ -15,7 +15,8 @@ const ROUTE_ROLES: Record = { '/config': ['admin'], '/review': ['admin', 'supervisor'], '/history': ['admin', 'supervisor', 'operator'], - '/tasks/new': ['admin', 'operator'], + '/tasks/new': ['admin', 'supervisor', 'operator'], + '/fission': ['admin', 'supervisor', 'operator'], '/dashboard': ['admin', 'supervisor', 'operator'], }; diff --git a/frontend/src/components/config/ProductSubComponents.tsx b/frontend/src/components/config/ProductSubComponents.tsx index ceb2086..1391853 100644 --- a/frontend/src/components/config/ProductSubComponents.tsx +++ b/frontend/src/components/config/ProductSubComponents.tsx @@ -58,16 +58,22 @@ export function ProductImageUpload({ ) : ( - +
+ +

+ 必须含产品主体(瓶身/包装本体),生图以此为锚点保证产品一致。 + 质地图、手臂上脸图等可作补充,但不能替代主图单独上传。 +

+
)} {error &&

{error}

} - 已选 {selectedCount} 批 + 已选 {selectedCount} + {/* 原图按真实比例完整展示,不裁切 */} + {caption + {caption && ( +

{caption}

+ )} + + + ); +} diff --git a/frontend/src/components/tasks/ImageStrategyGroup.tsx b/frontend/src/components/tasks/ImageStrategyGroup.tsx new file mode 100644 index 0000000..18d0b1d --- /dev/null +++ b/frontend/src/components/tasks/ImageStrategyGroup.tsx @@ -0,0 +1,112 @@ +'use client'; +// ImageStrategyGroup — 按"套"(A/B/C)分组展示挑图,组内按 seq 固定6图序,点图放大 +import { ImageCandidate } from '@/types/index'; + +// 套别中文标签(三套正交叙事:痛点先行/场景先行/成分背书先行) +export const STRATEGY_LABEL: Record = { + A: '套1 · 痛点先行', + B: '套2 · 场景先行', + C: '套3 · 成分背书先行', +}; + +// 角色中文名(北哥固定6图序,让"重试批次hook"变可读) +export const ROLE_LABEL: Record = { + hook: '封面·痛点大字', + product_closeup: '产品特写', + ingredient: '成分背书', + texture: '质地展示', + applied_proof: '使用实证', + closer: '收尾导购', + pain: '痛点场景', + proof: '效果实证', + quality: '品质细节', + credit: '资质背书', + convert: '转化引导', + main: '主图', +}; + +interface ImageStrategyGroupProps { + strategy: string; + candidates: ImageCandidate[]; + selectedImageIds: number[]; + onToggle: (id: number) => void; + onZoom: (c: ImageCandidate) => void; + onRegen?: (strategy: string, role: string | undefined, label: string) => void; +} + +export function ImageStrategyGroup({ + strategy, candidates, selectedImageIds, onToggle, onZoom, onRegen, +}: ImageStrategyGroupProps) { + // 组内按 seq 升序(北哥固定序);seq 缺失的排末尾 + const ordered = [...candidates].sort( + (a, b) => (a.seq ?? 999) - (b.seq ?? 999) + ); + + return ( +
+

+ {STRATEGY_LABEL[strategy] || `套${strategy}`} + + 共 {ordered.length} 张 + + {onRegen && ( + + )} +

+
+ {ordered.map((c) => { + const selected = selectedImageIds.includes(c.candidate_id); + return ( +
+ {/* 序号角标:组内第几张 */} + + {c.seq ?? '?'} + + {onRegen && ( + + )} + {ROLE_LABEL[c.role] onZoom(c)} + className="aspect-[3/4] w-full cursor-zoom-in object-cover" + /> + {c.ai_visual_score != null && ( + = 80 ? 'bg-emerald-500' : c.ai_visual_score >= 60 ? 'bg-amber-500' : 'bg-gray-500'}`} + > + AI {Math.round(c.ai_visual_score)} + + )} + +
+ ); + })} +
+
+ ); +} diff --git a/frontend/src/components/tasks/ImportTextModal.tsx b/frontend/src/components/tasks/ImportTextModal.tsx new file mode 100644 index 0000000..e31211a --- /dev/null +++ b/frontend/src/components/tasks/ImportTextModal.tsx @@ -0,0 +1,86 @@ +'use client'; +/** + * ImportTextModal — 导入外部文案弹窗(R4-c 轨B 补输入入口) + * 此前"导入外部文案"按钮直接建任务跳走,没给用户贴文案的地方。 + * 这里让用户粘贴多条文案(用一行 "---" 分隔),确认后回调父组件: + * 父组件先建任务(track=import),再逐条调 import-text 端点,最后跳 text 页。 + */ +import { useEffect, useState } from 'react'; + +interface Props { + open: boolean; + submitting: boolean; + onClose: () => void; + /** 返回切分后的多条文案,父组件负责建任务+逐条导入 */ + onConfirm: (contents: string[]) => void; +} + +const SPLIT_HINT = '多条文案用单独一行 --- 分隔;只贴一条则整体作为一条'; + +function splitContents(raw: string): string[] { + return raw + .split(/^\s*---\s*$/m) + .map((s) => s.trim()) + .filter((s) => s.length > 0); +} + +export function ImportTextModal({ open, submitting, onClose, onConfirm }: Props) { + const [raw, setRaw] = useState(''); + // 每次打开清空上次残留内容,避免重新打开显示旧文案+旧计数 + useEffect(() => { if (open) setRaw(''); }, [open]); + if (!open) return null; + + const parts = splitContents(raw); + + return ( +
+
e.stopPropagation()} + > +

导入外部文案

+

+ 把已有文案贴进来直接进候选池,跳过 AI 生成。{SPLIT_HINT}。 +

+