Kaynağa Gözat

完成个人名片样式层级的重构

yuanmingze 2 ay önce
ebeveyn
işleme
981684c404

+ 4 - 1
components.d.ts

@@ -15,10 +15,13 @@ declare module 'vue' {
     WdDatetimePicker: typeof import('@wot-ui/ui/components/wd-datetime-picker/wd-datetime-picker.vue')['default']
     WdDialog: typeof import('@wot-ui/ui/components/wd-dialog/wd-dialog.vue')['default']
     WdEmpty: typeof import('@wot-ui/ui/components/wd-empty/wd-empty.vue')['default']
+    WdForm: typeof import('@wot-ui/ui/components/wd-form/wd-form.vue')['default']
+    WdFormItem: typeof import('@wot-ui/ui/components/wd-form-item/wd-form-item.vue')['default']
     WdIcon: typeof import('@wot-ui/ui/components/wd-icon/wd-icon.vue')['default']
-    WdLoading: typeof import('@wot-ui/ui/components/wd-loading/wd-loading.vue')['default']
+    WdInput: typeof import('@wot-ui/ui/components/wd-input/wd-input.vue')['default']
     WdNavbar: typeof import('@wot-ui/ui/components/wd-navbar/wd-navbar.vue')['default']
     WdNoticeBar: typeof import('@wot-ui/ui/components/wd-notice-bar/wd-notice-bar.vue')['default']
+    WdPicker: typeof import('@wot-ui/ui/components/wd-picker/wd-picker.vue')['default']
     WdPopup: typeof import('@wot-ui/ui/components/wd-popup/wd-popup.vue')['default']
     WdProgress: typeof import('@wot-ui/ui/components/wd-progress/wd-progress.vue')['default']
     WdSearch: typeof import('@wot-ui/ui/components/wd-search/wd-search.vue')['default']

+ 1 - 1
package.json

@@ -32,7 +32,7 @@
     "@dcloudio/uni-mp-xhs": "3.0.0-4080420251103001",
     "@dcloudio/uni-quickapp-webview": "3.0.0-4080420251103001",
     "@dcloudio/uni-ui": "^1.5.11",
-    "@wot-ui/ui": "^2.0.5",
+    "@wot-ui/ui": "^2.1.0",
     "async-validator": "^4.2.5",
     "dayjs": "^1.11.19",
     "lodash-es": "^4.17.22",

Dosya farkı çok büyük olduğundan ihmal edildi
+ 257 - 78
pnpm-lock.yaml


+ 7 - 0
src/pages.json

@@ -27,6 +27,13 @@
         "navigationStyle": "custom"
       }
     },
