diff --git a/src/api/ai/index.ts b/src/api/ai/index.ts index faf208d5..802100a1 100644 --- a/src/api/ai/index.ts +++ b/src/api/ai/index.ts @@ -90,12 +90,11 @@ export interface AiExecuteResponse { export interface AiCommandRecordPageQuery extends PageQuery { keywords?: string; - executeStatus?: string; - parseSuccess?: boolean; + executeStatus?: number; + parseStatus?: number; userId?: number; - isDangerous?: boolean; - provider?: string; - model?: string; + aiProvider?: string; + aiModel?: string; functionName?: string; createTime?: [string, string]; } @@ -105,33 +104,23 @@ export interface AiCommandRecordVO { userId: number; username: string; originalCommand: string; - provider?: string; - model?: string; - parseSuccess?: boolean; + aiProvider?: string; + aiModel?: string; + parseStatus?: number; functionCalls?: string; explanation?: string; confidence?: number; parseErrorMessage?: string; inputTokens?: number; outputTokens?: number; - totalTokens?: number; - parseTime?: number; + parseDurationMs?: number; functionName?: string; functionArguments?: string; - executeStatus?: string; - executeResult?: string; + executeStatus?: number; executeErrorMessage?: string; - affectedRows?: number; - isDangerous?: boolean; - requiresConfirmation?: boolean; - userConfirmed?: boolean; - executionTime?: number; ipAddress?: string; - userAgent?: string; - currentRoute?: string; createTime?: string; updateTime?: string; - remark?: string; } /** @@ -180,9 +169,9 @@ class AiCommandApi { /** * 撤销命令执行(如果支持) */ - static rollbackCommand(recordId: string) { + static rollbackCommand(logId: string) { return request({ - url: `/api/v1/ai/command/rollback/${recordId}`, + url: `/api/v1/ai/command/rollback/${logId}`, method: "post", }); } diff --git a/src/api/auth-api.ts b/src/api/auth-api.ts index 772e9ef3..d2e9e3f6 100644 --- a/src/api/auth-api.ts +++ b/src/api/auth-api.ts @@ -4,18 +4,16 @@ const AUTH_BASE_URL = "/api/v1/auth"; const AuthAPI = { /** 登录接口*/ - login(data: LoginFormData) { - const formData = new FormData(); - formData.append("username", data.username); - formData.append("password", data.password); - formData.append("captchaKey", data.captchaKey); - formData.append("captchaCode", data.captchaCode); + login(data: LoginRequest) { return request({ url: `${AUTH_BASE_URL}/login`, method: "post", - data: formData, - headers: { - "Content-Type": "multipart/form-data", + data: { + username: data.username, + password: data.password, + captchaId: data.captchaId, + captchaCode: data.captchaCode, + tenantId: data.tenantId, }, }); }, @@ -51,18 +49,20 @@ const AuthAPI = { export default AuthAPI; -/** 登录表单数据 */ -export interface LoginFormData { +/** 登录请求参数 */ +export interface LoginRequest { /** 用户名 */ username: string; /** 密码 */ password: string; - /** 验证码缓存key */ - captchaKey: string; + /** 验证码缓存ID */ + captchaId?: string; /** 验证码 */ - captchaCode: string; - /** 记住我 */ - rememberMe: boolean; + captchaCode?: string; + /** 记住我(前端使用,不发送到后端) */ + rememberMe?: boolean; + /** 租户ID(可选,多租户模式下用于指定租户) */ + tenantId?: number; } /** 登录响应 */ @@ -79,8 +79,8 @@ export interface LoginResult { /** 验证码信息 */ export interface CaptchaInfo { - /** 验证码缓存key */ - captchaKey: string; + /** 验证码缓存ID */ + captchaId: string; /** 验证码图片Base64字符串 */ captchaBase64: string; } diff --git a/src/api/system/tenant-api.ts b/src/api/system/tenant-api.ts new file mode 100644 index 00000000..1043a993 --- /dev/null +++ b/src/api/system/tenant-api.ts @@ -0,0 +1,68 @@ +import request from "@/utils/request"; + +const TENANT_BASE_URL = "/api/v1/tenants"; + +/** + * 租户信息 + */ +export interface TenantInfo { + /** 租户ID */ + id: number; + /** 租户名称 */ + name: string; + /** 租户编码 */ + code?: string; + /** 租户状态(1-正常 0-禁用) */ + status?: number; + /** 联系人姓名 */ + contactName?: string; + /** 联系人电话 */ + contactPhone?: string; + /** 联系人邮箱 */ + contactEmail?: string; + /** 租户域名 */ + domain?: string; + /** 租户Logo */ + logo?: string; + /** 是否默认租户 */ + isDefault?: boolean; +} + +/** + * 租户 API + */ +const TenantAPI = { + /** + * 获取当前用户的租户列表 + */ + getTenantList() { + return request({ + url: `${TENANT_BASE_URL}`, + method: "get", + }); + }, + + /** + * 获取当前租户信息 + */ + getCurrentTenant() { + return request({ + url: `${TENANT_BASE_URL}/current`, + method: "get", + }); + }, + + /** + * 切换租户 + * + * @param tenantId 目标租户ID + */ + switchTenant(tenantId: number) { + return request({ + url: `${TENANT_BASE_URL}/${tenantId}/switch`, + method: "post", + }); + }, +}; + +export default TenantAPI; diff --git a/src/assets/images/login-bg.svg b/src/assets/images/login-bg.svg index d2351030..b143be88 100644 --- a/src/assets/images/login-bg.svg +++ b/src/assets/images/login-bg.svg @@ -15,43 +15,43 @@ - + - + - + - + - + - + - + diff --git a/src/components/CURD/types.ts b/src/components/CURD/types.ts index a5fe55ec..628e1908 100644 --- a/src/components/CURD/types.ts +++ b/src/components/CURD/types.ts @@ -23,7 +23,7 @@ type ToolbarTable = "edit" | "view" | "delete"; export type IToolsButton = { name: string; // 按钮名称 text?: string; // 按钮文本 - perm?: Array | string; // 权限标识(可以是完整权限字符串如'sys:user:add'或操作权限如'add') + perm?: Array | string; // 权限标识(可以是完整权限字符串如'sys:user:create'或操作权限如'create') attrs?: Partial & { style?: CSSProperties }; // 按钮属性 render?: (row: IObject) => boolean; // 条件渲染 }; diff --git a/src/components/TenantSelect/index.vue b/src/components/TenantSelect/index.vue new file mode 100644 index 00000000..12f55f17 --- /dev/null +++ b/src/components/TenantSelect/index.vue @@ -0,0 +1,152 @@ + + + + + diff --git a/src/components/TenantSelectDialog/index.vue b/src/components/TenantSelectDialog/index.vue new file mode 100644 index 00000000..18467040 --- /dev/null +++ b/src/components/TenantSelectDialog/index.vue @@ -0,0 +1,187 @@ + + + + + diff --git a/src/directives/permission/index.ts b/src/directives/permission/index.ts index b2d856a4..bd462078 100644 --- a/src/directives/permission/index.ts +++ b/src/directives/permission/index.ts @@ -13,7 +13,7 @@ export const hasPerm: Directive = { // 校验传入的权限值是否合法 if (!requiredPerms || (typeof requiredPerms !== "string" && !Array.isArray(requiredPerms))) { throw new Error( - "需要提供权限标识!例如:v-has-perm=\"'sys:user:add'\" 或 v-has-perm=\"['sys:user:add', 'sys:user:edit']\"" + "需要提供权限标识!例如:v-has-perm=\"'sys:user:create'\" 或 v-has-perm=\"['sys:user:create', 'sys:user:update']\"" ); } diff --git a/src/enums/api/code-enum.ts b/src/enums/api/code-enum.ts index 6afe5904..fd06b2c3 100644 --- a/src/enums/api/code-enum.ts +++ b/src/enums/api/code-enum.ts @@ -20,4 +20,9 @@ export const enum ApiCodeEnum { * 刷新令牌无效或过期 */ REFRESH_TOKEN_INVALID = "A0231", + + /** + * 需要选择租户 + */ + CHOOSE_TENANT = "A0250", } diff --git a/src/enums/system/menu-enum.ts b/src/enums/system/menu-enum.ts index 3ed2f378..5e8352d1 100644 --- a/src/enums/system/menu-enum.ts +++ b/src/enums/system/menu-enum.ts @@ -1,7 +1,6 @@ // 核心枚举定义 export enum MenuTypeEnum { - CATALOG = 2, // 目录 - MENU = 1, // 菜单 - BUTTON = 4, // 按钮 - EXTLINK = 3, // 外链 + CATALOG = "C", // 目录 + MENU = "M", // 菜单 + BUTTON = "B", // 按钮 } diff --git a/src/layouts/components/NavBar/components/NavbarActions.vue b/src/layouts/components/NavBar/components/NavbarActions.vue index bcd27d1b..890aedbd 100644 --- a/src/layouts/components/NavBar/components/NavbarActions.vue +++ b/src/layouts/components/NavBar/components/NavbarActions.vue @@ -26,6 +26,11 @@ + + + @@ -80,11 +85,14 @@ import Fullscreen from "@/components/Fullscreen/index.vue"; import SizeSelect from "@/components/SizeSelect/index.vue"; import LangSelect from "@/components/LangSelect/index.vue"; import Notification from "@/components/Notification/index.vue"; +import TenantSelect from "@/components/TenantSelect/index.vue"; +import { useTenantStoreHook } from "@/store/modules/tenant-store"; const { t } = useI18n(); const appStore = useAppStore(); const settingStore = useSettingsStore(); const userStore = useUserStore(); +const tenantStore = useTenantStoreHook(); const route = useRoute(); const router = useRouter(); @@ -92,6 +100,21 @@ const router = useRouter(); // 是否为桌面设备 const isDesktop = computed(() => appStore.device === DeviceEnum.DESKTOP); +// 是否显示租户选择(如果用户有多个租户,则显示租户选择器) +// 最小侵入:只有在多租户模式下(租户列表长度 > 1)才显示 +const showTenantSelect = computed(() => { + // 如果租户列表为空,不显示 + if (tenantStore.tenantList.length === 0) { + return false; + } + // 如果只有一个租户,也不显示(单租户模式,用户无感知) + if (tenantStore.tenantList.length === 1) { + return false; + } + // 多个租户时才显示 + return true; +}); + /** * 打开个人中心页面 */ @@ -244,6 +267,16 @@ function handleSettingsClick() { .user-profile__name { color: rgba(255, 255, 255, 0.85); } + + // 租户选择器在白色文字模式下的样式 + :deep(.tenant-select) { + color: rgba(255, 255, 255, 0.85); + + &:hover { + color: #fff; + background: rgba(255, 255, 255, 0.1); + } + } } // 深色文字样式(用于浅色背景:明亮主题下的左侧布局) @@ -265,6 +298,16 @@ function handleSettingsClick() { .user-profile__name { color: var(--el-text-color-regular) !important; } + + // 租户选择器在深色文字模式下的样式 + :deep(.tenant-select) { + color: var(--el-text-color-regular) !important; + + &:hover { + color: var(--el-color-primary) !important; + background: rgba(0, 0, 0, 0.04); + } + } } // 确保下拉菜单中的图标不受影响 diff --git a/src/plugins/permission.ts b/src/plugins/permission.ts index c81379ff..fa9ad4f1 100644 --- a/src/plugins/permission.ts +++ b/src/plugins/permission.ts @@ -2,6 +2,7 @@ import type { RouteRecordRaw } from "vue-router"; import NProgress from "@/utils/nprogress"; import router from "@/router"; import { usePermissionStore, useUserStore } from "@/store"; +import { useTenantStoreHook } from "@/store/modules/tenant-store"; export function setupPermission() { const whiteList = ["/login"]; @@ -38,6 +39,12 @@ export function setupPermission() { await userStore.getUserInfo(); } + // 登录成功后,尝试获取租户列表和当前租户信息(如果启用多租户) + // 最小侵入:如果接口失败,不影响正常流程(可能是单租户模式) + const tenantStore = useTenantStoreHook(); + // 由 tenantStore 内部自行判断前端多租户开关(VITE_APP_TENANT_ENABLED) + await tenantStore.prepareTenantContextAfterLogin(); + const dynamicRoutes = await permissionStore.generateRoutes(); dynamicRoutes.forEach((route: RouteRecordRaw) => { router.addRoute(route); diff --git a/src/store/index.ts b/src/store/index.ts index 9e6d43bf..d401ad65 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -14,4 +14,5 @@ export * from "./modules/settings-store"; export * from "./modules/tags-view-store"; export * from "./modules/user-store"; export * from "./modules/dict-store"; +export * from "./modules/tenant-store"; export { store }; diff --git a/src/store/modules/tenant-store.ts b/src/store/modules/tenant-store.ts new file mode 100644 index 00000000..a8a69db2 --- /dev/null +++ b/src/store/modules/tenant-store.ts @@ -0,0 +1,172 @@ +import { store } from "@/store"; +import TenantAPI, { type TenantInfo } from "@/api/system/tenant-api"; + +// 前端多租户开关;默认开启,若后端未启用多租户可在 .env 设置 VITE_APP_TENANT_ENABLED=false +const TENANT_ENABLED = import.meta.env.VITE_APP_TENANT_ENABLED !== "false"; + +const TENANT_ID_KEY = "current_tenant_id"; +const TENANT_INFO_KEY = "current_tenant_info"; + +/** + * 租户 Store + */ +export const useTenantStore = defineStore("tenant", () => { + // 当前租户ID + const currentTenantId = ref(null); + // 当前租户信息 + const currentTenant = ref(null); + // 用户可访问的租户列表 + const tenantList = ref([]); + + /** + * 初始化租户信息 + * 从 localStorage 恢复上次使用的租户 + */ + function initTenant() { + const savedTenantId = localStorage.getItem(TENANT_ID_KEY); + const savedTenantInfo = localStorage.getItem(TENANT_INFO_KEY); + + if (savedTenantId) { + currentTenantId.value = Number(savedTenantId); + } + + if (savedTenantInfo) { + try { + currentTenant.value = JSON.parse(savedTenantInfo); + } catch (e) { + console.error("解析租户信息失败", e); + } + } + } + + /** + * 获取用户租户列表 + */ + function fetchTenantList() { + return new Promise((resolve, reject) => { + TenantAPI.getTenantList() + .then((data) => { + tenantList.value = data || []; + resolve(data || []); + }) + .catch((error) => { + reject(error); + }); + }); + } + + /** + * 登录后初始化租户:获取列表并尽量确定当前租户 + * - 忽略错误,以便单租户模式不受影响 + */ + // 登录后准备租户上下文:先取租户列表,再用后端返回的当前租户;若单租户则自动选中 + async function prepareTenantContextAfterLogin() { + if (!TENANT_ENABLED) { + return; + } + + try { + await fetchTenantList(); + + if (tenantList.value.length > 0 && !currentTenantId.value) { + try { + const currentTenantInfo = await TenantAPI.getCurrentTenant(); + if (currentTenantInfo) { + setCurrentTenant(currentTenantInfo); + } else if (tenantList.value.length === 1) { + setCurrentTenant(tenantList.value[0]); + } + } catch (error) { + if (tenantList.value.length === 1) { + setCurrentTenant(tenantList.value[0]); + } + console.debug("获取当前租户信息失败(可能是单租户模式):", error); + } + } + } catch (error) { + console.debug("获取租户列表失败(可能是单租户模式):", error); + } + } + + /** + * 设置当前租户 + * + * @param tenant 租户信息 + */ + function setCurrentTenant(tenant: TenantInfo) { + currentTenantId.value = tenant.id; + currentTenant.value = tenant; + + // 保存到 localStorage + localStorage.setItem(TENANT_ID_KEY, String(tenant.id)); + localStorage.setItem(TENANT_INFO_KEY, JSON.stringify(tenant)); + } + + /** + * 切换租户 + * + * @param tenantId 目标租户ID + */ + function switchTenant(tenantId: number) { + return new Promise((resolve, reject) => { + TenantAPI.switchTenant(tenantId) + .then((tenantInfo) => { + // 后端返回切换后的租户信息 + if (tenantInfo) { + setCurrentTenant(tenantInfo); + } else { + // 如果后端未返回,从租户列表中找到对应的租户信息 + const tenant = tenantList.value.find((t) => t.id === tenantId); + if (tenant) { + setCurrentTenant(tenant); + } else { + // 如果列表中没有,重新获取租户信息 + TenantAPI.getCurrentTenant() + .then((info) => { + if (info) { + setCurrentTenant(info); + } + }) + .catch(console.error); + } + } + resolve(); + }) + .catch((error) => { + reject(error); + }); + }); + } + + /** + * 清除租户信息 + */ + function clearTenant() { + currentTenantId.value = null; + currentTenant.value = null; + tenantList.value = []; + localStorage.removeItem(TENANT_ID_KEY); + localStorage.removeItem(TENANT_INFO_KEY); + } + + // 初始化 + initTenant(); + + return { + currentTenantId, + currentTenant, + tenantList, + prepareTenantContextAfterLogin, + fetchTenantList, + setCurrentTenant, + switchTenant, + clearTenant, + }; +}); + +/** + * 在组件外部使用 TenantStore 的钩子函数 + */ +export function useTenantStoreHook() { + return useTenantStore(store); +} diff --git a/src/store/modules/user-store.ts b/src/store/modules/user-store.ts index 410cfffa..2922f456 100644 --- a/src/store/modules/user-store.ts +++ b/src/store/modules/user-store.ts @@ -1,6 +1,6 @@ import { store } from "@/store"; -import AuthAPI, { type LoginFormData } from "@/api/auth-api"; +import AuthAPI, { type LoginRequest } from "@/api/auth-api"; import UserAPI, { type UserInfo } from "@/api/system/user-api"; import { AuthStorage } from "@/utils/auth"; @@ -18,16 +18,16 @@ export const useUserStore = defineStore("user", () => { /** * 登录 * - * @param {LoginFormData} + * @param {LoginRequest} * @returns */ - function login(LoginFormData: LoginFormData) { + function login(loginRequest: LoginRequest) { return new Promise((resolve, reject) => { - AuthAPI.login(LoginFormData) + AuthAPI.login(loginRequest) .then((data) => { const { accessToken, refreshToken } = data; // 保存记住我状态和token - rememberMe.value = LoginFormData.rememberMe; + rememberMe.value = loginRequest.rememberMe ?? false; AuthStorage.setTokens(accessToken, refreshToken, rememberMe.value); resolve(); }) diff --git a/src/utils/request.ts b/src/utils/request.ts index 6da85474..ba95cb0e 100644 --- a/src/utils/request.ts +++ b/src/utils/request.ts @@ -4,6 +4,7 @@ import { ApiCodeEnum } from "@/enums/api/code-enum"; import { AuthStorage, redirectToLogin } from "@/utils/auth"; import { useTokenRefresh } from "@/composables/auth/useTokenRefresh"; import { authConfig } from "@/settings"; +import { useTenantStoreHook } from "@/store/modules/tenant-store"; // 初始化token刷新组合式函数 const { refreshTokenAndRetry } = useTokenRefresh(); @@ -19,7 +20,7 @@ const httpRequest = axios.create({ }); /** - * 请求拦截器 - 添加 Authorization 头 + * 请求拦截器 - 添加 Authorization 头和租户ID */ httpRequest.interceptors.request.use( (config: InternalAxiosRequestConfig) => { @@ -32,6 +33,19 @@ httpRequest.interceptors.request.use( delete config.headers.Authorization; } + // 添加租户ID到请求头(如果存在) + // 注意:只有在登录成功后,tenantStore 才会初始化,所以这里需要 try-catch + try { + const tenantStore = useTenantStoreHook(); + const tenantId = tenantStore.currentTenantId; + if (tenantId) { + config.headers["tenant-id"] = String(tenantId); + } + } catch (error) { + // 如果租户 store 未初始化(如登录前),忽略错误 + // 这是正常的,因为多租户功能是可选的,未启用时不会初始化 tenantStore + } + return config; }, (error) => { @@ -57,6 +71,15 @@ httpRequest.interceptors.response.use( return data; } + // 特殊处理:需要选择租户(不显示错误提示,返回特殊对象供业务层处理) + if (code === ApiCodeEnum.CHOOSE_TENANT) { + return Promise.reject({ + code: ApiCodeEnum.CHOOSE_TENANT, + data, + msg, + }); + } + // 业务错误 ElMessage.error(msg || "系统出错"); return Promise.reject(new Error(msg || "Business Error")); diff --git a/src/views/ai/command-record/index.vue b/src/views/ai/command-record/index.vue index e840c7e4..199bbac6 100644 --- a/src/views/ai/command-record/index.vue +++ b/src/views/ai/command-record/index.vue @@ -13,9 +13,9 @@ /> - + - + - + - - + + @@ -56,21 +56,9 @@ clearable style="width: 140px" > - - - - - - - - - - + + + @@ -117,29 +105,27 @@ min-width="160" show-overflow-tooltip /> - - + + - - - - - +