refactor(reaction): 收敛单选提交交互
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled

This commit is contained in:
2026-08-06 07:53:06 -06:00
parent 9f89be3876
commit 5b8e0f866b
5 changed files with 98 additions and 102 deletions

View File

@@ -1,9 +1,10 @@
import http from "utils/http" import http, { type ApiResponse } from "utils/http"
import { filterResult } from "oj/transforms" import { filterResult } from "oj/transforms"
import type { import type {
Exercise, Exercise,
Problem, Problem,
ReactionKey, ReactionKey,
ReactionState,
Submission, Submission,
SubmissionListPayload, SubmissionListPayload,
SubmitCodePayload, SubmitCodePayload,
@@ -225,11 +226,43 @@ export function getMessageList(offset = 0, limit = 10) {
} }
export function getReaction(problemID: number) { export function getReaction(problemID: number) {
return http.get("reaction", { params: { problem_id: problemID } }) return http
.get<ReactionWireState>("reaction", {
params: { problem_id: problemID },
})
.then(normalizeReactionResponse)
} }
export function setReaction(problemID: number, types: ReactionKey[]) { interface ReactionWireState {
return http.post("reaction", { problem_id: problemID, types }) mine: ReactionKey[] | ReactionKey | null
mine_type?: ReactionKey | null
counts: ReactionState["counts"]
}
function normalizeReactionResponse(
res: ApiResponse<ReactionWireState>,
): ApiResponse<ReactionState> {
const legacyMine = Array.isArray(res.data.mine)
? (res.data.mine[0] ?? null)
: res.data.mine
return {
...res,
data: {
mine: res.data.mine_type ?? legacyMine,
counts: res.data.counts,
},
}
}
export function setReaction(problemID: number, type: ReactionKey) {
return http
.post<ReactionWireState>("reaction", {
problem_id: problemID,
type,
// 过渡期同时发送旧字段:新前端先上线时,旧后端也能接受单选提交。
types: [type],
})
.then(normalizeReactionResponse)
} }
// TODO: 这个API有问题 // TODO: 这个API有问题

View File

@@ -1,34 +1,34 @@
<template> <template>
<n-alert v-if="!userStore.isAuthed" type="error" title="请先登录" /> <n-alert v-if="!userStore.isAuthed" type="error" title="请先登录" />
<div v-else> <div v-else>
<div class="reactions"> <n-flex class="reactions">
<n-tooltip v-for="item in REACTIONS" :key="item.key" trigger="hover"> <n-tooltip
<template #trigger> v-for="item in REACTIONS"
<button :key="item.key"
class="reaction-button" trigger="hover"
:class="{ active: selected.includes(item.key) }" :disabled="solved && !locked"
:disabled="isDisabled(item.key)"
@click="toggle(item.key)"
>
<Icon :icon="item.icon" :width="28" />
<span v-if="counts" class="count">×{{ counts[item.key] }}</span>
</button>
</template>
{{ tooltipOf(item.key, item.label) }}
</n-tooltip>
</div>
<n-flex v-if="solved && !locked" align="center" class="footer">
<n-button
type="primary"
size="small"
:disabled="!selected.length"
:loading="submitting"
@click="submit"
> >
提交评价 <template #trigger>
</n-button> <n-button
<span class="hint">最多选 {{ MAX_REACTIONS }} 提交后不能修改</span> :type="mine === item.key ? 'primary' : 'tertiary'"
:secondary="mine === item.key"
:disabled="!solved || locked || submitting"
:loading="submitting === item.key"
@click="pick(item.key)"
>
<template #icon>
<Icon :icon="item.icon" :width="20" />
</template>
{{ item.label }}
<span v-if="counts" class="count">{{ counts[item.key] }}</span>
</n-button>
</template>
{{ tooltipOf(item.label) }}
</n-tooltip>
</n-flex> </n-flex>
<div v-if="solved" class="hint">
{{ locked ? "已评价不能修改" : "选一个点了直接提交不能修改" }}
</div>
</div> </div>
</template> </template>
@@ -38,63 +38,52 @@ import { storeToRefs } from "pinia"
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 { MAX_REACTIONS, REACTIONS } from "utils/constants" import { REACTIONS } from "utils/constants"
import type { ReactionCounts, ReactionKey } from "utils/types" import type { ReactionCounts, ReactionKey } from "utils/types"
const emit = defineEmits<{ submitted: [] }>()
const userStore = useUserStore() const userStore = useUserStore()
const problemStore = useProblemStore() const problemStore = useProblemStore()
const { problem } = storeToRefs(problemStore) const { problem } = storeToRefs(problemStore)
const message = useMessage() const message = useMessage()
// selected 是本地待提交的选择,提交成功后就是 mine之后不可再改 const mine = ref<ReactionKey | null>(null)
const selected = ref<ReactionKey[]>([])
const counts = ref<ReactionCounts | null>(null) const counts = ref<ReactionCounts | null>(null)
const submitting = ref(false) // 正在提交的那个 key用来只给被点的按钮转圈
const submitting = ref<ReactionKey | null>(null)
const solved = computed(() => problem.value?.my_status === 0) const solved = computed(() => problem.value?.my_status === 0)
// 评价一次定终身:后端已存在记录就锁死,只剩查看 // 评价一次定终身:后端已存在记录就锁死,只剩查看
const locked = ref(false) const locked = ref(false)
function isDisabled(key: ReactionKey) { function tooltipOf(label: string) {
if (!solved.value || locked.value || submitting.value) return true
if (selected.value.includes(key)) return false
return selected.value.length >= MAX_REACTIONS
}
function tooltipOf(key: ReactionKey, label: string) {
if (!solved.value) return "完成本题后可以评价" if (!solved.value) return "完成本题后可以评价"
if (locked.value) return `${label}(已评价,不能修改)` return `${label}(已评价,不能修改)`
if (isDisabled(key)) return `最多选 ${MAX_REACTIONS} 个,先取消一个`
return label
} }
function toggle(key: ReactionKey) { async function pick(key: ReactionKey) {
selected.value = selected.value.includes(key) if (!problem.value) return
? selected.value.filter((k) => k !== key) submitting.value = key
: [...selected.value, key]
}
async function submit() {
if (!problem.value || !selected.value.length) return
submitting.value = true
try { try {
const res = await setReaction(problem.value.id, selected.value) const res = await setReaction(problem.value.id, key)
selected.value = res.data.mine mine.value = res.data.mine
counts.value = res.data.counts counts.value = res.data.counts
locked.value = true locked.value = true
emit("submitted")
} catch { } catch {
message.error("提交失败,请重试") message.error("提交失败,请重试")
} finally { } finally {
submitting.value = false submitting.value = null
} }
} }
async function load() { async function load() {
if (!problem.value) return if (!problem.value) return
const res = await getReaction(problem.value.id) const res = await getReaction(problem.value.id)
selected.value = res.data.mine mine.value = res.data.mine
counts.value = res.data.counts counts.value = res.data.counts
locked.value = res.data.mine.length > 0 locked.value = res.data.mine !== null
} }
onMounted(() => { onMounted(() => {
@@ -103,54 +92,25 @@ onMounted(() => {
</script> </script>
<style scoped> <style scoped>
/* 七个按钮一行约需 700px窄面板放不下就换行,不做横向滚动免得按钮被藏起来 */ /* 七个按钮一行放不下就换行,不做横向滚动免得按钮被藏起来 */
.reactions { .reactions {
display: flex;
flex-wrap: wrap;
gap: 8px; gap: 8px;
} }
.reaction-button { /* 锁定后选中的那颗仍要看得清,不然自己评过什么都糊成一片 */
flex-shrink: 0; .reactions :deep(.n-button.n-button--disabled) {
display: inline-flex; opacity: 0.6;
align-items: center;
gap: 6px;
height: 48px;
padding: 0 14px;
border: 1px solid rgba(128, 128, 128, 0.3);
border-radius: 8px;
background: transparent;
color: inherit;
font: inherit;
cursor: pointer;
transition:
border-color 0.2s,
background-color 0.2s;
} }
.reaction-button:hover:not(:disabled) { .reactions :deep(.n-button--primary-type.n-button--disabled) {
border-color: rgba(128, 128, 128, 0.6);
background: rgba(128, 128, 128, 0.1);
}
.reaction-button.active {
border-color: #18a058;
background: rgba(24, 160, 88, 0.12);
}
.reaction-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* 锁定后选中的那几个仍要看得清,不然自己评过什么都糊成一片 */
.reaction-button.active:disabled {
opacity: 1; opacity: 1;
} }
.footer { .count {
margin-top: 12px; margin-left: 6px;
opacity: 0.7;
font-variant-numeric: tabular-nums;
} }
.hint { .hint {
margin-top: 12px;
font-size: 13px; font-size: 13px;
opacity: 0.6; opacity: 0.6;
} }
.count {
font-size: 15px;
opacity: 0.7;
}
</style> </style>

View File

@@ -40,6 +40,11 @@ const router = useRouter()
const [commentPanel] = useToggle() const [commentPanel] = useToggle()
const message = useMessage() const message = useMessage()
// 评价提交后停一下让用户看到选中态,再收起弹窗
function closeCommentPanel() {
setTimeout(() => (commentPanel.value = false), 800)
}
const { isDesktop } = useBreakpoints() const { isDesktop } = useBreakpoints()
// ==================== 烟花效果 ==================== // ==================== 烟花效果 ====================
@@ -73,7 +78,7 @@ const { start: startCooldown, isPending: isCooldown } = useTimeout(5000, {
const { start: showCommentPanelDelayed } = useTimeoutFn( const { start: showCommentPanelDelayed } = useTimeoutFn(
async () => { async () => {
const res = await getReaction(problem.value!.id) const res = await getReaction(problem.value!.id)
if (res.data.mine.length === 0) { if (res.data.mine === null) {
commentPanel.value = true commentPanel.value = true
} }
}, },
@@ -264,6 +269,6 @@ watch(
:style="{ maxWidth: isDesktop && '50vw', maxHeight: '80vh' }" :style="{ maxWidth: isDesktop && '50vw', maxHeight: '80vh' }"
v-model:show="commentPanel" v-model:show="commentPanel"
> >
<ProblemReaction /> <ProblemReaction @submitted="closeCommentPanel" />
</n-modal> </n-modal>
</template> </template>

View File

@@ -368,5 +368,3 @@ export const REACTIONS: {
{ key: "interesting", label: "有意思", icon: "fluent-emoji:star-struck" }, { key: "interesting", label: "有意思", icon: "fluent-emoji:star-struck" },
{ key: "want_explain", label: "想听讲解", icon: "fluent-emoji:books" }, { key: "want_explain", label: "想听讲解", icon: "fluent-emoji:books" },
] ]
export const MAX_REACTIONS = 3

View File

@@ -606,7 +606,7 @@ export type ReactionKey =
export type ReactionCounts = Record<ReactionKey, number> export type ReactionCounts = Record<ReactionKey, number>
export interface ReactionState { export interface ReactionState {
mine: ReactionKey[] mine: ReactionKey | null
counts: ReactionCounts | null counts: ReactionCounts | null
} }