+    {
+      "path": "pages/personal-card/index",
+      "style": {
+        "navigationBarTitleText": "个人名片",
+        "navigationStyle": "default"
+      }
+    },
     {
       "path": "pages/login/index",
       "style": {

+ 12 - 3
src/pages/mine-page/index.vue

@@ -93,7 +93,12 @@
         </view>
       </view>
       <view class="card menu-list">
-        <view class="menu-item" v-for="item in menuList" :key="item.path">
+        <view
+          class="menu-item"
+          v-for="item in menuList"
+          :key="item.path"
+          @click="navigateTo(item.path)"
+        >
           <view class="menu-item-content">
             <image class="menu-item-icon" :src="item.icon" mode="aspectFit" />
             <view class="menu-item-text">{{ item.txt }}</view>
@@ -194,13 +199,17 @@ const handleCompanyConfirm = (curr: { item: { name: string; color: string }; ind
 }
 
 const navigateToAuth = () => {
-  console.log('cqwe')
-
   uni.navigateTo({
     url: '/pages-auth/certification-service/index',
   })
 }
 
+const navigateTo = (path: string) => {
+  uni.navigateTo({
+    url: path,
+  })
+}
+
 const handleLogoutClick = () => {
   uni.showModal({
     title: '提示',

+ 1 - 1
src/pages/mine-page/mine.config.ts

@@ -2,7 +2,7 @@ export const menuList = [
   {
     txt: '个人名片',
     icon: 'https://yy-cloud-oss.oss-cn-beijing.aliyuncs.com/img/grmp.png',
-    path: 'id-card',
+    path: '/pages/personal-card/index',
   },
   {
     txt: '积分规则',

+ 263 - 0
src/pages/personal-card/components/PlatformExperienceCard.vue

@@ -0,0 +1,263 @@
+<template>
+  <view class="platform-experience-card">
+    <view class="platform-experience-card__header">
+      <view class="platform-experience-card__title">{{ title }}</view>
+    </view>
+
+    <view class="platform-experience-card__info-list">
+      <view class="platform-experience-card__info-item">
+        <text class="platform-experience-card__info-label">服务企业</text>
+        <text class="platform-experience-card__info-value">
+          {{ serviceDeptNameText }}
+        </text>
+      </view>
+
+      <view class="platform-experience-card__info-item">
+        <text class="platform-experience-card__info-label">服务时长</text>
+        <text
+          class="platform-experience-card__info-value platform-experience-card__info-value--primary"
+        >
+          {{ serviceDurationText }}
+        </text>
+      </view>
+    </view>
+
+    <view class="platform-experience-card__overview">
+      <view class="platform-experience-card__task">
+        <view class="platform-experience-card__task-item">
+          <text class="platform-experience-card__task-value">
+            {{ finishTaskCountText }}
+          </text>
+          <text class="platform-experience-card__task-label">完成任务量</text>
+        </view>
+
+        <view class="platform-experience-card__task-divider" />
+
+        <view class="platform-experience-card__task-item">
+          <text class="platform-experience-card__task-value">
+            {{ approvedTaskCountText }}
+          </text>
+          <text class="platform-experience-card__task-label">通过任务量</text>
+        </view>
+      </view>
+
+      <view class="platform-experience-card__rate">
+        <view class="platform-experience-card__rate-text">
+          <text class="platform-experience-card__rate-label">审核通过率</text>
+        </view>
+        <text class="platform-experience-card__rate-value">
+          {{ approvedTaskRateText }}
+        </text>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { computed } from 'vue'
+
+type DisplayValue = string | number | null | undefined
+
+export interface PlatformExperienceCardData {
+  serviceDeptName?: DisplayValue
+  serviceDuration?: DisplayValue
+  approvedTaskRate?: DisplayValue
+  finishTaskCount?: DisplayValue
+  approvedTaskCount?: DisplayValue
+}
+
+const props = withDefaults(
+  defineProps<{
+    title?: string
+    data?: PlatformExperienceCardData | null
+  }>(),
+  {
+    title: '平台经历',
+    data: null,
+  }
+)
+
+function formatDisplayValue(value: DisplayValue): string {
+  if (value === null || value === undefined || value === '') {
+    return '--'
+  }
+
+  return String(value)
+}
+
+const serviceDeptNameText = computed(() => formatDisplayValue(props.data?.serviceDeptName))
+const serviceDurationText = computed(() => formatDisplayValue(props.data?.serviceDuration))
+const approvedTaskRateText = computed(() => formatDisplayValue(props.data?.approvedTaskRate))
+const finishTaskCountText = computed(() => formatDisplayValue(props.data?.finishTaskCount))
+const approvedTaskCountText = computed(() => formatDisplayValue(props.data?.approvedTaskCount))
+</script>
+
+<style lang="scss" scoped>
+.platform-experience-card {
+  position: relative;
+  overflow: hidden;
+  padding: 32rpx 28rpx 30rpx;
+  border-radius: 32rpx;
+  background: #fff;
+  box-shadow: 0 18rpx 48rpx rgba(22, 38, 67, 0.1);
+}
+
+.platform-experience-card::before {
+  position: absolute;
+  top: -100rpx;
+  right: -80rpx;
+  width: 240rpx;
+  height: 240rpx;
+  border-radius: 50%;
+  background: rgba(47, 124, 255, 0.18);
+  content: '';
+}
+
+.platform-experience-card__header {
+  position: relative;
+  z-index: 1;
+  margin-bottom: 26rpx;
+}
+
+.platform-experience-card__title {
+  position: relative;
+  padding-left: 20rpx;
+  font-size: 34rpx;
+  font-weight: 800;
+  color: #172033;
+  line-height: 1.35;
+}
+
+.platform-experience-card__title::before {
+  position: absolute;
+  left: 0;
+  top: 50%;
+  width: 7rpx;
+  height: 32rpx;
+  border-radius: 999rpx;
+  background: linear-gradient(180deg, #1f7cff 0%, #45b7ff 100%);
+  transform: translateY(-50%);
+  content: '';
+}
+
+.platform-experience-card__info-list {
+  position: relative;
+  z-index: 1;
+  padding: 6rpx 24rpx;
+  border-radius: 26rpx;
+  background: #f7f9fc;
+  box-sizing: border-box;
+}
+
+.platform-experience-card__info-item {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  min-height: 88rpx;
+}
+
+.platform-experience-card__info-item + .platform-experience-card__info-item {
+  border-top: 1rpx solid rgba(23, 32, 51, 0.08);
+}
+
+.platform-experience-card__info-label {
+  flex-shrink: 0;
+  margin-right: 24rpx;
+  font-size: 26rpx;
+  color: #7a8499;
+  line-height: 1.35;
+}
+
+.platform-experience-card__info-value {
+  min-width: 0;
+  flex: 1;
+  font-size: 28rpx;
+  font-weight: 600;
+  color: #172033;
+  line-height: 1.35;
+  text-align: right;
+  word-break: break-all;
+}
+
+.platform-experience-card__info-value--primary {
+  color: #1f7cff;
+  font-weight: 800;
+}
+
+.platform-experience-card__overview {
+  position: relative;
+  z-index: 1;
+  margin-top: 24rpx;
+}
+
+.platform-experience-card__rate {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 30rpx 26rpx;
+  border-radius: 28rpx;
+  background: linear-gradient(135deg, #1f7cff 0%, #54a8ff 100%);
+  box-shadow: 0 14rpx 32rpx rgba(47, 124, 255, 0.3);
+  box-sizing: border-box;
+}
+
+.platform-experience-card__rate-text {
+  min-width: 0;
+}
+
+.platform-experience-card__rate-label {
+  display: block;
+  font-size: 30rpx;
+  font-weight: 700;
+  color: #fff;
+  line-height: 1.3;
+}
+
+.platform-experience-card__rate-value {
+  flex-shrink: 0;
+  margin-left: 24rpx;
+  font-size: 48rpx;
+  font-weight: 800;
+  color: #fff;
+  line-height: 1.1;
+}
+
+.platform-experience-card__task {
+  display: flex;
+  align-items: center;
+  margin-bottom: 20rpx;
+  padding: 28rpx 0;
+  border: 1rpx solid rgba(31, 124, 255, 0.16);
+  border-radius: 28rpx;
+  background: #f2f7ff;
+  box-sizing: border-box;
+}
+
+.platform-experience-card__task-item {
+  flex: 1;
+  min-width: 0;
+  text-align: center;
+}
+
+.platform-experience-card__task-value {
+  display: block;
+  font-size: 42rpx;
+  font-weight: 800;
+  color: #172033;
+  line-height: 1.15;
+}
+
+.platform-experience-card__task-label {
+  display: block;
+  margin-top: 10rpx;
+  font-size: 24rpx;
+  color: #6f7b91;
+  line-height: 1.3;
+}
+
+.platform-experience-card__task-divider {
+  width: 1rpx;
+  height: 58rpx;
+  background: rgba(23, 32, 51, 0.14);
+}
+</style>

+ 318 - 0
src/pages/personal-card/components/ProfessionalSkillCard.vue

@@ -0,0 +1,318 @@
+<template>
+  <view class="professional-skill-card">
+    <view class="card-title">
+      <view class="title-line" />
+      <text class="title-text">{{ title }}</text>
+    </view>
+
+    <view class="card-content">
+      <view class="info-row">
+        <text class="row-label">医药代表资质备案号</text>
+        <input
+          v-model="model.qualificationRecordNumber"
+          class="row-input"
+          type="text"
+          placeholder="请输入备案号"
+          placeholder-class="input-placeholder"
+        />
+      </view>
+
+      <view class="certificate-section">
+        <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">
+              <text class="delete-text">×</text>
+            </view>
+          </view>
+
+          <view v-else class="upload-box" @click="chooseCertificate">
+            <text class="upload-plus">+</text>
+            <text class="upload-text">选择图片</text>
+          </view>
+        </view>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { computed, ref } from 'vue'
+
+import type { PersonalBusinessCardResponse } from '@/services/modules/minePage/personalCard/type'
+
+interface ProfessionalSkillFields {
+  qualificationRecordNumber?: string
+  qualificationsUrl?: string
+}
+
+type ProfessionalSkillModel = PersonalBusinessCardResponse & ProfessionalSkillFields
+
+const props = withDefaults(
+  defineProps<{
+    title?: string
+    uploadApi?: (filePath: string) => Promise<string>
+  }>(),
+  {
+    title: '专业技能',
+    uploadApi: undefined,
+  }
+)
+
+const model = defineModel<ProfessionalSkillModel>({
+  required: true,
+})
+
+const uploading = ref(false)
+
+const certificateUrl = computed(() => model.value.qualificationsUrl ?? '')
+
+async function chooseCertificate() {
+  if (uploading.value) return
+
+  try {
+    const filePath = await chooseImage()
+
+    uploading.value = true
+    uni.showLoading({
+      title: '上传中',
+      mask: true,
+    })
+
+    const url = props.uploadApi ? await props.uploadApi(filePath) : filePath
+
+    model.value.qualificationsUrl = url
+
+    uni.showToast({
+      title: '上传成功',
+      icon: 'success',
+    })
+  } catch (error) {
+    if (error instanceof Error && error.message === 'choose image canceled') return
+
+    uni.showToast({
+      title: '上传失败',
+      icon: 'none',
+    })
+  } finally {
+    uploading.value = false
+    uni.hideLoading()
+  }
+}
+
+function chooseImage(): Promise<string> {
+  return new Promise((resolve, reject) => {
+    uni.chooseImage({
+      count: 1,
+      sizeType: ['compressed'],
+      sourceType: ['album', 'camera'],
+      success(res) {
+        const filePath = res.tempFilePaths[0]
+
+        if (!filePath) {
+          reject(new Error('empty file path'))
+          return
+        }
+
+        resolve(filePath)
+      },
+      fail() {
+        reject(new Error('choose image canceled'))
+      },
+    })
+  })
+}
+
+function previewCertificate() {
+  if (!certificateUrl.value) return
+
+  uni.previewImage({
+    urls: [certificateUrl.value],
+    current: certificateUrl.value,
+  })
+}
+
+function removeCertificate() {
+  model.value.qualificationsUrl = ''
+}
+</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;
+  border-radius: 30rpx;
+  background: #ffffff;
+  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;
+}
+
+.title-line {
+  width: 8rpx;
+  height: 34rpx;
+  margin-right: 14rpx;
+  border-radius: 999rpx;
+  background: linear-gradient(180deg, #2f84ff 0%, #57a3ff 100%);
+}
+
+.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);
+}
+
+.info-row {
+  display: flex;
+  align-items: center;
+  min-height: 88rpx;
+  padding: 0 24rpx;
+  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;
+}
+
+.upload-box,
+.image-box {
+  position: relative;
+  width: 220rpx;
+  height: 220rpx;
+  overflow: hidden;
+  border-radius: 16rpx;
+}
+
+.upload-box {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  background: #f3f4f6;
+}
+
+.upload-plus {
+  height: 64rpx;
+  font-size: 64rpx;
+  font-weight: 300;
+  line-height: 64rpx;
+  color: #60656f;
+}
+
+.upload-text {
+  margin-top: 24rpx;
+  font-size: 28rpx;
+  line-height: 40rpx;
+  color: #60656f;
+}
+
+.certificate-image {
+  width: 100%;
+  height: 100%;
+}
+
+.delete-btn {
+  position: absolute;
+  top: 8rpx;
+  right: 8rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 38rpx;
+  height: 38rpx;
+  border-radius: 50%;
+  background: rgba(0, 0, 0, 0.55);
+}
+
+.delete-text {
+  font-size: 34rpx;
+  line-height: 34rpx;
+  color: #ffffff;
+}
+</style>

+ 204 - 0
src/pages/personal-card/components/UserInfoCard.vue

@@ -0,0 +1,204 @@
+<template>
+  <view class="user-info-card">
+    <view class="card-title">
+      <view class="title-line" />
+      <text class="title-text">基本信息</text>
+    </view>
+
+    <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-form-item>
+
+        <wd-form-item title="性别" prop="gender">
+          <wd-input
+            v-model="genderLabel"
+            readonly
+            type="text"
+            align-right
+            placeholder="请选择性别"
+            suffix-icon="down"
+            @click="showPicker('gender')"
+          />
+        </wd-form-item>
+
+        <wd-form-item title="身份证号" prop="idCardNumber">
+          <wd-input v-model="model.idCardNumber" type="text" align-right />
+        </wd-form-item>
+
+        <wd-form-item title="手机号" prop="username">
+          <wd-input v-model="model.username" type="text" align-right />
+        </wd-form-item>
+
+        <wd-form-item title="年龄" prop="age">
+          <wd-input v-model="model.age" type="text" align-right />
+        </wd-form-item>
+
+        <wd-form-item title="学历" prop="degree">
+          <wd-input
+            v-model="degreeLabel"
+            readonly
+            type="text"
+            align-right
+            placeholder="请选择学历"
+            suffix-icon="down"
+            @click="showPicker('degree')"
+          />
+        </wd-form-item>
+      </wd-form>
+    </view>
+
+    <wd-picker v-model:visible="pickerShow" :columns="pickerColumns" @confirm="pickerConfirm" />
+  </view>
+</template>
+
+<script setup lang="ts">
+import { computed, ref } from 'vue'
+
+import type { DictItem } from '@/services/modules/common/type'
+import type { PersonalBusinessCardResponse } from '@/services/modules/minePage/personalCard/type'
+
+type PickerType = 'gender' | 'degree'
+
+interface PickerConfirmEvent {
+  selectedItems?: DictItem | DictItem[]
+  value?: string | string[]
+}
+
+const props = withDefaults(
+  defineProps<{
+    title?: string
+    genderColumns: DictItem[]
+    degreeColumns: DictItem[]
+  }>(),
+  {
+    title: '基本信息',
+  }
+)
+
+const model = defineModel<PersonalBusinessCardResponse>({
+  required: true,
+})
+
+const formRef = ref()
+
+const pickerType = ref<PickerType>('gender')
+const pickerShow = ref(false)
+const pickerColumns = ref<DictItem[]>([])
+
+const genderLabel = computed<string>({
+  get() {
+    if (!model.value.gender) return ''
+
+    const current = props.genderColumns.find((item) => item.value === model.value.gender)
+    return current?.label ?? ''
+  },
+  set() {
+    // readonly input 占位 setter,避免 v-model 绑定 readonly computed 报警告
+  },
+})
+
+const degreeLabel = computed<string>({
+  get() {
+    if (!model.value.degree) return ''
+
+    const current = props.degreeColumns.find((item) => item.value === model.value.degree)
+    return current?.label ?? ''
+  },
+  set() {
+    // readonly input 占位 setter,避免 v-model 绑定 readonly computed 报警告
+  },
+})
+
+function showPicker(type: PickerType) {
+  pickerType.value = type
+  pickerColumns.value = type === 'gender' ? props.genderColumns : props.degreeColumns
+  pickerShow.value = true
+}
+
+function pickerConfirm(event: PickerConfirmEvent) {
+  const currentValue = Array.isArray(event.value) ? event.value[0] : event.value
+
+  if (!currentValue) return
+
+  if (pickerType.value === 'gender') {
+    model.value.gender = currentValue
+    return
+  }
+
+  model.value.degree = currentValue
+}
+</script>
+
+<style lang="scss" scoped>
+.user-info-card {
+  position: relative;
+  margin-bottom: 28rpx;
+  padding: 30rpx 26rpx 26rpx;
+  box-sizing: border-box;
+  overflow: hidden;
+  border-radius: 30rpx;
+  background: #ffffff;
+  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;
+}
+
+.title-line {
+  width: 8rpx;
+  height: 34rpx;
+  margin-right: 14rpx;
+  border-radius: 999rpx;
+  background: linear-gradient(180deg, #2f84ff 0%, #57a3ff 100%);
+}
+
+.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;
+
+  box-shadow:
+    0 18rpx 44rpx rgba(30, 64, 120, 0.12),
+    0 6rpx 18rpx rgba(30, 64, 120, 0.06);
+}
+</style>

+ 132 - 0
src/pages/personal-card/index.vue

@@ -0,0 +1,132 @@
+<template>
+  <view class="personal-card-page">
+    <UserInfoCard
+      v-if="personalBusinessCardInfo"
+      v-model="personalBusinessCardInfo"
+      :gender-columns="genderColumns"
+      :degree-columns="degreeColumns"
+    />
+
+    <PlatformExperienceCard v-if="personalBusinessCardInfo" :data="personalBusinessCardInfo" />
+
+    <ProfessionalSkillCard
+      v-if="personalBusinessCardInfo"
+      v-model="personalBusinessCardInfo"
+      :upload-api="uploadCertificate"
+    />
+
+    <view v-if="personalBusinessCardInfo" class="save-section">
+      <button class="save-btn" :disabled="saving" @click="handleSave">
+        {{ saving ? '保存中...' : '保存' }}
+      </button>
+    </view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { computed, ref } from 'vue'
+
+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 { useUserStore } from '@/stores/modules/user'
+
+import PlatformExperienceCard from './components/PlatformExperienceCard.vue'
+import ProfessionalSkillCard from './components/ProfessionalSkillCard.vue'
+import UserInfoCard from './components/UserInfoCard.vue'
+
+const userStore = useUserStore()
+
+const currentUserInfo = computed(() => userStore.currentUserInfo)
+
+const personalBusinessCardInfo = ref<PersonalBusinessCardResponse>()
+
+const genderColumns = ref<DictItem[]>([])
+const degreeColumns = ref<DictItem[]>([])
+
+onLoad(() => {
+  initPage()
+})
+
+async function initPage() {
+  await Promise.all([getDict(), getPersonalBusinessCardInfo()])
+}
+
+async function getPersonalBusinessCardInfo() {
+  const userId = currentUserInfo.value?.userId
+
+  if (!userId) return
+
+  const res = await getPersonalBusinessCardApi(userId)
+  personalBusinessCardInfo.value = res.data
+}
+
+async function getDict() {
+  const [genderRes, degreeRes] = await Promise.all([
+    getDictTypeApi('gender'),
+    getDictTypeApi('user_degree'),
+  ])
+
+  genderColumns.value = genderRes?.data ?? []
+  degreeColumns.value = degreeRes?.data ?? []
+}
+
+async function uploadCertificate(filePath: string): Promise<string> {
+  return filePath
+}
+
+const saving = ref(false)
+
+const handleSave = () => {}
+</script>
+
+<style lang="scss" scoped>
+.personal-card-page {
+  min-height: 100vh;
+  padding: 24rpx 24rpx 64rpx;
+  box-sizing: border-box;
+  background: linear-gradient(180deg, #f1f6ff 0%, #f6f7fb 48%, #f7f8fa 100%);
+}
+
+.save-section {
+  margin-top: 8rpx;
+  padding: 8rpx 6rpx 32rpx;
+  box-sizing: border-box;
+}
+
+.save-btn {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 100%;
+  height: 92rpx;
+  padding: 0;
+  margin: 0;
+  border: none;
+  border-radius: 999rpx;
+  background: linear-gradient(90deg, #2f84ff 0%, #4ba0ff 100%);
+  color: #ffffff;
+  font-size: 32rpx;
+  font-weight: 600;
+  line-height: 92rpx;
+  letter-spacing: 2rpx;
+  box-shadow:
+    0 14rpx 30rpx rgba(47, 132, 255, 0.26),
+    0 4rpx 10rpx rgba(47, 132, 255, 0.14);
+
+  &::after {
+    border: none;
+  }
+
+  &[disabled] {
+    opacity: 0.68;
+    color: #ffffff;
+    background: linear-gradient(90deg, #8bbdff 0%, #9dccff 100%);
+    box-shadow: 0 8rpx 20rpx rgba(47, 132, 255, 0.14);
+  }
+}
+</style>

+ 0 - 2
src/services/modules/minePage/index.ts

@@ -1,2 +0,0 @@
-import http from '../../index'
-

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

@@ -0,0 +1,10 @@
+import http from '../../../index'
+import type { PersonalBusinessCardResponse } 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)
+}

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

@@ -0,0 +1,17 @@
+export interface PersonalBusinessCardResponse {
+  age: number
+  approvedTaskCount: number
+  approvedTaskRate: string
+  degree: string
+  finishTaskCount: number
+  gender: string
+  idCardNumber: string
+  isLivenessAuth: 0 | 1
+  qualificationRecordNumber: string
+  qualificationsUrl: string[]
+  realname: string
+  serviceDeptName: string
+  serviceDuration: string
+  userId: number
+  username: string
+}

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


+ 1 - 0
src/services/modules/userInfo/index.ts

@@ -1,5 +1,6 @@
 import http from '../../index'
 import type { QuizPltTestResultResponse } from './type'
+
 export const stopSixtyReminderApi = () => {
   return http.post<boolean>('/admin/api/stop-sixtyyearsold-reminder')
 }

+ 104 - 0
src/utils/id-card.ts

@@ -0,0 +1,104 @@
+const CHINESE_RESIDENT_ID_CARD_18_REGEX =
+  /^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/
+
+const CHINESE_RESIDENT_ID_CARD_15_REGEX =
+  /^[1-9]\d{5}\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}$/
+
+const ID_CARD_WEIGHTS = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2] as const
+
+const ID_CARD_CHECK_CODES = ['1', '0', 'X', '9', '8', '7', '6', '5', '4', '3', '2'] as const
+
+export function isValidChineseResidentIdCard(idCard: string): boolean {
+  const normalizedIdCard = idCard.trim()
+
+  if (CHINESE_RESIDENT_ID_CARD_15_REGEX.test(normalizedIdCard)) {
+    return isValidBirthDate(getBirthDateFrom15DigitIdCard(normalizedIdCard))
+  }
+
+  if (!CHINESE_RESIDENT_ID_CARD_18_REGEX.test(normalizedIdCard)) {
+    return false
+  }
+
+  if (!isValidBirthDate(getBirthDateFrom18DigitIdCard(normalizedIdCard))) {
+    return false
+  }
+
+  return isValidIdCardChecksum(normalizedIdCard)
+}
+
+export function getAgeFromChineseResidentIdCard(
+  idCard: string,
+  currentDate = new Date()
+): number | null {
+  const normalizedIdCard = idCard.trim()
+
+  if (!isValidChineseResidentIdCard(normalizedIdCard)) {
+    return null
+  }
+
+  const birthDate =
+    normalizedIdCard.length === 15
+      ? getBirthDateFrom15DigitIdCard(normalizedIdCard)
+      : getBirthDateFrom18DigitIdCard(normalizedIdCard)
+
+  if (!birthDate) return null
+
+  let age = currentDate.getFullYear() - birthDate.getFullYear()
+
+  const hasHadBirthdayThisYear =
+    currentDate.getMonth() > birthDate.getMonth() ||
+    (currentDate.getMonth() === birthDate.getMonth() &&
+      currentDate.getDate() >= birthDate.getDate())
+
+  if (!hasHadBirthdayThisYear) {
+    age -= 1
+  }
+
+  return age >= 0 ? age : null
+}
+
+function isValidIdCardChecksum(idCard: string): boolean {
+  const first17Digits = idCard.slice(0, 17)
+
+  const sum = first17Digits
+    .split('')
+    .reduce((total, digit, index) => total + Number(digit) * ID_CARD_WEIGHTS[index], 0)
+
+  const expectedCheckCode = ID_CARD_CHECK_CODES[sum % 11]
+  const actualCheckCode = idCard[17].toUpperCase()
+
+  return actualCheckCode === expectedCheckCode
+}
+
+function getBirthDateFrom18DigitIdCard(idCard: string): Date | null {
+  const year = Number(idCard.slice(6, 10))
+  const month = Number(idCard.slice(10, 12))
+  const day = Number(idCard.slice(12, 14))
+
+  return createValidDate(year, month, day)
+}
+
+function getBirthDateFrom15DigitIdCard(idCard: string): Date | null {
+  const year = Number(`19${idCard.slice(6, 8)}`)
+  const month = Number(idCard.slice(8, 10))
+  const day = Number(idCard.slice(10, 12))
+
+  return createValidDate(year, month, day)
+}
+
+function createValidDate(year: number, month: number, day: number): Date | null {
+  const date = new Date(year, month - 1, day)
+
+  const isSameDate =
+    date.getFullYear() === year && date.getMonth() === month - 1 && date.getDate() === day
+
+  return isSameDate ? date : null
+}
+
+function isValidBirthDate(date: Date | null): boolean {
+  if (!date) return false
+
+  const now = new Date()
+
+  return date.getTime() <= now.getTime()
+}

Bu fark içinde çok fazla dosya değişikliği olduğu için bazı dosyalar gösterilmiyor