您最多选择25个主题 主题必须以字母或数字开头,可以包含连字符 (-),并且长度不得超过35个字符
 
 
 
 

563 行
19 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. let aliyunCaptchaScriptPromise = null
  212. let captchaInstance = null
  213. // Tab
  214. const activeTab = ref(isMobileClient.value ? 'phone' : 'qr')
  215. // ===== 微信扫码 =====
  216. const qrLoading = ref(true)
  217. const qrExpired = ref(false)
  218. const qrFailed = ref(false)
  219. const qrErrorMessage = ref('')
  220. const qrData = ref({ ticket: '', url: '' })
  221. const qrLogo = '' // 可替换为 logo URL
  222. let qrTimer = null
  223. // 获取二维码
  224. async function refreshQR() {
  225. if (isMobileClient.value) return
  226. clearInterval(qrTimer)
  227. qrLoading.value = true
  228. qrExpired.value = false
  229. qrFailed.value = false
  230. qrErrorMessage.value = ''
  231. qrData.value = { ticket: '', url: '' }
  232. try {
  233. // 邀请码必须在创建二维码 Ticket 时写入,扫码回调才能完成邀请归因。
  234. const res = await post('/user/getloginqr', withInvite())
  235. if (res.code === 0 && res.data?.ticket && res.data?.url) {
  236. qrData.value = res.data
  237. qrLoading.value = false
  238. startPolling()
  239. } else {
  240. qrLoadError()
  241. }
  242. } catch (error) {
  243. qrLoadError(error)
  244. }
  245. }
  246. function qrLoadError(error) {
  247. clearInterval(qrTimer)
  248. qrLoading.value = false
  249. qrExpired.value = false
  250. qrFailed.value = true
  251. const message = String(error?.message || '')
  252. qrErrorMessage.value = /access[_ ]?token|登录凭证/i.test(message)
  253. ? '未能获取微信登录凭证,请稍后重试'
  254. : '暂时无法生成二维码,请稍后重试'
  255. }
  256. function markQrExpired() {
  257. clearInterval(qrTimer)
  258. qrLoading.value = false
  259. qrFailed.value = false
  260. qrExpired.value = true
  261. }
  262. // 轮询扫码结果
  263. function startPolling() {
  264. clearInterval(qrTimer)
  265. qrTimer = setInterval(async () => {
  266. try {
  267. const res = await post('/user/scanquery', { ticket: qrData.value.ticket })
  268. if (res.code === 0 && res.data?.token) {
  269. clearInterval(qrTimer)
  270. handleLoginSuccess(res.data)
  271. } else if (res.code === 1010) {
  272. // 二维码过期
  273. markQrExpired()
  274. }
  275. } catch (error) {
  276. // useApi 会把非 0 业务码抛出;1010 需要在异常分支识别为真正过期。
  277. if (Number(error?.code) === 1010) markQrExpired()
  278. // 其他短暂轮询失败不中断
  279. }
  280. }, 1000)
  281. }
  282. // ===== 手机号登录 =====
  283. const phoneForm = reactive({
  284. phone: '',
  285. code: '',
  286. sendingCode: false,
  287. loading: false,
  288. error: '',
  289. devCode: '', // 开发环境后端直接返回的验证码,仅用于提示
  290. })
  291. const countdown = ref(0)
  292. const captchaReady = ref(false)
  293. let countdownTimer = null
  294. function shouldUseSmsCaptcha() {
  295. return import.meta.client && !import.meta.dev
  296. }
  297. function cleanupAliyunCaptchaPopup() {
  298. if (!import.meta.client) return
  299. ;['aliyunCaptcha-mask', 'aliyunCaptcha-window-popup'].forEach((id) => {
  300. document.getElementById(id)?.remove()
  301. })
  302. }
  303. function loadAliyunCaptchaScript() {
  304. if (!import.meta.client) return Promise.reject(new Error('验证码仅支持浏览器环境'))
  305. if (window.initAliyunCaptcha) return Promise.resolve()
  306. if (aliyunCaptchaScriptPromise) return aliyunCaptchaScriptPromise
  307. aliyunCaptchaScriptPromise = new Promise((resolve, reject) => {
  308. const existed = document.querySelector(`script[src="${ALIYUN_CAPTCHA_SCRIPT}"]`)
  309. const script = existed || document.createElement('script')
  310. const timeout = setTimeout(() => {
  311. aliyunCaptchaScriptPromise = null
  312. reject(new Error('验证码组件加载超时,请刷新后重试'))
  313. }, 15000)
  314. const done = () => {
  315. clearTimeout(timeout)
  316. if (window.initAliyunCaptcha) resolve()
  317. else {
  318. aliyunCaptchaScriptPromise = null
  319. reject(new Error('验证码组件加载失败'))
  320. }
  321. }
  322. const fail = () => {
  323. clearTimeout(timeout)
  324. aliyunCaptchaScriptPromise = null
  325. reject(new Error('验证码组件加载失败,请刷新后重试'))
  326. }
  327. script.addEventListener('load', done, { once: true })
  328. script.addEventListener('error', fail, { once: true })
  329. if (!existed) {
  330. script.src = ALIYUN_CAPTCHA_SCRIPT
  331. script.async = true
  332. document.head.appendChild(script)
  333. }
  334. })
  335. return aliyunCaptchaScriptPromise
  336. }
  337. async function initSmsCaptcha() {
  338. if (!shouldUseSmsCaptcha()) return
  339. await loadAliyunCaptchaScript()
  340. await nextTick()
  341. const element = document.getElementById('captcha-element')
  342. const button = document.getElementById('captcha-button')
  343. if (!element || !button) {
  344. throw new Error('验证码初始化失败,请刷新后重试')
  345. }
  346. if (captchaReady.value && captchaInstance) return
  347. cleanupAliyunCaptchaPopup()
  348. window.initAliyunCaptcha({
  349. SceneId: ALIYUN_CAPTCHA_SCENE_ID,
  350. prefix: ALIYUN_CAPTCHA_PREFIX,
  351. mode: 'popup',
  352. element: '#captcha-element',
  353. button: '#captcha-button',
  354. captchaVerifyCallback,
  355. onBizResultCallback,
  356. getInstance,
  357. slideStyle: {
  358. width: 360,
  359. height: 40,
  360. },
  361. language: 'cn',
  362. })
  363. captchaReady.value = true
  364. }
  365. function getInstance(instance) {
  366. captchaInstance = instance
  367. }
  368. function onBizResultCallback() {
  369. // 阿里云验证码业务回调占位,短信发送结果已在 captchaVerifyCallback 中处理。
  370. }
  371. function startSmsCountdown() {
  372. clearInterval(countdownTimer)
  373. countdown.value = 60
  374. countdownTimer = setInterval(() => {
  375. countdown.value--
  376. if (countdown.value <= 0) {
  377. clearInterval(countdownTimer)
  378. }
  379. }, 1000)
  380. }
  381. async function submitSmsCode(captchaVerifyParam = '') {
  382. const payload = {
  383. phone: phoneForm.phone,
  384. type: 3,
  385. }
  386. if (captchaVerifyParam) payload.captchaVerifyParam = captchaVerifyParam
  387. const res = await post('/common/sendcode', payload)
  388. if (res.code !== 0) {
  389. throw new Error(res.msg || '发送验证码失败')
  390. }
  391. // 开发/测试环境后端不发短信,直接把验证码返回,这里自动回填方便调试
  392. if (res.data?.code) {
  393. phoneForm.code = String(res.data.code)
  394. phoneForm.devCode = String(res.data.code)
  395. }
  396. startSmsCountdown()
  397. }
  398. async function captchaVerifyCallback(captchaVerifyParam) {
  399. phoneForm.error = ''
  400. phoneForm.sendingCode = true
  401. try {
  402. await submitSmsCode(captchaVerifyParam)
  403. return {
  404. captchaResult: true,
  405. bizResult: true,
  406. }
  407. } catch (e) {
  408. phoneForm.error = e.message || '发送验证码失败'
  409. return {
  410. captchaResult: false,
  411. bizResult: true,
  412. }
  413. } finally {
  414. phoneForm.sendingCode = false
  415. }
  416. }
  417. function validatePhone() {
  418. if (!phoneForm.phone || phoneForm.phone.length !== 11) {
  419. phoneForm.error = '请输入正确的11位手机号'
  420. return false
  421. }
  422. return true
  423. }
  424. // 发送验证码
  425. async function sendCode() {
  426. if (!validatePhone()) return
  427. phoneForm.error = ''
  428. if (!shouldUseSmsCaptcha()) {
  429. phoneForm.sendingCode = true
  430. try {
  431. await submitSmsCode()
  432. } catch (e) {
  433. phoneForm.error = e.message || '发送验证码失败'
  434. } finally {
  435. phoneForm.sendingCode = false
  436. }
  437. return
  438. }
  439. try {
  440. phoneForm.sendingCode = true
  441. await initSmsCaptcha()
  442. phoneForm.sendingCode = false
  443. await new Promise(resolve => setTimeout(resolve, 50))
  444. document.getElementById('captcha-button')?.click()
  445. } catch (e) {
  446. phoneForm.sendingCode = false
  447. phoneForm.error = e.message || '验证码初始化失败,请刷新后重试'
  448. }
  449. }
  450. // 手机号登录
  451. async function loginByPhone() {
  452. if (!phoneForm.phone || !phoneForm.code) return
  453. phoneForm.error = ''
  454. phoneForm.loading = true
  455. try {
  456. const res = await post('/user/login_phone', withInvite({
  457. phone: phoneForm.phone,
  458. code: phoneForm.code,
  459. }))
  460. if (res.code === 0 && res.data?.token) {
  461. handleLoginSuccess(res.data)
  462. } else {
  463. phoneForm.error = res.msg || '登录失败'
  464. }
  465. } catch (e) {
  466. phoneForm.error = e.message || '登录失败'
  467. } finally {
  468. phoneForm.loading = false
  469. }
  470. }
  471. // ===== 公共 =====
  472. function handleLoginSuccess(data) {
  473. const invited = Boolean(inviteCode.value)
  474. setLogin(data)
  475. clearInvite()
  476. reportCv('login_success', { page_key: 'login_dialog' })
  477. toast.add({
  478. title: invited && data.is_register ? '登录并领取成功' : '登录成功',
  479. description: invited && data.is_register ? '邀请奖励点数已自动到账。' : '',
  480. color: 'success',
  481. })
  482. emit('login-success', data)
  483. }
  484. // 初始化
  485. onMounted(() => {
  486. reportCv('login_dialog_show', { page_key: 'login_dialog' })
  487. if (!isMobileClient.value) refreshQR()
  488. })
  489. watch(isMobileClient, (mobile) => {
  490. if (mobile) {
  491. clearInterval(qrTimer)
  492. activeTab.value = 'phone'
  493. } else if (activeTab.value === 'qr' && !qrData.value.url) {
  494. refreshQR()
  495. }
  496. })
  497. onUnmounted(() => {
  498. clearInterval(qrTimer)
  499. clearInterval(countdownTimer)
  500. captchaInstance = null
  501. captchaReady.value = false
  502. cleanupAliyunCaptchaPopup()
  503. })
  504. </script>