fix(题单): 归属校验、进度分母、截止时间可见性等六处零碎
Some checks failed
Deploy / deploy (push) Has been cancelled

## user-progress 缺归属校验

学生端那条 GET /problem-sets/:id/user-progress 只有 requireTeacher,没有归属校验,
任何 Teacher Admin 都能读到别人建的题单的学生名单与进度。补上,口径与后台
loadOwned 一致(超管放行,其余人只能看自己建的),越权报 404。

顺带订正 docs/specs/phase4-review-authz.md:449:那条写着题单进度「显式下发真名」,
与代码不符 —— 学生端这条走 sampleUser 且没传 includeRealName,realName 恒为 null
(SQL 里那次 leftJoin userProfile 是白查的)。真正下发真名的是后台那条
GET /admin/problem-sets/:id/progress,结论仍成立,但当时漏掉了归属校验这个缺口。

## 头部进度的分母

分母只算必做题之后,ProblemSetHeader 还在拿 completedCount / problemsCount 算 ——
前者是必做完成数,后者是总题数。做完全部必做题的人会看到「9 / 10、90%」,而同一张
卡片上又标着「已完成」,题单 6 那 10 个人正是这种。改成读 userProgress,另外把
「另有 N 道选做」标出来,否则「共 10 道题目」和「9 / 9」对不上。

## 截止时间

end_time 管的不是「到点不能做了」,是「到点之前看不到自己加入题单之前的旧代码」,
而学生端一个字都不显示 —— 被挡住的人不知道为什么,也不知道什么时候解锁。头部加一个
带解释的「截止 …」标签;提交列表那个锁图标的说明也补上另外两条解锁路径。

## 两个必然筛空的筛选器

学生端题单列表的难度、状态两个下拉:线上 16 个题单全是 Easy / active,选「中等」
「困难」「已归档」永远是空列表。撤掉,保留关键词搜索。接口那两个 query 参数留着,
哪天真的用起这两个字段再把 select 加回来。

## 题目移出题单时的提交记录

旧栈 problemset/signals.py 的 post_delete 会清掉该题在本题单的 ProblemSetSubmission,
OJ2 没做,于是那张表一直在攒指向已移出题单的孤儿行。补上。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QqqZwxtXLo2GTqMi51C94D
This commit is contained in:
2026-09-01 00:01:58 -06:00
parent 9d9e104df6
commit e5a6f2d1e7
6 changed files with 62 additions and 61 deletions

View File

