上线版: 产品表单统一+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>
This commit is contained in:
@@ -5,6 +5,8 @@
|
||||
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 字段
|
||||
@@ -12,11 +14,32 @@ interface FailedBatch {
|
||||
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;
|
||||
@@ -35,18 +58,30 @@ interface GenerationState {
|
||||
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[] }) => void;
|
||||
setFlywheelInjected: (data: { recentPreference: string; rejectReasons: string[]; signalCount?: number }) => void;
|
||||
hydrateFromTask: (data: {
|
||||
textCandidates: TextCandidate[];
|
||||
imageCandidates: ImageCandidate[];
|
||||
@@ -57,6 +92,9 @@ interface GenerationState {
|
||||
export const useGenerationStore = create<GenerationState>((set) => ({
|
||||
connectionStatus: 'connecting',
|
||||
reconnectAttempt: 0,
|
||||
stage: 'idle',
|
||||
startedAt: null,
|
||||
textOutcome: null,
|
||||
textDone: 0,
|
||||
textTotal: 0,
|
||||
textCandidates: [],
|
||||
@@ -71,12 +109,31 @@ export const useGenerationStore = create<GenerationState>((set) => ({
|
||||
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) => ({ textCandidates: [...s.textCandidates, 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) {
|
||||
@@ -95,12 +152,34 @@ export const useGenerationStore = create<GenerationState>((set) => ({
|
||||
}));
|
||||
},
|
||||
|
||||
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) => ({ imageCandidates: [...s.imageCandidates, 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) {
|
||||
@@ -118,6 +197,7 @@ export const useGenerationStore = create<GenerationState>((set) => ({
|
||||
},
|
||||
|
||||
// 进入已完成/进行中任务时一次性回填(历史任务 SSE 不重放,必须主动拉接口)
|
||||
// 同时把带 is_selected 标记的候选 id 写进 taskStore,修复跨页选中态丢失(B2)
|
||||
hydrateFromTask({ textCandidates, imageCandidates }) {
|
||||
set({
|
||||
textCandidates,
|
||||
@@ -127,14 +207,31 @@ export const useGenerationStore = create<GenerationState>((set) => ({
|
||||
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,
|
||||
connectionStatus: 'connecting', reconnectAttempt: 0, stage: 'idle',
|
||||
textDone: 0, textTotal: 0, textCandidates: [],
|
||||
imageDone: 0, imageTotal: 0, imageCandidates: [],
|
||||
failedBatches: [], fatalError: null, flywheelInjected: null,
|
||||
failedBatches: [], fatalError: null, flywheelInjected: null, startedAt: null,
|
||||
textOutcome: null,
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user