Parcourir la source

添加签名功能及相关组件,优化用户协议流程

yuanmingze il y a 8 mois
Parent
commit
42b5a7f723

+ 1 - 0
.eslintrc.cjs

@@ -80,5 +80,6 @@ module.exports = {
     ],
 
     'simple-import-sort/exports': 'error',
+    'no-undef': 'off',
   },
 }

+ 3 - 1
components.d.ts

@@ -8,10 +8,12 @@ export {}
 declare module 'vue' {
   export interface GlobalComponents {
     AuthInputItem: typeof import('./src/components/auth/AuthInputItem.vue')['default']
+    WdButton: typeof import('wot-design-uni/components/wd-button/wd-button.vue')['default']
+    WdCheckbox: typeof import('wot-design-uni/components/wd-checkbox/wd-checkbox.vue')['default']
     WdConfigProvider: typeof import('wot-design-uni/components/wd-config-provider/wd-config-provider.vue')['default']
     WdIcon: typeof import('wot-design-uni/components/wd-icon/wd-icon.vue')['default']
     WdNavbar: typeof import('wot-design-uni/components/wd-navbar/wd-navbar.vue')['default']
-    WdNavbarCapsule: typeof import('wot-design-uni/components/wd-navbar-capsule/wd-navbar-capsule.vue')['default']
     WdNoticeBar: typeof import('wot-design-uni/components/wd-notice-bar/wd-notice-bar.vue')['default']
+    WdPopup: typeof import('wot-design-uni/components/wd-popup/wd-popup.vue')['default']
   }
 }

+ 259 - 0
src/pages-common/signature/components/SignaturePad.vue

