diff --git a/backend/alembic/versions/014_task_status_failed.py b/backend/alembic/versions/014_task_status_failed.py new file mode 100644 index 0000000..f88496c --- /dev/null +++ b/backend/alembic/versions/014_task_status_failed.py @@ -0,0 +1,34 @@ +"""014 generation_tasks.status ENUM 补 failed 终态(B6 错误态) + +Revision ID: 014 +Revises: 013 +Create Date: 2026-06-16 + +status 是 MySQL ENUM,原7值无失败终态。Celery生成彻底失败(重试耗尽)时 +要落 failed,区分"没跑(pending)"和"跑挂了(failed)"。不补 ENUM 会 Data truncated。 +""" +from alembic import op + +revision = "014" +down_revision = "013" +branch_labels = None +depends_on = None + +_ENUM_WITH_FAILED = ( + "ENUM('pending','generating','pending_selection','pending_review'," + "'approved','rejected','archived','failed') NOT NULL DEFAULT 'pending'" +) +_ENUM_ORIG = ( + "ENUM('pending','generating','pending_selection','pending_review'," + "'approved','rejected','archived') NOT NULL DEFAULT 'pending'" +) + + +def upgrade(): + op.execute(f"ALTER TABLE generation_tasks MODIFY COLUMN status {_ENUM_WITH_FAILED}") + + +def downgrade(): + # 回退前先把 failed 行归位到 pending,避免越界 + op.execute("UPDATE generation_tasks SET status='pending' WHERE status='failed'") + op.execute(f"ALTER TABLE generation_tasks MODIFY COLUMN status {_ENUM_ORIG}") diff --git a/backend/app/constants/enums.py b/backend/app/constants/enums.py index 05bf329..4d29c2b 100644 --- a/backend/app/constants/enums.py +++ b/backend/app/constants/enums.py @@ -23,6 +23,7 @@ class TaskStatus(str, Enum): APPROVED = "approved" REJECTED = "rejected" ARCHIVED = "archived" + FAILED = "failed" # 生成彻底失败终态(重试耗尽):区分"没跑"和"跑挂了"(B6) # ── 审核状态(generation_tasks 平铺字段)────────────────── diff --git a/backend/app/workers/tasks.py b/backend/app/workers/tasks.py index 397a520..e17097f 100644 --- a/backend/app/workers/tasks.py +++ b/backend/app/workers/tasks.py @@ -140,16 +140,21 @@ def run_generation_pipeline(self, task_id: int) -> dict: except Exception as exc: logger.error("run_generation_pipeline failed: task_id=%s err=%s", task_id, exc) + # 重试耗尽才落 FAILED 终态;还能重试则保持 GENERATING(别伪装成"待开始")。 + # B6:失败要可见,区分"没跑(pending)"和"跑挂了(failed)"。 + exhausted = self.request.retries >= self.max_retries try: from app.models.task import GenerationTask as GT from app.constants.enums import TaskStatus as TS t = db.query(GT).filter(GT.id == task_id).first() if t: - t.status = TS.PENDING + t.status = TS.FAILED if exhausted else TS.GENERATING db.commit() except Exception: pass _push_event_sync(task_id, workspace_id, "error", {"code": 50001, "message": str(exc)}, seq + 1) + if exhausted: + return {"task_id": task_id, "status": "failed", "error": str(exc)} raise self.retry(exc=exc) finally: db.close() diff --git a/frontend/src/app/history/components.tsx b/frontend/src/app/history/components.tsx index ed64600..f6160d2 100644 --- a/frontend/src/app/history/components.tsx +++ b/frontend/src/app/history/components.tsx @@ -17,6 +17,7 @@ const STATUS_LABEL: Record = { approved: { text: '已通过', color: 'text-green-600 bg-green-50' }, archived: { text: '已归档', color: 'text-gray-500 bg-gray-100' }, rejected: { text: '被打回', color: 'text-red-500 bg-red-50' }, + failed: { text: '生成失败', color: 'text-red-600 bg-red-50' }, }; // --- DownloadButton --- 触发打包→轮询→下载 zip diff --git a/frontend/src/app/history/page.tsx b/frontend/src/app/history/page.tsx index 0e59f1a..e1c828e 100644 --- a/frontend/src/app/history/page.tsx +++ b/frontend/src/app/history/page.tsx @@ -11,9 +11,9 @@ import { getErrorAction } from '@/types/errors'; import { clsx } from 'clsx'; import { HistoryTable, HistorySkeleton, Pagination } from './components'; -const HISTORY_STATUSES = ['approved', 'archived', 'rejected']; +const HISTORY_STATUSES = ['approved', 'archived', 'rejected', 'failed']; const STATUS_LABELS: Record = { - approved: '已通过', archived: '已归档', rejected: '被打回', + approved: '已通过', archived: '已归档', rejected: '被打回', failed: '生成失败', }; const PAGE_SIZE = 20; diff --git a/frontend/src/app/tasks/[id]/images/page.tsx b/frontend/src/app/tasks/[id]/images/page.tsx index 1b51795..a076f29 100644 --- a/frontend/src/app/tasks/[id]/images/page.tsx +++ b/frontend/src/app/tasks/[id]/images/page.tsx @@ -7,6 +7,7 @@ import { ImageProgressCardSkeleton } from '@/components/tasks/ImageProgressCard' import { ImageProgressHeader } from '@/components/tasks/ImageProgressHeader'; import { ImageActionBar } from '@/components/tasks/ImageActionBar'; import { SseStatusBar } from '@/components/tasks/SseStatusBar'; +import { FatalErrorBanner } from '@/components/tasks/FatalErrorBanner'; import { useSse } from '@/hooks/useSse'; import { useGenerationStore } from '@/stores/generationStore'; import { useTaskStore } from '@/stores/taskStore'; @@ -50,6 +51,7 @@ export default function ImageSelectionPage() { )}
+ {/* 图片网格(谁好谁先冒) */}
diff --git a/frontend/src/app/tasks/[id]/text/page.tsx b/frontend/src/app/tasks/[id]/text/page.tsx index 6994460..d96c4d5 100644 --- a/frontend/src/app/tasks/[id]/text/page.tsx +++ b/frontend/src/app/tasks/[id]/text/page.tsx @@ -6,6 +6,7 @@ import { ProgressBar } from '@/components/layout/ProgressBar'; 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 { useSse } from '@/hooks/useSse'; import { useGenerationStore } from '@/stores/generationStore'; import { useTaskStore } from '@/stores/taskStore'; @@ -50,6 +51,9 @@ export default function TextSelectionPage() {
+ {/* 任务级失败提示(B6):生成彻底失败时明确反馈,不再无限转圈 */} + + {/* SSE 进度提示(生成中且无内容时) */} {showSkeleton && (
s.fatalError); + if (!fatalError) return null; + + return ( +
+
+ +
+

生成失败

+

+ {fatalError.message || '生成过程出错,请重试或联系管理员'} +

+

+ 可在任务列表对该任务重新生成;多次失败请检查 key 配置或网络。 +

+
+
+
+ ); +} diff --git a/frontend/src/components/tasks/RecentTasksBar.tsx b/frontend/src/components/tasks/RecentTasksBar.tsx index 3b11261..eecf661 100644 --- a/frontend/src/components/tasks/RecentTasksBar.tsx +++ b/frontend/src/components/tasks/RecentTasksBar.tsx @@ -19,6 +19,7 @@ const STATUS_LABELS: Record = { approved: { label: '已通过', color: 'text-status-success' }, rejected: { label: '已打回', color: 'text-status-error' }, archived: { label: '已归档', color: 'text-text-tertiary' }, + failed: { label: '生成失败', color: 'text-status-error' }, }; export function RecentTasksBar() { diff --git a/frontend/src/hooks/useSse.ts b/frontend/src/hooks/useSse.ts index 811a621..af74033 100644 --- a/frontend/src/hooks/useSse.ts +++ b/frontend/src/hooks/useSse.ts @@ -121,6 +121,10 @@ function handleEvent( break; case 'error': + // 任务级致命错误:不再静默丢弃,落 fatalError 让 UI 提示(B6) + store.setFatalError(event.data.code, event.data.message); + break; + case 'heartbeat': case 'analyze_done': case 'task_done': diff --git a/frontend/src/stores/generationStore.ts b/frontend/src/stores/generationStore.ts index 0005c65..338066e 100644 --- a/frontend/src/stores/generationStore.ts +++ b/frontend/src/stores/generationStore.ts @@ -28,6 +28,9 @@ interface GenerationState { imageCandidates: ImageCandidate[]; failedBatches: FailedBatch[]; + // 任务级致命错误(SSE error事件,生成彻底失败)。区别于图片批次级 failedBatches。 + fatalError: { code: number; message: string } | null; + // 飞轮注入(飞轮隐形,只显注入提示,无"训练AI"按钮) flywheelInjected: { recentPreference: string; @@ -41,6 +44,7 @@ interface GenerationState { setImageProgress: (done: number, total: number) => void; addImageCandidate: (candidate: ImageCandidate) => void; markBatchFailed: (batch: string, reason: string, retryable: boolean) => void; + setFatalError: (code: number, message: string) => void; setFlywheelInjected: (data: { recentPreference: string; rejectReasons: string[] }) => void; hydrateFromTask: (data: { textCandidates: TextCandidate[]; @@ -59,6 +63,7 @@ export const useGenerationStore = create((set) => ({ imageTotal: 0, imageCandidates: [], failedBatches: [], + fatalError: null, flywheelInjected: null, setConnectionStatus(s, attempt = 0) { @@ -95,6 +100,10 @@ export const useGenerationStore = create((set) => ({ })); }, + setFatalError(code, message) { + set({ fatalError: { code, message } }); + }, + setFlywheelInjected(data) { set({ flywheelInjected: data }); }, @@ -116,7 +125,7 @@ export const useGenerationStore = create((set) => ({ connectionStatus: 'connecting', reconnectAttempt: 0, textDone: 0, textTotal: 0, textCandidates: [], imageDone: 0, imageTotal: 0, imageCandidates: [], - failedBatches: [], flywheelInjected: null, + failedBatches: [], fatalError: null, flywheelInjected: null, }); }, })); diff --git a/frontend/src/types/dto.tasks.ts b/frontend/src/types/dto.tasks.ts index 9f0a10a..d5f20f3 100644 --- a/frontend/src/types/dto.tasks.ts +++ b/frontend/src/types/dto.tasks.ts @@ -3,7 +3,7 @@ // 任务 export type TaskStatus = | 'pending' | 'generating' | 'pending_selection' - | 'pending_review' | 'approved' | 'rejected' | 'archived'; + | 'pending_review' | 'approved' | 'rejected' | 'archived' | 'failed'; export interface TaskListItem { id: number; diff --git a/plans/总核销表.md b/plans/总核销表.md index 75d01e1..63416b2 100644 --- a/plans/总核销表.md +++ b/plans/总核销表.md @@ -43,11 +43,11 @@ KR1链路连通 + KR2质量达标 + KR3自主可验 + KR4引擎补全(第2环+ ### 桶A 必须做(主链可用性,最先修) | ID | 任务 | 根据 | 状态 | |----|------|------|------| -| A1 | Sidebar 4个死链路由(/tasks /images /tasks/build /tasks/list 全不存在) | Sidebar.tsx确证 | □ | -| D1 | 改稿进飞轮(SignalType.text_edit + TextCandidate.edited + 改稿端点走task_actions) | 最强真实信号 | □ | -| C2 | 审核界面显分(读score_json总分,不读eval_score NULL列) | 审核可判断 | □ | -| B7 | 事件处理补齐(SSE/轮询状态流转) | 前端诊断 | □ | -| B6 | 错误态呈现(status=failed前端可见) | 前端诊断 | □ | +| A1 | Sidebar 4个死链路由(/tasks /images /tasks/build /tasks/list 全不存在) | Sidebar.tsx确证 | ✅ 2026-06-16 删4死链+补回/review审核台(限supervisor+admin),tsc全量EXIT0,commit 77f52e7 | +| D1 | 改稿进飞轮(SignalType.text_edit + TextCandidate.edited + 改稿端点走task_actions) | 最强真实信号 | ◐ 后端✅2026-06-16端到端真跑(edited False→True+飞轮信号落库weight=5,实测抓出ENUM未同步bug已修,commit e3fb6f2)。**前端改稿框入口攒批做(倩倩姐2026-06-16:后端先串完前端攒批)** | +| C2 | 审核界面显分(读score_json总分,不读eval_score NULL列) | 审核可判断 | ✅ 2026-06-16 review.py复用_score_array_to_obj算总分+7维,ReviewCard接ScoreDimBars(passLine=80红线),端到端验task40返回total=83,新旧维度兼容,commit ea423e8 | +| B7 | 事件处理补齐(SSE/轮询状态流转) | 前端诊断 | ✅ 2026-06-16 核实已做无缺口:SSE签ticket换JWT(?ticket=合规红线)+useSse消费progress/done/error全链+轮询兜底,后端事件发布到位,标绿 | +| B6 | 错误态呈现(status=failed前端可见) | 前端诊断 | ✅ 2026-06-16 后端加TaskStatus.FAILED终态+异常区分重试中vs耗尽(耗尽落failed不retry)+014迁移ALTER status ENUM;前端fatalError state+useSse error不再静默丢+FatalErrorBanner提示+failed标签(列表/历史页可见,history纳入HISTORY_STATUSES),tsc EXIT0 | ### 桶B 飞轮专项(产品最大亮点·做扎实+前端呈现) | ID | 任务 | 守的底线 | 状态 | @@ -81,7 +81,7 @@ KR1链路连通 + KR2质量达标 + KR3自主可验 + KR4引擎补全(第2环+ --- ## 🔴 跨模块迁移版本号统一(防三环撞008车) -008=brand_keyword(第5环) / 009=features_json+analyze_status(第2环) / 010=fission_tasks表(第11环) / 011=benchmark_ids+source_fission_id(第2环S12+第11环合并) +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。 ## 总验收(最高门禁)