Files
OJ2/apps/web/src/oj/problemset/list.vue
T
xuyueandClaude Opus 5.5 ef3c53c671
Deploy / deploy (push) Successful in 49s
feat(看板娘): 点她会按页面说一句傲娇的话,再给两三个能直接办事的按钮
- 她从不主动开口,点一下才冒气泡;比赛里不弹。台词 326 句(shared/mascot/lines.ts),
  口吻照「小鲸鱼」角色卡,学校里舍掉英文、粗口,「用户」改叫「同学」
- 按情况换话:题目页分没写、没交、例子没对上/对上、答错、编译错误、运行出错、超时、
  错很多次、做对、一次就对、终于对了、以前做过;首页、题目列表、提交、专题、自学、排名等各有一组
- 按钮真的去办:运行例子、我错在哪(切到结果里出错的那一段)、举手、输题号、随便来一道、
  下一道、选题……
- 老师也看得到:有学生举手时任何页面先说这件事;首页、课堂任务、统计、题目页各有一组
- 页面用 useMascotGuide 告诉她现在的情况(shared/mascot/guide.ts),以后按学生行为调整也接在这一层
- 修:连点时像卡住了(快点时按压过渡来不及显示,改成每下播一遍压扁回弹);
  一打开从左上角飞到右下角;去掉单击的摆动

设计稿与台词表:https://claude.ai/artifact/Qz2zPCfq2zCrqmxyms1UGx

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 16:12:23 +08:00

412 lines
11 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 { goAction, useMascotGuide } from 'shared/mascot/guide'
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)
}),
)
// 看板娘:加入了还没做完的专题,布置中的优先,其次最近加入的
useMascotGuide(() => {
if (userStore.isTeacherOrAbove) return null
const pending = sets.value.filter((set) => set.userProgress.isJoined && !set.userProgress.isCompleted)
const latest = (set: ProblemSet) => Date.parse(set.userProgress.joinTime ?? '') || 0
const target = pending.find((set) => set.assigning) ?? [...pending].sort((a, b) => latest(b) - latest(a))[0]
if (!target) return null
return { situation: 'problemsets.unfinished', actions: [goAction(router, '接着做', `/problemset/${target.id}`)] }
})
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>
<!-- 没有布置中的,就只有一组:数量写在标题旁边,不再另起一行「专题 14 个」把标题再说一遍 -->
<span v-if="!assigning.length && others.length" class="muted count">{{ others.length }} 个</span>
<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="assigning.length && others.length" class="sect">
其他专题
<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);
}
/* 和分组标题旁的「N 个」同一档:小字、不加粗 */
.top .count {
font-size: var(--oj-fs-sec);
font-weight: 400;
}
.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>