feat:添加Toast组件

This commit is contained in:
zyronon
2025-08-12 22:53:08 +08:00
parent b8ab369d54
commit 04e5161554
8 changed files with 389 additions and 37 deletions

View File

@@ -6,7 +6,7 @@ import {createPinia} from "pinia"
import router from "@/router.ts";
import VueVirtualScroller from 'vue-virtual-scroller'
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'
import './global.d.ts'
import './types/global.d.ts'
const pinia = createPinia()
const app = createApp(App)

View File

@@ -0,0 +1,120 @@
import {createVNode, render} from 'vue'
import ToastComponent from '@/pages/pc/components/Toast/Toast.vue'
import type {ToastOptions, ToastInstance, ToastService} from '@/pages/pc/components/Toast/type.ts'
interface ToastContainer {
id: string
container: HTMLElement
instance: ToastInstance
offset: number
}
let toastContainers: ToastContainer[] = []
let toastIdCounter = 0
// 创建Toast容器
const createToastContainer = (): HTMLElement => {
const container = document.createElement('div')
container.className = 'toast-container'
container.style.cssText = `
position: fixed;
top: 20px;
left: 50%;
transform: translateX(-50%);
z-index: 9999;
pointer-events: none;
`
return container
}
// 更新所有Toast的位置
const updateToastPositions = () => {
toastContainers.forEach((toastContainer, index) => {
const offset = index * 70 // 每个Toast之间的间距从80px减少到50px
toastContainer.offset = offset
toastContainer.container.style.marginTop = `${offset}px`
})
}
// 移除Toast容器
const removeToastContainer = (id: string) => {
const index = toastContainers.findIndex(container => container.id === id)
if (index > -1) {
const container = toastContainers[index]
// 延迟销毁,等待动画完成
setTimeout(() => {
render(null, container.container)
container.container.remove()
const currentIndex = toastContainers.findIndex(c => c.id === id)
if (currentIndex > -1) {
toastContainers.splice(currentIndex, 1)
updateToastPositions()
}
}, 300) // 等待动画完成0.3秒)
}
}
const Toast: ToastService = (options: ToastOptions | string): ToastInstance => {
const toastOptions = typeof options === 'string' ? {message: options} : options
const id = `toast-${++toastIdCounter}`
// 创建Toast容器
const container = createToastContainer()
document.body.appendChild(container)
// 创建VNode
const vnode = createVNode(ToastComponent, {
...toastOptions,
onClose: () => {
removeToastContainer(id)
}
})
// 渲染到容器
render(vnode, container)
// 创建实例
const instance: ToastInstance = {
close: () => {
vnode.component?.exposed?.close?.()
}
}
// 添加到容器列表
const toastContainer: ToastContainer = {
id,
container,
instance,
offset: 0
}
toastContainers.push(toastContainer)
updateToastPositions()
return instance
}
// 添加类型方法
Toast.success = (message: string, options?: Omit<ToastOptions, 'message' | 'type'>) => {
return Toast({message, type: 'success', ...options})
}
Toast.warning = (message: string, options?: Omit<ToastOptions, 'message' | 'type'>) => {
return Toast({message, type: 'warning', ...options})
}
Toast.info = (message: string, options?: Omit<ToastOptions, 'message' | 'type'>) => {
return Toast({message, type: 'info', ...options})
}
Toast.error = (message: string, options?: Omit<ToastOptions, 'message' | 'type'>) => {
return Toast({message, type: 'error', ...options})
}
// 关闭所有消息
Toast.closeAll = () => {
toastContainers.forEach(container => container.instance.close())
toastContainers = []
}
export default Toast

View File

