fix(题单): complete_time 翻回「只设不清」;完成题单后那个必然报错的 tab

三件事,都是上一轮结论没查扎实留下的。

## complete_time

f9354b0 把「退回未完成时清空 complete_time」定成两边统一的行为,理由写的是
「快照里 complete_time 非空 ⟺ is_completed」。那个检查只做了一个方向(已完成但
没有完成时间 = 0 条),反方向没查 —— 实际有 4 条 is_completed=false 却留着
complete_time,全在题单 8:那批人 2025-11-14 在它还只有 6 题时完成过,老师后来
加到 12 题,进度退回未完成,完成时间保留了下来。

旧栈是故意不清的(problemset/models.py:218 只有 `if is_completed and not
complete_time` 这一条赋值),语义是「曾经完成于」。清空是重写时在学生路径上引入的,
上一版又把它推广到了后台路径。

翻回只设不清。「未完成 + 有完成时间」是允许的组合。反过来清空的代价不可逆:往一个
100 人已完成的题单里加一道题、再改主意删掉,这 100 个人的历史完成时间就一起被冲成
「现在」—— 上一轮的实跑已经在题单 9 上复现过。

## 两处注释订正

「旧后端不做加题后的重算」这个说法是错的,本仓早先的注释里有,上一版我照搬进了
services/problemset.ts 和提交信息。旧栈用 signals 做了,而且两件事都做:
problemset/signals.py 在 ProblemSetProblem 的 post_save / post_delete 上重算全部
参与者进度、再重算该题单全部奖章。重写时 views 里翻不到显式调用就当成没做,于是
奖章那一半漏了 —— 53 条应发未发正是这么来的。

另外补上 53 条里那 23 条的出处:旧栈的管理命令 fix_problemset_progress 按实际 AC
补 progress_detail,而 signals 不挂在 Progress 上,所以进度补了、奖章没补。

## 用户进度 tab

detail.vue 的 showTabs 写的是「超管 或 自己完成了题单」,而它渲染的
UserProgressView 调的是 requireTeacher 的接口。两边正好错开:

- 学生做完题单 → tab 出现 → 点进去 403(实跑确认:已完成该题单的 student 拿到
  403 permission-denied,devadmin 拿到 200)。而 loadUserProgress 没有 try/catch、
  loading.value = false 又写在 await 之后,转圈永远停不下来。
- Teacher Admin 看不到这一栏,尽管他们才是它的目标用户、也是唯一调得动的角色。

条件换成 isTeacherOrAbove,取数补 try/finally。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QqqZwxtXLo2GTqMi51C94D
This commit is contained in:
2026-08-31 08:42:40 -06:00
parent 05cf8011e3
commit d7a6414735
4 changed files with 40 additions and 23 deletions

View File

@@ -249,7 +249,8 @@ adminProblemSetRoutes.post("/problem-sets/:id/problems", requireTeacher, async (
hint: parsed.data.hint, hint: parsed.data.hint,
}).returning({ id: schema.problemsetProblem.id }) }).returning({ id: schema.problemsetProblem.id })
// 题目集变了,已加入的人的 totalProblemsCount / 百分比都得跟着变, // 题目集变了,已加入的人的 totalProblemsCount / 百分比都得跟着变,
// 否则学生看到的进度分母还是老的。旧后端没做这一步。 // 否则学生看到的进度分母还是老的。旧栈是靠 ProblemSetProblem 的 post_save 信号做的,
// 不在 views 里,别因为翻不到显式调用就以为它没做(见 services/problemset.ts
await resyncProgress(row.id) await resyncProgress(row.id)
return success(c, { id: created!.id }, 201) return success(c, { id: created!.id }, 201)
}) })

View File

