diff --git a/src/admin/achievement/components/AchievementModal.vue b/src/admin/achievement/components/AchievementModal.vue
new file mode 100644
index 0000000..bab15b1
--- /dev/null
+++ b/src/admin/achievement/components/AchievementModal.vue
@@ -0,0 +1,160 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ metricHelp }}
+
+
+
+
+
+
+
+
+
+
+ 未解锁时学生只能看到 ???
+
+
+
+
+
+
+
+
+
+
+
+ 取消
+
+ 保存
+
+
+
+
+
diff --git a/src/admin/achievement/list.vue b/src/admin/achievement/list.vue
new file mode 100644
index 0000000..6c1f2f7
--- /dev/null
+++ b/src/admin/achievement/list.vue
@@ -0,0 +1,135 @@
+
+
+
+
+
+ 新建成就
+
+
+
+ 「已解锁人数」是唯一的仪表盘:配置一周后仍为
+ 0,多半是阈值配错了而不是太难。
+
+
+
+
+
+
+
diff --git a/src/admin/api.ts b/src/admin/api.ts
index 2d8f7c8..fe34e5b 100644
--- a/src/admin/api.ts
+++ b/src/admin/api.ts
@@ -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("admin/achievement")
+}
+
+export function getMetricOptions() {
+ return http.get("admin/achievement/metrics")
+}
+
+export function createAchievement(data: Partial) {
+ return http.post("admin/achievement", data)
+}
+
+export function updateAchievement(data: Partial) {
+ return http.put("admin/achievement", data)
+}
+
+export function deleteAchievement(id: number) {
+ return http.delete("admin/achievement", { params: { id } })
+}
diff --git a/src/oj/rank/list.vue b/src/oj/rank/list.vue
index d911614..0b94a7e 100644
--- a/src/oj/rank/list.vue
+++ b/src/oj/rank/list.vue
@@ -198,17 +198,30 @@ const columns: DataTableColumn[] = [
"streamline-emojis:smiling-face-with-sunglasses",
),
key: "username",
- width: 200,
+ width: 240,
render: (row) =>
- h(
- NButton,
- {
- text: true,
- type: "info",
- onClick: () => router.push("/user?name=" + row.user.username),
- },
- () => row.user.username,
- ),
+ h("div", { style: "display:flex;align-items:center;gap:6px" }, [
+ h(
+ NButton,
+ {
+ text: true,
+ type: "info",
+ onClick: () => router.push("/user?name=" + row.user.username),
+ },
+ () => row.user.username,
+ ),
+ h(
+ NButton,
+ {
+ text: true,
+ size: "tiny",
+ title: "查看成就",
+ onClick: () =>
+ router.push("/achievement?name=" + row.user.username),
+ },
+ () => "🏆",
+ ),
+ ]),
},
{
title: renderTableTitle(
@@ -378,32 +391,44 @@ const myClassColumns: DataTableColumn[] = [
{
title: "用户名",
key: "username",
- width: 200,
+ width: 240,
render: (row) =>
- h(
- NButton,
- {
- text: true,
- type: "info",
- onClick: () => router.push("/user?name=" + row.username),
- },
- () =>
- row.rank === myRank.value
- ? h(
- NFlex,
- { align: "flex-end" },
- {
- default: () => [
- h("span", {}, row.username),
- h(Icon, {
- width: 20,
- icon: "fluent-emoji:person-raising-hand",
- }),
- ],
- },
- )
- : row.username,
- ),
+ h("div", { style: "display:flex;align-items:center;gap:6px" }, [
+ h(
+ NButton,
+ {
+ text: true,
+ type: "info",
+ onClick: () => router.push("/user?name=" + row.username),
+ },
+ () =>
+ row.rank === myRank.value
+ ? h(
+ NFlex,
+ { align: "flex-end" },
+ {
+ default: () => [
+ h("span", {}, row.username),
+ h(Icon, {
+ width: 20,
+ icon: "fluent-emoji:person-raising-hand",
+ }),
+ ],
+ },
+ )
+ : row.username,
+ ),
+ h(
+ NButton,
+ {
+ text: true,
+ size: "tiny",
+ title: "查看成就",
+ onClick: () => router.push("/achievement?name=" + row.username),
+ },
+ () => "🏆",
+ ),
+ ]),
},
{
title: "已解决",
diff --git a/src/oj/user/index.vue b/src/oj/user/index.vue
index 55ba92d..b9c2588 100644
--- a/src/oj/user/index.vue
+++ b/src/oj/user/index.vue
@@ -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([])
+const achievementSummary = ref(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()
+})
+
+
+
+
+
+ 成就 {{ achievementSummary.unlocked }} /
+ {{ achievementSummary.total }}
+
+
+ {{ achievementSummary.percent }}%
+
+
+
+ 查看全部
+
+
+
+
+
+ {{ a.icon }}
+
+ {{ a.name }}
+
+
+ 还没有获得成就
+
+
+
+
diff --git a/src/routes.ts b/src/routes.ts
index 067f28e..8f8f383 100644
--- a/src/routes.ts
+++ b/src/routes.ts
@@ -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",
diff --git a/src/shared/layout/admin.vue b/src/shared/layout/admin.vue
index 1923e46..bf9d9e0 100644
--- a/src/shared/layout/admin.vue
+++ b/src/shared/layout/admin.vue
@@ -141,6 +141,15 @@ const options = computed(() => {
),
key: "admin tutorial list",
},
+ {
+ label: () =>
+ h(
+ RouterLink,
+ { to: "/admin/achievement/list" },
+ { default: () => "成就" },
+ ),
+ key: "admin achievement list",
+ },
{
label: () =>
h(