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

1275 lines
50 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(function () {
"use strict";
const palette = {
green: "#176b4f",
green2: "#2f8f6b",
mint: "#72b893",
lime: "#c6e66f",
blue: "#4d7fa8",
amber: "#d39a35",
red: "#c65e51",
violet: "#7f6aaa",
ink: "#27362f",
muted: "#718078",
line: "#dfe7e1",
soft: "#f4f7f4",
};
const chartInstances = new Map();
const resizeObserver =
typeof ResizeObserver === "function"
? new ResizeObserver((entries) => {
entries.forEach((entry) => chartInstances.get(entry.target)?.resize());
})
: null;
function clamp(value, min = 0, max = 100) {
return Math.max(min, Math.min(max, value));
}
function hashString(value) {
return [...String(value)].reduce((hash, char) => (hash * 31 + char.charCodeAt(0)) >>> 0, 2166136261);
}
function firstNumber(value, fallback = 0) {
const match = String(value ?? "").replace(/,/g, "").match(/-?\d+(?:\.\d+)?/);
return match ? Number(match[0]) : fallback;
}
function reportScore(report) {
if (report.type === "industry" && report.id === "gap-2026-03") return 76.8;
if (report.id === "salary-2026-03") return 98;
const metric = report.metrics.find((item) => /匹配度|质量评分|竞争力/.test(item.label));
return clamp(firstNumber(metric?.value, 78.6));
}
function evidencePrefix(report) {
const prefixes = {
school: "SCH",
college: "COL",
profession: "PRO",
industry: "IND",
quality: "DQA",
};
return prefixes[report.type] || "EVD";
}
function profileStats(report, seed) {
const commonSchoolStats = [
{
code: "SCH-EDU-001",
label: "全日制在校生",
value: "12,860",
unit: "人",
note: "较上学年 +3.1%",
source: "教务管理系统 · 学籍库",
updated: "2026-03-20",
owner: "教务处",
definition: "统计学籍状态为“在籍”的全日制学生,休学学生单列、不重复计入。",
},
{
code: "SCH-GRA-002",
label: "2026届毕业生",
value: "4,218",
unit: "人",
note: "覆盖 32 个专业",
source: "教务管理系统 · 毕业生库",
updated: "2026-03-20",
owner: "教务处",
definition: "按预计毕业年份为2026、通过学籍有效性校验的学生去重统计。",
},
{
code: "SCH-MAJ-003",
label: "招生专业",
value: "32",
unit: "个",
note: "分属 6 个学院",
source: "专业设置与招生计划",
updated: "2026-03-18",
owner: "发展规划处",
definition: "统计本学年有在校生且处于有效状态的专业,不含停招无在校生专业。",
},
{
code: "SCH-TEA-004",
label: "专任教师",
value: "812",
unit: "人",
note: "双师型占比 64.7%",
source: "人事系统 · 教师主数据",
updated: "2026-03-15",
owner: "人事处",
definition: "按在岗专任教师主数据去重;双师型依据校级认定结果计算。",
},
{
code: "SCH-CRS-005",
label: "本学年课程",
value: "1,286",
unit: "门次",
note: "课程标准完整率 96.2%",
source: "教务管理系统 · 开课计划",
updated: "2026-03-20",
owner: "教务处",
definition: "按课程代码、教学班和学期统计开课门次;课程标准完整率按已归档标准计。",
},
{
code: "SCH-ENT-006",
label: "有效合作企业",
value: "126",
unit: "家",
note: "近12个月新增 18 家",
source: "校企合作项目台账",
updated: "2026-03-21",
owner: "产教融合中心",
definition: "合作协议有效,且近12个月至少有需求确认、课程、项目、实习或就业记录之一。",
},
{
code: "SCH-EMP-007",
label: "2025届去向落实率",
value: "93.2",
unit: "%",
note: "有效回执 3,864 份",
source: "就业管理系统 · 去向回执",
updated: "2025-12-31",
owner: "就业处",
definition: "按教育主管部门就业去向统计口径,分母排除经审核的暂不就业特殊样本。",
},
{
code: "SCH-INT-008",
label: "岗位实习对口率",
value: "78.4",
unit: "%",
note: "较上届 +4.6pct",
source: "实习管理系统 · 岗位映射",
updated: "2026-03-19",
owner: "就业处",
definition: "实习岗位标准分类与学生所属专业方向达到二级或以上匹配的学生占比。",
},
];
if (report.type === "school" || report.type === "industry" || report.type === "quality") {
return commonSchoolStats;
}
const isCollege = report.type === "college";
const studentCount = isCollege ? 2860 + (seed % 520) : 438 + (seed % 310);
const graduateCount = isCollege ? 880 + (seed % 240) : 132 + (seed % 96);
const courseCount = isCollege ? 268 + (seed % 80) : 34 + (seed % 19);
const teacherCount = isCollege ? 146 + (seed % 45) : 24 + (seed % 14);
const enterpriseCount = firstNumber(report.metrics.find((item) => /企业/.test(item.label))?.value, 12);
const prefix = evidencePrefix(report);
return [
{
code: `${prefix}-STU-001`,
label: "在校生规模",
value: studentCount.toLocaleString("zh-CN"),
unit: "人",
note: isCollege ? `${report.scope}在籍学生` : "三个年级全日制在籍",
source: "教务管理系统 · 学籍库",
updated: "2026-03-20",
owner: report.author,
definition: "按当前报告数据范围过滤后,对有效在籍学生学号去重统计。",
},
{
code: `${prefix}-GRA-002`,
label: "2026届毕业生",
value: graduateCount.toLocaleString("zh-CN"),
unit: "人",
note: "已通过学籍一致性校验",
source: "教务管理系统 · 毕业生库",
updated: "2026-03-20",
owner: report.author,
definition: "预计毕业年份为2026且学籍有效的学生,跨专业调整以当前专业归属为准。",
},
{
code: `${prefix}-CRS-003`,
label: "有效开课",
value: courseCount.toLocaleString("zh-CN"),
unit: "门次",
note: "含理论、实训与项目课程",
source: "教务管理系统 · 开课计划",
updated: "2026-03-20",
owner: "教务处",
definition: "本学年已经排课且有教学班的课程门次,取消或未开班课程不计入。",
},
{
code: `${prefix}-TEA-004`,
label: "专任教师",
value: teacherCount.toLocaleString("zh-CN"),
unit: "人",
note: `企业兼职导师 ${Math.max(8, Math.round(teacherCount * 0.28))} 人`,
source: "教师主数据与导师台账",
updated: "2026-03-18",
owner: "人事处",
definition: "专任教师按在岗主数据统计,企业兼职导师须存在有效聘任或授课记录。",
},
{
code: `${prefix}-ENT-005`,
label: "关联合作企业",
value: String(Math.max(enterpriseCount, isCollege ? 26 : 8)),
unit: "家",
note: `${Math.max(3, Math.round(enterpriseCount * 0.3))} 家有本学期项目`,
source: "校企合作项目台账",
updated: "2026-03-21",
owner: "产教融合中心",
definition: "与当前学院或专业存在有效课程、项目、实习、就业或导师关系的企业。",
},
{
code: `${prefix}-PRJ-006`,
label: "企业项目实训",
value: String(isCollege ? 18 + (seed % 8) : 6 + (seed % 6)),
unit: "项",
note: "均有企业任务书或验收记录",
source: "项目实训与企业评价库",
updated: "2026-03-19",
owner: report.author,
definition: "纳入统计的项目须具备企业任务书、学生过程产物和至少一项验收证据。",
},
];
}
function buildEvidence(report) {
const seed = hashString(report.id);
const score = reportScore(report);
const prefix = evidencePrefix(report);
const dimensions = report.bars.slice(0, 5).map((bar, index) => {
const school = clamp(firstNumber(bar.value, 72));
const regional = clamp(school - (3 + ((seed + index * 3) % 8)));
const peer = clamp(school - (1.4 + ((seed + index * 5) % 5)));
const coverageGap = 9 + ((seed + index * 7) % 24);
return {
name: bar.label,
school,
regional,
peer,
demand: school,
coverage: clamp(school - coverageGap),
attainment: clamp(school - Math.round(coverageGap * 0.68)),
confirmed: clamp(school - 5 + ((index * 4) % 11)),
};
});
const benchmarkSchoolCount = report.type === "profession" ? 27 : 18;
const peerCount = report.type === "profession" ? 11 : 8;
const rank = Math.max(1, Math.min(benchmarkSchoolCount, Math.round((100 - score) / 4.8)));
const trendBase = clamp(score - 18, 45, 76);
const trendLabels = ["2025 Q2", "2025 Q3", "2025 Q4", "2026.01", "2026.02", "2026.03"];
const demandTrend = trendLabels.map((_, index) => clamp(trendBase + index * 4 + ((seed + index) % 3)));
const responseTrend = trendLabels.map((_, index) =>
clamp(trendBase - 7 + index * 3 + ((seed + index * 2) % 3)),
);
const heatmap = [];
dimensions.forEach((item, row) => {
[item.demand, item.coverage, item.attainment, item.confirmed].forEach((value, column) => {
heatmap.push([column, row, value]);
});
});
const composition = [
{ name: "数字技术", value: 34 + (seed % 5) },
{ name: "智能制造", value: 28 + ((seed >> 2) % 5) },
{ name: "商贸服务", value: 24 + ((seed >> 4) % 5) },
{ name: "城建文创", value: 22 + ((seed >> 6) % 4) },
{ name: "教育服务", value: 18 + ((seed >> 8) % 4) },
];
const quadrant = dimensions.map((item, index) => ({
name: item.name,
coverage: item.coverage,
demand: item.demand,
sample: 280 + ((seed + index * 337) % 1720),
}));
return {
score,
scoreLabel:
report.type === "quality"
? "数据质量评分"
: report.id === "salary-2026-03"
? "区域薪资竞争力"
: "供需匹配度",
benchmark: {
regional: clamp(score - 4.8),
peer: clamp(score - 2.7),
rank,
schoolCount: benchmarkSchoolCount,
peerCount,
sampleText:
report.type === "profession"
? `区域 ${benchmarkSchoolCount} 所院校同名或相近专业、同类 ${peerCount} 个专业群`
: `区域 ${benchmarkSchoolCount} 所同层次院校、同类 ${peerCount} 所院校`,
},
snapshot: {
id: "SNP-2026-03-22-0830",
records: "128,642",
quality: "92.6",
coverage: "94.8%",
generated: "2026-03-22 09:30",
},
dimensions,
trend: { labels: trendLabels, demand: demandTrend, response: responseTrend },
heatmap,
composition,
quadrant,
profile: {
name:
report.type === "school" || report.type === "industry" || report.type === "quality"
? "杭州科技职业技术学院"
: `杭州科技职业技术学院 · ${report.scope}`,
attributes: [
["分析对象", report.scope],
["统计周期", report.period],
["数据快照", "2026-03-22 08:30"],
["空间范围", "杭州都市圈 + 校企合作企业"],
],
stats: profileStats(report, seed),
},
sources: [
{
code: `${prefix}-JOB-101`,
asset: "互联网基础数据包",
source: "区域招聘岗位与薪资基础包",
sample: "77,520 条",
updated: "2026-03-18",
owner: "平台厂商(订阅授权)",
confidence: "高",
limitation: "反映招聘需求信号,不等于实际录用人数",
},
{
code: `${prefix}-EDU-102`,
asset: "校级数据",
source: "学籍、专业、课程与教师主数据",
sample: "17,176 条",
updated: "2026-03-20",
owner: "学校",
confidence: "高",
limitation: "跨系统统计以学号、专业代码和课程代码归一",
},
{
code: `${prefix}-EMP-103`,
asset: "校级数据",
source: "实习、就业与薪资回执",
sample: "25,596 条",
updated: "2026-03-19",
owner: "学校",
confidence: "中高",
limitation: "薪资缺失或区间异常记录已降权",
},
{
code: `${prefix}-ENT-104`,
asset: "企业数据",
source: "126家合作企业需求确认",
sample: "8,350 条",
updated: "2026-03-21",
owner: "合作企业(按协议用途)",
confidence: "高",
limitation: "仅用于协议约定的专业建设分析",
},
],
evidenceCodes: {
overall: `${prefix}-CAL-201`,
benchmark: `${prefix}-BAS-202`,
radar: `${prefix}-SKL-203`,
trend: `${prefix}-TRD-204`,
matrix: `${prefix}-MAT-205`,
composition: `${prefix}-ENT-206`,
quadrant: `${prefix}-GAP-207`,
threeD: `${prefix}-3DM-208`,
},
};
}
function svgFrame(content, height = 300, label = "") {
return `<svg viewBox="0 0 680 ${height}" role="img" aria-label="${label}" xmlns="http://www.w3.org/2000/svg">${content}</svg>`;
}
function gaugeSvg(evidence) {
const dash = clamp(evidence.score);
return svgFrame(
`
<defs>
<linearGradient id="gauge-gradient" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stop-color="${palette.amber}"/>
<stop offset=".55" stop-color="${palette.green2}"/>
<stop offset="1" stop-color="${palette.lime}"/>
</linearGradient>
</defs>
<path d="M145 232 A195 195 0 0 1 535 232" pathLength="100" fill="none" stroke="#e9efeb" stroke-width="26" stroke-linecap="round"/>
<path d="M145 232 A195 195 0 0 1 535 232" pathLength="100" fill="none" stroke="url(#gauge-gradient)" stroke-width="26" stroke-linecap="round" stroke-dasharray="${dash} ${100 - dash}"/>
<text x="340" y="172" text-anchor="middle" font-size="58" font-weight="800" fill="${palette.ink}">${evidence.score.toFixed(1)}</text>
<text x="340" y="202" text-anchor="middle" font-size="16" fill="${palette.muted}">${evidence.scoreLabel}</text>
<rect x="250" y="226" width="180" height="38" rx="19" fill="#eef7f2"/>
<text x="340" y="251" text-anchor="middle" font-size="15" font-weight="700" fill="${palette.green}">区域排名 ${evidence.benchmark.rank}/${evidence.benchmark.schoolCount}</text>
<text x="145" y="278" font-size="13" fill="${palette.muted}">低匹配</text>
<text x="500" y="278" font-size="13" fill="${palette.muted}">高匹配</text>
`,
300,
`${evidence.scoreLabel}${evidence.score.toFixed(1)}分`,
);
}
function groupedBarSvg(evidence) {
const width = 680;
const labelWidth = 118;
const chartWidth = 470;
const rowHeight = 47;
const colors = [palette.green, palette.blue, palette.amber];
const rows = evidence.dimensions
.map((item, index) => {
const y = 65 + index * rowHeight;
const bars = [item.school, item.regional, item.peer]
.map(
(value, seriesIndex) =>
`<rect x="${labelWidth}" y="${y + seriesIndex * 9}" width="${(value / 100) * chartWidth}" height="7" rx="3.5" fill="${colors[seriesIndex]}"/>
<text x="${labelWidth + (value / 100) * chartWidth + 7}" y="${y + seriesIndex * 9 + 7}" font-size="11" fill="${palette.muted}">${value.toFixed(0)}</text>`,
)
.join("");
return `<text x="8" y="${y + 14}" font-size="13" font-weight="600" fill="${palette.ink}">${item.name.slice(0, 9)}</text>${bars}`;
})
.join("");
return svgFrame(
`
<g font-size="12" fill="${palette.muted}">
<circle cx="126" cy="25" r="5" fill="${colors[0]}"/><text x="137" y="29">本校/本专业</text>
<circle cx="251" cy="25" r="5" fill="${colors[1]}"/><text x="262" y="29">区域均值</text>
<circle cx="357" cy="25" r="5" fill="${colors[2]}"/><text x="368" y="29">同类均值</text>
</g>
${[0, 25, 50, 75, 100]
.map(
(tick) =>
`<line x1="${labelWidth + (tick / 100) * chartWidth}" y1="48" x2="${labelWidth + (tick / 100) * chartWidth}" y2="292" stroke="#e6ece8"/>
<text x="${labelWidth + (tick / 100) * chartWidth}" y="309" text-anchor="middle" font-size="11" fill="${palette.muted}">${tick}</text>`,
)
.join("")}
${rows}
`,
325,
"学校值、区域均值与同类院校均值对比",
);
}
function radarPoints(values, radius = 103, centerX = 210, centerY = 145) {
return values
.map((value, index) => {
const angle = -Math.PI / 2 + (index * Math.PI * 2) / values.length;
const distance = (value / 100) * radius;
return `${centerX + Math.cos(angle) * distance},${centerY + Math.sin(angle) * distance}`;
})
.join(" ");
}
function radarSvg(evidence) {
const count = evidence.dimensions.length;
const labels = evidence.dimensions
.map((item, index) => {
const angle = -Math.PI / 2 + (index * Math.PI * 2) / count;
const x = 210 + Math.cos(angle) * 132;
const y = 145 + Math.sin(angle) * 124;
const anchor = x < 185 ? "end" : x > 235 ? "start" : "middle";
return `<text x="${x}" y="${y}" text-anchor="${anchor}" dominant-baseline="middle" font-size="12" fill="${palette.muted}">${item.name.slice(0, 8)}</text>`;
})
.join("");
const grids = [20, 40, 60, 80, 100]
.map((value) => `<polygon points="${radarPoints(Array(count).fill(value))}" fill="none" stroke="#e4ebe6"/>`)
.join("");
const demand = evidence.dimensions.map((item) => item.demand);
const coverage = evidence.dimensions.map((item) => item.coverage);
return svgFrame(
`
${grids}${labels}
<polygon points="${radarPoints(demand)}" fill="${palette.green}" fill-opacity=".18" stroke="${palette.green}" stroke-width="2.5"/>
<polygon points="${radarPoints(coverage)}" fill="${palette.amber}" fill-opacity=".14" stroke="${palette.amber}" stroke-width="2.5"/>
<g transform="translate(430 95)" font-size="13">
<circle cx="0" cy="0" r="6" fill="${palette.green}"/><text x="14" y="5" fill="${palette.ink}">企业需求强度</text>
<circle cx="0" cy="35" r="6" fill="${palette.amber}"/><text x="14" y="40" fill="${palette.ink}">课程技能覆盖</text>
<rect x="-10" y="65" width="190" height="80" rx="12" fill="#f4f8f5"/>
<text x="4" y="90" fill="${palette.muted}">最大结构性缺口</text>
<text x="4" y="122" font-size="22" font-weight="800" fill="${palette.red}">${Math.max(
...evidence.dimensions.map((item) => item.demand - item.coverage),
).toFixed(0)} pct</text>
</g>
`,
300,
"企业需求与课程技能覆盖雷达对比",
);
}
function linePoints(values, width = 540, height = 190, startX = 75, startY = 32) {
return values
.map((value, index) => {
const x = startX + (index / (values.length - 1)) * width;
const y = startY + height - ((value - 40) / 60) * height;
return [x, y];
})
.map(([x, y]) => `${x},${y}`)
.join(" ");
}
function trendSvg(evidence) {
const demandPoints = linePoints(evidence.trend.demand);
const responsePoints = linePoints(evidence.trend.response);
const demandArea = `${demandPoints} 615,242 75,242`;
return svgFrame(
`
<defs><linearGradient id="trend-fill" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${palette.green}" stop-opacity=".28"/><stop offset="1" stop-color="${palette.green}" stop-opacity=".02"/></linearGradient></defs>
${[40, 55, 70, 85, 100]
.map((tick) => {
const y = 32 + 190 - ((tick - 40) / 60) * 190;
return `<line x1="75" y1="${y}" x2="615" y2="${y}" stroke="#e5ebe7"/><text x="58" y="${y + 4}" text-anchor="end" font-size="11" fill="${palette.muted}">${tick}</text>`;
})
.join("")}
<polygon points="${demandArea}" fill="url(#trend-fill)"/>
<polyline points="${demandPoints}" fill="none" stroke="${palette.green}" stroke-width="3"/>
<polyline points="${responsePoints}" fill="none" stroke="${palette.amber}" stroke-width="3" stroke-dasharray="8 5"/>
${evidence.trend.labels
.map(
(label, index) =>
`<text x="${75 + (index / 5) * 540}" y="265" text-anchor="middle" font-size="11" fill="${palette.muted}">${label}</text>`,
)
.join("")}
<circle cx="438" cy="18" r="5" fill="${palette.green}"/><text x="449" y="22" font-size="12" fill="${palette.muted}">产业需求</text>
<circle cx="530" cy="18" r="5" fill="${palette.amber}"/><text x="541" y="22" font-size="12" fill="${palette.muted}">课程调整</text>
`,
285,
"产业需求与学校课程调整趋势",
);
}
function heatColor(value) {
if (value >= 85) return "#176b4f";
if (value >= 75) return "#4f9677";
if (value >= 65) return "#9fc7ad";
if (value >= 55) return "#ead09b";
return "#dc897d";
}
function heatmapSvg(evidence) {
const columns = ["岗位需求", "课程覆盖", "学生达成", "企业确认"];
const cellWidth = 105;
const cellHeight = 42;
const startX = 190;
const startY = 54;
const cells = evidence.heatmap
.map(([column, row, value]) => {
const x = startX + column * cellWidth;
const y = startY + row * cellHeight;
return `<rect x="${x}" y="${y}" width="${cellWidth - 5}" height="${cellHeight - 5}" rx="7" fill="${heatColor(value)}"/>
<text x="${x + (cellWidth - 5) / 2}" y="${y + 24}" text-anchor="middle" font-size="13" font-weight="700" fill="${value >= 75 ? "white" : palette.ink}">${value.toFixed(0)}</text>`;
})
.join("");
return svgFrame(
`
${columns
.map(
(label, index) =>
`<text x="${startX + index * cellWidth + 50}" y="33" text-anchor="middle" font-size="12" fill="${palette.muted}">${label}</text>`,
)
.join("")}
${evidence.dimensions
.map(
(item, index) =>
`<text x="${startX - 12}" y="${startY + index * cellHeight + 24}" text-anchor="end" font-size="12" fill="${palette.ink}">${item.name.slice(0, 10)}</text>`,
)
.join("")}
${cells}
<text x="190" y="285" font-size="11" fill="${palette.muted}">低</text>
${["#dc897d", "#ead09b", "#9fc7ad", "#4f9677", "#176b4f"]
.map((color, index) => `<rect x="${215 + index * 45}" y="274" width="45" height="10" fill="${color}"/>`)
.join("")}
<text x="452" y="285" font-size="11" fill="${palette.muted}">高</text>
`,
300,
"技能供需证据热力图",
);
}
function donutSvg(evidence) {
const total = evidence.composition.reduce((sum, item) => sum + item.value, 0);
let offset = 0;
const colors = [palette.green, palette.blue, palette.amber, palette.violet, palette.mint];
const rings = evidence.composition
.map((item, index) => {
const percent = (item.value / total) * 100;
const circle = `<circle cx="195" cy="145" r="83" pathLength="100" fill="none" stroke="${colors[index]}" stroke-width="31" stroke-dasharray="${percent} ${100 - percent}" stroke-dashoffset="${-offset}" transform="rotate(-90 195 145)"/>`;
offset += percent;
return circle;
})
.join("");
return svgFrame(
`
${rings}
<circle cx="195" cy="145" r="57" fill="white"/>
<text x="195" y="137" text-anchor="middle" font-size="28" font-weight="800" fill="${palette.ink}">126</text>
<text x="195" y="160" text-anchor="middle" font-size="12" fill="${palette.muted}">有效合作企业</text>
${evidence.composition
.map(
(item, index) =>
`<circle cx="410" cy="${74 + index * 39}" r="6" fill="${colors[index]}"/>
<text x="425" y="${79 + index * 39}" font-size="13" fill="${palette.ink}">${item.name}</text>
<text x="595" y="${79 + index * 39}" text-anchor="end" font-size="13" font-weight="700" fill="${palette.ink}">${item.value} 家</text>`,
)
.join("")}
`,
300,
"合作企业产业结构",
);
}
function quadrantSvg(evidence) {
const x = (value) => 70 + ((value - 40) / 60) * 540;
const y = (value) => 245 - ((value - 40) / 60) * 200;
return svgFrame(
`
<rect x="${x(70)}" y="45" width="${610 - x(70)}" height="${y(75) - 45}" fill="#eaf5ee"/>
<rect x="70" y="45" width="${x(70) - 70}" height="${y(75) - 45}" fill="#fff6e7"/>
<rect x="${x(70)}" y="${y(75)}" width="${610 - x(70)}" height="${245 - y(75)}" fill="#f2f6f3"/>
<rect x="70" y="${y(75)}" width="${x(70) - 70}" height="${245 - y(75)}" fill="#fff0ed"/>
${[40, 55, 70, 85, 100]
.map(
(tick) =>
`<line x1="${x(tick)}" y1="45" x2="${x(tick)}" y2="245" stroke="#dfe7e1"/><line x1="70" y1="${y(tick)}" x2="610" y2="${y(tick)}" stroke="#dfe7e1"/>`,
)
.join("")}
<line x1="${x(70)}" y1="45" x2="${x(70)}" y2="245" stroke="${palette.amber}" stroke-width="1.5" stroke-dasharray="6 4"/>
<line x1="70" y1="${y(75)}" x2="610" y2="${y(75)}" stroke="${palette.amber}" stroke-width="1.5" stroke-dasharray="6 4"/>
<text x="595" y="63" text-anchor="end" font-size="12" font-weight="700" fill="${palette.green}">优势巩固区</text>
<text x="82" y="63" font-size="12" font-weight="700" fill="#a26b18">高优补强区</text>
${evidence.quadrant
.map((item, index) => {
const radius = 11 + Math.sqrt(item.sample) / 8;
const color = item.coverage < 70 && item.demand >= 75 ? palette.red : palette.green2;
return `<circle cx="${x(item.coverage)}" cy="${y(item.demand)}" r="${radius}" fill="${color}" fill-opacity=".76" stroke="white" stroke-width="2"/>
<text x="${x(item.coverage)}" y="${y(item.demand) - radius - 5}" text-anchor="middle" font-size="11" font-weight="600" fill="${palette.ink}">${item.name.slice(0, 7)}</text>`;
})
.join("")}
<text x="340" y="282" text-anchor="middle" font-size="12" fill="${palette.muted}">课程技能覆盖度 →</text>
<text x="18" y="145" text-anchor="middle" font-size="12" fill="${palette.muted}" transform="rotate(-90 18 145)">企业需求强度 →</text>
`,
300,
"技能优势短板象限图",
);
}
function isometricSvg(evidence) {
const dims = evidence.dimensions.slice(0, 4);
const measures = ["需求", "课程", "达成", "确认"];
const colors = [palette.green, palette.blue, palette.amber, palette.violet];
const bars = [];
dims.forEach((item, xIndex) => {
[item.demand, item.coverage, item.attainment, item.confirmed].forEach((value, yIndex) => {
const x = 100 + xIndex * 125 + yIndex * 15;
const y = 238 - yIndex * 19;
const height = value * 1.55;
const color = colors[yIndex];
bars.push(`
<polygon points="${x},${y} ${x + 34},${y} ${x + 34},${y - height} ${x},${y - height}" fill="${color}" fill-opacity=".8"/>
<polygon points="${x + 34},${y} ${x + 43},${y - 9} ${x + 43},${y - height - 9} ${x + 34},${y - height}" fill="${color}" fill-opacity=".55"/>
<polygon points="${x},${y - height} ${x + 34},${y - height} ${x + 43},${y - height - 9} ${x + 9},${y - height - 9}" fill="${color}"/>
`);
});
});
return svgFrame(
`
<polygon points="72,238 575,238 635,162 132,162" fill="#f2f6f3" stroke="#dfe7e1"/>
${bars.join("")}
${dims
.map(
(item, index) =>
`<text x="${120 + index * 125}" y="275" text-anchor="middle" font-size="12" fill="${palette.ink}">${item.name.slice(0, 7)}</text>`,
)
.join("")}
${measures
.map(
(item, index) =>
`<rect x="${475 + (index % 2) * 92}" y="${18 + Math.floor(index / 2) * 24}" width="10" height="10" rx="2" fill="${colors[index]}"/>
<text x="${491 + (index % 2) * 92}" y="${28 + Math.floor(index / 2) * 24}" font-size="11" fill="${palette.muted}">${item}</text>`,
)
.join("")}
`,
300,
"岗位需求与课程覆盖对照图",
);
}
function chartCard(report, key, figure, title, caption, fallback, options = {}) {
const evidence = buildEvidence(report);
const interactive = options.interactive !== false;
const wideClass = options.wide ? " wide" : "";
return `
<figure class="report-chart-card${wideClass}">
<div class="report-chart-head">
<span>FIG ${figure}</span>
<strong>${title}</strong>
</div>
<div class="report-chart-stage${interactive ? "" : " static"}">
${interactive ? `<div class="report-echart" data-report-chart="${key}" role="img" aria-label="${title}"></div>` : ""}
<div class="report-chart-fallback">${fallback}</div>
</div>
<figcaption>
<span class="report-evidence-code">证据 ${evidence.evidenceCodes[key]}</span>
${caption}
</figcaption>
</figure>
`;
}
function renderExecutiveCharts(report, options = {}) {
const evidence = buildEvidence(report);
return `
<div class="report-chart-grid">
${chartCard(
report,
"overall",
"1-1",
`${evidence.scoreLabel}与区域排名`,
`本期得分使用快照 ${evidence.snapshot.id} 中的 ${evidence.snapshot.records} 条有效记录计算;区域排名采用 ${evidence.benchmark.sampleText}。`,
gaugeSvg(evidence),
options,
)}
${chartCard(
report,
"benchmark",
"1-2",
"学校值、区域均值与同类均值",
`区域基准样本为 ${evidence.benchmark.schoolCount} 所院校,同类基准为 ${evidence.benchmark.peerCount} 所院校;仅比较口径一致且样本量达阈值的指标。`,
groupedBarSvg(evidence),
options,
)}
</div>
`;
}
function renderProfileEvidence(report, options = {}) {
const evidence = buildEvidence(report);
return `
<div class="report-profile-banner">
<div>
<span class="report-profile-kicker">INSTITUTION FACTS · 学校现状数据</span>
<h3>${evidence.profile.name}</h3>
<p>学校现状指标只使用校方业务数据;产业需求使用厂商订阅数据,企业确认使用合作授权数据。三类数据分开标识,不用互联网招聘信息替代学校业务数据。</p>
</div>
<div class="report-profile-attributes">
${evidence.profile.attributes.map(([label, value]) => `<span>${label}<strong>${value}</strong></span>`).join("")}
</div>
</div>
<div class="report-profile-stat-grid">
${evidence.profile.stats
.map(
(item) => `
<article>
<span class="report-profile-code">${item.code}</span>
<span class="report-profile-label">${item.label}</span>
<strong>${item.value}<small>${item.unit}</small></strong>
<em>${item.note}</em>
<details>
<summary>查看证据口径</summary>
<div>
<span>来源<strong>${item.source}</strong></span>
<span>更新时间<strong>${item.updated}</strong></span>
<span>责任部门<strong>${item.owner}</strong></span>
<p>${item.definition}</p>
</div>
</details>
</article>
`,
)
.join("")}
</div>
<div class="report-chart-grid">
${chartCard(
report,
"composition",
"2-1",
"学校合作企业产业结构",
"企业须同时具备有效协议和近12个月业务记录;126家合作企业都能找到对应的协议、需求、课程、项目、实习或就业记录。",
donutSvg(evidence),
options,
)}
${chartCard(
report,
"radar",
"2-2",
"企业需求与课程技能覆盖",
"需求强度来自77,520条去重岗位和8,350条企业确认记录;课程覆盖来自1,286门次课程标准的技能映射,映射覆盖率94.8%。",
radarSvg(evidence),
options,
)}
</div>
`;
}
function renderSupplyVisuals(report, options = {}) {
const evidence = buildEvidence(report);
return `
<div class="report-chart-grid">
${chartCard(
report,
"trend",
"4-1",
"产业需求与课程调整趋势",
"需求序列按每月新增岗位、企业确认和薪资变化计算;课程调整序列按课程覆盖、实训项目和学生考核结果计算,二者均以2025年二季度为对比起点。",
trendSvg(evidence),
options,
)}
${chartCard(
report,
"matrix",
"4-2",
"技能供需证据热力矩阵",
"每个单元格都列出对应的岗位样本、课程标准、学生项目结果和企业确认记录;低于60分时需要业务负责人复核。",
heatmapSvg(evidence),
options,
)}
${chartCard(
report,
"quadrant",
"4-3",
"技能优势与短板优先级",
"横轴为课程技能覆盖,纵轴为企业需求强度,气泡面积代表有效岗位样本量。左上象限为高需求、低覆盖的优先补强区。",
quadrantSvg(evidence),
options,
)}
${chartCard(
report,
"threeD",
"4-4",
"岗位需求与课程覆盖数据表",
"立体柱高同时呈现岗位需求、课程覆盖、学生达成和企业确认四个观察面。在线版支持拖拽旋转,下载版保留固定等距视图。",
isometricSvg(evidence),
{ ...options, wide: false },
)}
</div>
`;
}
function renderEvidenceLedger(report) {
const evidence = buildEvidence(report);
return `
<div class="report-evidence-summary">
<span><small>本报告快照</small><strong>${evidence.snapshot.id}</strong></span>
<span><small>有效记录</small><strong>${evidence.snapshot.records}</strong></span>
<span><small>质量评分</small><strong>${evidence.snapshot.quality}</strong></span>
<span><small>技能映射覆盖</small><strong>${evidence.snapshot.coverage}</strong></span>
</div>
<div class="report-proof-chain">
<span><i>01</i><strong>原始记录</strong><small>保留来源、权属、时间戳</small></span>
<b></b>
<span><i>02</i><strong>数据处理</strong><small>去重、统一名称、降低异常数据权重</small></span>
<b></b>
<span><i>03</i><strong>指标计算</strong><small>样本阈值与基准校验</small></span>
<b></b>
<span><i>04</i><strong>结论复核</strong><small>AI生成、业务负责人确认</small></span>
</div>
<div class="report-table-wrap">
<table class="report-table report-evidence-table">
<thead><tr><th>证据编号</th><th>数据资产 / 来源</th><th>有效样本</th><th>更新时间</th><th>权属责任</th><th>置信度</th><th>已知限制</th></tr></thead>
<tbody>
${evidence.sources
.map(
(source) => `
<tr>
<td><strong>${source.code}</strong></td>
<td><span class="report-source-tag">${source.asset}</span><small>${source.source}</small></td>
<td>${source.sample}</td>
<td>${source.updated}</td>
<td>${source.owner}</td>
<td><span class="report-confidence">${source.confidence}</span></td>
<td>${source.limitation}</td>
</tr>
`,
)
.join("")}
</tbody>
</table>
</div>
<div class="report-evidence-notice">
<strong>证据使用边界</strong>
<p>校内学生、课程、教师、实习和就业数据以学校业务系统为准;互联网岗位只用于判断外部需求变化;企业数据只在协议授权范围内用于确认需求。任何单一来源都不能直接决定专业撤并或招生调整。</p>
</div>
`;
}
function chartOption(kind, evidence) {
const baseText = {
color: palette.ink,
fontFamily: '"PingFang SC","Microsoft YaHei",sans-serif',
};
const axisLine = { lineStyle: { color: "#dfe7e1" } };
const axisLabel = { color: palette.muted, fontSize: 13 };
const splitLine = { lineStyle: { color: "#e8eee9" } };
if (kind === "overall") {
return {
animationDuration: 800,
series: [
{
type: "gauge",
startAngle: 210,
endAngle: -30,
min: 0,
max: 100,
splitNumber: 5,
center: ["50%", "58%"],
radius: "82%",
progress: { show: true, width: 18, roundCap: true, itemStyle: { color: palette.green } },
axisLine: {
roundCap: true,
lineStyle: {
width: 18,
color: [
[0.6, "#f2d7a5"],
[0.8, "#9bc8b0"],
[1, "#d9eba5"],
],
},
},
pointer: {
show: true,
length: "56%",
width: 5,
itemStyle: { color: palette.ink },
},
anchor: { show: true, size: 12, itemStyle: { color: palette.ink } },
axisTick: { distance: -25, splitNumber: 2, length: 5, lineStyle: { color: "#82948a" } },
splitLine: { distance: -27, length: 10, lineStyle: { color: "#82948a", width: 1 } },
axisLabel: {
distance: -43,
color: palette.muted,
fontSize: 13,
formatter: (value) => (value === 0 || value === 100 ? "" : value),
},
title: { offsetCenter: [0, "71%"], color: palette.muted, fontSize: 13 },
detail: {
valueAnimation: true,
offsetCenter: [0, "24%"],
fontSize: 42,
fontWeight: 800,
color: palette.ink,
formatter: "{value}",
},
data: [{ value: evidence.score.toFixed(1), name: `${evidence.scoreLabel} · 排名 ${evidence.benchmark.rank}/${evidence.benchmark.schoolCount}` }],
},
],
};
}
if (kind === "benchmark") {
return {
animationDuration: 700,
color: [palette.green, palette.blue, palette.amber],
tooltip: { trigger: "axis", axisPointer: { type: "shadow" } },
legend: { top: 2, textStyle: baseText, itemWidth: 12, itemHeight: 8 },
grid: { left: 105, right: 25, top: 48, bottom: 24 },
xAxis: { type: "value", max: 100, axisLine, axisLabel, splitLine },
yAxis: {
type: "category",
inverse: true,
data: evidence.dimensions.map((item) => item.name),
axisLine,
axisTick: { show: false },
axisLabel: { ...axisLabel, width: 88, overflow: "truncate" },
},
series: [
{ name: "本校/本专业", type: "bar", data: evidence.dimensions.map((item) => item.school), barMaxWidth: 9 },
{ name: "区域均值", type: "bar", data: evidence.dimensions.map((item) => item.regional), barMaxWidth: 9 },
{ name: "同类均值", type: "bar", data: evidence.dimensions.map((item) => item.peer), barMaxWidth: 9 },
],
};
}
if (kind === "composition") {
return {
animationDuration: 800,
color: [palette.green, palette.blue, palette.amber, palette.violet, palette.mint],
tooltip: { trigger: "item", formatter: "{b}<br/>{c} 家 · {d}%" },
legend: { type: "scroll", orient: "vertical", right: 12, top: "center", textStyle: baseText },
graphic: [
{ type: "text", left: "28%", top: "42%", style: { text: "126", fill: palette.ink, font: "800 30px sans-serif", textAlign: "center" } },
{ type: "text", left: "25%", top: "55%", style: { text: "合作企业", fill: palette.muted, font: "12px sans-serif", textAlign: "center" } },
],
series: [
{
type: "pie",
radius: ["45%", "70%"],
center: ["32%", "51%"],
roseType: "radius",
itemStyle: { borderColor: "#fff", borderWidth: 3, borderRadius: 5 },
label: { show: false },
data: evidence.composition,
},
],
};
}
if (kind === "radar") {
return {
animationDuration: 800,
color: [palette.green, palette.amber],
tooltip: { trigger: "item" },
legend: { top: 3, textStyle: baseText },
radar: {
center: ["50%", "55%"],
radius: "63%",
indicator: evidence.dimensions.map((item) => ({ name: item.name, max: 100 })),
axisName: { color: palette.muted, fontSize: 13 },
splitNumber: 5,
splitArea: { areaStyle: { color: ["#fbfcfb", "#f4f8f5"] } },
splitLine,
axisLine: { lineStyle: { color: "#dfe7e1" } },
},
series: [
{
type: "radar",
symbolSize: 6,
lineStyle: { width: 2.5 },
areaStyle: { opacity: 0.14 },
data: [
{ name: "企业需求强度", value: evidence.dimensions.map((item) => item.demand) },
{ name: "课程技能覆盖", value: evidence.dimensions.map((item) => item.coverage) },
],
},
],
};
}
if (kind === "trend") {
return {
animationDuration: 800,
color: [palette.green, palette.amber],
tooltip: { trigger: "axis" },
legend: { top: 2, textStyle: baseText },
grid: { left: 44, right: 18, top: 45, bottom: 38 },
xAxis: { type: "category", boundaryGap: false, data: evidence.trend.labels, axisLine, axisLabel },
yAxis: { type: "value", min: 40, max: 100, axisLine, axisLabel, splitLine },
dataZoom: [{ type: "inside" }],
series: [
{
name: "产业需求",
type: "line",
smooth: true,
symbolSize: 7,
data: evidence.trend.demand,
lineStyle: { width: 3 },
areaStyle: {
color: {
type: "linear",
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: "rgba(23,107,79,.28)" },
{ offset: 1, color: "rgba(23,107,79,.02)" },
],
},
},
},
{
name: "课程调整",
type: "line",
smooth: true,
symbolSize: 7,
data: evidence.trend.response,
lineStyle: { width: 3, type: "dashed" },
},
],
};
}
if (kind === "matrix") {
return {
animationDuration: 700,
tooltip: {
position: "top",
formatter: (params) =>
`${evidence.dimensions[params.value[1]].name}<br/>${["岗位需求", "课程覆盖", "学生达成", "企业确认"][params.value[0]]}:${params.value[2]}`,
},
grid: { left: 105, right: 46, top: 26, bottom: 65 },
xAxis: {
type: "category",
data: ["岗位需求", "课程覆盖", "学生达成", "企业确认"],
splitArea: { show: true },
axisLine,
axisLabel: { ...axisLabel, interval: 0, rotate: 18 },
},
yAxis: {
type: "category",
data: evidence.dimensions.map((item) => item.name),
splitArea: { show: true },
axisLine,
axisLabel: { ...axisLabel, width: 88, overflow: "truncate" },
},
visualMap: {
min: 40,
max: 100,
calculable: true,
orient: "horizontal",
left: "center",
bottom: 2,
inRange: { color: ["#dc897d", "#ead09b", "#9fc7ad", "#176b4f"] },
textStyle: axisLabel,
itemWidth: 12,
itemHeight: 110,
},
series: [
{
type: "heatmap",
data: evidence.heatmap,
label: { show: true, color: palette.ink, fontWeight: 700 },
itemStyle: { borderColor: "#fff", borderWidth: 3, borderRadius: 5 },
emphasis: { itemStyle: { shadowBlur: 10, shadowColor: "rgba(0,0,0,.2)" } },
},
],
};
}
if (kind === "quadrant") {
return {
animationDuration: 800,
tooltip: {
formatter: (params) =>
`${params.data[3]}<br/>课程覆盖:${params.data[0]}<br/>需求强度:${params.data[1]}<br/>岗位样本:${params.data[2]}条`,
},
grid: { left: 48, right: 24, top: 25, bottom: 46 },
xAxis: { name: "课程技能覆盖度", min: 40, max: 100, axisLine, axisLabel, splitLine },
yAxis: { name: "企业需求强度", min: 40, max: 100, axisLine, axisLabel, splitLine },
visualMap: {
show: false,
min: 200,
max: 2000,
dimension: 2,
inRange: { color: [palette.mint, palette.green], symbolSize: [16, 48] },
},
series: [
{
type: "scatter",
data: evidence.quadrant.map((item) => [item.coverage, item.demand, item.sample, item.name]),
label: { show: true, position: "top", formatter: (params) => params.data[3], color: palette.ink, fontSize: 13 },
itemStyle: { borderColor: "#fff", borderWidth: 2, opacity: 0.86 },
markLine: {
silent: true,
symbol: "none",
lineStyle: { color: palette.amber, type: "dashed" },
label: { color: palette.muted, fontSize: 13 },
data: [{ xAxis: 70, name: "覆盖阈值" }, { yAxis: 75, name: "需求阈值" }],
},
},
],
};
}
if (kind === "threeD") {
const axis3D = {
type: "category",
axisLabel: { color: palette.muted, fontSize: 13, interval: 0 },
axisLine: { lineStyle: { color: "#bfcac3" } },
splitLine: { lineStyle: { color: "#e2e9e4" } },
};
const values = [];
evidence.dimensions.slice(0, 5).forEach((item, xIndex) => {
[item.demand, item.coverage, item.attainment, item.confirmed].forEach((value, yIndex) => {
values.push([xIndex, yIndex, value]);
});
});
return {
animationDuration: 900,
tooltip: {
formatter: (params) =>
`${evidence.dimensions[params.value[0]].name}<br/>${["岗位需求", "课程覆盖", "学生达成", "企业确认"][params.value[1]]}:${params.value[2]}`,
},
visualMap: {
min: 40,
max: 100,
calculable: true,
orient: "vertical",
right: 0,
top: "center",
inRange: { color: ["#e1a263", "#90bda2", "#176b4f", "#c6e66f"] },
textStyle: axisLabel,
itemWidth: 12,
itemHeight: 110,
},
xAxis3D: { ...axis3D, data: evidence.dimensions.slice(0, 5).map((item) => item.name) },
yAxis3D: { ...axis3D, data: ["岗位需求", "课程覆盖", "学生达成", "企业确认"] },
zAxis3D: {
type: "value",
min: 0,
max: 100,
axisLabel: { color: palette.muted, fontSize: 13 },
axisLine: { lineStyle: { color: "#bfcac3" } },
splitLine: { lineStyle: { color: "#e2e9e4" } },
},
grid3D: {
boxWidth: 125,
boxDepth: 72,
boxHeight: 72,
environment: "#fbfcfb",
light: { main: { intensity: 1.2, shadow: true }, ambient: { intensity: 0.55 } },
viewControl: { alpha: 24, beta: 35, distance: 180, autoRotate: false, rotateSensitivity: 1 },
},
series: [
{
type: "bar3D",
data: values,
shading: "lambert",
bevelSize: 0.3,
bevelSmoothness: 3,
itemStyle: { opacity: 0.94 },
emphasis: { itemStyle: { color: palette.lime } },
},
],
};
}
return {};
}
function dispose(root) {
const scope = root || document;
scope.querySelectorAll?.("[data-report-chart]").forEach((element) => {
const instance = chartInstances.get(element);
if (!instance) return;
resizeObserver?.unobserve(element);
instance.dispose();
chartInstances.delete(element);
});
}
function init(report, root) {
if (!window.echarts || !root) return;
const evidence = buildEvidence(report);
root.querySelectorAll("[data-report-chart]").forEach((element) => {
const kind = element.dataset.reportChart;
let instance;
try {
instance = window.echarts.init(element, null, {
renderer: "canvas",
devicePixelRatio: Math.min(window.devicePixelRatio || 1, 2),
});
instance.setOption(chartOption(kind, evidence), true);
chartInstances.set(element, instance);
resizeObserver?.observe(element);
element.closest(".report-chart-stage")?.classList.add("is-interactive");
} catch (error) {
instance?.dispose();
console.warn(`图表 ${kind} 使用静态降级视图`, error);
}
});
}
function resizeAll() {
chartInstances.forEach((instance) => instance.resize());
}
window.addEventListener("resize", resizeAll, { passive: true });
window.ReportVisuals = Object.freeze({
buildEvidence,
renderExecutiveCharts,
renderProfileEvidence,
renderSupplyVisuals,
renderEvidenceLedger,
init,
dispose,
resizeAll,
});
})();