feat(点评): 点评轮盘换成一排大按钮,选之前不显示人数,选完看大家怎么选、直接去下一题
- 七个选项不变,排成 4 + 3 的大按钮(表情 + 15px 的字)。原来的轮盘要把鼠标移到扇区上 才看得清,每块的字只有 12px 左右;还用了 Chrome 111 才有的 color-mix(),机房 105 那档 渲染不出扇区底色 - 选之前不显示「N 人选择」,免得跟风;选完换成横条图,标出自己选的那一项 - 防误触:弹出后 600ms 内点不了,强制弹窗里不响应 Enter / 空格,也不预选任何一项 - 选完弹窗不马上关,下面只有一个按钮、焦点就在上面:这节课的「下一题」,或者刚才被拦下 的换题「继续」,都没有就是「好的」 - 标题从「恭喜你成功提交」改成「做对了!」:交上去不等于做对 - 提交失败留在弹窗里提示「没记上,再点一次」;没做对的人在抽屉里只看到一句说明 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
116b0a2c95
commit
c64b0a402b
5 files changed
+299
-545
No files matched your search
@@ -8,9 +8,7 @@ import { useLessonStore } from "oj/store/lesson"
|
|||||||
* 这节课的题就是首页「班里在做」那几道,和左栏顶上的课堂条读同一份(lesson store);
|
* 这节课的题就是首页「班里在做」那几道,和左栏顶上的课堂条读同一份(lesson store);
|
||||||
* 通过的那一刻课堂条已经把这道标成做完、并且重拉了一次。
|
* 通过的那一刻课堂条已经把这道标成做完、并且重拉了一次。
|
||||||
*
|
*
|
||||||
* 「下一道」是按列表顺序排在这道**后面**、还没通过的第一道,后面都做完了再从头找 ——
|
* 「下一道」怎么算见 lesson store 的 nextAfter,AC 后的点评弹窗里那个「下一题」也用它。
|
||||||
* 学生常常跳着做,先做完了第 3 道再回头做第 1 道。当前这道本身不看它的 myStatus:
|
|
||||||
* 重拉回来之前,它还是通过之前那一刻的状态。
|
|
||||||
*
|
*
|
||||||
* 这道题不在这节课的列表里(学生在做别的)就什么都不显示。
|
* 这道题不在这节课的列表里(学生在做别的)就什么都不显示。
|
||||||
*/
|
*/
|
||||||
@@ -24,16 +22,7 @@ const lessonStore = useLessonStore()
|
|||||||
// 一般课堂条早就拉过了,这里只是兜底(旧了才拉)
|
// 一般课堂条早就拉过了,这里只是兜底(旧了才拉)
|
||||||
onMounted(() => lessonStore.load())
|
onMounted(() => lessonStore.load())
|
||||||
|
|
||||||
const lesson = computed(() => {
|
const lesson = computed(() => lessonStore.nextAfter(props.problemDisplayId))
|
||||||
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 label = computed(() => lessonStore.label)
|
const label = computed(() => lessonStore.label)
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -2,236 +2,90 @@
|
|||||||
import { Icon } from "@iconify/vue"
|
import { Icon } from "@iconify/vue"
|
||||||
import { useThemeVars } from "naive-ui"
|
import { useThemeVars } from "naive-ui"
|
||||||
import { storeToRefs } from "pinia"
|
import { storeToRefs } from "pinia"
|
||||||
import type { CSSProperties } from "vue"
|
|
||||||
import { getReaction, setReaction } from "oj/api"
|
import { getReaction, setReaction } from "oj/api"
|
||||||
import { useProblemStore } from "oj/store/problem"
|
import { useProblemStore } from "oj/store/problem"
|
||||||
import { useUserStore } from "shared/store/user"
|
import { useUserStore } from "shared/store/user"
|
||||||
import { REACTIONS } from "utils/constants"
|
import { REACTIONS } from "utils/constants"
|
||||||
import type { ReactionCounts, ReactionKey } from "utils/types"
|
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 emit = defineEmits<{ submitted: [] }>()
|
||||||
|
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
const problemStore = useProblemStore()
|
const { problem } = storeToRefs(useProblemStore())
|
||||||
const { problem } = storeToRefs(problemStore)
|
|
||||||
const message = useMessage()
|
|
||||||
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)
|
||||||
// 正在提交的 key,用来锁住整组并只在当前选项显示进度。
|
const loadFailed = ref(false)
|
||||||
const submitting = ref<ReactionKey | null>(null)
|
const submitting = ref<ReactionKey | null>(null)
|
||||||
const activeIndex = ref<number | null>(null)
|
const submitFailed = ref(false)
|
||||||
const keyboardActive = ref(false)
|
const ready = ref(!props.guard)
|
||||||
const wheelRef = ref<HTMLElement | null>(null)
|
|
||||||
let loadSequence = 0
|
let loadSequence = 0
|
||||||
|
|
||||||
const wheelGeometry = {
|
const solved = computed(() => problem.value?.myStatus === 0)
|
||||||
startAngle: -90,
|
|
||||||
contentRadius: 35,
|
|
||||||
pushRadius: 4,
|
|
||||||
outerRadius: 50,
|
|
||||||
arcPointCount: 9,
|
|
||||||
hitInnerRadius: 0.18,
|
|
||||||
hitOuterRadius: 0.49,
|
|
||||||
} as const
|
|
||||||
|
|
||||||
const sliceAngle = 360 / REACTIONS.length
|
const state = computed(() => {
|
||||||
|
if (!userStore.isAuthed) return "anonymous"
|
||||||
function pointOnCircle(angle: number, radius: number) {
|
if (loading.value) return "loading"
|
||||||
const radians = (angle * Math.PI) / 180
|
if (loadFailed.value) return "failed"
|
||||||
return {
|
if (mine.value !== null && counts.value) return "results"
|
||||||
x: 50 + Math.cos(radians) * radius,
|
if (solved.value) return "choose"
|
||||||
y: 50 + Math.sin(radians) * radius,
|
return "locked"
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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(", ")})`,
|
const { start: armGuard, stop: disarmGuard } = useTimeoutFn(
|
||||||
"--content-x": `${position.x}%`,
|
() => {
|
||||||
"--content-y": `${position.y}%`,
|
ready.value = true
|
||||||
"--push-x": `${push.x - 50}px`,
|
},
|
||||||
"--push-y": `${push.y - 50}px`,
|
GUARD_MS,
|
||||||
"--divider-angle": `${dividerAngle}deg`,
|
{ immediate: false },
|
||||||
}
|
)
|
||||||
}
|
watch(
|
||||||
|
() => state.value === "choose",
|
||||||
const wheelItems = REACTIONS.map((item, index) => ({
|
(choosing) => {
|
||||||
...item,
|
if (!choosing || !props.guard) return
|
||||||
index,
|
ready.value = false
|
||||||
style: getWheelItemStyle(index),
|
disarmGuard()
|
||||||
}))
|
armGuard()
|
||||||
|
},
|
||||||
const solved = computed(() => problem.value?.myStatus === 0)
|
{ immediate: true },
|
||||||
const locked = computed(() => mine.value !== null)
|
|
||||||
const canInteract = computed(
|
|
||||||
() =>
|
|
||||||
userStore.isAuthed &&
|
|
||||||
!!problem.value &&
|
|
||||||
solved.value &&
|
|
||||||
!locked.value &&
|
|
||||||
!loading.value &&
|
|
||||||
!submitting.value,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const wheelCenter = computed(() => {
|
function onOptionClick(key: ReactionKey, event: MouseEvent) {
|
||||||
if (loading.value) {
|
if (!ready.value) return
|
||||||
return {
|
if (props.guard && event.detail === 0) return
|
||||||
icon: "ph:spinner-gap-bold",
|
pick(key)
|
||||||
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 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,
|
|
||||||
}))
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pick(key: ReactionKey) {
|
async function pick(key: ReactionKey) {
|
||||||
if (!problem.value || !solved.value || locked.value || loading.value || submitting.value) return
|
if (!problem.value || !solved.value || mine.value !== null || submitting.value) return
|
||||||
|
|
||||||
activeIndex.value = null
|
|
||||||
keyboardActive.value = false
|
|
||||||
submitting.value = key
|
submitting.value = key
|
||||||
|
submitFailed.value = false
|
||||||
try {
|
try {
|
||||||
const res = await setReaction(problem.value.id, key)
|
const res = await setReaction(problem.value.id, key)
|
||||||
mine.value = res.mine
|
mine.value = res.mine
|
||||||
counts.value = res.counts
|
counts.value = res.counts
|
||||||
emit("submitted")
|
emit("submitted")
|
||||||
} catch {
|
} catch {
|
||||||
message.error("提交失败,请重试")
|
// 留在这里让他再点一次:弹窗关不掉,没有别的出路
|
||||||
|
submitFailed.value = true
|
||||||
} finally {
|
} finally {
|
||||||
submitting.value = null
|
submitting.value = null
|
||||||
}
|
}
|
||||||
@@ -240,13 +94,14 @@ async function pick(key: ReactionKey) {
|
|||||||
async function load(problemId: number) {
|
async function load(problemId: number) {
|
||||||
const sequence = ++loadSequence
|
const sequence = ++loadSequence
|
||||||
loading.value = true
|
loading.value = true
|
||||||
|
loadFailed.value = false
|
||||||
try {
|
try {
|
||||||
const res = await getReaction(problemId)
|
const res = await getReaction(problemId)
|
||||||
if (sequence !== loadSequence) return
|
if (sequence !== loadSequence) return
|
||||||
mine.value = res.mine
|
mine.value = res.mine
|
||||||
counts.value = res.counts
|
counts.value = res.counts
|
||||||
} catch {
|
} catch {
|
||||||
if (sequence === loadSequence) message.error("暂时无法读取题目点评")
|
if (sequence === loadSequence) loadFailed.value = true
|
||||||
} finally {
|
} finally {
|
||||||
if (sequence === loadSequence) loading.value = false
|
if (sequence === loadSequence) loading.value = false
|
||||||
}
|
}
|
||||||
@@ -258,9 +113,7 @@ watch(
|
|||||||
mine.value = null
|
mine.value = null
|
||||||
counts.value = null
|
counts.value = null
|
||||||
submitting.value = null
|
submitting.value = null
|
||||||
activeIndex.value = null
|
submitFailed.value = false
|
||||||
keyboardActive.value = false
|
|
||||||
|
|
||||||
if (!isAuthed || problemId === undefined) {
|
if (!isAuthed || problemId === undefined) {
|
||||||
loadSequence += 1
|
loadSequence += 1
|
||||||
loading.value = false
|
loading.value = false
|
||||||
@@ -270,391 +123,230 @@ watch(
|
|||||||
},
|
},
|
||||||
{ immediate: true },
|
{ 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}%`,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="reaction-panel" :style="reactionStyle" aria-label="题目点评">
|
<section
|
||||||
<div class="wheel-stage">
|
class="reaction"
|
||||||
<div
|
:style="{
|
||||||
ref="wheelRef"
|
'--accent': theme.primaryColor,
|
||||||
class="reaction-wheel"
|
'--border': theme.borderColor,
|
||||||
:class="{
|
'--card': theme.cardColor,
|
||||||
'has-selection': locked,
|
'--hover': theme.hoverColor,
|
||||||
'is-disabled': !canInteract,
|
|
||||||
'is-keyboard-active': keyboardActive,
|
|
||||||
}"
|
}"
|
||||||
role="group"
|
aria-label="题目点评"
|
||||||
aria-label="选择一项题目点评,点击后立即提交"
|
|
||||||
:aria-busy="loading || !!submitting"
|
|
||||||
@pointermove="onWheelPointerMove"
|
|
||||||
@pointerleave="clearPreview"
|
|
||||||
@click="onWheelClick"
|
|
||||||
>
|
>
|
||||||
<span
|
<n-empty v-if="state === 'anonymous'" description="登录之后才能点评" />
|
||||||
v-for="item in wheelItems"
|
|
||||||
:key="`${item.key}-face`"
|
|
||||||
class="segment-face"
|
|
||||||
:class="{
|
|
||||||
'is-active': activeIndex === item.index,
|
|
||||||
'is-selected': mine === item.key,
|
|
||||||
'is-submitting': submitting === item.key,
|
|
||||||
}"
|
|
||||||
:style="item.style"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<span
|
<n-flex v-else-if="state === 'loading'" justify="center" class="pad">
|
||||||
v-for="item in wheelItems"
|
<n-spin size="small" />
|
||||||
:key="`${item.key}-divider`"
|
</n-flex>
|
||||||
class="segment-divider"
|
|
||||||
:style="item.style"
|
<n-empty v-else-if="state === 'failed'" description="点评暂时读不出来,过一会儿再打开看看" />
|
||||||
aria-hidden="true"
|
|
||||||
|
<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
|
<button
|
||||||
v-for="item in wheelItems"
|
v-for="item in REACTIONS"
|
||||||
:key="item.key"
|
:key="item.key"
|
||||||
type="button"
|
type="button"
|
||||||
class="reaction-option"
|
class="option"
|
||||||
:class="{
|
:class="{ picking: submitting === item.key }"
|
||||||
'is-active': activeIndex === item.index,
|
:disabled="!!submitting"
|
||||||
'is-selected': mine === item.key,
|
:aria-busy="submitting === item.key"
|
||||||
'is-submitting': submitting === item.key,
|
@click="onOptionClick(item.key, $event)"
|
||||||
'is-muted': locked && mine !== item.key,
|
|
||||||
'is-unavailable': !userStore.isAuthed || !solved || loading,
|
|
||||||
}"
|
|
||||||
:style="item.style"
|
|
||||||
:disabled="!canInteract"
|
|
||||||
:aria-pressed="mine === item.key"
|
|
||||||
:aria-label="optionAriaLabel(item.key, item.label)"
|
|
||||||
@focus="preview(item.index, true)"
|
|
||||||
@blur="clearPreview"
|
|
||||||
@click.stop="pick(item.key)"
|
|
||||||
>
|
>
|
||||||
<span class="option-content">
|
|
||||||
<span class="option-icon" aria-hidden="true">
|
<span class="option-icon" aria-hidden="true">
|
||||||
<Icon :icon="submitting === item.key ? 'svg-spinners:180-ring-with-bg' : item.icon" />
|
<n-spin v-if="submitting === item.key" :size="28" />
|
||||||
|
<Icon v-else :icon="item.icon" />
|
||||||
</span>
|
</span>
|
||||||
<span class="option-label">{{ item.label }}</span>
|
<span class="option-label">{{ item.label }}</span>
|
||||||
<span v-if="counts" class="option-count"> {{ counts[item.key] }} 人 </span>
|
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
<n-text v-if="submitFailed" type="error" class="error">没记上,再点一次</n-text>
|
||||||
|
</template>
|
||||||
|
|
||||||
<div class="wheel-core" aria-hidden="true">
|
<template v-else-if="results">
|
||||||
<div class="core-content">
|
<p class="hint">
|
||||||
<Icon
|
你选了<b>「{{ results.mineLabel }}」</b> · 一共 {{ results.total }} 人点评过这道题
|
||||||
class="core-icon"
|
</p>
|
||||||
:class="{ 'is-spinning': wheelCenter.spinning }"
|
<ul class="bars">
|
||||||
:icon="wheelCenter.icon"
|
<li
|
||||||
/>
|
v-for="row in results.rows"
|
||||||
<span class="core-eyebrow">{{ wheelCenter.eyebrow }}</span>
|
:key="row.key"
|
||||||
<strong class="core-label">{{ wheelCenter.label }}</strong>
|
class="bar-row"
|
||||||
</div>
|
:class="{ mine: row.key === mine }"
|
||||||
</div>
|
>
|
||||||
</div>
|
<span class="bar-icon" aria-hidden="true"><Icon :icon="row.icon" /></span>
|
||||||
</div>
|
<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>
|
||||||
|
<slot name="after" />
|
||||||
|
</template>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.reaction-panel {
|
.reaction {
|
||||||
width: min(100%, 720px);
|
max-width: 560px;
|
||||||
box-sizing: border-box;
|
|
||||||
container-type: inline-size;
|
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: clamp(12px, 3vw, 24px);
|
|
||||||
color: var(--reaction-text);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.wheel-stage {
|
.pad {
|
||||||
display: grid;
|
padding: 32px 0;
|
||||||
place-items: center;
|
|
||||||
padding: 4px 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.reaction-wheel {
|
.hint {
|
||||||
position: relative;
|
margin: 0 0 14px;
|
||||||
width: min(100%, 400px);
|
font-size: 14px;
|
||||||
box-sizing: border-box;
|
opacity: 0.8;
|
||||||
aspect-ratio: 1;
|
|
||||||
overflow: hidden;
|
|
||||||
border: 1px solid var(--reaction-border);
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--reaction-card);
|
|
||||||
isolation: isolate;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.segment-face {
|
.options {
|
||||||
position: absolute;
|
display: flex;
|
||||||
inset: 1px;
|
flex-wrap: wrap;
|
||||||
overflow: hidden;
|
justify-content: center;
|
||||||
background: color-mix(in srgb, var(--reaction-text) 4%, var(--reaction-card));
|
gap: 10px;
|
||||||
clip-path: var(--segment-path);
|
transition: opacity 0.25s;
|
||||||
transform: translate(0, 0) scale(1);
|
|
||||||
transform-origin: center;
|
|
||||||
pointer-events: none;
|
|
||||||
transition:
|
|
||||||
transform 140ms cubic-bezier(0, 0, 0.2, 1),
|
|
||||||
background-color 140ms ease-out;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.segment-face:is(.is-active, .is-submitting) {
|
/* 防误触那 600ms:看得出是「还没好」,而不是坏了 */
|
||||||
z-index: 2;
|
.options.waiting {
|
||||||
background: color-mix(in srgb, var(--reaction-accent) 10%, var(--reaction-card));
|
opacity: 0.45;
|
||||||
transform: translate(calc(var(--push-x) * 0.7), calc(var(--push-y) * 0.7)) scale(1.045);
|
|
||||||
}
|
|
||||||
|
|
||||||
.segment-face.is-selected {
|
|
||||||
z-index: 3;
|
|
||||||
background: color-mix(in srgb, var(--reaction-accent) 18%, var(--reaction-card));
|
|
||||||
transform: translate(var(--push-x), var(--push-y)) scale(1.075);
|
|
||||||
}
|
|
||||||
|
|
||||||
.segment-divider {
|
|
||||||
position: absolute;
|
|
||||||
z-index: 8;
|
|
||||||
top: 1px;
|
|
||||||
left: calc(50% - 1px);
|
|
||||||
width: 2px;
|
|
||||||
height: calc(50% - 1px);
|
|
||||||
background: var(--reaction-border);
|
|
||||||
transform: rotate(var(--divider-angle));
|
|
||||||
transform-origin: 50% 100%;
|
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reaction-option {
|
.option {
|
||||||
position: absolute;
|
/* 一行 4 个,7 个排成 4 + 3,第二行居中 */
|
||||||
z-index: 9;
|
flex: 0 0 calc((100% - 30px) / 4);
|
||||||
top: var(--content-y);
|
min-width: 0;
|
||||||
left: var(--content-x);
|
height: 92px;
|
||||||
display: flex;
|
display: flex;
|
||||||
width: clamp(66px, 20%, 80px);
|
flex-direction: column;
|
||||||
min-height: clamp(52px, 16%, 64px);
|
|
||||||
align-items: stretch;
|
|
||||||
justify-content: stretch;
|
|
||||||
padding: 0;
|
|
||||||
border: 0;
|
|
||||||
border-radius: 6px;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--reaction-text);
|
|
||||||
font: inherit;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
touch-action: manipulation;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reaction-option:disabled {
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reaction-option.is-unavailable {
|
|
||||||
opacity: 0.58;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reaction-option.is-muted {
|
|
||||||
opacity: 0.48;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reaction-option:is(.is-selected, .is-submitting) {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reaction-option:focus-visible {
|
|
||||||
outline: 2px solid color-mix(in srgb, var(--reaction-accent) 55%, transparent);
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.option-content {
|
|
||||||
display: flex;
|
|
||||||
width: 100%;
|
|
||||||
min-height: 100%;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
flex-direction: column;
|
gap: 6px;
|
||||||
gap: 2px;
|
border: 1px solid var(--border);
|
||||||
border-radius: 6px;
|
border-radius: 10px;
|
||||||
transform: translate(0, 0) scale(1);
|
background-color: var(--card);
|
||||||
transition: transform 140ms 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reaction-option:active:not(:disabled) .option-content {
|
.option:hover:not(:disabled) {
|
||||||
transform: scale(0.96);
|
border-color: var(--accent);
|
||||||
|
background-color: var(--hover);
|
||||||
|
transform: translateY(-2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.reaction-option:is(.is-active, .is-submitting) .option-content {
|
.option:disabled {
|
||||||
transform: translate(calc(var(--push-x) * 0.8), calc(var(--push-y) * 0.8)) scale(1.18);
|
cursor: default;
|
||||||
|
opacity: 0.55;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reaction-option:is(.is-active, .is-submitting):active:not(:disabled) .option-content {
|
.option.picking {
|
||||||
transform: translate(calc(var(--push-x) * 0.8), calc(var(--push-y) * 0.8)) scale(1.1);
|
opacity: 1;
|
||||||
}
|
border-color: var(--accent);
|
||||||
|
|
||||||
.reaction-option.is-selected .option-content {
|
|
||||||
transform: translate(var(--push-x), var(--push-y)) scale(1.24);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.option-icon {
|
.option-icon {
|
||||||
display: grid;
|
display: flex;
|
||||||
width: 28px;
|
font-size: 34px;
|
||||||
height: 28px;
|
|
||||||
place-items: center;
|
|
||||||
font-size: 24px;
|
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.option-icon svg {
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.option-label {
|
.option-label {
|
||||||
font-size: 13px;
|
font-size: 15px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
line-height: 1.25;
|
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.option-count {
|
.error {
|
||||||
color: var(--reaction-text-faint);
|
display: block;
|
||||||
font-size: 12px;
|
margin-top: 10px;
|
||||||
font-weight: 400;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
line-height: 1.2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reaction-option.is-selected .option-count {
|
|
||||||
color: var(--reaction-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.wheel-core {
|
|
||||||
position: absolute;
|
|
||||||
z-index: 12;
|
|
||||||
top: 50%;
|
|
||||||
left: 50%;
|
|
||||||
display: grid;
|
|
||||||
width: 31%;
|
|
||||||
aspect-ratio: 1;
|
|
||||||
place-items: center;
|
|
||||||
padding: 12px;
|
|
||||||
border: 1px solid var(--reaction-border);
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--reaction-card);
|
|
||||||
color: var(--reaction-text);
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.core-content {
|
.bars {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0 0 16px;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
justify-items: center;
|
grid-template-columns: 24px 4.5em 1fr 3.5em;
|
||||||
gap: 3px;
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.core-icon {
|
.bar-row.mine {
|
||||||
width: clamp(20px, 5cqi, 26px);
|
background-color: var(--hover);
|
||||||
height: clamp(20px, 5cqi, 26px);
|
box-shadow: inset 3px 0 0 var(--accent);
|
||||||
font-size: clamp(20px, 5cqi, 26px);
|
|
||||||
color: var(--reaction-text-faint);
|
|
||||||
}
|
|
||||||
|
|
||||||
.core-eyebrow {
|
|
||||||
color: var(--reaction-text-faint);
|
|
||||||
font-size: clamp(9px, 2.3cqi, 11px);
|
|
||||||
font-weight: 400;
|
|
||||||
}
|
|
||||||
|
|
||||||
.core-label {
|
|
||||||
font-size: clamp(12px, 3cqi, 15px);
|
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
line-height: 1.2;
|
|
||||||
text-wrap: balance;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.reaction-wheel.is-keyboard-active :is(.segment-face, .option-content) {
|
.bar-icon {
|
||||||
transition-duration: 0.01ms;
|
display: flex;
|
||||||
|
font-size: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.is-spinning {
|
.bar-track {
|
||||||
animation: reaction-spin 850ms linear infinite;
|
height: 10px;
|
||||||
|
border-radius: 5px;
|
||||||
|
background-color: rgba(128, 128, 128, 0.14);
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@container (max-width: 440px) {
|
.bar-fill {
|
||||||
.wheel-stage {
|
display: block;
|
||||||
padding-top: 0;
|
height: 100%;
|
||||||
|
border-radius: 5px;
|
||||||
|
background-color: rgba(128, 128, 128, 0.45);
|
||||||
}
|
}
|
||||||
|
|
||||||
.reaction-option {
|
.bar-row.mine .bar-fill {
|
||||||
width: clamp(58px, 20%, 70px);
|
background-color: var(--accent);
|
||||||
min-height: clamp(48px, 16%, 56px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.segment-face:is(.is-active, .is-submitting) {
|
.bar-count {
|
||||||
transform: translate(calc(var(--push-x) * 0.5), calc(var(--push-y) * 0.5)) scale(1.03);
|
text-align: right;
|
||||||
}
|
font-variant-numeric: tabular-nums;
|
||||||
|
|
||||||
.segment-face.is-selected {
|
|
||||||
transform: translate(calc(var(--push-x) * 0.75), calc(var(--push-y) * 0.75)) scale(1.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.option-content {
|
|
||||||
gap: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reaction-option:is(.is-active, .is-submitting) .option-content {
|
|
||||||
transform: translate(calc(var(--push-x) * 0.55), calc(var(--push-y) * 0.55)) scale(1.12);
|
|
||||||
}
|
|
||||||
|
|
||||||
.reaction-option:is(.is-active, .is-submitting):active:not(:disabled) .option-content {
|
|
||||||
transform: translate(calc(var(--push-x) * 0.55), calc(var(--push-y) * 0.55)) scale(1.06);
|
|
||||||
}
|
|
||||||
|
|
||||||
.reaction-option.is-selected .option-content {
|
|
||||||
transform: translate(calc(var(--push-x) * 0.75), calc(var(--push-y) * 0.75)) scale(1.17);
|
|
||||||
}
|
|
||||||
|
|
||||||
.option-icon {
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
font-size: 21px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.option-icon svg {
|
|
||||||
width: 21px;
|
|
||||||
height: 21px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.option-label {
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.option-count {
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wheel-core {
|
|
||||||
padding: 10px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
.segment-face,
|
|
||||||
.option-content,
|
|
||||||
.option-icon,
|
|
||||||
.option-label,
|
|
||||||
.option-count {
|
|
||||||
transition-duration: 0.01ms;
|
|
||||||
}
|
|
||||||
|
|
||||||
.is-spinning {
|
|
||||||
animation-duration: 1.8s;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes reaction-spin {
|
|
||||||
to {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -5,7 +5,7 @@ import { getReaction } from "oj/api"
|
|||||||
import { useProblemStore } from "oj/store/problem"
|
import { useProblemStore } from "oj/store/problem"
|
||||||
import { useSubmissionStore } from "oj/store/submission"
|
import { useSubmissionStore } from "oj/store/submission"
|
||||||
import { useFireworks } from "oj/problem/composables/useFireworks"
|
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 { SubmissionStatus } from "utils/constants"
|
||||||
import { useProblemPageContext } from "../composables/problemPageContext"
|
import { useProblemPageContext } from "../composables/problemPageContext"
|
||||||
|
|
||||||
@@ -28,10 +28,9 @@ const { submission } = storeToRefs(submissionStore)
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const ctx = useProblemPageContext()
|
const ctx = useProblemPageContext()
|
||||||
|
|
||||||
const { isDesktop } = useBreakpoints()
|
|
||||||
const { celebrate } = useFireworks()
|
const { celebrate } = useFireworks()
|
||||||
|
|
||||||
// ==================== AC 后弹出点评轮盘 ====================
|
// ==================== AC 后弹出点评 ====================
|
||||||
// 只对已经能评价、且还没评过的人弹:后端要求先有 AC 才收评价,这里刚 AC 完正好;
|
// 只对已经能评价、且还没评过的人弹:后端要求先有 AC 才收评价,这里刚 AC 完正好;
|
||||||
// mine 非 null 说明早就评过了,别再打扰。
|
// mine 非 null 说明早就评过了,别再打扰。
|
||||||
//
|
//
|
||||||
@@ -50,6 +49,7 @@ async function openReview() {
|
|||||||
try {
|
try {
|
||||||
const res = await getReaction(problem.value!.id)
|
const res = await getReaction(problem.value!.id)
|
||||||
if (res.mine === null) {
|
if (res.mine === null) {
|
||||||
|
reviewed.value = false
|
||||||
commentPanel.value = true
|
commentPanel.value = true
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -76,6 +76,46 @@ function closeCommentPanel() {
|
|||||||
settleReview()
|
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(
|
const { start: showCommentPanelDelayed, stop: cancelCommentPanel } = useTimeoutFn(
|
||||||
openReview,
|
openReview,
|
||||||
1500,
|
1500,
|
||||||
@@ -188,15 +228,25 @@ watch(
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<!-- 原来的标题是「恭喜你成功提交」:交上去不等于做对。auto-focus 关掉:不预选任何一项 -->
|
||||||
<n-modal
|
<n-modal
|
||||||
|
v-model:show="commentPanel"
|
||||||
preset="card"
|
preset="card"
|
||||||
title="恭喜你成功提交,说说你对这道题的感受吧"
|
:title="reviewed ? '大家是这样觉得的' : '做对了!说说你对这道题的感受吧'"
|
||||||
:mask-closable="false"
|
:mask-closable="false"
|
||||||
:closable="false"
|
:closable="false"
|
||||||
:close-on-esc="false"
|
:close-on-esc="false"
|
||||||
:style="{ maxWidth: isDesktop && '50vw', maxHeight: '80vh' }"
|
:auto-focus="false"
|
||||||
v-model:show="commentPanel"
|
style="width: min(600px, calc(100vw - 32px))"
|
||||||
>
|
>
|
||||||
<ProblemReaction @submitted="closeCommentPanel" />
|
<ProblemReaction guard @submitted="onReviewed">
|
||||||
|
<template #after>
|
||||||
|
<n-flex justify="end">
|
||||||
|
<n-button ref="continueButton" type="primary" @click="continueAfterReview">
|
||||||
|
{{ continueAction.label }}
|
||||||
|
</n-button>
|
||||||
|
</n-flex>
|
||||||
|
</template>
|
||||||
|
</ProblemReaction>
|
||||||
</n-modal>
|
</n-modal>
|
||||||
</template>
|
</template>
|
||||||
@@ -49,10 +49,27 @@ export const useLessonStore = defineStore("lesson", () => {
|
|||||||
return !!find(problemDisplayId)
|
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(() =>
|
const label = computed(() =>
|
||||||
activity.value?.source === "teacher" ? "老师布置的题" : "这节课的题",
|
activity.value?.source === "teacher" ? "老师布置的题" : "这节课的题",
|
||||||
)
|
)
|
||||||
|
|
||||||
return { activity, label, load, markAccepted, includes }
|
return { activity, label, load, markAccepted, includes, nextAfter }
|
||||||
})
|
})
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
# OJ2 设计文档:题目详情页重设计
|
# OJ2 设计文档:题目详情页重设计
|
||||||
|
|
||||||
日期:2026-09-28
|
日期: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」那一页已作废,只留着对照)。效果图表达不了的规则都写在这里,
|
那一页是定稿,「初稿 1280×800」那一页已作废,只留着对照)。效果图表达不了的规则都写在这里,
|
||||||
**实施时以本文为准**。
|
**实施时以本文为准**。
|
||||||
|
|
||||||
@@ -53,7 +53,7 @@
|
|||||||
|
|
||||||
## 3. 已拍板的决定
|
## 3. 已拍板的决定
|
||||||
|
|
||||||
1. **AC 后点评保持强制弹窗**,七个选项不变(「题目有错」留在轮盘里)。
|
1. **AC 后点评保持强制弹窗**,七个选项不变(「题目有错」和其他六项放在一起,不单独挪走)。
|
||||||
2. **题单入口隐藏**「我的提交」「本题提交」「统计」「点评」和标题下的「已解决 / 尝试过」标签 ——
|
2. **题单入口隐藏**「我的提交」「本题提交」「统计」「点评」和标题下的「已解决 / 尝试过」标签 ——
|
||||||
不让学生看到加入题单之前的提交、抄到答案。
|
不让学生看到加入题单之前的提交、抄到答案。
|
||||||
3. **「运行例子」先继续用 Judge0**,不另做判题机端点。
|
3. **「运行例子」先继续用 Judge0**,不另做判题机端点。
|
||||||
@@ -161,7 +161,7 @@ B 看得到。
|
|||||||
「放回编辑器」「复制」;加入题单之前被锁住的提交显示为一行说明,不能展开;底部「看这道题所有人的提交」。
|
「放回编辑器」「复制」;加入题单之前被锁住的提交显示为一行说明,不能展开;底部「看这道题所有人的提交」。
|
||||||
「放回编辑器」后提示「已放回,按 Ctrl+Z 撤回」—— 要确认编辑器的撤销能撤回这次替换。
|
「放回编辑器」后提示「已放回,按 Ctrl+Z 撤回」—— 要确认编辑器的撤销能撤回这次替换。
|
||||||
- **统计**:出题人、创建时间、标签、数字卡、结果饼图、历年通过率,内容原样。
|
- **统计**:出题人、创建时间、标签、数字卡、结果饼图、历年通过率,内容原样。
|
||||||
- **点评**:轮盘原样,每人只能评一次。
|
- **点评**:和强制弹窗同一个组件(第 7 节),每人只能评一次。
|
||||||
- 题单入口三个按钮都不出现。比赛入口没有「点评」。
|
- 题单入口三个按钮都不出现。比赛入口没有「点评」。
|
||||||
|
|
||||||
### 5.6 手机
|
### 5.6 手机
|
||||||
@@ -197,9 +197,15 @@ B 看得到。
|
|||||||
## 7. 强制点评
|
## 7. 强制点评
|
||||||
|
|
||||||
- 文案:「做对了!说说你对这道题的感受吧」(原来的「恭喜你成功提交」不准确,交上去不等于做对)。
|
- 文案:「做对了!说说你对这道题的感受吧」(原来的「恭喜你成功提交」不准确,交上去不等于做对)。
|
||||||
- 选之前**不显示人数**(圆心只显示悬停的选项名),选完才显示大家怎么选的 —— 避免跟风。
|
- **轮盘换成了一排大按钮**(2026-09-28 用户同意重新设计):七个选项一行 4 个、排成 4 + 3,
|
||||||
- 轮盘 360px(原来 300,每块的字只有 12px 左右)。
|
每个带表情和 15px 的字。原来的轮盘要把鼠标移到扇区上才看得清是哪一项,每块的字只有 12px
|
||||||
- 防误触:弹出后 600ms 内不接受点击;不响应 Enter / 空格;不预选任何一块;选完焦点落到「下一题」。
|
左右,而且用了 Chrome 111 才有的 `color-mix()`,机房 105 那档渲染不出扇区底色。
|
||||||
|
- 选之前**不显示人数**(后端本来就要自己评过才返回 counts),选完变成横条图:顺序和选项一致、
|
||||||
|
标出自己选的那一项 —— 避免跟风。没做对的人在抽屉里只看到一句说明,也没有人数。
|
||||||
|
- 防误触:弹出后 600ms 内选项半透明、点不了;强制弹窗里不响应 Enter / 空格(键盘触发的
|
||||||
|
click 的 `detail` 是 0);弹窗 `auto-focus` 关掉,不预选任何一项。
|
||||||
|
- 选完不马上关:弹窗里换成横条图,下面只有一个按钮、焦点落在它上面 —— 被拦下的换题写「继续」,
|
||||||
|
否则是这节课的「下一题:题号 题目」,都没有就是「好的」。
|
||||||
- **窗口期内换题要先点评**:通过后 1.5 秒内点「下一题」、课堂条芯片、‹ ›、顶栏题号直达,
|
- **窗口期内换题要先点评**:通过后 1.5 秒内点「下一题」、课堂条芯片、‹ ›、顶栏题号直达,
|
||||||
先立刻弹出点评,选完再跳。**现在线上可以被跳过**(`SubmitCode.vue` 换题时 `cancelCommentPanel()`)。
|
先立刻弹出点评,选完再跳。**现在线上可以被跳过**(`SubmitCode.vue` 换题时 `cancelCommentPanel()`)。
|
||||||
- 查询或提交点评的接口出错时不能把学生卡住:查询失败就不弹;提交失败留在弹窗里提示「没记上,再点一次」。
|
- 查询或提交点评的接口出错时不能把学生卡住:查询失败就不弹;提交失败留在弹窗里提示「没记上,再点一次」。
|
||||||
|
|||||||
Reference in new issue
Block a user