Du kannst nicht mehr als 25 Themen auswählen Themen müssen entweder mit einem Buchstaben oder einer Ziffer beginnen. Sie können Bindestriche („-“) enthalten und bis zu 35 Zeichen lang sein.
 
 
 
 

691 Zeilen
17 KiB

  1. <template>
  2. <view class="page">
  3. <view class="page-content">
  4. <view class="page-title">请确认申请人信息</view>
  5. <!-- 第1步:证件类型选择 + 上传 -->
  6. <view class="section">
  7. <view class="section-header">
  8. <view class="step-num">1</view>
  9. <text class="label">为谁申请</text>
  10. <text class="tag warn">请用患者本人信息进行实名申请</text>
  11. </view>
  12. <view class="section-desc">请选择用来实名证件的类型</view>
  13. <view class="cert-tabs">
  14. <view class="cert-tab" :class="{ active: certType === 'idcard' }" @tap="certType = 'idcard'">
  15. <text>身份证证件</text>
  16. </view>
  17. <view class="cert-tab" :class="{ active: certType === 'child' }" @tap="certType = 'child'">
  18. <text>无证件儿童</text>
  19. </view>
  20. <view class="cert-tab" :class="{ active: certType === 'temp' }" @tap="certType = 'temp'">
  21. <text>临时身份证</text>
  22. </view>
  23. </view>
  24. <!-- 身份证 / 临时身份证 -->
  25. <view v-if="certType === 'idcard' || certType === 'temp'" class="upload-area">
  26. <view class="upload-item" @tap="chooseImage('front')">
  27. <image :src="form.frontImage || placeholderFront" mode="aspectFill" class="upload-img" />
  28. <text class="upload-label">请上传证件人像面</text>
  29. </view>
  30. <view class="upload-item" @tap="chooseImage('back')">
  31. <image :src="form.backImage || placeholderBack" mode="aspectFill" class="upload-img" />
  32. <text class="upload-label">请上传证件国徽面</text>
  33. </view>
  34. </view>
  35. <!-- 无证件儿童 -->
  36. <view v-if="certType === 'child'" class="child-upload">
  37. <view class="child-tip">请上传近期免冠照片 <text class="tip-highlight">用于无证件儿童身份核验</text></view>
  38. <view class="child-desc">为了避免增加审核时长,如已办理身份证,请走身份证件通道</view>
  39. <view class="child-photo-box" @tap="chooseImage('child')">
  40. <image v-if="form.childImage" :src="form.childImage" mode="aspectFill" class="child-photo-img" />
  41. <view v-else class="child-photo-placeholder">
  42. <u-icon name="camera" size="40" color="#ccc" />
  43. </view>
  44. </view>
  45. </view>
  46. </view>
  47. <!-- 第2步:信息核验 -->
  48. <view class="section">
  49. <view class="section-header">
  50. <view class="step-num">2</view>
  51. <text class="label">信息核验</text>
  52. <text class="tag info">如姓名有误,请进行修改再提交</text>
  53. </view>
  54. <view class="section-desc">请核对证件信息是否有误</view>
  55. <view class="form-row">
  56. <text class="form-label">证件姓名</text>
  57. <input class="form-input" v-model="form.name" :placeholder="certType === 'child' ? '请输入姓名' : '上传身份证后自动识别'" />
  58. </view>
  59. <view class="form-row">
  60. <text class="form-label">证件号码</text>
  61. <input class="form-input" v-model="form.idNo" :placeholder="certType === 'child' ? '请输入身份证' : '上传身份证后自动识别'" />
  62. </view>
  63. <view class="form-row" v-if="certType !== 'child'">
  64. <text class="form-label">发证机关</text>
  65. <input class="form-input" v-model="form.authority" :placeholder="authorityPlaceholder" />
  66. </view>
  67. <view class="form-row border-none" v-if="certType !== 'child'">
  68. <text class="form-label">有效期限</text>
  69. <input class="form-input" v-model="form.validity" :placeholder="validityPlaceholder" />
  70. </view>
  71. </view>
  72. <!-- 第3步:手机号码绑定 -->
  73. <view class="section">
  74. <view class="section-header">
  75. <view class="step-num">3</view>
  76. <text class="label">手机号码绑定</text>
  77. </view>
  78. <view class="section-desc">请输入未进行过平台认证的号码进行绑定</view>
  79. <view class="phone-row">
  80. <input class="phone-input" type="number" v-model="form.phone" placeholder="请输入手机号码" maxlength="11" />
  81. </view>
  82. <view class="sms-row">
  83. <input class="sms-input" type="number" v-model="form.smsCode" placeholder="请输入验证码" maxlength="6" />
  84. <button class="sms-btn" :disabled="countdown > 0" @tap="sendSms">
  85. {{ countdown > 0 ? countdown + 's后重新获取' : '获取验证码' }}
  86. </button>
  87. </view>
  88. </view>
  89. </view>
  90. <!-- 底部提交按钮 -->
  91. <view class="submit-bar">
  92. <button class="submit-btn" @tap="handleSubmit">提交认证</button>
  93. </view>
  94. <!-- 绑定已有患者确认弹窗 -->
  95. <u-popup :show="showBindPopup" mode="center" round="12" :safeAreaInsetBottom="false" @close="showBindPopup = false">
  96. <view class="confirm-popup">
  97. <view class="confirm-title">提示</view>
  98. <view class="confirm-content">检测到患者信息已存在({{ bindPatientInfo.name }} {{ bindPatientInfo.phone }}),是否确认绑定该患者信息?</view>
  99. <view class="confirm-btns">
  100. <u-button text="取消" size="normal" :plain="true" shape="circle" @click="showBindPopup = false" />
  101. <u-button text="确认绑定" size="normal" color="#0E63E3" shape="circle" @click="doSubmit(true)" />
  102. </view>
  103. </view>
  104. </u-popup>
  105. </view>
  106. </template>
  107. <script setup>
  108. import { ref, reactive, onUnmounted } from 'vue'
  109. import { onShow } from '@dcloudio/uni-app'
  110. import { get, post, upload } from '@/utils/request.js'
  111. import { getUserInfo, setUserInfo } from '@/utils/cache.js'
  112. const CDN_BASE = 'https://cdn.csybhelp.com'
  113. const placeholderFront = CDN_BASE + '/images/patient/user/idcard-front.webp?v=1'
  114. const placeholderBack = CDN_BASE + '/images/patient/user/idcard-back.webp?v=1'
  115. const certType = ref('idcard')
  116. const countdown = ref(0)
  117. const submitting = ref(false)
  118. const showBindPopup = ref(false)
  119. const bindPatientInfo = ref({ name: '', phone: '' })
  120. let timer = null
  121. // 身份证号格式校验(18位,含校验位)
  122. const validateIdCard = (id) => {
  123. if (!/^\d{17}[\dXx]$/.test(id)) return false
  124. const weights = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2]
  125. const checkCodes = ['1', '0', 'X', '9', '8', '7', '6', '5', '4', '3', '2']
  126. let sum = 0
  127. for (let i = 0; i < 17; i++) sum += parseInt(id[i]) * weights[i]
  128. return checkCodes[sum % 11] === id[17].toUpperCase()
  129. }
  130. // 证件类型映射: certType -> idCardType (后端字段)
  131. const certTypeMap = { idcard: 1, child: 2, temp: 3 }
  132. const authorityPlaceholder = ref('上传身份证后自动识别')
  133. const validityPlaceholder = ref('上传身份证后自动识别')
  134. const form = reactive({
  135. frontImage: '', // 上传后的CDN URL
  136. backImage: '',
  137. childImage: '',
  138. name: '',
  139. idNo: '',
  140. authority: '',
  141. validity: '',
  142. gender: '',
  143. birthday: '',
  144. phone: '',
  145. smsCode: ''
  146. })
  147. // 页面显示时加载已有认证信息
  148. onShow(() => {
  149. loadAuthInfo()
  150. })
  151. onUnmounted(() => {
  152. if (timer) { clearInterval(timer); timer = null }
  153. })
  154. const loadAuthInfo = async () => {
  155. try {
  156. const res = await get('/api/mp/authInfo')
  157. if (res.data.authStatus === 1) {
  158. const d = res.data
  159. // 反向映射 idCardType -> certType
  160. const typeMap = { 1: 'idcard', 2: 'child', 3: 'temp' }
  161. certType.value = typeMap[d.idCardType] || 'idcard'
  162. form.frontImage = d.idCardFront || ''
  163. form.backImage = d.idCardBack || ''
  164. form.childImage = d.photo || ''
  165. form.name = d.realName || ''
  166. form.idNo = d.idCard || ''
  167. form.authority = d.issuingAuthority || ''
  168. form.validity = d.validPeriod || ''
  169. form.gender = d.gender || ''
  170. form.birthday = d.birthday || ''
  171. form.phone = d.phone || ''
  172. }
  173. } catch (e) {
  174. // 未认证,忽略
  175. }
  176. }
  177. // 上传图片到COS,返回CDN URL
  178. const uploadImage = (tempFilePath) => {
  179. return new Promise((resolve, reject) => {
  180. uni.showLoading({ title: '上传中...' })
  181. upload('/api/mp/upload', { filePath: tempFilePath, name: 'file' })
  182. .then(res => {
  183. uni.hideLoading()
  184. resolve(res.data.url)
  185. })
  186. .catch(err => {
  187. uni.hideLoading()
  188. uni.showToast({ title: '上传失败', icon: 'none' })
  189. reject(err)
  190. })
  191. })
  192. }
  193. // OCR识别身份证正面
  194. const doOcrFront = async (imageUrl) => {
  195. uni.showLoading({ title: '识别中...' })
  196. try {
  197. const res = await post('/common/ocr/idcard', { imageUrl, cardSide: 'FRONT' })
  198. const d = res.data
  199. form.name = d.name || ''
  200. form.idNo = d.idNum || ''
  201. uni.hideLoading()
  202. } catch (e) {
  203. uni.hideLoading()
  204. uni.showToast({ title: '识别失败,请手动填写', icon: 'none' })
  205. }
  206. }
  207. // OCR识别身份证反面
  208. const doOcrBack = async (imageUrl) => {
  209. uni.showLoading({ title: '识别中...' })
  210. try {
  211. const res = await post('/common/ocr/idcard', { imageUrl, cardSide: 'BACK' })
  212. form.authority = res.data.authority || ''
  213. form.validity = res.data.validDate || ''
  214. if (!form.authority) authorityPlaceholder.value = '请输入发证机关'
  215. if (!form.validity) validityPlaceholder.value = '请输入有效期限'
  216. uni.hideLoading()
  217. } catch (e) {
  218. uni.hideLoading()
  219. authorityPlaceholder.value = '请输入发证机关'
  220. validityPlaceholder.value = '请输入有效期限'
  221. }
  222. }
  223. const chooseImage = (type) => {
  224. uni.chooseImage({
  225. count: 1,
  226. sizeType: ['compressed'],
  227. sourceType: ['album', 'camera'],
  228. success: async (res) => {
  229. const tempPath = res.tempFilePaths[0]
  230. try {
  231. const url = await uploadImage(tempPath)
  232. if (type === 'front') {
  233. form.frontImage = url
  234. await doOcrFront(url)
  235. } else if (type === 'back') {
  236. form.backImage = url
  237. await doOcrBack(url)
  238. } else if (type === 'child') {
  239. form.childImage = url
  240. }
  241. } catch (e) {
  242. // 上传失败已提示
  243. }
  244. }
  245. })
  246. }
  247. const sendSms = async () => {
  248. if (countdown.value > 0) return
  249. if (!form.phone || form.phone.length !== 11) {
  250. uni.showToast({ title: '请输入正确的手机号码', icon: 'none' })
  251. return
  252. }
  253. try {
  254. const res = await post('/api/mp/sendSmsCode', { mobile: form.phone, bizType: 'real_name_auth' })
  255. // 开发环境可能返回验证码
  256. if (res.data && res.data.code) {
  257. uni.showToast({ title: `验证码: ${res.data.code}`, icon: 'none', duration: 3000 })
  258. } else {
  259. uni.showToast({ title: '验证码已发送', icon: 'none' })
  260. }
  261. countdown.value = 60
  262. timer = setInterval(() => {
  263. countdown.value--
  264. if (countdown.value <= 0) { clearInterval(timer); timer = null }
  265. }, 1000)
  266. } catch (e) {
  267. if (e && e.msg) uni.showToast({ title: e.msg, icon: 'none' })
  268. }
  269. }
  270. const handleSubmit = async () => {
  271. if (submitting.value) return
  272. // 前端校验
  273. const idCardType = certTypeMap[certType.value]
  274. if (idCardType === 2) {
  275. if (!form.childImage) return uni.showToast({ title: '请上传免冠照片', icon: 'none' })
  276. } else {
  277. if (!form.frontImage) return uni.showToast({ title: '请上传证件人像面', icon: 'none' })
  278. if (!form.backImage) return uni.showToast({ title: '请上传证件国徽面', icon: 'none' })
  279. }
  280. if (!form.name) return uni.showToast({ title: '请输入证件姓名', icon: 'none' })
  281. if (!form.idNo) return uni.showToast({ title: '请输入证件号码', icon: 'none' })
  282. // 身份证格式校验
  283. if (!validateIdCard(form.idNo)) return uni.showToast({ title: '身份证号格式不正确', icon: 'none' })
  284. // 从身份证号解析性别和生日(提交时统一解析,确保一定有值)
  285. if (form.idNo.length === 18) {
  286. const genderNum = parseInt(form.idNo.charAt(16))
  287. form.gender = genderNum % 2 === 1 ? '男' : '女'
  288. form.birthday = `${form.idNo.substring(6, 10)}-${form.idNo.substring(10, 12)}-${form.idNo.substring(12, 14)}`
  289. }
  290. if (!form.phone || form.phone.length !== 11) return uni.showToast({ title: '请输入正确的手机号', icon: 'none' })
  291. if (!form.smsCode || form.smsCode.length !== 6) return uni.showToast({ title: '请输入6位验证码', icon: 'none' })
  292. await doSubmit(false)
  293. }
  294. const doSubmit = async (confirmBind) => {
  295. showBindPopup.value = false
  296. submitting.value = true
  297. try {
  298. await post('/api/mp/authSubmit', {
  299. idCardType: certTypeMap[certType.value],
  300. idCardFront: form.frontImage,
  301. idCardBack: form.backImage,
  302. photo: form.childImage,
  303. realName: form.name,
  304. idCard: form.idNo,
  305. gender: form.gender,
  306. birthday: form.birthday,
  307. issuingAuthority: form.authority,
  308. validPeriod: form.validity,
  309. mobile: form.phone,
  310. code: form.smsCode,
  311. confirmBind: confirmBind || undefined
  312. })
  313. // 刷新用户信息缓存
  314. try {
  315. const userRes = await get('/api/mp/userinfo')
  316. setUserInfo(userRes.data)
  317. } catch (e) {}
  318. uni.showToast({ title: '认证成功', icon: 'success' })
  319. setTimeout(() => { uni.navigateBack() }, 1500)
  320. } catch (e) {
  321. if (e && e.code === 1010) {
  322. // 患者已存在,弹出确认绑定弹窗
  323. bindPatientInfo.value = {
  324. name: (e.data && e.data.patientName) || '',
  325. phone: (e.data && e.data.patientPhone) || ''
  326. }
  327. showBindPopup.value = true
  328. } else if (e && e.msg) {
  329. uni.showToast({ title: e.msg, icon: 'none' })
  330. }
  331. } finally {
  332. submitting.value = false
  333. }
  334. }
  335. </script>
  336. <style lang="scss" scoped>
  337. .page {
  338. min-height: 100vh;
  339. background: #f4f4f5;
  340. padding-bottom: 200rpx;
  341. }
  342. .page-content {
  343. padding: 32rpx;
  344. }
  345. .page-title {
  346. font-size: 36rpx;
  347. font-weight: 600;
  348. color: #303133;
  349. margin-bottom: 32rpx;
  350. }
  351. .section {
  352. background: #fff;
  353. border-radius: 8rpx;
  354. padding: 40rpx 32rpx;
  355. margin-bottom: 24rpx;
  356. border: 1rpx solid #ebeef5;
  357. }
  358. .section-header {
  359. display: flex;
  360. align-items: center;
  361. flex-wrap: wrap;
  362. gap: 12rpx;
  363. margin-bottom: 8rpx;
  364. }
  365. .step-num {
  366. width: 44rpx;
  367. height: 44rpx;
  368. border-radius: 50%;
  369. background: #0e63e3;
  370. color: #fff;
  371. font-size: 24rpx;
  372. font-weight: 600;
  373. display: flex;
  374. align-items: center;
  375. justify-content: center;
  376. flex-shrink: 0;
  377. }
  378. .label {
  379. font-size: 32rpx;
  380. font-weight: 600;
  381. color: #303133;
  382. }
  383. .tag {
  384. font-size: 22rpx;
  385. border-radius: 8rpx;
  386. padding: 4rpx 16rpx;
  387. &.warn {
  388. color: #e6a23c;
  389. background: #fdf6ec;
  390. border: 1rpx solid #faecd8;
  391. }
  392. &.info {
  393. color: #3a85f0;
  394. background: #e8f0fe;
  395. border: 1rpx solid #c6d9f7;
  396. }
  397. }
  398. .section-desc {
  399. font-size: 26rpx;
  400. color: #909399;
  401. margin: 12rpx 0 28rpx 56rpx;
  402. }
  403. .cert-tabs {
  404. display: flex;
  405. gap: 0;
  406. margin-bottom: 32rpx;
  407. }
  408. .cert-tab {
  409. flex: 1;
  410. display: flex;
  411. align-items: center;
  412. justify-content: center;
  413. padding: 16rpx 0;
  414. border: 1rpx solid #dcdfe6;
  415. font-size: 26rpx;
  416. color: #606266;
  417. background: #fff;
  418. white-space: nowrap;
  419. margin-left: -1rpx;
  420. &:first-child {
  421. border-radius: 8rpx 0 0 8rpx;
  422. margin-left: 0;
  423. }
  424. &:last-child {
  425. border-radius: 0 8rpx 8rpx 0;
  426. }
  427. &.active {
  428. color: #0e63e3;
  429. border-color: #0e63e3;
  430. background: #e8f0fe;
  431. position: relative;
  432. z-index: 1;
  433. }
  434. }
  435. .upload-area {
  436. display: flex;
  437. gap: 24rpx;
  438. }
  439. .upload-item {
  440. flex: 1;
  441. display: flex;
  442. flex-direction: column;
  443. align-items: center;
  444. }
  445. .upload-img {
  446. width: 100%;
  447. height: 200rpx;
  448. border-radius: 12rpx;
  449. }
  450. .upload-label {
  451. font-size: 24rpx;
  452. color: #606266;
  453. margin-top: 12rpx;
  454. }
  455. .child-upload {
  456. margin-top: 24rpx;
  457. }
  458. .child-tip {
  459. font-size: 26rpx;
  460. color: #303133;
  461. margin-bottom: 8rpx;
  462. .tip-highlight {
  463. color: #0e63e3;
  464. margin-left: 10rpx;
  465. }
  466. }
  467. .child-desc {
  468. font-size: 22rpx;
  469. color: #909399;
  470. margin-bottom: 20rpx;
  471. }
  472. .child-photo-box {
  473. width: 200rpx;
  474. height: 200rpx;
  475. border-radius: 12rpx;
  476. overflow: hidden;
  477. }
  478. .child-photo-img {
  479. width: 100%;
  480. height: 100%;
  481. }
  482. .child-photo-placeholder {
  483. width: 100%;
  484. height: 100%;
  485. background: #f5f5f5;
  486. display: flex;
  487. align-items: center;
  488. justify-content: center;
  489. }
  490. .form-row {
  491. display: flex;
  492. align-items: center;
  493. padding: 28rpx 0;
  494. border-bottom: 1rpx solid #f2f6fc;
  495. &.border-none {
  496. border-bottom: none;
  497. }
  498. }
  499. .form-label {
  500. width: 160rpx;
  501. font-size: 28rpx;
  502. color: #303133;
  503. flex-shrink: 0;
  504. }
  505. .form-input {
  506. flex: 1;
  507. font-size: 28rpx;
  508. color: #303133;
  509. }
  510. .phone-row {
  511. margin-top: 20rpx;
  512. }
  513. .phone-input {
  514. width: 100%;
  515. height: 80rpx;
  516. border: 1rpx solid #dcdfe6;
  517. border-radius: 8rpx;
  518. padding: 0 24rpx;
  519. font-size: 28rpx;
  520. box-sizing: border-box;
  521. }
  522. .sms-row {
  523. display: flex;
  524. align-items: center;
  525. gap: 20rpx;
  526. margin-top: 20rpx;
  527. }
  528. .sms-input {
  529. flex: 1;
  530. height: 80rpx;
  531. border: 1rpx solid #dcdfe6;
  532. border-radius: 8rpx;
  533. padding: 0 24rpx;
  534. font-size: 28rpx;
  535. box-sizing: border-box;
  536. }
  537. .sms-btn {
  538. height: 80rpx;
  539. line-height: 80rpx;
  540. padding: 0 28rpx;
  541. background: #0e63e3;
  542. color: #fff;
  543. border: none;
  544. border-radius: 8rpx;
  545. font-size: 26rpx;
  546. white-space: nowrap;
  547. &::after {
  548. border: none;
  549. }
  550. &:active {
  551. opacity: 0.85;
  552. }
  553. &[disabled] {
  554. background: #a0cfff;
  555. color: #fff;
  556. }
  557. }
  558. .submit-bar {
  559. position: fixed;
  560. bottom: 0;
  561. left: 0;
  562. right: 0;
  563. padding: 24rpx 32rpx;
  564. padding-bottom: calc(40rpx + env(safe-area-inset-bottom));
  565. background: #fff;
  566. border-top: 1rpx solid #ebeef5;
  567. z-index: 10;
  568. }
  569. .submit-btn {
  570. width: 100%;
  571. height: 88rpx;
  572. line-height: 88rpx;
  573. background: #0e63e3;
  574. color: #fff;
  575. border: none;
  576. border-radius: 44rpx;
  577. font-size: 32rpx;
  578. font-weight: 500;
  579. box-shadow: 0 8rpx 24rpx rgba(14, 99, 227, 0.3);
  580. &::after {
  581. border: none;
  582. }
  583. &:active {
  584. opacity: 0.85;
  585. }
  586. }
  587. .confirm-popup {
  588. padding: 48rpx 40rpx 40rpx;
  589. width: 560rpx;
  590. }
  591. .confirm-title {
  592. font-size: 34rpx;
  593. font-weight: 600;
  594. color: #333;
  595. text-align: center;
  596. margin-bottom: 28rpx;
  597. }
  598. .confirm-content {
  599. font-size: 28rpx;
  600. color: #666;
  601. line-height: 1.7;
  602. text-align: center;
  603. margin-bottom: 40rpx;
  604. }
  605. .confirm-btns {
  606. display: flex;
  607. gap: 24rpx;
  608. }
  609. </style>