机房的老浏览器缺 emoji 字体,成就图标会渲染成方块。后端的 icon 字段 改存 iconify 图标名(noto 集),前端统一走新的 AchievementIcon 组件 渲染成 SVG。 组件按三种形态判断:图片 URL 走 img,含冒号走 Icon,其余当纯文本兜底, 所以存量的 emoji 数据不会瞎掉。 顺带修好解锁弹窗:题单奖章的 icon 是图片 URL,之前当文本渲染,会把 一串路径直接显示出来。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
108 lines
2.3 KiB
Vue
108 lines
2.3 KiB
Vue
<script setup lang="ts">
|
|
import AchievementIcon from "shared/components/AchievementIcon.vue"
|
|
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">
|
|
<AchievementIcon :icon="current.icon" :size="34" />
|
|
</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>
|