- 产品编辑入口统一走 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>
238 lines
8.1 KiB
TypeScript
238 lines
8.1 KiB
TypeScript
/**
|
||
* 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;
|
||
|
||
// 任务启动时间戳(ms),task_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,
|
||
});
|
||
},
|
||
}));
|