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:
Sakurasan
2026-08-30 01:54:57 +08:00
parent 1e07c5903f
commit 33f5e7b71a
4 changed files with 78 additions and 82 deletions
+8
View File
@@ -284,3 +284,11 @@ src/
- 背景:daisyUI dark 主题的 primary 为紫色,普通主按钮(btn-primary)在深色模式下显示为紫底。 - 背景: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 等特殊按钮与链接、开关、焦点环均保持原样。 - 方案:`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 会话。 - 验证:`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
View File
@@ -1,25 +1,8 @@
<template> <template>
<RouterView /> <RouterView />
<Toast :queue="toastQueue" /> <Toast />
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref, provide } from 'vue';
import Toast from '@/components/common/Toast.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> </script>
+43 -49
View File
@@ -1,56 +1,50 @@
<!-- src/components/common/Toast.vue --> <!-- src/components/common/Toast.vue:daisyUI toast 容器堆叠展示全部活动提示 -->
<template> <template>
<div aria-live="polite" class="toast toast-top toast-end z-50 mt-16"> <div aria-live="polite" class="toast toast-top toast-end z-50 mt-16 gap-2">
<div <TransitionGroup name="toast">
v-if="show && currentMessage" <div
class="alert shadow-lg" v-for="t in toasts"
:class="{ :key="t.id"
'alert-error': currentMessage.type === 'error', role="status"
'alert-success': currentMessage.type === 'success', class="alert shadow-lg"
'alert-info': currentMessage.type !== 'error' && currentMessage.type !== 'success', :class="{
}" 'alert-error': t.type === 'error',
> 'alert-success': t.type === 'success',
<span>{{ currentMessage.message }}</span> 'alert-info': t.type === 'info',
</div> }"
>
<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> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onUnmounted, watch } from 'vue'; import { useToasts } from '@/composables/toast';
import type { ToastMessage } from '@/composables/toast';
const props = defineProps<{ const { toasts, dismiss } = useToasts();
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);
}
});
</script> </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>
+26 -15
View File
@@ -1,24 +1,35 @@
import { inject } from 'vue' import { ref } from 'vue'
import type { InjectionKey } from 'vue'
export type ToastType = 'info' | 'success' | 'error' export type ToastType = 'info' | 'success' | 'error'
export type ToastMessage = { export type ToastItem = {
message: string id: number
type?: ToastType message: string
duration?: number type: ToastType
duration: number
} }
export type ToastContext = { // 模块级共享状态:所有活动 toast 堆叠展示,互不阻塞
setToast: (message: string, type?: ToastType, duration?: number) => void 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 { export function useToast() {
const ctx = inject(ToastKey) return { setToast }
if (!ctx) { }
throw new Error('ToastContext 未提供:请在 App 根组件 provide(ToastKey, ...)')
} // 供 Toast 组件渲染使用
return ctx export function useToasts() {
return { toasts, dismiss }
} }