feat: 项目结构重构优化
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
105
.github/workflows/test.yml.example
vendored
Normal file
@@ -0,0 +1,105 @@
|
||||
name: 单元测试
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, dev]
|
||||
pull_request:
|
||||
branches: [main, dev]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [20.x, 22.x]
|
||||
|
||||
steps:
|
||||
- name: 检出代码
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: 安装 pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 8
|
||||
|
||||
- name: 设置 Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: "pnpm"
|
||||
|
||||
- name: 安装依赖
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: 运行测试
|
||||
run: pnpm test:run
|
||||
|
||||
- name: 生成覆盖率报告
|
||||
run: pnpm test:coverage
|
||||
|
||||
- name: 上传覆盖率报告
|
||||
uses: codecov/codecov-action@v3
|
||||
with:
|
||||
files: ./coverage/coverage-final.json
|
||||
flags: unittests
|
||||
name: codecov-umbrella
|
||||
|
||||
- name: 检查覆盖率阈值
|
||||
run: |
|
||||
echo "检查测试覆盖率是否达到目标..."
|
||||
# 可以在这里添加覆盖率阈值检查逻辑
|
||||
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: 检出代码
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: 安装 pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 8
|
||||
|
||||
- name: 设置 Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.x
|
||||
cache: "pnpm"
|
||||
|
||||
- name: 安装依赖
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: 运行 ESLint
|
||||
run: pnpm lint:eslint
|
||||
|
||||
- name: 运行 Prettier
|
||||
run: pnpm lint:prettier
|
||||
|
||||
- name: 运行 Stylelint
|
||||
run: pnpm lint:stylelint
|
||||
|
||||
type-check:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: 检出代码
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: 安装 pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 8
|
||||
|
||||
- name: 设置 Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.x
|
||||
cache: "pnpm"
|
||||
|
||||
- name: 安装依赖
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: 类型检查
|
||||
run: pnpm type-check
|
||||
45
.gitignore
vendored
45
.gitignore
vendored
@@ -1,20 +1,43 @@
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
dist-ssr
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
dist-ssr/
|
||||
stats.html
|
||||
|
||||
# Local env files
|
||||
*.local
|
||||
.history
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Editor directories and files
|
||||
.idea
|
||||
.idea/
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
!.vscode/settings.json
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.local
|
||||
|
||||
stats.html
|
||||
pnpm-lock.yaml
|
||||
package-lock.json
|
||||
.stylelintcache
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Lint cache
|
||||
.eslintcache
|
||||
.stylelintcache
|
||||
|
||||
# Test coverage
|
||||
coverage/
|
||||
*.lcov
|
||||
.vitest/
|
||||
test-results/
|
||||
|
||||
# Lock files (use pnpm)
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
|
||||
# Local history
|
||||
.history
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<div align="center">
|
||||
<img alt="vue3-element-admin" width="80" height="80" src="./src/assets/logo.png">
|
||||
<img alt="vue3-element-admin" width="80" height="80" src="./src/assets/images/logo.png">
|
||||
<h1>vue3-element-admin</h1>
|
||||
|
||||
<img src="https://img.shields.io/badge/Vue-3.5.21-brightgreen.svg"/>
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||

