feat: 项目结构重构优化

This commit is contained in:
Ray.Hao
2025-12-26 12:35:37 +08:00
parent 65ad4fe59f
commit aa374dd2ba
164 changed files with 11305 additions and 3103 deletions

View File

@@ -1,3 +1,6 @@
# ============================================
# 🌐 网络配置
# ============================================
# 应用端口
VITE_APP_PORT=3000
# 项目名称
@@ -9,15 +12,22 @@ VITE_APP_BASE_API=/dev-api
# VITE_APP_API_URL=https://api.youlai.tech # 线上
VITE_APP_API_URL=http://localhost:8000 # 本地
# WebSocket 端点(不配置则关闭),线上 ws://api.youlai.tech/ws ,本地 ws://localhost:8000/ws
# WebSocket 端点(不配置则关闭)
# 线上: ws://api.youlai.tech/ws
# 本地: ws://localhost:8000/ws
VITE_APP_WS_ENDPOINT=
# ============================================
# 🔧 开发工具
# ============================================
# 启用 Mock 服务
VITE_MOCK_DEV_SERVER=false
# ============================================
# 多租户功能开关
# 🎛️ 功能开关
# ============================================
# 是否启用多租户功能默认false
# 注意前端开关需要与后端配置youlai.tenant.enabled保持一致
VITE_APP_TENANT_ENABLED=true
# 多租户(需与后端 youlai.tenant.enabled 保持一致
VITE_APP_TENANT_ENABLED=false
# AI 助手(系统级开关,用户可在设置中单独控制)
VITE_ENABLE_AI_ASSISTANT=true

View File

@@ -1,12 +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
# ============================================
# 多租户功能开关
# 🎛️ 功能开关
# ============================================
# 是否启用多租户功能默认false
VITE_APP_TENANT_ENABLED=false
# 多租户(需与后端 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"/>

View File

@@ -183,7 +183,7 @@ export default [
parserOptions: {
ecmaVersion: "latest",
sourceType: "module",
project: "./tsconfig.json",
project: "./tsconfig.eslint.json",
tsconfigRootDir: __dirname,
},
},

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

@@ -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,76 +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",
"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;

View File

@@ -1,20 +1,27 @@
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) {
return request<any, LoginResult>({
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: {
username: data.username,
password: data.password,
captchaId: data.captchaId,
captchaCode: data.captchaCode,
tenantId: data.tenantId,
},
data: payload,
});
},

View File

@@ -1,5 +1,5 @@
import request from "@/utils/request";
import type { GeneratorPreviewVo, TablePageQuery, TablePageVo, GenConfigForm } from "@/api/types";
import type { GeneratorPreviewVo, TablePageQuery, TablePageVo, GenConfigForm } from "@/types/api";
const GENERATOR_BASE_URL = "/api/v1/codegen";

View File

@@ -1,6 +1,5 @@
import request from "@/utils/request";
import type { MenuTypeEnum } from "@/enums/business";
import type { MenuQuery, MenuVo, MenuForm, MenuOption, RouteVo, Meta } from "@/types/api";
import type { MenuQuery, MenuVo, MenuForm, RouteVo, OptionType } from "@/types/api";
const MENU_BASE_URL = "/api/v1/menus";

