wwf
14 小时以前 4e6f18dfa08e2f2f4f02aaa1b8e8e51852b7a9a1
考点核验
17个文件已修改
15个文件已添加
1560 ■■■■■ 已修改文件
package-lock.json 82 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
package.json 4 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/assets/images/h5/face_default.png 补丁 | 查看 | 原始文档 | blame | 历史
src/assets/images/h5/face_fail.png 补丁 | 查看 | 原始文档 | blame | 历史
src/assets/images/h5/face_success.png 补丁 | 查看 | 原始文档 | blame | 历史
src/assets/images/h5/face_tip_1.png 补丁 | 查看 | 原始文档 | blame | 历史
src/assets/images/h5/face_tip_2.png 补丁 | 查看 | 原始文档 | blame | 历史
src/assets/images/h5/face_tip_3.png 补丁 | 查看 | 原始文档 | blame | 历史
src/assets/images/h5/face_tip_4.png 补丁 | 查看 | 原始文档 | blame | 历史
src/assets/images/map/position-marker-green.png 补丁 | 查看 | 原始文档 | blame | 历史
src/assets/images/map/position-marker-red.png 补丁 | 查看 | 原始文档 | blame | 历史
src/config/qxueyou.js 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main.js 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/router/h5/router.js 58 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/router/index.js 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/stores/login.js 8 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/utils/UA.js 57 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/utils/axios.js 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/utils/tool.js 15 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/utils/wxjssdk.js 334 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/h5/faceAuth/components/auditDialog.vue 156 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/h5/faceAuth/components/camera.vue 265 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/h5/faceAuth/index.vue 199 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/h5/index.vue 35 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/h5/login/index.vue 18 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/h5/signup/BaiduMap.vue 3 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/h5/signup/index.vue 80 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/h5/verify/form.vue 154 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/h5/verify/index.vue 35 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/main/components/Signature.vue 29 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/main/components/UploadBtn.vue 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
vite.config.js 1 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
package-lock.json
@@ -16,8 +16,10 @@
        "file-saver": "^2.0.5",
        "pdfh5": "^3.0.0",
        "pinia": "^3.0.3",
        "pinia-plugin-persistedstate": "^4.7.1",
        "qs": "^6.14.1",
        "sass-embedded": "^1.98.0",
        "vconsole": "^3.15.1",
        "vue": "^3.5.22",
        "vue-router": "^4.6.3",
        "xlsx": "^0.18.5",
