Nevar pievienot vairāk kā 25 tēmas Tēmai ir jāsākas ar burtu vai ciparu, tā var saturēt domu zīmes ('-') un var būt līdz 35 simboliem gara.
 
 
 
 

684 rindas
16 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="上传身份证后自动识别" />
  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="上传身份证后自动识别" />
  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 form = reactive({
  133. frontImage: '', // 上传后的CDN URL
  134. backImage: '',
  135. childImage: '',
  136. name: '',
  137. idNo: '',
  138. authority: '',
  139. validity: '',
  140. gender: '',
  141. birthday: '',
  142. phone: '',
  143. smsCode: ''
  144. })
  145. // 页面显示时加载已有认证信息
  146. onShow(() => {
  147. loadAuthInfo()
  148. })
  149. onUnmounted(() => {
  150. if (timer) { clearInterval(timer); timer = null }
  151. })
  152. const loadAuthInfo = async () => {
  153. try {
  154. const res = await get('/api/mp/authInfo')
  155. if (res.data.authStatus === 1) {
  156. const d = res.data
  157. // 反向映射 idCardType -> certType
  158. const typeMap = { 1: 'idcard', 2: 'child', 3: 'temp' }
  159. certType.value = typeMap[d.idCardType] || 'idcard'
  160. form.frontImage = d.idCardFront || ''
  161. form.backImage = d.idCardBack || ''
  162. form.childImage = d.photo || ''
  163. form.name = d.realName || ''
  164. form.idNo = d.idCard || ''
  165. form.authority = d.issuingAuthority || ''
  166. form.validity = d.validPeriod || ''
  167. form.gender = d.gender || ''
  168. form.birthday = d.birthday || ''
  169. form.phone = d.phone || ''
  170. }
  171. } catch (e) {
  172. // 未认证,忽略
  173. }
  174. }
  175. // 上传图片到COS,返回CDN URL
  176. const uploadImage = (tempFilePath) => {
  177. return new Promise((resolve, reject) => {
  178. uni.showLoading({ title: '上传中...' })
  179. upload('/api/mp/upload', { filePath: tempFilePath, name: 'file' })
  180. .then(res => {
  181. uni.hideLoading()
  182. resolve(res.data.url)
  183. })
  184. .catch(err => {
  185. uni.hideLoading()
  186. uni.showToast({ title: '上传失败', icon: 'none' })
  187. reject(err)
  188. })
  189. })
  190. }
  191. // OCR识别身份证正面
  192. const doOcrFront = async (imageUrl) => {
  193. uni.showLoading({ title: '识别中...' })
  194. try {
  195. const res = await post('/common/ocr/idcard', { imageUrl, cardSide: 'FRONT' })
  196. const d = res.data
  197. form.name = d.name || ''
  198. form.idNo = d.idNum || ''
  199. uni.hideLoading()
  200. } catch (e) {
  201. uni.hideLoading()
  202. uni.showToast({ title: '识别失败,请手动填写', icon: 'none' })
  203. }
  204. }
  205. // OCR识别身份证反面
  206. const doOcrBack = async (imageUrl) => {
  207. uni.showLoading({ title: '识别中...' })
  208. try {
  209. const res = await post('/common/ocr/idcard', { imageUrl, cardSide: 'BACK' })
  210. form.authority = res.data.authority || ''
  211. form.validity = res.data.validDate || ''
  212. uni.hideLoading()
  213. } catch (e) {
  214. uni.hideLoading()
  215. }
  216. }
  217. const chooseImage = (type) => {
  218. uni.chooseImage({
  219. count: 1,
  220. sizeType: ['compressed'],
  221. sourceType: ['album', 'camera'],
  222. success: async (res) => {
  223. const tempPath = res.tempFilePaths[0]
  224. try {
  225. const url = await uploadImage(tempPath)
  226. if (type === 'front') {
  227. form.frontImage = url
  228. await doOcrFront(url)
  229. } else if (type === 'back') {
  230. form.backImage = url
  231. await doOcrBack(url)
  232. } else if (type === 'child') {
  233. form.childImage = url
  234. }
  235. } catch (e) {
  236. // 上传失败已提示
  237. }
  238. }
  239. })
  240. }
  241. const sendSms = async () => {
  242. if (countdown.value > 0) return
  243. if (!form.phone || form.phone.length !== 11) {
  244. uni.showToast({ title: '请输入正确的手机号码', icon: 'none' })
  245. return
  246. }
  247. try {
  248. const res = await post('/api/mp/sendSmsCode', { mobile: form.phone, bizType: 'real_name_auth' })
  249. // 开发环境可能返回验证码
  250. if (res.data?.code) {
  251. uni.showToast({ title: `验证码: ${res.data.code}`, icon: 'none', duration: 3000 })
  252. } else {
  253. uni.showToast({ title: '验证码已发送', icon: 'none' })
  254. }
  255. countdown.value = 60
  256. timer = setInterval(() => {
  257. countdown.value--
  258. if (countdown.value <= 0) { clearInterval(timer); timer = null }
  259. }, 1000)
  260. } catch (e) {
  261. if (e && e.msg) uni.showToast({ title: e.msg, icon: 'none' })
  262. }
  263. }
  264. const handleSubmit = async () => {
  265. if (submitting.value) return
  266. // 前端校验
  267. const idCardType = certTypeMap[certType.value]
  268. if (idCardType === 2) {
  269. if (!form.childImage) return uni.showToast({ title: '请上传免冠照片', icon: 'none' })
  270. } else {
  271. if (!form.frontImage) return uni.showToast({ title: '请上传证件人像面', icon: 'none' })
  272. if (!form.backImage) return uni.showToast({ title: '请上传证件国徽面', icon: 'none' })
  273. }
  274. if (!form.name) return uni.showToast({ title: '请输入证件姓名', icon: 'none' })
  275. if (!form.idNo) return uni.showToast({ title: '请输入证件号码', icon: 'none' })
  276. // 身份证格式校验
  277. if (!validateIdCard(form.idNo)) return uni.showToast({ title: '身份证号格式不正确', icon: 'none' })
  278. // 从身份证号解析性别和生日(提交时统一解析,确保一定有值)
  279. if (form.idNo.length === 18) {
  280. const genderNum = parseInt(form.idNo.charAt(16))
  281. form.gender = genderNum % 2 === 1 ? '男' : '女'
  282. form.birthday = `${form.idNo.substring(6, 10)}-${form.idNo.substring(10, 12)}-${form.idNo.substring(12, 14)}`
  283. }
  284. if (!form.phone || form.phone.length !== 11) return uni.showToast({ title: '请输入正确的手机号', icon: 'none' })
  285. if (!form.smsCode || form.smsCode.length !== 6) return uni.showToast({ title: '请输入6位验证码', icon: 'none' })
  286. await doSubmit(false)
  287. }
  288. const doSubmit = async (confirmBind) => {
  289. showBindPopup.value = false
  290. submitting.value = true
  291. try {
  292. await post('/api/mp/authSubmit', {
  293. idCardType: certTypeMap[certType.value],
  294. idCardFront: form.frontImage,
  295. idCardBack: form.backImage,
  296. photo: form.childImage,
  297. realName: form.name,
  298. idCard: form.idNo,
  299. gender: form.gender,
  300. birthday: form.birthday,
  301. issuingAuthority: form.authority,
  302. validPeriod: form.validity,
  303. mobile: form.phone,
  304. code: form.smsCode,
  305. confirmBind: confirmBind || undefined
  306. })
  307. // 刷新用户信息缓存
  308. try {
  309. const userRes = await get('/api/mp/userinfo')
  310. setUserInfo(userRes.data)
  311. } catch (e) {}
  312. uni.showToast({ title: '认证成功', icon: 'success' })
  313. setTimeout(() => { uni.navigateBack() }, 1500)
  314. } catch (e) {
  315. if (e && e.code === 1010) {
  316. // 患者已存在,弹出确认绑定弹窗
  317. bindPatientInfo.value = {
  318. name: e.data?.patientName || '',
  319. phone: e.data?.patientPhone || ''
  320. }
  321. showBindPopup.value = true
  322. } else if (e && e.msg) {
  323. uni.showToast({ title: e.msg, icon: 'none' })
  324. }
  325. } finally {
  326. submitting.value = false
  327. }
  328. }
  329. </script>
  330. <style lang="scss" scoped>
  331. .page {
  332. min-height: 100vh;
  333. background: #f4f4f5;
  334. padding-bottom: 200rpx;
  335. }
  336. .page-content {
  337. padding: 32rpx;
  338. }
  339. .page-title {
  340. font-size: 36rpx;
  341. font-weight: 600;
  342. color: #303133;
  343. margin-bottom: 32rpx;
  344. }
  345. .section {
  346. background: #fff;
  347. border-radius: 8rpx;
  348. padding: 40rpx 32rpx;
  349. margin-bottom: 24rpx;
  350. border: 1rpx solid #ebeef5;
  351. }
  352. .section-header {
  353. display: flex;
  354. align-items: center;
  355. flex-wrap: wrap;
  356. gap: 12rpx;
  357. margin-bottom: 8rpx;
  358. }
  359. .step-num {
  360. width: 44rpx;
  361. height: 44rpx;
  362. border-radius: 50%;
  363. background: #0e63e3;
  364. color: #fff;
  365. font-size: 24rpx;
  366. font-weight: 600;
  367. display: flex;
  368. align-items: center;
  369. justify-content: center;
  370. flex-shrink: 0;
  371. }
  372. .label {
  373. font-size: 32rpx;
  374. font-weight: 600;
  375. color: #303133;
  376. }
  377. .tag {
  378. font-size: 22rpx;
  379. border-radius: 8rpx;
  380. padding: 4rpx 16rpx;
  381. &.warn {
  382. color: #e6a23c;
  383. background: #fdf6ec;
  384. border: 1rpx solid #faecd8;
  385. }
  386. &.info {
  387. color: #3a85f0;
  388. background: #e8f0fe;
  389. border: 1rpx solid #c6d9f7;
  390. }
  391. }
  392. .section-desc {
  393. font-size: 26rpx;
  394. color: #909399;
  395. margin: 12rpx 0 28rpx 56rpx;
  396. }
  397. .cert-tabs {
  398. display: flex;
  399. gap: 0;
  400. margin-bottom: 32rpx;
  401. }
  402. .cert-tab {
  403. flex: 1;
  404. display: flex;
  405. align-items: center;
  406. justify-content: center;
  407. padding: 16rpx 0;
  408. border: 1rpx solid #dcdfe6;
  409. font-size: 26rpx;
  410. color: #606266;
  411. background: #fff;
  412. white-space: nowrap;
  413. margin-left: -1rpx;
  414. &:first-child {
  415. border-radius: 8rpx 0 0 8rpx;
  416. margin-left: 0;
  417. }
  418. &:last-child {
  419. border-radius: 0 8rpx 8rpx 0;
  420. }
  421. &.active {
  422. color: #0e63e3;
  423. border-color: #0e63e3;
  424. background: #e8f0fe;
  425. position: relative;
  426. z-index: 1;
  427. }
  428. }
  429. .upload-area {
  430. display: flex;
  431. gap: 24rpx;
  432. }
  433. .upload-item {
  434. flex: 1;
  435. display: flex;
  436. flex-direction: column;
  437. align-items: center;
  438. }
  439. .upload-img {
  440. width: 100%;
  441. height: 200rpx;
  442. border-radius: 12rpx;
  443. }
  444. .upload-label {
  445. font-size: 24rpx;
  446. color: #606266;
  447. margin-top: 12rpx;
  448. }
  449. .child-upload {
  450. margin-top: 24rpx;
  451. }
  452. .child-tip {
  453. font-size: 26rpx;
  454. color: #303133;
  455. margin-bottom: 8rpx;
  456. .tip-highlight {
  457. color: #0e63e3;
  458. margin-left: 10rpx;
  459. }
  460. }
  461. .child-desc {
  462. font-size: 22rpx;
  463. color: #909399;
  464. margin-bottom: 20rpx;
  465. }
  466. .child-photo-box {
  467. width: 200rpx;
  468. height: 200rpx;
  469. border-radius: 12rpx;
  470. overflow: hidden;
  471. }
  472. .child-photo-img {
  473. width: 100%;
  474. height: 100%;
  475. }
  476. .child-photo-placeholder {
  477. width: 100%;
  478. height: 100%;
  479. background: #f5f5f5;
  480. display: flex;
  481. align-items: center;
  482. justify-content: center;
  483. }
  484. .form-row {
  485. display: flex;
  486. align-items: center;
  487. padding: 28rpx 0;
  488. border-bottom: 1rpx solid #f2f6fc;
  489. &.border-none {
  490. border-bottom: none;
  491. }
  492. }
  493. .form-label {
  494. width: 160rpx;
  495. font-size: 28rpx;
  496. color: #303133;
  497. flex-shrink: 0;
  498. }
  499. .form-input {
  500. flex: 1;
  501. font-size: 28rpx;
  502. color: #303133;
  503. }
  504. .phone-row {
  505. margin-top: 20rpx;
  506. }
  507. .phone-input {
  508. width: 100%;
  509. height: 80rpx;
  510. border: 1rpx solid #dcdfe6;
  511. border-radius: 8rpx;
  512. padding: 0 24rpx;
  513. font-size: 28rpx;
  514. box-sizing: border-box;
  515. }
  516. .sms-row {
  517. display: flex;
  518. align-items: center;
  519. gap: 20rpx;
  520. margin-top: 20rpx;
  521. }
  522. .sms-input {
  523. flex: 1;
  524. height: 80rpx;
  525. border: 1rpx solid #dcdfe6;
  526. border-radius: 8rpx;
  527. padding: 0 24rpx;
  528. font-size: 28rpx;
  529. box-sizing: border-box;
  530. }
  531. .sms-btn {
  532. height: 80rpx;
  533. line-height: 80rpx;
  534. padding: 0 28rpx;
  535. background: #0e63e3;
  536. color: #fff;
  537. border: none;
  538. border-radius: 8rpx;
  539. font-size: 26rpx;
  540. white-space: nowrap;
  541. &::after {
  542. border: none;
  543. }
  544. &:active {
  545. opacity: 0.85;
  546. }
  547. &[disabled] {
  548. background: #a0cfff;
  549. color: #fff;
  550. }
  551. }
  552. .submit-bar {
  553. position: fixed;
  554. bottom: 0;
  555. left: 0;
  556. right: 0;
  557. padding: 24rpx 32rpx;
  558. padding-bottom: calc(40rpx + env(safe-area-inset-bottom));
  559. background: #fff;
  560. border-top: 1rpx solid #ebeef5;
  561. z-index: 10;
  562. }
  563. .submit-btn {
  564. width: 100%;
  565. height: 88rpx;
  566. line-height: 88rpx;
  567. background: #0e63e3;
  568. color: #fff;
  569. border: none;
  570. border-radius: 44rpx;
  571. font-size: 32rpx;
  572. font-weight: 500;
  573. box-shadow: 0 8rpx 24rpx rgba(14, 99, 227, 0.3);
  574. &::after {
  575. border: none;
  576. }
  577. &:active {
  578. opacity: 0.85;
  579. }
  580. }
  581. .confirm-popup {
  582. padding: 48rpx 40rpx 40rpx;
  583. width: 560rpx;
  584. }
  585. .confirm-title {
  586. font-size: 34rpx;
  587. font-weight: 600;
  588. color: #333;
  589. text-align: center;
  590. margin-bottom: 28rpx;
  591. }
  592. .confirm-content {
  593. font-size: 28rpx;
  594. color: #666;
  595. line-height: 1.7;
  596. text-align: center;
  597. margin-bottom: 40rpx;
  598. }
  599. .confirm-btns {
  600. display: flex;
  601. gap: 24rpx;
  602. }
  603. </style>