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 AdminAchievement,
|
||||||
type MetricOption,
|
type MetricOption,
|
||||||
} from "admin/api"
|
} from "admin/api"
|
||||||
|
import AchievementIcon from "shared/components/AchievementIcon.vue"
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
show: boolean
|
show: boolean
|
||||||
@@ -21,7 +22,7 @@ function emptyForm() {
|
|||||||
return {
|
return {
|
||||||
name: "",
|
name: "",
|
||||||
description: "",
|
description: "",
|
||||||
icon: "🏆",
|
icon: "noto:trophy",
|
||||||
rarity: "bronze",
|
rarity: "bronze",
|
||||||
hidden: false,
|
hidden: false,
|
||||||
metric: "",
|
metric: "",
|
||||||
@@ -99,7 +100,22 @@ async function save() {
|
|||||||
/>
|
/>
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
<n-form-item label="图标">
|
<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>
|
||||||
<n-form-item label="稀有度">
|
<n-form-item label="稀有度">
|
||||||
<n-select
|
<n-select
|
||||||
@@ -158,3 +174,14 @@ async function save() {
|
|||||||
</template>
|
</template>
|
||||||
</n-modal>
|
</n-modal>
|
||||||
</template>
|
</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,
|
getAdminAchievements,
|
||||||
type AdminAchievement,
|
type AdminAchievement,
|
||||||
} from "admin/api"
|
} from "admin/api"
|
||||||
|
import AchievementIcon from "shared/components/AchievementIcon.vue"
|
||||||
import AchievementModal from "./components/AchievementModal.vue"
|
import AchievementModal from "./components/AchievementModal.vue"
|
||||||
|
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
@@ -57,7 +58,12 @@ function remove(row: AdminAchievement) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const columns: DataTableColumn<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: "名称", key: "name" },
|
||||||
{
|
{
|
||||||
title: "稀有度",
|
title: "稀有度",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import AchievementIcon from "shared/components/AchievementIcon.vue"
|
||||||
import type { Achievement } from "utils/types"
|
import type { Achievement } from "utils/types"
|
||||||
|
|
||||||
const props = defineProps<{ achievement: Achievement }>()
|
const props = defineProps<{ achievement: Achievement }>()
|
||||||
@@ -67,7 +68,9 @@ const unlockDate = computed(() => {
|
|||||||
:style="{ borderColor: RARITY_COLOR[achievement.rarity] }"
|
:style="{ borderColor: RARITY_COLOR[achievement.rarity] }"
|
||||||
>
|
>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="icon">{{ achievement.icon }}</div>
|
<div class="icon">
|
||||||
|
<AchievementIcon :icon="achievement.icon" :size="32" />
|
||||||
|
</div>
|
||||||
<div class="body">
|
<div class="body">
|
||||||
<div class="title">
|
<div class="title">
|
||||||
<span class="name">{{ achievement.name }}</span>
|
<span class="name">{{ achievement.name }}</span>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import type {
|
|||||||
} from "utils/types"
|
} from "utils/types"
|
||||||
import { getAchievementSummary } from "oj/achievement/api"
|
import { getAchievementSummary } from "oj/achievement/api"
|
||||||
import { getMetrics, getUserBadges } from "../api"
|
import { getMetrics, getUserBadges } from "../api"
|
||||||
|
import AchievementIcon from "shared/components/AchievementIcon.vue"
|
||||||
import GroupedUserBadge from "shared/components/GroupedUserBadge.vue"
|
import GroupedUserBadge from "shared/components/GroupedUserBadge.vue"
|
||||||
import { useUserStore } from "shared/store/user"
|
import { useUserStore } from "shared/store/user"
|
||||||
|
|
||||||
@@ -298,7 +299,9 @@ onMounted(() => {
|
|||||||
<n-flex align="center" :size="10" class="achievement-recent">
|
<n-flex align="center" :size="10" class="achievement-recent">
|
||||||
<n-tooltip v-for="a in achievementSummary.recent" :key="a.id">
|
<n-tooltip v-for="a in achievementSummary.recent" :key="a.id">
|
||||||
<template #trigger>
|
<template #trigger>
|
||||||
<span class="achievement-icon">{{ a.icon }}</span>
|
<span class="achievement-icon">
|
||||||
|
<AchievementIcon :icon="a.icon" :size="24" />
|
||||||
|
</span>
|
||||||
</template>
|
</template>
|
||||||
{{ a.name }}
|
{{ a.name }}
|
||||||
</n-tooltip>
|
</n-tooltip>
|
||||||
@@ -392,6 +395,8 @@ h2 {
|
|||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
}
|
}
|
||||||
.achievement-icon {
|
.achievement-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
cursor: default;
|
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">
|
<script setup lang="ts">
|
||||||
|
import AchievementIcon from "shared/components/AchievementIcon.vue"
|
||||||
import { useAchievementStore } from "shared/store/achievement"
|
import { useAchievementStore } from "shared/store/achievement"
|
||||||
|
|
||||||
const store = useAchievementStore()
|
const store = useAchievementStore()
|
||||||
@@ -47,7 +48,9 @@ onUnmounted(() => {
|
|||||||
class="toast"
|
class="toast"
|
||||||
:style="{ borderColor: RARITY_COLOR[current.rarity] }"
|
: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="body">
|
||||||
<div class="label">
|
<div class="label">
|
||||||
{{ current.kind === "badge" ? "获得奖章" : "成就解锁" }}
|
{{ current.kind === "badge" ? "获得奖章" : "成就解锁" }}
|
||||||
|
|||||||
Reference in New Issue
Block a user