Files
OJ2/apps/web/src/oj/problemset/list.vue
T
xuyueandClaude Opus 5.5 309ca0ece2
Deploy / deploy (push) Canceled after 0s
refactor(前端): 全站共用的班级下拉、胶囊配色挪出提交列表目录,编辑器不再偷读题目页的 store
- classLabel / groupClassOptions / classSelectOptions 挪到 utils/classes.ts,useTone 挪到
  shared/composables/tone.ts:原来放在 oj/submission/ 下,被前台、后台、shared 引了 50 多处
- 流程图编辑器的草稿键改由题目页传进来(键名不变,学生存着的草稿照样读得到)。原来编辑器
  自己读题目页的 store 和路由拼,后台出题页也用这个组件,那边 store 是空的
- SQL 表名补全改由题目页传表进来。原来补全直接读题目页的 store,自学页的 SQL 代码块会
  补出上一道看过的题的表名

实跑:提交列表、题目页、排名、后台题目页、SQL 题、流程图题打开无报错;SQL 题输入 stu
补出 students;流程图加一步,草稿照旧存在 flowchart-v2-problem-<题号>。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 14:59:55 +08:00

417 lines
10 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { useThemeVars } from "naive-ui"
import AdminLink from "shared/components/AdminLink.vue"
import { useBreakpoints } from "shared/composables/breakpoints"
import { useUserStore } from "shared/store/user"
import { getProblemSetList } from "oj/api"
import { rgba, useTone } from "shared/composables/tone"
import { parseTime } from "utils/functions"
import type { ProblemSet } from "utils/types"
import { badgeCondition, ladder, nextBadge } from "./badges"
/**
* 题单列表(设计稿「题单重设计」列表 A):老师正在布置的题单单独一排大卡片 —— 做对几道、
* 布置到哪天、再做对几道拿哪个奖章;其余题单一排排小卡片,加入过的在前。
*
* 题单一共十几个,一次全取回来在前端分组,不分页
*/
const router = useRouter()
const userStore = useUserStore()
const { isDesktop } = useBreakpoints()
const theme = useThemeVars()
const tone = useTone()
const sets = ref<ProblemSet[]>([])
const loaded = ref(false)
const keyword = ref("")
onMounted(async () => {
try {
sets.value = (await getProblemSetList(0, 250)).results
} finally {
loaded.value = true
}
})
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)
}),
)
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>
<div class="page oj-page">
<div class="top">
<h2>题单</h2>
<div class="spacer"></div>
<n-input v-model:value="keyword" placeholder="搜题单" clearable class="search" />
<!-- 手机上一行放不下,也没人在手机上出题,只给电脑 -->
<AdminLink v-if="isDesktop && userStore.isTeacherOrAbove" to="/admin/problemset/create"
>新建题单</AdminLink
>
</div>
<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 }} 个</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 ? '没有找到这样的题单' : '还没有题单'"
/>
</div>
</template>
<style scoped>
.page {
display: flex;
flex-direction: column;
gap: var(--oj-gap);
}
.top {
display: flex;
align-items: center;
gap: 12px;
}
.top h2 {
margin: 0;
font-size: var(--oj-fs-title);
}
.search {
width: 220px;
}
.spacer {
flex-grow: 1;
}
.sect {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: -6px;
font-size: var(--oj-fs-h2);
font-weight: 700;
color: v-bind("theme.textColor1");
}
.sect .muted {
font-size: var(--oj-fs-sec);
font-weight: 400;
}
.muted {
color: v-bind("theme.textColor3");
}
.green {
color: v-bind("success.color");
}
.small {
font-size: var(--oj-fs-sec);
white-space: nowrap;
}
.tiny {
font-size: var(--oj-fs-meta);
white-space: nowrap;
}
.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;
}
/* 1200 限宽下布置中是一行三张、其他题单一行四张;原来一行四张的大卡只有 280 宽,
「还没加入 · 9 道」和「另有 1 道选做」都被挤成两行 */
.big-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
gap: 16px;
}
.small-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(270px, 1fr));
gap: 14px;
}
.card {
box-sizing: border-box;
min-width: 0;
border: 1px solid v-bind("theme.borderColor");
border-radius: var(--oj-radius);
background: v-bind("theme.cardColor");
color: v-bind("theme.textColor1");
text-decoration: none;
transition:
border-color 0.2s,
box-shadow 0.2s;
}
.card:hover {
border-color: v-bind("theme.primaryColor");
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.06);
}
.card.done {
background: v-bind("doneBackground");
}
.big {
padding: 20px 22px;
display: flex;
flex-direction: column;
gap: 10px;
}
.title {
flex-grow: 1;
font-size: 18px;
}
.desc {
height: 20px;
font-size: var(--oj-fs-meta);
}
.count {
font-size: 26px;
font-weight: 700;
}
.pill {
height: 24px;
padding: 0 9px;
border-radius: 4px;
font-size: var(--oj-fs-meta);
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: 22px;
font-size: var(--oj-fs-meta);
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.4;
}
.small-card {
height: 88px;
padding: 14px 18px;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.small-card b {
font-size: var(--oj-fs-body);
}
</style>