@@ -0,0 +1,259 @@
+<template>
+  <view class="signature-body">
+    <!-- 左侧操作栏 -->
+    <view class="sidebar">
+      <view class="btns">
+        <view class="button" @click="handleClear">重签</view>
+        <view class="button active" @click="handleSubmit">完成签名</view>
+      </view>
+    </view>
+
+    <!-- 签名画布 -->
+    <view class="canvas">
+      <canvas
+        id="signature-canvas"
+        canvas-id="signature-canvas"
+        disable-scroll
+        @touchstart="onTouchStart"
+        @touchmove="onTouchMove"
+        @touchend="onTouchEnd"
+      />
+      <text class="signature-text">签 名 区</text>
+    </view>
+
+    <!-- 隐藏旋转画布 -->
+    <canvas
+      class="hidden-canvas"
+      id="signature-rotate-canvas"
+      canvas-id="signature-rotate-canvas"
+    />
+  </view>
+</template>
+
+<script setup lang="ts">
+import { getCurrentInstance, nextTick, onMounted, ref } from 'vue'
+
+const CANVAS_ID = 'signature-canvas'
+const ROTATE_CANVAS_ID = 'signature-rotate-canvas'
+
+const emit = defineEmits<{
+  (e: 'submit', filePath: string): void
+  (e: 'clear'): void
+  (e: 'error', err: unknown): void
+}>()
+
+const instance = getCurrentInstance()
+
+const ctx = ref<UniApp.CanvasContext | null>(null)
+const canvasWidth = ref(0)
+const canvasHeight = ref(0)
+
+const drawing = ref(false)
+const lastPoint = ref<{ x: number; y: number } | null>(null)
+
+onMounted(async () => {
+  await nextTick()
+
+  const sys = uni.getSystemInfoSync()
+  canvasWidth.value = sys.windowWidth
+  canvasHeight.value = sys.windowHeight
+
+  ctx.value = uni.createCanvasContext(CANVAS_ID, instance?.proxy as any)
+
+  drawBackground()
+})
+
+const drawBackground = () => {
+  if (!ctx.value) return
+
+  ctx.value.setFillStyle('#ffffff')
+  ctx.value.fillRect(0, 0, canvasWidth.value, canvasHeight.value)
+
+  ctx.value.save()
+  ctx.value.translate(canvasWidth.value / 2, canvasHeight.value / 2)
+  ctx.value.rotate(Math.PI / 2)
+  ctx.value.setFontSize(60)
+  ctx.value.setFillStyle('rgba(0,0,0,0.06)')
+  ctx.value.setTextAlign('center')
+  ctx.value.fillText('签 名 区', 0, 0)
+  ctx.value.restore()
+
+  ctx.value.draw()
+}
+
+const onTouchStart = (e: UniApp.TouchEvent) => {
+  const touch = e.touches?.[0]
+  if (!touch || !ctx.value) return
+
+  drawing.value = true
+  lastPoint.value = { x: touch.x, y: touch.y }
+}
+
+const onTouchMove = (e: UniApp.TouchEvent) => {
+  if (!drawing.value || !ctx.value || !lastPoint.value) return
+
+  const touch = e.touches?.[0]
+  if (!touch) return
+
+  const current = { x: touch.x, y: touch.y }
+
+  ctx.value.beginPath()
+  ctx.value.setLineCap('round')
+  ctx.value.setStrokeStyle('#000000')
+  ctx.value.setLineWidth(4)
+  ctx.value.moveTo(lastPoint.value.x, lastPoint.value.y)
+  ctx.value.lineTo(current.x, current.y)
+  ctx.value.stroke()
+  ctx.value.draw(true)
+
+  lastPoint.value = current
+}
+
+const onTouchEnd = () => {
+  drawing.value = false
+  lastPoint.value = null
+}
+
+const handleClear = () => {
+  if (!ctx.value) return
+
+  ctx.value.clearRect(0, 0, canvasWidth.value, canvasHeight.value)
+  drawBackground()
+  emit('clear')
+}
+
+const handleSubmit = () => {
+  uni.showLoading({ title: '生成签名中' })
+
+  uni.canvasToTempFilePath(
+    {
+      canvasId: CANVAS_ID,
+      fileType: 'png',
+      quality: 1,
+      success(res) {
+        rotateImage(res.tempFilePath)
+      },
+      fail(err) {
+        uni.hideLoading()
+        emit('error', err)
+      },
+    },
+    instance?.proxy as any
+  )
+}
+
+const rotateImage = (src: string) => {
+  uni.getImageInfo({
+    src,
+    success(info) {
+      const rotateCtx = uni.createCanvasContext(ROTATE_CANVAS_ID, instance?.proxy as any)
+
+      const targetHeight = 300
+      const ratio = info.height / info.width
+      const targetWidth = targetHeight / ratio
+
+      rotateCtx.translate(targetHeight / 2, targetWidth / 2)
+      rotateCtx.rotate((270 * Math.PI) / 180)
+      rotateCtx.drawImage(src, -targetWidth / 2, -targetHeight / 2, targetWidth, targetHeight)
+
+      rotateCtx.draw(false, () => {
+        uni.canvasToTempFilePath(
+          {
+            canvasId: ROTATE_CANVAS_ID,
+            width: targetHeight,
+            height: targetWidth,
+            fileType: 'png',
+            quality: 1,
+            success(res) {
+              uni.hideLoading()
+              emit('submit', res.tempFilePath)
+            },
+            fail(err) {
+              uni.hideLoading()
+              emit('error', err)
+            },
+          },
+          instance?.proxy as any
+        )
+      })
+    },
+    fail(err) {
+      uni.hideLoading()
+      emit('error', err)
+    },
+  })
+}
+</script>
+
+<style scoped lang="scss">
+.signature-body {
+  display: flex;
+  width: 100%;
+  height: 100vh;
+  background: #ffffff;
+
+  .sidebar {
+    width: 150rpx;
+    background-color: #e5f1fe;
+    flex-shrink: 0;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+
+    .btns {
+      display: flex;
+      gap: 100rpx;
+      transform: rotate(90deg);
+
+      .button {
+        width: 250rpx;
+        height: 80rpx;
+        line-height: 80rpx;
+        text-align: center;
+        border-radius: 6rpx;
+        background-color: #ffffff;
+        color: #007bff;
+        font-size: 28rpx;
+      }
+
+      .button.active {
+        background-color: #4b9ef2;
+        color: #ffffff;
+      }
+    }
+  }
+
+  .canvas {
+    flex: 1;
+    position: relative;
+    overflow: hidden;
+
+    canvas {
+      width: 100%;
+      height: 100%;
+      display: block;
+    }
+
+    .signature-text {
+      position: absolute;
+      inset: 0;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      transform: rotate(90deg);
+      font-size: 80rpx;
+      color: rgba(208, 216, 242, 0.3);
+      pointer-events: none;
+      user-select: none;
+    }
+  }
+
+  .hidden-canvas {
+    position: fixed;
+    left: -9999px;
+    top: -9999px;
+    width: 1px;
+    height: 1px;
+  }
+}
+</style>