@@ -0,0 +1,206 @@
<template>
<Transition name="message-fade" appear>
<div v-if="visible" class="message" :class="type" :style="style" @mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave">
<div class="message-content">
<Icon v-if="icon" :icon="icon" class="message-icon" />
<span class="message-text">{{ message }}</span>
<Icon v-if="showClose" icon="mdi:close" class="message-close" @click="close" />
</div>
</div>
</Transition>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { Icon } from '@iconify/vue'
interface Props {
message: string
type?: 'success' | 'warning' | 'info' | 'error'
duration?: number
showClose?: boolean
}
const props = withDefaults(defineProps<Props>(), {
type: 'info',
duration: 3000,
showClose: false
})
const emit = defineEmits(['close'])
const visible = ref(false)
let timer = null
const icon = computed(() => {
const icons = {
success: 'mdi:check-circle',
warning: 'mdi:alert-circle',
info: 'mdi:information',
error: 'mdi:close-circle'
}
return icons[props.type]
})
const style = computed(() => ({
// 移除offset现在由容器管理位置
}))
const startTimer = () => {
if (props.duration > 0) {
timer = setTimeout(close, props.duration)
}
}
const clearTimer = () => {
if (timer) {
clearTimeout(timer)
timer = null
}
}
const handleMouseEnter = () => {
clearTimer()
}
const handleMouseLeave = () => {
startTimer()
}
const close = () => {
visible.value = false
// 延迟发出close事件等待动画完成
setTimeout(() => {
emit('close')
}, 300) // 等待动画完成0.3秒)
}
onMounted(() => {
visible.value = true
startTimer()
})
onBeforeUnmount(() => {
clearTimer()
})
// 暴露方法给父组件
defineExpose({
close,
show: () => {
visible.value = true
startTimer()
}
})
</script>
<style scoped lang="scss">
.message {
position: relative;
min-width: 16rem;
padding: 0.8rem 1rem;
border-radius: 0.2rem;
box-shadow: 0 0.2rem 0.9rem rgba(0, 0, 0, 0.15);
background: white;
border: 1px solid #ebeef5;
transition: all 0.3s ease;
pointer-events: auto;
&.success {
background: #f0f9ff;
border-color: #67c23a;
color: #67c23a;
}
&.warning {
background: #fdf6ec;
border-color: #e6a23c;
color: #e6a23c;
}
&.info {
background: #f4f4f5;
border-color: #909399;
color: #909399;
}
&.error {
background: #fef0f0;
border-color: #f56c6c;
color: #f56c6c;
}
}
// 深色模式支持
html.dark {
.message {
background: var(--color-second);
border-color: var(--color-item-border);
color: var(--color-main-text);
&.success {
background: rgba(103, 194, 58, 0.1);
border-color: #67c23a;
color: #67c23a;
}
&.warning {
background: rgba(230, 162, 60, 0.1);
border-color: #e6a23c;
color: #e6a23c;
}
&.info {
background: rgba(144, 147, 153, 0.1);
border-color: #909399;
color: #909399;
}
&.error {
background: rgba(245, 108, 108, 0.1);
border-color: #f56c6c;
color: #f56c6c;
}
}
}
.message-content {
display: flex;
align-items: center;
gap: 8px;
}
.message-icon {
font-size: 1.2rem;
}
.message-text {
flex: 1;
font-size: 14px;
}
.message-close {
cursor: pointer;
font-size: 1.2rem;
opacity: 0.7;
&:hover {
opacity: 1;
}
}
.message-fade-enter-active,
.message-fade-leave-active {
transition: all 0.3s ease;
}
.message-fade-enter-from {
opacity: 0;
transform: translateY(-40px);
}
.message-fade-leave-to {
opacity: 0;
transform: translateY(-40px);
}
</style>

View File

@@ -0,0 +1,26 @@
export type ToastType = 'success' | 'warning' | 'info' | 'error'
export interface ToastOptions {
message: string
type?: ToastType
duration?: number
showClose?: boolean
}
export interface ToastInstance {
close: () => void
}
export interface ToastService {
(options: ToastOptions | string): ToastInstance
success(message: string, options?: Omit<ToastOptions, 'message' | 'type'>): ToastInstance
warning(message: string, options?: Omit<ToastOptions, 'message' | 'type'>): ToastInstance
info(message: string, options?: Omit<ToastOptions, 'message' | 'type'>): ToastInstance
error(message: string, options?: Omit<ToastOptions, 'message' | 'type'>): ToastInstance
closeAll(): void
}

View File

