Files
edu-industry/demo/app.js
T
lofyerandfactory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> 92515928d9 feat: add edu-industry platform demos
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2026-08-24 11:19:43 +08:00

4727 lines
250 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 = [
{
name: "区域招聘岗位基础包",
type: "互联网岗位 · 标准数据包",
category: "vendor",
categoryLabel: "基础数据包",
commercial: "付费订阅",
provider: "平台厂商导入",
rights: "项目期内授权使用",
coverage: "杭州及长三角",
frequency: "每月更新",
status: "服务有效",
records: "41,236",
quality: "93.4%",
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: "平台数据产品部",
technicalOwner: "平台数据运维组",
action: "检查更新",
},
{
name: "薪资与企业画像基础包",
type: "互联网数据 · 增值数据包",
category: "vendor",
categoryLabel: "基础数据包",
commercial: "付费订阅",
provider: "平台厂商导入",
rights: "项目期内授权使用",
coverage: "区域重点产业",
frequency: "每月更新",
status: "服务有效",
records: "36,284",
quality: "91.8%",
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: "平台数据产品部",
technicalOwner: "平台数据运维组",
action: "检查更新",
},
{
name: "实习管理平台",
type: "校内系统 · API",
category: "school",
categoryLabel: "校级数据",
commercial: "校方自有",
provider: "学校实习系统",
rights: "校方数据资产",
coverage: "全校实习学生",
frequency: "每日同步",
status: "正常",
records: "32,418",
quality: "96.2%",
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: "就业处",
technicalOwner: "学校信息中心",
action: "立即同步",
},
{
name: "就业管理系统",
type: "校内系统 · 数据库视图",
category: "school",
categoryLabel: "校级数据",
commercial: "校方自有",
provider: "学校就业系统",
rights: "校方数据资产",
coverage: "近三届毕业生",
frequency: "每日同步",
status: "字段缺失",
records: "8,926",
quality: "87.6%",
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: "就业处",
technicalOwner: "学校信息中心",
action: "处理异常",
warning: true,
},
{
name: "课程标准库",
type: "校内文件 · 结构化导入",
category: "school",
categoryLabel: "校级数据",
commercial: "校方自有",
provider: "学校教务处",
rights: "校方数据资产",
coverage: "32个专业",
frequency: "学期更新",
status: "正常",
records: "1,428",
quality: "95.1%",
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: "教务处",
technicalOwner: "学校信息中心",
action: "立即同步",
},
{
name: "企业确认需求库",
type: "企业提交 · 问卷/工作台",
category: "enterprise",
categoryLabel: "企业数据",
commercial: "合作授权",
provider: "126家合作企业",
rights: "限定合作场景使用",
coverage: "岗位与技能需求",
frequency: "按需更新",
status: "授权有效",
records: "8,350",
quality: "97.3%",
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: "产教融合中心",
technicalOwner: "学校信息中心",
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",
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: { 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 validTask =
state.entities.task === null ||
(isPlainObject(state.entities.task) && Array.isArray(state.entities.task.progressLogs) && Array.isArray(state.entities.task.acceptanceHistory));
const validReport = state.entities.report === null || (isPlainObject(state.entities.report) && Array.isArray(state.entities.report.history));
const validOptionalEntities = ["project", "evaluation", "review"].every((key) => state.entities[key] === null || isPlainObject(state.entities[key]));
if (!validEntityArrays || !validCustomCollections || !validCoreEntities || !validTask || !validReport || !validOptionalEntities) {
return resetInvalidClosedLoopState(seed, raw.schemaVersion, "实体结构或历史数组不满足 V2 不变量");
}
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: 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();
renderClosedLoop();
renderTasks();
renderProjects();
renderDemands();
renderReportCenter();
if (message) showToast(message, "闭环状态已保存");
}
const closedLoopActionCatalog = {
demand: {
title: "提交企业需求",
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: "确认接收并生成快照",
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: "专业建议决策",
description: "采纳、暂缓或驳回都必须填写理由;采纳后自动创建关联任务。",
roles: ["专业负责人"],
fields: [
["decision", "决策", "select", "采纳|暂缓|驳回"],
["reason", "决策理由", "textarea", "企业需求与岗位样本相互印证,现有课程覆盖不足,纳入本期实训调整。"],
["owner", "任务负责人", "text", "李雯"],
["due", "任务截止日期", "date", "2026-12-20"],
],
},
project: {
title: "创建关联项目",
description: "项目自动绑定 demandId、recommendationId 与 taskId。",
roles: ["专业负责人"],
fields: [
["title", "项目名称", "text", "边缘计算测试真实项目进课堂"],
["profession", "牵头专业", "text", "软件技术"],
["owner", "项目负责人", "text", "李雯"],
],
},
evidence: {
title: "提交阶段进展与证据",
description: "保存证据名称、类型、说明、提交人、时间和文件名/链接;提交后自动更新任务和项目进度。",
roles: ["专业负责人", "学院负责人"],
fields: [
["name", "证据名称", "text", "边缘计算测试实训方案"],
["type", "证据类型", "select", "课程方案|阶段成果|评审纪要|学生作品"],
["description", "证据说明", "textarea", "已完成企业任务拆解、课程映射和中期评价量规。"],
["submitter", "提交人", "text", "李雯 · 软件技术专业"],
["fileRef", "文件名或链接", "text", "边缘计算测试实训方案-v1.0.pdf"],
["progress", "本次完成进度", "number", "80"],
],
},
evaluation: {
title: "提交企业评价",
description: "企业导师只能评价本企业关联对象;通过或需整改均须填写意见。",
roles: ["企业导师"],
fields: [
["result", "评价结论", "select", "通过|需整改"],
["comment", "评价意见", "textarea", "任务覆盖真实设备测试流程,验收量规清晰,同意进入校级验收。"],
],
},
acceptance: {
title: "校级任务验收",
description: "校级管理者在企业评价后验收或退回任务,并记录意见。",
roles: ["校级管理者"],
fields: [
["result", "验收结论", "select", "验收通过|退回整改"],
["comment", "验收意见", "textarea", "企业评价、课程方案和过程证据齐全,同意完成。"],
],
},
reportCreate: {
title: "生成闭环报告草稿",
description: "报告关联需求、快照、任务和全部证据,生成后进入真实审批流。",
roles: ["专业负责人", "学院负责人", "校级管理者"],
fields: [["title", "报告名称", "text", "边缘计算测试人才共育闭环报告"]],
},
reportSubmit: {
title: "提交报告审核",
description: "草稿提交后进入待审核状态。",
roles: ["专业负责人", "学院负责人", "校级管理者"],
fields: [["comment", "提交说明", "textarea", "闭环实体与证据已核对,请校级管理者审核。"]],
},
reportReview: {
title: "报告审批",
description: "只有校级管理者可批准或退回业务报告;审批人必须与本次提交人分离。",
roles: ["校级管理者"],
fields: [
["result", "审批结论", "select", "批准|退回"],
["approver", "独立审批人", "select", "刘嘉禾 · 校级管理者|周明远 · 校级管理者"],
["comment", "审批意见", "textarea", "实体关联、数据口径和验收记录完整,同意批准。"],
],
},
reportPublish: {
title: "正式发布报告",
description: "只有已批准报告可正式发布;发布后下载和导出才标识为正式版。",
roles: ["校级管理者"],
fields: [["comment", "发布说明", "textarea", "作为本期校园闭环正式报告发布。"]],
},
feedback: {
title: "记录就业侧使用反馈",
description: "就业老师确认数据对就业业务是否可用,并登记下一周期关注项。",
roles: ["就业老师"],
fields: [
["usable", "业务确认", "select", "可用|需补充"],
["department", "使用部门", "text", "就业处"],
["comment", "使用反馈", "textarea", "岗位技能标签可用于就业指导课程和重点企业岗位推荐。"],
["nextCycle", "下一周期关注", "textarea", "补充学生项目作品与就业岗位匹配率。"],
],
},
review: {
title: "下一周期复核",
description: "保存前后指标与改善结论,并决定关闭或进入下一周期。",
roles: ["专业负责人"],
fields: [
["before", "前周期指标", "number", "32"],
["after", "本周期指标", "number", "68"],
["conclusion", "复核结论", "select", "改善|无变化|恶化"],
["decision", "周期决定", "select", "关闭|进入下一周期"],
["comment", "复核说明", "textarea", "云原生与边缘测试课程覆盖率明显提升,企业评价通过。"],
],
},
};
function actionPrerequisite(action) {
const e = closedLoopState.entities;
const role = currentRoleName();
const allowed = closedLoopActionCatalog[action]?.roles.includes(role);
if (!allowed) return { enabled: false, reason: "当前角色无此动作权限。" };
const checks = {
demand: [
e.snapshot.status === "待接收" && !e.task && !e.report,
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.task?.status === "已完成"
? "任务已完成校级验收,无需重复评价。"
: 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 === "已发布", "需先正式发布报告。"],
review: [e.usageFeedback.length > 0, "需先形成就业侧数据使用反馈。"],
};
const [enabled, reason] = checks[action] || [false, "动作不可用。"];
return { enabled, reason: enabled ? "前置条件已满足。" : reason };
}
function renderClosedLoopAction(action, label) {
const availability = actionPrerequisite(action);
return `<button data-loop-action="${action}" ${availability.enabled ? "" : "disabled"}><span><strong>${label}</strong><small>${availability.reason}</small></span><em>${availability.enabled ? "去处理" : "未解锁"}</em></button>`;
}
function renderClosedLoop() {
const root = document.getElementById("loopChain");
if (!root) return;
const e = closedLoopState.entities;
document.getElementById("loopSchemaBadge").textContent = `SCHEMA V${closedLoopState.schemaVersion}`;
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>`;
const stages = [
["需求", e.demand.id, e.demand.status],
["快照", e.snapshot.id, e.snapshot.status],
["建议", e.recommendation.id, e.recommendation.status],
["任务", e.task?.id || "TSK-待创建", e.task?.status || "未创建"],
["项目", e.project?.id || "PRJ-待创建", e.project?.status || "未创建"],
["证据", e.evidences.at(-1)?.id || "EVD-待提交", e.evidences.length ? `已提交 ${e.evidences.length} 项` : "未提交"],
["企业评价", e.evaluation?.id || "EVL-待提交", e.evaluation?.result || "未评价"],
["报告", e.report?.id || "RPT-待生成", e.report?.status || "未生成"],
["反馈/复核", e.review?.id || e.usageFeedback.at(-1)?.id || e.receipt.id, e.review?.decision || (e.usageFeedback.length ? "已反馈" : e.receipt.status)],
];
root.innerHTML = stages
.map(
([label, id, status], index) =>
`<div class="loop-stage ${loopStatusClass(status)}"><i>${String(index + 1).padStart(2, "0")}</i><span><small>${label}</small><strong>${safeText(id)}</strong><em>${safeText(status)}</em></span></div>`,
)
.join("");
const evidence = e.evidences.at(-1);
const feedback = e.usageFeedback.at(-1);
document.getElementById("loopEntityGrid").innerHTML = `
<article><span>企业需求 · ${e.demand.id}</span><strong>${safeText(e.demand.position)} · ${e.demand.headcount}人</strong><small>技能:${safeText(e.demand.skills)}</small><small>授权:${safeText(e.demand.authorizationNo)} · 至 ${e.demand.validUntil}</small><small>提交:${safeText(e.demand.submitter)} · ${e.demand.submittedAt}</small></article>
<article><span>分析快照 · ${e.snapshot.id}</span><strong>${e.snapshot.status} · ${e.snapshot.checkedRecords.toLocaleString("zh-CN")} / ${e.snapshot.expectedRecords.toLocaleString("zh-CN")} 条</strong><small>${e.snapshot.packageId} / ${e.snapshot.packageVersion}</small><small>包回执:${e.receipt.receivedRecords.toLocaleString("zh-CN")} / ${e.receipt.expectedRecords.toLocaleString("zh-CN")} 条</small><small class="loop-hash">SHA ${safeText(e.receipt.receivedSha256 || e.receipt.expectedSha256)}</small><small>技术接收:${safeText(e.snapshot.operator || "待确认")}</small></article>
<article><span>专业建议 · ${e.recommendation.id}</span><strong>${safeText(e.recommendation.title)}</strong><small>${e.recommendation.status} · ${safeText(e.recommendation.reason || "待填写决策理由")}</small><small>${safeText(e.recommendation.operator || "待专业负责人决策")}</small></article>
<article><span>任务 / 项目</span><strong>${safeText(e.task?.id || "待创建")} / ${safeText(e.project?.id || "待创建")}</strong><small>任务进度 ${e.task?.progress || 0}% · ${safeText(e.task?.status || "未创建")}</small><small>绑定:${safeText(e.project?.demandId || "-")} / ${safeText(e.project?.recommendationId || "-")} / ${safeText(e.project?.taskId || "-")}</small></article>
<article><span>最新证据 / 企业评价</span><strong>${safeText(evidence?.id || "暂无证据")} / ${safeText(e.evaluation?.id || "暂无评价")}</strong><small>${safeText(evidence ? `${evidence.name} · ${evidence.type} · ${evidence.fileRef}` : "提交证据后自动更新进度")}</small><small>${safeText(e.evaluation ? `${e.evaluation.result} · ${e.evaluation.comment}` : "企业导师仅可评价本企业对象")}</small></article>
<article><span>报告 / 回执 / 使用反馈</span><strong>${safeText(e.report?.id || "待生成")} · ${safeText(e.report?.status || "未生成")}</strong><small>${e.receipt.id} · ${e.receipt.status}</small><small>${safeText(feedback ? `${feedback.department}:${feedback.usable} · ${feedback.comment}` : "就业老师尚未登记使用反馈")}</small></article>`;
const role = currentRoleName();
const boundary = {
校级管理者: "可校级验收、报告批准/退回与正式发布;不代替技术接收或专业决策。",
校级技术管理员: "负责包接收、SHA/记录数质量校验与快照验收;不能批准业务报告。",
学院负责人: "可提交项目阶段证据、生成和提交报告;校级审批权不可用。",
专业负责人: "可决策专业建议、推进任务项目、提交证据并完成周期复核。",
就业老师: "可登记就业侧数据使用反馈;不能审批校级报告。",
企业导师: "仅可提交和评价海康威视 ENT-HKVS 的需求、项目和任务对象。",
厂商超级管理员: "校园闭环只读;共享标识用于静态原型业务对照,不构成实时同步。",
};
document.getElementById("loopRoleActionTitle").textContent = `${role}下一步`;
document.getElementById("loopRoleBoundary").innerHTML = `<strong>权限边界</strong><p>${boundary[role]}</p>`;
const actionLabels = {
demand: "提交 / 更新本企业需求",
receive: "确认包与需求接收",
decision: "决策专业建议",
project: "创建关联项目",
evidence: "提交进展与证据",
evaluation: "提交企业评价",
acceptance: "校级验收 / 退回",
reportCreate: "生成闭环报告草稿",
reportSubmit: "提交报告审核",
reportReview: "批准 / 退回报告",
reportPublish: "正式发布报告",
feedback: "登记就业使用反馈",
review: "下一周期复核",
};
const roleActions = {
企业导师: ["demand", "evaluation"],
校级技术管理员: ["receive"],
专业负责人: ["decision", "project", "evidence", "reportCreate", "reportSubmit", "review"],
学院负责人: ["evidence", "reportCreate", "reportSubmit"],
校级管理者: ["acceptance", "reportCreate", "reportSubmit", "reportReview", "reportPublish"],
就业老师: ["feedback"],
厂商超级管理员: [],
}[role] || [];
document.getElementById("loopActionList").innerHTML =
roleActions.map((action) => renderClosedLoopAction(action, actionLabels[action])).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;
}
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 = `CLOSED LOOP · ${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 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: "TSK-SOFTWARE-20260823-01",
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: "PRJ-HKVS-20260823-01",
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 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 ? "待企业评价" : "进行中";
addClosedLoopAudit("EVIDENCE_SUBMITTED", evidence.id, `${values.name} · ${values.fileRef} · 进度${e.task.progress}%`);
} else if (action === "evaluation") {
e.evaluation = {
id: "EVL-HKVS-20260823-01",
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({ result: values.result, comment: values.comment, operator: currentRoleUser(), at });
addClosedLoopAudit("SCHOOL_ACCEPTANCE_RECORDED", e.task.id, `${values.result} · ${values.comment}`);
} else if (action === "reportCreate") {
e.report = {
id: "RPT-HZVTC-20260823-01",
title: values.title,
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") {
e.review = {
id: "RVW-SOFTWARE-2027C1-01",
before: Number(values.before),
after: Number(values.after),
conclusion: values.conclusion,
decision: values.decision,
comment: values.comment,
operator: currentRoleUser(),
at,
};
addClosedLoopAudit("NEXT_CYCLE_REVIEW_RECORDED", e.review.id, `${values.before}→${values.after} · ${values.conclusion} · ${values.decision}`);
}
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: "云平台能力成为共同短板", 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",
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 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%" },
]);
}
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>`;
}
}
const dataOpsState = {
context: "source",
sourceName: sources[0].name,
tab: "overview",
issue: null,
};
const dataIssueCatalog = {
"salary-missing": {
title: "就业系统薪资字段缺失",
level: "阻断发布",
sourceName: "就业管理系统",
affected: "86 条记录 · 电子商务专业 41 条、现代服务类专业 45 条",
reason: "employment_salary 为空,未达到必填完整率 95% 的发布阈值。",
action: "由就业处补齐薪资或填写缺失原因,修复文件按 student_id + cohort 回写后重新校验。",
},
"job-normalization": {
title: "新增岗位名称待归一",
level: "需要复核",
sourceName: "区域招聘岗位基础包",
affected: "218 个岗位别名 · 影响 1,426 条招聘记录",
reason: "新增“具身智能应用工程师”等岗位未映射到当前岗位分类词典 V3.2。",
action: "优先审核需求量前 50 的岗位名称,确认标准岗位、技能标签及别名规则。",
},
"enterprise-duplicate": {
title: "疑似重复企业记录",
level: "一般事项",
sourceName: "企业确认需求库",
affected: "12 家企业 · 涉及 37 项岗位需求",
reason: "企业简称不同,但统一社会信用代码或工商地址高度相似。",
action: "由产教融合中心确认主企业记录,合并需求与授权回执并保留审计映射。",
},
};
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.steward}</dd></div>
<div><dt>技术责任人</dt><dd>${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.steward}</strong></span><span>技术责任人<strong>${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(issueKey) {
const issue = dataIssueCatalog[issueKey];
return `
<div class="data-issue-hero">
<span>${issue.level}</span><h3>${issue.title}</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>沿用稳定版本</strong></span><span>责任部门<strong>${currentDataSource().steward}</strong></span></div>
</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 = dataIssueCatalog[dataOpsState.issue];
const issueSource = sources.find((item) => item.name === issue.sourceName);
if (issueSource) dataOpsState.sourceName = issueSource.name;
document.getElementById("dataOpsKicker").textContent = "DATA QUALITY WORKBENCH";
document.getElementById("dataOpsTitle").textContent = issue.title;
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: "AI将岗位、技能和专业变化归纳为可解释的决策简报,并保留证据定位。" },
{ 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 button = event.target.closest("[data-loop-action]");
if (button) openClosedLoopAction(button.dataset.loopAction);
});
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();
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() {
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();
setupGenericInteractions();
setupRoleExperience();
setupPlatformActions();
setupAnalysis();
setupReportGeneration();
setupReportCenterInteractions();
setupForms();
setupFilters();
setupSourceFilters();
setupDataOperations();
setupWorkspaceDetails();
setupProductExperience();
setupClosedLoop();
renderClosedLoop();
}
document.addEventListener("DOMContentLoaded", init);