@@ -473,6 +475,15 @@
      },
      "peerDependencies": {
        "@babel/core": "^7.0.0-0"
      }
    },
    "node_modules/@babel/runtime": {
      "version": "7.28.6",
      "resolved": "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.28.6.tgz",
      "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
      "license": "MIT",
      "engines": {
        "node": ">=6.9.0"
      }
    },
    "node_modules/@babel/template": {
@@ -2779,6 +2790,29 @@
        "url": "https://github.com/sponsors/mesqueeb"
      }
    },
    "node_modules/copy-text-to-clipboard": {
      "version": "3.2.2",
      "resolved": "https://registry.npmmirror.com/copy-text-to-clipboard/-/copy-text-to-clipboard-3.2.2.tgz",
      "integrity": "sha512-T6SqyLd1iLuqPA90J5N4cTalrtovCySh58iiZDGJ6FGznbclKh4UI+FGacQSgFzwKG77W7XT5gwbVEbd9cIH1A==",
      "license": "MIT",
      "engines": {
        "node": ">=12"
      },
      "funding": {
        "url": "https://github.com/sponsors/sindresorhus"
      }
    },
    "node_modules/core-js": {
      "version": "3.48.0",
      "resolved": "https://registry.npmmirror.com/core-js/-/core-js-3.48.0.tgz",
      "integrity": "sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ==",
      "hasInstallScript": true,
      "license": "MIT",
      "funding": {
        "type": "opencollective",
        "url": "https://opencollective.com/core-js"
      }
    },
    "node_modules/crc-32": {
      "version": "1.2.2",
      "resolved": "https://registry.npmmirror.com/crc-32/-/crc-32-1.2.2.tgz",
@@ -2898,6 +2932,12 @@
      "funding": {
        "url": "https://github.com/sponsors/sindresorhus"
      }
    },
    "node_modules/defu": {
      "version": "6.1.4",
      "resolved": "https://registry.npmmirror.com/defu/-/defu-6.1.4.tgz",
      "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==",
      "license": "MIT"
    },
    "node_modules/delayed-stream": {
      "version": "1.0.0",
@@ -4051,6 +4091,11 @@
      "dev": true,
      "license": "MIT"
    },
    "node_modules/mutation-observer": {
      "version": "1.0.3",
      "resolved": "https://registry.npmmirror.com/mutation-observer/-/mutation-observer-1.0.3.tgz",
      "integrity": "sha512-M/O/4rF2h776hV7qGMZUH3utZLO/jK7p8rnNgGkjKUw8zCGjRQPxB8z6+5l8+VjRUQ3dNYu4vjqXYLr+U8ZVNA=="
    },
    "node_modules/nanoid": {
      "version": "3.3.11",
      "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.11.tgz",
@@ -4374,6 +4419,31 @@
      },
      "peerDependenciesMeta": {
        "typescript": {
          "optional": true
        }
      }
    },
    "node_modules/pinia-plugin-persistedstate": {
      "version": "4.7.1",
      "resolved": "https://registry.npmmirror.com/pinia-plugin-persistedstate/-/pinia-plugin-persistedstate-4.7.1.tgz",
      "integrity": "sha512-WHOqh2esDlR3eAaknPbqXrkkj0D24h8shrDPqysgCFR6ghqP/fpFfJmMPJp0gETHsvrh9YNNg6dQfo2OEtDnIQ==",
      "license": "MIT",
      "dependencies": {
        "defu": "^6.1.4"
      },
      "peerDependencies": {
        "@nuxt/kit": ">=3.0.0",
        "@pinia/nuxt": ">=0.10.0",
        "pinia": ">=3.0.0"
      },
      "peerDependenciesMeta": {
        "@nuxt/kit": {
          "optional": true
        },
        "@pinia/nuxt": {
          "optional": true
        },
        "pinia": {
          "optional": true
        }
      }
@@ -5344,6 +5414,18 @@
      "integrity": "sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg==",
      "license": "MIT"
    },
    "node_modules/vconsole": {
      "version": "3.15.1",
      "resolved": "https://registry.npmmirror.com/vconsole/-/vconsole-3.15.1.tgz",
      "integrity": "sha512-KH8XLdrq9T5YHJO/ixrjivHfmF2PC2CdVoK6RWZB4yftMykYIaXY1mxZYAic70vADM54kpMQF+dYmvl5NRNy1g==",
      "license": "MIT",
      "dependencies": {
        "@babel/runtime": "^7.17.2",
        "copy-text-to-clipboard": "^3.0.1",
        "core-js": "^3.11.0",
        "mutation-observer": "^1.0.3"
      }
    },
    "node_modules/vite": {
      "version": "7.3.1",
      "resolved": "https://registry.npmmirror.com/vite/-/vite-7.3.1.tgz",
package.json
@@ -1,5 +1,5 @@
{
  "name": "app-web-examination-platform",
  "name": "app-web-examination-user",
  "version": "0.0.0",
  "private": true,
  "type": "module",
@@ -22,8 +22,10 @@
    "file-saver": "^2.0.5",
    "pdfh5": "^3.0.0",
    "pinia": "^3.0.3",
    "pinia-plugin-persistedstate": "^4.7.1",
    "qs": "^6.14.1",
    "sass-embedded": "^1.98.0",
    "vconsole": "^3.15.1",
    "vue": "^3.5.22",
    "vue-router": "^4.6.3",
    "xlsx": "^0.18.5",
src/assets/images/h5/face_default.png
src/assets/images/h5/face_fail.png
src/assets/images/h5/face_success.png
src/assets/images/h5/face_tip_1.png
src/assets/images/h5/face_tip_2.png
src/assets/images/h5/face_tip_3.png
src/assets/images/h5/face_tip_4.png
src/assets/images/map/position-marker-green.png
src/assets/images/map/position-marker-red.png
src/config/qxueyou.js
@@ -9,7 +9,7 @@
  baseUrl: serverContext,
  htmlRoot: baseDomain + htmlContext,
  serverRoot: baseDomain + serverContext,
  upload: `${serverContext}/base/file/upload`,
  upload: `${serverContext}/infra/file/exam/upload`,
  ACCESS_TOKEN_KEY: 'qxy-user-accessToken',
  REFRESH_TOKEN_KEY: 'qxy-user-refreshToken'
}
src/main.js
@@ -1,5 +1,6 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import './assets/styles/global.css'
@@ -27,6 +28,11 @@
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
  app.component(key, component)
}
// import('vconsole').then((module) => {
//   new module.default()
// })
app.config.globalProperties.$rules = ruleGenerator
app.config.globalProperties.$property = property
app.config.globalProperties.$qxueyou = qxueyou
@@ -47,7 +53,9 @@
app.use(ElementPlus, {
  locale: zhCn
})
app.use(createPinia())
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
app.use(pinia)
app.use(router)
app.mount('#app')
src/router/h5/router.js
@@ -1,28 +1,40 @@
const router = [
  {
    path: '/h5/verify',
    name: '考点核验',
    component: () => import('@/views/h5/verify/index.vue'),
  },
  {
    path: '/h5/verForm/:id',
    name: '提交考点核验',
    component: () => import('@/views/h5/verify/form.vue'),
  },
  {
    path: '/h5/noVerAccess',
    name: '核验无权限',
    component: () => import('@/views/h5/verify/noAccess.vue'),
  },
  {
    path: '/h5/login',
    name: '身份验证登录',
    component: () => import('@/views/h5/login/index.vue'),
  },
  {
    path: '/h5/signup',
    name: '签到',
    component: () => import('@/views/h5/signup/index.vue'),
    path: '/h5',
    name: 'h5页面',
    component: () => import('@/views/h5/index.vue'),
    children: [
      {
        path: 'verify',
        name: '考点核验',
        component: () => import('@/views/h5/verify/index.vue'),
      },
      {
        path: 'verForm',
        name: '提交考点核验',
        component: () => import('@/views/h5/verify/form.vue'),
      },
      {
        path: 'noVerAccess',
        name: '核验无权限',
        component: () => import('@/views/h5/verify/noAccess.vue'),
      },
      {
        path: 'login',
        name: '身份验证登录',
        component: () => import('@/views/h5/login/index.vue'),
      },
      {
        path: 'signup',
        name: '签到',
        component: () => import('@/views/h5/signup/index.vue'),
      },
      {
        path: 'face',
        name: '人脸验证',
        component: () => import('@/views/h5/faceAuth/index.vue'),
      },
    ],
  },
]
export default router
src/router/index.js
@@ -3,6 +3,7 @@
import errorPage from '@/router/error/index.js'
import mainPage from '@/router/main/index.js'
import h5 from '@/router/h5/router.js'
import { useLoginStore } from '@/stores/login.js'
const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
@@ -10,6 +11,7 @@
})
router.beforeEach((to, from, next) => {
  const { setLastRouteInfo } = useLoginStore()
  if (!to.matched.length) {
    if (to.path === '/') {
      next({ path: '/main/home' })
@@ -17,6 +19,9 @@
      next({ path: '/error/404', query: { errorUrl: to.path } })
    }
  } else {
    if (from.name) {
      setLastRouteInfo(from)
    }
    next()
  }
})
src/stores/login.js
@@ -2,10 +2,14 @@
import { defineStore } from 'pinia'
export const useLoginStore = defineStore('login', () => {
  const loginDialogVisible = ref(false)
  const lastRouteInfo = ref({})
  function setLoginDialogVisible(visible) {
    loginDialogVisible.value = visible
  }
  function setLastRouteInfo(info) {
    lastRouteInfo.value = info
  }
  return { loginDialogVisible, setLoginDialogVisible }
})
  return { loginDialogVisible, setLoginDialogVisible, lastRouteInfo, setLastRouteInfo }
}, { persist: true })
src/utils/UA.js
New file
@@ -0,0 +1,57 @@
const ua = window.navigator.userAgent
const mobileAgents = ['Android', 'iPhone', 'qxyiOSApp', 'SymbianOS', 'Windows Phone', 'iPad', 'iPod', 'OpenHarmony']
const isWeixin = ua.match(/MicroMessenger/i) == 'MicroMessenger'
const isWeixinWork = ua.match(/wxwork/i) == 'wxwork'
const isTBSX5 = ua.match(/MQQBrowser/i) == 'MQQBrowser' || ua.match(/TBS/i) == 'TBS'
const isHarmony = ua.indexOf('OpenHarmony') > -1
const isHarmonyApp = ua.indexOf('qxyHarmony') > -1
const isAndroid = ua.indexOf('Android') > -1 || ua.indexOf('Adr') > -1
const isAndroidApp = ua.indexOf('qxyAndroidApp') > -1
const isiOSApp = ua.indexOf('qxyiOSApp') > -1
const isiOS = !!ua.match(/\(i[^;]+;( U;)? CPU.+Mac OS X/) || isiOSApp
const isApp = isAndroidApp || isiOSApp
const isOldVerApp = (isApp) && ua.indexOf('qxyEcpt') < 0
let isMobile = false
for (let v = 0; v < mobileAgents.length; v++) {
  if (ua.includes(mobileAgents[v])) {
    isMobile = true
    break
  }
}
let iosInputBlur = function () { // 兼容ios输入框
  if(isiOS) { // 判断是否为IOS系统
    setTimeout(() => {
      const scrollHeight = document.documentElement.scrollTop || document.body.scrollTop || 0
      window.scrollTo(0, Math.max(scrollHeight - 1, 0))
    }, 100)
  }
}
let wxWorkIosScrollToTop = function() {
  if (isWeixinWork && isiOS) {
    setTimeout(() => {
      window.scrollTo(1, 0)
    }, 1000)
  }
}
export {
  isWeixin,
  isWeixinWork,
  isMobile,
  isTBSX5,
  isiOS,
  isiOSApp,
  isAndroid,
  isAndroidApp,
  isApp,
  isOldVerApp,
  iosInputBlur,
  wxWorkIosScrollToTop,
  isHarmony,
  isHarmonyApp
}
src/utils/axios.js
@@ -138,11 +138,17 @@
          });
          refreshQueue = [];
          tokenUtils.clearTokens();
          if (router.currentRoute._value.path.includes('/h5/')) {
            router.push({ path: '/h5/login', query: router.currentRoute._value.query })
          }
        } finally {
          isRefreshing = false;
        }
      } else {
        tokenUtils.clearTokens();
        if (router.currentRoute._value.path.includes('/h5/')) {
          router.push({ path: '/h5/login', query: router.currentRoute._value.query })
        }
      }
    }
    
