Initial commit

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
lofyer
2026-07-13 15:38:41 +08:00
co-authored by factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
commit 71db82393a
180 changed files with 170640 additions and 0 deletions
@@ -0,0 +1,34 @@
{# 图表通用常量 + i18n 词典 #}
<script>
const COLORS = {
primary: '#2563eb',
primaryLight: '#93bbfd',
success: '#059669',
warning: '#d97706',
danger: '#dc2626',
gray: '#d1d5db',
grayDark: '#6b7280',
cluster_good: '#2563eb',
cluster_weak: '#f59e0b',
level1: '#ef4444',
level2: '#f59e0b',
level3: '#3b82f6',
level4: '#10b981',
};
// ECharts i18n bundle (injected from Python via Jinja)
const I18N = {{ ec_i18n | tojson }};
const LANG = '{{ lang | default("zh") }}';
// helper:维度名翻译(中文 key → 当前 lang 显示值)
const DIM_T = {{ dim_translation_map | tojson }};
const SUB_T = {{ sub_translation_map | tojson }};
function tDim(name) { return DIM_T[name] || name; }
function tSub(name) { return SUB_T[name] || name; }
// 短名(去掉"力"等后缀)—— 中文版才简化,英文不变
function shortDim(name) {
var n = tDim(name);
if (LANG === 'zh') return n.replace('力', '').replace('教育', '');
return n;
}
</script>
@@ -0,0 +1,204 @@
{# 各维度详细图表:得分柱状图、子维度雷达图、对比柱状图、水平分布、散点图 #}
<script>
// ===== 8. 各维度独立得分柱状图 =====
const dimScoreBars = {{ dim_score_bar_data | tojson }};
Object.keys(dimScoreBars).forEach(partId => {
const el = document.getElementById('dim-score-' + partId);
if (!el) return;
const chart = echarts.init(el);
const d = dimScoreBars[partId];
const option = {
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
grid: { left: '8%', right: '8%', bottom: '12%', top: '8%', containLabel: true },
xAxis: { type: 'category', data: [d.school_name, I18N.ec_district_avg, I18N.ec_same_type], axisLabel: { fontSize: 12 } },
yAxis: { type: 'value', min: 30, max: 80 },
series: [{
type: 'bar', barWidth: '40%',
data: [
{ value: d.school_score, itemStyle: { color: COLORS.primary } },
{ value: d.district_avg, itemStyle: { color: COLORS.gray } },
{ value: d.same_type_avg, itemStyle: { color: COLORS.warning } },
],
label: { show: true, position: 'top', fontSize: 13, fontWeight: 'bold', formatter: p => p.value.toFixed(2) },
markLine: { silent: true, data: [{ yAxis: 50, label: { formatter: I18N.ec_baseline_50, fontSize: 10 }, lineStyle: { color: '#ef4444', type: 'dashed' } }] },
}],
};
chart.setOption(option);
window.addEventListener('resize', () => chart.resize());
});
// ===== 9. 各维度子维度雷达图 =====
const dimSubRadar = {{ dim_sub_radar_data | tojson }};
Object.keys(dimSubRadar).forEach(partId => {
const el = document.getElementById('sub-radar-' + partId);
if (!el) return;
const chart = echarts.init(el);
const d = dimSubRadar[partId];
if (d.type === 'radar' && d.sub_dims && d.sub_dims.length >= 3) {
const option = {
tooltip: {},
legend: { data: [d.school_name, I18N.ec_district_avg, I18N.ec_same_type], bottom: 5, textStyle: { fontSize: 11 } },
radar: {
indicator: d.sub_dims.map(sd => ({ name: tSub(sd), max: 80 })),
shape: 'polygon', splitNumber: 4, axisName: { color: '#333', fontSize: 10 }, radius: '60%',
},
series: [{
type: 'radar',
data: [
{ value: d.school_values, name: d.school_name,
areaStyle: { opacity: 0.3, color: COLORS.primary },
lineStyle: { width: 3, color: COLORS.primary }, itemStyle: { color: COLORS.primary } },
{ value: d.district_avg, name: I18N.ec_district_avg,
lineStyle: { width: 1, type: 'dashed', color: COLORS.grayDark }, itemStyle: { color: COLORS.grayDark } },
{ value: d.same_type_avg, name: I18N.ec_same_type,
lineStyle: { width: 1, type: 'dotted', color: COLORS.warning }, itemStyle: { color: COLORS.warning } },
],
}],
};
chart.setOption(option);
} else {
const option = {
tooltip: { trigger: 'axis' },
legend: { data: [d.school_name, I18N.ec_district_avg, I18N.ec_same_type], bottom: 5, textStyle: { fontSize: 11 } },
grid: { left: '3%', right: '4%', bottom: '18%', top: '5%', containLabel: true },
xAxis: { type: 'category', data: d.sub_dims.map(tSub), axisLabel: { fontSize: 11 } },
yAxis: { type: 'value', min: 20, max: 80 },
series: [
{ name: d.school_name, type: 'bar', data: d.school_values,
itemStyle: { color: COLORS.primary }, barWidth: '22%',
label: { show: true, position: 'top', fontSize: 10 } },
{ name: I18N.ec_district_avg, type: 'bar', data: d.district_avg, itemStyle: { color: COLORS.gray }, barWidth: '22%' },
{ name: I18N.ec_same_type, type: 'bar', data: d.same_type_avg, itemStyle: { color: COLORS.warning }, barWidth: '22%' },
],
};
chart.setOption(option);
}
window.addEventListener('resize', () => chart.resize());
});
// ===== 10. 各维度子维度柱状图 =====
const subDimCharts = {{ sub_dim_chart_data | tojson }};
const SCHOOL_DISPLAY = {{ school_display | tojson }};
Object.keys(subDimCharts).forEach(chartId => {
const el = document.getElementById('chart-' + chartId);
if (!el) return;
const chart = echarts.init(el);
const d = subDimCharts[chartId];
const option = {
tooltip: { trigger: 'axis' },
legend: { data: [SCHOOL_DISPLAY, I18N.ec_district_avg], bottom: 5 },
grid: { left: '3%', right: '4%', bottom: '18%', containLabel: true },
xAxis: { type: 'category', data: d.categories.map(tSub), axisLabel: { rotate: 15, fontSize: 11 } },
yAxis: { type: 'value', min: 20, max: 80 },
series: [
{ name: SCHOOL_DISPLAY, type: 'bar', data: d.school_values,
itemStyle: { color: COLORS.primary }, barWidth: '28%',
label: { show: true, position: 'top', fontSize: 10 } },
{ name: I18N.ec_district_avg, type: 'bar', data: d.avg_values, itemStyle: { color: COLORS.gray }, barWidth: '28%' },
{ name: I18N.ec_baseline_50, type: 'line', data: d.categories.map(() => 50),
lineStyle: { color: '#ef4444', type: 'dashed', width: 1 }, symbol: 'none' },
],
};
chart.setOption(option);
window.addEventListener('resize', () => chart.resize());
});
// ===== 11. 水平分布堆叠条形图 =====
const levelDistData = {{ level_dist_data | tojson }};
Object.keys(levelDistData).forEach(chartId => {
const el = document.getElementById('level-' + chartId);
if (!el) return;
const chart = echarts.init(el);
const d = levelDistData[chartId];
if (!d.categories || d.categories.length === 0) return;
const localizedCats = d.categories.map(tSub);
const option = {
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' },
formatter: function(params) {
let s = params[0].axisValue + '<br/>';
params.forEach(p => { s += p.marker + ' ' + p.seriesName + ': ' + p.value + '%<br/>'; });
const idx = localizedCats.indexOf(params[0].axisValue);
if (idx >= 0) s += '<strong>' + I18N.ec_at_level.replace('{name}', SCHOOL_DISPLAY).replace('{lv}', d.school_levels[idx]) + '</strong>';
return s;
}
},
legend: { data: [I18N.ec_lvl_one, I18N.ec_lvl_two, I18N.ec_lvl_three, I18N.ec_lvl_four], bottom: 5, textStyle: { fontSize: 11 } },
grid: { left: '3%', right: '4%', bottom: '18%', top: '5%', containLabel: true },
xAxis: { type: 'category', data: localizedCats, axisLabel: { fontSize: 11, rotate: 15 } },
yAxis: { type: 'value', max: 100, axisLabel: { formatter: '{value}%' } },
series: [
{ name: I18N.ec_lvl_one, type: 'bar', stack: 'total', data: d.level1, itemStyle: { color: COLORS.level1 }, barWidth: '50%' },
{ name: I18N.ec_lvl_two, type: 'bar', stack: 'total', data: d.level2, itemStyle: { color: COLORS.level2 } },
{ name: I18N.ec_lvl_three, type: 'bar', stack: 'total', data: d.level3, itemStyle: { color: COLORS.level3 } },
{ name: I18N.ec_lvl_four, type: 'bar', stack: 'total', data: d.level4, itemStyle: { color: COLORS.level4 } },
],
};
chart.setOption(option);
window.addEventListener('resize', () => chart.resize());
});
// ===== 12. 各维度聚类散点图(2D / 3D =====
const dimScatterData = {{ dim_scatter_data | tojson }};
Object.keys(dimScatterData).forEach(partId => {
const el = document.getElementById('scatter-' + partId);
if (!el) return;
const d = dimScatterData[partId];
if (!d || !d.axes) return;
const chart = echarts.init(el);
const axisLabels = d.axes.map(tSub);
if (d.type === '3d') {
const option = {
tooltip: {
formatter: function(params) {
const v = params.value || (params.data && params.data.value);
if (!v) return '';
return params.seriesName + '<br/>'
+ axisLabels[0] + ': ' + (typeof v[0] === 'number' ? v[0].toFixed(2) : v[0]) + '<br/>'
+ axisLabels[1] + ': ' + (typeof v[1] === 'number' ? v[1].toFixed(2) : v[1]) + '<br/>'
+ axisLabels[2] + ': ' + (typeof v[2] === 'number' ? v[2].toFixed(2) : v[2]);
}
},
legend: { data: [I18N.ec_good_type_short, I18N.ec_weak_type_short, d.school_name], bottom: 5, textStyle: { fontSize: 11 } },
xAxis3D: { name: axisLabels[0], type: 'value', nameTextStyle: { fontSize: 10 } },
yAxis3D: { name: axisLabels[1], type: 'value', nameTextStyle: { fontSize: 10 } },
zAxis3D: { name: axisLabels[2], type: 'value', nameTextStyle: { fontSize: 10 } },
grid3D: {
viewControl: { autoRotate: true, autoRotateSpeed: 5, distance: 200, alpha: 20, beta: 30 },
boxWidth: 100, boxHeight: 100, boxDepth: 100,
light: { main: { intensity: 1.2, shadow: true }, ambient: { intensity: 0.3 } },
},
series: [
{ name: I18N.ec_good_type_short, type: 'scatter3D', data: d.good_data.map(p => ({ value: p })),
symbolSize: 8, itemStyle: { color: COLORS.success, opacity: 0.7 } },
{ name: I18N.ec_weak_type_short, type: 'scatter3D', data: d.weak_data.map(p => ({ value: p })),
symbolSize: 8, itemStyle: { color: COLORS.danger, opacity: 0.7 } },
{ name: d.school_name, type: 'scatter3D',
data: d.school_point ? [{ value: d.school_point }] : [],
symbolSize: 18, itemStyle: { color: COLORS.primary, borderColor: '#fff', borderWidth: 2 },
label: { show: true, formatter: d.school_name, textStyle: { fontSize: 12, fontWeight: 'bold', color: COLORS.primary } } },
],
};
chart.setOption(option);
} else {
const option = {
tooltip: { formatter: p => (p.data && p.data.name) ? p.data.name : axisLabels[0] + ': ' + p.value[0] + '<br/>' + axisLabels[1] + ': ' + p.value[1] },
legend: { data: [I18N.ec_good_type_short, I18N.ec_weak_type_short, d.school_name], bottom: 5, textStyle: { fontSize: 11 } },
grid: { left: '10%', right: '8%', bottom: '18%', top: '5%' },
xAxis: { name: axisLabels[0], nameLocation: 'center', nameGap: 30, nameTextStyle: { fontSize: 11 }, splitLine: { show: true, lineStyle: { type: 'dashed' } } },
yAxis: { name: axisLabels[1], nameLocation: 'center', nameGap: 40, nameTextStyle: { fontSize: 11 }, splitLine: { show: true, lineStyle: { type: 'dashed' } } },
series: [
{ name: I18N.ec_good_type_short, type: 'scatter', data: d.good_data, symbolSize: 10, itemStyle: { color: COLORS.success, opacity: 0.6 } },
{ name: I18N.ec_weak_type_short, type: 'scatter', data: d.weak_data, symbolSize: 10, itemStyle: { color: COLORS.danger, opacity: 0.6 } },
{ name: d.school_name, type: 'scatter',
data: d.school_point ? [{ value: d.school_point, name: d.school_name, symbolSize: 22 }] : [],
symbolSize: 22, itemStyle: { color: COLORS.primary, borderColor: '#fff', borderWidth: 3 },
label: { show: true, formatter: d.school_name, position: 'top', textStyle: { fontSize: 13, fontWeight: 'bold', color: COLORS.primary } } },
],
};
chart.setOption(option);
}
window.addEventListener('resize', () => chart.resize());
});
</script>
@@ -0,0 +1,322 @@
<script>
// ===== A. 学校画像卡(仪表盘 + 红绿灯) =====
const profileData = {{ profile_card_data | tojson }};
if (profileData && profileData.total_score) {
(function() {
const chart = echarts.init(document.getElementById('profile-card-chart'));
const score = profileData.total_score;
const districtAvg = profileData.district_avg;
const signals = profileData.dim_signals;
const levelCounts = profileData.level_counts;
const signalColor = function(minLevel) {
if (minLevel >= 4) return '#10b981';
if (minLevel >= 3) return '#3b82f6';
if (minLevel >= 2) return '#f59e0b';
return '#ef4444';
};
const subtitle = (profileData.school_type ? (tDim(profileData.school_type) || profileData.school_type) + ' · ' : '')
+ I18N.ec_district_rank_n.replace('{rank}', profileData.rank);
const option = {
title: [
{ text: profileData.school_name, left: 'center', top: 6, textStyle: { fontSize: 16, fontWeight: 'bold', color: '#1e3a5f' } },
{ text: subtitle, left: 'center', top: 28, textStyle: { fontSize: 11, color: '#6b7280' } },
],
series: [
{
type: 'gauge',
center: ['50%', '60%'],
radius: '55%',
startAngle: 210,
endAngle: -30,
min: 30,
max: 70,
splitNumber: 4,
itemStyle: { color: score >= districtAvg ? '#2563eb' : '#f59e0b' },
progress: { show: true, width: 18, roundCap: true },
pointer: { show: false },
axisLine: { lineStyle: { width: 18, color: [[0.25, '#fee2e2'], [0.5, '#fef3c7'], [0.75, '#dbeafe'], [1, '#d1fae5']] } },
axisTick: { show: false },
splitLine: { show: false },
axisLabel: { show: true, distance: 25, fontSize: 10, color: '#6b7280',
formatter: function(v) { return v.toFixed(0); } },
detail: {
valueAnimation: true, offsetCenter: [0, '-5%'],
fontSize: 36, fontWeight: 'bold', color: '#1e3a5f',
formatter: function(v) { return v.toFixed(1); },
},
data: [{ value: score }],
},
{ type: 'pie', center: ['50%', '92%'], radius: ['0%', '0%'], silent: true, data: [] },
],
graphic: (function() {
var items = [];
var startX = 50 - (signals.length - 1) * 6;
signals.forEach(function(sig, i) {
var x = startX + i * 12;
items.push({
type: 'circle', shape: { cx: 0, cy: 0, r: 8 },
left: x + '%', top: '78%',
style: { fill: signalColor(sig.min_level), shadowBlur: 6, shadowColor: signalColor(sig.min_level) },
});
items.push({
type: 'text',
left: x + '%', top: '84%',
style: { text: shortDim(sig.name), textAlign: 'center', fontSize: 9, fill: '#374151' },
});
});
var summaryText = I18N.ec_level_dist_summary + ': ';
summaryText += I18N.ec_lv_short + '4=' + levelCounts[4] + ' '
+ I18N.ec_lv_short + '3=' + levelCounts[3] + ' '
+ I18N.ec_lv_short + '2=' + levelCounts[2] + ' '
+ I18N.ec_lv_short + '1=' + levelCounts[1];
items.push({
type: 'text', left: 'center', top: '93%',
style: { text: summaryText, textAlign: 'center', fontSize: 10, fill: '#6b7280' },
});
return items;
})(),
};
chart.setOption(option);
window.addEventListener('resize', () => chart.resize());
})();
}
// ===== B. 优势-短板象限图 =====
const quadrantData = {{ quadrant_data | tojson }};
if (quadrantData && quadrantData.items && quadrantData.items.length > 0) {
(function() {
const chart = echarts.init(document.getElementById('quadrant-chart'));
const items = quadrantData.items;
const dimColors = {
'课程领导力': '#2563eb', '教学变革力': '#7c3aed', '学生发展指导力': '#059669',
'教师发展支持力': '#d97706', '教育质量评估力': '#dc2626',
'教育条件保障力': '#0891b2', '数字化赋能力': '#be185d',
};
const scatterData = items.map(function(item) {
return {
value: [item.score, item.diff],
name: tSub(item.name),
originalName: item.name,
parent: item.parent,
level: item.level,
itemStyle: { color: dimColors[item.parent] || '#6b7280' },
};
});
const option = {
tooltip: {
formatter: function(p) {
var d = p.data;
return '<strong>' + d.name + '</strong> (' + tDim(d.parent) + ')<br/>' +
I18N.ec_quad_score + ': ' + d.value[0] + '<br/>' +
I18N.ec_quad_diff + ': ' + (d.value[1] > 0 ? '+' : '') + d.value[1] + '<br/>' +
I18N.ec_quad_level + ': ' + d.level;
}
},
grid: { left: '12%', right: '5%', bottom: '12%', top: '8%' },
xAxis: {
name: I18N.ec_quad_x_axis, nameLocation: 'center', nameGap: 28,
nameTextStyle: { fontSize: 12 },
min: 20, max: 80,
splitLine: { show: true, lineStyle: { type: 'dashed', color: '#e5e7eb' } },
},
yAxis: {
name: I18N.ec_quad_y_axis, nameLocation: 'center', nameGap: 40,
nameTextStyle: { fontSize: 12 },
splitLine: { show: true, lineStyle: { type: 'dashed', color: '#e5e7eb' } },
},
series: [{
type: 'scatter',
data: scatterData,
symbolSize: function(val, params) { return Math.abs(val[1]) * 1.5 + 10; },
label: {
show: true, position: 'right', fontSize: 9,
formatter: function(p) {
var n = p.data.name;
if (LANG === 'en') {
return n.length > 14 ? n.substring(0, 14) + '…' : n;
}
return n.length > 5 ? n.substring(0, 5) + '…' : n;
},
color: '#374151',
},
}],
graphic: [
{ type: 'text', right: '8%', top: '10%',
style: { text: '■ ' + I18N.ec_quadrant_q1, fill: '#059669', fontSize: 11, fontWeight: 'bold' } },
{ type: 'text', left: '14%', bottom: '14%',
style: { text: '■ ' + I18N.ec_quadrant_q3, fill: '#dc2626', fontSize: 11, fontWeight: 'bold' } },
{ type: 'text', right: '8%', bottom: '14%',
style: { text: '⊙ ' + I18N.ec_quadrant_q4, fill: '#d97706', fontSize: 11, fontWeight: 'bold' } },
{ type: 'text', left: '14%', top: '10%',
style: { text: '↗ ' + I18N.ec_quadrant_q2, fill: '#2563eb', fontSize: 11, fontWeight: 'bold' } },
],
markLine: {},
};
option.series[0].markLine = {
silent: true, symbol: 'none',
lineStyle: { color: '#9ca3af', type: 'dashed', width: 1 },
data: [
{ xAxis: 50, label: { formatter: I18N.ec_quad_district_avg_marker, fontSize: 10, position: 'start' } },
{ yAxis: 0, label: { show: false } },
],
};
chart.setOption(option);
window.addEventListener('resize', () => chart.resize());
})();
}
// ===== C. 温度计条形图 =====
const thermoData = {{ thermometer_data | tojson }};
if (thermoData && thermoData.data) {
(function() {
var partSubMap = {};
{% for dim_name, dim_data in dimensions.items() %}
{% set part_names_map = {"课程领导力": "part3", "教学变革力": "part4", "学生发展指导力": "part5", "教师发展支持力": "part6", "教育质量评估力": "part7", "教育条件保障力": "part8", "数字化赋能力": "part9"} %}
{% set pid = part_names_map[dim_name] %}
{% for sub_dim in framework[dim_name].sub_dimensions %}
{% set sd = sub_dimensions.get(sub_dim, {}) %}
{% if sd %}
partSubMap['{{ pid }}-{{ loop.index }}'] = '{{ sub_dim }}';
{% endif %}
{% endfor %}
{% endfor %}
Object.keys(partSubMap).forEach(function(key) {
var sdName = partSubMap[key];
var d = thermoData.data[sdName];
if (!d) return;
var el = document.getElementById('thermo-' + key);
if (!el) return;
var chart = echarts.init(el);
var score = d.score;
var distAvg = d.district_avg;
var sameTypeAvg = d.same_type_avg;
var th = d.thresholds;
var option = {
grid: { left: '3%', right: '3%', top: '25%', bottom: '25%' },
xAxis: { type: 'value', min: 20, max: 80, axisLabel: { show: false }, axisTick: { show: false }, axisLine: { show: false }, splitLine: { show: false } },
yAxis: { type: 'category', data: [''], axisLabel: { show: false }, axisTick: { show: false }, axisLine: { show: false } },
series: [
{ type: 'bar', data: [th.level2 - 20], stack: 'bg', barWidth: '60%', itemStyle: { color: '#fee2e2', borderRadius: [4, 0, 0, 4] }, silent: true },
{ type: 'bar', data: [th.level3 - th.level2], stack: 'bg', barWidth: '60%', itemStyle: { color: '#fef3c7' }, silent: true },
{ type: 'bar', data: [th.level4 - th.level3], stack: 'bg', barWidth: '60%', itemStyle: { color: '#dbeafe' }, silent: true },
{ type: 'bar', data: [80 - th.level4], stack: 'bg', barWidth: '60%', itemStyle: { color: '#d1fae5', borderRadius: [0, 4, 4, 0] }, silent: true },
],
graphic: (function() {
var items = [];
var toPercent = function(val) { return ((val - 20) / 60 * 94 + 3) + '%'; };
items.push({
type: 'text', left: toPercent(score), top: '2%',
style: { text: I18N.ec_thermo_self_marker + score, textAlign: 'center', fontSize: 11, fontWeight: 'bold', fill: '#1e3a5f' },
});
items.push({
type: 'text', left: toPercent(distAvg), bottom: '0%',
style: { text: I18N.ec_thermo_dist_marker, textAlign: 'center', fontSize: 9, fill: '#6b7280' },
});
if (Math.abs(sameTypeAvg - distAvg) > 1) {
items.push({
type: 'text', left: toPercent(sameTypeAvg), bottom: '0%',
style: { text: I18N.ec_thermo_same_marker, textAlign: 'center', fontSize: 9, fill: '#d97706' },
});
}
return items;
})(),
};
chart.setOption(option);
window.addEventListener('resize', () => chart.resize());
});
})();
}
// ===== D. 进步空间瀑布图 =====
const waterfallData = {{ waterfall_data | tojson }};
if (waterfallData && waterfallData.steps && waterfallData.steps.length > 1) {
(function() {
var el = document.getElementById('waterfall-chart');
if (!el) return;
var chart = echarts.init(el);
var steps = waterfallData.steps;
var categories = [];
var baseData = [];
var gainData = [];
var totalData = [];
var runningBase = 0;
steps.forEach(function(step, i) {
// 名字本地化(gain 项是子维度名,current/potential 是固定文案)
var displayName;
if (step.type === 'current') {
displayName = I18N.ec_waterfall_current;
} else if (step.type === 'potential') {
displayName = I18N.ec_waterfall_potential;
} else {
displayName = tSub(step.name) || step.name;
}
var maxLen = LANG === 'en' ? 14 : 6;
var shortName = displayName.length > maxLen ? displayName.substring(0, maxLen) + '…' : displayName;
categories.push(shortName);
if (step.type === 'current') {
baseData.push(0); gainData.push(0); totalData.push(step.value);
runningBase = step.value;
} else if (step.type === 'gain') {
baseData.push(runningBase); gainData.push(step.value); totalData.push(0);
runningBase += step.value;
} else if (step.type === 'potential') {
baseData.push(0); gainData.push(0); totalData.push(step.value);
}
});
var option = {
tooltip: {
trigger: 'axis', axisPointer: { type: 'shadow' },
formatter: function(params) {
var idx = params[0].dataIndex;
var step = steps[idx];
var detailName = step.type === 'gain' ? (tSub(step.name) || step.name) : step.name;
if (step.type === 'current') return I18N.ec_waterfall_current + ': <strong>' + step.value + '</strong> ' + I18N.ec_waterfall_pts_unit;
if (step.type === 'potential') return I18N.ec_waterfall_potential + ': <strong>' + step.value + '</strong> ' + I18N.ec_waterfall_pts_unit + ' (+' + waterfallData.total_gain + ')';
return '<strong>' + detailName + '</strong><br/>' +
(step.detail || '') + '<br/>' + I18N.ec_waterfall_contrib + ': +' + step.value + ' ' + I18N.ec_waterfall_pts_unit;
},
},
grid: { left: '5%', right: '5%', bottom: '18%', top: '8%', containLabel: true },
xAxis: {
type: 'category', data: categories,
axisLabel: { fontSize: 10, rotate: 20 },
},
yAxis: {
type: 'value',
min: Math.floor(waterfallData.current_total - 2),
max: Math.ceil(waterfallData.potential_total + 2),
axisLabel: { fontSize: 11 },
},
series: [
{ type: 'bar', stack: 'waterfall', data: baseData, itemStyle: { color: 'transparent' }, barWidth: '45%', emphasis: { itemStyle: { color: 'transparent' } } },
{ type: 'bar', stack: 'waterfall', data: gainData,
itemStyle: { color: '#10b981', borderRadius: [4, 4, 0, 0] }, barWidth: '45%',
label: { show: true, position: 'top', fontSize: 10, color: '#059669',
formatter: function(p) { return p.value > 0 ? '+' + p.value.toFixed(2) : ''; } },
},
{ type: 'bar', data: totalData,
itemStyle: { color: function(p) { return p.dataIndex === 0 ? '#2563eb' : '#0891b2'; }, borderRadius: [4, 4, 0, 0] },
barWidth: '45%',
label: { show: true, position: 'top', fontSize: 12, fontWeight: 'bold', color: '#1e3a5f',
formatter: function(p) { return p.value > 0 ? p.value.toFixed(1) : ''; } },
},
],
};
chart.setOption(option);
window.addEventListener('resize', () => chart.resize());
})();
}
</script>
@@ -0,0 +1,214 @@
{# 第一部分:总体概览的所有图表 #}
<script>
// ===== 1. 得分对比横向条形图 =====
const scoreCompare = {{ score_compare_data | tojson }};
(function() {
const chart = echarts.init(document.getElementById('score-compare-chart'));
const cats = scoreCompare.categories.map(tDim);
const option = {
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
legend: { data: [scoreCompare.school_name, I18N.ec_district_avg, I18N.ec_same_type_avg], bottom: 5, textStyle: { fontSize: 12 } },
grid: { left: '3%', right: '8%', bottom: '18%', top: '5%', containLabel: true },
xAxis: { type: 'value', min: 30, max: 80 },
yAxis: { type: 'category', data: cats, axisLabel: { fontSize: 12 } },
series: [
{ name: scoreCompare.school_name, type: 'bar', data: scoreCompare.school_values,
itemStyle: { color: COLORS.primary }, barWidth: '22%',
label: { show: true, position: 'right', fontSize: 11 } },
{ name: I18N.ec_district_avg, type: 'bar', data: scoreCompare.district_avg,
itemStyle: { color: COLORS.gray }, barWidth: '22%' },
{ name: I18N.ec_same_type_avg, type: 'bar', data: scoreCompare.same_type_avg,
itemStyle: { color: COLORS.warning }, barWidth: '22%' },
],
};
chart.setOption(option);
window.addEventListener('resize', () => chart.resize());
})();
// ===== 2. 七维度雷达图 =====
const radarData = {{ radar_data | tojson }};
(function() {
const chart = echarts.init(document.getElementById('radar-chart'));
const option = {
tooltip: {},
legend: { data: radarData.legend, bottom: 10 },
radar: {
indicator: radarData.dimensions.map(d => ({ name: tDim(d), max: 80 })),
shape: 'polygon', splitNumber: 4,
axisName: { color: '#333', fontSize: 12 }, radius: '65%',
},
series: [{
type: 'radar',
data: radarData.series.map((s, i) => ({
value: s.values, name: s.name,
areaStyle: { opacity: i === 0 ? 0.3 : 0.1 },
lineStyle: { width: i === 0 ? 3 : 1 },
})),
}],
};
chart.setOption(option);
window.addEventListener('resize', () => chart.resize());
})();
// ===== 3. 聚类类型分布饼图 =====
const clusterDist = {{ cluster_type_dist_data | tojson }};
if (clusterDist.total) {
(function() {
const chart = echarts.init(document.getElementById('cluster-type-dist-chart'));
const labelFormatter = LANG === 'en' ? '{b}\n{c} ({d}%)' : '{b}\n{c}所 ({d}%)';
const belongsTo = (clusterDist.school_cluster === '较好' || clusterDist.school_cluster === 'High-Performing')
? I18N.ec_cluster_good_short
: I18N.ec_cluster_weak_short;
const centerText = clusterDist.school_name + '\n' + I18N.ec_belongs_to + ' ' + belongsTo;
const option = {
tooltip: { trigger: 'item' },
legend: { bottom: 5, textStyle: { fontSize: 11 } },
series: [{
type: 'pie', radius: ['35%', '60%'], center: ['50%', '45%'],
avoidLabelOverlap: true,
itemStyle: { borderRadius: 8, borderColor: '#fff', borderWidth: 2 },
label: { show: true, formatter: labelFormatter, fontSize: 12 },
emphasis: { label: { show: true, fontSize: 14, fontWeight: 'bold' } },
data: [
{ value: clusterDist.good_count, name: I18N.ec_cluster_good, itemStyle: { color: COLORS.primary } },
{ value: clusterDist.weak_count, name: I18N.ec_cluster_weak, itemStyle: { color: COLORS.warning } },
]
}],
graphic: [{
type: 'text', left: 'center', top: '40%',
style: {
text: centerText,
textAlign: 'center', fill: COLORS.primary, fontSize: 12, fontWeight: 'bold',
}
}],
};
chart.setOption(option);
window.addEventListener('resize', () => chart.resize());
})();
}
// ===== 4. 两类学校折线对比图 =====
const clusterLine = {{ cluster_line_compare_data | tojson }};
if (clusterLine.dimensions) {
(function() {
const chart = echarts.init(document.getElementById('cluster-line-chart'));
const goodLegend = I18N.ec_cluster_good_full.replace('{n}', clusterLine.good_count);
const weakLegend = I18N.ec_cluster_weak_full.replace('{n}', clusterLine.weak_count);
const option = {
tooltip: { trigger: 'axis' },
legend: { data: [goodLegend, weakLegend], bottom: 5, textStyle: { fontSize: 11 } },
grid: { left: '3%', right: '4%', bottom: '18%', top: '8%', containLabel: true },
xAxis: {
type: 'category', data: clusterLine.dimensions.map(shortDim),
axisLabel: { fontSize: 11, rotate: 15 }, boundaryGap: false,
},
yAxis: { type: 'value', min: 40, max: 60 },
series: [
{
name: goodLegend,
type: 'line', data: clusterLine.good_values,
lineStyle: { width: 3, color: COLORS.primary }, itemStyle: { color: COLORS.primary },
symbol: 'circle', symbolSize: 8,
label: { show: true, position: 'top', fontSize: 10, color: COLORS.primary, formatter: p => p.value.toFixed(2) },
},
{
name: weakLegend,
type: 'line', data: clusterLine.weak_values,
lineStyle: { width: 3, color: COLORS.warning }, itemStyle: { color: COLORS.warning },
symbol: 'circle', symbolSize: 8,
label: { show: true, position: 'bottom', fontSize: 10, color: COLORS.warning, formatter: p => p.value.toFixed(2) },
},
],
};
chart.setOption(option);
window.addEventListener('resize', () => chart.resize());
})();
}
// ===== 5. 聚类类型对比雷达图 =====
const clusterRadar = {{ cluster_radar_data | tojson }};
if (clusterRadar.dimensions) {
(function() {
const chart = echarts.init(document.getElementById('cluster-radar-chart'));
const colorMap = [COLORS.cluster_good, COLORS.cluster_weak, COLORS.danger];
const option = {
tooltip: {},
legend: {
data: clusterRadar.legend, bottom: 5, textStyle: { fontSize: 11 },
formatter: function(name) {
if (name === '课程实施较好类' || name === I18N.ec_cluster_good) return I18N.ec_cluster_good_full.replace('{n}', clusterRadar.good_count);
if (name === '课程实施待提升类' || name === I18N.ec_cluster_weak) return I18N.ec_cluster_weak_full.replace('{n}', clusterRadar.weak_count);
return name;
}
},
radar: {
indicator: clusterRadar.dimensions.map(d => ({ name: tDim(d), max: 80 })),
shape: 'polygon', splitNumber: 4,
axisName: { color: '#333', fontSize: 11 }, radius: '60%',
},
series: [{
type: 'radar',
data: clusterRadar.series.map((s, i) => ({
value: s.values, name: s.name,
areaStyle: { opacity: i === 2 ? 0.05 : 0.15 },
lineStyle: { width: i === 2 ? 3 : 2, type: i === 2 ? 'solid' : 'dashed', color: colorMap[i] },
itemStyle: { color: colorMap[i] },
symbol: i === 2 ? 'diamond' : 'circle', symbolSize: i === 2 ? 8 : 4,
})),
}],
};
chart.setOption(option);
window.addEventListener('resize', () => chart.resize());
})();
}
// ===== 6. 区内学校排名条形图 =====
const rankingData = {{ school_ranking_data | tojson }};
if (rankingData.schools) {
(function() {
const chart = echarts.init(document.getElementById('school-ranking-chart'));
const barColors = rankingData.schools.map(s => s === rankingData.current_school ? COLORS.primary : COLORS.gray);
const option = {
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
grid: { left: '3%', right: '12%', bottom: '5%', top: '5%', containLabel: true },
xAxis: { type: 'value', min: 35, max: 65 },
yAxis: { type: 'category', data: rankingData.schools.slice().reverse(),
axisLabel: { fontSize: 12, fontWeight: function(v) { return v === rankingData.current_school ? 'bold' : 'normal'; } } },
series: [{
type: 'bar', barWidth: '50%',
data: rankingData.total_scores.slice().reverse().map((v, i) => ({
value: v, itemStyle: { color: barColors.slice().reverse()[i] },
})),
label: { show: true, position: 'right', fontSize: 12, fontWeight: 'bold', formatter: p => p.value.toFixed(1) },
}],
};
chart.setOption(option);
window.addEventListener('resize', () => chart.resize());
})();
}
// ===== 7. 维度间相关性热力图 =====
const corrData = {{ correlation_data | tojson }};
if (corrData.dimensions) {
(function() {
const chart = echarts.init(document.getElementById('correlation-chart'));
const localizedDims = corrData.dimensions.map(tDim);
const names = (corrData.short_names && LANG === 'zh') ? corrData.short_names : localizedDims;
const option = {
tooltip: { position: 'top', formatter: p => localizedDims[p.value[0]] + ' × ' + localizedDims[p.value[1]] + '<br/>' + I18N.ec_correlation + ': ' + p.value[2] },
grid: { left: '15%', right: '12%', bottom: '18%', top: '3%' },
xAxis: { type: 'category', data: names, axisLabel: { rotate: 30, fontSize: 11 }, splitArea: { show: true } },
yAxis: { type: 'category', data: names, axisLabel: { fontSize: 11 }, splitArea: { show: true } },
visualMap: { min: -1, max: 1, calculable: true, orient: 'vertical', right: 0, top: 'center',
inRange: { color: ['#fee2e2', '#fef3c7', '#f3f4f6', '#dbeafe', '#2563eb'] }, textStyle: { fontSize: 11 } },
series: [{
type: 'heatmap', data: corrData.data,
label: { show: true, fontSize: 11, formatter: p => p.value[2].toFixed(2) },
itemStyle: { borderWidth: 2, borderColor: '#fff' },
}],
};
chart.setOption(option);
window.addEventListener('resize', () => chart.resize());
})();
}
</script>
+793
View File
@@ -0,0 +1,793 @@
{# ===== AI 对话助手组件 ===== #}
{# 需要模板传入: chat_config = { api_key_encoded, api_base_url, model, school_name } #}
{# 以及: report_data_json (完整 report_data 的 JSON 字符串) #}
{% if chat_config %}
<style>
/* ====== Chat Widget Styles ====== */
.chat-fab {
position: fixed;
bottom: 32px;
right: 32px;
width: 56px;
height: 56px;
border-radius: 50%;
background: linear-gradient(135deg, #1e3a5f 0%, #2563eb 100%);
color: white;
border: none;
cursor: pointer;
z-index: 10000;
box-shadow: 0 4px 20px rgba(37, 99, 235, 0.4);
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.3s, box-shadow 0.3s;
font-size: 24px;
}
.chat-fab:hover {
transform: scale(1.08);
box-shadow: 0 6px 28px rgba(37, 99, 235, 0.55);
}
.chat-fab .badge {
position: absolute;
top: -2px; right: -2px;
width: 18px; height: 18px;
background: #ef4444;
border-radius: 50%;
font-size: 11px;
display: flex; align-items: center; justify-content: center;
font-weight: 700;
display: none;
}
/* Chat Dialog */
.chat-dialog {
position: fixed;
bottom: 100px;
right: 32px;
width: 420px;
max-height: 600px;
background: white;
border-radius: 16px;
box-shadow: 0 12px 48px rgba(0,0,0,0.18);
z-index: 10001;
display: none;
flex-direction: column;
overflow: hidden;
animation: chatSlideUp 0.3s ease;
}
.chat-dialog.open {
display: flex;
}
@keyframes chatSlideUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
/* Header */
.chat-header {
background: linear-gradient(135deg, #1e3a5f 0%, #2563eb 100%);
color: white;
padding: 16px 20px;
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
}
.chat-header-left {
display: flex;
align-items: center;
gap: 10px;
}
.chat-header-left .avatar {
width: 32px; height: 32px;
background: rgba(255,255,255,0.2);
border-radius: 50%;
display: flex; align-items: center; justify-content: center;
font-size: 18px;
}
.chat-header-left .title {
font-size: 15px;
font-weight: 600;
}
.chat-header-left .subtitle {
font-size: 11px;
opacity: 0.75;
}
.chat-header-actions {
display: flex;
gap: 8px;
}
.chat-header-actions button {
background: rgba(255,255,255,0.15);
border: none;
color: white;
width: 28px; height: 28px;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
display: flex; align-items: center; justify-content: center;
transition: background 0.2s;
}
.chat-header-actions button:hover {
background: rgba(255,255,255,0.3);
}
/* Messages Area */
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
max-height: 400px;
min-height: 200px;
background: #f8fafc;
}
.chat-messages::-webkit-scrollbar { width: 4px; }
.chat-messages::-webkit-scrollbar-thumb { background: #d1d5db; border-radius: 4px; }
.chat-msg {
display: flex;
gap: 8px;
max-width: 88%;
animation: msgFadeIn 0.3s ease;
}
@keyframes msgFadeIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.chat-msg.user {
align-self: flex-end;
flex-direction: row-reverse;
}
.chat-msg .msg-avatar {
width: 28px; height: 28px;
border-radius: 50%;
display: flex; align-items: center; justify-content: center;
font-size: 14px;
flex-shrink: 0;
}
.chat-msg.assistant .msg-avatar {
background: linear-gradient(135deg, #dbeafe, #bfdbfe);
color: #1e40af;
}
.chat-msg.user .msg-avatar {
background: linear-gradient(135deg, #d1fae5, #a7f3d0);
color: #065f46;
}
.chat-msg .msg-bubble {
padding: 10px 14px;
border-radius: 12px;
font-size: 13.5px;
line-height: 1.7;
word-break: break-word;
}
.chat-msg.assistant .msg-bubble {
background: white;
color: #1f2937;
border: 1px solid #e5e7eb;
border-top-left-radius: 4px;
}
.chat-msg.user .msg-bubble {
background: linear-gradient(135deg, #2563eb, #3b82f6);
color: white;
border-top-right-radius: 4px;
}
.chat-msg .msg-bubble p { text-indent: 0; margin: 4px 0; }
.chat-msg .msg-bubble strong { color: #1a56db; }
.chat-msg.user .msg-bubble strong { color: #bfdbfe; }
.chat-msg .msg-bubble ul, .chat-msg .msg-bubble ol {
margin: 4px 0 4px 1.2em;
padding: 0;
}
.chat-msg .msg-bubble li { margin: 2px 0; }
/* Typing indicator */
.typing-indicator {
display: flex;
gap: 4px;
padding: 8px 14px;
}
.typing-indicator span {
width: 6px; height: 6px;
background: #9ca3af;
border-radius: 50%;
animation: typingBounce 1.2s infinite;
}
.typing-indicator span:nth-child(2) { animation-delay: 0.2s; }
.typing-indicator span:nth-child(3) { animation-delay: 0.4s; }
@keyframes typingBounce {
0%, 60%, 100% { transform: translateY(0); }
30% { transform: translateY(-6px); }
}
/* Quick Suggestions */
.chat-suggestions {
padding: 8px 16px;
display: flex;
flex-wrap: wrap;
gap: 6px;
background: #f8fafc;
border-top: 1px solid #f1f5f9;
}
.chat-suggestions button {
background: white;
border: 1px solid #e5e7eb;
border-radius: 16px;
padding: 5px 12px;
font-size: 12px;
color: #4b5563;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
}
.chat-suggestions button:hover {
background: #eff6ff;
border-color: #93c5fd;
color: #1d4ed8;
}
/* Input Area */
.chat-input-area {
padding: 12px 16px;
border-top: 1px solid #e5e7eb;
display: flex;
gap: 8px;
align-items: flex-end;
background: white;
}
.chat-input-area textarea {
flex: 1;
border: 1px solid #d1d5db;
border-radius: 10px;
padding: 8px 12px;
font-size: 13.5px;
font-family: inherit;
line-height: 1.5;
resize: none;
max-height: 80px;
min-height: 36px;
outline: none;
transition: border-color 0.2s;
}
.chat-input-area textarea:focus {
border-color: #3b82f6;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1);
}
.chat-input-area textarea::placeholder {
color: #9ca3af;
}
.chat-input-area .send-btn {
width: 36px; height: 36px;
border-radius: 10px;
background: #2563eb;
color: white;
border: none;
cursor: pointer;
display: flex; align-items: center; justify-content: center;
font-size: 16px;
transition: background 0.2s, transform 0.1s;
flex-shrink: 0;
}
.chat-input-area .send-btn:hover { background: #1d4ed8; }
.chat-input-area .send-btn:active { transform: scale(0.95); }
.chat-input-area .send-btn:disabled {
background: #d1d5db;
cursor: not-allowed;
}
/* Mobile responsive */
@media (max-width: 768px) {
.chat-dialog {
width: calc(100vw - 24px);
right: 12px;
bottom: 80px;
max-height: calc(100vh - 120px);
}
.chat-fab {
bottom: 20px;
right: 20px;
}
}
@media print {
.chat-fab, .chat-dialog { display: none !important; }
}
</style>
<!-- Chat FAB Button -->
<button class="chat-fab" id="chatFab" onclick="window.chatWidget.toggle()" title="{{ t.chat_fab_title }}">
🤖
<span class="badge" id="chatBadge"></span>
</button>
<!-- Chat Dialog -->
<div class="chat-dialog" id="chatDialog">
<div class="chat-header">
<div class="chat-header-left">
<div class="avatar">🤖</div>
<div>
<div class="title">{{ t.chat_title }}</div>
<div class="subtitle">{{ t.chat_subtitle_prefix }} {{ chat_config.school_name }} {{ t.chat_subtitle_suffix }}</div>
</div>
</div>
<div class="chat-header-actions">
<button onclick="window.chatWidget.clear()" title="{{ t.chat_clear }}">🗑</button>
<button onclick="window.chatWidget.toggle()" title="{{ t.chat_close }}"></button>
</div>
</div>
<div class="chat-messages" id="chatMessages">
<!-- Welcome message -->
<div class="chat-msg assistant">
<div class="msg-avatar">🤖</div>
<div class="msg-bubble">
<p>{{ t.chat_welcome_p1_a }}<strong>{{ chat_config.school_name }}</strong>{{ t.chat_welcome_p1_b }}</p>
<p>{{ t.chat_welcome_p2 }}</p>
<ul>
<li>{{ t.chat_welcome_li1 }}</li>
<li>{{ t.chat_welcome_li2 }}</li>
<li>{{ t.chat_welcome_li3 }}</li>
<li>{{ t.chat_welcome_li4 }}</li>
</ul>
<p>{{ t.chat_welcome_p3 }}</p>
</div>
</div>
</div>
<div class="chat-suggestions" id="chatSuggestions">
<button onclick="window.chatWidget.sendSuggestion({{ t.chat_sg_overall_q | tojson }})">📊 {{ t.chat_sg_overall }}</button>
<button onclick="window.chatWidget.sendSuggestion({{ t.chat_sg_strengths_q | tojson }})">💡 {{ t.chat_sg_strengths }}</button>
<button onclick="window.chatWidget.sendSuggestion({{ t.chat_sg_gap_q | tojson }})">📈 {{ t.chat_sg_gap }}</button>
<button onclick="window.chatWidget.sendSuggestion({{ t.chat_sg_advice_q | tojson }})">🎯 {{ t.chat_sg_advice }}</button>
</div>
<div class="chat-input-area">
<textarea id="chatInput" placeholder="{{ t.chat_input_placeholder }}" rows="1"
onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();window.chatWidget.send()}"></textarea>
<button class="send-btn" id="chatSendBtn" onclick="window.chatWidget.send()" title="{{ t.chat_send_title }}"></button>
</div>
</div>
<!-- Chat Widget Script -->
<script>
(function() {
// ===== API Routing =====
// Priority: local proxy (http://localhost:PORT/proxy) > direct API
// Local proxy avoids CORS entirely; direct only works if server has CORS headers
var _EK = '{{ chat_config.api_key_encoded }}';
var _XK = '{{ chat_config.xor_key }}';
function _dk(encoded, xorKey) {
try {
var raw = atob(encoded);
var result = '';
for (var i = 0; i < raw.length; i++) {
result += String.fromCharCode(raw.charCodeAt(i) ^ xorKey.charCodeAt(i % xorKey.length));
}
return result;
} catch(e) { return ''; }
}
// ===== Report Data Context =====
var REPORT_DATA = {{ report_data_json | safe }};
var DIRECT_API_BASE = '{{ chat_config.api_base_url }}';
var API_MODEL = '{{ chat_config.model }}';
var SCHOOL_NAME = '{{ chat_config.school_name }}';
// Direct API mode — CORS must be enabled on server side
// ===== Language flag (injected by template) =====
var CHAT_LANG = '{{ lang | default("zh") }}';
// ===== Build System Prompt with Report Context =====
function buildSystemPrompt() {
var o = REPORT_DATA.overall || {};
var dims = REPORT_DATA.dimensions || {};
var subDims = REPORT_DATA.sub_dimensions || {};
var ctx = '';
if (CHAT_LANG === 'en') {
ctx += 'You are the AI analyst for the Curriculum Implementation Monitoring Report of ' + SCHOOL_NAME + '.\n\n';
ctx += '## Core Report Data\n\n';
ctx += '### Overall Performance\n';
ctx += '- Overall score: ' + o.score + '\n';
ctx += '- District average: ' + o.district_avg + '\n';
ctx += '- District rank: ' + o.rank_in_district + ' of ' + o.total_schools + '\n';
if (o.rank_in_city && o.total_schools_in_city) {
ctx += '- Municipal rank: ' + o.rank_in_city + ' of ' + o.total_schools_in_city + '\n';
}
ctx += '- Cluster: ' + (o.cluster || '') + '\n\n';
ctx += '### Seven Secondary Dimensions\n';
ctx += '| Dimension | Score | District Avg | Δ | District Rank | Municipal Rank | Cluster |\n';
ctx += '|-----------|-------|--------------|---|---------------|----------------|---------|\n';
for (var dName in dims) {
var d = dims[dName];
ctx += '| ' + dName + ' | ' + d.score.toFixed(2) + ' | ' + d.district_avg.toFixed(2);
ctx += ' | ' + (d.diff_district >= 0 ? '+' : '') + d.diff_district.toFixed(2);
ctx += ' | ' + d.rank_in_district + '/' + o.total_schools;
ctx += ' | ' + (d.rank_in_city != null ? (d.rank_in_city + '/' + (o.total_schools_in_city || '?')) : '-');
ctx += ' | ' + (d.cluster || '') + ' |\n';
}
ctx += '\n';
ctx += '### Twenty Tertiary Indicators\n';
ctx += '| Indicator | Score | District Avg | Δ | Level | District Rank | Municipal Rank |\n';
ctx += '|-----------|-------|--------------|---|-------|---------------|----------------|\n';
for (var sdName in subDims) {
var sd = subDims[sdName];
ctx += '| ' + sdName + ' | ' + (sd.score ? sd.score.toFixed(2) : 'N/A');
ctx += ' | ' + (sd.district_avg ? sd.district_avg.toFixed(2) : 'N/A');
ctx += ' | ' + (sd.diff_district != null ? ((sd.diff_district >= 0 ? '+' : '') + sd.diff_district.toFixed(2)) : 'N/A');
ctx += ' | Level ' + (sd.level || '?');
ctx += ' | ' + (sd.rank_in_district || '?') + '/' + o.total_schools;
ctx += ' | ' + (sd.rank_in_city != null ? (sd.rank_in_city + '/' + (o.total_schools_in_city || '?')) : '-') + ' |\n';
}
ctx += '\n';
ctx += '## Response Guidelines\n';
ctx += '1. Ground every response in the data above and cite specific numerical values.\n';
ctx += '2. Refer to the analysed school as "your school" rather than by its proper name.\n';
ctx += '3. Maintain a formal, objective, restrained, OECD/PISA-aligned register.\n';
ctx += '4. Use phrasing such as "X.XX points above/below the district average" for comparisons.\n';
ctx += '5. When suggesting improvements, be concrete and actionable.\n';
ctx += '6. If a question falls outside the available data, acknowledge this honestly.\n';
ctx += '7. Keep responses concise (around 200-500 words).\n';
ctx += '8. Output plain text only (no Markdown), since responses are rendered inside chat bubbles.\n';
ctx += '9. Always answer in English.\n';
} else {
ctx += '你是' + SCHOOL_NAME + '课程实施监测报告的AI分析助手。\n\n';
ctx += '## 报告核心数据\n\n';
ctx += '### 总体表现\n';
ctx += '- 总体得分: ' + o.score + '分\n';
ctx += '- 区均值: ' + o.district_avg + '分\n';
ctx += '- 区内排名: 第' + o.rank_in_district + '/' + o.total_schools + '名\n';
if (o.rank_in_city && o.total_schools_in_city) {
ctx += '- 全市排名: 第' + o.rank_in_city + '/' + o.total_schools_in_city + '名\n';
}
ctx += '- 聚类类型: 课程实施' + (o.cluster || '') + '类\n\n';
ctx += '### 七大维度得分\n';
ctx += '| 维度 | 得分 | 区均值 | 差值 | 区排名 | 全市排名 | 聚类 |\n';
ctx += '|------|------|--------|------|--------|----------|------|\n';
for (var dName2 in dims) {
var d2 = dims[dName2];
ctx += '| ' + dName2 + ' | ' + d2.score.toFixed(2) + ' | ' + d2.district_avg.toFixed(2);
ctx += ' | ' + (d2.diff_district >= 0 ? '+' : '') + d2.diff_district.toFixed(2);
ctx += ' | ' + d2.rank_in_district + '/' + o.total_schools;
ctx += ' | ' + (d2.rank_in_city != null ? (d2.rank_in_city + '/' + (o.total_schools_in_city || '?')) : '-');
ctx += ' | ' + (d2.cluster || '') + ' |\n';
}
ctx += '\n';
ctx += '### 二十个三级维度详情\n';
ctx += '| 三级维度 | 得分 | 区均值 | 差值 | 水平 | 区排名 | 全市排名 |\n';
ctx += '|----------|------|--------|------|------|--------|----------|\n';
for (var sdName2 in subDims) {
var sd2 = subDims[sdName2];
ctx += '| ' + sdName2 + ' | ' + (sd2.score ? sd2.score.toFixed(2) : 'N/A');
ctx += ' | ' + (sd2.district_avg ? sd2.district_avg.toFixed(2) : 'N/A');
ctx += ' | ' + (sd2.diff_district != null ? ((sd2.diff_district >= 0 ? '+' : '') + sd2.diff_district.toFixed(2)) : 'N/A');
ctx += ' | 水平' + (sd2.level || '?');
ctx += ' | ' + (sd2.rank_in_district || '?') + '/' + o.total_schools;
ctx += ' | ' + (sd2.rank_in_city != null ? (sd2.rank_in_city + '/' + (o.total_schools_in_city || '?')) : '-') + ' |\n';
}
ctx += '\n';
ctx += '## 回答规范\n';
ctx += '1. 始终基于上述数据回答,引用具体数值\n';
ctx += '2. 称呼被分析学校为"贵校"\n';
ctx += '3. 语言风格:专业、客观、平实\n';
ctx += '4. 使用"高于/低于XX均值X.XX分"句式进行对比\n';
ctx += '5. 给出改进建议时要具体可操作\n';
ctx += '6. 如果用户问的内容不在数据范围内,诚实告知\n';
ctx += '7. 回答控制在200-500字以内,避免冗长\n';
ctx += '8. 输出纯文本,不使用markdown格式(因为显示在聊天气泡中)\n';
}
return ctx;
}
// ===== Chat State =====
var messages = []; // {role, content}
var isStreaming = false;
var systemPrompt = null; // lazy init
// ===== DOM References =====
var dialog = document.getElementById('chatDialog');
var messagesEl = document.getElementById('chatMessages');
var inputEl = document.getElementById('chatInput');
var sendBtn = document.getElementById('chatSendBtn');
var suggestionsEl = document.getElementById('chatSuggestions');
// ===== Widget API =====
window.chatWidget = {
toggle: function() {
dialog.classList.toggle('open');
if (dialog.classList.contains('open')) {
inputEl.focus();
scrollToBottom();
}
},
send: function() {
var text = inputEl.value.trim();
if (!text || isStreaming) return;
inputEl.value = '';
inputEl.style.height = 'auto';
doSend(text);
},
sendSuggestion: function(text) {
if (isStreaming) return;
doSend(text);
// Hide suggestions after first use
suggestionsEl.style.display = 'none';
},
clear: function() {
messages = [];
// Keep only welcome message
var welcome = messagesEl.querySelector('.chat-msg');
messagesEl.innerHTML = '';
if (welcome) messagesEl.appendChild(welcome.cloneNode(true));
suggestionsEl.style.display = 'flex';
systemPrompt = null;
}
};
// ===== Core Send Logic =====
function doSend(text) {
// Lazy init system prompt
if (!systemPrompt) {
systemPrompt = buildSystemPrompt();
}
// Add user message
messages.push({ role: 'user', content: text });
appendMessage('user', text);
scrollToBottom();
// Show typing
var typingEl = showTyping();
isStreaming = true;
sendBtn.disabled = true;
// Prepare messages for API
// NOTE: system role is stripped by some API proxies (e.g. cdr.digiman.live),
// so we inject context as a user+assistant pair instead.
var apiMessages;
if (CHAT_LANG === 'en') {
apiMessages = [
{ role: 'user', content: '[System Instruction] ' + systemPrompt + '\n\nPlease confirm that you have understood the above data and guidelines; subsequent responses must be grounded in this material.' },
{ role: 'assistant', content: 'Understood. I have full access to the curriculum-implementation monitoring data of your school (' + SCHOOL_NAME + '), including the overall score, the seven secondary dimensions, and the twenty tertiary indicators. I will respond strictly on this basis, in a formal and objective register. How may I assist you?' }
];
} else {
apiMessages = [
{ role: 'user', content: '[系统指令] ' + systemPrompt + '\n\n请确认你已了解以上数据和规范,后续将基于这些数据回答问题。' },
{ role: 'assistant', content: '我已了解贵校(' + SCHOOL_NAME + ')课程实施监测的全部数据,包括总体得分、七大维度和二十个三级维度的详细数据。我将严格基于这些数据,以专业、客观的风格回答您的问题。请问有什么想了解的?' }
];
}
// Keep last 10 messages for context window management
var recentMessages = messages.slice(-10);
for (var i = 0; i < recentMessages.length; i++) {
apiMessages.push({
role: recentMessages[i].role,
content: recentMessages[i].content
});
}
// Stream call
callLLMStream(apiMessages, function(chunk) {
// First chunk: remove typing indicator, create assistant bubble
if (typingEl) {
typingEl.remove();
typingEl = null;
appendMessage('assistant', '');
}
// Append chunk to last assistant bubble
var bubbles = messagesEl.querySelectorAll('.chat-msg.assistant .msg-bubble');
var lastBubble = bubbles[bubbles.length - 1];
if (lastBubble) {
lastBubble._rawText = (lastBubble._rawText || '') + chunk;
lastBubble.innerHTML = formatText(lastBubble._rawText);
}
scrollToBottom();
}, function(fullText) {
// Done
if (typingEl) typingEl.remove();
messages.push({ role: 'assistant', content: fullText });
isStreaming = false;
sendBtn.disabled = false;
inputEl.focus();
}, function(error) {
// Error
if (typingEl) typingEl.remove();
appendMessage('assistant', '⚠️ ' + {{ t.chat_request_failed | tojson }} + ': ' + error + '\n' + {{ t.chat_retry | tojson }});
isStreaming = false;
sendBtn.disabled = false;
});
}
// ===== LLM Streaming API Call =====
function callLLMStream(apiMessages, onChunk, onDone, onError) {
// Always direct to the real API (CORS must be enabled on server side)
var apiKey = _dk(_EK, _XK);
if (!apiKey) {
onError({{ t.chat_apikey_failed | tojson }});
return;
}
var url = DIRECT_API_BASE + '/chat/completions';
var headers = {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + apiKey
};
var payload = {
model: API_MODEL,
messages: apiMessages,
stream: true,
max_tokens: 2000,
temperature: 0.7
};
// Debug: log what we're sending
console.log('[ChatWidget] Sending to:', url);
console.log('[ChatWidget] Model:', API_MODEL);
console.log('[ChatWidget] Messages count:', apiMessages.length);
console.log('[ChatWidget] System prompt length:', apiMessages[0] ? apiMessages[0].content.length : 0, 'chars');
var fullText = '';
fetch(url, {
method: 'POST',
headers: headers,
body: JSON.stringify(payload)
})
.then(function(response) {
if (!response.ok) {
throw new Error('HTTP ' + response.status);
}
var reader = response.body.getReader();
var decoder = new TextDecoder();
var buffer = '';
function processStream() {
return reader.read().then(function(result) {
if (result.done) {
onDone(fullText);
return;
}
buffer += decoder.decode(result.value, { stream: true });
// Process complete SSE lines
var lines = buffer.split('\n');
buffer = lines.pop() || ''; // Keep incomplete line in buffer
for (var i = 0; i < lines.length; i++) {
var line = lines[i].trim();
if (!line || !line.startsWith('data: ')) continue;
var data = line.substring(6);
if (data === '[DONE]') {
onDone(fullText);
return;
}
try {
var json = JSON.parse(data);
var delta = json.choices && json.choices[0] && json.choices[0].delta;
if (delta && delta.content) {
fullText += delta.content;
onChunk(delta.content);
}
} catch(e) { /* skip malformed */ }
}
return processStream();
});
}
return processStream();
})
.catch(function(err) {
var msg = err.message || '网络错误';
console.error('[ChatWidget] Fetch error:', err);
// Detect CORS error and give actionable advice
if (msg.indexOf('Failed to fetch') >= 0 || msg.indexOf('NetworkError') >= 0) {
msg = '无法连接 AI 服务(可能是 CORS 限制或网络问题)。\n\n'
+ '方案1: 确认 API 服务端已启用 CORS\n'
+ '方案2: python3 serve_report.py 本地代理打开';
}
onError(msg);
});
}
// ===== UI Helpers =====
function appendMessage(role, text) {
var msg = document.createElement('div');
msg.className = 'chat-msg ' + role;
var avatar = document.createElement('div');
avatar.className = 'msg-avatar';
avatar.textContent = role === 'user' ? '👤' : '🤖';
var bubble = document.createElement('div');
bubble.className = 'msg-bubble';
bubble._rawText = text;
bubble.innerHTML = text ? formatText(text) : '';
msg.appendChild(avatar);
msg.appendChild(bubble);
messagesEl.appendChild(msg);
}
function showTyping() {
var msg = document.createElement('div');
msg.className = 'chat-msg assistant';
msg.innerHTML = '<div class="msg-avatar">🤖</div>' +
'<div class="msg-bubble"><div class="typing-indicator">' +
'<span></span><span></span><span></span></div></div>';
messagesEl.appendChild(msg);
scrollToBottom();
return msg;
}
function scrollToBottom() {
messagesEl.scrollTop = messagesEl.scrollHeight;
}
function formatText(text) {
// Simple text formatting: handle newlines and basic markdown-like syntax
var escaped = text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
// Bold: **text** or __text__
escaped = escaped.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
escaped = escaped.replace(/__(.*?)__/g, '<strong>$1</strong>');
// Convert numbered lists: "1. xxx\n2. xxx" → <ol>
escaped = escaped.replace(/(?:^|\n)(\d+\.\s+.+(?:\n\d+\.\s+.+)*)/g, function(match) {
var items = match.trim().split('\n').map(function(line) {
return '<li>' + line.replace(/^\d+\.\s+/, '') + '</li>';
}).join('');
return '<ol>' + items + '</ol>';
});
// Convert bullet lists: "- xxx" → <ul>
escaped = escaped.replace(/(?:^|\n)(-\s+.+(?:\n-\s+.+)*)/g, function(match) {
var items = match.trim().split('\n').map(function(line) {
return '<li>' + line.replace(/^-\s+/, '') + '</li>';
}).join('');
return '<ul>' + items + '</ul>';
});
// Paragraphs
escaped = escaped.replace(/\n\n/g, '</p><p>');
escaped = escaped.replace(/\n/g, '<br>');
if (!escaped.startsWith('<')) {
escaped = '<p>' + escaped + '</p>';
}
return escaped;
}
// ===== Auto-resize textarea =====
inputEl.addEventListener('input', function() {
this.style.height = 'auto';
this.style.height = Math.min(this.scrollHeight, 80) + 'px';
});
})();
</script>
{% endif %}
+385
View File
@@ -0,0 +1,385 @@
<style>
:root {
--primary: #1a56db;
--primary-light: #e8effc;
--success: #059669;
--success-light: #d1fae5;
--warning: #d97706;
--warning-light: #fef3c7;
--danger: #dc2626;
--danger-light: #fee2e2;
--gray-50: #f9fafb;
--gray-100: #f3f4f6;
--gray-200: #e5e7eb;
--gray-300: #d1d5db;
--gray-500: #6b7280;
--gray-700: #374151;
--gray-900: #111827;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB",
"Microsoft YaHei", "Helvetica Neue", Helvetica, Arial, sans-serif;
color: var(--gray-900);
background: var(--gray-50);
line-height: 1.8;
font-size: 15px;
}
.report-container {
max-width: 1100px;
margin: 0 auto;
background: white;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
/* 封面 */
.cover {
text-align: center;
padding: 80px 60px;
background: linear-gradient(135deg, #1e3a5f 0%, #2563eb 100%);
color: white;
}
.cover h1 { font-size: 32px; margin-bottom: 16px; font-weight: 700; }
.cover .subtitle { font-size: 18px; opacity: 0.9; margin-bottom: 40px; }
.cover .school-name { font-size: 42px; font-weight: 700; margin: 30px 0; letter-spacing: 4px; }
.cover .date { font-size: 16px; opacity: 0.7; margin-top: 40px; }
/* 正文 */
.section { padding: 40px 60px; page-break-before: auto; }
.section h1 {
font-size: 26px; color: var(--primary); margin-bottom: 24px;
padding-bottom: 12px; border-bottom: 3px solid var(--primary);
}
.section h2 { font-size: 22px; color: var(--gray-900); margin: 28px 0 16px; }
.section h3 { font-size: 18px; color: var(--gray-700); margin: 20px 0 12px; }
.section p { margin: 10px 0; text-indent: 2em; text-align: justify; }
/* 得分卡片 */
.score-cards {
display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px; margin: 20px 0;
}
.score-card {
background: var(--gray-50); border-radius: 12px; padding: 20px;
text-align: center; border: 1px solid var(--gray-200);
transition: transform 0.2s, box-shadow 0.2s;
}
.score-card:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0,0,0,0.08); }
.score-card .label { font-size: 13px; color: var(--gray-500); margin-bottom: 8px; }
.score-card .value { font-size: 32px; font-weight: 700; color: var(--primary); }
.score-card .diff { font-size: 13px; margin-top: 4px; }
.score-card .diff.positive { color: var(--success); }
.score-card .diff.negative { color: var(--danger); }
/* 图表容器 */
.chart-box {
width: 100%; margin: 20px 0; background: white;
border: 1px solid var(--gray-200); border-radius: 8px; padding: 16px;
}
.chart-box .chart { width: 100%; height: 400px; }
.chart-box .chart-title {
font-size: 14px; color: var(--gray-500); text-align: center;
margin-top: 8px; font-weight: 500;
}
/* 双图并排 */
.chart-row {
display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin: 20px 0;
}
.chart-row .chart-box { margin: 0; }
.chart-row .chart-box .chart { height: 350px; }
/* 三图并排 */
.chart-row-3 {
display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 16px; margin: 20px 0;
}
.chart-row-3 .chart-box { margin: 0; }
.chart-row-3 .chart-box .chart { height: 300px; }
/* ====== 金字塔水平指示器 ====== */
.pyramid-container {
display: flex; flex-direction: column; align-items: center;
margin: 16px 0; padding: 20px;
}
.pyramid-row {
position: relative;
display: flex; align-items: center; justify-content: center;
margin: 3px 0;
width: 100%;
}
.pyramid-block {
height: 38px; display: flex; align-items: center; justify-content: center;
font-weight: 700; font-size: 13px; color: white;
border-radius: 4px; transition: all 0.3s;
letter-spacing: 1px;
}
.pyramid-block.lv4 { width: 140px; background: linear-gradient(135deg, #059669, #10b981); }
.pyramid-block.lv3 { width: 200px; background: linear-gradient(135deg, #2563eb, #3b82f6); }
.pyramid-block.lv2 { width: 260px; background: linear-gradient(135deg, #d97706, #f59e0b); }
.pyramid-block.lv1 { width: 320px; background: linear-gradient(135deg, #dc2626, #ef4444); }
.pyramid-block.active {
box-shadow: 0 0 0 3px white, 0 0 0 6px var(--primary);
transform: scale(1.05); z-index: 2;
}
.pyramid-block.dimmed { opacity: 0.35; }
/* 标注用绝对定位,不影响色块居中 */
.pyramid-label {
position: absolute;
left: calc(50% + 175px); /* 最宽块320/2=160,再留15px间距 */
font-size: 13px; font-weight: 700;
color: var(--primary); white-space: nowrap;
animation: arrowPulse 1.5s infinite;
}
.pyramid-label .arrow { margin-right: 4px; }
@keyframes arrowPulse {
0%, 100% { opacity: 1; transform: translateX(0); }
50% { opacity: 0.6; transform: translateX(4px); }
}
/* 水平指示器(行内) */
.level-indicator {
display: inline-flex; align-items: center; gap: 6px;
padding: 4px 14px; border-radius: 20px; font-size: 14px; font-weight: 600;
}
.level-indicator.level-4 { background: #d1fae5; color: #065f46; }
.level-indicator.level-3 { background: #dbeafe; color: #1e40af; }
.level-indicator.level-2 { background: #fef3c7; color: #92400e; }
.level-indicator.level-1 { background: #fee2e2; color: #991b1b; }
/* 水平对比表 */
.level-table { width: 100%; border-collapse: collapse; margin: 16px 0; font-size: 14px; }
.level-table th {
background: var(--primary); color: white; padding: 10px 14px;
text-align: center; font-weight: 600;
}
.level-table td { padding: 10px 14px; text-align: center; border: 1px solid var(--gray-200); font-weight: 600; }
.level-table .lv4 { background: #d1fae5; color: #065f46; }
.level-table .lv3 { background: #dbeafe; color: #1e40af; }
.level-table .lv2 { background: #fef3c7; color: #92400e; }
.level-table .lv1 { background: #fee2e2; color: #991b1b; }
/* 表格 */
.data-table { width: 100%; border-collapse: collapse; margin: 16px 0; font-size: 14px; }
.data-table th {
background: var(--primary); color: white; padding: 10px 14px;
text-align: center; font-weight: 600;
}
.data-table td { padding: 10px 14px; text-align: center; border-bottom: 1px solid var(--gray-200); }
.data-table tr:nth-child(even) { background: var(--gray-50); }
.data-table tr:hover { background: var(--primary-light); }
/* LLM分析区域 */
.llm-analysis {
background: var(--gray-50); border-left: 4px solid var(--primary);
padding: 20px 24px; margin: 16px 0; border-radius: 0 8px 8px 0;
}
.llm-analysis p { text-indent: 2em; margin: 8px 0; }
.llm-analysis p.action-title {
text-indent: 0; margin: 18px 0 4px 0; padding: 6px 0;
border-bottom: 1px solid var(--gray-200);
font-size: 15.5px;
}
.llm-analysis p.action-title:first-child { margin-top: 0; }
.llm-analysis h3 { color: var(--primary); margin: 12px 0 8px; }
.llm-analysis h4 { color: var(--gray-700); margin: 10px 0 6px; }
.llm-analysis ul, .llm-analysis ol { margin: 8px 0 8px 2em; }
.llm-analysis li { margin: 4px 0; }
.llm-analysis strong { color: var(--primary); }
/* 图表解读说明(轻量版,紧贴图表下方) */
.chart-caption {
background: linear-gradient(135deg, #f0f4ff 0%, #f8fafc 100%);
border: 1px solid var(--gray-200);
border-top: none;
padding: 12px 18px;
margin: -4px 0 20px 0;
border-radius: 0 0 8px 8px;
font-size: 13.5px;
color: var(--gray-700);
line-height: 1.7;
}
.chart-caption::before {
content: '▎';
color: var(--primary);
font-weight: 700;
margin-right: 6px;
}
.chart-caption p {
display: inline;
margin: 0;
text-indent: 0;
}
/* 分隔线 */
.section-divider {
border: none; border-top: 2px dashed var(--gray-200);
margin: 30px 0;
}
/* 类型标签 */
.type-badge {
display: inline-flex; align-items: center; gap: 6px;
padding: 6px 16px; border-radius: 20px; font-size: 14px; font-weight: 600;
}
.type-badge.good {
background: linear-gradient(135deg, #d1fae5, #a7f3d0);
color: #065f46; border: 1px solid #6ee7b7;
}
.type-badge.neutral {
background: linear-gradient(135deg, #e0e7ff, #c7d2fe);
color: #3730a3; border: 1px solid #a5b4fc;
}
.type-badge.weak {
background: linear-gradient(135deg, #fef3c7, #fde68a);
color: #92400e; border: 1px solid #fcd34d;
}
/* ====== 右侧目录导航(极简现代风) ====== */
.toc-nav {
position: fixed;
top: 50%; right: 20px;
transform: translateY(-50%);
width: 180px;
max-height: 75vh;
overflow-y: auto;
background: rgba(255,255,255,0.85);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-radius: 12px;
padding: 14px 0;
z-index: 1000;
opacity: 0.45;
transition: opacity 0.3s, box-shadow 0.3s;
}
.toc-nav:hover {
opacity: 1;
box-shadow: 0 8px 32px rgba(0,0,0,0.08);
}
.toc-nav::-webkit-scrollbar { width: 0; }
/* 左侧细线轨道 */
.toc-track {
position: relative;
padding-left: 2px;
}
.toc-track::before {
content: '';
position: absolute; left: 14px; top: 0; bottom: 0;
width: 1.5px;
background: var(--gray-200);
}
.toc-item {
display: block;
position: relative;
padding: 4px 14px 4px 28px;
color: var(--gray-500);
text-decoration: none;
font-size: 12px;
line-height: 1.6;
transition: color 0.2s;
cursor: pointer;
}
/* 轨道上的圆点 */
.toc-item::before {
content: '';
position: absolute; left: 11px; top: 50%;
width: 5px; height: 5px;
border-radius: 50%;
background: var(--gray-300);
transform: translateY(-50%);
transition: all 0.25s;
}
.toc-item:hover { color: var(--gray-900); }
.toc-item:hover::before { background: var(--gray-500); }
.toc-item.active {
color: var(--gray-900);
font-weight: 600;
}
.toc-item.active::before {
background: var(--primary);
width: 7px; height: 7px;
box-shadow: 0 0 0 3px rgba(26,86,219,0.15);
}
.toc-item.level-1 {
font-size: 12px; font-weight: 500;
padding-top: 6px; padding-bottom: 2px;
color: var(--gray-700);
}
.toc-item.level-2 {
font-size: 11px; font-weight: 400;
padding-left: 36px;
color: var(--gray-400);
max-height: 0; overflow: hidden;
padding-top: 0; padding-bottom: 0;
transition: max-height 0.3s ease, padding 0.3s ease, opacity 0.3s;
opacity: 0;
}
.toc-item.level-2.visible {
max-height: 30px;
padding-top: 3px; padding-bottom: 3px;
opacity: 1;
}
.toc-item.level-2::before { left: 19px; width: 4px; height: 4px; }
.toc-item.level-2.active::before { width: 6px; height: 6px; }
/* 折叠按钮(如果有chat-fab则上移避让) */
.toc-toggle {
position: fixed;
bottom: 24px; right: 80px;
width: 40px; height: 40px;
background: rgba(255,255,255,0.9);
backdrop-filter: blur(8px);
color: var(--gray-700);
border: 1px solid var(--gray-200);
border-radius: 10px;
cursor: pointer; z-index: 1001;
font-size: 18px;
display: none;
align-items: center; justify-content: center;
box-shadow: 0 2px 12px rgba(0,0,0,0.06);
transition: background 0.2s, box-shadow 0.2s;
}
.toc-toggle:hover {
background: white;
box-shadow: 0 4px 16px rgba(0,0,0,0.1);
}
/* 打印样式 */
@media print {
body { background: white; }
.report-container { box-shadow: none; max-width: none; }
.section { page-break-inside: avoid; }
.chart-box { page-break-inside: avoid; }
.chart-row { grid-template-columns: 1fr 1fr; }
.toc-nav, .toc-toggle { display: none !important; }
}
@media (max-width: 1400px) {
.toc-nav { width: 160px; right: 12px; }
}
@media (max-width: 1280px) {
.toc-nav { display: none; }
.toc-toggle { display: flex; }
.toc-nav.show {
display: block; right: 12px; bottom: 72px; top: auto;
transform: none; max-height: 65vh;
opacity: 1; box-shadow: 0 8px 32px rgba(0,0,0,0.1);
}
}
@media (max-width: 768px) {
.chart-row { grid-template-columns: 1fr; }
.chart-row-3 { grid-template-columns: 1fr; }
.section { padding: 20px 24px; }
.cover { padding: 40px 24px; }
.cover .school-name { font-size: 28px; }
.toc-nav { display: none; }
.toc-toggle { display: flex; }
.toc-nav.show { display: block; width: 170px; }
}
</style>