import { View, Text } from "@tarojs/components"
import Taro from "@tarojs/taro"
import { useEffect, useRef, useState } from "react"
import "./index.less"
type ToastIcon = "success" | "error" | "loading" | "none"
interface ToastOptions {
/** 提示内容,无字数限制 */
title: string
/** 图标,默认 none */
icon?: ToastIcon
/** 显示时长(ms),loading 模式下不生效,默认 1500 */
duration?: number
/** 是否显示透明遮罩,防止触摸穿透,默认 false */
mask?: boolean
}
interface ToastInstance {
show: (options: ToastOptions | string) => void
hide: () => void
}
// 当前激活的 Toast 实例(由挂载在页面中的 <Toast /> 注册)
let activeInstance: ToastInstance | null = null
const resolveOptions = (options: ToastOptions | string): Required<ToastOptions> => {
const opts: ToastOptions = typeof options === "string" ? { title: options } : options
return {
title: opts.title ?? "",
icon: opts.icon ?? "none",
duration: opts.duration ?? 1500,
mask: opts.mask ?? false,
}
}
const show = (options: ToastOptions | string) => {
if (!activeInstance) {
// 未挂载 <Toast /> 时,回退到原生 showToast(仍有字数限制)
const opts = resolveOptions(options)
Taro.showToast({ title: opts.title, icon: opts.icon as any, duration: opts.duration, mask: opts.mask })
return
}
activeInstance.show(options)
}
const hide = () => {
activeInstance?.hide()
}
export interface ToastComponent extends React.FC {
show: (options: ToastOptions | string) => void
success: (title: string, duration?: number) => void
error: (title: string, duration?: number) => void
loading: (title: string, mask?: boolean) => void
hide: () => void
}
const ToastInner: React.FC = () => {
const [visible, setVisible] = useState(false)
const [options, setOptions] = useState<Required<ToastOptions>>({
title: "",
icon: "none",
duration: 1500,
mask: false,
})
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const clearTimer = () => {
if (timerRef.current) {
clearTimeout(timerRef.current)
timerRef.current = null
}
}
const doShow = (opts: ToastOptions | string) => {
const finalOpts = resolveOptions(opts)
clearTimer()
setOptions(finalOpts)
setVisible(true)
// loading 模式不自动消失,需手动调用 hide()
if (finalOpts.icon !== "loading") {
timerRef.current = setTimeout(() => {
setVisible(false)
}, finalOpts.duration)
}
}
const doHide = () => {
clearTimer()
setVisible(false)
}
useEffect(() => {
activeInstance = { show: doShow, hide: doHide }
return () => {
clearTimer()
if (activeInstance && activeInstance.show === doShow) {
activeInstance = null
}
}
}, [])
if (!visible) return null
const { icon, mask } = options
const hasIcon = icon !== "none"
return (
<View className={`ec-toast ${mask ? "ec-toast--mask" : ""}`}>
<View className="ec-toast__box">
{hasIcon && (
<View className="ec-toast__icon">
{icon === "success" && <View className="ec-toast__success" />}
{icon === "error" && (
<View className="ec-toast__error">
<View className="ec-toast__line ec-toast__line--1" />
<View className="ec-toast__line ec-toast__line--2" />
</View>
)}
{icon === "loading" && <View className="ec-toast__loading" />}
</View>
)}
{options.title ? <Text className="ec-toast__title">{options.title}</Text> : null}
</View>
</View>
)
}
// 将命令式 API 挂在默认导出的组件上
const ExportedToast = Object.assign(ToastInner, {
show,
success: (title: string, duration = 1500) => show({ title, icon: "success", duration }),
error: (title: string, duration = 1500) => show({ title, icon: "error", duration }),
loading: (title: string, mask = true) => show({ title, icon: "loading", mask, duration: 0 }),
hide,
}) as ToastComponent
export default ExportedToast
.ec-toast {
position: fixed;
left: 0;
top: 0;
right: 0;
bottom: 0;
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
// 默认透明 + 点击穿透,行为贴近微信 showToast(mask:false)
background: rgba(0, 0, 0, 0);
pointer-events: none;
// mask 模式:半透明遮罩 + 阻挡触摸
&--mask {
background: rgba(0, 0, 0, 0.35);
pointer-events: auto;
}
&__box {
min-width: 180rpx;
max-width: 560rpx;
padding: 32rpx 40rpx;
background: rgba(0, 0, 0, 0.8);
border-radius: 16rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
box-sizing: border-box;
pointer-events: auto;
}
&__icon {
width: 80rpx;
height: 80rpx;
margin-bottom: 16rpx;
display: flex;
align-items: center;
justify-content: center;
}
&__title {
color: #fff;
font-size: 30rpx;
line-height: 1.5;
text-align: center;
word-break: break-all;
white-space: pre-wrap;
}
// success 图标:圆 + 勾
&__success {
width: 80rpx;
height: 80rpx;
border-radius: 50%;
background: #fff;
position: relative;
&::after {
content: "";
position: absolute;
left: 26rpx;
top: 14rpx;
width: 22rpx;
height: 40rpx;
border-right: 4rpx solid #07c160;
border-bottom: 4rpx solid #07c160;
transform: rotate(45deg);
}
}
// error 图标:圆 + 叉
&__error {
width: 80rpx;
height: 80rpx;
border-radius: 50%;
background: #fff;
position: relative;
}
&__line {
position: absolute;
left: 50%;
top: 50%;
width: 44rpx;
height: 4rpx;
background: #fa5151;
border-radius: 2rpx;
&--1 {
transform: translate(-50%, -50%) rotate(45deg);
}
&--2 {
transform: translate(-50%, -50%) rotate(-45deg);
}
}
// loading 图标:旋转圆环
&__loading {
width: 64rpx;
height: 64rpx;
border: 6rpx solid rgba(255, 255, 255, 0.25);
border-top-color: #fff;
border-radius: 50%;
animation: ec-toast-spin 0.8s linear infinite;
}
}
@keyframes ec-toast-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}