merge: 成就系统前端剩余部分
个人主页与排行榜的成就入口、管理后台成就配置页。 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() {
|
||||
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 } })
|
||||
}
|
||||
|
||||
@@ -198,8 +198,9 @@ const columns: DataTableColumn<Rank>[] = [
|
||||
"streamline-emojis:smiling-face-with-sunglasses",
|
||||
),
|
||||
key: "username",
|
||||
width: 200,
|
||||
width: 240,
|
||||
render: (row) =>
|
||||
h("div", { style: "display:flex;align-items:center;gap:6px" }, [
|
||||
h(
|
||||
NButton,
|
||||
{
|
||||
@@ -209,6 +210,18 @@ const columns: DataTableColumn<Rank>[] = [
|
||||
},
|
||||
() => row.user.username,
|
||||
),
|
||||
h(
|
||||
NButton,
|
||||
{
|
||||
text: true,
|
||||
size: "tiny",
|
||||
title: "查看成就",
|
||||
onClick: () =>
|
||||
router.push("/achievement?name=" + row.user.username),
|
||||
},
|
||||
() => "🏆",
|
||||
),
|
||||
]),
|
||||
},
|
||||
{
|
||||
title: renderTableTitle(
|
||||
@@ -378,8 +391,9 @@ const myClassColumns: DataTableColumn<UserRank>[] = [
|
||||
{
|
||||
title: "用户名",
|
||||
key: "username",
|
||||
width: 200,
|
||||
width: 240,
|
||||
render: (row) =>
|
||||
h("div", { style: "display:flex;align-items:center;gap:6px" }, [
|
||||
h(
|
||||
NButton,
|
||||
{
|
||||
@@ -404,6 +418,17 @@ const myClassColumns: DataTableColumn<UserRank>[] = [
|
||||
)
|
||||
: row.username,
|
||||
),
|
||||
h(
|
||||
NButton,
|
||||
{
|
||||
text: true,
|
||||
size: "tiny",
|
||||
title: "查看成就",
|
||||
onClick: () => router.push("/achievement?name=" + row.username),
|
||||
},
|
||||
() => "🏆",
|
||||
),
|
||||
]),
|
||||
},
|
||||
{
|
||||
title: "已解决",
|
||||
|
||||
@@ -4,7 +4,12 @@ import { NH2, NH3 } from "naive-ui"
|
||||
import { getProfile } from "shared/api"
|
||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||
import { durationToDays, parseTime } from "utils/functions"
|
||||
import type { Profile, UserBadge as UserBadgeType } from "utils/types"
|
||||
import type {
|
||||
AchievementSummary,
|
||||
Profile,
|
||||
UserBadge as UserBadgeType,
|
||||
} from "utils/types"
|
||||
import { getAchievementSummary } from "oj/achievement/api"
|
||||
import { getMetrics, getUserBadges } from "../api"
|
||||
import GroupedUserBadge from "shared/components/GroupedUserBadge.vue"
|
||||
import { useUserStore } from "shared/store/user"
|
||||
@@ -19,6 +24,7 @@ const latestSubmissionAt = ref("")
|
||||
const toLatestAt = ref("")
|
||||
const learnDuration = ref("")
|
||||
const userBadges = ref<GroupedBadge[]>([])
|
||||
const achievementSummary = ref<AchievementSummary | null>(null)
|
||||
const [loading, toggle] = useToggle()
|
||||
const [show, toggleShow] = useToggle(false)
|
||||
|
||||
@@ -142,6 +148,19 @@ async function init() {
|
||||
}
|
||||
}
|
||||
|
||||
// 单独取,不塞进上面的 promises 数组:那里是按位置取 results[0]/[1] 的,
|
||||
// 插一项进去会打乱既有索引。成就摘要取不到也不该影响整个个人主页
|
||||
async function loadAchievementSummary() {
|
||||
try {
|
||||
const res = await getAchievementSummary(
|
||||
(route.query.name as string) || undefined,
|
||||
)
|
||||
achievementSummary.value = res.data
|
||||
} catch {
|
||||
achievementSummary.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const metrics = computed(() => {
|
||||
if (loading.value) return []
|
||||
return [
|
||||
@@ -180,7 +199,10 @@ const metrics = computed(() => {
|
||||
]
|
||||
})
|
||||
|
||||
onMounted(init)
|
||||
onMounted(() => {
|
||||
init()
|
||||
loadAchievementSummary()
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<n-flex
|
||||
@@ -244,6 +266,48 @@ onMounted(init)
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
|
||||
<!-- 成就摘要 -->
|
||||
<n-card
|
||||
v-if="!loading && profile && achievementSummary"
|
||||
class="wrapper"
|
||||
hoverable
|
||||
>
|
||||
<n-flex align="center" justify="space-between">
|
||||
<n-flex align="center" :size="12">
|
||||
<span class="achievement-title">
|
||||
成就 {{ achievementSummary.unlocked }} /
|
||||
{{ achievementSummary.total }}
|
||||
</span>
|
||||
<n-tag size="small" type="info">
|
||||
{{ achievementSummary.percent }}%
|
||||
</n-tag>
|
||||
</n-flex>
|
||||
<n-button
|
||||
text
|
||||
type="primary"
|
||||
@click="
|
||||
router.push({
|
||||
path: '/achievement',
|
||||
query: route.query.name ? { name: route.query.name } : {},
|
||||
})
|
||||
"
|
||||
>
|
||||
查看全部
|
||||
</n-button>
|
||||
</n-flex>
|
||||
<n-flex align="center" :size="10" class="achievement-recent">
|
||||
<n-tooltip v-for="a in achievementSummary.recent" :key="a.id">
|
||||
<template #trigger>
|
||||
<span class="achievement-icon">{{ a.icon }}</span>
|
||||
</template>
|
||||
{{ a.name }}
|
||||
</n-tooltip>
|
||||
<n-text v-if="!achievementSummary.recent.length" depth="3">
|
||||
还没有获得成就
|
||||
</n-text>
|
||||
</n-flex>
|
||||
</n-card>
|
||||
|
||||
<!-- 徽章展示卡片 -->
|
||||
<n-card
|
||||
v-if="!loading && profile && userBadges.length > 0"
|
||||
@@ -321,4 +385,14 @@ h2 {
|
||||
word-wrap: break-word;
|
||||
max-width: 100%;
|
||||
}
|
||||
.achievement-title {
|
||||
font-weight: 600;
|
||||
}
|
||||
.achievement-recent {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.achievement-icon {
|
||||
font-size: 24px;
|
||||
cursor: default;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -301,6 +301,14 @@ export const admins: RouteRecordRaw = {
|
||||
component: () => import("admin/problemset/list.vue"),
|
||||
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",
|
||||
name: "admin problemset create",
|
||||
|
||||
@@ -141,6 +141,15 @@ const options = computed<MenuOption[]>(() => {
|
||||
),
|
||||
key: "admin tutorial list",
|
||||
},
|
||||
{
|
||||
label: () =>
|
||||
h(
|
||||
RouterLink,
|
||||
{ to: "/admin/achievement/list" },
|
||||
{ default: () => "成就" },
|
||||
),
|
||||
key: "admin achievement list",
|
||||
},
|
||||
{
|
||||
label: () =>
|
||||
h(
|
||||
|
||||
Reference in New Issue
Block a user