feat(reaction): 后台题目反馈统计页

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-06 03:42:25 -06:00
parent e153064d87
commit a8cc7c855a
3 changed files with 120 additions and 6 deletions

View File

@@ -0,0 +1,114 @@
<template>
<n-flex justify="space-between" class="titleWrapper">
<h2 class="title">题目反馈统计</h2>
<div>
<n-input
v-model:value="query.problem"
clearable
placeholder="输入题目序号"
/>
</div>
</n-flex>
<n-data-table
striped
:columns="columns"
:data="rows"
:scroll-x="1100"
remote
@update:sorter="handleSorter"
/>
<Pagination
:total="total"
v-model:limit="query.limit"
v-model:page="query.page"
/>
</template>
<script lang="ts" setup>
import { Icon } from "@iconify/vue"
import { NButton, NFlex } from "naive-ui"
import Pagination from "shared/components/Pagination.vue"
import { REACTIONS } from "utils/constants"
import type { ReactionStatsRow } from "utils/types"
import { getReactionStats } from "../api"
const rows = ref<ReactionStatsRow[]>([])
const total = ref(0)
const query = reactive({
limit: 10,
page: 1,
problem: "",
ordering: "-users",
})
const columns: DataTableColumn<ReactionStatsRow>[] = [
{
title: "题目",
key: "pid",
width: 100,
fixed: "left",
render: (row) =>
h(
NButton,
{
text: true,
type: "info",
onClick: () => window.open("/problem/" + row.pid, "_blank"),
},
() => row.pid,
),
},
{ title: "标题", key: "title", minWidth: 180, ellipsis: true },
{ title: "表态人数", key: "users", width: 110, sorter: true },
...REACTIONS.map((item) => ({
title: () =>
h(NFlex, { align: "center", size: 4, wrap: false }, () => [
h(Icon, { icon: item.icon, width: 18 }),
item.label,
]),
key: item.key,
width: 110,
sorter: true,
})),
]
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
listStats()
}
async function listStats() {
const offset = (query.page - 1) * query.limit
const res = await getReactionStats(
offset,
query.limit,
query.problem,
query.ordering,
)
rows.value = res.data.results
total.value = res.data.total
}
onMounted(listStats)
watch(() => [query.page, query.limit], listStats)
watchDebounced(() => query.problem, listStats, {
debounce: 500,
maxWait: 1000,
})
</script>
<style scoped>
.titleWrapper {
margin-bottom: 16px;
}
.title {
margin: 0;
}
</style>

View File

@@ -258,9 +258,9 @@ export const admins: RouteRecordRaw = {
meta: { requiresSuperAdmin: true },
},
{
path: "comment/list",
name: "admin comment list",
component: () => import("admin/communication/comments.vue"),
path: "reaction/list",
name: "admin reaction list",
component: () => import("admin/communication/reactions.vue"),
meta: { requiresSuperAdmin: true },
},
{

View File

@@ -118,10 +118,10 @@ const options = computed<MenuOption[]>(() => {
label: () =>
h(
RouterLink,
{ to: "/admin/comment/list" },
{ default: () => "评论" },
{ to: "/admin/reaction/list" },
{ default: () => "题目反馈" },
),
key: "admin comment list",
key: "admin reaction list",
},
{
label: () =>