Deploy / deploy (push) Successful in 49s
- 她从不主动开口,点一下才冒气泡;比赛里不弹。台词 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>
412 lines
11 KiB
Vue
412 lines
11 KiB
Vue
<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>
|