refactor(ui): 界面升级与代码规范优化

This commit is contained in:
Ray.Hao
2026-06-18 18:38:12 +08:00
parent 288ea9b43d
commit 14035cfa4d
130 changed files with 7813 additions and 5893 deletions

View File

@@ -1,15 +1,20 @@
# http://editorconfig.org
root = true
# 表示所有文件适用
# 所有文件
[*]
charset = utf-8 # 设置文件字符集为 utf-8
end_of_line = lf # 控制换行类型(lf | cr | crlf)
indent_style = space # 缩进风格(tab | space)
indent_size = 2 # 缩进大小
insert_final_newline = true # 始终在文件末尾插入一个新行
# 文件字符集
charset = utf-8
# 换行符类型:lf | cr | crlf
end_of_line = lf
# 缩进风格:space | tab
indent_style = space
# 缩进大小
indent_size = 2
# 文件末尾保留一个空行
insert_final_newline = true
# 表示仅 md 文件适用以下规则
# Markdown 保留原始排版
[*.md]
max_line_length = off # 关闭最大行长度限制
trim_trailing_whitespace = false # 关闭末尾空格修剪
max_line_length = off
trim_trailing_whitespace = false

View File

@@ -6,8 +6,8 @@ VITE_APP_TITLE=vue3-element-admin
VITE_APP_BASE_API=/dev-api
# 接口地址
VITE_APP_API_URL=https://api.youlai.tech # 线上
# VITE_APP_API_URL=https://api.youlai.tech/v2 # 线上(多租户)
# VITE_APP_API_URL=https://api.youlai.tech # 线上
VITE_APP_API_URL=https://api.youlai.tech/v2 # 线上(多租户)
# VITE_APP_API_URL=http://localhost:8000 # 本地

View File

@@ -6,10 +6,6 @@ module.exports = {
"stylelint-config-html/vue",
"stylelint-config-recess-order",
],
plugins: [
"stylelint-prettier", // 统一代码风格,格式冲突时以 Prettier 规则为准
],
overrides: [
{
files: ["**/*.{vue,html}"],
@@ -21,19 +17,22 @@ module.exports = {
},
],
rules: {
"prettier/prettier": true, // 强制执行 Prettier 格式化规则(需配合 .prettierrc 配置文件)
"no-empty-source": null, // 允许空的样式文件
"declaration-property-value-no-unknown": null, // 允许非常规数值格式 ,如 height: calc(100% - 50)
// 允许使用未知伪类
"no-empty-source": null,
"declaration-property-value-no-unknown": null,
"selector-pseudo-class-no-unknown": [
true,
{
ignorePseudoClasses: ["global", "export", "deep"],
},
],
// 允许使用未知伪元素
"no-invalid-position-declaration": null, // Vue 模板内联 style 误报
"at-rule-no-unknown": null, // 禁用默认的未知 at-rule 检查
"scss/at-rule-no-unknown": true, // 启用 SCSS 特定的 at-rule 检查
"selector-pseudo-element-no-unknown": [
true,
{
ignorePseudoElements: ["deep", "slotted", "global", "v-deep", "v-slotted", "v-global"],
},
],
"no-invalid-position-declaration": null,
"at-rule-no-unknown": null,
"scss/at-rule-no-unknown": true,
},
};

113
.vscode/settings.json vendored
View File

