refactor(reaction): 收敛单选提交交互
This commit is contained in:
@@ -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有问题
|
||||||
|
|||||||
@@ -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
|
||||||
|
v-for="item in REACTIONS"
|
||||||
|
:key="item.key"
|
||||||
|
trigger="hover"
|
||||||
|
:disabled="solved && !locked"
|
||||||
|
>
|
||||||
<template #trigger>
|
<template #trigger>
|
||||||
<button
|
|
||||||
class="reaction-button"
|
|
||||||
:class="{ active: selected.includes(item.key) }"
|
|
||||||
: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
|
<n-button
|
||||||
type="primary"
|
:type="mine === item.key ? 'primary' : 'tertiary'"
|
||||||
size="small"
|
:secondary="mine === item.key"
|
||||||
:disabled="!selected.length"
|
:disabled="!solved || locked || submitting"
|
||||||
:loading="submitting"
|
:loading="submitting === item.key"
|
||||||
@click="submit"
|
@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>
|
</n-button>
|
||||||
<span class="hint">最多选 {{ MAX_REACTIONS }} 个,提交后不能修改</span>
|
</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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user