Переглянути джерело

完成个人名片模块重构

yuanmingze 2 місяців тому
батько
коміт
a950ae7d79

+ 119 - 117
src/pages/personal-card/components/ProfessionalSkillCard.vue

@@ -8,12 +8,13 @@
     <view class="card-content">
       <view class="info-row">
         <text class="row-label">医药代表资质备案号</text>
-        <input
+        <wd-input
           v-model="model.qualificationRecordNumber"
           class="row-input"
           type="text"
           placeholder="请输入备案号"
-          placeholder-class="input-placeholder"
+          align-right
+          clearable
         />
       </view>
 
@@ -21,17 +22,26 @@
         <text class="section-label">资质证书</text>
 
         <view class="upload-wrapper">
-          <view v-if="certificateUrl" class="image-box" @click="previewCertificate">
-            <image class="certificate-image" :src="certificateUrl" mode="aspectFill" />
-
-            <view class="delete-btn" @click.stop="removeCertificate">
+          <view
+            v-for="(url, index) in certificateList"
+            :key="`${url}-${index}`"
+            class="image-box"
+            @click="previewCertificate(index)"
+          >
+            <image class="certificate-image" :src="toFullUrl(url)" mode="aspectFill" />
+
+            <view class="delete-btn" @click.stop="removeCertificate(index)">
               <text class="delete-text">×</text>
             </view>
           </view>
 
-          <view v-else class="upload-box" @click="chooseCertificate">
+          <view
+            v-if="certificateList.length < maxCount"
+            class="upload-box"
+            @click="chooseCertificate"
+          >
             <text class="upload-plus">+</text>
-            <text class="upload-text">选择图片</text>
+            <text class="upload-text">{{ uploading ? '上传中' : '选择图片' }}</text>
           </view>
         </view>
       </view>
@@ -44,31 +54,53 @@ import { computed, ref } from 'vue'
 
 import type { PersonalBusinessCardResponse } from '@/services/modules/minePage/personalCard/type'
 
+import { useUserStore } from '@/stores/modules/user'
+
+interface UploadResponse {
+  code: number
+  success: boolean
+  msg: string | null
+  data: {
+    url: string
+  } | null
+}
+
 interface ProfessionalSkillFields {
-  qualificationRecordNumber?: string
-  qualificationsUrl?: string
+  qualificationRecordNumber?: string | null
+  qualificationsUrl?: string[] | null
 }
 
-type ProfessionalSkillModel = PersonalBusinessCardResponse & ProfessionalSkillFields
+type Model = PersonalBusinessCardResponse & ProfessionalSkillFields
 
 const props = withDefaults(
   defineProps<{
     title?: string
-    uploadApi?: (filePath: string) => Promise<string>
+    maxCount?: number
   }>(),
   {
     title: '专业技能',
-    uploadApi: undefined,
+    maxCount: 9,
   }
 )
 
-const model = defineModel<ProfessionalSkillModel>({
-  required: true,
-})
+const model = defineModel<Model>({ required: true })
 
+const userStore = useUserStore()
 const uploading = ref(false)
+const baseUrl = (import.meta.env.VITE_BASE_API as string).replace(/\/$/, '')
 
-const certificateUrl = computed(() => model.value.qualificationsUrl ?? '')
+const certificateList = computed<string[]>({
+  get: () => model.value.qualificationsUrl ?? [],
+  set: (value) => {
+    model.value.qualificationsUrl = value
+  },
+})
+
+function toFullUrl(url: string) {
+  if (!url) return ''
+  if (/^(https?:|wxfile:|blob:|data:)/.test(url)) return url
+  return `${baseUrl}${url.startsWith('/') ? url : `/${url}`}`
+}
 
 async function chooseCertificate() {
   if (uploading.value) return
@@ -77,26 +109,17 @@ async function chooseCertificate() {
     const filePath = await chooseImage()
 
     uploading.value = true
-    uni.showLoading({
-      title: '上传中',
-      mask: true,
-    })
-
-    const url = props.uploadApi ? await props.uploadApi(filePath) : filePath
+    uni.showLoading({ title: '上传中', mask: true })
 
-    model.value.qualificationsUrl = url
+    const url = await upload(filePath)
+    certificateList.value = [...certificateList.value, url]
 
-    uni.showToast({
-      title: '上传成功',
-      icon: 'success',
-    })
+    uni.showToast({ title: '上传成功', icon: 'success' })
   } catch (error) {
-    if (error instanceof Error && error.message === 'choose image canceled') return
+    if (error instanceof Error && error.message === 'cancel') return
 
-    uni.showToast({
-      title: '上传失败',
-      icon: 'none',
-    })
+    console.error('[ProfessionalSkillCard] upload failed:', error)
+    uni.showToast({ title: '上传失败', icon: 'none' })
   } finally {
     uploading.value = false
     uni.hideLoading()
@@ -109,74 +132,73 @@ function chooseImage(): Promise<string> {
       count: 1,
       sizeType: ['compressed'],
       sourceType: ['album', 'camera'],
-      success(res) {
-        const filePath = res.tempFilePaths[0]
-
-        if (!filePath) {
-          reject(new Error('empty file path'))
-          return
-        }
+      success: (res) => {
+        const filePath = Array.isArray(res.tempFilePaths) ? res.tempFilePaths[0] : res.tempFilePaths
+        filePath ? resolve(filePath) : reject(new Error('empty file path'))
+      },
+      fail: () => reject(new Error('cancel')),
+    })
+  })
+}
 
