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