B6/B7: failed终态前端可见 + SSE事件链核实
B6 错误态: - 后端已加 TaskStatus.FAILED + 异常区分重试中/耗尽(014迁移ALTER status ENUM) - 前端 TaskStatus 类型加 failed - RecentTasksBar/history STATUS_LABEL 补 failed 标签 - history 页 HISTORY_STATUSES 纳入 failed(否则失败任务前端找不到) - FatalErrorBanner + useSse error 不再静默丢弃 B7: 核实 SSE(ticket换JWT) + 事件消费链无缺口,标绿 tsc EXIT=0
This commit is contained in:
@@ -17,6 +17,7 @@ const STATUS_LABEL: Record<string, { text: string; color: string }> = {
|
||||
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
|
||||
|
||||
@@ -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<string, string> = {
|
||||
approved: '已通过', archived: '已归档', rejected: '被打回',
|
||||
approved: '已通过', archived: '已归档', rejected: '被打回', failed: '生成失败',
|
||||
};
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
|
||||
@@ -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() {
|
||||
)}
|
||||
<SseStatusBar status={connectionStatus} attempt={reconnectAttempt} />
|
||||
<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">
|
||||
|
||||
@@ -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() {
|
||||
<SseStatusBar status={connectionStatus} attempt={reconnectAttempt} />
|
||||
</div>
|
||||
|
||||
{/* 任务级失败提示(B6):生成彻底失败时明确反馈,不再无限转圈 */}
|
||||
<FatalErrorBanner />
|
||||
|
||||
{/* SSE 进度提示(生成中且无内容时) */}
|
||||
{showSkeleton && (
|
||||
<div className="mb-4 bg-brand-cream border border-brand-orange/20 rounded-xl px-4 py-3 text-sm text-text-secondary"
|
||||
|
||||
30
frontend/src/components/tasks/FatalErrorBanner.tsx
Normal file
30
frontend/src/components/tasks/FatalErrorBanner.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
'use client';
|
||||
// FatalErrorBanner — 任务级致命错误提示(B6)。生成彻底失败时给用户明确反馈,
|
||||
// 不再让页面无限转圈。从 generationStore.fatalError 读。
|
||||
import { useGenerationStore } from '@/stores/generationStore';
|
||||
|
||||
export function FatalErrorBanner() {
|
||||
const fatalError = useGenerationStore((s) => s.fatalError);
|
||||
if (!fatalError) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="mb-4 bg-red-50 border border-status-error rounded-xl px-4 py-3"
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<span aria-hidden="true" className="text-status-error">⚠️</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-status-error">生成失败</p>
|
||||
<p className="text-xs text-text-secondary mt-0.5 break-words">
|
||||
{fatalError.message || '生成过程出错,请重试或联系管理员'}
|
||||
</p>
|
||||
<p className="text-xs text-text-tertiary mt-1">
|
||||
可在任务列表对该任务重新生成;多次失败请检查 key 配置或网络。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -19,6 +19,7 @@ const STATUS_LABELS: Record<TaskStatus, { label: string; color: string }> = {
|
||||
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() {
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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<GenerationState>((set) => ({
|
||||
imageTotal: 0,
|
||||
imageCandidates: [],
|
||||
failedBatches: [],
|
||||
fatalError: null,
|
||||
flywheelInjected: null,
|
||||
|
||||
setConnectionStatus(s, attempt = 0) {
|
||||
@@ -95,6 +100,10 @@ export const useGenerationStore = create<GenerationState>((set) => ({
|
||||
}));
|
||||
},
|
||||
|
||||
setFatalError(code, message) {
|
||||
set({ fatalError: { code, message } });
|
||||
},
|
||||
|
||||
setFlywheelInjected(data) {
|
||||
set({ flywheelInjected: data });
|
||||
},
|
||||
@@ -116,7 +125,7 @@ export const useGenerationStore = create<GenerationState>((set) => ({
|
||||
connectionStatus: 'connecting', reconnectAttempt: 0,
|
||||
textDone: 0, textTotal: 0, textCandidates: [],
|
||||
imageDone: 0, imageTotal: 0, imageCandidates: [],
|
||||
failedBatches: [], flywheelInjected: null,
|
||||
failedBatches: [], fatalError: null, flywheelInjected: null,
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user