feat(题单): 题单页重做——布置中的放最上面,详情顶上一条进度和奖章阶梯,没加入看得见题点不进;老师看全班、后台一屏编辑
Deploy / deploy (push) Canceled after 0s

- 列表:布置中的大卡片(做对几道、布置到哪天、再做对几道拿哪个奖章),其余小卡片,加入过的在前
- 详情:进度 + 奖章阶梯(下一个金圈「再做对 n 道」),题目两列写几点做对 / 交过几次;
  以前做对过的标「旧代码先藏着」;做完整条变绿,烟花只在做完那一刻放
- 老师多一个「全班情况」页签;题目页从链接直接进来没加入时,条上给「加入题单」
- 后台一屏编辑:布置一周 / 两周 / 四周 / 选日期,公开开关,粘贴多个题号一次加,拖动排序,奖章只剩两种条件
- 旧代码藏着的地方都说清楚是哪个题单、哪天之后能看
- 修掉列表卡片把选做题数说成「新加了 N 题」

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-09 22:01:39 +08:00
1 parent 8c53e28dcb
commit c17f88cdbb
32 files changed
+2630 -2181

No files matched your search

+615 -108
View File
@@ -1,130 +1,637 @@
<script setup lang="ts">
import { useRouteQuery } from "@vueuse/router"
import { useThemeVars } from "naive-ui"
import { getProblemSetDetail, getProblemSetProblems, joinProblemSet } from "oj/api"
import { useTone } from "oj/submission/composables/tone"
import { errorMessage } from "utils/api"
import { getProblemSetDetail, getProblemSetProblems, joinProblemSet, getUserBadges } from "../api"
import type { ProblemSet, ProblemSetProblem, UserBadge as UserBadgeType } from "utils/types"
import { useFireworks } from "../problem/composables/useFireworks"
import ProblemSetHeader from "./components/ProblemSetHeader.vue"
import ProblemSetProblemsList from "./components/ProblemSetProblemsList.vue"
import UserProgressView from "./components/UserProgressView.vue"
import { parseTime } from "utils/functions"
import type { ProblemSet, ProblemSetProblem } from "utils/types"
import { useAuthModalStore } from "shared/store/authModal"
import { useUserStore } from "shared/store/user"
import { useFireworks } from "../problem/composables/useFireworks"
import BadgeLadder from "./components/BadgeLadder.vue"
import ClassView from "./components/ClassView.vue"
/**
* 题单详情(设计稿「题单重设计」详情 B):顶上一条是进度和奖章阶梯,下面题目分两列。
*
* - 还没加入:题目看得见、点不进去,右边一个大按钮「加入题单,开始做」。加入是用户要留的
* 一步(「只是打开就算加入的话,会有疑惑的」),所以这里要让人一眼看懂为什么要点。
* - 布置期内以前做对过的题标「以前做对过 · 旧代码先藏着」,规则见后端 problemSetLockCutoffs。
* - 做完了:整条变绿,写几点做完、用了多久。烟花只在做完的那一刻放(做对最后一道、
* 1.5 秒后跳回来的那一下),以后再打开不放。
* - 老师多一个「全班情况」页签。
*/
const route = useRoute()
const router = useRouter()
const message = useMessage()
const { celebrate } = useFireworks()
const theme = useThemeVars()
const tone = useTone()
const userStore = useUserStore()
const authStore = useAuthModalStore()
const { celebrate } = useFireworks()
const problemSetId = computed(() => Number(route.params.problemSetId))
const problemSet = ref<ProblemSet | null>(null)
const id = computed(() => Number(route.params.problemSetId))
const set = ref<ProblemSet | null>(null)
const problems = ref<ProblemSetProblem[]>([])
const isJoined = ref(false)
const isJoining = ref(false)
const userBadges = ref<UserBadgeType[]>([])
const activeTab = ref("problems")
const missing = ref(false)
const joining = ref(false)
const tab = useRouteQuery<string>("tab", "problems")
async function loadProblemSetDetail() {
const res = await getProblemSetDetail(problemSetId.value)
problemSet.value = res
isJoined.value = res.userProgress?.isJoined || false
}
async function loadProblems() {
const res = await getProblemSetProblems(problemSetId.value)
problems.value = res
}
async function loadUserBadges() {
if (!isJoined.value) return
const res = await getUserBadges()
userBadges.value = res.filter(
(badge: UserBadgeType) => badge.badge.problemSetId === problemSetId.value,
)
}
async function init() {
await Promise.all([loadProblemSetDetail(), loadProblems()])
if (isJoined.value) {
if (problemSet.value?.userProgress?.isCompleted) {
celebrate()
}
loadUserBadges()
}
}
async function handleProblemClick(problemId: string) {
if (!userStore.isAuthed) {
message.warning("请先登录!")
return
}
if (!isJoined.value) {
message.warning("请先点击【加入题单】按钮!")
return
}
router.push({
name: "problemset problem",
params: {
problemSetId: problemSetId.value,
problemID: problemId,
},
})
}
async function handleJoinProblemSet() {
if (isJoining.value) return
isJoining.value = true
async function load() {
missing.value = false
try {
await joinProblemSet(problemSetId.value)
isJoined.value = true
message.success("成功加入题单!")
// 加入题单后加载用户徽章
await loadUserBadges()
} catch (err) {
message.error("加入题单失败:" + errorMessage(err, "未知错误"))
} finally {
isJoining.value = false
const [detail, list] = await Promise.all([
getProblemSetDetail(id.value),
getProblemSetProblems(id.value),
])
set.value = detail
problems.value = list
} catch {
missing.value = true
return
}
const done = set.value.userProgress.completeTime
if (set.value.userProgress.isCompleted && done && Date.now() - Date.parse(done) < 60_000) {
celebrate()
}
}
// 「用户进度」那一栏调的是 requireTeacher 的接口(Teacher Admin | Super Admin),
// 所以这里的条件必须跟它一致。以前写的是「超管 或 自己完成了题单」,两边正好错开:
// 学生做完题单会看到这一栏、点进去 403;而真正该用它的 Teacher Admin 反倒看不到。
const showTabs = computed(() => userStore.isTeacherOrAbove)
watch(id, load)
onMounted(load)
onMounted(init)
const progress = computed(() => set.value?.userProgress)
const joined = computed(() => !!progress.value?.isJoined)
/** 老师不用加入也能点进题目去看(不记进度),也不给他摆「加入」按钮 */
const canOpen = computed(() => joined.value || userStore.isTeacherOrAbove)
const optional = computed(() => (set.value ? set.value.problemsCount - set.value.requiredCount : 0))
const percent = computed(() =>
set.value && progress.value?.isJoined && set.value.requiredCount
? Math.min(100, (progress.value.completedCount / set.value.requiredCount) * 100)
: 0,
)
const hiddenCount = computed(() => problems.value.filter((item) => item.oldCodeHidden).length)
/** 做完用了多久:从加入到做完 */
const spent = computed(() => {
const p = progress.value
if (!p?.joinTime || !p.completeTime) return ""
const minutes = Math.round((Date.parse(p.completeTime) - Date.parse(p.joinTime)) / 60_000)
if (minutes < 60) return `${Math.max(1, minutes)} 分钟`
if (minutes < 24 * 60) return `${Math.round(minutes / 60)} 小时`
return `${Math.round(minutes / 1440)} 天`
})
const columns = computed(() => {
const half = Math.ceil(problems.value.length / 2)
return [problems.value.slice(0, half), problems.value.slice(half)].filter((col) => col.length)
})
function indexOf(item: ProblemSetProblem) {
return problems.value.indexOf(item) + 1
}
function solvedAt(time: string) {
const today = parseTime(new Date(), "YYYY-MM-DD") === parseTime(time, "YYYY-MM-DD")
return parseTime(time, today ? "HH:mm" : "M月D日")
}
function problemLink(item: ProblemSetProblem) {
return {
name: "problemset problem",
params: { problemSetId: id.value, problemID: item.problem._id },
}
}
async function join() {
if (!userStore.isAuthed) {
authStore.openLoginModal()
return
}
if (joining.value) return
joining.value = true
try {
await joinProblemSet(id.value)
message.success("加入了,现在开始在这里做对的题都会记进度")
await load()
} catch (err) {
message.error(errorMessage(err, "加入失败"))
} finally {
joining.value = false
}
}
/** 编辑只给出题人和超管(后台按归属校验,别人点进去是 404) */
const canEdit = computed(
() => !!set.value && (userStore.isSuperAdmin || set.value.createdBy.id === userStore.user?.id),
)
const info = computed(() => tone("info"))
const success = computed(() => tone("success"))
const warning = computed(() => tone("warning"))
const purple = { color: "#5b3fa8", background: "rgba(122, 95, 208, 0.12)" }
</script>
<template>
<div v-if="problemSet">
<ProblemSetHeader
:problem-set="problemSet"
:is-joined="isJoined"
:is-joining="isJoining"
:user-badges="userBadges"
@join="handleJoinProblemSet"
/>
<n-tabs v-if="showTabs" v-model:value="activeTab" animated>
<n-tab-pane name="problems" tab="题目列表">
<ProblemSetProblemsList
:problems="problems"
:is-joined="isJoined"
@problem-click="handleProblemClick"
/>
</n-tab-pane>
<n-tab-pane name="progress" tab="用户进度">
<UserProgressView />
</n-tab-pane>
</n-tabs>
<ProblemSetProblemsList
v-else
:problems="problems"
:is-joined="isJoined"
@problem-click="handleProblemClick"
/>
<n-result
v-if="missing"
status="404"
title="题单不存在"
description="可能还没公开,或者已经删掉了"
>
<template #footer>
<n-button @click="router.push({ name: 'problemsets' })">回到题单列表</n-button>
</template>
</n-result>
<div v-else-if="set" class="page">
<section class="band" :class="{ done: progress?.isCompleted }">
<span v-if="progress?.isCompleted" class="done-mark" aria-hidden="true">✓</span>
<div class="head">
<div class="row">
<router-link :to="{ name: 'problemsets' }" class="muted back">‹ 题单</router-link>
<h2 class="ell">{{ set.title }}</h2>
<span v-if="set.assigning" class="pill assign"
>布置到 {{ parseTime(set.assignedUntil!, "M月D日") }}</span
>
</div>
<template v-if="progress?.isCompleted">
<div class="done-text">
<b>全部做完了</b> · {{ parseTime(progress.completeTime!, "M月D日 HH:mm") }}
<template v-if="spent">,加入后用了 {{ spent }}</template>
</div>
</template>
<template v-else-if="joined">
<div class="row baseline">
<span class="muted small">做对</span>
<span class="num count">{{ progress!.completedCount }}</span>
<span class="muted num">/ {{ set.requiredCount }} 道</span>
<span class="muted tiny gap">
<template v-if="optional > 0">另有 {{ optional }} 道选做 · </template>
{{ parseTime(progress!.joinTime!, "M月D日 HH:mm") }} 加入
</span>
</div>
<div class="bar"><div class="fill" :style="{ width: `${percent}%` }"></div></div>
</template>
<div v-else class="muted small">
{{ set.createdBy.username }} 出的 · {{ set.problemsCount }} 道<template
v-if="optional > 0"
>,{{ optional }} 道选做</template
><template v-else>,全部必做</template>
<template v-if="!set.badges.length"> · 这个题单没有奖章</template>
</div>
<div v-if="set.description && set.description !== set.title" class="sec small desc">
{{ set.description }}
</div>
</div>
<template v-if="set.badges.length">
<span class="divider"></span>
<div class="ladder-box"><BadgeLadder :set="set" /></div>
</template>
<div class="spacer"></div>
<div v-if="!joined && !userStore.isTeacherOrAbove" class="join">
<n-button type="primary" size="large" :loading="joining" @click="join">
加入题单,开始做
</n-button>
<span class="muted tiny">加入以后,在题单里做对的题才算进度</span>
</div>
</section>
<div v-if="!joined && hiddenCount" class="notice" :style="purple">
<svg
class="lock"
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<rect x="5" y="11" width="14" height="9" rx="2" />
<path d="M8 11V8a4 4 0 0 1 8 0v3" />
</svg>
其中 <b>{{ hiddenCount }}</b> 道你以前做对过。布置期内(到
{{
parseTime(set.assignedUntil!, "M月D日")
}})这几道的旧代码先藏起来,在题单里重新做对就能看。
</div>
<div v-if="userStore.isTeacherOrAbove" class="tabs">
<button class="seg" :class="{ on: tab !== 'class' }" @click="tab = 'problems'">题目</button>
<button class="seg" :class="{ on: tab === 'class' }" @click="tab = 'class'">全班情况</button>
<div class="spacer"></div>
<n-button
v-if="canEdit"
size="small"
@click="router.push({ name: 'admin problemset edit', params: { problemSetId: set.id } })"
>
编辑题单
</n-button>
</div>
<ClassView v-if="userStore.isTeacherOrAbove && tab === 'class'" :problem-set-id="set.id" />
<template v-else>
<div v-if="problems.length" class="cols">
<section v-for="(col, ci) in columns" :key="ci" class="card list">
<component
:is="canOpen ? 'router-link' : 'div'"
v-for="item in col"
:key="item.id"
:to="canOpen ? problemLink(item) : undefined"
class="prow"
:class="{ off: !canOpen }"
>
<span v-if="item.isCompleted" class="dot done-dot">✓</span>
<span v-else-if="item.wrongCount" class="dot tried-dot">!</span>
<span v-else class="dot empty-dot"></span>
<span class="muted num idx">{{ indexOf(item) }}</span>
<span class="ell ptitle">{{ item.problem.title }}</span>
<span v-if="!item.isRequired" class="opt">选做</span>
<span v-if="item.oldCodeHidden" class="old" :style="purple"
><svg
class="lock"
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<rect x="5" y="11" width="14" height="9" rx="2" />
<path d="M8 11V8a4 4 0 0 1 8 0v3" /></svg
>以前做对过 · 旧代码先藏着</span
>
<div class="spacer"></div>
<span v-if="item.isCompleted && item.solvedTime" class="muted num tiny"
>{{ solvedAt(item.solvedTime) }} 做对</span
>
<span v-else-if="item.wrongCount && joined" class="tiny tried"
>交过 {{ item.wrongCount }} 次</span
>
<span v-else-if="canOpen" class="go">去做 ›</span>
</component>
</section>
</div>
<n-empty v-else description="这个题单还没有题目" />
<div v-if="joined && problems.length" class="muted tiny foot">做对一道会自动回到这里</div>
</template>
</div>
</template>
<style scoped></style>
<style scoped>
.page {
display: flex;
flex-direction: column;
gap: 12px;
}
.band {
box-sizing: border-box;
padding: 14px 20px 12px;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 16px 24px;
border: 1px solid v-bind("theme.borderColor");
border-radius: 6px;
background: v-bind("theme.cardColor");
}
.band.done {
background: v-bind("success.background");
border-color: transparent;
}
.done-mark {
width: 48px;
height: 48px;
border-radius: 50%;
flex-shrink: 0;
background: v-bind("theme.successColor");
color: #ffffff;
font-size: 24px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
}
.done-text {
font-size: 14px;
color: v-bind("success.color");
}
.head {
display: flex;
flex-direction: column;
gap: 6px;
width: 380px;
max-width: 100%;
min-width: 0;
}
.head h2 {
margin: 0;
font-size: 20px;
}
.desc {
line-height: 1.5;
}
.row {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.row.baseline {
align-items: baseline;
gap: 6px;
}
.back {
flex-shrink: 0;
font-size: 13px;
text-decoration: none;
}
.count {
font-size: 24px;
font-weight: 700;
}
.gap {
margin-left: 6px;
}
.bar {
height: 8px;
border-radius: 4px;
background: v-bind("theme.actionColor");
overflow: hidden;
}
.fill {
height: 100%;
background: v-bind("theme.successColor");
}
.divider {
width: 1px;
align-self: stretch;
background: v-bind("theme.dividerColor");
}
.ladder-box {
max-width: 100%;
overflow-x: auto;
}
.join {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 6px;
}
.lock {
flex-shrink: 0;
vertical-align: -1px;
}
.old .lock {
margin-right: 3px;
}
.notice {
box-sizing: border-box;
padding: 10px 14px;
border-radius: 6px;
font-size: 13px;
}
.tabs {
display: flex;
align-items: center;
}
.seg {
height: 30px;
padding: 0 14px;
border: 1px solid v-bind("theme.borderColor");
background: transparent;
font: inherit;
font-size: 13px;
color: v-bind("theme.textColor2");
cursor: pointer;
}
.seg:first-child {
border-radius: 4px 0 0 4px;
}
.seg + .seg {
border-left: none;
border-radius: 0 4px 4px 0;
}
.seg.on {
color: v-bind("success.color");
background: v-bind("success.background");
font-weight: 600;
}
.cols {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(420px, 1fr));
gap: 12px;
align-items: start;
}
.card {
border: 1px solid v-bind("theme.borderColor");
border-radius: 6px;
background: v-bind("theme.cardColor");
overflow: hidden;
}
.list {
display: flex;
flex-direction: column;
}
.prow {
height: 40px;
flex-shrink: 0;
box-sizing: border-box;
padding: 0 16px;
display: flex;
align-items: center;
gap: 12px;
border-bottom: 1px solid v-bind("theme.dividerColor");
color: v-bind("theme.textColor1");
text-decoration: none;
}
.prow:last-child {
border-bottom: none;
}
a.prow:hover {
background: v-bind("theme.hoverColor");
}
.prow.off {
color: v-bind("theme.textColor3");
}
.dot {
width: 22px;
height: 22px;
border-radius: 50%;
flex-shrink: 0;
box-sizing: border-box;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 700;
}
.done-dot {
background: v-bind("theme.successColor");
color: #ffffff;
}
.tried-dot {
background: v-bind("warning.background");
color: v-bind("warning.color");
}
.empty-dot {
border: 1.5px dashed v-bind("theme.borderColor");
}
.idx {
width: 20px;
text-align: right;
font-size: 13px;
flex-shrink: 0;
}
.ptitle {
font-size: 14px;
}
.opt {
height: 20px;
padding: 0 6px;
border-radius: 3px;
font-size: 12px;
border: 1px solid v-bind("theme.borderColor");
color: v-bind("theme.textColor3");
display: inline-flex;
align-items: center;
flex-shrink: 0;
}
.old {
height: 20px;
padding: 0 6px;
border-radius: 3px;
font-size: 12px;
display: inline-flex;
align-items: center;
white-space: nowrap;
flex-shrink: 0;
}
.tried {
color: v-bind("warning.color");
}
.go {
font-size: 13px;
color: v-bind("theme.primaryColor");
flex-shrink: 0;
}
.foot {
text-align: center;
}
.pill {
height: 22px;
padding: 0 8px;
border-radius: 3px;
font-size: 12px;
display: inline-flex;
align-items: center;
white-space: nowrap;
flex-shrink: 0;
}
.assign {
color: v-bind("info.color");
background: v-bind("info.background");
}
.spacer {
flex-grow: 1;
}
.muted {
color: v-bind("theme.textColor3");
}
.sec {
color: v-bind("theme.textColor2");
}
.small {
font-size: 13px;
}
.tiny {
font-size: 12px;
}
.num {
font-variant-numeric: tabular-nums;
}
.ell {
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
@media (max-width: 640px) {
.cols {
grid-template-columns: minmax(0, 1fr);
}
.head {
width: 100%;
}
.divider {
display: none;
}
.join {
align-items: stretch;
width: 100%;
}
}
</style>