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,
|
updateAchievement,
|
||||||
type AdminAchievement,
|
type AdminAchievement,
|
||||||
type MetricOption,
|
type MetricOption,
|
||||||
|
deleteAchievement,
|
||||||
} from "admin/api"
|
} from "admin/api"
|
||||||
import AchievementIcon from "shared/components/AchievementIcon.vue"
|
import AchievementIcon from "shared/components/AchievementIcon.vue"
|
||||||
import { RARITY_LABEL } from "utils/constants"
|
import { RARITY_LABEL } from "utils/constants"
|
||||||
@@ -20,6 +21,27 @@ const props = defineProps<{
|
|||||||
editing: AdminAchievement | null
|
editing: AdminAchievement | null
|
||||||
}>()
|
}>()
|
||||||
const emit = defineEmits<{ "update:show": [boolean]; saved: [] }>()
|
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 message = useMessage()
|
||||||
const metrics = ref<MetricOption[]>([])
|
const metrics = ref<MetricOption[]>([])
|
||||||
@@ -42,9 +64,7 @@ function emptyForm() {
|
|||||||
|
|
||||||
const form = ref(emptyForm())
|
const form = ref(emptyForm())
|
||||||
|
|
||||||
const metricOptions = computed(() =>
|
const metricOptions = computed(() => metrics.value.map((m) => ({ label: m.name, value: m.key })))
|
||||||
metrics.value.map((m) => ({ label: `${m.name}(${m.key})`, value: m.key })),
|
|
||||||
)
|
|
||||||
|
|
||||||
const metricHelp = computed(
|
const metricHelp = computed(
|
||||||
() => metrics.value.find((m) => m.key === form.value.metric)?.helpText ?? "",
|
() => metrics.value.find((m) => m.key === form.value.metric)?.helpText ?? "",
|
||||||
@@ -155,6 +175,9 @@ async function save() {
|
|||||||
</n-form>
|
</n-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<n-flex justify="end">
|
<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 @click="emit('update:show', false)">取消</n-button>
|
||||||
<n-button type="primary" :loading="saving" @click="save"> 保存 </n-button>
|
<n-button type="primary" :loading="saving" @click="save"> 保存 </n-button>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
@@ -163,6 +186,10 @@ async function save() {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.delete {
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.icon-preview {
|
.icon-preview {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -1,33 +1,71 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import PageHeader from "admin/components/PageHeader.vue"
|
import PageHeader from "admin/components/PageHeader.vue"
|
||||||
import { NButton, NFlex } from "naive-ui"
|
import { useThemeVars } from "naive-ui"
|
||||||
import { deleteAchievement, getAdminAchievements, type AdminAchievement } from "admin/api"
|
import {
|
||||||
|
getAdminAchievements,
|
||||||
|
getBaseInfo,
|
||||||
|
getMetricOptions,
|
||||||
|
type AdminAchievement,
|
||||||
|
type MetricOption,
|
||||||
|
} from "admin/api"
|
||||||
import AchievementIcon from "shared/components/AchievementIcon.vue"
|
import AchievementIcon from "shared/components/AchievementIcon.vue"
|
||||||
import AchievementModal from "./components/AchievementModal.vue"
|
import AchievementModal from "./components/AchievementModal.vue"
|
||||||
import { RARITY_LABEL } from "utils/constants"
|
import { RARITY_LABEL } from "utils/constants"
|
||||||
|
|
||||||
const message = useMessage()
|
/**
|
||||||
const dialog = useDialog()
|
* 后台成就(设计稿「我的成就重设计」AchAdmin):按指标分组,一组一行排成阶梯 ——
|
||||||
|
* 「做对的题数」初出茅庐 ≥1 → 小有所成 ≥10 → … → 二百大关 ≥200。每档写名字、门槛、
|
||||||
|
* 解锁人数和占全部学生的比例;隐藏的写「隐藏」,下架的变淡写「下架了」;点一档就编辑
|
||||||
|
* (删除在编辑框左下角)。
|
||||||
|
*
|
||||||
|
* 原来是一张平铺的表,同一个指标的几档散在各处,看不出阶梯,也看不出哪一档太难没人拿到
|
||||||
|
*/
|
||||||
|
const theme = useThemeVars()
|
||||||
|
|
||||||
const list = ref<AdminAchievement[]>([])
|
const list = ref<AdminAchievement[]>([])
|
||||||
|
const metrics = ref<MetricOption[]>([])
|
||||||
|
const students = ref(0)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const showModal = ref(false)
|
const showModal = ref(false)
|
||||||
const editing = ref<AdminAchievement | null>(null)
|
const editing = ref<AdminAchievement | null>(null)
|
||||||
|
|
||||||
// 下架的只在有的时候才提,全部上架时标题不啰嗦
|
const offline = computed(() => list.value.filter((a) => !a.visible).length)
|
||||||
const title = computed(() => {
|
|
||||||
if (!list.value.length) return "成就管理"
|
/** 按指标分组,指标按后端给的顺序(做题 → 坚持 → 比赛题单 → 趣味),组内按门槛从低到高 */
|
||||||
const offline = list.value.filter((a) => !a.visible).length
|
const groups = computed(() => {
|
||||||
return offline
|
const byMetric = new Map<string, AdminAchievement[]>()
|
||||||
? `成就管理(${list.value.length} 条,${offline} 条下架)`
|
for (const a of list.value) byMetric.set(a.metric, [...(byMetric.get(a.metric) ?? []), a])
|
||||||
: `成就管理(${list.value.length} 条)`
|
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() {
|
async function load() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
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
|
list.value = res
|
||||||
|
metrics.value = options
|
||||||
|
if (info) students.value = info.site.students
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
@@ -43,85 +81,134 @@ function edit(row: AdminAchievement) {
|
|||||||
showModal.value = true
|
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)
|
onMounted(load)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<PageHeader
|
<PageHeader title="成就">
|
||||||
:title="title"
|
<template #extra>
|
||||||
description="「已解锁人数」是唯一的仪表盘:配置一周后仍为 0,多半是阈值配错了而不是太难。"
|
<n-text depth="3">
|
||||||
>
|
{{ list.length }} 个<template v-if="offline"> · {{ offline }} 个下架了</template>
|
||||||
|
</n-text>
|
||||||
|
</template>
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<n-button type="primary" @click="create">新建成就</n-button>
|
<n-button type="primary" @click="create">新建成就</n-button>
|
||||||
</template>
|
</template>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
<n-data-table
|
<n-spin :show="loading">
|
||||||
:loading="loading"
|
<div class="groups">
|
||||||
:data="list"
|
<div v-for="group in groups" :key="group.metric" class="group">
|
||||||
:columns="columns"
|
<span class="metric">{{ group.name }}</span>
|
||||||
:row-key="(row: AdminAchievement) => row.id"
|
<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" />
|
<AchievementModal v-model:show="showModal" :editing="editing" @saved="load" />
|
||||||
</template>
|
</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