diff --git a/demo/README.md b/demo/README.md
index 5636857..9747ff5 100644
--- a/demo/README.md
+++ b/demo/README.md
@@ -15,8 +15,10 @@ python3 -m http.server 8080
## 核心产品能力
- 校级供需决策总览、周期切换、专业搜索和七步产品导览
-- “企业需求 → 学校接收/分析快照 → 专业建议决策 → 任务/项目 → 执行证据 → 企业评价 → 校级验收 → 报告审批发布 → 数据使用反馈 → 下一周期复核”校园业务闭环
-- 闭环实体、角色动作、审批历史与审计事件使用 `localStorage` 持久化,支持刷新续办、Schema 迁移和样板重置
+- “企业需求 → 学校接收/分析快照 → 专业建议决策 → 任务/项目 → 执行证据 → 企业评价 → 校级验收 → 报告审批发布 → 数据使用反馈 → 成效复核”11 节点可交互业务流程图
+- 流程节点直接读取同一套业务实体和状态机,支持节点选择、输入输出、责任角色、关联 ID、当前待办定位及节点内办理
+- “需整改 → 补充证据”“验收退回 → 补充证据”“进入下一周期 → 企业需求”三类回流边与实际状态同步
+- 流程实体、角色动作、审批历史与审计事件使用 `localStorage` 持久化,支持刷新续办、Schema 迁移和样板重置
- 技术接收同时强校验租户、包号、版本、交付记录数和完整 64 位 SHA-256;任一不一致只写失败审计,不推进回执或分析快照
- 交付包记录数与分析样本量分开保存:样板包回执为 `2,846,320` 条,生成的校园分析快照为 `128,642` 条
- 专业技能供需、岗位流向、薪资对标、课程技能映射与专业对比
@@ -43,6 +45,20 @@ python3 -m http.server 8080
- **校级数据**:来自学校实习、就业、教务等业务系统,数据权属归学校。
- **企业数据**:由合作企业确认提交,按照合作协议限定使用范围。
+## 可交互业务流程图
+
+“业务流程”不是状态卡片列表,而是由节点和有向边构成的可操作流程:
+
+- 11 个节点分别对应企业需求、分析快照、专业建议、任务、项目、证据、企业评价、校级验收、报告、使用反馈和成效复核;
+- 节点状态分为已完成、当前待办、退回/整改和前置未满足,流程完成度与当前责任角色实时计算;
+- 点击任一节点可查看节点输入、节点输出、关键字段、责任角色和关联实体;
+- 当前角色具备权限且前置条件满足时,可在节点详情直接办理;无权限时显示责任边界而非伪造可用按钮;
+- 企业评价需整改、校级验收退回和进入下一周期均使用独立回流边表达;
+- 桌面端显示完整流程拓扑,移动端转换为带连接线的纵向节点流程,并用三张可点击关系卡保留整改、退回与下一周期回流;
+- “进入下一周期”会归档上一周期报告,创建带 `previousVersionId` 的新需求、快照和回执版本,并清空新周期尚未产生的下游实体。
+
+流程图只负责展示并调用现有状态机,不维护第二套演示状态。因此原有租户、SHA、记录数、证据冻结、独立审批和角色授权规则继续生效。
+
## 数据与报告依赖图谱
“数据资产”页提供两种追溯方向:
@@ -78,6 +94,7 @@ python3 -m http.server 8080
- 当前报告一经生成,支撑该版本的任务证据冻结;新增证据必须进入下一周期或重开新版本;
- 阶段进度必须达到 100%,企业导师才能提交通过或整改评价;
- 企业评价为“需整改”时,校级验收继续阻断,补充证据并重新评价后才可进入验收;
+- 就业侧最新反馈为“需补充”时,成效复核继续阻断;改为“可用”后才可复核,周期关闭后不再接受追加反馈;
- 报告提交人不能批准或退回自己的提交,非校级管理者不显示报告审批与发布动作;
- 报告正式发布前,就业老师不能登记使用反馈,下载和打印均明确标识非正式版。
@@ -102,7 +119,7 @@ python3 -m http.server 8080
## 开发与验收说明
-- 闭环样板使用命名空间 `eduIndustry.campusClosedLoop.TENANT-HZVTC.v1`,当前 `schemaVersion=2`。页面“重置样板”会恢复种子;旧 V1 或不满足深层实体/历史数组不变量的损坏 V2 状态,均按安全策略失效并重建 V2 种子。
+- 业务流程样板沿用命名空间 `eduIndustry.campusClosedLoop.TENANT-HZVTC.v1`,当前 `schemaVersion=2`。页面“重置样板”会恢复种子;旧 V1 或不满足深层实体/历史数组不变量的损坏 V2 状态,均按安全策略失效并重建 V2 种子。
- 为避免静态原型长期演示导致浏览器存储无界增长,审计、证据、反馈、自定义集合和实体历史采用明确保留上限;正式系统应改用服务端分页、归档和不可变审计存储。
- 共享样板标识为租户 `TENANT-HZVTC`、厂商包 `EDU-PKG-20260823-01`、版本 `V2026.08.23`、学校回执 `RCT-HZVTC-20260823-01`。这些标识只用于本静态原型的业务链对照,不表示厂商端和校园端页面实时同步。
- 当前前端不向外部服务发送请求,可直接在隔离网络运行。
@@ -111,10 +128,10 @@ python3 -m http.server 8080
- JavaScript 语法检查:`node --check app.js && node --check report-visuals.js`
- 推荐桌面验收视口为 `1440 × 900`,移动验收视口为 `390 × 844`。
-新增闭环预览:
+业务流程图预览:
-- `preview-campus-closed-loop.png`
-- `preview-campus-closed-loop-mobile.png`
+- `preview-campus-workflow.png`
+- `preview-campus-workflow-mobile.png`
数据依赖图谱预览:
diff --git a/demo/app.js b/demo/app.js
index c6c4127..2dc12e7 100644
--- a/demo/app.js
+++ b/demo/app.js
@@ -470,7 +470,7 @@ const roleProfiles = {
organizationLabel: "当前专业",
emblem: "软",
root: "专业建设工作台",
- context: "软件技术专业 · 2026 春季闭环",
+ context: "软件技术专业 · 2026 春季流程",
workspaceLabel: "专业工作空间",
menus: ["loop", "profession", "collaboration", "actions", "report"],
defaultView: "loop",
@@ -551,6 +551,7 @@ function closedLoopSeed() {
validUntil: "2027-08-22",
submitter: "陈凯 · 海康威视",
submittedAt: "2026-08-23 09:10",
+ cycle: "2026C2",
description: "面向软件技术专业共建边缘计算测试实训,企业导师参与中期评审与成果评价。",
},
snapshot: {
@@ -599,7 +600,7 @@ function closedLoopSeed() {
businessUsable: false,
},
},
- sequences: { evidence: 0, feedback: 0 },
+ sequences: { cycle: 1, task: 0, project: 0, evaluation: 0, acceptance: 0, report: 0, review: 0, evidence: 0, feedback: 0 },
custom: { demands: [], tasks: [], projects: [], reports: [] },
auditEvents: [
{
@@ -609,7 +610,7 @@ function closedLoopSeed() {
operator: "样板数据初始化",
role: "系统",
at: "2026-08-23 09:00",
- detail: "创建可重置的校园闭环样板。",
+ detail: "创建可重置的校园业务流程样板。",
},
],
};
@@ -639,7 +640,7 @@ function resetInvalidClosedLoopState(seed, rawVersion, reason) {
id: `AUD-RESET-${Date.now()}`,
action: "SECURITY_SCHEMA_RESET",
objectId: CLOSED_LOOP_SHARED.receiptId,
- operator: "闭环状态迁移器",
+ operator: "流程状态迁移器",
role: "系统",
at: nowText(),
detail: `Schema V${rawVersion ?? "未知"} 状态已按安全策略失效并重建 V${CLOSED_LOOP_SCHEMA_VERSION} 种子:${reason}`,
@@ -684,14 +685,55 @@ function migrateClosedLoopState(raw) {
const validEntityArrays = Array.isArray(state.entities.evidences) && Array.isArray(state.entities.usageFeedback);
const validCustomCollections = Object.keys(seed.custom).every((key) => Array.isArray(state.custom[key]));
const validCoreEntities = ["demand", "snapshot", "recommendation", "receipt"].every((key) => isPlainObject(state.entities[key]));
+ const validCoreIds =
+ validCoreEntities &&
+ ["demand", "snapshot", "recommendation", "receipt"].every(
+ (key) => typeof state.entities[key].id === "string" && state.entities[key].id.length > 0 && state.entities[key].id.length <= 96,
+ );
const validTask =
state.entities.task === null ||
(isPlainObject(state.entities.task) && Array.isArray(state.entities.task.progressLogs) && Array.isArray(state.entities.task.acceptanceHistory));
const validReport = state.entities.report === null || (isPlainObject(state.entities.report) && Array.isArray(state.entities.report.history));
const validOptionalEntities = ["project", "evaluation", "review"].every((key) => state.entities[key] === null || isPlainObject(state.entities[key]));
- if (!validEntityArrays || !validCustomCollections || !validCoreEntities || !validTask || !validReport || !validOptionalEntities) {
+ if (!validEntityArrays || !validCustomCollections || !validCoreEntities || !validCoreIds || !validTask || !validReport || !validOptionalEntities) {
return resetInvalidClosedLoopState(seed, raw.schemaVersion, "实体结构或历史数组不满足 V2 不变量");
}
+ if (state.entities.task) {
+ state.entities.task.acceptanceHistory = state.entities.task.acceptanceHistory.map((item, index) =>
+ isPlainObject(item) && !item.id
+ ? { ...item, id: `ACC-HZVTC-${String(index + 1).padStart(2, "0")}` }
+ : item,
+ );
+ }
+ const cycleFromDemandId = state.entities.demand.id.match(/-(\d{4}C[12])-/)?.[1];
+ state.entities.demand.cycle = state.entities.demand.cycle || cycleFromDemandId || "2026C2";
+ state.sequences.cycle = Math.max(
+ Number.isInteger(state.sequences.cycle) ? state.sequences.cycle : 0,
+ state.entities.demand.previousVersionId ? 2 : 1,
+ );
+ ["task", "project", "evaluation", "report", "review"].forEach((key) => {
+ state.sequences[key] = Math.max(Number.isInteger(state.sequences[key]) ? state.sequences[key] : 0, state.entities[key] ? 1 : 0);
+ });
+ state.sequences.acceptance = Math.max(
+ Number.isInteger(state.sequences.acceptance) ? state.sequences.acceptance : 0,
+ state.entities.task?.acceptanceHistory.length || 0,
+ );
+ if (state.entities.evaluation && ["进行中", "待评价"].includes(state.entities.task?.status)) {
+ const staleEvaluationId = state.entities.evaluation.id || "EVL-旧记录";
+ state.entities.evaluation = null;
+ state.auditEvents.unshift({
+ id: closedLoopToken("AUD"),
+ action: "STALE_EVALUATION_MIGRATED",
+ objectId: staleEvaluationId,
+ operator: "流程状态迁移器",
+ role: "系统",
+ at: nowText(),
+ detail: "检测到补证后的待评价任务仍引用旧企业评价,已清除当前评价引用并要求重新评价。",
+ });
+ }
+ if (state.entities.review?.decision === "进入下一周期" && !state.entities.demand.previousVersionId) {
+ state.pendingNextCycleMigration = { ...state.entities.review };
+ }
state.sequences.evidence = Math.max(Number.isInteger(state.sequences.evidence) ? state.sequences.evidence : 0, state.entities.evidences.length);
state.sequences.feedback = Math.max(Number.isInteger(state.sequences.feedback) ? state.sequences.feedback : 0, state.entities.usageFeedback.length);
return trimClosedLoopCollections(state);
@@ -714,7 +756,7 @@ function saveClosedLoopState() {
localStorage.setItem(CLOSED_LOOP_STORAGE_KEY, JSON.stringify(closedLoopState));
return true;
} catch {
- showToast("浏览器阻止了本地存储,本次闭环状态仅在当前页面有效。", "状态未持久化");
+ showToast("浏览器阻止了本地存储,本次流程状态仅在当前页面有效。", "状态未持久化");
return false;
}
}
@@ -799,7 +841,7 @@ function syncClosedLoopCollections() {
_closedLoop: true,
id: e.task.id,
title: e.task.title,
- source: `闭环建议 ${e.task.recommendationId} · 需求 ${e.task.demandId}`,
+ source: `流程建议 ${e.task.recommendationId} · 需求 ${e.task.demandId}`,
profession: e.task.profession,
owner: e.task.owner,
due: e.task.due,
@@ -837,7 +879,7 @@ function syncClosedLoopCollections() {
_closedLoop: true,
id: e.report.id,
title: e.report.title,
- version: CLOSED_LOOP_SHARED.packageVersion,
+ version: e.report.version || CLOSED_LOOP_SHARED.packageVersion,
status: e.report.status,
statusClass: loopStatusClass(e.report.status),
updated: e.report.updatedAt || "刚刚",
@@ -857,12 +899,14 @@ function saveAndRenderClosedLoop(message = "") {
renderProjects();
renderDemands();
renderReportCenter();
- if (message) showToast(message, "闭环状态已保存");
+ if (message) showToast(message, "流程状态已保存");
}
const closedLoopActionCatalog = {
demand: {
title: "提交企业需求",
+ queueLabel: "提交 / 更新本企业需求",
+ nodeKey: "demand",
description: "仅企业导师可维护本企业需求;岗位、技能、人数、授权号、有效期和提交人均为必填。",
roles: ["企业导师"],
fields: [
@@ -876,6 +920,8 @@ const closedLoopActionCatalog = {
},
receive: {
title: "确认接收并生成快照",
+ queueLabel: "确认包与需求接收",
+ nodeKey: "snapshot",
description: "校级技术管理员强校验租户、包号、版本、完整 SHA-256 和记录数;全部一致后确认接收,并生成独立分析快照。",
roles: ["校级技术管理员"],
fields: [
@@ -889,6 +935,8 @@ const closedLoopActionCatalog = {
},
decision: {
title: "专业建议决策",
+ queueLabel: "决策专业建议",
+ nodeKey: "recommendation",
description: "采纳、暂缓或驳回都必须填写理由;采纳后自动创建关联任务。",
roles: ["专业负责人"],
fields: [
@@ -900,6 +948,8 @@ const closedLoopActionCatalog = {
},
project: {
title: "创建关联项目",
+ queueLabel: "创建关联项目",
+ nodeKey: "project",
description: "项目自动绑定 demandId、recommendationId 与 taskId。",
roles: ["专业负责人"],
fields: [
@@ -910,6 +960,8 @@ const closedLoopActionCatalog = {
},
evidence: {
title: "提交阶段进展与证据",
+ queueLabel: "提交进展与证据",
+ nodeKey: "evidence",
description: "保存证据名称、类型、说明、提交人、时间和文件名/链接;提交后自动更新任务和项目进度。",
roles: ["专业负责人", "学院负责人"],
fields: [
@@ -923,6 +975,8 @@ const closedLoopActionCatalog = {
},
evaluation: {
title: "提交企业评价",
+ queueLabel: "提交企业评价",
+ nodeKey: "evaluation",
description: "企业导师只能评价本企业关联对象;通过或需整改均须填写意见。",
roles: ["企业导师"],
fields: [
@@ -932,6 +986,8 @@ const closedLoopActionCatalog = {
},
acceptance: {
title: "校级任务验收",
+ queueLabel: "校级验收 / 退回",
+ nodeKey: "acceptance",
description: "校级管理者在企业评价后验收或退回任务,并记录意见。",
roles: ["校级管理者"],
fields: [
@@ -940,19 +996,25 @@ const closedLoopActionCatalog = {
],
},
reportCreate: {
- title: "生成闭环报告草稿",
+ title: "生成业务报告草稿",
+ queueLabel: "生成业务报告草稿",
+ nodeKey: "report",
description: "报告关联需求、快照、任务和全部证据,生成后进入真实审批流。",
roles: ["专业负责人", "学院负责人", "校级管理者"],
- fields: [["title", "报告名称", "text", "边缘计算测试人才共育闭环报告"]],
+ fields: [["title", "报告名称", "text", "边缘计算测试人才共育成效报告"]],
},
reportSubmit: {
title: "提交报告审核",
+ queueLabel: "提交报告审核",
+ nodeKey: "report",
description: "草稿提交后进入待审核状态。",
roles: ["专业负责人", "学院负责人", "校级管理者"],
- fields: [["comment", "提交说明", "textarea", "闭环实体与证据已核对,请校级管理者审核。"]],
+ fields: [["comment", "提交说明", "textarea", "流程实体与证据已核对,请校级管理者审核。"]],
},
reportReview: {
title: "报告审批",
+ queueLabel: "批准 / 退回报告",
+ nodeKey: "report",
description: "只有校级管理者可批准或退回业务报告;审批人必须与本次提交人分离。",
roles: ["校级管理者"],
fields: [
@@ -963,12 +1025,16 @@ const closedLoopActionCatalog = {
},
reportPublish: {
title: "正式发布报告",
+ queueLabel: "正式发布报告",
+ nodeKey: "report",
description: "只有已批准报告可正式发布;发布后下载和导出才标识为正式版。",
roles: ["校级管理者"],
- fields: [["comment", "发布说明", "textarea", "作为本期校园闭环正式报告发布。"]],
+ fields: [["comment", "发布说明", "textarea", "作为本期校园业务流程正式报告发布。"]],
},
feedback: {
title: "记录就业侧使用反馈",
+ queueLabel: "登记就业使用反馈",
+ nodeKey: "feedback",
description: "就业老师确认数据对就业业务是否可用,并登记下一周期关注项。",
roles: ["就业老师"],
fields: [
@@ -980,6 +1046,8 @@ const closedLoopActionCatalog = {
},
review: {
title: "下一周期复核",
+ queueLabel: "下一周期复核",
+ nodeKey: "review",
description: "保存前后指标与改善结论,并决定关闭或进入下一周期。",
roles: ["专业负责人"],
fields: [
@@ -992,11 +1060,8 @@ const closedLoopActionCatalog = {
},
};
-function actionPrerequisite(action) {
+function closedLoopActionState(action) {
const e = closedLoopState.entities;
- const role = currentRoleName();
- const allowed = closedLoopActionCatalog[action]?.roles.includes(role);
- if (!allowed) return { enabled: false, reason: "当前角色无此动作权限。" };
const checks = {
demand: [
e.snapshot.status === "待接收" && !e.task && !e.report,
@@ -1010,38 +1075,639 @@ function actionPrerequisite(action) {
e.report ? "报告版本已生成,当前任务证据已冻结;新增证据必须进入下一周期或重开新版本。" : e.task?.status === "已完成" ? "任务已完成校级验收,不能继续追加当前版本证据。" : "需先创建处于执行或整改状态的关联任务和项目。",
],
evaluation: [
- e.evidences.length > 0 && e.task?.progress === 100 && e.project?.progress === 100 && e.demand.enterpriseId === "ENT-HKVS" && e.task?.status !== "已完成",
+ e.evidences.length > 0 &&
+ e.task?.progress === 100 &&
+ e.project?.progress === 100 &&
+ e.demand.enterpriseId === "ENT-HKVS" &&
+ e.task?.status === "待评价" &&
+ !e.evaluation,
e.task?.status === "已完成"
? "任务已完成校级验收,无需重复评价。"
- : e.evidences.length === 0
- ? "需先有本企业项目执行证据。"
- : "阶段进度必须达到100%后才能提交企业评价。",
+ : ["需整改", "已退回"].includes(e.task?.status)
+ ? "必须先补充新的执行证据,原评价不能直接改写。"
+ : e.evaluation?.result === "通过"
+ ? "企业评价已通过,等待校级验收。"
+ : e.evidences.length === 0
+ ? "需先有本企业项目执行证据。"
+ : "阶段进度必须达到100%后才能提交企业评价。",
],
acceptance: [e.evaluation?.result === "通过" && e.task?.status === "待验收", e.evaluation?.result === "需整改" ? "企业评价为需整改,必须先补充证据并重新评价。" : "未有企业通过评价时任务不能申请校级验收。"],
- reportCreate: [e.task?.status === "已完成" && !e.report, e.report ? "闭环报告已生成。" : "需先通过校级任务验收。"],
+ reportCreate: [e.task?.status === "已完成" && !e.report, e.report ? "业务报告已生成。" : "需先通过校级任务验收。"],
reportSubmit: [e.report?.status === "草稿" || e.report?.status === "已退回", "仅草稿或已退回报告可提交审核。"],
reportReview: [e.report?.status === "待审核", "仅待审核报告可审批。"],
reportPublish: [
e.report?.status === "已批准" && Boolean(e.report.approvedBy) && e.report.approvedBy !== e.report.submittedBy,
"正式发布前必须由不同于提交人的校级管理者批准报告。",
],
- feedback: [e.report?.status === "已发布", "需先正式发布报告。"],
- review: [e.usageFeedback.length > 0, "需先形成就业侧数据使用反馈。"],
+ feedback: [e.report?.status === "已发布" && !e.review, e.review ? "本周期已完成成效复核,不能继续追加使用反馈。" : "需先正式发布报告。"],
+ review: [
+ e.usageFeedback.at(-1)?.usable === "可用" && !e.review,
+ e.review ? "本周期成效复核已完成。" : e.usageFeedback.length ? "最新业务使用反馈仍为需补充,确认可用后才能复核。" : "需先形成就业侧数据使用反馈。",
+ ],
};
const [enabled, reason] = checks[action] || [false, "动作不可用。"];
return { enabled, reason: enabled ? "前置条件已满足。" : reason };
}
-function renderClosedLoopAction(action, label) {
+function actionPrerequisite(action) {
+ const role = currentRoleName();
+ const allowed = closedLoopActionCatalog[action]?.roles.includes(role);
+ if (!allowed) return { enabled: false, reason: "当前角色无此动作权限。" };
+ return closedLoopActionState(action);
+}
+
+const CLOSED_LOOP_NODE_POSITIONS = {
+ demand: [60, 35],
+ snapshot: [415, 35],
+ recommendation: [770, 35],
+ task: [20, 220],
+ project: [215, 220],
+ evidence: [410, 220],
+ evaluation: [605, 220],
+ acceptance: [800, 220],
+ report: [170, 420],
+ feedback: [415, 420],
+ review: [660, 420],
+};
+
+function closedLoopCssPixelValue(styles, property) {
+ const value = parseFloat(styles.getPropertyValue(property));
+ if (!Number.isFinite(value) || value <= 0) throw new Error(`流程布局变量 ${property} 无效。`);
+ return value;
+}
+
+function closedLoopCanvasStyles() {
+ const canvas = document.querySelector(".loop-flow-canvas");
+ if (!canvas) throw new Error("流程画布尚未挂载。");
+ return getComputedStyle(canvas);
+}
+
+function closedLoopCanvasSize() {
+ const styles = closedLoopCanvasStyles();
+ return {
+ width: closedLoopCssPixelValue(styles, "--loop-canvas-width"),
+ height: closedLoopCssPixelValue(styles, "--loop-canvas-height"),
+ };
+}
+
+function closedLoopNodeSize() {
+ const styles = closedLoopCanvasStyles();
+ return {
+ width: closedLoopCssPixelValue(styles, "--loop-node-width"),
+ height: closedLoopCssPixelValue(styles, "--loop-node-height"),
+ };
+}
+
+function closedLoopNodeGeometry(key, size = closedLoopNodeSize()) {
+ const [left, top] = CLOSED_LOOP_NODE_POSITIONS[key];
+ const { width, height } = size;
+ return {
+ left,
+ right: left + width,
+ top,
+ bottom: top + height,
+ centerX: left + width / 2,
+ centerY: top + height / 2,
+ };
+}
+
+function closedLoopMainEdgeDefinitions() {
+ const size = closedLoopNodeSize();
+ const demand = closedLoopNodeGeometry("demand", size);
+ const snapshot = closedLoopNodeGeometry("snapshot", size);
+ const recommendation = closedLoopNodeGeometry("recommendation", size);
+ const task = closedLoopNodeGeometry("task", size);
+ const project = closedLoopNodeGeometry("project", size);
+ const evidence = closedLoopNodeGeometry("evidence", size);
+ const evaluation = closedLoopNodeGeometry("evaluation", size);
+ const acceptance = closedLoopNodeGeometry("acceptance", size);
+ const report = closedLoopNodeGeometry("report", size);
+ const feedback = closedLoopNodeGeometry("feedback", size);
+ const review = closedLoopNodeGeometry("review", size);
+ const recommendationElbowY = task.top - 40;
+ const acceptanceElbowY = report.top - 35;
+ return [
+ { from: "demand", to: "snapshot", path: `M${demand.right} ${demand.centerY} H${snapshot.left}` },
+ { from: "snapshot", to: "recommendation", path: `M${snapshot.right} ${snapshot.centerY} H${recommendation.left}` },
+ {
+ from: "recommendation",
+ to: "task",
+ path: `M${recommendation.centerX} ${recommendation.bottom} V${recommendationElbowY} H${task.centerX} V${task.top}`,
+ label: "采纳后创建任务",
+ labelPosition: [(recommendation.centerX + task.centerX) / 2, recommendationElbowY - 9],
+ },
+ { from: "task", to: "project", path: `M${task.right} ${task.centerY} H${project.left}` },
+ { from: "project", to: "evidence", path: `M${project.right} ${project.centerY} H${evidence.left}` },
+ { from: "evidence", to: "evaluation", path: `M${evidence.right} ${evidence.centerY} H${evaluation.left}` },
+ { from: "evaluation", to: "acceptance", path: `M${evaluation.right} ${evaluation.centerY} H${acceptance.left}` },
+ {
+ from: "acceptance",
+ to: "report",
+ path: `M${acceptance.centerX} ${acceptance.bottom} V${acceptanceElbowY} H${report.centerX} V${report.top}`,
+ label: "验收通过",
+ labelPosition: [(acceptance.centerX + report.centerX) / 2 + 15, acceptanceElbowY - 9],
+ },
+ { from: "report", to: "feedback", path: `M${report.right} ${report.centerY} H${feedback.left}` },
+ { from: "feedback", to: "review", path: `M${feedback.right} ${feedback.centerY} H${review.left}` },
+ ];
+}
+
+const closedLoopUiState = {
+ selectedNodeKey: null,
+};
+
+function closedLoopNodeDefinitions() {
+ const e = closedLoopState.entities;
+ const evidence = e.evidences.at(-1);
+ const feedback = e.usageFeedback.at(-1);
+ const acceptance = e.task?.acceptanceHistory?.at(-1);
+ const rawNodes = [
+ {
+ key: "demand",
+ title: "企业需求",
+ phase: "需求提出",
+ id: e.demand.id,
+ status: e.demand.status,
+ owner: "企业导师",
+ description: "企业提交真实岗位、技能、人数与授权信息,形成后续分析和共育项目的业务起点。",
+ input: "岗位、技能、计划人数、授权编号",
+ output: "可追踪的企业需求版本",
+ complete: e.demand.status === "已提交",
+ risk: false,
+ relations: [e.demand.enterpriseId, e.demand.previousVersionId, e.recommendation.demandId],
+ facts: [
+ ["岗位", e.demand.position],
+ ["计划人数", `${e.demand.headcount} 人`],
+ ["技能要求", e.demand.skills],
+ ["授权有效期", e.demand.validUntil],
+ ],
+ },
+ {
+ key: "snapshot",
+ title: "接收与分析快照",
+ phase: "技术校验",
+ id: e.snapshot.id,
+ status: e.snapshot.status,
+ owner: "校级技术管理员",
+ description: "强校验租户、包号、版本、记录数和完整 SHA-256,通过后生成独立分析快照。",
+ input: "厂商数据包、Manifest、租户授权",
+ output: "已验收分析快照",
+ complete: e.snapshot.status === "已验收",
+ risk: false,
+ relations: [e.receipt.id, e.snapshot.packageId, e.snapshot.packageVersion],
+ facts: [
+ ["交付记录", `${e.receipt.receivedRecords.toLocaleString("zh-CN")} / ${e.receipt.expectedRecords.toLocaleString("zh-CN")} 条`],
+ ["分析样本", `${e.snapshot.checkedRecords.toLocaleString("zh-CN")} / ${e.snapshot.expectedRecords.toLocaleString("zh-CN")} 条`],
+ ["接收状态", e.receipt.status],
+ ["技术接收人", e.snapshot.operator || "待确认"],
+ ],
+ },
+ {
+ key: "recommendation",
+ title: "专业建议决策",
+ phase: "业务决策",
+ id: e.recommendation.id,
+ status: e.recommendation.status,
+ owner: "专业负责人",
+ description: "专业负责人基于快照和企业需求采纳、暂缓或驳回建议,采纳后自动创建任务。",
+ input: "分析结论、企业需求、课程差距",
+ output: "带理由的专业决策",
+ complete: e.recommendation.status === "已采纳",
+ risk: ["暂缓", "驳回"].includes(e.recommendation.status),
+ relations: [e.recommendation.snapshotId, e.recommendation.demandId],
+ facts: [
+ ["建议", e.recommendation.title],
+ ["决策理由", e.recommendation.reason || "待填写"],
+ ["决策人", e.recommendation.operator || "待专业负责人决策"],
+ ["决策时间", e.recommendation.decidedAt || "待决策"],
+ ],
+ },
+ {
+ key: "task",
+ title: "改进任务",
+ phase: "任务生成",
+ id: e.task?.id || "TSK-待创建",
+ status: e.task?.status || "未创建",
+ owner: e.task?.owner || "专业负责人",
+ description: "采纳专业建议后自动生成改进任务,保存负责人、期限、进度和验收历史。",
+ input: "已采纳建议与任务负责人",
+ output: "可执行、可验收的改进任务",
+ reached: e.recommendation.status === "已采纳",
+ complete: Boolean(e.task),
+ risk: ["需整改", "已退回"].includes(e.task?.status),
+ relations: [e.task?.demandId, e.task?.recommendationId],
+ facts: [
+ ["任务名称", e.task?.title || "待创建"],
+ ["负责人", e.task?.owner || "待指定"],
+ ["截止日期", e.task?.due || "待确定"],
+ ["执行进度", `${e.task?.progress || 0}%`],
+ ],
+ },
+ {
+ key: "project",
+ title: "关联共育项目",
+ phase: "项目承接",
+ id: e.project?.id || "PRJ-待创建",
+ status: e.project?.status || "未创建",
+ owner: e.project?.owner || "专业负责人",
+ description: "把企业需求、专业建议和改进任务绑定为同一个校企共育项目,避免任务与项目脱节。",
+ input: "需求 ID、建议 ID、任务 ID",
+ output: "三类实体强绑定的项目",
+ complete: Boolean(e.project),
+ risk: ["需整改", "已退回"].includes(e.project?.status),
+ relations: [e.project?.demandId, e.project?.recommendationId, e.project?.taskId],
+ facts: [
+ ["项目名称", e.project?.title || "待创建"],
+ ["牵头专业", e.project?.profession || "待确定"],
+ ["负责人", e.project?.owner || "待指定"],
+ ["项目进度", `${e.project?.progress || 0}%`],
+ ],
+ },
+ {
+ key: "evidence",
+ title: "执行证据",
+ phase: "过程执行",
+ id: evidence?.id || "EVD-待提交",
+ status: e.evidences.length ? `已提交 ${e.evidences.length} 项 · 进度 ${e.task?.progress || 0}%` : "未提交",
+ owner: "专业 / 学院负责人",
+ description: "提交课程方案、阶段成果、评审纪要或学生作品,并同步更新任务与项目进度。",
+ input: "过程产物、提交人、文件引用",
+ output: "带时间和对象关联的验收证据",
+ complete:
+ e.evidences.length > 0 &&
+ e.task?.progress === 100 &&
+ e.project?.progress === 100 &&
+ !["需整改", "已退回"].includes(e.task?.status),
+ risk: ["需整改", "已退回"].includes(e.task?.status),
+ relations: [evidence?.taskId, evidence?.projectId],
+ facts: [
+ ["最新证据", evidence?.name || "待提交"],
+ ["证据类型", evidence?.type || "待选择"],
+ ["文件引用", evidence?.fileRef || "待上传"],
+ ["提交人", evidence?.submitter || "待提交"],
+ ],
+ },
+ {
+ key: "evaluation",
+ title: "企业评价",
+ phase: "企业复核",
+ id: e.evaluation?.id || "EVL-待提交",
+ status: e.evaluation?.result || "未评价",
+ owner: "企业导师",
+ description: "企业导师只评价本企业关联对象;需整改会回流到执行证据节点,通过后进入校级验收。",
+ input: "100% 阶段进度与执行证据",
+ output: "通过或需整改的企业意见",
+ complete: Boolean(e.evaluation),
+ risk: e.evaluation?.result === "需整改",
+ relations: [e.evaluation?.taskId, e.evaluation?.projectId, e.evaluation?.enterpriseId],
+ facts: [
+ ["评价结论", e.evaluation?.result || "待评价"],
+ ["评价意见", e.evaluation?.comment || "待填写"],
+ ["评价人", e.evaluation?.operator || "待企业导师"],
+ ["评价时间", e.evaluation?.at || "待评价"],
+ ],
+ },
+ {
+ key: "acceptance",
+ title: "校级验收",
+ phase: "学校验收",
+ id: e.task?.status === "待验收" ? "ACC-待验收" : acceptance?.id || "ACC-待验收",
+ status:
+ e.task?.status === "待验收"
+ ? "待验收"
+ : e.task?.status === "已完成"
+ ? "验收通过"
+ : acceptance?.result || "未到达",
+ owner: "校级管理者",
+ description: "校级管理者核对企业评价、任务进度与证据后验收;退回时重新进入证据补充。",
+ input: "企业通过评价、任务和项目证据",
+ output: "校级验收结论",
+ complete: e.task?.status === "已完成",
+ risk: e.task?.status === "已退回",
+ relations: [e.task?.id, e.evaluation?.id, e.project?.id],
+ facts: [
+ ["验收结论", acceptance?.result || "待验收"],
+ ["验收意见", acceptance?.comment || "待填写"],
+ ["验收人", acceptance?.operator || "待校级管理者"],
+ ["任务状态", e.task?.status || "未创建"],
+ ],
+ },
+ {
+ key: "report",
+ title: "报告审批发布",
+ phase: "正式交付",
+ id: e.report?.id || "RPT-待生成",
+ status: e.report?.status || "未生成",
+ owner: "编制人 / 独立审批人",
+ description: "报告关联需求、快照、任务和证据,依次经过草稿、提交、独立审批和正式发布。",
+ input: "验收通过的任务与冻结证据",
+ output: "可归档的正式报告版本",
+ complete: e.report?.status === "已发布",
+ risk: e.report?.status === "已退回",
+ relations: [e.report?.snapshotId, e.report?.demandId, e.report?.taskId],
+ facts: [
+ ["报告名称", e.report?.title || "待生成"],
+ ["当前状态", e.report?.status || "未生成"],
+ ["提交人", e.report?.submittedBy || "待提交"],
+ ["批准人", e.report?.approvedBy || "待独立审批"],
+ ],
+ },
+ {
+ key: "feedback",
+ title: "业务使用反馈",
+ phase: "成果使用",
+ id: feedback?.id || "FBK-待登记",
+ status: feedback?.usable || "未反馈",
+ owner: "就业老师",
+ description: "就业老师确认报告和数据是否可用于就业业务,并记录下一周期需要继续补充的事项。",
+ input: "正式报告与业务使用结果",
+ output: "可用性确认和下一周期关注项",
+ complete: e.usageFeedback.length > 0 && feedback?.usable === "可用",
+ risk: feedback?.usable === "需补充",
+ relations: [feedback?.reportId, feedback?.snapshotId],
+ facts: [
+ ["使用部门", feedback?.department || "待登记"],
+ ["可用性", feedback?.usable || "待确认"],
+ ["使用反馈", feedback?.comment || "待填写"],
+ ["下一周期", feedback?.nextCycle || "待登记"],
+ ],
+ },
+ {
+ key: "review",
+ title: "成效复核",
+ phase: "周期复盘",
+ id: e.review?.id || "RVW-待复核",
+ status: e.review?.decision || "未复核",
+ owner: "专业负责人",
+ description: "对比前后周期指标,判断改善、无变化或恶化,并决定关闭或进入下一周期。",
+ input: "前后指标、使用反馈和改进证据",
+ output: "关闭结论或下一周期起点",
+ complete: e.review?.decision === "关闭",
+ risk: e.review?.conclusion === "恶化",
+ relations: [e.review?.id, e.report?.id, feedback?.id],
+ facts: [
+ ["前周期指标", e.review ? String(e.review.before) : "待记录"],
+ ["本周期指标", e.review ? String(e.review.after) : "待记录"],
+ ["复核结论", e.review?.conclusion || "待判断"],
+ ["周期决定", e.review?.decision || "待决定"],
+ ],
+ },
+ ];
+
+ const actionsByNode = Object.entries(closedLoopActionCatalog).reduce((grouped, [action, config]) => {
+ const actions = grouped.get(config.nodeKey) || [];
+ actions.push(action);
+ grouped.set(config.nodeKey, actions);
+ return grouped;
+ }, new Map());
+ let currentAssigned = false;
+ return rawNodes.map((node, index) => {
+ const actions = actionsByNode.get(node.key) || [];
+ const reached = node.reached ?? actions.some((action) => closedLoopActionState(action).enabled);
+ let state = "locked";
+ if (node.complete) {
+ state = "complete";
+ } else if (!currentAssigned && node.risk) {
+ state = "risk";
+ currentAssigned = true;
+ } else if (!currentAssigned && reached) {
+ state = "current";
+ currentAssigned = true;
+ }
+ const [x, y] = CLOSED_LOOP_NODE_POSITIONS[node.key];
+ return { ...node, actions, reached, index, state, x, y };
+ });
+}
+
+function closedLoopCurrentNode(nodes) {
+ return nodes.find((node) => ["current", "risk"].includes(node.state)) || null;
+}
+
+function closedLoopNodeStatusClass(node) {
+ if (node.risk) return "risk";
+ if (node.state === "complete") return "done";
+ if (node.state === "risk") return "risk";
+ if (node.state === "current") return "review";
+ return "progress";
+}
+
+function renderClosedLoopNode(node, selectedKey) {
+ const selected = node.key === selectedKey;
+ return `
+ `;
+}
+
+function closedLoopBranchDefinitions(nodes) {
+ const e = closedLoopState.entities;
+ const byKey = new Map(nodes.map((node) => [node.key, node]));
+ const size = closedLoopNodeSize();
+ const demand = closedLoopNodeGeometry("demand", size);
+ const recommendation = closedLoopNodeGeometry("recommendation", size);
+ const evidence = closedLoopNodeGeometry("evidence", size);
+ const evaluation = closedLoopNodeGeometry("evaluation", size);
+ const acceptance = closedLoopNodeGeometry("acceptance", size);
+ const review = closedLoopNodeGeometry("review", size);
+ const remediationElbowY = evaluation.top - 32;
+ const acceptanceReturnY = acceptance.bottom + 45;
+ const cycleRailX = closedLoopCanvasSize().width - 35;
+ const cycleRailY = demand.top - 17;
+ return [
+ {
+ from: "evaluation",
+ fromLabel: "企业评价",
+ to: "evidence",
+ toLabel: "执行证据",
+ label: "需整改",
+ description: "需整改时补充证据并重新评价",
+ tone: "risk",
+ active: e.evaluation?.result === "需整改",
+ state: e.evaluation?.result === "需整改" ? "risk" : "branch",
+ path: `M${evaluation.centerX} ${evaluation.top} V${remediationElbowY} H${evidence.centerX} V${evidence.top}`,
+ labelPosition: [(evaluation.centerX + evidence.centerX) / 2, remediationElbowY - 8],
+ },
+ {
+ from: "acceptance",
+ fromLabel: "校级验收",
+ to: "evidence",
+ toLabel: "执行证据",
+ label: "退回补证",
+ description: "退回时补证并重新进入企业评价",
+ tone: "risk",
+ active: e.task?.status === "已退回",
+ state: e.task?.status === "已退回" ? "risk" : "branch",
+ path: `M${acceptance.centerX} ${acceptance.bottom} V${acceptanceReturnY} H${evidence.centerX} V${evidence.bottom}`,
+ labelPosition: [(acceptance.centerX + evidence.centerX) / 2, acceptanceReturnY - 8],
+ },
+ {
+ from: "review",
+ fromLabel: "成效复核",
+ to: "demand",
+ toLabel: "企业需求",
+ label: "进入下一周期",
+ description: "进入下一周期时创建新需求版本",
+ tone: "cycle",
+ active: Boolean(e.demand.previousVersionId),
+ state: e.demand.previousVersionId ? (byKey.get("demand").state === "current" ? "current" : "complete") : "branch",
+ path: `M${review.right} ${review.centerY} H${cycleRailX} V${cycleRailY} H${demand.centerX} V${demand.top}`,
+ labelPosition: [cycleRailX - 90, recommendation.bottom + 23],
+ },
+ ];
+}
+
+function closedLoopEdgeState(sourceNode, targetNode, branchState = "") {
+ if (branchState) return branchState;
+ if (targetNode.state === "complete") return "complete";
+ if (targetNode.state === "risk") return "risk";
+ if (targetNode.state === "current") return "current";
+ return "locked";
+}
+
+function renderClosedLoopEdges(nodes) {
+ const byKey = new Map(nodes.map((node) => [node.key, node]));
+ const edges = [...closedLoopMainEdgeDefinitions(), ...closedLoopBranchDefinitions(nodes)];
+ const definitions = `
+
+ ${["complete", "current", "risk", "locked", "branch"]
+ .map(
+ (state) =>
+ ``,
+ )
+ .join("")}
+ `;
+ return (
+ definitions +
+ edges
+ .map(({ from, to, path, label = "", labelPosition = [], state: branchState = "" }) => {
+ const state = closedLoopEdgeState(byKey.get(from), byKey.get(to), branchState);
+ const [labelX, labelY] = labelPosition;
+ return `
+
+
+ ${label ? `${label}` : ""}
+ `;
+ })
+ .join("")
+ );
+}
+
+function renderClosedLoopMobileBranches(nodes) {
+ return closedLoopBranchDefinitions(nodes)
+ .map(
+ (branch) => `
+ `,
+ )
+ .join("");
+}
+
+function closedLoopNodeAction(node) {
+ const role = currentRoleName();
+ const roleActions = node.actions.filter((action) => closedLoopActionCatalog[action].roles.includes(role));
+ if (!roleActions.length) return null;
+ return roleActions.find((action) => actionPrerequisite(action).enabled) || roleActions[0];
+}
+
+function renderClosedLoopNodeDetail(node) {
+ document.getElementById("loopNodeKicker").textContent = `${String(node.index + 1).padStart(2, "0")} · ${node.phase.toUpperCase()}`;
+ document.getElementById("loopNodeTitle").textContent = node.title;
+ const status = document.getElementById("loopNodeStatus");
+ status.className = `status-badge ${closedLoopNodeStatusClass(node)}`;
+ status.textContent = node.status;
+ const action = closedLoopNodeAction(node);
+ const availability = action ? actionPrerequisite(action) : null;
+ const relations = node.relations.filter(Boolean);
+ const detailState = node.risk ? "risk" : node.state;
+ const actionMarkup = action
+ ? `
+
+ ${closedLoopActionCatalog[action].queueLabel}${availability.reason}
+
+
`
+ : `
+
+ 当前角色只读该节点由${safeText(node.owner)}负责,切换对应角色后可办理已解锁动作。
+
`;
+ document.getElementById("loopNodeDetail").innerHTML = `
+
+
${String(node.index + 1).padStart(2, "0")}
+
责任角色${safeText(node.owner)}${safeText(node.description)}
+
${node.risk ? "需要处理" : node.state === "complete" ? "节点已完成" : node.state === "current" ? "当前待办" : "等待前置节点"}
+
+
+
节点输入${safeText(node.input)}
+
+
节点输出${safeText(node.output)}
+
+
+ ${node.facts.map(([label, value]) => `${label}${safeText(value)}`).join("")}
+
+
+
关联实体
+
${relations.map((relation) => ``).join("") || "前置实体完成后自动建立关联"}
+
+ ${actionMarkup}`;
+}
+
+function selectClosedLoopNode(nodeKey, options = {}) {
+ const nodes = closedLoopNodeDefinitions();
+ const node = nodes.find((item) => item.key === nodeKey);
+ if (!node) return null;
+ closedLoopUiState.selectedNodeKey = node.key;
+ document.querySelectorAll("[data-loop-node]").forEach((button) => {
+ const selected = button.dataset.loopNode === node.key;
+ button.classList.toggle("selected", selected);
+ button.setAttribute("aria-pressed", String(selected));
+ });
+ renderClosedLoopNodeDetail(node);
+ const selectedButton = document.querySelector(`[data-loop-node="${node.key}"]`);
+ if (options.focus) selectedButton?.focus({ preventScroll: true });
+ return { node, selectedButton };
+}
+
+function renderClosedLoopAction(action, nodes) {
const availability = actionPrerequisite(action);
- return ``;
+ const node = nodes.find((item) => item.key === closedLoopActionCatalog[action].nodeKey);
+ return `
+ `;
}
function renderClosedLoop() {
const root = document.getElementById("loopChain");
if (!root) return;
- const e = closedLoopState.entities;
+ const nodes = closedLoopNodeDefinitions();
+ const currentNode = closedLoopCurrentNode(nodes);
+ if (!closedLoopUiState.selectedNodeKey || !nodes.some((node) => node.key === closedLoopUiState.selectedNodeKey)) {
+ closedLoopUiState.selectedNodeKey = currentNode?.key || nodes.at(-1).key;
+ }
+ const selectedNode = nodes.find((node) => node.key === closedLoopUiState.selectedNodeKey) || currentNode || nodes.at(-1);
+ const completedCount = nodes.filter((node) => node.state === "complete").length;
+ const progress = Math.round((completedCount / nodes.length) * 100);
document.getElementById("loopSchemaBadge").textContent = `SCHEMA V${closedLoopState.schemaVersion}`;
+ document.getElementById("loopFocusCurrent").disabled = !currentNode;
document.getElementById("loopIdentityStrip").innerHTML = `
共享业务标识对照
租户 ${closedLoopState.shared.tenantId}
@@ -1049,72 +1715,40 @@ function renderClosedLoop() {
版本 ${closedLoopState.shared.packageVersion}
学校回执 ${closedLoopState.shared.receiptId}
静态原型仅用相同标识核对业务链,不代表厂商端与校园端页面实时同步。`;
- const stages = [
- ["需求", e.demand.id, e.demand.status],
- ["快照", e.snapshot.id, e.snapshot.status],
- ["建议", e.recommendation.id, e.recommendation.status],
- ["任务", e.task?.id || "TSK-待创建", e.task?.status || "未创建"],
- ["项目", e.project?.id || "PRJ-待创建", e.project?.status || "未创建"],
- ["证据", e.evidences.at(-1)?.id || "EVD-待提交", e.evidences.length ? `已提交 ${e.evidences.length} 项` : "未提交"],
- ["企业评价", e.evaluation?.id || "EVL-待提交", e.evaluation?.result || "未评价"],
- ["报告", e.report?.id || "RPT-待生成", e.report?.status || "未生成"],
- ["反馈/复核", e.review?.id || e.usageFeedback.at(-1)?.id || e.receipt.id, e.review?.decision || (e.usageFeedback.length ? "已反馈" : e.receipt.status)],
- ];
- root.innerHTML = stages
- .map(
- ([label, id, status], index) =>
- `${String(index + 1).padStart(2, "0")}${label}${safeText(id)}${safeText(status)}
`,
- )
- .join("");
- const evidence = e.evidences.at(-1);
- const feedback = e.usageFeedback.at(-1);
- document.getElementById("loopEntityGrid").innerHTML = `
- 企业需求 · ${e.demand.id}${safeText(e.demand.position)} · ${e.demand.headcount}人技能:${safeText(e.demand.skills)}授权:${safeText(e.demand.authorizationNo)} · 至 ${e.demand.validUntil}提交:${safeText(e.demand.submitter)} · ${e.demand.submittedAt}
- 分析快照 · ${e.snapshot.id}${e.snapshot.status} · ${e.snapshot.checkedRecords.toLocaleString("zh-CN")} / ${e.snapshot.expectedRecords.toLocaleString("zh-CN")} 条${e.snapshot.packageId} / ${e.snapshot.packageVersion}包回执:${e.receipt.receivedRecords.toLocaleString("zh-CN")} / ${e.receipt.expectedRecords.toLocaleString("zh-CN")} 条SHA ${safeText(e.receipt.receivedSha256 || e.receipt.expectedSha256)}技术接收:${safeText(e.snapshot.operator || "待确认")}
- 专业建议 · ${e.recommendation.id}${safeText(e.recommendation.title)}${e.recommendation.status} · ${safeText(e.recommendation.reason || "待填写决策理由")}${safeText(e.recommendation.operator || "待专业负责人决策")}
- 任务 / 项目${safeText(e.task?.id || "待创建")} / ${safeText(e.project?.id || "待创建")}任务进度 ${e.task?.progress || 0}% · ${safeText(e.task?.status || "未创建")}绑定:${safeText(e.project?.demandId || "-")} / ${safeText(e.project?.recommendationId || "-")} / ${safeText(e.project?.taskId || "-")}
- 最新证据 / 企业评价${safeText(evidence?.id || "暂无证据")} / ${safeText(e.evaluation?.id || "暂无评价")}${safeText(evidence ? `${evidence.name} · ${evidence.type} · ${evidence.fileRef}` : "提交证据后自动更新进度")}${safeText(e.evaluation ? `${e.evaluation.result} · ${e.evaluation.comment}` : "企业导师仅可评价本企业对象")}
- 报告 / 回执 / 使用反馈${safeText(e.report?.id || "待生成")} · ${safeText(e.report?.status || "未生成")}${e.receipt.id} · ${e.receipt.status}${safeText(feedback ? `${feedback.department}:${feedback.usable} · ${feedback.comment}` : "就业老师尚未登记使用反馈")}`;
+ document.getElementById("loopFlowSummary").innerHTML = `
+ 流程完成度${progress}%
+ 已完成节点${completedCount} / ${nodes.length}
+ 当前待办${safeText(currentNode?.title || "流程已关闭")}
+ 责任角色${safeText(currentNode?.owner || "无待办")}`;
+ root.innerHTML = nodes.map((node) => renderClosedLoopNode(node, selectedNode.key)).join("");
+ const flowSvg = document.getElementById("loopFlowEdges");
+ const canvasSize = closedLoopCanvasSize();
+ flowSvg.setAttribute("viewBox", `0 0 ${canvasSize.width} ${canvasSize.height}`);
+ flowSvg.innerHTML = renderClosedLoopEdges(nodes);
+ document.getElementById("loopMobileBranches").innerHTML = renderClosedLoopMobileBranches(nodes);
+ renderClosedLoopNodeDetail(selectedNode);
const role = currentRoleName();
- const boundary = {
- 校级管理者: "可校级验收、报告批准/退回与正式发布;不代替技术接收或专业决策。",
- 校级技术管理员: "负责包接收、SHA/记录数质量校验与快照验收;不能批准业务报告。",
- 学院负责人: "可提交项目阶段证据、生成和提交报告;校级审批权不可用。",
- 专业负责人: "可决策专业建议、推进任务项目、提交证据并完成周期复核。",
- 就业老师: "可登记就业侧数据使用反馈;不能审批校级报告。",
- 企业导师: "仅可提交和评价海康威视 ENT-HKVS 的需求、项目和任务对象。",
- 厂商超级管理员: "校园闭环只读;共享标识用于静态原型业务对照,不构成实时同步。",
+ const roleActions = Object.entries(closedLoopActionCatalog)
+ .filter(([, config]) => config.roles.includes(role))
+ .map(([action]) => action);
+ const boundaryNote = {
+ 校级管理者: "不代替技术接收或专业决策。",
+ 校级技术管理员: "技术质量职责不包含业务验收或报告批准。",
+ 学院负责人: "校级验收、审批和发布节点不可用。",
+ 专业负责人: "校级验收、批准和发布仍由校级管理者办理。",
+ 就业老师: "不能进入校级报告审批节点。",
+ 企业导师: "仅限本企业授权对象,不能操作学校内部审批。",
+ 厂商超级管理员: "校园流程只读;共享标识用于业务对照,不构成实时同步。",
};
- document.getElementById("loopRoleActionTitle").textContent = `${role}下一步`;
- document.getElementById("loopRoleBoundary").innerHTML = `权限边界${boundary[role]}
`;
- const actionLabels = {
- demand: "提交 / 更新本企业需求",
- receive: "确认包与需求接收",
- decision: "决策专业建议",
- project: "创建关联项目",
- evidence: "提交进展与证据",
- evaluation: "提交企业评价",
- acceptance: "校级验收 / 退回",
- reportCreate: "生成闭环报告草稿",
- reportSubmit: "提交报告审核",
- reportReview: "批准 / 退回报告",
- reportPublish: "正式发布报告",
- feedback: "登记就业使用反馈",
- review: "下一周期复核",
- };
- const roleActions = {
- 企业导师: ["demand", "evaluation"],
- 校级技术管理员: ["receive"],
- 专业负责人: ["decision", "project", "evidence", "reportCreate", "reportSubmit", "review"],
- 学院负责人: ["evidence", "reportCreate", "reportSubmit"],
- 校级管理者: ["acceptance", "reportCreate", "reportSubmit", "reportReview", "reportPublish"],
- 就业老师: ["feedback"],
- 厂商超级管理员: [],
- }[role] || [];
+ const capabilityText = roleActions.length
+ ? `可办理:${roleActions.map((action) => closedLoopActionCatalog[action].queueLabel).join("、")}。`
+ : "本流程无可办理动作。";
+ document.getElementById("loopRoleActionTitle").textContent = `${role}可办理节点`;
+ document.getElementById("loopRoleBoundary").innerHTML = `权限边界${capabilityText}${boundaryNote[role]}
`;
document.getElementById("loopActionList").innerHTML =
- roleActions.map((action) => renderClosedLoopAction(action, actionLabels[action])).join("") ||
- `当前为只读视角请切换到校园业务角色验证闭环动作。
`;
+ roleActions.map((action) => renderClosedLoopAction(action, nodes)).join("") ||
+ `当前为只读视角切换校园业务角色后,右侧会显示该角色可办理的流程节点。
`;
document.getElementById("loopAuditCount").textContent = `${closedLoopState.auditEvents.length} 条`;
document.getElementById("loopAuditList").innerHTML = closedLoopState.auditEvents
.slice(0, 18)
@@ -1132,6 +1766,7 @@ function openClosedLoopAction(action) {
showToast(availability.reason, "前置条件未满足");
return;
}
+ selectClosedLoopNode(closedLoopActionCatalog[action].nodeKey || closedLoopUiState.selectedNodeKey);
const e = closedLoopState.entities;
const receiveDefaults = {
tenantId: e.receipt.tenantId,
@@ -1155,7 +1790,7 @@ function openClosedLoopAction(action) {
feedback: {},
review: e.review || {},
}[action];
- document.getElementById("closedLoopModalKicker").textContent = `CLOSED LOOP · ${action.toUpperCase()}`;
+ document.getElementById("closedLoopModalKicker").textContent = `WORKFLOW NODE · ${action.toUpperCase()}`;
document.getElementById("closedLoopModalTitle").textContent = config.title;
document.getElementById("closedLoopModalDescription").textContent = config.description;
document.getElementById("closedLoopForm").dataset.action = action;
@@ -1176,6 +1811,125 @@ function openClosedLoopAction(action) {
openModal(document.getElementById("closedLoopModal"));
}
+function nextClosedLoopCycle(currentCycle) {
+ const match = /^(\d{4})C([12])$/.exec(currentCycle || "");
+ if (!match) return "2027C1";
+ const year = Number(match[1]);
+ return match[2] === "1" ? `${year}C2` : `${year + 1}C1`;
+}
+
+function closedLoopCycleEntityId(prefix, owner, cycle, sequence) {
+ return `${prefix}-${owner}-${cycle}-${String(sequence).padStart(2, "0")}`;
+}
+
+const CLOSED_LOOP_LEGACY_ENTITY_IDS = {
+ task: "TSK-SOFTWARE-20260823-01",
+ project: "PRJ-HKVS-20260823-01",
+ evaluation: "EVL-HKVS-20260823-01",
+ acceptance: "ACC-HZVTC-20260823-01",
+ report: "RPT-HZVTC-20260823-01",
+ review: "RVW-SOFTWARE-2027C1-01",
+};
+
+function nextClosedLoopEntityId(kind, prefix, owner) {
+ const sequence = ++closedLoopState.sequences[kind];
+ const cycle = closedLoopState.entities.demand.cycle || "2026C2";
+ if (cycle === "2026C2" && sequence === 1) return CLOSED_LOOP_LEGACY_ENTITY_IDS[kind];
+ return closedLoopCycleEntityId(prefix, owner, cycle, sequence);
+}
+
+function startClosedLoopNextCycle(review, at) {
+ const e = closedLoopState.entities;
+ const previousCycle = e.demand.cycle || "2026C2";
+ const previous = {
+ demandId: e.demand.id,
+ snapshotId: e.snapshot.id,
+ reportId: e.report?.id || "",
+ feedbackId: e.usageFeedback.at(-1)?.id || "",
+ reviewId: review.id,
+ };
+ if (e.report) {
+ const reportTemplate =
+ reports.find((report) => report.type === "school" && !report._closedLoop && !report._customPersisted) ||
+ reports[0];
+ closedLoopState.custom.reports.unshift({
+ ...reportTemplate,
+ ...e.report,
+ evidenceIds: Array.isArray(e.report.evidenceIds) ? [...e.report.evidenceIds] : [],
+ evidences: e.evidences.map((item) => ({ ...item })),
+ history: e.report.history.map((item) => ({ ...item })),
+ status: "已归档",
+ statusClass: "review",
+ updated: at,
+ archivedAt: at,
+ archivedCycle: previousCycle,
+ featured: false,
+ roles: ["校级管理者", "校级技术管理员", "学院负责人", "专业负责人", "就业老师"],
+ });
+ }
+
+ const nextSequence = ++closedLoopState.sequences.cycle;
+ const nextCycle = nextClosedLoopCycle(previousCycle);
+ const nextDemandId = closedLoopCycleEntityId("DEM", "HKVS", nextCycle, nextSequence);
+ const nextSnapshotId = closedLoopCycleEntityId("SNP", "HZVTC", nextCycle, nextSequence);
+ const nextReceiptId = closedLoopCycleEntityId("RCT", "HZVTC", nextCycle, nextSequence);
+ e.demand = {
+ ...e.demand,
+ id: nextDemandId,
+ status: "草稿",
+ submittedAt: "",
+ previousVersionId: previous.demandId,
+ cycle: nextCycle,
+ };
+ e.snapshot = {
+ ...e.snapshot,
+ id: nextSnapshotId,
+ status: "待接收",
+ receivedAt: "",
+ acceptedAt: "",
+ operator: "",
+ checkedRecords: 0,
+ previousSnapshotId: previous.snapshotId,
+ };
+ e.recommendation = {
+ ...e.recommendation,
+ id: closedLoopCycleEntityId("REC", "SOFTWARE", nextCycle, nextSequence),
+ status: "待决策",
+ reason: "",
+ operator: "",
+ decidedAt: "",
+ snapshotId: nextSnapshotId,
+ demandId: nextDemandId,
+ };
+ Object.assign(e, {
+ task: null,
+ project: null,
+ evidences: [],
+ evaluation: null,
+ report: null,
+ usageFeedback: [],
+ review: null,
+ receipt: {
+ ...e.receipt,
+ id: nextReceiptId,
+ status: "待技术确认",
+ receivedRecords: 0,
+ receivedSha256: "",
+ verification: null,
+ note: "",
+ technicalConfirmedBy: "",
+ technicalConfirmedAt: "",
+ businessUsable: false,
+ },
+ });
+ closedLoopState.shared.receiptId = nextReceiptId;
+ addClosedLoopAudit(
+ "NEXT_CYCLE_STARTED",
+ nextDemandId,
+ `由 ${previous.reviewId} 启动 ${nextCycle};上一周期 ${previous.demandId} / ${previous.snapshotId} / ${previous.reportId || "无报告"} / ${previous.feedbackId || "无反馈"} 已归档留痕。`,
+ );
+}
+
function applyClosedLoopAction(action, values) {
const e = closedLoopState.entities;
const at = nowText();
@@ -1225,7 +1979,7 @@ function applyClosedLoopAction(action, values) {
addClosedLoopAudit("RECOMMENDATION_DECIDED", e.recommendation.id, `${values.decision} · ${values.reason}`);
if (values.decision === "采纳" && !e.task) {
e.task = {
- id: "TSK-SOFTWARE-20260823-01",
+ id: nextClosedLoopEntityId("task", "TSK", "SOFTWARE"),
status: "进行中",
title: e.recommendation.title,
profession: "软件技术",
@@ -1241,7 +1995,7 @@ function applyClosedLoopAction(action, values) {
}
} else if (action === "project") {
e.project = {
- id: "PRJ-HKVS-20260823-01",
+ id: nextClosedLoopEntityId("project", "PRJ", "HKVS"),
status: "进行中",
title: values.title,
profession: values.profession,
@@ -1254,6 +2008,7 @@ function applyClosedLoopAction(action, values) {
};
addClosedLoopAudit("PROJECT_CREATED", e.project.id, `绑定 ${e.demand.id} / ${e.recommendation.id} / ${e.task.id}`);
} else if (action === "evidence") {
+ const reopeningEvaluation = ["需整改", "已退回"].includes(e.task.status);
const evidence = {
id: `EVD-HKVS-${String(++closedLoopState.sequences.evidence).padStart(2, "0")}`,
name: values.name,
@@ -1271,10 +2026,14 @@ function applyClosedLoopAction(action, values) {
e.task.progressLogs.push({ at, operator: currentRoleUser(), progress: e.task.progress, detail: values.description });
e.project.progress = e.task.progress;
e.project.status = e.task.progress >= 100 ? "待企业评价" : "进行中";
+ if (reopeningEvaluation) {
+ e.evaluation = null;
+ addClosedLoopAudit("ENTERPRISE_REEVALUATION_REQUIRED", e.task.id, "整改证据已补充,原企业评价不再作为当前验收依据。");
+ }
addClosedLoopAudit("EVIDENCE_SUBMITTED", evidence.id, `${values.name} · ${values.fileRef} · 进度${e.task.progress}%`);
} else if (action === "evaluation") {
e.evaluation = {
- id: "EVL-HKVS-20260823-01",
+ id: nextClosedLoopEntityId("evaluation", "EVL", "HKVS"),
result: values.result,
comment: values.comment,
enterpriseId: "ENT-HKVS",
@@ -1292,12 +2051,19 @@ function applyClosedLoopAction(action, values) {
e.project.status = passed ? "已验收" : "已退回";
e.task.progress = passed ? 100 : Math.min(e.task.progress, 90);
e.project.progress = e.task.progress;
- e.task.acceptanceHistory.push({ result: values.result, comment: values.comment, operator: currentRoleUser(), at });
+ e.task.acceptanceHistory.push({
+ id: nextClosedLoopEntityId("acceptance", "ACC", "HZVTC"),
+ result: values.result,
+ comment: values.comment,
+ operator: currentRoleUser(),
+ at,
+ });
addClosedLoopAudit("SCHOOL_ACCEPTANCE_RECORDED", e.task.id, `${values.result} · ${values.comment}`);
} else if (action === "reportCreate") {
e.report = {
- id: "RPT-HZVTC-20260823-01",
+ id: nextClosedLoopEntityId("report", "RPT", "HZVTC"),
title: values.title,
+ version: CLOSED_LOOP_SHARED.packageVersion,
status: "草稿",
demandId: e.demand.id,
snapshotId: e.snapshot.id,
@@ -1307,7 +2073,7 @@ function applyClosedLoopAction(action, values) {
submittedBy: "",
approvedBy: "",
updatedAt: at,
- history: [{ status: "草稿", comment: "关联闭环实体生成。", operator: currentRoleUser(), at }],
+ history: [{ status: "草稿", comment: "关联流程实体生成。", operator: currentRoleUser(), at }],
};
addClosedLoopAudit("REPORT_DRAFT_CREATED", e.report.id, `关联 ${e.snapshot.id} / ${e.demand.id} / ${e.task.id} / ${e.evidences.length}项证据`);
} else if (action === "reportSubmit") {
@@ -1342,8 +2108,8 @@ function applyClosedLoopAction(action, values) {
e.receipt.status = values.usable === "可用" ? "业务确认可用" : "业务要求补充";
addClosedLoopAudit("DATA_USAGE_FEEDBACK_ADDED", feedback.id, `${values.department} · ${values.usable} · 下一周期:${values.nextCycle}`);
} else if (action === "review") {
- e.review = {
- id: "RVW-SOFTWARE-2027C1-01",
+ const review = {
+ id: nextClosedLoopEntityId("review", "RVW", "SOFTWARE"),
before: Number(values.before),
after: Number(values.after),
conclusion: values.conclusion,
@@ -1352,8 +2118,11 @@ function applyClosedLoopAction(action, values) {
operator: currentRoleUser(),
at,
};
- addClosedLoopAudit("NEXT_CYCLE_REVIEW_RECORDED", e.review.id, `${values.before}→${values.after} · ${values.conclusion} · ${values.decision}`);
+ e.review = review;
+ addClosedLoopAudit("NEXT_CYCLE_REVIEW_RECORDED", review.id, `${values.before}→${values.after} · ${values.conclusion} · ${values.decision}`);
+ if (values.decision === "进入下一周期") startClosedLoopNextCycle(review, at);
}
+ closedLoopUiState.selectedNodeKey = null;
saveAndRenderClosedLoop(`${closedLoopActionCatalog[action].title}已完成`);
}
@@ -3370,7 +4139,7 @@ function updateDataOpsDrawer() {
button.classList.toggle("active", button.dataset.dataOpsTab === dataOpsState.tab);
});
if (dataOpsState.tab === "usage") {
- footer.innerHTML = ``;
+ footer.innerHTML = ``;
return;
}
const primaryLabel =
@@ -3706,7 +4475,7 @@ function renderReportCenter() {
const descriptions = {
校级管理者: "统一管理校级、学院、专业、产业与数据质量报告,支持在线阅读和版本追溯。",
学院负责人: "查看本院、所属专业及相关数据质量报告,将分析结论转化为专业建设行动。",
- 专业负责人: "查看本专业报告、提交闭环报告审核并追踪审批历史;校级批准与发布由校级管理者执行。",
+ 专业负责人: "查看本专业报告、提交业务流程报告审核并追踪审批历史;校级批准与发布由校级管理者执行。",
就业老师: "查看校级与数据质量报告,登记就业侧使用反馈;不能审批校级业务报告。",
校级技术管理员: "查看数据质量、版本、口径和授权报告,确保进入分析的每个数据版本均可追溯。",
};
@@ -4210,7 +4979,7 @@ const productTourSteps = [
{ view: "dashboard", selector: ".ai-panel", title: "发现关键变化", description: "AI将岗位、技能和专业变化归纳为可解释的决策简报,并保留证据定位。" },
{ view: "profession", selector: ".profile-banner", title: "定位专业差距", description: "从岗位、技能、薪资和课程映射判断专业优势与结构性缺口。" },
{ view: "collaboration", selector: ".collab-stats", title: "连接企业协同", description: "把企业需求转化为课程共建、真实项目、企业导师和实习就业合作。" },
- { view: "actions", selector: ".action-overview", title: "形成改进闭环", description: "每项建议都有负责人、截止日期、进展证据和下一周期效果复核。" },
+ { view: "actions", selector: ".action-overview", title: "跟踪改进成效", description: "每项建议都有负责人、截止日期、进展证据和下一周期效果复核。" },
{ view: "data", selector: ".source-summary-grid", title: "管理数据资产", description: "厂商、学校和企业三类数据分权维护,支持初次导入、增量更新、版本和审计。" },
{ view: "reports", selector: ".featured-report-grid", title: "完成正式交付", description: "在线报告、材料包和展示大屏使用同一数据快照,适用于决策、汇报与报送。" },
];
@@ -5142,8 +5911,42 @@ function validateClosedLoopSubmission(action, values) {
function setupClosedLoop() {
document.getElementById("view-loop").addEventListener("click", (event) => {
- const button = event.target.closest("[data-loop-action]");
- if (button) openClosedLoopAction(button.dataset.loopAction);
+ const actionButton = event.target.closest("[data-loop-action]");
+ if (actionButton) {
+ openClosedLoopAction(actionButton.dataset.loopAction);
+ return;
+ }
+ const nodeButton = event.target.closest("[data-loop-node]");
+ if (nodeButton) {
+ selectClosedLoopNode(nodeButton.dataset.loopNode, { focus: true });
+ document.getElementById("loopNodeDetail").scrollIntoView({ behavior: "smooth", block: "nearest" });
+ return;
+ }
+ const branchButton = event.target.closest("[data-loop-branch-target]");
+ if (branchButton) {
+ const selected = selectClosedLoopNode(branchButton.dataset.loopBranchTarget, { focus: true });
+ selected?.selectedButton?.scrollIntoView({ behavior: "smooth", block: "nearest" });
+ return;
+ }
+ const relationButton = event.target.closest("[data-loop-relation]");
+ if (relationButton) {
+ showToast(`${relationButton.dataset.loopRelation} 已在当前节点关联实体中定位。`, "实体关系");
+ return;
+ }
+ if (event.target.closest("#loopFocusCurrent")) {
+ const nodes = closedLoopNodeDefinitions();
+ const currentNode = closedLoopCurrentNode(nodes);
+ if (!currentNode) {
+ showToast("所有流程节点均已完成,当前没有待办节点。", "流程已关闭");
+ return;
+ }
+ const selected = selectClosedLoopNode(currentNode.key, { focus: true });
+ selected?.selectedButton?.scrollIntoView({
+ behavior: "smooth",
+ block: "nearest",
+ inline: "center",
+ });
+ }
});
document.getElementById("reportApprovalToolbar").addEventListener("click", (event) => {
const button = event.target.closest("[data-loop-action]");
@@ -5178,9 +5981,10 @@ function setupClosedLoop() {
}
});
document.getElementById("resetClosedLoop").addEventListener("click", () => {
- if (!window.confirm("确认重置校园闭环样板?本地持久化的闭环实体、自定义业务记录和审计事件将恢复为种子状态。")) return;
+ if (!window.confirm("确认重置校园业务流程样板?本地持久化的流程实体、自定义业务记录和审计事件将恢复为种子状态。")) return;
localStorage.removeItem(CLOSED_LOOP_STORAGE_KEY);
closedLoopState = closedLoopSeed();
+ closedLoopUiState.selectedNodeKey = null;
closedLoopState.auditEvents.unshift({
id: `AUD-${Date.now()}`,
action: "SAMPLE_RESET",
@@ -5190,12 +5994,24 @@ function setupClosedLoop() {
at: nowText(),
detail: `按 schemaVersion ${CLOSED_LOOP_SCHEMA_VERSION} 重置样板。`,
});
- saveAndRenderClosedLoop("校园闭环样板已重置");
+ saveAndRenderClosedLoop("校园业务流程样板已重置");
renderProfessionProfile(document.getElementById("professionSelect").value);
});
}
function init() {
+ if (closedLoopState.pendingNextCycleMigration) {
+ const legacyReview = closedLoopState.pendingNextCycleMigration;
+ delete closedLoopState.pendingNextCycleMigration;
+ startClosedLoopNextCycle(legacyReview, legacyReview.at || nowText());
+ addClosedLoopAudit(
+ "LEGACY_NEXT_CYCLE_MIGRATED",
+ closedLoopState.entities.demand.id,
+ `旧版复核 ${legacyReview.id || "RVW-旧记录"} 已转换为真实的新周期实体。`,
+ "流程状态迁移器",
+ "系统",
+ );
+ }
syncClosedLoopCollections();
saveClosedLoopState();
renderSparkline("sparkMatch", [65, 68, 67, 72, 74, 78.6]);
diff --git a/demo/enterprise.css b/demo/enterprise.css
index b4e8104..0469303 100644
--- a/demo/enterprise.css
+++ b/demo/enterprise.css
@@ -2255,6 +2255,10 @@ body textarea {
font-size: 13px !important;
}
+.app-shell .eyebrow {
+ font-size: 13px !important;
+}
+
.app-shell .main-content span,
.app-shell .main-content em {
font-size: 13px !important;
@@ -2722,113 +2726,558 @@ body textarea {
min-width: 0;
}
-.loop-chain {
- display: grid;
- grid-template-columns: repeat(3, minmax(0, 1fr));
- gap: 10px;
+.loop-status-panel {
+ margin-bottom: 16px;
+ overflow: hidden;
+ background:
+ radial-gradient(circle at 96% 2%, rgba(198, 230, 111, 0.08), transparent 24%),
+ white;
}
-.loop-stage {
- display: grid;
- min-width: 0;
- grid-template-columns: 36px minmax(0, 1fr);
- gap: 10px;
- padding: 13px;
- background: #f8faf8;
- border: 1px solid var(--line-soft);
+.loop-flow-toolbar {
+ display: flex;
+ align-items: stretch;
+ justify-content: space-between;
+ gap: 12px;
+ margin: 18px 0 12px;
+ padding: 10px;
+ background: #f3f7f4;
+ border: 1px solid #e1e9e3;
border-radius: 11px;
}
-.loop-stage > i {
+.loop-flow-summary {
+ display: grid;
+ min-width: 0;
+ flex: 1;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 7px;
+}
+
+.loop-flow-summary > span {
+ min-width: 0;
+ padding: 9px 11px;
+ background: white;
+ border: 1px solid #e5ebe6;
+ border-radius: 8px;
+}
+
+.loop-flow-summary small,
+.loop-flow-summary strong {
+ display: block;
+}
+
+.loop-flow-summary small {
+ color: var(--muted);
+}
+
+.loop-flow-summary strong {
+ margin-top: 4px;
+ overflow: hidden;
+ color: var(--ink);
+ font-size: 14px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.loop-flow-toolbar > .button {
+ align-self: stretch;
+ flex: 0 0 auto;
+}
+
+.loop-flow-scroll {
+ overflow-x: auto;
+ overflow-y: hidden;
+ background:
+ linear-gradient(rgba(35, 107, 79, 0.045) 1px, transparent 1px),
+ linear-gradient(90deg, rgba(35, 107, 79, 0.045) 1px, transparent 1px),
+ #f7f9f7;
+ background-size: 25px 25px;
+ border: 1px solid #dce5df;
+ border-radius: 13px;
+}
+
+.loop-flow-canvas {
+ --loop-canvas-width: 1000px;
+ --loop-canvas-height: 600px;
+ --loop-node-width: 170px;
+ --loop-node-height: 130px;
+ position: relative;
+ width: var(--loop-canvas-width);
+ min-width: var(--loop-canvas-width);
+ height: var(--loop-canvas-height);
+ margin: 0 auto;
+}
+
+.loop-flow-edges,
+.loop-flow-nodes {
+ position: absolute;
+ inset: 0;
+}
+
+.loop-flow-edges {
+ width: 100%;
+ height: 100%;
+ overflow: visible;
+ pointer-events: none;
+}
+
+.loop-flow-edge path {
+ fill: none;
+ stroke: #bdc9c2;
+ stroke-width: 2;
+ vector-effect: non-scaling-stroke;
+}
+
+.loop-flow-edge text {
+ fill: #77867e;
+ stroke: #f7f9f7;
+ stroke-width: 5px;
+ paint-order: stroke;
+ font-size: 13px;
+ font-weight: 700;
+}
+
+.loop-flow-edge.complete path {
+ stroke: var(--green);
+}
+
+.loop-flow-edge.current path {
+ stroke: #d69a31;
+ stroke-dasharray: 7 5;
+}
+
+.loop-flow-edge.risk path {
+ stroke: #c76656;
+ stroke-dasharray: 6 4;
+}
+
+.loop-flow-edge.branch path {
+ stroke: #96a69d;
+ stroke-dasharray: 5 5;
+}
+
+#loop-arrow-complete path {
+ fill: var(--green);
+}
+
+#loop-arrow-current path {
+ fill: #d69a31;
+}
+
+#loop-arrow-risk path {
+ fill: #c76656;
+}
+
+#loop-arrow-locked path,
+#loop-arrow-branch path {
+ fill: #96a69d;
+}
+
+.loop-flow-node {
+ position: absolute;
+ left: var(--loop-x);
+ top: var(--loop-y);
+ z-index: 2;
+ display: grid;
+ width: var(--loop-node-width);
+ height: var(--loop-node-height);
+ grid-template-columns: 34px minmax(0, 1fr);
+ gap: 8px;
+ padding: 11px;
+ color: var(--ink);
+ background: white;
+ border: 1px solid #dfe7e1;
+ border-radius: 11px;
+ box-shadow: 0 7px 20px rgba(24, 61, 46, 0.07);
+ text-align: left;
+ transition:
+ transform 0.18s ease,
+ border-color 0.18s ease,
+ box-shadow 0.18s ease;
+}
+
+.loop-flow-node:hover,
+.loop-flow-node:focus-visible {
+ z-index: 3;
+ transform: translateY(-2px);
+ box-shadow: 0 12px 28px rgba(24, 61, 46, 0.14);
+}
+
+.loop-flow-node.selected {
+ z-index: 3;
+ outline: 3px solid rgba(35, 135, 101, 0.16);
+ outline-offset: 2px;
+}
+
+.loop-flow-node.complete {
+ background: #f2f8f5;
+ border-color: #a9d0ba;
+}
+
+.loop-flow-node.complete.has-risk {
+ background: #fff8f5;
+ border-color: #d9a293;
+ box-shadow: inset 0 3px #c76656;
+}
+
+.loop-flow-node.current {
+ background: #fffaf0;
+ border-color: #ddac55;
+ box-shadow: 0 9px 25px rgba(181, 124, 27, 0.16);
+}
+
+.loop-flow-node.risk {
+ background: #fff5f2;
+ border-color: #db9b8c;
+ box-shadow: 0 9px 25px rgba(173, 78, 69, 0.13);
+}
+
+.loop-flow-node.locked {
+ color: #66736c;
+ background: #f6f8f6;
+ border-style: dashed;
+ box-shadow: none;
+}
+
+.loop-flow-node.locked.has-risk {
+ background: #fff8f5;
+ border-color: #d9a293;
+}
+
+.loop-flow-node-step {
display: grid;
width: 34px;
height: 34px;
place-items: center;
- color: var(--green);
- background: #e7f3ed;
+ color: #6f7e76;
+ background: #e9eeeb;
border-radius: 9px;
- font-size: 13px;
- font-style: normal;
font-weight: 800;
}
-.loop-stage.review > i {
- color: #916217;
- background: #fff6df;
-}
-
-.loop-stage.risk > i {
- color: #ad4e45;
- background: #fff0ed;
-}
-
-.loop-stage.done > i {
+.loop-flow-node.complete .loop-flow-node-step {
color: white;
background: var(--green);
}
-.loop-stage span,
-.loop-stage small,
-.loop-stage strong,
-.loop-stage em {
+.loop-flow-node.complete.has-risk .loop-flow-node-step {
+ background: #b95b4d;
+}
+
+.loop-flow-node.current .loop-flow-node-step {
+ color: #825813;
+ background: #ffecc5;
+}
+
+.loop-flow-node.risk .loop-flow-node-step {
+ color: #a34e42;
+ background: #ffe3dc;
+}
+
+.loop-flow-node-copy {
+ min-width: 0;
+}
+
+.loop-flow-node-copy small,
+.loop-flow-node-copy strong,
+.loop-flow-node-copy em {
display: block;
min-width: 0;
}
-.loop-stage small {
+.loop-flow-node-copy small {
color: var(--muted);
- font-size: 13px;
}
-.loop-stage strong {
- margin: 3px 0;
- overflow-wrap: anywhere;
- font-size: 13px;
+.loop-flow-node-copy strong {
+ margin: 4px 0;
+ font-size: 14px;
+ line-height: 1.35;
}
-.loop-stage em {
- color: var(--green-2);
- font-size: 13px;
+.loop-flow-node-copy em {
+ overflow: hidden;
+ color: var(--green);
font-style: normal;
+ text-overflow: ellipsis;
+ white-space: nowrap;
}
-.loop-entity-grid {
+.loop-flow-node.current .loop-flow-node-copy em {
+ color: #9b6817;
+}
+
+.loop-flow-node.complete.has-risk .loop-flow-node-copy em,
+.loop-flow-node.risk .loop-flow-node-copy em {
+ color: #a34e42;
+}
+
+.loop-flow-node.locked .loop-flow-node-copy em {
+ color: #88948e;
+}
+
+.loop-flow-node-owner {
+ grid-column: 1 / -1;
+ margin-top: 4px;
+ padding-top: 7px;
+ overflow: hidden;
+ color: var(--muted);
+ border-top: 1px solid rgba(94, 113, 103, 0.14);
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.loop-flow-legend {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ flex-wrap: wrap;
+ margin-top: 11px;
+ color: var(--muted);
+}
+
+.loop-mobile-branches {
+ display: none;
+}
+
+.loop-flow-legend > span {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.loop-flow-legend i {
+ width: 11px;
+ height: 11px;
+ background: #f4f6f4;
+ border: 2px dashed #aebbb4;
+ border-radius: 3px;
+}
+
+.loop-flow-legend i.complete {
+ background: var(--green);
+ border-color: var(--green);
+ border-style: solid;
+}
+
+.loop-flow-legend i.current {
+ background: #fff2d5;
+ border-color: #d59a31;
+ border-style: solid;
+}
+
+.loop-flow-legend i.risk {
+ background: #ffe5df;
+ border-color: #c76656;
+ border-style: solid;
+}
+
+.loop-node-detail {
+ display: grid;
+ gap: 12px;
+}
+
+.loop-node-hero {
+ display: grid;
+ grid-template-columns: 54px minmax(0, 1fr) auto;
+ align-items: start;
+ gap: 13px;
+ padding: 16px;
+ color: #dcebe4;
+ background:
+ radial-gradient(circle at 92% 10%, rgba(198, 230, 111, 0.14), transparent 26%),
+ linear-gradient(135deg, #173f33, #102e26);
+ border-radius: 12px;
+}
+
+.loop-node-hero.current {
+ background:
+ radial-gradient(circle at 92% 10%, rgba(255, 228, 167, 0.18), transparent 26%),
+ linear-gradient(135deg, #4b3819, #2e281d);
+}
+
+.loop-node-hero.risk {
+ background:
+ radial-gradient(circle at 92% 10%, rgba(255, 213, 202, 0.16), transparent 26%),
+ linear-gradient(135deg, #543128, #33231f);
+}
+
+.loop-node-hero.locked {
+ color: #d7dfda;
+ background: linear-gradient(135deg, #45534c, #303a35);
+}
+
+.loop-node-hero-step {
+ display: grid;
+ width: 52px;
+ height: 52px;
+ place-items: center;
+ color: #173f33;
+ background: var(--lime);
+ border-radius: 12px;
+ font-size: 17px;
+ font-weight: 900;
+}
+
+.loop-node-hero small,
+.loop-node-hero strong {
+ display: block;
+}
+
+.loop-node-hero small {
+ color: #9db3aa;
+}
+
+.loop-node-hero strong {
+ margin-top: 5px;
+ color: white;
+ font-size: 15px;
+}
+
+.loop-node-hero p {
+ margin: 7px 0 0;
+ color: #a8bdb4;
+ line-height: 1.65;
+}
+
+.loop-node-hero > em {
+ padding: 6px 8px;
+ color: var(--lime);
+ background: rgba(255, 255, 255, 0.08);
+ border-radius: 7px;
+ font-style: normal;
+ font-weight: 800;
+ white-space: nowrap;
+}
+
+.loop-node-io {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) 28px minmax(0, 1fr);
+ align-items: stretch;
+ gap: 8px;
+}
+
+.loop-node-io article {
+ padding: 12px;
+ background: #f7faf8;
+ border: 1px solid var(--line-soft);
+ border-radius: 9px;
+}
+
+.loop-node-io span,
+.loop-node-io strong {
+ display: block;
+}
+
+.loop-node-io span {
+ color: var(--muted);
+}
+
+.loop-node-io strong {
+ margin-top: 6px;
+ line-height: 1.5;
+}
+
+.loop-node-io > i {
+ display: grid;
+ place-items: center;
+ color: #91a49a;
+}
+
+.loop-node-io svg {
+ width: 18px;
+ height: 18px;
+}
+
+.loop-node-facts {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
- gap: 10px;
+ gap: 8px;
}
-.loop-entity-grid article {
+.loop-node-facts > span {
min-width: 0;
- padding: 15px;
- background: #f9fbf9;
+ padding: 11px;
+ background: white;
border: 1px solid var(--line-soft);
- border-radius: 11px;
+ border-radius: 9px;
}
-.loop-entity-grid span,
-.loop-entity-grid strong,
-.loop-entity-grid small {
+.loop-node-facts small,
+.loop-node-facts strong {
display: block;
overflow-wrap: anywhere;
}
-.loop-entity-grid span {
- color: var(--green);
- font-size: 13px;
- font-weight: 800;
-}
-
-.loop-entity-grid strong {
- margin: 8px 0;
- font-size: 14px;
-}
-
-.loop-entity-grid small {
- margin-top: 5px;
+.loop-node-facts small {
color: var(--muted);
- font-size: 13px;
- line-height: 1.55;
+}
+
+.loop-node-facts strong {
+ margin-top: 5px;
+ line-height: 1.5;
+}
+
+.loop-node-relations {
+ padding: 12px;
+ background: #f6f8f6;
+ border: 1px solid var(--line-soft);
+ border-radius: 9px;
+}
+
+.loop-node-relations > span {
+ display: block;
+ margin-bottom: 7px;
+ color: var(--muted);
+ font-weight: 700;
+}
+
+.loop-node-relations > div {
+ display: flex;
+ gap: 6px;
+ flex-wrap: wrap;
+}
+
+.loop-node-relations button {
+ max-width: 100%;
+ padding: 6px 8px;
+ overflow: hidden;
+ color: var(--green);
+ background: white;
+ border: 1px solid #cfdfd6;
+ border-radius: 6px;
+ font-family: "SFMono-Regular", Consolas, monospace;
+ text-overflow: ellipsis;
+}
+
+.loop-node-action {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 12px;
+ background: #edf7f1;
+ border: 1px solid #d2e6da;
+ border-radius: 9px;
+}
+
+.loop-node-action.locked,
+.loop-node-action.readonly {
+ background: #f6f8f6;
+ border-color: var(--line-soft);
+}
+
+.loop-node-action span,
+.loop-node-action strong,
+.loop-node-action small {
+ display: block;
+}
+
+.loop-node-action small {
+ margin-top: 4px;
+ color: var(--muted);
+ line-height: 1.5;
}
.loop-role-boundary {
@@ -2857,12 +3306,13 @@ body textarea {
}
.loop-action-list > button {
- display: flex;
+ display: grid;
width: 100%;
+ min-width: 0;
+ grid-template-columns: 34px minmax(0, 1fr) auto;
align-items: center;
- justify-content: space-between;
- gap: 12px;
- padding: 13px;
+ gap: 9px;
+ padding: 11px;
color: var(--ink);
background: white;
border: 1px solid var(--line-soft);
@@ -2870,33 +3320,60 @@ body textarea {
text-align: left;
}
+.loop-action-list > button:not(:disabled):hover {
+ background: #f3f9f6;
+ border-color: #a9cfbb;
+}
+
.loop-action-list button:disabled {
opacity: 0.58;
cursor: not-allowed;
}
+.loop-action-list > button > i {
+ display: grid;
+ width: 32px;
+ height: 32px;
+ place-items: center;
+ color: var(--green);
+ background: var(--green-soft);
+ border-radius: 8px;
+ font-style: normal;
+ font-weight: 800;
+}
+
.loop-action-list span,
.loop-action-list strong,
-.loop-action-list small {
+.loop-action-list small,
+.loop-action-list em {
display: block;
+ min-width: 0;
}
.loop-action-list strong {
+ margin-top: 3px;
font-size: 14px;
}
.loop-action-list small {
- margin-top: 4px;
color: var(--muted);
- font-size: 13px;
- line-height: 1.45;
}
.loop-action-list em {
+ margin-top: 4px;
+ overflow: hidden;
+ color: var(--muted);
+ font-size: 13px;
+ font-style: normal;
+ line-height: 1.45;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.loop-action-list b {
flex: 0 0 auto;
color: var(--green);
font-size: 13px;
- font-style: normal;
font-weight: 800;
}
@@ -3085,11 +3562,13 @@ body textarea {
.loop-action-panel {
position: static;
}
+
+ .loop-flow-summary {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
}
@media (max-width: 680px) {
- .loop-chain,
- .loop-entity-grid,
.loop-modal-fields {
grid-template-columns: 1fr;
}
@@ -3103,6 +3582,170 @@ body textarea {
flex-direction: column;
}
+ .loop-flow-toolbar {
+ flex-direction: column;
+ }
+
+ .loop-flow-summary {
+ grid-template-columns: 1fr 1fr;
+ }
+
+ .loop-flow-toolbar > .button {
+ width: 100%;
+ }
+
+ .loop-flow-scroll {
+ overflow: visible;
+ }
+
+ .loop-flow-canvas {
+ width: 100%;
+ min-width: 0;
+ height: auto;
+ padding: 12px;
+ }
+
+ .loop-flow-edges {
+ display: none;
+ }
+
+ .loop-flow-nodes {
+ position: static;
+ display: grid;
+ gap: 13px;
+ }
+
+ .loop-flow-node {
+ position: relative;
+ left: auto;
+ top: auto;
+ width: 100%;
+ height: auto;
+ min-height: 0;
+ }
+
+ .loop-flow-node:not(:last-child)::after {
+ position: absolute;
+ z-index: 1;
+ bottom: -14px;
+ left: 28px;
+ width: 2px;
+ height: 14px;
+ background: #9fb1a7;
+ content: "";
+ }
+
+ .loop-flow-legend {
+ gap: 8px 12px;
+ }
+
+ .loop-mobile-branches {
+ display: grid;
+ gap: 7px;
+ margin-top: 10px;
+ }
+
+ .loop-mobile-branches button {
+ display: grid;
+ position: relative;
+ min-width: 0;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 4px 8px;
+ padding: 10px;
+ color: var(--ink);
+ background: #f7f9f7;
+ border: 1px dashed #b9c6bf;
+ border-radius: 8px;
+ text-align: left;
+ }
+
+ .loop-mobile-branches button.active.risk {
+ background: #fff4f1;
+ border-color: #cf7969;
+ border-style: solid;
+ }
+
+ .loop-mobile-branches button.active.cycle {
+ background: #fff9ec;
+ border-color: #d9a44a;
+ border-style: solid;
+ }
+
+ .loop-mobile-branches span {
+ display: flex;
+ min-width: 0;
+ align-items: center;
+ gap: 6px;
+ }
+
+ .loop-mobile-branches i,
+ .loop-mobile-branches em {
+ font-style: normal;
+ }
+
+ .loop-mobile-branches i {
+ color: #8c9992;
+ }
+
+ .loop-mobile-branches small {
+ grid-column: 1 / -1;
+ color: var(--muted);
+ line-height: 1.45;
+ }
+
+ .loop-mobile-branches em {
+ align-self: start;
+ color: var(--muted);
+ font-weight: 800;
+ white-space: nowrap;
+ }
+
+ .loop-mobile-branches button.active.risk em {
+ color: #ad4e45;
+ }
+
+ .loop-mobile-branches button.active.cycle em {
+ color: #956313;
+ }
+
+ .loop-node-hero {
+ grid-template-columns: 44px minmax(0, 1fr);
+ padding: 13px;
+ }
+
+ .loop-node-hero-step {
+ width: 42px;
+ height: 42px;
+ border-radius: 10px;
+ }
+
+ .loop-node-hero > em {
+ grid-column: 2;
+ justify-self: start;
+ }
+
+ .loop-node-io {
+ grid-template-columns: 1fr;
+ }
+
+ .loop-node-io > i {
+ height: 14px;
+ transform: rotate(90deg);
+ }
+
+ .loop-node-facts {
+ grid-template-columns: 1fr;
+ }
+
+ .loop-node-action {
+ align-items: stretch;
+ flex-direction: column;
+ }
+
+ .loop-node-action .button {
+ width: 100%;
+ }
+
.report-approval-toolbar {
align-items: stretch;
flex-direction: column;
diff --git a/demo/index.html b/demo/index.html
index a487269..35e63cc 100644
--- a/demo/index.html
+++ b/demo/index.html
@@ -51,7 +51,7 @@
- 闭环工作台
+ 业务流程