feat(achievement): 成就图标改用 iconify 渲染
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled

机房的老浏览器缺 emoji 字体,成就图标会渲染成方块。后端的 icon 字段
改存 iconify 图标名(noto 集),前端统一走新的 AchievementIcon 组件
渲染成 SVG。

组件按三种形态判断:图片 URL 走 img,含冒号走 Icon,其余当纯文本兜底,
所以存量的 emoji 数据不会瞎掉。

顺带修好解锁弹窗:题单奖章的 icon 是图片 URL,之前当文本渲染,会把
一串路径直接显示出来。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-04 19:20:10 -06:00
parent 0ba25dc4e3
commit 0932a05850
6 changed files with 100 additions and 6 deletions

View File

@@ -6,6 +6,7 @@ import {
type AdminAchievement,
type MetricOption,
} from "admin/api"
import AchievementIcon from "shared/components/AchievementIcon.vue"
const props = defineProps<{
show: boolean
@@ -21,7 +22,7 @@ function emptyForm() {
return {
name: "",
description: "",
icon: "🏆",
icon: "noto:trophy",
rarity: "bronze",
hidden: false,
metric: "",
@@ -99,7 +100,22 @@ async function save() {
/>
</n-form-item>
<n-form-item label="图标">
<n-input v-model:value="form.icon" placeholder="emoji例如 🦉" />
<n-flex vertical :size="4" style="flex: 1">
<n-flex align="center" :size="10">
<n-input
v-model:value="form.icon"
placeholder="iconify 图标名,例如 noto:owl"
/>
<div class="icon-preview">
<AchievementIcon :icon="form.icon" :size="28" />
</div>
</n-flex>
<n-text depth="3" style="font-size: 12px">
iconify 图标名推荐 noto: 开头的彩色 emoji 图标右侧是实时
预览预览不出来说明名字写错了图标名可在 icon-sets.iconify.design
搜索
</n-text>
</n-flex>
</n-form-item>
<n-form-item label="稀有度">
<n-select
@@ -158,3 +174,14 @@ async function save() {
</template>
</n-modal>
</template>
<style scoped>
.icon-preview {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 34px;
flex: none;
}
</style>

View File

@@ -5,6 +5,7 @@ import {
getAdminAchievements,
type AdminAchievement,
} from "admin/api"
import AchievementIcon from "shared/components/AchievementIcon.vue"
import AchievementModal from "./components/AchievementModal.vue"
const message = useMessage()
@@ -57,7 +58,12 @@ function remove(row: AdminAchievement) {
}
const columns: DataTableColumn<AdminAchievement>[] = [
{ title: "图标", key: "icon", width: 60 },
{
title: "图标",
key: "icon",
width: 60,
render: (row) => h(AchievementIcon, { icon: row.icon, size: 24 }),
},
{ title: "名称", key: "name" },
{
title: "稀有度",

View File

@@ -1,4 +1,5 @@
<script setup lang="ts">
import AchievementIcon from "shared/components/AchievementIcon.vue"
import type { Achievement } from "utils/types"
const props = defineProps<{ achievement: Achievement }>()
@@ -67,7 +68,9 @@ const unlockDate = computed(() => {
:style="{ borderColor: RARITY_COLOR[achievement.rarity] }"
>
<div class="row">
<div class="icon">{{ achievement.icon }}</div>
<div class="icon">
<AchievementIcon :icon="achievement.icon" :size="32" />
</div>
<div class="body">
<div class="title">
<span class="name">{{ achievement.name }}</span>

View File

@@ -11,6 +11,7 @@ import type {
} from "utils/types"
import { getAchievementSummary } from "oj/achievement/api"
import { getMetrics, getUserBadges } from "../api"
import AchievementIcon from "shared/components/AchievementIcon.vue"
import GroupedUserBadge from "shared/components/GroupedUserBadge.vue"
import { useUserStore } from "shared/store/user"
@@ -298,7 +299,9 @@ onMounted(() => {
<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>
<span class="achievement-icon">
<AchievementIcon :icon="a.icon" :size="24" />
</span>
</template>
{{ a.name }}
</n-tooltip>
@@ -392,6 +395,8 @@ h2 {
margin-top: 10px;
}
.achievement-icon {
display: inline-flex;
align-items: center;
font-size: 24px;
cursor: default;
}

View File

@@ -0,0 +1,50 @@
<script setup lang="ts">
import { Icon } from "@iconify/vue"
// 成就图标统一走 iconifynoto 彩色 emoji 图标集),渲染出来是 SVG
// 老浏览器缺 emoji 字体也能正常显示。
//
// 三种形态按顺序判断,顺序不能换:题单奖章的 icon 是图片 URL
// 而 https:// 里也带冒号,先判 iconify 会把 URL 当成图标名。
// 存量成就的 icon 还可能是 emoji 字符,最后原样当文本兜底。
const props = withDefaults(defineProps<{ icon: string; size?: number }>(), {
size: 32,
})
const kind = computed(() => {
const v = props.icon ?? ""
if (/^(https?:\/\/|\/|data:)/.test(v)) return "image"
if (v.includes(":")) return "iconify"
return "text"
})
</script>
<template>
<img
v-if="kind === 'image'"
:src="icon"
:width="size"
:height="size"
class="image"
alt=""
/>
<Icon
v-else-if="kind === 'iconify'"
:icon="icon"
:width="size"
:height="size"
/>
<span v-else class="fallback" :style="{ fontSize: `${size}px` }">
{{ icon }}
</span>
</template>
<style scoped>
.image {
object-fit: contain;
border-radius: 4px;
}
.fallback {
line-height: 1;
}
</style>

View File

@@ -1,4 +1,5 @@
<script setup lang="ts">
import AchievementIcon from "shared/components/AchievementIcon.vue"
import { useAchievementStore } from "shared/store/achievement"
const store = useAchievementStore()
@@ -47,7 +48,9 @@ onUnmounted(() => {
class="toast"
:style="{ borderColor: RARITY_COLOR[current.rarity] }"
>
<div class="icon">{{ current.icon }}</div>
<div class="icon">
<AchievementIcon :icon="current.icon" :size="34" />
</div>
<div class="body">
<div class="label">
{{ current.kind === "badge" ? "获得奖章" : "成就解锁" }}