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

+15 -25
View File
@@ -36,10 +36,10 @@ import {
type HeatmapItem,
type SolvedList,
type ProblemSet,
type ProblemSetBadge,
type ProblemSetClassView,
type ProblemSetList,
type ProblemSetLock,
type ProblemSetProblem,
type ProblemSetProgressList,
type UserBadge,
type FlowchartStatistics,
type SubmissionStatistics,
@@ -482,23 +482,15 @@ export function retryFlowchartSubmission(submissionId: string) {
return api.post<{ status: string }>(`flowcharts/${encodeURIComponent(submissionId)}/retry`)
}
/** 自己在这道题上评完的每一次(早的在前),加入题单之前的已经滤掉 */
/** 自己在这道题上评完的每一次(早的在前),题单布置期内藏着的已经滤掉 */
export function getFlowchartScores(problemId: number) {
return api.get<FlowchartScores>(`problems/${problemId}/flowchart/scores`)
}
// ==================== 题单相关API ====================
export function getProblemSetList(
offset = 0,
limit = 10,
keyword = "",
difficulty = "",
status = "",
) {
return api.get<ProblemSetList>("problem-sets", {
params: { offset, limit, keyword, difficulty, status },
})
export function getProblemSetList(offset = 0, limit = 10, keyword = "") {
return api.get<ProblemSetList>("problem-sets", { params: { offset, limit, keyword } })
}
export function getProblemSetDetail(id: number) {
@@ -517,20 +509,18 @@ export function getUserBadges(username?: string) {
return api.get<UserBadge[]>(`users/${encodeURIComponent(username ?? "me")}/badges`)
}
export function getProblemSetBadges(problemSetId: number) {
return api.get<ProblemSetBadge[]>(`problem-sets/${problemSetId}/badges`)
/** 老师看某个班在题单里的情况;不传班级就看服务端推断的那个班 */
export function getProblemSetClassView(problemSetId: number, className = "") {
return api.get<ProblemSetClassView>(`problem-sets/${problemSetId}/class-view`, {
params: className ? { className } : {},
})
}
export function getProblemSetUserProgress(
problemSetId: number,
params?: {
limit?: number
offset?: number
className?: string
completionStatus?: "" | "completed" | "in_progress" | "not_started"
},
) {
return api.get<ProblemSetProgressList>(`problem-sets/${problemSetId}/user-progress`, { params })
/** 这道题以前的代码为什么藏着:落在哪些正在布置的题单里。传主键或题号都行 */
export function getProblemSetLocks(problem: { id: number } | { displayId: string }) {
return api.get<ProblemSetLock[]>("problem-sets/locks", {
params: "id" in problem ? { problemId: problem.id } : { problemDisplayId: problem.displayId },
})
}
export function getExercises(tutorialId: number): Promise<Exercise[]> {
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { storeToRefs } from "pinia"
import type { RouteLocationRaw } from "vue-router"
import { getProblemSetDetail, getProblemSetProblems } from "oj/api"
import { getProblemSetDetail, getProblemSetProblems, joinProblemSet } from "oj/api"
import { useContestStore } from "oj/store/contest"
import { isFlowchartPass, useFlowchartStore } from "oj/store/flowchart"
import { useLessonStore } from "oj/store/lesson"
@@ -161,6 +161,7 @@ const setBar = computed(() => {
const done = required.filter((item) => item.isCompleted).length
return {
title: problemSet.value.title,
joined: problemSet.value.userProgress.isJoined,
home: { name: "problemset", params: { problemSetId: ctx.value.problemSetId } },
done,
total: required.length,
@@ -171,6 +172,22 @@ const setBar = computed(() => {
}
})
// 没加入就点进了题(直接打开的链接):在条上给个「加入」,不然做对了也不记进度
const joiningSet = ref(false)
async function joinHere() {
const id = ctx.value.problemSetId
if (!id || joiningSet.value) return
joiningSet.value = true
try {
await joinProblemSet(Number(id))
problemSet.value = await getProblemSetDetail(Number(id))
} catch {
// 加入失败就还是那条提示,回题单页再点一次
} finally {
joiningSet.value = false
}
}
// 判题那一路已经记了账(judge/run.ts),这里只是把这一条就地改掉,不用重拉
onSolvedHere(() => {
const item = setProblems.value.find(
@@ -294,13 +311,19 @@ onSolvedHere(() => {
<router-link :to="setBar.home" class="back set-back" :title="`回到题单:${setBar.title}`">
‹ {{ setBar.title }}
</router-link>
<div class="set-progress" aria-hidden="true">
<div v-if="setBar.joined" class="set-progress" aria-hidden="true">
<div class="set-progress-fill" :style="{ width: `${setBar.percentage}%` }" />
</div>
<span class="meta">
<span v-if="setBar.joined" class="meta">
做完 {{ setBar.done }}/{{ setBar.total }}
<template v-if="setBar.position"> · 第 {{ setBar.position }} 题</template>
</span>
<template v-else>
<span class="meta">还没加入,在这里做对了不算进度</span>
<n-button size="tiny" type="primary" :loading="joiningSet" @click="joinHere"
>加入题单</n-button
>
</template>
<div class="spacer" />
<component
:is="setBar.previous ? 'router-link' : 'span'"
@@ -236,7 +236,8 @@ function goCode() {
</template>
</div>
<p v-if="hiddenCount" class="note">
另外 {{ hiddenCount }} 次是加入题单之前画的,先藏起来了:在题单里画到 A 或 S 就解锁
另外 {{ hiddenCount }} 次是题单布置之前画的,先藏起来了:在题单里画到 A 或
S,或者布置期结束就解锁
</p>
<!-- 正在评:评的是最新这一次,所以只在看最新时显示 -->
@@ -3,6 +3,7 @@ import { useThemeVars } from "naive-ui"
import { getRankOfProblem, getSubmission, getSubmissions } from "oj/api"
import { useCodeStore } from "oj/store/code"
import { useProblemStore } from "oj/store/problem"
import LockNote from "oj/problemset/components/LockNote.vue"
import Pagination from "shared/components/Pagination.vue"
import { useCollabStore } from "shared/store/collab"
import { useUserStore } from "shared/store/user"
@@ -139,7 +140,7 @@ type Loaded = { code: string; language: LANGUAGE }
const details = reactive<Record<string, Loaded | "loading" | "failed">>({})
async function toggle(row: SubmissionListItem) {
// 加入题单之前的提交被锁住,点不开(后端也不会给代码)
// 题单布置期内藏着的旧提交,点不开(后端也不会给代码)
if (!row.showLink) return
if (expanded.value === row.id) {
expanded.value = null
@@ -332,7 +333,7 @@ watch(query, () => {
</div>
</div>
</template>
<!-- 加入题单之前交的那几次:合成一行,点不开(后端也不给代码) -->
<!-- 题单布置期内藏着的那几次:合成一行,点不开(后端也不给代码) -->
<div v-if="lockedCount" class="locked">
<svg
width="13"
@@ -349,8 +350,9 @@ watch(query, () => {
<path d="M8 11V8a4 4 0 018 0v3" />
</svg>
<span>
加入题单之前的
{{ lockedCount }} 次提交先藏起来了,在题单里做出这道题就能看(题单过了截止时间也会解锁)
有 {{ lockedCount }} 次提交先藏起来了:<LockNote
:problem="{ displayId: problemDisplayId }"
/>
</span>
</div>
</div>
+37
View File
@@ -0,0 +1,37 @@
import type { ProblemSet, ProblemSetBadge } from "utils/types"
/**
* 奖章的两种条件和后端 eligibleForBadge 同一个口径:
* - 全部做完:必做题全对(completedCount === totalCount)
* - 做对 N 道:做对的全部题数(含选做,solvedCount)≥ N
*/
export function badgeCondition(badge: ProblemSetBadge) {
return badge.conditionType === "all_problems" ? "全部做完" : `做对 ${badge.conditionValue} 道`
}
/** 拿到这枚奖章还差几道;已经拿到为 0 */
export function badgeShortfall(badge: ProblemSetBadge, set: ProblemSet) {
if (badge.isEarned) return 0
const progress = set.userProgress
const remaining =
badge.conditionType === "all_problems"
? set.requiredCount - progress.completedCount
: badge.conditionValue - progress.solvedCount
return Math.max(1, remaining)
}
/** 阶梯顺序:按要做对的题数从少到多,「全部做完」排最后 */
export function ladder(set: ProblemSet) {
const need = (badge: ProblemSetBadge) =>
badge.conditionType === "all_problems" ? Number.MAX_SAFE_INTEGER : badge.conditionValue
return [...set.badges].sort((a, b) => need(a) - need(b) || a.id - b.id)
}
/** 下一枚最近的奖章和还差几道;全拿到了(或者没有奖章)为 null */
export function nextBadge(set: ProblemSet) {
const pending = ladder(set).filter((badge) => !badge.isEarned)
if (pending.length === 0) return null
const scored = pending.map((badge) => ({ badge, left: badgeShortfall(badge, set) }))
scored.sort((a, b) => a.left - b.left)
return scored[0]!
}
@@ -0,0 +1,143 @@
<script setup lang="ts">
import { useThemeVars } from "naive-ui"
import { useTone } from "oj/submission/composables/tone"
import { parseTime } from "utils/functions"
import type { ProblemSet } from "utils/types"
import { badgeCondition, badgeShortfall, ladder, nextBadge } from "../badges"
/**
* 奖章阶梯(设计稿「题单重设计」详情 B):一排奖章连成一条线,拿到的亮、写哪天拿的;
* 下一个要拿的套金圈、写「再做对 n 道」;更远的灰着、写条件。
*/
const props = defineProps<{ set: ProblemSet }>()
const theme = useThemeVars()
const tone = useTone()
const steps = computed(() => {
const next = nextBadge(props.set)?.badge.id
return ladder(props.set).map((badge) => ({
badge,
next: badge.id === next,
sub: badge.isEarned
? badge.earnedTime
? parseTime(badge.earnedTime, "M月D日")
: "拿到了"
: badge.id === next && props.set.userProgress.isJoined
? `再做对 ${badgeShortfall(badge, props.set)} 道`
: badgeCondition(badge),
}))
})
/** 连线亮到最后一枚拿到的奖章那里 */
const filled = computed(() => {
const last = steps.value.map((step) => step.badge.isEarned).lastIndexOf(true)
return steps.value.length > 1 && last > 0 ? last / (steps.value.length - 1) : 0
})
const warning = computed(() => tone("warning"))
</script>
<template>
<div class="ladder" :style="{ width: `${steps.length * 92}px` }">
<div v-if="steps.length > 1" class="track">
<div class="track-fill" :style="{ width: `${filled * 100}%` }"></div>
</div>
<div
v-for="step in steps"
:key="step.badge.id"
class="step"
:title="`${step.badge.name} · ${badgeCondition(step.badge)}${step.badge.description ? `\n${step.badge.description}` : ''}`"
>
<span class="medal" :class="{ next: step.next }">
<img
:src="step.badge.icon"
:alt="step.badge.name"
:class="{ locked: !step.badge.isEarned }"
/>
</span>
<b class="name" :class="{ dim: !step.badge.isEarned }">{{ step.badge.name }}</b>
<span class="sub" :class="{ urge: step.next && !step.badge.isEarned }">{{ step.sub }}</span>
</div>
</div>
</template>
<style scoped>
.ladder {
position: relative;
display: flex;
flex-shrink: 0;
}
.track {
position: absolute;
left: 46px;
right: 46px;
top: 21px;
height: 3px;
background: v-bind("theme.dividerColor");
}
.track-fill {
height: 100%;
background: v-bind("theme.successColor");
}
.step {
position: relative;
width: 92px;
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 3px;
}
.medal {
width: 44px;
height: 44px;
border-radius: 50%;
background: v-bind("theme.cardColor");
display: flex;
align-items: center;
justify-content: center;
}
.medal.next {
box-shadow: 0 0 0 2px #f0b44c;
}
.medal img {
width: 36px;
height: 36px;
}
.locked {
filter: grayscale(1);
opacity: 0.35;
}
.name {
max-width: 88px;
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.name.dim {
color: v-bind("theme.textColor3");
font-weight: 400;
}
.sub {
font-size: 11px;
font-variant-numeric: tabular-nums;
color: v-bind("theme.textColor3");
white-space: nowrap;
}
.sub.urge {
color: v-bind("warning.color");
font-weight: 600;
}
</style>
@@ -0,0 +1,342 @@
<script setup lang="ts">
import { useThemeVars } from "naive-ui"
import { getProblemSetClassView } from "oj/api"
import { useTone } from "oj/submission/composables/tone"
import { parseTime } from "utils/functions"
import type { ProblemSetClassView } from "utils/types"
/**
* 老师看一个班在题单里的情况(设计稿「题单重设计」老师 · 全班情况):真名 × 每道题,
* 格子里写几点做对 / 错了几次;紫色角 = 加入之前就做对过(可能是凭记忆默写的)。
* 没加入的人在上面点名。班级默认是服务端推断的(最近在做的那个班),可以换。
* 点格子到提交列表看这个学生这道题的代码。
*/
const props = defineProps<{ problemSetId: number }>()
const theme = useThemeVars()
const tone = useTone()
const data = ref<ProblemSetClassView | null>(null)
const className = ref("")
const loading = ref(false)
async function load() {
loading.value = true
try {
data.value = await getProblemSetClassView(props.problemSetId, className.value)
className.value = data.value.className ?? ""
} finally {
loading.value = false
}
}
onMounted(load)
watch(className, (value, old) => {
if (old && value !== data.value?.className) load()
})
function classLabel(name: string) {
return /^\d{3,}$/.test(name) ? `${name.slice(0, 2)}计算机${name.slice(2)}班` : name
}
const classOptions = computed(() =>
(data.value?.classes ?? []).map((item) => ({
value: item.className,
label: `${classLabel(item.className)}(${item.joined}/${item.size} 人加入)`,
})),
)
const joined = computed(() => data.value?.students.filter((s) => s.joinTime) ?? [])
const absent = computed(() => data.value?.students.filter((s) => !s.joinTime) ?? [])
const average = computed(() =>
joined.value.length
? joined.value.reduce((sum, s) => sum + s.completedCount, 0) / joined.value.length
: 0,
)
const finished = computed(
() => joined.value.filter((s) => data.value && s.completedCount >= data.value.totalCount).length,
)
const beforeCount = computed(() =>
joined.value.reduce((sum, s) => sum + s.cells.filter((cell) => cell.solvedBefore).length, 0),
)
const columns = computed(
() => `22px 72px 44px repeat(${data.value?.problems.length ?? 1}, minmax(72px, 1fr))`,
)
function name(student: { realName: string | null; username: string }) {
return student.realName || student.username
}
function cellLink(username: string, problemId: string) {
return `/submission?username=${encodeURIComponent(username)}&problem=${encodeURIComponent(problemId)}`
}
function cellTime(time: string) {
const today = parseTime(new Date(), "YYYY-MM-DD") === parseTime(time, "YYYY-MM-DD")
return parseTime(time, today ? "HH:mm" : "M/D")
}
const success = computed(() => tone("success"))
const warning = computed(() => tone("warning"))
const error = computed(() => tone("error"))
</script>
<template>
<section class="card">
<div v-if="data && !data.className" class="empty muted">还没有学生加入这个题单</div>
<template v-else-if="data">
<div class="summary">
<n-select
v-model:value="className"
:options="classOptions"
size="small"
class="pick"
:consistent-menu-width="false"
/>
<span
><b class="num big">{{ joined.length }}</b
><span class="muted"> / {{ data.students.length }} 人加入</span></span
>
<span
><span class="muted">平均做对</span> <b class="num big">{{ average.toFixed(1) }}</b
><span class="muted"> / {{ data.totalCount }} 道</span></span
>
<span
><span class="muted">做完</span> <b class="num big">{{ finished }}</b
><span class="muted"> 人</span></span
>
<span v-if="absent.length" class="absent ell" :title="absent.map(name).join('、')"
>没加入:{{ absent.map(name).join("、") }}</span
>
<div class="spacer"></div>
<span v-if="beforeCount" class="legend-before"
><span class="corner"></span>加入前就做对过({{ beforeCount }} 格)</span
>
</div>
<div class="scroll">
<div class="grid" :style="{ gridTemplateColumns: columns }">
<div class="head"></div>
<div class="head">姓名</div>
<div class="head right">做对</div>
<div v-for="(p, i) in data.problems" :key="p.id" class="head col" :title="p.title">
<b class="ell">{{ i + 1 }} {{ p.title }}</b>
<span class="num"
>{{ p.solved }} 人对<template v-if="!p.isRequired"> · 选做</template></span
>
</div>
<template v-for="(s, idx) in joined" :key="s.userId">
<div class="cell muted num tiny">{{ idx + 1 }}</div>
<div class="cell ell" :title="s.username">
<b>{{ name(s) }}</b>
</div>
<div class="cell right num">
<b>{{ s.completedCount }}</b>
</div>
<a
v-for="(c, i) in s.cells"
:key="i"
:href="cellLink(s.username, data.problems[i]!._id)"
target="_blank"
class="box"
:class="{ ok: c.solvedTime, bad: !c.solvedTime && c.wrongCount }"
:title="`${name(s)} · ${data.problems[i]!.title}${c.solvedBefore ? ' · 加入前就做对过' : ''}`"
>
<template v-if="c.solvedTime"
>✓ <span class="num">{{ cellTime(c.solvedTime) }}</span></template
>
<template v-else-if="c.wrongCount">错 {{ c.wrongCount }} 次</template>
<span v-if="c.solvedBefore" class="corner"></span>
</a>
</template>
</div>
</div>
<div class="foot muted">
<span>绿 = 在题单里做对(几点做对的)</span><span>黄 = 交过还没对</span
><span>紫色角 = 加入前就做对过</span><span>点格子看这个学生这道题的提交</span>
<div class="spacer"></div>
<span>按学号排</span>
</div>
</template>
<div v-else class="empty"><n-spin size="small" /></div>
</section>
</template>
<style scoped>
.card {
border: 1px solid v-bind("theme.borderColor");
border-radius: 6px;
background: v-bind("theme.cardColor");
overflow: hidden;
}
.empty {
padding: 40px;
text-align: center;
}
.summary {
min-height: 52px;
box-sizing: border-box;
padding: 8px 16px;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px 20px;
font-size: 13px;
border-bottom: 1px solid v-bind("theme.dividerColor");
}
.pick {
width: 240px;
}
.big {
font-size: 18px;
}
.absent {
max-width: 420px;
color: v-bind("error.color");
}
.legend-before {
display: inline-flex;
align-items: center;
gap: 6px;
color: #5b3fa8;
}
.legend-before .corner {
position: static;
}
.scroll {
overflow-x: auto;
}
.grid {
display: grid;
column-gap: 6px;
padding: 0 16px 8px;
}
.head {
position: sticky;
top: 0;
height: 44px;
display: flex;
flex-direction: column;
justify-content: center;
font-size: 12px;
color: v-bind("theme.textColor3");
background: v-bind("theme.cardColor");
border-bottom: 1px solid v-bind("theme.dividerColor");
margin-bottom: 4px;
}
.head.col {
align-items: center;
line-height: 16px;
min-width: 0;
overflow: hidden;
}
.head.col b {
max-width: 100%;
color: v-bind("theme.textColor2");
}
.right {
text-align: right;
align-items: flex-end;
padding-right: 8px;
}
.cell {
height: 32px;
display: flex;
align-items: center;
font-size: 14px;
min-width: 0;
}
.cell.right {
justify-content: flex-end;
}
.box {
position: relative;
height: 26px;
margin: 3px 0;
border-radius: 3px;
display: flex;
align-items: center;
justify-content: center;
gap: 3px;
font-size: 12px;
text-decoration: none;
background: v-bind("theme.actionColor");
color: v-bind("theme.textColor3");
}
.box.ok {
background: v-bind("success.background");
color: v-bind("success.color");
}
.box.bad {
background: v-bind("warning.background");
color: v-bind("warning.color");
}
.box:hover {
outline: 1px solid v-bind("theme.primaryColor");
}
.corner {
position: absolute;
top: 2px;
right: 3px;
width: 0;
height: 0;
border-top: 8px solid #7a5fd0;
border-left: 8px solid transparent;
}
.foot {
min-height: 30px;
box-sizing: border-box;
padding: 6px 16px;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 4px 16px;
font-size: 12px;
border-top: 1px solid v-bind("theme.dividerColor");
}
.spacer {
flex-grow: 1;
}
.muted {
color: v-bind("theme.textColor3");
}
.tiny {
font-size: 12px;
}
.num {
font-variant-numeric: tabular-nums;
}
.ell {
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>
@@ -0,0 +1,47 @@
<script setup lang="ts">
import { useThemeVars } from "naive-ui"
import { getProblemSetLocks } from "oj/api"
import { parseTime } from "utils/functions"
import type { ProblemSetLock } from "utils/types"
/**
* 「旧代码为什么藏着」那一句(设计稿「题单重设计」旧代码藏着时学生看到的):
* 题单《X》布置中,这道题以前的代码先藏着 · 在题单里做对,或 M月D日 之后就能看。
* 规则在后端 problemSetLockCutoffs;查不到是哪个题单(比如刚过期)就说通用的一句
*/
const props = defineProps<{ problem: { id: number } | { displayId: string } }>()
const theme = useThemeVars()
const locks = ref<ProblemSetLock[]>([])
watch(
() => ("id" in props.problem ? props.problem.id : props.problem.displayId),
async () => {
try {
locks.value = await getProblemSetLocks(props.problem)
} catch {
locks.value = []
}
},
{ immediate: true },
)
const first = computed(() => locks.value[0])
</script>
<template>
<span v-if="first">
题单<router-link :to="{ name: 'problemset', params: { problemSetId: first.problemSetId } }"
>《{{ first.title }}》</router-link
>布置中,这道题以前的代码先藏着 · 在题单里做对,或
{{ parseTime(first.assignedUntil, "M月D日") }} 之后就能看
</span>
<span v-else>这道题正在题单里布置,以前的代码先藏着 · 在题单里做对,或者布置期结束就能看</span>
</template>
<style scoped>
a {
color: v-bind("theme.primaryColor");
text-decoration: none;
}
</style>
@@ -1,123 +0,0 @@
<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"
interface Props {
problemSet: ProblemSet
isJoined: boolean
isJoining: boolean
userBadges: UserBadgeType[]
}
interface Emits {
(e: "join"): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const userStore = useUserStore()
function getDifficultyTag(difficulty: string) {
const difficultyMap: Record<
string,
{ type: "success" | "warning" | "error" | "default"; text: string }
> = {
Easy: { type: "success", text: "简单" },
Medium: { type: "warning", text: "中等" },
Hard: { type: "error", text: "困难" },
}
return difficultyMap[difficulty] || { type: "default", text: "未知" }
}
// 进度一律读 userProgress:它的分母是**必做题数**,而 problemsCount 是总题数。
// 拿总题数当分母的话,做完全部必做题的人会看到「9 / 10、90%」,而同一张卡片上
// 又标着「已完成」—— 题单 6 那 10 个人正是这种。
function getProgressPercentage() {
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")
}
</script>
<template>
<n-card style="margin-bottom: 24px">
<n-flex justify="space-between" align="center">
<n-flex align="center">
<n-tag type="warning" v-if="problemSet.status === 'archived'"> 已归档 </n-tag>
<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>
<Icon width="20" icon="fluent-emoji:information" />
</template>
{{ problemSet.description }}
</n-tooltip>
</n-flex>
<n-flex align="center" v-if="userStore.isAuthed">
<!-- 用户徽章显示区域 - 只在已加入且有徽章时显示 -->
<n-flex v-if="isJoined && userBadges.length > 0" align="center">
<n-text>已获徽章</n-text>
<UserBadge v-for="badge in userBadges" :key="badge.id" :badge="badge" />
</n-flex>
<!-- 完成进度 - 只在已加入时显示 -->
<n-flex align="center" v-if="isJoined">
<n-text strong>完成进度</n-text>
<n-text>
{{ 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
v-if="isJoined"
:percentage="getProgressPercentage()"
:height="8"
:border-radius="4"
style="width: 200px"
/>
<n-button
v-if="!isJoined"
type="primary"
size="large"
:loading="isJoining"
@click="handleJoin"
>
加入题单
</n-button>
<n-tag v-else type="success" size="large">
<template #icon>
<Icon icon="ph:check-circle-fill" />
</template>
已加入
</n-tag>
</n-flex>
</n-flex>
</n-card>
</template>
@@ -1,79 +0,0 @@
<script setup lang="ts">
import { Icon } from "@iconify/vue"
import type { ProblemSetProblem } from "utils/types"
import { DIFFICULTY } from "utils/constants"
import { getTagColor } from "utils/functions"
import { useBreakpoints } from "shared/composables/breakpoints"
interface Props {
problems: ProblemSetProblem[]
isJoined: boolean
}
interface Emits {
(e: "problem-click", problemId: string): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const { isDesktop } = useBreakpoints()
function handleProblemClick(problemId: string) {
emit("problem-click", problemId)
}
</script>
<template>
<div>
<n-grid :cols="isDesktop ? 4 : 1" :x-gap="16" :y-gap="16">
<n-grid-item v-for="(problemSetProblem, index) in problems" :key="problemSetProblem.id">
<n-card
hoverable
@click="handleProblemClick(problemSetProblem.problem._id)"
style="cursor: pointer"
>
<n-flex align="center">
<Icon
style="margin-right: 10px"
width="48"
icon="fluent-emoji:check-mark-button"
v-if="problemSetProblem.isCompleted"
/>
<n-flex vertical style="flex: 1">
<n-flex align="center">
<n-h4 style="margin: 0">#{{ index + 1 }}</n-h4>
<n-h4 style="margin: 0">
{{ problemSetProblem.problem.title }}
</n-h4>
</n-flex>
<n-flex align="center" size="small">
<n-tag
v-if="problemSetProblem.problem.difficulty"
:type="getTagColor(problemSetProblem.problem.difficulty)"
size="small"
>
{{ DIFFICULTY[problemSetProblem.problem.difficulty] }}
</n-tag>
<n-text type="info">分数:{{ problemSetProblem.score }}</n-text>
<n-text v-if="!problemSetProblem.isRequired">(选做)</n-text>
</n-flex>
</n-flex>
</n-flex>
</n-card>
</n-grid-item>
</n-grid>
<div class="tip">
<n-text depth="3">题目完成后会自动返回题单页面</n-text>
</div>
</div>
</template>
<style scoped>
.tip {
padding-top: 24px;
text-align: center;
}
</style>
@@ -1,267 +0,0 @@
<script setup lang="ts">
import { errorMessage } from "utils/api"
import { h, computed, ref, onMounted, watch } from "vue"
import { watchDebounced } from "@vueuse/core"
import { parseTime } from "utils/functions"
import type { ProblemSetProgress } from "utils/types"
import { getProblemSetUserProgress } from "../../api"
import { NFlex, NTag } from "naive-ui"
import { usePagination } from "shared/composables/pagination"
import Pagination from "shared/components/Pagination.vue"
const route = useRoute()
const message = useMessage()
const problemSetId = computed(() => Number(route.params.problemSetId))
const progress = ref<ProblemSetProgress[]>([])
const loading = ref(false)
const total = ref(0)
const statistics = ref<{
total: number
completed: number
avgProgress: number
} | null>(null)
const classFilter = ref<string>("")
const completionFilter = ref<"" | "completed" | "in_progress" | "not_started">("")
const allProblems = ref<Array<{ id: number; _id: string; title: string }>>([])
// 完成度筛选选项
const completionOptions = [
{ label: "全部", value: "" },
{ label: "未开始", value: "not_started" },
{ label: "进行中", value: "in_progress" },
{ label: "已完成", value: "completed" },
]
// 使用分页 composable
const { query } = usePagination({}, { defaultLimit: 50 })
// 加载用户进度数据
async function loadUserProgress() {
loading.value = true
const offset = (query.page - 1) * query.limit
const params: {
limit?: number
offset?: number
className?: string
completionStatus?: "" | "completed" | "in_progress" | "not_started"
} = {
limit: query.limit,
offset,
}
if (classFilter.value.trim()) {
params.className = classFilter.value.trim()
}
if (completionFilter.value) {
params.completionStatus = completionFilter.value
}
try {
const res = await getProblemSetUserProgress(problemSetId.value, params)
progress.value = res.results
total.value = res.total
// 使用后端返回的统计数据(基于所有数据)
if (res.statistics) {
statistics.value = res.statistics
}
// 保存所有题目信息
if (res.problems) {
allProblems.value = res.problems
}
} catch (err) {
// finally 里收掉 loading:以前 loading.value = false 写在 await 之后,
// 请求一失败(403、断网)转圈就永远停不下来
message.error("加载用户进度失败:" + errorMessage(err, "未知错误"))
} finally {
loading.value = false
}
}
// 监听分页参数变化
watch([() => query.page, () => query.limit], loadUserProgress)
// 监听班级过滤变化(防抖)
watchDebounced(
classFilter,
() => {
query.page = 1 // 重置到第一页
loadUserProgress()
},
{ debounce: 500 },
)
// 监听完成度筛选变化
watch(completionFilter, () => {
query.page = 1 // 重置到第一页
loadUserProgress()
})
// 使用后端返回的统计数据
const stats = computed(() => {
if (statistics.value) {
return {
total: statistics.value.total,
completed: statistics.value.completed,
avgProgress: Math.round(statistics.value.avgProgress),
}
}
// 如果后端还没有返回统计数据,使用默认值
return {
total: total.value,
completed: 0,
avgProgress: 0,
}
})
onMounted(loadUserProgress)
// 定义表格列
const progressColumns = [
{
title: "排名",
key: "rank",
width: 80,
render: (_row: ProblemSetProgress, index: number) => {
// 计算全局排名:当前页偏移 + 当前行索引 + 1
const globalRank = (query.page - 1) * query.limit + index + 1
return globalRank
},
},
{
title: "用户",
key: "user.username",
width: 120,
render: (row: ProblemSetProgress) => row.user.username,
},
{
title: "加入时间",
key: "join_time",
width: 180,
render: (row: ProblemSetProgress) => parseTime(row.joinTime, "YYYY-MM-DD HH:mm:ss"),
},
{
title: "已完成数量",
key: "completedProblemsCount",
width: 100,
},
{
title: "已/未完成题目",
key: "completed_problems",
width: 300,
render: (row: ProblemSetProgress) => {
if (row.progressPercentage === 100) {
return "全部题目已完成"
}
if (row.progressPercentage > 50 && row.progressPercentage < 100) {
const completedProblemIds = new Set(row.completedProblems.map((p: any) => p.id))
const incompleteProblems = allProblems.value.filter((p) => !completedProblemIds.has(p.id))
return h("div", { style: "max-height: 120px; overflow-y: auto" }, [
h(NFlex, {}, () =>
incompleteProblems.map((problem) =>
h(
NTag,
{ type: "warning", size: "small", style: "margin: 2px" },
() => `${problem._id}: ${problem.title}`,
),
),
),
])
}
return h("div", { style: "max-height: 120px; overflow-y: auto" }, [
h(NFlex, {}, () =>
row.completedProblems.map((problem: any) =>
h(
NTag,
{
type: "success",
size: "small",
style: "margin: 2px",
},
() => `${problem._id}: ${problem.title}`,
),
),
),
])
},
},
{
title: "进度",
key: "progress_percentage",
width: 120,
render: (row: ProblemSetProgress) => {
return `${row.progressPercentage.toFixed(0)}%`
},
},
{
title: "状态",
key: "is_completed",
width: 100,
render: (row: ProblemSetProgress) => {
if (row.isCompleted) {
return h(NTag, { type: "success" }, () => "已完成")
} else {
return h(NTag, { type: "warning" }, () => "进行中")
}
},
},
]
</script>
<template>
<div>
<!-- 过滤条件 -->
<n-form label-placement="left" inline>
<n-form-item label="班级">
<n-input
v-model:value="classFilter"
placeholder="输入班级名称"
style="width: 200px"
clearable
/>
</n-form-item>
<n-form-item label="完成度:">
<n-select
v-model:value="completionFilter"
:options="completionOptions"
placeholder="完成度"
style="width: 160px"
clearable
/>
</n-form-item>
</n-form>
<!-- 统计信息卡片 -->
<n-grid :cols="3" :x-gap="16" style="margin-bottom: 16px">
<n-grid-item>
<n-card size="small">
<n-statistic label="总参与人数" :value="stats.total" />
</n-card>
</n-grid-item>
<n-grid-item>
<n-card size="small">
<n-statistic label="已完成人数" :value="stats.completed" />
</n-card>
</n-grid-item>
<n-grid-item>
<n-card size="small">
<n-statistic label="平均进度" :value="stats.avgProgress.toFixed(0) + '%'" />
</n-card>
</n-grid-item>
</n-grid>
<n-data-table
:loading="loading"
:columns="progressColumns"
:data="progress"
:pagination="false"
:bordered="false"
:single-line="false"
/>
<Pagination
:total="total"
:limit="query.limit"
:page="query.page"
@update:limit="(limit: number) => (query.limit = limit)"
@update:page="(page: number) => (query.page = page)"
/>
</div>
</template>
+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>
+359 -218
View File
@@ -1,263 +1,404 @@
<script setup lang="ts">
import { useRouteQuery } from "@vueuse/router"
import { getProblemSetList } from "../api"
import { useThemeVars } from "naive-ui"
import { getProblemSetList } from "oj/api"
import { rgba, useTone } from "oj/submission/composables/tone"
import { parseTime } from "utils/functions"
import type { ProblemSet } from "utils/types"
import Pagination from "shared/components/Pagination.vue"
import { usePagination } from "shared/composables/pagination"
import { badgeCondition, ladder, nextBadge } from "./badges"
/**
* 题单列表(设计稿「题单重设计」列表 A):老师正在布置的题单单独一排大卡片 —— 做对几道、
* 布置到哪天、再做对几道拿哪个奖章;其余题单一排排小卡片,加入过的在前。
*
* 题单一共十几个,一次全取回来在前端分组,不分页
*/
const router = useRouter()
const theme = useThemeVars()
const tone = useTone()
const total = ref(0)
const problemSets = ref<ProblemSet[]>([])
const sets = ref<ProblemSet[]>([])
const loaded = ref(false)
const keyword = ref("")
interface ProblemSetQuery {
keyword: string
}
// 使用分页 composable
const { query, clearQuery } = usePagination<ProblemSetQuery>(
{
keyword: useRouteQuery("keyword", "").value,
},
{
defaultLimit: 30,
},
)
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)
total.value = res.total
problemSets.value = res.results
}
function getDifficultyTag(difficulty: string) {
const difficultyMap: Record<
string,
{ type: "success" | "warning" | "error" | "default"; text: string }
> = {
Easy: { type: "success", text: "简单" },
Medium: { type: "warning", text: "中等" },
Hard: { type: "error", text: "困难" },
onMounted(async () => {
try {
sets.value = (await getProblemSetList(0, 250)).results
} finally {
loaded.value = true
}
return difficultyMap[difficulty] || { type: "default", text: "未知" }
}
function goToProblemSet(problemSetId: number) {
router.push(`/problemset/${problemSetId}`)
}
function getConditionText(conditionType: string, conditionValue: number): string {
const conditionMap: Record<string, string> = {
all_problems: "完成所有题目",
problem_count: `完成 ${conditionValue} 道题目`,
score: `达到 ${conditionValue} 分`,
}
return conditionMap[conditionType] || "未知条件"
}
onMounted(listProblemSets)
// 监听搜索关键词变化(防抖)
watchDebounced(() => query.keyword, listProblemSets, {
debounce: 500,
maxWait: 1000,
})
// 监听其他查询条件变化
watch(() => [query.page, query.limit], listProblemSets)
const shown = computed(() => {
const word = keyword.value.trim().toLowerCase()
if (!word) return sets.value
return sets.value.filter(
(set) => set.title.toLowerCase().includes(word) || set.description.toLowerCase().includes(word),
)
})
/** 布置中的:快到期的在前 */
const assigning = computed(() =>
shown.value
.filter((set) => set.assigning)
.sort((a, b) => Date.parse(a.assignedUntil!) - Date.parse(b.assignedUntil!)),
)
/** 其他:加入过的在前(最近加入的最前),再按新建排 */
const others = computed(() =>
shown.value
.filter((set) => !set.assigning)
.sort((a, b) => {
const ja = a.userProgress.joinTime
const jb = b.userProgress.joinTime
if (ja && jb) return Date.parse(jb) - Date.parse(ja)
if (ja || jb) return ja ? -1 : 1
return Date.parse(b.createTime) - Date.parse(a.createTime)
}),
)
const joinedOthers = computed(() => others.value.filter((set) => set.userProgress.isJoined).length)
function description(set: ProblemSet) {
// 简介大多就是把标题再抄一遍,一样的就不重复显示
return set.description && set.description !== set.title ? set.description : ""
}
function optional(set: ProblemSet) {
return set.problemsCount - set.requiredCount
}
function percent(set: ProblemSet) {
const progress = set.userProgress
return progress.isJoined && set.requiredCount
? Math.min(100, (progress.completedCount / set.requiredCount) * 100)
: 0
}
function open(set: ProblemSet) {
router.push({ name: "problemset", params: { problemSetId: set.id } })
}
const info = computed(() => tone("info"))
const success = computed(() => tone("success"))
// 做完的卡片只淡淡地染一点绿(设计稿),用 success.background 那一档太抢眼
const doneBackground = computed(() => rgba(theme.value.successColor, 0.05))
</script>
<template>
<n-flex vertical size="large">
<!-- 难度和状态两个筛选器撤了:线上 16 个题单全是 Easy / active,选「中等」「困难」
「已归档」永远是空列表。接口那两个 query 参数还在,哪天真的用起这两个字段,
把 select 加回来即可。 -->
<n-space>
<n-input
v-model:value="query.keyword"
placeholder="搜索题单..."
clearable
@clear="clearQuery"
style="width: 200px"
/>
</n-space>
<div v-if="problemSets.length > 0" class="set-grid">
<div
v-for="problemSet in problemSets"
:key="problemSet.id"
class="set-card"
:class="{ completed: problemSet.userProgress?.isCompleted }"
@click="goToProblemSet(problemSet.id)"
>
<n-flex justify="space-between" align="center" :wrap="false" :size="8">
<span class="set-title">{{ problemSet.title }}</span>
<n-tag v-if="problemSet.userProgress?.isCompleted" type="success" size="small" round>
已完成
</n-tag>
<n-tag v-else-if="problemSet.userProgress?.isJoined" type="info" size="small" round>
进行中
</n-tag>
</n-flex>
<!-- 简介大多就是把标题再抄一遍,一样的就不重复显示 -->
<n-text
v-if="problemSet.description && problemSet.description !== problemSet.title"
depth="3"
class="set-desc"
>
{{ problemSet.description }}
</n-text>
<div class="set-progress">
<n-flex justify="space-between" align="center" class="set-progress-text">
<n-text depth="3">
<!-- 进度是加入/完成时存下的快照,分母用快照自己的 totalCount:题单后来
加了题的话,拿现在的 problemsCount 去除,就会出现「已完成」却是 5 / 6 -->
<template v-if="problemSet.userProgress?.isJoined">
已完成 {{ problemSet.userProgress.completedCount }} /
{{ problemSet.userProgress.totalCount }} 题
<template v-if="problemSet.problemsCount > problemSet.userProgress.totalCount">
· 新加了
{{ problemSet.problemsCount - problemSet.userProgress.totalCount }} 题
</template>
</template>
<template v-else>共 {{ problemSet.problemsCount }} 题 · 还没开始</template>
</n-text>
<!-- 线上题单全是简单难度,只有不是简单时才值得标出来 -->
<n-tag
v-if="problemSet.difficulty !== 'Easy'"
:type="getDifficultyTag(problemSet.difficulty).type"
size="small"
:bordered="false"
>
{{ getDifficultyTag(problemSet.difficulty).text }}
</n-tag>
<n-tag v-if="problemSet.status === 'archived'" size="small" :bordered="false">
已归档
</n-tag>
</n-flex>
<n-progress
type="line"
:percentage="Math.round(problemSet.userProgress?.progressPercentage ?? 0)"
:show-indicator="false"
:height="6"
status="success"
/>
</div>
<n-flex v-if="problemSet.badges?.length" align="center" :size="6" class="set-badges">
<n-text depth="3" class="set-badges-label">
徽章 {{ problemSet.badges.filter((b) => b.isEarned).length }} /
{{ problemSet.badges.length }}
</n-text>
<n-tooltip v-for="badge in problemSet.badges" :key="badge.id" trigger="hover">
<template #trigger>
<n-image
:src="badge.icon"
:alt="badge.name"
width="24"
height="24"
object-fit="cover"
preview-disabled
:class="badge.isEarned ? 'earned-badge' : 'locked-badge'"
/>
</template>
<n-flex vertical size="small">
<span style="font-weight: bold"> 徽章: {{ badge.name }} </span>
<span>
获取条件:
{{ getConditionText(badge.conditionType, badge.conditionValue) }}
</span>
<n-text type="primary" v-if="badge.isEarned"> ✓ 已获得 </n-text>
</n-flex>
</n-tooltip>
</n-flex>
</div>
<div class="page">
<div class="top">
<h2>题单</h2>
<div class="spacer"></div>
<n-input v-model:value="keyword" placeholder="搜题单" clearable class="search" />
</div>
<Pagination
v-if="problemSets.length > 0"
:total="total"
v-model:limit="query.limit"
v-model:page="query.page"
<template v-if="assigning.length">
<div class="sect">
布置中<span class="muted">老师现在布置的 · {{ assigning.length }} 个</span>
</div>
<div class="big-grid">
<a
v-for="set in assigning"
:key="set.id"
:href="`/problemset/${set.id}`"
class="card big"
:class="{ done: set.userProgress.isCompleted }"
@click.prevent="open(set)"
>
<div class="row">
<b class="ell title">{{ set.title }}</b>
<span v-if="set.userProgress.isCompleted" class="pill done-pill">✓ 做完了</span>
</div>
<div class="muted ell desc">{{ description(set) }}</div>
<div class="row baseline">
<template v-if="set.userProgress.isJoined">
<span class="num count">{{ set.userProgress.completedCount }}</span>
<span class="muted num">/ {{ set.requiredCount }}</span>
</template>
<span v-else class="muted small">还没加入 · {{ set.requiredCount }} 道</span>
<span v-if="optional(set) > 0" class="muted tiny">另有 {{ optional(set) }} 道选做</span>
<div class="spacer"></div>
<span class="pill assign">布置到 {{ parseTime(set.assignedUntil!, "M月D日") }}</span>
</div>
<div class="bar"><div class="fill" :style="{ width: `${percent(set)}%` }"></div></div>
<div v-if="nextBadge(set)" class="row hint">
<img :src="nextBadge(set)!.badge.icon" alt="" class="icon locked" />
<span>
再做对 <b class="num">{{ nextBadge(set)!.left }}</b> 道拿「{{
nextBadge(set)!.badge.name
}}」
</span>
</div>
<div v-else-if="set.badges.length" class="row hint got">
<img
v-for="badge in ladder(set)"
:key="badge.id"
:src="badge.icon"
:alt="badge.name"
:title="badge.name"
class="icon"
/>
<span>奖章全拿到了</span>
</div>
<div v-else class="muted hint">这个题单没有奖章</div>
</a>
</div>
</template>
<div v-if="others.length" class="sect">
{{ assigning.length ? "其他题单" : "题单" }}
<span class="muted"
>{{ others.length }} 个<template v-if="joinedOthers"> · 加入过的在前</template></span
>
</div>
<div class="small-grid">
<a
v-for="set in others"
:key="set.id"
:href="`/problemset/${set.id}`"
class="card small-card"
:class="{ done: set.userProgress.isCompleted }"
@click.prevent="open(set)"
>
<div class="row">
<b class="ell">{{ set.title }}</b>
<span v-if="set.userProgress.isCompleted" class="green tiny">✓</span>
</div>
<div class="row tiny">
<span v-if="set.userProgress.isJoined" class="num">
做对 <b>{{ set.userProgress.completedCount }}</b> / {{ set.requiredCount }}
</span>
<span v-else class="muted num"
>{{ set.problemsCount }} 道 · {{ set.joinedCount }} 人做过</span
>
<div class="spacer"></div>
<img
v-for="badge in ladder(set)"
:key="badge.id"
:src="badge.icon"
:alt="badge.name"
:title="`${badge.name} · ${badgeCondition(badge)}${badge.isEarned ? ' · 已拿到' : ''}`"
class="mini"
:class="{ locked: !badge.isEarned }"
/>
</div>
</a>
</div>
<n-empty
v-if="loaded && !shown.length"
:description="keyword ? '没有找到这样的题单' : '还没有题单'"
/>
</n-flex>
<n-empty v-if="problemSets.length === 0"></n-empty>
</div>
</template>
<style scoped>
.set-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 16px;
}
.set-card {
.page {
display: flex;
flex-direction: column;
gap: 14px;
}
.top {
display: flex;
align-items: center;
gap: 12px;
}
.top h2 {
margin: 0;
font-size: 20px;
}
.search {
width: 220px;
}
.spacer {
flex-grow: 1;
}
.sect {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
font-weight: 700;
color: v-bind("theme.textColor2");
}
.sect .muted {
font-weight: 400;
}
.muted {
color: v-bind("theme.textColor3");
}
.green {
color: v-bind("success.color");
}
.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;
}
.row {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.row.baseline {
align-items: baseline;
}
.big-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 12px;
}
.small-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 10px;
padding: 16px 18px;
border-radius: 8px;
border: 1px solid rgba(128, 128, 128, 0.2);
cursor: pointer;
}
.card {
box-sizing: border-box;
min-width: 0;
border: 1px solid v-bind("theme.borderColor");
border-radius: 6px;
background: v-bind("theme.cardColor");
color: v-bind("theme.textColor1");
text-decoration: none;
transition:
border-color 0.2s,
box-shadow 0.2s;
}
.set-card:hover {
border-color: #18a058;
.card:hover {
border-color: v-bind("theme.primaryColor");
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.06);
}
.set-card.completed {
background-color: rgba(24, 160, 88, 0.05);
.card.done {
background: v-bind("doneBackground");
}
.set-title {
font-size: 16px;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.set-desc {
font-size: 13px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.set-progress {
.big {
padding: 14px 16px;
display: flex;
flex-direction: column;
gap: 6px;
margin-top: auto;
gap: 8px;
}
.set-progress-text {
font-size: 13px;
.title {
flex-grow: 1;
font-size: 16px;
}
.set-badges-label {
.desc {
height: 17px;
font-size: 12px;
margin-right: 2px;
}
.earned-badge {
border: 2px solid #ffd700;
border-radius: 50%;
box-shadow: 0 0 8px rgba(255, 215, 0, 0.4);
.count {
font-size: 22px;
font-weight: 700;
}
/* 没拿到的徽章压成灰色:原来拿没拿到只差一圈金边,一排看下去分不清 */
.locked-badge {
.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");
}
.done-pill {
color: v-bind("success.color");
background: v-bind("success.background");
font-weight: 600;
}
.bar {
height: 6px;
border-radius: 3px;
background: v-bind("theme.actionColor");
overflow: hidden;
}
.fill {
height: 100%;
background: v-bind("theme.successColor");
}
.hint {
height: 20px;
font-size: 12px;
color: v-bind("theme.textColor2");
}
.hint.got {
gap: 4px;
color: v-bind("success.color");
}
.hint.got span {
margin-left: 4px;
}
.icon {
width: 18px;
height: 18px;
flex-shrink: 0;
}
.mini {
width: 16px;
height: 16px;
flex-shrink: 0;
}
.locked {
filter: grayscale(1);
opacity: 0.45;
opacity: 0.4;
}
.small-card {
height: 74px;
padding: 10px 14px;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.small-card b {
font-size: 14px;
}
</style>
+1 -1
View File
@@ -62,7 +62,7 @@ export const useFlowchartStore = defineStore("flowchart", () => {
* 1.5 秒后跳回题单,都只认这一下 —— 进页面读到以前的 A 不该再跳一次
*/
const evaluatedSeq = ref(0)
/** 这道题评完的每一次,早的在前;hidden = 加入题单之前、被藏起来的次数 */
/** 这道题评完的每一次,早的在前;hidden = 题单布置期内被藏起来的次数 */
const scores = ref<FlowchartScores["scores"]>([])
const hiddenCount = ref(0)
const submissionCount = computed(() => scores.value.length)
@@ -202,7 +202,9 @@ const canRetry = computed(
<Icon icon="ph:lock-simple" :width="30" class="lock-icon" />
<!-- 列表项里没有 userId,分不出是别人的还是自己被题单挡住的,文案两种都说得通 -->
<span class="locked-title">这张流程图看不到</span>
<span class="muted">别人画的看不到;自己的在题单里做完这道题就能看到。</span>
<span class="muted"
>别人画的看不到;自己以前画的在题单布置期内先藏着,在题单里做对或者布置期结束就能看到。</span
>
</div>
<div v-else class="body">
<div class="graph-box">
@@ -1,4 +1,5 @@
<script setup lang="ts">
import LockNote from "oj/problemset/components/LockNote.vue"
import { Icon } from "@iconify/vue"
import { useThemeVars } from "naive-ui"
import { getSubmission } from "oj/api"
@@ -388,8 +389,8 @@ function openStandalone() {
</n-button>
</template>
<template v-else-if="locked === 'problemset'">
<span class="locked-title">这道题在你加入的题单里</span>
<span class="muted">加入题单之前交的代码先藏起来,在题单里做完这道题就能看到。</span>
<span class="locked-title">这份代码在题单布置期内先藏着</span>
<span class="muted"><LockNote :problem="{ displayId: row.problemDisplayId }" /></span>
</template>
<template v-else>
<span class="locked-title">登录之后才能看代码</span>
@@ -3,7 +3,7 @@ import { useDark } from "@vueuse/core"
export type Tone = "success" | "error" | "warning" | "info" | "default"
function rgba(hex: string, alpha: number) {
export function rgba(hex: string, alpha: number) {
const value = hex.replace("#", "")
const full =
value.length === 3
+5 -1
View File
@@ -968,7 +968,11 @@ function dayBreak(index: number) {
<span
v-if="!row.showLink"
class="lock-hint"
:title="othersRow(row) ? '别人的代码看不到' : '在题单里做完才能看'"
:title="
othersRow(row)
? '别人的代码看不到'
: '题单布置期内先藏着:在题单里做对,或者布置期结束就能看'
"
>
<Icon icon="ph:lock-simple" :width="13" />
</span>