3 changed files with 254 additions and 0 deletions
-
95src/components/SongMessageContainer.vue
-
115src/components/SongScrim.vue
-
44src/composables/useMessage.ts
@ -0,0 +1,95 @@ |
|||||
|
<script setup lang="ts"> |
||||
|
/* SongMessageContainer — 提示框承载层(雾隐淡入,墨线 + 小章意象) */ |
||||
|
interface ToastItem { |
||||
|
id: number |
||||
|
type: string |
||||
|
text: string |
||||
|
} |
||||
|
|
||||
|
defineProps<{ |
||||
|
list: ToastItem[] |
||||
|
}>() |
||||
|
</script> |
||||
|
|
||||
|
<template> |
||||
|
<div class="song-messages"> |
||||
|
<TransitionGroup name="song-toast"> |
||||
|
<div v-for="t in list" :key="t.id" class="song-toast" :class="`song-toast--${t.type}`"> |
||||
|
<span class="song-toast__stamp" aria-hidden="true"> |
||||
|
<template v-if="t.type === 'success'">✓</template> |
||||
|
<template v-else-if="t.type === 'danger'">✕</template> |
||||
|
<template v-else-if="t.type === 'warning'">!</template> |
||||
|
<template v-else>印</template> |
||||
|
</span> |
||||
|
<span class="song-toast__text">{{ t.text }}</span> |
||||
|
</div> |
||||
|
</TransitionGroup> |
||||
|
</div> |
||||
|
</template> |
||||
|
|
||||
|
<style scoped> |
||||
|
.song-messages { |
||||
|
position: fixed; |
||||
|
top: 24px; |
||||
|
left: 50%; |
||||
|
transform: translateX(-50%); |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
align-items: center; |
||||
|
gap: 10px; |
||||
|
z-index: 1000; |
||||
|
pointer-events: none; |
||||
|
} |
||||
|
|
||||
|
.song-toast { |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
gap: 10px; |
||||
|
max-width: min(90vw, 420px); |
||||
|
padding: 10px 18px; |
||||
|
background: var(--song-bg-elevated); |
||||
|
border: 1px solid var(--song-border); |
||||
|
border-left: 3px solid var(--song-text-secondary); |
||||
|
border-radius: var(--song-radius-s); |
||||
|
box-shadow: var(--song-shadow-2); |
||||
|
color: var(--song-text); |
||||
|
font-size: 13px; |
||||
|
pointer-events: auto; |
||||
|
} |
||||
|
|
||||
|
.song-toast__stamp { |
||||
|
display: grid; |
||||
|
place-items: center; |
||||
|
width: 18px; |
||||
|
height: 18px; |
||||
|
flex-shrink: 0; |
||||
|
border: 1px solid currentColor; |
||||
|
border-radius: 2px; |
||||
|
font-size: 11px; |
||||
|
line-height: 1; |
||||
|
font-weight: 700; |
||||
|
color: var(--song-text-secondary); |
||||
|
} |
||||
|
|
||||
|
.song-toast.song-toast--success { border-left-color: var(--song-success); } |
||||
|
.song-toast.song-toast--success .song-toast__stamp { color: var(--song-success); } |
||||
|
.song-toast.song-toast--danger { border-left-color: var(--song-danger); } |
||||
|
.song-toast.song-toast--danger .song-toast__stamp { color: var(--song-danger); } |
||||
|
.song-toast.song-toast--warning { border-left-color: var(--song-warning); } |
||||
|
.song-toast.song-toast--warning .song-toast__stamp { color: var(--song-warning); } |
||||
|
.song-toast.song-toast--info { border-left-color: var(--song-primary); } |
||||
|
.song-toast.song-toast--info .song-toast__stamp { color: var(--song-primary); } |
||||
|
|
||||
|
/* 雾隐淡入 §7 */ |
||||
|
.song-toast-enter-active, |
||||
|
.song-toast-leave-active { |
||||
|
transition: opacity var(--song-dur-2) var(--song-ease), |
||||
|
transform var(--song-dur-2) var(--song-ease); |
||||
|
} |
||||
|
|
||||
|
.song-toast-enter-from, |
||||
|
.song-toast-leave-to { |
||||
|
opacity: 0; |
||||
|
transform: translateY(-10px); |
||||
|
} |
||||
|
</style> |
||||
@ -0,0 +1,115 @@ |
|||||
|
<script setup lang="ts"> |
||||
|
/* SongScrim — 纹样底纹合成器 §5 |
||||
|
* 纹样均以 SVG 几何化矢量、低占比,置于内容之下。 |
||||
|
* pattern: ice(冰裂纹) | blind(直棂窗) | tortoise(龟背纹) | vine(缠枝纹) | |
||||
|
* water(水波) | cloud(祥云) |
||||
|
* box: 卡片/区块内局部底纹 */ |
||||
|
withDefaults( |
||||
|
defineProps<{ |
||||
|
pattern?: 'ice' | 'blind' | 'tortoise' | 'vine' | 'water' | 'cloud' |
||||
|
opacity?: number |
||||
|
box?: boolean |
||||
|
}>(), |
||||
|
{ pattern: 'ice', opacity: 1, box: false } |
||||
|
) |
||||
|
defineOptions({ name: 'SongScrim' }) |
||||
|
|
||||
|
const PATTERNS: Record<string, string> = { |
||||
|
ice: ` |
||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="220" height="220" viewBox="0 0 220 220"> |
||||
|
<g fill="none" stroke="#6B7A86" stroke-width="1"> |
||||
|
<path d="M0 40 40 0 M60 0 0 60 M40 40 90 90 M90 90 110 70 M0 90 90 0"/> |
||||
|
<path d="M120 0 90 30 M0 120 30 90 M110 110 60 160 M160 60 110 110 M90 90 160 160"/> |
||||
|
<path d="M220 40 180 0 M180 180 220 220 M40 220 0 180 M180 180 160 200 M200 160 180 180"/> |
||||
|
</g> |
||||
|
</svg>`, |
||||
|
blind: ` |
||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="120" height="120" viewBox="0 0 120 120"> |
||||
|
<g fill="none" stroke="#6B7A86" stroke-width="1"> |
||||
|
<path d="M0 0v120 M20 0v120 M40 0v120 M60 0v120 M80 0v120 M100 0v120 M120 0v120"/> |
||||
|
</g> |
||||
|
</svg>`, |
||||
|
tortoise: ` |
||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="120" height="120" viewBox="0 0 120 120"> |
||||
|
<g fill="none" stroke="#6B7A86" stroke-width="1"> |
||||
|
<path d="M30 8 60 21 30 34Z M60 21 90 8 90 34Z"/> |
||||
|
<path d="M30 34 60 60 30 86Z M60 60 90 34 90 86Z"/> |
||||
|
<path d="M30 8 30 60 0 38Z M90 8 90 60 120 38Z"/> |
||||
|
<path d="M30 86 0 64 0 92Z M90 86 120 64 120 92Z"/> |
||||
|
</g> |
||||
|
</svg>`, |
||||
|
vine: ` |
||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="120" height="60" viewBox="0 0 120 60"> |
||||
|
<g fill="none" stroke="#6B7A86" stroke-width="1"> |
||||
|
<path d="M0 30 Q15 10 30 30 T60 30 T90 30 T120 30"/> |
||||
|
<path d="M10 45 q0 -12 0 -24 M30 45 30 15 M50 45 50 15 M70 45 70 15 M90 45 90 15 M110 45 110 15"/> |
||||
|
</g> |
||||
|
</svg>`, |
||||
|
water: ` |
||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="120" height="120" viewBox="0 0 120 120"> |
||||
|
<g fill="none" stroke="#6B7A86" stroke-width="1"> |
||||
|
<path d="M20 40 q10 -8 20 0 q10 8 20 0 q10 -8 20 0 q10 8 20 0"/> |
||||
|
<path d="M10 64 q10 -8 20 0 q10 8 20 0 q10 -8 20 0 q10 8 20 0 q10 -8 20 0"/> |
||||
|
<path d="M30 88 q10 -8 20 0 q10 8 20 0 q10 -8 20 0"/> |
||||
|
<path d="M5 16 q10 -8 20 0 q10 8 20 0 q10 -8 20 0 q10 8 20 0 q10 -8 20 0 q10 8 20 0"/> |
||||
|
</g> |
||||
|
</svg>`, |
||||
|
cloud: ` |
||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="120" height="120" viewBox="0 0 120 120"> |
||||
|
<g fill="none" stroke="#6B7A86" stroke-width="1"> |
||||
|
<path d="M10 30 C10 12 30 8 34 24 C38 6 62 10 58 28 C78 18 90 34 76 46 C96 46 96 66 72 62 C82 78 60 88 54 74 C52 92 30 92 28 74 C12 84 2 68 18 60 C0 54 2 38 22 44 C14 34 16 18 30 24 C22 26 14 26 10 30 Z"/> |
||||
|
</g> |
||||
|
</svg>` |
||||
|
} |
||||
|
</script> |
||||
|
|
||||
|
<template> |
||||
|
<div class="song-scrim" :class="{ 'song-scrim--box': box }" aria-hidden="true"> |
||||
|
<div |
||||
|
v-for="i in 4" |
||||
|
:key="i" |
||||
|
class="song-scrim__tile" |
||||
|
v-html="PATTERNS[pattern]" |
||||
|
:style="{ opacity }" |
||||
|
/> |
||||
|
</div> |
||||
|
</template> |
||||
|
|
||||
|
<style scoped> |
||||
|
:global(.song-scrim) { |
||||
|
position: fixed; |
||||
|
inset: 0; |
||||
|
z-index: 0; |
||||
|
pointer-events: none; |
||||
|
overflow: hidden; |
||||
|
display: grid; |
||||
|
grid-template-columns: repeat(2, 1fr); |
||||
|
grid-template-rows: repeat(2, 1fr); |
||||
|
} |
||||
|
|
||||
|
.song-scrim--box { |
||||
|
position: absolute !important; |
||||
|
border-radius: var(--song-radius-l); |
||||
|
} |
||||
|
|
||||
|
.song-scrim__tile { |
||||
|
width: 100%; |
||||
|
height: 100%; |
||||
|
} |
||||
|
|
||||
|
.song-scrim__tile :deep(svg) { |
||||
|
width: 100%; |
||||
|
height: 100%; |
||||
|
} |
||||
|
|
||||
|
/* 纹样线条颜色由 --song-scrim-line 按主题决定(低对比、确定性渲染,不用 blend/filter 技巧) */ |
||||
|
.song-scrim__tile :deep(path), |
||||
|
.song-scrim__tile :deep(line), |
||||
|
.song-scrim__tile :deep(circle) { |
||||
|
stroke: var(--song-scrim-line, rgba(90, 107, 116, 0.16)); |
||||
|
} |
||||
|
|
||||
|
.song-scrim--box :deep(svg) { |
||||
|
opacity: 0.5; |
||||
|
} |
||||
|
</style> |
||||
@ -0,0 +1,44 @@ |
|||||
|
import { createVNode, reactive, render } from 'vue' |
||||
|
import SongMessageContainer from '@/components/SongMessageContainer.vue' |
||||
|
|
||||
|
interface ToastItem { |
||||
|
id: number |
||||
|
type: string |
||||
|
text: string |
||||
|
} |
||||
|
|
||||
|
type ToastType = 'success' | 'danger' | 'warning' | 'info' |
||||
|
|
||||
|
const list = reactive<ToastItem[]>([]) |
||||
|
let seed = 0 |
||||
|
let hostEl: HTMLDivElement | null = null |
||||
|
|
||||
|
function remove(id: number) { |
||||
|
const i = list.findIndex((t) => t.id === id) |
||||
|
if (i > -1) list.splice(i, 1) |
||||
|
} |
||||
|
|
||||
|
function push(type: ToastType, text: string, duration = 3400) { |
||||
|
const id = ++seed |
||||
|
list.push({ id, type, text }) |
||||
|
setTimeout(() => remove(id), duration) |
||||
|
} |
||||
|
|
||||
|
function ensureHost() { |
||||
|
if (!hostEl) { |
||||
|
hostEl = document.createElement('div') |
||||
|
hostEl.className = 'song-message-host' |
||||
|
document.body.appendChild(hostEl) |
||||
|
render(createVNode(SongMessageContainer, { list }), hostEl) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export function useMessage() { |
||||
|
ensureHost() |
||||
|
return { |
||||
|
success: (text: string) => push('success', text), |
||||
|
danger: (text: string) => push('danger', text), |
||||
|
warning: (text: string) => push('warning', text), |
||||
|
info: (text: string) => push('info', text) |
||||
|
} |
||||
|
} |
||||
Write
Preview
Loading…
Cancel
Save
Reference in new issue