wangzhiqiang 3 mesiacov pred
rodič
commit
7a82427c52

+ 1 - 1
index.html

@@ -4,7 +4,7 @@
     <meta charset="UTF-8" />
     <link rel="icon" href="/favicon.ico" />
     <link rel="stylesheet" href="//at.alicdn.com/t/font_2570680_gkyjimtz1d.css">
-    <link rel="stylesheet" href="//at.alicdn.com/t/c/font_4934570_z6o9obhwtu.css">
+    <link rel="stylesheet" href="//at.alicdn.com/t/c/font_4934570_uiml0zf75g.css">
     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
     <title></title>
   </head>

+ 9 - 0
src/api/common.js

@@ -31,4 +31,13 @@ export function attachFile(data) {
             'Content-Type': 'multipart/form-data'
         }
     })
+}
+
+// 创建工单
+export function workorderCreate(data) {
+    return request({
+        url: '/agent-service/workorder/create',
+        method: 'post',
+        data,
+    })
 }

+ 11 - 0
src/hooks/useErrorTrigger.js

@@ -0,0 +1,11 @@
+import { ref } from 'vue'
+
+export const errorTooltipVisible = ref(false)
+
+export function triggerTooltip() {
+    errorTooltipVisible.value = true
+    setTimeout(() => {
+        errorTooltipVisible.value = false
+    }, 8000)
+}
+

+ 182 - 0
src/hooks/useOssloader.js