@@ -53,7 +53,7 @@ const columns: DataTableColumn<SubmissionListItem>[] = [
h(Icon, { icon: "catppuccin:lock" }),
),
default: () =>
"这道题在你已经加入的题单中,只有在题单中完成此题,代码才可见。",
"这道题在你已经加入的题单里,加入之前的提交先藏起来了。在题单中做出此题即可解锁;题单过了截止时间也会解锁。",
},
),
])

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import { Icon } from "@iconify/vue"
import type { ProblemSet, UserBadge as UserBadgeType } from "utils/types"
import { parseTime } from "utils/functions"
import UserBadge from "shared/components/UserBadge.vue"
import { useUserStore } from "shared/store/user"
@@ -32,14 +33,22 @@ function getDifficultyTag(difficulty: string) {
return difficultyMap[difficulty] || { type: "default", text: "未知" }
}
// 进度一律读 userProgress它的分母是**必做题数**,而 problemsCount 是总题数。
// 拿总题数当分母的话做完全部必做题的人会看到「9 / 10、90%」,而同一张卡片上
// 又标着「已完成」—— 题单 6 那 10 个人正是这种。
function getProgressPercentage() {
if (!props.problemSet) return 0
return Math.round(
((props.problemSet.completedCount ?? 0) / props.problemSet.problemsCount) *
100,
)
return Math.round(props.problemSet?.userProgress?.progressPercentage ?? 0)
}
// 有选做题时把「必做 N 题」标出来,否则「共 10 道题目」和「9 / 9」对不上
const optionalCount = computed(
() => props.problemSet.problemsCount - (props.problemSet.userProgress?.totalCount ?? 0),
)
const endTimeText = computed(() =>
props.problemSet.endTime ? parseTime(props.problemSet.endTime, "YYYY-MM-DD HH:mm") : "",
)
function handleJoin() {
emit("join")
}
@@ -55,6 +64,14 @@ function handleJoin() {
<n-tag :type="getDifficultyTag(problemSet.difficulty).type">
{{ getDifficultyTag(problemSet.difficulty).text }}
</n-tag>
<!-- 截止时间不是到点不能做了到点之前看不到自己加入题单之前的旧代码
所以这里要连着解释一句否则学生只看到一个日期不知道它管什么 -->
<n-tooltip trigger="hover" v-if="endTimeText">
<template #trigger>
<n-tag type="info">截止 {{ endTimeText }}</n-tag>
</template>
这个时间之前你在加入题单之前提交过的代码是看不到的在题单里做出该题即可解锁
</n-tooltip>
<n-h2 style="margin: 0">{{ problemSet.title }}</n-h2>
<n-tooltip trigger="hover" v-if="problemSet.description">
<template #trigger>
@@ -79,7 +96,11 @@ function handleJoin() {
<n-flex align="center" v-if="isJoined">
<n-text strong>完成进度</n-text>
<n-text>
{{ problemSet.completedCount }} / {{ problemSet.problemsCount }}
{{ problemSet.userProgress?.completedCount ?? 0 }} /
{{ problemSet.userProgress?.totalCount ?? 0 }}
</n-text>
<n-text depth="3" v-if="optionalCount > 0">
另有 {{ optionalCount }} 道选做
</n-text>
</n-flex>
<n-progress

View File

@@ -16,45 +16,22 @@ const problemSets = ref<ProblemSet[]>([])
interface ProblemSetQuery {
keyword: string
difficulty: string
status: string
}
// 使用分页 composable
const { query, clearQuery } = usePagination<ProblemSetQuery>(
{
keyword: useRouteQuery("keyword", "").value,
difficulty: useRouteQuery("difficulty", "").value,
status: useRouteQuery("status", "").value,
},
{
defaultLimit: 30,
},
)
const difficultyOptions = [
{ label: "全部", value: "" },
{ label: "简单", value: "Easy" },
{ label: "中等", value: "Medium" },
{ label: "困难", value: "Hard" },
]
const statusOptions = [
{ label: "全部", value: "" },
{ label: "活跃", value: "active" },
{ label: "已归档", value: "archived" },
]
async function listProblemSets() {
if (query.page < 1) query.page = 1
const offset = (query.page - 1) * query.limit
const res = await getProblemSetList(
offset,
query.limit,
query.keyword,
query.difficulty,
query.status,
)
const res = await getProblemSetList(offset, query.limit, query.keyword)
total.value = res.total
problemSets.value = res.results
}
@@ -102,35 +79,15 @@ watchDebounced(() => query.keyword, listProblemSets, {
})
// 监听其他查询条件变化
watch(
() => [query.page, query.limit, query.difficulty, query.status],
listProblemSets,
)
watch(() => [query.page, query.limit], listProblemSets)
</script>
<template>
<n-flex vertical size="large">
<!-- 难度和状态两个筛选器撤了线上 16 个题单全是 Easy / active中等困难
已归档永远是空列表接口那两个 query 参数还在哪天真的用起这两个字段
select 加回来即可 -->
<n-space>
<n-space align="center">
<n-text>难度</n-text>
<n-select
v-model:value="query.difficulty"
:options="difficultyOptions"
placeholder="选择难度"
style="width: 120px"
clearable
/>
</n-space>
<n-space align="center">
<n-text>状态</n-text>
<n-select
v-model:value="query.status"
:options="statusOptions"
placeholder="选择状态"
style="width: 120px"
clearable
/>
</n-space>
<n-input
v-model:value="query.keyword"
placeholder="搜索题单..."