src/utils/tool.js
@@ -1,5 +1,7 @@
import { useOptionItemsStore } from '@/stores/optionItems.js';
import $qxueyou from '@/config/qxueyou.js'
import { tokenUtils } from '@/utils/axios.js'
import $axios from '@/utils/axios.js'
/**
 * 获取 assets/images 目录下的图片URL
 * @param {string} imageName - 图片文件名(包含扩展名)
@@ -159,20 +161,25 @@
let uploadRequest = function(blob, fileName, fileType){
  return new Promise((resolve) => {
    const file = new File([blob], fileName, {
      type: blob.type || 'application/octet-stream',
      lastModified: Date.now()
    });
    let fd = new FormData()
    let xhr = new XMLHttpRequest()
    fd.append('image', blob, `${fileName}.${fileType}`)
    fd.append('file', file)
    xhr.open('POST', $qxueyou.upload, true)
    xhr.setRequestHeader('Authorization', localStorage.getItem($qxueyou.ACCESS_TOKEN_KEY));
    xhr.onreadystatechange = () => {
      if (xhr.readyState === 4 && xhr.status === 200 && xhr.responseText) {
        let file = JSON.parse(xhr.responseText)[0] // 返回结果
        resolve(file.path)
        let file = JSON.parse(xhr.responseText) // 返回结果
        resolve(file.data)
      }
    }
    xhr.onerror = (evt) => { // 上传失败回调
      store.commit("snack/error", "上传失败!")
      console.log(JSON.stringify(evt.target))
      resolve()
      resolve(false)
    }
    xhr.send(fd);
  })
src/utils/wxjssdk.js
New file
@@ -0,0 +1,334 @@
import wx from 'weixin-js-sdk'
import axios from './axios'
import store from '../store.js'
import $qxueyou from '@/config/qxueyou.js'
import utilsUA from '@/plugins/utilsUA'
import { getUUID, qxyResImg } from '@/plugins/utils'
let newFeature = false
let oldShare = ['onMenuShareTimeline', 'onMenuShareAppMessage', 'onMenuShareQQ', 'onMenuShareQZone']
let newShare = ['updateTimelineShareData', 'updateAppMessageShareData']
let weixinFlag = utilsUA.isWeixin
let mobileFlag = utilsUA.isMobile
let channel = weixinFlag && mobileFlag ? 'wx_pub' : 'wx_pub_qr'
let isWxpub = 'wx_pub'.includes(channel)
/**
 * 微信获取签名
 * @param {*} toRoute 目标路由
 */
function getWxSignature(toRoute) {
  if (!weixinFlag) { return false }
  axios.get('/wx/js/signature', {
    params: { url: location.href }
  }).then(res => {
    if (!res || !res.data) {
      return false
    }
    let result = res.data.data || {}
    wx.config({ // 微信配置
      debug: false,
      appId: result.appId,
      timestamp: result.timestamp,
      nonceStr: result.nonceStr,
      signature: result.signature,
      jsApiList: [
        ...(newFeature ? newShare : oldShare),
        'chooseWXPay',
        'chooseImage',
        'getLocalImgData'
      ],
      openTagList: ['wx-open-launch-app']
    })
    wx.ready(function () {
      initShareOption(toRoute)
    })
    wx.error(function (res) {
      console.log(res)
    })
  })
}
/**
 * 初始化分享数据
 * @param {*} toRoute
 */
function initShareOption(toRoute) {
  if (!weixinFlag) { return false }
  let uuid = getUUID()
  let shareOptions = getShareOptions(uuid,toRoute)
  shareOptions.success = function () {
    shareSuccess(uuid,toRoute)
  }
  shareOptions.cancel = function () {
    console.log('取消分享')
  }
  shareOptions.trigger = function () {
    console.log('用户点击发送给朋友')
  }
  // 向下兼容旧版分享接口
  if (!newFeature) {
    wx.onMenuShareTimeline(shareOptions)
    wx.onMenuShareAppMessage(shareOptions)
  } else {
    wx.updateAppMessageShareData(shareOptions)
    wx.updateTimelineShareData(shareOptions)
  }
}
/**
 * 获取分享数据
 * @param {*} list
 */
function getShareOptions(uuid,toRoute) {
  let result = {}
  let customShare = store.state.share.custom
  if (customShare.title) {
    result.title = customShare.title
    result.desc = customShare.desc
  } else {
    result.title = toRoute.name
    result.desc = toRoute.name
  }
  if (customShare.imgUrl) {
    if (customShare.imgUrl.includes('http')) {
      result.imgUrl = customShare.imgUrl
    } else {
      result.imgUrl = qxyResImg(customShare.imgUrl)
    }
  }
  if (customShare.uuidLink) {
    let pageUrl = customShare.pageUrl || toRoute.fullPath
    let encode = encodeURI(`${uuid},${$qxueyou.htmlRoot + pageUrl}`)
    result.link = customShare.uuidLink + btoa(encode)
    // result.link = customShare.uuidLink + uuid
  } else if (customShare.link) {
    result.link = customShare.link
  } else {
    result.link = location.href
  }
  return result
}
/**
 * 分享成功回调
 */
function shareSuccess(uuid,toRoute) {
  let customShare = store.state.share.custom
  if (customShare.targetId) {
    let pageUrl = customShare.pageUrl || toRoute.fullPath
    axios.post('/wx/share/callback', {
      urlId: uuid,
      pageUrl: $qxueyou.htmlRoot + pageUrl,
      targetId: customShare.targetId,
      planIds: customShare.planIds
    }).then(() => {
      initShareOption(toRoute)
    })
  }
  // 当分享有方案Id,触发是否关注公众号
  if (customShare.planIds) {
    store.commit('wxh5/subscribe', '及时获取奖励提醒')
  }
  let mask = store.state.share.mask
  if (mask.show) {
    let text = mask.type === 'plan' ? '分享成功,继续助力' : '邀请成功,继续邀请'
    let newMask = { show: true, type: mask.type, text: text, codeText: mask.codeText }
    store.commit("share/maskText", newMask)
  }
}
/**
 * 统一支付接口处理
 * @param {*} orderId
 * @param {*} successCallback // 支付成功回调
 * @param {*} showCodeCallback // 显示二维码回调
 */
function unipayPay(orderId, successCallback, showCodeCallback) {
  axios.post('/wx/pay/createOrder', {
    orderId: orderId,
    channel: channel,
    redirectUrl: weixinFlag ? store.state.order.paySuccessUrl : undefined,
  }).then(res => {
    if (!res.data.data) { return false }
    let params = res.data.data.param
    if (isWxpub) { // 公众号支付
      chooseWXPay(params, successCallback)
    } else { // 扫码支付
      store.commit('timer/paying', true)
      showCodeCallback && showCodeCallback(params.codeUrl)
      checkIsPay(orderId, successCallback)
    }
  })
}
function chooseWXPay(result, successCallback) {
  //调用微信支付接口
  wx.chooseWXPay({
    timestamp: result.timeStamp, // 支付签名时间戳,注意微信jssdk中的所有使用timestamp字段均为小写。但最新版的支付后台生成签名使用的timeStamp字段名需大写其中的S字符
    nonceStr: result.nonceStr, // 支付签名随机串,不长于 32 位
    package: result.packageValue, // 统一支付接口返回的prepay_id参数值,提交格式如:prepay_id=\*\*\*)
    signType: result.signType, // 签名方式,默认为'SHA1',使用新版支付需传入'MD5'
    paySign: result.paySign, // 支付签名
    success: () => {
      successCallback && successCallback()
    },
    fail: (e) => {
      store.commit('snack/error', '请联系技术客服解决' + e.errMsg)
    }
  })
}
/**
 * 扫码支付成功的回调
 * @param {} orderId
 * @returns {}
 */
function checkIsPay(orderId, successCallback) {
  if (!store.state.timer.paying) {
    return false
  }
  setTimeout(function () {
    axios.get('/transact/order/payResult', {
      params: { orderId: orderId }
    }).then(res => {
      if (res.data.success) {
        successCallback && successCallback()
        store.commit('timer/paying', false)
      } else {
        checkIsPay(orderId, successCallback)
      }
    })
  }, 2000)
}
/**
 * 获取定位
 * @param {*} locationCallback
 */
function getPosition(locationCallback){
  wx.getLocation({
    success: function (res) {
      locationCallback(res)
    }
  })
}
function chooseImage(){
  return new Promise((resolve) => {
    wx.chooseImage({
      count: 1,
      sizeType: ['compressed'],
      sourceType: ['camera'],
      success: (res) => {
        if (res && res.localIds) {
          getLocalImgData(res.localIds[0]).then((localData) => {
            resolve(localData)
          })
        } else {
          store.commit('snack/error', `微信上传图片失败:${res}`)
        }
      },
      fail:function(e) {
        store.commit('snack/error', `微信上传图片异常:${JSON.stringify(e)}`)
      }
    })
  })
}
function getLocalImgData(localId){
  return new Promise((resolve) => {
    wx.getLocalImgData({
      localId: localId,
      success: function (res) {
        resolve(res.localData)
      },
      fail:function(e) {
        store.commit('snack/error', `微信获取图片异常:${JSON.stringify(e)}`)
      }
    })
  })
}
/**
 * 预览图片
 * @param {*} url
 * @param {*} urlList
 */
