feat(点评): 点评轮盘换成一排大按钮,选之前不显示人数,选完看大家怎么选、直接去下一题

- 七个选项不变,排成 4 + 3 的大按钮(表情 + 15px 的字)。原来的轮盘要把鼠标移到扇区上
  才看得清,每块的字只有 12px 左右;还用了 Chrome 111 才有的 color-mix(),机房 105 那档
  渲染不出扇区底色
- 选之前不显示「N 人选择」,免得跟风;选完换成横条图,标出自己选的那一项
- 防误触:弹出后 600ms 内点不了,强制弹窗里不响应 Enter / 空格,也不预选任何一项
- 选完弹窗不马上关,下面只有一个按钮、焦点就在上面:这节课的「下一题」,或者刚才被拦下
  的换题「继续」,都没有就是「好的」
- 标题从「恭喜你成功提交」改成「做对了!」:交上去不等于做对
- 提交失败留在弹窗里提示「没记上,再点一次」;没做对的人在抽屉里只看到一句说明

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-09-28 16:22:48 +08:00
1 parent 116b0a2c95
commit c64b0a402b
5 files changed
+304 -550

No files matched your search

@@ -8,9 +8,7 @@ import { useLessonStore } from "oj/store/lesson"
* 这节课的题就是首页「班里在做」那几道,和左栏顶上的课堂条读同一份(lesson store);
* 通过的那一刻课堂条已经把这道标成做完、并且重拉了一次。
*
* 「下一道」是按列表顺序排在这道**后面**、还没通过的第一道,后面都做完了再从头找 ——
* 学生常常跳着做,先做完了第 3 道再回头做第 1 道。当前这道本身不看它的 myStatus:
* 重拉回来之前,它还是通过之前那一刻的状态。
* 「下一道」怎么算见 lesson store 的 nextAfter,AC 后的点评弹窗里那个「下一题」也用它。
*
* 这道题不在这节课的列表里(学生在做别的)就什么都不显示。
*/
@@ -24,16 +22,7 @@ const lessonStore = useLessonStore()
// 一般课堂条早就拉过了,这里只是兜底(旧了才拉)
onMounted(() => lessonStore.load())
const lesson = computed(() => {
const list = lessonStore.activity?.problems ?? []
const current = props.problemDisplayId.toLowerCase()
const index = list.findIndex((item) => item.problemDisplayId.toLowerCase() === current)
if (index < 0) return null
const rest = [...list.slice(index + 1), ...list.slice(0, index)].filter(
(item) => item.myStatus !== "accepted",
)
return { total: list.length, next: rest[0] ?? null, remaining: rest.length }
})
const lesson = computed(() => lessonStore.nextAfter(props.problemDisplayId))
const label = computed(() => lessonStore.label)
</script>
@@ -2,236 +2,90 @@
import { Icon } from "@iconify/vue"
import { useThemeVars } from "naive-ui"
import { storeToRefs } from "pinia"
import type { CSSProperties } from "vue"
import { getReaction, setReaction } from "oj/api"
import { useProblemStore } from "oj/store/problem"
import { useUserStore } from "shared/store/user"
import { REACTIONS } from "utils/constants"
import type { ReactionCounts, ReactionKey } from "utils/types"
/**
* 题目点评:七个选项,每人每题只能评一次,做对了才能评。
*
* 原来是一个轮盘:鼠标移到扇区上才看得清是哪一项,每块的字只有 12px 左右,
* 移过去的同时圆心就报出「N 人选择」—— 学生还没想好就先看到了大家怎么选。
* 现在是一排大按钮,**选之前不给人数**(后端本来就要自己评过才返回 counts),
* 选完变成一张横条图,看大家是怎么觉得的(设计文档第 7 节)。
*
* `guard`:AC 之后那个强制弹窗用。弹窗是自己冒出来的,学生手还在键盘上、鼠标还在
* 原来的位置,所以前 600ms 点不了,也不响应 Enter / 空格(键盘触发的 click 的
* detail 是 0)。抽屉里的是学生自己点开的,不设防。
*/
const props = defineProps<{ guard?: boolean }>()
const emit = defineEmits<{ submitted: [] }>()
const userStore = useUserStore()
const problemStore = useProblemStore()
const { problem } = storeToRefs(problemStore)
const message = useMessage()
const { problem } = storeToRefs(useProblemStore())
const theme = useThemeVars()
const GUARD_MS = 600
const mine = ref<ReactionKey | null>(null)
const counts = ref<ReactionCounts | null>(null)
const loading = ref(false)
// 正在提交的 key,用来锁住整组并只在当前选项显示进度。
const loadFailed = ref(false)
const submitting = ref<ReactionKey | null>(null)
const activeIndex = ref<number | null>(null)
const keyboardActive = ref(false)
const wheelRef = ref<HTMLElement | null>(null)
const submitFailed = ref(false)
const ready = ref(!props.guard)
let loadSequence = 0
const wheelGeometry = {
startAngle: -90,
contentRadius: 35,
pushRadius: 4,
outerRadius: 50,
arcPointCount: 9,
hitInnerRadius: 0.18,
hitOuterRadius: 0.49,
} as const
const sliceAngle = 360 / REACTIONS.length
function pointOnCircle(angle: number, radius: number) {
const radians = (angle * Math.PI) / 180
return {
x: 50 + Math.cos(radians) * radius,
y: 50 + Math.sin(radians) * radius,
}
}
function getWheelItemStyle(index: number): CSSProperties {
const centerAngle = wheelGeometry.startAngle + index * sliceAngle
const startAngle = centerAngle - sliceAngle / 2
const endAngle = centerAngle + sliceAngle / 2
const dividerAngle = index * sliceAngle - sliceAngle / 2
const position = pointOnCircle(centerAngle, wheelGeometry.contentRadius)
const push = pointOnCircle(centerAngle, wheelGeometry.pushRadius)
const arcPoints = Array.from({ length: wheelGeometry.arcPointCount }, (_, pointIndex) => {
const progress = pointIndex / (wheelGeometry.arcPointCount - 1)
const angle = startAngle + (endAngle - startAngle) * progress
const point = pointOnCircle(angle, wheelGeometry.outerRadius)
return `${point.x.toFixed(3)}% ${point.y.toFixed(3)}%`
})
return {
"--segment-path": `polygon(50% 50%, ${arcPoints.join(", ")})`,
"--content-x": `${position.x}%`,
"--content-y": `${position.y}%`,
"--push-x": `${push.x - 50}px`,
"--push-y": `${push.y - 50}px`,
"--divider-angle": `${dividerAngle}deg`,
}
}
const wheelItems = REACTIONS.map((item, index) => ({
...item,
index,
style: getWheelItemStyle(index),
}))
const solved = computed(() => problem.value?.myStatus === 0)
const locked = computed(() => mine.value !== null)
const canInteract = computed(
() =>
userStore.isAuthed &&
!!problem.value &&
solved.value &&
!locked.value &&
!loading.value &&
!submitting.value,
)
const wheelCenter = computed(() => {
if (loading.value) {
return {
icon: "ph:spinner-gap-bold",
eyebrow: "正在读取",
label: "题目点评",
spinning: true,
}
}
if (submitting.value) {
const item = REACTIONS.find((reaction) => reaction.key === submitting.value)
return {
icon: "svg-spinners:180-ring-with-bg",
eyebrow: "正在记录",
label: item?.label ?? "提交点评",
spinning: false,
}
}
if (mine.value) {
const item = REACTIONS.find((reaction) => reaction.key === mine.value)
return {
icon: "ph:check-bold",
eyebrow: "你的选择",
label: item?.label ?? "已提交",
spinning: false,
}
}
if (!userStore.isAuthed) {
return {
icon: "ph:user-circle-dashed",
eyebrow: "登录后开放",
label: "登录后点评",
spinning: false,
}
}
if (activeIndex.value !== null) {
const item = wheelItems[activeIndex.value]
const count = counts.value?.[item.key]
return {
icon: item.icon,
eyebrow: count === undefined ? "选择这项" : `${count} 人选择`,
label: item.label,
spinning: false,
}
}
if (!solved.value) {
return {
icon: "ph:lock-simple-bold",
eyebrow: "完成后开放",
label: "通关后点评",
spinning: false,
}
}
return {
icon: "ph:cursor-click-bold",
eyebrow: "移动到扇区",
label: "选择点评",
spinning: false,
}
const state = computed(() => {
if (!userStore.isAuthed) return "anonymous"
if (loading.value) return "loading"
if (loadFailed.value) return "failed"
if (mine.value !== null && counts.value) return "results"
if (solved.value) return "choose"
return "locked"
})
const reactionStyle = computed(() => ({
"--reaction-accent": theme.value.primaryColor,
"--reaction-card": theme.value.cardColor,
"--reaction-border": theme.value.borderColor,
"--reaction-text": theme.value.textColor1,
"--reaction-text-faint": theme.value.textColor3,
}))
// 选项出现的那一刻起算:换题、登录之后重新出现也重新算
const { start: armGuard, stop: disarmGuard } = useTimeoutFn(
() => {
ready.value = true
},
GUARD_MS,
{ immediate: false },
)
watch(
() => state.value === "choose",
(choosing) => {
if (!choosing || !props.guard) return
ready.value = false
disarmGuard()
armGuard()
},
{ immediate: true },
)
function optionAriaLabel(key: ReactionKey, label: string) {
const count = counts.value?.[key]
const countText = count === undefined ? "" : `,${count} 人选择`
const selectedText = mine.value === key ? ",你的选择" : ""
return `${label}${countText}${selectedText}`
}
function getPointerIndex(event: PointerEvent | MouseEvent) {
const wheel = wheelRef.value
if (!wheel) return null
const bounds = wheel.getBoundingClientRect()
const x = event.clientX - (bounds.left + bounds.width / 2)
const y = event.clientY - (bounds.top + bounds.height / 2)
const distance = Math.hypot(x, y)
if (
distance < bounds.width * wheelGeometry.hitInnerRadius ||
distance > bounds.width * wheelGeometry.hitOuterRadius
) {
return null
}
const angle = (Math.atan2(y, x) * 180) / Math.PI
const rawIndex = Math.round((angle - wheelGeometry.startAngle) / sliceAngle)
return ((rawIndex % wheelItems.length) + wheelItems.length) % wheelItems.length
}
function preview(index: number, fromKeyboard = false) {
if (!canInteract.value) return
keyboardActive.value = fromKeyboard
activeIndex.value = index
}
function clearPreview() {
if (locked.value) return
activeIndex.value = null
keyboardActive.value = false
}
function onWheelPointerMove(event: PointerEvent) {
if (!canInteract.value) return
keyboardActive.value = false
activeIndex.value = getPointerIndex(event)
}
function onWheelClick(event: MouseEvent) {
if (!canInteract.value) return
const target = event.target
if (target instanceof Element && target.closest(".reaction-option")) return
const index = getPointerIndex(event)
if (index !== null) pick(wheelItems[index].key)
function onOptionClick(key: ReactionKey, event: MouseEvent) {
if (!ready.value) return
if (props.guard && event.detail === 0) return
pick(key)
}
async function pick(key: ReactionKey) {
if (!problem.value || !solved.value || locked.value || loading.value || submitting.value) return
activeIndex.value = null
keyboardActive.value = false
if (!problem.value || !solved.value || mine.value !== null || submitting.value) return
submitting.value = key
submitFailed.value = false
try {
const res = await setReaction(problem.value.id, key)
mine.value = res.mine
counts.value = res.counts
emit("submitted")
} catch {
message.error("提交失败,请重试")
// 留在这里让他再点一次:弹窗关不掉,没有别的出路
submitFailed.value = true
} finally {
submitting.value = null
}
@@ -240,13 +94,14 @@ async function pick(key: ReactionKey) {
async function load(problemId: number) {
const sequence = ++loadSequence
loading.value = true
loadFailed.value = false
try {
const res = await getReaction(problemId)
if (sequence !== loadSequence) return
mine.value = res.mine
counts.value = res.counts
} catch {
if (sequence === loadSequence) message.error("暂时无法读取题目点评")
if (sequence === loadSequence) loadFailed.value = true
} finally {
if (sequence === loadSequence) loading.value = false
}
@@ -258,9 +113,7 @@ watch(
mine.value = null
counts.value = null
submitting.value = null
activeIndex.value = null
keyboardActive.value = false
submitFailed.value = false
if (!isAuthed || problemId === undefined) {
loadSequence += 1
loading.value = false
@@ -270,391 +123,230 @@ watch(
},
{ immediate: true },
)
const results = computed(() => {
const all = counts.value
if (!all) return null
const total = REACTIONS.reduce((sum, item) => sum + (all[item.key] ?? 0), 0)
const max = Math.max(1, ...REACTIONS.map((item) => all[item.key] ?? 0))
return {
total,
mineLabel: REACTIONS.find((item) => item.key === mine.value)?.label ?? "",
// 顺序和选项一致、不按人数排:每道题的图都长一个样,一眼能比
rows: REACTIONS.map((item) => ({
...item,
count: all[item.key] ?? 0,
width: `${((all[item.key] ?? 0) / max) * 100}%`,
})),
}
})
</script>
<template>
<section class="reaction-panel" :style="reactionStyle" aria-label="题目点评">
<div class="wheel-stage">
<div
ref="wheelRef"
class="reaction-wheel"
:class="{
'has-selection': locked,
'is-disabled': !canInteract,
'is-keyboard-active': keyboardActive,
}"
role="group"
aria-label="选择一项题目点评,点击后立即提交"
:aria-busy="loading || !!submitting"
@pointermove="onWheelPointerMove"
@pointerleave="clearPreview"
@click="onWheelClick"
>
<span
v-for="item in wheelItems"
:key="`${item.key}-face`"
class="segment-face"
:class="{
'is-active': activeIndex === item.index,
'is-selected': mine === item.key,
'is-submitting': submitting === item.key,
}"
:style="item.style"
aria-hidden="true"
/>
<section
class="reaction"
:style="{
'--accent': theme.primaryColor,
'--border': theme.borderColor,
'--card': theme.cardColor,
'--hover': theme.hoverColor,
}"
aria-label="题目点评"
>
<n-empty v-if="state === 'anonymous'" description="登录之后才能点评" />
<span
v-for="item in wheelItems"
:key="`${item.key}-divider`"
class="segment-divider"
:style="item.style"
aria-hidden="true"
/>
<n-flex v-else-if="state === 'loading'" justify="center" class="pad">
<n-spin size="small" />
</n-flex>
<n-empty v-else-if="state === 'failed'" description="点评暂时读不出来,过一会儿再打开看看" />
<n-empty
v-else-if="state === 'locked'"
description="做对之后才能点评。评完就能看到大家是怎么觉得的"
/>
<template v-else-if="state === 'choose'">
<p class="hint">选一个最接近的,<b>每道题只能评一次</b>。选完就能看到大家是怎么选的</p>
<div class="options" :class="{ waiting: !ready }" role="group" aria-label="题目点评的选项">
<button
v-for="item in wheelItems"
v-for="item in REACTIONS"
:key="item.key"
type="button"
class="reaction-option"
:class="{
'is-active': activeIndex === item.index,
'is-selected': mine === item.key,
'is-submitting': submitting === item.key,
'is-muted': locked && mine !== item.key,
'is-unavailable': !userStore.isAuthed || !solved || loading,
}"
:style="item.style"
:disabled="!canInteract"
:aria-pressed="mine === item.key"
:aria-label="optionAriaLabel(item.key, item.label)"
@focus="preview(item.index, true)"
@blur="clearPreview"
@click.stop="pick(item.key)"
class="option"
:class="{ picking: submitting === item.key }"
:disabled="!!submitting"
:aria-busy="submitting === item.key"
@click="onOptionClick(item.key, $event)"
>
<span class="option-content">
<span class="option-icon" aria-hidden="true">
<Icon :icon="submitting === item.key ? 'svg-spinners:180-ring-with-bg' : item.icon" />
</span>
<span class="option-label">{{ item.label }}</span>
<span v-if="counts" class="option-count"> {{ counts[item.key] }} 人 </span>
<span class="option-icon" aria-hidden="true">
<n-spin v-if="submitting === item.key" :size="28" />
<Icon v-else :icon="item.icon" />
</span>
<span class="option-label">{{ item.label }}</span>
</button>
<div class="wheel-core" aria-hidden="true">
<div class="core-content">
<Icon
class="core-icon"
:class="{ 'is-spinning': wheelCenter.spinning }"
:icon="wheelCenter.icon"
/>
<span class="core-eyebrow">{{ wheelCenter.eyebrow }}</span>
<strong class="core-label">{{ wheelCenter.label }}</strong>
</div>
</div>
</div>
</div>
<n-text v-if="submitFailed" type="error" class="error">没记上,再点一次</n-text>
</template>
<template v-else-if="results">
<p class="hint">
你选了<b>「{{ results.mineLabel }}」</b> · 一共 {{ results.total }} 人点评过这道题
</p>
<ul class="bars">
<li
v-for="row in results.rows"
:key="row.key"
class="bar-row"
:class="{ mine: row.key === mine }"
>
<span class="bar-icon" aria-hidden="true"><Icon :icon="row.icon" /></span>
<span class="bar-label">{{ row.label }}</span>
<span class="bar-track"><span class="bar-fill" :style="{ width: row.width }" /></span>
<span class="bar-count">{{ row.count }} 人</span>
</li>
</ul>
<slot name="after" />
</template>
</section>
</template>
<style scoped>
.reaction-panel {
width: min(100%, 720px);
box-sizing: border-box;
container-type: inline-size;
.reaction {
max-width: 560px;
margin: 0 auto;
padding: clamp(12px, 3vw, 24px);
color: var(--reaction-text);
}
.wheel-stage {
display: grid;
place-items: center;
padding: 4px 0;
.pad {
padding: 32px 0;
}
.reaction-wheel {
position: relative;
width: min(100%, 400px);
box-sizing: border-box;
aspect-ratio: 1;
overflow: hidden;
border: 1px solid var(--reaction-border);
border-radius: 50%;
background: var(--reaction-card);
isolation: isolate;
.hint {
margin: 0 0 14px;
font-size: 14px;
opacity: 0.8;
}
.segment-face {
position: absolute;
inset: 1px;
overflow: hidden;
background: color-mix(in srgb, var(--reaction-text) 4%, var(--reaction-card));
clip-path: var(--segment-path);
transform: translate(0, 0) scale(1);
transform-origin: center;
pointer-events: none;
transition:
transform 140ms cubic-bezier(0, 0, 0.2, 1),
background-color 140ms ease-out;
.options {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 10px;
transition: opacity 0.25s;
}
.segment-face:is(.is-active, .is-submitting) {
z-index: 2;
background: color-mix(in srgb, var(--reaction-accent) 10%, var(--reaction-card));
transform: translate(calc(var(--push-x) * 0.7), calc(var(--push-y) * 0.7)) scale(1.045);
}
.segment-face.is-selected {
z-index: 3;
background: color-mix(in srgb, var(--reaction-accent) 18%, var(--reaction-card));
transform: translate(var(--push-x), var(--push-y)) scale(1.075);
}
.segment-divider {
position: absolute;
z-index: 8;
top: 1px;
left: calc(50% - 1px);
width: 2px;
height: calc(50% - 1px);
background: var(--reaction-border);
transform: rotate(var(--divider-angle));
transform-origin: 50% 100%;
/* 防误触那 600ms:看得出是「还没好」,而不是坏了 */
.options.waiting {
opacity: 0.45;
pointer-events: none;
}
.reaction-option {
position: absolute;
z-index: 9;
top: var(--content-y);
left: var(--content-x);
.option {
/* 一行 4 个,7 个排成 4 + 3,第二行居中 */
flex: 0 0 calc((100% - 30px) / 4);
min-width: 0;
height: 92px;
display: flex;
width: clamp(66px, 20%, 80px);
min-height: clamp(52px, 16%, 64px);
align-items: stretch;
justify-content: stretch;
padding: 0;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--reaction-text);
font: inherit;
transform: translate(-50%, -50%);
touch-action: manipulation;
cursor: pointer;
}
.reaction-option:disabled {
cursor: not-allowed;
}
.reaction-option.is-unavailable {
opacity: 0.58;
}
.reaction-option.is-muted {
opacity: 0.48;
}
.reaction-option:is(.is-selected, .is-submitting) {
opacity: 1;
}
.reaction-option:focus-visible {
outline: 2px solid color-mix(in srgb, var(--reaction-accent) 55%, transparent);
outline-offset: 2px;
}
.option-content {
display: flex;
width: 100%;
min-height: 100%;
flex-direction: column;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 2px;
border-radius: 6px;
transform: translate(0, 0) scale(1);
transition: transform 140ms cubic-bezier(0, 0, 0.2, 1);
gap: 6px;
border: 1px solid var(--border);
border-radius: 10px;
background-color: var(--card);
color: inherit;
font: inherit;
cursor: pointer;
transition:
border-color 0.15s,
background-color 0.15s,
transform 0.15s;
}
.reaction-option:active:not(:disabled) .option-content {
transform: scale(0.96);
.option:hover:not(:disabled) {
border-color: var(--accent);
background-color: var(--hover);
transform: translateY(-2px);
}
.reaction-option:is(.is-active, .is-submitting) .option-content {
transform: translate(calc(var(--push-x) * 0.8), calc(var(--push-y) * 0.8)) scale(1.18);
.option:disabled {
cursor: default;
opacity: 0.55;
}
.reaction-option:is(.is-active, .is-submitting):active:not(:disabled) .option-content {
transform: translate(calc(var(--push-x) * 0.8), calc(var(--push-y) * 0.8)) scale(1.1);
}
.reaction-option.is-selected .option-content {
transform: translate(var(--push-x), var(--push-y)) scale(1.24);
.option.picking {
opacity: 1;
border-color: var(--accent);
}
.option-icon {
display: grid;
width: 28px;
height: 28px;
place-items: center;
font-size: 24px;
display: flex;
font-size: 34px;
line-height: 1;
}
.option-icon svg {
width: 24px;
height: 24px;
}
.option-label {
font-size: 13px;
font-size: 15px;
font-weight: 600;
line-height: 1.25;
white-space: nowrap;
}
.option-count {
color: var(--reaction-text-faint);
font-size: 12px;
font-weight: 400;
font-variant-numeric: tabular-nums;
line-height: 1.2;
}
.reaction-option.is-selected .option-count {
color: var(--reaction-text);
}
.wheel-core {
position: absolute;
z-index: 12;
top: 50%;
left: 50%;
display: grid;
width: 31%;
aspect-ratio: 1;
place-items: center;
padding: 12px;
border: 1px solid var(--reaction-border);
border-radius: 50%;
background: var(--reaction-card);
color: var(--reaction-text);
.error {
display: block;
margin-top: 10px;
text-align: center;
transform: translate(-50%, -50%);
pointer-events: none;
}
.core-content {
.bars {
list-style: none;
margin: 0 0 16px;
padding: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.bar-row {
display: grid;
justify-items: center;
gap: 3px;
grid-template-columns: 24px 4.5em 1fr 3.5em;
align-items: center;
gap: 8px;
padding: 4px 8px;
border-radius: 6px;
font-size: 14px;
}
.core-icon {
width: clamp(20px, 5cqi, 26px);
height: clamp(20px, 5cqi, 26px);
font-size: clamp(20px, 5cqi, 26px);
color: var(--reaction-text-faint);
}
.core-eyebrow {
color: var(--reaction-text-faint);
font-size: clamp(9px, 2.3cqi, 11px);
font-weight: 400;
}
.core-label {
font-size: clamp(12px, 3cqi, 15px);
.bar-row.mine {
background-color: var(--hover);
box-shadow: inset 3px 0 0 var(--accent);
font-weight: 600;
line-height: 1.2;
text-wrap: balance;
}
.reaction-wheel.is-keyboard-active :is(.segment-face, .option-content) {
transition-duration: 0.01ms;
.bar-icon {
display: flex;
font-size: 20px;
}
.is-spinning {
animation: reaction-spin 850ms linear infinite;
.bar-track {
height: 10px;
border-radius: 5px;
background-color: rgba(128, 128, 128, 0.14);
overflow: hidden;
}
@container (max-width: 440px) {
.wheel-stage {
padding-top: 0;
}
.reaction-option {
width: clamp(58px, 20%, 70px);
min-height: clamp(48px, 16%, 56px);
}
.segment-face:is(.is-active, .is-submitting) {
transform: translate(calc(var(--push-x) * 0.5), calc(var(--push-y) * 0.5)) scale(1.03);
}
.segment-face.is-selected {
transform: translate(calc(var(--push-x) * 0.75), calc(var(--push-y) * 0.75)) scale(1.05);
}
.option-content {
gap: 2px;
}
.reaction-option:is(.is-active, .is-submitting) .option-content {
transform: translate(calc(var(--push-x) * 0.55), calc(var(--push-y) * 0.55)) scale(1.12);
}
.reaction-option:is(.is-active, .is-submitting):active:not(:disabled) .option-content {
transform: translate(calc(var(--push-x) * 0.55), calc(var(--push-y) * 0.55)) scale(1.06);
}
.reaction-option.is-selected .option-content {
transform: translate(calc(var(--push-x) * 0.75), calc(var(--push-y) * 0.75)) scale(1.17);
}
.option-icon {
width: 24px;
height: 24px;
font-size: 21px;
}
.option-icon svg {
width: 21px;
height: 21px;
}
.option-label {
font-size: 11px;
}
.option-count {
font-size: 11px;
}
.wheel-core {
padding: 10px;
}
.bar-fill {
display: block;
height: 100%;
border-radius: 5px;
background-color: rgba(128, 128, 128, 0.45);
}
@media (prefers-reduced-motion: reduce) {
.segment-face,
.option-content,
.option-icon,
.option-label,
.option-count {
transition-duration: 0.01ms;
}
.is-spinning {
animation-duration: 1.8s;
}
.bar-row.mine .bar-fill {
background-color: var(--accent);
}
@keyframes reaction-spin {
to {
transform: rotate(360deg);
}
.bar-count {
text-align: right;
font-variant-numeric: tabular-nums;
}
</style>
@@ -5,7 +5,7 @@ import { getReaction } from "oj/api"
import { useProblemStore } from "oj/store/problem"
import { useSubmissionStore } from "oj/store/submission"
import { useFireworks } from "oj/problem/composables/useFireworks"
import { useBreakpoints } from "shared/composables/breakpoints"
import { useLessonStore } from "oj/store/lesson"
import { SubmissionStatus } from "utils/constants"
import { useProblemPageContext } from "../composables/problemPageContext"
@@ -28,10 +28,9 @@ const { submission } = storeToRefs(submissionStore)
const router = useRouter()
const ctx = useProblemPageContext()
const { isDesktop } = useBreakpoints()
const { celebrate } = useFireworks()
// ==================== AC 后弹出点评轮盘 ====================
// ==================== AC 后弹出点评 ====================
// 只对已经能评价、且还没评过的人弹:后端要求先有 AC 才收评价,这里刚 AC 完正好;
// mine 非 null 说明早就评过了,别再打扰。
//
@@ -50,6 +49,7 @@ async function openReview() {
try {
const res = await getReaction(problem.value!.id)
if (res.mine === null) {
reviewed.value = false
commentPanel.value = true
return
}
@@ -76,6 +76,46 @@ function closeCommentPanel() {
settleReview()
}
/**
* 评完不马上关:弹窗里换成大家怎么选的,下面只有一个按钮,焦点落在它上面。
* 按钮去哪:刚才点「下一题」之类被拦下的,就接着去那里;否则是这节课的下一题
* (和结果页签里 LessonNext 同一个算法);都没有就是「好的」,关掉回到结果页签。
*/
const reviewed = ref(false)
const continueButton = useTemplateRef<{ $el: HTMLElement }>("continueButton")
const lessonStore = useLessonStore()
// navigationAfterReview 是普通变量(守卫里改,不需要触发渲染),按钮文案要一个响应式的影子
const navigationAfterReviewShown = ref(false)
/** 题目名中位 7 字、最长 33 字,按钮里放不下那么长的 */
const shortTitle = (title: string) => (title.length > 14 ? `${title.slice(0, 13)}…` : title)
const continueAction = computed(() => {
if (navigationAfterReviewShown.value) return { label: "继续", target: null }
const lesson = problem.value ? lessonStore.nextAfter(problem.value._id) : null
const next = ctx.value.lessonNext ? lesson?.next : null
if (next)
return {
label: `下一题:${next.problemDisplayId} ${shortTitle(next.title)}`,
target: `/problem/${next.problemDisplayId}`,
}
return { label: "好的", target: null }
})
async function onReviewed() {
reviewed.value = true
navigationAfterReviewShown.value = navigationAfterReview !== null
await nextTick()
continueButton.value?.$el.focus()
}
function continueAfterReview() {
const target = continueAction.value.target
if (target && !navigationAfterReview) navigationAfterReview = target
closeCommentPanel()
}
const { start: showCommentPanelDelayed, stop: cancelCommentPanel } = useTimeoutFn(
openReview,
1500,
@@ -188,15 +228,25 @@ watch(
</script>
<template>
<!-- 原来的标题是「恭喜你成功提交」:交上去不等于做对。auto-focus 关掉:不预选任何一项 -->
<n-modal
v-model:show="commentPanel"
preset="card"
title="恭喜你成功提交,说说你对这道题的感受吧"
:title="reviewed ? '大家是这样觉得的' : '做对了!说说你对这道题的感受吧'"
:mask-closable="false"
:closable="false"
:close-on-esc="false"
:style="{ maxWidth: isDesktop && '50vw', maxHeight: '80vh' }"
v-model:show="commentPanel"
:auto-focus="false"
style="width: min(600px, calc(100vw - 32px))"
>
<ProblemReaction @submitted="closeCommentPanel" />
<ProblemReaction guard @submitted="onReviewed">
<template #after>
<n-flex justify="end">
<n-button ref="continueButton" type="primary" @click="continueAfterReview">
{{ continueAction.label }}
</n-button>
</n-flex>
</template>
</ProblemReaction>
</n-modal>
</template>
+18 -1
View File
@@ -49,10 +49,27 @@ export const useLessonStore = defineStore("lesson", () => {
return !!find(problemDisplayId)
}
/**
* 通过这道之后该去哪一道:按列表顺序排在这道**后面**、还没通过的第一道,后面都做完了
* 再从头找 —— 学生常常跳着做,先做完了第 3 道再回头做第 1 道。当前这道本身不看它的
* myStatus:重拉回来之前,它还是通过之前那一刻的状态。
* 这道不在这节课里返回 null;在,但别的都做完了,next 为 null。
*/
function nextAfter(problemDisplayId: string) {
const list = activity.value?.problems ?? []
const id = problemDisplayId.toLowerCase()
const index = list.findIndex((item) => item.problemDisplayId.toLowerCase() === id)
if (index < 0) return null
const rest = [...list.slice(index + 1), ...list.slice(0, index)].filter(
(item) => item.myStatus !== "accepted",
)
return { total: list.length, next: rest[0] ?? null, remaining: rest.length }
}
/** 老师布置的叫「老师布置的题」,推断出来的叫「这节课的题」 */
const label = computed(() =>
activity.value?.source === "teacher" ? "老师布置的题" : "这节课的题",
)
return { activity, label, load, markAccepted, includes }
return { activity, label, load, markAccepted, includes, nextAfter }
})
@@ -1,7 +1,7 @@
# OJ2 设计文档:题目详情页重设计
日期:2026-09-28
状态:**设计已定,第 14 节的 1–4 步已实施**(还没做:5.5 的「我的提交」行内展开与「放回编辑器」、5.2 的例子表格、第 7 节的点评文案与防误触)。效果图在 claude.ai 的私有画布「题目详情页重设计」里(「机房(方案 B)」
状态:**设计已定,第 14 节的 1–4 步和第 7 节已实施**(还没做:5.5 的「我的提交」行内展开与「放回编辑器」、5.2 的例子表格)。效果图在 claude.ai 的私有画布「题目详情页重设计」里(「机房(方案 B)」
那一页是定稿,「初稿 1280×800」那一页已作废,只留着对照)。效果图表达不了的规则都写在这里,
**实施时以本文为准**。
@@ -53,7 +53,7 @@
## 3. 已拍板的决定
1. **AC 后点评保持强制弹窗**,七个选项不变(「题目有错」留在轮盘里)。
1. **AC 后点评保持强制弹窗**,七个选项不变(「题目有错」和其他六项放在一起,不单独挪走)。
2. **题单入口隐藏**「我的提交」「本题提交」「统计」「点评」和标题下的「已解决 / 尝试过」标签 ——
不让学生看到加入题单之前的提交、抄到答案。
3. **「运行例子」先继续用 Judge0**,不另做判题机端点。
@@ -161,7 +161,7 @@ B 看得到。
「放回编辑器」「复制」;加入题单之前被锁住的提交显示为一行说明,不能展开;底部「看这道题所有人的提交」。
「放回编辑器」后提示「已放回,按 Ctrl+Z 撤回」—— 要确认编辑器的撤销能撤回这次替换。
- **统计**:出题人、创建时间、标签、数字卡、结果饼图、历年通过率,内容原样。
- **点评**:轮盘原样,每人只能评一次。
- **点评**:和强制弹窗同一个组件(第 7 节),每人只能评一次。
- 题单入口三个按钮都不出现。比赛入口没有「点评」。
### 5.6 手机
@@ -197,9 +197,15 @@ B 看得到。
## 7. 强制点评
- 文案:「做对了!说说你对这道题的感受吧」(原来的「恭喜你成功提交」不准确,交上去不等于做对)。
- 选之前**不显示人数**(圆心只显示悬停的选项名),选完才显示大家怎么选的 —— 避免跟风。
- 轮盘 360px(原来 300,每块的字只有 12px 左右)。
- 防误触:弹出后 600ms 内不接受点击;不响应 Enter / 空格;不预选任何一块;选完焦点落到「下一题」。
- **轮盘换成了一排大按钮**(2026-09-28 用户同意重新设计):七个选项一行 4 个、排成 4 + 3,
每个带表情和 15px 的字。原来的轮盘要把鼠标移到扇区上才看得清是哪一项,每块的字只有 12px
左右,而且用了 Chrome 111 才有的 `color-mix()`,机房 105 那档渲染不出扇区底色。
- 选之前**不显示人数**(后端本来就要自己评过才返回 counts),选完变成横条图:顺序和选项一致、
标出自己选的那一项 —— 避免跟风。没做对的人在抽屉里只看到一句说明,也没有人数。
- 防误触:弹出后 600ms 内选项半透明、点不了;强制弹窗里不响应 Enter / 空格(键盘触发的
click 的 `detail` 是 0);弹窗 `auto-focus` 关掉,不预选任何一项。
- 选完不马上关:弹窗里换成横条图,下面只有一个按钮、焦点落在它上面 —— 被拦下的换题写「继续」,
否则是这节课的「下一题:题号 题目」,都没有就是「好的」。
- **窗口期内换题要先点评**:通过后 1.5 秒内点「下一题」、课堂条芯片、‹ ›、顶栏题号直达,
先立刻弹出点评,选完再跳。**现在线上可以被跳过**(`SubmitCode.vue` 换题时 `cancelCommentPanel()`)。
- 查询或提交点评的接口出错时不能把学生卡住:查询失败就不弹;提交失败留在弹窗里提示「没记上,再点一次」。