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)在深色模式下显示为紫底。
|
- 背景: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
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user