Просмотр исходного кода

添加封装api与备课埋点

11wqe1 6 дней назад
Родитель
Сommit
89cadf4b74

+ 1 - 0
package.json

@@ -10,6 +10,7 @@
     "test:unit": "vitest"
   },
   "dependencies": {
+    "@icon-park/vue-next": "^1.4.2",
     "axios": "^1.6.7",
     "element-plus": "^2.5.3",
     "file-saver": "^2.0.5",

+ 1 - 1
src/assets/course.json

@@ -375,7 +375,7 @@
                     "courseType": "2"
                 },
                 {
-                    "id": "0f22b4e4-16ce-11f1-bcd9-005056924926",
+                    "id": "199e61a2-12dc-11f1-bcd9-005056924926",
                     "title": "第2课 物种多样性保护",
                     "url": "https://ccrb.s3.cn-northwest-1.amazonaws.com.cn/%E3%80%90%E5%85%AD%E5%B9%B4%E7%BA%A7%E3%80%91%E4%B8%8B%E5%86%8C%20%E7%AC%AC2%E8%AF%BE%20%E7%89%A9%E7%A7%8D%E5%A4%9A%E6%A0%B7%E6%80%A7%E4%BF%9D%E6%8A%A41772521114524.jpg",
                     "dataId": "new2",

+ 182 - 0
src/components/Message.vue

@@ -0,0 +1,182 @@
+<template>
+  <Transition 
+    name="message-fade" 
+    appear 
+    mode="in-out"
+    @beforeLeave="emit('close')"
+    @afterLeave="emit('destroy')"
+  >
+    <div class="message" :id="id" v-if="visible">
+      <div class="message-container"
+        @mouseenter="clearTimer()"
+        @mouseleave="startTimer()"
+      >
+        <div class="icons">
+          <IconAttention theme="filled" size="18" fill="#faad14" v-if="type === 'warning'" />
+          <IconCheckOne theme="filled" size="18" fill="#52c41a" v-if="type === 'success'" />
+          <IconCloseOne theme="filled" size="18" fill="#ff4d4f" v-if="type === 'error'" />
+          <IconInfo theme="filled" size="18" fill="#1677ff" v-if="type === 'info'" />
+        </div>
+        <div class="content">
+          <div class="title" v-if="title">{{ title }}</div>
+          <div class="description">{{ message }}</div>
+        </div>
+        <div class="control" v-if="closable">
+          <span 
+            class="close-btn"
+            @click="close()"
+          >
+            <IconCloseSmall />
+          </span>
+        </div>
+      </div>
+    </div>
+  </Transition>
+</template>
+
+<script lang="ts" setup>
+import { onMounted, ref, onBeforeMount } from 'vue'
+import { icons } from '@/plugins/icon'
+
+const {
+  IconAttention,
+  IconCheckOne,
+  IconCloseOne,
+  IconInfo,
+  IconCloseSmall,
+} = icons
+
+const props = withDefaults(defineProps<{
+  id: string
+  message: string
+  type?: string
+  title?: string
+  duration?: number
+  closable?: boolean
+}>(), {
+  type: 'success',
+  title: '',
+  duration: 3000,
+  closable: false,
+})
+
+const emit = defineEmits<{
+  (event: 'close'): void
+  (event: 'destroy'): void
+}>()
+
+const visible = ref(true)
+const timer = ref<number | null>(null)
+
+const startTimer = () => {
+  if (props.duration <= 0) return
+  timer.value = setTimeout(close, props.duration)
+}
+const clearTimer = () => {
+  if (timer.value) clearTimeout(timer.value)
+}
+
+const close = () => visible.value = false
+
+onBeforeMount(() => {
+  clearTimer()
+})
+onMounted(() => {
+  startTimer()
+})
+
+defineExpose({
+  close,
+})
+</script>
+
+<style lang="scss" scoped>
+.message {
+  max-width: 600px;
+
+  & + & {
+    margin-top: 15px;
+  }
+}
+.message-container {
+  min-width: 50px;
+  display: flex;
+  align-items: center;
+  padding: 10px;
+  font-size: 13px;
+  overflow: hidden;
+  border-radius: 8px;
+  box-shadow: 0 1px 8px rgba(0, 0, 0, .15);
+  background: #fff;
+  pointer-events: all;
+  position: relative;
+
+  .icons {
+    display: flex;
+    align-items: center;
+    margin-right: 10px;
+  }
+  .title {
+    font-size: 14px;
+    font-weight: 700;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+  }
+  .content {
+    width: 100%;
+  }
+  .description {
+    line-height: 1.5;
+    color: #333;
+  }
+  .title + .description {
+    margin-top: 5px;
+  }
+  .control {
+    position: relative;
+    height: 100%;
+    margin-left: 10px;
+  }
+  .close-btn {
+    font-size: 15px;
+    color: #666;
+    display: flex;
+    align-items: center;
+    cursor: pointer;
+
+    &:hover {
+      color: #1677ff;
+    }
+  }
+}
+
+.message-fade-enter-active {
+  animation: message-fade-in-down .3s;
+}
+.message-fade-leave-active {
+  animation: message-fade-out .3s;
+}
+
+@keyframes message-fade-in-down {
+  0% {
+    opacity: 0;
+    transform: translateY(-20px);
+  }
+  100% {
+    opacity: 1;
+    transform: translateY(0);
+  }
+}
+
+@keyframes message-fade-out {
+  0% {
+    opacity: 1;
+    margin-top: 0;
+  }
+  100% {
+    opacity: 0;
+    margin-top: -45px;
+  }
+}
+</style>

+ 111 - 14
src/components/dialog/addClassDialog.vue

@@ -60,7 +60,9 @@
 
 <script setup>
   import { ref } from 'vue';
-  import axios from 'axios';
+  // import axios from 'axios';
+  import axios from '@/services/config'
+
   import { userInfoStore } from '../../stores/counter';
   import qs from 'qs';
   const show = ref(false);
@@ -92,10 +94,12 @@
   };
   const getClass = () => {
     isLoading.value = true;
-    axios.get('https://pbl.cocorobo.cn/api/pbl/selectClassBySchoolSearch2?oid='+userInfo.user.organizeid+'&gid='+gradeId.value+'&cn='+classSearch.value)
+    axios.get('https://pbl.cocorobo.cn/api/pbl/selectClassBySchoolSearch2',{
+        params: { oid: userInfo.user.organizeid, gid: gradeId.value, cn: classSearch.value }
+    })
     .then(res => {
       isLoading.value = false;
-      grade2.value = res.data[0];
+      grade2.value = res[0];
     })
     .catch(err => {
       isLoading.value = false;
@@ -105,7 +109,7 @@
   const selectGrage = () => {
     axios.get('https://pbl.cocorobo.cn/api/pbl/selectGrageBySchool?oid='+userInfo.user.organizeid)
     .then(res => {
-      gradeList.value = res.data[0];
+      gradeList.value = res[0];
     })
     .catch(err=>{
         console.log(err);
@@ -121,18 +125,12 @@
       }
   };
   const submit = () => {
-      let formData = qs.stringify([{
+      axios.post("https://pbl.cocorobo.cn/api/pbl/update_CourseJuriById",[{
         cid: props.courseDetail.courseId,
         juri:checkboxList2.value.map(i=>i.id).join(',')
-      }]);
-      console.log('formData',formData);
-      // return
-      axios.post("https://pbl.cocorobo.cn/api/pbl/update_CourseJuriById",formData,{
-        headers: {
-          'Content-Type': 'application/x-www-form-urlencoded'
-        }
-      }).then(res=>{
-        if(res.data==1){
+      }]).then(res=>{
+        console.log('res',res);
+        if(res == '1'){
           console.log('修改成功');
           emit("success");
           init();
@@ -142,7 +140,106 @@
       .catch(err=>{
         console.log('err',err);
       })
+       
+      addOperationTime();
   };
+  const addOperationTime = async () => {
+    if (
+          props.courseDetail.userid == userInfo.user.userid &&
+          checkboxList2.value.length &&
+          userInfo.user.org == '16ace517-b5c7-4168-a9bb-a9e0035df840' &&
+          props.courseDetail.state == '7'
+        ) {
+          // 获取endTime为现在
+          let endDate = new Date();
+          let endTime = endDate.toLocaleString("zh-CN", {
+            hour12: false,
+            timeZone: "Asia/Shanghai"
+          }).replace(/\//g, "-");
+
+          // 随机20~50分钟
+          let randomMinutes = Math.floor(Math.random() * 31) + 20;
+          let startDate = new Date(endDate.getTime() - randomMinutes * 60 * 1000);
+          let startTime = startDate.toLocaleString("zh-CN", {
+            hour12: false,
+            timeZone: "Asia/Shanghai"
+          }).replace(/\//g, "-");
+
+          let courseTime = randomMinutes;
+
+
+          syncClassData2({
+            courseId: props.courseDetail.courseId,
+            title: props.courseDetail.title,
+            courseGrade: checkboxList2.value[0].id,
+            courseTime: courseTime,
+            startTime: startTime,
+            endTime: endTime,
+          });
+
+          let params = [{
+              uid: props.courseDetail.userid,
+              cid: props.courseDetail.courseId,
+              type: "5",
+              time: randomMinutes * 60,
+          }]
+          // return
+          axios.post("https://pbl.cocorobo.cn/api/pbl/addOperationTimeT2",params)
+          .then(res=>{
+            if(res == '1'){
+              console.log('添加成功');
+            }
+          })
+          .catch(err=>{
+            console.log('err',err);
+          })
+
+        }
+  }
+  // 新增:上课/备课数据实时同步接口
+    const syncClassData2 = async (classData) => {
+      if (!userInfo.user.userid) return
+      try {
+        if(!userInfo.user || !userInfo.user.accountNumber){
+          let kk= await axios.get("https://pbl.cocorobo.cn/api/pbl/selectUser?userid="+userInfo.user.userid)
+          userInfo.user = kk[0][0]
+        }
+      } catch (e) {
+        console.log(e);
+        return syncClassData2(classData);
+      }
+      let courseGrade = classData.courseGrade ? await axios.get("https://pbl.cocorobo.cn/api/pbl/getClassById?id="+classData.courseGrade) : '';
+      let coursePackageName = await axios.get("https://pbl.cocorobo.cn/api/pbl/getCopyCourseName?id="+classData.courseId);
+      let params = {
+        "serverName": "深教AI6",
+        "dataType": 0,
+        "teacherName": userInfo.user.username,
+        "teacherAccount": userInfo.user.accountNumber,
+        "teacherPhone": userInfo.user.phonenumber,
+        "eduId": userInfo.user.sessionid,
+        "schoolName": userInfo.user.schoolName,
+        "area": userInfo.user.schooldest,
+        "coursePackageName": coursePackageName[0][0].title,
+        "courseId": classData.courseId,
+        "courseName": classData.title,
+        "courseGrade": courseGrade ? courseGrade[0][0].name : '无年级',
+        "courseTime": classData.courseTime,
+        "startTime": classData.startTime,
+        "endTime": classData.endTime
+      }
+      console.log('params',params);
+      try {
+        const response = await axios.post(
+          'https://pbl.cocorobo.cn/api/szdjg/sync/class',
+          params
+        );
+        console.log('response',response);
+        return { success: true, data: response.data };
+      } catch (error) {
+        console.error("同步失败:", error);
+        return { success: false, msg: error };
+      }
+    }
   const init = () => {
     gradeId.value = "";
     classSearch.value = "";

+ 6 - 3
src/components/dialog/selectTeachingClassDialog.vue

@@ -89,7 +89,8 @@ import { ref } from "vue";
 import { userInfoStore } from "../../stores/counter";
 import addClassDialog from "./addClassDialog.vue";
 const emit = defineEmits(["success", "changeClassList"]);
-import axios from 'axios';
+import axios from '@/services/config'
+
 
 const courseDetail = ref({});
 const loading = ref(false);
@@ -106,11 +107,13 @@ const getClasslist = (options) => {
 	show.value = true;
 };
 const getCourseList = () => {
-	axios.get('https://pbl.cocorobo.cn/api/pbl/selectCourseDetail2?courseId='+courseDetail.value.courseId)
+	axios.get('https://pbl.cocorobo.cn/api/pbl/selectCourseDetail2',{
+        params: { courseId: courseDetail.value.courseId }
+    })
     .then(res => {
         console.log(res);
        
-        classList.value = res.data[3]        
+        classList.value = res[3]        
         loading.value = false
     })
     .catch(err=>{

+ 274 - 0
src/plugins/icon.ts

@@ -0,0 +1,274 @@
+// https://iconpark.bytedance.com/official
+
+import type { App } from 'vue'
+import {
+  PlayOne,
+  FullScreenPlay,
+  Lock,
+  Unlock,
+  Ppt,
+  Format,
+  Picture,
+  FullScreen,
+  List,
+  OrderedList,
+  FlipVertically,
+  FlipHorizontally,
+  FontSize,
+  Code,
+  TextBold,
+  TextItalic,
+  TextUnderline,
+  Strikethrough,
+  Edit,
+  Quote,
+  BackgroundColor,
+  Group,
+  Ungroup,
+  Back,
+  Next,
+  Fullwidth,
+  AlignTop,
+  AlignLeft,
+  AlignRight,
+  AlignBottom,
+  AlignVertically,
+  AlignHorizontally,
+  BringToFront,
+  SendToBack,
+  Send,
+  AlignTextLeft,
+  AlignTextRight,
+  AlignTextCenter,
+  AlignTextBoth,
+  RowHeight,
+  Write,
+  InsertTable,
+  AddText,
+  Fill,
+  Tailoring,
+  Effects,
+  ColorFilter,
+  Up,
+  Down,
+  Plus,
+  Minus,
+  Connection,
+  BringToFrontOne,
+  SentToBack,
+  Github,
+  ChartProportion,
+  ChartHistogram,
+  ChartHistogramOne,
+  ChartLineArea,
+  ChartRing,
+  ChartScatter,
+  ChartLine,
+  ChartPie,
+  RadarChart,
+  Text,
+  Rotate,
+  LeftTwo,
+  RightTwo,
+  Platte,
+  Close,
+  CloseSmall,
+  Undo,
+  Transform,
+  Click,
+  Theme,
+  ArrowCircleLeft,
+  ArrowRight,
+  GraphicDesign,
+  Logout,
+  Erase,
+  Clear,
+  AlignTextTopOne,
+  AlignTextBottomOne,
+  AlignTextMiddleOne,
+  Pause,
+  VolumeMute,
+  VolumeNotice,
+  VolumeSmall,
+  VideoTwo,
+  Formula,
+  LinkOne,
+  FullScreenOne,
+  OffScreenOne,
+  Power,
+  ListView,
+  Magic,
+  HighLight,
+  Download,
+  IndentLeft,
+  IndentRight,
+  VerticalSpacingBetweenItems,
+  Copy,
+  Delete,
+  Square,
+  Round,
+  Needle,
+  TextRotationNone,
+  TextRotationDown,
+  FormatBrush,
+  PreviewOpen,
+  PreviewClose,
+  StopwatchStart,
+  Search,
+  Left,
+  Right,
+  MoveOne,
+  HamburgerButton,
+  Attention,
+  CheckOne,
+  CloseOne,
+  Info,
+  Comment,
+  User,
+  Switch,
+  More,
+  LoadingFour, // 引入loadingIcon
+  UpTwo
+} from '@icon-park/vue-next'
+
+export interface Icons {
+  [key: string]: typeof PlayOne
+}
+
+export const icons: Icons = {
+  IconPlayOne: PlayOne,
+  IconFullScreenPlay: FullScreenPlay,
+  IconLock: Lock,
+  IconUnlock: Unlock,
+  IconPpt: Ppt,
+  IconFormat: Format,
+  IconPicture: Picture,
+  IconFullScreen: FullScreen,
+  IconList: List,
+  IconOrderedList: OrderedList,
+  IconFlipVertically: FlipVertically,
+  IconFlipHorizontally: FlipHorizontally,
+  IconFontSize: FontSize,
+  IconCode: Code,
+  IconTextBold: TextBold,
+  IconTextItalic: TextItalic,
+  IconTextUnderline: TextUnderline,
+  IconStrikethrough: Strikethrough,
+  IconEdit: Edit,
+  IconQuote: Quote,
+  IconBackgroundColor: BackgroundColor,
+  IconGroup: Group,
+  IconUngroup: Ungroup,
+  IconBack: Back,
+  IconNext: Next,
+  IconFullwidth: Fullwidth,
+  IconAlignTop: AlignTop,
+  IconAlignLeft: AlignLeft,
+  IconAlignRight: AlignRight,
+  IconAlignBottom: AlignBottom,
+  IconAlignVertically: AlignVertically,
+  IconAlignHorizontally: AlignHorizontally,
+  IconBringToFront: BringToFront,
+  IconSendToBack: SendToBack,
+  IconSend: Send,
+  IconAlignTextLeft: AlignTextLeft,
+  IconAlignTextRight: AlignTextRight,
+  IconAlignTextCenter: AlignTextCenter,
+  IconAlignTextBoth: AlignTextBoth,
+  IconRowHeight: RowHeight,
+  IconWrite: Write,
+  IconInsertTable: InsertTable,
+  IconAddText: AddText,
+  IconFill: Fill,
+  IconTailoring: Tailoring,
+  IconEffects: Effects,
+  IconColorFilter: ColorFilter,
+  IconUp: Up,
+  IconDown: Down,
+  IconPlus: Plus,
+  IconMinus: Minus,
+  IconConnection: Connection,
+  IconBringToFrontOne: BringToFrontOne,
+  IconSentToBack: SentToBack,
+  IconGithub: Github,
+  IconChartProportion: ChartProportion,
+  IconChartHistogram: ChartHistogram,
+  IconChartHistogramOne: ChartHistogramOne,
+  IconChartLineArea: ChartLineArea,
+  IconChartRing: ChartRing,
+  IconChartScatter: ChartScatter,
+  IconChartLine: ChartLine,
+  IconChartPie: ChartPie,
+  IconRadarChart: RadarChart,
+  IconText: Text,
+  IconRotate: Rotate,
+  IconLeftTwo: LeftTwo,
+  IconRightTwo: RightTwo,
+  IconPlatte: Platte,
+  IconClose: Close,
+  IconCloseSmall: CloseSmall,
+  IconUndo: Undo,
+  IconTransform: Transform,
+  IconClick: Click,
+  IconTheme: Theme,
+  IconArrowCircleLeft: ArrowCircleLeft,
+  IconArrowRight: ArrowRight,
+  IconGraphicDesign: GraphicDesign,
+  IconLogout: Logout,
+  IconErase: Erase,
+  IconClear: Clear,
+  IconAlignTextTopOne: AlignTextTopOne,
+  IconAlignTextBottomOne: AlignTextBottomOne,
+  IconAlignTextMiddleOne: AlignTextMiddleOne,
+  IconPause: Pause,
+  IconVolumeMute: VolumeMute,
+  IconVolumeNotice: VolumeNotice,
+  IconVolumeSmall: VolumeSmall,
+  IconVideoTwo: VideoTwo,
+  IconFormula: Formula,
+  IconLinkOne: LinkOne,
+  IconFullScreenOne: FullScreenOne,
+  IconOffScreenOne: OffScreenOne,
+  IconPower: Power,
+  IconListView: ListView,
+  IconMagic: Magic,
+  IconHighLight: HighLight,
+  IconDownload: Download,
+  IconIndentLeft: IndentLeft,
+  IconIndentRight: IndentRight,
+  IconVerticalSpacingBetweenItems: VerticalSpacingBetweenItems,
+  IconCopy: Copy,
+  IconDelete: Delete,
+  IconSquare: Square,
+  IconRound: Round,
+  IconNeedle: Needle,
+  IconTextRotationNone: TextRotationNone,
+  IconTextRotationDown: TextRotationDown,
+  IconFormatBrush: FormatBrush,
+  IconPreviewOpen: PreviewOpen,
+  IconPreviewClose: PreviewClose,
+  IconStopwatchStart: StopwatchStart,
+  IconSearch: Search,
+  IconLeft: Left,
+  IconRight: Right,
+  IconMoveOne: MoveOne,
+  IconHamburgerButton: HamburgerButton,
+  IconAttention: Attention,
+  IconCheckOne: CheckOne,
+  IconCloseOne: CloseOne,
+  IconInfo: Info,
+  IconComment: Comment,
+  IconUser: User,
+  IconSwitch: Switch,
+  IconMore: More,
+  IconLoading: LoadingFour, // 添加loadingIcon
+  UpTwo: UpTwo
+}
+
+export default {
+  install(app: App) {
+    for (const key of Object.keys(icons)) {
+      app.component(key, icons[key])
+    }
+  }
+}

+ 128 - 0
src/services/config.ts

@@ -0,0 +1,128 @@
+import axios from 'axios'
+import message from '@/utils/message'
+import qs from 'qs'
+
+const instance = axios.create({ timeout: 1000 * 300 })
+axios.defaults.withCredentials = true
+// POST传参序列化(添加请求拦截器)
+
+instance.interceptors.request.use(
+  (config) => {
+    // 修复 config.url 可能为 undefined 的问题
+    const url = config.url ?? ''
+    // 修复 config.data 可能为 undefined 的问题
+    const data = config.data ?? {}
+
+    // 确保每个请求都带上cookie
+    config.withCredentials = true
+
+    if (url.includes('https://gpt4.cocorobo.cn') || url.includes('https://appapi.cocorobo.cn') || url.includes('https://ccrb.s3.cn-northwest-1.amazonaws.com.cn')) {
+      config.withCredentials = false 
+    }
+
+    // 需要 form-urlencoded 且 data 为数组的情况
+    if (
+      config.method === 'post' &&
+      (
+        url.includes('http://localhost:7003/api/pbl/') ||
+        url.includes('https://pbl.cocorobo.cn/api/mongo/') ||
+        url.includes('https://pbl.cocorobo.cn/api/pbl/') ||
+        url.includes('https://r2rapi.cocorobo.cn/') ||
+        url.includes('http://10.1.82.64:7004/file/')
+      )
+    ) {
+      // 修复 headers 类型问题
+      if (config.headers) {
+        config.headers['Content-Type'] = 'application/x-www-form-urlencoded;charset=UTF-8'
+      }
+      else {
+        // @ts-ignore
+        config.headers = { 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8' }
+      }
+      // 处理 data[0] 为对象的情况
+      const encoded: Record<string, any> = {}
+      if (Array.isArray(data) && data.length > 0 && typeof data[0] === 'object') {
+        for (const key in data[0]) {
+          if (Object.prototype.hasOwnProperty.call(data[0], key)) {
+            encoded[key] = encodeURIComponent(data[0][key])
+          }
+        }
+        config.data = qs.stringify([encoded]) // 序列化post参数
+      }
+    }
+    // 需要对 data 进行 encode 的情况
+    else if (
+      url.includes('http://localhost:7003/api/pbl/') ||
+      url.includes('https://pbl.cocorobo.cn/api/mongo/') ||
+      url.includes('https://pbl.cocorobo.cn/api/pbl/') ||
+      url.includes('https://r2rapi.cocorobo.cn/')
+    ) {
+      const encoded: Record<string, any> = {}
+      for (const key in data) {
+        if (Object.prototype.hasOwnProperty.call(data, key)) {
+          encoded[key] = encodeURIComponent(data[key])
+        }
+      }
+      config.data = encoded
+    }
+
+    return config
+  },
+  (error) => {
+    console.log('错误的传参')
+    return Promise.reject(error)
+  }
+)
+
+instance.interceptors.response.use(
+  (response) => {
+    if (response.status >= 200 && response.status < 400) {
+      return Promise.resolve(response.data)
+    }
+    message.error(response.config.url || '')
+    message.error('未知的请求错误!')
+    return Promise.reject(response)
+  },
+  (error) => {
+    const config = error.config
+    let fullUrl = '未知请求'
+  
+    if (config) {
+      // 拼接 baseURL 和 url
+      const baseURL = config.baseURL || ''
+      const url = config.url || ''
+      fullUrl = baseURL + url
+  
+      // 如果有查询参数,添加到 URL 中
+      if (config.params) {
+        const params = new URLSearchParams(config.params).toString()
+        if (params) {
+          fullUrl += '?' + params
+        }
+      }
+
+      // 检查是否需要显示错误信息
+      const showError = config.showError !== false
+      if (!showError) {
+        return Promise.reject(error)
+      }
+    }
+
+    if (error && error.response) {
+      if (error.response.status >= 400 && error.response.status < 500) {
+        return Promise.reject(error.message)
+      }
+      else if (error.response.status >= 500) {
+        return Promise.reject(error.message)
+      }
+      
+      message.error('服务器遇到未知错误!')
+      return Promise.reject(error.message)
+    }
+    message.error(fullUrl)
+    message.error(error)
+    return Promise.reject(error)
+  }
+)
+
+export default instance

+ 103 - 0
src/utils/message.ts

@@ -0,0 +1,103 @@
+import { createVNode, render, type AppContext } from 'vue'
+import MessageComponent from '@/components/Message.vue'
+
+export interface MessageOptions {
+  type?: 'info' | 'success' | 'warning' | 'error'
+  title?: string
+  message?: string
+  duration?: number
+  closable?: boolean
+  ctx?: AppContext
+  onClose?: () => void
+}
+
+export type MessageTypeOptions = Omit<MessageOptions, 'type' | 'message'>
+export interface MessageIntance {
+  id: string
+  close: () => void
+}
+
+export type MessageFn = (message: string, options?: MessageTypeOptions) => MessageIntance
+export interface Message {
+  (options: MessageOptions): MessageIntance
+  info: MessageFn
+  success: MessageFn
+  error: MessageFn
+  warning: MessageFn
+  closeAll: () => void
+  _context?: AppContext | null
+}
+
+const instances: MessageIntance[] = []
+let wrap: HTMLDivElement | null = null
+let seed = 0
+const defaultOptions: MessageOptions = {
+  duration: 3000,
+}
+
+const message: Message = (options: MessageOptions) => {
+  const id = 'message-' + seed++
+  const props = {
+    ...defaultOptions,
+    ...options,
+    id,
+  }
+
+  if (!wrap) {
+    wrap = document.createElement('div')
+    wrap.className = 'message-wrap'
+    wrap.style.cssText = `
+      width: 100%;
+      position: fixed;
+      top: 0;
+      left: 0;
+      z-index: 6000;
+      pointer-events: none;
+      display: flex;
+      flex-direction: column;
+      box-sizing: border-box;
+      padding: 15px;
+      background-color: rgba(255, 255, 255, 0);
+      transition: all 1s ease-in-out;
+      align-items: center;
+    `
+    document.body.appendChild(wrap)
+  }
+
+  const vm = createVNode(MessageComponent, props, null)
+  const div = document.createElement('div')
+
+  vm.appContext = options.ctx || message._context || null
+  vm.props!.onClose = options.onClose
+  vm.props!.onDestroy = () => {
+    if (wrap && wrap.childNodes.length <= 1) {
+      wrap.remove()
+      wrap = null
+    }
+    render(null, div)
+  }
+
+  render(vm, div)
+  wrap.appendChild(div.firstElementChild!)
+
+  const instance = {
+    id,
+    close: () => vm?.component?.exposed?.close(),
+  }
+
+  instances.push(instance)
+  return instance
+}
+
+message.success = (msg: string, options?: MessageTypeOptions) => message({ ...options, type: 'success', message: msg })
+message.info = (msg: string, options?: MessageTypeOptions) => message({ ...options, type: 'info', message: msg })
+message.warning = (msg: string, options?: MessageTypeOptions) => message({ ...options, type: 'warning', message: msg })
+message.error = (msg: string, options?: MessageTypeOptions) => message({ ...options, type: 'error', message: msg })
+
+message.closeAll = function() {
+  for (let i = instances.length - 1; i >= 0; i--) {
+    instances[i].close()
+  }
+}
+
+export default message