上线版: 产品表单统一+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:
yangqianqian
2026-06-30 18:08:13 +08:00
parent a77212781c
commit df1856d793
150 changed files with 8616 additions and 1765 deletions

View File

@@ -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;
// 任务启动时间戳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[] }) => 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,
});
},
}));