Merge branch 'develop' of https://gitee.com/youlaiorg/vue3-element-admin into develop
This commit is contained in:
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"vue-mcp": {
|
||||
"url": "http://localhost:3000/__mcp/sse"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
# ============================================
|
||||
# 🌐 网络配置
|
||||
# ============================================
|
||||
# 应用端口
|
||||
VITE_APP_PORT=3000
|
||||
# 项目名称
|
||||
@@ -6,11 +9,25 @@ VITE_APP_TITLE=vue3-element-admin
|
||||
VITE_APP_BASE_API=/dev-api
|
||||
|
||||
# 接口地址
|
||||
VITE_APP_API_URL=https://api.youlai.tech # 线上
|
||||
# VITE_APP_API_URL=http://localhost:8989 # 本地
|
||||
# VITE_APP_API_URL=https://api.youlai.tech # 线上
|
||||
VITE_APP_API_URL=http://localhost:8000 # 本地
|
||||
|
||||
# WebSocket 端点(不配置则关闭),线上 ws://api.youlai.tech/ws ,本地 ws://localhost:8989/ws
|
||||
# WebSocket 端点(不配置则关闭)
|
||||
# 线上: ws://api.youlai.tech/ws
|
||||
# 本地: ws://localhost:8000/ws
|
||||
VITE_APP_WS_ENDPOINT=
|
||||
|
||||
# ============================================
|
||||
# 🔧 开发工具
|
||||
# ============================================
|
||||
# 启用 Mock 服务
|
||||
VITE_MOCK_DEV_SERVER=false
|
||||
|
||||
# ============================================
|
||||
# 🎛️ 功能开关
|
||||
# ============================================
|
||||
# 多租户(需与后端 youlai.tenant.enabled 保持一致)
|
||||
VITE_APP_TENANT_ENABLED=false
|
||||
|
||||
# AI 助手(系统级开关,用户可在设置中单独控制)
|
||||
VITE_ENABLE_AI_ASSISTANT=true
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
# ============================================
|
||||
# 🌐 网络配置
|
||||
# ============================================
|
||||
# 代理前缀
|
||||
VITE_APP_BASE_API = '/prod-api'
|
||||
# 项目名称
|
||||
VITE_APP_TITLE=vue3-element-admin
|
||||
# WebSocket端点(可选)
|
||||
# WebSocket 端点(可选)
|
||||
#VITE_APP_WS_ENDPOINT=wss://api.youlai.tech/ws
|
||||
|
||||
# ============================================
|
||||
# 🎛️ 功能开关
|
||||
# ============================================
|
||||
# 多租户(需与后端 youlai.tenant.enabled 保持一致)
|
||||
VITE_APP_TENANT_ENABLED=false
|
||||
|
||||
# AI 助手(系统级开关)
|
||||
VITE_ENABLE_AI_ASSISTANT=false
|
||||
|
||||
105
.github/workflows/test.yml.example
vendored
Normal file
105
.github/workflows/test.yml.example
vendored
Normal file
@@ -0,0 +1,105 @@
|
||||
name: 单元测试
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, dev]
|
||||
pull_request:
|
||||
branches: [main, dev]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [20.x, 22.x]
|
||||
|
||||
steps:
|
||||
- name: 检出代码
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: 安装 pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 8
|
||||
|
||||
- name: 设置 Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: "pnpm"
|
||||
|
||||
- name: 安装依赖
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: 运行测试
|
||||
run: pnpm test:run
|
||||
|
||||
- name: 生成覆盖率报告
|
||||
run: pnpm test:coverage
|
||||
|
||||
- name: 上传覆盖率报告
|
||||
uses: codecov/codecov-action@v3
|
||||
with:
|
||||
files: ./coverage/coverage-final.json
|
||||
flags: unittests
|
||||
name: codecov-umbrella
|
||||
|
||||
- name: 检查覆盖率阈值
|
||||
run: |
|
||||
echo "检查测试覆盖率是否达到目标..."
|
||||
# 可以在这里添加覆盖率阈值检查逻辑
|
||||
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: 检出代码
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: 安装 pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 8
|
||||
|
||||
- name: 设置 Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.x
|
||||
cache: "pnpm"
|
||||
|
||||
- name: 安装依赖
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: 运行 ESLint
|
||||
run: pnpm lint:eslint
|
||||
|
||||
- name: 运行 Prettier
|
||||
run: pnpm lint:prettier
|
||||
|
||||
- name: 运行 Stylelint
|
||||
run: pnpm lint:stylelint
|
||||
|
||||
type-check:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: 检出代码
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: 安装 pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 8
|
||||
|
||||
- name: 设置 Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.x
|
||||
cache: "pnpm"
|
||||
|
||||
- name: 安装依赖
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: 类型检查
|
||||
run: pnpm type-check
|
||||
45
.gitignore
vendored
45
.gitignore
vendored
@@ -1,20 +1,43 @@
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
dist-ssr
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
dist-ssr/
|
||||
stats.html
|
||||
|
||||
# Local env files
|
||||
*.local
|
||||
.history
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Editor directories and files
|
||||
.idea
|
||||
.idea/
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
!.vscode/settings.json
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.local
|
||||
|
||||
stats.html
|
||||
pnpm-lock.yaml
|
||||
package-lock.json
|
||||
.stylelintcache
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Lint cache
|
||||
.eslintcache
|
||||
.stylelintcache
|
||||
|
||||
# Test coverage
|
||||
coverage/
|
||||
*.lcov
|
||||
.vitest/
|
||||
test-results/
|
||||
|
||||
# Lock files (use pnpm)
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
|
||||
# Local history
|
||||
.history
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<div align="center">
|
||||
<img alt="vue3-element-admin" width="80" height="80" src="./src/assets/logo.png">
|
||||
<img alt="vue3-element-admin" width="80" height="80" src="./src/assets/images/logo.png">
|
||||
<h1>vue3-element-admin</h1>
|
||||
|
||||
<img src="https://img.shields.io/badge/Vue-3.5.21-brightgreen.svg"/>
|
||||
|
||||
40
README.md
40
README.md
@@ -21,7 +21,7 @@
|
||||

|
||||
|
||||
<div align="center">
|
||||
<img alt="vue3-element-admin" width="80" height="80" src="./src/assets/logo.png">
|
||||
<img alt="vue3-element-admin" width="80" height="80" src="./src/assets/images/logo.png">
|
||||
<h1>vue3-element-admin</h1>
|
||||
|
||||
<img src="https://img.shields.io/badge/Vue-3.5.22-brightgreen.svg"/>
|
||||
@@ -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 交互,根据提示完成信息的输入和选择。
|
||||
|
||||

|
||||
|
||||
|
||||
## 项目统计
|
||||
|
||||

