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.
 
 
 
 

386 line
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. :class="[
  34. 'flex-1 pb-3 text-sm font-medium transition-colors border-b-2 cursor-pointer',
  35. activeTab === 'qr'
  36. ? 'text-[var(--c-0b8cff)] border-[var(--c-0b8cff)]'
  37. : 'text-[var(--c-94a3b8)] border-transparent hover:text-[var(--c-475569)]',
  38. ]"
  39. @click="activeTab = 'qr'"
  40. >
  41. 微信扫码登录
  42. </button>
  43. <button
  44. :class="[
  45. 'flex-1 pb-3 text-sm font-medium transition-colors border-b-2 cursor-pointer',
  46. activeTab === 'phone'
  47. ? 'text-[var(--c-0b8cff)] border-[var(--c-0b8cff)]'
  48. : 'text-[var(--c-94a3b8)] border-transparent hover:text-[var(--c-475569)]',
  49. ]"
  50. @click="activeTab = 'phone'"
  51. >
  52. 手机号登录
  53. </button>
  54. </div>
  55. </div>
  56. <!-- 微信扫码登录 -->
  57. <div v-if="activeTab === 'qr'" class="px-8 py-6">
  58. <div class="flex flex-col items-center">
  59. <!-- 二维码区域 -->
  60. <div
  61. class="relative w-48 h-48 rounded-xl border border-[var(--c-e2e8f0)] flex items-center justify-center bg-[var(--c-ffffff)]"
  62. >
  63. <!-- 加载中 -->
  64. <div v-if="qrLoading" class="flex flex-col items-center gap-2">
  65. <svg class="w-8 h-8 text-[var(--c-0b8cff)] animate-spin" fill="none" viewBox="0 0 24 24">
  66. <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
  67. <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
  68. </svg>
  69. <span class="text-xs text-[var(--c-94a3b8)]">获取二维码...</span>
  70. </div>
  71. <!-- 二维码生成失败:首次请求未拿到有效 ticket/url -->
  72. <button
  73. v-else-if="qrFailed"
  74. type="button"
  75. 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)]"
  76. aria-label="二维码生成失败,点击重新生成"
  77. @click="refreshQR"
  78. >
  79. <span class="flex size-11 items-center justify-center rounded-full bg-[var(--c-fff1f0)] text-[var(--c-f04438)]">
  80. <UIcon name="i-lucide-circle-alert" class="size-6" />
  81. </span>
  82. <span class="text-sm font-bold text-[var(--c-172033)]">二维码生成失败</span>
  83. <span class="text-xs leading-5 text-[var(--c-94a3b8)]">{{ qrErrorMessage }}</span>
  84. <span class="text-xs font-bold text-[var(--c-0b8cff)] group-hover:underline">点击重新生成</span>
  85. </button>
  86. <!-- 二维码过期 -->
  87. <button
  88. v-else-if="qrExpired"
  89. type="button"
  90. 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)]"
  91. aria-label="二维码已过期,点击刷新"
  92. @click="refreshQR"
  93. >
  94. <svg class="w-10 h-10 text-[var(--c-cbd5e1)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
  95. <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" />
  96. </svg>
  97. <span class="text-sm text-[var(--c-94a3b8)]">二维码已过期</span>
  98. <span class="text-xs font-bold text-[var(--c-0b8cff)]">点击刷新</span>
  99. </button>
  100. <!-- 二维码 -->
  101. <vue-qr
  102. v-else-if="qrData.url"
  103. :text="qrData.url"
  104. :size="180"
  105. :margin="8"
  106. :logo-image="qrLogo"
  107. :logo-size="36"
  108. />
  109. </div>
  110. <p class="mt-4 text-sm text-[var(--c-64748b)] text-center">
  111. 请使用微信扫描二维码完成登录
  112. </p>
  113. </div>
  114. </div>
  115. <!-- 手机号登录 -->
  116. <div v-if="activeTab === 'phone'" class="px-8 py-6">
  117. <div class="space-y-4">
  118. <!-- 手机号 -->
  119. <div>
  120. <label class="block text-xs font-medium text-[var(--c-475569)] mb-1.5">手机号</label>
  121. <input
  122. v-model="phoneForm.phone"
  123. type="tel"
  124. maxlength="11"
  125. placeholder="请输入手机号"
  126. 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"
  127. @input="phoneForm.phone = phoneForm.phone.replace(/\D/g, '')"
  128. />
  129. </div>
  130. <!-- 验证码 -->
  131. <div>
  132. <label class="block text-xs font-medium text-[var(--c-475569)] mb-1.5">验证码</label>
  133. <div class="flex gap-3">
  134. <input
  135. v-model="phoneForm.code"
  136. type="text"
  137. maxlength="6"
  138. placeholder="请输入验证码"
  139. 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"
  140. />
  141. <button
  142. :disabled="phoneForm.sendingCode || countdown > 0"
  143. :class="[
  144. 'shrink-0 px-4 py-2.5 text-sm font-medium rounded-lg transition-colors cursor-pointer',
  145. countdown > 0
  146. ? 'text-[var(--c-94a3b8)] bg-[var(--c-f1f5f9)] cursor-not-allowed'
  147. : 'text-[var(--c-0b8cff)] bg-[var(--c-eef7ff)] hover:bg-[var(--c-e0f0ff)]',
  148. ]"
  149. @click="sendCode"
  150. >
  151. {{ countdown > 0 ? `${countdown}s` : '获取验证码' }}
  152. </button>
  153. </div>
  154. </div>
  155. <!-- 开发环境提示(后端未发短信,直接返回验证码) -->
  156. <p v-if="phoneForm.devCode" class="text-xs text-[var(--c-94a3b8)]">
  157. 开发环境验证码:<span class="font-bold text-[var(--c-0b8cff)]">{{ phoneForm.devCode }}</span>(已自动填入)
  158. </p>
  159. <!-- 错误提示 -->
  160. <p v-if="phoneForm.error" class="text-xs text-red-500">{{ phoneForm.error }}</p>
  161. <!-- 登录按钮 -->
  162. <button
  163. :disabled="phoneForm.loading || !phoneForm.phone || !phoneForm.code"
  164. 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"
  165. @click="loginByPhone"
  166. >
  167. <span v-if="phoneForm.loading" class="inline-flex items-center gap-2">
  168. <svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
  169. <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
  170. <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
  171. </svg>
  172. 登录中...
  173. </span>
  174. <span v-else>登录</span>
  175. </button>
  176. </div>
  177. </div>
  178. <!-- 底部协议 -->
  179. <div class="px-8 pb-6 text-center">
  180. <p class="text-xs text-[var(--c-94a3b8)]">
  181. 登录即表示同意
  182. <NuxtLink to="/terms" target="_blank" class="text-[var(--c-0b8cff)] hover:underline">《服务条款》</NuxtLink>
  183. <NuxtLink to="/privacy" target="_blank" class="text-[var(--c-0b8cff)] hover:underline">《隐私政策》</NuxtLink>
  184. </p>
  185. </div>
  186. </div>
  187. </div>
  188. </template>
  189. <script setup>
  190. import VueQr from 'vue-qr'
  191. const emit = defineEmits(['close', 'login-success'])
  192. const { post } = useApi()
  193. const { setLogin } = useUser()
  194. const { inviteCode, withInvite, clearInvite } = useInvite()
  195. const toast = useToast()
  196. // Tab
  197. const activeTab = ref('qr')
  198. // ===== 微信扫码 =====
  199. const qrLoading = ref(true)
  200. const qrExpired = ref(false)
  201. const qrFailed = ref(false)
  202. const qrErrorMessage = ref('')
  203. const qrData = ref({ ticket: '', url: '' })
  204. const qrLogo = '' // 可替换为 logo URL
  205. let qrTimer = null
  206. // 获取二维码
  207. async function refreshQR() {
  208. clearInterval(qrTimer)
  209. qrLoading.value = true
  210. qrExpired.value = false
  211. qrFailed.value = false
  212. qrErrorMessage.value = ''
  213. qrData.value = { ticket: '', url: '' }
  214. try {
  215. // 邀请码必须在创建二维码 Ticket 时写入,扫码回调才能完成邀请归因。
  216. const res = await post('/user/getloginqr', withInvite())
  217. if (res.code === 0 && res.data?.ticket && res.data?.url) {
  218. qrData.value = res.data
  219. qrLoading.value = false
  220. startPolling()
  221. } else {
  222. qrLoadError()
  223. }
  224. } catch (error) {
  225. qrLoadError(error)
  226. }
  227. }
  228. function qrLoadError(error) {
  229. clearInterval(qrTimer)
  230. qrLoading.value = false
  231. qrExpired.value = false
  232. qrFailed.value = true
  233. const message = String(error?.message || '')
  234. qrErrorMessage.value = /access[_ ]?token|登录凭证/i.test(message)
  235. ? '未能获取微信登录凭证,请稍后重试'
  236. : '暂时无法生成二维码,请稍后重试'
  237. }
  238. function markQrExpired() {
  239. clearInterval(qrTimer)
  240. qrLoading.value = false
  241. qrFailed.value = false
  242. qrExpired.value = true
  243. }
  244. // 轮询扫码结果
  245. function startPolling() {
  246. clearInterval(qrTimer)
  247. qrTimer = setInterval(async () => {
  248. try {
  249. const res = await post('/user/scanquery', { ticket: qrData.value.ticket })
  250. if (res.code === 0 && res.data?.token) {
  251. clearInterval(qrTimer)
  252. handleLoginSuccess(res.data)
  253. } else if (res.code === 1010) {
  254. // 二维码过期
  255. markQrExpired()
  256. }
  257. } catch (error) {
  258. // useApi 会把非 0 业务码抛出;1010 需要在异常分支识别为真正过期。
  259. if (Number(error?.code) === 1010) markQrExpired()
  260. // 其他短暂轮询失败不中断
  261. }
  262. }, 1000)
  263. }
  264. // ===== 手机号登录 =====
  265. const phoneForm = reactive({
  266. phone: '',
  267. code: '',
  268. sendingCode: false,
  269. loading: false,
  270. error: '',
  271. devCode: '', // 开发环境后端直接返回的验证码,仅用于提示
  272. })
  273. const countdown = ref(0)
  274. let countdownTimer = null
  275. // 发送验证码
  276. async function sendCode() {
  277. if (!phoneForm.phone || phoneForm.phone.length !== 11) {
  278. phoneForm.error = '请输入正确的11位手机号'
  279. return
  280. }
  281. phoneForm.error = ''
  282. phoneForm.sendingCode = true
  283. try {
  284. const res = await post('/common/sendcode', {
  285. phone: phoneForm.phone,
  286. type: 3,
  287. })
  288. if (res.code === 0) {
  289. // 开发/测试环境后端不发短信,直接把验证码返回,这里自动回填方便调试
  290. if (res.data?.code) {
  291. phoneForm.code = String(res.data.code)
  292. phoneForm.devCode = String(res.data.code)
  293. }
  294. countdown.value = 60
  295. countdownTimer = setInterval(() => {
  296. countdown.value--
  297. if (countdown.value <= 0) {
  298. clearInterval(countdownTimer)
  299. }
  300. }, 1000)
  301. } else {
  302. phoneForm.error = res.msg || '发送验证码失败'
  303. }
  304. } catch (e) {
  305. phoneForm.error = e.message || '发送验证码失败'
  306. } finally {
  307. phoneForm.sendingCode = false
  308. }
  309. }
  310. // 手机号登录
  311. async function loginByPhone() {
  312. if (!phoneForm.phone || !phoneForm.code) return
  313. phoneForm.error = ''
  314. phoneForm.loading = true
  315. try {
  316. const res = await post('/user/login_phone', withInvite({
  317. phone: phoneForm.phone,
  318. code: phoneForm.code,
  319. }))
  320. if (res.code === 0 && res.data?.token) {
  321. handleLoginSuccess(res.data)
  322. } else {
  323. phoneForm.error = res.msg || '登录失败'
  324. }
  325. } catch (e) {
  326. phoneForm.error = e.message || '登录失败'
  327. } finally {
  328. phoneForm.loading = false
  329. }
  330. }
  331. // ===== 公共 =====
  332. function handleLoginSuccess(data) {
  333. const invited = Boolean(inviteCode.value)
  334. setLogin(data)
  335. clearInvite()
  336. toast.add({
  337. title: invited && data.is_register ? '登录并领取成功' : '登录成功',
  338. description: invited && data.is_register ? '邀请奖励点数已自动到账。' : '',
  339. color: 'success',
  340. })
  341. emit('login-success', data)
  342. }
  343. // 初始化
  344. onMounted(() => {
  345. refreshQR()
  346. })
  347. onUnmounted(() => {
  348. clearInterval(qrTimer)
  349. clearInterval(countdownTimer)
  350. })
  351. </script>