function previewImage(current, urls){
  wx.previewImage({
    current: current, // 当前显示图片的http链接
    urls: urls ? urls : [current] // 需要预览的图片http链接列表
  })
}
/**
 * 返回小程序页面
 * @param {*} mpRouter // 小程序的路由
 * @param {*} otherCallback // 其他回调操作
 */
function redirectToMp(mpRouter) {
  return new Promise(resolve => {
    // 非微信 或 人社局活动入口
    if (!weixinFlag || store.state.course.isRsjActivity) {
      resolve(true)
      return false
    }
    wx.miniProgram.getEnv(function(res) {
      if (res.miniprogram) { // 小程序
        wx.miniProgram.redirectTo({
          url: mpRouter,
          success: function(){
            console.log('跳转成功')
            setTimeout(() => { // 处理其他机构的小程序跳转场景
              resolve(true);
            }, 3000)
          },
          fail: function(){
            resolve(true)
          }
        })
      } else { // 非小程序
        resolve(true)
      }
    })
  })
}
/**
 * 向小程序发送消息
 */
function postMessage(data) {
  // 非微信
  if (!weixinFlag) { return false }
  wx.miniProgram.getEnv(function(res) {
    if (res.miniprogram) { // 小程序
      wx.miniProgram.postMessage({
        data: data
      })
    }
  })
}
export {
  getWxSignature,
  initShareOption,
  unipayPay,
  getPosition,
  previewImage,
  chooseImage,
  redirectToMp,
  postMessage
}
src/views/h5/faceAuth/components/auditDialog.vue
New file
@@ -0,0 +1,156 @@
<template>
  <el-dialog
    v-model="dialogFlag"
    width="80%"
    style="max-width: 500px;"
    align-center
    :show-close="status!='auditing'"
    :close-on-click-modal="false"
    :close-on-press-escape="false"
  >
    <div class="p-7 pt-0" style="display: flex; flex-direction: column; align-items: center;">
      <div class="image_box" v-if="['unStart', 'auditing'].includes(status)">
        <el-image :src="base64"></el-image>
        <div v-if="status=='auditing'" class="scan-line"></div>
      </div>
      <el-row justify="center" v-else-if="['success', 'fail'].includes(status)">
        <el-image :src="$getImageUrl(`/h5/face_${status}.png`)"></el-image>
        <el-row>
          <el-text class="text-lg">人脸验证{{ status=='success'?'':'不' }}通过</el-text>
        </el-row>
        <el-row class="mt-2">
          <el-text v-if="status=='success'">系统已成功审核您的身份</el-text>
          <el-text v-else-if="status=='fail'">请重新验证您的身份</el-text>
        </el-row>
      </el-row>
      <el-row justify="center" class="mt-5" v-if="status=='auditing'">
        <el-text>正在审核中,请耐心等待...</el-text>
      </el-row>
      <el-button
        v-if="status=='unStart'"
        @click="submitAudit"
        :loading="status=='auditing'"
        type="primary"
        size="large" class="mt-5"
        style="width: 100%;"
      >
        提交审核
      </el-button>
      <el-button
        v-else-if="status=='success'"
        @click="handlerSuccess"
        type="primary"
        size="large" class="mt-5"
        style="width: 100%;"
      >
        完成验证
      </el-button>
      <el-button
        v-else-if="status=='fail'"
        @click="dialogFlag=false"
        type="primary"
        size="large" class="mt-5"
        style="width: 100%;"
      >
        确定
      </el-button>
    </div>
  </el-dialog>