@@ -0,0 +1,182 @@
+/**
+ * 上传图片 / 文件
+ */
+import { ref, computed } from 'vue'
+import { ElMessage, ElLoading } from 'element-plus'
+import { attachFile, attachImage } from '@/api/common.js'
+import { base64ToBlob } from '@/utils/index.js'
+import QRCode from 'qrcode' //生成二维码
+
+export function useOssloader() {
+    const loading = ref(null)
+
+    // 加载信息
+    const openFullScreen = (loadText) => {
+        loading.value = ElLoading.service({
+            lock: true,
+            text: loadText,
+            background: 'rgba(0, 0, 0, 0.7)',
+        })
+    }
+
+    const closeFullScreen = () => {
+        loading.value.close()
+    }
+
+    // 上传图片校验
+    const beforeAvatarUpload = (rawFile) => {
+        let fileType = ["image/jpeg", "image/png", "image/webp"];
+        if (!fileType.includes(rawFile.type)) {
+            ElMessage.error("请上传图片格式为jpg/png/webp!");
+            return false;
+        } else if (rawFile.size / 1024 / 1024 > 2) {
+            ElMessage.error(`图片大小不能超过 2MB!`);
+            return false;
+        }
+        return true;
+    }
+
+    // 上传前校验文件类型
+    const beforeUpload = (file) => {
+        const fileExt = file.name.split('.').pop().toLowerCase()
+        const validExts = ['apk', 'aab', 'ipa']
+        if (!validExts.includes(fileExt)) {
+            ElMessage.error('只允许上传 .apk、.aab 或 .ipa 文件')
+            return false
+        }
+        return true
+    }
+
+    // 单图片上传
+    const selfUpload = async (param, type = false) => {
+        const file = param.file
+
+        // 构造唯一文件名
+        const uniqueFileName = generateUniqueFileName(file.name)
+
+        // 创建新的 File 对象
+        const newFile = new File([file], uniqueFileName, {
+            type: file.type
+        })
+
+        const formData = new FormData()
+        formData.append('file', newFile)
+
+        if (!type) {
+            openFullScreen('图片上传中')
+        }
+
+        try {
+            const res = await attachFile(formData)
+            const url = res.data.url
+
+            if (!type) {
+                ElMessage.success('图片上传成功')
+                return url
+            }
+
+            return { url, name: uniqueFileName }
+        } catch (err) {
+            ElMessage.error('图片上传失败')
+            console.error(err)
+
+            return null
+        } finally {
+            if (!type) {
+                closeFullScreen()
+            }
+        }
+    }
+
+    // 自定义上传逻辑
+    const customUpload = async (param) => {
+        const file = param.file
+        const fileExt = file.name.substring(file.name.lastIndexOf('.')) // 原始扩展名,如 .apk
+
+        const formData = new FormData()
+        // formData.append('file', file, generateUniqueFileName(fileExt)) // 使用唯一名上传
+        formData.append('file', file)
+
+        openFullScreen('APP上传中')
+
+        try {
+            const res = await attachFile(formData)
+            const apkUrl = res.data.url
+            ElMessage.success('上传成功')
+
+            // 生成二维码
+            const qrDataURL = await QRCode.toDataURL(apkUrl)
+            const blob = base64ToBlob(qrDataURL)
+            const qrFormData = new FormData()
+            qrFormData.append('file', blob, generateUniqueFileName('.png')) // 二维码也加唯一名
+
+            const imageData = await attachFile(qrFormData)
+            const qrCode = imageData.data.url
+            ElMessage.success('二维码生成成功!')
+
+            return { apkUrl, qrCode } // 返回对象
+        } catch (err) {
+            ElMessage.error('APP上传失败')
+            console.error(err)
+            return null
+        } finally {
+            closeFullScreen()
+        }
+    }
+
+    // 批量上传图片
+    const uploadAllImages = async (imageList) => {
+        openFullScreen('图片上传中')
+        try {
+            const newImageList = []
+
+            for (const fileItem of imageList) {
+                const file = fileItem.raw instanceof File ? fileItem.raw : null
+                if (!file) continue
+
+                // 构造 param 模拟 el-upload 组件传入的结构
+                const param = { file }
+
+                const result = await selfUpload(param, true)
+
+                if (result) {
+                    newImageList.push({
+                        attachName: result.name,
+                        size: Math.ceil(fileItem.size / 1024),
+                        url: result.url
+                    })
+                }
+            }
+
+            return newImageList
+
+        } catch (err) {
+            ElMessage.error('图片上传失败')
+            console.error('图片上传失败', err)
+
+            return null
+        } finally {
+            closeFullScreen()
+        }
+    }
+
+    /**
+     * 根据原文件名生成唯一文件名
+     * @param {string} originalName 原始文件名,例如 xxx.jpg
+     * @returns {string} 唯一文件名,例如 1722323123_xxxxxx.jpg
+    */
+    function generateUniqueFileName(originalName) {
+        const timestamp = Date.now()
+        const ext = originalName.substring(originalName.lastIndexOf('.'))
+        const randomStr = Math.random().toString(36).substring(2, 10)
+        return `${timestamp}_${randomStr}${ext}`
+    }
+
+    return {
+        beforeAvatarUpload,
+        beforeUpload,
+        selfUpload,
+        customUpload,
+        uploadAllImages
+    }
+}

+ 217 - 0
src/layout/Feedback/index.vue

