reset-password.vue 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477
  1. <template>
  2. <view class="acct-pwd-page">
  3. <view class="acct-pwd-nav" :style="{ paddingTop: statusBar + 'px' }">
  4. <view class="acct-pwd-nav-row">
  5. <view class="acct-pwd-back" @tap="goBack">
  6. <text class="acct-pwd-back-icon">←</text>
  7. </view>
  8. <text class="acct-pwd-nav-title">{{ copy.title }}</text>
  9. <view class="acct-pwd-nav-placeholder" />
  10. </view>
  11. </view>
  12. <scroll-view
  13. scroll-y
  14. class="acct-pwd-scroll"
  15. :style="{ height: scrollHeight + 'px' }"
  16. >
  17. <view class="acct-pwd-body" :style="{ paddingBottom: safeBottom + 'px' }">
  18. <view class="acct-pwd-hero">
  19. <view class="acct-pwd-hero-icon-wrap">
  20. <text class="acct-pwd-hero-icon">🔐</text>
  21. </view>
  22. <text class="acct-pwd-hero-title">{{ copy.heroTitle }}</text>
  23. <text class="acct-pwd-hero-desc">{{ copy.heroDesc }}</text>
  24. </view>
  25. <view class="acct-pwd-card">
  26. <text class="acct-pwd-label">{{ copy.handsetLabel }}</text>
  27. <view class="acct-pwd-phone-row">
  28. <region-picker
  29. v-model="region"
  30. class="acct-pwd-region"
  31. :disabled="phoneLocked"
  32. :title="copy.regionTitle"
  33. :search-placeholder="copy.regionSearch"
  34. :empty-text="copy.regionEmpty"
  35. />
  36. <input
  37. v-model="handset"
  38. class="acct-pwd-input"
  39. :disabled="phoneLocked"
  40. :placeholder="copy.handsetPlaceholder"
  41. placeholder-class="acct-pwd-input__placeholder"
  42. type="number"
  43. maxlength="15"
  44. @input="onHandsetInput"
  45. />
  46. </view>
  47. <text class="acct-pwd-hint">{{ copy.fieldHint }}</text>
  48. <text v-if="phoneLocked" class="acct-pwd-locked-tip">{{ copy.lockedTip }}</text>
  49. </view>
  50. <button
  51. class="acct-pwd-submit"
  52. :disabled="!canSubmit || probing"
  53. :loading="probing"
  54. :class="{ 'acct-pwd-submit--off': !canSubmit, 'acct-pwd-submit--busy': probing }"
  55. @tap="onNext"
  56. >
  57. <text class="acct-pwd-submit__text">{{ probing ? copy.probing : copy.next }}</text>
  58. </button>
  59. <view class="acct-pwd-help">
  60. <text class="acct-pwd-help__title">{{ copy.helpTitle }}</text>
  61. <text class="acct-pwd-help__line">· {{ copy.help1 }}</text>
  62. <text class="acct-pwd-help__line">· {{ copy.help2 }}</text>
  63. </view>
  64. </view>
  65. </scroll-view>
  66. </view>
  67. </template>
  68. <script>
  69. import { mapState } from 'vuex'
  70. import RegionPicker from '@/components/passport-kit/region-picker.vue'
  71. import { requestPasscode, verifyHandsetUsed } from '@/features/passport/credential-bridge'
  72. import { looksLikeHandset, trimHandset } from '@/features/passport/form-rules'
  73. import { DEFAULT_REGION } from '@/features/passport/region-dialcodes'
  74. export default {
  75. components: { RegionPicker },
  76. data() {
  77. return {
  78. statusBar: 0,
  79. scrollHeight: 600,
  80. safeBottom: 0,
  81. handset: '',
  82. region: DEFAULT_REGION,
  83. phoneLocked: false,
  84. probing: false,
  85. redirect: ''
  86. }
  87. },
  88. computed: {
  89. ...mapState('user', {
  90. profile: (state) => state
  91. }),
  92. copy() {
  93. const t = this.$t.bind(this)
  94. return {
  95. title: t('user.resetPwdTitle'),
  96. heroTitle: t('user.resetPwdHeroTitle'),
  97. heroDesc: t('user.resetPwdHeroDesc'),
  98. handsetLabel: t('passport.label-handset-reg'),
  99. handsetPlaceholder: t('passport.ph-handset-recover'),
  100. fieldHint: t('passport.field-hint-region'),
  101. lockedTip: t('user.resetPwdLockedTip'),
  102. regionTitle: t('passport.region-title'),
  103. regionSearch: t('passport.region-search'),
  104. regionEmpty: t('passport.region-empty'),
  105. next: t('passport.btn-next'),
  106. probing: t('passport.btn-probing'),
  107. helpTitle: t('passport.help-title'),
  108. help1: t('passport.help-1'),
  109. help2: t('passport.help-2')
  110. }
  111. },
  112. canSubmit() {
  113. return looksLikeHandset(this.handset)
  114. }
  115. },
  116. onLoad(query) {
  117. const sys = uni.getSystemInfoSync()
  118. this.statusBar = sys.statusBarHeight || 0
  119. this.safeBottom = sys.safeAreaInsets ? sys.safeAreaInsets.bottom : 0
  120. const navH = this.statusBar + uni.upx2px(88)
  121. this.scrollHeight = (sys.windowHeight || 667) - navH
  122. let handset = ''
  123. let region = ''
  124. if (query && query.handset) {
  125. try {
  126. handset = trimHandset(decodeURIComponent(query.handset))
  127. } catch (_) {
  128. handset = trimHandset(query.handset)
  129. }
  130. }
  131. if (query && query.region) {
  132. try {
  133. region = String(decodeURIComponent(query.region)).replace(/^\+/, '')
  134. } catch (_) {
  135. region = String(query.region).replace(/^\+/, '')
  136. }
  137. }
  138. if (query && query.redirect) {
  139. try {
  140. this.redirect = decodeURIComponent(query.redirect)
  141. } catch (_) {
  142. this.redirect = query.redirect || ''
  143. }
  144. }
  145. if (query && query.lock === '1') {
  146. this.phoneLocked = true
  147. }
  148. if (!handset && this.profile && this.profile.mobile) {
  149. handset = trimHandset(this.profile.mobile)
  150. }
  151. if (!region && this.profile && this.profile.area_code) {
  152. region = String(this.profile.area_code).replace(/^\+/, '')
  153. }
  154. if (handset) this.handset = handset
  155. if (region) this.region = region
  156. if (!this.phoneLocked && this.profile && this.profile.isLogin && looksLikeHandset(this.handset)) {
  157. this.phoneLocked = true
  158. }
  159. },
  160. onShow() {
  161. this.resetSubmitState()
  162. },
  163. methods: {
  164. goBack() {
  165. if (getCurrentPages().length > 1) {
  166. uni.navigateBack({ delta: 1 })
  167. } else {
  168. uni.navigateTo({
  169. url: '/pages/account/security',
  170. fail: () => uni.switchTab({ url: '/pages/mine/index' })
  171. })
  172. }
  173. },
  174. onHandsetInput(e) {
  175. this.handset = trimHandset(e.detail.value)
  176. },
  177. toast(msg) {
  178. if (this.$appKit && this.$appKit.msg) this.$appKit.msg(msg)
  179. else uni.showToast({ title: msg, icon: 'none' })
  180. },
  181. resetSubmitState() {
  182. this.probing = false
  183. try {
  184. uni.hideLoading()
  185. } catch (_) {}
  186. },
  187. buildSmsVerifyUrl(ticketId) {
  188. const params = [
  189. `scene=resetpwd`,
  190. `handset=${encodeURIComponent(this.handset)}`,
  191. `region=${encodeURIComponent(this.region)}`,
  192. `ticketId=${encodeURIComponent(ticketId || '')}`
  193. ]
  194. if (this.redirect) {
  195. params.push(`redirect=${encodeURIComponent(this.redirect)}`)
  196. }
  197. return `/pages/account/sms-verify?${params.join('&')}`
  198. },
  199. goSmsVerify(ticketId) {
  200. return new Promise((resolve) => {
  201. uni.navigateTo({
  202. url: this.buildSmsVerifyUrl(ticketId),
  203. success: () => resolve(true),
  204. fail: () => resolve(false)
  205. })
  206. })
  207. },
  208. async onNext() {
  209. if (this.probing) return
  210. if (!this.canSubmit) {
  211. this.toast(this.$t('passport.err-handset'))
  212. return
  213. }
  214. this.probing = true
  215. uni.showLoading({ title: this.copy.probing, mask: true })
  216. try {
  217. const { taken } = await verifyHandsetUsed({
  218. handset: this.handset,
  219. region: this.region
  220. })
  221. if (!taken) {
  222. this.toast(this.$t('passport.err-not-exist'))
  223. return
  224. }
  225. const { ticketId, needHumanCheck } = await requestPasscode({
  226. scene: 'resetpwd',
  227. handset: this.handset,
  228. region: this.region
  229. })
  230. if (needHumanCheck) {
  231. this.toast(this.$t('passport.tip-need-captcha'))
  232. return
  233. }
  234. const navigated = await this.goSmsVerify(ticketId)
  235. if (!navigated) {
  236. this.toast(this.$t('passport.err-network'))
  237. }
  238. } catch (err) {
  239. const bizHandled = err && (err.kind === 'BUSINESS' || err.kind === 'SILENT')
  240. if (!bizHandled) {
  241. this.toast((err && err.message) || this.$t('passport.err-network'))
  242. }
  243. } finally {
  244. this.resetSubmitState()
  245. }
  246. }
  247. }
  248. }
  249. </script>
  250. <style scoped>
  251. .acct-pwd-page {
  252. min-height: 100vh;
  253. background-color: #f4f6fb;
  254. }
  255. .acct-pwd-nav {
  256. background-color: #ffffff;
  257. border-bottom: 1rpx solid #eef1f6;
  258. }
  259. .acct-pwd-nav-row {
  260. display: flex;
  261. flex-direction: row;
  262. align-items: center;
  263. height: 88rpx;
  264. padding: 0 24rpx;
  265. box-sizing: border-box;
  266. }
  267. .acct-pwd-back {
  268. width: 64rpx;
  269. height: 64rpx;
  270. display: flex;
  271. align-items: center;
  272. justify-content: center;
  273. }
  274. .acct-pwd-back-icon {
  275. font-size: 40rpx;
  276. color: #1a1d26;
  277. line-height: 1;
  278. }
  279. .acct-pwd-nav-title {
  280. flex: 1;
  281. text-align: center;
  282. font-size: 32rpx;
  283. font-weight: 600;
  284. color: #1a1d26;
  285. }
  286. .acct-pwd-nav-placeholder {
  287. width: 64rpx;
  288. }
  289. .acct-pwd-body {
  290. padding: 28rpx 28rpx 40rpx;
  291. box-sizing: border-box;
  292. }
  293. .acct-pwd-hero {
  294. display: flex;
  295. flex-direction: column;
  296. align-items: center;
  297. margin-bottom: 32rpx;
  298. padding: 32rpx 24rpx 8rpx;
  299. }
  300. .acct-pwd-hero-icon-wrap {
  301. width: 120rpx;
  302. height: 120rpx;
  303. border-radius: 60rpx;
  304. background: linear-gradient(145deg, #eef4ff 0%, #e8f0fe 100%);
  305. display: flex;
  306. align-items: center;
  307. justify-content: center;
  308. box-shadow: 0 8rpx 28rpx rgba(74, 140, 255, 0.12);
  309. margin-bottom: 20rpx;
  310. }
  311. .acct-pwd-hero-icon {
  312. font-size: 56rpx;
  313. line-height: 1;
  314. }
  315. .acct-pwd-hero-title {
  316. font-size: 34rpx;
  317. font-weight: 700;
  318. color: #1a1d26;
  319. margin-bottom: 10rpx;
  320. }
  321. .acct-pwd-hero-desc {
  322. font-size: 26rpx;
  323. color: #8a8d9e;
  324. text-align: center;
  325. line-height: 1.5;
  326. padding: 0 16rpx;
  327. }
  328. .acct-pwd-card {
  329. background-color: #ffffff;
  330. border-radius: 24rpx;
  331. padding: 28rpx;
  332. box-shadow: 0 6rpx 24rpx rgba(74, 140, 255, 0.06);
  333. margin-bottom: 28rpx;
  334. }
  335. .acct-pwd-label {
  336. display: block;
  337. font-size: 26rpx;
  338. font-weight: 600;
  339. color: #4b5563;
  340. margin-bottom: 16rpx;
  341. }
  342. .acct-pwd-phone-row {
  343. display: flex;
  344. flex-direction: row;
  345. align-items: stretch;
  346. }
  347. .acct-pwd-region {
  348. flex-shrink: 0;
  349. }
  350. .acct-pwd-input {
  351. flex: 1;
  352. height: 96rpx;
  353. padding: 0 24rpx;
  354. font-size: 30rpx;
  355. color: #1a1d26;
  356. background-color: #f7f9fc;
  357. border: 1rpx solid #e5e9f2;
  358. border-left: none;
  359. border-radius: 0 20rpx 20rpx 0;
  360. box-sizing: border-box;
  361. }
  362. .acct-pwd-input__placeholder {
  363. color: #b8bcc8;
  364. }
  365. .acct-pwd-hint {
  366. display: block;
  367. margin-top: 14rpx;
  368. font-size: 24rpx;
  369. color: #8a8d9e;
  370. line-height: 1.45;
  371. }
  372. .acct-pwd-locked-tip {
  373. display: block;
  374. margin-top: 10rpx;
  375. font-size: 24rpx;
  376. color: #4a8cff;
  377. }
  378. .acct-pwd-submit {
  379. width: 100%;
  380. height: 96rpx;
  381. line-height: 96rpx;
  382. border-radius: 48rpx;
  383. border: none;
  384. background: linear-gradient(135deg, #4a8cff 0%, #6b5cff 100%);
  385. box-shadow: 0 12rpx 32rpx rgba(74, 140, 255, 0.28);
  386. }
  387. .acct-pwd-submit::after {
  388. border: none;
  389. }
  390. .acct-pwd-submit--off {
  391. opacity: 0.45;
  392. box-shadow: none;
  393. }
  394. .acct-pwd-submit--busy {
  395. opacity: 0.88;
  396. }
  397. .acct-pwd-submit__text {
  398. font-size: 32rpx;
  399. font-weight: 600;
  400. color: #ffffff;
  401. }
  402. .acct-pwd-help {
  403. margin-top: 28rpx;
  404. padding: 24rpx 28rpx;
  405. border-radius: 20rpx;
  406. background-color: #ffffff;
  407. box-shadow: 0 4rpx 16rpx rgba(74, 140, 255, 0.04);
  408. }
  409. .acct-pwd-help__title {
  410. display: block;
  411. font-size: 26rpx;
  412. font-weight: 600;
  413. color: #4a8cff;
  414. margin-bottom: 12rpx;
  415. }
  416. .acct-pwd-help__line {
  417. display: block;
  418. font-size: 24rpx;
  419. color: #8a8d9e;
  420. line-height: 1.55;
  421. }
  422. </style>
  423. <style>
  424. /* 区号选择器浅色主题(账号设置流) */
  425. .acct-pwd-phone-row .region-trigger {
  426. background-color: #f7f9fc !important;
  427. border: 1rpx solid #e5e9f2 !important;
  428. border-right: none !important;
  429. border-radius: 20rpx 0 0 20rpx !important;
  430. }
  431. .acct-pwd-phone-row .region-trigger__code {
  432. color: #1a1d26 !important;
  433. }
  434. .acct-pwd-phone-row .region-trigger__caret {
  435. color: #8a8d9e !important;
  436. }
  437. </style>