@@ -1,71 +1,62 @@
{
"typescript.tsdk": "./node_modules/typescript/lib",
"js/ts.tsdk.path": "./node_modules/typescript/lib",
"npm.packageManager": "pnpm",
"editor.tabSize": 2,
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.quickSuggestions": {
"other": true,
"comments": true,
"strings": true
},
"editor.codeActionsOnSave": {
"source.fixAll": "explicit",
"source.fixAll.eslint": "explicit",
"source.fixAll.stylelint": "explicit"
},
"editor.insertSpaces": true,
"editor.detectIndentation": false,
"editor.formatOnSave": true,
"editor.formatOnSaveMode": "file",
"editor.formatOnPaste": false,
"editor.formatOnType": false,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.tabCompletion": "off",
"files.eol": "\n",
"search.exclude": {
"**/node_modules": true,
"**/*.log": true,
"**/*.log*": true,
"**/bower_components": true,
"**/dist": true,
"**/elehukouben": true,
"**/.git": true,
"**/.gitignore": true,
"**/.svn": true,
"**/.DS_Store": true,
"**/.idea": true,
"**/.vscode": false,
"**/yarn.lock": true,
"**/tmp": true,
"out": true,
"dist": true,
"node_modules": true,
"CHANGELOG.md": true,
"examples": true,
"res": true,
"screenshots": true,
"yarn-error.log": true,
"**/.yarn": true
"files.insertFinalNewline": true,
"files.trimTrailingWhitespace": true,
"prettier.requireConfig": true,
"prettier.useEditorConfig": true,
"eslint.format.enable": false,
"eslint.validate": ["javascript", "typescript", "vue"],
"stylelint.validate": ["css", "scss", "vue"],
"[vue]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"files.exclude": {
"**/.cache": true,
"**/.editorconfig": true,
"**/.eslintcache": true,
"**/bower_components": true,
"**/.idea": true,
"**/tmp": true,
"**/.git": true,
"**/.svn": true,
"**/.hg": true,
"**/CVS": true,
"**/.DS_Store": true
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[javascript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[json]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[jsonc]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[scss]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[css]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[html]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"editor.codeActionsOnSave": {
"source.fixAll": "never",
"source.fixAll.eslint": "never",
"source.fixAll.stylelint": "never"
},
"search.exclude": {
"**/dist": true,
"**/node_modules": true,
"**/*.log": true
},
"files.watcherExclude": {
"**/.git/objects/**": true,
"**/.git/subtree-cache/**": true,
"**/.vscode/**": true,
"**/node_modules/**": true,
"**/tmp/**": true,
"**/bower_components/**": true,
"**/dist/**": true,
"**/yarn.lock": true
"**/node_modules/**": true
},
"i18n-ally.localesPaths": [
"src/lang/package"
],
"scss.lint.unknownAtRules": "ignore",
"i18n-ally.keystyle": "nested"
"i18n-ally.localesPaths": ["src/lang/package"],
"i18n-ally.keystyle": "nested",
"scss.lint.unknownAtRules": "ignore"
}

View File

@@ -47,16 +47,16 @@
<table align="center">
<tr>
<td><img alt="PC Preview 1" width="400" src="./src/assets/images/preview/pc-01.png"></td>
<td><img alt="PC Preview 2" width="400" src="./src/assets/images/preview/pc-02.png"></td>
<td><img alt="PC Preview 1" width="400" src="./public/images/preview/pc-01.png"></td>
<td><img alt="PC Preview 2" width="400" src="./public/images/preview/pc-02.png"></td>
</tr>
<tr>
<td><img alt="PC Preview 3" width="400" src="./src/assets/images/preview/pc-03.png"></td>
<td><img alt="PC Preview 4" width="400" src="./src/assets/images/preview/pc-04.png"></td>
<td><img alt="PC Preview 3" width="400" src="./public/images/preview/pc-03.png"></td>
<td><img alt="PC Preview 4" width="400" src="./public/images/preview/pc-04.png"></td>
</tr>
<tr>
<td><img alt="PC Preview 5" width="400" src="./src/assets/images/preview/pc-05.png"></td>
<td><img alt="PC Preview 6" width="400" src="./src/assets/images/preview/pc-06.png"></td>
<td><img alt="PC Preview 5" width="400" src="./public/images/preview/pc-05.png"></td>
<td><img alt="PC Preview 6" width="400" src="./public/images/preview/pc-06.png"></td>
</tr>
</table>
@@ -64,10 +64,10 @@
<table align="center">
<tr>
<td><img alt="APP Preview 1" width="200" src="./src/assets/images/preview/app-01.png"></td>
<td><img alt="APP Preview 2" width="200" src="./src/assets/images/preview/app-02.png"></td>
<td><img alt="APP Preview 3" width="200" src="./src/assets/images/preview/app-03.png"></td>
<td><img alt="APP Preview 4" width="200" src="./src/assets/images/preview/app-04.png"></td>
<td><img alt="APP Preview 1" width="200" src="./public/images/preview/app-01.png"></td>
<td><img alt="APP Preview 2" width="200" src="./public/images/preview/app-02.png"></td>
<td><img alt="APP Preview 3" width="200" src="./public/images/preview/app-03.png"></td>
<td><img alt="APP Preview 4" width="200" src="./public/images/preview/app-04.png"></td>
</tr>
</table>
@@ -226,17 +226,17 @@ Thanks to all the contributors!
<table align="center">
<tr>
<td align="center">
<img src="./public/images/qr/wechat-official.png" height="180" alt="WeChat Official Account"><br>
<img src="./public/images/qrcode/wechat-official.png" height="180" alt="WeChat Official Account"><br>
<sub>WeChat Official Account</sub>
</td>
<td>&nbsp;&nbsp;&nbsp;&nbsp;</td>
<td align="center">
<img src="./public/images/qr/wechat-mp.jpg" height="180" alt="Mini Program"><br>
<img src="./public/images/qrcode/wechat-mp.jpg" height="180" alt="Mini Program"><br>
<sub>Mini Program</sub>
</td>
<td>&nbsp;&nbsp;&nbsp;&nbsp;</td>
<td align="center">
<img src="./public/images/qr/wechat-personal.png" height="180" alt="Add Author WeChat"><br>
<img src="./public/images/qrcode/wechat-personal.png" height="180" alt="Add Author WeChat"><br>
<sub>Add Author WeChat</sub>
</td>
</tr>

View File

@@ -44,16 +44,16 @@
<table align="center">
<tr>
<td><img alt="PC预览1" width="400" src="./src/assets/images/preview/pc-01.png"></td>
<td><img alt="PC预览2" width="400" src="./src/assets/images/preview/pc-02.png"></td>
<td><img alt="PC预览1" width="400" src="./public/images/preview/pc-01.png"></td>
<td><img alt="PC预览2" width="400" src="./public/images/preview/pc-02.png"></td>
</tr>
<tr>
<td><img alt="PC预览3" width="400" src="./src/assets/images/preview/pc-03.png"></td>
<td><img alt="PC预览4" width="400" src="./src/assets/images/preview/pc-04.png"></td>
<td><img alt="PC预览3" width="400" src="./public/images/preview/pc-03.png"></td>
<td><img alt="PC预览4" width="400" src="./public/images/preview/pc-04.png"></td>
</tr>
<tr>
<td><img alt="PC预览5" width="400" src="./src/assets/images/preview/pc-05.png"></td>
<td><img alt="PC预览6" width="400" src="./src/assets/images/preview/pc-06.png"></td>
<td><img alt="PC预览5" width="400" src="./public/images/preview/pc-05.png"></td>
<td><img alt="PC预览6" width="400" src="./public/images/preview/pc-06.png"></td>
</tr>
</table>
@@ -61,10 +61,10 @@
<table align="center">
<tr>
<td><img alt="APP预览1" width="200" src="./src/assets/images/preview/app-01.png"></td>
<td><img alt="APP预览2" width="200" src="./src/assets/images/preview/app-02.png"></td>
<td><img alt="APP预览3" width="200" src="./src/assets/images/preview/app-03.png"></td>
<td><img alt="APP预览4" width="200" src="./src/assets/images/preview/app-04.png"></td>
<td><img alt="APP预览1" width="200" src="./public/images/preview/app-01.png"></td>
<td><img alt="APP预览2" width="200" src="./public/images/preview/app-02.png"></td>
<td><img alt="APP预览3" width="200" src="./public/images/preview/app-03.png"></td>
<td><img alt="APP预览4" width="200" src="./public/images/preview/app-04.png"></td>
</tr>
</table>
@@ -223,17 +223,17 @@ Thanks to all the contributors!
<table align="center">
<tr>
<td align="center">
<img src="./public/images/qr/wechat-official.png" height="180" alt="公众号「有来技术」"><br>
<img src="./public/images/qrcode/wechat-official.png" height="180" alt="公众号「有来技术」"><br>
<sub>公众号「有来技术」</sub>
</td>
<td>&nbsp;&nbsp;&nbsp;&nbsp;</td>
<td align="center">
<img src="./public/images/qr/wechat-mp.jpg" height="180" alt="小程序「有来技术」"><br>
<img src="./public/images/qrcode/wechat-mp.jpg" height="180" alt="小程序「有来技术」"><br>
<sub>小程序「有来技术」</sub>
</td>
<td>&nbsp;&nbsp;&nbsp;&nbsp;</td>
<td align="center">
<img src="./public/images/qr/wechat-personal.png" height="180" alt="添加作者微信"><br>
<img src="./public/images/qrcode/wechat-personal.png" height="180" alt="添加作者微信"><br>
<sub>添加作者微信</sub>
</td>
</tr>

View File

@@ -1,3 +1,4 @@
/* eslint-disable no-console */
/**
* 部署管理后台到服务器 | 用法:pnpm run deploy
*
@@ -12,22 +13,19 @@
*/
import { execSync } from "node:child_process";
// ==================== 改为你的服务器信息 ====================
const HOST = "127.0.0.1"; // 服务器 IP,改为你的
const USER = "root"; // SSH 用户名,改为你的
const PORT = 22; // SSH 端口,改为你的
const TARGET = "/data/www/vue3-element-admin"; // 部署目录,改为你的
const HOST = "www.youlai.tech"; // 服务器 IP,改为你的
const USER = "root"; // SSH 用户名,改为你的
const PORT = 1010; // SSH 端口,改为你的
const TARGET = "/mnt/nginx/html/vue"; // 部署目录,改为你的
// =========================================================
console.log("构建中...");
execSync("pnpm build-only", { stdio: "inherit" });
console.log(`部署 → ${USER}@${HOST}:${TARGET}`);
execSync(
`scp -o StrictHostKeyChecking=no -P ${PORT} -r "dist/." "${USER}@${HOST}:${TARGET}/"`,
{ stdio: "inherit", shell: true }
);
execSync(`scp -o StrictHostKeyChecking=no -P ${PORT} -r "dist/." "${USER}@${HOST}:${TARGET}/"`, {
stdio: "inherit",
shell: true,
});
console.log("部署完成");

View File

@@ -1,62 +1,20 @@
// https://eslint.org/docs/latest/use/configure/configuration-files-new
// 基础ESLint配置
import eslint from "@eslint/js";
import globals from "globals";
// TypeScript支持
import * as typescriptEslint from "typescript-eslint";
// Vue支持
import pluginVue from "eslint-plugin-vue";
import vueParser from "vue-eslint-parser";
// 代码风格与格式化
import configPrettier from "eslint-config-prettier";
import prettierPlugin from "eslint-plugin-prettier";
// 解析自动导入配置
import fs from "node:fs";
let autoImportGlobals = {};
let autoImportGlobals: Record<string, "readonly"> = {};
try {
autoImportGlobals =
JSON.parse(fs.readFileSync("./.eslintrc-auto-import.json", "utf-8")).globals || {};
} catch (error) {
// 文件不存在或解析错误时使用空对象
console.warn("Could not load auto-import globals", error);
}
// Element Plus组件
const elementPlusComponents = {
// Element Plus 组件添加为全局变量,避免 no-undef 报错
ElInput: "readonly",
ElSelect: "readonly",
ElSwitch: "readonly",
ElCascader: "readonly",
ElInputNumber: "readonly",
ElTimePicker: "readonly",
ElTimeSelect: "readonly",
ElDatePicker: "readonly",
ElTreeSelect: "readonly",
ElText: "readonly",
ElRadioGroup: "readonly",
ElCheckboxGroup: "readonly",
ElOption: "readonly",
ElRadio: "readonly",
ElCheckbox: "readonly",
ElInputTag: "readonly",
ElForm: "readonly",
ElFormItem: "readonly",
ElTable: "readonly",
ElTableColumn: "readonly",
ElButton: "readonly",
ElDialog: "readonly",
ElPagination: "readonly",
ElMessage: "readonly",
ElMessageBox: "readonly",
ElNotification: "readonly",
ElTree: "readonly",
};
export default [
// 忽略文件配置
{
ignores: [
"**/node_modules/**",
@@ -68,36 +26,20 @@ export default [
],
},
// 基础 JavaScript 配置
eslint.configs.recommended,
// Vue 推荐配置
...pluginVue.configs["flat/recommended"],
// TypeScript 推荐配置
...typescriptEslint.configs.recommended,
// 全局配置
{
// 指定要检查的文件
files: ["**/*.{js,mjs,cjs,ts,mts,cts,vue}"],
languageOptions: {
ecmaVersion: "latest",
sourceType: "module",
globals: {
...globals.browser, // 浏览器环境全局变量
...globals.node, // Node.js 环境全局变量
...globals.es2022, // ES2022 全局对象
...autoImportGlobals, // 自动导入的 API 函数
...elementPlusComponents, // Element Plus 组件
// 全局类型定义,解决 TypeScript 中定义但 ESLint 不识别的问题
PageQuery: "readonly",
PageResult: "readonly",
OptionType: "readonly",
ApiResponse: "readonly",
ExcelResult: "readonly",
TagView: "readonly",
AppSettings: "readonly",
...globals.browser,
...globals.node,
...globals.es2022,
...autoImportGlobals,
__APP_INFO__: "readonly",
},
},
@@ -128,7 +70,6 @@ export default [
},
},
// Vue 文件特定配置
{
files: ["**/*.vue"],
languageOptions: {
@@ -142,7 +83,6 @@ export default [
},
},
rules: {
// Vue 规则
"vue/multi-word-component-names": "off",
"vue/no-v-html": "off",
"vue/require-default-prop": "off",
@@ -175,7 +115,6 @@ export default [
},
},
// TypeScript 文件特定配置
{
files: ["**/*.{ts,tsx,mts,cts}"],
languageOptions: {
@@ -188,8 +127,7 @@ export default [
},
},
rules: {
// TypeScript 规则
"@typescript-eslint/no-explicit-any": "warn", // 逐步收敛 any 类型
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/no-empty-function": "off",
"@typescript-eslint/no-empty-object-type": "off",
"@typescript-eslint/ban-ts-comment": "off",
@@ -202,13 +140,18 @@ export default [
caughtErrorsIgnorePattern: "^_",
},
],
"@typescript-eslint/no-unused-expressions": "warn", // 降级为警告
"@typescript-eslint/consistent-type-imports": "off", // 关闭强制使用type import
"@typescript-eslint/no-unused-expressions": "warn",
"@typescript-eslint/consistent-type-imports": [
"error",
{
fixStyle: "separate-type-imports",
prefer: "type-imports",
},
],
"@typescript-eslint/no-import-type-side-effects": "error",
},
},
// .d.ts 文件配置
{
files: ["**/*.d.ts"],
rules: {
@@ -217,7 +160,6 @@ export default [
},
},
// CURD 组件配置
{
files: ["**/components/CURD/**/*.{ts,vue}"],
rules: {
@@ -227,14 +169,9 @@ export default [
},
},
// Prettier 集成(必须放在最后)
{
plugins: {
prettier: prettierPlugin, // 将 Prettier 的输出作为 ESLint 的问题来报告
},
rules: {
...configPrettier.rules,
"prettier/prettier": ["error", {}, { usePrettierrc: true }],
"arrow-body-style": "off",
"prefer-arrow-callback": "off",
},

View File

@@ -176,6 +176,10 @@ export default defineMock([
url: "notices/:id/detail",
method: ["GET"],
body: ({ params }) => {
const myNotice = myNoticeList.find((item) => item.id === params.id);
if (myNotice) myNotice.isRead = 1;
if (noticeMap[params.id]) noticeMap[params.id].isRead = 1;
return {
code: "00000",
data: noticeMap[params.id],
@@ -183,6 +187,22 @@ export default defineMock([
};
},
},
// 全部已读
{
url: "notices/read-all",
method: ["PUT"],
body() {
myNoticeList.forEach((item) => {
item.isRead = 1;
});
return {
code: "00000",
data: null,
msg: "全部已读成功",
};
},
},
// 修改通知
{
url: "notices/:id",
@@ -213,63 +233,75 @@ export default defineMock([
{
url: "notices/my",
method: ["GET"],
body: {
code: "00000",
data: {
list: [
{
id: "10",
title: "v2.16.1 版本修复了 WebSocket 重复连接导致的后台线程阻塞问题,优化了通知公告。",
type: 1,
level: "L",
publisherName: "系统管理员",
publishTime: "2024-09-30 17:30",
isRead: 0,
},
{
id: "9",
title: "公司将在 10 月 15 日举办新产品发布会,敬请期待。",
type: 5,
level: "L",
publisherName: "系统管理员",
publishTime: "2024-09-30 17:29",
isRead: 0,
},
{
id: "8",
title: "国庆假期从 10 月 1 日至 10 月 7 日放假,共 7 天。",
type: 4,
level: "L",
publisherName: "系统管理员",
publishTime: "2024-09-30 17:28",
isRead: 0,
},
{
id: "7",
title: "最近发现一些钓鱼邮件,请大家提高警惕,不要点击陌生链接。",
type: 3,
level: "L",
publisherName: "系统管理员",
publishTime: "2024-09-30 17:27",
isRead: 0,
},
{
id: "6",
title: "系统将于本周六凌晨 2 点进行维护,预计维护时间为 2 小时。",
type: 2,
level: "L",
publisherName: "系统管理员",
publishTime: "2024-09-30 17:26",
isRead: 0,
},
],
total: 10,
},
msg: "一切ok",
body({ query }) {
const pageNum = Number(query?.pageNum || 1);
const pageSize = Number(query?.pageSize || 10);
const isRead =
query?.isRead == null || query.isRead === "" ? undefined : Number(query.isRead);
const filtered =
isRead == null ? myNoticeList : myNoticeList.filter((item) => item.isRead === isRead);
const start = (pageNum - 1) * pageSize;
return {
code: "00000",
data: {
list: filtered.slice(start, start + pageSize),
total: filtered.length,
},
msg: "一切ok",
};
},
},
]);
const myNoticeList = [
{
id: "10",
title: "v2.16.1 版本修复了 WebSocket 重复连接导致的后台线程阻塞问题,优化了通知公告。",
type: 1,
level: "L",
publisherName: "系统管理员",
publishTime: "2024-09-30 17:30",
isRead: 0,
},
{
id: "9",
title: "公司将在 10 月 15 日举办新产品发布会,敬请期待。",
type: 5,
level: "L",
publisherName: "系统管理员",
publishTime: "2024-09-30 17:29",
isRead: 0,
},
{
id: "8",
title: "国庆假期从 10 月 1 日至 10 月 7 日放假,共 7 天。",
type: 4,
level: "L",
publisherName: "系统管理员",
publishTime: "2024-09-30 17:28",
isRead: 0,
},
{
id: "7",
title: "最近发现一些钓鱼邮件,请大家提高警惕,不要点击陌生链接。",
type: 3,
level: "L",
publisherName: "系统管理员",
publishTime: "2024-09-30 17:27",
isRead: 1,
},
{
id: "6",
title: "系统将于本周六凌晨 2 点进行维护,预计维护时间为 2 小时。",
type: 2,
level: "L",
publisherName: "系统管理员",
publishTime: "2024-09-30 17:26",
isRead: 1,
},
];
// 通知映射表数据
const noticeMap: Record<string, any> = {
1: {

View File

@@ -1,7 +1,7 @@
{
"name": "vue3-element-admin",
"description": "Vue3 + Vite + TypeScript + Element-Plus 的后台管理模板,vue-element-admin 的 Vue3 版本",
"version": "4.6.2",
"version": "4.7.0",
"private": true,
"type": "module",
"scripts": {

2525
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 43 KiB

View File

Before

Width:  |  Height:  |  Size: 4.1 MiB

After

Width:  |  Height:  |  Size: 4.1 MiB

View File

Before

Width:  |  Height:  |  Size: 306 KiB

After

Width:  |  Height:  |  Size: 306 KiB

View File

@@ -1,10 +1,9 @@
import request from "@/utils/request";
import type { LoginRequest, LoginResponse, CaptchaInfo } from "./types";
import type { CaptchaInfo, LoginRequest, LoginResult } from "./types";
const AUTH_BASE_URL = "/api/v1/auth";
const AuthAPI = {
/** 登录接口*/
login(data: LoginRequest) {
const payload: Pick<
LoginRequest,
@@ -16,30 +15,28 @@ const AuthAPI = {
captchaCode: data.captchaCode,
};
// tenantId 可选,仅在提供时包含(多租户功能)
// 仅多租户场景传入 tenantId
if (typeof data.tenantId !== "undefined") {
payload.tenantId = data.tenantId;
}
return request<unknown, LoginResponse>({
return request<unknown, LoginResult>({
url: `${AUTH_BASE_URL}/login`,
method: "post",
data: payload,
});
},
/** 切换租户(平台用户) - 返回新的 token */
switchTenant(tenantId: number) {
return request<unknown, LoginResponse>({
return request<unknown, LoginResult>({
url: `${AUTH_BASE_URL}/switch-tenant`,
method: "post",
params: { tenantId },
});
},
/** 刷新 token 接口*/
refreshToken(refreshToken: string) {
return request<unknown, LoginResponse>({
return request<unknown, LoginResult>({
url: `${AUTH_BASE_URL}/refresh-token`,
method: "post",
params: { refreshToken },
@@ -49,7 +46,6 @@ const AuthAPI = {
});
},
/** 退出登录接口 */
logout() {
return request({
url: `${AUTH_BASE_URL}/logout`,
@@ -57,7 +53,6 @@ const AuthAPI = {
});
},
/** 获取验证码接口*/
getCaptcha() {
return request<unknown, CaptchaInfo>({
url: `${AUTH_BASE_URL}/captcha`,

View File

@@ -1,7 +1,3 @@
/**
* 认证相关类型定义
*/
/**
* 登录请求参数
*/
@@ -10,20 +6,20 @@ export interface LoginRequest {
username: string;
/** 密码 */
password: string;
/** 验证码缓存key */
/** 验证码缓存 key */
captchaId?: string;
/** 验证码 */
captchaCode?: string;
/** 记住我 */
rememberMe?: boolean;
/** 租户ID */
/** 租户 ID */
tenantId?: number;
}
/**
* 登录响应
* 登录结果
*/
export interface LoginResponse {
export interface LoginResult {
/** 访问令牌 */
accessToken: string;
/** 刷新令牌 */
@@ -35,11 +31,11 @@ export interface LoginResponse {
}
/**
* 验证码响应
* 验证码信息
*/
export interface CaptchaInfo {
/** 验证码缓存key */
/** 验证码缓存 key */
captchaId: string;
/** 验证码图片Base64 */
/** 验证码图片 Base64 */
captchaBase64: string;
}

View File

@@ -1,5 +1,6 @@
import request from "@/utils/request";
import type { GeneratorPreviewItem, TableQueryParams, TableItem, GenConfigForm } from "./types";
import type { PageResult } from "@/api/common";
const GENERATOR_BASE_URL = "/api/v1/codegen";

View File

@@ -1,18 +1,18 @@
/**
* 通用 API 类型定义
* API 响应壳
*/
/** API 响应结构 */
export interface ApiResponse<T = unknown> {
/** 响应码 */
export interface ApiResult<T = unknown> {
/** 业务状态码 */
code: string;
/** 响应数据 */
/** 业务数据 */
data: T;
/** 响应消息 */
/** 业务消息 */
msg: string;
}
/** 基础查询参数 */
/**
* 分页查询基础参数
*/
export interface BaseQueryParams {
/** 页码 */
pageNum: number;
@@ -26,7 +26,9 @@ export interface BaseQueryParams {
order?: string;
}
/** 分页数据结构(仅分页接口) */
/**
* 分页接口结果
*/
export interface PageResult<T> {
/** 数据列表 */
list: T[];
@@ -34,7 +36,9 @@ export interface PageResult<T> {
total: number;
}
/** 下拉选项 */
/**
* 通用选项项
*/
export interface OptionItem {
/** 选项值 */
value: string | number;
@@ -44,9 +48,11 @@ export interface OptionItem {
children?: OptionItem[];
}
/** Excel 导入结果 */
/**
* Excel 导入结果
*/
export interface ExcelResult {
/** 响应码 */
/** 业务状态码 */
code: string;
/** 无效数据数量 */
invalidCount: number;

View File

@@ -1,5 +1,6 @@
import request from "@/utils/request";
import type { ConfigQueryParams, ConfigForm, ConfigItem } from "./types";
import type { PageResult } from "@/api/common";
const CONFIG_BASE_URL = "/api/v1/configs";

View File

@@ -8,6 +8,7 @@ import type {
DictItemForm,
DictItemOption,
} from "./types";
import type { OptionItem, PageResult } from "@/api/common";
const DICT_BASE_URL = "/api/v1/dicts";

View File

@@ -6,6 +6,7 @@ import type {
VisitTrendDetail,
VisitOverviewDetail,
} from "./types";
import type { PageResult } from "@/api/common";
const LOG_BASE_URL = "/api/v1/logs";

View File

@@ -1,5 +1,6 @@
import request from "@/utils/request";
import type { NoticeQueryParams, NoticeForm, NoticeItem, NoticeDetail } from "./types";
import type { PageResult } from "@/api/common";
const NOTICE_BASE_URL = "/api/v1/notices";

View File

@@ -1,6 +1,6 @@
import request from "@/utils/request";
import type { RoleQueryParams, RoleItem, RoleForm } from "./types";
import type { OptionItem } from "@/api/common";
import type { OptionItem, PageResult } from "@/api/common";
const ROLE_BASE_URL = "/api/v1/roles";

View File

@@ -1,6 +1,6 @@
import request from "@/utils/request";
import type { TenantPlanForm, TenantPlanItem, TenantPlanQueryParams } from "./types";
import type { OptionItem } from "@/api/common";
import type { OptionItem, PageResult } from "@/api/common";
const TENANT_PLAN_BASE_URL = "/api/v1/tenant-plans";

View File

@@ -7,6 +7,7 @@ import type {
TenantQueryParams,
TenantItem,
} from "./types";
import type { PageResult } from "@/api/common";
const TENANT_BASE_URL = "/api/v1/tenants";

View File

@@ -11,7 +11,7 @@ import type {
MobileUpdateForm,
EmailUpdateForm,
} from "./types";
import type { OptionItem, ExcelResult } from "@/api/common";
import type { ExcelResult, OptionItem, PageResult } from "@/api/common";
const USER_BASE_URL = "/api/v1/users";

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M16.0503 12.0498L21 16.9996L16.0503 21.9493L14.636 20.5351L17.172 17.9988L4 17.9996V15.9996L17.172 15.9988L14.636 13.464L16.0503 12.0498ZM7.94975 2.0498L9.36396 3.46402L6.828 5.9988L20 5.99955V7.99955L6.828 7.9988L9.36396 10.5351L7.94975 11.9493L3 6.99955L7.94975 2.0498Z"></path></svg>

After

Width:  |  Height:  |  Size: 376 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M5 18H19V11.0314C19 7.14806 15.866 4 12 4C8.13401 4 5 7.14806 5 11.0314V18ZM12 2C16.9706 2 21 6.04348 21 11.0314V20H3V11.0314C3 6.04348 7.02944 2 12 2ZM9.5 21H14.5C14.5 22.3807 13.3807 23.5 12 23.5C10.6193 23.5 9.5 22.3807 9.5 21Z"></path></svg>

Before

Width:  |  Height:  |  Size: 334 B

View File

@@ -0,0 +1,6 @@
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5 12h4.2c2 0 3.6-1.6 3.6-3.6V6" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9.2 12h3.6c2 0 3.6 1.6 3.6 3.6V18" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M16.4 6H19v2.6M16.4 18H19v-2.6" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="5" cy="12" r="1.5" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 539 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M2 22C2 17.5817 5.58172 14 10 14C14.4183 14 18 17.5817 18 22H16C16 18.6863 13.3137 16 10 16C6.68629 16 4 18.6863 4 22H2ZM10 13C6.685 13 4 10.315 4 7C4 3.685 6.685 1 10 1C13.315 1 16 3.685 16 7C16 10.315 13.315 13 10 13ZM10 11C12.21 11 14 9.21 14 7C14 4.79 12.21 3 10 3C7.79 3 6 4.79 6 7C6 9.21 7.79 11 10 11ZM18.2837 14.7028C21.0644 15.9561 23 18.752 23 22H21C21 19.564 19.5483 17.4671 17.4628 16.5271L18.2837 14.7028ZM17.5962 3.41321C19.5944 4.23703 21 6.20361 21 8.5C21 11.3702 18.8042 13.7252 16 13.9776V11.9646C17.6967 11.7222 19 10.264 19 8.5C19 7.11935 18.2016 5.92603 17.041 5.35635L17.5962 3.41321Z"></path></svg>

After

Width:  |  Height:  |  Size: 711 B

View File

@@ -1 +0,0 @@
<svg t="1719845783644" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="277659" width="200" height="200"><path d="M392.297931 841.021793a130.01269 128.635586 90 1 0 257.271172 0 130.01269 128.635586 90 1 0-257.271172 0Z" fill="#FFD08D" p-id="277660"></path><path d="M456.174345 52.965517m50.458483 0l-0.035311 0q50.458483 0 50.458483 50.458483l0 59.568552q0 50.458483-50.458483 50.458482l0.035311 0q-50.458483 0-50.458483-50.458482l0-59.568552q0-50.458483 50.458483-50.458483Z" fill="#FFD293" p-id="277661"></path><path d="M520.933517 1006.344828c-90.641655 0-163.945931-74.081103-163.945931-165.323035 0-91.206621 73.304276-165.323034 163.945931-165.323034 90.606345 0 163.945931 74.116414 163.945931 165.323034 0 91.241931-73.339586 165.323034-163.945931 165.323035z m0-70.62069c51.447172 0 93.325241-42.301793 93.325242-94.702345 0-52.365241-41.878069-94.702345-93.325242-94.702345-51.482483 0-93.325241 42.337103-93.325241 94.702345 0 52.400552 41.842759 94.702345 93.325241 94.702345z" fill="#FCA100" p-id="277662"></path><path d="M506.632828 88.275862a15.148138 15.148138 0 0 0-15.148138 15.148138v59.603862a15.148138 15.148138 0 0 0 30.296276 0V103.424a15.148138 15.148138 0 0 0-15.183449-15.148138z m85.768827 15.148138v59.603862a85.768828 85.768828 0 1 1-171.537655 0V103.424a85.768828 85.768828 0 1 1 171.537655 0z" fill="#FB9C00" p-id="277663"></path><path d="M177.893517 494.344828c0-183.472552 147.173517-332.270345 328.739311-332.270345 181.530483 0 346.394483 148.797793 346.394482 332.270345v260.025379l100.034207 129.977379H77.85931l100.034207-129.977379V494.344828z" fill="#FFA300" p-id="277664"></path></svg>

Before

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -1 +0,0 @@
<svg t="1719844718926" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="266272" width="200" height="200"><path d="M138.0173018 383.04044608h747.9653964c7.09277447 0 12.89595271-5.80317818 12.89595812-12.89595273v-64.47977419c0-28.37110348-23.21271811-51.58382158-51.58382153-51.58382161H497.81445089c-24.50231446 0-47.71503251-11.60636172-62.54538509-31.59508902l-24.50231442-33.52948343c-14.83034722-19.98873261-38.04306533-31.59508889-62.54537971-31.59509427h-145.72429566c-42.55665253 0-77.37573228 34.81907982-77.37573233 77.37573236v135.40753016c0 7.09277447 5.80317818 12.89595271 12.89595812 12.89595273zM885.9826982 415.2803386h-747.9653964c-7.09277447 0-12.89595271 5.80317818-12.89595812 12.89595272V787.97344057c0 42.55665253 34.81907982 77.37573228 77.37573233 77.37573223l619.00584798 1.28959637c42.55665253 0 77.37573228-34.81907982 77.37573233-77.37573236v-361.08674549c0-7.09277447-5.80317818-12.89595271-12.89595812-12.89595272z m-606.1098899 310.14771686c-1.28959636 1.93439451-3.22399088 3.86878903-5.8031835 5.80318347-32.88468534 20.63353074-67.05896691-13.54075089-46.4254362-46.42544153 1.28959636-1.93439451 3.22399088-3.86878903 5.80317822-5.80317821 32.88468534-20.63353074 67.70376514 14.18554911 46.42544148 46.42543627z m0-137.98671761c-1.28959636 1.93439451-3.22399088 3.86878903-5.8031835 5.80317817-32.88468534 20.63353074-67.05896691-13.54075089-46.4254362-46.4254362 1.28959636-1.93439451 3.22399088-3.86878903 5.80317822-5.80317818 32.88468534-21.27832364 67.70376514 13.54075089 46.42544148 46.42543621z m361.08674562 149.59307926h-264.36708411c-6.44797635 0-12.25115989-2.57919268-16.11994359-7.73757266-21.27832364-27.08150713-2.57919268-56.74220161 23.212718-56.74220164h264.36708417c6.44797635 0 12.25115989 2.57919268 16.11994364 7.73757283 21.27832364 27.08150713 2.57919268 56.74220161-23.21271811 56.74220147z m128.95954853-137.98671756h-393.32663264c-6.44797635 0-12.25115989-2.57919268-16.11994359-7.73757269-21.27832364-27.08150713-2.57919268-56.74220161 23.212718-56.74220692h393.32663275c6.44797635 0 12.25115989 2.57919268 16.11994354 7.7375727 21.27832364 27.08150713 2.57919268 56.74220161-23.21271806 56.74220691z" fill="#08D19F" p-id="266273"></path></svg>

Before

Width:  |  Height:  |  Size: 2.2 KiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M19.9139 14.529C19.7336 13.955 19.4877 13.2856 19.2385 12.643L18.3288 10.3969C18.3295 10.371 18.3408 9.92858 18.3408 9.70053C18.3408 5.8599 16.5082 2.00037 12.0009 2C7.49403 2.00037 5.66113 5.8599 5.66113 9.70053C5.66113 9.92858 5.67237 10.371 5.67312 10.3969L4.76379 12.643C4.51453 13.2856 4.26827 13.955 4.08798 14.529C3.2285 17.2657 3.507 18.3982 3.71915 18.4238C4.17419 18.4779 5.49021 16.3635 5.49021 16.3635C5.49021 17.5879 6.12741 19.1858 7.5064 20.3398C6.99064 20.4971 6.35868 20.7388 5.95237 21.0355C5.58729 21.3025 5.63302 21.5743 5.69861 21.6841C5.9876 22.1661 10.6542 21.9918 12.0017 21.8417C13.3488 21.9918 18.0158 22.1661 18.3044 21.6841C18.3704 21.5743 18.4157 21.3025 18.0507 21.0355C17.6443 20.7388 17.012 20.4971 16.4959 20.3395C17.8745 19.1858 18.5117 17.5879 18.5117 16.3635C18.5117 16.3635 19.8281 18.4779 20.2831 18.4238C20.4949 18.3982 20.7734 17.2657 19.9139 14.529Z"></path></svg>

Before

Width:  |  Height:  |  Size: 995 B

View File

@@ -0,0 +1,4 @@
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4 4h6v6H4V4Zm10 0h6v6h-6V4ZM4 14h6v6H4v-6Z" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/>
<path d="M14 14h2.5v2.5H14V14Zm4 0h2v2m-6 2h2v2m2-2h2v2h-2v-2Z" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 359 B

View File

@@ -0,0 +1,4 @@
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 3.5 5 6.2v5.3c0 4.4 2.8 7.6 7 9 4.2-1.4 7-4.6 7-9V6.2l-7-2.7Z" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/>
<path d="m8.8 12 2.2 2.2 4.5-4.8" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 351 B

View File

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@@ -1 +0,0 @@
<svg t="1719844784164" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="269353" width="200" height="200"><path d="M920.78 322.56L700.878 102.656c-36.199-36.198-94.925-36.198-131.123 0s-36.199 94.925 0 131.123l23.5 23.501-86.272 86.323c-123.033-75.11-285.798-59.494-392.192 46.9l-37.376 37.375L293.94 644.403l-89.088 89.088c-23.5 23.501-23.5 61.645 0 85.146s61.645 23.5 85.146 0l89.088-89.088L595.61 946.074l37.376-37.376c106.393-106.394 122.01-269.21 46.899-392.192l86.323-86.324 23.5 23.501c36.2 36.199 94.926 36.199 131.124 0s36.198-94.925-0.051-131.123z m-454.758 132.3a41.206 41.206 0 0 1-34.304 18.28 41.057 41.057 0 0 1-22.988-7.015c-55.91-37.581-122.266-8.09-122.932-7.783-20.633 9.728-45.21 0.87-54.886-19.763a41.196 41.196 0 0 1 19.763-54.886c4.403-2.048 108.851-50.023 204.083 13.926 18.893 12.698 23.962 38.349 11.264 57.242z" fill="#FF5C64" p-id="269354"></path><path d="M569.754 102.656c-36.199 36.198-36.199 94.925 0 131.123l23.5 23.501-86.272 86.323c-123.033-75.11-285.798-59.494-392.192 46.9l-37.376 37.375L293.94 644.403l-89.088 89.088c-23.5 23.501-23.5 61.645 0 85.146s61.645 23.5 85.146 0l89.088-89.088 105.83 105.83c99.226-42.445 182.119-115.558 236.851-207.462a319.145 319.145 0 0 0-41.881-111.411l86.323-86.324 20.173 20.173c4.3-26.624 6.656-53.862 6.656-81.664 0-82.073-19.61-159.539-54.221-228.147l-37.888-37.888c-36.25-36.198-94.976-36.198-131.174 0zM466.022 454.861a41.206 41.206 0 0 1-34.304 18.278 41.057 41.057 0 0 1-22.988-7.014c-55.91-37.581-122.266-8.09-122.932-7.783-20.633 9.728-45.21 0.87-54.886-19.763a41.196 41.196 0 0 1 19.763-54.886c4.403-2.048 108.851-50.023 204.083 13.926 18.893 12.698 23.962 38.349 11.264 57.242z" fill="#FF5C64" p-id="269355"></path><path d="M253.85 684.442c228.812-49.152 400.332-252.468 400.332-495.924 0-38.758-4.505-76.441-12.697-112.69-25.754-1.69-52.07 7.167-71.731 26.88-36.199 36.198-36.199 94.924 0 131.122l23.5 23.501-86.272 86.272c-123.033-75.11-285.798-59.494-392.192 46.9l-37.376 37.375L293.94 644.403l-40.09 40.039z m32-226.15c-20.634 9.727-45.21 0.87-54.887-19.764a41.196 41.196 0 0 1 19.763-54.886c4.404-2.048 108.852-50.023 204.084 13.926 18.892 12.698 23.961 38.349 11.264 57.242a41.206 41.206 0 0 1-34.304 18.278 41.057 41.057 0 0 1-22.99-7.014c-55.96-37.53-122.316-8.09-122.93-7.783z" fill="#FF5C64" p-id="269356"></path><path d="M230.912 438.58a41.196 41.196 0 0 1 19.763-54.887c3.226-1.536 60.058-27.546 127.642-16.333 18.022-18.074 34.662-37.53 49.817-58.112-107.827-30.618-228.505-3.584-313.395 81.306L77.363 427.93l71.629 71.628a502.748 502.748 0 0 0 103.885-40.243 41.861 41.861 0 0 1-21.965-20.736z" fill="#FF5C64" p-id="269357"></path></svg>

Before

Width:  |  Height:  |  Size: 2.6 KiB

View File

@@ -1 +0,0 @@
<svg t="1719843414729" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="175406" width="200" height="200"><path d="M512 1024C229.236364 1024 0 794.763636 0 512S229.236364 0 512 0s512 229.236364 512 512-229.236364 512-512 512z m56.599273-542.72a153.134545 153.134545 0 0 0 97.652363-142.149818C666.251636 254.789818 597.038545 186.181818 512 186.181818S357.794909 254.789818 357.794909 339.130182a153.134545 153.134545 0 0 0 97.605818 142.149818C328.471273 507.345455 232.727273 619.054545 232.727273 752.500364c0 32.488727 22.993455 43.240727 41.425454 51.851636l1.070546 0.465455c74.333091 26.065455 181.992727 33.000727 233.797818 33.000727 56.552727 0 167.191273-9.122909 240.686545-34.304 27.601455-10.472727 41.565091-27.648 41.565091-51.013818 0-133.492364-95.744-245.201455-222.673454-271.220364z" fill="#17E3C7" p-id="175407"></path></svg>

Before

Width:  |  Height:  |  Size: 890 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M18.5753 13.7114C19.0742 13.7114 19.4733 13.2873 19.4733 12.8134C19.4733 12.3145 19.0742 11.9155 18.5753 11.9155C18.0765 11.9155 17.6774 12.3145 17.6774 12.8134C17.6774 13.3123 18.0765 13.7114 18.5753 13.7114ZM14.1497 13.7114C14.6485 13.7114 15.0476 13.2873 15.0476 12.8134C15.0476 12.3145 14.6485 11.9155 14.1497 11.9155C13.6508 11.9155 13.2517 12.3145 13.2517 12.8134C13.2517 13.3123 13.6508 13.7114 14.1497 13.7114ZM20.717 18.7516C20.5942 18.8253 20.5205 18.9482 20.5451 19.1202C20.5451 19.1693 20.5451 19.2185 20.5696 19.2676C20.6679 19.6854 20.8643 20.349 20.8643 20.3736C20.8643 20.4473 20.8889 20.4964 20.8889 20.5456C20.8889 20.6685 20.7907 20.7668 20.6679 20.7668C20.6187 20.7668 20.5942 20.7422 20.5451 20.7176L19.0961 19.882C18.9978 19.8329 18.875 19.7837 18.7522 19.7837C18.6786 19.7837 18.6049 19.7837 18.5558 19.8083C17.8681 20.0049 17.1559 20.1032 16.3946 20.1032C12.7352 20.1032 9.78815 17.6456 9.78815 14.5983C9.78815 11.5509 12.7352 9.09329 16.3946 9.09329C20.0539 9.09329 23.001 11.5509 23.001 14.5983C23.001 16.2448 22.1168 17.7439 20.717 18.7516ZM16.6737 8.09757C16.581 8.09473 16.488 8.09329 16.3946 8.09329C12.2199 8.09329 8.78815 10.9536 8.78815 14.5983C8.78815 15.1519 8.86733 15.6874 9.01626 16.1975H8.92711C8.04096 16.1975 7.15481 16.0503 6.3425 15.8296C6.26866 15.805 6.19481 15.805 6.12097 15.805C5.97327 15.805 5.82558 15.8541 5.7025 15.9277L3.95482 16.9334C3.90559 16.958 3.85635 16.9825 3.80712 16.9825C3.65943 16.9825 3.53636 16.8599 3.53636 16.7127C3.53636 16.6391 3.56097 16.59 3.58559 16.5164C3.6102 16.4919 3.83174 15.6824 3.95482 15.1918C3.95482 15.1427 3.97943 15.0691 3.97943 15.0201C3.97943 14.8238 3.88097 14.6766 3.75789 14.5785C2.05944 13.3765 1.00098 11.5858 1.00098 9.59876C1.00098 5.94369 4.5702 3 8.95173 3C12.7157 3 15.8802 5.16856 16.6737 8.09757ZM11.5199 8.51604C12.0927 8.51604 12.5462 8.03871 12.5462 7.4898C12.5462 6.91701 12.0927 6.46356 11.5199 6.46356C10.9471 6.46356 10.4937 6.91701 10.4937 7.4898C10.4937 8.06258 10.9471 8.51604 11.5199 8.51604ZM6.26045 8.51604C6.83324 8.51604 7.28669 8.03871 7.28669 7.4898C7.28669 6.91701 6.83324 6.46356 6.26045 6.46356C5.68767 6.46356 5.23421 6.91701 5.23421 7.4898C5.23421 8.06258 5.68767 8.51604 6.26045 8.51604Z"></path></svg>

Before

Width:  |  Height:  |  Size: 2.3 KiB

View File

@@ -1,23 +1,35 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 1400 800">
<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 1400 900" preserveAspectRatio="none">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#0a0f1a" />
<stop offset="50%" stop-color="#0d1424" />
<stop offset="100%" stop-color="#111a2e" />
<linearGradient id="surface" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#101728"/>
<stop offset="58%" stop-color="#0d1424"/>
<stop offset="100%" stop-color="#0b1020"/>
</linearGradient>
<linearGradient id="rightWash" x1="72%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#0b1020" stop-opacity="0"/>
<stop offset="100%" stop-color="#7c6dff" stop-opacity=".18"/>
</linearGradient>
<linearGradient id="bottomWash" x1="0%" y1="100%" x2="58%" y2="46%">
<stop offset="0%" stop-color="#2563eb" stop-opacity=".16"/>
<stop offset="52%" stop-color="#2563eb" stop-opacity=".05"/>
<stop offset="100%" stop-color="#0b1020" stop-opacity="0"/>
</linearGradient>
<pattern id="grid" width="88" height="88" patternUnits="userSpaceOnUse">
<path d="M88 0H0v88" fill="none" stroke="#94a3b8" stroke-opacity=".1" stroke-width="1"/>
<path d="M44 0v88M0 44h88" fill="none" stroke="#94a3b8" stroke-opacity=".05" stroke-width="1"/>
</pattern>
<linearGradient id="gridFade" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="#ffffff" stop-opacity=".76"/>
<stop offset="74%" stop-color="#ffffff" stop-opacity=".24"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0"/>
</linearGradient>
<radialGradient id="glow1" cx="15%" cy="18%" r="55%">
<stop offset="0%" stop-color="rgba(22,119,255,0.15)" />
<stop offset="45%" stop-color="rgba(22,119,255,0.05)" />
<stop offset="100%" stop-color="rgba(22,119,255,0)" />
</radialGradient>
<radialGradient id="glow2" cx="85%" cy="72%" r="50%">
<stop offset="0%" stop-color="rgba(99,102,241,0.1)" />
<stop offset="45%" stop-color="rgba(99,102,241,0.03)" />
<stop offset="100%" stop-color="rgba(99,102,241,0)" />
</radialGradient>
</defs>
<rect width="100%" height="100%" fill="url(#bg)" />
<rect width="100%" height="100%" fill="url(#glow1)" />
<rect width="100%" height="100%" fill="url(#glow2)" />
</svg>
<rect width="1400" height="900" fill="url(#surface)"/>
<rect width="1400" height="900" fill="url(#rightWash)"/>
<rect width="1400" height="900" fill="url(#bottomWash)"/>
<mask id="gridMask">
<rect width="1400" height="900" fill="url(#gridFade)"/>
</mask>
<rect width="1400" height="900" fill="url(#grid)" mask="url(#gridMask)"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -1,23 +1,35 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 1400 800">
<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 1400 900" preserveAspectRatio="none">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#f0f4ff" />
<stop offset="50%" stop-color="#f5f8ff" />
<stop offset="100%" stop-color="#edf2fe" />
<linearGradient id="surface" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#fbfdff"/>
<stop offset="58%" stop-color="#f6fbff"/>
<stop offset="100%" stop-color="#eef3ff"/>
</linearGradient>
<linearGradient id="rightWash" x1="72%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0"/>
<stop offset="100%" stop-color="#8b7cff" stop-opacity=".13"/>
</linearGradient>
<linearGradient id="bottomWash" x1="0%" y1="100%" x2="58%" y2="46%">
<stop offset="0%" stop-color="#56c8ff" stop-opacity=".18"/>
<stop offset="52%" stop-color="#56c8ff" stop-opacity=".06"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0"/>
</linearGradient>
<pattern id="grid" width="88" height="88" patternUnits="userSpaceOnUse">
<path d="M88 0H0v88" fill="none" stroke="#5d87ff" stroke-opacity=".06" stroke-width="1"/>
<path d="M44 0v88M0 44h88" fill="none" stroke="#5d87ff" stroke-opacity=".032" stroke-width="1"/>
</pattern>
<linearGradient id="gridFade" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="#ffffff" stop-opacity=".72"/>
<stop offset="74%" stop-color="#ffffff" stop-opacity=".22"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0"/>
</linearGradient>
<radialGradient id="glow1" cx="15%" cy="20%" r="55%">
<stop offset="0%" stop-color="rgba(22,119,255,0.12)" />
<stop offset="50%" stop-color="rgba(22,119,255,0.04)" />
<stop offset="100%" stop-color="rgba(22,119,255,0)" />
</radialGradient>
<radialGradient id="glow2" cx="85%" cy="75%" r="50%">
<stop offset="0%" stop-color="rgba(99,102,241,0.08)" />
<stop offset="50%" stop-color="rgba(99,102,241,0.02)" />
<stop offset="100%" stop-color="rgba(99,102,241,0)" />
</radialGradient>
</defs>
<rect width="100%" height="100%" fill="url(#bg)" />
<rect width="100%" height="100%" fill="url(#glow1)" />
<rect width="100%" height="100%" fill="url(#glow2)" />
</svg>
<rect width="1400" height="900" fill="url(#surface)"/>
<rect width="1400" height="900" fill="url(#rightWash)"/>
<rect width="1400" height="900" fill="url(#bottomWash)"/>
<mask id="gridMask">
<rect width="1400" height="900" fill="url(#gridFade)"/>
</mask>
<rect width="1400" height="900" fill="url(#grid)" mask="url(#gridMask)"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -141,28 +141,38 @@ const handleInputKeydown: (evt: KeyboardEvent | Event) => void = (evt) => {
<style scoped>
.command-palette-trigger {
display: flex;
gap: 10px;
gap: 8px;
align-items: center;
justify-content: space-between;
height: 32px;
padding: 0 12px;
width: 150px;
height: 28px;
padding: 0 8px 0 10px;
user-select: none;
background: var(--el-fill-color-light);
border: 1px solid var(--el-border-color-lighter);
border-radius: 999px;
background: var(--el-fill-color-extra-light);
border: 1px solid var(--el-border-color-light);
border-radius: 6px;
transition:
background-color 0.16s,
border-color 0.16s;
}
.command-palette-trigger__left {
display: flex;
gap: 8px;
gap: 6px;
align-items: center;
min-width: 0;
}
.command-palette-trigger__left :deep([class^="i-svg:"]) {
flex-shrink: 0;
width: 14px;
height: 14px;
color: var(--el-text-color-secondary) !important;
}
.command-palette-trigger__text {
overflow: hidden;
text-overflow: ellipsis;
font-size: 12px;
color: var(--el-text-color-secondary);
white-space: nowrap;
@@ -172,14 +182,15 @@ const handleInputKeydown: (evt: KeyboardEvent | Event) => void = (evt) => {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 2px 8px;
font-size: 12px;
height: 18px;
padding: 0 6px;
font-size: 11px;
line-height: 1;
color: var(--el-text-color-secondary);
color: var(--el-text-color-placeholder);
white-space: nowrap;
background: var(--el-bg-color-overlay);
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
border: 1px solid var(--el-border-color);
border-radius: 4px;
}
.command-palette-trigger:focus-visible {
@@ -188,6 +199,7 @@ const handleInputKeydown: (evt: KeyboardEvent | Event) => void = (evt) => {
}
.command-palette-trigger:hover {
background: var(--el-fill-color-light);
border-color: var(--el-border-color);
}

View File

@@ -1,6 +1,12 @@
<template>
<div class="hamburger-wrapper" @click="toggleClick">
<div :class="['i-svg:collapse', { hamburger: true, 'is-active': isActive }, hamburgerClass]" />
<div
:class="[
'i-svg:arrow-left-right',
{ hamburger: true, 'is-active': isActive },
hamburgerClass,
]"
/>
</div>
</template>

View File

@@ -1,51 +1,68 @@
<template>
<el-dropdown class="notice__dropdown" trigger="click">
<el-dropdown class="notice__dropdown" trigger="click" @visible-change="handleVisibleChange">
<div class="notice__trigger">
<el-badge v-if="unreadTotal > 0" :value="unreadTotal" :max="99">
<div class="i-svg:bell" />
</el-badge>
<div v-else class="i-svg:bell" />
<div class="notice__icon">
<el-icon class="notice__bell" :size="16">
<Bell />
</el-icon>
<span v-if="unreadTotal > 0" class="notice__pulse" />
</div>
</div>
<template #dropdown>
<div class="p-5">
<template v-if="list.length > 0">
<div v-for="item in list" :key="item.id" class="w-500px py-3">
<div class="flex-y-center">
<DictTag v-model="item.type" code="notice_type" size="small" />
<el-text
size="small"
class="w-200px cursor-pointer !ml-2 !flex-1"
truncated
@click="read(item.id)"
>
{{ item.title }}
</el-text>
<div class="notice__panel">
<div class="notice__tabs">
<button
type="button"
:class="['notice__tab', { 'is-active': activeStatus === 0 }]"
@click.stop="switchStatus(0)"
>
<span>未读</span>
<span v-if="unreadTotal > 0" class="notice__count">
{{ unreadTotal > 99 ? "99+" : unreadTotal }}
</span>
</button>
<button
type="button"
:class="['notice__tab', { 'is-active': activeStatus === 1 }]"
@click.stop="switchStatus(1)"
>
已读
</button>
</div>
<div class="text-xs text-gray">
{{ item.publishTime }}
</div>
<div v-if="list.length > 0" class="notice__list">
<div
v-for="item in list"
:key="item.id"
:class="['notice__item', { 'is-read': item.isRead === 1 }]"
>
<DictTag v-model="item.type" code="notice_type" size="small" />
<el-text size="small" class="notice__title" truncated @click="read(item.id)">
{{ item.title }}
</el-text>
<div class="notice__time">
{{ item.publishTime }}
</div>
</div>
<el-divider />
<div class="flex-x-between">
<el-link type="primary" underline="never" @click="goMore">
<span class="text-xs">查看更多</span>
<el-icon class="text-xs">
<ArrowRight />
</el-icon>
</el-link>
<el-link v-if="list.length > 0" type="primary" underline="never" @click="readAll">
<span class="text-xs">全部已读</span>
</el-link>
</div>
</template>
<template v-else>
<div class="flex-center h-150px w-350px">
<el-empty :image-size="50" description="暂无消息" />
</div>
</template>
</div>
<div v-else class="notice__empty">
<el-empty :image-size="54" :description="emptyText" />
</div>
<el-divider class="notice__divider" />
<div class="notice__footer">
<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="unreadTotal > 0" type="primary" underline="never" @click="readAll">
<span class="text-xs">全部已读</span>
</el-link>
</div>
</div>
</template>
</el-dropdown>
@@ -78,7 +95,23 @@
<script setup lang="ts">
import { useNotice } from "./useNotice";
const { list, unreadTotal, detail, dialogVisible, read, readAll, goMore } = useNotice();
const {
list,
unreadTotal,
activeStatus,
emptyText,
detail,
dialogVisible,
switchStatus,
refresh,
read,
readAll,
goMore,
} = useNotice();
function handleVisibleChange(visible: boolean) {
if (visible) refresh();
}
</script>
<style lang="scss" scoped>
@@ -98,5 +131,155 @@ const { list, unreadTotal, detail, dialogVisible, read, readAll, goMore } = useN
width: 100%;
height: 100%;
}
&__icon {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 6px;
}
&__bell {
color: var(--el-text-color-regular);
}
&__pulse {
position: absolute;
top: 4px;
right: 5px;
width: 6px;
height: 6px;
background: var(--el-color-danger);
border: 1px solid var(--el-bg-color);
border-radius: 50%;
&::after {
position: absolute;
inset: -1px;
content: "";
background: var(--el-color-danger);
border-radius: inherit;
animation: notice-pulse 1.8s ease-out infinite;
}
}
&__panel {
width: 390px;
padding: 12px;
}
&__tabs {
display: flex;
gap: 4px;
padding: 3px;
margin-bottom: 8px;
background: var(--el-fill-color-light);
border-radius: 6px;
}
&__tab {
display: inline-flex;
flex: 1;
gap: 6px;
align-items: center;
justify-content: center;
height: 28px;
padding: 0 10px;
font-size: 12px;
color: var(--el-text-color-secondary);
cursor: pointer;
background: transparent;
border: 0;
border-radius: 5px;
&.is-active {
font-weight: 500;
color: var(--el-color-primary);
background: var(--el-bg-color-overlay);
box-shadow: 0 1px 2px rgb(15 23 42 / 5%);
}
}
&__count {
min-width: 18px;
height: 16px;
padding: 0 5px;
font-size: 10px;
line-height: 16px;
color: #fff;
text-align: center;
background: var(--el-color-danger);
border-radius: 999px;
}
&__list {
max-height: 280px;
overflow-y: auto;
}
&__item {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 8px;
align-items: center;
padding: 10px 2px;
& + & {
border-top: 1px solid var(--el-border-color-lighter);
}
&.is-read {
opacity: 0.72;
}
}
&__title {
min-width: 0;
cursor: pointer;
}
&__time {
flex-shrink: 0;
font-size: 12px;
color: var(--el-text-color-secondary);
white-space: nowrap;
}
&__empty {
display: flex;
align-items: center;
justify-content: center;
height: 150px;
}
&__divider {
margin: 10px 0;
}
&__footer {
display: flex;
align-items: center;
justify-content: space-between;
}
}
@keyframes notice-pulse {
0% {
opacity: 0.45;
transform: scale(1);
}
70% {
opacity: 0;
transform: scale(2.8);
}
100% {
opacity: 0;
transform: scale(2.8);
}
}
</style>

View File

@@ -1,7 +1,7 @@
/**
* 通知中心逻辑
*/
import { ref, onMounted, onBeforeUnmount } from "vue";
import { computed, ref, onMounted, onBeforeUnmount } from "vue";
import type { NoticeItem, NoticeDetail, NoticeQueryParams } from "@/api/system/notice";
import NoticeAPI from "@/api/system/notice";
import { useSse } from "@/composables";
@@ -10,6 +10,7 @@ import router from "@/router";
const PAGE_SIZE = 5;
const NOTICE_EVENT = "notice";
type NoticeStatus = 0 | 1;
export function useNotice() {
const { on } = useSse();
@@ -17,8 +18,10 @@ export function useNotice() {
// 状态
const list = ref<NoticeItem[]>([]);
const unreadTotal = ref(0);
const activeStatus = ref<NoticeStatus>(0);
const detail = ref<NoticeDetail | null>(null);
const dialogVisible = ref(false);
const emptyText = computed(() => (activeStatus.value === 0 ? "暂无未读消息" : "暂无已读消息"));
let unsubscribe: (() => void) | null = null;
@@ -30,29 +33,64 @@ export function useNotice() {
const query: NoticeQueryParams = {
pageNum: 1,
pageSize: PAGE_SIZE,
isRead: 0,
isRead: activeStatus.value,
...params,
};
const page = await NoticeAPI.getMyNoticePage(query);
list.value = page.list || [];
if (query.isRead === 0) {
unreadTotal.value = page.total ?? 0;
}
}
async function fetchUnreadTotal() {
const page = await NoticeAPI.getMyNoticePage({
pageNum: 1,
pageSize: 1,
isRead: 0,
});
unreadTotal.value = page.total ?? 0;
}
async function switchStatus(status: NoticeStatus) {
if (activeStatus.value === status) return;
activeStatus.value = status;
await fetchList();
}
async function refresh() {
await Promise.all([
fetchList(),
activeStatus.value === 0 ? Promise.resolve() : fetchUnreadTotal(),
]);
}
async function read(id: string) {
const item = list.value.find((notice: NoticeItem) => notice.id === id);
const wasUnread = item?.isRead !== 1;
detail.value = await NoticeAPI.getDetail(id);
dialogVisible.value = true;
const idx = list.value.findIndex((item: NoticeItem) => item.id === id);
if (idx >= 0) list.value.splice(idx, 1);
if (unreadTotal.value > 0) unreadTotal.value -= 1;
if (wasUnread && unreadTotal.value > 0) unreadTotal.value -= 1;
await fetchList();
await refresh();
}
async function readAll() {
if (unreadTotal.value <= 0) return;
await NoticeAPI.readAll();
list.value = [];
unreadTotal.value = 0;
if (activeStatus.value === 0) {
list.value = [];
} else {
await fetchList();
}
ElMessage.success("已全部标记为已读");
}
@@ -71,15 +109,16 @@ export function useNotice() {
try {
if (!data.id) return;
if (list.value.some((item: NoticeItem) => item.id === data.id)) return;
unreadTotal.value += 1;
if (activeStatus.value !== 0) return;
if (list.value.some((item: NoticeItem) => item.id === data.id)) return;
list.value.unshift({
id: data.id,
title: data.title,
type: data.type,
publishTime: data.publishTime,
isRead: 0,
} as NoticeItem);
if (list.value.length > PAGE_SIZE) {
@@ -103,8 +142,9 @@ export function useNotice() {
const idx = list.value.findIndex((item: NoticeItem) => item.id === data.id);
if (idx >= 0) {
const wasUnread = list.value[idx].isRead !== 1;
list.value.splice(idx, 1);
if (unreadTotal.value > 0) unreadTotal.value -= 1;
if (wasUnread && unreadTotal.value > 0) unreadTotal.value -= 1;
}
} catch (e) {
console.error("处理撤回通知失败", e);
@@ -117,7 +157,7 @@ export function useNotice() {
// ============================================
onMounted(() => {
fetchList();
refresh();
setupSubscription();
});
@@ -131,9 +171,13 @@ export function useNotice() {
return {
list,
unreadTotal,
activeStatus,
emptyText,
detail,
dialogVisible,
fetchList,
switchStatus,
refresh,
read,
readAll,
goMore,

View File

@@ -1,6 +1,6 @@
<template>
<el-scrollbar>
<div :class="{ hidden: hidden }" class="p-3 flex justify-end">
<div :class="{ 'is-hidden': hidden }" class="pagination-container">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
@@ -79,3 +79,16 @@ function handleCurrentChange(val: number) {
emit("pagination", { page: val, limit: pageSize.value });
}
</script>
<style lang="scss" scoped>
.pagination-container {
display: flex;
justify-content: flex-end;
padding-top: 14px;
overflow: visible;
}
.pagination-container.is-hidden {
display: none;
}
</style>

View File

@@ -5,7 +5,5 @@ export type { DictMessage, DictChangeMessage, DictChangeCallback } from "./sse";
// 表格相关
export { useTableSelection } from "./useTableSelection";
// 最近访问菜单
export { useRecentMenus } from "./useRecentMenus";
export type { RecentMenuItem } from "./useRecentMenus";
export { usePageTable } from "./usePageTable";
export type { UsePageTableOptions, UsePageTableReturn } from "./usePageTable";

View File

@@ -0,0 +1,114 @@
import { reactive, ref, type Ref } from "vue";
import type { BaseQueryParams, PageResult } from "@/api/common";
/**
* 分页列表页通用状态管理
*
* 只管理请求、分页数据和查询参数,不处理勾选、弹窗、表单
*/
export function usePageTable<T, Q extends BaseQueryParams = BaseQueryParams>(
options: UsePageTableOptions<T, Q>
): UsePageTableReturn<T, Q> {
const { initialParams, request, onBeforeReset } = options;
const loading = ref(false);
const list = ref<T[]>([]) as Ref<T[]>;
const total = ref(0);
const params = reactive({ ...initialParams }) as Q;
/**
* 拉取当前查询参数对应的分页数据
*
* 只负责请求和回填,不处理弹窗、路由或消息提示
*/
async function fetchData(): Promise<void> {
loading.value = true;
try {
const data = await request(params);
list.value = data.list ?? [];
total.value = data.total ?? 0;
} finally {
loading.value = false;
}
}
/**
* 回到第一页并查询
*/
function handleQuery(): void {
params.pageNum = 1;
fetchData();
}
/**
* 恢复初始查询参数
*
* 保持响应式引用不变,不触发查询
*/
function resetParams(): void {
Object.assign(params, initialParams);
}
/**
* 恢复初始查询参数并重新查询
*/
function handleResetQuery(): void {
onBeforeReset?.();
resetParams();
fetchData();
}
return {
loading,
list,
total,
params,
fetchData,
handleQuery,
handleResetQuery,
resetParams,
};
}
export interface UsePageTableOptions<T, Q extends BaseQueryParams> {
/** 初始查询参数,同时作为重置基准 */
initialParams: Q;
/**
* 分页请求函数
*/
request: (params: Q) => Promise<PageResult<T>>;
/**
* 重置查询前的回调
*
* 常用于同步重置搜索表单字段
*/
onBeforeReset?: () => void;
}
export interface UsePageTableReturn<T, Q extends BaseQueryParams> {
/** 加载状态 */
loading: Ref<boolean>;
/** 当前页数据列表 */
list: Ref<T[]>;
/** 数据总条数 */
total: Ref<number>;
/** 当前查询参数 */
params: Q;
/**
* 拉取分页数据
*/
fetchData: () => Promise<void>;
/**
* 回到第一页并查询
*/
handleQuery: () => void;
/**
* 恢复初始参数并重新查询
*/
handleResetQuery: () => void;
/**
* 恢复初始参数但不触发查询
*/
resetParams: () => void;
}

View File

@@ -1,99 +0,0 @@
import { ref } from "vue";
/**
* 最近访问菜单项
*/
export interface RecentMenuItem {
path: string;
title: string;
icon?: string;
visitedAt: number;
}
const STORAGE_KEY = "recent_menus";
const MAX_COUNT = 8;
// 全局状态
const recentMenus = ref<RecentMenuItem[]>([]);
/**
* 从 localStorage 加载数据
*/
function loadFromStorage(): RecentMenuItem[] {
try {
const data = localStorage.getItem(STORAGE_KEY);
return data ? JSON.parse(data) : [];
} catch {
return [];
}
}
/**
* 保存到 localStorage
*/
function saveToStorage(menus: RecentMenuItem[]) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(menus));
}
// 初始化
recentMenus.value = loadFromStorage();
/**
* 最近访问菜单 composable
*/
export function useRecentMenus() {
/**
* 清空所有记录
*/
function clearRecentMenus() {
recentMenus.value = [];
localStorage.removeItem(STORAGE_KEY);
}
/**
* 格式化访问时间
*/
function formatVisitTime(timestamp: number): string {
const now = Date.now();
const diff = now - timestamp;
if (diff < 60000) return "刚刚";
if (diff < 3600000) return `${Math.floor(diff / 60000)}分钟前`;
if (diff < 86400000) return `${Math.floor(diff / 3600000)}小时前`;
if (diff < 604800000) return `${Math.floor(diff / 86400000)}天前`;
const date = new Date(timestamp);
return `${date.getMonth() + 1}-${date.getDate()}`;
}
return {
recentMenus,
clearRecentMenus,
formatVisitTime,
};
}
/**
* 添加最近访问记录(全局方法,供路由守卫调用)
*/
export function addRecentMenu(path: string, title: string, icon?: string) {
if (!path || !title) return;
// 过滤掉不需要记录的路径
const excludePaths = ["/dashboard", "/redirect", "/404", "/401", "/login", "/"];
if (excludePaths.some((p) => path === p || path.startsWith(p + "/"))) return;
// 移除已存在的相同路径
const filtered = recentMenus.value.filter((item) => item.path !== path);
// 添加到开头
const newItem: RecentMenuItem = {
path,
title,
icon,
visitedAt: Date.now(),
};
recentMenus.value = [newItem, ...filtered].slice(0, MAX_COUNT);
saveToStorage(recentMenus.value);
}

View File

@@ -1,56 +1,55 @@
import { computed, ref } from "vue";
/**
* 表格行选择 Composable
* 表格行选择 Composable。
*
* @description 提供统一的表格行选择逻辑,包括选中ID管理和清空选择
* @template T 数据项类型,必须包含 id 属性
* @returns 返回选中的ID列表、选择变化处理函数、清空选择函数
* 提供统一的表格行选择逻辑,包括选中 ID 管理和清空选择。
*
* 规则:
*
* - 不和分页状态合并。
* - 不和弹窗状态合并。
* - 不和 API 请求合并。
*
* @template T 数据项类型,必须包含可选 `id` 属性
*
* @example
* ```typescript
* const { selectedIds, handleSelectionChange, clearSelection } = useTableSelection<UserVO>();
* ```ts
* const { selectedIds, hasSelection, handleSelectionChange } = useTableSelection<RoleItem>();
* ```
*/
export function useTableSelection<T extends { id: string | number }>() {
/**
* 选中的数据项ID列表
*/
export function useTableSelection<T extends { id?: string | number }>() {
/** 选中的数据项 ID 列表。 */
const selectedIds = ref<(string | number)[]>([]);
/** 选中数量。 */
const selectedCount = computed(() => selectedIds.value.length);
/** 是否有选中项。 */
const hasSelection = computed(() => selectedIds.value.length > 0);
/**
* 表格选中项变化处理
* 表格选中项变化处理。
*
* @param selection 选中的行数据列表
*/
function handleSelectionChange(selection: T[]): void {
selectedIds.value = selection.map((item) => item.id);
selectedIds.value = selection.flatMap((item) => (item.id ? [item.id] : []));
}
/**
* 清空选择
*/
function clearSelection(): void {
selectedIds.value = [];
}
/**
* 检查指定ID是否被选中
* @param id 要检查的ID
* @returns 是否被选中
* 检查指定 ID 是否被选中。
*
* @param id 要检查的 ID
*/
function isSelected(id: string | number): boolean {
return selectedIds.value.includes(id);
}
/**
* 获取选中的数量
*/
const selectedCount = computed(() => selectedIds.value.length);
/**
* 是否有选中项
*/
const hasSelection = computed(() => selectedIds.value.length > 0);
/** 清空选中 ID。注意:不会清空表格 UI 上的勾选状态,需页面自行调用表格实例的 `clearSelection`。 */
function clearSelection(): void {
selectedIds.value = [];
}
return {
selectedIds,

View File

@@ -48,13 +48,15 @@ export const STORAGE_KEYS = {
// ===== UI 设置 =====
SHOW_TAGS_VIEW: `${APP_PREFIX}:ui:show_tags_view`,
TAGS_VIEW_STYLE: `${APP_PREFIX}:ui:tags_view_style`,
SHOW_APP_LOGO: `${APP_PREFIX}:ui:show_app_logo`,
SHOW_WATERMARK: `${APP_PREFIX}:ui:show_watermark`,
PAGE_SWITCHING_ANIMATION: `${APP_PREFIX}:ui:page_switching_animation`,
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`,
THEME_PALETTE: `${APP_PREFIX}:ui:theme_palette`,
THEME_COLORS: `${APP_PREFIX}:ui:theme_colors`,
GRAY_MODE: `${APP_PREFIX}:ui:gray_mode`,
COLOR_WEAK: `${APP_PREFIX}:ui:color_weak`,

View File

@@ -4,6 +4,7 @@
* @description
* 包含表单类型、查询类型等代码生成功能的枚举定义
*/
import type { OptionItem } from "@/api/common";
/**
* 表单类型枚举

View File

@@ -4,6 +4,7 @@
* @description
* 包含主题、布局、语言、设备等应用设置的枚举定义
*/
import type { OptionItem } from "@/api/common";
/**
* 主题模式枚举
@@ -38,6 +39,21 @@ export const enum SidebarColor {
MINIMAL_WHITE = "minimal-white",
}
/**
* 页签栏风格枚举
*/
export const enum TagsViewStyle {
/**
* 线性页签,默认现代后台风格
*/
LINE = "line",
/**
* 卡片页签,保留图标和浅色激活块
*/
CARD = "card",
}
/**
* 菜单布局枚举
*/
@@ -55,6 +71,11 @@ export const enum LayoutMode {
* 混合菜单布局
*/
MIX = "mix",
/**
* 双列菜单布局
*/
DOUBLE = "double",
}
/**

View File

@@ -67,8 +67,37 @@
"theme": "Theme",
"interface": "Interface",
"navigation": "Navigation",
"themeColor": "Theme Color",
"themePalette": "Color System",
"customColors": "Custom Colors",
"customPalette": "Custom",
"customPaletteTip": "Custom colors applied",
"themeColorNames": {
"primary": "Primary",
"success": "Success",
"warning": "Warning",
"danger": "Danger",
"info": "Info"
},
"themePalettes": {
"arco": {
"name": "ArcoD",
"description": "Clear blue-orange balance"
},
"antDesign": {
"name": "AntD",
"description": "Stable and structured"
},
"elementPlus": {
"name": "ElementD",
"description": "Close to component defaults"
}
},
"showTagsView": "Show Tags View",
"tagsViewStyle": "Tags View Style",
"tagsViewStyles": {
"line": "Line",
"card": "Card"
},
"showAppLogo": "Show App Logo",
"sidebarColorScheme": "Sidebar Color Scheme",
"showWatermark": "Show Watermark",
@@ -83,15 +112,16 @@
"resetConfig": "Reset Default",
"copySuccess": "Configuration copied to clipboard",
"resetSuccess": "Reset to default configuration",
"copyDescription": "Copy config will generate current settings code, reset will restore all settings to default",
"copyDescription": "Copy config generates the current defaults fragment; reset restores defaults",
"confirmReset": "Are you sure to reset all settings to default? This operation cannot be undone.",
"applyToFile": "Apply to File",
"onlyCopy": "Only Copy",
"leftLayout": "Left Mode",
"topLayout": "Top Mode",
"mixLayout": "Mix Mode",
"doubleLayout": "Double Mode",
"configManagement": "Config Management",
"copyConfigDescription": "Generate current settings code and copy to clipboard, then overwrite src/settings.ts file",
"copyConfigDescription": "Generate the current defaults fragment and copy it to clipboard",
"resetConfigDescription": "Restore all settings to system default values"
}
}

View File

@@ -67,12 +67,40 @@
"theme": "主题设置",
"interface": "界面设置",
"navigation": "导航设置",
"themeColor": "主题颜色",
"themeColorTip": "主题颜色",
"themePalette": "配色体系",
"customColors": "自定义颜色",
"customPalette": "自定义",
"customPaletteTip": "已自定义颜色",
"themeColorNames": {
"primary": "主色",
"success": "成功",
"warning": "警告",
"danger": "危险",
"info": "信息"
},
"themePalettes": {
"arco": {
"name": "ArcoD",
"description": "蓝橙清晰,默认推荐"
},
"antDesign": {
"name": "AntD",
"description": "规范稳重,后台友好"
},
"elementPlus": {
"name": "ElementD",
"description": "贴近组件默认识别"
}
},
"darkMode": "暗黑模式",
"layoutSetting": "布局设置",
"sidebarColorScheme": "侧边栏配色",
"showTagsView": "显示页签",
"tagsViewStyle": "页签风格",
"tagsViewStyles": {
"line": "线性",
"card": "卡片"
},
"showAppLogo": "显示Logo",
"showWatermark": "显示水印",
"pageSwitchingAnimation": "页面切换动画",
@@ -86,15 +114,16 @@
"resetConfig": "重置默认",
"copySuccess": "配置已复制到剪贴板",
"resetSuccess": "已重置为默认配置",
"copyDescription": "复制配置将生成当前设置的代码,重置将恢复所有设置为默认值",
"copyDescription": "复制配置会生成当前 defaults 片段,重置会恢复默认设置",
"confirmReset": "确定要重置所有设置为默认值吗?此操作不可恢复。",
"applyToFile": "应用到文件",
"onlyCopy": "仅复制",
"leftLayout": "左侧模式",
"topLayout": "顶部模式",
"mixLayout": "混合模式",
"doubleLayout": "双列模式",
"configManagement": "配置管理",
"copyConfigDescription": "生成当前设置的代码并复制到剪贴板,然后覆盖 src/settings.ts 文件",
"copyConfigDescription": "生成当前 defaults 片段并复制到剪贴板",
"resetConfigDescription": "恢复所有设置为系统默认值"
}
}

View File

@@ -0,0 +1,359 @@
<template>
<BaseLayout>
<aside
v-show="!appStore.contentFullscreen"
class="layout__double-sidebar"
:class="{ 'layout__double-sidebar--collapsed': !isSidebarOpen }"
>
<div class="layout__double-primary">
<LayoutLogo v-if="showLogo" :collapse="true" />
<el-scrollbar class="layout__double-primary-scroll">
<button
v-for="item in topMenuItems"
:key="item.path"
class="layout__primary-item"
:class="{ 'is-active': item.path === activeTopMenuPath }"
:title="item.meta?.title ? translateRouteTitle(item.meta.title) : undefined"
type="button"
@click="handleTopMenuSelect(item.path)"
>
<MenuIcon :icon="item.meta?.icon" />
<span v-if="item.meta?.title">{{ translateRouteTitle(item.meta.title) }}</span>
</button>
</el-scrollbar>
<button
class="layout__primary-toggle"
:title="isSidebarOpen ? '收起菜单' : '展开菜单'"
type="button"
@click="toggleSidebar"
>
<svg
class="layout__primary-toggle-icon"
:class="{ 'is-collapsed': !isSidebarOpen }"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
d="M12 5l-5 5 5 5"
fill="none"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
</div>
<div class="layout__double-secondary">
<div v-if="showLogo" class="layout__double-title">
{{ appConfig.title }}
</div>
<el-scrollbar class="layout__double-secondary-scroll">
<LayoutSidebar
:data="sideMenuRoutes"
:base-path="activeTopMenuPath"
:always-expand="true"
/>
</el-scrollbar>
</div>
</aside>
<main
class="layout__main"
:class="{
hasTagsView: showTagsView,
'layout__main--collapsed': !isSidebarOpen,
'layout__main--fullscreen': appStore.contentFullscreen,
}"
>
<LayoutNavbar v-show="!appStore.contentFullscreen" />
<LayoutTagsView v-if="showTagsView" />
<LayoutMain />
</main>
</BaseLayout>
</template>
<script setup lang="ts">
import { useLayout } from "./useLayout";
import { useMixMenu } from "./composables/useMixMenu";
import { useAppStore } from "@/stores";
import { appConfig } from "@/settings";
import { translateRouteTitle } from "@/lang/utils";
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 appStore = useAppStore();
const { showTagsView, showLogo, isSidebarOpen, toggleSidebar } = useLayout();
const { MenuIcon, topMenuItems, activeTopMenuPath, sideMenuRoutes, handleTopMenuSelect } =
useMixMenu();
</script>
<style lang="scss" scoped>
@use "@/styles/mixins" as *;
$double-sidebar-width: $sidebar-primary-width + $sidebar-secondary-width;
.layout {
&__double-sidebar {
position: fixed;
top: 0;
bottom: 0;
left: 0;
z-index: 999;
display: flex;
width: $double-sidebar-width;
overflow: hidden;
background-color: var(--menu-background);
border-right: 1px solid var(--menu-border);
transition: width 0.28s;
&--collapsed {
width: $sidebar-width-collapsed + $sidebar-secondary-width;
.layout__double-primary {
flex-basis: $sidebar-width-collapsed;
width: $sidebar-width-collapsed;
}
.layout__primary-item {
gap: 0;
min-height: 48px;
padding: 8px 4px;
span {
display: none;
}
}
.layout__primary-toggle-icon {
transform: rotate(180deg);
}
.layout__double-secondary {
flex: 0 0 $sidebar-secondary-width;
width: $sidebar-secondary-width;
min-width: $sidebar-secondary-width;
border-right: 1px solid var(--menu-border);
}
}
}
&__double-primary {
display: flex;
flex: 0 0 $sidebar-primary-width;
flex-direction: column;
width: $sidebar-primary-width;
overflow: hidden;
background:
linear-gradient(
180deg,
color-mix(in srgb, var(--menu-background) 94%, var(--el-color-primary) 6%),
var(--menu-background)
),
var(--menu-background);
border-right: 1px solid var(--menu-border);
transition:
flex-basis 0.28s,
width 0.28s;
:deep(.logo) {
height: $navbar-height;
background-color: transparent;
}
}
&__double-primary-scroll {
flex: 1;
min-height: 0;
}
&__primary-item {
position: relative;
display: flex;
flex-direction: column;
gap: 6px;
align-items: center;
justify-content: center;
width: calc(100% - 12px);
min-height: 64px;
padding: 8px 4px;
margin: 6px;
font: inherit;
font-size: 12px;
line-height: 1.2;
color: var(--menu-text);
cursor: pointer;
background: transparent;
border: 0;
border-radius: 8px;
transition:
color 0.18s,
background-color 0.18s;
:deep(.el-icon),
:deep([class^="i-svg:"]) {
width: 20px;
height: 20px;
font-size: 20px;
color: currentcolor !important;
}
span {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
text-align: center;
white-space: nowrap;
}
&:hover {
color: var(--menu-active-text);
background-color: var(--menu-hover);
}
&.is-active {
color: var(--menu-active-text);
background-color: var(--menu-active-bg);
}
&:focus-visible {
outline: none;
background-color: var(--menu-hover);
}
}
&__primary-toggle {
display: flex;
flex: 0 0 48px;
align-items: center;
justify-content: center;
width: 100%;
padding: 0;
font: inherit;
color: var(--menu-text);
cursor: pointer;
background: transparent;
border: none;
border-top: 1px solid var(--menu-border);
transition:
color 0.18s,
background-color 0.18s;
&:hover {
color: var(--menu-active-text);
background-color: var(--menu-hover);
}
}
&__primary-toggle-icon {
width: 16px;
height: 16px;
transition: transform 0.28s;
&.is-collapsed {
transform: rotate(180deg);
}
}
&__double-secondary {
display: flex;
flex: 0 0 auto;
flex-direction: column;
width: $sidebar-secondary-width;
min-width: 0;
overflow: hidden;
background-color: var(--menu-background);
transition: width 0.28s;
:deep(.el-menu) {
border: none;
}
/* 恢复 .hideSidebar 被隐藏的文字(第二列始终展开) */
:deep(.el-menu-item span),
:deep(.el-sub-menu__title span) {
visibility: visible !important;
width: auto !important;
height: auto !important;
overflow: visible !important;
}
:deep(.el-sub-menu__icon-arrow) {
display: inline-flex !important;
}
}
&__double-secondary-scroll {
flex: 1;
min-height: 0;
}
&__double-title {
display: flex;
flex: 0 0 $navbar-height;
align-items: center;
min-width: $sidebar-secondary-width;
padding: 0 18px;
font-size: 16px;
font-weight: 600;
color: var(--sidebar-logo-text-color);
white-space: nowrap;
}
&__main {
position: relative;
height: 100%;
margin-left: $double-sidebar-width;
overflow-y: auto;
transition: margin-left 0.28s;
&--collapsed {
margin-left: $sidebar-width-collapsed + $sidebar-secondary-width;
}
&--fullscreen {
margin-left: 0 !important;
}
}
}
.hasTagsView {
:deep(.app-main) {
@include app-main-height-with-tags;
}
}
.mobile {
.layout__double-sidebar {
width: $double-sidebar-width;
transition:
transform 0.28s,
width 0s;
}
&.hideSidebar {
.layout__double-sidebar {
transform: translateX(-$double-sidebar-width);
}
}
&.openSidebar {
.layout__double-sidebar {
transform: translateX(0);
}
}
.layout__main {
margin-left: 0 !important;
}
}
</style>

View File

@@ -45,7 +45,7 @@ const appStore = useAppStore();
</script>
<style lang="scss" scoped>
@use "@/styles/base/mixins" as *;
@use "@/styles/mixins" as *;
.layout {
&__sidebar {

View File

@@ -89,7 +89,6 @@ 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 { width } = useWindowSize();
const appStore = useAppStore();
@@ -123,7 +122,7 @@ const useMenuColors = computed(
</script>
<style lang="scss" scoped>
@use "@/styles/base/mixins" as *;
@use "@/styles/mixins" as *;
.layout {
&__header {
@@ -133,8 +132,7 @@ const useMenuColors = computed(
width: 100%;
height: $navbar-height;
background-color: var(--menu-background);
border-bottom: 1px solid var(--card-border);
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 6%);
border-bottom: 1px solid var(--menu-border);
&-content {
display: flex;
@@ -167,16 +165,33 @@ const useMenuColors = computed(
:deep(.el-menu--horizontal) {
display: flex;
gap: 4px;
align-items: center;
height: 100%;
.el-menu-item {
position: relative;
height: 100%;
padding: 0 12px;
line-height: $navbar-height;
border-bottom: none;
border-radius: 0;
transition:
color 0.16s ease,
background-color 0.16s ease,
border-color 0.16s ease;
&:hover {
background-color: var(--menu-hover) !important;
}
&.is-active {
background-color: rgba(255, 255, 255, 0.12);
color: var(--el-color-primary) !important;
background-color: color-mix(
in srgb,
var(--el-color-primary) 10%,
var(--el-bg-color-overlay)
) !important;
border-bottom: 2px solid var(--el-color-primary);
}
}
@@ -202,7 +217,7 @@ const useMenuColors = computed(
width: $sidebar-width;
height: 100%;
background-color: var(--menu-background);
border-right: 1px solid var(--card-border);
border-right: 1px solid var(--menu-border);
transition: width 0.28s;
&.layout__sidebar--collapsed {
@@ -228,7 +243,7 @@ const useMenuColors = computed(
height: $sidebar-toggle-height;
line-height: $sidebar-toggle-height;
background-color: var(--menu-background);
box-shadow: 0 0 6px -2px var(--el-color-primary);
border-top: 1px solid var(--menu-border);
}
}
@@ -267,4 +282,20 @@ const useMenuColors = computed(
@include app-main-height-with-tags;
}
}
:global(html.dark),
:global(html.sidebar-color-blue) {
.layout__header-menu {
:deep(.el-menu--horizontal) {
.el-menu-item.is-active {
color: var(--menu-active-text) !important;
background-color: color-mix(
in srgb,
var(--el-color-primary) 18%,
var(--menu-background)
) !important;
}
}
}
}
</style>

View File

@@ -49,7 +49,7 @@ const isLogoCollapsed = computed(() => width.value < 768);
</script>
<style lang="scss" scoped>
@use "@/styles/base/mixins" as *;
@use "@/styles/mixins" as *;
.layout {
&__header {

View File

@@ -33,10 +33,10 @@ function toggleSideBar() {
align-items: center;
justify-content: space-between;
height: $navbar-height;
padding: 0 16px 0 4px;
padding: 0 14px 0 6px;
background-color: var(--content-bg);
border-bottom: 1px solid var(--card-border);
box-shadow: 0 1px 2px 0 rgb(0 0 0 / 3%);
box-shadow: 0 1px 0 rgb(15 23 42 / 3%);
&__actions {
display: flex;

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,7 @@
<el-menu
ref="menuRef"
:default-active="activeMenuPath"
:collapse="!appStore.sidebar.opened"
:collapse="props.alwaysExpand ? false : !appStore.sidebar.opened"
:background-color="menuThemeProps.backgroundColor"
:text-color="menuThemeProps.textColor"
:active-text-color="menuThemeProps.activeTextColor"
@@ -34,7 +34,6 @@ import { useSettingsStore, useAppStore } from "@/stores";
import { isExternal } from "@/utils/index";
import LayoutSidebarItem from "./LayoutSidebarItem.vue";
import variables from "@/styles/variables.module.scss";
const props = defineProps({
data: {
type: Array as PropType<RouteRecordRaw[]>,
@@ -50,6 +49,11 @@ const props = defineProps({
default: "vertical",
validator: (value: string) => ["vertical", "horizontal"].includes(value),
},
/** 强制展开,忽略全局 sidebar.opened 状态(DoubleLayout 第二列需要始终展开) */
alwaysExpand: {
type: Boolean,
default: false,
},
});
const menuRef = ref<MenuInstance>();

View File

@@ -284,11 +284,11 @@ html.sidebar-color-blue {
// 深色主题下的父菜单激活状态"
html.dark & {
&.has-active-child > .el-sub-menu__title {
color: var(--el-color-primary-light-3) !important;
background-color: rgba(64, 128, 255, 0.15) !important;
color: var(--menu-active-text) !important;
background-color: var(--menu-active-bg) !important;
.menu-icon {
color: var(--el-color-primary-light-3) !important;
color: var(--menu-active-text) !important;
}
}
}
@@ -296,11 +296,11 @@ html.sidebar-color-blue {
// 深蓝色侧边栏配色下的父菜单激活状态"
html.sidebar-color-blue & {
&.has-active-child > .el-sub-menu__title {
color: var(--el-color-primary-light-3) !important;
background-color: rgba(64, 128, 255, 0.2) !important;
color: var(--menu-active-text) !important;
background-color: var(--menu-active-bg) !important;
.menu-icon {
color: var(--el-color-primary-light-3) !important;
color: var(--menu-active-text) !important;
}
}
}

View File

@@ -1,5 +1,5 @@
<template>
<div class="tags-container">
<div :class="['tags-container', tagsStyleClass]">
<!-- 页签滚动区 -->
<el-scrollbar
ref="scrollbarRef"
@@ -129,8 +129,10 @@
<script setup lang="ts">
import { useRoute, useRouter, type RouteRecordRaw } from "vue-router";
import { resolve } from "path-browserify";
import { TagsViewStyle } from "@/enums";
import { translateRouteTitle } from "@/lang/utils";
import { useAppStore, usePermissionStore, useTagsViewStore } from "@/stores";
import { useAppStore, usePermissionStore, useSettingsStore, useTagsViewStore } from "@/stores";
import type { TagView } from "@/stores/tags-view";
interface ContextMenu {
visible: boolean;
@@ -143,6 +145,7 @@ const route = useRoute();
const appStore = useAppStore();
const permissionStore = usePermissionStore();
const settingsStore = useSettingsStore();
const tagsViewStore = useTagsViewStore();
const { visitedViews } = storeToRefs(tagsViewStore);
@@ -161,6 +164,16 @@ const currentTag = computed(() => {
return visitedViews.value.find((tag) => tagsViewStore.isActive(tag)) || null;
});
const tagsStyleClass = computed(() => {
switch (settingsStore.tagsViewStyle) {
case TagsViewStyle.CARD:
return "tags-container--card";
case TagsViewStyle.LINE:
default:
return "tags-container--line";
}
});
// 图标兼容:判断是否为 Element Plus 图标 (el-icon-xxx)
const isEpIcon = (icon: string) => icon.startsWith("el-icon");
@@ -338,7 +351,8 @@ const handleScroll = (event: WheelEvent) => {
const hasHorizontalScroll = scrollWrapper.scrollWidth > scrollWrapper.clientWidth;
if (!hasHorizontalScroll) return;
const deltaY = event.deltaY || -(event as any).wheelDelta || 0;
const legacyEvent = event as WheelEvent & { wheelDelta?: number };
const deltaY = event.deltaY || -(legacyEvent.wheelDelta ?? 0);
const newScrollLeft = scrollWrapper.scrollLeft + deltaY;
scrollbarRef.value.setScrollLeft(newScrollLeft);
@@ -358,7 +372,7 @@ const refreshSelectedTag = (tag: TagView | null) => {
const closeSelectedTag = (tag: TagView | null) => {
if (!tag) return;
tagsViewStore.delView(tag).then((result: any) => {
tagsViewStore.delView(tag).then((result) => {
if (tagsViewStore.isActive(tag)) {
tagsViewStore.toLastView(result.visitedViews, tag);
}
@@ -421,7 +435,7 @@ const closeOtherTags = () => {
// 关闭所有标签页
const closeAllTags = (tag: TagView | null) => {
tagsViewStore.delAllViews().then((result: any) => {
tagsViewStore.delAllViews().then((result) => {
tagsViewStore.toLastView(result.visitedViews, tag || undefined);
});
};
@@ -475,9 +489,9 @@ useContextMenuManager();
align-items: center;
width: 100%;
height: $tags-view-height;
padding: 0 4px 0 8px;
padding: 0 12px;
background-color: var(--content-bg);
border-bottom: 1px solid var(--border-color);
border-bottom: 1px solid var(--card-border);
}
.scroll-container {
@@ -496,31 +510,30 @@ useContextMenuManager();
.tags-wrapper {
display: flex;
gap: 6px;
gap: 4px;
align-items: center;
height: 100%;
}
// ============================================
// 标签项
// 未激活: 透明底,融入白色容器背景
// 激活: 浅主色底 + 边框,从背景中"跳"出来
// ============================================
.tags-item {
position: relative;
display: inline-flex;
flex-shrink: 0;
gap: 6px;
align-items: center;
height: 30px;
padding: 0 14px;
height: 26px;
padding: 0 12px;
font-size: 13px;
color: var(--el-text-color-regular);
cursor: pointer;
user-select: none;
background: transparent;
border: 1px solid transparent;
border-radius: 8px;
background: #f8fafc;
border: 1px solid var(--card-border);
border-radius: 2px;
transition:
color 0.15s ease,
background-color 0.15s ease,
@@ -562,10 +575,10 @@ useContextMenuManager();
}
}
// 悬浮态 — 浅灰底浮现
&:hover {
color: var(--el-text-color-primary);
background-color: var(--el-fill-color-light);
background-color: var(--content-bg);
border-color: var(--el-border-color);
.tags-item__close {
opacity: 1;
@@ -576,12 +589,11 @@ useContextMenuManager();
}
}
// 激活态 — 浅主色块突出显示
&.is-active {
font-weight: 500;
color: var(--el-color-primary);
background-color: var(--el-color-primary-light-9);
border-color: var(--el-color-primary-light-7);
background-color: var(--content-bg);
border-color: var(--el-color-primary-light-5);
.tags-item__icon {
color: var(--el-color-primary);
@@ -606,6 +618,78 @@ useContextMenuManager();
}
}
.tags-container--line {
.tags-wrapper {
gap: 8px;
}
.tags-item {
height: 100%;
padding: 0 8px;
color: var(--el-text-color-secondary);
background: transparent;
border: 0;
border-radius: 0;
&::after {
position: absolute;
right: 6px;
bottom: 0;
left: 6px;
height: 2px;
content: "";
background: var(--el-color-primary);
transform: scaleX(0);
transform-origin: center;
transition: transform 0.16s ease;
}
&:hover {
color: var(--el-text-color-primary);
background: var(--el-fill-color-lighter);
border-color: transparent;
border-radius: 4px 4px 0 0;
}
&.is-active {
color: var(--el-color-primary);
background: transparent;
border-color: transparent;
&::after {
transform: scaleX(1);
}
}
}
}
.tags-container--card {
.tags-wrapper {
gap: 8px;
}
.tags-item {
height: 28px;
padding: 0 10px;
color: var(--el-text-color-secondary);
background: transparent;
border-color: transparent;
border-radius: 4px;
&:hover {
color: var(--el-text-color-primary);
background: var(--el-fill-color-lighter);
border-color: transparent;
}
&.is-active {
color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
border-color: var(--el-color-primary-light-6);
}
}
}
// ============================================
// 右侧操作按钮
// ============================================
@@ -613,23 +697,23 @@ useContextMenuManager();
.tags-actions {
display: flex;
flex-shrink: 0;
gap: 2px;
gap: 4px;
align-items: center;
height: 100%;
padding-left: 4px;
padding-left: 8px;
&__btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
width: 30px;
height: 30px;
cursor: pointer;
border-radius: 6px;
transition: background-color 0.15s ease;
&:hover {
background-color: var(--el-fill-color-light);
background-color: var(--el-fill-color);
}
:deep(.el-icon),

View File

@@ -178,23 +178,27 @@ function handleSettingsClick() {
<style lang="scss" scoped>
.navbar-actions {
display: flex;
gap: 4px;
align-items: center;
min-height: 44px;
min-height: 32px;
&__item {
position: relative;
display: flex;
align-items: center;
justify-content: center;
min-width: 44px; /* 增加最小点击区域到44px,符合人机交互标准 */
height: 44px;
padding: 0 8px;
min-width: 32px;
height: 32px;
padding: 0 6px;
text-align: center;
cursor: pointer;
transition: all 0.3s;
border-radius: 8px;
transition:
background-color 0.16s,
color 0.16s;
// 只对需要居中的子元素生效,不使用通配符避免影响选择器组件
> [class^="i-svg:"] {
> [class*="i-svg:"] {
display: flex;
align-items: center;
justify-content: center;
@@ -207,7 +211,7 @@ function handleSettingsClick() {
align-items: center;
justify-content: center;
width: 100%;
height: 44px;
height: 32px;
}
:deep(.i-svg\:language) {
@@ -220,7 +224,7 @@ function handleSettingsClick() {
}
// 图标样式
:deep([class^="i-svg:"]) {
:deep([class*="i-svg:"]) {
font-size: 16px;
line-height: 1;
color: var(--el-text-color-secondary);
@@ -228,9 +232,9 @@ function handleSettingsClick() {
}
&:hover {
background: var(--el-fill-color-light);
background: var(--el-fill-color);
:deep([class^="i-svg:"]) {
:deep([class*="i-svg:"]) {
color: var(--el-color-primary);
}
}
@@ -240,8 +244,8 @@ function handleSettingsClick() {
display: flex;
align-items: center;
justify-content: center;
height: 44px;
padding: 0 8px;
height: 32px;
padding: 0 6px 0 2px;
&__avatar {
flex-shrink: 0;
@@ -252,6 +256,7 @@ function handleSettingsClick() {
&__name {
margin-left: 8px;
font-size: 13px;
color: var(--el-text-color-regular);
white-space: nowrap;
transition: color 0.3s;
@@ -262,14 +267,14 @@ function handleSettingsClick() {
// 白色文字样式(用于深色背景:暗黑主题、顶部布局、混合布局等)
.navbar-actions--white-text {
.navbar-actions__item {
:deep([class^="i-svg:"]) {
:deep([class*="i-svg:"]) {
color: color-mix(in srgb, var(--el-color-white) 85%, transparent);
}
&:hover {
background: color-mix(in srgb, var(--el-color-white) 10%, transparent);
:deep([class^="i-svg:"]) {
:deep([class*="i-svg:"]) {
color: var(--el-color-white);
}
}
@@ -298,14 +303,14 @@ function handleSettingsClick() {
// 深色文字样式(用于浅色背景:明亮主题下的左侧布局等)
.navbar-actions--dark-text {
.navbar-actions__item {
:deep([class^="i-svg:"]) {
:deep([class*="i-svg:"]) {
color: var(--el-text-color-secondary) !important;
}
&:hover {
background: rgba(0, 0, 0, 0.04);
:deep([class^="i-svg:"]) {
:deep([class*="i-svg:"]) {
color: var(--el-color-primary) !important;
}
}
@@ -333,7 +338,7 @@ function handleSettingsClick() {
// 确保下拉菜单中的图标不受影响
::v-deep(.el-dropdown-menu) {
[class^="i-svg:"] {
[class*="i-svg:"] {
color: var(--el-text-color-regular) !important;
&:hover {

View File

@@ -12,6 +12,7 @@ import { LayoutMode } from "@/enums/settings";
import LeftLayout from "./LeftLayout.vue";
import TopLayout from "./TopLayout.vue";
import MixLayout from "./MixLayout.vue";
import DoubleLayout from "./DoubleLayout.vue";
import Settings from "./components/LayoutSettings.vue";
const route = useRoute();
@@ -26,6 +27,8 @@ const currentLayoutComponent = computed(() => {
return TopLayout;
case LayoutMode.MIX:
return MixLayout;
case LayoutMode.DOUBLE:
return DoubleLayout;
default:
return LeftLayout;
}

View File

@@ -9,7 +9,6 @@ import { createApp } from "vue";
import App from "./App.vue";
// ===== 样式导入 =====
import "element-plus/dist/index.css";
import "element-plus/theme-chalk/dark/css-vars.css";
import "vxe-table/lib/style.css";
import "@/styles/index.scss";

View File

@@ -4,7 +4,6 @@ import router from "@/router";
import { usePermissionStore, useUserStore } from "@/stores";
import { useTenantStoreHook } from "@/stores/tenant";
import { isTenantEnabled } from "@/utils/tenant";
import { addRecentMenu } from "@/composables/useRecentMenus";
/**
* 路由权限守卫
@@ -78,12 +77,6 @@ export function setupPermissionGuard() {
router.afterEach((to) => {
NProgress.done();
// 记录最近访问
if (to.meta?.title && to.path) {
const icon = typeof to.meta.icon === "string" ? to.meta.icon : undefined;
addRecentMenu(to.path, to.meta.title as string, icon);
}
});
}

View File

@@ -59,7 +59,7 @@ export const constantRoutes: RouteRecordRaw[] = [
meta: { title: "个人中心", icon: "user", hidden: true },
},
{
path: "my-notice",
path: "profile/notice",
name: "MyNotice",
component: () => import("@/views/profile/notice/index.vue"),
meta: { title: "我的通知", icon: "user", hidden: true },

View File

@@ -2,15 +2,73 @@
* 应用配置
*/
import { LayoutMode, ComponentSize, SidebarColor, ThemeMode, LanguageEnum } from "@/enums";
import {
LayoutMode,
ComponentSize,
SidebarColor,
ThemeMode,
LanguageEnum,
TagsViewStyle,
} from "@/enums";
const env = import.meta.env;
const { pkg } = __APP_INFO__;
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
// ============================================
// 应用配置
// ============================================
export const themeColorNames = ["primary", "success", "warning", "danger", "info"] as const;
export type ThemeColorName = (typeof themeColorNames)[number];
export type ThemeColorMap = Record<ThemeColorName, string>;
export interface ThemePalettePreset {
id: string;
name: string;
description: string;
colors: ThemeColorMap;
}
export const themePalettePresets = [
{
id: "arco",
name: "ArcoD",
description: "蓝橙对比清晰,适合现代中后台",
colors: {
primary: "#165DFF",
success: "#00B42A",
warning: "#FF7D00",
danger: "#F53F3F",
info: "#86909C",
},
},
{
id: "ant-design",
name: "AntD",
description: "规范稳重,适合标准业务系统",
colors: {
primary: "#1677FF",
success: "#52C41A",
warning: "#FAAD14",
danger: "#FF4D4F",
info: "#1677FF",
},
},
{
id: "element-plus",
name: "ElementD",
description: "贴近组件默认色识别",
colors: {
primary: "#409EFF",
success: "#67C23A",
warning: "#E6A23C",
danger: "#F56C6C",
info: "#909399",
},
},
] as const satisfies readonly ThemePalettePreset[];
export const defaultThemePalette = themePalettePresets[0];
export const appConfig = {
name: pkg.name as string,
version: pkg.version as string,
@@ -20,36 +78,19 @@ export const appConfig = {
tenantEnabled: env.VITE_APP_TENANT_ENABLED === "true",
} as const;
// ============================================
// 用户偏好默认值
// ============================================
export const defaults = {
theme: prefersDark ? ThemeMode.DARK : ThemeMode.LIGHT,
themeColor: "#1677FF",
themePalette: defaultThemePalette.id,
themeColors: { ...defaultThemePalette.colors },
sidebarColorScheme: SidebarColor.MINIMAL_WHITE,
layout: LayoutMode.LEFT,
size: ComponentSize.DEFAULT,
language: LanguageEnum.ZH_CN,
showTagsView: true,
tagsViewStyle: TagsViewStyle.CARD,
showAppLogo: true,
showWatermark: false,
pageSwitchingAnimation: "fade-slide",
showSettings: true,
watermarkContent: pkg.name,
} as const;
// ============================================
// 主题色预设
// ============================================
export const themeColorPresets = [
"#1677FF",
"#165DFF",
"#3370FF",
"#22C55E",
"#FAAD14",
"#FF4D4F",
"#722ED1",
"#EB2F96",
"#13C2C2",
"#F97316",
] as const;

View File

@@ -1,5 +1,5 @@
import { SidebarColor, ThemeMode } from "@/enums";
import type { LayoutMode } from "@/enums";
import type { LayoutMode, TagsViewStyle } from "@/enums";
import {
applyTheme,
generateThemeColors,
@@ -9,12 +9,20 @@ import {
watchSystemTheme,
} from "@/utils/theme";
import { STORAGE_KEYS } from "@/constants";
import { defaults } from "@/settings";
import { defaults, themePalettePresets } from "@/settings";
import type { ThemeColorMap, ThemeColorName } from "@/settings";
const CUSTOM_THEME_PALETTE = "custom";
const HEX_COLOR_RE = /^#[0-9a-f]{6}$/i;
export const useSettingsStore = defineStore("setting", () => {
// 界面显示
const settingsVisible = ref(false);
const showTagsView = useStorage(STORAGE_KEYS.SHOW_TAGS_VIEW, defaults.showTagsView);
const tagsViewStyle = useStorage<TagsViewStyle>(
STORAGE_KEYS.TAGS_VIEW_STYLE,
defaults.tagsViewStyle
);
const showAppLogo = useStorage(STORAGE_KEYS.SHOW_APP_LOGO, defaults.showAppLogo);
const showWatermark = useStorage(STORAGE_KEYS.SHOW_WATERMARK, defaults.showWatermark);
const pageSwitchingAnimation = useStorage(
@@ -31,13 +39,15 @@ export const useSettingsStore = defineStore("setting", () => {
// 主题
const theme = useStorage<ThemeMode>(STORAGE_KEYS.THEME, defaults.theme);
const themeColor = useStorage(STORAGE_KEYS.THEME_COLOR, defaults.themeColor);
const themePalette = useStorage<string>(STORAGE_KEYS.THEME_PALETTE, defaults.themePalette);
const themeColors = useStorage<ThemeColorMap>(STORAGE_KEYS.THEME_COLORS, {
...defaults.themeColors,
});
const activeThemePalette = computed(
() => themePalettePresets.find((item) => item.id === themePalette.value) || null
);
// 旧默认值 → 新默认值 自动迁移(用户自定义的颜色不会被覆盖)
const LEGACY_DEFAULTS = ["#4080FF", "#4080ff", "#2563EB", "#2563eb"];
if (LEGACY_DEFAULTS.includes(themeColor.value)) {
themeColor.value = defaults.themeColor;
}
const resolvedTheme = ref<ThemeMode>(resolveThemeMode(theme.value));
// 特殊模式
@@ -65,12 +75,12 @@ export const useSettingsStore = defineStore("setting", () => {
);
watch(
[resolvedTheme, themeColor],
([t, c]: [ThemeMode, string]) => {
[resolvedTheme, themeColors],
([t, colors]) => {
toggleDarkMode(t === ThemeMode.DARK);
applyTheme(generateThemeColors(c, t));
applyTheme(generateThemeColors(colors, t));
},
{ immediate: true }
{ immediate: true, deep: true }
);
watch(sidebarColorScheme, (v) => toggleSidebarColor(v === SidebarColor.CLASSIC_BLUE), {
@@ -95,8 +105,27 @@ export const useSettingsStore = defineStore("setting", () => {
{ immediate: true }
);
function applyThemePalette(id: string) {
const preset = themePalettePresets.find((item) => item.id === id);
if (!preset) return;
themePalette.value = preset.id;
themeColors.value = { ...preset.colors };
}
function updateThemeColor(name: ThemeColorName, color: string) {
if (!HEX_COLOR_RE.test(color)) return;
themePalette.value = CUSTOM_THEME_PALETTE;
themeColors.value = {
...themeColors.value,
[name]: color,
};
}
function resetSettings() {
showTagsView.value = defaults.showTagsView;
tagsViewStyle.value = defaults.tagsViewStyle;
showAppLogo.value = defaults.showAppLogo;
showWatermark.value = defaults.showWatermark;
pageSwitchingAnimation.value = defaults.pageSwitchingAnimation;
@@ -104,13 +133,15 @@ export const useSettingsStore = defineStore("setting", () => {
colorWeak.value = false;
sidebarColorScheme.value = defaults.sidebarColorScheme;
layout.value = defaults.layout as LayoutMode;
themeColor.value = defaults.themeColor;
themePalette.value = defaults.themePalette;
themeColors.value = { ...defaults.themeColors };
theme.value = defaults.theme;
}
return {
settingsVisible,
showTagsView,
tagsViewStyle,
showAppLogo,
showWatermark,
pageSwitchingAnimation,
@@ -118,9 +149,13 @@ export const useSettingsStore = defineStore("setting", () => {
colorWeak,
sidebarColorScheme,
layout,
themeColor,
themePalette,
themeColors,
theme,
resolvedTheme,
activeThemePalette,
applyThemePalette,
updateThemeColor,
resetSettings,
};
});

View File

@@ -1,3 +1,25 @@
import type { LocationQuery } from "vue-router";
export interface TagView {
name: string;
title: string;
path: string;
fullPath: string;
icon?: string;
affix?: boolean;
keepAlive?: boolean;
query?: LocationQuery;
}
export interface TagsViewResult {
visitedViews: TagView[];
cachedViews: string[];
}
export interface DirectionalTagsViewResult {
visitedViews: TagView[];
}
export const useTagsViewStore = defineStore("tagsView", () => {
const visitedViews = ref<TagView[]>([]);
const cachedViews = ref<string[]>([]);
@@ -115,7 +137,7 @@ export const useTagsViewStore = defineStore("tagsView", () => {
addCachedView(view);
}
function delView(view: TagView) {
function delView(view: TagView): Promise<TagsViewResult> {
return new Promise((resolve) => {
delVisitedView(view);
delCachedView(view);
@@ -126,7 +148,7 @@ export const useTagsViewStore = defineStore("tagsView", () => {
});
}
function delOtherViews(view: TagView) {
function delOtherViews(view: TagView): Promise<TagsViewResult> {
return new Promise((resolve) => {
delOtherVisitedViews(view);
delOtherCachedViews(view);
@@ -137,10 +159,13 @@ export const useTagsViewStore = defineStore("tagsView", () => {
});
}
function delLeftViews(view: TagView) {
function delLeftViews(view: TagView): Promise<DirectionalTagsViewResult> {
return new Promise((resolve) => {
const currIndex = visitedViews.value.findIndex((v) => v.path === view.path);
if (currIndex === -1) {
resolve({
visitedViews: [...visitedViews.value],
});
return;
}
visitedViews.value = visitedViews.value.filter((item, index) => {
@@ -160,10 +185,13 @@ export const useTagsViewStore = defineStore("tagsView", () => {
});
}
function delRightViews(view: TagView) {
function delRightViews(view: TagView): Promise<DirectionalTagsViewResult> {
return new Promise((resolve) => {
const currIndex = visitedViews.value.findIndex((v) => v.path === view.path);
if (currIndex === -1) {
resolve({
visitedViews: [...visitedViews.value],
});
return;
}
visitedViews.value = visitedViews.value.filter((item, index) => {
@@ -182,7 +210,7 @@ export const useTagsViewStore = defineStore("tagsView", () => {
});
}
function delAllViews() {
function delAllViews(): Promise<TagsViewResult> {
return new Promise((resolve) => {
const affixTags = visitedViews.value.filter((tag) => tag?.affix);
visitedViews.value = affixTags;
@@ -223,7 +251,7 @@ export const useTagsViewStore = defineStore("tagsView", () => {
keepAlive: route.meta?.keepAlive,
query: route.query,
};
delView(tags).then((res: any) => {
delView(tags).then((res) => {
if (isActive(tags)) {
toLastView(res.visitedViews, tags);
}

View File

@@ -1,68 +0,0 @@
/**
* 布局 Mixins
*
* 所有布局组件共享的样式抽象,确保单一来源。
*
* ═══════════════════════════════════════════════════════════
* 主题变量契约(添加新主题时必须定义以下 CSS 变量)
* ═══════════════════════════════════════════════════════════
*
* --menu-background 菜单背景色
* --menu-text 菜单文字色
* --menu-active-text 菜单激活文字色(通常复用 --el-menu-active-color)
* --menu-hover 菜单悬浮背景色【自动派生——见 _theme.scss 颜色令牌】
* --sidebar-logo-background Logo 区域背景色
* --sidebar-logo-text-color Logo 区域文字色
*
* 自动派生规则:--menu-hover 通过 color-mix() 从 --menu-background 派生,
* 因此新主题只需定义 --menu-background,hover 颜色自动生成。
* 如需覆盖自动派生值,显式设置 --menu-hover 即可。
*/
@use "variables" as *;
// ============================================
// 布局计算 Mixins
// ============================================
/**
* 计算主内容区高度(考虑 TagsView 是否存在)
*
* 使用场景:各布局文件中的 .hasTagsView 样式块
*
* @example
* .hasTagsView {
* :deep(.app-main) {
* @include app-main-height-with-tags;
* }
* }
*/
@mixin app-main-height-with-tags {
height: calc(100vh - $navbar-height - $tags-view-height) !important;
}
/**
* 主内容区基础高度(无 TagsView)
*
* @example
* .layout__main {
* @include app-main-height;
* }
*/
@mixin app-main-height {
height: calc(100vh - $navbar-height);
}
/**
* 侧边栏滚动区高度(带 Logo 时)
*/
@mixin sidebar-scroll-height-with-logo {
height: calc(100vh - $navbar-height);
}
/**
* 侧边栏滚动区高度(带底部折叠按钮时,如 MixLayout 左侧)
*/
@mixin sidebar-scroll-height-with-toggle {
height: calc(100vh - $navbar-height - $sidebar-toggle-height);
}

View File

@@ -1,94 +0,0 @@
/**
* 全局基础重置样式
* 补充 UnoCSS 预设未覆盖的项目级样式
*/
#app {
width: 100%;
height: 100%;
}
html {
box-sizing: border-box;
width: 100%;
height: 100%;
line-height: 1.5;
tab-size: 4;
text-size-adjust: 100%;
}
#app,
html {
box-sizing: border-box;
}
*,
*::before,
*::after {
box-sizing: inherit;
}
body {
width: 100%;
height: 100%;
margin: 0;
font-family:
"Helvetica Neue", Helvetica, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "微软雅黑",
Arial, sans-serif;
line-height: inherit;
background-color: var(--page-bg);
-moz-osx-font-smoothing: grayscale;
-webkit-font-smoothing: antialiased;
text-rendering: optimizelegibility;
}
/* 全局平滑滚动 */
html {
scroll-behavior: smooth;
}
/* 滚动条现代化 */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-thumb {
background-color: rgb(0 0 0 / 12%);
border-radius: 3px;
}
::-webkit-scrollbar-track {
background-color: transparent;
}
img,
svg {
display: inline-block;
}
svg {
// 因icon大小被设置为和字体大小一致,而span等标签的下边缘会和字体的基线对齐,故需设置一个往下的偏移比例,来纠正视觉上的未对齐效果
vertical-align: -0.15em;
}
ul,
li {
padding: 0;
margin: 0;
list-style: none;
}
a,
a:focus,
a:hover {
color: inherit;
text-decoration: none;
cursor: pointer;
}
a:focus,
a:active,
div:focus {
outline: none;
}

View File

@@ -1,140 +0,0 @@
/**
* 主题变量定义
*
* ═══════════════════════════════════════════════════════════
* 主题变量契约
* ═══════════════════════════════════════════════════════════
*
* 新增主题只需定义以下变量,其余自动派生:
*
* 必须定义:
* --menu-background 菜单背景色
* --menu-text 菜单文字色
* --menu-active-text 菜单激活文字色
* --sidebar-logo-background Logo 区域背景色
* --sidebar-logo-text-color Logo 区域文字色
*
* 可选覆盖(有自动派生默认值):
* --menu-hover 菜单悬浮背景色
* → 浅色主题:从 --el-color-primary 与背景混合派生
* → 深色主题:半透明黑色叠加层,自适应动态背景
* → 可显式覆盖以微调
*/
// ============================================
// SCSS 颜色令牌(构建时源)
// ============================================
$menu-bg-light: #fff;
$menu-hover-light: #e6f4ff; // 设计指定色,非纯推导
$menu-bg-classic-blue: #304156;
// ============================================
// 默认主题(浅色 + 白色侧边栏)
// ============================================
:root {
// 菜单
--menu-background: #{$menu-bg-light};
--menu-text: #606266;
--menu-active-text: var(--el-menu-active-color);
--menu-hover: #{$menu-hover-light};
// 侧边栏 Logo
--sidebar-logo-background: #fafafa;
--sidebar-logo-text-color: #383853;
// 页面背景与内容区
--page-bg: #fafbfc;
--content-bg: #ffffff;
// 边框
--border-color: #e2e8ee;
--card-border: rgb(0 0 0 / 8%);
// 卡片
--card-radius: calc(var(--custom-radius) + 4px);
--card-shadow: 0 1px 3px 0 rgb(0 0 0 / 6%), 0 1px 2px -1px rgb(0 0 0 / 4%);
// 全局圆角基准(可被 settings 动态覆盖)
--custom-radius: 8px;
// 页面布局间距
--page-padding: 20px;
--page-gap: 16px;
// 灰色阶
--gray-100: #f9fafb;
--gray-200: #f2f4f5;
--gray-300: #e6eaeb;
--gray-400: #dbdfe1;
--gray-500: #949eb7;
--gray-600: #7987a1;
--gray-700: #4d5875;
--gray-800: #383853;
--gray-900: #323251;
// 悬浮与激活
--hover-color: #edeff0;
--active-color: #f2f4f5;
}
// ============================================
// 深蓝色侧边栏主题
// ============================================
html.sidebar-color-blue {
--menu-background: #{$menu-bg-classic-blue};
--menu-text: #bfcbd9;
--menu-active-text: var(--el-menu-active-color);
// 自动派生:背景色加深 20% → #263445
--menu-hover: color-mix(in srgb, var(--menu-background) 80%, #000 20%);
--sidebar-logo-background: #2d3748;
--sidebar-logo-text-color: #fff;
}
// ============================================
// 暗黑主题
// ============================================
html.dark {
--menu-background: var(--el-bg-color-overlay);
--menu-text: #fff;
--menu-active-text: var(--el-menu-active-color);
// 半透明叠加层:自适应 Element Plus 动态 overlay 背景色
--menu-hover: rgb(0 0 0 / 20%);
--sidebar-logo-background: rgb(0 0 0 / 20%);
--sidebar-logo-text-color: #fff;
--page-bg: #070707;
--content-bg: #161618;
--border-color: rgb(255 255 255 / 10%);
--card-border: rgb(255 255 255 / 8%);
--card-shadow: 0 1px 3px 0 rgb(0 0 0 / 40%), 0 1px 2px -1px rgb(0 0 0 / 30%);
--gray-100: #110f0f;
--gray-200: #17171c;
--gray-300: #393946;
--gray-400: #505062;
--gray-500: #73738c;
--gray-600: #8f8fa3;
--gray-700: #ababba;
--gray-800: #c7c7d1;
--gray-900: #e3e3e8;
--hover-color: #252530;
--active-color: #202226;
.el-table {
--el-table-current-row-bg-color: var(--el-fill-color-light);
}
}
// ============================================
// 无障碍模式
// ============================================
html.color-weak {
filter: invert(80%);
}

View File

@@ -0,0 +1,355 @@
/**
* Element Plus 运行时样式覆盖
* 目标是把默认组件调成更轻、更清晰的中后台观感
*/
:root {
--el-border-radius-base: 6px;
--el-border-radius-small: 4px;
--el-font-weight-primary: 400;
--el-component-size: 34px;
--el-component-size-small: 28px;
--el-component-size-large: 38px;
--el-transition-duration: 0.16s;
--el-transition-duration-fast: 0.12s;
}
.el-card {
--el-card-border-color: var(--card-border);
--el-card-border-radius: var(--card-radius);
--el-card-padding: 18px;
overflow: hidden;
color: var(--el-text-color-primary);
border: 1px solid var(--card-border);
border-radius: var(--card-radius);
box-shadow: var(--card-shadow);
transition:
border-color 0.16s ease,
box-shadow 0.16s ease;
&.is-always-shadow,
&.is-hover-shadow:hover {
border-color: var(--card-border-hover);
box-shadow: var(--card-shadow-hover);
}
&.is-never-shadow {
box-shadow: var(--card-shadow);
}
.el-card__header {
padding: 14px 18px;
font-size: 14px;
font-weight: 500;
line-height: 1.4;
color: var(--el-text-color-primary);
border-bottom: 1px solid var(--card-border);
}
.el-card__body {
padding: var(--el-card-padding);
}
}
.el-input,
.el-select,
.el-date-editor {
--el-input-height: var(--el-component-size);
}
.el-input__wrapper,
.el-select__wrapper,
.el-textarea__inner {
background-color: var(--el-bg-color);
border-radius: 6px;
box-shadow: 0 0 0 1px var(--border-color) inset;
transition:
background-color 0.16s,
box-shadow 0.16s;
&:hover {
box-shadow: 0 0 0 1px var(--el-color-primary-light-5) inset;
}
&.is-focus,
&:focus,
&:focus-within {
box-shadow:
0 0 0 1px var(--el-color-primary) inset,
0 0 0 3px var(--el-color-primary-light-9);
}
}
.el-form--inline {
display: flex;
flex-wrap: wrap;
gap: 12px 16px;
align-items: center;
.el-form-item {
margin-right: 0;
margin-bottom: 0;
}
}
.el-form-item__label {
color: var(--el-text-color-regular);
}
.el-button {
--el-button-border-radius: 6px;
min-width: 34px;
font-weight: 400;
letter-spacing: 0;
box-shadow: none;
&:not(.is-link):not(.is-text) {
border-color: var(--border-color);
}
&:not(.is-link):not(.is-text):not(.el-button--large):not(.el-button--small) {
padding: 8px 14px;
}
&:focus-visible {
outline: none;
box-shadow: 0 0 0 3px var(--el-color-primary-light-9);
}
}
.el-button--default {
background: var(--el-bg-color);
&:hover:not(.is-disabled),
&:focus:not(.is-disabled) {
color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
border-color: var(--el-color-primary-light-5);
}
}
.el-button--primary,
.el-button--success,
.el-button--warning,
.el-button--danger {
border-color: transparent;
&:hover:not(.is-disabled) {
box-shadow: 0 6px 14px color-mix(in srgb, var(--el-button-bg-color) 28%, transparent);
filter: brightness(1.04);
}
}
.el-button.is-link {
min-width: auto;
padding: 0;
font-weight: 500;
}
.el-table {
--el-table-border-color: var(--border-color);
--el-table-header-bg-color: #f8fafc;
--el-table-row-hover-bg-color: #f8fbff;
--el-table-current-row-bg-color: #eef5ff;
--el-table-tr-bg-color: var(--el-bg-color);
overflow: hidden;
color: var(--el-text-color-primary);
border-radius: 6px;
&::before {
display: none;
}
&.el-table--border::after {
display: block;
background-color: var(--border-color);
}
.el-table__inner-wrapper::before {
height: 0;
}
.el-table__header th {
height: 44px;
padding: 0;
font-weight: 500;
color: var(--el-text-color-regular);
background: var(--el-table-header-bg-color);
border-bottom: 1px solid var(--border-color);
}
.el-table__body td {
height: 46px;
padding: 0;
border-bottom: 1px solid #eef1f6;
}
.cell {
line-height: 1.4;
}
.el-table__fixed-right {
box-shadow: -1px 0 0 var(--border-color);
}
&.is-empty {
.el-table__border-left-patch,
.el-table__border-right-patch,
.el-table__border-bottom-patch,
.el-table__fixed-right {
display: none;
}
.el-table__inner-wrapper::before,
&.el-table--border::before,
&.el-table--border::after,
&.el-table--border .el-table__inner-wrapper::after {
display: none;
}
.el-table__body td.el-table__cell {
border-right-color: transparent;
}
}
}
.el-table--striped .el-table__body tr.el-table__row--striped td {
background-color: #fbfcff;
}
.el-pagination {
--el-pagination-button-height: 32px;
--el-pagination-button-width: 32px;
gap: 6px;
font-weight: 400;
.btn-prev,
.btn-next,
.el-pager li {
border: 1px solid var(--border-color);
border-radius: 6px;
}
.el-pager li.is-active {
border-color: var(--el-color-primary);
box-shadow: 0 6px 14px rgb(22 93 255 / 18%);
}
}
.el-tag {
height: 24px;
padding: 0 8px;
font-weight: 400;
border: none;
border-radius: 4px;
}
.el-tree {
--el-tree-node-hover-bg-color: var(--el-color-primary-light-9);
color: var(--el-text-color-regular);
.el-tree-node__content {
height: 34px;
padding-right: 8px;
border-radius: 6px;
transition:
color 0.16s,
background-color 0.16s;
}
.el-tree-node:focus > .el-tree-node__content,
.el-tree-node.is-current > .el-tree-node__content {
color: var(--el-color-primary);
background-color: var(--el-color-primary-light-9);
}
}
.el-dialog {
overflow: hidden;
border-radius: 8px;
box-shadow: 0 18px 48px rgb(15 23 42 / 18%);
.el-dialog__header {
padding: 18px 20px 12px;
margin: 0;
border-bottom: 1px solid var(--border-color);
}
.el-dialog__body {
padding: 20px;
}
.el-dialog__footer {
padding: 14px 20px;
border-top: 1px solid var(--border-color);
}
}
.el-drawer {
.el-drawer__header {
padding: 16px 20px;
margin: 0;
color: var(--el-text-color-primary);
border-bottom: 1px solid var(--border-color);
}
.el-drawer__body {
padding: 20px;
}
.el-drawer__footer {
padding: 14px 16px;
border-top: 1px solid var(--border-color);
}
}
.el-dropdown-menu__item,
.el-select-dropdown__item {
margin: 2px 6px;
border-radius: 6px;
}
.el-menu--popup {
--el-menu-bg-color: var(--menu-background);
--el-menu-text-color: var(--menu-text);
--el-menu-active-color: var(--menu-active-text);
--el-menu-hover-bg-color: var(--menu-hover);
background-color: var(--menu-background);
border-radius: 8px;
.el-menu-item,
.el-sub-menu__title {
color: var(--menu-text) !important;
}
.el-menu-item:hover,
.el-sub-menu__title:hover {
color: var(--menu-active-text) !important;
background-color: var(--menu-hover) !important;
}
.el-menu-item.is-active {
color: var(--menu-active-text) !important;
background-color: var(--menu-hover) !important;
}
}
html.dark {
.el-table {
--el-table-header-bg-color: #171b22;
--el-table-row-hover-bg-color: rgb(22 93 255 / 10%);
--el-table-current-row-bg-color: rgb(22 93 255 / 14%);
.el-table__body td {
border-bottom-color: rgb(255 255 255 / 8%);
}
}
.el-table--striped .el-table__body tr.el-table__row--striped td {
background-color: rgb(255 255 255 / 2%);
}
}

View File

@@ -0,0 +1,30 @@
/**
* Element Plus Sass 主题变量配置
*
* 通过 vite.config.ts 的 additionalData 注入,必须在 Element Plus 组件 Sass
* 样式加载前生效。
*/
@forward "element-plus/theme-chalk/src/common/var.scss" with (
$colors: (
"primary": (
// 与 src/settings.ts 的默认色板保持一致,避免首屏出现两套颜色。
"base": #165dff,
),
"success": (
"base": #00b42a,
),
"warning": (
"base": #ff7d00,
),
"danger": (
"base": #f53f3f,
),
"info": (
"base": #86909c,
),
),
$bg-color: (
"page": #f5f7fb,
)
);

View File

@@ -1,19 +1,15 @@
/**
* 样式总入口
* 严格按照 ITCSS 架构顺序导入
*/
// 1. 基础层
@use "./base/variables" as *;
@use "./base/theme";
@use "./base/reset";
// 1. 基础与主题层
@use "./theme";
@use "./reset";
// 2. 第三方适配层
@use "./vendors/element-plus";
@use "./vendors/element-plus-override";
@use "./vendors/vxe-table";
@use "./vendors/wangeditor";
@use "./vendors/nprogress";
@use "./element-plus-overrides";
@use "./vendors";
// 3. 布局层
@use "./layout/layout";
// 3. 布局与页面层
@use "./menu";
@use "./page";

View File

@@ -1,57 +1,8 @@
/**
* 全局标准布局样式
* 菜单交互样式
*/
.page-container {
padding: var(--page-padding);
}
.page-aside {
margin-bottom: var(--page-gap);
@media (min-width: 992px) {
margin-bottom: 0;
}
}
.page-main {
display: flex;
flex-direction: column;
gap: var(--page-gap);
}
.page-search {
margin-bottom: var(--page-gap);
.el-card__body {
padding: 14px 16px 4px;
}
.el-form-item {
margin-bottom: 10px;
}
}
.page-content {
display: flex;
flex: 1;
flex-direction: column;
}
.page-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--page-gap);
&__left,
&__right {
display: flex;
gap: 8px;
}
}
/* 混合布局左侧菜单 hover 样式 */
// 混合布局左侧菜单 hover
.layout-mix .layout__sidebar--left .el-menu {
.el-menu-item:hover,
.el-sub-menu__title:hover {
@@ -59,16 +10,23 @@
}
}
/* 深色或深蓝侧边栏的 hover 样式 */
// 深色或深蓝侧边栏的 hover
html.dark .layout-mix .layout__sidebar--left .el-menu,
html.sidebar-color-blue .layout-mix .layout__sidebar--left .el-menu {
.el-menu-item:hover,
.el-sub-menu__title:hover {
background-color: var(--menu-hover) !important;
}
.el-menu-item.is-active,
.el-sub-menu.is-active > .el-sub-menu__title,
.el-sub-menu.has-active-child > .el-sub-menu__title {
color: var(--menu-active-text) !important;
background-color: var(--menu-active-bg) !important;
}
}
/* 顶部布局菜单填充空间 */
// 顶部布局菜单填充空间
.layout-top .layout__header-left .el-menu--horizontal,
.layout-mix .layout__header-menu .el-menu--horizontal {
display: flex;
@@ -79,7 +37,7 @@ html.sidebar-color-blue .layout-mix .layout__sidebar--left .el-menu {
overflow: hidden;
}
/* 窄屏隐藏菜单文字,仅保留图标 */
// 窄屏隐藏菜单文字,仅保留图标
.hideSidebar {
&.layout-top .layout__header .el-menu--horizontal,
&.layout-mix .layout__header .el-menu--horizontal {

21
src/styles/mixins.scss Normal file
View File

@@ -0,0 +1,21 @@
/**
* 布局 Mixins
*/
@use "variables" as *;
@mixin app-main-height-with-tags {
height: calc(100vh - $navbar-height - $tags-view-height) !important;
}
@mixin app-main-height {
height: calc(100vh - $navbar-height);
}
@mixin sidebar-scroll-height-with-logo {
height: calc(100vh - $navbar-height);
}
@mixin sidebar-scroll-height-with-toggle {
height: calc(100vh - $navbar-height - $sidebar-toggle-height);
}

111
src/styles/page.scss Normal file
View File

@@ -0,0 +1,111 @@
/**
* 页面公共样式
*/
.page-container {
display: flex;
flex-direction: column;
gap: var(--page-gap);
height: 100%;
min-height: 0;
padding: var(--page-padding);
background: var(--page-bg);
}
.page-search {
flex-shrink: 0;
.el-card__body {
padding: 12px 16px;
}
.el-form-item {
margin-bottom: 0;
}
}
.page-content {
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-height: 0;
overflow: hidden;
.el-card__body {
display: grid;
flex: 1 1 auto;
grid-template-rows: auto minmax(0, 1fr) auto;
min-height: 0;
padding: 12px 16px;
overflow: hidden;
}
}
.page-toolbar {
display: flex;
flex-shrink: 0;
gap: 12px;
align-items: center;
justify-content: space-between;
margin-bottom: var(--page-gap);
&__left,
&__right {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
.el-button {
margin: 0;
}
}
&__right {
justify-content: flex-end;
margin-left: auto;
}
}
.page-table-wrapper {
min-height: 0;
overflow: hidden;
background: var(--content-bg);
border: 1px solid var(--border-color);
border-radius: var(--card-radius);
}
.page-table {
width: 100%;
height: 100%;
min-height: 0;
border-radius: 0;
&.el-table--border {
border: 0;
}
.el-table__inner-wrapper {
height: 100%;
}
}
.page-pagination {
flex-shrink: 0;
}
.page-icon-btn {
width: 32px;
height: 32px;
padding: 0;
&.el-button + .el-button {
margin-left: 0;
}
}
.dialog-footer {
display: flex;
gap: 8px;
justify-content: flex-end;
}

45
src/styles/reset.scss Normal file
View File

@@ -0,0 +1,45 @@
html,
body,
#app {
width: 100%;
height: 100%;
}
html {
box-sizing: border-box;
line-height: 1.5;
}
*,
*::before,
*::after {
box-sizing: inherit;
}
body {
margin: 0;
font-family:
Inter, system-ui, Avenir, "Helvetica Neue", Helvetica, "PingFang SC", "Hiragino Sans GB",
"Microsoft YaHei", Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-thumb {
background-color: rgb(0 0 0 / 12%);
border-radius: 3px;
}
::-webkit-scrollbar-track {
background-color: transparent;
}
a {
color: inherit;
text-decoration: none;
}

62
src/styles/theme.scss Normal file
View File

@@ -0,0 +1,62 @@
/**
* 主题变量定义
*/
// 浅色主题
:root {
--menu-background: #fff;
--menu-text: #606266;
--menu-active-text: var(--el-menu-active-color);
--menu-active-bg: #e8f3ff;
--menu-hover: #e6f4ff;
--menu-border: var(--card-border);
--sidebar-logo-background: #fafafa;
--sidebar-logo-text-color: #383853;
--page-bg: #f6f8fc;
--content-bg: #ffffff;
--border-color: #e3eaf3;
--card-border: #dfe7f1;
--card-border-hover: #d5dfec;
--card-radius: 8px;
--card-shadow: 0 1px 2px rgb(15 23 42 / 3%);
--card-shadow-hover: 0 10px 24px rgb(15 23 42 / 6%), 0 2px 6px rgb(15 23 42 / 4%);
--custom-radius: 8px;
--page-padding: 10px;
--page-gap: 10px;
}
// 深蓝色侧边栏
html.sidebar-color-blue {
--menu-background: #304156;
--menu-text: #bfcbd9;
--menu-active-text: #ffffff;
--menu-active-bg: rgb(22 93 255 / 18%);
--menu-hover: rgb(255 255 255 / 7%);
--menu-border: rgb(15 23 42 / 22%);
--sidebar-logo-background: #2d3748;
--sidebar-logo-text-color: #fff;
}
// 暗黑主题
html.dark {
--menu-background: var(--el-bg-color-overlay);
--menu-text: #fff;
--menu-active-text: #ffffff;
--menu-active-bg: rgb(22 93 255 / 16%);
--menu-hover: rgb(0 0 0 / 20%);
--menu-border: rgb(255 255 255 / 8%);
--sidebar-logo-background: rgb(0 0 0 / 20%);
--sidebar-logo-text-color: #fff;
--page-bg: #0d1117;
--content-bg: #161618;
--border-color: rgb(255 255 255 / 10%);
--card-border: rgb(255 255 255 / 10%);
--card-border-hover: rgb(255 255 255 / 14%);
--card-shadow: none;
--card-shadow-hover: none;
}
// 无障碍模式
html.color-weak {
filter: invert(80%);
}

View File

@@ -1,18 +1,14 @@
/* stylelint-disable property-no-unknown */
// SCSS 变量导出给 JS/TS
// 通过 SCSS 变量导出给 JS/TS 使用的模块文件
// 注意:依赖 src/styles/base/_variables.scss 中定义的 SCSS 变量
@use "./base/variables" as *;
@use "./variables" as *;
:export {
sidebar-width: $sidebar-width;
navbar-height: $navbar-height;
tags-view-height: $tags-view-height;
menu-background: $menu-background;
menu-text: $menu-text;
menu-active-text: $menu-active-text;
menu-hover: $menu-hover;
}
/* stylelint-enable property-no-unknown */

View File

@@ -1,22 +1,17 @@
/**
* SCSS 变量定义
* 只放数学变量和 CSS 变量映射
*/
// ============================================
// 布局尺寸变量
// ============================================
// 布局尺寸
$sidebar-width: 210px;
$sidebar-width-collapsed: 54px;
$navbar-height: 50px;
$tags-view-height: 40px;
$sidebar-primary-width: 76px;
$sidebar-secondary-width: 210px;
$navbar-height: 48px;
$tags-view-height: 38px;
$sidebar-toggle-height: 50px;
// ============================================
// CSS 变量映射(供组件和 SCSS 使用)
// ============================================
// 菜单配色(映射 theme.scss 的 CSS 变量)
$menu-background: var(--menu-background);
$menu-text: var(--menu-text);
$menu-active-text: var(--menu-active-text);

View File

@@ -1,12 +1,14 @@
/**
* Vxe Table 主题统一:
* 1) 用 Element Plus 的 CSS 变量覆写 Vxe Table 的 CSS 变量
* 2) 自定义局部样式
* 第三方组件样式适配
*/
/* 变量覆写 */
// NProgress 进度条样式
#nprogress .bar {
background-color: var(--el-color-primary);
}
// VxeTable 变量覆写
:root {
/* color */
--vxe-font-color: var(--el-text-color-regular);
--vxe-primary-color: var(--el-color-primary);
--vxe-success-color: var(--el-color-success);
@@ -31,17 +33,11 @@
--vxe-info-disabled-color: var(--el-color-info-light-5);
--vxe-warning-disabled-color: var(--el-color-warning-light-5);
--vxe-danger-disabled-color: var(--el-color-danger-light-5);
/* input/radio/checkbox */
--vxe-input-border-color: var(--el-border-color);
--vxe-input-disabled-color: var(--el-text-color-disabled);
--vxe-input-disabled-background-color: var(--el-fill-color-light);
--vxe-input-placeholder-color: var(--el-text-color-placeholder);
/* popup */
--vxe-table-popup-border-color: var(--el-border-color);
/* table */
--vxe-table-header-font-color: var(--el-text-color-regular);
--vxe-table-footer-font-color: var(--el-text-color-regular);
--vxe-table-border-color: var(--el-border-color-lighter);
@@ -52,81 +48,71 @@
--vxe-table-row-current-background-color: var(--el-fill-color-light);
--vxe-table-row-hover-current-background-color: var(--el-fill-color-light);
--vxe-table-checkbox-range-background-color: var(--el-fill-color-light);
/* menu */
--vxe-table-menu-background-color: var(--el-bg-color-overlay);
/* loading */
--vxe-loading-color: var(--el-color-primary);
--vxe-loading-background-color: var(--el-mask-color);
/* validate */
--vxe-table-validate-error-color: var(--el-color-danger);
/* toolbar */
--vxe-toolbar-background-color: var(--el-bg-color);
--vxe-toolbar-custom-active-background-color: var(--el-bg-color-overlay);
--vxe-toolbar-panel-background-color: var(--el-bg-color-overlay);
/* pager */
--vxe-pager-background-color: var(--el-bg-color);
/* modal */
--vxe-modal-header-background-color: var(--el-bg-color);
--vxe-modal-body-background-color: var(--el-bg-color);
--vxe-modal-border-color: var(--el-border-color);
/* button */
--vxe-button-default-background-color: var(--el-bg-color-overlay);
/* input */
--vxe-input-background-color: var(--el-fill-color-blank);
--vxe-input-panel-background-color: var(--el-fill-color-blank);
/* form */
--vxe-form-background-color: var(--el-bg-color);
--vxe-form-validate-error-color: var(--el-color-danger);
/* select */
--vxe-select-option-hover-background-color: var(--el-bg-color-overlay);
--vxe-select-panel-background-color: var(--el-bg-color);
}
/* 自定义组件样式 */
// VxeTable 自定义样式
.vxe-grid {
/* 表单 */
&--form-wrapper {
.vxe-form {
padding: 10px 20px;
margin-bottom: 20px;
}
&--form-wrapper .vxe-form {
padding: 10px 20px;
margin-bottom: 20px;
}
/* 工具栏 */
&--toolbar-wrapper {
.vxe-toolbar {
padding: 20px;
}
&--toolbar-wrapper .vxe-toolbar {
padding: 20px;
}
/* 分页 */
&--pager-wrapper {
.vxe-pager {
height: 70px;
padding: 0 20px;
&--pager-wrapper .vxe-pager {
height: 70px;
padding: 0 20px;
&--wrapper {
/* 参考 Bootstrap 的响应式设计 WIDTH = 768 */
@media screen and (width <= 768px) {
.vxe-pager--total,
.vxe-pager--sizes,
.vxe-pager--jump,
.vxe-pager--jump-prev,
.vxe-pager--jump-next {
display: none;
}
&--wrapper {
@media screen and (width <= 768px) {
.vxe-pager--total,
.vxe-pager--sizes,
.vxe-pager--jump,
.vxe-pager--jump-prev,
.vxe-pager--jump-next {
display: none;
}
}
}
}
}
// WangEditor 暗黑模式
html.dark {
--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);
--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);
--w-e-modal-button-bg-color: var(--el-bg-color-overlay);
--w-e-modal-button-border-color: var(--el-border-color-light);
}

View File

@@ -1,186 +0,0 @@
/**
* Element Plus 组件样式定制
*/
$border: 1px solid var(--border-color);
/* 全局组件圆角基准 */
:root {
--el-border-radius-base: 6px;
--el-border-radius-small: 4px;
--el-font-weight-primary: 400;
}
/* 卡片 — 边框模式 */
.el-card {
border: 1px solid var(--card-border);
border-radius: var(--card-radius);
box-shadow: var(--card-shadow);
transition: box-shadow 0.2s ease;
&:hover {
box-shadow:
0 4px 12px 0 rgb(0 0 0 / 8%),
0 1px 3px 0 rgb(0 0 0 / 4%);
}
.el-card__header {
padding: 16px 20px;
border-bottom: 1px solid var(--card-border);
}
.el-card__body {
padding: 20px;
}
}
/* 弹窗 */
.el-dialog {
border-radius: 12px;
.el-dialog__header {
padding: 20px 20px 0;
margin: 0;
border-bottom: none;
}
.el-dialog__body {
padding: 20px;
}
.el-dialog__footer {
padding: 0 20px 20px;
border-top: none;
}
}
/* 抽屉 */
.el-drawer {
.el-drawer__header {
padding: 16px 20px;
margin: 0;
color: inherit;
border-bottom: $border;
}
.el-drawer__body {
padding: 20px;
}
.el-drawer__footer {
padding: 16px;
border-top: $border;
}
}
/* el-table */
.el-table {
--el-table-border-color: var(--border-color);
--el-table-header-bg-color: var(--gray-100);
--el-table-row-hover-bg-color: var(--el-fill-color-lighter);
--el-table-current-row-bg-color: var(--el-fill-color-light);
border-radius: var(--custom-radius);
/* 表头 */
.el-table__header th {
padding: 12px 0;
font-weight: 500;
color: var(--el-text-color-regular);
border-bottom: 1px solid var(--border-color);
}
/* 单元格 */
.el-table__body td {
padding: 10px 0;
}
/* 无数据时不显示底部边框 */
.el-table__empty-block {
border-bottom: none;
}
}
/* 斑马纹 — 更柔和 */
.el-table--striped .el-table__body tr.el-table__row--striped td {
background-color: rgb(0 0 0 / 1.5%);
}
html.dark .el-table--striped .el-table__body tr.el-table__row--striped td {
background-color: rgb(255 255 255 / 2%);
}
/* 下拉菜单项 */
.el-dropdown-menu__item,
.el-select-dropdown__item {
margin: 2px 6px;
border-radius: 6px;
}
/* 按钮 */
.el-button {
--el-button-border-radius: var(--el-border-radius-base);
letter-spacing: 0.01em;
&:not(.is-link):not(.el-button--large):not(.el-button--small) {
padding: 9px 16px;
}
}
.el-button--default {
--el-button-border-color: var(--border-color);
&:hover:not(.is-disabled) {
color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
border-color: var(--el-color-primary-light-5);
}
}
.el-button--primary,
.el-button--success,
.el-button--warning,
.el-button--danger {
&:hover:not(.is-disabled) {
box-shadow: 0 2px 6px 0 var(--el-button-bg-color);
}
}
/* 标签 */
.el-tag {
border-radius: 4px;
}
/* 抽屉和对话框底部按钮区域 */
.dialog-footer {
display: flex;
gap: 8px;
justify-content: flex-end;
}
/* 弹出菜单统一使用主题变量 */
.el-menu--popup {
--el-menu-bg-color: var(--menu-background);
--el-menu-text-color: var(--menu-text);
--el-menu-active-color: var(--menu-active-text);
--el-menu-hover-bg-color: var(--menu-hover);
background-color: var(--menu-background);
border-radius: 8px;
.el-menu-item,
.el-sub-menu__title {
color: var(--menu-text) !important;
}
.el-menu-item:hover,
.el-sub-menu__title:hover {
color: var(--menu-active-text) !important;
background-color: var(--menu-hover) !important;
}
.el-menu-item.is-active {
color: var(--menu-active-text) !important;
background-color: var(--menu-hover) !important;
}
}

View File

@@ -1,28 +0,0 @@
/**
* Element Plus 主题变量覆盖
* 必须在组件样式之前导入
*/
@forward "element-plus/theme-chalk/src/common/var.scss" with (
$colors: (
"primary": (
// 默认主题色 - 修改此值时需同步修改 src/settings.ts 中的 themeColor
"base": #1677ff,
),
"success": (
"base": #22c55e,
),
"warning": (
"base": #faad14,
),
"danger": (
"base": #ff4d4f,
),
"info": (
"base": #788896,
),
),
$bg-color: (
"page": #f5f8fd,
)
);

View File

@@ -1,7 +0,0 @@
/**
* NProgress 进度条样式
*/
#nprogress .bar {
background-color: var(--el-color-primary);
}

View File

@@ -1,28 +0,0 @@
/**
* 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);
}

11
src/types/global.d.ts vendored
View File

@@ -1,11 +0,0 @@
/**
* 全局类型声明
*
* @deprecated 请使用 @/types 下的具名导出
*/
declare global {
type TagView = import("@/types/ui").TagView;
type AppSettings = import("@/types/ui").AppSettings;
}
export {};

View File

@@ -1,5 +0,0 @@
/**
* 类型统一导出
*/
export * from "./ui";

View File

@@ -1,4 +0,0 @@
declare module "*.scss" {
const classes: Record<string, string>;
export default classes;
}

View File

@@ -1,6 +0,0 @@
/**
* UI 类型统一导出
*/
export * from "./tagsview";
export * from "./settings";

View File

@@ -1,21 +0,0 @@
/**
* 应用设置相关类型定义
*/
import type { ThemeMode } from "@/enums";
export interface AppSettings {
title: string;
version: string;
showSettings: boolean;
showTagsView: boolean;
showAppLogo: boolean;
layout: "left" | "top" | "mix";
themeColor: string;
theme: ThemeMode;
size: string;
language: string;
showWatermark: boolean;
watermarkContent: string;
sidebarColorScheme: "classic-blue" | "minimal-white";
}

View File

@@ -1,16 +0,0 @@
import type { LocationQuery } from "vue-router";
/**
* 标签页相关类型定义
*/
export interface TagView {
name: string;
title: string;
path: string;
fullPath: string;
icon?: string;
affix?: boolean;
keepAlive?: boolean;
query?: LocationQuery;
}

View File

@@ -44,7 +44,7 @@ function extractFileName(contentDisposition: string): string {
* downloadFile(response, "用户列表.xlsx");
* ```
*/
export function downloadFile(response: { data: any; headers: Record<string, string> }, customFileName?: string): void {
export function downloadFile(response: { data: any; headers: any }, customFileName?: string): void {
try {
const fileData = response.data;
const contentDisposition = response.headers["content-disposition"];

View File

@@ -4,6 +4,7 @@ import { ApiCodeEnum } from "@/enums/api";
import { useUserStoreHook } from "@/stores/user";
import { usePermissionStoreHook } from "@/stores/permission";
import { AuthStorage, redirectToLogin } from "@/utils/auth";
import type { ApiResult } from "@/api/common";
// 记录已重试的请求,防止无限循环
const retriedConfigs = new WeakSet<InternalAxiosRequestConfig>();
@@ -34,7 +35,7 @@ http.interceptors.request.use(
// 响应拦截器
http.interceptors.response.use(
(response: AxiosResponse<ApiResponse>): any => {
(response: AxiosResponse<ApiResult>): any => {
const { responseType } = response.config;
// 二进制数据直接返回
@@ -60,7 +61,7 @@ http.interceptors.response.use(
return Promise.reject(error);
}
const { code, msg } = response.data as ApiResponse;
const { code, msg } = response.data as ApiResult;
// Token 过期:尝试刷新 token 后自动重试一次
if (code === ApiCodeEnum.ACCESS_TOKEN_INVALID) {

View File

@@ -1,29 +1,18 @@
import { ThemeMode } from "@/enums";
import { themeColorNames } from "@/settings";
import type { ThemeColorMap, ThemeColorName } from "@/settings";
const SYSTEM_DARK_MEDIA = "(prefers-color-scheme: dark)";
// 辅助函数:将十六进制颜色转换为 RGB
function hexToRgb(hex: string): [number, number, number] {
const bigint = parseInt(hex.slice(1), 16);
return [(bigint >> 16) & 255, (bigint >> 8) & 255, bigint & 255];
}
// 辅助函数:将 RGB 转换为十六进制颜色
function rgbToHex(r: number, g: number, b: number): string {
return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
}
// 辅助函数:调整颜色亮度
/** function adjustBrightness(hex: string, factor: number, theme: string): string {
const rgb = hexToRgb(hex);
// 是否是暗黑模式
const isDarkMode = theme === "dark" ? 0 : 255;
const newRgb = rgb.map((val) =>
Math.max(0, Math.min(255, Math.round(val + (isDarkMode - val) * factor)))
) as [number, number, number];
return rgbToHex(...newRgb);
} */
/**
* 加深颜色值
* @param {String} color 颜色值字符串
@@ -39,7 +28,7 @@ export function getDarkColor(color: string, level: number): string {
/**
* 变浅颜色值
* @param {String} color 颜色值字符串
* @param {Number} level 加深的程度,限0-1之间
* @param {Number} level 变浅的程度,限0-1之间
* @returns {String} 返回处理后的颜色值
*/
export const getLightColor = (color: string, level: number): string => {
@@ -49,39 +38,17 @@ export const getLightColor = (color: string, level: number): string => {
};
/**
* 生成主题色
* @param primary 主题色
* @param theme 主题类型
* Element Plus 运行时需要 base、light-1..9 和 dark-2。
* 这里从完整颜色方案一次性生成,避免主色和功能色来自不同体系。
*/
export function generateThemeColors(primary: string, theme: ThemeMode) {
export function generateThemeColors(palette: ThemeColorMap, theme: ThemeMode) {
const resolvedTheme = resolveThemeMode(theme);
const colors: Record<string, string> = {
primary,
};
const colors: Record<string, string> = {};
// 生成 primary 浅色/深色变体
for (let i = 1; i <= 9; i++) {
colors[`primary-light-${i}`] =
resolvedTheme === ThemeMode.LIGHT
? `${getLightColor(primary, i / 10)}`
: `${getDarkColor(primary, i / 10)}`;
}
colors["primary-dark-2"] =
resolvedTheme === ThemeMode.LIGHT
? `${getLightColor(primary, 0.2)}`
: `${getDarkColor(primary, 0.3)}`;
// 语义色(success / warning / danger / info)
const semanticColors: Record<string, string> = {
success: "#22c55e",
warning: "#faad14",
danger: "#ff4d4f",
info: "#788896",
};
Object.entries(semanticColors).forEach(([name, base]) => {
themeColorNames.forEach((name: ThemeColorName) => {
const base = palette[name];
colors[name] = base;
for (let i = 1; i <= 9; i++) {
colors[`${name}-light-${i}`] =
resolvedTheme === ThemeMode.LIGHT
@@ -123,9 +90,8 @@ export function applyTheme(colors: Record<string, string>) {
el.style.setProperty(`--el-color-${key}`, value);
});
// 确保主题色立即生效,强制重新渲染
requestAnimationFrame(() => {
// 触发样式重新计算
// 给依赖 CSS 变量的组件一次明确的样式刷新信号。
el.style.setProperty("--theme-update-trigger", Date.now().toString());
});
}
@@ -148,8 +114,8 @@ export function toggleDarkMode(isDark: boolean) {
*
* @param isBlue 布尔值,表示是否开启深蓝色侧边栏颜色方案
*/
export function toggleSidebarColor(isBuleSidebar: boolean) {
if (isBuleSidebar) {
export function toggleSidebarColor(isBlueSidebar: boolean) {
if (isBlueSidebar) {
document.documentElement.classList.add("sidebar-color-blue");
} else {
document.documentElement.classList.remove("sidebar-color-blue");

View File

@@ -166,6 +166,7 @@
<script setup lang="ts">
import type { GenConfigForm } from "@/api/codegen";
import type { OptionItem } from "@/api/common";
const formData = defineModel<GenConfigForm>({ required: true });

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