</template>
<script>
import { uploadByBase64 } from '@/utils/tool.js';
export default {
  components: {},
  data() {
    return {
      dialogFlag: false,
      status: ''
    }
  },
  props: {
    modelValue: {
      type: Boolean,
      default: false
    },
    base64: {
      type: String,
      default: ''
    }
  },
  computed: {
  },
  created() {
  },
  watch: {
    modelValue(val) {
      this.dialogFlag = val
      if (val) {
        this.status = 'unStart'
      }
    },
    dialogFlag(val) {
      this.$emit('update:modelValue', val)
    }
  },
  methods: {
    async submitAudit() {
      this.status = 'auditing'
      const url = await uploadByBase64(this.base64 ,'人脸照片')
      if (!url) {
        this.status = 'fail'
        return
      }
      const params = { faceImgPath: url  }
      this.$axios.get('/system/auth/staff/checkin/face-match', { params }).then(res => {
        if (res.data.code == 0) {
          // this.status = res.data.data ? 'success' : 'fail'
          this.status = 'success'
        } else {
          this.status = 'fail'
          this.$message.error(res.data.msg || "人脸比对失败")
        }
      }).catch(() => {
        this.status = 'fail'
      })
    },
    handlerSuccess() {
      this.dialogFlag = false
      this.$emit('handlerSuccess')
    }
  }
}
</script>
<style scoped lang="scss">
.image_box {
  position: relative;
  width: 90%;
  max-width: 300px;
  aspect-ratio: 1/1;
  border-radius: 50% 50%;
  overflow: hidden;
  display: flex;
  justify-content: center;
  border: 5px solid #5693f4;
}
/* 扫描线 */
.scan-line {
  position: absolute;
  left: 0px;
  right: 0px;
  height: 50px;
  background: linear-gradient(0deg, #5693f4, transparent);
  animation: scan 2s ease-in-out infinite;
  /* 初始位置设在顶部 */
  top: -50px;
}
@keyframes scan {
  0% { top: -50px; }
  100% { top: 100%; } /* 移动到底部 */
}
</style>
src/views/h5/faceAuth/components/camera.vue
New file
@@ -0,0 +1,265 @@
<template>
  <div ref="cameraBox" class="camera_box" :style="cameraStyle" v-show="isCameraOpening">
    <video ref="videoEl" :width="videoWidth" :height="videoHeight" autoplay></video>
    <canvas ref="canvasEl" :width="videoWidth" :height="videoHeight" style="display:none;"></canvas>
    <el-row justify="center" class="btn_box">
      <el-button plain text @click="closeCamera()">
        <Icon icon="material-symbols:cancel" width="28" height="28"  style="color: #FA5252" />
      </el-button>
      <el-button plain text @click="startRecordImage()">
        <Icon icon="ant-design:camera-filled" width="28" height="28"  style="color: #FA5252" />
      </el-button>
    </el-row>
  </div>
</template>
<script>
import { uploadByBase64 } from '@/utils/tool.js'
export default {
  data () {
    return {
      recorderOptions: { // recorder 配置项
        mimeType: 'video/webm;codecs=vp8,opus'
      },
      isCameraOpening: false,
    }
  },
  computed: {
    videoWidth: function () {
      return 400
    },
    videoHeight: function(){
      return (this.videoWidth * 3) / 4
    },
    cameraStyle: function(){
      return {
        width: `${this.videoWidth}px`,
        height: `${this.videoHeight}px`
      }
    },
  },
  beforeUnmount(){
    this.closeCamera()
  },
  mounted () {
    this.initNavigatorMedia()
    this.openCamera()
  },
  methods: {
    initNavigatorMedia: function(){
      // 获取媒体属性,旧版本浏览器可能不支持mediaDevices,设置一个空对象
      if (navigator.mediaDevices === undefined) {
        navigator.mediaDevices = {};
      }
      // 使用getUserMedia,因为它会覆盖现有的属性。
      // 这里,如果缺少getUserMedia属性,就添加它。
      if (navigator.mediaDevices.getUserMedia === undefined) {
        this.initGetUserMedia()
      }
    },
    initGetUserMedia: function(){
      navigator.mediaDevices.getUserMedia = (constraints) => {
        // 首先获取现存的getUserMedia(如果存在)
        let getUserMedia =
          navigator.webkitGetUserMedia ||
          navigator.mozGetUserMedia ||
          navigator.getUserMedia;
        // 有些浏览器不支持,会返回错误信息
        // 保持接口一致
        if (!getUserMedia) {//不存在则报错
          return Promise.reject(
            new Error("getUserMedia is not implemented in this browser")
          );
        }
        // 否则,使用Promise将调用包装到旧的navigator.getUserMedia
        return new Promise((resolve, reject) => {
          getUserMedia.call(navigator, constraints, resolve, reject);
        });
      };
    },
    openCamera: function(){ // 打开摄像头
      navigator.mediaDevices.getUserMedia({
        audio: false,
        video: {
          width: this.videoWidth,
          height: this.videoHeight,
        }
      }).then((stream) => {
        this.isCameraOpening = true
        this.mediaStream = stream
        this.initVideoSrcObject()
      }).catch(err => {
        console.log(`${err.name}:${err.message}`);
        this.closeCamera()
        this.mediaErrorHandler(err)
      });
    },
    closeCamera() { // 关闭摄像头
      if (!this.isCameraOpening) return false
      this.isCameraOpening = false
      if (this.mediaStream) {
        let tracks = this.mediaStream.getTracks()
        tracks.forEach(track => track.stop());
      }
      if (this.mediaRecorder) this.mediaRecorder.stop();
      this.$emit('close')
    },
    initVideoSrcObject: function(){ // 初始化 视频录制 的 video srcObject
      // 旧的浏览器可能没有srcObject
      if ("srcObject" in this.$refs.videoEl) {
        this.$refs.videoEl.srcObject = this.mediaStream;
        this.initVideoMoveListener()
      } else {
        console.log('浏览器不支持')
      }
    },
    initVideoMoveListener: function(){ // 初始化 视频窗口 的 移动事件
      let eventState = {}
      let startMoving = (e) => { // 开始移动的回调
        e.preventDefault()
        e.stopPropagation()
        eventState = {
          left: this.$refs.cameraBox.offsetLeft,
          top: this.$refs.cameraBox.offsetTop,
          x: e.clientX,
          y: e.clientY
        }
        document.addEventListener('mousemove', moving)
        document.addEventListener('mouseup', endMoving)
      }
      let moving = (e) => { // 移动的回调
        e.preventDefault()
        e.stopPropagation()
        let margin = 10
        let left = e.clientX - (eventState.x - eventState.left)
        let top = e.clientY - (eventState.y - eventState.top)
        let maxLeft = document.documentElement.clientWidth - this.videoWidth - margin
        let maxTop = document.documentElement.clientHeight - this.videoHeight - margin
        // 限制移动截图区域不能移到可视区域外
        if (left < 10) left = margin
        if (top < 10) top = margin
        if (left > maxLeft) left = maxLeft
        if (top > maxTop) top = maxTop
        this.$refs.cameraBox.style.left = `${left}px`
        this.$refs.cameraBox.style.top = `${top}px`
      }
      let endMoving = (e) => { // 结束移动的回调
        e.preventDefault()
        document.removeEventListener('mousemove', moving)
        document.removeEventListener('mouseup', endMoving)
      }
      this.$refs.cameraBox.addEventListener('mousedown', startMoving)
    },
    initUploadChunk: async function(blobs){ // 初始化上传任务
      let tmpBlob = new Blob(blobs, { 'type': this.recorderOptions.mimeType });
      // let file = new File(blobs, fileName, { type: 'video/webm' });
      let file = null
      try {
        // 解决 Webm 视频 duration 问题
        file = await fixWebmMetaInfo(tmpBlob)
      } catch (error) {
        file = tmpBlob
        console.log(error)
      }
      let path = await uploadChunk(file, '学习视频', 'webm')
      if (!path) return false
      if (this.recordingFlag || this.recordCameraFlag) {
        this.$store.commit('authCamera/recordVideoUrl', path)
        this.$store.commit('authCamera/faceCaptureAll', { flag: false, imgList: this.faceCaptureAllImgList })
      } else {
        this.$store.commit('authCamera/biopsyVideoUrl', path)
      }
    },
    startRecordImage() { // 开始录制图像(拍照)
      if (!this.$refs.canvasEl) return false
      this.drawImage()
      // this.closeCamera()
    },
    drawImage: function(){ // 绘制图片
      this.$refs.canvasEl.getContext('2d').drawImage(
        this.$refs.videoEl,
        0,
        0,
        this.videoWidth,
        this.videoHeight
      );
      this.uploadBase64()
    },
    async uploadBase64(){ // 上传图片
      let base64 = this.$refs.canvasEl.toDataURL("image/png", 1);
      // const url = await uploadByBase64(base64, '核验照片')
      if (base64) {
        this.$emit('handlerSuccess', base64)
        this.closeCamera()
      } else {
        this.$message.error('拍摄失败')
      }
    },
    uploadErrorHandler: function (evt) { // 上传失败回调
      console.log(JSON.stringify(evt.target))
    },
    mediaErrorHandler: function(){ // 开启摄像头或录制屏幕异常处理
      this.$message.error('当前浏览器不支持,请更换浏览器')
    },
  }
};
</script>
<style lang="scss" scoped>
.camera_box{
  position: fixed;
  bottom: 10px;
  right: 10px;
  z-index: 300;
}
.status_box{
  position: absolute;
  left: 0;
  right: 0;
  padding: 6px;
  margin-top: -38px;
  font-size: 0.8rem;
  color: white;
  background: rgba(0,0,0,0.4);
  .normal > span,
  .abnormal > span{
    width: 6px;
    height: 6px;
    border-radius: 28px;
    display: inline-block;
    margin-right: 5px;
  }
  .normal > span{
    background-color: #00E63C;
  }
  .abnormal > span{
    background-color: #FF3232;
  }
}
.btn_box{
  background: rgba(0,0,0,0.4);
  margin-top: -63px;
  padding-top: 10px;
  padding-bottom: 7px;
  position: absolute;
  left: 0;
  right: 0;
}
.duration{
  position: absolute;
  top: 0;
  right: 0;
  color: white;
  text-shadow: 1px 1px 1px black, 1px -1px 1px black, -1px -1px 1px black, -1px 1px 1px black;
}
</style>
src/views/h5/faceAuth/index.vue
New file
@@ -0,0 +1,199 @@
<template>
  <div class="p-7 py-4 face">
    <div>
      <el-row class="px-7" justify="space-between">
        <el-text class="text-lg">姓名:<span class="font-bold">{{ userInfo.name }}</span></el-text>
        <el-text class="text-lg">身份证尾号:<span class="font-bold">{{ userInfo.idCard?.slice(-4) }}</span></el-text>
      </el-row>
      <el-row justify="center">
        <el-image :src="$getImageUrl(`/h5/face_default.png`)" style="width: 300px;"></el-image>
      </el-row>
      <!-- <video id="video" width="400" height="300" autoplay></video> -->
      <el-row justify="center">
        <el-text class="text-xl">请拍摄照片完成人脸认证</el-text>
      </el-row>
      <el-row class="my-7">
        <el-text class="text-info text-center">
          提示:人脸验证将会进行系统审核,为确保您能快速通过验证,请勿衣着暴露,配合系统指示完成验证。
        </el-text>
      </el-row>
      <el-row class="pt-7">
        <el-text class="text-xl">拍摄须知</el-text>
      </el-row>
      <el-row justify="space-between" class="mt-3">
        <div v-for="(tip,index) in tipItems" :key="`tip${index}`">
          <el-image :src="$getImageUrl(`/h5/face_tip_${index+1}.png`)" style="width: 70px;"></el-image>
        </div>
      </el-row>
    </div>
    <el-row justify="center" class="mb-7">
      <el-button @click="startCapture" type="primary" style="width: 100%;" size="large">开始拍摄</el-button>
    </el-row>
    <camera
      v-if="openCameraFlag"
      @close="openCameraFlag=false"
      @handlerSuccess="shootSuccess"
    ></camera>
    <auditDialog
      v-model="auditDialogFlag"
      :base64="base64"
      @handlerSuccess="auditSuccess"
    >
    </auditDialog>
  </div>
</template>
<script>
import camera from '@/views/h5/faceAuth/components/camera.vue';
import {isWeixin} from '@/utils/UA.js'
import auditDialog from '@/views/h5/faceAuth/components/auditDialog.vue';
import { useSessionStore } from '@/stores/session.js'
import { storeToRefs } from 'pinia';
export default {
  components: {
    camera,
    auditDialog
  },
  setup() {
    const { userInfo } = storeToRefs(useSessionStore())
    return { userInfo }
  },
  data() {
    return {
      tipItems: [
        { label: '标准拍摄', isCheck: true },
        { label: '标准拍摄', isCheck: true },
        { label: '标准拍摄', isCheck: true },
        { label: '标准拍摄', isCheck: true },
      ],
      openCameraFlag: false,
      base64: '',
      auditDialogFlag: false
    }
  },
  computed: {
    getSigninButtonStyle() {
      if (this.positionStatus == 'success') {
        return {
          'background': '#66d06c',
          'border-color': '#e7f7eb'
        }
      } else {
        return {
          'background': '#e1e1e1',
          'border-color': '#f8f8f8'
        }
      }
    }
  },
  async mounted() {
    this.currentTimeText = this.$dayjs().format('HH:mm')
  },
  methods: {
    getUserPositionStatus(evt) {
      this.userPositionStatus = evt
    },
    startCapture() {
      if (isWeixin) {
        console.log('')
      } else {
        this.openCameraFlag = true
      }
    },
    shootSuccess(evt) {
      this.base64 = evt
      if (this.base64) {
        this.auditDialogFlag = true
      }
    },
    auditSuccess() {
      localStorage.setItem('isFace', true)
      if (!this.getIsSignup()) {
        this.$router.replace({ path: '/h5/signup', query: { appId: this.appId } })
      } else {
        this.$router.replace({ path: '/h5/verForm', query: { appId: this.appId }})
      }
    },
    getIsSignup() {
      return Boolean(localStorage.getItem('isSignup'))
    }
  }
}
</script>
<style lang="scss" scoped>
.face {
  height: 100vh;
  display: flex;
  flex-direction: column;
  justify-content: space-between;
  max-width: 700px;
  margin: 0 auto;
  overflow: auto;
}
.mapBox {
  width: 300px;
  height: 300px;
  border-radius: 150px;
}
.mask {
  position: absolute;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%);
  width: 300px;
  height: 340px;
  background: radial-gradient(circle at center, transparent 0px, white 160px);
  z-index: 12; /* 位于红色盒子上方 */
}
.center-sign {
  z-index: 13;
  position: absolute;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%);
}
.text-sign {
  z-index: 13;
  position: absolute;
  left: 50%;
  top: 66%;
  white-space: nowrap;
  transform: translate(-50%, -50%);
}
.signin-button {
  width: 140px;
  height: 140px;
  border-radius: 70px;
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
  border: 16px solid
}
.ripple {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  border-radius: 50%;
  background-color: rgba(255, 255, 255, 0.5);
  transform: scale(0);
  animation: ripple 2s ease-out infinite;
  pointer-events: none; /* 防止波纹遮挡按钮点击 */
  z-index: 14;
}
/* 确保按钮文字显示在波纹上方 */
.signin-button .el-row {
  position: relative;
  z-index: 2;
}
@keyframes ripple {
  to {
    transform: scale(2);
    opacity: 0;
  }
}
</style>
src/views/h5/index.vue
New file
@@ -0,0 +1,35 @@
<template>
  <div>
    <router-view></router-view>
  </div>
