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() {
|
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 } })
|
||||||
|
}
|
||||||
|
|||||||
@@ -198,17 +198,30 @@ const columns: DataTableColumn<Rank>[] = [
|
|||||||
"streamline-emojis:smiling-face-with-sunglasses",
|
"streamline-emojis:smiling-face-with-sunglasses",
|
||||||
),
|
),
|
||||||
key: "username",
|
key: "username",
|
||||||
width: 200,
|
width: 240,
|
||||||
render: (row) =>
|
render: (row) =>
|
||||||
h(
|
h("div", { style: "display:flex;align-items:center;gap:6px" }, [
|
||||||
NButton,
|
h(
|
||||||
{
|
NButton,
|
||||||
text: true,
|
{
|
||||||
type: "info",
|
text: true,
|
||||||
onClick: () => router.push("/user?name=" + row.user.username),
|
type: "info",
|
||||||
},
|
onClick: () => router.push("/user?name=" + row.user.username),
|
||||||
() => row.user.username,
|
},
|
||||||
),
|
() => row.user.username,
|
||||||
|
),
|
||||||
|
h(
|
||||||
|
NButton,
|
||||||
|
{
|
||||||
|
text: true,
|
||||||
|
size: "tiny",
|
||||||
|
title: "查看成就",
|
||||||
|
onClick: () =>
|
||||||
|
router.push("/achievement?name=" + row.user.username),
|
||||||
|
},
|
||||||
|
() => "🏆",
|
||||||
|
),
|
||||||
|
]),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: renderTableTitle(
|
title: renderTableTitle(
|
||||||
@@ -378,32 +391,44 @@ const myClassColumns: DataTableColumn<UserRank>[] = [
|
|||||||
{
|
{
|
||||||
title: "用户名",
|
title: "用户名",
|
||||||
key: "username",
|
key: "username",
|
||||||
width: 200,
|
width: 240,
|
||||||
render: (row) =>
|
render: (row) =>
|
||||||
h(
|
h("div", { style: "display:flex;align-items:center;gap:6px" }, [
|
||||||
NButton,
|
h(
|
||||||
{
|
NButton,
|
||||||
text: true,
|
{
|
||||||
type: "info",
|
text: true,
|
||||||
onClick: () => router.push("/user?name=" + row.username),
|
type: "info",
|
||||||
},
|
onClick: () => router.push("/user?name=" + row.username),
|
||||||
() =>
|
},
|
||||||
row.rank === myRank.value
|
() =>
|
||||||
? h(
|
row.rank === myRank.value
|
||||||
NFlex,
|
? h(
|
||||||
{ align: "flex-end" },
|
NFlex,
|
||||||
{
|
{ align: "flex-end" },
|
||||||
default: () => [
|
{
|
||||||
h("span", {}, row.username),
|
default: () => [
|
||||||
h(Icon, {
|
h("span", {}, row.username),
|
||||||
width: 20,
|
h(Icon, {
|
||||||
icon: "fluent-emoji:person-raising-hand",
|
width: 20,
|
||||||
}),
|
icon: "fluent-emoji:person-raising-hand",
|
||||||
],
|
}),
|
||||||
},
|
],
|
||||||
)
|
},
|
||||||
: row.username,
|
)
|
||||||
),
|
: row.username,
|
||||||
|
),
|
||||||
|
h(
|
||||||
|
NButton,
|
||||||
|
{
|
||||||
|
text: true,
|
||||||
|
size: "tiny",
|
||||||
|
title: "查看成就",
|
||||||
|
onClick: () => router.push("/achievement?name=" + row.username),
|
||||||
|
},
|
||||||
|
() => "🏆",
|
||||||
|
),
|
||||||
|
]),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "已解决",
|
title: "已解决",
|
||||||
|
|||||||
@@ -4,7 +4,12 @@ import { NH2, NH3 } from "naive-ui"
|
|||||||
import { getProfile } from "shared/api"
|
import { getProfile } from "shared/api"
|
||||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||||
import { durationToDays, parseTime } from "utils/functions"
|
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 { getMetrics, getUserBadges } from "../api"
|
||||||
import GroupedUserBadge from "shared/components/GroupedUserBadge.vue"
|
import GroupedUserBadge from "shared/components/GroupedUserBadge.vue"
|
||||||
import { useUserStore } from "shared/store/user"
|
import { useUserStore } from "shared/store/user"
|
||||||
@@ -19,6 +24,7 @@ const latestSubmissionAt = ref("")
|
|||||||
const toLatestAt = ref("")
|
const toLatestAt = ref("")
|
||||||
const learnDuration = ref("")
|
const learnDuration = ref("")
|
||||||
const userBadges = ref<GroupedBadge[]>([])
|
const userBadges = ref<GroupedBadge[]>([])
|
||||||
|
const achievementSummary = ref<AchievementSummary | null>(null)
|
||||||
const [loading, toggle] = useToggle()
|
const [loading, toggle] = useToggle()
|
||||||
const [show, toggleShow] = useToggle(false)
|
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(() => {
|
const metrics = computed(() => {
|
||||||
if (loading.value) return []
|
if (loading.value) return []
|
||||||
return [
|
return [
|
||||||
@@ -180,7 +199,10 @@ const metrics = computed(() => {
|
|||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(init)
|
onMounted(() => {
|
||||||
|
init()
|
||||||
|
loadAchievementSummary()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<n-flex
|
<n-flex
|
||||||
@@ -244,6 +266,48 @@ onMounted(init)
|
|||||||
</n-gi>
|
</n-gi>
|
||||||
</n-grid>
|
</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
|
<n-card
|
||||||
v-if="!loading && profile && userBadges.length > 0"
|
v-if="!loading && profile && userBadges.length > 0"
|
||||||
@@ -321,4 +385,14 @@ h2 {
|
|||||||
word-wrap: break-word;
|
word-wrap: break-word;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
.achievement-title {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.achievement-recent {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
.achievement-icon {
|
||||||
|
font-size: 24px;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -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