-        resolve(filePath)
+function upload(filePath: string): Promise<string> {
+  return new Promise((resolve, reject) => {
+    uni.uploadFile({
+      url: `${baseUrl}/admin/api/file/upload/mobile`,
+      filePath,
+      name: 'file',
+      header: {
+        Authorization: `Bearer ${userStore.access_token}`,
       },
-      fail() {
-        reject(new Error('choose image canceled'))
+      success: (res) => {
+        try {
+          const parsed = JSON.parse(res.data) as UploadResponse
+
+          if (
+            res.statusCode < 200 ||
+            res.statusCode >= 300 ||
+            !parsed.success ||
+            parsed.code !== 0 ||
+            !parsed.data?.url
+          ) {
+            reject(new Error(parsed.msg || 'upload failed'))
+            return
+          }
+
+          resolve(parsed.data.url)
+        } catch {
+          reject(new Error('parse upload response failed'))
+        }
       },
+      fail: reject,
     })
   })
 }
 
-function previewCertificate() {
-  if (!certificateUrl.value) return
+function previewCertificate(index: number) {
+  const urls = certificateList.value.map(toFullUrl)
 
   uni.previewImage({
-    urls: [certificateUrl.value],
-    current: certificateUrl.value,
+    urls,
+    current: urls[index],
   })
 }
 
-function removeCertificate() {
-  model.value.qualificationsUrl = ''
+function removeCertificate(index: number) {
+  certificateList.value = certificateList.value.filter((_, i) => i !== index)
 }
 </script>
 
 <style lang="scss" scoped>
 .professional-skill-card {
-  margin-top: 28rpx;
-  position: relative;
-  margin-bottom: 28rpx;
-  padding: 30rpx 26rpx 30rpx;
-  box-sizing: border-box;
-  overflow: hidden;
+  margin: 28rpx 0;
+  padding: 30rpx 26rpx;
   border-radius: 30rpx;
-  background: #ffffff;
+  background: #fff;
   box-shadow: 0 18rpx 48rpx rgba(22, 38, 67, 0.1);
-
-  &::before {
-    position: absolute;
-    top: 0;
-    left: 28rpx;
-    right: 28rpx;
-    height: 1rpx;
-    content: '';
-    background: rgba(255, 255, 255, 0.9);
-  }
-
-  &::after {
-    position: absolute;
-    top: -96rpx;
-    right: -78rpx;
-    width: 260rpx;
-    height: 260rpx;
-    content: '';
-    border-radius: 50%;
-    background: linear-gradient(135deg, rgba(47, 132, 255, 0.18) 0%, rgba(47, 132, 255, 0.08) 100%);
-  }
 }
 
 .card-title {
-  position: relative;
-  z-index: 1;
   display: flex;
   align-items: center;
   margin-bottom: 18rpx;
@@ -187,86 +209,73 @@ function removeCertificate() {
   height: 34rpx;
   margin-right: 14rpx;
   border-radius: 999rpx;
-  background: linear-gradient(180deg, #2f84ff 0%, #57a3ff 100%);
+  background: linear-gradient(180deg, #2f84ff, #57a3ff);
 }
 
 .title-text {
   font-size: 34rpx;
   font-weight: 700;
-  line-height: 48rpx;
   color: #1f2a44;
 }
 
 .card-content {
-  position: relative;
-  z-index: 1;
   overflow: hidden;
   border-radius: 22rpx;
-  background: #ffffff;
-  box-shadow:
-    0 18rpx 44rpx rgba(30, 64, 120, 0.08),
-    0 6rpx 18rpx rgba(30, 64, 120, 0.04);
+  background: #fff;
 }
 
 .info-row {
   display: flex;
   align-items: center;
-  min-height: 88rpx;
+  height: 88rpx;
   padding: 0 24rpx;
+  border-bottom: 1rpx solid rgba(0, 0, 0, 0.06);
   box-sizing: border-box;
-  border-bottom: 1rpx solid rgba(31, 63, 120, 0.08);
 }
 
 .row-label {
   flex-shrink: 0;
   font-size: 30rpx;
-  font-weight: 400;
-  line-height: 42rpx;
   color: #2f3545;
 }
 
 .row-input {
   flex: 1;
   min-width: 0;
-  height: 88rpx;
   margin-left: 24rpx;
   font-size: 30rpx;
-  font-weight: 500;
-  line-height: 88rpx;
-  color: #2f3545;
   text-align: right;
 }
 
-.input-placeholder {
-  color: #b3bdcc;
-}
-
 .certificate-section {
   padding: 22rpx 24rpx 28rpx;
-  box-sizing: border-box;
 }
 
 .section-label {
   display: block;
   margin-bottom: 24rpx;
   font-size: 30rpx;
-  font-weight: 400;
-  line-height: 42rpx;
   color: #2f3545;
 }
 
 .upload-wrapper {
   display: flex;
-  align-items: center;
+  flex-wrap: wrap;
+  gap: 18rpx;
 }
 
-.upload-box,
-.image-box {
+.image-box,
+.upload-box {
   position: relative;
   width: 220rpx;
   height: 220rpx;
-  overflow: hidden;
   border-radius: 16rpx;
+  overflow: hidden;
+}
+
+.certificate-image {
+  width: 100%;
+  height: 100%;
 }
 
 .upload-box {
@@ -274,33 +283,26 @@ function removeCertificate() {
   flex-direction: column;
   align-items: center;
   justify-content: center;
-  background: #f3f4f6;
+  background-color: #f3f4f6;
 }
 
 .upload-plus {
-  height: 64rpx;
   font-size: 64rpx;
-  font-weight: 300;
   line-height: 64rpx;
   color: #60656f;
 }
 
 .upload-text {
-  margin-top: 24rpx;
+  margin-top: 20rpx;
   font-size: 28rpx;
-  line-height: 40rpx;
   color: #60656f;
 }
 
-.certificate-image {
-  width: 100%;
-  height: 100%;
-}
-
 .delete-btn {
   position: absolute;
   top: 8rpx;
   right: 8rpx;
+  z-index: 2;
   display: flex;
   align-items: center;
   justify-content: center;
@@ -313,6 +315,6 @@ function removeCertificate() {
 .delete-text {
   font-size: 34rpx;
   line-height: 34rpx;
-  color: #ffffff;
+  color: #fff;
 }
 </style>

+ 6 - 6
src/pages/personal-card/components/UserInfoCard.vue

@@ -8,7 +8,7 @@
     <view class="card-content">
       <wd-form ref="formRef" :model="model" :title-width="100">
         <wd-form-item title="姓名" prop="realname">
-          <wd-input v-model="model.realname" type="text" align-right />
+          <wd-input v-model="model.realname" type="text" :readonly="disabled" align-right />
         </wd-form-item>
 
         <wd-form-item title="性别" prop="gender">
@@ -24,15 +24,15 @@
         </wd-form-item>
 
         <wd-form-item title="身份证号" prop="idCardNumber">
-          <wd-input v-model="model.idCardNumber" type="text" align-right />
+          <wd-input v-model="model.idCardNumber" type="text" align-right :readonly="disabled" />
         </wd-form-item>
 
         <wd-form-item title="手机号" prop="username">
-          <wd-input v-model="model.username" type="text" align-right />
+          <wd-input v-model="model.username" type="text" readonly align-right />
         </wd-form-item>
 
         <wd-form-item title="年龄" prop="age">
-          <wd-input v-model="model.age" type="text" align-right />
+          <wd-input v-model="model.age" type="text" readonly align-right />
         </wd-form-item>
 
         <wd-form-item title="学历" prop="degree">
@@ -69,6 +69,7 @@ interface PickerConfirmEvent {
 const props = withDefaults(
   defineProps<{
     title?: string
+    disabled: false
     genderColumns: DictItem[]
     degreeColumns: DictItem[]
   }>(),
@@ -81,8 +82,6 @@ const model = defineModel<PersonalBusinessCardResponse>({
   required: true,
 })
 
-const formRef = ref()
-
 const pickerType = ref<PickerType>('gender')
 const pickerShow = ref(false)
 const pickerColumns = ref<DictItem[]>([])
@@ -112,6 +111,7 @@ const degreeLabel = computed<string>({
 })
 
 function showPicker(type: PickerType) {
+  if (props.disabled) return
   pickerType.value = type
   pickerColumns.value = type === 'gender' ? props.genderColumns : props.degreeColumns
   pickerShow.value = true

+ 63 - 3
src/pages/personal-card/index.vue

@@ -3,6 +3,7 @@
     <UserInfoCard
       v-if="personalBusinessCardInfo"
       v-model="personalBusinessCardInfo"
+      :disabled="disabled"
       :gender-columns="genderColumns"
       :degree-columns="degreeColumns"
     />
@@ -30,8 +31,14 @@ import { onLoad } from '@dcloudio/uni-app'
 
 import { getDictTypeApi } from '@/services/modules/common'
 import type { DictItem } from '@/services/modules/common/type'
-import { getPersonalBusinessCardApi } from '@/services/modules/minePage/personalCard/index'
-import type { PersonalBusinessCardResponse } from '@/services/modules/minePage/personalCard/type'
+import {
+  getPersonalBusinessCardApi,
+  savePersonalBusinessCardApi,
+} from '@/services/modules/minePage/personalCard/index'
+import type {
+  PersonalBusinessCardResponse,
+  SavePersonalBusinessCardRequest,
+} from '@/services/modules/minePage/personalCard/type'
 
 import { useUserStore } from '@/stores/modules/user'
 
@@ -52,7 +59,10 @@ onLoad(() => {
   initPage()
 })
 
+const disabled = ref(false)
+
 async function initPage() {
+  disabled.value = currentUserInfo.value?.faceAuthStatus ?? false
   await Promise.all([getDict(), getPersonalBusinessCardInfo()])
 }
 
@@ -81,7 +91,57 @@ async function uploadCertificate(filePath: string): Promise<string> {
 
 const saving = ref(false)
 
-const handleSave = () => {}
+function buildSavePayload(): SavePersonalBusinessCardRequest | null {
+  const info = personalBusinessCardInfo.value
+
+  if (!info) return null
+
+  return {
+    degree: info.degree ?? null,
+    gender: info.gender ?? null,
+    idCardNumber: info.idCardNumber ?? null,
+    qualificationRecordNumber: info.qualificationRecordNumber ?? null,
+    qualificationsUrl: Array.isArray(info.qualificationsUrl) ? info.qualificationsUrl : [],
+    realname: info.realname ?? null,
+    userId: info.userId,
+  }
+}
+
+const handleSave = async () => {
+  if (saving.value) return
+  const payload = buildSavePayload()
+  if (!payload) {
+    uni.showToast({
+      title: '数据异常,请重新进入页面',
+      icon: 'none',
+    })
+    return
+  }
+  try {
+    saving.value = true
+    const res = await savePersonalBusinessCardApi(payload)
+    if (res.code !== 0) {
+      uni.showToast({
+        title: res.msg || '提交失败',
+        icon: 'none',
+      })
+      return
+    }
+    uni.showToast({
+      title: '提交成功',
+      icon: 'success',
+    })
+  } catch (error) {
+    console.error('[PersonalBusinessCard] save failed:', error)
+
+    uni.showToast({
+      title: '提交失败,请稍后重试',
+      icon: 'none',
+    })
+  } finally {
+    saving.value = false
+  }
+}
 </script>
 
 <style lang="scss" scoped>

+ 6 - 3
src/services/modules/minePage/personalCard/index.ts

@@ -1,10 +1,13 @@
 import http from '../../../index'
-import type { PersonalBusinessCardResponse } from './type'
+import type { PersonalBusinessCardResponse, SavePersonalBusinessCardRequest } from './type'
 
 export const getPersonalBusinessCardApi = (id: string) => {
   return http.get<PersonalBusinessCardResponse>(`/admin/api/get-personal-business-card/${id}`)
 }
 
-export const submitUserInfoApi = (data: any) => {
-  return http.post(`/admin/api/save-personal-business-card`, data)
+export const savePersonalBusinessCardApi = (data: SavePersonalBusinessCardRequest) => {
+  return http.post(`/admin/api/save-personal-business-card`, data, {
+    loading: true,
+    loadingText: '保存中...',
+  })
 }

+ 10 - 0
src/services/modules/minePage/personalCard/type.d.ts

@@ -15,3 +15,13 @@ export interface PersonalBusinessCardResponse {
   userId: number
   username: string
 }
+
+export interface SavePersonalBusinessCardRequest {
+  degree: string | undefined
+  gender: string | undefined
+  idCardNumber: string | undefined
+  qualificationRecordNumber: string | undefined
+  qualificationsUrl: string[] | undefined
+  realname: string | undefined
+  userId: number | undefined
+}