Browse Source

完成专属客服功能

yuanmingze 3 tuần trước cách đây
mục cha
commit
ba3b480794

+ 73 - 4
src/composables/photo/useSaveToAlbum.ts

@@ -1,11 +1,18 @@
 export function useSaveToAlbum() {
   async function ensureAlbumPermission(): Promise<boolean> {
     const setting = await uni.getSetting()
-    if (setting.authSetting['scope.writePhotosAlbum']) return true
+    const permission = setting.authSetting['scope.writePhotosAlbum']
 
+    // 尚未申请权限时,直接调用保存 API,由微信触发首次授权弹窗。
+    if (permission !== false) return true
+
+    return openAlbumSetting()
+  }
+
+  async function openAlbumSetting(): Promise<boolean> {
     const modal = await uni.showModal({
       title: '友情提示',
-      content: '小程序需要获取您的图片保存功能才可使用',
+      content: '保存二维码需要相册权限,请在设置中允许保存到相册',
       confirmText: '去设置',
       cancelText: '取消',
     })
@@ -20,9 +27,71 @@ export function useSaveToAlbum() {
     const ok = await ensureAlbumPermission()
     if (!ok) return
 
-    await uni.saveImageToPhotosAlbum({ filePath })
+    try {
+      await uni.saveImageToPhotosAlbum({ filePath })
+    } catch (error) {
+      if (!isAlbumPermissionError(error)) throw error
+
+      const authorized = await openAlbumSetting()
+      if (!authorized) return
+
+      await uni.saveImageToPhotosAlbum({ filePath })
+    }
+
     uni.showToast({ title: '保存成功', icon: 'success' })
   }
 
-  return { saveToAlbum }
+  async function saveBase64ToAlbum(dataUrl: string): Promise<void> {
+    if (!dataUrl) throw new Error('图片数据为空')
+
+    // #ifdef H5
+    downloadBase64Image(dataUrl)
+    uni.showToast({ title: '下载成功', icon: 'success' })
+    return
+    // #endif
+
+    // #ifdef MP-WEIXIN
+    const filePath = await writeBase64ImageFile(dataUrl)
+    await saveToAlbum(filePath)
+    // #endif
+  }
+
+  return { saveToAlbum, saveBase64ToAlbum }
+}
+
+function writeBase64ImageFile(dataUrl: string): Promise<string> {
+  const matched = dataUrl.match(/^data:image\/(png|jpe?g|webp);base64,(.+)$/i)
+  if (!matched) return Promise.reject(new Error('不支持的图片格式'))
+
+  const extension = matched[1].toLowerCase().replace('jpeg', 'jpg')
+  const base64 = matched[2]
+  const filePath = `${wx.env.USER_DATA_PATH}/service-qrcode-${Date.now()}.${extension}`
+
+  return new Promise((resolve, reject) => {
+    uni.getFileSystemManager().writeFile({
+      filePath,
+      data: base64,
+      encoding: 'base64',
+      success: () => resolve(filePath),
+      fail: reject,
+    })
+  })
+}
+
+function downloadBase64Image(dataUrl: string) {
+  const link = document.createElement('a')
+  link.href = dataUrl
+  link.download = `service-qrcode-${Date.now()}.jpg`
+  document.body.appendChild(link)
+  link.click()
+  document.body.removeChild(link)
+}
+
+function isAlbumPermissionError(error: unknown) {
+  const message =
+    error && typeof error === 'object' && 'errMsg' in error
+      ? String((error as { errMsg?: unknown }).errMsg)
+      : ''
+
+  return /auth deny|auth denied|authorize no response|permission/i.test(message)
 }

+ 137 - 0
src/pages/mine/index.vue

@@ -193,6 +193,41 @@
         </scroll-view>
       </view>
     </wd-popup>
+
+    <!-- 专属客服 -->
+    <wd-popup
+      v-model="serviceShow"
+      position="center"
+      closable
+      root-portal
+      lock-scroll
+      custom-style="width: 580rpx; border-radius: 24rpx; overflow: hidden; background: #ffffff;"
+      @close="serviceShow = false"
+    >
+      <view class="service-dialog">
+        <view class="service-dialog-title">专属客服</view>
+
+        <view class="service-dialog-image-wrap">
+          <image
+            v-if="serviceImgSrc"
+            class="service-dialog-image"
+            :src="serviceImgSrc"
+            mode="aspectFit"
+            :show-menu-by-longpress="true"
+          />
+        </view>
+
+        <view
+          class="service-dialog-save"
+          :class="{ 'is-saving': serviceSaving }"
+          hover-class="service-dialog-save--hover"
+          @click="saveServiceQrCode"
+        >
+          <wd-icon v-if="!serviceSaving" name="download" color="#ffffff" size="34rpx" />
+          <text>{{ serviceSaving ? '保存中...' : '保存二维码' }}</text>
+        </view>
+      </view>
+    </wd-popup>
   </view>
 </template>
 
@@ -202,10 +237,12 @@ import { computed, ref } from 'vue'
 import { onShow } from '@dcloudio/uni-app'
 
 import {
+  getDeptCsmApi,
   getInternalMsgCountUnreadApi,
   taxLocationEntranceApi,
 } from '@/services/modules/mine/common'
 
+import { useSaveToAlbum } from '@/composables/photo/useSaveToAlbum'
 import { useUserStore } from '@/stores/modules/user'
 
 import { functionList, menuList, rolesList } from './mine.config'
@@ -227,6 +264,11 @@ const showOSAIS = computed(() => {
 
 const hasUnreadNotification = ref(false)
 const handleCompanyShow = ref(false)
+const serviceShow = ref(false)
+const serviceImgSrc = ref('')
+const serviceSaving = ref(false)
+
+const { saveBase64ToAlbum } = useSaveToAlbum()
 
 const companyList = computed(() => {
   const currentIndex = userStore.currentUserInfoIndex ?? 0
@@ -362,7 +404,41 @@ const navigateToSettlementChannelSign = () => {
   })
 }
 
+const openService = async () => {
+  if (!checkBeforeNavigate()) return
+
+  try {
+    const arrayBuffer = await getDeptCsmApi()
+    serviceImgSrc.value = `data:image/jpeg;base64,${uni.arrayBufferToBase64(arrayBuffer)}`
+    serviceShow.value = true
+  } catch (error) {
+    console.error('获取专属客服失败:', error)
+  }
+}
+
+const saveServiceQrCode = async () => {
+  if (!serviceImgSrc.value || serviceSaving.value) return
+
+  serviceSaving.value = true
+
+  try {
+    await saveBase64ToAlbum(serviceImgSrc.value)
+  } catch (error) {
+    console.error('保存专属客服二维码失败:', error)
+    uni.showToast({
+      title: '保存失败,请稍后重试',
+      icon: 'none',
+    })
+  } finally {
+    serviceSaving.value = false
+  }
+}
+
 const functionListClick = (item: { key: string; txt: string; icon: string; path?: string }) => {
+  if (item.key === 'service') {
+    openService()
+    return
+  }
   navigateTo(item.path)
 }
 
@@ -764,4 +840,65 @@ const handleLogoutClick = () => {
     font-weight: 700;
   }
 }
+
+.service-dialog {
+  display: flex;
+  flex-direction: column;
+  height: 850rpx;
+  padding: 42rpx 40rpx 42rpx;
+  box-sizing: border-box;
+  text-align: center;
+}
+
+.service-dialog-title {
+  color: var(--font-color-333);
+  font-size: 34rpx;
+  font-weight: 700;
+  line-height: 48rpx;
+}
+
+.service-dialog-image-wrap {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  flex: 1;
+  min-height: 0;
+  padding: 18rpx 0;
+  box-sizing: border-box;
+}
+
+.service-dialog-image {
+  display: block;
+  width: 440rpx;
+  height: 560rpx;
+}
+
+.service-dialog-save {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  flex-shrink: 0;
+  width: 390rpx;
+  height: 82rpx;
+  margin: 0 auto;
+  border-radius: 14rpx;
+  color: #ffffff;
+  background: #20a4dc;
+  font-size: 30rpx;
+  font-weight: 500;
+  line-height: 42rpx;
+  box-shadow: 0 10rpx 24rpx rgba(32, 164, 220, 0.2);
+
+  text {
+    margin-left: 8rpx;
+  }
+}
+
+.service-dialog-save--hover {
+  opacity: 0.9;
+}
+
+.service-dialog-save.is-saving {
+  opacity: 0.65;
+}
 </style>

+ 6 - 0
src/services/modules/mine/common/index.ts

@@ -31,6 +31,12 @@ export const getInternalMsgCountUnreadApi = () => {
   })
 }
 
+export const getDeptCsmApi = () => {
+  return http.getRaw<ArrayBuffer>('/admin/dept/csm', undefined, {
+    responseType: 'arraybuffer',
+  })
+}
+
 export const taxLocationEntranceApi = (subjectLocation: string) => {
   return http.get<string>('/admin/user-sign-cert/sign/tax-location-entrance', {
     subjectLocation: subjectLocation,

+ 2 - 0
src/services/request/index.ts

@@ -19,6 +19,7 @@ export interface ApiResponse<T = any> {
 
 interface RequestOptions {
   header?: UniApp.RequestOptions['header']
+  responseType?: UniApp.RequestOptions['responseType']
   timeout?: number
   loading?: boolean
   loadingText?: string
@@ -52,6 +53,7 @@ function send<T>(config: RequestConfig, raw = false): Promise<T | ApiResponse<T>
       data: config.data,
       timeout: config.timeout ?? TIMEOUT,
       header: buildHeaders(config.url, config.header),
+      responseType: config.responseType,
 
       async success(res) {
         const statusCode = res.statusCode ?? 0