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

@@ -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>