|
||||
|
||||
|
||||
Thanks to all the contributors!
|
||||
感谢所有的贡献者!
|
||||
|
||||
[](https://github.com/youlaitech/vue3-element-admin/graphs/contributors)
|
||||
|
||||
|
||||
## 特别感谢
|
||||
|
||||
- 感谢 [GitCode](https://gitcode.com/) 官方的 [G-Star](https://gitcode.com/g-star) 认证
|
||||
|
||||
@@ -64,6 +64,7 @@ export default [
|
||||
"**/*.min.*",
|
||||
"**/auto-imports.d.ts",
|
||||
"**/components.d.ts",
|
||||
"types/**/*.d.ts",
|
||||
],
|
||||
},
|
||||
|
||||
@@ -182,7 +183,7 @@ export default [
|
||||
parserOptions: {
|
||||
ecmaVersion: "latest",
|
||||
sourceType: "module",
|
||||
project: "./tsconfig.json",
|
||||
project: "./tsconfig.eslint.json",
|
||||
tsconfigRootDir: __dirname,
|
||||
},
|
||||
},
|
||||
|
||||
118
mock/ai.mock.ts
Normal file
118
mock/ai.mock.ts
Normal 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",
|
||||
};
|
||||
},
|
||||
},
|
||||
]);
|
||||
@@ -7,7 +7,7 @@ export default defineMock([
|
||||
body: {
|
||||
code: "00000",
|
||||
data: {
|
||||
captchaKey: "534b8ef2b0a24121bec76391ddd159f9",
|
||||
captchaId: "534b8ef2b0a24121bec76391ddd159f9",
|
||||
captchaBase64:
|
||||
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAHgAAAAkCAIAAADNSmkJAAAFKUlEQVR4Xu2ZXUwcVRiGV70wMWo08V5NvPXCrDbFaGpMaZW2hqQxaoiJTRsaMBCNSYtpa2JTKiFSelFa+Q/QZcMWqEhBlh+htbEpZhMrBQrlJ0hBywLLyrJ0WZbje3bqOvPNLHPWrDvdOE9ONmfe78zkzMs335wzWJhJQrBQweS/wTQ6QWgYHdoIOcecOe05O+t2WkutO+p2ZF3Ksg/YV9ZW6FATYajR3nveg60H9327r3O8c35lHgp+r05dPdJzBL73TPSQ8SaCKIxGLsPlop+K0JHrEkPuoT31e5qGmmjARACF0agYyGVNlyVm/pzZXrN9fHGcBkz0UBid+31u93i3XFFT80vN8cvHqWqih8Lo1NpUqS5vwh3vnd223VQ10UNh9NbyrcFQUK6oCawHUipSqGqiB83oBf+CXFGDMp1mS6OqiR4Ko7FexkpOrqhpHGw82nOUqiZ6KIzGrkRuorW0dJMmOy+hOCfYGzb2RBFv6HRO0gEJw/U7y+pgL1bwmTxexN6sZ31TdEwEhdG+gA+7EqyXpUO1uZH20cWL8hMTRt1N9tBXzCJrOIRoCPJpSO2RAp4HmtCdIfZ+2JWgEBN9LbR28seTGU0Zue1tMLp+YIAMSADzfvbkKX4/eb28j4YODiGin3heqmIlLja5hAUCu+nmGY3JWKvpMAlqNGgebsauBOvlqSX+JEx7p7EbTLen53XlzfmWUioqXikrc68Y8N2juJ/fyVsNChGHEE//rBANYWaZz+TRQqpLaBgNsPfDrgSpbS21YtV87IdjrlkX9JZbt5DOma2t9ITo5F+5glN22WwL/n+yDv00mw06orKxOqQ5+J04hhViwzAXETIcJDVm8uxZqktoGx2Nj9t43Wgaul/ERQiGQvtbWnDWgZYW9CXlQFjZ/7ciyHNn+Z2MexTimIeLz59TiIln0M1e+IbPpOAaDUnEYPTi6iqKxpbycs/qKo1tCslfKcffPn9enuMiPPY1vxO/ckeFQ4h46cdGqUWoidE/y54q5tPY5WDrGzQqIXot4BgchEE57e00IMCw2/1qZSVO/7SjA78o9INzcxsbrL+fnTnDDh9mmZn8F30oG1Hm+nABv5mQMopDS/h1HxtqTzWbABMe9sxpPoe9zezeOo1GELqWhPS8t46M0IAYHbdvR1aHbaOjbjfLz2eFhez6dba4yAfgF30o0BFVE8+Mjh/wFxPI+I5mAEHU6Ls+38vhTFwOBGhMDF8gkFpbC5ffsdv/uBs6dIj19dExEtARVXv9YNbop8NFY3aZ6gRRo+tu3IBHnzmdNCBMXldXJKPfL74WzWUJRE+coDUknqsOdZXQbAJYwluVTbOZI3Qt8GFzMwxyjo3RgBiN4fr+elXVpZGRLWXl6PdOTtJBSlBDUK/lnIrjOlrtqWYTQDJaF6FrTXu9sOa1ysrVoM5HVE1GFxZQcyJ/p+xzv6K/rbr6N6+XDpUBl0tKFIrbz78qWB6YnWFMCBld4XLBms+7df75ook/GNzb0GCV7U1Qfz9p64TyQWNjYD3qe9rj4SMJtQP3MyjSDPzWIRHPjH7X4YAvfXoPuyZf9Pbi3PcuXIh4mp3NllYC6XY79C+jl2o8PBipxjnBttn4MgMNnWgfcRJGPI2OL8hTj3LloIlmRicvBhiNykvecpqoa3RSY4DRcLAwyicuOepVR1JjgNFYHWONHL04czTX0UmNAUYD7Pr+xc4wqTHGaBb2OtZvHUmNYUazcA2J6etdUmOk0f8rTKMTxF91RG0D1SwYGwAAAABJRU5ErkJggg==",
|
||||
},
|
||||
@@ -26,7 +26,7 @@ export default defineMock([
|
||||
tokenType: "Bearer",
|
||||
refreshToken:
|
||||
"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhZG1pbiIsImRlcHRJZCI6MSwiZGF0YVNjb3BlIjoxLCJ1c2VySWQiOjIsImlhdCI6MTcyODE5MzA1MiwiYXV0aG9yaXRpZXMiOlsiUk9MRV9BRE1JTiJdLCJqdGkiOiJhZDg3NzlhZDZlYWY0OWY3OTE4M2ZmYmI5OWM4MjExMSJ9.58YHwL3sNNC22jyAmOZeSm-7MITzfHb_epBIz7LvWeA",
|
||||
expires: null,
|
||||
expiresIn: null,
|
||||
},
|
||||
msg: "一切ok",
|
||||
},
|
||||
|
||||
@@ -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
43
mock/statistics.mock.ts
Normal 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
65
mock/tenant.mock.ts
Normal 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 ? "切换租户成功" : "租户不存在",
|
||||
};
|
||||
},
|
||||
},
|
||||
]);
|
||||
81
package.json
81
package.json
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "vue3-element-admin",
|
||||
"description": "Vue3 + Vite + TypeScript + Element-Plus 的后台管理模板,vue-element-admin 的 Vue3 版本",
|
||||
"version": "3.4.2",
|
||||
"version": "4.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -10,6 +10,10 @@
|
||||
"preview": "vite preview",
|
||||
"build-only": "vite build",
|
||||
"type-check": "vue-tsc --noEmit",
|
||||
"test": "vitest",
|
||||
"test:ui": "vitest --ui",
|
||||
"test:run": "vitest run",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"lint:eslint": "eslint --cache \"src/**/*.{vue,ts,js}\" --fix",
|
||||
"lint:prettier": "prettier --write \"**/*.{js,cjs,ts,json,css,scss,vue,html,md}\"",
|
||||
"lint:stylelint": "stylelint --cache \"**/*.{css,scss,vue}\" --fix",
|
||||
@@ -48,77 +52,82 @@
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.2",
|
||||
"@stomp/stompjs": "^7.2.1",
|
||||
"@vueuse/core": "^12.8.2",
|
||||
"@wangeditor-next/editor": "^5.6.47",
|
||||
"@vueuse/core": "^14.1.0",
|
||||
"@wangeditor-next/editor": "^5.6.49",
|
||||
"@wangeditor-next/editor-for-vue": "^5.1.14",
|
||||
"animate.css": "^4.1.1",
|
||||
"axios": "^1.13.2",
|
||||
"codemirror": "^5.65.20",
|
||||
"codemirror-editor-vue3": "^2.8.0",
|
||||
"default-passive-events": "^2.0.0",
|
||||
"echarts": "^6.0.0",
|
||||
"element-plus": "^2.11.8",
|
||||
"element-plus": "^2.13.0",
|
||||
"exceljs": "^4.4.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
"lodash-es": "^4.17.22",
|
||||
"nprogress": "^0.2.0",
|
||||
"path-browserify": "^1.0.1",
|
||||
"path-to-regexp": "^8.3.0",
|
||||
"pinia": "^3.0.4",
|
||||
"qs": "^6.14.0",
|
||||
"sortablejs": "^1.15.6",
|
||||
"vue": "^3.5.24",
|
||||
"vue": "^3.5.26",
|
||||
"vue-draggable-plus": "^0.6.0",
|
||||
"vue-i18n": "^11.1.12",
|
||||
"vue-router": "^4.6.3",
|
||||
"vxe-table": "~4.6.25"
|
||||
"vue-i18n": "^11.2.7",
|
||||
"vue-router": "^4.6.4",
|
||||
"vxe-table": "~4.17.33"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@commitlint/cli": "^19.8.1",
|
||||
"@commitlint/config-conventional": "^19.8.1",
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@iconify/utils": "^2.3.0",
|
||||
"@commitlint/cli": "^20.2.0",
|
||||
"@commitlint/config-conventional": "^20.2.0",
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@iconify/utils": "^3.1.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@testing-library/vue": "^8.1.0",
|
||||
"@types/codemirror": "^5.60.17",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/node": "^25.0.3",
|
||||
"@types/nprogress": "^0.2.3",
|
||||
"@types/path-browserify": "^1.0.3",
|
||||
"@types/qs": "^6.14.0",
|
||||
"@types/sortablejs": "^1.15.9",
|
||||
"@typescript-eslint/eslint-plugin": "^8.46.4",
|
||||
"@typescript-eslint/parser": "^8.46.4",
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"autoprefixer": "^10.4.22",
|
||||
"@typescript-eslint/eslint-plugin": "^8.50.1",
|
||||
"@typescript-eslint/parser": "^8.50.1",
|
||||
"@vitejs/plugin-vue": "^6.0.3",
|
||||
"@vitest/ui": "^4.0.16",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"autoprefixer": "^10.4.23",
|
||||
"commitizen": "^4.3.1",
|
||||
"cz-git": "^1.12.0",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.5.4",
|
||||
"eslint-plugin-vue": "^10.5.1",
|
||||
"globals": "^15.15.0",
|
||||
"eslint-plugin-vue": "^10.6.2",
|
||||
"globals": "^16.5.0",
|
||||
"happy-dom": "^20.0.11",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^15.5.2",
|
||||
"lint-staged": "^16.2.7",
|
||||
"postcss": "^8.5.6",
|
||||
"postcss-html": "^1.8.0",
|
||||
"postcss-scss": "^4.0.9",
|
||||
"prettier": "^3.6.2",
|
||||
"sass": "^1.94.0",
|
||||
"stylelint": "^16.25.0",
|
||||
"prettier": "^3.7.4",
|
||||
"sass": "^1.97.1",
|
||||
"stylelint": "^16.26.1",
|
||||
"stylelint-config-html": "^1.1.0",
|
||||
"stylelint-config-recess-order": "^6.1.0",
|
||||
"stylelint-config-recommended": "^15.0.0",
|
||||
"stylelint-config-recommended-scss": "^14.1.0",
|
||||
"stylelint-config-recess-order": "^7.4.0",
|
||||
"stylelint-config-recommended": "^17.0.0",
|
||||
"stylelint-config-recommended-scss": "^16.0.2",
|
||||
"stylelint-config-recommended-vue": "^1.6.1",
|
||||
"stylelint-prettier": "^5.0.3",
|
||||
"terser": "^5.44.1",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.46.4",
|
||||
"unocss": "^66.5.6",
|
||||
"unplugin-auto-import": "^19.3.0",
|
||||
"unplugin-vue-components": "^28.8.0",
|
||||
"vite": "^7.2.2",
|
||||
"vite-plugin-mock-dev-server": "^2.0.2",
|
||||
"typescript-eslint": "^8.50.1",
|
||||
"unocss": "^66.5.10",
|
||||
"unplugin-auto-import": "^20.3.0",
|
||||
"unplugin-vue-components": "^30.0.0",
|
||||
"vite": "^7.3.0",
|
||||
"vite-plugin-mock-dev-server": "^2.0.7",
|
||||
"vitest": "^4.0.16",
|
||||
"vue-eslint-parser": "^10.2.0",
|
||||
"vue-tsc": "^2.2.12"
|
||||
"vue-tsc": "^3.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
|
||||
7304
pnpm-lock.yaml
generated
Normal file
7304
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
11
src/App.vue
11
src/App.vue
@@ -1,9 +1,9 @@
|
||||
<template>
|
||||
<el-config-provider :locale="locale" :size="size">
|
||||
<!-- 开启水印 -->
|
||||
<!-- å¼€å<EFBFBD>¯æ°´å<EFBFBD>?-->
|
||||
<el-watermark
|
||||
:font="{ color: fontColor }"
|
||||
:content="showWatermark ? defaultSettings.watermarkContent : ''"
|
||||
:content="showWatermark ? watermarkContent : ''"
|
||||
:z-index="9999"
|
||||
class="wh-full"
|
||||
>
|
||||
@@ -17,7 +17,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useAppStore, useSettingsStore, useUserStore } from "@/store";
|
||||
import { defaultSettings } from "@/settings";
|
||||
import { appConfig } from "@/settings";
|
||||
import { ThemeMode, ComponentSize } from "@/enums";
|
||||
import AiAssistant from "@/components/AiAssistant/index.vue";
|
||||
|
||||
@@ -28,9 +28,10 @@ const userStore = useUserStore();
|
||||
const locale = computed(() => appStore.locale);
|
||||
const size = computed(() => appStore.size as ComponentSize);
|
||||
const showWatermark = computed(() => settingsStore.showWatermark);
|
||||
const watermarkContent = appConfig.name;
|
||||
|
||||
// 只有在启用 AI 助手且用户已登录时才显示
|
||||
// 使用 userInfo 作为响应式依赖,当用户退出登录时会自动更新
|
||||
// å<EFBFBD>ªæœ‰åœ¨å<EFBFBD>¯ç”?AI 助手且用户已登录时æ‰<C3A6>显示
|
||||
// 使用 userInfo 作为å“<EFBFBD>应å¼<EFBFBD>ä¾<EFBFBD>赖,当用户退出登录时会自动更æ–?
|
||||
const enableAiAssistant = computed(() => {
|
||||
const isEnabled = settingsStore.enableAiAssistant;
|
||||
const isLoggedIn = userStore.userInfo && Object.keys(userStore.userInfo).length > 0;
|
||||
|
||||
73
src/api/ai.ts
Normal file
73
src/api/ai.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import request from "@/utils/request";
|
||||
import type {
|
||||
AiCommandRequest,
|
||||
AiCommandResponse,
|
||||
AiExecuteRequest,
|
||||
AiExecuteResponse,
|
||||
AiCommandRecordPageQuery,
|
||||
AiCommandRecordVo,
|
||||
} from "@/types/api";
|
||||
|
||||
const AI_BASE_URL = "/api/v1/ai/assistant";
|
||||
|
||||
/**
|
||||
* AI 命令 API
|
||||
*/
|
||||
const AiCommandApi = {
|
||||
/**
|
||||
* 解析 AI 命令
|
||||
*
|
||||
* @param data AI 命令请求参数
|
||||
* @returns AI 命令解析响应
|
||||
*/
|
||||
parseCommand(data: AiCommandRequest) {
|
||||
return request<any, AiCommandResponse>({
|
||||
url: `${AI_BASE_URL}/parse`,
|
||||
method: "post",
|
||||
data,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 执行 AI 命令
|
||||
*
|
||||
* @param data AI 命令执行请求
|
||||
* @returns AI 命令执行响应
|
||||
*/
|
||||
executeCommand(data: AiExecuteRequest) {
|
||||
return request<any, AiExecuteResponse>({
|
||||
url: `${AI_BASE_URL}/execute`,
|
||||
method: "post",
|
||||
data,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取 AI 命令记录分页列表
|
||||
*
|
||||
* @param queryParams 查询参数
|
||||
* @returns AI 命令记录分页列表
|
||||
*/
|
||||
getPage(queryParams: AiCommandRecordPageQuery) {
|
||||
return request<any, PageResult<AiCommandRecordVo[]>>({
|
||||
url: `${AI_BASE_URL}/records`,
|
||||
method: "get",
|
||||
params: queryParams,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 删除 AI 命令记录
|
||||
*
|
||||
* @param ids 记录ID,多个以逗号分隔
|
||||
* @returns 删除结果
|
||||
*/
|
||||
deleteByIds(ids: string) {
|
||||
return request({
|
||||
url: `${AI_BASE_URL}/records/${ids}`,
|
||||
method: "delete",
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default AiCommandApi;
|
||||
@@ -1,86 +0,0 @@
|
||||
import request from "@/utils/request";
|
||||
|
||||
const AUTH_BASE_URL = "/api/v1/auth";
|
||||
|
||||
const AuthAPI = {
|
||||
/** 登录接口*/
|
||||
login(data: LoginFormData) {
|
||||
const formData = new FormData();
|
||||
formData.append("username", data.username);
|
||||
formData.append("password", data.password);
|
||||
formData.append("captchaKey", data.captchaKey);
|
||||
formData.append("captchaCode", data.captchaCode);
|
||||
return request<any, LoginResult>({
|
||||
url: `${AUTH_BASE_URL}/login`,
|
||||
method: "post",
|
||||
data: formData,
|
||||
headers: {
|
||||
"Content-Type": "multipart/form-data",
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
/** 刷新 token 接口*/
|
||||
refreshToken(refreshToken: string) {
|
||||
return request<any, LoginResult>({
|
||||
url: `${AUTH_BASE_URL}/refresh-token`,
|
||||
method: "post",
|
||||
params: { refreshToken },
|
||||
headers: {
|
||||
Authorization: "no-auth",
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
/** 退出登录接口 */
|
||||
logout() {
|
||||
return request({
|
||||
url: `${AUTH_BASE_URL}/logout`,
|
||||
method: "delete",
|
||||
});
|
||||
},
|
||||
|
||||
/** 获取验证码接口*/
|
||||
getCaptcha() {
|
||||
return request<any, CaptchaInfo>({
|
||||
url: `${AUTH_BASE_URL}/captcha`,
|
||||
method: "get",
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default AuthAPI;
|
||||
|
||||
/** 登录表单数据 */
|
||||
export interface LoginFormData {
|
||||
/** 用户名 */
|
||||
username: string;
|
||||
/** 密码 */
|
||||
password: string;
|
||||
/** 验证码缓存key */
|
||||
captchaKey: string;
|
||||
/** 验证码 */
|
||||
captchaCode: string;
|
||||
/** 记住我 */
|
||||
rememberMe: boolean;
|
||||
}
|
||||
|
||||
/** 登录响应 */
|
||||
export interface LoginResult {
|
||||
/** 访问令牌 */
|
||||
accessToken: string;
|
||||
/** 刷新令牌 */
|
||||
refreshToken: string;
|
||||
/** 令牌类型 */
|
||||
tokenType: string;
|
||||
/** 过期时间(秒) */
|
||||
expiresIn: number;
|
||||
}
|
||||
|
||||
/** 验证码信息 */
|
||||
export interface CaptchaInfo {
|
||||
/** 验证码缓存key */
|
||||
captchaKey: string;
|
||||
/** 验证码图片Base64字符串 */
|
||||
captchaBase64: string;
|
||||
}
|
||||
57
src/api/auth.ts
Normal file
57
src/api/auth.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import request from "@/utils/request";
|
||||
import type { LoginRequest, LoginResponse, CaptchaInfo } from "@/types/api/auth";
|
||||
|
||||
const AUTH_BASE_URL = "/api/v1/auth";
|
||||
|
||||
const AuthAPI = {
|
||||
/** 登录接口*/
|
||||
login(data: LoginRequest) {
|
||||
const payload: Record<string, any> = {
|
||||
username: data.username,
|
||||
password: data.password,
|
||||
captchaId: data.captchaId,
|
||||
captchaCode: data.captchaCode,
|
||||
};
|
||||
|
||||
// tenantId is optional — include only when provided (multi-tenant feature)
|
||||
if (typeof data.tenantId !== "undefined") {
|
||||
payload.tenantId = data.tenantId;
|
||||
}
|
||||
|
||||
return request<any, LoginResponse>({
|
||||
url: `${AUTH_BASE_URL}/login`,
|
||||
method: "post",
|
||||
data: payload,
|
||||
});
|
||||
},
|
||||
|
||||
/** 刷新 token 接口*/
|
||||
refreshToken(refreshToken: string) {
|
||||
return request<any, LoginResponse>({
|
||||
url: `${AUTH_BASE_URL}/refresh-token`,
|
||||
method: "post",
|
||||
params: { refreshToken },
|
||||
headers: {
|
||||
Authorization: "no-auth",
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
/** 退出登录接口 */
|
||||
logout() {
|
||||
return request({
|
||||
url: `${AUTH_BASE_URL}/logout`,
|
||||
method: "delete",
|
||||
});
|
||||
},
|
||||
|
||||
/** 获取验证码接口*/
|
||||
getCaptcha() {
|
||||
return request<any, CaptchaInfo>({
|
||||
url: `${AUTH_BASE_URL}/captcha`,
|
||||
method: "get",
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default AuthAPI;
|
||||
@@ -1,199 +0,0 @@
|
||||
import request from "@/utils/request";
|
||||
|
||||
const GENERATOR_BASE_URL = "/api/v1/codegen";
|
||||
|
||||
const GeneratorAPI = {
|
||||
/** 获取数据表分页列表 */
|
||||
getTablePage(params: TablePageQuery) {
|
||||
return request<any, PageResult<TablePageVO[]>>({
|
||||
url: `${GENERATOR_BASE_URL}/table/page`,
|
||||
method: "get",
|
||||
params,
|
||||
});
|
||||
},
|
||||
|
||||
/** 获取代码生成配置 */
|
||||
getGenConfig(tableName: string) {
|
||||
return request<any, GenConfigForm>({
|
||||
url: `${GENERATOR_BASE_URL}/${tableName}/config`,
|
||||
method: "get",
|
||||
});
|
||||
},
|
||||
|
||||
/** 获取代码生成配置 */
|
||||
saveGenConfig(tableName: string, data: GenConfigForm) {
|
||||
return request({
|
||||
url: `${GENERATOR_BASE_URL}/${tableName}/config`,
|
||||
method: "post",
|
||||
data,
|
||||
});
|
||||
},
|
||||
|
||||
/** 获取代码生成预览数据 */
|
||||
getPreviewData(tableName: string, pageType?: "classic" | "curd") {
|
||||
return request<any, GeneratorPreviewVO[]>({
|
||||
url: `${GENERATOR_BASE_URL}/${tableName}/preview`,
|
||||
method: "get",
|
||||
params: pageType ? { pageType } : undefined,
|
||||
});
|
||||
},
|
||||
|
||||
/** 重置代码生成配置 */
|
||||
resetGenConfig(tableName: string) {
|
||||
return request({
|
||||
url: `${GENERATOR_BASE_URL}/${tableName}/config`,
|
||||
method: "delete",
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 下载 ZIP 文件
|
||||
* @param url
|
||||
* @param fileName
|
||||
*/
|
||||
download(tableName: string, pageType?: "classic" | "curd") {
|
||||
return request({
|
||||
url: `${GENERATOR_BASE_URL}/${tableName}/download`,
|
||||
method: "get",
|
||||
params: pageType ? { pageType } : undefined,
|
||||
responseType: "blob",
|
||||
}).then((response) => {
|
||||
const fileName = decodeURI(
|
||||
response.headers["content-disposition"].split(";")[1].split("=")[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);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
90
src/api/codegen.ts
Normal file
90
src/api/codegen.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import request from "@/utils/request";
|
||||
import type { GeneratorPreviewVo, TablePageQuery, TablePageVo, GenConfigForm } from "@/types/api";
|
||||
|
||||
const GENERATOR_BASE_URL = "/api/v1/codegen";
|
||||
|
||||
const GeneratorAPI = {
|
||||
/** 获取数据表分页列表 */
|
||||
getTablePage(params: TablePageQuery) {
|
||||
return request<any, PageResult<TablePageVo[]>>({
|
||||
url: `${GENERATOR_BASE_URL}/table/page`,
|
||||
method: "get",
|
||||
params,
|
||||
});
|
||||
},
|
||||
|
||||
/** 获取代码生成配置 */
|
||||
getGenConfig(tableName: string) {
|
||||
return request<any, GenConfigForm>({
|
||||
url: `${GENERATOR_BASE_URL}/${tableName}/config`,
|
||||
method: "get",
|
||||
});
|
||||
},
|
||||
|
||||
/** 获取代码生成配置 */
|
||||
saveGenConfig(tableName: string, data: GenConfigForm) {
|
||||
return request({
|
||||
url: `${GENERATOR_BASE_URL}/${tableName}/config`,
|
||||
method: "post",
|
||||
data,
|
||||
});
|
||||
},
|
||||
|
||||
/** 获取代码生成预览数据 */
|
||||
getPreviewData(tableName: string, pageType?: "classic" | "curd") {
|
||||
return request<any, GeneratorPreviewVo[]>({
|
||||
url: `${GENERATOR_BASE_URL}/${tableName}/preview`,
|
||||
method: "get",
|
||||
params: pageType ? { pageType } : undefined,
|
||||
});
|
||||
},
|
||||
|
||||
/** 重置代码生成配置 */
|
||||
resetGenConfig(tableName: string) {
|
||||
return request({
|
||||
url: `${GENERATOR_BASE_URL}/${tableName}/config`,
|
||||
method: "delete",
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 下载 ZIP 文件
|
||||
* @param url
|
||||
* @param fileName
|
||||
*/
|
||||
download(tableName: string, pageType?: "classic" | "curd") {
|
||||
return request({
|
||||
url: `${GENERATOR_BASE_URL}/${tableName}/download`,
|
||||
method: "get",
|
||||
params: pageType ? { pageType } : undefined,
|
||||
responseType: "blob",
|
||||
}).then((response) => {
|
||||
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);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default GeneratorAPI;
|
||||
|
||||
/** 代码生成预览对象 */
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
import request from "@/utils/request";
|
||||
import type { ConfigPageQuery, ConfigForm, ConfigPageVo } from "@/types/api";
|
||||
|
||||
const CONFIG_BASE_URL = "/api/v1/config";
|
||||
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;
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
import request from "@/utils/request";
|
||||
import type { DeptQuery, DeptVo, DeptForm } from "@/types/api";
|
||||
|
||||
const DEPT_BASE_URL = "/api/v1/dept";
|
||||
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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
import request from "@/utils/request";
|
||||
|
||||
const LOG_BASE_URL = "/api/v1/logs";
|
||||
|
||||
const LogAPI = {
|
||||
/** 获取日志分页列表 */
|
||||
getPage(queryParams: LogPageQuery) {
|
||||
return request<any, PageResult<LogPageVO[]>>({
|
||||
url: `${LOG_BASE_URL}/page`,
|
||||
method: "get",
|
||||
params: queryParams,
|
||||
});
|
||||
},
|
||||
/** 获取访问趋势 */
|
||||
getVisitTrend(queryParams: VisitTrendQuery) {
|
||||
return request<any, VisitTrendVO>({
|
||||
url: `${LOG_BASE_URL}/visit-trend`,
|
||||
method: "get",
|
||||
params: queryParams,
|
||||
});
|
||||
},
|
||||
/** 获取访问统计 */
|
||||
getVisitStats() {
|
||||
return request<any, VisitStatsVO>({ url: `${LOG_BASE_URL}/visit-stats`, method: "get" });
|
||||
},
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
export interface VisitTrendVO {
|
||||
/** 日期列表 */
|
||||
dates: string[];
|
||||
/** 浏览量(PV) */
|
||||
pvList: number[];
|
||||
/** 访客数(UV) */
|
||||
uvList: number[];
|
||||
/** IP数 */
|
||||
ipList: number[];
|
||||
}
|
||||
export interface VisitTrendQuery {
|
||||
/** 开始日期 */
|
||||
startDate: string;
|
||||
/** 结束日期 */
|
||||
endDate: string;
|
||||
}
|
||||
export interface VisitStatsVO {
|
||||
/** 今日访客数(UV) */
|
||||
todayUvCount: number;
|
||||
/** 总访客数 */
|
||||
totalUvCount: number;
|
||||
/** 访客数同比增长率(相对于昨天同一时间段的增长率) */
|
||||
uvGrowthRate: number;
|
||||
/** 今日浏览量(PV) */
|
||||
todayPvCount: number;
|
||||
/** 总浏览量 */
|
||||
totalPvCount: number;
|
||||
/** 同比增长率(相对于昨天同一时间段的增长率) */
|
||||
pvGrowthRate: number;
|
||||
}
|
||||
17
src/api/system/log.ts
Normal file
17
src/api/system/log.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
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[]>>({
|
||||
url: `${LOG_BASE_URL}/page`,
|
||||
method: "get",
|
||||
params: queryParams,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default LogAPI;
|
||||
@@ -1,135 +0,0 @@
|
||||
import request from "@/utils/request";
|
||||
const MENU_BASE_URL = "/api/v1/menus";
|
||||
|
||||
const MenuAPI = {
|
||||
/** 获取当前用户的路由列表 */
|
||||
getRoutes() {
|
||||
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 });
|
||||
},
|
||||
/** 获取菜单下拉数据源 */
|
||||
getOptions(onlyParent?: boolean) {
|
||||
return request<any, OptionType[]>({
|
||||
url: `${MENU_BASE_URL}/options`,
|
||||
method: "get",
|
||||
params: { onlyParent },
|
||||
});
|
||||
},
|
||||
/** 获取菜单表单数据 */
|
||||
getFormData(id: string) {
|
||||
return request<any, MenuForm>({ url: `${MENU_BASE_URL}/${id}/form`, method: "get" });
|
||||
},
|
||||
/** 新增菜单 */
|
||||
create(data: MenuForm) {
|
||||
return request({ url: `${MENU_BASE_URL}`, method: "post", data });
|
||||
},
|
||||
/** 修改菜单 */
|
||||
update(id: string, data: MenuForm) {
|
||||
return request({ url: `${MENU_BASE_URL}/${id}`, method: "put", data });
|
||||
},
|
||||
/** 删除菜单 */
|
||||
deleteById(id: string) {
|
||||
return request({ url: `${MENU_BASE_URL}/${id}`, method: "delete" });
|
||||
},
|
||||
};
|
||||
|
||||
export default MenuAPI;
|
||||
|
||||
export interface MenuQuery {
|
||||
/** 搜索关键字 */
|
||||
keywords?: string;
|
||||
}
|
||||
import type { MenuTypeEnum } from "@/enums/system/menu-enum";
|
||||
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;
|
||||
}
|
||||
41
src/api/system/menu.ts
Normal file
41
src/api/system/menu.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import request from "@/utils/request";
|
||||
import type { MenuQuery, MenuVo, MenuForm, RouteVo, OptionType } from "@/types/api";
|
||||
|
||||
const MENU_BASE_URL = "/api/v1/menus";
|
||||
|
||||
const MenuAPI = {
|
||||
/** 获取当前用户的路由列表 */
|
||||
getRoutes() {
|
||||
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 });
|
||||
},
|
||||
/** 获取菜单下拉数据源 */
|
||||
getOptions(onlyParent?: boolean) {
|
||||
return request<any, OptionType[]>({
|
||||
url: `${MENU_BASE_URL}/options`,
|
||||
method: "get",
|
||||
params: { onlyParent },
|
||||
});
|
||||
},
|
||||
/** 获取菜单表单数据 */
|
||||
getFormData(id: string) {
|
||||
return request<any, MenuForm>({ url: `${MENU_BASE_URL}/${id}/form`, method: "get" });
|
||||
},
|
||||
/** 新增菜单 */
|
||||
create(data: MenuForm) {
|
||||
return request({ url: `${MENU_BASE_URL}`, method: "post", data });
|
||||
},
|
||||
/** 修改菜单 */
|
||||
update(id: string, data: MenuForm) {
|
||||
return request({ url: `${MENU_BASE_URL}/${id}`, method: "put", data });
|
||||
},
|
||||
/** 删除菜单 */
|
||||
deleteById(id: string) {
|
||||
return request({ url: `${MENU_BASE_URL}/${id}`, method: "delete" });
|
||||
},
|
||||
};
|
||||
|
||||
export default MenuAPI;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
24
src/api/system/statistics.ts
Normal file
24
src/api/system/statistics.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
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>({
|
||||
url: `${STATISTICS_BASE_URL}/visits/trend`,
|
||||
method: "get",
|
||||
params: queryParams,
|
||||
});
|
||||
},
|
||||
/** 获取访问概览统计 */
|
||||
getVisitOverview() {
|
||||
return request<any, VisitStatsVo>({
|
||||
url: `${STATISTICS_BASE_URL}/visits/overview`,
|
||||
method: "get",
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default StatisticsAPI;
|
||||
103
src/api/system/tenant.ts
Normal file
103
src/api/system/tenant.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import request from "@/utils/request";
|
||||
import type {
|
||||
TenantCreateForm,
|
||||
TenantCreateResultVo,
|
||||
TenantForm,
|
||||
TenantInfo,
|
||||
TenantPageQuery,
|
||||
TenantPageVo,
|
||||
} from "@/types/api";
|
||||
|
||||
const TENANT_BASE_URL = "/api/v1/tenants";
|
||||
|
||||
/**
|
||||
* 租户信息
|
||||
*/
|
||||
|
||||
const TenantAPI = {
|
||||
/**
|
||||
* 获取当前用户可访问的租户列表
|
||||
*/
|
||||
getTenantList() {
|
||||
return request<any, TenantInfo[]>({
|
||||
url: `${TENANT_BASE_URL}`,
|
||||
method: "get",
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取当前租户信息
|
||||
*/
|
||||
getCurrentTenant() {
|
||||
return request<any, TenantInfo>({
|
||||
url: `${TENANT_BASE_URL}/current`,
|
||||
method: "get",
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 切换租户
|
||||
*
|
||||
* @param tenantId 目标租户ID
|
||||
*/
|
||||
switchTenant(tenantId: number) {
|
||||
return request<any, TenantInfo>({
|
||||
url: `${TENANT_BASE_URL}/${tenantId}/switch`,
|
||||
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;
|
||||
@@ -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
1
src/api/types.ts
Normal 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 |
@@ -3,71 +3,61 @@
|
||||
<defs>
|
||||
<style>
|
||||
@media (prefers-color-scheme: dark) {
|
||||
#bg-rect { fill: #101a29; }
|
||||
#blueGlow-rect { fill: url(#blueGlowDark); }
|
||||
#blueGlow2-rect { fill: url(#blueGlow2Dark); }
|
||||
#pinkPurpleGlow-rect { fill: url(#pinkPurpleGlowDark); }
|
||||
#bg-layer { fill: url(#bgDark); }
|
||||
#soft-glow { fill: url(#glowDark); }
|
||||
.accent-arc { stroke: rgba(118, 156, 255, 0.35); }
|
||||
.accent-dot { fill: rgba(154, 188, 255, 0.45); }
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- 亮色主题渐变 -->
|
||||
<linearGradient id="bgGradient" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stop-color="#f9fcff" />
|
||||
<stop offset="100%" stop-color="#f5f9fd" />
|
||||
<linearGradient id="bgLight" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#f3f7ff" />
|
||||
<stop offset="60%" stop-color="#e3edff" />
|
||||
<stop offset="100%" stop-color="#d6e7ff" />
|
||||
</linearGradient>
|
||||
|
||||
<!-- 中间区域淡蓝白光晕 -->
|
||||
<radialGradient id="blueGlow" cx="50%" cy="50%" r="70%" fx="50%" fy="50%">
|
||||
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.9" />
|
||||
<stop offset="50%" stop-color="#f0f8ff" stop-opacity="0.5" />
|
||||
<stop offset="100%" stop-color="#eef7fd" stop-opacity="0" />
|
||||
<linearGradient id="bgDark" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#0b1324" />
|
||||
<stop offset="60%" stop-color="#162135" />
|
||||
<stop offset="100%" stop-color="#1e2c44" />
|
||||
</linearGradient>
|
||||
|
||||
<radialGradient id="glowLight" cx="20%" cy="15%" r="60%">
|
||||
<stop offset="0%" stop-color="rgba(64,128,255,0.35)" />
|
||||
<stop offset="40%" stop-color="rgba(64,128,255,0.18)" />
|
||||
<stop offset="100%" stop-color="rgba(64,128,255,0)" />
|
||||
</radialGradient>
|
||||
|
||||
<!-- 左上角蓝白光晕 -->
|
||||
<radialGradient id="blueGlow2" cx="15%" cy="15%" r="40%" fx="15%" fy="15%">
|
||||
<stop offset="0%" stop-color="#d9efff" stop-opacity="0.85" />
|
||||
<stop offset="40%" stop-color="#e5f4fd" stop-opacity="0.6" />
|
||||
<stop offset="100%" stop-color="#e9f5fd" stop-opacity="0" />
|
||||
<radialGradient id="glowDark" cx="20%" cy="15%" r="60%">
|
||||
<stop offset="0%" stop-color="rgba(98,142,255,0.4)" />
|
||||
<stop offset="50%" stop-color="rgba(98,142,255,0.18)" />
|
||||
<stop offset="100%" stop-color="rgba(98,142,255,0)" />
|
||||
</radialGradient>
|
||||
|
||||
<!-- 右下角粉紫色光晕 -->
|
||||
<radialGradient id="pinkPurpleGlow" cx="85%" cy="85%" r="40%" fx="85%" fy="85%">
|
||||
<stop offset="0%" stop-color="#f7e6f9" stop-opacity="0.8" />
|
||||
<stop offset="35%" stop-color="#f9edf8" stop-opacity="0.6" />
|
||||
<stop offset="100%" stop-color="#f8f2f8" stop-opacity="0" />
|
||||
<radialGradient id="glowSecondary" cx="80%" cy="70%" r="55%">
|
||||
<stop offset="0%" stop-color="rgba(22,93,255,0.3)" />
|
||||
<stop offset="50%" stop-color="rgba(22,93,255,0.12)" />
|
||||
<stop offset="100%" stop-color="rgba(22,93,255,0)" />
|
||||
</radialGradient>
|
||||
|
||||
<!-- 暗色主题渐变 -->
|
||||
<radialGradient id="blueGlowDark" cx="50%" cy="50%" r="70%" fx="50%" fy="50%">
|
||||
<stop offset="0%" stop-color="#1e3a5e" stop-opacity="0.6" />
|
||||
<stop offset="50%" stop-color="#1c314e" stop-opacity="0.3" />
|
||||
<stop offset="100%" stop-color="#1a2d47" stop-opacity="0" />
|
||||
</radialGradient>
|
||||
|
||||
<!-- 左上角蓝白光晕 - 暗色模式 -->
|
||||
<radialGradient id="blueGlow2Dark" cx="15%" cy="15%" r="40%" fx="15%" fy="15%">
|
||||
<stop offset="0%" stop-color="#1e3858" stop-opacity="0.85" />
|
||||
<stop offset="40%" stop-color="#1a304f" stop-opacity="0.6" />
|
||||
<stop offset="100%" stop-color="#172b45" stop-opacity="0" />
|
||||
</radialGradient>
|
||||
|
||||
<!-- 右下角粉紫色光晕 - 暗色模式 -->
|
||||
<radialGradient id="pinkPurpleGlowDark" cx="85%" cy="85%" r="40%" fx="85%" fy="85%">
|
||||
<stop offset="0%" stop-color="#2e2335" stop-opacity="0.85" />
|
||||
<stop offset="35%" stop-color="#2a2035" stop-opacity="0.6" />
|
||||
<stop offset="100%" stop-color="#2a202d" stop-opacity="0" />
|
||||
</radialGradient>
|
||||
<linearGradient id="meshLight" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="rgba(255,255,255,0.6)" />
|
||||
<stop offset="35%" stop-color="rgba(255,255,255,0.2)" />
|
||||
<stop offset="100%" stop-color="rgba(255,255,255,0)" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- 背景层 -->
|
||||
<rect id="bg-rect" width="100%" height="100%" fill="url(#bgGradient)" />
|
||||
<rect id="bg-layer" width="100%" height="100%" fill="url(#bgLight)" />
|
||||
<rect id="soft-glow" width="100%" height="100%" fill="url(#glowLight)" />
|
||||
<rect width="100%" height="100%" fill="url(#glowSecondary)" />
|
||||
<rect width="100%" height="100%" fill="url(#meshLight)" />
|
||||
|
||||
<!-- 中间淡蓝白光晕 -->
|
||||
<rect id="blueGlow-rect" width="100%" height="100%" fill="url(#blueGlow)" />
|
||||
<!-- 柔和块面光影,替代明显线条 -->
|
||||
<g opacity="0.45">
|
||||
<rect x="-40" y="520" width="520" height="220" rx="180" fill="rgba(255,255,255,0.25)" />
|
||||
<rect x="760" y="90" width="520" height="210" rx="180" fill="rgba(255,255,255,0.22)" />
|
||||
<rect x="420" y="620" width="560" height="190" rx="180" fill="rgba(255,255,255,0.18)" />
|
||||
</g>
|
||||
|
||||
<!-- 左上蓝白光晕 -->
|
||||
<rect id="blueGlow2-rect" width="100%" height="100%" fill="url(#blueGlow2)" />
|
||||
|
||||
<!-- 右下粉紫光晕 -->
|
||||
<rect id="pinkPurpleGlow-rect" width="100%" height="100%" fill="url(#pinkPurpleGlow)" />
|
||||
<!-- 去掉点状噪声,仅保留大区域柔光 -->
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 2.5 KiB |
@@ -1,18 +1,30 @@
|
||||
<template>
|
||||
<template>
|
||||
<!-- 悬浮按钮 -->
|
||||
<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用户的姓名为测试人员",
|
||||
@@ -226,7 +343,7 @@ const handleExecute = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
// 路由配置映射表(支持扩展)
|
||||
// 路由配置映射表
|
||||
const routeConfig = [
|
||||
{ keywords: ["用户", "user", "user list"], path: "/system/user", name: "用户管理" },
|
||||
{ keywords: ["角色", "role"], path: "/system/role", name: "角色管理" },
|
||||
@@ -270,13 +387,13 @@ const extractKeywordFromCommand = (cmd: string): string => {
|
||||
const keywordsPattern = allKeywords.join("|");
|
||||
|
||||
const patterns = [
|
||||
new RegExp(`(?:查询|获取|搜索|查找|找).*?([^\\s,,。]+?)(?:的)?(?:${keywordsPattern})`, "i"),
|
||||
new RegExp(`(?:${keywordsPattern}).*?([^\\s,,。]+?)(?:的|信息|详情)?`, "i"),
|
||||
new RegExp(`(?:查询|获取|搜索|查找|找).*?([^\\s,。]+?)(?:的)?(?:${keywordsPattern})`, "i"),
|
||||
new RegExp(`(?:${keywordsPattern}).*?([^\\s,。]+?)(?:的|信息|详情)?`, "i"),
|
||||
new RegExp(
|
||||
`(?:姓名为|名字叫|叫做|名称为|名是|为)([^\\s,,。]+?)(?:的)?(?:${keywordsPattern})?`,
|
||||
`(?:姓名为|名字叫|叫做|名称为|名是|为)([^\\s,。]+?)(?:的)?(?:${keywordsPattern})?`,
|
||||
"i"
|
||||
),
|
||||
new RegExp(`([^\\s,,。]+?)(?:的)?(?:${keywordsPattern})(?:信息|详情)?`, "i"),
|
||||
new RegExp(`([^\\s,。]+?)(?:的)?(?:${keywordsPattern})(?:信息|详情)?`, "i"),
|
||||
];
|
||||
|
||||
for (const pattern of patterns) {
|
||||
@@ -530,7 +647,7 @@ const executeAction = async (action: AiAction) => {
|
||||
|
||||
// 关闭对话框
|
||||
handleClose();
|
||||
}, 800);
|
||||
}, 1000);
|
||||
} else if (action.type === "execute") {
|
||||
// 执行函数调用
|
||||
ElMessage.info("功能开发中,请前往 AI 命令助手页面体验完整功能");
|
||||
@@ -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 {
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
v-if="item.redirect === 'noredirect' || index === breadcrumbs.length - 1"
|
||||
class="color-gray-400"
|
||||
>
|
||||
{{ translateRouteTitle(item.meta.title) }}
|
||||
{{ translateRouteTitle(item.meta.title ?? "") }}
|
||||
</span>
|
||||
<a v-else @click.prevent="handleLink(item)">
|
||||
{{ translateRouteTitle(item.meta.title) }}
|
||||
{{ translateRouteTitle(item.meta.title ?? "") }}
|
||||
</a>
|
||||
</el-breadcrumb-item>
|
||||
</el-breadcrumb>
|
||||
@@ -18,7 +18,7 @@
|
||||
import { RouteLocationMatched } from "vue-router";
|
||||
import { compile } from "path-to-regexp";
|
||||
import router from "@/router";
|
||||
import { translateRouteTitle } from "@/utils/i18n";
|
||||
import { translateRouteTitle } from "@/lang/utils";
|
||||
|
||||
const currentRoute = useRoute();
|
||||
const pathCompile = (path: string) => {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<template>
|
||||
<template>
|
||||
<div
|
||||
class="rounded bg-[var(--el-bg-color)] border border-[var(--el-border-color)] p-5 h-full md:flex flex-1 flex-col md:overflow-auto"
|
||||
>
|
||||
<!-- 表格工具栏 -->
|
||||
<!-- 表格工具 -->
|
||||
<div class="flex flex-col md:flex-row justify-between gap-y-2.5 mb-2.5">
|
||||
<!-- 左侧工具栏 -->
|
||||
<!-- 左侧工具 -->
|
||||
<div class="toolbar-left flex gap-y-2.5 gap-x-2 md:gap-x-3 flex-wrap">
|
||||
<template v-for="(btn, index) in toolbarLeftBtn" :key="index">
|
||||
<el-button
|
||||
@@ -17,7 +17,7 @@
|
||||
</el-button>
|
||||
</template>
|
||||
</div>
|
||||
<!-- 右侧工具栏 -->
|
||||
<!-- 右侧工具 -->
|
||||
<div class="toolbar-right flex gap-y-2.5 gap-x-2 md:gap-x-3 flex-wrap">
|
||||
<template v-for="(btn, index) in toolbarRightBtn" :key="index">
|
||||
<el-popover v-if="btn.name === 'filter'" placement="bottom" trigger="click">
|
||||
@@ -62,7 +62,7 @@
|
||||
<el-image
|
||||
:src="item"
|
||||
:preview-src-list="scope.row[col.prop]"
|
||||
:initial-index="index"
|
||||
:initial-index="Number(index)"
|
||||
:preview-teleported="true"
|
||||
:style="`width: ${col.imageWidth ?? 40}px; height: ${col.imageHeight ?? 40}px`"
|
||||
/>
|
||||
@@ -78,7 +78,7 @@
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
<!-- 根据行的selectList属性返回对应列表值 -->
|
||||
<!-- 根据行的selectList属性返回对应列表 -->
|
||||
<template v-else-if="col.templet === 'list'">
|
||||
<template v-if="col.prop">
|
||||
{{ (col.selectList ?? {})[scope.row[col.prop]] }}
|
||||
@@ -92,7 +92,7 @@
|
||||
</el-link>
|
||||
</template>
|
||||
</template>
|
||||
<!-- 生成开关组件 -->
|
||||
<!-- 生成开关组 -->
|
||||
<template v-else-if="col.templet === 'switch'">
|
||||
<template v-if="col.prop">
|
||||
<!-- pageData.length>0: 解决el-switch组件会在表格初始化的时候触发一次change事件 -->
|
||||
@@ -111,7 +111,7 @@
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
<!-- 生成输入框组件 -->
|
||||
<!-- 生成输入框组 -->
|
||||
<template v-else-if="col.templet === 'input'">
|
||||
<template v-if="col.prop">
|
||||
<el-input
|
||||
@@ -125,7 +125,7 @@
|
||||
<!-- 格式化为价格 -->
|
||||
<template v-else-if="col.templet === 'price'">
|
||||
<template v-if="col.prop">
|
||||
{{ `${col.priceFormat ?? "¥"}${scope.row[col.prop]}` }}
|
||||
{{ `${col.priceFormat ?? ""}${scope.row[col.prop]}` }}
|
||||
</template>
|
||||
</template>
|
||||
<!-- 格式化为百分比 -->
|
||||
@@ -220,7 +220,7 @@
|
||||
<el-form-item label="工作表名" prop="sheetname">
|
||||
<el-input v-model="exportsFormData.sheetname" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="数据源" prop="origin">
|
||||
<el-form-item label="数据来源" prop="origin">
|
||||
<el-select v-model="exportsFormData.origin">
|
||||
<el-option label="当前数据 (当前页的数据)" :value="ExportsOriginEnum.CURRENT" />
|
||||
<el-option
|
||||
@@ -247,8 +247,8 @@
|
||||
<!-- 弹窗底部操作按钮 -->
|
||||
<template #footer>
|
||||
<div style="padding-right: var(--el-dialog-padding-primary)">
|
||||
<el-button type="primary" @click="handleExportsSubmit">确 定</el-button>
|
||||
<el-button @click="handleCloseExportsModal">取 消</el-button>
|
||||
<el-button type="primary" @click="handleExportsSubmit">确定</el-button>
|
||||
<el-button @click="handleCloseExportsModal">取消</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
@@ -270,7 +270,7 @@
|
||||
:model="importFormData"
|
||||
:rules="importFormRules"
|
||||
>
|
||||
<el-form-item label="文件名" prop="files">
|
||||
<el-form-item label="文件" prop="files">
|
||||
<el-upload
|
||||
ref="uploadRef"
|
||||
v-model:file-list="importFormData.files"
|
||||
@@ -283,7 +283,7 @@
|
||||
>
|
||||
<el-icon class="el-icon--upload"><upload-filled /></el-icon>
|
||||
<div class="el-upload__text">
|
||||
<span>将文件拖到此处,或</span>
|
||||
<span>将文件拖到此处,或点击上传</span>
|
||||
<em>点击上传</em>
|
||||
</div>
|
||||
<template #tip>
|
||||
@@ -312,9 +312,9 @@
|
||||
:disabled="importFormData.files.length === 0"
|
||||
@click="handleImportSubmit"
|
||||
>
|
||||
确 定
|
||||
确定
|
||||
</el-button>
|
||||
<el-button @click="handleCloseImportModal">取 消</el-button>
|
||||
<el-button @click="handleCloseImportModal">取消</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
@@ -427,7 +427,7 @@ const toolbarRightBtn = computed(() => {
|
||||
const tableToolbar = config.value.cols[config.value.cols.length - 1].operat ?? ["edit", "delete"];
|
||||
const tableToolbarBtn = createToolbar(tableToolbar, { link: true, size: "small" });
|
||||
|
||||
// 表格列
|
||||
// 表格相关
|
||||
const cols = ref(
|
||||
props.contentConfig.cols.map((col) => {
|
||||
if (col.initFn) {
|
||||
@@ -517,7 +517,7 @@ function handleDelete(id?: number | string) {
|
||||
.then(() => {
|
||||
ElMessage.success("删除成功");
|
||||
removeIds.value = [];
|
||||
//清空选中项
|
||||
// 清空选中项
|
||||
tableRef.value?.clearSelection();
|
||||
handleRefresh(true);
|
||||
})
|
||||
@@ -551,7 +551,7 @@ const exportsFormData = reactive({
|
||||
});
|
||||
const exportsFormRules: FormRules = {
|
||||
fields: [{ required: true, message: "请选择字段" }],
|
||||
origin: [{ required: true, message: "请选择数据源" }],
|
||||
origin: [{ required: true, message: "请选择数据来源" }],
|
||||
};
|
||||
// 打开导出弹窗
|
||||
function handleOpenExportsModal() {
|
||||
@@ -709,7 +709,7 @@ function handleImports() {
|
||||
fileReader.onload = (ev) => {
|
||||
if (ev.target !== null && ev.target.result !== null) {
|
||||
const result = ev.target.result as ArrayBuffer;
|
||||
// 从 buffer中加载数据解析
|
||||
// 从 buffer 中加载并解析数据
|
||||
workbook.xlsx
|
||||
.load(result)
|
||||
.then((workbook) => {
|
||||
@@ -753,7 +753,7 @@ function handleImports() {
|
||||
};
|
||||
}
|
||||
|
||||
// 操作栏
|
||||
// 操作人"
|
||||
function handleToolbar(name: string) {
|
||||
switch (name) {
|
||||
case "refresh":
|
||||
@@ -786,7 +786,7 @@ function handleToolbar(name: string) {
|
||||
}
|
||||
}
|
||||
|
||||
// 操作列
|
||||
// 操作人"
|
||||
function handleOperate(data: IOperateData) {
|
||||
switch (data.name) {
|
||||
case "delete":
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<template>
|
||||
<template>
|
||||
<div>
|
||||
<!-- drawer -->
|
||||
<template v-if="modalConfig.component === 'drawer'">
|
||||
@@ -59,8 +59,8 @@
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button v-if="!formDisable" type="primary" @click="handleSubmit">确 定</el-button>
|
||||
<el-button @click="handleClose">{{ !formDisable ? "取 消" : "关闭" }}</el-button>
|
||||
<el-button v-if="!formDisable" type="primary" @click="handleSubmit">确定</el-button>
|
||||
<el-button @click="handleClose">关闭</el-button>
|
||||
</template>
|
||||
</el-drawer>
|
||||
</template>
|
||||
@@ -124,8 +124,8 @@
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button v-if="!formDisable" type="primary" @click="handleSubmit">确 定</el-button>
|
||||
<el-button @click="handleClose">{{ !formDisable ? "取 消" : "关闭" }}</el-button>
|
||||
<el-button v-if="!formDisable" type="primary" @click="handleSubmit">确定</el-button>
|
||||
<el-button @click="handleClose">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
@@ -144,7 +144,7 @@ defineSlots<{ [key: string]: (_args: any) => any }>();
|
||||
const props = defineProps<{ modalConfig: IModalConfig }>();
|
||||
// 自定义事件
|
||||
const emit = defineEmits<{ submitClick: []; customSubmit: [queryParams: IObject] }>();
|
||||
// 组件映射表
|
||||
// 组件映射
|
||||
|
||||
const componentMap = new Map<IComponentType, any>([
|
||||
// @ts-ignore
|
||||
@@ -173,14 +173,14 @@ const childrenMap = new Map<IComponentType, any>([
|
||||
]);
|
||||
|
||||
const pk = props.modalConfig.pk ?? "id"; // 主键名,用于表单数据处理
|
||||
const modalVisible = ref(false); // 弹窗显示状态
|
||||
const modalVisible = ref(false); // 弹窗显示状态"
|
||||
const formRef = ref<FormInstance>(); // 表单实例
|
||||
const formItems = reactive(props.modalConfig.formItems ?? []); // 表单配置项
|
||||
const formItems = reactive(props.modalConfig.formItems ?? []); // 表单配置项"
|
||||
const formData = reactive<IObject>({}); // 表单数据
|
||||
const formRules: FormRules = {}; // 表单验证规则
|
||||
const formDisable = ref(false); // 表单禁用状态
|
||||
const formDisable = ref(false); // 表单禁用状态"
|
||||
|
||||
// 获取tooltip提示框属性
|
||||
// 获取 tooltip 提示框属性
|
||||
const getTooltipProps = (tips: string | IObject) => {
|
||||
return typeof tips === "string" ? { content: tips } : tips;
|
||||
};
|
||||
@@ -189,7 +189,7 @@ const handleClose = () => {
|
||||
modalVisible.value = false;
|
||||
formRef.value?.resetFields();
|
||||
};
|
||||
// 设置表单值
|
||||
// 设置表单项
|
||||
const setFormData = (data: IObject) => {
|
||||
for (const key in formData) {
|
||||
if (Object.prototype.hasOwnProperty.call(formData, key) && key in data) {
|
||||
@@ -245,11 +245,11 @@ onMounted(() => {
|
||||
// 暴露的属性和方法
|
||||
defineExpose({
|
||||
setFormData,
|
||||
// 展示/因此 modal
|
||||
// 展示/隐藏 modal
|
||||
setModalVisible: (visible: boolean = true) => (modalVisible.value = visible),
|
||||
// 获取表单数据
|
||||
getFormData: (key: string) => formData[key] ?? formData,
|
||||
// 设置表单项值
|
||||
// 设置表单项
|
||||
setFormItemData: (key: string, value: any) => (formData[key] = value),
|
||||
// 禁用表单
|
||||
handleDisabled: (disable: boolean) => {
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<!-- 自定义插槽 -->
|
||||
<!-- 自定义æ<EFBFBD>’æ§?-->
|
||||
<slot
|
||||
v-if="item.type === 'custom'"
|
||||
:name="item.slotName"
|
||||
@@ -71,14 +71,14 @@ import { ArrowUp, ArrowDown } from "@element-plus/icons-vue";
|
||||
import type { FormInstance } from "element-plus";
|
||||
import InputTag from "@/components/InputTag/index.vue";
|
||||
|
||||
// 定义接收的属性
|
||||
// 定义接收的属�
|
||||
const props = defineProps<{ searchConfig: ISearchConfig }>();
|
||||
// 自定义事件
|
||||
// 自定义事�
|
||||
const emit = defineEmits<{
|
||||
queryClick: [queryParams: IObject];
|
||||
resetClick: [queryParams: IObject];
|
||||
}>();
|
||||
// 组件映射表
|
||||
// ç»„ä»¶æ˜ å°„è¡?
|
||||
const componentMap = new Map<ISearchComponent, any>([
|
||||
// @ts-ignore
|
||||
["input", markRaw(ElInput)], // @ts-ignore
|
||||
@@ -105,7 +105,7 @@ const formItems = reactive(props.searchConfig?.formItems ?? []);
|
||||
const isExpandable = ref(props.searchConfig?.isExpandable ?? true);
|
||||
// 是å<C2AF>¦å·²å±•å¼€
|
||||
const isExpand = ref(false);
|
||||
// 表单项展示数量,若可展开,超出展示数量的表单项隐藏
|
||||
// 表å<EFBFBD>•项展示数é‡<EFBFBD>,若å<EFBFBD>¯å±•开,超出展示数é‡<EFBFBD>的表å<EFBFBD>•项éš<EFBFBD>è—?
|
||||
const showNumber = computed(() =>
|
||||
isExpandable.value ? (props.searchConfig?.showNumber ?? 3) : formItems.length
|
||||
);
|
||||
@@ -113,7 +113,7 @@ const showNumber = computed(() =>
|
||||
const cardAttrs = computed<IObject>(() => {
|
||||
return { shadow: "never", style: { "margin-bottom": "12px" }, ...props.searchConfig?.cardAttrs };
|
||||
});
|
||||
// 表单组件自定义属性(label位置、宽度、对齐方式等)
|
||||
// 表å<EFBFBD>•组件自定义属性(labelä½<EFBFBD>ç½®ã€<EFBFBD>宽度ã€<EFBFBD>对é½<EFBFBD>æ–¹å¼<EFBFBD>ç‰ï¼?
|
||||
const formAttrs = computed<IForm>(() => {
|
||||
return { inline: true, ...props.searchConfig?.form };
|
||||
});
|
||||
@@ -124,7 +124,7 @@ const isGrid = computed(() =>
|
||||
: "flex flex-wrap gap-x-8 gap-y-4"
|
||||
);
|
||||
|
||||
// 获取tooltip提示框属性
|
||||
// 获å<EFBFBD>–tooltipæ<EFBFBD><EFBFBD>示框属æ€?
|
||||
const getTooltipProps = (tips: string | IObject) => {
|
||||
return typeof tips === "string" ? { content: tips } : tips;
|
||||
};
|
||||
|
||||
@@ -23,7 +23,7 @@ type ToolbarTable = "edit" | "view" | "delete";
|
||||
export type IToolsButton = {
|
||||
name: string; // 按钮名称
|
||||
text?: string; // 按钮文本
|
||||
perm?: Array<string> | string; // 权限标识(可以是完整权限字符串如'sys:user:add'或操作权限如'add')
|
||||
perm?: Array<string> | string; // 权限标识(可以是完整权限字符串如'sys:user:create'或操作权限如'create')
|
||||
attrs?: Partial<ButtonProps> & { style?: CSSProperties }; // 按钮属性
|
||||
render?: (row: IObject) => boolean; // 条件渲染
|
||||
};
|
||||
@@ -59,7 +59,7 @@ export interface IContentConfig<T = any> {
|
||||
// 权限前缀(如sys:user,用于组成权限标识),不提供则不进行权限校验
|
||||
permPrefix?: string;
|
||||
// table组件属性
|
||||
table?: Omit<TableProps<any>, "data">;
|
||||
table?: Partial<Omit<TableProps<any>, "data">>;
|
||||
// 分页组件位置(默认:left)
|
||||
pagePosition?: "left" | "right";
|
||||
// pagination组件属性
|
||||
|
||||
66
src/components/CommandPalette/index.vue
Normal file
66
src/components/CommandPalette/index.vue
Normal file
@@ -0,0 +1,66 @@
|
||||
<template>
|
||||
<div>
|
||||
<el-button type="text" icon="Search" aria-label="打开搜索面板" @click="open" />
|
||||
|
||||
<el-dialog v-model="visible" width="640px" :close-on-click-modal="true" @close="close">
|
||||
<template #title>
|
||||
<div class="flex items-center gap-2">
|
||||
<el-icon><Search /></el-icon>
|
||||
<span>搜索菜单</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div>
|
||||
<el-input
|
||||
ref="inputRef"
|
||||
v-model="keyword"
|
||||
placeholder="输入菜单名称或关键字搜索"
|
||||
@input="onSearch"
|
||||
@keydown.enter.prevent="onSelect"
|
||||
/>
|
||||
|
||||
<div class="mt-3">
|
||||
<div
|
||||
v-if="results.length === 0 && history.length === 0"
|
||||
class="text-center text-gray-500"
|
||||
>
|
||||
没有搜索历史
|
||||
</div>
|
||||
|
||||
<ul v-else class="space-y-2">
|
||||
<li
|
||||
v-for="(item, idx) in results.length ? results : history"
|
||||
:key="item.path + idx"
|
||||
class="p-2 hover:bg-gray-100 cursor-pointer rounded"
|
||||
@click="onGo(item)"
|
||||
>
|
||||
<div>{{ item.title }}</div>
|
||||
<div class="text-sm text-gray-400">{{ item.path }}</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div style="text-align: right">
|
||||
<el-button @click="close">关闭</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from "vue";
|
||||
import { Search } from "@element-plus/icons-vue";
|
||||
import { useCommandPalette } from "./useCommandPalette";
|
||||
|
||||
const { visible, keyword, results, history, inputRef, open, close, onSearch, onSelect, onGo } =
|
||||
useCommandPalette();
|
||||
|
||||
onMounted(() => {
|
||||
// no-op for now
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
208
src/components/CommandPalette/useCommandPalette.ts
Normal file
208
src/components/CommandPalette/useCommandPalette.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* 菜单搜索逻辑
|
||||
*/
|
||||
import { ref, onMounted, onBeforeUnmount, toRaw } from "vue";
|
||||
import { RouteRecordRaw, LocationQueryRaw } from "vue-router";
|
||||
import router from "@/router";
|
||||
import { usePermissionStore } from "@/store";
|
||||
import { isExternal } from "@/utils";
|
||||
|
||||
/** 搜索项类型 */
|
||||
interface SearchItem {
|
||||
title: string;
|
||||
path: string;
|
||||
name?: string;
|
||||
icon?: string;
|
||||
redirect?: string;
|
||||
params?: LocationQueryRaw;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = "menu_search_history";
|
||||
const MAX_HISTORY = 5;
|
||||
|
||||
export function useCommandPalette() {
|
||||
const permissionStore = usePermissionStore();
|
||||
|
||||
// 状态
|
||||
const visible = ref(false);
|
||||
const keyword = ref("");
|
||||
const activeIndex = ref(-1);
|
||||
const inputRef = ref<HTMLInputElement>();
|
||||
const menuItems = ref<SearchItem[]>([]);
|
||||
const results = ref<SearchItem[]>([]);
|
||||
const history = ref<SearchItem[]>([]);
|
||||
|
||||
// 排除的路由
|
||||
const excludedPaths = ["/redirect", "/login", "/401", "/404"];
|
||||
|
||||
// ============================================
|
||||
// 弹窗控制
|
||||
// ============================================
|
||||
|
||||
function open() {
|
||||
keyword.value = "";
|
||||
results.value = [];
|
||||
activeIndex.value = -1;
|
||||
visible.value = true;
|
||||
setTimeout(() => inputRef.value?.focus(), 100);
|
||||
}
|
||||
|
||||
function close() {
|
||||
visible.value = false;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 搜索逻辑
|
||||
// ============================================
|
||||
|
||||
function onSearch() {
|
||||
activeIndex.value = -1;
|
||||
if (!keyword.value.trim()) {
|
||||
results.value = [];
|
||||
return;
|
||||
}
|
||||
const kw = keyword.value.toLowerCase();
|
||||
results.value = menuItems.value.filter((item) => item.title.toLowerCase().includes(kw));
|
||||
}
|
||||
|
||||
function onSelect() {
|
||||
if (results.value.length > 0 && activeIndex.value >= 0) {
|
||||
onGo(results.value[activeIndex.value]);
|
||||
}
|
||||
}
|
||||
|
||||
function onNavigate(direction: "up" | "down") {
|
||||
if (results.value.length === 0) return;
|
||||
|
||||
if (direction === "up") {
|
||||
activeIndex.value = activeIndex.value <= 0 ? results.value.length - 1 : activeIndex.value - 1;
|
||||
} else {
|
||||
activeIndex.value = activeIndex.value >= results.value.length - 1 ? 0 : activeIndex.value + 1;
|
||||
}
|
||||
}
|
||||
|
||||
function onGo(item: SearchItem) {
|
||||
close();
|
||||
addHistory(item);
|
||||
|
||||
if (isExternal(item.path)) {
|
||||
window.open(item.path, "_blank");
|
||||
} else {
|
||||
router.push({ path: item.path, query: item.params });
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 历史记录
|
||||
// ============================================
|
||||
|
||||
function loadHistory() {
|
||||
try {
|
||||
const data = localStorage.getItem(STORAGE_KEY);
|
||||
history.value = data ? JSON.parse(data) : [];
|
||||
} catch {
|
||||
history.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
function saveHistory() {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(history.value));
|
||||
}
|
||||
|
||||
function addHistory(item: SearchItem) {
|
||||
// 去重
|
||||
const idx = history.value.findIndex((i) => i.path === item.path);
|
||||
if (idx !== -1) history.value.splice(idx, 1);
|
||||
|
||||
// 添加到开头
|
||||
history.value.unshift(item);
|
||||
|
||||
// 限制数量
|
||||
if (history.value.length > MAX_HISTORY) {
|
||||
history.value = history.value.slice(0, MAX_HISTORY);
|
||||
}
|
||||
|
||||
saveHistory();
|
||||
}
|
||||
|
||||
function removeHistory(index: number) {
|
||||
history.value.splice(index, 1);
|
||||
saveHistory();
|
||||
}
|
||||
|
||||
function clearHistory() {
|
||||
history.value = [];
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 路由解析
|
||||
// ============================================
|
||||
|
||||
function loadRoutes(routes: RouteRecordRaw[], parentPath = "") {
|
||||
routes.forEach((route) => {
|
||||
const path = route.path.startsWith("/")
|
||||
? route.path
|
||||
: `${parentPath}${parentPath.endsWith("/") ? "" : "/"}${route.path}`;
|
||||
|
||||
if (excludedPaths.includes(route.path) || isExternal(route.path)) return;
|
||||
|
||||
if (route.children) {
|
||||
loadRoutes(route.children, path);
|
||||
} else if (route.meta?.title) {
|
||||
menuItems.value.push({
|
||||
title: route.meta.title === "dashboard" ? "首页" : route.meta.title,
|
||||
path,
|
||||
name: typeof route.name === "string" ? route.name : undefined,
|
||||
icon: route.meta.icon,
|
||||
redirect: typeof route.redirect === "string" ? route.redirect : undefined,
|
||||
params: route.meta.params
|
||||
? JSON.parse(JSON.stringify(toRaw(route.meta.params)))
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 快捷键
|
||||
// ============================================
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") {
|
||||
e.preventDefault();
|
||||
open();
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 生命周期
|
||||
// ============================================
|
||||
|
||||
onMounted(() => {
|
||||
loadRoutes(permissionStore.routes);
|
||||
loadHistory();
|
||||
document.addEventListener("keydown", handleKeydown);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener("keydown", handleKeydown);
|
||||
});
|
||||
|
||||
return {
|
||||
visible,
|
||||
keyword,
|
||||
results,
|
||||
history,
|
||||
activeIndex,
|
||||
inputRef,
|
||||
open,
|
||||
close,
|
||||
onSearch,
|
||||
onSelect,
|
||||
onNavigate,
|
||||
onGo,
|
||||
removeHistory,
|
||||
clearHistory,
|
||||
};
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
<template>
|
||||
<div cursor-pointer flex-center rounded class="el" :class="padding">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
defineProps({
|
||||
padding: {
|
||||
type: String,
|
||||
default: "p-2",
|
||||
},
|
||||
});
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
.el {
|
||||
transition: 0.3s var(--el-transition-function-ease-in-out-bezier);
|
||||
&:hover {
|
||||
background-color: var(--el-fill-color);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -90,7 +90,7 @@ const selectedValue = ref<any>(
|
||||
: undefined
|
||||
);
|
||||
|
||||
// 监听 modelValue 和 options 的变化
|
||||
// 监å<EFBFBD>¬ modelValue å’?options çš„å<EFBFBD>˜åŒ?
|
||||
watch(
|
||||
[() => props.modelValue, () => options.value],
|
||||
([newValue, newOptions]) => {
|
||||
@@ -6,27 +6,30 @@
|
||||
<span>{{ label }}</span>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useDictStore } from "@/store";
|
||||
|
||||
const props = defineProps({
|
||||
code: String, // 字典编码
|
||||
modelValue: [String, Number], // 字典项的值
|
||||
modelValue: [String, Number], // å—典项的å€?
|
||||
size: {
|
||||
type: String,
|
||||
default: "default", // 标签大小
|
||||
},
|
||||
});
|
||||
|
||||
const label = ref("");
|
||||
const tagType = ref<"success" | "warning" | "info" | "primary" | "danger" | undefined>(); // 标签类型
|
||||
const tagSize = ref<"default" | "large" | "small">(props.size as "default" | "large" | "small"); // 标签大小
|
||||
|
||||
const dictStore = useDictStore();
|
||||
|
||||
/**
|
||||
* 根据字典项的值获取对应的 label 和 tagType
|
||||
* æ ¹æ<EFBFBD>®å—典项的值获å<EFBFBD>–对应的 label å’?tagType
|
||||
* @param dictCode 字典编码
|
||||
* @param value 字典项的值
|
||||
* @returns 包含 label 和 tagType 的对象
|
||||
* @param value å—典项的å€?
|
||||
* @returns 包å<EFBFBD>« label å’?tagType 的对è±?
|
||||
*/
|
||||
const getLabelAndTagByValue = async (dictCode: string, value: any) => {
|
||||
// 按需加载字典数据
|
||||
@@ -40,8 +43,9 @@ const getLabelAndTagByValue = async (dictCode: string, value: any) => {
|
||||
tagType: dictItem?.tagType,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 更新 label 和 tagType
|
||||
* æ›´æ–° label å’?tagType
|
||||
*/
|
||||
const updateLabelAndTag = async () => {
|
||||
if (!props.code || props.modelValue === undefined) return;
|
||||
@@ -1,12 +1,12 @@
|
||||
<!--
|
||||
* 基于 ECharts 的 Vue3 图表组件
|
||||
* 版权所有 © 2021-present 有来开源组织
|
||||
* 基于 ECharts �Vue3 图表组件
|
||||
* 版æ<EFBFBD>ƒæ‰€æœ?© 2021-present 有æ<EFBFBD>¥å¼€æº<EFBFBD>组ç»?
|
||||
*
|
||||
* å¼€æº<EFBFBD>å<EFBFBD><EFBFBD>议:https://opensource.org/licenses/MIT
|
||||
* 项目地å<EFBFBD>€ï¼šhttps://gitee.com/youlaiorg/vue3-element-admin
|
||||
* å<EFBFBD>‚考:https://echarts.apache.org/handbook/zh/basics/import/#%E6%8C%89%E9%9C%80%E5%BC%95%E5%85%A5-echarts-%E5%9B%BE%E8%A1%A8%E5%92%8C%E7%BB%84%E4%BB%B6
|
||||
*
|
||||
* 在使用时,请保留此注释,感谢您对开源的支持!
|
||||
* 在使用时,请ä¿<EFBFBD>ç•™æ¤æ³¨é‡Šï¼Œæ„Ÿè°¢æ‚¨å¯¹å¼€æº<EFBFBD>的支æŒ<EFBFBD>ï¼?
|
||||
-->
|
||||
|
||||
<template>
|
||||
@@ -14,13 +14,13 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// 引入 echarts 核心模块,核心模块提供了 echarts 使用必须要的接口。
|
||||
// 引入 echarts æ ¸å¿ƒæ¨¡å<EFBFBD>—ï¼Œæ ¸å¿ƒæ¨¡å<EFBFBD>—æ<EFBFBD><EFBFBD>供了 echarts 使用必须è¦<C3A8>的接å<C2A5>£ã€?
|
||||
import * as echarts from "echarts/core";
|
||||
// 引入柱状ã€<C3A3>折线和饼图常用图表
|
||||
import { BarChart, LineChart, PieChart } from "echarts/charts";
|
||||
// 引入标题,提示框,直角坐标系,数据集,内置数据转换器组件,
|
||||
// å¼•å…¥æ ‡é¢˜ï¼Œæ<EFBFBD><EFBFBD>示框,直角å<EFBFBD><EFBFBD>æ ‡ç³»ï¼Œæ•°æ<EFBFBD>®é›†ï¼Œå†…置数æ<EFBFBD>®è½¬æ<EFBFBD>¢å™¨ç»„ä»¶ï¼?
|
||||
import { GridComponent, TooltipComponent, LegendComponent } from "echarts/components";
|
||||
// 引入 Canvas 渲染器,注意引入 CanvasRenderer 或者 SVGRenderer 是必须的一步
|
||||
// 引入 Canvas 渲染器,注æ„<EFBFBD>引入 CanvasRenderer 或è€?SVGRenderer 是必须的一æ?
|
||||
import { CanvasRenderer } from "echarts/renderers";
|
||||
|
||||
import { useResizeObserver } from "@vueuse/core";
|
||||
@@ -45,7 +45,7 @@ const props = defineProps<{
|
||||
const chartRef = ref<HTMLDivElement | null>(null);
|
||||
let chartInstance: echarts.ECharts | null = null;
|
||||
|
||||
// 初始化图表
|
||||
// åˆ<EFBFBD>始化图è¡?
|
||||
const initChart = () => {
|
||||
if (chartRef.value) {
|
||||
chartInstance = echarts.init(chartRef.value);
|
||||
@@ -55,12 +55,12 @@ const initChart = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// 监听尺寸变化,自动调整
|
||||
// 监å<EFBFBD>¬å°ºå¯¸å<EFBFBD>˜åŒ–,自动调æ•?
|
||||
useResizeObserver(chartRef, () => {
|
||||
chartInstance?.resize();
|
||||
});
|
||||
|
||||
// 监听 options 变化,更新图表
|
||||
// 监å<EFBFBD>¬ options å<EFBFBD>˜åŒ–,更新图è¡?
|
||||
watch(
|
||||
() => props.options,
|
||||
(newOptions) => {
|
||||
|
||||
@@ -6,8 +6,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useSettingsStore } from "@/store";
|
||||
import { ThemeMode, SidebarColor } from "@/enums/settings/theme-enum";
|
||||
import { LayoutMode } from "@/enums/settings/layout-enum";
|
||||
import { ThemeMode, SidebarColor, LayoutMode } from "@/enums/settings";
|
||||
|
||||
defineProps({
|
||||
isActive: { type: Boolean, required: true },
|
||||
@@ -24,7 +23,7 @@ const hamburgerClass = computed(() => {
|
||||
return "hamburger--white";
|
||||
}
|
||||
|
||||
// 如果是混合布局 && 侧边栏配色方案是经典蓝
|
||||
// 如果是混å<EFBFBD>ˆå¸ƒå±€ && ä¾§è¾¹æ <C3A6>é…<C3A9>色方案是ç»<C3A7>å…¸è“?
|
||||
if (
|
||||
layout.value === LayoutMode.MIX &&
|
||||
settingsStore.sidebarColorScheme === SidebarColor.CLASSIC_BLUE
|
||||
|
||||
@@ -159,7 +159,7 @@ onClickOutside(iconSelectRef, () => (popoverVisible.value = false), {
|
||||
});
|
||||
|
||||
/**
|
||||
* 清空已选图标
|
||||
* 清空已选图æ ?
|
||||
*/
|
||||
function clearSelectedIcon() {
|
||||
selectedIcon.value = "";
|
||||
|
||||
@@ -17,8 +17,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useAppStore } from "@/store/modules/app-store";
|
||||
import { LanguageEnum } from "@/enums/settings/locale-enum";
|
||||
import { useAppStore } from "@/store/modules/app";
|
||||
import { LanguageEnum } from "@/enums/settings";
|
||||
|
||||
defineProps({
|
||||
size: {
|
||||
@@ -38,7 +38,7 @@ const { locale, t } = useI18n();
|
||||
/**
|
||||
* 处ç<E2809E>†è¯è¨€åˆ‡æ<E280A1>¢
|
||||
*
|
||||
* @param lang 语言(zh-cn、en)
|
||||
* @param lang è¯è¨€ï¼ˆzh-cnã€<C3A3>enï¼?
|
||||
*/
|
||||
function handleLanguageChange(lang: string) {
|
||||
locale.value = lang;
|
||||
|
||||
@@ -1,523 +0,0 @@
|
||||
<template>
|
||||
<div @click="openSearchModal">
|
||||
<div class="i-svg:search" />
|
||||
<el-dialog
|
||||
v-model="isModalVisible"
|
||||
width="30%"
|
||||
:append-to-body="true"
|
||||
:show-close="false"
|
||||
@close="closeSearchModal"
|
||||
>
|
||||
<template #header>
|
||||
<el-input
|
||||
ref="searchInputRef"
|
||||
v-model="searchKeyword"
|
||||
size="large"
|
||||
placeholder="输入菜单名称关键字搜索"
|
||||
clearable
|
||||
@keyup.enter="selectActiveResult"
|
||||
@input="updateSearchResults"
|
||||
@keydown.up.prevent="navigateResults('up')"
|
||||
@keydown.down.prevent="navigateResults('down')"
|
||||
@keydown.esc="closeSearchModal"
|
||||
>
|
||||
<template #prepend>
|
||||
<el-button icon="Search" />
|
||||
</template>
|
||||
</el-input>
|
||||
</template>
|
||||
|
||||
<div class="search-result">
|
||||
<!-- 搜索历史 -->
|
||||
<template v-if="searchKeyword === '' && searchHistory.length > 0">
|
||||
<div class="search-history">
|
||||
<div class="search-history__title">
|
||||
搜索历史
|
||||
<el-button
|
||||
type="primary"
|
||||
text
|
||||
size="small"
|
||||
class="search-history__clear"
|
||||
@click="clearHistory"
|
||||
>
|
||||
<el-icon><Delete /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
<ul class="search-history__list">
|
||||
<li
|
||||
v-for="(item, index) in searchHistory"
|
||||
:key="index"
|
||||
class="search-history__item"
|
||||
@click="navigateToRoute(item)"
|
||||
>
|
||||
<div class="search-history__icon">
|
||||
<el-icon><Clock /></el-icon>
|
||||
</div>
|
||||
<span class="search-history__name">{{ item.title }}</span>
|
||||
<div class="search-history__action">
|
||||
<el-icon @click.stop="removeHistoryItem(index)"><Close /></el-icon>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 搜索结果 -->
|
||||
<template v-else>
|
||||
<ul v-if="displayResults.length > 0">
|
||||
<li
|
||||
v-for="(item, index) in displayResults"
|
||||
:key="item.path"
|
||||
:class="[
|
||||
'search-result__item',
|
||||
{
|
||||
'search-result__item--active': index === activeIndex,
|
||||
},
|
||||
]"
|
||||
@click="navigateToRoute(item)"
|
||||
>
|
||||
<el-icon v-if="item.icon && item.icon.startsWith('el-icon')">
|
||||
<component :is="item.icon.replace('el-icon-', '')" />
|
||||
</el-icon>
|
||||
<div v-else-if="item.icon" :class="`i-svg:${item.icon}`" />
|
||||
<div v-else class="i-svg:menu" />
|
||||
<span class="ml-2">{{ item.title }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<!-- 无搜索历史显示 -->
|
||||
<div v-if="searchKeyword === '' && searchHistory.length === 0" class="no-history">
|
||||
<p class="no-history__text">没有搜索历史</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<div class="ctrl-k-hint">
|
||||
<span class="ctrl-k-text">Ctrl+K 快速打开</span>
|
||||
</div>
|
||||
<div class="shortcuts-group">
|
||||
<div class="key-box">
|
||||
<div class="key-btn">选择</div>
|
||||
</div>
|
||||
<div class="arrow-box">
|
||||
<div class="arrow-up-down">
|
||||
<div class="key-btn">
|
||||
<div class="i-svg:up" />
|
||||
</div>
|
||||
<div class="key-btn ml-1">
|
||||
<div class="i-svg:down" />
|
||||
</div>
|
||||
</div>
|
||||
<span class="key-text">切换</span>
|
||||
</div>
|
||||
<div class="key-box">
|
||||
<div class="key-btn esc-btn">ESC</div>
|
||||
<span class="key-text">关闭</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import router from "@/router";
|
||||
import { usePermissionStore } from "@/store";
|
||||
import { isExternal } from "@/utils";
|
||||
import { RouteRecordRaw, LocationQueryRaw } from "vue-router";
|
||||
import { Clock, Close, Delete } from "@element-plus/icons-vue";
|
||||
|
||||
const HISTORY_KEY = "menu_search_history";
|
||||
const MAX_HISTORY = 5;
|
||||
|
||||
const permissionStore = usePermissionStore();
|
||||
const isModalVisible = ref(false);
|
||||
const searchKeyword = ref("");
|
||||
const searchInputRef = ref();
|
||||
const excludedRoutes = ref(["/redirect", "/login", "/401", "/404"]);
|
||||
const menuItems = ref<SearchItem[]>([]);
|
||||
const searchResults = ref<SearchItem[]>([]);
|
||||
const activeIndex = ref(-1);
|
||||
const searchHistory = ref<SearchItem[]>([]);
|
||||
|
||||
interface SearchItem {
|
||||
title: string;
|
||||
path: string;
|
||||
name?: string;
|
||||
icon?: string;
|
||||
redirect?: string;
|
||||
params?: LocationQueryRaw;
|
||||
}
|
||||
|
||||
// 从本地存储加载搜索历史
|
||||
function loadSearchHistory() {
|
||||
const historyStr = localStorage.getItem(HISTORY_KEY);
|
||||
if (historyStr) {
|
||||
try {
|
||||
searchHistory.value = JSON.parse(historyStr);
|
||||
} catch {
|
||||
searchHistory.value = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 保存搜索历史到本地存储
|
||||
function saveSearchHistory() {
|
||||
localStorage.setItem(HISTORY_KEY, JSON.stringify(searchHistory.value));
|
||||
}
|
||||
|
||||
// 添加项目到搜索历史
|
||||
function addToHistory(item: SearchItem) {
|
||||
// 检查是否已存在
|
||||
const index = searchHistory.value.findIndex((i) => i.path === item.path);
|
||||
|
||||
// 如果存在则移除
|
||||
if (index !== -1) {
|
||||
searchHistory.value.splice(index, 1);
|
||||
}
|
||||
|
||||
// 添加到历史开头
|
||||
searchHistory.value.unshift(item);
|
||||
|
||||
// 限制历史记录数量
|
||||
if (searchHistory.value.length > MAX_HISTORY) {
|
||||
searchHistory.value = searchHistory.value.slice(0, MAX_HISTORY);
|
||||
}
|
||||
|
||||
// 保存到本地存储
|
||||
saveSearchHistory();
|
||||
}
|
||||
|
||||
// 移除历史记录项
|
||||
function removeHistoryItem(index: number) {
|
||||
searchHistory.value.splice(index, 1);
|
||||
saveSearchHistory();
|
||||
}
|
||||
|
||||
// 清空历史记录
|
||||
function clearHistory() {
|
||||
searchHistory.value = [];
|
||||
localStorage.removeItem(HISTORY_KEY);
|
||||
}
|
||||
|
||||
// 注册全局快捷键
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
// 判断是否为Ctrl+K组合键
|
||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") {
|
||||
e.preventDefault(); // 阻止默认行为
|
||||
openSearchModal();
|
||||
}
|
||||
}
|
||||
|
||||
// 添加键盘事件监听
|
||||
onMounted(() => {
|
||||
loadRoutes(permissionStore.routes);
|
||||
loadSearchHistory();
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
});
|
||||
|
||||
// 移除键盘事件监听
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
});
|
||||
|
||||
// 打开搜索模态框
|
||||
function openSearchModal() {
|
||||
searchKeyword.value = "";
|
||||
activeIndex.value = -1;
|
||||
isModalVisible.value = true;
|
||||
setTimeout(() => {
|
||||
searchInputRef.value.focus();
|
||||
}, 100);
|
||||
}
|
||||
|
||||
// 关闭搜索模态框
|
||||
function closeSearchModal() {
|
||||
isModalVisible.value = false;
|
||||
}
|
||||
|
||||
// 更新搜索结果
|
||||
function updateSearchResults() {
|
||||
activeIndex.value = -1;
|
||||
if (searchKeyword.value) {
|
||||
const keyword = searchKeyword.value.toLowerCase();
|
||||
searchResults.value = menuItems.value.filter((item) =>
|
||||
item.title.toLowerCase().includes(keyword)
|
||||
);
|
||||
} else {
|
||||
searchResults.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
// 显示搜索结果
|
||||
const displayResults = computed(() => searchResults.value);
|
||||
|
||||
// 执行搜索
|
||||
function selectActiveResult() {
|
||||
if (displayResults.value.length > 0 && activeIndex.value >= 0) {
|
||||
navigateToRoute(displayResults.value[activeIndex.value]);
|
||||
}
|
||||
}
|
||||
|
||||
// 导航搜索结果
|
||||
function navigateResults(direction: string) {
|
||||
if (displayResults.value.length === 0) return;
|
||||
|
||||
if (direction === "up") {
|
||||
activeIndex.value =
|
||||
activeIndex.value <= 0 ? displayResults.value.length - 1 : activeIndex.value - 1;
|
||||
} else if (direction === "down") {
|
||||
activeIndex.value =
|
||||
activeIndex.value >= displayResults.value.length - 1 ? 0 : activeIndex.value + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// 跳转到
|
||||
function navigateToRoute(item: SearchItem) {
|
||||
closeSearchModal();
|
||||
// 添加到历史记录
|
||||
addToHistory(item);
|
||||
|
||||
if (isExternal(item.path)) {
|
||||
window.open(item.path, "_blank");
|
||||
} else {
|
||||
router.push({ path: item.path, query: item.params });
|
||||
}
|
||||
}
|
||||
|
||||
function loadRoutes(routes: RouteRecordRaw[], parentPath = "") {
|
||||
routes.forEach((route) => {
|
||||
const path = route.path.startsWith("/")
|
||||
? route.path
|
||||
: `${parentPath}${parentPath.endsWith("/") ? "" : "/"}${route.path}`;
|
||||
if (excludedRoutes.value.includes(route.path) || isExternal(route.path)) return;
|
||||
|
||||
if (route.children) {
|
||||
loadRoutes(route.children, path);
|
||||
} else if (route.meta?.title) {
|
||||
const title = route.meta.title === "dashboard" ? "首页" : route.meta.title;
|
||||
menuItems.value.push({
|
||||
title,
|
||||
path,
|
||||
name: typeof route.name === "string" ? route.name : undefined,
|
||||
icon: route.meta.icon,
|
||||
redirect: typeof route.redirect === "string" ? route.redirect : undefined,
|
||||
params: route.meta.params
|
||||
? JSON.parse(JSON.stringify(toRaw(route.meta.params)))
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.search-result {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
|
||||
ul {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
&__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
|
||||
&--active {
|
||||
color: var(--el-color-primary);
|
||||
background-color: var(--el-menu-hover-bg-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 搜索历史样式 */
|
||||
.search-history {
|
||||
&__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 12px;
|
||||
font-size: 12px;
|
||||
line-height: 34px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
&__clear {
|
||||
padding: 2px;
|
||||
font-size: 12px;
|
||||
|
||||
&:hover {
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
}
|
||||
|
||||
&__list {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-right: 10px;
|
||||
font-size: 16px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
&__name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-primary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__action {
|
||||
padding: 4px;
|
||||
color: var(--el-text-color-secondary);
|
||||
border-radius: 4px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: var(--el-color-danger);
|
||||
background-color: var(--el-fill-color);
|
||||
}
|
||||
}
|
||||
|
||||
&__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 40px;
|
||||
padding: 0 12px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--el-fill-color-light);
|
||||
|
||||
.search-history__action {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 没有搜索历史时的样式 */
|
||||
.no-history {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100px;
|
||||
|
||||
&__text {
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.shortcuts-group {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.key-box {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.arrow-box {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.arrow-up-down {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.key-btn {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 32px;
|
||||
height: 20px;
|
||||
padding: 0 4px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-regular);
|
||||
background-color: var(--el-fill-color-blank);
|
||||
border: 1px solid var(--el-border-color);
|
||||
border-radius: 3px;
|
||||
box-shadow:
|
||||
inset 0 -2px 0 0 var(--el-border-color),
|
||||
inset 0 0 1px 1px var(--el-color-white),
|
||||
0 1px 2px rgba(30, 35, 90, 0.2);
|
||||
|
||||
&::before {
|
||||
position: absolute;
|
||||
top: 1px;
|
||||
right: 1px;
|
||||
left: 1px;
|
||||
height: 50%;
|
||||
pointer-events: none;
|
||||
content: "";
|
||||
background: linear-gradient(to bottom, rgba(255, 255, 255, 0.8), rgba(255, 255, 255, 0));
|
||||
border-radius: 2px 2px 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
.esc-btn {
|
||||
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.key-text {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.ctrl-k-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ctrl-k-text {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
// 适配Element Plus对话框
|
||||
:deep(.el-dialog__footer) {
|
||||
box-sizing: border-box;
|
||||
padding-top: 10px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
// 暗黑模式适配
|
||||
html.dark {
|
||||
.key-btn::before {
|
||||
background: linear-gradient(to bottom, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
82
src/components/NoticeDropdown/index.vue
Normal file
82
src/components/NoticeDropdown/index.vue
Normal file
@@ -0,0 +1,82 @@
|
||||
<template>
|
||||
<el-dropdown trigger="click">
|
||||
<el-badge v-if="list.length > 0" :value="list.length" :max="99">
|
||||
<div class="i-svg:bell" />
|
||||
</el-badge>
|
||||
|
||||
<div v-else class="i-svg:bell" />
|
||||
|
||||
<template #dropdown>
|
||||
<div class="p-5">
|
||||
<template v-if="list.length > 0">
|
||||
<div v-for="item in list" :key="item.id" class="w-500px py-3">
|
||||
<div class="flex-y-center">
|
||||
<DictTag v-model="item.type" code="notice_type" size="small" />
|
||||
<el-text
|
||||
size="small"
|
||||
class="w-200px cursor-pointer !ml-2 !flex-1"
|
||||
truncated
|
||||
@click="read(item.id)"
|
||||
>
|
||||
{{ item.title }}
|
||||
</el-text>
|
||||
|
||||
<div class="text-xs text-gray">
|
||||
{{ item.publishTime }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-divider />
|
||||
<div class="flex-x-between">
|
||||
<el-link type="primary" underline="never" @click="goMore">
|
||||
<span class="text-xs">查看更多</span>
|
||||
<el-icon class="text-xs">
|
||||
<ArrowRight />
|
||||
</el-icon>
|
||||
</el-link>
|
||||
<el-link v-if="list.length > 0" type="primary" underline="never" @click="readAll">
|
||||
<span class="text-xs">全部已读</span>
|
||||
</el-link>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="flex-center h-150px w-350px">
|
||||
<el-empty :image-size="50" description="暂无消息" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="detail?.title ?? '通知详情'"
|
||||
width="800px"
|
||||
custom-class="notification-detail"
|
||||
>
|
||||
<div v-if="detail" class="p-x-20px">
|
||||
<div class="flex-y-center mb-16px text-13px text-color-secondary">
|
||||
<span class="flex-y-center">
|
||||
<el-icon><User /></el-icon>
|
||||
{{ detail.publisherName }}
|
||||
</span>
|
||||
<span class="ml-2 flex-y-center">
|
||||
<el-icon><Timer /></el-icon>
|
||||
{{ detail.publishTime }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="max-h-60vh pt-16px mb-24px overflow-y-auto border-t border-solid border-color">
|
||||
<div v-html="detail.content"></div>
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useNotice } from "./useNotice";
|
||||
|
||||
const { list, detail, dialogVisible, read, readAll, goMore } = useNotice();
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
114
src/components/NoticeDropdown/useNotice.ts
Normal file
114
src/components/NoticeDropdown/useNotice.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* 通知中心逻辑
|
||||
*/
|
||||
import { ref, onMounted, onBeforeUnmount } from "vue";
|
||||
import type { NoticePageVo, NoticeDetailVo, NoticePageQuery } from "@/types/api";
|
||||
import NoticeAPI from "@/api/system/notice";
|
||||
import { useStomp } from "@/composables";
|
||||
import router from "@/router";
|
||||
|
||||
const PAGE_SIZE = 5;
|
||||
|
||||
export function useNotice() {
|
||||
const { subscribe, unsubscribe, isConnected } = useStomp();
|
||||
|
||||
// 状态
|
||||
const list = ref<NoticePageVo[]>([]);
|
||||
const detail = ref<NoticeDetailVo | null>(null);
|
||||
const dialogVisible = ref(false);
|
||||
|
||||
let subscribed = false;
|
||||
|
||||
// ============================================
|
||||
// 数据获取
|
||||
// ============================================
|
||||
|
||||
async function fetchList(params?: Partial<NoticePageQuery>) {
|
||||
const query: NoticePageQuery = {
|
||||
pageNum: 1,
|
||||
pageSize: PAGE_SIZE,
|
||||
isRead: 0,
|
||||
...params,
|
||||
} as NoticePageQuery;
|
||||
const page = await NoticeAPI.getMyNoticePage(query);
|
||||
list.value = page.list || [];
|
||||
}
|
||||
|
||||
async function read(id: string) {
|
||||
detail.value = await NoticeAPI.getDetail(id);
|
||||
dialogVisible.value = true;
|
||||
|
||||
// 从列表中移除已读项
|
||||
const idx = list.value.findIndex((item: NoticePageVo) => item.id === id);
|
||||
if (idx >= 0) list.value.splice(idx, 1);
|
||||
}
|
||||
|
||||
async function readAll() {
|
||||
await NoticeAPI.readAll();
|
||||
list.value = [];
|
||||
}
|
||||
|
||||
function goMore() {
|
||||
router.push({ name: "MyNotice" });
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// WebSocket 订阅
|
||||
// ============================================
|
||||
|
||||
function setupSubscription() {
|
||||
if (subscribed || !isConnected.value) return;
|
||||
|
||||
subscribe("/user/queue/message", (message: any) => {
|
||||
try {
|
||||
const data = JSON.parse(message.body || "{}");
|
||||
if (!data.id) return;
|
||||
|
||||
// 避免重复
|
||||
if (list.value.some((item: NoticePageVo) => item.id === data.id)) return;
|
||||
|
||||
list.value.unshift({
|
||||
id: data.id,
|
||||
title: data.title,
|
||||
type: data.type,
|
||||
publishTime: data.publishTime,
|
||||
} as NoticePageVo);
|
||||
|
||||
ElNotification({
|
||||
title: "您收到一条新的通知消息!",
|
||||
message: data.title,
|
||||
type: "success",
|
||||
position: "bottom-right",
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("解析通知消息失败", e);
|
||||
}
|
||||
});
|
||||
|
||||
subscribed = true;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 生命周期
|
||||
// ============================================
|
||||
|
||||
onMounted(() => {
|
||||
fetchList();
|
||||
setupSubscription();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
unsubscribe("/user/queue/message");
|
||||
subscribed = false;
|
||||
});
|
||||
|
||||
return {
|
||||
list,
|
||||
detail,
|
||||
dialogVisible,
|
||||
fetchList,
|
||||
read,
|
||||
readAll,
|
||||
goMore,
|
||||
};
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
<template>
|
||||
<el-dropdown trigger="click">
|
||||
<el-badge v-if="noticeList.length > 0" :value="noticeList.length" :max="99">
|
||||
<div class="i-svg:bell" />
|
||||
</el-badge>
|
||||
|
||||
<div v-else class="i-svg:bell" />
|
||||
|
||||
<template #dropdown>
|
||||
<div class="p-5">
|
||||
<template v-if="noticeList.length > 0">
|
||||
<div v-for="(item, index) in noticeList" :key="index" class="w-500px py-3">
|
||||
<div class="flex-y-center">
|
||||
<DictLabel v-model="item.type" code="notice_type" size="small" />
|
||||
<el-text
|
||||
size="small"
|
||||
class="w-200px cursor-pointer !ml-2 !flex-1"
|
||||
truncated
|
||||
@click="handleReadNotice(item.id)"
|
||||
>
|
||||
{{ item.title }}
|
||||
</el-text>
|
||||
|
||||
<div class="text-xs text-gray">
|
||||
{{ item.publishTime }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-divider />
|
||||
<div class="flex-x-between">
|
||||
<el-link type="primary" underline="never" @click="handleViewMoreNotice">
|
||||
<span class="text-xs">查看更多</span>
|
||||
<el-icon class="text-xs">
|
||||
<ArrowRight />
|
||||
</el-icon>
|
||||
</el-link>
|
||||
<el-link
|
||||
v-if="noticeList.length > 0"
|
||||
type="primary"
|
||||
underline="never"
|
||||
@click="handleMarkAllAsRead"
|
||||
>
|
||||
<span class="text-xs">全部已读</span>
|
||||
</el-link>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="flex-center h-150px w-350px">
|
||||
<el-empty :image-size="50" description="暂无消息" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
|
||||
<el-dialog
|
||||
v-model="noticeDialogVisible"
|
||||
:title="noticeDetail?.title ?? '通知详情'"
|
||||
width="800px"
|
||||
custom-class="notification-detail"
|
||||
>
|
||||
<div v-if="noticeDetail" class="p-x-20px">
|
||||
<div class="flex-y-center mb-16px text-13px text-color-secondary">
|
||||
<span class="flex-y-center">
|
||||
<el-icon><User /></el-icon>
|
||||
{{ noticeDetail.publisherName }}
|
||||
</span>
|
||||
<span class="ml-2 flex-y-center">
|
||||
<el-icon><Timer /></el-icon>
|
||||
{{ noticeDetail.publishTime }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="max-h-60vh pt-16px mb-24px overflow-y-auto border-t border-solid border-color">
|
||||
<div v-html="noticeDetail.content"></div>
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import NoticeAPI, { NoticePageVO, NoticeDetailVO } from "@/api/system/notice-api";
|
||||
import router from "@/router";
|
||||
|
||||
const noticeList = ref<NoticePageVO[]>([]);
|
||||
const noticeDialogVisible = ref(false);
|
||||
const noticeDetail = ref<NoticeDetailVO | null>(null);
|
||||
|
||||
import { useStomp } from "@/composables/websocket/useStomp";
|
||||
const { subscribe, unsubscribe, isConnected } = useStomp();
|
||||
|
||||
watch(
|
||||
() => isConnected.value,
|
||||
(connected) => {
|
||||
if (connected) {
|
||||
subscribe("/user/queue/message", (message: any) => {
|
||||
console.log("收到通知消息:", message);
|
||||
const data = JSON.parse(message.body);
|
||||
const id = data.id;
|
||||
if (!noticeList.value.some((notice) => notice.id == id)) {
|
||||
noticeList.value.unshift({
|
||||
id,
|
||||
title: data.title,
|
||||
type: data.type,
|
||||
publishTime: data.publishTime,
|
||||
});
|
||||
|
||||
ElNotification({
|
||||
title: "您收到一条新的通知消息!",
|
||||
message: data.title,
|
||||
type: "success",
|
||||
position: "bottom-right",
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* 获取我的通知公告
|
||||
*/
|
||||
function featchMyNotice() {
|
||||
NoticeAPI.getMyNoticePage({ pageNum: 1, pageSize: 5, isRead: 0 }).then((data) => {
|
||||
noticeList.value = data.list;
|
||||
});
|
||||
}
|
||||
|
||||
// 阅读通知公告
|
||||
function handleReadNotice(id: string) {
|
||||
NoticeAPI.getDetail(id).then((data) => {
|
||||
noticeDialogVisible.value = true;
|
||||
noticeDetail.value = data;
|
||||
// 标记为已读
|
||||
const index = noticeList.value.findIndex((notice) => notice.id === id);
|
||||
if (index >= 0) {
|
||||
noticeList.value.splice(index, 1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 查看更多
|
||||
function handleViewMoreNotice() {
|
||||
router.push({ name: "MyNotice" });
|
||||
}
|
||||
|
||||
// 全部已读
|
||||
function handleMarkAllAsRead() {
|
||||
NoticeAPI.readAll().then(() => {
|
||||
noticeList.value = [];
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
featchMyNotice();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
unsubscribe("/user/queue/message");
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
@@ -36,7 +36,7 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
const count = ref(0);
|
||||
const operationWidth = ref(props.minWidth || 80);
|
||||
|
||||
// 计算操作列宽度
|
||||
// 霈∠<EFBFBD><EFBFBD>滢<EFBFBD><EFBFBD>堒捐摨?
|
||||
const calculateWidth = () => {
|
||||
count.value++;
|
||||
|
||||
@@ -46,7 +46,7 @@ const calculateWidth = () => {
|
||||
count.value = 0;
|
||||
};
|
||||
|
||||
// 计算最终宽度
|
||||
// 霈∠<EFBFBD><EFBFBD><EFBFBD>蝏<EFBFBD>捐摨?
|
||||
const finalWidth = computed(() => {
|
||||
return props.width || operationWidth.value || props.minWidth;
|
||||
});
|
||||
@@ -54,32 +54,32 @@ const finalWidth = computed(() => {
|
||||
// <20>芷<EFBFBD><E88AB7><EFBFBD>摰賢漲<E8B3A2><E6BCB2>誘
|
||||
const vAutoWidth = {
|
||||
mounted() {
|
||||
// 初次挂载的时候计算一次
|
||||
// <EFBFBD>脲活<EFBFBD><EFBFBD>蝸<EFBFBD><EFBFBD>𧒄<EFBFBD>躰恣蝞𦯀<EFBFBD>甈?
|
||||
calculateWidth();
|
||||
},
|
||||
updated() {
|
||||
// 数据更新时重新计算一次
|
||||
// <EFBFBD>唳旿<EFBFBD>湔鰵<EFBFBD>園<EFBFBD><EFBFBD>啗恣蝞𦯀<EFBFBD>甈?
|
||||
calculateWidth();
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取按钮数量和宽带来获取操作组的最大宽度
|
||||
* 注意使用时需要使用 `class="operation-buttons"` 的标签包裹操作按钮
|
||||
* @returns {number} 返回操作组的最大宽度
|
||||
* <EFBFBD>瑕<EFBFBD><EFBFBD>厰僼<EFBFBD>圈<EFBFBD><EFBFBD><EFBFBD>捐撣行䔉<EFBFBD>瑕<EFBFBD><EFBFBD>滢<EFBFBD>蝏<EFBFBD><EFBFBD><EFBFBD><EFBFBD>憭批捐摨?
|
||||
* 瘜冽<EFBFBD>雿輻鍂<EFBFBD>園<EFBFBD>閬<EFBFBD>蝙<EFBFBD>?`class="operation-buttons"` <EFBFBD><EFBFBD><EFBFBD>蝑曉<EFBFBD>鋆寞<EFBFBD>雿𨀣<EFBFBD><EFBFBD>?
|
||||
* @returns {number} 餈𥪜<EFBFBD><EFBFBD>滢<EFBFBD>蝏<EFBFBD><EFBFBD><EFBFBD><EFBFBD>憭批捐摨?
|
||||
*/
|
||||
const getOperationMaxWidth = () => {
|
||||
const el = document.getElementsByClassName("operation-buttons");
|
||||
|
||||
// 取操作组的最大宽度
|
||||
// <EFBFBD>𡝗<EFBFBD>雿𦦵<EFBFBD><EFBFBD><EFBFBD><EFBFBD>憭批捐摨?
|
||||
let maxWidth = 0;
|
||||
let totalWidth: any = 0;
|
||||
Array.prototype.forEach.call(el, (item) => {
|
||||
// <20>瑕<EFBFBD>瘥譍葵item<65><6D>om
|
||||
const buttons = item.querySelectorAll(".el-button");
|
||||
// 获取每行按钮的总宽度
|
||||
// <EFBFBD>瑕<EFBFBD>瘥讛<EFBFBD><EFBFBD>厰僼<EFBFBD><EFBFBD><EFBFBD>餃捐摨?
|
||||
totalWidth = Array.from(buttons).reduce((acc, button: any) => {
|
||||
return acc + button.scrollWidth + 22; // 每个按钮的宽度加上预留宽度
|
||||
return acc + button.scrollWidth + 22; // 瘥譍葵<EFBFBD>厰僼<EFBFBD><EFBFBD>捐摨血<EFBFBD>銝𢠃<EFBFBD><EFBFBD>坔捐摨?
|
||||
}, 0);
|
||||
|
||||
// <20>瑕<EFBFBD><E79195><EFBFBD>憭抒<E686AD>摰賢漲
|
||||
|
||||
@@ -20,8 +20,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ComponentSize } from "@/enums/settings/layout-enum";
|
||||
import { useAppStore } from "@/store/modules/app-store";
|
||||
import { ComponentSize } from "@/enums/settings";
|
||||
import { useAppStore } from "@/store/modules/app";
|
||||
|
||||
const { t } = useI18n();
|
||||
const sizeOptions = computed(() => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<template>
|
||||
<template>
|
||||
<div ref="tableSelectRef" :style="'width:' + width">
|
||||
<el-popover
|
||||
:visible="popoverVisible"
|
||||
@@ -30,13 +30,13 @@
|
||||
</slot>
|
||||
</div>
|
||||
</template>
|
||||
<!-- 弹出框内容 -->
|
||||
<!-- 弹出框内 -->
|
||||
<div ref="popoverContentRef">
|
||||
<!-- 表单 -->
|
||||
<el-form ref="formRef" :model="queryParams" :inline="true">
|
||||
<template v-for="item in selectConfig.formItems" :key="item.prop">
|
||||
<el-form-item :label="item.label" :prop="item.prop">
|
||||
<!-- Input 输入框 -->
|
||||
<!-- Input 输入 -->
|
||||
<template v-if="item.type === 'input'">
|
||||
<template v-if="item.attrs?.type === 'number'">
|
||||
<el-input
|
||||
@@ -53,7 +53,7 @@
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
<!-- Select 选择器 -->
|
||||
<!-- Select 选择 -->
|
||||
<template v-else-if="item.type === 'select'">
|
||||
<el-select v-model="queryParams[item.prop]" v-bind="item.attrs">
|
||||
<template v-for="option in item.options" :key="option.value">
|
||||
@@ -65,11 +65,11 @@
|
||||
<template v-else-if="item.type === 'tree-select'">
|
||||
<el-tree-select v-model="queryParams[item.prop]" v-bind="item.attrs" />
|
||||
</template>
|
||||
<!-- DatePicker 日期选择器 -->
|
||||
<!-- DatePicker 日期选择 -->
|
||||
<template v-else-if="item.type === 'date-picker'">
|
||||
<el-date-picker v-model="queryParams[item.prop]" v-bind="item.attrs" />
|
||||
</template>
|
||||
<!-- Input 输入框 -->
|
||||
<!-- Input 输入 -->
|
||||
<template v-else>
|
||||
<template v-if="item.attrs?.type === 'number'">
|
||||
<el-input
|
||||
@@ -133,8 +133,8 @@
|
||||
<el-button type="primary" size="small" @click="handleConfirm">
|
||||
{{ confirmText }}
|
||||
</el-button>
|
||||
<el-button size="small" @click="handleClear">清 空</el-button>
|
||||
<el-button size="small" @click="handleClose">关 闭</el-button>
|
||||
<el-button size="small" @click="handleClear">清空</el-button>
|
||||
<el-button size="small" @click="handleClose">关闭</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-popover>
|
||||
@@ -156,15 +156,15 @@ export interface ISelectConfig<T = any> {
|
||||
placeholder?: string;
|
||||
// popover组件属性
|
||||
popover?: Partial<Omit<PopoverProps, "visible" | "v-model:visible">>;
|
||||
// 列表的网络请求函数(需返回promise)
|
||||
// 列表的网络请求函数 (需返回 Promise)
|
||||
indexAction: (_queryParams: T) => Promise<any>;
|
||||
// 主键名(跨页选择必填,默认为id)
|
||||
// 主键 (跨页选择必填, 默认为 id)
|
||||
pk?: string;
|
||||
// 多选
|
||||
// 是否多选
|
||||
multiple?: boolean;
|
||||
// 表单项
|
||||
formItems: Array<{
|
||||
// 组件类型(如input,select等)
|
||||
// 组件类型(如 input, select 等)
|
||||
type?: "input" | "select" | "tree-select" | "date-picker";
|
||||
// 标签文本
|
||||
label: string;
|
||||
@@ -282,7 +282,7 @@ for (const item of props.selectConfig.tableColumns) {
|
||||
// 选择
|
||||
const selectedItems = ref<IObject[]>([]);
|
||||
const confirmText = computed(() => {
|
||||
return selectedItems.value.length > 0 ? `已选(${selectedItems.value.length})` : "确 定";
|
||||
return selectedItems.value.length > 0 ? `已选${selectedItems.value.length}条` : "请选择";
|
||||
});
|
||||
function handleSelect(selection: any[]) {
|
||||
if (isMultiple || selection.length === 0) {
|
||||
|
||||
106
src/components/TenantSwitcher/index.vue
Normal file
106
src/components/TenantSwitcher/index.vue
Normal file
@@ -0,0 +1,106 @@
|
||||
<template>
|
||||
<el-dropdown
|
||||
v-if="tenantList.length > 0"
|
||||
class="tenant-switcher"
|
||||
trigger="click"
|
||||
:hide-on-click="true"
|
||||
@command="onCommand"
|
||||
>
|
||||
<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";
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "change", tenantId: number): void;
|
||||
}>();
|
||||
|
||||
const tenantStore = useTenantStoreHook();
|
||||
|
||||
const tenantList = computed(() => tenantStore.tenantList);
|
||||
|
||||
const currentTenantIdRef = computed<number | null>({
|
||||
get: () => tenantStore.currentTenantId,
|
||||
set: (val) => {
|
||||
tenantStore.currentTenantId = val;
|
||||
},
|
||||
});
|
||||
|
||||
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>
|
||||
@@ -1,10 +1,10 @@
|
||||
<!--
|
||||
<!--
|
||||
TextScroll 组件 - 文本滚动公告
|
||||
|
||||
功能:
|
||||
功能:
|
||||
- 支持水平方向文本滚动
|
||||
- 提供多种预设样式(默认、成功、警告、危险、信息)
|
||||
- 支持自定义滚动速度和方向
|
||||
- 支持自定义滚动速度和方式
|
||||
- 可选的打字机输入效果
|
||||
- 鼠标悬停时暂停滚动
|
||||
- 可选的关闭按钮
|
||||
@@ -20,7 +20,7 @@
|
||||
<div class="left-icon">
|
||||
<el-icon><Bell /></el-icon>
|
||||
</div>
|
||||
<!-- 滚动内容包装器 -->
|
||||
<!-- 滚动内容容器 -->
|
||||
<div class="scroll-wrapper">
|
||||
<div
|
||||
ref="scrollContent"
|
||||
@@ -48,7 +48,7 @@ const emit = defineEmits(["close"]);
|
||||
interface Props {
|
||||
/** 滚动文本内容(必填) */
|
||||
text: string;
|
||||
/** 滚动速度,数值越小滚动越慢 */
|
||||
/** 滚动速度,数值越小滚动越快 */
|
||||
speed?: number;
|
||||
/** 滚动方向:左侧或右侧 */
|
||||
direction?: "left" | "right";
|
||||
@@ -82,7 +82,7 @@ const scrollContent = ref<HTMLElement | null>(null);
|
||||
const animationDuration = ref(0);
|
||||
|
||||
/**
|
||||
* 打字机效果相关状态
|
||||
* 打字机效果相关状态"
|
||||
*/
|
||||
// 当前已显示的文本内容
|
||||
const currentText = ref("");
|
||||
@@ -143,7 +143,7 @@ const handleRightIconClick = () => {
|
||||
emit("close");
|
||||
// 获取当前组件的DOM元素
|
||||
if (containerRef.value) {
|
||||
// 从DOM中移除元素
|
||||
// 从 DOM 中移除元素
|
||||
containerRef.value.remove();
|
||||
}
|
||||
};
|
||||
@@ -155,7 +155,7 @@ const handleRightIconClick = () => {
|
||||
const startTypewriter = () => {
|
||||
let index = 0;
|
||||
currentText.value = "";
|
||||
isTypewriterComplete.value = false; // 重置状态
|
||||
isTypewriterComplete.value = false; // 重置状态"
|
||||
|
||||
// 递归函数,逐字添加文本
|
||||
const type = () => {
|
||||
@@ -166,7 +166,7 @@ const startTypewriter = () => {
|
||||
// 设置下一个字符的延迟
|
||||
typewriterTimer = setTimeout(type, props.typewriterSpeed);
|
||||
} else {
|
||||
// 所有字符都已添加,设置完成状态
|
||||
// 所有字符都已添加,设置完成状态"
|
||||
isTypewriterComplete.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -35,6 +35,6 @@ const theneList = [
|
||||
];
|
||||
|
||||
const handleDarkChange = (theme: ThemeMode) => {
|
||||
settingsStore.updateTheme(theme);
|
||||
settingsStore.theme = theme;
|
||||
};
|
||||
</script>
|
||||
@@ -53,11 +53,12 @@ import {
|
||||
UploadRequestOptions,
|
||||
} from "element-plus";
|
||||
|
||||
import FileAPI, { FileInfo } from "@/api/file-api";
|
||||
import FileAPI from "@/api/file";
|
||||
import type { FileInfo } from "@/types/api";
|
||||
|
||||
const props = defineProps({
|
||||
/**
|
||||
* 请求携带的额外参数
|
||||
* 请求携带的额外参<EFBFBD>?
|
||||
*/
|
||||
data: {
|
||||
type: Object,
|
||||
@@ -121,7 +122,7 @@ const modelValue = defineModel("modelValue", {
|
||||
|
||||
const fileList = ref([] as UploadFile[]);
|
||||
|
||||
// 监听 modelValue 转换用于显示的 fileList
|
||||
// 监听 modelValue 转换用于显示<EFBFBD>?fileList
|
||||
watch(
|
||||
modelValue,
|
||||
(value) => {
|
||||
@@ -141,7 +142,7 @@ watch(
|
||||
);
|
||||
|
||||
/**
|
||||
* 上传前校验
|
||||
* 上传前校<EFBFBD>?
|
||||
*/
|
||||
function handleBeforeUpload(file: UploadRawFile) {
|
||||
// 限制文件大小
|
||||
@@ -185,7 +186,7 @@ function handleUpload(options: UploadRequestOptions) {
|
||||
* 上传文件超出限制
|
||||
*/
|
||||
function handleExceed() {
|
||||
ElMessage.warning(`最多只能上传${props.limit}个文件`);
|
||||
ElMessage.warning("最多只能上传 " + props.limit + " 个文件");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -193,7 +194,7 @@ function handleExceed() {
|
||||
*/
|
||||
const handleSuccess = (response: any, uploadFile: UploadFile, files: UploadFiles) => {
|
||||
ElMessage.success("上传成功");
|
||||
//只有当状态为success或者fail,代表文件上传全部完成了,失败也算完成
|
||||
//只有当状态为success或者fail,代表文件上传全部完成了,失败也算完<EFBFBD>?
|
||||
if (
|
||||
files.every((file: UploadFile) => {
|
||||
return file.status === "success" || file.status === "fail";
|
||||
@@ -202,7 +203,7 @@ const handleSuccess = (response: any, uploadFile: UploadFile, files: UploadFiles
|
||||
const fileInfos = [] as FileInfo[];
|
||||
files.map((file: UploadFile) => {
|
||||
if (file.status === "success") {
|
||||
//只取携带response的才是刚上传的
|
||||
//只取携带response的才是刚上传<EFBFBD>?
|
||||
const res = file.response as FileInfo;
|
||||
if (res) {
|
||||
fileInfos.push({ name: res.name, url: res.url } as FileInfo);
|
||||
@@ -250,7 +251,7 @@ function handleDownload(file: UploadUserFile) {
|
||||
|
||||
/** 获取一个不重复的id */
|
||||
function getUid(): number {
|
||||
// 时间戳左移13位(相当于乘以8192) + 4位随机数
|
||||
// 时间戳左<EFBFBD>?3位(相当于乘<EFBFBD>?192<EFBFBD>?+ 4位随机数
|
||||
return (Date.now() << 13) | Math.floor(Math.random() * 8192);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -40,7 +40,8 @@
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { UploadRawFile, UploadRequestOptions, UploadUserFile } from "element-plus";
|
||||
import FileAPI, { FileInfo } from "@/api/file-api";
|
||||
import FileAPI from "@/api/file";
|
||||
import type { FileInfo } from "@/types/api";
|
||||
|
||||
const props = defineProps({
|
||||
/**
|
||||
@@ -67,7 +68,7 @@ const props = defineProps({
|
||||
default: 10,
|
||||
},
|
||||
/**
|
||||
* 单个文件的最大允许大小
|
||||
* 单个文件的最大允许大<EFBFBD>?
|
||||
*/
|
||||
maxFileSize: {
|
||||
type: Number,
|
||||
@@ -78,12 +79,12 @@ const props = defineProps({
|
||||
*/
|
||||
accept: {
|
||||
type: String,
|
||||
default: "image/*", // 默认支持所有图片格式 ,如果需要指定格式,格式如下:'.png,.jpg,.jpeg,.gif,.bmp'
|
||||
default: "image/*", // 默认支持所有图片格式,如果需要指定格式,格式如下:.png,.jpg,.jpeg,.gif,.bmp
|
||||
},
|
||||
});
|
||||
|
||||
const previewVisible = ref(false); // 是否显示预览
|
||||
const previewImageIndex = ref(0); // 预览图片的索引
|
||||
const previewImageIndex = ref(0); // 预览图片的索<EFBFBD>?
|
||||
|
||||
const modelValue = defineModel("modelValue", {
|
||||
type: [Array] as PropType<string[]>,
|
||||
@@ -107,28 +108,28 @@ function handleRemove(imageUrl: string) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传前校验
|
||||
* 上传前校<EFBFBD>?
|
||||
*/
|
||||
function handleBeforeUpload(file: UploadRawFile) {
|
||||
// 校验文件类型:虽然 accept 属性限制了用户在文件选择器中可选的文件类型,但仍需在上传时再次校验文件实际类型,确保符合 accept 的规则
|
||||
// 校验文件类型:虽<EFBFBD>?accept 属性限制了用户在文件选择器中可选的文件类型,但仍需在上传时再次校验文件实际类型,确保符<EFBFBD>?accept 的规<EFBFBD>?
|
||||
const acceptTypes = props.accept.split(",").map((type) => type.trim());
|
||||
|
||||
// 检查文件格式是否符合 accept
|
||||
const isValidType = acceptTypes.some((type) => {
|
||||
if (type === "image/*") {
|
||||
// 如果是 image/*,检查 MIME 类型是否以 "image/" 开头
|
||||
// 如果<EFBFBD>?image/*,检<EFBFBD>?MIME 类型是否<EFBFBD>?"image/" 开<EFBFBD>?
|
||||
return file.type.startsWith("image/");
|
||||
} else if (type.startsWith(".")) {
|
||||
// 如果是扩展名 (.png, .jpg),检查文件名是否以指定扩展名结尾
|
||||
return file.name.toLowerCase().endsWith(type);
|
||||
} else {
|
||||
// 如果是具体的 MIME 类型 (image/png, image/jpeg),检查是否完全匹配
|
||||
// 如果是具体的 MIME 类型 (image/png, image/jpeg),检查是否完全匹<EFBFBD>?
|
||||
return file.type === type;
|
||||
}
|
||||
});
|
||||
|
||||
if (!isValidType) {
|
||||
ElMessage.warning(`上传文件的格式不正确,仅支持:${props.accept}`);
|
||||
ElMessage.warning("上传文件的格式不正确,仅支持 " + props.accept);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -169,7 +170,7 @@ function handleUpload(options: UploadRequestOptions) {
|
||||
* 上传文件超出限制
|
||||
*/
|
||||
function handleExceed() {
|
||||
ElMessage.warning("最多只能上传" + props.limit + "张图片");
|
||||
ElMessage.warning("最多只能上传 " + props.limit + " 张图片");
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -33,11 +33,12 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { UploadRawFile, UploadRequestOptions } from "element-plus";
|
||||
import FileAPI, { FileInfo } from "@/api/file-api";
|
||||
import FileAPI from "@/api/file";
|
||||
import type { FileInfo } from "@/types/api";
|
||||
|
||||
const props = defineProps({
|
||||
/**
|
||||
* 请求携带的额外参数
|
||||
* 请求携带的额外参<EFBFBD>?
|
||||
*/
|
||||
data: {
|
||||
type: Object,
|
||||
@@ -53,7 +54,7 @@ const props = defineProps({
|
||||
default: "file",
|
||||
},
|
||||
/**
|
||||
* 最大文件大小(单位:M)
|
||||
* 最大文件大小(单位:M<EFBFBD>?
|
||||
*/
|
||||
maxFileSize: {
|
||||
type: Number,
|
||||
@@ -61,7 +62,7 @@ const props = defineProps({
|
||||
},
|
||||
|
||||
/**
|
||||
* 上传图片格式,默认支持所有图片(image/*),指定格式示例:'.png,.jpg,.jpeg,.gif,.bmp'
|
||||
* 上传图片格式,默认支持所有图<EFBFBD>?image/*),指定格式示例:'.png,.jpg,.jpeg,.gif,.bmp'
|
||||
*/
|
||||
accept: {
|
||||
type: String,
|
||||
@@ -69,7 +70,7 @@ const props = defineProps({
|
||||
},
|
||||
|
||||
/**
|
||||
* 自定义样式,用于设置组件的宽度和高度等其他样式
|
||||
* 自定义样式,用于设置组件的宽度和高度等其他样<EFBFBD>?
|
||||
*/
|
||||
style: {
|
||||
type: Object,
|
||||
@@ -91,25 +92,25 @@ const modelValue = defineModel("modelValue", {
|
||||
* 限制用户上传文件的格式和大小
|
||||
*/
|
||||
function handleBeforeUpload(file: UploadRawFile) {
|
||||
// 校验文件类型:虽然 accept 属性限制了用户在文件选择器中可选的文件类型,但仍需在上传时再次校验文件实际类型,确保符合 accept 的规则
|
||||
// 校验文件类型:虽<EFBFBD>?accept 属性限制了用户在文件选择器中可选的文件类型,但仍需在上传时再次校验文件实际类型,确保符<EFBFBD>?accept 的规<EFBFBD>?
|
||||
const acceptTypes = props.accept.split(",").map((type) => type.trim());
|
||||
|
||||
// 检查文件格式是否符合 accept
|
||||
// 检查文件格式是否符<EFBFBD>?accept
|
||||
const isValidType = acceptTypes.some((type) => {
|
||||
if (type === "image/*") {
|
||||
// 如果是 image/*,检查 MIME 类型是否以 "image/" 开头
|
||||
// 如果<EFBFBD>?image/*,检<EFBFBD>?MIME 类型是否<EFBFBD>?"image/" 开<EFBFBD>?
|
||||
return file.type.startsWith("image/");
|
||||
} else if (type.startsWith(".")) {
|
||||
// 如果是扩展名 (.png, .jpg),检查文件名是否以指定扩展名结尾
|
||||
return file.name.toLowerCase().endsWith(type);
|
||||
} else {
|
||||
// 如果是具体的 MIME 类型 (image/png, image/jpeg),检查是否完全匹配
|
||||
// 如果是具体的 MIME 类型 (image/png, image/jpeg),检查是否完全匹<EFBFBD>?
|
||||
return file.type === type;
|
||||
}
|
||||
});
|
||||
|
||||
if (!isValidType) {
|
||||
ElMessage.warning(`上传文件的格式不正确,仅支持:${props.accept}`);
|
||||
ElMessage.warning("上传文件的格式不正确,仅支持 " + props.accept);
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
<!--
|
||||
* 基于 wangEditor-next 的富文本编辑器组件二次封装
|
||||
* 版权所有 © 2021-present 有来开源组织
|
||||
* 基于 wangEditor-next 的富文本编辑器组件二次å°<EFBFBD>è£?
|
||||
* 版æ<EFBFBD>ƒæ‰€æœ?© 2021-present 有æ<EFBFBD>¥å¼€æº<EFBFBD>组ç»?
|
||||
*
|
||||
* å¼€æº<EFBFBD>å<EFBFBD><EFBFBD>议:https://opensource.org/licenses/MIT
|
||||
* 项目地å<EFBFBD>€ï¼šhttps://gitee.com/youlaiorg/vue3-element-admin
|
||||
*
|
||||
* 在使用时,请保留此注释,感谢您对开源的支持!
|
||||
* 在使用时,请ä¿<EFBFBD>ç•™æ¤æ³¨é‡Šï¼Œæ„Ÿè°¢æ‚¨å¯¹å¼€æº<EFBFBD>的支æŒ<EFBFBD>ï¼?
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div style="z-index: 999; border: 1px solid var(--el-border-color)">
|
||||
<!-- 工具栏 -->
|
||||
<!-- 工具æ ?-->
|
||||
<Toolbar
|
||||
:editor="editorRef"
|
||||
mode="simple"
|
||||
:default-config="toolbarConfig"
|
||||
style="border-bottom: 1px solid var(--el-border-color)"
|
||||
/>
|
||||
<!-- 编辑器 -->
|
||||
<!-- 编辑�-->
|
||||
<Editor
|
||||
v-model="modelValue"
|
||||
:style="{ height: height, overflowY: 'hidden' }"
|
||||
@@ -34,7 +34,7 @@ import { Toolbar, Editor } from "@wangeditor-next/editor-for-vue";
|
||||
import { IToolbarConfig, IEditorConfig } from "@wangeditor-next/editor";
|
||||
|
||||
// æ–‡ä»¶ä¸Šä¼ API
|
||||
import FileAPI from "@/api/file-api";
|
||||
import FileAPI from "@/api/file";
|
||||
|
||||
// ä¸Šä¼ å›¾ç‰‡å›žè°ƒå‡½æ•°ç±»åž‹
|
||||
type InsertFnType = (_url: string, _alt: string, _href: string) => void;
|
||||
@@ -51,15 +51,15 @@ const modelValue = defineModel("modelValue", {
|
||||
required: false,
|
||||
});
|
||||
|
||||
// 编辑器实例,必须用 shallowRef,重要!
|
||||
// 编辑器实例,必须ç”?shallowRef,é‡<C3A9>è¦<C3A8>ï¼<C3AF>
|
||||
const editorRef = shallowRef();
|
||||
|
||||
// 工具栏配置
|
||||
// 工具æ <EFBFBD>é…<EFBFBD>ç½?
|
||||
const toolbarConfig = ref<Partial<IToolbarConfig>>({});
|
||||
|
||||
// 编辑器配置
|
||||
// 编辑器é…<EFBFBD>ç½?
|
||||
const editorConfig = ref<Partial<IEditorConfig>>({
|
||||
placeholder: "请输入内容...",
|
||||
placeholder: "请输入内�..",
|
||||
MENU_CONF: {
|
||||
uploadImage: {
|
||||
customUpload(file: File, insertFn: InsertFnType) {
|
||||
|
||||
@@ -265,6 +265,5 @@ export function useAiAction(options: UseAiActionOptions = {}) {
|
||||
executeAiAction,
|
||||
executeCommand,
|
||||
handleAutoSearch,
|
||||
init,
|
||||
};
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
import type { InternalAxiosRequestConfig } from "axios";
|
||||
import { useUserStoreHook } from "@/store/modules/user-store";
|
||||
import { AuthStorage, redirectToLogin } from "@/utils/auth";
|
||||
|
||||
/**
|
||||
* 重试请求的回调函数类型
|
||||
*/
|
||||
type RetryCallback = () => void;
|
||||
|
||||
/**
|
||||
* Token刷新组合式函数
|
||||
*/
|
||||
export function useTokenRefresh() {
|
||||
// Token 刷新相关状态
|
||||
let isRefreshingToken = false;
|
||||
const pendingRequests: RetryCallback[] = [];
|
||||
|
||||
/**
|
||||
* 刷新 Token 并重试请求
|
||||
*/
|
||||
async function refreshTokenAndRetry(
|
||||
config: InternalAxiosRequestConfig,
|
||||
httpRequest: any
|
||||
): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// 封装需要重试的请求
|
||||
const retryRequest = () => {
|
||||
const newToken = AuthStorage.getAccessToken();
|
||||
if (newToken && config.headers) {
|
||||
config.headers.Authorization = `Bearer ${newToken}`;
|
||||
}
|
||||
httpRequest(config).then(resolve).catch(reject);
|
||||
};
|
||||
|
||||
// 将请求加入等待队列
|
||||
pendingRequests.push(retryRequest);
|
||||
|
||||
// 如果没有正在刷新,则开始刷新流程
|
||||
if (!isRefreshingToken) {
|
||||
isRefreshingToken = true;
|
||||
|
||||
useUserStoreHook()
|
||||
.refreshToken()
|
||||
.then(() => {
|
||||
// 刷新成功,重试所有等待的请求
|
||||
pendingRequests.forEach((callback) => {
|
||||
try {
|
||||
callback();
|
||||
} catch (error) {
|
||||
console.error("Retry request error:", error);
|
||||
}
|
||||
});
|
||||
// 清空队列
|
||||
pendingRequests.length = 0;
|
||||
})
|
||||
.catch(async (error) => {
|
||||
console.error("Token refresh failed:", error);
|
||||
// 刷新失败,先 reject 所有等待的请求,再清空队列
|
||||
const failedRequests = [...pendingRequests];
|
||||
pendingRequests.length = 0;
|
||||
|
||||
// 拒绝所有等待的请求
|
||||
failedRequests.forEach(() => {
|
||||
reject(new Error("Token refresh failed"));
|
||||
});
|
||||
|
||||
// 跳转登录页
|
||||
await redirectToLogin("登录状态已失效,请重新登录");
|
||||
})
|
||||
.finally(() => {
|
||||
isRefreshingToken = false;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取刷新状态(用于外部判断)
|
||||
*/
|
||||
function getRefreshStatus() {
|
||||
return {
|
||||
isRefreshing: isRefreshingToken,
|
||||
pendingCount: pendingRequests.length,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
refreshTokenAndRetry,
|
||||
getRefreshStatus,
|
||||
};
|
||||
}
|
||||
@@ -1,14 +1,11 @@
|
||||
export { useStomp } from "./websocket/useStomp";
|
||||
export { useDictSync } from "./websocket/useDictSync";
|
||||
export type { DictMessage } from "./websocket/useDictSync";
|
||||
export { useOnlineCount } from "./websocket/useOnlineCount";
|
||||
export { useTokenRefresh } from "./auth/useTokenRefresh";
|
||||
// WebSocket 服务
|
||||
export { setupWebSocket, cleanupWebSocket } from "./websocket";
|
||||
export { useStomp, useDictSync, useOnlineCount } from "./websocket";
|
||||
export type { DictMessage, DictChangeMessage, DictChangeCallback } from "./websocket";
|
||||
|
||||
export { useLayout } from "./layout/useLayout";
|
||||
export { useLayoutMenu } from "./layout/useLayoutMenu";
|
||||
export { useDeviceDetection } from "./layout/useDeviceDetection";
|
||||
// AI 相关
|
||||
export { useAiAction } from "./ai/useAiAction";
|
||||
export type { UseAiActionOptions, AiActionHandler } from "./ai/useAiAction";
|
||||
|
||||
export { useAiAction } from "./useAiAction";
|
||||
export type { UseAiActionOptions, AiActionHandler } from "./useAiAction";
|
||||
|
||||
export { useTableSelection } from "./useTableSelection";
|
||||
// 表格相关
|
||||
export { useTableSelection } from "./table/useTableSelection";
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import { watchEffect, computed } from "vue";
|
||||
import { useWindowSize } from "@vueuse/core";
|
||||
import { useAppStore } from "@/store";
|
||||
import { DeviceEnum } from "@/enums/settings/device-enum";
|
||||
|
||||
/**
|
||||
* 设备检测和响应式处理
|
||||
* 监听屏幕尺寸变化,自动调整设备类型和侧边栏状态
|
||||
*/
|
||||
export function useDeviceDetection() {
|
||||
const appStore = useAppStore();
|
||||
const { width } = useWindowSize();
|
||||
|
||||
// 桌面设备断点
|
||||
const DESKTOP_BREAKPOINT = 992;
|
||||
|
||||
// 计算设备类型
|
||||
const isDesktop = computed(() => width.value >= DESKTOP_BREAKPOINT);
|
||||
const isMobile = computed(() => appStore.device === DeviceEnum.MOBILE);
|
||||
|
||||
// 监听屏幕尺寸变化,自动调整设备类型和侧边栏状态
|
||||
watchEffect(() => {
|
||||
const deviceType = isDesktop.value ? DeviceEnum.DESKTOP : DeviceEnum.MOBILE;
|
||||
|
||||
// 更新设备类型
|
||||
appStore.toggleDevice(deviceType);
|
||||
|
||||
// 根据设备类型调整侧边栏状态
|
||||
if (isDesktop.value) {
|
||||
appStore.openSideBar();
|
||||
} else {
|
||||
appStore.closeSideBar();
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
isDesktop,
|
||||
isMobile,
|
||||
};
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import { useAppStore, useSettingsStore } from "@/store";
|
||||
import { defaultSettings } from "@/settings";
|
||||
|
||||
/**
|
||||
* 布局相关的通用逻辑
|
||||
*/
|
||||
export function useLayout() {
|
||||
const appStore = useAppStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
|
||||
// 计算当前布局模式
|
||||
const currentLayout = computed(() => settingsStore.layout);
|
||||
|
||||
// 侧边栏展开状态
|
||||
const isSidebarOpen = computed(() => appStore.sidebar.opened);
|
||||
|
||||
// 是否显示标签视图
|
||||
const isShowTagsView = computed(() => settingsStore.showTagsView);
|
||||
|
||||
// 是否显示设置面板
|
||||
const isShowSettings = computed(() => defaultSettings.showSettings);
|
||||
|
||||
// 是否显示Logo
|
||||
const isShowLogo = computed(() => settingsStore.showAppLogo);
|
||||
|
||||
// 是否移动设备
|
||||
const isMobile = computed(() => appStore.device === "mobile");
|
||||
|
||||
// 布局CSS类
|
||||
const layoutClass = computed(() => ({
|
||||
hideSidebar: !appStore.sidebar.opened,
|
||||
openSidebar: appStore.sidebar.opened,
|
||||
mobile: appStore.device === "mobile",
|
||||
[`layout-${settingsStore.layout}`]: true,
|
||||
}));
|
||||
|
||||
/**
|
||||
* 处理切换侧边栏的展开/收起状态
|
||||
*/
|
||||
function toggleSidebar() {
|
||||
appStore.toggleSidebar();
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭侧边栏(移动端)
|
||||
*/
|
||||
function closeSidebar() {
|
||||
appStore.closeSideBar();
|
||||
}
|
||||
|
||||
return {
|
||||
currentLayout,
|
||||
isSidebarOpen,
|
||||
isShowTagsView,
|
||||
isShowSettings,
|
||||
isShowLogo,
|
||||
isMobile,
|
||||
layoutClass,
|
||||
toggleSidebar,
|
||||
closeSidebar,
|
||||
};
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import { useRoute } from "vue-router";
|
||||
import { useAppStore, usePermissionStore } from "@/store";
|
||||
|
||||
/**
|
||||
* 布局菜单处理逻辑
|
||||
*/
|
||||
export function useLayoutMenu() {
|
||||
const route = useRoute();
|
||||
const appStore = useAppStore();
|
||||
const permissionStore = usePermissionStore();
|
||||
|
||||
// 顶部菜单激活路径
|
||||
const activeTopMenuPath = computed(() => appStore.activeTopMenuPath);
|
||||
|
||||
// 常规路由(左侧菜单或顶部菜单)
|
||||
const routes = computed(() => permissionStore.routes);
|
||||
|
||||
// 混合布局左侧菜单路由
|
||||
const sideMenuRoutes = computed(() => permissionStore.mixLayoutSideMenus);
|
||||
|
||||
// 当前激活的菜单
|
||||
const activeMenu = computed(() => {
|
||||
const { meta, path } = route;
|
||||
|
||||
// 如果设置了activeMenu,则使用
|
||||
if (meta?.activeMenu) {
|
||||
return meta.activeMenu;
|
||||
}
|
||||
|
||||
return path;
|
||||
});
|
||||
|
||||
return {
|
||||
routes,
|
||||
sideMenuRoutes,
|
||||
activeMenu,
|
||||
activeTopMenuPath,
|
||||
};
|
||||
}
|
||||
61
src/composables/websocket/index.ts
Normal file
61
src/composables/websocket/index.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* WebSocket 服务统一管理
|
||||
*
|
||||
* @description
|
||||
* 提供 WebSocket 服务的统一初始化和清理接口
|
||||
* - 字典同步服务
|
||||
* - 在线用户统计服务
|
||||
*
|
||||
* @author 有来技术团队
|
||||
*/
|
||||
|
||||
import { useDictSync } from "./useDictSync";
|
||||
import { useOnlineCount } from "./useOnlineCount";
|
||||
|
||||
/**
|
||||
* 初始化所有 WebSocket 服务
|
||||
*
|
||||
* 应在应用启动时调用,统一初始化所有 WebSocket 连接
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // 在 main.ts 中调用
|
||||
* setupWebSocket();
|
||||
* ```
|
||||
*/
|
||||
export function setupWebSocket() {
|
||||
// 初始化字典同步服务
|
||||
const dictSync = useDictSync();
|
||||
dictSync.initialize();
|
||||
|
||||
// 初始化在线用户统计服务
|
||||
const onlineCount = useOnlineCount();
|
||||
onlineCount.initialize();
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理所有 WebSocket 连接
|
||||
*
|
||||
* 应在用户登出时调用,释放所有 WebSocket 资源
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // 在 user store 的 logout 方法中调用
|
||||
* cleanupWebSocket();
|
||||
* ```
|
||||
*/
|
||||
export function cleanupWebSocket() {
|
||||
// 清理字典同步服务
|
||||
const dictSync = useDictSync();
|
||||
dictSync.cleanup();
|
||||
|
||||
// 清理在线用户统计服务
|
||||
const onlineCount = useOnlineCount();
|
||||
onlineCount.cleanup();
|
||||
}
|
||||
|
||||
// 导出所有 WebSocket 相关的 composables
|
||||
export { useDictSync } from "./useDictSync";
|
||||
export { useOnlineCount } from "./useOnlineCount";
|
||||
export { useStomp } from "./useStomp";
|
||||
export type { DictMessage, DictChangeMessage, DictChangeCallback } from "./useDictSync";
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useDictStoreHook } from "@/store/modules/dict-store";
|
||||
import { useDictStoreHook } from "@/store/modules/dict";
|
||||
import { useStomp } from "./useStomp";
|
||||
import type { IMessage } from "@stomp/stompjs";
|
||||
|
||||
@@ -69,8 +69,6 @@ function createDictSyncComposable() {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[DictSync] 字典 "${dictCode}" 已更新,清除本地缓存`);
|
||||
|
||||
// 清除缓存,等待按需加载
|
||||
dictStore.removeDictItem(dictCode);
|
||||
|
||||
@@ -98,7 +96,7 @@ function createDictSyncComposable() {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("[DictSync] 初始化字典同步服务...");
|
||||
// console.log("[DictSync] 初始化字典同步服务..."); // 高频日志已禁用
|
||||
|
||||
// 建立 WebSocket 连接
|
||||
stomp.connect();
|
||||
@@ -106,19 +104,17 @@ function createDictSyncComposable() {
|
||||
// 订阅字典主题(useStomp 会自动处理重连后的订阅恢复)
|
||||
subscriptionId = stomp.subscribe(DICT_TOPIC, handleDictChangeMessage);
|
||||
|
||||
if (subscriptionId) {
|
||||
console.log(`[DictSync] 已订阅字典主题: ${DICT_TOPIC}`);
|
||||
} else {
|
||||
console.log(`[DictSync] 暂存字典主题订阅,等待连接建立后自动订阅`);
|
||||
}
|
||||
// if (subscriptionId) {
|
||||
// console.log(`[DictSync] 已订阅字典主题: ${DICT_TOPIC}`);
|
||||
// } else {
|
||||
// console.log(`[DictSync] 暂存字典主题订阅,等待连接建立后自动订阅`);
|
||||
// }
|
||||
};
|
||||
|
||||
/**
|
||||
* 关闭 WebSocket 连接并清理资源
|
||||
*/
|
||||
const cleanup = () => {
|
||||
console.log("[DictSync] 清理字典同步服务...");
|
||||
|
||||
// 取消订阅(如果有的话)
|
||||
if (subscriptionId) {
|
||||
stomp.unsubscribe(subscriptionId);
|
||||
@@ -162,14 +158,6 @@ function createDictSyncComposable() {
|
||||
initialize,
|
||||
cleanup,
|
||||
onDictChange,
|
||||
|
||||
// 别名方法(向后兼容)
|
||||
initWebSocket: initialize,
|
||||
closeWebSocket: cleanup,
|
||||
onDictMessage: onDictChange,
|
||||
|
||||
// 用于测试和调试
|
||||
handleDictChangeMessage,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -182,7 +170,7 @@ function createDictSyncComposable() {
|
||||
* ```ts
|
||||
* const dictSync = useDictSync();
|
||||
*
|
||||
* // 初始化(在应用启动时调用)
|
||||
* // 初始化(通常在应用启动时调用)
|
||||
* dictSync.initialize();
|
||||
*
|
||||
* // 注册回调
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { ref, watch, onMounted, onUnmounted, getCurrentInstance } from "vue";
|
||||
import { ref, onMounted, onUnmounted, getCurrentInstance } from "vue";
|
||||
import { useStomp } from "./useStomp";
|
||||
import { registerWebSocketInstance } from "@/plugins/websocket";
|
||||
import { AuthStorage } from "@/utils/auth";
|
||||
|
||||
/**
|
||||
@@ -40,9 +39,6 @@ function createOnlineCountComposable() {
|
||||
// 订阅 ID
|
||||
let subscriptionId: string | null = null;
|
||||
|
||||
// 注册到全局实例管理器
|
||||
registerWebSocketInstance("onlineCount", stomp);
|
||||
|
||||
/**
|
||||
* 处理在线用户数量消息
|
||||
*/
|
||||
@@ -59,7 +55,6 @@ function createOnlineCountComposable() {
|
||||
if (count !== undefined && !isNaN(count)) {
|
||||
onlineUserCount.value = count;
|
||||
lastUpdateTime.value = Date.now();
|
||||
console.log(`[useOnlineCount] 在线用户数更新: ${count}`);
|
||||
} else {
|
||||
console.warn("[useOnlineCount] 收到无效的在线用户数:", data);
|
||||
}
|
||||
@@ -73,18 +68,11 @@ function createOnlineCountComposable() {
|
||||
*/
|
||||
const subscribeToOnlineCount = () => {
|
||||
if (subscriptionId) {
|
||||
console.log("[useOnlineCount] 已存在订阅,跳过");
|
||||
return;
|
||||
}
|
||||
|
||||
// 订阅在线用户计数主题(useStomp 会处理重连后的订阅恢复)
|
||||
subscriptionId = stomp.subscribe(ONLINE_COUNT_TOPIC, handleOnlineCountMessage);
|
||||
|
||||
if (subscriptionId) {
|
||||
console.log(`[useOnlineCount] 已订阅主题: ${ONLINE_COUNT_TOPIC}`);
|
||||
} else {
|
||||
console.log(`[useOnlineCount] 暂存订阅配置,等待连接建立后自动订阅`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -105,8 +93,6 @@ function createOnlineCountComposable() {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("[useOnlineCount] 初始化在线用户计数服务...");
|
||||
|
||||
// 建立 WebSocket 连接
|
||||
stomp.connect();
|
||||
|
||||
@@ -118,8 +104,6 @@ function createOnlineCountComposable() {
|
||||
* 关闭 WebSocket 连接并清理资源
|
||||
*/
|
||||
const cleanup = () => {
|
||||
console.log("[useOnlineCount] 清理在线用户计数服务...");
|
||||
|
||||
// 取消订阅
|
||||
if (subscriptionId) {
|
||||
stomp.unsubscribe(subscriptionId);
|
||||
@@ -137,19 +121,6 @@ function createOnlineCountComposable() {
|
||||
lastUpdateTime.value = 0;
|
||||
};
|
||||
|
||||
// 监听连接状态变化
|
||||
watch(
|
||||
stomp.isConnected,
|
||||
(connected) => {
|
||||
if (connected) {
|
||||
console.log("[useOnlineCount] WebSocket 已连接");
|
||||
} else {
|
||||
console.log("[useOnlineCount] WebSocket 已断开");
|
||||
}
|
||||
},
|
||||
{ immediate: false }
|
||||
);
|
||||
|
||||
return {
|
||||
// 状态
|
||||
onlineUserCount: readonly(onlineUserCount),
|
||||
@@ -160,10 +131,6 @@ function createOnlineCountComposable() {
|
||||
// 方法
|
||||
initialize,
|
||||
cleanup,
|
||||
|
||||
// 别名方法(向后兼容)
|
||||
initWebSocket: initialize,
|
||||
closeWebSocket: cleanup,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -172,15 +139,12 @@ function createOnlineCountComposable() {
|
||||
*
|
||||
* 用于实时显示系统在线用户数量
|
||||
*
|
||||
* @param options 配置选项
|
||||
* @param options.autoInit 是否在组件挂载时自动初始化(默认 true)
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // 在组件中使用
|
||||
* // 在组件中使用(推荐)
|
||||
* const { onlineUserCount, isConnected } = useOnlineCount();
|
||||
*
|
||||
* // 手动控制初始化
|
||||
* // 手动控制初始化(高级用法)
|
||||
* const { onlineUserCount, initialize, cleanup } = useOnlineCount({ autoInit: false });
|
||||
* onMounted(() => initialize());
|
||||
* onUnmounted(() => cleanup());
|
||||
@@ -194,22 +158,19 @@ export function useOnlineCount(options: { autoInit?: boolean } = {}) {
|
||||
globalInstance = createOnlineCountComposable();
|
||||
}
|
||||
|
||||
// 只在组件上下文中且 autoInit 为 true 时使用生命周期钩子
|
||||
// 组件级自动初始化(仅在组件上下文中生效)
|
||||
const instance = getCurrentInstance();
|
||||
if (autoInit && instance) {
|
||||
onMounted(() => {
|
||||
// 只有在未连接时才尝试初始化
|
||||
// 防止重复初始化:只有在未连接时才尝试初始化
|
||||
if (!globalInstance!.isConnected.value) {
|
||||
console.log("[useOnlineCount] 组件挂载,初始化 WebSocket 连接");
|
||||
globalInstance!.initialize();
|
||||
} else {
|
||||
console.log("[useOnlineCount] WebSocket 已连接,跳过初始化");
|
||||
}
|
||||
});
|
||||
|
||||
// 注意:不在卸载时关闭连接,保持全局连接
|
||||
// 注意:组件卸载时不关闭连接,保持全局连接
|
||||
onUnmounted(() => {
|
||||
console.log("[useOnlineCount] 组件卸载(保持 WebSocket 连接)");
|
||||
// 全局连接由 cleanupWebSocket() 统一管理
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,16 @@ export interface UseStompOptions {
|
||||
debug?: boolean;
|
||||
/** 是否在重连时自动恢复订阅,默认为 true */
|
||||
autoRestoreSubscriptions?: boolean;
|
||||
/**
|
||||
* 心跳接收间隔,单位毫秒,默认为 4000
|
||||
* 注意:标签页失活时,浏览器会节流定时器,建议设置较长的间隔(如 10000)以减少失活影响
|
||||
*/
|
||||
heartbeatIncoming?: number;
|
||||
/**
|
||||
* 心跳发送间隔,单位毫秒,默认为 4000
|
||||
* 注意:标签页失活时,浏览器会节流定时器,建议设置较长的间隔(如 10000)以减少失活影响
|
||||
*/
|
||||
heartbeatOutgoing?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -65,6 +75,8 @@ export function useStomp(options: UseStompOptions = {}) {
|
||||
maxReconnectDelay: options.maxReconnectDelay ?? 60000,
|
||||
autoRestoreSubscriptions: options.autoRestoreSubscriptions ?? true,
|
||||
debug: options.debug ?? false,
|
||||
heartbeatIncoming: options.heartbeatIncoming ?? 4000,
|
||||
heartbeatOutgoing: options.heartbeatOutgoing ?? 4000,
|
||||
};
|
||||
|
||||
// ==================== 状态管理 ====================
|
||||
@@ -105,19 +117,9 @@ export function useStomp(options: UseStompOptions = {}) {
|
||||
/**
|
||||
* 日志输出(支持调试模式控制)
|
||||
*/
|
||||
const log = (...args: any[]) => {
|
||||
if (config.debug) {
|
||||
console.log("[useStomp]", ...args);
|
||||
}
|
||||
};
|
||||
|
||||
const logWarn = (...args: any[]) => {
|
||||
console.warn("[useStomp]", ...args);
|
||||
};
|
||||
|
||||
const logError = (...args: any[]) => {
|
||||
console.error("[useStomp]", ...args);
|
||||
};
|
||||
const log = config.debug ? (...args: any[]) => console.log("[useStomp]", ...args) : () => {};
|
||||
const logWarn = (...args: any[]) => console.warn("[useStomp]", ...args);
|
||||
const logError = (...args: any[]) => console.error("[useStomp]", ...args);
|
||||
|
||||
/**
|
||||
* 恢复所有订阅
|
||||
@@ -179,8 +181,8 @@ export function useStomp(options: UseStompOptions = {}) {
|
||||
},
|
||||
debug: config.debug ? (msg) => console.log("[STOMP]", msg) : () => {},
|
||||
reconnectDelay: 0, // 禁用内置重连,使用自定义重连逻辑
|
||||
heartbeatIncoming: 4000,
|
||||
heartbeatOutgoing: 4000,
|
||||
heartbeatIncoming: config.heartbeatIncoming,
|
||||
heartbeatOutgoing: config.heartbeatOutgoing,
|
||||
});
|
||||
|
||||
// ==================== 事件监听器 ====================
|
||||
@@ -312,6 +314,41 @@ export function useStomp(options: UseStompOptions = {}) {
|
||||
// 初始化客户端
|
||||
initializeClient();
|
||||
|
||||
// ==================== 标签页可见性监听 ====================
|
||||
|
||||
/**
|
||||
* 处理标签页可见性变化
|
||||
* 当标签页从失活变为激活时,检查连接状态并尝试重连
|
||||
*/
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.hidden) {
|
||||
log("标签页已失活");
|
||||
} else {
|
||||
log("标签页已激活,检查WebSocket连接状态...");
|
||||
|
||||
// 标签页激活时,检查连接状态
|
||||
if (stompClient.value && !stompClient.value.connected && !isManualDisconnect) {
|
||||
logWarn("检测到WebSocket连接已断开,尝试重新连接...");
|
||||
// 重置重连次数,给予更多重连机会
|
||||
reconnectAttempts.value = 0;
|
||||
connect();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 监听标签页可见性变化
|
||||
if (typeof document !== "undefined") {
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
}
|
||||
|
||||
// 清理函数:移除事件监听器
|
||||
const cleanup = () => {
|
||||
if (typeof document !== "undefined") {
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
}
|
||||
disconnect();
|
||||
};
|
||||
|
||||
// ==================== 公共接口 ====================
|
||||
|
||||
/**
|
||||
@@ -517,6 +554,7 @@ export function useStomp(options: UseStompOptions = {}) {
|
||||
// 连接管理
|
||||
connect,
|
||||
disconnect,
|
||||
cleanup, // 清理资源(包括移除事件监听器)
|
||||
|
||||
// 订阅管理
|
||||
subscribe,
|
||||
|
||||
@@ -1,74 +1,64 @@
|
||||
/**
|
||||
* 项目常量统一管理
|
||||
* 存储键命名规范:{prefix}:{namespace}:{key}
|
||||
* 应用常量定义
|
||||
*
|
||||
* @description
|
||||
* 包含应用中所有的常量定义,包括角色、存储键名等
|
||||
*/
|
||||
|
||||
/**
|
||||
* 应用存储前缀
|
||||
*/
|
||||
export const APP_PREFIX = "vea";
|
||||
|
||||
/**
|
||||
* 超级管理员角色标识
|
||||
*
|
||||
* @description
|
||||
* 拥有系统最高权限,可以访问所有资源
|
||||
*/
|
||||
export const ROLE_ROOT = "ROOT";
|
||||
|
||||
/**
|
||||
* 存储键名常量
|
||||
*
|
||||
* @description
|
||||
* 统一管理所有 localStorage/sessionStorage 的键名
|
||||
* 命名规则:{APP_PREFIX}:{分类}:{具体名称}
|
||||
*/
|
||||
export const STORAGE_KEYS = {
|
||||
// 用户认证相关
|
||||
ACCESS_TOKEN: `${APP_PREFIX}:auth:access_token`, // JWT访问令牌
|
||||
REFRESH_TOKEN: `${APP_PREFIX}:auth:refresh_token`, // JWT刷新令牌
|
||||
REMEMBER_ME: `${APP_PREFIX}:auth:remember_me`, // 记住登录状态
|
||||
// ===== 认证相关 =====
|
||||
ACCESS_TOKEN: `${APP_PREFIX}:auth:access_token`,
|
||||
REFRESH_TOKEN: `${APP_PREFIX}:auth:refresh_token`,
|
||||
REMEMBER_ME: `${APP_PREFIX}:auth:remember_me`,
|
||||
|
||||
// 系统核心相关
|
||||
DICT_CACHE: `${APP_PREFIX}:system:dict_cache`, // 字典数据缓存
|
||||
// ===== 租户相关 =====
|
||||
TENANT_ID: `${APP_PREFIX}:tenant:id`,
|
||||
TENANT_INFO: `${APP_PREFIX}:tenant:info`,
|
||||
|
||||
// UI设置相关
|
||||
SHOW_TAGS_VIEW: `${APP_PREFIX}:ui:show_tags_view`, // 显示标签页视图
|
||||
SHOW_APP_LOGO: `${APP_PREFIX}:ui:show_app_logo`, // 显示应用Logo
|
||||
SHOW_WATERMARK: `${APP_PREFIX}:ui:show_watermark`, // 显示水印
|
||||
ENABLE_AI_ASSISTANT: `${APP_PREFIX}:ui:enable_ai_assistant`, // 启用 AI 助手
|
||||
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`, // 主题色
|
||||
// ===== 系统相关 =====
|
||||
DICT_CACHE: `${APP_PREFIX}:system:dict_cache`,
|
||||
|
||||
// 应用状态相关
|
||||
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 ROLE_ROOT = "ROOT"; // 超级管理员角色
|
||||
|
||||
// 分组键集合(便于批量操作)
|
||||
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 SYSTEM_KEYS = {
|
||||
DICT_CACHE: STORAGE_KEYS.DICT_CACHE,
|
||||
} as const;
|
||||
|
||||
export const SETTINGS_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,
|
||||
SIDEBAR_COLOR_SCHEME: STORAGE_KEYS.SIDEBAR_COLOR_SCHEME,
|
||||
LAYOUT: STORAGE_KEYS.LAYOUT,
|
||||
THEME_COLOR: STORAGE_KEYS.THEME_COLOR,
|
||||
THEME: STORAGE_KEYS.THEME,
|
||||
} 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 const ALL_STORAGE_KEYS = {
|
||||
...AUTH_KEYS,
|
||||
...SYSTEM_KEYS,
|
||||
...SETTINGS_KEYS,
|
||||
...APP_KEYS,
|
||||
// ===== 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`,
|
||||
GRAY_MODE: `${APP_PREFIX}:ui:gray_mode`,
|
||||
COLOR_WEAK: `${APP_PREFIX}:ui:color_weak`,
|
||||
|
||||
// ===== 应用状态 =====
|
||||
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 type StorageKey = (typeof STORAGE_KEYS)[keyof typeof STORAGE_KEYS];
|
||||
|
||||
@@ -13,7 +13,7 @@ export const hasPerm: Directive = {
|
||||
// 校验传入的权限值是否合法
|
||||
if (!requiredPerms || (typeof requiredPerms !== "string" && !Array.isArray(requiredPerms))) {
|
||||
throw new Error(
|
||||
"需要提供权限标识!例如:v-has-perm=\"'sys:user:add'\" 或 v-has-perm=\"['sys:user:add', 'sys:user:edit']\""
|
||||
"需要提供权限标识!例如:v-has-perm=\"'sys:user:create'\" 或 v-has-perm=\"['sys:user:create', 'sys:user:update']\""
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
/**
|
||||
* API响应码枚举
|
||||
* API 相关枚举
|
||||
*
|
||||
* @description
|
||||
* 包含 API 响应码、请求状态等枚举定义
|
||||
*/
|
||||
|
||||
/**
|
||||
* API 响应码枚举
|
||||
*/
|
||||
export const enum ApiCodeEnum {
|
||||
/**
|
||||
@@ -20,4 +27,9 @@ export const enum ApiCodeEnum {
|
||||
* 刷新令牌无效或过期
|
||||
*/
|
||||
REFRESH_TOKEN_INVALID = "A0231",
|
||||
|
||||
/**
|
||||
* 需要选择租户
|
||||
*/
|
||||
CHOOSE_TENANT = "A0250",
|
||||
}
|
||||
27
src/enums/business.ts
Normal file
27
src/enums/business.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* 业务相关枚举
|
||||
*
|
||||
* @description
|
||||
* 包含菜单、用户、角色等业务实体的枚举定义
|
||||
*/
|
||||
|
||||
/**
|
||||
* 菜单类型枚举
|
||||
*/
|
||||
export enum MenuTypeEnum {
|
||||
CATALOG = "C", // 目录
|
||||
MENU = "M", // 菜单
|
||||
BUTTON = "B", // 按钮
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户性别枚举
|
||||
*/
|
||||
export enum UserGender {
|
||||
/** 未知 */
|
||||
UNKNOWN = 0,
|
||||
/** 男 */
|
||||
MALE = 1,
|
||||
/** 女 */
|
||||
FEMALE = 2,
|
||||
}
|
||||
@@ -1,3 +1,26 @@
|
||||
/**
|
||||
* 代码生成相关枚举
|
||||
*
|
||||
* @description
|
||||
* 包含表单类型、查询类型等代码生成功能的枚举定义
|
||||
*/
|
||||
|
||||
/**
|
||||
* 表单类型枚举
|
||||
*/
|
||||
export const FormTypeEnum: Record<string, OptionType> = {
|
||||
INPUT: { value: 1, label: "输入框" },
|
||||
SELECT: { value: 2, label: "下拉框" },
|
||||
RADIO: { value: 3, label: "单选框" },
|
||||
CHECK_BOX: { value: 4, label: "复选框" },
|
||||
INPUT_NUMBER: { value: 5, label: "数字输入框" },
|
||||
SWITCH: { value: 6, label: "开关" },
|
||||
TEXT_AREA: { value: 7, label: "文本域" },
|
||||
DATE: { value: 8, label: "日期框" },
|
||||
DATE_TIME: { value: 9, label: "日期时间框" },
|
||||
HIDDEN: { value: 10, label: "隐藏域" },
|
||||
};
|
||||
|
||||
/**
|
||||
* 查询类型枚举
|
||||
*/
|
||||
@@ -1,15 +0,0 @@
|
||||
/**
|
||||
* 表单类型枚举
|
||||
*/
|
||||
export const FormTypeEnum: Record<string, OptionType> = {
|
||||
INPUT: { value: 1, label: "输入框" },
|
||||
SELECT: { value: 2, label: "下拉框" },
|
||||
RADIO: { value: 3, label: "单选框" },
|
||||
CHECK_BOX: { value: 4, label: "复选框" },
|
||||
INPUT_NUMBER: { value: 5, label: "数字输入框" },
|
||||
SWITCH: { value: 6, label: "开关" },
|
||||
TEXT_AREA: { value: 7, label: "文本域" },
|
||||
DATE: { value: 8, label: "日期框" },
|
||||
DATE_TIME: { value: 9, label: "日期时间框" },
|
||||
HIDDEN: { value: 10, label: "隐藏域" },
|
||||
};
|
||||
46
src/enums/common.ts
Normal file
46
src/enums/common.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* 通用枚举
|
||||
*
|
||||
* @description
|
||||
* 包含对话框模式、通用状态等跨业务的枚举定义
|
||||
*/
|
||||
|
||||
/**
|
||||
* 对话框模式枚举
|
||||
*
|
||||
* @description
|
||||
* 定义对话框的操作模式(创建、编辑、查看)
|
||||
*/
|
||||
export enum DialogMode {
|
||||
/** 创建模式 - 新增数据 */
|
||||
CREATE = "create",
|
||||
/** 编辑模式 - 修改数据 */
|
||||
EDIT = "edit",
|
||||
/** 查看模式 - 只读展示 */
|
||||
VIEW = "view",
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用状态枚举
|
||||
*
|
||||
* @description
|
||||
* 适用于大多数业务实体的启用/禁用状态
|
||||
*/
|
||||
export enum CommonStatus {
|
||||
/** 禁用 */
|
||||
DISABLED = 0,
|
||||
/** 启用 */
|
||||
ENABLED = 1,
|
||||
}
|
||||
|
||||
/**
|
||||
* 审核状态枚举
|
||||
*/
|
||||
export enum AuditStatus {
|
||||
/** 待审核 */
|
||||
PENDING = 0,
|
||||
/** 已通过 */
|
||||
APPROVED = 1,
|
||||
/** 已拒绝 */
|
||||
REJECTED = 2,
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
export * from "./api/code-enum";
|
||||
/**
|
||||
* 枚举统一导出
|
||||
*
|
||||
* @description
|
||||
* 按业务域分组的枚举定义
|
||||
*/
|
||||
|
||||
export * from "./codegen/form-enum";
|
||||
export * from "./codegen/query-enum";
|
||||
|
||||
export * from "./settings/layout-enum";
|
||||
export * from "./settings/theme-enum";
|
||||
export * from "./settings/locale-enum";
|
||||
export * from "./settings/device-enum";
|
||||
|
||||
export * from "./system/menu-enum";
|
||||
export * from "./api";
|
||||
export * from "./business";
|
||||
export * from "./codegen";
|
||||
export * from "./common";
|
||||
export * from "./settings";
|
||||
|
||||
123
src/enums/settings.ts
Normal file
123
src/enums/settings.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* 设置相关枚举
|
||||
*
|
||||
* @description
|
||||
* 包含主题、布局、语言、设备等应用设置的枚举定义
|
||||
*/
|
||||
|
||||
/**
|
||||
* 主题模式枚举
|
||||
*/
|
||||
export const enum ThemeMode {
|
||||
/**
|
||||
* 明亮主题
|
||||
*/
|
||||
LIGHT = "light",
|
||||
/**
|
||||
* 暗黑主题
|
||||
*/
|
||||
DARK = "dark",
|
||||
|
||||
/**
|
||||
* 系统自动
|
||||
*/
|
||||
AUTO = "auto",
|
||||
}
|
||||
|
||||
/**
|
||||
* 侧边栏配色方案枚举
|
||||
*/
|
||||
export const enum SidebarColor {
|
||||
/**
|
||||
* 经典蓝
|
||||
*/
|
||||
CLASSIC_BLUE = "classic-blue",
|
||||
/**
|
||||
* 极简白
|
||||
*/
|
||||
MINIMAL_WHITE = "minimal-white",
|
||||
}
|
||||
|
||||
/**
|
||||
* 菜单布局枚举
|
||||
*/
|
||||
export const enum LayoutMode {
|
||||
/**
|
||||
* 左侧菜单布局
|
||||
*/
|
||||
LEFT = "left",
|
||||
/**
|
||||
* 顶部菜单布局
|
||||
*/
|
||||
TOP = "top",
|
||||
|
||||
/**
|
||||
* 混合菜单布局
|
||||
*/
|
||||
MIX = "mix",
|
||||
}
|
||||
|
||||
/**
|
||||
* 侧边栏状态枚举
|
||||
*/
|
||||
export const enum SidebarStatus {
|
||||
/**
|
||||
* 展开
|
||||
*/
|
||||
OPENED = "opened",
|
||||
|
||||
/**
|
||||
* 关闭
|
||||
*/
|
||||
CLOSED = "closed",
|
||||
}
|
||||
|
||||
/**
|
||||
* 组件尺寸枚举
|
||||
*/
|
||||
export const enum ComponentSize {
|
||||
/**
|
||||
* 默认
|
||||
*/
|
||||
DEFAULT = "default",
|
||||
|
||||
/**
|
||||
* 大型
|
||||
*/
|
||||
LARGE = "large",
|
||||
|
||||
/**
|
||||
* 小型
|
||||
*/
|
||||
SMALL = "small",
|
||||
}
|
||||
|
||||
/**
|
||||
* 语言枚举
|
||||
*/
|
||||
export const enum LanguageEnum {
|
||||
/**
|
||||
* 中文
|
||||
*/
|
||||
ZH_CN = "zh-cn",
|
||||
|
||||
/**
|
||||
* 英文
|
||||
*/
|
||||
EN = "en",
|
||||
}
|
||||
|
||||
/**
|
||||
* 设备枚举
|
||||
*/
|
||||
export const enum DeviceEnum {
|
||||
/**
|
||||
* 宽屏设备
|
||||
*/
|
||||
DESKTOP = "desktop",
|
||||
|
||||
/**
|
||||
* 窄屏设备
|
||||
*/
|
||||
MOBILE = "mobile",
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
/**
|
||||
* 设备枚举
|
||||
*/
|
||||
export const enum DeviceEnum {
|
||||
/**
|
||||
* 宽屏设备
|
||||
*/
|
||||
DESKTOP = "desktop",
|
||||
|
||||
/**
|
||||
* 窄屏设备
|
||||
*/
|
||||
MOBILE = "mobile",
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
/**
|
||||
* 菜单布局枚举
|
||||
*/
|
||||
export const enum LayoutMode {
|
||||
/**
|
||||
* 左侧菜单布局
|
||||
*/
|
||||
LEFT = "left",
|
||||
/**
|
||||
* 顶部菜单布局
|
||||
*/
|
||||
TOP = "top",
|
||||
|
||||
/**
|
||||
* 混合菜单布局
|
||||
*/
|
||||
MIX = "mix",
|
||||
}
|
||||
|
||||
/**
|
||||
* 侧边栏状态枚举
|
||||
*/
|
||||
export const enum SidebarStatus {
|
||||
/**
|
||||
* 展开
|
||||
*/
|
||||
OPENED = "opened",
|
||||
|
||||
/**
|
||||
* 关闭
|
||||
*/
|
||||
CLOSED = "closed",
|
||||
}
|
||||
|
||||
/**
|
||||
* 组件尺寸枚举
|
||||
*/
|
||||
export const enum ComponentSize {
|
||||
/**
|
||||
* 默认
|
||||
*/
|
||||
DEFAULT = "default",
|
||||
|
||||
/**
|
||||
* 大型
|
||||
*/
|
||||
LARGE = "large",
|
||||
|
||||
/**
|
||||
* 小型
|
||||
*/
|
||||
SMALL = "small",
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
/**
|
||||
* 语言枚举
|
||||
*/
|
||||
export const enum LanguageEnum {
|
||||
/**
|
||||
* 中文
|
||||
*/
|
||||
ZH_CN = "zh-cn",
|
||||
|
||||
/**
|
||||
* 英文
|
||||
*/
|
||||
EN = "en",
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
/**
|
||||
* 主题枚举
|
||||
*/
|
||||
export const enum ThemeMode {
|
||||
/**
|
||||
* 明亮主题
|
||||
*/
|
||||
LIGHT = "light",
|
||||
/**
|
||||
* 暗黑主题
|
||||
*/
|
||||
DARK = "dark",
|
||||
|
||||
/**
|
||||
* 系统自动
|
||||
*/
|
||||
AUTO = "auto",
|
||||
}
|
||||
|
||||
/**
|
||||
* 侧边栏配色方案枚举
|
||||
*/
|
||||
export const enum SidebarColor {
|
||||
/**
|
||||
* 经典蓝
|
||||
*/
|
||||
CLASSIC_BLUE = "classic-blue",
|
||||
/**
|
||||
* 极简白
|
||||
*/
|
||||
MINIMAL_WHITE = "minimal-white",
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
// 核心枚举定义
|
||||
export enum MenuTypeEnum {
|
||||
CATALOG = 2, // 目录
|
||||
MENU = 1, // 菜单
|
||||
BUTTON = 4, // 按钮
|
||||
EXTLINK = 3, // 外链
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { App } from "vue";
|
||||
import { createI18n } from "vue-i18n";
|
||||
import { useAppStoreHook } from "@/store/modules/app-store";
|
||||
import { useAppStoreHook } from "@/store/modules/app";
|
||||
// 本地语言包
|
||||
import enLocale from "./package/en.json";
|
||||
import zhCnLocale from "./package/zh-cn.json";
|
||||
|
||||
13
src/lang/utils.ts
Normal file
13
src/lang/utils.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* 国际化工具函数
|
||||
*/
|
||||
import i18n from "./index";
|
||||
|
||||
/**
|
||||
* 翻译路由标题
|
||||
* 用于面包屑、侧边栏、标签页等场景
|
||||
*/
|
||||
export function translateRouteTitle(title: string): string {
|
||||
const key = `route.${title}`;
|
||||
return i18n.global.te(key) ? i18n.global.t(key) : title;
|
||||
}
|
||||
@@ -1,21 +1,17 @@
|
||||
<template>
|
||||
<div class="layout" :class="layoutClass">
|
||||
<!-- 移动端遮罩层 - 当侧边栏打开时显示 -->
|
||||
<!-- 移动端遮罩层 -->
|
||||
<div v-if="isMobile && isSidebarOpen" class="layout__overlay" @click="closeSidebar" />
|
||||
|
||||
<!-- 布局内容插槽 - 各种布局模式的具体内容 -->
|
||||
<slot></slot>
|
||||
<!-- 布局内容插槽 -->
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useLayout, useDeviceDetection } from "@/composables";
|
||||
import { useLayout } from "./useLayout";
|
||||
|
||||
/// Layout-related functionality and state management
|
||||
const { layoutClass, isSidebarOpen, closeSidebar } = useLayout();
|
||||
|
||||
/// Device detection for responsive layout
|
||||
const { isMobile } = useDeviceDetection();
|
||||
const { layoutClass, isSidebarOpen, isMobile, closeSidebar } = useLayout();
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@@ -1,47 +1,40 @@
|
||||
<template>
|
||||
<BaseLayout>
|
||||
<!-- 左侧菜单栏 -->
|
||||
<!-- 左侧è<EFBFBD>œå<EFBFBD>•æ ?-->
|
||||
<div class="layout__sidebar" :class="{ 'layout__sidebar--collapsed': !isSidebarOpen }">
|
||||
<div :class="{ 'has-logo': isShowLogo }" class="layout-sidebar">
|
||||
<!-- Logo -->
|
||||
<AppLogo v-if="isShowLogo" :collapse="!isSidebarOpen" />
|
||||
<!-- 主菜单内容 -->
|
||||
<div :class="{ 'has-logo': showLogo }" class="layout-sidebar">
|
||||
<LayoutLogo v-if="showLogo" :collapse="!isSidebarOpen" />
|
||||
<el-scrollbar>
|
||||
<BasicMenu :data="routes" base-path="" />
|
||||
<LayoutSidebar :data="routes" base-path="" />
|
||||
</el-scrollbar>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主内容区 -->
|
||||
<div
|
||||
class="layout__main"
|
||||
:class="{
|
||||
hasTagsView: isShowTagsView,
|
||||
hasTagsView: showTagsView,
|
||||
'layout__main--collapsed': !isSidebarOpen,
|
||||
}"
|
||||
class="layout__main"
|
||||
>
|
||||
<NavBar />
|
||||
<TagsView v-if="isShowTagsView" />
|
||||
<AppMain />
|
||||
<LayoutNavbar />
|
||||
<LayoutTagsView v-if="showTagsView" />
|
||||
<LayoutMain />
|
||||
</div>
|
||||
</BaseLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useLayout } from "@/composables/layout/useLayout";
|
||||
import { useLayoutMenu } from "@/composables/layout/useLayoutMenu";
|
||||
import BaseLayout from "../base/index.vue";
|
||||
import AppLogo from "../../components/AppLogo/index.vue";
|
||||
import NavBar from "../../components/NavBar/index.vue";
|
||||
import TagsView from "../../components/TagsView/index.vue";
|
||||
import AppMain from "../../components/AppMain/index.vue";
|
||||
import BasicMenu from "../../components/Menu/BasicMenu.vue";
|
||||
import { useLayout } from "./useLayout";
|
||||
import BaseLayout from "./BaseLayout.vue";
|
||||
import LayoutLogo from "./components/LayoutLogo.vue";
|
||||
import LayoutNavbar from "./components/LayoutNavbar.vue";
|
||||
import LayoutTagsView from "./components/LayoutTagsView.vue";
|
||||
import LayoutMain from "./components/LayoutMain.vue";
|
||||
import LayoutSidebar from "./components/LayoutSidebar.vue";
|
||||
|
||||
// 布局相关参数
|
||||
const { isShowTagsView, isShowLogo, isSidebarOpen } = useLayout();
|
||||
|
||||
// 菜单相关
|
||||
const { routes } = useLayoutMenu();
|
||||
const { showTagsView, showLogo, isSidebarOpen, routes } = useLayout();
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@@ -98,7 +91,7 @@ const { routes } = useLayoutMenu();
|
||||
}
|
||||
}
|
||||
|
||||
/* 移动端样式 */
|
||||
/* ç§»åŠ¨ç«¯æ ·å¼?*/
|
||||
.mobile {
|
||||
.layout__sidebar {
|
||||
width: $sidebar-width !important;
|
||||
360
src/layouts/MixLayout.vue
Normal file
360
src/layouts/MixLayout.vue
Normal file
@@ -0,0 +1,360 @@
|
||||
<template>
|
||||
<BaseLayout>
|
||||
<!-- 顶部菜单栏 -->
|
||||
<div class="layout__header">
|
||||
<div class="layout__header-content">
|
||||
<div v-if="showLogo" class="layout__header-logo">
|
||||
<LayoutLogo :collapse="isLogoCollapsed" />
|
||||
</div>
|
||||
|
||||
<!-- 顶部菜单 -->
|
||||
<div class="layout__header-menu">
|
||||
<el-menu
|
||||
mode="horizontal"
|
||||
:default-active="activeTopMenuPath"
|
||||
:background-color="useMenuColors ? variables['menu-background'] : undefined"
|
||||
:text-color="useMenuColors ? variables['menu-text'] : undefined"
|
||||
:active-text-color="useMenuColors ? variables['menu-active-text'] : undefined"
|
||||
@select="handleTopMenuSelect"
|
||||
>
|
||||
<el-menu-item v-for="item in topMenuItems" :key="item.path" :index="item.path">
|
||||
<template v-if="item.meta">
|
||||
<MenuIcon :icon="item.meta.icon" />
|
||||
<span v-if="item.meta.title" class="ml-1">
|
||||
{{ translateRouteTitle(item.meta.title) }}
|
||||
</span>
|
||||
</template>
|
||||
</el-menu-item>
|
||||
</el-menu>
|
||||
</div>
|
||||
|
||||
<div class="layout__header-actions">
|
||||
<LayoutToolbar />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主内容区容器 -->
|
||||
<div class="layout__container">
|
||||
<!-- 左侧菜单栏 -->
|
||||
<div class="layout__sidebar--left" :class="{ 'layout__sidebar--collapsed': !isSidebarOpen }">
|
||||
<el-scrollbar>
|
||||
<el-menu
|
||||
:default-active="activeSideMenuPath"
|
||||
:collapse="!isSidebarOpen"
|
||||
:collapse-transition="false"
|
||||
:unique-opened="false"
|
||||
:background-color="variables['menu-background']"
|
||||
:text-color="variables['menu-text']"
|
||||
:active-text-color="variables['menu-active-text']"
|
||||
>
|
||||
<LayoutSidebarItem
|
||||
v-for="item in sideMenuRoutes"
|
||||
:key="item.path"
|
||||
:item="item"
|
||||
:base-path="resolvePath(item.path)"
|
||||
/>
|
||||
</el-menu>
|
||||
</el-scrollbar>
|
||||
<div class="layout__sidebar-toggle">
|
||||
<Hamburger :is-active="isSidebarOpen" @toggle-click="toggleSidebar" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主内容区 -->
|
||||
<div :class="{ hasTagsView: showTagsView }" class="layout__main">
|
||||
<LayoutTagsView v-if="showTagsView" />
|
||||
<LayoutMain />
|
||||
</div>
|
||||
</div>
|
||||
</BaseLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { LocationQueryRaw, RouteRecordRaw } from "vue-router";
|
||||
import { useWindowSize } from "@vueuse/core";
|
||||
import { useLayout } from "./useLayout";
|
||||
import { useAppStore, usePermissionStore, useSettingsStore } from "@/store";
|
||||
import { isExternal } from "@/utils/index";
|
||||
import { translateRouteTitle } from "@/lang/utils";
|
||||
import { SidebarColor } from "@/enums/settings";
|
||||
import { ElIcon } from "element-plus";
|
||||
import BaseLayout from "./BaseLayout.vue";
|
||||
import LayoutLogo from "./components/LayoutLogo.vue";
|
||||
import LayoutToolbar from "./components/LayoutToolbar.vue";
|
||||
import LayoutTagsView from "./components/LayoutTagsView.vue";
|
||||
import LayoutMain from "./components/LayoutMain.vue";
|
||||
import LayoutSidebarItem from "./components/LayoutSidebarItem.vue";
|
||||
import Hamburger from "@/components/Hamburger/index.vue";
|
||||
import variables from "@/styles/variables.module.scss";
|
||||
|
||||
// 菜单图标渲染组件
|
||||
const MenuIcon = defineComponent({
|
||||
props: { icon: String },
|
||||
setup(props) {
|
||||
const isElIcon = computed(() => props.icon?.startsWith("el-icon"));
|
||||
const iconName = computed(() => props.icon?.replace("el-icon-", ""));
|
||||
|
||||
return () => {
|
||||
if (!props.icon) {
|
||||
return h("div", { class: "i-svg:menu" });
|
||||
}
|
||||
|
||||
// Element Plus 图标
|
||||
if (isElIcon.value) {
|
||||
return h(ElIcon, null, () => h(resolveComponent(iconName.value!)));
|
||||
}
|
||||
|
||||
// SVG 图标
|
||||
return h("div", { class: `i-svg:${props.icon}` });
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { width } = useWindowSize();
|
||||
|
||||
const appStore = useAppStore();
|
||||
const permissionStore = usePermissionStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
|
||||
const { showTagsView, showLogo, isSidebarOpen, toggleSidebar, sideMenuRoutes, activeTopMenuPath } =
|
||||
useLayout();
|
||||
|
||||
const isLogoCollapsed = computed(() => width.value < 768);
|
||||
|
||||
// 是否使用深色菜单配色(暗色主题或经典蓝侧边栏)
|
||||
const useMenuColors = computed(
|
||||
() =>
|
||||
settingsStore.theme === "dark" || settingsStore.sidebarColorScheme === SidebarColor.CLASSIC_BLUE
|
||||
);
|
||||
|
||||
// 顶部菜单项(处理单子菜单显示优化)
|
||||
const topMenuItems = computed(() => {
|
||||
const routes = permissionStore.routes.filter((item) => !item.meta?.hidden);
|
||||
|
||||
return routes.map((route) => {
|
||||
// alwaysShow 或无子菜单,直接返回
|
||||
if (route.meta?.alwaysShow || !route.children?.length) return route;
|
||||
|
||||
// 过滤可见子菜单
|
||||
const visibleChildren = route.children.filter((child) => !child.meta?.hidden);
|
||||
|
||||
// 仅一个可见子菜单时,显示子菜单信息
|
||||
if (visibleChildren.length === 1) {
|
||||
const child = visibleChildren[0];
|
||||
return {
|
||||
...route,
|
||||
meta: {
|
||||
...route.meta,
|
||||
title: child.meta?.title || route.meta?.title,
|
||||
icon: child.meta?.icon || route.meta?.icon,
|
||||
},
|
||||
};
|
||||
}
|
||||
return route;
|
||||
});
|
||||
});
|
||||
|
||||
// 左侧菜单激活路径
|
||||
const activeSideMenuPath = computed(() => {
|
||||
const { meta, path } = route;
|
||||
return typeof meta?.activeMenu === "string" ? meta.activeMenu : path;
|
||||
});
|
||||
|
||||
// 解析左侧菜单路径
|
||||
function resolvePath(routePath: string) {
|
||||
if (isExternal(routePath)) return routePath;
|
||||
if (routePath.startsWith("/")) return activeTopMenuPath.value + routePath;
|
||||
return `${activeTopMenuPath.value}/${routePath}`;
|
||||
}
|
||||
|
||||
// 从路径提取顶级菜单路径
|
||||
function extractTopMenuPath(path: string): string {
|
||||
return path.split("/").filter(Boolean).length > 1 ? path.match(/^\/[^/]+/)?.[0] || "/" : "/";
|
||||
}
|
||||
|
||||
// 顶部菜单点击
|
||||
function handleTopMenuSelect(menuPath: string) {
|
||||
if (menuPath === activeTopMenuPath.value) return;
|
||||
|
||||
appStore.activeTopMenu(menuPath);
|
||||
permissionStore.setMixLayoutSideMenus(menuPath);
|
||||
navigateToFirstMenu(permissionStore.mixLayoutSideMenus);
|
||||
}
|
||||
|
||||
// 导航到第一个可访问菜单
|
||||
function navigateToFirstMenu(menus: RouteRecordRaw[]) {
|
||||
if (!menus.length) return;
|
||||
|
||||
const [first] = menus;
|
||||
if (first.children?.length) {
|
||||
navigateToFirstMenu(first.children as RouteRecordRaw[]);
|
||||
} else if (first.name) {
|
||||
router.push({
|
||||
name: first.name,
|
||||
query:
|
||||
typeof first.meta?.params === "object"
|
||||
? (first.meta.params as LocationQueryRaw)
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 监听路由变化,同步顶部菜单状态
|
||||
watch(
|
||||
() => route.path,
|
||||
(newPath) => {
|
||||
const topMenuPath = extractTopMenuPath(newPath);
|
||||
if (topMenuPath !== activeTopMenuPath.value) {
|
||||
appStore.activeTopMenu(topMenuPath);
|
||||
permissionStore.setMixLayoutSideMenus(topMenuPath);
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.layout {
|
||||
&__header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 999;
|
||||
width: 100%;
|
||||
height: $navbar-height;
|
||||
background-color: var(--menu-background);
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
|
||||
&-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
&-logo {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
&-menu {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
|
||||
:deep(.el-menu) {
|
||||
height: 100%;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
}
|
||||
|
||||
:deep(.el-menu--horizontal) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
|
||||
.el-menu-item {
|
||||
height: 100%;
|
||||
line-height: $navbar-height;
|
||||
border-bottom: none;
|
||||
|
||||
&.is-active {
|
||||
background-color: rgba(255, 255, 255, 0.12);
|
||||
border-bottom: 2px solid var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&-actions {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
padding: 0 16px;
|
||||
}
|
||||
}
|
||||
|
||||
&__container {
|
||||
display: flex;
|
||||
height: calc(100vh - $navbar-height);
|
||||
padding-top: 0;
|
||||
|
||||
.layout__sidebar--left {
|
||||
position: relative;
|
||||
width: $sidebar-width;
|
||||
height: 100%;
|
||||
background-color: var(--menu-background);
|
||||
transition: width 0.28s;
|
||||
|
||||
&.layout__sidebar--collapsed {
|
||||
width: $sidebar-width-collapsed !important;
|
||||
}
|
||||
|
||||
:deep(.el-scrollbar) {
|
||||
height: calc(100vh - $navbar-height - 50px);
|
||||
}
|
||||
|
||||
:deep(.el-menu) {
|
||||
height: 100%;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.layout__sidebar-toggle {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 50px;
|
||||
line-height: 50px;
|
||||
background-color: var(--menu-background);
|
||||
box-shadow: 0 0 6px -2px var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.layout__main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
margin-left: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.mobile) {
|
||||
.layout__container {
|
||||
.layout__sidebar--left {
|
||||
position: fixed;
|
||||
top: $navbar-height;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 1000;
|
||||
transition: transform 0.28s;
|
||||
}
|
||||
}
|
||||
|
||||
&.hideSidebar {
|
||||
.layout__sidebar--left {
|
||||
width: $sidebar-width !important;
|
||||
transform: translateX(-$sidebar-width);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.hasTagsView) {
|
||||
.app-main {
|
||||
height: calc(100vh - $navbar-height - $tags-view-height) !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,47 +1,37 @@
|
||||
<template>
|
||||
<BaseLayout>
|
||||
<!-- 顶部菜单栏 -->
|
||||
<!-- 顶部è<EFBFBD>œå<EFBFBD>•æ ?-->
|
||||
<div class="layout__header">
|
||||
<div class="layout__header-left">
|
||||
<!-- Logo -->
|
||||
<AppLogo v-if="isShowLogo" :collapse="isLogoCollapsed" />
|
||||
<!-- 菜单 -->
|
||||
<BasicMenu :data="routes" menu-mode="horizontal" base-path="" />
|
||||
<LayoutLogo v-if="showLogo" :collapse="isLogoCollapsed" />
|
||||
<LayoutSidebar :data="routes" menu-mode="horizontal" base-path="" />
|
||||
</div>
|
||||
<!-- 操作按钮 -->
|
||||
<div class="layout__header-right">
|
||||
<NavbarActions />
|
||||
<LayoutToolbar />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主内容区 -->
|
||||
<div :class="{ hasTagsView: isShowTagsView }" class="layout__main">
|
||||
<TagsView v-if="isShowTagsView" />
|
||||
<AppMain />
|
||||
<div :class="{ hasTagsView: showTagsView }" class="layout__main">
|
||||
<LayoutTagsView v-if="showTagsView" />
|
||||
<LayoutMain />
|
||||
</div>
|
||||
</BaseLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useLayout } from "@/composables/layout/useLayout";
|
||||
import { useLayoutMenu } from "@/composables/layout/useLayoutMenu";
|
||||
import BaseLayout from "../base/index.vue";
|
||||
import AppLogo from "../../components/AppLogo/index.vue";
|
||||
import BasicMenu from "../../components/Menu/BasicMenu.vue";
|
||||
import NavbarActions from "../../components/NavBar/components/NavbarActions.vue";
|
||||
import TagsView from "../../components/TagsView/index.vue";
|
||||
import AppMain from "../../components/AppMain/index.vue";
|
||||
import { useWindowSize } from "@vueuse/core";
|
||||
import { useLayout } from "./useLayout";
|
||||
import BaseLayout from "./BaseLayout.vue";
|
||||
import LayoutLogo from "./components/LayoutLogo.vue";
|
||||
import LayoutSidebar from "./components/LayoutSidebar.vue";
|
||||
import LayoutToolbar from "./components/LayoutToolbar.vue";
|
||||
import LayoutTagsView from "./components/LayoutTagsView.vue";
|
||||
import LayoutMain from "./components/LayoutMain.vue";
|
||||
|
||||
// 布局相关参数
|
||||
const { isShowTagsView, isShowLogo } = useLayout();
|
||||
|
||||
// 菜单相关
|
||||
const { routes } = useLayoutMenu();
|
||||
|
||||
// 响应式窗口尺寸
|
||||
const { showTagsView, showLogo, routes } = useLayout();
|
||||
const { width } = useWindowSize();
|
||||
|
||||
// 只有在小屏设备(移动设备)时才折叠Logo(只显示图标,隐藏文字)
|
||||
const isLogoCollapsed = computed(() => width.value < 768);
|
||||
</script>
|
||||
|
||||
@@ -62,30 +52,28 @@ const isLogoCollapsed = computed(() => width.value < 768);
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
min-width: 0; // 允许flex收缩
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
|
||||
// Logo样式由AppLogo组件的全局样式控制
|
||||
:deep(.logo) {
|
||||
flex-shrink: 0; // 防止Logo被压缩
|
||||
flex-shrink: 0;
|
||||
height: $navbar-height;
|
||||
}
|
||||
}
|
||||
|
||||
&-right {
|
||||
display: flex;
|
||||
flex-shrink: 0; // 防止操作按钮被压缩
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
padding-left: 12px;
|
||||
}
|
||||
|
||||
// 菜单样式
|
||||
:deep(.el-menu--horizontal) {
|
||||
flex: 1;
|
||||
min-width: 0; // 允许菜单收缩
|
||||
min-width: 0;
|
||||
height: $navbar-height;
|
||||
overflow: hidden; // 防止菜单溢出
|
||||
overflow: hidden;
|
||||
line-height: $navbar-height;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
@@ -101,7 +89,6 @@ const isLogoCollapsed = computed(() => width.value < 768);
|
||||
line-height: $navbar-height;
|
||||
}
|
||||
|
||||
// 父菜单激活状态 - 水平布局专用
|
||||
&.has-active-child {
|
||||
.el-sub-menu__title {
|
||||
color: var(--el-color-primary) !important;
|
||||
@@ -114,7 +101,6 @@ const isLogoCollapsed = computed(() => width.value < 768);
|
||||
}
|
||||
}
|
||||
|
||||
// 修复子菜单弹出位置
|
||||
.el-menu--popup {
|
||||
min-width: 160px;
|
||||
}
|
||||
@@ -127,7 +113,6 @@ const isLogoCollapsed = computed(() => width.value < 768);
|
||||
}
|
||||
}
|
||||
|
||||
// 当存在TagsView时的样式调整
|
||||
.hasTagsView {
|
||||
:deep(.app-main) {
|
||||
height: calc(100vh - $navbar-height - $tags-view-height) !important;
|
||||
@@ -4,7 +4,7 @@
|
||||
<router-link :key="+collapse" class="wh-full flex-center" to="/">
|
||||
<img :src="logo" class="w20px h20px" />
|
||||
<span v-if="!collapse" class="title">
|
||||
{{ defaultSettings.title }}
|
||||
{{ appConfig.title }}
|
||||
</span>
|
||||
</router-link>
|
||||
</transition>
|
||||
@@ -12,8 +12,8 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { defaultSettings } from "@/settings";
|
||||
import logo from "@/assets/logo.png";
|
||||
import { appConfig } from "@/settings";
|
||||
import logo from "@/assets/images/logo.png";
|
||||
|
||||
defineProps({
|
||||
collapse: {
|
||||
@@ -40,7 +40,7 @@ defineProps({
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
// 顶部布局和混合布局的特殊处理
|
||||
// 顶部布局和混合布局的特殊处<EFBFBD>?
|
||||
.layout-top,
|
||||
.layout-mix {
|
||||
.logo {
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user