🆕 对接登录、路由、资源

This commit is contained in:
乾乾
2025-11-14 19:11:52 +08:00
committed by Dawn
parent 578d56054e
commit f6edb09a20
33 changed files with 2380 additions and 347 deletions

View File

@@ -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 过期需要刷新的错误码(多个用逗号分隔)
# 注意:目前暂不支持自动刷新 token406 直接退出登录
VITE_SERVICE_EXPIRED_TOKEN_CODES=

View File

@@ -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
View File

@@ -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

View File

@@ -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
View 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
View 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
View 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
View 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
})
}

View 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>

View File

@@ -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>

View File

@@ -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
View 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}`
}

View File

@@ -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' : ''">

View File

@@ -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

View File

@@ -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
}
}
/**

View File

@@ -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
}

View File

@@ -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 }

View File

@@ -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 }

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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()
})

View File

@@ -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),
/*修改 角色*/

View File

@@ -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
}

View File

@@ -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
View 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
View File

@@ -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
View 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
View 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

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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()

View File

@@ -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>

View File

@@ -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) => {
}
}
})