- 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>
4035 lines
178 KiB
JavaScript
4035 lines
178 KiB
JavaScript
const sources = [
|
||
{
|
||
id: "boss",
|
||
name: "BOSS直聘",
|
||
short: "BOSS",
|
||
category: "commercial",
|
||
categoryLabel: "商业平台",
|
||
domain: "www.bosszhipin.com",
|
||
adapter: "BOSS-Collector 4.6",
|
||
mode: "网页/API混合采集",
|
||
schedule: "每 6 小时",
|
||
status: "running",
|
||
statusLabel: "正式运行",
|
||
health: 98.6,
|
||
today: 68420,
|
||
total: "4.86M",
|
||
updated: "10:42",
|
||
coverage: "全国 · 重点产业岗位",
|
||
contract: "JOB-SIGNAL V3.0",
|
||
schema: "SOURCE-BOSS 2.5",
|
||
version: "SRC-BOSS-20260823-04",
|
||
owner: "商业源采集组",
|
||
retention: "原始响应 180 天 · 解析结果 36 个月",
|
||
rate: "自适应窗口 · 64 分片",
|
||
retry: "指数退避 · 最多 5 次",
|
||
change: "新增低空经济、具身智能岗位词表;更新城市筛选与增量水位。",
|
||
repo: "connector-registry/boss-collector",
|
||
codeVersion: "release/4.6.2 · 4f8d91a",
|
||
runtime: [
|
||
["10:42", "增量分片完成", "64/64 分片 · 68,420 条"],
|
||
["06:18", "原始快照固化", "RAW-BOSS-20260823-01 · SHA-256 已记录"],
|
||
["02:31", "解析规则发布", "PARSER-BOSS 4.6.2 · 回归样本 1,000 条"],
|
||
],
|
||
},
|
||
{
|
||
id: "zhaopin",
|
||
name: "智联招聘",
|
||
short: "智联",
|
||
category: "commercial",
|
||
categoryLabel: "商业平台",
|
||
domain: "www.zhaopin.com",
|
||
adapter: "ZHAOPIN-Collector 3.8",
|
||
mode: "网页/API混合采集",
|
||
schedule: "每 6 小时",
|
||
status: "running",
|
||
statusLabel: "正式运行",
|
||
health: 97.9,
|
||
today: 52360,
|
||
total: "3.92M",
|
||
updated: "10:38",
|
||
coverage: "全国 · 校招与社会招聘",
|
||
contract: "JOB-SIGNAL V3.0",
|
||
schema: "SOURCE-ZL 2.2",
|
||
version: "SRC-ZL-20260823-04",
|
||
owner: "商业源采集组",
|
||
retention: "原始响应 180 天 · 解析结果 36 个月",
|
||
rate: "自适应窗口 · 48 分片",
|
||
retry: "指数退避 · 最多 5 次",
|
||
change: "补充企业性质与招聘人数映射;岗位有效期按源端状态增量维护。",
|
||
repo: "connector-registry/zhaopin-collector",
|
||
codeVersion: "release/3.8.1 · 96c2be7",
|
||
runtime: [
|
||
["10:38", "增量任务运行中", "44/48 分片 · 52,360 条"],
|
||
["06:05", "字段完整性预检", "完整率 98.8% · 通过"],
|
||
["02:16", "代码版本部署", "ZHAOPIN-Collector 3.8.1"],
|
||
],
|
||
},
|
||
{
|
||
id: "brzp",
|
||
name: "全国中小企业招聘平台",
|
||
short: "百日",
|
||
category: "public",
|
||
categoryLabel: "公共平台",
|
||
domain: "brzp.chinasme.cn",
|
||
adapter: "BRZP-Collector 2.1",
|
||
mode: "活动聚合与岗位采集",
|
||
schedule: "每日 01:30",
|
||
status: "running",
|
||
statusLabel: "正式运行",
|
||
health: 99.2,
|
||
today: 18640,
|
||
total: "824K",
|
||
updated: "09:55",
|
||
coverage: "全国中小企业 · 校招 · 实习",
|
||
contract: "JOB-SIGNAL V3.0",
|
||
schema: "SOURCE-BRZP 1.4",
|
||
version: "SRC-BRZP-20260823-01",
|
||
owner: "公共源采集组",
|
||
retention: "原始页面 12 个月 · 解析结果 36 个月",
|
||
rate: "16 分片 · 活动页优先",
|
||
retry: "失败分片次日补采",
|
||
change: "同步全国中小企业招聘活动、地方专区、校园招聘和实习机会。",
|
||
repo: "connector-registry/brzp-collector",
|
||
codeVersion: "release/2.1.0 · c2d8f73",
|
||
runtime: [
|
||
["09:55", "活动专区补采完成", "新增活动 18 个 · 岗位 2,846 条"],
|
||
["03:08", "聚合来源校验", "9 个合作活动入口可访问"],
|
||
["01:42", "每日全量完成", "18,640 条 · 有效率 98.6%"],
|
||
],
|
||
},
|
||
{
|
||
id: "ncss",
|
||
name: "国家大学生就业服务平台",
|
||
short: "国就",
|
||
category: "public",
|
||
categoryLabel: "公共平台",
|
||
domain: "www.ncss.cn",
|
||
adapter: "NCSS-Collector 2.8",
|
||
mode: "职位与专场采集",
|
||
schedule: "每日 02:00",
|
||
status: "running",
|
||
statusLabel: "正式运行",
|
||
health: 99.4,
|
||
today: 14820,
|
||
total: "1.32M",
|
||
updated: "08:40",
|
||
coverage: "高校毕业生 · 校招 · 实习",
|
||
contract: "JOB-SIGNAL V3.0",
|
||
schema: "SOURCE-NCSS 2.0",
|
||
version: "SRC-NCSS-20260823-01",
|
||
owner: "公共源采集组",
|
||
retention: "原始页面 12 个月 · 解析结果 36 个月",
|
||
rate: "24 分片 · 专场增量",
|
||
retry: "指数退避 · 最多 3 次",
|
||
change: "更新2026届国企岗位专题、联合招聘与实习岗位。",
|
||
repo: "connector-registry/ncss-collector",
|
||
codeVersion: "release/2.8.0 · 1a8d6e2",
|
||
runtime: [
|
||
["08:40", "职位增量完成", "14,820 条 · 1,286 家单位"],
|
||
["02:56", "专场活动同步", "新增 11 场联合招聘"],
|
||
["02:01", "调度任务启动", "24 个地域分片"],
|
||
],
|
||
},
|
||
{
|
||
id: "mohrss",
|
||
name: "中国公共招聘网",
|
||
short: "公招",
|
||
category: "public",
|
||
categoryLabel: "公共平台",
|
||
domain: "job.mohrss.gov.cn",
|
||
adapter: "MOHRSS-Collector 3.2",
|
||
mode: "公共岗位与招聘会采集",
|
||
schedule: "每日 02:30",
|
||
status: "running",
|
||
statusLabel: "正式运行",
|
||
health: 99.6,
|
||
today: 12680,
|
||
total: "1.86M",
|
||
updated: "08:26",
|
||
coverage: "全国公共就业岗位 · 重点行业",
|
||
contract: "JOB-SIGNAL V3.0",
|
||
schema: "SOURCE-MOHRSS 2.3",
|
||
version: "SRC-MOHRSS-20260823-01",
|
||
owner: "公共源采集组",
|
||
retention: "原始页面 12 个月 · 解析结果 36 个月",
|
||
rate: "20 分片 · 地域增量",
|
||
retry: "失败分片次日补采",
|
||
change: "更新制造业招聘专区、名企招聘和中央企业招聘信息。",
|
||
repo: "connector-registry/mohrss-collector",
|
||
codeVersion: "release/3.2.4 · 66e102b",
|
||
runtime: [
|
||
["08:26", "增量任务完成", "12,680 条 · 有效率 99.1%"],
|
||
["03:12", "重点专区同步", "制造业与中央企业专区已更新"],
|
||
["02:31", "调度任务启动", "20 个地域分片"],
|
||
],
|
||
},
|
||
{
|
||
id: "iguopin",
|
||
name: "国聘",
|
||
short: "国聘",
|
||
category: "public",
|
||
categoryLabel: "公共平台",
|
||
domain: "www.iguopin.com",
|
||
adapter: "IGUOPIN-Collector 2.4",
|
||
mode: "国资央企岗位采集",
|
||
schedule: "每日 03:00",
|
||
status: "running",
|
||
statusLabel: "正式运行",
|
||
health: 98.8,
|
||
today: 9640,
|
||
total: "642K",
|
||
updated: "08:15",
|
||
coverage: "央企国企 · 校园招聘",
|
||
contract: "JOB-SIGNAL V3.0",
|
||
schema: "SOURCE-GP 1.8",
|
||
version: "SRC-GP-20260823-01",
|
||
owner: "公共源采集组",
|
||
retention: "原始响应 12 个月 · 解析结果 36 个月",
|
||
rate: "12 分片 · 企业专题增量",
|
||
retry: "指数退避 · 最多 3 次",
|
||
change: "更新国资央企2026届岗位与重点企业专题。",
|
||
repo: "connector-registry/iguopin-collector",
|
||
codeVersion: "release/2.4.1 · a831bd0",
|
||
runtime: [
|
||
["08:15", "企业专题同步完成", "9,640 条 · 312 家企业"],
|
||
["03:44", "动态页面解析完成", "页面成功率 99.2%"],
|
||
["03:01", "调度任务启动", "12 个企业分片"],
|
||
],
|
||
},
|
||
{
|
||
id: "hzrc",
|
||
name: "杭州人才网",
|
||
short: "杭才",
|
||
category: "public",
|
||
categoryLabel: "公共平台",
|
||
domain: "www.hzrc.com",
|
||
adapter: "HZRC-Collector 2.7",
|
||
mode: "本地人才岗位采集",
|
||
schedule: "每 12 小时",
|
||
status: "attention",
|
||
statusLabel: "降速保护",
|
||
health: 92.4,
|
||
today: 3860,
|
||
total: "386K",
|
||
updated: "10:28",
|
||
coverage: "杭州都市圈 · 本地企业",
|
||
contract: "JOB-SIGNAL V3.0",
|
||
schema: "SOURCE-HZRC 2.0",
|
||
version: "SRC-HZRC-20260823-02",
|
||
owner: "区域源采集组",
|
||
retention: "原始页面 12 个月 · 解析结果 36 个月",
|
||
rate: "源端延迟保护 · 4 分片",
|
||
retry: "15分钟后自动重试",
|
||
change: "响应时延升高,采集器自动降低并发;已完成数据保持可用。",
|
||
repo: "connector-registry/hzrc-collector",
|
||
codeVersion: "release/2.7.3 · d09f71c",
|
||
runtime: [
|
||
["10:28", "触发自动降速", "P95 4.8s · 并发 12 → 4"],
|
||
["09:52", "增量任务继续", "3,860 条 · 8 分片待执行"],
|
||
["02:48", "全量索引校验", "岗位唯一率 99.7%"],
|
||
],
|
||
},
|
||
{
|
||
id: "school-employment",
|
||
name: "校内就业管理系统",
|
||
short: "就业",
|
||
category: "school",
|
||
categoryLabel: "校内系统",
|
||
domain: "school://employment-view",
|
||
adapter: "School-DB-Connector 2.3",
|
||
mode: "只读数据库视图",
|
||
schedule: "每日 04:00",
|
||
status: "running",
|
||
statusLabel: "正式运行",
|
||
health: 97.8,
|
||
today: 4280,
|
||
total: "928K",
|
||
updated: "07:52",
|
||
coverage: "36所学校 · 近三届毕业生",
|
||
contract: "SCHOOL-EMPLOYMENT V2.2",
|
||
schema: "EMPLOYMENT-VIEW 2.2",
|
||
version: "SCH-EMP-20260823-01",
|
||
owner: "学校数据接入组",
|
||
retention: "明细按租户策略 · 汇总 36 个月",
|
||
rate: "租户隔离 · 12 并行连接",
|
||
retry: "单租户失败隔离",
|
||
change: "新增就业质量、岗位相关度和去向确认字段。",
|
||
repo: "connector-registry/school-db-connector",
|
||
codeVersion: "release/2.3.0 · 310ae74",
|
||
runtime: [
|
||
["07:52", "36租户同步完成", "4,280 条变更 · 2 条隔离"],
|
||
["04:46", "跨表关联校验", "学生与专业关联率 99.6%"],
|
||
["04:01", "每日任务启动", "36 个租户分片"],
|
||
],
|
||
},
|
||
{
|
||
id: "school-internship",
|
||
name: "校内实习管理系统",
|
||
short: "实习",
|
||
category: "school",
|
||
categoryLabel: "校内系统",
|
||
domain: "school://internship-api",
|
||
adapter: "School-API-Connector 2.5",
|
||
mode: "租户 API 接入",
|
||
schedule: "每日 04:30",
|
||
status: "running",
|
||
statusLabel: "正式运行",
|
||
health: 98.4,
|
||
today: 5720,
|
||
total: "1.18M",
|
||
updated: "08:03",
|
||
coverage: "36所学校 · 实习全过程",
|
||
contract: "SCHOOL-INTERNSHIP V2.1",
|
||
schema: "INTERNSHIP-API 2.1",
|
||
version: "SCH-INT-20260823-01",
|
||
owner: "学校数据接入组",
|
||
retention: "明细按租户策略 · 汇总 36 个月",
|
||
rate: "租户隔离 · 12 并行连接",
|
||
retry: "单租户失败隔离",
|
||
change: "补充企业导师评价、实习工资与岗位任务字段。",
|
||
repo: "connector-registry/school-api-connector",
|
||
codeVersion: "release/2.5.2 · 8b17a63",
|
||
runtime: [
|
||
["08:03", "36租户同步完成", "5,720 条变更 · 全部通过"],
|
||
["05:10", "接口水位提交", "watermark 2026-08-23T04:30:00+08:00"],
|
||
["04:31", "每日任务启动", "36 个租户分片"],
|
||
],
|
||
},
|
||
];
|
||
|
||
const collectionJobs = [
|
||
{
|
||
id: "JOB-20260823-1042",
|
||
name: "BOSS重点产业岗位增量",
|
||
sourceId: "boss",
|
||
scope: "全国 · 数字技术/智能制造",
|
||
strategy: "增量 · 64分片 · 自适应",
|
||
progress: 68,
|
||
records: "68,420",
|
||
valid: "98.5%",
|
||
status: "running",
|
||
statusLabel: "运行中",
|
||
},
|
||
{
|
||
id: "JOB-20260823-1038",
|
||
name: "智联全国岗位增量",
|
||
sourceId: "zhaopin",
|
||
scope: "全国 · 校招与社招",
|
||
strategy: "增量 · 48分片 · 自适应",
|
||
progress: 91,
|
||
records: "52,360",
|
||
valid: "98.8%",
|
||
status: "running",
|
||
statusLabel: "运行中",
|
||
},
|
||
{
|
||
id: "JOB-20260823-1028",
|
||
name: "杭州人才网区域补采",
|
||
sourceId: "hzrc",
|
||
scope: "杭州都市圈 · 全岗位",
|
||
strategy: "补采 · 4分片 · 降速",
|
||
progress: 42,
|
||
records: "3,860",
|
||
valid: "96.4%",
|
||
status: "attention",
|
||
statusLabel: "降速保护",
|
||
},
|
||
{
|
||
id: "JOB-20260824-0130",
|
||
name: "百日招聘每日全量",
|
||
sourceId: "brzp",
|
||
scope: "全国 · 校招/实习/活动",
|
||
strategy: "全量 · 16分片",
|
||
progress: 0,
|
||
records: "—",
|
||
valid: "—",
|
||
status: "scheduled",
|
||
statusLabel: "等待调度",
|
||
},
|
||
{
|
||
id: "JOB-20260824-0200",
|
||
name: "国家大学生就业职位增量",
|
||
sourceId: "ncss",
|
||
scope: "全国 · 2026届",
|
||
strategy: "增量 · 24分片",
|
||
progress: 0,
|
||
records: "—",
|
||
valid: "—",
|
||
status: "scheduled",
|
||
statusLabel: "等待调度",
|
||
},
|
||
{
|
||
id: "JOB-20260824-0400",
|
||
name: "校内就业36租户同步",
|
||
sourceId: "school-employment",
|
||
scope: "36所学校 · 近三届",
|
||
strategy: "增量 · 租户隔离",
|
||
progress: 0,
|
||
records: "—",
|
||
valid: "—",
|
||
status: "scheduled",
|
||
statusLabel: "等待调度",
|
||
},
|
||
];
|
||
|
||
const standardJobRecords = [
|
||
{
|
||
id: "e5a87030-3c5f-4d74-a3ca-89427e2f75a1",
|
||
sourceId: "boss",
|
||
sourceRecordId: "BOSS-20260823-884201",
|
||
sourceUrl: "https://www.bosszhipin.com/job_detail/sample-884201.html",
|
||
taskId: "JOB-20260823-1042",
|
||
snapshotId: "RAW-BOSS-20260823-000884",
|
||
collectorVersion: "BOSS-Collector-4.6.2",
|
||
parserVersion: "PARSER-BOSS-4.6.2",
|
||
jobTitleRaw: "云原生开发工程师",
|
||
jobTitleNormalized: "云计算工程技术人员",
|
||
jobCode: "IT.CLOUD.DEV",
|
||
enterprise: "杭州示例数字科技有限公司",
|
||
region: "杭州",
|
||
regionCode: "330100",
|
||
workplace: "杭州市滨江区",
|
||
salaryMin: 12000,
|
||
salaryMax: 18000,
|
||
employmentType: "全职",
|
||
publishedAt: "2026-08-21 08:56",
|
||
collectedAt: "2026-08-23 10:42",
|
||
normalizedAt: "2026-08-23 10:45",
|
||
quality: 98.8,
|
||
status: "有效",
|
||
skills: ["Docker", "Kubernetes", "Java", "CI/CD"],
|
||
description: "负责容器平台应用开发、持续集成和服务可观测性建设。",
|
||
contentHash: "96c2be742dc43f8e7ee526db6358030873406c450f15e67225c605dc39b431a8",
|
||
payloadHash: "4f8d91a47e2b6d82b2aaad136d344d61c83f9cd472185d2543725c91870e9e32",
|
||
dataVersion: "V2026.08.23",
|
||
},
|
||
{
|
||
id: "57df68fe-2454-44d2-9f3d-20e91ac0912a",
|
||
sourceId: "zhaopin",
|
||
sourceRecordId: "ZL-20260823-620311",
|
||
sourceUrl: "https://www.zhaopin.com/jobdetail/sample-620311.htm",
|
||
taskId: "JOB-20260823-1038",
|
||
snapshotId: "RAW-ZHAOPIN-20260823-000620",
|
||
collectorVersion: "ZHAOPIN-Collector-3.8.1",
|
||
parserVersion: "PARSER-ZHAOPIN-3.8.1",
|
||
jobTitleRaw: "工业视觉应用工程师",
|
||
jobTitleNormalized: "人工智能工程技术人员",
|
||
jobCode: "AI.VISION.APP",
|
||
enterprise: "浙江示例智能装备股份有限公司",
|
||
region: "宁波",
|
||
regionCode: "330200",
|
||
workplace: "宁波市鄞州区",
|
||
salaryMin: 10000,
|
||
salaryMax: 16000,
|
||
employmentType: "全职",
|
||
publishedAt: "2026-08-22 14:05",
|
||
collectedAt: "2026-08-23 10:38",
|
||
normalizedAt: "2026-08-23 10:41",
|
||
quality: 98.6,
|
||
status: "有效",
|
||
skills: ["Python", "OpenCV", "机器视觉", "PLC"],
|
||
description: "负责机器视觉方案部署、相机标定、缺陷检测模型优化和产线集成。",
|
||
contentHash: "66e102b99bf056868670ac971011740c6f66f62b86ea43cc66326ed9a0a5807e",
|
||
payloadHash: "c2d8f733e6711219823110d7d35fdc5535f133ed60f60950f9dc78a228cb402d",
|
||
dataVersion: "V2026.08.23",
|
||
},
|
||
{
|
||
id: "62eaa80d-0b30-462b-8eca-c6c9bb5e8446",
|
||
sourceId: "brzp",
|
||
sourceRecordId: "BRZP-20260823-110028",
|
||
sourceUrl: "https://brzp.chinasme.cn/brzp/job/sample-110028",
|
||
taskId: "JOB-20260823-0955",
|
||
snapshotId: "RAW-BRZP-20260823-000110",
|
||
collectorVersion: "BRZP-Collector-2.1.0",
|
||
parserVersion: "PARSER-BRZP-2.1.0",
|
||
jobTitleRaw: "跨境电商运营实习生",
|
||
jobTitleNormalized: "电子商务师",
|
||
jobCode: "EC.CROSSBORDER.OPS",
|
||
enterprise: "浙江示例跨境服务有限公司",
|
||
region: "嘉兴",
|
||
regionCode: "330400",
|
||
workplace: "嘉兴市南湖区",
|
||
salaryMin: 3500,
|
||
salaryMax: 5000,
|
||
employmentType: "实习",
|
||
publishedAt: "2026-08-22 18:00",
|
||
collectedAt: "2026-08-23 09:55",
|
||
normalizedAt: "2026-08-23 10:00",
|
||
quality: 97.9,
|
||
status: "有效",
|
||
skills: ["跨境电商", "内容运营", "数据分析", "英语"],
|
||
description: "协助店铺运营、海外内容制作、经营数据分析与合规资料整理。",
|
||
contentHash: "310ae74d6d695235229da958f8fb8e59af6ea4bebef659dfdc1a6bc0764f3f30",
|
||
payloadHash: "a831bd0d4e29cab1e650f36a4fc9c5d5cc8053f560b5d3dc7aac62b384b47e11",
|
||
dataVersion: "V2026.08.23",
|
||
},
|
||
{
|
||
id: "84c7ac1a-2a5c-4bcc-b64a-fc9cdf42b081",
|
||
sourceId: "ncss",
|
||
sourceRecordId: "NCSS-20260823-381205",
|
||
sourceUrl: "https://www.ncss.cn/student/jobs/sample-381205.html",
|
||
taskId: "JOB-20260823-0840",
|
||
snapshotId: "RAW-NCSS-20260823-000381",
|
||
collectorVersion: "NCSS-Collector-2.8.0",
|
||
parserVersion: "PARSER-NCSS-2.8.0",
|
||
jobTitleRaw: "智能网联汽车测试工程师",
|
||
jobTitleNormalized: "汽车工程技术人员",
|
||
jobCode: "AUTO.ICV.TEST",
|
||
enterprise: "浙江示例汽车技术研究院",
|
||
region: "杭州",
|
||
regionCode: "330100",
|
||
workplace: "杭州市钱塘区",
|
||
salaryMin: 9000,
|
||
salaryMax: 14000,
|
||
employmentType: "校招",
|
||
publishedAt: "2026-08-22 10:18",
|
||
collectedAt: "2026-08-23 08:40",
|
||
normalizedAt: "2026-08-23 08:44",
|
||
quality: 99.1,
|
||
status: "有效",
|
||
skills: ["CAN", "自动驾驶", "测试开发", "Python"],
|
||
description: "承担智能网联汽车道路测试、数据采集、场景回放和测试报告编制。",
|
||
contentHash: "18f4".repeat(16),
|
||
payloadHash: "7ca2".repeat(16),
|
||
dataVersion: "V2026.08.23",
|
||
},
|
||
{
|
||
id: "3852086b-33d7-4ed5-9d90-52f1fd6f3a14",
|
||
sourceId: "mohrss",
|
||
sourceRecordId: "MOHRSS-20260823-226801",
|
||
sourceUrl: "https://job.mohrss.gov.cn/job/sample-226801",
|
||
taskId: "JOB-20260823-0826",
|
||
snapshotId: "RAW-MOHRSS-20260823-000226",
|
||
collectorVersion: "MOHRSS-Collector-3.2.4",
|
||
parserVersion: "PARSER-MOHRSS-3.2.4",
|
||
jobTitleRaw: "工业机器人系统运维员",
|
||
jobTitleNormalized: "工业机器人系统运维员",
|
||
jobCode: "MFG.ROBOT.OPS",
|
||
enterprise: "绍兴示例精密制造有限公司",
|
||
region: "绍兴",
|
||
regionCode: "330600",
|
||
workplace: "绍兴市柯桥区",
|
||
salaryMin: 8000,
|
||
salaryMax: 12000,
|
||
employmentType: "全职",
|
||
publishedAt: "2026-08-21 16:35",
|
||
collectedAt: "2026-08-23 08:26",
|
||
normalizedAt: "2026-08-23 08:30",
|
||
quality: 98.9,
|
||
status: "有效",
|
||
skills: ["工业机器人", "设备维护", "电气控制", "故障诊断"],
|
||
description: "负责工业机器人工作站巡检、预防性维护、故障诊断与备件管理。",
|
||
contentHash: "66a8".repeat(16),
|
||
payloadHash: "04de".repeat(16),
|
||
dataVersion: "V2026.08.23",
|
||
},
|
||
{
|
||
id: "c0f2185a-b718-4c47-82e5-21b082eeb2ed",
|
||
sourceId: "iguopin",
|
||
sourceRecordId: "GP-20260823-119640",
|
||
sourceUrl: "https://www.iguopin.com/job/detail/sample-119640",
|
||
taskId: "JOB-20260823-0815",
|
||
snapshotId: "RAW-IGUOPIN-20260823-000119",
|
||
collectorVersion: "IGUOPIN-Collector-2.4.1",
|
||
parserVersion: "PARSER-IGUOPIN-2.4.1",
|
||
jobTitleRaw: "数据治理工程师",
|
||
jobTitleNormalized: "数据工程技术人员",
|
||
jobCode: "IT.DATA.GOV",
|
||
enterprise: "华东示例产业数字集团",
|
||
region: "上海",
|
||
regionCode: "310000",
|
||
workplace: "上海市浦东新区",
|
||
salaryMin: 15000,
|
||
salaryMax: 24000,
|
||
employmentType: "校招",
|
||
publishedAt: "2026-08-22 09:20",
|
||
collectedAt: "2026-08-23 08:15",
|
||
normalizedAt: "2026-08-23 08:19",
|
||
quality: 99.3,
|
||
status: "有效",
|
||
skills: ["数据标准", "元数据", "数据质量", "SQL"],
|
||
description: "参与集团数据标准、元数据、主数据和数据质量体系建设。",
|
||
contentHash: "9ec1".repeat(16),
|
||
payloadHash: "81bd".repeat(16),
|
||
dataVersion: "V2026.08.23",
|
||
},
|
||
{
|
||
id: "9dbf174b-4f0d-45ea-aa66-031a42ee802a",
|
||
sourceId: "hzrc",
|
||
sourceRecordId: "HZRC-20260823-038602",
|
||
sourceUrl: "https://www.hzrc.com/job/sample-038602",
|
||
taskId: "JOB-20260823-1028",
|
||
snapshotId: "RAW-HZRC-20260823-000038",
|
||
collectorVersion: "HZRC-Collector-2.7.3",
|
||
parserVersion: "PARSER-HZRC-2.7.3",
|
||
jobTitleRaw: "低空运营调度师",
|
||
jobTitleNormalized: "航空运输服务人员",
|
||
jobCode: "AVIATION.LOWALT.DISPATCH",
|
||
enterprise: "杭州示例低空运营有限公司",
|
||
region: "杭州",
|
||
regionCode: "330100",
|
||
workplace: "杭州市余杭区",
|
||
salaryMin: 9000,
|
||
salaryMax: 15000,
|
||
employmentType: "全职",
|
||
publishedAt: "2026-08-23 07:25",
|
||
collectedAt: "2026-08-23 10:28",
|
||
normalizedAt: "2026-08-23 10:31",
|
||
quality: 92.8,
|
||
status: "有效",
|
||
skills: ["飞行计划", "运行控制", "空域管理", "应急处置"],
|
||
description: "负责低空飞行任务排班、运行监控、空域协调和异常事件处置。",
|
||
contentHash: "d09f".repeat(16),
|
||
payloadHash: "375a".repeat(16),
|
||
dataVersion: "V2026.08.23",
|
||
},
|
||
{
|
||
id: "f45cf9a9-b6e7-4249-bb11-fec640df9dae",
|
||
sourceId: "boss",
|
||
sourceRecordId: "BOSS-20260823-884733",
|
||
sourceUrl: "https://www.bosszhipin.com/job_detail/sample-884733.html",
|
||
taskId: "JOB-20260823-1042",
|
||
snapshotId: "RAW-BOSS-20260823-000885",
|
||
collectorVersion: "BOSS-Collector-4.6.2",
|
||
parserVersion: "PARSER-BOSS-4.6.2",
|
||
jobTitleRaw: "新能源电池工艺工程师",
|
||
jobTitleNormalized: "电池制造工程技术人员",
|
||
jobCode: "ENERGY.BATTERY.PROCESS",
|
||
enterprise: "宁波示例新能源科技有限公司",
|
||
region: "宁波",
|
||
regionCode: "330200",
|
||
workplace: "宁波市北仑区",
|
||
salaryMin: 13000,
|
||
salaryMax: 21000,
|
||
employmentType: "全职",
|
||
publishedAt: "2026-08-23 08:10",
|
||
collectedAt: "2026-08-23 10:42",
|
||
normalizedAt: "2026-08-23 10:46",
|
||
quality: 98.4,
|
||
status: "有效",
|
||
skills: ["锂电池", "工艺设计", "DOE", "质量分析"],
|
||
description: "负责电池制造工艺设计、参数优化、量产导入和制程质量改进。",
|
||
contentHash: "bfd7".repeat(16),
|
||
payloadHash: "41e2".repeat(16),
|
||
dataVersion: "V2026.08.23",
|
||
},
|
||
{
|
||
id: "9286399d-da74-408d-9882-4e04fb84ce6a",
|
||
sourceId: "zhaopin",
|
||
sourceRecordId: "ZL-20260823-621006",
|
||
sourceUrl: "https://www.zhaopin.com/jobdetail/sample-621006.htm",
|
||
taskId: "JOB-20260823-1038",
|
||
snapshotId: "RAW-ZHAOPIN-20260823-000621",
|
||
collectorVersion: "ZHAOPIN-Collector-3.8.1",
|
||
parserVersion: "PARSER-ZHAOPIN-3.8.1",
|
||
jobTitleRaw: "跨境电商数据分析师",
|
||
jobTitleNormalized: "商务数据分析师",
|
||
jobCode: "EC.DATA.ANALYST",
|
||
enterprise: "嘉兴示例国际贸易有限公司",
|
||
region: "嘉兴",
|
||
regionCode: "330400",
|
||
workplace: "嘉兴市秀洲区",
|
||
salaryMin: 8500,
|
||
salaryMax: 14000,
|
||
employmentType: "全职",
|
||
publishedAt: "2026-08-22 17:30",
|
||
collectedAt: "2026-08-23 10:38",
|
||
normalizedAt: "2026-08-23 10:43",
|
||
quality: 98.2,
|
||
status: "有效",
|
||
skills: ["SQL", "Power BI", "跨境电商", "指标体系"],
|
||
description: "负责跨境业务经营分析、指标看板建设、用户分层和专题复盘。",
|
||
contentHash: "92bd".repeat(16),
|
||
payloadHash: "d371".repeat(16),
|
||
dataVersion: "V2026.08.23",
|
||
},
|
||
{
|
||
id: "4c974cea-17fc-450e-8973-17a6be52851a",
|
||
sourceId: "brzp",
|
||
sourceRecordId: "BRZP-20260823-110309",
|
||
sourceUrl: "https://brzp.chinasme.cn/brzp/job/sample-110309",
|
||
taskId: "JOB-20260823-0955",
|
||
snapshotId: "RAW-BRZP-20260823-000111",
|
||
collectorVersion: "BRZP-Collector-2.1.0",
|
||
parserVersion: "PARSER-BRZP-2.1.0",
|
||
jobTitleRaw: "AI内容运营实习生",
|
||
jobTitleNormalized: "互联网营销师",
|
||
jobCode: "MEDIA.AI.CONTENT",
|
||
enterprise: "苏州示例数字内容有限公司",
|
||
region: "苏州",
|
||
regionCode: "320500",
|
||
workplace: "苏州市工业园区",
|
||
salaryMin: 4000,
|
||
salaryMax: 6000,
|
||
employmentType: "实习",
|
||
publishedAt: "2026-08-22 20:10",
|
||
collectedAt: "2026-08-23 09:55",
|
||
normalizedAt: "2026-08-23 10:02",
|
||
quality: 96.9,
|
||
status: "有效",
|
||
skills: ["AIGC", "内容策划", "短视频", "数据复盘"],
|
||
description: "协助生成式内容策划、短视频运营、素材管理和传播效果复盘。",
|
||
contentHash: "ac53".repeat(16),
|
||
payloadHash: "5b28".repeat(16),
|
||
dataVersion: "V2026.08.23",
|
||
},
|
||
];
|
||
|
||
const rawSnapshots = [
|
||
["RAW-BOSS-20260823-01", "boss", "JOB-20260823-1042", "text/html + JSON", "68,420", "8.42 GB", "2026-08-23 10:42", "4f8d91a47e2b6d82b2aaad136d344d61c83f9cd472185d2543725c91870e9e32", "已固化"],
|
||
["RAW-ZHAOPIN-20260823-01", "zhaopin", "JOB-20260823-1038", "text/html + JSON", "52,360", "6.18 GB", "2026-08-23 10:38", "c2d8f733e6711219823110d7d35fdc5535f133ed60f60950f9dc78a228cb402d", "已固化"],
|
||
["RAW-BRZP-20260823-01", "brzp", "JOB-20260823-0955", "text/html", "18,640", "1.86 GB", "2026-08-23 09:55", "a831bd0d4e29cab1e650f36a4fc9c5d5cc8053f560b5d3dc7aac62b384b47e11", "已固化"],
|
||
["RAW-NCSS-20260823-01", "ncss", "JOB-20260823-0840", "text/html", "14,820", "1.42 GB", "2026-08-23 08:40", "18f4".repeat(16), "已固化"],
|
||
["RAW-MOHRSS-20260823-01", "mohrss", "JOB-20260823-0826", "text/html + JSON", "12,680", "1.28 GB", "2026-08-23 08:26", "66a8".repeat(16), "已固化"],
|
||
["RAW-IGUOPIN-20260823-01", "iguopin", "JOB-20260823-0815", "text/html", "9,640", "968 MB", "2026-08-23 08:15", "9ec1".repeat(16), "已固化"],
|
||
["RAW-HZRC-20260823-02", "hzrc", "JOB-20260823-1028", "text/html", "3,860", "384 MB", "2026-08-23 10:28", "d09f".repeat(16), "已固化"],
|
||
].map(([id, sourceId, taskId, mediaType, records, size, capturedAt, sha256, status]) => ({
|
||
id,
|
||
sourceId,
|
||
taskId,
|
||
mediaType,
|
||
records,
|
||
size,
|
||
capturedAt,
|
||
sha256,
|
||
status,
|
||
objectPath: `raw/2026/08/23/${sourceId}/${id.toLowerCase()}.jsonl.zst`,
|
||
}));
|
||
|
||
const recordRawSnapshots = standardJobRecords.map((record, index) => ({
|
||
id: record.snapshotId,
|
||
sourceId: record.sourceId,
|
||
taskId: record.taskId,
|
||
mediaType: "text/html + JSON",
|
||
records: "1",
|
||
size: `${84 + index * 7} KB`,
|
||
capturedAt: record.collectedAt,
|
||
sha256: record.payloadHash,
|
||
status: "已固化",
|
||
objectPath: `raw/2026/08/23/${record.sourceId}/${record.taskId.toLowerCase()}/${record.snapshotId.toLowerCase()}.response.zst`,
|
||
recordId: record.id,
|
||
}));
|
||
|
||
const allRawSnapshots = [...recordRawSnapshots, ...rawSnapshots];
|
||
|
||
const quarantineRecords = [
|
||
["QUA-20260823-001286", "boss", "BOSS-20260823-884890", "智能装备技术支持", "薪资上下限倒置", "SL-004", "2026-08-23 10:43", "待自动重解析", "JOB-20260823-1042", "RAW-BOSS-20260823-000886", "杭州"],
|
||
["QUA-20260823-001285", "zhaopin", "ZL-20260823-621104", "工业软件实施顾问", "工作地区代码缺失", "RQ-012", "2026-08-23 10:39", "待字段补全", "JOB-20260823-1038", "RAW-ZHAOPIN-20260823-000622", "宁波"],
|
||
["QUA-20260823-001284", "brzp", "BRZP-20260823-110408", "电商运营", "来源详情页已失效", "TM-007", "2026-08-23 09:57", "等待次日复核", "JOB-20260823-0955", "RAW-BRZP-20260823-000112", "嘉兴"],
|
||
["QUA-20260823-001283", "hzrc", "HZRC-20260823-038711", "AI内容策略师", "岗位词典未匹配", "NM-021", "2026-08-23 10:30", "进入人工归一", "JOB-20260823-1028", "RAW-HZRC-20260823-000039", "杭州"],
|
||
["QUA-20260823-001282", "mohrss", "MOHRSS-20260823-226955", "设备维护员", "企业名称完整性不足", "RQ-018", "2026-08-23 08:28", "待规则复核", "JOB-20260823-0826", "RAW-MOHRSS-20260823-000227", "绍兴"],
|
||
].map(([id, sourceId, sourceRecordId, jobTitle, issue, rule, detectedAt, disposition, taskId, snapshotId, region]) => ({
|
||
id,
|
||
sourceId,
|
||
sourceRecordId,
|
||
jobTitle,
|
||
issue,
|
||
rule,
|
||
detectedAt,
|
||
disposition,
|
||
taskId,
|
||
snapshotId,
|
||
region,
|
||
}));
|
||
|
||
const contracts = {
|
||
"external-job": {
|
||
key: "JOB-SIGNAL",
|
||
title: "外部招聘岗位信号标准",
|
||
version: "V3.0.0",
|
||
description: "统一商业招聘平台、公共就业平台和活动聚合平台的岗位、企业、薪资、技能与来源证据,用于产教供需分析。",
|
||
schemaPath: "./schemas/external-job-source.schema.json",
|
||
fields: 39,
|
||
required: 19,
|
||
owner: "平台数据标准委员会",
|
||
status: "生产已发布",
|
||
release: "2026-08-18",
|
||
compatibility: "向后兼容 V2.6",
|
||
fieldsPreview: [
|
||
["record_id", "string/uuid", "是", "平台记录唯一标识", "UUID v4"],
|
||
["source_platform", "string", "是", "来源平台代码", "受控代码表"],
|
||
["source_record_id", "string", "是", "来源记录标识", "同平台唯一"],
|
||
["source_url", "string/uri", "是", "来源页面或接口定位", "http/https"],
|
||
["collected_at", "datetime", "是", "实际采集时间", "ISO-8601 +08:00"],
|
||
["published_at", "datetime", "否", "源端发布时间", "ISO-8601"],
|
||
["job_title_raw", "string", "是", "岗位原始名称", "1—200字符"],
|
||
["job_title_normalized", "string", "否", "统一岗位名称", "岗位词典 V4.3"],
|
||
["enterprise_name", "string", "是", "企业名称", "保留原文"],
|
||
["region_code", "string", "是", "工作地区代码", "GB/T 2260"],
|
||
["salary_min", "number", "否", "月薪下限(元)", "非负数"],
|
||
["salary_max", "number", "否", "月薪上限(元)", "≥ salary_min"],
|
||
["skills", "array<string>", "否", "解析后的技能标签", "技能词典 V3.8"],
|
||
["content_hash", "string", "是", "原始内容哈希", "SHA-256"],
|
||
],
|
||
rules: [
|
||
["唯一与幂等", "source_platform + source_record_id 唯一;重复采集必须幂等更新。"],
|
||
["来源证据", "必须保留 source_url、collected_at、content_hash 和采集任务编号。"],
|
||
["薪资口径", "统一折算为税前月薪;面议、日薪、时薪保留原文并标注换算状态。"],
|
||
["时效与失效", "岗位状态和最后发现时间分开记录;失效采用版本化软删除。"],
|
||
["文本治理", "岗位职责原文进入原始层,分析层只保留去标记、去联系方式后的业务文本。"],
|
||
],
|
||
},
|
||
"school-employment": {
|
||
key: "SCHOOL-EMPLOYMENT",
|
||
title: "校内就业事实数据标准",
|
||
version: "V2.2.0",
|
||
description: "统一学校毕业生就业状态、岗位、企业、薪资、专业相关度和去向确认事实,支持租户隔离和敏感字段最小化。",
|
||
schemaPath: "./schemas/school-employment.schema.json",
|
||
fields: 32,
|
||
required: 16,
|
||
owner: "平台数据标准委员会",
|
||
status: "待生产发布",
|
||
release: "计划 2026-08-28",
|
||
compatibility: "兼容 V2.1,新增5字段",
|
||
fieldsPreview: [
|
||
["tenant_id", "string", "是", "学校租户标识", "平台分配"],
|
||
["student_token", "string", "是", "学生不可逆令牌", "禁止明文学号"],
|
||
["cohort", "integer", "是", "毕业届次", "YYYY"],
|
||
["program_code", "string", "是", "专业代码", "教育部专业目录"],
|
||
["employment_status", "string", "是", "就业状态", "受控枚举"],
|
||
["job_title", "string", "否", "就业岗位", "业务原文"],
|
||
["enterprise_token", "string", "否", "企业统一标识", "信用代码令牌化"],
|
||
["salary_monthly", "number", "否", "税前月薪", "元/月"],
|
||
["job_relevance", "string", "否", "岗位专业相关度", "高/中/低/未确认"],
|
||
["confirmed_at", "datetime", "是", "业务确认时间", "ISO-8601"],
|
||
["source_system", "string", "是", "校内来源系统", "系统代码"],
|
||
["updated_at", "datetime", "是", "业务更新时间", "增量水位"],
|
||
],
|
||
rules: [
|
||
["租户隔离", "所有记录必须携带 tenant_id,存储、任务、查询和导出均按租户强制隔离。"],
|
||
["身份最小化", "平台侧使用 student_token,不接收姓名、身份证和明文学号。"],
|
||
["业务确认", "就业状态必须具有确认人角色、确认时间和来源系统。"],
|
||
["增量更新", "updated_at 作为增量水位,主键冲突时以业务更新时间和版本号判定。"],
|
||
["质量阈值", "专业代码合法率100%,必填完整率≥95%,学生专业关联率≥98%。"],
|
||
],
|
||
},
|
||
"school-internship": {
|
||
key: "SCHOOL-INTERNSHIP",
|
||
title: "校内实习事实数据标准",
|
||
version: "V2.1.0",
|
||
description: "统一学生实习岗位、企业、起止日期、状态、工资、岗位任务和评价事实,支持租户隔离和敏感身份最小化。",
|
||
schemaPath: "./schemas/school-internship.schema.json",
|
||
fields: 30,
|
||
required: 14,
|
||
owner: "平台数据标准委员会",
|
||
status: "生产已发布",
|
||
release: "2026-08-24",
|
||
compatibility: "兼容 V2.0,补充任务与评价证据",
|
||
fieldsPreview: [
|
||
["tenant_id", "string", "是", "学校租户标识", "平台分配"],
|
||
["internship_id", "string", "是", "实习业务编号", "租户内唯一"],
|
||
["student_token", "string", "是", "学生不可逆令牌", "禁止明文学号"],
|
||
["program_code", "string", "是", "专业代码", "教育部专业目录"],
|
||
["enterprise_token", "string", "否", "企业统一标识", "令牌化"],
|
||
["job_title", "string", "是", "实习岗位名称", "业务原文"],
|
||
["job_tasks", "array<string>", "否", "实习岗位任务", "学校确认"],
|
||
["start_date", "date", "是", "开始日期", "YYYY-MM-DD"],
|
||
["end_date", "date", "否", "结束日期", "YYYY-MM-DD"],
|
||
["status", "string", "是", "实习状态", "受控枚举"],
|
||
["salary_monthly", "number", "否", "税前月实习工资", "元/月"],
|
||
["evidence_reference", "string", "否", "校内证据引用", "不传附件"],
|
||
["source_updated_at", "datetime", "否", "业务更新时间", "增量水位"],
|
||
["content_hash", "string", "是", "业务内容哈希", "SHA-256"],
|
||
],
|
||
rules: [
|
||
["租户隔离", "任务、认证、水位、Raw、隔离区和输出目录均按 tenant_id 隔离。"],
|
||
["身份最小化", "平台只接收 student_token,拒绝姓名、身份证、手机号、邮箱和明文学号。"],
|
||
["企业要求", "进行中或已完成实习必须提供企业令牌或企业名称。"],
|
||
["证据引用", "敏感附件保留在学校系统,平台只保存 evidence_reference。"],
|
||
["增量幂等", "tenant_id + source_record_id + source_updated_at 形成增量与幂等边界。"],
|
||
],
|
||
},
|
||
"package-manifest": {
|
||
key: "PACKAGE-MANIFEST",
|
||
title: "数据交付包清单标准",
|
||
version: "V1.2.0",
|
||
description: "定义每次导入、导出和租户交付必须携带的数据版本、文件、Schema、质量、血缘、范围和校验信息。",
|
||
schemaPath: "./schemas/package-manifest.schema.json",
|
||
fields: 21,
|
||
required: 14,
|
||
owner: "平台数据交付组",
|
||
status: "生产已发布",
|
||
release: "2026-08-23",
|
||
compatibility: "兼容 V1.1,新增2个发布字段",
|
||
fieldsPreview: [
|
||
["package_id", "string", "是", "交付包唯一编号", "EDU-PKG-*"],
|
||
["release_id", "string", "是", "发布候选编号", "REL-*"],
|
||
["release_status", "string", "是", "候选或发布状态", "DRAFT/PUBLISHED"],
|
||
["data_product", "string", "是", "数据产品代码", "产品目录"],
|
||
["data_version", "string", "是", "业务数据版本", "SemVer/日期版"],
|
||
["contract_name", "string", "是", "数据契约名称", "契约注册中心"],
|
||
["contract_version", "string", "是", "契约版本", "SemVer"],
|
||
["data_as_of", "date", "是", "业务截止日期", "YYYY-MM-DD"],
|
||
["tenant_scope", "array<string>", "是", "允许交付租户", "至少1个"],
|
||
["record_count", "integer", "是", "有效记录数", "非负整数"],
|
||
["files", "array<object>", "是", "数据与附件文件清单", "含SHA-256"],
|
||
["quality_score", "number", "是", "综合质量评分", "0—100"],
|
||
["lineage_snapshot", "string", "是", "血缘快照编号", "LINEAGE-*"],
|
||
["generated_at", "datetime", "是", "生成时间", "ISO-8601"],
|
||
],
|
||
rules: [
|
||
["文件完整性", "Manifest 内每个文件必须记录路径、字节数、媒体类型和 SHA-256。"],
|
||
["范围约束", "tenant_scope 与数据服务授权同时校验,不允许客户端自行扩大范围。"],
|
||
["契约绑定", "数据文件必须绑定 contract_name 和精确 contract_version。"],
|
||
["质量绑定", "必须包含质量报告文件、质量评分和失败规则摘要。"],
|
||
["可回滚", "每次交付保留上一稳定版本引用和回滚入口。"],
|
||
],
|
||
},
|
||
};
|
||
|
||
const exchangeLogs = [
|
||
["EXP-20260823-0930", "导出", "区域招聘岗位基础包", "V2026.08.23", "2,846,320", "交付服务", "成功", "09:30"],
|
||
["IMP-20260823-0752", "导入", "校内就业36租户增量", "SCH-EMP-20260823", "4,280", "学校接入服务", "成功", "07:52"],
|
||
["EXP-20260822-1740", "导出", "薪资与企业画像增强包", "V2026.08.22", "1,286,420", "陈致远", "成功", "昨天 17:40"],
|
||
["IMP-20260822-1608", "导入", "企业确认需求批次", "ENT-20260822-03", "8,640", "企业接入服务", "隔离12条", "昨天 16:08"],
|
||
];
|
||
|
||
const qualityRules = [
|
||
["UQ", "来源主键唯一", "source_platform + source_record_id", "99.96%", "通过"],
|
||
["RQ", "核心字段完整", "岗位、企业、地区、来源、采集时间", "98.84%", "通过"],
|
||
["TM", "岗位时效合法", "发布时间、最后发现时间与状态一致", "97.31%", "通过"],
|
||
["SL", "薪资区间合法", "币种、周期、上下限和换算状态", "96.72%", "通过"],
|
||
["NM", "岗位归一覆盖", "映射到岗位词典 V4.3", "96.40%", "27待审"],
|
||
["LN", "来源证据完整", "URL、任务、快照与内容哈希", "100%", "通过"],
|
||
];
|
||
|
||
let normalizationItems = [
|
||
["具身智能应用工程师", "机器人应用工程技术人员", "0.91"],
|
||
["低空运营调度师", "航空运输服务人员", "0.84"],
|
||
["AI内容策略师", "互联网营销师", "0.82"],
|
||
["智能驾驶数据闭环工程师", "数据工程技术人员", "0.88"],
|
||
["跨境合规运营", "电子商务师", "0.86"],
|
||
];
|
||
|
||
const auditEvents = [
|
||
{
|
||
id: "AUD-20260823-1042-8821",
|
||
time: "10:42:18",
|
||
type: "collect",
|
||
event: "BOSS增量分片完成",
|
||
object: "JOB-20260823-1042 · shard-64",
|
||
actor: "collector-boss-prod",
|
||
result: "成功",
|
||
evidence: ["RAW快照", "响应哈希", "运行日志"],
|
||
},
|
||
{
|
||
id: "AUD-20260823-1038-7412",
|
||
time: "10:38:06",
|
||
type: "collect",
|
||
event: "智联采集水位提交",
|
||
object: "SRC-ZL-20260823-04",
|
||
actor: "collector-zhaopin-prod",
|
||
result: "成功",
|
||
evidence: ["水位", "分片清单", "代码版本"],
|
||
},
|
||
{
|
||
id: "AUD-20260823-1028-5630",
|
||
time: "10:28:42",
|
||
type: "collect",
|
||
event: "源端延迟触发降速",
|
||
object: "杭州人才网 · P95 4.8s",
|
||
actor: "scheduler-protection",
|
||
result: "自动保护",
|
||
evidence: ["指标快照", "策略版本"],
|
||
},
|
||
{
|
||
id: "AUD-20260823-1016-4482",
|
||
time: "10:16:33",
|
||
type: "quality",
|
||
event: "岗位归一批次完成",
|
||
object: "NORM-20260823-06",
|
||
actor: "normalization-engine",
|
||
result: "27项待审",
|
||
evidence: ["输入版本", "词典版本", "模型版本"],
|
||
},
|
||
{
|
||
id: "AUD-20260823-0930-2290",
|
||
time: "09:30:12",
|
||
type: "publish",
|
||
event: "区域岗位基础包发布",
|
||
object: "V2026.08.23 · 36租户",
|
||
actor: "陈致远",
|
||
result: "成功",
|
||
evidence: ["审批单", "Manifest", "质量报告"],
|
||
},
|
||
{
|
||
id: "AUD-20260823-0752-1108",
|
||
time: "07:52:55",
|
||
type: "exchange",
|
||
event: "校内就业增量导入",
|
||
object: "36租户 · 4,280条",
|
||
actor: "school-ingestion-service",
|
||
result: "成功",
|
||
evidence: ["源版本", "字段映射", "错误明细"],
|
||
},
|
||
];
|
||
|
||
const OPERATIONS_STORAGE_KEY = "eduIndustry.vendorOperations.v1";
|
||
const OPERATIONS_SCHEMA_VERSION = 2;
|
||
const OPERATIONS_ACTOR = "陈致远";
|
||
const OPERATIONS_LIMITS = {
|
||
auditEvents: 300,
|
||
qualityDispositions: 60,
|
||
issueTimeline: 120,
|
||
exchangeLogs: 100,
|
||
};
|
||
|
||
function operationsSeed() {
|
||
const baseEvidence = ["指标快照", "任务日志"];
|
||
return {
|
||
schemaVersion: OPERATIONS_SCHEMA_VERSION,
|
||
seededAt: "2026-08-23T10:30:00+08:00",
|
||
issues: [
|
||
{
|
||
id: "ISSUE-HZRC-20260823-01",
|
||
status: "ALERTED",
|
||
actor: "scheduler-protection",
|
||
time: "2026-08-23T10:28:42+08:00",
|
||
trace: "TRACE-HZRC-20260823-5630",
|
||
evidence: [...baseEvidence, "P95延迟采样"],
|
||
title: "杭州人才网响应时延升高",
|
||
severity: "P1",
|
||
owner: "待指派",
|
||
sla: "2小时内恢复,剩余 01:42",
|
||
impact: "杭州都市圈补采降速,8个分片待执行;稳定版本未受影响",
|
||
relatedTask: "JOB-20260823-1028",
|
||
quarantineId: "QUA-20260823-001283",
|
||
sourceId: "hzrc",
|
||
qualityRequired: true,
|
||
timeline: [
|
||
{
|
||
id: "STEP-HZRC-ALERT",
|
||
status: "ALERTED",
|
||
actor: "scheduler-protection",
|
||
time: "2026-08-23T10:28:42+08:00",
|
||
trace: "TRACE-HZRC-20260823-5630",
|
||
evidence: ["指标快照", "降速策略 V2.4"],
|
||
note: "P95 4.8s,系统自动将并发从12降至4。",
|
||
},
|
||
],
|
||
},
|
||
{
|
||
id: "ISSUE-NORM-20260823-01",
|
||
status: "ACKNOWLEDGED",
|
||
actor: "周敏",
|
||
time: "2026-08-23T10:18:00+08:00",
|
||
trace: "TRACE-NORM-20260823-4482",
|
||
evidence: ["归一批次 NORM-20260823-06", "词典 V4.3"],
|
||
title: "27 个岗位别名待人工归一",
|
||
severity: "P1",
|
||
owner: "周敏",
|
||
sla: "今日 16:00 前完成",
|
||
impact: "具身智能、低空经济等新增岗位暂未进入稳定分类",
|
||
relatedTask: "NORM-20260823-06",
|
||
quarantineId: "QUA-20260823-001283",
|
||
sourceId: "hzrc",
|
||
qualityRequired: false,
|
||
timeline: [
|
||
{
|
||
id: "STEP-NORM-ACK",
|
||
status: "ACKNOWLEDGED",
|
||
actor: "周敏",
|
||
time: "2026-08-23T10:18:00+08:00",
|
||
trace: "TRACE-NORM-20260823-4482",
|
||
evidence: ["归一待审清单"],
|
||
note: "已确认影响范围,等待词典负责人处理。",
|
||
},
|
||
],
|
||
},
|
||
{
|
||
id: "ISSUE-CONTRACT-20260823-01",
|
||
status: "ASSIGNED",
|
||
actor: "陈致远",
|
||
time: "2026-08-23T09:48:00+08:00",
|
||
trace: "TRACE-CONTRACT-20260823-2201",
|
||
evidence: ["Schema差异报告", "下游影响清单"],
|
||
title: "校内就业契约 V2.2 待发布",
|
||
severity: "P2",
|
||
owner: "数据标准组",
|
||
sla: "2026-08-28 前完成",
|
||
impact: "36所学校的新就业质量与去向确认字段尚未启用",
|
||
relatedTask: "CONTRACT-SCHOOL-EMPLOYMENT-V2.2",
|
||
quarantineId: "无",
|
||
sourceId: "school-employment",
|
||
qualityRequired: false,
|
||
timeline: [
|
||
{
|
||
id: "STEP-CONTRACT-ASSIGNED",
|
||
status: "ASSIGNED",
|
||
actor: "陈致远",
|
||
time: "2026-08-23T09:48:00+08:00",
|
||
trace: "TRACE-CONTRACT-20260823-2201",
|
||
evidence: ["责任人指派记录"],
|
||
note: "已指派数据标准组执行兼容性与样本回放。",
|
||
},
|
||
],
|
||
},
|
||
],
|
||
workOrders: [
|
||
{
|
||
id: "WO-HZRC-20260823-01",
|
||
status: "OPEN",
|
||
actor: "scheduler-protection",
|
||
time: "2026-08-23T10:28:42+08:00",
|
||
trace: "TRACE-HZRC-20260823-5630",
|
||
evidence: ["自动建单规则"],
|
||
issueId: "ISSUE-HZRC-20260823-01",
|
||
assignee: "待指派",
|
||
},
|
||
{
|
||
id: "WO-NORM-20260823-01",
|
||
status: "ASSIGNED",
|
||
actor: "周敏",
|
||
time: "2026-08-23T10:18:00+08:00",
|
||
trace: "TRACE-NORM-20260823-4482",
|
||
evidence: ["归一待审清单"],
|
||
issueId: "ISSUE-NORM-20260823-01",
|
||
assignee: "周敏",
|
||
},
|
||
{
|
||
id: "WO-CONTRACT-20260823-01",
|
||
status: "ASSIGNED",
|
||
actor: "陈致远",
|
||
time: "2026-08-23T09:48:00+08:00",
|
||
trace: "TRACE-CONTRACT-20260823-2201",
|
||
evidence: ["Schema差异报告"],
|
||
issueId: "ISSUE-CONTRACT-20260823-01",
|
||
assignee: "数据标准组",
|
||
},
|
||
],
|
||
qualityDispositions: [],
|
||
releases: [
|
||
{
|
||
id: "REL-20260823-01",
|
||
status: "DRAFT",
|
||
actor: "陈致远",
|
||
time: "2026-08-23T10:30:00+08:00",
|
||
trace: "TRACE-REL-20260823-01",
|
||
evidence: ["候选数据快照", "质量报告 QUALITY-20260823-09"],
|
||
dataVersion: "V2026.08.23",
|
||
packageId: "EDU-PKG-20260823-01",
|
||
previousStableVersion: "V2026.08.22",
|
||
qualityGate: "BLOCKED",
|
||
qualityScore: 94.7,
|
||
impactTenants: ["TENANT-HZVTC", "TENANT-ZIME", "TENANT-NBCC"],
|
||
applicant: "陈致远",
|
||
approvalId: "APPROVAL-REL-20260823-01",
|
||
manifestGeneratedAt: null,
|
||
candidateManifest: null,
|
||
candidateInputFingerprint: null,
|
||
publishedManifestGeneratedAt: null,
|
||
publishedManifest: null,
|
||
publishedAt: null,
|
||
},
|
||
],
|
||
approvals: [
|
||
{
|
||
id: "APPROVAL-REL-20260823-01",
|
||
status: "DRAFT",
|
||
actor: "陈致远",
|
||
time: "2026-08-23T10:30:00+08:00",
|
||
trace: "TRACE-APPROVAL-20260823-01",
|
||
evidence: ["审批规则 RBAC-RELEASE-2P"],
|
||
releaseId: "REL-20260823-01",
|
||
applicant: "陈致远",
|
||
approver: null,
|
||
comment: "",
|
||
decidedAt: null,
|
||
},
|
||
{
|
||
id: "APPROVAL-ROLLBACK-REL-20260823-01",
|
||
status: "DRAFT",
|
||
actor: "陈致远",
|
||
time: "2026-08-23T10:30:00+08:00",
|
||
trace: "TRACE-ROLLBACK-APPROVAL-20260823-01",
|
||
evidence: ["回滚审批规则 RBAC-ROLLBACK-2P"],
|
||
releaseId: "REL-20260823-01",
|
||
type: "ROLLBACK",
|
||
applicant: "陈致远",
|
||
approver: null,
|
||
comment: "",
|
||
reason: "",
|
||
decidedAt: null,
|
||
},
|
||
],
|
||
deliveries: [
|
||
{
|
||
id: "DELIVERY-HZVTC-20260823-01",
|
||
status: "PENDING",
|
||
actor: "delivery-orchestrator",
|
||
time: "2026-08-23T10:30:00+08:00",
|
||
trace: "TRACE-DELIVERY-HZVTC-01",
|
||
evidence: ["租户范围策略"],
|
||
tenantId: "TENANT-HZVTC",
|
||
school: "杭州科技职业技术学院",
|
||
packageId: "EDU-PKG-20260823-01",
|
||
dataVersion: "V2026.08.23",
|
||
expectedRecords: 2846320,
|
||
receiptRecords: null,
|
||
expectedSha: "4f8d91a47e2b6d82b2aaad136d344d61c83f9cd472185d2543725c91870e9e32",
|
||
receiptSha: null,
|
||
attempts: 0,
|
||
failureReason: "",
|
||
},
|
||
{
|
||
id: "DELIVERY-ZIME-20260823-01",
|
||
status: "PENDING",
|
||
actor: "delivery-orchestrator",
|
||
time: "2026-08-23T10:30:00+08:00",
|
||
trace: "TRACE-DELIVERY-ZIME-01",
|
||
evidence: ["租户范围策略"],
|
||
tenantId: "TENANT-ZIME",
|
||
school: "浙江机电职业技术大学",
|
||
packageId: "EDU-PKG-20260823-01",
|
||
dataVersion: "V2026.08.23",
|
||
expectedRecords: 2819044,
|
||
receiptRecords: null,
|
||
expectedSha: "96c2be742dc43f8e7ee526db6358030873406c450f15e67225c605dc39b431a8",
|
||
receiptSha: null,
|
||
attempts: 0,
|
||
failureReason: "",
|
||
},
|
||
{
|
||
id: "DELIVERY-NBCC-20260823-01",
|
||
status: "PENDING",
|
||
actor: "delivery-orchestrator",
|
||
time: "2026-08-23T10:30:00+08:00",
|
||
trace: "TRACE-DELIVERY-NBCC-01",
|
||
evidence: ["租户范围策略"],
|
||
tenantId: "TENANT-NBCC",
|
||
school: "宁波城市职业技术学院",
|
||
packageId: "EDU-PKG-20260823-01",
|
||
dataVersion: "V2026.08.23",
|
||
expectedRecords: 2768420,
|
||
receiptRecords: null,
|
||
expectedSha: "c2d8f733e6711219823110d7d35fdc5535f133ed60f60950f9dc78a228cb402d",
|
||
receiptSha: null,
|
||
attempts: 0,
|
||
failureReason: "",
|
||
},
|
||
],
|
||
auditEvents: [
|
||
{
|
||
id: "AUD-OPS-20260823-1028",
|
||
status: "RECORDED",
|
||
actor: "scheduler-protection",
|
||
time: "2026-08-23T10:28:42+08:00",
|
||
trace: "TRACE-HZRC-20260823-5630",
|
||
evidence: ["指标快照", "策略版本"],
|
||
type: "operations",
|
||
event: "杭州人才网时延告警建单",
|
||
object: "ISSUE-HZRC-20260823-01",
|
||
result: "待确认",
|
||
},
|
||
],
|
||
};
|
||
}
|
||
|
||
function isOperationsEntityValid(entity) {
|
||
return entity && typeof entity.id === "string" && typeof entity.status === "string" && typeof entity.actor === "string" && typeof entity.time === "string" && typeof entity.trace === "string" && Array.isArray(entity.evidence);
|
||
}
|
||
|
||
function trimOperationsStore(store) {
|
||
store.auditEvents = store.auditEvents.slice(0, OPERATIONS_LIMITS.auditEvents);
|
||
store.qualityDispositions = store.qualityDispositions.slice(-OPERATIONS_LIMITS.qualityDispositions);
|
||
store.issues.forEach((issue) => {
|
||
issue.timeline = issue.timeline.slice(-OPERATIONS_LIMITS.issueTimeline);
|
||
});
|
||
return store;
|
||
}
|
||
|
||
function loadOperationsStore() {
|
||
try {
|
||
const parsed = JSON.parse(localStorage.getItem(OPERATIONS_STORAGE_KEY));
|
||
const collections = ["issues", "workOrders", "qualityDispositions", "releases", "approvals", "deliveries", "auditEvents"];
|
||
if (
|
||
parsed?.schemaVersion === OPERATIONS_SCHEMA_VERSION &&
|
||
collections.every((key) => Array.isArray(parsed[key]) && parsed[key].every(isOperationsEntityValid))
|
||
) {
|
||
return trimOperationsStore(parsed);
|
||
}
|
||
} catch {
|
||
// Invalid or unavailable browser storage falls back to a reproducible seed.
|
||
}
|
||
const seed = operationsSeed();
|
||
try {
|
||
localStorage.setItem(OPERATIONS_STORAGE_KEY, JSON.stringify(seed));
|
||
} catch {
|
||
// The in-memory seed remains fully interactive when storage is blocked.
|
||
}
|
||
return seed;
|
||
}
|
||
|
||
let operationsStore = loadOperationsStore();
|
||
|
||
function saveOperationsStore() {
|
||
trimOperationsStore(operationsStore);
|
||
try {
|
||
localStorage.setItem(OPERATIONS_STORAGE_KEY, JSON.stringify(operationsStore));
|
||
} catch {
|
||
showToast("浏览器阻止了本地存储,本次状态仅在当前页面有效", "状态未持久化");
|
||
}
|
||
}
|
||
|
||
function operationsNow() {
|
||
return new Date().toISOString();
|
||
}
|
||
|
||
function operationToken(prefix) {
|
||
return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2, 7).toUpperCase()}`;
|
||
}
|
||
|
||
function addOperationsAudit({ type = "operations", event, object, actor = OPERATIONS_ACTOR, result, evidence = [] }) {
|
||
const time = operationsNow();
|
||
const trace = operationToken("TRACE");
|
||
operationsStore.auditEvents.unshift({
|
||
id: operationToken("AUD"),
|
||
status: "RECORDED",
|
||
actor,
|
||
time,
|
||
trace,
|
||
evidence: [...evidence],
|
||
type,
|
||
event,
|
||
object,
|
||
result,
|
||
});
|
||
return { actor, time, trace, evidence: [...evidence] };
|
||
}
|
||
|
||
function migrateOperationsEntity(entity, status, metadata) {
|
||
entity.status = status;
|
||
entity.actor = metadata.actor;
|
||
entity.time = metadata.time;
|
||
entity.trace = metadata.trace;
|
||
entity.evidence = [...metadata.evidence];
|
||
}
|
||
|
||
const state = {
|
||
sourceFilter: "all",
|
||
sourceSearch: "",
|
||
sourceId: "boss",
|
||
sourceTab: "overview",
|
||
jobFilter: "all",
|
||
recordLayer: "standard",
|
||
recordSource: "all",
|
||
recordRegion: "all",
|
||
recordSearch: "",
|
||
recordId: "",
|
||
contractKey: "external-job",
|
||
auditFilter: "all",
|
||
operationsTab: "issues",
|
||
issueId: "",
|
||
};
|
||
|
||
function h(value) {
|
||
return String(value ?? "")
|
||
.replaceAll("&", "&")
|
||
.replaceAll("<", "<")
|
||
.replaceAll(">", ">")
|
||
.replaceAll('"', """)
|
||
.replaceAll("'", "'");
|
||
}
|
||
|
||
function showToast(message, title = "操作已完成") {
|
||
const toast = document.createElement("div");
|
||
toast.className = "toast";
|
||
toast.innerHTML = `<span>✓</span><span><strong>${h(title)}</strong><small>${h(message)}</small></span>`;
|
||
document.getElementById("toastRegion").appendChild(toast);
|
||
setTimeout(() => toast.remove(), 3600);
|
||
}
|
||
|
||
function openModal(modal) {
|
||
modal.classList.add("open");
|
||
modal.setAttribute("aria-hidden", "false");
|
||
document.body.style.overflow = "hidden";
|
||
}
|
||
|
||
function closeModal(modal) {
|
||
modal.classList.remove("open");
|
||
modal.setAttribute("aria-hidden", "true");
|
||
document.body.style.overflow = "";
|
||
}
|
||
|
||
function switchView(viewName) {
|
||
document.querySelectorAll(".view").forEach((view) => view.classList.toggle("active", view.id === `view-${viewName}`));
|
||
document.querySelectorAll(".nav-item[data-view]").forEach((button) => button.classList.toggle("active", button.dataset.view === viewName));
|
||
const activeView = document.getElementById(`view-${viewName}`);
|
||
document.getElementById("breadcrumbCurrent").textContent = activeView?.dataset.title || "运行总览";
|
||
document.body.classList.remove("sidebar-open");
|
||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||
}
|
||
|
||
function formatNumber(value) {
|
||
return Number(value).toLocaleString("zh-CN");
|
||
}
|
||
|
||
function sourceById(id) {
|
||
return sources.find((source) => source.id === id) || sources[0];
|
||
}
|
||
|
||
function sourceStatusPill(source) {
|
||
const tone = source.status === "attention" ? "attention" : source.status === "failed" ? "failed" : "running";
|
||
return `<span class="status-pill ${tone}">${h(source.statusLabel)}</span>`;
|
||
}
|
||
|
||
const operationsStatusLabels = {
|
||
ALERTED: "待确认",
|
||
ACKNOWLEDGED: "已确认",
|
||
ASSIGNED: "已指派",
|
||
INVESTIGATING: "排障中",
|
||
QUALITY_REVIEW_PENDING: "待质量复核",
|
||
VERIFIED: "复核通过",
|
||
REVIEWED_BLOCKED: "复核后仍阻断",
|
||
RESOLVED: "已解决",
|
||
CLOSED: "已关闭",
|
||
OPEN: "已建单",
|
||
DRAFT: "草稿",
|
||
PENDING_APPROVAL: "待审批",
|
||
APPROVED: "已批准",
|
||
REJECTED: "已驳回",
|
||
PUBLISHED: "已发布",
|
||
ROLLED_BACK: "已回滚",
|
||
PENDING: "待发送",
|
||
SENDING: "发送中",
|
||
DELIVERED: "已送达",
|
||
ACKNOWLEDGED_DELIVERY: "已确认回执",
|
||
ACKNOWLEDGED: "已确认",
|
||
FAILED: "失败",
|
||
RETRYING: "重试中",
|
||
PENDING_REVIEW: "待复核",
|
||
RECORDED: "已记录",
|
||
PASS: "通过",
|
||
BLOCKED: "阻断",
|
||
ROLLBACK_PENDING_APPROVAL: "回滚待审批",
|
||
ROLLBACK_APPROVED: "回滚已批准",
|
||
ROLLBACK_REJECTED: "回滚已驳回",
|
||
ROLLBACK_EXECUTED: "回滚已执行",
|
||
};
|
||
|
||
function operationStatusLabel(status) {
|
||
return operationsStatusLabels[status] || status;
|
||
}
|
||
|
||
function operationStatusTone(status) {
|
||
if (["FAILED", "REJECTED", "BLOCKED", "REVIEWED_BLOCKED", "ROLLBACK_REJECTED"].includes(status)) return "failed";
|
||
if (["ALERTED", "PENDING_APPROVAL", "QUALITY_REVIEW_PENDING", "PENDING", "RETRYING", "ASSIGNED", "ROLLBACK_PENDING_APPROVAL"].includes(status)) return "attention";
|
||
if (
|
||
[
|
||
"VERIFIED",
|
||
"RESOLVED",
|
||
"CLOSED",
|
||
"APPROVED",
|
||
"PUBLISHED",
|
||
"DELIVERED",
|
||
"ACKNOWLEDGED",
|
||
"ROLLED_BACK",
|
||
"PASS",
|
||
"ROLLBACK_APPROVED",
|
||
"ROLLBACK_EXECUTED",
|
||
].includes(status)
|
||
)
|
||
return "running";
|
||
return "";
|
||
}
|
||
|
||
function operationTime(value) {
|
||
if (!value) return "—";
|
||
const date = new Date(value);
|
||
if (Number.isNaN(date.getTime())) return value;
|
||
return date.toLocaleString("zh-CN", { hour12: false });
|
||
}
|
||
|
||
function currentRelease() {
|
||
return operationsStore.releases.find((release) => release.id === "REL-20260823-01");
|
||
}
|
||
|
||
function currentApproval() {
|
||
const release = currentRelease();
|
||
return operationsStore.approvals.find((approval) => approval.id === release?.approvalId);
|
||
}
|
||
|
||
function currentRollbackApproval() {
|
||
const release = currentRelease();
|
||
return operationsStore.approvals.find((approval) => approval.releaseId === release?.id && approval.type === "ROLLBACK");
|
||
}
|
||
|
||
function isIndependentApproval(approval, approver = approval?.approver) {
|
||
return Boolean(approval && approver && approver !== approval.applicant);
|
||
}
|
||
|
||
function decideIndependentApproval({
|
||
approval,
|
||
target,
|
||
approver,
|
||
comment,
|
||
approved,
|
||
approveStatus,
|
||
rejectStatus,
|
||
approveEvent,
|
||
rejectEvent,
|
||
object,
|
||
evidence,
|
||
defaultApproveComment,
|
||
defaultRejectComment,
|
||
resultLabel = (status) => status,
|
||
successMessage,
|
||
successTitle,
|
||
}) {
|
||
if (!isIndependentApproval(approval, approver)) {
|
||
const rollback = approval?.type === "ROLLBACK";
|
||
showToast(rollback ? "回滚申请人与审批人相同,四眼规则阻止了该操作" : "申请人和审批人相同,四眼审批规则阻止了该操作", rollback ? "无法批准自己的回滚" : "无法批准自己的申请");
|
||
return false;
|
||
}
|
||
const nextStatus = approved ? approveStatus : rejectStatus;
|
||
const metadata = addOperationsAudit({
|
||
type: "publish",
|
||
event: approved ? approveEvent : rejectEvent,
|
||
object,
|
||
actor: approver,
|
||
result: resultLabel(nextStatus),
|
||
evidence,
|
||
});
|
||
approval.approver = approver;
|
||
approval.comment = comment || (approved ? defaultApproveComment : defaultRejectComment);
|
||
approval.decidedAt = metadata.time;
|
||
migrateOperationsEntity(approval, nextStatus, metadata);
|
||
if (target) migrateOperationsEntity(target, nextStatus, metadata);
|
||
saveOperationsStore();
|
||
renderOperations();
|
||
renderAudit();
|
||
showToast(successMessage(approver, approved), successTitle(approved));
|
||
return true;
|
||
}
|
||
|
||
function latestQualityDisposition(issueId = "ISSUE-HZRC-20260823-01") {
|
||
return operationsStore.qualityDispositions.filter((disposition) => disposition.issueId === issueId).at(-1);
|
||
}
|
||
|
||
function isVerifiedFixReplay(disposition) {
|
||
return Boolean(
|
||
disposition?.action === "FIX_REPLAY" &&
|
||
disposition.status === "VERIFIED" &&
|
||
disposition.verification?.replayedRecords === disposition.verification?.expectedRecords &&
|
||
disposition.verification?.rulesPassed === true &&
|
||
disposition.verification?.reviewerSigned === true,
|
||
);
|
||
}
|
||
|
||
function qualityGatePassed() {
|
||
return isVerifiedFixReplay(latestQualityDisposition());
|
||
}
|
||
|
||
function updateReleaseQualityGate() {
|
||
const release = currentRelease();
|
||
if (!release || ["PUBLISHED", "ROLLED_BACK"].includes(release.status)) return;
|
||
release.qualityGate = qualityGatePassed() ? "PASS" : "BLOCKED";
|
||
}
|
||
|
||
function releaseReadyForApproval(release = currentRelease()) {
|
||
return Boolean(release?.qualityGate === "PASS" && release.manifestGeneratedAt && release.candidateManifest);
|
||
}
|
||
|
||
function renderOperationsMetrics() {
|
||
const openIssues = operationsStore.issues.filter((issue) => !["RESOLVED", "CLOSED"].includes(issue.status)).length;
|
||
const pendingApprovals = operationsStore.approvals.filter((approval) => ["PENDING_APPROVAL", "ROLLBACK_PENDING_APPROVAL"].includes(approval.status)).length;
|
||
const deliveryAttention = operationsStore.deliveries.filter((delivery) => ["PENDING", "SENDING", "FAILED", "RETRYING"].includes(delivery.status)).length;
|
||
const acknowledged = operationsStore.deliveries.filter((delivery) => delivery.status === "ACKNOWLEDGED").length;
|
||
document.getElementById("operationsMetrics").innerHTML = `
|
||
<article><span>未闭环问题</span><strong>${openIssues}</strong><small>确认、指派、排障与质量处置</small></article>
|
||
<article><span>审批待办</span><strong>${pendingApprovals}</strong><small>申请人与审批人强制分离</small></article>
|
||
<article><span>交付待办 / 异常</span><strong>${deliveryAttention}</strong><small>按租户隔离,不重复发送成功学校</small></article>
|
||
<article><span>已核验回执</span><strong>${acknowledged}</strong><small>记录数与 SHA-256 均一致</small></article>
|
||
`;
|
||
document.getElementById("operationsNavCount").textContent = openIssues + pendingApprovals + deliveryAttention;
|
||
document.getElementById("notificationButton").querySelector("b").textContent = openIssues + pendingApprovals + deliveryAttention;
|
||
const release = currentRelease();
|
||
const releaseReady = releaseReadyForApproval(release);
|
||
const overviewState = document.getElementById("overviewReleaseState");
|
||
const overviewDetail = document.getElementById("overviewReleaseDetail");
|
||
const qualityState = document.getElementById("qualityReleaseState");
|
||
const qualityDetail = document.getElementById("qualityReleaseDetail");
|
||
if (overviewState) overviewState.textContent = release?.status === "PUBLISHED" ? "稳定版本已发布" : releaseReady ? "候选可提交审批" : "候选发布受阻";
|
||
if (overviewDetail) overviewDetail.textContent = release?.qualityGate === "PASS" ? (release?.manifestGeneratedAt ? "质量与候选 Manifest 已就绪" : "质量通过,待生成候选 Manifest") : "杭州人才网修复重放尚未验证";
|
||
if (qualityState) qualityState.textContent = release?.qualityGate === "PASS" ? "当前批次质量门禁已通过" : "当前批次仍需修复重放复核";
|
||
if (qualityDetail) qualityDetail.textContent = release?.qualityGate === "PASS" ? "质量证据已通过独立复核,可继续生成候选包" : "质量分达标不等于门禁通过,稳定版本继续保持不变";
|
||
}
|
||
|
||
function renderOverviewIssues() {
|
||
const issues = operationsStore.issues.filter((issue) => !["RESOLVED", "CLOSED"].includes(issue.status)).slice(0, 3);
|
||
const list = document.getElementById("overviewIssueList");
|
||
list.innerHTML = issues.length
|
||
? issues
|
||
.map(
|
||
(issue) => `
|
||
<button data-open-issue="${h(issue.id)}">
|
||
<i class="${issue.severity === "P1" ? "red" : issue.severity === "P2" ? "amber" : "blue"}"></i>
|
||
<span><strong>${h(issue.title)}</strong><small>${h(issue.impact)}</small></span>
|
||
<em>${h(issue.severity)}</em>
|
||
</button>
|
||
`,
|
||
)
|
||
.join("")
|
||
: `<div class="info-note">当前没有待处理问题,所有事项均已闭环。</div>`;
|
||
const countBadge = list.closest(".alert-panel").querySelector(".panel-head > em");
|
||
countBadge.textContent = `${issues.length} 项`;
|
||
}
|
||
|
||
function renderOperationsIssues() {
|
||
const rows = operationsStore.issues
|
||
.map((issue) => {
|
||
const last = issue.timeline.at(-1);
|
||
return `
|
||
<tr>
|
||
<td><span class="job-title"><strong>${h(issue.title)}</strong><small>${h(issue.id)} · ${h(issue.trace)}</small></span></td>
|
||
<td><span class="severity-badge ${h(issue.severity.toLowerCase())}">${h(issue.severity)}</span><small class="cell-note">${h(issue.sla)}</small></td>
|
||
<td><span class="operations-impact">${h(issue.impact)}</span></td>
|
||
<td>${h(issue.owner)}</td>
|
||
<td><span class="status-pill ${operationStatusTone(issue.status)}">${h(operationStatusLabel(issue.status))}</span></td>
|
||
<td><span class="record-time-copy"><strong>${h(last?.actor || issue.actor)}</strong><small>${h(operationTime(last?.time || issue.time))}</small></span></td>
|
||
<td><div class="row-actions"><button data-open-issue="${h(issue.id)}">进入处置</button></div></td>
|
||
</tr>
|
||
`;
|
||
})
|
||
.join("");
|
||
document.getElementById("operationsIssueTable").innerHTML = rows;
|
||
const openCount = operationsStore.issues.filter((issue) => !["RESOLVED", "CLOSED"].includes(issue.status)).length;
|
||
document.getElementById("issueQueueSummary").textContent = `${openCount} 项未闭环 · 状态刷新后保留`;
|
||
}
|
||
|
||
function releaseNextAction(release, approval) {
|
||
if (release.status === "DRAFT") {
|
||
const ready = releaseReadyForApproval(release);
|
||
return `<button class="button primary" data-release-submit ${ready ? "" : "disabled"}>${release.manifestGeneratedAt ? "提交独立审批" : "请先生成候选 Manifest"}</button>`;
|
||
}
|
||
if (release.status === "PENDING_APPROVAL") {
|
||
return `
|
||
<label class="approval-field"><span>独立审批人</span><select id="releaseApprover"><option value="林若溪">林若溪 · 数据产品负责人</option><option value="王海宁">王海宁 · 发布审批人</option><option value="陈致远">陈致远 · 当前申请人</option></select></label>
|
||
<label class="approval-field approval-comment"><span>审批意见</span><input id="approvalComment" value="质量门禁与租户范围已复核" /></label>
|
||
<button class="button secondary" data-release-reject>驳回</button>
|
||
<button class="button primary" data-release-approve>批准</button>
|
||
`;
|
||
}
|
||
if (release.status === "REJECTED") {
|
||
return `<button class="button primary" data-release-resubmit>返回草稿并重建候选</button>`;
|
||
}
|
||
if (release.status === "APPROVED") {
|
||
return `<button class="button primary" data-release-publish>发布稳定版本</button>`;
|
||
}
|
||
if (release.status === "PUBLISHED") {
|
||
const rollbackApproval = currentRollbackApproval();
|
||
if (rollbackApproval?.status === "ROLLBACK_PENDING_APPROVAL") {
|
||
return `
|
||
<article class="rollback-approval-block">
|
||
<strong>全局回滚申请待独立审批</strong>
|
||
<small>${h(rollbackApproval.reason)} · 申请人 ${h(rollbackApproval.applicant)} 不能自批</small>
|
||
<label class="approval-field"><span>独立审批人</span><select id="rollbackApprover"><option value="林若溪">林若溪 · 数据产品负责人</option><option value="王海宁">王海宁 · 发布审批人</option><option value="陈致远">陈致远 · 当前申请人</option></select></label>
|
||
<label class="approval-field approval-comment"><span>审批意见</span><input id="rollbackApprovalComment" value="已核对影响租户、恢复目标与回滚窗口" /></label>
|
||
<span class="rollback-action-row"><button class="button secondary" data-rollback-reject>驳回</button><button class="button primary" data-rollback-approve>批准回滚</button></span>
|
||
</article>`;
|
||
}
|
||
if (rollbackApproval?.status === "ROLLBACK_APPROVED") {
|
||
return `<button class="button secondary danger-button" data-release-rollback>执行已批准回滚至 ${h(release.previousStableVersion)}</button>`;
|
||
}
|
||
return `
|
||
<label class="approval-field approval-comment"><span>全局回滚原因</span><input id="rollbackReason" value="发布后质量监控异常,需要恢复上一稳定版本" /></label>
|
||
<button class="button secondary danger-button" data-rollback-submit>提交独立回滚审批</button>`;
|
||
}
|
||
return `<span class="release-final-note">当前候选版本已回滚,交付记录仍按租户保留审计。</span>`;
|
||
}
|
||
|
||
function renderReleaseWorkspace() {
|
||
updateReleaseQualityGate();
|
||
const release = currentRelease();
|
||
const approval = currentApproval();
|
||
const rollbackApproval = currentRollbackApproval();
|
||
document.getElementById("releaseWorkspace").innerHTML = `
|
||
<div class="release-workspace-grid">
|
||
<article class="panel candidate-card">
|
||
<div class="panel-head"><div><span>RELEASE CANDIDATE</span><h2>${h(release.id)}</h2></div><span class="status-pill ${operationStatusTone(release.status)}">${h(operationStatusLabel(release.status))}</span></div>
|
||
<div class="candidate-version-banner">
|
||
<span><small>数据版本</small><strong>${h(release.dataVersion)}</strong></span>
|
||
<span><small>候选包</small><strong>${h(release.packageId)}</strong></span>
|
||
<span><small>上一稳定版本</small><strong>${h(release.previousStableVersion)}</strong></span>
|
||
</div>
|
||
<div class="release-facts">
|
||
<span><small>质量门禁</small><strong class="${release.qualityGate === "PASS" ? "gate-pass" : "gate-blocked"}">${h(operationStatusLabel(release.qualityGate))} · ${release.qualityScore}</strong></span>
|
||
<span><small>影响租户</small><strong>${release.impactTenants.length} 所学校</strong></span>
|
||
<span><small>申请人</small><strong>${h(release.applicant)}</strong></span>
|
||
<span><small>Manifest</small><strong>${release.publishedManifestGeneratedAt ? `发布版已生成 · ${h(operationTime(release.publishedManifestGeneratedAt))}` : release.manifestGeneratedAt ? `候选已生成 · ${h(operationTime(release.manifestGeneratedAt))}` : "尚未生成"}</strong></span>
|
||
</div>
|
||
<div class="release-quality-note ${release.qualityGate === "PASS" ? "passed" : ""}">
|
||
${
|
||
release.status === "PUBLISHED"
|
||
? "质量证据、候选 Manifest 与独立审批已冻结,正式发布 Manifest 已生成;当前尚未代表学校接收。"
|
||
: release.status === "ROLLED_BACK"
|
||
? `稳定别名已回滚到 ${h(release.previousStableVersion)},原发布、审批和租户交付记录继续保留审计。`
|
||
: release.status === "PENDING_APPROVAL"
|
||
? "候选 Manifest 已冻结,正在等待不同于申请人的独立审批人处理。"
|
||
: release.status === "APPROVED"
|
||
? "独立审批已通过,可以执行稳定别名切换并生成正式发布 Manifest。"
|
||
: release.status === "REJECTED"
|
||
? "独立审批已驳回,旧候选不可继续发布;返回草稿后必须修订并重新生成 Manifest。"
|
||
: release.qualityGate === "PASS"
|
||
? release.manifestGeneratedAt
|
||
? "修复重放证据与候选 Manifest 已就绪,可以提交独立发布审批。"
|
||
: "修复重放已由独立复核人验证;还必须生成候选 Manifest 后才能提交审批。"
|
||
: "质量门禁阻断:只有 FIX_REPLAY 的记录数、规则结果与复核签名全部验证通过才能解锁;豁免或丢弃均保持阻断。"
|
||
}
|
||
</div>
|
||
<div class="candidate-actions">
|
||
${
|
||
release.status === "PUBLISHED" && release.publishedManifest
|
||
? `<button class="button secondary" data-release-official-manifest>下载发布 Manifest</button>`
|
||
: release.status === "DRAFT"
|
||
? `<button class="button secondary" data-release-manifest ${release.qualityGate === "PASS" ? "" : "disabled"}>${release.qualityGate === "PASS" ? (release.manifestGeneratedAt ? "重新生成候选 Manifest" : "生成候选包 / Manifest") : "质量通过后生成 Manifest"}</button>`
|
||
: `<span class="release-final-note">候选 Manifest 已冻结,审批中不可重建。</span>`
|
||
}
|
||
<button class="text-button" data-open-issue="ISSUE-HZRC-20260823-01">查看阻断问题</button>
|
||
</div>
|
||
</article>
|
||
<article class="panel approval-card">
|
||
<div class="panel-head"><div><span>FOUR-EYES APPROVAL</span><h2>独立审批</h2></div><span class="status-pill ${operationStatusTone(approval.status)}">${h(operationStatusLabel(approval.status))}</span></div>
|
||
<dl class="approval-summary">
|
||
<div><dt>审批编号</dt><dd>${h(approval.id)}</dd></div>
|
||
<div><dt>申请人</dt><dd>${h(approval.applicant)}</dd></div>
|
||
<div><dt>审批人</dt><dd>${h(approval.approver || "待独立审批人处理")}</dd></div>
|
||
<div><dt>审批意见</dt><dd>${h(approval.comment || "—")}</dd></div>
|
||
<div><dt>审批引用</dt><dd>${h(`${approval.id}:${approval.status}`)}</dd></div>
|
||
${
|
||
rollbackApproval?.status !== "DRAFT"
|
||
? `<div><dt>回滚审批</dt><dd>${h(`${rollbackApproval.id}:${rollbackApproval.status}`)} · ${h(rollbackApproval.approver || "待独立审批")}</dd></div>`
|
||
: ""
|
||
}
|
||
</dl>
|
||
<div class="approval-actions">${releaseNextAction(release, approval)}</div>
|
||
</article>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function deliveryActionButtons(delivery) {
|
||
if (delivery.status === "PENDING") return `<span class="cell-note">发布后可启动</span>`;
|
||
if (["SENDING", "RETRYING"].includes(delivery.status)) {
|
||
return `<div class="row-actions"><button data-delivery-delivered="${h(delivery.id)}">模拟送达</button><button class="secondary" data-delivery-failed="${h(delivery.id)}">模拟失败</button></div>`;
|
||
}
|
||
if (delivery.status === "FAILED") return `<div class="row-actions"><button data-delivery-retry="${h(delivery.id)}">单校重试</button></div>`;
|
||
if (delivery.status === "DELIVERED") {
|
||
return `<div class="row-actions"><button data-delivery-ack="${h(delivery.id)}">校验并确认回执</button><button class="secondary" data-delivery-rollback="${h(delivery.id)}">单校回滚</button></div>`;
|
||
}
|
||
if (delivery.status === "ACKNOWLEDGED") return `<div class="row-actions"><button class="secondary" data-delivery-rollback="${h(delivery.id)}">单校回滚</button></div>`;
|
||
return `<span class="cell-note">已完成租户级回滚</span>`;
|
||
}
|
||
|
||
function renderDeliveries() {
|
||
const published = currentRelease()?.status === "PUBLISHED";
|
||
const button = document.getElementById("startPendingDeliveries");
|
||
button.disabled = !published || !operationsStore.deliveries.some((delivery) => delivery.status === "PENDING");
|
||
button.textContent = published ? "启动待发送学校" : "候选发布后可启动";
|
||
document.getElementById("deliveryTable").innerHTML = operationsStore.deliveries
|
||
.map(
|
||
(delivery) => `
|
||
<tr>
|
||
<td><span class="job-title"><strong>${h(delivery.school)}</strong><small>${h(delivery.tenantId)}</small></span></td>
|
||
<td><span class="record-time-copy"><strong>${h(delivery.packageId)}</strong><small>${h(delivery.dataVersion)}</small></span></td>
|
||
<td><span class="status-pill ${operationStatusTone(delivery.status)}">${h(operationStatusLabel(delivery.status))}</span>${delivery.failureReason ? `<small class="failure-note">${h(delivery.failureReason)}</small>` : ""}</td>
|
||
<td>
|
||
${
|
||
delivery.status === "DELIVERED"
|
||
? `<input class="receipt-input" id="receipt-count-${h(delivery.id)}" inputmode="numeric" value="${delivery.receiptRecords ?? delivery.expectedRecords}" aria-label="${h(delivery.school)}回执记录数" />`
|
||
: `<span class="record-time-copy"><strong>${delivery.receiptRecords === null ? "—" : formatNumber(delivery.receiptRecords)}</strong><small>期望 ${formatNumber(delivery.expectedRecords)}</small></span>`
|
||
}
|
||
</td>
|
||
<td>
|
||
${
|
||
delivery.status === "DELIVERED"
|
||
? `<input class="receipt-input hash-input" id="receipt-sha-${h(delivery.id)}" value="${h(delivery.receiptSha || delivery.expectedSha)}" aria-label="${h(delivery.school)}回执SHA-256" />`
|
||
: `<span class="hash-copy">${h(delivery.receiptSha || "—")}</span>`
|
||
}
|
||
</td>
|
||
<td><span class="record-time-copy"><strong>${h(delivery.actor)}</strong><small>${h(operationTime(delivery.time))} · ${delivery.attempts} 次</small></span></td>
|
||
<td>${deliveryActionButtons(delivery)}</td>
|
||
</tr>
|
||
`,
|
||
)
|
||
.join("");
|
||
}
|
||
|
||
function renderOperations() {
|
||
renderOperationsMetrics();
|
||
renderOverviewIssues();
|
||
renderOperationsIssues();
|
||
renderReleaseWorkspace();
|
||
renderDeliveries();
|
||
document.querySelectorAll("[data-operations-tab]").forEach((button) => button.classList.toggle("active", button.dataset.operationsTab === state.operationsTab));
|
||
document.querySelectorAll(".operations-workspace").forEach((workspace) => workspace.classList.toggle("active", workspace.id === `operations-${state.operationsTab}`));
|
||
}
|
||
|
||
function issueActionFooter(issue) {
|
||
const disposition = latestQualityDisposition(issue.id);
|
||
const qualityReviewAction =
|
||
disposition?.action === "FIX_REPLAY"
|
||
? `<button class="button primary" data-issue-action="verify">验证重放并通过</button>`
|
||
: `<button class="button primary" data-issue-action="verify-blocked">完成复核并保持阻断</button>`;
|
||
const actions = {
|
||
ALERTED: `<button class="button primary" data-issue-action="acknowledge">确认告警</button>`,
|
||
ACKNOWLEDGED: `<label class="footer-field"><span>指派负责人</span><select id="issueAssignee"><option>周敏 · 区域源运维</option><option>赵亦辰 · 采集工程师</option><option>数据标准组</option></select></label><button class="button primary" data-issue-action="assign">确认指派</button>`,
|
||
ASSIGNED: `<button class="button primary" data-issue-action="investigate">开始排障</button>`,
|
||
INVESTIGATING: issue.qualityRequired
|
||
? `<span class="footer-guidance">填写质量处置并提交复核</span>`
|
||
: `<button class="button primary" data-issue-action="resolve">解决问题</button>`,
|
||
QUALITY_REVIEW_PENDING: qualityReviewAction,
|
||
VERIFIED: `<button class="button primary" data-issue-action="resolve">标记已解决</button>`,
|
||
RESOLVED: `<button class="button primary" data-issue-action="close">关闭问题</button>`,
|
||
CLOSED: `<span class="release-final-note">问题已闭环,所有动作与证据已保留。</span>`,
|
||
};
|
||
return `<button class="button secondary" data-issue-close>关闭抽屉</button>${actions[issue.status] || ""}`;
|
||
}
|
||
|
||
function openIssueDrawer(issueId) {
|
||
const issue = operationsStore.issues.find((item) => item.id === issueId);
|
||
if (!issue) return;
|
||
state.issueId = issueId;
|
||
const disposition = operationsStore.qualityDispositions.filter((item) => item.issueId === issue.id).at(-1);
|
||
document.getElementById("issueDrawerKicker").textContent = `${issue.severity} · ${issue.id}`;
|
||
document.getElementById("issueDrawerTitle").textContent = issue.title;
|
||
document.getElementById("issueDrawerDescription").textContent = `${operationStatusLabel(issue.status)} · ${issue.owner} · ${issue.sla}`;
|
||
document.getElementById("issueDrawerContent").innerHTML = `
|
||
<div class="issue-summary-grid">
|
||
<span><small>负责人</small><strong>${h(issue.owner)}</strong></span>
|
||
<span><small>SLA</small><strong>${h(issue.sla)}</strong></span>
|
||
<span><small>关联任务</small><strong>${h(issue.relatedTask)}</strong></span>
|
||
<span><small>隔离记录</small><strong>${h(issue.quarantineId)}</strong></span>
|
||
</div>
|
||
<article class="drawer-card">
|
||
<h3>影响与证据</h3>
|
||
<p>${h(issue.impact)}</p>
|
||
<div class="evidence-tags">${issue.evidence.map((item) => `<span>${h(item)}</span>`).join("")}</div>
|
||
</article>
|
||
${
|
||
issue.status === "INVESTIGATING" && issue.qualityRequired
|
||
? `
|
||
<article class="drawer-card quality-disposition-form">
|
||
<h3>提交质量处置复核</h3>
|
||
<div class="form-grid">
|
||
<label><span>处置方式</span><select id="qualityDispositionAction"><option value="FIX_REPLAY">修复并重放</option><option value="WAIVE">豁免</option><option value="DISCARD">丢弃</option></select></label>
|
||
<label><span>独立复核人</span><select id="qualityReviewer"><option>林若溪</option><option>王海宁</option><option>数据质量组</option></select></label>
|
||
<label class="full"><span>处置原因</span><textarea id="qualityDispositionReason" rows="3" placeholder="说明根因、修复范围和重放结果"></textarea></label>
|
||
</div>
|
||
<button class="button primary wide" data-issue-action="submit-quality">提交质量复核</button>
|
||
</article>
|
||
`
|
||
: ""
|
||
}
|
||
${
|
||
disposition
|
||
? `
|
||
<article class="drawer-card immutable-action">
|
||
<h3>不可变质量动作</h3>
|
||
<dl>
|
||
<div><dt>动作编号</dt><dd>${h(disposition.id)}</dd></div>
|
||
<div><dt>处置方式</dt><dd>${h({ FIX_REPLAY: "修复并重放", WAIVE: "豁免", DISCARD: "丢弃" }[disposition.action])}</dd></div>
|
||
<div><dt>原因</dt><dd>${h(disposition.reason)}</dd></div>
|
||
<div><dt>复核人 / 状态</dt><dd>${h(disposition.reviewer)} · ${h(operationStatusLabel(disposition.status))}</dd></div>
|
||
<div><dt>追踪 / 证据</dt><dd>${h(disposition.trace)} · ${h(disposition.evidence.join("、"))}</dd></div>
|
||
</dl>
|
||
</article>
|
||
`
|
||
: ""
|
||
}
|
||
<article class="drawer-card">
|
||
<h3>处置时间线</h3>
|
||
<div class="issue-timeline">
|
||
${issue.timeline
|
||
.map(
|
||
(step) => `
|
||
<span><i></i><span><strong>${h(operationStatusLabel(step.status))} · ${h(step.actor)}</strong><small>${h(operationTime(step.time))} · ${h(step.note)}</small><em>${h(step.trace)} · ${h(step.evidence.join("、"))}</em></span></span>
|
||
`,
|
||
)
|
||
.join("")}
|
||
</div>
|
||
</article>
|
||
`;
|
||
document.getElementById("issueDrawerFooter").innerHTML = issueActionFooter(issue);
|
||
const drawer = document.getElementById("issueDrawer");
|
||
drawer.classList.add("open");
|
||
drawer.setAttribute("aria-hidden", "false");
|
||
document.body.style.overflow = "hidden";
|
||
}
|
||
|
||
function closeIssueDrawer() {
|
||
const drawer = document.getElementById("issueDrawer");
|
||
drawer.classList.remove("open");
|
||
drawer.setAttribute("aria-hidden", "true");
|
||
document.body.style.overflow = "";
|
||
}
|
||
|
||
function transitionIssue(issue, status, note, evidence, actor = OPERATIONS_ACTOR) {
|
||
const metadata = addOperationsAudit({
|
||
event: `${issue.title}:${operationStatusLabel(status)}`,
|
||
object: issue.id,
|
||
actor,
|
||
result: operationStatusLabel(status),
|
||
evidence,
|
||
});
|
||
migrateOperationsEntity(issue, status, metadata);
|
||
issue.timeline.push({
|
||
id: operationToken("STEP"),
|
||
status,
|
||
actor: metadata.actor,
|
||
time: metadata.time,
|
||
trace: metadata.trace,
|
||
evidence: [...metadata.evidence],
|
||
note,
|
||
});
|
||
const workOrder = operationsStore.workOrders.find((item) => item.issueId === issue.id);
|
||
if (workOrder) {
|
||
migrateOperationsEntity(workOrder, status === "CLOSED" ? "CLOSED" : status, metadata);
|
||
workOrder.assignee = issue.owner;
|
||
}
|
||
saveOperationsStore();
|
||
renderOperations();
|
||
renderAudit();
|
||
openIssueDrawer(issue.id);
|
||
}
|
||
|
||
function renderOverviewSources() {
|
||
const selected = sources.slice(0, 5);
|
||
document.getElementById("overviewSourceHealth").innerHTML = selected
|
||
.map(
|
||
(source) => `
|
||
<button class="source-health-item" data-source-detail="${h(source.id)}">
|
||
<span class="source-health-logo ${h(source.category)}">${h(source.short.slice(0, 2))}</span>
|
||
<span class="source-health-copy"><strong>${h(source.name)}</strong><small>${h(source.adapter)} · 更新 ${h(source.updated)}</small></span>
|
||
<span class="health-meter"><i><b style="width:${source.health}%"></b></i><strong>${source.health.toFixed(1)}</strong></span>
|
||
${sourceStatusPill(source)}
|
||
</button>
|
||
`,
|
||
)
|
||
.join("");
|
||
}
|
||
|
||
function filteredSources() {
|
||
const keyword = state.sourceSearch.trim().toLowerCase();
|
||
return sources.filter((source) => {
|
||
const matchesFilter = state.sourceFilter === "all" || source.category === state.sourceFilter;
|
||
const text = `${source.name}${source.domain}${source.adapter}${source.categoryLabel}`.toLowerCase();
|
||
return matchesFilter && text.includes(keyword);
|
||
});
|
||
}
|
||
|
||
function renderSources() {
|
||
const filtered = filteredSources();
|
||
document.getElementById("sourceGrid").innerHTML = filtered
|
||
.map(
|
||
(source) => `
|
||
<article class="source-card">
|
||
<div class="source-card-head">
|
||
<span class="source-logo ${h(source.category)}">${h(source.short.slice(0, 2))}</span>
|
||
<span class="source-name"><strong>${h(source.name)}</strong><small>${h(source.domain)}</small></span>
|
||
<em class="source-type ${h(source.category)}">${h(source.categoryLabel)}</em>
|
||
</div>
|
||
<div class="source-card-metrics">
|
||
<span><small>健康度</small><strong>${source.health.toFixed(1)}</strong></span>
|
||
<span><small>今日新增</small><strong>${formatNumber(source.today)}</strong></span>
|
||
<span><small>累计有效</small><strong>${h(source.total)}</strong></span>
|
||
</div>
|
||
<div class="source-card-meta">
|
||
<span>${h(source.mode)}</span><span>${h(source.schedule)}</span><span>${h(source.contract)}</span>
|
||
</div>
|
||
<div class="source-card-foot">
|
||
<span>${h(source.statusLabel)} · 更新于 ${h(source.updated)}</span>
|
||
<div class="source-card-actions">
|
||
${source.category === "school" ? "" : `<button data-source-records="${h(source.id)}">查看数据</button>`}
|
||
<button data-source-detail="${h(source.id)}">进入工作台</button>
|
||
</div>
|
||
</div>
|
||
</article>
|
||
`,
|
||
)
|
||
.join("");
|
||
if (!filtered.length) {
|
||
document.getElementById("sourceGrid").innerHTML = `<div class="info-note">没有找到符合条件的数据源,请调整分类或搜索关键词。</div>`;
|
||
}
|
||
document.getElementById("sourceListSummary").textContent = `当前展示 ${filtered.length} 个数据源`;
|
||
document.getElementById("sourceTotal").textContent = sources.length;
|
||
document.getElementById("sourceRunning").textContent = sources.filter((source) => source.status === "running").length;
|
||
document.getElementById("sourceNavCount").textContent = sources.length;
|
||
}
|
||
|
||
function drawerStats(source) {
|
||
return `
|
||
<div class="drawer-stat-grid">
|
||
<span><small>当前源版本</small><strong>${h(source.version)}</strong><em>${h(source.updated)} 更新</em></span>
|
||
<span><small>综合健康度</small><strong>${source.health.toFixed(1)}</strong><em>${h(source.statusLabel)}</em></span>
|
||
<span><small>今日有效记录</small><strong>${formatNumber(source.today)}</strong><em>累计 ${h(source.total)}</em></span>
|
||
<span><small>目标契约</small><strong>${h(source.contract)}</strong><em>${h(source.schema)}</em></span>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function renderSourceOverview(source) {
|
||
return `
|
||
${drawerStats(source)}
|
||
<div class="drawer-contract-banner">
|
||
<span>${h(source.categoryLabel.toUpperCase())} · ${h(source.adapter)}</span>
|
||
<strong>${h(source.coverage)}</strong>
|
||
<small>${h(source.change)}</small>
|
||
</div>
|
||
<article class="drawer-card">
|
||
<h3>资产责任与运行策略</h3>
|
||
<dl>
|
||
<div><dt>来源入口</dt><dd>${h(source.domain)}</dd></div>
|
||
<div><dt>接入方式</dt><dd>${h(source.mode)}</dd></div>
|
||
<div><dt>更新频率</dt><dd>${h(source.schedule)}</dd></div>
|
||
<div><dt>技术责任人</dt><dd>${h(source.owner)}</dd></div>
|
||
<div><dt>原始数据留存</dt><dd>${h(source.retention)}</dd></div>
|
||
<div><dt>连接器资产</dt><dd>${h(source.repo)} · ${h(source.codeVersion)}</dd></div>
|
||
</dl>
|
||
</article>
|
||
<article class="drawer-card">
|
||
<h3>进入生产的控制点</h3>
|
||
<ul>
|
||
<li>先保存原始响应与内容哈希,再执行解析、清洗和岗位归一。</li>
|
||
<li>解析规则、代码版本、任务分片和增量水位与数据版本绑定。</li>
|
||
<li>新采集批次未通过质量验收前,不覆盖当前稳定交付版本。</li>
|
||
</ul>
|
||
</article>
|
||
`;
|
||
}
|
||
|
||
function renderSourceAcquisition(source) {
|
||
return `
|
||
${drawerStats(source)}
|
||
<article class="drawer-card">
|
||
<h3>生产采集配置</h3>
|
||
<dl>
|
||
<div><dt>采集模式</dt><dd>${h(source.mode)}</dd></div>
|
||
<div><dt>调度计划</dt><dd>${h(source.schedule)}</dd></div>
|
||
<div><dt>并发与速率</dt><dd>${h(source.rate)}</dd></div>
|
||
<div><dt>失败重试</dt><dd>${h(source.retry)}</dd></div>
|
||
<div><dt>采集范围</dt><dd>${h(source.coverage)}</dd></div>
|
||
<div><dt>目标数据契约</dt><dd>${h(source.contract)}</dd></div>
|
||
</dl>
|
||
</article>
|
||
<article class="drawer-card">
|
||
<h3>任务执行要求</h3>
|
||
<ul>
|
||
<li>所有请求必须携带 task_id、shard_id、collector_version 和 trace_id。</li>
|
||
<li>源端异常触发自动降速、熔断或分片隔离,不影响其他来源继续运行。</li>
|
||
<li>增量任务必须提交已验收水位;失败重试保持幂等。</li>
|
||
<li>动态页面、接口响应和文件下载均进入原始留存层,解析过程可重放。</li>
|
||
</ul>
|
||
</article>
|
||
`;
|
||
}
|
||
|
||
function renderSourceMapping(source) {
|
||
const rows =
|
||
source.category === "school"
|
||
? [
|
||
["student_key", "student_token", "令牌化", "必填"],
|
||
["major_code", "program_code", "专业代码字典", "必填"],
|
||
["job_name", "job_title", "去空白与标准化", "条件必填"],
|
||
["company_code", "enterprise_token", "企业标识令牌化", "条件必填"],
|
||
["salary", "salary_monthly", "统一元/月", "可选"],
|
||
["update_time", "updated_at", "ISO-8601", "必填"],
|
||
]
|
||
: [
|
||
["source_id", "source_record_id", "原样保留", "必填"],
|
||
["job_name", "job_title_raw", "去标记与空白", "必填"],
|
||
["company_name", "enterprise_name", "原文 + 企业归一", "必填"],
|
||
["city", "region_code", "GB/T 2260", "必填"],
|
||
["salary", "salary_min/max", "统一税前月薪", "可选"],
|
||
["description", "job_description", "去联系方式", "必填"],
|
||
["publish_time", "published_at", "ISO-8601", "可选"],
|
||
["detail_url", "source_url", "绝对URL", "必填"],
|
||
];
|
||
return `
|
||
<div class="drawer-contract-banner">
|
||
<span>FIELD MAPPING · ${h(source.schema)}</span>
|
||
<strong>${h(source.name)} → ${h(source.contract)}</strong>
|
||
<small>映射版本与连接器代码独立版本化,支持回放历史原始数据进行回归验证。</small>
|
||
</div>
|
||
<article class="drawer-card">
|
||
<h3>核心字段映射</h3>
|
||
<div class="table-wrap">
|
||
<table class="mapping-table">
|
||
<thead><tr><th>源字段</th><th>目标字段</th><th>转换规则</th><th>要求</th></tr></thead>
|
||
<tbody>${rows.map((row) => `<tr>${row.map((cell) => `<td>${h(cell)}</td>`).join("")}</tr>`).join("")}</tbody>
|
||
</table>
|
||
</div>
|
||
</article>
|
||
<article class="drawer-card">
|
||
<h3>样本回归</h3>
|
||
<p>最近回归样本 1,000 条,结构通过率 100%,必填完整率 98.8%,岗位归一覆盖率 96.4%。未匹配岗位进入人工复核队列,不以模型推断覆盖原始事实。</p>
|
||
</article>
|
||
`;
|
||
}
|
||
|
||
function renderSourceRuntime(source) {
|
||
return `
|
||
${drawerStats(source)}
|
||
<article class="drawer-card">
|
||
<h3>最近运行事件</h3>
|
||
<div class="runtime-list">
|
||
${source.runtime.map(([time, title, detail]) => `<span><strong>${h(time)} · ${h(title)}</strong><small>${h(detail)}</small></span>`).join("")}
|
||
</div>
|
||
</article>
|
||
<article class="drawer-card">
|
||
<h3>稳定版本保护</h3>
|
||
<p>运行中的采集、解析和质量任务只写入暂存版本。完整规则通过并完成发布审批后,才更新供下游分析引用的稳定别名。</p>
|
||
</article>
|
||
`;
|
||
}
|
||
|
||
function renderSourceAudit(source) {
|
||
return `
|
||
<div class="drawer-contract-banner">
|
||
<span>TRACEABLE SOURCE ASSET</span>
|
||
<strong>${h(source.version)} · ${h(source.codeVersion)}</strong>
|
||
<small>来源、代码、任务、原始快照、规则、质量和交付版本均可通过追踪编号关联。</small>
|
||
</div>
|
||
<article class="drawer-card">
|
||
<h3>当前审计证据</h3>
|
||
<div class="audit-evidence-list">
|
||
<span><strong>来源注册记录</strong><small>${h(source.domain)} · 资产责任人 ${h(source.owner)}</small></span>
|
||
<span><strong>连接器代码版本</strong><small>${h(source.repo)} · ${h(source.codeVersion)}</small></span>
|
||
<span><strong>原始数据版本</strong><small>RAW-${h(source.id.toUpperCase())}-20260823 · SHA-256 已固化</small></span>
|
||
<span><strong>字段映射与契约</strong><small>${h(source.schema)} → ${h(source.contract)}</small></span>
|
||
<span><strong>生产运行策略</strong><small>${h(source.rate)} · ${h(source.retry)}</small></span>
|
||
</div>
|
||
</article>
|
||
<article class="drawer-card">
|
||
<h3>数据使用边界</h3>
|
||
<p>平台只向产教分析产品交付经契约治理的岗位信号、企业画像和统计结果。原始层与交付层分区存储,租户交付范围由 Manifest 和平台数据范围策略共同控制。</p>
|
||
</article>
|
||
`;
|
||
}
|
||
|
||
function updateSourceDrawer() {
|
||
const source = sourceById(state.sourceId);
|
||
document.getElementById("sourceDrawerKicker").textContent = `${source.categoryLabel.toUpperCase()} · ${source.adapter}`;
|
||
document.getElementById("sourceDrawerTitle").textContent = source.name;
|
||
document.getElementById("sourceDrawerDescription").textContent = `${source.domain} · ${source.coverage} · ${source.statusLabel}`;
|
||
document.querySelectorAll("#sourceDrawerTabs button").forEach((button) => {
|
||
button.classList.toggle("active", button.dataset.sourceTab === state.sourceTab);
|
||
});
|
||
const renderers = {
|
||
overview: renderSourceOverview,
|
||
acquisition: renderSourceAcquisition,
|
||
mapping: renderSourceMapping,
|
||
runtime: renderSourceRuntime,
|
||
audit: renderSourceAudit,
|
||
};
|
||
document.getElementById("sourceDrawerContent").innerHTML = renderers[state.sourceTab](source);
|
||
document.getElementById("sourceDrawerFooter").innerHTML = `
|
||
<button class="button secondary" data-drawer-close>关闭</button>
|
||
${source.category === "school" ? "" : `<button class="button secondary" data-source-records="${h(source.id)}">查看采集数据</button>`}
|
||
<button class="button secondary" data-source-test="${h(source.id)}">测试连接</button>
|
||
<button class="button primary" data-source-job="${h(source.id)}">创建采集任务</button>
|
||
`;
|
||
}
|
||
|
||
function openSourceDrawer(sourceId) {
|
||
state.sourceId = sourceId;
|
||
state.sourceTab = "overview";
|
||
updateSourceDrawer();
|
||
const drawer = document.getElementById("sourceDrawer");
|
||
drawer.classList.add("open");
|
||
drawer.setAttribute("aria-hidden", "false");
|
||
document.body.style.overflow = "hidden";
|
||
}
|
||
|
||
function closeSourceDrawer() {
|
||
const drawer = document.getElementById("sourceDrawer");
|
||
drawer.classList.remove("open");
|
||
drawer.setAttribute("aria-hidden", "true");
|
||
document.body.style.overflow = "";
|
||
}
|
||
|
||
function renderCollectionJobs() {
|
||
const jobs = collectionJobs.filter((job) => state.jobFilter === "all" || job.status === state.jobFilter);
|
||
document.getElementById("collectionJobTable").innerHTML = jobs
|
||
.map((job) => {
|
||
const source = sourceById(job.sourceId);
|
||
const statusTone = job.status === "attention" ? "attention" : job.status === "running" ? "running" : "";
|
||
return `
|
||
<tr>
|
||
<td><span class="job-title"><strong>${h(job.name)}</strong><small>${h(job.id)} · ${h(source.name)}</small></span></td>
|
||
<td>${h(job.scope)}</td>
|
||
<td><span class="job-strategy"><span>${h(job.strategy)}</span><small>${h(source.contract)}</small></span></td>
|
||
<td><div class="job-progress"><span><small>${job.progress ? "已完成" : "等待开始"}</small><strong>${job.progress}%</strong></span><i><b style="width:${job.progress}%"></b></i></div></td>
|
||
<td>
|
||
<span class="job-strategy"><span>${h(job.records)} 条</span><small>有效率 ${h(job.valid)}</small></span>
|
||
${job.records === "—" ? "" : `<button class="record-count-link" data-job-records="${h(job.id)}">查看本批数据</button>`}
|
||
</td>
|
||
<td><span class="status-pill ${statusTone}">${h(job.statusLabel)}</span></td>
|
||
<td><div class="row-actions"><button data-job-detail="${h(job.id)}">详情</button><button class="secondary" data-job-toggle="${h(job.id)}">${job.status === "running" ? "暂停" : "运行"}</button></div></td>
|
||
</tr>
|
||
`;
|
||
})
|
||
.join("");
|
||
if (!jobs.length) {
|
||
document.getElementById("collectionJobTable").innerHTML = `<tr><td colspan="7">当前分类暂无采集任务。</td></tr>`;
|
||
}
|
||
document.getElementById("runningJobCount").textContent = collectionJobs.filter((job) => job.status === "running").length;
|
||
}
|
||
|
||
function populateRecordSources() {
|
||
const select = document.getElementById("recordSourceFilter");
|
||
select.innerHTML = [
|
||
`<option value="all">全部外部来源</option>`,
|
||
...sources.filter((source) => source.category !== "school").map((source) => `<option value="${h(source.id)}">${h(source.name)}</option>`),
|
||
].join("");
|
||
if (!sources.some((source) => source.id === state.recordSource && source.category !== "school")) state.recordSource = "all";
|
||
select.value = state.recordSource;
|
||
}
|
||
|
||
function recordSearchMatches(values) {
|
||
const keyword = state.recordSearch.trim().toLowerCase();
|
||
return !keyword || values.filter(Boolean).join(" ").toLowerCase().includes(keyword);
|
||
}
|
||
|
||
function filteredStandardRecords() {
|
||
return standardJobRecords.filter((record) => {
|
||
const source = sourceById(record.sourceId);
|
||
const sourceMatch = state.recordSource === "all" || record.sourceId === state.recordSource;
|
||
const regionMatch = state.recordRegion === "all" || record.region === state.recordRegion;
|
||
return (
|
||
sourceMatch &&
|
||
regionMatch &&
|
||
recordSearchMatches([
|
||
record.id,
|
||
record.sourceRecordId,
|
||
record.taskId,
|
||
record.snapshotId,
|
||
record.jobTitleRaw,
|
||
record.jobTitleNormalized,
|
||
record.enterprise,
|
||
record.region,
|
||
record.workplace,
|
||
record.jobCode,
|
||
record.skills.join(" "),
|
||
source.name,
|
||
])
|
||
);
|
||
});
|
||
}
|
||
|
||
function filteredRawSnapshots() {
|
||
return allRawSnapshots.filter((snapshot) => {
|
||
const source = sourceById(snapshot.sourceId);
|
||
const sourceMatch = state.recordSource === "all" || snapshot.sourceId === state.recordSource;
|
||
return sourceMatch && recordSearchMatches([snapshot.id, snapshot.taskId, snapshot.objectPath, snapshot.sha256, snapshot.mediaType, source.name]);
|
||
});
|
||
}
|
||
|
||
function filteredQuarantineRecords() {
|
||
return quarantineRecords.filter((record) => {
|
||
const source = sourceById(record.sourceId);
|
||
const sourceMatch = state.recordSource === "all" || record.sourceId === state.recordSource;
|
||
const regionMatch = state.recordRegion === "all" || record.region === state.recordRegion;
|
||
return sourceMatch && regionMatch && recordSearchMatches([record.id, record.sourceRecordId, record.taskId, record.snapshotId, record.jobTitle, record.issue, record.rule, source.name]);
|
||
});
|
||
}
|
||
|
||
function recordSourceCell(source, secondary) {
|
||
return `
|
||
<span class="record-source-cell">
|
||
<b class="record-source-mark ${h(source.category)}">${h(source.short.slice(0, 2))}</b>
|
||
<span class="record-source-copy"><strong>${h(source.name)}</strong><small>${h(secondary)}</small></span>
|
||
</span>
|
||
`;
|
||
}
|
||
|
||
function recordSalary(record) {
|
||
return `¥${formatNumber(record.salaryMin)}–${formatNumber(record.salaryMax)} / 月`;
|
||
}
|
||
|
||
function renderStandardRecordTable(records, sourceLayer = false) {
|
||
if (!records.length) return `<div class="info-note">当前查询条件下没有匹配记录,请调整来源、地区或关键词。</div>`;
|
||
const rows = records
|
||
.map((record) => {
|
||
const source = sourceById(record.sourceId);
|
||
if (sourceLayer) {
|
||
return `
|
||
<tr>
|
||
<td>${recordSourceCell(source, record.sourceRecordId)}</td>
|
||
<td><span class="record-title"><strong>${h(record.jobTitleRaw)}</strong><small>${h(record.enterprise)}</small></span></td>
|
||
<td><span class="record-title"><strong>${h(record.sourceUrl.replace(/^https?:\/\//, ""))}</strong><small>来源详情定位</small></span></td>
|
||
<td><span class="record-time-copy"><strong>${h(record.taskId)}</strong><small>${h(record.snapshotId)}</small></span></td>
|
||
<td><span class="record-time-copy"><strong>${h(record.collectorVersion)}</strong><small>${h(record.parserVersion)}</small></span></td>
|
||
<td>${h(record.collectedAt)}</td>
|
||
<td><div class="row-actions"><button data-record-id="${h(record.id)}">查看</button></div></td>
|
||
</tr>
|
||
`;
|
||
}
|
||
return `
|
||
<tr>
|
||
<td>${recordSourceCell(source, record.sourceRecordId)}</td>
|
||
<td><span class="record-title"><strong>${h(record.jobTitleNormalized)}</strong><small>原始:${h(record.jobTitleRaw)}</small></span></td>
|
||
<td><span class="record-title"><strong>${h(record.enterprise)}</strong><small>${h(record.workplace)}</small></span></td>
|
||
<td><span class="record-title"><strong>${h(recordSalary(record))}</strong><small>${h(record.employmentType)} · ${h(record.jobCode)}</small></span></td>
|
||
<td><span class="record-time-copy"><strong>${h(record.publishedAt)}</strong><small>采集 ${h(record.collectedAt)}</small></span></td>
|
||
<td><span class="record-quality ${record.quality < 95 ? "attention" : ""}">${record.quality.toFixed(1)}</span></td>
|
||
<td><div class="row-actions"><button data-record-id="${h(record.id)}">查看</button></div></td>
|
||
</tr>
|
||
`;
|
||
})
|
||
.join("");
|
||
return `
|
||
<div class="table-wrap">
|
||
<table class="data-table record-table">
|
||
<thead>
|
||
${
|
||
sourceLayer
|
||
? "<tr><th>来源与源记录</th><th>源端岗位与企业</th><th>来源URL</th><th>任务与快照</th><th>采集器与解析器</th><th>采集时间</th><th>操作</th></tr>"
|
||
: "<tr><th>来源与源记录</th><th>标准岗位</th><th>企业与工作地点</th><th>薪资与分类</th><th>发布时间</th><th>质量分</th><th>操作</th></tr>"
|
||
}
|
||
</thead>
|
||
<tbody>${rows}</tbody>
|
||
</table>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function renderRawSnapshotTable(snapshots) {
|
||
if (!snapshots.length) return `<div class="info-note">当前查询条件下没有匹配的原始快照。</div>`;
|
||
return `
|
||
<div class="table-wrap">
|
||
<table class="data-table record-table">
|
||
<thead><tr><th>来源与快照</th><th>采集任务</th><th>对象格式</th><th>记录与大小</th><th>对象存储定位</th><th>固化时间</th><th>校验状态</th><th>操作</th></tr></thead>
|
||
<tbody>
|
||
${snapshots
|
||
.map((snapshot) => {
|
||
const source = sourceById(snapshot.sourceId);
|
||
return `
|
||
<tr>
|
||
<td>${recordSourceCell(source, snapshot.id)}</td>
|
||
<td>${h(snapshot.taskId)}</td>
|
||
<td>${h(snapshot.mediaType)}</td>
|
||
<td><span class="record-title"><strong>${h(snapshot.records)} 条</strong><small>${h(snapshot.size)}</small></span></td>
|
||
<td><span class="record-title"><strong>${h(snapshot.objectPath)}</strong><small>WORM 原始对象区</small></span></td>
|
||
<td>${h(snapshot.capturedAt)}</td>
|
||
<td><span class="status-pill running">${h(snapshot.status)}</span></td>
|
||
<td><div class="row-actions"><button data-snapshot-id="${h(snapshot.id)}">查看</button></div></td>
|
||
</tr>
|
||
`;
|
||
})
|
||
.join("")}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function renderQuarantineTable(records) {
|
||
if (!records.length) return `<div class="info-note">当前查询条件下没有匹配的隔离记录。</div>`;
|
||
return `
|
||
<div class="table-wrap">
|
||
<table class="data-table record-table">
|
||
<thead><tr><th>隔离编号</th><th>来源记录</th><th>岗位</th><th>质量问题</th><th>任务与快照</th><th>发现时间</th><th>处置状态</th><th>操作</th></tr></thead>
|
||
<tbody>
|
||
${records
|
||
.map((record) => {
|
||
const source = sourceById(record.sourceId);
|
||
return `
|
||
<tr>
|
||
<td><span class="record-time-copy"><strong>${h(record.id)}</strong><small>${h(record.rule)}</small></span></td>
|
||
<td>${recordSourceCell(source, record.sourceRecordId)}</td>
|
||
<td>${h(record.jobTitle)}</td>
|
||
<td><span class="record-title"><strong>${h(record.issue)}</strong><small>未进入稳定版本</small></span></td>
|
||
<td><span class="record-time-copy"><strong>${h(record.taskId)}</strong><small>${h(record.snapshotId)}</small></span></td>
|
||
<td>${h(record.detectedAt)}</td>
|
||
<td><span class="status-pill attention">${h(record.disposition)}</span></td>
|
||
<td><div class="row-actions"><button data-quarantine-id="${h(record.id)}">查看</button></div></td>
|
||
</tr>
|
||
`;
|
||
})
|
||
.join("")}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function currentRecordResults() {
|
||
if (state.recordLayer === "raw") return filteredRawSnapshots();
|
||
if (state.recordLayer === "quarantine") return filteredQuarantineRecords();
|
||
return filteredStandardRecords();
|
||
}
|
||
|
||
function renderRecords() {
|
||
document.querySelectorAll("#recordLayers [data-record-layer]").forEach((button) => {
|
||
button.classList.toggle("active", button.dataset.recordLayer === state.recordLayer);
|
||
});
|
||
document.getElementById("recordSourceFilter").value = state.recordSource;
|
||
document.getElementById("recordRegionFilter").value = state.recordRegion;
|
||
document.getElementById("recordSearch").value = state.recordSearch;
|
||
const results = currentRecordResults();
|
||
const renderers = {
|
||
standard: () => renderStandardRecordTable(results),
|
||
source: () => renderStandardRecordTable(results, true),
|
||
raw: () => renderRawSnapshotTable(results),
|
||
quarantine: () => renderQuarantineTable(results),
|
||
};
|
||
document.getElementById("recordTableContainer").innerHTML = renderers[state.recordLayer]();
|
||
const layerLabel = { standard: "标准岗位", source: "源端记录", raw: "原始快照", quarantine: "隔离记录" }[state.recordLayer];
|
||
document.getElementById("recordQuerySummary").textContent = `${layerLabel} · 当前展示 ${results.length} 条结构样例`;
|
||
}
|
||
|
||
function openRecordExplorer(sourceId = "all", search = "", layer = "standard") {
|
||
state.recordSource = sources.some((source) => source.id === sourceId && source.category !== "school") ? sourceId : "all";
|
||
state.recordSearch = search;
|
||
state.recordRegion = "all";
|
||
state.recordLayer = layer;
|
||
populateRecordSources();
|
||
renderRecords();
|
||
switchView("records");
|
||
}
|
||
|
||
function openRecordDrawer({ kicker, title, description, content, footer }) {
|
||
document.getElementById("recordDrawerKicker").textContent = kicker;
|
||
document.getElementById("recordDrawerTitle").textContent = title;
|
||
document.getElementById("recordDrawerDescription").textContent = description;
|
||
document.getElementById("recordDrawerContent").innerHTML = content;
|
||
document.getElementById("recordDrawerFooter").innerHTML = footer;
|
||
const drawer = document.getElementById("recordDrawer");
|
||
drawer.classList.add("open");
|
||
drawer.setAttribute("aria-hidden", "false");
|
||
document.body.style.overflow = "hidden";
|
||
}
|
||
|
||
function closeRecordDrawer() {
|
||
const drawer = document.getElementById("recordDrawer");
|
||
drawer.classList.remove("open");
|
||
drawer.setAttribute("aria-hidden", "true");
|
||
document.body.style.overflow = "";
|
||
}
|
||
|
||
function openStandardRecord(recordId) {
|
||
const record = standardJobRecords.find((item) => item.id === recordId);
|
||
if (!record) return;
|
||
const source = sourceById(record.sourceId);
|
||
const payload = {
|
||
record_id: record.id,
|
||
source_platform: record.sourceId.toUpperCase(),
|
||
source_record_id: record.sourceRecordId,
|
||
source_url: record.sourceUrl,
|
||
collector_task_id: record.taskId,
|
||
collector_version: record.collectorVersion,
|
||
raw_snapshot_id: record.snapshotId,
|
||
collected_at: record.collectedAt,
|
||
published_at: record.publishedAt,
|
||
job_title_raw: record.jobTitleRaw,
|
||
job_title_normalized: record.jobTitleNormalized,
|
||
job_code: record.jobCode,
|
||
job_description: record.description,
|
||
enterprise_name: record.enterprise,
|
||
region_code: record.regionCode,
|
||
workplace: record.workplace,
|
||
salary_min: record.salaryMin,
|
||
salary_max: record.salaryMax,
|
||
salary_currency: "CNY",
|
||
salary_period: "month",
|
||
skills: record.skills,
|
||
content_hash: record.contentHash,
|
||
parsing_version: record.parserVersion,
|
||
normalized_at: record.normalizedAt,
|
||
data_version: record.dataVersion,
|
||
};
|
||
openRecordDrawer({
|
||
kicker: `STANDARD RECORD · ${record.dataVersion}`,
|
||
title: record.jobTitleNormalized,
|
||
description: `${source.name} · ${record.sourceRecordId} · ${record.status}`,
|
||
content: `
|
||
<div class="record-detail-banner">
|
||
<div><span>${h(source.name)} · ${h(record.jobCode)}</span><strong>${h(record.jobTitleRaw)} → ${h(record.jobTitleNormalized)}</strong><small>${h(record.enterprise)} · ${h(record.workplace)} · ${h(recordSalary(record))}</small></div>
|
||
<strong class="record-detail-score">${record.quality.toFixed(1)}</strong>
|
||
</div>
|
||
<article class="drawer-card">
|
||
<h3>标准业务字段</h3>
|
||
<dl>
|
||
<div><dt>记录标识</dt><dd>${h(record.id)}</dd></div>
|
||
<div><dt>企业与岗位</dt><dd>${h(record.enterprise)} · ${h(record.jobTitleNormalized)}</dd></div>
|
||
<div><dt>工作地点</dt><dd>${h(record.workplace)} · ${h(record.regionCode)}</dd></div>
|
||
<div><dt>薪资与性质</dt><dd>${h(recordSalary(record))} · ${h(record.employmentType)}</dd></div>
|
||
<div><dt>技能标签</dt><dd><span class="evidence-tags">${record.skills.map((skill) => `<span>${h(skill)}</span>`).join("")}</span></dd></div>
|
||
<div><dt>来源定位</dt><dd><a class="record-source-link" href="${h(record.sourceUrl)}" target="_blank" rel="noopener noreferrer">${h(record.sourceUrl)}</a></dd></div>
|
||
</dl>
|
||
</article>
|
||
<article class="drawer-card">
|
||
<h3>全链路证据</h3>
|
||
<div class="record-lineage">
|
||
<span><b>01</b><span><strong>采集任务</strong><small>${h(record.taskId)} · ${h(record.collectorVersion)}</small></span></span>
|
||
<span><b>02</b><span><strong>原始快照</strong><small>${h(record.snapshotId)} · payload SHA-256 ${h(record.payloadHash)}</small></span></span>
|
||
<span><b>03</b><span><strong>解析映射</strong><small>${h(record.parserVersion)} · ${h(record.sourceRecordId)}</small></span></span>
|
||
<span><b>04</b><span><strong>质量与归一</strong><small>质量分 ${record.quality.toFixed(1)} · 岗位代码 ${h(record.jobCode)}</small></span></span>
|
||
<span><b>05</b><span><strong>稳定版本</strong><small>${h(record.dataVersion)} · content SHA-256 ${h(record.contentHash)}</small></span></span>
|
||
</div>
|
||
</article>
|
||
<article class="drawer-card">
|
||
<h3>JOB-SIGNAL V3.0 契约记录</h3>
|
||
<pre class="record-payload">${h(JSON.stringify(payload, null, 2))}</pre>
|
||
</article>
|
||
`,
|
||
footer: `
|
||
<button class="button secondary" data-record-close>关闭</button>
|
||
<button class="button secondary" data-record-source="${h(record.sourceId)}">查看数据源资产</button>
|
||
<button class="button primary" data-record-raw="${h(record.snapshotId)}" data-record-source-id="${h(record.sourceId)}">定位原始快照</button>
|
||
`,
|
||
});
|
||
}
|
||
|
||
function openRawSnapshot(snapshotId) {
|
||
const snapshot = allRawSnapshots.find((item) => item.id === snapshotId);
|
||
if (!snapshot) return;
|
||
const source = sourceById(snapshot.sourceId);
|
||
const manifest = {
|
||
snapshot_id: snapshot.id,
|
||
source_id: snapshot.sourceId,
|
||
collector_task_id: snapshot.taskId,
|
||
captured_at: snapshot.capturedAt,
|
||
media_type: snapshot.mediaType,
|
||
record_count: Number(snapshot.records.replaceAll(",", "")),
|
||
object_path: snapshot.objectPath,
|
||
compressed_size: snapshot.size,
|
||
sha256: snapshot.sha256,
|
||
retention_policy: source.retention,
|
||
immutable: true,
|
||
};
|
||
openRecordDrawer({
|
||
kicker: "RAW SNAPSHOT · IMMUTABLE",
|
||
title: snapshot.id,
|
||
description: `${source.name} · ${snapshot.records} 条 · ${snapshot.status}`,
|
||
content: `
|
||
<div class="record-detail-banner">
|
||
<div><span>${h(source.name)} · ${h(snapshot.mediaType)}</span><strong>${h(snapshot.records)} 条原始记录</strong><small>${h(snapshot.objectPath)} · ${h(snapshot.size)}</small></div>
|
||
<strong class="record-detail-score">RAW</strong>
|
||
</div>
|
||
<article class="drawer-card">
|
||
<h3>原始对象元数据</h3>
|
||
<dl>
|
||
<div><dt>采集任务</dt><dd>${h(snapshot.taskId)}</dd></div>
|
||
<div><dt>固化时间</dt><dd>${h(snapshot.capturedAt)}</dd></div>
|
||
<div><dt>对象格式</dt><dd>${h(snapshot.mediaType)}</dd></div>
|
||
<div><dt>对象定位</dt><dd>${h(snapshot.objectPath)}</dd></div>
|
||
<div><dt>SHA-256</dt><dd>${h(snapshot.sha256)}</dd></div>
|
||
<div><dt>留存策略</dt><dd>${h(source.retention)}</dd></div>
|
||
</dl>
|
||
</article>
|
||
<article class="drawer-card">
|
||
<h3>快照清单</h3>
|
||
<pre class="record-payload">${h(JSON.stringify(manifest, null, 2))}</pre>
|
||
</article>
|
||
`,
|
||
footer: `
|
||
<button class="button secondary" data-record-close>关闭</button>
|
||
<button class="button primary" data-record-source="${h(snapshot.sourceId)}">查看数据源资产</button>
|
||
`,
|
||
});
|
||
}
|
||
|
||
function openQuarantineRecord(recordId) {
|
||
const record = quarantineRecords.find((item) => item.id === recordId);
|
||
if (!record) return;
|
||
const source = sourceById(record.sourceId);
|
||
openRecordDrawer({
|
||
kicker: `QUARANTINE · ${record.rule}`,
|
||
title: record.jobTitle,
|
||
description: `${source.name} · ${record.sourceRecordId} · 未进入稳定版本`,
|
||
content: `
|
||
<div class="record-detail-banner">
|
||
<div><span>${h(record.rule)} · 质量隔离</span><strong>${h(record.issue)}</strong><small>${h(record.disposition)} · 发现于 ${h(record.detectedAt)}</small></div>
|
||
<strong class="record-detail-score">隔离</strong>
|
||
</div>
|
||
<article class="drawer-card">
|
||
<h3>隔离处置明细</h3>
|
||
<dl>
|
||
<div><dt>隔离编号</dt><dd>${h(record.id)}</dd></div>
|
||
<div><dt>来源记录</dt><dd>${h(record.sourceRecordId)}</dd></div>
|
||
<div><dt>质量问题</dt><dd>${h(record.issue)}</dd></div>
|
||
<div><dt>命中规则</dt><dd>${h(record.rule)}</dd></div>
|
||
<div><dt>当前处置</dt><dd>${h(record.disposition)}</dd></div>
|
||
<div><dt>工作地区</dt><dd>${h(record.region)}</dd></div>
|
||
</dl>
|
||
</article>
|
||
<article class="drawer-card">
|
||
<h3>来源证据</h3>
|
||
<div class="record-lineage">
|
||
<span><b>01</b><span><strong>采集任务</strong><small>${h(record.taskId)}</small></span></span>
|
||
<span><b>02</b><span><strong>原始快照</strong><small>${h(record.snapshotId)}</small></span></span>
|
||
<span><b>03</b><span><strong>质量规则</strong><small>${h(record.rule)} · ${h(record.issue)}</small></span></span>
|
||
<span><b>04</b><span><strong>隔离区</strong><small>不覆盖稳定版本,修复后按原任务参数重放</small></span></span>
|
||
</div>
|
||
</article>
|
||
`,
|
||
footer: `
|
||
<button class="button secondary" data-record-close>关闭</button>
|
||
<button class="button primary" data-record-source="${h(record.sourceId)}">查看数据源资产</button>
|
||
`,
|
||
});
|
||
}
|
||
|
||
function renderContract() {
|
||
const contract = contracts[state.contractKey];
|
||
document.querySelectorAll("#contractTabs button").forEach((button) => {
|
||
button.classList.toggle("active", button.dataset.contract === state.contractKey);
|
||
});
|
||
document.getElementById("contractContent").innerHTML = `
|
||
<div class="contract-hero">
|
||
<article class="panel contract-overview">
|
||
<span>${h(contract.key)} · ${h(contract.version)}</span>
|
||
<h2>${h(contract.title)}</h2>
|
||
<p>${h(contract.description)}</p>
|
||
<div class="contract-meta-grid">
|
||
<span><small>字段总数</small><strong>${contract.fields} 个</strong></span>
|
||
<span><small>必填字段</small><strong>${contract.required} 个</strong></span>
|
||
<span><small>标准责任人</small><strong>${h(contract.owner)}</strong></span>
|
||
</div>
|
||
</article>
|
||
<article class="panel contract-release">
|
||
<span>CONTRACT RELEASE</span>
|
||
<strong>${h(contract.status)}</strong>
|
||
<small>${h(contract.release)} · ${h(contract.compatibility)}</small>
|
||
<div class="release-steps"><span class="done">起草</span><span class="done">评审</span><span class="done">测试</span><span class="${contract.status.includes("已发布") ? "done" : ""}">发布</span></div>
|
||
</article>
|
||
</div>
|
||
<div class="contract-body-grid">
|
||
<article class="panel field-panel">
|
||
<div class="panel-head"><div><span>字段定义</span><h2>核心字段预览</h2></div><a class="text-button" href="${h(contract.schemaPath)}" download>下载 JSON Schema</a></div>
|
||
<div class="table-wrap">
|
||
<table class="field-table">
|
||
<thead><tr><th>字段</th><th>类型</th><th>必填</th><th>业务说明</th><th>约束/代码</th></tr></thead>
|
||
<tbody>${contract.fieldsPreview
|
||
.map(
|
||
([field, type, required, description, rule]) =>
|
||
`<tr><td><code class="field-code">${h(field)}</code></td><td>${h(type)}</td><td class="${required === "是" ? "required-mark" : ""}">${h(required)}</td><td>${h(description)}</td><td>${h(rule)}</td></tr>`,
|
||
)
|
||
.join("")}</tbody>
|
||
</table>
|
||
</div>
|
||
</article>
|
||
<article class="panel contract-rules-panel">
|
||
<div class="panel-head"><div><span>执行规则</span><h2>契约控制点</h2></div><em>${contract.rules.length} 组</em></div>
|
||
<div class="contract-rule-list">${contract.rules.map(([title, detail]) => `<span><strong>${h(title)}</strong>${h(detail)}</span>`).join("")}</div>
|
||
</article>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function renderExchangeLogs() {
|
||
document.getElementById("exchangeLogTable").innerHTML = exchangeLogs
|
||
.map(
|
||
(row) => `
|
||
<tr>
|
||
${row
|
||
.map((cell, index) => `<td>${index === 6 ? `<span class="status-pill ${String(cell).includes("隔离") ? "attention" : ""}">${h(cell)}</span>` : h(cell)}</td>`)
|
||
.join("")}
|
||
</tr>
|
||
`,
|
||
)
|
||
.join("");
|
||
}
|
||
|
||
function renderQuality() {
|
||
document.getElementById("qualityRuleList").innerHTML = qualityRules
|
||
.map(
|
||
([code, title, detail, score, status]) => `
|
||
<span class="rule-item"><b>${h(code)}</b><span><strong>${h(title)}</strong><small>${h(detail)}</small></span><em>${h(score)} · ${h(status)}</em></span>
|
||
`,
|
||
)
|
||
.join("");
|
||
document.getElementById("normalizationList").innerHTML = normalizationItems
|
||
.map(
|
||
([raw, target, confidence], index) => `
|
||
<span class="normalization-item">
|
||
<span><strong>${h(raw)}</strong><small>来源岗位别名</small></span><i>→</i>
|
||
<span><strong>${h(target)}</strong><small>建议映射 · 置信度 ${h(confidence)}</small></span>
|
||
<button data-normalize-index="${index}">确认映射</button>
|
||
</span>
|
||
`,
|
||
)
|
||
.join("");
|
||
if (!normalizationItems.length) {
|
||
document.getElementById("normalizationList").innerHTML = `<div class="info-note">当前高优先级待审项已处理完成,其余低优先级项将在下一批次继续复核。</div>`;
|
||
}
|
||
}
|
||
|
||
function renderAudit() {
|
||
const mergedEvents = [
|
||
...operationsStore.auditEvents.map((event) => ({ ...event, time: operationTime(event.time) })),
|
||
...auditEvents,
|
||
];
|
||
const events = mergedEvents.filter((event) => state.auditFilter === "all" || event.type === state.auditFilter);
|
||
document.getElementById("auditTable").innerHTML = events
|
||
.map(
|
||
(event) => `
|
||
<tr>
|
||
<td><span class="audit-event"><strong>${h(event.time)}</strong><small>${h(event.id)}</small></span></td>
|
||
<td>${h(event.event)}</td>
|
||
<td>${h(event.object)}</td>
|
||
<td>${h(event.actor)}</td>
|
||
<td><span class="status-pill ${event.result.includes("保护") || event.result.includes("待审") ? "attention" : ""}">${h(event.result)}</span></td>
|
||
<td><div class="evidence-tags">${event.evidence.map((item) => `<span>${h(item)}</span>`).join("")}</div></td>
|
||
</tr>
|
||
`,
|
||
)
|
||
.join("");
|
||
}
|
||
|
||
function openInfoModal(kicker, title, content) {
|
||
document.getElementById("infoModalKicker").textContent = kicker;
|
||
document.getElementById("infoModalTitle").textContent = title;
|
||
document.getElementById("infoModalContent").innerHTML = content;
|
||
openModal(document.getElementById("infoModal"));
|
||
}
|
||
|
||
function csvEscape(value) {
|
||
const text = String(value ?? "");
|
||
return /[",\n]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text;
|
||
}
|
||
|
||
function downloadText(filename, content, type = "text/plain;charset=utf-8") {
|
||
const blob = new Blob([content], { type });
|
||
const url = URL.createObjectURL(blob);
|
||
const anchor = document.createElement("a");
|
||
anchor.href = url;
|
||
anchor.download = filename;
|
||
document.body.appendChild(anchor);
|
||
anchor.click();
|
||
anchor.remove();
|
||
setTimeout(() => URL.revokeObjectURL(url), 500);
|
||
}
|
||
|
||
function downloadJson(filename, value) {
|
||
downloadText(filename, JSON.stringify(value, null, 2), "application/json;charset=utf-8");
|
||
}
|
||
|
||
function exportSourceCatalog() {
|
||
const headers = ["source_id", "name", "category", "domain", "adapter", "mode", "schedule", "status", "contract", "owner", "version"];
|
||
const rows = sources.map((source) => [
|
||
source.id,
|
||
source.name,
|
||
source.category,
|
||
source.domain,
|
||
source.adapter,
|
||
source.mode,
|
||
source.schedule,
|
||
source.statusLabel,
|
||
source.contract,
|
||
source.owner,
|
||
source.version,
|
||
]);
|
||
const csv = [headers, ...rows].map((row) => row.map(csvEscape).join(",")).join("\n");
|
||
downloadText("edu-data-source-catalog-20260823.csv", `\uFEFF${csv}`, "text/csv;charset=utf-8");
|
||
showToast(`已导出${sources.length}个数据源及其连接器、契约、责任人与生产版本`, "数据源目录已生成");
|
||
}
|
||
|
||
function candidateManifestInputFingerprint() {
|
||
const release = currentRelease();
|
||
const disposition = latestQualityDisposition();
|
||
return JSON.stringify({
|
||
releaseId: release?.id,
|
||
dataVersion: release?.dataVersion,
|
||
product: document.getElementById("exportProduct").value,
|
||
tenant: document.getElementById("exportTenant").value,
|
||
asOf: document.getElementById("exportAsOf").value,
|
||
format: document.getElementById("exportFormat").value,
|
||
impactTenants: release?.impactTenants,
|
||
deliveries: operationsStore.deliveries.map((delivery) => [delivery.tenantId, delivery.expectedRecords, delivery.expectedSha]),
|
||
qualityDisposition: disposition
|
||
? {
|
||
id: disposition.id,
|
||
action: disposition.action,
|
||
status: disposition.status,
|
||
trace: disposition.trace,
|
||
verification: disposition.verification,
|
||
}
|
||
: null,
|
||
});
|
||
}
|
||
|
||
function buildPackageManifest(options = {}) {
|
||
const release = currentRelease();
|
||
const approval = currentApproval();
|
||
const releaseStatus = options.releaseStatus || release?.status || "DRAFT";
|
||
const approvalStatus = options.approvalStatus || approval?.status || "DRAFT";
|
||
const published = releaseStatus === "PUBLISHED";
|
||
const product = document.getElementById("exportProduct").value;
|
||
const tenantLabel = document.getElementById("exportTenant").value;
|
||
const asOf = document.getElementById("exportAsOf").value;
|
||
const formatLabel = document.getElementById("exportFormat").value;
|
||
const format = {
|
||
"Parquet + Manifest": "parquet",
|
||
"CSV UTF-8 + Manifest": "csv_utf8",
|
||
"JSONL + Manifest": "jsonl",
|
||
}[formatLabel];
|
||
const formatMeta = {
|
||
parquet: { extension: "parquet", mediaType: "application/vnd.apache.parquet", compression: "zstd", bytesPerRecord: 240 },
|
||
csv_utf8: { extension: "csv", mediaType: "text/csv", compression: "none", bytesPerRecord: 410 },
|
||
jsonl: { extension: "jsonl", mediaType: "application/x-ndjson", compression: "none", bytesPerRecord: 620 },
|
||
}[format];
|
||
const tenantDeliveries = release?.impactTenants?.length
|
||
? release.impactTenants.map((tenantId) => operationsStore.deliveries.find((delivery) => delivery.tenantId === tenantId)).filter(Boolean)
|
||
: [];
|
||
const tenantScope = tenantDeliveries.length ? tenantDeliveries.map((delivery) => delivery.tenantId) : [tenantLabel];
|
||
const tenantDataFiles = tenantDeliveries.length
|
||
? tenantDeliveries.map((delivery) => ({
|
||
path: `data/${delivery.tenantId}/job-signal.${formatMeta.extension}`,
|
||
media_type: formatMeta.mediaType,
|
||
bytes: delivery.expectedRecords * formatMeta.bytesPerRecord,
|
||
sha256: delivery.expectedSha,
|
||
record_count: delivery.expectedRecords,
|
||
compression: formatMeta.compression,
|
||
}))
|
||
: [
|
||
{
|
||
path: `data/job-signal.${formatMeta.extension}`,
|
||
media_type: formatMeta.mediaType,
|
||
bytes: 2846320 * formatMeta.bytesPerRecord,
|
||
sha256: "4f8d91a47e2b6d82b2aaad136d344d61c83f9cd472185d2543725c91870e9e32",
|
||
record_count: 2846320,
|
||
compression: formatMeta.compression,
|
||
},
|
||
];
|
||
const recordCount = tenantDataFiles.reduce((total, file) => total + file.record_count, 0);
|
||
return {
|
||
manifest_version: "1.2.0",
|
||
package_id: release?.packageId || `EDU-PKG-${asOf.replaceAll("-", "")}-CANDIDATE`,
|
||
...(release ? { release_id: release.id, release_status: releaseStatus } : {}),
|
||
data_product: product,
|
||
data_version: release?.dataVersion || `V${asOf.replaceAll("-", ".")}`,
|
||
contract_name: "JOB-SIGNAL",
|
||
contract_version: "3.0.0",
|
||
data_as_of: asOf,
|
||
tenant_scope: tenantScope,
|
||
output_format: format,
|
||
record_count: recordCount,
|
||
quality_score: 94.7,
|
||
lineage_snapshot: `LINEAGE-${asOf.replaceAll("-", "")}-09`,
|
||
previous_stable_version: release?.previousStableVersion || "V2026.08.22",
|
||
files: [
|
||
...tenantDataFiles,
|
||
{
|
||
path: "schema/external-job-source.schema.json",
|
||
media_type: "application/schema+json",
|
||
bytes: 18420,
|
||
sha256: "96c2be742dc43f8e7ee526db6358030873406c450f15e67225c605dc39b431a8",
|
||
record_count: null,
|
||
compression: "none",
|
||
},
|
||
{
|
||
path: "quality/quality-report.json",
|
||
media_type: "application/json",
|
||
bytes: 32640,
|
||
sha256: "c2d8f733e6711219823110d7d35fdc5535f133ed60f60950f9dc78a228cb402d",
|
||
record_count: null,
|
||
compression: "none",
|
||
},
|
||
{
|
||
path: "lineage/source-lineage.json",
|
||
media_type: "application/json",
|
||
bytes: 84210,
|
||
sha256: "66e102b99bf056868670ac971011740c6f66f62b86ea43cc66326ed9a0a5807e",
|
||
record_count: null,
|
||
compression: "none",
|
||
},
|
||
],
|
||
quality_report_path: "quality/quality-report.json",
|
||
generated_at: new Date().toISOString(),
|
||
generated_by: "陈致远",
|
||
approval_reference: approval ? `${approval.id}:${approvalStatus}` : null,
|
||
notes: published
|
||
? "该文件是独立审批通过后生成的发布 Manifest;静态原型只记录本地稳定版本状态,不会向学校真实发送。"
|
||
: "该文件仅为候选包 Manifest;只有独立审批通过并执行发布后才成为稳定版本,静态原型不会向学校真实发送。",
|
||
};
|
||
}
|
||
|
||
function generateCandidateManifest() {
|
||
const release = currentRelease();
|
||
updateReleaseQualityGate();
|
||
if (!release || release.status !== "DRAFT") {
|
||
showToast("候选已进入审批或发布流程,Manifest 已冻结;驳回后需返回草稿重建", "无法重建候选 Manifest");
|
||
return;
|
||
}
|
||
if (release.qualityGate !== "PASS") {
|
||
showToast("只有 FIX_REPLAY 的完整验证证据通过后才能生成候选 Manifest", "质量门禁阻断");
|
||
return;
|
||
}
|
||
const inputFingerprint = candidateManifestInputFingerprint();
|
||
if (release.candidateManifest && release.candidateInputFingerprint === inputFingerprint) {
|
||
downloadJson(`${release.candidateManifest.package_id}-candidate-manifest.json`, release.candidateManifest);
|
||
showToast("候选输入与质量证据未变化,已复用原 Manifest;未新增审计或交换记录", "候选 Manifest 已复用");
|
||
return;
|
||
}
|
||
const manifest = buildPackageManifest();
|
||
const metadata = addOperationsAudit({
|
||
type: "publish",
|
||
event: "生成候选包 Manifest",
|
||
object: `${release.id} · ${release.packageId}`,
|
||
result: "候选已生成",
|
||
evidence: ["Manifest", "质量报告", "血缘快照", "SHA-256"],
|
||
});
|
||
release.manifestGeneratedAt = metadata.time;
|
||
release.candidateManifest = manifest;
|
||
release.candidateInputFingerprint = inputFingerprint;
|
||
release.actor = metadata.actor;
|
||
release.time = metadata.time;
|
||
release.trace = metadata.trace;
|
||
release.evidence = [...metadata.evidence];
|
||
saveOperationsStore();
|
||
downloadJson(`${manifest.package_id}-candidate-manifest.json`, manifest);
|
||
exchangeLogs.unshift([manifest.package_id, "候选导出", manifest.data_product, manifest.data_version, formatNumber(manifest.record_count), "陈致远", "候选已生成", "刚刚"]);
|
||
exchangeLogs.splice(OPERATIONS_LIMITS.exchangeLogs);
|
||
renderExchangeLogs();
|
||
renderOperations();
|
||
renderAudit();
|
||
showToast(`${manifest.package_id} 仅生成候选文件,尚未发布或向学校发送`, "候选包 / Manifest 已生成");
|
||
}
|
||
|
||
function exportDailyReport() {
|
||
const report = {
|
||
report_id: "OPS-DAILY-20260823",
|
||
generated_at: new Date().toISOString(),
|
||
environment: "production-east-cn",
|
||
batch: "EDU-JOB-20260823",
|
||
source_count: sources.length,
|
||
running_sources: sources.filter((source) => source.status === "running").length,
|
||
running_jobs: collectionJobs.filter((job) => job.status === "running").length,
|
||
records_collected_today: sources.reduce((sum, source) => sum + source.today, 0),
|
||
quality_score: 94.7,
|
||
normalization_coverage: 96.4,
|
||
stable_data_version: "V2026.08.23",
|
||
tenant_delivery_count: 36,
|
||
attention_items: [
|
||
"27个岗位别名待人工归一",
|
||
"杭州人才网处于自动降速保护",
|
||
"SCHOOL-EMPLOYMENT V2.2待生产发布",
|
||
],
|
||
};
|
||
downloadJson("edu-data-operations-daily-20260823.json", report);
|
||
showToast("运行日报包含生产批次、来源、采集、质量、归一和交付状态", "运行日报已生成");
|
||
}
|
||
|
||
function exportExchangeLog() {
|
||
const headers = ["trace_id", "type", "object", "version", "records", "actor", "result", "time"];
|
||
const csv = [headers, ...exchangeLogs].map((row) => row.map(csvEscape).join(",")).join("\n");
|
||
downloadText("edu-data-exchange-log-20260823.csv", `\uFEFF${csv}`, "text/csv;charset=utf-8");
|
||
showToast("导入与交付记录已按当前筛选范围导出", "交换记录已生成");
|
||
}
|
||
|
||
function exportAuditLog() {
|
||
const headers = ["trace_id", "time", "type", "event", "object", "actor", "result", "evidence"];
|
||
const rows = [...operationsStore.auditEvents, ...auditEvents].map((event) => [
|
||
event.trace || event.id,
|
||
event.time,
|
||
event.type,
|
||
event.event,
|
||
event.object,
|
||
event.actor,
|
||
event.result,
|
||
event.evidence.join("|"),
|
||
]);
|
||
const csv = [headers, ...rows].map((row) => row.map(csvEscape).join(",")).join("\n");
|
||
downloadText("edu-data-audit-log-20260823.csv", `\uFEFF${csv}`, "text/csv;charset=utf-8");
|
||
showToast("审计日志已携带追踪编号、操作者、结果和证据清单", "审计日志已生成");
|
||
}
|
||
|
||
function setupNavigation() {
|
||
document.querySelectorAll(".nav-item[data-view]").forEach((button) => button.addEventListener("click", () => switchView(button.dataset.view)));
|
||
document.addEventListener("click", (event) => {
|
||
const jump = event.target.closest("[data-view-jump]");
|
||
if (jump) switchView(jump.dataset.viewJump);
|
||
const issueButton = event.target.closest("[data-open-issue]");
|
||
if (issueButton) {
|
||
const issueId = issueButton.dataset.openIssue;
|
||
const modal = issueButton.closest(".modal-backdrop");
|
||
if (modal) closeModal(modal);
|
||
state.operationsTab = "issues";
|
||
renderOperations();
|
||
switchView("operations");
|
||
openIssueDrawer(issueId);
|
||
}
|
||
const operationsJump = event.target.closest("[data-operations-jump]");
|
||
if (operationsJump) {
|
||
const operationsTab = operationsJump.dataset.operationsJump;
|
||
const modal = operationsJump.closest(".modal-backdrop");
|
||
if (modal) closeModal(modal);
|
||
state.operationsTab = operationsTab;
|
||
renderOperations();
|
||
switchView("operations");
|
||
}
|
||
const sourceButton = event.target.closest("[data-source-detail]");
|
||
if (sourceButton) openSourceDrawer(sourceButton.dataset.sourceDetail);
|
||
const sourceRecordsButton = event.target.closest("[data-source-records]");
|
||
if (sourceRecordsButton) {
|
||
if (document.getElementById("sourceDrawer").classList.contains("open")) closeSourceDrawer();
|
||
openRecordExplorer(sourceRecordsButton.dataset.sourceRecords);
|
||
}
|
||
const sourceModalButton = event.target.closest("[data-open-source-modal]");
|
||
if (sourceModalButton) openModal(document.getElementById("sourceModal"));
|
||
});
|
||
document.getElementById("mobileMenu").addEventListener("click", () => document.body.classList.add("sidebar-open"));
|
||
document.getElementById("sidebarScrim").addEventListener("click", () => document.body.classList.remove("sidebar-open"));
|
||
document.getElementById("globalSearch").addEventListener("keydown", (event) => {
|
||
if (event.key !== "Enter") return;
|
||
state.sourceSearch = event.currentTarget.value;
|
||
document.getElementById("sourceSearch").value = state.sourceSearch;
|
||
switchView("sources");
|
||
renderSources();
|
||
});
|
||
}
|
||
|
||
function setupSourceRegistry() {
|
||
document.getElementById("sourceFilters").addEventListener("click", (event) => {
|
||
const button = event.target.closest("[data-source-filter]");
|
||
if (!button) return;
|
||
state.sourceFilter = button.dataset.sourceFilter;
|
||
document.querySelectorAll("[data-source-filter]").forEach((item) => item.classList.toggle("active", item === button));
|
||
renderSources();
|
||
});
|
||
document.getElementById("sourceSearch").addEventListener("input", (event) => {
|
||
state.sourceSearch = event.target.value;
|
||
renderSources();
|
||
});
|
||
document.getElementById("sourceCatalogExport").addEventListener("click", exportSourceCatalog);
|
||
document.getElementById("sourceForm").addEventListener("submit", (event) => {
|
||
event.preventDefault();
|
||
const name = document.getElementById("newSourceName").value.trim();
|
||
const domainInput = document.getElementById("newSourceDomain").value.trim();
|
||
let domain;
|
||
try {
|
||
const url = new URL(domainInput);
|
||
if (!["http:", "https:", "jdbc:", "sftp:"].includes(url.protocol)) throw new Error("unsupported");
|
||
domain = url.host || domainInput;
|
||
} catch {
|
||
showToast("请输入有效的 HTTP、HTTPS、JDBC 或 SFTP 数据源地址", "数据源地址无效");
|
||
return;
|
||
}
|
||
if (sources.some((source) => source.domain === domain)) {
|
||
showToast("该入口已登记,请进入现有数据源创建新版本或采集任务", "数据源已存在");
|
||
return;
|
||
}
|
||
const category = document.getElementById("newSourceCategory").value;
|
||
const categoryLabel = { commercial: "商业平台", public: "公共平台", school: "校内系统" }[category];
|
||
const source = {
|
||
id: `source-${Date.now()}`,
|
||
name,
|
||
short: name.slice(0, 2),
|
||
category,
|
||
categoryLabel,
|
||
domain,
|
||
adapter: `Custom-Collector 1.0`,
|
||
mode: document.getElementById("newSourceMode").value,
|
||
schedule: document.getElementById("newSourceSchedule").value,
|
||
status: "running",
|
||
statusLabel: "连接测试",
|
||
health: 100,
|
||
today: 0,
|
||
total: "0",
|
||
updated: "刚刚",
|
||
coverage: document.getElementById("newSourceScope").value.trim(),
|
||
contract: document.getElementById("newSourceContract").value,
|
||
schema: "CUSTOM-SOURCE 1.0",
|
||
version: `SRC-CUSTOM-${new Date().toISOString().slice(0, 10).replaceAll("-", "")}-01`,
|
||
owner: document.getElementById("newSourceOwner").value.trim(),
|
||
retention: "原始数据 180 天 · 解析结果 36 个月",
|
||
rate: "初次样本验证 · 1 分片",
|
||
retry: "指数退避 · 最多 3 次",
|
||
change: "新数据源已注册,等待小样本连接测试和字段映射确认。",
|
||
repo: "connector-registry/custom-collector",
|
||
codeVersion: "draft/1.0.0 · pending",
|
||
runtime: [["刚刚", "数据源完成注册", "连接测试任务已创建"]],
|
||
};
|
||
sources.unshift(source);
|
||
populateJobSources();
|
||
populateRecordSources();
|
||
collectionJobs.unshift({
|
||
id: `JOB-${Date.now()}`,
|
||
name: `${name}连接与样本预检`,
|
||
sourceId: source.id,
|
||
scope: source.coverage,
|
||
strategy: "样本验证 · 1分片",
|
||
progress: 0,
|
||
records: "—",
|
||
valid: "—",
|
||
status: "scheduled",
|
||
statusLabel: "等待调度",
|
||
});
|
||
event.currentTarget.reset();
|
||
closeModal(document.getElementById("sourceModal"));
|
||
renderSources();
|
||
renderOverviewSources();
|
||
renderCollectionJobs();
|
||
switchView("sources");
|
||
showToast(`${name}已进入数据源注册中心,连接测试任务已创建`, "数据源注册成功");
|
||
});
|
||
}
|
||
|
||
function setupSourceDrawer() {
|
||
document.getElementById("closeSourceDrawer").addEventListener("click", closeSourceDrawer);
|
||
document.getElementById("sourceDrawer").addEventListener("click", (event) => {
|
||
if (event.target === event.currentTarget) closeSourceDrawer();
|
||
});
|
||
document.getElementById("sourceDrawerTabs").addEventListener("click", (event) => {
|
||
const button = event.target.closest("[data-source-tab]");
|
||
if (!button) return;
|
||
state.sourceTab = button.dataset.sourceTab;
|
||
updateSourceDrawer();
|
||
});
|
||
document.getElementById("sourceDrawerFooter").addEventListener("click", (event) => {
|
||
if (event.target.closest("[data-drawer-close]")) {
|
||
closeSourceDrawer();
|
||
return;
|
||
}
|
||
const test = event.target.closest("[data-source-test]");
|
||
if (test) {
|
||
const source = sourceById(test.dataset.sourceTest);
|
||
test.disabled = true;
|
||
test.textContent = "正在测试…";
|
||
setTimeout(() => {
|
||
test.disabled = false;
|
||
test.textContent = "测试连接";
|
||
showToast(`${source.name}入口可达,样本解析和契约映射验证通过`, "连接测试通过");
|
||
}, 900);
|
||
return;
|
||
}
|
||
const jobButton = event.target.closest("[data-source-job]");
|
||
if (jobButton) {
|
||
const source = sourceById(jobButton.dataset.sourceJob);
|
||
closeSourceDrawer();
|
||
document.getElementById("jobSource").value = source.id;
|
||
document.getElementById("jobName").value = `${source.name}增量采集`;
|
||
openModal(document.getElementById("jobModal"));
|
||
}
|
||
});
|
||
}
|
||
|
||
function setupCollectionJobs() {
|
||
document.getElementById("jobFilters").addEventListener("click", (event) => {
|
||
const button = event.target.closest("[data-job-filter]");
|
||
if (!button) return;
|
||
state.jobFilter = button.dataset.jobFilter;
|
||
document.querySelectorAll("[data-job-filter]").forEach((item) => item.classList.toggle("active", item === button));
|
||
renderCollectionJobs();
|
||
});
|
||
document.getElementById("collectionJobTable").addEventListener("click", (event) => {
|
||
const toggle = event.target.closest("[data-job-toggle]");
|
||
if (toggle) {
|
||
const job = collectionJobs.find((item) => item.id === toggle.dataset.jobToggle);
|
||
if (!job) return;
|
||
if (job.status === "running") {
|
||
job.status = "scheduled";
|
||
job.statusLabel = "已暂停";
|
||
} else {
|
||
job.status = "running";
|
||
job.statusLabel = "运行中";
|
||
if (!job.progress) job.progress = 4;
|
||
}
|
||
renderCollectionJobs();
|
||
showToast(`${job.name}当前状态:${job.statusLabel}`, "任务状态已更新");
|
||
return;
|
||
}
|
||
const detail = event.target.closest("[data-job-detail]");
|
||
if (detail) {
|
||
const job = collectionJobs.find((item) => item.id === detail.dataset.jobDetail);
|
||
const source = sourceById(job.sourceId);
|
||
openInfoModal(
|
||
"COLLECTION JOB DETAIL",
|
||
job.name,
|
||
`<div class="info-grid"><span>追踪编号<strong>${h(job.id)}</strong></span><span>数据来源<strong>${h(source.name)}</strong></span><span>任务范围<strong>${h(job.scope)}</strong></span><span>运行策略<strong>${h(job.strategy)}</strong></span><span>当前进度<strong>${job.progress}%</strong></span><span>目标契约<strong>${h(source.contract)}</strong></span></div><div class="info-note">任务绑定连接器 ${h(source.codeVersion)}、源版本 ${h(source.version)} 和原始快照。失败分片进入隔离队列,可按原任务参数重放。</div>`,
|
||
);
|
||
return;
|
||
}
|
||
const records = event.target.closest("[data-job-records]");
|
||
if (records) {
|
||
const job = collectionJobs.find((item) => item.id === records.dataset.jobRecords);
|
||
if (!job) return;
|
||
openRecordExplorer(job.sourceId, job.id, "source");
|
||
}
|
||
});
|
||
document.getElementById("newCollectionJob").addEventListener("click", () => openModal(document.getElementById("jobModal")));
|
||
document.getElementById("jobForm").addEventListener("submit", (event) => {
|
||
event.preventDefault();
|
||
const sourceId = document.getElementById("jobSource").value;
|
||
const job = {
|
||
id: `JOB-${Date.now()}`,
|
||
name: document.getElementById("jobName").value.trim(),
|
||
sourceId,
|
||
scope: `${document.getElementById("jobRegion").value.trim()} · ${document.getElementById("jobScope").value.trim()}`,
|
||
strategy: `${document.getElementById("jobMode").value} · 预检分片`,
|
||
progress: document.getElementById("jobSchedule").value === "立即执行" ? 4 : 0,
|
||
records: "—",
|
||
valid: "—",
|
||
status: document.getElementById("jobSchedule").value === "立即执行" ? "running" : "scheduled",
|
||
statusLabel: document.getElementById("jobSchedule").value === "立即执行" ? "预检中" : "等待调度",
|
||
};
|
||
collectionJobs.unshift(job);
|
||
closeModal(document.getElementById("jobModal"));
|
||
renderCollectionJobs();
|
||
switchView("collectors");
|
||
showToast(`${job.name}已创建,任务先执行小样本预检`, "采集任务已进入队列");
|
||
});
|
||
document.getElementById("pauseAllJobs").addEventListener("click", (event) => {
|
||
const resume = event.currentTarget.dataset.mode === "resume";
|
||
collectionJobs.forEach((job) => {
|
||
if (resume && job.status === "scheduled") {
|
||
job.status = "running";
|
||
job.statusLabel = "运行中";
|
||
} else if (!resume && job.status === "running") {
|
||
job.status = "scheduled";
|
||
job.statusLabel = "已暂停";
|
||
}
|
||
});
|
||
event.currentTarget.dataset.mode = resume ? "" : "resume";
|
||
event.currentTarget.textContent = resume ? "暂停全部" : "恢复全部";
|
||
renderCollectionJobs();
|
||
showToast(resume ? "已恢复所有可运行采集任务" : "运行中任务已安全暂停并保存水位", "调度器状态已更新");
|
||
});
|
||
}
|
||
|
||
function exportCurrentRecords() {
|
||
const results = currentRecordResults();
|
||
if (!results.length) {
|
||
showToast("当前查询条件下没有可导出的记录", "没有查询结果");
|
||
return;
|
||
}
|
||
if (state.recordLayer === "raw") {
|
||
downloadJson("raw-snapshot-query-20260823.json", {
|
||
query: { source_id: state.recordSource, keyword: state.recordSearch },
|
||
stable_version: "V2026.08.23",
|
||
records: results,
|
||
});
|
||
} else if (state.recordLayer === "quarantine") {
|
||
const headers = ["quarantine_id", "source_id", "source_record_id", "job_title", "issue", "rule", "detected_at", "disposition", "task_id", "snapshot_id"];
|
||
const rows = results.map((record) => [record.id, record.sourceId, record.sourceRecordId, record.jobTitle, record.issue, record.rule, record.detectedAt, record.disposition, record.taskId, record.snapshotId]);
|
||
const csv = [headers, ...rows].map((row) => row.map(csvEscape).join(",")).join("\n");
|
||
downloadText("quarantine-record-query-20260823.csv", `\uFEFF${csv}`, "text/csv;charset=utf-8");
|
||
} else {
|
||
const headers =
|
||
state.recordLayer === "source"
|
||
? ["record_id", "source_id", "source_record_id", "source_url", "task_id", "snapshot_id", "job_title_raw", "enterprise_name", "collected_at", "collector_version", "parser_version"]
|
||
: ["record_id", "source_id", "source_record_id", "job_title_raw", "job_title_normalized", "job_code", "enterprise_name", "region_code", "workplace", "salary_min", "salary_max", "published_at", "collected_at", "quality_score", "content_hash", "data_version"];
|
||
const rows = results.map((record) =>
|
||
state.recordLayer === "source"
|
||
? [record.id, record.sourceId, record.sourceRecordId, record.sourceUrl, record.taskId, record.snapshotId, record.jobTitleRaw, record.enterprise, record.collectedAt, record.collectorVersion, record.parserVersion]
|
||
: [record.id, record.sourceId, record.sourceRecordId, record.jobTitleRaw, record.jobTitleNormalized, record.jobCode, record.enterprise, record.regionCode, record.workplace, record.salaryMin, record.salaryMax, record.publishedAt, record.collectedAt, record.quality, record.contentHash, record.dataVersion],
|
||
);
|
||
const csv = [headers, ...rows].map((row) => row.map(csvEscape).join(",")).join("\n");
|
||
downloadText(`${state.recordLayer}-job-record-query-20260823.csv`, `\uFEFF${csv}`, "text/csv;charset=utf-8");
|
||
}
|
||
showToast(`已按当前条件导出 ${results.length} 条${{ standard: "标准岗位", source: "源端记录", raw: "原始快照", quarantine: "隔离记录" }[state.recordLayer]}`, "查询结果已生成");
|
||
}
|
||
|
||
function setupRecords() {
|
||
document.getElementById("recordLayers").addEventListener("click", (event) => {
|
||
const button = event.target.closest("[data-record-layer]");
|
||
if (!button) return;
|
||
state.recordLayer = button.dataset.recordLayer;
|
||
renderRecords();
|
||
});
|
||
document.getElementById("recordSourceFilter").addEventListener("change", (event) => {
|
||
state.recordSource = event.target.value;
|
||
renderRecords();
|
||
});
|
||
document.getElementById("recordRegionFilter").addEventListener("change", (event) => {
|
||
state.recordRegion = event.target.value;
|
||
renderRecords();
|
||
});
|
||
document.getElementById("recordSearch").addEventListener("input", (event) => {
|
||
state.recordSearch = event.target.value;
|
||
renderRecords();
|
||
});
|
||
document.getElementById("recordTableContainer").addEventListener("click", (event) => {
|
||
const record = event.target.closest("[data-record-id]");
|
||
if (record) {
|
||
openStandardRecord(record.dataset.recordId);
|
||
return;
|
||
}
|
||
const snapshot = event.target.closest("[data-snapshot-id]");
|
||
if (snapshot) {
|
||
openRawSnapshot(snapshot.dataset.snapshotId);
|
||
return;
|
||
}
|
||
const quarantine = event.target.closest("[data-quarantine-id]");
|
||
if (quarantine) openQuarantineRecord(quarantine.dataset.quarantineId);
|
||
});
|
||
document.getElementById("recordRefresh").addEventListener("click", () => {
|
||
renderRecords();
|
||
showToast("查询已重新执行,当前绑定稳定数据版本 V2026.08.23", "采集数据已刷新");
|
||
});
|
||
document.getElementById("recordExport").addEventListener("click", exportCurrentRecords);
|
||
document.getElementById("closeRecordDrawer").addEventListener("click", closeRecordDrawer);
|
||
document.getElementById("recordDrawer").addEventListener("click", (event) => {
|
||
if (event.target === event.currentTarget) closeRecordDrawer();
|
||
});
|
||
document.getElementById("recordDrawerFooter").addEventListener("click", (event) => {
|
||
if (event.target.closest("[data-record-close]")) {
|
||
closeRecordDrawer();
|
||
return;
|
||
}
|
||
const source = event.target.closest("[data-record-source]");
|
||
if (source) {
|
||
closeRecordDrawer();
|
||
openSourceDrawer(source.dataset.recordSource);
|
||
return;
|
||
}
|
||
const raw = event.target.closest("[data-record-raw]");
|
||
if (raw) {
|
||
state.recordSource = raw.dataset.recordSourceId;
|
||
state.recordLayer = "raw";
|
||
state.recordSearch = raw.dataset.recordRaw;
|
||
closeRecordDrawer();
|
||
populateRecordSources();
|
||
renderRecords();
|
||
showToast(`${raw.dataset.recordRaw} 已在原始快照层定位`, "已切换数据层");
|
||
}
|
||
});
|
||
}
|
||
|
||
function setupContracts() {
|
||
document.getElementById("contractTabs").addEventListener("click", (event) => {
|
||
const button = event.target.closest("[data-contract]");
|
||
if (!button) return;
|
||
state.contractKey = button.dataset.contract;
|
||
renderContract();
|
||
});
|
||
document.getElementById("downloadStandardKit").addEventListener("click", () => {
|
||
const kit = {
|
||
product: "产教数据中台标准包",
|
||
version: "2026.08",
|
||
generated_at: new Date().toISOString(),
|
||
contracts: Object.values(contracts).map((contract) => ({
|
||
name: contract.key,
|
||
version: contract.version,
|
||
status: contract.status,
|
||
schema: contract.schemaPath,
|
||
})),
|
||
dictionaries: ["GB/T 2260 地区代码", "教育部职业教育专业目录", "岗位分类词典 V4.3", "技能词典 V3.8"],
|
||
delivery_requirements: ["JSON Schema", "数据样例", "字段说明", "质量门槛", "变更日志", "Manifest"],
|
||
};
|
||
downloadJson("edu-industry-data-standard-kit-2026.08.json", kit);
|
||
showToast("标准包索引已生成,可继续下载每个契约的 JSON Schema 与样例文件", "数据标准包已下载");
|
||
});
|
||
document.getElementById("compareContractVersions").addEventListener("click", () => {
|
||
const contract = contracts[state.contractKey];
|
||
openInfoModal(
|
||
"CONTRACT VERSION DIFF",
|
||
`${contract.key} 版本变更`,
|
||
`<div class="info-grid"><span>当前版本<strong>${h(contract.version)}</strong></span><span>兼容策略<strong>${h(contract.compatibility)}</strong></span><span>发布时间<strong>${h(contract.release)}</strong></span><span>影响来源<strong>${state.contractKey === "external-job" ? "7个外部来源" : "36所学校租户"}</strong></span></div><div class="info-note">正式发布前执行 Schema 兼容性、历史样本回放、字段映射和下游影响测试。破坏性变更必须提升主版本并提供双写迁移窗口。</div>`,
|
||
);
|
||
});
|
||
}
|
||
|
||
function setupExchange() {
|
||
const input = document.getElementById("importFile");
|
||
const zone = document.getElementById("uploadZone");
|
||
const preview = document.getElementById("importPreview");
|
||
const validateButton = document.getElementById("validateImport");
|
||
const handleFile = (file) => {
|
||
if (!file) return;
|
||
input._selectedFile = file;
|
||
preview.querySelector("small").textContent = `${(file.size / 1024).toFixed(1)} KB · ${file.type || "application/octet-stream"}`;
|
||
preview.querySelector("strong").textContent = file.name;
|
||
validateButton.disabled = false;
|
||
};
|
||
input.addEventListener("change", () => handleFile(input.files[0]));
|
||
["dragenter", "dragover"].forEach((type) =>
|
||
zone.addEventListener(type, (event) => {
|
||
event.preventDefault();
|
||
zone.classList.add("dragging");
|
||
}),
|
||
);
|
||
["dragleave", "drop"].forEach((type) =>
|
||
zone.addEventListener(type, (event) => {
|
||
event.preventDefault();
|
||
zone.classList.remove("dragging");
|
||
if (type === "drop") handleFile(event.dataTransfer.files[0]);
|
||
}),
|
||
);
|
||
validateButton.addEventListener("click", async () => {
|
||
const file = input._selectedFile || input.files[0];
|
||
if (!file) return;
|
||
validateButton.disabled = true;
|
||
validateButton.textContent = "正在预检…";
|
||
let result = { passed: true, message: "文件结构可读取,已进入服务端分片与质量干跑" };
|
||
if (file.name.toLowerCase().endsWith(".csv")) {
|
||
const text = await file.text();
|
||
const [headerLine, ...lines] = text.split(/\r?\n/).filter(Boolean);
|
||
const headers = headerLine.split(",").map((item) => item.trim().replace(/^"|"$/g, ""));
|
||
const contractKey = document.getElementById("importContract").value;
|
||
const required =
|
||
contractKey === "external-job"
|
||
? ["record_id", "source_platform", "source_record_id", "source_url", "collected_at", "job_title_raw", "enterprise_name", "region_code", "content_hash"]
|
||
: ["tenant_id", "student_token", "cohort", "program_code", "employment_status", "confirmed_at", "source_system", "updated_at"];
|
||
const missing = required.filter((field) => !headers.includes(field));
|
||
result = {
|
||
passed: missing.length === 0,
|
||
message: missing.length ? `缺少必填字段:${missing.join("、")}` : `识别 ${headers.length} 个字段、${lines.length} 条样本记录,核心必填字段完整`,
|
||
};
|
||
}
|
||
preview.querySelector("small").textContent = result.passed ? "结构预检通过" : "结构预检未通过";
|
||
preview.querySelector("strong").textContent = result.message;
|
||
validateButton.disabled = false;
|
||
validateButton.textContent = result.passed ? "重新预检" : "修复后重试";
|
||
if (result.passed) {
|
||
exchangeLogs.unshift([`IMP-${Date.now()}`, "导入", file.name, "暂存版本", "样本预检", "陈致远", "预检通过", "刚刚"]);
|
||
renderExchangeLogs();
|
||
}
|
||
showToast(result.message, result.passed ? "导入预检通过" : "导入预检失败");
|
||
});
|
||
document.getElementById("buildExportPackage").addEventListener("click", () => {
|
||
document.querySelector(".export-panel").scrollIntoView({ behavior: "smooth", block: "center" });
|
||
});
|
||
document.getElementById("generatePackage").addEventListener("click", () => {
|
||
generateCandidateManifest();
|
||
});
|
||
document.getElementById("exportExchangeLog").addEventListener("click", exportExchangeLog);
|
||
}
|
||
|
||
function setupQuality() {
|
||
document.getElementById("normalizationList").addEventListener("click", (event) => {
|
||
const button = event.target.closest("[data-normalize-index]");
|
||
if (!button) return;
|
||
const index = Number(button.dataset.normalizeIndex);
|
||
const item = normalizationItems[index];
|
||
normalizationItems.splice(index, 1);
|
||
renderQuality();
|
||
showToast(`“${item[0]}”已映射到“${item[1]}”,规则将在下一批次生效`, "岗位归一已确认");
|
||
});
|
||
document.getElementById("normalizationBatch").addEventListener("click", () => {
|
||
const count = normalizationItems.length;
|
||
normalizationItems = [];
|
||
renderQuality();
|
||
showToast(`已确认 ${count} 个高置信度岗位映射,变更记录已写入审计`, "批量归一完成");
|
||
});
|
||
document.getElementById("qualityRuleButton").addEventListener("click", () => {
|
||
openInfoModal(
|
||
"QUALITY POLICY",
|
||
"生产质量规则策略",
|
||
`<div class="info-grid"><span>阻断规则<strong>12条</strong></span><span>告警规则<strong>20条</strong></span><span>综合发布阈值<strong>≥ 90</strong></span><span>规则版本<strong>QUALITY 3.6</strong></span></div><div class="info-note">阻断规则失败时任务进入隔离区,不更新稳定版本;告警规则允许带限制发布,但必须在质量报告中披露样本、影响范围和处置计划。</div>`,
|
||
);
|
||
});
|
||
}
|
||
|
||
function setupAudit() {
|
||
document.getElementById("auditFilters").addEventListener("click", (event) => {
|
||
const button = event.target.closest("[data-audit-filter]");
|
||
if (!button) return;
|
||
state.auditFilter = button.dataset.auditFilter;
|
||
document.querySelectorAll("[data-audit-filter]").forEach((item) => item.classList.toggle("active", item === button));
|
||
renderAudit();
|
||
});
|
||
document.getElementById("exportAuditLog").addEventListener("click", exportAuditLog);
|
||
document.getElementById("auditPolicyButton").addEventListener("click", () => {
|
||
openInfoModal(
|
||
"AUDIT POLICY",
|
||
"生产审计与留存策略",
|
||
`<div class="info-grid"><span>关键事件留存<strong>36个月</strong></span><span>审计存储<strong>WORM</strong></span><span>每日完整性校验<strong>SHA-256</strong></span><span>追踪覆盖率<strong>100%</strong></span></div><div class="info-note">采集请求、连接器版本、原始快照、映射规则、质量结果、审批和租户交付都写入统一 trace_id。敏感配置只记录密钥引用和变更摘要,不在日志中保存明文凭据。</div>`,
|
||
);
|
||
});
|
||
}
|
||
|
||
function setupOperations() {
|
||
document.getElementById("operationsTabs").addEventListener("click", (event) => {
|
||
const button = event.target.closest("[data-operations-tab]");
|
||
if (!button) return;
|
||
state.operationsTab = button.dataset.operationsTab;
|
||
renderOperations();
|
||
});
|
||
document.getElementById("operationsPrimaryAction").addEventListener("click", () => {
|
||
const issue = operationsStore.issues.find((item) => !["RESOLVED", "CLOSED"].includes(item.status));
|
||
if (issue) openIssueDrawer(issue.id);
|
||
else {
|
||
state.operationsTab = "releases";
|
||
renderOperations();
|
||
}
|
||
});
|
||
document.getElementById("resetOperationsSeed").addEventListener("click", () => {
|
||
operationsStore = operationsSeed();
|
||
saveOperationsStore();
|
||
closeIssueDrawer();
|
||
renderOperations();
|
||
renderAudit();
|
||
showToast("问题、工单、质量、审批、发布、交付与审计已恢复到可重复演示种子", "运营处置数据已重置");
|
||
});
|
||
document.getElementById("closeIssueDrawer").addEventListener("click", closeIssueDrawer);
|
||
document.getElementById("issueDrawer").addEventListener("click", (event) => {
|
||
if (event.target === event.currentTarget) closeIssueDrawer();
|
||
});
|
||
document.getElementById("issueDrawer").addEventListener("click", (event) => {
|
||
if (event.target.closest("[data-issue-close]")) {
|
||
closeIssueDrawer();
|
||
return;
|
||
}
|
||
const button = event.target.closest("[data-issue-action]");
|
||
if (!button) return;
|
||
const issue = operationsStore.issues.find((item) => item.id === state.issueId);
|
||
if (!issue) return;
|
||
const action = button.dataset.issueAction;
|
||
if (action === "acknowledge" && issue.status === "ALERTED") {
|
||
transitionIssue(issue, "ACKNOWLEDGED", "运营人员已确认告警、影响与SLA。", ["告警确认记录"]);
|
||
return;
|
||
}
|
||
if (action === "assign" && issue.status === "ACKNOWLEDGED") {
|
||
const assignee = document.getElementById("issueAssignee")?.value;
|
||
if (!assignee) return;
|
||
issue.owner = assignee;
|
||
transitionIssue(issue, "ASSIGNED", `已指派给 ${assignee}。`, ["责任人指派记录"]);
|
||
return;
|
||
}
|
||
if (action === "investigate" && issue.status === "ASSIGNED") {
|
||
transitionIssue(issue, "INVESTIGATING", "已锁定源端时延与解析隔离记录,开始执行排障。", ["排障工作区快照", issue.relatedTask]);
|
||
return;
|
||
}
|
||
if (action === "submit-quality" && issue.status === "INVESTIGATING") {
|
||
const dispositionAction = document.getElementById("qualityDispositionAction")?.value;
|
||
const reviewer = document.getElementById("qualityReviewer")?.value;
|
||
const reason = document.getElementById("qualityDispositionReason")?.value.trim();
|
||
if (!reason) {
|
||
showToast("必须填写根因、处置范围与复核依据", "缺少处置原因");
|
||
return;
|
||
}
|
||
if (reviewer === OPERATIONS_ACTOR) {
|
||
showToast("质量复核人必须独立于处置提交人", "复核职责未分离");
|
||
return;
|
||
}
|
||
const evidence =
|
||
dispositionAction === "FIX_REPLAY"
|
||
? ["不可变处置动作", "修复补丁 HZRC-2.7.4", "重放任务 REPLAY-HZRC-20260823-01"]
|
||
: dispositionAction === "WAIVE"
|
||
? ["不可变处置动作", "风险影响评估", "豁免理由"]
|
||
: ["不可变处置动作", "拟丢弃范围", "完整性影响评估"];
|
||
const metadata = addOperationsAudit({
|
||
type: "quality",
|
||
event: `${issue.title}提交质量复核`,
|
||
object: issue.id,
|
||
result: "待复核",
|
||
evidence,
|
||
});
|
||
operationsStore.qualityDispositions.push({
|
||
id: operationToken("QD"),
|
||
status: "PENDING_REVIEW",
|
||
actor: metadata.actor,
|
||
time: metadata.time,
|
||
trace: metadata.trace,
|
||
evidence: [...metadata.evidence],
|
||
issueId: issue.id,
|
||
action: dispositionAction,
|
||
reason,
|
||
reviewer,
|
||
immutable: true,
|
||
verification:
|
||
dispositionAction === "FIX_REPLAY"
|
||
? {
|
||
expectedRecords: 3860,
|
||
replayedRecords: null,
|
||
rulesPassed: false,
|
||
reviewerSigned: false,
|
||
}
|
||
: null,
|
||
});
|
||
migrateOperationsEntity(issue, "QUALITY_REVIEW_PENDING", metadata);
|
||
issue.timeline.push({
|
||
id: operationToken("STEP"),
|
||
status: "QUALITY_REVIEW_PENDING",
|
||
actor: metadata.actor,
|
||
time: metadata.time,
|
||
trace: metadata.trace,
|
||
evidence: [...metadata.evidence],
|
||
note: `${{ FIX_REPLAY: "修复并重放", WAIVE: "豁免", DISCARD: "丢弃" }[dispositionAction]}已提交 ${reviewer} 复核。`,
|
||
});
|
||
const workOrder = operationsStore.workOrders.find((item) => item.issueId === issue.id);
|
||
if (workOrder) migrateOperationsEntity(workOrder, "QUALITY_REVIEW_PENDING", metadata);
|
||
saveOperationsStore();
|
||
renderOperations();
|
||
renderAudit();
|
||
openIssueDrawer(issue.id);
|
||
showToast("处置动作已固化并写入审计,等待独立质量复核", "质量处置已提交");
|
||
return;
|
||
}
|
||
if (action === "verify-blocked" && issue.status === "QUALITY_REVIEW_PENDING") {
|
||
const disposition = latestQualityDisposition(issue.id);
|
||
if (!disposition || disposition.action === "FIX_REPLAY") return;
|
||
const actionLabel = disposition.action === "WAIVE" ? "风险豁免" : "丢弃异常数据";
|
||
const metadata = addOperationsAudit({
|
||
type: "quality",
|
||
event: `${issue.title}${actionLabel}复核完成`,
|
||
object: disposition.id,
|
||
actor: disposition.reviewer,
|
||
result: "REVIEWED_BLOCKED",
|
||
evidence: disposition.action === "WAIVE" ? ["风险影响评估", "独立复核签名", "需另行风险审批"] : ["丢弃范围清单", "完整性影响评估", "需重建候选并复核"],
|
||
});
|
||
migrateOperationsEntity(disposition, "REVIEWED_BLOCKED", metadata);
|
||
migrateOperationsEntity(issue, "INVESTIGATING", metadata);
|
||
issue.timeline.push({
|
||
id: operationToken("STEP"),
|
||
status: "REVIEWED_BLOCKED",
|
||
actor: metadata.actor,
|
||
time: metadata.time,
|
||
trace: metadata.trace,
|
||
evidence: [...metadata.evidence],
|
||
note:
|
||
disposition.action === "WAIVE"
|
||
? "风险豁免不等同于质量验证通过;候选门禁保持阻断,必须另行提交风险审批。"
|
||
: "丢弃可能改变数据完整性;候选门禁保持阻断,必须重建候选并重新执行质量验证。",
|
||
});
|
||
const workOrder = operationsStore.workOrders.find((item) => item.issueId === issue.id);
|
||
if (workOrder) migrateOperationsEntity(workOrder, "INVESTIGATING", metadata);
|
||
updateReleaseQualityGate();
|
||
saveOperationsStore();
|
||
renderOperations();
|
||
renderAudit();
|
||
openIssueDrawer(issue.id);
|
||
showToast(`${actionLabel}已复核,但不能解除发布门禁`, "质量门禁仍阻断");
|
||
return;
|
||
}
|
||
if (action === "verify" && issue.status === "QUALITY_REVIEW_PENDING") {
|
||
const disposition = latestQualityDisposition(issue.id);
|
||
if (!disposition || disposition.action !== "FIX_REPLAY") {
|
||
showToast("只有修复并重放可以进入质量验证通过状态", "质量门禁仍阻断");
|
||
return;
|
||
}
|
||
const metadata = addOperationsAudit({
|
||
type: "quality",
|
||
event: `${issue.title}质量复核通过`,
|
||
object: disposition.id,
|
||
actor: disposition.reviewer,
|
||
result: "VERIFIED",
|
||
evidence: ["重放结果 3,860/3,860", "质量规则全量通过", "复核签名"],
|
||
});
|
||
disposition.verification = {
|
||
expectedRecords: 3860,
|
||
replayedRecords: 3860,
|
||
rulesPassed: true,
|
||
reviewerSigned: true,
|
||
};
|
||
migrateOperationsEntity(disposition, "VERIFIED", metadata);
|
||
migrateOperationsEntity(issue, "VERIFIED", metadata);
|
||
issue.timeline.push({
|
||
id: operationToken("STEP"),
|
||
status: "VERIFIED",
|
||
actor: metadata.actor,
|
||
time: metadata.time,
|
||
trace: metadata.trace,
|
||
evidence: [...metadata.evidence],
|
||
note: "记录数、质量规则与重放证据已复核,候选版本质量门禁解除。",
|
||
});
|
||
const workOrder = operationsStore.workOrders.find((item) => item.issueId === issue.id);
|
||
if (workOrder) migrateOperationsEntity(workOrder, "VERIFIED", metadata);
|
||
updateReleaseQualityGate();
|
||
saveOperationsStore();
|
||
renderOperations();
|
||
renderAudit();
|
||
openIssueDrawer(issue.id);
|
||
showToast("质量处置已 VERIFIED,候选版本现在可提交审批", "质量门禁已通过");
|
||
return;
|
||
}
|
||
if (action === "resolve" && (issue.status === "VERIFIED" || (issue.status === "INVESTIGATING" && !issue.qualityRequired))) {
|
||
transitionIssue(issue, "RESOLVED", "影响已消除,稳定版本与后续任务验证正常。", ["恢复指标", "验证报告"]);
|
||
return;
|
||
}
|
||
if (action === "close" && issue.status === "RESOLVED") {
|
||
transitionIssue(issue, "CLOSED", "问题复盘完成,工单正式关闭。", ["关闭检查单", "复盘摘要"]);
|
||
}
|
||
});
|
||
document.getElementById("releaseWorkspace").addEventListener("click", (event) => {
|
||
const release = currentRelease();
|
||
const approval = currentApproval();
|
||
if (event.target.closest("[data-release-manifest]")) {
|
||
generateCandidateManifest();
|
||
return;
|
||
}
|
||
if (event.target.closest("[data-release-official-manifest]")) {
|
||
if (!release.publishedManifest) {
|
||
showToast("尚未形成发布 Manifest", "下载不可用");
|
||
return;
|
||
}
|
||
downloadJson(`${release.packageId}-published-manifest.json`, release.publishedManifest);
|
||
showToast(`${release.packageId} 发布 Manifest 已下载,审批引用为 ${release.publishedManifest.approval_reference}`, "发布 Manifest");
|
||
return;
|
||
}
|
||
if (event.target.closest("[data-release-submit]")) {
|
||
updateReleaseQualityGate();
|
||
if (!releaseReadyForApproval(release)) {
|
||
showToast(
|
||
release.qualityGate === "PASS" ? "质量门禁已通过,但必须先生成并冻结候选 Manifest" : "只有验证通过的 FIX_REPLAY 才能解除候选版本门禁",
|
||
"候选尚不可提交",
|
||
);
|
||
return;
|
||
}
|
||
const metadata = addOperationsAudit({
|
||
type: "publish",
|
||
event: "候选版本提交独立审批",
|
||
object: release.id,
|
||
result: "待审批",
|
||
evidence: ["候选 Manifest", "质量门禁 PASS", "影响租户清单"],
|
||
});
|
||
migrateOperationsEntity(release, "PENDING_APPROVAL", metadata);
|
||
migrateOperationsEntity(approval, "PENDING_APPROVAL", metadata);
|
||
saveOperationsStore();
|
||
renderOperations();
|
||
renderAudit();
|
||
showToast(`${release.id} 已提交,申请人不能批准自己的申请`, "审批单已创建");
|
||
return;
|
||
}
|
||
if (event.target.closest("[data-release-resubmit]")) {
|
||
const metadata = addOperationsAudit({
|
||
type: "publish",
|
||
event: "驳回候选返回草稿重建",
|
||
object: release.id,
|
||
result: "草稿",
|
||
evidence: ["审批驳回意见", "候选失效记录", "质量报告"],
|
||
});
|
||
migrateOperationsEntity(release, "DRAFT", metadata);
|
||
migrateOperationsEntity(approval, "DRAFT", metadata);
|
||
release.manifestGeneratedAt = null;
|
||
release.candidateManifest = null;
|
||
release.candidateInputFingerprint = null;
|
||
approval.approver = null;
|
||
approval.comment = "";
|
||
approval.decidedAt = null;
|
||
saveOperationsStore();
|
||
renderOperations();
|
||
renderAudit();
|
||
showToast("旧候选 Manifest 已失效,请完成修订后重新生成", "候选已返回草稿");
|
||
return;
|
||
}
|
||
if (event.target.closest("[data-release-approve]") || event.target.closest("[data-release-reject]")) {
|
||
if (release.status !== "PENDING_APPROVAL" || approval.status !== "PENDING_APPROVAL") return;
|
||
const approver = document.getElementById("releaseApprover")?.value;
|
||
const comment = document.getElementById("approvalComment")?.value.trim();
|
||
const approved = Boolean(event.target.closest("[data-release-approve]"));
|
||
decideIndependentApproval({
|
||
approval,
|
||
target: release,
|
||
approver,
|
||
comment,
|
||
approved,
|
||
approveStatus: "APPROVED",
|
||
rejectStatus: "REJECTED",
|
||
approveEvent: "候选版本独立审批通过",
|
||
rejectEvent: "候选版本独立审批驳回",
|
||
object: release.id,
|
||
evidence: ["审批意见", "审批人身份", "四眼规则校验"],
|
||
defaultApproveComment: "审批通过",
|
||
defaultRejectComment: "需修订后重提",
|
||
resultLabel: operationStatusLabel,
|
||
successMessage: (actor, passed) => `${actor} 已${passed ? "批准" : "驳回"} ${release.id}`,
|
||
successTitle: (passed) => (passed ? "独立审批通过" : "审批已驳回"),
|
||
});
|
||
return;
|
||
}
|
||
if (event.target.closest("[data-release-publish]") && release.status === "APPROVED") {
|
||
if (
|
||
approval.status !== "APPROVED" ||
|
||
!isIndependentApproval(approval) ||
|
||
!release.candidateManifest ||
|
||
!release.manifestGeneratedAt
|
||
) {
|
||
showToast("发布需要有效候选 Manifest 和已完成职责分离的独立审批", "发布前置校验失败");
|
||
return;
|
||
}
|
||
const metadata = addOperationsAudit({
|
||
type: "publish",
|
||
event: "候选版本发布为稳定版本",
|
||
object: `${release.id} · ${release.dataVersion}`,
|
||
result: "已发布",
|
||
evidence: [`独立审批单 ${approval.id}:APPROVED`, "候选 Manifest", "稳定别名切换记录"],
|
||
});
|
||
migrateOperationsEntity(release, "PUBLISHED", metadata);
|
||
release.publishedAt = metadata.time;
|
||
release.publishedManifest = buildPackageManifest({ releaseStatus: "PUBLISHED", approvalStatus: "APPROVED" });
|
||
release.publishedManifestGeneratedAt = release.publishedManifest.generated_at;
|
||
saveOperationsStore();
|
||
downloadJson(`${release.packageId}-published-manifest.json`, release.publishedManifest);
|
||
state.operationsTab = "deliveries";
|
||
renderOperations();
|
||
renderAudit();
|
||
showToast(`稳定别名已切换,发布 Manifest 引用 ${approval.id}:APPROVED;尚未向学校发送`, "候选版本已发布");
|
||
return;
|
||
}
|
||
if (event.target.closest("[data-rollback-submit]") && release.status === "PUBLISHED") {
|
||
const rollbackApproval = currentRollbackApproval();
|
||
const reason = document.getElementById("rollbackReason")?.value.trim();
|
||
if (!reason) {
|
||
showToast("必须填写回滚原因与生产影响", "缺少回滚依据");
|
||
return;
|
||
}
|
||
const metadata = addOperationsAudit({
|
||
type: "publish",
|
||
event: "全局版本回滚提交独立审批",
|
||
object: `${release.id} → ${release.previousStableVersion}`,
|
||
result: "ROLLBACK_PENDING_APPROVAL",
|
||
evidence: ["回滚原因", "影响租户清单", "目标稳定版本"],
|
||
});
|
||
rollbackApproval.reason = reason;
|
||
rollbackApproval.approver = null;
|
||
rollbackApproval.comment = "";
|
||
rollbackApproval.decidedAt = null;
|
||
migrateOperationsEntity(rollbackApproval, "ROLLBACK_PENDING_APPROVAL", metadata);
|
||
saveOperationsStore();
|
||
renderOperations();
|
||
renderAudit();
|
||
showToast("全局回滚申请已提交,申请人不能批准自己的回滚", "等待独立回滚审批");
|
||
return;
|
||
}
|
||
if (event.target.closest("[data-rollback-approve]") || event.target.closest("[data-rollback-reject]")) {
|
||
const rollbackApproval = currentRollbackApproval();
|
||
if (release.status !== "PUBLISHED" || rollbackApproval?.status !== "ROLLBACK_PENDING_APPROVAL") return;
|
||
const approver = document.getElementById("rollbackApprover")?.value;
|
||
const comment = document.getElementById("rollbackApprovalComment")?.value.trim();
|
||
const approved = Boolean(event.target.closest("[data-rollback-approve]"));
|
||
decideIndependentApproval({
|
||
approval: rollbackApproval,
|
||
approver,
|
||
comment,
|
||
approved,
|
||
approveStatus: "ROLLBACK_APPROVED",
|
||
rejectStatus: "ROLLBACK_REJECTED",
|
||
approveEvent: "全局版本回滚独立审批通过",
|
||
rejectEvent: "全局版本回滚独立审批驳回",
|
||
object: `${release.id} → ${release.previousStableVersion}`,
|
||
evidence: ["回滚审批意见", "审批人身份", "四眼规则校验", "影响租户清单"],
|
||
defaultApproveComment: "同意在受控窗口执行回滚",
|
||
defaultRejectComment: "回滚依据不足",
|
||
successMessage: (actor, passed) => `${actor} 已${passed ? "批准" : "驳回"}全局回滚`,
|
||
successTitle: (passed) => (passed ? "回滚审批通过" : "回滚审批驳回"),
|
||
});
|
||
return;
|
||
}
|
||
if (event.target.closest("[data-release-rollback]") && release.status === "PUBLISHED") {
|
||
const rollbackApproval = currentRollbackApproval();
|
||
if (
|
||
rollbackApproval?.status !== "ROLLBACK_APPROVED" ||
|
||
!isIndependentApproval(rollbackApproval)
|
||
) {
|
||
showToast("全局版本回滚必须先完成职责分离的独立审批", "回滚执行被阻断");
|
||
return;
|
||
}
|
||
const metadata = addOperationsAudit({
|
||
type: "publish",
|
||
event: "发布版本全局回滚",
|
||
object: release.id,
|
||
result: "已回滚",
|
||
evidence: [`稳定别名恢复 ${release.previousStableVersion}`, `回滚审批 ${rollbackApproval.id}:${rollbackApproval.status}`, "影响范围锁"],
|
||
});
|
||
migrateOperationsEntity(release, "ROLLED_BACK", metadata);
|
||
migrateOperationsEntity(rollbackApproval, "ROLLBACK_EXECUTED", metadata);
|
||
saveOperationsStore();
|
||
renderOperations();
|
||
renderAudit();
|
||
showToast(`稳定别名已恢复为 ${release.previousStableVersion}`, "版本已回滚");
|
||
}
|
||
});
|
||
document.getElementById("startPendingDeliveries").addEventListener("click", () => {
|
||
if (currentRelease()?.status !== "PUBLISHED") {
|
||
showToast("只有已发布版本才能启动学校交付", "交付尚不可用");
|
||
return;
|
||
}
|
||
const pending = operationsStore.deliveries.filter((delivery) => delivery.status === "PENDING");
|
||
pending.forEach((delivery) => {
|
||
const metadata = addOperationsAudit({
|
||
type: "publish",
|
||
event: "租户交付进入发送队列",
|
||
object: `${delivery.tenantId} · ${delivery.packageId}`,
|
||
actor: "delivery-orchestrator",
|
||
result: "SENDING",
|
||
evidence: ["租户范围校验", "包签名", "幂等键"],
|
||
});
|
||
migrateOperationsEntity(delivery, "SENDING", metadata);
|
||
delivery.attempts += 1;
|
||
});
|
||
saveOperationsStore();
|
||
renderOperations();
|
||
renderAudit();
|
||
showToast(`${pending.length} 所待发送学校已进入原型队列,未发生真实网络发送`, "交付队列已启动");
|
||
});
|
||
document.getElementById("deliveryTable").addEventListener("click", (event) => {
|
||
const button =
|
||
event.target.closest("[data-delivery-delivered]") ||
|
||
event.target.closest("[data-delivery-failed]") ||
|
||
event.target.closest("[data-delivery-retry]") ||
|
||
event.target.closest("[data-delivery-ack]") ||
|
||
event.target.closest("[data-delivery-rollback]");
|
||
if (!button) return;
|
||
const id =
|
||
button.dataset.deliveryDelivered ||
|
||
button.dataset.deliveryFailed ||
|
||
button.dataset.deliveryRetry ||
|
||
button.dataset.deliveryAck ||
|
||
button.dataset.deliveryRollback;
|
||
const delivery = operationsStore.deliveries.find((item) => item.id === id);
|
||
if (!delivery) return;
|
||
if (button.dataset.deliveryDelivered) {
|
||
const metadata = addOperationsAudit({
|
||
type: "publish",
|
||
event: "租户交付包模拟送达",
|
||
object: delivery.tenantId,
|
||
actor: "delivery-orchestrator",
|
||
result: "DELIVERED",
|
||
evidence: ["传输完成标记", "包签名"],
|
||
});
|
||
migrateOperationsEntity(delivery, "DELIVERED", metadata);
|
||
delivery.failureReason = "";
|
||
} else if (button.dataset.deliveryFailed) {
|
||
const metadata = addOperationsAudit({
|
||
type: "publish",
|
||
event: "租户交付失败隔离",
|
||
object: delivery.tenantId,
|
||
actor: "delivery-orchestrator",
|
||
result: "FAILED",
|
||
evidence: ["模拟超时日志", "租户隔离策略"],
|
||
});
|
||
migrateOperationsEntity(delivery, "FAILED", metadata);
|
||
delivery.failureReason = "模拟租户端接收超时,其他学校未受影响";
|
||
} else if (button.dataset.deliveryRetry) {
|
||
const metadata = addOperationsAudit({
|
||
type: "publish",
|
||
event: "失败租户单独重试",
|
||
object: delivery.tenantId,
|
||
actor: OPERATIONS_ACTOR,
|
||
result: "RETRYING",
|
||
evidence: ["原包幂等键", "失败原因", "单租户重试指令"],
|
||
});
|
||
migrateOperationsEntity(delivery, "RETRYING", metadata);
|
||
delivery.attempts += 1;
|
||
delivery.failureReason = "";
|
||
} else if (button.dataset.deliveryAck) {
|
||
const countInput = document.getElementById(`receipt-count-${delivery.id}`);
|
||
const shaInput = document.getElementById(`receipt-sha-${delivery.id}`);
|
||
const receiptRecords = Number(countInput?.value);
|
||
const receiptSha = shaInput?.value.trim();
|
||
if (receiptRecords !== delivery.expectedRecords || receiptSha !== delivery.expectedSha) {
|
||
showToast("记录数与 SHA-256 必须同时等于候选包期望值,当前回执未确认", "回执校验失败");
|
||
return;
|
||
}
|
||
const metadata = addOperationsAudit({
|
||
type: "publish",
|
||
event: "学校回执校验通过",
|
||
object: delivery.tenantId,
|
||
actor: "receipt-verifier",
|
||
result: "ACKNOWLEDGED",
|
||
evidence: [`记录数 ${receiptRecords}`, `SHA-256 ${receiptSha}`, "回执签名"],
|
||
});
|
||
migrateOperationsEntity(delivery, "ACKNOWLEDGED", metadata);
|
||
delivery.receiptRecords = receiptRecords;
|
||
delivery.receiptSha = receiptSha;
|
||
} else if (button.dataset.deliveryRollback) {
|
||
const metadata = addOperationsAudit({
|
||
type: "publish",
|
||
event: "单租户交付回滚",
|
||
object: `${delivery.tenantId} · ${delivery.dataVersion}`,
|
||
actor: OPERATIONS_ACTOR,
|
||
result: "ROLLED_BACK",
|
||
evidence: ["租户范围锁", `恢复 ${currentRelease().previousStableVersion}`, "回滚记录"],
|
||
});
|
||
migrateOperationsEntity(delivery, "ROLLED_BACK", metadata);
|
||
}
|
||
saveOperationsStore();
|
||
renderOperations();
|
||
renderAudit();
|
||
showToast(`${delivery.school}:${operationStatusLabel(delivery.status)}`, "学校交付状态已更新");
|
||
});
|
||
}
|
||
|
||
function setupGlobalActions() {
|
||
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) closeModal(backdrop);
|
||
});
|
||
});
|
||
document.getElementById("notificationButton").addEventListener("click", () => {
|
||
const openIssues = operationsStore.issues.filter((issue) => !["RESOLVED", "CLOSED"].includes(issue.status));
|
||
const pendingApproval = operationsStore.approvals.find((approval) => ["PENDING_APPROVAL", "ROLLBACK_PENDING_APPROVAL"].includes(approval.status));
|
||
const deliveryTodos = operationsStore.deliveries.filter((delivery) => ["PENDING", "SENDING", "FAILED", "RETRYING"].includes(delivery.status));
|
||
openInfoModal(
|
||
"OPERATIONS NOTIFICATIONS",
|
||
"真实运营待办",
|
||
`<div class="runtime-list notification-list">
|
||
${openIssues
|
||
.map(
|
||
(issue) =>
|
||
`<button data-open-issue="${h(issue.id)}"><strong>${h(issue.title)}</strong><small>${h(operationStatusLabel(issue.status))} · ${h(issue.owner)} · ${h(issue.sla)}</small></button>`,
|
||
)
|
||
.join("")}
|
||
${
|
||
pendingApproval
|
||
? `<button data-operations-jump="releases"><strong>${h(pendingApproval.releaseId)} ${pendingApproval.type === "ROLLBACK" ? "等待独立回滚审批" : "等待独立发布审批"}</strong><small>申请人 ${h(pendingApproval.applicant)} 不能批准自己的申请</small></button>`
|
||
: ""
|
||
}
|
||
${deliveryTodos.length ? `<button data-operations-jump="deliveries"><strong>${deliveryTodos.length} 所学校交付待处理</strong><small>包含待发送、发送中、失败或重试状态</small></button>` : ""}
|
||
${!openIssues.length && !pendingApproval && !deliveryTodos.length ? `<span><strong>当前没有运营待办</strong><small>问题、审批和交付均已闭环</small></span>` : ""}
|
||
</div>`,
|
||
);
|
||
});
|
||
document.getElementById("operatorButton").addEventListener("click", () => {
|
||
openInfoModal(
|
||
"OPERATOR PROFILE",
|
||
"厂商数据运营管理员",
|
||
`<div class="info-grid"><span>姓名<strong>陈致远</strong></span><span>角色<strong>数据运营管理员</strong></span><span>权限范围<strong>全国招聘数据域</strong></span><span>最近登录<strong>今天 08:12</strong></span></div><div class="info-note">可管理数据源、采集任务、标准契约、质量规则和数据交付;生产发布需要数据产品负责人复核。</div>`,
|
||
);
|
||
});
|
||
document.addEventListener("keydown", (event) => {
|
||
if (event.key !== "Escape") return;
|
||
if (document.getElementById("issueDrawer").classList.contains("open")) {
|
||
closeIssueDrawer();
|
||
return;
|
||
}
|
||
if (document.getElementById("recordDrawer").classList.contains("open")) {
|
||
closeRecordDrawer();
|
||
return;
|
||
}
|
||
if (document.getElementById("sourceDrawer").classList.contains("open")) {
|
||
closeSourceDrawer();
|
||
return;
|
||
}
|
||
const modal = document.querySelector(".modal-backdrop.open");
|
||
if (modal) {
|
||
closeModal(modal);
|
||
return;
|
||
}
|
||
document.body.classList.remove("sidebar-open");
|
||
});
|
||
}
|
||
|
||
function setupOverviewActions() {
|
||
document.querySelector("[data-open-export]").addEventListener("click", exportDailyReport);
|
||
}
|
||
|
||
function populateJobSources() {
|
||
document.getElementById("jobSource").innerHTML = sources.map((source) => `<option value="${h(source.id)}">${h(source.name)} · ${h(source.categoryLabel)}</option>`).join("");
|
||
}
|
||
|
||
function init() {
|
||
renderOverviewSources();
|
||
renderSources();
|
||
renderCollectionJobs();
|
||
populateRecordSources();
|
||
renderRecords();
|
||
renderContract();
|
||
renderExchangeLogs();
|
||
renderQuality();
|
||
renderOperations();
|
||
renderAudit();
|
||
populateJobSources();
|
||
setupNavigation();
|
||
setupSourceRegistry();
|
||
setupSourceDrawer();
|
||
setupCollectionJobs();
|
||
setupRecords();
|
||
setupContracts();
|
||
setupExchange();
|
||
setupQuality();
|
||
setupOperations();
|
||
setupAudit();
|
||
setupGlobalActions();
|
||
setupOverviewActions();
|
||
}
|
||
|
||
document.addEventListener("DOMContentLoaded", init);
|