@@ -12,10 +12,10 @@ import {getCurrentStudyWord} from "@/hooks/dict.ts";
import {useRuntimeStore} from "@/stores/runtime.ts";
import Book from "@/pages/pc/components/Book.vue";
import PopConfirm from "@/pages/pc/components/PopConfirm.vue";
import {ElMessage, ElProgress, ElSlider} from 'element-plus';
import {ElProgress, ElSlider} from 'element-plus';
import Toast from '@/pages/pc/components/Toast/Toast.ts';
import BaseButton from "@/components/BaseButton.vue";
import {getDefaultDict} from "@/types/func.ts";
import ConflictNotice from "@/pages/pc/components/ConflictNotice.vue";
const store = useBaseStore()
const router = useRouter()
@@ -47,7 +47,7 @@ async function init() {
function startStudy() {
if (store.sdict.id) {
if (!store.sdict.words.length) {
return ElMessage.warning('没有单词可学习!')
return Toast.warning('没有单词可学习!')
}
window.umami?.track('startStudyDict', {
name: store.sdict.name,
@@ -59,7 +59,7 @@ function startStudy() {
nav('study-word', {}, currentStudy)
} else {
window.umami?.track('no-dict')
ElMessage.warning('请先选择一本词典')
Toast.warning('请先选择一本词典')
}
}
@@ -68,7 +68,7 @@ function setPerDayStudyNumber() {
show = true
tempPerDayStudyNumber = store.sdict.perDayStudyNumber
} else {
ElMessage.warning('请先选择一本词典')
Toast.warning('请先选择一本词典')
}
}
@@ -102,7 +102,7 @@ function handleBatchDel() {
}
})
selectIds = []
ElMessage.success("删除成功!")
Toast.success("删除成功!")
}
function toggleSelect(item) {
@@ -181,7 +181,8 @@ const progressTextRight = $computed(() => {
</div>
个单词 <span class="color-blue cursor-pointer" @click="setPerDayStudyNumber">更改</span>
</div>
<BaseButton size="large" :disabled="!store.sdict.name" @click="startStudy">
<!-- <BaseButton size="large" :disabled="!store.sdict.name" @click="startStudy">-->
<BaseButton size="large" @click="startStudy">
<div class="flex items-center gap-2">
<span>开始学习</span>
<Icon icon="icons8:right-round" class="text-2xl"/>

View File

@@ -1,5 +1,3 @@
export {}
declare global {
interface Console {
parse(v: any): void
@@ -9,10 +7,11 @@ declare global {
interface Window {
umami: {
track(name:string,data?:any):void
track(name: string, data?: any): void
}
}
}
console.json = function (v: any, space = 0) {
const json = JSON.stringify(
v,
@@ -30,3 +29,5 @@ console.json = function (v: any, space = 0) {
console.parse = function (v: any) {
console.log(JSON.parse(v))
}
export {}

View File

@@ -47,9 +47,7 @@
"src/**/*.tsx",
"src/**/*.d.ts",
"src/**/*.vue",
"auto-imports.d.ts",
"src/vite-env.d.ts",
"src/global.d.ts"
],
"references": [
{

View File

@@ -42,25 +42,25 @@ export default defineConfig(() => {
open: true //如果存在本地服务端口,将在打包后自动展示
}) : null,
SlidePlugin(),
// importToCDN({
// modules: [
// {
// name: 'vue',
// var: 'Vue',
// path: `https://cdn.jsdelivr.net/npm/vue@3.5.14/dist/vue.global.prod.min.js`
// },
// {
// name: 'vue-router',
// var: 'VueRouter',
// path: `https://cdn.jsdelivr.net/npm/vue-router@4.5.1/dist/vue-router.global.prod.min.js`
// },
// {
// name: 'axios',
// var: 'axios',
// path: 'https://cdn.jsdelivr.net/npm/axios@1.9.0/dist/axios.min.js'
// },
// ]
// })
importToCDN({
modules: [
{
name: 'vue',
var: 'Vue',
path: `https://type-words.oss-cn-shenzhen.aliyuncs.com/vue.global.prod.min.js`
},
{
name: 'vue-router',
var: 'VueRouter',
path: `https://type-words.oss-cn-shenzhen.aliyuncs.com/vue-router.global.prod.min.js`
},
{
name: 'axios',
var: 'axios',
path: 'https://type-words.oss-cn-shenzhen.aliyuncs.com/axios.min.js'
},
]
})
],
define: {
LATEST_COMMIT_HASH: JSON.stringify(latestCommitHash + (process.env.NODE_ENV === 'production' ? '' : ' (dev)')),
@@ -73,11 +73,11 @@ export default defineConfig(() => {
},
extensions: ['.mjs', '.js', '.ts', '.jsx', '.tsx', '.json', '.vue']
},
// build: {
// rollupOptions: {
// external: ['axios'],// 使用全局的 axios。因为百度翻译库内部用了0.19版本的axios会被打包到代码里面
// }
// },
build: {
rollupOptions: {
external: ['axios'],// 使用全局的 axios。因为百度翻译库内部用了0.19版本的axios会被打包到代码里面
}
},
css: {
preprocessorOptions: {
scss: {