選択できるのは25トピックまでです。 トピックは、先頭が英数字で、英数字とダッシュ('-')を使用した35文字以内のものにしてください。
 
 
 
 

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