Files
OJ2/apps/web/src/oj/pk/index.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

472 lines
14 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 type { ClassBattleItem, ClassPk, ClassPkPeriod } from '@oj2/contract'
import { useThemeVars } from 'naive-ui'
import { getClassBattle, getClassPk } from 'oj/api'
import { classLabel, groupClassOptions } from 'utils/classes'
import { useAIStream } from 'shared/composables/aiStream'
import { useBreakpoints } from 'shared/composables/breakpoints'
import { useUserStore } from 'shared/store/user'
import { useMascotGuide } from 'shared/mascot/guide'
import PkDuel from './components/PkDuel.vue'
import PkGrid from './components/PkGrid.vue'
import PkRace from './components/PkRace.vue'
import { pkColor } from './utils'
// Markdown 连着整套代码高亮(两百多 KB),这里要等点开 / AI 写完才用得上,别让它拖慢页面
const Markdown = defineAsyncComponent(() => import('shared/components/Markdown.vue'))
/**
* 班级 PK(设计稿「班级 PK 重设计」定稿)。用户定的:两个班按「同一批题」对决(B),
* 三个班以上按题对照(C),学生也能看。原来的综合分、雷达图、十张柱状图、四分位数都去掉了。
*
* 选了哪些班、看哪段时间都在地址栏里(`?classes=252,253&period=term`),从排名页、班级详情
* 进来可以直接带上;不带就用自己的班(老师用默认的班),后端再配一个同年级的对手。
*/
const router = useRouter()
const route = useRoute()
const message = useMessage()
const theme = useThemeVars()
const userStore = useUserStore()
const { isDesktop } = useBreakpoints()
const MAX_CLASSES = 8
const teacher = computed(() => userStore.isTeacherOrAbove)
// 看板娘:学生念叨一句班级对决,老师只打招呼
useMascotGuide(() => (teacher.value ? null : { situation: 'pk' }))
const pk = ref<ClassPk | null>(null)
const loading = ref(false)
const battle = ref<ClassBattleItem[]>([])
function queryClasses() {
const raw = route.query.classes
return typeof raw === 'string' && raw ? raw.split(',').filter(Boolean) : []
}
const period = computed<ClassPkPeriod>(() => (route.query.period === 'week' ? 'week' : 'term'))
let seq = 0
async function load() {
const mine = ++seq
loading.value = true
try {
const data = await getClassPk(queryClasses(), period.value)
if (mine !== seq) return
pk.value = data
// 后端配好的班写回地址栏:刷新、分享出去看到的是同一组
const classes = data.classes.map((item) => item.className).join(',')
if (classes && classes !== route.query.classes) router.replace({ query: { ...route.query, classes } })
} catch {
if (mine === seq) pk.value = null
} finally {
if (mine === seq) loading.value = false
}
}
watch(() => [route.query.classes, route.query.period], load, { immediate: true })
onMounted(async () => {
try {
battle.value = await getClassBattle()
} catch {
battle.value = []
}
})
const selected = computed(() => pk.value?.classes.map((item) => item.className) ?? [])
function setClasses(classes: string[]) {
report.value = ''
ai.abort()
router.push({ query: { ...route.query, classes: classes.join(',') || undefined } })
}
function remove(className: string) {
setClasses(selected.value.filter((name) => name !== className))
}
function replace(from: string, to: string) {
if (selected.value.includes(to)) return
setClasses(selected.value.map((name) => (name === from ? to : name)))
}
function chipLabel(className: string) {
return isDesktop.value ? classLabel(className) : `${className.slice(2)}班`
}
function add(className: string) {
if (selected.value.includes(className)) return
setClasses([...selected.value, className])
}
function setPeriod(value: ClassPkPeriod) {
report.value = ''
ai.abort()
router.push({ query: { ...route.query, period: value === 'term' ? undefined : value } })
}
/** 「+ 加一个班」:这学期在用的班(班级对抗里有的),按年级分组,自己年级放最前 */
const addOptions = computed(() =>
groupClassOptions(
battle.value.map((item) => item.className).filter((name) => !selected.value.includes(name)),
undefined,
selected.value[0]?.slice(0, 2),
),
)
const mode = computed(() => {
if (!pk.value || pk.value.classes.length < 2) return 'empty'
return pk.value.classes.length === 2 ? 'duel' : 'grid'
})
const ruleText = computed(() => (mode.value === 'grid' ? '三个班以上按题对照 · 过半的人交过算这个班布置过' : '两个班只比都布置过的题 · 过半的人交过算布置过'))
// ---------- AI 分析(只给老师) ----------
const ai = useAIStream()
const report = ref('')
const showReport = ref(false)
async function analyze() {
if (selected.value.length < 2) return
showReport.value = true
report.value = ''
try {
await ai.run('ai/class-pk-analysis', { classNames: selected.value, period: period.value }, { onDelta: (content) => (report.value += content) })
} catch (error) {
if (!report.value) showReport.value = false
message.error((error as Error).message)
}
}
</script>
<template>
<div class="pk-page oj-page" :class="{ compact: !isDesktop }">
<div class="toolbar">
<div class="title-line">
<router-link to="/rank" class="back">‹ 排名</router-link>
<h2>班级 PK</h2>
<span v-if="isDesktop" class="muted">{{ ruleText }}</span>
<div class="spacer" />
<div v-if="isDesktop" class="seg" role="group" aria-label="时间">
<button :class="{ on: period === 'week' }" @click="setPeriod('week')">这周</button>
<button :class="{ on: period === 'term' }" @click="setPeriod('term')">这学期</button>
</div>
<n-button v-if="teacher && isDesktop" secondary type="info" :disabled="selected.length < 2" :loading="ai.waiting.value" @click="analyze">
<template #icon>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M12 3l1.8 4.7L18.5 9.5l-4.7 1.8L12 16l-1.8-4.7L5.5 9.5l4.7-1.8z" />
</svg>
</template>
AI 分析
</n-button>
</div>
<div class="chips">
<template v-for="(className, index) in selected" :key="className">
<span v-if="index === 1 && selected.length === 2" class="vs">VS</span>
<span v-if="className === pk?.mine || selected.length > 2" class="chip">
<i :style="{ background: pkColor(index) }" />
{{ chipLabel(className) }}
<span v-if="className === pk?.mine" class="mine-tag">你们班</span>
<button v-else class="x" :aria-label="`去掉${classLabel(className)}`" @click="remove(className)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true">
<path d="M6 6l12 12M18 6L6 18" />
</svg>
</button>
</span>
<!-- 只剩两个班时不能删(删成一个班后端会再配一个),点它换成别的班 -->
<n-popselect v-else :options="addOptions" scrollable trigger="click" :value="null" @update:value="(value: string) => replace(className, value)">
<button class="chip" title="换一个班">
<i :style="{ background: pkColor(index) }" />
{{ chipLabel(className) }}
<svg
class="chev"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M6 9l6 6 6-6" />
</svg>
</button>
</n-popselect>
</template>
<n-popselect v-if="selected.length < MAX_CLASSES" :options="addOptions" scrollable trigger="click" :value="null" @update:value="add">
<button class="chip add">+ 加一个班</button>
</n-popselect>
</div>
<div v-if="!isDesktop" class="seg wide" role="group" aria-label="时间">
<button :class="{ on: period === 'week' }" @click="setPeriod('week')">这周</button>
<button :class="{ on: period === 'term' }" @click="setPeriod('term')">这学期</button>
</div>
</div>
<n-spin :show="loading">
<template v-if="pk">
<div v-if="mode === 'empty'" class="card empty">
<b>再加一个班来比</b>
<span class="muted">
{{ pk.classes.length ? '同年级没找到别的在用的班,点上面「+ 加一个班」挑一个' : '你还没有班级,点上面「+ 加一个班」挑两个班来比' }}
</span>
</div>
<template v-else>
<div v-if="!pk.problems.length" class="card empty">
<b>这几个班{{ period === 'week' ? '这周' : '这学期' }}还没有一起布置过的题</b>
<span class="muted">一个班过半的人交过这道题才算布置过,至少两个班都布置过才能按题比</span>
<PkRace v-if="period === 'term'" :pk="pk" :height="240" class="empty-race" />
</div>
<PkDuel v-else-if="mode === 'duel'" :pk="pk" :compact="!isDesktop" />
<PkGrid v-else :pk="pk" :compact="!isDesktop" />
</template>
<section v-if="teacher && showReport" class="ai">
<div class="ai-head">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M12 3l1.8 4.7L18.5 9.5l-4.7 1.8L12 16l-1.8-4.7L5.5 9.5l4.7-1.8z" />
</svg>
<b>AI 分析</b>
<div class="spacer" />
<n-button size="small" :loading="ai.running.value" @click="analyze">重新分析</n-button>
</div>
<span v-if="ai.waiting.value" class="muted">正在看这几个班的数据…</span>
<Markdown v-if="report" :content="report" />
</section>
</template>
<div v-else-if="!loading" class="card empty"><b>没取到数据</b><span class="muted">刷新一下试试</span></div>
</n-spin>
</div>
</template>
<style scoped>
/* 比别的阅读型页面宽:三个班以上是一张「题 × 班」的对照格,班多了格子会挤 */
.pk-page {
max-width: 1440px;
display: flex;
flex-direction: column;
gap: var(--oj-gap);
}
.toolbar {
display: flex;
flex-direction: column;
gap: 14px;
}
.title-line {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12px;
}
.title-line h2 {
margin: 0;
font-size: var(--oj-fs-title);
}
.back {
font-size: var(--oj-fs-sec);
color: v-bind('theme.primaryColor');
text-decoration: none;
}
.spacer {
flex-grow: 1;
}
.muted {
font-size: var(--oj-fs-sec);
color: v-bind('theme.textColor3');
}
.seg {
display: inline-flex;
height: var(--oj-ctrl-h);
border: 1px solid v-bind('theme.borderColor');
border-radius: 4px;
overflow: hidden;
}
.seg button {
padding: 0 14px;
border: 0;
border-left: 1px solid v-bind('theme.borderColor');
background: v-bind('theme.cardColor');
color: v-bind('theme.textColor2');
font: inherit;
font-size: var(--oj-fs-sec);
cursor: pointer;
white-space: nowrap;
}
.seg button:first-child {
border-left: 0;
}
.seg button.on {
background: rgba(24, 160, 88, 0.12);
color: #18a058;
font-weight: 600;
}
.seg.wide button {
flex: 1;
}
.chips {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.chip {
height: var(--oj-ctrl-h);
box-sizing: border-box;
padding: 0 10px 0 12px;
border: 1px solid v-bind('theme.borderColor');
border-radius: 17px;
background: v-bind('theme.cardColor');
color: v-bind('theme.textColor1');
font: inherit;
font-size: var(--oj-fs-sec);
font-weight: 600;
display: inline-flex;
align-items: center;
gap: 6px;
white-space: nowrap;
}
.chip i {
width: 10px;
height: 10px;
border-radius: 5px;
flex-shrink: 0;
}
.chip.add {
padding: 0 14px;
border-style: dashed;
font-weight: 400;
color: v-bind('theme.textColor2');
cursor: pointer;
}
.x {
width: 18px;
height: 18px;
padding: 0;
border: 0;
background: none;
color: v-bind('theme.textColor3');
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.x svg {
width: 12px;
height: 12px;
}
button.chip {
cursor: pointer;
}
.chev {
width: 12px;
height: 12px;
color: v-bind('theme.textColor3');
}
.x:hover {
color: v-bind('theme.textColor1');
}
.vs {
font-weight: 800;
font-size: 16px;
font-style: italic;
color: v-bind('theme.textColor3');
}
.mine-tag {
font-size: 12px;
background: rgba(24, 160, 88, 0.12);
color: #18a058;
border-radius: 3px;
padding: 0 6px;
height: 18px;
display: inline-flex;
align-items: center;
font-weight: 600;
}
.card {
border: 1px solid v-bind('theme.borderColor');
border-radius: var(--oj-radius);
background: v-bind('theme.cardColor');
}
.empty {
min-height: 200px;
padding: 32px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
text-align: center;
}
.empty-race {
max-width: 700px;
margin-top: 12px;
}
.ai {
margin-top: 16px;
padding: 16px 20px;
border-radius: var(--oj-radius);
border: 1px solid rgba(47, 111, 208, 0.3);
background: rgba(47, 111, 208, 0.05);
display: flex;
flex-direction: column;
gap: 8px;
}
.ai-head {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
color: #2f6fd0;
}
.ai-head b {
color: v-bind('theme.textColor1');
}
.ai-head .muted {
font-size: var(--oj-fs-meta);
}
.compact .chip {
font-size: var(--oj-fs-meta);
}
</style>