Compare commits

..
2 Commits
Author SHA1 Message Date
xuyueandClaude Opus 5 9f89be3876 feat(reaction): 评价改为先勾选再提交,提交后不可修改
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 06:15:34 -06:00
xuyueandClaude Opus 5 1fb93e6b16 refactor(reaction): 后台反馈统计独立成 reaction 目录,去掉排序、加最近评价时间
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 06:15:34 -06:00
6 changed files with 83 additions and 82 deletions
+2 -7
View File
@@ -287,14 +287,9 @@ export function createAnnouncement(announcement: AnnouncementEdit) {
return http.post("admin/announcement", announcement) return http.post("admin/announcement", announcement)
} }
export function getReactionStats( export function getReactionStats(offset = 0, limit = 10, problem: string) {
offset = 0,
limit = 10,
problem: string,
ordering: string,
) {
return http.get("admin/reaction", { return http.get("admin/reaction", {
params: { offset, limit, problem, ordering }, params: { offset, limit, problem },
}) })
} }
@@ -9,14 +9,7 @@
/> />
</div> </div>
</n-flex> </n-flex>
<n-data-table <n-data-table striped :columns="columns" :data="rows" />
striped
:columns="columns"
:data="rows"
:scroll-x="1100"
remote
@update:sorter="handleSorter"
/>
<Pagination <Pagination
:total="total" :total="total"
v-model:limit="query.limit" v-model:limit="query.limit"
@@ -29,6 +22,7 @@ import { Icon } from "@iconify/vue"
import { NButton, NFlex } from "naive-ui" import { NButton, NFlex } from "naive-ui"
import Pagination from "shared/components/Pagination.vue" import Pagination from "shared/components/Pagination.vue"
import { REACTIONS } from "utils/constants" import { REACTIONS } from "utils/constants"
import { parseTime } from "utils/functions"
import type { ReactionStatsRow } from "utils/types" import type { ReactionStatsRow } from "utils/types"
import { getReactionStats } from "../api" import { getReactionStats } from "../api"
@@ -38,7 +32,6 @@ const query = reactive({
limit: 10, limit: 10,
page: 1, page: 1,
problem: "", problem: "",
ordering: "-users",
}) })
const columns: DataTableColumn<ReactionStatsRow>[] = [ const columns: DataTableColumn<ReactionStatsRow>[] = [
@@ -58,8 +51,8 @@ const columns: DataTableColumn<ReactionStatsRow>[] = [
() => row.pid, () => row.pid,
), ),
}, },
{ title: "标题", key: "title", minWidth: 180, ellipsis: true }, { title: "标题", key: "title", minWidth: 180 },
{ title: "表态人数", key: "users", width: 110, sorter: true }, { title: "表态人数", key: "users", width: 120 },
...REACTIONS.map((item) => ({ ...REACTIONS.map((item) => ({
title: () => title: () =>
h(NFlex, { align: "center", size: 4, wrap: false }, () => [ h(NFlex, { align: "center", size: 4, wrap: false }, () => [
@@ -67,35 +60,25 @@ const columns: DataTableColumn<ReactionStatsRow>[] = [
item.label, item.label,
]), ]),
key: item.key, key: item.key,
width: 110, width: 120,
sorter: true,
})), })),
{
title: "时间",
key: "last_time",
width: 180,
render: (row) => parseTime(row.last_time, "YYYY-MM-DD HH:mm:ss"),
},
] ]
function handleSorter(sorter: { columnKey: string; order: string | false }) {
if (!sorter || !sorter.order) {
query.ordering = "-users"
} else {
query.ordering =
(sorter.order === "descend" ? "-" : "") + String(sorter.columnKey)
}
query.page = 1
}
async function listStats() { async function listStats() {
const offset = (query.page - 1) * query.limit const offset = (query.page - 1) * query.limit
const res = await getReactionStats( const res = await getReactionStats(offset, query.limit, query.problem)
offset,
query.limit,
query.problem,
query.ordering,
)
rows.value = res.data.results rows.value = res.data.results
total.value = res.data.total total.value = res.data.total
} }
onMounted(listStats) onMounted(listStats)
watch(() => [query.page, query.limit, query.ordering], listStats) watch(() => [query.page, query.limit], listStats)
watchDebounced(() => query.problem, listStats, { watchDebounced(() => query.problem, listStats, {
debounce: 500, debounce: 500,
maxWait: 1000, maxWait: 1000,
+52 -30
View File
@@ -1,11 +1,12 @@
<template> <template>
<n-alert v-if="!userStore.isAuthed" type="error" title="请先登录" /> <n-alert v-if="!userStore.isAuthed" type="error" title="请先登录" />
<div v-else class="reactions"> <div v-else>
<div 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">
<template #trigger> <template #trigger>
<button <button
class="reaction-button" class="reaction-button"
:class="{ active: mine.includes(item.key) }" :class="{ active: selected.includes(item.key) }"
:disabled="isDisabled(item.key)" :disabled="isDisabled(item.key)"
@click="toggle(item.key)" @click="toggle(item.key)"
> >
@@ -16,6 +17,19 @@
{{ tooltipOf(item.key, item.label) }} {{ tooltipOf(item.key, item.label) }}
</n-tooltip> </n-tooltip>
</div> </div>
<n-flex v-if="solved && !locked" align="center" class="footer">
<n-button
type="primary"
size="small"
:disabled="!selected.length"
:loading="submitting"
@click="submit"
>
提交评价
</n-button>
<span class="hint">最多选 {{ MAX_REACTIONS }} 提交后不能修改</span>
</n-flex>
</div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
@@ -32,58 +46,55 @@ const problemStore = useProblemStore()
const { problem } = storeToRefs(problemStore) const { problem } = storeToRefs(problemStore)
const message = useMessage() const message = useMessage()
const mine = ref<ReactionKey[]>([]) // selected 是本地待提交的选择,提交成功后就是 mine,之后不可再改
const selected = ref<ReactionKey[]>([])
const counts = ref<ReactionCounts | null>(null) const counts = ref<ReactionCounts | null>(null)
const submitting = ref(false)
const solved = computed(() => problem.value?.my_status === 0) const solved = computed(() => problem.value?.my_status === 0)
// 评价一次定终身:后端已存在记录就锁死,只剩查看
const locked = ref(false)
function isDisabled(key: ReactionKey) { function isDisabled(key: ReactionKey) {
if (!solved.value) return true if (!solved.value || locked.value || submitting.value) return true
if (mine.value.includes(key)) return false if (selected.value.includes(key)) return false
return mine.value.length >= MAX_REACTIONS return selected.value.length >= MAX_REACTIONS
} }
function tooltipOf(key: ReactionKey, label: string) { function tooltipOf(key: ReactionKey, label: string) {
if (!solved.value) return "完成本题后可以评价" if (!solved.value) return "完成本题后可以评价"
if (locked.value) return `${label}(已评价,不能修改)`
if (isDisabled(key)) return `最多选 ${MAX_REACTIONS} 个,先取消一个` if (isDisabled(key)) return `最多选 ${MAX_REACTIONS} 个,先取消一个`
return label return label
} }
// 连续点击不做防抖、不禁用按钮,靠请求代号保证后到的旧响应不会覆盖新状态: function toggle(key: ReactionKey) {
// 每次点击自增一次,只有仍是最新请求时才把结果写回本地状态 selected.value = selected.value.includes(key)
let requestSeq = 0 ? selected.value.filter((k) => k !== key)
: [...selected.value, key]
}
async function toggle(key: ReactionKey) { async function submit() {
if (!problem.value) return if (!problem.value || !selected.value.length) return
const prevMine = [...mine.value] submitting.value = true
const prevCounts = counts.value ? { ...counts.value } : null
const selected = mine.value.includes(key)
const next = selected
? mine.value.filter((k) => k !== key)
: [...mine.value, key]
mine.value = next
if (counts.value) counts.value[key] += selected ? -1 : 1
const seq = ++requestSeq
try { try {
const res = await setReaction(problem.value.id, next) const res = await setReaction(problem.value.id, selected.value)
if (seq !== requestSeq) return selected.value = res.data.mine
mine.value = res.data.mine
counts.value = res.data.counts counts.value = res.data.counts
locked.value = true
} catch { } catch {
if (seq !== requestSeq) return message.error("提交失败,请重试")
mine.value = prevMine } finally {
counts.value = prevCounts submitting.value = false
message.error("操作失败,请重试")
} }
} }
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)
mine.value = res.data.mine selected.value = res.data.mine
counts.value = res.data.counts counts.value = res.data.counts
locked.value = res.data.mine.length > 0
} }
onMounted(() => { onMounted(() => {
@@ -127,6 +138,17 @@ onMounted(() => {
opacity: 0.5; opacity: 0.5;
cursor: not-allowed; cursor: not-allowed;
} }
/* 锁定后选中的那几个仍要看得清,不然自己评过什么都糊成一片 */
.reaction-button.active:disabled {
opacity: 1;
}
.footer {
margin-top: 12px;
}
.hint {
font-size: 13px;
opacity: 0.6;
}
.count { .count {
font-size: 15px; font-size: 15px;
opacity: 0.7; opacity: 0.7;
+1 -1
View File
@@ -260,7 +260,7 @@ export const admins: RouteRecordRaw = {
{ {
path: "reaction/list", path: "reaction/list",
name: "admin reaction list", name: "admin reaction list",
component: () => import("admin/communication/reactions.vue"), component: () => import("admin/reaction/list.vue"),
meta: { requiresSuperAdmin: true }, meta: { requiresSuperAdmin: true },
}, },
{ {
+1 -1
View File
@@ -119,7 +119,7 @@ const options = computed<MenuOption[]>(() => {
h( h(
RouterLink, RouterLink,
{ to: "/admin/reaction/list" }, { to: "/admin/reaction/list" },
{ default: () => "题目反馈" }, { default: () => "反馈" },
), ),
key: "admin reaction list", key: "admin reaction list",
}, },
+1
View File
@@ -614,6 +614,7 @@ export interface ReactionStatsRow extends ReactionCounts {
pid: string pid: string
title: string title: string
users: number users: number
last_time: string
} }
export interface Tutorial { export interface Tutorial {