+ 40 - 0
src/pages-common/signature/index.vue

@@ -0,0 +1,40 @@
+<template>
+  <SignaturePad @submit="handleSignatureDone" @clear="handleClear" @error="handleError" />
+</template>
+
+<script setup lang="ts">
+import { ref } from 'vue'
+
+import { onLoad } from '@dcloudio/uni-app'
+
+import SignaturePad from './components/SignaturePad.vue'
+
+const type = ref('')
+onLoad((e) => {
+  type.value = e.type || ''
+})
+
+const handleSignatureDone = (filePath: string) => {
+  console.log('Signature done, file path:', filePath)
+
+  saveSignature(filePath)
+}
+
+const saveSignature = (filePath: string) => {
+  console.log('filePath', filePath)
+}
+
+const handleClear = () => {
+  console.log('签名已清空')
+}
+
+const handleError = (err: unknown) => {
+  console.error('SignaturePad error:', err)
+  uni.showToast({
+    icon: 'none',
+    title: '签名出错,请重试',
+  })
+}
+</script>
+
+<style lang="scss" scoped></style>

+ 14 - 0
src/pages.json

@@ -40,6 +40,20 @@
       }
     }
   ],
+  "subPackages": [
+    {
+      "root": "pages-common",
+      "pages": [
+        {
+          "path": "signature/index",
+          "style": {
+            "navigationBarTitleText": "签名",
+            "navigationStyle": "default"
+          }
+        }
+      ]
+    }
+  ],
   "globalStyle": {
     "navigationBarTextStyle": "black",
     "navigationBarTitleText": "uni-app",

+ 152 - 0
src/pages/index/components/HonestAgreementGate.vue

@@ -0,0 +1,152 @@
+<template>
+  <wd-popup
+    v-model="visible"
+    position="bottom"
+    :close-on-click-modal="false"
+    :show-close="false"
+    round
+    style="height: 85vh"
+  >
+    <view class="honest-popup">
+      <view class="header">
+        <text class="title">承诺书 2.0</text>
+        <view class="close"><wd-icon name="close" @click="handleReject" /></view>
+      </view>
+
+      <scroll-view class="content" scroll-y>
+        <view>深圳要易云科技服务有限公司:</view>
+
+        <view class="honest-text">
+          为建设公开、诚信、阳光的要易云平台商业环境,防止发生各种违法违纪案件和不良行为,现本人向贵公司郑重承诺如下:
+        </view>
+
+        <view class="honest-text">
+          一、本人保证在过往工作生活中表现良好,无违法犯罪记录,以及不存在法律法规等规定的不适宜从事要易云平台项下业务的其他情形。
+        </view>
+
+        <view class="honest-text">
+          二、本人将严格遵守与反腐败相关的法律法规和廉洁从业有关规定。
+        </view>
+
+        <view class="honest-text">
+          三、当本人知悉任何显示本人可能违反了与反腐败相关的法律法规的信息时,本人应将该等信息及时向贵公司汇报。
+        </view>
+
+        <view class="honest-text">
+          四、本人承诺,本人在要易云平台所提交的所有材料、信息均准确、真实、有效,无任何伪造、变造、篡改和隐瞒等虚假内容。有违前述承诺而造成的一切损害及后果由本人自行承担。
+        </view>
+
+        <view class="honest-text">
+          五、本人承诺,以本人名义开通的要易云平台账号仅限本人使用,不以任何形式包括但不限于赠与、借用、出售、转租等方式许可他人使用。如发生他人使用本人账号的情形,因此造成的一切损害及后果均由本人自行承担。
+        </view>
+
+        <view class="honest-text">
+          六、如果贵公司合理确信,本人已违反了与反腐败相关的法律法规,或已使贵公司或者本人通过贵公司平台合作的相关公司可能遭受违反反腐败相关法律的重大风险,则贵公司有权立刻终止/解除与本人的协议,并由本人承担由此给贵公司或者本人通过贵公司平台合作的相关公司造成的全部损失(包括但不限于任何直接或间接损失、诉讼费、律师费、调查取证费、差旅费、政府机关的罚款、第三方索赔、名誉损失等)。
+        </view>
+
+        <view class="honest-text">承诺人(签字):</view>
+        <view class="honest-text">日期:</view>
+
+        <view class="agreement">
+          <wd-checkbox v-model="isRead" shape="square" />
+          <text class="agreement-text">我已阅读完《承诺书 2.0》</text>
+        </view>
+      </scroll-view>
+
+      <!-- Footer -->
+      <view class="footer">
+        <wd-button type="primary" round block :disabled="!isRead" @click="handleAgree" size="large">
+          {{ isRead ? '同意廉洁承诺书' : '请滑动阅读完本条款后再同意' }}
+        </wd-button>
+      </view>
+    </view>
+  </wd-popup>
+</template>
+
+<script setup lang="ts">
+import { ref } from 'vue'
+
+import { useUserStore } from '@/stores/modules/user'
+
+const userStore = useUserStore()
+
+const visible = defineModel<boolean>({ required: true })
+
+const isRead = ref(false)
+
+const handleAgree = () => {
+  uni.navigateTo({
+    url: '/pages-common/signature/index?type=HONEST_AGREEMENT_V2',
+  })
+}
+
+const handleReject = () => {
+  uni.showModal({
+    title: '提 示',
+    content: '由于您未签署廉洁承诺书,即将退出登录!',
+    cancelText: '继续签署',
+    confirmText: '退出登录',
+    success: (e) => {
+      if (e.confirm) {
+        uni.showTabBar()
+        userStore.logout()
+        uni.reLaunch({
+          url: '/pages/login/index',
+        })
+      }
+    },
+  })
+}
+</script>
+
+<style scoped lang="scss">
+.honest-popup {
+  height: 70vh;
+  display: flex;
+  flex-direction: column;
+  padding: 32rpx;
+
+  .header {
+    position: relative;
+    text-align: center;
+    font-weight: 600;
+    font-size: 36rpx;
+    padding-bottom: 24rpx;
+    width: 100%;
+
+    .close {
+      position: absolute;
+      right: 0;
+      top: 0;
+      color: #666;
+    }
+  }
+
+  .content {
+    flex: 1;
+    overflow-y: auto;
+    font-size: 28rpx;
+    line-height: 48rpx;
+    color: #333;
+  }
+
+  .honest-text {
+    text-indent: 72rpx;
+    margin-top: 20rpx;
+  }
+
+  .agreement {
+    display: flex;
+    align-items: center;
+    margin-top: 24rpx;
+
+    .agreement-text {
+      font-size: 28rpx;
+    }
+  }
+
+  .footer {
+    padding-top: 24rpx;
+  }
+}
+</style>

+ 0 - 1
src/pages/index/components/WorkbenchCard.vue

@@ -16,7 +16,6 @@
         <view class="workbench-card-summary-value">我的任务包</view>
         <view class="workbench-card-summary-link">领包记录</view>
       </view>
-
       <view class="workbench-card-summary-stat" @click="handleOpenOnTheWay">
         <view
           class="workbench-card-summary-value"

+ 17 - 7
src/pages/index/components/WorkbenchTaskSection.vue

@@ -12,6 +12,7 @@
         class="workbench-task-item"
         v-for="item in currentTaskList"
         :key="item.backgroundImage"
+        @click="onSelectTask(item)"
         :style="{
           backgroundImage: 'url(' + item.backgroundImage + ')',
           color: item.color,
@@ -34,23 +35,32 @@ import { computed, ref } from 'vue'
 
 import { getLocation } from '@/lib/location'
 
+import { useUserStore } from '@/stores/modules/user'
+
 import { takePhoto } from '@/utils/image'
 
 import { DEFAULT_WORKBENCH_TASKS, INVESTMENT_MANAGER_TASKS } from '../taskList/index'
 import ImagePreviewOverlay from './ImagePreviewOverlay.vue'
 
-const userRole = computed(() => {
-  // return store.user.role
-  return 'default' // or 'investment-manager'
+const emit = defineEmits<{
+  (e: 'select-task', item: (typeof currentTaskList.value)[number]): void
+}>()
+
+const userStore = useUserStore()
+
+const isInvestmentManager = computed(() => {
+  const user = userStore.currentUserInfo
+  return Boolean(user?.roles?.includes(31))
 })
 
 const currentTaskList = computed(() => {
-  if (userRole.value === 'investment-manager') {
-    return INVESTMENT_MANAGER_TASKS
-  }
-  return DEFAULT_WORKBENCH_TASKS
+  return isInvestmentManager.value ? INVESTMENT_MANAGER_TASKS : DEFAULT_WORKBENCH_TASKS
 })
 
+const onSelectTask = (item: (typeof currentTaskList.value)[number]) => {
+  emit('select-task', item)
+}
+
 const previewVisible = ref(false)
 const imageUrl = ref('')
 const address = ref('')

+ 72 - 6
src/pages/index/index.vue

@@ -24,29 +24,95 @@
         background-color="#fdf6ec "
       />
     </view>
-    <WorkbenchTaskSection />
+    <WorkbenchTaskSection @select-task="handleSelectTask" />
+    <!--廉洁协议 -->
+    <HonestAgreementGate v-model="isHonestAgreementVisible" />
   </view>
 </template>
 
 <script setup lang="ts">
 import { computed, ref } from 'vue'
 
+import { onShow } from '@dcloudio/uni-app'
+
+import { useUserStore } from '@/stores/modules/user'
+
+import HonestAgreementGate from './components/HonestAgreementGate.vue'
 import WorkbenchCard from './components/WorkbenchCard.vue'
 import WorkbenchTaskSection from './components/WorkbenchTaskSection.vue'
 
-const isLogin = ref(false)
-const onTheWay = ref(5)
-const waitApprove = ref(2)
+const userStore = useUserStore()
+
+const isLogin = computed(() => userStore.isLoggedIn)
+const waitApprove = computed(() => userStore.currentUserInfo?.waitApprove ?? 0)
+const onTheWay = computed(() => userStore.currentUserInfo?.onTheWay ?? 0)
+
+onShow(async () => {
+  // 每次进入页面时刷新用户状态
+  if (!userStore.isLoggedIn) return
+  const userInfo = await userStore.getUserInfoByCode()
+  if (!userInfo) return
+  // 未签署廉洁协议
+  if (!userInfo.signedAgreement?.includes('HONEST_AGREEMENT_V2')) {
+    uni.hideTabBar()
+    openHonestAgreementGate()
+    return
+  } else {
+    uni.showTabBar()
+  }
+
+  console.log('qaq', userInfo)
+})
+
+const ensureCanOperate = (): boolean => {
+  if (!userStore.isLoggedIn) {
+    uni.showToast({
+      icon: 'none',
+      title: '请先登录',
+    })
+    setTimeout(() => {
+      uni.navigateTo({ url: '/pages/login/index' })
+    }, 1500)
+    return false
+  }
+
+  if (userStore.hasBlacklistRisk) {
+    uni.showModal({
+      title: '提示',
+      content: '该账号存在风险,请联系管理员',
+      showCancel: false,
+    })
+    return false
+  }
+
+  return true
+}
 
 const goTask = () => {
+  if (!ensureCanOperate()) return
   console.log('去领包')
 }
 const taskHistory = () => {
-  // 领包记录
-  console.log('去领包')
+  if (!ensureCanOperate()) return
+  console.log('领包记录')
 }
 const handleOpenStatus = (type: 'on-the-way' | 'wait-approve') => {
+  if (!ensureCanOperate()) return
   // 领包记录
+  console.log('type', type)
+}
+
+const handleSelectTask = (item: any) => {
+  if (!ensureCanOperate()) return
+  // 统一处理跳转 / 权限校验
+}
+
+// 业务判断
+// 显示签署廉洁协议弹窗
+const isHonestAgreementVisible = ref(false)
+
+const openHonestAgreementGate = () => {
+  isHonestAgreementVisible.value = true
 }
 </script>
 

+ 38 - 17
src/stores/modules/user.ts

@@ -30,9 +30,16 @@ export const useUserStore = defineStore('user', {
     isBlacklisted: false,
   }),
   getters: {
+    isLoggedIn(state): boolean {
+      return Boolean(state.access_token)
+    },
+    hasBlacklistRisk(state): boolean {
+      return Boolean(state.isBlacklisted)
+    },
     currentUserInfo(state): UserInfoItem | undefined {
-      const currentUserInfo = state.userInfoList[state.currentUserInfoIndex || 0]
-      return currentUserInfo || undefined
+      const index = state.currentUserInfoIndex
+      if (index == null) return undefined
+      return state.userInfoList[index]
     },
   },
   actions: {
@@ -55,23 +62,37 @@ export const useUserStore = defineStore('user', {
         success: true,
       }
     },
-    async getUserInfoByCode() {
-      const res = await getWXLoginCode()
-      if (res.code) {
-        const userInfoResult = await getUserInfoByCodeApi(res.code)
-        if (userInfoResult.code === 0 && userInfoResult.data) {
-          this.userInfoList = userInfoResult.data.userInfo
-          // 检查黑名单状态
-          if (this.userInfoList[0]) {
-            const { phone, idCardNumber } = this.userInfoList[0]
-            this.fetchUserBlacklistStatus({
-              phoneNumber: phone,
-              idCardNumber,
-            })
-          }
-        }
+    async getUserInfoByCode(): Promise<UserInfoItem | undefined> {
+      const wxRes = await getWXLoginCode()
+      if (!wxRes?.code) return undefined
+
+      const userInfoResult = await getUserInfoByCodeApi(wxRes.code)
+      if (userInfoResult.code !== 0 || !userInfoResult.data) {
+        return undefined
+      }
+
+      const userInfoList = userInfoResult.data.userInfo || []
+      this.userInfoList = userInfoList
+
+      const index = this.currentUserInfoIndex ?? 0
+      const currentUserInfo = userInfoList[index]
+
+      if (!currentUserInfo) {
+        return undefined
+      }
+
+      // 黑名单校验(不阻塞主流程)
+      const { phone, idCardNumber } = currentUserInfo
+      if (phone && idCardNumber) {
+        this.fetchUserBlacklistStatus({
+          phoneNumber: phone,
+          idCardNumber,
+        })
       }
+
+      return currentUserInfo
     },
+
     async fetchUserBlacklistStatus(payload: CheckBlacklistRequest) {
       const res = await checkBlacklistApi(payload)
       if (res.code == 0) {