feat(后台成就): 成就按指标排成阶梯,一组一行从低到高,每档写解锁人数和占全部学生的比例,下架的变淡;点一档就编辑,删除挪进编辑框
Deploy / deploy (push) Canceled after 0s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-13 14:32:33 +08:00
1 parent c33b084788
commit ee649b81b1
2 files changed
+199 -85

No files matched your search

@@ -6,6 +6,7 @@ import {
updateAchievement,
type AdminAchievement,
type MetricOption,
deleteAchievement,
} from "admin/api"
import AchievementIcon from "shared/components/AchievementIcon.vue"
import { RARITY_LABEL } from "utils/constants"
@@ -20,6 +21,27 @@ const props = defineProps<{
editing: AdminAchievement | null
}>()
const emit = defineEmits<{ "update:show": [boolean]; saved: [] }>()
const dialog = useDialog()
/** 删除放在编辑框左下角(列表改成阶梯之后,每一档上只留「点了就编辑」) */
function confirmDelete() {
const target = props.editing
if (!target) return
dialog.warning({
title: `删除「${target.name}」?`,
content: target.unlockCount
? `${target.unlockCount} 人已经拿到了,删掉会连他们的记录一起删。只是不想再发,关掉「上架」就行。`
: "删掉就找不回来了。",
positiveText: "删除",
negativeText: "不删",
onPositiveClick: async () => {
await deleteAchievement(target.id)
message.success("删掉了")
emit("update:show", false)
emit("saved")
},
})
}
const message = useMessage()
const metrics = ref<MetricOption[]>([])
@@ -42,9 +64,7 @@ function emptyForm() {
const form = ref(emptyForm())
const metricOptions = computed(() =>
metrics.value.map((m) => ({ label: `${m.name}(${m.key})`, value: m.key })),
)
const metricOptions = computed(() => metrics.value.map((m) => ({ label: m.name, value: m.key })))
const metricHelp = computed(
() => metrics.value.find((m) => m.key === form.value.metric)?.helpText ?? "",
@@ -155,6 +175,9 @@ async function save() {
</n-form>
<template #footer>
<n-flex justify="end">
<n-button v-if="editing" quaternary type="error" class="delete" @click="confirmDelete">
删除
</n-button>
<n-button @click="emit('update:show', false)">取消</n-button>
<n-button type="primary" :loading="saving" @click="save"> 保存 </n-button>
</n-flex>
@@ -163,6 +186,10 @@ async function save() {
</template>
<style scoped>
.delete {
margin-right: auto;
}
.icon-preview {
display: flex;
align-items: center;
+169 -82
View File
@@ -1,33 +1,71 @@
<script setup lang="ts">
import PageHeader from "admin/components/PageHeader.vue"
import { NButton, NFlex } from "naive-ui"
import { deleteAchievement, getAdminAchievements, type AdminAchievement } from "admin/api"
import { useThemeVars } from "naive-ui"
import {
getAdminAchievements,
getBaseInfo,
getMetricOptions,
type AdminAchievement,
type MetricOption,
} from "admin/api"
import AchievementIcon from "shared/components/AchievementIcon.vue"
import AchievementModal from "./components/AchievementModal.vue"
import { RARITY_LABEL } from "utils/constants"
const message = useMessage()
const dialog = useDialog()
/**
* 后台成就(设计稿「我的成就重设计」AchAdmin):按指标分组,一组一行排成阶梯 ——
* 「做对的题数」初出茅庐 ≥1 → 小有所成 ≥10 → … → 二百大关 ≥200。每档写名字、门槛、
* 解锁人数和占全部学生的比例;隐藏的写「隐藏」,下架的变淡写「下架了」;点一档就编辑
* (删除在编辑框左下角)。
*
* 原来是一张平铺的表,同一个指标的几档散在各处,看不出阶梯,也看不出哪一档太难没人拿到
*/
const theme = useThemeVars()
const list = ref<AdminAchievement[]>([])
const metrics = ref<MetricOption[]>([])
const students = ref(0)
const loading = ref(false)
const showModal = ref(false)
const editing = ref<AdminAchievement | null>(null)
// 下架的只在有的时候才提,全部上架时标题不啰嗦
const title = computed(() => {
if (!list.value.length) return "成就管理"
const offline = list.value.filter((a) => !a.visible).length
return offline
? `成就管理(${list.value.length} 条,${offline} 条下架)`
: `成就管理(${list.value.length} 条)`
const offline = computed(() => list.value.filter((a) => !a.visible).length)
/** 按指标分组,指标按后端给的顺序(做题 → 坚持 → 比赛题单 → 趣味),组内按门槛从低到高 */
const groups = computed(() => {
const byMetric = new Map<string, AdminAchievement[]>()
for (const a of list.value) byMetric.set(a.metric, [...(byMetric.get(a.metric) ?? []), a])
const order = metrics.value.map((m) => m.key)
return [...byMetric.entries()]
.sort(
([a], [b]) =>
(order.indexOf(a) === -1 ? 99 : order.indexOf(a)) -
(order.indexOf(b) === -1 ? 99 : order.indexOf(b)),
)
.map(([metric, items]) => ({
metric,
name: items[0]?.metricName ?? metric,
items: [...items].sort((x, y) => x.threshold - y.threshold),
}))
})
function percent(a: AdminAchievement) {
if (!students.value) return ""
const value = (a.unlockCount / students.value) * 100
return value > 0 && value < 1 ? "<1%" : `${Math.round(value)}%`
}
async function load() {
loading.value = true
try {
const res = await getAdminAchievements()
const [res, options, info] = await Promise.all([
getAdminAchievements(),
metrics.value.length ? Promise.resolve(metrics.value) : getMetricOptions(),
students.value ? Promise.resolve(null) : getBaseInfo().catch(() => null),
])
list.value = res
metrics.value = options
if (info) students.value = info.site.students
} finally {
loading.value = false
}
@@ -43,85 +81,134 @@ function edit(row: AdminAchievement) {
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,
render: (row) => h(AchievementIcon, { icon: row.icon, size: 24 }),
},
{ title: "名称", key: "name" },
{
title: "稀有度",
key: "rarity",
width: 90,
render: (row) => RARITY_LABEL[row.rarity] ?? row.rarity,
},
{ title: "指标", key: "metricName" },
{
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: "unlockCount", 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>
<PageHeader
:title="title"
description="「已解锁人数」是唯一的仪表盘:配置一周后仍为 0,多半是阈值配错了而不是太难。"
>
<PageHeader title="成就">
<template #extra>
<n-text depth="3">
{{ list.length }} 个<template v-if="offline"> · {{ offline }} 个下架了</template>
</n-text>
</template>
<template #actions>
<n-button type="primary" @click="create">新建成就</n-button>
</template>
</PageHeader>
<n-data-table
:loading="loading"
:data="list"
:columns="columns"
:row-key="(row: AdminAchievement) => row.id"
/>
<n-spin :show="loading">
<div class="groups">
<div v-for="group in groups" :key="group.metric" class="group">
<span class="metric">{{ group.name }}</span>
<div class="steps">
<template v-for="(a, i) in group.items" :key="a.id">
<span v-if="i" class="arrow" aria-hidden="true">→</span>
<button
type="button"
class="step"
:class="{ off: !a.visible }"
:title="`${RARITY_LABEL[a.rarity] ?? a.rarity} · 点了编辑`"
@click="edit(a)"
>
<AchievementIcon :icon="a.icon" :size="26" />
<span class="step-text">
<b>
{{ a.name }}
<span class="muted small"
>{{ a.operator === "gte" ? "≥" : "≤" }} {{ a.threshold }}</span
>
</b>
<span class="small">
{{ a.unlockCount }} 人
<span v-if="percent(a)" class="muted">· {{ percent(a) }}</span>
<span v-if="a.hidden" class="muted"> 隐藏</span>
<span v-if="!a.visible" class="off-note"> 下架了</span>
</span>
</span>
</button>
</template>
</div>
</div>
</div>
</n-spin>
<AchievementModal v-model:show="showModal" :editing="editing" @saved="load" />
</template>
<style scoped>
.groups {
display: flex;
flex-direction: column;
}
.group {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 0;
border-bottom: 1px solid v-bind("theme.dividerColor");
}
.metric {
width: 150px;
flex: none;
font-size: 14px;
}
.steps {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.arrow {
color: v-bind("theme.textColor3");
}
.step {
display: inline-flex;
align-items: center;
gap: 8px;
min-width: 160px;
padding: 6px 10px;
border-radius: 8px;
border: 1px solid v-bind("theme.dividerColor");
background: v-bind("theme.cardColor");
font: inherit;
color: inherit;
text-align: left;
cursor: pointer;
}
.step:hover {
border-color: v-bind("theme.primaryColor");
}
.step.off {
opacity: 0.5;
}
.step-text {
display: flex;
flex-direction: column;
}
.step-text b {
font-size: 14px;
}
.small {
font-size: 12px;
font-weight: 400;
font-variant-numeric: tabular-nums;
}
.muted {
color: v-bind("theme.textColor3");
}
.off-note {
color: v-bind("theme.errorColor");
}
</style>