Files
railwaysystem/doc/railwaysystem.html
T

1663 lines
54 KiB
HTML
Raw Normal View History

<!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>