refactor: 项目结构优化调整

This commit is contained in:
Ray.Hao
2025-12-20 21:56:48 +08:00
parent 5851976c5d
commit 65ad4fe59f
68 changed files with 2463 additions and 1761 deletions

View File

@@ -50,11 +50,9 @@
<a target="_blank" href="https://vue.youlai.tech">🖥️ 在线预览</a> | <a target="_blank" href="https://app.youlai.tech">📲 移动端预览</a> | <a target="_blank" href="https://juejin.cn/post/7228990409909108793">📑 阅读文档</a>| <a target="_blank" href="https://www.youlai.tech//vue3-element-admin">🌐 官网</a> | <a href="./README.en-US.md">💬 English
</div>
## 项目简介
[vue3-element-admin](https://gitcode.com/youlai/vue3-element-admin) 基于 Vue3、Vite7、TypeScript 和 Element-Plus 搭建的极简开箱即用企业级后台管理前端模板。 配套 Java 后端 [youlai-boot](https://gitee.com/youlaiorg/youlai-boot) 和 Node 后端 [youlai-nest](https://gitee.com/youlaiorg/youlai-nest) 。 提供开发简版[vue3-element-template](https://gitee.com/youlaiorg/vue3-element-template) 和 JS 版本[vue3-element-admin-js](https://gitee.com/youlaiorg/vue3-element-admin) 供开发者快速开发。
[vue3-element-admin](https://gitcode.com/youlai/vue3-element-admin) 基于 Vue3、Vite7、TypeScript 和 Element-Plus 搭建的极简开箱即用企业级后台管理前端模板。 配套 Java 后端 [youlai-boot](https://gitee.com/youlaiorg/youlai-boot)、多租户后端 [youlai-boot-tenant](https://gitee.com/youlaiorg/youlai-boot-tenant) 和 Node 后端 [youlai-nest](https://gitee.com/youlaiorg/youlai-nest) 。 提供开发简版[vue3-element-template](https://gitee.com/youlaiorg/vue3-element-template) 和 JS 版本[vue3-element-admin-js](https://gitee.com/youlaiorg/vue3-element-admin) 供开发者快速开发。
## 项目特色
@@ -64,10 +62,11 @@
- **系统功能:** 提供用户管理、角色管理、菜单管理、部门管理、字典管理、系统配置、通知公告等功能模块。
- **权限管理:** 支持动态路由、按钮权限、角色权限和数据权限等多种权限管理方式。
- **多租户:** 支持多租户模式与租户隔离。
- **基础设施:** 提供国际化、多布局、暗黑模式、全屏、水印、接口文档和代码生成器等功能。
- **持续更新**:项目持续开源更新,实时更新工具和依赖。
## 项目截图
🖥️ **控制台**
@@ -84,20 +83,20 @@
## 项目源码
| 项目 | Gitee | Github | GitCode|
| ---- | ----| ---- | ---- |
| vue3-element-admin ✅| [vue3-element-admin](https://gitee.com/youlaiorg/vue3-element-admin) | [vue3-element-admin](https://github.com/youlaitech/vue3-element-admin) | [vue3-element-admin](https://gitcode.com/youlai/vue3-element-admin) |
| vue3-element-admin JS版| [vue3-element-admin-js](https://gitee.com/youlaiorg/vue3-element-admin-js) | [vue3-element-admin-js](https://github.com/youlaitech/vue3-element-admin-js) | [vue3-element-admin-js](https://gitcode.com/youlai/vue3-element-admin-js) |
| vue3-element-admin 精简版 | [vue3-element-template](https://gitee.com/youlaiorg/vue3-element-template) | [vue3-element-template](https://github.com/youlaitech/vue3-element-template) |[vue3-element-template](https://gitcode.com/youlai/vue3-element-template)|
| vue-uniapp-admin 移动版 | [vue-uniapp-admin](https://gitee.com/youlaiorg/vue-uniapp-admin) | [vue-uniapp-admin](https://github.com/youlaitech/vue-uniapp-admin) |[vue-uniapp-admin](https://gitcode.com/youlai/vue-uniapp-admin)|
| Java 后端 | [youlai-boot](https://gitee.com/youlaiorg/youlai-boot) | [youlai-boot](https://github.com/haoxianrui/youlai-boot.git) |[youlai-boot](https://gitcode.com/youlai/youlai-boot.git)|
| Node 后端 | [youlai-nest](https://gitee.com/youlaiorg/youlai-nest) | [youlai-nest](https://github.com/haoxianrui/youlai-nest.git) |[youlai-nest](https://gitcode.com/youlai/youlai-nest.git)|
| 项目 | Gitee | Github | GitCode |
| ------------------------- | -------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | ------------------------------------------------------------------------- |
| vue3-element-admin ✅ | [vue3-element-admin](https://gitee.com/youlaiorg/vue3-element-admin) | [vue3-element-admin](https://github.com/youlaitech/vue3-element-admin) | [vue3-element-admin](https://gitcode.com/youlai/vue3-element-admin) |
| vue3-element-admin JS版 | [vue3-element-admin-js](https://gitee.com/youlaiorg/vue3-element-admin-js) | [vue3-element-admin-js](https://github.com/youlaitech/vue3-element-admin-js) | [vue3-element-admin-js](https://gitcode.com/youlai/vue3-element-admin-js) |
| vue3-element-admin 精简版 | [vue3-element-template](https://gitee.com/youlaiorg/vue3-element-template) | [vue3-element-template](https://github.com/youlaitech/vue3-element-template) | [vue3-element-template](https://gitcode.com/youlai/vue3-element-template) |
| vue-uniapp-admin 移动版 | [vue-uniapp-admin](https://gitee.com/youlaiorg/vue-uniapp-admin) | [vue-uniapp-admin](https://github.com/youlaitech/vue-uniapp-admin) | [vue-uniapp-admin](https://gitcode.com/youlai/vue-uniapp-admin) |
| Java 后端 | [youlai-boot](https://gitee.com/youlaiorg/youlai-boot) | [youlai-boot](https://github.com/haoxianrui/youlai-boot.git) | [youlai-boot](https://gitcode.com/youlai/youlai-boot.git) |
| Java 多租户后端 | [youlai-boot-tenant](https://gitee.com/youlaiorg/youlai-boot-tenant) | - | - |
| Node 后端 | [youlai-nest](https://gitee.com/youlaiorg/youlai-nest) | [youlai-nest](https://github.com/haoxianrui/youlai-nest.git) | [youlai-nest](https://gitcode.com/youlai/youlai-nest.git) |
## 开发指南
| 名称 | 地址 |
|---------------|--------------------|
| -------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| 视频教程 | [https://www.bilibili.com/video/BV1eFUuYyEFj](https://www.bilibili.com/video/BV1eFUuYyEFj) |
| 项目搭建 | [基于 Vue3 + Vite + TypeScript + Element-Plus 从0到1搭建后台管理系统](https://blog.csdn.net/u013737132/article/details/130191394) |
| 官方文档 | [https://www.youlai.tech/vue3-element-admin](https://www.youlai.tech/vue3-element-admin/) |
@@ -105,19 +104,16 @@
| 提交规范 | [Husky + Lint-staged + Commitlint + Commitizen + cz-git 配置 Git 提交规范](https://youlai.blog.csdn.net/article/details/145615236) |
| 接口文档 | [https://www.apifox.cn](https://www.apifox.cn/apidoc/shared-195e783f-4d85-4235-a038-eec696de4ea5) |
## 项目启动
- **环境准备**
| 环境类型 | 版本要求 | 备注 |
|---------|---------|------|
| ------------ | ------------------------------------------------------------ | --------------------------------- |
| **Node.js** | `^20.19.0``>=22.12.0` | 推荐使用 LTS 版本(主版本为偶数) |
| **包管理器** | `pnpm >= 8.0.0` | 项目使用 pnpm 作为包管理器 |
| **开发工具** | [Visual Studio Code](https://code.visualstudio.com/Download) | 推荐安装 Vue、TypeScript 相关插件 |
- **快速开始**
```bash
@@ -140,7 +136,6 @@ pnpm install
pnpm run dev
```
## 项目部署
执行 `pnpm run build` 命令后,项目将被打包并生成 `dist` 目录。接下来,将 `dist` 目录下的文件上传到服务器 `/usr/share/nginx/html` 目录下,并配置 Nginx 进行反向代理。
@@ -183,7 +178,6 @@ server {
2. 根据后端工程的说明文档 [README.md](https://gitee.com/youlaiorg/youlai-boot#%E9%A1%B9%E7%9B%AE%E8%BF%90%E8%A1%8C) 完成本地启动。
3. 修改 `.env.development` 文件中的 `VITE_APP_API_URL` 的值,将其从 https://api.youlai.tech 更改为 http://localhost:8989 即可。
## 注意事项
- **自动导入插件自动生成默认关闭**
@@ -208,25 +202,21 @@ server {
如果有其他问题或者建议,建议 [ISSUE](https://gitee.com/youlaiorg/vue3-element-admin/issues/new)
## 提交规范
执行 `pnpm run commit` 唤起 git commit 交互,根据提示完成信息的输入和选择。
![](https://foruda.gitee.com/images/1687755823165218215/c1705416_716974.png)
## 项目统计
![](https://repobeats.axiom.co/api/embed/aa7cca3d6fa9c308fc659fa6e09af9a1910506c3.svg "Repobeats analytics image")
Thanks to all the contributors!
感谢所有的贡献者!
[![contributors](https://contrib.rocks/image?repo=youlaitech/vue3-element-admin)](https://github.com/youlaitech/vue3-element-admin/graphs/contributors)
## 特别感谢
- 感谢 [GitCode](https://gitcode.com/) 官方的 [G-Star](https://gitcode.com/g-star) 认证

118
mock/ai.mock.ts Normal file
View File

@@ -0,0 +1,118 @@
import { defineMock } from "./base";
export default defineMock([
{
url: "ai/assistant/parse",
method: ["POST"],
body: ({ body }) => {
return {
code: "00000",
data: {
parseLogId: "10001",
success: true,
functionCalls: [
{
name: "navigate",
arguments: {
path: "/system/user",
},
},
],
explanation: `Mock: 已解析命令:${body?.command ?? ""}`,
confidence: 0.92,
},
msg: "一切ok",
};
},
},
{
url: "ai/assistant/execute",
method: ["POST"],
body: {
code: "00000",
data: {
success: true,
message: "Mock: 执行成功",
},
msg: "一切ok",
},
},
{
url: "ai/assistant/records",
method: ["GET"],
body: ({ query }) => {
const pageNum = Number(query?.pageNum ?? 1);
const pageSize = Number(query?.pageSize ?? 10);
const total = 2;
return {
code: "00000",
data: {
list: [
{
id: "10001",
userId: 1,
username: "admin",
originalCommand: "跳转到用户管理",
aiProvider: "qwen",
aiModel: "qwen-plus",
parseStatus: 1,
functionCalls: JSON.stringify(
[
{
name: "navigate",
arguments: { path: "/system/user" },
},
],
null,
0
),
explanation: "Mock: 识别到跳转用户管理",
confidence: 0.92,
parseDurationMs: 128,
functionName: "navigate",
functionArguments: JSON.stringify({ path: "/system/user" }),
executeStatus: 1,
ipAddress: "127.0.0.1",
createTime: "2025-12-17 15:00:00",
updateTime: "2025-12-17 15:00:00",
},
{
id: "10002",
userId: 1,
username: "admin",
originalCommand: "获取姓名为张三的用户信息",
aiProvider: "qwen",
aiModel: "qwen-plus",
parseStatus: 0,
functionCalls: "[]",
explanation: "Mock: 解析失败示例",
confidence: 0.2,
parseErrorMessage: "Mock: 无法匹配函数",
parseDurationMs: 256,
executeStatus: 0,
ipAddress: "127.0.0.1",
createTime: "2025-12-17 15:01:00",
updateTime: "2025-12-17 15:01:00",
},
].slice((pageNum - 1) * pageSize, pageNum * pageSize),
total,
},
msg: "一切ok",
};
},
},
{
url: "ai/assistant/records/:ids",
method: ["DELETE"],
body: ({ params }) => {
return {
code: "00000",
data: {
ids: params?.ids,
},
msg: "一切ok",
};
},
},
]);

View File

@@ -164,44 +164,4 @@ export default defineMock([
msg: "一切ok",
},
},
{
url: "logs/visit-trend",
method: ["GET"],
body: {
code: "00000",
data: {
dates: [
"2024-06-30",
"2024-07-01",
"2024-07-02",
"2024-07-03",
"2024-07-04",
"2024-07-05",
"2024-07-06",
"2024-07-07",
],
pvList: [1751, 5168, 4882, 5301, 4721, 4885, 1901, 1003],
uvList: null,
ipList: [207, 566, 565, 631, 579, 496, 222, 152],
},
msg: "一切ok",
},
},
{
url: "logs/visit-stats",
method: ["GET"],
body: {
code: "00000",
data: {
todayPvCount: 1629,
totalPvCount: 286086,
pvGrowthRate: -0.65,
todayIpCount: 169,
totalIpCount: 19985,
ipGrowthRate: -0.57,
},
msg: "一切ok",
},
},
]);

43
mock/statistics.mock.ts Normal file
View File

@@ -0,0 +1,43 @@
import { defineMock } from "./base";
export default defineMock([
{
url: "statistics/visits/trend",
method: ["GET"],
body: {
code: "00000",
data: {
dates: [
"2024-06-30",
"2024-07-01",
"2024-07-02",
"2024-07-03",
"2024-07-04",
"2024-07-05",
"2024-07-06",
"2024-07-07",
],
pvList: [1751, 5168, 4882, 5301, 4721, 4885, 1901, 1003],
uvList: null,
ipList: [207, 566, 565, 631, 579, 496, 222, 152],
},
msg: "一切ok",
},
},
{
url: "statistics/visits/overview",
method: ["GET"],
body: {
code: "00000",
data: {
todayUvCount: 169,
totalUvCount: 19985,
uvGrowthRate: -0.57,
todayPvCount: 1629,
totalPvCount: 286086,
pvGrowthRate: -0.65,
},
msg: "一切ok",
},
},
]);

65
mock/tenant.mock.ts Normal file
View File

@@ -0,0 +1,65 @@
import { defineMock } from "./base";
export default defineMock([
{
url: "tenants",
method: ["GET"],
body: {
code: "00000",
data: [
{
id: 1,
name: "默认租户",
domain: "default",
},
{
id: 2,
name: "演示租户",
domain: "demo",
},
],
msg: "一切ok",
},
},
{
url: "tenants/current",
method: ["GET"],
body: {
code: "00000",
data: {
id: 1,
name: "默认租户",
domain: "default",
},
msg: "一切ok",
},
},
{
url: "tenants/:tenantId/switch",
method: ["POST"],
body({ params }) {
const tenantId = Number(params.tenantId);
const allTenants = [
{
id: 1,
name: "默认租户",
domain: "default",
},
{
id: 2,
name: "演示租户",
domain: "demo",
},
];
const tenant = allTenants.find((t) => t.id === tenantId) || null;
return {
code: tenant ? "00000" : "A0400",
data: tenant,
msg: tenant ? "切换租户成功" : "租户不存在",
};
},
},
]);

View File

@@ -1,180 +1,73 @@
import request from "@/utils/request";
import type {
AiCommandRequest,
AiCommandResponse,
AiExecuteRequest,
AiExecuteResponse,
AiCommandRecordPageQuery,
AiCommandRecordVo,
} from "@/types/api";
/**
* AI 命令请求参数
*/
export interface AiCommandRequest {
/** 用户输入的自然语言命令 */
command: string;
/** 当前页面路由(用于上下文) */
currentRoute?: string;
/** 当前激活的组件名称 */
currentComponent?: string;
/** 额外上下文信息 */
context?: Record<string, any>;
}
/**
* 函数调用参数
*/
export interface FunctionCall {
/** 函数名称 */
name: string;
/** 函数描述 */
description?: string;
/** 参数对象 */
arguments: Record<string, any>;
}
/**
* AI 命令解析响应
*/
export interface AiCommandResponse {
/** 解析日志ID用于关联执行记录 */
parseLogId?: string;
/** 是否成功解析 */
success: boolean;
/** 解析后的函数调用列表 */
functionCalls: FunctionCall[];
/** AI 的理解和说明 */
explanation?: string;
/** 置信度 (0-1) */
confidence?: number;
/** 错误信息 */
error?: string;
/** 原始 LLM 响应(用于调试) */
rawResponse?: string;
}
/**
* AI 命令执行请求
*/
export interface AiExecuteRequest {
/** 关联的解析日志ID */
parseLogId?: string;
/** 原始命令(用于审计) */
originalCommand?: string;
/** 要执行的函数调用 */
functionCall: FunctionCall;
/** 确认模式auto=自动执行, manual=需要用户确认 */
confirmMode?: "auto" | "manual";
/** 用户确认标志 */
userConfirmed?: boolean;
/** 幂等性令牌(防止重复执行) */
idempotencyKey?: string;
/** 当前页面路由 */
currentRoute?: string;
}
/**
* AI 命令执行响应
*/
export interface AiExecuteResponse {
/** 是否执行成功 */
success: boolean;
/** 执行结果数据 */
data?: any;
/** 执行结果说明 */
message?: string;
/** 影响的记录数 */
affectedRows?: number;
/** 错误信息 */
error?: string;
/** 记录ID用于追踪 */
recordId?: string;
/** 需要用户确认 */
requiresConfirmation?: boolean;
/** 确认提示信息 */
confirmationPrompt?: string;
}
export interface AiCommandRecordPageQuery extends PageQuery {
keywords?: string;
executeStatus?: number;
parseStatus?: number;
userId?: number;
aiProvider?: string;
aiModel?: string;
functionName?: string;
createTime?: [string, string];
}
export interface AiCommandRecordVO {
id: string;
userId: number;
username: string;
originalCommand: string;
aiProvider?: string;
aiModel?: string;
parseStatus?: number;
functionCalls?: string;
explanation?: string;
confidence?: number;
parseErrorMessage?: string;
inputTokens?: number;
outputTokens?: number;
parseDurationMs?: number;
functionName?: string;
functionArguments?: string;
executeStatus?: number;
executeErrorMessage?: string;
ipAddress?: string;
createTime?: string;
updateTime?: string;
}
const AI_BASE_URL = "/api/v1/ai/assistant";
/**
* AI 命令 API
*/
class AiCommandApi {
const AiCommandApi = {
/**
* 解析自然语言命令
* 解析 AI 命令
*
* @param data 命令请求参数
* @returns 解析结果
* @param data AI 命令请求参数
* @returns AI 命令解析响应
*/
static parseCommand(data: AiCommandRequest): Promise<AiCommandResponse> {
parseCommand(data: AiCommandRequest) {
return request<any, AiCommandResponse>({
url: "/api/v1/ai/command/parse",
url: `${AI_BASE_URL}/parse`,
method: "post",
data,
});
}
},
/**
* 执行已解析的命令
* 执行 AI 命令
*
* @param data 执行请求参数
* @returns 执行结果数据(成功时返回,失败时抛出异常)
* @param data AI 命令执行请求
* @returns AI 命令执行响应
*/
static executeCommand(data: AiExecuteRequest): Promise<any> {
return request<any, any>({
url: "/api/v1/ai/command/execute",
executeCommand(data: AiExecuteRequest) {
return request<any, AiExecuteResponse>({
url: `${AI_BASE_URL}/execute`,
method: "post",
data,
});
}
},
/**
* 获取命令记录分页列表
* 获取 AI 命令记录分页列表
*
* @param queryParams 查询参数
* @returns AI 命令记录分页列表
*/
static getCommandRecordPage(queryParams: AiCommandRecordPageQuery) {
return request<any, PageResult<AiCommandRecordVO[]>>({
url: "/api/v1/ai/command/records",
getPage(queryParams: AiCommandRecordPageQuery) {
return request<any, PageResult<AiCommandRecordVo[]>>({
url: `${AI_BASE_URL}/records`,
method: "get",
params: queryParams,
});
}
},
/**
* 撤销命令执行(如果支持)
* 删除 AI 命令记录
*
* @param ids 记录ID多个以逗号分隔
* @returns 删除结果
*/
static rollbackCommand(logId: string) {
deleteByIds(ids: string) {
return request({
url: `/api/v1/ai/command/rollback/${logId}`,
method: "post",
url: `${AI_BASE_URL}/records/${ids}`,
method: "delete",
});
}
}
},
};
export default AiCommandApi;

View File

@@ -1,11 +1,12 @@
import request from "@/utils/request";
import type { GeneratorPreviewVo, TablePageQuery, TablePageVo, GenConfigForm } from "@/api/types";
const GENERATOR_BASE_URL = "/api/v1/codegen";
const GeneratorAPI = {
/** 获取数据表分页列表 */
getTablePage(params: TablePageQuery) {
return request<any, PageResult<TablePageVO[]>>({
return request<any, PageResult<TablePageVo[]>>({
url: `${GENERATOR_BASE_URL}/table/page`,
method: "get",
params,
@@ -31,7 +32,7 @@ const GeneratorAPI = {
/** 获取代码生成预览数据 */
getPreviewData(tableName: string, pageType?: "classic" | "curd") {
return request<any, GeneratorPreviewVO[]>({
return request<any, GeneratorPreviewVo[]>({
url: `${GENERATOR_BASE_URL}/${tableName}/preview`,
method: "get",
params: pageType ? { pageType } : undefined,
@@ -58,15 +59,26 @@ const GeneratorAPI = {
params: pageType ? { pageType } : undefined,
responseType: "blob",
}).then((response) => {
const fileName = decodeURI(
response.headers["content-disposition"].split(";")[1].split("=")[1]
);
const contentDisposition = response?.headers?.["content-disposition"] as string | undefined;
let fileName = `${tableName}.zip`;
if (contentDisposition) {
// content-disposition: attachment; filename=xxx.zip
const match = /filename\*?=(?:UTF-8''|")?([^;"]+)/i.exec(contentDisposition);
if (match?.[1]) {
try {
fileName = decodeURIComponent(match[1]);
} catch {
fileName = match[1];
}
}
}
const blob = new Blob([response.data], { type: "application/zip" });
const a = document.createElement("a");
const url = window.URL.createObjectURL(blob);
a.href = url;
a.download = fileName;
a.click();
window.URL.revokeObjectURL(url);
});
@@ -76,124 +88,3 @@ const GeneratorAPI = {
export default GeneratorAPI;
/** 代码生成预览对象 */
export interface GeneratorPreviewVO {
/** 文件生成路径 */
path: string;
/** 文件名称 */
fileName: string;
/** 文件内容 */
content: string;
}
/** 数据表分页查询参数 */
export interface TablePageQuery extends PageQuery {
/** 关键字(表名) */
keywords?: string;
}
/** 数据表分页对象 */
export interface TablePageVO {
/** 表名称 */
tableName: string;
/** 表描述 */
tableComment: string;
/** 存储引擎 */
engine: string;
/** 字符集排序规则 */
tableCollation: string;
/** 创建时间 */
createTime: string;
}
/** 代码生成配置表单 */
export interface GenConfigForm {
/** 主键 */
id?: string;
/** 表名 */
tableName?: string;
/** 业务名 */
businessName?: string;
/** 模块名 */
moduleName?: string;
/** 包名 */
packageName?: string;
/** 实体名 */
entityName?: string;
/** 作者 */
author?: string;
/** 上级菜单 */
parentMenuId?: string;
/** 后端应用名 */
backendAppName?: string;
/** 前端应用名 */
frontendAppName?: string;
/** 字段配置列表 */
fieldConfigs?: FieldConfig[];
/** 页面类型 classic|curd */
pageType?: "classic" | "curd";
/** 要移除的表前缀,如 sys_ */
removeTablePrefix?: string;
}
/** 字段配置 */
export interface FieldConfig {
/** 主键 */
id?: string;
/** 列名 */
columnName?: string;
/** 列类型 */
columnType?: string;
/** 字段名 */
fieldName?: string;
/** 字段类型 */
fieldType?: string;
/** 字段描述 */
fieldComment?: string;
/** 是否在列表显示 */
isShowInList?: number;
/** 是否在表单显示 */
isShowInForm?: number;
/** 是否在查询条件显示 */
isShowInQuery?: number;
/** 是否必填 */
isRequired?: number;
/** 表单类型 */
formType?: number;
/** 查询类型 */
queryType?: number;
/** 字段长度 */
maxLength?: number;
/** 字段排序 */
fieldSort?: number;
/** 字典类型 */
dictType?: string;
}

View File

@@ -1,4 +1,5 @@
import request from "@/utils/request";
import type { FileInfo } from "@/types/api";
const FileAPI = {
/** 上传文件 (传入 FormData上传进度回调 */
@@ -57,8 +58,3 @@ const FileAPI = {
};
export default FileAPI;
export interface FileInfo {
name: string;
url: string;
}

View File

@@ -1,11 +1,12 @@
import request from "@/utils/request";
import type { ConfigPageQuery, ConfigForm, ConfigPageVo } from "@/types/api";
const CONFIG_BASE_URL = "/api/v1/configs";
const ConfigAPI = {
/** 获取配置分页数据 */
getPage(queryParams?: ConfigPageQuery) {
return request<any, PageResult<ConfigPageVO[]>>({
return request<any, PageResult<ConfigPageVo[]>>({
url: `${CONFIG_BASE_URL}/page`,
method: "get",
params: queryParams,
@@ -37,34 +38,3 @@ const ConfigAPI = {
};
export default ConfigAPI;
export interface ConfigPageQuery extends PageQuery {
/** 搜索关键字 */
keywords?: string;
}
export interface ConfigForm {
/** 主键 */
id?: string;
/** 配置名称 */
configName?: string;
/** 配置键 */
configKey?: string;
/** 配置值 */
configValue?: string;
/** 描述、备注 */
remark?: string;
}
export interface ConfigPageVO {
/** 主键 */
id?: string;
/** 配置名称 */
configName?: string;
/** 配置键 */
configKey?: string;
/** 配置值 */
configValue?: string;
/** 描述、备注 */
remark?: string;
}

View File

@@ -1,11 +1,12 @@
import request from "@/utils/request";
import type { DeptQuery, DeptVo, DeptForm } from "@/types/api";
const DEPT_BASE_URL = "/api/v1/depts";
const DeptAPI = {
/** 获取部门树形列表 */
getList(queryParams?: DeptQuery) {
return request<any, DeptVO[]>({ url: `${DEPT_BASE_URL}`, method: "get", params: queryParams });
return request<any, DeptVo[]>({ url: `${DEPT_BASE_URL}`, method: "get", params: queryParams });
},
/** 获取部门下拉数据源 */
getOptions() {
@@ -30,46 +31,3 @@ const DeptAPI = {
};
export default DeptAPI;
export interface DeptQuery {
/** 搜索关键字 */
keywords?: string;
/** 状态 */
status?: number;
}
export interface DeptVO {
/** 子部门 */
children?: DeptVO[];
/** 创建时间 */
createTime?: Date;
/** 部门ID */
id?: string;
/** 部门名称 */
name?: string;
/** 部门编号 */
code?: string;
/** 父部门ID */
parentid?: string;
/** 排序 */
sort?: number;
/** 状态(1:启用0:禁用) */
status?: number;
/** 修改时间 */
updateTime?: Date;
}
export interface DeptForm {
/** 部门ID(新增不填) */
id?: string;
/** 部门名称 */
name?: string;
/** 部门编号 */
code?: string;
/** 父部门ID */
parentId: string;
/** 排序 */
sort?: number;
/** 状态(1:启用0禁用) */
status?: number;
}

View File

@@ -1,11 +1,20 @@
import request from "@/utils/request";
import type {
DictPageQuery,
DictPageVo,
DictForm,
DictItemPageQuery,
DictItemPageVo,
DictItemForm,
DictItemOption,
} from "@/types/api";
const DICT_BASE_URL = "/api/v1/dicts";
const DictAPI = {
/** 字典分页列表 */
getPage(queryParams: DictPageQuery) {
return request<any, PageResult<DictPageVO[]>>({
return request<any, PageResult<DictPageVo[]>>({
url: `${DICT_BASE_URL}/page`,
method: "get",
params: queryParams,
@@ -34,7 +43,7 @@ const DictAPI = {
/** 获取字典项分页列表 */
getDictItemPage(dictCode: string, queryParams: DictItemPageQuery) {
return request<any, PageResult<DictItemPageVO[]>>({
return request<any, PageResult<DictItemPageVo[]>>({
url: `${DICT_BASE_URL}/${dictCode}/items/page`,
method: "get",
params: queryParams,
@@ -69,77 +78,3 @@ const DictAPI = {
};
export default DictAPI;
export interface DictPageQuery extends PageQuery {
/** 搜索关键字 */
keywords?: string;
/** 状态(1:启用;0:禁用) */
status?: number;
}
export interface DictPageVO {
/** 字典ID */
id: string;
/** 字典名称 */
name: string;
/** 字典编码 */
dictCode: string;
/** 状态(1:启用;0:禁用) */
status: number;
}
export interface DictForm {
/** 字典ID(新增不填) */
id?: string;
/** 字典名称 */
name?: string;
/** 字典编码 */
dictCode?: string;
/** 状态(1:启用;0:禁用) */
status?: number;
/** 备注 */
remark?: string;
}
export interface DictItemPageQuery extends PageQuery {
/** 搜索关键字 */
keywords?: string;
/** 字典编码 */
dictCode?: string;
}
export interface DictItemPageVO {
/** 字典项ID */
id: string;
/** 字典编码 */
dictCode: string;
/** 字典项值 */
value: string;
/** 字典项标签 */
label: string;
/** 状态(1:启用;0:禁用) */
status: number;
/** 排序 */
sort?: number;
}
export interface DictItemForm {
/** 字典项ID(新增不填) */
id?: string;
/** 字典编码 */
dictCode?: string;
/** 字典项值 */
value?: string;
/** 字典项标签 */
label?: string;
/** 状态(1:启用;0:禁用) */
status?: number;
/** 排序 */
sort?: number;
/** 标签类型 */
tagType?: "success" | "warning" | "info" | "primary" | "danger" | "";
}
export interface DictItemOption {
/** 值 */
value: number | string;
/** 标签 */
label: string;
/** 标签类型 */
tagType?: "" | "success" | "info" | "warning" | "danger";
[key: string]: any;
}

View File

@@ -1,11 +1,12 @@
import request from "@/utils/request";
import type { LogPageQuery, LogPageVo } from "@/types/api";
const LOG_BASE_URL = "/api/v1/logs";
const LogAPI = {
/** 获取日志分页列表 */
getPage(queryParams: LogPageQuery) {
return request<any, PageResult<LogPageVO[]>>({
return request<any, PageResult<LogPageVo[]>>({
url: `${LOG_BASE_URL}/page`,
method: "get",
params: queryParams,
@@ -14,35 +15,3 @@ const LogAPI = {
};
export default LogAPI;
export interface LogPageQuery extends PageQuery {
/** 搜索关键字 */
keywords?: string;
/** 操作时间 */
createTime?: [string, string];
}
export interface LogPageVO {
/** 主键 */
id: string;
/** 日志模块 */
module: string;
/** 日志内容 */
content: string;
/** 请求路径 */
requestUri: string;
/** 请求方法 */
method: string;
/** IP 地址 */
ip: string;
/** 地区 */
region: string;
/** 浏览器 */
browser: string;
/** 终端系统 */
os: string;
/** 执行时间(毫秒) */
executionTime: number;
/** 操作人 */
operator: string;
}

View File

@@ -1,16 +1,17 @@
import request from "@/utils/request";
import type { MenuTypeEnum } from "@/enums/business";
import type { MenuQuery, MenuVo, MenuForm, MenuOption, RouteVo, Meta } from "@/types/api";
const MENU_BASE_URL = "/api/v1/menus";
const MenuAPI = {
/** 获取当前用户的路由列表 */
getRoutes() {
return request<any, RouteVO[]>({ url: `${MENU_BASE_URL}/routes`, method: "get" });
return request<any, RouteVo[]>({ url: `${MENU_BASE_URL}/routes`, method: "get" });
},
/** 获取菜单树形列表 */
getList(queryParams: MenuQuery) {
return request<any, MenuVO[]>({ url: `${MENU_BASE_URL}`, method: "get", params: queryParams });
return request<any, MenuVo[]>({ url: `${MENU_BASE_URL}`, method: "get", params: queryParams });
},
/** 获取菜单下拉数据源 */
getOptions(onlyParent?: boolean) {
@@ -39,98 +40,3 @@ const MenuAPI = {
};
export default MenuAPI;
export interface MenuQuery {
/** 搜索关键字 */
keywords?: string;
}
export interface MenuVO {
/** 子菜单 */
children?: MenuVO[];
/** 组件路径 */
component?: string;
/** ICON */
icon?: string;
/** 菜单ID */
id?: string;
/** 菜单名称 */
name?: string;
/** 父菜单ID */
parentId?: string;
/** 按钮权限标识 */
perm?: string;
/** 跳转路径 */
redirect?: string;
/** 路由名称 */
routeName?: string;
/** 路由相对路径 */
routePath?: string;
/** 菜单排序(数字越小排名越靠前) */
sort?: number;
/** 菜单类型 */
type?: MenuTypeEnum;
/** 是否可见(1:显示;0:隐藏) */
visible?: number;
}
export interface MenuForm {
/** 菜单ID */
id?: string;
/** 父菜单ID */
parentId?: string;
/** 菜单名称 */
name?: string;
/** 是否可见(1-是 0-否) */
visible: number;
/** ICON */
icon?: string;
/** 排序 */
sort?: number;
/** 路由名称 */
routeName?: string;
/** 路由路径 */
routePath?: string;
/** 组件路径 */
component?: string;
/** 跳转路由路径 */
redirect?: string;
/** 菜单类型 */
type?: MenuTypeEnum;
/** 权限标识 */
perm?: string;
/** 【菜单】是否开启页面缓存 */
keepAlive?: number;
/** 【目录】只有一个子路由是否始终显示 */
alwaysShow?: number;
/** 其他参数 */
params?: KeyValue[];
}
interface KeyValue {
key: string;
value: string;
}
export interface RouteVO {
/** 子路由列表 */
children: RouteVO[];
/** 组件路径 */
component?: string;
/** 路由属性 */
meta?: Meta;
/** 路由名称 */
name?: string;
/** 路由路径 */
path?: string;
/** 跳转链接 */
redirect?: string;
}
export interface Meta {
/** 【目录】只有一个子路由是否始终显示 */
alwaysShow?: boolean;
/** 是否隐藏(true-是 false-否) */
hidden?: boolean;
/** ICON */
icon?: string;
/** 【菜单】是否开启页面缓存 */
keepAlive?: boolean;
/** 路由title */
title?: string;
}

View File

@@ -1,11 +1,12 @@
import request from "@/utils/request";
import type { NoticePageQuery, NoticeForm, NoticePageVo, NoticeDetailVo } from "@/types/api";
const NOTICE_BASE_URL = "/api/v1/notices";
const NoticeAPI = {
/** 获取通知公告分页数据 */
getPage(queryParams?: NoticePageQuery) {
return request<any, PageResult<NoticePageVO[]>>({
return request<any, PageResult<NoticePageVo[]>>({
url: `${NOTICE_BASE_URL}/page`,
method: "get",
params: queryParams,
@@ -37,7 +38,7 @@ const NoticeAPI = {
},
/** 查看通知 */
getDetail(id: string) {
return request<any, NoticeDetailVO>({ url: `${NOTICE_BASE_URL}/${id}/detail`, method: "get" });
return request<any, NoticeDetailVo>({ url: `${NOTICE_BASE_URL}/${id}/detail`, method: "get" });
},
/** 全部已读 */
readAll() {
@@ -45,7 +46,7 @@ const NoticeAPI = {
},
/** 获取我的通知分页列表 */
getMyNoticePage(queryParams?: NoticePageQuery) {
return request<any, PageResult<NoticePageVO[]>>({
return request<any, PageResult<NoticePageVo[]>>({
url: `${NOTICE_BASE_URL}/my`,
method: "get",
params: queryParams,
@@ -54,68 +55,3 @@ const NoticeAPI = {
};
export default NoticeAPI;
export interface NoticePageQuery extends PageQuery {
/** 标题 */
title?: string;
/** 发布状态(0:草稿;1:已发布;2:已撤回) */
publishStatus?: number;
/** 是否已读(1:是;0:否) */
isRead?: number;
}
export interface NoticeForm {
/** 通知ID(新增不填) */
id?: string;
/** 标题 */
title?: string;
/** 内容 */
content?: string;
/** 类型 */
type?: number;
/** 优先级/级别 */
level?: string;
/** 目标类型 */
targetType?: number;
/** 目标用户ID(多个以英文逗号(,)分割) */
targetUserIds?: string;
}
export interface NoticePageVO {
/** 通知ID */
id: string;
/** 标题 */
title?: string;
/** 内容 */
content?: string;
/** 类型 */
type?: number;
/** 发布人ID */
publisherId?: bigint;
/** 优先级 */
priority?: number;
/** 目标类型 */
targetType?: number;
/** 发布状态 */
publishStatus?: number;
/** 发布时间 */
publishTime?: Date;
/** 撤回时间 */
revokeTime?: Date;
}
export interface NoticeDetailVO {
/** 通知ID */
id?: string;
/** 标题 */
title?: string;
/** 内容 */
content?: string;
/** 类型 */
type?: number;
/** 发布人名称 */
publisherName?: string;
/** 优先级/级别 */
level?: string;
/** 发布时间 */
publishTime?: Date;
/** 发布状态 */
publishStatus?: number;
}

View File

@@ -1,11 +1,12 @@
import request from "@/utils/request";
import type { RolePageQuery, RolePageVo, RoleForm } from "@/types/api";
const ROLE_BASE_URL = "/api/v1/roles";
const RoleAPI = {
/** 获取角色分页数据 */
getPage(queryParams?: RolePageQuery) {
return request<any, PageResult<RolePageVO[]>>({
return request<any, PageResult<RolePageVo[]>>({
url: `${ROLE_BASE_URL}/page`,
method: "get",
params: queryParams,
@@ -42,38 +43,3 @@ const RoleAPI = {
};
export default RoleAPI;
export interface RolePageQuery extends PageQuery {
/** 搜索关键字 */
keywords?: string;
}
export interface RolePageVO {
/** 角色ID */
id?: string;
/** 角色编码 */
code?: string;
/** 角色名称 */
name?: string;
/** 排序 */
sort?: number;
/** 角色状态 */
status?: number;
/** 创建时间 */
createTime?: Date;
/** 修改时间 */
updateTime?: Date;
}
export interface RoleForm {
/** 角色ID */
id?: string;
/** 角色编码 */
code?: string;
/** 数据权限 */
dataScope?: number;
/** 角色名称 */
name?: string;
/** 排序 */
sort?: number;
/** 角色状态(1-正常0-停用) */
status?: number;
}

View File

@@ -1,11 +1,12 @@
import request from "@/utils/request";
import type { VisitTrendQuery, VisitTrendVo, VisitStatsVo } from "@/types/api";
const STATISTICS_BASE_URL = "/api/v1/statistics";
const StatisticsAPI = {
/** 获取访问趋势统计 */
getVisitTrend(queryParams: VisitTrendQuery) {
return request<any, VisitTrendVO>({
return request<any, VisitTrendVo>({
url: `${STATISTICS_BASE_URL}/visits/trend`,
method: "get",
params: queryParams,
@@ -13,7 +14,7 @@ const StatisticsAPI = {
},
/** 获取访问概览统计 */
getVisitOverview() {
return request<any, VisitStatsVO>({
return request<any, VisitStatsVo>({
url: `${STATISTICS_BASE_URL}/visits/overview`,
method: "get",
});
@@ -21,36 +22,3 @@ const StatisticsAPI = {
};
export default StatisticsAPI;
export interface VisitTrendQuery {
/** 开始日期 */
startDate: string;
/** 结束日期 */
endDate: string;
}
export interface VisitTrendVO {
/** 日期列表 */
dates: string[];
/** 浏览量(PV) */
pvList: number[];
/** 访客数(UV) */
uvList: number[];
/** IP数 */
ipList: number[];
}
export interface VisitStatsVO {
/** 今日访客数(UV) */
todayUvCount: number;
/** 总访客数 */
totalUvCount: number;
/** 访客数同比增长率(相对于昨天同一时间段的增长率) */
uvGrowthRate: number;
/** 今日浏览量(PV) */
todayPvCount: number;
/** 总浏览量 */
totalPvCount: number;
/** 同比增长率(相对于昨天同一时间段的增长率) */
pvGrowthRate: number;
}

View File

@@ -1,39 +1,22 @@
import request from "@/utils/request";
import type {
TenantCreateForm,
TenantCreateResultVo,
TenantForm,
TenantInfo,
TenantPageQuery,
TenantPageVo,
} from "@/types/api";
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<any, TenantInfo[]>({
@@ -63,6 +46,58 @@ const TenantAPI = {
method: "post",
});
},
/** 获取租户分页数据(平台租户管理) */
getPage(queryParams?: TenantPageQuery) {
return request<any, PageResult<TenantPageVo[]>>({
url: `${TENANT_BASE_URL}/page`,
method: "get",
params: queryParams,
});
},
/** 获取租户表单数据 */
getFormData(tenantId: string) {
return request<any, TenantForm>({
url: `${TENANT_BASE_URL}/${tenantId}/form`,
method: "get",
});
},
/** 新增租户并初始化默认数据 */
create(data: TenantCreateForm) {
return request<any, TenantCreateResultVo>({
url: `${TENANT_BASE_URL}`,
method: "post",
data,
});
},
/** 修改租户 */
update(tenantId: string, data: TenantForm) {
return request({
url: `${TENANT_BASE_URL}/${tenantId}`,
method: "put",
data,
});
},
/** 删除租户(批量) */
deleteByIds(ids: string) {
return request({
url: `${TENANT_BASE_URL}/${ids}`,
method: "delete",
});
},
/** 修改租户状态 */
updateStatus(tenantId: string, status: number) {
return request({
url: `${TENANT_BASE_URL}/${tenantId}/status`,
method: "put",
params: { status },
});
},
};
export default TenantAPI;

View File

@@ -1,4 +1,15 @@
import request from "@/utils/request";
import type {
UserInfo,
UserPageQuery,
UserPageVo,
UserForm,
UserProfileVo,
UserProfileForm,
PasswordChangeForm,
MobileUpdateForm,
EmailUpdateForm,
} from "@/types/api";
const USER_BASE_URL = "/api/v1/users";
@@ -21,7 +32,7 @@ const UserAPI = {
* @param queryParams 查询参数
*/
getPage(queryParams: UserPageQuery) {
return request<any, PageResult<UserPageVO[]>>({
return request<any, PageResult<UserPageVo[]>>({
url: `${USER_BASE_URL}/page`,
method: "get",
params: queryParams,
@@ -139,7 +150,7 @@ const UserAPI = {
/** 获取个人中心用户信息 */
getProfile() {
return request<any, UserProfileVO>({
return request<any, UserProfileVo>({
url: `${USER_BASE_URL}/profile`,
method: "get",
});
@@ -211,174 +222,3 @@ const UserAPI = {
};
export default UserAPI;
/** 登录用户信息 */
export interface UserInfo {
/** 用户ID */
userId?: string;
/** 用户名 */
username?: string;
/** 昵称 */
nickname?: string;
/** 头像URL */
avatar?: string;
/** 角色 */
roles: string[];
/** 权限 */
perms: string[];
}
/**
* 用户分页查询对象
*/
export interface UserPageQuery extends PageQuery {
/** 搜索关键字 */
keywords?: string;
/** 用户状态 */
status?: number;
/** 部门ID */
deptId?: string;
/** 开始时间 */
createTime?: [string, string];
}
/** 用户分页对象 */
export interface UserPageVO {
/** 用户ID */
id: string;
/** 用户头像URL */
avatar?: string;
/** 创建时间 */
createTime?: Date;
/** 部门名称 */
deptName?: string;
/** 用户邮箱 */
email?: string;
/** 性别 */
gender?: number;
/** 手机号 */
mobile?: string;
/** 用户昵称 */
nickname?: string;
/** 角色名称,多个使用英文逗号(,)分割 */
roleNames?: string;
/** 用户状态(1:启用;0:禁用) */
status?: number;
/** 用户名 */
username?: string;
}
/** 用户表单类型 */
export interface UserForm {
/** 用户ID */
id?: string;
/** 用户头像 */
avatar?: string;
/** 部门ID */
deptId?: string;
/** 邮箱 */
email?: string;
/** 性别 */
gender?: number;
/** 手机号 */
mobile?: string;
/** 昵称 */
nickname?: string;
/** 角色ID集合 */
roleIds?: number[];
/** 用户状态(1:正常;0:禁用) */
status?: number;
/** 用户名 */
username?: string;
}
/** 个人中心用户信息 */
export interface UserProfileVO {
/** 用户ID */
id?: string;
/** 用户名 */
username?: string;
/** 昵称 */
nickname?: string;
/** 头像URL */
avatar?: string;
/** 性别 */
gender?: number;
/** 手机号 */
mobile?: string;
/** 邮箱 */
email?: string;
/** 部门名称 */
deptName?: string;
/** 角色名称,多个使用英文逗号(,)分割 */
roleNames?: string;
/** 创建时间 */
createTime?: Date;
}
/** 个人中心用户信息表单 */
export interface UserProfileForm {
/** 用户ID */
id?: string;
/** 用户名 */
username?: string;
/** 昵称 */
nickname?: string;
/** 头像URL */
avatar?: string;
/** 性别 */
gender?: number;
/** 手机号 */
mobile?: string;
/** 邮箱 */
email?: string;
}
/** 修改密码表单 */
export interface PasswordChangeForm {
/** 原密码 */
oldPassword?: string;
/** 新密码 */
newPassword?: string;
/** 确认新密码 */
confirmPassword?: string;
}
/** 修改手机表单 */
export interface MobileUpdateForm {
/** 手机号 */
mobile?: string;
/** 验证码 */
code?: string;
}
/** 修改邮箱表单 */
export interface EmailUpdateForm {
/** 邮箱 */
email?: string;
/** 验证码 */
code?: string;
}

1
src/api/types.ts Normal file
View File

@@ -0,0 +1 @@
export * from "@/types/api";

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -3,16 +3,28 @@
<div class="ai-assistant">
<!-- AI 助手图标按钮 -->
<el-button
v-if="!dialogVisible"
v-if="!dialogVisible && !fabCollapsed"
class="ai-fab-button"
type="primary"
circle
size="large"
:style="fabStyle"
@contextmenu.prevent="fabCollapsed = true"
@click="handleOpen"
>
<div class="i-svg:ai ai-icon" />
</el-button>
<!-- 收缩态贴边小标签避免遮挡表单控件 -->
<div
v-if="!dialogVisible && fabCollapsed"
class="ai-fab-tab"
:style="fabStyle"
@click="fabCollapsed = false"
>
AI
</div>
<!-- AI 对话框 -->
<el-dialog
v-model="dialogVisible"
@@ -107,7 +119,7 @@
</template>
<script setup lang="ts">
import { onBeforeUnmount } from "vue";
import { onBeforeUnmount, onMounted, watch } from "vue";
import { useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import AiCommandApi from "@/api/ai";
@@ -151,6 +163,111 @@ const command = ref("");
const loading = ref(false);
const response = ref<AiResponse | null>(null);
const fabCollapsed = useStorage<boolean>("vea:ui:ai_assistant_fab_collapsed", false);
const fabRight = ref(30);
const fabBottom = ref(80);
const fabStyle = computed(() => ({
right: `${fabRight.value}px`,
bottom: `${fabBottom.value}px`,
}));
const isElementVisible = (el: Element) => {
const style = window.getComputedStyle(el);
if (style.display === "none" || style.visibility === "hidden") {
return false;
}
return (el as HTMLElement).getClientRects().length > 0;
};
const getActiveRightDrawerWidth = (): number => {
const drawers = Array.from(document.querySelectorAll(".el-drawer"));
for (let i = drawers.length - 1; i >= 0; i--) {
const drawer = drawers[i] as HTMLElement;
if (!isElementVisible(drawer)) {
continue;
}
const rect = drawer.getBoundingClientRect();
if (rect.width > 0 && rect.right >= window.innerWidth - 1) {
return rect.width;
}
}
return 0;
};
const updateFabPosition = () => {
const safeMargin = 24;
const drawerWidth = getActiveRightDrawerWidth() || 0;
const baseRight = drawerWidth + 30;
// base position
const nextRight = baseRight;
let nextBottom = 80;
// Avoid Element Plus popper overlays (select dropdown, icon picker, date picker, etc.)
// If the FAB would overlap any visible popper, push it upward.
const fabSize = fabCollapsed.value ? 42 : 60;
const computeFabRect = (rightPx: number, bottomPx: number) => {
const right = window.innerWidth - rightPx;
const left = right - fabSize;
const bottom = window.innerHeight - bottomPx;
const top = bottom - fabSize;
return { left, right, top, bottom };
};
const intersects = (
a: { left: number; right: number; top: number; bottom: number },
b: DOMRect
) => {
return !(a.right <= b.left || a.left >= b.right || a.bottom <= b.top || a.top >= b.bottom);
};
const poppers = Array.from(document.querySelectorAll(".el-popper"));
for (const popper of poppers) {
if (!isElementVisible(popper)) {
continue;
}
const rect = (popper as HTMLElement).getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) {
continue;
}
const candidateFabRect = computeFabRect(nextRight, nextBottom);
if (intersects(candidateFabRect, rect)) {
const requiredBottom = Math.ceil(window.innerHeight - rect.top + safeMargin);
nextBottom = Math.max(nextBottom, requiredBottom);
}
}
// clamp so the button doesn't get pushed off-screen
const maxBottom = window.innerHeight - fabSize - safeMargin;
nextBottom = Math.min(nextBottom, Math.max(0, maxBottom));
fabRight.value = nextRight + (drawerWidth > 0 ? safeMargin : 0);
fabBottom.value = nextBottom;
};
watch(
fabCollapsed,
() => {
updateFabPosition();
},
{ flush: "post" }
);
let domObserver: MutationObserver | null = null;
let rafId: number | null = null;
const scheduleUpdateFabPosition = () => {
if (rafId != null) {
return;
}
rafId = window.requestAnimationFrame(() => {
rafId = null;
updateFabPosition();
});
};
// 快捷命令示例
const examples = [
"修改test用户的姓名为测试人员",
@@ -550,7 +667,32 @@ const executeAction = async (action: AiAction) => {
};
// 组件卸载时清理定时器
onMounted(() => {
updateFabPosition();
window.addEventListener("resize", updateFabPosition);
domObserver = new MutationObserver(() => {
scheduleUpdateFabPosition();
});
domObserver.observe(document.body, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ["class", "style"],
});
});
onBeforeUnmount(() => {
window.removeEventListener("resize", updateFabPosition);
if (domObserver) {
domObserver.disconnect();
domObserver = null;
}
if (rafId != null) {
window.cancelAnimationFrame(rafId);
rafId = null;
}
if (navigationTimer) {
clearTimeout(navigationTimer);
navigationTimer = null;
@@ -566,8 +708,6 @@ onBeforeUnmount(() => {
.ai-assistant {
.ai-fab-button {
position: fixed;
right: 30px;
bottom: 80px;
z-index: 9999;
width: 60px;
height: 60px;
@@ -584,6 +724,24 @@ onBeforeUnmount(() => {
height: 32px;
}
}
.ai-fab-tab {
position: fixed;
z-index: 9999;
display: inline-flex;
align-items: center;
justify-content: center;
width: 42px;
height: 42px;
font-size: 14px;
font-weight: 600;
color: #fff;
cursor: pointer;
user-select: none;
background: var(--el-color-primary);
border-radius: 999px;
box-shadow: 0 4px 12px rgba(2, 119, 252, 0.35);
}
}
.ai-assistant-dialog {

View File

@@ -1,152 +0,0 @@
<template>
<el-dropdown trigger="click" @command="handleTenantSwitch">
<div class="tenant-select">
<el-icon class="tenant-select__icon"><OfficeBuilding /></el-icon>
<span class="tenant-select__name">{{ currentTenantName }}</span>
<el-icon class="tenant-select__arrow"><ArrowDown /></el-icon>
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
v-for="tenant in tenantList"
:key="tenant.id"
:command="tenant.id"
:class="{ 'is-active': tenant.id === currentTenantId }"
>
<div class="tenant-item">
<span class="tenant-item__name">{{ tenant.name }}</span>
<el-icon v-if="tenant.id === currentTenantId" class="tenant-item__check">
<Check />
</el-icon>
</div>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
<script setup lang="ts">
import { computed, onMounted } from "vue";
import { useTenantStoreHook } from "@/store/modules/tenant-store";
import { ElMessage } from "element-plus";
import { OfficeBuilding, ArrowDown, Check } from "@element-plus/icons-vue";
const tenantStore = useTenantStoreHook();
// 当前租户名称
const currentTenantName = computed(() => {
if (tenantStore.currentTenant?.name) {
return tenantStore.currentTenant.name;
}
// 如果当前租户信息不存在,尝试从租户列表中查找
if (tenantStore.currentTenantId) {
const tenant = tenantStore.tenantList.find((t) => t.id === tenantStore.currentTenantId);
if (tenant) {
return tenant.name;
}
}
return "未选择租户";
});
// 当前租户ID
const currentTenantId = computed(() => tenantStore.currentTenantId);
// 租户列表
const tenantList = computed(() => tenantStore.tenantList);
/**
* 切换租户
*/
async function handleTenantSwitch(tenantId: number) {
if (tenantId === currentTenantId.value) {
return;
}
try {
await tenantStore.switchTenant(tenantId);
ElMessage.success("切换租户成功");
// 刷新页面以重新加载菜单和权限
window.location.reload();
} catch (error: any) {
ElMessage.error(error.message || "切换租户失败");
}
}
// 初始化:获取租户列表
onMounted(() => {
tenantStore.fetchTenantList().catch((error) => {
console.error("获取租户列表失败:", error);
});
});
</script>
<style lang="scss" scoped>
.tenant-select {
display: flex;
align-items: center;
height: 100%;
padding: 0 8px;
cursor: pointer;
border-radius: 4px;
transition: all 0.3s;
&__icon {
margin-right: 6px;
font-size: 18px;
color: inherit;
transition: color 0.3s;
}
&__name {
max-width: 100px;
overflow: hidden;
text-overflow: ellipsis;
font-size: 14px;
color: inherit;
white-space: nowrap;
transition: color 0.3s;
}
&__arrow {
margin-left: 6px;
font-size: 12px;
color: inherit;
opacity: 0.7;
transition: all 0.3s;
}
&:hover {
background: rgba(0, 0, 0, 0.04);
.tenant-select__icon,
.tenant-select__name {
color: var(--el-color-primary);
}
.tenant-select__arrow {
color: var(--el-color-primary);
opacity: 1;
}
}
}
.tenant-item {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
&__name {
flex: 1;
}
&__check {
margin-left: 8px;
color: var(--el-color-primary);
}
}
:deep(.el-dropdown-menu__item.is-active) {
background-color: var(--el-color-primary-light-9);
}
</style>

View File

@@ -1,187 +0,0 @@
<template>
<el-dialog
v-model="visible"
title="选择租户"
width="400px"
:close-on-click-modal="false"
:close-on-press-escape="false"
:show-close="false"
>
<div v-if="loading" class="tenant-dialog-loading">
<el-icon class="is-loading"><Loading /></el-icon>
<span>加载租户列表...</span>
</div>
<div v-else-if="tenantList.length === 0" class="tenant-dialog-empty">
<el-empty description="暂无可用租户" />
</div>
<el-radio-group v-else v-model="selectedTenantId" class="tenant-radio-group">
<el-radio
v-for="tenant in tenantList"
:key="tenant.id"
:label="tenant.id"
class="tenant-radio-item"
>
<div class="tenant-radio-content">
<div class="tenant-radio-content__name">{{ tenant.name }}</div>
<div v-if="tenant.code" class="tenant-radio-content__code">{{ tenant.code }}</div>
</div>
</el-radio>
</el-radio-group>
<template #footer>
<el-button @click="handleCancel">取消</el-button>
<el-button type="primary" :loading="switching" @click="handleConfirm">确定</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, computed, watch } from "vue";
import { useTenantStoreHook } from "@/store/modules/tenant-store";
import { ElMessage } from "element-plus";
import { Loading } from "@element-plus/icons-vue";
const props = defineProps<{
modelValue: boolean;
}>();
const emit = defineEmits<{
"update:modelValue": [value: boolean];
confirm: [];
}>();
const tenantStore = useTenantStoreHook();
const visible = computed({
get: () => props.modelValue,
set: (value) => emit("update:modelValue", value),
});
const loading = ref(false);
const switching = ref(false);
const selectedTenantId = ref<number | null>(null);
const tenantList = computed(() => tenantStore.tenantList);
// 监听对话框打开,加载租户列表
watch(visible, (newVal) => {
if (newVal) {
loadTenantList();
// 默认选择当前租户或第一个租户
selectedTenantId.value = tenantStore.currentTenantId || tenantList.value[0]?.id || null;
}
});
/**
* 加载租户列表
*/
async function loadTenantList() {
loading.value = true;
try {
await tenantStore.fetchTenantList();
// 如果列表为空,自动关闭对话框
if (tenantList.value.length === 0) {
visible.value = false;
ElMessage.warning("您暂无可用租户");
}
} catch (error: any) {
ElMessage.error(error.message || "获取租户列表失败");
} finally {
loading.value = false;
}
}
/**
* 确认选择
*/
async function handleConfirm() {
if (!selectedTenantId.value) {
ElMessage.warning("请选择租户");
return;
}
switching.value = true;
try {
await tenantStore.switchTenant(selectedTenantId.value);
ElMessage.success("切换租户成功");
visible.value = false;
emit("confirm");
} catch (error: any) {
ElMessage.error(error.message || "切换租户失败");
} finally {
switching.value = false;
}
}
/**
* 取消
*/
function handleCancel() {
// 如果用户没有租户,不允许取消
if (tenantList.value.length === 0) {
return;
}
visible.value = false;
}
</script>
<style lang="scss" scoped>
.tenant-dialog-loading {
display: flex;
gap: 12px;
align-items: center;
justify-content: center;
padding: 40px 0;
color: var(--el-text-color-secondary);
}
.tenant-dialog-empty {
padding: 20px 0;
}
.tenant-radio-group {
display: flex;
flex-direction: column;
gap: 12px;
width: 100%;
}
.tenant-radio-item {
width: 100%;
padding: 12px;
margin: 0;
border: 1px solid var(--el-border-color-light);
border-radius: 6px;
transition: all 0.3s;
&:hover {
background-color: var(--el-color-primary-light-9);
border-color: var(--el-color-primary);
}
:deep(.el-radio__input.is-checked) {
.el-radio__inner {
background-color: var(--el-color-primary);
border-color: var(--el-color-primary);
}
}
}
.tenant-radio-content {
display: flex;
flex-direction: column;
gap: 4px;
&__name {
font-size: 14px;
font-weight: 500;
color: var(--el-text-color-primary);
}
&__code {
font-size: 12px;
color: var(--el-text-color-secondary);
}
}
</style>

View File

@@ -1,124 +0,0 @@
<template>
<el-dropdown v-if="showTenantSelector" trigger="click" @command="handleSwitchTenant">
<div class="tenant-selector">
<el-icon><OfficeBuilding /></el-icon>
<span class="tenant-name">{{ currentTenantName }}</span>
<el-icon class="el-icon--right"><ArrowDown /></el-icon>
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
v-for="tenant in tenantList"
:key="tenant.id"
:command="tenant.id"
:disabled="tenant.id === currentTenantId"
>
<div class="tenant-item">
<span>{{ tenant.name }}</span>
<el-icon v-if="tenant.id === currentTenantId" class="check-icon">
<Check />
</el-icon>
</div>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
<script setup lang="ts">
import { computed } from "vue";
import { ElMessage } from "element-plus";
import { OfficeBuilding, ArrowDown, Check } from "@element-plus/icons-vue";
import { useTenantStoreHook } from "@/store/modules/tenant-store";
/**
* 租户切换器组件
*
* 功能:
* - 显示当前租户名称
* - 下拉列表展示所有可访问的租户
* - 点击切换租户
* - 切换后刷新页面以重新加载数据
*
* 使用条件:
* - 需要在 .env 中设置 VITE_APP_TENANT_ENABLED=true
* - 后端需要启用多租户功能
* - 用户至少属于一个租户
*/
// 多租户开关
const TENANT_ENABLED = import.meta.env.VITE_APP_TENANT_ENABLED === "true";
const tenantStore = useTenantStoreHook();
// 当前租户ID
const currentTenantId = computed(() => tenantStore.currentTenantId);
// 当前租户名称
const currentTenantName = computed(() => {
return tenantStore.currentTenant?.name || "未选择租户";
});
// 租户列表
const tenantList = computed(() => tenantStore.tenantList);
// 是否显示租户切换器(多租户开关启用 且 有租户列表)
const showTenantSelector = computed(() => {
return TENANT_ENABLED && tenantList.value.length > 0;
});
/**
* 切换租户
*/
const handleSwitchTenant = async (tenantId: number) => {
if (tenantId === currentTenantId.value) {
return;
}
try {
await tenantStore.switchTenant(tenantId);
ElMessage.success("切换租户成功");
// 刷新页面以重新加载数据(确保所有数据都基于新租户)
setTimeout(() => {
window.location.reload();
}, 500);
} catch (error: any) {
ElMessage.error(error?.msg || "切换租户失败");
}
};
</script>
<style scoped lang="scss">
.tenant-selector {
display: flex;
align-items: center;
height: 50px;
padding: 0 15px;
cursor: pointer;
user-select: none;
transition: background-color 0.3s;
&:hover {
background-color: var(--el-fill-color-light);
}
.tenant-name {
margin: 0 8px;
font-size: 14px;
font-weight: 500;
}
}
.tenant-item {
display: flex;
align-items: center;
justify-content: space-between;
min-width: 150px;
.check-icon {
margin-left: 10px;
color: var(--el-color-primary);
}
}
</style>

View File

@@ -1,17 +1,33 @@
<template>
<el-select
<el-dropdown
v-if="tenantList.length > 0"
v-model="currentTenantIdRef"
placeholder="选择租户"
style="width: 180px"
@change="onChange"
class="tenant-switcher"
trigger="click"
:hide-on-click="true"
@command="onCommand"
>
<el-option v-for="item in tenantList" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
<div class="tenant-switcher__trigger">
<span class="tenant-switcher__label">{{ currentTenantName }}</span>
<el-icon class="tenant-switcher__icon"><ArrowDown /></el-icon>
</div>
<template #dropdown>
<el-dropdown-menu class="tenant-switcher__menu">
<el-dropdown-item
v-for="item in tenantList"
:key="item.id"
:command="item.id"
:class="{ 'is-active': item.id === currentTenantIdRef }"
>
{{ item.name }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
<script setup lang="ts">
import { computed } from "vue";
import { ArrowDown } from "@element-plus/icons-vue";
import { useTenantStoreHook } from "@/store/modules/tenant-store";
const emit = defineEmits<{
@@ -29,7 +45,62 @@ const currentTenantIdRef = computed<number | null>({
},
});
function onChange(tenantId: number) {
const currentTenantName = computed(() => {
const currentId = currentTenantIdRef.value;
const fromList = tenantList.value.find((t) => t.id === currentId)?.name;
return fromList || tenantStore.currentTenant?.name || "切换租户";
});
function onCommand(tenantId: number) {
if (tenantId === currentTenantIdRef.value) {
return;
}
emit("change", tenantId);
}
</script>
<style scoped lang="scss">
.tenant-switcher {
display: flex;
align-items: center;
height: 100%;
&__trigger {
display: inline-flex;
align-items: center;
justify-content: center;
height: 32px;
padding: 0 10px;
cursor: pointer;
user-select: none;
border-radius: 8px;
transition: background 0.2s;
}
&__label {
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
font-size: 13px;
line-height: 1;
white-space: nowrap;
}
&__icon {
margin-left: 6px;
font-size: 12px;
opacity: 0.8;
}
&:hover {
.tenant-switcher__trigger {
background: rgba(0, 0, 0, 0.04);
}
}
:deep(.el-dropdown-menu__item.is-active) {
font-weight: 600;
color: var(--el-color-primary);
}
}
</style>

View File

@@ -1,5 +1,3 @@
/**
* 配置统一导出
*/
export * from "./storage";

View File

@@ -1,60 +0,0 @@
/**
* 本地存储键名配置
*
* @description
* 统一管理 localStorage/sessionStorage 的键名
* 命名规范:{prefix}:{namespace}:{key}
*/
export const APP_PREFIX = "vea";
/**
* 存储键名常量
*/
export const STORAGE_KEYS = {
// ===== 认证相关 =====
ACCESS_TOKEN: `${APP_PREFIX}:auth:access_token`,
REFRESH_TOKEN: `${APP_PREFIX}:auth:refresh_token`,
REMEMBER_ME: `${APP_PREFIX}:auth:remember_me`,
// ===== 租户相关 =====
TENANT_ID: `${APP_PREFIX}:tenant:id`,
TENANT_INFO: `${APP_PREFIX}:tenant:info`,
// ===== 系统相关 =====
DICT_CACHE: `${APP_PREFIX}:system:dict_cache`,
// ===== UI 设置 =====
SHOW_TAGS_VIEW: `${APP_PREFIX}:ui:show_tags_view`,
SHOW_APP_LOGO: `${APP_PREFIX}:ui:show_app_logo`,
SHOW_WATERMARK: `${APP_PREFIX}:ui:show_watermark`,
ENABLE_AI_ASSISTANT: `${APP_PREFIX}:ui:enable_ai_assistant`,
LAYOUT: `${APP_PREFIX}:ui:layout`,
SIDEBAR_COLOR_SCHEME: `${APP_PREFIX}:ui:sidebar_color_scheme`,
THEME: `${APP_PREFIX}:ui:theme`,
THEME_COLOR: `${APP_PREFIX}:ui:theme_color`,
// ===== 应用状态 =====
DEVICE: `${APP_PREFIX}:app:device`,
SIZE: `${APP_PREFIX}:app:size`,
LANGUAGE: `${APP_PREFIX}:app:language`,
SIDEBAR_STATUS: `${APP_PREFIX}:app:sidebar_status`,
ACTIVE_TOP_MENU_PATH: `${APP_PREFIX}:app:active_top_menu_path`,
} as const;
/**
* 认证相关键名(便于批量操作)
*/
export const AUTH_KEYS = {
ACCESS_TOKEN: STORAGE_KEYS.ACCESS_TOKEN,
REFRESH_TOKEN: STORAGE_KEYS.REFRESH_TOKEN,
REMEMBER_ME: STORAGE_KEYS.REMEMBER_ME,
} as const;
/**
* 租户相关键名(便于批量操作)
*/
export const TENANT_KEYS = {
TENANT_ID: STORAGE_KEYS.TENANT_ID,
TENANT_INFO: STORAGE_KEYS.TENANT_INFO,
} as const;

108
src/constants/index.ts Normal file
View File

@@ -0,0 +1,108 @@
/**
* 常量统一导出
*
* @description
* 包含应用中所有的常量定义
*/
/**
* 超级管理员角色标识
*
* @description
* 拥有系统最高权限,可以访问所有资源
*/
export const ROLE_ROOT = "ROOT";
/**
* 应用存储前缀
*/
export const APP_PREFIX = "vea";
/**
* 存储命名空间
*/
const NAMESPACES = {
AUTH: "auth",
TENANT: "tenant",
SYSTEM: "system",
UI: "ui",
APP: "app",
} as const;
/**
* 存储键名常量
*/
export const STORAGE_KEYS = {
// ===== 认证相关 =====
ACCESS_TOKEN: `${APP_PREFIX}:${NAMESPACES.AUTH}:access_token`,
REFRESH_TOKEN: `${APP_PREFIX}:${NAMESPACES.AUTH}:refresh_token`,
REMEMBER_ME: `${APP_PREFIX}:${NAMESPACES.AUTH}:remember_me`,
// ===== 租户相关 =====
TENANT_ID: `${APP_PREFIX}:${NAMESPACES.TENANT}:id`,
TENANT_INFO: `${APP_PREFIX}:${NAMESPACES.TENANT}:info`,
// ===== 系统相关 =====
DICT_CACHE: `${APP_PREFIX}:${NAMESPACES.SYSTEM}:dict_cache`,
// ===== UI 设置 =====
SHOW_TAGS_VIEW: `${APP_PREFIX}:${NAMESPACES.UI}:show_tags_view`,
SHOW_APP_LOGO: `${APP_PREFIX}:${NAMESPACES.UI}:show_app_logo`,
SHOW_WATERMARK: `${APP_PREFIX}:${NAMESPACES.UI}:show_watermark`,
ENABLE_AI_ASSISTANT: `${APP_PREFIX}:${NAMESPACES.UI}:enable_ai_assistant`,
LAYOUT: `${APP_PREFIX}:${NAMESPACES.UI}:layout`,
SIDEBAR_COLOR_SCHEME: `${APP_PREFIX}:${NAMESPACES.UI}:sidebar_color_scheme`,
THEME: `${APP_PREFIX}:${NAMESPACES.UI}:theme`,
THEME_COLOR: `${APP_PREFIX}:${NAMESPACES.UI}:theme_color`,
// ===== 应用状态 =====
DEVICE: `${APP_PREFIX}:${NAMESPACES.APP}:device`,
SIZE: `${APP_PREFIX}:${NAMESPACES.APP}:size`,
LANGUAGE: `${APP_PREFIX}:${NAMESPACES.APP}:language`,
SIDEBAR_STATUS: `${APP_PREFIX}:${NAMESPACES.APP}:sidebar_status`,
ACTIVE_TOP_MENU_PATH: `${APP_PREFIX}:${NAMESPACES.APP}:active_top_menu_path`,
} as const;
/**
* 认证相关键名(便于批量操作)
*/
export const AUTH_KEYS = {
ACCESS_TOKEN: STORAGE_KEYS.ACCESS_TOKEN,
REFRESH_TOKEN: STORAGE_KEYS.REFRESH_TOKEN,
REMEMBER_ME: STORAGE_KEYS.REMEMBER_ME,
} as const;
/**
* 租户相关键名(便于批量操作)
*/
export const TENANT_KEYS = {
TENANT_ID: STORAGE_KEYS.TENANT_ID,
TENANT_INFO: STORAGE_KEYS.TENANT_INFO,
} as const;
/**
* UI设置相关键名
*/
export const UI_KEYS = {
SHOW_TAGS_VIEW: STORAGE_KEYS.SHOW_TAGS_VIEW,
SHOW_APP_LOGO: STORAGE_KEYS.SHOW_APP_LOGO,
SHOW_WATERMARK: STORAGE_KEYS.SHOW_WATERMARK,
ENABLE_AI_ASSISTANT: STORAGE_KEYS.ENABLE_AI_ASSISTANT,
LAYOUT: STORAGE_KEYS.LAYOUT,
SIDEBAR_COLOR_SCHEME: STORAGE_KEYS.SIDEBAR_COLOR_SCHEME,
THEME: STORAGE_KEYS.THEME,
THEME_COLOR: STORAGE_KEYS.THEME_COLOR,
} as const;
/**
* 应用状态相关键名
*/
export const APP_KEYS = {
DEVICE: STORAGE_KEYS.DEVICE,
SIZE: STORAGE_KEYS.SIZE,
LANGUAGE: STORAGE_KEYS.LANGUAGE,
SIDEBAR_STATUS: STORAGE_KEYS.SIDEBAR_STATUS,
ACTIVE_TOP_MENU_PATH: STORAGE_KEYS.ACTIVE_TOP_MENU_PATH,
} as const;
export type StorageKey = (typeof STORAGE_KEYS)[keyof typeof STORAGE_KEYS];

View File

@@ -1,7 +1,7 @@
import type { Directive, DirectiveBinding } from "vue";
import { useUserStore } from "@/store";
import { ROLE_ROOT } from "@/enums";
import { ROLE_ROOT } from "@/constants";
/**
* 按钮权限

View File

@@ -25,23 +25,3 @@ export enum UserGender {
/** 女 */
FEMALE = 2,
}
/**
* 超级管理员角色标识
*
* @description
* 拥有系统最高权限,可以访问所有资源
*/
export const ROLE_ROOT = "ROOT";
/**
* 角色类型枚举
*/
export enum RoleType {
/** 超级管理员 */
ROOT = "ROOT",
/** 管理员 */
ADMIN = "ADMIN",
/** 普通用户 */
USER = "USER",
}

View File

@@ -279,13 +279,18 @@ function handleSettingsClick() {
}
// 租户选择器在白色文字模式下的样式
:deep(.tenant-select) {
::v-deep(.tenant-switcher__trigger) {
color: rgba(255, 255, 255, 0.85);
&:hover {
}
::v-deep(.tenant-switcher__trigger .tenant-switcher__icon) {
color: rgba(255, 255, 255, 0.85);
}
::v-deep(.tenant-switcher__trigger:hover) {
color: #fff;
background: rgba(255, 255, 255, 0.1);
}
::v-deep(.tenant-switcher__trigger:hover .tenant-switcher__icon) {
color: #fff;
}
}
@@ -310,18 +315,23 @@ function handleSettingsClick() {
}
// 租户选择器在深色文字模式下的样式
:deep(.tenant-select) {
::v-deep(.tenant-switcher__trigger) {
color: var(--el-text-color-regular) !important;
&:hover {
}
::v-deep(.tenant-switcher__trigger .tenant-switcher__icon) {
color: var(--el-text-color-regular) !important;
}
::v-deep(.tenant-switcher__trigger:hover) {
color: var(--el-color-primary) !important;
background: rgba(0, 0, 0, 0.04);
}
::v-deep(.tenant-switcher__trigger:hover .tenant-switcher__icon) {
color: var(--el-color-primary) !important;
}
}
// 确保下拉菜单中的图标不受影响
:deep(.el-dropdown-menu) {
::v-deep(.el-dropdown-menu) {
[class^="i-svg:"] {
color: var(--el-text-color-regular) !important;

View File

@@ -5,7 +5,7 @@ import zhCn from "element-plus/es/locale/lang/zh-cn";
import en from "element-plus/es/locale/lang/en";
import { store } from "@/store";
import { DeviceEnum, SidebarStatus } from "@/enums";
import { STORAGE_KEYS } from "@/config/storage";
import { STORAGE_KEYS } from "@/constants";
export const useAppStore = defineStore("app", () => {
// 设备类型

View File

@@ -1,6 +1,7 @@
import { store } from "@/store";
import DictAPI, { type DictItemOption } from "@/api/system/dict";
import { STORAGE_KEYS } from "@/config/storage";
import DictAPI from "@/api/system/dict";
import type { DictItemOption } from "@/types/api";
import { STORAGE_KEYS } from "@/constants";
export const useDictStore = defineStore("dict", () => {
// 字典数据缓存

View File

@@ -2,7 +2,7 @@ import { defaultSettings } from "@/settings";
import { SidebarColor, ThemeMode } from "@/enums";
import type { LayoutMode } from "@/enums";
import { applyTheme, generateThemeColors, toggleDarkMode, toggleSidebarColor } from "@/utils/theme";
import { STORAGE_KEYS } from "@/config/storage";
import { STORAGE_KEYS } from "@/constants";
// 🎯 设置项类型定义
interface SettingsState {

View File

@@ -1,6 +1,7 @@
import { store } from "@/store";
import TenantAPI, { type TenantInfo } from "@/api/system/tenant";
import { STORAGE_KEYS } from "@/config/storage";
import TenantAPI from "@/api/system/tenant";
import type { TenantInfo } from "@/types/api";
import { STORAGE_KEYS } from "@/constants";
/**
* 租户 Store
@@ -63,28 +64,52 @@ export const useTenantStore = defineStore("tenant", () => {
* 此方法由路由守卫调用,仅在启用多租户时执行
*/
async function loadTenant() {
restoreTenant();
// 1. 获取租户列表
await fetchTenantList();
// 2. 如果已有租户列表且未设置当前租户
if (tenantList.value.length > 0 && !currentTenantId.value) {
// 2. 校验本地恢复的租户是否仍然可用(避免 tenantId 不在列表导致无默认选中)
if (
currentTenantId.value &&
tenantList.value.length > 0 &&
!tenantList.value.some((t) => t.id === currentTenantId.value)
) {
console.debug("[Tenant] 本地租户已不可用,清除并重新选择:", currentTenantId.value);
currentTenantId.value = null;
currentTenant.value = null;
localStorage.removeItem(STORAGE_KEYS.TENANT_ID);
localStorage.removeItem(STORAGE_KEYS.TENANT_INFO);
}
// 3. 如果已有租户列表,则保证一定有一个默认租户被选中
if (tenantList.value.length > 0) {
// 3.1 优先后端当前租户
if (!currentTenantId.value) {
try {
// 尝试从后端获取当前租户
const currentTenantInfo = await TenantAPI.getCurrentTenant();
if (currentTenantInfo) {
setCurrentTenant(currentTenantInfo);
return;
}
} catch (error) {
console.debug("[Tenant] 获取当前租户失败,尝试自动选择:", error);
console.debug("[Tenant] 获取当前租户失败,尝试本地/默认选择:", error);
}
}
// 3. 如果只有一个租户,自动选中
if (tenantList.value.length === 1) {
// 3.2 本地已有 tenantId但 currentTenant 为空时,从列表补全 tenantInfo保持展示名称一致
if (currentTenantId.value && !currentTenant.value) {
const matched = tenantList.value.find((t) => t.id === currentTenantId.value);
if (matched) {
setCurrentTenant(matched);
return;
}
}
// 3.3 兜底:默认选中第一个(即使有多个租户,也保证 TenantSwitcher 有默认选中)
if (!currentTenantId.value) {
setCurrentTenant(tenantList.value[0]);
console.debug("[Tenant] 自动选中唯一租户:", tenantList.value[0].name);
} else {
console.debug("[Tenant] 多个租户可用,等待用户选择");
console.debug("[Tenant] 默认选中第一个租户:", tenantList.value[0].name);
}
}
}

View File

@@ -76,16 +76,3 @@ $sidebar-width: 210px; // 侧边栏宽度
$sidebar-width-collapsed: 54px; // 侧边栏收缩宽度
$navbar-height: 50px; // 导航栏高度
$tags-view-height: 34px; // TagsView 高度
/* 供 JS/TS 侧按需读取的变量导出 */
/* stylelint-disable property-no-unknown */
:export {
sidebar-width: $sidebar-width;
navbar-height: $navbar-height;
tags-view-height: $tags-view-height;
menu-background: $menu-background;
menu-text: $menu-text;
menu-active-text: $menu-active-text;
menu-hover: $menu-hover;
}
/* stylelint-enable property-no-unknown */

147
src/types/api/ai.ts Normal file
View File

@@ -0,0 +1,147 @@
/**
* AI 模块类型定义
*/
/** AI命令请求参数 */
export interface AiCommandRequest {
/** 用户输入的自然语言命令 */
command: string;
/** 当前页面路由(用于上下文) */
currentRoute?: string;
/** 当前激活的组件名称 */
currentComponent?: string;
/** 额外上下文信息 */
context?: Record<string, any>;
}
/** 函数调用参数 */
export interface FunctionCall {
/** 函数名称 */
name: string;
/** 函数描述 */
description?: string;
/** 参数对象 */
arguments: Record<string, any>;
}
/** AI命令解析响应 */
export interface AiCommandResponse {
/** 解析日志ID用于关联执行记录 */
parseLogId?: string;
/** 是否成功解析 */
success: boolean;
/** 解析后的函数调用列表 */
functionCalls: FunctionCall[];
/** AI的理解和说明 */
explanation?: string;
/** 置信度(0-1) */
confidence?: number;
/** 错误信息 */
error?: string;
/** 原始LLM响应用于调试 */
rawResponse?: string;
}
/** AI命令执行请求 */
export interface AiExecuteRequest {
/** 关联的解析日志ID */
parseLogId?: string;
/** 原始命令(用于审计) */
originalCommand?: string;
/** 要执行的函数调用 */
functionCall: FunctionCall;
/** 确认模式auto=自动执行, manual=需要用户确认 */
confirmMode?: "auto" | "manual";
/** 用户确认标志 */
userConfirmed?: boolean;
/** 幂等性令牌(防止重复执行) */
idempotencyKey?: string;
/** 当前页面路由 */
currentRoute?: string;
}
/** AI命令执行响应 */
export interface AiExecuteResponse {
/** 是否执行成功 */
success: boolean;
/** 执行结果数据 */
data?: any;
/** 执行结果说明 */
message?: string;
/** 影响的记录数 */
affectedRows?: number;
/** 错误信息 */
error?: string;
/** 记录ID用于追踪 */
recordId?: string;
/** 需要用户确认 */
requiresConfirmation?: boolean;
/** 确认提示信息 */
confirmationPrompt?: string;
}
/** AI命令记录分页查询参数 */
export interface AiCommandRecordPageQuery extends PageQuery {
/** 搜索关键字 */
keywords?: string;
/** 执行状态 */
executeStatus?: number;
/** 解析状态 */
parseStatus?: number;
/** 用户ID */
userId?: number;
/** AI提供商 */
aiProvider?: string;
/** AI模型 */
aiModel?: string;
/** 函数名称 */
functionName?: string;
/** 创建时间 */
createTime?: [string, string];
}
/** AI命令记录视图对象 */
export interface AiCommandRecordVo {
/** 记录ID */
id: string;
/** 用户ID */
userId: number;
/** 用户名 */
username: string;
/** 原始命令 */
originalCommand: string;
/** AI提供商 */
aiProvider?: string;
/** AI模型 */
aiModel?: string;
/** 解析状态 */
parseStatus?: number;
/** 函数调用列表 */
functionCalls?: string;
/** 解析说明 */
explanation?: string;
/** 置信度 */
confidence?: number;
/** 解析错误信息 */
parseErrorMessage?: string;
/** 输入Token数 */
inputTokens?: number;
/** 输出Token数 */
outputTokens?: number;
/** 解析耗时(毫秒) */
parseDurationMs?: number;
/** 函数名称 */
functionName?: string;
/** 函数参数 */
functionArguments?: string;
/** 执行状态 */
executeStatus?: number;
/** 执行错误信息 */
executeErrorMessage?: string;
/** IP地址 */
ipAddress?: string;
/** 创建时间 */
createTime?: string;
/** 更新时间 */
updateTime?: string;
}

99
src/types/api/codegen.ts Normal file
View File

@@ -0,0 +1,99 @@
/**
* CodeGen 代码生成类型定义
*/
/** 代码生成预览对象 */
export interface GeneratorPreviewVo {
/** 文件生成路径 */
path: string;
/** 文件名称 */
fileName: string;
/** 文件内容 */
content: string;
}
/** 数据表分页查询参数 */
export interface TablePageQuery extends PageQuery {
/** 搜索关键字(表名) */
keywords?: string;
}
/** 数据表分页对象 */
export interface TablePageVo {
/** 表名称 */
tableName: string;
/** 表描述 */
tableComment: string;
/** 是否已配置(1:是;0:否) */
isConfigured?: number;
/** 存储引擎 */
engine: string;
/** 字符集排序规则 */
tableCollation: string;
/** 创建时间 */
createTime: string;
}
/** 代码生成配置表单 */
export interface GenConfigForm {
/** 主键 */
id?: string;
/** 表名 */
tableName?: string;
/** 业务名 */
businessName?: string;
/** 模块名 */
moduleName?: string;
/** 包名 */
packageName?: string;
/** 实体名 */
entityName?: string;
/** 作者 */
author?: string;
/** 上级菜单 */
parentMenuId?: string;
/** 后端应用名 */
backendAppName?: string;
/** 前端应用名 */
frontendAppName?: string;
/** 字段配置列表 */
fieldConfigs?: FieldConfig[];
/** 页面类型 classic|curd */
pageType?: "classic" | "curd";
/** 要移除的表前缀,如 sys_ */
removeTablePrefix?: string;
}
/** 字段配置 */
export interface FieldConfig {
/** 主键 */
id?: string;
/** 列名 */
columnName?: string;
/** 列类型 */
columnType?: string;
/** 字段名 */
fieldName?: string;
/** 字段类型 */
fieldType?: string;
/** 字段描述 */
fieldComment?: string;
/** 是否在列表显示 */
isShowInList?: number;
/** 是否在表单显示 */
isShowInForm?: number;
/** 是否在查询条件显示 */
isShowInQuery?: number;
/** 是否必填 */
isRequired?: number;
/** 表单类型 */
formType?: number;
/** 查询类型 */
queryType?: number;
/** 字段长度 */
maxLength?: number;
/** 字段排序 */
fieldSort?: number;
/** 字典类型 */
dictType?: string;
}

View File

@@ -2,31 +2,50 @@
* 通用 API 类型定义
*/
/** API 响应结构 */
export interface ApiResponse<T = any> {
/** 响应码 */
code: string;
/** 响应数据 */
data: T;
/** 响应消息 */
msg: string;
}
/** 分页查询参数 */
export interface PageQuery {
/** 页码 */
pageNum: number;
/** 每页记录数 */
pageSize: number;
}
/** 分页响应结构 */
export interface PageResult<T> {
/** 数据列表 */
list: T;
/** 总记录数 */
total: number;
}
/** 下拉选项 */
export interface OptionType {
/** 选项值 */
value: string | number;
/** 选项标签 */
label: string;
/** 子选项 */
children?: OptionType[];
}
/** Excel 导入结果 */
export interface ExcelResult {
/** 响应码 */
code: string;
/** 无效数据数量 */
invalidCount: number;
/** 有效数据数量 */
validCount: number;
/** 错误信息列表 */
messageList: string[];
}

35
src/types/api/config.ts Normal file
View File

@@ -0,0 +1,35 @@
/**
* Config 配置类型定义
*/
/** 配置分页查询参数 */
export interface ConfigPageQuery extends PageQuery {
/** 搜索关键字 */
keywords?: string;
}
/** 配置表单对象 */
export interface ConfigForm {
/** 配置ID */
id?: string;
/** 配置名称 */
configName?: string;
/** 配置键 */
configKey?: string;
/** 配置值 */
configValue?: string;
/** 备注 */
remark?: string;
}
/** 配置分页对象 */
export interface ConfigPageVo {
/** 配置ID */
id?: string;
/** 配置名称 */
configName?: string;
/** 配置键 */
configKey?: string;
/** 配置值 */
configValue?: string;
}

49
src/types/api/dept.ts Normal file
View File

@@ -0,0 +1,49 @@
/**
* Dept 部门类型定义
*/
/** 部门查询参数 */
export interface DeptQuery {
/** 搜索关键字 */
keywords?: string;
/** 状态 */
status?: number;
}
/** 部门视图对象 */
export interface DeptVo {
/** 子部门 */
children?: DeptVo[];
/** 创建时间 */
createTime?: Date;
/** 部门ID */
id?: string;
/** 部门名称 */
name?: string;
/** 父部门ID */
parentId?: string;
/** 排序 */
sort?: number;
/** 状态(1:启用0:禁用) */
status?: number;
/** 父节点ID路径 */
treePath?: string;
/** 修改时间 */
updateTime?: Date;
}
/** 部门表单对象 */
export interface DeptForm {
/** 部门ID */
id?: string;
/** 部门名称 */
name?: string;
/** 部门编号 */
code?: string;
/** 父部门ID */
parentId?: string;
/** 排序 */
sort?: number;
/** 状态(1:启用0:禁用) */
status?: number;
}

90
src/types/api/dict.ts Normal file
View File

@@ -0,0 +1,90 @@
/**
* Dict 字典类型定义
*/
/** 字典分页查询参数 */
export interface DictPageQuery extends PageQuery {
/** 搜索关键字 */
keywords?: string;
/** 状态(1:启用;0:禁用) */
status?: number;
}
/** 字典分页对象 */
export interface DictPageVo {
/** 字典ID */
id: string;
/** 字典名称 */
name: string;
/** 字典编码 */
dictCode: string;
/** 状态(1:启用;0:禁用) */
status: number;
}
/** 字典表单对象 */
export interface DictForm {
/** 字典ID */
id?: string;
/** 字典名称 */
name?: string;
/** 字典编码 */
dictCode?: string;
/** 状态(1:启用;0:禁用) */
status?: number;
/** 备注 */
remark?: string;
}
/** 字典项分页查询参数 */
export interface DictItemPageQuery extends PageQuery {
/** 搜索关键字 */
keywords?: string;
/** 字典编码 */
dictCode?: string;
}
/** 字典项分页对象 */
export interface DictItemPageVo {
/** 字典项ID */
id: string;
/** 字典编码 */
dictCode: string;
/** 字典项标签 */
label: string;
/** 字典项值 */
value: string;
/** 状态(1:启用;0:禁用) */
status: number;
/** 排序 */
sort?: number;
}
/** 字典项表单对象 */
export interface DictItemForm {
/** 字典项ID */
id?: string;
/** 字典编码 */
dictCode?: string;
/** 字典项标签 */
label?: string;
/** 字典项值 */
value?: string;
/** 状态(1:启用;0:禁用) */
status?: number;
/** 排序 */
sort?: number;
/** 标签类型 */
tagType?: "success" | "warning" | "info" | "primary" | "danger" | "";
}
/** 字典项选项 */
export interface DictItemOption {
/** 字典项值 */
value: number | string;
/** 字典项标签 */
label: string;
/** 标签类型 */
tagType?: "success" | "warning" | "info" | "primary" | "danger" | "";
}

11
src/types/api/file.ts Normal file
View File

@@ -0,0 +1,11 @@
/**
* File 文件上传类型定义
*/
/** 文件信息 */
export interface FileInfo {
/** 文件名称 */
name: string;
/** 文件URL */
url: string;
}

View File

@@ -2,5 +2,22 @@
* API 类型统一导出
*/
export * from "./common";
export * from "./auth";
export * from "./common";
// System 模块
export * from "./user";
export * from "./role";
export * from "./menu";
export * from "./dept";
export * from "./dict";
export * from "./config";
export * from "./log";
export * from "./statistics";
export * from "./notice";
export * from "./tenant";
// 其他模块
export * from "./ai";
export * from "./file";
export * from "./codegen";

37
src/types/api/log.ts Normal file
View File

@@ -0,0 +1,37 @@
/**
* Log 日志类型定义
*/
/** 日志分页查询参数 */
export interface LogPageQuery extends PageQuery {
/** 搜索关键字 */
keywords?: string;
/** 操作时间 */
createTime?: [string, string];
}
/** 日志分页对象 */
export interface LogPageVo {
/** 日志ID */
id: string;
/** 日志模块 */
module: string;
/** 日志内容 */
content: string;
/** 请求路径 */
requestUri: string;
/** 请求方法 */
method: string;
/** IP地址 */
ip: string;
/** 地区 */
region: string;
/** 浏览器 */
browser: string;
/** 终端系统 */
os: string;
/** 执行时间(毫秒) */
executionTime: number;
/** 操作人 */
operator: string;
}

103
src/types/api/menu.ts Normal file
View File

@@ -0,0 +1,103 @@
/**
* Menu 菜单类型定义
*/
/** 菜单查询参数 */
export interface MenuQuery {
/** 搜索关键字 */
keywords?: string;
}
/** 菜单视图对象 */
export interface MenuVo {
/** 子菜单 */
children?: MenuVo[];
/** 组件路径 */
component?: string;
/** ICON */
icon?: string;
/** 菜单ID */
id?: string;
/** 菜单名称 */
name?: string;
/** 父菜单ID */
parentId?: string;
/** 路由路径 */
path?: string;
/** 按钮权限标识 */
perm?: string;
/** 跳转路径 */
redirect?: string;
/** 菜单排序(数字越小排名越靠前) */
sort?: number;
/** 菜单类型C-目录 M-菜单 B-按钮) */
type?: string;
/** 菜单是否可见(1:显示;0:隐藏) */
visible?: number;
}
/** 菜单表单对象 */
export interface MenuForm {
/** 菜单ID */
id?: string;
/** 父菜单ID */
parentId?: string;
/** 菜单名称 */
name?: string;
/** 菜单类型C-目录 M-菜单 B-按钮) */
type?: string;
/** 路由路径 */
path?: string;
/** 跳转路径 */
redirect?: string;
/** 组件路径 */
component?: string;
/** ICON */
icon?: string;
/** 排序 */
sort?: number;
/** 菜单是否可见 */
visible?: number;
/** 按钮权限标识 */
perm?: string;
}
/** 菜单选项 */
export interface MenuOption {
key: string;
value: string;
}
/** 路由对象 */
export interface RouteVo {
/** 子路由列表 */
children: RouteVo[];
/** 组件路径 */
component?: string;
/** 路由名称 */
name?: string;
/** 路由路径 */
path?: string;
/** 路由属性 */
meta?: Meta;
/** 跳转链接 */
redirect?: string;
}
/** 路由属性 */
export interface Meta {
/** 【目录】只有一个子路由是否始终显示 */
alwaysShow?: boolean;
/** 是否隐藏(true-是 false-否) */
hidden?: boolean;
/** ICON */
icon?: string;
/** 【菜单】是否开启页面缓存 */
keepAlive?: boolean;
/** 路由参数 */
params?: Record<string, any>;
/** 角色集合 */
roles?: string[];
/** 路由title */
title?: string;
}

71
src/types/api/notice.ts Normal file
View File

@@ -0,0 +1,71 @@
/**
* Notice 通知类型定义
*/
/** 通知分页查询参数 */
export interface NoticePageQuery extends PageQuery {
/** 通知标题 */
title?: string;
/** 发布状态(0:草稿;1:已发布;2:已撤回) */
publishStatus?: number;
/** 是否已读(1:是;0:否) */
isRead?: number;
}
/** 通知表单对象 */
export interface NoticeForm {
/** 通知ID */
id?: string;
/** 通知标题 */
title?: string;
/** 通知内容 */
content?: string;
/** 通知类型 */
type?: number;
/** 通知等级 */
level?: string;
/** 发布状态(0:草稿;1:已发布;2:已撤回) */
publishStatus?: number;
/** 目标用户ID(多个以英文逗号(,)分割) */
targetUserIds?: string;
}
/** 通知分页对象 */
export interface NoticePageVo {
/** 通知ID */
id: string;
/** 通知标题 */
title: string;
/** 通知内容 */
content: string;
/** 通知类型 */
type: number;
/** 通知等级 */
level: string;
/** 发布状态 */
publishStatus: number;
/** 是否已读 */
isRead: number;
/** 发布时间 */
publishTime?: Date;
/** 撤回时间 */
revokeTime?: Date;
}
/** 通知详情对象 */
export interface NoticeDetailVo {
/** 通知ID */
id?: string;
/** 通知标题 */
title?: string;
/** 通知内容 */
content?: string;
/** 通知类型 */
type?: number;
/** 通知等级 */
level?: string;
/** 发布状态 */
publishStatus?: number;
/** 目标用户ID */
targetUserIds?: string;
}

45
src/types/api/role.ts Normal file
View File

@@ -0,0 +1,45 @@
/**
* Role 角色类型定义
*/
/** 角色分页查询参数 */
export interface RolePageQuery extends PageQuery {
/** 搜索关键字 */
keywords?: string;
}
/** 角色分页对象 */
export interface RolePageVo {
/** 角色ID */
id?: string;
/** 角色编码 */
code?: string;
/** 角色名称 */
name?: string;
/** 排序 */
sort?: number;
/** 角色状态 */
status?: number;
/** 创建时间 */
createTime?: Date;
/** 修改时间 */
updateTime?: Date;
}
/** 角色表单对象 */
export interface RoleForm {
/** 角色ID */
id?: string;
/** 角色编码 */
code?: string;
/** 角色名称 */
name?: string;
/** 排序 */
sort?: number;
/** 数据权限 */
dataScope?: number;
/** 角色状态 */
status?: number;
/** 备注 */
remark?: string;
}

View File

@@ -0,0 +1,39 @@
/**
* Statistics 统计类型定义
*/
/** 访问趋势查询参数 */
export interface VisitTrendQuery {
/** 开始日期 */
startDate: string;
/** 结束日期 */
endDate: string;
}
/** 访问趋势视图对象 */
export interface VisitTrendVo {
/** 日期列表 */
dates: string[];
/** 浏览量(PV)列表 */
pvList: number[];
/** 访客数(UV)列表 */
uvList: number[];
/** IP数列表 */
ipList: number[];
}
/** 访问量统计视图对象 */
export interface VisitStatsVo {
/** 今日独立访客数(UV) */
todayUvCount: number;
/** 累计独立访客数(UV) */
totalUvCount: number;
/** 独立访客增长率 */
uvGrowthRate: number;
/** 今日页面浏览量(PV) */
todayPvCount: number;
/** 累计页面浏览量(PV) */
totalPvCount: number;
/** 页面浏览量增长率 */
pvGrowthRate: number;
}

79
src/types/api/tenant.ts Normal file
View File

@@ -0,0 +1,79 @@
/**
* Tenant 租户类型定义
*/
import type { PageQuery } from "./common";
/** 租户信息 */
export interface TenantInfo {
/** 租户ID */
id: number;
/** 租户名称 */
name: string;
/** 租户域名 */
domain?: string;
}
/** 租户分页查询参数 */
export interface TenantPageQuery extends PageQuery {
/** 关键字(租户名称/租户编码/域名) */
keywords?: string;
/** 租户状态(1-正常 0-禁用) */
status?: number;
}
/** 租户分页对象 */
export interface TenantPageVo {
id?: string;
name?: string;
code?: string;
contactName?: string;
contactPhone?: string;
contactEmail?: string;
domain?: string;
logo?: string;
status?: number;
remark?: string;
expireTime?: string;
createTime?: string;
updateTime?: string;
}
/** 租户表单对象(编辑) */
export interface TenantForm {
id?: string;
name?: string;
code?: string;
contactName?: string;
contactPhone?: string;
contactEmail?: string;
domain?: string;
logo?: string;
status?: number;
remark?: string;
expireTime?: string;
}
/** 新增租户表单对象 */
export interface TenantCreateForm {
name?: string;
code?: string;
contactName?: string;
contactPhone?: string;
contactEmail?: string;
domain?: string;
logo?: string;
remark?: string;
expireTime?: string;
adminUsername?: string;
}
/** 新增租户结果 */
export interface TenantCreateResultVo {
tenantId?: string;
tenantCode?: string;
tenantName?: string;
adminUsername?: string;
adminInitialPassword?: string;
adminRoleCode?: string;
}

149
src/types/api/user.ts Normal file
View File

@@ -0,0 +1,149 @@
/**
* User 用户类型定义
*/
/** 登录用户信息 */
export interface UserInfo {
/** 用户ID */
userId?: string;
/** 用户名 */
username?: string;
/** 用户昵称 */
nickname?: string;
/** 头像URL */
avatar?: string;
/** 角色集合 */
roles: string[];
/** 权限集合 */
perms: string[];
}
/** 用户分页查询参数 */
export interface UserPageQuery extends PageQuery {
/** 搜索关键字 */
keywords?: string;
/** 用户状态 */
status?: number;
/** 部门ID */
deptId?: string;
/** 创建时间 */
createTime?: [string, string];
}
/** 用户分页对象 */
export interface UserPageVo {
/** 用户ID */
id: string;
/** 用户头像地址 */
avatar?: string;
/** 创建时间 */
createTime?: Date;
/** 部门名称 */
deptName?: string;
/** 用户邮箱 */
email?: string;
/** 性别 */
gender?: number;
/** 手机号 */
mobile?: string;
/** 用户昵称 */
nickname?: string;
/** 角色名称,多个使用英文逗号(,)分割 */
roleNames?: string;
/** 用户状态(1:启用;0:禁用) */
status?: number;
/** 用户名 */
username?: string;
}
/** 用户表单对象 */
export interface UserForm {
/** 用户ID */
id?: string;
/** 用户头像 */
avatar?: string;
/** 部门ID */
deptId?: string;
/** 用户邮箱 */
email?: string;
/** 性别 */
gender?: number;
/** 手机号 */
mobile?: string;
/** 用户昵称 */
nickname?: string;
/** 角色ID集合 */
roleIds?: number[];
/** 用户状态(1:正常;0:禁用) */
status?: number;
/** 用户名 */
username?: string;
}
/** 个人中心用户信息 */
export interface UserProfileVo {
/** 用户ID */
id?: string;
/** 用户名 */
username?: string;
/** 用户昵称 */
nickname?: string;
/** 头像URL */
avatar?: string;
/** 性别 */
gender?: number;
/** 手机号 */
mobile?: string;
/** 邮箱 */
email?: string;
/** 部门名称 */
deptName?: string;
/** 角色名称 */
roleNames?: string;
/** 创建时间 */
createTime?: Date;
}
/** 个人中心用户信息表单 */
export interface UserProfileForm {
/** 用户ID */
id?: string;
/** 用户名 */
username?: string;
/** 用户昵称 */
nickname?: string;
/** 头像URL */
avatar?: string;
/** 性别 */
gender?: number;
/** 手机号 */
mobile?: string;
/** 邮箱 */
email?: string;
}
/** 修改密码表单 */
export interface PasswordChangeForm {
/** 原密码 */
oldPassword?: string;
/** 新密码 */
newPassword?: string;
/** 确认新密码 */
confirmPassword?: string;
}
/** 修改手机表单 */
export interface MobileUpdateForm {
/** 手机号 */
mobile?: string;
/** 验证码 */
code?: string;
}
/** 修改邮箱表单 */
export interface EmailUpdateForm {
/** 邮箱 */
email?: string;
/** 验证码 */
code?: string;
}

View File

@@ -1,6 +1,6 @@
import { Storage } from "./storage";
import { AUTH_KEYS } from "@/config/storage";
import { ROLE_ROOT } from "@/enums";
import { AUTH_KEYS } from "@/constants";
import { ROLE_ROOT } from "@/constants";
import { useUserStoreHook } from "@/store/modules/user-store";
import router from "@/router";

View File

@@ -2,6 +2,7 @@ import axios, { type InternalAxiosRequestConfig, type AxiosResponse } from "axio
import qs from "qs";
import { ApiCodeEnum } from "@/enums/api";
import { AuthStorage, redirectToLogin } from "@/utils/auth";
import { STORAGE_KEYS } from "@/constants";
import { useTokenRefresh } from "@/composables/auth/useTokenRefresh";
import { authConfig } from "@/settings";
@@ -28,8 +29,14 @@ httpRequest.interceptors.request.use(
// 如果 Authorization 设置为 no-auth则不携带 Token
if (config.headers.Authorization !== "no-auth" && accessToken) {
config.headers.Authorization = `Bearer ${accessToken}`;
const tenantId = localStorage.getItem(STORAGE_KEYS.TENANT_ID);
if (tenantId) {
config.headers["tenant-id"] = tenantId;
}
} else {
delete config.headers.Authorization;
delete config.headers["tenant-id"];
}
return config;

View File

@@ -1,4 +1,4 @@
import { STORAGE_KEYS, APP_PREFIX } from "@/config/storage";
import { STORAGE_KEYS, APP_PREFIX } from "@/constants";
/**
* 存储工具类

View File

@@ -1,7 +1,7 @@
<template>
<div class="app-container">
<!-- 搜索区域 -->
<div class="search-container">
<div class="filter-section">
<el-form ref="queryFormRef" :model="queryParams" :inline="true">
<el-form-item prop="keywords" label="关键字">
<el-input
@@ -83,13 +83,13 @@
</div>
<!-- 数据表格 -->
<el-card shadow="hover" class="data-table">
<el-card shadow="hover" class="table-section">
<el-table
v-loading="loading"
:data="pageData"
highlight-current-row
border
class="data-table__content"
class="table-section__content"
>
<el-table-column label="创建时间" prop="createTime" width="180" />
<el-table-column label="用户名" prop="username" width="120" />
@@ -263,7 +263,8 @@ defineOptions({
inheritAttrs: false,
});
import AiCommandApi, { AiCommandRecordVO, AiCommandRecordPageQuery } from "@/api/ai";
import AiCommandApi from "@/api/ai";
import type { AiCommandRecordVo, AiCommandRecordPageQuery } from "@/types/api";
import { onMounted, reactive, ref } from "vue";
const queryFormRef = ref();
@@ -282,10 +283,10 @@ const queryParams = reactive<AiCommandRecordPageQuery>({
createTime: ["", ""],
});
const pageData = ref<AiCommandRecordVO[]>([]);
const pageData = ref<AiCommandRecordVo[]>([]);
const detailDialogVisible = ref(false);
const currentRow = ref<AiCommandRecordVO>();
const currentRow = ref<AiCommandRecordVo>();
function getExecuteStatusText(status: number): string {
switch (status) {
@@ -315,7 +316,7 @@ function getExecuteStatusTagType(status: number): "info" | "success" | "danger"
function fetchData() {
loading.value = true;
AiCommandApi.getCommandRecordPage(queryParams)
AiCommandApi.getPage(queryParams)
.then((data) => {
pageData.value = data.list || [];
total.value = data.total || 0;
@@ -336,7 +337,7 @@ function handleResetQuery() {
fetchData();
}
function handleViewDetail(row: AiCommandRecordVO) {
function handleViewDetail(row: AiCommandRecordVo) {
currentRow.value = row;
detailDialogVisible.value = true;
}
@@ -363,7 +364,7 @@ onMounted(() => {
</script>
<style lang="scss" scoped>
.search-container {
.filter-section {
margin-bottom: 20px;
}

View File

@@ -524,12 +524,8 @@ import type { EditorConfiguration } from "codemirror";
import { FormTypeEnum, QueryTypeEnum } from "@/enums/codegen";
import GeneratorAPI, {
TablePageVO,
GenConfigForm,
TablePageQuery,
FieldConfig,
} from "@/api/codegen";
import GeneratorAPI from "@/api/codegen";
import type { FieldConfig, GenConfigForm, TablePageQuery, TablePageVo } from "@/api/types";
import { ElLoading } from "element-plus";
import DictAPI from "@/api/system/dict";
@@ -586,7 +582,7 @@ const queryParams = reactive<TablePageQuery>({
const loading = ref(false);
const loadingText = ref("loading...");
const pageData = ref<TablePageVO[]>([]);
const pageData = ref<TablePageVo[]>([]);
const total = ref(0);
const formTypeOptions: Record<string, OptionType> = FormTypeEnum;
@@ -630,7 +626,7 @@ watch(
);
const { copy, copied } = useClipboard();
const code = ref();
const code = ref<string>("");
const cmRef = ref<CmComponentRef>();
const cmOptions: EditorConfiguration = {
mode: "text/javascript",
@@ -640,7 +636,7 @@ const prevBtnText = ref("");
const nextBtnText = ref("下一步,字段配置");
const active = ref(0);
const currentTableName = ref("");
const sortFlag = ref<object>();
const sortFlag = ref<Sortable | null>(null);
// ================= 本地写盘(可选) =================
const supportsFSAccess = typeof (window as any).showDirectoryPicker === "function";
@@ -677,12 +673,24 @@ watch(active, (val) => {
} else if (val === 1) {
prevBtnText.value = "上一步,基础配置";
nextBtnText.value = "下一步,确认生成";
nextTick(() => {
initSort();
});
} else if (val === 2) {
prevBtnText.value = "上一步,字段配置";
nextBtnText.value = "下载代码";
}
});
watch(
() => dialog.visible,
(visible) => {
if (!visible) {
destroySort();
}
}
);
watch(copied, () => {
if (copied.value) {
ElMessage.success("复制成功");
@@ -696,7 +704,8 @@ watch(
if (
fieldConfig.fieldType &&
fieldConfig.fieldType.includes("Date") &&
fieldConfig.isShowInQuery === 1
fieldConfig.isShowInQuery === 1 &&
fieldConfig.queryType == null
) {
fieldConfig.queryType = QueryTypeEnum.BETWEEN.value as number;
}
@@ -705,12 +714,17 @@ watch(
{ deep: true, immediate: true }
);
function destroySort() {
if (!sortFlag.value) return;
sortFlag.value.destroy();
sortFlag.value = null;
}
const initSort = () => {
if (sortFlag.value) {
return;
}
if (sortFlag.value) return;
const table = document.querySelector(".elTableCustom .el-table__body-wrapper tbody");
sortFlag.value = Sortable.create(<HTMLElement>table, {
if (!table) return;
sortFlag.value = Sortable.create(table as HTMLElement, {
group: "shared",
animation: 150,
ghostClass: "sortable-ghost", //拖拽样式
@@ -724,20 +738,6 @@ const initSort = () => {
});
};
const setNodeSort = (oldIndex: number, newIndex: number) => {
// 使用arr复制一份表格数组数据
const arr = Object.assign([], genConfigFormData.value.fieldConfigs);
const currentRow = arr.splice(oldIndex, 1)[0];
arr.splice(newIndex, 0, currentRow);
arr.forEach((item: FieldConfig, index) => {
item.fieldSort = index + 1;
});
genConfigFormData.value.fieldConfigs = [];
nextTick(async () => {
genConfigFormData.value.fieldConfigs = arr;
});
};
/** 上一步 */
function handlePrevClick() {
if (active.value === 2) {
@@ -755,7 +755,6 @@ function handlePrevClick() {
loading.value = false;
});
});
initSort();
}
if (active.value-- <= 0) active.value = 0;
}
@@ -770,7 +769,6 @@ function handleNextClick() {
ElMessage.error("表名、业务名、包名、模块名、实体名不能为空");
return;
}
initSort();
}
if (active.value === 1) {
// 保存生成配置
@@ -783,7 +781,7 @@ function handleNextClick() {
loadingText.value = "代码生成中,请稍后...";
GeneratorAPI.saveGenConfig(tableName, genConfigFormData.value)
.then(() => {
handlePreview(tableName);
return handlePreview(tableName);
})
.then(() => {
if (active.value++ >= 2) active.value = 2;
@@ -833,36 +831,34 @@ function handleResetQuery() {
async function handleOpenDialog(tableName: string) {
dialog.visible = true;
active.value = 0;
menuOptions.value = await MenuAPI.getOptions(true);
currentTableName.value = tableName;
// 获取字典数据
DictAPI.getList().then((data) => {
dictOptions.value = data;
loading.value = true;
GeneratorAPI.getGenConfig(tableName)
.then((data) => {
try {
const [menuList, dictList, config] = await Promise.all([
MenuAPI.getOptions(true),
DictAPI.getList(),
GeneratorAPI.getGenConfig(tableName),
]);
menuOptions.value = menuList;
dictOptions.value = dictList;
dialog.title = `${tableName} 代码生成`;
genConfigFormData.value = data;
genConfigFormData.value = config;
checkAllSelected("isShowInQuery", isCheckAllQuery);
checkAllSelected("isShowInList", isCheckAllList);
checkAllSelected("isShowInForm", isCheckAllForm);
// 如果已经配置过,直接跳转到预览页面
if (genConfigFormData.value.id) {
active.value = 2;
handlePreview(tableName);
} else {
// 如果没有配置过,跳转到基础配置页面
active.value = 0;
await handlePreview(tableName);
}
})
.finally(() => {
} catch {
ElMessage.error("获取生成配置失败");
dialog.visible = false;
} finally {
loading.value = false;
});
});
}
}
/** 重置配置 */
@@ -896,27 +892,26 @@ const checkAllSelected = (key: keyof FieldConfig, isCheckAllRef: any) => {
};
/** 获取生成预览 */
function handlePreview(tableName: string) {
async function handlePreview(tableName: string) {
treeData.value = [];
GeneratorAPI.getPreviewData(tableName, (genConfigFormData.value.pageType as any) || "classic")
.then((data) => {
try {
const data = await GeneratorAPI.getPreviewData(
tableName,
(genConfigFormData.value.pageType as any) || "classic"
);
dialog.title = `代码生成 ${tableName}`;
// 组装树形结构完善代码
const tree = buildTree(data);
// 缓存原始数据用于写盘
lastPreviewFiles.value = data || [];
// 去掉根节点“前后端代码”,直接展示其 children 作为一级目录
treeData.value = tree?.children ? [...tree.children] : [];
// 默认选中第一个叶子节点并设置 code 值
const firstLeafNode = findFirstLeafNode(tree);
if (firstLeafNode) {
code.value = firstLeafNode.content || "";
}
})
.catch(() => {
} catch {
active.value = 0;
});
throw new Error("preview_failed");
}
}
/**
@@ -1065,27 +1060,11 @@ const pickBackendDir = async () => {
}
};
async function ensureDir(root: any, path: string[], force = true) {
async function ensureDir(root: any, path: string[], create = true) {
let current = root;
for (const segment of path) {
try {
// @ts-ignore
current = await current.getDirectoryHandle(segment, { create: true });
} catch (err: any) {
// 若同名文件阻塞目录创建,尝试强制删除后重建
if (force && err?.name === "TypeMismatchError") {
try {
// @ts-ignore
await current.removeEntry(segment, { recursive: true });
// @ts-ignore
current = await current.getDirectoryHandle(segment, { create: true });
} catch {
throw err;
}
} else {
throw err;
}
}
current = await current.getDirectoryHandle(segment, { create });
}
return current;
}
@@ -1103,15 +1082,8 @@ async function writeFile(dirHandle: any, filePath: string, content: string) {
fileHandle = await targetDir.getFileHandle(fileName, { create: true });
} catch (err: any) {
if (err?.name === "TypeMismatchError") {
// 存在同名目录,尝试删除后重建文件
try {
// @ts-ignore
await targetDir.removeEntry(fileName, { recursive: true });
// @ts-ignore
fileHandle = await targetDir.getFileHandle(fileName, { create: true });
} catch {
// 存在同名目录(或其它类型冲突),为安全起见不自动删除
throw err;
}
} else {
throw err;
}
@@ -1319,6 +1291,10 @@ async function confirmWrite() {
/** 组件挂载后执行 */
onMounted(() => {
handleQuery();
});
onBeforeUnmount(() => {
cmRef.value?.destroy();
destroySort();
});
</script>

View File

@@ -90,14 +90,17 @@
<el-dialog
v-model="tenantDialogVisible"
title="选择登录租户"
width="500px"
:width="isSmallScreen ? '92vw' : '500px'"
:fullscreen="isSmallScreen"
append-to-body
:teleported="true"
:close-on-click-modal="false"
:close-on-press-escape="false"
:show-close="false"
>
<div class="tenant-select-content">
<div class="tenant-select-content" :style="tenantDialogBodyStyle">
<p class="tenant-select-tip">检测到你的账号属于多个租户请选择登录租户</p>
<TenantSwitcher @change="(id: number) => (selectedTenantId = id)" />
<TenantSwitcher @change="handleTenantSwitcherChange" />
</div>
<template #footer>
<el-button @click="tenantDialogVisible = false">取消</el-button>
@@ -154,6 +157,7 @@ const loginFormRef = ref<FormInstance>();
const loading = ref(false);
// 是否大写锁定
const isCapsLock = ref(false);
const isSmallScreen = useMediaQuery("(max-width: 768px)");
// 验证码图片Base64字符串
const captchaBase64 = ref();
// 记住我
@@ -162,6 +166,26 @@ const rememberMe = AuthStorage.getRememberMe();
const tenantDialogVisible = ref(false);
const selectedTenantId = ref<number | null>(null);
function handleTenantSwitcherChange(id: number) {
selectedTenantId.value = id;
tenantStore.currentTenantId = id;
const matched = tenantStore.tenantList?.find((t) => t.id === id) || null;
tenantStore.currentTenant = matched;
}
const tenantDialogBodyStyle = computed(() => {
if (isSmallScreen.value) {
return {
maxHeight: "calc(100vh - 160px)",
overflow: "auto",
};
}
return {
maxHeight: "60vh",
overflow: "auto",
};
});
const loginFormData = ref<LoginRequest>({
username: "admin",
password: "123456",
@@ -232,10 +256,19 @@ async function handleLoginSubmit() {
await router.push(decodeURIComponent(redirectPath));
} catch (error: any) {
// 检查是否是 choose_tenant 响应
if (error?.code === ApiCodeEnum.CHOOSE_TENANT && error?.data?.tenants) {
if (
error?.code === ApiCodeEnum.CHOOSE_TENANT &&
Array.isArray(error?.data) &&
error.data.length > 0
) {
// 需要选择租户
tenantStore.setTenantList(error.data.tenants);
selectedTenantId.value = error.data.tenants[0]?.id || null;
tenantStore.setTenantList(error.data);
selectedTenantId.value = error.data[0]?.id || null;
if (selectedTenantId.value) {
tenantStore.currentTenantId = selectedTenantId.value;
tenantStore.currentTenant =
error.data.find((t: any) => t.id === selectedTenantId.value) || null;
}
tenantDialogVisible.value = true;
return; // 等待用户选择租户
}
@@ -263,7 +296,10 @@ async function handleTenantSelected() {
try {
loading.value = true;
// 使用选中的租户ID重新登录将 tenantId 设置到表单数据中)
const loginData = { ...loginFormData.value, tenantId: selectedTenantId.value };
const loginData = {
...loginFormData.value,
tenantId: selectedTenantId.value,
};
await userStore.login(loginData);
// 登录成功,关闭对话框并跳转
tenantDialogVisible.value = false;

View File

@@ -29,6 +29,10 @@
<span>⦿</span>
统一身份认证与权限管理
</li>
<li>
<span>⦿</span>
支持多租户模式与租户隔离
</li>
<li>
<span>⦿</span>
数据安全与操作审计
@@ -49,9 +53,14 @@
<div class="auth-panel__title-row">
<span class="auth-panel__title">{{ defaultSettings.title }}</span>
</div>
<div v-if="defaultSettings.version" class="auth-panel__version-row">
<span class="auth-panel__version-label">Version</span>
<span class="auth-panel__version-pill">v{{ defaultSettings.version }}</span>
<div v-if="defaultSettings.version || tenantEnabled" class="auth-panel__version-row">
<el-text size="small" type="info">VERSION</el-text>
<el-tag v-if="defaultSettings.version" size="small" effect="light" round>
{{ `v${defaultSettings.version}` }}
</el-tag>
<el-tag v-if="tenantEnabled" type="success" size="small" effect="light" round>
多租户
</el-tag>
</div>
</div>
</div>
@@ -82,6 +91,8 @@ type LayoutMap = "login" | "register" | "resetPwd";
const { t } = useI18n();
const component = ref<LayoutMap>("login");
const tenantEnabled = import.meta.env.VITE_APP_TENANT_ENABLED === "true";
const formComponents = {
login: defineAsyncComponent(() => import("./components/Login.vue")),
register: defineAsyncComponent(() => import("./components/Register.vue")),
@@ -425,21 +436,6 @@ onBeforeUnmount(() => {
font-size: 0.78rem;
}
.auth-panel__version-label {
color: var(--el-text-color-placeholder);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.auth-panel__version-pill {
padding: 0.1rem 0.55rem;
font-weight: 500;
color: var(--el-color-primary);
background: linear-gradient(135deg, rgba(22, 93, 255, 0.12), rgba(64, 150, 255, 0.18));
border: 1px solid rgba(22, 93, 255, 0.18);
border-radius: 999px;
}
.auth-panel__form {
width: 100%;
max-width: 100%;

View File

@@ -141,7 +141,8 @@ defineOptions({
inheritAttrs: false,
});
import ConfigAPI, { ConfigPageVO, ConfigForm, ConfigPageQuery } from "@/api/system/config";
import ConfigAPI from "@/api/system/config";
import type { ConfigPageVo, ConfigForm, ConfigPageQuery } from "@/api/types";
import { ElMessage, ElMessageBox } from "element-plus";
import { useDebounceFn } from "@vueuse/core";
@@ -159,7 +160,7 @@ const queryParams = reactive<ConfigPageQuery>({
});
// 系统配置表格数据
const pageData = ref<ConfigPageVO[]>([]);
const pageData = ref<ConfigPageVo[]>([]);
const dialog = reactive({
title: "",

View File

@@ -163,7 +163,8 @@ defineOptions({
inheritAttrs: false,
});
import DeptAPI, { DeptVO, DeptForm, DeptQuery } from "@/api/system/dept";
import DeptAPI from "@/api/system/dept";
import type { DeptVo, DeptForm, DeptQuery } from "@/api/types";
const queryFormRef = ref();
const deptFormRef = ref();
@@ -177,7 +178,7 @@ const dialog = reactive({
visible: false,
});
const deptList = ref<DeptVO[]>();
const deptList = ref<DeptVo[]>();
const deptOptions = ref<OptionType[]>();
const formData = reactive<DeptForm>({
status: 1,

View File

@@ -155,7 +155,8 @@
<script setup lang="ts">
import type { TagProps } from "element-plus";
import DictAPI, { DictItemPageQuery, DictItemPageVO, DictItemForm } from "@/api/system/dict";
import DictAPI from "@/api/system/dict";
import type { DictItemPageQuery, DictItemPageVo, DictItemForm } from "@/api/types";
const route = useRoute();
@@ -173,7 +174,7 @@ const queryParams = reactive<DictItemPageQuery>({
pageSize: 10,
});
const tableData = ref<DictItemPageVO[]>();
const tableData = ref<DictItemPageVo[]>();
const dialog = reactive({
title: "",
@@ -226,7 +227,7 @@ function handleSelectionChange(selection: any) {
}
// 打开弹窗
function handleOpenDialog(row?: DictItemPageVO) {
function handleOpenDialog(row?: DictItemPageVo) {
dialog.visible = true;
dialog.title = row ? "编辑字典项" : "新增字典项";

View File

@@ -137,7 +137,8 @@ defineOptions({
inherititems: false,
});
import DictAPI, { DictPageQuery, DictPageVO, DictForm } from "@/api/system/dict";
import DictAPI from "@/api/system/dict";
import type { DictPageQuery, DictPageVo, DictForm } from "@/api/types";
import router from "@/router";
@@ -153,7 +154,7 @@ const queryParams = reactive<DictPageQuery>({
pageSize: 10,
});
const tableData = ref<DictPageVO[]>();
const tableData = ref<DictPageVo[]>();
const dialog = reactive({
title: "",
@@ -285,7 +286,7 @@ function handleDelete(id?: number) {
}
// 打开字典项
function handleOpenDictData(row: DictPageVO) {
function handleOpenDictData(row: DictPageVo) {
router.push({
path: "/system/dict-item",
query: { dictCode: row.dictCode, title: "【" + row.name + "】字典数据" },

View File

@@ -68,7 +68,8 @@ defineOptions({
inheritAttrs: false,
});
import LogAPI, { LogPageVO, LogPageQuery } from "@/api/system/log";
import LogAPI from "@/api/system/log";
import type { LogPageVo, LogPageQuery } from "@/api/types";
const queryFormRef = ref();
@@ -83,7 +84,7 @@ const queryParams = reactive<LogPageQuery>({
});
// 日志表格数据
const pageData = ref<LogPageVO[]>();
const pageData = ref<LogPageVo[]>();
/** 获取数据 */
function fetchData() {

View File

@@ -47,15 +47,19 @@
>
<el-table-column label="菜单名称" min-width="200">
<template #default="scope">
<div class="menu-name-cell">
<span class="menu-name-cell__icon">
<template v-if="scope.row.icon && scope.row.icon.startsWith('el-icon')">
<el-icon style="vertical-align: -0.15em">
<component :is="scope.row.icon.replace('el-icon-', '')" />
</el-icon>
</template>
<template v-else-if="scope.row.icon">
<div :class="`i-svg:${scope.row.icon}`" />
<span :class="`i-svg:${scope.row.icon}`" />
</template>
{{ scope.row.name }}
</span>
<span class="menu-name-cell__text">{{ scope.row.name }}</span>
</div>
</template>
</el-table-column>
@@ -340,7 +344,8 @@
import { useAppStore } from "@/store/modules/app-store";
import { DeviceEnum } from "@/enums/settings";
import MenuAPI, { MenuQuery, MenuForm, MenuVO } from "@/api/system/menu";
import MenuAPI from "@/api/system/menu";
import type { MenuQuery, MenuForm, MenuVo } from "@/api/types";
import { MenuTypeEnum } from "@/enums/business";
defineOptions({
@@ -363,7 +368,7 @@ const drawerSize = computed(() => (appStore.device === DeviceEnum.DESKTOP ? "600
// 查询参数
const queryParams = reactive<MenuQuery>({});
// 菜单表格数据
const menuTableData = ref<MenuVO[]>([]);
const menuTableData = ref<MenuVo[]>([]);
// 顶级菜单下拉选项
const menuOptions = ref<OptionType[]>([]);
// 初始菜单表单数据
@@ -432,7 +437,7 @@ function handleResetQuery() {
}
// 行点击事件
function handleRowClick(row: MenuVO) {
function handleRowClick(row: MenuVo) {
selectedMenuId.value = row.id;
}
@@ -562,3 +567,26 @@ onMounted(() => {
handleQuery();
});
</script>
<style scoped>
.menu-name-cell {
display: inline-flex;
align-items: center;
max-width: 100%;
}
.menu-name-cell__icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
min-width: 18px;
margin-right: 6px;
}
.menu-name-cell__text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>

View File

@@ -260,12 +260,8 @@ defineOptions({
inheritAttrs: false,
});
import NoticeAPI, {
NoticePageVO,
NoticeForm,
NoticePageQuery,
NoticeDetailVO,
} from "@/api/system/notice";
import NoticeAPI from "@/api/system/notice";
import type { NoticePageVo, NoticeForm, NoticePageQuery, NoticeDetailVo } from "@/api/types";
import UserAPI from "@/api/system/user";
const queryFormRef = ref();
@@ -282,7 +278,7 @@ const queryParams = reactive<NoticePageQuery>({
const userOptions = ref<OptionType[]>([]);
// 通知公告表格数据
const pageData = ref<NoticePageVO[]>([]);
const pageData = ref<NoticePageVo[]>([]);
// 弹窗
const dialog = reactive({
@@ -319,7 +315,7 @@ const rules = reactive({
const detailDialog = reactive({
visible: false,
});
const currentNotice = ref<NoticeDetailVO>({});
const currentNotice = ref<NoticeDetailVo>({});
// 查询通知公告
function handleQuery() {

View File

@@ -215,7 +215,8 @@
import { useAppStore } from "@/store/modules/app-store";
import { DeviceEnum } from "@/enums/settings";
import RoleAPI, { RolePageVO, RoleForm, RolePageQuery } from "@/api/system/role";
import RoleAPI from "@/api/system/role";
import type { RolePageVo, RoleForm, RolePageQuery } from "@/api/types";
import MenuAPI from "@/api/system/menu";
defineOptions({
@@ -239,7 +240,7 @@ const queryParams = reactive<RolePageQuery>({
});
// 角色表格数据
const roleList = ref<RolePageVO[]>();
const roleList = ref<RolePageVo[]>();
// 菜单权限下拉
const menuPermOptions = ref<OptionType[]>([]);
@@ -389,7 +390,7 @@ function handleDelete(roleId?: number) {
}
// 打开分配菜单权限弹窗
async function handleOpenAssignPermDialog(row: RolePageVO) {
async function handleOpenAssignPermDialog(row: RolePageVo) {
const roleId = row.id;
if (roleId) {
assignPermDialogVisible.value = true;

View File

@@ -0,0 +1,411 @@
<template>
<div class="app-container">
<!-- 搜索区域 -->
<div class="filter-section">
<el-form ref="queryFormRef" :model="queryParams" :inline="true">
<el-form-item prop="keywords" label="关键字">
<el-input
v-model="queryParams.keywords"
placeholder="租户名称/租户编码/域名"
clearable
@keyup.enter="handleQuery"
/>
</el-form-item>
<el-form-item prop="status" label="状态">
<el-select v-model="queryParams.status" placeholder="全部" clearable style="width: 120px">
<el-option label="正常" :value="1" />
<el-option label="禁用" :value="0" />
</el-select>
</el-form-item>
<el-form-item class="search-buttons">
<el-button type="primary" icon="search" @click="handleQuery">搜索</el-button>
<el-button icon="refresh" @click="handleResetQuery">重置</el-button>
</el-form-item>
</el-form>
</div>
<el-card shadow="hover" class="table-section">
<div class="table-section__toolbar">
<div class="table-section__toolbar--actions">
<el-button
v-hasPerm="['sys:tenant:create']"
type="success"
icon="plus"
@click="handleOpenDialog()"
>
新增
</el-button>
<el-button
v-hasPerm="['sys:tenant:delete']"
type="danger"
icon="delete"
:disabled="ids.length === 0"
@click="handleDelete()"
>
删除
</el-button>
</div>
</div>
<el-table
v-loading="loading"
:data="pageData"
highlight-current-row
border
class="table-section__content"
@selection-change="handleSelectionChange"
>
<el-table-column type="selection" width="55" align="center" />
<el-table-column label="租户名称" prop="name" min-width="160" />
<el-table-column label="租户编码" prop="code" width="140" />
<el-table-column label="域名" prop="domain" min-width="160" />
<el-table-column label="联系人" prop="contactName" width="120" />
<el-table-column label="电话" prop="contactPhone" width="140" />
<el-table-column label="状态" width="120" align="center">
<template #default="scope">
<el-switch
v-if="hasPermChangeStatus"
v-model="scope.row.status"
inline-prompt
active-text="正常"
inactive-text="禁用"
:active-value="1"
:inactive-value="0"
@change="
(val) => {
pageData.length > 0 && handleChangeStatus(scope.row.id, Number(val));
}
"
/>
<el-tag v-else :type="scope.row.status === 1 ? 'success' : 'info'">
{{ scope.row.status === 1 ? "正常" : "禁用" }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="过期时间" prop="expireTime" width="180" />
<el-table-column label="创建时间" prop="createTime" width="180" />
<el-table-column fixed="right" label="操作" width="180">
<template #default="scope">
<el-button
v-hasPerm="['sys:tenant:update']"
type="primary"
size="small"
link
icon="edit"
@click="handleOpenDialog(scope.row.id)"
>
编辑
</el-button>
<el-button
v-hasPerm="['sys:tenant:delete']"
type="danger"
size="small"
link
icon="delete"
@click="handleDelete(scope.row.id)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<pagination
v-if="total > 0"
v-model:total="total"
v-model:page="queryParams.pageNum"
v-model:limit="queryParams.pageSize"
@pagination="fetchData"
/>
</el-card>
<!-- 租户表单弹窗 -->
<el-dialog
v-model="dialog.visible"
:title="dialog.title"
width="600px"
@close="handleCloseDialog"
>
<el-form ref="dataFormRef" :model="formData" :rules="rules" label-width="100px">
<el-form-item label="租户名称" prop="name">
<el-input v-model="formData.name" placeholder="请输入租户名称" />
</el-form-item>
<el-form-item label="租户编码" prop="code">
<el-input
v-model="formData.code"
placeholder="请输入租户编码"
:disabled="!!formData.id"
/>
</el-form-item>
<el-form-item label="域名" prop="domain">
<el-input v-model="formData.domain" placeholder="demo.youlai.tech可选" />
</el-form-item>
<el-form-item label="联系人" prop="contactName">
<el-input v-model="formData.contactName" placeholder="可选" />
</el-form-item>
<el-form-item label="联系电话" prop="contactPhone">
<el-input v-model="formData.contactPhone" placeholder="可选" />
</el-form-item>
<el-form-item label="联系邮箱" prop="contactEmail">
<el-input v-model="formData.contactEmail" placeholder="可选" />
</el-form-item>
<el-form-item label="过期时间" prop="expireTime">
<el-date-picker
v-model="formData.expireTime"
type="datetime"
placeholder="不填表示永不过期"
value-format="YYYY-MM-DD HH:mm:ss"
style="width: 100%"
/>
</el-form-item>
<el-form-item v-if="!formData.id" label="管理员账号" prop="adminUsername">
<el-input v-model="formData.adminUsername" placeholder="为空则系统生成" />
</el-form-item>
<el-form-item v-if="formData.id" label="状态" prop="status">
<el-radio-group v-model="formData.status">
<el-radio :value="1">正常</el-radio>
<el-radio :value="0">禁用</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input v-model="formData.remark" type="textarea" :rows="3" placeholder="可选" />
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button type="primary" @click="handleSubmit">确定</el-button>
<el-button @click="handleCloseDialog">取消</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
defineOptions({
name: "Tenant",
inheritAttrs: false,
});
import { ElMessage, ElMessageBox } from "element-plus";
import { useDebounceFn } from "@vueuse/core";
import { hasPerm } from "@/utils/auth";
import TenantAPI from "@/api/system/tenant";
import type { TenantCreateForm, TenantForm, TenantPageQuery, TenantPageVo } from "@/api/types";
const queryFormRef = ref();
const dataFormRef = ref();
const loading = ref(false);
const ids = ref<number[]>([]);
const total = ref(0);
const queryParams = reactive<TenantPageQuery>({
pageNum: 1,
pageSize: 10,
keywords: "",
});
const pageData = ref<TenantPageVo[]>([]);
const dialog = reactive({
title: "",
visible: false,
});
const formData = reactive<TenantForm & TenantCreateForm>({
id: undefined,
name: "",
code: "",
domain: "",
contactName: "",
contactPhone: "",
contactEmail: "",
remark: "",
expireTime: undefined,
status: 1,
adminUsername: "",
});
const rules = reactive({
name: [{ required: true, message: "请输入租户名称", trigger: "blur" }],
code: [{ required: true, message: "请输入租户编码", trigger: "blur" }],
});
const hasPermChangeStatus = computed(() => hasPerm("sys:tenant:change-status"));
function fetchData() {
loading.value = true;
TenantAPI.getPage(queryParams)
.then((data) => {
pageData.value = data.list;
total.value = data.total;
})
.finally(() => {
loading.value = false;
});
}
function handleQuery() {
queryParams.pageNum = 1;
fetchData();
}
function handleResetQuery() {
queryFormRef.value?.resetFields();
queryParams.pageNum = 1;
fetchData();
}
function handleSelectionChange(selection: any) {
ids.value = selection.map((item: any) => Number(item.id));
}
async function handleOpenDialog(tenantId?: string) {
dialog.visible = true;
if (tenantId) {
dialog.title = "修改租户";
const data = await TenantAPI.getFormData(tenantId);
Object.assign(formData, data);
formData.adminUsername = "";
} else {
dialog.title = "新增租户";
Object.assign(formData, {
id: undefined,
name: "",
code: "",
domain: "",
contactName: "",
contactPhone: "",
contactEmail: "",
remark: "",
expireTime: undefined,
status: 1,
adminUsername: "",
});
}
}
function handleCloseDialog() {
dialog.visible = false;
dataFormRef.value?.resetFields();
dataFormRef.value?.clearValidate();
Object.assign(formData, {
id: undefined,
name: "",
code: "",
domain: "",
contactName: "",
contactPhone: "",
contactEmail: "",
remark: "",
expireTime: undefined,
status: 1,
adminUsername: "",
});
}
const handleSubmit = useDebounceFn(async () => {
const valid = await dataFormRef.value?.validate().catch(() => false);
if (!valid) return;
loading.value = true;
try {
const tenantId = formData.id;
if (tenantId) {
const payload: TenantForm = {
id: formData.id,
name: formData.name,
code: formData.code,
domain: formData.domain,
contactName: formData.contactName,
contactPhone: formData.contactPhone,
contactEmail: formData.contactEmail,
remark: formData.remark,
expireTime: formData.expireTime,
status: formData.status,
};
await TenantAPI.update(String(tenantId), payload);
ElMessage.success("修改成功");
} else {
const payload: TenantCreateForm = {
name: formData.name,
code: formData.code,
domain: formData.domain,
contactName: formData.contactName,
contactPhone: formData.contactPhone,
contactEmail: formData.contactEmail,
remark: formData.remark,
expireTime: formData.expireTime,
adminUsername: formData.adminUsername,
};
const result = await TenantAPI.create(payload);
ElMessage.success(`新增成功:管理员账号 ${result?.adminUsername || ""}`);
}
handleCloseDialog();
handleResetQuery();
} catch {
ElMessage.error(formData.id ? "修改失败" : "新增失败");
} finally {
loading.value = false;
}
}, 300);
function handleDelete(tenantId?: string) {
const tenantIds = tenantId ? tenantId : ids.value.join(",");
if (!tenantIds) {
ElMessage.warning("请勾选删除项");
return;
}
ElMessageBox.confirm("确认删除选中的租户吗?", "警告", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
})
.then(async () => {
loading.value = true;
try {
await TenantAPI.deleteByIds(tenantIds);
ElMessage.success("删除成功");
handleResetQuery();
} finally {
loading.value = false;
}
})
.catch(() => {
// 用户取消
});
}
async function handleChangeStatus(id: string | undefined, status: number) {
if (!id) return;
try {
await TenantAPI.updateStatus(String(id), status);
ElMessage.success("状态更新成功");
} catch {
ElMessage.error("状态更新失败");
fetchData();
}
}
onMounted(() => {
fetchData();
});
</script>
<style scoped lang="scss"></style>

View File

@@ -253,7 +253,7 @@ import { useDebounceFn } from "@vueuse/core";
import { ElMessage, ElMessageBox, type FormInstance } from "element-plus";
// ==================== 3. 类型定义 ====================
import type { UserForm, UserPageQuery, UserPageVO } from "@/api/system/user";
import type { UserForm, UserPageQuery, UserPageVo } from "@/api/types";
// ==================== 3.5 工具函数 ====================
import { downloadFile } from "@/utils";
@@ -299,7 +299,7 @@ const queryParams = reactive<UserPageQuery>({
});
// 列表数据
const userList = ref<UserPageVO[]>([]);
const userList = ref<UserPageVo[]>([]);
const total = ref(0);
const loading = ref(false);
@@ -363,7 +363,7 @@ async function fetchUserList(): Promise<void> {
}
// ==================== 表格选择 ====================
const { selectedIds, hasSelection, handleSelectionChange } = useTableSelection<UserPageVO>();
const { selectedIds, hasSelection, handleSelectionChange } = useTableSelection<UserPageVo>();
// ==================== 查询操作 ====================
@@ -390,7 +390,7 @@ function handleResetQuery(): void {
* 重置用户密码
* @param row 用户数据
*/
function handleResetPassword(row: UserPageVO): void {
function handleResetPassword(row: UserPageVo): void {
ElMessageBox.prompt(`请输入用户【${row.username}】的新密码`, "重置密码", {
confirmButtonText: "确定",
cancelButtonText: "取消",