first commit

This commit is contained in:
2026-09-22 10:48:18 +08:00
commit 2006f7b4a4
20 changed files with 1877 additions and 0 deletions

263
app/templates/analysis.html Normal file
View File

@@ -0,0 +1,263 @@
<!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>