From c64b0a402b2bfb7a013d0860446e56229dff223f Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Mon, 28 Sep 2026 16:22:48 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E7=82=B9=E8=AF=84):=20=E7=82=B9=E8=AF=84?= =?UTF-8?q?=E8=BD=AE=E7=9B=98=E6=8D=A2=E6=88=90=E4=B8=80=E6=8E=92=E5=A4=A7?= =?UTF-8?q?=E6=8C=89=E9=92=AE=EF=BC=8C=E9=80=89=E4=B9=8B=E5=89=8D=E4=B8=8D?= =?UTF-8?q?=E6=98=BE=E7=A4=BA=E4=BA=BA=E6=95=B0=EF=BC=8C=E9=80=89=E5=AE=8C?= =?UTF-8?q?=E7=9C=8B=E5=A4=A7=E5=AE=B6=E6=80=8E=E4=B9=88=E9=80=89=E3=80=81?= =?UTF-8?q?=E7=9B=B4=E6=8E=A5=E5=8E=BB=E4=B8=8B=E4=B8=80=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 七个选项不变,排成 4 + 3 的大按钮(表情 + 15px 的字)。原来的轮盘要把鼠标移到扇区上 才看得清,每块的字只有 12px 左右;还用了 Chrome 111 才有的 color-mix(),机房 105 那档 渲染不出扇区底色 - 选之前不显示「N 人选择」,免得跟风;选完换成横条图,标出自己选的那一项 - 防误触:弹出后 600ms 内点不了,强制弹窗里不响应 Enter / 空格,也不预选任何一项 - 选完弹窗不马上关,下面只有一个按钮、焦点就在上面:这节课的「下一题」,或者刚才被拦下 的换题「继续」,都没有就是「好的」 - 标题从「恭喜你成功提交」改成「做对了!」:交上去不等于做对 - 提交失败留在弹窗里提示「没记上,再点一次」;没做对的人在抽屉里只看到一句说明 Co-Authored-By: Claude Opus 5.5 --- .../src/oj/problem/components/LessonNext.vue | 15 +- .../oj/problem/components/ProblemReaction.vue | 738 +++++------------- .../problem/components/SubmissionEffects.vue | 64 +- apps/web/src/oj/store/lesson.ts | 19 +- ...2026-09-28-problem-page-redesign-design.md | 18 +- 5 files changed, 304 insertions(+), 550 deletions(-) diff --git a/apps/web/src/oj/problem/components/LessonNext.vue b/apps/web/src/oj/problem/components/LessonNext.vue index c626aba..0b328e0 100644 --- a/apps/web/src/oj/problem/components/LessonNext.vue +++ b/apps/web/src/oj/problem/components/LessonNext.vue @@ -8,9 +8,7 @@ import { useLessonStore } from "oj/store/lesson" * 这节课的题就是首页「班里在做」那几道,和左栏顶上的课堂条读同一份(lesson store); * 通过的那一刻课堂条已经把这道标成做完、并且重拉了一次。 * - * 「下一道」是按列表顺序排在这道**后面**、还没通过的第一道,后面都做完了再从头找 —— - * 学生常常跳着做,先做完了第 3 道再回头做第 1 道。当前这道本身不看它的 myStatus: - * 重拉回来之前,它还是通过之前那一刻的状态。 + * 「下一道」怎么算见 lesson store 的 nextAfter,AC 后的点评弹窗里那个「下一题」也用它。 * * 这道题不在这节课的列表里(学生在做别的)就什么都不显示。 */ @@ -24,16 +22,7 @@ const lessonStore = useLessonStore() // 一般课堂条早就拉过了,这里只是兜底(旧了才拉) onMounted(() => lessonStore.load()) -const lesson = computed(() => { - const list = lessonStore.activity?.problems ?? [] - const current = props.problemDisplayId.toLowerCase() - const index = list.findIndex((item) => item.problemDisplayId.toLowerCase() === current) - if (index < 0) return null - const rest = [...list.slice(index + 1), ...list.slice(0, index)].filter( - (item) => item.myStatus !== "accepted", - ) - return { total: list.length, next: rest[0] ?? null, remaining: rest.length } -}) +const lesson = computed(() => lessonStore.nextAfter(props.problemDisplayId)) const label = computed(() => lessonStore.label) diff --git a/apps/web/src/oj/problem/components/ProblemReaction.vue b/apps/web/src/oj/problem/components/ProblemReaction.vue index 2f0a88a..e375c41 100644 --- a/apps/web/src/oj/problem/components/ProblemReaction.vue +++ b/apps/web/src/oj/problem/components/ProblemReaction.vue @@ -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(null) const counts = ref(null) const loading = ref(false) -// 正在提交的 key,用来锁住整组并只在当前选项显示进度。 +const loadFailed = ref(false) const submitting = ref(null) -const activeIndex = ref(null) -const keyboardActive = ref(false) -const wheelRef = ref(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}%`, + })), + } +}) diff --git a/apps/web/src/oj/problem/components/SubmissionEffects.vue b/apps/web/src/oj/problem/components/SubmissionEffects.vue index 1d2092a..20c9e28 100644 --- a/apps/web/src/oj/problem/components/SubmissionEffects.vue +++ b/apps/web/src/oj/problem/components/SubmissionEffects.vue @@ -5,7 +5,7 @@ import { getReaction } from "oj/api" import { useProblemStore } from "oj/store/problem" import { useSubmissionStore } from "oj/store/submission" import { useFireworks } from "oj/problem/composables/useFireworks" -import { useBreakpoints } from "shared/composables/breakpoints" +import { useLessonStore } from "oj/store/lesson" import { SubmissionStatus } from "utils/constants" import { useProblemPageContext } from "../composables/problemPageContext" @@ -28,10 +28,9 @@ const { submission } = storeToRefs(submissionStore) const router = useRouter() const ctx = useProblemPageContext() -const { isDesktop } = useBreakpoints() const { celebrate } = useFireworks() -// ==================== AC 后弹出点评轮盘 ==================== +// ==================== AC 后弹出点评 ==================== // 只对已经能评价、且还没评过的人弹:后端要求先有 AC 才收评价,这里刚 AC 完正好; // mine 非 null 说明早就评过了,别再打扰。 // @@ -50,6 +49,7 @@ async function openReview() { try { const res = await getReaction(problem.value!.id) if (res.mine === null) { + reviewed.value = false commentPanel.value = true return } @@ -76,6 +76,46 @@ function closeCommentPanel() { settleReview() } +/** + * 评完不马上关:弹窗里换成大家怎么选的,下面只有一个按钮,焦点落在它上面。 + * 按钮去哪:刚才点「下一题」之类被拦下的,就接着去那里;否则是这节课的下一题 + * (和结果页签里 LessonNext 同一个算法);都没有就是「好的」,关掉回到结果页签。 + */ +const reviewed = ref(false) +const continueButton = useTemplateRef<{ $el: HTMLElement }>("continueButton") +const lessonStore = useLessonStore() + +// navigationAfterReview 是普通变量(守卫里改,不需要触发渲染),按钮文案要一个响应式的影子 +const navigationAfterReviewShown = ref(false) + +/** 题目名中位 7 字、最长 33 字,按钮里放不下那么长的 */ +const shortTitle = (title: string) => (title.length > 14 ? `${title.slice(0, 13)}…` : title) + +const continueAction = computed(() => { + if (navigationAfterReviewShown.value) return { label: "继续", target: null } + const lesson = problem.value ? lessonStore.nextAfter(problem.value._id) : null + const next = ctx.value.lessonNext ? lesson?.next : null + if (next) + return { + label: `下一题:${next.problemDisplayId} ${shortTitle(next.title)}`, + target: `/problem/${next.problemDisplayId}`, + } + return { label: "好的", target: null } +}) + +async function onReviewed() { + reviewed.value = true + navigationAfterReviewShown.value = navigationAfterReview !== null + await nextTick() + continueButton.value?.$el.focus() +} + +function continueAfterReview() { + const target = continueAction.value.target + if (target && !navigationAfterReview) navigationAfterReview = target + closeCommentPanel() +} + const { start: showCommentPanelDelayed, stop: cancelCommentPanel } = useTimeoutFn( openReview, 1500, @@ -188,15 +228,25 @@ watch( diff --git a/apps/web/src/oj/store/lesson.ts b/apps/web/src/oj/store/lesson.ts index 0b2ea45..979bbf6 100644 --- a/apps/web/src/oj/store/lesson.ts +++ b/apps/web/src/oj/store/lesson.ts @@ -49,10 +49,27 @@ export const useLessonStore = defineStore("lesson", () => { return !!find(problemDisplayId) } + /** + * 通过这道之后该去哪一道:按列表顺序排在这道**后面**、还没通过的第一道,后面都做完了 + * 再从头找 —— 学生常常跳着做,先做完了第 3 道再回头做第 1 道。当前这道本身不看它的 + * myStatus:重拉回来之前,它还是通过之前那一刻的状态。 + * 这道不在这节课里返回 null;在,但别的都做完了,next 为 null。 + */ + function nextAfter(problemDisplayId: string) { + const list = activity.value?.problems ?? [] + const id = problemDisplayId.toLowerCase() + const index = list.findIndex((item) => item.problemDisplayId.toLowerCase() === id) + if (index < 0) return null + const rest = [...list.slice(index + 1), ...list.slice(0, index)].filter( + (item) => item.myStatus !== "accepted", + ) + return { total: list.length, next: rest[0] ?? null, remaining: rest.length } + } + /** 老师布置的叫「老师布置的题」,推断出来的叫「这节课的题」 */ const label = computed(() => activity.value?.source === "teacher" ? "老师布置的题" : "这节课的题", ) - return { activity, label, load, markAccepted, includes } + return { activity, label, load, markAccepted, includes, nextAfter } }) 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 a8aa3d9..d7c12a9 100644 --- a/docs/specs/2026-09-28-problem-page-redesign-design.md +++ b/docs/specs/2026-09-28-problem-page-redesign-design.md @@ -1,7 +1,7 @@ # OJ2 设计文档:题目详情页重设计 日期:2026-09-28 -状态:**设计已定,第 14 节的 1–4 步已实施**(还没做:5.5 的「我的提交」行内展开与「放回编辑器」、5.2 的例子表格、第 7 节的点评文案与防误触)。效果图在 claude.ai 的私有画布「题目详情页重设计」里(「机房(方案 B)」 +状态:**设计已定,第 14 节的 1–4 步和第 7 节已实施**(还没做:5.5 的「我的提交」行内展开与「放回编辑器」、5.2 的例子表格)。效果图在 claude.ai 的私有画布「题目详情页重设计」里(「机房(方案 B)」 那一页是定稿,「初稿 1280×800」那一页已作废,只留着对照)。效果图表达不了的规则都写在这里, **实施时以本文为准**。 @@ -53,7 +53,7 @@ ## 3. 已拍板的决定 -1. **AC 后点评保持强制弹窗**,七个选项不变(「题目有错」留在轮盘里)。 +1. **AC 后点评保持强制弹窗**,七个选项不变(「题目有错」和其他六项放在一起,不单独挪走)。 2. **题单入口隐藏**「我的提交」「本题提交」「统计」「点评」和标题下的「已解决 / 尝试过」标签 —— 不让学生看到加入题单之前的提交、抄到答案。 3. **「运行例子」先继续用 Judge0**,不另做判题机端点。 @@ -161,7 +161,7 @@ B 看得到。 「放回编辑器」「复制」;加入题单之前被锁住的提交显示为一行说明,不能展开;底部「看这道题所有人的提交」。 「放回编辑器」后提示「已放回,按 Ctrl+Z 撤回」—— 要确认编辑器的撤销能撤回这次替换。 - **统计**:出题人、创建时间、标签、数字卡、结果饼图、历年通过率,内容原样。 -- **点评**:轮盘原样,每人只能评一次。 +- **点评**:和强制弹窗同一个组件(第 7 节),每人只能评一次。 - 题单入口三个按钮都不出现。比赛入口没有「点评」。 ### 5.6 手机 @@ -197,9 +197,15 @@ B 看得到。 ## 7. 强制点评 - 文案:「做对了!说说你对这道题的感受吧」(原来的「恭喜你成功提交」不准确,交上去不等于做对)。 -- 选之前**不显示人数**(圆心只显示悬停的选项名),选完才显示大家怎么选的 —— 避免跟风。 -- 轮盘 360px(原来 300,每块的字只有 12px 左右)。 -- 防误触:弹出后 600ms 内不接受点击;不响应 Enter / 空格;不预选任何一块;选完焦点落到「下一题」。 +- **轮盘换成了一排大按钮**(2026-09-28 用户同意重新设计):七个选项一行 4 个、排成 4 + 3, + 每个带表情和 15px 的字。原来的轮盘要把鼠标移到扇区上才看得清是哪一项,每块的字只有 12px + 左右,而且用了 Chrome 111 才有的 `color-mix()`,机房 105 那档渲染不出扇区底色。 +- 选之前**不显示人数**(后端本来就要自己评过才返回 counts),选完变成横条图:顺序和选项一致、 + 标出自己选的那一项 —— 避免跟风。没做对的人在抽屉里只看到一句说明,也没有人数。 +- 防误触:弹出后 600ms 内选项半透明、点不了;强制弹窗里不响应 Enter / 空格(键盘触发的 + click 的 `detail` 是 0);弹窗 `auto-focus` 关掉,不预选任何一项。 +- 选完不马上关:弹窗里换成横条图,下面只有一个按钮、焦点落在它上面 —— 被拦下的换题写「继续」, + 否则是这节课的「下一题:题号 题目」,都没有就是「好的」。 - **窗口期内换题要先点评**:通过后 1.5 秒内点「下一题」、课堂条芯片、‹ ›、顶栏题号直达, 先立刻弹出点评,选完再跳。**现在线上可以被跳过**(`SubmitCode.vue` 换题时 `cancelCommentPanel()`)。 - 查询或提交点评的接口出错时不能把学生卡住:查询失败就不弹;提交失败留在弹窗里提示「没记上,再点一次」。