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

401 行
14 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. 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. {{ countdown > 0 ? `${countdown}s` : '获取验证码' }}
  153. </button>
  154. </div>
  155. </div>
  156. <!-- 开发环境提示(后端未发短信,直接返回验证码) -->
  157. <p v-if="phoneForm.devCode" class="text-xs text-[var(--c-94a3b8)]">
  158. 开发环境验证码:<span class="font-bold text-[var(--c-0b8cff)]">{{ phoneForm.devCode }}</span>(已自动填入)
  159. </p>
  160. <!-- 错误提示 -->
  161. <p v-if="phoneForm.error" class="text-xs text-red-500">{{ phoneForm.error }}</p>
  162. <!-- 登录按钮 -->
  163. <button
  164. :disabled="phoneForm.loading || !phoneForm.phone || !phoneForm.code"
  165. 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"
  166. @click="loginByPhone"
  167. >
  168. <span v-if="phoneForm.loading" class="inline-flex items-center gap-2">
  169. <svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
  170. <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
  171. <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
  172. </svg>
  173. 登录中...
  174. </span>
  175. <span v-else>登录</span>
  176. </button>
  177. </div>
  178. </div>
  179. <!-- 底部协议 -->
  180. <div class="px-8 pb-6 text-center">
  181. <p class="text-xs text-[var(--c-94a3b8)]">
  182. 登录即表示同意
  183. <NuxtLink to="/terms" target="_blank" class="text-[var(--c-0b8cff)] hover:underline">《服务条款》</NuxtLink>
  184. <NuxtLink to="/privacy" target="_blank" class="text-[var(--c-0b8cff)] hover:underline">《隐私政策》</NuxtLink>
  185. </p>
  186. </div>
  187. </div>
  188. </div>
  189. </template>
  190. <script setup>
  191. import VueQr from 'vue-qr'
  192. const emit = defineEmits(['close', 'login-success'])
  193. const { post } = useApi()
  194. const { setLogin } = useUser()
  195. const { inviteCode, withInvite, clearInvite } = useInvite()
  196. const { isMobileClient } = useClientDevice()
  197. const { reportCv } = usePvcv()
  198. const toast = useToast()
  199. // Tab
  200. const activeTab = ref(isMobileClient.value ? 'phone' : 'qr')
  201. // ===== 微信扫码 =====
  202. const qrLoading = ref(true)
  203. const qrExpired = ref(false)
  204. const qrFailed = ref(false)
  205. const qrErrorMessage = ref('')
  206. const qrData = ref({ ticket: '', url: '' })
  207. const qrLogo = '' // 可替换为 logo URL
  208. let qrTimer = null
  209. // 获取二维码
  210. async function refreshQR() {
  211. if (isMobileClient.value) return
  212. clearInterval(qrTimer)
  213. qrLoading.value = true
  214. qrExpired.value = false
  215. qrFailed.value = false
  216. qrErrorMessage.value = ''
  217. qrData.value = { ticket: '', url: '' }
  218. try {
  219. // 邀请码必须在创建二维码 Ticket 时写入,扫码回调才能完成邀请归因。
  220. const res = await post('/user/getloginqr', withInvite())
  221. if (res.code === 0 && res.data?.ticket && res.data?.url) {
  222. qrData.value = res.data
  223. qrLoading.value = false
  224. startPolling()
  225. } else {
  226. qrLoadError()
  227. }
  228. } catch (error) {
  229. qrLoadError(error)
  230. }
  231. }
  232. function qrLoadError(error) {
  233. clearInterval(qrTimer)
  234. qrLoading.value = false
  235. qrExpired.value = false
  236. qrFailed.value = true
  237. const message = String(error?.message || '')
  238. qrErrorMessage.value = /access[_ ]?token|登录凭证/i.test(message)
  239. ? '未能获取微信登录凭证,请稍后重试'
  240. : '暂时无法生成二维码,请稍后重试'
  241. }
  242. function markQrExpired() {
  243. clearInterval(qrTimer)
  244. qrLoading.value = false
  245. qrFailed.value = false
  246. qrExpired.value = true
  247. }
  248. // 轮询扫码结果
  249. function startPolling() {
  250. clearInterval(qrTimer)
  251. qrTimer = setInterval(async () => {
  252. try {
  253. const res = await post('/user/scanquery', { ticket: qrData.value.ticket })
  254. if (res.code === 0 && res.data?.token) {
  255. clearInterval(qrTimer)
  256. handleLoginSuccess(res.data)
  257. } else if (res.code === 1010) {
  258. // 二维码过期
  259. markQrExpired()
  260. }
  261. } catch (error) {
  262. // useApi 会把非 0 业务码抛出;1010 需要在异常分支识别为真正过期。
  263. if (Number(error?.code) === 1010) markQrExpired()
  264. // 其他短暂轮询失败不中断
  265. }
  266. }, 1000)
  267. }
  268. // ===== 手机号登录 =====
  269. const phoneForm = reactive({
  270. phone: '',
  271. code: '',
  272. sendingCode: false,
  273. loading: false,
  274. error: '',
  275. devCode: '', // 开发环境后端直接返回的验证码,仅用于提示
  276. })
  277. const countdown = ref(0)
  278. let countdownTimer = null
  279. // 发送验证码
  280. async function sendCode() {
  281. if (!phoneForm.phone || phoneForm.phone.length !== 11) {
  282. phoneForm.error = '请输入正确的11位手机号'
  283. return
  284. }
  285. phoneForm.error = ''
  286. phoneForm.sendingCode = true
  287. try {
  288. const res = await post('/common/sendcode', {
  289. phone: phoneForm.phone,
  290. type: 3,
  291. })
  292. if (res.code === 0) {
  293. // 开发/测试环境后端不发短信,直接把验证码返回,这里自动回填方便调试
  294. if (res.data?.code) {
  295. phoneForm.code = String(res.data.code)
  296. phoneForm.devCode = String(res.data.code)
  297. }
  298. countdown.value = 60
  299. countdownTimer = setInterval(() => {
  300. countdown.value--
  301. if (countdown.value <= 0) {
  302. clearInterval(countdownTimer)
  303. }
  304. }, 1000)
  305. } else {
  306. phoneForm.error = res.msg || '发送验证码失败'
  307. }
  308. } catch (e) {
  309. phoneForm.error = e.message || '发送验证码失败'
  310. } finally {
  311. phoneForm.sendingCode = false
  312. }
  313. }
  314. // 手机号登录
  315. async function loginByPhone() {
  316. if (!phoneForm.phone || !phoneForm.code) return
  317. phoneForm.error = ''
  318. phoneForm.loading = true
  319. try {
  320. const res = await post('/user/login_phone', withInvite({
  321. phone: phoneForm.phone,
  322. code: phoneForm.code,
  323. }))
  324. if (res.code === 0 && res.data?.token) {
  325. handleLoginSuccess(res.data)
  326. } else {
  327. phoneForm.error = res.msg || '登录失败'
  328. }
  329. } catch (e) {
  330. phoneForm.error = e.message || '登录失败'
  331. } finally {
  332. phoneForm.loading = false
  333. }
  334. }
  335. // ===== 公共 =====
  336. function handleLoginSuccess(data) {
  337. const invited = Boolean(inviteCode.value)
  338. setLogin(data)
  339. clearInvite()
  340. reportCv('login_success', { page_key: 'login_dialog' })
  341. toast.add({
  342. title: invited && data.is_register ? '登录并领取成功' : '登录成功',
  343. description: invited && data.is_register ? '邀请奖励点数已自动到账。' : '',
  344. color: 'success',
  345. })
  346. emit('login-success', data)
  347. }
  348. // 初始化
  349. onMounted(() => {
  350. reportCv('login_dialog_show', { page_key: 'login_dialog' })
  351. if (!isMobileClient.value) refreshQR()
  352. })
  353. watch(isMobileClient, (mobile) => {
  354. if (mobile) {
  355. clearInterval(qrTimer)
  356. activeTab.value = 'phone'
  357. } else if (activeTab.value === 'qr' && !qrData.value.url) {
  358. refreshQR()
  359. }
  360. })
  361. onUnmounted(() => {
  362. clearInterval(qrTimer)
  363. clearInterval(countdownTimer)
  364. })
  365. </script>