存量前端:生图分组/灯箱/重生控件/导入文案/驳回横幅+SSE+总核销表

- ImageStrategyGroup/ImageLightbox/RegenControls/ImportTextModal/
  RejectReasonBanner 新建组件
- tasks images/text/new + history + 配置/布局/SSE hooks 存量改动
- 总核销表进度更新

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
yangqianqian
2026-06-18 11:17:53 +08:00
parent d85dcd401b
commit 25f86b2f4a
20 changed files with 741 additions and 60 deletions

View File

@@ -116,6 +116,14 @@ export function HistoryTable({ tasks }: { tasks: TaskListItem[] }) {
>
</Link>
{task.status !== 'failed' && (
<Link
href={`/tasks/${task.id}/images`}
className="text-brand-orange hover:underline text-xs"
>
</Link>
)}
{task.status === 'approved' && <DownloadButton taskId={task.id} />}
</div>
</td>

View File

@@ -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<ImageCandidate | null>(null);
const [regenTarget, setRegenTarget] = useState<RegenTarget | null>(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<string, ImageCandidate>();
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 (
<div className="flex flex-col h-full">
<ProgressBar currentStep={3} />
<RejectReasonBanner taskId={taskId} />
{flywheelInjected && (
<FlywheelBannerFromSse
recentPreference={flywheelInjected.recentPreference}
@@ -53,29 +88,34 @@ export default function ImageSelectionPage() {
<div className="p-6 flex-1 overflow-auto">
<FatalErrorBanner />
<ImageProgressHeader imageTotal={imageTotal} imageDone={imageDone} pendingCount={pendingCount} />
{/* 图片网格(谁好谁先冒) */}
<div className="grid grid-cols-3 xl:grid-cols-5 gap-3">
{imageCandidates.length === 0
? Array.from({ length: 5 }).map((_, i) => <ImageProgressCardSkeleton key={i} />)
: imageCandidates.map((c) => (
<div key={c.candidate_id}
onClick={() => 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) ? '已选中' : '未选中'}`}
>
<img src={c.url} alt={c.role} className="w-full aspect-square object-cover" />
<p className="text-xs text-center py-1 text-text-secondary">{c.role}</p>
</div>
))}
{imageCandidates.length === 0 ? (
<div className="grid grid-cols-3 gap-3 xl:grid-cols-6">
{Array.from({ length: 6 }).map((_, i) => <ImageProgressCardSkeleton key={i} />)}
</div>
) : (
groups.map(([s, list]) =>
list.length > 0 ? (
<ImageStrategyGroup
key={s}
strategy={s}
candidates={list}
selectedImageIds={selectedImageIds}
onToggle={toggleImageSelection}
onZoom={setZoom}
onRegen={openRegen}
/>
) : null
)
)}
{/* 失败批次提示 */}
{failedBatches.length > 0 && allDone && (
<div role="alert" className="mt-4 bg-red-50 border border-status-error rounded-xl px-4 py-3 text-sm text-status-error">
{failedBatches.length}
{failedBatches.filter((b) => b.retryable).map((b) => (
<button key={b.batch} onClick={() => handleRetry(b.batch)}
className="ml-2 underline" aria-label={`重试${b.batch}`}> {b.batch}</button>
className="ml-2 underline" aria-label={`重试${ROLE_LABEL[b.batch] || b.batch}`}>
{ROLE_LABEL[b.batch] || b.batch}
</button>
))}
</div>
)}
@@ -83,6 +123,16 @@ export default function ImageSelectionPage() {
<ImageActionBar selectedCount={selectedImageIds.length} onProceed={handleProceedToConfirm} />
)}
</div>
<ImageLightbox
url={zoom?.url ?? null}
caption={zoom ? (ROLE_LABEL[zoom.role] || zoom.role) : undefined}
onClose={() => setZoom(null)}
/>
<RegenControls
target={regenTarget}
onClose={() => setRegenTarget(null)}
onSubmit={handleRegen}
/>
</div>
);
}

View File

@@ -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 (
<div className="flex flex-col h-full">
<ProgressBar currentStep={2} />
<RejectReasonBanner taskId={taskId} />
{flywheelInjected && (
<FlywheelBannerFromSse recentPreference={flywheelInjected.recentPreference} rejectReasons={flywheelInjected.rejectReasons} />
)}
@@ -72,6 +83,7 @@ export default function TextSelectionPage() {
candidate={c}
selected={selectedTextIds.includes(c.candidate_id)}
onToggle={toggleTextSelection}
onSaveEdit={handleSaveEdit}
/>
))}
</div>

View File

@@ -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 (
<div className="flex flex-col h-full">
<ProgressBar currentStep={1} />
@@ -86,7 +130,7 @@ export default function NewTaskPage() {
submitting={submitting}
error={error}
onSubmitAi={(e) => handleSubmit(e, 'ai')}
onSubmitImport={(e) => handleSubmit(e, 'import')}
onSubmitImport={openImport}
/>
<div className="mt-8"><RecentTasksBar /></div>
</div>
@@ -94,6 +138,13 @@ export default function NewTaskPage() {
{/* 右侧配置预览面板 */}
{selectedProduct && <ConfigPreviewPanel product={selectedProduct} />}
</div>
<ImportTextModal
open={importOpen}
submitting={submitting}
onClose={() => setImportOpen(false)}
onConfirm={handleImportConfirm}
/>
</div>
);
}

View File

@@ -15,7 +15,8 @@ const ROUTE_ROLES: Record<string, UserRole[]> = {
'/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'],
};

View File

@@ -58,6 +58,7 @@ export function ProductImageUpload({
</button>
</div>
) : (
<div>
<button
type="button"
onClick={() => inputRef.current?.click()}
@@ -68,6 +69,11 @@ export function ProductImageUpload({
>
{uploading ? '上传中…' : '+ 上传产品参考图'}
</button>
<p className="text-xs text-text-tertiary mt-1 leading-relaxed">
/
</p>
</div>
)}
{error && <p className="text-xs text-red-500 mt-1">{error}</p>}
<input

View File

@@ -21,8 +21,8 @@ interface ProgressBarProps {
export function ProgressBar({ currentStep }: ProgressBarProps) {
const { role } = useAuthStore();
// 组长/管理员不走流程条
if (role === 'supervisor' || role === 'admin') return null;
// 管理员是配置者角色不走流程条supervisor 含运营全部操作,走流程时与 operator 一致显示
if (role === 'admin') return null;
return (
<div

View File

@@ -16,11 +16,14 @@ interface NavItem {
icon: string;
}
// 仅保留真实存在的路由(app/目录)。选文案/选图片是任务流程内步骤
// (经 /tasks/[id]/text|images 进入,需 task id,不做顶层导航)。
// 三并列顶层创作入口倩倩姐2026-06-15拍板"三并列入口"
// 文案创作=开新任务;图片排版=历史任务列表里挑一条进图片步骤(图片排版必须有 task id,
// 故落到 /history,每行提供"去排图"入口,不造无 task-id 的死链);裂变扩散=独立裂变页。
const NAV_ITEMS: NavItem[] = [
{ label: '工作台', href: '/dashboard', icon: '🏠' },
{ label: '开新任务', href: '/tasks/new', icon: '✏️' },
{ label: '文案创作', href: '/tasks/new', icon: '✏️' },
{ label: '图片排版', href: '/history', icon: '🖼️' },
{ label: '裂变扩散', href: '/fission', icon: '🌱' },
{ label: '审核台', href: '/review', roles: ['supervisor', 'admin'], icon: '✅' },
{ label: '历史归档', href: '/history', icon: '🗂️' },
{ label: '配置中心', href: '/config', roles: ['admin'], icon: '⚙️' },

View File

@@ -9,7 +9,7 @@ export function ImageActionBar({ selectedCount, onProceed }: ImageActionBarProps
return (
<div className="sticky bottom-0 bg-surface-primary border-t border-border-default px-6 py-4 flex items-center justify-between mt-4">
<span className="text-sm text-text-secondary">
<strong className="text-text-primary">{selectedCount}</strong>
<strong className="text-text-primary">{selectedCount}</strong>
</span>
<button
onClick={onProceed}

View File

@@ -0,0 +1,57 @@
'use client';
// ImageLightbox — 点击挑图卡片放大看全图(解决"只看半截"ESC/点遮罩关闭
import { useEffect } from 'react';
interface ImageLightboxProps {
url: string | null;
caption?: string;
onClose: () => void;
}
export function ImageLightbox({ url, caption, onClose }: ImageLightboxProps) {
useEffect(() => {
if (!url) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', onKey);
// 打开时锁滚动,关闭时恢复
const prev = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
window.removeEventListener('keydown', onKey);
document.body.style.overflow = prev;
};
}, [url, onClose]);
if (!url) return null;
return (
<div
role="dialog"
aria-modal="true"
aria-label="图片放大预览"
onClick={onClose}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4"
>
<div className="relative max-h-full max-w-3xl" onClick={(e) => e.stopPropagation()}>
<button
onClick={onClose}
aria-label="关闭预览"
className="absolute -top-3 -right-3 z-10 h-9 w-9 rounded-full bg-white text-gray-700 shadow-lg hover:bg-gray-100"
>
</button>
{/* 原图按真实比例完整展示,不裁切 */}
<img
src={url}
alt={caption || '放大预览'}
className="max-h-[85vh] w-auto rounded-lg object-contain"
/>
{caption && (
<p className="mt-2 text-center text-sm text-white/90">{caption}</p>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,112 @@
'use client';
// ImageStrategyGroup — 按"套"(A/B/C)分组展示挑图,组内按 seq 固定6图序点图放大
import { ImageCandidate } from '@/types/index';
// 套别中文标签(三套正交叙事:痛点先行/场景先行/成分背书先行)
export const STRATEGY_LABEL: Record<string, string> = {
A: '套1 · 痛点先行',
B: '套2 · 场景先行',
C: '套3 · 成分背书先行',
};
// 角色中文名北哥固定6图序让"重试批次hook"变可读)
export const ROLE_LABEL: Record<string, string> = {
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 (
<section className="mb-6">
<h3 className="mb-2 flex items-center text-sm font-semibold text-text-primary">
{STRATEGY_LABEL[strategy] || `${strategy}`}
<span className="ml-2 text-xs font-normal text-text-tertiary">
{ordered.length}
</span>
{onRegen && (
<button
onClick={() => onRegen(strategy, undefined, STRATEGY_LABEL[strategy] || `${strategy}`)}
className="ml-auto rounded-md border border-border-default px-2 py-0.5 text-xs font-normal text-text-secondary hover:border-brand-orange hover:text-brand-orange"
>
</button>
)}
</h3>
<div className="grid grid-cols-3 gap-3 xl:grid-cols-6">
{ordered.map((c) => {
const selected = selectedImageIds.includes(c.candidate_id);
return (
<div
key={c.candidate_id}
className={`relative overflow-hidden rounded-xl border transition-all
${selected ? 'border-brand-orange ring-2 ring-brand-orange' : 'border-border-default'}`}
>
{/* 序号角标:组内第几张 */}
<span className="absolute left-1.5 top-1.5 z-10 rounded-md bg-black/60 px-1.5 py-0.5 text-[11px] font-semibold text-white">
{c.seq ?? '?'}
</span>
{onRegen && (
<button
onClick={() => onRegen(strategy, c.role, ROLE_LABEL[c.role] || c.role)}
aria-label={`重生 ${ROLE_LABEL[c.role] || c.role}`}
className="absolute bottom-9 right-1.5 z-10 rounded-md bg-black/55 px-1.5 py-0.5 text-[11px] text-white hover:bg-brand-orange"
>
</button>
)}
<img
src={c.url}
alt={ROLE_LABEL[c.role] || c.role}
onClick={() => onZoom(c)}
className="aspect-[3/4] w-full cursor-zoom-in object-cover"
/>
{c.ai_visual_score != null && (
<span
title={c.ai_visual_note || undefined}
className={`absolute right-1.5 top-1.5 rounded-md px-1.5 py-0.5 text-[11px] font-semibold text-white
${c.ai_visual_score >= 80 ? 'bg-emerald-500' : c.ai_visual_score >= 60 ? 'bg-amber-500' : 'bg-gray-500'}`}
>
AI {Math.round(c.ai_visual_score)}
</span>
)}
<button
onClick={() => onToggle(c.candidate_id)}
aria-label={`${selected ? '取消选择' : '选择'} ${ROLE_LABEL[c.role] || c.role}`}
className={`w-full py-1.5 text-xs font-medium transition-colors
${selected ? 'bg-brand-orange text-white' : 'bg-surface-secondary text-text-secondary hover:bg-brand-orange/10'}`}
>
{ROLE_LABEL[c.role] || c.role}{selected ? ' ✓' : ''}
</button>
</div>
);
})}
</div>
</section>
);
}

View File

@@ -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 (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
role="dialog"
aria-modal="true"
aria-label="导入外部文案"
onClick={onClose}
>
<div
className="bg-surface-primary rounded-2xl shadow-xl w-full max-w-lg p-6"
onClick={(e) => e.stopPropagation()}
>
<h3 className="text-lg font-bold text-text-primary mb-1"></h3>
<p className="text-xs text-text-tertiary mb-3 leading-relaxed">
AI {SPLIT_HINT}
</p>
<textarea
value={raw}
onChange={(e) => setRaw(e.target.value)}
rows={10}
placeholder={'第一条文案…\n---\n第二条文案…'}
className="w-full border border-border-default rounded-lg px-3 py-2 text-sm
focus:outline-none focus:border-brand-orange resize-y"
aria-label="粘贴文案内容"
/>
<div className="flex items-center justify-between mt-3">
<span className="text-xs text-text-secondary">
<strong className="text-text-primary">{parts.length}</strong>
</span>
<div className="flex gap-2">
<button
type="button"
onClick={onClose}
className="text-sm px-4 py-2 rounded-lg border border-border-default text-text-secondary hover:bg-surface-tertiary"
>
</button>
<button
type="button"
disabled={parts.length === 0 || submitting}
onClick={() => onConfirm(parts)}
className="text-sm px-4 py-2 rounded-lg bg-brand-orange text-white font-medium
hover:bg-orange-600 disabled:opacity-50"
>
{submitting ? '导入中…' : `导入这 ${parts.length} 条 →`}
</button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,75 @@
'use client';
// RegenControls — R2 重生弹窗:单张/单套重生 + 可选人工提示词
import { useState } from 'react';
export interface RegenTarget {
strategy: 'A' | 'B' | 'C';
role?: string; // 有=单张重生;无=整套重生
label: string; // 展示用(套名 或 角色名)
}
interface RegenControlsProps {
target: RegenTarget | null;
onClose: () => void;
onSubmit: (target: RegenTarget, customPrompt: string) => Promise<void>;
}
export function RegenControls({ target, onClose, onSubmit }: RegenControlsProps) {
const [prompt, setPrompt] = useState('');
const [busy, setBusy] = useState(false);
if (!target) return null;
async function handleSubmit() {
if (!target || busy) return;
setBusy(true);
try {
await onSubmit(target, prompt.trim());
setPrompt('');
onClose();
} finally {
setBusy(false);
}
}
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
onClick={onClose}
>
<div
className="w-full max-w-md rounded-2xl bg-white p-5 shadow-xl"
onClick={(e) => e.stopPropagation()}
>
<h3 className="mb-1 text-base font-semibold text-text-primary">
{target.role ? '此张' : '整套'}{target.label}
</h3>
<p className="mb-3 text-xs text-text-tertiary">
</p>
<textarea
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
maxLength={200}
rows={3}
placeholder="例:标题字再大一点,背景换成浅米色,突出成分卖点"
className="mb-3 w-full resize-none rounded-lg border border-border-default p-2 text-sm focus:border-brand-orange focus:outline-none"
/>
<div className="flex justify-end gap-2">
<button
onClick={onClose}
className="rounded-lg px-4 py-1.5 text-sm text-text-secondary hover:bg-surface-secondary"
>
</button>
<button
onClick={handleSubmit}
disabled={busy}
className="rounded-lg bg-brand-orange px-4 py-1.5 text-sm font-medium text-white disabled:opacity-50"
>
{busy ? '提交中…' : '开始重生'}
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,46 @@
'use client';
/**
* RejectReasonBanner — 打回原因横幅R4-b 审核回路死链修复)
* 被打回的任务回到 pending_selection用户重进挑选页时显示"为何被打回"
* 不再一脸懵。任务页/历史页可复用。
* 自取 task 详情判断 review_status==='rejected' 才显示。
*/
import { useEffect, useState } from 'react';
import { api } from '@/lib/api';
import { TaskListItem } from '@/types/dto.tasks';
interface Props {
taskId: number | null;
/** 外部已有 task 数据时直接传入,省一次请求 */
task?: TaskListItem | null;
}
export function RejectReasonBanner({ taskId, task: taskProp }: Props) {
const [task, setTask] = useState<TaskListItem | null>(taskProp ?? null);
useEffect(() => {
if (taskProp) { setTask(taskProp); return; }
if (!taskId) return;
let alive = true;
api.get<TaskListItem>(`/api/v1/tasks/${taskId}`)
.then((d) => { if (alive) setTask(d); })
.catch(() => { /* 取不到不显示,不阻塞页面 */ });
return () => { alive = false; };
}, [taskId, taskProp]);
if (!task || task.review_status !== 'rejected' || !task.reject_reason) return null;
return (
<div
className="mx-6 mt-4 bg-red-50 border border-red-200 rounded-xl px-4 py-3"
role="alert"
>
<p className="text-sm font-medium text-red-700">
<span aria-hidden="true"></span>
</p>
<p className="text-sm text-red-600 mt-1 leading-relaxed">
{task.reject_reason}
</p>
</div>
);
}

View File

@@ -9,6 +9,7 @@ interface TextCandidateCardProps {
candidate: TextCandidate;
selected: boolean;
onToggle: (id: number) => void;
onSaveEdit?: (id: number, content: string) => Promise<void>;
}
const BANNED_STATUS_STYLES = {
@@ -24,8 +25,27 @@ const VERDICT_STYLES: Record<string, string> = {
'不合格':'text-status-error',
};
export function TextCandidateCard({ candidate, selected, onToggle }: TextCandidateCardProps) {
export function TextCandidateCard({ candidate, selected, onToggle, onSaveEdit }: TextCandidateCardProps) {
const [expanded, setExpanded] = useState(false);
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(candidate.content);
const [saving, setSaving] = useState(false);
const [justSaved, setJustSaved] = useState(false);
async function handleSave(e: React.MouseEvent) {
e.stopPropagation();
if (!onSaveEdit || saving || draft.trim() === '') return;
setSaving(true);
try {
await onSaveEdit(candidate.candidate_id, draft);
setEditing(false);
setJustSaved(true); // 飞轮节点反馈:改稿=最强信号已喂回
setTimeout(() => setJustSaved(false), 4000);
} finally {
setSaving(false);
}
}
return (
<article
onClick={() => onToggle(candidate.candidate_id)}
@@ -52,7 +72,23 @@ export function TextCandidateCard({ candidate, selected, onToggle }: TextCandida
)}
<div className="mt-1">
<span className="text-xs font-medium text-brand-orange mb-1 block">{candidate.angle_label}</span>
<span className="text-xs font-medium text-brand-orange mb-1 block">
{candidate.angle_label}
{(candidate.edited || justSaved) && (
<span className="ml-1.5 text-[10px] text-brand-green-dark bg-brand-green-light px-1.5 py-0.5 rounded-full">稿</span>
)}
</span>
{editing ? (
<textarea
value={draft}
onChange={(e) => setDraft(e.target.value)}
onClick={(e) => e.stopPropagation()}
rows={6}
className="w-full text-sm border border-brand-orange/50 rounded-lg p-2 focus:outline-none focus:ring-1 focus:ring-brand-orange"
aria-label="改稿编辑框"
/>
) : (
<>
<p className={clsx(
'text-sm text-text-primary whitespace-pre-wrap',
!expanded && 'line-clamp-4'
@@ -64,6 +100,34 @@ export function TextCandidateCard({ candidate, selected, onToggle }: TextCandida
>
{expanded ? '收起' : '展开全文'}
</button>
</>
)}
{/* D1 改稿入口(飞轮最强信号 text_edit +5 */}
{onSaveEdit && (
<div className="mt-2 flex items-center gap-2">
{editing ? (
<>
<button type="button" onClick={handleSave} disabled={saving}
className="text-xs bg-brand-orange text-white rounded-md px-2.5 py-1 hover:bg-orange-600 disabled:opacity-50">
{saving ? '保存中…' : '保存改稿'}
</button>
<button type="button" onClick={(e) => { e.stopPropagation(); setEditing(false); setDraft(candidate.content); }}
className="text-xs text-text-secondary hover:underline"></button>
</>
) : (
<button type="button" onClick={(e) => { e.stopPropagation(); setDraft(candidate.content); setEditing(true); }}
className="text-xs text-brand-orange hover:underline"> 稿</button>
)}
</div>
)}
{/* 飞轮节点反馈:改稿即时提示已学习 */}
{justSaved && (
<p role="status" aria-live="polite" className="mt-1.5 text-[11px] text-brand-green-dark">
🌿
</p>
)}
</div>
{/* 违禁词状态 */}

View File

@@ -9,6 +9,7 @@ import { useEffect, useRef } from 'react';
import { createSseClient, SseClient, SseConnectionStatus } from '@/lib/sse';
import { SseEvent } from '@/types/sse';
import { useGenerationStore } from '@/stores/generationStore';
import { useTaskStore } from '@/stores/taskStore';
import { TextCandidate, ImageCandidate } from '@/types/index';
import { api } from '@/lib/api';
@@ -19,6 +20,8 @@ export function useSse(taskId: number | null) {
useEffect(() => {
if (!taskId) return;
store.reset();
// 进入任务时清空上一个任务残留的选中态,避免"交审"按钮基于陈旧选择误判可点/置灰
useTaskStore.getState().clearSelections();
// reset 之后立即回填已有候选(历史/已完成任务 SSE 不重放)。
// 必须放在 reset 之后、与 SSE 同一 effect 内,否则与页面侧 hydrate 形成
@@ -102,8 +105,12 @@ function handleEvent(
const ic: ImageCandidate = {
candidate_id: event.data.candidate_id,
strategy: event.data.strategy,
seq: event.data.seq,
url: event.data.url,
role: event.data.role as ImageCandidate['role'],
is_regen: event.data.is_regen ?? false,
ai_visual_score: event.data.ai_visual_score ?? null,
ai_visual_note: event.data.ai_visual_note ?? null,
};
store.addImageCandidate(ic);
break;

View File

@@ -41,6 +41,7 @@ interface GenerationState {
setTextProgress: (done: number, total: number) => void;
addTextCandidate: (candidate: TextCandidate) => void;
patchTextCandidateScore: (candidateId: number, score: TextCandidate['score']) => void;
patchTextCandidateContent: (candidateId: number, content: string) => void;
setImageProgress: (done: number, total: number) => void;
addImageCandidate: (candidate: ImageCandidate) => void;
markBatchFailed: (batch: string, reason: string, retryable: boolean) => void;
@@ -86,6 +87,14 @@ export const useGenerationStore = create<GenerationState>((set) => ({
}));
},
patchTextCandidateContent(candidateId, content) {
set((s) => ({
textCandidates: s.textCandidates.map((tc) =>
tc.candidate_id === candidateId ? { ...tc, content, edited: true } : tc
),
}));
},
setImageProgress(done, total) {
set({ imageDone: done, imageTotal: total });
},

View File

@@ -13,6 +13,10 @@ export interface TaskListItem {
created_at: string;
text_count: number;
image_count: number;
// 审核回路:打回原因+状态(R4-b死链修复/R8历史展示),后端_fmt_task返回
review_status?: string | null;
reject_reason?: string | null;
reviewed_at?: string | null;
}
export interface CreateTaskRequest {
@@ -54,6 +58,7 @@ export interface TextCandidate {
banned_word_status: BannedWordStatus;
verdict?: string; // AI评委总评"优秀"|"合格"|"不合格",旧数据为空字符串
summary?: string; // AI评委一句话总评(含改进点),旧数据为空字符串
edited?: boolean; // 是否被运营改过稿D1 飞轮最强信号)
}
export interface SelectCandidateRequest { candidate_id: number; }
@@ -67,6 +72,12 @@ export interface ImageCandidate {
strategy: 'A' | 'B' | 'C';
url: string;
role: ImageRole;
seq?: number;
is_selected?: boolean;
is_regen?: boolean; // R2 重生标记(同 strategy+role 去重展示最新用)
// E12 AI评图分纯展示+排序,不进飞轮权重
ai_visual_score?: number | null;
ai_visual_note?: string | null;
}
// 审核

View File

@@ -35,7 +35,9 @@ export interface ImageProgressEvent extends SseBaseEvent {
export interface ImageCandidateEvent extends SseBaseEvent {
type: 'image_candidate';
data: { candidate_id: number; strategy: 'A' | 'B' | 'C'; url: string; role: string };
data: { candidate_id: number; strategy: 'A' | 'B' | 'C'; seq?: number; url: string; role: string;
is_regen?: boolean;
ai_visual_score?: number | null; ai_visual_note?: string | null };
}
export interface FlywheelInjectedEvent extends SseBaseEvent {

View File

@@ -33,7 +33,7 @@ KR1链路连通 + KR2质量达标 + KR3自主可验 + KR4引擎补全(第2环+
| ID | 任务 | 模块核销表 | 细化 | 状态 |
|----|------|-----------|------|------|
| M6 | 第2环 标杆8特征分析 | plans/核销-第2环-标杆.md | 🟢S1-S12(S12拆a/b)/A1-A12 | ◐ **引擎双路径端到端真验完成**(2026-06-16 c1ac2a6):①文本路径(手填亮点)8维齐全conf0.9 ②截图vision路径(真喂4.7MB素颜霜图)source=vision真读图,识别三段式卖点/√图标/大字报模板/办公背景,看不到的诚实标"截图未体现"。features_json落库done。端点健壮(404/422/读图失败降级/key缺失提示)。**剩非引擎本体待办:①北哥过目8维草案标✅满分 ②第5/6环消费features_json对标(接线,归M6下游或随M1/M2)**。8维=banana不变量+俊达配方+3链接 |
| M7 | 第11环 裂变(工作台内) | plans/核销-第11环-裂变.md | 🟢S1-S13(含S10.5)/AC1-12 | □ 代码:只有model骨架(fission.py)+迁移010/011,**service/API/worker全无**,"1爆款→N套笔记包"流程不存在。**档位不等北哥**:banana有现成裂变流程可移植,参考链接已有link_url字段(benchmarks.py:81可手录),先移植打通再请北哥定档位微调 |
| M7 | 第11环 裂变(工作台内) | plans/核销-第11环-裂变.md | 🟢S1-S13(含S10.5)/AC1-12 | **引擎端到端真跑通**(2026-06-16 cefdbaa):①fission_service扇出1源→N子任务(回填source_fission_id,复用主管道run_generation_pipeline),MAX_FANOUT=5红线 ②fission_prompt三档(high/mid/low)规则 ③tasks.py worker识别裂变子任务→注入对应档位进文案上下文(自抓关键缺口,**worker曾跑旧内存代码未热重载,restart后实测注入日志level=high真出现**) ④POST/fission触发+GET/fission/{id}进度聚合,main.py注册路由 ⑤实测fission_id=2扇出task70/71,子任务带source_fission_id+档位注入生效,进度端点聚合正确(total=2/done=0/generating)。**剩待办:①三档真实措辞待北哥定义(现占位规则) ②前端裂变入口+进度呈现(归前端攒批) ③子任务真出3套差异化文案+配图的质量验收(需北哥过目)** |
## P3 前端打通+飞轮2026-06-16 九文档审核结论·按序逐条修)
@@ -80,6 +80,87 @@ KR1链路连通 + KR2质量达标 + KR3自主可验 + KR4引擎补全(第2环+
---
## P4 本轮执行清单倩倩姐2026-06-12实跑15问+截图欠账·去重合并·按序R1→R8做一块核销一块
> 来源倩倩姐亲自实跑前端提15问 + 上次实跑截图欠账(C3/C5/C7/B1/M3/M4/M5/E12/补1/D2)。
> 经4 agent并行审码定位 file:line + 2交叉核(is_active登录校验/api-keys路由前缀)。
> 顺序倩倩姐拍板P0(R1挑图体验/R2重生/R3生图质量) → P1(R4工作台/R5多图/R6账号) → P2(R7飞轮/R8归档)。
> 拍板3点①operator能看改**全部**产品档案 ②3套图是前端没分组非后端没出 ③截图欠账全接入不许丢。
### R1 挑图页体验P0
| 子项 | 去重映射 | 根因证据 | 状态 |
|------|---------|---------|------|
| 图按A/B/C分组展示 | =既有B1(九文档桶C) | ✅ImageStrategyGroup按strategy分3组,page.tsx:34-36+pipeline_io.py:263 | ✅ |
| 点图放大看全图lightbox | =C5,既有M5含 | ✅ImageLightbox object-contain完整不裁+ESC/遮罩关,page.tsx:78接线 | ✅ |
| 组内按seq固定6图序 | 问题12新 | ✅组内sort by seq升序,后端pipeline_io.py:272推seq+sse.ts声明 | ✅ |
| 修"交审"按钮点不动 | 问题7新bug | ✅useSse.ts:24进任务clearSelections清残留,选0张才灰 | ✅ |
| 修"批次重试"点不动 | 问题8新bug | ✅按钮可点+发请求+ROLE_LABEL中文名;单批重生归R2(一期整体重生已标注) | ✅ |
| 进度条补五维(不全0) | =B1实跑(≠九文档B1) | ✅占位0→异步GET补全,后端_score_array_to_obj返七维+dims,ScoreDimBars消费 | ✅ |
> R1 核销证据tsc EXIT=0(0错误) + 独立agent交叉重核5✅+1⚠一期妥协(单批重生归R2),无漏接线/无类型隐患。2026-06-12。
### R2 单张/单套重生+人工提示词P0
| 子项 | 去重映射 | 根因证据 | 状态 |
|------|---------|---------|------|
| 后端regenerate加strategy/role/custom_prompt | 问题6新 | ✅RegenerateRequest请求体+role必配strategy校验,透传enqueue→Celery→pipeline→image_gen全链路 | ✅ |
| 前端单张/单套重生+提示词输入框 | 问题6新 | ✅RegenControls弹窗(含custom_prompt框)+每张"重生此张"+每套"重生整套"+同strategy+role去重展示最新 | ✅ |
> R2 核销证据①后端真import+4签名校验通过 ②alembic 017(is_regen)升级到head ③Pydantic五模式校验全过(整体/单套/单张/非法strategy拦/超长拦) ④生图局部重生4逻辑测试过(单张只1张/custom_prompt注入/全量3张/匹配不到回退) ⑤前端tsc EXIT=0 ⑥独立agent交叉审8项:7✅+1硬伤(空文案降级生图)已修(无文案硬失败推error40002不静默降级) ⑦红线守住:重生新增不删旧/eval_score留NULL/合规约束不被custom_prompt覆盖(sanitize+追加末尾)。2026-06-12。
### R3 生图质量P0·C3硬伤提前
| 子项 | 去重映射 | 根因证据 | 状态 |
|------|---------|---------|------|
| C3中文乱码(canvas叠字) | =C3,记忆标硬伤 | 倩倩姐2026-06-12拍板"只留口子不实现":OVERLAY_TEXT_RENDER_ENABLED=False+image_gen.py TODO(C3-overlay)接入点;本轮验收图大标题"倍分子素颜霜"+瓶身字清晰未触发错别字 | ✅ |
| C7像小红书(prompt约束) | =C7截图🟡 | 新增IMAGE_XHS_STYLE_CONSTRAINTS(反电商/素人感)append到base_prompt;真实验收:参考图笔电背景→生成图换梳妆台镜子生活场景,带标题/卖点/#标签/emoji,明显去电商化 | ✅ |
| 补1上传主图必含瓶身校验 | =既有补1 | 后端2层硬拦截已就位(task_service.py:57建任务校验+pipeline_io.py:199worker兜底);前端ProductSubComponents.tsx加引导"必须含产品主体,质地图不能替代主图"(倩倩姐定调:质地图也算产品但主体必传) | ✅ |
| C1产品一致(走真图) | =既有C1 | 真实端到端:产品5真图(白罐金边盖)→生成图瓶身忠于参考图原样,印"倍分子焕颜沁透水润 素颜霜 SKIN CREAM"未改包装/未混入他品;apiports503自动回落codeproxy+gpt-5.5强档兜底 | ✅ |
> R3 核销证据(2026-06-17真实端到端)worker容器内 r3_verify.py 用产品5真图+真key+真模型生 product_closeup 特写图,83秒,1.9MB,AI分82。独立Explore agent交叉审4处改动无硬伤、R1/R2回归安全。
> 备忘(非R3,记R5收紧)ALLOW_TEXT_ONLY_IMAGE默认true第三层放水,与前端引导文案语义不一致;当前前两层防线已拦截"入镜却纯文生图",R5多图时一并收紧默认值。
> 待优化(非阻塞)①产品5未录brand_keyword(品牌词客户填,本次验证未带词) ②生成图出现"核心买点。"模板占位词漏到图上=文案字段没填实,记文案优化。
### R4 三功能工作台P1·形态级
| 子项 | 去重映射 | 根因证据 | 状态 |
|------|---------|---------|------|
| 文案/图片排版/裂变三独立入口+可连贯 | 问题3新形态 | Sidebar线性流水线,裂变前端零入口 | 🟢 倩倩姐2026-06-15拍板"三并列入口";Sidebar.tsx改三并列(文案创作/tasks/new·图片排版/history·裂变扩散/fission);裂变扩散=独立裂变页(倩倩姐拍板"新建独立裂变页",app/fission/page.tsx+FissionLauncher+FissionProgress);tsc零错 |
| 图片排版可脱离文案独立用 | 问题3新 | images页强依赖text页带入 | 🟢 图片排版入口落/history每行加"排图"链→/tasks/{id}/images(图片步骤需task id,从历史任一非failed任务直接进排图,不强依赖刚走完text);components.tsx已加 |
| supervisor含运营全部操作 | 问题9新 | AuthGuard:18把/tasks/new挡住supervisor | 🟢 AuthGuard:18已加supervisor;ProgressBar:25只排除admin;后端POST/tasks用require_write_permission不卡角色(前轮R4-a改) |
| 导入文案补粘贴/上传入口 | 问题11新 | tasks/new:48直跳过输入,/import-text没调 | 🟢 新建ImportTextModal(粘贴多条用"---"切分);new页"导入外部文案"→先校验→开弹窗→建import任务→逐条POST/api/v1/tasks/{id}/import-text→跳text页;半成品态保留task_id跳text可续;tsc零错;端点路径核对/api/v1/tasks/{id}/import-text真实存在 |
| 第8环审核回路(死链/打回) | =既有M3 | M3未开工 | 🟢 M3前后端已建成(前轮核实非未开工);_fmt_task补review_status/reject_reason/reviewed_at修死链;RejectReasonBanner组件text/images页呈现打回原因 |
### R5 产品多图P1
| 子项 | 去重映射 | 根因证据 | 状态 |
|------|---------|---------|------|
| 产品图多张(DB+上传端点+前端multiple) | 问题2/14新 | image_path单值字符串,无multiple,覆盖式 | □ |
| 生图按场景选用对应图 | 问题2新 | pipeline_io只读单条image_path | □ |
| operator选品/快速建品/产品入镜 | 问题1新 | POST/products限admin,operator不能建 | □ |
### R6 账号体系+Key+权限P1
| 子项 | 去重映射 | 根因证据 | 状态 |
|------|---------|---------|------|
| 后端users.py增/删/列用户(删=软删is_active) | 问题15新 | 无users CRUD,is_active登录已校验:37 | □ |
| 前端配置台账号管理Tab | 问题15新 | 无账号管理入口 | □ |
| Key绑定账号显式展示 | 问题15新 | UserApiKey已按user隔离,只缺前端展示 | □ |
| operator配置编辑权(放开全部产品) | 问题13a新 | /config硬限admin,products CRUD require_admin | □ |
| 保存按钮补错误提示(治点不动) | 问题13b新 | ProductForm校验静默return无提示 | □ |
### R7 飞轮闭环+可视化P2
| 子项 | 去重映射 | 根因证据 | 状态 |
|------|---------|---------|------|
| 飞轮片段注入生图链路(补断点1) | 新 | run_image_generation没传flywheel_fragment | □ |
| 选图信号带strategy进飞轮(补断点2) | 新 | image_select的angle_label=None空转 | □ |
| 统一前端查询与注入的计数口径 | 新 | service用次数/aggregator用权重不一致 | □ |
| AI评图分仅展示+生成筛选 | =既有E12 | 守eval_score NULL红线 | □ |
| 飞轮可视化轻量节点反馈 | =既有FV/D2 | 让客户感到越用越好 | □ |
### R8 归档P2
| 子项 | 去重映射 | 根因证据 | 状态 |
|------|---------|---------|------|
| 历史打回显示原因 | =问题10,既有M4沾边 | _fmt_task()没返reject_reason,前端没读 | □ |
| 日期/品类筛选+导出 | =既有M4 | M4未开工 | □ |
---
## 🔴 跨模块迁移版本号统一防三环撞008车
008=brand_keyword / 009=benchmark_analyze_fields(含features_json) / 010=fission_tasks表 / 011=task_benchmark_fission_fields / 012=product_target_audience / 013=text_candidate edited+signal_type ENUM补text_edit(D1已落) / **014=generation_tasks.status ENUM补failed(B6已落)**。下一个可用=015。