From 5bb69ecce3ec6c7e9883a3fc1c8b2df94450ad62 Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Mon, 28 Sep 2026 17:19:37 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E7=82=B9=E8=AF=84):=20=E7=82=B9=E8=AF=84?= =?UTF-8?q?=E6=94=B9=E5=9B=9E=E8=BD=AE=E7=9B=98=E5=B9=B6=E9=87=8D=E6=96=B0?= =?UTF-8?q?=E8=AE=BE=E8=AE=A1=E2=80=94=E2=80=94=E6=AF=8F=E5=9D=97=E7=9B=B4?= =?UTF-8?q?=E6=8E=A5=E5=86=99=E7=9D=80=E5=90=8D=E5=AD=97=EF=BC=8C=E9=80=89?= =?UTF-8?q?=E5=AE=8C=E5=B0=B1=E5=9C=B0=E5=8F=98=E6=88=90=E7=8E=AB=E7=91=B0?= =?UTF-8?q?=E5=9B=BE=E7=9C=8B=E5=A4=A7=E5=AE=B6=E6=80=8E=E4=B9=88=E9=80=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上一版把轮盘换成了按钮格子,改回轮盘,但重新画了一个: - SVG 画的 7 块环形扇区,每块上直接写着表情和名字(最大 16px),不用先把鼠标移过去; 悬停的那块染上主色、往外弹出,圆心报它的名字 - 选之前哪里都没有人数;选完就地变成玫瑰图:每块从里往外填一段、长短按人数,标「N 人」, 自己选的那块是主色、弹出、带 ✓,圆心写「你选了 学到了 · 共 N 人评过」 - 没登录、没做对时轮盘照画、整个淡下去,圆心写原因 - 原来的轮盘用 CSS clip-path 加 color-mix(),后者 Chrome 111 才有,机房 105 那档扇区没有底色 - 上一版的防误触(600ms、强制弹窗里不响应 Enter / 空格、不预选)和选完的「下一题」都保留; 抽屉里能用键盘选 Co-Authored-By: Claude Opus 5.5 --- .../oj/problem/components/ProblemReaction.vue | 581 ++++++++++++------ ...2026-09-28-problem-page-redesign-design.md | 19 +- 2 files changed, 405 insertions(+), 195 deletions(-) diff --git a/apps/web/src/oj/problem/components/ProblemReaction.vue b/apps/web/src/oj/problem/components/ProblemReaction.vue index e375c41..c447f81 100644 --- a/apps/web/src/oj/problem/components/ProblemReaction.vue +++ b/apps/web/src/oj/problem/components/ProblemReaction.vue @@ -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(null) const counts = ref(null) const loading = ref(false) @@ -36,11 +39,62 @@ const loadFailed = ref(false) const submitting = ref(null) const submitFailed = ref(false) const ready = ref(!props.guard) +const activeIndex = ref(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(() => { 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 ? ",你的选择" : ""}` +} @@ -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; + } } diff --git a/docs/specs/2026-09-28-problem-page-redesign-design.md b/docs/specs/2026-09-28-problem-page-redesign-design.md index d7c12a9..baf4d08 100644 --- a/docs/specs/2026-09-28-problem-page-redesign-design.md +++ b/docs/specs/2026-09-28-problem-page-redesign-design.md @@ -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 秒内点「下一题」、课堂条芯片、‹ ›、顶栏题号直达,