From c113f68bf47167b34608b1b8ebf02d8443a3009f Mon Sep 17 00:00:00 2001 From: laily Date: Tue, 4 Aug 2026 13:55:32 +0800 Subject: [PATCH] feat: add draw-prototype-flow --- skills/draw-prototype-flow/README.md | 37 +++ skills/draw-prototype-flow/SKILL.md | 138 ++++++++++ .../assets/prototype-flow-template.drawio | 73 +++++ .../assets/scaffold-spec.example.json | 68 +++++ .../references/design-patterns.md | 93 +++++++ .../references/product-model.md | 71 +++++ .../scripts/scaffold_drawio.py | 259 ++++++++++++++++++ .../scripts/validate_drawio.py | 236 ++++++++++++++++ 8 files changed, 975 insertions(+) create mode 100644 skills/draw-prototype-flow/README.md create mode 100644 skills/draw-prototype-flow/SKILL.md create mode 100644 skills/draw-prototype-flow/assets/prototype-flow-template.drawio create mode 100644 skills/draw-prototype-flow/assets/scaffold-spec.example.json create mode 100644 skills/draw-prototype-flow/references/design-patterns.md create mode 100644 skills/draw-prototype-flow/references/product-model.md create mode 100755 skills/draw-prototype-flow/scripts/scaffold_drawio.py create mode 100755 skills/draw-prototype-flow/scripts/validate_drawio.py diff --git a/skills/draw-prototype-flow/README.md b/skills/draw-prototype-flow/README.md new file mode 100644 index 0000000..44aece7 --- /dev/null +++ b/skills/draw-prototype-flow/README.md @@ -0,0 +1,37 @@ +# draw-prototype-flow + +把产品想法或需求文档画成一份可编辑的 Draw.io 低保真原型,并同步表达用户动线、页面状态、角色权限和关键规则。 + +## 什么时候使用 + +- 想从零梳理 Web、移动端或后台系统的页面原型与用户流程。 +- 已有 `.drawio` 原型,希望补齐跳转、异常态、权限或规则说明。 +- 需要一张能用于产品评审、开发对齐和测试设计的产品说明板。 + +## 使用前准备 + +最好提供产品目标、目标用户、核心任务和已有需求材料。缺少细节时,Agent 会先列出必要假设;只有关键范围无法判断时才会询问。 + +可以指定「快速 / 标准 / 完整」档位;不指定时默认按产品评审所需的标准档交付。 + +## 使用示例 + +```text +请用 draw-prototype-flow 为一个企业访客预约系统画低保真原型和用户动线。 +覆盖访客申请、员工审批、前台核验,以及驳回、过期和无权限状态,输出 editable.drawio。 +``` + +```text +检查 current.drawio,把所有主按钮的去向、审批状态和错误恢复路径补完整,并保持现有视觉风格。 +``` + +## Agent 会做什么 + +1. 整理角色、页面、动作、状态和规则。 +2. 绘制可编辑页面、关键状态和带标签的用户动线。 +3. 补充页面索引、权限矩阵或字段规则等必要规格。 +4. 校验 Draw.io 结构与跨图引用,并说明假设和未决项。 + +## 如何判断完成 + +最终会得到可在 diagrams.net/Draw.io 打开的 `.drawio` 源文件。核心任务应能从入口走到结果,每个关键按钮有明确去向,异常状态有恢复动作,原型、动线和规格使用同一套页面与状态 ID。 diff --git a/skills/draw-prototype-flow/SKILL.md b/skills/draw-prototype-flow/SKILL.md new file mode 100644 index 0000000..ba906c5 --- /dev/null +++ b/skills/draw-prototype-flow/SKILL.md @@ -0,0 +1,138 @@ +--- +name: draw-prototype-flow +description: >- + 将产品想法、需求文档或现有系统整理成可编辑的低保真 Draw.io 原型、用户动线和产品规则说明板。 + 当用户提出画原型图、线框图、用户流程、用户动线、页面跳转、交互状态、wireframe、user flow、 + UX flow、产品方案可视化,或要求检查和完善现有 .drawio 产品原型时使用。适合同时表达页面、弹窗、 + 异常态、角色权限、状态流转、字段规则与操作反馈;不用于像素级高保真 UI、纯技术架构图或单张插画。 +--- + +# 绘制低保真原型与用户动线 + +把原型、动线和关键规则放进同一套可追踪的产品模型,交付原生可编辑的 `.drawio` 文件。 + +## 使用资源 + +- 快速单页原型复制 `assets/prototype-flow-template.drawio`,保留其画板尺寸、语义色和基础组件,再替换占位内容。 +- 多页面原型复制 `assets/scaffold-spec.example.json`,填写页面清单和 `transitions`,再运行 `scripts/scaffold_drawio.py` 生成可编辑骨架与总览连线,禁止手工从空 XML 重复搭建外壳。 +- 开始绘制前读取 `references/design-patterns.md`;遇到复杂业务、多个角色或超过 8 个页面时,再读取 `references/product-model.md`。 +- 完成后运行 `scripts/validate_drawio.py`。该脚本可检查常见的压缩或未压缩 Draw.io 文件;Agent 输出时仍设置 `compressed="false"`,便于审阅、差异比较和后续修改。 + +## 工作流 + +### 1. 确认交付边界 + +从用户材料中提取目标用户、核心任务、终端形态、必须覆盖的流程和输出路径。信息不全但不影响主流程时,采用合理假设并在交付说明中列出;只有会改变产品范围或关键流程时才询问。 + +默认交付: + +- 原生可编辑 `.drawio`; +- 一张端到端动线总览; +- 核心任务的页面与关键状态; +- 页面索引、状态流转和角色权限等必要规格; +- 可选的 PNG/SVG 预览,仅作为查看副本,不能代替源文件。 + +根据请求选择交付档位,避免把概念验证画成大而全的规格库: + +- **快速**:1 条核心动线、3~8 个画板、最高风险的 1~2 个异常;适合早期讨论。 +- **标准(默认)**:核心任务、8~20 个画板、动线总览、关键状态和必要的权限/字段规则;适合产品评审。 +- **完整**:多角色、多业务域、系统异常、状态机、权限矩阵和详细规格;适合开发与测试对齐。 + +用户没有指定时,根据材料成熟度选择,先说明档位和不覆盖的内容。 + +### 2. 先建模,再画图 + +建立稳定 ID,避免用会随排序变化的显示序号充当引用: + +- 角色:`R-APPLICANT`、`R-APPROVER`; +- 页面:`P-LOGIN`、`P-ORDER-LIST`; +- 状态:`S-DRAFT`、`S-PENDING`; +- 动作:`A-SUBMIT`、`A-REJECT`。 + +至少整理以下关系: + +1. 角色 → 目标 → 入口; +2. 页面/弹窗 → 可见信息 → 可执行动作; +3. 动作 → 前置条件 → 成功去向 → 失败反馈; +4. 业务对象 → 状态 → 可编辑性 → 角色权限; +5. 字段 → 必填/值域/联动 → 校验反馈。 + +复杂任务使用 `references/product-model.md` 中的清单模板。先消除断头页面、无去向按钮和无入口状态,再开始排版。 + +### 3. 选择画布结构 + +- 不超过 12 个交互画板:可放在一个 Draw.io 页面,按「主流程 → 异常态 → 规格」排列。 +- 超过 12 个交互画板:默认拆成多个 Draw.io 页面,例如 `00-总览`、`10-申请人`、`20-审批人`、`30-异常状态`、`40-规格`。总览页保留所有跨域跳转。 +- 用户明确需要一张巨幅说明板时才使用单页矩阵;每行最多 5 个 1600×900 画板,横向间距 120,纵向间距 260,先放原型、再放流程和规格。 + +这里的“交互画板”只统计用户可进入的页面、弹窗和异常状态;动线总览、页面索引、字段字典和权限矩阵不参与 12 个阈值计算。生成多页骨架: + +```bash +python3 /scripts/scaffold_drawio.py scaffold-spec.json output.drawio +``` + +每个画板标题使用「显示序号 · 页面名 · 状态」;底部元信息写角色、入口、核心功能和下一步,但跨页引用使用稳定 ID,例如 `下一步:P-DETAIL`,不要手写易漂移的序号。 + +### 4. 绘制页面与状态 + +遵循 `references/design-patterns.md` 的视觉语法,并保持低保真: + +- 使用一致的应用外壳、导航、标题层级、表单、表格、按钮和反馈组件; +- 主操作、成功、警告、危险和辅助跳转使用固定语义色; +- 按钮、字段、弹窗和表格单元格保持独立图元,禁止把整个页面栅格化为图片; +- 为核心任务画成功路径,也覆盖与业务相关的加载、空数据、校验失败、权限不足、并发冲突和不可逆确认; +- 弹窗作为触发页面的明确状态,必须同时画取消/返回路径和确认后的结果; +- 内容优先使用真实语义的示例数据,避免大段 Lorem Ipsum。 + +### 5. 绘制动线 + +在总览中按角色或阶段使用泳道。所有跳转必须使用 Draw.io edge,并设置真实的 `source`、`target`: + +- 节点写页面稳定 ID 与名称; +- 连线标签写用户动作或系统结果,如「提交审批」「保存失败」「自动完结」; +- 判断节点写条件,分支在线上写具体结果; +- 成功用绿、警告/可恢复异常用橙、破坏性操作用红、普通导航用蓝; +- 连线优先正交,绕开画板与文字,不用无来源或无目标的装饰箭头; +- 每个入口可达,每个非终态有去向,每个终态有清晰结果。 + +### 6. 补齐产品规格 + +原型之后至少提供页面与动作索引。按复杂度增加: + +- 角色 × 页面 × 操作权限矩阵; +- 状态流转、触发动作、操作者和结果; +- 字段字典、值域、必填与联动规则; +- 异常反馈与恢复动作; +- 报表、导出或批处理口径。 + +不要为凑数量复制页面。规格应解释原型无法准确表达的规则,并使用与原型相同的稳定 ID。 + +### 7. 一致性检查 + +逐项核对: + +1. 每个核心任务都能从入口走到明确结果; +2. 每个可点击主操作都在动线或规格中有去向; +3. 每条连线的源按钮、目标页面和标签语义一致; +4. 页面状态、角色权限、字段规则和按钮可编辑性不冲突; +5. 确认弹窗、失败反馈和恢复路径成对出现; +6. 所有引用使用稳定 ID,显示序号没有被当作唯一标识; +7. 文字没有明显截断,图元不重叠,连线不穿过主要内容。 + +运行结构校验: + +```bash +python3 /scripts/validate_drawio.py --min-frames 1 +``` + +存在两个以上交互画板时追加 `--min-edges 1`,确保至少有一条真实动线;正式交付按核心转移清单设置更高的最小连线数。 + +多页面或正式交付追加 `--strict`;如果环境有 Draw.io 导出工具,再导出 PNG/SVG 并目视检查全图和 2~3 个关键画板。不要把包含私有需求的文件上传到公共在线转换服务。 + +## 完成标准 + +- `.drawio` 能被 XML 解析并在 diagrams.net/Draw.io 打开; +- 图元和文字可单独编辑,关键跳转是有源和目标的连接线; +- 核心流程、关键分支、角色权限和业务状态相互一致; +- 校验无 error;正式交付在 `--strict` 下也无 warning,或逐条说明保留原因; +- 向用户列出产物路径、采用的假设、覆盖范围以及仍待产品决策的问题。 diff --git a/skills/draw-prototype-flow/assets/prototype-flow-template.drawio b/skills/draw-prototype-flow/assets/prototype-flow-template.drawio new file mode 100644 index 0000000..c690671 --- /dev/null +++ b/skills/draw-prototype-flow/assets/prototype-flow-template.drawio @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/skills/draw-prototype-flow/assets/scaffold-spec.example.json b/skills/draw-prototype-flow/assets/scaffold-spec.example.json new file mode 100644 index 0000000..55738c0 --- /dev/null +++ b/skills/draw-prototype-flow/assets/scaffold-spec.example.json @@ -0,0 +1,68 @@ +{ + "title": "产品名称", + "columns": 3, + "transitions": [ + { + "from": "P-LIST", + "to": "P-EDIT", + "label": "创建任务", + "kind": "primary" + }, + { + "from": "P-EDIT", + "to": "P-LIST", + "label": "提交成功", + "kind": "success" + } + ], + "pages": [ + { + "name": "00-总览", + "kind": "flow", + "frames": [ + { + "id": "FLOW-OVERVIEW", + "title": "端到端用户动线", + "role": "ALL", + "state": "" + } + ] + }, + { + "name": "10-核心页面", + "kind": "screen", + "frames": [ + { + "id": "P-LIST", + "title": "任务列表", + "role": "R-USER", + "state": "S-DEFAULT" + }, + { + "id": "P-EDIT", + "title": "创建任务", + "role": "R-USER", + "state": "S-EDITING" + } + ] + }, + { + "name": "40-规格", + "kind": "spec", + "frames": [ + { + "id": "SPEC-PAGE-INDEX", + "title": "页面与动作索引", + "role": "ALL", + "state": "" + }, + { + "id": "SPEC-PERMISSION", + "title": "角色权限矩阵", + "role": "ALL", + "state": "" + } + ] + } + ] +} diff --git a/skills/draw-prototype-flow/references/design-patterns.md b/skills/draw-prototype-flow/references/design-patterns.md new file mode 100644 index 0000000..110c3dd --- /dev/null +++ b/skills/draw-prototype-flow/references/design-patterns.md @@ -0,0 +1,93 @@ +# 低保真产品说明板设计模式 + +## 1. 三层交付结构 + +一份可落地的产品原型同时包含三层: + +1. **页面层**:用户看见什么、能做什么,包括页面、抽屉、弹窗和关键状态。 +2. **动线层**:谁在什么条件下执行什么动作,从哪里到哪里,失败后如何恢复。 +3. **规则层**:状态、权限、字段值域、联动、统计口径以及操作反馈。 + +只画页面会遗漏行为,只画流程会缺少界面上下文,只画规则又难以评审。三层必须复用同一套稳定 ID。 + +## 2. 画板与信息层级 + +默认桌面端画板为 1600×900;移动端改为统一设备宽高,但同一文件不要混用多个无说明尺寸。 + +每个画板从上到下包含: + +- 外部编号标题:`03 · P-REQUEST-EDIT · 编辑申请 · 草稿`; +- 应用外壳:产品名、当前角色、导航和环境信息; +- 页面标题与一句任务说明; +- 主要内容区; +- 主次操作; +- 底部元信息:角色、入口、关键规则和下一步稳定 ID。 + +画板之间保留足够留白,让跨画板连线走在间隙中。弹窗状态可单独复制一张触发页面并叠加 20%~30% 遮罩,避免脱离上下文的孤立弹窗。 + +## 3. 视觉语法 + +除非用户提供品牌规范,使用以下克制的基线: + +| 语义 | 建议色 | 用途 | +| --- | --- | --- | +| 深色外壳 | `#061B3A` | 侧边栏、顶栏、产品框架 | +| 主操作 | `#1768E8` | 主要按钮、普通导航连线 | +| 成功 | `#18864B` | 成功反馈、通过、完成 | +| 警告 | `#D97706` | 可恢复异常、未保存、待处理 | +| 危险 | `#D92D20` | 删除、废弃、不可逆动作 | +| 辅助跳转 | `#5B6ABF` | 预览、详情、跨域辅助流程 | +| 主文字 | `#10284B` | 标题与正文重点 | +| 次文字 | `#718198` | 说明、占位和元信息 | +| 页面背景 | `#F4F7FB` | 内容画布 | +| 边框 | `#D9E2EF` | 卡片、表格、输入框 | + +低保真仍应有层级,但不要添加装饰性插画、复杂阴影、渐变或像素级视觉润色。圆角统一为 8~10,正文 12~14,区块标题 18,页面标题 28~30。中文优先使用环境常见无衬线字体并提供回退。 + +## 4. 组件表达 + +- **按钮**:独立图元,文字使用动词;危险动作不用模糊文案。 +- **输入框**:同时表达字段名、当前值/占位、必填和错误反馈,不能只画空矩形。 +- **表格**:表头、至少一条典型数据、空态与行操作;大表无需填满整页。 +- **状态标签**:颜色与文案双重编码,不能只靠颜色。 +- **反馈**:就近错误用于字段校验,全局消息用于操作结果,阻断性问题使用弹窗或整页状态。 +- **附件/媒体**:画出上传中、失败、预览、删除和权限差异中真正相关的状态。 + +## 5. 动线表达 + +总览使用角色或阶段泳道。页面节点使用稳定 ID;操作节点以动词开头;判断节点写可验证条件;开始和结束节点清楚区分。 + +连接线要求: + +- 使用正交线并设置 `source`、`target`; +- 线上的标签描述动作或结果,不重复节点名称; +- 同一语义使用同一颜色,不给每条线随机配色; +- 交叉多时拆分页面或增加局部子流程,不依赖长距离回折线; +- 页面内按钮到目标页的连线与总览中的转移必须一致。 + +## 6. 页面与状态覆盖 + +按业务风险选择,不机械地给每页复制全部状态: + +- 核心成功路径; +- 表单校验与保存失败; +- 加载、空数据和加载失败; +- 无权限、账号异常或配置缺失; +- 未保存离开; +- 删除、撤回、废弃等不可逆确认; +- 审批或库存等并发冲突; +- 只读、已结束和审计视角。 + +## 7. 大型原型的默认优化 + +大型说明板容易出现两个问题:单页过大导致导航困难;显示序号变化导致页内引用失真。默认采用多页面结构与稳定 ID,显示序号只用于阅读顺序。 + +建议页签: + +- `00-总览`:角色、核心任务、端到端动线、状态机; +- `10-核心页面`:主流程页面; +- `20-角色或子域`:按角色/业务域拆分; +- `30-异常状态`:跨页面异常与恢复; +- `40-规格`:索引、权限、字段与口径。 + +如果用户要求单页巨幅板,仍先在左上放导航索引,并限制每行画板数量。 diff --git a/skills/draw-prototype-flow/references/product-model.md b/skills/draw-prototype-flow/references/product-model.md new file mode 100644 index 0000000..5608070 --- /dev/null +++ b/skills/draw-prototype-flow/references/product-model.md @@ -0,0 +1,71 @@ +# 产品模型与覆盖检查 + +复杂原型先用以下表格建立轻量 SSOT。可以放在工作笔记中,也可以作为 Draw.io 的规格页;不要让原型、动线和规则各自维护一套名称。 + +## 页面清单 + +| 稳定 ID | 页面/状态 | 角色 | 入口 | 用户目标 | 主操作 | 成功去向 | 关键异常 | +| --- | --- | --- | --- | --- | --- | --- | --- | +| P-LOGIN | 登录 | 访客 | 未登录 | 进入系统 | 登录 | P-HOME | S-AUTH-FAIL | + +检查:每个页面都有入口;非终态至少有一个去向;弹窗写明触发页面。 + +## 转移清单 + +| 动作 ID | 来源 | 操作者 | 前置条件 | 动作 | 成功目标 | 失败目标/反馈 | +| --- | --- | --- | --- | --- | --- | --- | +| A-SUBMIT | P-EDIT | 申请人 | 校验通过 | 提交 | P-DETAIL / S-PENDING | P-EDIT / 保存失败消息 | + +检查:每个主按钮有一条转移;每条连线对应真实操作;失败后能重试、返回或安全结束。 + +## 状态机 + +| 当前状态 | 可执行动作 | 角色 | 下一状态 | 字段可编辑性 | 记录/通知 | +| --- | --- | --- | --- | --- | --- | +| S-DRAFT | A-SUBMIT | 申请人 | S-PENDING | 全部可编辑 | 创建审批任务 | + +检查:状态名称唯一;每次变化有触发者;终态不会意外回到可编辑状态;并发变化有刷新或冲突处理。 + +## 角色权限矩阵 + +| 页面/动作 | 申请人 | 审批人 | 管理员 | 无权限表现 | +| --- | --- | --- | --- | --- | +| P-DETAIL / 查看 | 本人数据 | 分配给自己的数据 | 全部 | 403 或无入口 | +| A-DELETE / 删除 | 仅草稿 | 无 | 按规则 | 隐藏按钮或阻断 | + +检查:区分「看不见」「只读」「可操作」;数据范围和按钮权限同时表达;管理员操作注明审计要求。 + +## 字段规则 + +| 字段 ID | 页面 | 类型 | 必填 | 值域/格式 | 显示条件 | 联动 | 错误反馈 | +| --- | --- | --- | --- | --- | --- | --- | --- | +| F-AMOUNT | P-EDIT | 金额 | 是 | > 0,2 位小数 | 始终 | 影响合计 | 请输入有效金额 | + +检查:原型中的标签与字段表一致;依赖字段有清空/保留规则;日期、金额、附件和枚举给出边界。 + +## 异常与恢复 + +| 异常 ID | 触发点 | 用户看到什么 | 数据是否保留 | 恢复动作 | 记录 | +| --- | --- | --- | --- | --- | --- | +| E-SAVE-TIMEOUT | A-SAVE | 保存超时消息 | 保留本地输入 | 重试 | 请求 ID | + +检查:错误文案可行动;不可恢复问题给出返回路径;危险操作有二次确认;成功反馈不与实际状态矛盾。 + +## 端到端覆盖 + +每个核心任务至少验证一条成功路径和一条高风险分支: + +```text +角色 → 入口 → 页面 → 动作 → 状态变化 → 下一角色/系统处理 → 终态 → 可见反馈 +``` + +发布前抽查: + +- 新用户第一次进入; +- 正常创建并完成; +- 中途保存和再次进入; +- 校验失败或服务失败; +- 无权限或配置缺失; +- 不可逆动作; +- 多角色交接; +- 已结束对象的只读/审计视角。 diff --git a/skills/draw-prototype-flow/scripts/scaffold_drawio.py b/skills/draw-prototype-flow/scripts/scaffold_drawio.py new file mode 100755 index 0000000..1f9a97e --- /dev/null +++ b/skills/draw-prototype-flow/scripts/scaffold_drawio.py @@ -0,0 +1,259 @@ +#!/usr/bin/env python3 +"""Generate an editable multi-page Draw.io prototype skeleton from JSON.""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +import xml.etree.ElementTree as ET +from pathlib import Path + + +FRAME_W = 1600 +FRAME_H = 900 +GAP_X = 120 +GAP_Y = 260 + +STYLE = { + "label": "text;html=0;strokeColor=none;fillColor=none;fontColor=#10284B;fontSize=24;fontStyle=1;fontFamily=Microsoft YaHei;", + "bg": "rounded=0;whiteSpace=wrap;html=0;fillColor=#F4F7FB;strokeColor=#D9E2EF;", + "nav": "rounded=0;whiteSpace=wrap;html=0;fillColor=#061B3A;strokeColor=#061B3A;fontColor=#C8D6EA;fontSize=16;fontFamily=Microsoft YaHei;align=left;verticalAlign=top;spacingTop=32;spacingLeft=32;", + "title": "text;html=0;strokeColor=none;fillColor=none;fontColor=#10284B;fontSize=30;fontStyle=1;fontFamily=Microsoft YaHei;align=left;", + "card": "rounded=1;whiteSpace=wrap;html=0;fillColor=#FFFFFF;strokeColor=#D9E2EF;fontColor=#718198;fontSize=16;fontFamily=Microsoft YaHei;arcSize=8;", + "primary": "rounded=1;whiteSpace=wrap;html=0;fillColor=#1768E8;strokeColor=#1768E8;fontColor=#FFFFFF;fontSize=16;fontStyle=1;fontFamily=Microsoft YaHei;arcSize=10;", + "secondary": "rounded=1;whiteSpace=wrap;html=0;fillColor=#FFFFFF;strokeColor=#AFC1DA;fontColor=#27405F;fontSize=16;fontStyle=1;fontFamily=Microsoft YaHei;arcSize=10;", + "meta": "text;html=0;strokeColor=none;fillColor=none;fontColor=#718198;fontSize=13;fontFamily=Microsoft YaHei;align=left;", + "table_head": "rounded=0;whiteSpace=wrap;html=0;fillColor=#EAF0F8;strokeColor=#D9E2EF;fontColor=#263750;fontSize=14;fontStyle=1;fontFamily=Microsoft YaHei;align=left;spacingLeft=18;", + "table_row": "rounded=0;whiteSpace=wrap;html=0;fillColor=#FFFFFF;strokeColor=#D9E2EF;fontColor=#40516A;fontSize=14;fontFamily=Microsoft YaHei;align=left;spacingLeft=18;", +} + +EDGE_COLORS = { + "primary": "#1768E8", + "success": "#18864B", + "warning": "#D97706", + "danger": "#D92D20", + "secondary": "#5B6ABF", +} + + +def args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("spec", type=Path, help="JSON scaffold specification") + parser.add_argument("output", type=Path, help="Target .drawio path") + parser.add_argument("--force", action="store_true", help="Overwrite an existing output file") + return parser.parse_args() + + +def safe_id(value: str) -> str: + value = re.sub(r"[^A-Za-z0-9_-]+", "-", value).strip("-").lower() + return value or "frame" + + +def geometry(parent: ET.Element, x: int, y: int, width: int, height: int) -> None: + ET.SubElement(parent, "mxGeometry", { + "x": str(x), "y": str(y), "width": str(width), "height": str(height), "as": "geometry" + }) + + +def cell(root: ET.Element, cell_id: str, value: str, style: str, x: int, y: int, + width: int, height: int) -> ET.Element: + node = ET.SubElement(root, "mxCell", { + "id": cell_id, "value": value, "style": style, "vertex": "1", "parent": "1" + }) + geometry(node, x, y, width, height) + return node + + +def edge(root: ET.Element, edge_id: str, value: str, source: str, target: str, kind: str) -> None: + color = EDGE_COLORS.get(kind, EDGE_COLORS["primary"]) + node = ET.SubElement(root, "mxCell", { + "id": edge_id, + "value": value, + "style": ( + "edgeStyle=orthogonalEdgeStyle;rounded=1;html=0;endArrow=classic;endFill=1;" + f"strokeWidth=2;strokeColor={color};fontColor=#40516A;" + ), + "edge": "1", + "parent": "1", + "source": source, + "target": target, + }) + ET.SubElement(node, "mxGeometry", {"relative": "1", "as": "geometry"}) + + +def screen_frame(root: ET.Element, prefix: str, number: int, frame: dict, x: int, y: int, + product: str) -> None: + stable_id = frame["id"] + title = frame["title"] + state = frame.get("state", "") + role = frame.get("role", "") + suffix = f" · {state}" if state else "" + cell(root, f"{prefix}-label", f"{number:02d} · {stable_id} · {title}{suffix}", STYLE["label"], x, y, 1300, 40) + top = y + 55 + cell(root, f"{prefix}-bg", "", STYLE["bg"], x, top, FRAME_W, FRAME_H) + cell(root, f"{prefix}-nav", f"{product}\n工作台\n核心任务\n数据概览\n系统设置", STYLE["nav"], x, top, 260, FRAME_H) + cell(root, f"{prefix}-title", title, STYLE["title"], x + 310, top + 50, 700, 50) + cell(root, f"{prefix}-card", "在此绘制页面内容、字段、表格和反馈状态", STYLE["card"], x + 310, top + 150, 1240, 560) + cell(root, f"{prefix}-secondary", "次要操作", STYLE["secondary"], x + 1060, top + 745, 200, 50) + cell(root, f"{prefix}-primary", "主要操作", STYLE["primary"], x + 1310, top + 745, 240, 50) + cell(root, f"{prefix}-meta", f"角色:{role or '待定义'}|状态:{state or '待定义'}|稳定 ID:{stable_id}|下一步:待定义", STYLE["meta"], x + 310, top + 845, 1240, 30) + + +def flow_frame(root: ET.Element, prefix: str, number: int, frame: dict, x: int, y: int) -> None: + stable_id, title = frame["id"], frame["title"] + cell(root, f"{prefix}-label", f"{number:02d} · {stable_id} · {title}", STYLE["label"], x, y, 1300, 40) + top = y + 55 + cell(root, f"{prefix}-bg", "", STYLE["bg"], x, top, FRAME_W, FRAME_H) + cell(root, f"{prefix}-title", title, STYLE["title"], x + 80, top + 55, 900, 50) + cell(root, f"{prefix}-card", "按角色或阶段添加泳道;使用带 source/target 的正交连接线表达动作与结果", STYLE["card"], x + 80, top + 150, 1440, 620) + cell(root, f"{prefix}-meta", f"稳定 ID:{stable_id}|覆盖:入口、判断、跨角色交接、成功与失败终态", STYLE["meta"], x + 80, top + 835, 1440, 30) + + +def flow_graph(root: ET.Element, prefix: str, transitions: list[dict], frames: dict[str, dict], + x: int, y: int) -> None: + ordered_ids: list[str] = [] + for transition in transitions: + for stable_id in (transition["from"], transition["to"]): + if stable_id not in ordered_ids: + ordered_ids.append(stable_id) + + node_ids: dict[str, str] = {} + for index, stable_id in enumerate(ordered_ids): + col, row = index % 5, index // 5 + node_id = f"{prefix}-node-{safe_id(stable_id)}" + node_ids[stable_id] = node_id + frame = frames[stable_id] + cell( + root, + node_id, + f"{stable_id}\n{frame['title']}", + "rounded=1;whiteSpace=wrap;html=0;fillColor=#FFFFFF;strokeColor=#1768E8;" + "fontColor=#10284B;fontSize=14;fontStyle=1;fontFamily=Microsoft YaHei;arcSize=10;", + x + 110 + col * 280, + y + 245 + row * 155, + 220, + 82, + ) + + for index, transition in enumerate(transitions): + edge( + root, + f"{prefix}-edge-{index}", + transition["label"], + node_ids[transition["from"]], + node_ids[transition["to"]], + transition.get("kind", "primary"), + ) + + +def spec_frame(root: ET.Element, prefix: str, number: int, frame: dict, x: int, y: int) -> None: + stable_id, title = frame["id"], frame["title"] + cell(root, f"{prefix}-label", f"{number:02d} · {stable_id} · {title}", STYLE["label"], x, y, 1300, 40) + top = y + 55 + cell(root, f"{prefix}-bg", "", STYLE["bg"], x, top, FRAME_W, FRAME_H) + cell(root, f"{prefix}-title", title, STYLE["title"], x + 80, top + 55, 900, 50) + cell(root, f"{prefix}-head", "稳定 ID 名称/条件 角色 动作/规则 结果", STYLE["table_head"], x + 80, top + 150, 1440, 58) + for row in range(4): + cell(root, f"{prefix}-row-{row}", "待填写 待填写 待填写 待填写 待填写", STYLE["table_row"], x + 80, top + 208 + row * 66, 1440, 66) + cell(root, f"{prefix}-meta", f"规格 ID:{stable_id}|所有引用使用页面/状态/动作稳定 ID", STYLE["meta"], x + 80, top + 835, 1440, 30) + + +def load_spec(path: Path) -> dict: + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise ValueError(f"Cannot read spec: {exc}") from exc + if not isinstance(data.get("pages"), list) or not data["pages"]: + raise ValueError("spec.pages must be a non-empty list") + columns = data.get("columns", 3) + if not isinstance(columns, int) or not 1 <= columns <= 5: + raise ValueError("columns must be an integer from 1 to 5") + seen: set[str] = set() + for page in data["pages"]: + if page.get("kind") not in {"screen", "flow", "spec"}: + raise ValueError(f"Unsupported page kind: {page.get('kind')!r}") + if not page.get("name") or not isinstance(page.get("frames"), list) or not page["frames"]: + raise ValueError("each page needs name and a non-empty frames list") + for frame in page["frames"]: + if not frame.get("id") or not frame.get("title"): + raise ValueError("each frame needs id and title") + if frame["id"] in seen: + raise ValueError(f"duplicate stable frame id: {frame['id']}") + seen.add(frame["id"]) + transitions = data.get("transitions", []) + if not isinstance(transitions, list): + raise ValueError("transitions must be a list") + for transition in transitions: + if not all(transition.get(key) for key in ("from", "to", "label")): + raise ValueError("each transition needs from, to and label") + if transition["from"] not in seen or transition["to"] not in seen: + raise ValueError(f"transition references unknown frame: {transition}") + if transition.get("kind", "primary") not in EDGE_COLORS: + raise ValueError(f"unsupported transition kind: {transition.get('kind')!r}") + return data + + +def build(spec: dict) -> ET.ElementTree: + mxfile = ET.Element("mxfile", { + "host": "app.diagrams.net", "agent": "draw-prototype-flow", "version": "24.7.17", + "type": "device", "compressed": "false", "pages": str(len(spec["pages"])) + }) + product = spec.get("title", "产品名称") + columns = spec.get("columns", 3) + frames_by_id = { + frame["id"]: frame + for page in spec["pages"] + for frame in page["frames"] + } + number = 0 + for page_index, page in enumerate(spec["pages"]): + diagram = ET.SubElement(mxfile, "diagram", { + "id": f"page-{page_index}-{safe_id(page['name'])}", "name": page["name"] + }) + model = ET.SubElement(diagram, "mxGraphModel", { + "dx": "1200", "dy": "800", "grid": "1", "gridSize": "10", "guides": "1", + "tooltips": "1", "connect": "1", "arrows": "1", "fold": "1", "page": "0", + "pageScale": "1", "math": "0", "shadow": "0" + }) + root = ET.SubElement(model, "root") + ET.SubElement(root, "mxCell", {"id": "0"}) + ET.SubElement(root, "mxCell", {"id": "1", "parent": "0"}) + for frame_index, frame in enumerate(page["frames"]): + number += 1 + col, row = frame_index % columns, frame_index // columns + x, y = col * (FRAME_W + GAP_X), row * (FRAME_H + GAP_Y) + 15 + prefix = f"p{page_index}-f{frame_index}-{safe_id(frame['id'])}" + if page["kind"] == "screen": + screen_frame(root, prefix, number, frame, x, y, product) + elif page["kind"] == "flow": + flow_frame(root, prefix, number, frame, x, y) + else: + spec_frame(root, prefix, number, frame, x, y) + if page["kind"] == "flow" and spec.get("transitions"): + flow_graph(root, f"p{page_index}-auto-flow", spec["transitions"], frames_by_id, 0, 15) + ET.indent(mxfile, space=" ") + return ET.ElementTree(mxfile) + + +def main() -> int: + options = args() + if options.output.exists() and not options.force: + print(f"Refusing to overwrite existing file: {options.output}", file=sys.stderr) + return 2 + try: + spec = load_spec(options.spec) + tree = build(spec) + options.output.parent.mkdir(parents=True, exist_ok=True) + tree.write(options.output, encoding="utf-8", xml_declaration=True) + except (ValueError, OSError) as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return 1 + frame_count = sum(len(page["frames"]) for page in spec["pages"]) + print(f"Created {options.output}: {len(spec['pages'])} page(s), {frame_count} frame(s)") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/draw-prototype-flow/scripts/validate_drawio.py b/skills/draw-prototype-flow/scripts/validate_drawio.py new file mode 100755 index 0000000..97ea9dc --- /dev/null +++ b/skills/draw-prototype-flow/scripts/validate_drawio.py @@ -0,0 +1,236 @@ +#!/usr/bin/env python3 +"""Validate the structure of an uncompressed Draw.io product prototype.""" + +from __future__ import annotations + +import argparse +import base64 +import html +import json +import re +import sys +import urllib.parse +import xml.etree.ElementTree as ET +import zlib +from dataclasses import asdict, dataclass +from pathlib import Path + + +FRAME_RE = re.compile(r"^(?:\d{2,3}|P-[A-Z0-9-]+)\s*(?:[·.\-—-]|\s)") +NUMERIC_REF_RE = re.compile(r"(?:下一步|关联|进入|见|去向)[^|\n]{0,40}(? str: + value = html.unescape(value or "") + value = re.sub(r"", " ", value, flags=re.I) + value = re.sub(r"<[^>]+>", "", value) + return " ".join(value.replace("\xa0", " ").split()) + + +def is_frame_title(cell: ET.Element) -> bool: + """Recognize large external frame headings, not numbered table content.""" + if not FRAME_RE.match(label_text(cell.get("value"))): + return False + geometry = cell.find("mxGeometry") + if geometry is None: + return False + try: + width = float(geometry.get("width", "0")) + height = float(geometry.get("height", "0")) + except ValueError: + return False + style = cell.get("style", "") + size_match = re.search(r"(?:^|;)fontSize=(\d+(?:\.\d+)?)(?:;|$)", style) + font_size = float(size_match.group(1)) if size_match else 0 + return width >= 500 and height <= 80 and font_size >= 20 + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("file", type=Path, help="Uncompressed .drawio file") + parser.add_argument("--min-frames", type=int, default=0) + parser.add_argument("--min-edges", type=int, default=0) + parser.add_argument("--strict", action="store_true", help="Treat warnings as failures") + parser.add_argument("--json", action="store_true", dest="as_json") + return parser.parse_args() + + +def graph_model(diagram: ET.Element) -> tuple[ET.Element | None, bool]: + """Return a graph model and whether the page used Draw.io compression.""" + model = diagram.find("mxGraphModel") + if model is not None: + return model, False + payload = (diagram.text or "").strip() + if not payload: + return None, False + try: + decoded = base64.b64decode(payload) + xml_text = urllib.parse.unquote(zlib.decompress(decoded, -15).decode("utf-8")) + model = ET.fromstring(xml_text) + except (ValueError, UnicodeDecodeError, zlib.error, ET.ParseError): + return None, True + return (model if model.tag == "mxGraphModel" else None), True + + +def validate(path: Path, min_frames: int, min_edges: int) -> tuple[dict, list[Issue]]: + issues: list[Issue] = [] + try: + root = ET.parse(path).getroot() + except (OSError, ET.ParseError) as exc: + return {"file": str(path), "pages": []}, [Issue("error", "xml", str(exc))] + + if root.tag != "mxfile": + issues.append(Issue("error", "root", f"Expected mxfile root, found {root.tag!r}")) + + diagrams = root.findall("diagram") + if not diagrams: + issues.append(Issue("error", "pages", "No diagram pages found")) + + names = [diagram.get("name", "") for diagram in diagrams] + for name in sorted(set(names)): + if name and names.count(name) > 1: + issues.append(Issue("warning", "duplicate-page-name", f"Duplicate page name: {name}")) + + page_reports = [] + total_frames = 0 + total_edges = 0 + + for index, diagram in enumerate(diagrams, start=1): + page_name = diagram.get("name") or f"page-{index}" + model, compressed = graph_model(diagram) + if model is None: + issues.append(Issue( + "error", + "compressed-page", + "Page has no readable mxGraphModel", + page_name, + )) + page_reports.append({"name": page_name, "vertices": 0, "edges": 0, "frames": 0}) + continue + if compressed: + issues.append(Issue( + "warning", + "compressed-page", + "Compressed page is readable, but agent-generated output should use compressed=false", + page_name, + )) + + graph_root = model.find("root") + if graph_root is None: + issues.append(Issue("error", "graph-root", "Missing graph root", page_name)) + continue + + cells = graph_root.findall("mxCell") + by_id: dict[str, ET.Element] = {} + for cell in cells: + cell_id = cell.get("id") + if not cell_id: + issues.append(Issue("error", "missing-id", "mxCell has no id", page_name)) + continue + if cell_id in by_id: + issues.append(Issue("error", "duplicate-id", f"Duplicate cell id: {cell_id}", page_name, cell_id)) + by_id[cell_id] = cell + + vertices = [cell for cell in cells if cell.get("vertex") == "1"] + edges = [cell for cell in cells if cell.get("edge") == "1"] + frame_cells = [cell for cell in vertices if is_frame_title(cell)] + + for cell in cells: + cell_id = cell.get("id") + parent = cell.get("parent") + if parent and parent not in by_id: + issues.append(Issue("error", "dangling-parent", f"Unknown parent {parent}", page_name, cell_id)) + if (cell.get("vertex") == "1" or cell.get("edge") == "1") and cell.find("mxGeometry") is None: + issues.append(Issue("error", "geometry", "Vertex/edge has no mxGeometry", page_name, cell_id)) + if cell.get("vertex") == "1" and NUMERIC_REF_RE.search(label_text(cell.get("value"))): + issues.append(Issue( + "warning", + "numeric-cross-reference", + "Cross-reference appears to use a display number; use a stable page/state ID", + page_name, + cell_id, + )) + + for edge in edges: + edge_id = edge.get("id") + source = edge.get("source") + target = edge.get("target") + if not source or not target: + issues.append(Issue("error", "unbound-edge", "Edge needs both source and target", page_name, edge_id)) + continue + if source not in by_id: + issues.append(Issue("error", "dangling-source", f"Unknown source {source}", page_name, edge_id)) + if target not in by_id: + issues.append(Issue("error", "dangling-target", f"Unknown target {target}", page_name, edge_id)) + if not label_text(edge.get("value")): + issues.append(Issue("warning", "unlabelled-edge", "User-flow edge has no action/result label", page_name, edge_id)) + + if len(frame_cells) > 15: + issues.append(Issue( + "warning", + "crowded-page", + f"{len(frame_cells)} numbered frames on one page; consider splitting into multiple pages", + page_name, + )) + if FLOW_PAGE_RE.search(page_name) and not edges: + issues.append(Issue("warning", "no-flow", "Flow/overview page has no linked user-flow edges", page_name)) + + total_frames += len(frame_cells) + total_edges += len(edges) + page_reports.append({ + "name": page_name, + "cells": len(cells), + "vertices": len(vertices), + "edges": len(edges), + "frames": len(frame_cells), + }) + + if total_frames < min_frames: + issues.append(Issue("error", "min-frames", f"Found {total_frames} frames; expected at least {min_frames}")) + if total_edges < min_edges: + issues.append(Issue("error", "min-edges", f"Found {total_edges} edges; expected at least {min_edges}")) + + return { + "file": str(path), + "page_count": len(diagrams), + "frame_count": total_frames, + "edge_count": total_edges, + "pages": page_reports, + }, issues + + +def main() -> int: + args = parse_args() + report, issues = validate(args.file, args.min_frames, args.min_edges) + errors = sum(issue.level == "error" for issue in issues) + warnings = sum(issue.level == "warning" for issue in issues) + + if args.as_json: + print(json.dumps({**report, "issues": [asdict(issue) for issue in issues]}, ensure_ascii=False, indent=2)) + else: + print(f"{report['file']}: {report.get('page_count', 0)} page(s), " + f"{report.get('frame_count', 0)} frame(s), {report.get('edge_count', 0)} edge(s)") + for page in report.get("pages", []): + print(f" {page['name']}: {page.get('frames', 0)} frames, " + f"{page.get('vertices', 0)} vertices, {page.get('edges', 0)} edges") + for issue in issues: + where = " / ".join(value for value in (issue.page, issue.cell) if value) + suffix = f" ({where})" if where else "" + print(f" {issue.level.upper()} [{issue.code}] {issue.message}{suffix}") + print(f"Result: {errors} error(s), {warnings} warning(s)") + + return 1 if errors or (args.strict and warnings) else 0 + + +if __name__ == "__main__": + sys.exit(main())