Initial commit
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
commit
71db82393a
@@ -0,0 +1,147 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ school }}课程实施监测数据分析报告</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/echarts-gl@2/dist/echarts-gl.min.js"></script>
|
||||
{% include 'components/styles.html' %}
|
||||
</head>
|
||||
<body>
|
||||
{# ===== 右侧目录导航(JS自动从h1/h2生成) ===== #}
|
||||
<nav class="toc-nav" id="toc-nav"><div class="toc-track" id="toc-track"></div></nav>
|
||||
<button class="toc-toggle" id="toc-toggle" onclick="document.getElementById('toc-nav').classList.toggle('show')">☰</button>
|
||||
|
||||
<div class="report-container">
|
||||
|
||||
{# ===== 封面 ===== #}
|
||||
{% include 'sections/cover.html' %}
|
||||
|
||||
{# ===== 第一部分:测评背景与实施 ===== #}
|
||||
{% include 'sections/part0_background.html' %}
|
||||
|
||||
{# ===== 第二部分:总体表现 ===== #}
|
||||
{% include 'sections/part1_overview.html' %}
|
||||
|
||||
{# ===== 第三~九部分:各维度详细分析 ===== #}
|
||||
{% set part_names = {
|
||||
"课程领导力": "part3", "教学变革力": "part4", "学生发展指导力": "part5",
|
||||
"教师发展支持力": "part6", "教育质量评估力": "part7",
|
||||
"教育条件保障力": "part8", "数字化赋能力": "part9"
|
||||
} %}
|
||||
{% set part_numbers = {
|
||||
"课程领导力": "三", "教学变革力": "四", "学生发展指导力": "五",
|
||||
"教师发展支持力": "六", "教育质量评估力": "七",
|
||||
"教育条件保障力": "八", "数字化赋能力": "九"
|
||||
} %}
|
||||
|
||||
{% for dim_name, dim_data in dimensions.items() %}
|
||||
{% set part_id = part_names[dim_name] %}
|
||||
{% set part_number = part_numbers[dim_name] %}
|
||||
{% include 'sections/dimension_detail.html' %}
|
||||
{% endfor %}
|
||||
|
||||
{# ===== 总结与建议 ===== #}
|
||||
{% include 'sections/conclusion.html' %}
|
||||
|
||||
</div>
|
||||
|
||||
{# ===== ECharts 图表渲染 ===== #}
|
||||
{% include 'components/charts_common.js.html' %}
|
||||
{% include 'components/charts_overview.js.html' %}
|
||||
{% include 'components/charts_dimension.js.html' %}
|
||||
{% include 'components/charts_innovative.js.html' %}
|
||||
|
||||
{# ===== 自动生成目录 + 滚动高亮 ===== #}
|
||||
<script>
|
||||
(function() {
|
||||
/* ---- 1. 自动扫描 h1/h2 生成 TOC ---- */
|
||||
var track = document.getElementById('toc-track');
|
||||
var container = document.querySelector('.report-container');
|
||||
// 取 .section 内的 h1(部分标题)和 h2[id](子维度标题)
|
||||
var headings = container.querySelectorAll('.section > h1, h2[id]');
|
||||
var currentGroup = null;
|
||||
|
||||
headings.forEach(function(h) {
|
||||
// 确保有 id 可跳转;h1 取父 .section 的 id,h2 自身有 id
|
||||
var id = '';
|
||||
if (h.tagName === 'H1') {
|
||||
var sec = h.closest('.section');
|
||||
id = sec ? sec.id : '';
|
||||
} else {
|
||||
id = h.id;
|
||||
}
|
||||
if (!id) return;
|
||||
|
||||
var a = document.createElement('a');
|
||||
a.href = '#' + id;
|
||||
a.className = 'toc-item';
|
||||
// 去掉"第X部分 "前缀,只保留核心文字
|
||||
var text = h.textContent.replace(/^第[一二三四五六七八九十]+部分\s*/, '');
|
||||
// h2 也去掉"二、""三、"等编号前缀
|
||||
text = text.replace(/^[一二三四五六七八九十]+、\s*/, '');
|
||||
a.textContent = text;
|
||||
|
||||
if (h.tagName === 'H1') {
|
||||
a.classList.add('level-1');
|
||||
a.setAttribute('data-group', id);
|
||||
currentGroup = id;
|
||||
} else {
|
||||
a.classList.add('level-2');
|
||||
if (currentGroup) a.setAttribute('data-parent', currentGroup);
|
||||
}
|
||||
track.appendChild(a);
|
||||
});
|
||||
|
||||
/* ---- 2. 事件绑定 ---- */
|
||||
var tocItems = track.querySelectorAll('.toc-item');
|
||||
var level2Items = track.querySelectorAll('.toc-item.level-2');
|
||||
var sections = [];
|
||||
|
||||
tocItems.forEach(function(item) {
|
||||
var target = document.getElementById(item.getAttribute('href').slice(1));
|
||||
if (target) sections.push({ el: target, link: item });
|
||||
|
||||
// 点击平滑滚动
|
||||
item.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
target && target.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
if (window.innerWidth <= 1280) {
|
||||
document.getElementById('toc-nav').classList.remove('show');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function expandGroup(gid) {
|
||||
level2Items.forEach(function(item) {
|
||||
item.classList.toggle('visible', item.getAttribute('data-parent') === gid);
|
||||
});
|
||||
}
|
||||
|
||||
/* ---- 3. 滚动高亮 ---- */
|
||||
var ticking = false;
|
||||
window.addEventListener('scroll', function() {
|
||||
if (ticking) return;
|
||||
ticking = true;
|
||||
window.requestAnimationFrame(function() {
|
||||
var scrollY = window.scrollY + 150;
|
||||
var current = null;
|
||||
for (var i = sections.length - 1; i >= 0; i--) {
|
||||
if (sections[i].el.offsetTop <= scrollY) { current = sections[i]; break; }
|
||||
}
|
||||
tocItems.forEach(function(item) { item.classList.remove('active'); });
|
||||
if (current) {
|
||||
current.link.classList.add('active');
|
||||
expandGroup(current.link.getAttribute('data-group') || current.link.getAttribute('data-parent') || '');
|
||||
}
|
||||
ticking = false;
|
||||
});
|
||||
});
|
||||
|
||||
window.dispatchEvent(new Event('scroll'));
|
||||
})();
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,18 @@
|
||||
{# 图表通用常量 #}
|
||||
<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',
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,202 @@
|
||||
{# 各维度详细图表:得分柱状图、子维度雷达图、对比柱状图、水平分布、散点图 #}
|
||||
<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, '区均值', '同类学校'], 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: '均值基线(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, '区均值', '同类学校'], bottom: 5, textStyle: { fontSize: 11 } },
|
||||
radar: {
|
||||
indicator: d.sub_dims.map(sd => ({ name: 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: '区均值',
|
||||
lineStyle: { width: 1, type: 'dashed', color: COLORS.grayDark }, itemStyle: { color: COLORS.grayDark } },
|
||||
{ value: d.same_type_avg, name: '同类学校',
|
||||
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, '区均值', '同类学校'], bottom: 5, textStyle: { fontSize: 11 } },
|
||||
grid: { left: '3%', right: '4%', bottom: '18%', top: '5%', containLabel: true },
|
||||
xAxis: { type: 'category', data: d.sub_dims, 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: '区均值', type: 'bar', data: d.district_avg, itemStyle: { color: COLORS.gray }, barWidth: '22%' },
|
||||
{ name: '同类学校', 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 }};
|
||||
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 }}', '区均值'], bottom: 5 },
|
||||
grid: { left: '3%', right: '4%', bottom: '18%', containLabel: true },
|
||||
xAxis: { type: 'category', data: d.categories, axisLabel: { rotate: 15, fontSize: 11 } },
|
||||
yAxis: { type: 'value', min: 20, max: 80 },
|
||||
series: [
|
||||
{ name: '{{ school }}', type: 'bar', data: d.school_values,
|
||||
itemStyle: { color: COLORS.primary }, barWidth: '28%',
|
||||
label: { show: true, position: 'top', fontSize: 10 } },
|
||||
{ name: '区均值', type: 'bar', data: d.avg_values, itemStyle: { color: COLORS.gray }, barWidth: '28%' },
|
||||
{ name: '均值基线(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 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 = d.categories.indexOf(params[0].axisValue);
|
||||
if (idx >= 0) s += '<strong>{{ school }}:水平' + d.school_levels[idx] + '</strong>';
|
||||
return s;
|
||||
}
|
||||
},
|
||||
legend: { data: ['水平一', '水平二', '水平三', '水平四'], bottom: 5, textStyle: { fontSize: 11 } },
|
||||
grid: { left: '3%', right: '4%', bottom: '18%', top: '5%', containLabel: true },
|
||||
xAxis: { type: 'category', data: d.categories, axisLabel: { fontSize: 11, rotate: 15 } },
|
||||
yAxis: { type: 'value', max: 100, axisLabel: { formatter: '{value}%' } },
|
||||
series: [
|
||||
{ name: '水平一', type: 'bar', stack: 'total', data: d.level1, itemStyle: { color: COLORS.level1 }, barWidth: '50%' },
|
||||
{ name: '水平二', type: 'bar', stack: 'total', data: d.level2, itemStyle: { color: COLORS.level2 } },
|
||||
{ name: '水平三', type: 'bar', stack: 'total', data: d.level3, itemStyle: { color: COLORS.level3 } },
|
||||
{ name: '水平四', 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);
|
||||
|
||||
if (d.type === '3d') {
|
||||
const axes = d.axes;
|
||||
const option = {
|
||||
tooltip: {
|
||||
formatter: function(params) {
|
||||
const v = params.value || (params.data && params.data.value);
|
||||
if (!v) return '';
|
||||
return params.seriesName + '<br/>'
|
||||
+ axes[0] + ': ' + (typeof v[0] === 'number' ? v[0].toFixed(2) : v[0]) + '<br/>'
|
||||
+ axes[1] + ': ' + (typeof v[1] === 'number' ? v[1].toFixed(2) : v[1]) + '<br/>'
|
||||
+ axes[2] + ': ' + (typeof v[2] === 'number' ? v[2].toFixed(2) : v[2]);
|
||||
}
|
||||
},
|
||||
legend: { data: ['较好类', '待提升类', d.school_name], bottom: 5, textStyle: { fontSize: 11 } },
|
||||
xAxis3D: { name: d.axes[0], type: 'value', nameTextStyle: { fontSize: 10 } },
|
||||
yAxis3D: { name: d.axes[1], type: 'value', nameTextStyle: { fontSize: 10 } },
|
||||
zAxis3D: { name: d.axes[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: '较好类', type: 'scatter3D', data: d.good_data.map(p => ({ value: p })),
|
||||
symbolSize: 8, itemStyle: { color: COLORS.success, opacity: 0.7 } },
|
||||
{ name: '待提升类', 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 : d.axes[0] + ': ' + p.value[0] + '<br/>' + d.axes[1] + ': ' + p.value[1] },
|
||||
legend: { data: ['较好类', '待提升类', d.school_name], bottom: 5, textStyle: { fontSize: 11 } },
|
||||
grid: { left: '10%', right: '8%', bottom: '18%', top: '5%' },
|
||||
xAxis: { name: d.axes[0], nameLocation: 'center', nameGap: 30, nameTextStyle: { fontSize: 11 }, splitLine: { show: true, lineStyle: { type: 'dashed' } } },
|
||||
yAxis: { name: d.axes[1], nameLocation: 'center', nameGap: 40, nameTextStyle: { fontSize: 11 }, splitLine: { show: true, lineStyle: { type: 'dashed' } } },
|
||||
series: [
|
||||
{ name: '较好类', type: 'scatter', data: d.good_data, symbolSize: 10, itemStyle: { color: COLORS.success, opacity: 0.6 } },
|
||||
{ name: '待提升类', 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,384 @@
|
||||
<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 option = {
|
||||
title: [
|
||||
{ text: profileData.school_name, left: 'center', top: 6, textStyle: { fontSize: 16, fontWeight: 'bold', color: '#1e3a5f' } },
|
||||
{ text: profileData.school_type + ' · 区内第' + profileData.rank + '名', 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 }],
|
||||
},
|
||||
// 底部饼图:20个三级维度的水平分布
|
||||
{
|
||||
type: 'pie',
|
||||
center: ['50%', '92%'],
|
||||
radius: ['0%', '0%'], // 隐藏饼图,只用graphic展示
|
||||
silent: true,
|
||||
data: [],
|
||||
},
|
||||
],
|
||||
graphic: (function() {
|
||||
var items = [];
|
||||
// 7个维度信号灯(底部横排)
|
||||
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: sig.name.replace('力', ''),
|
||||
textAlign: 'center', fontSize: 9, fill: '#374151',
|
||||
},
|
||||
});
|
||||
});
|
||||
// 水平分布摘要文字
|
||||
var summaryText = '水平分布: ';
|
||||
summaryText += 'Lv4=' + levelCounts[4] + ' Lv3=' + levelCounts[3] + ' Lv2=' + levelCounts[2] + ' Lv1=' + 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: 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> (' + d.parent + ')<br/>' +
|
||||
'得分: ' + d.value[0] + '分<br/>' +
|
||||
'差异: ' + (d.value[1] > 0 ? '+' : '') + d.value[1] + '分<br/>' +
|
||||
'水平: ' + d.level;
|
||||
}
|
||||
},
|
||||
grid: { left: '12%', right: '5%', bottom: '12%', top: '8%' },
|
||||
xAxis: {
|
||||
name: '得分', nameLocation: 'center', nameGap: 28,
|
||||
nameTextStyle: { fontSize: 12 },
|
||||
min: 20, max: 80,
|
||||
splitLine: { show: true, lineStyle: { type: 'dashed', color: '#e5e7eb' } },
|
||||
},
|
||||
yAxis: {
|
||||
name: '与区均值差异', 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;
|
||||
return n.length > 5 ? n.substring(0, 5) + '…' : n;
|
||||
},
|
||||
color: '#374151',
|
||||
},
|
||||
}],
|
||||
// 象限标注
|
||||
graphic: [
|
||||
// 均值十字线
|
||||
{ type: 'line', shape: { x1: 0, y1: 0, x2: 0, y2: 0 }, silent: true },
|
||||
// 象限标签
|
||||
{ type: 'text', right: '8%', top: '10%',
|
||||
style: { text: '✦ 核心优势', fill: '#059669', fontSize: 11, fontWeight: 'bold' } },
|
||||
{ type: 'text', left: '14%', bottom: '14%',
|
||||
style: { text: '⚠ 急需改进', fill: '#dc2626', fontSize: 11, fontWeight: 'bold' } },
|
||||
{ type: 'text', right: '8%', bottom: '14%',
|
||||
style: { text: '⊙ 隐性风险', fill: '#d97706', fontSize: 11, fontWeight: 'bold' } },
|
||||
{ type: 'text', left: '14%', top: '10%',
|
||||
style: { text: '↗ 潜力项', 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: '区均值', 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() {
|
||||
// 为每个 thermo-{partId}-{subIndex} 容器渲染
|
||||
Object.keys(thermoData.data).forEach(function(sdName) {
|
||||
var d = thermoData.data[sdName];
|
||||
// 查找匹配的容器(遍历所有 thermo- 开头的元素)
|
||||
var allThermos = document.querySelectorAll('[id^="thermo-"]');
|
||||
allThermos.forEach(function(el) {
|
||||
if (el.getAttribute('data-rendered')) return;
|
||||
// 找到未渲染的容器,按顺序匹配
|
||||
});
|
||||
});
|
||||
|
||||
// 用更简单的方式:直接在维度渲染时按 partId 和 subIndex 匹配
|
||||
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: '▼ ' + score, textAlign: 'center', fontSize: 11, fontWeight: 'bold',
|
||||
fill: '#1e3a5f' },
|
||||
});
|
||||
// 区均值指针
|
||||
items.push({
|
||||
type: 'text', left: toPercent(distAvg), bottom: '0%',
|
||||
style: { text: '▲区均', textAlign: 'center', fontSize: 9, fill: '#6b7280' },
|
||||
});
|
||||
// 同类学校均值指针
|
||||
if (Math.abs(sameTypeAvg - distAvg) > 1) {
|
||||
items.push({
|
||||
type: 'text', left: toPercent(sameTypeAvg), bottom: '0%',
|
||||
style: { text: '▲同类', 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) {
|
||||
var shortName = step.name.length > 6 ? step.name.substring(0, 6) + '…' : step.name;
|
||||
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];
|
||||
if (step.type === 'current') return '当前总分: <strong>' + step.value + '</strong>分';
|
||||
if (step.type === 'potential') return '潜在总分: <strong>' + step.value + '</strong>分 (+' + waterfallData.total_gain + ')';
|
||||
return '<strong>' + step.name + '</strong><br/>' +
|
||||
(step.detail || '') + '<br/>预估总分贡献: +' + step.value + '分';
|
||||
},
|
||||
},
|
||||
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,208 @@
|
||||
{# 第一部分:总体概览的所有图表 #}
|
||||
<script>
|
||||
// ===== 1. 得分对比横向条形图 =====
|
||||
const scoreCompare = {{ score_compare_data | tojson }};
|
||||
(function() {
|
||||
const chart = echarts.init(document.getElementById('score-compare-chart'));
|
||||
const option = {
|
||||
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
|
||||
legend: { data: [scoreCompare.school_name, '区均值', '同类学校均值'], 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: scoreCompare.categories, 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: '区均值', type: 'bar', data: scoreCompare.district_avg,
|
||||
itemStyle: { color: COLORS.gray }, barWidth: '22%' },
|
||||
{ name: '同类学校均值', 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: 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 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: '{b}\n{c}所 ({d}%)', fontSize: 12 },
|
||||
emphasis: { label: { show: true, fontSize: 14, fontWeight: 'bold' } },
|
||||
data: [
|
||||
{ value: clusterDist.good_count, name: '课程实施较好类', itemStyle: { color: COLORS.primary } },
|
||||
{ value: clusterDist.weak_count, name: '课程实施待提升类', itemStyle: { color: COLORS.warning } },
|
||||
]
|
||||
}],
|
||||
graphic: [{
|
||||
type: 'text', left: 'center', top: '40%',
|
||||
style: {
|
||||
text: clusterDist.school_name + '\n属于' + (clusterDist.school_cluster === '较好' ? '较好类' : '待提升类'),
|
||||
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 option = {
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: {
|
||||
data: ['课程实施较好类(' + clusterLine.good_count + '所)', '课程实施待提升类(' + clusterLine.weak_count + '所)'],
|
||||
bottom: 5, textStyle: { fontSize: 11 }
|
||||
},
|
||||
grid: { left: '3%', right: '4%', bottom: '18%', top: '8%', containLabel: true },
|
||||
xAxis: {
|
||||
type: 'category', data: clusterLine.dimensions.map(d => d.replace('力', '')),
|
||||
axisLabel: { fontSize: 11, rotate: 15 }, boundaryGap: false,
|
||||
},
|
||||
yAxis: { type: 'value', min: 40, max: 60 },
|
||||
series: [
|
||||
{
|
||||
name: '课程实施较好类(' + clusterLine.good_count + '所)',
|
||||
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: '课程实施待提升类(' + clusterLine.weak_count + '所)',
|
||||
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 === '课程实施较好类') return name + '(' + clusterRadar.good_count + '所)';
|
||||
if (name === '课程实施待提升类') return name + '(' + clusterRadar.weak_count + '所)';
|
||||
return name;
|
||||
}
|
||||
},
|
||||
radar: {
|
||||
indicator: clusterRadar.dimensions.map(d => ({ name: 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 names = corrData.short_names || corrData.dimensions;
|
||||
const option = {
|
||||
tooltip: { position: 'top', formatter: p => corrData.dimensions[p.value[0]] + ' × ' + corrData.dimensions[p.value[1]] + '<br/>相关系数: ' + 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>
|
||||
@@ -0,0 +1,375 @@
|
||||
<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 h3 { color: var(--primary); margin: 12px 0 8px; }
|
||||
.llm-analysis h4 { color: var(--gray-700); margin: 10px 0 6px; }
|
||||
.llm-analysis ul { 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.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; }
|
||||
|
||||
/* 折叠按钮 */
|
||||
.toc-toggle {
|
||||
position: fixed;
|
||||
bottom: 24px; right: 24px;
|
||||
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>
|
||||
@@ -0,0 +1,19 @@
|
||||
{# 总结与建议 #}
|
||||
<div class="section" id="conclusion">
|
||||
<h1>第十部分 总结与改进建议</h1>
|
||||
|
||||
<!-- D. 进步空间瀑布图 -->
|
||||
{% if waterfall_data and waterfall_data.improvements %}
|
||||
<div class="chart-box">
|
||||
<div id="waterfall-chart" class="chart" style="height:400px"></div>
|
||||
<div class="chart-title">图10-1 进步空间分析:提升至水平3的潜在收益</div>
|
||||
</div>
|
||||
<div class="chart-caption">
|
||||
{{ llm_sections.get('part10_caption_waterfall', '') | safe }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="llm-analysis">
|
||||
{{ llm_sections.get('part10_conclusion', '<p>分析加载中...</p>') | safe }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,8 @@
|
||||
{# 封面 #}
|
||||
<div class="cover">
|
||||
<h1>课程实施监测数据分析报告</h1>
|
||||
<div class="subtitle">基于学校领导力视角的七维度分析</div>
|
||||
<div class="school-name">{{ school }}</div>
|
||||
<div class="subtitle">长宁区 · {{ school_info.get('type', '') }}</div>
|
||||
<div class="date">{{ generation_date }}</div>
|
||||
</div>
|
||||
@@ -0,0 +1,121 @@
|
||||
{# 单个维度详细分析(在 for 循环内 include) #}
|
||||
{# 需要外部传入: dim_name, dim_data, part_id, part_number #}
|
||||
|
||||
<div class="section" id="{{ part_id }}">
|
||||
<h1>第{{ part_number }}部分 {{ dim_name }}表现</h1>
|
||||
|
||||
<h2>一、整体表现</h2>
|
||||
|
||||
<div class="score-cards">
|
||||
<div class="score-card">
|
||||
<div class="label">{{ dim_name }}得分</div>
|
||||
<div class="value">{{ "%.1f"|format(dim_data.score) }}</div>
|
||||
<div class="diff {{ 'positive' if dim_data.diff_district > 0 else 'negative' }}">
|
||||
{{ "%+.1f"|format(dim_data.diff_district) }} vs 区均值
|
||||
</div>
|
||||
</div>
|
||||
<div class="score-card">
|
||||
<div class="label">同类学校均值</div>
|
||||
<div class="value" style="font-size:24px">{{ "%.1f"|format(dim_data.same_type_avg) }}</div>
|
||||
</div>
|
||||
<div class="score-card">
|
||||
<div class="label">区内排名</div>
|
||||
<div class="value">{{ dim_data.rank_in_district }}/{{ overall.total_schools }}</div>
|
||||
</div>
|
||||
<div class="score-card">
|
||||
<div class="label">维度类型</div>
|
||||
<div class="value" style="font-size:14px">
|
||||
<span class="type-badge {{ 'good' if '较好' in (dim_data.get('cluster', '') or '') else 'weak' }}">
|
||||
{{ dim_data.get('cluster', '-') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% set part_num = part_id | replace('part', '') %}
|
||||
<!-- 维度独立得分柱状图 + 子维度雷达/柱状图 -->
|
||||
<div class="chart-row">
|
||||
<div class="chart-box">
|
||||
<div id="dim-score-{{ part_id }}" class="chart"></div>
|
||||
<div class="chart-title">图{{ part_num }}-1 {{ dim_name }}得分情况</div>
|
||||
</div>
|
||||
<div class="chart-box">
|
||||
<div id="sub-radar-{{ part_id }}" class="chart"></div>
|
||||
<div class="chart-title">图{{ part_num }}-2 {{ dim_name }}各子维度得分情况</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-caption">
|
||||
{{ llm_sections.get(part_id + '_caption_dim_score', '') | safe }}
|
||||
</div>
|
||||
|
||||
<!-- 子维度柱状图 + 水平分布堆叠图 -->
|
||||
<div class="chart-row">
|
||||
<div class="chart-box">
|
||||
<div id="chart-{{ part_id }}" class="chart"></div>
|
||||
<div class="chart-title">图{{ part_num }}-3 {{ dim_name }}各子维度对比详情</div>
|
||||
</div>
|
||||
<div class="chart-box">
|
||||
<div id="level-{{ part_id }}" class="chart"></div>
|
||||
<div class="chart-title">图{{ part_num }}-4 {{ dim_name }}各子维度水平分布</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-caption">
|
||||
{{ llm_sections.get(part_id + '_caption_sub_detail', '') | safe }}
|
||||
</div>
|
||||
|
||||
<!-- 聚类散点图(2D或3D) -->
|
||||
<div class="chart-box">
|
||||
<div id="scatter-{{ part_id }}" class="chart" style="height:450px"></div>
|
||||
<div class="chart-title">图{{ part_num }}-5 {{ dim_name }}聚类分析散点图</div>
|
||||
</div>
|
||||
<div class="chart-caption">
|
||||
{{ llm_sections.get(part_id + '_caption_scatter', '') | safe }}
|
||||
</div>
|
||||
|
||||
<!-- 维度整体LLM分析 -->
|
||||
<div class="llm-analysis">
|
||||
{{ llm_sections.get(part_id + '_overall', '<p>分析加载中...</p>') | safe }}
|
||||
</div>
|
||||
|
||||
<!-- 水平对比表 -->
|
||||
<h3>{{ dim_name }}各子维度水平对比(表{{ part_num }}-1)</h3>
|
||||
<table class="level-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>子维度</th>
|
||||
<th>{{ school }}</th>
|
||||
<th>得分</th>
|
||||
<th>区均值</th>
|
||||
<th>排名</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for sub_dim in framework[dim_name].sub_dimensions %}
|
||||
{% set sd = sub_dimensions.get(sub_dim, {}) %}
|
||||
{% if sd %}
|
||||
<tr>
|
||||
<td>{{ sub_dim }}</td>
|
||||
<td class="lv{{ sd.level }}">水平{{ sd.level }}</td>
|
||||
<td>{{ "%.1f"|format(sd.score) }}</td>
|
||||
<td>{{ "%.1f"|format(sd.district_avg) }}</td>
|
||||
<td>{{ sd.rank_in_district }}/{{ overall.total_schools }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<hr class="section-divider">
|
||||
|
||||
<!-- 各三级维度详细分析 -->
|
||||
{% for sub_dim in framework[dim_name].sub_dimensions %}
|
||||
{% set sd = sub_dimensions.get(sub_dim, {}) %}
|
||||
{% if sd %}
|
||||
{% set sub_index = loop.index %}
|
||||
{% include 'sections/sub_dimension.html' %}
|
||||
{% if not loop.last %}
|
||||
<hr class="section-divider">
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
@@ -0,0 +1,106 @@
|
||||
{# 第一部分:测评背景与实施(固定内容,不需要LLM生成) #}
|
||||
<div class="section" id="part1">
|
||||
<h1>第一部分 测评背景与实施</h1>
|
||||
|
||||
<h2>一、测评背景</h2>
|
||||
|
||||
<p>为深入贯彻《教育部关于做好普通高中新课程新教材实施工作的指导意见》(教基〔2018〕15号)、《关于新时代推进普通高中育人方式改革的指导意见》(国办发〔2019〕29号)及《基础教育课程教学改革深化行动方案》(教材厅函〔2023〕3号)等一系列国家教育政策导向,积极响应《教育部办公厅关于开展课程实施与教材使用监测工作的通知》(教材厅函〔2023〕5号)的具体要求,上海市教委积极行动,发布了针对性的政策文件,进一步强化国家课程方案的实施转化,并提出建立健全课程实施的监测与反馈机制,以循证决策为引领,持续优化与改进课程规划与实施路径。</p>
|
||||
|
||||
<p>学校领导力是学校发展的核心驱动力。实施管理的素养导向立意与决策质量直接影响学校课程教学的整体表现与成效。课程实施管理的重点在于学校课程管理规划与施策,以及这些决策在教学活动中的切实落地。鉴于此,我们从学校领导力视角出发,确保课程领导力、教学变革力、学生发展指导力、教师发展支持力、教育质量评估力、教育条件保障力和数字化赋能力七大维度相互协同,共同作用于学校课程实施的全局。</p>
|
||||
|
||||
<h2>二、测评框架</h2>
|
||||
|
||||
<p>本报告从学校领导力视角出发,对课程实施监测指标数据进行系统性的分析与指标再建构,构建了学校课程实施的七维度指标体系(见表1-1)。</p>
|
||||
|
||||
<table class="data-table">
|
||||
<caption style="text-align:center; font-weight:600; margin-bottom:10px; color:var(--gray-700);">表1-1 指标体系表</caption>
|
||||
<thead>
|
||||
<tr><th style="width:120px;">二级维度</th><th style="width:150px;">三级维度</th><th>指标解读</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td rowspan="3"><strong>课程领导力</strong></td>
|
||||
<td>国家标准遵循</td>
|
||||
<td rowspan="3" style="text-align:left;">1.确保开齐开足国家课程;2.考察学校课程设置的合理性与多样性,包括学科类课程、校本课程、综合实践活动与劳动课程各类课程的课程安排与学生实践;3.关注核心素养导向课程的规范化建设情况</td>
|
||||
</tr>
|
||||
<tr><td>课程结构建设</td></tr>
|
||||
<tr><td>课程规范落实</td></tr>
|
||||
<tr>
|
||||
<td rowspan="2"><strong>教学变革力</strong></td>
|
||||
<td>教学方式变革</td>
|
||||
<td rowspan="2" style="text-align:left;">1.课中教学方式革新,倡导深度学习,重视个性化教育;2.课后作业设计、管理科学高效</td>
|
||||
</tr>
|
||||
<tr><td>作业设计与管理变革</td></tr>
|
||||
<tr>
|
||||
<td rowspan="2"><strong>学生发展指导力</strong></td>
|
||||
<td>学科发展的个性化辅导</td>
|
||||
<td rowspan="2" style="text-align:left;">1.根据学生的特点和需要,教师进行选课指导、个性化辅导等;2.学校提供个性化的生涯指导服务,建立完善的生涯发展支持体系;3.关注学生综合素质发展</td>
|
||||
</tr>
|
||||
<tr><td>学生生涯发展指导</td></tr>
|
||||
<tr>
|
||||
<td rowspan="3"><strong>教师发展支持力</strong></td>
|
||||
<td>培训支持</td>
|
||||
<td rowspan="3" style="text-align:left;">1.给教师提供入职培训、在职培训以及外出学习机会;2.学校定期组织教研活动,建立教学资源库;3.给教师提供参与科研项目和研究活动的支持</td>
|
||||
</tr>
|
||||
<tr><td>教研支持</td></tr>
|
||||
<tr><td>项目支持</td></tr>
|
||||
<tr>
|
||||
<td rowspan="4"><strong>教育质量评估力</strong></td>
|
||||
<td>科学评价观</td>
|
||||
<td rowspan="4" style="text-align:left;">1.面向学生核心素养与综合素质发展,确立科学评价观;2.关注学业质量,对标课程标准科学评价学生表现;3.关注综合素质多方面评价;4.关注学生实践活动表现</td>
|
||||
</tr>
|
||||
<tr><td>学业质量评估</td></tr>
|
||||
<tr><td>综合素质评估</td></tr>
|
||||
<tr><td>实践活动评估</td></tr>
|
||||
<tr>
|
||||
<td rowspan="3"><strong>教育条件保障力</strong></td>
|
||||
<td>区域推进</td>
|
||||
<td rowspan="3" style="text-align:left;">1.了解学校所在区域教育局对高中教育教学工作的推动情况;2.评估学校信息技术环境、教学设备、场馆设施的支持情况;3.关注学校如何统筹师资配置、校内资源、社区资源等</td>
|
||||
</tr>
|
||||
<tr><td>环境支持</td></tr>
|
||||
<tr><td>资源支持</td></tr>
|
||||
<tr>
|
||||
<td rowspan="3"><strong>数字化赋能力</strong></td>
|
||||
<td>教学方式创新</td>
|
||||
<td rowspan="3" style="text-align:left;">1.关注数智化资源支持与赋能课程、教学、评价的情况;2.了解学校校内外的数智化资源、信息化平台与信息系统建设情况</td>
|
||||
</tr>
|
||||
<tr><td>评价精准化与个性化</td></tr>
|
||||
<tr><td>课程迭代优化</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2>三、测评实施</h2>
|
||||
|
||||
<h3>(一)测评对象</h3>
|
||||
<p>本次监测面向长宁区普通高中学校,共有9所高中学校参与本次监测。每所参与监测的高中学校,均抽取了行政管理部门的学校管理者代表(包括校长、副校长、部门主任等)、各学科教研组组长参加问卷调查。</p>
|
||||
|
||||
<h3>(二)测评方法</h3>
|
||||
<p>基于上海市教师教育学院(上海市教委教研室)开展的中小学课程实施监测的研究框架,采用问卷调查方式采集学校课程实施信息,涵盖学校基础信息、课程实施情况和学科课程实施情况三个维度的数据。</p>
|
||||
|
||||
<h3>(三)数据分析</h3>
|
||||
<p>数据分析流程如下:</p>
|
||||
<p><strong>第一,指标体系重构。</strong>基于学校领导力视角的七维度重构维度、指标及具体题目的映射关系,形成多级指标体系。</p>
|
||||
<p><strong>第二,PCA(主成分分析)合成。</strong>对问卷中的各个题目进行标准化处理,利用主成分分析方法,将多个相关变量合成为主成分,提取各三级维度得分。</p>
|
||||
<p><strong>第三,标准化得分统一量纲。</strong>对PCA合成后的主成分进行标准化处理,将数据统一到均值50、标准差10的正态分布上。68.27%的样本处于[40分, 60分]区间,84.45%处于[30分, 70分],如某所学校得分为60分,意味着其表现超过了约84%的学校。</p>
|
||||
<p><strong>第四,对三级维度划分水平。</strong>依据维度内涵和学校表现分布,从题目层面确定水平划分的分界点分数,对各学校划分水平1~4(见表1-2)。</p>
|
||||
<p><strong>第五,聚类分析。</strong>基于标准化得分,对学校进行聚类分析,将具有相似特征的学校归为一类,识别出各维度下不同类型的学校群体。</p>
|
||||
|
||||
<h3>(四)三级维度水平划分</h3>
|
||||
<table class="data-table" style="font-size:13px;">
|
||||
<caption style="text-align:center; font-weight:600; margin-bottom:10px; color:var(--gray-700);">表1-2 三级维度水平划分表</caption>
|
||||
<thead>
|
||||
<tr><th>三级维度</th><th>水平4</th><th>水平3</th><th>水平2</th><th>水平1</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for sub_dim, levels in level_descriptions.items() %}
|
||||
<tr>
|
||||
<td style="text-align:left;"><strong>{{ sub_dim }}</strong></td>
|
||||
<td style="text-align:left; font-size:12px;">{{ levels.get(4, '') }}</td>
|
||||
<td style="text-align:left; font-size:12px;">{{ levels.get(3, '') }}</td>
|
||||
<td style="text-align:left; font-size:12px;">{{ levels.get(2, '') }}</td>
|
||||
<td style="text-align:left; font-size:12px;">{{ levels.get(1, '') }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -0,0 +1,140 @@
|
||||
{# 第一部分:总体表现 #}
|
||||
<div class="section" id="part2">
|
||||
<h1>第二部分 学校课程实施总体表现</h1>
|
||||
|
||||
<h2>一、学校课程实施总体状况</h2>
|
||||
|
||||
<!-- 得分卡片 -->
|
||||
<div class="score-cards">
|
||||
<div class="score-card">
|
||||
<div class="label">总体得分</div>
|
||||
<div class="value">{{ "%.1f"|format(overall.score) }}</div>
|
||||
<div class="diff {{ 'positive' if overall.score > overall.district_avg else 'negative' }}">
|
||||
{{ "%+.1f"|format(overall.score - overall.district_avg) }} vs 区均值({{ "%.1f"|format(overall.district_avg) }})
|
||||
</div>
|
||||
</div>
|
||||
<div class="score-card">
|
||||
<div class="label">区内排名</div>
|
||||
<div class="value">{{ overall.rank_in_district }}/{{ overall.total_schools }}</div>
|
||||
</div>
|
||||
<div class="score-card">
|
||||
<div class="label">同类学校均值</div>
|
||||
<div class="value" style="font-size:24px">{{ "%.1f"|format(overall.same_type_avg) }}</div>
|
||||
<div class="diff {{ 'positive' if overall.score > overall.same_type_avg else 'negative' }}">
|
||||
{{ "%+.1f"|format(overall.score - overall.same_type_avg) }} vs 同类
|
||||
</div>
|
||||
</div>
|
||||
<div class="score-card">
|
||||
<div class="label">课程实施类型</div>
|
||||
<div class="value" style="font-size:16px">
|
||||
<span class="type-badge {{ 'good' if '较好' in (overall.cluster or '') else 'weak' }}">
|
||||
{{ overall.cluster }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- A. 学校画像卡 + 优势-短板象限图 -->
|
||||
<div class="chart-row">
|
||||
<div class="chart-box">
|
||||
<div id="profile-card-chart" class="chart" style="height:420px"></div>
|
||||
<div class="chart-title">图2-0a 学校课程实施画像总览</div>
|
||||
</div>
|
||||
<div class="chart-box">
|
||||
<div id="quadrant-chart" class="chart" style="height:420px"></div>
|
||||
<div class="chart-title">图2-0b 三级维度优势-短板象限分析</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-caption">
|
||||
{{ llm_sections.get('part2_caption_profile', '') | safe }}
|
||||
</div>
|
||||
|
||||
<!-- 得分对比横向条形图 + 七维度雷达图 -->
|
||||
<div class="chart-row">
|
||||
<div class="chart-box">
|
||||
<div id="score-compare-chart" class="chart"></div>
|
||||
<div class="chart-title">图2-1 课程实施七维度得分对比</div>
|
||||
</div>
|
||||
<div class="chart-box">
|
||||
<div id="radar-chart" class="chart"></div>
|
||||
<div class="chart-title">图2-2 课程实施七维度雷达图</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-caption">
|
||||
{{ llm_sections.get('part2_caption_scores', '') | safe }}
|
||||
</div>
|
||||
|
||||
<!-- 聚类类型分布 + 两类学校折线对比 -->
|
||||
<div class="chart-row">
|
||||
<div class="chart-box">
|
||||
<div id="cluster-type-dist-chart" class="chart"></div>
|
||||
<div class="chart-title">图2-3 学校课程实施总体类型分布</div>
|
||||
</div>
|
||||
<div class="chart-box">
|
||||
<div id="cluster-line-chart" class="chart"></div>
|
||||
<div class="chart-title">图2-4 两类学校课程实施特征对比</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-caption">
|
||||
{{ llm_sections.get('part2_caption_cluster', '') | safe }}
|
||||
</div>
|
||||
|
||||
<!-- 聚类类型对比雷达图 + 区内学校排名条形图 -->
|
||||
<div class="chart-row">
|
||||
<div class="chart-box">
|
||||
<div id="cluster-radar-chart" class="chart"></div>
|
||||
<div class="chart-title">图2-5 学校课程实施类型特征对比(雷达图)</div>
|
||||
</div>
|
||||
<div class="chart-box">
|
||||
<div id="school-ranking-chart" class="chart"></div>
|
||||
<div class="chart-title">图2-6 区内各校总体得分排名</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-caption">
|
||||
{{ llm_sections.get('part2_caption_ranking', '') | safe }}
|
||||
</div>
|
||||
|
||||
<!-- 维度间相关性热力图 -->
|
||||
<div class="chart-box">
|
||||
<div id="correlation-chart" class="chart" style="height:450px"></div>
|
||||
<div class="chart-title">图2-7 课程实施维度间相关性分析</div>
|
||||
</div>
|
||||
<div class="chart-caption">
|
||||
{{ llm_sections.get('part2_caption_correlation', '') | safe }}
|
||||
</div>
|
||||
|
||||
<!-- LLM综合分析 -->
|
||||
<div class="llm-analysis">
|
||||
{{ llm_sections.get('part2_overview', '<p>分析加载中...</p>') | safe }}
|
||||
</div>
|
||||
|
||||
<h2>二、分维度状况</h2>
|
||||
|
||||
<!-- 维度得分对比表 -->
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>维度</th>
|
||||
<th>{{ school }}</th>
|
||||
<th>区均值</th>
|
||||
<th>同类学校均值</th>
|
||||
<th>差异(vs区)</th>
|
||||
<th>区内排名</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for dim_name, dim_data in dimensions.items() %}
|
||||
<tr>
|
||||
<td><strong>{{ dim_name }}</strong></td>
|
||||
<td>{{ "%.2f"|format(dim_data.score) }}</td>
|
||||
<td>{{ "%.2f"|format(dim_data.district_avg) }}</td>
|
||||
<td>{{ "%.2f"|format(dim_data.same_type_avg) }}</td>
|
||||
<td style="color: {{ 'green' if dim_data.diff_district > 0 else 'red' }}">
|
||||
{{ "%+.2f"|format(dim_data.diff_district) }}
|
||||
</td>
|
||||
<td>{{ dim_data.rank_in_district }}/{{ overall.total_schools }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -0,0 +1,59 @@
|
||||
{# 单个子维度详情(含金字塔) #}
|
||||
{# 需要外部传入: sub_dim, sd, part_id, sub_index, school #}
|
||||
{# sub_index 从1开始,子维度编号从"二"开始("一"是"整体表现") #}
|
||||
{% set cn_nums = ['', '二', '三', '四', '五', '六', '七', '八'] %}
|
||||
|
||||
<h2 id="sub-{{ part_id }}-{{ sub_index }}">{{ cn_nums[sub_index] }}、{{ sub_dim }}</h2>
|
||||
|
||||
<div class="score-cards" style="grid-template-columns: repeat(4, 1fr);">
|
||||
<div class="score-card">
|
||||
<div class="label">得分</div>
|
||||
<div class="value" style="font-size:24px">{{ "%.1f"|format(sd.score) }}</div>
|
||||
<div class="diff {{ 'positive' if sd.diff_district > 0 else 'negative' }}">
|
||||
{{ "%+.1f"|format(sd.diff_district) }} vs 区均值
|
||||
</div>
|
||||
</div>
|
||||
<div class="score-card">
|
||||
<div class="label">区均值</div>
|
||||
<div class="value" style="font-size:24px">{{ "%.1f"|format(sd.district_avg) }}</div>
|
||||
</div>
|
||||
<div class="score-card">
|
||||
<div class="label">水平等级</div>
|
||||
<div class="level-indicator level-{{ sd.level }}">水平{{ sd.level }}</div>
|
||||
</div>
|
||||
<div class="score-card">
|
||||
<div class="label">排名</div>
|
||||
<div class="value" style="font-size:24px">{{ sd.rank_in_district }}/{{ overall.total_schools }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- C. 温度计条形图 -->
|
||||
<div class="chart-box" style="padding:10px 16px;">
|
||||
<div id="thermo-{{ part_id }}-{{ sub_index }}" class="chart" style="height:80px"></div>
|
||||
</div>
|
||||
|
||||
<!-- 金字塔水平指示器(含各水平学校数量) -->
|
||||
{% set lv_cn = {4: '四', 3: '三', 2: '二', 1: '一'} %}
|
||||
<div class="pyramid-container">
|
||||
{% for lv in [4, 3, 2, 1] %}
|
||||
{% set lv_count = sd.level_distribution.get('水平' ~ lv, 0) if sd.level_distribution else '' %}
|
||||
<div class="pyramid-row">
|
||||
<div class="pyramid-block lv{{ lv }} {{ 'active' if sd.level == lv else 'dimmed' }}">
|
||||
水平{{ lv_cn[lv] }}{% if lv_count is not none and lv_count != '' %} · {{ lv_count }}所{% endif %}
|
||||
</div>
|
||||
{% if sd.level == lv %}
|
||||
<span class="pyramid-label"><span class="arrow">◀</span>{{ school }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<p style="text-indent:0; margin:10px 0; color: var(--gray-500); font-size:14px;">
|
||||
<strong>水平{{ sd.level }}含义:</strong>{{ sd.level_description }}
|
||||
</p>
|
||||
|
||||
<!-- 三级维度LLM分析 -->
|
||||
{% set sub_key = part_id + '_sub' + sub_index|string + '_' + sub_dim %}
|
||||
<div class="llm-analysis">
|
||||
{{ llm_sections.get(sub_key, '<p>分析加载中...</p>') | safe }}
|
||||
</div>
|
||||
@@ -0,0 +1,155 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ html_lang | default('zh-CN') }}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ school_display }} - {{ t.report_title }}</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/echarts-gl@2/dist/echarts-gl.min.js"></script>
|
||||
{% include 'components/styles.html' %}
|
||||
</head>
|
||||
<body>
|
||||
{# ===== 右侧目录导航(JS自动从h1/h2生成) ===== #}
|
||||
<nav class="toc-nav" id="toc-nav"><div class="toc-track" id="toc-track"></div></nav>
|
||||
<button class="toc-toggle" id="toc-toggle" onclick="document.getElementById('toc-nav').classList.toggle('show')">☰</button>
|
||||
|
||||
<div class="report-container">
|
||||
|
||||
{# ===== 封面 ===== #}
|
||||
{% include 'sections/cover.html' %}
|
||||
|
||||
{# ===== 第一部分:测评背景与实施 ===== #}
|
||||
{% include 'sections/part0_background.html' %}
|
||||
|
||||
{# ===== 第二部分:总体表现 ===== #}
|
||||
{% include 'sections/part1_overview.html' %}
|
||||
|
||||
{# ===== 第三~九部分:各维度详细分析 ===== #}
|
||||
{% set part_names = {
|
||||
"课程领导力": "part3", "教学变革力": "part4", "学生发展指导力": "part5",
|
||||
"教师发展支持力": "part6", "教育质量评估力": "part7",
|
||||
"教育条件保障力": "part8", "数字化赋能力": "part9"
|
||||
} %}
|
||||
|
||||
{% for dim_name, dim_data in dimensions.items() %}
|
||||
{% set part_id = part_names[dim_name] %}
|
||||
{% include 'sections/dimension_detail.html' %}
|
||||
{% endfor %}
|
||||
|
||||
{# ===== 总结与建议 ===== #}
|
||||
{% include 'sections/conclusion.html' %}
|
||||
|
||||
{# ===== 实践落地行动指南 ===== #}
|
||||
{% include 'sections/action_guide.html' %}
|
||||
|
||||
</div>
|
||||
|
||||
{# ===== ECharts 图表渲染 ===== #}
|
||||
{% include 'components/charts_common.js.html' %}
|
||||
{% include 'components/charts_overview.js.html' %}
|
||||
{% include 'components/charts_dimension.js.html' %}
|
||||
{% include 'components/charts_innovative.js.html' %}
|
||||
|
||||
{# ===== AI 对话助手(可选) ===== #}
|
||||
{% include 'components/chat_widget.html' %}
|
||||
|
||||
{# ===== 自动生成目录 + 滚动高亮 ===== #}
|
||||
<script>
|
||||
(function() {
|
||||
/* ---- 1. 自动扫描 h1/h2 生成 TOC ---- */
|
||||
var track = document.getElementById('toc-track');
|
||||
var container = document.querySelector('.report-container');
|
||||
// 取 .section 内的 h1(部分标题)和 h2[id](子维度标题)
|
||||
var headings = container.querySelectorAll('.section > h1, h2[id]');
|
||||
var currentGroup = null;
|
||||
|
||||
headings.forEach(function(h) {
|
||||
// 确保有 id 可跳转;h1 取父 .section 的 id,h2 自身有 id
|
||||
var id = '';
|
||||
if (h.tagName === 'H1') {
|
||||
var sec = h.closest('.section');
|
||||
id = sec ? sec.id : '';
|
||||
} else {
|
||||
id = h.id;
|
||||
}
|
||||
if (!id) return;
|
||||
|
||||
var a = document.createElement('a');
|
||||
a.href = '#' + id;
|
||||
a.className = 'toc-item';
|
||||
// 去掉"第X部分 / Part X." 前缀,只保留核心文字
|
||||
var text = h.textContent;
|
||||
// 中文:去掉"第X部分 "
|
||||
text = text.replace(/^第[一二三四五六七八九十]+部分\s*/, '');
|
||||
// 英文:去掉"Part X. "
|
||||
text = text.replace(/^Part\s+[IVXLC]+\.?\s*/, '');
|
||||
// h2 也去掉"二、""三、"等编号前缀
|
||||
text = text.replace(/^[一二三四五六七八九十]+、\s*/, '');
|
||||
// 英文 h2:去掉"I." "II." 等罗马数字前缀
|
||||
text = text.replace(/^[IVXLC]+\.\s*/, '');
|
||||
// 英文 h2:去掉 "(i) (ii)" 等小写罗马数字
|
||||
text = text.replace(/^\([ivxlc]+\)\s*/, '');
|
||||
a.textContent = text;
|
||||
|
||||
if (h.tagName === 'H1') {
|
||||
a.classList.add('level-1');
|
||||
a.setAttribute('data-group', id);
|
||||
currentGroup = id;
|
||||
} else {
|
||||
a.classList.add('level-2');
|
||||
if (currentGroup) a.setAttribute('data-parent', currentGroup);
|
||||
}
|
||||
track.appendChild(a);
|
||||
});
|
||||
|
||||
/* ---- 2. 事件绑定 ---- */
|
||||
var tocItems = track.querySelectorAll('.toc-item');
|
||||
var level2Items = track.querySelectorAll('.toc-item.level-2');
|
||||
var sections = [];
|
||||
|
||||
tocItems.forEach(function(item) {
|
||||
var target = document.getElementById(item.getAttribute('href').slice(1));
|
||||
if (target) sections.push({ el: target, link: item });
|
||||
|
||||
// 点击平滑滚动
|
||||
item.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
target && target.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
if (window.innerWidth <= 1280) {
|
||||
document.getElementById('toc-nav').classList.remove('show');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function expandGroup(gid) {
|
||||
level2Items.forEach(function(item) {
|
||||
item.classList.toggle('visible', item.getAttribute('data-parent') === gid);
|
||||
});
|
||||
}
|
||||
|
||||
/* ---- 3. 滚动高亮 ---- */
|
||||
var ticking = false;
|
||||
window.addEventListener('scroll', function() {
|
||||
if (ticking) return;
|
||||
ticking = true;
|
||||
window.requestAnimationFrame(function() {
|
||||
var scrollY = window.scrollY + 150;
|
||||
var current = null;
|
||||
for (var i = sections.length - 1; i >= 0; i--) {
|
||||
if (sections[i].el.offsetTop <= scrollY) { current = sections[i]; break; }
|
||||
}
|
||||
tocItems.forEach(function(item) { item.classList.remove('active'); });
|
||||
if (current) {
|
||||
current.link.classList.add('active');
|
||||
expandGroup(current.link.getAttribute('data-group') || current.link.getAttribute('data-parent') || '');
|
||||
}
|
||||
ticking = false;
|
||||
});
|
||||
});
|
||||
|
||||
window.dispatchEvent(new Event('scroll'));
|
||||
})();
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -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>
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
|
||||
// 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 %}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,75 @@
|
||||
{# 第十一部分:实践落地行动指南(拆分为并行segment,动态编号) #}
|
||||
{% set ns = namespace(idx=0) %}
|
||||
{# 章节小节编号:中文用一二三..,英文用 I II III.. #}
|
||||
{% set ag_section_labels = ag_section_labels if ag_section_labels is defined else (cn_nums if cn_nums is defined else ['I.','II.','III.','IV.','V.','VI.','VII.','VIII.','IX.']) %}
|
||||
|
||||
<div class="section" id="action-guide">
|
||||
<h1>{{ t.p11_h1 }}</h1>
|
||||
|
||||
<p class="section-intro" style="color:#555; font-style:italic; margin-bottom:1.5em; border-left:4px solid #2563eb; padding-left:12px;">
|
||||
{{ t.p11_intro }}
|
||||
</p>
|
||||
|
||||
{# ===== 战略聚焦:最紧迫的三件事(从第十部分引用,再次强化) ===== #}
|
||||
{% if llm_sections.get('part10_top3_priorities') %}
|
||||
<div class="top3-box" style="background: linear-gradient(135deg, #fef2f2, #fee2e2); border: 2px solid #dc2626; border-radius: 12px; padding: 24px; margin: 0 0 24px 0;">
|
||||
<h2 style="color: #991b1b; margin: 0 0 12px 0; font-size: 18px;">{{ t.p11_focus_title }}</h2>
|
||||
<p style="color: #7f1d1d; font-size: 14px; margin: 0 0 16px 0;">{{ t.p11_focus_intro | safe }}</p>
|
||||
<div class="llm-analysis" style="color: #7f1d1d;">
|
||||
{{ llm_sections.get('part10_top3_priorities') | safe }}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ===== 急需突破(水平1)——重点展开 ===== #}
|
||||
{% if llm_sections.get('part11_critical') %}
|
||||
<h2 id="action-critical">{{ ag_section_labels[ns.idx] }} {{ t.p11_critical }}</h2>
|
||||
{% set ns.idx = ns.idx + 1 %}
|
||||
<div class="llm-analysis">
|
||||
{{ llm_sections.get('part11_critical') | safe }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ===== 重点攻关(水平2)——重点展开 ===== #}
|
||||
{% if llm_sections.get('part11_attention') %}
|
||||
<h2 id="action-attention">{{ ag_section_labels[ns.idx] }} {{ t.p11_attention }}</h2>
|
||||
{% set ns.idx = ns.idx + 1 %}
|
||||
<div class="llm-analysis">
|
||||
{{ llm_sections.get('part11_attention') | safe }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ===== 如果水平1和2都没有,显示提示 ===== #}
|
||||
{% if not llm_sections.get('part11_critical') and not llm_sections.get('part11_attention') %}
|
||||
<p style="color:#059669; margin:1em 0;">{{ t.p11_no_weak }}</p>
|
||||
{% endif %}
|
||||
|
||||
{# ===== 稳步巩固(水平3)——精简概括 ===== #}
|
||||
{% if llm_sections.get('part11_maintain') %}
|
||||
<h2 id="action-maintain">{{ ag_section_labels[ns.idx] }} {{ t.p11_maintain }}</h2>
|
||||
{% set ns.idx = ns.idx + 1 %}
|
||||
<div class="llm-analysis">
|
||||
{{ llm_sections.get('part11_maintain') | safe }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ===== 深化引领(水平4)——精简概括 ===== #}
|
||||
{% if llm_sections.get('part11_excel') %}
|
||||
<h2 id="action-excel">{{ ag_section_labels[ns.idx] }} {{ t.p11_excel }}</h2>
|
||||
{% set ns.idx = ns.idx + 1 %}
|
||||
<div class="llm-analysis">
|
||||
{{ llm_sections.get('part11_excel') | safe }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ===== 学期行动时间表 ===== #}
|
||||
<h2 id="action-timeline">{{ ag_section_labels[ns.idx] }} {{ t.p11_timeline }}</h2>
|
||||
{% set ns.idx = ns.idx + 1 %}
|
||||
<div class="llm-analysis">
|
||||
{{ llm_sections.get('part11_timeline', '<p>' + t.p11_timeline_loading + '</p>') | safe }}
|
||||
</div>
|
||||
|
||||
<p class="section-note" style="color:#888; font-size:0.9em; margin-top:2em; border-top:1px solid #eee; padding-top:1em;">
|
||||
{{ t.p11_disclaimer | safe }}
|
||||
</p>
|
||||
</div>
|
||||
@@ -0,0 +1,40 @@
|
||||
{# 总结与建议 #}
|
||||
<div class="section" id="conclusion">
|
||||
<h1>{{ t.p10_h1 }}</h1>
|
||||
|
||||
{# ===== 最紧迫的三件事(置顶,让校长第一时间看到) ===== #}
|
||||
{% if llm_sections.get('part10_top3_priorities') %}
|
||||
<div class="top3-box" style="background: linear-gradient(135deg, #eff6ff, #dbeafe); border: 2px solid #3b82f6; border-radius: 12px; padding: 24px; margin: 0 0 24px 0;">
|
||||
<h2 style="color: #1e40af; margin: 0 0 16px 0; font-size: 20px;">{{ t.p10_h2_top3 }}</h2>
|
||||
<p style="color: #1e3a5f; font-size: 14px; margin: 0 0 16px 0; font-style: italic;">{{ t.top3_intro }}</p>
|
||||
<div class="llm-analysis">
|
||||
{{ llm_sections.get('part10_top3_priorities') | safe }}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ===== 跨维度综合分析 ===== #}
|
||||
{% if llm_sections.get('part10_cross_dimension') %}
|
||||
<h2>{{ t.p10_h2_cross }}</h2>
|
||||
<div class="llm-analysis">
|
||||
{{ llm_sections.get('part10_cross_dimension') | safe }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- D. 进步空间瀑布图 -->
|
||||
{% if waterfall_data and waterfall_data.improvements %}
|
||||
<h2>{{ t.p10_h2_improve }}</h2>
|
||||
<div class="chart-box">
|
||||
<div id="waterfall-chart" class="chart" style="height:400px"></div>
|
||||
<div class="chart-title">{{ t.p10_fig_waterfall }}</div>
|
||||
</div>
|
||||
<div class="chart-caption">
|
||||
{{ llm_sections.get('part10_caption_waterfall', '') | safe }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<h2>{{ t.p10_h2_review }}</h2>
|
||||
<div class="llm-analysis">
|
||||
{{ llm_sections.get('part10_conclusion', '<p>' + t.loading + '</p>') | safe }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,8 @@
|
||||
{# 封面 #}
|
||||
<div class="cover">
|
||||
<h1>{{ t.report_title }}</h1>
|
||||
<div class="subtitle">{{ t.report_subtitle }}</div>
|
||||
<div class="school-name">{{ school_display }}</div>
|
||||
<div class="subtitle">{{ district_display }} · {{ t.school_type(school_info.get('type', '')) }}</div>
|
||||
<div class="date">{{ generation_date }}</div>
|
||||
</div>
|
||||
@@ -0,0 +1,144 @@
|
||||
{# 单个维度详细分析(在 for 循环内 include) #}
|
||||
{# 需要外部传入: dim_name, dim_data, part_id #}
|
||||
{% set dim_display = t.dim(dim_name) %}
|
||||
{% set part_num = part_id | replace('part', '') %}
|
||||
|
||||
<div class="section" id="{{ part_id }}">
|
||||
<h1>{{ t.part(part_id) }}</h1>
|
||||
|
||||
<h2>{{ t.dim_section_overall }}</h2>
|
||||
|
||||
<div class="score-cards">
|
||||
<div class="score-card">
|
||||
<div class="label">{{ t.dim_score_label.format(name=dim_display) }}</div>
|
||||
<div class="value">{{ "%.1f"|format(dim_data.score) }}</div>
|
||||
<div class="diff {{ 'positive' if dim_data.diff_district > 0 else 'negative' }}">
|
||||
{{ "%+.1f"|format(dim_data.diff_district) }} {{ t.vs_district_avg }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="score-card">
|
||||
<div class="label">{{ t.same_type_avg }}</div>
|
||||
<div class="value" style="font-size:24px">{{ "%.1f"|format(dim_data.same_type_avg) }}</div>
|
||||
</div>
|
||||
<div class="score-card">
|
||||
<div class="label">{{ t.rank_in_district }}</div>
|
||||
<div class="value">{{ dim_data.rank_in_district }}/{{ overall.total_schools }}</div>
|
||||
{% if overall.total_schools_in_city and dim_data.rank_in_city is defined %}
|
||||
<div class="diff" style="color: var(--gray-500); font-size: 12px;">
|
||||
{{ t.sd_city_rank_prefix }} {{ dim_data.rank_in_city }}/{{ overall.total_schools_in_city }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="score-card">
|
||||
<div class="label">{{ t.performance }}</div>
|
||||
<div class="value" style="font-size:14px">
|
||||
{# 基于实际数据判断表现标签,而非聚类标签 #}
|
||||
{% set diff = dim_data.diff_district %}
|
||||
{% set rank = dim_data.rank_in_district %}
|
||||
{% set total = overall.total_schools %}
|
||||
{% set rank_pct = rank / total if total else 1 %}
|
||||
{% if diff > 3 and rank_pct <= 0.33 %}
|
||||
<span class="type-badge good">{{ t.perf_good }}</span>
|
||||
{% elif diff > 0 and rank_pct <= 0.5 %}
|
||||
<span class="type-badge good">{{ t.perf_above }}</span>
|
||||
{% elif diff >= -2 and rank_pct <= 0.67 %}
|
||||
<span class="type-badge neutral">{{ t.perf_neutral }}</span>
|
||||
{% elif diff < -3 or rank_pct > 0.8 %}
|
||||
<span class="type-badge weak">{{ t.perf_weak }}</span>
|
||||
{% else %}
|
||||
<span class="type-badge neutral">{{ t.perf_below }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 维度独立得分柱状图 + 子维度雷达/柱状图 -->
|
||||
<div class="chart-row">
|
||||
<div class="chart-box">
|
||||
<div id="dim-score-{{ part_id }}" class="chart"></div>
|
||||
<div class="chart-title">{{ t.dim_fig_score.format(n=part_num, name=dim_display) }}</div>
|
||||
</div>
|
||||
<div class="chart-box">
|
||||
<div id="sub-radar-{{ part_id }}" class="chart"></div>
|
||||
<div class="chart-title">{{ t.dim_fig_subradar.format(n=part_num, name=dim_display) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-caption">
|
||||
{{ llm_sections.get(part_id + '_caption_dim_score', '') | safe }}
|
||||
</div>
|
||||
|
||||
<!-- 子维度柱状图 + 水平分布堆叠图 -->
|
||||
<div class="chart-row">
|
||||
<div class="chart-box">
|
||||
<div id="chart-{{ part_id }}" class="chart"></div>
|
||||
<div class="chart-title">{{ t.dim_fig_subbar.format(n=part_num, name=dim_display) }}</div>
|
||||
</div>
|
||||
<div class="chart-box">
|
||||
<div id="level-{{ part_id }}" class="chart"></div>
|
||||
<div class="chart-title">{{ t.dim_fig_levels.format(n=part_num, name=dim_display) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-caption">
|
||||
{{ llm_sections.get(part_id + '_caption_sub_detail', '') | safe }}
|
||||
</div>
|
||||
|
||||
<!-- 聚类散点图(2D或3D) -->
|
||||
<div class="chart-box">
|
||||
<div id="scatter-{{ part_id }}" class="chart" style="height:450px"></div>
|
||||
<div class="chart-title">{{ t.dim_fig_scatter.format(n=part_num, name=dim_display) }}</div>
|
||||
</div>
|
||||
<div class="chart-caption">
|
||||
{{ llm_sections.get(part_id + '_caption_scatter', '') | safe }}
|
||||
</div>
|
||||
|
||||
<!-- 维度整体LLM分析 -->
|
||||
<div class="llm-analysis">
|
||||
{{ llm_sections.get(part_id + '_overall', '<p>' + t.loading + '</p>') | safe }}
|
||||
</div>
|
||||
|
||||
<!-- 水平对比表 -->
|
||||
<h3>{{ t.dim_section_subdims.format(name=dim_display) }} ({{ t.tbl }} {{ part_num }}-1)</h3>
|
||||
<table class="level-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ t.sub_dimension }}</th>
|
||||
<th>{{ school_display }}</th>
|
||||
<th>{{ t.score }}</th>
|
||||
<th>{{ t.district_avg }}</th>
|
||||
<th>{{ t.rank_in_district }}</th>
|
||||
{% if overall.total_schools_in_city %}<th>{{ t.rank_in_city }}</th>{% endif %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for sub_dim in framework[dim_name].sub_dimensions %}
|
||||
{% set sd = sub_dimensions.get(sub_dim, {}) %}
|
||||
{% if sd %}
|
||||
<tr>
|
||||
<td>{{ t.sub(sub_dim) }}</td>
|
||||
<td class="lv{{ sd.level }}">{{ t.level_label }} {{ sd.level }}</td>
|
||||
<td>{{ "%.1f"|format(sd.score) }}</td>
|
||||
<td>{{ "%.1f"|format(sd.district_avg) }}</td>
|
||||
<td>{{ sd.rank_in_district }}/{{ overall.total_schools }}</td>
|
||||
{% if overall.total_schools_in_city %}
|
||||
<td>{{ sd.rank_in_city if sd.rank_in_city is defined else '-' }}/{{ overall.total_schools_in_city }}</td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<hr class="section-divider">
|
||||
|
||||
<!-- 各三级维度详细分析 -->
|
||||
{% for sub_dim in framework[dim_name].sub_dimensions %}
|
||||
{% set sd = sub_dimensions.get(sub_dim, {}) %}
|
||||
{% if sd %}
|
||||
{% set sub_index = loop.index %}
|
||||
{% include 'sections/sub_dimension.html' %}
|
||||
{% if not loop.last %}
|
||||
<hr class="section-divider">
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
@@ -0,0 +1,106 @@
|
||||
{# 第一部分:测评背景与实施(固定内容,不需要LLM生成) #}
|
||||
<div class="section" id="part1">
|
||||
<h1>{{ t.part('part0') }}</h1>
|
||||
|
||||
<h2>{{ t.p0_h1_background }}</h2>
|
||||
|
||||
<p>{{ t.p0_para1 }}</p>
|
||||
|
||||
<p>{{ t.p0_para2 }}</p>
|
||||
|
||||
<h2>{{ t.p0_h1_framework }}</h2>
|
||||
|
||||
<p>{{ t.p0_framework_intro }}</p>
|
||||
|
||||
<table class="data-table">
|
||||
<caption style="text-align:center; font-weight:600; margin-bottom:10px; color:var(--gray-700);">{{ t.tbl_indicator_system }}</caption>
|
||||
<thead>
|
||||
<tr><th style="width:140px;">{{ t.th_secondary_dim }}</th><th style="width:170px;">{{ t.th_tertiary_dim }}</th><th>{{ t.th_indicator_interp }}</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td rowspan="3"><strong>{{ t.dim('课程领导力') }}</strong></td>
|
||||
<td>{{ t.sub('国家标准遵循') }}</td>
|
||||
<td rowspan="3" style="text-align:left;">{{ t.p0_interp_curriculum }}</td>
|
||||
</tr>
|
||||
<tr><td>{{ t.sub('课程结构建设') }}</td></tr>
|
||||
<tr><td>{{ t.sub('课程规范落实') }}</td></tr>
|
||||
<tr>
|
||||
<td rowspan="2"><strong>{{ t.dim('教学变革力') }}</strong></td>
|
||||
<td>{{ t.sub('教学方式变革') }}</td>
|
||||
<td rowspan="2" style="text-align:left;">{{ t.p0_interp_instruction }}</td>
|
||||
</tr>
|
||||
<tr><td>{{ t.sub('作业设计与管理变革') }}</td></tr>
|
||||
<tr>
|
||||
<td rowspan="2"><strong>{{ t.dim('学生发展指导力') }}</strong></td>
|
||||
<td>{{ t.sub('学科发展的个性化辅导') }}</td>
|
||||
<td rowspan="2" style="text-align:left;">{{ t.p0_interp_student }}</td>
|
||||
</tr>
|
||||
<tr><td>{{ t.sub('学生生涯发展指导') }}</td></tr>
|
||||
<tr>
|
||||
<td rowspan="3"><strong>{{ t.dim('教师发展支持力') }}</strong></td>
|
||||
<td>{{ t.sub('培训支持') }}</td>
|
||||
<td rowspan="3" style="text-align:left;">{{ t.p0_interp_teacher }}</td>
|
||||
</tr>
|
||||
<tr><td>{{ t.sub('教研支持') }}</td></tr>
|
||||
<tr><td>{{ t.sub('项目支持') }}</td></tr>
|
||||
<tr>
|
||||
<td rowspan="4"><strong>{{ t.dim('教育质量评估力') }}</strong></td>
|
||||
<td>{{ t.sub('科学评价观') }}</td>
|
||||
<td rowspan="4" style="text-align:left;">{{ t.p0_interp_quality }}</td>
|
||||
</tr>
|
||||
<tr><td>{{ t.sub('学业质量评估') }}</td></tr>
|
||||
<tr><td>{{ t.sub('综合素质评估') }}</td></tr>
|
||||
<tr><td>{{ t.sub('实践活动评估') }}</td></tr>
|
||||
<tr>
|
||||
<td rowspan="3"><strong>{{ t.dim('教育条件保障力') }}</strong></td>
|
||||
<td>{{ t.sub('区域推进') }}</td>
|
||||
<td rowspan="3" style="text-align:left;">{{ t.p0_interp_condition }}</td>
|
||||
</tr>
|
||||
<tr><td>{{ t.sub('环境支持') }}</td></tr>
|
||||
<tr><td>{{ t.sub('资源支持') }}</td></tr>
|
||||
<tr>
|
||||
<td rowspan="3"><strong>{{ t.dim('数字化赋能力') }}</strong></td>
|
||||
<td>{{ t.sub('教学方式创新') }}</td>
|
||||
<td rowspan="3" style="text-align:left;">{{ t.p0_interp_digital }}</td>
|
||||
</tr>
|
||||
<tr><td>{{ t.sub('评价精准化与个性化') }}</td></tr>
|
||||
<tr><td>{{ t.sub('课程迭代优化') }}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2>{{ t.p0_h1_implement }}</h2>
|
||||
|
||||
<h3>{{ t.p0_h2_target }}</h3>
|
||||
<p>{{ t.p0_target_text.format(district=district_display, total=total_schools_in_district) }}</p>
|
||||
|
||||
<h3>{{ t.p0_h2_method }}</h3>
|
||||
<p>{{ t.p0_method_text }}</p>
|
||||
|
||||
<h3>{{ t.p0_h2_analysis }}</h3>
|
||||
<p>{{ t.p0_analysis_intro }}</p>
|
||||
<p>{{ t.p0_step1 | safe }}</p>
|
||||
<p>{{ t.p0_step2 | safe }}</p>
|
||||
<p>{{ t.p0_step3 | safe }}</p>
|
||||
<p>{{ t.p0_step4 | safe }}</p>
|
||||
<p>{{ t.p0_step5 | safe }}</p>
|
||||
|
||||
<h3>{{ t.p0_h2_levels }}</h3>
|
||||
<table class="data-table" style="font-size:13px;">
|
||||
<caption style="text-align:center; font-weight:600; margin-bottom:10px; color:var(--gray-700);">{{ t.tbl_level_definition }}</caption>
|
||||
<thead>
|
||||
<tr><th>{{ t.th_tertiary_dim }}</th><th>{{ t.level_4 }}</th><th>{{ t.level_3 }}</th><th>{{ t.level_2 }}</th><th>{{ t.level_1 }}</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for sub_dim_zh in level_descriptions_keys %}
|
||||
<tr>
|
||||
<td style="text-align:left;"><strong>{{ t.sub(sub_dim_zh) }}</strong></td>
|
||||
<td style="text-align:left; font-size:12px;">{{ t.level_desc(sub_dim_zh, 4) }}</td>
|
||||
<td style="text-align:left; font-size:12px;">{{ t.level_desc(sub_dim_zh, 3) }}</td>
|
||||
<td style="text-align:left; font-size:12px;">{{ t.level_desc(sub_dim_zh, 2) }}</td>
|
||||
<td style="text-align:left; font-size:12px;">{{ t.level_desc(sub_dim_zh, 1) }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -0,0 +1,176 @@
|
||||
{# 第一部分:总体表现 #}
|
||||
<div class="section" id="part2">
|
||||
<h1>{{ t.part('part1') }}</h1>
|
||||
|
||||
<h2>{{ t.p1_h1_overall_status }}</h2>
|
||||
|
||||
<!-- 得分卡片 -->
|
||||
<div class="score-cards">
|
||||
<div class="score-card">
|
||||
<div class="label">{{ t.total_score }}</div>
|
||||
<div class="value">{{ "%.1f"|format(overall.score) }}</div>
|
||||
<div class="diff {{ 'positive' if overall.score > overall.district_avg else 'negative' }}">
|
||||
{{ "%+.1f"|format(overall.score - overall.district_avg) }} {{ t.vs_district_avg }} ({{ "%.1f"|format(overall.district_avg) }})
|
||||
</div>
|
||||
</div>
|
||||
<div class="score-card">
|
||||
<div class="label">{{ t.rank_in_district }}</div>
|
||||
<div class="value">{{ overall.rank_in_district }}/{{ overall.total_schools }}</div>
|
||||
</div>
|
||||
{% if overall.rank_in_city is defined and overall.total_schools_in_city %}
|
||||
<div class="score-card">
|
||||
<div class="label">{{ t.rank_in_city }}</div>
|
||||
<div class="value">{{ overall.rank_in_city }}/{{ overall.total_schools_in_city }}</div>
|
||||
{% set city_pct = (overall.rank_in_city / overall.total_schools_in_city * 100) | round(1) %}
|
||||
<div class="diff {{ 'positive' if city_pct <= 50 else 'negative' }}">
|
||||
{{ t.rank_in_top_pct.format(pct=city_pct) }}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="score-card">
|
||||
<div class="label">{{ t.same_type_avg }}</div>
|
||||
<div class="value" style="font-size:24px">{{ "%.1f"|format(overall.same_type_avg) }}</div>
|
||||
<div class="diff {{ 'positive' if overall.score > overall.same_type_avg else 'negative' }}">
|
||||
{{ "%+.1f"|format(overall.score - overall.same_type_avg) }} {{ t.vs_same_type }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="score-card">
|
||||
<div class="label">{{ t.cluster_type }}</div>
|
||||
<div class="value" style="font-size:16px">
|
||||
{% set cluster_raw = (overall.cluster or '') %}
|
||||
{% set is_good = ('较好' in cluster_raw) or ('High' in cluster_raw) or ('high' in cluster_raw) %}
|
||||
<span class="type-badge {{ 'good' if is_good else 'weak' }}">
|
||||
{{ t.cluster_label(cluster_raw) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- A. 学校画像卡 + 优势-短板象限图 -->
|
||||
<div class="chart-row">
|
||||
<div class="chart-box">
|
||||
<div id="profile-card-chart" class="chart" style="height:420px"></div>
|
||||
<div class="chart-title">{{ t.fig2_0a }}</div>
|
||||
</div>
|
||||
<div class="chart-box">
|
||||
<div id="quadrant-chart" class="chart" style="height:420px"></div>
|
||||
<div class="chart-title">{{ t.fig2_0b }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-caption">
|
||||
{{ llm_sections.get('part2_caption_profile', '') | safe }}
|
||||
</div>
|
||||
|
||||
<!-- 得分对比横向条形图 + 七维度雷达图 -->
|
||||
<div class="chart-row">
|
||||
<div class="chart-box">
|
||||
<div id="score-compare-chart" class="chart"></div>
|
||||
<div class="chart-title">{{ t.fig2_1 }}</div>
|
||||
</div>
|
||||
<div class="chart-box">
|
||||
<div id="radar-chart" class="chart"></div>
|
||||
<div class="chart-title">{{ t.fig2_2 }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-caption">
|
||||
{{ llm_sections.get('part2_caption_scores', '') | safe }}
|
||||
</div>
|
||||
|
||||
<!-- 聚类类型分布 + 两类学校折线对比 -->
|
||||
<div class="chart-row">
|
||||
<div class="chart-box">
|
||||
<div id="cluster-type-dist-chart" class="chart"></div>
|
||||
<div class="chart-title">{{ t.fig2_3 }}</div>
|
||||
</div>
|
||||
<div class="chart-box">
|
||||
<div id="cluster-line-chart" class="chart"></div>
|
||||
<div class="chart-title">{{ t.fig2_4 }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-caption">
|
||||
{{ llm_sections.get('part2_caption_cluster', '') | safe }}
|
||||
</div>
|
||||
|
||||
<!-- 聚类类型对比雷达图 + 区内学校排名条形图 -->
|
||||
<div class="chart-row">
|
||||
<div class="chart-box">
|
||||
<div id="cluster-radar-chart" class="chart"></div>
|
||||
<div class="chart-title">{{ t.fig2_5 }}</div>
|
||||
</div>
|
||||
<div class="chart-box">
|
||||
<div id="school-ranking-chart" class="chart"></div>
|
||||
<div class="chart-title">{{ t.fig2_6 }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-caption">
|
||||
{{ llm_sections.get('part2_caption_ranking', '') | safe }}
|
||||
</div>
|
||||
|
||||
<!-- 维度间相关性热力图 -->
|
||||
<div class="chart-box">
|
||||
<div id="correlation-chart" class="chart" style="height:450px"></div>
|
||||
<div class="chart-title">{{ t.fig2_7 }}</div>
|
||||
</div>
|
||||
<div class="chart-caption">
|
||||
{{ llm_sections.get('part2_caption_correlation', '') | safe }}
|
||||
</div>
|
||||
|
||||
<!-- LLM综合分析 -->
|
||||
<div class="llm-analysis">
|
||||
{{ llm_sections.get('part2_overview', '<p>' + t.loading + '</p>') | safe }}
|
||||
</div>
|
||||
|
||||
{# ===== 紧急预警框(仅在存在预警子维度时显示) ===== #}
|
||||
{% if llm_sections.get('part2_alert') %}
|
||||
<div class="alert-box" style="background: linear-gradient(135deg, #fef2f2, #fee2e2); border: 2px solid #ef4444; border-radius: 12px; padding: 20px 24px; margin: 24px 0;">
|
||||
<h3 style="color: #dc2626; margin: 0 0 12px 0; font-size: 18px;">{{ t.alert_title }}</h3>
|
||||
<div class="llm-analysis" style="color: #7f1d1d;">
|
||||
{{ llm_sections.get('part2_alert') | safe }}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ===== 学校定位与实际表现差距分析(所有学校均显示) ===== #}
|
||||
{% if llm_sections.get('part2_positioning_gap') %}
|
||||
<div class="positioning-gap-box" style="background: linear-gradient(135deg, #fffbeb, #fef3c7); border: 2px solid #f59e0b; border-radius: 12px; padding: 20px 24px; margin: 24px 0;">
|
||||
<h3 style="color: #b45309; margin: 0 0 12px 0; font-size: 18px;">{{ t.positioning_gap_title }}</h3>
|
||||
<div class="llm-analysis" style="color: #78350f;">
|
||||
{{ llm_sections.get('part2_positioning_gap') | safe }}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<h2>{{ t.p1_h1_dim_status }}</h2>
|
||||
|
||||
<!-- 维度得分对比表 -->
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ t.dimension }}</th>
|
||||
<th>{{ school_display }}</th>
|
||||
<th>{{ t.district_avg }}</th>
|
||||
<th>{{ t.same_type_avg }}</th>
|
||||
<th>{{ t.diff_vs_district }}</th>
|
||||
<th>{{ t.rank_in_district }}</th>
|
||||
{% if overall.total_schools_in_city %}<th>{{ t.rank_in_city }}</th>{% endif %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for dim_name, dim_data in dimensions.items() %}
|
||||
<tr>
|
||||
<td><strong>{{ t.dim(dim_name) }}</strong></td>
|
||||
<td>{{ "%.2f"|format(dim_data.score) }}</td>
|
||||
<td>{{ "%.2f"|format(dim_data.district_avg) }}</td>
|
||||
<td>{{ "%.2f"|format(dim_data.same_type_avg) }}</td>
|
||||
<td style="color: {{ 'green' if dim_data.diff_district > 0 else 'red' }}">
|
||||
{{ "%+.2f"|format(dim_data.diff_district) }}
|
||||
</td>
|
||||
<td>{{ dim_data.rank_in_district }}/{{ overall.total_schools }}</td>
|
||||
{% if overall.total_schools_in_city %}
|
||||
<td>{{ dim_data.rank_in_city if dim_data.rank_in_city is defined else '-' }}/{{ overall.total_schools_in_city }}</td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -0,0 +1,64 @@
|
||||
{# 单个子维度详情(含金字塔) #}
|
||||
{# 需要外部传入: sub_dim, sd, part_id, sub_index, school_display #}
|
||||
{# sub_index 从1开始,子维度在维度页面中的小节号从"二/II"开始("一/I"是"整体表现") #}
|
||||
{% set sub_section_label = sub_section_label_func(sub_index) if sub_section_label_func is defined else (cn_subnums[sub_index] if cn_subnums is defined else '') %}
|
||||
{% set sub_display = t.sub(sub_dim) %}
|
||||
|
||||
<h2 id="sub-{{ part_id }}-{{ sub_index }}">{{ sub_section_label }} {{ sub_display }}</h2>
|
||||
|
||||
<div class="score-cards" style="grid-template-columns: repeat(4, 1fr);">
|
||||
<div class="score-card">
|
||||
<div class="label">{{ t.sd_score }}</div>
|
||||
<div class="value" style="font-size:24px">{{ "%.1f"|format(sd.score) }}</div>
|
||||
<div class="diff {{ 'positive' if sd.diff_district > 0 else 'negative' }}">
|
||||
{{ "%+.1f"|format(sd.diff_district) }} {{ t.vs_district_avg }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="score-card">
|
||||
<div class="label">{{ t.sd_district_avg }}</div>
|
||||
<div class="value" style="font-size:24px">{{ "%.1f"|format(sd.district_avg) }}</div>
|
||||
</div>
|
||||
<div class="score-card">
|
||||
<div class="label">{{ t.sd_level_grade }}</div>
|
||||
<div class="level-indicator level-{{ sd.level }}">{{ t.level_label }} {{ sd.level }}</div>
|
||||
</div>
|
||||
<div class="score-card">
|
||||
<div class="label">{{ t.sd_rank }}</div>
|
||||
<div class="value" style="font-size:24px">{{ sd.rank_in_district }}/{{ overall.total_schools }}</div>
|
||||
{% if overall.total_schools_in_city and sd.rank_in_city is defined %}
|
||||
<div class="diff" style="color: var(--gray-500); font-size: 12px;">
|
||||
{{ t.sd_city_rank_prefix }} {{ sd.rank_in_city }}/{{ overall.total_schools_in_city }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- C. 温度计条形图 -->
|
||||
<div class="chart-box" style="padding:10px 16px;">
|
||||
<div id="thermo-{{ part_id }}-{{ sub_index }}" class="chart" style="height:80px"></div>
|
||||
</div>
|
||||
|
||||
<!-- 金字塔水平指示器(含各水平学校数量) -->
|
||||
<div class="pyramid-container">
|
||||
{% for lv in [4, 3, 2, 1] %}
|
||||
{% set lv_count = sd.level_distribution.get('水平' ~ lv, 0) if sd.level_distribution else '' %}
|
||||
<div class="pyramid-row">
|
||||
<div class="pyramid-block lv{{ lv }} {{ 'active' if sd.level == lv else 'dimmed' }}">
|
||||
{{ t.level_label }} {{ lv }}{% if lv_count is not none and lv_count != '' %} · {{ lv_count }} {{ t.schools_unit }}{% endif %}
|
||||
</div>
|
||||
{% if sd.level == lv %}
|
||||
<span class="pyramid-label"><span class="arrow">◀</span>{{ school_display }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<p style="text-indent:0; margin:10px 0; color: var(--gray-500); font-size:14px;">
|
||||
<strong>{{ t.sd_level_meaning.format(lv=sd.level) }}:</strong>{{ t.level_desc(sub_dim, sd.level) or sd.level_description }}
|
||||
</p>
|
||||
|
||||
<!-- 三级维度LLM分析 -->
|
||||
{% set sub_key = part_id + '_sub' + sub_index|string + '_' + sub_dim %}
|
||||
<div class="llm-analysis">
|
||||
{{ llm_sections.get(sub_key, '<p>' + t.loading + '</p>') | safe }}
|
||||
</div>
|
||||
Reference in New Issue
Block a user