import type { ProjectCreateRequest, ProjectPlanGenerateRequest, ProjectPlanView, } from "@/lib/api/types"; // 立项向导 5 步(UX §6.2)。表单状态用字符串/数组,提交时归一为 ProjectCreateRequest。 export interface WizardForm { title: string; genre: string; logline: string; sellingPoints: string[]; structure: string; premise: string; protagonist: string; theme: string; // 立项书级常量(灵感④):基调 / 结局取向 / 叙事视角。 tone: string; endingType: string; narrativePov: string; } export const WIZARD_STEPS = 5; // 各步标题(与 WIZARD_STEPS 一一对应)。第 5 步为「确认/概览」:回显已填字段供复核后提交。 export const STEP_TITLES = [ "书名 + 题材", "一句话故事 + 卖点 + 结构", "立意 / 总纲", "主角 / 金手指", "确认与提交", ] as const; export const emptyWizardForm: WizardForm = { title: "", genre: "", logline: "", sellingPoints: [], structure: "", premise: "", protagonist: "", theme: "", tone: "", endingType: "", narrativePov: "", }; export const GENRES = ["玄幻", "仙侠", "都市", "科幻", "历史", "悬疑"]; export const STRUCTURES = ["三幕", "故事圈", "雪花"]; export const SELLING_POINT_PRESETS = ["逆袭", "打脸", "系统流", "双男主", "群像"]; // 立项书级常量预设(灵感④ / D 枚举)——free text 单选,命不中预设可留空。 export const TONES = ["热血", "轻松", "沉重", "治愈", "暗黑", "爽文"]; export const ENDING_TYPES = ["HE", "BE", "开放", "正剧"]; export const NARRATIVE_POVS = ["第一人称", "第三人称限知", "第三人称全知", "多视角"]; // 每一步可否前进:仅第 1 步(书名)必填,其余可空着继续(草稿式立项)。 export function canAdvance(step: number, form: WizardForm): boolean { if (step === 1) return form.title.trim().length > 0; return true; } // 整个向导可否提交:书名必填。 export function canSubmit(form: WizardForm): boolean { return form.title.trim().length > 0; } export function clampStep(step: number): number { if (step < 1) return 1; if (step > WIZARD_STEPS) return WIZARD_STEPS; return step; } // AI 立项方案 → 向导表单补丁(灵感⑤)。按字段回填: // - 有对应向导输入的字段直接映射(书名候选→title、叙事结构→structure、基调→tone); // - 无独立向导输入的字段(时空背景/结局设计)折进立意/总纲(premise,带标签),**不静默丢内容**; // - 结局取向/叙事视角这类预设枚举无法从自由文本可靠反推 → 不臆造,留给作者手选(不进 patch)。 // 返回补丁而非整表覆盖,由 applyPlanPatch 按字段并入(保护已编辑字段)。 export function mapPlanResultToWizardForm( plan: ProjectPlanView, ): Partial { const patch: Partial = {}; const firstTitle = plan.title_candidates?.[0]?.trim(); if (firstTitle) patch.title = firstTitle; const structure = plan.narrative_structure.trim(); if (structure) patch.structure = structure; const tone = plan.tone.trim(); if (tone) patch.tone = tone; // 立意/总纲 = 故事核心 + 无独立向导字段的时空背景/结局设计(带标签,逐段拼接不丢内容)。 const premiseParts: string[] = []; const core = plan.story_core.trim(); if (core) premiseParts.push(core); const setting = plan.setting.trim(); if (setting) premiseParts.push(`时空背景:${setting}`); const ending = plan.ending_design.trim(); if (ending) premiseParts.push(`结局设计:${ending}`); if (premiseParts.length > 0) patch.premise = premiseParts.join("\n\n"); return patch; } // WizardForm 中值为字符串的字段键(排除 sellingPoints: string[])——方案 patch 只回填这些。 type WizardStringKey = { [K in keyof WizardForm]: WizardForm[K] extends string ? K : never; }[keyof WizardForm]; // 把方案补丁按字段并入向导表单:**仅回填当前为空的字段**(保护作者已编辑内容, // 非 bulk 覆盖)。只处理字符串字段(方案 patch 只产字符串值);空 = trim 后为空串。 export function applyPlanPatch( form: WizardForm, patch: Partial, ): WizardForm { const next: WizardForm = { ...form }; for (const key of Object.keys(patch) as WizardStringKey[]) { const value = patch[key]; // 守卫短路:非字符串值(如误传数组键)直接跳过,绝不触达 .trim()。 if (typeof value === "string" && form[key].trim() === "") { next[key] = value; } } return next; } // AI 立项方案的种子门控:题材 + 一句话故事(logline)都非空才允许生成(后端亦校验,防 slop)。 export function canGeneratePlan(form: WizardForm): boolean { return form.genre.trim().length > 0 && form.logline.trim().length > 0; } // 向导草稿 → 立项方案生成请求(snake_case;空值省略)。genre+logline 为种子,其余有则贴合。 export function toPlanSeedRequest( form: WizardForm, ): ProjectPlanGenerateRequest { const trimOrUndef = (s: string): string | undefined => { const v = s.trim(); return v.length > 0 ? v : undefined; }; return { genre: trimOrUndef(form.genre), logline: trimOrUndef(form.logline), title: trimOrUndef(form.title), premise: trimOrUndef(form.premise), theme: trimOrUndef(form.theme), structure: trimOrUndef(form.structure), tone: trimOrUndef(form.tone), ending_type: trimOrUndef(form.endingType), narrative_pov: trimOrUndef(form.narrativePov), selling_points: form.sellingPoints, }; } // 归一为后端请求体(snake_case,空值落 null/省略)。 export function toCreateRequest(form: WizardForm): ProjectCreateRequest { const trim = (s: string): string | null => { const v = s.trim(); return v.length > 0 ? v : null; }; // 立意(premise)与主角/金手指(protagonist)是两个独立输入,但 M1 projects 表只有 // premise 一个字段 → 合并进 premise,二者各占一段,互不覆盖(QA H1:此前共用同一字段)。 const premise = [trim(form.premise), form.protagonist.trim() ? `主角/金手指:${form.protagonist.trim()}` : null] .filter((s): s is string => s !== null) .join("\n\n") || null; return { title: form.title.trim(), genre: trim(form.genre), logline: trim(form.logline), premise, theme: trim(form.theme), selling_points: form.sellingPoints, structure: trim(form.structure), tone: trim(form.tone), ending_type: trim(form.endingType), narrative_pov: trim(form.narrativePov), }; }