</template>
<script>
import { useSessionStore } from '@/stores/session.js'
export default {
  setup() {
    const { setUserInfo } = useSessionStore()
    return { setUserInfo }
  },
  data() {
    return {}
  },
  async created() {
    await this.getUserInfo()
  },
  methods: {
    getUserInfo() {
      return new Promise((resolve) => {
        this.$axios.get('/system/auth/staff/profile').then(res => {
          if (res.data.code == 0) {
            this.setUserInfo(res.data.data || {})
          } else {
            this.$message.error(res.data.msg || '获取用户信息失败')
          }
        }).finally(() => {
          resolve()
        })
      })
    },
  }
}
</script>
src/views/h5/login/index.vue
@@ -1,5 +1,5 @@
<template>
  <div class="login">
  <div class="login" v-if="loginType == 'mobilePhone'">
    <el-form ref="form" :model="form">
      <el-form-item :rules="[$rules.required('请输入手机号') , $rules.phone()]" prop="mobile">
        <el-input v-model="form.mobile" placeholder="请输入手机号" style="width: 100%" size="large" />
@@ -32,9 +32,16 @@
</template>
<script>
import { tokenUtils } from '@/utils/axios.js';
import { useLoginStore } from '@/stores/login.js'
import { isWeixin } from '@/utils/UA.js'
export default {
  setup() {
    const { lastRouteInfo } = useLoginStore()
    return { lastRouteInfo }
  },
  data() {
    return {
      loginType: '', //mobile、weixin
      form: {
        mobile: '',
        code: '',
@@ -47,6 +54,8 @@
  },
  created() {
    tokenUtils.clearTokens()
    this.loginType = isWeixin ? 'weixin' : 'mobilePhone'
    this.loginType = 'mobile'
  },
  computed: {
    appId() {
@@ -104,9 +113,12 @@
        if (res.data.code == 0) {
          const resData = res.data.data
          tokenUtils.setTokens(resData.accessToken, resData.refreshToken)
          this.$router.replace({ path: '/h5/verify', query: { appId: this.appId } })
          this.$message.success('登录成功')
          if (this.lastRouteInfo.name) {
            this.$router.replace(this.lastRouteInfo)
          }
        } else {
          this.$message.error(res.data.msg)
          this.$message.error(res.data.msg || '登录失败')
        }
      }).finally(() => {
        this.loginLoading = false
src/views/h5/signup/BaiduMap.vue
@@ -32,8 +32,6 @@
  });
  return baiduMapPromise;
}
import { getCurrentPosition } from '@/utils/tool.js'
export default {
  name: 'BaiduMap',
  props: {
@@ -78,6 +76,7 @@
      map.enableScrollWheelZoom();
      this.map = map;
      this.$emit('ready', map);
      this.$emit('getMapStatus', 'success')
      this.getUserPosition()
    },
    async getUserPosition() {
src/views/h5/signup/index.vue
@@ -2,8 +2,8 @@
  <div class="p-4 signin">
    <div>
      <el-row class="px-7" justify="space-between">
        <el-text class="text-lg">姓名:<span class="font-bold">张三</span></el-text>
        <el-text class="text-lg">身份证尾号:<span class="font-bold">8888</span></el-text>
        <el-text class="text-lg">姓名:<span class="font-bold">{{ userInfo.name }}</span></el-text>
        <el-text class="text-lg">身份证尾号:<span class="font-bold">{{ userInfo.idCard?.slice(-4) }}</span></el-text>
      </el-row>
      <el-row justify="center" class="m-4">
        <div style="position: relative;">
@@ -13,7 +13,7 @@
              :center="centerPoint"
              @getUserPositionStatus="(evt) => userPositionStatus = evt"
              @getMapStatus="(evt) => mapStatus = evt"
              @getDistance="(evt) => distance = evt"
              @getDistance="getDistance"
            />
          </div>
          <div class="center-sign">
@@ -37,18 +37,18 @@
          </div>
        </div>
      </el-row>
      <el-row justify="center">
      <!-- <el-row justify="center">
        <el-text class="text-lg font-bold">{{ positionName }}</el-text>
      </el-row>
      </el-row> -->
      <el-row justify="center" class="mt-1">
        <el-text class="text-lg text-info">{{ positionAddress }}</el-text>
      </el-row>
    </div>
    <el-row justify="center" class="mt-7">
      <div class="signin-button" :style="getSigninButtonStyle">
      <div class="signin-button" :style="getSigninButtonStyle" @click="signinConfirm()">
        <span class="ripple" v-if="!positionError"></span>
        <el-row justify="center">
          <el-text class="text-lg  text-white">签到</el-text>
          <el-text class="text-lg text-white">签到</el-text>
        </el-row>
        <el-row justify="center" class="mt-1">
          <el-text class="text-lg text-white">{{ currentTimeText }}</el-text>
@@ -60,29 +60,37 @@
<script>
import BaiduMap from '@/views/h5/signup/BaiduMap.vue'
import { useSessionStore } from '@/stores/session.js'
import { storeToRefs } from 'pinia';
export default {
  components: {
    BaiduMap
  },
  setup() {
    const { userInfo } = storeToRefs(useSessionStore())
    return { userInfo }
  },
  data() {
    return {
      userPositionStatus: 'loading', // loading、fail、success
      mapStatus: 'loading', //loading、fail、success
      distance: 0,
      distance: null,
      positionError: false,
      failMsg: '超出签到范围,不可签到',
      positionName: '万科云城',
      positionName: '',
      positionAddress: '南山区打石二路南118号',
      currentTimeText: '',
      centerPoint: {
        lat:22.580372,
        lat: 22.580372,
        lng: 113.946530
      }
    }
  },
  computed: {
    canSignup() {
      return this.mapStatus == 'success' && this.userPositionStatus == 'success' && !this.positionError
    },
    getSigninButtonStyle() {
      if (this.positionStatus == 'success') {
      if (this.canSignup) {
        return {
          'background': '#66d06c',
          'border-color': '#e7f7eb'
@@ -93,21 +101,67 @@
          'border-color': '#f8f8f8'
        }
      }
    },
    appId() {
      return this.$route.query.appId
    }
  },
  created() {
    this.getSignupAddress()
  },
  async mounted() {
    this.currentTimeText = this.$dayjs().format('HH:mm')
  },
  methods: {
    getSignupAddress() {
      const params = { applicationId: this.appId }
      this.$axios.get('/exam/verify-record/get-by-application-id', { params }).then(res => {
        if (res.data.code == 0) {
          const resData = res.data.data || {}
          // this.centerPoint = {
          //   lat: resData.examSite?.locationLat,
          //   lng: resData.examSite?.locationLng
          // }
          this.positionAddress = resData.examSite?.address
        } else {
          this.$message.error(res.data.msg)
        }
      })
    },
    getUserPositionStatus(evt) {
      this.userPositionStatus = evt
    },
    getDistance(evt) {
      this.distance = evt
      if (this.distance && this.distance <= 500) {
        this.positionError = false
      } else {
        this.positionError = true
      }
    },
    signinConfirm() {
      if (!this.canSignup) {
        return
      }
      this.$message.success('签到成功')
      localStorage.setItem('isSignup', true)
      setTimeout(() => {
        if (this.getIsFace()) {
          this.$router.replace({ path: '/h5/face', query: { appId: this.appId }})
        } else {
          this.$router.replace({ path: '/h5/verForm', query: { appId: this.appId }})
        }
      }, 500)
    },
    getIsFace() {
      return Boolean(localStorage.getItem('isFace'))
    }
  }
}
</script>
<style lang="scss" scoped>
.signin {
  height: 100%;
  height: 100vh;
  display: flex;
  flex-direction: column;
  justify-content: space-between;
src/views/h5/verify/form.vue
@@ -1,34 +1,45 @@
<template>
  <div>
  <div v-if="pdfUrl">
    <el-row class="p-3 m-0" justify="space-between" align="middle">
      <el-col :span="3"></el-col>
      <el-col :span="18">
        <el-text class="text-lg font-bold text-center">
          {{ title }}
        </el-text>
      <el-col :span="4"></el-col>
      <el-col :span="16">
        <el-row justify="center">
          <el-text class="text-lg font-bold text-center">
            {{ title }}
          </el-text>
        </el-row>
      </el-col>
      <el-col :span="3">
        <el-button text style="color: var(--el-color-primary);" :loading="saveLoading" @click="tempSave()">暂存</el-button>
      <el-col :span="4">
        <el-row justify="center">
          <el-button
            v-if="!isVerified"
            text style="color: var(--el-color-primary);"
            :loading="saveLoading" @click="tempSave()"
            class="mx-4"
          >
            暂存
          </el-button>
        </el-row>
      </el-col>
    </el-row>
    
    <el-divider class="m-0" style="flex-shrink: 0;"></el-divider>
    <el-scrollbar :height="`${mainHeight}px`" class="p-2 m-0 mt-1" min-size="none">
    <el-scrollbar :height="`${mainHeight}px`" class="p-2 m-0 mt-1" >
      <div v-if="pdfUrl" :style="{width: '100%', height: `${mainHeight - 100}px`}">
        <PdfPreview v-if="pdfUrl" :url="pdfUrl"></PdfPreview>
      </div>
      <div class="p-2 my-4">
        <el-form ref="verifyForm" :model="form">
          <el-form-item label="以上申报内容是否属实" prop="isVerified">
            <el-radio-group v-model="form.isVerified">
              <el-radio :value="true">是</el-radio>
              <el-radio :value="false">否</el-radio>
          <el-form-item label="*以上申报内容是否属实" prop="isVerified">
            <el-radio-group v-model="form.isContentTrue" :disabled="isVerified">
              <el-radio :value="1">是</el-radio>
              <el-radio :value="0">否</el-radio>
            </el-radio-group>
          </el-form-item>
          <el-form-item label="该考点核验是否通过" prop="isPass">
            <el-radio-group v-model="form.isPass">
              <el-radio :value="true">是</el-radio>
              <el-radio :value="false">否</el-radio>
          <el-form-item label="*该考点核验是否通过" prop="isPass">
            <el-radio-group v-model="form.isSitePass" :disabled="isVerified">
              <el-radio :value="1">是</el-radio>
              <el-radio :value="0">否</el-radio>
            </el-radio-group>
          </el-form-item>
          <el-row><el-text>专家评估意见</el-text></el-row>
@@ -38,16 +49,23 @@
              :rows="3"
              type="textarea"
              placeholder="请填写评估意见"
              :disabled="isVerified"
            />
          </el-form-item>
          <el-row><el-text>现场工作照片</el-text></el-row>
          <el-row><el-text>*现场工作照片</el-text></el-row>
          <el-row>
            <UploadBtn v-model="form.image" :accept="['pdf', 'jpg']" :limitFileCount="10" listType="picture-card"></UploadBtn>
            <UploadBtn v-model="form.image" :disabled="isVerified" :accept="['pdf', 'jpg']" :limitFileCount="10" listType="picture-card"></UploadBtn>
          </el-row>
          
          <Signature v-model="form.signature"></Signature>
          <Signature v-model="form.signatureUrl" :disabled="isVerified" :isRequire="true"></Signature>
          <el-button type="primary" size="large" class="my-7" style="width: 100%;">提交核验结果</el-button>
          <el-button
            v-if="!isVerified"
            @click="submitVerify"
            type="primary" size="large"
            class="my-7" style="width: 100%;"
            :loading="submitLoading"
          >提交核验结果</el-button>
        </el-form>
      </div>
    </el-scrollbar>
@@ -59,7 +77,6 @@
import Signature from '@/views/main/components/Signature.vue';
import { useSessionStore } from '@/stores/session.js'
import { storeToRefs } from 'pinia';
import { tokenUtils } from '@/utils/axios.js';
export default {
  components: {
@@ -77,13 +94,15 @@
      pdfUrl: '',
      form: {
        id: '',
        isVerified: false,
        isPass: false,
        isContentTrue: 0,
        isSitePass: 0,
        suggestion: '',
        image: [],
        signature: ''
        signatureUrl: ''
      },
      saveLoading: false
      isVerified: false,
      saveLoading: false,
      submitLoading: false
    }
  },
  computed: {
@@ -91,7 +110,7 @@
      return this.pageHeight - 80
    },
    appId() {
      return this.$route.params.id
      return this.$route.query.appId
    }
  },
  async created() {
@@ -99,18 +118,24 @@
  },
  mounted() {
    document.title = '考点核验'
    this.pdfUrl = this.$qxueyou.qxyRes + '20260304/匠心学院职业技能评价考点核验申请表_1772591122177.pdf'
  },
  methods: {
    getVerifyDetail() {
      const params = { id: this.appId }
      this.$axios.get('/exam/verify-record/get', { params }).then(res => {
      const params = { applicationId: this.appId }
      this.$axios.get('/exam/verify-record/get-by-application-id', { params }).then(res => {
        if (res.data.code == 0) {
          const resData = res.data.data || {}
          this.pdfUrl = this.$qxueyou.qxyRes + resData.examSiteVerifyFile
          this.title = resData.organizationName + '-' + resData.examSite.siteName + '考点核验'
          if (resData.id) {
            this.form = {
              ...resData
            }
            this.form.isContentTrue  = resData.isContentTrue
            this.form.isSitePass = resData.isSitePass
            this.form.suggestion = resData.evaluationOpinion
            resData.sitePhotos?.forEach(ele => {
              this.form.image.push({ name: '', url: ele })
            })
            this.form.signatureUrl = resData.signatureUrl
            this.isVerified = resData.isVerified
          }
        } else {
          this.$message.error('获取核验信息失败')
@@ -119,25 +144,62 @@
    },
    tempSave() {
      const data = {
        applicationId: this.appId,
        id: this.form.id,
        userId: 0,
        name: "",
        gender: "",
        mobile: "",
        age: 0,
        idNumber: "",
        isContentTrue: 0,
        isSitePass: 0,
        evaluationOpinion: "",
        sitePhotos: [],
        status: 0
        userId: this.userInfo.id,
        name: this.userInfo.name,
        mobile: this.userInfo.mobile,
        idNumber: this.userInfo.idCard,
        isContentTrue: this.form.isContentTrue,
        isSitePass: this.form.isSitePass,
        evaluationOpinion: this.form.suggestion,
        sitePhotos: this.form.image.map(ele => ele.url),
        signatureUrl: this.form.signatureUrl,
      }
      this.$axios.post('/exam/verify-record/create', data).then(res => {
      this.saveLoading = true
      this.$axios.put(`/exam/verify-record/save`, data).then(res => {
        if (res.data.code == 0) {
          console.log(res.data.data)
          this.$message.success('保存成功')
          this.getVerifyDetail()
        } else {
          this.$message.error(res.data.msg)
        }
      }).finally(() => {
        this.saveLoading = false
      })
    },
    submitVerify() {
      if (this.form.image.length==0) {
        this.$message.error('请上传现场工作照片')
        return
      }
      if (!this.form.signatureUrl) {
        this.$message.error('请填写签名')
        return
      }
      this.$messageBox.confirm('提交之后不可再编辑核验,确认提交吗', '提示',
      { confirmButtonText: '确定', cancelButtonText: '取消', type: 'tip' }).then(res => {
        if (res == 'confirm') {
          this.submitLoading = true
          const data = {
            applicationId: this.appId,
            isContentTrue: this.form.isContentTrue,
            isSitePass: this.form.isSitePass,
            evaluationOpinion: this.form.suggestion,
            sitePhotos: this.form.image.map(ele => ele.url),
            signatureUrl: this.form.signatureUrl,
          }
          this.$axios.post('/exam/verify-record/verify', data).then(res => {
            if (res.data.code == 0) {
              this.$message.success('提交核验成功')
              this.isVerified = true
            } else {
              this.$message.error(res.data.msg || '提交核验失败')
            }
          }).finally(() => {
            this.submitLoading = false
          })
        }
      })
    },
    onPagesLoaded(msg) {
src/views/h5/verify/index.vue
@@ -1,6 +1,5 @@
<template>
  <div>
  </div>
  <div></div>
</template>
<script>
import { tokenUtils } from '@/utils/axios.js';
@@ -8,9 +7,7 @@
export default {
  components: {},
  data() {
    return {
    }
    return {}
  },
  computed: {
    query() {
@@ -21,17 +18,17 @@
    }
  },
  async created() {
    if (tokenUtils.getAccessToken()) {
      const canVerify = await this.getCanVerify()
      if (canVerify) {
        this.$router.replace(`/h5/verForm/${this.appId}`)
      }
      // else {
      //   this.$router.replace('/h5/noVerAccess')
      // }
    } else {
      this.$message.error('请先登录')
      this.$router.replace({ path: '/h5/login', query: { appId: this.appId } })
    const canVerify = await this.getCanVerify()
    if (canVerify) {
      if (!this.getIsFace()) {
        this.$router.replace({ path: '/h5/face', query: { appId: this.appId }})
      } else if (!this.getIsSignup()) {
        this.$router.replace({ path: '/h5/signup', query: { appId: this.appId } })
      } else {
        this.$router.replace({ path: '/h5/verForm', query: { appId: this.appId }})
      }
    } else {
      this.$router.replace('/h5/noVerAccess')
    }
  },
  mounted() {
@@ -54,6 +51,12 @@
        })
      })
    },
    getIsFace() {
      return Boolean(localStorage.getItem('isFace'))
    },
    getIsSignup() {
      return Boolean(localStorage.getItem('isSignup'))
    }
  }
}
</script>
src/views/main/components/Signature.vue
@@ -1,11 +1,17 @@
<template>
  <div class="signature">
    <el-row justify="space-between">
      <el-text>签名</el-text>
      <el-text v-if="imageUrl" @click="imageUrl=''">清除签名</el-text>
      <el-text v-else @click="signatureDialog=true">点击签名</el-text>
      <el-text>{{isRequire?'*':''}}签名</el-text>
      <template v-if="!disabled">
        <el-text v-if="imageUrl" @click="imageUrl=''">清除签名</el-text>
        <el-text v-else @click="signatureDialog=true">点击签名</el-text>
      </template>
    </el-row>
    <el-image v-if="imageUrl" :src="imageUrl"></el-image>
    <el-image
      v-if="imageUrl"
      style="width: 100%;"
      :src="imageUrl.includes('http') ? imageUrl : $qxueyou.qxyRes + imageUrl">
    </el-image>
    <div v-else class="image-slot"></div>
    <el-dialog 
@@ -68,6 +74,14 @@
    modelValue: {
      type: String,
      default: ''
    },
    isRequire: {
      type: Boolean,
      default: false
    },
    disabled: {
      type: Boolean,
      default: false
    }
  },
  computed: {
@@ -193,9 +207,10 @@
      }
      let base64 = this.editCanvas.toDataURL('image/png', 1)
      let smallBase64 = await this.resizedataURL(base64, 240, 80)
      // let url = await uploadByBase64(smallBase64, '签名')
      // if (!url) return false
      this.imageUrl = smallBase64
      let url = await uploadByBase64(smallBase64, '签名')
      if (!url) return false
      this.imageUrl = url
      this.$emit('update:modelValue', url)
      this.signatureDialog = false
    },
    resizedataURL: function(base64, wantedWidth, wantedHeight){
src/views/main/components/UploadBtn.vue
@@ -38,7 +38,7 @@
      <template v-if="listType=='picture-card'">
        <el-image
          ref="previewImg"
          :src="file.url"
          :src="file.url.includes('http') ? file.url : $qxueyou.qxyRes + file.url"
          :initial-index="initialPreviewImgIndex"
          :preview-src-list="filterPreviewImgList"
        >
@@ -177,7 +177,7 @@
        file: UploadRequestOptions.file,
        directory: ''
      }
      this.$axios.post('/infra/file/upload', data, {
      this.$axios.post('/infra/file/exam/upload', data, {
        headers: { 'Content-Type': "multipart/form-data" }
      }).then(res => {
        let index = this.list.findIndex(ele => ele.uid == data.file.uid)
vite.config.js
@@ -14,6 +14,7 @@
    },
  },
  server: {
    host: '0.0.0.0',
    proxy: {
      '/app-api': {
        target: 'http://101.43.143.75:48180', // dev