feat(后台成就): 成就按指标排成阶梯,一组一行从低到高,每档写解锁人数和占全部学生的比例,下架的变淡;点一档就编辑,删除挪进编辑框
Deploy / deploy (push) Canceled after 0s
Deploy / deploy (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
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;
|
||||
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user