feat(点评): 点评改回轮盘并重新设计——每块直接写着名字,选完就地变成玫瑰图看大家怎么选

上一版把轮盘换成了按钮格子,改回轮盘,但重新画了一个:
- SVG 画的 7 块环形扇区,每块上直接写着表情和名字(最大 16px),不用先把鼠标移过去;
  悬停的那块染上主色、往外弹出,圆心报它的名字
- 选之前哪里都没有人数;选完就地变成玫瑰图:每块从里往外填一段、长短按人数,标「N 人」,
  自己选的那块是主色、弹出、带 ✓,圆心写「你选了 学到了 · 共 N 人评过」
- 没登录、没做对时轮盘照画、整个淡下去,圆心写原因
- 原来的轮盘用 CSS clip-path 加 color-mix(),后者 Chrome 111 才有,机房 105 那档扇区没有底色
- 上一版的防误触(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 17:19:37 +08:00
1 parent c64b0a402b
commit 5bb69ecce3
2 files changed
+405 -195

No files matched your search

@@ -9,26 +9,29 @@ import { REACTIONS } from "utils/constants"
import type { ReactionCounts, ReactionKey } from "utils/types"
/**
* 题目点评:七个选项,每人每题只能评一次,做对了才能评。
* 题目点评轮盘:七个选项,每人每题只能评一次,做对了才能评。
*
* 原来是一个轮盘:鼠标移到扇区上才看得清是哪一项,每块的字只有 12px 左右,
* 移过去的同时圆心就报出「N 人选择」—— 学生还没想好就先看到了大家怎么选。
* 现在是一排大按钮,**选之前不给人数**(后端本来就要自己评过才返回 counts),
* 选完变成一张横条图,看大家是怎么觉得的(设计文档第 7 节)。
* 和原来那个轮盘比:
* - 每块上**直接写着表情和名字**,字号跟着轮盘走(最大 16px),不用先把鼠标移过去才看得清;
* 原来每块的字只有 12px 左右
* - **选之前哪里都没有人数**(后端本来就要自己评过才返回 counts),圆心只报悬停的那一项,
* 免得跟风;选完就地变成玫瑰图:每块从里往外填一段,长短按人数,标出「N 人」
* - 扇区是 SVG 画的。原来是 CSS clip-path 加 `color-mix()`,后者 Chrome 111 才有,
* 机房 105 那档整条声明作废、扇区没有底色
*
* `guard`:AC 之后那个强制弹窗用。弹窗是自己冒出来的,学生手还在键盘上、鼠标还在
* 原来的位置,所以前 600ms 点不了,也不响应 Enter / 空格(键盘触发的 click 的
* detail 是 0)。抽屉里的是学生自己点开的,不设防。
* `guard`:AC 之后那个强制弹窗用。弹窗是自己冒出来的,学生手还在键盘上、鼠标还停在
* 原来的位置(很可能正好压在某一块上),所以前 600ms 点不了,也不响应 Enter / 空格;
* 扇区不进 Tab 顺序,不会被预选。抽屉里的是学生自己点开的,不设防、键盘能用。
*/
const props = defineProps<{ guard?: boolean }>()
const emit = defineEmits<{ submitted: [] }>()
const GUARD_MS = 600
const userStore = useUserStore()
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)
@@ -36,11 +39,62 @@ const loadFailed = ref(false)
const submitting = ref<ReactionKey | null>(null)
const submitFailed = ref(false)
const ready = ref(!props.guard)
const activeIndex = ref<number | null>(null)
let loadSequence = 0
// ==================== 几何 ====================
// viewBox 400 × 400,圆心 (200, 200)。标签是盖在 SVG 上的一层 HTML,按百分比定位
const C = 200
const OUTER = 192
const INNER = 70
const LABEL_RADIUS = 136
/** 悬停 / 选中时往外弹出的距离(viewBox 单位) */
const POP = 7
const step = 360 / REACTIONS.length
function polar(angle: number, radius: number) {
const rad = (angle * Math.PI) / 180
return { x: C + Math.cos(rad) * radius, y: C + Math.sin(rad) * radius }
}
/** 一块环形扇区:外弧 → 内弧 */
function sectorPath(start: number, end: number, inner: number, outer: number) {
const a = polar(start, outer)
const b = polar(end, outer)
const c = polar(end, inner)
const d = polar(start, inner)
const f = (n: number) => n.toFixed(2)
return [
`M${f(a.x)} ${f(a.y)}`,
`A${outer} ${outer} 0 0 1 ${f(b.x)} ${f(b.y)}`,
`L${f(c.x)} ${f(c.y)}`,
`A${inner} ${inner} 0 0 0 ${f(d.x)} ${f(d.y)}`,
"Z",
].join(" ")
}
// 第一项正对 12 点,顺时针排
const sectors = REACTIONS.map((item, index) => {
const mid = -90 + index * step
const label = polar(mid, LABEL_RADIUS)
const rad = (mid * Math.PI) / 180
return {
...item,
index,
path: sectorPath(mid - step / 2, mid + step / 2, INNER, OUTER),
mid,
left: `${(label.x / 400) * 100}%`,
top: `${(label.y / 400) * 100}%`,
popX: Math.cos(rad) * POP,
popY: Math.sin(rad) * POP,
}
})
// ==================== 状态 ====================
const solved = computed(() => problem.value?.myStatus === 0)
const state = computed(() => {
type WheelState = "anonymous" | "loading" | "failed" | "locked" | "choose" | "results"
const state = computed<WheelState>(() => {
if (!userStore.isAuthed) return "anonymous"
if (loading.value) return "loading"
if (loadFailed.value) return "failed"
@@ -49,7 +103,10 @@ const state = computed(() => {
return "locked"
})
// 选项出现的那一刻起算:换题、登录之后重新出现也重新算
const choosable = computed(() => state.value === "choose")
const canPick = computed(() => choosable.value && ready.value && !submitting.value)
// 从能选的那一刻起算:换题、登录之后重新能选,也重新算
const { start: armGuard, stop: disarmGuard } = useTimeoutFn(
() => {
ready.value = true
@@ -58,33 +115,99 @@ const { start: armGuard, stop: disarmGuard } = useTimeoutFn(
{ immediate: false },
)
watch(
() => state.value === "choose",
(choosing) => {
if (!choosing || !props.guard) return
choosable,
(value) => {
if (!value || !props.guard) return
ready.value = false
activeIndex.value = null
disarmGuard()
armGuard()
},
{ immediate: true },
)
function onOptionClick(key: ReactionKey, event: MouseEvent) {
if (!ready.value) return
// ==================== 玫瑰图(选完之后) ====================
const results = computed(() => {
const all = counts.value
if (state.value !== "results" || !all) return null
const values = REACTIONS.map((item) => all[item.key] ?? 0)
const max = Math.max(1, ...values)
return {
total: values.reduce((sum, value) => sum + value, 0),
petals: sectors.map((sector, index) => {
const count = values[index] ?? 0
// 一个人都没有的那块不画;有人的至少露出一截,不然 1 人和 0 人看着一样
const reach = count === 0 ? 0 : 0.12 + 0.88 * (count / max)
const path =
reach === 0
? ""
: sectorPath(
sector.mid - step / 2,
sector.mid + step / 2,
INNER,
INNER + (OUTER - INNER) * reach,
)
return { count, path }
}),
}
})
// ==================== 圆心 ====================
const labelOf = (key: ReactionKey | null) => REACTIONS.find((item) => item.key === key)?.label ?? ""
const center = computed<{ small: string; big: string; foot?: string }>(() => {
switch (state.value) {
case "anonymous":
return { small: "登录之后", big: "才能点评" }
case "loading":
return { small: "正在读取", big: "…" }
case "failed":
return { small: "暂时读不出来", big: "过会儿再看" }
case "locked":
return { small: "做对之后", big: "才能点评" }
case "results":
return {
small: "你选了",
big: labelOf(mine.value),
foot: `共 ${results.value?.total ?? 0} 人评过`,
}
}
if (submitting.value) return { small: "正在记下", big: labelOf(submitting.value) }
if (activeIndex.value !== null) {
return { small: "选这个?", big: REACTIONS[activeIndex.value]?.label ?? "" }
}
return { small: "点一块", big: "选一个" }
})
// ==================== 交互 ====================
function preview(index: number | null) {
if (!canPick.value) return
activeIndex.value = index
}
function onSectorClick(key: ReactionKey, event: MouseEvent) {
// 键盘触发的 click(Enter / 空格)detail 是 0
if (props.guard && event.detail === 0) return
pick(key)
}
function onSectorKey(key: ReactionKey) {
if (!props.guard) pick(key)
}
async function pick(key: ReactionKey) {
if (!problem.value || !solved.value || mine.value !== null || submitting.value) return
const current = problem.value
if (!canPick.value || !current) return
submitting.value = key
submitFailed.value = false
try {
const res = await setReaction(problem.value.id, key)
const res = await setReaction(current.id, key)
mine.value = res.mine
counts.value = res.counts
activeIndex.value = null
emit("submitted")
} catch {
// 留在这里让他再点一次:弹窗关不掉,没有别的出路
// 留在这里让他再点一次:强制弹窗关不掉,没有别的出路
submitFailed.value = true
} finally {
submitting.value = null
@@ -114,6 +237,7 @@ watch(
counts.value = null
submitting.value = null
submitFailed.value = false
activeIndex.value = null
if (!isAuthed || problemId === undefined) {
loadSequence += 1
loading.value = false
@@ -124,22 +248,12 @@ 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}%`,
})),
}
})
function sectorAriaLabel(index: number) {
const item = REACTIONS[index]!
if (!results.value) return item.label
const count = results.value.petals[index]?.count ?? 0
return `${item.label},${count} 人${mine.value === item.key ? ",你的选择" : ""}`
}
</script>
<template>
@@ -147,67 +261,95 @@ const results = computed(() => {
class="reaction"
:style="{
'--accent': theme.primaryColor,
// 抽屉和弹窗的底色是 modalColor(暗色下比 cardColor 浅一档),缝和圆心要和它一样
'--card': theme.modalColor,
'--border': theme.borderColor,
'--card': theme.cardColor,
'--hover': theme.hoverColor,
'--text': theme.textColor1,
'--faint': theme.textColor3,
}"
aria-label="题目点评"
>
<n-empty v-if="state === 'anonymous'" description="登录之后才能点评" />
<p v-if="choosable" class="hint">
选一块最接近的,<b>每道题只能评一次</b>。选完就能看到大家是怎么选的
</p>
<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 REACTIONS"
:key="item.key"
type="button"
class="option"
:class="{ picking: submitting === item.key }"
:disabled="!!submitting"
:aria-busy="submitting === item.key"
@click="onOptionClick(item.key, $event)"
<div
class="wheel"
:class="[`is-${state}`, { 'is-waiting': choosable && !ready }]"
@pointerleave="preview(null)"
>
<svg viewBox="0 0 400 400" class="wheel-svg">
<g
v-for="sector in sectors"
:key="sector.key"
class="sector"
:class="{
active: activeIndex === sector.index,
picking: submitting === sector.key,
mine: mine === sector.key,
}"
:style="{ '--pop-x': `${sector.popX}px`, '--pop-y': `${sector.popY}px` }"
:role="choosable ? 'button' : 'img'"
:tabindex="choosable && !guard ? 0 : -1"
:aria-label="sectorAriaLabel(sector.index)"
:aria-disabled="!canPick"
@pointerenter="preview(sector.index)"
@focus="preview(sector.index)"
@blur="preview(null)"
@click="onSectorClick(sector.key, $event)"
@keydown.enter.prevent="onSectorKey(sector.key)"
@keydown.space.prevent="onSectorKey(sector.key)"
>
<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>
<path class="sector-face" :d="sector.path" />
<!-- 选完之后的玫瑰图:从里往外填一段,长短按人数 -->
<path
v-if="results?.petals[sector.index]?.path"
class="petal"
:d="results.petals[sector.index]!.path"
/>
</g>
<circle class="core" :cx="C" :cy="C" :r="INNER - 4" />
</svg>
<!-- 标签层是 HTML(表情图标是 Iconify 组件),跟着扇区一起弹出 -->
<div
v-for="sector in sectors"
:key="`${sector.key}-label`"
class="label"
:class="{
active: activeIndex === sector.index,
picking: submitting === sector.key,
mine: mine === sector.key,
}"
:style="{
left: sector.left,
top: sector.top,
'--pop-x': `${(sector.popX / 400) * 100}cqi`,
'--pop-y': `${(sector.popY / 400) * 100}cqi`,
}"
aria-hidden="true"
>
<span class="label-icon">
<n-spin v-if="submitting === sector.key" :size="18" />
<Icon v-else :icon="sector.icon" />
</span>
<span class="label-text">{{ sector.label }}</span>
<span v-if="results" class="label-count">
{{ mine === sector.key ? "✓ " : "" }}{{ results.petals[sector.index]?.count ?? 0 }} 人
</span>
</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>
<div class="center" aria-live="polite">
<span class="center-small">{{ center.small }}</span>
<strong class="center-big">{{ center.big }}</strong>
<span v-if="center.foot" class="center-small">{{ center.foot }}</span>
</div>
</div>
<p v-if="submitFailed" class="error">没记上,再点一次</p>
<div v-if="state === 'results' && $slots.after" class="after">
<slot name="after" />
</template>
</div>
</section>
</template>
@@ -215,138 +357,203 @@ const results = computed(() => {
.reaction {
max-width: 560px;
margin: 0 auto;
}
.pad {
padding: 32px 0;
color: var(--text);
}
.hint {
margin: 0 0 14px;
margin: 0 0 12px;
text-align: center;
font-size: 14px;
opacity: 0.8;
color: var(--faint);
}
.options {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 10px;
.hint b {
color: var(--text);
}
.wheel {
position: relative;
width: min(100%, 400px);
aspect-ratio: 1;
margin: 0 auto;
/* 标签字号、弹出距离都按轮盘宽度算(cqi,Chrome 105 起有) */
container-type: inline-size;
user-select: none;
transition: opacity 0.25s;
}
/* 防误触那 600ms:看得出是「还没好」,而不是坏了 */
.options.waiting {
.wheel.is-waiting {
opacity: 0.45;
pointer-events: none;
}
.option {
/* 一行 4 个,7 个排成 4 + 3,第二行居中 */
flex: 0 0 calc((100% - 30px) / 4);
min-width: 0;
height: 92px;
.wheel-svg {
display: block;
width: 100%;
height: 100%;
overflow: visible;
}
/* ---------- 扇区 ---------- */
.sector {
outline: none;
transition: transform 0.16s cubic-bezier(0, 0, 0.2, 1);
}
.sector-face {
fill: var(--faint);
fill-opacity: 0.1;
/* 卡片色的粗描边就是扇区之间的缝,宽度处处一样 */
stroke: var(--card);
stroke-width: 4;
stroke-linejoin: round;
transition:
fill 0.16s,
fill-opacity 0.16s;
}
.is-choose .sector {
cursor: pointer;
}
.is-choose .sector.active,
.sector.picking,
.sector.mine {
transform: translate(var(--pop-x), var(--pop-y));
}
.is-choose .sector.active .sector-face,
.sector.picking .sector-face {
fill: var(--accent);
fill-opacity: 0.18;
}
.sector:focus-visible .sector-face {
stroke: var(--accent);
stroke-width: 3;
}
/* 没登录、没做对、读不出来:轮盘照画,整个淡下去 */
.is-anonymous .sector-face,
.is-locked .sector-face,
.is-failed .sector-face,
.is-loading .sector-face {
fill-opacity: 0.05;
}
/* ---------- 选完:玫瑰图 ---------- */
.petal {
fill: var(--faint);
fill-opacity: 0.28;
stroke: var(--card);
stroke-width: 4;
pointer-events: none;
}
.sector.mine .sector-face {
fill: var(--accent);
fill-opacity: 0.1;
}
.sector.mine .petal {
fill: var(--accent);
fill-opacity: 0.5;
}
.core {
fill: var(--card);
stroke: var(--border);
stroke-width: 1;
}
/* ---------- 标签 ---------- */
.label {
position: absolute;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
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;
gap: 1px;
transform: translate(-50%, -50%);
pointer-events: none;
white-space: nowrap;
transition: transform 0.16s cubic-bezier(0, 0, 0.2, 1);
}
.option:hover:not(:disabled) {
border-color: var(--accent);
background-color: var(--hover);
transform: translateY(-2px);
.is-choose .label.active,
.label.picking,
.label.mine {
transform: translate(calc(-50% + var(--pop-x)), calc(-50% + var(--pop-y))) scale(1.08);
}
.option:disabled {
cursor: default;
opacity: 0.55;
}
.option.picking {
opacity: 1;
border-color: var(--accent);
}
.option-icon {
.label-icon {
display: flex;
font-size: 34px;
font-size: clamp(22px, 8cqi, 32px);
line-height: 1;
}
.option-label {
font-size: 15px;
.label-text {
font-size: clamp(12px, 4cqi, 16px);
font-weight: 600;
white-space: nowrap;
}
.label-count {
font-size: clamp(11px, 3.2cqi, 13px);
color: var(--faint);
font-variant-numeric: tabular-nums;
}
.label.mine .label-count {
color: var(--accent);
font-weight: 600;
}
.is-anonymous .label,
.is-locked .label,
.is-failed .label,
.is-loading .label {
opacity: 0.45;
}
/* ---------- 圆心 ---------- */
.center {
position: absolute;
top: 50%;
left: 50%;
width: 30%;
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
text-align: center;
transform: translate(-50%, -50%);
pointer-events: none;
}
.center-small {
font-size: clamp(10px, 3cqi, 12px);
color: var(--faint);
}
.center-big {
font-size: clamp(14px, 4.6cqi, 18px);
line-height: 1.2;
}
.error {
display: block;
margin-top: 10px;
margin: 10px 0 0;
text-align: center;
color: v-bind("theme.errorColor");
}
.bars {
list-style: none;
margin: 0 0 16px;
padding: 0;
display: flex;
flex-direction: column;
gap: 6px;
.after {
margin-top: 16px;
}
.bar-row {
display: grid;
grid-template-columns: 24px 4.5em 1fr 3.5em;
align-items: center;
gap: 8px;
padding: 4px 8px;
border-radius: 6px;
font-size: 14px;
}
.bar-row.mine {
background-color: var(--hover);
box-shadow: inset 3px 0 0 var(--accent);
font-weight: 600;
}
.bar-icon {
display: flex;
font-size: 20px;
}
.bar-track {
height: 10px;
border-radius: 5px;
background-color: rgba(128, 128, 128, 0.14);
overflow: hidden;
}
.bar-fill {
display: block;
height: 100%;
border-radius: 5px;
background-color: rgba(128, 128, 128, 0.45);
}
.bar-row.mine .bar-fill {
background-color: var(--accent);
}
.bar-count {
text-align: right;
font-variant-numeric: tabular-nums;
@media (prefers-reduced-motion: reduce) {
.sector,
.label {
transition: none;
}
}
</style>
@@ -161,7 +161,7 @@ B 看得到。
「放回编辑器」「复制」;加入题单之前被锁住的提交显示为一行说明,不能展开;底部「看这道题所有人的提交」。
「放回编辑器」后提示「已放回,按 Ctrl+Z 撤回」—— 要确认编辑器的撤销能撤回这次替换。
- **统计**:出题人、创建时间、标签、数字卡、结果饼图、历年通过率,内容原样。
- **点评**:和强制弹窗同一个组件(第 7 节),每人只能评一次。
- **点评**:和强制弹窗同一个轮盘(第 7 节),每人只能评一次。
- 题单入口三个按钮都不出现。比赛入口没有「点评」。
### 5.6 手机
@@ -197,13 +197,16 @@ B 看得到。
## 7. 强制点评
- 文案:「做对了!说说你对这道题的感受吧」(原来的「恭喜你成功提交」不准确,交上去不等于做对)。
- **轮盘换成了一排大按钮**(2026-09-28 用户同意重新设计):七个选项一行 4 个、排成 4 + 3,
每个带表情和 15px 的字。原来的轮盘要把鼠标移到扇区上才看得清是哪一项,每块的字只有 12px
左右,而且用了 Chrome 111 才有的 `color-mix()`,机房 105 那档渲染不出扇区底色。
- 选之前**不显示人数**(后端本来就要自己评过才返回 counts),选完变成横条图:顺序和选项一致、
标出自己选的那一项 —— 避免跟风。没做对的人在抽屉里只看到一句说明,也没有人数。
- 防误触:弹出后 600ms 内选项半透明、点不了;强制弹窗里不响应 Enter / 空格(键盘触发的
click 的 `detail` 是 0);弹窗 `auto-focus` 关掉,不预选任何一项。
- **轮盘重新设计过**(2026-09-28,用户要求保留轮盘的形式):SVG 画的 7 块环形扇区,
每块上**直接写着表情和名字**(字号随轮盘缩放,最大 16px),不用先悬停才看得清;悬停的那块
染上主色、往外弹出一点,圆心报它的名字。原来的轮盘每块的字只有 12px 左右,而且用了
Chrome 111 才有的 `color-mix()`,机房 105 那档渲染不出扇区底色。
- 选之前**不显示人数**(后端本来就要自己评过才返回 counts),选完就地变成**玫瑰图**:每块从里
往外填一段、长短按人数,标「N 人」,自己选的那块用主色、弹出、带 ✓ —— 避免跟风。
没登录、没做对时轮盘照画、整个淡下去,圆心写原因,不给人数。
- 防误触:弹出后 600ms 内轮盘半透明、点不了;强制弹窗里不响应 Enter / 空格(键盘触发的
click 的 `detail` 是 0),扇区不进 Tab 顺序;弹窗 `auto-focus` 关掉,不预选任何一块。
抽屉里的是学生自己点开的,不设防、键盘能选。
- 选完不马上关:弹窗里换成横条图,下面只有一个按钮、焦点落在它上面 —— 被拦下的换题写「继续」,
否则是这节课的「下一题:题号 题目」,都没有就是「好的」。
- **窗口期内换题要先点评**:通过后 1.5 秒内点「下一题」、课堂条芯片、‹ ›、顶栏题号直达,