rewrite: toast stacking with daisyui toast container
- module-level reactive toast list in composables/toast (setToast signature unchanged, 13 call sites untouched) - multiple toasts stack simultaneously in daisyUI toast container, each auto-dismisses (3s) with close button - TransitionGroup enter/leave animation (transform/opacity, honors reduced-motion) - drop provide/inject queue that blocked consecutive toasts
This commit is contained in:
@@ -284,3 +284,11 @@ src/
|
||||
- 背景:daisyUI dark 主题的 primary 为紫色,普通主按钮(btn-primary)在深色模式下显示为紫底。
|
||||
- 方案:`main.css` 覆写 `.btn-primary` 的 daisyUI 颜色变量(`--btn-color` / `--btn-fg`)——浅色主题黑底白字(#171717/#fff,hover 纯黑)、深色主题白底黑字(#fff/#171717,hover 浅灰)。仅影响 btn-primary;success/error/warning/outline/ghost 等特殊按钮与链接、开关、焦点环均保持原样。
|
||||
- 验证:`pnpm build` 通过;浏览器实测深色(白底黑字 New Token)与浅色(黑底白字 Log In)两种主题,特殊按钮未受影响;已恢复维护者的 auto 主题偏好与 admin 会话。
|
||||
|
||||
### 增量 7 — Toast 重写:多实例堆叠(维护者反馈)✅
|
||||
|
||||
- 背景:原实现为串行队列(processQueue 一次展示一条),连续操作时提示互相阻塞。
|
||||
- daisyUI 的 `toast` 组件本身只负责定位与堆叠(容器内多个 `alert` 自动纵向排列),队列/自动消失/动画需应用层实现——已按此重写:
|
||||
- `composables/toast.ts`:模块级响应式 `toasts` 列表,`setToast(message, type?, duration?)` 推入带唯一 id 的条目并定时自动移除(默认 3s);`useToast()` 签名不变,13 个调用视图零改动;移除原 provide/inject 方案。
|
||||
- `Toast.vue`:daisyUI `toast toast-top toast-end` 容器 + `TransitionGroup` 进出场动画(仅 transform/opacity,配合全局 reduced-motion 降级)、每条带关闭按钮(aria-label)、容器 `aria-live="polite"`。
|
||||
- 验证:`pnpm build` 通过;浏览器实测连续触发两条 toast 同时堆叠展示、3s 后全部自动消失。
|
||||
|
||||
+1
-18
@@ -1,25 +1,8 @@
|
||||
<template>
|
||||
|
||||
<RouterView />
|
||||
<Toast :queue="toastQueue" />
|
||||
|
||||
<Toast />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, provide } from 'vue';
|
||||
import Toast from '@/components/common/Toast.vue';
|
||||
import { ToastKey } from './composables/toast';
|
||||
import type { ToastMessage, ToastType } from './composables/toast';
|
||||
|
||||
const toastQueue = ref<ToastMessage[]>([]);
|
||||
|
||||
const setToast = (message: string, type: ToastType = 'info', duration?: number) => {
|
||||
toastQueue.value.push({ message, type, duration });
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
// authStore.checkLoginStatus();
|
||||
});
|
||||
|
||||
provide(ToastKey, { setToast });
|
||||
</script>
|
||||
|
||||
@@ -1,56 +1,50 @@
|
||||
<!-- src/components/common/Toast.vue -->
|
||||
<!-- src/components/common/Toast.vue:daisyUI toast 容器堆叠展示全部活动提示 -->
|
||||
<template>
|
||||
<div aria-live="polite" class="toast toast-top toast-end z-50 mt-16">
|
||||
<div
|
||||
v-if="show && currentMessage"
|
||||
class="alert shadow-lg"
|
||||
:class="{
|
||||
'alert-error': currentMessage.type === 'error',
|
||||
'alert-success': currentMessage.type === 'success',
|
||||
'alert-info': currentMessage.type !== 'error' && currentMessage.type !== 'success',
|
||||
}"
|
||||
>
|
||||
<span>{{ currentMessage.message }}</span>
|
||||
</div>
|
||||
<div aria-live="polite" class="toast toast-top toast-end z-50 mt-16 gap-2">
|
||||
<TransitionGroup name="toast">
|
||||
<div
|
||||
v-for="t in toasts"
|
||||
:key="t.id"
|
||||
role="status"
|
||||
class="alert shadow-lg"
|
||||
:class="{
|
||||
'alert-error': t.type === 'error',
|
||||
'alert-success': t.type === 'success',
|
||||
'alert-info': t.type === 'info',
|
||||
}"
|
||||
>
|
||||
<span class="min-w-0 flex-1 break-words">{{ t.message }}</span>
|
||||
<button type="button" class="btn btn-ghost btn-xs" aria-label="关闭提示" @click="dismiss(t.id)">✕</button>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onUnmounted, watch } from 'vue';
|
||||
import type { ToastMessage } from '@/composables/toast';
|
||||
import { useToasts } from '@/composables/toast';
|
||||
|
||||
const props = defineProps<{
|
||||
queue: ToastMessage[];
|
||||
}>();
|
||||
|
||||
const show = ref(false);
|
||||
const currentMessage = ref<ToastMessage | null>(null);
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const processQueue = () => {
|
||||
if (props.queue.length === 0) {
|
||||
show.value = false;
|
||||
currentMessage.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
currentMessage.value = props.queue.shift() ?? null;
|
||||
show.value = true;
|
||||
|
||||
timer = setTimeout(() => {
|
||||
processQueue();
|
||||
}, currentMessage.value?.duration || 3000);
|
||||
};
|
||||
|
||||
watch(() => props.queue, () => {
|
||||
if (!show.value) {
|
||||
processQueue();
|
||||
}
|
||||
}, { deep: true });
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
});
|
||||
const { toasts, dismiss } = useToasts();
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 只动 transform/opacity;系统开启减弱动态效果时由全局样式禁用 */
|
||||
.toast-enter-active,
|
||||
.toast-leave-active {
|
||||
transition: opacity 0.25s ease, transform 0.25s ease;
|
||||
}
|
||||
|
||||
.toast-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
|
||||
.toast-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(16px);
|
||||
}
|
||||
|
||||
.toast-leave-active {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,24 +1,35 @@
|
||||
import { inject } from 'vue'
|
||||
import type { InjectionKey } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
|
||||
export type ToastType = 'info' | 'success' | 'error'
|
||||
|
||||
export type ToastMessage = {
|
||||
message: string
|
||||
type?: ToastType
|
||||
duration?: number
|
||||
export type ToastItem = {
|
||||
id: number
|
||||
message: string
|
||||
type: ToastType
|
||||
duration: number
|
||||
}
|
||||
|
||||
export type ToastContext = {
|
||||
setToast: (message: string, type?: ToastType, duration?: number) => void
|
||||
// 模块级共享状态:所有活动 toast 堆叠展示,互不阻塞
|
||||
const toasts = ref<ToastItem[]>([])
|
||||
let seed = 0
|
||||
|
||||
function dismiss(id: number) {
|
||||
toasts.value = toasts.value.filter(t => t.id !== id)
|
||||
}
|
||||
|
||||
export const ToastKey: InjectionKey<ToastContext> = Symbol('toast')
|
||||
export function setToast(message: string, type: ToastType = 'info', duration = 3000) {
|
||||
const id = ++seed
|
||||
toasts.value.push({ id, message, type, duration })
|
||||
if (duration > 0) {
|
||||
setTimeout(() => dismiss(id), duration)
|
||||
}
|
||||
}
|
||||
|
||||
export function useToast(): ToastContext {
|
||||
const ctx = inject(ToastKey)
|
||||
if (!ctx) {
|
||||
throw new Error('ToastContext 未提供:请在 App 根组件 provide(ToastKey, ...)')
|
||||
}
|
||||
return ctx
|
||||
export function useToast() {
|
||||
return { setToast }
|
||||
}
|
||||
|
||||
// 供 Toast 组件渲染使用
|
||||
export function useToasts() {
|
||||
return { toasts, dismiss }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user