Files
OJ2/apps/web/src/shared/components/HelpRequestList.vue
T
xuyueandClaude Opus 5.5 e8a20d12f9 refactor(前端): shared / utils 不再 import oj 和 admin 下的任何东西
依赖方向原来是反的:顶栏、登录框、举手列表这些 shared 组件从 oj/ 里拿东西。
- 跨端的接口挪进 shared/api.ts(按 apps/web/CLAUDE.md 的分法):学生端题目列表、
  出题人列表、这节课的题、待弹的成就
- 这节课的 store → shared/store/lesson.ts;头像 RankAvatar → shared/components
- 纯函数挪进 utils/:举手列表的错法归类 errorKind、它依赖的答案错误分类 wrongAnswer
  和 Python 报错翻译 pythonError、题目列表行的 toProblemRow
- 只有后台在用的出题人下拉 AuthorSelect 挪回 admin/components

纯搬家。首页、题目列表、题目页、排名、课堂看板、后台题目列表打开无报错,
顶栏题号框敲 1001 回车直达题目页。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 15:11:21 +08:00

228 lines
6.0 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 { Icon } from "@iconify/vue"
import { errorKind } from "utils/errorKind"
import { useBreakpoints } from "shared/composables/breakpoints"
import { useTeacherHelp } from "shared/composables/teacherHelp"
import { useCollabStore } from "shared/store/collab"
import type { CollabRequestItem } from "shared/composables/websocket"
import { SubmissionStatus } from "utils/constants"
import UserName from "./UserName.vue"
/**
* 顶栏「N 人举手」点开的面板(设计稿「举手与协作」HcPanel),挂在按钮下面,不是弹框。
*
* 按题分组,每人写错法(和课堂看板同一套 errorKind)和等了多久。同一道题两人以上举手,
* 用户的做法是**停下来全班讲**:组头给「讲这道题」(去看板、选中这道题看错法)和
* 「讲完了,都放下」(这道题还在排队的手一次放下)。
*/
const emit = defineEmits<{ close: [] }>()
const collabStore = useCollabStore()
const router = useRouter()
const { help } = useTeacherHelp()
// 接单之后要在题目页的编辑器里替学生写代码,那个编辑器窄屏上没法用 —— 窄屏只让看
const { isDesktop } = useBreakpoints()
// 等待时长每秒走一格。面板只在打开时挂着,关着不转
const now = useNow({ scheduler: (cb) => useIntervalFn(cb, 1000) })
function waited(createdAt: number) {
const seconds = Math.max(0, Math.floor((now.value.getTime() - createdAt) / 1000))
return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`
}
/** 等了三分钟以上标红 */
function waitedLong(createdAt: number) {
return now.value.getTime() - createdAt >= 180_000
}
function kindOf(item: CollabRequestItem) {
if (!item.latest) return "还没交过"
if (item.latest.result === SubmissionStatus.accepted) return "已经做对了"
return errorKind(item.latest).label
}
function pendingOf(items: CollabRequestItem[]) {
return items.filter((item) => item.status === "pending")
}
function onHelp(item: CollabRequestItem) {
if (help(item.studentId)) emit("close")
}
/** 「讲这道题」:去这个班的看板、选中这道题,下面展开错着的人按错法归堆,点人就是讲错误弹层 */
function explain(items: CollabRequestItem[], problemId: string) {
const className = items.find((item) => item.className)?.className
emit("close")
router.push({
name: "classroom board",
query: { ...(className ? { className } : {}), problem: problemId },
})
}
</script>
<template>
<div class="panel">
<div class="title">
<b>举手 {{ collabStore.requests.length }} 人</b>
</div>
<n-alert v-if="!isDesktop" type="info" :bordered="false">接单要在电脑上打开</n-alert>
<n-empty v-if="collabStore.groupedRequests.length === 0" description="没有人举手" />
<div
v-for="group in collabStore.groupedRequests"
:key="group.problemId"
class="group"
:class="{ many: pendingOf(group.items).length >= 2 }"
>
<div class="group-head">
<span class="group-title">
<b class="num">{{ group.problemId }}</b>
<span class="muted">{{ group.problemTitle }}</span>
</span>
<b class="group-count">{{ group.items.length }} 人</b>
<template v-if="pendingOf(group.items).length >= 2">
<n-button size="small" @click="explain(group.items, group.problemId)">讲这道题</n-button>
<n-button size="small" @click="collabStore.dismiss(group.problemId)">
讲完了,都放下
</n-button>
</template>
</div>
<div v-for="item in group.items" :key="item.studentId" class="row">
<div class="who">
<UserName :username="item.studentName" />
<span class="kind">{{ kindOf(item) }}</span>
</div>
<template v-if="item.status === 'active'">
<n-text type="info" strong class="helping">{{ item.teacherName }} 在帮</n-text>
</template>
<template v-else>
<span class="waited" :class="{ long: waitedLong(item.createdAt) }">
等了 {{ waited(item.createdAt) }}
</span>
<n-button v-if="isDesktop" type="primary" size="small" @click="onHelp(item)">
帮他
</n-button>
<n-button
quaternary
circle
size="small"
aria-label="放下他的手"
title="放下他的手"
@click="collabStore.reject(item.studentId)"
>
<Icon icon="mdi:close" height="16" />
</n-button>
</template>
</div>
</div>
</div>
</template>
<style scoped>
.panel {
width: 440px;
max-width: calc(100vw - 32px);
max-height: 70vh;
overflow: auto;
display: flex;
flex-direction: column;
gap: 10px;
}
.title {
font-size: 15px;
}
.group {
border: 1px solid var(--n-divider-color, #e6e7eb);
border-radius: 8px;
overflow: hidden;
}
.group.many {
border-color: #f3d6dd;
}
.group-head {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: rgba(128, 128, 128, 0.08);
white-space: nowrap;
}
.group.many .group-head {
background: rgba(208, 48, 80, 0.07);
}
.group-title {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
font-size: 14px;
}
.group-title .muted {
margin-left: 6px;
}
.group-count {
font-size: 14px;
}
.group.many .group-count {
color: #c0304e;
}
.row {
display: flex;
align-items: center;
gap: 10px;
min-height: 48px;
padding: 0 8px 0 12px;
border-top: 1px solid rgba(128, 128, 128, 0.12);
}
.who {
flex: 1 1 auto;
min-width: 0;
display: flex;
flex-direction: column;
font-size: 15px;
}
.kind {
font-size: 12px;
line-height: 16px;
opacity: 0.65;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.waited {
font-size: 13px;
font-variant-numeric: tabular-nums;
opacity: 0.75;
white-space: nowrap;
}
.waited.long {
color: #c0304e;
opacity: 1;
}
.helping {
font-size: 13px;
white-space: nowrap;
}
.muted {
opacity: 0.6;
}
.num {
font-variant-numeric: tabular-nums;
}
</style>