4 changed files with 211 additions and 0 deletions
-
84src/components/SongMessageContainer.vue
-
55src/components/index.js
-
38src/composables/useMessage.js
-
34src/composables/useSongTheme.js
@ -0,0 +1,84 @@ |
|||||
|
<script setup> |
||||
|
/* SongMessageContainer — 提示框承载层(雾隐淡入,墨线 + 小章意象) */ |
||||
|
defineProps({ |
||||
|
list: { type: Array, required: true } |
||||
|
}) |
||||
|
</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; |
||||
|
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,55 @@ |
|||||
|
import SongButton from './SongButton.vue' |
||||
|
import SongCard from './SongCard.vue' |
||||
|
import SongTag from './SongTag.vue' |
||||
|
import SongInput from './SongInput.vue' |
||||
|
import SongNav from './SongNav.vue' |
||||
|
import SongTable from './SongTable.vue' |
||||
|
import SongBadge from './SongBadge.vue' |
||||
|
import SongProgress from './SongProgress.vue' |
||||
|
import SongModal from './SongModal.vue' |
||||
|
import SongScrim from './SongScrim.vue' |
||||
|
import SongLogo from './SongLogo.vue' |
||||
|
import SongDisclaimer from './SongDisclaimer.vue' |
||||
|
import SongLoading from './SongLoading.vue' |
||||
|
import SongSeal from './SongSeal.vue' |
||||
|
|
||||
|
export { |
||||
|
SongButton, |
||||
|
SongCard, |
||||
|
SongTag, |
||||
|
SongInput, |
||||
|
SongNav, |
||||
|
SongTable, |
||||
|
SongBadge, |
||||
|
SongProgress, |
||||
|
SongModal, |
||||
|
SongScrim, |
||||
|
SongLogo, |
||||
|
SongDisclaimer, |
||||
|
SongLoading, |
||||
|
SongSeal |
||||
|
} |
||||
|
|
||||
|
export const SongComponents = { |
||||
|
install(app) { |
||||
|
const map = { |
||||
|
SongButton, |
||||
|
SongCard, |
||||
|
SongTag, |
||||
|
SongInput, |
||||
|
SongNav, |
||||
|
SongTable, |
||||
|
SongBadge, |
||||
|
SongProgress, |
||||
|
SongModal, |
||||
|
SongScrim, |
||||
|
SongLogo, |
||||
|
SongDisclaimer, |
||||
|
SongLoading, |
||||
|
SongSeal |
||||
|
} |
||||
|
for (const [name, c] of Object.entries(map)) { |
||||
|
app.component(name, c) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,38 @@ |
|||||
|
import { reactive, createVNode, render } from 'vue' |
||||
|
import SongMessageContainer from '../components/SongMessageContainer.vue' |
||||
|
|
||||
|
const list = reactive([]) |
||||
|
let seed = 0 |
||||
|
let hostEl = null |
||||
|
|
||||
|
function push(type, text, duration = 3400) { |
||||
|
const id = ++seed |
||||
|
list.push({ id, type, text }) |
||||
|
setTimeout(() => remove(id), duration) |
||||
|
} |
||||
|
|
||||
|
function remove(id) { |
||||
|
const i = list.findIndex(t => t.id === id) |
||||
|
if (i > -1) list.splice(i, 1) |
||||
|
} |
||||
|
|
||||
|
function ensureHost() { |
||||
|
if (!hostEl) { |
||||
|
hostEl = document.createElement('div') |
||||
|
hostEl.className = 'song-message-host' |
||||
|
document.body.appendChild(hostEl) |
||||
|
const vnode = createVNode(SongMessageContainer, { list }) |
||||
|
render(vnode, hostEl) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export function useMessage() { |
||||
|
ensureHost() |
||||
|
return { |
||||
|
list, |
||||
|
success: text => push('success', text), |
||||
|
danger: text => push('danger', text), |
||||
|
warning: text => push('warning', text), |
||||
|
info: text => push('info', text) |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,34 @@ |
|||||
|
import { ref, reactive } from 'vue' |
||||
|
|
||||
|
const STORAGE_KEY = 'song-theme' |
||||
|
const isDark = ref(false) |
||||
|
let initialized = false |
||||
|
|
||||
|
function apply(v) { |
||||
|
isDark.value = v |
||||
|
document.documentElement.setAttribute('data-theme', v ? 'dark' : 'light') |
||||
|
try { |
||||
|
localStorage.setItem(STORAGE_KEY, v ? 'dark' : 'light') |
||||
|
} catch { /* ignore */ } |
||||
|
} |
||||
|
|
||||
|
const theme = reactive({ |
||||
|
isDark, |
||||
|
toggle() { |
||||
|
apply(!isDark.value) |
||||
|
} |
||||
|
}) |
||||
|
|
||||
|
export function useSongTheme() { |
||||
|
if (!initialized) { |
||||
|
initialized = true |
||||
|
let stored = null |
||||
|
try { stored = localStorage.getItem(STORAGE_KEY) } catch { /* ignore */ } |
||||
|
const prefersDark = typeof window !== 'undefined' && |
||||
|
window.matchMedia('(prefers-color-scheme: dark)').matches |
||||
|
apply(stored ? stored === 'dark' : prefersDark) |
||||
|
} |
||||
|
return theme |
||||
|
} |
||||
|
|
||||
|
export const initSongTheme = useSongTheme |
||||
Write
Preview
Loading…
Cancel
Save
Reference in new issue