#1 增加统一登录

Merged
Pchen0 merged 3 commits from Pchen0/newlogin into Pchen0/main 1 month ago

+ 1 - 1
.env

@@ -1,3 +1,3 @@
 VITE_APP_API_BASE_URL=https://lepao-api.xxoo365.top
 VITE_APP_API_BASE_URL=https://lepao-api.xxoo365.top
 VITE_RSA_PUBLIC_KEY=LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUlJQklqQU5CZ2txaGtpRzl3MEJBUUVGQUFPQ0FROEFNSUlCQ2dLQ0FRRUF6Z1lGYzVRMGVqbTh4akZsSjdMSQpBZDJGeC9TalM0OWQ5emwyZHlaNzNDMGZLU3Fuc1pJQUZkREpWZWV6bUp6T1hOWFdoYVZHaHFwM0dCUWVvcDBKClIxekZ3bUs1em9ReElTTDc5WVF3SmxoSjdaellhL0xNcGtGZDRDVFQ4UzUwTGFzN1FpcUtqRE1BQjFLZEpaTnIKNE5HcjNUWVV4MVVpTzlUTW9YV3lBdFZRQVN2a3lFSVFIb3B4T2Vod0ZuNGRhVE8vLzF5TXRyNnZoclE4enJRMwpxUG01YWJmY0lRM3B1WDVJd1MrekRmSkI5Rm9rc0paa3RWNHI2KzM2U1E3WGp2MDFBQjJvK20yejZqNzNuWjQ1Ci95TEx5NmZHTG5lTWpTaUxHMDhNUWFCUjV1dTNITTRnMkpnanA4eU10Rkg1Tkc5Zys1dXRhTDNzd3JGQjhxd1UKdFFJREFRQUIKLS0tLS1FTkQgUFVCTElDIEtFWS0tLS0tCg==
 VITE_RSA_PUBLIC_KEY=LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUlJQklqQU5CZ2txaGtpRzl3MEJBUUVGQUFPQ0FROEFNSUlCQ2dLQ0FRRUF6Z1lGYzVRMGVqbTh4akZsSjdMSQpBZDJGeC9TalM0OWQ5emwyZHlaNzNDMGZLU3Fuc1pJQUZkREpWZWV6bUp6T1hOWFdoYVZHaHFwM0dCUWVvcDBKClIxekZ3bUs1em9ReElTTDc5WVF3SmxoSjdaellhL0xNcGtGZDRDVFQ4UzUwTGFzN1FpcUtqRE1BQjFLZEpaTnIKNE5HcjNUWVV4MVVpTzlUTW9YV3lBdFZRQVN2a3lFSVFIb3B4T2Vod0ZuNGRhVE8vLzF5TXRyNnZoclE4enJRMwpxUG01YWJmY0lRM3B1WDVJd1MrekRmSkI5Rm9rc0paa3RWNHI2KzM2U1E3WGp2MDFBQjJvK20yejZqNzNuWjQ1Ci95TEx5NmZHTG5lTWpTaUxHMDhNUWFCUjV1dTNITTRnMkpnanA4eU10Rkg1Tkc5Zys1dXRhTDNzd3JGQjhxd1UKdFFJREFRQUIKLS0tLS1FTkQgUFVCTElDIEtFWS0tLS0tCg==
-VITE_APP_VERSION=2.4
+VITE_APP_VERSION=2.5

+ 19 - 1
src/api/login.js

@@ -7,7 +7,9 @@ const api = {
   GetPermissions: '/User/GetPermissions',
   GetPermissions: '/User/GetPermissions',
   sendEmail: '/Captcha/SendEmail',
   sendEmail: '/Captcha/SendEmail',
   GetLoginUrl: '/UniLogin/GetLoginUrl',
   GetLoginUrl: '/UniLogin/GetLoginUrl',
-  UniLogin: '/UniLogin/Login'
+  UniLogin: '/UniLogin/Login',
+  BindSocial: '/UniLogin/BindSocial',
+  UnbindSocial: '/UniLogin/UnbindSocial'
 }
 }
 
 
 export function register(parameter) {
 export function register(parameter) {
@@ -62,4 +64,20 @@ export function uniLogin(parameter) {
     method: 'post',
     method: 'post',
     data: parameter
     data: parameter
   })
   })
