index.vue 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626
  1. <template>
  2. <div class="login">
  3. <van-nav-bar title="自然人开票" />
  4. <div v-if="restoringSession" class="login-content">
  5. <van-loading vertical>正在恢复登录状态…</van-loading>
  6. </div>
  7. <div v-else-if="userStore.access_token && formData.pushRecordId" class="login-content">
  8. <div class="text">暂时无法恢复开票流程,请重试。</div>
  9. <div class="login-btn btn" @click="initializeLogin">重试</div>
  10. </div>
  11. <div v-else class="login-content">
  12. <div class="text">感谢您参加{{ pubName }}项目,请您登录系统开具发票。</div>
  13. <div class="bold-text">已注册电子税务局</div>
  14. <div class="form">
  15. <!-- 手机号输入 -->
  16. <van-cell-group inset class="grop">
  17. <van-field
  18. class="input"
  19. v-model="formData.mobile"
  20. placeholder="请输入手机号"
  21. clearable
  22. type="tel"
  23. maxlength="30"
  24. @update:model-value="onInputMobile"
  25. />
  26. </van-cell-group>
  27. <!-- 密码 -->
  28. <van-cell-group inset class="grop">
  29. <van-field
  30. class="input"
  31. v-model="formData.password"
  32. placeholder="请输入密码"
  33. clearable
  34. :type="passwordType"
  35. maxlength="30"
  36. >
  37. <template #right-icon>
  38. <span @click="switchPasswordType">
  39. <van-icon :name="passwordType === 'password' ? 'eye' : 'closed-eye'" />
  40. </span>
  41. </template>
  42. </van-field>
  43. </van-cell-group>
  44. <!-- 验证码输入 + 按钮 -->
  45. <van-cell-group inset class="grop">
  46. <van-field
  47. class="input code-input"
  48. v-model="formData.code"
  49. placeholder="请输入验证码"
  50. clearable
  51. type="tel"
  52. maxlength="6"
  53. @update:model-value="onInputCode"
  54. />
  55. <button class="code-btn" :disabled="isCounting || !formData.mobile" @click="sendCode">
  56. {{ isCounting ? countDown + 's后重发' : '获取验证码' }}
  57. </button>
  58. </van-cell-group>
  59. <div class="tips">如忘记密码,请下载电子税务局APP进行密码找回</div>
  60. <!-- 同意协议 -->
  61. <div class="agree">
  62. <label>
  63. <van-checkbox v-model="agree" checked-color="#0072f8" shape="square" icon-size="4vw">
  64. 我已阅读并同意
  65. <a @click.self.stop="toAgreement('auth')">《授权协议》</a>
  66. <a @click.self.stop="toAgreement('privacy')">《隐私权政策》</a>
  67. </van-checkbox>
  68. </label>
  69. </div>
  70. <!-- 登录按钮 -->
  71. <div class="login-btn btn" @click="loginBtn">登录</div>
  72. </div>
  73. <div class="bold-text">未注册电子税务局</div>
  74. <div class="register-btn btn" @click="registerUrl">立即注册</div>
  75. <div class="footer">客服电话:400-639-1811</div>
  76. <div class="footer">技术支持:要易云(北京)科技有限公司</div>
  77. <!-- 失败弹窗 -->
  78. <ModernDialog
  79. v-model:show="showDialog"
  80. title="提示"
  81. :message="codeMessage"
  82. cancelText="返回继续登录"
  83. @cancel="onCancel"
  84. />
  85. <!-- 注册弹窗 -->
  86. <ModernDialog
  87. v-model:show="loginDialog"
  88. title="提示"
  89. :message="loginMessage"
  90. cancelText="返回"
  91. @cancel="loginCancel"
  92. confirmText="立即注册"
  93. @confirm="loginConfirm"
  94. />
  95. <!-- 修改密码弹窗 -->
  96. <ModernDialog
  97. v-model:show="changePasswordDialog"
  98. title="提示"
  99. message="您的登录被税局识别到存在登录异地税局风险,请下载“电子税务局”APP并完成修改密码再次返回进行登录。"
  100. cancelText="我知道了"
  101. @cancel="changePasswordDCancel"
  102. />
  103. </div>
  104. </div>
  105. </template>
  106. <script setup lang="ts">
  107. import { ref, reactive, watch } from 'vue'
  108. import { useRouter, useRoute } from 'vue-router'
  109. import { showFailToast, showSuccessToast, showToast, showLoadingToast } from 'vant'
  110. import {
  111. getPubNameApi,
  112. sendEtsSmsApi,
  113. loginEtssmsApi,
  114. registerUrlApi,
  115. } from '@/services/modules/login'
  116. import { getStatusApi } from '@/services/modules/invoiceInformation'
  117. import type {
  118. SendTtsSmsRequest,
  119. LoginEtssmsRequest,
  120. RegisterUrlRequest,
  121. } from '@/services/modules/login/type.d'
  122. import { useUserStore } from '@/stores/modules/user'
  123. import { useDebounceFn } from '@/utils/util'
  124. import { REGISTER_URL_KEY } from '@/constants/storage'
  125. import ModernDialog from '@/components/ModernDialog.vue'
  126. const userStore = useUserStore()
  127. const router = useRouter()
  128. const route = useRoute()
  129. const restoringSession = ref(false)
  130. const passwordType = ref<'text' | 'password'>('password')
  131. const switchPasswordType = () => {
  132. passwordType.value = passwordType.value === 'password' ? 'text' : 'password'
  133. }
  134. /* 表单数据 */
  135. const formData = reactive<LoginEtssmsRequest>({
  136. mobile: '',
  137. code: '',
  138. pushRecordId: '',
  139. password: '',
  140. })
  141. const sendEtsSms = reactive<SendTtsSmsRequest>({
  142. mobile: '',
  143. pushRecordId: '',
  144. password: '',
  145. })
  146. /* 限制手机号只能输入数字,最多 30 位 */
  147. const onInputMobile = (val: string) => {
  148. formData.mobile = val.replace(/\D/g, '').slice(0, 30)
  149. }
  150. /* 限制验证码只能输入数字,最多 6 位 */
  151. const onInputCode = (val: string) => {
  152. formData.code = val.replace(/\D/g, '').slice(0, 6)
  153. }
  154. /* 是否同意协议 */
  155. const agree = ref(false)
  156. /* 验证码按钮逻辑 */
  157. const isCounting = ref(false)
  158. const countDown = ref(60)
  159. let timer: number | undefined
  160. const sending = ref(false)
  161. const showDialog = ref(false)
  162. const codeMessage = ref()
  163. const onCancel = () => {
  164. showDialog.value = false
  165. }
  166. const changePasswordDialog = ref(false)
  167. const changePasswordDCancel = () => {
  168. changePasswordDialog.value = false
  169. }
  170. /* 发送验证码 */
  171. const sendCode = async () => {
  172. if (sending.value) return
  173. if (!/^1\d{10}$/.test(formData.mobile)) {
  174. showToast('请输入正确的手机号')
  175. return
  176. }
  177. if (!formData.password) {
  178. showToast('密码不能为空')
  179. return
  180. }
  181. sending.value = true
  182. const toast = showLoadingToast({ message: '发送中…', forbidClick: true, duration: 3000 })
  183. sendEtsSms.mobile = formData.mobile
  184. sendEtsSms.password = formData.password
  185. try {
  186. const res = await sendEtsSmsApi(sendEtsSms)
  187. if (res.code === 0 && res.data) {
  188. showSuccessToast('发送成功')
  189. startCountDown()
  190. } else {
  191. codeMessage.value = res.msg
  192. showDialog.value = true
  193. }
  194. toast.close()
  195. } catch (err: any) {
  196. toast.close()
  197. const { code, message } = err
  198. if (code === 401) {
  199. loginMessage.value = message
  200. loginDialog.value = true
  201. return
  202. }
  203. showFailToast(err?.message || '获取验证码失败')
  204. } finally {
  205. sending.value = false
  206. }
  207. }
  208. /* 倒计时逻辑 */
  209. const startCountDown = () => {
  210. isCounting.value = true
  211. countDown.value = 60
  212. timer = window.setInterval(() => {
  213. countDown.value--
  214. if (countDown.value <= 0) {
  215. isCounting.value = false
  216. clearInterval(timer)
  217. }
  218. }, 1000)
  219. }
  220. const loginDialog = ref(false)
  221. const loginMessage = ref('')
  222. const loginCancel = () => {
  223. loginDialog.value = false
  224. }
  225. const loginConfirm = () => {
  226. registerUrl()
  227. }
  228. // 首次登录与缓存恢复使用同一套业务状态判断,避免已提交业务重新进入开票页。
  229. const continueInvoice = async (pushRecordId: string) => {
  230. const token = userStore.access_token
  231. const statusRes = await getStatusApi({ pushRecordId })
  232. if (
  233. route.name !== 'Login' ||
  234. userStore.pushRecordId !== pushRecordId ||
  235. userStore.access_token !== token ||
  236. !userStore.hasValidSession()
  237. )
  238. return
  239. if (Number(statusRes.code) !== 0 || !statusRes.data) {
  240. throw new Error(statusRes.msg || '获取开票状态失败,请重试')
  241. }
  242. const isSubmitStatusMap = ['PENDING', 'FINISHED', 'RED_INK_ENTRY', 'NOT_APPROVED', 'DISCARD']
  243. const isSubmitEventStatusMap = [
  244. 'SUBMITTED',
  245. 'PENDING_PAYMENT',
  246. 'PAID',
  247. 'PENDING_FINAL_INVOICING',
  248. 'INVOICE_FAILED',
  249. 'FINAL_INVOICE_SUBMITTED',
  250. 'INVOICE_DOWNLOADABLE',
  251. ]
  252. const { invoiceStatus, eventStatus } = statusRes.data
  253. const submitted =
  254. isSubmitStatusMap.includes(invoiceStatus) && isSubmitEventStatusMap.includes(eventStatus)
  255. await router.replace({ path: submitted ? '/login-success' : '/invoice-information' })
  256. }
  257. /* 登录逻辑 */
  258. const loginBtn = useDebounceFn(async () => {
  259. if (!formData.mobile) {
  260. showFailToast('请输入手机号')
  261. return
  262. }
  263. if (!formData.password) {
  264. showFailToast('密码不能为空')
  265. return
  266. }
  267. if (!formData.code) {
  268. showFailToast('验证码不能为空')
  269. return
  270. }
  271. if (!agree.value) {
  272. showFailToast('请勾选阅读并同意')
  273. return
  274. }
  275. const toast = showLoadingToast({
  276. message: '登录中…',
  277. forbidClick: true,
  278. duration: 0,
  279. })
  280. const pushRecordId = formData.pushRecordId
  281. try {
  282. const res: any = await loginEtssmsApi(formData)
  283. if (route.name !== 'Login' || userStore.pushRecordId !== pushRecordId) return
  284. if (res?.access_token) {
  285. userStore.setAccessToken(res.access_token, res.expires_in)
  286. formData.password = ''
  287. formData.code = ''
  288. sendEtsSms.password = ''
  289. if (await getPubName(pushRecordId)) await continueInvoice(pushRecordId)
  290. } else if (res.code == 401) {
  291. loginMessage.value = res.msg
  292. loginDialog.value = true
  293. } else if (res.code == 402) {
  294. changePasswordDialog.value = true
  295. }
  296. } catch (err: any) {
  297. showFailToast(err?.message || '登录失败,请重试')
  298. } finally {
  299. toast.close()
  300. }
  301. }, 800)
  302. // 获取注册链接
  303. const registerUrlParams = ref<RegisterUrlRequest>({
  304. areaId: '',
  305. returnUrl: '',
  306. pushRecordId: '',
  307. })
  308. const registerUrl = useDebounceFn(async () => {
  309. try {
  310. const res = await registerUrlApi(registerUrlParams.value)
  311. if (res.code === 0 && res.data.url) {
  312. // 保存一次性链接
  313. sessionStorage.setItem(REGISTER_URL_KEY, res.data.url as string)
  314. return router.push({ path: 'register' })
  315. }
  316. showFailToast('获取注册链接失败')
  317. } catch (err: any) {
  318. showFailToast(err?.message || '获取注册链接失败')
  319. }
  320. }, 800)
  321. // 项目名称
  322. const pubName = ref('')
  323. const getPubName = async (pushRecordId: string) => {
  324. try {
  325. const res = await getPubNameApi({
  326. pushRecordId,
  327. })
  328. if (route.name !== 'Login' || userStore.pushRecordId !== pushRecordId) return false
  329. if (res.code === 0) {
  330. pubName.value = res.data.projectName
  331. registerUrlParams.value.areaId = res.data.cityCode
  332. userStore.setNeedFaceId(res.data.needFaceId)
  333. registerUrlParams.value.pushRecordId = pushRecordId
  334. return true
  335. } else {
  336. codeMessage.value = res.msg
  337. showDialog.value = true
  338. if (userStore.access_token) showFailToast(res.msg || '获取项目信息失败,请重试')
  339. }
  340. } catch (err: any) {
  341. showFailToast(err?.message || '获取项目信息失败,请重试')
  342. }
  343. return false
  344. }
  345. /* 协议跳转 */
  346. const toAgreement = (type: string) => {
  347. router.push({
  348. path: '/agreement',
  349. query: { type },
  350. })
  351. }
  352. const resetLoginState = () => {
  353. formData.mobile = ''
  354. formData.password = ''
  355. formData.code = ''
  356. sendEtsSms.password = ''
  357. agree.value = false
  358. isCounting.value = false
  359. countDown.value = 60
  360. sending.value = false
  361. showDialog.value = false
  362. loginDialog.value = false
  363. changePasswordDialog.value = false
  364. if (timer) {
  365. clearInterval(timer)
  366. timer = undefined
  367. }
  368. }
  369. let initializationId = 0
  370. const initializeLogin = async () => {
  371. if (route.name !== 'Login') return
  372. const currentInitialization = ++initializationId
  373. if (route.query.from === 'reject') {
  374. resetLoginState()
  375. return router.replace({
  376. path: route.path,
  377. query: {
  378. pushRecordId: userStore.pushRecordId,
  379. },
  380. })
  381. }
  382. const pushRecordId =
  383. route.query.pushRecordId === undefined
  384. ? userStore.pushRecordId
  385. : typeof route.query.pushRecordId === 'string'
  386. ? route.query.pushRecordId
  387. : ''
  388. if (!pushRecordId) {
  389. formData.pushRecordId = ''
  390. sendEtsSms.pushRecordId = ''
  391. showToast('登录链接存在不完整或者输入错误')
  392. return
  393. }
  394. if (formData.pushRecordId !== pushRecordId) resetLoginState()
  395. userStore.setPushRecordId(pushRecordId)
  396. sendEtsSms.pushRecordId = pushRecordId
  397. formData.pushRecordId = pushRecordId
  398. registerUrlParams.value.returnUrl = `${location.origin}${import.meta.env.BASE_URL}login?pushRecordId=${encodeURIComponent(pushRecordId)}`
  399. restoringSession.value = userStore.hasValidSession()
  400. try {
  401. const ready = await getPubName(pushRecordId)
  402. if (currentInitialization !== initializationId) return
  403. if (ready && userStore.hasValidSession()) await continueInvoice(pushRecordId)
  404. } catch (err: any) {
  405. // 网络异常保留 token 供重试;真正的失效由请求拦截器统一清理。
  406. showFailToast(err?.message || '恢复登录状态失败,请重试')
  407. } finally {
  408. if (currentInitialization === initializationId) restoringSession.value = false
  409. }
  410. }
  411. // KeepAlive 下同一路由的新链接、协议返回及失效回跳都需要重新检查。
  412. watch(
  413. () => {
  414. return route.name === 'Login' ? route.fullPath : ''
  415. },
  416. () => {
  417. void initializeLogin()
  418. },
  419. { immediate: true },
  420. )
  421. </script>
  422. <style lang="scss" scoped>
  423. .login {
  424. min-height: 100vh;
  425. display: flex;
  426. flex-direction: column;
  427. background: linear-gradient(180deg, #ffffff 0%, #f5f7fb 100%);
  428. overflow-x: hidden;
  429. .van-nav-bar {
  430. background-color: #fff;
  431. border-bottom: 1px solid #f2f2f2;
  432. font-weight: 600;
  433. }
  434. .login-content {
  435. flex: 1;
  436. padding: 6vw 6vw 12vw;
  437. display: flex;
  438. flex-direction: column;
  439. align-items: center;
  440. .text {
  441. width: 90%;
  442. font-size: 4vw;
  443. text-align: center;
  444. line-height: 6vw;
  445. color: #333;
  446. margin-top: 8vw;
  447. margin-bottom: 6vw;
  448. }
  449. .bold-text {
  450. width: 100%;
  451. font-weight: 700;
  452. font-size: 4.5vw;
  453. text-align: left;
  454. color: #222;
  455. margin: 4vw 0 2vw;
  456. }
  457. .form {
  458. width: 96%;
  459. margin-top: 20px;
  460. background: #fff;
  461. border-radius: 3vw;
  462. box-shadow: 0 4px 20px rgba(0, 0, 0, 0.05);
  463. padding: 6vw 4vw;
  464. margin-bottom: 8vw;
  465. .grop {
  466. margin-top: 3vw;
  467. display: flex;
  468. align-items: center;
  469. justify-content: space-between;
  470. }
  471. .input {
  472. width: 100%;
  473. border-radius: 6vw;
  474. border: 1px solid #e5e5e5;
  475. padding-left: 5vw;
  476. font-size: 3.8vw;
  477. background: #fff;
  478. transition: 0.3s;
  479. &:focus {
  480. border-color: #0072f8;
  481. box-shadow: 0 0 8px rgba(254, 120, 61, 0.3);
  482. }
  483. }
  484. .code-input {
  485. width: 55%;
  486. }
  487. .code-btn {
  488. background: linear-gradient(90deg, #0072f8 0%, #3a9fff 100%);
  489. border: none;
  490. border-radius: 25px;
  491. height: 9vw;
  492. width: 30%;
  493. color: #fff;
  494. font-weight: 600;
  495. font-size: 3.4vw;
  496. cursor: pointer;
  497. transition: all 0.3s;
  498. &:active {
  499. opacity: 0.85;
  500. }
  501. &:disabled {
  502. background: #ccc;
  503. cursor: not-allowed;
  504. }
  505. }
  506. .tips {
  507. font-size: 3vw;
  508. color: #999;
  509. margin-top: 3vw;
  510. text-align: center;
  511. }
  512. .agree {
  513. text-align: left;
  514. font-size: 3vw;
  515. line-height: 5vw;
  516. margin: 4vw 0;
  517. color: #666;
  518. padding-left: 36px;
  519. .custom-checkbox {
  520. accent-color: #0072f8;
  521. }
  522. label {
  523. display: flex;
  524. align-items: center;
  525. flex-wrap: wrap;
  526. input {
  527. width: 3.5vw;
  528. height: 3.5vw;
  529. margin-right: 6px;
  530. }
  531. }
  532. a {
  533. color: #0072f8;
  534. text-decoration: none;
  535. margin-left: 2px;
  536. }
  537. }
  538. }
  539. .btn {
  540. box-sizing: border-box;
  541. width: 80%;
  542. height: 12vw;
  543. border-radius: 6vw;
  544. color: #fff;
  545. font-size: 4vw;
  546. line-height: 12vw;
  547. text-align: center;
  548. font-weight: 600;
  549. cursor: pointer;
  550. transition: all 0.3s ease;
  551. margin: 4vw 0;
  552. &:active {
  553. transform: scale(0.98);
  554. }
  555. }
  556. .login-btn {
  557. margin: 0 auto;
  558. background: linear-gradient(90deg, #0072f8 0%, #3a9fff 100%);
  559. box-shadow: 0 4px 12px rgba(0, 114, 248, 0.25);
  560. }
  561. .register-btn {
  562. background: linear-gradient(90deg, #ff8036 0%, #ffa253 100%);
  563. box-shadow: 0 4px 12px rgba(255, 128, 54, 0.3);
  564. }
  565. .footer {
  566. width: 100%;
  567. text-align: center;
  568. color: #999;
  569. font-size: 3vw;
  570. margin-top: 25px;
  571. }
  572. }
  573. }
  574. </style>