fix(前端): 我改 /api2 前缀时漏了 5 处;修 4 个字段名不匹配;收紧 .gitignore

浏览器走查 + 真实 AI 调用暴露出来的,都是「接口好的、界面坏的」,
只靠脚本打 API 永远发现不了。

## 一、AI 功能和测试用例下载全都会 404(我今天造成的)

把 api2.ts 的 baseURL 从 /api2 改成 /api 时,只改了走 axios 实例的调用,
漏掉 5 处不走它的:

  oj/store/ai.ts                        AI 题解分析     原生 fetch("/api2/...")
  oj/problem/.../SubmissionResult.vue   AI 提示         原生 fetch
  oj/rank/list.vue                      AI 班级分析     原生 fetch
  oj/class/pk.vue                       AI 班级 PK      原生 fetch
  utils/download.ts                     测试用例下载    独立 axios,baseURL "/api2/admin"

生产的 Caddy 只认 /api,这 5 条全是 404。阶段 0 的端点清单里就写着
「盲点 2:4 处用原生 fetch」「盲点 3:download.ts 是独立 axios 实例」——
我今天读过那份文档,还是漏了。

修完实测四条 AI 链路全通(真实 DeepSeek 调用):
  /api/ai/analysis          200  SSE 176 分片 5.2s  start→delta→done→end 完整
  /api/ai/hint              200  2.4s  且未泄露参考答案
  /api/ai/class-analysis    200  12.6s
  /api/ai/class-pk-analysis 200  12.6s
落库确认(ai_analysis 新增,model=deepseek-v4-flash 与旧后端一致)。
hint 对别人的提交回 404 是**正确**的,它用 userId 限定只能看自己的。

## 二、4 个字段名永久对不上(重写引入的回归)

适配器只在**大写字母**前插下划线:`top10Avg` → `top10_avg`;
而旧 Django 给的是 `top_10_avg`(数字前也有)。前端还按旧名读。

影响比"三列空白"大:`row.top_10_avg.toFixed(2)` 在 undefined 上抛异常,
Vue 放弃整个子树 —— 班级PK 和班级排名页的**整个「分层统计」面板都不显示**
(Q1/Q3/四分位距/标准差/前10%/中间80%/后10%/人数 全没了),控制台还不报错。

修完实测:前10%均值 194.80、中间80%均值 93.79、后10%均值 49.40 全部出现。
写脚本按这个规律全仓扫过,确认只有这 4 个,改完复查归零。

## 三、.gitignore 差一点把生产库提交上去

原来只忽略 data/test_case/ 和 data/judge_server/,而 compose 会在
data/postgres/ 生成**整个数据库**(含 raw_password 明文列)、data/redis/、
data/backend/(学生上传的文件)。这次 `git add -A` 报权限错误才发现 ——
只因为 postgres 容器用别的 uid、目录读不了才没提交成功。改成忽略整个 data/。
已确认历史里从没提交过这些。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-08 05:37:24 -06:00
parent 34ae9b032c
commit 2edb8cfb0d
6 changed files with 37 additions and 32 deletions

11
.gitignore vendored
View File

@@ -20,9 +20,14 @@ build/
docs/spikes/node_modules/
docs/spikes/endpoints-*.json
# 本地判题验收数据来自生产测试点,只读挂载,不进版本库
data/test_case/
data/judge_server/
# data/ 整个目录都是运行时数据,一律不进版本库
#
# 必须写成整目录:原来只忽略了 test_case/ 和 judge_server/,而
# docker/compose.*.yml 会在这里生成 data/postgres/**整个数据库**,含
# raw_password 明文列、data/redis/、data/backend/(学生上传的文件)。
# 阶段 5 演练时 `git add -A` 差一点就把生产库快照提交上去,只因为 postgres
# 容器用的是别的 uid、目录读不了才失败。别再收窄这条。
data/
# 生产题目样本只用于本地验收,不进入 git
*.csv

View File

