Files
OJ2/apps/web/src/oj/problem/components/ProblemSubmission.vue
T
xuyueandClaude Opus 5.5 c17f88cdbb
Deploy / deploy (push) Canceled after 0s
feat(题单): 题单页重做——布置中的放最上面,详情顶上一条进度和奖章阶梯,没加入看得见题点不进;老师看全班、后台一屏编辑
- 列表:布置中的大卡片(做对几道、布置到哪天、再做对几道拿哪个奖章),其余小卡片,加入过的在前
- 详情:进度 + 奖章阶梯(下一个金圈「再做对 n 道」),题目两列写几点做对 / 交过几次;
  以前做对过的标「旧代码先藏着」;做完整条变绿,烟花只在做完那一刻放
- 老师多一个「全班情况」页签;题目页从链接直接进来没加入时,条上给「加入题单」
- 后台一屏编辑:布置一周 / 两周 / 四周 / 选日期,公开开关,粘贴多个题号一次加,拖动排序,奖章只剩两种条件
- 旧代码藏着的地方都说清楚是哪个题单、哪天之后能看
- 修掉列表卡片把选做题数说成「新加了 N 题」

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 22:01:39 +08:00

531 lines
15 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 lang="ts" setup>
import { useThemeVars } from "naive-ui"
import { getRankOfProblem, getSubmission, getSubmissions } from "oj/api"
import { useCodeStore } from "oj/store/code"
import { useProblemStore } from "oj/store/problem"
import LockNote from "oj/problemset/components/LockNote.vue"
import Pagination from "shared/components/Pagination.vue"
import { useCollabStore } from "shared/store/collab"
import { useUserStore } from "shared/store/user"
import { JUDGE_STATUS, LANGUAGE_SHOW_VALUE } from "utils/constants"
import { copyToClipboard, parseTime } from "utils/functions"
import type { ProblemRank } from "@oj2/contract"
import type { LANGUAGE, SubmissionListItem } from "utils/types"
import { useProblemPageContext } from "../composables/problemPageContext"
import { useTeacherCollab } from "../composables/teacherCollab"
/**
* 「我的提交」抽屉:这道题自己交过的每一次。
*
* 原来是一张表(提交时间 / 编号 / 状态 / 语言),点编号弹出一个 70vw 的提交详情弹窗,
* 盖住整个题目页;「复制回到题目」还会整页跳一次路由。现在每一行点开就在行内看代码,
* 「放回编辑器」直接换掉右边编辑器里的代码(按 Ctrl+Z 能撤回),抽屉不关、不跳页。
*/
const userStore = useUserStore()
const codeStore = useCodeStore()
const problemStore = useProblemStore()
const collabStore = useCollabStore()
const ctx = useProblemPageContext()
const route = useRoute()
const router = useRouter()
const message = useMessage()
const theme = useThemeVars()
const problemDisplayId = computed(() => String(route.params.problemID ?? ""))
/**
* 协作中的教师:编辑器里是学生的代码,「放回编辑器」会经 Yjs 直接盖掉学生正在写的
* 那一份,所以不给(设计文档第 9 节)。
*/
const teacherCollab = useTeacherCollab()
/** 协作中看的是学生的提交(设计文档第 9 节):房间里的 peerName 就是学生的用户名 */
const peer = computed(() => (teacherCollab.value ? (collabStore.room?.peerName ?? null) : null))
// ==================== 班上第几个做对的 ====================
// 只在题库入口给:比赛有自己的排名,题单里整个抽屉都不出现
const rank = ref<ProblemRank | null>(null)
const rankLine = computed(() => {
const r = rank.value
if (!r) return null
const inClass = !!r.className
const scope = inClass ? "班上" : "全站"
const count = inClass ? r.classAcCount : r.allAcCount
if (r.rank !== -1)
return {
text: `你是${scope}第 ${r.rank} 个做对的,${scope}一共 ${count} 人做对了`,
solved: true,
}
if (count > 0)
return { text: `${inClass ? "你们班" : "全站"}已经有 ${count} 人做对了`, solved: false }
return null
})
/** 「看看他们的写法」:同班的走 'ks' + 班级名的用户名前缀约定(设计文档第 13 节) */
function goAccepted() {
const r = rank.value
if (!r) return
const target = {
name: "submissions",
query: {
problem: problemDisplayId.value,
result: "0",
page: 1,
limit: 10,
...(r.className ? { username: "ks" + r.className } : {}),
},
}
// 协作中跳走这一页协作就断了
if (teacherCollab.value) window.open(router.resolve(target).href, "_blank")
else router.push(target)
}
// ==================== 列表 ====================
const submissions = ref<SubmissionListItem[]>([])
const total = ref(0)
// 拉回来之前别说「还没交过」
const listed = ref(false)
const query = reactive({ limit: 10, page: 1 })
const blocked = computed(() => {
if (!userStore.isAuthed) return "登录之后才能看到自己的提交"
if (!userStore.showSubmissions) return "提交列表已被管理员关闭"
return ""
})
async function listSubmissions() {
const res = await getSubmissions({
...query,
...(peer.value
? { username: peer.value, exactUsername: "1" as const }
: { myself: "1" as const }),
offset: query.limit * (query.page - 1),
problemDisplayId: problemDisplayId.value,
contestId: ctx.value.contestId,
})
submissions.value = res.results
total.value = res.total
listed.value = true
}
/**
* 「我的提交统计」:这一页里各种结果各几次(「答案错误 × 3」「答案正确 × 1」),和原来一样
* 只数当前这一页。被题单闸门锁住的那几次不数 —— 它们在列表里只露一行说明,这里也不该露出结果
*/
const statusDistribution = computed(() => {
const counts = new Map<number, number>()
for (const row of submissions.value) {
if (row.showLink) counts.set(row.result, (counts.get(row.result) ?? 0) + 1)
}
return [...counts.entries()]
.sort((a, b) => a[0] - b[0])
.map(([result, count]) => ({
result,
name: JUDGE_STATUS[result as keyof typeof JUDGE_STATUS]?.name ?? "未知",
type: JUDGE_STATUS[result as keyof typeof JUDGE_STATUS]?.type ?? "info",
count,
}))
})
/** 列表是新的在前;「第几次」从最早那次数起 */
function attemptNo(index: number) {
return total.value - (query.page - 1) * query.limit - index
}
// ==================== 行内展开 ====================
const expanded = ref<string | null>(null)
type Loaded = { code: string; language: LANGUAGE }
const details = reactive<Record<string, Loaded | "loading" | "failed">>({})
async function toggle(row: SubmissionListItem) {
// 题单布置期内藏着的旧提交,点不开(后端也不会给代码)
if (!row.showLink) return
if (expanded.value === row.id) {
expanded.value = null
return
}
expanded.value = row.id
if (details[row.id] && details[row.id] !== "failed") return
details[row.id] = "loading"
try {
const res = await getSubmission(row.id)
details[row.id] = { code: res.code, language: res.language }
} catch {
details[row.id] = "failed"
}
}
/** 这一页里被题单闸门藏起来的次数 */
const lockedCount = computed(() => submissions.value.filter((row) => !row.showLink).length)
/** 展开的代码先只露 5 行,「一共 11 行」点一下再全摆出来(设计稿) */
const PREVIEW_LINES = 5
const fullCode = reactive<Record<string, boolean>>({})
function preview(id: string) {
const code = loadedOf(id)?.code ?? ""
const lines = code.replace(/\s+$/, "").split("\n")
const more = !fullCode[id] && lines.length > PREVIEW_LINES + 1
return {
text: more ? `${lines.slice(0, PREVIEW_LINES).join("\n")}\n` : code,
more,
total: lines.length,
}
}
function loadedOf(id: string): Loaded | null {
const value = details[id]
return value && typeof value === "object" ? value : null
}
/** 这道题现在还收不收那次提交的语言(0020 从题目里摘掉了 Java / JS / Go,老提交还在) */
function canPutBack(id: string) {
const loaded = loadedOf(id)
return !!loaded && problemStore.codeLanguages.some((language) => language === loaded.language)
}
async function putBack(id: string) {
const loaded = loadedOf(id)
if (!loaded || !canPutBack(id)) return
// 语言不一样就先按正常的路子切过去(存语言偏好、读那门语言的草稿;画流程图时切回代码编辑器),
// 等编辑器切完再放代码 —— 直接改 codeStore 的语言会绕过这些,草稿存错地方
if (codeStore.code.language !== loaded.language) {
problemStore.switchLanguage(loaded.language)
await nextTick()
}
// 同一个编辑器里 dispatch 一次整段替换,CodeMirror 的历史记着它,Ctrl+Z 能撤回
codeStore.setCode(loaded.code)
message.success("已放回编辑器,按 Ctrl+Z 可以撤回")
}
async function copy(id: string) {
const loaded = loadedOf(id)
if (!loaded) return
const ok = await copyToClipboard(loaded.code)
message[ok ? "success" : "error"](ok ? "代码已复制" : "复制失败")
}
/** 完整详情(测试点、自测猫、分享)开新标签:这一页的编辑器和结果还要留着 */
function openDetail(id: string) {
window.open(router.resolve(`/submission/${id}`).href, "_blank")
}
onMounted(() => {
if (blocked.value) return
listSubmissions()
// 排名是按当前登录的人算的,协作中那是老师自己的,不给
if (ctx.value.entry === "problem" && !peer.value) {
getRankOfProblem(problemDisplayId.value)
.then((res) => {
rank.value = res
})
.catch(() => {})
}
})
watch(query, () => {
expanded.value = null
listSubmissions()
})
</script>
<template>
<n-alert v-if="blocked" type="warning" :show-icon="false">{{ blocked }}</n-alert>
<template v-else>
<div v-if="rankLine" class="rank" :class="{ solved: rankLine.solved }">
<span>{{ rankLine.text }}</span>
<n-button v-if="userStore.showSubmissions" text type="primary" @click="goAccepted">
看看他们的写法 ›
</n-button>
</div>
<div v-if="statusDistribution.length" class="distribution">
<n-tag
v-for="item in statusDistribution"
:key="item.result"
:type="item.type"
size="small"
round
:bordered="false"
>
{{ item.name }} × {{ item.count }}
</n-tag>
</div>
<n-empty
v-if="listed && !submissions.length"
class="empty"
:description="peer ? '他还没有交过这道题' : '这道题你还没有交过'"
/>
<!-- 设计稿「我的提交 / 统计 / 点评:抽屉盖住左栏」:一整块带框的列表,行间一条细线 -->
<div v-else-if="submissions.length" class="list">
<template v-for="(row, index) in submissions" :key="row.id">
<div v-if="row.showLink" class="item" :class="{ open: expanded === row.id }">
<button
type="button"
class="row"
:aria-expanded="expanded === row.id"
@click="toggle(row)"
>
<span class="attempt">第 {{ attemptNo(index) }} 次</span>
<span class="verdict" :class="JUDGE_STATUS[row.result]?.type">
{{ JUDGE_STATUS[row.result]?.name ?? "未知" }}
</span>
<span class="meta">
{{ LANGUAGE_SHOW_VALUE[row.language] }} ·
{{ parseTime(row.createTime, "MM-DD HH:mm") }}
</span>
<svg
class="caret"
:class="{ up: expanded === row.id }"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
aria-hidden="true"
>
<path d="M6 9l6 6 6-6" />
</svg>
</button>
<div v-if="expanded === row.id" class="detail">
<n-flex v-if="details[row.id] === 'loading'" justify="center" class="pad">
<n-spin size="small" />
</n-flex>
<n-text v-else-if="details[row.id] === 'failed'" type="error">
代码读不出来,收起来再点开试试
</n-text>
<template v-else-if="loadedOf(row.id)">
<pre class="code">{{ preview(row.id).text
}}<button
v-if="preview(row.id).more"
type="button"
class="code-more"
@click="fullCode[row.id] = true"
>…… 一共 {{ preview(row.id).total }} 行,点开看全部</button></pre>
<div class="detail-actions">
<n-button
v-if="!teacherCollab"
size="small"
type="primary"
ghost
:disabled="!canPutBack(row.id)"
:title="
canPutBack(row.id)
? undefined
: `这道题现在不收 ${LANGUAGE_SHOW_VALUE[loadedOf(row.id)!.language]} 了,只能复制`
"
@click="putBack(row.id)"
>
放回编辑器
</n-button>
<n-button size="small" @click="copy(row.id)">复制</n-button>
<n-button size="small" text class="more" @click="openDetail(row.id)">
完整详情 ›
</n-button>
</div>
</template>
</div>
</div>
</template>
<!-- 题单布置期内藏着的那几次:合成一行,点不开(后端也不给代码) -->
<div v-if="lockedCount" class="locked">
<svg
width="13"
height="13"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<rect x="5" y="11" width="14" height="10" rx="2" />
<path d="M8 11V8a4 4 0 018 0v3" />
</svg>
<span>
有 {{ lockedCount }} 次提交先藏起来了:<LockNote
:problem="{ displayId: problemDisplayId }"
/>
</span>
</div>
</div>
<Pagination
v-if="total > query.limit"
:total="total"
v-model:limit="query.limit"
v-model:page="query.page"
/>
</template>
</template>
<style scoped>
.distribution {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 10px;
}
.rank {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px 12px;
padding: 10px 12px;
margin-bottom: 12px;
border-radius: 6px;
background-color: rgba(128, 128, 128, 0.08);
font-size: 14px;
}
.rank.solved {
background-color: rgba(24, 160, 88, 0.1);
}
.empty {
margin: 32px 0;
}
.list {
margin: 0 0 12px;
border: 1px solid v-bind("theme.dividerColor");
border-radius: 6px;
overflow: hidden;
}
.item + .item,
.item + .locked {
border-top: 1px solid v-bind("theme.dividerColor");
}
.row {
width: 100%;
height: 44px;
box-sizing: border-box;
padding: 0 14px;
display: flex;
align-items: center;
gap: 12px;
border: 0;
background: transparent;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
}
.row:hover,
.item.open .row {
background-color: rgba(128, 128, 128, 0.06);
}
.attempt {
flex: none;
width: 4em;
font-size: 13px;
color: v-bind("theme.textColor2");
font-variant-numeric: tabular-nums;
}
.verdict {
flex: none;
min-width: 5em;
}
.verdict.success {
color: v-bind("theme.successColorPressed");
font-weight: 600;
}
.verdict.error {
color: v-bind("theme.errorColorPressed");
}
.verdict.warning {
color: v-bind("theme.warningColorPressed");
}
.meta {
font-size: 13px;
color: v-bind("theme.textColor3");
font-variant-numeric: tabular-nums;
}
.caret {
margin-left: auto;
flex: none;
color: v-bind("theme.textColor3");
transition: transform 0.15s;
}
.caret.up {
transform: rotate(180deg);
}
.detail {
padding: 10px 14px;
display: flex;
flex-direction: column;
gap: 10px;
}
.pad {
padding: 12px 0;
}
.code {
margin: 0;
padding: 8px 10px;
max-height: 360px;
overflow: auto;
border-radius: 4px;
background-color: rgba(128, 128, 128, 0.06);
font-family: Consolas, Monaco, monospace;
font-size: 13px;
line-height: 20px;
white-space: pre;
}
.code-more {
padding: 0;
border: 0;
background: transparent;
font: inherit;
color: v-bind("theme.textColor3");
cursor: pointer;
}
.code-more:hover {
color: v-bind("theme.primaryColor");
}
.detail-actions {
display: flex;
align-items: center;
gap: 8px;
}
.locked {
height: 44px;
box-sizing: border-box;
padding: 0 14px;
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: v-bind("theme.textColor3");
}
.more {
margin-left: auto;
}
</style>