Files
lofyerandfactory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> b9fbd4c6af refactor: simplify product copy
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2026-08-25 11:37:59 +08:00

6048 lines
305 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const professionData = {
software: {
name: "软件技术",
college: "信息工程学院",
graduates: 186,
score: 84.2,
level: "高匹配",
demand: "2,864",
salary: "1.08",
coverage: "76%",
companyCount: 12,
skills: [
{ name: "Java / Spring", category: "后端开发", market: 92, course: 86 },
{ name: "Python", category: "应用开发", market: 84, course: 71 },
{ name: "Docker / K8s", category: "云原生", market: 81, course: 32 },
{ name: "数据库", category: "数据技术", market: 78, course: 74 },
{ name: "前端工程化", category: "Web开发", market: 72, course: 65 },
{ name: "AI辅助开发", category: "智能编程", market: 69, course: 28 },
],
jobs: [
{ name: "Java开发工程师", count: 826, growth: 18, width: 100 },
{ name: "前端开发工程师", count: 594, growth: 12, width: 72 },
{ name: "测试开发工程师", count: 463, growth: 9, width: 56 },
{ name: "云平台运维", count: 382, growth: 31, width: 46 },
{ name: "数据开发工程师", count: 311, growth: 24, width: 38 },
],
recommendations: [
{
priority: "高优先级",
title: "强化“云原生与容器化”实训模块",
description:
"建议在综合实训中增加 Docker、Kubernetes 和 CI/CD 流水线,并把企业部署任务用于课堂练习。",
evidence: "2,864条岗位样本中,相关技能出现率由42%升至78%,当前课程覆盖仅32%",
},
{
priority: "中优先级",
title: "将AI辅助开发纳入软件工程实践",
description:
"增加代码生成、测试辅助与安全审查内容,同时明确人工复核和代码责任边界。",
evidence: "69%的重点企业已在JD中明确要求具备AI编程工具使用经验",
},
],
companies: [
{ short: "海康", name: "海康威视", focus: "边缘计算 · 软件开发", match: 94 },
{ short: "网易", name: "网易杭州", focus: "云服务 · 测试开发", match: 91 },
{ short: "数梦", name: "数梦工场", focus: "数据平台 · 后端开发", match: 89 },
],
},
mechatronics: {
name: "机电一体化技术",
college: "智能制造学院",
graduates: 154,
score: 81.7,
level: "高匹配",
demand: "2,146",
salary: "1.05",
coverage: "72%",
companyCount: 9,
skills: [
{ name: "PLC编程", category: "自动控制", market: 94, course: 88 },
{ name: "工业机器人", category: "智能装备", market: 89, course: 71 },
{ name: "机器视觉", category: "工业视觉", market: 82, course: 38 },
{ name: "设备数字孪生", category: "数字工厂", market: 73, course: 26 },
{ name: "电气制图", category: "工程设计", market: 68, course: 82 },
{ name: "精益生产", category: "生产管理", market: 64, course: 52 },
],
jobs: [
{ name: "自动化工程师", count: 742, growth: 21, width: 100 },
{ name: "机器人工程师", count: 526, growth: 27, width: 71 },
{ name: "设备调试工程师", count: 398, growth: 13, width: 54 },
{ name: "机器视觉工程师", count: 287, growth: 38, width: 39 },
{ name: "电气工程师", count: 193, growth: 8, width: 26 },
],
recommendations: [
{
priority: "高优先级",
title: "新增工业视觉综合实训项目",
description:
"与装备企业共同设计缺陷检测任务,练习相机标定、图像处理、产线集成和质量评价。",
evidence: "机器视觉岗位近90天增长38%,课程覆盖度仅38%,技能缺口44个百分点",
},
{
priority: "中优先级",
title: "引入设备数字孪生教学案例",
description:
"把现有 PLC 和生产线实训对应到数字孪生模型,并安排设备数据采集与仿真任务。",
evidence: "73%的龙头制造企业已把数字孪生列入智能工厂相关岗位要求",
},
],
companies: [
{ short: "吉利", name: "吉利汽车", focus: "智能产线 · 自动化", match: 95 },
{ short: "大华", name: "大华股份", focus: "机器视觉 · 智能装备", match: 92 },
{ short: "西奥", name: "西奥电梯", focus: "设备调试 · 电气", match: 88 },
],
},
ecommerce: {
name: "电子商务",
college: "商贸旅游学院",
graduates: 212,
score: 74.8,
level: "待关注",
demand: "1,932",
salary: "0.96",
coverage: "68%",
companyCount: 15,
skills: [
{ name: "内容运营", category: "新媒体", market: 91, course: 76 },
{ name: "数据分析", category: "经营分析", market: 86, course: 54 },
{ name: "跨境平台运营", category: "跨境电商", market: 83, course: 43 },
{ name: "直播投放", category: "营销增长", market: 78, course: 69 },
{ name: "AI内容生产", category: "智能营销", market: 75, course: 31 },
{ name: "供应链管理", category: "履约运营", market: 68, course: 59 },
],
jobs: [
{ name: "电商运营", count: 618, growth: 13, width: 100 },
{ name: "内容运营", count: 496, growth: 22, width: 80 },
{ name: "跨境电商运营", count: 374, growth: 34, width: 61 },
{ name: "直播投放", count: 283, growth: 18, width: 46 },
{ name: "用户增长", count: 161, growth: 27, width: 26 },
],
recommendations: [
{
priority: "高优先级",
title: "重构跨境电商平台运营课程",
description:
"增加企业店铺运营、选品定价、海外投放和合规履约任务,并用企业运营数据评价学习过程。",
evidence: "跨境运营岗位增长34%,当前课程技能覆盖43%,企业反馈实操能力不足",
},
{
priority: "中优先级",
title: "增加AI营销内容生产工作流",
description:
"将AI文案、图片与短视频辅助生产纳入内容运营课程,保留品牌规范与人工审核。",
evidence: "重点企业JD中AI内容工具要求出现率75%,现有覆盖仅31%",
},
],
companies: [
{ short: "遥望", name: "遥望科技", focus: "直播电商 · 内容运营", match: 93 },
{ short: "连连", name: "连连数字", focus: "跨境支付 · 商家运营", match: 90 },
{ short: "有赞", name: "有赞科技", focus: "SaaS电商 · 用户运营", match: 87 },
],
},
};
const professions = [
{ key: "software", icon: "软", name: "软件技术", college: "信息工程学院", score: 84.2, demand: 2864, gaps: 3, trend: 4.8 },
{ key: "mechatronics", icon: "机", name: "机电一体化技术", college: "智能制造学院", score: 81.7, demand: 2146, gaps: 4, trend: 3.6 },
{ key: "ecommerce", icon: "商", name: "电子商务", college: "商贸旅游学院", score: 74.8, demand: 1932, gaps: 6, trend: -1.2 },
{ key: "iot", icon: "物", name: "物联网应用技术", college: "信息工程学院", score: 79.5, demand: 1648, gaps: 4, trend: 6.1 },
{ key: "mold", icon: "模", name: "模具设计与制造", college: "智能制造学院", score: 68.4, demand: 738, gaps: 7, trend: -2.4 },
];
const industries = [
{ name: "工业视觉与智能检测", growth: 38, width: 100 },
{ name: "跨境电商运营", growth: 34, width: 89 },
{ name: "云平台与DevOps", growth: 31, width: 82 },
{ name: "工业机器人应用", growth: 27, width: 71 },
{ name: "AI营销内容生产", growth: 24, width: 63 },
];
const trendSets = {
term: {
score: 78.6,
labels: ["9月", "10月", "11月", "12月", "1月", "3月"],
school: [69, 71.5, 72.2, 75.8, 76.4, 78.6],
benchmark: [68, 69, 69.8, 70.7, 71.2, 72.1],
},
year: {
score: 78.6,
labels: ["一季度", "二季度", "三季度", "四季度", "本期"],
school: [67.5, 71.2, 73.8, 76.1, 78.6],
benchmark: [68.4, 69.2, 70.1, 71.3, 72.1],
},
three: {
score: 78.6,
labels: ["2023", "2024", "2025", "2026"],
school: [64.8, 69.7, 74.4, 78.6],
benchmark: [66.2, 68.4, 70.3, 72.1],
},
};
const projects = [
{ type: "course", tone: "green", title: "云原生应用开发课程共建", meta: "信息工程学院 × 数梦工场", progress: 72, partners: ["李", "王", "企"] },
{ type: "lab", tone: "amber", title: "工业视觉缺陷检测实训", meta: "智能制造学院 × 大华股份", progress: 48, partners: ["陈", "赵", "企"] },
{ type: "practice", tone: "blue", title: "跨境电商店铺运营", meta: "商贸旅游学院 × 连连数字", progress: 86, partners: ["徐", "周", "企"] },
{ type: "course", tone: "green", title: "数字文旅内容生产工作坊", meta: "艺术设计学院 × 宋城集团", progress: 35, partners: ["许", "孙", "企"] },
];
const enterpriseDemands = [
{
logo: "吉",
company: "吉利汽车",
industry: "智能制造",
title: "新能源汽车产线数字化人才共育",
description: "希望联合开发设备数据采集和产线运维实训,首期可接收30名学生。",
due: "3天后截止",
},
{
logo: "遥",
company: "遥望科技",
industry: "数字商贸",
title: "直播电商运营实战项目",
description: "提供品牌运营任务,需要课程团队共同设计过程评价标准。",
due: "本周五截止",
},
{
logo: "海",
company: "海康威视",
industry: "数字技术",
title: "边缘计算软件测试人才需求",
description: "邀请专业负责人确认技能清单,并讨论联合实训基地合作。",
due: "7天后截止",
},
];
const partners = [
{ logo: "吉利", name: "吉利汽车", industry: "汽车制造 · 龙头企业", reason: "近一年发布高匹配岗位186个,与机电专业技能重合度高。", tags: ["智能产线", "企业导师"], match: 95 },
{ logo: "数梦", name: "数梦工场", industry: "数据智能 · 专精特新", reason: "已确认参与云原生课程共建,并提供项目案例。", tags: ["云平台", "课程共建"], match: 94 },
{ logo: "遥望", name: "遥望科技", industry: "直播电商 · 上市企业", reason: "内容运营和直播投放需求稳定,愿意提供实训项目。", tags: ["直播电商", "项目实训"], match: 93 },
{ logo: "大华", name: "大华股份", industry: "智能物联 · 龙头企业", reason: "工业视觉岗位增长显著,与学校智能制造方向互补。", tags: ["机器视觉", "实训基地"], match: 92 },
];
const tasks = [
{ title: "强化云原生与容器化实训内容", source: "技能缺口分析 · AI建议已审核", profession: "软件技术", owner: "李雯", due: "2026-04-30", progress: 72, status: "progress", statusText: "进行中" },
{ title: "建设工业视觉缺陷检测实训项目", source: "企业需求 · 大华股份确认", profession: "机电一体化", owner: "陈昊", due: "2026-05-15", progress: 48, status: "progress", statusText: "进行中" },
{ title: "完成跨境电商课程标准修订", source: "专业供需分析 · 教研室提交", profession: "电子商务", owner: "徐莉", due: "2026-03-28", progress: 90, status: "review", statusText: "待确认" },
{ title: "引入3名企业导师参与毕业设计", source: "校企合作计划", profession: "物联网应用", owner: "王健", due: "2026-03-20", progress: 100, status: "done", statusText: "已完成" },
{ title: "开展模具专业企业需求复核调研", source: "低匹配专业预警", profession: "模具设计", owner: "赵明", due: "2026-03-12", progress: 35, status: "risk", statusText: "存在风险" },
{ title: "更新AI辅助开发课程使用规范", source: "教学质量委员会决议", profession: "软件技术", owner: "周宁", due: "2026-04-18", progress: 100, status: "done", statusText: "已完成" },
];
const sources = [
{
id: "regional-jobs",
name: "区域招聘岗位基础包",
type: "互联网岗位 · 标准数据包",
category: "vendor",
categoryLabel: "基础数据包",
commercial: "付费订阅",
provider: "平台厂商导入",
rights: "项目期内授权使用",
coverage: "杭州及长三角",
frequency: "每月更新",
status: "服务有效",
records: "41,236",
quality: "93.4%",
analysisCompleteness: 98.8,
updated: "2026-03-18",
dataAsOf: "2026-03-17",
nextUpdate: "2026-04-01",
updateMode: "全量首包 / 月度增量",
version: "V2026.03",
schema: "JOB-PACK 3.2",
format: "Parquet / CSV UTF-8",
primaryKey: "package_id + source_record_id",
requiredFields: "岗位名称、企业标识、地区代码、发布时间、薪资单位、来源地址",
threshold: "综合质量 ≥ 90;主键唯一率 100%",
changeSummary: "新增 12,430 条,修改 846 条,失效 312 条;新增 AI 应用岗位分类",
steward: "平台数据产品部",
stewardContact: "林岚 · 数据产品经理",
technicalOwner: "平台数据运维组",
technicalContact: "平台数据包运维值班",
action: "检查更新",
},
{
id: "salary-company",
name: "薪资与企业画像基础包",
type: "互联网数据 · 增值数据包",
category: "vendor",
categoryLabel: "基础数据包",
commercial: "付费订阅",
provider: "平台厂商导入",
rights: "项目期内授权使用",
coverage: "区域重点产业",
frequency: "每月更新",
status: "服务有效",
records: "36,284",
quality: "91.8%",
analysisCompleteness: 100,
updated: "2026-03-18",
dataAsOf: "2026-03-17",
nextUpdate: "2026-04-01",
updateMode: "全量首包 / 月度增量",
version: "V2026.Q1.3",
schema: "SALARY-COMPANY 2.4",
format: "Parquet / CSV UTF-8",
primaryKey: "package_id + enterprise_id + position_code",
requiredFields: "企业标识、岗位代码、薪资区间、统计周期、企业行业与规模",
threshold: "综合质量 ≥ 90;薪资合法率 ≥ 98%",
changeSummary: "更新 2,186 条薪资样本,补充 326 家企业规模与融资阶段标签",
steward: "平台数据产品部",
stewardContact: "林岚 · 数据产品经理",
technicalOwner: "平台数据运维组",
technicalContact: "平台数据包运维值班",
action: "检查更新",
},
{
id: "internship",
name: "实习管理平台",
type: "校内系统 · API",
category: "school",
categoryLabel: "校级数据",
commercial: "校方自有",
provider: "学校实习系统",
rights: "校方数据资产",
coverage: "全校实习学生",
frequency: "每日同步",
status: "正常",
records: "32,418",
quality: "96.2%",
analysisCompleteness: 100,
updated: "今天 08:12",
dataAsOf: "2026-03-21",
nextUpdate: "明天 08:00",
updateMode: "初次全量 / 每日增量",
version: "SCHOOL-20260322-01",
schema: "INTERNSHIP 2.1",
format: "API JSON",
primaryKey: "tenant_id + internship_id",
requiredFields: "学生标识、专业代码、企业标识、岗位、开始/结束日期、状态、更新时间",
threshold: "必填完整率 ≥ 95%;跨表关联率 ≥ 98%",
changeSummary: "新增 186 条,修改 42 条;更新 31 家实习企业岗位信息",
steward: "就业处",
stewardContact: "赵晴 · 实习管理专员",
technicalOwner: "学校信息中心",
technicalContact: "王博 · 数据接口岗",
action: "立即同步",
},
{
id: "employment",
name: "就业管理系统",
type: "校内系统 · 数据库视图",
category: "school",
categoryLabel: "校级数据",
commercial: "校方自有",
provider: "学校就业系统",
rights: "校方数据资产",
coverage: "近三届毕业生",
frequency: "每日同步",
status: "字段缺失",
records: "8,926",
quality: "87.6%",
analysisCompleteness: 91.2,
updated: "今天 07:50",
dataAsOf: "2026-03-21",
nextUpdate: "异常处理后重试",
updateMode: "初次全量 / 每日增量",
version: "SCHOOL-20260322-02",
schema: "EMPLOYMENT 2.1",
format: "数据库视图",
primaryKey: "tenant_id + student_id + cohort",
requiredFields: "学生标识、专业代码、届次、就业状态、岗位、企业、业务更新时间",
threshold: "专业代码合法率 100%;必填完整率 ≥ 95%",
changeSummary: "新增 126 条,修改 43 条;86 条薪资记录进入隔离区,未覆盖当前生产版本",
steward: "就业处",
stewardContact: "赵晴 · 就业数据专员",
technicalOwner: "学校信息中心",
technicalContact: "王博 · 数据接口岗",
action: "处理异常",
warning: true,
},
{
id: "curriculum",
name: "课程标准库",
type: "校内文件 · 结构化导入",
category: "school",
categoryLabel: "校级数据",
commercial: "校方自有",
provider: "学校教务处",
rights: "校方数据资产",
coverage: "32个专业",
frequency: "学期更新",
status: "正常",
records: "1,428",
quality: "95.1%",
analysisCompleteness: 100,
updated: "昨天 17:20",
dataAsOf: "2026-03-20",
nextUpdate: "2026-08-25",
updateMode: "初次全量 / 学期快照",
version: "CURRICULUM-2026S1-03",
schema: "CURRICULUM 1.8",
format: "XLSX / CSV UTF-8",
primaryKey: "tenant_id + course_code + curriculum_version",
requiredFields: "课程代码、专业代码、课程名称、学时、课程标准、技能标签、版本",
threshold: "课程代码唯一率 100%;技能映射覆盖率 ≥ 80%",
changeSummary: "导入 2026 春季课程快照;新增 38 门课程,修订 126 份课程标准",
steward: "教务处",
stewardContact: "陈璐 · 教务数据专员",
technicalOwner: "学校信息中心",
technicalContact: "王博 · 数据接口岗",
action: "立即同步",
},
{
id: "enterprise-demand",
name: "企业确认需求库",
type: "企业提交 · 问卷/工作台",
category: "enterprise",
categoryLabel: "企业数据",
commercial: "合作授权",
provider: "126家合作企业",
rights: "只限约定合作用途",
coverage: "岗位与技能需求",
frequency: "按需更新",
status: "授权有效",
records: "8,350",
quality: "97.3%",
analysisCompleteness: 98.5,
updated: "昨天 20:08",
dataAsOf: "2026-03-21",
nextUpdate: "企业提交后实时入队",
updateMode: "初次导入 / 事件增量",
version: "ENTERPRISE-20260321-06",
schema: "ENTERPRISE-DEMAND 1.6",
format: "工作台表单 / API JSON / XLSX",
primaryKey: "tenant_id + enterprise_credit_code + demand_id",
requiredFields: "企业标识、岗位、需求人数、技能、有效期、提交人、确认时间、授权编号",
threshold: "企业身份与授权关联率 100%;必填完整率 ≥ 95%",
changeSummary: "新增 18 家企业确认、54 项岗位需求;12 项过期需求自动停用",
steward: "产教融合中心",
stewardContact: "王健 · 企业合作专员",
technicalOwner: "学校信息中心",
technicalContact: "王博 · 数据接口岗",
action: "查看回执",
},
];
const roleProfiles = {
校级管理者: {
user: "周明远",
avatar: "周",
organization: "杭州科技职业技术学院",
organizationLabel: "当前组织",
emblem: "杭",
root: "产教融合中心",
context: "2025—2026 学年 · 第 2 学期",
workspaceLabel: "学校工作空间",
menus: ["dashboard", "loop", "profession", "collaboration", "actions", "data", "report", "screen"],
defaultView: "dashboard",
},
校级技术管理员: {
user: "王博",
avatar: "王",
organization: "杭州科技职业技术学院 · 信息中心",
organizationLabel: "当前数据域",
emblem: "数",
root: "学校数据运维中心",
context: "校级技术管理员 · 6 个数据源 · 2 个待处理任务",
workspaceLabel: "数据运维",
menus: ["loop", "data", "actions", "report"],
defaultView: "data",
dataAdministrator: true,
},
学院负责人: {
user: "李雯",
avatar: "李",
organization: "信息工程学院",
organizationLabel: "当前学院",
emblem: "信",
root: "学院工作台",
context: "信息工程学院 · 6 个专业 · 2026届",
workspaceLabel: "学院工作空间",
menus: ["loop", "profession", "collaboration", "actions", "report"],
defaultView: "profession",
},
专业负责人: {
user: "李雯",
avatar: "李",
organization: "信息工程学院 · 软件技术专业",
organizationLabel: "当前专业",
emblem: "软",
root: "专业建设工作台",
context: "软件技术专业 · 2026 春季流程",
workspaceLabel: "专业工作空间",
menus: ["loop", "profession", "collaboration", "actions", "report"],
defaultView: "loop",
},
就业老师: {
user: "赵晴",
avatar: "赵",
organization: "杭州科技职业技术学院 · 就业处",
organizationLabel: "当前业务域",
emblem: "就",
root: "就业服务工作台",
context: "就业处 · 2026届 · 数据使用反馈",
workspaceLabel: "就业工作空间",
menus: ["loop", "data", "actions", "report"],
defaultView: "loop",
},
企业导师: {
user: "陈凯",
avatar: "陈",
organization: "海康威视 · 智能物联事业群",
organizationLabel: "当前企业",
emblem: "企",
root: "校企共育空间",
context: "企业导师 · 2026 春季合作项目",
workspaceLabel: "企业工作空间",
menus: ["loop", "collaboration", "actions"],
defaultView: "collaboration",
},
厂商超级管理员: {
user: "陈致远",
avatar: "陈",
organization: "产教融智平台运营中心",
organizationLabel: "平台运营方",
emblem: "融",
root: "平台运营中心",
context: "生产环境 · 华东区域 · 12 家学校租户",
workspaceLabel: "平台运营",
menus: [],
defaultView: "platform",
platform: true,
},
};
const CLOSED_LOOP_SCHEMA_VERSION = 2;
const CLOSED_LOOP_LIMITS = {
auditEvents: 200,
evidences: 100,
usageFeedback: 50,
customCollection: 50,
entityHistory: 100,
};
const CLOSED_LOOP_STORAGE_KEY = "eduIndustry.campusClosedLoop.TENANT-HZVTC.v1";
const CLOSED_LOOP_SHARED = {
tenantId: "TENANT-HZVTC",
packageId: "EDU-PKG-20260823-01",
packageVersion: "V2026.08.23",
packageRecords: 2846320,
packageSha256: "4f8d91a47e2b6d82b2aaad136d344d61c83f9cd472185d2543725c91870e9e32",
receiptId: "RCT-HZVTC-20260823-01",
};
function closedLoopSeed() {
return {
schemaVersion: CLOSED_LOOP_SCHEMA_VERSION,
seededAt: "2026-08-23T09:00:00+08:00",
shared: { ...CLOSED_LOOP_SHARED },
entities: {
demand: {
id: "DEM-HKVS-20260823-01",
status: "已提交",
company: "海康威视",
enterpriseId: "ENT-HKVS",
title: "边缘计算软件测试人才共育需求",
position: "边缘计算测试工程师",
skills: "Linux、Python、自动化测试、边缘设备调试",
headcount: 24,
authorizationNo: "AUTH-HKVS-20260823-01",
validUntil: "2027-08-22",
submitter: "陈凯 · 海康威视",
submittedAt: "2026-08-23 09:10",
cycle: "2026C2",
description: "面向软件技术专业共建边缘计算测试实训,企业导师参与中期评审与成果评价。",
},
snapshot: {
id: "SNP-HZVTC-20260824-01",
status: "待接收",
receivedAt: "",
acceptedAt: "",
operator: "",
packageId: CLOSED_LOOP_SHARED.packageId,
packageVersion: CLOSED_LOOP_SHARED.packageVersion,
sha256: CLOSED_LOOP_SHARED.packageSha256,
expectedRecords: 128642,
checkedRecords: 0,
},
recommendation: {
id: "REC-SOFTWARE-20260823-01",
status: "待决策",
title: "强化“云原生与容器化”实训模块",
reason: "",
operator: "",
decidedAt: "",
snapshotId: "SNP-HZVTC-20260824-01",
demandId: "DEM-HKVS-20260823-01",
},
task: null,
project: null,
evidences: [],
evaluation: null,
report: null,
usageFeedback: [],
review: null,
receipt: {
id: CLOSED_LOOP_SHARED.receiptId,
status: "待技术确认",
tenantId: CLOSED_LOOP_SHARED.tenantId,
packageId: CLOSED_LOOP_SHARED.packageId,
packageVersion: CLOSED_LOOP_SHARED.packageVersion,
expectedRecords: CLOSED_LOOP_SHARED.packageRecords,
expectedSha256: CLOSED_LOOP_SHARED.packageSha256,
receivedRecords: 0,
receivedSha256: "",
verification: null,
note: "",
technicalConfirmedBy: "",
technicalConfirmedAt: "",
businessUsable: false,
},
},
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: [
{
id: "AUD-20260823-0001",
action: "SEED_CREATED",
objectId: "DEM-HKVS-20260823-01",
operator: "样板数据初始化",
role: "系统",
at: "2026-08-23 09:00",
detail: "创建可重置的校园业务流程样板。",
},
],
};
}
function isPlainObject(value) {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
function trimClosedLoopCollections(state) {
state.auditEvents = state.auditEvents.slice(0, CLOSED_LOOP_LIMITS.auditEvents);
state.entities.evidences = state.entities.evidences.slice(-CLOSED_LOOP_LIMITS.evidences);
state.entities.usageFeedback = state.entities.usageFeedback.slice(-CLOSED_LOOP_LIMITS.usageFeedback);
Object.keys(state.custom).forEach((key) => {
state.custom[key] = state.custom[key].slice(0, CLOSED_LOOP_LIMITS.customCollection);
});
if (state.entities.task) {
state.entities.task.progressLogs = state.entities.task.progressLogs.slice(-CLOSED_LOOP_LIMITS.entityHistory);
state.entities.task.acceptanceHistory = state.entities.task.acceptanceHistory.slice(-CLOSED_LOOP_LIMITS.entityHistory);
}
if (state.entities.report) state.entities.report.history = state.entities.report.history.slice(-CLOSED_LOOP_LIMITS.entityHistory);
return state;
}
function resetInvalidClosedLoopState(seed, rawVersion, reason) {
seed.auditEvents.unshift({
id: `AUD-RESET-${Date.now()}`,
action: "SECURITY_SCHEMA_RESET",
objectId: CLOSED_LOOP_SHARED.receiptId,
operator: "流程状态迁移器",
role: "系统",
at: nowText(),
detail: `Schema V${rawVersion ?? "未知"} 状态已按安全策略失效并重建 V${CLOSED_LOOP_SCHEMA_VERSION} 种子:${reason}`,
});
return seed;
}
function migrateClosedLoopState(raw) {
const seed = closedLoopSeed();
if (!raw || typeof raw !== "object") return seed;
if (raw.schemaVersion !== CLOSED_LOOP_SCHEMA_VERSION) {
return resetInvalidClosedLoopState(seed, raw.schemaVersion, "旧版本未执行完整 SHA 强校验");
}
const rawEntities = isPlainObject(raw.entities) ? raw.entities : {};
const rawCustom = isPlainObject(raw.custom) ? raw.custom : {};
const allowedCustom = Object.fromEntries(Object.entries(rawCustom).filter(([key]) => Object.hasOwn(seed.custom, key)));
const mergeEntity = (key) => {
const seedEntity = seed.entities[key];
const rawEntity = rawEntities[key];
if (isPlainObject(seedEntity) && isPlainObject(rawEntity)) {
return { ...seedEntity, ...rawEntity };
}
return rawEntity ?? seedEntity;
};
const state = {
...seed,
...raw,
schemaVersion: CLOSED_LOOP_SCHEMA_VERSION,
shared: { ...seed.shared, ...(isPlainObject(raw.shared) ? raw.shared : {}) },
entities: {
...seed.entities,
...rawEntities,
demand: mergeEntity("demand"),
snapshot: mergeEntity("snapshot"),
recommendation: mergeEntity("recommendation"),
receipt: mergeEntity("receipt"),
},
sequences: { ...seed.sequences, ...(isPlainObject(raw.sequences) ? raw.sequences : {}) },
custom: { ...seed.custom, ...allowedCustom },
auditEvents: Array.isArray(raw.auditEvents) ? raw.auditEvents : seed.auditEvents,
};
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 || !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);
}
function loadClosedLoopState() {
try {
return migrateClosedLoopState(JSON.parse(localStorage.getItem(CLOSED_LOOP_STORAGE_KEY)));
} catch {
return closedLoopSeed();
}
}
let closedLoopState = loadClosedLoopState();
function saveClosedLoopState() {
closedLoopState.schemaVersion = CLOSED_LOOP_SCHEMA_VERSION;
trimClosedLoopCollections(closedLoopState);
try {
localStorage.setItem(CLOSED_LOOP_STORAGE_KEY, JSON.stringify(closedLoopState));
return true;
} catch {
showToast("浏览器阻止了本地存储,本次流程状态仅在当前页面有效。", "状态未持久化");
return false;
}
}
function currentRoleName() {
return document.getElementById("roleSelect")?.value || "校级管理者";
}
function currentRoleUser() {
const role = currentRoleName();
return `${roleProfiles[role]?.user || "样板用户"} · ${role}`;
}
function nowText() {
return new Intl.DateTimeFormat("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false,
})
.format(new Date())
.replaceAll("/", "-");
}
function safeText(value) {
const element = document.createElement("span");
element.textContent = String(value ?? "");
return element.innerHTML;
}
function closedLoopToken(prefix) {
return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2, 7).toUpperCase()}`;
}
function addClosedLoopAudit(action, objectId, detail, operator = currentRoleUser(), role = currentRoleName()) {
closedLoopState.auditEvents.unshift({
id: closedLoopToken("AUD"),
action,
objectId,
operator,
role,
at: nowText(),
detail,
});
}
function loopStatusClass(status = "") {
if (["已发布", "已完成", "已验收", "已采纳", "通过", "已接收", "已确认", "关闭"].includes(status)) return "done";
if (["已退回", "需整改", "驳回", "恶化"].includes(status)) return "risk";
if (["暂缓", "待审核", "待验收", "待评价", "进入下一周期"].includes(status)) return "review";
return "progress";
}
function syncClosedLoopCollections() {
const e = closedLoopState.entities;
const removeSynced = (list) => {
for (let index = list.length - 1; index >= 0; index -= 1) {
if (list[index]._closedLoop || list[index]._customPersisted) list.splice(index, 1);
}
};
removeSynced(enterpriseDemands);
removeSynced(tasks);
removeSynced(projects);
enterpriseDemands.unshift(
{
_closedLoop: true,
id: e.demand.id,
logo: "海",
company: e.demand.company,
industry: "智能物联",
title: e.demand.title,
description: `${e.demand.position} · ${e.demand.headcount}人 · ${e.demand.skills}`,
due: `${e.demand.validUntil} 有效`,
status: e.demand.status,
},
...closedLoopState.custom.demands.map((item) => ({ ...item, _customPersisted: true })),
);
if (e.task) {
tasks.unshift({
_closedLoop: true,
id: e.task.id,
title: e.task.title,
source: `流程建议 ${e.task.recommendationId} · 需求 ${e.task.demandId}`,
profession: e.task.profession,
owner: e.task.owner,
due: e.task.due,
progress: e.task.progress,
status: e.task.status === "已完成" ? "done" : e.task.status === "待验收" ? "review" : e.task.status === "已退回" ? "risk" : "progress",
statusText: e.task.status,
});
}
tasks.unshift(...closedLoopState.custom.tasks.map((item) => ({ ...item, _customPersisted: true })));
if (e.project) {
projects.unshift({
_closedLoop: true,
id: e.project.id,
type: "practice",
tone: "green",
title: e.project.title,
meta: `${e.project.profession} × ${e.demand.company} · ${e.project.id}`,
progress: e.project.progress,
partners: ["李", "陈", "企"],
status: e.project.status,
demandId: e.project.demandId,
recommendationId: e.project.recommendationId,
taskId: e.project.taskId,
});
}
projects.unshift(...closedLoopState.custom.projects.map((item) => ({ ...item, _customPersisted: true })));
for (let index = reports.length - 1; index >= 0; index -= 1) {
if (reports[index]._closedLoop || reports[index]._customPersisted) reports.splice(index, 1);
}
if (e.report) {
const base = reports.find((item) => item.type === "school") || reports[0];
reports.unshift({
...base,
_closedLoop: true,
id: e.report.id,
title: e.report.title,
version: e.report.version || CLOSED_LOOP_SHARED.packageVersion,
status: e.report.status,
statusClass: loopStatusClass(e.report.status),
updated: e.report.updatedAt || "刚刚",
featured: true,
roles: ["校级管理者", "校级技术管理员", "学院负责人", "专业负责人", "就业老师"],
});
}
reports.unshift(...closedLoopState.custom.reports.map((item) => ({ ...item, _customPersisted: true })));
}
function saveAndRenderClosedLoop(message = "") {
saveClosedLoopState();
syncClosedLoopCollections();
renderDataReportLineage();
renderClosedLoop();
renderTasks();
renderProjects();
renderDemands();
renderReportCenter();
if (message) showToast(message, "流程状态已保存");
}
const closedLoopActionCatalog = {
demand: {
title: "提交企业需求",
queueLabel: "提交 / 更新本企业需求",
nodeKey: "demand",
description: "仅企业导师可维护本企业需求;岗位、技能、人数、授权号、有效期和提交人均为必填。",
roles: ["企业导师"],
fields: [
["position", "岗位名称", "text", "边缘计算测试工程师"],
["skills", "技能要求", "textarea", "Linux、Python、自动化测试、边缘设备调试"],
["headcount", "计划人数", "number", "24"],
["authorizationNo", "授权号", "text", "AUTH-HKVS-20260823-01"],
["validUntil", "授权有效期", "date", "2027-08-22"],
["submitter", "提交人", "text", "陈凯 · 海康威视"],
],
},
receive: {
title: "确认接收并生成快照",
queueLabel: "确认包与需求接收",
nodeKey: "snapshot",
description: "校级技术管理员强校验租户、包号、版本、完整 SHA-256 和记录数;全部一致后确认接收,并生成独立分析快照。",
roles: ["校级技术管理员"],
fields: [
["tenantId", "租户标识", "text", CLOSED_LOOP_SHARED.tenantId],
["packageId", "数据包编号", "text", CLOSED_LOOP_SHARED.packageId],
["packageVersion", "数据包版本", "text", CLOSED_LOOP_SHARED.packageVersion],
["checkedRecords", "交付包记录数", "number", String(CLOSED_LOOP_SHARED.packageRecords)],
["sha256", "完整 SHA-256 摘要", "text", CLOSED_LOOP_SHARED.packageSha256],
["note", "技术接收说明", "textarea", "包版本、记录数与SHA校验一致,授权关联率100%,允许进入分析快照。"],
],
},
decision: {
title: "专业建议决策",
queueLabel: "决策专业建议",
nodeKey: "recommendation",
description: "采纳、暂缓或驳回都必须填写理由;采纳后自动创建关联任务。",
roles: ["专业负责人"],
fields: [
["decision", "决策", "select", "采纳|暂缓|驳回"],
["reason", "决策理由", "textarea", "企业需求与岗位样本相互印证,现有课程覆盖不足,纳入本期实训调整。"],
["owner", "任务负责人", "text", "李雯"],
["due", "任务截止日期", "date", "2026-12-20"],
],
},
project: {
title: "创建关联项目",
queueLabel: "创建关联项目",
nodeKey: "project",
description: "项目自动绑定 demandId、recommendationId 与 taskId。",
roles: ["专业负责人"],
fields: [
["title", "项目名称", "text", "边缘计算测试项目进课堂"],
["profession", "牵头专业", "text", "软件技术"],
["owner", "项目负责人", "text", "李雯"],
],
},
evidence: {
title: "提交阶段进展与证据",
queueLabel: "提交进展与证据",
nodeKey: "evidence",
description: "保存证据名称、类型、说明、提交人、时间和文件名/链接;提交后自动更新任务和项目进度。",
roles: ["专业负责人", "学院负责人"],
fields: [
["name", "证据名称", "text", "边缘计算测试实训方案"],
["type", "证据类型", "select", "课程方案|阶段成果|评审纪要|学生作品"],
["description", "证据说明", "textarea", "已完成企业任务拆解、课程映射和中期评价量规。"],
["submitter", "提交人", "text", "李雯 · 软件技术专业"],
["fileRef", "文件名或链接", "text", "边缘计算测试实训方案-v1.0.pdf"],
["progress", "本次完成进度", "number", "80"],
],
},
evaluation: {
title: "提交企业评价",
queueLabel: "提交企业评价",
nodeKey: "evaluation",
description: "企业导师只能评价本企业关联对象;通过或需整改均须填写意见。",
roles: ["企业导师"],
fields: [
["result", "评价结论", "select", "通过|需整改"],
["comment", "评价意见", "textarea", "任务覆盖企业设备测试流程,验收量规清晰,同意进入校级验收。"],
],
},
acceptance: {
title: "校级任务验收",
queueLabel: "校级验收 / 退回",
nodeKey: "acceptance",
description: "校级管理者在企业评价后验收或退回任务,并记录意见。",
roles: ["校级管理者"],
fields: [
["result", "验收结论", "select", "验收通过|退回整改"],
["comment", "验收意见", "textarea", "企业评价、课程方案和过程证据齐全,同意完成。"],
],
},
reportCreate: {
title: "生成业务报告草稿",
queueLabel: "生成业务报告草稿",
nodeKey: "report",
description: "报告关联需求、快照、任务和全部证据,生成后进入校内审批流程。",
roles: ["专业负责人", "学院负责人", "校级管理者"],
fields: [["title", "报告名称", "text", "边缘计算测试人才共育成效报告"]],
},
reportSubmit: {
title: "提交报告审核",
queueLabel: "提交报告审核",
nodeKey: "report",
description: "草稿提交后进入待审核状态。",
roles: ["专业负责人", "学院负责人", "校级管理者"],
fields: [["comment", "提交说明", "textarea", "流程实体与证据已核对,请校级管理者审核。"]],
},
reportReview: {
title: "报告审批",
queueLabel: "批准 / 退回报告",
nodeKey: "report",
description: "只有校级管理者可批准或退回业务报告;审批人必须与本次提交人分离。",
roles: ["校级管理者"],
fields: [
["result", "审批结论", "select", "批准|退回"],
["approver", "独立审批人", "select", "刘嘉禾 · 校级管理者|周明远 · 校级管理者"],
["comment", "审批意见", "textarea", "实体关联、数据口径和验收记录完整,同意批准。"],
],
},
reportPublish: {
title: "正式发布报告",
queueLabel: "正式发布报告",
nodeKey: "report",
description: "只有已批准报告可正式发布;发布后下载和导出才标识为正式版。",
roles: ["校级管理者"],
fields: [["comment", "发布说明", "textarea", "作为本期校园业务流程正式报告发布。"]],
},
feedback: {
title: "记录就业侧使用反馈",
queueLabel: "登记就业使用反馈",
nodeKey: "feedback",
description: "就业老师确认数据对就业业务是否可用,并登记下一周期关注项。",
roles: ["就业老师"],
fields: [
["usable", "业务确认", "select", "可用|需补充"],
["department", "使用部门", "text", "就业处"],
["comment", "使用反馈", "textarea", "岗位技能标签可用于就业指导课程和重点企业岗位推荐。"],
["nextCycle", "下一周期关注", "textarea", "补充学生项目作品与就业岗位匹配率。"],
],
},
review: {
title: "下一周期复核",
queueLabel: "下一周期复核",
nodeKey: "review",
description: "保存前后指标与改善结论,并决定关闭或进入下一周期。",
roles: ["专业负责人"],
fields: [
["before", "前周期指标", "number", "32"],
["after", "本周期指标", "number", "68"],
["conclusion", "复核结论", "select", "改善|无变化|恶化"],
["decision", "周期决定", "select", "关闭|进入下一周期"],
["comment", "复核说明", "textarea", "云原生与边缘测试课程覆盖率明显提升,企业评价通过。"],
],
},
};
function closedLoopActionState(action) {
const e = closedLoopState.entities;
const checks = {
demand: [
e.snapshot.status === "待接收" && !e.task && !e.report,
e.snapshot.status !== "待接收" ? "技术接收完成后不能修改当前需求;如需变更,请创建新版本并重新接收。" : "已有下游任务或报告,当前需求版本不能直接修改。",
],
receive: [e.demand.status === "已提交" && e.snapshot.status !== "已验收", e.snapshot.status === "已验收" ? "技术接收与快照验收已完成。" : "需先由企业提交完整需求。"],
decision: [e.snapshot.status === "已验收" && e.recommendation.status !== "已采纳", e.recommendation.status === "已采纳" ? "建议已采纳并创建关联任务。" : "需先完成数据接收并生成已验收快照。"],
project: [Boolean(e.task && !e.project), e.project ? "关联项目已创建。" : "需先采纳建议并自动创建任务。"],
evidence: [
Boolean(e.task && e.project && ["进行中", "需整改", "已退回"].includes(e.task.status) && !e.report),
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.evaluation,
e.task?.status === "已完成"
? "任务已完成校级验收,无需重复评价。"
: ["需整改", "已退回"].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 ? "业务报告已生成。" : "需先通过校级任务验收。"],
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 === "已发布" && !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 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 `
<button
type="button"
class="loop-flow-node ${node.state} ${node.risk ? "has-risk" : ""} ${selected ? "selected" : ""}"
data-loop-node="${node.key}"
style="--loop-x:${node.x}px;--loop-y:${node.y}px"
aria-current="${node.state === "current" || node.state === "risk" ? "step" : "false"}"
aria-pressed="${selected}"
>
<span class="loop-flow-node-step">${String(node.index + 1).padStart(2, "0")}</span>
<span class="loop-flow-node-copy">
<small>${node.phase}</small>
<strong>${node.title}</strong>
<em>${safeText(node.status)}</em>
</span>
<span class="loop-flow-node-owner">${safeText(node.owner)}</span>
</button>`;
}
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 = `
<defs>
${["complete", "current", "risk", "locked", "branch"]
.map(
(state) =>
`<marker id="loop-arrow-${state}" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M 0 0 L 10 5 L 0 10 z" /></marker>`,
)
.join("")}
</defs>`;
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 `
<g class="loop-flow-edge ${state}" data-loop-edge="${from}-${to}">
<path d="${path}" marker-end="url(#loop-arrow-${state})"></path>
${label ? `<text x="${labelX}" y="${labelY}" text-anchor="middle">${label}</text>` : ""}
</g>`;
})
.join("")
);
}
function renderClosedLoopMobileBranches(nodes) {
return closedLoopBranchDefinitions(nodes)
.map(
(branch) => `
<button type="button" class="${branch.tone} ${branch.active ? "active" : ""}" data-loop-branch-target="${branch.to}">
<span><strong>${branch.fromLabel}</strong><i>→</i><strong>${branch.toLabel}</strong></span>
<small>${branch.description}</small>
<em>${branch.active ? "已触发" : "条件分支"}</em>
</button>`,
)
.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
? `
<div class="loop-node-action ${availability.enabled ? "available" : "locked"}">
<span><strong>${closedLoopActionCatalog[action].queueLabel}</strong><small>${availability.reason}</small></span>
<button class="button ${availability.enabled ? "primary" : "secondary"}" data-loop-action="${action}" ${availability.enabled ? "" : "disabled"}>
${availability.enabled ? "办理当前节点" : "前置未满足"}
</button>
</div>`
: `
<div class="loop-node-action readonly">
<span><strong>当前角色只读</strong><small>该节点由${safeText(node.owner)}负责;切换到对应角色后,可办理满足前置条件的动作。</small></span>
</div>`;
document.getElementById("loopNodeDetail").innerHTML = `
<div class="loop-node-hero ${detailState}">
<span class="loop-node-hero-step">${String(node.index + 1).padStart(2, "0")}</span>
<span><small>责任角色</small><strong>${safeText(node.owner)}</strong><p>${safeText(node.description)}</p></span>
<em>${node.risk ? "需要处理" : node.state === "complete" ? "节点已完成" : node.state === "current" ? "当前待办" : "等待前置节点"}</em>
</div>
<div class="loop-node-io">
<article><span>节点输入</span><strong>${safeText(node.input)}</strong></article>
<i><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m9 18 6-6-6-6" /></svg></i>
<article><span>节点输出</span><strong>${safeText(node.output)}</strong></article>
</div>
<div class="loop-node-facts">
${node.facts.map(([label, value]) => `<span><small>${label}</small><strong>${safeText(value)}</strong></span>`).join("")}
</div>
<div class="loop-node-relations">
<span>关联实体</span>
<div>${relations.map((relation) => `<button type="button" data-loop-relation="${safeText(relation)}">${safeText(relation)}</button>`).join("") || "<small>前置实体完成后自动建立关联</small>"}</div>
</div>
${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);
const node = nodes.find((item) => item.key === closedLoopActionCatalog[action].nodeKey);
return `
<button data-loop-action="${action}" data-loop-action-node="${node.key}" ${availability.enabled ? "" : "disabled"}>
<i>${String(node.index + 1).padStart(2, "0")}</i>
<span><small>${node.title}</small><strong>${closedLoopActionCatalog[action].queueLabel}</strong><em>${availability.reason}</em></span>
<b>${availability.enabled ? "办理" : "条件未满足"}</b>
</button>`;
}
function renderClosedLoop() {
const root = document.getElementById("loopChain");
if (!root) return;
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 = `
<strong>共享业务标识对照</strong>
<span>租户 <b>${closedLoopState.shared.tenantId}</b></span>
<span>厂商包 <b>${closedLoopState.shared.packageId}</b></span>
<span>版本 <b>${closedLoopState.shared.packageVersion}</b></span>
<span>学校回执 <b>${closedLoopState.shared.receiptId}</b></span>
<small>静态原型仅用相同标识核对业务链,不代表厂商端与校园端页面实时同步。</small>`;
document.getElementById("loopFlowSummary").innerHTML = `
<span><small>流程完成度</small><strong>${progress}%</strong></span>
<span><small>已完成节点</small><strong>${completedCount} / ${nodes.length}</strong></span>
<span><small>当前待办</small><strong>${safeText(currentNode?.title || "流程已关闭")}</strong></span>
<span><small>责任角色</small><strong>${safeText(currentNode?.owner || "无待办")}</strong></span>`;
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 roleActions = Object.entries(closedLoopActionCatalog)
.filter(([, config]) => config.roles.includes(role))
.map(([action]) => action);
const boundaryNote = {
校级管理者: "不代替技术接收或专业决策。",
校级技术管理员: "技术质量职责不包含业务验收或报告批准。",
学院负责人: "校级验收、审批和发布节点不可用。",
专业负责人: "校级验收、批准和发布仍由校级管理者办理。",
就业老师: "不能进入校级报告审批节点。",
企业导师: "仅限本企业授权对象,不能操作学校内部审批。",
厂商超级管理员: "校园流程只读;共享标识用于业务对照,不构成实时同步。",
};
const capabilityText = roleActions.length
? `可办理:${roleActions.map((action) => closedLoopActionCatalog[action].queueLabel).join("、")}。`
: "本流程无可办理动作。";
document.getElementById("loopRoleActionTitle").textContent = `${role}可办理节点`;
document.getElementById("loopRoleBoundary").innerHTML = `<strong>权限边界</strong><p>${capabilityText}${boundaryNote[role]}</p>`;
document.getElementById("loopActionList").innerHTML =
roleActions.map((action) => renderClosedLoopAction(action, nodes)).join("") ||
`<div class="inline-empty-state"><strong>当前为只读视角</strong><small>切换校园业务角色后,右侧会显示该角色可办理的流程节点。</small></div>`;
document.getElementById("loopAuditCount").textContent = `${closedLoopState.auditEvents.length} 条`;
document.getElementById("loopAuditList").innerHTML = closedLoopState.auditEvents
.slice(0, 18)
.map(
(event) =>
`<span><i></i><strong>${safeText(event.action)} · ${safeText(event.objectId)}</strong><small>${safeText(event.operator)} · ${safeText(event.at)}</small><em>${safeText(event.detail)}</em></span>`,
)
.join("");
}
function openClosedLoopAction(action) {
const config = closedLoopActionCatalog[action];
const availability = actionPrerequisite(action);
if (!config || !availability.enabled) {
showToast(availability.reason, "前置条件未满足");
return;
}
selectClosedLoopNode(closedLoopActionCatalog[action].nodeKey || closedLoopUiState.selectedNodeKey);
const e = closedLoopState.entities;
const receiveDefaults = {
tenantId: e.receipt.tenantId,
packageId: e.receipt.packageId,
packageVersion: e.receipt.packageVersion,
checkedRecords: e.receipt.receivedRecords || e.receipt.expectedRecords,
sha256: e.receipt.receivedSha256 || e.receipt.expectedSha256,
};
const existingByAction = {
demand: e.demand,
receive: e.receipt,
decision: e.recommendation,
project: e.project || {},
evidence: {},
evaluation: e.evaluation || {},
acceptance: {},
reportCreate: e.report || {},
reportSubmit: {},
reportReview: {},
reportPublish: {},
feedback: {},
review: e.review || {},
}[action];
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;
document.getElementById("closedLoopFields").innerHTML = config.fields
.map(([name, label, type, fallback]) => {
const existingValue = action === "receive" ? receiveDefaults[name] : existingByAction?.[name];
const value = existingValue ?? fallback;
if (type === "textarea") return `<label><span>${label}</span><textarea name="${name}" rows="3" required>${safeText(value)}</textarea></label>`;
if (type === "select") {
return `<label><span>${label}</span><select name="${name}" required>${fallback
.split("|")
.map((option) => `<option ${option === value ? "selected" : ""}>${option}</option>`)
.join("")}</select></label>`;
}
return `<label><span>${label}</span><input name="${name}" type="${type}" value="${safeText(value)}" required></label>`;
})
.join("");
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();
if (action === "demand") {
Object.assign(e.demand, values, { headcount: Number(values.headcount), status: "已提交", submittedAt: at });
addClosedLoopAudit("DEMAND_SUBMITTED", e.demand.id, `${values.position} · ${values.headcount}人 · ${values.authorizationNo}`);
} else if (action === "receive") {
Object.assign(e.receipt, {
status: "已技术确认",
receivedRecords: Number(values.checkedRecords),
receivedSha256: values.sha256,
note: values.note,
verification: {
tenantMatched: true,
packageMatched: true,
versionMatched: true,
recordCountMatched: true,
sha256Matched: true,
verifiedAt: at,
},
technicalConfirmedBy: currentRoleUser(),
technicalConfirmedAt: at,
});
Object.assign(e.snapshot, {
packageId: values.packageId,
packageVersion: values.packageVersion,
sha256: values.sha256,
sourcePackageRecords: Number(values.checkedRecords),
checkedRecords: e.snapshot.expectedRecords,
status: "已验收",
receivedAt: at,
acceptedAt: at,
operator: currentRoleUser(),
});
addClosedLoopAudit(
"PACKAGE_RECEIVED_SNAPSHOT_ACCEPTED",
e.snapshot.id,
`${values.tenantId} · ${values.packageId} · ${values.packageVersion} · 交付 ${Number(values.checkedRecords).toLocaleString("zh-CN")} 条 · 分析快照 ${e.snapshot.checkedRecords.toLocaleString("zh-CN")} 条 · SHA ${values.sha256}`,
);
} else if (action === "decision") {
Object.assign(e.recommendation, {
status: values.decision === "采纳" ? "已采纳" : values.decision,
reason: values.reason,
operator: currentRoleUser(),
decidedAt: at,
});
addClosedLoopAudit("RECOMMENDATION_DECIDED", e.recommendation.id, `${values.decision} · ${values.reason}`);
if (values.decision === "采纳" && !e.task) {
e.task = {
id: nextClosedLoopEntityId("task", "TSK", "SOFTWARE"),
status: "进行中",
title: e.recommendation.title,
profession: "软件技术",
owner: values.owner,
due: values.due,
progress: 10,
demandId: e.demand.id,
recommendationId: e.recommendation.id,
progressLogs: [{ at, operator: currentRoleUser(), progress: 10, detail: "建议采纳,任务自动创建。" }],
acceptanceHistory: [],
};
addClosedLoopAudit("TASK_AUTO_CREATED", e.task.id, `由建议 ${e.recommendation.id} 自动创建。`);
}
} else if (action === "project") {
e.project = {
id: nextClosedLoopEntityId("project", "PRJ", "HKVS"),
status: "进行中",
title: values.title,
profession: values.profession,
owner: values.owner,
progress: e.task?.progress || 10,
demandId: e.demand.id,
recommendationId: e.recommendation.id,
taskId: e.task.id,
createdAt: at,
};
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,
type: values.type,
description: values.description,
submitter: values.submitter,
submittedAt: at,
fileRef: values.fileRef,
taskId: e.task.id,
projectId: e.project.id,
};
e.evidences.push(evidence);
e.task.progress = Math.min(100, Number(values.progress));
e.task.status = e.task.progress >= 100 ? "待评价" : "进行中";
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: nextClosedLoopEntityId("evaluation", "EVL", "HKVS"),
result: values.result,
comment: values.comment,
enterpriseId: "ENT-HKVS",
taskId: e.task.id,
projectId: e.project.id,
operator: currentRoleUser(),
at,
};
e.task.status = values.result === "通过" ? "待验收" : "需整改";
e.project.status = values.result === "通过" ? "待校级验收" : "需整改";
addClosedLoopAudit("ENTERPRISE_EVALUATION_SUBMITTED", e.evaluation.id, `${values.result} · ${values.comment}`);
} else if (action === "acceptance") {
const passed = values.result === "验收通过";
e.task.status = passed ? "已完成" : "已退回";
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({
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: nextClosedLoopEntityId("report", "RPT", "HZVTC"),
title: values.title,
version: CLOSED_LOOP_SHARED.packageVersion,
status: "草稿",
demandId: e.demand.id,
snapshotId: e.snapshot.id,
taskId: e.task.id,
evidenceIds: e.evidences.map((item) => item.id),
createdBy: currentRoleUser(),
submittedBy: "",
approvedBy: "",
updatedAt: 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") {
e.report.status = "待审核";
e.report.submittedBy = currentRoleUser();
e.report.approvedBy = "";
e.report.updatedAt = at;
e.report.history.push({ status: "待审核", comment: values.comment, operator: currentRoleUser(), at });
addClosedLoopAudit("REPORT_SUBMITTED", e.report.id, values.comment);
} else if (action === "reportReview") {
e.report.status = values.result === "批准" ? "已批准" : "已退回";
e.report.approvedBy = values.result === "批准" ? values.approver : "";
e.report.updatedAt = at;
e.report.history.push({ status: e.report.status, comment: values.comment, operator: values.approver, at });
addClosedLoopAudit("REPORT_REVIEWED", e.report.id, `${values.result} · ${values.approver} · ${values.comment}`, values.approver, "校级管理者");
} else if (action === "reportPublish") {
e.report.status = "已发布";
e.report.updatedAt = at;
e.report.history.push({ status: "已发布", comment: values.comment, operator: currentRoleUser(), at });
addClosedLoopAudit("REPORT_PUBLISHED", e.report.id, values.comment);
} else if (action === "feedback") {
const feedback = {
id: `FBK-EMP-${String(++closedLoopState.sequences.feedback).padStart(2, "0")}`,
...values,
operator: currentRoleUser(),
at,
reportId: e.report.id,
snapshotId: e.snapshot.id,
};
e.usageFeedback.push(feedback);
e.receipt.businessUsable = values.usable === "可用";
e.receipt.status = values.usable === "可用" ? "业务确认可用" : "业务要求补充";
addClosedLoopAudit("DATA_USAGE_FEEDBACK_ADDED", feedback.id, `${values.department} · ${values.usable} · 下一周期:${values.nextCycle}`);
} else if (action === "review") {
const review = {
id: nextClosedLoopEntityId("review", "RVW", "SOFTWARE"),
before: Number(values.before),
after: Number(values.after),
conclusion: values.conclusion,
decision: values.decision,
comment: values.comment,
operator: currentRoleUser(),
at,
};
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}已完成`);
}
const reports = [
{
id: "school-2026-03",
type: "school",
typeLabel: "校级报告",
title: "2026年春季全校产教供需分析总报告",
scope: "全校 32 个专业",
period: "2026年3月",
periodKey: "current",
pages: 42,
version: "V2026.03",
status: "已发布",
statusClass: "done",
updated: "今天 09:30",
author: "教务处 · 就业处",
roles: ["校级管理者"],
featured: true,
summary:
"本期全校供需匹配度为78.6分,较上一分析周期提升4.2分。智能制造、数字商贸和云平台方向需求增长明显,但云原生、工业视觉、跨境运营等技能仍存在结构性缺口。",
metrics: [
{ label: "供需匹配度", value: "78.6", note: "较上期 +4.2" },
{ label: "产业岗位样本", value: "18,420", note: "近90天 +12.8%" },
{ label: "重点技能缺口", value: "23", note: "8项高优先级" },
{ label: "改进完成率", value: "68%", note: "21/31项已完成" },
],
findings: [
{ title: "云平台与DevOps岗位快速增长", detail: "近90天需求增长31%,软件技术和云计算方向课程覆盖仍不足。", value: "+31%" },
{ title: "工业视觉成为智能制造新缺口", detail: "机器视觉相关需求增长38%,涉及机电、工业机器人等4个专业。", value: "+38%" },
{ title: "跨境电商人才需求由运营转向复合能力", detail: "企业更关注数据分析、内容生产和海外合规履约能力。", value: "6项缺口" },
],
bars: [
{ label: "信息技术类", value: 86 },
{ label: "智能制造类", value: 82 },
{ label: "数字商贸类", value: 75 },
{ label: "文化创意类", value: 72 },
{ label: "现代服务类", value: 69 },
],
actions: [
{ priority: "P0", title: "推动11个专业处理高优技能缺口", detail: "由教务处牵头,4月底前提交课程调整或实训项目方案。" },
{ priority: "P1", title: "建设工业视觉与云原生两类校企共享实训", detail: "优先对接大华、数梦工场等已表达合作意向的企业。" },
{ priority: "P1", title: "每季度请企业确认一次需求", detail: "把互联网招聘信息与合作企业确认的需求分开,避免混用。" },
],
},
{
id: "software-2026-03",
type: "profession",
typeLabel: "专业报告",
title: "软件技术专业人才需求分析报告",
scope: "信息工程学院 · 软件技术",
period: "2026年3月",
periodKey: "current",
pages: 28,
version: "V2026.03",
status: "已发布",
statusClass: "done",
updated: "今天 09:18",
author: "信息工程学院",
roles: ["校级管理者", "学院负责人"],
featured: true,
summary:
"软件技术专业供需匹配度84.2分,整体处于高匹配区间。Java后端和测试开发培养基础较好,云原生部署、AI辅助开发和数据工程能力是下一阶段课程建设重点。",
metrics: [
{ label: "专业匹配度", value: "84.2", note: "全校排名第3" },
{ label: "对口岗位需求", value: "2,864", note: "近90天 +18.6%" },
{ label: "课程技能覆盖", value: "76%", note: "31/41项已覆盖" },
{ label: "潜力合作企业", value: "12", note: "4家高意向" },
],
findings: [
{ title: "Java/Spring能力供需匹配良好", detail: "市场需求强度92%,课程覆盖86%,可继续保持项目化教学。", value: "差值6" },
{ title: "Docker/K8s技能缺口明显", detail: "市场需求81%,课程覆盖仅32%,相差49个百分点。", value: "-49" },
{ title: "AI辅助开发进入企业基础要求", detail: "69%的重点岗位提出相关要求,当前课程覆盖仅28%。", value: "-41" },
],
bars: [
{ label: "Java / Spring", value: 92 },
{ label: "Python应用", value: 84 },
{ label: "Docker / K8s", value: 81 },
{ label: "数据库技术", value: 78 },
{ label: "AI辅助开发", value: 69 },
],
actions: [
{ priority: "P0", title: "强化云原生与容器化实训模块", detail: "在综合实训中覆盖Docker、Kubernetes、CI/CD和可观测性。" },
{ priority: "P1", title: "将AI辅助开发纳入软件工程实践", detail: "增加代码生成、测试辅助、安全审查和人工复核规范。" },
{ priority: "P1", title: "联合企业建设交付项目", detail: "引入数梦工场、海康威视的工程任务和企业导师评价。" },
],
},
{
id: "college-info-2026-03",
type: "college",
typeLabel: "学院报告",
title: "信息工程学院供需分析与专业建设报告",
scope: "信息工程学院 · 6个专业",
period: "2026年3月",
periodKey: "current",
pages: 36,
version: "V2026.03",
status: "待审核",
statusClass: "review",
updated: "今天 08:55",
author: "信息工程学院",
roles: ["校级管理者", "学院负责人"],
summary:
"学院6个专业平均匹配度80.4分,软件技术、物联网应用技术表现较好。云平台、数据工程和 AI 应用课程可由多个专业共用。",
metrics: [
{ label: "学院匹配度", value: "80.4", note: "较上期 +5.1" },
{ label: "覆盖专业", value: "6", note: "2个高匹配专业" },
{ label: "相关岗位", value: "6,120", note: "近90天样本" },
{ label: "合作企业", value: "38", note: "本学期新增6家" },
],
findings: [
{ title: "基础技术课程重复开设", detail: "数据库、Linux 和软件工程内容在多个专业重复,可由学院统一开设共用模块。", value: "6专业" },
{ title: "5个专业都缺云平台课程内容", detail: "云部署、容器和运维内容在5个专业中均覆盖不足。", value: "5专业" },
{ title: "企业项目集中度偏高", detail: "前5家企业承担了61%的项目,需要扩展中型技术企业伙伴。", value: "61%" },
],
bars: [
{ label: "软件技术", value: 84 },
{ label: "物联网应用", value: 80 },
{ label: "大数据技术", value: 79 },
{ label: "人工智能技术", value: 78 },
{ label: "计算机网络", value: 75 },
],
actions: [
{ priority: "P0", title: "开设学院共用工程实践课程", detail: "云平台、数据库和 DevOps 基础内容由学院共用,各专业再安排不同项目。" },
{ priority: "P1", title: "扩展8家中型技术企业合作", detail: "减少项目对少数企业的依赖,为不同专业提供更多企业任务。" },
{ priority: "P1", title: "建立跨专业学生项目团队", detail: "围绕智能物联场景组织软件、数据和网络方向联合实训。" },
],
},
{
id: "gap-2026-03",
type: "industry",
typeLabel: "产业专题",
title: "杭州重点产业人才缺口专题报告",
scope: "杭州重点产业 · 全校专业",
period: "2026年3月",
periodKey: "current",
pages: 31,
version: "V2026.03",
status: "已发布",
statusClass: "done",
updated: "昨天 17:40",
author: "产教融合中心",
roles: ["校级管理者"],
summary:
"杭州区域人才需求持续向智能制造、云计算、跨境电商和数字内容方向集中。全校有23项重点技能缺口,其中8项需要在本学期安排课程或项目调整。",
metrics: [
{ label: "重点缺口", value: "23", note: "8项高优" },
{ label: "关联专业", value: "11", note: "覆盖5个学院" },
{ label: "相关企业", value: "326", note: "126家已确认" },
{ label: "岗位增长", value: "24.8%", note: "重点方向均值" },
],
findings: [
{ title: "工业视觉缺口最为紧迫", detail: "相关岗位增长38%,学校课程平均覆盖不足40%。", value: "+38%" },
{ title: "跨境运营强调数据与合规", detail: "单一平台运营能力已经不能满足企业复合岗位要求。", value: "83%需求" },
{ title: "云平台运维需求外溢到传统专业", detail: "智能制造和物联网岗位也开始要求设备上云与运维能力。", value: "7专业" },
],
bars: [
{ label: "工业视觉", value: 92 },
{ label: "云平台运维", value: 88 },
{ label: "跨境电商", value: 83 },
{ label: "数据工程", value: 79 },
{ label: "AI内容生产", value: 75 },
],
actions: [
{ priority: "P0", title: "针对8项高优缺口建立校级响应清单", detail: "明确对应学院、专业负责人、企业伙伴和阶段性交付物。" },
{ priority: "P1", title: "将企业确认数据纳入缺口复核", detail: "避免只依赖互联网招聘信息推断实际人才缺口。" },
{ priority: "P1", title: "每季度更新重点产业技能词典", detail: "由行业专家和专业负责人共同审核新增技能及映射关系。" },
],
},
{
id: "salary-2026-03",
type: "industry",
dataProfile: "salary",
typeLabel: "产业专题",
title: "2026届实习就业薪资竞争力分析",
scope: "全校2026届学生",
period: "2026年3月",
periodKey: "current",
pages: 24,
version: "V2026.03",
status: "已发布",
statusClass: "done",
updated: "昨天 16:20",
author: "就业处",
roles: ["校级管理者"],
summary:
"2026届学生就业薪资中位数为8,640元,达到区域同岗位中位数的98%。信息技术类专业高于区域基准,现代服务和文化创意类仍有提升空间。",
metrics: [
{ label: "就业薪资中位数", value: "8,640", note: "区域基准98%" },
{ label: "实习月薪中位数", value: "3,820", note: "同比 +8.4%" },
{ label: "高于市场专业", value: "12", note: "占全部37.5%" },
{ label: "有效薪资样本", value: "6,284", note: "完整度91.2%" },
],
findings: [
{ title: "软件技术薪资竞争力领先", detail: "应届岗位中位数达到区域同岗位的108%。", value: "1.08" },
{ title: "技能证据显著影响起薪", detail: "具备企业项目或高质量作品集的学生起薪平均高14%。", value: "+14%" },
{ title: "服务类专业薪资离散度较大", detail: "企业规模和岗位类型对薪资影响明显,需加强预期指导。", value: "差异32%" },
],
bars: [
{ label: "信息技术类", value: 108 },
{ label: "智能制造类", value: 102 },
{ label: "数字商贸类", value: 96 },
{ label: "文化创意类", value: 91 },
{ label: "现代服务类", value: 88 },
],
actions: [
{ priority: "P1", title: "将企业项目成果纳入就业推荐材料", detail: "用学生作品和企业评价说明其岗位能力。" },
{ priority: "P1", title: "建立分专业薪资预期指导", detail: "使用分位数而非平均数,为学生提供地域和岗位差异说明。" },
{ priority: "P2", title: "持续核验企业薪资数据完整性", detail: "对缺失和区间过宽的薪资记录降低统计权重。" },
],
},
{
id: "quality-2026-03",
type: "quality",
typeLabel: "数据质量",
title: "2026年3月数据资产质量与口径报告",
scope: "3类数据资产 · 6个数据源",
period: "2026年3月",
periodKey: "current",
pages: 19,
version: "V2026.03",
status: "已发布",
statusClass: "done",
updated: "昨天 15:05",
author: "数据管理中心",
roles: ["校级管理者", "校级技术管理员"],
summary:
"本期分析快照包含128,642条有效记录,总体质量评分92.6。互联网基础数据包、校级数据和企业数据均保留独立权属与授权标识。",
metrics: [
{ label: "质量评分", value: "92.6", note: "达到分析阈值" },
{ label: "有效记录", value: "128,642", note: "去重后快照" },
{ label: "归一覆盖率", value: "94.8%", note: "218项待复核" },
{ label: "数据源", value: "6", note: "三类资产" },
],
findings: [
{ title: "就业薪资字段存在缺失", detail: "涉及86条记录,已在薪资结论中降低相关权重。", value: "86条" },
{ title: "岗位归一覆盖率达到分析要求", detail: "剩余218个新岗位名称进入人工审核队列。", value: "94.8%" },
{ title: "企业数据授权记录完整", detail: "126家合作企业均保留提交时间和授权场景。", value: "100%" },
],
bars: [
{ label: "完整性", value: 95 },
{ label: "准确性", value: 91 },
{ label: "及时性", value: 97 },
{ label: "一致性", value: 93 },
{ label: "归一覆盖", value: 95 },
],
actions: [
{ priority: "P0", title: "补齐就业薪资缺失记录", detail: "由就业处在本期正式材料发布前完成字段确认。" },
{ priority: "P1", title: "审核218个新增岗位名称", detail: "先处理需求量前50的岗位,并把确认后的别名加入规则表。" },
{ priority: "P2", title: "增加数据包服务期自动提醒", detail: "基础数据包到期前30天通知厂商和学校管理员。" },
],
},
{
id: "mechatronics-2026-03",
type: "profession",
typeLabel: "专业报告",
title: "机电一体化技术专业人才需求分析报告",
scope: "智能制造学院 · 机电一体化",
period: "2026年3月",
periodKey: "current",
pages: 27,
version: "V2026.03",
status: "已发布",
statusClass: "done",
updated: "2026-03-20",
author: "智能制造学院",
roles: ["校级管理者"],
summary: "机电一体化专业匹配度81.7分,PLC和设备调试基础扎实,工业视觉与设备数字孪生是主要技能缺口。",
metrics: [
{ label: "专业匹配度", value: "81.7", note: "较上期 +3.6" },
{ label: "对口岗位", value: "2,146", note: "近90天 +21%" },
{ label: "技能覆盖", value: "72%", note: "工业视觉不足" },
{ label: "潜力企业", value: "9", note: "3家高意向" },
],
findings: [
{ title: "PLC能力覆盖充足", detail: "课程覆盖88%,与企业岗位要求保持一致。", value: "88%" },
{ title: "工业视觉缺口扩大", detail: "岗位增长38%,课程覆盖仅38%。", value: "-44" },
{ title: "数字孪生进入设备岗位要求", detail: "龙头企业正在将相关能力纳入工程师岗位。", value: "+29%" },
],
bars: [
{ label: "PLC编程", value: 94 },
{ label: "工业机器人", value: 89 },
{ label: "机器视觉", value: 82 },
{ label: "数字孪生", value: 73 },
{ label: "电气制图", value: 68 },
],
actions: [
{ priority: "P0", title: "新增工业视觉综合实训", detail: "联合装备企业建设缺陷检测与产线集成任务。" },
{ priority: "P1", title: "引入数字孪生教学案例", detail: "将现有PLC实训映射到设备数据与仿真场景。" },
],
},
{
id: "ecommerce-2026-03",
type: "profession",
typeLabel: "专业报告",
title: "电子商务专业人才需求分析报告",
scope: "商贸旅游学院 · 电子商务",
period: "2026年3月",
periodKey: "current",
pages: 26,
version: "V2026.03",
status: "待审核",
statusClass: "review",
updated: "2026-03-19",
author: "商贸旅游学院",
roles: ["校级管理者"],
summary: "电子商务专业匹配度74.8分,内容运营基础较好,跨境运营、数据分析和AI内容生产需要重点强化。",
metrics: [
{ label: "专业匹配度", value: "74.8", note: "待关注" },
{ label: "对口岗位", value: "1,932", note: "近90天 +13%" },
{ label: "技能覆盖", value: "68%", note: "6项明显缺口" },
{ label: "潜力企业", value: "15", note: "4家高意向" },
],
findings: [
{ title: "内容运营保持较高需求", detail: "相关岗位需求强度91%,课程覆盖76%。", value: "91%" },
{ title: "跨境平台运营覆盖不足", detail: "岗位增长34%,课程覆盖仅43%。", value: "-40" },
{ title: "AI内容生产成为新要求", detail: "75%的重点企业提出相关工具能力。", value: "75%" },
],
bars: [
{ label: "内容运营", value: 91 },
{ label: "数据分析", value: 86 },
{ label: "跨境运营", value: 83 },
{ label: "直播投放", value: 78 },
{ label: "AI内容生产", value: 75 },
],
actions: [
{ priority: "P0", title: "重构跨境电商平台运营课程", detail: "增加企业店铺运营、海外投放和合规履约任务。" },
{ priority: "P1", title: "增加AI营销内容生产工作流", detail: "纳入AI文案、图片、视频辅助生产与人工审核。" },
],
},
{
id: "school-2025",
type: "school",
typeLabel: "校级报告",
title: "2025年度全校产教供需分析总报告",
scope: "全校 32 个专业",
period: "2025年度",
periodKey: "previous",
pages: 40,
version: "V2025.12",
status: "已归档",
statusClass: "review",
updated: "2025-12-28",
author: "教务处 · 就业处",
roles: ["校级管理者"],
summary: "2025年度全校供需匹配度为74.4分,初步识别云平台、工业视觉和跨境运营等重点建设方向。",
metrics: [
{ label: "供需匹配度", value: "74.4", note: "年度提升3.2" },
{ label: "岗位样本", value: "62,840", note: "全年累计" },
{ label: "技能缺口", value: "28", note: "12项高优" },
{ label: "合作企业", value: "108", note: "新增23家" },
],
findings: [
{ title: "数字技术需求增长最快", detail: "软件、数据与云平台岗位全年增长22%。", value: "+22%" },
{ title: "专业之间响应速度差异明显", detail: "已有9个专业启动课程调整,5个专业尚未响应。", value: "9/14" },
],
bars: [
{ label: "信息技术类", value: 82 },
{ label: "智能制造类", value: 77 },
{ label: "数字商贸类", value: 70 },
{ label: "文化创意类", value: 68 },
],
actions: [
{ priority: "P0", title: "每季度更新一次分析", detail: "用季度数据快照和任务进展替代一年一次的人工报告。" },
{ priority: "P1", title: "完善岗位分类与技能词典", detail: "提高跨数据源岗位匹配和趋势比较的稳定性。" },
],
},
];
const professionReportCatalog = [
["物联网应用技术", "信息工程学院", 79.5, "边缘计算与物联网集成"],
["大数据技术", "信息工程学院", 78.9, "数据工程与实时计算"],
["人工智能技术应用", "信息工程学院", 77.8, "模型应用与数据标注"],
["计算机网络技术", "信息工程学院", 75.6, "云网络与安全运维"],
["工业机器人技术", "智能制造学院", 82.3, "机器人集成与机器视觉"],
["电气自动化技术", "智能制造学院", 80.8, "智能控制与设备联网"],
["机械制造及自动化", "智能制造学院", 76.4, "数字化制造与精益生产"],
["模具设计与制造", "智能制造学院", 68.4, "数字化设计与智能检测"],
["新能源汽车技术", "智能制造学院", 83.1, "三电检测与智能诊断"],
["汽车检测与维修技术", "智能制造学院", 74.9, "新能源汽车售后与诊断"],
["跨境电子商务", "商贸旅游学院", 81.2, "海外投放与合规履约"],
["国际商务", "商贸旅游学院", 73.6, "数字贸易与供应链协作"],
["市场营销", "商贸旅游学院", 72.8, "数据营销与用户增长"],
["现代物流管理", "商贸旅游学院", 78.1, "智慧物流与仓配优化"],
["酒店管理与数字化运营", "商贸旅游学院", 71.5, "收益管理与数字化服务"],
["旅游管理", "商贸旅游学院", 70.9, "文旅内容与目的地运营"],
["会展策划与管理", "商贸旅游学院", 72.1, "数字会展与活动运营"],
["建筑工程技术", "城市建设学院", 76.8, "BIM应用与智慧建造"],
["工程造价", "城市建设学院", 75.2, "全过程造价与数字算量"],
["市政工程技术", "城市建设学院", 73.7, "城市更新与工程数字化"],
["环境艺术设计", "艺术设计学院", 74.6, "空间数字表达与项目交付"],
["广告艺术设计", "艺术设计学院", 76.2, "品牌内容与AI视觉生产"],
["动漫设计", "艺术设计学院", 78.4, "三维内容与实时渲染"],
["数字媒体艺术设计", "艺术设计学院", 80.1, "交互设计与数字内容生产"],
["学前教育", "教育学院", 77.2, "幼儿发展评估与数字资源"],
["早期教育", "教育学院", 73.4, "家庭指导与托育服务"],
["婴幼儿托育服务与管理", "教育学院", 79.3, "托育运营与健康照护"],
["应用电子技术", "智能制造学院", 75.9, "嵌入式系统与智能硬件"],
["云计算技术应用", "信息工程学院", 85.1, "云平台运维与DevOps"],
];
professionReportCatalog.forEach(([name, college, score, focus], index) => {
const demand = 820 + ((index * 173) % 1780);
const coverage = Math.max(58, Math.min(88, Math.round(score - 5 + (index % 5))));
const gap = Math.max(8, Math.round(92 - coverage));
const companyCount = 6 + (index % 10);
reports.push({
id: `profession-auto-${index + 1}`,
type: "profession",
typeLabel: "专业报告",
title: `${name}专业人才需求分析报告`,
scope: `${college} · ${name}`,
period: "2026年3月",
periodKey: "current",
pages: 24 + (index % 6),
version: "V2026.03",
status: index % 9 === 0 ? "待审核" : "已发布",
statusClass: index % 9 === 0 ? "review" : "done",
updated: `2026-03-${String(18 - (index % 8)).padStart(2, "0")}`,
author: college,
roles: ["校级管理者"],
summary: `${name}专业供需匹配度${score}分。当前课程基础总体稳定,下一阶段应优先补充${focus}相关课程和企业项目。`,
metrics: [
{ label: "专业匹配度", value: String(score), note: score >= 80 ? "高匹配" : score >= 74 ? "保持关注" : "需要改进" },
{ label: "对口岗位", value: demand.toLocaleString("zh-CN"), note: "近90天有效样本" },
{ label: "技能覆盖", value: `${coverage}%`, note: `${gap}个百分点缺口` },
{ label: "潜力企业", value: String(companyCount), note: `${2 + (index % 4)}家高意向` },
],
findings: [
{ title: `${focus}需求持续增长`, detail: `重点企业正在将相关能力纳入岗位要求,近90天需求信号保持上升。`, value: `+${16 + (index % 23)}%` },
{ title: "课程实践与企业任务仍有差距", detail: "现有课程已覆盖基础知识,但企业场景、工具链和交付评价练习仍然不足。", value: `-${gap}` },
{ title: "可以联系更多合作企业", detail: `已有${companyCount}家企业与该专业较匹配,可讨论项目实训和企业导师安排。`, value: `${companyCount}家` },
],
bars: [
{ label: "专业基础能力", value: Math.min(95, coverage + 8) },
{ label: focus.slice(0, 8), value: Math.min(94, coverage + 14) },
{ label: "工程实践", value: Math.max(55, coverage - 4) },
{ label: "数据与AI应用", value: Math.max(52, coverage - 9) },
{ label: "企业项目交付", value: Math.max(50, coverage - 12) },
],
actions: [
{ priority: "P0", title: `强化${focus}教学模块`, detail: "引入企业任务,明确使用的工具、过程产物和验收标准。" },
{ priority: "P1", title: "邀请企业导师参与课程评审", detail: `优先对接已识别的${companyCount}家潜力企业,核验岗位技能与课程映射。` },
{ priority: "P1", title: "整理学生项目材料", detail: "把项目作品、企业评价和技能考核结果放入就业推荐材料。" },
],
});
});
[
["智能制造学院", "6个专业", "工业视觉、数字孪生与设备联网"],
["商贸旅游学院", "7个专业", "跨境运营、数字营销与智慧服务"],
["城市建设学院", "3个专业", "BIM应用、智慧建造与城市更新"],
["艺术设计与教育联合板块", "7个专业", "数字内容、AI创作与特色人才培养"],
].forEach(([name, scope, focus], index) => {
reports.push({
id: `college-auto-${index + 1}`,
type: "college",
typeLabel: "学院报告",
title: `${name}供需分析与专业建设报告`,
scope: `${name} · ${scope}`,
period: "2026年3月",
periodKey: "current",
pages: 32 + index,
version: "V2026.03",
status: index === 3 ? "待审核" : "已发布",
statusClass: index === 3 ? "review" : "done",
updated: `2026-03-${18 - index}`,
author: name,
roles: ["校级管理者"],
summary: `${name}整体供需表现稳定。多个专业都需要${focus}相关课程,可由学院统一开设部分内容。`,
metrics: [
{ label: "学院匹配度", value: String(76.8 + index * 1.3), note: "较上期持续提升" },
{ label: "覆盖专业", value: scope.replace("个专业", ""), note: "全部纳入分析" },
{ label: "技能缺口", value: String(8 + index * 2), note: "跨专业统筹响应" },
{ label: "合作企业", value: String(26 + index * 7), note: "持续扩展" },
],
findings: [
{ title: `多个专业都需要${focus}`, detail: "多个专业的技能缺口相近,适合共用学院课程或实训环境。", value: "高关联" },
{ title: "企业项目分布仍需优化", detail: "建议降低头部企业集中度,引入更多中型和专精特新企业。", value: "待改善" },
{ title: "建议可以开始执行", detail: "重点事项已有负责人和期限,可加入学院任务清单继续跟进。", value: "可执行" },
],
bars: [
{ label: "专业匹配", value: 80 + index },
{ label: "技能覆盖", value: 73 + index * 2 },
{ label: "企业合作", value: 76 + index },
{ label: "项目实践", value: 70 + index * 2 },
],
actions: [
{ priority: "P0", title: `建设${focus}共享模块`, detail: "列出学院共用内容,以及各专业需要单独设置的练习。" },
{ priority: "P1", title: "按合作类型整理企业名单", detail: "分别列出可参与课程共建、项目实训、实习就业和企业导师工作的企业。" },
{ priority: "P1", title: "按月检查专业改进任务", detail: "校级和学院共同跟进逾期、未响应或缺少材料的任务。" },
],
});
});
const reportDataProfiles = {
school: {
label: "校级综合分析",
topics: ["区域岗位趋势", "课程技能覆盖", "实习就业成效", "企业需求验证"],
dependencies: [
{ sourceId: "regional-jobs", requirement: "required", purpose: "岗位规模、技能需求与区域趋势" },
{ sourceId: "salary-company", requirement: "optional", purpose: "区域薪资基准与企业画像补充" },
{ sourceId: "internship", requirement: "required", purpose: "学生实习岗位与过程成效" },
{ sourceId: "employment", requirement: "required", purpose: "毕业去向、岗位与就业薪资" },
{ sourceId: "curriculum", requirement: "required", purpose: "专业课程与技能培养供给" },
{ sourceId: "enterprise-demand", requirement: "required", purpose: "合作企业正式确认的人才需求" },
],
},
college: {
label: "学院专业建设分析",
topics: ["专业结构对比", "共用课程分析", "培养结果检查", "企业合作机会"],
dependencies: [
{ sourceId: "regional-jobs", requirement: "required", purpose: "学院相关岗位与技能趋势" },
{ sourceId: "salary-company", requirement: "optional", purpose: "专业群薪资和企业结构参考" },
{ sourceId: "internship", requirement: "optional", purpose: "学院实习岗位与实践反馈" },
{ sourceId: "employment", requirement: "optional", purpose: "学院毕业去向与岗位匹配验证" },
{ sourceId: "curriculum", requirement: "required", purpose: "跨专业课程与共享技能供给" },
{ sourceId: "enterprise-demand", requirement: "required", purpose: "学院合作企业需求与项目机会" },
],
},
profession: {
label: "专业人才需求分析",
topics: ["岗位技能需求", "课程技能对应", "学生学习结果", "企业需求复核"],
dependencies: [
{ sourceId: "regional-jobs", requirement: "required", purpose: "对口岗位、技能频次与需求变化" },
{ sourceId: "salary-company", requirement: "optional", purpose: "区域同岗位薪资竞争力" },
{ sourceId: "internship", requirement: "optional", purpose: "学生实践岗位与企业评价" },
{ sourceId: "employment", requirement: "optional", purpose: "毕业去向和岗位匹配验证" },
{ sourceId: "curriculum", requirement: "required", purpose: "课程标准、学时与技能覆盖" },
{ sourceId: "enterprise-demand", requirement: "required", purpose: "企业确认的人才需求与合作意向" },
],
},
industry: {
label: "产业人才缺口分析",
topics: ["产业岗位趋势", "岗位技能归一", "培养供给对照", "企业确认校验"],
dependencies: [
{ sourceId: "regional-jobs", requirement: "required", purpose: "产业岗位规模、增速与技能信号" },
{ sourceId: "salary-company", requirement: "optional", purpose: "重点产业薪资与企业结构" },
{ sourceId: "curriculum", requirement: "required", purpose: "全校专业与课程技能供给" },
{ sourceId: "enterprise-demand", requirement: "required", purpose: "合作企业需求交叉验证" },
],
},
salary: {
label: "实习就业薪资分析",
topics: ["区域薪资基准", "实习薪资分布", "就业薪资对比", "专业竞争力"],
dependencies: [
{ sourceId: "regional-jobs", requirement: "optional", purpose: "区域岗位热度与样本辅助筛选" },
{ sourceId: "salary-company", requirement: "required", purpose: "区域同岗位薪资基准" },
{ sourceId: "internship", requirement: "required", purpose: "学生实习薪资与岗位信息" },
{ sourceId: "employment", requirement: "required", purpose: "毕业生就业薪资与去向" },
],
},
quality: {
label: "数据质量与口径分析",
topics: ["完整性与准确性", "版本与时效", "授权与权属", "加工血缘"],
dependencies: [
{ sourceId: "regional-jobs", requirement: "required", purpose: "岗位数据质量与归一覆盖" },
{ sourceId: "salary-company", requirement: "required", purpose: "薪资和企业画像质量" },
{ sourceId: "internship", requirement: "required", purpose: "实习数据完整性与时效" },
{ sourceId: "employment", requirement: "required", purpose: "就业数据完整性与异常记录" },
{ sourceId: "curriculum", requirement: "required", purpose: "课程数据版本与技能映射" },
{ sourceId: "enterprise-demand", requirement: "required", purpose: "企业身份、授权与需求质量" },
],
},
};
const dataGapCatalog = [
{
id: "job-normalization",
sourceId: "regional-jobs",
level: "attention",
levelLabel: "待复核",
title: "新增岗位缺少标准岗位代码与技能映射",
drawerTitle: "新增岗位名称待归一",
drawerLevel: "需要复核",
amount: "218 个岗位名称 · 影响 1,426 条记录",
affected: "218 个岗位别名 · 影响 1,426 条招聘记录",
fields: ["standard_position_code", "normalized_job_name", "skill_tags"],
affectedProfileKeys: ["school", "college", "profession", "industry", "quality"],
reason: "新增“具身智能应用工程师”等岗位未映射到当前岗位分类词典 V3.2。",
action: "优先审核需求量前 50 的岗位名称,确认标准岗位、技能标签及别名规则。",
status: "平台侧复核中",
due: "2026-03-24",
request: "请平台数据产品部确认标准岗位及技能标签,由平台数据运维组发布别名规则增量。",
},
{
id: "salary-missing",
sourceId: "employment",
level: "blocking",
levelLabel: "需补数",
title: "就业薪资与缺失原因未完整提供",
drawerTitle: "就业系统薪资字段缺失",
drawerLevel: "当前不能发布",
amount: "86 条记录 · 当前字段完整率 91.2%",
affected: "86 条记录 · 电子商务专业 41 条、现代服务类专业 45 条",
fields: ["employment_salary", "salary_period", "missing_reason"],
affectedReportIds: ["school-2026-03", "salary-2026-03", "quality-2026-03", "ecommerce-2026-03"],
reason: "employment_salary 为空,未达到必填完整率 95% 的发布阈值。",
action: "由就业处补齐薪资或填写缺失原因,修复文件按 student_id + cohort 回写后重新校验。",
status: "待就业处补充",
due: "2026-03-25",
request: "请就业处补齐薪资或登记缺失原因,由学校信息中心回写只读视图并重新执行质量校验。",
},
{
id: "enterprise-duplicate",
sourceId: "enterprise-demand",
level: "attention",
levelLabel: "待核验",
title: "企业主标识存在疑似重复",
drawerTitle: "疑似重复企业记录",
drawerLevel: "一般事项",
amount: "12 家企业 · 涉及 37 项确认需求",
affected: "12 家企业 · 涉及 37 项岗位需求",
fields: ["enterprise_credit_code", "master_enterprise_id"],
affectedProfileKeys: ["school", "college", "profession", "industry", "quality"],
reason: "企业简称不同,但统一社会信用代码或工商地址高度相似。",
action: "由产教融合中心确认主企业记录,合并需求与授权回执并保留审计映射。",
status: "待产教融合中心确认",
due: "2026-03-26",
request: "请产教融合中心确认企业主记录,由学校信息中心合并需求与授权回执并保留审计映射。",
},
];
const sourceAnalysisTopics = {
"regional-jobs": ["岗位需求趋势", "岗位技能归一", "产业增长信号"],
"salary-company": ["区域薪资基准", "企业规模画像", "岗位竞争力"],
internship: ["实习岗位分布", "实践过程成效", "企业评价"],
employment: ["毕业去向", "就业薪资", "岗位匹配成效"],
curriculum: ["课程技能覆盖", "专业培养供给", "课程版本"],
"enterprise-demand": ["企业需求确认", "校企合作机会", "授权有效性"],
};
const dataLineageState = {
mode: "report",
selectedReportId: "school-2026-03",
selectedSourceId: "employment",
gapsOnly: false,
};
const iconPaths = {
course: '<path d="M4 5h16v14H4V5Zm4 0V3h8v2M8 10h8m-8 4h5"/>',
lab: '<path d="M9 3h6m-5 0v6l-5 9a2 2 0 0 0 2 3h10a2 2 0 0 0 2-3l-5-9V3M8 15h8"/>',
practice: '<path d="M4 7h16v13H4V7Zm4 0V4h8v3m-4 4v5m-2-2h4"/>',
database: '<ellipse cx="12" cy="5" rx="8" ry="3"/><path d="M4 5v6c0 1.66 3.58 3 8 3s8-1.34 8-3V5M4 11v6c0 1.66 3.58 3 8 3s8-1.34 8-3v-6"/>',
cloud: '<path d="M17.5 19H6a4 4 0 0 1-.6-7.95A7 7 0 0 1 19 9a5 5 0 0 1-1.5 10Z"/>',
file: '<path d="M6 2h9l5 5v15H6V2Zm9 0v6h5M10 13h6m-6 4h4"/>',
package: '<path d="m4 7 8-4 8 4-8 4-8-4Zm0 0v10l8 4 8-4V7M12 11v10"/>',
school: '<path d="M3 10h18M5 7l7-4 7 4M5 10v8m4-8v8m6-8v8m4-8v8M3 21h18"/>',
building: '<path d="M4 21V7l8-4 8 4v14M8 10h2m4 0h2m-8 4h2m4 0h2m-6 7v-3h4v3"/>',
};
function svgIcon(name, className = "") {
return `<svg class="${className}" viewBox="0 0 24 24" aria-hidden="true">${iconPaths[name] || iconPaths.file}</svg>`;
}
function formatNumber(value) {
return Number(value).toLocaleString("zh-CN");
}
function showToast(message, title = "操作成功") {
const region = document.getElementById("toastRegion");
const toast = document.createElement("div");
toast.className = "toast";
toast.innerHTML = `
<span class="toast-icon">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m5 12 4 4L19 6"/></svg>
</span>
<span><strong>${title}</strong><small>${message}</small></span>
`;
region.appendChild(toast);
setTimeout(() => {
toast.classList.add("out");
setTimeout(() => toast.remove(), 240);
}, 2600);
}
function openModal(modal) {
modal.classList.add("open");
modal.setAttribute("aria-hidden", "false");
document.body.style.overflow = "hidden";
setTimeout(() => {
const focusable = modal.querySelector("button, input, select, textarea");
focusable?.focus();
}, 100);
}
function closeModal(modal) {
modal.classList.remove("open");
modal.setAttribute("aria-hidden", "true");
if (!document.querySelector(".modal-backdrop.open")) {
document.body.style.overflow = "";
}
}
function switchView(viewName) {
const view = document.getElementById(`view-${viewName}`);
if (!view) return;
document.querySelectorAll(".view").forEach((item) => item.classList.remove("active"));
view.classList.add("active");
document.querySelectorAll(".nav-item[data-view]").forEach((item) => {
item.classList.toggle("active", item.dataset.view === viewName);
});
if (viewName !== "platform") {
document.querySelectorAll("[data-platform-target]").forEach((item) => item.classList.remove("active"));
}
document.getElementById("breadcrumbCurrent").textContent = view.dataset.title;
if (viewName === "reports") renderReportCenter();
if (viewName === "loop") renderClosedLoop();
document.body.classList.remove("sidebar-open");
window.scrollTo({ top: 0, behavior: "smooth" });
}
function applyRole(role, options = {}) {
const { notify = true } = options;
const profile = roleProfiles[role] || roleProfiles.校级管理者;
const isPlatformRole = Boolean(profile.platform);
const isDataAdministrator = Boolean(profile.dataAdministrator);
document.getElementById("roleName").textContent = role;
document.getElementById("roleUserName").textContent = profile.user;
document.getElementById("roleAvatar").textContent = profile.avatar;
document.getElementById("organizationName").textContent = profile.organization;
document.getElementById("organizationLabel").textContent = profile.organizationLabel;
document.getElementById("organizationEmblem").textContent = profile.emblem;
document.getElementById("breadcrumbRoot").textContent = profile.root;
document.getElementById("topbarContext").textContent = profile.context;
document.getElementById("workspaceNavLabel").textContent = profile.workspaceLabel;
document.getElementById("dataNavText").textContent = isDataAdministrator ? "数据运维中心" : "数据资产";
document.getElementById("dataPageTitle").textContent = isDataAdministrator ? "学校数据运维中心" : "数据资产与质量";
document.getElementById("dataPageDescription").textContent = isDataAdministrator
? "管理学校数据源、初次导入、增量更新、字段契约、质量问题、版本与审计。"
: "区分厂商基础数据包、学校自有数据与企业授权数据,每项分析结论都注明数据来源。";
document.getElementById("syncAllButtonText").textContent = isDataAdministrator ? "新建更新任务" : "同步全部数据";
document.getElementById("schoolNav").hidden = isPlatformRole;
document.getElementById("platformNav").hidden = !isPlatformRole;
document.getElementById("sidebarFooter").hidden = isPlatformRole || !profile.menus.includes("report");
document.querySelectorAll("[data-menu-key]").forEach((item) => {
item.hidden = !profile.menus.includes(item.dataset.menuKey);
});
const hasDeliveryMenu = profile.menus.includes("report") || profile.menus.includes("screen");
document.getElementById("deliveryNavLabel").hidden = !hasDeliveryMenu;
reportState.type = "all";
reportState.period = "all";
reportState.search = "";
reportState.limit = 6;
const collaborationHeading = document.getElementById("collaborationHeading");
const collaborationDescription = document.getElementById("collaborationDescription");
const demandButton = document.getElementById("enterpriseDemandButton");
const projectButton = document.getElementById("newProjectButton");
const collaborationStats = document.querySelector(".collab-stats");
const renderCollaborationStats = (items) => {
collaborationStats.innerHTML = items
.map(
(item) =>
`<div><span>${item.label}</span><strong>${item.value}</strong><small>${item.unit}</small><em>${item.note}</em></div>`,
)
.join("");
};
if (role === "企业导师") {
collaborationHeading.textContent = "企业合作工作台";
collaborationDescription.textContent = "提交岗位与技能需求,参与课程共建并评价学生项目成果。";
demandButton.innerHTML = `${svgIcon("file")}提交企业需求`;
projectButton.innerHTML = `${svgIcon("course")}发起共建项目`;
renderCollaborationStats([
{ label: "已发布人才需求", value: "6", unit: "项", note: "2 项等待学校响应" },
{ label: "共建项目", value: "3", unit: "个", note: "全部按计划推进" },
{ label: "企业导师", value: "12", unit: "人", note: "本月授课 28 学时" },
{ label: "参与学生", value: "86", unit: "人", note: "企业评价 4.7 / 5" },
]);
} else if (role === "学院负责人" || role === "专业负责人") {
collaborationHeading.textContent = "学院校企合作";
collaborationDescription.textContent = "围绕本院专业对接企业需求,推进课程共建、实训与企业导师协作。";
demandButton.innerHTML = `${svgIcon("file")}邀请企业调研`;
projectButton.innerHTML = `${svgIcon("course")}新建合作项目`;
renderCollaborationStats([
{ label: "本院合作企业", value: "38", unit: "家", note: "本学期新增 6 家" },
{ label: "本院合作项目", value: "8", unit: "个", note: "3 个进行中" },
{ label: "企业导师", value: "21", unit: "人", note: "覆盖 6 个专业" },
{ label: "学生参与", value: "386", unit: "人次", note: "满意度 94%" },
]);
} else {
collaborationHeading.textContent = "校企合作";
collaborationDescription.textContent = "根据企业需求安排课程共建、合作项目和人才培养计划。";
demandButton.innerHTML = `${svgIcon("file")}发起需求调研`;
projectButton.innerHTML = `${svgIcon("course")}新建合作项目`;
renderCollaborationStats([
{ label: "在库合作企业", value: "126", unit: "家", note: "本学期新增 18 家" },
{ label: "本学期合作项目", value: "24", unit: "个", note: "8 个进行中" },
{ label: "企业导师", value: "68", unit: "人", note: "覆盖 17 个专业" },
{ label: "学生参与", value: "1,284", unit: "人次", note: "满意度 92%" },
]);
}
renderDataReportLineage();
renderClosedLoop();
if (document.getElementById("professionSelect")) {
renderProfessionProfile(document.getElementById("professionSelect").value);
}
switchView(profile.defaultView);
if (isPlatformRole) {
document.querySelectorAll("#platformNav [data-platform-target]").forEach((item) => item.classList.remove("active"));
const firstPlatformItem = document.querySelector("#platformNav [data-platform-target]");
firstPlatformItem?.classList.add("active");
document.getElementById("breadcrumbCurrent").textContent = "平台总览";
}
if (notify) showToast(`工作台与数据权限已切换为“${role}”`, "角色已切换");
}
function renderSparkline(elementId, points, color = "#238765") {
const element = document.getElementById(elementId);
if (!element) return;
const width = 78;
const height = 28;
const max = Math.max(...points);
const min = Math.min(...points);
const range = max - min || 1;
const coords = points.map((point, index) => {
const x = (index / (points.length - 1)) * width;
const y = height - ((point - min) / range) * (height - 5) - 2;
return [x, y];
});
const line = coords.map(([x, y]) => `${x},${y}`).join(" ");
const area = `M ${coords[0][0]} ${height} L ${coords.map(([x, y]) => `${x} ${y}`).join(" L ")} L ${coords.at(-1)[0]} ${height} Z`;
element.innerHTML = `
<svg viewBox="0 0 ${width} ${height}" preserveAspectRatio="none">
<defs><linearGradient id="${elementId}Gradient" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${color}" stop-opacity=".25"/><stop offset="1" stop-color="${color}" stop-opacity="0"/></linearGradient></defs>
<path class="area" d="${area}" fill="url(#${elementId}Gradient)"></path>
<polyline points="${line}" fill="none" stroke="${color}" stroke-width="1.8" vector-effect="non-scaling-stroke"></polyline>
</svg>
`;
}
function chartCoordinates(values, width, height, padding) {
const min = 60;
const max = 84;
return values.map((value, index) => {
const x = padding + (index / (values.length - 1)) * (width - padding * 2);
const y = height - padding - ((value - min) / (max - min)) * (height - padding * 2);
return [x, y];
});
}
function renderTrendChart(period = "term") {
const data = trendSets[period];
const svg = document.getElementById("lineChart");
const width = 620;
const height = 220;
const padding = 18;
const schoolPoints = chartCoordinates(data.school, width, height, padding);
const benchmarkPoints = chartCoordinates(data.benchmark, width, height, padding);
const schoolLine = schoolPoints.map(([x, y]) => `${x},${y}`).join(" ");
const benchmarkLine = benchmarkPoints.map(([x, y]) => `${x},${y}`).join(" ");
const area = `M ${schoolPoints[0][0]} ${height - padding} L ${schoolPoints
.map(([x, y]) => `${x} ${y}`)
.join(" L ")} L ${schoolPoints.at(-1)[0]} ${height - padding} Z`;
svg.innerHTML = `
<defs>
<linearGradient id="areaGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#238765" stop-opacity=".2"/>
<stop offset="1" stop-color="#238765" stop-opacity="0"/>
</linearGradient>
</defs>
${[35, 80, 125, 170].map((y) => `<line class="grid-line" x1="${padding}" x2="${width - padding}" y1="${y}" y2="${y}"/>`).join("")}
<path class="area-shape" d="${area}"></path>
<polyline class="benchmark-line" points="${benchmarkLine}"></polyline>
<polyline class="main-line" points="${schoolLine}"></polyline>
${schoolPoints.map(([x, y], index) => `<circle class="data-point ${index === schoolPoints.length - 1 ? "last-point" : ""}" cx="${x}" cy="${y}" r="${index === schoolPoints.length - 1 ? 5 : 3.5}"></circle>`).join("")}
<text class="chart-value" x="${schoolPoints.at(-1)[0] - 6}" y="${schoolPoints.at(-1)[1] - 13}" text-anchor="end">${data.score}</text>
`;
document.getElementById("chartLabels").innerHTML = data.labels.map((label) => `<span>${label}</span>`).join("");
document.getElementById("dialScore").textContent = data.score.toFixed(1);
document.getElementById("metricMatch").textContent = data.score.toFixed(1);
document.getElementById("dialValue").style.strokeDashoffset = `${389.6 * (1 - data.score / 100)}`;
}
function renderProfessionTable(filter = "") {
const table = document.getElementById("professionTable");
const keyword = filter.trim().toLowerCase();
const filtered = professions.filter(
(item) => item.name.toLowerCase().includes(keyword) || item.college.toLowerCase().includes(keyword),
);
table.innerHTML = filtered
.map((item) => {
const level = item.score >= 80 ? "high" : item.score >= 72 ? "mid" : "low";
const gapLevel = item.gaps <= 3 ? "low" : item.gaps <= 5 ? "mid" : "high";
const gapText = item.gaps <= 3 ? "轻微" : item.gaps <= 5 ? "关注" : "预警";
return `
<tr>
<td><span class="profession-cell"><span class="profession-icon">${item.icon}</span><strong>${item.name}</strong></span></td>
<td>${item.college}</td>
<td><span class="score-cell"><strong>${item.score}</strong><span class="score-bar ${level}"><i style="width:${item.score}%"></i></span></span></td>
<td>${formatNumber(item.demand)} 个</td>
<td><span class="gap-badge ${gapLevel}">${gapText} · ${item.gaps}项</span></td>
<td><span class="${item.trend >= 0 ? "trend-up" : "trend-down"}">${item.trend >= 0 ? "↑" : "↓"} ${Math.abs(item.trend)}%</span></td>
<td><button class="row-action profession-row-action" data-profession="${item.key}" aria-label="查看${item.name}">
<svg viewBox="0 0 24 24"><path d="m9 18 6-6-6-6"/></svg>
</button></td>
</tr>
`;
})
.join("");
table.querySelectorAll(".profession-row-action").forEach((button) => {
button.addEventListener("click", () => {
const key = professionData[button.dataset.profession] ? button.dataset.profession : "software";
document.getElementById("professionSelect").value = key;
renderProfessionProfile(key);
switchView("profession");
});
});
}
function renderIndustryList() {
document.getElementById("industryList").innerHTML = industries
.map(
(item, index) => `
<div class="industry-item">
<span class="industry-rank">${String(index + 1).padStart(2, "0")}</span>
<span class="industry-copy"><strong>${item.name}</strong><small><i style="width:${item.width}%"></i></small></span>
<span class="industry-growth">+${item.growth}%</span>
</div>
`,
)
.join("");
}
function skillGapClass(gap) {
if (gap > 30) return "high";
if (gap > 12) return "mid";
return "low";
}
function renderProfessionProfile(key) {
const data = professionData[key] || professionData.software;
document.getElementById("professionTitle").textContent = data.name;
document.getElementById("professionCollege").textContent = data.college;
document.getElementById("graduateCount").textContent = data.graduates;
document.getElementById("profileScore").textContent = data.score.toFixed(1);
document.getElementById("profileLevel").textContent = data.level;
document.getElementById("profileDemand").textContent = data.demand;
document.getElementById("profileSalary").textContent = data.salary;
document.getElementById("profileCoverage").textContent = data.coverage;
document.getElementById("profileCompanies").textContent = data.companyCount;
document.getElementById("skillChart").innerHTML = data.skills
.map((skill) => {
const gap = skill.market - skill.course;
const state = gap > 30 ? "明显缺口" : gap > 12 ? "部分覆盖" : "覆盖良好";
return `
<div class="skill-row">
<span class="skill-name"><strong>${skill.name}</strong><small>${skill.category}</small></span>
<span class="skill-bars">
<span><i style="width:${skill.market}%"></i></span>
<span><i style="width:${skill.course}%"></i></span>
</span>
<span class="skill-gap ${skillGapClass(gap)}"><strong>${gap > 0 ? `-${gap}` : `+${Math.abs(gap)}`}</strong><small>${state}</small></span>
</div>
`;
})
.join("");
document.getElementById("jobDemandList").innerHTML = data.jobs
.map(
(job) => `
<div class="job-demand-item">
<div class="job-demand-copy"><strong>${job.name}</strong><span>${job.count}个 <em>+${job.growth}%</em></span></div>
<div class="job-demand-bar"><i style="width:${job.width}%"></i></div>
</div>
`,
)
.join("");
document.getElementById("courseRecommendations").innerHTML = data.recommendations
.map(
(recommendation, index) => `
<div class="recommendation-card" data-recommendation="${index}">
<div class="recommendation-top">
<span class="priority-mark ${recommendation.priority.includes("中") ? "mid" : ""}">${recommendation.priority}</span>
<span class="recommendation-copy">
<strong>${recommendation.title}</strong>
${key === "software" && index === 0 ? `<small class="loop-linked-id">${closedLoopState.entities.recommendation.id} · ${closedLoopState.entities.recommendation.status} · 快照 ${closedLoopState.entities.snapshot.id}</small>` : ""}
<p>${recommendation.description}</p>
<span class="recommendation-evidence">
<svg viewBox="0 0 24 24"><path d="M4 19V9m5 10V5m5 14v-8m5 8V7"/></svg>
数据依据:${recommendation.evidence}
</span>
<span class="recommendation-actions">
<button data-action="accept">采纳并创建任务</button>
<button data-action="hold">暂缓</button>
<button data-action="reject">不采纳</button>
</span>
</span>
</div>
</div>
`,
)
.join("");
document.querySelectorAll(".recommendation-actions button").forEach((button) => {
button.addEventListener("click", () => {
const card = button.closest(".recommendation-card");
const action = button.dataset.action;
if (key === "software" && Number(card.dataset.recommendation) === 0) {
if (currentRoleName() !== "专业负责人") {
showToast("仅专业负责人可保存专业建议决策,当前角色保持只读。", "权限不足");
return;
}
openClosedLoopAction("decision");
const decisionSelect = document.querySelector('#closedLoopFields select[name="decision"]');
if (decisionSelect) decisionSelect.value = action === "accept" ? "采纳" : action === "hold" ? "暂缓" : "驳回";
return;
}
if (action === "accept") {
const recommendation = data.recommendations[Number(card.dataset.recommendation)];
const existingTask = tasks.some((task) => task.title === recommendation.title);
if (!existingTask) {
const task = {
id: `TSK-REC-${Date.now()}`,
title: recommendation.title,
source: `专业建议 · ${data.name} · 已关联数据证据`,
profession: data.name,
owner: "待确认",
due: "2026-06-30",
progress: 0,
status: "review",
statusText: "待确认",
};
closedLoopState.custom.tasks.unshift(task);
addClosedLoopAudit("RECOMMENDATION_ACCEPTED_TASK_CREATED", task.id, `${data.name} · ${recommendation.title}`);
saveClosedLoopState();
syncClosedLoopCollections();
renderTasks();
}
card.classList.add("accepted");
button.textContent = "已采纳";
button.disabled = true;
showToast(existingTask ? "该建议已在改进任务中,可前往任务中心查看" : "建议已加入改进任务,并关联专业分析证据");
} else if (action === "hold") {
showToast("建议已标记为暂缓,下期分析时将再次提醒", "状态已更新");
} else {
card.style.opacity = "0.52";
showToast("建议已记录为不采纳,可在历史记录中恢复", "状态已更新");
}
});
});
document.getElementById("companyShortlist").innerHTML = data.companies
.map(
(company) => `
<div class="company-short-item">
<span class="company-logo">${company.short}</span>
<span class="company-short-copy"><strong>${company.name}</strong><small>${company.focus}</small></span>
<span class="match-chip">${company.match}%</span>
</div>
`,
)
.join("");
if (professionTabState !== "skills") renderProfessionDetailView(professionTabState);
}
let professionTabState = "skills";
function professionEvidenceNote(code, text) {
return `<div class="profession-evidence-note"><span>${code}</span><p>${text}</p><button data-source-detail="区域招聘岗位基础包">查看数据证据</button></div>`;
}
function renderProfessionJobsView(data) {
const totalJobs = data.jobs.reduce((sum, item) => sum + item.count, 0);
const regions = [
{ name: "杭州主城区", value: 46, count: Math.round(totalJobs * 0.46) },
{ name: "杭州区县与都市圈", value: 24, count: Math.round(totalJobs * 0.24) },
{ name: "长三角其他城市", value: 19, count: Math.round(totalJobs * 0.19) },
{ name: "其他地区", value: 11, count: Math.round(totalJobs * 0.11) },
];
return `
<div class="profession-detail-grid">
<article class="panel profession-detail-panel wide">
<div class="panel-header"><div><span class="panel-kicker">岗位流向</span><h2>专业人才对应岗位结构</h2></div><span class="time-caption">近90天 · ${totalJobs.toLocaleString("zh-CN")}条</span></div>
<div class="job-flow-board">
<div class="job-flow-source"><span>${data.name}</span><strong>${data.graduates}</strong><small>2026届毕业生</small></div>
<div class="job-flow-lines">${data.jobs.map((job, index) => `<i style="--flow-width:${Math.max(3, job.width / 11)}px;--flow-top:${18 + index * 18}%"></i>`).join("")}</div>
<div class="job-flow-targets">
${data.jobs
.map(
(job, index) =>
`<span><b>${String(index + 1).padStart(2, "0")}</b><strong>${job.name}</strong><small>${job.count}个 · 增长 ${job.growth}%</small><i><em style="width:${job.width}%"></em></i></span>`,
)
.join("")}
</div>
</div>
</article>
<article class="panel profession-detail-panel">
<div class="panel-header"><div><span class="panel-kicker">地域分布</span><h2>岗位需求地区</h2></div></div>
<div class="region-distribution-list">
${regions.map((region) => `<span><strong>${region.name}</strong><small>${region.count}个 · ${region.value}%</small><i><em style="width:${region.value}%"></em></i></span>`).join("")}
</div>
</article>
<article class="panel profession-detail-panel">
<div class="panel-header"><div><span class="panel-kicker">企业结构</span><h2>岗位来源企业</h2></div></div>
<div class="employer-structure">
<span style="--share:34"><i></i><strong>龙头企业</strong><small>34% · 976个岗位</small></span>
<span style="--share:42"><i></i><strong>专精特新与成长型企业</strong><small>42% · 1,203个岗位</small></span>
<span style="--share:24"><i></i><strong>其他有效企业</strong><small>24% · 685个岗位</small></span>
</div>
</article>
</div>
${professionEvidenceNote("证据 PRO-JOB-301", `岗位流向使用区域招聘基础包中的去重岗位、学校近三届就业去向及合作企业确认记录计算。互联网岗位反映需求信号,不等于实际录用人数。`)}
`;
}
function renderProfessionSalaryView(data) {
const salaryByKey = {
软件技术: { school: 8640, region: 8010, peer: 8230, p25: 7200, p75: 10800, samples: 628 },
机电一体化技术: { school: 7920, region: 7540, peer: 7680, p25: 6800, p75: 9600, samples: 512 },
电子商务: { school: 6980, region: 7280, peer: 7160, p25: 5800, p75: 8500, samples: 706 },
};
const salary = salaryByKey[data.name];
const max = 12000;
const comparisons = [
["本专业就业中位数", salary.school, "school"],
["区域同岗位中位数", salary.region, "region"],
["同类院校专业中位数", salary.peer, "peer"],
];
return `
<div class="profession-detail-grid">
<article class="panel profession-detail-panel wide">
<div class="panel-header"><div><span class="panel-kicker">薪资对标</span><h2>就业薪资竞争力与分位数</h2></div><span class="salary-index ${Number(data.salary) >= 1 ? "positive" : "warning"}">竞争力指数 ${data.salary}</span></div>
<div class="salary-comparison">
${comparisons
.map(
([label, value, tone]) =>
`<span><strong>${label}</strong><i><em class="${tone}" style="width:${(value / max) * 100}%"></em></i><b>¥ ${value.toLocaleString("zh-CN")}</b></span>`,
)
.join("")}
</div>
<div class="salary-range">
<span><small>P25</small><strong>¥ ${salary.p25.toLocaleString("zh-CN")}</strong></span>
<i><b style="left:${(salary.p25 / max) * 100}%;right:${100 - (salary.p75 / max) * 100}%"></b><em style="left:${(salary.school / max) * 100}%"></em></i>
<span><small>P75</small><strong>¥ ${salary.p75.toLocaleString("zh-CN")}</strong></span>
</div>
</article>
<article class="panel profession-detail-panel">
<div class="panel-header"><div><span class="panel-kicker">样本口径</span><h2>有效薪资样本</h2></div></div>
<div class="salary-sample-card"><strong>${salary.samples}</strong><span>条完整就业薪资记录</span><small>缺失、异常区间和无法匹配岗位的记录已剔除或降权。</small></div>
</article>
<article class="panel profession-detail-panel">
<div class="panel-header"><div><span class="panel-kicker">影响因素</span><h2>技能证据与起薪</h2></div></div>
<div class="salary-factor-list">
<span><b>+14%</b><strong>企业项目经历</strong></span>
<span><b>+9%</b><strong>高质量作品集</strong></span>
<span><b>+7%</b><strong>企业导师评价</strong></span>
</div>
</article>
</div>
${professionEvidenceNote("证据 PRO-SAL-302", `薪资对标使用${salary.samples}条完整校内就业回执与区域同岗位样本,按中位数比较并控制地区、岗位层级和企业规模差异。`)}
`;
}
function renderProfessionCoursesView(data) {
const courseNames = {
软件技术: ["Java企业级开发", "Web前端工程实践", "云原生综合实训", "数据库应用", "软件工程项目"],
机电一体化技术: ["PLC控制技术", "工业机器人应用", "机器视觉实训", "设备数字孪生", "电气设计"],
电子商务: ["电商运营实务", "内容营销", "跨境平台运营", "直播投放", "数据化经营"],
};
const courses = courseNames[data.name];
return `
<div class="profession-detail-grid">
<article class="panel profession-detail-panel wide">
<div class="panel-header"><div><span class="panel-kicker">课程映射</span><h2>课程—产业技能覆盖矩阵</h2></div><span class="matrix-legend"><i></i>充分覆盖 <i></i>部分覆盖 <i></i>待补强</span></div>
<div class="course-matrix-wrap">
<table class="course-matrix">
<thead><tr><th>课程 / 技能</th>${data.skills.map((skill) => `<th>${skill.name}</th>`).join("")}<th>调整建议</th></tr></thead>
<tbody>
${courses
.map((course, row) => {
const cells = data.skills
.map((skill, column) => {
const score = Math.max(18, Math.min(96, skill.course + ((row * 17 + column * 11) % 35) - 18));
const level = score >= 72 ? "full" : score >= 45 ? "partial" : "gap";
return `<td><span class="matrix-cell ${level}" title="${course}对${skill.name}覆盖度${score}%">${score}</span></td>`;
})
.join("");
return `<tr><td><strong>${course}</strong><small>${row < 2 ? "专业核心课" : row === 2 ? "综合实训" : "专业方向课"}</small></td>${cells}<td><button data-workspace-detail="course-${row}">${row === 2 ? "重点重构" : "查看标准"}</button></td></tr>`;
})
.join("")}
</tbody>
</table>
</div>
</article>
<article class="panel profession-detail-panel">
<div class="panel-header"><div><span class="panel-kicker">覆盖统计</span><h2>技能映射质量</h2></div></div>
<div class="coverage-summary"><strong>${data.coverage}</strong><span>课程技能覆盖率</span><i><b style="width:${data.coverage}"></b></i><small>课程标准、实训任务和考核要求均参与映射。</small></div>
</article>
<article class="panel profession-detail-panel">
<div class="panel-header"><div><span class="panel-kicker">优先调整</span><h2>高需求低覆盖能力</h2></div></div>
<div class="course-gap-list">${data.skills
.map((skill) => ({ ...skill, gap: skill.market - skill.course }))
.sort((a, b) => b.gap - a.gap)
.slice(0, 3)
.map((skill, index) => `<span><b>${String(index + 1).padStart(2, "0")}</b><strong>${skill.name}</strong><small>需求 ${skill.market} · 覆盖 ${skill.course} · 缺口 ${skill.gap}</small></span>`)
.join("")}</div>
</article>
</div>
${professionEvidenceNote("证据 PRO-CRS-303", `矩阵由课程标准、教学任务、实训产物和考核要求映射至版本化技能词典。文字出现不等于学生达成,仍需结合项目作品和企业评价。`)}
`;
}
function renderProfessionDetailView(tab) {
const key = document.getElementById("professionSelect").value;
const data = professionData[key] || professionData.software;
const renderers = {
jobs: renderProfessionJobsView,
salary: renderProfessionSalaryView,
courses: renderProfessionCoursesView,
};
document.getElementById("professionDetailView").innerHTML = renderers[tab](data);
}
function renderProjects(filter = "all") {
const toneClass = { green: "", amber: "amber", blue: "blue" };
const filteredProjects =
filter === "all"
? projects
: projects.filter((project) => (filter === "practice" ? project.type === "practice" || project.type === "lab" : project.type === filter));
document.getElementById("projectList").innerHTML = filteredProjects
.map(
(project) => `
<div class="project-item">
<span class="project-type ${toneClass[project.tone]}">${svgIcon(project.type)}</span>
<span class="project-copy"><strong>${project.title}</strong><small>${project.id ? `${project.id} · ${project.status || "进行中"} · ` : ""}${project.meta}</small></span>
<span class="project-partners">${project.partners.map((partner) => `<i class="partner-avatar">${partner}</i>`).join("")}</span>
<span class="project-progress"><span>项目进度 <strong>${project.progress}%</strong></span><i><b style="width:${project.progress}%"></b></i></span>
<button class="row-action" data-project-id="${project.id || ""}" data-project-detail="${project.title}" aria-label="查看${project.title}"><svg viewBox="0 0 24 24"><path d="m9 18 6-6-6-6"/></svg></button>
</div>
`,
)
.join("");
if (!filteredProjects.length) {
document.getElementById("projectList").innerHTML = `<div class="inline-empty-state"><strong>当前分类暂无项目</strong><small>可以新建项目或切换其他分类。</small></div>`;
}
}
function renderDemands() {
document.getElementById("enterpriseDemandList").innerHTML = enterpriseDemands
.map(
(demand) => `
<div class="demand-item">
<div class="demand-item-top">
<span class="demand-company"><span class="company-logo">${demand.logo}</span><span><strong>${demand.company}</strong><small>${demand.industry}</small></span></span>
<span class="gap-badge mid">${demand.status || "待响应"}</span>
</div>
<strong>${demand.title}</strong>
${demand.id ? `<small class="loop-linked-id">${demand.id}</small>` : ""}
<p>${demand.description}</p>
<div class="demand-action"><span>${demand.due}</span><button data-demand-id="${demand.id || ""}" data-demand="${demand.company}">查看并响应</button></div>
</div>
`,
)
.join("");
document.querySelectorAll("[data-demand]").forEach((button) => {
button.addEventListener("click", () => {
openWorkspaceDetail("demand", button.dataset.demandId || button.dataset.demand);
});
});
}
function renderPartners(filter = "all") {
const categoryByPartner = {
吉利汽车: "智能制造",
大华股份: "智能制造",
数梦工场: "数字技术",
遥望科技: "现代服务",
};
const filteredPartners = filter === "all" ? partners : partners.filter((partner) => categoryByPartner[partner.name] === filter);
document.getElementById("partnerGrid").innerHTML = filteredPartners
.map(
(partner) => `
<article class="partner-card">
<div class="partner-card-head">
<span class="company-logo">${partner.logo}</span>
<span><strong>${partner.name}</strong><small>${partner.industry}</small></span>
</div>
<p class="partner-reason">${partner.reason}</p>
<div class="partner-tags">${partner.tags.map((tag) => `<span>${tag}</span>`).join("")}</div>
<div class="partner-card-foot"><strong>${partner.match}% 匹配</strong><button data-connect="${partner.name}">发起对接</button></div>
</article>
`,
)
.join("");
document.querySelectorAll("[data-connect]").forEach((button) => {
button.addEventListener("click", () => {
openWorkspaceDetail("partner", button.dataset.connect);
});
});
}
function renderTasks(filter = "all", search = "") {
const keyword = search.trim().toLowerCase();
const filtered = tasks.filter((task) => {
const matchesFilter =
filter === "all" ||
task.status === filter ||
(filter === "progress" && task.status === "risk");
const matchesSearch = `${task.title}${task.profession}${task.owner}`.toLowerCase().includes(keyword);
return matchesFilter && matchesSearch;
});
document.getElementById("taskTable").innerHTML = filtered
.map(
(task) => `
<tr>
<td><span class="task-title"><strong>${task.title}</strong><small>${task.id ? `${task.id} · ` : ""}${task.source}</small></span></td>
<td>${task.profession}</td>
<td><span class="owner-cell"><span class="owner-avatar">${task.owner.slice(0, 1)}</span>${task.owner}</span></td>
<td>${task.due}</td>
<td><span class="task-progress-cell"><i><b style="width:${task.progress}%"></b></i><span>${task.progress}%</span></span></td>
<td><span class="status-badge ${task.status}">${task.statusText}</span></td>
<td><button class="row-action" data-task-id="${task.id || ""}" data-task-detail="${task.title}" aria-label="查看${task.title}"><svg viewBox="0 0 24 24"><path d="m9 18 6-6-6-6"/></svg></button></td>
</tr>
`,
)
.join("");
if (!filtered.length) {
document.getElementById("taskTable").innerHTML = `<tr><td colspan="7" style="padding:34px;text-align:center;color:#87938d">没有找到符合条件的任务</td></tr>`;
}
}
function reportDataProfileKey(report) {
return report.dataProfile || report.type;
}
function reportDataProfile(report) {
return reportDataProfiles[reportDataProfileKey(report)] || reportDataProfiles.profession;
}
function sourceById(sourceId) {
return sources.find((source) => source.id === sourceId);
}
function gapsForSource(sourceId) {
return dataGapCatalog.filter((gap) => gap.sourceId === sourceId);
}
function dataGapById(gapId) {
return dataGapCatalog.find((gap) => gap.id === gapId);
}
function dependencyForReport(report, sourceId) {
return reportDataProfile(report).dependencies.find((dependency) => dependency.sourceId === sourceId);
}
function reportsUsingSource(sourceId) {
return reports.filter((report) => Boolean(dependencyForReport(report, sourceId)));
}
function gapAffectsReport(gap, report) {
if (!dependencyForReport(report, gap.sourceId)) return false;
if (gap.affectedReportIds) return gap.affectedReportIds.includes(report.id);
if (report.periodKey !== "current") return false;
return gap.affectedProfileKeys?.includes(reportDataProfileKey(report)) ?? false;
}
function reportsAffectedByGap(gap) {
return reports.filter((report) => gapAffectsReport(gap, report));
}
function reportReadiness(report) {
const requiredDependencies = reportDataProfile(report).dependencies.filter(
(dependency) => dependency.requirement === "required",
);
if (!requiredDependencies.length) return 100;
return (
requiredDependencies.reduce(
(total, dependency) => total + sourceById(dependency.sourceId).analysisCompleteness,
0,
) /
requiredDependencies.length
);
}
function reportGaps(report) {
return dataGapCatalog.filter((gap) => gapAffectsReport(gap, report));
}
function reportsGroupedByType(reportItems) {
return reportItems.reduce((groups, report) => {
const group = groups.get(report.type) || {
type: report.type,
label: report.typeLabel,
reports: [],
};
group.reports.push(report);
groups.set(report.type, group);
return groups;
}, new Map());
}
function readableReportIdSet() {
return new Set(reportsForCurrentRole().map((report) => report.id));
}
function lineageEmptyNode(message) {
return `<div class="lineage-empty-node">${svgIcon("database")}<strong>${message}</strong><small>关闭“只看缺口”可查看完整依赖。</small></div>`;
}
function renderLineageSourceNode(source, dependency, selected = false, relevantGaps = gapsForSource(source.id)) {
const gaps = relevantGaps.filter((gap) => gap.sourceId === source.id);
const hasGap = gaps.length > 0;
const requirement = dependency?.requirement || "required";
const completeness = source.analysisCompleteness;
return `
<button class="lineage-node lineage-source-node ${hasGap ? "has-gap" : ""} ${selected ? "selected" : ""}" data-source-detail="${source.name}">
<span class="lineage-node-top">
<em class="${requirement}">${requirement === "required" ? "必需数据" : "可选增强"}</em>
<i class="${hasGap ? "gap" : "ready"}">${hasGap ? gaps[0].levelLabel : "可用"}</i>
</span>
<strong>${source.name}</strong>
<small>${dependency?.purpose || source.type}</small>
<span class="lineage-node-meta">
<span><b>${completeness.toFixed(1)}%</b> 完整</span>
<span>${source.steward}</span>
</span>
${hasGap ? `<span class="lineage-node-gap">${gaps.map((gap) => gap.fields.slice(0, 2).join("、")).join(";")}</span>` : ""}
</button>
`;
}
function renderLineageTopicNode(topic, index) {
return `
<div class="lineage-topic-node">
<span>${String(index + 1).padStart(2, "0")}</span>
<strong>${topic}</strong>
<small>按同一统计口径计算 · 可查看数据来源</small>
</div>
`;
}
function renderLineageReportNode(report, readableIds = readableReportIdSet()) {
const gaps = reportGaps(report);
const readiness = reportReadiness(report);
const canRead = readableIds.has(report.id);
return `
<button class="lineage-node lineage-report-node ${gaps.length ? "has-gap" : ""}" data-lineage-report="${report.id}">
<span class="lineage-node-top">
<em>${report.typeLabel}</em>
<i class="${gaps.length ? "gap" : "ready"}">${gaps.length ? `${gaps.length}项缺口` : "数据齐备"}</i>
</span>
<strong>${report.title}</strong>
<small>${report.scope}</small>
<span class="lineage-node-meta">
<span><b>${readiness.toFixed(1)}%</b> 完备</span>
<span>${canRead ? report.version : "仅依赖元数据"}</span>
</span>
</button>
`;
}
function renderLineageReportGroup(group, readableIds) {
const affectedCount = group.reports.filter((report) => reportGaps(report).length).length;
return `
<details class="lineage-report-group" ${group.reports.length <= 5 ? "open" : ""}>
<summary>
<span><strong>${group.label}</strong><small>${affectedCount ? `${affectedCount} 份存在数据缺口` : "当前数据关系可用"}</small></span>
<em>${group.reports.length} 份</em>
</summary>
<div class="lineage-report-group-list">
${group.reports.map((report) => renderLineageReportNode(report, readableIds)).join("")}
</div>
</details>
`;
}
function renderLineageStage(title, countText, className, content) {
return `
<section class="lineage-stage ${className}">
<div class="lineage-stage-head"><span>${title}</span><strong>${countText}</strong></div>
<div class="lineage-stage-list">${content}</div>
</section>
`;
}
function lineageConnector(label) {
return `
<div class="lineage-connector" aria-hidden="true">
<span>${label}</span>
<i><b></b></i>
</div>
`;
}
function renderReportLineageCanvas(report) {
const profile = reportDataProfile(report);
const gaps = reportGaps(report);
const readableIds = readableReportIdSet();
const gapSourceIds = new Set(gaps.map((gap) => gap.sourceId));
const visibleDependencies = dataLineageState.gapsOnly
? profile.dependencies.filter((dependency) => gapSourceIds.has(dependency.sourceId))
: profile.dependencies;
const sourceNodes =
visibleDependencies
.map((dependency) => renderLineageSourceNode(sourceById(dependency.sourceId), dependency, false, gaps))
.join("") || lineageEmptyNode("当前报告没有数据缺口");
const topicNodes = profile.topics.map(renderLineageTopicNode).join("");
return `
<div class="lineage-stage-grid report-direction">
${renderLineageStage("数据资产", `${visibleDependencies.length} / ${profile.dependencies.length} 项`, "sources", sourceNodes)}
${lineageConnector("计算指标")}
${renderLineageStage("指标 / 分析主题", `${profile.topics.length} 类`, "topics", topicNodes)}
${lineageConnector("生成报告")}
${renderLineageStage("目标报告", "1 份", "reports", renderLineageReportNode(report, readableIds))}
</div>
`;
}
function renderSourceLineageCanvas(source) {
const sourceReports = reportsUsingSource(source.id);
const affectedReportIds = new Set(
gapsForSource(source.id).flatMap((gap) => reportsAffectedByGap(gap).map((report) => report.id)),
);
const visibleReports = dataLineageState.gapsOnly
? sourceReports.filter((report) => affectedReportIds.has(report.id))
: sourceReports;
const reportGroups = [...reportsGroupedByType(visibleReports).values()];
const readableIds = readableReportIdSet();
const reportNodes =
reportGroups
.map((group) => renderLineageReportGroup(group, readableIds))
.join("") || lineageEmptyNode("当前数据来源没有待处理的报告关系");
const topics = sourceAnalysisTopics[source.id] || ["基础指标", "专题分析", "质量校验"];
return `
<div class="lineage-stage-grid source-direction">
${renderLineageStage("选中数据", "1 项", "sources", renderLineageSourceNode(source, null, true))}
${lineageConnector("用于分析")}
${renderLineageStage("可做的分析", `${topics.length} 类`, "topics", topics.map(renderLineageTopicNode).join(""))}
${lineageConnector("用于报告")}
${renderLineageStage("使用该数据的报告", `${visibleReports.length} 份`, "reports", reportNodes)}
</div>
`;
}
function lineageSummaryCard(label, value, note, className = "") {
return `<article class="${className}"><span>${label}</span><strong>${value}</strong><small>${note}</small></article>`;
}
function renderLineageSummary(report, source) {
const summary = document.getElementById("dataLineageSummary");
if (dataLineageState.mode === "report") {
const profile = reportDataProfile(report);
const requiredCount = profile.dependencies.filter((dependency) => dependency.requirement === "required").length;
const gaps = reportGaps(report);
summary.innerHTML = [
lineageSummaryCard("当前报告", report.typeLabel, report.version),
lineageSummaryCard("依赖数据", `${requiredCount} 必需 · ${profile.dependencies.length - requiredCount} 可选`, "依赖关系已登记"),
lineageSummaryCard("数据完备度", `${reportReadiness(report).toFixed(1)}%`, gaps.length ? "使用稳定版本,可继续分析" : "达到生成条件"),
lineageSummaryCard("待补数据", `${gaps.length} 项`, gaps.length ? "已定位责任人与时限" : "当前无数据缺口", gaps.length ? "warning" : "ready"),
].join("");
return;
}
const sourceReports = reportsUsingSource(source.id);
const gaps = gapsForSource(source.id);
summary.innerHTML = [
lineageSummaryCard("当前数据", source.categoryLabel, source.version),
lineageSummaryCard("使用该数据的报告", `${sourceReports.length} 份`, `${reportsGroupedByType(sourceReports).size} 类报告`),
lineageSummaryCard("当前质量", source.quality, `${source.records} 条有效记录`),
lineageSummaryCard("数据缺口", `${gaps.length} 项`, gaps.length ? "已定位责任人与时限" : "当前无数据缺口", gaps.length ? "warning" : "ready"),
].join("");
}
function renderLineageGapCard(gap, selectedReport) {
const source = sourceById(gap.sourceId);
const impactCount = selectedReport ? 1 : reportsAffectedByGap(gap).length;
return `
<article class="lineage-gap-card ${gap.level}">
<div class="lineage-gap-card-head">
<span><i></i>${gap.levelLabel}</span>
<em>${gap.status}</em>
</div>
<strong>${gap.title}</strong>
<small>${source.name} · ${gap.amount}</small>
<div class="lineage-gap-fields">${gap.fields.map((field) => `<span>${field}</span>`).join("")}</div>
<p>${gap.request}</p>
<dl>
<div><dt>业务索取人</dt><dd>${source.stewardContact}</dd></div>
<div><dt>技术接口人</dt><dd>${source.technicalContact}</dd></div>
<div><dt>要求完成</dt><dd>${gap.due}</dd></div>
<div><dt>影响范围</dt><dd>${selectedReport ? "当前报告" : `${impactCount} 份报告`}</dd></div>
</dl>
<button data-lineage-gap-action="${gap.id}">进入补数与质量处置</button>
</article>
`;
}
function renderLineageGaps(report, source) {
const gaps = dataLineageState.mode === "report" ? reportGaps(report) : gapsForSource(source.id);
document.getElementById("dataLineageGapCount").textContent = `${gaps.length} 项待处理`;
document.getElementById("dataLineageGapList").innerHTML = gaps.length
? gaps.map((gap) => renderLineageGapCard(gap, dataLineageState.mode === "report" ? report : null)).join("")
: `
<div class="lineage-gap-empty">
${svgIcon("database")}
<span><strong>当前关系没有待补数据</strong><small>数据版本、质量和授权均达到报告生成要求。</small></span>
</div>
`;
}
function renderDataReportLineage() {
const selector = document.getElementById("dataLineageSelector");
if (!selector) return;
const mode = dataLineageState.mode;
const readableReportIds = new Set(reportsForCurrentRole().map((report) => report.id));
if (!reports.some((report) => report.id === dataLineageState.selectedReportId)) {
dataLineageState.selectedReportId = "school-2026-03";
}
if (!sources.some((source) => source.id === dataLineageState.selectedSourceId)) {
dataLineageState.selectedSourceId = sources[0].id;
}
const report = reports.find((item) => item.id === dataLineageState.selectedReportId) || reports[0];
const source = sourceById(dataLineageState.selectedSourceId) || sources[0];
document.querySelectorAll("[data-lineage-mode]").forEach((button) => {
const selected = button.dataset.lineageMode === mode;
button.classList.toggle("active", selected);
button.setAttribute("aria-pressed", String(selected));
});
document.getElementById("dataLineageSelectorLabel").textContent = mode === "report" ? "选择报告" : "选择数据";
selector.setAttribute("aria-label", mode === "report" ? "选择报告查看所需数据" : "选择数据查看使用它的报告");
selector.innerHTML =
mode === "report"
? reports
.map((item) => {
const canRead = readableReportIds.has(item.id);
return `<option value="${item.id}">${item.typeLabel} · ${item.title}${canRead ? "" : " · 仅依赖元数据"}</option>`;
})
.join("")
: sources
.map((item) => `<option value="${item.id}">${item.categoryLabel} · ${item.name}</option>`)
.join("");
selector.value = mode === "report" ? report.id : source.id;
document.getElementById("dataLineageGapToggle").checked = dataLineageState.gapsOnly;
document.getElementById("dataLineageCanvas").innerHTML =
mode === "report" ? renderReportLineageCanvas(report) : renderSourceLineageCanvas(source);
renderLineageSummary(report, source);
renderLineageGaps(report, source);
}
function setupDataReportLineage() {
const lineage = document.getElementById("dataReportLineage");
lineage.addEventListener("click", (event) => {
const modeButton = event.target.closest("[data-lineage-mode]");
if (modeButton) {
dataLineageState.mode = modeButton.dataset.lineageMode;
renderDataReportLineage();
return;
}
const reportButton = event.target.closest("[data-lineage-report]");
if (reportButton) {
const reportId = reportButton.dataset.lineageReport;
if (!reportsForCurrentRole().some((report) => report.id === reportId)) {
showToast("依赖元数据可见,但当前角色没有该报告正文的阅读权限。", "权限范围提示");
return;
}
openReportReader(reportId);
return;
}
const gapButton = event.target.closest("[data-lineage-gap-action]");
if (gapButton) openDataOpsDrawer("issue", gapButton.dataset.lineageGapAction);
});
document.getElementById("dataLineageSelector").addEventListener("change", (event) => {
if (dataLineageState.mode === "report") dataLineageState.selectedReportId = event.target.value;
else dataLineageState.selectedSourceId = event.target.value;
renderDataReportLineage();
});
document.getElementById("dataLineageGapToggle").addEventListener("change", (event) => {
dataLineageState.gapsOnly = event.target.checked;
renderDataReportLineage();
});
}
const dataOpsState = {
context: "source",
sourceName: sources[0].name,
tab: "overview",
issue: null,
};
function currentDataSource() {
return sources.find((source) => source.name === dataOpsState.sourceName) || sources[0];
}
function dataOpsStat(label, value, note = "") {
return `<span><small>${label}</small><strong>${value}</strong>${note ? `<em>${note}</em>` : ""}</span>`;
}
function renderDataOpsOverview(source) {
return `
<div class="data-ops-stat-grid">
${dataOpsStat("当前版本", source.version, source.updateMode)}
${dataOpsStat("业务截止日期", source.dataAsOf, `入库于 ${source.updated}`)}
${dataOpsStat("质量评分", source.quality, `发布阈值 ${source.threshold.split(";")[0]}`)}
${dataOpsStat("下次更新", source.nextUpdate, source.frequency)}
</div>
<article class="data-ops-highlight">
<span>本次更新内容</span>
<strong>${source.changeSummary}</strong>
<p>更新数据先放入暂存区检查;不满足最低质量要求的数据进入隔离区,不会覆盖当前已发布版本。</p>
</article>
<div class="data-ops-two-column">
<article class="data-ops-card">
<h3>更新与责任</h3>
<dl>
<div><dt>数据类别</dt><dd>${source.categoryLabel} · ${source.commercial}</dd></div>
<div><dt>业务责任人</dt><dd>${source.stewardContact} · ${source.steward}</dd></div>
<div><dt>技术责任人</dt><dd>${source.technicalContact} · ${source.technicalOwner}</dd></div>
<div><dt>更新模式</dt><dd>${source.updateMode}</dd></div>
<div><dt>当前状态</dt><dd><span class="data-ops-state ${source.warning ? "warning" : ""}">${source.status}</span></dd></div>
</dl>
</article>
<article class="data-ops-card">
<h3>质量与可用性</h3>
<div class="data-quality-meter"><i><b style="width:${source.quality}"></b></i><strong>${source.quality}</strong></div>
<ul>
<li>主键、必填字段和代码合法性已执行自动校验</li>
<li>授权缺失或已过期记录不得进入分析快照</li>
<li>失败任务保留错误明细、操作者和追踪编号</li>
</ul>
</article>
</div>
`;
}
function renderDataOpsRequirements(source) {
const fields = source.requiredFields.split("、");
return `
<article class="data-contract-hero">
<div><span>数据契约</span><strong>${source.schema}</strong><small>${source.name}</small></div>
<div><span>支持格式</span><strong>${source.format}</strong><small>日期使用 ISO-8601 · 字符编码 UTF-8</small></div>
<div><span>主键要求</span><strong>${source.primaryKey}</strong><small>主键唯一率必须达到 100%</small></div>
</article>
<article class="data-ops-card">
<h3>初次导入与增量更新要求</h3>
<div class="data-requirement-steps">
<span><b>01</b><strong>准备文件或连接</strong><small>支持标准模板导入,也可按更新频率配置 API、数据库视图或 SFTP。</small></span>
<span><b>02</b><strong>字段映射与样本预检</strong><small>确认主键、必填字段、代码字典和敏感字段处理规则。</small></span>
<span><b>03</b><strong>暂存与质量校验</strong><small>先写入暂存区,发现严重错误时不更新生产版本。</small></span>
<span><b>04</b><strong>审批发布与留痕</strong><small>数据管理员确认质量和授权后,生成不可变版本并进入分析。</small></span>
</div>
</article>
<article class="data-ops-card">
<h3>必填字段</h3>
<div class="data-field-tags">${fields.map((field) => `<span>${field}</span>`).join("")}</div>
</article>
<article class="data-ops-card">
<h3>验收门槛</h3>
<table class="data-ops-table">
<thead><tr><th>校验项</th><th>门槛</th><th>失败处理</th></tr></thead>
<tbody>
<tr><td>主键唯一性</td><td>100%</td><td>整批不发布,可下载冲突明细</td></tr>
<tr><td>必填完整性</td><td>≥ 95%</td><td>进入隔离区,修复后重试</td></tr>
<tr><td>代码与日期合法性</td><td>≥ 98%</td><td>错误记录不进入分析</td></tr>
<tr><td>综合质量</td><td>${source.threshold}</td><td>低于阈值不得发布</td></tr>
</tbody>
</table>
</article>
`;
}
function renderDataOpsHistory(source) {
return `
<div class="data-task-banner ${source.warning ? "warning" : ""}">
<span><i></i><strong>${source.warning ? "最近一次增量任务需要处理" : "最近一次更新任务已完成"}</strong></span>
<em>${source.warning ? "QUALITY_CHECK · 78%" : "PUBLISHED · 100%"}</em>
</div>
<div class="data-version-timeline">
<article class="${source.warning ? "warning" : "active"}">
<i></i>
<div><span>${source.version}</span><strong>${source.updated} · ${source.updateMode}</strong><p>${source.changeSummary}</p><small>追踪编号 TRACE-${source.category.toUpperCase()}-20260322-01</small></div>
</article>
<article>
<i></i>
<div><span>上一稳定版本</span><strong>2026-03-17 · 已发布</strong><p>当前分析快照仍引用该稳定版本,可随时回滚。</p><small>保留 180 天 · 完整性校验通过</small></div>
</article>
<article>
<i></i>
<div><span>初次导入</span><strong>2026-02-28 · FULL</strong><p>完成字段映射、主键确认、授权登记与全量质量基线。</p><small>审批人:学校数据管理员 / 平台数据产品负责人</small></div>
</article>
</div>
<article class="data-ops-card">
<h3>失败处理策略</h3>
<p>网络失败从检查点自动重试 3 次;数据错误进入隔离区;出现主键冲突、授权缺失或 Schema 不兼容时,整批数据不发布。历史已发布版本不直接覆盖。</p>
</article>
`;
}
function renderDataOpsAudit(source) {
const authorization =
source.category === "vendor"
? "按学校租户、产品版本、用途和服务期限授权"
: source.category === "enterprise"
? "绑定企业授权编号、合作用途、专业范围和有效期"
: "数据权属归学校,仅限本租户获授权角色使用";
return `
<div class="data-authorization-card">
<span>权属与授权</span><strong>${source.rights}</strong><p>${authorization}</p>
<div><span>业务责任人<strong>${source.stewardContact} · ${source.steward}</strong></span><span>技术责任人<strong>${source.technicalContact} · ${source.technicalOwner}</strong></span><span>授权状态<strong>${source.status}</strong></span></div>
</div>
<article class="data-ops-card">
<h3>最近审计事件</h3>
<div class="data-audit-list">
<span><i></i><strong>版本校验完成</strong><small>${source.updated} · 系统质量引擎 · TRACE-8F2A</small></span>
<span><i></i><strong>授权范围复核</strong><small>2026-03-18 16:22 · 数据管理员 · 审批通过</small></span>
<span><i></i><strong>字段映射版本更新</strong><small>2026-03-17 14:08 · ${source.technicalOwner} · ${source.schema}</small></span>
</div>
</article>
<article class="data-ops-card">
<h3>访问与留存策略</h3>
<p>敏感字段按角色脱敏,导入、导出、报告下载和权限变更均保留操作者、时间戳、前后版本哈希及追踪编号。授权撤回后立即停止后续分析使用。</p>
</article>
`;
}
function renderDataOpsUsage() {
const e = closedLoopState.entities;
return `
<div class="data-authorization-card">
<span>接收回执</span><strong>${e.receipt.id} · ${e.receipt.status}</strong>
<p>${e.snapshot.packageId} / ${e.snapshot.packageVersion} 已接收状态:${e.snapshot.status}。共享业务标识仅用于静态原型对照,不表示两页面实时同步。</p>
<div><span>SHA校验<strong>${safeText(e.receipt.receivedSha256 || e.receipt.expectedSha256)}</strong></span><span>交付包记录数<strong>${e.receipt.receivedRecords.toLocaleString("zh-CN")} / ${e.receipt.expectedRecords.toLocaleString("zh-CN")}</strong></span><span>分析快照<strong>${e.snapshot.id} · ${e.snapshot.checkedRecords.toLocaleString("zh-CN")}条</strong></span></div>
</div>
<article class="data-ops-card">
<h3>业务使用情况</h3>
<div class="data-impact-grid">
<span>报告<strong>${e.report?.id || "待生成"}</strong></span>
<span>项目<strong>${e.project?.id || "待创建"}</strong></span>
<span>任务<strong>${e.task?.id || "待创建"}</strong></span>
</div>
</article>
<article class="data-ops-card">
<h3>业务部门确认与下一周期反馈</h3>
<div class="workspace-log-list">
${e.usageFeedback
.map((item) => `<span><strong>${safeText(item.department)} · ${safeText(item.usable)} · ${safeText(item.id)}</strong><small>${safeText(item.operator)} · ${safeText(item.at)}</small><em>${safeText(item.comment)};下一周期:${safeText(item.nextCycle)}</em></span>`)
.join("") || `<span><strong>尚无业务使用反馈</strong><small>就业老师可在报告发布后登记。</small></span>`}
</div>
<div class="loop-inline-actions">
${currentRoleName() === "就业老师" ? `<button class="button primary" data-loop-action="feedback">新增使用反馈</button>` : ""}
${currentRoleName() === "校级技术管理员" && e.snapshot.status !== "已验收" ? `<button class="button primary" data-loop-action="receive">确认技术接收</button>` : ""}
</div>
</article>`;
}
function renderDataIssue(issueId) {
const issue = dataGapById(issueId);
const source = currentDataSource();
const impactCount = reportsAffectedByGap(issue).length;
return `
<div class="data-issue-hero">
<span>${issue.drawerLevel}</span><h3>${issue.drawerTitle}</h3><p>${issue.affected}</p>
</div>
<article class="data-ops-card"><h3>规则命中原因</h3><p>${issue.reason}</p></article>
<article class="data-ops-card"><h3>建议处置</h3><p>${issue.action}</p></article>
<article class="data-ops-card">
<h3>受影响分析与补数责任</h3>
<div class="data-impact-grid"><span>当前生产版本<strong>未覆盖</strong></span><span>影响报告<strong>${impactCount} 份</strong></span><span>分析快照<strong>沿用稳定版本</strong></span></div>
<dl class="data-issue-owner-list">
<div><dt>业务索取人</dt><dd>${source.stewardContact} · ${source.steward}</dd></div>
<div><dt>技术接口人</dt><dd>${source.technicalContact} · ${source.technicalOwner}</dd></div>
</dl>
</article>
`;
}
function renderAddDataSource() {
return `
<div class="data-wizard-progress"><span class="active"><b>1</b>选择方式</span><i></i><span><b>2</b>字段映射</span><i></i><span><b>3</b>质量规则</span><i></i><span><b>4</b>审批发布</span></div>
<article class="data-ops-card">
<h3>新建学校数据源</h3>
<div class="data-source-methods">
<button class="active"><strong>标准文件导入</strong><small>CSV / XLSX,适合初次版本快速接入</small></button>
<button><strong>API 接口</strong><small>JSON 增量更新,支持水位与幂等键</small></button>
<button><strong>数据库视图</strong><small>只读连接,按调度计划提取增量</small></button>
<button><strong>SFTP 文件</strong><small>按约定目录和文件命名规则接收</small></button>
</div>
<div class="data-form-grid">
<label><span>数据源名称</span><input value="教务课程与成绩数据"></label>
<label><span>数据类别</span><select><option>校级数据</option><option>企业授权数据</option></select></label>
<label><span>业务责任部门</span><input value="教务处"></label>
<label><span>技术责任部门</span><input value="学校信息中心"></label>
<label class="full"><span>初次导入数据内容</span><textarea rows="3">当前有效专业、课程、教学班、课程标准及近三学期学生成绩。</textarea></label>
</div>
</article>
<article class="data-ops-card">
<h3>初次导入交付清单</h3>
<ul><li>标准模板或只读连接信息</li><li>字段说明、主键、代码字典和样例数据</li><li>业务截止日期、覆盖范围和责任人</li><li>敏感字段清单、授权范围和留存期限</li></ul>
</article>
`;
}
function renderAuthorizationOverview() {
return `
<div class="authorization-summary-grid">
${dataOpsStat("厂商基础包", "2 项授权", "服务期内可用")}
${dataOpsStat("学校自有数据", "3 个数据源", "权属归学校")}
${dataOpsStat("企业授权", "126 家", "12 项 30 日内到期")}
</div>
<article class="data-ops-card">
<h3>授权使用矩阵</h3>
<table class="data-ops-table">
<thead><tr><th>数据类别</th><th>允许用途</th><th>可见角色</th><th>期限与退出</th></tr></thead>
<tbody>
<tr><td>厂商基础数据包</td><td>产业趋势、岗位与薪资分析</td><td>校级管理者、学院负责人</td><td>随订阅服务期,到期停止新分析</td></tr>
<tr><td>学校自有数据</td><td>专业诊断、课程和就业分析</td><td>按校内组织与最小权限</td><td>学校自主留存与删除</td></tr>
<tr><td>企业授权数据</td><td>人才需求确认、课程与项目共建</td><td>约定学院、专业和项目成员</td><td>撤权即时生效,到期自动停用</td></tr>
</tbody>
</table>
</article>
<article class="data-ops-card">
<h3>安全控制</h3>
<div class="security-control-grid"><span>租户隔离<strong>已启用</strong></span><span>敏感字段脱敏<strong>18 项规则</strong></span><span>导出审计<strong>完整留痕</strong></span><span>授权到期提醒<strong>提前 30 天</strong></span></div>
</article>
`;
}
function updateDataOpsDrawer() {
const source = currentDataSource();
const content = document.getElementById("dataOpsContent");
const tabs = document.getElementById("dataOpsTabs");
const footer = document.getElementById("dataOpsFooter");
tabs.hidden = dataOpsState.context !== "source";
if (dataOpsState.context === "batch") {
document.getElementById("dataOpsKicker").textContent = "DATA UPDATE CENTER";
document.getElementById("dataOpsTitle").textContent = "数据更新任务中心";
document.getElementById("dataOpsDescription").textContent = "先确认业务截止日期、变更内容和最低质量要求,再创建带编号的更新任务。";
content.innerHTML = `
<div class="batch-update-summary">${dataOpsStat("待更新", "2 个来源", "1个自动调度 · 1个人工导入")}${dataOpsStat("运行正常", "4 个来源", "最近24小时无失败")}${dataOpsStat("隔离记录", "86 条", "未进入生产版本")}</div>
<article class="data-ops-card">
<h3>数据源更新计划</h3>
<div class="batch-source-list">
${sources
.map(
(item) => `<label>
<input type="checkbox" ${item.category === "school" ? "checked" : ""}>
<span><strong>${item.name}</strong><small>${item.updateMode} · 业务截止 ${item.dataAsOf}</small></span>
<em class="${item.warning ? "warning" : ""}">${item.warning ? "需先处理异常" : item.nextUpdate}</em>
</label>`,
)
.join("")}
</div>
</article>
<article class="data-ops-card">
<h3>执行策略</h3>
<div class="data-form-grid"><label><span>任务类型</span><select><option>按来源执行增量更新</option><option>重新执行全量校验</option></select></label><label><span>失败策略</span><select><option>失败源隔离,其他来源继续</option><option>任一失败则停止全部任务</option></select></label><label class="full"><span>任务说明</span><textarea rows="3">本期报告生成前更新学校业务数据,基础数据包沿用当前已验收版本。</textarea></label></div>
</article>`;
footer.innerHTML = `<button class="button secondary" data-data-ops-close>取消</button><button class="button primary" data-data-ops-result="更新任务 OPS-20260322-08 已创建,可在版本与任务中追踪">创建更新任务</button>`;
return;
}
if (dataOpsState.context === "add") {
document.getElementById("dataOpsKicker").textContent = "DATA SOURCE ONBOARDING";
document.getElementById("dataOpsTitle").textContent = "新增数据源";
document.getElementById("dataOpsDescription").textContent = "支持标准模板直接导入,并可按业务需要配置自动增量连接。";
content.innerHTML = renderAddDataSource();
footer.innerHTML = `<button class="button secondary" data-data-ops-close>保存草稿</button><button class="button primary" data-data-ops-result="数据源连接测试通过,已进入字段映射">测试并进入字段映射</button>`;
return;
}
if (dataOpsState.context === "authorization") {
document.getElementById("dataOpsKicker").textContent = "DATA AUTHORIZATION";
document.getElementById("dataOpsTitle").textContent = "数据权属与授权中心";
document.getElementById("dataOpsDescription").textContent = "设置数据用途、可见范围、服务期限、撤权方式和操作记录要求。";
content.innerHTML = renderAuthorizationOverview();
footer.innerHTML = `<button class="button secondary" data-data-ops-close>关闭</button><button class="button primary" data-data-ops-result="授权台账已导出并写入审计日志">导出授权台账</button>`;
return;
}
if (dataOpsState.context === "issue") {
const issue = dataGapById(dataOpsState.issue);
const issueSource = sourceById(issue.sourceId);
if (issueSource) dataOpsState.sourceName = issueSource.name;
document.getElementById("dataOpsKicker").textContent = "DATA QUALITY WORKBENCH";
document.getElementById("dataOpsTitle").textContent = issue.drawerTitle;
document.getElementById("dataOpsDescription").textContent = "查看受影响数据、规则命中原因和可审计的处理流程。";
content.innerHTML = renderDataIssue(dataOpsState.issue);
footer.innerHTML = `<button class="button secondary" data-data-ops-result="错误明细文件已准备,可交由责任部门修复">下载错误明细</button><button class="button primary" data-data-ops-result="已创建质量整改任务,并通知责任部门">创建整改任务</button>`;
return;
}
document.getElementById("dataOpsKicker").textContent = `${source.categoryLabel.toUpperCase()} · ${source.version}`;
document.getElementById("dataOpsTitle").textContent = source.name;
document.getElementById("dataOpsDescription").textContent = `${source.provider} · ${source.coverage} · ${source.frequency}`;
const renderers = {
overview: renderDataOpsOverview,
requirements: renderDataOpsRequirements,
history: renderDataOpsHistory,
audit: renderDataOpsAudit,
usage: renderDataOpsUsage,
};
content.innerHTML = renderers[dataOpsState.tab](source);
tabs.querySelectorAll("button").forEach((button) => {
button.classList.toggle("active", button.dataset.dataOpsTab === dataOpsState.tab);
});
if (dataOpsState.tab === "usage") {
footer.innerHTML = `<button class="button secondary" data-data-ops-close>关闭</button><button class="button primary" data-view-loop>前往业务流程</button>`;
return;
}
const primaryLabel =
source.category === "vendor" ? "检查并验收新版本" : source.warning ? "进入异常处置" : "创建更新任务";
footer.innerHTML = `<button class="button secondary" data-data-ops-tab-jump="requirements">查看数据要求</button><button class="button primary" data-data-ops-result="${source.name}更新任务已创建,当前生产版本保持不变">${primaryLabel}</button>`;
}
function openDataOpsDrawer(context, value) {
dataOpsState.context = context;
dataOpsState.tab = "overview";
if (context === "source") dataOpsState.sourceName = value;
if (context === "issue") dataOpsState.issue = value;
updateDataOpsDrawer();
const drawer = document.getElementById("dataOpsDrawer");
drawer.classList.add("open");
drawer.setAttribute("aria-hidden", "false");
document.body.style.overflow = "hidden";
}
function closeDataOpsDrawer() {
const drawer = document.getElementById("dataOpsDrawer");
drawer.classList.remove("open");
drawer.setAttribute("aria-hidden", "true");
document.body.style.overflow = "";
}
function workspaceMetric(label, value, note) {
return `<span><small>${label}</small><strong>${value}</strong><em>${note}</em></span>`;
}
function openWorkspaceDetail(kind, value = "") {
const configurations = {
project: () => {
const project = projects.find((item) => item.id === value || item.title === value) || projects[0];
const loopProject = project._closedLoop ? closedLoopState.entities.project : null;
return {
kicker: "COLLABORATION PROJECT",
title: project.title,
description: project.meta,
content: `
<div class="workspace-metric-grid">${workspaceMetric("项目进度", `${project.progress}%`, "按计划推进")}${workspaceMetric("参与单位", "3 方", "学校、学院、企业")}${workspaceMetric("学生参与", "42 人", "跨专业组队")}</div>
<article class="workspace-detail-card"><h3>里程碑与交付物</h3><div class="milestone-list"><span class="done"><i></i><strong>企业任务书与验收标准确认</strong><small>已完成 · 2026-02-28</small></span><span class="done"><i></i><strong>课程任务和学生分组</strong><small>已完成 · 2026-03-08</small></span><span class="active"><i></i><strong>企业中期评审</strong><small>进行中 · 2026-04-12</small></span><span><i></i><strong>成果验收与能力评价</strong><small>计划 · 2026-05-30</small></span></div></article>
${loopProject ? `<article class="workspace-detail-card"><h3>实体关联</h3><div class="workspace-evidence-list"><span>需求<strong>${loopProject.demandId}</strong></span><span>建议<strong>${loopProject.recommendationId}</strong></span><span>任务<strong>${loopProject.taskId}</strong></span></div></article><article class="workspace-detail-card"><h3>执行证据</h3><div class="workspace-log-list">${closedLoopState.entities.evidences.map((item) => `<span><strong>${safeText(item.name)} · ${safeText(item.type)}</strong><small>${safeText(item.submitter)} · ${safeText(item.submittedAt)} · ${safeText(item.fileRef)}</small></span>`).join("") || "<span><strong>尚未提交证据</strong><small>提交后同步更新项目进度</small></span>"}</div><button class="button primary loop-inline-action" data-loop-action="evidence">提交阶段进展与证据</button></article>` : `<article class="workspace-detail-card"><h3>验收证据</h3><div class="workspace-evidence-list"><span>企业任务书<strong>已归档</strong></span><span>学生过程产物<strong>86 份</strong></span><span>企业导师评价<strong>待中期评审</strong></span></div></article>`}`,
primary: loopProject ? "关闭详情" : "更新项目进度",
};
},
task: () => {
const task = tasks.find((item) => item.id === value || item.title === value) || tasks[0];
const loopTask = task._closedLoop ? closedLoopState.entities.task : null;
return {
kicker: "IMPROVEMENT TASK",
title: task.title,
description: `${task.profession} · 来源:${task.source}`,
content: `
<div class="workspace-metric-grid">${workspaceMetric("负责人", task.owner, "主责")}${workspaceMetric("截止日期", task.due, task.statusText)}${workspaceMetric("当前进度", `${task.progress}%`, "自动汇总")}</div>
<article class="workspace-detail-card"><h3>任务目标</h3><p>根据分析建议调整课程、项目或企业合作安排,并用正式方案、企业确认和学生考核结果验收。</p></article>
<article class="workspace-detail-card"><h3>进展记录</h3><div class="workspace-log-list">${loopTask ? loopTask.progressLogs.map((item) => `<span><strong>${safeText(item.detail)}</strong><small>${safeText(item.operator)} · ${safeText(item.at)} · 进度 ${item.progress}%</small></span>`).join("") : `<span><strong>完成现状与差距复核</strong><small>李雯 · 2026-03-18 16:20</small></span><span><strong>企业导师已确认技能清单</strong><small>陈凯 · 2026-03-20 10:42</small></span><span><strong>课程调整方案进入教研室评审</strong><small>系统 · 今天 08:30</small></span>`}</div></article>
<article class="workspace-detail-card"><h3>验收要求</h3><div class="workspace-evidence-list"><span>课程或项目方案<strong>${loopTask && closedLoopState.entities.evidences.length ? "已提交" : "必需"}</strong></span><span>企业确认回执<strong>${closedLoopState.entities.evaluation ? closedLoopState.entities.evaluation.result : "必需"}</strong></span><span>校级验收<strong>${loopTask?.status || "待执行"}</strong></span></div>${loopTask ? `<button class="button primary loop-inline-action" data-loop-action="evidence">提交阶段进展与证据</button>` : ""}</article>`,
primary: loopTask ? "关闭详情" : "提交阶段进展",
};
},
demand: () => {
const demand = enterpriseDemands.find((item) => item.id === value || item.company === value) || enterpriseDemands[0];
const loopDemand = demand._closedLoop ? closedLoopState.entities.demand : null;
return {
kicker: "ENTERPRISE DEMAND",
title: demand.title,
description: `${demand.company} · ${demand.industry} · ${demand.due}`,
content: `
<article class="workspace-detail-card"><h3>需求说明</h3><p>${demand.description}</p></article>
<div class="workspace-metric-grid">${workspaceMetric("计划人数", `${loopDemand?.headcount || 24} 人`, "2026届")}${workspaceMetric("技能要求", loopDemand?.skills || "8 项", loopDemand ? loopDemand.authorizationNo : "3 项重点")}${workspaceMetric("有效期", loopDemand?.validUntil || "实训 + 实习", loopDemand?.submitter || "企业导师参与")}</div>
<article class="workspace-detail-card"><h3>学校响应方案</h3><div class="workspace-form-grid"><label><span>校内负责人</span><input value="李雯 · 专业负责人"></label><label><span>预计响应时间</span><input type="date" value="2026-04-08"></label><label class="full"><span>拟合作方案</span><textarea rows="4">先组织岗位与技能复核,随后将企业任务拆解为综合实训模块,并安排企业导师参与中期评审与成果验收。</textarea></label></div></article>`,
primary: "提交响应方案",
};
},
partner: () => {
const partner = partners.find((item) => item.name === value) || partners[0];
return {
kicker: "PARTNER ENGAGEMENT",
title: `发起与${partner.name}的合作对接`,
description: `${partner.industry} · 综合匹配度 ${partner.match}%`,
content: `
<article class="workspace-detail-card"><h3>推荐依据</h3><p>${partner.reason}</p><div class="workspace-tag-row">${partner.tags.map((tag) => `<span>${tag}</span>`).join("")}</div></article>
<article class="workspace-detail-card"><h3>对接安排</h3><div class="workspace-form-grid"><label><span>校内负责人</span><input value="产教融合中心 · 王健"></label><label><span>建议时间</span><input type="date" value="2026-04-15"></label><label class="full"><span>沟通议题</span><textarea rows="4">岗位技能确认、课程共建、企业项目进课堂、企业导师与学生实习安排。</textarea></label></div></article>`,
primary: "创建对接事项",
};
},
notifications: () => ({
kicker: "NOTIFICATION CENTER",
title: "待办与通知",
description: "聚合需要当前角色处理的业务事项。",
content: `<div class="notification-list"><button data-view-jump="actions"><i class="red"></i><span><strong>3项高优任务即将到期</strong><small>涉及软件技术、电子商务和模具设计专业</small></span><em>去处理</em></button><button data-data-issue="salary-missing"><i class="amber"></i><span><strong>就业数据质量未达发布阈值</strong><small>86条薪资记录等待责任部门补齐</small></span><em>查看</em></button><button data-view-jump="reports"><i></i><span><strong>信息工程学院报告待审核</strong><small>报告版本 V2026.03 · 今天 08:55</small></span><em>审核</em></button></div>`,
primary: "全部标记为已读",
}),
organizations: () => ({
kicker: "ORGANIZATION SCOPE",
title: "组织与数据范围",
description: "根据账号角色切换组织,数据权限同步变化。",
content: `<div class="organization-list"><button class="active"><span class="tenant-logo">杭</span><span><strong>杭州科技职业技术学院</strong><small>校级管理者 · 全校32个专业</small></span><em>当前</em></button><button><span class="tenant-logo blue">信</span><span><strong>信息工程学院</strong><small>学院负责人 · 6个专业</small></span><em>可进入</em></button><button><span class="tenant-logo amber">企</span><span><strong>海康威视合作空间</strong><small>企业导师 · 3个共建项目</small></span><em>可进入</em></button></div><article class="workspace-detail-card"><h3>权限说明</h3><p>切换组织只改变可查看的业务范围,不改变租户隔离。平台运营人员默认不能查看学校明细数据。</p></article>`,
primary: "确认当前范围",
}),
templates: () => ({
kicker: "REPORT TEMPLATE LIBRARY",
title: "报告模板库",
description: "按报告用途设置章节、数据说明和审批步骤。",
content: `<div class="template-library"><article><span>校级报告</span><strong>全校产教供需分析总报告</strong><small>学校数据、区域基准、专业结构、任务清单</small><em>42页 · 已启用</em></article><article><span>学院报告</span><strong>学院专业建设分析报告</strong><small>专业对比、共用课程、企业合作与任务跟踪</small><em>36页 · 已启用</em></article><article><span>专业报告</span><strong>专业人才需求分析报告</strong><small>岗位、技能、薪资、课程对应和企业推荐</small><em>24—30页 · 已启用</em></article><article><span>数据质量</span><strong>数据资产质量与口径报告</strong><small>来源、权属、版本、质量规则、限制和审计</small><em>19页 · 已启用</em></article></div>`,
primary: "新建模板版本",
}),
compare: () => ({
kicker: "PROGRAM COMPARISON",
title: "专业对比篮",
description: "最多选择4个专业,比较供需、技能、薪资与改进优先级。",
content: `<div class="compare-table-wrap"><table class="data-ops-table"><thead><tr><th>专业</th><th>匹配度</th><th>岗位需求</th><th>薪资竞争力</th><th>技能覆盖</th><th>建议</th></tr></thead><tbody><tr><td><strong>${document.getElementById("professionTitle").textContent}</strong></td><td>84.2</td><td>2,864</td><td>1.08</td><td>76%</td><td>保持优势</td></tr><tr><td>机电一体化技术</td><td>81.7</td><td>2,146</td><td>1.05</td><td>72%</td><td>补强工业视觉</td></tr><tr><td>电子商务</td><td>74.8</td><td>1,932</td><td>0.96</td><td>68%</td><td>优先调整</td></tr></tbody></table></div><article class="workspace-detail-card"><h3>对比结论</h3><p>软件技术需求和薪资竞争力领先;机电专业需补强机器视觉;电子商务应优先响应跨境运营和数据分析能力缺口。</p></article>`,
primary: "生成专业对比报告",
}),
industry: () => ({
kicker: "INDUSTRY TREND",
title: "重点产业需求趋势",
description: "查看近90天岗位增速、样本和关联专业。",
content: `<div class="industry-detail-list">${industries.map((item, index) => `<span><b>${String(index + 1).padStart(2, "0")}</b><strong>${item.name}</strong><i><em style="width:${item.width}%"></em></i><small>+${item.growth}%</small><em>${820 + index * 173}条样本</em></span>`).join("")}</div><article class="workspace-detail-card"><h3>口径说明</h3><p>岗位增速来自近90天去重招聘信号,并由合作企业需求确认校验;只用于识别趋势,不直接解释为实际录用人数。</p></article>`,
primary: "生成产业专题报告",
}),
course: () => ({
kicker: "COURSE STANDARD",
title: "课程标准与技能映射",
description: "查看课程目标、项目任务、考核证据和调整记录。",
content: `<article class="workspace-detail-card"><h3>映射依据</h3><p>课程技能覆盖同时读取课程标准、实训任务、考核要求和学生项目产物,不因正文中出现技能名称就判定为充分覆盖。</p></article><div class="workspace-metric-grid">${workspaceMetric("课程目标", "6 项", "已结构化")}${workspaceMetric("实训任务", "4 项", "2项企业任务")}${workspaceMetric("技能标签", "12 项", "版本 V3.2")}</div><article class="workspace-detail-card"><h3>最近调整</h3><div class="workspace-log-list"><span><strong>新增企业项目验收要求</strong><small>教研室 · 2026-03-18</small></span><span><strong>更新云平台技能映射</strong><small>课程负责人 · 2026-03-12</small></span></div></article>`,
primary: "进入课程标准修订",
}),
};
const factory = configurations[kind] || configurations.task;
const config = factory();
document.getElementById("workspaceDetailKicker").textContent = config.kicker;
document.getElementById("workspaceDetailTitle").textContent = config.title;
document.getElementById("workspaceDetailDescription").textContent = config.description;
document.getElementById("workspaceDetailContent").innerHTML = config.content;
document.getElementById("workspaceDetailFooter").innerHTML = `<button class="button secondary" data-workspace-close>关闭</button><button class="button primary" data-workspace-confirm>${config.primary}</button>`;
const drawer = document.getElementById("workspaceDetailDrawer");
drawer.classList.add("open");
drawer.setAttribute("aria-hidden", "false");
document.body.style.overflow = "hidden";
}
function closeWorkspaceDetail() {
const drawer = document.getElementById("workspaceDetailDrawer");
drawer.classList.remove("open");
drawer.setAttribute("aria-hidden", "true");
document.body.style.overflow = "";
}
function renderSources(filter = "all") {
const filteredSources = filter === "all" ? sources : sources.filter((source) => source.category === filter);
const iconByCategory = {
vendor: "package",
school: "school",
enterprise: "building",
};
const commercialClass = {
vendor: "paid",
school: "owned",
enterprise: "authorized",
};
document.getElementById("sourceGrid").innerHTML = filteredSources
.map(
(source) => `
<article class="source-card ${source.category}">
<div class="source-card-tags">
<span class="source-category-badge ${source.category}">${source.categoryLabel}</span>
<span class="source-commercial-badge ${commercialClass[source.category]}">${source.commercial}</span>
</div>
<div class="source-card-head">
<span class="source-icon ${source.category}">
${svgIcon(iconByCategory[source.category])}
</span>
<span class="source-name"><strong>${source.name}</strong><small>${source.type}</small></span>
<span class="source-status ${source.warning ? "warning" : ""}">${source.status}</span>
</div>
<div class="source-license-grid">
<span>数据提供方<strong>${source.provider}</strong></span>
<span>权属与授权<strong>${source.rights}</strong></span>
<span>覆盖范围<strong>${source.coverage}</strong></span>
<span>更新频率<strong>${source.frequency}</strong></span>
</div>
<div class="source-card-meta">
<span>有效记录<strong>${source.records}</strong></span>
<span>质量评分<strong>${source.quality}</strong></span>
</div>
<div class="source-card-foot">
<span>业务截止:${source.dataAsOf} · 入库:${source.updated}</span>
<span class="source-card-actions">
<button data-source-detail="${source.name}">资产详情</button>
<button data-sync-source="${source.name}">${source.action}</button>
</span>
</div>
</article>
`,
)
.join("");
document.getElementById("sourceFilterResult").textContent = `当前展示 ${filteredSources.length} 个数据源`;
document.querySelectorAll("[data-sync-source]").forEach((button) => {
button.addEventListener("click", () => {
const source = sources.find((item) => item.name === button.dataset.syncSource);
dataOpsState.sourceName = source.name;
dataOpsState.tab = source.warning ? "overview" : "history";
openDataOpsDrawer("source", source.name);
dataOpsState.tab = source.warning ? "overview" : "history";
updateDataOpsDrawer();
});
});
}
const reportState = {
type: "all",
period: "all",
search: "",
limit: 6,
};
function reportsForCurrentRole() {
const role = document.getElementById("roleSelect")?.value || "校级管理者";
return reports.filter((report) => {
if (report.roles.includes(role)) return true;
if (role === "专业负责人") return report.roles.includes("学院负责人") && report.type === "profession";
if (role === "就业老师") return report.roles.includes("校级管理者") && ["school", "quality"].includes(report.type);
return false;
});
}
function reportIcon(type) {
const icons = {
school: "school",
college: "building",
profession: "file",
industry: "cloud",
quality: "database",
};
return svgIcon(icons[type] || "file");
}
function visibleReports() {
const keyword = reportState.search.trim().toLowerCase();
return reportsForCurrentRole().filter((report) => {
const matchesType = reportState.type === "all" || report.type === reportState.type;
const matchesPeriod = reportState.period === "all" || report.periodKey === reportState.period;
const searchable = `${report.title}${report.scope}${report.author}`.toLowerCase();
return matchesType && matchesPeriod && searchable.includes(keyword);
});
}
function renderFeaturedReports(roleReports) {
const featuredReports = roleReports.filter((report) => report.featured).slice(0, 2);
const fallbackReports = roleReports.slice(0, 2);
const selected = featuredReports.length >= 2 ? featuredReports : fallbackReports;
document.getElementById("featuredReports").innerHTML = selected
.map(
(report, index) => `
<article class="featured-report-card ${index === 1 ? "secondary" : ""}">
<div class="featured-report-copy">
<span><span class="report-featured-label">${index === 0 ? "本期重点报告" : "推荐阅读"}</span><span class="status-badge ${report.statusClass}">${report.status}</span></span>
<h2>${report.title}</h2>
<p>${report.summary}</p>
<span class="featured-report-meta">
<span>${report.scope}</span><span>${report.pages}页</span><span>${report.updated}</span>
</span>
<span class="featured-report-actions">
<button data-report-open="${report.id}">在线阅读</button>
<button data-report-download="${report.id}">下载报告</button>
</span>
</div>
<div class="featured-report-cover" aria-hidden="true">
<small>${report.typeLabel.toUpperCase()}</small>
<strong>${report.title}</strong>
<div class="featured-report-mini">
${report.bars
.slice(0, 5)
.map((bar, barIndex) => `<i style="height:${Math.max(28, Math.min(bar.value, 96))}%;--bar-index:${barIndex}"></i>`)
.join("")}
<b></b>
</div>
<span>${report.version} · ${report.period}</span>
</div>
</article>
`,
)
.join("");
}
function renderReportList() {
const filtered = visibleReports();
const visible = filtered.slice(0, reportState.limit);
const list = document.getElementById("reportList");
list.innerHTML = visible
.map(
(report) => `
<button class="report-list-item" data-report-open="${report.id}">
<span class="report-list-icon ${report.type}">${reportIcon(report.type)}</span>
<span class="report-list-copy">
<strong>${report.title}</strong>
<small>${report.scope}</small>
<span class="report-list-meta"><span>${report.period}</span><span>${report.pages}页</span><span>${report.author}</span></span>
</span>
<span class="report-list-side">
<em class="${report.statusClass}">${report.status}</em>
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m9 18 6-6-6-6" /></svg>
</span>
</button>
`,
)
.join("");
if (!visible.length) {
list.innerHTML = `
<div class="report-empty-state">
${svgIcon("file")}
<strong>没有找到符合条件的报告</strong>
<small>可以调整报告类型、周期或搜索关键词。</small>
</div>
`;
}
document.getElementById("reportListSummary").textContent = `共 ${filtered.length} 份报告,当前显示 ${visible.length} 份`;
const loadMore = document.getElementById("loadMoreReports");
loadMore.hidden = visible.length >= filtered.length;
}
function renderReportCenter() {
const roleReports = reportsForCurrentRole();
const role = document.getElementById("roleSelect")?.value || "校级管理者";
const descriptions = {
校级管理者: "查看和管理校级、学院、专业、产业与数据质量报告,并保留各版本。",
学院负责人: "查看本院、所属专业及相关数据质量报告,并根据分析结论安排专业建设任务。",
专业负责人: "查看本专业报告、提交业务流程报告审核并追踪审批历史;校级批准与发布由校级管理者执行。",
就业老师: "查看校级与数据质量报告,登记就业侧使用反馈;不能审批校级业务报告。",
校级技术管理员: "查看数据质量、版本、口径和授权报告,并检查每个分析版本的数据来源和处理记录。",
};
document.getElementById("reportCenterDescription").textContent =
descriptions[role] || "查看和管理分析报告,并保留各版本。";
document.getElementById("reportTotalCount").textContent = roleReports.length;
document.getElementById("reportNewCount").textContent = roleReports.filter((item) => item.periodKey === "current").length;
document.getElementById("reportReviewCount").textContent = roleReports.filter((item) => item.status === "待审核").length;
document.getElementById("reportSearch").value = reportState.search;
document.getElementById("reportPeriodFilter").value = reportState.period;
document.querySelectorAll("#reportTypeFilters [data-report-filter]").forEach((button) => {
button.classList.toggle("active", button.dataset.reportFilter === reportState.type);
});
renderFeaturedReports(roleReports);
renderReportList();
}
function reportSectionHeading(index, english, title) {
return `
<div class="report-section-heading">
<span>${String(index).padStart(2, "0")}</span>
<div><small>${english}</small><h2>${title}</h2></div>
</div>
`;
}
function renderReportDocument(report, options = {}) {
const interactive = options.interactive !== false;
const evidence = window.ReportVisuals.buildEvidence(report);
const findingEvidenceCodes = [
evidence.evidenceCodes.trend,
evidence.evidenceCodes.matrix,
evidence.evidenceCodes.quadrant,
];
return `
<section class="report-cover-page" id="report-section-cover">
<div class="report-cover-brand"><i></i>产教融智 · INDUSTRY EDUCATION</div>
<div class="report-cover-main">
<span>${report.typeLabel} · ${report.period}</span>
<h1>${report.title}</h1>
<p>${report.scope}。报告使用同一数据快照生成,每项结论都注明来源、样本量和统计口径。</p>
<mark class="report-standard-stamp">STANDARD REPORT · 可查看数据来源、统计口径和结论依据</mark>
</div>
<div class="report-cover-foot">
<span>编制单位<strong>${report.author}</strong></span>
<span>分析版本<strong>${report.version} · 快照 ${evidence.snapshot.id}</strong></span>
</div>
</section>
<section class="report-section" id="report-section-summary">
${reportSectionHeading(1, "EXECUTIVE SUMMARY", "执行摘要")}
<div class="report-summary-callout">${report.summary}</div>
<div class="report-metric-grid">
${report.metrics
.map(
(metric) => `
<div><span>${metric.label}</span><strong>${metric.value}</strong><small>${metric.note}</small></div>
`,
)
.join("")}
</div>
<div class="report-benchmark-strip">
<span><small>本校 / 本专业</small><strong>${evidence.score.toFixed(1)}</strong></span>
<span><small>区域院校均值</small><strong>${evidence.benchmark.regional.toFixed(1)}</strong><em>${(evidence.score - evidence.benchmark.regional).toFixed(1)}分</em></span>
<span><small>同类院校均值</small><strong>${evidence.benchmark.peer.toFixed(1)}</strong><em>${(evidence.score - evidence.benchmark.peer).toFixed(1)}分</em></span>
<span><small>区域可比排名</small><strong>${evidence.benchmark.rank}/${evidence.benchmark.schoolCount}</strong><em>样本口径一致</em></span>
</div>
${window.ReportVisuals.renderExecutiveCharts(report, { interactive })}
<div class="report-interpretation">
<span>综合解读</span>
<p>本期${evidence.scoreLabel}为<strong>${evidence.score.toFixed(1)}分</strong>,高于区域可比均值<strong>${(evidence.score - evidence.benchmark.regional).toFixed(1)}分</strong>,高于同类院校均值<strong>${(evidence.score - evidence.benchmark.peer).toFixed(1)}分</strong>。计算使用${evidence.benchmark.sampleText}和同一数据快照;低样本指标已降低权重,互联网招聘数量不直接当作人才缺口人数。</p>
</div>
</section>
<section class="report-section" id="report-section-profile">
${reportSectionHeading(2, "INSTITUTION PROFILE", report.type === "profession" ? "专业与学校现状" : report.type === "college" ? "学院与学校现状" : "学校现状")}
${window.ReportVisuals.renderProfileEvidence(report, { interactive })}
<div class="report-interpretation">
<span>学校现状判断</span>
<p>学校现状数据来自学籍、教务、人事、实习就业和校企合作台账。当前全校专业、课程和企业合作规模能够满足常规人才培养需要,但企业项目实训、企业导师评价和课程技能对应关系仍不完整。每项指标都可查看来源系统、更新时间、责任部门和计算口径。</p>
</div>
</section>
<section class="report-section" id="report-section-findings">
${reportSectionHeading(3, "KEY FINDINGS", "关键发现与证据定位")}
<div class="report-finding-list">
${report.findings
.map(
(finding, index) => `
<div class="report-finding">
<span>${String(index + 1).padStart(2, "0")}</span>
<span>
<strong>${finding.title}</strong>
<small>${finding.detail}</small>
<small class="report-finding-proof">证据定位:${findingEvidenceCodes[index % findingEvidenceCodes.length]} · ${evidence.sources[index % evidence.sources.length].code} · 有效样本 ${evidence.sources[index % evidence.sources.length].sample}</small>
</span>
<em>${finding.value}</em>
</div>
`,
)
.join("")}
</div>
<div class="report-finding-validation">
<strong>结论交叉验证规则</strong>
<span>外部岗位变化</span><i>+</i><span>校内培养数据</span><i>+</i><span>企业确认需求</span><i>→</i><span>业务负责人复核</span>
</div>
</section>
<section class="report-section" id="report-section-analysis">
${reportSectionHeading(4, "SUPPLY & DEMAND", "供需结构与技能缺口分析")}
${window.ReportVisuals.renderSupplyVisuals(report, { interactive })}
<h3 class="report-subsection-title">表4-1 核心维度可比数据</h3>
<div class="report-table-wrap">
<table class="report-table report-comparison-table">
<thead><tr><th>分析维度</th><th>本校/本专业</th><th>区域均值</th><th>同类均值</th><th>较区域</th><th>需求强度</th><th>课程覆盖</th><th>证据状态</th></tr></thead>
<tbody>
${evidence.dimensions
.map(
(item) => `
<tr>
<td><strong>${item.name}</strong></td>
<td>${item.school.toFixed(1)}</td>
<td>${item.regional.toFixed(1)}</td>
<td>${item.peer.toFixed(1)}</td>
<td class="${item.school >= item.regional ? "report-positive" : "report-negative"}">${item.school >= item.regional ? "+" : ""}${(item.school - item.regional).toFixed(1)}</td>
<td>${item.demand.toFixed(0)}</td>
<td>${item.coverage.toFixed(0)}</td>
<td><span class="report-confidence">已交叉验证</span></td>
</tr>
`,
)
.join("")}
</tbody>
</table>
</div>
<div class="report-method-note">
图4-1至图4-4使用同一分析快照。需求强度、课程覆盖、学生达成和企业确认均为0—100标准分,用于同口径横向比较,不代表绝对岗位人数或学生人数。三维图用于同时观察四类证据,并非独立的3D业务流程。
</div>
<div class="report-interpretation">
<span>结构性结论</span>
<p>高需求但课程覆盖不足的能力位于象限图左上区域,应优先加入专业建设清单;高需求且覆盖充分的能力需要检查学生掌握情况和企业评价。趋势图显示课程调整仍慢于岗位需求变化,建议增加企业项目和企业验收,不要只增加课程名称。</p>
</div>
</section>
<section class="report-section" id="report-section-evidence">
${reportSectionHeading(5, "DATA EVIDENCE", "样本与数据来源")}
${window.ReportVisuals.renderEvidenceLedger(report)}
</section>
<section class="report-section" id="report-section-method">
${reportSectionHeading(6, "METHODOLOGY", "指标口径、分析方法与限制")}
<div class="report-method-steps">
<article><span>01</span><strong>数据采集</strong><p>采集近90天岗位、校内主数据、课程标准、实习就业回执和合作企业确认记录,保留权属与授权标签。</p></article>
<article><span>02</span><strong>清洗归一</strong><p>按岗位ID、企业、发布时间去重;专业依据教育部专业代码归一;技能使用版本化词典映射。</p></article>
<article><span>03</span><strong>选择对比样本</strong><p>只纳入同层次、同区域且有效样本达到100条的院校;同类院校按专业结构、规模和产业方向选择。</p></article>
<article><span>04</span><strong>相互核对</strong><p>外部岗位、校内数据和企业确认中至少两类结果一致,才列为较可靠结论;AI草拟文字必须人工审核。</p></article>
</div>
<div class="report-table-wrap">
<table class="report-table">
<thead><tr><th>指标</th><th>计算口径</th><th>样本与置信规则</th><th>使用限制</th></tr></thead>
<tbody>
<tr><td>供需匹配度</td><td>岗位方向40% + 技能覆盖35% + 薪资竞争力15% + 企业确认10%</td><td>四类分项均有样本;缺项按规则降权,不直接补均值</td><td>用于识别建设方向,不用于直接决定招生规模</td></tr>
<tr><td>技能缺口</td><td>需求强度-课程覆盖-学生达成修正值</td><td>岗位样本≥100且课程映射覆盖≥80%为中高置信</td><td>标准分差值不能解释为缺口人数</td></tr>
<tr><td>岗位需求</td><td>近90天有效招聘记录去重并按企业、时效和重复发布降权</td><td>77,520条岗位记录;126家企业确认用于校验</td><td>招聘信号不等于实际录用人数</td></tr>
<tr><td>课程技能覆盖</td><td>课程标准、实训任务和考核要求与技能词典映射后的加权覆盖</td><td>1,286门次课程;映射覆盖率94.8%</td><td>有文本描述不等于学生实际达成</td></tr>
<tr><td>学生达成</td><td>课程考核、项目产物、企业评价和证书证据的综合达成度</td><td>缺少企业评价的项目仅计校内证据,不做强外推</td><td>不同专业考核难度不可直接比较绝对分数</td></tr>
<tr><td>薪资竞争力</td><td>校内就业薪资中位数 / 区域同岗位中位数</td><td>完整薪资样本6,284条;异常和缺失记录已剔除或降权</td><td>受地区、岗位层级和企业规模影响</td></tr>
</tbody>
</table>
</div>
<div class="report-method-note">
本报告用于讨论专业建设和校企合作。AI只整理数据和草拟文字,不修改原始数据,也不删除相反结果。业务负责人仍需结合政策、办学定位和资源情况审核;报告不能替代招生、专业撤并或课程调整的正式决策程序。
</div>
</section>
<section class="report-section" id="report-section-actions">
${reportSectionHeading(7, "ACTION PLAN", "行动建议与验收证据")}
<div class="report-action-list">
${report.actions
.map(
(action, index) => `
<div class="report-action-card">
<span class="report-action-priority">${action.priority}</span>
<span>
<strong>${action.title}</strong>
<small>${action.detail}</small>
<small class="report-action-evidence">建议依据:${findingEvidenceCodes[index % findingEvidenceCodes.length]} · 验收要求:课程/项目方案、企业确认回执、学生达成记录至少两项</small>
</span>
<button data-report-action-create>加入改进任务</button>
</div>
`,
)
.join("")}
</div>
<div class="report-action-cycle">
<span><strong>责任确认</strong><small>5个工作日内</small></span>
<i></i>
<span><strong>方案与资源</strong><small>提交可按验收要求检查的成果</small></span>
<i></i>
<span><strong>阶段复核</strong><small>按月更新证据</small></span>
<i></i>
<span><strong>效果评估</strong><small>下一周期回看指标</small></span>
</div>
</section>
`;
}
function renderReportApprovalToolbar(report) {
const toolbar = document.getElementById("reportApprovalToolbar");
const loopReport = closedLoopState.entities.report;
if (!loopReport || report.id !== loopReport.id) {
toolbar.innerHTML = `<span><strong>${safeText(report.status)}</strong><small>历史报告按原有发布状态展示。</small></span>`;
return;
}
const official = loopReport.status === "已发布";
const actions = [];
if (["草稿", "已退回"].includes(loopReport.status) && ["专业负责人", "学院负责人", "校级管理者"].includes(currentRoleName())) {
actions.push(`<button class="button secondary" data-loop-action="reportSubmit">提交审核</button>`);
}
if (loopReport.status === "待审核" && currentRoleName() === "校级管理者") {
actions.push(`<button class="button primary" data-loop-action="reportReview">审批报告</button>`);
}
if (loopReport.status === "已批准" && currentRoleName() === "校级管理者") {
actions.push(`<button class="button primary" data-loop-action="reportPublish">正式发布</button>`);
}
toolbar.innerHTML = `
<span class="report-flow-state ${official ? "official" : "draft"}"><strong>${loopReport.status} · ${official ? "正式版" : "非正式版"}</strong><small>${loopReport.id} · 发布前打印、下载和导出均标识非正式版</small></span>
<span class="report-link-summary">关联 ${loopReport.snapshotId} / ${loopReport.demandId} / ${loopReport.taskId} / ${loopReport.evidenceIds.length}项证据</span>
<details><summary>审批历史 ${loopReport.history.length} 条</summary><div>${loopReport.history
.map((item) => `<span><strong>${safeText(item.status)}</strong><small>${safeText(item.operator)} · ${safeText(item.at)}</small><em>${safeText(item.comment)}</em></span>`)
.join("")}</div></details>
<div class="report-flow-actions">${actions.join("")}</div>`;
}
function openReportReader(reportId) {
const report = reports.find((item) => item.id === reportId);
if (!report) return;
const reader = document.getElementById("reportReader");
const tocItems = [
["cover", "封面"],
["summary", "执行摘要"],
["profile", "学校现状"],
["findings", "关键发现"],
["analysis", "供需结构"],
["evidence", "数据来源"],
["method", "指标口径"],
["actions", "行动建议"],
];
window.ReportVisuals.dispose(document.getElementById("reportDocument"));
reader.dataset.reportId = report.id;
document.getElementById("reportReaderTitle").textContent = report.title;
document.getElementById("reportReaderVersion").textContent = report.version;
renderReportApprovalToolbar(report);
document.getElementById("reportDocument").innerHTML = renderReportDocument(report);
document.getElementById("reportToc").innerHTML = tocItems
.map(
([key, label], index) =>
`<button class="${index === 0 ? "active" : ""}" data-report-section="${key}"><span>${String(index).padStart(2, "0")}</span>${label}</button>`,
)
.join("");
reader.classList.add("open");
reader.setAttribute("aria-hidden", "false");
document.body.style.overflow = "hidden";
document.querySelector(".report-document-wrap").scrollTop = 0;
requestAnimationFrame(() => window.ReportVisuals.init(report, document.getElementById("reportDocument")));
}
function closeReportReader() {
const reader = document.getElementById("reportReader");
window.ReportVisuals.dispose(document.getElementById("reportDocument"));
reader.classList.remove("open");
reader.setAttribute("aria-hidden", "true");
document.body.style.overflow = "";
}
function reportStandaloneStyles() {
return `
:root{--green:#176b4f;--green2:#2f8f6b;--lime:#c6e66f;--ink:#27362f;--muted:#718078;--line:#dfe7e1;--soft:#f4f7f4}
*{box-sizing:border-box}body{margin:0;background:#eef1ed;color:var(--ink);font-family:"PingFang SC","Microsoft YaHei",sans-serif;font-size:14px;line-height:1.7}
.report-document{max-width:1040px;margin:30px auto;background:white;box-shadow:0 16px 50px #20362b1a}
.report-cover-page{min-height:570px;padding:68px 70px 56px;color:white;background:radial-gradient(circle at 90% 100%,#244f3e 0 18%,transparent 19%),linear-gradient(155deg,#184638,#0f2c24);display:flex;flex-direction:column;justify-content:space-between}
.report-cover-brand{display:flex;align-items:center;gap:12px;color:var(--lime);font-weight:700;letter-spacing:.1em}.report-cover-brand i{display:inline-block;width:34px;height:4px;background:var(--lime)}
.report-cover-main span{color:#9bb4a9;font-size:14px}.report-cover-main h1{max-width:700px;margin:18px 0 0;font-family:"Songti SC","STSong",serif;font-size:42px;line-height:1.35}.report-cover-main p{max-width:620px;color:#aac0b7;font-size:16px}
.report-standard-stamp{display:inline-flex;margin-top:16px;padding:7px 10px;color:#dce8b9;background:#c6e66f14;border:1px solid #c6e66f40;border-radius:7px;font-size:13px;font-weight:700}
.report-cover-foot{display:grid;grid-template-columns:1fr 1fr;gap:16px;padding-top:24px;border-top:1px solid #ffffff22}.report-cover-foot span,.report-cover-foot strong{display:block}.report-cover-foot span{color:#829f93}.report-cover-foot strong{margin-top:6px;color:white}
.report-section{padding:50px 60px;border-bottom:1px solid #e5eae6}.report-section-heading{display:flex;gap:16px;margin-bottom:24px}.report-section-heading>span{display:grid;width:36px;height:36px;flex:0 0 auto;place-items:center;color:var(--green);background:#e8f4ee;border-radius:10px;font-weight:800}.report-section-heading small{color:var(--green2);font-size:11px;font-weight:700;letter-spacing:.1em}.report-section-heading h2{margin:2px 0 0;font-size:24px}
.report-summary-callout{padding:20px;background:#eff7f2;border-left:4px solid var(--green);border-radius:0 12px 12px 0;font-size:15px}
.report-metric-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin-top:20px}.report-metric-grid div{padding:16px;background:#f8faf8;border:1px solid #e5eae6;border-radius:11px}.report-metric-grid span,.report-metric-grid strong,.report-metric-grid small{display:block}.report-metric-grid span{color:var(--muted);font-size:11px}.report-metric-grid strong{margin:8px 0 5px;font-size:23px}.report-metric-grid small{color:var(--green2);font-size:11px}
.report-benchmark-strip{display:grid;grid-template-columns:repeat(4,1fr);margin-top:12px;overflow:hidden;color:white;background:#153d31;border-radius:12px}.report-benchmark-strip>span{padding:15px;border-right:1px solid #ffffff1a}.report-benchmark-strip small,.report-benchmark-strip strong,.report-benchmark-strip em{display:block}.report-benchmark-strip small{color:#a9beb5;font-size:11px}.report-benchmark-strip strong{margin:7px 0 4px;font-size:22px}.report-benchmark-strip em{color:var(--lime);font-size:11px;font-style:normal}
.report-chart-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;margin-top:22px}.report-chart-card{min-width:0;margin:0;overflow:hidden;border:1px solid var(--line);border-radius:14px;box-shadow:0 7px 24px #1e3c2f0f}.report-chart-head{display:flex;align-items:center;gap:9px;padding:14px 15px 11px;border-bottom:1px solid #edf1ee}.report-chart-head>span{padding:4px 6px;color:var(--green);background:#e8f4ee;border-radius:5px;font-size:11px;font-weight:800}.report-chart-head strong{font-size:13px}.report-chart-stage{height:310px;overflow:hidden;background:white}.report-chart-fallback{display:grid;width:100%;height:100%;place-items:center;padding:6px}.report-chart-fallback svg{display:block;width:100%;height:100%}.report-chart-card figcaption{min-height:83px;padding:12px 15px;color:#65736c;background:#f8faf8;border-top:1px solid #edf1ee;font-size:11px}.report-evidence-code{display:inline-flex;margin-right:5px;padding:3px 6px;color:var(--green);background:#e8f4ee;border-radius:5px;font-size:11px;font-weight:800}
.report-interpretation{display:grid;grid-template-columns:84px 1fr;gap:15px;margin-top:16px;padding:17px 18px;background:linear-gradient(100deg,#edf7f1,#f8fbf7);border:1px solid #dbeadf;border-radius:12px}.report-interpretation>span{align-self:start;padding:5px 8px;color:white;background:var(--green);border-radius:6px;font-size:11px;font-weight:800;text-align:center}.report-interpretation p{margin:0;font-size:13px}.report-interpretation strong{color:var(--green)}
.report-profile-banner{display:grid;grid-template-columns:1.2fr .8fr;gap:20px;padding:22px;color:#e7f0ec;background:radial-gradient(circle at 90% 0,#c6e66f24,transparent 35%),linear-gradient(135deg,#173f33,#102e26);border-radius:15px}.report-profile-kicker{color:var(--lime);font-size:11px;font-weight:800}.report-profile-banner h3{margin:9px 0 8px;color:white;font-size:19px}.report-profile-banner p{margin:0;color:#a9bdb5;font-size:12px}.report-profile-attributes{display:grid;grid-template-columns:1fr 1fr;gap:8px}.report-profile-attributes span{padding:10px;color:#8da79c;background:#ffffff0e;border:1px solid #ffffff12;border-radius:9px;font-size:11px}.report-profile-attributes strong{display:block;margin-top:5px;color:#eaf2ee;font-size:11px}
.report-profile-stat-grid{display:grid;grid-template-columns:repeat(4,1fr);align-items:start;gap:10px;margin-top:13px}.report-profile-stat-grid article{padding:14px;background:#fafbf9;border:1px solid #e5eae6;border-radius:11px}.report-profile-code,.report-profile-label,.report-profile-stat-grid article>strong,.report-profile-stat-grid article>em{display:block}.report-profile-code{color:#84928a;font-size:11px;font-weight:700}.report-profile-label{margin-top:9px;color:var(--muted);font-size:11px}.report-profile-stat-grid article>strong{margin:5px 0 4px;font-size:23px}.report-profile-stat-grid article>strong small{margin-left:3px;color:var(--muted);font-size:11px}.report-profile-stat-grid article>em{min-height:32px;color:var(--green2);font-size:11px;font-style:normal}.report-profile-stat-grid details{margin-top:9px;padding-top:8px;border-top:1px solid #e8ede9}.report-profile-stat-grid summary{color:var(--green);font-size:11px;font-weight:700;cursor:pointer}.report-profile-stat-grid details>div{margin-top:8px;padding:9px;color:var(--muted);background:white;border:1px solid #e8ede9;border-radius:8px;font-size:11px}.report-profile-stat-grid details span,.report-profile-stat-grid details strong{display:block}.report-profile-stat-grid details span+span{margin-top:6px}.report-profile-stat-grid details strong{color:var(--ink)}.report-profile-stat-grid details p{padding-top:8px;border-top:1px dashed var(--line)}
.report-finding-list,.report-action-list{display:flex;flex-direction:column;gap:11px}.report-finding,.report-action-card{display:grid;grid-template-columns:36px 1fr auto;align-items:start;gap:13px;padding:15px;background:#fafbf9;border:1px solid #e5eae6;border-radius:11px}.report-finding>span:first-child,.report-action-priority{display:grid;width:36px;height:36px;place-items:center;color:var(--green);background:#e8f4ee;border-radius:9px;font-weight:800}.report-finding strong,.report-finding small,.report-action-card strong,.report-action-card small{display:block}.report-finding small,.report-action-card small{color:var(--muted);font-size:12px}.report-finding-proof,.report-action-evidence{margin-top:7px;color:var(--green2)!important;font-size:11px!important;font-weight:600}.report-finding em{color:var(--green);font-style:normal;font-weight:800}.report-finding-validation{display:flex;align-items:center;justify-content:center;gap:8px;flex-wrap:wrap;margin-top:15px;padding:13px;background:#f5f8f5;border:1px dashed #d4ded7;border-radius:10px;font-size:11px}.report-finding-validation span{padding:5px 7px;color:var(--green);background:white;border:1px solid #dbe6df;border-radius:6px;font-weight:700}
.report-table-wrap{max-width:100%;overflow-x:auto;border:1px solid #e5eae6;border-radius:10px}.report-table{width:100%;border-collapse:collapse}.report-table th{padding:11px 10px;color:var(--muted);background:#f4f7f4;border-bottom:1px solid var(--line);font-size:12px;text-align:left}.report-table td{padding:13px 10px;border-bottom:1px solid #e5eae6;font-size:13px}.report-comparison-table{min-width:760px}.report-evidence-table{min-width:860px}.report-source-tag,.report-confidence{display:inline-flex;padding:4px 7px;color:var(--green);background:#e8f4ee;border-radius:6px;font-size:11px;font-weight:700}.report-evidence-table td:nth-child(2) small{display:block;margin-top:5px;color:var(--muted);font-size:11px}.report-positive{color:var(--green);font-weight:700}.report-negative{color:#b45249;font-weight:700}.report-subsection-title{margin:28px 0 12px;font-size:15px}
.report-evidence-summary{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin-bottom:14px}.report-evidence-summary span{padding:13px;background:#f7faf7;border:1px solid #e5eae6;border-radius:10px}.report-evidence-summary small,.report-evidence-summary strong{display:block}.report-evidence-summary small{color:var(--muted);font-size:11px}.report-evidence-summary strong{margin-top:6px;font-size:14px}.report-proof-chain{display:grid;grid-template-columns:1fr 24px 1fr 24px 1fr 24px 1fr;align-items:center;gap:4px;margin-bottom:15px;padding:14px;background:#173e32;border-radius:12px}.report-proof-chain>span{text-align:center}.report-proof-chain i,.report-proof-chain strong,.report-proof-chain small{display:block}.report-proof-chain i{width:27px;height:27px;margin:0 auto 6px;padding-top:5px;color:#173e32;background:var(--lime);border-radius:8px;font-size:11px;font-style:normal;font-weight:800}.report-proof-chain strong{color:white;font-size:11px}.report-proof-chain small{color:#92aaa0;font-size:11px}.report-proof-chain>b{height:1px;background:#668078}.report-evidence-notice{margin-top:14px;padding:15px 17px;color:#6f5220;background:#fff9ed;border:1px solid #eee0bd;border-radius:10px}.report-evidence-notice strong{display:block}.report-evidence-notice p{margin:5px 0 0;font-size:11px}
.report-method-steps{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:16px}.report-method-steps article{display:grid;grid-template-columns:34px 1fr;gap:3px 11px;padding:14px;background:#fafbf9;border:1px solid #e5eae6;border-radius:10px}.report-method-steps article>span{display:grid;width:34px;height:34px;grid-row:1/3;place-items:center;color:var(--green);background:#e8f4ee;border-radius:9px;font-size:11px;font-weight:800}.report-method-steps strong{font-size:12px}.report-method-steps p{margin:2px 0 0;color:var(--muted);font-size:11px}.report-method-note{margin-top:18px;padding:15px;background:#f7f8f5;border:1px dashed #ced8d1;border-radius:10px;font-size:12px}
.report-action-priority{color:#a14238;background:#fff0ed}.report-action-card button{display:none}.report-action-cycle{display:grid;grid-template-columns:repeat(7,auto);align-items:center;justify-content:center;gap:10px;margin-top:17px;padding:15px;color:#dcebe4;background:#173e32;border-radius:11px}.report-action-cycle span,.report-action-cycle strong,.report-action-cycle small{display:block}.report-action-cycle strong{font-size:11px}.report-action-cycle small{color:#91aaa0;font-size:11px}.report-action-cycle i{width:28px;height:1px;background:var(--lime)}
.report-file-note{max-width:1040px;margin:0 auto 30px;color:var(--muted);text-align:center;font-size:13px}
.report-document small,.report-document summary,.report-document .report-source-tag,.report-document .report-confidence,.report-document .report-evidence-code,.report-document .report-profile-code,.report-document .report-profile-label,.report-document .report-action-priority,.report-document .report-standard-stamp{font-size:13px!important}
.report-document p,.report-document li,.report-document td,.report-document figcaption,.report-document .report-finding small,.report-document .report-action-card small{font-size:14px!important}
.report-document th,.report-document svg text{font-size:13px!important}
@media(max-width:700px){body{background:white}.report-document{margin:0;box-shadow:none}.report-cover-page{min-height:480px;padding:44px 25px}.report-cover-main h1{font-size:30px}.report-section{padding:32px 20px}.report-metric-grid,.report-benchmark-strip,.report-evidence-summary,.report-profile-stat-grid{grid-template-columns:1fr 1fr}.report-chart-grid,.report-profile-banner,.report-method-steps{grid-template-columns:1fr}.report-profile-attributes{grid-template-columns:1fr 1fr}.report-cover-foot{grid-template-columns:1fr}.report-finding,.report-action-card{grid-template-columns:36px 1fr}.report-finding em{grid-column:2}.report-interpretation{grid-template-columns:1fr}.report-proof-chain,.report-action-cycle{grid-template-columns:1fr}.report-proof-chain>b,.report-action-cycle i{width:1px;height:14px;justify-self:center}.report-chart-card figcaption{min-height:0}}
@media print{body{background:white}.report-document{max-width:none;margin:0;box-shadow:none}.report-cover-page{break-after:page}.report-section{break-before:page}.report-chart-card,.report-profile-stat-grid article,.report-finding,.report-action-card{break-inside:avoid}.report-file-note{display:none}}
`;
}
function downloadReportSample(reportId) {
const report = reports.find((item) => item.id === reportId);
if (!report) return;
const isClosedLoopReport = closedLoopState.entities.report?.id === reportId;
const official = !isClosedLoopReport || closedLoopState.entities.report.status === "已发布";
const editionLabel = official ? "正式发布版" : "非正式版 · 仅供审核";
const content = `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>${report.title}</title>
<style>${reportStandaloneStyles()}</style>
</head>
<body>
<p class="report-file-note"><strong>${editionLabel}</strong>${isClosedLoopReport ? ` · ${report.id} · ${closedLoopState.entities.report.status}` : ""}</p>
<article class="report-document">${renderReportDocument(report, { interactive: false })}</article>
<p class="report-file-note">本文件由产教融智报告中心生成 · 数据快照 ${report.version} · ${report.period}</p>
</body>
</html>`;
const blob = new Blob([content], { type: "text/html;charset=utf-8" });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = `${report.title.replace(/[\\/:*?"<>|]/g, "-")}-${official ? "正式版" : "非正式版"}.html`;
anchor.click();
setTimeout(() => URL.revokeObjectURL(url), 500);
if (isClosedLoopReport) {
addClosedLoopAudit("REPORT_DOWNLOADED", report.id, `${editionLabel}离线HTML`);
saveClosedLoopState();
renderClosedLoop();
}
showToast(`离线HTML报告已下载,当前标识:${editionLabel}`, "下载已开始");
}
function setupSourceFilters() {
const filterButtons = [...document.querySelectorAll("[data-source-filter]")];
const summaryCards = [...document.querySelectorAll("[data-source-summary-filter]")];
const applyFilter = (filter) => {
filterButtons.forEach((button) => button.classList.toggle("active", button.dataset.sourceFilter === filter));
summaryCards.forEach((card) => card.classList.toggle("active", card.dataset.sourceSummaryFilter === filter));
renderSources(filter);
document.querySelector(".source-panel").scrollIntoView({ behavior: "smooth", block: "start" });
};
filterButtons.forEach((button) => {
button.addEventListener("click", () => applyFilter(button.dataset.sourceFilter));
});
summaryCards.forEach((card) => {
card.addEventListener("click", () => applyFilter(card.dataset.sourceSummaryFilter));
});
}
function setupDataOperations() {
document.getElementById("addDataSourceButton").addEventListener("click", () => openDataOpsDrawer("add"));
document.getElementById("dataAuthorizationButton").addEventListener("click", () => openDataOpsDrawer("authorization"));
document.getElementById("syncAllButton").addEventListener("click", () => openDataOpsDrawer("batch"));
document.querySelectorAll("[data-data-issue]").forEach((button) => {
button.addEventListener("click", () => openDataOpsDrawer("issue", button.dataset.dataIssue));
});
document.getElementById("dataOpsTabs").addEventListener("click", (event) => {
const button = event.target.closest("[data-data-ops-tab]");
if (!button) return;
dataOpsState.tab = button.dataset.dataOpsTab;
updateDataOpsDrawer();
});
document.getElementById("dataOpsDrawer").addEventListener("click", (event) => {
if (event.target === event.currentTarget) closeDataOpsDrawer();
});
document.getElementById("closeDataOpsDrawer").addEventListener("click", closeDataOpsDrawer);
document.getElementById("dataOpsFooter").addEventListener("click", (event) => {
const closeButton = event.target.closest("[data-data-ops-close]");
if (closeButton) {
closeDataOpsDrawer();
return;
}
const tabButton = event.target.closest("[data-data-ops-tab-jump]");
if (tabButton) {
dataOpsState.tab = tabButton.dataset.dataOpsTabJump;
updateDataOpsDrawer();
document.querySelector(".data-ops-drawer").scrollTop = 0;
return;
}
const resultButton = event.target.closest("[data-data-ops-result]");
if (resultButton) {
const message = resultButton.dataset.dataOpsResult;
closeDataOpsDrawer();
showToast(message, "数据运维任务已记录");
}
});
document.getElementById("dataOpsContent").addEventListener("click", (event) => {
const loopAction = event.target.closest("[data-loop-action]");
if (loopAction) {
closeDataOpsDrawer();
openClosedLoopAction(loopAction.dataset.loopAction);
return;
}
const method = event.target.closest(".data-source-methods button");
if (method) {
method.parentElement.querySelectorAll("button").forEach((item) => item.classList.toggle("active", item === method));
}
});
document.getElementById("dataOpsFooter").addEventListener("click", (event) => {
if (event.target.closest("[data-view-loop]")) {
closeDataOpsDrawer();
switchView("loop");
}
});
document.addEventListener("click", (event) => {
const sourceButton = event.target.closest("[data-source-detail]");
if (sourceButton) openDataOpsDrawer("source", sourceButton.dataset.sourceDetail);
});
}
function setupWorkspaceDetails() {
document.getElementById("organizationSwitcher").addEventListener("click", () => openWorkspaceDetail("organizations"));
document.getElementById("notificationButton").addEventListener("click", () => openWorkspaceDetail("notifications"));
document.getElementById("industryTrendMore").addEventListener("click", () => openWorkspaceDetail("industry"));
document.getElementById("compareProfession").addEventListener("click", () => openWorkspaceDetail("compare"));
document.getElementById("reportTemplateButton").addEventListener("click", () => openWorkspaceDetail("templates"));
document.getElementById("closeWorkspaceDetail").addEventListener("click", closeWorkspaceDetail);
document.getElementById("workspaceDetailDrawer").addEventListener("click", (event) => {
if (event.target === event.currentTarget) closeWorkspaceDetail();
});
document.getElementById("workspaceDetailFooter").addEventListener("click", (event) => {
if (event.target.closest("[data-workspace-close]")) {
closeWorkspaceDetail();
return;
}
if (event.target.closest("[data-workspace-confirm]")) {
const action = event.target.closest("[data-workspace-confirm]").textContent;
closeWorkspaceDetail();
showToast(`${action}已保存,并写入业务记录`, "业务操作已完成");
}
});
document.addEventListener("click", (event) => {
const project = event.target.closest("[data-project-detail]");
if (project) {
openWorkspaceDetail("project", project.dataset.projectId || project.dataset.projectDetail);
return;
}
const task = event.target.closest("[data-task-detail]");
if (task) {
openWorkspaceDetail("task", task.dataset.taskId || task.dataset.taskDetail);
return;
}
const course = event.target.closest("[data-workspace-detail^='course-']");
if (course) {
openWorkspaceDetail("course", course.dataset.workspaceDetail);
return;
}
});
document.getElementById("workspaceDetailContent").addEventListener("click", (event) => {
const loopAction = event.target.closest("[data-loop-action]");
if (loopAction) {
closeWorkspaceDetail();
openClosedLoopAction(loopAction.dataset.loopAction);
return;
}
const viewButton = event.target.closest("[data-view-jump]");
if (viewButton) {
closeWorkspaceDetail();
switchView(viewButton.dataset.viewJump);
return;
}
const issueButton = event.target.closest("[data-data-issue]");
if (issueButton) {
closeWorkspaceDetail();
openDataOpsDrawer("issue", issueButton.dataset.dataIssue);
}
});
document.querySelectorAll("[data-project-filter]").forEach((button) => {
button.addEventListener("click", () => {
button.parentElement.querySelectorAll("button").forEach((item) => item.classList.toggle("active", item === button));
renderProjects(button.dataset.projectFilter);
});
});
document.querySelectorAll("[data-partner-filter]").forEach((button) => {
button.addEventListener("click", () => {
button.parentElement.querySelectorAll("button").forEach((item) => item.classList.toggle("active", item === button));
renderPartners(button.dataset.partnerFilter);
});
});
}
const productTourSteps = [
{ view: "dashboard", selector: ".quality-strip", title: "先核对数据", description: "先看数据来自哪里、何时更新、是否通过检查;分析只使用已验收版本。" },
{ view: "dashboard", selector: ".ai-panel", title: "查看关键变化", description: "系统整理岗位、技能和专业变化,每条结论都可打开查看数据来源。" },
{ view: "profession", selector: ".profile-banner", title: "定位专业差距", description: "从岗位、技能、薪资和课程映射判断专业优势与结构性缺口。" },
{ view: "collaboration", selector: ".collab-stats", 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: "在线报告、材料包和展示大屏使用同一数据快照,适用于决策、汇报与报送。" },
];
let productTourStep = 0;
function closeProductTour() {
const tour = document.getElementById("productTour");
tour.classList.remove("open");
tour.setAttribute("aria-hidden", "true");
if (document.getElementById("productTourCoach").hidden) document.body.style.overflow = "";
}
function closeTourCoach() {
document.getElementById("productTourCoach").hidden = true;
document.querySelectorAll(".tour-target").forEach((item) => item.classList.remove("tour-target"));
document.body.classList.remove("tour-running");
}
function renderProductTourStep() {
const step = productTourSteps[productTourStep];
document.querySelectorAll(".tour-target").forEach((item) => item.classList.remove("tour-target"));
switchView(step.view);
const target = document.querySelector(`#view-${step.view} ${step.selector}`);
if (target) {
target.classList.add("tour-target");
setTimeout(() => target.scrollIntoView({ behavior: "smooth", block: "center" }), 80);
}
document.getElementById("tourStepLabel").textContent = `${String(productTourStep + 1).padStart(2, "0")} / ${String(productTourSteps.length).padStart(2, "0")}`;
document.getElementById("tourStepTitle").textContent = step.title;
document.getElementById("tourStepDescription").textContent = step.description;
document.getElementById("tourPrevious").disabled = productTourStep === 0;
document.getElementById("tourNext").textContent = productTourStep === productTourSteps.length - 1 ? "进入展示大屏" : "下一步";
}
function openShowcaseScreen() {
closeTourCoach();
const screen = document.getElementById("showcaseScreen");
screen.classList.add("open");
screen.setAttribute("aria-hidden", "false");
document.body.style.overflow = "hidden";
}
function closeShowcaseScreen() {
const screen = document.getElementById("showcaseScreen");
screen.classList.remove("open");
screen.setAttribute("aria-hidden", "true");
document.body.style.overflow = "";
}
function setupProductExperience() {
document.getElementById("productTourButton").addEventListener("click", () => {
const tour = document.getElementById("productTour");
tour.classList.add("open");
tour.setAttribute("aria-hidden", "false");
document.body.style.overflow = "hidden";
});
document.getElementById("closeProductTour").addEventListener("click", closeProductTour);
document.getElementById("productTour").addEventListener("click", (event) => {
if (event.target === event.currentTarget) closeProductTour();
});
document.getElementById("startProductTour").addEventListener("click", () => {
const roleSelect = document.getElementById("roleSelect");
roleSelect.value = "校级管理者";
applyRole("校级管理者", { notify: false });
closeProductTour();
productTourStep = 0;
document.getElementById("productTourCoach").hidden = false;
document.body.classList.add("tour-running");
renderProductTourStep();
});
document.getElementById("tourPlatformButton").addEventListener("click", () => {
document.getElementById("roleSelect").value = "厂商超级管理员";
applyRole("厂商超级管理员", { notify: false });
closeProductTour();
});
document.getElementById("tourReportButton").addEventListener("click", () => {
document.getElementById("roleSelect").value = "校级管理者";
applyRole("校级管理者", { notify: false });
closeProductTour();
switchView("reports");
setTimeout(() => openReportReader("school-2026-03"), 120);
});
document.getElementById("tourPrevious").addEventListener("click", () => {
if (productTourStep > 0) {
productTourStep -= 1;
renderProductTourStep();
}
});
document.getElementById("tourNext").addEventListener("click", () => {
if (productTourStep === productTourSteps.length - 1) {
openShowcaseScreen();
return;
}
productTourStep += 1;
renderProductTourStep();
});
document.getElementById("closeTourCoach").addEventListener("click", closeTourCoach);
document.querySelectorAll("[data-showcase-open]").forEach((button) => button.addEventListener("click", openShowcaseScreen));
document.getElementById("closeShowcaseScreen").addEventListener("click", closeShowcaseScreen);
}
function setupGenericInteractions() {
document.querySelectorAll(".nav-item[data-view]").forEach((button) => {
button.addEventListener("click", () => switchView(button.dataset.view));
});
document.querySelectorAll("[data-view-jump]").forEach((button) => {
button.addEventListener("click", () => switchView(button.dataset.viewJump));
});
document.querySelectorAll("[data-toast]").forEach((button) => {
button.addEventListener("click", () => showToast(button.dataset.toast, "操作已记录"));
});
document.querySelectorAll("[data-close-modal]").forEach((button) => {
button.addEventListener("click", () => closeModal(button.closest(".modal-backdrop")));
});
document.querySelectorAll(".modal-backdrop").forEach((backdrop) => {
backdrop.addEventListener("click", (event) => {
if (event.target === backdrop && backdrop.id !== "analysisModal") closeModal(backdrop);
});
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
if (document.getElementById("showcaseScreen").classList.contains("open")) {
closeShowcaseScreen();
return;
}
if (!document.getElementById("productTourCoach").hidden) {
closeTourCoach();
return;
}
if (document.getElementById("productTour").classList.contains("open")) {
closeProductTour();
return;
}
if (document.getElementById("dataOpsDrawer").classList.contains("open")) {
closeDataOpsDrawer();
return;
}
if (document.getElementById("workspaceDetailDrawer").classList.contains("open")) {
closeWorkspaceDetail();
return;
}
if (document.getElementById("reportReader").classList.contains("open")) {
closeReportReader();
return;
}
const open = document.querySelector(".modal-backdrop.open");
if (open && open.id !== "analysisModal") closeModal(open);
document.body.classList.remove("sidebar-open");
}
});
const mobileMenu = document.getElementById("mobileMenu");
const sidebarScrim = document.getElementById("sidebarScrim");
mobileMenu.addEventListener("click", () => document.body.classList.toggle("sidebar-open"));
sidebarScrim.addEventListener("click", () => document.body.classList.remove("sidebar-open"));
const tooltip = document.getElementById("tooltip");
document.querySelectorAll("[data-tip]").forEach((button) => {
const show = () => {
tooltip.textContent = button.dataset.tip;
const rect = button.getBoundingClientRect();
tooltip.style.left = `${Math.min(rect.left, window.innerWidth - 245)}px`;
tooltip.style.top = `${rect.bottom + 7}px`;
tooltip.classList.add("show");
};
const hide = () => tooltip.classList.remove("show");
button.addEventListener("mouseenter", show);
button.addEventListener("mouseleave", hide);
button.addEventListener("focus", show);
button.addEventListener("blur", hide);
});
document.querySelectorAll("[data-report-generate]").forEach((button) => {
button.addEventListener("click", () => openReportGeneration(button.dataset.reportGenerate));
});
}
function openReportGeneration(context = "center") {
const modal = document.getElementById("reportModal");
const title = document.getElementById("reportModalTitle");
const description = document.getElementById("reportModalDescription");
const options = document.getElementById("reportOptions");
const currentProfession = document.getElementById("professionTitle")?.textContent || "软件技术";
const configurations = {
package: {
title: "生成供需会材料包",
description: "所有报告将使用同一分析快照,确保统计口径一致。",
options: [
["全校供需分析总报告", "约42页 · PDF / Word", true],
["32个专业人才需求报告", "独立文件 · 自动生成目录", true],
["关键指标一页纸", "适用于会议汇报与报送", true],
["数据口径与质量附件", "指标定义、样本量及数据来源", false],
],
},
profession: {
title: `生成${currentProfession}专业报告`,
description: "生成包含岗位、技能、薪资、企业与课程建议的专业分析报告。",
options: [
["执行摘要与专业画像", "核心指标、趋势与结论", true],
["技能供需与课程映射", "技能缺口及课程覆盖依据", true],
["岗位、薪资与企业分析", "岗位需求、薪资分位数和合作企业", true],
["行动建议与数据附件", "建议责任、口径及数据来源", true],
],
},
center: {
title: "生成新分析报告",
description: "选择报告范围和内容,生成后将自动进入报告中心。",
options: [
["校级供需分析报告", "全校专业和产业供需总览", true],
["学院或专业报告", "按当前账号数据范围生成", true],
["产业人才缺口专题", "重点方向、企业和增长趋势", false],
["数据质量与口径报告", "数据资产、质量与使用限制", false],
],
},
};
const configuration = configurations[context] || configurations.center;
modal.dataset.context = context;
title.textContent = configuration.title;
description.textContent = configuration.description;
options.innerHTML = `
<legend>选择报告内容</legend>
${configuration.options
.map(
([name, note, checked]) =>
`<label><input type="checkbox" ${checked ? "checked" : ""} /><span><strong>${name}</strong><small>${note}</small></span></label>`,
)
.join("")}
`;
openModal(modal);
}
function setupReportCenterInteractions() {
const typeFilters = document.getElementById("reportTypeFilters");
const search = document.getElementById("reportSearch");
const period = document.getElementById("reportPeriodFilter");
typeFilters.addEventListener("click", (event) => {
const button = event.target.closest("[data-report-filter]");
if (!button) return;
reportState.type = button.dataset.reportFilter;
reportState.limit = 6;
typeFilters.querySelectorAll("button").forEach((item) => item.classList.toggle("active", item === button));
renderReportList();
});
search.addEventListener("input", () => {
reportState.search = search.value;
reportState.limit = 6;
renderReportList();
});
period.addEventListener("change", () => {
reportState.period = period.value;
reportState.limit = 6;
renderReportList();
});
document.getElementById("loadMoreReports").addEventListener("click", () => {
reportState.limit += 4;
renderReportList();
});
document.addEventListener("click", (event) => {
const openButton = event.target.closest("[data-report-open]");
if (openButton) {
openReportReader(openButton.dataset.reportOpen);
return;
}
const downloadButton = event.target.closest("[data-report-download]");
if (downloadButton) {
downloadReportSample(downloadButton.dataset.reportDownload);
return;
}
const actionButton = event.target.closest("[data-report-action-create]");
if (actionButton) {
const title = actionButton.closest(".report-action-card").querySelector("strong").textContent.trim();
if (!tasks.some((task) => task.title === title)) {
const report = reports.find((item) => item.id === document.getElementById("reportReader").dataset.reportId);
const task = {
id: `TSK-REPORT-${Date.now()}`,
title,
source: `报告建议 · ${report?.version || "当前版本"} · 已关联数据来源`,
profession: report?.type === "profession" ? report.scope.split("·").pop().trim() : "全校专业",
owner: "待确认",
due: "2026-04-30",
progress: 0,
status: "review",
statusText: "待确认",
};
closedLoopState.custom.tasks.unshift(task);
addClosedLoopAudit("TASK_CREATED_FROM_REPORT", task.id, `报告 ${report?.id || "未知"} · ${title}`);
saveClosedLoopState();
syncClosedLoopCollections();
renderTasks();
}
actionButton.textContent = "已加入任务";
actionButton.disabled = true;
showToast("报告建议已创建改进任务,并关联本报告的数据证据与版本");
}
});
document.getElementById("closeReportReader").addEventListener("click", closeReportReader);
document.getElementById("reportToc").addEventListener("click", (event) => {
const button = event.target.closest("[data-report-section]");
if (!button) return;
document.querySelectorAll("#reportToc button").forEach((item) => item.classList.toggle("active", item === button));
const section = document.getElementById(`report-section-${button.dataset.reportSection}`);
section?.scrollIntoView({ behavior: "smooth", block: "start" });
});
document.getElementById("reportPrintButton").addEventListener("click", () => {
const reportId = document.getElementById("reportReader").dataset.reportId;
if (closedLoopState.entities.report?.id === reportId) {
const official = closedLoopState.entities.report.status === "已发布";
addClosedLoopAudit("REPORT_PRINTED", reportId, official ? "正式版打印" : "非正式版打印");
saveClosedLoopState();
showToast(official ? "当前打印为正式发布版。" : "报告尚未发布,打印输出将作为非正式版使用。", official ? "正式版" : "非正式版提示");
}
window.print();
});
document.getElementById("reportDownloadButton").addEventListener("click", () => {
downloadReportSample(document.getElementById("reportReader").dataset.reportId);
});
}
function setupRoleExperience() {
const roleSelect = document.getElementById("roleSelect");
roleSelect.addEventListener("change", () => applyRole(roleSelect.value));
document.querySelectorAll("[data-platform-target]").forEach((button) => {
button.addEventListener("click", () => {
const targetId = button.dataset.platformTarget;
switchView("platform");
document.querySelectorAll("#platformNav [data-platform-target]").forEach((item) => {
item.classList.toggle("active", item.dataset.platformTarget === targetId);
});
const labels = {
platformOverview: "平台总览",
packageManagement: "数据包管理",
tenantManagement: "机构与租户",
accountManagement: "账号与权限",
backupManagement: "备份与恢复",
auditManagement: "审计与监控",
};
document.getElementById("breadcrumbCurrent").textContent = labels[targetId] || "平台总览";
setTimeout(() => {
document.getElementById(targetId)?.scrollIntoView({ behavior: "smooth", block: "start" });
}, 80);
document.body.classList.remove("sidebar-open");
});
});
}
const platformActionTemplates = {
import: {
kicker: "DATA PACKAGE IMPORT",
title: "导入基础数据包",
description: "校验厂商数据包版本,并选择本次发布的学校租户范围。",
confirm: "开始导入",
progress: true,
content: `
<div class="platform-form-grid">
<label class="full"><span>选择数据包</span><select><option>区域招聘岗位基础包 · V2026.04</option><option>薪资与企业画像增强包 · V2026.Q2</option></select></label>
<label><span>导入模式</span><select><option>增量更新 INCREMENTAL</option><option>初次全量 FULL</option></select></label>
<label><span>业务截止日期</span><input type="date" value="2026-03-31"></label>
<label><span>数据文件</span><input value="jobs-hz-2026-04.parquet" readonly></label>
<label><span>预计记录数</span><input value="92.6 万条" readonly></label>
<label><span>数据版本</span><input value="V2026.04"></label>
<label><span>Schema 版本</span><input value="JOB-PACK 3.3"></label>
<label class="full"><span>本次更新内容</span><textarea rows="3">新增约 8.4 万条岗位,更新 1.2 万条岗位状态;补充具身智能、AI应用工程和工业软件分类,并保留失效记录的旧版本。</textarea></label>
<fieldset>
<legend>导入要求与发布门槛</legend>
<div class="platform-contract-summary">
<span>主键<strong>package_id + source_record_id</strong></span>
<span>格式<strong>Parquet / CSV UTF-8</strong></span>
<span>必填完整率<strong>≥ 95%</strong></span>
<span>综合质量<strong>≥ 90 方可发布</strong></span>
</div>
</fieldset>
<fieldset>
<legend>授权学校范围</legend>
<div class="platform-check-grid">
<label><input type="checkbox" checked>杭州科技职业技术学院</label>
<label><input type="checkbox" checked>浙江机电职业技术大学</label>
<label><input type="checkbox" checked>宁波城市职业技术学院</label>
<label><input type="checkbox">其余 9 家已订阅学校</label>
</div>
</fieldset>
</div>
`,
},
backup: {
kicker: "PLATFORM BACKUP",
title: "创建平台备份",
description: "本操作仅创建新的加密备份,不影响当前生产数据。",
confirm: "开始备份",
progress: true,
content: `
<div class="platform-form-grid">
<label><span>备份范围</span><select><option>全部租户与平台配置</option><option>仅平台基础数据包</option><option>指定学校租户</option></select></label>
<label><span>备份类型</span><select><option>增量备份</option><option>全量备份</option></select></label>
<label class="full"><span>备份说明</span><textarea rows="3">版本发布前手动备份,用于平台升级回滚保障。</textarea></label>
</div>
`,
},
restore: {
kicker: "RECOVERY DRILL",
title: "发起恢复演练",
description: "在隔离环境中验证备份可恢复性,不覆盖生产数据。",
confirm: "开始演练",
progress: true,
content: `
<div class="platform-form-grid">
<label class="full"><span>备份版本</span><select><option>2026-03-22 02:00 · 全量备份 · 18.6GB</option><option>2026-03-21 02:00 · 增量备份 · 1.2GB</option></select></label>
<label><span>演练环境</span><input value="隔离恢复验证环境" readonly></label>
<label><span>预计耗时</span><input value="约 8 分钟" readonly></label>
</div>
`,
},
tenant: {
kicker: "TENANT ONBOARDING",
title: "新增学校租户",
description: "创建学校独立工作空间并配置初始数据服务。",
confirm: "创建租户",
content: `
<div class="platform-form-grid">
<label class="full"><span>学校名称</span><input placeholder="请输入学校全称"></label>
<label><span>产品版本</span><select><option>旗舰版</option><option>专业版</option><option>标准版</option></select></label>
<label><span>服务区域</span><select><option>华东区域</option><option>华南区域</option><option>华北区域</option></select></label>
<label class="full"><span>管理员邮箱</span><input type="email" placeholder="admin@example.edu.cn"></label>
</div>
`,
},
packageDetail: {
kicker: "DATA PACKAGE DETAIL",
title: "数据包服务详情",
description: "查看版本、质量校验和学校授权情况。",
confirm: "完成",
content: `
<div class="platform-detail-card"><strong>当前生产版本 V2026.03</strong><small>2026-03-18 发布 · 86.4万条有效记录 · 质量评分 93.4</small></div>
<div class="platform-detail-card"><strong>授权与服务</strong><small>12家学校已订阅,服务协议全部有效,下次更新计划为 2026-04-01。</small></div>
`,
},
tenantDetail: {
kicker: "TENANT DETAIL",
title: "学校租户详情",
description: "查看租户版本、服务状态和资源用量。",
confirm: "进入租户",
content: `
<div class="platform-detail-card"><strong>租户运行正常</strong><small>旗舰版 · 386名用户 · 6个数据源 · 本月生成报告74份</small></div>
<div class="platform-detail-card"><strong>资源使用</strong><small>数据存储 38.2GB / 100GB,AI分析额度使用 64%,服务有效至 2027-02-28。</small></div>
`,
},
service: {
kicker: "SERVICE MONITOR",
title: "平台服务监控",
description: "查看服务可用性、任务吞吐和最近异常。",
confirm: "完成",
content: `<div class="platform-detail-card"><strong>最近24小时运行正常</strong><small>共处理428个任务,成功425个,3个任务已自动重试成功,无生产事故。</small></div>`,
},
account: {
kicker: "PLATFORM ACCOUNTS",
title: "平台管理员账号",
description: "管理厂商运营、实施与技术支持账号。",
confirm: "进入管理",
content: `<div class="platform-detail-card"><strong>8 个平台级账号</strong><small>超级管理员2人,平台运营3人,数据运维2人,安全审计1人,全部启用MFA。</small></div>`,
},
permission: {
kicker: "ROLE PERMISSION",
title: "角色与权限模板",
description: "分别设置学校、学院、企业和平台运营人员的权限。",
confirm: "进入配置",
content: `<div class="platform-detail-card"><strong>7 套权限模板</strong><small>采用租户隔离、角色权限和数据范围三层控制,最近修改已通过审计复核。</small></div>`,
},
security: {
kicker: "SECURITY POLICY",
title: "平台安全策略",
description: "查看认证、访问控制和安全告警策略。",
confirm: "完成",
content: `<div class="platform-detail-card"><strong>当前无高危安全风险</strong><small>MFA覆盖率100%,异常登录拦截3次,密钥轮换将在12天后执行。</small></div>`,
},
audit: {
kicker: "AUDIT LOG",
title: "平台审计日志",
description: "查看数据导入、权限变更、租户操作和系统任务记录。",
confirm: "查看完整日志",
content: `<div class="platform-detail-card"><strong>今日记录 286 条关键操作</strong><small>数据操作68条,权限操作12条,系统任务201条,安全事件5条,全部保留操作者和时间戳。</small></div>`,
},
};
function setupPlatformActions() {
const modal = document.getElementById("platformActionModal");
const confirmButton = document.getElementById("platformActionConfirm");
document.querySelectorAll("[data-platform-action]").forEach((button) => {
button.addEventListener("click", () => {
const action = button.dataset.platformAction;
const template = platformActionTemplates[action] || platformActionTemplates.service;
modal.dataset.action = action;
document.getElementById("platformActionKicker").textContent = template.kicker;
document.getElementById("platformActionTitle").textContent = template.title;
document.getElementById("platformActionDescription").textContent = template.description;
document.getElementById("platformActionContent").innerHTML = template.content;
document.getElementById("platformActionProgress").hidden = true;
document.getElementById("platformActionProgressBar").style.width = "0";
confirmButton.textContent = template.confirm;
confirmButton.disabled = false;
openModal(modal);
});
});
confirmButton.addEventListener("click", () => {
const action = modal.dataset.action;
const template = platformActionTemplates[action] || platformActionTemplates.service;
if (!template.progress) {
closeModal(modal);
showToast(`${template.title}操作已记录`, "平台操作");
return;
}
const progress = document.getElementById("platformActionProgress");
const bar = document.getElementById("platformActionProgressBar");
const value = document.getElementById("platformActionProgressValue");
const text = document.getElementById("platformActionProgressText");
progress.hidden = false;
confirmButton.disabled = true;
let current = 0;
const messages = {
import: ["正在校验数据包签名…", "正在检查字段与质量规则…", "正在写入版本库并发布…"],
backup: ["正在创建一致性快照…", "正在加密并写入备份存储…", "正在校验备份完整性…"],
restore: ["正在创建隔离环境…", "正在恢复备份数据…", "正在验证数据完整性…"],
}[action];
const timer = setInterval(() => {
current = Math.min(current + 10, 100);
bar.style.width = `${current}%`;
value.textContent = `${current}%`;
text.textContent = messages[Math.min(Math.floor(current / 35), 2)];
if (current === 100) {
clearInterval(timer);
setTimeout(() => {
closeModal(modal);
confirmButton.disabled = false;
const successMessages = {
import: "基础数据包已通过校验并发布到所选学校",
backup: "平台备份已创建并完成完整性校验",
restore: "隔离恢复演练成功,生产数据未受影响",
};
showToast(successMessages[action], "平台操作完成");
}, 350);
}
}, 90);
});
}
function setupAnalysis() {
document.getElementById("runAnalysis").addEventListener("click", () => {
const modal = document.getElementById("analysisModal");
const progress = document.getElementById("analysisProgress");
const status = document.getElementById("analysisStatus");
const steps = [...document.querySelectorAll(".analysis-steps span")];
const statuses = [
"读取本期分析快照…",
"归一并匹配产业岗位…",
"计算专业技能覆盖差距…",
"整理分析结果和建议…",
];
openModal(modal);
progress.style.width = "0";
steps.forEach((step, index) => step.classList.toggle("active", index === 0));
let value = 0;
const timer = setInterval(() => {
value += 10;
progress.style.width = `${value}%`;
const stage = Math.min(Math.floor(value / 26), 3);
status.textContent = statuses[stage];
steps.forEach((step, index) => step.classList.toggle("active", index <= stage));
if (value >= 100) {
clearInterval(timer);
setTimeout(() => {
closeModal(modal);
showToast("供需分析 V2026.03 已生成,3项关键变化需要关注");
}, 350);
}
}, 120);
});
}
function setupReportGeneration() {
const button = document.getElementById("generateReportButton");
button.addEventListener("click", () => {
const progressWrap = document.getElementById("reportProgress");
const progressBar = document.getElementById("reportProgressBar");
const progressValue = document.getElementById("reportProgressValue");
const progressText = document.getElementById("reportProgressText");
progressWrap.hidden = false;
button.disabled = true;
button.textContent = "正在生成…";
let value = 0;
const timer = setInterval(() => {
value += 8;
if (value > 100) value = 100;
progressBar.style.width = `${value}%`;
progressValue.textContent = `${value}%`;
if (value < 34) progressText.textContent = "正在准备报告模板…";
else if (value < 70) progressText.textContent = "正在渲染32个专业图表…";
else progressText.textContent = "正在校验口径并打包…";
if (value === 100) {
clearInterval(timer);
setTimeout(() => {
const baseReport = reportsForCurrentRole()[0] || reports[0];
const generatedReport = {
...baseReport,
id: `generated-${Date.now()}`,
version: `${baseReport.version}-R${reports.filter((item) => item.id.startsWith("generated-")).length + 1}`,
status: "待审核",
statusClass: "review",
updated: "刚刚",
featured: false,
};
closedLoopState.custom.reports.unshift(generatedReport);
addClosedLoopAudit("REPORT_CREATED", generatedReport.id, `${generatedReport.title} · ${generatedReport.status}`);
saveClosedLoopState();
syncClosedLoopCollections();
closeModal(document.getElementById("reportModal"));
showToast("报告已生成并进入报告中心,可继续在线阅读或下载");
progressWrap.hidden = true;
progressBar.style.width = "0";
button.disabled = false;
button.innerHTML = `${svgIcon("file")}开始生成`;
const role = document.getElementById("roleSelect").value;
if (roleProfiles[role]?.menus.includes("report")) {
switchView("reports");
renderReportCenter();
}
}, 450);
}
}, 100);
});
}
function configureFormModal(type) {
const currentRole = document.getElementById("roleSelect").value;
const configs = {
task: {
kicker: "ACTION FORM",
title: "创建改进任务",
description: "把分析结果写成有负责人、截止日期和验收要求的任务。",
label: "任务名称",
value: "强化云原生与容器化实训内容",
scopeLabel: "关联专业",
scopeOptions: ["软件技术", "机电一体化技术", "电子商务"],
priorityLabel: "优先级",
priorityOptions: ["高优先级", "中优先级", "低优先级"],
goalLabel: "目标与说明",
goal: "依据近90天岗位数据,将 Docker、Kubernetes 与 CI/CD 实践纳入实训课程,并邀请企业导师参与课程评审。",
ownerLabel: "负责人",
owner: "李雯 · 信息工程学院",
dueLabel: "计划完成时间",
due: "2026-06-30",
},
project: {
kicker: "COLLABORATION PROJECT",
title: "新建合作项目",
description: "连接专业团队与企业伙伴,共同推进人才培养项目。",
label: "项目名称",
value: "企业项目进课堂",
scopeLabel: "牵头专业",
scopeOptions: ["软件技术", "机电一体化技术", "电子商务"],
priorityLabel: "项目类型",
priorityOptions: ["企业项目进课堂", "课程共建", "实训基地", "企业导师"],
goalLabel: "共建目标与验收方式",
goal: "围绕企业任务共同设计教学项目、过程评价和成果验收,并保存企业任务书与导师评价。",
ownerLabel: "校内负责人",
owner: "王健 · 产教融合中心",
dueLabel: "计划启动时间",
due: "2026-04-15",
},
demand: {
kicker: "ENTERPRISE RESEARCH",
title: "发起企业需求调研",
description: "向合作企业收集岗位、技能和人才培养需求。",
label: "调研主题",
value: "2026年度重点产业人才需求调研",
scopeLabel: "调研范围",
scopeOptions: ["126家合作企业", "重点产业企业", "当前专业合作企业"],
priorityLabel: "调研类型",
priorityOptions: ["岗位与技能需求", "课程共建意愿", "实习就业需求"],
goalLabel: "调研说明",
goal: "收集未来12个月岗位、技能、人才数量和校企合作意愿,企业提交结果需确认授权用途与有效期。",
ownerLabel: "发起部门",
owner: "产教融合中心",
dueLabel: "反馈截止时间",
due: "2026-04-30",
},
};
if (currentRole === "企业导师" && type === "demand") {
configs.demand = {
kicker: "ENTERPRISE DEMAND",
title: "提交企业人才需求",
description: "提交岗位、技能与共育需求,供学校专业团队确认是否承接。",
label: "需求名称",
value: "边缘计算软件测试人才共育需求",
scopeLabel: "关联专业",
scopeOptions: ["软件技术", "物联网应用技术", "计算机网络技术"],
priorityLabel: "合作方式",
priorityOptions: ["人才共育", "企业项目", "课程共建", "实习就业"],
goalLabel: "企业需求说明",
goal: "填写岗位、技能、计划人数和合作方式,并由企业联系人确认数据用途与有效期。",
ownerLabel: "企业联系人",
owner: "陈凯 · 企业人才发展部",
dueLabel: "期望响应时间",
due: "2026-04-20",
};
}
if (currentRole === "企业导师" && type === "project") {
configs.project = {
kicker: "CO-CREATION PROJECT",
title: "发起校企共建项目",
description: "邀请学校共同设计企业项目、课程内容和评价标准。",
label: "项目名称",
value: "智能物联边缘计算综合实训",
scopeLabel: "面向专业",
scopeOptions: ["物联网应用技术", "软件技术", "计算机网络技术"],
priorityLabel: "项目类型",
priorityOptions: ["企业项目", "课程共建", "企业导师", "实训基地"],
goalLabel: "企业任务与验收要求",
goal: "按边缘计算测试场景设计实训任务,由企业导师参加中期评审,并检查测试报告、代码质量和协作过程。",
ownerLabel: "企业负责人",
owner: "陈凯 · 企业人才发展部",
dueLabel: "建议启动时间",
due: "2026-04-20",
};
}
const config = configs[type];
document.getElementById("formModalKicker").textContent = config.kicker;
document.getElementById("formModalTitle").textContent = config.title;
document.getElementById("formModalDescription").textContent = config.description;
document.getElementById("formNameLabel").textContent = config.label;
document.getElementById("formName").value = config.value;
document.getElementById("formScopeLabel").textContent = config.scopeLabel;
document.getElementById("formScope").innerHTML = config.scopeOptions.map((option) => `<option>${option}</option>`).join("");
document.getElementById("formPriorityLabel").textContent = config.priorityLabel;
document.getElementById("formPriority").innerHTML = config.priorityOptions.map((option) => `<option>${option}</option>`).join("");
document.getElementById("formGoalLabel").textContent = config.goalLabel;
document.getElementById("formGoal").value = config.goal;
document.getElementById("formOwnerLabel").textContent = config.ownerLabel;
document.getElementById("formOwner").value = config.owner;
document.getElementById("formDueLabel").textContent = config.dueLabel;
document.getElementById("formDue").value = config.due;
document.getElementById("actionForm").dataset.type = type;
openModal(document.getElementById("formModal"));
}
function setupForms() {
document.getElementById("newTaskButton").addEventListener("click", () => configureFormModal("task"));
document.getElementById("newProjectButton").addEventListener("click", () => {
if (currentRoleName() === "专业负责人") openClosedLoopAction("project");
else configureFormModal("project");
});
document.getElementById("enterpriseDemandButton").addEventListener("click", () => {
if (currentRoleName() === "企业导师") openClosedLoopAction("demand");
else configureFormModal("demand");
});
document.getElementById("actionForm").addEventListener("submit", (event) => {
event.preventDefault();
const type = event.currentTarget.dataset.type;
const title = document.getElementById("formName").value.trim();
const scope = document.getElementById("formScope").value;
const classification = document.getElementById("formPriority").value;
const goal = document.getElementById("formGoal").value.trim();
const owner = document.getElementById("formOwner").value.trim();
const due = document.getElementById("formDue").value;
if (type === "task") {
const task = {
id: `TSK-CUSTOM-${Date.now()}`,
title,
source: `手动创建 · ${classification}`,
profession: scope,
owner: owner.split("·")[0].trim(),
due,
progress: 0,
status: "progress",
statusText: "进行中",
};
closedLoopState.custom.tasks.unshift(task);
addClosedLoopAudit("TASK_CREATED", task.id, `${title} · ${scope} · ${owner}`);
saveClosedLoopState();
syncClosedLoopCollections();
renderTasks();
switchView("actions");
} else if (type === "project") {
const project = {
id: `PRJ-CUSTOM-${Date.now()}`,
type: classification.includes("课程") ? "course" : "practice",
tone: "green",
title,
meta: `${scope} × 合作伙伴待确认`,
progress: 0,
partners: [owner.slice(0, 1), "企"],
};
closedLoopState.custom.projects.unshift(project);
addClosedLoopAudit("PROJECT_CREATED", project.id, `${title} · ${scope} · ${owner}`);
saveClosedLoopState();
syncClosedLoopCollections();
renderProjects();
switchView("collaboration");
} else if (type === "demand") {
const demand = {
id: `DEM-CUSTOM-${Date.now()}`,
logo: document.getElementById("roleSelect").value === "企业导师" ? "企" : "调",
company: document.getElementById("roleSelect").value === "企业导师" ? "企业人才发展部" : "校企需求调研",
industry: classification,
title,
description: goal,
due: due ? `${due} 截止` : "待确认",
status: "已提交",
};
closedLoopState.custom.demands.unshift(demand);
addClosedLoopAudit("DEMAND_CREATED", demand.id, `${title} · ${classification} · ${owner}`);
saveClosedLoopState();
syncClosedLoopCollections();
renderDemands();
switchView("collaboration");
}
closeModal(document.getElementById("formModal"));
const messages = {
task: "改进任务已创建,并通知专业负责人",
project: "合作项目草案已创建,可继续邀请企业伙伴",
demand: "企业需求调研已创建,共有126家在库企业可发送",
};
showToast(messages[type] || "内容已保存");
});
}
function setupFilters() {
document.getElementById("professionSearch").addEventListener("input", (event) => renderProfessionTable(event.target.value));
document.getElementById("professionSelect").addEventListener("change", (event) => renderProfessionProfile(event.target.value));
document.querySelectorAll("#periodSwitch button").forEach((button) => {
button.addEventListener("click", () => {
document.querySelectorAll("#periodSwitch button").forEach((item) => item.classList.remove("active"));
button.classList.add("active");
renderTrendChart(button.dataset.period);
});
});
document.querySelectorAll("#professionTabs button").forEach((button) => {
button.addEventListener("click", () => {
document.querySelectorAll("#professionTabs button").forEach((item) => item.classList.remove("active"));
button.classList.add("active");
professionTabState = button.dataset.tab;
const skillsView = document.getElementById("professionSkillsView");
const detailView = document.getElementById("professionDetailView");
const showSkills = professionTabState === "skills";
skillsView.hidden = !showSkills;
detailView.hidden = showSkills;
if (!showSkills) renderProfessionDetailView(professionTabState);
});
});
let taskFilter = "all";
const taskSearch = document.getElementById("taskSearch");
document.querySelectorAll("#taskFilters button").forEach((button) => {
button.addEventListener("click", () => {
taskFilter = button.dataset.filter;
document.querySelectorAll("#taskFilters button").forEach((item) => item.classList.remove("active"));
button.classList.add("active");
renderTasks(taskFilter, taskSearch.value);
});
});
taskSearch.addEventListener("input", () => renderTasks(taskFilter, taskSearch.value));
}
function validateClosedLoopSubmission(action, values) {
const e = closedLoopState.entities;
if (action === "receive") {
values.tenantId = values.tenantId.trim();
values.packageId = values.packageId.trim();
values.packageVersion = values.packageVersion.trim();
values.sha256 = values.sha256.trim().toLowerCase();
const checkedRecords = Number(values.checkedRecords);
const checks = [
[values.tenantId === e.receipt.tenantId, `租户标识必须为 ${e.receipt.tenantId}`],
[values.packageId === e.receipt.packageId, `数据包编号必须为 ${e.receipt.packageId}`],
[values.packageVersion === e.receipt.packageVersion, `数据包版本必须为 ${e.receipt.packageVersion}`],
[Number.isInteger(checkedRecords) && checkedRecords === e.receipt.expectedRecords, `交付包记录数必须为 ${e.receipt.expectedRecords.toLocaleString("zh-CN")} 条`],
[/^[a-f0-9]{64}$/.test(values.sha256), "SHA-256 必须是完整的64位十六进制摘要"],
[values.sha256 === e.receipt.expectedSha256, "SHA-256 与厂商发布 Manifest 的数据文件摘要不一致"],
];
const failure = checks.find(([matched]) => !matched);
if (failure) {
return {
valid: false,
title: "技术接收校验失败",
message: `${failure[1]};回执与分析快照状态均未前进。`,
auditAction: "PACKAGE_RECEIPT_VALIDATION_FAILED",
auditDetail: `${values.tenantId || "空租户"} · ${values.packageId || "空包号"} · ${values.packageVersion || "空版本"} · ${Number.isFinite(checkedRecords) ? checkedRecords : "无效记录数"} 条 · SHA ${values.sha256 || "空"}`,
};
}
values.checkedRecords = String(checkedRecords);
}
if (action === "evidence") {
const progress = Number(values.progress);
if (!Number.isFinite(progress) || progress < 0 || progress > 100) {
return { valid: false, title: "进度值无效", message: "阶段进度必须是0到100之间的数字。" };
}
}
if (action === "reportReview" && values.approver === e.report?.submittedBy) {
return {
valid: false,
title: "报告审批职责未分离",
message: "本次报告提交人不能批准或退回自己的提交,请选择另一位校级管理者。",
auditAction: "REPORT_REVIEW_BLOCKED_SELF_APPROVAL",
auditDetail: `${e.report.id} · 提交人 ${e.report.submittedBy} · 拟审批人 ${values.approver}`,
};
}
return { valid: true };
}
function setupClosedLoop() {
document.getElementById("view-loop").addEventListener("click", (event) => {
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]");
if (button) openClosedLoopAction(button.dataset.loopAction);
});
document.getElementById("closedLoopForm").addEventListener("submit", (event) => {
event.preventDefault();
const action = event.currentTarget.dataset.action;
const values = Object.fromEntries(new FormData(event.currentTarget).entries());
const availability = actionPrerequisite(action);
if (!availability.enabled) {
showToast(availability.reason, "权限或前置条件已变化");
return;
}
const validation = validateClosedLoopSubmission(action, values);
if (!validation.valid) {
if (validation.auditAction) {
addClosedLoopAudit(validation.auditAction, action === "receive" ? closedLoopState.entities.receipt.id : closedLoopState.entities.report?.id || action, validation.auditDetail);
saveClosedLoopState();
renderClosedLoop();
}
showToast(validation.message, validation.title);
return;
}
closeModal(document.getElementById("closedLoopModal"));
applyClosedLoopAction(action, values);
const reader = document.getElementById("reportReader");
if (reader.classList.contains("open")) {
const report = reports.find((item) => item.id === reader.dataset.reportId);
if (report) renderReportApprovalToolbar(report);
document.body.style.overflow = "hidden";
}
});
document.getElementById("resetClosedLoop").addEventListener("click", () => {
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",
objectId: CLOSED_LOOP_SHARED.tenantId,
operator: currentRoleUser(),
role: currentRoleName(),
at: nowText(),
detail: `按 schemaVersion ${CLOSED_LOOP_SCHEMA_VERSION} 重置样板。`,
});
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]);
renderSparkline("sparkJobs", [38, 42, 41, 50, 57, 64], "#4978a8");
renderSparkline("sparkGaps", [34, 31, 32, 29, 26, 23], "#d89527");
renderTrendChart();
renderProfessionTable();
renderIndustryList();
renderProfessionProfile("software");
renderProjects();
renderDemands();
renderPartners();
renderTasks();
renderSources();
renderDataReportLineage();
setupGenericInteractions();
setupRoleExperience();
setupPlatformActions();
setupAnalysis();
setupReportGeneration();
setupReportCenterInteractions();
setupForms();
setupFilters();
setupSourceFilters();
setupDataReportLineage();
setupDataOperations();
setupWorkspaceDetails();
setupProductExperience();
setupClosedLoop();
renderClosedLoop();
}
document.addEventListener("DOMContentLoaded", init);