Files
railwaysystem/doc/wxy_doc/railwaysystem.html
T
wangzhibiao b12ef9c9e4 docs(doc): 结构化梳理项目doc与docs文档中心并补齐导航索引
- 厘清 doc(业务与产品需求)与 docs(技术架构与代码审计)的职责边界
- doc 根目录保持一期正式生效基线 PRD V1.0,甲供原始材料归整至 raw_materials/,历史过程稿归档至 archive/
- 规范化接纳 wxy_doc/ 早期原型与技术说明并完善说明
- 为 doc/、docs/ 及项目根目录补齐结构化 README.md 导航索引与一键启动指引
2026-09-14 00:39:39 +08:00

1663 lines
54 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>学员管理系统一期业务流程图</title>
<style>
:root{
--bg:#f3f6fa;--panel:#fff;--ink:#18283b;--muted:#62738a;
--line:#dce4ee;--blue:#285ed6;--teal:#087f8c;--amber:#a96608;
}
*{box-sizing:border-box}
body{
margin:0;background:var(--bg);color:var(--ink);
font-family:system-ui,-apple-system,"Segoe UI","Microsoft YaHei",sans-serif;
}
button{font:inherit;cursor:pointer}
.app{display:grid;grid-template-columns:252px minmax(0,1fr);min-height:100vh}
aside{
background:#14243a;color:#fff;padding:26px 16px;
position:sticky;top:0;height:100vh;overflow:auto;
}
.brand{padding:0 12px 22px;border-bottom:1px solid #ffffff20;margin-bottom:18px}
.brand small{color:#a9bddb;letter-spacing:2px;font-size:11px}
.brand h1{font-size:20px;line-height:1.6;margin:10px 0}
.brand p{font-size:12px;color:#a9bddb;line-height:1.7;margin:0}
nav{display:grid;gap:6px}
nav button{
text-align:left;border:0;background:transparent;color:#c8d5e7;
padding:12px;border-radius:9px;font-size:14px;
}
nav button:hover{background:#ffffff0b}
nav button.active{background:#2a5bbb;color:#fff}
nav button span{display:inline-block;width:28px;font-size:12px;opacity:.7}
.side-note{font-size:12px;color:#a9bddb;line-height:1.9;padding:20px 12px}
main{padding:30px;min-width:0}
.kicker{font-size:12px;color:var(--teal);font-weight:700;letter-spacing:1px}
h2{font-size:27px;margin:8px 0 12px;line-height:1.4}
.subtitle{color:var(--muted);line-height:1.9;max-width:1000px;margin:0}
.overview{
margin:18px 0;background:#eaf1ff;border-left:4px solid var(--blue);
padding:13px 16px;font-size:13px;line-height:1.9;
}
.toolbar{
display:flex;gap:8px;align-items:center;flex-wrap:wrap;
padding:14px 0;
}
button.tool{
border:1px solid #ced8e5;border-radius:7px;
background:#fff;color:var(--ink);padding:8px 12px;font-size:13px;
}
button.tool:hover{background:#eaf1ff;border-color:#9ab5ef}
#scaleText{min-width:52px;text-align:center;font-size:13px;color:var(--muted)}
.legend{display:flex;gap:15px;flex-wrap:wrap;margin-left:auto;font-size:12px;color:var(--muted)}
.legend i{
width:10px;height:10px;border-radius:3px;display:inline-block;
margin-right:5px;border:1px solid #a9bbd1;
}
.legend .b{background:#eef4ff}.legend .g{background:#e6f7f4}
.legend .y{background:#fff4dc}.legend .p{background:#fff0f2}
.canvas-shell{
background:#fff;border:1px solid var(--line);
border-radius:12px;overflow:hidden;
}
.canvas{
overflow:auto;height:68vh;min-height:450px;
background-image:radial-gradient(#dae3ef .7px,transparent .7px);
background-size:20px 20px;
}
#graph{display:block;max-width:none}
svg text{font-family:system-ui,-apple-system,"Microsoft YaHei",sans-serif}
svg .node{cursor:pointer;transition:opacity .15s}
svg .node:focus{outline:none}
svg .node:focus .shape{stroke:#172f64;stroke-width:3}
svg .edge{transition:opacity .15s}
svg .edge path{fill:none;stroke:#9baec5;stroke-width:1.5}
svg .edge.dashed path{stroke-dasharray:6 5}
svg .edge.active path{stroke:#245bd1;stroke-width:2.8}
svg .dim{opacity:.13}
svg .node.active .shape{stroke:#245bd1;stroke-width:3}
.canvas-help{padding:11px 16px;border-top:1px solid var(--line);color:var(--muted);font-size:12px}
.lower{display:grid;grid-template-columns:1fr 1fr;gap:18px;margin-top:20px}
.panel{padding:20px;background:#fff;border:1px solid var(--line);border-radius:10px}
.panel h3{font-size:15px;margin:0 0 12px}
.panel p,.panel li{font-size:13px;line-height:1.9}
.panel ul{padding-left:20px;margin:0}
#selection{color:var(--muted)}
#selection strong{color:var(--ink)}
.path-item{padding:9px 0;border-bottom:1px solid #edf1f6;font-size:13px;line-height:1.7}
.path-item:last-child{border:0}
.path-item button{border:0;background:none;color:var(--blue);padding:0;text-align:left}
.path-label{color:var(--muted);display:block;margin:3px 0}
details{margin-top:20px;background:#fff;border:1px solid var(--line);border-radius:10px;padding:16px 20px}
summary{cursor:pointer;font-size:14px;font-weight:650}
.table-wrap{overflow:auto;margin-top:14px}
table{border-collapse:collapse;width:100%;font-size:13px}
th,td{text-align:left;border:1px solid var(--line);padding:10px 12px;line-height:1.65}
th{background:#eef3f9;white-space:nowrap}
td:first-child{width:52px;color:var(--muted)}
footer{margin-top:22px;font-size:12px;line-height:1.9;color:var(--muted)}
@media(max-width:960px){
.app{grid-template-columns:1fr}
aside{position:static;height:auto;padding:18px}
.brand{padding-bottom:10px;margin-bottom:10px}
.brand h1{margin:5px 0}
nav{display:flex;overflow:auto;padding-bottom:5px}
nav button{white-space:nowrap}
.side-note{display:none}
main{padding:18px}.lower{grid-template-columns:1fr}
.legend{margin-left:0}.canvas{height:62vh}
}
@media print{
.app{display:block}aside,.toolbar,.canvas-help,footer{display:none}
body,main{background:#fff;padding:0}
.canvas{height:auto;overflow:visible;background:none}
#graph{width:100%!important;height:auto!important}
.canvas-shell{border:0}
.lower{display:block}.panel{break-inside:avoid;margin-top:12px}
details{display:none}
}
</style>
</head>
<body>
<div class="app">
<aside>
<div class="brand">
<small>PHASE ONE · PROCESS ATLAS</small>
<h1>学员管理系统<br>一期业务流程图</h1>
<p>模块关系 · 内部功能 · 数据流向<br>状态变化 · 异常处理</p>
</div>
<nav id="nav" aria-label="流程图目录"></nav>
<div class="side-note">
目标设计,不代表已开发完成。<br>
演示阶段使用模拟设备接口。<br>
业务记录、状态和统计真实联动。<br><br>
蓝色:业务处理<br>
绿色:记录或结果<br>
黄色:条件判断<br>
粉色:提醒或异常
</div>
</aside>
<main>
<div class="kicker">培训基地软件平台 · 一期</div>
<h2 id="title"></h2>
<p class="subtitle" id="subtitle"></p>
<div class="overview">
已按需求修正:教师冲突告警、同一学员同一课程一分钟内签到取首条、
授权下发时间记录、学习时长异常示例,以及本期结业联动。
未明确的业务规则列在每张图的说明中。
</div>
<div class="toolbar">
<button class="tool" id="fit">适应宽度</button>
<button class="tool" id="minus" aria-label="缩小">-</button>
<span id="scaleText">100%</span>
<button class="tool" id="plus" aria-label="放大">+</button>
<button class="tool" id="actual">100%</button>
<button class="tool" id="clear">清除节点选择</button>
<button class="tool" id="export">导出当前SVG</button>
<button class="tool" id="print">打印当前图</button>
<div class="legend">
<span><i class="b"></i>处理</span>
<span><i class="g"></i>记录/输出</span>
<span><i class="y"></i>判断</span>
<span><i class="p"></i>异常/提醒</span>
<span>虚线:关联依据</span>
</div>
</div>
<div class="canvas-shell">
<div class="canvas" id="viewport">
<svg id="graph" xmlns="http://www.w3.org/2000/svg"
role="img" aria-label="业务流程图"></svg>
</div>
<div class="canvas-help">
点击节点可突出显示其直接上游和下游;下方列出完整关联说明。
放大后可使用滚动条查看。箭头上的完整说明也可悬停查看。
</div>
</div>
<div class="lower">
<section class="panel">
<h3>节点关联</h3>
<div id="selection" aria-live="polite"></div>
</section>
<section class="panel">
<h3>需求说明与待定规则</h3>
<ul id="notes"></ul>
</section>
</div>
<details id="paths">
<summary>查看本图全部处理路径</summary>
<div class="table-wrap">
<table>
<thead><tr><th>序号</th><th>来源</th><th>处理或传递内容</th><th>去向</th><th>类型</th></tr></thead>
<tbody id="pathBody"></tbody>
</table>
</div>
</details>
<footer>
内容依据:一期原始需求、学员管理系统功能分析及本次讨论的设计调整。
参训、课次、入住、版本记录等为落实需求补充的数据组织方式。
本文件仅用于流程展示,不连接数据库,也不执行实际设备操作。
</footer>
</main>
</div>
<script>
"use strict";
/*
节点格式:编号|文字|类型
类型:p处理、o记录或结果、d判断、w异常或提醒。
边格式:来源>去向|处理说明|d(可选,表示虚线)。
行只控制展示布局,实际处理关系以箭头和路径表为准。
*/
function make(title, subtitle, rows, edges, notes){
const nodes=[];
rows.forEach((row,r)=>row.forEach((item,c)=>{
const parts=item.split("|");
nodes.push({id:parts[0],text:parts[1],type:parts[2]||"p",r,c});
}));
return {
title,subtitle,rows,nodes,notes,
edges:edges.map(item=>{
const parts=item.split("|");
const ends=parts[0].split(">");
return {from:ends[0],to:ends[1],label:parts[1]||"",dashed:parts[2]==="d"};
})
};
}
const graphs=[
make("00 一期系统总关联图",
"课程和人员共同支撑培训组织;住宿与教学形成并行业务分支;签到、成绩和学习明细共同支撑统计与画像。",
[
["person|一、人员管理","course|二、课程管理"],
["plan|三、学习班管理","ingest|六、数据接入"],
["dorm|四、宿舍管理","check|二、课程签到管理","score|五、成绩管理"],
["stats|七、学习情况统计分析"],
["portrait|八、学员画像","finish|本期结业与权限收尾"],
["sec|九、系统权限管理","all|全部业务入口和后台任务|o"]
],
[
"person>plan|教师、学员及人脸档案",
"course>plan|课程内容、资源及版本",
"plan>dorm|报到、住宿安排及培训期限",
"person>dorm|提供学员人脸档案|d",
"plan>check|提供参训名单和具体课次",
"plan>score|明确学员、班次及课程归属",
"plan>ingest|用于匹配业务对象|d",
"ingest>check|电子班牌自动签到",
"ingest>score|第三方实训成绩",
"ingest>stats|学习使用原始明细",
"check>stats|人工与自动签到记录",
"score>stats|按确认口径提供成绩",
"stats>portrait|考勤、成绩及学习时长指标",
"portrait>person|学员详情及成长轨迹|d",
"course>portrait|推荐清单可关联的教学资源|d",
"plan>finish|培训结束与本期参训关系",
"stats>finish|提供结业查看依据|d",
"finish>dorm|按规则处理本期住宿权限",
"sec>all|登录、角色、数据权限与审计贯穿全部模块|d"
],
[
"报到确认参加本期培训;签到记录具体课次出勤,两者分开保存。",
"数据接入负责收集及校验数据;签到和成绩模块负责业务维护。",
"统计形成指标,画像根据指标和规则形成标签、雷达图及推荐清单。",
"模拟接口替代真实设备输入输出;业务记录及状态流转仍需真实保存。",
"结业不是必须先打开画像页面;权限到期任务也独立执行。"
]),
make("01 人员管理",
"教师档案关联授课履历,学员档案关联参训、报到、学习和画像;状态变化按确认范围联动权限。",
[
["entry|进入人员管理"],
["teacher|教师资料查看与编辑","student|学员资料维护或批量导入"],
["teach|关联授课记录","cert|维护资质证书和有效期","identity|身份证加密存储及账号关联","face|管理多张人脸底库照片"],
["timeline|按学期或年份展示授课时间轴|o","expiry|定时检查证书有效期","status|维护在读、休学、结业、退学等状态","learning|查看课程和学习信息"],
["leave|离职:逻辑删除并冻结账号","alert|到期前自动提醒|w","scope|确定本次变更影响范围|d","life|汇总全生命周期学习轨迹|o"],
["history|保留教师历史授课记录|o","freeze|联动门禁与电子班牌权限","audit|记录编辑及状态变更日志|o"],
["report|报到模块采集人脸数据|o","permissions|保存权限处理结果与失败记录|o"]
],
[
"entry>teacher|选择教师管理",
"entry>student|选择学员管理",
"teacher>teach|关联开班和课次",
"teach>timeline|按学期或年份筛选",
"teacher>cert|维护证书资料",
"cert>expiry|按配置规则检查",
"expiry>alert|达到提前提醒条件",
"teacher>leave|办理离职",
"leave>history|保留历史关系",
"student>identity|维护或导入基本资料,支持下载Excel模板",
"student>face|多底库照片管理,真实识别精度后续验证",
"identity>status|保存人员状态",
"identity>learning|关联参训和学习信息",
"learning>life|汇总报到、签到、成绩和画像历史",
"status>scope|区分人员整体状态与本期参训状态",
"scope>freeze|按已确认规则冻结关联权限",
"freeze>permissions|保存处理结果",
"report>face|同步本次采集的人脸数据",
"teacher>audit|记录所有编辑行为|d",
"identity>audit|记录编辑行为|d",
"status>audit|记录变更前后状态|d",
"leave>audit|记录离职与账号处理|d"
],
[
"教师基础信息含个人情况和授课信息;授课履历从开班、课次关联记录汇总。",
"学员支持Excel模板下载与批量导入;人脸档案支持多张照片。",
"学员身份、课程、学习、签到、成绩和画像应关联到同一个学员标识。",
"某一期结业影响哪些权限必须明确,不能默认冻结其他有效班次。",
"模拟阶段展示人脸采集和结果,不代表已验证真实1:N识别精度。"
]),
make("02 课程与人工签到管理",
"课程信息、教学资源、版本及发布状态共同支撑开班;人工签到与自动采集进入统一业务记录。",
[
["create|创建、查看或编辑课程","manual|人工签到管理入口"],
["base|唯一课程编码、名称、类型和时长","target|选择培训班、课次及学员"],
["content|维护教学目标和课程内容","act|查询、新增、编辑、删除或补签"],
["resource|关联文档、视频、3D模型和实训场景","verify|校验对象关联及操作权限"],
["valid|校验资料格式与大小|d","saved|保存签到及操作记录|o"],
["preview|支持资源预览|o","reject|反馈问题并重新上传|w","auto|自动采集的签到记录|o"],
["version|维护课程版本及标签","stats|供签到统计使用|o"],
["draft|草稿|o","pending|待审核|o"],
["review|审核结果|d","delete|按权限执行课程删除"],
["published|已发布|o","archive|已归档|o"],
["plan|绑定开班计划|o","reference|删除时的历史引用处理规则|w"]
],
[
"create>base|维护基本属性",
"base>content|填写内容",
"content>resource|关联资源",
"resource>valid|检查大小及允许格式",
"valid>preview|符合规则",
"valid>reject|不符合规则",
"reject>resource|重新选择资料",
"preview>version|保存资源关系、版本及热门等标签",
"version>draft|保存草稿",
"draft>pending|提交审核",
"pending>review|按审核流程处理",
"review>published|审核通过",
"review>draft|未通过时按规则退回",
"published>archive|执行归档",
"published>plan|供开班选择",
"create>delete|申请删除",
"delete>reference|保护历史引用的方式需确定|d",
"manual>target|选择处理对象",
"target>act|执行人工维护",
"act>verify|校验并记录修改原因",
"verify>saved|保存有效操作",
"auto>saved|与人工维护共同形成签到数据",
"saved>stats|进入统计"
],
[
"课程字段包括全局唯一业务编码、名称、类型、时长、目标、内容和配套资料。",
"资料支持文档、视频、模型链接及实训场景;需格式、大小校验和预览。",
"课程状态覆盖草稿、待审核、已发布、已归档;支持版本及标签管理。",
"删除已被历史培训引用的课程如何处理,属于需要确定的实现规则。",
"课程资源关联不等于重新开发VR或3D教学系统。"
]),
make("03 学习班与报到管理",
"开班计划管理时间、课程、教师和招生;参训名单、课表、住宿安排共同支撑报到查询。",
[
["create|新建或复制开班计划"],
["config|填写课程、时间、地点、教师、人数和条件"],
["conflict|检测同一时段同一教师冲突|d","list|建立参训名单","schedule|安排课次、教室与实训场地"],
["warn|出现冲突时告警|w","count|实时统计报名人数","saved|保存开班计划状态|o"],
["threshold|人数达到招生人数90%|d","identify|一体机模拟身份证或人脸识别","deadline|检查开班后X日报到时效"],
["notice|提醒教师|w","success|识别成功?|d","late|核对超期未报到及请假情况|d"],
["human|管理员人工核验","eligible|核对参训资格及是否已报到","cancel|符合条件时取消资格"],
["humanok|人工核验通过?|d","alloc|读取预分配或办理住宿安排","release|释放对应宿舍资源|o"],
["denied|保存未通过核验结果|w","report|保存报到状态与时间|o","face|人脸同步学员信息模块|o"],
["result|返回宿舍、教室、实训场地和课程表|o","dorm|分配结果同步宿舍管理|o"]
],
[
"create>config|新建或复制后调整参数",
"config>conflict|自动检测",
"conflict>warn|发现冲突",
"conflict>saved|无冲突时保存",
"warn>saved|告警后的保存或调整策略待定",
"config>list|按报名条件建立名单",
"list>count|实时计数",
"count>threshold|检查预警阈值",
"threshold>notice|达到90%时提醒",
"config>schedule|形成教学安排",
"saved>identify|开放本期报到",
"identify>success|取得模拟识别结果",
"success>eligible|识别成功",
"success>human|识别失败",
"human>humanok|管理员确认",
"humanok>eligible|核验通过",
"humanok>denied|核验未通过",
"list>eligible|提供本期参训名单|d",
"eligible>alloc|有资格且尚未报到",
"eligible>result|已报到时读取现有结果",
"alloc>report|确认住宿安排",
"report>result|完成报到",
"schedule>result|查询教室、场地和课表|d",
"identify>face|同步有效采集的人脸数据",
"alloc>dorm|同步宿舍分配",
"saved>deadline|按计划时间检查",
"deadline>late|识别超期对象",
"report>late|提供当前报到状态|d",
"late>cancel|满足取消规则",
"cancel>release|释放预占资源"
],
[
"计划状态:未开始、报名中、进行中、已结束、已取消。",
"报到状态:未报到、已报到、请假、放弃。",
"教师时间冲突原文要求告警,不等于必须阻止保存。",
"90%为提醒阈值;超额报名是否允许需确认。",
"报到完成后应能取得宿舍、教室、实训场地及课表信息。",
"X值、请假例外、预分配时间和人工确认方式需确定。"
]),
make("04 宿舍与开门权限管理",
"床位是资源,入住是占用关系,授权是开门资格;分配、下发、到期和审计分别留存。",
[
["resource|维护楼栋、楼层、房间和床位"],
["room|维护空置、部分入住、已满、维修中状态","request|获取本期学员住宿需求"],
["match|按性别和培训班自动匹配,优先同班同寝"],
["available|存在可用床位?|d"],
["adjust|无床位时提示并调整|w","stay|保存分配或入住关系|o"],
["face|学员人脸档案|o","combine|组合学员、宿舍门锁和有效期","endtime|开班结束时间|o"],
["send|模拟自动下发人脸开门权限"],
["ok|下发成功?|d"],
["yes|保存授权成功状态|o","no|保存失败原因和重试次数|w"],
["log|记录下发时间、结果及每次重试|o","temporary|管理员创建短期开门密码授权"],
["expire|授权到期或受控状态变化","query|按权限查看编辑授权及审计记录"],
["revoke|模拟执行失效、冻结或撤销"],
["rlog|保存执行结果,失败可重试|o"]
],
[
"resource>room|维护资源",
"room>match|提供可用床位",
"request>match|提供性别及培训班归属",
"match>available|检查资源",
"available>adjust|无合适床位",
"adjust>match|调整后重新匹配",
"available>stay|确认可分配床位",
"stay>combine|提供对应宿舍和门锁",
"face>combine|提供有效人脸数据",
"endtime>combine|授权期限与开班计划联动",
"combine>send|自动触发模拟下发",
"send>ok|获取执行结果",
"ok>yes|成功",
"ok>no|失败",
"no>send|重试",
"yes>log|记录时间及结果",
"no>log|记录失败及重试次数",
"log>query|支持查看和审计",
"temporary>log|保留临时授权审计",
"temporary>expire|到期自动处理",
"yes>expire|检查有效期",
"expire>revoke|按规则执行",
"revoke>rlog|记录执行结果",
"rlog>revoke|失败时重试",
"rlog>query|供查询追溯"
],
[
"授权记录必须包含下发时间、结果及失败重试次数。",
"授权期限与开班结束时间联动,结业后按规则自动失效。",
"支持管理员短期开门密码和权限审计。",
"房间维修状态与占用情况应避免相互覆盖。",
"换宿、提前退宿及结业释放床位的具体时间需确认。",
"模拟成功只代表演示接口结果,不代表真实门锁已收到授权。"
]),
make("05 成绩管理",
"人工录入、Excel导入和外部采集汇入统一成绩记录;提交发布后锁定,修改经过审批并保留版本。",
[
["manual|人工录入课程、实训和考核成绩","excel|Excel模板导入","api|接收模拟第三方成绩"],
["validate|校验表头、字段格式和学号合法性|d"],
["error|生成可下载错误反馈报告|w","match|匹配学员、班次、课程和考核项目"],
["draft|保存成绩及来源:草稿|o"],
["weight|按配置权重计算综合成绩","history|记录每次编辑前后版本及差值|o"],
["submit|成绩已提交|o"],
["publish|成绩已发布并锁定|o","export|按权限筛选并导出Excel"],
["apply|提交已发布成绩修改申请及原因","stats|按确认口径提供统计数据|o"],
["approval|修改审批通过?|d"],
["keep|保留原成绩并记录审批结果|o","change|按审批内容更正成绩"],
["recalc|保存历史并重新计算受影响成绩"]
],
[
"excel>validate|导入校验",
"validate>error|错误数据",
"validate>match|合格数据",
"manual>match|人工录入关联对象",
"api>match|保留来源系统和事件标识",
"match>draft|保存",
"draft>weight|自动计算",
"draft>history|每次编辑留痕",
"weight>submit|提交",
"submit>publish|执行发布",
"publish>stats|正式或其他确认的统计口径",
"draft>export|按权限导出",
"publish>export|按权限导出",
"publish>apply|需要更正时",
"apply>approval|执行审批",
"approval>keep|未通过",
"approval>change|通过",
"change>history|记录修改前后差值",
"history>recalc|更正后需要重算时",
"change>recalc|重新计算",
"recalc>publish|保存更正后的发布结果"
],
[
"成绩类型包括课程成绩、实训成绩和考核成绩。",
"成绩状态覆盖草稿、已提交、已发布。",
"已发布成绩锁定;修改需走审批并保存每次修改历史。",
"Excel导入校验表头、字段格式和学号,错误数据提供反馈下载。",
"成绩权重、多次考核取值、缺考处理和草稿统计范围需确认。"
]),
make("06 数据接入",
"三条接入路径分别处理签到、学习使用明细和实训成绩;演示数据也经过匹配、校验和存储。",
[
["att|模拟电子班牌人脸或二维码事件","use|模拟阅览室、听书机、VR及3D使用记录","score|模拟VR、3D巡检及实操成绩"],
["amap|匹配学员、课程、班次和课次","umap|匹配学员及可确定的课程班次","mode|批量汇缴或实时接收"],
["dedup|同一学员同一课程一分钟内重复取第一条","raw|保存开始、结束时间及来源","smap|匹配学员、课程和考核项目"],
["asave|保存有效签到及来源|o","duration|计算本次学习时长","svalidate|校验并保存来源信息"],
["amanage|进入课程签到管理","abnormal|时长超过关联课程总时长两倍?|d","smanage|进入成绩管理"],
["pull|管理员手动拉取最近一小时签到","review|标记异常并通知管理员审核|w","normal|正常学习明细|o"],
["failure|对接失败或数据缺口|w","usable|按确认口径提供统计明细|o","error|对象无法匹配时记录异常|w"],
["mapping|人工修正映射后重新处理"]
],
[
"att>amap|采集签到",
"amap>dedup|匹配成功",
"dedup>asave|过滤重复后存储",
"asave>amanage|与人工维护互补",
"failure>pull|提供手动补采",
"pull>amap|补采仍执行匹配与去重",
"use>umap|采集使用明细",
"umap>raw|保存",
"raw>duration|计算时长",
"duration>abnormal|课程可明确关联时检查示例规则",
"abnormal>review|触发异常",
"abnormal>normal|未触发异常",
"review>usable|按审核及统计规则处理",
"normal>usable|进入统计",
"score>mode|选择汇缴或实时模式",
"mode>smap|匹配业务对象",
"smap>svalidate|匹配成功",
"svalidate>smanage|进入成绩业务流程",
"amap>error|无法匹配",
"umap>error|无法匹配",
"smap>error|无法匹配",
"error>mapping|管理员处理",
"mapping>amap|重处理签到",
"mapping>umap|重处理学习记录",
"mapping>smap|重处理成绩"
],
[
"签到去重规则必须同时限定同一学员、同一课程和一分钟窗口,并保留第一条。",
"补采支持管理员拉取最近一小时签到数据。",
"学习明细记录关联学员、开始时间、结束时间及来源。",
"时长超过课程总时长两倍的示例应作用于学习记录;没有课程归属时不能直接套用。",
"异常记录是否参与统计需确认,不在图中默认删除或默认计入。",
"外部事件重复投递还需配套幂等处理,属于落实需求的实现设计。"
]),
make("07 学习情况统计分析",
"统计从明细计算,支持按学员、课程及时间比较,并能下钻、导出或异步生成。",
[
["roster|参训名单及课表","att|人工与自动签到记录","score|各类成绩及状态","use|学习系统使用明细"],
["expected|确定应到学员及课次","attendance|汇总实到、迟到和缺勤","grades|学员课程成绩与课程成绩分布","hours|各学员各系统总学习时长"],
["metrics|按学员、课程、班次及系统组织指标"],
["time|日、周、月或自定义时间筛选"],
["chart|生成统计表和可视化图表|o","compare|计算同比及环比"],
["drill|点击指标下钻明细","export|申请一键导出","portrait|向学员画像提供指标|o"],
["detail|查看签到、成绩及学习原始记录|o","async|是否需要后台异步生成?|d"],
["task|创建任务并保存筛选条件","file|生成报表文件|o"],
["process|执行任务并更新状态"],
["success|任务成功?|d"],
["notice|完成后消息提醒下载|o","fail|显示失败原因并支持重试|w"]
],
[
"roster>expected|计算应到范围",
"att>attendance|汇总考勤明细",
"expected>attendance|提供统计分母",
"score>grades|按确认的成绩状态口径",
"use>hours|按有效记录汇总",
"attendance>metrics|考勤指标",
"grades>metrics|成绩指标",
"hours>metrics|时长指标",
"metrics>time|设置查询区间",
"time>chart|可视化展示",
"time>compare|使用一致口径比较",
"chart>drill|点击指标",
"drill>detail|追溯数据依据",
"chart>export|导出所选范围",
"metrics>portrait|画像计算输入",
"export>async|检查任务规模",
"async>file|同步生成",
"async>task|异步生成",
"task>process|后台执行",
"process>success|检查结果",
"success>notice|成功",
"success>fail|失败",
"fail>task|重试",
"notice>file|提供下载"
],
[
"包括每位学员各系统时长、学员各课程签到及成绩、各课程学员成绩分析。",
"时间筛选覆盖日、周、月及自定义区间。",
"图表支持下钻和同比环比,所有统计报表支持一键导出。",
"异步任务应记录筛选条件和状态,完成后提醒下载。",
"签到率分母、请假处理、异常时长及成绩状态口径需统一。"
]),
make("08 学员画像",
"画像读取统计指标,以可配置规则生成标签和雷达图,保存月度快照并形成定向资料推荐。",
[
["metrics|考勤、理论成绩、实操能力及学习时长","rules|管理员配置动态标签及维度规则"],
["calculate|按规则换算画像维度值"],
["labels|匹配标签,例如签到率大于90%标记勤勉"],
["current|生成当前画像及雷达图|o"],
["evidence|查看指标和标签依据","timer|每月1日自动触发快照","filter|按标签筛选学员"],
["detail|下钻统计及学习明细|o","snapshot|保存画像历史快照|o","need|确定需要补强的学习方向"],
["history|比较成长变化|o","resource|关联课程文档、视频及实训资源"],
["profile|回到学员详情展示|o","recommend|生成定向补习资料推荐清单|o"]
],
[
"metrics>calculate|统计输入",
"rules>calculate|维度及标签规则",
"calculate>labels|执行规则",
"labels>current|生成展示",
"current>evidence|查看来源依据",
"evidence>detail|追溯原始记录",
"timer>snapshot|定时保存",
"current>snapshot|保存当期画像",
"snapshot>history|按月份比较",
"history>profile|成长轨迹",
"current>profile|当前画像",
"current>filter|精准筛选",
"filter>need|识别补强方向",
"need>recommend|生成推荐",
"resource>recommend|匹配可用教学资料"
],
[
"标签规则支持管理员配置,不应仅在代码中固定几个标签。",
"雷达图包含考勤、理论成绩、实操能力和学习时长。",
"每月1日保存快照;建议同时保存规则版本以便正确解释变化。",
"推荐清单关联现有课程资源,实际推送渠道另行确定。",
"不同单位换算为雷达图维度分值的规则需确认。"
]),
make("09 系统权限管理",
"登录安全、功能权限、数据范围和审计贯穿全部模块;前端菜单与后端接口均需遵循授权。",
[
["login|用户进入登录页面","policy|密码复杂度及账号安全策略"],
["check|校验账号状态和登录凭据"],
["success|登录成功?|d"],
["failcount|记录登录失败次数","session|建立登录会话"],
["threshold|达到失败锁定阈值?|d","role|读取用户、角色、权限关联"],
["lock|按策略锁定登录|w","preset|管理员、教师、学员及自定义角色"],
["timeout|会话超时自动退出","permission|检查菜单、按钮及接口权限"],
["allow|允许执行当前操作?|d"],
["deny|拒绝越权操作|w","scope|按授课等关系限制数据行范围"],
["execute|执行查询、增删改或导出"],
["audit|记录操作人、时间、IP及操作类型|o"],
["snapshot|保存变更前后JSON快照或导出范围|o"],
["result|关联业务对象与执行结果,支持追溯|o"],
["admin|管理员维护用户、角色及权限","relation|教师与所带培训班的关联|o"]
],
[
"login>check|提交登录",
"policy>check|应用安全策略|d",
"check>success|校验结果",
"success>failcount|失败",
"failcount>threshold|累计次数",
"threshold>lock|达到阈值",
"threshold>login|未达到阈值",
"success>session|成功",
"session>role|读取授权",
"role>preset|支持预置及自定义角色",
"preset>permission|检查功能权限",
"session>timeout|按策略监测超时",
"permission>allow|校验当前操作",
"allow>deny|不允许",
"allow>scope|允许",
"relation>scope|教师只访问所带班级数据|d",
"scope>execute|在授权范围执行",
"execute>audit|核心增删改及导出记录",
"audit>snapshot|保存对应操作证据",
"snapshot>result|完整追溯",
"deny>result|记录适用的安全事件",
"admin>role|维护用户角色权限",
"admin>audit|权限调整留痕"
],
[
"RBAC采用用户、角色、权限三层模型,并支持自定义角色。",
"数据行权限示例:教师仅查看所带班级学员成绩。",
"所有核心增、删、改、导出操作记录操作人、时间、IP及相应快照。",
"导出记录筛选条件、数据范围及结果,不存在修改前后分数。",
"已有若依能力需逐项核对复用,不能仅凭框架名称认定全部满足。"
]),
make("10 本期结业及状态联动",
"补齐此前确认的演示终点。权限到期任务独立运行;结业、人员状态和住宿释放按各自规则处理。",
[
["manual|管理员办理本期结业","timer|开班结束或授权期限到达","change|休学、退学等状态变更"],
["scope|定位本期参训记录和影响范围|d"],
["status|更新本期参训状态|o","auth|定位相应住宿及签到权限","stay|核对退宿与床位释放条件|d"],
["history|保留报到、签到、学习、成绩和画像档案|o","execute|模拟执行到期失效、冻结或撤销","release|满足规则后结束入住并释放床位"],
["result|权限处理成功?|d","keep|不满足退宿条件时保留入住安排|o"],
["success|记录权限处理结果及时间|o","retry|记录失败原因,单独重试|w"],
["query|支持历史查询及操作追溯|o"]
],
[
"manual>scope|确定本次结业对象",
"change>scope|区分人员整体与单期范围",
"scope>status|按确认的状态规则更新",
"scope>auth|识别受影响权限",
"scope>stay|结业相关退宿检查",
"status>history|保留学习档案",
"timer>auth|独立触发,不依赖查看画像",
"auth>execute|按规则处理",
"execute>result|获取模拟执行结果",
"result>success|成功",
"result>retry|失败",
"retry>execute|只重试权限处理",
"stay>release|符合已确认的退宿条件",
"stay>keep|尚不满足条件",
"history>query|历史档案",
"success>query|授权审计",
"release>query|住宿历史",
"retry>query|失败记录"
],
[
"办理本期结业来自已确认的演示路径;管理员操作入口属于具体实现设计。",
"原需求明确授权与开班结束时间联动,并在结业后自动过期失效。",
"不能把查看画像设为权限到期的前置条件。",
"某一期结业不默认冻结学员其他有效班次的权限。",
"结业立即退宿或立即释放床位并非原文明确规则,应另行确认。"
])
];
const NS = "http://www.w3.org/2000/svg";
const svg = document.getElementById("graph");
const viewport = document.getElementById("viewport");
let current = 0;
let selected = null;
let scale = 1;
let layout = null;
let renderVersion = 0;
let enginePromise = null;
const layoutCache = new Map();
const FONT = '14px "Microsoft YaHei", system-ui, sans-serif';
const canvasMeasure = document.createElement("canvas");
const measureContext = canvasMeasure.getContext("2d");
const diagramCSS = `
text {
font-family:"Microsoft YaHei",system-ui,sans-serif;
}
.edge .halo {
fill:none;
stroke:#fff;
stroke-width:7;
stroke-linejoin:round;
stroke-linecap:round;
pointer-events:none;
}
.edge .wire {
fill:none;
stroke:#8296b1;
stroke-width:1.65;
stroke-linejoin:round;
stroke-linecap:round;
}
.edge.dashed .wire {
stroke-dasharray:7 5;
}
.edge.active .wire {
stroke:#245bd1;
stroke-width:2.6;
}
.edge-label rect {
fill:#fff;
stroke:#e7edf5;
stroke-width:.7;
}
.node {
cursor:pointer;
}
.node .shape {
stroke-width:1.4;
}
.node:hover .shape,
.node:focus .shape,
.node.active .shape {
stroke:#245bd1;
stroke-width:2.7;
}
.node:focus {
outline:none;
}
.edge.dim,.node.dim,.edge-label.dim {
opacity:.18;
}
`;
const style = document.createElement("style");
style.textContent = diagramCSS + `
#graph {
background:#fff;
}
#viewport[aria-busy="true"] {
opacity:.55;
pointer-events:none;
}
`;
document.head.append(style);
function el(tag, attrs = {}, text) {
const e = document.createElementNS(NS, tag);
Object.entries(attrs).forEach(([k, v]) => {
e.setAttribute(k, String(v));
});
if (text !== undefined) e.textContent = text;
return e;
}
function html(tag, attrs = {}, text) {
const e = document.createElement(tag);
Object.entries(attrs).forEach(([k, v]) => {
e.setAttribute(k, String(v));
});
if (text !== undefined) e.textContent = text;
return e;
}
function nodeName(id) {
return graphs[current].nodes.find(n => n.id === id)?.text || id;
}
function wrapText(text, maxWidth, font = FONT) {
measureContext.font = font;
const lines = [];
let line = "";
for (const ch of text) {
if (ch === "\n") {
lines.push(line);
line = "";
continue;
}
if (
line &&
measureContext.measureText(line + ch).width > maxWidth
) {
lines.push(line);
line = "";
}
line += ch;
}
if (line) lines.push(line);
return lines.length ? lines : [""];
}
function addText(parent, lines, x, y, options = {}) {
const lineHeight = options.lineHeight || 22;
const text = el("text", {
x,
"text-anchor": "middle",
"font-size": options.size || 14,
"font-weight": options.weight || 500,
fill: options.color || "#21354e"
});
lines.forEach((line, i) => {
text.append(el("tspan", {
x,
y: y + i * lineHeight
}, line));
});
parent.append(text);
}
function validateGraphs() {
graphs.forEach(g => {
const ids = new Set(g.nodes.map(n => n.id));
if (ids.size !== g.nodes.length) {
throw new Error(g.title + " 存在重复节点");
}
g.edges.forEach(e => {
if (!ids.has(e.from) || !ids.has(e.to)) {
throw new Error(
g.title + " 存在无效关联:" + e.from + " → " + e.to
);
}
});
});
}
function loadScript(url) {
return new Promise((resolve, reject) => {
const script = document.createElement("script");
let completed = false;
const timer = setTimeout(() => {
if (completed) return;
completed = true;
script.remove();
reject(new Error("加载超时:" + url));
}, 12000);
script.src = url;
script.onload = () => {
if (completed) return;
completed = true;
clearTimeout(timer);
if (typeof window.ELK === "function") resolve();
else reject(new Error("布局库未正确加载"));
};
script.onerror = () => {
if (completed) return;
completed = true;
clearTimeout(timer);
script.remove();
reject(new Error("无法加载:" + url));
};
document.head.append(script);
});
}
async function getEngine() {
if (!enginePromise) {
enginePromise = (async () => {
if (typeof window.ELK !== "function") {
const sources = [
"./elk.bundled.js",
"https://cdn.jsdelivr.net/npm/elkjs@0.10.0/lib/elk.bundled.js"
];
let loaded = false;
for (const source of sources) {
try {
await loadScript(source);
loaded = true;
break;
} catch (_) {
// 本地不可用时尝试网络加载。
}
}
if (!loaded) {
throw new Error(
"无法加载布局库。请把 elk.bundled.js 放到 HTML 同一目录," +
"然后重新打开页面。"
);
}
}
return new window.ELK();
})().catch(error => {
enginePromise = null;
throw error;
});
}
return enginePromise;
}
function createLayoutInput(g) {
const children = g.nodes.map(node => {
const lines = wrapText(node.text, 230);
return {
id: node.id,
width: 270,
height: Math.max(92, lines.length * 24 + 34)
};
});
const edges = g.edges.map((edge, index) => {
const labelLines = wrapText(
edge.label || "",
180,
'12px "Microsoft YaHei", system-ui, sans-serif'
);
measureContext.font =
'12px "Microsoft YaHei", system-ui, sans-serif';
const labelWidth = Math.max(
30,
...labelLines.map(line =>
measureContext.measureText(line).width
)
) + 20;
return {
id: "edge-" + index,
sources: [edge.from],
targets: [edge.to],
labels: edge.label ? [{
id: "label-" + index,
text: edge.label,
width: labelWidth,
height: labelLines.length * 19 + 12,
layoutOptions: {
"elk.edgeLabels.placement": "CENTER"
}
}] : []
};
});
return {
id: "root",
layoutOptions: {
"elk.algorithm": "layered",
"elk.direction": "DOWN",
"elk.edgeRouting": "ORTHOGONAL",
// 每层最多五个业务节点,避免横向无限铺开。
"elk.layered.layering.strategy": "COFFMAN_GRAHAM",
"elk.layered.layering.coffmanGraham.layerBound": "5",
// 为节点、独立连线和文字留出空间。
"elk.spacing.nodeNode": "100",
"elk.spacing.edgeNode": "32",
"elk.spacing.edgeEdge": "28",
"elk.spacing.labelNode": "22",
"elk.spacing.edgeLabel": "14",
"elk.layered.spacing.nodeNodeBetweenLayers": "135",
"elk.layered.spacing.edgeEdgeBetweenLayers": "32",
"elk.layered.spacing.edgeNodeBetweenLayers": "35",
// 不把不同边合并到同一个节点连接点。
"elk.layered.mergeEdges": "false",
"elk.layered.crossingMinimization.strategy": "LAYER_SWEEP",
"elk.layered.nodePlacement.strategy": "NETWORK_SIMPLEX",
// 统一布局断开的辅助节点。
"elk.separateConnectedComponents": "false",
"elk.padding": "[top=40,left=40,bottom=40,right=40]"
},
children,
edges
};
}
/*
将布局引擎给出的折线路径转为圆角折线。
保留避障路径,仅在转角处进行小半径圆滑处理。
*/
function roundedPath(points, radius = 11) {
const clean = [];
points.forEach(p => {
const last = clean[clean.length - 1];
if (!last || Math.hypot(p.x - last.x, p.y - last.y) > 0.01) {
clean.push(p);
}
});
if (!clean.length) return "";
if (clean.length === 1) {
return `M ${clean[0].x} ${clean[0].y}`;
}
let d = `M ${clean[0].x} ${clean[0].y}`;
for (let i = 1; i < clean.length - 1; i++) {
const a = clean[i - 1];
const b = clean[i];
const c = clean[i + 1];
const ab = Math.hypot(b.x - a.x, b.y - a.y);
const bc = Math.hypot(c.x - b.x, c.y - b.y);
const r = Math.min(radius, ab / 3, bc / 3);
const p1 = {
x: b.x - (b.x - a.x) / ab * r,
y: b.y - (b.y - a.y) / ab * r
};
const p2 = {
x: b.x + (c.x - b.x) / bc * r,
y: b.y + (c.y - b.y) / bc * r
};
d += ` L ${p1.x} ${p1.y}`;
d += ` Q ${b.x} ${b.y} ${p2.x} ${p2.y}`;
}
const end = clean[clean.length - 1];
return d + ` L ${end.x} ${end.y}`;
}
function addDefinitions() {
const defs = el("defs");
[
["normal", "#8296b1"],
["active", "#245bd1"]
].forEach(([name, color]) => {
const marker = el("marker", {
id: "arrow-" + name,
viewBox: "0 0 10 10",
refX: 9,
refY: 5,
markerWidth: 7,
markerHeight: 7,
orient: "auto"
});
marker.append(el("path", {
d: "M0 0 L10 5 L0 10 Z",
fill: color
}));
defs.append(marker);
});
svg.append(defs);
}
function paintGraph(result, graph) {
svg.replaceChildren();
svg.append(el("title", {}, graph.title));
svg.append(el("desc", {},
"完整流程图。节点、连接路径及说明文字由自动布局计算。"
));
addDefinitions();
const edgeLayer = el("g");
const labelLayer = el("g");
const nodeLayer = el("g");
result.edges.forEach(edge => {
const index = Number(edge.id.replace("edge-", ""));
const original = graph.edges[index];
const group = el("g", {
class: "edge" + (original.dashed ? " dashed" : ""),
"data-index": index
});
const sections = edge.sections || [];
sections.forEach((section, sectionIndex) => {
const points = [
section.startPoint,
...(section.bendPoints || []),
section.endPoint
];
const d = roundedPath(points);
/*
白色底线用于区分交叉关系。
两条线相交时出现清楚的断开层次,不误读为连接点。
*/
group.append(el("path", {
class: "halo",
d
}));
const wire = el("path", {
class: "wire",
d
});
if (sectionIndex === sections.length - 1) {
wire.setAttribute("marker-end", "url(#arrow-normal)");
}
group.append(wire);
});
group.append(el("title", {},
nodeName(original.from) + " → " +
original.label + " → " + nodeName(original.to)
));
edgeLayer.append(group);
(edge.labels || []).forEach(label => {
if (!Number.isFinite(label.x) || !Number.isFinite(label.y)) return;
const labelGroup = el("g", {
class: "edge-label",
"data-index": index
});
labelGroup.append(el("rect", {
x: label.x,
y: label.y,
width: label.width,
height: label.height,
rx: 5
}));
const lines = wrapText(
original.label,
label.width - 20,
'12px "Microsoft YaHei", system-ui, sans-serif'
);
addText(
labelGroup,
lines,
label.x + label.width / 2,
label.y + 17,
{
size: 12,
lineHeight: 19,
color: "#506681"
}
);
labelGroup.append(el("title", {}, original.label));
labelLayer.append(labelGroup);
});
});
const palette = {
p: ["#eef4ff", "#a5bce6"],
o: ["#e9f8f3", "#93c9b5"],
d: ["#fff5df", "#d5b66e"],
w: ["#fff0f2", "#d8a0aa"]
};
result.children.forEach(position => {
const node = graph.nodes.find(n => n.id === position.id);
const colors = palette[node.type] || palette.p;
const group = el("g", {
class: "node",
"data-id": node.id,
tabindex: 0,
role: "button",
"aria-label": node.text
});
/*
使用矩形边界保持连接端点与布局引擎一致。
判断节点通过颜色与“判断”标记区分。
*/
group.append(el("rect", {
class: "shape",
x: position.x,
y: position.y,
width: position.width,
height: position.height,
rx: node.type === "d" ? 20 : 10,
fill: colors[0],
stroke: colors[1]
}));
const lines = wrapText(node.text, position.width - 40);
const textHeight = (lines.length - 1) * 24;
addText(
group,
lines,
position.x + position.width / 2,
position.y + position.height / 2 - textHeight / 2 + 5,
{
size: 14,
lineHeight: 24,
weight: 550
}
);
group.append(el("title", {}, node.text));
const activate = () => {
selectNode(selected === node.id ? null : node.id);
};
group.addEventListener("click", activate);
group.addEventListener("keydown", event => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
activate();
}
});
nodeLayer.append(group);
});
svg.append(edgeLayer, labelLayer, nodeLayer);
layout = {
width: Math.ceil(result.width + 20),
height: Math.ceil(result.height + 20)
};
svg.setAttribute(
"viewBox",
`-10 -10 ${layout.width} ${layout.height}`
);
svg.setAttribute("aria-label", graph.title);
}
function setScale(value) {
if (!layout) return;
scale = Math.max(0.1, Math.min(3, value));
svg.style.width = layout.width * scale + "px";
svg.style.height = layout.height * scale + "px";
document.getElementById("scaleText").textContent =
Math.round(scale * 100) + "%";
}
function fit() {
if (!layout) return;
setScale(
Math.min(1, Math.max(200, viewport.clientWidth - 20) / layout.width)
);
}
function fillPathTable(graph) {
const tbody = document.getElementById("pathBody");
tbody.replaceChildren();
graph.edges.forEach((edge, index) => {
const tr = html("tr");
[
String(index + 1),
nodeName(edge.from),
edge.label,
nodeName(edge.to),
edge.dashed ? "关联依据" : "处理/传递"
].forEach(value => {
tr.append(html("td", {}, value));
});
tbody.append(tr);
});
}
async function render(index) {
const version = ++renderVersion;
current = index;
selected = null;
layout = null;
const graph = graphs[index];
document.getElementById("title").textContent = graph.title;
document.getElementById("subtitle").textContent = graph.subtitle;
document.getElementById("notes").replaceChildren(
...graph.notes.map(note => html("li", {}, note))
);
document.getElementById("selection").textContent =
"正在计算节点位置、独立连线路径和文字位置……";
document.querySelectorAll("nav button").forEach((button, i) => {
button.classList.toggle("active", i === index);
if (i === index) button.setAttribute("aria-current", "page");
else button.removeAttribute("aria-current");
});
document.getElementById("paths").open = false;
fillPathTable(graph);
svg.replaceChildren();
viewport.setAttribute("aria-busy", "true");
try {
let result = layoutCache.get(index);
if (!result) {
const engine = await getEngine();
if (version !== renderVersion) return;
if (document.fonts?.ready) await document.fonts.ready;
result = await engine.layout(createLayoutInput(graph));
layoutCache.set(index, result);
}
if (version !== renderVersion) return;
paintGraph(result, graph);
selectNode(null);
fit();
viewport.scrollTop = 0;
viewport.scrollLeft = 0;
try {
history.replaceState(null, "", "#diagram-" + index);
} catch (_) {}
} catch (error) {
if (version !== renderVersion) return;
document.getElementById("selection").textContent =
"布局失败:" + error.message;
svg.replaceChildren(el("text", {
x: 24,
y: 40,
"font-size": 15,
fill: "#a43b45"
}, "布局库未加载或布局失败,请查看下方提示。"));
svg.setAttribute("viewBox", "0 0 900 110");
svg.style.width = "100%";
svg.style.height = "110px";
console.error(error);
} finally {
if (version === renderVersion) {
viewport.setAttribute("aria-busy", "false");
}
}
}
function selectNode(id) {
selected = id;
const graph = graphs[current];
const related = graph.edges.filter(
edge => edge.from === id || edge.to === id
);
const nodeIds = new Set(id ? [id] : []);
related.forEach(edge => {
nodeIds.add(edge.from);
nodeIds.add(edge.to);
});
svg.querySelectorAll(".node").forEach(node => {
node.classList.toggle("active", node.dataset.id === id);
node.classList.toggle("dim", !!id && !nodeIds.has(node.dataset.id));
});
svg.querySelectorAll(".edge,.edge-label").forEach(group => {
const edge = graph.edges[Number(group.dataset.index)];
const active = !!id &&
(edge.from === id || edge.to === id);
group.classList.toggle("active", active);
group.classList.toggle("dim", !!id && !active);
group.querySelectorAll(".wire[marker-end]").forEach(path => {
path.setAttribute(
"marker-end",
active ? "url(#arrow-active)" : "url(#arrow-normal)"
);
});
});
const panel = document.getElementById("selection");
panel.replaceChildren();
if (!id) {
panel.append(html("p", {},
"保留完整流程图。点击节点可突出显示其上下游," +
"交叉线通过白色间隔区分,说明文字独立放置。"
));
panel.append(html("p", {},
`本图包含 ${graph.nodes.length} 个节点、` +
`${graph.edges.length} 条关联。`
));
return;
}
panel.append(html("strong", {}, nodeName(id)));
function addGroup(title, edges, incoming) {
panel.append(html("p", {}, title));
if (!edges.length) {
panel.append(html("p", {}, "本图未设置直接关联。"));
return;
}
edges.forEach(edge => {
const other = incoming ? edge.from : edge.to;
const div = html("div", { class: "path-item" });
const button = html("button", {
type: "button"
}, nodeName(other));
button.onclick = () => selectNode(other);
div.append(button);
div.append(html("span", {
class: "path-label"
},
edge.label + (edge.dashed ? "〔关联依据〕" : "")
));
panel.append(div);
});
}
addGroup(
"上游输入",
graph.edges.filter(e => e.to === id),
true
);
addGroup(
"下游处理",
graph.edges.filter(e => e.from === id),
false
);
}
function downloadSvg() {
if (!layout) return;
const clone = svg.cloneNode(true);
clone.setAttribute("width", layout.width);
clone.setAttribute("height", layout.height);
clone.style.width = layout.width + "px";
clone.style.height = layout.height + "px";
clone.querySelectorAll(".dim").forEach(node => {
node.classList.remove("dim");
});
clone.insertBefore(
el("style", {}, diagramCSS),
clone.firstChild
);
const data =
'<?xml version="1.0" encoding="UTF-8"?>\n' +
new XMLSerializer().serializeToString(clone);
const url = URL.createObjectURL(
new Blob([data], {
type: "image/svg+xml;charset=utf-8"
})
);
const link = html("a", {
href: url,
download: graphs[current].title + "-完整流程图.svg"
});
document.body.append(link);
link.click();
link.remove();
setTimeout(() => URL.revokeObjectURL(url), 2000);
}
try {
validateGraphs();
// 清理上一版分段模式动态添加的控件。
document.getElementById("nodePicker")?.remove();
document.getElementById("scopeButton")?.remove();
const help = document.querySelector(".canvas-help");
if (help) {
help.textContent =
"完整流程图:允许交叉,连线使用独立端点与圆角路径。" +
"点击节点查看直接关联;复杂图可放大查看或导出SVG。";
}
const nav = document.getElementById("nav");
nav.replaceChildren();
graphs.forEach((graph, index) => {
const button = html("button", {
type: "button"
});
button.append(html("span", {},
String(index).padStart(2, "0")
));
button.append(document.createTextNode(
graph.title.replace(/^\d+\s*/, "")
));
button.onclick = () => render(index);
nav.append(button);
});
document.getElementById("fit").onclick = fit;
document.getElementById("minus").onclick = () => setScale(scale / 1.2);
document.getElementById("plus").onclick = () => setScale(scale * 1.2);
document.getElementById("actual").onclick = () => setScale(1);
document.getElementById("clear").onclick = () => selectNode(null);
document.getElementById("export").onclick = downloadSvg;
document.getElementById("print").onclick = () => {
if (layout) window.print();
};
const initial = Number(
location.hash.replace("#diagram-", "")
);
render(
Number.isInteger(initial) &&
initial >= 0 &&
initial < graphs.length
? initial
: 0
);
window.addEventListener("resize", fit);
} catch (error) {
document.getElementById("selection").textContent =
"初始化失败:" + error.message;
console.error(error);
}
</script>
</body>
</html>