+}
+
+export function bindSocial(parameter) {
+  return request({
+    url: api.BindSocial,
+    method: 'post',
+    data: parameter
+  })
+}
+
+export function unbindSocial(parameter) {
+  return request({
+    url: api.UnbindSocial,
+    method: 'post',
+    data: parameter
+  })
 }
 }

+ 2 - 0
src/pages/Login/components/container.vue

@@ -25,6 +25,8 @@ const changeMode = (mode) => {
 onMounted(() => {
 onMounted(() => {
     if(route.query.mode) {
     if(route.query.mode) {
         changeMode(route.query.mode)
         changeMode(route.query.mode)
+    } else if (route.query.type && route.query.code) {
+        changeMode('uniLogin')
     }
     }
 })
 })
 
 

+ 36 - 11
src/pages/Login/uniLogin/uniLogin.vue

@@ -20,7 +20,7 @@
                     </a-tooltip>
                     </a-tooltip>
                 </div>
                 </div>
             </div>
             </div>
-            <div class="tip">未注册的账号登录后将自动注册<br>不同登录方式的账号资产和权限互不共享</div>
+            <div class="tip">未注册的第三方账号登录后将自动注册</div>
             <a-button type="text" class="forgetpass" @click="emit('changeMode', 'login')">账号密码登录</a-button>
             <a-button type="text" class="forgetpass" @click="emit('changeMode', 'login')">账号密码登录</a-button>
         </a-form>
         </a-form>
     </div>
     </div>
@@ -72,16 +72,38 @@ const GetLoginUrl = async (type) => {
 const UniLogin = async (type, code) => {
 const UniLogin = async (type, code) => {
     try {
     try {
         changeLoading(true)
         changeLoading(true)
-        userStore.uniLogin(type, code)
-            .then((res) => { loginSuccess(res) })
-            .catch((err) => {
-                requestFailed(err.message)
-            })
-            .finally(() => {
-                changeLoading(false)
-            })
+        const res = await userStore.uniLogin(type, code)
+        loginSuccess(res)
     } catch (error) {
     } catch (error) {
-        requestFailed('登录失败!请稍后再试')
+        requestFailed(error.message || '登录失败!请稍后再试')
+    } finally {
+        changeLoading(false)
+    }
+}
+
+const BindSocial = async (type, code) => {
+    try {
+        changeLoading(true)
+        const user = await userStore.getInfo()
+        if (!user?.uuid || !user?.session)
+            throw new Error('请先登录后再绑定第三方账号')
+
+        await userStore.bindSocial(type, code)
+        Notification.success({
+            title: '绑定成功',
+            content: `${type === 'qq' ? 'QQ' : '微信'}账号已绑定到当前账户`,
+            duration: 2000
+        })
+
+        if (isElectron())
+            window.electron.openOldWindow(from || '/user/setting')
+        else
+            router.push(from || '/user/setting')
+    } catch (error) {
+        requestFailed(error.message || '绑定失败!请稍后再试')
+        router.push('/user/setting')
+    } finally {
+        changeLoading(false)
     }
     }
 }
 }
 
 
@@ -110,7 +132,10 @@ const requestFailed = (msg) => {
 
 
 onMounted(() => {
 onMounted(() => {
     if (route.query.type && route.query.code) {
     if (route.query.type && route.query.code) {
-        UniLogin(route.query.type, route.query.code)
+        if (route.query.action === 'bind')
+            BindSocial(route.query.type, route.query.code)
+        else
+            UniLogin(route.query.type, route.query.code)
     }
     }
 })
 })
 
 

+ 173 - 0
src/pages/User/setting/components/social-bindings.vue

@@ -0,0 +1,173 @@
+<template>
+  <div class="social-bindings">
+    <div class="section-title">第三方账号</div>
+    <div class="description">绑定 QQ 和微信后,无论使用哪种方式登录,都会进入当前 RunForge 账户。</div>
+
+    <div class="account-list">
+      <div v-for="item in accounts" :key="item.type" class="account-card">
+        <div class="account-info">
+          <a-avatar :size="44" :style="{ backgroundColor: '#FE82A5' }">
+            <img v-if="getBinding(item.type)?.avatar" :src="getBinding(item.type)?.avatar" :alt="item.name" />
+            <icon-qq v-else-if="item.type === 'qq'" />
+            <icon-wechat v-else />
+          </a-avatar>
+          <div>
+            <div class="account-name">{{ item.name }}</div>
+            <a-tag :color="isBound(item.type) ? 'green' : 'gray'" size="small">
+              {{ isBound(item.type) ? '已绑定' : '未绑定' }}
+            </a-tag>
+            <div v-if="isBound(item.type)" class="account-nickname">
+              昵称:{{ getBinding(item.type)?.nickname || '未获取' }}
+            </div>
+          </div>
+        </div>
+
+        <a-button
+          v-if="!isBound(item.type)"
+          type="primary"
+          :loading="state.loadingType === item.type"
+          @click="bind(item.type)"
+        >
+          立即绑定
+        </a-button>
+        <a-button
+          v-else
+          status="danger"
+          :loading="state.loadingType === item.type"
+          @click="unbind(item.type)"
+        >
+          解绑
+        </a-button>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { onBeforeMount, reactive, ref } from 'vue'
+import { getLoginUrl } from '@/api/login'
+import { useUserStore } from '@/store/modules/user'
+import { isElectron } from '@/utils/electron'
+import { Message, Modal, Notification } from '@arco-design/web-vue'
+
+const userStore = useUserStore()
+const user = ref({})
+
+const state = reactive({
+  loadingType: ''
+})
+
+const accounts = [
+  { type: 'qq', name: 'QQ' },
+  { type: 'wx', name: '微信' }
+]
+
+const refreshUser = async () => {
+  user.value = await userStore.getInfoFromServer()
+}
+
+const isBound = (type) => {
+  return user.value?.boundTypes?.includes(type) ||
+    user.value?.socialBindings?.some(item => item.type === type && item.bound)
+}
+
+const getBinding = (type) => {
+  return user.value?.socialBindings?.find(item => item.type === type && item.bound) || null
+}
+
+const bind = async (type) => {
+  try {
+    state.loadingType = type
+    const res = await getLoginUrl({
+      type,
+      mode: 'uniLogin',
+      action: 'bind',
+      from: '/user/setting'
+    })
+    if (!res || res.code !== 0)
+      throw new Error(res?.msg ?? '获取绑定链接失败!')
+
+    if (isElectron())
+      window.electron.openNewWindow(res.data)
+    else
+      window.location.href = res.data
+  } catch (error) {
+    Notification.error({
+      title: '获取绑定链接失败',
+      content: error.message || '请稍后再试'
+    })
+  } finally {
+    state.loadingType = ''
+  }
+}
+
+const unbind = (type) => {
+  const accountName = type === 'qq' ? 'QQ' : '微信'
+  Modal.confirm({
+    title: `解绑${accountName}`,
+    content: `解绑后将无法继续通过该${accountName}账号登录当前账户,是否继续?`,
+    onOk: async () => {
+      try {
+        state.loadingType = type
+        await userStore.unbindSocial(type)
+        await refreshUser()
+        Message.success(`${accountName}解绑成功`)
+      } catch (error) {
+        Notification.error({
+          title: `${accountName}解绑失败`,
+          content: error.message || '请稍后再试'
+        })
+      } finally {
+        state.loadingType = ''
+      }
+    }
+  })
+}
+
+onBeforeMount(refreshUser)
+</script>
+
+<style scoped lang="less">
+.social-bindings {
+  width: 560px;
+  margin: 0 auto;
+}
+
+.description {
+  margin-bottom: 18px;
+  color: var(--color-text-3);
+}
+
+.account-list {
+  display: flex;
+  flex-direction: column;
+  gap: 16px;
+}
+
+.account-card {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 18px;
+  border: 1px solid var(--color-border-2);
+  border-radius: 8px;
+}
+
+.account-info {
+  display: flex;
+  align-items: center;
+  gap: 14px;
+}
+
+.account-name {
+  margin-bottom: 6px;
+  font-size: 16px;
+  font-weight: 600;
+}
+
+.account-nickname {
+  margin-top: 6px;
+  color: var(--color-text-3);
+  font-size: 12px;
+}
+</style>

+ 4 - 0
src/pages/User/setting/index.vue

@@ -15,6 +15,9 @@
           <a-tab-pane key="2" title="安全设置">
           <a-tab-pane key="2" title="安全设置">
             <SecuritySettings />
             <SecuritySettings />
           </a-tab-pane>
           </a-tab-pane>
+          <a-tab-pane key="3" title="第三方账号">
+            <SocialBindings />
+          </a-tab-pane>
         </a-tabs>
         </a-tabs>
       </a-col>
       </a-col>
     </a-row>
     </a-row>
@@ -25,6 +28,7 @@
 import UserPanel from './components/user-panel.vue'
 import UserPanel from './components/user-panel.vue'
 import BasicInformation from './components/basic-information.vue'
 import BasicInformation from './components/basic-information.vue'
 import SecuritySettings from './components/security-settings.vue'
 import SecuritySettings from './components/security-settings.vue'
+import SocialBindings from './components/social-bindings.vue'
 </script>
 </script>
 
 
 <style scoped lang="less">
 <style scoped lang="less">

+ 66 - 8
src/pages/admin/user/userList.vue

@@ -72,6 +72,33 @@
                 <template #lastTime="{ record }">
                 <template #lastTime="{ record }">
                     {{ stramptoTime(record.lastTime ?? record.registTime) }}
                     {{ stramptoTime(record.lastTime ?? record.registTime) }}
                 </template>
                 </template>
+                <template #qq_bind="{ record }">
+                    <template v-if="record.qq_social_nickname || record.qq_social_avatar">
+                        <a-space>
+                            <a-avatar :size="24">
+                                <img v-if="record.qq_social_avatar" :alt="record.qq_social_nickname || 'QQ'" :src="record.qq_social_avatar" />
+                                <icon-qq v-else />
+                            </a-avatar>
+                            <span>{{ record.qq_social_nickname || '未获取' }}</span>
+                        </a-space>
+                    </template>
+                    <span v-else class="muted">未绑定</span>
+                </template>
+                <template #wx_bind="{ record }">
+                    <template v-if="record.wx_social_nickname || record.wx_social_avatar">
+                        <a-space>
+                            <a-avatar :size="24">
+                                <img v-if="record.wx_social_avatar" :alt="record.wx_social_nickname || '微信'" :src="record.wx_social_avatar" />
+                                <icon-wechat v-else />
+                            </a-avatar>
+                            <span>{{ record.wx_social_nickname || '未获取' }}</span>
+                        </a-space>
+                    </template>
+                    <span v-else class="muted">未绑定</span>
+                </template>
+                <template #last_login_type="{ record }">
+                    <span>{{ formatLastLoginType(record.last_login_type) }}</span>
+                </template>
                 <template #optional="{ record }">
                 <template #optional="{ record }">
                     <a-button @click="changeCount(record)">更改次数</a-button>
                     <a-button @click="changeCount(record)">更改次数</a-button>
                 </template>
                 </template>
@@ -130,31 +157,48 @@ const columns = [
     {
     {
         title: 'UUID',
         title: 'UUID',
         dataIndex: 'uuid',
         dataIndex: 'uuid',
+        width: 160,
     }, {
     }, {
         title: '用户名',
         title: '用户名',
         slotName: 'username',
         slotName: 'username',
-    }, {
-        title: '昵称',
-        dataIndex: 'nickname',
+        width: 160,
     }, {
     }, {
         title: '邮箱',
         title: '邮箱',
         dataIndex: 'email',
         dataIndex: 'email',
+        width: 180,
+    },
+    {
+        title: 'QQ 绑定',
+        slotName: 'qq_bind',
+        width: 150,
     },
     },
     {
     {
-        title: '登陆方式',
-        dataIndex: 'social_type',
+        title: '微信绑定',
+        slotName: 'wx_bind',
+        width: 150,
+    },
+    {
+        title: '登录方式',
+        slotName: 'last_login_type',
+        width: 120,
     }, {
     }, {
         title: '注册时间',
         title: '注册时间',
-        slotName: 'registTime'
+        slotName: 'registTime',
+        width: 160,
     }, {
     }, {
         title: '上次登录时间',
         title: '上次登录时间',
-        slotName: 'lastTime'
+        slotName: 'lastTime',
+        width: 160,
     }, {
     }, {
         title: '剩余乐跑次数',
         title: '剩余乐跑次数',
         dataIndex: 'lepao_count',
         dataIndex: 'lepao_count',
+        width: 120,
     }, {
     }, {
         title: '操作',
         title: '操作',
-        slotName: 'optional'
+        slotName: 'optional',
+        width: 100,
+        align: 'center',
+        fixed: 'right'
     }]
     }]
 
 
 const search = () => {
 const search = () => {
@@ -263,6 +307,16 @@ const stramptoTime = (time) => {
     return new Date(time).toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
     return new Date(time).toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
 }
 }
 
 
+/** login_history.type:最近一次登录方式 */
+const formatLastLoginType = (type) => {
+    if (type === null || type === undefined || type === '') return '无记录'
+    const t = String(type).toLowerCase()
+    if (t === 'password') return '账号密码'
+    if (t === 'qq') return 'QQ'
+    if (t === 'wx') return '微信'
+    return type
+}
+
 </script>
 </script>
 
 
 <style scoped>
 <style scoped>
@@ -273,4 +327,8 @@ const stramptoTime = (time) => {
 .table {
 .table {
     margin-top: 15px;
     margin-top: 15px;
 }
 }
+
+.muted {
+    color: var(--color-text-3);
+}
 </style>
 </style>

+ 28 - 2
src/store/modules/user.js

@@ -1,7 +1,7 @@
 import { defineStore } from 'pinia'
 import { defineStore } from 'pinia'
 import storage from 'store'
 import storage from 'store'
 import expirePlugin from 'store/plugins/expire'
 import expirePlugin from 'store/plugins/expire'
-import { login, uniLogin } from '@/api/login'
+import { bindSocial, login, unbindSocial, uniLogin } from '@/api/login'
 import { ChangeUsername, GetUserInfo } from '@/api/user'
 import { ChangeUsername, GetUserInfo } from '@/api/user'
 
 
 storage.addPlugin(expirePlugin)
 storage.addPlugin(expirePlugin)
@@ -13,7 +13,9 @@ export const useUserStore = defineStore('user', {
     username: '',
     username: '',
     avatar: '',
     avatar: '',
     email: '',
     email: '',
-    roles: []
+    roles: [],
+    socialBindings: [],
+    boundTypes: []
   }),
   }),
 
 
   actions: { 
   actions: { 
@@ -42,6 +44,28 @@ export const useUserStore = defineStore('user', {
       }
       }
     },
     },
 
 
+    async bindSocial(type, code) {
+      try {
+        const res = await bindSocial({ type, code })
+        if (!res || res.code !== 0) throw new Error(res?.msg ?? '绑定失败!请稍后再试')
+        const user = await this.getInfoFromServer()
+        return res.data || user
+      } catch (error) {
+        throw error
+      }
+    },
+
+    async unbindSocial(type) {
+      try {
+        const res = await unbindSocial({ type })
+        if (!res || res.code !== 0) throw new Error(res?.msg ?? '解绑失败!请稍后再试')
+        const user = await this.getInfoFromServer()
+        return res.data || user
+      } catch (error) {
+        throw error
+      }
+    },
+
     // 更新用户名
     // 更新用户名
     async changeName(username) {
     async changeName(username) {
       try {
       try {
@@ -90,6 +114,8 @@ export const useUserStore = defineStore('user', {
       this.username = user.username || ''
       this.username = user.username || ''
       this.email = user.email || ''
       this.email = user.email || ''
       this.roles = user.roles || []
       this.roles = user.roles || []
+      this.socialBindings = user.socialBindings || []
+      this.boundTypes = user.boundTypes || []
     }
     }
   }
   }
 })
 })