diff --git a/src/oj/problem/components/ProblemReaction.vue b/src/oj/problem/components/ProblemReaction.vue index 83f9622..e8a83ce 100644 --- a/src/oj/problem/components/ProblemReaction.vue +++ b/src/oj/problem/components/ProblemReaction.vue @@ -2,6 +2,7 @@ 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" @@ -22,10 +23,73 @@ const counts = ref(null) const loading = ref(false) // 正在提交的 key,用来锁住整组并只在当前选项显示进度。 const submitting = ref(null) +const activeIndex = ref(null) +const keyboardActive = ref(false) +const wheelRef = ref(null) let loadSequence = 0 +const wheelGeometry = { + startAngle: -90, + contentRadius: 34.5, + pushRadius: 4.5, + outerRadius: 50, + gapAngle: 1.15, + 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 + wheelGeometry.gapAngle + const endAngle = centerAngle + sliceAngle / 2 - wheelGeometry.gapAngle + 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`, + } +} + +const wheelItems = REACTIONS.map((item, index) => ({ + ...item, + index, + style: getWheelItemStyle(index), +})) + const solved = computed(() => problem.value?.my_status === 0) const locked = computed(() => mine.value !== null) +const canInteract = computed( + () => + !!problem.value && + solved.value && + !locked.value && + !loading.value && + !submitting.value, +) const selectedLabel = computed( () => REACTIONS.find((item) => item.key === mine.value)?.label ?? "", ) @@ -62,12 +126,73 @@ const statusText = computed(() => { return "点击即提交,点评提交后不能修改" }) +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 (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-accent-hover": theme.value.primaryColorHover, - "--reaction-accent-pressed": theme.value.primaryColorPressed, "--reaction-card": theme.value.cardColor, "--reaction-canvas": theme.value.bodyColor, + "--reaction-action": theme.value.actionColor, + "--reaction-hover": theme.value.hoverColor, + "--reaction-border": theme.value.borderColor, + "--reaction-rail": theme.value.railColor, + "--reaction-shadow": theme.value.boxShadow1, "--reaction-text": theme.value.textColor1, "--reaction-text-muted": theme.value.textColor2, "--reaction-text-faint": theme.value.textColor3, @@ -82,6 +207,56 @@ function optionAriaLabel(key: ReactionKey, label: string) { 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) { if ( !problem.value || @@ -92,6 +267,8 @@ async function pick(key: ReactionKey) { ) return + activeIndex.value = null + keyboardActive.value = false submitting.value = key try { const res = await setReaction(problem.value.id, key) @@ -126,6 +303,8 @@ watch( mine.value = null counts.value = null submitting.value = null + activeIndex.value = null + keyboardActive.value = false if (!isAuthed || problemId === undefined) { loadSequence += 1 @@ -180,53 +359,84 @@ watch(