Преглед на файлове

签约认证页面重构

yuanmingze преди 2 месеца
родител
ревизия
6c78147ed5

+ 344 - 0
src/pages-auth/fesco/index.vue

@@ -0,0 +1,344 @@
+<!--
+ * @desc fesco
+ * @author linyuanjie
+ * @date 2024/11/23
+-->
+<template>
+  <page-meta :page-style="pageStyle" />
+
+  <view class="fesco-page-wrapper">
+    <view class="fesco-header">
+      <view class="fesco-header__content">
+        <view class="fesco-header__title">签约指引</view>
+        <view class="fesco-header__subtitle"> 保存二维码后,使用微信扫一扫进入小程序完成签约 </view>
+      </view>
+
+      <view class="fesco-video-entry" @click="handleOpenTutorial">
+        <wd-icon name="play-circle-stroke" color="#0190FF" size="34rpx" />
+        <text class="fesco-video-entry__text">操作视频</text>
+      </view>
+    </view>
+
+    <view class="fesco-step-card">
+      <image class="fesco-step-img" :src="FESCO_STEP_IMAGE" mode="aspectFit" />
+    </view>
+
+    <view class="fesco-qrcode-wrapper">
+      <view class="fesco-qrcode-box">
+        <view class="fesco-qrcode-box__decor" />
+
+        <view class="fesco-qrcode-title">
+          {{ deptName || '签约二维码' }}
+        </view>
+
+        <view class="fesco-qrcode-subtitle"> 长按二维码保存至相册 </view>
+
+        <view class="fesco-qrcode-img-wrapper">
+          <image
+            class="fesco-qrcode-img"
+            :src="FESCO_QRCODE_IMAGE"
+            :show-menu-by-longpress="true"
+            mode="aspectFit"
+          />
+        </view>
+
+        <view class="fesco-tips-wrapper">
+          <view v-for="(item, index) in qrcodeTips" :key="item" class="fesco-tip-item">
+            <view class="fesco-tip-index">{{ index + 1 }}</view>
+            <text class="fesco-tip-text">{{ item }}</text>
+          </view>
+        </view>
+      </view>
+    </view>
+
+    <wd-popup
+      v-model="adviseShow"
+      position="center"
+      closable
+      root-portal
+      lock-scroll
+      custom-style="width: 686rpx; border-radius: 32rpx; overflow: hidden; background: #ffffff;"
+      @close="handleCloseTutorial"
+    >
+      <view class="fesco-popup">
+        <view class="fesco-popup__header">
+          <view class="fesco-popup__title">操作教程</view>
+          <view class="fesco-popup__subtitle">请根据视频步骤完成签约操作</view>
+        </view>
+
+        <view class="fesco-video-wrapper">
+          <video class="fesco-video" :src="FESCO_VIDEO_URL" controls @error="handleVideoError" />
+        </view>
+      </view>
+    </wd-popup>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { computed, ref } from 'vue'
+
+import { useUserStore } from '@/stores/modules/user'
+
+interface VideoErrorEvent {
+  detail: {
+    errMsg: string
+  }
+}
+
+const FESCO_STEP_IMAGE = 'https://yy-cloud-oss.oss-cn-beijing.aliyuncs.com/img/fesco-step.png'
+const FESCO_QRCODE_IMAGE = 'https://yy-cloud-oss.oss-cn-beijing.aliyuncs.com/img/fesco-qrcode.png'
+const FESCO_VIDEO_URL = 'https://yy-cloud-oss.oss-cn-beijing.aliyuncs.com/video/fesco-video.mp4'
+
+const qrcodeTips = [
+  '长按二维码并保存至相册',
+  '打开微信扫一扫',
+  '从相册选择已保存二维码',
+  '进入小程序完成签约',
+]
+
+const userStore = useUserStore()
+
+const adviseShow = ref(false)
+
+const deptName = computed(() => userStore.currentUserInfo?.deptName ?? '')
+
+const pageStyle = computed(() => {
+  return `overflow:${adviseShow.value ? 'hidden' : 'visible'};`
+})
+
+const handleOpenTutorial = (): void => {
+  adviseShow.value = true
+}
+
+const handleCloseTutorial = (): void => {
+  adviseShow.value = false
+}
+
+const handleVideoError = (event: VideoErrorEvent): void => {
+  console.log('视频错误信息:', event.detail.errMsg)
+}
+</script>
+
+<style lang="scss" scoped>
+.fesco-page-wrapper {
+  min-height: 100vh;
+  padding: 32rpx 32rpx 56rpx;
+  box-sizing: border-box;
+  background:
+    radial-gradient(circle at 16% 6%, rgba(1, 144, 255, 0.18) 0, rgba(1, 144, 255, 0) 260rpx),
+    linear-gradient(180deg, #eef7ff 0%, #f6f8fb 38%, #f4f7f9 100%);
+}
+
+.fesco-header {
+  display: flex;
+  align-items: flex-start;
+  justify-content: space-between;
+  gap: 24rpx;
+}
+
+.fesco-header__content {
+  flex: 1;
+  min-width: 0;
+}
+
+.fesco-header__title {
+  color: #17233d;
+  font-size: 44rpx;
+  font-weight: 700;
+  line-height: 62rpx;
+}
+
+.fesco-header__subtitle {
+  margin-top: 8rpx;
+  color: #6b7a8f;
+  font-size: 26rpx;
+  line-height: 38rpx;
+}
+
+.fesco-video-entry {
+  flex-shrink: 0;
+  display: flex;
+  align-items: center;
+  height: 64rpx;
+  padding: 0 22rpx;
+  box-sizing: border-box;
+  background: rgba(255, 255, 255, 0.92);
+  border: 2rpx solid rgba(1, 144, 255, 0.14);
+  border-radius: 999rpx;
+  box-shadow: 0 12rpx 32rpx rgba(1, 144, 255, 0.12);
+}
+
+.fesco-video-entry__text {
+  margin-left: 10rpx;
+  color: #0190ff;
+  font-size: 26rpx;
+  font-weight: 500;
+}
+
+.fesco-step-card {
+  display: flex;
+  justify-content: center;
+  margin-top: 36rpx;
+  padding: 28rpx 20rpx;
+  box-sizing: border-box;
+  background: rgba(255, 255, 255, 0.88);
+  border: 2rpx solid rgba(255, 255, 255, 0.9);
+  border-radius: 32rpx;
+  box-shadow: 0 18rpx 48rpx rgba(39, 83, 132, 0.08);
+}
+
+.fesco-step-img {
+  width: 646rpx;
+  height: 128rpx;
+}
+
+.fesco-qrcode-wrapper {
+  display: flex;
+  justify-content: center;
+  margin-top: 32rpx;
+}
+
+.fesco-qrcode-box {
+  position: relative;
+  width: 100%;
+  padding: 44rpx 36rpx 40rpx;
+  box-sizing: border-box;
+  overflow: hidden;
+  background: linear-gradient(180deg, #ffffff 0%, #f5faff 100%);
+  border: 2rpx solid rgba(255, 255, 255, 0.95);
+  border-radius: 40rpx;
+  box-shadow: 0 24rpx 60rpx rgba(22, 61, 108, 0.1);
+}
+
+.fesco-qrcode-box__decor {
+  position: absolute;
+  top: -120rpx;
+  right: -100rpx;
+  width: 260rpx;
+  height: 260rpx;
+  background: rgba(1, 144, 255, 0.1);
+  border-radius: 50%;
+}
+
+.fesco-qrcode-title {
+  position: relative;
+  z-index: 1;
+  color: #234a7f;
+  font-size: 38rpx;
+  font-weight: 700;
+  line-height: 54rpx;
+  text-align: center;
+}
+
+.fesco-qrcode-subtitle {
+  position: relative;
+  z-index: 1;
+  margin-top: 8rpx;
+  color: #7a8da6;
+  font-size: 24rpx;
+  line-height: 36rpx;
+  text-align: center;
+}
+
+.fesco-qrcode-img-wrapper {
+  position: relative;
+  z-index: 1;
+  width: 430rpx;
+  height: 430rpx;
+  margin: 34rpx auto 36rpx;
+  padding: 20rpx;
+  box-sizing: border-box;
+  background: #ffffff;
+  border: 2rpx solid rgba(1, 144, 255, 0.12);
+  border-radius: 32rpx;
+  box-shadow: 0 18rpx 46rpx rgba(1, 144, 255, 0.12);
+}
+
+.fesco-qrcode-img {
+  display: block;
+  width: 390rpx;
+  height: 390rpx;
+}
+
+.fesco-tips-wrapper {
+  position: relative;
+  z-index: 1;
+  display: flex;
+  flex-direction: column;
+  gap: 18rpx;
+}
+
+.fesco-tip-item {
+  display: flex;
+  align-items: center;
+  min-height: 64rpx;
+  padding: 0 24rpx;
+  box-sizing: border-box;
+  background: rgba(1, 144, 255, 0.06);
+  border-radius: 20rpx;
+}
+
+.fesco-tip-index {
+  flex-shrink: 0;
+  width: 36rpx;
+  height: 36rpx;
+  margin-right: 16rpx;
+  color: #ffffff;
+  font-size: 22rpx;
+  font-weight: 600;
+  line-height: 36rpx;
+  text-align: center;
+  background: linear-gradient(135deg, #28a7ff 0%, #0178ff 100%);
+  border-radius: 50%;
+}
+
+.fesco-tip-text {
+  color: #234a7f;
+  font-size: 28rpx;
+  font-weight: 500;
+  line-height: 40rpx;
+}
+
+.fesco-popup {
+  width: 686rpx;
+  overflow: hidden;
+  background: #ffffff;
+  border-radius: 32rpx;
+}
+
+.fesco-popup__header {
+  padding: 36rpx 36rpx 24rpx;
+  box-sizing: border-box;
+  background: linear-gradient(180deg, #f2f8ff 0%, #ffffff 100%);
+}
+
+.fesco-popup__title {
+  color: #17233d;
+  font-size: 34rpx;
+  font-weight: 700;
+  line-height: 48rpx;
+  text-align: center;
+}
+
+.fesco-popup__subtitle {
+  margin-top: 8rpx;
+  color: #7a8da6;
+  font-size: 24rpx;
+  line-height: 34rpx;
+  text-align: center;
+}
+
+.fesco-video-wrapper {
+  padding: 0 28rpx 32rpx;
+  box-sizing: border-box;
+}
+
+.fesco-video {
+  display: block;
+  width: 100%;
+  height: 386rpx;
+  overflow: hidden;
+  background-color: #000000;
+  border-radius: 24rpx;
+}
+</style>

+ 701 - 3
src/pages-auth/settlement-channel-sign/index.vue

@@ -1,7 +1,705 @@
 <template>
-  <view>签约结算渠道</view>
+  <view class="settlement-page">
+    <view class="channel-panel">
+      <view class="panel-title">认证渠道</view>
+
+      <radio-group @change="handleChannelChange">
+        <label
+          v-for="channel in channelList"
+          :key="channel.value"
+          class="channel-item"
+          :class="{
+            'channel-item--active': channel.selectable && selectedSubjectLocation === channel.value,
+            'channel-item--disabled': !channel.selectable,
+          }"
+          @click="handleSelectChannel(channel)"
+        >
+          <text class="channel-name">{{ channel.label }}</text>
+
+          <radio
+            v-if="channel.selectable"
+            class="channel-radio"
+            :value="channel.value"
+            :checked="selectedSubjectLocation === channel.value"
+            color="#3fa3f1"
+          />
+
+          <text v-else class="channel-status">
+            {{ channel.statusText }}
+          </text>
+        </label>
+      </radio-group>
+    </view>
+
+    <view v-if="showSubmit" class="submit-bar">
+      <button
+        class="submit-button"
+        :class="{ 'submit-button--disabled': !canSubmit }"
+        :disabled="!canSubmit"
+        hover-class="none"
+        @click="handleSubmit"
+      >
+        {{ isSubmitting ? '提交中...' : '提交' }}
+      </button>
+    </view>
+
+    <wd-popup
+      v-model="agreementPopupVisible"
+      position="center"
+      custom-style="width: 640rpx; border-radius: 24rpx; overflow: hidden;"
+      :close-on-click-modal="false"
+      @close="handleCloseAgreementPopup"
+    >
+      <view class="agreement-popup">
+        <view class="agreement-title">业务合作协议</view>
+
+        <scroll-view scroll-y class="agreement-scroll">
+          <view class="agreement-policy">
+            为开通共享经济业务,请阅读、勾选签署以下平台业务合作协议。
+            点击勾选协议,即表示您已阅读并同意接受该协议内容。若不同意,请勿勾选。
+          </view>
+        </scroll-view>
+
+        <view class="agreement-check-row">
+          <wd-checkbox v-model="agreementChecked" shape="square" checked-color="#3fa3f1" />
+
+          <view class="agreement-check-content">
+            <text>已阅读并同意</text>
+
+            <template v-for="(agreement, index) in agreementList" :key="agreement.templateUrl">
+              <text class="agreement-link" @click.stop="handlePreviewAgreementPdf(agreement)">
+                《{{ agreement.name }}》
+              </text>
+
+              <text v-if="index < agreementList.length - 1">、</text>
+            </template>
+          </view>
+        </view>
+
+        <view class="agreement-actions">
+          <button
+            class="agreement-action agreement-action--cancel"
+            hover-class="none"
+            @click="handleCloseAgreementPopup"
+          >
+            退出
+          </button>
+
+          <button
+            class="agreement-action agreement-action--confirm"
+            hover-class="none"
+            @click="handleConfirmAgreement"
+          >
+            同意
+          </button>
+        </view>
+      </view>
+    </wd-popup>
+  </view>
 </template>
 
-<script setup lang="ts"></script>
+<script setup lang="ts">
+import { computed, ref } from 'vue'
+
+import { onLoad } from '@dcloudio/uni-app'
+
+import {
+  batchChannelCertApi,
+  saveBaseInfoApi,
+  signAgreementApi,
+  signCertApi,
+  signRedirectApi,
+} from '@/services/modules/auth'
+import { getDictTypeApi } from '@/services/modules/common'
+import type { DictItem } from '@/services/modules/common/type'
+import type { CertItem } from '@/services/modules/login/userInfo'
+import { getSubjectLocationAgreementApi } from '@/services/modules/mine/common'
+import type { agreementInfosItem } from '@/services/modules/mine/common/type'
+
+import { useSettlementSignStore } from '@/stores/modules/settlementSign'
+import { useUserStore } from '@/stores/modules/user'
+
+import { previewAgreementPdf } from '@/utils/agreementPdfPreview'
+
+const settlementSignStore = useSettlementSignStore()
+interface RadioChangeEvent {
+  detail: {
+    value: string
+  }
+}
+
+interface ChannelItem {
+  value: string
+  label: string
+  selectable: boolean
+  statusText: string
+}
+
+interface AgreementPdfItem {
+  name: string
+  templateUrl: string
+}
+
+const SELECTABLE_CERT_STATUS = new Set(['UN_SIGN', 'FAIL_SIGN'])
+
+const userStore = useUserStore()
+
+const subjectLocationDictList = ref<DictItem[]>([])
+const selectedSubjectLocation = ref('')
+const isSubmitting = ref(false)
+
+const agreementList = ref<AgreementPdfItem[]>([])
+const agreementPopupVisible = ref(false)
+const agreementChecked = ref(false)
+
+const currentUserInfo = computed(() => userStore.currentUserInfo)
+
+const subjectLocationList = computed(() => {
+  return currentUserInfo.value?.subjectLocationList ?? []
+})
+
+const certList = computed(() => {
+  return currentUserInfo.value?.certList ?? []
+})
+
+const channelList = computed<ChannelItem[]>(() => {
+  return subjectLocationList.value.map((subjectLocation) => {
+    const certItem = findCertItem(subjectLocation)
+    const selectable = isSelectableChannel(certItem)
+
+    return {
+      value: subjectLocation,
+      label: getSubjectLocationName(subjectLocation),
+      selectable,
+      statusText: selectable ? '' : getCertStatusText(certItem),
+    }
+  })
+})
+
+const selectedChannel = computed(() => {
+  return channelList.value.find((channel) => {
+    return channel.value === selectedSubjectLocation.value
+  })
+})
+
+const showSubmit = computed(() => {
+  return channelList.value.some((channel) => channel.selectable)
+})
+
+const canSubmit = computed(() => {
+  return Boolean(selectedChannel.value?.selectable) && !isSubmitting.value
+})
+
+const showToast = (title: string): void => {
+  uni.showToast({
+    title,
+    icon: 'none',
+  })
+}
+
+const getSubjectLocationDict = async (): Promise<void> => {
+  try {
+    const res = await getDictTypeApi('subject_location?filterData=false')
+    subjectLocationDictList.value = res.data ?? []
+  } catch (error) {
+    console.log('getSubjectLocationDict error:', error)
+    subjectLocationDictList.value = []
+  }
+}
+
+const getSubjectLocationName = (value: string): string => {
+  const dictItem = subjectLocationDictList.value.find((item) => {
+    return String(item.value) === value
+  })
+
+  return dictItem?.label || value
+}
+
+const findCertItem = (subjectLocation: string): CertItem | undefined => {
+  return certList.value.find((item) => {
+    return item.subjectLocation === subjectLocation
+  })
+}
+
+const isSelectableChannel = (certItem?: CertItem): boolean => {
+  if (!certItem) return true
+
+  return SELECTABLE_CERT_STATUS.has(certItem.commonCertStatus ?? '')
+}
+
+const getCertStatusText = (certItem?: CertItem): string => {
+  return certItem?.commonCertStatusName || '已提交'
+}
+
+const handleSelectChannel = (channel: ChannelItem): void => {
+  if (!channel.selectable) return
+
+  selectedSubjectLocation.value = channel.value
+}
+
+const handleChannelChange = (event: RadioChangeEvent): void => {
+  selectedSubjectLocation.value = event.detail.value
+}
+
+const normalizeAgreementInfos = (list: agreementInfosItem[] = []): AgreementPdfItem[] => {
+  return list
+    .filter((item) => Boolean(item.templateUrl?.trim()))
+    .map((item) => {
+      return {
+        name: item.name?.trim() || '协议',
+        templateUrl: item.templateUrl.trim(),
+      }
+    })
+}
+
+const handleSubmit = async (): Promise<void> => {
+  if (!canSubmit.value) {
+    showToast('请选择认证渠道')
+    return
+  }
+
+  agreementList.value = []
+  isSubmitting.value = true
+
+  try {
+    const saved = await saveBaseInfo()
+
+    if (!saved) return
+
+    await signCert()
+  } catch (error) {
+    console.error('handleSubmit error:', error)
+    showToast('提交失败,请稍后重试')
+  } finally {
+    isSubmitting.value = false
+  }
+}
+
+const saveBaseInfo = async (): Promise<boolean> => {
+  const data = {
+    userId: currentUserInfo.value?.userId || '',
+    idCardNumber: currentUserInfo.value?.idCardNumber || '',
+    bankCardNumber: currentUserInfo.value?.bankCardNumber || '',
+    bankName: currentUserInfo.value?.bankName || '',
+    bankPhone: currentUserInfo.value?.bankPhone || '',
+    subjectLocation: selectedSubjectLocation.value,
+  }
+
+  const res = await saveBaseInfoApi(data)
+
+  if (res.code !== 0) {
+    showToast(res.msg || '保存信息失败')
+    return false
+  }
+
+  return true
+}
+
+const signCert = async (): Promise<void> => {
+  const data = {
+    userId: currentUserInfo.value?.userId || '',
+    subjectLocation: selectedSubjectLocation.value,
+  }
+
+  const res = await signCertApi(data)
+
+  if (res.code !== 0) {
+    showToast(res.msg || '签约失败')
+    return
+  }
+
+  await afterSignCert()
+}
+
+// 判断是否是有感签约
+const isExternal = computed(() => {
+  const externalValueArr = ['BOSS_KG_YJB', 'OSAIS']
+  const currentGigItem = subjectLocationDictList.value.find(
+    (item) => item.value === selectedSubjectLocation.value
+  )
+  return externalValueArr.includes(currentGigItem?.externalValue || '')
+})
+
+const afterSignCert = async (): Promise<void> => {
+  if (selectedSubjectLocation.value === 'FESCO') {
+    uni.navigateTo({
+      url: '/pages-auth/fesco/index',
+    })
+    return
+  }
+
+  const currentCert = findCertItem(selectedSubjectLocation.value)
+
+  const shouldLoadAgreement =
+    (!certList.value.length ||
+      !currentCert ||
+      !currentCert.agreementUrl ||
+      currentCert.commonCertStatus === 'FAIL_SIGN') &&
+    !isExternal.value
+
+  if (shouldLoadAgreement) {
+    await getSubjectLocationAgreement()
+    return
+  }
+
+  certFn()
+}
+
+const getSubjectLocationAgreement = async (): Promise<void> => {
+  try {
+    const res = await getSubjectLocationAgreementApi({
+      subjectLocation: selectedSubjectLocation.value,
+    })
+
+    agreementList.value = normalizeAgreementInfos(res.data?.agreementInfos)
+
+    showAgreementPopupIfNeeded()
+  } catch (error) {
+    console.error('getSubjectLocationAgreement error:', error)
+  }
+}
+
+const showAgreementPopupIfNeeded = (): void => {
+  agreementChecked.value = false
+  if (agreementList.value.length) {
+    agreementPopupVisible.value = true
+    return
+  }
+  certFn()
+}
+
+const handleCloseAgreementPopup = (): void => {
+  agreementChecked.value = false
+  agreementPopupVisible.value = false
+}
+
+interface SignatureSuccessPayload {
+  signatureUrl: string
+}
+
+const handleConfirmAgreement = () => {
+  if (!agreementChecked.value) {
+    showToast('请先勾选相关协议')
+    return
+  }
+
+  agreementChecked.value = false
+  agreementPopupVisible.value = false
+
+  // 有感签约下 直接去认证
+  if (isExternal.value) {
+    certFn()
+    return
+  }
+  uni.navigateTo({
+    url: `/pages-common/signature/index?type=SETTLEMENT_CHANNEL_SIGN`,
+    events: {
+      authSuccess: handleSignatureSuccess,
+    },
+  })
+}
+
+const handleSignatureSuccess = async (payload: SignatureSuccessPayload) => {
+  const signatureUrl = payload.signatureUrl?.trim()
+
+  if (!signatureUrl) {
+    showToast('签名地址为空')
+    return
+  }
+
+  const obj = {
+    userId: currentUserInfo.value?.userId || '',
+    subjectLocation: selectedSubjectLocation.value,
+    agreementUrl: signatureUrl,
+  }
+
+  const res = await signAgreementApi(obj)
+  if (res.code === 0) {
+    certFn()
+  }
+}
+
+const handlePreviewAgreementPdf = (agreement: AgreementPdfItem): void => {
+  void previewAgreementPdf({
+    url: agreement.templateUrl,
+  })
+}
+
+const certFn = async () => {
+  const subjectLocation = selectedSubjectLocation.value
+
+  if (!subjectLocation) {
+    showToast('请选择认证渠道')
+    return
+  }
+
+  const accountInfo = wx.getAccountInfoSync()
+  const appId = accountInfo.miniProgram.appId
+
+  const signRedirectUrl = `/pages-auth/settlement-channel-sign/index?subjectLocationByUrl=${encodeURIComponent(
+    subjectLocation
+  )}`
+
+  const data = {
+    userId: currentUserInfo.value?.userId || '',
+    subjectLocation,
+    token: currentUserInfo.value?.certToken || '',
+    signRedirectUrl,
+    wxMiniAppId: appId,
+  }
+
+  const res = await batchChannelCertApi(data)
+
+  if (res.code !== 0) {
+    showToast(res.msg || '签约失败')
+    return
+  }
+
+  const h5SignUrl = res.data?.h5SignUrl
+
+  if (h5SignUrl) {
+    const context = settlementSignStore.createContext(subjectLocation)
+
+    uni.navigateTo({
+      url: `/pages-auth/web-view/index?url=${encodeURIComponent(
+        h5SignUrl
+      )}&subjectLocation=${encodeURIComponent(
+        subjectLocation
+      )}&signTraceId=${encodeURIComponent(context.signTraceId)}`,
+    })
+
+    return
+  }
+
+  settlementSignStore.clearContext()
+
+  uni.showToast({
+    title: '签约成功',
+    icon: 'success',
+    duration: 2000,
+  })
+
+  await userStore.getUserInfoByCode()
+}
+
+const signRedirect = async (subjectLocation: string): Promise<boolean> => {
+  if (!subjectLocation) {
+    showToast('参数异常')
+    return false
+  }
+  try {
+    const res = await signRedirectApi({
+      subjectLocation,
+    })
+    if (res.code !== 0) {
+      showToast(res.msg || '签约失败')
+      return false
+    }
+
+    settlementSignStore.clearContext()
+    uni.showToast({
+      title: '签约成功',
+      icon: 'success',
+    })
+    await userStore.getUserInfoByCode()
+    return true
+  } catch (error) {
+    console.error('signRedirect error:', error)
+    showToast('网络异常,请稍后重试')
+    return false
+  }
+}
+
+onLoad(async (query) => {
+  await userStore.getUserInfoByCode()
+  const subjectLocation = query?.subjectLocationByUrl?.trim() || ''
+
+  if (subjectLocation) {
+    await signRedirect(subjectLocation)
+  }
+
+  getSubjectLocationDict()
+})
+</script>
+
+<style lang="scss" scoped>
+.settlement-page {
+  min-height: 100vh;
+  padding-bottom: 132rpx;
+  box-sizing: border-box;
+  background: #f5f6f8;
+}
+
+.channel-panel {
+  margin: 24rpx;
+  padding: 32rpx 28rpx 8rpx;
+  box-sizing: border-box;
+  border-radius: 20rpx;
+  background: #ffffff;
+}
+
+.panel-title {
+  margin-bottom: 20rpx;
+  font-size: 34rpx;
+  line-height: 48rpx;
+  font-weight: 600;
+  color: #1f2329;
+}
+
+.channel-item {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  min-height: 96rpx;
+  border-bottom: 1rpx solid #edf0f5;
+}
+
+.channel-item:last-child {
+  border-bottom: none;
+}
+
+.channel-item--active {
+  .channel-name {
+    color: #3fa3f1;
+    font-weight: 600;
+  }
+}
+
+.channel-item--disabled {
+  .channel-name {
+    color: #4e5969;
+  }
+}
+
+.channel-name {
+  flex: 1;
+  min-width: 0;
+  padding-right: 24rpx;
+  font-size: 30rpx;
+  line-height: 42rpx;
+  color: #333333;
+}
+
+.channel-radio {
+  flex-shrink: 0;
+  transform: scale(0.82);
+}
+
+.channel-status {
+  flex-shrink: 0;
+  max-width: 180rpx;
+  padding: 6rpx 18rpx;
+  box-sizing: border-box;
+  font-size: 24rpx;
+  line-height: 34rpx;
+  text-align: center;
+}
+
+.submit-bar {
+  position: fixed;
+  right: 0;
+  bottom: 0;
+  left: 0;
+  z-index: 1;
+  padding: 18rpx 32rpx calc(18rpx + env(safe-area-inset-bottom));
+  box-sizing: border-box;
+  background: #ffffff;
+  box-shadow: 0 -6rpx 20rpx rgba(0, 0, 0, 0.04);
+}
+
+.submit-button {
+  width: 100%;
+  height: 84rpx;
+  padding: 0;
+  border-radius: 16rpx;
+  font-size: 32rpx;
+  line-height: 84rpx;
+  font-weight: 500;
+  color: #ffffff;
+  background: #3fa3f1;
+
+  &::after {
+    border: none;
+  }
+}
+
+.submit-button--disabled {
+  color: #ffffff;
+  background: #c7d7ea;
+}
+
+.agreement-popup {
+  background: #ffffff;
+}
+
+.agreement-title {
+  padding: 32rpx 32rpx 20rpx;
+  font-size: 32rpx;
+  line-height: 44rpx;
+  font-weight: 600;
+  text-align: center;
+  color: #1f2329;
+}
+
+.agreement-scroll {
+  min-height: 200rpx;
+  padding: 0 32rpx;
+  box-sizing: border-box;
+}
+
+.agreement-policy {
+  font-size: 28rpx;
+  line-height: 44rpx;
+  color: #4e5969;
+}
+
+.agreement-check-row {
+  display: flex;
+  align-items: flex-start;
+  padding: 24rpx 32rpx 28rpx;
+  box-sizing: border-box;
+}
+
+.agreement-check-content {
+  flex: 1;
+  min-width: 0;
+  margin-left: 12rpx;
+  font-size: 24rpx;
+  line-height: 38rpx;
+  color: #4e5969;
+}
+
+.agreement-link {
+  color: #3fa3f1;
+}
+
+.agreement-actions {
+  display: flex;
+  border-top: 1rpx solid #edf0f5;
+}
+
+.agreement-action {
+  flex: 1;
+  height: 88rpx;
+  padding: 0;
+  margin: 0;
+  border-radius: 0;
+  font-size: 30rpx;
+  line-height: 88rpx;
+  background: #ffffff;
+
+  &::after {
+    border: none;
+  }
+}
+
+.agreement-action--cancel {
+  color: #86909c;
+  border-right: 1rpx solid #edf0f5;
+}
 
-<style lang="scss" scoped></style>
+.agreement-action--confirm {
+  color: #3fa3f1;
+  font-weight: 500;
+}
+</style>

+ 148 - 0
src/pages-auth/web-view/index.vue

@@ -0,0 +1,148 @@
+<template>
+  <view class="web-view-page">
+    <web-view v-if="url" :src="url" @message="handleMessage" />
+  </view>
+</template>
+
+<script setup lang="ts">
+import { ref } from 'vue'
+
+import { onLoad } from '@dcloudio/uni-app'
+
+import { useSettlementSignStore } from '@/stores/modules/settlementSign'
+
+interface WebViewPageQuery {
+  url?: string
+  subjectLocation?: string
+  signTraceId?: string
+}
+
+interface WebViewMessage {
+  subjectLocation?: string
+  signTraceId?: string
+  success?: boolean
+  status?: string
+  [key: string]: unknown
+}
+
+interface WebViewMessageEvent {
+  detail?: {
+    data?: unknown
+  }
+}
+
+const settlementSignStore = useSettlementSignStore()
+
+const url = ref('')
+const routeSubjectLocation = ref('')
+const routeSignTraceId = ref('')
+
+const showToast = (title: string) => {
+  uni.showToast({
+    title,
+    icon: 'none',
+  })
+}
+
+const safeDecodeURIComponent = (value: string) => {
+  try {
+    return decodeURIComponent(value)
+  } catch (error) {
+    console.error('decodeURIComponent error:', error)
+    return value
+  }
+}
+
+const normalizeMessageData = (event: WebViewMessageEvent): WebViewMessage => {
+  const rawData = event.detail?.data
+
+  const firstData = Array.isArray(rawData) ? rawData[0] : rawData
+
+  if (!firstData) return {}
+
+  if (typeof firstData === 'string') {
+    try {
+      return JSON.parse(firstData) as WebViewMessage
+    } catch {
+      return {
+        status: firstData,
+      }
+    }
+  }
+
+  if (typeof firstData === 'object') {
+    return firstData as WebViewMessage
+  }
+
+  return {}
+}
+
+const getStringValue = (value: unknown): string => {
+  return typeof value === 'string' ? value.trim() : ''
+}
+
+const resolveSubjectLocation = (message: WebViewMessage): string => {
+  const messageSubjectLocation = getStringValue(message.subjectLocation)
+  const contextSubjectLocation = settlementSignStore.pendingSubjectLocation
+
+  return messageSubjectLocation || routeSubjectLocation.value || contextSubjectLocation
+}
+
+const resolveSignTraceId = (message: WebViewMessage): string => {
+  const messageSignTraceId = getStringValue(message.signTraceId)
+  const contextSignTraceId = settlementSignStore.validContext?.signTraceId || ''
+
+  return messageSignTraceId || routeSignTraceId.value || contextSignTraceId
+}
+
+const redirectToSettlementSignPage = (subjectLocation: string, signTraceId: string) => {
+  if (!subjectLocation) {
+    showToast('参数异常')
+    return
+  }
+
+  const query = [`subjectLocationByUrl=${encodeURIComponent(subjectLocation)}`, `source=webMessage`]
+
+  if (signTraceId) {
+    query.push(`signTraceId=${encodeURIComponent(signTraceId)}`)
+  }
+
+  uni.redirectTo({
+    url: `/pages-auth/settlement-channel-sign/index?${query.join('&')}`,
+  })
+}
+
+const handleMessage = (event: WebViewMessageEvent) => {
+  const message = normalizeMessageData(event)
+
+  console.log('web-view message:', message)
+
+  const subjectLocation = resolveSubjectLocation(message)
+  const signTraceId = resolveSignTraceId(message)
+
+  redirectToSettlementSignPage(subjectLocation, signTraceId)
+}
+
+onLoad((query: WebViewPageQuery) => {
+  if (!query?.url) {
+    showToast('签约地址不存在')
+
+    uni.navigateBack({
+      delta: 1,
+    })
+
+    return
+  }
+
+  url.value = safeDecodeURIComponent(query.url)
+  routeSubjectLocation.value = query.subjectLocation || ''
+  routeSignTraceId.value = query.signTraceId || ''
+})
+</script>
+
+<style lang="scss" scoped>
+.web-view-page {
+  width: 100%;
+  height: 100vh;
+}
+</style>

+ 179 - 53
src/pages-common/signature/index.vue

@@ -3,7 +3,7 @@
 </template>
 
 <script setup lang="ts">
-import { ref } from 'vue'
+import { computed, getCurrentInstance, ref } from 'vue'
 
 import { onLoad } from '@dcloudio/uni-app'
 
@@ -14,75 +14,201 @@ import { useUserStore } from '@/stores/modules/user'
 
 import SignaturePad from './components/SignaturePad.vue'
 
-const type = ref('')
-onLoad((e) => {
-  type.value = e?.type || ''
-})
+type SignatureType = 'HONEST_AGREEMENT_V2' | 'SETTLEMENT_CHANNEL_SIGN' | ''
+
+interface UploadResponse {
+  code: number
+  msg?: string
+  data?: {
+    url?: string
+  }
+}
+
+interface SignatureSuccessPayload {
+  signatureUrl: string
+}
+
+interface EventChannelLike {
+  emit: (eventName: string, payload?: unknown) => void
+}
+
+interface PageProxyWithEventChannel {
+  getOpenerEventChannel?: () => EventChannelLike | undefined
+}
 
 const userStore = useUserStore()
-const handleSignatureDone = (filePath: string) => {
-  uni.showLoading({
-    title: '保存中',
+
+const pageInstance = getCurrentInstance()
+
+const type = ref<SignatureType>('')
+
+const accessToken = computed(() => userStore.access_token || '')
+
+const getOpenerEventChannel = () => {
+  const proxy = pageInstance?.proxy as PageProxyWithEventChannel | null
+
+  return proxy?.getOpenerEventChannel?.()
+}
+
+const showToast = (title: string) => {
+  uni.showToast({
+    title,
+    icon: 'none',
+    duration: 1500,
   })
+}
+
+const isSupportedSignatureType = () => {
+  return type.value === 'HONEST_AGREEMENT_V2' || type.value === 'SETTLEMENT_CHANNEL_SIGN'
+}
+
+const parseUploadResponse = (data: string): UploadResponse | undefined => {
+  try {
+    return JSON.parse(data) as UploadResponse
+  } catch (error) {
+    console.error('parseUploadResponse error:', error)
+    return undefined
+  }
+}
+
+const uploadSignatureFile = (filePath: string): Promise<string> => {
+  return new Promise((resolve, reject) => {
+    uni.uploadFile({
+      url: fileApi.fileUpload(),
+      filePath,
+      name: 'file',
+      header: {
+        Authorization: `Bearer ${accessToken.value}`,
+      },
+      success: (res) => {
+        if (res.statusCode !== 200 || !res.data) {
+          reject(new Error('上传失败'))
+          return
+        }
+
+        const uploadRes = parseUploadResponse(res.data)
+
+        if (!uploadRes) {
+          reject(new Error('上传结果异常'))
+          return
+        }
+
+        if (uploadRes.code !== 0) {
+          reject(new Error(uploadRes.msg || '上传失败'))
+          return
+        }
+
+        const signatureUrl = uploadRes.data?.url?.trim()
 
-  uni.uploadFile({
-    url: fileApi.fileUpload(),
-    filePath: filePath,
-    name: 'file',
-    header: {
-      Authorization: `Bearer ${userStore.access_token}`,
-    },
-    success(res) {
-      uni.hideLoading()
-      if (res.statusCode === 200 && res.data) {
-        const uploadRes = JSON.parse(res.data)
-        if (uploadRes.code === 0) {
-          if (type.value === 'HONEST_AGREEMENT_V2') {
-            saveSignature(uploadRes.data.url)
-            return
-          }
-        } else {
-          uni.showToast({
-            title: uploadRes.msg || '上传失败',
-            icon: 'none',
-            duration: 1500,
-          })
+        if (!signatureUrl) {
+          reject(new Error('签名地址为空'))
+          return
         }
-      }
-    },
-    fail() {
-      uni.hideLoading()
-      uni.showToast({
-        title: '上传失败',
-        icon: 'none',
-        duration: 1500,
-      })
-    },
+
+        resolve(signatureUrl)
+      },
+      fail: (error) => {
+        reject(error)
+      },
+    })
   })
 }
 
-// 签署协议
-const saveSignature = async (url: string) => {
-  const res = await signAgreementApi({
-    agreementType: type.value,
-    signatureUrl: url,
+const handleSignatureDone = async (filePath: string) => {
+  if (!isSupportedSignatureType()) {
+    showToast('签署类型异常')
+    return
+  }
+
+  uni.showLoading({
+    title: '保存中',
+    mask: true,
   })
-  if (res.code === 0 && res.data) {
-    uni.navigateBack()
+
+  try {
+    const signatureUrl = await uploadSignatureFile(filePath)
+
+    await handleUploadedSignature(signatureUrl)
+  } catch (error) {
+    console.error('handleSignatureDone error:', error)
+
+    showToast(error instanceof Error ? error.message : '上传失败')
+  } finally {
+    uni.hideLoading()
+  }
+}
+
+const handleUploadedSignature = async (signatureUrl: string) => {
+  if (type.value === 'HONEST_AGREEMENT_V2') {
+    await saveSignature(signatureUrl)
+    return
   }
+
+  if (type.value === 'SETTLEMENT_CHANNEL_SIGN') {
+    settleChannelSign(signatureUrl)
+    return
+  }
+
+  showToast('签署类型异常')
+}
+
+// 廉洁承诺书签署
+const saveSignature = async (signatureUrl: string) => {
+  try {
+    const res = await signAgreementApi({
+      agreementType: type.value,
+      signatureUrl,
+    })
+
+    if (res.code !== 0) {
+      showToast(res.msg || '签署失败')
+      return
+    }
+
+    uni.navigateBack({
+      delta: 1,
+    })
+  } catch (error) {
+    console.error('saveSignature error:', error)
+
+    showToast('签署失败')
+  }
+}
+
+// 结算渠道签署:只回传 signatureUrl
+const settleChannelSign = (signatureUrl: string) => {
+  const eventChannel = getOpenerEventChannel()
+
+  if (!eventChannel) {
+    console.error('getOpenerEventChannel failed')
+    showToast('页面通信通道不存在')
+    return
+  }
+
+  const payload: SignatureSuccessPayload = {
+    signatureUrl,
+  }
+
+  eventChannel.emit('authSuccess', payload)
+
+  uni.navigateBack({
+    delta: 1,
+  })
 }
 
 const handleClear = () => {
   console.log('签名已清空')
 }
 
-const handleError = (err: unknown) => {
-  console.error('SignaturePad error:', err)
-  uni.showToast({
-    icon: 'none',
-    title: '签名出错,请重试',
-  })
+const handleError = (error: unknown) => {
+  console.error('SignaturePad error:', error)
+
+  showToast('签名出错,请重试')
 }
+
+onLoad((query) => {
+  type.value = query?.type || ''
+})
 </script>
 
 <style lang="scss" scoped></style>

+ 35 - 78
src/pages-mine/agreement/index.vue

@@ -17,7 +17,7 @@
         :key="item.templateUrl"
         class="agreement-card"
         hover-class="agreement-card--active"
-        @click="openPdfFile(item)"
+        @click="handlePreviewPdf(item)"
       >
         <text class="agreement-card__title">《{{ item.name }}》</text>
         <text class="agreement-card__arrow">›</text>
@@ -32,15 +32,22 @@ import { computed, ref } from 'vue'
 import { onLoad } from '@dcloudio/uni-app'
 
 import { getSubjectLocationAgreementApi } from '@/services/modules/mine/common'
-import type { agreementInfosItem } from '@/services/modules/mine/type'
+import type { agreementInfosItem } from '@/services/modules/mine/common/type'
 
 import { useUserStore } from '@/stores/modules/user'
 
+import { previewAgreementPdf } from '@/utils/agreementPdfPreview'
+
 interface StaticAgreementItem {
   name: string
   path: string
 }
 
+interface AgreementPdfItem {
+  name: string
+  templateUrl: string
+}
+
 const AGREEMENT_PAGE_BASE = '/pages-mine/agreement'
 const CERT_SUCCESS_STATUS = 'CERT'
 
@@ -48,7 +55,7 @@ const userStore = useUserStore()
 
 const currentUserInfo = computed(() => userStore.currentUserInfo)
 
-const certSuccessList = ref<agreementInfosItem[]>([])
+const certSuccessList = ref<AgreementPdfItem[]>([])
 
 const showToast = (title: string): void => {
   uni.showToast({
@@ -58,21 +65,18 @@ const showToast = (title: string): void => {
 }
 
 const hasSignedAgreement = (agreementCode: string): boolean => {
-  const signedAgreement = currentUserInfo.value?.signedAgreement
+  const signedAgreement = currentUserInfo.value?.signedAgreement ?? []
 
-  if (Array.isArray(signedAgreement)) {
-    return signedAgreement.includes(agreementCode)
-  }
-
-  if (typeof signedAgreement === 'string') {
-    return signedAgreement.includes(agreementCode)
-  }
-
-  return false
+  return Array.isArray(signedAgreement) && signedAgreement.includes(agreementCode)
 }
 
-const showHonestAgreement = computed(() => hasSignedAgreement('HONEST_AGREEMENT'))
-const showHonestAgreementV2 = computed(() => hasSignedAgreement('HONEST_AGREEMENT_V2'))
+const showHonestAgreement = computed(() => {
+  return hasSignedAgreement('HONEST_AGREEMENT')
+})
+
+const showHonestAgreementV2 = computed(() => {
+  return hasSignedAgreement('HONEST_AGREEMENT_V2')
+})
 
 const staticAgreementList = computed<StaticAgreementItem[]>(() => {
   const list: StaticAgreementItem[] = [
@@ -111,24 +115,26 @@ const getCertifiedSubjectLocations = (): string[] => {
   const certList = currentUserInfo.value?.certList ?? []
 
   const subjectLocations = certList
-    .filter((item) => item?.commonCertStatus === CERT_SUCCESS_STATUS)
-    .map((item) => item?.subjectLocation?.trim())
+    .filter((item) => item.commonCertStatus === CERT_SUCCESS_STATUS)
+    .map((item) => item.subjectLocation?.trim())
     .filter((subjectLocation): subjectLocation is string => Boolean(subjectLocation))
 
   return Array.from(new Set(subjectLocations))
 }
 
-const normalizeAgreementInfos = (list: agreementInfosItem[] = []): agreementInfosItem[] => {
+const normalizeAgreementInfos = (list: agreementInfosItem[] = []): AgreementPdfItem[] => {
   return list
-    .filter((item) => Boolean(item?.templateUrl?.trim()))
-    .map((item) => ({
-      name: item.name?.trim() || '协议',
-      templateUrl: item.templateUrl.trim(),
-    }))
+    .filter((item) => Boolean(item.templateUrl?.trim()))
+    .map((item) => {
+      return {
+        name: item.name?.trim() || '协议',
+        templateUrl: item.templateUrl.trim(),
+      }
+    })
 }
 
-const dedupeAgreementInfos = (list: agreementInfosItem[]): agreementInfosItem[] => {
-  const map = new Map<string, agreementInfosItem>()
+const dedupeAgreementInfos = (list: AgreementPdfItem[]): AgreementPdfItem[] => {
+  const map = new Map<string, AgreementPdfItem>()
 
   list.forEach((item) => {
     if (!map.has(item.templateUrl)) {
@@ -165,7 +171,7 @@ const getCertSuccessList = async (): Promise<void> => {
           return normalizeAgreementInfos(res.data?.agreementInfos)
         } catch (error) {
           hasError = true
-          console.log('getSubjectLocationAgreementApi error', error)
+          console.log('getSubjectLocationAgreementApi error:', error)
           return []
         }
       })
@@ -187,61 +193,12 @@ const navigateToAgreementPage = (path: string): void => {
   })
 }
 
-const downloadFile = (url: string): Promise<string> => {
-  return new Promise((resolve, reject) => {
-    uni.downloadFile({
-      url,
-      success: (res) => {
-        if (res.statusCode === 200 && res.tempFilePath) {
-          resolve(res.tempFilePath)
-          return
-        }
-
-        reject(new Error('download file failed'))
-      },
-      fail: reject,
-    })
+const handlePreviewPdf = (item: AgreementPdfItem): void => {
+  void previewAgreementPdf({
+    url: item.templateUrl,
   })
 }
 
-const openDocument = (filePath: string): Promise<void> => {
-  return new Promise((resolve, reject) => {
-    uni.openDocument({
-      filePath,
-      fileType: 'pdf',
-      showMenu: true,
-      success: () => {
-        resolve()
-      },
-      fail: reject,
-    })
-  })
-}
-
-const openPdfFile = async (item: agreementInfosItem): Promise<void> => {
-  const fileUrl = item.templateUrl?.trim()
-
-  if (!fileUrl) {
-    showToast('文件地址不存在')
-    return
-  }
-
-  uni.showLoading({
-    title: '文件加载中',
-    mask: true,
-  })
-
-  try {
-    const filePath = await downloadFile(fileUrl)
-    await openDocument(filePath)
-  } catch (error) {
-    console.log('openPdfFile error', error)
-    showToast('文件打开失败')
-  } finally {
-    uni.hideLoading()
-  }
-}
-
 onLoad(() => {
   void getCertSuccessList()
 })

+ 14 - 0
src/pages.json

@@ -240,6 +240,20 @@
             "navigationBarTitleText": "签约结算渠道",
             "navigationStyle": "default"
           }
+        },
+        {
+          "path": "fesco/index",
+          "style": {
+            "navigationBarTitleText": "fesco认证流程",
+            "navigationStyle": "default"
+          }
+        },
+        {
+          "path": "web-view/index",
+          "style": {
+            "navigationBarTitleText": "认证",
+            "navigationStyle": "default"
+          }
         }
       ]
     }

+ 36 - 1
src/services/modules/auth/index.ts

@@ -1,5 +1,8 @@
 import http from '../../index'
 import type {
+  BatchChannelCertRequest,
+  BatchChannelCertResponse,
+  BindBankInfoRequest,
   GetAuthInfoResponse,
   GigCertOcrRequest,
   GigCertOcrResponse,
@@ -9,6 +12,10 @@ import type {
   GigCertPersonVerifyMobile3Response,
   PersonCheckFaceRequest,
   PersonVerifyBank4Request,
+  SaveBaseInfoRequest,
+  SignAgreementRequest,
+  SignCertRequest,
+  SignRedirectRequest,
 } from './type'
 
 export const getAuthInfoApi = () => {
@@ -39,6 +46,34 @@ export const personVerifyBank4Api = (data: PersonVerifyBank4Request) => {
   return http.post(`/gig/cert/person-verify-bank4`, data)
 }
 
-export const bindBankInfoApi = (data: PersonVerifyBank4Request) => {
+export const bindBankInfoApi = (data: BindBankInfoRequest) => {
   return http.post(`/admin/user-sign-cert/bind-bank-info`, data)
 }
+
+export const saveBaseInfoApi = (data: SaveBaseInfoRequest) => {
+  return http.post(`/admin/user-sign-cert/save-base-info`, data)
+}
+export const signCertApi = (data: SignCertRequest) => {
+  return http.post(`/admin/user-sign-cert/sign`, data)
+}
+
+export const batchChannelCertApi = (data: BatchChannelCertRequest) => {
+  return http.post<BatchChannelCertResponse>(`/admin/user-sign-cert/batch-channel-cert`, data, {
+    loading: true,
+    loadingText: '签约中...',
+  })
+}
+
+export const signAgreementApi = (data: SignAgreementRequest) => {
+  return http.post(`/admin/user-sign-cert/sign-agreement`, data, {
+    loading: true,
+    loadingText: '提交中...',
+  })
+}
+
+export const signRedirectApi = (data: SignRedirectRequest) => {
+  return http.get(`/admin/user-sign-cert/sign/redirect`, data, {
+    loading: true,
+    loadingText: '签约中...',
+  })
+}

+ 33 - 0
src/services/modules/auth/type.d.ts

@@ -112,3 +112,36 @@ export interface BindBankInfoRequest {
   bankCardNumber: string
   bankPhone: string
 }
+export interface SaveBaseInfoRequest {
+  userId: string
+  idCardNumber: string
+  bankCardNumber: string
+  bankName: string
+  bankPhone: string
+  subjectLocation: string
+}
+export interface SignCertRequest {
+  userId: string
+  subjectLocation: string
+}
+export interface BatchChannelCertRequest {
+  userId: string
+  subjectLocation: string
+  token: string
+  signRedirectUrl: string
+  wxMiniAppId: string
+}
+export interface BatchChannelCertResponse {
+  commonCertStatus: string
+  errMsg: string
+  h5SignUrl: string
+}
+
+export interface SignAgreementRequest {
+  userId: string
+  agreementUrl: string
+  subjectLocation: string
+}
+export interface SignRedirectRequest {
+  subjectLocation: string
+}

+ 0 - 20
src/services/modules/login/userInfo.d.ts

@@ -78,43 +78,23 @@ export interface CertItem {
   id: number
   userId: number
   deptId: number
-
   gigType: string
   gigChannel: string
   subjectLocation: string
-
   callbackStatus: string | null
-
   certStatus: string | null
   certStatusDisplay: string | null
   certRemark: string | null
-
-  /**
-   * 通用认证状态
-   * 示例:
-   * UN_SIGN / CERT
-   */
   commonCertStatus: string | null
-
-  /**
-   * 通用认证状态名称
-   * 示例:
-   * 未签约
-   */
   commonCertStatusName: string | null
-
   userType: string | null
-
   bankPhone: string | null
   bankCardNumber: string | null
-
   idCardFrontUrl: string | null
   idCardBackUrl: string | null
   certVideo1Url: string | null
   certVideo2Url: string | null
-
   agreementUrl: string | null
-
   createTime: string
   updateTime: string
   createUser: number

+ 1 - 1
src/services/request/config.ts

@@ -2,7 +2,7 @@
 
 export const BASE_API = import.meta.env.VITE_BASE_API
 export const MODE = import.meta.env.MODE
-export const TIMEOUT = 10000
+export const TIMEOUT = 30000
 
 // 业务成功码(仅用于校验,不拆包)
 export const SUCCESS_CODE_LIST = [0, '0', 200, '200', 2000, '2000', '0000']

+ 1 - 0
src/stores/index.ts

@@ -13,4 +13,5 @@ export function setupStore(app: App) {
   app.use(pinia)
 }
 
+export * from './modules/settlementSign'
 export * from './modules/user'

+ 65 - 0
src/stores/modules/settlementSign.ts

@@ -0,0 +1,65 @@
+import { defineStore } from 'pinia'
+
+import { uniStorage } from '@/utils/uniStorage'
+
+export interface SettlementSignContext {
+  signTraceId: string
+  subjectLocation: string
+  createdAt: number
+}
+
+interface SettlementSignState {
+  context?: SettlementSignContext
+}
+
+const SIGN_CONTEXT_EXPIRE_TIME = 30 * 60 * 1000
+
+const createSignTraceId = (subjectLocation: string): string => {
+  return `${Date.now()}_${subjectLocation}`
+}
+
+export const useSettlementSignStore = defineStore('settlementSign', {
+  state: (): SettlementSignState => ({
+    context: undefined,
+  }),
+
+  getters: {
+    validContext(state): SettlementSignContext | undefined {
+      if (!state.context?.subjectLocation) return undefined
+
+      const isExpired = Date.now() - state.context.createdAt > SIGN_CONTEXT_EXPIRE_TIME
+
+      if (isExpired) return undefined
+
+      return state.context
+    },
+
+    pendingSubjectLocation(): string {
+      return this.validContext?.subjectLocation || ''
+    },
+  },
+
+  actions: {
+    createContext(subjectLocation: string): SettlementSignContext {
+      const context: SettlementSignContext = {
+        signTraceId: createSignTraceId(subjectLocation),
+        subjectLocation,
+        createdAt: Date.now(),
+      }
+
+      this.context = context
+
+      return context
+    },
+
+    clearContext(): void {
+      this.context = undefined
+    },
+  },
+
+  persist: {
+    key: 'settlement-sign-store',
+    storage: uniStorage,
+    pick: ['context'],
+  },
+})

+ 77 - 0
src/utils/agreementPdfPreview.ts

@@ -0,0 +1,77 @@
+export interface PreviewAgreementPdfOptions {
+  url?: string | null
+  loadingTitle?: string
+  emptyTitle?: string
+  errorTitle?: string
+  showMenu?: boolean
+}
+
+const showToast = (title: string): void => {
+  uni.showToast({
+    title,
+    icon: 'none',
+  })
+}
+
+const downloadPdfFileByWx = (url: string): Promise<string> => {
+  return new Promise((resolve, reject) => {
+    wx.downloadFile({
+      url,
+      success: (res) => {
+        if (res.statusCode === 200 && res.tempFilePath) {
+          resolve(res.tempFilePath)
+          return
+        }
+
+        reject(new Error(`download pdf failed, statusCode: ${res.statusCode}`))
+      },
+      fail: reject,
+    })
+  })
+}
+
+const openPdfDocumentByWx = (filePath: string, showMenu: boolean): Promise<void> => {
+  return new Promise((resolve, reject) => {
+    wx.openDocument({
+      filePath,
+      fileType: 'pdf',
+      showMenu,
+      success: () => {
+        resolve()
+      },
+      fail: reject,
+    })
+  })
+}
+
+export const previewAgreementPdf = async (
+  options: PreviewAgreementPdfOptions
+): Promise<boolean> => {
+  const fileUrl = options.url?.trim()
+
+  if (!fileUrl) {
+    showToast(options.emptyTitle || '文件地址不存在')
+    return false
+  }
+
+  uni.showLoading({
+    title: options.loadingTitle || '文件加载中',
+    mask: true,
+  })
+
+  try {
+    const filePath = await downloadPdfFileByWx(fileUrl)
+
+    await openPdfDocumentByWx(filePath, options.showMenu ?? true)
+
+    return true
+  } catch (error) {
+    console.error('previewAgreementPdf error:', error)
+
+    showToast(options.errorTitle || '文件打开失败')
+
+    return false
+  } finally {
+    uni.hideLoading()
+  }
+}