Browse Source

feat(component): add SongScrim and message toast

main
Mohan 1 month ago
parent
commit
b42d735870
  1. 95
      src/components/SongMessageContainer.vue
  2. 115
      src/components/SongScrim.vue
  3. 44
      src/composables/useMessage.ts

95
src/components/SongMessageContainer.vue

@ -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>

115
src/components/SongScrim.vue

@ -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>

44
src/composables/useMessage.ts

@ -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)
}
}
Loading…
Cancel
Save