Files
OJ2/apps/web/src/admin/achievement/list.vue
T
xuyueandClaude Opus 5.5 1b703d13ae
Deploy / deploy (push) Canceled after 0s
style(专题): 「题单」改名「专题」,顶栏、列表、详情、后台、成就说法统一
只改界面文字和注释:网址 /problemset、接口、表名、字段名都不动,老链接照常能打开。
设计稿页名「题单重设计」和已执行的迁移文件保留原样。
线上 3 个成就(题单收藏家 / 入门 / 通关)已同步改名。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 00:12:18 +08:00

230 lines
6.0 KiB
Vue

<script setup lang="ts">
import PageHeader from 'admin/components/PageHeader.vue'
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'
/**
* 后台成就(设计稿「我的成就重设计」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 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, 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
}
}
function create() {
editing.value = null
showModal.value = true
}
function edit(row: AdminAchievement) {
editing.value = row
showModal.value = true
}
onMounted(load)
</script>
<template>
<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-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:nth-child(even) {
background: v-bind('theme.tableColorStriped');
}
.group {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 10px;
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');
}
/* 手机上指标名放在一组上头,下面两档一行;箭头拿掉(折行之后箭头会单独落在行首) */
@media (max-width: 768px) {
.group {
flex-direction: column;
align-items: stretch;
gap: 8px;
padding: 12px 10px;
}
.metric {
width: auto;
font-weight: 600;
}
.steps {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.arrow {
display: none;
}
.step {
min-width: 0;
}
}
</style>