Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f89be3876 | ||
|
|
1fb93e6b16 |
+2
-7
@@ -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,
|
||||||
@@ -1,20 +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 class="reactions">
|
<div v-else>
|
||||||
<n-tooltip v-for="item in REACTIONS" :key="item.key" trigger="hover">
|
<div class="reactions">
|
||||||
<template #trigger>
|
<n-tooltip v-for="item in REACTIONS" :key="item.key" trigger="hover">
|
||||||
<button
|
<template #trigger>
|
||||||
class="reaction-button"
|
<button
|
||||||
:class="{ active: mine.includes(item.key) }"
|
class="reaction-button"
|
||||||
:disabled="isDisabled(item.key)"
|
:class="{ active: selected.includes(item.key) }"
|
||||||
@click="toggle(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>
|
<Icon :icon="item.icon" :width="28" />
|
||||||
</button>
|
<span v-if="counts" class="count">×{{ counts[item.key] }}</span>
|
||||||
</template>
|
</button>
|
||||||
{{ tooltipOf(item.key, item.label) }}
|
</template>
|
||||||
</n-tooltip>
|
{{ 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"
|
||||||
|
>
|
||||||
|
提交评价
|
||||||
|
</n-button>
|
||||||
|
<span class="hint">最多选 {{ MAX_REACTIONS }} 个,提交后不能修改</span>
|
||||||
|
</n-flex>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -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
@@ -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 },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user