This commit is contained in:
Ray.Hao
2025-12-27 10:47:21 +08:00
239 changed files with 16106 additions and 5516 deletions

View File

@@ -1,7 +0,0 @@
{
"mcpServers": {
"vue-mcp": {
"url": "http://localhost:3000/__mcp/sse"
}
}
}

View File

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

View File

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

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

View File

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

View File

@@ -21,7 +21,7 @@
![](https://foruda.gitee.com/images/1708618984641188532/a7cca095_716974.png "rainbow.png")
<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 交互,根据提示完成信息的输入和选择。
![](https://foruda.gitee.com/images/1687755823165218215/c1705416_716974.png)
## 项目统计
![](https://repobeats.axiom.co/api/embed/aa7cca3d6fa9c308fc659fa6e09af9a1910506c3.svg "Repobeats analytics image")
Thanks to all the contributors!
感谢所有的贡献者!
[![contributors](https://contrib.rocks/image?repo=youlaitech/vue3-element-admin)](https://github.com/youlaitech/vue3-element-admin/graphs/contributors)
## 特别感谢
- 感谢 [GitCode](https://gitcode.com/) 官方的 [G-Star](https://gitcode.com/g-star) 认证

View File

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

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

View File

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

View File

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

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

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

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

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -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
View 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;
/** 代码生成预览对象 */

View File

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

View File

@@ -1,11 +1,12 @@
import request from "@/utils/request";
import type { ConfigPageQuery, ConfigForm, ConfigPageVo } from "@/types/api";
const CONFIG_BASE_URL = "/api/v1/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;
}

View File

@@ -1,11 +1,12 @@
import request from "@/utils/request";
import type { DeptQuery, DeptVo, DeptForm } from "@/types/api";
const DEPT_BASE_URL = "/api/v1/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;
}

View File

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

View File

@@ -1,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
View 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;

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

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

View File

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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组件属性

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

View 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,
};
}

View File

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

View File

@@ -90,7 +90,7 @@ const selectedValue = ref<any>(
: undefined
);
// modelValue options
// çå<EFBFBD>¬ modelValue å?options çšå<EFBFBD>˜åŒ?
watch(
[() => props.modelValue, () => options.value],
([newValue, newOptions]) => {

View File

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

View File

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

View File

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

View File

@@ -159,7 +159,7 @@ onClickOutside(iconSelectRef, () => (popoverVisible.value = false), {
});
/**
* 清空已选图标
* 清空已选图�
*/
function clearSelectedIcon() {
selectedIcon.value = "";

View File

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

View File

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

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

View 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,
};
}

View File

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

View File

@@ -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>摰賢漲

View File

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

View File

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

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

View File

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

View File

@@ -35,6 +35,6 @@ const theneList = [
];
const handleDarkChange = (theme: ThemeMode) => {
settingsStore.updateTheme(theme);
settingsStore.theme = theme;
};
</script>

View File

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

View File

@@ -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 + " 张图片");
}
/**

View File

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

View File

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

View File

@@ -265,6 +265,5 @@ export function useAiAction(options: UseAiActionOptions = {}) {
executeAiAction,
executeCommand,
handleAutoSearch,
init,
};
}

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -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();
*
* // 注册回调

View File

@@ -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() 统一管理
});
}

View File

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

View File

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

View File

@@ -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']\""
);
}

View File

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

@@ -0,0 +1,27 @@
/**
* 业务相关枚举
*
* @description
* 包含菜单、用户、角色等业务实体的枚举定义
*/
/**
* 菜单类型枚举
*/
export enum MenuTypeEnum {
CATALOG = "C", // 目录
MENU = "M", // 菜单
BUTTON = "B", // 按钮
}
/**
* 用户性别枚举
*/
export enum UserGender {
/** 未知 */
UNKNOWN = 0,
/** 男 */
MALE = 1,
/** 女 */
FEMALE = 2,
}

View File

@@ -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: "隐藏域" },
};
/**
*
*/

View File

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

View File

@@ -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
View 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",
}

View File

@@ -1,14 +0,0 @@
/**
* 设备枚举
*/
export const enum DeviceEnum {
/**
* 宽屏设备
*/
DESKTOP = "desktop",
/**
* 窄屏设备
*/
MOBILE = "mobile",
}

View File

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

View File

@@ -1,14 +0,0 @@
/**
* 语言枚举
*/
export const enum LanguageEnum {
/**
* 中文
*/
ZH_CN = "zh-cn",
/**
* 英文
*/
EN = "en",
}

View File

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

View File

@@ -1,7 +0,0 @@
// 核心枚举定义
export enum MenuTypeEnum {
CATALOG = 2, // 目录
MENU = 1, // 菜单
BUTTON = 4, // 按钮
EXTLINK = 3, // 外链
}

View File

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

View File

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

View File

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

View File

@@ -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%;
// LogoAppLogo
: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;

View File

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