feat(achievement): 成就图标改用 iconify 渲染
机房的老浏览器缺 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:
@@ -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>
|
||||
|
||||
@@ -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: "稀有度",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
50
src/shared/components/AchievementIcon.vue
Normal file
50
src/shared/components/AchievementIcon.vue
Normal file
@@ -0,0 +1,50 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from "@iconify/vue"
|
||||
|
||||
// 成就图标统一走 iconify(noto 彩色 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>
|
||||
@@ -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" ? "获得奖章" : "成就解锁" }}
|
||||
|
||||
Reference in New Issue
Block a user