Files
hangbiao/app/templates/analysis.html
2026-09-22 10:48:18 +08:00

264 lines
13 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ item.title }} · 航标</title>
<link rel="stylesheet" href="/static/css/app.css">
</head>
<body>
<header class="topbar">
<a class="brand" href="/">
<div class="mark">航</div>
<div><b>航标</b><span>{{ item.title }}</span></div>
</a>
<div style="display:flex;gap:8px">
{% if result %}
<a class="ghost" href="/analysis/{{ item.id }}/export">导出 Excel</a>
{% endif %}
<form action="/logout" method="post"><button class="ghost" type="submit">退出</button></form>
</div>
</header>
<main class="wrap layout">
<aside class="rail">
<h2>分析航线</h2>
{% set steps = [
(1, '数据体检', '看清表格里有谁、有哪些考试'),
(2, '口径与目标', '决定算谁、瞄哪条线'),
(3, '滤波与回测', '理解预估为什么可信'),
(4, '下达指标', '班级目标、临界生、轨迹')
] %}
{% for n, title, desc in steps %}
<a class="step {% if step == n %}on{% endif %}" href="/analysis/{{ item.id }}?step={{ n }}">
<div class="dot">{{ n }}</div>
<div><b>{{ title }}</b><span>{{ desc }}</span></div>
</a>
{% endfor %}
</aside>
<section class="card panel">
{% if step == 1 %}
<h1>第一步:成绩表体检</h1>
<p class="muted">先确认系统读到的是你想算的那批人。分数量纲可以不同,系统稍后会在每次考试内部标准化。</p>
<div class="kpis">
<div class="kpi"><em>识别学生</em><strong>{{ summary.n }}</strong></div>
<div class="kpi"><em>班级</em><strong>{{ summary.n_class }}</strong></div>
<div class="kpi"><em>考试次数</em><strong>{{ summary.exams|length }}</strong></div>
<div class="kpi"><em>艺体 / 请假类</em><strong>{{ summary.art }} / {{ summary.leave }}</strong></div>
</div>
{% for w in summary.warnings %}
<div class="warn">{{ w }}</div>
{% endfor %}
<div class="grid-2">
<div>
<h3>工作表</h3>
<table class="table">
<thead><tr><th>表名</th><th>类型</th><th>行数</th></tr></thead>
<tbody>
{% for sh in summary.sheets %}
<tr><td>{{ sh.name }}</td><td>{{ sh.kind }}</td><td>{{ sh.rows }}</td></tr>
{% endfor %}
</tbody>
</table>
<h3>方向 / 学籍</h3>
<div class="chips">
{% for k,v in summary.tracks.items() %}<span class="chip">{{ k or "未分方向" }} {{ v }}</span>{% endfor %}
</div>
<div class="chips">
{% for k,v in summary.status.items() %}<span class="chip">{{ k }} {{ v }}</span>{% endfor %}
</div>
</div>
<div>
<h3>考试识别与分数尺度</h3>
<table class="table">
<thead><tr><th>考试</th><th>有效人数</th><th>最低</th><th>最高</th><th>平均</th></tr></thead>
<tbody>
{% for e in summary.score_scale %}
<tr>
<td>{{ e.exam }}</td><td>{{ e.n }}</td>
<td>{{ e.min }}</td><td>{{ e.max }}</td><td>{{ e.mean }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<p class="tiny">平均分从高一到高三明显下降是正常的(试卷结构不同),所以不能直接比原始分,只能比同次考试里的相对位置。</p>
</div>
</div>
<h3>各班人数</h3>
<div class="chips">
{% for k,v in summary.classes.items() %}<span class="chip">{{ k }}班 {{ v }}人</span>{% endfor %}
</div>
<div class="actions">
<a class="primary" href="/analysis/{{ item.id }}?step=2">口径看起来对,下一步</a>
</div>
{% endif %}
{% if step == 2 %}
<h1>第二步:口径与目标线</h1>
<p class="muted">下达指标前先把“算谁、瞄哪条线”钉死。默认排除艺体和长期请假,明远生可参与年级排名。</p>
<form method="post" action="/analysis/{{ item.id }}/settings" onsubmit="if(document.activeElement && document.activeElement.value==='compute'){this.querySelector('[value=compute]').textContent='正在滤波测算…'; this.querySelector('[value=compute]').disabled=false;}">
<h3>用于滤波的考试</h3>
<p class="tiny">建议全选。系统会自动让近考权重大、早考权重小;不是把五次考试简单平均。</p>
{% for e in summary.exams %}
<label class="check"><input type="checkbox" name="exams" value="{{ e }}" {% if e in settings.exams %}checked{% endif %}> {{ e }}(有效 {{ summary.exam_present[e] }} 人)</label>
{% endfor %}
<div class="grid-2" style="margin-top:12px">
<div>
<h3>纳入测算</h3>
<label class="check"><input type="checkbox" name="include_mingyuan" {% if settings.include_mingyuan %}checked{% endif %}> 包含明远 / 港籍学籍</label>
<label class="check"><input type="checkbox" name="include_other" {% if settings.include_other %}checked{% endif %}> 包含其他学籍</label>
<label class="check"><input type="checkbox" name="include_art" {% if settings.include_art %}checked{% endif %}> 包含艺体生</label>
<label class="check"><input type="checkbox" name="include_leave" {% if settings.include_leave %}checked{% endif %}> 包含请假 / 出国 / 休学备注</label>
{% if preview_n is not none %}<p class="tiny">按当前口径约 {{ preview_n }} 人进入测算。</p>{% endif %}
</div>
<div>
<h3>预估多远</h3>
<label>预测步长</label>
<select name="horizon">
<option value="1" {% if settings.horizon == 1 %}selected{% endif %}>下一次大考(1 步)</option>
<option value="2" {% if settings.horizon == 2 %}selected{% endif %}>大约高考前再考 2 次的距离</option>
</select>
<p class="tiny">步长越远,不确定性越大,临界生会变多,下达人数仍按期望取整到目标总量。</p>
</div>
</div>
<h3>各方向目标人数</h3>
<p class="tiny">这是年级总盘子。班级指标由个人达线概率加总后再取整,合计等于这里的人数。</p>
{% for g in settings.groups %}
<div class="explain" style="margin-bottom:12px">
<b>{{ g }}</b>
{% for t in settings.targets[g] %}
<div class="grid-2" style="margin-top:8px">
<input type="hidden" name="t_id_{{ g }}" value="{{ t.id }}">
<div><label>指标名称</label><input name="t_name_{{ g }}" value="{{ t.name }}"></div>
<div><label>年级总人数</label><input name="t_k_{{ g }}" type="number" min="1" value="{{ t.k }}"></div>
</div>
{% endfor %}
</div>
{% endfor %}
<div class="actions">
<button class="ghost" name="action" value="save">只保存口径</button>
<button class="primary" name="action" value="compute">开始滤波测算</button>
</div>
</form>
{% endif %}
{% if step == 3 %}
<h1>第三步:滤波在做什么</h1>
{% if not result %}
<p class="muted">还没有测算结果。请回到第二步点击「开始滤波测算」。</p>
<a class="primary" href="/analysis/{{ item.id }}?step=2">去设置口径</a>
{% else %}
<div class="route">
<i>原始分</i><i class="alt">同次标准化</i><i>卡尔曼估计水平</i><i class="alt">模拟下一次排名</i><i>个人概率</i><i class="alt">班级加总取整</i>
</div>
<p class="explain">
把每次考试看成一次带噪声的定位:发挥好坏是测量噪声 R,真实进步是过程噪声 Q。
R 相对大时,系统不会只信最近一次;Q 相对大时,会更快跟上近期趋势。这就是导航里“既看 GPS,也看车自己开的方向”。
</p>
{% for g in result.groups %}
<h3>{{ g.group }} · 模型参数</h3>
<div class="kpis">
<div class="kpi"><em>过程噪声 Q(真实水平会变)</em><strong>{{ g.model.q }}</strong></div>
<div class="kpi"><em>测量噪声 R(发挥波动)</em><strong>{{ g.model.r }}</strong></div>
<div class="kpi"><em>一步预测 RMSE</em><strong>{{ g.model.rmse }}</strong></div>
<div class="kpi"><em>测算人数 / 模拟次数</em><strong>{{ g.n }} / {{ g.model.n_sim }}</strong></div>
</div>
{% if g.holdout %}
<p class="tiny">留出最近一次考试做对照:用更早的成绩预估最近一次,看滤波是否比“只看上一场名次”更准。</p>
<table class="table">
<thead><tr><th>目标</th><th>K</th><th>滤波命中</th><th>只看最近一次</th></tr></thead>
<tbody>
{% for h in g.holdout %}
<tr>
<td>{{ h.target_name }}</td>
<td>{{ h.k }}</td>
<td>{{ h.kf_hit }} / {{ h.k }}({{ '%.0f'|format(h.kf_precision * 100) }}%)</td>
<td>{{ h.last_hit }} / {{ h.k }}({{ '%.0f'|format(h.last_precision * 100) }}%)</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% endfor %}
<div class="actions">
<a class="primary" href="/analysis/{{ item.id }}?step=4">看各班下达指标</a>
</div>
{% endif %}
{% endif %}
{% if step == 4 %}
<h1>第四步:下达指标</h1>
{% if not result %}
<p class="muted">还没有测算结果。请先完成第二步测算。</p>
{% else %}
<p class="explain">
「下达指标」是滤波期望取整后的人数,含义是:若学生按现有轨迹走,这个班本来应当到线的人数。
班会目标是超越它,而不是刚好打平。保底 / 冲刺是模拟分布的大约 20% / 80% 分位,用来看把握而不是另下两套考核。
</p>
{% for g in result.groups %}
{% for t in g.targets %}
<h3>{{ g.group }} · {{ t.name }}(年级 {{ t.k }} 人)</h3>
<p class="tiny">期望合计 {{ t.sum_expected }},临界生(概率 20%–80%){{ t.critical_n }} 人。点击班级看学生概率,点击学生看轨迹。</p>
<table class="table">
<thead>
<tr>
<th>班级</th><th>测算人数</th><th>最近在线</th><th>滤波期望</th>
<th>下达指标</th><th>保底</th><th>冲刺</th><th>相对最近</th>
</tr>
</thead>
<tbody>
{% for row in t.classes %}
<tr class="clickable" onclick="openClass({{ item.id }}, '{{ g.group }}', '{{ t.id }}', '{{ row.class_name }}')">
<td>{{ row.class_name }}班</td>
<td>{{ row.n }}</td>
<td>{{ row.current }}</td>
<td>{{ '%.2f'|format(row.expected) }}</td>
<td><b>{{ row.assigned }}</b></td>
<td>{{ row.floor }}</td>
<td>{{ row.stretch }}</td>
<td class="{% if row.delta > 0 %}delta-up{% elif row.delta < 0 %}delta-down{% endif %}">
{% if row.delta > 0 %}+{{ row.delta }}{% else %}{{ row.delta }}{% endif %}
</td>
</tr>
<tr>
<td colspan="8" class="tiny">{{ class_comment(row, t.name) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if t.critical %}
<h4>临界生(优先关注)</h4>
<table class="table">
<thead><tr><th>姓名</th><th>班</th><th>最近名次</th><th>达线概率</th></tr></thead>
<tbody>
{% for s in t.critical[:15] %}
<tr class="clickable" onclick="openStudent({{ item.id }}, '{{ s.sid }}')">
<td>{{ s.name }}</td><td>{{ s.class_name }}</td>
<td>{{ s.last_rank or "—" }}</td>
<td><span class="tag mid">{{ '%.1f'|format(s.p * 100) }}%</span></td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% endfor %}
{% endfor %}
<div class="actions">
<a class="primary" href="/analysis/{{ item.id }}/export">导出下达表</a>
<a class="ghost" href="/analysis/{{ item.id }}?step=2">调整口径再算</a>
</div>
{% endif %}
{% endif %}
</section>
</main>
<div class="drawer-bg" id="drawer">
<div class="drawer" id="drawer-body"></div>
</div>
<script>
window.HB_EXAMS = {{ (result.exams if result else summary.exams) | tojson }};
</script>
<script src="/static/js/app.js"></script>
</body>
</html>