feat: add draw-prototype-flow

This commit is contained in:
2026-08-04 13:55:32 +08:00
parent 5ff8899b48
commit c113f68bf4
8 changed files with 975 additions and 0 deletions
+37
View File
@@ -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。
+138
View File
@@ -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 <skill-dir>/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 <skill-dir>/scripts/validate_drawio.py <output.drawio> --min-frames 1
```
存在两个以上交互画板时追加 `--min-edges 1`,确保至少有一条真实动线;正式交付按核心转移清单设置更高的最小连线数。
多页面或正式交付追加 `--strict`;如果环境有 Draw.io 导出工具,再导出 PNG/SVG 并目视检查全图和 2~3 个关键画板。不要把包含私有需求的文件上传到公共在线转换服务。
## 完成标准
- `.drawio` 能被 XML 解析并在 diagrams.net/Draw.io 打开;
- 图元和文字可单独编辑,关键跳转是有源和目标的连接线;
- 核心流程、关键分支、角色权限和业务状态相互一致;
- 校验无 error;正式交付在 `--strict` 下也无 warning,或逐条说明保留原因;
- 向用户列出产物路径、采用的假设、覆盖范围以及仍待产品决策的问题。
@@ -0,0 +1,73 @@
<?xml version="1.0" encoding="UTF-8"?>
<mxfile host="app.diagrams.net" agent="draw-prototype-flow" version="24.7.17" type="device" compressed="false">
<diagram id="prototype-template" name="00-原型与动线">
<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>
<mxCell id="0"/>
<mxCell id="1" parent="0"/>
<mxCell id="label-p01" value="01 · P-LIST · 任务列表" style="text;html=0;strokeColor=none;fillColor=none;fontColor=#10284B;fontSize=24;fontStyle=1;fontFamily=Microsoft YaHei;" vertex="1" parent="1"><mxGeometry x="0" y="15" width="1000" height="40" as="geometry"/></mxCell>
<mxCell id="p01-bg" value="" style="rounded=0;whiteSpace=wrap;html=0;fillColor=#F4F7FB;strokeColor=#D9E2EF;" vertex="1" parent="1"><mxGeometry x="0" y="70" width="1600" height="900" as="geometry"/></mxCell>
<mxCell id="p01-nav" value="产品名称&#xa;工作台&#xa;我的任务&#xa;数据概览&#xa;系统设置" style="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;" vertex="1" parent="1"><mxGeometry x="0" y="70" width="260" height="900" as="geometry"/></mxCell>
<mxCell id="p01-title" value="我的任务" style="text;html=0;strokeColor=none;fillColor=none;fontColor=#10284B;fontSize=30;fontStyle=1;fontFamily=Microsoft YaHei;align=left;" vertex="1" parent="1"><mxGeometry x="310" y="120" width="500" height="50" as="geometry"/></mxCell>
<mxCell id="p01-desc" value="查看进度、继续处理并创建新任务" style="text;html=0;strokeColor=none;fillColor=none;fontColor=#718198;fontSize=14;fontFamily=Microsoft YaHei;align=left;" vertex="1" parent="1"><mxGeometry x="310" y="170" width="600" height="30" as="geometry"/></mxCell>
<mxCell id="p01-filter" value="状态:全部 关键词:请输入名称 查询" style="rounded=1;whiteSpace=wrap;html=0;fillColor=#FFFFFF;strokeColor=#D9E2EF;fontColor=#40516A;fontSize=14;fontFamily=Microsoft YaHei;align=left;spacingLeft=18;arcSize=8;" vertex="1" parent="1"><mxGeometry x="310" y="225" width="1240" height="70" as="geometry"/></mxCell>
<mxCell id="p01-new" value="+ 创建任务" style="rounded=1;whiteSpace=wrap;html=0;fillColor=#1768E8;strokeColor=#1768E8;fontColor=#FFFFFF;fontSize=16;fontStyle=1;fontFamily=Microsoft YaHei;arcSize=10;" vertex="1" parent="1"><mxGeometry x="1330" y="130" width="220" height="48" as="geometry"/></mxCell>
<mxCell id="p01-table-head" value="编号 名称 状态 更新时间 操作" style="rounded=0;whiteSpace=wrap;html=0;fillColor=#EAF0F8;strokeColor=#D9E2EF;fontColor=#263750;fontSize=14;fontStyle=1;fontFamily=Microsoft YaHei;align=left;spacingLeft=18;" vertex="1" parent="1"><mxGeometry x="310" y="335" width="1240" height="54" as="geometry"/></mxCell>
<mxCell id="p01-table-row1" value="T-001 示例任务 A 待处理 2026-08-04 10:30 查看 编辑" style="rounded=0;whiteSpace=wrap;html=0;fillColor=#FFFFFF;strokeColor=#D9E2EF;fontColor=#40516A;fontSize=14;fontFamily=Microsoft YaHei;align=left;spacingLeft=18;" vertex="1" parent="1"><mxGeometry x="310" y="389" width="1240" height="62" as="geometry"/></mxCell>
<mxCell id="p01-table-row2" value="T-002 示例任务 B 已完成 2026-08-03 16:20 查看" style="rounded=0;whiteSpace=wrap;html=0;fillColor=#F7F9FC;strokeColor=#D9E2EF;fontColor=#40516A;fontSize=14;fontFamily=Microsoft YaHei;align=left;spacingLeft=18;" vertex="1" parent="1"><mxGeometry x="310" y="451" width="1240" height="62" as="geometry"/></mxCell>
<mxCell id="p01-meta" value="角色:R-USER|入口:登录后首页|功能:筛选、查看、创建|下一步:P-EDIT" style="text;html=0;strokeColor=none;fillColor=none;fontColor=#718198;fontSize=13;fontFamily=Microsoft YaHei;align=left;" vertex="1" parent="1"><mxGeometry x="310" y="915" width="1240" height="30" as="geometry"/></mxCell>
<mxCell id="label-p02" value="02 · P-EDIT · 创建任务 · 编辑中" style="text;html=0;strokeColor=none;fillColor=none;fontColor=#10284B;fontSize=24;fontStyle=1;fontFamily=Microsoft YaHei;" vertex="1" parent="1"><mxGeometry x="1720" y="15" width="1000" height="40" as="geometry"/></mxCell>
<mxCell id="p02-bg" value="" style="rounded=0;whiteSpace=wrap;html=0;fillColor=#F4F7FB;strokeColor=#D9E2EF;" vertex="1" parent="1"><mxGeometry x="1720" y="70" width="1600" height="900" as="geometry"/></mxCell>
<mxCell id="p02-nav" value="产品名称&#xa;工作台&#xa;我的任务&#xa;数据概览&#xa;系统设置" style="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;" vertex="1" parent="1"><mxGeometry x="1720" y="70" width="260" height="900" as="geometry"/></mxCell>
<mxCell id="p02-title" value="创建任务" style="text;html=0;strokeColor=none;fillColor=none;fontColor=#10284B;fontSize=30;fontStyle=1;fontFamily=Microsoft YaHei;align=left;" vertex="1" parent="1"><mxGeometry x="2030" y="120" width="500" height="50" as="geometry"/></mxCell>
<mxCell id="p02-form" value="" style="rounded=1;whiteSpace=wrap;html=0;fillColor=#FFFFFF;strokeColor=#D9E2EF;arcSize=8;" vertex="1" parent="1"><mxGeometry x="2030" y="210" width="1240" height="570" as="geometry"/></mxCell>
<mxCell id="p02-field1" value="任务名称 *&#xa;请输入任务名称" style="rounded=1;whiteSpace=wrap;html=0;fillColor=#FFFFFF;strokeColor=#CBD6E5;fontColor=#40516A;fontSize=14;fontFamily=Microsoft YaHei;align=left;spacingLeft=14;arcSize=8;" vertex="1" parent="1"><mxGeometry x="2080" y="270" width="540" height="74" as="geometry"/></mxCell>
<mxCell id="p02-field2" value="任务类型 *&#xa;请选择类型 ▾" style="rounded=1;whiteSpace=wrap;html=0;fillColor=#FFFFFF;strokeColor=#CBD6E5;fontColor=#40516A;fontSize=14;fontFamily=Microsoft YaHei;align=left;spacingLeft=14;arcSize=8;" vertex="1" parent="1"><mxGeometry x="2670" y="270" width="540" height="74" as="geometry"/></mxCell>
<mxCell id="p02-field3" value="任务说明&#xa;请输入必要的背景、范围和完成标准" style="rounded=1;whiteSpace=wrap;html=0;fillColor=#FFFFFF;strokeColor=#CBD6E5;fontColor=#40516A;fontSize=14;fontFamily=Microsoft YaHei;align=left;verticalAlign=top;spacingTop=14;spacingLeft=14;arcSize=8;" vertex="1" parent="1"><mxGeometry x="2080" y="390" width="1130" height="180" as="geometry"/></mxCell>
<mxCell id="p02-cancel" value="取消" style="rounded=1;whiteSpace=wrap;html=0;fillColor=#FFFFFF;strokeColor=#AFC1DA;fontColor=#27405F;fontSize=16;fontStyle=1;fontFamily=Microsoft YaHei;arcSize=10;" vertex="1" parent="1"><mxGeometry x="2720" y="700" width="200" height="50" as="geometry"/></mxCell>
<mxCell id="p02-submit" value="提交" style="rounded=1;whiteSpace=wrap;html=0;fillColor=#1768E8;strokeColor=#1768E8;fontColor=#FFFFFF;fontSize=16;fontStyle=1;fontFamily=Microsoft YaHei;arcSize=10;" vertex="1" parent="1"><mxGeometry x="2970" y="700" width="240" height="50" as="geometry"/></mxCell>
<mxCell id="p02-meta" value="角色:R-USER|入口:P-LIST / A-CREATE|状态:S-EDITING|下一步:P-CONFIRM" style="text;html=0;strokeColor=none;fillColor=none;fontColor=#718198;fontSize=13;fontFamily=Microsoft YaHei;align=left;" vertex="1" parent="1"><mxGeometry x="2030" y="915" width="1240" height="30" as="geometry"/></mxCell>
<mxCell id="label-p03" value="03 · P-CONFIRM · 提交确认" style="text;html=0;strokeColor=none;fillColor=none;fontColor=#10284B;fontSize=24;fontStyle=1;fontFamily=Microsoft YaHei;" vertex="1" parent="1"><mxGeometry x="3440" y="15" width="1000" height="40" as="geometry"/></mxCell>
<mxCell id="p03-bg" value="" style="rounded=0;whiteSpace=wrap;html=0;fillColor=#F4F7FB;strokeColor=#D9E2EF;" vertex="1" parent="1"><mxGeometry x="3440" y="70" width="1600" height="900" as="geometry"/></mxCell>
<mxCell id="p03-context" value="创建任务页面(保留用户已填写内容)" style="rounded=1;whiteSpace=wrap;html=0;fillColor=#FFFFFF;strokeColor=#D9E2EF;fontColor=#718198;fontSize=18;fontFamily=Microsoft YaHei;" vertex="1" parent="1"><mxGeometry x="3540" y="170" width="1400" height="650" as="geometry"/></mxCell>
<mxCell id="p03-overlay" value="" style="rounded=0;whiteSpace=wrap;html=0;fillColor=#10284B;strokeColor=none;opacity=25;" vertex="1" parent="1"><mxGeometry x="3440" y="70" width="1600" height="900" as="geometry"/></mxCell>
<mxCell id="p03-modal" value="" style="rounded=1;whiteSpace=wrap;html=0;fillColor=#FFFFFF;strokeColor=#CBD6E5;arcSize=10;" vertex="1" parent="1"><mxGeometry x="3750" y="300" width="980" height="420" as="geometry"/></mxCell>
<mxCell id="p03-title" value="确认提交任务?" style="text;html=0;strokeColor=none;fillColor=none;fontColor=#10284B;fontSize=26;fontStyle=1;fontFamily=Microsoft YaHei;align=left;" vertex="1" parent="1"><mxGeometry x="3820" y="355" width="600" height="45" as="geometry"/></mxCell>
<mxCell id="p03-desc" value="提交后将进入处理流程。你仍可在任务详情中查看进度。" style="text;html=0;strokeColor=none;fillColor=none;fontColor=#40516A;fontSize=16;fontFamily=Microsoft YaHei;align=left;whiteSpace=wrap;" vertex="1" parent="1"><mxGeometry x="3820" y="425" width="820" height="70" as="geometry"/></mxCell>
<mxCell id="p03-back" value="返回修改" style="rounded=1;whiteSpace=wrap;html=0;fillColor=#FFFFFF;strokeColor=#AFC1DA;fontColor=#27405F;fontSize=16;fontStyle=1;fontFamily=Microsoft YaHei;arcSize=10;" vertex="1" parent="1"><mxGeometry x="4080" y="590" width="220" height="52" as="geometry"/></mxCell>
<mxCell id="p03-confirm" value="确认提交" style="rounded=1;whiteSpace=wrap;html=0;fillColor=#18864B;strokeColor=#18864B;fontColor=#FFFFFF;fontSize=16;fontStyle=1;fontFamily=Microsoft YaHei;arcSize=10;" vertex="1" parent="1"><mxGeometry x="4340" y="590" width="260" height="52" as="geometry"/></mxCell>
<mxCell id="p03-meta" value="角色:R-USER|触发:P-EDIT / A-SUBMIT|取消:返回编辑|确认:S-SUBMITTED" style="text;html=0;strokeColor=none;fillColor=none;fontColor=#718198;fontSize=13;fontFamily=Microsoft YaHei;align=left;" vertex="1" parent="1"><mxGeometry x="3750" y="915" width="1240" height="30" as="geometry"/></mxCell>
<mxCell id="label-p04" value="04 · FLOW-OVERVIEW · 端到端用户动线" style="text;html=0;strokeColor=none;fillColor=none;fontColor=#10284B;fontSize=24;fontStyle=1;fontFamily=Microsoft YaHei;" vertex="1" parent="1"><mxGeometry x="0" y="1175" width="1200" height="40" as="geometry"/></mxCell>
<mxCell id="p04-bg" value="" style="rounded=0;whiteSpace=wrap;html=0;fillColor=#FFFFFF;strokeColor=#D9E2EF;" vertex="1" parent="1"><mxGeometry x="0" y="1230" width="1600" height="900" as="geometry"/></mxCell>
<mxCell id="p04-lane-user" value="R-USER 用户" style="swimlane;html=0;rounded=0;startSize=70;horizontal=0;fillColor=#EEF4FF;strokeColor=#9CBDF0;fontColor=#1768E8;fontSize=16;fontStyle=1;fontFamily=Microsoft YaHei;" vertex="1" parent="1"><mxGeometry x="70" y="1310" width="1460" height="330" as="geometry"/></mxCell>
<mxCell id="p04-lane-system" value="R-SYSTEM 系统" style="swimlane;html=0;rounded=0;startSize=70;horizontal=0;fillColor=#F4F7FB;strokeColor=#D9E2EF;fontColor=#40516A;fontSize=16;fontStyle=1;fontFamily=Microsoft YaHei;" vertex="1" parent="1"><mxGeometry x="70" y="1670" width="1460" height="330" as="geometry"/></mxCell>
<mxCell id="flow-start" value="开始" style="ellipse;whiteSpace=wrap;html=0;fillColor=#EEF4FF;strokeColor=#1768E8;fontColor=#1768E8;fontSize=14;fontStyle=1;fontFamily=Microsoft YaHei;" vertex="1" parent="1"><mxGeometry x="180" y="1430" width="120" height="70" as="geometry"/></mxCell>
<mxCell id="flow-list" value="P-LIST&#xa;任务列表" style="rounded=1;whiteSpace=wrap;html=0;fillColor=#FFFFFF;strokeColor=#1768E8;fontColor=#10284B;fontSize=14;fontStyle=1;fontFamily=Microsoft YaHei;arcSize=10;" vertex="1" parent="1"><mxGeometry x="410" y="1415" width="220" height="100" as="geometry"/></mxCell>
<mxCell id="flow-edit" value="P-EDIT&#xa;创建任务" style="rounded=1;whiteSpace=wrap;html=0;fillColor=#FFFFFF;strokeColor=#1768E8;fontColor=#10284B;fontSize=14;fontStyle=1;fontFamily=Microsoft YaHei;arcSize=10;" vertex="1" parent="1"><mxGeometry x="760" y="1415" width="220" height="100" as="geometry"/></mxCell>
<mxCell id="flow-confirm" value="P-CONFIRM&#xa;提交确认" style="rhombus;whiteSpace=wrap;html=0;fillColor=#FFF8E8;strokeColor=#D97706;fontColor=#604A16;fontSize=14;fontStyle=1;fontFamily=Microsoft YaHei;" vertex="1" parent="1"><mxGeometry x="1110" y="1395" width="220" height="140" as="geometry"/></mxCell>
<mxCell id="flow-process" value="S-SUBMITTED&#xa;创建处理记录" style="rounded=1;whiteSpace=wrap;html=0;fillColor=#EAF8F0;strokeColor=#89D3A8;fontColor=#18864B;fontSize=14;fontStyle=1;fontFamily=Microsoft YaHei;arcSize=10;" vertex="1" parent="1"><mxGeometry x="760" y="1785" width="260" height="100" as="geometry"/></mxCell>
<mxCell id="flow-end" value="完成" style="ellipse;whiteSpace=wrap;html=0;fillColor=#EAF8F0;strokeColor=#18864B;fontColor=#18864B;fontSize=14;fontStyle=1;fontFamily=Microsoft YaHei;" vertex="1" parent="1"><mxGeometry x="1190" y="1800" width="120" height="70" as="geometry"/></mxCell>
<mxCell id="flow-e1" value="进入" style="edgeStyle=orthogonalEdgeStyle;rounded=1;html=0;endArrow=classic;endFill=1;strokeWidth=2;strokeColor=#1768E8;fontColor=#40516A;" edge="1" parent="1" source="flow-start" target="flow-list"><mxGeometry relative="1" as="geometry"/></mxCell>
<mxCell id="flow-e2" value="创建" style="edgeStyle=orthogonalEdgeStyle;rounded=1;html=0;endArrow=classic;endFill=1;strokeWidth=2;strokeColor=#1768E8;fontColor=#40516A;" edge="1" parent="1" source="flow-list" target="flow-edit"><mxGeometry relative="1" as="geometry"/></mxCell>
<mxCell id="flow-e3" value="提交" style="edgeStyle=orthogonalEdgeStyle;rounded=1;html=0;endArrow=classic;endFill=1;strokeWidth=2;strokeColor=#D97706;fontColor=#40516A;" edge="1" parent="1" source="flow-edit" target="flow-confirm"><mxGeometry relative="1" as="geometry"/></mxCell>
<mxCell id="flow-e4" value="确认" style="edgeStyle=orthogonalEdgeStyle;rounded=1;html=0;endArrow=classic;endFill=1;strokeWidth=2;strokeColor=#18864B;fontColor=#40516A;" edge="1" parent="1" source="flow-confirm" target="flow-process"><mxGeometry relative="1" as="geometry"/></mxCell>
<mxCell id="flow-e5" value="处理成功" style="edgeStyle=orthogonalEdgeStyle;rounded=1;html=0;endArrow=classic;endFill=1;strokeWidth=2;strokeColor=#18864B;fontColor=#40516A;" edge="1" parent="1" source="flow-process" target="flow-end"><mxGeometry relative="1" as="geometry"/></mxCell>
<mxCell id="label-p05" value="05 · SPEC-INDEX · 页面、状态与操作索引" style="text;html=0;strokeColor=none;fillColor=none;fontColor=#10284B;fontSize=24;fontStyle=1;fontFamily=Microsoft YaHei;" vertex="1" parent="1"><mxGeometry x="1720" y="1175" width="1200" height="40" as="geometry"/></mxCell>
<mxCell id="p05-bg" value="" style="rounded=0;whiteSpace=wrap;html=0;fillColor=#FFFFFF;strokeColor=#D9E2EF;" vertex="1" parent="1"><mxGeometry x="1720" y="1230" width="1600" height="900" as="geometry"/></mxCell>
<mxCell id="p05-title" value="页面与动作索引" style="text;html=0;strokeColor=none;fillColor=none;fontColor=#10284B;fontSize=30;fontStyle=1;fontFamily=Microsoft YaHei;align=left;" vertex="1" parent="1"><mxGeometry x="1810" y="1310" width="700" height="50" as="geometry"/></mxCell>
<mxCell id="p05-head" value="稳定 ID 页面/状态 角色 主操作 下一步" style="rounded=0;whiteSpace=wrap;html=0;fillColor=#EAF0F8;strokeColor=#D9E2EF;fontColor=#263750;fontSize=14;fontStyle=1;fontFamily=Microsoft YaHei;align=left;spacingLeft=18;" vertex="1" parent="1"><mxGeometry x="1810" y="1400" width="1420" height="56" as="geometry"/></mxCell>
<mxCell id="p05-row1" value="P-LIST 任务列表 R-USER A-CREATE P-EDIT" style="rounded=0;whiteSpace=wrap;html=0;fillColor=#FFFFFF;strokeColor=#D9E2EF;fontColor=#40516A;fontSize=14;fontFamily=Microsoft YaHei;align=left;spacingLeft=18;" vertex="1" parent="1"><mxGeometry x="1810" y="1456" width="1420" height="64" as="geometry"/></mxCell>
<mxCell id="p05-row2" value="P-EDIT 创建任务 R-USER A-SUBMIT P-CONFIRM" style="rounded=0;whiteSpace=wrap;html=0;fillColor=#F7F9FC;strokeColor=#D9E2EF;fontColor=#40516A;fontSize=14;fontFamily=Microsoft YaHei;align=left;spacingLeft=18;" vertex="1" parent="1"><mxGeometry x="1810" y="1520" width="1420" height="64" as="geometry"/></mxCell>
<mxCell id="p05-row3" value="P-CONFIRM 提交确认 R-USER A-CONFIRM S-SUBMITTED" style="rounded=0;whiteSpace=wrap;html=0;fillColor=#FFFFFF;strokeColor=#D9E2EF;fontColor=#40516A;fontSize=14;fontFamily=Microsoft YaHei;align=left;spacingLeft=18;" vertex="1" parent="1"><mxGeometry x="1810" y="1584" width="1420" height="64" as="geometry"/></mxCell>
<mxCell id="p05-note" value="规则:所有原型、动线、状态机与权限矩阵复用稳定 ID;显示序号只代表当前阅读顺序。" style="rounded=1;whiteSpace=wrap;html=0;fillColor=#FFF8E8;strokeColor=#E6BE65;fontColor=#604A16;fontSize=14;fontFamily=Microsoft YaHei;align=left;spacingLeft=18;arcSize=8;" vertex="1" parent="1"><mxGeometry x="1810" y="1730" width="1420" height="80" as="geometry"/></mxCell>
<mxCell id="cross-e1" value="创建任务" style="edgeStyle=orthogonalEdgeStyle;rounded=1;html=0;endArrow=classic;endFill=1;strokeWidth=2;strokeColor=#1768E8;fontColor=#40516A;" edge="1" parent="1" source="p01-new" target="p02-title"><mxGeometry relative="1" as="geometry"/></mxCell>
<mxCell id="cross-e2" value="提交确认" style="edgeStyle=orthogonalEdgeStyle;rounded=1;html=0;endArrow=classic;endFill=1;strokeWidth=2;strokeColor=#18864B;fontColor=#40516A;" edge="1" parent="1" source="p02-submit" target="p03-title"><mxGeometry relative="1" as="geometry"/></mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>
@@ -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": ""
}
]
}
]
}
@@ -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-规格`:索引、权限、字段与口径。
如果用户要求单页巨幅板,仍先在左上放导航索引,并限制每行画板数量。
@@ -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 | 金额 | 是 | > 02 位小数 | 始终 | 影响合计 | 请输入有效金额 |
检查:原型中的标签与字段表一致;依赖字段有清空/保留规则;日期、金额、附件和枚举给出边界。
## 异常与恢复
| 异常 ID | 触发点 | 用户看到什么 | 数据是否保留 | 恢复动作 | 记录 |
| --- | --- | --- | --- | --- | --- |
| E-SAVE-TIMEOUT | A-SAVE | 保存超时消息 | 保留本地输入 | 重试 | 请求 ID |
检查:错误文案可行动;不可恢复问题给出返回路径;危险操作有二次确认;成功反馈不与实际状态矛盾。
## 端到端覆盖
每个核心任务至少验证一条成功路径和一条高风险分支:
```text
角色 → 入口 → 页面 → 动作 → 状态变化 → 下一角色/系统处理 → 终态 → 可见反馈
```
发布前抽查:
- 新用户第一次进入;
- 正常创建并完成;
- 中途保存和再次进入;
- 校验失败或服务失败;
- 无权限或配置缺失;
- 不可逆动作;
- 多角色交接;
- 已结束对象的只读/审计视角。
+259
View File
@@ -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())
+236
View File
@@ -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}(?<!\d)\d{2,3}(?!\d)")
FLOW_PAGE_RE = re.compile(r"(?:总览|流程|动线|flow)", re.I)
@dataclass
class Issue:
level: str
code: str
message: str
page: str | None = None
cell: str | None = None
def label_text(value: str | None) -> str:
value = html.unescape(value or "")
value = re.sub(r"<br\s*/?>", " ", 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())