Files
OJ2/apps/web/src/oj/problemset/detail.vue
T

660 lines
17 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 { useRouteQuery } from "@vueuse/router"
import { useThemeVars } from "naive-ui"
import { getProblemSetDetail, getProblemSetProblems, joinProblemSet } from "oj/api"
import { useTone } from "oj/submission/composables/tone"
import { errorMessage } from "utils/api"
import { parseTime } from "utils/functions"
import type { ProblemSet, ProblemSetProblem } from "utils/types"
import { useAuthModalStore } from "shared/store/authModal"
import { useUserStore } from "shared/store/user"
import { useFireworks } from "../problem/composables/useFireworks"
import NotFound from "shared/components/NotFound.vue"
import BadgeLadder from "./components/BadgeLadder.vue"
import ClassView from "./components/ClassView.vue"
/**
* 题单详情(设计稿「题单重设计」详情 B):顶上一条是进度和奖章阶梯,下面题目分两列。
*
* - 还没加入:题目看得见、点不进去,右边一个大按钮「加入题单,开始做」。加入是用户要留的
* 一步(「只是打开就算加入的话,会有疑惑的」),所以这里要让人一眼看懂为什么要点。
* - 布置期内以前做对过的题标「以前做对过 · 旧代码先藏着」,规则见后端 problemSetLockCutoffs。
* - 做完了:整条变绿,写几点做完、用了多久。烟花只在做完的那一刻放(做对最后一道、
* 1.5 秒后跳回来的那一下),以后再打开不放。
* - 老师多一个「全班情况」页签。
*/
const route = useRoute()
const router = useRouter()
const message = useMessage()
const theme = useThemeVars()
const tone = useTone()
const userStore = useUserStore()
const authStore = useAuthModalStore()
const { celebrate } = useFireworks()
const id = computed(() => Number(route.params.problemSetId))
const set = ref<ProblemSet | null>(null)
const problems = ref<ProblemSetProblem[]>([])
const missing = ref(false)
const joining = ref(false)
const tab = useRouteQuery<string>("tab", "problems")
async function load() {
missing.value = false
try {
const [detail, list] = await Promise.all([
getProblemSetDetail(id.value),
getProblemSetProblems(id.value),
])
set.value = detail
problems.value = list
} catch {
missing.value = true
return
}
const done = set.value.userProgress.completeTime
if (set.value.userProgress.isCompleted && done && Date.now() - Date.parse(done) < 60_000) {
celebrate()
}
}
watch(id, load)
onMounted(load)
const progress = computed(() => set.value?.userProgress)
const joined = computed(() => !!progress.value?.isJoined)
/** 老师不用加入也能点进题目去看(不记进度),也不给他摆「加入」按钮 */
const canOpen = computed(() => joined.value || userStore.isTeacherOrAbove)
const optional = computed(() => (set.value ? set.value.problemsCount - set.value.requiredCount : 0))
const percent = computed(() =>
set.value && progress.value?.isJoined && set.value.requiredCount
? Math.min(100, (progress.value.completedCount / set.value.requiredCount) * 100)
: 0,
)
const hiddenCount = computed(() => problems.value.filter((item) => item.oldCodeHidden).length)
/** 做完用了多久:从加入到做完 */
const spent = computed(() => {
const p = progress.value
if (!p?.joinTime || !p.completeTime) return ""
const minutes = Math.round((Date.parse(p.completeTime) - Date.parse(p.joinTime)) / 60_000)
if (minutes < 60) return `${Math.max(1, minutes)} 分钟`
if (minutes < 24 * 60) return `${Math.round(minutes / 60)} 小时`
return `${Math.round(minutes / 1440)} 天`
})
const columns = computed(() => {
const half = Math.ceil(problems.value.length / 2)
return [problems.value.slice(0, half), problems.value.slice(half)].filter((col) => col.length)
})
function indexOf(item: ProblemSetProblem) {
return problems.value.indexOf(item) + 1
}
function solvedAt(time: string) {
const today = parseTime(new Date(), "YYYY-MM-DD") === parseTime(time, "YYYY-MM-DD")
return parseTime(time, today ? "HH:mm" : "M月D日")
}
function problemLink(item: ProblemSetProblem) {
return {
name: "problemset problem",
params: { problemSetId: id.value, problemID: item.problem._id },
}
}
async function join() {
if (!userStore.isAuthed) {
authStore.openLoginModal()
return
}
if (joining.value) return
joining.value = true
try {
await joinProblemSet(id.value)
message.success("加入了,现在开始在这里做对的题都会记进度")
await load()
} catch (err) {
message.error(errorMessage(err, "加入失败"))
} finally {
joining.value = false
}
}
/** 编辑只给出题人和超管(后台按归属校验,别人点进去是 404) */
const canEdit = computed(
() => !!set.value && (userStore.isSuperAdmin || set.value.createdBy.id === userStore.user?.id),
)
const info = computed(() => tone("info"))
const success = computed(() => tone("success"))
const warning = computed(() => tone("warning"))
const purple = { color: "#5b3fa8", background: "rgba(122, 95, 208, 0.12)" }
</script>
<template>
<NotFound
v-if="missing"
title="这个题单不存在"
hint="可能还没公开,或者已经删掉了"
to="/problemset"
to-label="回到题单列表"
/>
<div v-else-if="set" class="page oj-page">
<section class="band" :class="{ done: progress?.isCompleted }">
<span v-if="progress?.isCompleted" class="done-mark" aria-hidden="true">✓</span>
<div class="head">
<div class="row">
<router-link :to="{ name: 'problemsets' }" class="muted back">‹ 题单</router-link>
<h2 class="ell">{{ set.title }}</h2>
<span v-if="set.assigning" class="pill assign"
>布置到 {{ parseTime(set.assignedUntil!, "M月D日") }}</span
>
</div>
<template v-if="progress?.isCompleted">
<div class="done-text">
<b>全部做完了</b> · {{ parseTime(progress.completeTime!, "M月D日 HH:mm") }}
<template v-if="spent">,加入后用了 {{ spent }}</template>
</div>
</template>
<template v-else-if="joined">
<div class="row baseline">
<span class="muted small">做对</span>
<span class="num count">{{ progress!.completedCount }}</span>
<span class="muted num">/ {{ set.requiredCount }} 道</span>
<span class="muted tiny gap">
<template v-if="optional > 0">另有 {{ optional }} 道选做 · </template>
{{ parseTime(progress!.joinTime!, "M月D日 HH:mm") }} 加入
</span>
</div>
<div class="bar"><div class="fill" :style="{ width: `${percent}%` }"></div></div>
</template>
<div v-else class="muted small">
{{ set.createdBy.username }} 出的 · {{ set.problemsCount }} 道<template
v-if="optional > 0"
>,{{ optional }} 道选做</template
><template v-else>,全部必做</template>
<template v-if="!set.badges.length"> · 这个题单没有奖章</template>
</div>
<div v-if="set.description && set.description !== set.title" class="sec small desc">
{{ set.description }}
</div>
</div>
<template v-if="set.badges.length">
<span class="divider"></span>
<div class="ladder-box"><BadgeLadder :set="set" /></div>
</template>
<div class="spacer"></div>
<div v-if="!joined && !userStore.isTeacherOrAbove" class="join">
<n-button type="primary" size="large" :loading="joining" @click="join">
加入题单,开始做
</n-button>
</div>
</section>
<div v-if="!joined && hiddenCount" class="notice" :style="purple">
<svg
class="lock"
width="12"
height="12"
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="9" rx="2" />
<path d="M8 11V8a4 4 0 0 1 8 0v3" />
</svg>
其中 <b>{{ hiddenCount }}</b> 道你以前做对过。布置期内(到
{{
parseTime(set.assignedUntil!, "M月D日")
}})这几道的旧代码先藏起来,在题单里重新做对就能看。
</div>
<div v-if="userStore.isTeacherOrAbove" class="tabs">
<button class="seg" :class="{ on: tab !== 'class' }" @click="tab = 'problems'">题目</button>
<button class="seg" :class="{ on: tab === 'class' }" @click="tab = 'class'">全班情况</button>
<div class="spacer"></div>
<n-button
v-if="canEdit"
size="small"
@click="router.push({ name: 'admin problemset edit', params: { problemSetId: set.id } })"
>
编辑题单
</n-button>
</div>
<ClassView v-if="userStore.isTeacherOrAbove && tab === 'class'" :problem-set-id="set.id" />
<template v-else>
<div v-if="problems.length" class="cols">
<section v-for="(col, ci) in columns" :key="ci" class="card list">
<component
:is="canOpen ? 'router-link' : 'div'"
v-for="item in col"
:key="item.id"
:to="canOpen ? problemLink(item) : undefined"
class="prow"
:class="{ off: !canOpen }"
>
<span v-if="item.isCompleted" class="dot done-dot">✓</span>
<span v-else-if="item.wrongCount" class="dot tried-dot">!</span>
<span v-else class="dot empty-dot"></span>
<span class="muted num idx">{{ indexOf(item) }}</span>
<span class="ell ptitle">{{ item.problem.title }}</span>
<span v-if="!item.isRequired" class="opt">选做</span>
<span
v-if="item.oldCodeHidden"
class="old"
:style="purple"
title="以前做对过,旧代码在布置期内先藏着"
><svg
class="lock"
width="12"
height="12"
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="9" rx="2" />
<path d="M8 11V8a4 4 0 0 1 8 0v3" /></svg
><span class="old-long">以前做对过 · 旧代码先藏着</span
><span class="old-short">做对过</span></span
>
<div class="spacer"></div>
<span v-if="item.isCompleted && item.solvedTime" class="muted num tiny"
>{{ solvedAt(item.solvedTime) }} 做对</span
>
<span v-else-if="item.wrongCount && joined" class="tiny tried"
>交过 {{ item.wrongCount }} 次</span
>
<span v-else-if="canOpen" class="go">去做 ›</span>
</component>
</section>
</div>
<n-empty v-else description="这个题单还没有题目" />
</template>
</div>
</template>
<style scoped>
.page {
display: flex;
flex-direction: column;
gap: var(--oj-gap);
}
.band {
box-sizing: border-box;
padding: 22px 24px 20px;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 16px 20px;
border: 1px solid v-bind("theme.borderColor");
border-radius: var(--oj-radius);
background: v-bind("theme.cardColor");
}
.band.done {
background: v-bind("success.background");
border-color: transparent;
}
.done-mark {
width: 48px;
height: 48px;
border-radius: 50%;
flex-shrink: 0;
background: v-bind("theme.successColor");
color: #ffffff;
font-size: 24px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
}
.done-text {
font-size: var(--oj-fs-body);
color: v-bind("success.color");
}
.head {
display: flex;
flex-direction: column;
gap: 8px;
width: 360px;
max-width: 100%;
min-width: 0;
}
.head h2 {
margin: 0;
font-size: var(--oj-fs-title);
}
.desc {
line-height: 1.5;
}
.row {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.row.baseline {
align-items: baseline;
gap: 6px;
}
.back {
flex-shrink: 0;
font-size: var(--oj-fs-sec);
text-decoration: none;
}
.count {
font-size: 28px;
font-weight: 700;
}
.gap {
margin-left: 6px;
}
.bar {
height: 8px;
border-radius: 4px;
background: v-bind("theme.actionColor");
overflow: hidden;
}
.fill {
height: 100%;
background: v-bind("theme.successColor");
}
.divider {
width: 1px;
align-self: stretch;
background: v-bind("theme.dividerColor");
}
/* 窄屏时阶梯要能横向滚,可 overflow-x 一设,纵向也跟着被裁:下一个奖章的金圈画在图标外 2px,
上沿会被切掉一条。上下留出几像素给它 */
.ladder-box {
max-width: 100%;
overflow-x: auto;
padding: 4px 2px;
}
.join {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 6px;
}
.lock {
flex-shrink: 0;
vertical-align: -1px;
}
.old .lock {
margin-right: 3px;
}
/* 手机上长文案会把题目名挤得只剩一两个字,换成短的;锁的图标还在,全文在悬停提示里 */
.old-short {
display: none;
}
.notice {
box-sizing: border-box;
padding: 12px 18px;
border-radius: var(--oj-radius);
font-size: var(--oj-fs-sec);
}
.tabs {
display: flex;
align-items: center;
}
.seg {
height: var(--oj-ctrl-h);
padding: 0 16px;
border: 1px solid v-bind("theme.borderColor");
background: transparent;
font: inherit;
font-size: var(--oj-fs-sec);
color: v-bind("theme.textColor2");
cursor: pointer;
}
.seg:first-child {
border-radius: 4px 0 0 4px;
}
.seg + .seg {
border-left: none;
border-radius: 0 4px 4px 0;
}
.seg.on {
color: v-bind("success.color");
background: v-bind("success.background");
font-weight: 600;
}
.cols {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(420px, 1fr));
gap: 16px;
align-items: start;
}
.card {
border: 1px solid v-bind("theme.borderColor");
border-radius: var(--oj-radius);
background: v-bind("theme.cardColor");
overflow: hidden;
}
.list {
display: flex;
flex-direction: column;
}
.prow {
height: var(--oj-row-h);
flex-shrink: 0;
box-sizing: border-box;
padding: 0 20px;
display: flex;
align-items: center;
gap: 12px;
border-bottom: 1px solid v-bind("theme.dividerColor");
color: v-bind("theme.textColor1");
text-decoration: none;
}
.prow:last-child {
border-bottom: none;
}
a.prow:hover {
background: v-bind("theme.hoverColor");
}
.prow.off {
color: v-bind("theme.textColor3");
}
.dot {
width: 24px;
height: 24px;
border-radius: 50%;
flex-shrink: 0;
box-sizing: border-box;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 700;
}
.done-dot {
background: v-bind("theme.successColor");
color: #ffffff;
}
.tried-dot {
background: v-bind("warning.background");
color: v-bind("warning.color");
}
.empty-dot {
border: 1.5px dashed v-bind("theme.borderColor");
}
.idx {
width: 20px;
text-align: right;
font-size: var(--oj-fs-sec);
flex-shrink: 0;
}
.ptitle {
font-size: var(--oj-fs-body);
}
.opt {
height: 22px;
padding: 0 7px;
border-radius: 4px;
font-size: var(--oj-fs-meta);
border: 1px solid v-bind("theme.borderColor");
color: v-bind("theme.textColor3");
display: inline-flex;
align-items: center;
flex-shrink: 0;
}
.old {
height: 22px;
padding: 0 7px;
border-radius: 4px;
font-size: var(--oj-fs-meta);
display: inline-flex;
align-items: center;
white-space: nowrap;
flex-shrink: 0;
}
.tried {
color: v-bind("warning.color");
}
.go {
font-size: var(--oj-fs-sec);
color: v-bind("theme.primaryColor");
flex-shrink: 0;
}
.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");
}
.spacer {
flex-grow: 1;
}
.muted {
color: v-bind("theme.textColor3");
}
.sec {
color: v-bind("theme.textColor2");
}
.small {
font-size: var(--oj-fs-sec);
}
.tiny {
font-size: var(--oj-fs-meta);
}
.num {
font-variant-numeric: tabular-nums;
}
.ell {
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
@media (max-width: 640px) {
.cols {
grid-template-columns: minmax(0, 1fr);
}
.band {
padding: 16px;
}
.prow {
padding: 0 12px;
gap: 10px;
}
.head {
width: 100%;
}
.divider {
display: none;
}
.join {
align-items: stretch;
width: 100%;
}
.old-long {
display: none;
}
.old-short {
display: inline;
}
}
</style>