feat(achievement): 添加管理后台成就配置页
指标下拉框由后端 METRIC_REGISTRY 动态提供,选中后显示该指标的说明。 列表显示已解锁人数——阈值配错时学生永远拿不到也永远不会来问, 这个计数器是唯一的仪表盘。 API 追加到 src/admin/api.ts(仓库的 admin 接口集中在这一个文件里), 路由 meta 用 requiresSuperAdmin 以匹配后端的 super_admin_required。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
160
src/admin/achievement/components/AchievementModal.vue
Normal file
160
src/admin/achievement/components/AchievementModal.vue
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
createAchievement,
|
||||||
|
getMetricOptions,
|
||||||
|
updateAchievement,
|
||||||
|
type AdminAchievement,
|
||||||
|
type MetricOption,
|
||||||
|
} from "admin/api"
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
show: boolean
|
||||||
|
editing: AdminAchievement | null
|
||||||
|
}>()
|
||||||
|
const emit = defineEmits<{ "update:show": [boolean]; saved: [] }>()
|
||||||
|
|
||||||
|
const message = useMessage()
|
||||||
|
const metrics = ref<MetricOption[]>([])
|
||||||
|
const saving = ref(false)
|
||||||
|
|
||||||
|
function emptyForm() {
|
||||||
|
return {
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
icon: "🏆",
|
||||||
|
rarity: "bronze",
|
||||||
|
hidden: false,
|
||||||
|
metric: "",
|
||||||
|
operator: "gte" as "gte" | "lte",
|
||||||
|
threshold: 1,
|
||||||
|
visible: true,
|
||||||
|
order: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const form = ref(emptyForm())
|
||||||
|
|
||||||
|
const metricOptions = computed(() =>
|
||||||
|
metrics.value.map((m) => ({ label: `${m.name}(${m.key})`, value: m.key })),
|
||||||
|
)
|
||||||
|
|
||||||
|
const metricHelp = computed(
|
||||||
|
() => metrics.value.find((m) => m.key === form.value.metric)?.help_text ?? "",
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.show,
|
||||||
|
async (show) => {
|
||||||
|
if (!show) return
|
||||||
|
if (!metrics.value.length) {
|
||||||
|
const res = await getMetricOptions()
|
||||||
|
metrics.value = res.data
|
||||||
|
}
|
||||||
|
if (props.editing) {
|
||||||
|
form.value = { ...emptyForm(), ...props.editing }
|
||||||
|
} else {
|
||||||
|
form.value = { ...emptyForm(), metric: metrics.value[0]?.key ?? "" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (!form.value.name || !form.value.metric) {
|
||||||
|
message.error("名称和指标不能为空")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
if (props.editing) {
|
||||||
|
await updateAchievement({ ...form.value, id: props.editing.id })
|
||||||
|
} else {
|
||||||
|
await createAchievement(form.value)
|
||||||
|
}
|
||||||
|
message.success("保存成功")
|
||||||
|
emit("update:show", false)
|
||||||
|
emit("saved")
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<n-modal
|
||||||
|
:show="show"
|
||||||
|
preset="card"
|
||||||
|
style="width: 560px"
|
||||||
|
:title="editing ? '编辑成就' : '新建成就'"
|
||||||
|
@update:show="emit('update:show', $event)"
|
||||||
|
>
|
||||||
|
<n-form label-placement="left" :label-width="80">
|
||||||
|
<n-form-item label="名称" required>
|
||||||
|
<n-input v-model:value="form.name" placeholder="成就名称" />
|
||||||
|
</n-form-item>
|
||||||
|
<n-form-item label="描述" required>
|
||||||
|
<n-input
|
||||||
|
v-model:value="form.description"
|
||||||
|
type="textarea"
|
||||||
|
placeholder="达成条件的描述,展示给学生看"
|
||||||
|
/>
|
||||||
|
</n-form-item>
|
||||||
|
<n-form-item label="图标">
|
||||||
|
<n-input v-model:value="form.icon" placeholder="emoji,例如 🦉" />
|
||||||
|
</n-form-item>
|
||||||
|
<n-form-item label="稀有度">
|
||||||
|
<n-select
|
||||||
|
v-model:value="form.rarity"
|
||||||
|
:options="[
|
||||||
|
{ label: '青铜', value: 'bronze' },
|
||||||
|
{ label: '白银', value: 'silver' },
|
||||||
|
{ label: '黄金', value: 'gold' },
|
||||||
|
{ label: '白金', value: 'platinum' },
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
</n-form-item>
|
||||||
|
<n-form-item label="指标" required>
|
||||||
|
<n-select
|
||||||
|
v-model:value="form.metric"
|
||||||
|
:options="metricOptions"
|
||||||
|
filterable
|
||||||
|
/>
|
||||||
|
</n-form-item>
|
||||||
|
<n-form-item v-if="metricHelp" label=" ">
|
||||||
|
<n-text depth="3">{{ metricHelp }}</n-text>
|
||||||
|
</n-form-item>
|
||||||
|
<n-form-item label="条件">
|
||||||
|
<n-flex align="center">
|
||||||
|
<n-select
|
||||||
|
v-model:value="form.operator"
|
||||||
|
style="width: 130px"
|
||||||
|
:options="[
|
||||||
|
{ label: '大于等于', value: 'gte' },
|
||||||
|
{ label: '小于等于', value: 'lte' },
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
<n-input-number v-model:value="form.threshold" :min="0" />
|
||||||
|
</n-flex>
|
||||||
|
</n-form-item>
|
||||||
|
<n-form-item label="隐藏成就">
|
||||||
|
<n-switch v-model:value="form.hidden" />
|
||||||
|
<n-text depth="3" style="margin-left: 12px">
|
||||||
|
未解锁时学生只能看到 ???
|
||||||
|
</n-text>
|
||||||
|
</n-form-item>
|
||||||
|
<n-form-item label="上架">
|
||||||
|
<n-switch v-model:value="form.visible" />
|
||||||
|
</n-form-item>
|
||||||
|
<n-form-item label="排序">
|
||||||
|
<n-input-number v-model:value="form.order" />
|
||||||
|
</n-form-item>
|
||||||
|
</n-form>
|
||||||
|
<template #footer>
|
||||||
|
<n-flex justify="end">
|
||||||
|
<n-button @click="emit('update:show', false)">取消</n-button>
|
||||||
|
<n-button type="primary" :loading="saving" @click="save">
|
||||||
|
保存
|
||||||
|
</n-button>
|
||||||
|
</n-flex>
|
||||||
|
</template>
|
||||||
|
</n-modal>
|
||||||
|
</template>
|
||||||
135
src/admin/achievement/list.vue
Normal file
135
src/admin/achievement/list.vue
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { NButton, NFlex } from "naive-ui"
|
||||||
|
import {
|
||||||
|
deleteAchievement,
|
||||||
|
getAdminAchievements,
|
||||||
|
type AdminAchievement,
|
||||||
|
} from "admin/api"
|
||||||
|
import AchievementModal from "./components/AchievementModal.vue"
|
||||||
|
|
||||||
|
const message = useMessage()
|
||||||
|
const dialog = useDialog()
|
||||||
|
|
||||||
|
const list = ref<AdminAchievement[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const showModal = ref(false)
|
||||||
|
const editing = ref<AdminAchievement | null>(null)
|
||||||
|
|
||||||
|
const RARITY_LABEL: Record<string, string> = {
|
||||||
|
bronze: "青铜",
|
||||||
|
silver: "白银",
|
||||||
|
gold: "黄金",
|
||||||
|
platinum: "白金",
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await getAdminAchievements()
|
||||||
|
list.value = res.data
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function create() {
|
||||||
|
editing.value = null
|
||||||
|
showModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function edit(row: AdminAchievement) {
|
||||||
|
editing.value = row
|
||||||
|
showModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function remove(row: AdminAchievement) {
|
||||||
|
dialog.warning({
|
||||||
|
title: "删除成就",
|
||||||
|
content: `确定删除「${row.name}」?已解锁记录会一并删除。`,
|
||||||
|
positiveText: "删除",
|
||||||
|
negativeText: "取消",
|
||||||
|
onPositiveClick: async () => {
|
||||||
|
await deleteAchievement(row.id)
|
||||||
|
message.success("已删除")
|
||||||
|
load()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: DataTableColumn<AdminAchievement>[] = [
|
||||||
|
{ title: "图标", key: "icon", width: 60 },
|
||||||
|
{ title: "名称", key: "name" },
|
||||||
|
{
|
||||||
|
title: "稀有度",
|
||||||
|
key: "rarity",
|
||||||
|
width: 90,
|
||||||
|
render: (row) => RARITY_LABEL[row.rarity] ?? row.rarity,
|
||||||
|
},
|
||||||
|
{ title: "指标", key: "metric_name" },
|
||||||
|
{
|
||||||
|
title: "条件",
|
||||||
|
key: "threshold",
|
||||||
|
width: 110,
|
||||||
|
render: (row) => `${row.operator === "gte" ? "≥" : "≤"} ${row.threshold}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "隐藏",
|
||||||
|
key: "hidden",
|
||||||
|
width: 70,
|
||||||
|
render: (row) => (row.hidden ? "是" : "—"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "上架",
|
||||||
|
key: "visible",
|
||||||
|
width: 70,
|
||||||
|
render: (row) => (row.visible ? "是" : "否"),
|
||||||
|
},
|
||||||
|
{ title: "已解锁人数", key: "unlock_count", width: 110 },
|
||||||
|
{
|
||||||
|
title: "操作",
|
||||||
|
key: "actions",
|
||||||
|
width: 130,
|
||||||
|
render: (row) =>
|
||||||
|
h(NFlex, { size: 8 }, () => [
|
||||||
|
h(
|
||||||
|
NButton,
|
||||||
|
{ text: true, type: "primary", onClick: () => edit(row) },
|
||||||
|
() => "编辑",
|
||||||
|
),
|
||||||
|
h(
|
||||||
|
NButton,
|
||||||
|
{ text: true, type: "error", onClick: () => remove(row) },
|
||||||
|
() => "删除",
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<n-card title="成就管理">
|
||||||
|
<template #header-extra>
|
||||||
|
<n-button type="primary" @click="create">新建成就</n-button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<n-alert type="info" style="margin-bottom: 12px">
|
||||||
|
「已解锁人数」是唯一的仪表盘:配置一周后仍为
|
||||||
|
0,多半是阈值配错了而不是太难。
|
||||||
|
</n-alert>
|
||||||
|
|
||||||
|
<n-data-table
|
||||||
|
:loading="loading"
|
||||||
|
:data="list"
|
||||||
|
:columns="columns"
|
||||||
|
:row-key="(row: AdminAchievement) => row.id"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AchievementModal
|
||||||
|
v-model:show="showModal"
|
||||||
|
:editing="editing"
|
||||||
|
@saved="load"
|
||||||
|
/>
|
||||||
|
</n-card>
|
||||||
|
</template>
|
||||||
@@ -531,3 +531,48 @@ export function pinAIReport(id: number) {
|
|||||||
export function getPinnedAIReports() {
|
export function getPinnedAIReports() {
|
||||||
return http.get("admin/ai/reports", { params: { pinned_only: "true" } })
|
return http.get("admin/ai/reports", { params: { pinned_only: "true" } })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== 成就 ====================
|
||||||
|
|
||||||
|
export interface AdminAchievement {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
icon: string
|
||||||
|
rarity: string
|
||||||
|
hidden: boolean
|
||||||
|
metric: string
|
||||||
|
metric_name: string
|
||||||
|
operator: "gte" | "lte"
|
||||||
|
threshold: number
|
||||||
|
visible: boolean
|
||||||
|
unlock_count: number
|
||||||
|
order: number
|
||||||
|
create_time: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MetricOption {
|
||||||
|
key: string
|
||||||
|
name: string
|
||||||
|
help_text: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAdminAchievements() {
|
||||||
|
return http.get<AdminAchievement[]>("admin/achievement")
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMetricOptions() {
|
||||||
|
return http.get<MetricOption[]>("admin/achievement/metrics")
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAchievement(data: Partial<AdminAchievement>) {
|
||||||
|
return http.post<AdminAchievement>("admin/achievement", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateAchievement(data: Partial<AdminAchievement>) {
|
||||||
|
return http.put<AdminAchievement>("admin/achievement", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteAchievement(id: number) {
|
||||||
|
return http.delete("admin/achievement", { params: { id } })
|
||||||
|
}
|
||||||
|
|||||||
@@ -301,6 +301,14 @@ export const admins: RouteRecordRaw = {
|
|||||||
component: () => import("admin/problemset/list.vue"),
|
component: () => import("admin/problemset/list.vue"),
|
||||||
meta: { requiresTeacherAdmin: true },
|
meta: { requiresTeacherAdmin: true },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "achievement/list",
|
||||||
|
name: "admin achievement list",
|
||||||
|
// 后端 admin 接口是 super_admin_required,这里必须跟着用
|
||||||
|
// requiresSuperAdmin,否则教师能进页面但每个请求都 403
|
||||||
|
component: () => import("admin/achievement/list.vue"),
|
||||||
|
meta: { requiresSuperAdmin: true },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "problemset/create",
|
path: "problemset/create",
|
||||||
name: "admin problemset create",
|
name: "admin problemset create",
|
||||||
|
|||||||
@@ -141,6 +141,15 @@ const options = computed<MenuOption[]>(() => {
|
|||||||
),
|
),
|
||||||
key: "admin tutorial list",
|
key: "admin tutorial list",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: () =>
|
||||||
|
h(
|
||||||
|
RouterLink,
|
||||||
|
{ to: "/admin/achievement/list" },
|
||||||
|
{ default: () => "成就" },
|
||||||
|
),
|
||||||
|
key: "admin achievement list",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: () =>
|
label: () =>
|
||||||
h(
|
h(
|
||||||
|
|||||||
Reference in New Issue
Block a user