View File

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@@ -1,4 +1,4 @@
<template>
<template>
<!-- 悬浮按钮 -->
<div class="ai-assistant">
<!-- AI 助手图标按钮 -->
@@ -343,7 +343,7 @@ const handleExecute = async () => {
}
};
// 路由配置映射表(支持扩展)
// 路由配置映射表
const routeConfig = [
{ keywords: ["用户", "user", "user list"], path: "/system/user", name: "用户管理" },
{ keywords: ["角色", "role"], path: "/system/role", name: "角色管理" },
@@ -387,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) {

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

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

@@ -12,7 +12,7 @@ import { useDictStore } from "@/store";
const props = defineProps({
code: String, //
modelValue: [String, Number], //
modelValue: [String, Number], // å­å¸é¡¹çšå?
size: {
type: String,
default: "default", //
@@ -26,10 +26,10 @@ const tagSize = ref<"default" | "large" | "small">(props.size as "default" | "la
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) => {
//
@@ -45,7 +45,7 @@ const getLabelAndTagByValue = async (dictCode: string, value: any) => {
};
/**
* 更新 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

@@ -23,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,7 +17,7 @@
</template>
<script setup lang="ts">
import { useAppStore } from "@/store/modules/app-store";
import { useAppStore } from "@/store/modules/app";
import { LanguageEnum } from "@/enums/settings";
defineProps({
@@ -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

@@ -1,6 +1,6 @@
<template>
<el-dropdown trigger="click">
<el-badge v-if="noticeList.length > 0" :value="noticeList.length" :max="99">
<el-badge v-if="list.length > 0" :value="list.length" :max="99">
<div class="i-svg:bell" />
</el-badge>
@@ -8,15 +8,15 @@
<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">
<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">
<DictLabel v-model="item.type" code="notice_type" size="small" />
<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="handleReadNotice(item.id)"
@click="read(item.id)"
>
{{ item.title }}
</el-text>
@@ -28,18 +28,13 @@
</div>
<el-divider />
<div class="flex-x-between">
<el-link type="primary" underline="never" @click="handleViewMoreNotice">
<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="noticeList.length > 0"
type="primary"
underline="never"
@click="handleMarkAllAsRead"
>
<el-link v-if="list.length > 0" type="primary" underline="never" @click="readAll">
<span class="text-xs">全部已读</span>
</el-link>
</div>
@@ -54,58 +49,34 @@
</el-dropdown>
<el-dialog
v-model="noticeDialogVisible"
:title="noticeDetail?.title ?? '通知详情'"
v-model="dialogVisible"
:title="detail?.title ?? '通知详情'"
width="800px"
custom-class="notification-detail"
>
<div v-if="noticeDetail" class="p-x-20px">
<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>
{{ noticeDetail.publisherName }}
{{ detail.publisherName }}
</span>
<span class="ml-2 flex-y-center">
<el-icon><Timer /></el-icon>
{{ noticeDetail.publishTime }}
{{ 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="noticeDetail.content"></div>
<div v-html="detail.content"></div>
</div>
</div>
</el-dialog>
</template>
<script setup lang="ts">
import { useNotificationCenter } from "@/composables/notice/useNotificationCenter";
import { useNotice } from "./useNotice";
const {
noticeList,
noticeDialogVisible,
noticeDetail,
fetchMyNotices,
readNotice,
markAllAsRead,
viewMore,
} = useNotificationCenter();
function handleReadNotice(id: string) {
readNotice(id);
}
function handleViewMoreNotice() {
viewMore();
}
function handleMarkAllAsRead() {
markAllAsRead();
}
onMounted(() => {
fetchMyNotices();
});
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

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

@@ -21,7 +21,7 @@
<script setup lang="ts">
import { ComponentSize } from "@/enums/settings";
import { useAppStore } from "@/store/modules/app-store";
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

@@ -28,7 +28,7 @@
<script setup lang="ts">
import { computed } from "vue";
import { ArrowDown } from "@element-plus/icons-vue";
import { useTenantStoreHook } from "@/store/modules/tenant-store";
import { useTenantStoreHook } from "@/store/modules/tenant";
const emit = defineEmits<{
(e: "change", tenantId: number): void;

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

@@ -34,6 +34,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";
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";
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";
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' }"
@@ -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

@@ -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";
export { useLayout } from "./layout/useLayout";
export { useLayoutMenu } from "./layout/useLayoutMenu";
export { useDeviceDetection } from "./layout/useDeviceDetection";
// WebSocket 服务
export { setupWebSocket, cleanupWebSocket } from "./websocket";
export { useStomp, useDictSync, useOnlineCount } from "./websocket";
export type { DictMessage, DictChangeMessage, DictChangeCallback } from "./websocket";
// AI 相关
export { useAiAction } from "./ai/useAiAction";
export type { UseAiActionOptions, AiActionHandler } from "./ai/useAiAction";
// 表格相关
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";
/**
* 设备检测和响应式处理
* 监听屏幕尺寸变化,自动调整设备类型和侧边栏状态
*/
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

@@ -1,106 +0,0 @@
import { ref, onMounted, onBeforeUnmount } from "vue";
import NoticeAPI, {
type NoticePageVO,
type NoticeDetailVO,
type NoticePageQuery,
} from "@/api/system/notice";
import { useStomp } from "@/composables/websocket/useStomp";
import router from "@/router";
const DEFAULT_PAGE_SIZE = 5;
const noticeList = ref<NoticePageVO[]>([]);
const noticeDialogVisible = ref(false);
const noticeDetail = ref<NoticeDetailVO | null>(null);
const { subscribe, unsubscribe, isConnected } = useStomp();
let subscribed = false;
function normalizeQuery(params?: Partial<NoticePageQuery>): NoticePageQuery {
return {
pageNum: 1,
pageSize: DEFAULT_PAGE_SIZE,
isRead: 0,
...(params || {}),
} as NoticePageQuery;
}
async function fetchMyNotices(params?: Partial<NoticePageQuery>) {
const query = normalizeQuery(params);
const page = await NoticeAPI.getMyNoticePage(query);
noticeList.value = page.list || [];
}
async function readNotice(id: string) {
const data = await NoticeAPI.getDetail(id);
noticeDetail.value = data;
noticeDialogVisible.value = true;
const index = noticeList.value.findIndex((item) => item.id === id);
if (index >= 0) {
noticeList.value.splice(index, 1);
}
}
async function markAllAsRead() {
await NoticeAPI.readAll();
noticeList.value = [];
}
function viewMore() {
router.push({ name: "MyNotice" });
}
function setupStompSubscription() {
if (subscribed || !isConnected.value) return;
subscribe("/user/queue/message", (message: any) => {
try {
const data = JSON.parse(message.body || "{}");
const id = data.id;
if (!id) return;
if (!noticeList.value.some((item) => item.id === id)) {
noticeList.value.unshift({
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;
}
export function useNotificationCenter() {
onMounted(() => {
fetchMyNotices();
setupStompSubscription();
});
onBeforeUnmount(() => {
unsubscribe("/user/queue/message");
subscribed = false;
});
return {
noticeList,
noticeDialogVisible,
noticeDetail,
fetchMyNotices,
readNotice,
markAllAsRead,
viewMore,
};
}

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";
@@ -158,14 +158,6 @@ function createDictSyncComposable() {
initialize,
cleanup,
onDictChange,
// 别名方法(向后兼容)
initWebSocket: initialize,
closeWebSocket: cleanup,
onDictMessage: onDictChange,
// 用于测试和调试
handleDictChangeMessage,
};
}
@@ -178,7 +170,7 @@ function createDictSyncComposable() {
* ```ts
* const dictSync = useDictSync();
*
* // 初始化(在应用启动时调用)
* // 初始化(通常在应用启动时调用)
* dictSync.initialize();
*
* // 注册回调

View File

@@ -1,6 +1,5 @@
import { ref, onMounted, onUnmounted, getCurrentInstance } from "vue";
import { useStomp } from "./useStomp";
import { registerWebSocketInstance } from "@/utils/websocket";
import { AuthStorage } from "@/utils/auth";
/**
@@ -40,9 +39,6 @@ function createOnlineCountComposable() {
// 订阅 ID
let subscriptionId: string | null = null;
// 注册到全局实例管理器
registerWebSocketInstance("onlineCount", stomp);
/**
* 处理在线用户数量消息
*/
@@ -135,10 +131,6 @@ function createOnlineCountComposable() {
// 方法
initialize,
cleanup,
// 别名方法(向后兼容)
initWebSocket: initialize,
closeWebSocket: cleanup,
};
}
@@ -147,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());
@@ -169,18 +158,20 @@ export function useOnlineCount(options: { autoInit?: boolean } = {}) {
globalInstance = createOnlineCountComposable();
}
// 只在组件上下文中且 autoInit 为 true 时使用生命周期钩子
// 组件级自动初始化(仅在组件上下文中生效)
const instance = getCurrentInstance();
if (autoInit && instance) {
onMounted(() => {
// 只有在未连接时才尝试初始化
// 防止重复初始化:只有在未连接时才尝试初始化
if (!globalInstance!.isConnected.value) {
globalInstance!.initialize();
}
});
// 注意:不在卸载时关闭连接,保持全局连接
onUnmounted(() => {});
// 注意:组件卸载时关闭连接,保持全局连接
onUnmounted(() => {
// 全局连接由 cleanupWebSocket() 统一管理
});
}
return globalInstance;

View File

@@ -117,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);
/**
* 恢复所有订阅

View File

@@ -1,3 +0,0 @@
/**
* 配置统一导出
*/

View File

@@ -1,10 +1,15 @@
/**
* 常量统一导出
* 应用常量定义
*
* @description
* 包含应用中所有的常量定义
* 包含应用中所有的常量定义,包括角色、存储键名等
*/
/**
* 应用存储前缀
*/
export const APP_PREFIX = "vea";
/**
* 超级管理员角色标识
*
@@ -13,96 +18,47 @@
*/
export const ROLE_ROOT = "ROOT";
/**
* 应用存储前缀
*/
export const APP_PREFIX = "vea";
/**
* 存储命名空间
*/
const NAMESPACES = {
AUTH: "auth",
TENANT: "tenant",
SYSTEM: "system",
UI: "ui",
APP: "app",
} as const;
/**
* 存储键名常量
*
* @description
* 统一管理所有 localStorage/sessionStorage 的键名
* 命名规则:{APP_PREFIX}:{分类}:{具体名称}
*/
export const STORAGE_KEYS = {
// ===== 认证相关 =====
ACCESS_TOKEN: `${APP_PREFIX}:${NAMESPACES.AUTH}:access_token`,
REFRESH_TOKEN: `${APP_PREFIX}:${NAMESPACES.AUTH}:refresh_token`,
REMEMBER_ME: `${APP_PREFIX}:${NAMESPACES.AUTH}:remember_me`,
ACCESS_TOKEN: `${APP_PREFIX}:auth:access_token`,
REFRESH_TOKEN: `${APP_PREFIX}:auth:refresh_token`,
REMEMBER_ME: `${APP_PREFIX}:auth:remember_me`,
// ===== 租户相关 =====
TENANT_ID: `${APP_PREFIX}:${NAMESPACES.TENANT}:id`,
TENANT_INFO: `${APP_PREFIX}:${NAMESPACES.TENANT}:info`,
TENANT_ID: `${APP_PREFIX}:tenant:id`,
TENANT_INFO: `${APP_PREFIX}:tenant:info`,
// ===== 系统相关 =====
DICT_CACHE: `${APP_PREFIX}:${NAMESPACES.SYSTEM}:dict_cache`,
DICT_CACHE: `${APP_PREFIX}:system:dict_cache`,
// ===== UI 设置 =====
SHOW_TAGS_VIEW: `${APP_PREFIX}:${NAMESPACES.UI}:show_tags_view`,
SHOW_APP_LOGO: `${APP_PREFIX}:${NAMESPACES.UI}:show_app_logo`,
SHOW_WATERMARK: `${APP_PREFIX}:${NAMESPACES.UI}:show_watermark`,
ENABLE_AI_ASSISTANT: `${APP_PREFIX}:${NAMESPACES.UI}:enable_ai_assistant`,
LAYOUT: `${APP_PREFIX}:${NAMESPACES.UI}:layout`,
SIDEBAR_COLOR_SCHEME: `${APP_PREFIX}:${NAMESPACES.UI}:sidebar_color_scheme`,
THEME: `${APP_PREFIX}:${NAMESPACES.UI}:theme`,
THEME_COLOR: `${APP_PREFIX}:${NAMESPACES.UI}:theme_color`,
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}:${NAMESPACES.APP}:device`,
SIZE: `${APP_PREFIX}:${NAMESPACES.APP}:size`,
LANGUAGE: `${APP_PREFIX}:${NAMESPACES.APP}:language`,
SIDEBAR_STATUS: `${APP_PREFIX}:${NAMESPACES.APP}:sidebar_status`,
ACTIVE_TOP_MENU_PATH: `${APP_PREFIX}:${NAMESPACES.APP}:active_top_menu_path`,
DEVICE: `${APP_PREFIX}:app:device`,
SIZE: `${APP_PREFIX}:app:size`,
LANGUAGE: `${APP_PREFIX}:app:language`,
SIDEBAR_STATUS: `${APP_PREFIX}:app:sidebar_status`,
ACTIVE_TOP_MENU_PATH: `${APP_PREFIX}:app:active_top_menu_path`,
} as const;
/**
* 认证相关键名(便于批量操作)
* 存储键名类型
*/
export const AUTH_KEYS = {
ACCESS_TOKEN: STORAGE_KEYS.ACCESS_TOKEN,
REFRESH_TOKEN: STORAGE_KEYS.REFRESH_TOKEN,
REMEMBER_ME: STORAGE_KEYS.REMEMBER_ME,
} as const;
/**
* 租户相关键名(便于批量操作)
*/
export const TENANT_KEYS = {
TENANT_ID: STORAGE_KEYS.TENANT_ID,
TENANT_INFO: STORAGE_KEYS.TENANT_INFO,
} as const;
/**
* UI设置相关键名
*/
export const UI_KEYS = {
SHOW_TAGS_VIEW: STORAGE_KEYS.SHOW_TAGS_VIEW,
SHOW_APP_LOGO: STORAGE_KEYS.SHOW_APP_LOGO,
SHOW_WATERMARK: STORAGE_KEYS.SHOW_WATERMARK,
ENABLE_AI_ASSISTANT: STORAGE_KEYS.ENABLE_AI_ASSISTANT,
LAYOUT: STORAGE_KEYS.LAYOUT,
SIDEBAR_COLOR_SCHEME: STORAGE_KEYS.SIDEBAR_COLOR_SCHEME,
THEME: STORAGE_KEYS.THEME,
THEME_COLOR: STORAGE_KEYS.THEME_COLOR,
} as const;
/**
* 应用状态相关键名
*/
export const APP_KEYS = {
DEVICE: STORAGE_KEYS.DEVICE,
SIZE: STORAGE_KEYS.SIZE,
LANGUAGE: STORAGE_KEYS.LANGUAGE,
SIDEBAR_STATUS: STORAGE_KEYS.SIDEBAR_STATUS,
ACTIVE_TOP_MENU_PATH: STORAGE_KEYS.ACTIVE_TOP_MENU_PATH,
} as const;
export type StorageKey = (typeof STORAGE_KEYS)[keyof typeof STORAGE_KEYS];

View File

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

View File

@@ -3,12 +3,12 @@
<div class="flex-y-center">
<!-- 菜单折叠按钮 -->
<Hamburger :is-active="isSidebarOpened" @toggle-click="toggleSideBar" />
<!-- 面包屑导航 -->
<!-- é<EFBFBD>¢åŒå±å¯¼èˆ?-->
<Breadcrumb />
</div>
<!-- 导航栏操作区域 -->
<!-- 导航æ <EFBFBD>æ<EFBFBD>作区åŸ?-->
<div class="navbar__actions">
<NavbarActions />
<LayoutToolbar />
</div>
</div>
</template>
@@ -17,19 +17,13 @@
import { useAppStore } from "@/store";
import Hamburger from "@/components/Hamburger/index.vue";
import Breadcrumb from "@/components/Breadcrumb/index.vue";
import NavbarActions from "./components/NavbarActions.vue";
const appStore = useAppStore();
//
const isSidebarOpened = computed(() => appStore.sidebar.opened);
// /
function toggleSideBar() {
console.log("🔄 Hamburger clicked! Current state:", isSidebarOpened.value);
console.log("🔄 Device type:", appStore.device);
appStore.toggleSidebar();
console.log("🔄 New state:", appStore.sidebar.opened);
}
</script>

View File

@@ -1,4 +1,4 @@
<template>
<template>
<el-drawer
v-model="drawerVisible"
size="380"
@@ -48,6 +48,22 @@
<span class="text-xs">{{ t("settings.showWatermark") }}</span>
<el-switch v-model="settingsStore.showWatermark" />
</div>
<div class="config-item flex-x-between">
<span class="text-xs">灰色模式</span>
<el-switch v-model="settingsStore.grayMode" />
</div>
<div class="config-item flex-x-between">
<span class="text-xs">色弱模式</span>
<el-switch v-model="settingsStore.colorWeak" />
</div>
<div v-if="aiSystemEnabled" class="config-item flex-x-between">
<span class="text-xs">AI 助手</span>
<el-switch v-model="settingsStore.userEnableAi" />
</div>
<div v-if="!isDark" class="config-item flex-x-between">
<span class="text-xs">{{ t("settings.sidebarColorScheme") }}</span>
<el-radio-group v-model="sidebarColor" @change="changeSidebarColor">
@@ -65,7 +81,7 @@
<section class="config-section">
<el-divider>{{ t("settings.navigation") }}</el-divider>
<!-- 整合的布局选择 -->
<!-- 整合的布局选择 -->
<div class="layout-select">
<div class="layout-grid">
<el-tooltip
@@ -110,7 +126,7 @@
<template #footer>
<div class="action-buttons">
<el-tooltip
content="复制配置将生成当前设置的代码,覆盖 src/settings.ts 下的 defaultSettings 变量"
content="复制配置将生成当前设置的代码,覆盖到 `src/settings.ts` 下的 `defaultSettings` 变量"
placement="top"
>
<el-button
@@ -145,12 +161,15 @@ import { DocumentCopy, RefreshLeft, Check } from "@element-plus/icons-vue";
const { t } = useI18n();
import { LayoutMode, SidebarColor, ThemeMode } from "@/enums";
import { useSettingsStore } from "@/store";
import { themeColorPresets } from "@/settings";
import { themeColorPresets, appConfig } from "@/settings";
//
const copyIcon = markRaw(DocumentCopy);
const resetIcon = markRaw(RefreshLeft);
// AI
const aiSystemEnabled = appConfig.aiEnabled;
//
const copyLoading = ref(false);
const resetLoading = ref(false);
@@ -168,8 +187,8 @@ const layoutOptions: LayoutOption[] = [
{ value: LayoutMode.MIX, label: t("settings.mixLayout"), className: "mix" },
];
// 使
const colorPresets = themeColorPresets;
// 使 prop
const colorPresets = [...themeColorPresets];
const settingsStore = useSettingsStore();
@@ -178,7 +197,9 @@ const sidebarColor = ref(settingsStore.sidebarColorScheme);
const selectedThemeColor = computed({
get: () => settingsStore.themeColor,
set: (value) => settingsStore.updateThemeColor(value),
set: (value) => {
settingsStore.themeColor = value;
},
});
const drawerVisible = computed({
@@ -192,7 +213,7 @@ const drawerVisible = computed({
* @param isDark 是否启用暗黑模式
*/
const handleThemeChange = (isDark: string | number | boolean) => {
settingsStore.updateTheme(isDark ? ThemeMode.DARK : ThemeMode.LIGHT);
settingsStore.theme = isDark ? ThemeMode.DARK : ThemeMode.LIGHT;
};
/**
@@ -201,7 +222,7 @@ const handleThemeChange = (isDark: string | number | boolean) => {
* @param val 颜色方案名称
*/
const changeSidebarColor = (val: any) => {
settingsStore.updateSidebarColorScheme(val);
settingsStore.sidebarColorScheme = val;
};
/**
@@ -212,7 +233,7 @@ const changeSidebarColor = (val: any) => {
const handleLayoutChange = (layout: LayoutMode) => {
if (settingsStore.layout === layout) return;
settingsStore.updateLayout(layout);
settingsStore.layout = layout;
};
/**
@@ -249,7 +270,7 @@ const handleResetSettings = async () => {
try {
settingsStore.resetSettings();
//
// "
isDark.value = settingsStore.theme === ThemeMode.DARK;
sidebarColor.value = settingsStore.sidebarColorScheme;
@@ -319,7 +340,7 @@ const handleCloseDrawer = () => {
/* 设置内容区域 */
.settings-content {
height: calc(100vh - 120px); /* 减去头部和底部按钮的高度 */
max-height: calc(100vh - 120px);
padding: 20px;
overflow-y: auto;
}
@@ -340,7 +361,6 @@ const handleCloseDrawer = () => {
}
}
}
/* 主题切换器优化 */
.theme-switch {
transform: scale(1.2);
@@ -571,7 +591,6 @@ const handleCloseDrawer = () => {
}
}
/* 复制配置对话框样式 */
:deep(.copy-config-dialog) {
.el-message-box__content {
max-height: 400px;

View File

@@ -14,8 +14,8 @@
@open="onMenuOpen"
@close="onMenuClose"
>
<!-- 菜单项 -->
<MenuItem
<!-- è<EFBFBD>œå<EFBFBD>é¡?-->
<LayoutSidebarItem
v-for="route in data"
:key="route.path"
:item="route"
@@ -32,7 +32,7 @@ import type { RouteRecordRaw } from "vue-router";
import { SidebarColor } from "@/enums/settings";
import { useSettingsStore, useAppStore } from "@/store";
import { isExternal } from "@/utils/index";
import MenuItem from "./components/MenuItem.vue";
import LayoutSidebarItem from "./LayoutSidebarItem.vue";
import variables from "@/styles/variables.module.scss";
const props = defineProps({
@@ -63,10 +63,10 @@ const expandedMenuIndexes = ref<string[]>([]);
//
const theme = computed(() => settingsStore.theme);
//
// 获å<EFBFBD>æµè²ä¸»é¢˜ä¸çšä¾§è¾¹æ <EFBFBD>é<EFBFBD>è²æ¹æ¡?
const sidebarColorScheme = computed(() => settingsStore.sidebarColorScheme);
//
// è<EFBFBD>œå<EFBFBD>主题属æ?
const menuThemeProps = computed(() => {
const isDarkOrClassicBlue =
theme.value === "dark" || sidebarColorScheme.value === SidebarColor.CLASSIC_BLUE;
@@ -78,11 +78,11 @@ const menuThemeProps = computed(() => {
};
});
//
// 计ç®å½å<EFBFBD>æ¿æ´»çšè<EFBFBD>œå<EFBFBD>é¡?
const activeMenuPath = computed((): string => {
const { meta, path } = currentRoute;
// metaactiveMenu使
// 妿žœè·¯ç±meta中设置äºactiveMenu,åˆä½¿ç¨å®ƒï¼ˆç¨äºŽå¤ç<EFBFBD>ä¸äºç¹æ®Šæƒåµï¼Œå¦è¯¦æƒé¡µï¼?
if (meta?.activeMenu && typeof meta.activeMenu === "string") {
return meta.activeMenu;
}
@@ -94,8 +94,8 @@ const activeMenuPath = computed((): string => {
/**
* 获取完整路径
*
* @param routePath 当前路由的相对路径 /user
* @returns 完整的绝对路径 D://vue3-element-admin/system/user
* @param routePath å½å<EFBFBD>è·¯ç±çšç¸å¯¹è·¯å¾? /user
* @returns 完æ´çšç»<EFBFBD>对路å¾?D://vue3-element-admin/system/user
*/
function resolveFullPath(routePath: string) {
if (isExternal(routePath)) {
@@ -143,8 +143,8 @@ watch(
);
/**
* 监听菜单模式变化当菜单模式切换为水平模式时关闭所有展开的菜单项
* 避免在水平模式下菜单项显示错位
* çå<EFBFBD>¬è<EFBFBD>œå<EFBFBD>模å¼<EFBFBD>å<EFBFBD>˜åŒï¼šå½è<EFBFBD>œå<EFBFBD>模å¼<EFBFBD>åˆæ<EFBFBD>¢ä¸ºæ°´å¹³æ¨¡å¼<EFBFBD>æï¼Œå³é­ææœå±å¼çšè<EFBFBD>œå<EFBFBD>项ï¼?
* é<EFBFBD>¿å<EFBFBD>在水平模å¼<EFBFBD>ä¸è<EFBFBD>œå<EFBFBD>项显示éä½<EFBFBD>ã?
*/
watch(
() => props.menuMode,
@@ -156,7 +156,7 @@ watch(
);
/**
* 监听激活菜单变化为包含激活子菜单的父菜单添加样式类
* çå<EFBFBD>¬æ¿æ´»è<EFBFBD>œå<EFBFBD>å<EFBFBD>˜åŒï¼Œä¸ºåŒå<EFBFBD>«æ¿æ´»å­<EFBFBD>è<EFBFBD>œå<EFBFBD>çšçˆè<EFBFBD>œå<EFBFBD>添加样å¼<EFBFBD>ç±?
*/
watch(
() => activeMenuPath.value,
@@ -169,7 +169,7 @@ watch(
);
/**
* 监听路由变化确保菜单能随TagsView切换而正确激活
* çå<EFBFBD>¬è·¯ç±å<EFBFBD>˜åŒï¼Œç¡®ä¿<EFBFBD>è<EFBFBD>œå<EFBFBD>能éš<EFBFBD>TagsViewåˆæ<EFBFBD>¢èŒæ­£ç¡®æ¿æ´?
*/
watch(
() => currentRoute.path,
@@ -181,7 +181,7 @@ watch(
);
/**
* 更新父菜单样式 - 为包含激活子菜单的父菜单添加 has-active-child
* æ´æ°çˆè<EFBFBD>œå<EFBFBD>æ ·å¼?- 为åŒå<EFBFBD>«æ¿æ´»å­<EFBFBD>è<EFBFBD>œå<EFBFBD>çšçˆè<EFBFBD>œå<EFBFBD>添加 has-active-child ç±?
*/
function updateParentMenuStyles() {
if (!menuRef.value?.$el) return;
@@ -191,13 +191,13 @@ function updateParentMenuStyles() {
const menuEl = menuRef.value?.$el as HTMLElement;
if (!menuEl) return;
// has-active-child
// ç§»é¤ææœçްæœçš has-active-child ç±?
const allSubMenus = menuEl.querySelectorAll(".el-sub-menu");
allSubMenus.forEach((subMenu) => {
subMenu.classList.remove("has-active-child");
});
//
// 查æ¾å½å<EFBFBD>æ¿æ´»çšè<EFBFBD>œå<EFBFBD>é¡?
const activeMenuItem = menuEl.querySelector(".el-menu-item.is-active");
if (activeMenuItem) {
@@ -210,12 +210,12 @@ function updateParentMenuStyles() {
parent = parent.parentElement;
}
} else {
//
// 水平模å¼<EFBFBD>ä¸å<EFBFBD>¯èƒ½éœè¦<EFBFBD>ç¹æ®Šå¤ç<EFBFBD>?
if (props.menuMode === "horizontal") {
// 使
// 对于水平è<EFBFBD>œå<EFBFBD>,使ç¨è·¯å¾åŒ¹é<EFBFBD>æ<EFBFBD>¥æ¾åˆ°çˆè<EFBFBD>œå<EFBFBD>?
const currentPath = activeMenuPath.value;
//
// æŸ¥æ¾ææœçˆè<EFBFBD>œå<EFBFBD>é¡¹ï¼Œæ£æŸ¥åªä¸ªåŒå<EFBFBD>«å½å<EFBFBD>è·¯å¾?
allSubMenus.forEach((subMenu) => {
const subMenuEl = subMenu as HTMLElement;
const subMenuPath =
@@ -239,7 +239,7 @@ function updateParentMenuStyles() {
* 组件挂载后立即更新父菜单样式
*/
onMounted(() => {
//
// ç¡®ä¿<EFBFBD>在ç»ä»æŒè½½å<EFBFBD>Žæ´æ°æ ·å¼<EFBFBD>,ä¸<EFBFBD>ä¾<EFBFBD>èµäºŽå¼æ­¥æ<EFBFBD>ä½?
updateParentMenuStyles();
});
</script>

View File

@@ -1,4 +1,4 @@
<template>
<template>
<div v-if="!item.meta || !item.meta.hidden">
<!--叶子节点显示叶子节点或唯一子节点且父节点未配置始终显示 -->
<template
@@ -22,11 +22,12 @@
:index="resolvePath(onlyOneChild.path)"
:class="{ 'submenu-title-noDropdown': !isNest }"
>
<MenuItemContent
v-if="onlyOneChild.meta"
:icon="onlyOneChild.meta.icon || item.meta?.icon"
:title="onlyOneChild.meta.title"
/>
<template v-if="onlyOneChild.meta">
<MenuIcon :icon="onlyOneChild.meta.icon || item.meta?.icon" />
<span v-if="onlyOneChild.meta.title" class="ml-1">
{{ translateRouteTitle(onlyOneChild.meta.title) }}
</span>
</template>
</el-menu-item>
</AppLink>
</template>
@@ -34,10 +35,15 @@
<!--非叶子节点显示含多个子节点的父菜单或始终显示的单子节点 -->
<el-sub-menu v-else :index="resolvePath(item.path)" :data-path="item.path" teleported>
<template #title>
<MenuItemContent v-if="item.meta" :icon="item.meta.icon" :title="item.meta.title" />
<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>
</template>
<MenuItem
<LayoutSidebarItem
v-for="child in item.children"
:key="child.path"
:is-nest="true"
@@ -49,17 +55,39 @@
</template>
<script setup lang="ts">
import MenuItemContent from "./MenuItemContent.vue";
import path from "path-browserify";
import { RouteRecordRaw } from "vue-router";
import { isExternal } from "@/utils";
import { translateRouteTitle } from "@/lang/utils";
import { ElIcon } from "element-plus";
defineOptions({
name: "MenuItem",
name: "LayoutSidebarItem",
inheritAttrs: false,
});
import path from "path-browserify";
import { RouteRecordRaw } from "vue-router";
//
const MenuIcon = defineComponent({
props: { icon: String },
setup(props) {
const isElIcon = computed(() => props.icon?.startsWith("el-icon"));
const iconName = computed(() => props.icon?.replace("el-icon-", ""));
import { isExternal } from "@/utils";
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 props = defineProps({
/**
@@ -112,7 +140,7 @@ function hasOneShowingChild(children: RouteRecordRaw[] = [], parent: RouteRecord
return true;
}
//
//
if (showingChildren.length === 0) {
//
onlyOneChild.value = { ...parent, path: "", noShowingChildren: true };
@@ -137,7 +165,57 @@ function resolvePath(routePath: string) {
</script>
<style lang="scss">
/* stylelint-disable no-descending-specificity */
/* 菜单图标统一样式 */
.el-menu-item,
.el-sub-menu__title {
.el-icon {
width: 1em !important;
margin-right: 0 !important;
font-size: 18px;
color: currentcolor;
}
[class^="i-svg:"] {
width: 18px;
height: 18px;
font-size: 18px;
color: currentcolor !important;
}
}
/* 折叠状态下的图标样式 - 确保 SVG 图标不被压缩 */
.el-menu--collapse {
.el-menu-item,
.el-sub-menu > .el-sub-menu__title {
[class^="i-svg:"] {
width: 18px !important;
min-width: 18px !important;
height: 18px !important;
font-size: 18px !important;
}
}
/* tooltip 弹出层中的图标 */
.el-tooltip__trigger {
[class^="i-svg:"] {
width: 18px !important;
min-width: 18px !important;
height: 18px !important;
font-size: 18px !important;
}
}
}
/* hideSidebar 状态下的图标 */
.hideSidebar {
[class^="i-svg:"] {
width: 18px !important;
min-width: 18px !important;
height: 18px !important;
font-size: 18px !important;
}
.submenu-title-noDropdown {
position: relative;
@@ -203,7 +281,7 @@ html.sidebar-color-blue {
}
}
//
// "
html.dark & {
&.has-active-child > .el-sub-menu__title {
color: var(--el-color-primary-light-3) !important;
@@ -215,7 +293,7 @@ html.sidebar-color-blue {
}
}
//
// "
html.sidebar-color-blue & {
&.has-active-child > .el-sub-menu__title {
color: var(--el-color-primary-light-3) !important;
@@ -227,4 +305,5 @@ html.sidebar-color-blue {
}
}
}
/* stylelint-enable no-descending-specificity */
</style>

View File

@@ -1,4 +1,4 @@
<template>
<template>
<div class="tags-container">
<!-- 水平滚动容器 -->
<el-scrollbar
@@ -70,7 +70,7 @@
<script setup lang="ts">
import { useRoute, useRouter, type RouteRecordRaw } from "vue-router";
import { resolve } from "path-browserify";
import { translateRouteTitle } from "@/utils/i18n";
import { translateRouteTitle } from "@/lang/utils";
import { usePermissionStore, useTagsViewStore } from "@/store";
interface ContextMenu {
@@ -91,7 +91,7 @@ const { visitedViews } = storeToRefs(tagsViewStore);
//
const selectedTag = ref<TagView | null>(null);
//
// "
const contextMenu = reactive<ContextMenu>({
visible: false,
x: 0,

View File

@@ -4,7 +4,7 @@
<template v-if="isDesktop">
<!-- 搜索 -->
<div class="navbar-actions__item">
<MenuSearch />
<CommandPalette />
</div>
<!-- 全屏 -->
@@ -24,10 +24,10 @@
<!-- 通知 -->
<div class="navbar-actions__item">
<Notification />
<NoticeDropdown />
</div>
<!-- 租户选择如果启用多租户 -->
<!-- 租户选择如果启用多租户-->
<div v-if="showTenantSelect" class="navbar-actions__item">
<TenantSwitcher @change="handleTenantChange" />
</div>
@@ -60,11 +60,7 @@
</div>
<!-- 系统设置 -->
<div
v-if="defaultSettings.showSettings"
class="navbar-actions__item"
@click="handleSettingsClick"
>
<div v-if="defaults.showSettings" class="navbar-actions__item" @click="handleSettingsClick">
<div class="i-svg:setting" />
</div>
</div>
@@ -73,18 +69,18 @@
<script setup lang="ts">
import { useI18n } from "vue-i18n";
import { useRoute, useRouter } from "vue-router";
import { defaultSettings } from "@/settings";
import { defaults } from "@/settings";
import { DeviceEnum, SidebarColor, ThemeMode, LayoutMode } from "@/enums/settings";
import { useAppStore, useSettingsStore, useUserStore } from "@/store";
//
import MenuSearch from "@/components/MenuSearch/index.vue";
import CommandPalette from "@/components/CommandPalette/index.vue";
import Fullscreen from "@/components/Fullscreen/index.vue";
import SizeSelect from "@/components/SizeSelect/index.vue";
import LangSelect from "@/components/LangSelect/index.vue";
import Notification from "@/components/Notification/index.vue";
import NoticeDropdown from "@/components/NoticeDropdown/index.vue";
import TenantSwitcher from "@/components/TenantSwitcher/index.vue";
import { useTenantStoreHook } from "@/store/modules/tenant-store";
import { useTenantStoreHook } from "@/store/modules/tenant";
const { t } = useI18n();
const appStore = useAppStore();
@@ -105,7 +101,7 @@ const showTenantSelect = computed(() => {
if (tenantStore.tenantList.length === 0) {
return false;
}
//
// <EFBFBD>?
if (tenantStore.tenantList.length === 1) {
return false;
}
@@ -132,20 +128,20 @@ function handleProfileClick() {
router.push({ name: "Profile" });
}
//
// <EFBFBD>?
const navbarActionsClass = computed(() => {
const { theme, sidebarColorScheme, layout } = settingStore;
// 使
// 使<EFBFBD>?
if (theme === ThemeMode.DARK) {
return "navbar-actions--white-text";
}
//
// <EFBFBD>?
if (theme === ThemeMode.LIGHT) {
//
// - 使
// - 使
// - 使<EFBFBD>?
// - 使<EFBFBD>?
if (layout === LayoutMode.TOP || layout === LayoutMode.MIX) {
if (sidebarColorScheme === SidebarColor.CLASSIC_BLUE) {
return "navbar-actions--white-text";
@@ -159,7 +155,7 @@ const navbarActionsClass = computed(() => {
});
/**
* 退出登
* 退出登<EFBFBD>?
*/
function logout() {
ElMessageBox.confirm("确定注销并退出系统吗?", "提示", {
@@ -193,7 +189,7 @@ function handleSettingsClick() {
display: flex;
align-items: center;
justify-content: center;
min-width: 44px; /* 增加最小点击区域到44px符合人机交互标*/
min-width: 44px; /* 增加最小点击区域到44px符合人机交互标<EFBFBD>?*/
height: 100%;
min-height: 44px;
padding: 0 8px;
@@ -201,7 +197,7 @@ function handleSettingsClick() {
cursor: pointer;
transition: all 0.3s;
//
// <EFBFBD>?
> * {
display: flex;
align-items: center;
@@ -258,7 +254,7 @@ function handleSettingsClick() {
}
}
//
// <EFBFBD>?
.navbar-actions--white-text {
.navbar-actions__item {
:deep([class^="i-svg:"]) {
@@ -294,7 +290,7 @@ function handleSettingsClick() {
}
}
//
// <EFBFBD>?
.navbar-actions--dark-text {
.navbar-actions__item {
:deep([class^="i-svg:"]) {

View File

@@ -1,187 +0,0 @@
<!-- 混合布局顶部菜单 -->
<template>
<el-menu
mode="horizontal"
:default-active="activeTopMenuPath"
:background-color="
theme === 'dark' || sidebarColorScheme === SidebarColor.CLASSIC_BLUE
? variables['menu-background']
: undefined
"
:text-color="
theme === 'dark' || sidebarColorScheme === SidebarColor.CLASSIC_BLUE
? variables['menu-text']
: undefined
"
:active-text-color="
theme === 'dark' || sidebarColorScheme === SidebarColor.CLASSIC_BLUE
? variables['menu-active-text']
: undefined
"
@select="handleMenuSelect"
>
<el-menu-item v-for="menuItem in processedTopMenus" :key="menuItem.path" :index="menuItem.path">
<MenuItemContent
v-if="menuItem.meta"
:icon="menuItem.meta.icon"
:title="menuItem.meta.title"
/>
</el-menu-item>
</el-menu>
</template>
<script lang="ts" setup>
import MenuItemContent from "./components/MenuItemContent.vue";
defineOptions({
name: "MixTopMenu",
});
import { LocationQueryRaw, RouteRecordRaw } from "vue-router";
import { usePermissionStore, useAppStore, useSettingsStore } from "@/store";
import variables from "@/styles/variables.module.scss";
import { SidebarColor } from "@/enums/settings";
const router = useRouter();
const appStore = useAppStore();
const permissionStore = usePermissionStore();
const settingsStore = useSettingsStore();
// 获取主题
const theme = computed(() => settingsStore.theme);
// 获取浅色主题下的侧边栏配色方案
const sidebarColorScheme = computed(() => settingsStore.sidebarColorScheme);
// 顶部菜单列表
const topMenus = ref<RouteRecordRaw[]>([]);
// 处理后的顶部菜单列表 - 智能显示唯一子菜单的标题
const processedTopMenus = computed(() => {
return topMenus.value.map((route) => {
// 如果路由设置了 alwaysShow=true或者没有子菜单直接返回原路由
if (route.meta?.alwaysShow || !route.children || route.children.length === 0) {
return route;
}
// 过滤出非隐藏的子菜单
const visibleChildren = route.children.filter((child) => !child.meta?.hidden);
// 如果只有一个非隐藏的子菜单,显示子菜单的信息
if (visibleChildren.length === 1) {
const onlyChild = visibleChildren[0];
return {
...route,
meta: {
...route.meta,
title: onlyChild.meta?.title || route.meta?.title,
icon: onlyChild.meta?.icon || route.meta?.icon,
},
};
}
// 其他情况返回原路由
return route;
});
});
/**
* 处理菜单点击事件,切换顶部菜单并加载对应的左侧菜单
* @param routePath 点击的菜单路径
*/
const handleMenuSelect = (routePath: string) => {
updateMenuState(routePath);
};
/**
* 更新菜单状态 - 同时处理点击和路由变化情况
* @param topMenuPath 顶级菜单路径
* @param skipNavigation 是否跳过导航路由变化时为true点击菜单时为false
*/
const updateMenuState = (topMenuPath: string, skipNavigation = false) => {
// 不相同才更新,避免重复操作
if (topMenuPath !== appStore.activeTopMenuPath) {
appStore.activeTopMenu(topMenuPath); // 设置激活的顶部菜单
permissionStore.setMixLayoutSideMenus(topMenuPath); // 设置混合布局左侧菜单
}
// 如果是点击菜单且状态已变更,才进行导航
if (!skipNavigation) {
navigateToFirstLeftMenu(permissionStore.mixLayoutSideMenus); // 跳转到左侧第一个菜单
}
};
/**
* 跳转到左侧第一个可访问的菜单
* @param menus 左侧菜单列表
*/
const navigateToFirstLeftMenu = (menus: RouteRecordRaw[]) => {
if (menus.length === 0) return;
const [firstMenu] = menus;
// 如果第一个菜单有子菜单,递归跳转到第一个子菜单
if (firstMenu.children && firstMenu.children.length > 0) {
navigateToFirstLeftMenu(firstMenu.children as RouteRecordRaw[]);
} else if (firstMenu.name) {
router.push({
name: firstMenu.name,
query:
typeof firstMenu.meta?.params === "object"
? (firstMenu.meta.params as LocationQueryRaw)
: undefined,
});
}
};
// 获取当前路由路径的顶部菜单路径
const activeTopMenuPath = computed(() => appStore.activeTopMenuPath);
onMounted(() => {
topMenus.value = permissionStore.routes.filter((item) => !item.meta || !item.meta.hidden);
// 初始化顶部菜单
const currentTopMenuPath =
useRoute().path.split("/").filter(Boolean).length > 1
? useRoute().path.match(/^\/[^/]+/)?.[0] || "/"
: "/";
appStore.activeTopMenu(currentTopMenuPath); // 设置激活的顶部菜单
permissionStore.setMixLayoutSideMenus(currentTopMenuPath); // 设置混合布局左侧菜单
});
// 监听路由变化,同步更新顶部菜单和左侧菜单的激活状态
watch(
() => router.currentRoute.value.path,
(newPath) => {
if (newPath) {
// 提取顶级路径
const topMenuPath =
newPath.split("/").filter(Boolean).length > 1 ? newPath.match(/^\/[^/]+/)?.[0] || "/" : "/";
// 使用公共方法更新菜单状态,但跳过导航(因为路由已经变化)
updateMenuState(topMenuPath, true);
}
}
);
</script>
<style lang="scss" scoped>
.el-menu {
width: 100%;
height: 100%;
&--horizontal {
height: $navbar-height !important;
// 确保菜单项垂直居中
:deep(.el-menu-item) {
height: 100%;
line-height: $navbar-height;
}
// 移除默认的底部边框
&:after {
display: none;
}
}
}
</style>

View File

@@ -1,40 +0,0 @@
<template>
<!-- 菜单图标 -->
<template v-if="icon">
<el-icon v-if="isElIcon" class="menu-icon">
<component :is="iconComponent" />
</el-icon>
<div v-else :class="`i-svg:${icon}`" class="menu-icon" />
</template>
<template v-else>
<div class="i-svg:menu menu-icon" />
</template>
<!-- 菜单标题 -->
<span v-if="title" class="menu-title ml-1">{{ translateRouteTitle(title) }}</span>
</template>
<script setup lang="ts">
import { translateRouteTitle } from "@/utils/i18n";
const props = defineProps<{
icon?: string;
title?: string;
}>();
const isElIcon = computed(() => props.icon?.startsWith("el-icon"));
const iconComponent = computed(() => props.icon?.replace("el-icon-", ""));
</script>
<style lang="scss" scoped>
.menu-icon {
display: inline-flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
margin-right: 5px;
font-size: 18px;
color: currentcolor;
}
</style>

View File

@@ -1,42 +1,35 @@
<template>
<div class="layout-wrapper">
<component :is="currentLayoutComponent" />
<!-- 设置面板 - 独立于布局组件 -->
<Settings v-if="isShowSettings" />
<Settings v-if="showSettings" />
</div>
</template>
<script setup lang="ts">
import { useRoute } from "vue-router";
import { useLayout } from "@/composables/layout/useLayout";
import LeftLayout from "@/layouts/modes/left/index.vue";
import TopLayout from "@/layouts/modes/top/index.vue";
import MixLayout from "@/layouts/modes/mix/index.vue";
import Settings from "./components/Settings/index.vue";
import { useLayout } from "./useLayout";
import { LayoutMode } from "@/enums/settings";
import { defaultSettings } from "@/settings";
import LeftLayout from "./LeftLayout.vue";
import TopLayout from "./TopLayout.vue";
import MixLayout from "./MixLayout.vue";
import Settings from "./components/LayoutSettings.vue";
const { currentLayout } = useLayout();
const route = useRoute();
const { currentLayout, showSettings } = useLayout();
/// Select the corresponding component based on the current layout mode
const currentLayoutComponent = computed(() => {
const override = route.meta?.layout as LayoutMode | undefined;
const layoutToUse = override ?? currentLayout.value;
switch (layoutToUse) {
const layout = override ?? currentLayout.value;
switch (layout) {
case LayoutMode.TOP:
return TopLayout;
case LayoutMode.MIX:
return MixLayout;
case LayoutMode.LEFT:
default:
return LeftLayout;
}
});
/// Whether to show the settings panel
const isShowSettings = computed(() => defaultSettings.showSettings);
</script>
<style lang="scss" scoped>

View File

@@ -1,281 +0,0 @@
<template>
<BaseLayout>
<!-- 顶部菜单栏 -->
<div class="layout__header">
<div class="layout__header-content">
<!-- Logo区域 -->
<div v-if="isShowLogo" class="layout__header-logo">
<AppLogo :collapse="isLogoCollapsed" />
</div>
<!-- 顶部菜单区域 -->
<div class="layout__header-menu">
<MixTopMenu />
</div>
<!-- 右侧操作区域 -->
<div class="layout__header-actions">
<NavbarActions />
</div>
</div>
</div>
<!-- 主内容区容器 -->
<div class="layout__container">
<!-- 左侧菜单栏 -->
<div class="layout__sidebar--left" :class="{ 'layout__sidebar--collapsed': !isSidebarOpen }">
<el-scrollbar>
<el-menu
:default-active="activeLeftMenuPath"
: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']"
>
<MenuItem
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: isShowTagsView }" class="layout__main">
<TagsView v-if="isShowTagsView" />
<AppMain />
</div>
</div>
</BaseLayout>
</template>
<script setup lang="ts">
import { useRoute } from "vue-router";
import { useWindowSize } from "@vueuse/core";
import { useLayout, useLayoutMenu } from "@/composables";
import BaseLayout from "../base/index.vue";
import AppLogo from "../../components/AppLogo/index.vue";
import MixTopMenu from "../../components/Menu/MixTopMenu.vue";
import NavbarActions from "../../components/NavBar/components/NavbarActions.vue";
import TagsView from "../../components/TagsView/index.vue";
import AppMain from "../../components/AppMain/index.vue";
import MenuItem from "../../components/Menu/components/MenuItem.vue";
import Hamburger from "@/components/Hamburger/index.vue";
import variables from "@/styles/variables.module.scss";
import { isExternal } from "@/utils/index";
import { useAppStore, usePermissionStore } from "@/store";
const route = useRoute();
// 布局相关参数
const { isShowTagsView, isShowLogo, isSidebarOpen, toggleSidebar } = useLayout();
// 菜单相关
const { sideMenuRoutes, activeTopMenuPath } = useLayoutMenu();
// 响应式窗口尺寸
const { width } = useWindowSize();
// 只有在小屏设备移动设备时才折叠Logo只显示图标隐藏文字
const isLogoCollapsed = computed(() => width.value < 768);
// 当前激活的菜单
const activeLeftMenuPath = computed(() => {
const { meta, path } = route;
// 如果设置了activeMenu则使用
if ((meta?.activeMenu as unknown as string) && typeof meta.activeMenu === "string") {
return meta.activeMenu as unknown as string;
}
return path;
});
/**
* 解析路径 - 混合模式下,左侧菜单是从顶级菜单下的子菜单开始的
* 所以需要拼接顶级菜单路径
*/
function resolvePath(routePath: string) {
if (isExternal(routePath)) {
return routePath;
}
if (routePath.startsWith("/")) {
return activeTopMenuPath.value + routePath;
}
return `${activeTopMenuPath.value}/${routePath}`;
}
// 监听路由变化确保左侧菜单能随TagsView切换而正确激活
watch(
() => route.path,
(newPath: string) => {
// 获取顶级路径
const topMenuPath =
newPath.split("/").filter(Boolean).length > 1 ? newPath.match(/^\/[^/]+/)?.[0] || "/" : "/";
// 如果当前路径属于当前激活的顶部菜单
if (newPath.startsWith(activeTopMenuPath.value)) {
// no-op
}
// 如果路径改变了顶级菜单,确保顶部菜单和左侧菜单都更新
else if (topMenuPath !== activeTopMenuPath.value) {
const appStore = useAppStore();
const permissionStore = usePermissionStore();
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>

108
src/layouts/useLayout.ts Normal file
View File

@@ -0,0 +1,108 @@
/**
* 布局 Composable
*
* 整合布局状态、设备检测、菜单数据
*/
import { useRoute } from "vue-router";
import { useWindowSize } from "@vueuse/core";
import { useAppStore, usePermissionStore, useSettingsStore } from "@/store";
import { DeviceEnum } from "@/enums/settings";
import { defaults } from "@/settings";
const DESKTOP_BREAKPOINT = 992;
export function useLayout() {
const route = useRoute();
const appStore = useAppStore();
const settingsStore = useSettingsStore();
const permissionStore = usePermissionStore();
const { width } = useWindowSize();
// ============================================
// 设备检测
// ============================================
const isDesktop = computed(() => width.value >= DESKTOP_BREAKPOINT);
const isMobile = computed(() => appStore.device === DeviceEnum.MOBILE);
// 监听窗口变化,自动调整设备类型和侧边栏
watchEffect(() => {
const device = isDesktop.value ? DeviceEnum.DESKTOP : DeviceEnum.MOBILE;
appStore.toggleDevice(device);
if (isDesktop.value) {
appStore.openSideBar();
} else {
appStore.closeSideBar();
}
});
// ============================================
// 布局状态
// ============================================
const currentLayout = computed(() => settingsStore.layout);
const isSidebarOpen = computed(() => appStore.sidebar.opened);
const showTagsView = computed(() => settingsStore.showTagsView);
const showSettings = computed(() => defaults.showSettings);
const showLogo = computed(() => settingsStore.showAppLogo);
const layoutClass = computed(() => ({
hideSidebar: !appStore.sidebar.opened,
openSidebar: appStore.sidebar.opened,
mobile: appStore.device === DeviceEnum.MOBILE,
[`layout-${settingsStore.layout}`]: true,
}));
// ============================================
// 菜单数据
// ============================================
/** 路由列表(左侧/顶部菜单) */
const routes = computed(() => permissionStore.routes);
/** 混合布局侧边菜单 */
const sideMenuRoutes = computed(() => permissionStore.mixLayoutSideMenus);
/** 顶部菜单激活路径 */
const activeTopMenuPath = computed(() => appStore.activeTopMenuPath);
/** 当前激活菜单 */
const activeMenu = computed(() => {
const { meta, path } = route;
return meta?.activeMenu || path;
});
// ============================================
// 操作方法
// ============================================
function toggleSidebar() {
appStore.toggleSidebar();
}
function closeSidebar() {
appStore.closeSideBar();
}
return {
// 设备
isDesktop,
isMobile,
// 布局
currentLayout,
layoutClass,
isSidebarOpen,
showTagsView,
showSettings,
showLogo,
// 菜单
routes,
sideMenuRoutes,
activeMenu,
activeTopMenuPath,
// 方法
toggleSidebar,
closeSidebar,
};
}

View File

@@ -22,18 +22,18 @@ import { setupRouter } from "@/router";
import { setupStore } from "@/store";
// ===== 全局组件 =====
import { registerElementIcons } from "@/utils/register-components";
import * as ElementPlusIcons from "@element-plus/icons-vue";
// ===== 第三方插件 =====
import VXETable from "vxe-table";
import { InstallCodeMirror } from "codemirror-editor-vue3";
import { configureVxeTable } from "@/config/vxe-table";
import { configureVxeTable } from "@/plugins/vxe-table";
// ===== 路由守卫 =====
import { setupPermissionGuard } from "@/router/guards/permission";
// ===== 业务服务 =====
import { setupWebSocket } from "@/utils/websocket";
import { setupWebSocket } from "@/composables";
// 创建 Vue 应用实例
const app = createApp(App);
@@ -44,8 +44,8 @@ setupRouter(app);
setupStore(app);
setupI18n(app);
// 2⃣ 全局组件
registerElementIcons(app);
// 2⃣ 全局组件Element Plus 图标)
Object.entries(ElementPlusIcons).forEach(([name, comp]) => app.component(name, comp));
// 3⃣ 第三方插件
configureVxeTable();

View File

@@ -1,17 +1,14 @@
/**
* NProgress
*/
import NProgress from "nprogress";
import "nprogress/nprogress.css";
// 进度条
NProgress.configure({
// 动画方式
easing: "ease",
// 递增进度条的速度
speed: 500,
// 是否显示加载ico
showSpinner: false,
// 自动递增间隔
trickleSpeed: 200,
// 初始化时的最小百分比
minimum: 0.3,
});

View File

@@ -1,22 +1,15 @@
/**
* VxeTable
*
* @description
* VxeTable Vue PC
* @see https://vxetable.cn/v4.6/#/table/start/install
*/
import VXETable from "vxe-table";
/**
* VxeTable
*/
export function configureVxeTable() {
VXETable.setConfig({
size: "medium",
zIndex: 9999,
version: 0,
loadingText: null,
table: {
showHeader: true,
showOverflow: "tooltip",

View File

@@ -1,42 +1,19 @@
import type { RouteRecordRaw } from "vue-router";
import NProgress from "@/utils/nprogress";
import NProgress from "@/plugins/nprogress";
import router from "@/router";
import { usePermissionStore, useUserStore } from "@/store";
import { useTenantStoreHook } from "@/store/modules/tenant-store";
import { useTenantStoreHook } from "@/store/modules/tenant";
import { appConfig } from "@/settings";
/**
* 多租户功能是否启用
* 通过环境变量控制,实现零侵入的可插拔设计
* 路由权限守卫
*
* 处理登录验证、动态路由生成、404检测等
*/
const TENANT_ENABLED = import.meta.env.VITE_APP_TENANT_ENABLED === "true";
/**
* 初始化多租户上下文(插件式设计)
* - 仅在启用多租户时执行
* - 失败不影响主流程(优雅降级)
* - 完全解耦,可随时移除
*/
async function initTenantContextIfEnabled(): Promise<void> {
if (!TENANT_ENABLED) {
console.debug("[Tenant] 多租户功能未启用,跳过初始化");
return;
}
try {
console.debug("[Tenant] 开始加载租户...");
const tenantStore = useTenantStoreHook();
await tenantStore.loadTenant();
console.debug("[Tenant] 租户加载成功");
} catch (error) {
// 优雅降级:后端未启用多租户或接口不存在时,不影响正常流程
console.debug("[Tenant] 租户上下文初始化失败(可能后端未启用多租户):", error);
}
}
export function setupPermissionGuard() {
const whiteList = ["/login"];
router.beforeEach(async (to, from, next) => {
router.beforeEach(async (to, _from, next) => {
NProgress.start();
try {
@@ -53,7 +30,7 @@ export function setupPermissionGuard() {
return;
}
// 已登录登录页重定向
// 已登录访问登录页重定向到首页
if (to.path === "/login") {
next({ path: "/" });
return;
@@ -68,11 +45,8 @@ export function setupPermissionGuard() {
await userStore.getUserInfo();
}
// 【多租户插件】初始化租户上下文(零侵入设计
// - 通过 VITE_APP_TENANT_ENABLED 环境变量控制
// - 失败不影响主流程,优雅降级
// - 可通过设置环境变量为 false 完全移除此功能
await initTenantContextIfEnabled();
// 加载用户租户列表VITE_APP_TENANT_ENABLED=true 时生效
await initTenantContext();
const dynamicRoutes = await permissionStore.generateRoutes();
dynamicRoutes.forEach((route: RouteRecordRaw) => {
@@ -83,13 +57,13 @@ export function setupPermissionGuard() {
return;
}
// 路由404检查
// 路由 404 检查
if (to.matched.length === 0) {
next("/404");
return;
}
// 动态标题设置
// 动态标题
const title = (to.params.title as string) || (to.query.title as string);
if (title) {
to.meta.title = title;
@@ -97,7 +71,6 @@ export function setupPermissionGuard() {
next();
} catch (error) {
// 错误处理:重置状态并跳转登录
console.error("Route guard error:", error);
await useUserStore().resetAllState();
next("/login");
@@ -109,3 +82,18 @@ export function setupPermissionGuard() {
NProgress.done();
});
}
// ============================================
// 多租户支持(可选)
// ============================================
/** 初始化多租户上下文,未启用或失败时静默跳过 */
async function initTenantContext(): Promise<void> {
if (!appConfig.tenantEnabled) return;
try {
await useTenantStoreHook().loadTenant();
} catch {
// 静默失败,不影响主流程
}
}

View File

@@ -1,67 +1,55 @@
import { LayoutMode, ComponentSize, SidebarColor, ThemeMode, LanguageEnum } from "./enums";
const { pkg } = __APP_INFO__;
// 检查用户的操作系统是否使用深色模式
const mediaQueryList = window.matchMedia("(prefers-color-scheme: dark)");
export const defaultSettings: AppSettings = {
// 系统Title
title: pkg.name,
// 系统版本
version: pkg.version,
// 是否显示设置
showSettings: true,
// 是否显示标签视图
showTagsView: true,
// 是否显示应用Logo
showAppLogo: true,
// 布局方式,默认为左侧布局
layout: LayoutMode.LEFT,
// 主题,根据操作系统的色彩方案自动选择
theme: mediaQueryList.matches ? ThemeMode.DARK : ThemeMode.LIGHT,
// 组件大小 default | medium | small | large
size: ComponentSize.DEFAULT,
// 语言
language: LanguageEnum.ZH_CN,
// 主题颜色 - 修改此值时需同步修改 src/styles/element-plus-vars.scss
themeColor: "#4080FF",
// 是否显示水印
showWatermark: false,
// 水印内容
watermarkContent: pkg.name,
// 侧边栏配色方案
sidebarColorScheme: SidebarColor.CLASSIC_BLUE,
// 是否启用 AI 助手
enableAiAssistant: false,
};
/**
* 认证功能配置
* 应用配置
*/
export const authConfig = {
/**
* Token自动刷新开关
*
* true: 启用自动刷新 - ACCESS_TOKEN_INVALID时尝试刷新token
* false: 禁用自动刷新 - ACCESS_TOKEN_INVALID时直接跳转登录页
*
* 适用场景后端没有刷新接口或不需要自动刷新的项目可设为false
*/
enableTokenRefresh: true,
import { LayoutMode, ComponentSize, SidebarColor, ThemeMode, LanguageEnum } from "@/enums";
const env = import.meta.env;
const { pkg } = __APP_INFO__;
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
// ============================================
// 应用配置
// ============================================
export const appConfig = {
name: pkg.name as string,
version: pkg.version as string,
title: (env.VITE_APP_TITLE as string) || pkg.name,
// 功能开关
tenantEnabled: env.VITE_APP_TENANT_ENABLED === "true",
aiEnabled: env.VITE_ENABLE_AI_ASSISTANT === "true",
} as const;
// 主题色预设 - 经典配色方案
// 注意:修改默认主题色时,需要同步修改 src/styles/element-plus-vars.scss 中的 primary.base
// ============================================
// 用户偏好默认
// ============================================
export const defaults = {
theme: prefersDark ? ThemeMode.DARK : ThemeMode.LIGHT,
themeColor: "#4080FF",
sidebarColorScheme: SidebarColor.CLASSIC_BLUE,
layout: LayoutMode.LEFT,
size: ComponentSize.DEFAULT,
language: LanguageEnum.ZH_CN,
showTagsView: true,
showAppLogo: true,
showWatermark: false,
showSettings: true,
watermarkContent: pkg.name,
} as const;
// ============================================
// 主题色预设
// ============================================
export const themeColorPresets = [
"#4080FF", // Arco Design 蓝 - 现代感强
"#1890FF", // Ant Design 蓝 - 经典商务
"#409EFF", // Element Plus 蓝 - 清新自然
"#FA8C16", // 活力橙 - 温暖友好
"#722ED1", // 优雅紫 - 高端大气
"#13C2C2", // 青色 - 科技感
"#52C41A", // 成功绿 - 活力清新
"#F5222D", // 警示红 - 醒目强烈
"#2F54EB", // 深蓝 - 稳重专业
"#EB2F96", // 品红 - 时尚个性
];
"#4080FF",
"#1890FF",
"#409EFF",
"#FA8C16",
"#722ED1",
"#13C2C2",
"#52C41A",
"#F5222D",
"#2F54EB",
"#EB2F96",
] as const;

View File

@@ -8,11 +8,11 @@ export function setupStore(app: App<Element>) {
app.use(store);
}
export * from "./modules/app-store";
export * from "./modules/permission-store";
export * from "./modules/settings-store";
export * from "./modules/tags-view-store";
export * from "./modules/user-store";
export * from "./modules/dict-store";
export * from "./modules/tenant-store";
export * from "./modules/app";
export * from "./modules/permission";
export * from "./modules/settings";
export * from "./modules/tags-view";
export * from "./modules/user";
export * from "./modules/dict";
export * from "./modules/tenant";
export { store };

View File

@@ -1,85 +1,54 @@
import { defaultSettings } from "@/settings";
// 导入 Element Plus 中英文语言包
import zhCn from "element-plus/es/locale/lang/zh-cn";
import en from "element-plus/es/locale/lang/en";
import { store } from "@/store";
import { DeviceEnum, SidebarStatus } from "@/enums";
import { STORAGE_KEYS } from "@/constants";
import { defaults } from "@/settings";
export const useAppStore = defineStore("app", () => {
// 设备类型
const device = useStorage(STORAGE_KEYS.DEVICE, DeviceEnum.DESKTOP);
// 布局大小
const size = useStorage(STORAGE_KEYS.SIZE, defaultSettings.size);
// 语言
const language = useStorage(STORAGE_KEYS.LANGUAGE, defaultSettings.language);
// 侧边栏状态
const size = useStorage(STORAGE_KEYS.SIZE, defaults.size);
const language = useStorage(STORAGE_KEYS.LANGUAGE, defaults.language);
const sidebarStatus = useStorage(STORAGE_KEYS.SIDEBAR_STATUS, SidebarStatus.CLOSED);
const sidebar = reactive({
opened: sidebarStatus.value === SidebarStatus.OPENED,
withoutAnimation: false,
});
// 顶部菜单激活路径
const activeTopMenuPath = useStorage(STORAGE_KEYS.ACTIVE_TOP_MENU_PATH, "");
/**
*
*/
const locale = computed(() => {
if (language?.value == "en") {
return en;
} else {
return zhCn;
}
});
const locale = computed(() => (language?.value === "en" ? en : zhCn));
// 切换侧边栏
function toggleSidebar() {
sidebar.opened = !sidebar.opened;
sidebarStatus.value = sidebar.opened ? SidebarStatus.OPENED : SidebarStatus.CLOSED;
}
// 关闭侧边栏
function closeSideBar() {
sidebar.opened = false;
sidebarStatus.value = SidebarStatus.CLOSED;
}
// 打开侧边栏
function openSideBar() {
sidebar.opened = true;
sidebarStatus.value = SidebarStatus.OPENED;
}
// 切换设备
function toggleDevice(val: string) {
device.value = val;
}
/**
*
*
* @param val default | small | large
*/
function changeSize(val: string) {
size.value = val;
}
/**
*
*
* @param val
*/
function changeLanguage(val: string) {
language.value = val;
}
/**
*
*/
function activeTopMenu(val: string) {
activeTopMenuPath.value = val;
}
return {
device,
sidebar,
@@ -97,11 +66,6 @@ export const useAppStore = defineStore("app", () => {
};
});
/**
* Pinia Store 使 Pinia store
* 使 Pinia Store
* https://pinia.vuejs.org/core-concepts/outside-component-usage.html#using-a-store-outside-of-a-component
*/
export function useAppStoreHook() {
return useAppStore(store);
}

View File

@@ -3,7 +3,8 @@ import { constantRoutes } from "@/router";
import { store } from "@/store";
import router from "@/router";
import MenuAPI, { type RouteVO } from "@/api/system/menu";
import MenuAPI from "@/api/system/menu";
import { RouteVo } from "@/types";
const modules = import.meta.glob("../../views/**/**.vue");
const Layout = () => import("../../layouts/index.vue");
@@ -68,7 +69,7 @@ export const usePermissionStore = defineStore("permission", () => {
* Vue Router配置
* Layout层级嵌套
*/
const transformRoutes = (routes: RouteVO[], isTopLevel: boolean = true): RouteRecordRaw[] => {
const transformRoutes = (routes: RouteVo[], isTopLevel: boolean = true): RouteRecordRaw[] => {
return routes.map((route) => {
const { component, children, ...args } = route;

View File

@@ -1,176 +0,0 @@
import { defaultSettings } from "@/settings";
import { SidebarColor, ThemeMode } from "@/enums";
import type { LayoutMode } from "@/enums";
import { applyTheme, generateThemeColors, toggleDarkMode, toggleSidebarColor } from "@/utils/theme";
import { STORAGE_KEYS } from "@/constants";
// 🎯 设置项类型定义
interface SettingsState {
// 界面显示设置
settingsVisible: boolean;
showTagsView: boolean;
showAppLogo: boolean;
showWatermark: boolean;
enableAiAssistant: boolean;
// 布局设置
layout: LayoutMode;
sidebarColorScheme: string;
// 主题设置
theme: ThemeMode;
themeColor: string;
}
// 🎯 可变更的设置项类型
type MutableSetting = Exclude<keyof SettingsState, "settingsVisible">;
type SettingValue<K extends MutableSetting> = SettingsState[K];
export const useSettingsStore = defineStore("setting", () => {
// 设置面板可见性
const settingsVisible = ref<boolean>(false);
// 是否显示标签页视图
const showTagsView = useStorage<boolean>(
STORAGE_KEYS.SHOW_TAGS_VIEW,
defaultSettings.showTagsView
);
// 是否显示应用Logo
const showAppLogo = useStorage<boolean>(STORAGE_KEYS.SHOW_APP_LOGO, defaultSettings.showAppLogo);
// 是否显示水印
const showWatermark = useStorage<boolean>(
STORAGE_KEYS.SHOW_WATERMARK,
defaultSettings.showWatermark
);
// 是否启用 AI 助手
const enableAiAssistant = useStorage<boolean>(
STORAGE_KEYS.ENABLE_AI_ASSISTANT,
defaultSettings.enableAiAssistant
);
// 侧边栏配色方案
const sidebarColorScheme = useStorage<string>(
STORAGE_KEYS.SIDEBAR_COLOR_SCHEME,
defaultSettings.sidebarColorScheme
);
// 布局模式
const layout = useStorage<LayoutMode>(STORAGE_KEYS.LAYOUT, defaultSettings.layout as LayoutMode);
// 主题颜色
const themeColor = useStorage<string>(STORAGE_KEYS.THEME_COLOR, defaultSettings.themeColor);
// 主题模式(亮色/暗色)
const theme = useStorage<ThemeMode>(STORAGE_KEYS.THEME, defaultSettings.theme);
// 设置项映射,用于统一管理
const settingsMap = {
showTagsView,
showAppLogo,
showWatermark,
enableAiAssistant,
sidebarColorScheme,
layout,
} as const;
// 监听主题变化,自动应用样式
watch(
[theme, themeColor],
([newTheme, newThemeColor]: [ThemeMode, string]) => {
toggleDarkMode(newTheme === ThemeMode.DARK);
const colors = generateThemeColors(newThemeColor, newTheme);
applyTheme(colors);
},
{ immediate: true }
);
// 监听侧边栏配色变化
watch(
[sidebarColorScheme],
([newSidebarColorScheme]) => {
toggleSidebarColor(newSidebarColorScheme === SidebarColor.CLASSIC_BLUE);
},
{ immediate: true }
);
// 通用设置更新方法
function updateSetting<K extends keyof typeof settingsMap>(key: K, value: SettingValue<K>): void {
const setting = settingsMap[key];
if (setting) {
(setting as Ref<any>).value = value;
}
}
// 主题更新方法
function updateTheme(newTheme: ThemeMode): void {
theme.value = newTheme;
}
function updateThemeColor(newColor: string): void {
themeColor.value = newColor;
}
function updateSidebarColorScheme(newScheme: string): void {
sidebarColorScheme.value = newScheme;
}
function updateLayout(newLayout: LayoutMode): void {
layout.value = newLayout;
}
// 设置面板控制
function toggleSettingsPanel(): void {
settingsVisible.value = !settingsVisible.value;
}
function showSettingsPanel(): void {
settingsVisible.value = true;
}
function hideSettingsPanel(): void {
settingsVisible.value = false;
}
// 重置所有设置
function resetSettings(): void {
showTagsView.value = defaultSettings.showTagsView;
showAppLogo.value = defaultSettings.showAppLogo;
showWatermark.value = defaultSettings.showWatermark;
enableAiAssistant.value = defaultSettings.enableAiAssistant;
sidebarColorScheme.value = defaultSettings.sidebarColorScheme;
layout.value = defaultSettings.layout as LayoutMode;
themeColor.value = defaultSettings.themeColor;
theme.value = defaultSettings.theme;
}
return {
// 状态
settingsVisible,
showTagsView,
showAppLogo,
showWatermark,
enableAiAssistant,
sidebarColorScheme,
layout,
themeColor,
theme,
// 更新方法
updateSetting,
updateTheme,
updateThemeColor,
updateSidebarColorScheme,
updateLayout,
// 面板控制
toggleSettingsPanel,
showSettingsPanel,
hideSettingsPanel,
// 重置功能
resetSettings,
};
});

View File

@@ -0,0 +1,93 @@
import { SidebarColor, ThemeMode } from "@/enums";
import type { LayoutMode } from "@/enums";
import { applyTheme, generateThemeColors, toggleDarkMode, toggleSidebarColor } from "@/utils/theme";
import { STORAGE_KEYS } from "@/constants";
import { appConfig, defaults } from "@/settings";
export const useSettingsStore = defineStore("setting", () => {
// 界面显示
const settingsVisible = ref(false);
const showTagsView = useStorage(STORAGE_KEYS.SHOW_TAGS_VIEW, defaults.showTagsView);
const showAppLogo = useStorage(STORAGE_KEYS.SHOW_APP_LOGO, defaults.showAppLogo);
const showWatermark = useStorage(STORAGE_KEYS.SHOW_WATERMARK, defaults.showWatermark);
// 布局
const layout = useStorage<LayoutMode>(STORAGE_KEYS.LAYOUT, defaults.layout as LayoutMode);
const sidebarColorScheme = useStorage(
STORAGE_KEYS.SIDEBAR_COLOR_SCHEME,
defaults.sidebarColorScheme
);
// 主题
const theme = useStorage<ThemeMode>(STORAGE_KEYS.THEME, defaults.theme);
const themeColor = useStorage(STORAGE_KEYS.THEME_COLOR, defaults.themeColor);
// 特殊模式
const grayMode = useStorage(STORAGE_KEYS.GRAY_MODE, false);
const colorWeak = useStorage(STORAGE_KEYS.COLOR_WEAK, false);
// AI 助手:系统级 && 用户级
const userEnableAi = useStorage(STORAGE_KEYS.ENABLE_AI_ASSISTANT, false);
const enableAiAssistant = computed(() => appConfig.aiEnabled && userEnableAi.value);
// 主题变化监听
watch(
[theme, themeColor],
([t, c]: [ThemeMode, string]) => {
toggleDarkMode(t === ThemeMode.DARK);
applyTheme(generateThemeColors(c, t));
},
{ immediate: true }
);
watch(sidebarColorScheme, (v) => toggleSidebarColor(v === SidebarColor.CLASSIC_BLUE), {
immediate: true,
});
// 灰色模式监听
watch(
grayMode,
(v) => {
document.documentElement.style.filter = v ? "grayscale(100%)" : "";
},
{ immediate: true }
);
// 色弱模式监听
watch(
colorWeak,
(v) => {
document.documentElement.classList.toggle("color-weak", v);
},
{ immediate: true }
);
function resetSettings() {
showTagsView.value = defaults.showTagsView;
showAppLogo.value = defaults.showAppLogo;
showWatermark.value = defaults.showWatermark;
userEnableAi.value = false;
grayMode.value = false;
colorWeak.value = false;
sidebarColorScheme.value = defaults.sidebarColorScheme;
layout.value = defaults.layout as LayoutMode;
themeColor.value = defaults.themeColor;
theme.value = defaults.theme;
}
return {
settingsVisible,
showTagsView,
showAppLogo,
showWatermark,
enableAiAssistant,
userEnableAi,
grayMode,
colorWeak,
sidebarColorScheme,
layout,
themeColor,
theme,
resetSettings,
};
});

View File

@@ -2,13 +2,13 @@ import { store } from "@/store";
import AuthAPI from "@/api/auth";
import UserAPI from "@/api/system/user";
import type { LoginRequest } from "@/types/api";
import type { LoginRequest, UserInfo } from "@/types/api";
import { AuthStorage } from "@/utils/auth";
import { usePermissionStoreHook } from "@/store/modules/permission-store";
import { useDictStoreHook } from "@/store/modules/dict-store";
import { usePermissionStoreHook } from "@/store/modules/permission";
import { useDictStoreHook } from "@/store/modules/dict";
import { useTagsViewStore } from "@/store";
import { cleanupWebSocket } from "@/utils/websocket";
import { cleanupWebSocket } from "@/composables";
export const useUserStore = defineStore("user", () => {
// 用户信息

View File

@@ -9,7 +9,9 @@
background-color: var(--el-color-primary);
}
/* 混合布局左侧菜单 hover 样式 */
/* ============================================
混合布局左侧菜单 hover 样式
============================================ */
.layout-mix .layout__sidebar--left .el-menu {
.el-menu-item {
&:hover {

View File

@@ -1,12 +1,13 @@
// 1. 基础重置(补充 UnoCSS 预设未覆盖的全局样式)
// 1. 基础重置
@use "./reset";
// 2. 项目自定义主题变量CSS 变量 / SCSS 变量 / JS 导出)
// 2. 主题变量
@use "./variables" as *;
// 3. UI 框架适配Element Plus & Vxe Table
// 3. UI 框架适配
@use "./element-plus";
@use "./vxe-table";
@use "./wangeditor";
// 4. 业务通用样式
@use "./common";

View File

@@ -1,33 +1,61 @@
/**
* 项目自定义主题变量CSS 变量 / SCSS 变量 / JS 导出)
* 与 Element Plus 主题变量覆盖element-plus-vars.scss职责分离
* 项目主题变量
*
* 注意:此文件以下划线开头,是 Sass partial不会被单独编译只能被其他文件导入
* 结构:
* 1. SCSS 变量 - 布局尺寸(供 JS 导出和组件使用)
* 2. CSS 变量 - 侧边栏/菜单主题色
* 3. 主题模式 - 深蓝侧边栏、暗黑模式
* 4. 无障碍模式 - 色弱模式
*/
// ============================================
// 1. SCSS 变量 - 布局尺寸
// ============================================
$sidebar-width: 210px;
$sidebar-width-collapsed: 54px;
$navbar-height: 50px;
$tags-view-height: 34px;
// ============================================
// 2. CSS 变量 - 默认主题(浅色 + 白色侧边栏)
// ============================================
:root {
--menu-background: #fff; // 菜单背景色
--menu-text: #212121; // 菜单文字颜色 浅色主题-白色侧边栏配色下仅占位,实际颜色由 el-menu-item 组件决定
--menu-active-text: var(
--el-menu-active-color
); // 菜单激活文字颜色 浅色主题-白色侧边栏配色下仅占位,实际颜色由 el-menu-item 组件决定
// 菜单
--menu-background: #fff;
--menu-text: #212121;
--menu-active-text: var(--el-menu-active-color);
--menu-hover: #e6f4ff;
--menu-hover: #e6f4ff; // 菜单悬停背景色 浅色主题-白色侧边栏配色下仅占位,实际颜色由 el-menu-item 组件决定
--sidebar-logo-background: #f5f5f5; // 侧边栏 Logo 背景色
--sidebar-logo-text-color: #333; // 侧边栏 Logo 文字颜色
// 侧边栏 Logo
--sidebar-logo-background: #f5f5f5;
--sidebar-logo-text-color: #333;
}
/** 浅色主题-深蓝色侧边栏配色 */
// SCSS 变量映射(供组件使用)
$menu-background: var(--menu-background);
$menu-text: var(--menu-text);
$menu-active-text: var(--menu-active-text);
$menu-hover: var(--menu-hover);
$sidebar-logo-background: var(--sidebar-logo-background);
$sidebar-logo-text-color: var(--sidebar-logo-text-color);
// ============================================
// 3. 主题模式
// ============================================
// 浅色主题 - 深蓝色侧边栏
html.sidebar-color-blue {
--menu-background: #304156; // 菜单背景色
--menu-text: #bfcbd9; // 菜单文字颜色
--menu-active-text: var(--el-menu-active-color); // 菜单激活文字颜色
--menu-hover: #263445; // 菜单悬停背景色
--sidebar-logo-background: #2d3748; // 侧边栏 Logo 背景色
--sidebar-logo-text-color: #fff; // 侧边栏 Logo 文字颜色
--menu-background: #304156;
--menu-text: #bfcbd9;
--menu-active-text: var(--el-menu-active-color);
--menu-hover: #263445;
--sidebar-logo-background: #2d3748;
--sidebar-logo-text-color: #fff;
}
/** 暗黑主题 */
// 暗黑主题
html.dark {
--menu-background: var(--el-bg-color-overlay);
--menu-text: #fff;
@@ -36,43 +64,17 @@ html.dark {
--sidebar-logo-background: rgb(0 0 0 / 20%);
--sidebar-logo-text-color: #fff;
// Element Plus 表格选中行背景色
.el-table {
/* 自定义表格选中高亮时当前行的背景颜色(暗黑模式) */
--el-table-current-row-bg-color: var(--el-fill-color-light);
}
/** WangEditor Dark */
/* Textarea - css vars */
--w-e-textarea-bg-color: var(--el-bg-color); /* 深色背景 */
--w-e-textarea-color: var(--el-text-color-primary); /* 浅色文字 */
--w-e-textarea-border-color: var(--el-border-color); /* 较深的边框颜色 */
--w-e-textarea-slight-border-color: var(--el-border-color-lighter); /* 更淡一些的边框颜色 */
--w-e-textarea-slight-color: var(--el-text-color-secondary); /* 浅灰色,用于不那么重要的元素 */
--w-e-textarea-slight-bg-color: var(--el-bg-color-overlay); /* 稍微亮一点的背景色 */
--w-e-textarea-selected-border-color: var(--el-color-info-light-5); /* 选中元素时的高亮边框 */
--w-e-textarea-handler-bg-color: var(--el-color-primary); /* 工具按钮或交互元素的背景色 */
/* Toolbar - css vars */
--w-e-toolbar-color: var(--el-text-color-regular); /* 工具栏文字颜色 */
--w-e-toolbar-bg-color: var(--el-bg-color); /* 工具栏背景颜色 */
--w-e-toolbar-active-color: var(--el-text-color-primary); /* 当前激活项的文字颜色 */
--w-e-toolbar-active-bg-color: var(--el-fill-color-light); /* 当前激活项的背景颜色 */
--w-e-toolbar-disabled-color: var(--el-text-color-secondary); /* 禁用项的颜色 */
--w-e-toolbar-border-color: var(--el-border-color-base); /* 工具栏边框颜色 */
/* Modal - css vars */
--w-e-modal-button-bg-color: var(--el-bg-color-light-3); /* 弹出框按钮背景色 */
--w-e-modal-button-border-color: var(--el-border-color-light); /* 弹出框按钮边框颜色 */
}
$menu-background: var(--menu-background); // 菜单背景色
$menu-text: var(--menu-text); // 菜单文字颜色
$menu-active-text: var(--menu-active-text); // 菜单激活文字颜色
$menu-hover: var(--menu-hover); // 菜单悬停背景色
$sidebar-logo-background: var(--sidebar-logo-background); // 侧边栏 Logo 背景色
$sidebar-logo-text-color: var(--sidebar-logo-text-color); // 侧边栏 Logo 文字颜色
// ============================================
// 4. 无障碍模式
// ============================================
$sidebar-width: 210px; // 侧边栏宽度
$sidebar-width-collapsed: 54px; // 侧边栏收缩宽度
$navbar-height: 50px; // 导航栏高度
$tags-view-height: 34px; // TagsView 高度
// 色弱模式
html.color-weak {
filter: invert(80%);
}

View File

@@ -0,0 +1,28 @@
/**
* WangEditor 暗黑模式适配
* 使用 Element Plus CSS 变量实现主题统一
*/
html.dark {
/* Textarea */
--w-e-textarea-bg-color: var(--el-bg-color);
--w-e-textarea-color: var(--el-text-color-primary);
--w-e-textarea-border-color: var(--el-border-color);
--w-e-textarea-slight-border-color: var(--el-border-color-lighter);
--w-e-textarea-slight-color: var(--el-text-color-secondary);
--w-e-textarea-slight-bg-color: var(--el-bg-color-overlay);
--w-e-textarea-selected-border-color: var(--el-color-info-light-5);
--w-e-textarea-handler-bg-color: var(--el-color-primary);
/* Toolbar */
--w-e-toolbar-color: var(--el-text-color-regular);
--w-e-toolbar-bg-color: var(--el-bg-color);
--w-e-toolbar-active-color: var(--el-text-color-primary);
--w-e-toolbar-active-bg-color: var(--el-fill-color-light);
--w-e-toolbar-disabled-color: var(--el-text-color-secondary);
--w-e-toolbar-border-color: var(--el-border-color-base);
/* Modal */
--w-e-modal-button-bg-color: var(--el-bg-color-light-3);
--w-e-modal-button-border-color: var(--el-border-color-light);
}

View File

@@ -48,6 +48,10 @@ export interface MenuForm {
type?: string;
/** 路由路径 */
path?: string;
/** 路由名称(用于前端路由名) */
routeName?: string;
/** 路由路径(可用于自定义路由字段) */
routePath?: string;
/** 跳转路径 */
redirect?: string;
/** 组件路径 */
@@ -60,6 +64,12 @@ export interface MenuForm {
visible?: number;
/** 按钮权限标识 */
perm?: string;
/** 路由参数(用于表单编辑 params */
params?: { key?: string; value?: string }[];
/** 是否始终显示(仅对目录生效) */
alwaysShow?: number | boolean;
/** 是否缓存(用于 keepAlive */
keepAlive?: number | boolean;
}
/** 菜单选项 */

View File

@@ -28,6 +28,8 @@ export interface NoticeForm {
publishStatus?: number;
/** 目标用户ID(多个以英文逗号(,)分割) */
targetUserIds?: string;
/** 目标类型 (1:全部,2:指定用户等) */
targetType?: number;
}
/** 通知分页对象 */
@@ -68,4 +70,8 @@ export interface NoticeDetailVo {
publishStatus?: number;
/** 目标用户ID */
targetUserIds?: string;
/** 发布人名称 */
publisherName?: string;
/** 发布时间 */
publishTime?: Date;
}

View File

@@ -1,90 +1,127 @@
/* prettier-ignore */
/* eslint-disable */
// @ts-nocheck
// biome-ignore lint: disable
// oxlint-disable
// ------
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
export {}
declare module "vue" {
export {};
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
AppLink: (typeof import("./../components/AppLink/index.vue"))["default"];
Breadcrumb: (typeof import("./../components/Breadcrumb/index.vue"))["default"];
CopyButton: (typeof import("./../components/CopyButton/index.vue"))["default"];
CURD: (typeof import("./../components/CURD/index.vue"))["default"];
Dict: (typeof import("./../components/Dict/index.vue"))["default"];
DictLabel: (typeof import("./../components/Dict/DictLabel.vue"))["default"];
ECharts: (typeof import("./../components/ECharts/index.vue"))["default"];
ElBacktop: (typeof import("element-plus/es"))["ElBacktop"];
ElBreadcrumb: (typeof import("element-plus/es"))["ElBreadcrumb"];
ElBreadcrumbItem: (typeof import("element-plus/es"))["ElBreadcrumbItem"];
ElButton: (typeof import("element-plus/es"))["ElButton"];
ElCard: (typeof import("element-plus/es"))["ElCard"];
ElCascader: (typeof import("element-plus/es"))["ElCascader"];
ElCheckbox: (typeof import("element-plus/es"))["ElCheckbox"];
ElCheckboxGroup: (typeof import("element-plus/es"))["ElCheckboxGroup"];
ElCol: (typeof import("element-plus/es"))["ElCol"];
ElColorPicker: (typeof import("element-plus/es"))["ElColorPicker"];
ElConfigProvider: (typeof import("element-plus/es"))["ElConfigProvider"];
ElDatePicker: (typeof import("element-plus/es"))["ElDatePicker"];
ElDialog: (typeof import("element-plus/es"))["ElDialog"];
ElDivider: (typeof import("element-plus/es"))["ElDivider"];
ElDrawer: (typeof import("element-plus/es"))["ElDrawer"];
ElDropdown: (typeof import("element-plus/es"))["ElDropdown"];
ElDropdownItem: (typeof import("element-plus/es"))["ElDropdownItem"];
ElDropdownMenu: (typeof import("element-plus/es"))["ElDropdownMenu"];
ElForm: (typeof import("element-plus/es"))["ElForm"];
ElFormItem: (typeof import("element-plus/es"))["ElFormItem"];
ElIcon: (typeof import("element-plus/es"))["ElIcon"];
ElImage: (typeof import("element-plus/es"))["ElImage"];
ElInput: (typeof import("element-plus/es"))["ElInput"];
ElInputTag: (typeof import("element-plus/es"))["ElInputTag"];
ElInputNumber: (typeof import("element-plus/es"))["ElInputNumber"];
ElLink: (typeof import("element-plus/es"))["ElLink"];
ElMenu: (typeof import("element-plus/es"))["ElMenu"];
ElMenuItem: (typeof import("element-plus/es"))["ElMenuItem"];
ElOption: (typeof import("element-plus/es"))["ElOption"];
ElPagination: (typeof import("element-plus/es"))["ElPagination"];
ElPopover: (typeof import("element-plus/es"))["ElPopover"];
ElRadio: (typeof import("element-plus/es"))["ElRadio"];
ElRadioGroup: (typeof import("element-plus/es"))["ElRadioGroup"];
ElRow: (typeof import("element-plus/es"))["ElRow"];
ElScrollbar: (typeof import("element-plus/es"))["ElScrollbar"];
ElSelect: (typeof import("element-plus/es"))["ElSelect"];
ElStatistic: (typeof import("element-plus/es"))["ElStatistic"];
ElSubMenu: (typeof import("element-plus/es"))["ElSubMenu"];
ElSwitch: (typeof import("element-plus/es"))["ElSwitch"];
ElTable: (typeof import("element-plus/es"))["ElTable"];
ElTableColumn: (typeof import("element-plus/es"))["ElTableColumn"];
ElTag: (typeof import("element-plus/es"))["ElTag"];
ElText: (typeof import("element-plus/es"))["ElText"];
ElTimeSelect: (typeof import("element-plus/es"))["ElTimeSelect"];
ElTooltip: (typeof import("element-plus/es"))["ElTooltip"];
ElTree: (typeof import("element-plus/es"))["ElTree"];
ElTreeSelect: (typeof import("element-plus/es"))["ElTreeSelect"];
ElUpload: (typeof import("element-plus/es"))["ElUpload"];
ElWatermark: (typeof import("element-plus/es"))["ElWatermark"];
ElSkeleton: (typeof import("element-plus/es"))["ElSkeleton"];
FileUpload: (typeof import("./../components/Upload/FileUpload.vue"))["default"];
Form: (typeof import("./../components/CURD/Form.vue"))["default"];
Fullscreen: (typeof import("./../components/Fullscreen/index.vue"))["default"];
GithubCorner: (typeof import("./../components/GithubCorner/index.vue"))["default"];
Hamburger: (typeof import("./../components/Hamburger/index.vue"))["default"];
IconSelect: (typeof import("./../components/IconSelect/index.vue"))["default"];
LangSelect: (typeof import("./../components/LangSelect/index.vue"))["default"];
MenuSearch: (typeof import("./../components/MenuSearch/index.vue"))["default"];
MultiImageUpload: (typeof import("./../components/Upload/MultiImageUpload.vue"))["default"];
Notification: (typeof import("./../components/Notification/index.vue"))["default"];
PageContent: (typeof import("./../components/CURD/PageContent.vue"))["default"];
PageModal: (typeof import("./../components/CURD/PageModal.vue"))["default"];
PageSearch: (typeof import("./../components/CURD/PageSearch.vue"))["default"];
Pagination: (typeof import("./../components/Pagination/index.vue"))["default"];
RouterLink: (typeof import("vue-router"))["RouterLink"];
RouterView: (typeof import("vue-router"))["RouterView"];
SingleImageUpload: (typeof import("./../components/Upload/SingleImageUpload.vue"))["default"];
SizeSelect: (typeof import("./../components/SizeSelect/index.vue"))["default"];
TableSelect: (typeof import("./../components/TableSelect/index.vue"))["default"];
WangEditor: (typeof import("./../components/WangEditor/index.vue"))["default"];
AiAssistant: typeof import('./../components/AiAssistant/index.vue')['default']
AppLink: typeof import('./../components/AppLink/index.vue')['default']
Breadcrumb: typeof import('./../components/Breadcrumb/index.vue')['default']
CommandPalette: typeof import('./../components/CommandPalette/index.vue')['default']
CopyButton: typeof import('./../components/CopyButton/index.vue')['default']
DictSelect: typeof import('./../components/DictSelect/index.vue')['default']
DictTag: typeof import('./../components/DictTag/index.vue')['default']
ECharts: typeof import('./../components/ECharts/index.vue')['default']
ElAlert: typeof import('element-plus/es')['ElAlert']
ElAvatar: typeof import('element-plus/es')['ElAvatar']
ElBacktop: typeof import('element-plus/es')['ElBacktop']
ElBadge: typeof import('element-plus/es')['ElBadge']
ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb']
ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem']
ElButton: typeof import('element-plus/es')['ElButton']
ElCard: typeof import('element-plus/es')['ElCard']
ElCascader: typeof import('element-plus/es')['ElCascader']
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
ElCheckboxButton: typeof import('element-plus/es')['ElCheckboxButton']
ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
ElCol: typeof import('element-plus/es')['ElCol']
ElColorPicker: typeof import('element-plus/es')['ElColorPicker']
ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
ElDialog: typeof import('element-plus/es')['ElDialog']
ElDivider: typeof import('element-plus/es')['ElDivider']
ElDrawer: typeof import('element-plus/es')['ElDrawer']
ElDropdown: typeof import('element-plus/es')['ElDropdown']
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
ElEmpty: typeof import('element-plus/es')['ElEmpty']
ElForm: typeof import('element-plus/es')['ElForm']
ElFormItem: typeof import('element-plus/es')['ElFormItem']
ElIcon: typeof import('element-plus/es')['ElIcon']
ElImage: typeof import('element-plus/es')['ElImage']
ElImageViewer: typeof import('element-plus/es')['ElImageViewer']
ElInput: typeof import('element-plus/es')['ElInput']
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
ElLink: typeof import('element-plus/es')['ElLink']
ElMenu: typeof import('element-plus/es')['ElMenu']
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
ElOption: typeof import('element-plus/es')['ElOption']
ElPagination: typeof import('element-plus/es')['ElPagination']
ElPopover: typeof import('element-plus/es')['ElPopover']
ElProgress: typeof import('element-plus/es')['ElProgress']
ElRadio: typeof import('element-plus/es')['ElRadio']
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
ElRow: typeof import('element-plus/es')['ElRow']
ElScrollbar: typeof import('element-plus/es')['ElScrollbar']
ElSelect: typeof import('element-plus/es')['ElSelect']
ElSkeleton: typeof import('element-plus/es')['ElSkeleton']
ElSkeletonItem: typeof import('element-plus/es')['ElSkeletonItem']
ElSpace: typeof import('element-plus/es')['ElSpace']
ElStep: typeof import('element-plus/es')['ElStep']
ElSteps: typeof import('element-plus/es')['ElSteps']
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
ElSwitch: typeof import('element-plus/es')['ElSwitch']
ElTable: typeof import('element-plus/es')['ElTable']
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
ElTabPane: typeof import('element-plus/es')['ElTabPane']
ElTabs: typeof import('element-plus/es')['ElTabs']
ElTag: typeof import('element-plus/es')['ElTag']
ElText: typeof import('element-plus/es')['ElText']
ElTimeline: typeof import('element-plus/es')['ElTimeline']
ElTimelineItem: typeof import('element-plus/es')['ElTimelineItem']
ElTooltip: typeof import('element-plus/es')['ElTooltip']
ElTree: typeof import('element-plus/es')['ElTree']
ElTreeSelect: typeof import('element-plus/es')['ElTreeSelect']
ElUpload: typeof import('element-plus/es')['ElUpload']
ElWatermark: typeof import('element-plus/es')['ElWatermark']
FileUpload: typeof import('./../components/Upload/FileUpload.vue')['default']
Fullscreen: typeof import('./../components/Fullscreen/index.vue')['default']
GithubCorner: typeof import('./../components/GithubCorner/index.vue')['default']
Hamburger: typeof import('./../components/Hamburger/index.vue')['default']
IconSelect: typeof import('./../components/IconSelect/index.vue')['default']
InputTag: typeof import('./../components/InputTag/index.vue')['default']
LangSelect: typeof import('./../components/LangSelect/index.vue')['default']
LayoutLogo: typeof import('./../layouts/components/LayoutLogo.vue')['default']
LayoutMain: typeof import('./../layouts/components/LayoutMain.vue')['default']
LayoutNavbar: typeof import('./../layouts/components/LayoutNavbar.vue')['default']
LayoutSettings: typeof import('./../layouts/components/LayoutSettings.vue')['default']
LayoutSidebar: typeof import('./../layouts/components/LayoutSidebar.vue')['default']
LayoutSidebarItem: typeof import('./../layouts/components/LayoutSidebarItem.vue')['default']
LayoutTagsView: typeof import('./../layouts/components/LayoutTagsView.vue')['default']
LayoutToolbar: typeof import('./../layouts/components/LayoutToolbar.vue')['default']
Login: typeof import('./../views/login/components/Login.vue')['default']
MultiImageUpload: typeof import('./../components/Upload/MultiImageUpload.vue')['default']
NoticeDropdown: typeof import('./../components/NoticeDropdown/index.vue')['default']
OperationColumn: typeof import('./../components/OperationColumn/index.vue')['default']
PageContent: typeof import('./../components/CURD/PageContent.vue')['default']
PageModal: typeof import('./../components/CURD/PageModal.vue')['default']
PageSearch: typeof import('./../components/CURD/PageSearch.vue')['default']
Pagination: typeof import('./../components/Pagination/index.vue')['default']
Register: typeof import('./../views/login/components/Register.vue')['default']
ResetPwd: typeof import('./../views/login/components/ResetPwd.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
SingleImageUpload: typeof import('./../components/Upload/SingleImageUpload.vue')['default']
SizeSelect: typeof import('./../components/SizeSelect/index.vue')['default']
TableSelect: typeof import('./../components/TableSelect/index.vue')['default']
TenantSwitcher: typeof import('./../components/TenantSwitcher/index.vue')['default']
TextScroll: typeof import('./../components/TextScroll/index.vue')['default']
ThemeSwitch: typeof import('./../components/ThemeSwitch/index.vue')['default']
UserDeptTree: typeof import('./../views/system/user/components/UserDeptTree.vue')['default']
UserImportDialog: typeof import('./../views/system/user/components/UserImportDialog.vue')['default']
WangEditor: typeof import('./../components/WangEditor/index.vue')['default']
}
export interface ComponentCustomProperties {
vLoading: (typeof import("element-plus/es"))["ElLoadingDirective"];
export interface GlobalDirectives {
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
}
}

View File

@@ -1,47 +1,46 @@
import { Storage } from "./storage";
import { AUTH_KEYS } from "@/constants";
import { ROLE_ROOT } from "@/constants";
import { useUserStoreHook } from "@/store/modules/user-store";
import { STORAGE_KEYS, ROLE_ROOT } from "@/constants";
import { useUserStoreHook } from "@/store/modules/user";
import router from "@/router";
// 负责本地凭证与偏好的读写
export const AuthStorage = {
getAccessToken(): string {
const isRememberMe = Storage.get<boolean>(AUTH_KEYS.REMEMBER_ME, false);
const isRememberMe = Storage.get<boolean>(STORAGE_KEYS.REMEMBER_ME, false);
return isRememberMe
? Storage.get(AUTH_KEYS.ACCESS_TOKEN, "")
: Storage.sessionGet(AUTH_KEYS.ACCESS_TOKEN, "");
? Storage.get(STORAGE_KEYS.ACCESS_TOKEN, "")
: Storage.sessionGet(STORAGE_KEYS.ACCESS_TOKEN, "");
},
getRefreshToken(): string {
const isRememberMe = Storage.get<boolean>(AUTH_KEYS.REMEMBER_ME, false);
const isRememberMe = Storage.get<boolean>(STORAGE_KEYS.REMEMBER_ME, false);
return isRememberMe
? Storage.get(AUTH_KEYS.REFRESH_TOKEN, "")
: Storage.sessionGet(AUTH_KEYS.REFRESH_TOKEN, "");
? Storage.get(STORAGE_KEYS.REFRESH_TOKEN, "")
: Storage.sessionGet(STORAGE_KEYS.REFRESH_TOKEN, "");
},
setTokens(accessToken: string, refreshToken: string, rememberMe: boolean): void {
Storage.set(AUTH_KEYS.REMEMBER_ME, rememberMe);
Storage.set(STORAGE_KEYS.REMEMBER_ME, rememberMe);
if (rememberMe) {
Storage.set(AUTH_KEYS.ACCESS_TOKEN, accessToken);
Storage.set(AUTH_KEYS.REFRESH_TOKEN, refreshToken);
Storage.set(STORAGE_KEYS.ACCESS_TOKEN, accessToken);
Storage.set(STORAGE_KEYS.REFRESH_TOKEN, refreshToken);
} else {
Storage.sessionSet(AUTH_KEYS.ACCESS_TOKEN, accessToken);
Storage.sessionSet(AUTH_KEYS.REFRESH_TOKEN, refreshToken);
Storage.remove(AUTH_KEYS.ACCESS_TOKEN);
Storage.remove(AUTH_KEYS.REFRESH_TOKEN);
Storage.sessionSet(STORAGE_KEYS.ACCESS_TOKEN, accessToken);
Storage.sessionSet(STORAGE_KEYS.REFRESH_TOKEN, refreshToken);
Storage.remove(STORAGE_KEYS.ACCESS_TOKEN);
Storage.remove(STORAGE_KEYS.REFRESH_TOKEN);
}
},
clearAuth(): void {
Storage.remove(AUTH_KEYS.ACCESS_TOKEN);
Storage.remove(AUTH_KEYS.REFRESH_TOKEN);
Storage.sessionRemove(AUTH_KEYS.ACCESS_TOKEN);
Storage.sessionRemove(AUTH_KEYS.REFRESH_TOKEN);
Storage.remove(STORAGE_KEYS.ACCESS_TOKEN);
Storage.remove(STORAGE_KEYS.REFRESH_TOKEN);
Storage.sessionRemove(STORAGE_KEYS.ACCESS_TOKEN);
Storage.sessionRemove(STORAGE_KEYS.REFRESH_TOKEN);
},
getRememberMe(): boolean {
return Storage.get<boolean>(AUTH_KEYS.REMEMBER_ME, false);
return Storage.get<boolean>(STORAGE_KEYS.REMEMBER_ME, false);
},
};

View File

@@ -1,51 +0,0 @@
/**
* DOM 操作相关工具函数
*/
/**
* 检查元素是否包含指定 class
* @param ele HTML 元素
* @param cls class 名称
* @returns 是否包含
*
* @example
* ```ts
* const hasActiveClass = hasClass(element, 'active');
* ```
*/
export function hasClass(ele: HTMLElement, cls: string): boolean {
return !!ele.className.match(new RegExp("(\\s|^)" + cls + "(\\s|$)"));
}
/**
* 为元素添加 class
* @param ele HTML 元素
* @param cls class 名称
*
* @example
* ```ts
* addClass(element, 'active');
* ```
*/
export function addClass(ele: HTMLElement, cls: string): void {
if (!hasClass(ele, cls)) {
ele.className += " " + cls;
}
}
/**
* 从元素移除 class
* @param ele HTML 元素
* @param cls class 名称
*
* @example
* ```ts
* removeClass(element, 'active');
* ```
*/
export function removeClass(ele: HTMLElement, cls: string): void {
if (hasClass(ele, cls)) {
const reg = new RegExp("(\\s|^)" + cls + "(\\s|$)");
ele.className = ele.className.replace(reg, " ");
}
}

View File

@@ -1,12 +0,0 @@
// translate router.meta.title, be used in breadcrumb sidebar tagsview
import i18n from "@/lang/index";
export function translateRouteTitle(title: any) {
// 判断是否存在国际化配置,如果没有原生返回
const hasKey = i18n.global.te("route." + title);
if (hasKey) {
const translatedTitle = i18n.global.t("route." + title);
return translatedTitle;
}
return title;
}

View File

@@ -1,23 +1,9 @@
/**
* 工具函数统一导出
*
* 本文件作为 barrel export统一管理所有工具函数的导出
* 各类工具函数按功能分类存放在不同文件中:
* - dom.ts: DOM 操作相关
* - validate.ts: 数据验证相关
* - format.ts: 数据格式化相关
* - download.ts: 文件下载相关
* - auth.ts: 权限认证相关
* - storage.ts: 本地存储相关
* - request.ts: 网络请求相关
* - theme.ts: 主题相关
*/
// DOM 操作
export { hasClass, addClass, removeClass } from "./dom";
// 数据验证
export { isExternal, isValidURL, isEmail, isMobile } from "./validate";
export { isExternal, isValidURL, isEmail, isMobile, VALIDATORS } from "./validate";
// 数据格式化
export { formatGrowthRate, formatFileSize, formatNumber, formatCurrency } from "./format";

Some files were not shown because too many files have changed in this diff Show More