Files
beige/frontend/src/stores/generationStore.ts
yangqianqian df1856d793 上线版: 产品表单统一+form嵌套修复+用户管理+部署+三套叙事
- 产品编辑入口统一走 ProductFormFull(卖点/风格/人群/品牌词全字段);
  修复开任务页 <form> 套 <form> 致"编辑产品"报错、改不了、跳回首个产品
- dashboard 入口卡片对齐实际路由: 系统管理(/config) 与 工作配置(/settings) 分开;
  settings ?tab=products 直达改用挂载后读 URL, 消除 hydration mismatch
- 新增用户管理(users API/admin service/改密页) + alembic 022/023/024
- 上线部署: Dockerfile / docker-compose.prod+https / nginx https / .env.example
- A8 三套正交叙事(痛点/场景/成分背书) + beige 调色去AI化 + 飞轮 text_import 高权重信号

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 18:08:13 +08:00

238 lines
8.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* generationStore — SSE进度 / 图片候选 / 飞轮注入
* image_candidate 无 batch 字段,所以 imageCandidates 平铺存储
*/
import { create } from 'zustand';
import { SseConnectionStatus } from '@/lib/sse';
import { TextCandidate, ImageCandidate } from '@/types/index';
// 懒引入 taskStore避免循环依赖taskStore 不引 generationStore
import { useTaskStore } from '@/stores/taskStore';
interface FailedBatch {
batch: string; // 图片角色名("hook"/"pain"等),对应 BE batch_failed.batch 字段
reason: string;
retryable: boolean;
}
// 生成阶段(驱动阶段进度条,对齐产品包"有阶段进度"体验 #3#4
// idle→分析竞品→生成文案→配图→完成failed 单列
export type GenStage = 'idle' | 'analyzing' | 'text' | 'image' | 'done' | 'failed';
// 文案产出结果原因B1——task_done 透传,区分"完成但没出文案"的真实原因
export type TextOutcomeReason =
| 'scoring_unavailable' // 评分服务不可用apiports+codeproxy都挂
| 'generation_failed' // 文案生成本身失败
| 'quality_filtered' // 生成了但全部未达标
| 'replenishing' // 有产出但不足,后台补充中
| null; // 正常足量
export interface TextOutcome {
saved: number;
target: number;
reason: TextOutcomeReason;
}
interface GenerationState {
// 连接状态
connectionStatus: SseConnectionStatus;
reconnectAttempt: number;
// 当前生成阶段(#3#4 阶段进度)
stage: GenStage;
// 文案生成进度
textDone: number;
textTotal: number;
textCandidates: TextCandidate[];
// 图片生成进度(平铺,不分 batch — 契约无 batch 字段)
imageDone: number;
imageTotal: number;
imageCandidates: ImageCandidate[];
failedBatches: FailedBatch[];
// 任务级致命错误(SSE error事件,生成彻底失败)。区别于图片批次级 failedBatches。
fatalError: { code: number; message: string } | null;
// 飞轮注入(飞轮隐形,只显注入提示,无"训练AI"按钮)
flywheelInjected: {
recentPreference: string;
rejectReasons: string[];
signalCount?: number;
} | null;
// 任务启动时间戳mstask_started 事件到达时记录,用于 ETA 推算A1/A2
startedAt: number | null;
// 文案产出结果B1——task_done 到达时落定,驱动"完成但没出文案"的明确提示
textOutcome: TextOutcome | null;
setConnectionStatus: (s: SseConnectionStatus, attempt?: number) => void;
setStage: (stage: GenStage) => void;
setStartedAt: (ts: number) => void;
setTextOutcome: (outcome: TextOutcome) => void;
setTextProgress: (done: number, total: number) => void;
addTextCandidate: (candidate: TextCandidate) => void;
patchTextCandidateScore: (candidateId: number, score: TextCandidate['score']) => void;
patchTextCandidateContent: (candidateId: number, content: string) => void;
removeTextCandidate: (candidateId: number) => void;
removeImageCandidate: (candidateId: number) => void;
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[]; signalCount?: number }) => void;
hydrateFromTask: (data: {
textCandidates: TextCandidate[];
imageCandidates: ImageCandidate[];
}) => void;
reset: () => void;
}
export const useGenerationStore = create<GenerationState>((set) => ({
connectionStatus: 'connecting',
reconnectAttempt: 0,
stage: 'idle',
startedAt: null,
textOutcome: null,
textDone: 0,
textTotal: 0,
textCandidates: [],
imageDone: 0,
imageTotal: 0,
imageCandidates: [],
failedBatches: [],
fatalError: null,
flywheelInjected: null,
setConnectionStatus(s, attempt = 0) {
set({ connectionStatus: s, reconnectAttempt: attempt });
},
setStage(stage) {
set({ stage });
},
setStartedAt(ts) {
set({ startedAt: ts });
},
setTextOutcome(outcome) {
set({ textOutcome: outcome });
},
setTextProgress(done, total) {
set({ textDone: done, textTotal: total });
},
addTextCandidate(candidate) {
set((s) => {
const exists = s.textCandidates.some((tc) => tc.candidate_id === candidate.candidate_id);
return {
textCandidates: exists
? s.textCandidates.map((tc) => tc.candidate_id === candidate.candidate_id ? { ...tc, ...candidate } : tc)
: [...s.textCandidates, candidate],
};
});
},
patchTextCandidateScore(candidateId, score) {
set((s) => ({
textCandidates: s.textCandidates.map((tc) =>
tc.candidate_id === candidateId ? { ...tc, score } : tc
),
}));
},
patchTextCandidateContent(candidateId, content) {
set((s) => ({
textCandidates: s.textCandidates.map((tc) =>
tc.candidate_id === candidateId ? { ...tc, content, edited: true } : tc
),
}));
},
removeTextCandidate(candidateId) {
set((s) => ({
textCandidates: s.textCandidates.filter((tc) => tc.candidate_id !== candidateId),
}));
// 同步清选中态
useTaskStore.getState().deselectText(candidateId);
},
removeImageCandidate(candidateId) {
set((s) => ({
imageCandidates: s.imageCandidates.filter((ic) => ic.candidate_id !== candidateId),
}));
useTaskStore.getState().deselectImage(candidateId);
},
setImageProgress(done, total) {
set({ imageDone: done, imageTotal: total });
},
addImageCandidate(candidate) {
set((s) => {
const exists = s.imageCandidates.some((ic) => ic.candidate_id === candidate.candidate_id);
return {
imageCandidates: exists
? s.imageCandidates.map((ic) => ic.candidate_id === candidate.candidate_id ? { ...ic, ...candidate } : ic)
: [...s.imageCandidates, candidate],
};
});
},
markBatchFailed(batch, reason, retryable) {
set((s) => ({
failedBatches: [...s.failedBatches, { batch, reason, retryable }],
}));
},
setFatalError(code, message) {
set({ fatalError: { code, message } });
},
setFlywheelInjected(data) {
set({ flywheelInjected: data });
},
// 进入已完成/进行中任务时一次性回填(历史任务 SSE 不重放,必须主动拉接口)
// 同时把带 is_selected 标记的候选 id 写进 taskStore修复跨页选中态丢失B2
hydrateFromTask({ textCandidates, imageCandidates }) {
set({
textCandidates,
textDone: textCandidates.length,
textTotal: textCandidates.length || 0,
imageCandidates,
imageDone: imageCandidates.length,
imageTotal: imageCandidates.length || 0,
});
// 若候选带 is_selected 字段,同步还原 taskStore 选中态
const selectedTexts = textCandidates
.filter((c) => (c as TextCandidate & { is_selected?: boolean }).is_selected)
.map((c) => c.candidate_id);
const selectedImages = imageCandidates
.filter((c) => (c as ImageCandidate & { is_selected?: boolean }).is_selected)
.map((c) => c.candidate_id);
// 方案B倩倩姐2026-06-26拍板选中回填只做「首次进页恢复历史」。
// 本地已有选中=用户正在挑(图片/文案选中只存本地、点"去生图/去确认"才提交后端),
// 此时 5 秒轮询的后端快照绝不能整体覆盖本地,否则未提交的选中过几秒就被抹掉。
const taskState = useTaskStore.getState();
const localHasSelection =
taskState.selectedTextIds.length > 0 || taskState.selectedImageIds.length > 0;
if (!localHasSelection && (selectedTexts.length || selectedImages.length)) {
taskState.setSelections(selectedTexts, selectedImages);
}
},
reset() {
set({
connectionStatus: 'connecting', reconnectAttempt: 0, stage: 'idle',
textDone: 0, textTotal: 0, textCandidates: [],
imageDone: 0, imageTotal: 0, imageCandidates: [],
failedBatches: [], fatalError: null, flywheelInjected: null, startedAt: null,
textOutcome: null,
});
},
}));