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
+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 秒内点「下一题」、课堂条芯片、‹ ›、顶栏题号直达,
|
||||
|
||||
Reference in new issue
Block a user