Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ba25dc4e3 | ||
|
|
7e498a1c1c | ||
|
|
700559557d | ||
|
|
a9278589c9 | ||
|
|
d31bfd8b8f | ||
|
|
64a770ff62 | ||
|
|
d7d80e1c1b | ||
|
|
fe7095bd74 | ||
|
|
d81e00ee14 | ||
|
|
71186f0ca5 |
@@ -0,0 +1,160 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
createAchievement,
|
||||
getMetricOptions,
|
||||
updateAchievement,
|
||||
type AdminAchievement,
|
||||
type MetricOption,
|
||||
} from "admin/api"
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
editing: AdminAchievement | null
|
||||
}>()
|
||||
const emit = defineEmits<{ "update:show": [boolean]; saved: [] }>()
|
||||
|
||||
const message = useMessage()
|
||||
const metrics = ref<MetricOption[]>([])
|
||||
const saving = ref(false)
|
||||
|
||||
function emptyForm() {
|
||||
return {
|
||||
name: "",
|
||||
description: "",
|
||||
icon: "🏆",
|
||||
rarity: "bronze",
|
||||
hidden: false,
|
||||
metric: "",
|
||||
operator: "gte" as "gte" | "lte",
|
||||
threshold: 1,
|
||||
visible: true,
|
||||
order: 0,
|
||||
}
|
||||
}
|
||||
|
||||
const form = ref(emptyForm())
|
||||
|
||||
const metricOptions = computed(() =>
|
||||
metrics.value.map((m) => ({ label: `${m.name}(${m.key})`, value: m.key })),
|
||||
)
|
||||
|
||||
const metricHelp = computed(
|
||||
() => metrics.value.find((m) => m.key === form.value.metric)?.help_text ?? "",
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
async (show) => {
|
||||
if (!show) return
|
||||
if (!metrics.value.length) {
|
||||
const res = await getMetricOptions()
|
||||
metrics.value = res.data
|
||||
}
|
||||
if (props.editing) {
|
||||
form.value = { ...emptyForm(), ...props.editing }
|
||||
} else {
|
||||
form.value = { ...emptyForm(), metric: metrics.value[0]?.key ?? "" }
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
async function save() {
|
||||
if (!form.value.name || !form.value.metric) {
|
||||
message.error("名称和指标不能为空")
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
if (props.editing) {
|
||||
await updateAchievement({ ...form.value, id: props.editing.id })
|
||||
} else {
|
||||
await createAchievement(form.value)
|
||||
}
|
||||
message.success("保存成功")
|
||||
emit("update:show", false)
|
||||
emit("saved")
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-modal
|
||||
:show="show"
|
||||
preset="card"
|
||||
style="width: 560px"
|
||||
:title="editing ? '编辑成就' : '新建成就'"
|
||||
@update:show="emit('update:show', $event)"
|
||||
>
|
||||
<n-form label-placement="left" :label-width="80">
|
||||
<n-form-item label="名称" required>
|
||||
<n-input v-model:value="form.name" placeholder="成就名称" />
|
||||
</n-form-item>
|
||||
<n-form-item label="描述" required>
|
||||
<n-input
|
||||
v-model:value="form.description"
|
||||
type="textarea"
|
||||
placeholder="达成条件的描述,展示给学生看"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="图标">
|
||||
<n-input v-model:value="form.icon" placeholder="emoji,例如 🦉" />
|
||||
</n-form-item>
|
||||
<n-form-item label="稀有度">
|
||||
<n-select
|
||||
v-model:value="form.rarity"
|
||||
:options="[
|
||||
{ label: '青铜', value: 'bronze' },
|
||||
{ label: '白银', value: 'silver' },
|
||||
{ label: '黄金', value: 'gold' },
|
||||
{ label: '白金', value: 'platinum' },
|
||||
]"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="指标" required>
|
||||
<n-select
|
||||
v-model:value="form.metric"
|
||||
:options="metricOptions"
|
||||
filterable
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item v-if="metricHelp" label=" ">
|
||||
<n-text depth="3">{{ metricHelp }}</n-text>
|
||||
</n-form-item>
|
||||
<n-form-item label="条件">
|
||||
<n-flex align="center">
|
||||
<n-select
|
||||
v-model:value="form.operator"
|
||||
style="width: 130px"
|
||||
:options="[
|
||||
{ label: '大于等于', value: 'gte' },
|
||||
{ label: '小于等于', value: 'lte' },
|
||||
]"
|
||||
/>
|
||||
<n-input-number v-model:value="form.threshold" :min="0" />
|
||||
</n-flex>
|
||||
</n-form-item>
|
||||
<n-form-item label="隐藏成就">
|
||||
<n-switch v-model:value="form.hidden" />
|
||||
<n-text depth="3" style="margin-left: 12px">
|
||||
未解锁时学生只能看到 ???
|
||||
</n-text>
|
||||
</n-form-item>
|
||||
<n-form-item label="上架">
|
||||
<n-switch v-model:value="form.visible" />
|
||||
</n-form-item>
|
||||
<n-form-item label="排序">
|
||||
<n-input-number v-model:value="form.order" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<template #footer>
|
||||
<n-flex justify="end">
|
||||
<n-button @click="emit('update:show', false)">取消</n-button>
|
||||
<n-button type="primary" :loading="saving" @click="save">
|
||||
保存
|
||||
</n-button>
|
||||
</n-flex>
|
||||
</template>
|
||||
</n-modal>
|
||||
</template>
|
||||
@@ -0,0 +1,135 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton, NFlex } from "naive-ui"
|
||||
import {
|
||||
deleteAchievement,
|
||||
getAdminAchievements,
|
||||
type AdminAchievement,
|
||||
} from "admin/api"
|
||||
import AchievementModal from "./components/AchievementModal.vue"
|
||||
|
||||
const message = useMessage()
|
||||
const dialog = useDialog()
|
||||
|
||||
const list = ref<AdminAchievement[]>([])
|
||||
const loading = ref(false)
|
||||
const showModal = ref(false)
|
||||
const editing = ref<AdminAchievement | null>(null)
|
||||
|
||||
const RARITY_LABEL: Record<string, string> = {
|
||||
bronze: "青铜",
|
||||
silver: "白银",
|
||||
gold: "黄金",
|
||||
platinum: "白金",
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getAdminAchievements()
|
||||
list.value = res.data
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function create() {
|
||||
editing.value = null
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
function edit(row: AdminAchievement) {
|
||||
editing.value = row
|
||||
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 },
|
||||
{ title: "名称", key: "name" },
|
||||
{
|
||||
title: "稀有度",
|
||||
key: "rarity",
|
||||
width: 90,
|
||||
render: (row) => RARITY_LABEL[row.rarity] ?? row.rarity,
|
||||
},
|
||||
{ title: "指标", key: "metric_name" },
|
||||
{
|
||||
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: "unlock_count", 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>
|
||||
<n-card title="成就管理">
|
||||
<template #header-extra>
|
||||
<n-button type="primary" @click="create">新建成就</n-button>
|
||||
</template>
|
||||
|
||||
<n-alert type="info" style="margin-bottom: 12px">
|
||||
「已解锁人数」是唯一的仪表盘:配置一周后仍为
|
||||
0,多半是阈值配错了而不是太难。
|
||||
</n-alert>
|
||||
|
||||
<n-data-table
|
||||
:loading="loading"
|
||||
:data="list"
|
||||
:columns="columns"
|
||||
:row-key="(row: AdminAchievement) => row.id"
|
||||
/>
|
||||
|
||||
<AchievementModal
|
||||
v-model:show="showModal"
|
||||
:editing="editing"
|
||||
@saved="load"
|
||||
/>
|
||||
</n-card>
|
||||
</template>
|
||||
@@ -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<AdminAchievement[]>("admin/achievement")
|
||||
}
|
||||
|
||||
export function getMetricOptions() {
|
||||
return http.get<MetricOption[]>("admin/achievement/metrics")
|
||||
}
|
||||
|
||||
export function createAchievement(data: Partial<AdminAchievement>) {
|
||||
return http.post<AdminAchievement>("admin/achievement", data)
|
||||
}
|
||||
|
||||
export function updateAchievement(data: Partial<AdminAchievement>) {
|
||||
return http.put<AdminAchievement>("admin/achievement", data)
|
||||
}
|
||||
|
||||
export function deleteAchievement(id: number) {
|
||||
return http.delete("admin/achievement", { params: { id } })
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import http from "utils/http"
|
||||
import type {
|
||||
Achievement,
|
||||
AchievementSummary,
|
||||
PendingAchievement,
|
||||
} from "utils/types"
|
||||
|
||||
export function getAchievements(name?: string) {
|
||||
return http.get<{ username: string; achievements: Achievement[] }>(
|
||||
"achievements",
|
||||
{ params: name ? { name } : {} },
|
||||
)
|
||||
}
|
||||
|
||||
export function getAchievementSummary(name?: string) {
|
||||
return http.get<AchievementSummary>("achievements/summary", {
|
||||
params: name ? { name } : {},
|
||||
})
|
||||
}
|
||||
|
||||
export function getPendingAchievements() {
|
||||
return http.get<PendingAchievement[]>("achievements/pending")
|
||||
}
|
||||
|
||||
export function markAchievementsRead(ids: number[]) {
|
||||
return http.post("achievements/pending", { ids })
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
<script setup lang="ts">
|
||||
import type { Achievement } from "utils/types"
|
||||
|
||||
const props = defineProps<{ achievement: Achievement }>()
|
||||
|
||||
const RARITY_COLOR: Record<string, string> = {
|
||||
bronze: "#b87333",
|
||||
silver: "#9fa6b2",
|
||||
gold: "#e0a300",
|
||||
platinum: "#7dd3fc",
|
||||
}
|
||||
|
||||
const RARITY_LABEL: Record<string, string> = {
|
||||
bronze: "青铜",
|
||||
silver: "白银",
|
||||
gold: "黄金",
|
||||
platinum: "白金",
|
||||
}
|
||||
|
||||
// 隐藏且未解锁:后端已把名称/描述/图标和条件三件套都遮成 ??? 和 null,
|
||||
// 这里只负责不要把 null 渲染出来,也不要画出会泄露门槛的进度条
|
||||
const masked = computed(
|
||||
() => props.achievement.hidden && !props.achievement.unlocked,
|
||||
)
|
||||
|
||||
// 获得率低于 5% 的加稀有闪光边框
|
||||
const isRare = computed(
|
||||
() => props.achievement.unlock_rate > 0 && props.achievement.unlock_rate < 5,
|
||||
)
|
||||
|
||||
// 只有"越多越好"的成就画进度条。lte 类(如最短 AC 代码 ≤ 50 字符)
|
||||
// 画成百分比毫无意义,改成直接显示当前最好成绩
|
||||
const showProgressBar = computed(
|
||||
() =>
|
||||
!masked.value &&
|
||||
!props.achievement.unlocked &&
|
||||
props.achievement.operator === "gte" &&
|
||||
props.achievement.threshold !== null,
|
||||
)
|
||||
|
||||
const showBestSoFar = computed(
|
||||
() =>
|
||||
!masked.value &&
|
||||
!props.achievement.unlocked &&
|
||||
props.achievement.operator === "lte" &&
|
||||
props.achievement.threshold !== null,
|
||||
)
|
||||
|
||||
const percent = computed(() => {
|
||||
const { progress, threshold } = props.achievement
|
||||
if (threshold === null || threshold <= 0) return 100
|
||||
return Math.min(100, Math.round(((progress ?? 0) / threshold) * 100))
|
||||
})
|
||||
|
||||
const unlockDate = computed(() => {
|
||||
const { unlock_time, backfilled } = props.achievement
|
||||
// 补发的记录不显示具体日期:一次补发会给几百人盖上同一个时间戳
|
||||
if (backfilled || !unlock_time) return "已获得"
|
||||
return `${new Date(unlock_time).toLocaleDateString()} 获得`
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-card
|
||||
size="small"
|
||||
:class="{ locked: !achievement.unlocked, rare: isRare }"
|
||||
:style="{ borderColor: RARITY_COLOR[achievement.rarity] }"
|
||||
>
|
||||
<div class="row">
|
||||
<div class="icon">{{ achievement.icon }}</div>
|
||||
<div class="body">
|
||||
<div class="title">
|
||||
<span class="name">{{ achievement.name }}</span>
|
||||
<n-tag
|
||||
size="tiny"
|
||||
:color="{
|
||||
borderColor: RARITY_COLOR[achievement.rarity],
|
||||
textColor: RARITY_COLOR[achievement.rarity],
|
||||
}"
|
||||
>
|
||||
{{ RARITY_LABEL[achievement.rarity] }}
|
||||
</n-tag>
|
||||
</div>
|
||||
<div class="desc">{{ achievement.description }}</div>
|
||||
|
||||
<div v-if="achievement.unlocked" class="meta">
|
||||
<span>{{ unlockDate }}</span>
|
||||
<span class="rate">仅 {{ achievement.unlock_rate }}% 的人获得</span>
|
||||
</div>
|
||||
|
||||
<div v-else-if="showProgressBar" class="meta">
|
||||
<n-progress
|
||||
type="line"
|
||||
:percentage="percent"
|
||||
:height="6"
|
||||
:show-indicator="false"
|
||||
/>
|
||||
<span class="progress-text">
|
||||
{{ achievement.progress ?? 0 }} / {{ achievement.threshold }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-else-if="showBestSoFar" class="meta">
|
||||
<span class="progress-text">
|
||||
目标 ≤ {{ achievement.threshold }}
|
||||
</span>
|
||||
<span v-if="achievement.progress !== null" class="rate">
|
||||
当前最好 {{ achievement.progress }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-else class="meta">
|
||||
<span class="rate">仅 {{ achievement.unlock_rate }}% 的人获得</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</n-card>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.icon {
|
||||
font-size: 32px;
|
||||
line-height: 1;
|
||||
}
|
||||
.body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.desc {
|
||||
margin-top: 4px;
|
||||
font-size: 13px;
|
||||
opacity: 0.75;
|
||||
}
|
||||
.meta {
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
opacity: 0.65;
|
||||
}
|
||||
.meta :deep(.n-progress) {
|
||||
flex: 1;
|
||||
}
|
||||
.progress-text {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.locked {
|
||||
filter: grayscale(1);
|
||||
opacity: 0.55;
|
||||
}
|
||||
.rare {
|
||||
box-shadow: 0 0 12px rgba(125, 211, 252, 0.55);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,170 @@
|
||||
<script setup lang="ts">
|
||||
import { getAchievements, getAchievementSummary } from "oj/achievement/api"
|
||||
import { getUserBadges } from "oj/api"
|
||||
import type { Achievement, AchievementSummary } from "utils/types"
|
||||
import AchievementCard from "./components/AchievementCard.vue"
|
||||
|
||||
interface UserBadge {
|
||||
id: number
|
||||
earned_time: string
|
||||
badge: {
|
||||
id: number
|
||||
name: string
|
||||
description: string
|
||||
icon: string
|
||||
}
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const name = computed(() => (route.query.name as string) || undefined)
|
||||
|
||||
const achievements = ref<Achievement[]>([])
|
||||
const summary = ref<AchievementSummary | null>(null)
|
||||
const badges = ref<UserBadge[]>([])
|
||||
const tab = ref("all")
|
||||
const loading = ref(true)
|
||||
|
||||
const filtered = computed(() => {
|
||||
if (tab.value === "unlocked")
|
||||
return achievements.value.filter((a) => a.unlocked)
|
||||
if (tab.value === "locked")
|
||||
return achievements.value.filter((a) => !a.unlocked)
|
||||
return achievements.value
|
||||
})
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [list, sum, badgeRes] = await Promise.all([
|
||||
getAchievements(name.value),
|
||||
getAchievementSummary(name.value),
|
||||
getUserBadges(name.value),
|
||||
])
|
||||
// http 客户端返回 ApiResponse<T>,真实载荷在 .data 里
|
||||
achievements.value = list.data.achievements
|
||||
summary.value = sum.data
|
||||
badges.value = (badgeRes.data ?? []) as UserBadge[]
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
watch(name, load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="hall">
|
||||
<n-spin :show="loading">
|
||||
<n-card v-if="summary" class="overview">
|
||||
<div class="overview-row">
|
||||
<div class="percent">
|
||||
<div class="big">{{ summary.percent }}%</div>
|
||||
<div class="sub">
|
||||
{{ summary.unlocked }} / {{ summary.total }} 已获得
|
||||
</div>
|
||||
</div>
|
||||
<div class="rarity">
|
||||
<div
|
||||
v-for="r in summary.rarity"
|
||||
:key="r.rarity"
|
||||
class="rarity-item"
|
||||
>
|
||||
<div class="rarity-label">{{ r.label }}</div>
|
||||
<div class="rarity-count">{{ r.unlocked }} / {{ r.total }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</n-card>
|
||||
|
||||
<n-tabs v-model:value="tab" type="line" class="tabs">
|
||||
<n-tab name="all">全部</n-tab>
|
||||
<n-tab name="unlocked">已获得</n-tab>
|
||||
<n-tab name="locked">未获得</n-tab>
|
||||
<n-tab name="badges">题单奖章</n-tab>
|
||||
</n-tabs>
|
||||
|
||||
<div v-if="tab !== 'badges'" class="grid">
|
||||
<AchievementCard v-for="a in filtered" :key="a.id" :achievement="a" />
|
||||
<n-empty v-if="!filtered.length" description="这里还什么都没有" />
|
||||
</div>
|
||||
|
||||
<div v-else class="grid">
|
||||
<n-card v-for="b in badges" :key="b.id" size="small">
|
||||
<div class="badge-row">
|
||||
<img v-if="b.badge?.icon" :src="b.badge.icon" class="badge-icon" />
|
||||
<div>
|
||||
<div class="badge-name">{{ b.badge?.name }}</div>
|
||||
<div class="badge-desc">{{ b.badge?.description }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</n-card>
|
||||
<n-empty v-if="!badges.length" description="还没有获得任何题单奖章" />
|
||||
</div>
|
||||
</n-spin>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.hall {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 16px;
|
||||
}
|
||||
.overview-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.big {
|
||||
font-size: 40px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
.sub {
|
||||
margin-top: 6px;
|
||||
font-size: 13px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.rarity {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
}
|
||||
.rarity-item {
|
||||
text-align: center;
|
||||
}
|
||||
.rarity-label {
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.rarity-count {
|
||||
font-weight: 600;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.tabs {
|
||||
margin: 16px 0;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.badge-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
.badge-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
.badge-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
.badge-desc {
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
</style>
|
||||
@@ -2,7 +2,8 @@ import { ref, computed, watch, onUnmounted } from "vue"
|
||||
import { useIntervalFn, useTimeoutFn } from "@vueuse/core"
|
||||
import { getSubmission } from "oj/api"
|
||||
import { SubmissionStatus } from "utils/constants"
|
||||
import type { Submission } from "utils/types"
|
||||
import type { PendingAchievement, Submission } from "utils/types"
|
||||
import { useAchievementStore } from "shared/store/achievement"
|
||||
import {
|
||||
useSubmissionWebSocket,
|
||||
type SubmissionUpdate,
|
||||
@@ -45,6 +46,20 @@ export function useSubmissionMonitor() {
|
||||
|
||||
// ==================== WebSocket 处理 ====================
|
||||
const handleSubmissionUpdate = (data: SubmissionUpdate) => {
|
||||
// push_to_user 复用了 submission_update 这个 channel handler,
|
||||
// 其他类型的消息会走同一条 WebSocket 帧进来,必须先分流
|
||||
const frame = data as unknown as {
|
||||
type: string
|
||||
achievements?: PendingAchievement[]
|
||||
}
|
||||
if (frame.type === "achievement_unlocked") {
|
||||
useAchievementStore().enqueue(frame.achievements ?? [])
|
||||
return
|
||||
}
|
||||
if (frame.type !== "submission_update") {
|
||||
return
|
||||
}
|
||||
|
||||
console.log("[SubmissionMonitor] 收到WebSocket更新:", data)
|
||||
|
||||
if (data.submission_id !== submissionId.value) {
|
||||
|
||||
+60
-35
@@ -198,17 +198,30 @@ const columns: DataTableColumn<Rank>[] = [
|
||||
"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<UserRank>[] = [
|
||||
{
|
||||
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: "已解决",
|
||||
|
||||
+76
-2
@@ -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<GroupedBadge[]>([])
|
||||
const achievementSummary = ref<AchievementSummary | null>(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()
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<n-flex
|
||||
@@ -244,6 +266,48 @@ onMounted(init)
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
|
||||
<!-- 成就摘要 -->
|
||||
<n-card
|
||||
v-if="!loading && profile && achievementSummary"
|
||||
class="wrapper"
|
||||
hoverable
|
||||
>
|
||||
<n-flex align="center" justify="space-between">
|
||||
<n-flex align="center" :size="12">
|
||||
<span class="achievement-title">
|
||||
成就 {{ achievementSummary.unlocked }} /
|
||||
{{ achievementSummary.total }}
|
||||
</span>
|
||||
<n-tag size="small" type="info">
|
||||
{{ achievementSummary.percent }}%
|
||||
</n-tag>
|
||||
</n-flex>
|
||||
<n-button
|
||||
text
|
||||
type="primary"
|
||||
@click="
|
||||
router.push({
|
||||
path: '/achievement',
|
||||
query: route.query.name ? { name: route.query.name } : {},
|
||||
})
|
||||
"
|
||||
>
|
||||
查看全部
|
||||
</n-button>
|
||||
</n-flex>
|
||||
<n-flex align="center" :size="10" class="achievement-recent">
|
||||
<n-tooltip v-for="a in achievementSummary.recent" :key="a.id">
|
||||
<template #trigger>
|
||||
<span class="achievement-icon">{{ a.icon }}</span>
|
||||
</template>
|
||||
{{ a.name }}
|
||||
</n-tooltip>
|
||||
<n-text v-if="!achievementSummary.recent.length" depth="3">
|
||||
还没有获得成就
|
||||
</n-text>
|
||||
</n-flex>
|
||||
</n-card>
|
||||
|
||||
<!-- 徽章展示卡片 -->
|
||||
<n-card
|
||||
v-if="!loading && profile && userBadges.length > 0"
|
||||
@@ -321,4 +385,14 @@ h2 {
|
||||
word-wrap: break-word;
|
||||
max-width: 100%;
|
||||
}
|
||||
.achievement-title {
|
||||
font-weight: 600;
|
||||
}
|
||||
.achievement-recent {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.achievement-icon {
|
||||
font-size: 24px;
|
||||
cursor: default;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -82,6 +82,12 @@ export const ojs: RouteRecordRaw = {
|
||||
component: () => import("oj/user/index.vue"),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: "achievement",
|
||||
name: "achievement",
|
||||
component: () => import("oj/achievement/index.vue"),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: "setting",
|
||||
component: () => import("oj/user/setting.vue"),
|
||||
@@ -295,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",
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
<script setup lang="ts">
|
||||
import { useAchievementStore } from "shared/store/achievement"
|
||||
|
||||
const store = useAchievementStore()
|
||||
const { current, queue } = storeToRefs(store)
|
||||
const visible = ref(false)
|
||||
|
||||
const RARITY_COLOR: Record<string, string> = {
|
||||
bronze: "#b87333",
|
||||
silver: "#9fa6b2",
|
||||
gold: "#e0a300",
|
||||
platinum: "#7dd3fc",
|
||||
}
|
||||
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
let gapTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
// 多个同时解锁时排队依次弹出,不重叠堆积
|
||||
function playNext() {
|
||||
const item = store.next()
|
||||
if (!item) return
|
||||
visible.value = true
|
||||
timer = setTimeout(async () => {
|
||||
visible.value = false
|
||||
await store.markRead(item)
|
||||
gapTimer = setTimeout(playNext, 400)
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => queue.value.length,
|
||||
(len) => {
|
||||
if (len > 0 && !visible.value) playNext()
|
||||
},
|
||||
)
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timer) clearTimeout(timer)
|
||||
if (gapTimer) clearTimeout(gapTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Transition name="slide">
|
||||
<div
|
||||
v-if="visible && current"
|
||||
class="toast"
|
||||
:style="{ borderColor: RARITY_COLOR[current.rarity] }"
|
||||
>
|
||||
<div class="icon">{{ current.icon }}</div>
|
||||
<div class="body">
|
||||
<div class="label">
|
||||
{{ current.kind === "badge" ? "获得奖章" : "成就解锁" }}
|
||||
</div>
|
||||
<div class="name">{{ current.name }}</div>
|
||||
<div class="desc">{{ current.description }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.toast {
|
||||
position: fixed;
|
||||
right: 24px;
|
||||
bottom: 24px;
|
||||
z-index: 3000;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 14px 18px;
|
||||
border-radius: 10px;
|
||||
border: 2px solid;
|
||||
background: var(--n-color, rgba(24, 24, 28, 0.95));
|
||||
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.35);
|
||||
min-width: 260px;
|
||||
}
|
||||
.icon {
|
||||
font-size: 34px;
|
||||
}
|
||||
.label {
|
||||
font-size: 11px;
|
||||
letter-spacing: 2px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.name {
|
||||
font-weight: 700;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.desc {
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.slide-enter-active,
|
||||
.slide-leave-active {
|
||||
transition: all 0.35s ease;
|
||||
}
|
||||
.slide-enter-from,
|
||||
.slide-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(40px);
|
||||
}
|
||||
</style>
|
||||
@@ -141,6 +141,15 @@ const options = computed<MenuOption[]>(() => {
|
||||
),
|
||||
key: "admin tutorial list",
|
||||
},
|
||||
{
|
||||
label: () =>
|
||||
h(
|
||||
RouterLink,
|
||||
{ to: "/admin/achievement/list" },
|
||||
{ default: () => "成就" },
|
||||
),
|
||||
key: "admin achievement list",
|
||||
},
|
||||
{
|
||||
label: () =>
|
||||
h(
|
||||
|
||||
@@ -4,6 +4,23 @@ import Header from "../components/Header.vue"
|
||||
import Login from "../components/Login.vue"
|
||||
import Signup from "../components/Signup.vue"
|
||||
import LoginSummaryModal from "../components/LoginSummaryModal.vue"
|
||||
import AchievementToast from "../components/AchievementToast.vue"
|
||||
import { useAchievementStore } from "shared/store/achievement"
|
||||
import { useUserStore } from "shared/store/user"
|
||||
|
||||
const achievementStore = useAchievementStore()
|
||||
const userStore = useUserStore()
|
||||
const route = useRoute()
|
||||
|
||||
// 拉取才是主通道:WebSocket 不是常驻连接(只在问题页且有提交监听时建连),
|
||||
// 所以任何页面、任何时刻解锁的成就都靠这里补上
|
||||
watch(
|
||||
() => route.path,
|
||||
() => {
|
||||
if (userStore.isAuthed) achievementStore.fetchPending()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -19,6 +36,7 @@ import LoginSummaryModal from "../components/LoginSummaryModal.vue"
|
||||
<Login />
|
||||
<Signup />
|
||||
<LoginSummaryModal />
|
||||
<AchievementToast />
|
||||
<Beian />
|
||||
</n-layout>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import {
|
||||
getPendingAchievements,
|
||||
markAchievementsRead,
|
||||
} from "oj/achievement/api"
|
||||
import type { PendingAchievement } from "utils/types"
|
||||
|
||||
/**
|
||||
* 成就解锁弹窗队列。
|
||||
*
|
||||
* 通知走推拉结合,后端的 UserAchievement.notified 是唯一真相来源:
|
||||
* - 拉(主):布局层每次路由切换拉一次 pending,覆盖全部场景,绝不丢
|
||||
* - 推(增强):WebSocket 只在用户当场停留在问题页时把延迟压到几百毫秒
|
||||
*
|
||||
* 之所以不能只靠推:前端 WebSocket 不是常驻连接,只在问题页且有提交监听时
|
||||
* 才建连,纯推会丢消息(尤其是题单奖章,那些页面根本没建连接)。
|
||||
*/
|
||||
export const useAchievementStore = defineStore("achievement", () => {
|
||||
const queue = ref<PendingAchievement[]>([])
|
||||
const current = ref<PendingAchievement | null>(null)
|
||||
|
||||
// 成就和题单奖章的 id 来自两张不同的表,数值会重叠,
|
||||
// 只按 id 去重会让奖章 5 把成就 5 挤掉
|
||||
function keyOf(item: PendingAchievement) {
|
||||
return `${item.kind ?? "achievement"}:${item.id}`
|
||||
}
|
||||
|
||||
function enqueue(items: PendingAchievement[]) {
|
||||
if (!items?.length) return
|
||||
// 去重:WebSocket 推来的和 pending 拉来的可能是同一批
|
||||
const known = new Set([
|
||||
...queue.value.map(keyOf),
|
||||
...(current.value ? [keyOf(current.value)] : []),
|
||||
])
|
||||
queue.value.push(...items.filter((i) => !known.has(keyOf(i))))
|
||||
}
|
||||
|
||||
async function fetchPending() {
|
||||
try {
|
||||
// http 客户端返回 ApiResponse<T>,真实载荷在 .data 里
|
||||
const res = await getPendingAchievements()
|
||||
enqueue(res.data ?? [])
|
||||
} catch {
|
||||
// 拉取失败静默处理,下次路由切换会再拉
|
||||
}
|
||||
}
|
||||
|
||||
function next() {
|
||||
current.value = queue.value.shift() ?? null
|
||||
return current.value
|
||||
}
|
||||
|
||||
async function markRead(item: PendingAchievement) {
|
||||
// 奖章不在 UserAchievement 表里,它的 id 传给标记接口会被当成成就 id,
|
||||
// 把一个恰好同号、还没弹过的成就静默标记为已弹——那个奖杯就再也不会出现
|
||||
if (item.kind === "badge") return
|
||||
try {
|
||||
await markAchievementsRead([item.id])
|
||||
} catch {
|
||||
// 标记失败下次会重复弹一次,可接受
|
||||
}
|
||||
}
|
||||
|
||||
return { queue, current, enqueue, fetchPending, next, markRead }
|
||||
})
|
||||
@@ -723,3 +723,53 @@ export interface DetailsData {
|
||||
}
|
||||
|
||||
export type Grade = "S" | "A" | "B" | "C"
|
||||
|
||||
// ==================== 成就相关类型 ====================
|
||||
|
||||
export type AchievementRarity = "bronze" | "silver" | "gold" | "platinum"
|
||||
|
||||
export interface Achievement {
|
||||
id: number
|
||||
name: string
|
||||
description: string
|
||||
icon: string
|
||||
rarity: AchievementRarity
|
||||
hidden: boolean
|
||||
// 隐藏成就未解锁时,后端已做掩码处理,以下四个字段为 null
|
||||
metric: string | null
|
||||
operator: "gte" | "lte" | null
|
||||
threshold: number | null
|
||||
unlocked: boolean
|
||||
unlock_time: string | null
|
||||
backfilled: boolean
|
||||
progress: number | null
|
||||
unlock_rate: number
|
||||
}
|
||||
|
||||
export interface AchievementRarityStat {
|
||||
rarity: AchievementRarity
|
||||
label: string
|
||||
total: number
|
||||
unlocked: number
|
||||
}
|
||||
|
||||
export interface PendingAchievement {
|
||||
id: number
|
||||
name: string
|
||||
description: string
|
||||
icon: string
|
||||
rarity: AchievementRarity
|
||||
// 弹窗队列里混着两种东西:全站成就和题单奖章。它们的 id 来自两张不同的表,
|
||||
// 数值会重叠,所以去重和标记已读都必须带上 kind 一起判断。
|
||||
// pending 接口只返回成就,不带这个字段,缺省按 achievement 处理。
|
||||
kind?: "achievement" | "badge"
|
||||
}
|
||||
|
||||
export interface AchievementSummary {
|
||||
username: string
|
||||
total: number
|
||||
unlocked: number
|
||||
percent: number
|
||||
rarity: AchievementRarityStat[]
|
||||
recent: PendingAchievement[]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user