@@ -57,9 +57,9 @@ interface ClassComparison {
q3_ac: number
iqr: number
std_dev: number
top_10_avg: number
middle_80_avg: number
bottom_10_avg: number
top10_avg: number
middle80_avg: number
bottom10_avg: number
excellent_rate: number
pass_rate: number
active_rate: number
@@ -68,7 +68,7 @@ interface ClassComparison {
recent_total_ac?: number
recent_avg_ac?: number
recent_median_ac?: number
recent_top_10_avg?: number
recent_top10_avg?: number
recent_active_count?: number
}
@@ -176,7 +176,7 @@ async function analyzeWithAI() {
if (csrfToken) headers["X-CSRFToken"] = csrfToken
try {
const response = await fetch("/api2/ai/class-pk-analysis", {
const response = await fetch("/api/ai/class-pk-analysis", {
method: "POST",
headers,
body: JSON.stringify({
@@ -382,7 +382,7 @@ const top10AvgChartData = computed(() => {
const datasets = [
{
label: "前10%平均",
data: comparisons.value.map((c) => c.top_10_avg),
data: comparisons.value.map((c) => c.top10_avg),
backgroundColor: comparisons.value.map((_, i) => getClassColor(i).bg),
borderColor: comparisons.value.map((_, i) => getClassColor(i).border),
borderWidth: 2,
@@ -400,7 +400,7 @@ const bottom10AvgChartData = computed(() => {
const datasets = [
{
label: "后10%平均",
data: comparisons.value.map((c) => c.bottom_10_avg),
data: comparisons.value.map((c) => c.bottom10_avg),
backgroundColor: comparisons.value.map((_, i) => getClassColor(i).bg),
borderColor: comparisons.value.map((_, i) => getClassColor(i).border),
borderWidth: 2,
@@ -418,7 +418,7 @@ const middle80AvgChartData = computed(() => {
const datasets = [
{
label: "中间80%均值",
data: comparisons.value.map((c) => c.middle_80_avg),
data: comparisons.value.map((c) => c.middle80_avg),
backgroundColor: comparisons.value.map((_, i) => getClassColor(i).bg),
borderColor: comparisons.value.map((_, i) => getClassColor(i).border),
borderWidth: 2,
@@ -640,35 +640,35 @@ const tableColumns: DataTableColumn<ClassComparison>[] = [
},
{
title: "前10%均值",
key: "top_10_avg",
key: "top10_avg",
width: 100,
render: (row) =>
h(
"span",
{ style: { color: "#cf1322", fontWeight: "600" } },
row.top_10_avg.toFixed(2),
row.top10_avg.toFixed(2),
),
},
{
title: "中间80%均值",
key: "middle_80_avg",
key: "middle80_avg",
width: 110,
render: (row) =>
h(
"span",
{ style: { color: "#389e0d", fontWeight: "600" } },
row.middle_80_avg.toFixed(2),
row.middle80_avg.toFixed(2),
),
},
{
title: "后10%均值",
key: "bottom_10_avg",
key: "bottom10_avg",
width: 100,
render: (row) =>
h(
"span",
{ style: { color: "#096dd9", fontWeight: "500" } },
row.bottom_10_avg.toFixed(2),
row.bottom10_avg.toFixed(2),
),
},
{
@@ -956,17 +956,17 @@ const radarChartOptions = {
<!-- 分层统计 -->
<n-descriptions-item label="前10%均值">
<span style="color: #cf1322; font-weight: 600">{{
classData.top_10_avg.toFixed(2)
classData.top10_avg.toFixed(2)
}}</span>
</n-descriptions-item>
<n-descriptions-item label="中间80%均值">
<span style="color: #389e0d; font-weight: 600">{{
classData.middle_80_avg.toFixed(2)
classData.middle80_avg.toFixed(2)
}}</span>
</n-descriptions-item>
<n-descriptions-item label="后10%均值">
<span style="color: #096dd9; font-weight: 500">{{
classData.bottom_10_avg.toFixed(2)
classData.bottom10_avg.toFixed(2)
}}</span>
</n-descriptions-item>
@@ -1049,7 +1049,7 @@ const radarChartOptions = {
</n-descriptions-item>
<n-descriptions-item label="时间段前10名平均">
<span style="color: #ff4d4f; font-weight: 600">{{
classData.recent_top_10_avg?.toFixed(2)
classData.recent_top10_avg?.toFixed(2)
}}</span>
</n-descriptions-item>
<n-descriptions-item label="活跃学生数" :span="2">

View File

@@ -83,7 +83,7 @@ async function fetchHint(submissionId: string) {
headers["X-CSRFToken"] = csrfToken
}
const response = await fetch("/api2/ai/hint", {
const response = await fetch("/api/ai/hint", {
method: "POST",
headers,
body: JSON.stringify({ submissionId }),

View File

@@ -95,7 +95,7 @@ async function analyzeSingleClassWithAI() {
if (csrfToken) headers["X-CSRFToken"] = csrfToken
try {
const response = await fetch("/api2/ai/class-analysis", {
const response = await fetch("/api/ai/class-analysis", {
method: "POST",
headers,
body: JSON.stringify({ comparison: classDetailData.value }),
@@ -158,9 +158,9 @@ interface ClassComparison {
q3_ac: number
iqr: number
std_dev: number
top_10_avg: number
middle_80_avg: number
bottom_10_avg: number
top10_avg: number
middle80_avg: number
bottom10_avg: number
excellent_rate: number
pass_rate: number
active_rate: number
@@ -719,17 +719,17 @@ watch(
</n-descriptions-item>
<n-descriptions-item label="前10%均值">
<span style="color: #cf1322; font-weight: 600">{{
classDetailData.top_10_avg.toFixed(2)
classDetailData.top10_avg.toFixed(2)
}}</span>
</n-descriptions-item>
<n-descriptions-item label="中间80%均值">
<span style="color: #389e0d; font-weight: 600">{{
classDetailData.middle_80_avg.toFixed(2)
classDetailData.middle80_avg.toFixed(2)
}}</span>
</n-descriptions-item>
<n-descriptions-item label="后10%均值">
<span style="color: #096dd9; font-weight: 500">{{
classDetailData.bottom_10_avg.toFixed(2)
classDetailData.bottom10_avg.toFixed(2)
}}</span>
</n-descriptions-item>
<n-descriptions-item label="人数">

View File

@@ -104,7 +104,7 @@ export const useAIStore = defineStore("ai", () => {
}
try {
const response = await fetch("/api2/ai/analysis", {
const response = await fetch("/api/ai/analysis", {
method: "POST",
headers,
body: JSON.stringify({

View File

@@ -1,9 +1,9 @@
import axios from "axios"
// 指向新后端的 /api2/admin。响应是 zip 二进制,不走 { error, data } 信封,
// 指向新后端的 /api/admin。响应是 zip 二进制,不走 { error, data } 信封,
// 所以不能复用 utils/api2 的拦截器(它会把 response.data.data 取出来)。
const http = axios.create({
baseURL: "/api2/admin",
baseURL: "/api/admin",
responseType: "blob",
withCredentials: true,
})