🆕 对接登录、路由、资源
This commit is contained in:
35
.env.dev
35
.env.dev
@@ -1,10 +1,35 @@
|
||||
# 后端服务地址
|
||||
VITE_SERVICE_URL="http://localhost:9095/api/manage"
|
||||
# 项目标题
|
||||
VITE_APP_TITLE="HuLa—校园平台"
|
||||
VITE_APP_TITLE="HuLa Admin"
|
||||
# 标签后缀
|
||||
VITE_TITLE_SUFFIX=" | HuLa"
|
||||
# 项目备案号
|
||||
VITE_APP_ICP="桂ICP备2021000000号"
|
||||
VITE_APP_ICP="粤ICP备2025455944号"
|
||||
# 项目名称
|
||||
VITE_APP_NAME="HuLa-vue3"
|
||||
VITE_APP_NAME="HuLa-Admin"
|
||||
|
||||
# Gateway 地址
|
||||
VITE_API_BASE_URL=http://localhost:18760
|
||||
|
||||
# 是否启用代理(Y/N)
|
||||
VITE_HTTP_PROXY=Y
|
||||
|
||||
# Authorization 密钥
|
||||
VITE_SECRET_KEY=luohuo_web_pro:luohuo_web_pro_secret
|
||||
|
||||
# 应用环境
|
||||
VITE_APP_ENV=dev
|
||||
|
||||
# 后端服务响应码配置
|
||||
# 成功响应码
|
||||
VITE_SERVICE_SUCCESS_CODE=200
|
||||
|
||||
# 需要退出登录的错误码(多个用逗号分隔)
|
||||
# 401: 未授权/登录过期
|
||||
VITE_SERVICE_LOGOUT_CODES=401
|
||||
|
||||
# 需要弹窗提示后退出登录的错误码(多个用逗号分隔)
|
||||
VITE_SERVICE_MODAL_LOGOUT_CODES=406
|
||||
|
||||
# token 过期需要刷新的错误码(多个用逗号分隔)
|
||||
# 注意:目前暂不支持自动刷新 token,406 直接退出登录
|
||||
VITE_SERVICE_EXPIRED_TOKEN_CODES=
|
||||
|
||||
@@ -39,8 +39,10 @@
|
||||
"lint:staged": "lint-staged"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fingerprintjs/fingerprintjs": "^5.0.1",
|
||||
"@intlify/unplugin-vue-i18n": "^0.11.0",
|
||||
"@types/crypto-js": "^4.2.1",
|
||||
"@vicons/ionicons5": "^0.13.0",
|
||||
"@vicons/tabler": "^0.12.0",
|
||||
"@vueuse/core": "^10.7.0",
|
||||
"animate.css": "^4.1.1",
|
||||
|
||||
17
pnpm-lock.yaml
generated
17
pnpm-lock.yaml
generated
@@ -5,12 +5,18 @@ settings:
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
dependencies:
|
||||
'@fingerprintjs/fingerprintjs':
|
||||
specifier: ^5.0.1
|
||||
version: 5.0.1
|
||||
'@intlify/unplugin-vue-i18n':
|
||||
specifier: ^0.11.0
|
||||
version: 0.11.0(vue-i18n@9.8.0)
|
||||
'@types/crypto-js':
|
||||
specifier: ^4.2.1
|
||||
version: 4.2.1
|
||||
'@vicons/ionicons5':
|
||||
specifier: ^0.13.0
|
||||
version: 0.13.0
|
||||
'@vicons/tabler':
|
||||
specifier: ^0.12.0
|
||||
version: 0.12.0
|
||||
@@ -1419,6 +1425,10 @@ packages:
|
||||
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
|
||||
dev: true
|
||||
|
||||
/@fingerprintjs/fingerprintjs@5.0.1:
|
||||
resolution: {integrity: sha512-KbaeE/rk2WL8MfpRP6jTI4lSr42SJPjvkyrjP3QU6uUDkOMWWYC2Ts1sNSYcegHC8avzOoYTHBj+2fTqvZWQBA==}
|
||||
dev: false
|
||||
|
||||
/@humanwhocodes/config-array@0.11.13:
|
||||
resolution: {integrity: sha512-JSBDMiDKSzQVngfRjOdFXgFfklaXI4K9nLF49Auh21lmBWRLIK3+xTErTWD4KU54pb6coM6ESE7Awz/FNU3zgQ==}
|
||||
engines: {node: '>=10.10.0'}
|
||||
@@ -2074,6 +2084,10 @@ packages:
|
||||
resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==}
|
||||
dev: true
|
||||
|
||||
/@vicons/ionicons5@0.13.0:
|
||||
resolution: {integrity: sha512-zvZKBPjEXKN7AXNo2Na2uy+nvuv6SP4KAMQxpKL2vfHMj0fSvuw7JZcOPCjQC3e7ayssKnaoFVAhbYcW6v41qQ==}
|
||||
dev: false
|
||||
|
||||
/@vicons/tabler@0.12.0:
|
||||
resolution: {integrity: sha512-3+wUFuxb7e8OzZ8Wryct1pzfA2vyoF4lwW98O9s27ZrfCGaJGNmqG+q8A7vQ92Mf+COCgxpK+rhNPTtTvaU6qw==}
|
||||
dev: false
|
||||
@@ -2588,6 +2602,7 @@ packages:
|
||||
|
||||
/acorn-import-assertions@1.9.0(acorn@8.11.3):
|
||||
resolution: {integrity: sha512-cmMwop9x+8KFhxvKrKfPYmN6/pKTYYHBqLa0DfvVZcKMJWNyWLnaqND7dx/qn66R7ewM1UX5XMaDVP5wlVTaVA==}
|
||||
deprecated: package has been renamed to acorn-import-attributes
|
||||
peerDependencies:
|
||||
acorn: ^8
|
||||
dependencies:
|
||||
@@ -6709,7 +6724,7 @@ packages:
|
||||
dev: true
|
||||
|
||||
/signal-exit@4.1.0:
|
||||
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==, tarball: https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz}
|
||||
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
|
||||
engines: {node: '>=14'}
|
||||
dev: true
|
||||
|
||||
|
||||
22
src/App.vue
22
src/App.vue
@@ -3,7 +3,11 @@
|
||||
<div id="app">
|
||||
<n-notification-provider :max="3">
|
||||
<n-message-provider :max="3">
|
||||
<router-view />
|
||||
<n-dialog-provider>
|
||||
<n-loading-bar-provider>
|
||||
<Content />
|
||||
</n-loading-bar-provider>
|
||||
</n-dialog-provider>
|
||||
</n-message-provider>
|
||||
</n-notification-provider>
|
||||
</div>
|
||||
@@ -13,7 +17,8 @@
|
||||
<script setup lang="ts">
|
||||
import { mainStore } from '@/stores/main'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { darkTheme, dateZhCN, zhCN } from 'naive-ui'
|
||||
import { darkTheme, dateZhCN, zhCN, useDialog, useMessage, useNotification, useLoadingBar } from 'naive-ui'
|
||||
import { RouterView } from 'vue-router'
|
||||
|
||||
const store = mainStore()
|
||||
const NLanguage = ref(zhCN)
|
||||
@@ -26,6 +31,19 @@ const theme = ref<any>(EYE_THEME.value)
|
||||
watchEffect(() => {
|
||||
theme.value = EYE_THEME.value ? darkTheme : null
|
||||
})
|
||||
|
||||
const Content = defineComponent({
|
||||
setup() {
|
||||
// 挂载全局 API 到 window
|
||||
window.$dialog = useDialog()
|
||||
window.$message = useMessage()
|
||||
window.$notification = useNotification()
|
||||
window.$loadingBar = useLoadingBar()
|
||||
},
|
||||
render() {
|
||||
return h(RouterView)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
|
||||
105
src/api/auth.ts
Normal file
105
src/api/auth.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { request } from '@/utils/http'
|
||||
import { RequestModule } from '@/enums/request'
|
||||
import type { LoginParams, LoginResponse, CaptchaResponse, UserInfo, ApiResponse } from '@/types/api'
|
||||
|
||||
/**
|
||||
* 登录
|
||||
* @param data 登录参数
|
||||
*/
|
||||
export function loginApi(data: LoginParams): Promise<ApiResponse<LoginResponse>> {
|
||||
return request({
|
||||
url: '/anyTenant/login',
|
||||
method: 'post',
|
||||
data,
|
||||
module: RequestModule.OAUTH,
|
||||
needToken: false
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取验证码
|
||||
*/
|
||||
export function getCaptchaApi(): Promise<ApiResponse<CaptchaResponse>> {
|
||||
return request({
|
||||
url: '/anyTenant/captcha',
|
||||
method: 'get',
|
||||
module: RequestModule.OAUTH,
|
||||
needToken: false
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户信息
|
||||
* 后端从 token 中获取当前登录用户的 userId
|
||||
*/
|
||||
export function getUserInfoApi(): Promise<ApiResponse<UserInfo>> {
|
||||
return request({
|
||||
url: '/anyone/getUserInfo',
|
||||
method: 'get',
|
||||
module: RequestModule.OAUTH
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新 Token
|
||||
* @param refreshToken 刷新令牌
|
||||
*/
|
||||
export function refreshTokenApi(refreshToken: string): Promise<ApiResponse<LoginResponse>> {
|
||||
return request({
|
||||
url: '/anyTenant/login',
|
||||
method: 'post',
|
||||
data: {
|
||||
grantType: 'REFRESH_TOKEN',
|
||||
refreshToken
|
||||
},
|
||||
module: RequestModule.OAUTH,
|
||||
needToken: false
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 退出登录
|
||||
* @param data 退出参数
|
||||
*/
|
||||
export function logoutApi(data: { token: string; refreshToken?: string }): Promise<ApiResponse> {
|
||||
return request({
|
||||
url: '/anyUser/logout',
|
||||
method: 'post',
|
||||
data,
|
||||
module: RequestModule.OAUTH
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改密码
|
||||
* @param data 修改密码参数
|
||||
*/
|
||||
export function updatePasswordApi(data: {
|
||||
oldPassword: string
|
||||
newPassword: string
|
||||
confirmPassword: string
|
||||
}): Promise<ApiResponse> {
|
||||
return request({
|
||||
url: '/anyUser/updatePassword',
|
||||
method: 'post',
|
||||
data,
|
||||
module: RequestModule.OAUTH
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换租户和组织
|
||||
* @param data 切换参数
|
||||
*/
|
||||
export function switchTenantAndOrgApi(data: {
|
||||
orgId?: string
|
||||
clientId: string
|
||||
}): Promise<ApiResponse<LoginResponse>> {
|
||||
return request({
|
||||
url: '/anyone/switchTenantAndOrg',
|
||||
method: 'put',
|
||||
params: data,
|
||||
module: RequestModule.OAUTH
|
||||
})
|
||||
}
|
||||
|
||||
122
src/api/route.ts
Normal file
122
src/api/route.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { request } from '@/utils/http'
|
||||
import { RequestModule } from '@/enums/request'
|
||||
import type { RouteItem, UserRoutesResponse, ResourceTreeNode, ApiResponse } from '@/types/api'
|
||||
|
||||
/**
|
||||
* 获取公共路由
|
||||
* @param applicationId 应用 ID
|
||||
*/
|
||||
export function getConstantRoutesApi(applicationId: number = 1): Promise<ApiResponse<RouteItem[]>> {
|
||||
return request({
|
||||
url: '/anyTenant/menu/initRoute',
|
||||
method: 'get',
|
||||
params: { applicationId },
|
||||
module: RequestModule.OAUTH,
|
||||
needToken: false
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户路由(需要登录)
|
||||
* @param applicationId 应用 ID
|
||||
*/
|
||||
export function getUserRoutesApi(applicationId: number = 1): Promise<ApiResponse<UserRoutesResponse>> {
|
||||
return request({
|
||||
url: '/anyone/visible/resource',
|
||||
method: 'get',
|
||||
params: { applicationId },
|
||||
module: RequestModule.OAUTH
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查路由是否存在
|
||||
* @param routeName 路由名称
|
||||
*/
|
||||
export function checkRouteExistApi(routeName: string): Promise<ApiResponse<boolean>> {
|
||||
return request({
|
||||
url: '/defResource/isRouteExist',
|
||||
method: 'get',
|
||||
params: { routeName },
|
||||
module: RequestModule.BASE
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取资源树(用于菜单管理)
|
||||
* @param applicationId 应用 ID
|
||||
*/
|
||||
export function getResourceTreeApi(applicationId: number = 1): Promise<ApiResponse<ResourceTreeNode[]>> {
|
||||
return request({
|
||||
url: '/defResource/tree',
|
||||
method: 'post',
|
||||
params: { applicationId },
|
||||
module: RequestModule.BASE
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取可见资源(菜单 + 视图)
|
||||
* @param applicationId 应用 ID
|
||||
*/
|
||||
export function getVisibleResourceApi(applicationId: number = 1): Promise<ApiResponse<RouteItem[]>> {
|
||||
return request({
|
||||
url: '/defResource/visible',
|
||||
method: 'get',
|
||||
params: { applicationId },
|
||||
module: RequestModule.BASE
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增资源
|
||||
* @param data 资源数据
|
||||
*/
|
||||
export function addResourceApi(data: Partial<ResourceTreeNode>): Promise<ApiResponse> {
|
||||
return request({
|
||||
url: '/defResource/add',
|
||||
method: 'post',
|
||||
data,
|
||||
module: RequestModule.BASE
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑资源
|
||||
* @param data 资源数据
|
||||
*/
|
||||
export function editResourceApi(data: Partial<ResourceTreeNode>): Promise<ApiResponse> {
|
||||
return request({
|
||||
url: '/defResource/edit',
|
||||
method: 'post',
|
||||
data,
|
||||
module: RequestModule.BASE
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除资源
|
||||
* @param data 删除参数
|
||||
*/
|
||||
export function deleteResourceApi(data: { id?: string; ids?: string[] }): Promise<ApiResponse> {
|
||||
return request({
|
||||
url: '/defResource/del',
|
||||
method: 'post',
|
||||
data,
|
||||
module: RequestModule.BASE
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取资源详情
|
||||
* @param id 资源 ID
|
||||
*/
|
||||
export function getResourceDetailApi(id: string): Promise<ApiResponse<ResourceTreeNode>> {
|
||||
return request({
|
||||
url: '/defResource/detail',
|
||||
method: 'get',
|
||||
params: { id },
|
||||
module: RequestModule.BASE
|
||||
})
|
||||
}
|
||||
|
||||
54
src/api/tenant.ts
Normal file
54
src/api/tenant.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { request } from '@/utils/http'
|
||||
import { RequestModule } from '@/enums/request'
|
||||
import type { TenantInfo, ApiResponse } from '@/types/api'
|
||||
|
||||
/**
|
||||
* 获取所有正常状态的租户列表
|
||||
* 需要登录后调用
|
||||
*/
|
||||
export function getTenantListApi(): Promise<ApiResponse<TenantInfo[]>> {
|
||||
return request({
|
||||
url: '/defTenant/all',
|
||||
method: 'get',
|
||||
module: RequestModule.BASE
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户的可用租户列表
|
||||
* 需要登录后调用
|
||||
* 后端会从 token 中解析 userId,无需前端传递
|
||||
*/
|
||||
export function getUserTenantListApi(): Promise<ApiResponse<TenantInfo[]>> {
|
||||
return request({
|
||||
url: `/defTenant/listTenantByUserId`,
|
||||
method: 'get',
|
||||
module: RequestModule.BASE
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测租户编码是否存在
|
||||
* @param code 租户编码
|
||||
*/
|
||||
export function checkTenantCodeApi(code: string): Promise<ApiResponse<boolean>> {
|
||||
return request({
|
||||
url: `/defTenant/check/${code}`,
|
||||
method: 'get',
|
||||
module: RequestModule.BASE
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取租户详情
|
||||
* @param id 租户 ID
|
||||
*/
|
||||
export function getTenantDetailApi(id: string): Promise<ApiResponse<TenantInfo>> {
|
||||
return request({
|
||||
url: `/defTenant/detail`,
|
||||
method: 'get',
|
||||
params: { id },
|
||||
module: RequestModule.BASE
|
||||
})
|
||||
}
|
||||
|
||||
138
src/api/user.ts
Normal file
138
src/api/user.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { request } from '@/utils/http'
|
||||
import { RequestModule } from '@/enums/request'
|
||||
import type { UserInfo, PageParams, PageResponse, ApiResponse } from '@/types/api'
|
||||
|
||||
/**
|
||||
* 获取用户列表(分页)
|
||||
* @param params 分页参数
|
||||
*/
|
||||
export function getUserListApi(params: PageParams & Record<string, any>): Promise<ApiResponse<PageResponse<UserInfo>>> {
|
||||
return request({
|
||||
url: '/user/page',
|
||||
method: 'get',
|
||||
params,
|
||||
module: RequestModule.BASE
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户详情
|
||||
* @param id 用户 ID
|
||||
*/
|
||||
export function getUserDetailApi(id: number | string): Promise<ApiResponse<UserInfo>> {
|
||||
return request({
|
||||
url: '/user/detail',
|
||||
method: 'get',
|
||||
params: { id },
|
||||
module: RequestModule.BASE
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增用户
|
||||
* @param data 用户数据
|
||||
*/
|
||||
export function addUserApi(data: Partial<UserInfo>): Promise<ApiResponse> {
|
||||
return request({
|
||||
url: '/user/add',
|
||||
method: 'post',
|
||||
data,
|
||||
module: RequestModule.BASE
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑用户
|
||||
* @param data 用户数据
|
||||
*/
|
||||
export function editUserApi(data: Partial<UserInfo>): Promise<ApiResponse> {
|
||||
return request({
|
||||
url: '/user/edit',
|
||||
method: 'post',
|
||||
data,
|
||||
module: RequestModule.BASE
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除用户
|
||||
* @param data 删除参数
|
||||
*/
|
||||
export function deleteUserApi(data: { id?: number; ids?: number[] }): Promise<ApiResponse> {
|
||||
return request({
|
||||
url: '/user/del',
|
||||
method: 'post',
|
||||
data,
|
||||
module: RequestModule.BASE
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置用户密码
|
||||
* @param data 重置密码参数
|
||||
*/
|
||||
export function resetPasswordApi(data: { id: number; password: string }): Promise<ApiResponse> {
|
||||
return request({
|
||||
url: '/user/resetPassword',
|
||||
method: 'post',
|
||||
data,
|
||||
module: RequestModule.BASE
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改用户状态
|
||||
* @param data 状态参数
|
||||
*/
|
||||
export function updateUserStateApi(data: { id: number; state: boolean }): Promise<ApiResponse> {
|
||||
return request({
|
||||
url: '/user/updateState',
|
||||
method: 'post',
|
||||
data,
|
||||
module: RequestModule.BASE
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前用户信息(扩展)
|
||||
*/
|
||||
export function getCurrentUserInfoApi(): Promise<ApiResponse<UserInfo>> {
|
||||
return request({
|
||||
url: '/user/current',
|
||||
method: 'get',
|
||||
module: RequestModule.BASE
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新当前用户信息
|
||||
* @param data 用户数据
|
||||
*/
|
||||
export function updateCurrentUserApi(data: Partial<UserInfo>): Promise<ApiResponse> {
|
||||
return request({
|
||||
url: '/user/updateCurrent',
|
||||
method: 'post',
|
||||
data,
|
||||
module: RequestModule.BASE
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传用户头像
|
||||
* @param file 头像文件
|
||||
*/
|
||||
export function uploadAvatarApi(file: File): Promise<ApiResponse<{ url: string }>> {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
return request({
|
||||
url: '/user/uploadAvatar',
|
||||
method: 'post',
|
||||
data: formData,
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
},
|
||||
module: RequestModule.BASE
|
||||
})
|
||||
}
|
||||
|
||||
194
src/components/TenantSelector.vue
Normal file
194
src/components/TenantSelector.vue
Normal file
@@ -0,0 +1,194 @@
|
||||
<template>
|
||||
<n-modal
|
||||
v-model:show="showModal"
|
||||
:mask-closable="false"
|
||||
:close-on-esc="false"
|
||||
preset="card"
|
||||
title="选择企业"
|
||||
class="tenant-selector-modal"
|
||||
:style="{ width: '600px' }"
|
||||
>
|
||||
<n-spin :show="loading">
|
||||
<div class="tenant-list">
|
||||
<n-empty v-if="tenantList.length === 0 && !loading" description="暂无可用企业" />
|
||||
<div
|
||||
v-for="tenant in tenantList"
|
||||
:key="tenant.id"
|
||||
class="tenant-item"
|
||||
:class="{ selected: selectedTenantId === tenant.id, disabled: !tenant.state || tenant.status !== 0 }"
|
||||
@click="handleSelectTenant(tenant)"
|
||||
>
|
||||
<div class="tenant-info">
|
||||
<div class="tenant-name">
|
||||
<n-text strong>{{ tenant.name }}</n-text>
|
||||
<n-tag v-if="tenant.isDefault" type="success" size="small" :bordered="false">默认</n-tag>
|
||||
<n-tag v-if="!tenant.state" type="error" size="small" :bordered="false">已禁用</n-tag>
|
||||
</div>
|
||||
<div class="tenant-detail">
|
||||
<n-text depth="3" v-if="tenant.abbreviation">简称:{{ tenant.abbreviation }}</n-text>
|
||||
<n-text depth="3" v-if="tenant.contactPerson">联系人:{{ tenant.contactPerson }}</n-text>
|
||||
<n-text depth="3" v-if="tenant.contactPhone">电话:{{ tenant.contactPhone }}</n-text>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tenant-action">
|
||||
<n-icon v-if="selectedTenantId === tenant.id" size="24" color="#18a058">
|
||||
<CheckmarkCircle />
|
||||
</n-icon>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</n-spin>
|
||||
|
||||
<template #footer>
|
||||
<div class="modal-footer">
|
||||
<n-button @click="handleCancel">取消</n-button>
|
||||
<n-button type="primary" :disabled="!selectedTenantId" :loading="confirming" @click="handleConfirm">
|
||||
确定
|
||||
</n-button>
|
||||
</div>
|
||||
</template>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { NModal, NSpin, NEmpty, NText, NTag, NIcon, NButton } from 'naive-ui'
|
||||
import { CheckmarkCircle } from '@vicons/ionicons5'
|
||||
import type { TenantInfo } from '@/types/api'
|
||||
|
||||
interface Props {
|
||||
show: boolean
|
||||
tenantList: TenantInfo[]
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:show', value: boolean): void
|
||||
(e: 'confirm', tenantId: string): void
|
||||
(e: 'cancel'): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
loading: false
|
||||
})
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const showModal = ref(props.show)
|
||||
const selectedTenantId = ref<string>('')
|
||||
const confirming = ref(false)
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(val) => {
|
||||
showModal.value = val
|
||||
if (val) {
|
||||
// 如果有默认租户,自动选中
|
||||
const defaultTenant = props.tenantList.find((t) => t.isDefault)
|
||||
if (defaultTenant) {
|
||||
selectedTenantId.value = defaultTenant.id
|
||||
} else if (props.tenantList.length === 1) {
|
||||
// 如果只有一个租户,自动选中
|
||||
selectedTenantId.value = props.tenantList[0].id
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(showModal, (val) => {
|
||||
emit('update:show', val)
|
||||
})
|
||||
|
||||
const handleSelectTenant = (tenant: TenantInfo) => {
|
||||
// 禁用的租户不能选择
|
||||
// status: 0-正常 1-审核中 2-停用 3-待初始化租户
|
||||
if (!tenant.state || tenant.status !== 0) {
|
||||
window.$message.warning('该企业已被禁用,无法选择')
|
||||
return
|
||||
}
|
||||
selectedTenantId.value = tenant.id
|
||||
}
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (!selectedTenantId.value) {
|
||||
window.$message.warning('请选择一个企业')
|
||||
return
|
||||
}
|
||||
|
||||
confirming.value = true
|
||||
try {
|
||||
emit('confirm', selectedTenantId.value)
|
||||
} finally {
|
||||
confirming.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
emit('cancel')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.tenant-selector-modal {
|
||||
.tenant-list {
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
|
||||
.tenant-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px;
|
||||
margin-bottom: 12px;
|
||||
border: 2px solid #e0e0e6;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover:not(.disabled) {
|
||||
border-color: #18a058;
|
||||
background-color: #f0f9ff;
|
||||
}
|
||||
|
||||
&.selected {
|
||||
border-color: #18a058;
|
||||
background-color: #f0f9ff;
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.tenant-info {
|
||||
flex: 1;
|
||||
|
||||
.tenant-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.tenant-detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.tenant-action {
|
||||
margin-left: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="code-input">
|
||||
<!-- 循环生成6个输入框 -->
|
||||
<div v-for="(item, index) in codeLength" :key="index">
|
||||
<!-- 循环生成指定数量的输入框 -->
|
||||
<div v-for="(_, index) in codeLength" :key="index">
|
||||
<!-- 输入框 -->
|
||||
<input
|
||||
ref="inputRefs"
|
||||
@@ -18,40 +18,20 @@
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { mainStore } from '@/stores/main'
|
||||
import useState from '@/hooks/useState.ts'
|
||||
import { i18n } from '@/i18n'
|
||||
import { useLogin } from '@/hooks/useLogin'
|
||||
|
||||
const { codeMsg } = useLogin()
|
||||
const { showCode, showModal } = useState
|
||||
const { t } = i18n.global
|
||||
const store = mainStore()
|
||||
const { BGC_OTHER } = storeToRefs(store)
|
||||
const { codeLength, inputSize, email } = defineProps({
|
||||
const { codeLength, inputSize } = defineProps({
|
||||
// 验证码长度
|
||||
codeLength: { type: Number, default: 0 },
|
||||
// 输入框大小
|
||||
inputSize: { type: Number, default: 0 },
|
||||
// email
|
||||
email: { default: null }
|
||||
inputSize: { type: Number, default: 0 }
|
||||
})
|
||||
|
||||
const code = ref<Array<string>>(Array(codeLength).fill('')) // 验证码数组
|
||||
const inputRefs = ref<Array<HTMLInputElement | null>>([]) // 输入框引用数组
|
||||
const focusedIndex = ref<number>(0) // 当前聚焦的输入框索引
|
||||
|
||||
onMounted(() => {
|
||||
// 页面加载时自动聚焦第一个输入框
|
||||
inputRefs.value[0]?.focus()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
// 页面卸载时移除所有输入框的键盘事件监听
|
||||
inputRefs.value.forEach((input, index) =>
|
||||
input?.removeEventListener('keydown', (event: KeyboardEvent) => onKeyDown(event, index))
|
||||
)
|
||||
})
|
||||
|
||||
// 当输入框的值发生变化时,执行以下函数
|
||||
const onInput = async (value: string, index: number) => {
|
||||
// 只接受单个数字
|
||||
@@ -124,24 +104,7 @@ const onPaste = (event: ClipboardEvent) => {
|
||||
|
||||
/*重置密码方法*/
|
||||
const handlePawReset = async () => {
|
||||
const emailCode = code.value.join('')
|
||||
// await passwordReset({ email, emailCode }).then((r) => {
|
||||
// if (r.code === '00000') {
|
||||
// window.$notification.success({
|
||||
// title: t('reset_success'),
|
||||
// content: r.msg,
|
||||
// duration: 0
|
||||
// })
|
||||
// animation.value = 'modal-container animate__animated animate__rotateOutDownRight'
|
||||
// nextTick(() => {
|
||||
// showCode.value = false
|
||||
// showModal.value = false
|
||||
// animation.value = 'modal-container animate__animated animate__shakeX'
|
||||
// })
|
||||
// } else {
|
||||
// codeMsg.value = r.msg
|
||||
// }
|
||||
// })
|
||||
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
@@ -152,10 +115,7 @@ onMounted(() => {
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
// 页面卸载时移除所有输入框的键盘事件监听和粘贴事件监听
|
||||
inputRefs.value.forEach((input, index) => {
|
||||
input?.removeEventListener('keydown', (event: KeyboardEvent) => onKeyDown(event, index))
|
||||
})
|
||||
// 页面卸载时移除粘贴事件监听
|
||||
document.removeEventListener('paste', onPaste)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -52,7 +52,7 @@ const handleShowSelect = () => {
|
||||
{
|
||||
type: string
|
||||
label: string
|
||||
key: number
|
||||
key: string
|
||||
children: Array<{ label: string; value: string; disabled?: boolean }>
|
||||
}
|
||||
>()
|
||||
|
||||
30
src/enums/request.ts
Normal file
30
src/enums/request.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/** 请求模块枚举 */
|
||||
export enum RequestModule {
|
||||
/** OAuth 模块 - 认证相关 */
|
||||
OAUTH = 'oauth',
|
||||
/** Base 模块 - 基础数据 */
|
||||
BASE = 'base',
|
||||
/** System 模块 - 系统后台 */
|
||||
SYSTEM = 'system',
|
||||
/** AI 模块 - AI 相关 */
|
||||
AI = 'ai'
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取模块代理路径(开发环境)
|
||||
* @param module 模块枚举
|
||||
* @returns 代理路径,如 /proxy-oauth
|
||||
*/
|
||||
export function getModuleProxyPath(module: RequestModule): string {
|
||||
return `/proxy-${module}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取模块真实路径(生产环境)
|
||||
* @param module 模块枚举
|
||||
* @returns 真实路径,如 /oauth
|
||||
*/
|
||||
export function getModuleRealPath(module: RequestModule): string {
|
||||
return `/${module}`
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
@mouseenter="handleMouse(item)">
|
||||
<n-space justify="space-between" align="center">
|
||||
<n-space align="center">
|
||||
<n-icon :size="18" :component="(vicons as any)[item.icon]" />
|
||||
<n-icon :size="18" :component="item.icon ? (vicons as any)[item.icon as string] : undefined" />
|
||||
<n-space :size="10">
|
||||
<span>{{ item.name }}</span>
|
||||
<span style="font-size: 12px" :style="item.path === active.path ? 'text-decoration: underline' : ''">
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ref } from 'vue'
|
||||
import { i18n } from '@/i18n'
|
||||
import { RegExp } from '@/utils/RegExp.ts'
|
||||
|
||||
const { tagType1, tagType2, tagType3, passwordComplexity, complexityShow, loadingPaw, ValidationStatus } = typeState
|
||||
const { tagType1, tagType2, tagType3, passwordComplexity, complexityShow, ValidationStatus } = typeState
|
||||
const butShow = ref<boolean>()
|
||||
const { t } = i18n.global
|
||||
|
||||
@@ -17,8 +17,9 @@ const validateLoginUsername = (_rule: any, value: any, callback: any) => {
|
||||
// TODO 验证函数只在值变更时运行一次,并且错误信息只在这个时候生成。如果之后用户更改了语言,这个错误信息将不会自动更新 (nyh-2023-12-11 17:09:05)
|
||||
if (!value) {
|
||||
callback(new Error(t('input_username')))
|
||||
} else if (!RegExp.isEngORNub(value) || value.length > 12) {
|
||||
callback(new Error(t(!RegExp.isEngORNub(value) ? 'is_standard' : 'UN_EX_limit')))
|
||||
} else if (value.length > 50) {
|
||||
// 放宽限制:只限制最大长度为50,允许任何字符(包括中文、特殊字符等)
|
||||
callback(new Error(t('UN_EX_limit')))
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
@@ -34,18 +35,9 @@ const validatePassword = (_rule: any, value: any, callback: any) => {
|
||||
ValidationStatus.value = 'error'
|
||||
callback(new Error(t('input_paw')))
|
||||
} else {
|
||||
loadingPaw.value = true
|
||||
setTimeout(() => {
|
||||
if (value.length < 6) {
|
||||
ValidationStatus.value = 'warning'
|
||||
callback(t('paw_length'))
|
||||
loadingPaw.value = false
|
||||
} else {
|
||||
ValidationStatus.value = ''
|
||||
callback()
|
||||
loadingPaw.value = false
|
||||
}
|
||||
}, 1000)
|
||||
// 移除密码长度限制和延迟验证,允许任何非空密码
|
||||
ValidationStatus.value = ''
|
||||
callback()
|
||||
}
|
||||
}
|
||||
// TODO 这些校验还需要解决一些bug
|
||||
|
||||
@@ -1,31 +1,28 @@
|
||||
import { userStore } from '@/stores/user'
|
||||
import useState from '@/hooks/useState.ts'
|
||||
import { i18n } from '@/i18n'
|
||||
import router from '@/router'
|
||||
import { NForm } from 'naive-ui'
|
||||
import { remember } from '@/stores/remember'
|
||||
import apis from '@/services/apis'
|
||||
import { RCodeEnum } from '@/enums'
|
||||
import { Loading } from 'notiflix'
|
||||
import { delay } from 'lodash-es'
|
||||
import { tenant } from '@/stores/tenant'
|
||||
import { RSA } from '@/utils/RSA'
|
||||
import { tabs } from '@/stores/tabs.ts'
|
||||
import { getEnhancedFingerprint } from '@/utils/fingerprint'
|
||||
import type { LoginParams } from '@/types/api'
|
||||
|
||||
export const useLogin = () => {
|
||||
//定义初始化数据
|
||||
const userInfoStore = userStore()
|
||||
const tabsStore = tabs()
|
||||
const rememberStore = remember()
|
||||
const tenantStore = tenant()
|
||||
const { t } = i18n.global
|
||||
const { disabled, showModal, showCode } = useState
|
||||
disabled.value = false
|
||||
const signInLoading = ref<boolean>(false)
|
||||
const formRef = ref(<InstanceType<typeof NForm>>{})
|
||||
const ruleForm = reactive({
|
||||
userName: '',
|
||||
password: '',
|
||||
userName: '15830906024',
|
||||
password: '123456',
|
||||
tenantName: '',
|
||||
tenantId: '',
|
||||
tenantUrl: ''
|
||||
@@ -44,73 +41,93 @@ export const useLogin = () => {
|
||||
const loginErrorTitle = ref<string>()
|
||||
const loginErrorType = ref<string>()
|
||||
const statusCode = ref<string>()
|
||||
/*加密的数据*/
|
||||
const cipherData = ref()
|
||||
/**
|
||||
* 用户登录校验
|
||||
* @param formRef 表单校验
|
||||
*/
|
||||
const SignIn = async (formRef: InstanceType<typeof NForm>) => {
|
||||
const SignIn = async (formRef: InstanceType<typeof NForm>): Promise<{ success: boolean; needSelectTenant?: boolean } | undefined> => {
|
||||
/*使用按钮禁用的方式来实现按钮节流*/
|
||||
disabled.value = true
|
||||
/*初始化登录错误提示*/
|
||||
loginErrorMsg.value = false
|
||||
await formRef
|
||||
?.validate()
|
||||
.then(async () => {
|
||||
loginText.value = t('in_check')
|
||||
signInLoading.value = true
|
||||
Loading.pulse()
|
||||
|
||||
try {
|
||||
await formRef?.validate()
|
||||
|
||||
loginText.value = t('in_check')
|
||||
signInLoading.value = true
|
||||
Loading.pulse()
|
||||
|
||||
try {
|
||||
const { password, userName } = formRef.model
|
||||
const remember = { password, userName } as any
|
||||
const { value } = tenantStore.getTenant
|
||||
/*获取公钥*/
|
||||
const res = await apis.getPublicKey()
|
||||
if (res.code !== RCodeEnum.OK) {
|
||||
return window.$message.error(res.msg)
|
||||
|
||||
// 获取设备指纹
|
||||
const clientId = await getEnhancedFingerprint()
|
||||
|
||||
const loginParams: LoginParams = {
|
||||
account: userName,
|
||||
password,
|
||||
grantType: 'PASSWORD',
|
||||
clientId,
|
||||
systemType: 1, // 1-账号密码登录,2-IM聊天系统登录
|
||||
deviceType: 'PC' // PC 或 MOBILE
|
||||
}
|
||||
cipherData.value = RSA.encryptByPublicKey(JSON.stringify({ password, userName, tenantId: value }), res.msg)
|
||||
apis.login({ cipherData: cipherData.value }).then((res) => {
|
||||
if (res.code !== RCodeEnum.OK) {
|
||||
Loading.remove()
|
||||
loginText.value = t('login')
|
||||
signInLoading.value = false
|
||||
disabled.value = false
|
||||
nextTick(() => {
|
||||
loginErrorMsg.value = true
|
||||
loginErrorText.value = res.msg
|
||||
statusCode.value = res.code
|
||||
loginErrorTitle.value = res.code === RCodeEnum.FAIL ? t('account_error') : t('login_error')
|
||||
})
|
||||
return false
|
||||
}
|
||||
//将res中的数据传给pinia做持久化
|
||||
userInfoStore.setLoginInfo(res.data)
|
||||
|
||||
const result = await userInfoStore.login(loginParams)
|
||||
|
||||
if (result.success) {
|
||||
// 用户是否选择记住我
|
||||
if (rememberOption.value) {
|
||||
rememberStore.setRememberUser(remember, rememberOption.value)
|
||||
} else {
|
||||
rememberStore.deleteRemember()
|
||||
}
|
||||
|
||||
// 登录成功,返回 needSelectTenant 标志
|
||||
// 由登录页面处理租户选择逻辑
|
||||
loginText.value = t('login')
|
||||
signInLoading.value = false
|
||||
/*登录成功后放开按钮禁用*/
|
||||
disabled.value = false
|
||||
delay(() => {
|
||||
Loading.remove()
|
||||
router.push('/')
|
||||
window.$notification.success({
|
||||
title: t('login_success'),
|
||||
duration: 1500,
|
||||
keepAliveOnHover: true
|
||||
})
|
||||
}, 300)
|
||||
})
|
||||
})
|
||||
.catch(() => {
|
||||
Loading.remove()
|
||||
|
||||
return { success: true, needSelectTenant: result.needSelectTenant }
|
||||
} else {
|
||||
// 登录失败
|
||||
Loading.remove()
|
||||
loginText.value = t('login')
|
||||
signInLoading.value = false
|
||||
disabled.value = false
|
||||
|
||||
nextTick(() => {
|
||||
loginErrorMsg.value = true
|
||||
loginErrorText.value = result.message || t('login_error')
|
||||
loginErrorTitle.value = t('account_error')
|
||||
})
|
||||
|
||||
return { success: false }
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('登录失败:', error)
|
||||
Loading.remove()
|
||||
loginText.value = t('login')
|
||||
signInLoading.value = false
|
||||
disabled.value = false
|
||||
})
|
||||
|
||||
nextTick(() => {
|
||||
loginErrorMsg.value = true
|
||||
loginErrorText.value = error.message || t('login_error')
|
||||
loginErrorTitle.value = t('login_error')
|
||||
})
|
||||
|
||||
return { success: false }
|
||||
}
|
||||
} catch (error) {
|
||||
// 表单验证失败
|
||||
Loading.remove()
|
||||
disabled.value = false
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -92,8 +92,6 @@ const provinces = [
|
||||
'宁',
|
||||
'琼'
|
||||
]
|
||||
/*输入框loading*/
|
||||
const loadingPaw = ref(false)
|
||||
/*输入框验证的状态*/
|
||||
const ValidationStatus = ref()
|
||||
|
||||
@@ -128,6 +126,5 @@ export default {
|
||||
violationsInfo,
|
||||
isDispose,
|
||||
provinces,
|
||||
loadingPaw,
|
||||
ValidationStatus
|
||||
}
|
||||
|
||||
@@ -1,9 +1,22 @@
|
||||
import type { RouteItem } from '@/types/api'
|
||||
|
||||
interface MenuItem {
|
||||
path?: string
|
||||
/** 菜单/路由唯一标识 */
|
||||
id?: string
|
||||
/** 路由路径 */
|
||||
path?: string | null
|
||||
/** 菜单名称 */
|
||||
name: string
|
||||
page: string
|
||||
/** 用于前端 route name 的标识 */
|
||||
page?: string
|
||||
/** 图标 */
|
||||
icon?: string
|
||||
/** 是否在菜单中隐藏此项 */
|
||||
hideMenu?: boolean
|
||||
/** 是否在菜单中隐藏子路由 */
|
||||
hideChildrenInMenu?: boolean
|
||||
/** 子菜单 */
|
||||
children?: MenuItem[]
|
||||
}
|
||||
|
||||
export type { MenuItem }
|
||||
export type { MenuItem, RouteItem }
|
||||
|
||||
@@ -1,15 +1,27 @@
|
||||
import type { UserInfo } from '@/types/api'
|
||||
import type { Menu } from '@/services/types'
|
||||
|
||||
interface IState {
|
||||
loginInfo: {
|
||||
sysUser: {
|
||||
id: number
|
||||
/** 用户信息 */
|
||||
sysUser?: UserInfo & {
|
||||
id: string
|
||||
uid: string
|
||||
tenantId: string
|
||||
role: string
|
||||
}
|
||||
companyName: string
|
||||
token: string
|
||||
menus: []
|
||||
auths: [{ auth: string }]
|
||||
/** 公司名称 */
|
||||
companyName?: string
|
||||
/** 访问令牌 */
|
||||
token?: string
|
||||
/** 刷新令牌 */
|
||||
refreshToken?: string
|
||||
/** 菜单列表(前端侧边栏菜单结构) */
|
||||
menus?: Menu[]
|
||||
/** 权限列表 */
|
||||
auths?: Array<{ auth: string }>
|
||||
/** 角色列表 */
|
||||
roles?: Array<{ name: string; code: string }>
|
||||
}
|
||||
}
|
||||
export type { IState }
|
||||
|
||||
@@ -57,9 +57,22 @@ import { i18n } from '@/i18n'
|
||||
import { RouterLink, useRoute } from 'vue-router'
|
||||
import { Menu } from '@/services/types'
|
||||
|
||||
// 动态收集当前项目中实际存在的页面组件,用于过滤菜单和添加路由
|
||||
const pageModules = import.meta.glob('../../views/page/*.vue') as Record<string, () => Promise<any>>
|
||||
const availablePages = new Set(
|
||||
Object.keys(pageModules)
|
||||
.map((key) => {
|
||||
const match = key.match(/\/([^/]+)\.vue$/)
|
||||
return match ? match[1] : ''
|
||||
})
|
||||
.filter(Boolean)
|
||||
)
|
||||
|
||||
|
||||
const { t } = i18n.global
|
||||
const route = useRoute()
|
||||
const activeKey = ref<any>(route.path.split('/')[1])
|
||||
// 使用完整路由路径作为菜单选中 key,避免层级不一致导致的高亮问题
|
||||
const activeKey = ref<any>(route.path)
|
||||
const collapsed = ref(false)
|
||||
const menuInstRef = ref()
|
||||
const store = mainStore()
|
||||
@@ -77,7 +90,7 @@ watch(
|
||||
() => route.path,
|
||||
(newPath) => {
|
||||
// 在路径变化时更新 activeKey
|
||||
activeKey.value = newPath.split('/')[1]
|
||||
activeKey.value = newPath
|
||||
menuInstRef.value?.showOption(activeKey.value)
|
||||
}
|
||||
)
|
||||
@@ -88,30 +101,54 @@ const handleCollapsed = () => {
|
||||
emit('collapsed', collapsed.value)
|
||||
}
|
||||
|
||||
/*渲染菜单图标*/
|
||||
const renderIcon = (icon: string) => {
|
||||
return () => <NIcon component={(vicons as any)[icon]} />
|
||||
/* 渲染菜单图标(兼容后端返回的任意 icon 字符串,不存在的直接不渲染) */
|
||||
const renderIcon = (icon?: string) => {
|
||||
if (!icon) return undefined
|
||||
const Comp = (vicons as any)[icon]
|
||||
if (!Comp) return undefined
|
||||
return () => <NIcon component={Comp} />
|
||||
}
|
||||
/*菜单数据 注意:排除了主页的路由*/
|
||||
|
||||
/* 菜单数据 排除了主页的路由,实现 hideMenu / hideChildrenInMenu,且仅展示当前项目中真实存在页面的菜单 */
|
||||
const menuOptions: MenuOption[] = menus
|
||||
.filter((menu: Menu) => menu.path !== 'home')
|
||||
.filter((menu: Menu) => !menu.hideMenu)
|
||||
.map((menu: Menu) => {
|
||||
const menuOption: MenuOption = {
|
||||
label: () => <RouterLink to={{ name: menu.page }}>{() => menu.name}</RouterLink>,
|
||||
key: menu.path as string,
|
||||
icon: renderIcon(menu.icon)
|
||||
}
|
||||
if (menu.path) {
|
||||
return menuOption
|
||||
const hasChildren = Array.isArray(menu.children) && menu.children.length > 0 && !menu.hideChildrenInMenu
|
||||
|
||||
// 没有子菜单的普通菜单项;如果对应的页面不存在,则不展示
|
||||
if (!hasChildren && menu.page && menu.path) {
|
||||
if (!availablePages.has(menu.page)) return null as any
|
||||
|
||||
return {
|
||||
// 使用 path 进行跳转,避免依赖路由 name,适配动态路由 name 使用 id/path 的情况
|
||||
label: () => <RouterLink to={{ path: menu.path as string }}>{() => menu.name}</RouterLink>,
|
||||
key: menu.path as string,
|
||||
icon: renderIcon(menu.icon)
|
||||
} as MenuOption
|
||||
}
|
||||
|
||||
menuOption.children = menu.children?.map((child) => ({
|
||||
label: () => <RouterLink to={{ name: child.page }}>{() => child.name}</RouterLink>,
|
||||
key: child.path as string,
|
||||
icon: renderIcon(child.icon)
|
||||
}))
|
||||
return menuOption
|
||||
// 作为分组存在的菜单(多级菜单)
|
||||
const childrenOptions: MenuOption[] = (menu.children || [])
|
||||
.filter((child) => !child.hideMenu && (!child.page || availablePages.has(child.page)))
|
||||
.map((child) => ({
|
||||
// 同样通过 path 导航,保证和动态路由 path 对齐
|
||||
label: () => <RouterLink to={{ path: child.path as string }}>{() => child.name}</RouterLink>,
|
||||
key: child.path as string,
|
||||
icon: renderIcon(child.icon)
|
||||
}))
|
||||
|
||||
// 如果分组下没有可展示的子菜单,则不展示该分组
|
||||
if (!childrenOptions.length) return null as any
|
||||
|
||||
return {
|
||||
label: () => <span>{menu.name}</span>,
|
||||
key: (menu.path || menu.id || menu.name) as string,
|
||||
icon: renderIcon(menu.icon),
|
||||
children: childrenOptions
|
||||
} as MenuOption
|
||||
})
|
||||
.filter(Boolean) as MenuOption[]
|
||||
</script>
|
||||
<style scoped>
|
||||
.aside {
|
||||
|
||||
@@ -12,6 +12,9 @@ import { Common } from '@/utils/Common'
|
||||
|
||||
pinia.use(piniaPluginPersistedstate)
|
||||
const app = createApp(App)
|
||||
|
||||
window.$router = router
|
||||
|
||||
/*使用链式调用挂载*/
|
||||
app.use(pinia).use(router).use(i18n).directive('drag', drag).mount('#app')
|
||||
// app.component('vue-drag-resize', VueDragResize)
|
||||
|
||||
@@ -4,7 +4,18 @@ import type { MenuItem } from '@/interface/IRouter'
|
||||
import { noPermissionPaths, paginationPage } from './options.ts'
|
||||
import { tabs } from '@/stores/tabs'
|
||||
|
||||
// const modules = import.meta.glob('../views/page/*.vue')
|
||||
// 动态导入 views/page 目录下的所有页面组件
|
||||
const modules = import.meta.glob('../views/page/*.vue') as Record<string, () => Promise<any>>
|
||||
// 可用页面名称集合,例如 Home、User、Role 等
|
||||
const availablePages = new Set(
|
||||
Object.keys(modules)
|
||||
.map((key) => {
|
||||
const match = key.match(/\/([^/]+)\.vue$/)
|
||||
return match ? match[1] : ''
|
||||
})
|
||||
.filter(Boolean)
|
||||
)
|
||||
|
||||
const { VITE_APP_TITLE, VITE_TITLE_SUFFIX, BASE_URL } = import.meta.env
|
||||
const routes: Array<RouteRecordRaw> = [
|
||||
{
|
||||
@@ -44,17 +55,36 @@ export const setRoutes = (menus?: MenuItem[]) => {
|
||||
}
|
||||
if (menus?.length) {
|
||||
/**
|
||||
* 动态添加路由
|
||||
* 根据菜单数据动态添加路由
|
||||
* @param routeItem
|
||||
*/
|
||||
const addDynamicRoute = (routeItem: MenuItem) => {
|
||||
if (routeItem.page) {
|
||||
/*添加views文件夹中page文件下面的全部.vue文件*/
|
||||
if (routeItem.page && routeItem.path) {
|
||||
// 仅为当前项目中真实存在的页面添加路由,避免导入不存在的 .vue 文件
|
||||
if (!availablePages.has(routeItem.page)) {
|
||||
console.warn(`[router] 页面组件不存在,已忽略路由:${routeItem.page}`)
|
||||
return
|
||||
}
|
||||
|
||||
const key = `../views/page/${routeItem.page}.vue`
|
||||
const component = modules[key]
|
||||
if (!component) return
|
||||
|
||||
// 使用后端路由 id 或路径作为路由 name,避免多个菜单共用同一个 page 导致 name 冲突
|
||||
const routeName = routeItem.id || routeItem.path || routeItem.page
|
||||
|
||||
router.addRoute('page', {
|
||||
path: routeItem.path,
|
||||
name: routeItem.page,
|
||||
meta: { title: routeItem.name, icon: routeItem.icon, requiresAuth: true, dynamicAdded: true },
|
||||
component: () => import(`@/views/page/${routeItem.page}.vue`)
|
||||
name: routeName,
|
||||
meta: {
|
||||
title: routeItem.name,
|
||||
icon: routeItem.icon,
|
||||
requiresAuth: true,
|
||||
dynamicAdded: true,
|
||||
hideMenu: routeItem.hideMenu,
|
||||
hideChildrenInMenu: routeItem.hideChildrenInMenu
|
||||
},
|
||||
component
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -92,19 +122,37 @@ router.beforeEach(async (to: any, _from: any, next: any) => {
|
||||
data: { icon: to.meta.icon, path: to.path, title: to.meta.title }
|
||||
})
|
||||
}
|
||||
const store = userStore() // 拿到用户对象id信息判断是否登录
|
||||
const {
|
||||
loginInfo: { token }
|
||||
} = userStore() // 拿到用户对象id信息判断是否登录
|
||||
} = store
|
||||
/*判断页面是否需要分页*/
|
||||
paginationPage.includes(to.name) ? (to.meta.pagination = true) : (to.meta.pagination = false)
|
||||
|
||||
// 如果已登录但还没有加载动态路由,则加载动态路由
|
||||
if (token && (!store.loginInfo.menus || store.loginInfo.menus.length === 0)) {
|
||||
try {
|
||||
await store.initUserInfo()
|
||||
// 动态路由加载完成后,重新导航到目标路由
|
||||
return next({ ...to, replace: true })
|
||||
} catch (error) {
|
||||
console.error('加载用户信息失败:', error)
|
||||
// 加载失败,清除登录状态并跳转到登录页
|
||||
store.$reset()
|
||||
return next('/login')
|
||||
}
|
||||
}
|
||||
|
||||
// 如果未登录并且要访问的路径需要登录权限
|
||||
if (!token && !noPermissionPaths.includes(to.path)) {
|
||||
return next('/login') // 重定向到登录页
|
||||
}
|
||||
|
||||
// 如果要访问的路径不存在(没有匹配的路由记录)
|
||||
if (!to.matched.length) {
|
||||
return next('/:catchAll(.*)') // 重定向到捕获所有路径的路由
|
||||
}
|
||||
|
||||
// 其他情况,继续路由导航
|
||||
next()
|
||||
})
|
||||
|
||||
@@ -25,7 +25,7 @@ export default {
|
||||
/*新增 用户*/
|
||||
addUser: (form: User): Promise<Response> => POST(urls.user, form),
|
||||
/*删除单个 用户*/
|
||||
deleteUser: (id: number, username: string, uid: string): Promise<Response> =>
|
||||
deleteUser: (id: string, username: string, uid: string): Promise<Response> =>
|
||||
DELETE(urls.user + '/' + id + '/' + username + '/' + uid),
|
||||
/*批量删除 用户*/
|
||||
batchDeleteUsers: (data: BatchDelete): Promise<Response> => POST(urls.user + '/delete/batch', data),
|
||||
@@ -38,7 +38,7 @@ export default {
|
||||
/*新增 角色*/
|
||||
addRole: (form: any): Promise<Response> => POST(urls.role, form),
|
||||
/*删除单个 角色*/
|
||||
deleteRole: (id: number): Promise<Response> => DELETE(urls.role + '/' + id),
|
||||
deleteRole: (id: string): Promise<Response> => DELETE(urls.role + '/' + id),
|
||||
/*批量删除 角色*/
|
||||
batchDeleteRoles: (data: BatchDelete): Promise<Response> => POST(urls.role + '/delete/batch', data),
|
||||
/*修改 角色*/
|
||||
|
||||
@@ -20,11 +20,21 @@ export type Response = {
|
||||
}
|
||||
/*菜单*/
|
||||
export type Menu = {
|
||||
/** 路由路径,根 Home 一般为 'home' */
|
||||
path: null | string
|
||||
page: string
|
||||
/** 对应前端路由 name,可为空(仅作为分组的菜单) */
|
||||
page?: string
|
||||
/** 菜单展示名称 */
|
||||
name: string
|
||||
id: number
|
||||
icon: string
|
||||
/** 菜单/路由唯一标识 */
|
||||
id?: string
|
||||
/** 图标名称(可选) */
|
||||
icon?: string
|
||||
/** 是否在菜单中隐藏 */
|
||||
hideMenu?: boolean
|
||||
/** 是否在菜单中隐藏子菜单 */
|
||||
hideChildrenInMenu?: boolean
|
||||
/** 子菜单 */
|
||||
children?: Menu[]
|
||||
}
|
||||
/*分页搜索*/
|
||||
@@ -39,7 +49,7 @@ export type login = {
|
||||
}
|
||||
/*用户*/
|
||||
export type User = {
|
||||
id: number
|
||||
id: string
|
||||
uid: string
|
||||
userName: string
|
||||
password: string
|
||||
@@ -53,7 +63,7 @@ export type User = {
|
||||
}
|
||||
/*分页用户*/
|
||||
export type pageUser = {
|
||||
id: number
|
||||
id: string
|
||||
uid: string
|
||||
userName: string
|
||||
nickName: string
|
||||
@@ -69,7 +79,7 @@ export type pageUser = {
|
||||
}
|
||||
/*更新用户*/
|
||||
export type UpdateUser = {
|
||||
id: number
|
||||
id: string
|
||||
userName: string
|
||||
role: string
|
||||
status: number
|
||||
@@ -87,7 +97,7 @@ export type Renew = {
|
||||
}
|
||||
/*角色*/
|
||||
export type Role = {
|
||||
id: number
|
||||
id: string
|
||||
name: string
|
||||
flag: string
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import router, { resetRouter, setRoutes } from '@/router'
|
||||
import type { IState } from '@/interface/IState'
|
||||
import { loginApi, getUserInfoApi, logoutApi, switchTenantAndOrgApi } from '@/api/auth'
|
||||
import { getUserRoutesApi } from '@/api/route'
|
||||
import { getUserTenantListApi } from '@/api/tenant'
|
||||
import type { LoginParams, TenantInfo } from '@/types/api'
|
||||
|
||||
export const userStore = defineStore('localUserInfo', {
|
||||
state: (): IState =>
|
||||
@@ -9,13 +13,13 @@ export const userStore = defineStore('localUserInfo', {
|
||||
},
|
||||
getters: {
|
||||
getUserId(): any {
|
||||
return this.loginInfo.sysUser ? this.loginInfo.sysUser.id : 0
|
||||
return this.loginInfo.sysUser ? this.loginInfo.sysUser.id : ''
|
||||
},
|
||||
getUserUId(): any {
|
||||
return this.loginInfo.sysUser ? this.loginInfo.sysUser.uid : 0
|
||||
return this.loginInfo.sysUser ? this.loginInfo.sysUser.uid : ''
|
||||
},
|
||||
getTenantId(): any {
|
||||
return this.loginInfo.sysUser ? this.loginInfo.sysUser.tenantId : 0
|
||||
return this.loginInfo.sysUser ? this.loginInfo.sysUser.tenantId : ''
|
||||
},
|
||||
getUser(): any {
|
||||
return this.loginInfo.sysUser || {}
|
||||
@@ -30,33 +34,319 @@ export const userStore = defineStore('localUserInfo', {
|
||||
return this.loginInfo.menus || []
|
||||
},
|
||||
getAuths(): any {
|
||||
return this.loginInfo.auths.length ? this.loginInfo.auths.map((v) => v.auth) : []
|
||||
return this.loginInfo.auths?.length ? this.loginInfo.auths.map((v) => v.auth) : []
|
||||
},
|
||||
getRole(): any {
|
||||
return this.loginInfo.sysUser.role || ''
|
||||
return this.loginInfo.sysUser?.role || ''
|
||||
},
|
||||
getCompanyName(): any {
|
||||
return this.loginInfo.companyName || ''
|
||||
},
|
||||
isLogin(): boolean {
|
||||
return !!this.loginInfo.token
|
||||
}
|
||||
},
|
||||
actions: {
|
||||
/**
|
||||
* 登录
|
||||
* @param loginParams 登录参数
|
||||
*/
|
||||
async login(loginParams: LoginParams) {
|
||||
try {
|
||||
const res = await loginApi(loginParams)
|
||||
if (res.success && res.data) {
|
||||
this.loginInfo.token = res.data.token
|
||||
this.loginInfo.refreshToken = res.data.refreshToken
|
||||
const uid = res.data.uid
|
||||
|
||||
if (!this.loginInfo.sysUser) {
|
||||
this.loginInfo.sysUser = {
|
||||
id: '',
|
||||
uid: uid,
|
||||
tenantId: '',
|
||||
role: ''
|
||||
} as any
|
||||
} else {
|
||||
this.loginInfo.sysUser.uid = uid
|
||||
}
|
||||
|
||||
console.log('✅ 登录成功,token 和 uid 已保存:', {
|
||||
token: res.data.token?.substring(0, 20) + '...',
|
||||
uid,
|
||||
userId: this.loginInfo.sysUser?.id
|
||||
})
|
||||
|
||||
// 等待租户选择后再获取用户信息和初始化路由
|
||||
return { success: true, needSelectTenant: true, uid: res.data.uid }
|
||||
} else {
|
||||
return { success: false, message: res.msg || '登录失败' }
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('❌ 登录失败:', error)
|
||||
return { success: false, message: error.message || '登录失败' }
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取用户的可用租户列表
|
||||
*/
|
||||
async getUserTenantList(): Promise<TenantInfo[]> {
|
||||
try {
|
||||
const res = await getUserTenantListApi()
|
||||
if (res.success && res.data) {
|
||||
return res.data
|
||||
} else {
|
||||
console.error('获取租户列表失败:', res.msg)
|
||||
return []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取租户列表失败:', error)
|
||||
return []
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 设置租户并初始化用户信息
|
||||
* @param tenantId 租户 ID
|
||||
*/
|
||||
async setTenantAndInit(tenantId: string) {
|
||||
try {
|
||||
console.log('🏢 开始设置租户:', tenantId)
|
||||
|
||||
if (!this.loginInfo.sysUser) {
|
||||
this.loginInfo.sysUser = {
|
||||
id: '',
|
||||
uid: '',
|
||||
tenantId,
|
||||
role: '',
|
||||
userName: ''
|
||||
} as any
|
||||
} else {
|
||||
this.loginInfo.sysUser.tenantId = tenantId
|
||||
}
|
||||
|
||||
// 调用切换租户 API
|
||||
const switchRes = await switchTenantAndOrgApi({
|
||||
clientId: 'luohuo_web_pro' // 使用配置的客户端 ID
|
||||
})
|
||||
|
||||
if (switchRes.success && switchRes.data) {
|
||||
// 更新 token
|
||||
if (switchRes.data.token) {
|
||||
this.loginInfo.token = switchRes.data.token
|
||||
console.log('✅ 切换租户成功,token 已更新')
|
||||
}
|
||||
} else {
|
||||
console.error('❌ 切换租户失败:', switchRes.msg)
|
||||
return { success: false, message: switchRes.msg || '切换租户失败' }
|
||||
}
|
||||
|
||||
// 获取用户信息
|
||||
const userRes = await getUserInfoApi()
|
||||
if (userRes.success && userRes.data) {
|
||||
this.loginInfo.sysUser = {
|
||||
...userRes.data,
|
||||
id: userRes.data.id || '',
|
||||
uid: userRes.data.uid || '',
|
||||
tenantId,
|
||||
role:
|
||||
Array.isArray(userRes.data.roles) && userRes.data.roles.length > 0
|
||||
? userRes.data.roles[0].code || ''
|
||||
: ''
|
||||
}
|
||||
console.log('✅ 用户信息获取成功:', {
|
||||
id: userRes.data.id,
|
||||
userName: userRes.data.userName,
|
||||
tenantId: String(tenantId)
|
||||
})
|
||||
} else {
|
||||
console.error('❌ 获取用户信息失败:', userRes.msg)
|
||||
return { success: false, message: userRes.msg || '获取用户信息失败' }
|
||||
}
|
||||
|
||||
// 初始化动态路由
|
||||
await this.initUserInfo()
|
||||
|
||||
return { success: true }
|
||||
} catch (error: any) {
|
||||
console.error('❌ 设置租户失败:', error)
|
||||
return { success: false, message: error.message || '设置租户失败' }
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 初始化用户信息(获取动态路由)
|
||||
*/
|
||||
async initUserInfo() {
|
||||
try {
|
||||
const routeRes = await getUserRoutesApi(1) // applicationId = 1
|
||||
if (routeRes.success && routeRes.data) {
|
||||
const rawRoutes = routeRes.data.routerList || []
|
||||
|
||||
// 将后端返回的 VueRouter 数据转换为前端菜单结构
|
||||
const transformRoutesToMenus = (routes: any[]): any[] => {
|
||||
/**
|
||||
* 从路由记录解析出前端使用的 page 名称
|
||||
* - 优先使用 route.page
|
||||
* - 其次使用 route.component / route.meta.component
|
||||
* - 对于后端返回的 "/basic/.../index"、"/basic/.../Edit" 等,取最后一段作为 page
|
||||
* - 对于 "LAYOUT",不生成实际页面,仅作为分组存在
|
||||
*/
|
||||
const getPageFromRoute = (route: any): string | undefined => {
|
||||
if (route.page) return route.page
|
||||
|
||||
const rawComponent: string | undefined = route.component || route.meta?.component
|
||||
if (!rawComponent || rawComponent === 'LAYOUT') return undefined
|
||||
|
||||
if (rawComponent.includes('/basic/user/')) {
|
||||
return 'User'
|
||||
}
|
||||
if (rawComponent.includes('/basic/system/baseRole/')) {
|
||||
return 'Role'
|
||||
}
|
||||
if (rawComponent.includes('/basic/system/') || rawComponent.includes('/basic/msg/')) {
|
||||
return 'Home'
|
||||
}
|
||||
|
||||
let comp = rawComponent
|
||||
// 去掉可能的前缀,例如 src/views/、views/、page/、/basic/
|
||||
comp = comp.replace(/^\/?src\/views\//, '')
|
||||
comp = comp.replace(/^views\//, '')
|
||||
comp = comp.replace(/^page\//, '')
|
||||
comp = comp.replace(/^\/?basic\//, '')
|
||||
|
||||
const segments = comp.split('/')
|
||||
let last = segments[segments.length - 1] || ''
|
||||
|
||||
// 去掉 .vue 后缀
|
||||
if (last.endsWith('.vue')) {
|
||||
last = last.slice(0, -4)
|
||||
}
|
||||
return last || undefined
|
||||
}
|
||||
|
||||
const normalizePath = (path: string | undefined, page: string): string => {
|
||||
// Home 页特殊处理:保持为 "home"
|
||||
if (page === 'Home') {
|
||||
if (!path || path === '/' || path === '/home' || path === 'home') {
|
||||
return 'home'
|
||||
}
|
||||
}
|
||||
|
||||
const p = path || `/${page}`
|
||||
return p.startsWith('/') ? p : `/${p}`
|
||||
}
|
||||
|
||||
const loop = (list: any[]): any[] => {
|
||||
const result: any[] = []
|
||||
|
||||
list.forEach((route) => {
|
||||
const meta = route.meta || {}
|
||||
const hideMenu = meta.hideMenu === true
|
||||
const hideChildrenInMenu = meta.hideChildrenInMenu === true
|
||||
|
||||
const page = getPageFromRoute(route)
|
||||
const path = page ? normalizePath(route.path, page) : route.path
|
||||
|
||||
let children: any[] | undefined
|
||||
if (Array.isArray(route.children) && route.children.length && !hideChildrenInMenu) {
|
||||
children = loop(route.children)
|
||||
if (!children.length) children = undefined
|
||||
}
|
||||
|
||||
// 当前节点隐藏菜单,仅提升子节点
|
||||
if (hideMenu) {
|
||||
if (children) {
|
||||
result.push(...children)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// LAYOUT 等仅作为分组存在且没有子节点时,直接忽略
|
||||
if (!page && (!children || !children.length)) {
|
||||
return
|
||||
}
|
||||
|
||||
const menu: any = {
|
||||
id: route.id ? String(route.id) : undefined,
|
||||
path: page ? path : undefined, // 分组节点不需要 path
|
||||
// 菜单显示名称优先使用 meta.title
|
||||
name: meta.title || route.name || page,
|
||||
page,
|
||||
icon: route.icon || meta.icon,
|
||||
hideMenu,
|
||||
hideChildrenInMenu
|
||||
}
|
||||
|
||||
if (children) {
|
||||
menu.children = children
|
||||
}
|
||||
|
||||
result.push(menu)
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
return loop(routes)
|
||||
}
|
||||
|
||||
this.loginInfo.menus = transformRoutesToMenus(rawRoutes)
|
||||
// 同步权限资源与角色信息
|
||||
this.loginInfo.auths = (routeRes.data.resourceList || []).map((code: string) => ({ auth: code }))
|
||||
this.loginInfo.roles = (routeRes.data.roleList || []).map((code: string) => ({ name: code, code }))
|
||||
|
||||
console.log('✅ 动态路由初始化完成:', {
|
||||
rawRoutes,
|
||||
menus: this.loginInfo.menus,
|
||||
resourceList: routeRes.data.resourceList,
|
||||
roleList: routeRes.data.roleList
|
||||
})
|
||||
|
||||
// 设置动态路由
|
||||
setRoutes(this.loginInfo.menus)
|
||||
}
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('初始化用户信息失败:', error)
|
||||
return false
|
||||
}
|
||||
},
|
||||
|
||||
setLoginInfo(loginInfo: any) {
|
||||
this.loginInfo = loginInfo
|
||||
/*设置动态路由*/
|
||||
setRoutes(loginInfo.menus)
|
||||
},
|
||||
|
||||
setUser(user: any) {
|
||||
this.loginInfo.sysUser = JSON.parse(JSON.stringify(user))
|
||||
},
|
||||
logout() {
|
||||
// 将状态重置为初始状态
|
||||
this.$reset()
|
||||
//删除localStorage中的用户信息
|
||||
localStorage.removeItem('localUserInfo')
|
||||
router.push('/login').then(() => {
|
||||
//重置路由
|
||||
resetRouter()
|
||||
})
|
||||
|
||||
/**
|
||||
* 退出登录
|
||||
*/
|
||||
async logout() {
|
||||
try {
|
||||
if (this.loginInfo.token) {
|
||||
await logoutApi({
|
||||
token: this.loginInfo.token,
|
||||
refreshToken: this.loginInfo.refreshToken || ''
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('退出登录失败:', error)
|
||||
} finally {
|
||||
// 将状态重置为初始状态
|
||||
this.$reset()
|
||||
//删除localStorage中的用户信息
|
||||
localStorage.removeItem('localUserInfo')
|
||||
router.push('/login').then(() => {
|
||||
//重置路由
|
||||
resetRouter()
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
//开启数据持久化
|
||||
|
||||
339
src/types/api.ts
Normal file
339
src/types/api.ts
Normal file
@@ -0,0 +1,339 @@
|
||||
/** API 响应基础结构 */
|
||||
export interface ApiResponse<T = any> {
|
||||
/** 响应码 */
|
||||
code: number
|
||||
/** 响应消息 */
|
||||
msg: string
|
||||
/** 响应数据 */
|
||||
data: T
|
||||
/** 是否成功 */
|
||||
success?: boolean
|
||||
/** 时间戳 */
|
||||
timestamp?: number
|
||||
}
|
||||
|
||||
/** 分页参数 */
|
||||
export interface PageParams {
|
||||
/** 当前页码 */
|
||||
pageNum: number
|
||||
/** 每页数量 */
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
/** 分页响应 */
|
||||
export interface PageResponse<T = any> {
|
||||
/** 数据列表 */
|
||||
records: T[]
|
||||
/** 总数 */
|
||||
total: number
|
||||
/** 当前页 */
|
||||
current: number
|
||||
/** 每页大小 */
|
||||
size: number
|
||||
/** 总页数 */
|
||||
pages: number
|
||||
}
|
||||
|
||||
/** ==================== Auth 模块 ==================== */
|
||||
|
||||
/** 登录参数 */
|
||||
export interface LoginParams {
|
||||
/** 账号(用户名) */
|
||||
account: string
|
||||
/** 密码 */
|
||||
password?: string
|
||||
/** 手机号 */
|
||||
mobile?: string
|
||||
/** 验证码 */
|
||||
code?: string
|
||||
/** 验证码 key */
|
||||
key?: string
|
||||
/** 记住我 */
|
||||
rememberMe?: boolean
|
||||
/** 登录类型 */
|
||||
grantType: 'PASSWORD' | 'CAPTCHA' | 'REFRESH_TOKEN' | 'MOBILE'
|
||||
/** 客户端类型 */
|
||||
clientType?: string
|
||||
/** 登录源 */
|
||||
source?: string
|
||||
/** 设备指纹 */
|
||||
clientId: string
|
||||
/** 系统类型 (1-账号密码登录,2-IM聊天系统登录) */
|
||||
systemType: number
|
||||
/** 设备类型 */
|
||||
deviceType: 'PC' | 'MOBILE'
|
||||
/** 刷新令牌 */
|
||||
refreshToken?: string
|
||||
}
|
||||
|
||||
/** 登录响应 */
|
||||
export interface LoginResponse {
|
||||
/** 访问令牌 */
|
||||
token: string
|
||||
/** 刷新令牌 */
|
||||
refreshToken: string
|
||||
/** 用户 ID */
|
||||
uid: string
|
||||
/** 过期时间 */
|
||||
expire: string | number
|
||||
/** 过期时间戳 */
|
||||
expiration?: string
|
||||
/** 客户端 */
|
||||
client?: string
|
||||
/** 登录源 */
|
||||
source?: string
|
||||
/** UUID */
|
||||
uuid?: string
|
||||
}
|
||||
|
||||
/** 验证码响应 */
|
||||
export interface CaptchaResponse {
|
||||
/** 验证码图片 Base64 */
|
||||
image: string
|
||||
/** 验证码 key */
|
||||
key: string
|
||||
/** 过期时间(秒) */
|
||||
expire?: number
|
||||
}
|
||||
|
||||
/** 用户信息 */
|
||||
export interface UserInfo {
|
||||
/** 用户 ID */
|
||||
id: string
|
||||
/** 用户 UID */
|
||||
uid?: string
|
||||
/** 租户 ID */
|
||||
tenantId?: string
|
||||
/** 部门 ID */
|
||||
orgId?: string
|
||||
/** 小组 ID */
|
||||
groupId?: string
|
||||
/** 单位 ID */
|
||||
companyId?: string
|
||||
/** 岗位 ID */
|
||||
positionId?: string
|
||||
/** 账号名 */
|
||||
userName: string
|
||||
/** 昵称 */
|
||||
nickName?: string
|
||||
/** 头像 */
|
||||
avatar?: string
|
||||
/** 手机号 */
|
||||
mobile?: string
|
||||
/** 邮箱 */
|
||||
email?: string
|
||||
/** 性别 */
|
||||
sex?: string | number
|
||||
/** 状态 */
|
||||
state?: boolean
|
||||
/** 角色列表 */
|
||||
roles?: UserRole[]
|
||||
/** 权限列表 */
|
||||
permissions?: string[]
|
||||
/** 当前用户所属部门 */
|
||||
org?: Organization
|
||||
/** 当前用户所属单位 */
|
||||
company?: Organization
|
||||
/** 当前用户的岗位 */
|
||||
position?: Position
|
||||
/** 当前用户的所属公司列表 */
|
||||
companyList?: Organization[]
|
||||
/** 是否为超级管理员 */
|
||||
admin?: boolean
|
||||
/** 个人简介 */
|
||||
resume?: string
|
||||
}
|
||||
|
||||
/** 角色对象 */
|
||||
export interface UserRole {
|
||||
/** 角色 ID */
|
||||
id: string
|
||||
/** 角色类型 */
|
||||
type?: string
|
||||
/** 角色分类 */
|
||||
category?: string
|
||||
/** 角色名称 */
|
||||
name: string
|
||||
/** 角色标识 */
|
||||
roleKey?: string
|
||||
/** 角色编码 */
|
||||
code?: string
|
||||
/** 排序 */
|
||||
sort?: number
|
||||
/** 数据范围 */
|
||||
dataRange?: number
|
||||
/** 状态 */
|
||||
state?: boolean
|
||||
/** 是否只读 */
|
||||
readonly?: boolean
|
||||
/** 是否级联下级 */
|
||||
cascadeLower?: boolean
|
||||
}
|
||||
|
||||
/** 组织机构对象 */
|
||||
export interface Organization {
|
||||
/** 组织 ID */
|
||||
id: string
|
||||
/** 组织名称 */
|
||||
orgName?: string
|
||||
/** 组织编码 */
|
||||
code?: string
|
||||
/** 父级 ID */
|
||||
parentId?: string
|
||||
/** 排序 */
|
||||
sort?: number
|
||||
/** 状态 */
|
||||
state?: boolean
|
||||
}
|
||||
|
||||
/** 岗位对象 */
|
||||
export interface Position {
|
||||
/** 岗位 ID */
|
||||
id: string
|
||||
/** 岗位名称 */
|
||||
name: string
|
||||
/** 岗位编码 */
|
||||
code?: string
|
||||
/** 排序 */
|
||||
sort?: number
|
||||
/** 状态 */
|
||||
state?: boolean
|
||||
}
|
||||
|
||||
/** ==================== Route 模块 ==================== */
|
||||
/** 路由/菜单项 */
|
||||
export interface RouteItem {
|
||||
/** 路由 ID */
|
||||
id?: string
|
||||
/** 路由名称 */
|
||||
name: string
|
||||
/** 路由路径 */
|
||||
path?: string
|
||||
/** 组件名称/页面名称 */
|
||||
component?: string
|
||||
/** 页面文件名(用于动态导入) */
|
||||
page?: string
|
||||
/** 图标 */
|
||||
icon?: string
|
||||
/** 排序 */
|
||||
order?: number
|
||||
/** 是否隐藏 */
|
||||
hidden?: boolean
|
||||
/** 是否缓存 */
|
||||
keepAlive?: boolean
|
||||
/** 重定向 */
|
||||
redirect?: string
|
||||
/** 子路由 */
|
||||
children?: RouteItem[]
|
||||
/** 元信息 */
|
||||
meta?: RouteMeta
|
||||
}
|
||||
|
||||
/** 路由元信息 */
|
||||
export interface RouteMeta {
|
||||
/** 标题 */
|
||||
title?: string
|
||||
/** 图标 */
|
||||
icon?: string
|
||||
/** 组件标识(如 LAYOUT) */
|
||||
component?: string
|
||||
/** 是否需要认证 */
|
||||
requiresAuth?: boolean
|
||||
/** 角色权限 */
|
||||
roles?: string[]
|
||||
/** 权限编码 */
|
||||
permissions?: string[]
|
||||
/** 是否缓存 */
|
||||
keepAlive?: boolean
|
||||
/** 是否隐藏(旧字段) */
|
||||
hidden?: boolean
|
||||
/** 是否隐藏菜单 */
|
||||
hideMenu?: boolean
|
||||
/** 是否在菜单中隐藏子路由 */
|
||||
hideChildrenInMenu?: boolean
|
||||
/** 是否动态添加 */
|
||||
dynamicAdded?: boolean
|
||||
/** 是否分页 */
|
||||
pagination?: boolean
|
||||
}
|
||||
|
||||
/** 获取用户路由响应 */
|
||||
export interface UserRoutesResponse {
|
||||
/** 是否启用URI/按钮权限 */
|
||||
enabled?: boolean
|
||||
/** 是否区分大小写 */
|
||||
caseSensitive?: boolean
|
||||
/** 拥有的资源编码 */
|
||||
resourceList?: string[]
|
||||
/** 拥有的菜单路由 */
|
||||
routerList: RouteItem[]
|
||||
/** 拥有的角色编码 */
|
||||
roleList?: string[]
|
||||
}
|
||||
|
||||
/** ==================== Resource 模块 ==================== */
|
||||
/** 资源树节点 */
|
||||
export interface ResourceTreeNode {
|
||||
/** 资源 ID */
|
||||
id: string
|
||||
/** 资源名称 */
|
||||
name: string
|
||||
/** 资源编码 */
|
||||
code?: string
|
||||
/** 资源类型 */
|
||||
type?: string
|
||||
/** 父级 ID */
|
||||
parentId?: string
|
||||
/** 路径 */
|
||||
path?: string
|
||||
/** 组件 */
|
||||
component?: string
|
||||
/** 图标 */
|
||||
icon?: string
|
||||
/** 排序 */
|
||||
sort?: number
|
||||
/** 状态 */
|
||||
state?: boolean
|
||||
/** 子节点 */
|
||||
children?: ResourceTreeNode[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 租户信息
|
||||
*/
|
||||
export interface TenantInfo {
|
||||
/** 租户 ID */
|
||||
id: string
|
||||
/** 租户编码 */
|
||||
code?: string
|
||||
/** 租户名称 */
|
||||
name: string
|
||||
/** 租户简称 */
|
||||
abbreviation?: string
|
||||
/** 租户状态(0正常 1审核中 2停用 3待初始化租户) */
|
||||
status?: number
|
||||
/** 启用状态 */
|
||||
state?: boolean
|
||||
/** 联系人 */
|
||||
contactName?: string
|
||||
/** 联系手机 */
|
||||
contactMobile?: string
|
||||
/** 联系人(别名) */
|
||||
contactPerson?: string
|
||||
/** 联系电话(别名) */
|
||||
contactPhone?: string
|
||||
/** 绑定域名 */
|
||||
website?: string
|
||||
/** 统一社会信用代码 */
|
||||
creditCode?: string
|
||||
/** 有效期 */
|
||||
expirationTime?: string
|
||||
/** 企业简介 */
|
||||
intro?: string
|
||||
/** 是否默认租户 */
|
||||
isDefault?: boolean
|
||||
/** 员工状态 */
|
||||
employeeState?: boolean
|
||||
/** 员工 ID */
|
||||
employeeId?: string
|
||||
}
|
||||
26
src/typings/env.d.ts
vendored
26
src/typings/env.d.ts
vendored
@@ -12,8 +12,10 @@ declare module '*.vue' {
|
||||
我们可以使用 if (window.$message) 来进行判断,避免出现类型错误。*/
|
||||
declare interface Window {
|
||||
$message: ReturnType<typeof useMessage>
|
||||
$dialog: ReturnType<typeof useDialog>
|
||||
$notification: ReturnType<typeof useNotification>
|
||||
$loadingBar: ReturnType<typeof useLoadingBar>
|
||||
$router: import('vue-router').Router
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -42,37 +44,43 @@ interface ServiceEnvConfigWithProxyPattern extends ServiceEnvConfig {
|
||||
|
||||
interface ImportMetaEnv {
|
||||
/** 后端项目地址 */
|
||||
readonly VITE_SERVICE_URL: string
|
||||
readonly VITE_SERVICE_URL?: string
|
||||
/** Gateway 地址 */
|
||||
readonly VITE_API_BASE_URL: string
|
||||
/** 项目名称 */
|
||||
readonly VITE_APP_NAME: string
|
||||
readonly VITE_APP_NAME?: string
|
||||
/** 项目标题 */
|
||||
readonly VITE_APP_TITLE: string
|
||||
/** 页面标题后缀*/
|
||||
readonly VITE_TITLE_SUFFIX: string
|
||||
readonly VITE_TITLE_SUFFIX?: string
|
||||
/** 项目ICP备案号 */
|
||||
readonly VITE_APP_ICP: string
|
||||
readonly VITE_APP_ICP?: string
|
||||
/** 项目描述 */
|
||||
readonly VITE_APP_DESC: string
|
||||
readonly VITE_APP_DESC?: string
|
||||
/** 后端服务的环境类型 */
|
||||
readonly VITE_SERVICE_ENV?: ServiceEnvType
|
||||
/** 应用环境 */
|
||||
readonly VITE_APP_ENV?: string
|
||||
/**
|
||||
* 权限路由模式:
|
||||
* - static - 前端声明的静态
|
||||
* - dynamic - 后端返回的动态
|
||||
*/
|
||||
readonly VITE_AUTH_ROUTE_MODE: 'static' | 'dynamic'
|
||||
readonly VITE_AUTH_ROUTE_MODE?: 'static' | 'dynamic'
|
||||
/** 路由首页的路径 */
|
||||
readonly VITE_ROUTE_HOME_PATH: AuthRoute.RoutePath
|
||||
readonly VITE_ROUTE_HOME_PATH?: string
|
||||
/** iconify图标作为组件的前缀 */
|
||||
readonly VITE_ICON_PREFIX: string
|
||||
readonly VITE_ICON_PREFIX?: string
|
||||
/**
|
||||
* 本地SVG图标作为组件的前缀, 请注意一定要包含 VITE_ICON_PREFIX
|
||||
* - 格式 {VITE_ICON_PREFIX}-{本地图标集合名称}
|
||||
* - 例如:icon-local
|
||||
*/
|
||||
readonly VITE_ICON_LOCAL_PREFIX: string
|
||||
readonly VITE_ICON_LOCAL_PREFIX?: string
|
||||
/** 开启请求代理 */
|
||||
readonly VITE_HTTP_PROXY?: 'Y' | 'N'
|
||||
/** Authorization 密钥 */
|
||||
readonly VITE_SECRET_KEY?: string
|
||||
/** 是否开启打包文件大小结果分析 */
|
||||
readonly VITE_VISUALIZER?: 'Y' | 'N'
|
||||
/** 是否开启打包压缩 */
|
||||
|
||||
250
src/utils/fingerprint.ts
Normal file
250
src/utils/fingerprint.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
import FingerprintJS from '@fingerprintjs/fingerprintjs'
|
||||
|
||||
/** 缓存 key */
|
||||
const FINGERPRINT_CACHE_KEY = 'device_fingerprint'
|
||||
/** 缓存有效期(24小时) */
|
||||
const CACHE_EXPIRY = 24 * 60 * 60 * 1000
|
||||
|
||||
/** 缓存数据结构 */
|
||||
interface FingerprintCache {
|
||||
fingerprint: string
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取缓存的设备指纹
|
||||
*/
|
||||
export function getCachedFingerprint(): string | null {
|
||||
try {
|
||||
const cached = localStorage.getItem(FINGERPRINT_CACHE_KEY)
|
||||
if (!cached) return null
|
||||
|
||||
const data: FingerprintCache = JSON.parse(cached)
|
||||
const now = Date.now()
|
||||
|
||||
// 检查是否过期
|
||||
if (now - data.timestamp > CACHE_EXPIRY) {
|
||||
localStorage.removeItem(FINGERPRINT_CACHE_KEY)
|
||||
return null
|
||||
}
|
||||
|
||||
return data.fingerprint
|
||||
} catch (error) {
|
||||
console.error('❌ 读取缓存的设备指纹失败:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 缓存设备指纹
|
||||
* @param fingerprint 设备指纹
|
||||
*/
|
||||
function cacheFingerprint(fingerprint: string): void {
|
||||
try {
|
||||
const data: FingerprintCache = {
|
||||
fingerprint,
|
||||
timestamp: Date.now()
|
||||
}
|
||||
localStorage.setItem(FINGERPRINT_CACHE_KEY, JSON.stringify(data))
|
||||
} catch (error) {
|
||||
console.error('❌ 缓存设备指纹失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除设备指纹缓存
|
||||
*/
|
||||
export function clearFingerprintCache(): void {
|
||||
try {
|
||||
localStorage.removeItem(FINGERPRINT_CACHE_KEY)
|
||||
console.log('✅ 设备指纹缓存已清除')
|
||||
} catch (error) {
|
||||
console.error('❌ 清除设备指纹缓存失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 收集设备信息
|
||||
*/
|
||||
function collectDeviceInfo(): Record<string, any> {
|
||||
const startTime = performance.now()
|
||||
|
||||
const info = {
|
||||
// 平台信息
|
||||
platform: navigator.platform,
|
||||
userAgent: navigator.userAgent,
|
||||
language: navigator.language,
|
||||
languages: navigator.languages,
|
||||
|
||||
// 屏幕信息
|
||||
screenWidth: screen.width,
|
||||
screenHeight: screen.height,
|
||||
screenColorDepth: screen.colorDepth,
|
||||
screenPixelDepth: screen.pixelDepth,
|
||||
devicePixelRatio: window.devicePixelRatio,
|
||||
|
||||
// 时区信息
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
timezoneOffset: new Date().getTimezoneOffset(),
|
||||
|
||||
// 硬件信息
|
||||
hardwareConcurrency: navigator.hardwareConcurrency,
|
||||
deviceMemory: (navigator as any).deviceMemory,
|
||||
|
||||
// 浏览器信息
|
||||
cookieEnabled: navigator.cookieEnabled,
|
||||
doNotTrack: navigator.doNotTrack,
|
||||
maxTouchPoints: navigator.maxTouchPoints
|
||||
}
|
||||
|
||||
const endTime = performance.now()
|
||||
console.log(`📊 收集设备信息耗时: ${(endTime - startTime).toFixed(2)}ms`)
|
||||
|
||||
return info
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测浏览器特征
|
||||
*/
|
||||
function detectBrowserFeatures(): Record<string, boolean> {
|
||||
const startTime = performance.now()
|
||||
|
||||
const features = {
|
||||
// 存储特征
|
||||
localStorage: !!window.localStorage,
|
||||
sessionStorage: !!window.sessionStorage,
|
||||
indexedDB: !!window.indexedDB,
|
||||
|
||||
// 图形特征
|
||||
canvas: !!document.createElement('canvas').getContext,
|
||||
webgl: !!document.createElement('canvas').getContext('webgl'),
|
||||
webgl2: !!document.createElement('canvas').getContext('webgl2'),
|
||||
|
||||
// 音频特征
|
||||
audioContext: !!(window.AudioContext || (window as any).webkitAudioContext),
|
||||
|
||||
// 其他特征
|
||||
webWorker: !!window.Worker,
|
||||
serviceWorker: 'serviceWorker' in navigator,
|
||||
webRTC: !!(window.RTCPeerConnection || (window as any).webkitRTCPeerConnection),
|
||||
webSocket: !!window.WebSocket,
|
||||
geolocation: !!navigator.geolocation,
|
||||
notification: 'Notification' in window,
|
||||
vibrate: !!navigator.vibrate,
|
||||
battery: 'getBattery' in navigator,
|
||||
bluetooth: 'bluetooth' in navigator,
|
||||
usb: 'usb' in navigator
|
||||
}
|
||||
|
||||
const endTime = performance.now()
|
||||
console.log(`🔍 特征检测耗时: ${(endTime - startTime).toFixed(2)}ms`)
|
||||
|
||||
return features
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 SHA-256 生成哈希
|
||||
* @param data 数据
|
||||
*/
|
||||
async function sha256(data: string): Promise<string> {
|
||||
const startTime = performance.now()
|
||||
|
||||
try {
|
||||
const encoder = new TextEncoder()
|
||||
const dataBuffer = encoder.encode(data)
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', dataBuffer)
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer))
|
||||
const hashHex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join('')
|
||||
|
||||
const endTime = performance.now()
|
||||
console.log(`🔨 SHA-256计算耗时: ${(endTime - startTime).toFixed(2)}ms`)
|
||||
|
||||
return hashHex
|
||||
} catch (error) {
|
||||
console.error('❌ SHA-256 计算失败:', error)
|
||||
// 降级方案:使用简单的字符串哈希
|
||||
let hash = 0
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const char = data.charCodeAt(i)
|
||||
hash = (hash << 5) - hash + char
|
||||
hash = hash & hash
|
||||
}
|
||||
return Math.abs(hash).toString(16)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取增强的设备指纹
|
||||
*/
|
||||
export async function getEnhancedFingerprint(): Promise<string> {
|
||||
const totalStartTime = performance.now()
|
||||
console.log('🔍 开始生成设备指纹...')
|
||||
|
||||
try {
|
||||
// 1. 检查缓存
|
||||
const cached = getCachedFingerprint()
|
||||
if (cached) {
|
||||
const totalEndTime = performance.now()
|
||||
console.log(`🔍 使用缓存的设备指纹,总耗时: ${(totalEndTime - totalStartTime).toFixed(2)}ms`)
|
||||
console.log(`🔑 设备指纹: ${cached}`)
|
||||
return cached
|
||||
}
|
||||
|
||||
// 2. 收集设备信息
|
||||
const deviceInfo = collectDeviceInfo()
|
||||
|
||||
// 3. 使用 FingerprintJS 获取基础浏览器指纹
|
||||
const fpStartTime = performance.now()
|
||||
const fp = await FingerprintJS.load()
|
||||
const result = await fp.get()
|
||||
const baseFp = result.visitorId
|
||||
const fpEndTime = performance.now()
|
||||
console.log(`🔑 基础指纹生成耗时: ${(fpEndTime - fpStartTime).toFixed(2)}ms`)
|
||||
|
||||
// 4. 检测浏览器特征
|
||||
const features = detectBrowserFeatures()
|
||||
|
||||
// 5. 组合所有特征
|
||||
const combinedData = {
|
||||
baseFp,
|
||||
deviceInfo,
|
||||
features,
|
||||
components: result.components
|
||||
}
|
||||
|
||||
// 6. 生成最终指纹(使用 SHA-256)
|
||||
const dataString = JSON.stringify(combinedData)
|
||||
const fingerprint = await sha256(dataString)
|
||||
|
||||
// 7. 缓存结果
|
||||
cacheFingerprint(fingerprint)
|
||||
|
||||
const totalEndTime = performance.now()
|
||||
console.log(`🔍 设备指纹获取总耗时: ${(totalEndTime - totalStartTime).toFixed(2)}ms`)
|
||||
console.log(`🔑 设备指纹: ${fingerprint}`)
|
||||
|
||||
return fingerprint
|
||||
} catch (error) {
|
||||
console.error('❌ 设备指纹生成失败:', error)
|
||||
|
||||
// 降级方案:使用时间戳 + 随机数
|
||||
const fallbackFp = `fallback_${Date.now()}_${Math.random().toString(36).substring(2, 15)}`
|
||||
console.warn(`⚠️ 使用降级方案生成设备指纹: ${fallbackFp}`)
|
||||
|
||||
return fallbackFp
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化设备指纹(预加载)
|
||||
* 可以在应用启动时调用,提前生成指纹
|
||||
*/
|
||||
export async function initFingerprint(): Promise<void> {
|
||||
try {
|
||||
const fingerprint = await getEnhancedFingerprint()
|
||||
console.log('✅ 设备指纹初始化成功:', fingerprint)
|
||||
} catch (error) {
|
||||
console.error('❌ 设备指纹初始化失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
327
src/utils/http.ts
Normal file
327
src/utils/http.ts
Normal file
@@ -0,0 +1,327 @@
|
||||
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse, AxiosError } from 'axios'
|
||||
import type { ApiResponse } from '@/types/api'
|
||||
import { RequestModule, getModuleProxyPath, getModuleRealPath } from '@/enums/request'
|
||||
import { userStore } from '@/stores/user'
|
||||
|
||||
/** 请求配置 */
|
||||
export interface RequestConfig extends AxiosRequestConfig {
|
||||
/** 是否显示错误提示 */
|
||||
showError?: boolean
|
||||
/** 是否显示加载提示 */
|
||||
showLoading?: boolean
|
||||
/** 是否需要 Token */
|
||||
needToken?: boolean
|
||||
/** 请求模块 */
|
||||
module?: RequestModule
|
||||
}
|
||||
|
||||
/** 错误消息栈,用于防止重复弹窗 */
|
||||
let errMsgStack: string[] = []
|
||||
/** 是否正在处理退出登录 */
|
||||
let isLoggingOut = false
|
||||
|
||||
/** 是否启用代理 */
|
||||
const isHttpProxy = import.meta.env.DEV && import.meta.env.VITE_HTTP_PROXY === 'Y'
|
||||
|
||||
/** 获取 baseURL */
|
||||
const getBaseURL = (): string => {
|
||||
if (isHttpProxy) {
|
||||
// 开发环境使用代理,返回空字符串(由代理处理)
|
||||
return ''
|
||||
}
|
||||
// 生产环境直接使用 Gateway 地址
|
||||
return import.meta.env.VITE_API_BASE_URL || 'http://localhost:18760'
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Basic Auth Authorization
|
||||
*/
|
||||
const getBasicAuthorization = (): string => {
|
||||
const secretKey = import.meta.env.VITE_SECRET_KEY || 'luohuo_web_pro:luohuo_web_pro_secret'
|
||||
// Base64 编码
|
||||
return btoa(secretKey)
|
||||
}
|
||||
|
||||
/** 创建 Axios 实例 */
|
||||
const createAxiosInstance = (): AxiosInstance => {
|
||||
const instance = axios.create({
|
||||
baseURL: getBaseURL(),
|
||||
timeout: 30000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json;charset=UTF-8'
|
||||
}
|
||||
})
|
||||
|
||||
// 请求拦截器
|
||||
instance.interceptors.request.use(
|
||||
(config: any) => {
|
||||
const store = userStore()
|
||||
const requestConfig = config as RequestConfig
|
||||
|
||||
// 处理 URL
|
||||
if (requestConfig.module) {
|
||||
const modulePrefix = isHttpProxy
|
||||
? getModuleProxyPath(requestConfig.module) // 开发环境:/proxy-oauth
|
||||
: getModuleRealPath(requestConfig.module) // 生产环境:/oauth、/base 等
|
||||
|
||||
// 如果 URL 不是以 http 开头,添加模块前缀
|
||||
if (config.url && !config.url.startsWith('http')) {
|
||||
config.url = `${modulePrefix}${config.url}`
|
||||
}
|
||||
}
|
||||
|
||||
// 添加 Basic Auth Authorization
|
||||
config.headers['Authorization'] = getBasicAuthorization()
|
||||
|
||||
// 添加用户 Token
|
||||
if (config.needToken !== false && store.getToken) {
|
||||
config.headers['Token'] = store.getToken
|
||||
}
|
||||
|
||||
// 添加应用 ID
|
||||
config.headers['Applicationid'] = 1
|
||||
|
||||
// 添加租户 ID
|
||||
const tenantId = store.getTenantId
|
||||
if (tenantId) {
|
||||
config.headers['tenant-id'] = tenantId
|
||||
}
|
||||
|
||||
// 添加当前路由路径
|
||||
if (window.$router) {
|
||||
const currentRoute = window.$router.currentRoute.value
|
||||
if (currentRoute) {
|
||||
config.headers['Path'] = currentRoute.fullPath?.split('?')[0]
|
||||
}
|
||||
}
|
||||
|
||||
console.log('📡 Request:', config.method?.toUpperCase(), config.url)
|
||||
|
||||
return config
|
||||
},
|
||||
(error: AxiosError) => {
|
||||
console.error('❌ Request Error:', error)
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
// 响应拦截器
|
||||
instance.interceptors.response.use(
|
||||
(response: AxiosResponse<ApiResponse>) => {
|
||||
const { data, config } = response
|
||||
const requestConfig = config as RequestConfig
|
||||
|
||||
// 打印响应日志
|
||||
console.log('✅ Response:', config.url, data)
|
||||
|
||||
// 判断响应是否成功
|
||||
const isSuccess = data.code === 200 || data.code === '200' || data.code === '00000' || data.success === true
|
||||
|
||||
if (isSuccess) {
|
||||
return response
|
||||
}
|
||||
|
||||
// 处理业务错误
|
||||
handleBusinessError(data, requestConfig)
|
||||
return Promise.reject(data)
|
||||
},
|
||||
(error: AxiosError<ApiResponse>) => {
|
||||
console.error('❌ Response Error:', error)
|
||||
|
||||
// 处理 HTTP 错误
|
||||
handleHttpError(error)
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
return instance
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理业务错误
|
||||
* @param data 响应数据
|
||||
* @param config 请求配置
|
||||
*/
|
||||
function handleBusinessError(data: ApiResponse, config: RequestConfig) {
|
||||
const store = userStore()
|
||||
const responseCode = String(data.code)
|
||||
|
||||
// 获取环境变量配置的错误码
|
||||
const logoutCodes = import.meta.env.VITE_SERVICE_LOGOUT_CODES?.split(',') || []
|
||||
const modalLogoutCodes = import.meta.env.VITE_SERVICE_MODAL_LOGOUT_CODES?.split(',') || []
|
||||
|
||||
// 如果正在处理退出登录,直接返回,避免重复处理
|
||||
if (isLoggingOut) {
|
||||
console.log('⏸️ 正在处理退出登录,跳过当前错误处理')
|
||||
return
|
||||
}
|
||||
|
||||
// 处理需要直接退出登录的错误码(如 401)
|
||||
if (logoutCodes.includes(responseCode)) {
|
||||
console.warn(`🚪 检测到退出登录错误码: ${responseCode},即将退出登录`)
|
||||
|
||||
// 设置退出登录标志
|
||||
isLoggingOut = true
|
||||
|
||||
window.$message?.error(data.msg || '登录已过期,请重新登录')
|
||||
|
||||
// 延迟执行退出登录,确保消息显示
|
||||
setTimeout(() => {
|
||||
store.logout().finally(() => {
|
||||
// 重置标志
|
||||
isLoggingOut = false
|
||||
})
|
||||
}, 100)
|
||||
return
|
||||
}
|
||||
|
||||
// 处理需要弹窗提示后退出登录的错误码(如 406 token已过期)
|
||||
if (modalLogoutCodes.includes(responseCode)) {
|
||||
const errorMsg = data.msg || 'Token 已过期,请重新登录'
|
||||
|
||||
// 检查是否已经显示过相同的错误消息
|
||||
if (errMsgStack.includes(errorMsg)) {
|
||||
console.log('⏸️ 该错误消息已经显示,跳过重复弹窗')
|
||||
return
|
||||
}
|
||||
|
||||
console.warn(`⚠️ 检测到 token 过期错误码: ${responseCode},弹窗提示后退出登录`)
|
||||
|
||||
// 设置退出登录标志
|
||||
isLoggingOut = true
|
||||
|
||||
// 添加到错误消息栈
|
||||
errMsgStack.push(errorMsg)
|
||||
|
||||
// 防止用户刷新页面
|
||||
const handleBeforeUnload = () => {
|
||||
handleLogout()
|
||||
}
|
||||
window.addEventListener('beforeunload', handleBeforeUnload)
|
||||
|
||||
// 退出登录处理函数
|
||||
const handleLogout = () => {
|
||||
// 清理
|
||||
window.removeEventListener('beforeunload', handleBeforeUnload)
|
||||
errMsgStack = errMsgStack.filter((msg) => msg !== errorMsg)
|
||||
|
||||
// 执行退出登录
|
||||
store.logout().finally(() => {
|
||||
// 重置标志
|
||||
isLoggingOut = false
|
||||
})
|
||||
}
|
||||
|
||||
// 使用 naive-ui 的 dialog 弹窗
|
||||
window.$dialog?.error({
|
||||
title: '提示',
|
||||
content: errorMsg,
|
||||
positiveText: '确定',
|
||||
maskClosable: false,
|
||||
closable: false,
|
||||
onPositiveClick: () => {
|
||||
handleLogout()
|
||||
},
|
||||
onClose: () => {
|
||||
handleLogout()
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 403 无权限
|
||||
if (responseCode === '403') {
|
||||
window.$message?.error(data.msg || '无权限访问')
|
||||
return
|
||||
}
|
||||
|
||||
// 其他错误
|
||||
if (config.showError !== false) {
|
||||
window.$message?.error(data.msg || '请求失败')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 HTTP 错误
|
||||
* @param error Axios 错误对象
|
||||
*/
|
||||
function handleHttpError(error: AxiosError<ApiResponse>) {
|
||||
const store = userStore()
|
||||
|
||||
if (error.response) {
|
||||
const { status, data } = error.response
|
||||
|
||||
switch (status) {
|
||||
case 401:
|
||||
window.$message?.error('登录已过期,请重新登录')
|
||||
store.logout()
|
||||
break
|
||||
case 403:
|
||||
window.$message?.error('无权限访问')
|
||||
break
|
||||
case 404:
|
||||
window.$message?.error('请求的资源不存在')
|
||||
break
|
||||
case 500:
|
||||
window.$message?.error(data?.msg || '服务器错误')
|
||||
break
|
||||
default:
|
||||
window.$message?.error(data?.msg || `请求失败 (${status})`)
|
||||
}
|
||||
} else if (error.request) {
|
||||
window.$message?.error('网络错误,请检查网络连接')
|
||||
} else {
|
||||
window.$message?.error(error.message || '请求失败')
|
||||
}
|
||||
}
|
||||
|
||||
/** 创建 HTTP 实例 */
|
||||
const http = createAxiosInstance()
|
||||
|
||||
/**
|
||||
* GET 请求
|
||||
* @param url 请求地址
|
||||
* @param config 请求配置
|
||||
*/
|
||||
export function get<T = any>(url: string, config?: RequestConfig): Promise<ApiResponse<T>> {
|
||||
return http.get(url, config).then((res) => res.data)
|
||||
}
|
||||
|
||||
/**
|
||||
* POST 请求
|
||||
* @param url 请求地址
|
||||
* @param data 请求数据
|
||||
* @param config 请求配置
|
||||
*/
|
||||
export function post<T = any>(url: string, data?: any, config?: RequestConfig): Promise<ApiResponse<T>> {
|
||||
return http.post(url, data, config).then((res) => res.data)
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT 请求
|
||||
* @param url 请求地址
|
||||
* @param data 请求数据
|
||||
* @param config 请求配置
|
||||
*/
|
||||
export function put<T = any>(url: string, data?: any, config?: RequestConfig): Promise<ApiResponse<T>> {
|
||||
return http.put(url, data, config).then((res) => res.data)
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE 请求
|
||||
* @param url 请求地址
|
||||
* @param config 请求配置
|
||||
*/
|
||||
export function del<T = any>(url: string, config?: RequestConfig): Promise<ApiResponse<T>> {
|
||||
return http.delete(url, config).then((res) => res.data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用请求方法
|
||||
* @param config 请求配置
|
||||
*/
|
||||
export function request<T = any>(config: RequestConfig): Promise<ApiResponse<T>> {
|
||||
return http.request(config).then((res) => res.data)
|
||||
}
|
||||
|
||||
export default http
|
||||
@@ -114,7 +114,7 @@ export const roleTable = (data: Ref<any[]>) => {
|
||||
])
|
||||
|
||||
/*编辑处理*/
|
||||
const handleEditTable = (rowId: number) => {
|
||||
const handleEditTable = (rowId: string) => {
|
||||
showDrawer.value = true
|
||||
const findItem = data.value.find((item: pageUser) => item.id === rowId)
|
||||
if (findItem) {
|
||||
|
||||
@@ -260,7 +260,7 @@ export const userTable = (data: Ref<any[]>) => {
|
||||
}
|
||||
])
|
||||
/*编辑处理*/
|
||||
const handleEditTable = (rowId: number) => {
|
||||
const handleEditTable = (rowId: string) => {
|
||||
showDrawer.value = true
|
||||
const findItem = data.value.find((item: pageUser) => item.id === rowId)
|
||||
if (findItem) {
|
||||
|
||||
@@ -15,34 +15,12 @@
|
||||
<!-- 登录表单 -->
|
||||
<n-card class="form">
|
||||
<n-form ref="formRef" :show-require-mark="false" :rules="rules as any" :model="ruleForm">
|
||||
<!--租户选中框-->
|
||||
<n-form-item path="tenantName" :label="t('tenant')" label-style="font-size: 14px;color: #cccccc">
|
||||
<n-select
|
||||
v-model:value="ruleForm.tenantName"
|
||||
v-model:show="showSelect"
|
||||
:placeholder="t('select')"
|
||||
:loading="loadingSelect"
|
||||
@focus="handleShowSelect"
|
||||
:render-label="renderLabel"
|
||||
:render-tag="renderSingleSelectTag"
|
||||
@updateValue="handleUpdateValue"
|
||||
clearable
|
||||
remote
|
||||
:options="selectData">
|
||||
<template #arrow>
|
||||
<transition name="slide-left">
|
||||
<Cloud v-if="showSelect" />
|
||||
<BuildingSkyscraper v-else />
|
||||
</transition>
|
||||
</template>
|
||||
</n-select>
|
||||
</n-form-item>
|
||||
<!--用户名输入框-->
|
||||
<n-form-item path="userName" :label="t('un_or_el')" label-style="font-size: 14px;color: #cccccc">
|
||||
<n-input
|
||||
clearable
|
||||
:allow-input="Common.noSideSpace"
|
||||
@keydown.enter="SignIn(formRef)"
|
||||
@keydown.enter="handleLogin"
|
||||
v-model:value="ruleForm.userName"
|
||||
style="border-radius: 8px"
|
||||
:placeholder="t('input_username_email')">
|
||||
@@ -70,8 +48,8 @@
|
||||
type="password"
|
||||
clearable
|
||||
:allow-input="Common.noSideSpace"
|
||||
:loading="loadingPaw"
|
||||
@keydown.enter="SignIn(formRef)"
|
||||
:loading="signInLoading"
|
||||
@keydown.enter="handleLogin"
|
||||
v-model:value="ruleForm.password"
|
||||
style="border-radius: 8px"
|
||||
:placeholder="t('input_paw')">
|
||||
@@ -89,7 +67,7 @@
|
||||
</n-checkbox>
|
||||
|
||||
<n-button
|
||||
@click="SignIn(formRef)"
|
||||
@click="handleLogin"
|
||||
:disabled="disabled"
|
||||
:loading="signInLoading"
|
||||
type="primary"
|
||||
@@ -153,7 +131,7 @@
|
||||
</template>
|
||||
</n-popover>
|
||||
</div>
|
||||
<p>Copyright © 2023-2024 HuLa.All Rights Reserved.</p>
|
||||
<p>Copyright © 2025-2026 HuLa.All Rights Reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -168,11 +146,9 @@ import { storeToRefs } from 'pinia'
|
||||
import check from '@/hooks/useCheck.ts'
|
||||
import { useLogin } from '@/hooks/useLogin'
|
||||
import { animation } from '@/components/modal/type'
|
||||
import { BuildingSkyscraper, Cloud, Lock, User } from '@vicons/tabler'
|
||||
import { Lock, User } from '@vicons/tabler'
|
||||
import { AlertIze } from '@/customize'
|
||||
import { delay } from 'lodash-es'
|
||||
import apis from '@/services/apis'
|
||||
import { NAvatar, NForm, NText, SelectGroupOption, SelectOption, SelectRenderLabel, SelectRenderTag } from 'naive-ui'
|
||||
import { NForm } from 'naive-ui'
|
||||
import { Common } from '@/utils/Common'
|
||||
|
||||
const { t } = i18n.global
|
||||
@@ -180,108 +156,11 @@ const store = mainStore()
|
||||
const rememberStore = remember()
|
||||
const tenantStore = tenant()
|
||||
|
||||
const showSelect = ref()
|
||||
const loadingSelect = ref(false)
|
||||
const selectData = ref<Array<SelectOption | SelectGroupOption>>([])
|
||||
/*选中租户之后就存入localStorage*/
|
||||
const handleUpdateValue = (_value: string, option: SelectOption) => {
|
||||
if (option) {
|
||||
const data = { label: option.label, value: option.value }
|
||||
tenantStore.setTenant(data)
|
||||
}
|
||||
}
|
||||
const renderSingleSelectTag: SelectRenderTag = ({ option }) => {
|
||||
return h(
|
||||
'div',
|
||||
{
|
||||
style: {
|
||||
display: 'flex',
|
||||
alignItems: 'center'
|
||||
}
|
||||
},
|
||||
[
|
||||
h(NAvatar, {
|
||||
src: 'https://07akioni.oss-cn-beijing.aliyuncs.com/07akioni.jpeg',
|
||||
round: true,
|
||||
size: 24,
|
||||
style: {
|
||||
marginRight: '12px'
|
||||
}
|
||||
}),
|
||||
option.label as string
|
||||
]
|
||||
)
|
||||
}
|
||||
const renderLabel: SelectRenderLabel = (option) => {
|
||||
return h(
|
||||
'div',
|
||||
{
|
||||
style: {
|
||||
display: 'flex',
|
||||
alignItems: 'center'
|
||||
}
|
||||
},
|
||||
[
|
||||
h(NAvatar, {
|
||||
src: 'https://07akioni.oss-cn-beijing.aliyuncs.com/07akioni.jpeg',
|
||||
round: true,
|
||||
lazy: true
|
||||
}),
|
||||
h(
|
||||
'div',
|
||||
{
|
||||
style: {
|
||||
marginLeft: '12px',
|
||||
padding: '4px 0'
|
||||
}
|
||||
},
|
||||
[
|
||||
h('div', null, [option.label as string]),
|
||||
h(
|
||||
NText,
|
||||
{ depth: 3, tag: 'div', style: { fontSize: '11px', transform: 'scale(1)' } },
|
||||
{
|
||||
default: () => option.address
|
||||
}
|
||||
)
|
||||
]
|
||||
)
|
||||
]
|
||||
)
|
||||
}
|
||||
// 注入登录成功回调
|
||||
const onLoginSuccess = inject<() => Promise<void>>('onLoginSuccess')
|
||||
|
||||
type Itenant = {
|
||||
companyName: string
|
||||
tenantId: string
|
||||
address: string
|
||||
packageId: string
|
||||
status: '0' | '1'
|
||||
expireTime: string
|
||||
}
|
||||
/*点击选中框后进行异步查询选项框内容*/
|
||||
const handleShowSelect = () => {
|
||||
if (selectData.value.length > 0) return
|
||||
loadingSelect.value = true
|
||||
delay(() => {
|
||||
apis.getTenantList().then((r: any) => {
|
||||
// 使用一个 Map 来存储 group 的数据,以 flag 作为键
|
||||
const groupMap = new Array<{ label: string; value: string; address?: string; disabled?: boolean }>()
|
||||
// 遍历角色数据并更新 groupMap
|
||||
r.data.forEach((value: Itenant) => {
|
||||
// 找到对应的组,将数据添加到 children 中
|
||||
groupMap.push({
|
||||
label: value.companyName,
|
||||
value: value.tenantId,
|
||||
address: value.address,
|
||||
disabled: value.status === '1'
|
||||
})
|
||||
})
|
||||
selectData.value = Array.from(groupMap.values())
|
||||
loadingSelect.value = false
|
||||
})
|
||||
}, 300)
|
||||
}
|
||||
const { loadingPaw, ValidationStatus } = typeState
|
||||
// 租户选择应该在登录后进行,用于切换不同的租户环境
|
||||
const { ValidationStatus } = typeState
|
||||
const { TEXT_COLOR } = storeToRefs(store)
|
||||
const {
|
||||
signInLoading,
|
||||
@@ -301,14 +180,7 @@ const { validateLoginUsername, validatePassword } = check()
|
||||
|
||||
const rules = reactive({
|
||||
userName: { required: true, asyncValidator: validateLoginUsername, trigger: 'blur' },
|
||||
password: { required: true, asyncValidator: validatePassword, trigger: 'blur' },
|
||||
tenantName: {
|
||||
required: true,
|
||||
renderMessage: () => {
|
||||
return t('choose')
|
||||
},
|
||||
trigger: ['blur', 'change']
|
||||
}
|
||||
password: { required: true, asyncValidator: validatePassword, trigger: 'blur' }
|
||||
})
|
||||
|
||||
const linkList = reactive<any>({
|
||||
@@ -342,8 +214,9 @@ const changePawBox = () => {
|
||||
|
||||
/*获取用户是否点击了记住*/
|
||||
const handleRemember = () => {
|
||||
if (rememberStore.getRemember) {
|
||||
const { userName, password, rememberMe } = rememberStore.getRemember
|
||||
const rememberData = rememberStore.getRemember
|
||||
if (rememberData && rememberData.userName) {
|
||||
const { userName, password, rememberMe } = rememberData
|
||||
ruleForm.userName = userName
|
||||
ruleForm.password = password
|
||||
rememberOption.value = rememberMe
|
||||
@@ -358,6 +231,17 @@ const handleTenant = () => {
|
||||
}
|
||||
}
|
||||
|
||||
/*处理登录*/
|
||||
const handleLogin = async () => {
|
||||
const result = await SignIn(formRef.value)
|
||||
if (result && result.success && result.needSelectTenant) {
|
||||
// 登录成功,需要选择租户
|
||||
if (onLoginSuccess) {
|
||||
await onLoginSuccess()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
handleRemember()
|
||||
handleTenant()
|
||||
|
||||
@@ -57,6 +57,15 @@
|
||||
</template>
|
||||
</modal>
|
||||
</Teleport>
|
||||
|
||||
<!-- 租户选择弹窗 -->
|
||||
<TenantSelector
|
||||
v-model:show="showTenantSelector"
|
||||
:tenant-list="tenantList"
|
||||
:loading="tenantLoading"
|
||||
@confirm="handleTenantConfirm"
|
||||
@cancel="handleTenantCancel"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script async setup lang="ts">
|
||||
@@ -70,11 +79,21 @@ import CodeInput from '@/components/codeinput/index.vue'
|
||||
import CountDown from '@/components/countdown/index.vue'
|
||||
import HeaderGroup from '@/views/login/head/index.vue'
|
||||
import LoginForm from '@/views/login/form/index.vue'
|
||||
import TenantSelector from '@/components/TenantSelector.vue'
|
||||
import { initFingerprint } from '@/utils/fingerprint'
|
||||
import { userStore } from '@/stores/user'
|
||||
import { tenant } from '@/stores/tenant'
|
||||
import router from '@/router'
|
||||
import { Loading } from 'notiflix'
|
||||
import type { TenantInfo } from '@/types/api'
|
||||
|
||||
/*在layout中挂载需要挂载全局的hook*/
|
||||
window.$message = useMessage()
|
||||
window.$notification = useNotification()
|
||||
const { t } = i18n.global
|
||||
const userInfoStore = userStore()
|
||||
const tenantStore = tenant()
|
||||
|
||||
/*验证码输入框内容*/
|
||||
const code = ref<any>('')
|
||||
const { formRef, showModal, emailMsg, codeMsg, ruleEmail, showCode, handleCodeInput } = useLogin()
|
||||
@@ -101,6 +120,102 @@ const codeInputClose = async () => {
|
||||
animation.value = 'modal-container animate__animated animate__shakeX'
|
||||
})
|
||||
}
|
||||
|
||||
/*租户选择相关*/
|
||||
const showTenantSelector = ref(false)
|
||||
const tenantList = ref<TenantInfo[]>([])
|
||||
const tenantLoading = ref(false)
|
||||
|
||||
/*处理租户选择确认*/
|
||||
const handleTenantConfirm = async (tenantId: string) => {
|
||||
Loading.pulse()
|
||||
try {
|
||||
const selectedTenant = tenantList.value.find((t) => t.id === tenantId)
|
||||
if (selectedTenant) {
|
||||
tenantStore.setTenant({
|
||||
tenantName: selectedTenant.name,
|
||||
tenantId: String(tenantId),
|
||||
tenantUrl: selectedTenant.website || ''
|
||||
})
|
||||
}
|
||||
|
||||
// 设置租户并初始化用户信息
|
||||
const result = await userInfoStore.setTenantAndInit(tenantId)
|
||||
|
||||
if (result.success) {
|
||||
showTenantSelector.value = false
|
||||
Loading.remove()
|
||||
|
||||
// 根据后端返回的菜单,选择第一个可访问的页面作为默认跳转
|
||||
const menus = (userInfoStore.loginInfo.menus || []) as any[]
|
||||
const findFirstPath = (items: any[]): string | undefined => {
|
||||
for (const item of items) {
|
||||
if (item.children && item.children.length) {
|
||||
const childPath = findFirstPath(item.children)
|
||||
if (childPath) return childPath
|
||||
} else if (item.path) {
|
||||
return item.path as string
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
const defaultPath = findFirstPath(menus) || '/home'
|
||||
|
||||
router.push(defaultPath)
|
||||
|
||||
window.$notification.success({
|
||||
title: t('login_success'),
|
||||
duration: 1500,
|
||||
keepAliveOnHover: true
|
||||
})
|
||||
} else {
|
||||
Loading.remove()
|
||||
window.$message.error(result.message || '设置租户失败')
|
||||
}
|
||||
} catch (error: any) {
|
||||
Loading.remove()
|
||||
console.error('设置租户失败:', error)
|
||||
window.$message.error(error.message || '设置租户失败')
|
||||
}
|
||||
}
|
||||
|
||||
/*处理租户选择取消*/
|
||||
const handleTenantCancel = () => {
|
||||
showTenantSelector.value = false
|
||||
userInfoStore.logout()
|
||||
}
|
||||
|
||||
/*页面加载时初始化设备指纹*/
|
||||
onMounted(() => {
|
||||
initFingerprint()
|
||||
})
|
||||
|
||||
/*监听登录成功事件,显示租户选择弹窗*/
|
||||
provide('onLoginSuccess', async () => {
|
||||
tenantLoading.value = true
|
||||
showTenantSelector.value = true
|
||||
|
||||
try {
|
||||
const list = await userInfoStore.getUserTenantList()
|
||||
tenantList.value = list
|
||||
|
||||
if (list.length === 0) {
|
||||
window.$message.error('您没有可用的企业,请联系管理员')
|
||||
showTenantSelector.value = false
|
||||
userInfoStore.logout()
|
||||
} else if (list.length === 1) {
|
||||
// 如果只有一个租户,自动选择
|
||||
await handleTenantConfirm(list[0].id)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取租户列表失败:', error)
|
||||
window.$message.error('获取租户列表失败')
|
||||
showTenantSelector.value = false
|
||||
userInfoStore.logout()
|
||||
} finally {
|
||||
tenantLoading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@@ -14,7 +14,7 @@ import { visualizer } from 'rollup-plugin-visualizer'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig(({ mode }: ConfigEnv) => {
|
||||
// 获取当前环境的配置,如何设置第三个参数则加载所有变量,而不是以“VITE_”前缀的变量
|
||||
// 获取当前环境的配置,如何设置第三个参数则加载所有变量,而不是以"VITE_"前缀的变量
|
||||
const config = loadEnv(mode, process.cwd())
|
||||
return {
|
||||
resolve: {
|
||||
@@ -97,14 +97,41 @@ export default defineConfig(({ mode }: ConfigEnv) => {
|
||||
// 配置前端服务地址和端口
|
||||
server: {
|
||||
//配置跨域
|
||||
proxy: {
|
||||
'/api': {
|
||||
// “/api” 以及前置字符串会被替换为真正域名
|
||||
target: config.VITE_SERVICE_URL, // 请求域名
|
||||
changeOrigin: true, // 是否跨域
|
||||
rewrite: (path) => path.replace(/^\/api/, '')
|
||||
}
|
||||
},
|
||||
proxy:
|
||||
config.VITE_HTTP_PROXY === 'Y'
|
||||
? {
|
||||
// oauth 模块代理
|
||||
'/proxy-oauth': {
|
||||
target: config.VITE_API_BASE_URL,
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/proxy-oauth/, '/oauth')
|
||||
},
|
||||
// base 模块代理
|
||||
'/proxy-base': {
|
||||
target: config.VITE_API_BASE_URL,
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/proxy-base/, '/base')
|
||||
},
|
||||
// system 模块代理
|
||||
'/proxy-system': {
|
||||
target: config.VITE_API_BASE_URL,
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/proxy-system/, '/system')
|
||||
},
|
||||
// ai 模块代理
|
||||
'/proxy-ai': {
|
||||
target: config.VITE_API_BASE_URL,
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/proxy-ai/, '/ai')
|
||||
},
|
||||
// gateway 模块代理
|
||||
'/proxy-gateway': {
|
||||
target: config.VITE_API_BASE_URL,
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/proxy-gateway/, '/gateway')
|
||||
}
|
||||
}
|
||||
: undefined,
|
||||
cors: true, // 配置 CORS
|
||||
hmr: true, // 热更新
|
||||
host: '0.0.0.0',
|
||||
@@ -113,3 +140,4 @@ export default defineConfig(({ mode }: ConfigEnv) => {
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user