冯诚 3 年之前
父節點
當前提交
495732c075

+ 106 - 0
src/components/toast/Toast.vue

@@ -0,0 +1,106 @@
+<template>
+  <transition
+    enter-active-class="fadeIn"
+    leave-active-class="fadeOut"
+    @after-leave="$emit('closed')"
+  >
+    <div
+      v-if="visible"
+      class="ptc-toast"
+      :class="{ 'ptc-toast--loading': icon === 'loading' }"
+    >
+      <i v-if="icon !== 'none'" class="ptc-toast__icon"></i>
+      <span class="ptc-toast__message">{{ message }}</span>
+    </div>
+  </transition>
+</template>
+
+<script setup lang="ts">
+import { getCurrentInstance, ref, watch } from 'vue'
+
+const props = withDefaults(
+  defineProps<{
+    visible: boolean
+    message: string
+    duration?: number
+    icon?: 'loading' | 'none'
+  }>(),
+  {
+    duration: 2000,
+    icon: 'none',
+  }
+)
+const emit = defineEmits<{
+  (e: 'closed'): void
+  (e: 'update:visible', val: boolean): void
+}>()
+
+let tid: NodeJS.Timeout
+
+const close = () => emit('update:visible', false)
+const reset = () => {
+  clearTimeout(tid)
+  if (props.duration) {
+    tid = setTimeout(close, props.duration)
+  }
+}
+
+watch(
+  () => props.visible,
+  val => val && reset()
+)
+watch(() => props.message, reset)
+</script>
+
+<style lang="scss">
+.ptc-toast {
+  position: fixed;
+  top: 50%;
+  left: 50%;
+  transform: translate(-50%, -50%);
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  box-sizing: content-box;
+
+  // hack for avoid max-width when use left & fixed
+  min-width: 192px;
+  max-width: 60%;
+  width: fit-content;
+  padding: 22px 38px;
+  color: #fff;
+  font-size: 28px;
+  line-height: 40px;
+
+  // allow newline charactor
+  white-space: pre-wrap;
+  text-align: center;
+  word-wrap: break-word;
+  background-color: rgba(0, 0, 0, 0.7);
+  border-radius: 12px;
+  z-index: 9999;
+
+  &--loading {
+    padding: 56px 24px 40px;
+  }
+
+  &__icon {
+    margin-bottom: 32px;
+    width: 64px;
+    height: 64px;
+    background-size: contain;
+
+    .ptc-toast--loading & {
+      background-image: url(@img/loading.svg);
+      animation: rotate 1s linear infinite;
+    }
+  }
+
+  @keyframes rotate {
+    100% {
+      transform: rotate(360deg);
+    }
+  }
+}
+</style>

+ 70 - 0
src/components/toast/index.ts

@@ -0,0 +1,70 @@
+import { reactive, h } from 'vue'
+import PtcToast from './Toast.vue'
+import { mountComponent } from '../utils/mount-component'
+
+interface ToastProps {
+  visible: boolean
+  message: string
+  duration?: number
+  icon?: 'loading' | 'none'
+}
+
+type ToastOptions = Omit<ToastProps, 'visible'>
+
+interface ToastExposed {
+  open(options: ToastOptions): void
+  close(): void
+}
+
+const defaultOptions: Partial<ToastOptions> = {
+  duration: 2000,
+  icon: 'none',
+}
+
+let instance: ToastExposed | undefined
+
+function getInstance() {
+  if (!instance) {
+    instance = mountComponent<ToastExposed>({
+      setup(props, { expose }) {
+        const state = reactive<ToastProps>({ visible: false, message: '' })
+        const attrs = {
+          'onUpdate:visible': (v: boolean) => {
+            state.visible = v
+            v || document.body.classList.remove('lock')
+          },
+        }
+        const open = (options: ToastOptions) => {
+          state.visible = true
+          Object.assign(state, { ...defaultOptions, ...options })
+        }
+        const close = () => {
+          state.visible = false
+        }
+        expose({ open, close })
+
+        return () => h(PtcToast, { ...state, ...attrs })
+      },
+    }).instance
+  }
+  return instance
+}
+
+export default function Toast(options: string | ToastOptions) {
+  if (typeof options === 'string') options = { message: options }
+  const toast = getInstance()
+  toast.open(options)
+  options.icon === 'loading'
+    ? document.body.classList.add('lock')
+    : document.body.classList.remove('lock')
+  return toast
+}
+
+Toast.loading = (message = '加载中...') =>
+  Toast({ message, duration: 0, icon: 'loading' })
+Toast.hide = () => {
+  instance?.close()
+}
+
+const win: any = window
+win.Toast = Toast

+ 16 - 0
src/components/utils/mount-component.ts

@@ -0,0 +1,16 @@
+import { createApp, Component } from 'vue'
+
+export function mountComponent<Exposed = any>(RootComponent: Component) {
+  const app = createApp(RootComponent)
+  const root = document.createElement('div')
+
+  document.body.appendChild(root)
+
+  return {
+    instance: app.mount(root) as any as Exposed,
+    unmount() {
+      app.unmount()
+      document.body.removeChild(root)
+    },
+  }
+}

+ 2 - 1
src/hooks/useForm.ts

@@ -1,6 +1,7 @@
 import { reactive } from 'vue'
 import { object, setLocale, AnySchema, ValidationError } from 'yup'
 import { debounce } from 'lodash-es'
+import Toast from '@/components/toast'
 
 type FormSchemaShap<T> = {
   [K in keyof T]?: AnySchema
@@ -27,7 +28,7 @@ export default function useForm<Shap extends object>(
         .validate(values)
         .then(() => onSubmit(values))
         .catch((err: ValidationError) => {
-          console.error(err.message)
+          Toast(err.message)
           onInvalidSubmit?.(err)
         })
     },

+ 1 - 4
src/service/request.ts

@@ -1,8 +1,5 @@
 import axios, { AxiosRequestConfig, Canceler } from 'axios'
-
-const Toast = (message: string) => {}
-Toast.loading = () => {}
-Toast.hide = () => {}
+import Toast from '@/components/toast'
 
 export const netErrMsg = '系统开小差了,请稍候再试'
 

+ 8 - 0
src/style/atom.scss

@@ -46,3 +46,11 @@
 .pointer {
   cursor: pointer;
 }
+
+.lock-scroll {
+  overflow: hidden;
+}
+.lock {
+  overflow: hidden;
+  pointer-events: none;
+}