@@ -47,9 +47,13 @@ export function computeProgress(
// 乘 10000 四舍五入再除 100保留两位小数 // 乘 10000 四舍五入再除 100保留两位小数
progressPercentage: total > 0 ? Math.round((completed / total) * 10000) / 100 : 0, progressPercentage: total > 0 ? Math.round((completed / total) * 10000) / 100 : 0,
isCompleted, isCompleted,
// 完成状态没了complete_time 也不该留着。学生那一路本来就是这么写的, // 只设不清,语义是「曾经完成于」,对齐旧栈 problemset/models.py:218。
// 后台这一路以前保留旧时间,于是同一行会出现「未完成 + 有完成时间」。 //
completeTime: isCompleted ? previousCompleteTime ?? now : null, // 「未完成 + 有完成时间」是允许的组合,快照里就有 4 条 —— 题单 8 那批人在它
// 还只有 6 题时完成过,老师后来加到 12 题,进度退回未完成,完成时间留了下来。
// 反过来清空的代价是不可逆:往一个 100 人已完成的题单里加一道题、再改主意删掉,
// 这 100 个人的历史完成时间就一起被冲成了「现在」。
completeTime: previousCompleteTime ?? (isCompleted ? now : null),
} }
} }
@@ -139,9 +143,14 @@ export async function recalculateBadge(badge: BadgeRow, known?: (BadgeCheck & {
/** /**
* 题目集或分值变动后,把所有参与者的进度整体重算一遍,再重算这份题单的奖章。 * 题目集或分值变动后,把所有参与者的进度整体重算一遍,再重算这份题单的奖章。
* *
* 旧后端两件事都不做:往题单里加一道题,学生那边的 totalProblemsCount 还是老数字 * 别信「旧后端不做这件事」那个说法(本仓早先的注释里有,是错的):旧栈用 signals 做了
* 进度百分比因此偏高;奖章那边更是没人回头判过,生产快照里因此攒下 53 条应发未发 * 而且两件事都做 —— problemset/signals.py 在 ProblemSetProblem 的 post_save / post_delete
* 30 名学生,其中 23 条来自 2026-05-22 那次批量补进度)。 * 上重算全部参与者的进度、再重算该题单全部奖章的资格。重写时 views 里看不到显式调用,
* 就当成没做,于是奖章那一半漏了,生产快照里攒下 53 条应发未发30 名学生)。
*
* 那 53 条里有 23 条另有出处:旧栈的管理命令 fix_problemset_progress 按实际 AC 记录补
* progress_detail可 signals 只挂在 ProblemSetProblem 和 ProblemSetBadge 上、不挂 Progress
* 所以进度补了、奖章一枚没补。OJ2 这边目前也还没有补进度的对应工具。
*/ */
export async function resyncProgress(problemsetId: number) { export async function resyncProgress(problemsetId: number) {
const [links, progresses, badges] = await Promise.all([ const [links, progresses, badges] = await Promise.all([

View File

@@ -9,6 +9,7 @@ import { usePagination } from "shared/composables/pagination"
import Pagination from "shared/components/Pagination.vue" import Pagination from "shared/components/Pagination.vue"
const route = useRoute() const route = useRoute()
const message = useMessage()
const problemSetId = computed(() => Number(route.params.problemSetId)) const problemSetId = computed(() => Number(route.params.problemSetId))
const progress = ref<ProblemSetProgress[]>([]) const progress = ref<ProblemSetProgress[]>([])
const loading = ref(false) const loading = ref(false)
@@ -54,6 +55,7 @@ async function loadUserProgress() {
if (completionFilter.value) { if (completionFilter.value) {
params.completionStatus = completionFilter.value params.completionStatus = completionFilter.value
} }
try {
const res = await getProblemSetUserProgress(problemSetId.value, params) const res = await getProblemSetUserProgress(problemSetId.value, params)
progress.value = res.results progress.value = res.results
@@ -66,7 +68,13 @@ async function loadUserProgress() {
if (res.problems) { if (res.problems) {
allProblems.value = res.problems allProblems.value = res.problems
} }
} catch (err: any) {
// finally 里收掉 loading以前 loading.value = false 写在 await 之后,
// 请求一失败403、断网转圈就永远停不下来
message.error("加载用户进度失败:" + (err.data || "未知错误"))
} finally {
loading.value = false loading.value = false
}
} }
// 监听分页参数变化 // 监听分页参数变化

View File

@@ -97,11 +97,10 @@ async function handleJoinProblemSet() {
} }
} }
const showTabs = computed( // 「用户进度」那一栏调的是 requireTeacher 的接口Teacher Admin | Super Admin
() => // 所以这里的条件必须跟它一致。以前写的是「超管 或 自己完成了题单」,两边正好错开:
userStore.isSuperAdmin || // 学生做完题单会看到这一栏、点进去 403而真正该用它的 Teacher Admin 反倒看不到。
(isJoined.value && problemSet.value?.userProgress?.isCompleted), const showTabs = computed(() => userStore.isTeacherOrAbove)
)
onMounted(init) onMounted(init)
</script> </script>