|
||||
|
||||
<div align="center">
|
||||
<img alt="vue3-element-admin" width="80" height="80" src="./src/assets/logo.png">
|
||||
<img alt="vue3-element-admin" width="80" height="80" src="./src/assets/images/logo.png">
|
||||
<h1>vue3-element-admin</h1>
|
||||
|
||||
<img src="https://img.shields.io/badge/Vue-3.5.22-brightgreen.svg"/>
|
||||
|
||||
@@ -183,7 +183,7 @@ export default [
|
||||
parserOptions: {
|
||||
ecmaVersion: "latest",
|
||||
sourceType: "module",
|
||||
project: "./tsconfig.json",
|
||||
project: "./tsconfig.eslint.json",
|
||||
tsconfigRootDir: __dirname,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
78
package.json
78
package.json
@@ -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
7304
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
11
src/App.vue
11
src/App.vue
@@ -1,9 +1,9 @@
|
||||
<template>
|
||||
<el-config-provider :locale="locale" :size="size">
|
||||
<!-- 开启水印 -->
|
||||
<!-- å¼€å<EFBFBD>¯æ°´å<EFBFBD>?-->
|
||||
<el-watermark
|
||||
:font="{ color: fontColor }"
|
||||
:content="showWatermark ? defaultSettings.watermarkContent : ''"
|
||||
:content="showWatermark ? watermarkContent : ''"
|
||||
:z-index="9999"
|
||||
class="wh-full"
|
||||
>
|
||||
@@ -17,7 +17,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useAppStore, useSettingsStore, useUserStore } from "@/store";
|
||||
import { defaultSettings } from "@/settings";
|
||||
import { appConfig } from "@/settings";
|
||||
import { ThemeMode, ComponentSize } from "@/enums";
|
||||
import AiAssistant from "@/components/AiAssistant/index.vue";
|
||||
|
||||
@@ -28,9 +28,10 @@ const userStore = useUserStore();
|
||||
const locale = computed(() => appStore.locale);
|
||||
const size = computed(() => appStore.size as ComponentSize);
|
||||
const showWatermark = computed(() => settingsStore.showWatermark);
|
||||
const watermarkContent = appConfig.name;
|
||||
|
||||
// 只有在启用 AI 助手且用户已登录时才显示
|
||||
// 使用 userInfo 作为响应式依赖,当用户退出登录时会自动更新
|
||||
// å<EFBFBD>ªæœ‰åœ¨å<EFBFBD>¯ç”?AI 助手且用户已登录时æ‰<C3A6>显示
|
||||
// 使用 userInfo 作为å“<EFBFBD>应å¼<EFBFBD>ä¾<EFBFBD>赖,当用户退出登录时会自动更æ–?
|
||||
const enableAiAssistant = computed(() => {
|
||||
const isEnabled = settingsStore.enableAiAssistant;
|
||||
const isLoggedIn = userStore.userInfo && Object.keys(userStore.userInfo).length > 0;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 2.5 KiB |
@@ -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) {
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
v-if="item.redirect === 'noredirect' || index === breadcrumbs.length - 1"
|
||||
class="color-gray-400"
|
||||
>
|
||||
{{ translateRouteTitle(item.meta.title) }}
|
||||
{{ translateRouteTitle(item.meta.title ?? "") }}
|
||||
</span>
|
||||
<a v-else @click.prevent="handleLink(item)">
|
||||
{{ translateRouteTitle(item.meta.title) }}
|
||||
{{ translateRouteTitle(item.meta.title ?? "") }}
|
||||
</a>
|
||||
</el-breadcrumb-item>
|
||||
</el-breadcrumb>
|
||||
@@ -18,7 +18,7 @@
|
||||
import { RouteLocationMatched } from "vue-router";
|
||||
import { compile } from "path-to-regexp";
|
||||
import router from "@/router";
|
||||
import { translateRouteTitle } from "@/utils/i18n";
|
||||
import { translateRouteTitle } from "@/lang/utils";
|
||||
|
||||
const currentRoute = useRoute();
|
||||
const pathCompile = (path: string) => {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<template>
|
||||
<template>
|
||||
<div
|
||||
class="rounded bg-[var(--el-bg-color)] border border-[var(--el-border-color)] p-5 h-full md:flex flex-1 flex-col md:overflow-auto"
|
||||
>
|
||||
<!-- 表格工具栏 -->
|
||||
<!-- 表格工具 -->
|
||||
<div class="flex flex-col md:flex-row justify-between gap-y-2.5 mb-2.5">
|
||||
<!-- 左侧工具栏 -->
|
||||
<!-- 左侧工具 -->
|
||||
<div class="toolbar-left flex gap-y-2.5 gap-x-2 md:gap-x-3 flex-wrap">
|
||||
<template v-for="(btn, index) in toolbarLeftBtn" :key="index">
|
||||
<el-button
|
||||
@@ -17,7 +17,7 @@
|
||||
</el-button>
|
||||
</template>
|
||||
</div>
|
||||
<!-- 右侧工具栏 -->
|
||||
<!-- 右侧工具 -->
|
||||
<div class="toolbar-right flex gap-y-2.5 gap-x-2 md:gap-x-3 flex-wrap">
|
||||
<template v-for="(btn, index) in toolbarRightBtn" :key="index">
|
||||
<el-popover v-if="btn.name === 'filter'" placement="bottom" trigger="click">
|
||||
@@ -62,7 +62,7 @@
|
||||
<el-image
|
||||
:src="item"
|
||||
:preview-src-list="scope.row[col.prop]"
|
||||
:initial-index="index"
|
||||
:initial-index="Number(index)"
|
||||
:preview-teleported="true"
|
||||
:style="`width: ${col.imageWidth ?? 40}px; height: ${col.imageHeight ?? 40}px`"
|
||||
/>
|
||||
@@ -78,7 +78,7 @@
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
<!-- 根据行的selectList属性返回对应列表值 -->
|
||||
<!-- 根据行的selectList属性返回对应列表 -->
|
||||
<template v-else-if="col.templet === 'list'">
|
||||
<template v-if="col.prop">
|
||||
{{ (col.selectList ?? {})[scope.row[col.prop]] }}
|
||||
@@ -92,7 +92,7 @@
|
||||
</el-link>
|
||||
</template>
|
||||
</template>
|
||||
<!-- 生成开关组件 -->
|
||||
<!-- 生成开关组 -->
|
||||
<template v-else-if="col.templet === 'switch'">
|
||||
<template v-if="col.prop">
|
||||
<!-- pageData.length>0: 解决el-switch组件会在表格初始化的时候触发一次change事件 -->
|
||||
@@ -111,7 +111,7 @@
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
<!-- 生成输入框组件 -->
|
||||
<!-- 生成输入框组 -->
|
||||
<template v-else-if="col.templet === 'input'">
|
||||
<template v-if="col.prop">
|
||||
<el-input
|
||||
@@ -125,7 +125,7 @@
|
||||
<!-- 格式化为价格 -->
|
||||
<template v-else-if="col.templet === 'price'">
|
||||
<template v-if="col.prop">
|
||||
{{ `${col.priceFormat ?? "¥"}${scope.row[col.prop]}` }}
|
||||
{{ `${col.priceFormat ?? ""}${scope.row[col.prop]}` }}
|
||||
</template>
|
||||
</template>
|
||||
<!-- 格式化为百分比 -->
|
||||
@@ -220,7 +220,7 @@
|
||||
<el-form-item label="工作表名" prop="sheetname">
|
||||
<el-input v-model="exportsFormData.sheetname" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="数据源" prop="origin">
|
||||
<el-form-item label="数据来源" prop="origin">
|
||||
<el-select v-model="exportsFormData.origin">
|
||||
<el-option label="当前数据 (当前页的数据)" :value="ExportsOriginEnum.CURRENT" />
|
||||
<el-option
|
||||
@@ -247,8 +247,8 @@
|
||||
<!-- 弹窗底部操作按钮 -->
|
||||
<template #footer>
|
||||
<div style="padding-right: var(--el-dialog-padding-primary)">
|
||||
<el-button type="primary" @click="handleExportsSubmit">确 定</el-button>
|
||||
<el-button @click="handleCloseExportsModal">取 消</el-button>
|
||||
<el-button type="primary" @click="handleExportsSubmit">确定</el-button>
|
||||
<el-button @click="handleCloseExportsModal">取消</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
@@ -270,7 +270,7 @@
|
||||
:model="importFormData"
|
||||
:rules="importFormRules"
|
||||
>
|
||||
<el-form-item label="文件名" prop="files">
|
||||
<el-form-item label="文件" prop="files">
|
||||
<el-upload
|
||||
ref="uploadRef"
|
||||
v-model:file-list="importFormData.files"
|
||||
@@ -283,7 +283,7 @@
|
||||
>
|
||||
<el-icon class="el-icon--upload"><upload-filled /></el-icon>
|
||||
<div class="el-upload__text">
|
||||
<span>将文件拖到此处,或</span>
|
||||
<span>将文件拖到此处,或点击上传</span>
|
||||
<em>点击上传</em>
|
||||
</div>
|
||||
<template #tip>
|
||||
@@ -312,9 +312,9 @@
|
||||
:disabled="importFormData.files.length === 0"
|
||||
@click="handleImportSubmit"
|
||||
>
|
||||
确 定
|
||||
确定
|
||||
</el-button>
|
||||
<el-button @click="handleCloseImportModal">取 消</el-button>
|
||||
<el-button @click="handleCloseImportModal">取消</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
@@ -427,7 +427,7 @@ const toolbarRightBtn = computed(() => {
|
||||
const tableToolbar = config.value.cols[config.value.cols.length - 1].operat ?? ["edit", "delete"];
|
||||
const tableToolbarBtn = createToolbar(tableToolbar, { link: true, size: "small" });
|
||||
|
||||
// 表格列
|
||||
// 表格相关
|
||||
const cols = ref(
|
||||
props.contentConfig.cols.map((col) => {
|
||||
if (col.initFn) {
|
||||
@@ -517,7 +517,7 @@ function handleDelete(id?: number | string) {
|
||||
.then(() => {
|
||||
ElMessage.success("删除成功");
|
||||
removeIds.value = [];
|
||||
//清空选中项
|
||||
// 清空选中项
|
||||
tableRef.value?.clearSelection();
|
||||
handleRefresh(true);
|
||||
})
|
||||
@@ -551,7 +551,7 @@ const exportsFormData = reactive({
|
||||
});
|
||||
const exportsFormRules: FormRules = {
|
||||
fields: [{ required: true, message: "请选择字段" }],
|
||||
origin: [{ required: true, message: "请选择数据源" }],
|
||||
origin: [{ required: true, message: "请选择数据来源" }],
|
||||
};
|
||||
// 打开导出弹窗
|
||||
function handleOpenExportsModal() {
|
||||
@@ -709,7 +709,7 @@ function handleImports() {
|
||||
fileReader.onload = (ev) => {
|
||||
if (ev.target !== null && ev.target.result !== null) {
|
||||
const result = ev.target.result as ArrayBuffer;
|
||||
// 从 buffer中加载数据解析
|
||||
// 从 buffer 中加载并解析数据
|
||||
workbook.xlsx
|
||||
.load(result)
|
||||
.then((workbook) => {
|
||||
@@ -753,7 +753,7 @@ function handleImports() {
|
||||
};
|
||||
}
|
||||
|
||||
// 操作栏
|
||||
// 操作人"
|
||||
function handleToolbar(name: string) {
|
||||
switch (name) {
|
||||
case "refresh":
|
||||
@@ -786,7 +786,7 @@ function handleToolbar(name: string) {
|
||||
}
|
||||
}
|
||||
|
||||
// 操作列
|
||||
// 操作人"
|
||||
function handleOperate(data: IOperateData) {
|
||||
switch (data.name) {
|
||||
case "delete":
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<template>
|
||||
<template>
|
||||
<div>
|
||||
<!-- drawer -->
|
||||
<template v-if="modalConfig.component === 'drawer'">
|
||||
@@ -59,8 +59,8 @@
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button v-if="!formDisable" type="primary" @click="handleSubmit">确 定</el-button>
|
||||
<el-button @click="handleClose">{{ !formDisable ? "取 消" : "关闭" }}</el-button>
|
||||
<el-button v-if="!formDisable" type="primary" @click="handleSubmit">确定</el-button>
|
||||
<el-button @click="handleClose">关闭</el-button>
|
||||
</template>
|
||||
</el-drawer>
|
||||
</template>
|
||||
@@ -124,8 +124,8 @@
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button v-if="!formDisable" type="primary" @click="handleSubmit">确 定</el-button>
|
||||
<el-button @click="handleClose">{{ !formDisable ? "取 消" : "关闭" }}</el-button>
|
||||
<el-button v-if="!formDisable" type="primary" @click="handleSubmit">确定</el-button>
|
||||
<el-button @click="handleClose">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
@@ -144,7 +144,7 @@ defineSlots<{ [key: string]: (_args: any) => any }>();
|
||||
const props = defineProps<{ modalConfig: IModalConfig }>();
|
||||
// 自定义事件
|
||||
const emit = defineEmits<{ submitClick: []; customSubmit: [queryParams: IObject] }>();
|
||||
// 组件映射表
|
||||
// 组件映射
|
||||
|
||||
const componentMap = new Map<IComponentType, any>([
|
||||
// @ts-ignore
|
||||
@@ -173,14 +173,14 @@ const childrenMap = new Map<IComponentType, any>([
|
||||
]);
|
||||
|
||||
const pk = props.modalConfig.pk ?? "id"; // 主键名,用于表单数据处理
|
||||
const modalVisible = ref(false); // 弹窗显示状态
|
||||
const modalVisible = ref(false); // 弹窗显示状态"
|
||||
const formRef = ref<FormInstance>(); // 表单实例
|
||||
const formItems = reactive(props.modalConfig.formItems ?? []); // 表单配置项
|
||||
const formItems = reactive(props.modalConfig.formItems ?? []); // 表单配置项"
|
||||
const formData = reactive<IObject>({}); // 表单数据
|
||||
const formRules: FormRules = {}; // 表单验证规则
|
||||
const formDisable = ref(false); // 表单禁用状态
|
||||
const formDisable = ref(false); // 表单禁用状态"
|
||||
|
||||
// 获取tooltip提示框属性
|
||||
// 获取 tooltip 提示框属性
|
||||
const getTooltipProps = (tips: string | IObject) => {
|
||||
return typeof tips === "string" ? { content: tips } : tips;
|
||||
};
|
||||
@@ -189,7 +189,7 @@ const handleClose = () => {
|
||||
modalVisible.value = false;
|
||||
formRef.value?.resetFields();
|
||||
};
|
||||
// 设置表单值
|
||||
// 设置表单项
|
||||
const setFormData = (data: IObject) => {
|
||||
for (const key in formData) {
|
||||
if (Object.prototype.hasOwnProperty.call(formData, key) && key in data) {
|
||||
@@ -245,11 +245,11 @@ onMounted(() => {
|
||||
// 暴露的属性和方法
|
||||
defineExpose({
|
||||
setFormData,
|
||||
// 展示/因此 modal
|
||||
// 展示/隐藏 modal
|
||||
setModalVisible: (visible: boolean = true) => (modalVisible.value = visible),
|
||||
// 获取表单数据
|
||||
getFormData: (key: string) => formData[key] ?? formData,
|
||||
// 设置表单项值
|
||||
// 设置表单项
|
||||
setFormItemData: (key: string, value: any) => (formData[key] = value),
|
||||
// 禁用表单
|
||||
handleDisabled: (disable: boolean) => {
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<!-- 自定义插槽 -->
|
||||
<!-- 自定义æ<EFBFBD>’æ§?-->
|
||||
<slot
|
||||
v-if="item.type === 'custom'"
|
||||
:name="item.slotName"
|
||||
@@ -71,14 +71,14 @@ import { ArrowUp, ArrowDown } from "@element-plus/icons-vue";
|
||||
import type { FormInstance } from "element-plus";
|
||||
import InputTag from "@/components/InputTag/index.vue";
|
||||
|
||||
// 定义接收的属性
|
||||
// 定义接收的属�
|
||||
const props = defineProps<{ searchConfig: ISearchConfig }>();
|
||||
// 自定义事件
|
||||
// 自定义事�
|
||||
const emit = defineEmits<{
|
||||
queryClick: [queryParams: IObject];
|
||||
resetClick: [queryParams: IObject];
|
||||
}>();
|
||||
// 组件映射表
|
||||
// ç»„ä»¶æ˜ å°„è¡?
|
||||
const componentMap = new Map<ISearchComponent, any>([
|
||||
// @ts-ignore
|
||||
["input", markRaw(ElInput)], // @ts-ignore
|
||||
@@ -105,7 +105,7 @@ const formItems = reactive(props.searchConfig?.formItems ?? []);
|
||||
const isExpandable = ref(props.searchConfig?.isExpandable ?? true);
|
||||
// 是å<C2AF>¦å·²å±•å¼€
|
||||
const isExpand = ref(false);
|
||||
// 表单项展示数量,若可展开,超出展示数量的表单项隐藏
|
||||
// 表å<EFBFBD>•项展示数é‡<EFBFBD>,若å<EFBFBD>¯å±•开,超出展示数é‡<EFBFBD>的表å<EFBFBD>•项éš<EFBFBD>è—?
|
||||
const showNumber = computed(() =>
|
||||
isExpandable.value ? (props.searchConfig?.showNumber ?? 3) : formItems.length
|
||||
);
|
||||
@@ -113,7 +113,7 @@ const showNumber = computed(() =>
|
||||
const cardAttrs = computed<IObject>(() => {
|
||||
return { shadow: "never", style: { "margin-bottom": "12px" }, ...props.searchConfig?.cardAttrs };
|
||||
});
|
||||
// 表单组件自定义属性(label位置、宽度、对齐方式等)
|
||||
// 表å<EFBFBD>•组件自定义属性(labelä½<EFBFBD>ç½®ã€<EFBFBD>宽度ã€<EFBFBD>对é½<EFBFBD>æ–¹å¼<EFBFBD>ç‰ï¼?
|
||||
const formAttrs = computed<IForm>(() => {
|
||||
return { inline: true, ...props.searchConfig?.form };
|
||||
});
|
||||
@@ -124,7 +124,7 @@ const isGrid = computed(() =>
|
||||
: "flex flex-wrap gap-x-8 gap-y-4"
|
||||
);
|
||||
|
||||
// 获取tooltip提示框属性
|
||||
// 获å<EFBFBD>–tooltipæ<EFBFBD><EFBFBD>示框属æ€?
|
||||
const getTooltipProps = (tips: string | IObject) => {
|
||||
return typeof tips === "string" ? { content: tips } : tips;
|
||||
};
|
||||
|
||||
@@ -59,7 +59,7 @@ export interface IContentConfig<T = any> {
|
||||
// 权限前缀(如sys:user,用于组成权限标识),不提供则不进行权限校验
|
||||
permPrefix?: string;
|
||||
// table组件属性
|
||||
table?: Omit<TableProps<any>, "data">;
|
||||
table?: Partial<Omit<TableProps<any>, "data">>;
|
||||
// 分页组件位置(默认:left)
|
||||
pagePosition?: "left" | "right";
|
||||
// pagination组件属性
|
||||
|
||||
66
src/components/CommandPalette/index.vue
Normal file
66
src/components/CommandPalette/index.vue
Normal file
@@ -0,0 +1,66 @@
|
||||
<template>
|
||||
<div>
|
||||
<el-button type="text" icon="Search" aria-label="打开搜索面板" @click="open" />
|
||||
|
||||
<el-dialog v-model="visible" width="640px" :close-on-click-modal="true" @close="close">
|
||||
<template #title>
|
||||
<div class="flex items-center gap-2">
|
||||
<el-icon><Search /></el-icon>
|
||||
<span>搜索菜单</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div>
|
||||
<el-input
|
||||
ref="inputRef"
|
||||
v-model="keyword"
|
||||
placeholder="输入菜单名称或关键字搜索"
|
||||
@input="onSearch"
|
||||
@keydown.enter.prevent="onSelect"
|
||||
/>
|
||||
|
||||
<div class="mt-3">
|
||||
<div
|
||||
v-if="results.length === 0 && history.length === 0"
|
||||
class="text-center text-gray-500"
|
||||
>
|
||||
没有搜索历史
|
||||
</div>
|
||||
|
||||
<ul v-else class="space-y-2">
|
||||
<li
|
||||
v-for="(item, idx) in results.length ? results : history"
|
||||
:key="item.path + idx"
|
||||
class="p-2 hover:bg-gray-100 cursor-pointer rounded"
|
||||
@click="onGo(item)"
|
||||
>
|
||||
<div>{{ item.title }}</div>
|
||||
<div class="text-sm text-gray-400">{{ item.path }}</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div style="text-align: right">
|
||||
<el-button @click="close">关闭</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from "vue";
|
||||
import { Search } from "@element-plus/icons-vue";
|
||||
import { useCommandPalette } from "./useCommandPalette";
|
||||
|
||||
const { visible, keyword, results, history, inputRef, open, close, onSearch, onSelect, onGo } =
|
||||
useCommandPalette();
|
||||
|
||||
onMounted(() => {
|
||||
// no-op for now
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
208
src/components/CommandPalette/useCommandPalette.ts
Normal file
208
src/components/CommandPalette/useCommandPalette.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* 菜单搜索逻辑
|
||||
*/
|
||||
import { ref, onMounted, onBeforeUnmount, toRaw } from "vue";
|
||||
import { RouteRecordRaw, LocationQueryRaw } from "vue-router";
|
||||
import router from "@/router";
|
||||
import { usePermissionStore } from "@/store";
|
||||
import { isExternal } from "@/utils";
|
||||
|
||||
/** 搜索项类型 */
|
||||
interface SearchItem {
|
||||
title: string;
|
||||
path: string;
|
||||
name?: string;
|
||||
icon?: string;
|
||||
redirect?: string;
|
||||
params?: LocationQueryRaw;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = "menu_search_history";
|
||||
const MAX_HISTORY = 5;
|
||||
|
||||
export function useCommandPalette() {
|
||||
const permissionStore = usePermissionStore();
|
||||
|
||||
// 状态
|
||||
const visible = ref(false);
|
||||
const keyword = ref("");
|
||||
const activeIndex = ref(-1);
|
||||
const inputRef = ref<HTMLInputElement>();
|
||||
const menuItems = ref<SearchItem[]>([]);
|
||||
const results = ref<SearchItem[]>([]);
|
||||
const history = ref<SearchItem[]>([]);
|
||||
|
||||
// 排除的路由
|
||||
const excludedPaths = ["/redirect", "/login", "/401", "/404"];
|
||||
|
||||
// ============================================
|
||||
// 弹窗控制
|
||||
// ============================================
|
||||
|
||||
function open() {
|
||||
keyword.value = "";
|
||||
results.value = [];
|
||||
activeIndex.value = -1;
|
||||
visible.value = true;
|
||||
setTimeout(() => inputRef.value?.focus(), 100);
|
||||
}
|
||||
|
||||
function close() {
|
||||
visible.value = false;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 搜索逻辑
|
||||
// ============================================
|
||||
|
||||
function onSearch() {
|
||||
activeIndex.value = -1;
|
||||
if (!keyword.value.trim()) {
|
||||
results.value = [];
|
||||
return;
|
||||
}
|
||||
const kw = keyword.value.toLowerCase();
|
||||
results.value = menuItems.value.filter((item) => item.title.toLowerCase().includes(kw));
|
||||
}
|
||||
|
||||
function onSelect() {
|
||||
if (results.value.length > 0 && activeIndex.value >= 0) {
|
||||
onGo(results.value[activeIndex.value]);
|
||||
}
|
||||
}
|
||||
|
||||
function onNavigate(direction: "up" | "down") {
|
||||
if (results.value.length === 0) return;
|
||||
|
||||
if (direction === "up") {
|
||||
activeIndex.value = activeIndex.value <= 0 ? results.value.length - 1 : activeIndex.value - 1;
|
||||
} else {
|
||||
activeIndex.value = activeIndex.value >= results.value.length - 1 ? 0 : activeIndex.value + 1;
|
||||
}
|
||||
}
|
||||
|
||||
function onGo(item: SearchItem) {
|
||||
close();
|
||||
addHistory(item);
|
||||
|
||||
if (isExternal(item.path)) {
|
||||
window.open(item.path, "_blank");
|
||||
} else {
|
||||
router.push({ path: item.path, query: item.params });
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 历史记录
|
||||
// ============================================
|
||||
|
||||
function loadHistory() {
|
||||
try {
|
||||
const data = localStorage.getItem(STORAGE_KEY);
|
||||
history.value = data ? JSON.parse(data) : [];
|
||||
} catch {
|
||||
history.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
function saveHistory() {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(history.value));
|
||||
}
|
||||
|
||||
function addHistory(item: SearchItem) {
|
||||
// 去重
|
||||
const idx = history.value.findIndex((i) => i.path === item.path);
|
||||
if (idx !== -1) history.value.splice(idx, 1);
|
||||
|
||||
// 添加到开头
|
||||
history.value.unshift(item);
|
||||
|
||||
// 限制数量
|
||||
if (history.value.length > MAX_HISTORY) {
|
||||
history.value = history.value.slice(0, MAX_HISTORY);
|
||||
}
|
||||
|
||||
saveHistory();
|
||||
}
|
||||
|
||||
function removeHistory(index: number) {
|
||||
history.value.splice(index, 1);
|
||||
saveHistory();
|
||||
}
|
||||
|
||||
function clearHistory() {
|
||||
history.value = [];
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 路由解析
|
||||
// ============================================
|
||||
|
||||
function loadRoutes(routes: RouteRecordRaw[], parentPath = "") {
|
||||
routes.forEach((route) => {
|
||||
const path = route.path.startsWith("/")
|
||||
? route.path
|
||||
: `${parentPath}${parentPath.endsWith("/") ? "" : "/"}${route.path}`;
|
||||
|
||||
if (excludedPaths.includes(route.path) || isExternal(route.path)) return;
|
||||
|
||||
if (route.children) {
|
||||
loadRoutes(route.children, path);
|
||||
} else if (route.meta?.title) {
|
||||
menuItems.value.push({
|
||||
title: route.meta.title === "dashboard" ? "首页" : route.meta.title,
|
||||
path,
|
||||
name: typeof route.name === "string" ? route.name : undefined,
|
||||
icon: route.meta.icon,
|
||||
redirect: typeof route.redirect === "string" ? route.redirect : undefined,
|
||||
params: route.meta.params
|
||||
? JSON.parse(JSON.stringify(toRaw(route.meta.params)))
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 快捷键
|
||||
// ============================================
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") {
|
||||
e.preventDefault();
|
||||
open();
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 生命周期
|
||||
// ============================================
|
||||
|
||||
onMounted(() => {
|
||||
loadRoutes(permissionStore.routes);
|
||||
loadHistory();
|
||||
document.addEventListener("keydown", handleKeydown);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener("keydown", handleKeydown);
|
||||
});
|
||||
|
||||
return {
|
||||
visible,
|
||||
keyword,
|
||||
results,
|
||||
history,
|
||||
activeIndex,
|
||||
inputRef,
|
||||
open,
|
||||
close,
|
||||
onSearch,
|
||||
onSelect,
|
||||
onNavigate,
|
||||
onGo,
|
||||
removeHistory,
|
||||
clearHistory,
|
||||
};
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
<template>
|
||||
<div cursor-pointer flex-center rounded class="el" :class="padding">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
defineProps({
|
||||
padding: {
|
||||
type: String,
|
||||
default: "p-2",
|
||||
},
|
||||
});
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
.el {
|
||||
transition: 0.3s var(--el-transition-function-ease-in-out-bezier);
|
||||
&:hover {
|
||||
background-color: var(--el-fill-color);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -90,7 +90,7 @@ const selectedValue = ref<any>(
|
||||
: undefined
|
||||
);
|
||||
|
||||
// 监听 modelValue 和 options 的变化
|
||||
// 监å<EFBFBD>¬ modelValue å’?options çš„å<EFBFBD>˜åŒ?
|
||||
watch(
|
||||
[() => props.modelValue, () => options.value],
|
||||
([newValue, newOptions]) => {
|
||||
|
||||
@@ -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;
|
||||
@@ -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) => {
|
||||
|
||||
@@ -23,7 +23,7 @@ const hamburgerClass = computed(() => {
|
||||
return "hamburger--white";
|
||||
}
|
||||
|
||||
// 如果是混合布局 && 侧边栏配色方案是经典蓝
|
||||
// 如果是混å<EFBFBD>ˆå¸ƒå±€ && ä¾§è¾¹æ <C3A6>é…<C3A9>色方案是ç»<C3A7>å…¸è“?
|
||||
if (
|
||||
layout.value === LayoutMode.MIX &&
|
||||
settingsStore.sidebarColorScheme === SidebarColor.CLASSIC_BLUE
|
||||
|
||||
@@ -159,7 +159,7 @@ onClickOutside(iconSelectRef, () => (popoverVisible.value = false), {
|
||||
});
|
||||
|
||||
/**
|
||||
* 清空已选图标
|
||||
* 清空已选图æ ?
|
||||
*/
|
||||
function clearSelectedIcon() {
|
||||
selectedIcon.value = "";
|
||||
|
||||
@@ -17,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;
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
114
src/components/NoticeDropdown/useNotice.ts
Normal file
114
src/components/NoticeDropdown/useNotice.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* 通知中心逻辑
|
||||
*/
|
||||
import { ref, onMounted, onBeforeUnmount } from "vue";
|
||||
import type { NoticePageVo, NoticeDetailVo, NoticePageQuery } from "@/types/api";
|
||||
import NoticeAPI from "@/api/system/notice";
|
||||
import { useStomp } from "@/composables";
|
||||
import router from "@/router";
|
||||
|
||||
const PAGE_SIZE = 5;
|
||||
|
||||
export function useNotice() {
|
||||
const { subscribe, unsubscribe, isConnected } = useStomp();
|
||||
|
||||
// 状态
|
||||
const list = ref<NoticePageVo[]>([]);
|
||||
const detail = ref<NoticeDetailVo | null>(null);
|
||||
const dialogVisible = ref(false);
|
||||
|
||||
let subscribed = false;
|
||||
|
||||
// ============================================
|
||||
// 数据获取
|
||||
// ============================================
|
||||
|
||||
async function fetchList(params?: Partial<NoticePageQuery>) {
|
||||
const query: NoticePageQuery = {
|
||||
pageNum: 1,
|
||||
pageSize: PAGE_SIZE,
|
||||
isRead: 0,
|
||||
...params,
|
||||
} as NoticePageQuery;
|
||||
const page = await NoticeAPI.getMyNoticePage(query);
|
||||
list.value = page.list || [];
|
||||
}
|
||||
|
||||
async function read(id: string) {
|
||||
detail.value = await NoticeAPI.getDetail(id);
|
||||
dialogVisible.value = true;
|
||||
|
||||
// 从列表中移除已读项
|
||||
const idx = list.value.findIndex((item: NoticePageVo) => item.id === id);
|
||||
if (idx >= 0) list.value.splice(idx, 1);
|
||||
}
|
||||
|
||||
async function readAll() {
|
||||
await NoticeAPI.readAll();
|
||||
list.value = [];
|
||||
}
|
||||
|
||||
function goMore() {
|
||||
router.push({ name: "MyNotice" });
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// WebSocket 订阅
|
||||
// ============================================
|
||||
|
||||
function setupSubscription() {
|
||||
if (subscribed || !isConnected.value) return;
|
||||
|
||||
subscribe("/user/queue/message", (message: any) => {
|
||||
try {
|
||||
const data = JSON.parse(message.body || "{}");
|
||||
if (!data.id) return;
|
||||
|
||||
// 避免重复
|
||||
if (list.value.some((item: NoticePageVo) => item.id === data.id)) return;
|
||||
|
||||
list.value.unshift({
|
||||
id: data.id,
|
||||
title: data.title,
|
||||
type: data.type,
|
||||
publishTime: data.publishTime,
|
||||
} as NoticePageVo);
|
||||
|
||||
ElNotification({
|
||||
title: "您收到一条新的通知消息!",
|
||||
message: data.title,
|
||||
type: "success",
|
||||
position: "bottom-right",
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("解析通知消息失败", e);
|
||||
}
|
||||
});
|
||||
|
||||
subscribed = true;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 生命周期
|
||||
// ============================================
|
||||
|
||||
onMounted(() => {
|
||||
fetchList();
|
||||
setupSubscription();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
unsubscribe("/user/queue/message");
|
||||
subscribed = false;
|
||||
});
|
||||
|
||||
return {
|
||||
list,
|
||||
detail,
|
||||
dialogVisible,
|
||||
fetchList,
|
||||
read,
|
||||
readAll,
|
||||
goMore,
|
||||
};
|
||||
}
|
||||
@@ -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>摰賢漲
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -34,6 +34,6 @@ const theneList = [
|
||||
];
|
||||
|
||||
const handleDarkChange = (theme: ThemeMode) => {
|
||||
settingsStore.updateTheme(theme);
|
||||
settingsStore.theme = theme;
|
||||
};
|
||||
</script>
|
||||
@@ -53,11 +53,12 @@ import {
|
||||
UploadRequestOptions,
|
||||
} from "element-plus";
|
||||
|
||||
import FileAPI, { FileInfo } from "@/api/file";
|
||||
import FileAPI from "@/api/file";
|
||||
import type { FileInfo } from "@/types/api";
|
||||
|
||||
const props = defineProps({
|
||||
/**
|
||||
* 请求携带的额外参数
|
||||
* 请求携带的额外参<EFBFBD>?
|
||||
*/
|
||||
data: {
|
||||
type: Object,
|
||||
@@ -121,7 +122,7 @@ const modelValue = defineModel("modelValue", {
|
||||
|
||||
const fileList = ref([] as UploadFile[]);
|
||||
|
||||
// 监听 modelValue 转换用于显示的 fileList
|
||||
// 监听 modelValue 转换用于显示<EFBFBD>?fileList
|
||||
watch(
|
||||
modelValue,
|
||||
(value) => {
|
||||
@@ -141,7 +142,7 @@ watch(
|
||||
);
|
||||
|
||||
/**
|
||||
* 上传前校验
|
||||
* 上传前校<EFBFBD>?
|
||||
*/
|
||||
function handleBeforeUpload(file: UploadRawFile) {
|
||||
// 限制文件大小
|
||||
@@ -185,7 +186,7 @@ function handleUpload(options: UploadRequestOptions) {
|
||||
* 上传文件超出限制
|
||||
*/
|
||||
function handleExceed() {
|
||||
ElMessage.warning(`最多只能上传${props.limit}个文件`);
|
||||
ElMessage.warning("最多只能上传 " + props.limit + " 个文件");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -193,7 +194,7 @@ function handleExceed() {
|
||||
*/
|
||||
const handleSuccess = (response: any, uploadFile: UploadFile, files: UploadFiles) => {
|
||||
ElMessage.success("上传成功");
|
||||
//只有当状态为success或者fail,代表文件上传全部完成了,失败也算完成
|
||||
//只有当状态为success或者fail,代表文件上传全部完成了,失败也算完<EFBFBD>?
|
||||
if (
|
||||
files.every((file: UploadFile) => {
|
||||
return file.status === "success" || file.status === "fail";
|
||||
@@ -202,7 +203,7 @@ const handleSuccess = (response: any, uploadFile: UploadFile, files: UploadFiles
|
||||
const fileInfos = [] as FileInfo[];
|
||||
files.map((file: UploadFile) => {
|
||||
if (file.status === "success") {
|
||||
//只取携带response的才是刚上传的
|
||||
//只取携带response的才是刚上传<EFBFBD>?
|
||||
const res = file.response as FileInfo;
|
||||
if (res) {
|
||||
fileInfos.push({ name: res.name, url: res.url } as FileInfo);
|
||||
@@ -250,7 +251,7 @@ function handleDownload(file: UploadUserFile) {
|
||||
|
||||
/** 获取一个不重复的id */
|
||||
function getUid(): number {
|
||||
// 时间戳左移13位(相当于乘以8192) + 4位随机数
|
||||
// 时间戳左<EFBFBD>?3位(相当于乘<EFBFBD>?192<EFBFBD>?+ 4位随机数
|
||||
return (Date.now() << 13) | Math.floor(Math.random() * 8192);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -40,7 +40,8 @@
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { UploadRawFile, UploadRequestOptions, UploadUserFile } from "element-plus";
|
||||
import FileAPI, { FileInfo } from "@/api/file";
|
||||
import FileAPI from "@/api/file";
|
||||
import type { FileInfo } from "@/types/api";
|
||||
|
||||
const props = defineProps({
|
||||
/**
|
||||
@@ -67,7 +68,7 @@ const props = defineProps({
|
||||
default: 10,
|
||||
},
|
||||
/**
|
||||
* 单个文件的最大允许大小
|
||||
* 单个文件的最大允许大<EFBFBD>?
|
||||
*/
|
||||
maxFileSize: {
|
||||
type: Number,
|
||||
@@ -78,12 +79,12 @@ const props = defineProps({
|
||||
*/
|
||||
accept: {
|
||||
type: String,
|
||||
default: "image/*", // 默认支持所有图片格式 ,如果需要指定格式,格式如下:'.png,.jpg,.jpeg,.gif,.bmp'
|
||||
default: "image/*", // 默认支持所有图片格式,如果需要指定格式,格式如下:.png,.jpg,.jpeg,.gif,.bmp
|
||||
},
|
||||
});
|
||||
|
||||
const previewVisible = ref(false); // 是否显示预览
|
||||
const previewImageIndex = ref(0); // 预览图片的索引
|
||||
const previewImageIndex = ref(0); // 预览图片的索<EFBFBD>?
|
||||
|
||||
const modelValue = defineModel("modelValue", {
|
||||
type: [Array] as PropType<string[]>,
|
||||
@@ -107,28 +108,28 @@ function handleRemove(imageUrl: string) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传前校验
|
||||
* 上传前校<EFBFBD>?
|
||||
*/
|
||||
function handleBeforeUpload(file: UploadRawFile) {
|
||||
// 校验文件类型:虽然 accept 属性限制了用户在文件选择器中可选的文件类型,但仍需在上传时再次校验文件实际类型,确保符合 accept 的规则
|
||||
// 校验文件类型:虽<EFBFBD>?accept 属性限制了用户在文件选择器中可选的文件类型,但仍需在上传时再次校验文件实际类型,确保符<EFBFBD>?accept 的规<EFBFBD>?
|
||||
const acceptTypes = props.accept.split(",").map((type) => type.trim());
|
||||
|
||||
// 检查文件格式是否符合 accept
|
||||
const isValidType = acceptTypes.some((type) => {
|
||||
if (type === "image/*") {
|
||||
// 如果是 image/*,检查 MIME 类型是否以 "image/" 开头
|
||||
// 如果<EFBFBD>?image/*,检<EFBFBD>?MIME 类型是否<EFBFBD>?"image/" 开<EFBFBD>?
|
||||
return file.type.startsWith("image/");
|
||||
} else if (type.startsWith(".")) {
|
||||
// 如果是扩展名 (.png, .jpg),检查文件名是否以指定扩展名结尾
|
||||
return file.name.toLowerCase().endsWith(type);
|
||||
} else {
|
||||
// 如果是具体的 MIME 类型 (image/png, image/jpeg),检查是否完全匹配
|
||||
// 如果是具体的 MIME 类型 (image/png, image/jpeg),检查是否完全匹<EFBFBD>?
|
||||
return file.type === type;
|
||||
}
|
||||
});
|
||||
|
||||
if (!isValidType) {
|
||||
ElMessage.warning(`上传文件的格式不正确,仅支持:${props.accept}`);
|
||||
ElMessage.warning("上传文件的格式不正确,仅支持 " + props.accept);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -169,7 +170,7 @@ function handleUpload(options: UploadRequestOptions) {
|
||||
* 上传文件超出限制
|
||||
*/
|
||||
function handleExceed() {
|
||||
ElMessage.warning("最多只能上传" + props.limit + "张图片");
|
||||
ElMessage.warning("最多只能上传 " + props.limit + " 张图片");
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -33,11 +33,12 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { UploadRawFile, UploadRequestOptions } from "element-plus";
|
||||
import FileAPI, { FileInfo } from "@/api/file";
|
||||
import FileAPI from "@/api/file";
|
||||
import type { FileInfo } from "@/types/api";
|
||||
|
||||
const props = defineProps({
|
||||
/**
|
||||
* 请求携带的额外参数
|
||||
* 请求携带的额外参<EFBFBD>?
|
||||
*/
|
||||
data: {
|
||||
type: Object,
|
||||
@@ -53,7 +54,7 @@ const props = defineProps({
|
||||
default: "file",
|
||||
},
|
||||
/**
|
||||
* 最大文件大小(单位:M)
|
||||
* 最大文件大小(单位:M<EFBFBD>?
|
||||
*/
|
||||
maxFileSize: {
|
||||
type: Number,
|
||||
@@ -61,7 +62,7 @@ const props = defineProps({
|
||||
},
|
||||
|
||||
/**
|
||||
* 上传图片格式,默认支持所有图片(image/*),指定格式示例:'.png,.jpg,.jpeg,.gif,.bmp'
|
||||
* 上传图片格式,默认支持所有图<EFBFBD>?image/*),指定格式示例:'.png,.jpg,.jpeg,.gif,.bmp'
|
||||
*/
|
||||
accept: {
|
||||
type: String,
|
||||
@@ -69,7 +70,7 @@ const props = defineProps({
|
||||
},
|
||||
|
||||
/**
|
||||
* 自定义样式,用于设置组件的宽度和高度等其他样式
|
||||
* 自定义样式,用于设置组件的宽度和高度等其他样<EFBFBD>?
|
||||
*/
|
||||
style: {
|
||||
type: Object,
|
||||
@@ -91,25 +92,25 @@ const modelValue = defineModel("modelValue", {
|
||||
* 限制用户上传文件的格式和大小
|
||||
*/
|
||||
function handleBeforeUpload(file: UploadRawFile) {
|
||||
// 校验文件类型:虽然 accept 属性限制了用户在文件选择器中可选的文件类型,但仍需在上传时再次校验文件实际类型,确保符合 accept 的规则
|
||||
// 校验文件类型:虽<EFBFBD>?accept 属性限制了用户在文件选择器中可选的文件类型,但仍需在上传时再次校验文件实际类型,确保符<EFBFBD>?accept 的规<EFBFBD>?
|
||||
const acceptTypes = props.accept.split(",").map((type) => type.trim());
|
||||
|
||||
// 检查文件格式是否符合 accept
|
||||
// 检查文件格式是否符<EFBFBD>?accept
|
||||
const isValidType = acceptTypes.some((type) => {
|
||||
if (type === "image/*") {
|
||||
// 如果是 image/*,检查 MIME 类型是否以 "image/" 开头
|
||||
// 如果<EFBFBD>?image/*,检<EFBFBD>?MIME 类型是否<EFBFBD>?"image/" 开<EFBFBD>?
|
||||
return file.type.startsWith("image/");
|
||||
} else if (type.startsWith(".")) {
|
||||
// 如果是扩展名 (.png, .jpg),检查文件名是否以指定扩展名结尾
|
||||
return file.name.toLowerCase().endsWith(type);
|
||||
} else {
|
||||
// 如果是具体的 MIME 类型 (image/png, image/jpeg),检查是否完全匹配
|
||||
// 如果是具体的 MIME 类型 (image/png, image/jpeg),检查是否完全匹<EFBFBD>?
|
||||
return file.type === type;
|
||||
}
|
||||
});
|
||||
|
||||
if (!isValidType) {
|
||||
ElMessage.warning(`上传文件的格式不正确,仅支持:${props.accept}`);
|
||||
ElMessage.warning("上传文件的格式不正确,仅支持 " + props.accept);
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
<!--
|
||||
* 基于 wangEditor-next 的富文本编辑器组件二次封装
|
||||
* 版权所有 © 2021-present 有来开源组织
|
||||
* 基于 wangEditor-next 的富文本编辑器组件二次å°<EFBFBD>è£?
|
||||
* 版æ<EFBFBD>ƒæ‰€æœ?© 2021-present 有æ<EFBFBD>¥å¼€æº<EFBFBD>组ç»?
|
||||
*
|
||||
* å¼€æº<EFBFBD>å<EFBFBD><EFBFBD>议:https://opensource.org/licenses/MIT
|
||||
* 项目地å<EFBFBD>€ï¼šhttps://gitee.com/youlaiorg/vue3-element-admin
|
||||
*
|
||||
* 在使用时,请保留此注释,感谢您对开源的支持!
|
||||
* 在使用时,请ä¿<EFBFBD>ç•™æ¤æ³¨é‡Šï¼Œæ„Ÿè°¢æ‚¨å¯¹å¼€æº<EFBFBD>的支æŒ<EFBFBD>ï¼?
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div style="z-index: 999; border: 1px solid var(--el-border-color)">
|
||||
<!-- 工具栏 -->
|
||||
<!-- 工具æ ?-->
|
||||
<Toolbar
|
||||
:editor="editorRef"
|
||||
mode="simple"
|
||||
:default-config="toolbarConfig"
|
||||
style="border-bottom: 1px solid var(--el-border-color)"
|
||||
/>
|
||||
<!-- 编辑器 -->
|
||||
<!-- 编辑�-->
|
||||
<Editor
|
||||
v-model="modelValue"
|
||||
:style="{ height: height, overflowY: 'hidden' }"
|
||||
@@ -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) {
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
import type { InternalAxiosRequestConfig } from "axios";
|
||||
import { useUserStoreHook } from "@/store/modules/user-store";
|
||||
import { AuthStorage, redirectToLogin } from "@/utils/auth";
|
||||
|
||||
/**
|
||||
* 重试请求的回调函数类型
|
||||
*/
|
||||
type RetryCallback = () => void;
|
||||
|
||||
/**
|
||||
* Token刷新组合式函数
|
||||
*/
|
||||
export function useTokenRefresh() {
|
||||
// Token 刷新相关状态
|
||||
let isRefreshingToken = false;
|
||||
const pendingRequests: RetryCallback[] = [];
|
||||
|
||||
/**
|
||||
* 刷新 Token 并重试请求
|
||||
*/
|
||||
async function refreshTokenAndRetry(
|
||||
config: InternalAxiosRequestConfig,
|
||||
httpRequest: any
|
||||
): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// 封装需要重试的请求
|
||||
const retryRequest = () => {
|
||||
const newToken = AuthStorage.getAccessToken();
|
||||
if (newToken && config.headers) {
|
||||
config.headers.Authorization = `Bearer ${newToken}`;
|
||||
}
|
||||
httpRequest(config).then(resolve).catch(reject);
|
||||
};
|
||||
|
||||
// 将请求加入等待队列
|
||||
pendingRequests.push(retryRequest);
|
||||
|
||||
// 如果没有正在刷新,则开始刷新流程
|
||||
if (!isRefreshingToken) {
|
||||
isRefreshingToken = true;
|
||||
|
||||
useUserStoreHook()
|
||||
.refreshToken()
|
||||
.then(() => {
|
||||
// 刷新成功,重试所有等待的请求
|
||||
pendingRequests.forEach((callback) => {
|
||||
try {
|
||||
callback();
|
||||
} catch (error) {
|
||||
console.error("Retry request error:", error);
|
||||
}
|
||||
});
|
||||
// 清空队列
|
||||
pendingRequests.length = 0;
|
||||
})
|
||||
.catch(async (error) => {
|
||||
console.error("Token refresh failed:", error);
|
||||
// 刷新失败,先 reject 所有等待的请求,再清空队列
|
||||
const failedRequests = [...pendingRequests];
|
||||
pendingRequests.length = 0;
|
||||
|
||||
// 拒绝所有等待的请求
|
||||
failedRequests.forEach(() => {
|
||||
reject(new Error("Token refresh failed"));
|
||||
});
|
||||
|
||||
// 跳转登录页
|
||||
await redirectToLogin("登录状态已失效,请重新登录");
|
||||
})
|
||||
.finally(() => {
|
||||
isRefreshingToken = false;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取刷新状态(用于外部判断)
|
||||
*/
|
||||
function getRefreshStatus() {
|
||||
return {
|
||||
isRefreshing: isRefreshingToken,
|
||||
pendingCount: pendingRequests.length,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
refreshTokenAndRetry,
|
||||
getRefreshStatus,
|
||||
};
|
||||
}
|
||||
@@ -1,14 +1,11 @@
|
||||
export { useStomp } from "./websocket/useStomp";
|
||||
export { useDictSync } from "./websocket/useDictSync";
|
||||
export type { DictMessage } from "./websocket/useDictSync";
|
||||
export { useOnlineCount } from "./websocket/useOnlineCount";
|
||||
export { useTokenRefresh } from "./auth/useTokenRefresh";
|
||||
|
||||
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";
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import { useAppStore, useSettingsStore } from "@/store";
|
||||
import { defaultSettings } from "@/settings";
|
||||
|
||||
/**
|
||||
* 布局相关的通用逻辑
|
||||
*/
|
||||
export function useLayout() {
|
||||
const appStore = useAppStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
|
||||
// 计算当前布局模式
|
||||
const currentLayout = computed(() => settingsStore.layout);
|
||||
|
||||
// 侧边栏展开状态
|
||||
const isSidebarOpen = computed(() => appStore.sidebar.opened);
|
||||
|
||||
// 是否显示标签视图
|
||||
const isShowTagsView = computed(() => settingsStore.showTagsView);
|
||||
|
||||
// 是否显示设置面板
|
||||
const isShowSettings = computed(() => defaultSettings.showSettings);
|
||||
|
||||
// 是否显示Logo
|
||||
const isShowLogo = computed(() => settingsStore.showAppLogo);
|
||||
|
||||
// 是否移动设备
|
||||
const isMobile = computed(() => appStore.device === "mobile");
|
||||
|
||||
// 布局CSS类
|
||||
const layoutClass = computed(() => ({
|
||||
hideSidebar: !appStore.sidebar.opened,
|
||||
openSidebar: appStore.sidebar.opened,
|
||||
mobile: appStore.device === "mobile",
|
||||
[`layout-${settingsStore.layout}`]: true,
|
||||
}));
|
||||
|
||||
/**
|
||||
* 处理切换侧边栏的展开/收起状态
|
||||
*/
|
||||
function toggleSidebar() {
|
||||
appStore.toggleSidebar();
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭侧边栏(移动端)
|
||||
*/
|
||||
function closeSidebar() {
|
||||
appStore.closeSideBar();
|
||||
}
|
||||
|
||||
return {
|
||||
currentLayout,
|
||||
isSidebarOpen,
|
||||
isShowTagsView,
|
||||
isShowSettings,
|
||||
isShowLogo,
|
||||
isMobile,
|
||||
layoutClass,
|
||||
toggleSidebar,
|
||||
closeSidebar,
|
||||
};
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import { useRoute } from "vue-router";
|
||||
import { useAppStore, usePermissionStore } from "@/store";
|
||||
|
||||
/**
|
||||
* 布局菜单处理逻辑
|
||||
*/
|
||||
export function useLayoutMenu() {
|
||||
const route = useRoute();
|
||||
const appStore = useAppStore();
|
||||
const permissionStore = usePermissionStore();
|
||||
|
||||
// 顶部菜单激活路径
|
||||
const activeTopMenuPath = computed(() => appStore.activeTopMenuPath);
|
||||
|
||||
// 常规路由(左侧菜单或顶部菜单)
|
||||
const routes = computed(() => permissionStore.routes);
|
||||
|
||||
// 混合布局左侧菜单路由
|
||||
const sideMenuRoutes = computed(() => permissionStore.mixLayoutSideMenus);
|
||||
|
||||
// 当前激活的菜单
|
||||
const activeMenu = computed(() => {
|
||||
const { meta, path } = route;
|
||||
|
||||
// 如果设置了activeMenu,则使用
|
||||
if (meta?.activeMenu) {
|
||||
return meta.activeMenu;
|
||||
}
|
||||
|
||||
return path;
|
||||
});
|
||||
|
||||
return {
|
||||
routes,
|
||||
sideMenuRoutes,
|
||||
activeMenu,
|
||||
activeTopMenuPath,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
61
src/composables/websocket/index.ts
Normal file
61
src/composables/websocket/index.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* WebSocket 服务统一管理
|
||||
*
|
||||
* @description
|
||||
* 提供 WebSocket 服务的统一初始化和清理接口
|
||||
* - 字典同步服务
|
||||
* - 在线用户统计服务
|
||||
*
|
||||
* @author 有来技术团队
|
||||
*/
|
||||
|
||||
import { useDictSync } from "./useDictSync";
|
||||
import { useOnlineCount } from "./useOnlineCount";
|
||||
|
||||
/**
|
||||
* 初始化所有 WebSocket 服务
|
||||
*
|
||||
* 应在应用启动时调用,统一初始化所有 WebSocket 连接
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // 在 main.ts 中调用
|
||||
* setupWebSocket();
|
||||
* ```
|
||||
*/
|
||||
export function setupWebSocket() {
|
||||
// 初始化字典同步服务
|
||||
const dictSync = useDictSync();
|
||||
dictSync.initialize();
|
||||
|
||||
// 初始化在线用户统计服务
|
||||
const onlineCount = useOnlineCount();
|
||||
onlineCount.initialize();
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理所有 WebSocket 连接
|
||||
*
|
||||
* 应在用户登出时调用,释放所有 WebSocket 资源
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // 在 user store 的 logout 方法中调用
|
||||
* cleanupWebSocket();
|
||||
* ```
|
||||
*/
|
||||
export function cleanupWebSocket() {
|
||||
// 清理字典同步服务
|
||||
const dictSync = useDictSync();
|
||||
dictSync.cleanup();
|
||||
|
||||
// 清理在线用户统计服务
|
||||
const onlineCount = useOnlineCount();
|
||||
onlineCount.cleanup();
|
||||
}
|
||||
|
||||
// 导出所有 WebSocket 相关的 composables
|
||||
export { useDictSync } from "./useDictSync";
|
||||
export { useOnlineCount } from "./useOnlineCount";
|
||||
export { useStomp } from "./useStomp";
|
||||
export type { DictMessage, DictChangeMessage, DictChangeCallback } from "./useDictSync";
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useDictStoreHook } from "@/store/modules/dict-store";
|
||||
import { useDictStoreHook } from "@/store/modules/dict";
|
||||
import { useStomp } from "./useStomp";
|
||||
import type { IMessage } from "@stomp/stompjs";
|
||||
|
||||
@@ -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();
|
||||
*
|
||||
* // 注册回调
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
/**
|
||||
* 恢复所有订阅
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
/**
|
||||
* 配置统一导出
|
||||
*/
|
||||
@@ -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];
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { App } from "vue";
|
||||
import { createI18n } from "vue-i18n";
|
||||
import { useAppStoreHook } from "@/store/modules/app-store";
|
||||
import { useAppStoreHook } from "@/store/modules/app";
|
||||
// 本地语言包
|
||||
import enLocale from "./package/en.json";
|
||||
import zhCnLocale from "./package/zh-cn.json";
|
||||
|
||||
13
src/lang/utils.ts
Normal file
13
src/lang/utils.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* 国际化工具函数
|
||||
*/
|
||||
import i18n from "./index";
|
||||
|
||||
/**
|
||||
* 翻译路由标题
|
||||
* 用于面包屑、侧边栏、标签页等场景
|
||||
*/
|
||||
export function translateRouteTitle(title: string): string {
|
||||
const key = `route.${title}`;
|
||||
return i18n.global.te(key) ? i18n.global.t(key) : title;
|
||||
}
|
||||
@@ -1,21 +1,17 @@
|
||||
<template>
|
||||
<div class="layout" :class="layoutClass">
|
||||
<!-- 移动端遮罩层 - 当侧边栏打开时显示 -->
|
||||
<!-- 移动端遮罩层 -->
|
||||
<div v-if="isMobile && isSidebarOpen" class="layout__overlay" @click="closeSidebar" />
|
||||
|
||||
<!-- 布局内容插槽 - 各种布局模式的具体内容 -->
|
||||
<slot></slot>
|
||||
<!-- 布局内容插槽 -->
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useLayout, useDeviceDetection } from "@/composables";
|
||||
import { useLayout } from "./useLayout";
|
||||
|
||||
/// Layout-related functionality and state management
|
||||
const { layoutClass, isSidebarOpen, closeSidebar } = useLayout();
|
||||
|
||||
/// Device detection for responsive layout
|
||||
const { isMobile } = useDeviceDetection();
|
||||
const { layoutClass, isSidebarOpen, isMobile, closeSidebar } = useLayout();
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@@ -1,47 +1,40 @@
|
||||
<template>
|
||||
<BaseLayout>
|
||||
<!-- 左侧菜单栏 -->
|
||||
<!-- 左侧è<EFBFBD>œå<EFBFBD>•æ ?-->
|
||||
<div class="layout__sidebar" :class="{ 'layout__sidebar--collapsed': !isSidebarOpen }">
|
||||
<div :class="{ 'has-logo': isShowLogo }" class="layout-sidebar">
|
||||
<!-- Logo -->
|
||||
<AppLogo v-if="isShowLogo" :collapse="!isSidebarOpen" />
|
||||
<!-- 主菜单内容 -->
|
||||
<div :class="{ 'has-logo': showLogo }" class="layout-sidebar">
|
||||
<LayoutLogo v-if="showLogo" :collapse="!isSidebarOpen" />
|
||||
<el-scrollbar>
|
||||
<BasicMenu :data="routes" base-path="" />
|
||||
<LayoutSidebar :data="routes" base-path="" />
|
||||
</el-scrollbar>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主内容区 -->
|
||||
<div
|
||||
class="layout__main"
|
||||
:class="{
|
||||
hasTagsView: isShowTagsView,
|
||||
hasTagsView: showTagsView,
|
||||
'layout__main--collapsed': !isSidebarOpen,
|
||||
}"
|
||||
class="layout__main"
|
||||
>
|
||||
<NavBar />
|
||||
<TagsView v-if="isShowTagsView" />
|
||||
<AppMain />
|
||||
<LayoutNavbar />
|
||||
<LayoutTagsView v-if="showTagsView" />
|
||||
<LayoutMain />
|
||||
</div>
|
||||
</BaseLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useLayout } from "@/composables/layout/useLayout";
|
||||
import { useLayoutMenu } from "@/composables/layout/useLayoutMenu";
|
||||
import BaseLayout from "../base/index.vue";
|
||||
import AppLogo from "../../components/AppLogo/index.vue";
|
||||
import NavBar from "../../components/NavBar/index.vue";
|
||||
import TagsView from "../../components/TagsView/index.vue";
|
||||
import AppMain from "../../components/AppMain/index.vue";
|
||||
import BasicMenu from "../../components/Menu/BasicMenu.vue";
|
||||
import { useLayout } from "./useLayout";
|
||||
import BaseLayout from "./BaseLayout.vue";
|
||||
import LayoutLogo from "./components/LayoutLogo.vue";
|
||||
import LayoutNavbar from "./components/LayoutNavbar.vue";
|
||||
import LayoutTagsView from "./components/LayoutTagsView.vue";
|
||||
import LayoutMain from "./components/LayoutMain.vue";
|
||||
import LayoutSidebar from "./components/LayoutSidebar.vue";
|
||||
|
||||
// 布局相关参数
|
||||
const { isShowTagsView, isShowLogo, isSidebarOpen } = useLayout();
|
||||
|
||||
// 菜单相关
|
||||
const { routes } = useLayoutMenu();
|
||||
const { showTagsView, showLogo, isSidebarOpen, routes } = useLayout();
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@@ -98,7 +91,7 @@ const { routes } = useLayoutMenu();
|
||||
}
|
||||
}
|
||||
|
||||
/* 移动端样式 */
|
||||
/* ç§»åŠ¨ç«¯æ ·å¼?*/
|
||||
.mobile {
|
||||
.layout__sidebar {
|
||||
width: $sidebar-width !important;
|
||||
360
src/layouts/MixLayout.vue
Normal file
360
src/layouts/MixLayout.vue
Normal file
@@ -0,0 +1,360 @@
|
||||
<template>
|
||||
<BaseLayout>
|
||||
<!-- 顶部菜单栏 -->
|
||||
<div class="layout__header">
|
||||
<div class="layout__header-content">
|
||||
<div v-if="showLogo" class="layout__header-logo">
|
||||
<LayoutLogo :collapse="isLogoCollapsed" />
|
||||
</div>
|
||||
|
||||
<!-- 顶部菜单 -->
|
||||
<div class="layout__header-menu">
|
||||
<el-menu
|
||||
mode="horizontal"
|
||||
:default-active="activeTopMenuPath"
|
||||
:background-color="useMenuColors ? variables['menu-background'] : undefined"
|
||||
:text-color="useMenuColors ? variables['menu-text'] : undefined"
|
||||
:active-text-color="useMenuColors ? variables['menu-active-text'] : undefined"
|
||||
@select="handleTopMenuSelect"
|
||||
>
|
||||
<el-menu-item v-for="item in topMenuItems" :key="item.path" :index="item.path">
|
||||
<template v-if="item.meta">
|
||||
<MenuIcon :icon="item.meta.icon" />
|
||||
<span v-if="item.meta.title" class="ml-1">
|
||||
{{ translateRouteTitle(item.meta.title) }}
|
||||
</span>
|
||||
</template>
|
||||
</el-menu-item>
|
||||
</el-menu>
|
||||
</div>
|
||||
|
||||
<div class="layout__header-actions">
|
||||
<LayoutToolbar />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主内容区容器 -->
|
||||
<div class="layout__container">
|
||||
<!-- 左侧菜单栏 -->
|
||||
<div class="layout__sidebar--left" :class="{ 'layout__sidebar--collapsed': !isSidebarOpen }">
|
||||
<el-scrollbar>
|
||||
<el-menu
|
||||
:default-active="activeSideMenuPath"
|
||||
:collapse="!isSidebarOpen"
|
||||
:collapse-transition="false"
|
||||
:unique-opened="false"
|
||||
:background-color="variables['menu-background']"
|
||||
:text-color="variables['menu-text']"
|
||||
:active-text-color="variables['menu-active-text']"
|
||||
>
|
||||
<LayoutSidebarItem
|
||||
v-for="item in sideMenuRoutes"
|
||||
:key="item.path"
|
||||
:item="item"
|
||||
:base-path="resolvePath(item.path)"
|
||||
/>
|
||||
</el-menu>
|
||||
</el-scrollbar>
|
||||
<div class="layout__sidebar-toggle">
|
||||
<Hamburger :is-active="isSidebarOpen" @toggle-click="toggleSidebar" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主内容区 -->
|
||||
<div :class="{ hasTagsView: showTagsView }" class="layout__main">
|
||||
<LayoutTagsView v-if="showTagsView" />
|
||||
<LayoutMain />
|
||||
</div>
|
||||
</div>
|
||||
</BaseLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { LocationQueryRaw, RouteRecordRaw } from "vue-router";
|
||||
import { useWindowSize } from "@vueuse/core";
|
||||
import { useLayout } from "./useLayout";
|
||||
import { useAppStore, usePermissionStore, useSettingsStore } from "@/store";
|
||||
import { isExternal } from "@/utils/index";
|
||||
import { translateRouteTitle } from "@/lang/utils";
|
||||
import { SidebarColor } from "@/enums/settings";
|
||||
import { ElIcon } from "element-plus";
|
||||
import BaseLayout from "./BaseLayout.vue";
|
||||
import LayoutLogo from "./components/LayoutLogo.vue";
|
||||
import LayoutToolbar from "./components/LayoutToolbar.vue";
|
||||
import LayoutTagsView from "./components/LayoutTagsView.vue";
|
||||
import LayoutMain from "./components/LayoutMain.vue";
|
||||
import LayoutSidebarItem from "./components/LayoutSidebarItem.vue";
|
||||
import Hamburger from "@/components/Hamburger/index.vue";
|
||||
import variables from "@/styles/variables.module.scss";
|
||||
|
||||
// 菜单图标渲染组件
|
||||
const MenuIcon = defineComponent({
|
||||
props: { icon: String },
|
||||
setup(props) {
|
||||
const isElIcon = computed(() => props.icon?.startsWith("el-icon"));
|
||||
const iconName = computed(() => props.icon?.replace("el-icon-", ""));
|
||||
|
||||
return () => {
|
||||
if (!props.icon) {
|
||||
return h("div", { class: "i-svg:menu" });
|
||||
}
|
||||
|
||||
// Element Plus 图标
|
||||
if (isElIcon.value) {
|
||||
return h(ElIcon, null, () => h(resolveComponent(iconName.value!)));
|
||||
}
|
||||
|
||||
// SVG 图标
|
||||
return h("div", { class: `i-svg:${props.icon}` });
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { width } = useWindowSize();
|
||||
|
||||
const appStore = useAppStore();
|
||||
const permissionStore = usePermissionStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
|
||||
const { showTagsView, showLogo, isSidebarOpen, toggleSidebar, sideMenuRoutes, activeTopMenuPath } =
|
||||
useLayout();
|
||||
|
||||
const isLogoCollapsed = computed(() => width.value < 768);
|
||||
|
||||
// 是否使用深色菜单配色(暗色主题或经典蓝侧边栏)
|
||||
const useMenuColors = computed(
|
||||
() =>
|
||||
settingsStore.theme === "dark" || settingsStore.sidebarColorScheme === SidebarColor.CLASSIC_BLUE
|
||||
);
|
||||
|
||||
// 顶部菜单项(处理单子菜单显示优化)
|
||||
const topMenuItems = computed(() => {
|
||||
const routes = permissionStore.routes.filter((item) => !item.meta?.hidden);
|
||||
|
||||
return routes.map((route) => {
|
||||
// alwaysShow 或无子菜单,直接返回
|
||||
if (route.meta?.alwaysShow || !route.children?.length) return route;
|
||||
|
||||
// 过滤可见子菜单
|
||||
const visibleChildren = route.children.filter((child) => !child.meta?.hidden);
|
||||
|
||||
// 仅一个可见子菜单时,显示子菜单信息
|
||||
if (visibleChildren.length === 1) {
|
||||
const child = visibleChildren[0];
|
||||
return {
|
||||
...route,
|
||||
meta: {
|
||||
...route.meta,
|
||||
title: child.meta?.title || route.meta?.title,
|
||||
icon: child.meta?.icon || route.meta?.icon,
|
||||
},
|
||||
};
|
||||
}
|
||||
return route;
|
||||
});
|
||||
});
|
||||
|
||||
// 左侧菜单激活路径
|
||||
const activeSideMenuPath = computed(() => {
|
||||
const { meta, path } = route;
|
||||
return typeof meta?.activeMenu === "string" ? meta.activeMenu : path;
|
||||
});
|
||||
|
||||
// 解析左侧菜单路径
|
||||
function resolvePath(routePath: string) {
|
||||
if (isExternal(routePath)) return routePath;
|
||||
if (routePath.startsWith("/")) return activeTopMenuPath.value + routePath;
|
||||
return `${activeTopMenuPath.value}/${routePath}`;
|
||||
}
|
||||
|
||||
// 从路径提取顶级菜单路径
|
||||
function extractTopMenuPath(path: string): string {
|
||||
return path.split("/").filter(Boolean).length > 1 ? path.match(/^\/[^/]+/)?.[0] || "/" : "/";
|
||||
}
|
||||
|
||||
// 顶部菜单点击
|
||||
function handleTopMenuSelect(menuPath: string) {
|
||||
if (menuPath === activeTopMenuPath.value) return;
|
||||
|
||||
appStore.activeTopMenu(menuPath);
|
||||
permissionStore.setMixLayoutSideMenus(menuPath);
|
||||
navigateToFirstMenu(permissionStore.mixLayoutSideMenus);
|
||||
}
|
||||
|
||||
// 导航到第一个可访问菜单
|
||||
function navigateToFirstMenu(menus: RouteRecordRaw[]) {
|
||||
if (!menus.length) return;
|
||||
|
||||
const [first] = menus;
|
||||
if (first.children?.length) {
|
||||
navigateToFirstMenu(first.children as RouteRecordRaw[]);
|
||||
} else if (first.name) {
|
||||
router.push({
|
||||
name: first.name,
|
||||
query:
|
||||
typeof first.meta?.params === "object"
|
||||
? (first.meta.params as LocationQueryRaw)
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 监听路由变化,同步顶部菜单状态
|
||||
watch(
|
||||
() => route.path,
|
||||
(newPath) => {
|
||||
const topMenuPath = extractTopMenuPath(newPath);
|
||||
if (topMenuPath !== activeTopMenuPath.value) {
|
||||
appStore.activeTopMenu(topMenuPath);
|
||||
permissionStore.setMixLayoutSideMenus(topMenuPath);
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.layout {
|
||||
&__header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 999;
|
||||
width: 100%;
|
||||
height: $navbar-height;
|
||||
background-color: var(--menu-background);
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
|
||||
&-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
&-logo {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
&-menu {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
|
||||
:deep(.el-menu) {
|
||||
height: 100%;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
}
|
||||
|
||||
:deep(.el-menu--horizontal) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
|
||||
.el-menu-item {
|
||||
height: 100%;
|
||||
line-height: $navbar-height;
|
||||
border-bottom: none;
|
||||
|
||||
&.is-active {
|
||||
background-color: rgba(255, 255, 255, 0.12);
|
||||
border-bottom: 2px solid var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&-actions {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
padding: 0 16px;
|
||||
}
|
||||
}
|
||||
|
||||
&__container {
|
||||
display: flex;
|
||||
height: calc(100vh - $navbar-height);
|
||||
padding-top: 0;
|
||||
|
||||
.layout__sidebar--left {
|
||||
position: relative;
|
||||
width: $sidebar-width;
|
||||
height: 100%;
|
||||
background-color: var(--menu-background);
|
||||
transition: width 0.28s;
|
||||
|
||||
&.layout__sidebar--collapsed {
|
||||
width: $sidebar-width-collapsed !important;
|
||||
}
|
||||
|
||||
:deep(.el-scrollbar) {
|
||||
height: calc(100vh - $navbar-height - 50px);
|
||||
}
|
||||
|
||||
:deep(.el-menu) {
|
||||
height: 100%;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.layout__sidebar-toggle {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 50px;
|
||||
line-height: 50px;
|
||||
background-color: var(--menu-background);
|
||||
box-shadow: 0 0 6px -2px var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.layout__main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
margin-left: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.mobile) {
|
||||
.layout__container {
|
||||
.layout__sidebar--left {
|
||||
position: fixed;
|
||||
top: $navbar-height;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 1000;
|
||||
transition: transform 0.28s;
|
||||
}
|
||||
}
|
||||
|
||||
&.hideSidebar {
|
||||
.layout__sidebar--left {
|
||||
width: $sidebar-width !important;
|
||||
transform: translateX(-$sidebar-width);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.hasTagsView) {
|
||||
.app-main {
|
||||
height: calc(100vh - $navbar-height - $tags-view-height) !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,47 +1,37 @@
|
||||
<template>
|
||||
<BaseLayout>
|
||||
<!-- 顶部菜单栏 -->
|
||||
<!-- 顶部è<EFBFBD>œå<EFBFBD>•æ ?-->
|
||||
<div class="layout__header">
|
||||
<div class="layout__header-left">
|
||||
<!-- Logo -->
|
||||
<AppLogo v-if="isShowLogo" :collapse="isLogoCollapsed" />
|
||||
<!-- 菜单 -->
|
||||
<BasicMenu :data="routes" menu-mode="horizontal" base-path="" />
|
||||
<LayoutLogo v-if="showLogo" :collapse="isLogoCollapsed" />
|
||||
<LayoutSidebar :data="routes" menu-mode="horizontal" base-path="" />
|
||||
</div>
|
||||
<!-- 操作按钮 -->
|
||||
<div class="layout__header-right">
|
||||
<NavbarActions />
|
||||
<LayoutToolbar />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主内容区 -->
|
||||
<div :class="{ hasTagsView: isShowTagsView }" class="layout__main">
|
||||
<TagsView v-if="isShowTagsView" />
|
||||
<AppMain />
|
||||
<div :class="{ hasTagsView: showTagsView }" class="layout__main">
|
||||
<LayoutTagsView v-if="showTagsView" />
|
||||
<LayoutMain />
|
||||
</div>
|
||||
</BaseLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useLayout } from "@/composables/layout/useLayout";
|
||||
import { useLayoutMenu } from "@/composables/layout/useLayoutMenu";
|
||||
import BaseLayout from "../base/index.vue";
|
||||
import AppLogo from "../../components/AppLogo/index.vue";
|
||||
import BasicMenu from "../../components/Menu/BasicMenu.vue";
|
||||
import NavbarActions from "../../components/NavBar/components/NavbarActions.vue";
|
||||
import TagsView from "../../components/TagsView/index.vue";
|
||||
import AppMain from "../../components/AppMain/index.vue";
|
||||
import { useWindowSize } from "@vueuse/core";
|
||||
import { useLayout } from "./useLayout";
|
||||
import BaseLayout from "./BaseLayout.vue";
|
||||
import LayoutLogo from "./components/LayoutLogo.vue";
|
||||
import LayoutSidebar from "./components/LayoutSidebar.vue";
|
||||
import LayoutToolbar from "./components/LayoutToolbar.vue";
|
||||
import LayoutTagsView from "./components/LayoutTagsView.vue";
|
||||
import LayoutMain from "./components/LayoutMain.vue";
|
||||
|
||||
// 布局相关参数
|
||||
const { isShowTagsView, isShowLogo } = useLayout();
|
||||
|
||||
// 菜单相关
|
||||
const { routes } = useLayoutMenu();
|
||||
|
||||
// 响应式窗口尺寸
|
||||
const { showTagsView, showLogo, routes } = useLayout();
|
||||
const { width } = useWindowSize();
|
||||
|
||||
// 只有在小屏设备(移动设备)时才折叠Logo(只显示图标,隐藏文字)
|
||||
const isLogoCollapsed = computed(() => width.value < 768);
|
||||
</script>
|
||||
|
||||
@@ -62,30 +52,28 @@ const isLogoCollapsed = computed(() => width.value < 768);
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
min-width: 0; // 允许flex收缩
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
|
||||
// Logo样式由AppLogo组件的全局样式控制
|
||||
:deep(.logo) {
|
||||
flex-shrink: 0; // 防止Logo被压缩
|
||||
flex-shrink: 0;
|
||||
height: $navbar-height;
|
||||
}
|
||||
}
|
||||
|
||||
&-right {
|
||||
display: flex;
|
||||
flex-shrink: 0; // 防止操作按钮被压缩
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
padding-left: 12px;
|
||||
}
|
||||
|
||||
// 菜单样式
|
||||
:deep(.el-menu--horizontal) {
|
||||
flex: 1;
|
||||
min-width: 0; // 允许菜单收缩
|
||||
min-width: 0;
|
||||
height: $navbar-height;
|
||||
overflow: hidden; // 防止菜单溢出
|
||||
overflow: hidden;
|
||||
line-height: $navbar-height;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
@@ -101,7 +89,6 @@ const isLogoCollapsed = computed(() => width.value < 768);
|
||||
line-height: $navbar-height;
|
||||
}
|
||||
|
||||
// 父菜单激活状态 - 水平布局专用
|
||||
&.has-active-child {
|
||||
.el-sub-menu__title {
|
||||
color: var(--el-color-primary) !important;
|
||||
@@ -114,7 +101,6 @@ const isLogoCollapsed = computed(() => width.value < 768);
|
||||
}
|
||||
}
|
||||
|
||||
// 修复子菜单弹出位置
|
||||
.el-menu--popup {
|
||||
min-width: 160px;
|
||||
}
|
||||
@@ -127,7 +113,6 @@ const isLogoCollapsed = computed(() => width.value < 768);
|
||||
}
|
||||
}
|
||||
|
||||
// 当存在TagsView时的样式调整
|
||||
.hasTagsView {
|
||||
:deep(.app-main) {
|
||||
height: calc(100vh - $navbar-height - $tags-view-height) !important;
|
||||
@@ -4,7 +4,7 @@
|
||||
<router-link :key="+collapse" class="wh-full flex-center" to="/">
|
||||
<img :src="logo" class="w20px h20px" />
|
||||
<span v-if="!collapse" class="title">
|
||||
{{ defaultSettings.title }}
|
||||
{{ appConfig.title }}
|
||||
</span>
|
||||
</router-link>
|
||||
</transition>
|
||||
@@ -12,8 +12,8 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { defaultSettings } from "@/settings";
|
||||
import logo from "@/assets/logo.png";
|
||||
import { appConfig } from "@/settings";
|
||||
import logo from "@/assets/images/logo.png";
|
||||
|
||||
defineProps({
|
||||
collapse: {
|
||||
@@ -40,7 +40,7 @@ defineProps({
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
// 顶部布局和混合布局的特殊处理
|
||||
// 顶部布局和混合布局的特殊处<EFBFBD>?
|
||||
.layout-top,
|
||||
.layout-mix {
|
||||
.logo {
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
// 如果路由meta中设置了activeMenu,则使用它(用于处理一些特殊情况,如详情页)
|
||||
// 如果路由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>
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
@@ -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:"]) {
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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
108
src/layouts/useLayout.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
10
src/main.ts
10
src/main.ts
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
@@ -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 {
|
||||
// 静默失败,不影响主流程
|
||||
}
|
||||
}
|
||||
|
||||
112
src/settings.ts
112
src/settings.ts
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
93
src/store/modules/settings.ts
Normal file
93
src/store/modules/settings.ts
Normal 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,
|
||||
};
|
||||
});
|
||||
@@ -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", () => {
|
||||
// 用户信息
|
||||
@@ -9,7 +9,9 @@
|
||||
background-color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
/* 混合布局左侧菜单 hover 样式 */
|
||||
/* ============================================
|
||||
混合布局左侧菜单 hover 样式
|
||||
============================================ */
|
||||
.layout-mix .layout__sidebar--left .el-menu {
|
||||
.el-menu-item {
|
||||
&:hover {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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%);
|
||||
}
|
||||
|
||||
28
src/styles/wangeditor.scss
Normal file
28
src/styles/wangeditor.scss
Normal 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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/** 菜单选项 */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
201
src/types/components.d.ts
vendored
201
src/types/components.d.ts
vendored
@@ -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']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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, " ");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user