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