Você não pode selecionar mais de 25 tópicos Os tópicos devem começar com uma letra ou um número, podem incluir traços ('-') e podem ter até 35 caracteres.
 
 
 
 

319 linhas
10 KiB

  1. <template>
  2. <div class="fixed inset-0 z-[999] flex items-center justify-center">
  3. <!-- 遮罩 -->
  4. <div class="absolute inset-0 bg-black/40 backdrop-blur-sm" @click="$emit('close')" />
  5. <!-- 弹窗 -->
  6. <div class="relative w-full max-w-md mx-4 bg-white rounded-2xl shadow-2xl overflow-hidden">
  7. <!-- 关闭按钮 -->
  8. <button
  9. class="absolute top-4 right-4 z-10 w-8 h-8 flex items-center justify-center rounded-full text-slate-400 hover:text-slate-600 hover:bg-slate-100 transition-colors cursor-pointer"
  10. @click="$emit('close')"
  11. >
  12. <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
  13. <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
  14. </svg>
  15. </button>
  16. <!-- 标题 -->
  17. <div class="px-8 pt-8 pb-4 text-center">
  18. <h2 class="text-xl font-bold text-slate-900">登录奇想宇宙</h2>
  19. <p class="mt-1 text-sm text-slate-500">登录后享受更多 AI 创作能力</p>
  20. </div>
  21. <!-- Tab 切换 -->
  22. <div class="px-8">
  23. <div class="flex border-b border-slate-200">
  24. <button
  25. :class="[
  26. 'flex-1 pb-3 text-sm font-medium transition-colors border-b-2 cursor-pointer',
  27. activeTab === 'qr'
  28. ? 'text-indigo-600 border-indigo-500'
  29. : 'text-slate-400 border-transparent hover:text-slate-600',
  30. ]"
  31. @click="activeTab = 'qr'"
  32. >
  33. 微信扫码登录
  34. </button>
  35. <button
  36. :class="[
  37. 'flex-1 pb-3 text-sm font-medium transition-colors border-b-2 cursor-pointer',
  38. activeTab === 'phone'
  39. ? 'text-indigo-600 border-indigo-500'
  40. : 'text-slate-400 border-transparent hover:text-slate-600',
  41. ]"
  42. @click="activeTab = 'phone'"
  43. >
  44. 手机号登录
  45. </button>
  46. </div>
  47. </div>
  48. <!-- 微信扫码登录 -->
  49. <div v-if="activeTab === 'qr'" class="px-8 py-6">
  50. <div class="flex flex-col items-center">
  51. <!-- 二维码区域 -->
  52. <div
  53. class="relative w-48 h-48 rounded-xl border border-slate-200 flex items-center justify-center bg-white"
  54. >
  55. <!-- 加载中 -->
  56. <div v-if="qrLoading" class="flex flex-col items-center gap-2">
  57. <svg class="w-8 h-8 text-indigo-500 animate-spin" fill="none" viewBox="0 0 24 24">
  58. <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
  59. <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
  60. </svg>
  61. <span class="text-xs text-slate-400">获取二维码...</span>
  62. </div>
  63. <!-- 二维码过期 -->
  64. <div
  65. v-else-if="qrExpired"
  66. class="flex flex-col items-center gap-3 cursor-pointer"
  67. @click="refreshQR"
  68. >
  69. <svg class="w-10 h-10 text-slate-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
  70. <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" />
  71. </svg>
  72. <span class="text-sm text-slate-400">二维码已过期</span>
  73. <span class="text-xs text-indigo-500">点击刷新</span>
  74. </div>
  75. <!-- 二维码 -->
  76. <vue-qr
  77. v-else-if="qrData.url"
  78. :text="qrData.url"
  79. :size="180"
  80. :margin="8"
  81. :logo-image="qrLogo"
  82. :logo-size="36"
  83. />
  84. </div>
  85. <p class="mt-4 text-sm text-slate-500 text-center">
  86. 请使用微信扫描二维码完成登录
  87. </p>
  88. </div>
  89. </div>
  90. <!-- 手机号登录 -->
  91. <div v-if="activeTab === 'phone'" class="px-8 py-6">
  92. <div class="space-y-4">
  93. <!-- 手机号 -->
  94. <div>
  95. <label class="block text-xs font-medium text-slate-600 mb-1.5">手机号</label>
  96. <input
  97. v-model="phoneForm.phone"
  98. type="tel"
  99. maxlength="11"
  100. placeholder="请输入手机号"
  101. class="w-full px-3 py-2.5 text-sm border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500/20 focus:border-indigo-400 transition-colors"
  102. @input="phoneForm.phone = phoneForm.phone.replace(/\D/g, '')"
  103. />
  104. </div>
  105. <!-- 验证码 -->
  106. <div>
  107. <label class="block text-xs font-medium text-slate-600 mb-1.5">验证码</label>
  108. <div class="flex gap-3">
  109. <input
  110. v-model="phoneForm.code"
  111. type="text"
  112. maxlength="6"
  113. placeholder="请输入验证码"
  114. class="flex-1 px-3 py-2.5 text-sm border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500/20 focus:border-indigo-400 transition-colors"
  115. />
  116. <button
  117. :disabled="phoneForm.sendingCode || countdown > 0"
  118. :class="[
  119. 'shrink-0 px-4 py-2.5 text-sm font-medium rounded-lg transition-colors cursor-pointer',
  120. countdown > 0
  121. ? 'text-slate-400 bg-slate-100 cursor-not-allowed'
  122. : 'text-indigo-600 bg-indigo-50 hover:bg-indigo-100',
  123. ]"
  124. @click="sendCode"
  125. >
  126. {{ countdown > 0 ? `${countdown}s` : '获取验证码' }}
  127. </button>
  128. </div>
  129. </div>
  130. <!-- 错误提示 -->
  131. <p v-if="phoneForm.error" class="text-xs text-red-500">{{ phoneForm.error }}</p>
  132. <!-- 登录按钮 -->
  133. <button
  134. :disabled="phoneForm.loading || !phoneForm.phone || !phoneForm.code"
  135. class="w-full py-2.5 text-sm font-semibold text-white bg-indigo-500 hover:bg-indigo-600 disabled:bg-slate-300 disabled:cursor-not-allowed rounded-lg transition-colors cursor-pointer"
  136. @click="loginByPhone"
  137. >
  138. <span v-if="phoneForm.loading" class="inline-flex items-center gap-2">
  139. <svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
  140. <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
  141. <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
  142. </svg>
  143. 登录中...
  144. </span>
  145. <span v-else>登录</span>
  146. </button>
  147. </div>
  148. </div>
  149. <!-- 底部协议 -->
  150. <div class="px-8 pb-6 text-center">
  151. <p class="text-xs text-slate-400">
  152. 登录即表示同意
  153. <a href="#" class="text-indigo-500 hover:underline">《服务条款》</a>
  154. <a href="#" class="text-indigo-500 hover:underline">《隐私政策》</a>
  155. </p>
  156. </div>
  157. </div>
  158. </div>
  159. </template>
  160. <script setup>
  161. import VueQr from 'vue-qr'
  162. const emit = defineEmits(['close', 'login-success'])
  163. const { post } = useApi()
  164. const { setLogin } = useUser()
  165. // Tab
  166. const activeTab = ref('qr')
  167. // ===== 微信扫码 =====
  168. const qrLoading = ref(true)
  169. const qrExpired = ref(false)
  170. const qrData = ref({ ticket: '', url: '' })
  171. const qrLogo = '' // 可替换为 logo URL
  172. let qrTimer = null
  173. // 获取二维码
  174. async function refreshQR() {
  175. qrLoading.value = true
  176. qrExpired.value = false
  177. try {
  178. const res = await post('/user/getloginqr')
  179. if (res.code === 0 && res.data) {
  180. qrData.value = res.data
  181. qrLoading.value = false
  182. startPolling()
  183. } else {
  184. qrLoadError()
  185. }
  186. } catch {
  187. qrLoadError()
  188. }
  189. }
  190. function qrLoadError() {
  191. qrLoading.value = false
  192. qrExpired.value = true
  193. }
  194. // 轮询扫码结果
  195. function startPolling() {
  196. clearInterval(qrTimer)
  197. qrTimer = setInterval(async () => {
  198. try {
  199. const res = await post('/user/scanquery', { ticket: qrData.value.ticket })
  200. if (res.code === 0 && res.data?.token) {
  201. clearInterval(qrTimer)
  202. handleLoginSuccess(res.data)
  203. } else if (res.code === 1010) {
  204. // 二维码过期
  205. clearInterval(qrTimer)
  206. qrExpired.value = true
  207. }
  208. } catch {
  209. // 轮询失败不中断
  210. }
  211. }, 1000)
  212. }
  213. // ===== 手机号登录 =====
  214. const phoneForm = reactive({
  215. phone: '',
  216. code: '',
  217. sendingCode: false,
  218. loading: false,
  219. error: '',
  220. })
  221. const countdown = ref(0)
  222. let countdownTimer = null
  223. // 发送验证码
  224. async function sendCode() {
  225. if (!phoneForm.phone || phoneForm.phone.length !== 11) {
  226. phoneForm.error = '请输入正确的11位手机号'
  227. return
  228. }
  229. phoneForm.error = ''
  230. phoneForm.sendingCode = true
  231. try {
  232. const res = await post('/common/sendcode', {
  233. phone: phoneForm.phone,
  234. type: 3,
  235. })
  236. if (res.code === 0) {
  237. countdown.value = 60
  238. countdownTimer = setInterval(() => {
  239. countdown.value--
  240. if (countdown.value <= 0) {
  241. clearInterval(countdownTimer)
  242. }
  243. }, 1000)
  244. } else {
  245. phoneForm.error = res.msg || '发送验证码失败'
  246. }
  247. } catch (e) {
  248. phoneForm.error = e.message || '发送验证码失败'
  249. } finally {
  250. phoneForm.sendingCode = false
  251. }
  252. }
  253. // 手机号登录
  254. async function loginByPhone() {
  255. if (!phoneForm.phone || !phoneForm.code) return
  256. phoneForm.error = ''
  257. phoneForm.loading = true
  258. try {
  259. const res = await post('/user/login_phone', {
  260. phone: phoneForm.phone,
  261. code: phoneForm.code,
  262. })
  263. if (res.code === 0 && res.data?.token) {
  264. handleLoginSuccess(res.data)
  265. } else {
  266. phoneForm.error = res.msg || '登录失败'
  267. }
  268. } catch (e) {
  269. phoneForm.error = e.message || '登录失败'
  270. } finally {
  271. phoneForm.loading = false
  272. }
  273. }
  274. // ===== 公共 =====
  275. function handleLoginSuccess(data) {
  276. setLogin(data)
  277. emit('login-success', data)
  278. }
  279. // 初始化
  280. onMounted(() => {
  281. refreshQR()
  282. })
  283. onUnmounted(() => {
  284. clearInterval(qrTimer)
  285. clearInterval(countdownTimer)
  286. })
  287. </script>