feat: add draw-prototype-flow
This commit is contained in:
@@ -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。
|
||||||
@@ -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="产品名称
工作台
我的任务
数据概览
系统设置" 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="产品名称
工作台
我的任务
数据概览
系统设置" 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="任务名称 *
请输入任务名称" 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="任务类型 *
请选择类型 ▾" 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="任务说明
请输入必要的背景、范围和完成标准" 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
任务列表" 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
创建任务" 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
提交确认" 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
创建处理记录" 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 | 金额 | 是 | > 0,2 位小数 | 始终 | 影响合计 | 请输入有效金额 |
|
||||||
|
|
||||||
|
检查:原型中的标签与字段表一致;依赖字段有清空/保留规则;日期、金额、附件和枚举给出边界。
|
||||||
|
|
||||||
|
## 异常与恢复
|
||||||
|
|
||||||
|
| 异常 ID | 触发点 | 用户看到什么 | 数据是否保留 | 恢复动作 | 记录 |
|
||||||
|
| --- | --- | --- | --- | --- | --- |
|
||||||
|
| E-SAVE-TIMEOUT | A-SAVE | 保存超时消息 | 保留本地输入 | 重试 | 请求 ID |
|
||||||
|
|
||||||
|
检查:错误文案可行动;不可恢复问题给出返回路径;危险操作有二次确认;成功反馈不与实际状态矛盾。
|
||||||
|
|
||||||
|
## 端到端覆盖
|
||||||
|
|
||||||
|
每个核心任务至少验证一条成功路径和一条高风险分支:
|
||||||
|
|
||||||
|
```text
|
||||||
|
角色 → 入口 → 页面 → 动作 → 状态变化 → 下一角色/系统处理 → 终态 → 可见反馈
|
||||||
|
```
|
||||||
|
|
||||||
|
发布前抽查:
|
||||||
|
|
||||||
|
- 新用户第一次进入;
|
||||||
|
- 正常创建并完成;
|
||||||
|
- 中途保存和再次进入;
|
||||||
|
- 校验失败或服务失败;
|
||||||
|
- 无权限或配置缺失;
|
||||||
|
- 不可逆动作;
|
||||||
|
- 多角色交接;
|
||||||
|
- 已结束对象的只读/审计视角。
|
||||||
+259
@@ -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
@@ -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())
|
||||||
Reference in New Issue
Block a user