Skip to content

Commit 904f53a

Browse files
committed
fix(ai): 精修工作台 WorkbenchData serde camelCase 契约回填
RefineWorkbench 打开即崩(Cannot read properties of undefined (reading 'split')):commands_ai_refine.rs 唯一缺失 rename_all 的结构体输出 snake_case 键(rule_markdown),前端按 ruleMarkdown 读取恒为 undefined → renderMd(undefined).split。回填契约 + 序列化单测(锚定顶层 camelCase、 嵌套 SectionDiff 保持 snake_case 契约定)+ 前端 ?? 防御。v0.12.3 P0。
1 parent 027ca6a commit 904f53a

2 files changed

Lines changed: 68 additions & 6 deletions

File tree

‎app/src-tauri/src/commands_ai_refine.rs‎

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -351,7 +351,16 @@ pub fn ai_refine_apply(
351351
// ────────────────────────────────────────────────────────────
352352

353353
/// 工作台数据(前端 RefineWorkbench 模态数据源)。
354+
///
355+
/// @ai-context: 必须 camelCase(与 AiRefineResult 等兄弟结构体一致)——本模块
356+
/// 其余结构体均已 2026-08 统一契约;此处缺失曾导致前端按
357+
/// ruleMarkdown 读取得到 undefined → renderMd(undefined).split
358+
/// ("Cannot read properties of undefined (reading 'split')",
359+
/// v0.11.5 引入、真机验收漏测,v0.12.3 修复补测)。
360+
/// @ai-context: 嵌套 section(SectionDiff)保持 snake_case 是刻意契约
361+
/// (首次出现即定,前端类型注释"勿改"),rename 只作用于顶层键。
354362
#[derive(Debug, Clone, serde::Serialize)]
363+
#[serde(rename_all = "camelCase")]
355364
pub struct WorkbenchData {
356365
pub rule_markdown: String,
357366
pub refined_markdown: Option<String>,
@@ -515,3 +524,50 @@ pub(crate) fn ensure_balance_for(st: &AppState, chars: usize, model: &str) -> Re
515524
Err(_) => Ok(()), // 余额查询失败 → 放行(宽容降级,费用由确认弹窗展示)
516525
}
517526
}
527+
528+
#[cfg(test)]
529+
mod tests {
530+
use super::*;
531+
532+
/// v0.12.3 回归(Bug#2):WorkbenchData 顶层键必须 camelCase——
533+
/// 前端按 ruleMarkdown/refinedMarkdown 读取;snake_case 键使字段为
534+
/// undefined → renderMd(undefined).split 抛
535+
/// "Cannot read properties of undefined (reading 'split')"
536+
/// (原实现缺失 rename_all,v0.11.5 引入、真机验收漏测)。
537+
#[test]
538+
fn workbench_data_serializes_camel_case_top_level() {
539+
let data = WorkbenchData {
540+
rule_markdown: "# 标题\n正文".to_string(),
541+
refined_markdown: Some("# 标题\n精修正文".to_string()),
542+
sections: vec![crate::note_diff::SectionDiff {
543+
heading: "标题".to_string(),
544+
status: crate::note_diff::DiffStatus::Modified,
545+
removed_lines: vec!["正文".to_string()],
546+
added_lines: vec!["精修正文".to_string()],
547+
}],
548+
stats: crate::note_diff::DiffStats {
549+
added: 1,
550+
removed: 1,
551+
unchanged: 0,
552+
},
553+
meta: Some(crate::note_version::VersionMeta {
554+
cost_yuan: Some(0.01),
555+
model: Some("test-model".to_string()),
556+
slices: Some(1),
557+
merged_from: None,
558+
}),
559+
};
560+
let v = serde_json::to_value(&data).expect("序列化失败");
561+
let obj = v.as_object().expect("应为 JSON 对象");
562+
assert!(obj.contains_key("ruleMarkdown"), "顶层键必须为 ruleMarkdown(camelCase)");
563+
assert!(obj.contains_key("refinedMarkdown"), "顶层键必须为 refinedMarkdown(camelCase)");
564+
assert!(obj.contains_key("sections"));
565+
assert!(obj.contains_key("stats"));
566+
assert!(obj.contains_key("meta"));
567+
// 嵌套 SectionDiff 保持 snake_case(首次出现即定的契约,勿随顶层 rename)
568+
let sec = &obj["sections"][0];
569+
assert!(sec.get("removed_lines").is_some(), "SectionDiff 嵌套键保持 snake_case");
570+
assert!(sec.get("added_lines").is_some());
571+
assert_eq!(sec["status"], "modified");
572+
}
573+
}

‎app/src/components/RefineWorkbench.tsx‎

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -181,9 +181,15 @@ export default function RefineWorkbench({
181181
}
182182

183183
const wb = data!;
184-
const hasRefined = wb.refinedMarkdown != null;
185-
const leftHtml = renderMd(wb.ruleMarkdown);
186-
const rightHtml = hasRefined ? decorateRefined(renderMd(wb.refinedMarkdown!), wb.sections) : "";
184+
// v0.12.3 防御(Bug#2):后端字段缺失/契约漂移时渲染不崩——
185+
// 原实现 wb.ruleMarkdown 直接 split,serde 键错配时为 undefined 白屏。
186+
const ruleMd = wb.ruleMarkdown ?? "";
187+
const refinedMd = wb.refinedMarkdown ?? null;
188+
const sections = wb.sections ?? [];
189+
const stats = wb.stats ?? { added: 0, removed: 0, unchanged: 0 };
190+
const hasRefined = refinedMd != null;
191+
const leftHtml = renderMd(ruleMd);
192+
const rightHtml = hasRefined ? decorateRefined(renderMd(refinedMd), sections) : "";
187193

188194
return (
189195
<div style={overlay} onClick={onClose}>
@@ -195,9 +201,9 @@ export default function RefineWorkbench({
195201
background: "#f9fafb", flexShrink: 0,
196202
}}>
197203
<span style={{ fontWeight: 600, fontSize: 13 }}>🔧 精修工作台</span>
198-
<span style={{ fontSize: 11, color: "#047857" }}>新增 {wb.stats.added} 行</span>
199-
<span style={{ fontSize: 11, color: "#b91c1c" }}>删除 {wb.stats.removed} 行</span>
200-
<span style={{ fontSize: 11, color: "#6b7280" }}>章节 {wb.sections.length}</span>
204+
<span style={{ fontSize: 11, color: "#047857" }}>新增 {stats.added} 行</span>
205+
<span style={{ fontSize: 11, color: "#b91c1c" }}>删除 {stats.removed} 行</span>
206+
<span style={{ fontSize: 11, color: "#6b7280" }}>章节 {sections.length}</span>
201207
{wb.meta?.model && <span style={{ fontSize: 10, color: "#9ca3af" }}>{wb.meta.model}</span>}
202208
{wb.meta?.costYuan != null && (
203209
<span style={{ fontSize: 10, color: "#b45309" }}>¥{wb.meta.costYuan.toFixed(4)}</span>

0 commit comments

Comments
 (0)