ooodc a7883dbed9 refactor(todo): 重构待办事项管理逻辑及更新状态规则
- 移除 TodoItem 中的 priority、created_at 和 updated_at 字段
- 强制每个任务都必须有唯一 id,且由用户负责生成
- 修改合并模式逻辑,merge=true 下保留未提及的旧任务
- 支持已完成和已取消任务重新激活(状态改回 pending 或 in_progress)
- 禁止 in_progress 状态退回到 pending,必须标记为 completed 或 cancelled
- 优化状态转换校验,允许特定状态间合法切换
- 简化任务变更消息,移除详细的新增/更新/移除统计
- 更新文档和示例,明确 id 必须由用户生成和使用
- 修复和补充测试,增强状态转换和合并模式验证
- 调整任务时间戳生成逻辑,统一使用当前时间及索引
- 该变更提供更合理的任务状态机械及管理模式,提升稳定性和易用性
2026-06-13 09:22:33 +08:00

239 lines
9.6 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 鱼骨图(因果图)
> **必须写脚本生成 JSON。** 鱼骨图的分支角度、原因小骨坐标需要三角函数计算,直接手写 JSON 极易导致节点重叠和连线穿模。请用下方脚本模板。
## Content 约束
- 分类 4-6 个
- 每个分类的原因 ≤ 4
- 总原因 ≤ 20超过必须合并分类
## Layout 选型
- **脚本生成坐标**(必须):用 .cjs 脚本通过三角函数计算鱼骨坐标,脚本输出 JSON 文件后调用 `npx -y @larksuite/whiteboard-cli@^0.2.11` 渲染
## Layout 规则
- 主干水平居中,从左向右延伸
- 分类节点按 spineX 从左到右排列,奇数(第 1、3、5...)在上方,偶数(第 2、4...)在下方
- 每个分类的原因沿斜线(分支骨)等距排列
- 鱼头(中心问题)在右侧,用 ellipse
- 主干连线带箭头指向鱼头,分支骨和原因小骨连线 endArrow: "none"
- 原因小骨水平延伸到原因框右侧Y 坐标精准对齐
## 骨架示例
**上下交替**:分类标签按 spineX 从左到右排列奇数第1、3、5...在上方偶数第2、4...)在下方。
**视觉同色系**:同一个分支的分类标签、连线及其下的所有原因节点,必须使用同一个色系(如相同的背景色与边框色组合),以保持图形风格统一和逻辑连贯。可以预定义一组颜色数组,按分支轮询使用。
### 坐标计算脚本模板(必须严格参照此算法生成)
以下 Node.js 脚本模板包含了完整的动态布局算法,能够自动适配任意数量的分类和原因,生成完美不重叠的鱼骨图:
```javascript
const fs = require('fs');
const nodes = [];
// 1. 数据定义 (根据用户需求填充)
const categories = [
{ id: "c0", text: "前端代码", reasons: ["未压缩资源", "冗余请求", "超大图片未懒加载"] },
{ id: "c1", text: "后端服务", reasons: ["数据库慢查询", "缓存失效", "并发量过大"] },
{ id: "c2", text: "网络环境", reasons: ["CDN配置错误", "DNS解析缓慢", "带宽限制", "网络抖动"] }
];
// 2. 动态布局计算
const catWidth = 120;
const catHeight = 40;
const reasonWidth = 140; // 调整原因框宽度以适应长文本
const reasonHeight = 32;
const lineLength = 20; // 原因小骨连线的水平延伸长度
const paddingX = 40; // 同侧节点间的水平安全间距
// 预置的分支色系数组(分支骨分类和具体原因保持同一色系)
const branchColors = [
{ fill: "#E8F3FF", stroke: "#1664FF" }, // 蓝色系
{ fill: "#E6FFED", stroke: "#00B42A" }, // 绿色系
{ fill: "#FFF7E8", stroke: "#FF7D00" }, // 橙色系
{ fill: "#FFECE8", stroke: "#F5319D" }, // 粉色系
{ fill: "#F2E8FF", stroke: "#722ED1" }, // 紫色系
{ fill: "#E8FFFF", stroke: "#14C9C9" } // 青色系
];
let maxSpineY_up = 0;
let maxSpineY_down = 0;
// 第一步:计算每个 category 的内部尺寸和相对包围盒
categories.forEach((cat, index) => {
const isTop = index % 2 === 0;
const numReasons = cat.reasons.length;
// 动态计算分支高度,确保原因小骨不会垂直重叠
// 每个原因需要 reasonHeight + 上下间距(约 16)
const requiredY = (numReasons + 1) * (reasonHeight + 16);
const branchDY = Math.max(160, requiredY);
const branchDX = -branchDY * 0.7; // 保持固定的倾斜角度向左延伸
cat.isTop = isTop;
cat.branchDX = branchDX;
cat.branchDY = branchDY;
// 记录最大分支高度,用于计算背景高度和主骨 Y 坐标
if (isTop) maxSpineY_up = Math.max(maxSpineY_up, branchDY + catHeight + 40);
else maxSpineY_down = Math.max(maxSpineY_down, branchDY + catHeight + 40);
// 计算该分类的相对包围盒的极值(相对于 spineX 锚点)
// 最左侧可能由分类框或原因框决定
cat.minX = Math.min(branchDX - catWidth / 2, branchDX - lineLength - reasonWidth);
// 最右侧为主骨挂载点 0 或 分类框右侧
cat.maxX = Math.max(0, branchDX + catWidth / 2);
});
// 第二步:计算每个 category 在主骨上的绝对 X 坐标 (spineX)
let currentSpineX = 100; // 初始偏移
for (let i = 0; i < categories.length; i++) {
const cat = categories[i];
let startX = currentSpineX;
// 需要和上一个同侧的 category 保持距离,防止水平重叠
if (i >= 2) {
const prevSameSideCat = categories[i - 2];
const requiredX = prevSameSideCat.spineX + prevSameSideCat.maxX - cat.minX + paddingX;
startX = Math.max(startX, requiredX);
}
// 确保左侧最长分支不会超出画布左边界
if (startX + cat.minX < 50) {
startX = 50 - cat.minX;
}
cat.spineX = startX;
// 每次略微向前推进,确保异侧节点也能稍微错开
currentSpineX = startX + 80;
}
// 第三步:计算全局画布尺寸
const lastCat = categories[categories.length - 1];
const spineY = maxSpineY_up + 50; // 动态推导主骨 Y 坐标
const totalWidth = lastCat.spineX + 350; // 右侧留出鱼头的空间
const totalHeight = spineY + maxSpineY_down + 50;
// 4. 生成节点数据
// 背景
nodes.push({ type: "rect", x: 0, y: 0, width: totalWidth, height: totalHeight, fillColor: "#FFFFFF", borderWidth: 0 });
// 鱼头
const headWidth = 180;
const headHeight = 80;
const headX = totalWidth - headWidth - 40;
const headY = spineY - headHeight / 2;
nodes.push({ type: "ellipse", id: "head", x: headX, y: headY, width: headWidth, height: headHeight, text: "核心问题" });
// 主骨连线
const firstSpineX = categories[0].spineX + categories[0].minX;
nodes.push({
type: "connector",
connector: { from: { x: firstSpineX, y: spineY }, to: "head", toAnchor: "left", lineShape: "straight", endArrow: "arrow" }
});
// 遍历生成分类和原因小骨
categories.forEach((cat, index) => {
const isTop = cat.isTop;
const branchDY = cat.branchDY;
const branchDX = cat.branchDX;
const color = branchColors[index % branchColors.length];
// 分类标签
const catX = cat.spineX + branchDX - catWidth / 2;
const catY = spineY + (isTop ? -branchDY - catHeight : branchDY);
nodes.push({
type: "rect", id: cat.id, x: catX, y: catY, width: catWidth, height: catHeight, text: cat.text,
fillColor: color.fill, strokeColor: color.stroke
});
// 分支骨连线
nodes.push({
type: "connector",
connector: { from: { x: cat.spineX, y: spineY }, to: cat.id, toAnchor: isTop ? "bottom" : "top", lineShape: "straight", endArrow: "none", lineColor: color.stroke }
});
// 原因小骨
cat.reasons.forEach((reason, rIndex) => {
// 线性插值,均匀分布在分支骨上
const t = (rIndex + 1) / (cat.reasons.length + 1);
const attachX = cat.spineX + branchDX * t;
const attachY = spineY + (isTop ? -branchDY : branchDY) * t;
// 关键对齐:确保原因盒子完全在连线左侧,并且 Y 坐标中心精准对齐
const boxX = attachX - lineLength - reasonWidth;
const boxY = attachY - reasonHeight / 2;
const rId = `${cat.id}-r${rIndex}`;
nodes.push({
type: "rect", id: rId, x: boxX, y: boxY, width: reasonWidth, height: reasonHeight, text: reason,
fillColor: color.fill, strokeColor: color.stroke
});
// 原因小骨连线
nodes.push({
type: "connector",
connector: { from: { x: attachX, y: attachY }, to: rId, toAnchor: "right", lineShape: "straight", endArrow: "none", lineColor: color.stroke }
});
});
});
fs.writeFileSync('diagram.json', JSON.stringify({ version: 2, nodes }, null, 2));
```
## 连线格式与注意点
所有 connector 都用 `{ "type": "connector", "connector": { ... } }` 格式。
**注意:除了主骨外,其他所有连线(分支骨、原因小骨)都必须设置 `"endArrow": "none"`,否则会默认带箭头,导致方向混乱。**
分支骨:从主骨上的绝对坐标点 → 分类标签节点:
```json
{
"version": 2,
"nodes": [
{ "type": "rect", "x": 0, "y": 0, "width": "__totalWidth__", "height": "__totalHeight__" },
{ "type": "ellipse", "id": "head", "x": "__headX__", "y": "__headY__",
"width": 180, "height": 80, "text": "[中心问题]" },
{ "type": "connector", "connector": {
"from": { "x": "__spineStartX__", "y": "__spineY__" },
"to": "head", "toAnchor": "left",
"lineShape": "straight", "endArrow": "arrow"
}},
{ "type": "rect", "id": "c0", "x": "__catX__", "y": "__catY__",
"width": 120, "height": 40, "text": "[分类A]" },
{ "type": "connector", "connector": {
"from": { "x": "__spineX0__", "y": "__spineY__" },
"to": "c0", "toAnchor": "bottom",
"lineShape": "straight", "endArrow": "none"
}},
{ "type": "rect", "id": "c0-r0", "x": "__reasonX__", "y": "__reasonY__",
"width": 140, "height": 32, "text": "[原因1]" },
{ "type": "connector", "connector": {
"from": { "x": "__attachX__", "y": "__attachY__" },
"to": "c0-r0", "toAnchor": "right",
"lineShape": "straight", "endArrow": "none"
}}
]
}
```
上述骨架展示一个分类(上方)+ 一条原因的模式。完整鱼骨图重复此模式,上下交替。每个分类下可有多条原因,均匀插值分布在分支骨上。
## 陷阱
- **代码生成**:必须使用带有动态防重叠算法的脚本来计算坐标并输出 JSON。
- **分支骨防重叠**:同一侧的相邻分支骨和原因框必须没有任何交叉。
- **自适应高度**:原因数量较多时,分支骨自动拉长以容纳所有小骨。
- **原因小骨水平**:原因框右侧的附着点必须与连线起点 Y 坐标一致。
- **无箭头**:所有分类的分支连线、小骨连线均必须关闭箭头。
- **同色系**:同一个分支骨、分类标签节点以及原因小骨节点和连线,必须使用同色系的颜色以保持视觉连贯性。