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:
1 parent
c64b0a402b
commit
5bb69ecce3
2 files changed
+407
-197
No files matched your search
@@ -9,26 +9,29 @@ import { REACTIONS } from "utils/constants"
|
|||||||
import type { ReactionCounts, ReactionKey } from "utils/types"
|
import type { ReactionCounts, ReactionKey } from "utils/types"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 题目点评:七个选项,每人每题只能评一次,做对了才能评。
|
* 题目点评轮盘:七个选项,每人每题只能评一次,做对了才能评。
|
||||||
*
|
*
|
||||||
* 原来是一个轮盘:鼠标移到扇区上才看得清是哪一项,每块的字只有 12px 左右,
|
* 和原来那个轮盘比:
|
||||||
* 移过去的同时圆心就报出「N 人选择」—— 学生还没想好就先看到了大家怎么选。
|
* - 每块上**直接写着表情和名字**,字号跟着轮盘走(最大 16px),不用先把鼠标移过去才看得清;
|
||||||
* 现在是一排大按钮,**选之前不给人数**(后端本来就要自己评过才返回 counts),
|
* 原来每块的字只有 12px 左右
|
||||||
* 选完变成一张横条图,看大家是怎么觉得的(设计文档第 7 节)。
|
* - **选之前哪里都没有人数**(后端本来就要自己评过才返回 counts),圆心只报悬停的那一项,
|
||||||
|
* 免得跟风;选完就地变成玫瑰图:每块从里往外填一段,长短按人数,标出「N 人」
|
||||||
|
* - 扇区是 SVG 画的。原来是 CSS clip-path 加 `color-mix()`,后者 Chrome 111 才有,
|
||||||
|
* 机房 105 那档整条声明作废、扇区没有底色
|
||||||
*
|
*
|
||||||
* `guard`:AC 之后那个强制弹窗用。弹窗是自己冒出来的,学生手还在键盘上、鼠标还在
|
* `guard`:AC 之后那个强制弹窗用。弹窗是自己冒出来的,学生手还在键盘上、鼠标还停在
|
||||||
* 原来的位置,所以前 600ms 点不了,也不响应 Enter / 空格(键盘触发的 click 的
|
* 原来的位置(很可能正好压在某一块上),所以前 600ms 点不了,也不响应 Enter / 空格;
|
||||||
* detail 是 0)。抽屉里的是学生自己点开的,不设防。
|
* 扇区不进 Tab 顺序,不会被预选。抽屉里的是学生自己点开的,不设防、键盘能用。
|
||||||
*/
|
*/
|
||||||
const props = defineProps<{ guard?: boolean }>()
|
const props = defineProps<{ guard?: boolean }>()
|
||||||
const emit = defineEmits<{ submitted: [] }>()
|
const emit = defineEmits<{ submitted: [] }>()
|
||||||
|
|
||||||
|
const GUARD_MS = 600
|
||||||
|
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
const { problem } = storeToRefs(useProblemStore())
|
const { problem } = storeToRefs(useProblemStore())
|
||||||
const theme = useThemeVars()
|
const theme = useThemeVars()
|
||||||
|
|
||||||
const GUARD_MS = 600
|
|
||||||
|
|
||||||
const mine = ref<ReactionKey | null>(null)
|
const mine = ref<ReactionKey | null>(null)
|
||||||
const counts = ref<ReactionCounts | null>(null)
|
const counts = ref<ReactionCounts | null>(null)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
@@ -36,11 +39,62 @@ const loadFailed = ref(false)
|
|||||||
const submitting = ref<ReactionKey | null>(null)
|
const submitting = ref<ReactionKey | null>(null)
|
||||||
const submitFailed = ref(false)
|
const submitFailed = ref(false)
|
||||||
const ready = ref(!props.guard)
|
const ready = ref(!props.guard)
|
||||||
|
const activeIndex = ref<number | null>(null)
|
||||||
let loadSequence = 0
|
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 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 (!userStore.isAuthed) return "anonymous"
|
||||||
if (loading.value) return "loading"
|
if (loading.value) return "loading"
|
||||||
if (loadFailed.value) return "failed"
|
if (loadFailed.value) return "failed"
|
||||||
@@ -49,7 +103,10 @@ const state = computed(() => {
|
|||||||
return "locked"
|
return "locked"
|
||||||
})
|
})
|
||||||
|
|
||||||
// 选项出现的那一刻起算:换题、登录之后重新出现也重新算
|
const choosable = computed(() => state.value === "choose")
|
||||||
|
const canPick = computed(() => choosable.value && ready.value && !submitting.value)
|
||||||
|
|
||||||
|
// 从能选的那一刻起算:换题、登录之后重新能选,也重新算
|
||||||
const { start: armGuard, stop: disarmGuard } = useTimeoutFn(
|
const { start: armGuard, stop: disarmGuard } = useTimeoutFn(
|
||||||
() => {
|
() => {
|
||||||
ready.value = true
|
ready.value = true
|
||||||
@@ -58,33 +115,99 @@ const { start: armGuard, stop: disarmGuard } = useTimeoutFn(
|
|||||||
{ immediate: false },
|
{ immediate: false },
|
||||||
)
|
)
|
||||||
watch(
|
watch(
|
||||||
() => state.value === "choose",
|
choosable,
|
||||||
(choosing) => {
|
(value) => {
|
||||||
if (!choosing || !props.guard) return
|
if (!value || !props.guard) return
|
||||||
ready.value = false
|
ready.value = false
|
||||||
|
activeIndex.value = null
|
||||||
disarmGuard()
|
disarmGuard()
|
||||||
armGuard()
|
armGuard()
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ 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
|
if (props.guard && event.detail === 0) return
|
||||||
pick(key)
|
pick(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onSectorKey(key: ReactionKey) {
|
||||||
|
if (!props.guard) pick(key)
|
||||||
|
}
|
||||||
|
|
||||||
async function pick(key: ReactionKey) {
|
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
|
submitting.value = key
|
||||||
submitFailed.value = false
|
submitFailed.value = false
|
||||||
try {
|
try {
|
||||||
const res = await setReaction(problem.value.id, key)
|
const res = await setReaction(current.id, key)
|
||||||
mine.value = res.mine
|
mine.value = res.mine
|
||||||
counts.value = res.counts
|
counts.value = res.counts
|
||||||
|
activeIndex.value = null
|
||||||
emit("submitted")
|
emit("submitted")
|
||||||
} catch {
|
} catch {
|
||||||
// 留在这里让他再点一次:弹窗关不掉,没有别的出路
|
// 留在这里让他再点一次:强制弹窗关不掉,没有别的出路
|
||||||
submitFailed.value = true
|
submitFailed.value = true
|
||||||
} finally {
|
} finally {
|
||||||
submitting.value = null
|
submitting.value = null
|
||||||
@@ -114,6 +237,7 @@ watch(
|
|||||||
counts.value = null
|
counts.value = null
|
||||||
submitting.value = null
|
submitting.value = null
|
||||||
submitFailed.value = false
|
submitFailed.value = false
|
||||||
|
activeIndex.value = null
|
||||||
if (!isAuthed || problemId === undefined) {
|
if (!isAuthed || problemId === undefined) {
|
||||||
loadSequence += 1
|
loadSequence += 1
|
||||||
loading.value = false
|
loading.value = false
|
||||||
@@ -124,22 +248,12 @@ watch(
|
|||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
const results = computed(() => {
|
function sectorAriaLabel(index: number) {
|
||||||
const all = counts.value
|
const item = REACTIONS[index]!
|
||||||
if (!all) return null
|
if (!results.value) return item.label
|
||||||
const total = REACTIONS.reduce((sum, item) => sum + (all[item.key] ?? 0), 0)
|
const count = results.value.petals[index]?.count ?? 0
|
||||||
const max = Math.max(1, ...REACTIONS.map((item) => all[item.key] ?? 0))
|
return `${item.label},${count} 人${mine.value === item.key ? ",你的选择" : ""}`
|
||||||
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -147,67 +261,95 @@ const results = computed(() => {
|
|||||||
class="reaction"
|
class="reaction"
|
||||||
:style="{
|
:style="{
|
||||||
'--accent': theme.primaryColor,
|
'--accent': theme.primaryColor,
|
||||||
|
// 抽屉和弹窗的底色是 modalColor(暗色下比 cardColor 浅一档),缝和圆心要和它一样
|
||||||
|
'--card': theme.modalColor,
|
||||||
'--border': theme.borderColor,
|
'--border': theme.borderColor,
|
||||||
'--card': theme.cardColor,
|
'--text': theme.textColor1,
|
||||||
'--hover': theme.hoverColor,
|
'--faint': theme.textColor3,
|
||||||
}"
|
}"
|
||||||
aria-label="题目点评"
|
aria-label="题目点评"
|
||||||
>
|
>
|
||||||
<n-empty v-if="state === 'anonymous'" description="登录之后才能点评" />
|
<p v-if="choosable" class="hint">
|
||||||
|
选一块最接近的,<b>每道题只能评一次</b>。选完就能看到大家是怎么选的
|
||||||
<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)"
|
|
||||||
>
|
|
||||||
<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>
|
|
||||||
<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>
|
</p>
|
||||||
<ul class="bars">
|
|
||||||
<li
|
<div
|
||||||
v-for="row in results.rows"
|
class="wheel"
|
||||||
:key="row.key"
|
:class="[`is-${state}`, { 'is-waiting': choosable && !ready }]"
|
||||||
class="bar-row"
|
@pointerleave="preview(null)"
|
||||||
:class="{ mine: row.key === mine }"
|
|
||||||
>
|
>
|
||||||
<span class="bar-icon" aria-hidden="true"><Icon :icon="row.icon" /></span>
|
<svg viewBox="0 0 400 400" class="wheel-svg">
|
||||||
<span class="bar-label">{{ row.label }}</span>
|
<g
|
||||||
<span class="bar-track"><span class="bar-fill" :style="{ width: row.width }" /></span>
|
v-for="sector in sectors"
|
||||||
<span class="bar-count">{{ row.count }} 人</span>
|
:key="sector.key"
|
||||||
</li>
|
class="sector"
|
||||||
</ul>
|
: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)"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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" />
|
<slot name="after" />
|
||||||
</template>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -215,138 +357,203 @@ const results = computed(() => {
|
|||||||
.reaction {
|
.reaction {
|
||||||
max-width: 560px;
|
max-width: 560px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
color: var(--text);
|
||||||
|
|
||||||
.pad {
|
|
||||||
padding: 32px 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.hint {
|
.hint {
|
||||||
margin: 0 0 14px;
|
margin: 0 0 12px;
|
||||||
|
text-align: center;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
opacity: 0.8;
|
color: var(--faint);
|
||||||
}
|
}
|
||||||
|
|
||||||
.options {
|
.hint b {
|
||||||
display: flex;
|
color: var(--text);
|
||||||
flex-wrap: wrap;
|
}
|
||||||
justify-content: center;
|
|
||||||
gap: 10px;
|
.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;
|
transition: opacity 0.25s;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 防误触那 600ms:看得出是「还没好」,而不是坏了 */
|
/* 防误触那 600ms:看得出是「还没好」,而不是坏了 */
|
||||||
.options.waiting {
|
.wheel.is-waiting {
|
||||||
opacity: 0.45;
|
opacity: 0.45;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.option {
|
.wheel-svg {
|
||||||
/* 一行 4 个,7 个排成 4 + 3,第二行居中 */
|
display: block;
|
||||||
flex: 0 0 calc((100% - 30px) / 4);
|
width: 100%;
|
||||||
min-width: 0;
|
height: 100%;
|
||||||
height: 92px;
|
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;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
gap: 1px;
|
||||||
gap: 6px;
|
transform: translate(-50%, -50%);
|
||||||
border: 1px solid var(--border);
|
pointer-events: none;
|
||||||
border-radius: 10px;
|
white-space: nowrap;
|
||||||
background-color: var(--card);
|
transition: transform 0.16s cubic-bezier(0, 0, 0.2, 1);
|
||||||
color: inherit;
|
|
||||||
font: inherit;
|
|
||||||
cursor: pointer;
|
|
||||||
transition:
|
|
||||||
border-color 0.15s,
|
|
||||||
background-color 0.15s,
|
|
||||||
transform 0.15s;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.option:hover:not(:disabled) {
|
.is-choose .label.active,
|
||||||
border-color: var(--accent);
|
.label.picking,
|
||||||
background-color: var(--hover);
|
.label.mine {
|
||||||
transform: translateY(-2px);
|
transform: translate(calc(-50% + var(--pop-x)), calc(-50% + var(--pop-y))) scale(1.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
.option:disabled {
|
.label-icon {
|
||||||
cursor: default;
|
|
||||||
opacity: 0.55;
|
|
||||||
}
|
|
||||||
|
|
||||||
.option.picking {
|
|
||||||
opacity: 1;
|
|
||||||
border-color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.option-icon {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
font-size: 34px;
|
font-size: clamp(22px, 8cqi, 32px);
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.option-label {
|
.label-text {
|
||||||
font-size: 15px;
|
font-size: clamp(12px, 4cqi, 16px);
|
||||||
font-weight: 600;
|
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 {
|
.error {
|
||||||
display: block;
|
margin: 10px 0 0;
|
||||||
margin-top: 10px;
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
color: v-bind("theme.errorColor");
|
||||||
}
|
}
|
||||||
|
|
||||||
.bars {
|
.after {
|
||||||
list-style: none;
|
margin-top: 16px;
|
||||||
margin: 0 0 16px;
|
|
||||||
padding: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.bar-row {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
display: grid;
|
.sector,
|
||||||
grid-template-columns: 24px 4.5em 1fr 3.5em;
|
.label {
|
||||||
align-items: center;
|
transition: none;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -161,7 +161,7 @@ B 看得到。
|
|||||||
「放回编辑器」「复制」;加入题单之前被锁住的提交显示为一行说明,不能展开;底部「看这道题所有人的提交」。
|
「放回编辑器」「复制」;加入题单之前被锁住的提交显示为一行说明,不能展开;底部「看这道题所有人的提交」。
|
||||||
「放回编辑器」后提示「已放回,按 Ctrl+Z 撤回」—— 要确认编辑器的撤销能撤回这次替换。
|
「放回编辑器」后提示「已放回,按 Ctrl+Z 撤回」—— 要确认编辑器的撤销能撤回这次替换。
|
||||||
- **统计**:出题人、创建时间、标签、数字卡、结果饼图、历年通过率,内容原样。
|
- **统计**:出题人、创建时间、标签、数字卡、结果饼图、历年通过率,内容原样。
|
||||||
- **点评**:和强制弹窗同一个组件(第 7 节),每人只能评一次。
|
- **点评**:和强制弹窗同一个轮盘(第 7 节),每人只能评一次。
|
||||||
- 题单入口三个按钮都不出现。比赛入口没有「点评」。
|
- 题单入口三个按钮都不出现。比赛入口没有「点评」。
|
||||||
|
|
||||||
### 5.6 手机
|
### 5.6 手机
|
||||||
@@ -197,13 +197,16 @@ B 看得到。
|
|||||||
## 7. 强制点评
|
## 7. 强制点评
|
||||||
|
|
||||||
- 文案:「做对了!说说你对这道题的感受吧」(原来的「恭喜你成功提交」不准确,交上去不等于做对)。
|
- 文案:「做对了!说说你对这道题的感受吧」(原来的「恭喜你成功提交」不准确,交上去不等于做对)。
|
||||||
- **轮盘换成了一排大按钮**(2026-09-28 用户同意重新设计):七个选项一行 4 个、排成 4 + 3,
|
- **轮盘重新设计过**(2026-09-28,用户要求保留轮盘的形式):SVG 画的 7 块环形扇区,
|
||||||
每个带表情和 15px 的字。原来的轮盘要把鼠标移到扇区上才看得清是哪一项,每块的字只有 12px
|
每块上**直接写着表情和名字**(字号随轮盘缩放,最大 16px),不用先悬停才看得清;悬停的那块
|
||||||
左右,而且用了 Chrome 111 才有的 `color-mix()`,机房 105 那档渲染不出扇区底色。
|
染上主色、往外弹出一点,圆心报它的名字。原来的轮盘每块的字只有 12px 左右,而且用了
|
||||||
- 选之前**不显示人数**(后端本来就要自己评过才返回 counts),选完变成横条图:顺序和选项一致、
|
Chrome 111 才有的 `color-mix()`,机房 105 那档渲染不出扇区底色。
|
||||||
标出自己选的那一项 —— 避免跟风。没做对的人在抽屉里只看到一句说明,也没有人数。
|
- 选之前**不显示人数**(后端本来就要自己评过才返回 counts),选完就地变成**玫瑰图**:每块从里
|
||||||
- 防误触:弹出后 600ms 内选项半透明、点不了;强制弹窗里不响应 Enter / 空格(键盘触发的
|
往外填一段、长短按人数,标「N 人」,自己选的那块用主色、弹出、带 ✓ —— 避免跟风。
|
||||||
click 的 `detail` 是 0);弹窗 `auto-focus` 关掉,不预选任何一项。
|
没登录、没做对时轮盘照画、整个淡下去,圆心写原因,不给人数。
|
||||||
|
- 防误触:弹出后 600ms 内轮盘半透明、点不了;强制弹窗里不响应 Enter / 空格(键盘触发的
|
||||||
|
click 的 `detail` 是 0),扇区不进 Tab 顺序;弹窗 `auto-focus` 关掉,不预选任何一块。
|
||||||
|
抽屉里的是学生自己点开的,不设防、键盘能选。
|
||||||
- 选完不马上关:弹窗里换成横条图,下面只有一个按钮、焦点落在它上面 —— 被拦下的换题写「继续」,
|
- 选完不马上关:弹窗里换成横条图,下面只有一个按钮、焦点落在它上面 —— 被拦下的换题写「继续」,
|
||||||
否则是这节课的「下一题:题号 题目」,都没有就是「好的」。
|
否则是这节课的「下一题:题号 题目」,都没有就是「好的」。
|
||||||
- **窗口期内换题要先点评**:通过后 1.5 秒内点「下一题」、课堂条芯片、‹ ›、顶栏题号直达,
|
- **窗口期内换题要先点评**:通过后 1.5 秒内点「下一题」、课堂条芯片、‹ ›、顶栏题号直达,
|
||||||
|
|||||||
Reference in new issue
Block a user