@@ -0,0 +1,217 @@
+<template>
+    <div>
+        <div class="feedback" @click="openFeedback">
+            <el-tooltip ref="tooltipRef" content="遇到问题?提交反馈" placement="left" :visible="errorTooltipVisible" manual>
+                <i class="iconfont icon-bangzhufankuiwenhaoyiwen" @mouseenter="errorTooltipVisible = true"
+                    @mouseleave="errorTooltipVisible = false"></i>
+            </el-tooltip>
+        </div>
+
+        <!-- 操作弹窗 -->
+        <Layer :layer="layer" @confirm="submit(ruleForm)" @close="layer.show = false">
+            <el-form :model="formEdit" :rules="rules" ref="ruleForm" label-width="120px" style="margin-right:30px;">
+                <el-form-item label="反馈标题" required prop="title">
+                    <el-input v-model="formEdit.title" placeholder="请输入反馈标题" clearable />
+                </el-form-item>
+                <el-form-item label="反馈内容" required prop="content">
+                    <el-input type="textarea" rows="5" v-model="formEdit.content" placeholder="请输入反馈内容" clearable />
+                </el-form-item>
+                <el-form-item label="反馈截图" prop="image">
+                    <el-upload v-model:file-list="formEdit.imageList" action="#" list-type="picture-card"
+                        :before-upload="beforeAvatarUpload" :http-request="onImageUpload" multiple
+                        accept="image/jpg,image/jpeg,image/png,image/webp" :on-preview="handlePictureCardPreview"
+                        :on-remove="handleRemove">
+                        <el-icon>
+                            <Plus />
+                        </el-icon>
+                    </el-upload>
+                </el-form-item>
+            </el-form>
+        </Layer>
+
+        <!-- 图片预览弹窗 -->
+        <el-dialog v-model="dialogVisible">
+            <div class="img_show">
+                <div class="icon" @click="prevImg" :class="{ disabled: currentPreviewIndex === 0 }">
+                    <el-icon size="24">
+                        <ArrowLeftBold />
+                    </el-icon>
+                </div>
+                <el-image class="flex1" :src="dialogImageUrl" :z-index="99999" fit="fill" preview-teleported="true"
+                    alt="Preview Image" />
+
+                <div class="icon" @click="nextImg" :class="{ disabled: currentPreviewIndex === 0 }">
+                    <el-icon size="24">
+                        <ArrowRightBold />
+                    </el-icon>
+                </div>
+            </div>
+        </el-dialog>
+    </div>
+</template>
+
+<script setup>
+import { ref, reactive } from 'vue'
+import Layer from "@/components/layer/index.vue";
+import { useOssloader } from '@/hooks/useOssloader.js'
+import { Plus, ArrowLeftBold, ArrowRightBold } from "@element-plus/icons-vue";
+import { errorTooltipVisible } from '@/hooks/useErrorTrigger'
+import { workorderCreate } from '@/api/common.js'
+import { ElMessage } from 'element-plus'
+
+const { beforeAvatarUpload, uploadAllImages } = useOssloader()
+const tooltipRef = ref(null)
+
+// 弹窗
+const layer = ref({
+    show: false,
+    title: "意见反馈",
+    showButton: true,
+    width: '52vw',
+});
+
+const ruleForm = ref(null);
+
+const formEdit = ref({
+    title: '',//工单标题
+    content: '',//工单内容
+    imageList: [],
+    attachList: undefined,
+})
+
+const openFeedback = () => {
+    ruleForm.value?.resetFields()
+    layer.value.show = !layer.value.show
+
+    // 重置内容
+    formEdit.value = {
+        title: '',//工单标题
+        content: '',//工单内容
+        imageList: [],
+        attachList: undefined,
+    }
+}
+
+const rules = reactive({
+    title: [
+        { required: true, message: "请输入反馈标题", trigger: "blur" },
+    ],
+    content: [
+        { required: true, message: "请输入反馈内容", trigger: "blur" },
+    ]
+});
+
+const submit = async (formEl) => {
+    await formEl.validate(async (valid, fields) => {
+        if (valid) {
+            // 提交内容
+            if (formEdit.value.imageList.length > 0) {
+                const attachList = await uploadAllImages(formEdit.value.imageList)
+                formEdit.value.attachList = attachList
+            }
+            delete formEdit.value.imageList
+
+            workorderCreate({ ...formEdit.value }).then(() => {
+                ElMessage.success('提交成功')
+                layer.value.show = false
+            }).catch((err) => {
+                ElMessage.error('提交失败,请重试!')
+            })
+        } else {
+            console.log("error submit!", fields);
+        }
+    });
+};
+
+const handleRemove = (uploadFile, uploadFiles) => {
+    // console.log(uploadFile, uploadFiles)
+}
+
+// 上传二维码图片
+const onImageUpload = async (param) => {
+    // console.log('param :===>>', param);
+}
+
+// 图片预览弹窗
+const dialogImageUrl = ref('')
+const dialogVisible = ref(false)
+const currentPreviewIndex = ref(0)
+
+const handlePictureCardPreview = (uploadFile) => {
+    const index = formEdit.value.imageList.findIndex(item => item.uid === uploadFile.uid)
+
+    if (index !== -1) {
+        currentPreviewIndex.value = index
+        dialogImageUrl.value = formEdit.value.imageList[index].url
+        dialogVisible.value = true
+    }
+}
+
+const prevImg = () => {
+    if (formEdit.value.imageList.length === 0) return
+
+    currentPreviewIndex.value =
+        (currentPreviewIndex.value - 1 + formEdit.value.imageList.length) % formEdit.value.imageList.length
+
+    dialogImageUrl.value = formEdit.value.imageList[currentPreviewIndex.value].url
+}
+
+const nextImg = () => {
+    if (formEdit.value.imageList.length === 0) return
+
+    currentPreviewIndex.value =
+        (currentPreviewIndex.value + 1) % formEdit.value.imageList.length
+
+    dialogImageUrl.value = formEdit.value.imageList[currentPreviewIndex.value].url
+}
+
+</script>
+
+<style lang='scss' scoped>
+.feedback {
+    cursor: pointer;
+    z-index: 9999;
+    position: fixed;
+    right: 1rem;
+    bottom: 10rem;
+
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    width: 42px;
+    height: 42px;
+    border-radius: 50%;
+    background-color: var(--system-primary-color);
+    box-shadow: 0 4px 5px rgba(0, 0, 0, 0.1), 0 0 6px rgba(0, 0, 0, 0.4);
+
+    i {
+        font-size: 42px;
+        color: var(--system-primary-text-color);
+    }
+}
+
+.img_show {
+    display: flex;
+    align-items: center;
+    // 全局禁止选中
+    user-select: none;
+    -webkit-user-select: none;
+
+    .icon {
+        cursor: pointer;
+        width: 40px;
+        height: 40px;
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        border-radius: 50%;
+        background-color: rgba($color: #000000, $alpha: .2);
+        color: var(--system-primary-text-color);
+    }
+
+    .el-image {
+        flex: 1;
+        margin: 0 10px;
+    }
+}
+</style>

+ 3 - 0
src/layout/index.vue

@@ -32,6 +32,7 @@
             </keep-alive>
             <component v-else :is="Component" :key="route.fullPath" />
           </transition>
+          <Feedback />
         </router-view>
       </el-main>
     </el-container>
@@ -47,12 +48,14 @@ import Menu from "./Menu/index.vue";
 import Logo from "./Logo/index.vue";
 import Header from "./Header/index.vue";
 import Tabs from "./Tabs/index.vue";
+import Feedback from "./Feedback/index.vue"
 export default defineComponent({
   components: {
     Menu,
     Logo,
     Header,
     Tabs,
+    Feedback,
   },
   setup() {
     const store = useStore();

+ 17 - 0
src/utils/index.js

@@ -47,4 +47,21 @@ export function roundPrice(value, place = 2) {
         return 0.00
     }
     return Number(Number(value).toFixed(place));
+}
+
+/**
+ * 将 base64 转为 Blob
+ * @param {*} base64 base64数据
+ * @returns 
+ */
+export function base64ToBlob(base64) {
+    const arr = base64.split(',')
+    const mime = arr[0].match(/:(.*?);/)[1]
+    const bstr = atob(arr[1])
+    let n = bstr.length
+    const u8arr = new Uint8Array(n)
+    while (n--) {
+        u8arr[n] = bstr.charCodeAt(n)
+    }
+    return new Blob([u8arr], { type: mime })
 }

+ 6 - 0
src/utils/system/request.js

@@ -1,6 +1,8 @@
 import axios, { AxiosError, AxiosRequestConfig, AxiosResponse, AxiosInstance } from 'axios'
 import store from '@/store'
 import { ElMessage } from 'element-plus'
+import { triggerTooltip } from '@/hooks/useErrorTrigger'
+
 const baseURL = import.meta.env.VITE_BASE_URL
 
 const service = axios.create({
@@ -50,6 +52,10 @@ service.interceptors.response.use(
   },
   (error) => {
     console.log(error)
+
+    // 触发 tooltip 展示
+    triggerTooltip()
+
     const badMessage = error.message || error
     const code = parseInt(badMessage.toString().replace('Error: Request failed with status code ', ''))
     showError({ code, message: badMessage })