feat: add compliant data connectors and lineage

- 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>
This commit is contained in:
lofyer
2026-08-24 16:28:01 +08:00
co-authored by factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
parent 92515928d9
commit 8dff9847e3
46 changed files with 6145 additions and 55 deletions
+26
View File
@@ -24,6 +24,8 @@ python3 -m http.server 8080
- 改进任务筛选、搜索、创建、阶段进展和报告建议转任务
- 数据源初次导入、增量更新、质量处置、版本管理、授权与审计
- 厂商基础数据包、校级数据、企业数据分类及授权状态筛选
- 覆盖 6 个数据源和 42 份报告的“数据 → 指标/分析主题 → 报告”双向依赖图谱,支持只看缺口
- 报告缺失字段、当前完整率、影响范围、业务索取人、技术接口人和要求完成日期一体化追踪
- 校级管理者、校级技术管理员、学院负责人、专业负责人、就业老师、企业导师、厂商超级管理员七种差异化工作台
- 厂商平台级数据包导入、学校租户、账号权限、备份恢复和审计管理
- 校级、学院、专业、产业专题与数据质量报告中心
@@ -41,6 +43,25 @@ python3 -m http.server 8080
- **校级数据**:来自学校实习、就业、教务等业务系统,数据权属归学校。
- **企业数据**:由合作企业确认提交,按照合作协议限定使用范围。
## 数据与报告依赖图谱
“数据资产”页提供两种追溯方向:
- **从报告看数据**:选择任一校级、学院、专业、产业专题或数据质量报告,查看必需数据、可选增强数据、分析主题、当前完备度和缺口。
- **从数据看报告**:选择任一数据源,查看可形成的分析主题、支撑的报告类型和具体报告数量。
- **只看缺口**:仅保留存在质量或字段问题的依赖关系,快速定位不能稳定支撑报告的数据。
当前样板将 42 份报告映射到 6 个数据源,并以版本化依赖模板区分必需与可选关系。每个缺口展示:
- 缺失或待核验字段;
- 当前记录影响量、完整率和处理状态;
- 业务索取人及所属部门;
- 技术接口人及所属技术部门;
- 要求完成日期和受影响报告数;
- 可直接进入现有质量处置抽屉的操作入口。
依赖元数据用于跨部门补数协同,可以在数据运维角色下查看;报告正文仍按现有角色与数据范围授权,查看依赖关系不自动获得报告阅读权限。静态样板中的依赖、完整率和责任状态均为演示数据,正式系统应将 `ReportDataDependency`、`AnalysisTopic` 和 `DataRequest` 作为租户隔离、版本化、可审计的后端实体。
## 角色视角
- **校级管理者**:全校决策总览、专业对比、校企协同、改进任务、数据资产与报告。
@@ -67,6 +88,7 @@ python3 -m http.server 8080
- 厂商超级管理员在“平台运营中心 → 数据包管理”完成基础数据包导入、Schema 与质量校验、租户授权和版本发布。
- 校级技术管理员登录后默认进入“学校数据运维中心”,管理学校自有数据和企业授权数据。
- 每个数据源的“资产详情”包含业务截止日期、入库日期、下次更新、更新内容、主键、必填字段、格式、Schema、质量阈值、责任人与审计事件。
- “数据与报告双向依赖图谱”同时回答“这份报告还需要什么数据”和“这项数据可以形成哪些报告”,并把缺口直接分派到业务索取人与技术接口人。
- “接收与使用反馈”展示租户、包号、版本、完整 SHA、交付包记录数、独立分析快照、支撑报告/项目/任务、业务可用确认和下一周期反馈。
- 更新采用“暂存 → 校验 → 审批 → 发布”流程。失败数据进入隔离区,不覆盖当前稳定版本。
@@ -93,3 +115,7 @@ python3 -m http.server 8080
- `preview-campus-closed-loop.png`
- `preview-campus-closed-loop-mobile.png`
数据依赖图谱预览:
- `preview-data-report-lineage.png`
+543 -38
View File
@@ -231,6 +231,7 @@ const tasks = [
const sources = [
{
id: "regional-jobs",
name: "区域招聘岗位基础包",
type: "互联网岗位 · 标准数据包",
category: "vendor",
@@ -243,6 +244,7 @@ const sources = [
status: "服务有效",
records: "41,236",
quality: "93.4%",
analysisCompleteness: 98.8,
updated: "2026-03-18",
dataAsOf: "2026-03-17",
nextUpdate: "2026-04-01",
@@ -255,10 +257,13 @@ const sources = [
threshold: "综合质量 ≥ 90;主键唯一率 100%",
changeSummary: "新增 12,430 条,修改 846 条,失效 312 条;新增 AI 应用岗位分类",
steward: "平台数据产品部",
stewardContact: "林岚 · 数据产品经理",
technicalOwner: "平台数据运维组",
technicalContact: "平台数据包运维值班",
action: "检查更新",
},
{
id: "salary-company",
name: "薪资与企业画像基础包",
type: "互联网数据 · 增值数据包",
category: "vendor",
@@ -271,6 +276,7 @@ const sources = [
status: "服务有效",
records: "36,284",
quality: "91.8%",
analysisCompleteness: 100,
updated: "2026-03-18",
dataAsOf: "2026-03-17",
nextUpdate: "2026-04-01",
@@ -283,10 +289,13 @@ const sources = [
threshold: "综合质量 ≥ 90;薪资合法率 ≥ 98%",
changeSummary: "更新 2,186 条薪资样本,补充 326 家企业规模与融资阶段标签",
steward: "平台数据产品部",
stewardContact: "林岚 · 数据产品经理",
technicalOwner: "平台数据运维组",
technicalContact: "平台数据包运维值班",
action: "检查更新",
},
{
id: "internship",
name: "实习管理平台",
type: "校内系统 · API",
category: "school",
@@ -299,6 +308,7 @@ const sources = [
status: "正常",
records: "32,418",
quality: "96.2%",
analysisCompleteness: 100,
updated: "今天 08:12",
dataAsOf: "2026-03-21",
nextUpdate: "明天 08:00",
@@ -311,10 +321,13 @@ const sources = [
threshold: "必填完整率 ≥ 95%;跨表关联率 ≥ 98%",
changeSummary: "新增 186 条,修改 42 条;更新 31 家实习企业岗位信息",
steward: "就业处",
stewardContact: "赵晴 · 实习管理专员",
technicalOwner: "学校信息中心",
technicalContact: "王博 · 数据接口岗",
action: "立即同步",
},
{
id: "employment",
name: "就业管理系统",
type: "校内系统 · 数据库视图",
category: "school",
@@ -327,6 +340,7 @@ const sources = [
status: "字段缺失",
records: "8,926",
quality: "87.6%",
analysisCompleteness: 91.2,
updated: "今天 07:50",
dataAsOf: "2026-03-21",
nextUpdate: "异常处理后重试",
@@ -339,11 +353,14 @@ const sources = [
threshold: "专业代码合法率 100%;必填完整率 ≥ 95%",
changeSummary: "新增 126 条,修改 43 条;86 条薪资记录进入隔离区,未覆盖当前生产版本",
steward: "就业处",
stewardContact: "赵晴 · 就业数据专员",
technicalOwner: "学校信息中心",
technicalContact: "王博 · 数据接口岗",
action: "处理异常",
warning: true,
},
{
id: "curriculum",
name: "课程标准库",
type: "校内文件 · 结构化导入",
category: "school",
@@ -356,6 +373,7 @@ const sources = [
status: "正常",
records: "1,428",
quality: "95.1%",
analysisCompleteness: 100,
updated: "昨天 17:20",
dataAsOf: "2026-03-20",
nextUpdate: "2026-08-25",
@@ -368,10 +386,13 @@ const sources = [
threshold: "课程代码唯一率 100%;技能映射覆盖率 ≥ 80%",
changeSummary: "导入 2026 春季课程快照;新增 38 门课程,修订 126 份课程标准",
steward: "教务处",
stewardContact: "陈璐 · 教务数据专员",
technicalOwner: "学校信息中心",
technicalContact: "王博 · 数据接口岗",
action: "立即同步",
},
{
id: "enterprise-demand",
name: "企业确认需求库",
type: "企业提交 · 问卷/工作台",
category: "enterprise",
@@ -384,6 +405,7 @@ const sources = [
status: "授权有效",
records: "8,350",
quality: "97.3%",
analysisCompleteness: 98.5,
updated: "昨天 20:08",
dataAsOf: "2026-03-21",
nextUpdate: "企业提交后实时入队",
@@ -396,7 +418,9 @@ const sources = [
threshold: "企业身份与授权关联率 100%;必填完整率 ≥ 95%",
changeSummary: "新增 18 家企业确认、54 项岗位需求;12 项过期需求自动停用",
steward: "产教融合中心",
stewardContact: "王健 · 企业合作专员",
technicalOwner: "学校信息中心",
technicalContact: "王博 · 数据接口岗",
action: "查看回执",
},
];
@@ -827,6 +851,7 @@ function syncClosedLoopCollections() {
function saveAndRenderClosedLoop(message = "") {
saveClosedLoopState();
syncClosedLoopCollections();
renderDataReportLineage();
renderClosedLoop();
renderTasks();
renderProjects();
@@ -1502,6 +1527,7 @@ const reports = [
{
id: "salary-2026-03",
type: "industry",
dataProfile: "salary",
typeLabel: "产业专题",
title: "2026届实习就业薪资竞争力分析",
scope: "全校2026届学生",
@@ -1824,6 +1850,150 @@ professionReportCatalog.forEach(([name, college, score, focus], index) => {
});
});
const reportDataProfiles = {
school: {
label: "校级综合分析",
topics: ["区域岗位趋势", "课程技能覆盖", "实习就业成效", "企业需求验证"],
dependencies: [
{ sourceId: "regional-jobs", requirement: "required", purpose: "岗位规模、技能需求与区域趋势" },
{ sourceId: "salary-company", requirement: "optional", purpose: "区域薪资基准与企业画像补充" },
{ sourceId: "internship", requirement: "required", purpose: "学生实习岗位与过程成效" },
{ sourceId: "employment", requirement: "required", purpose: "毕业去向、岗位与就业薪资" },
{ sourceId: "curriculum", requirement: "required", purpose: "专业课程与技能培养供给" },
{ sourceId: "enterprise-demand", requirement: "required", purpose: "合作企业正式确认的人才需求" },
],
},
college: {
label: "学院专业建设分析",
topics: ["专业结构对比", "共享能力分析", "培养成效验证", "企业协同机会"],
dependencies: [
{ sourceId: "regional-jobs", requirement: "required", purpose: "学院相关岗位与技能趋势" },
{ sourceId: "salary-company", requirement: "optional", purpose: "专业群薪资和企业结构参考" },
{ sourceId: "internship", requirement: "optional", purpose: "学院实习岗位与实践反馈" },
{ sourceId: "employment", requirement: "optional", purpose: "学院毕业去向与岗位匹配验证" },
{ sourceId: "curriculum", requirement: "required", purpose: "跨专业课程与共享技能供给" },
{ sourceId: "enterprise-demand", requirement: "required", purpose: "学院合作企业需求与项目机会" },
],
},
profession: {
label: "专业人才需求分析",
topics: ["岗位技能需求", "课程能力映射", "学生达成验证", "企业需求复核"],
dependencies: [
{ sourceId: "regional-jobs", requirement: "required", purpose: "对口岗位、技能频次与需求变化" },
{ sourceId: "salary-company", requirement: "optional", purpose: "区域同岗位薪资竞争力" },
{ sourceId: "internship", requirement: "optional", purpose: "学生实践岗位与企业评价" },
{ sourceId: "employment", requirement: "optional", purpose: "毕业去向和岗位匹配验证" },
{ sourceId: "curriculum", requirement: "required", purpose: "课程标准、学时与技能覆盖" },
{ sourceId: "enterprise-demand", requirement: "required", purpose: "真实企业需求与合作意向确认" },
],
},
industry: {
label: "产业人才缺口分析",
topics: ["产业岗位趋势", "岗位技能归一", "培养供给对照", "企业确认校验"],
dependencies: [
{ sourceId: "regional-jobs", requirement: "required", purpose: "产业岗位规模、增速与技能信号" },
{ sourceId: "salary-company", requirement: "optional", purpose: "重点产业薪资与企业结构" },
{ sourceId: "curriculum", requirement: "required", purpose: "全校专业与课程技能供给" },
{ sourceId: "enterprise-demand", requirement: "required", purpose: "合作企业需求交叉验证" },
],
},
salary: {
label: "实习就业薪资分析",
topics: ["区域薪资基准", "实习薪资分布", "就业薪资对比", "专业竞争力"],
dependencies: [
{ sourceId: "regional-jobs", requirement: "optional", purpose: "区域岗位热度与样本辅助筛选" },
{ sourceId: "salary-company", requirement: "required", purpose: "区域同岗位薪资基准" },
{ sourceId: "internship", requirement: "required", purpose: "学生实习薪资与岗位信息" },
{ sourceId: "employment", requirement: "required", purpose: "毕业生就业薪资与去向" },
],
},
quality: {
label: "数据质量与口径分析",
topics: ["完整性与准确性", "版本与时效", "授权与权属", "加工血缘"],
dependencies: [
{ sourceId: "regional-jobs", requirement: "required", purpose: "岗位数据质量与归一覆盖" },
{ sourceId: "salary-company", requirement: "required", purpose: "薪资和企业画像质量" },
{ sourceId: "internship", requirement: "required", purpose: "实习数据完整性与时效" },
{ sourceId: "employment", requirement: "required", purpose: "就业数据完整性与异常记录" },
{ sourceId: "curriculum", requirement: "required", purpose: "课程数据版本与技能映射" },
{ sourceId: "enterprise-demand", requirement: "required", purpose: "企业身份、授权与需求质量" },
],
},
};
const dataGapCatalog = [
{
id: "job-normalization",
sourceId: "regional-jobs",
level: "attention",
levelLabel: "待复核",
title: "新增岗位缺少标准岗位代码与技能映射",
drawerTitle: "新增岗位名称待归一",
drawerLevel: "需要复核",
amount: "218 个岗位名称 · 影响 1,426 条记录",
affected: "218 个岗位别名 · 影响 1,426 条招聘记录",
fields: ["standard_position_code", "normalized_job_name", "skill_tags"],
affectedProfileKeys: ["school", "college", "profession", "industry", "quality"],
reason: "新增“具身智能应用工程师”等岗位未映射到当前岗位分类词典 V3.2。",
action: "优先审核需求量前 50 的岗位名称,确认标准岗位、技能标签及别名规则。",
status: "平台侧复核中",
due: "2026-03-24",
request: "请平台数据产品部确认标准岗位及技能标签,由平台数据运维组发布别名规则增量。",
},
{
id: "salary-missing",
sourceId: "employment",
level: "blocking",
levelLabel: "需补数",
title: "就业薪资与缺失原因未完整提供",
drawerTitle: "就业系统薪资字段缺失",
drawerLevel: "阻断发布",
amount: "86 条记录 · 当前字段完整率 91.2%",
affected: "86 条记录 · 电子商务专业 41 条、现代服务类专业 45 条",
fields: ["employment_salary", "salary_period", "missing_reason"],
affectedReportIds: ["school-2026-03", "salary-2026-03", "quality-2026-03", "ecommerce-2026-03"],
reason: "employment_salary 为空,未达到必填完整率 95% 的发布阈值。",
action: "由就业处补齐薪资或填写缺失原因,修复文件按 student_id + cohort 回写后重新校验。",
status: "待就业处补充",
due: "2026-03-25",
request: "请就业处补齐薪资或登记缺失原因,由学校信息中心回写只读视图并重新执行质量校验。",
},
{
id: "enterprise-duplicate",
sourceId: "enterprise-demand",
level: "attention",
levelLabel: "待核验",
title: "企业主标识存在疑似重复",
drawerTitle: "疑似重复企业记录",
drawerLevel: "一般事项",
amount: "12 家企业 · 涉及 37 项确认需求",
affected: "12 家企业 · 涉及 37 项岗位需求",
fields: ["enterprise_credit_code", "master_enterprise_id"],
affectedProfileKeys: ["school", "college", "profession", "industry", "quality"],
reason: "企业简称不同,但统一社会信用代码或工商地址高度相似。",
action: "由产教融合中心确认主企业记录,合并需求与授权回执并保留审计映射。",
status: "待产教融合中心确认",
due: "2026-03-26",
request: "请产教融合中心确认企业主记录,由学校信息中心合并需求与授权回执并保留审计映射。",
},
];
const sourceAnalysisTopics = {
"regional-jobs": ["岗位需求趋势", "岗位技能归一", "产业增长信号"],
"salary-company": ["区域薪资基准", "企业规模画像", "岗位竞争力"],
internship: ["实习岗位分布", "实践过程成效", "企业评价"],
employment: ["毕业去向", "就业薪资", "岗位匹配成效"],
curriculum: ["课程技能覆盖", "专业培养供给", "课程版本"],
"enterprise-demand": ["企业需求确认", "校企合作机会", "授权有效性"],
};
const dataLineageState = {
mode: "report",
selectedReportId: "school-2026-03",
selectedSourceId: "employment",
gapsOnly: false,
};
const iconPaths = {
course: '<path d="M4 5h16v14H4V5Zm4 0V3h8v2M8 10h8m-8 4h5"/>',
lab: '<path d="M9 3h6m-5 0v6l-5 9a2 2 0 0 0 2 3h10a2 2 0 0 0 2-3l-5-9V3M8 15h8"/>',
@@ -1983,6 +2153,7 @@ function applyRole(role, options = {}) {
]);
}
renderDataReportLineage();
renderClosedLoop();
if (document.getElementById("professionSelect")) {
renderProfessionProfile(document.getElementById("professionSelect").value);
@@ -2522,6 +2693,359 @@ function renderTasks(filter = "all", search = "") {
}
}
function reportDataProfileKey(report) {
return report.dataProfile || report.type;
}
function reportDataProfile(report) {
return reportDataProfiles[reportDataProfileKey(report)] || reportDataProfiles.profession;
}
function sourceById(sourceId) {
return sources.find((source) => source.id === sourceId);
}
function gapsForSource(sourceId) {
return dataGapCatalog.filter((gap) => gap.sourceId === sourceId);
}
function dataGapById(gapId) {
return dataGapCatalog.find((gap) => gap.id === gapId);
}
function dependencyForReport(report, sourceId) {
return reportDataProfile(report).dependencies.find((dependency) => dependency.sourceId === sourceId);
}
function reportsUsingSource(sourceId) {
return reports.filter((report) => Boolean(dependencyForReport(report, sourceId)));
}
function gapAffectsReport(gap, report) {
if (!dependencyForReport(report, gap.sourceId)) return false;
if (gap.affectedReportIds) return gap.affectedReportIds.includes(report.id);
if (report.periodKey !== "current") return false;
return gap.affectedProfileKeys?.includes(reportDataProfileKey(report)) ?? false;
}
function reportsAffectedByGap(gap) {
return reports.filter((report) => gapAffectsReport(gap, report));
}
function reportReadiness(report) {
const requiredDependencies = reportDataProfile(report).dependencies.filter(
(dependency) => dependency.requirement === "required",
);
if (!requiredDependencies.length) return 100;
return (
requiredDependencies.reduce(
(total, dependency) => total + sourceById(dependency.sourceId).analysisCompleteness,
0,
) /
requiredDependencies.length
);
}
function reportGaps(report) {
return dataGapCatalog.filter((gap) => gapAffectsReport(gap, report));
}
function reportsGroupedByType(reportItems) {
return reportItems.reduce((groups, report) => {
const group = groups.get(report.type) || {
type: report.type,
label: report.typeLabel,
reports: [],
};
group.reports.push(report);
groups.set(report.type, group);
return groups;
}, new Map());
}
function readableReportIdSet() {
return new Set(reportsForCurrentRole().map((report) => report.id));
}
function lineageEmptyNode(message) {
return `<div class="lineage-empty-node">${svgIcon("database")}<strong>${message}</strong><small>关闭“只看缺口”可查看完整依赖。</small></div>`;
}
function renderLineageSourceNode(source, dependency, selected = false, relevantGaps = gapsForSource(source.id)) {
const gaps = relevantGaps.filter((gap) => gap.sourceId === source.id);
const hasGap = gaps.length > 0;
const requirement = dependency?.requirement || "required";
const completeness = source.analysisCompleteness;
return `
<button class="lineage-node lineage-source-node ${hasGap ? "has-gap" : ""} ${selected ? "selected" : ""}" data-source-detail="${source.name}">
<span class="lineage-node-top">
<em class="${requirement}">${requirement === "required" ? "必需数据" : "可选增强"}</em>
<i class="${hasGap ? "gap" : "ready"}">${hasGap ? gaps[0].levelLabel : "可用"}</i>
</span>
<strong>${source.name}</strong>
<small>${dependency?.purpose || source.type}</small>
<span class="lineage-node-meta">
<span><b>${completeness.toFixed(1)}%</b> 完整</span>
<span>${source.steward}</span>
</span>
${hasGap ? `<span class="lineage-node-gap">${gaps.map((gap) => gap.fields.slice(0, 2).join("、")).join(";")}</span>` : ""}
</button>
`;
}
function renderLineageTopicNode(topic, index) {
return `
<div class="lineage-topic-node">
<span>${String(index + 1).padStart(2, "0")}</span>
<strong>${topic}</strong>
<small>统一口径计算 · 保留证据定位</small>
</div>
`;
}
function renderLineageReportNode(report, readableIds = readableReportIdSet()) {
const gaps = reportGaps(report);
const readiness = reportReadiness(report);
const canRead = readableIds.has(report.id);
return `
<button class="lineage-node lineage-report-node ${gaps.length ? "has-gap" : ""}" data-lineage-report="${report.id}">
<span class="lineage-node-top">
<em>${report.typeLabel}</em>
<i class="${gaps.length ? "gap" : "ready"}">${gaps.length ? `${gaps.length}项缺口` : "数据齐备"}</i>
</span>
<strong>${report.title}</strong>
<small>${report.scope}</small>
<span class="lineage-node-meta">
<span><b>${readiness.toFixed(1)}%</b> 完备</span>
<span>${canRead ? report.version : "仅依赖元数据"}</span>
</span>
</button>
`;
}
function renderLineageReportGroup(group, readableIds) {
const affectedCount = group.reports.filter((report) => reportGaps(report).length).length;
return `
<details class="lineage-report-group" ${group.reports.length <= 5 ? "open" : ""}>
<summary>
<span><strong>${group.label}</strong><small>${affectedCount ? `${affectedCount} 份存在数据缺口` : "当前数据关系可用"}</small></span>
<em>${group.reports.length} 份</em>
</summary>
<div class="lineage-report-group-list">
${group.reports.map((report) => renderLineageReportNode(report, readableIds)).join("")}
</div>
</details>
`;
}
function renderLineageStage(title, countText, className, content) {
return `
<section class="lineage-stage ${className}">
<div class="lineage-stage-head"><span>${title}</span><strong>${countText}</strong></div>
<div class="lineage-stage-list">${content}</div>
</section>
`;
}
function lineageConnector(label) {
return `
<div class="lineage-connector" aria-hidden="true">
<span>${label}</span>
<i><b></b></i>
</div>
`;
}
function renderReportLineageCanvas(report) {
const profile = reportDataProfile(report);
const gaps = reportGaps(report);
const readableIds = readableReportIdSet();
const gapSourceIds = new Set(gaps.map((gap) => gap.sourceId));
const visibleDependencies = dataLineageState.gapsOnly
? profile.dependencies.filter((dependency) => gapSourceIds.has(dependency.sourceId))
: profile.dependencies;
const sourceNodes =
visibleDependencies
.map((dependency) => renderLineageSourceNode(sourceById(dependency.sourceId), dependency, false, gaps))
.join("") || lineageEmptyNode("当前报告没有数据缺口");
const topicNodes = profile.topics.map(renderLineageTopicNode).join("");
return `
<div class="lineage-stage-grid report-direction">
${renderLineageStage("数据资产", `${visibleDependencies.length} / ${profile.dependencies.length} 项`, "sources", sourceNodes)}
${lineageConnector("形成指标")}
${renderLineageStage("指标 / 分析主题", `${profile.topics.length} 类`, "topics", topicNodes)}
${lineageConnector("生成报告")}
${renderLineageStage("目标报告", "1 份", "reports", renderLineageReportNode(report, readableIds))}
</div>
`;
}
function renderSourceLineageCanvas(source) {
const sourceReports = reportsUsingSource(source.id);
const affectedReportIds = new Set(
gapsForSource(source.id).flatMap((gap) => reportsAffectedByGap(gap).map((report) => report.id)),
);
const visibleReports = dataLineageState.gapsOnly
? sourceReports.filter((report) => affectedReportIds.has(report.id))
: sourceReports;
const reportGroups = [...reportsGroupedByType(visibleReports).values()];
const readableIds = readableReportIdSet();
const reportNodes =
reportGroups
.map((group) => renderLineageReportGroup(group, readableIds))
.join("") || lineageEmptyNode("当前数据来源没有待处理的报告关系");
const topics = sourceAnalysisTopics[source.id] || ["基础指标", "专题分析", "质量校验"];
return `
<div class="lineage-stage-grid source-direction">
${renderLineageStage("选中数据", "1 项", "sources", renderLineageSourceNode(source, null, true))}
${lineageConnector("支撑分析")}
${renderLineageStage("可形成的分析", `${topics.length} 类`, "topics", topics.map(renderLineageTopicNode).join(""))}
${lineageConnector("支撑报告")}
${renderLineageStage("可形成的报告", `${visibleReports.length} 份`, "reports", reportNodes)}
</div>
`;
}
function lineageSummaryCard(label, value, note, className = "") {
return `<article class="${className}"><span>${label}</span><strong>${value}</strong><small>${note}</small></article>`;
}
function renderLineageSummary(report, source) {
const summary = document.getElementById("dataLineageSummary");
if (dataLineageState.mode === "report") {
const profile = reportDataProfile(report);
const requiredCount = profile.dependencies.filter((dependency) => dependency.requirement === "required").length;
const gaps = reportGaps(report);
summary.innerHTML = [
lineageSummaryCard("当前报告", report.typeLabel, report.version),
lineageSummaryCard("依赖数据", `${requiredCount} 必需 · ${profile.dependencies.length - requiredCount} 可选`, "依赖关系已登记"),
lineageSummaryCard("数据完备度", `${reportReadiness(report).toFixed(1)}%`, gaps.length ? "使用稳定版本,可继续分析" : "达到生成条件"),
lineageSummaryCard("待补数据", `${gaps.length} 项`, gaps.length ? "已定位责任人与时限" : "当前无数据缺口", gaps.length ? "warning" : "ready"),
].join("");
return;
}
const sourceReports = reportsUsingSource(source.id);
const gaps = gapsForSource(source.id);
summary.innerHTML = [
lineageSummaryCard("当前数据", source.categoryLabel, source.version),
lineageSummaryCard("支撑报告", `${sourceReports.length} 份`, `${reportsGroupedByType(sourceReports).size} 类报告`),
lineageSummaryCard("当前质量", source.quality, `${source.records} 条有效记录`),
lineageSummaryCard("数据缺口", `${gaps.length} 项`, gaps.length ? "已定位责任人与时限" : "当前无数据缺口", gaps.length ? "warning" : "ready"),
].join("");
}
function renderLineageGapCard(gap, selectedReport) {
const source = sourceById(gap.sourceId);
const impactCount = selectedReport ? 1 : reportsAffectedByGap(gap).length;
return `
<article class="lineage-gap-card ${gap.level}">
<div class="lineage-gap-card-head">
<span><i></i>${gap.levelLabel}</span>
<em>${gap.status}</em>
</div>
<strong>${gap.title}</strong>
<small>${source.name} · ${gap.amount}</small>
<div class="lineage-gap-fields">${gap.fields.map((field) => `<span>${field}</span>`).join("")}</div>
<p>${gap.request}</p>
<dl>
<div><dt>业务索取人</dt><dd>${source.stewardContact}</dd></div>
<div><dt>技术接口人</dt><dd>${source.technicalContact}</dd></div>
<div><dt>要求完成</dt><dd>${gap.due}</dd></div>
<div><dt>影响范围</dt><dd>${selectedReport ? "当前报告" : `${impactCount} 份报告`}</dd></div>
</dl>
<button data-lineage-gap-action="${gap.id}">进入补数与质量处置</button>
</article>
`;
}
function renderLineageGaps(report, source) {
const gaps = dataLineageState.mode === "report" ? reportGaps(report) : gapsForSource(source.id);
document.getElementById("dataLineageGapCount").textContent = `${gaps.length} 项待处理`;
document.getElementById("dataLineageGapList").innerHTML = gaps.length
? gaps.map((gap) => renderLineageGapCard(gap, dataLineageState.mode === "report" ? report : null)).join("")
: `
<div class="lineage-gap-empty">
${svgIcon("database")}
<span><strong>当前关系没有待补数据</strong><small>数据版本、质量和授权均达到报告生成要求。</small></span>
</div>
`;
}
function renderDataReportLineage() {
const selector = document.getElementById("dataLineageSelector");
if (!selector) return;
const mode = dataLineageState.mode;
const readableReportIds = new Set(reportsForCurrentRole().map((report) => report.id));
if (!reports.some((report) => report.id === dataLineageState.selectedReportId)) {
dataLineageState.selectedReportId = "school-2026-03";
}
if (!sources.some((source) => source.id === dataLineageState.selectedSourceId)) {
dataLineageState.selectedSourceId = sources[0].id;
}
const report = reports.find((item) => item.id === dataLineageState.selectedReportId) || reports[0];
const source = sourceById(dataLineageState.selectedSourceId) || sources[0];
document.querySelectorAll("[data-lineage-mode]").forEach((button) => {
const selected = button.dataset.lineageMode === mode;
button.classList.toggle("active", selected);
button.setAttribute("aria-pressed", String(selected));
});
document.getElementById("dataLineageSelectorLabel").textContent = mode === "report" ? "选择报告" : "选择数据";
selector.setAttribute("aria-label", mode === "report" ? "选择要追溯的报告" : "选择要反查的数据");
selector.innerHTML =
mode === "report"
? reports
.map((item) => {
const canRead = readableReportIds.has(item.id);
return `<option value="${item.id}">${item.typeLabel} · ${item.title}${canRead ? "" : " · 仅依赖元数据"}</option>`;
})
.join("")
: sources
.map((item) => `<option value="${item.id}">${item.categoryLabel} · ${item.name}</option>`)
.join("");
selector.value = mode === "report" ? report.id : source.id;
document.getElementById("dataLineageGapToggle").checked = dataLineageState.gapsOnly;
document.getElementById("dataLineageCanvas").innerHTML =
mode === "report" ? renderReportLineageCanvas(report) : renderSourceLineageCanvas(source);
renderLineageSummary(report, source);
renderLineageGaps(report, source);
}
function setupDataReportLineage() {
const lineage = document.getElementById("dataReportLineage");
lineage.addEventListener("click", (event) => {
const modeButton = event.target.closest("[data-lineage-mode]");
if (modeButton) {
dataLineageState.mode = modeButton.dataset.lineageMode;
renderDataReportLineage();
return;
}
const reportButton = event.target.closest("[data-lineage-report]");
if (reportButton) {
const reportId = reportButton.dataset.lineageReport;
if (!reportsForCurrentRole().some((report) => report.id === reportId)) {
showToast("依赖元数据可见,但当前角色没有该报告正文的阅读权限。", "权限范围提示");
return;
}
openReportReader(reportId);
return;
}
const gapButton = event.target.closest("[data-lineage-gap-action]");
if (gapButton) openDataOpsDrawer("issue", gapButton.dataset.lineageGapAction);
});
document.getElementById("dataLineageSelector").addEventListener("change", (event) => {
if (dataLineageState.mode === "report") dataLineageState.selectedReportId = event.target.value;
else dataLineageState.selectedSourceId = event.target.value;
renderDataReportLineage();
});
document.getElementById("dataLineageGapToggle").addEventListener("change", (event) => {
dataLineageState.gapsOnly = event.target.checked;
renderDataReportLineage();
});
}
const dataOpsState = {
context: "source",
sourceName: sources[0].name,
@@ -2529,33 +3053,6 @@ const dataOpsState = {
issue: null,
};
const dataIssueCatalog = {
"salary-missing": {
title: "就业系统薪资字段缺失",
level: "阻断发布",
sourceName: "就业管理系统",
affected: "86 条记录 · 电子商务专业 41 条、现代服务类专业 45 条",
reason: "employment_salary 为空,未达到必填完整率 95% 的发布阈值。",
action: "由就业处补齐薪资或填写缺失原因,修复文件按 student_id + cohort 回写后重新校验。",
},
"job-normalization": {
title: "新增岗位名称待归一",
level: "需要复核",
sourceName: "区域招聘岗位基础包",
affected: "218 个岗位别名 · 影响 1,426 条招聘记录",
reason: "新增“具身智能应用工程师”等岗位未映射到当前岗位分类词典 V3.2。",
action: "优先审核需求量前 50 的岗位名称,确认标准岗位、技能标签及别名规则。",
},
"enterprise-duplicate": {
title: "疑似重复企业记录",
level: "一般事项",
sourceName: "企业确认需求库",
affected: "12 家企业 · 涉及 37 项岗位需求",
reason: "企业简称不同,但统一社会信用代码或工商地址高度相似。",
action: "由产教融合中心确认主企业记录,合并需求与授权回执并保留审计映射。",
},
};
function currentDataSource() {
return sources.find((source) => source.name === dataOpsState.sourceName) || sources[0];
}
@@ -2582,8 +3079,8 @@ function renderDataOpsOverview(source) {
<h3>更新与责任</h3>
<dl>
<div><dt>数据类别</dt><dd>${source.categoryLabel} · ${source.commercial}</dd></div>
<div><dt>业务责任人</dt><dd>${source.steward}</dd></div>
<div><dt>技术责任人</dt><dd>${source.technicalOwner}</dd></div>
<div><dt>业务责任人</dt><dd>${source.stewardContact} · ${source.steward}</dd></div>
<div><dt>技术责任人</dt><dd>${source.technicalContact} · ${source.technicalOwner}</dd></div>
<div><dt>更新模式</dt><dd>${source.updateMode}</dd></div>
<div><dt>当前状态</dt><dd><span class="data-ops-state ${source.warning ? "warning" : ""}">${source.status}</span></dd></div>
</dl>
@@ -2674,7 +3171,7 @@ function renderDataOpsAudit(source) {
return `
<div class="data-authorization-card">
<span>权属与授权</span><strong>${source.rights}</strong><p>${authorization}</p>
<div><span>业务责任人<strong>${source.steward}</strong></span><span>技术责任人<strong>${source.technicalOwner}</strong></span><span>授权状态<strong>${source.status}</strong></span></div>
<div><span>业务责任人<strong>${source.stewardContact} · ${source.steward}</strong></span><span>技术责任人<strong>${source.technicalContact} · ${source.technicalOwner}</strong></span><span>授权状态<strong>${source.status}</strong></span></div>
</div>
<article class="data-ops-card">
<h3>最近审计事件</h3>
@@ -2721,17 +3218,23 @@ function renderDataOpsUsage() {
</article>`;
}
function renderDataIssue(issueKey) {
const issue = dataIssueCatalog[issueKey];
function renderDataIssue(issueId) {
const issue = dataGapById(issueId);
const source = currentDataSource();
const impactCount = reportsAffectedByGap(issue).length;
return `
<div class="data-issue-hero">
<span>${issue.level}</span><h3>${issue.title}</h3><p>${issue.affected}</p>
<span>${issue.drawerLevel}</span><h3>${issue.drawerTitle}</h3><p>${issue.affected}</p>
</div>
<article class="data-ops-card"><h3>规则命中原因</h3><p>${issue.reason}</p></article>
<article class="data-ops-card"><h3>建议处置</h3><p>${issue.action}</p></article>
<article class="data-ops-card">
<h3>受影响分析</h3>
<div class="data-impact-grid"><span>当前生产版本<strong>未覆盖</strong></span><span>分析快照<strong>沿用稳定版本</strong></span><span>责任部门<strong>${currentDataSource().steward}</strong></span></div>
<h3>受影响分析与补数责任</h3>
<div class="data-impact-grid"><span>当前生产版本<strong>未覆盖</strong></span><span>影响报告<strong>${impactCount} 份</strong></span><span>分析快照<strong>沿用稳定版本</strong></span></div>
<dl class="data-issue-owner-list">
<div><dt>业务索取人</dt><dd>${source.stewardContact} · ${source.steward}</dd></div>
<div><dt>技术接口人</dt><dd>${source.technicalContact} · ${source.technicalOwner}</dd></div>
</dl>
</article>
`;
}
@@ -2841,11 +3344,11 @@ function updateDataOpsDrawer() {
}
if (dataOpsState.context === "issue") {
const issue = dataIssueCatalog[dataOpsState.issue];
const issueSource = sources.find((item) => item.name === issue.sourceName);
const issue = dataGapById(dataOpsState.issue);
const issueSource = sourceById(issue.sourceId);
if (issueSource) dataOpsState.sourceName = issueSource.name;
document.getElementById("dataOpsKicker").textContent = "DATA QUALITY WORKBENCH";
document.getElementById("dataOpsTitle").textContent = issue.title;
document.getElementById("dataOpsTitle").textContent = issue.drawerTitle;
document.getElementById("dataOpsDescription").textContent = "查看受影响数据、规则命中原因和可审计的处理流程。";
content.innerHTML = renderDataIssue(dataOpsState.issue);
footer.innerHTML = `<button class="button secondary" data-data-ops-result="错误明细文件已准备,可交由责任部门修复">下载错误明细</button><button class="button primary" data-data-ops-result="已创建质量整改任务,并通知责任部门">创建整改任务</button>`;
@@ -4707,6 +5210,7 @@ function init() {
renderPartners();
renderTasks();
renderSources();
renderDataReportLineage();
setupGenericInteractions();
setupRoleExperience();
setupPlatformActions();
@@ -4716,6 +5220,7 @@ function init() {
setupForms();
setupFilters();
setupSourceFilters();
setupDataReportLineage();
setupDataOperations();
setupWorkspaceDetails();
setupProductExperience();
+49 -1
View File
@@ -870,6 +870,54 @@
<button id="dataAuthorizationButton">查看授权说明</button>
</div>
<article class="panel data-lineage-panel" id="dataReportLineage">
<div class="panel-header data-lineage-header">
<div>
<span class="panel-kicker">DATA → ANALYSIS → REPORT</span>
<h2>数据与报告双向依赖图谱</h2>
<p>从报告反查必需数据,也可从数据查看能形成的报告;缺口直接定位业务索取人和技术接口人。</p>
</div>
<span class="lineage-snapshot-badge"><i></i>分析快照 V2026.03</span>
</div>
<div class="data-lineage-toolbar">
<div class="lineage-mode-switch" id="dataLineageModeSwitch" role="group" aria-label="选择图谱查看方向">
<button class="active" data-lineage-mode="report" aria-pressed="true">从报告看数据</button>
<button data-lineage-mode="source" aria-pressed="false">从数据看报告</button>
</div>
<label class="lineage-selector">
<span id="dataLineageSelectorLabel">选择报告</span>
<select id="dataLineageSelector" aria-label="选择要追溯的报告"></select>
</label>
<label class="lineage-gap-toggle">
<input type="checkbox" id="dataLineageGapToggle">
<span><i></i>只看缺口</span>
</label>
</div>
<div class="lineage-summary-grid" id="dataLineageSummary" aria-live="polite"></div>
<div class="lineage-legend" aria-label="图谱图例">
<span><i class="required"></i>必需数据</span>
<span><i class="optional"></i>可选增强</span>
<span><i class="gap"></i>存在缺口</span>
<small>点击数据节点查看资产要求,点击报告节点进入在线报告。</small>
</div>
<div class="data-lineage-canvas" id="dataLineageCanvas"></div>
<div class="lineage-gap-section">
<div class="lineage-gap-heading">
<div>
<span>DATA REQUEST DESK</span>
<h3>缺口与补数责任</h3>
</div>
<strong id="dataLineageGapCount">0 项待处理</strong>
</div>
<div class="lineage-gap-list" id="dataLineageGapList"></div>
</div>
</article>
<article class="panel source-panel">
<div class="panel-header">
<div>
@@ -918,7 +966,7 @@
<span class="panel-kicker">质量事项</span>
<h2>待处理问题</h2>
</div>
<span class="panel-count amber-count">4 项</span>
<span class="panel-count amber-count">3 项</span>
</div>
<div class="issue-list">
<button data-data-issue="salary-missing">
Binary file not shown.

After

Width:  |  Height:  |  Size: 330 KiB

+924
View File
@@ -3085,6 +3085,840 @@ svg {
font-weight: 600;
}
.data-lineage-panel {
margin-bottom: 14px;
overflow: hidden;
background:
radial-gradient(circle at 98% 1%, rgba(198, 230, 111, 0.08), transparent 21%),
white;
}
.data-lineage-header {
align-items: flex-start;
}
.data-lineage-header h2 {
margin-bottom: 7px;
}
.data-lineage-header p {
max-width: 720px;
margin: 0;
color: var(--muted);
line-height: 1.65;
}
.lineage-snapshot-badge {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
gap: 7px;
padding: 8px 11px;
color: var(--green);
background: var(--green-soft);
border: 1px solid #d2e8db;
border-radius: 9px;
font-weight: 700;
}
.lineage-snapshot-badge i {
width: 7px;
height: 7px;
background: var(--green);
border-radius: 50%;
box-shadow: 0 0 0 4px rgba(35, 135, 101, 0.11);
}
.data-lineage-toolbar {
display: grid;
grid-template-columns: auto minmax(280px, 1fr) auto;
align-items: end;
gap: 12px;
margin-top: 18px;
padding: 12px;
background: #f4f7f4;
border: 1px solid #e5ebe6;
border-radius: 12px;
}
.lineage-mode-switch {
display: flex;
gap: 4px;
padding: 4px;
background: #e8ede9;
border-radius: 9px;
}
.lineage-mode-switch button {
min-height: 38px;
padding: 0 13px;
color: var(--muted);
background: transparent;
border-radius: 7px;
font-weight: 700;
white-space: nowrap;
}
.lineage-mode-switch button.active {
color: white;
background: var(--green);
box-shadow: 0 4px 12px rgba(23, 107, 79, 0.18);
}
.lineage-selector {
display: grid;
min-width: 0;
gap: 5px;
color: var(--muted);
font-weight: 600;
}
.lineage-selector select {
width: 100%;
min-height: 38px;
padding: 0 34px 0 11px;
overflow: hidden;
color: var(--ink);
background: white;
border: 1px solid #dfe6e1;
border-radius: 8px;
text-overflow: ellipsis;
}
.lineage-gap-toggle {
display: flex;
min-height: 38px;
align-items: center;
cursor: pointer;
}
.lineage-gap-toggle input {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
opacity: 0;
}
.lineage-gap-toggle > span {
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--muted);
font-weight: 700;
white-space: nowrap;
}
.lineage-gap-toggle > span i {
position: relative;
width: 38px;
height: 22px;
background: #cbd4ce;
border-radius: 12px;
transition: background 0.18s ease;
}
.lineage-gap-toggle > span i::after {
position: absolute;
top: 3px;
left: 3px;
width: 16px;
height: 16px;
background: white;
border-radius: 50%;
box-shadow: 0 2px 5px rgba(21, 54, 42, 0.2);
content: "";
transition: transform 0.18s ease;
}
.lineage-gap-toggle input:checked + span {
color: #9a6515;
}
.lineage-gap-toggle input:checked + span i {
background: var(--amber);
}
.lineage-gap-toggle input:checked + span i::after {
transform: translateX(16px);
}
.lineage-gap-toggle input:focus-visible + span i {
outline: 3px solid rgba(35, 135, 101, 0.2);
outline-offset: 2px;
}
.lineage-summary-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 9px;
margin-top: 12px;
}
.lineage-summary-grid article {
min-width: 0;
padding: 13px 14px;
background: #fafbf9;
border: 1px solid var(--line-soft);
border-radius: 10px;
}
.lineage-summary-grid article.warning {
background: #fff9ed;
border-color: #efdcae;
}
.lineage-summary-grid article.ready {
background: #f1f8f4;
border-color: #d8e9de;
}
.lineage-summary-grid span,
.lineage-summary-grid strong,
.lineage-summary-grid small {
display: block;
}
.lineage-summary-grid span {
color: var(--muted);
}
.lineage-summary-grid strong {
margin: 7px 0 4px;
color: var(--ink);
font-size: 16px;
}
.lineage-summary-grid article.warning strong {
color: #9a6515;
}
.lineage-summary-grid article.ready strong {
color: var(--green);
}
.lineage-summary-grid small {
color: var(--muted);
line-height: 1.45;
}
.lineage-legend {
display: flex;
align-items: center;
gap: 15px;
margin: 18px 0 9px;
color: var(--muted);
}
.lineage-legend > span {
display: inline-flex;
align-items: center;
gap: 6px;
white-space: nowrap;
}
.lineage-legend i {
width: 11px;
height: 11px;
border: 2px solid var(--green);
border-radius: 3px;
}
.lineage-legend i.optional {
border-color: #6d88a1;
border-style: dashed;
}
.lineage-legend i.gap {
background: var(--amber-soft);
border-color: var(--amber);
}
.lineage-legend small {
margin-left: auto;
text-align: right;
}
.data-lineage-canvas {
padding: 15px;
background:
linear-gradient(rgba(34, 105, 79, 0.035) 1px, transparent 1px),
linear-gradient(90deg, rgba(34, 105, 79, 0.035) 1px, transparent 1px),
#f7f9f7;
background-size: 24px 24px;
border: 1px solid #e1e8e3;
border-radius: 14px;
}
.lineage-stage-grid {
display: grid;
grid-template-columns: minmax(250px, 1.08fr) 48px minmax(210px, 0.82fr) 48px minmax(250px, 1fr);
align-items: start;
gap: 8px;
}
.lineage-stage {
min-width: 0;
overflow: hidden;
background: rgba(255, 255, 255, 0.94);
border: 1px solid #dfe7e1;
border-radius: 12px;
box-shadow: 0 8px 24px rgba(25, 58, 45, 0.06);
}
.lineage-stage-head {
display: flex;
min-height: 45px;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 10px 12px;
color: var(--green);
background: #edf6f1;
border-bottom: 1px solid #dce9e1;
}
.lineage-stage.topics .lineage-stage-head {
color: #376991;
background: #eef4f9;
border-color: #dae6ef;
}
.lineage-stage.reports .lineage-stage-head {
color: #8b621f;
background: #fff8ea;
border-color: #eee0bd;
}
.lineage-stage-head span {
font-weight: 800;
}
.lineage-stage-head strong {
font-weight: 700;
}
.lineage-stage-list {
display: grid;
gap: 8px;
padding: 9px;
}
.lineage-node {
display: block;
width: 100%;
min-width: 0;
padding: 11px;
color: var(--ink);
background: white;
border: 1px solid #dfe7e1;
border-radius: 10px;
text-align: left;
transition:
transform 0.18s ease,
border-color 0.18s ease,
box-shadow 0.18s ease;
}
.lineage-node:hover,
.lineage-node:focus-visible {
transform: translateY(-1px);
border-color: #8fc2a9;
box-shadow: 0 8px 20px rgba(29, 83, 62, 0.11);
}
.lineage-node.selected {
background: #f3f9f6;
border-color: #78b597;
box-shadow: inset 3px 0 var(--green);
}
.lineage-node.has-gap {
background: #fffcf5;
border-color: #e8d29f;
}
.lineage-node-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 8px;
}
.lineage-node-top em,
.lineage-node-top i {
padding: 4px 6px;
border-radius: 6px;
font-style: normal;
font-weight: 700;
}
.lineage-node-top em {
color: var(--green);
background: var(--green-soft);
}
.lineage-node-top em.optional {
color: #4e718e;
background: var(--blue-soft);
}
.lineage-node-top i.ready {
color: var(--green);
background: #eff7f2;
}
.lineage-node-top i.gap {
color: #966214;
background: var(--amber-soft);
}
.lineage-node > strong,
.lineage-node > small {
display: block;
}
.lineage-node > strong {
overflow: hidden;
font-size: 14px;
line-height: 1.45;
text-overflow: ellipsis;
}
.lineage-node > small {
min-height: 38px;
margin-top: 5px;
overflow: hidden;
color: var(--muted);
line-height: 1.5;
}
.lineage-node-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-top: 8px;
padding-top: 8px;
color: var(--muted);
border-top: 1px solid #edf0ed;
}
.lineage-node-meta b {
color: var(--green);
}
.lineage-node-gap {
display: block;
margin-top: 7px;
padding: 6px 7px;
overflow: hidden;
color: #8c621b;
background: #fff5db;
border-radius: 6px;
line-height: 1.45;
text-overflow: ellipsis;
}
.lineage-report-node > strong {
white-space: normal;
}
.lineage-report-group {
min-width: 0;
overflow: hidden;
background: white;
border: 1px solid #e2e7e3;
border-radius: 9px;
}
.lineage-report-group > summary {
display: grid;
grid-template-columns: minmax(0, 1fr) auto 12px;
align-items: center;
gap: 8px;
padding: 10px;
color: var(--ink);
background: #fafbf9;
cursor: pointer;
list-style: none;
}
.lineage-report-group > summary::-webkit-details-marker {
display: none;
}
.lineage-report-group > summary::after {
width: 7px;
height: 7px;
border-right: 2px solid #819088;
border-bottom: 2px solid #819088;
content: "";
transform: rotate(45deg) translateY(-2px);
transition: transform 0.16s ease;
}
.lineage-report-group[open] > summary::after {
transform: rotate(225deg) translate(-2px, -1px);
}
.lineage-report-group > summary span,
.lineage-report-group > summary strong,
.lineage-report-group > summary small {
display: block;
min-width: 0;
}
.lineage-report-group > summary small {
margin-top: 3px;
color: var(--muted);
}
.lineage-report-group > summary em {
padding: 4px 6px;
color: #805b16;
background: var(--amber-soft);
border-radius: 6px;
font-style: normal;
font-weight: 700;
white-space: nowrap;
}
.lineage-report-group-list {
display: grid;
max-height: 360px;
gap: 7px;
padding: 8px;
overflow-y: auto;
border-top: 1px solid #e8ede9;
}
.lineage-topic-node {
display: grid;
grid-template-columns: 30px minmax(0, 1fr);
gap: 3px 9px;
padding: 10px;
background: #f8fbfd;
border: 1px solid #dfe8ef;
border-radius: 9px;
}
.lineage-topic-node > span {
display: grid;
width: 30px;
height: 30px;
grid-row: 1 / 3;
place-items: center;
color: #467594;
background: #e5f0f7;
border-radius: 7px;
font-weight: 800;
}
.lineage-topic-node strong {
align-self: end;
line-height: 1.4;
}
.lineage-topic-node small {
color: var(--muted);
line-height: 1.4;
}
.lineage-connector {
display: grid;
min-height: 120px;
align-content: center;
justify-items: center;
gap: 9px;
color: var(--muted);
}
.lineage-connector span {
text-align: center;
}
.lineage-connector i {
position: relative;
display: block;
width: 38px;
height: 2px;
background: #9fb2a8;
}
.lineage-connector i::after {
position: absolute;
top: -4px;
right: -1px;
width: 8px;
height: 8px;
border-top: 2px solid #789286;
border-right: 2px solid #789286;
content: "";
transform: rotate(45deg);
}
.lineage-connector b {
position: absolute;
top: -3px;
left: 4px;
width: 8px;
height: 8px;
background: var(--lime);
border: 2px solid white;
border-radius: 50%;
box-shadow: 0 0 0 1px #9fb2a8;
}
.lineage-empty-node {
display: grid;
min-height: 150px;
place-items: center;
align-content: center;
gap: 7px;
padding: 20px;
color: var(--muted);
text-align: center;
}
.lineage-empty-node svg {
width: 28px;
height: 28px;
color: #91a49a;
}
.lineage-empty-node strong,
.lineage-empty-node small {
display: block;
}
.lineage-gap-section {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid var(--line-soft);
}
.lineage-gap-heading {
display: flex;
align-items: end;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
}
.lineage-gap-heading span {
color: #ad741d;
font-weight: 800;
letter-spacing: 0.08em;
}
.lineage-gap-heading h3 {
margin: 4px 0 0;
font-size: 16px;
}
.lineage-gap-heading > strong {
padding: 6px 9px;
color: #956313;
background: var(--amber-soft);
border-radius: 7px;
}
.lineage-gap-list {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 9px;
}
.lineage-gap-card {
display: flex;
min-width: 0;
flex-direction: column;
padding: 13px;
background: #fffcf5;
border: 1px solid #ead8ad;
border-radius: 11px;
}
.lineage-gap-card.blocking {
background: #fff8f4;
border-color: #ebc5b5;
}
.lineage-gap-card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.lineage-gap-card-head span {
display: inline-flex;
align-items: center;
gap: 6px;
color: #a06611;
font-weight: 800;
}
.lineage-gap-card.blocking .lineage-gap-card-head span {
color: #a34f31;
}
.lineage-gap-card-head span i {
width: 7px;
height: 7px;
background: var(--amber);
border-radius: 50%;
}
.lineage-gap-card.blocking .lineage-gap-card-head span i {
background: #c96643;
}
.lineage-gap-card-head em {
color: var(--muted);
font-style: normal;
}
.lineage-gap-card > strong,
.lineage-gap-card > small {
display: block;
}
.lineage-gap-card > strong {
margin-top: 10px;
line-height: 1.5;
}
.lineage-gap-card > small {
margin-top: 4px;
color: var(--muted);
line-height: 1.5;
}
.lineage-gap-fields {
display: flex;
flex-wrap: wrap;
gap: 5px;
margin-top: 9px;
}
.lineage-gap-fields span {
padding: 4px 6px;
color: #81591a;
background: #fff1cb;
border-radius: 5px;
font-family: "SFMono-Regular", Consolas, monospace;
}
.lineage-gap-card.blocking .lineage-gap-fields span {
color: #944a30;
background: #fee9df;
}
.lineage-gap-card p {
margin: 10px 0;
color: var(--ink-soft);
line-height: 1.6;
}
.lineage-gap-card dl {
display: grid;
gap: 6px;
margin: auto 0 10px;
padding: 9px;
background: rgba(255, 255, 255, 0.75);
border-radius: 8px;
}
.lineage-gap-card dl div {
display: grid;
grid-template-columns: 74px minmax(0, 1fr);
gap: 7px;
}
.lineage-gap-card dt {
color: var(--muted);
}
.lineage-gap-card dd {
min-width: 0;
margin: 0;
font-weight: 700;
}
.lineage-gap-card > button {
width: 100%;
min-height: 36px;
color: #8c5d12;
background: white;
border: 1px solid #e3cd9c;
border-radius: 7px;
font-weight: 700;
}
.lineage-gap-card.blocking > button {
color: #9a4d32;
border-color: #e6b8a5;
}
.lineage-gap-card > button:hover {
background: #fff7e5;
}
.lineage-gap-empty {
display: flex;
grid-column: 1 / -1;
min-height: 90px;
align-items: center;
justify-content: center;
gap: 12px;
padding: 18px;
color: var(--muted);
background: #f4f8f5;
border: 1px dashed #cbded2;
border-radius: 11px;
}
.lineage-gap-empty svg {
width: 28px;
height: 28px;
color: var(--green);
}
.lineage-gap-empty strong,
.lineage-gap-empty small {
display: block;
}
.lineage-gap-empty small {
margin-top: 4px;
}
.data-issue-owner-list {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin: 12px 0 0;
}
.data-issue-owner-list div {
padding: 10px;
background: #f5f8f5;
border: 1px solid var(--line-soft);
border-radius: 8px;
}
.data-issue-owner-list dt {
color: var(--muted);
}
.data-issue-owner-list dd {
margin: 5px 0 0;
font-weight: 700;
}
.source-filter-toolbar {
display: flex;
align-items: center;
@@ -6397,6 +7231,30 @@ svg {
grid-template-columns: 1fr;
}
.lineage-stage-grid {
grid-template-columns: 1fr;
}
.lineage-stage-list {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.report-direction .lineage-stage.reports .lineage-stage-list {
grid-template-columns: 1fr;
}
.lineage-connector {
min-height: 36px;
}
.lineage-connector i {
transform: rotate(90deg);
}
.lineage-gap-list {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.platform-metric-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@@ -6487,6 +7345,24 @@ svg {
flex-direction: column;
}
.data-lineage-toolbar {
grid-template-columns: 1fr;
align-items: stretch;
}
.lineage-mode-switch {
display: grid;
grid-template-columns: 1fr 1fr;
}
.lineage-summary-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.lineage-gap-list {
grid-template-columns: 1fr;
}
.tenant-card-grid {
grid-template-columns: 1fr;
}
@@ -6977,6 +7853,54 @@ svg {
width: 100%;
}
.data-lineage-header,
.lineage-gap-heading {
align-items: flex-start;
flex-direction: column;
}
.lineage-snapshot-badge {
align-self: flex-start;
}
.lineage-mode-switch button {
padding: 0 8px;
}
.lineage-summary-grid,
.lineage-stage-list {
grid-template-columns: 1fr;
}
.lineage-legend {
align-items: flex-start;
flex-wrap: wrap;
gap: 8px 12px;
}
.lineage-legend small {
width: 100%;
margin-left: 0;
text-align: left;
}
.data-lineage-canvas {
padding: 10px;
}
.lineage-gap-toggle {
min-height: 34px;
}
.lineage-gap-card dl div {
grid-template-columns: 1fr;
gap: 2px;
}
.data-issue-owner-list {
grid-template-columns: 1fr;
}
.source-license-grid {
grid-template-columns: 1fr;
}