You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

670 line
22 KiB

  1. <template>
  2. <div
  3. class="fixed inset-0 z-[999] flex items-center justify-center"
  4. role="dialog"
  5. aria-modal="true"
  6. aria-labelledby="login-modal-title"
  7. >
  8. <!-- 遮罩 -->
  9. <div class="absolute inset-0 bg-black/40 backdrop-blur-sm" @click="$emit('close')" />
  10. <!-- 弹窗 -->
  11. <div class="relative w-full max-w-md mx-4 bg-[var(--c-ffffff)] rounded-2xl shadow-2xl overflow-hidden">
  12. <!-- 关闭按钮 -->
  13. <button
  14. type="button"
  15. aria-label="关闭登录弹窗"
  16. class="absolute top-4 right-4 z-10 w-8 h-8 flex items-center justify-center rounded-full text-[var(--c-94a3b8)] hover:text-[var(--c-475569)] hover:bg-[var(--c-f1f5f9)] transition-colors cursor-pointer"
  17. @click="$emit('close')"
  18. >
  19. <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
  20. <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
  21. </svg>
  22. </button>
  23. <!-- 标题 -->
  24. <div class="px-8 pt-8 pb-4 text-center">
  25. <img src="/logo.png" alt="AI在线" class="mx-auto mb-3 h-12 w-12 rounded-xl shadow-[0_6px_16px_var(--sh-11-140-255-220)]">
  26. <h2 id="login-modal-title" class="text-xl font-bold text-[var(--c-172033)]">登录 AI 在线</h2>
  27. <p class="mt-1 text-sm text-[var(--c-64748b)]">解放你的生产力</p>
  28. </div>
  29. <!-- Tab 切换 -->
  30. <div class="px-8">
  31. <div class="flex border-b border-[var(--c-e2e8f0)]">
  32. <button
  33. v-if="!isMobileClient"
  34. :class="[
  35. 'flex-1 pb-3 text-sm font-medium transition-colors border-b-2 cursor-pointer',
  36. activeTab === 'qr'
  37. ? 'text-[var(--c-0b8cff)] border-[var(--c-0b8cff)]'
  38. : 'text-[var(--c-94a3b8)] border-transparent hover:text-[var(--c-475569)]',
  39. ]"
  40. @click="activeTab = 'qr'"
  41. >
  42. 微信扫码登录
  43. </button>
  44. <button
  45. :class="[
  46. 'flex-1 pb-3 text-sm font-medium transition-colors border-b-2 cursor-pointer',
  47. activeTab === 'phone'
  48. ? 'text-[var(--c-0b8cff)] border-[var(--c-0b8cff)]'
  49. : 'text-[var(--c-94a3b8)] border-transparent hover:text-[var(--c-475569)]',
  50. ]"
  51. @click="activeTab = 'phone'"
  52. >
  53. 手机号登录
  54. </button>
  55. </div>
  56. </div>
  57. <!-- 微信扫码登录 -->
  58. <div v-if="!isMobileClient && activeTab === 'qr'" class="px-8 py-6">
  59. <div class="flex flex-col items-center">
  60. <!-- 二维码区域 -->
  61. <div
  62. class="relative w-48 h-48 rounded-xl border border-[var(--c-e2e8f0)] flex items-center justify-center bg-[var(--c-ffffff)]"
  63. >
  64. <!-- 加载中 -->
  65. <div v-if="qrLoading" class="flex flex-col items-center gap-2">
  66. <svg class="w-8 h-8 text-[var(--c-0b8cff)] animate-spin" fill="none" viewBox="0 0 24 24">
  67. <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
  68. <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
  69. </svg>
  70. <span class="text-xs text-[var(--c-94a3b8)]">获取二维码...</span>
  71. </div>
  72. <!-- 二维码生成失败:首次请求未拿到有效 ticket/url -->
  73. <button
  74. v-else-if="qrFailed"
  75. type="button"
  76. class="group flex cursor-pointer flex-col items-center gap-2.5 px-4 text-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--sh-11-140-255-180)]"
  77. aria-label="二维码生成失败,点击重新生成"
  78. @click="refreshQR"
  79. >
  80. <span class="flex size-11 items-center justify-center rounded-full bg-[var(--c-fff1f0)] text-[var(--c-f04438)]">
  81. <UIcon name="i-lucide-circle-alert" class="size-6" />
  82. </span>
  83. <span class="text-sm font-bold text-[var(--c-172033)]">二维码生成失败</span>
  84. <span class="text-xs leading-5 text-[var(--c-94a3b8)]">{{ qrErrorMessage }}</span>
  85. <span class="text-xs font-bold text-[var(--c-0b8cff)] group-hover:underline">点击重新生成</span>
  86. </button>
  87. <!-- 二维码过期 -->
  88. <button
  89. v-else-if="qrExpired"
  90. type="button"
  91. class="flex cursor-pointer flex-col items-center gap-3 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--sh-11-140-255-180)]"
  92. aria-label="二维码已过期,点击刷新"
  93. @click="refreshQR"
  94. >
  95. <svg class="w-10 h-10 text-[var(--c-cbd5e1)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
  96. <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 4v1m6 11h2m-6 0h-2m4-8h2m-8 0H6m5 5h2m-4 0h2m2 4h-2m2-4h2" />
  97. </svg>
  98. <span class="text-sm text-[var(--c-94a3b8)]">二维码已过期</span>
  99. <span class="text-xs font-bold text-[var(--c-0b8cff)]">点击刷新</span>
  100. </button>
  101. <!-- 二维码 -->
  102. <vue-qr
  103. v-else-if="qrData.url"
  104. :text="qrData.url"
  105. :size="180"
  106. :margin="8"
  107. :logo-image="qrLogo"
  108. :logo-size="36"
  109. />
  110. </div>
  111. <p class="mt-4 text-sm text-[var(--c-64748b)] text-center">
  112. 请使用微信扫描二维码完成登录
  113. </p>
  114. </div>
  115. </div>
  116. <!-- 手机号登录 -->
  117. <div v-if="activeTab === 'phone'" class="px-8 py-6">
  118. <div class="space-y-4">
  119. <!-- 手机号 -->
  120. <div>
  121. <label class="block text-xs font-medium text-[var(--c-475569)] mb-1.5">手机号</label>
  122. <input
  123. v-model="phoneForm.phone"
  124. type="tel"
  125. maxlength="11"
  126. placeholder="请输入手机号"
  127. class="w-full px-3 py-2.5 text-sm border border-[var(--c-e2e8f0)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--sh-11-140-255-180)] focus:border-[var(--c-0b8cff)] transition-colors"
  128. @input="phoneForm.phone = phoneForm.phone.replace(/\D/g, '')"
  129. />
  130. </div>
  131. <!-- 验证码 -->
  132. <div>
  133. <label class="block text-xs font-medium text-[var(--c-475569)] mb-1.5">验证码</label>
  134. <div class="flex gap-3">
  135. <input
  136. v-model="phoneForm.code"
  137. type="text"
  138. maxlength="6"
  139. placeholder="请输入验证码"
  140. class="flex-1 px-3 py-2.5 text-sm border border-[var(--c-e2e8f0)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--sh-11-140-255-180)] focus:border-[var(--c-0b8cff)] transition-colors"
  141. />
  142. <button
  143. :disabled="phoneForm.sendingCode || countdown > 0"
  144. :class="[
  145. 'shrink-0 px-4 py-2.5 text-sm font-medium rounded-lg transition-colors cursor-pointer',
  146. phoneForm.sendingCode || countdown > 0
  147. ? 'text-[var(--c-94a3b8)] bg-[var(--c-f1f5f9)] cursor-not-allowed'
  148. : 'text-[var(--c-0b8cff)] bg-[var(--c-eef7ff)] hover:bg-[var(--c-e0f0ff)]',
  149. ]"
  150. @click="sendCode"
  151. >
  152. {{ phoneForm.sendingCode ? '发送中...' : countdown > 0 ? `${countdown}s` : '获取验证码' }}
  153. </button>
  154. </div>
  155. </div>
  156. <!-- 阿里云验证码挂载点:生产环境发送登录短信前触发,开发环境仍走后端 mock -->
  157. <div id="captcha-element" class="absolute size-px overflow-hidden opacity-0" aria-hidden="true" />
  158. <button
  159. id="captcha-button"
  160. type="button"
  161. class="absolute size-px overflow-hidden opacity-0"
  162. tabindex="-1"
  163. aria-hidden="true"
  164. />
  165. <!-- 开发环境提示(后端未发短信,直接返回验证码) -->
  166. <p v-if="phoneForm.devCode" class="text-xs text-[var(--c-94a3b8)]">
  167. 开发环境验证码:<span class="font-bold text-[var(--c-0b8cff)]">{{ phoneForm.devCode }}</span>(已自动填入)
  168. </p>
  169. <!-- 错误提示 -->
  170. <p v-if="phoneForm.error" class="text-xs text-red-500">{{ phoneForm.error }}</p>
  171. <!-- 登录按钮 -->
  172. <button
  173. :disabled="phoneForm.loading || !phoneForm.phone || !phoneForm.code"
  174. class="w-full py-2.5 text-sm font-semibold text-white bg-[var(--c-0b8cff)] hover:bg-[var(--c-087dde)] disabled:bg-[var(--c-cbd5e1)] disabled:cursor-not-allowed rounded-lg transition-colors cursor-pointer"
  175. @click="loginByPhone"
  176. >
  177. <span v-if="phoneForm.loading" class="inline-flex items-center gap-2">
  178. <svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
  179. <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
  180. <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
  181. </svg>
  182. 登录中...
  183. </span>
  184. <span v-else>登录</span>
  185. </button>
  186. </div>
  187. </div>
  188. <!-- 底部协议 -->
  189. <div class="px-8 pb-6 text-center">
  190. <p class="text-xs text-[var(--c-94a3b8)]">
  191. 登录即表示同意
  192. <NuxtLink to="/terms" target="_blank" class="text-[var(--c-0b8cff)] hover:underline">《服务条款》</NuxtLink>
  193. <NuxtLink to="/privacy" target="_blank" class="text-[var(--c-0b8cff)] hover:underline">《隐私政策》</NuxtLink>
  194. </p>
  195. </div>
  196. </div>
  197. </div>
  198. </template>
  199. <script setup>
  200. import VueQr from 'vue-qr'
  201. const emit = defineEmits(['close', 'login-success'])
  202. const { post } = useApi()
  203. const { setLogin } = useUser()
  204. const { inviteCode, withInvite, clearInvite } = useInvite()
  205. const { isMobileClient } = useClientDevice()
  206. const { reportCv } = usePvcv()
  207. const toast = useToast()
  208. const ALIYUN_CAPTCHA_SCRIPT = 'https://o.alicdn.com/captcha-frontend/aliyunCaptcha/AliyunCaptcha.js'
  209. const ALIYUN_CAPTCHA_SCENE_ID = 'fgtt5yvc'
  210. const ALIYUN_CAPTCHA_PREFIX = '1ggr84'
  211. const ALIYUN_CAPTCHA_WIDTH = 360
  212. const ALIYUN_CAPTCHA_WARMUP_MS = 2000
  213. const SMS_CAPTCHA_ERROR_CODE = 1020
  214. const SMS_COOLDOWN_SECONDS = 60
  215. const SMS_LAST_SENT_AT_KEY = 'ai_last_send_time'
  216. let aliyunCaptchaScriptPromise = null
  217. let captchaInstance = null
  218. let captchaInitPromise = null
  219. let captchaInitializedAt = 0
  220. // Tab
  221. const activeTab = ref(isMobileClient.value ? 'phone' : 'qr')
  222. // ===== 微信扫码 =====
  223. const qrLoading = ref(true)
  224. const qrExpired = ref(false)
  225. const qrFailed = ref(false)
  226. const qrErrorMessage = ref('')
  227. const qrData = ref({ ticket: '', url: '' })
  228. const qrLogo = '' // 可替换为 logo URL
  229. let qrTimer = null
  230. // 获取二维码
  231. async function refreshQR() {
  232. if (isMobileClient.value) return
  233. clearInterval(qrTimer)
  234. qrLoading.value = true
  235. qrExpired.value = false
  236. qrFailed.value = false
  237. qrErrorMessage.value = ''
  238. qrData.value = { ticket: '', url: '' }
  239. try {
  240. // 邀请码必须在创建二维码 Ticket 时写入,扫码回调才能完成邀请归因。
  241. const res = await post('/user/getloginqr', withInvite())
  242. if (res.code === 0 && res.data?.ticket && res.data?.url) {
  243. qrData.value = res.data
  244. qrLoading.value = false
  245. startPolling()
  246. } else {
  247. qrLoadError()
  248. }
  249. } catch (error) {
  250. qrLoadError(error)
  251. }
  252. }
  253. function qrLoadError(error) {
  254. clearInterval(qrTimer)
  255. qrLoading.value = false
  256. qrExpired.value = false
  257. qrFailed.value = true
  258. const message = String(error?.message || '')
  259. qrErrorMessage.value = /access[_ ]?token|登录凭证/i.test(message)
  260. ? '未能获取微信登录凭证,请稍后重试'
  261. : '暂时无法生成二维码,请稍后重试'
  262. }
  263. function markQrExpired() {
  264. clearInterval(qrTimer)
  265. qrLoading.value = false
  266. qrFailed.value = false
  267. qrExpired.value = true
  268. }
  269. // 轮询扫码结果
  270. function startPolling() {
  271. clearInterval(qrTimer)
  272. qrTimer = setInterval(async () => {
  273. try {
  274. const res = await post('/user/scanquery', { ticket: qrData.value.ticket })
  275. if (res.code === 0 && res.data?.token) {
  276. clearInterval(qrTimer)
  277. handleLoginSuccess(res.data)
  278. } else if (res.code === 1010) {
  279. // 二维码过期
  280. markQrExpired()
  281. }
  282. } catch (error) {
  283. // useApi 会把非 0 业务码抛出;1010 需要在异常分支识别为真正过期。
  284. if (Number(error?.code) === 1010) markQrExpired()
  285. // 其他短暂轮询失败不中断
  286. }
  287. }, 1000)
  288. }
  289. // ===== 手机号登录 =====
  290. const phoneForm = reactive({
  291. phone: '',
  292. code: '',
  293. sendingCode: false,
  294. loading: false,
  295. error: '',
  296. devCode: '', // 开发环境后端直接返回的验证码,仅用于提示
  297. })
  298. const countdown = ref(0)
  299. let countdownTimer = null
  300. function shouldUseSmsCaptcha() {
  301. return import.meta.client && !import.meta.dev
  302. }
  303. function cleanupAliyunCaptchaPopup() {
  304. if (!import.meta.client) return
  305. ;['aliyunCaptcha-mask', 'aliyunCaptcha-window-popup'].forEach((id) => {
  306. document.getElementById(id)?.remove()
  307. })
  308. }
  309. function loadAliyunCaptchaScript() {
  310. if (!import.meta.client) return Promise.reject(new Error('验证码仅支持浏览器环境'))
  311. window.AliyunCaptchaConfig = {
  312. region: 'cn',
  313. prefix: ALIYUN_CAPTCHA_PREFIX,
  314. }
  315. if (window.initAliyunCaptcha) return Promise.resolve()
  316. if (aliyunCaptchaScriptPromise) return aliyunCaptchaScriptPromise
  317. aliyunCaptchaScriptPromise = new Promise((resolve, reject) => {
  318. const existed = document.querySelector(`script[src="${ALIYUN_CAPTCHA_SCRIPT}"]`)
  319. const script = existed || document.createElement('script')
  320. const timeout = setTimeout(() => {
  321. aliyunCaptchaScriptPromise = null
  322. reject(new Error('验证码组件加载超时,请刷新后重试'))
  323. }, 15000)
  324. const done = () => {
  325. clearTimeout(timeout)
  326. if (window.initAliyunCaptcha) resolve()
  327. else {
  328. aliyunCaptchaScriptPromise = null
  329. reject(new Error('验证码组件加载失败'))
  330. }
  331. }
  332. const fail = () => {
  333. clearTimeout(timeout)
  334. aliyunCaptchaScriptPromise = null
  335. reject(new Error('验证码组件加载失败,请刷新后重试'))
  336. }
  337. script.addEventListener('load', done, { once: true })
  338. script.addEventListener('error', fail, { once: true })
  339. if (!existed) {
  340. script.src = ALIYUN_CAPTCHA_SCRIPT
  341. script.async = true
  342. document.head.appendChild(script)
  343. }
  344. })
  345. return aliyunCaptchaScriptPromise
  346. }
  347. async function initSmsCaptcha() {
  348. if (!shouldUseSmsCaptcha()) return
  349. if (captchaInstance) return captchaInstance
  350. if (captchaInitPromise) return captchaInitPromise
  351. captchaInitPromise = (async () => {
  352. await loadAliyunCaptchaScript()
  353. await nextTick()
  354. const element = document.getElementById('captcha-element')
  355. const button = document.getElementById('captcha-button')
  356. if (!element || !button) {
  357. throw new Error('验证码初始化失败,请刷新后重试')
  358. }
  359. cleanupAliyunCaptchaPopup()
  360. const pageWidth = Math.max(document.documentElement.clientWidth || 0, window.innerWidth || 0)
  361. const rem = pageWidth > 0 && pageWidth <= ALIYUN_CAPTCHA_WIDTH
  362. ? Math.floor(pageWidth / ALIYUN_CAPTCHA_WIDTH * 100) / 100
  363. : 1
  364. return await new Promise((resolve, reject) => {
  365. let settled = false
  366. const timeout = window.setTimeout(() => {
  367. if (settled) return
  368. settled = true
  369. reject(new Error('验证码初始化超时,请刷新后重试'))
  370. }, 15000)
  371. const resolveInstance = (instance) => {
  372. if (settled) return
  373. settled = true
  374. window.clearTimeout(timeout)
  375. captchaInstance = instance
  376. resolve(instance)
  377. }
  378. try {
  379. captchaInitializedAt = Date.now()
  380. window.initAliyunCaptcha({
  381. SceneId: ALIYUN_CAPTCHA_SCENE_ID,
  382. prefix: ALIYUN_CAPTCHA_PREFIX,
  383. mode: 'popup',
  384. element: '#captcha-element',
  385. button: '#captcha-button',
  386. captchaVerifyCallback,
  387. onBizResultCallback,
  388. getInstance: resolveInstance,
  389. onError: () => {
  390. if (settled) return
  391. settled = true
  392. window.clearTimeout(timeout)
  393. reject(new Error('验证码组件加载失败,请稍后重试'))
  394. },
  395. autoRefresh: true,
  396. slideStyle: {
  397. width: ALIYUN_CAPTCHA_WIDTH,
  398. height: 40,
  399. },
  400. language: 'cn',
  401. rem,
  402. })
  403. } catch (error) {
  404. if (settled) return
  405. settled = true
  406. window.clearTimeout(timeout)
  407. reject(error)
  408. }
  409. })
  410. })()
  411. try {
  412. return await captchaInitPromise
  413. } catch (error) {
  414. captchaInitPromise = null
  415. captchaInstance = null
  416. captchaInitializedAt = 0
  417. throw error
  418. }
  419. }
  420. function onBizResultCallback() {
  421. // 阿里云验证码业务回调占位,短信发送结果已在 captchaVerifyCallback 中处理。
  422. }
  423. function getSmsCooldownSeconds() {
  424. if (!import.meta.client) return 0
  425. const lastSentAt = Number(window.localStorage.getItem(SMS_LAST_SENT_AT_KEY))
  426. if (!Number.isFinite(lastSentAt) || lastSentAt <= 0) {
  427. window.localStorage.removeItem(SMS_LAST_SENT_AT_KEY)
  428. return 0
  429. }
  430. const remaining = lastSentAt + SMS_COOLDOWN_SECONDS - Math.floor(Date.now() / 1000)
  431. return Math.min(SMS_COOLDOWN_SECONDS, Math.max(0, remaining))
  432. }
  433. function syncSmsCountdown() {
  434. clearInterval(countdownTimer)
  435. countdown.value = getSmsCooldownSeconds()
  436. if (countdown.value <= 0) {
  437. window.localStorage.removeItem(SMS_LAST_SENT_AT_KEY)
  438. return
  439. }
  440. countdownTimer = setInterval(() => {
  441. countdown.value = getSmsCooldownSeconds()
  442. if (countdown.value <= 0) {
  443. clearInterval(countdownTimer)
  444. window.localStorage.removeItem(SMS_LAST_SENT_AT_KEY)
  445. }
  446. }, 1000)
  447. }
  448. function startSmsCountdown() {
  449. window.localStorage.setItem(SMS_LAST_SENT_AT_KEY, String(Math.floor(Date.now() / 1000)))
  450. syncSmsCountdown()
  451. }
  452. function handleSmsCooldownStorage(event) {
  453. if (event.key === SMS_LAST_SENT_AT_KEY) syncSmsCountdown()
  454. }
  455. async function submitSmsCode(captchaVerifyParam = '') {
  456. const payload = {
  457. phone: phoneForm.phone,
  458. type: 3,
  459. }
  460. if (captchaVerifyParam) payload.captchaVerifyParam = captchaVerifyParam
  461. const res = await post('/common/sendcode', payload)
  462. if (res.code !== 0) {
  463. throw new Error(res.msg || '发送验证码失败')
  464. }
  465. // 开发/测试环境后端不发短信,直接把验证码返回,这里自动回填方便调试
  466. if (res.data?.code) {
  467. phoneForm.code = String(res.data.code)
  468. phoneForm.devCode = String(res.data.code)
  469. } else {
  470. phoneForm.code = ''
  471. phoneForm.devCode = ''
  472. }
  473. startSmsCountdown()
  474. }
  475. async function captchaVerifyCallback(captchaVerifyParam) {
  476. phoneForm.error = ''
  477. phoneForm.sendingCode = true
  478. try {
  479. await submitSmsCode(captchaVerifyParam)
  480. return {
  481. captchaResult: true,
  482. bizResult: true,
  483. }
  484. } catch (e) {
  485. phoneForm.error = e.message || '发送验证码失败'
  486. const captchaFailed = Number(e?.code) === SMS_CAPTCHA_ERROR_CODE
  487. return {
  488. captchaResult: !captchaFailed,
  489. bizResult: false,
  490. }
  491. } finally {
  492. phoneForm.sendingCode = false
  493. }
  494. }
  495. function validatePhone() {
  496. if (!phoneForm.phone || phoneForm.phone.length !== 11) {
  497. phoneForm.error = '请输入正确的11位手机号'
  498. return false
  499. }
  500. return true
  501. }
  502. // 发送验证码
  503. async function sendCode() {
  504. if (!validatePhone()) return
  505. phoneForm.error = ''
  506. if (!shouldUseSmsCaptcha()) {
  507. phoneForm.sendingCode = true
  508. try {
  509. await submitSmsCode()
  510. } catch (e) {
  511. phoneForm.error = e.message || '发送验证码失败'
  512. } finally {
  513. phoneForm.sendingCode = false
  514. }
  515. return
  516. }
  517. try {
  518. phoneForm.sendingCode = true
  519. const instance = await initSmsCaptcha()
  520. const warmupRemaining = ALIYUN_CAPTCHA_WARMUP_MS - (Date.now() - captchaInitializedAt)
  521. if (warmupRemaining > 0) {
  522. await new Promise(resolve => setTimeout(resolve, warmupRemaining))
  523. }
  524. phoneForm.sendingCode = false
  525. if (typeof instance?.startTracelessVerification === 'function') {
  526. instance.show?.()
  527. instance.startTracelessVerification()
  528. } else {
  529. const captchaButton = document.getElementById('captcha-button')
  530. if (captchaButton) captchaButton.click()
  531. else if (typeof instance?.show === 'function') instance.show()
  532. else throw new Error('验证码触发失败,请刷新后重试')
  533. }
  534. } catch (e) {
  535. phoneForm.sendingCode = false
  536. phoneForm.error = e.message || '验证码初始化失败,请刷新后重试'
  537. }
  538. }
  539. // 手机号登录
  540. async function loginByPhone() {
  541. if (!phoneForm.phone || !phoneForm.code) return
  542. phoneForm.error = ''
  543. phoneForm.loading = true
  544. try {
  545. const res = await post('/user/login_phone', withInvite({
  546. phone: phoneForm.phone,
  547. code: phoneForm.code,
  548. }))
  549. if (res.code === 0 && res.data?.token) {
  550. handleLoginSuccess(res.data)
  551. } else {
  552. phoneForm.error = res.msg || '登录失败'
  553. }
  554. } catch (e) {
  555. phoneForm.error = e.message || '登录失败'
  556. } finally {
  557. phoneForm.loading = false
  558. }
  559. }
  560. // ===== 公共 =====
  561. function handleLoginSuccess(data) {
  562. const invited = Boolean(inviteCode.value)
  563. setLogin(data)
  564. clearInvite()
  565. reportCv('login_success', { page_key: 'login_dialog' })
  566. toast.add({
  567. title: invited && data.is_register ? '登录并领取成功' : '登录成功',
  568. description: invited && data.is_register ? '邀请奖励点数已自动到账。' : '',
  569. color: 'success',
  570. })
  571. emit('login-success', data)
  572. }
  573. // 初始化
  574. onMounted(() => {
  575. reportCv('login_dialog_show', { page_key: 'login_dialog' })
  576. syncSmsCountdown()
  577. window.addEventListener('storage', handleSmsCooldownStorage)
  578. if (shouldUseSmsCaptcha()) initSmsCaptcha().catch(() => {})
  579. if (!isMobileClient.value) refreshQR()
  580. })
  581. watch(isMobileClient, (mobile) => {
  582. if (mobile) {
  583. clearInterval(qrTimer)
  584. activeTab.value = 'phone'
  585. } else if (activeTab.value === 'qr' && !qrData.value.url) {
  586. refreshQR()
  587. }
  588. })
  589. onUnmounted(() => {
  590. clearInterval(qrTimer)
  591. clearInterval(countdownTimer)
  592. window.removeEventListener('storage', handleSmsCooldownStorage)
  593. try {
  594. captchaInstance?.destroyCaptcha?.()
  595. } catch {}
  596. captchaInstance = null
  597. captchaInitPromise = null
  598. captchaInitializedAt = 0
  599. cleanupAliyunCaptchaPopup()
  600. })
  601. </script>