feat: v4.0
@ -16,7 +16,7 @@ module.exports = {
|
|||||||
footerPrefixsSelect: '选择关联issue前缀(可选):',
|
footerPrefixsSelect: '选择关联issue前缀(可选):',
|
||||||
customFooterPrefixs: '输入自定义issue前缀 :',
|
customFooterPrefixs: '输入自定义issue前缀 :',
|
||||||
footer: '列举关联issue (可选) 例如: #31, #I3244 :\n',
|
footer: '列举关联issue (可选) 例如: #31, #I3244 :\n',
|
||||||
confirmCommit: '是否提交或修改commit ?'
|
confirmCommit: '是否提交或修改commit ?',
|
||||||
},
|
},
|
||||||
types: [
|
types: [
|
||||||
{ value: 'feat', name: 'feat: ✨ 新增功能 | A new feature', emoji: ':sparkles:' },
|
{ value: 'feat', name: 'feat: ✨ 新增功能 | A new feature', emoji: ':sparkles:' },
|
||||||
@ -49,7 +49,7 @@ module.exports = {
|
|||||||
issuePrefixs: [
|
issuePrefixs: [
|
||||||
// 如果使用 gitee 作为开发管理
|
// 如果使用 gitee 作为开发管理
|
||||||
{ value: 'link', name: 'link: 链接 ISSUES 进行中' },
|
{ value: 'link', name: 'link: 链接 ISSUES 进行中' },
|
||||||
{ value: 'closed', name: 'closed: 标记 ISSUES 已完成' }
|
{ value: 'closed', name: 'closed: 标记 ISSUES 已完成' },
|
||||||
],
|
],
|
||||||
customIssuePrefixsAlign: 'top',
|
customIssuePrefixsAlign: 'top',
|
||||||
emptyIssuePrefixsAlias: 'skip',
|
emptyIssuePrefixsAlias: 'skip',
|
||||||
@ -57,13 +57,13 @@ module.exports = {
|
|||||||
allowCustomIssuePrefixs: true,
|
allowCustomIssuePrefixs: true,
|
||||||
allowEmptyIssuePrefixs: true,
|
allowEmptyIssuePrefixs: true,
|
||||||
confirmColorize: true,
|
confirmColorize: true,
|
||||||
maxHeaderLength: Infinity,
|
maxHeaderLength: Number.POSITIVE_INFINITY,
|
||||||
maxSubjectLength: Infinity,
|
maxSubjectLength: Number.POSITIVE_INFINITY,
|
||||||
minSubjectLength: 0,
|
minSubjectLength: 0,
|
||||||
scopeOverrides: undefined,
|
scopeOverrides: undefined,
|
||||||
defaultBody: '',
|
defaultBody: '',
|
||||||
defaultIssues: '',
|
defaultIssues: '',
|
||||||
defaultScope: '',
|
defaultScope: '',
|
||||||
defaultSubject: ''
|
defaultSubject: '',
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
|
0
.editorconfig
Normal file → Executable file
0
.env.development
Normal file → Executable file
@ -1,2 +0,0 @@
|
|||||||
dist
|
|
||||||
node_modules
|
|
10
.eslintrc
@ -1,10 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "@antfu",
|
|
||||||
"rules": {
|
|
||||||
"eslint-comments/no-unlimited-disable": "off",
|
|
||||||
"curly": ["error", "all"],
|
|
||||||
"vue/component-tags-order": ["error", {
|
|
||||||
"order": ["route", "script", "template", "style"]
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
}
|
|
4
.github/workflows/release.yml
vendored
@ -40,7 +40,7 @@ jobs:
|
|||||||
- name: Create Archive
|
- name: Create Archive
|
||||||
uses: thedoctor0/zip-release@main
|
uses: thedoctor0/zip-release@main
|
||||||
with:
|
with:
|
||||||
type: 'zip'
|
type: zip
|
||||||
filename: fantastic-admin.${{ steps.last_release.outputs.tag_name }}.zip
|
filename: fantastic-admin.${{ steps.last_release.outputs.tag_name }}.zip
|
||||||
exclusions: '/.git/* /.github/*'
|
exclusions: '/.git/* /.github/*'
|
||||||
|
|
||||||
@ -73,7 +73,7 @@ jobs:
|
|||||||
- name: Create Archive
|
- name: Create Archive
|
||||||
uses: thedoctor0/zip-release@main
|
uses: thedoctor0/zip-release@main
|
||||||
with:
|
with:
|
||||||
type: 'zip'
|
type: zip
|
||||||
filename: fantastic-admin.example.${{ steps.last_release.outputs.tag_name }}.zip
|
filename: fantastic-admin.example.${{ steps.last_release.outputs.tag_name }}.zip
|
||||||
exclusions: '/.git/* /.github/*'
|
exclusions: '/.git/* /.github/*'
|
||||||
|
|
||||||
|
3
.gitignore
vendored
Normal file → Executable file
@ -5,6 +5,3 @@ dist-ssr
|
|||||||
*.local
|
*.local
|
||||||
.eslintcache
|
.eslintcache
|
||||||
.stylelintcache
|
.stylelintcache
|
||||||
src/assets/sprites/*.*
|
|
||||||
!src/assets/sprites/.gitkeep
|
|
||||||
public/icons
|
|
||||||
|
2
.lintstagedrc
Normal file → Executable file
@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"*.{ts,tsx,vue}": "eslint --cache --fix --no-ignore --no-error-on-unmatched-pattern --ext .ts,.tsx,.vue src/",
|
"*.{ts,tsx,vue}": "eslint . --cache --fix",
|
||||||
"*.{css,scss,vue}": "stylelint src/**/*.{css,scss,vue} --cache --fix --allow-empty-input"
|
"*.{css,scss,vue}": "stylelint src/**/*.{css,scss,vue} --cache --fix --allow-empty-input"
|
||||||
}
|
}
|
||||||
|
1
.stylelintignore
Normal file → Executable file
@ -1,3 +1,4 @@
|
|||||||
dist
|
dist
|
||||||
node_modules
|
node_modules
|
||||||
src/assets/sprites
|
src/assets/sprites
|
||||||
|
public/tinymce
|
||||||
|
19
.stylelintrc
Normal file → Executable file
@ -20,6 +20,25 @@
|
|||||||
"property-no-unknown": null,
|
"property-no-unknown": null,
|
||||||
"font-family-no-missing-generic-family-keyword": null,
|
"font-family-no-missing-generic-family-keyword": null,
|
||||||
"selector-class-pattern": null,
|
"selector-class-pattern": null,
|
||||||
|
"function-no-unknown": [
|
||||||
|
true,
|
||||||
|
{
|
||||||
|
"ignoreFunctions": [
|
||||||
|
"v-bind",
|
||||||
|
"map-get",
|
||||||
|
"lighten",
|
||||||
|
"darken"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"selector-pseudo-element-no-unknown": [
|
||||||
|
true,
|
||||||
|
{
|
||||||
|
"ignorePseudoElements": [
|
||||||
|
"/^view-transition/"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
"scss/double-slash-comment-empty-line-before": null,
|
"scss/double-slash-comment-empty-line-before": null,
|
||||||
"scss/no-global-function-names": null
|
"scss/no-global-function-names": null
|
||||||
}
|
}
|
||||||
|
2
.vscode/extensions.json
vendored
Normal file → Executable file
@ -5,6 +5,6 @@
|
|||||||
"dbaeumer.vscode-eslint",
|
"dbaeumer.vscode-eslint",
|
||||||
"stylelint.vscode-stylelint",
|
"stylelint.vscode-stylelint",
|
||||||
"Vue.volar",
|
"Vue.volar",
|
||||||
"Vue.vscode-typescript-vue-plugin",
|
"antfu.unocss"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
24
.vscode/settings.json
vendored
Normal file → Executable file
@ -1,7 +1,27 @@
|
|||||||
{
|
{
|
||||||
|
"eslint.experimental.useFlatConfig": true,
|
||||||
|
"prettier.enable": false,
|
||||||
|
"editor.formatOnSave": false,
|
||||||
"editor.codeActionsOnSave": {
|
"editor.codeActionsOnSave": {
|
||||||
"source.fixAll.eslint": true,
|
"source.fixAll.eslint": true,
|
||||||
"source.fixAll.stylelint": true
|
"source.fixAll.stylelint": true,
|
||||||
|
"source.organizeImports": false
|
||||||
},
|
},
|
||||||
"stylelint.validate": ["css", "scss", "vue"]
|
"stylelint.validate": [
|
||||||
|
"css",
|
||||||
|
"scss",
|
||||||
|
"vue"
|
||||||
|
],
|
||||||
|
"eslint.validate": [
|
||||||
|
"javascript",
|
||||||
|
"javascriptreact",
|
||||||
|
"typescript",
|
||||||
|
"typescriptreact",
|
||||||
|
"vue",
|
||||||
|
"html",
|
||||||
|
"markdown",
|
||||||
|
"json",
|
||||||
|
"jsonc",
|
||||||
|
"yaml"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
2
LICENSE
@ -1,6 +1,6 @@
|
|||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
Copyright (c) 2020 fantastic-template
|
Copyright (c) 2020 fantastic-admin
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
25
README.md
@ -4,19 +4,19 @@
|
|||||||
|
|
||||||
<h1 align="center">Fantastic-admin</h1>
|
<h1 align="center">Fantastic-admin</h1>
|
||||||
|
|
||||||
<p align="center">一款<b>开箱即用</b>的 Vue 中后台管理系统框架</p>
|
<p align="center">一款<b>开箱即用</b>的 Vue3 中后台管理系统框架</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://fantastic-admin.gitee.io" target="_blank">官网</a>
|
<a href="https://fantastic-admin.gitee.io" target="_blank">官网</a>
|
||||||
( <a href="https://fantastic-admin.github.io" target="_blank">备用</a> )
|
<sup><a href="https://fantastic-admin.github.io" target="_blank">备用地址</a></sup>
|
||||||
<span> | </span>
|
|
||||||
演示
|
|
||||||
( <a href="https://fantastic-admin.gitee.io/basic-example" target="_blank">基础版</a>
|
|
||||||
<a href="https://fantastic-admin.gitee.io/pro-example" target="_blank">专业版</a> )
|
|
||||||
<span> | </span>
|
<span> | </span>
|
||||||
<a href="https://fantastic-admin.gitee.io/support.html" target="_blank">技术支持</a>
|
<a href="https://fantastic-admin.gitee.io/support.html" target="_blank">技术支持</a>
|
||||||
<span> | </span>
|
<span> | </span>
|
||||||
<a href="https://fantastic-admin.gitee.io/buy.html" target="_blank">购买</a>
|
演示
|
||||||
|
<sup><a href="https://fantastic-admin.gitee.io/basic-example" target="_blank">基础版</a> <a href="https://fantastic-admin.gitee.io/pro-example" target="_blank">专业版</a></sup>
|
||||||
|
<span> | </span>
|
||||||
|
购买
|
||||||
|
<sup><a href="https://fantastic-admin.gitee.io/buy.html" target="_blank">专业版</a></sup>
|
||||||
<p>
|
<p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
@ -26,9 +26,9 @@
|
|||||||
|
|
||||||
## 特点
|
## 特点
|
||||||
|
|
||||||
|
- 可自由替换 UI 组件库,默认使用 Element Plus
|
||||||
- 丰富的布局与主题,覆盖市面上各种中后台应用场景,兼容PC、平板和移动端
|
- 丰富的布局与主题,覆盖市面上各种中后台应用场景,兼容PC、平板和移动端
|
||||||
- 提供系统配置文件,轻松实现个性化定制
|
- 提供系统配置文件,轻松实现个性化定制
|
||||||
- 精心设计的动效,让每一处的动画都干净利落
|
|
||||||
- 根据路由配置自动生成导航栏
|
- 根据路由配置自动生成导航栏
|
||||||
- 基于文件系统的路由
|
- 基于文件系统的路由
|
||||||
- 支持全方位权限验证
|
- 支持全方位权限验证
|
||||||
@ -36,14 +36,17 @@
|
|||||||
- 轻松实现国际化多语言适配
|
- 轻松实现国际化多语言适配
|
||||||
- 提供接近于浏览器原生标签栏操作体验的标签页功能
|
- 提供接近于浏览器原生标签栏操作体验的标签页功能
|
||||||
|
|
||||||
## 分支说明
|
## 下载
|
||||||
|
|
||||||
|
> 本仓库为基础版
|
||||||
|
|
||||||
|
**直接拉取源码可能会包含未发布的内容,推荐去 [Github Releases](https://github.com/fantastic-admin/basic/releases) 页面下载稳定版本的压缩包**。如果确定需要拉取源码,请参考下列分支说明:
|
||||||
|
|
||||||
- `main` Vue3 版本框架源码分支,不含示例代码,可直接用于实际开发
|
- `main` Vue3 版本框架源码分支,不含示例代码,可直接用于实际开发
|
||||||
- `example` Vue3 版本演示源码分支,同线上演示站,包含大量示例,可用于参考学习
|
- `example` Vue3 版本演示源码分支,同线上演示站,包含大量示例,可用于参考学习
|
||||||
- `vue2` Vue2 版本框架源码分支,不含示例代码,可直接用于实际开发(停止维护)
|
- `vue2` Vue2 版本框架源码分支,不含示例代码,可直接用于实际开发(停止维护)
|
||||||
- `vue2-example` Vue2 版本演示源码分支,包含大量示例,可用于参考学习(停止维护)
|
- `vue2-example` Vue2 版本演示源码分支,包含大量示例,可用于参考学习(停止维护)
|
||||||
|
|
||||||
如果你更注重使用时的稳定性,推荐去 [Github Releases](https://github.com/fantastic-admin/basic/releases) 页面下载最新稳定版本的压缩包。
|
|
||||||
|
|
||||||
## 预览
|
## 预览
|
||||||
|
|
||||||
@ -80,7 +83,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th colspan="3" align="center">
|
<th colspan="3" align="center">
|
||||||
一款开箱即用的 Vue 项目启动套件
|
一款开箱即用的 Vue3 项目启动套件
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
27
eslint.config.js
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import antfu from '@antfu/eslint-config'
|
||||||
|
|
||||||
|
export default antfu(
|
||||||
|
{
|
||||||
|
ignores: [
|
||||||
|
'public',
|
||||||
|
'dist*',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rules: {
|
||||||
|
'eslint-comments/no-unlimited-disable': 'off',
|
||||||
|
'curly': ['error', 'all'],
|
||||||
|
'antfu/consistent-list-newline': 'off',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: [
|
||||||
|
'src/**/*.vue',
|
||||||
|
],
|
||||||
|
rules: {
|
||||||
|
'vue/component-tags-order': ['error', {
|
||||||
|
order: ['route', 'script', 'template', 'style'],
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
0
index.html
Normal file → Executable file
32
package.json
Normal file → Executable file
@ -1,46 +1,55 @@
|
|||||||
{
|
{
|
||||||
|
"type": "module",
|
||||||
"version": "3.2.4",
|
"version": "3.2.4",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build:test": "vue-tsc --noEmit && vite build --mode test",
|
"build": "vue-tsc && vite build",
|
||||||
"build": "vue-tsc --noEmit && vite build",
|
"build:test": "vue-tsc && vite build --mode test",
|
||||||
"serve:test": "http-server ./dist-test -o",
|
|
||||||
"serve": "http-server ./dist -o",
|
"serve": "http-server ./dist -o",
|
||||||
|
"serve:test": "http-server ./dist-test -o",
|
||||||
"svgo": "svgo -f src/assets/icons",
|
"svgo": "svgo -f src/assets/icons",
|
||||||
"new": "plop",
|
"new": "plop",
|
||||||
|
"generate:icons": "esno ./scripts/generate.icons.ts",
|
||||||
"lint": "npm-run-all -s lint:tsc lint:eslint lint:stylelint",
|
"lint": "npm-run-all -s lint:tsc lint:eslint lint:stylelint",
|
||||||
"lint:tsc": "vue-tsc --noEmit",
|
"lint:tsc": "vue-tsc",
|
||||||
"lint:eslint": "eslint --cache --fix --no-ignore --no-error-on-unmatched-pattern --ext .ts,.tsx,.vue src/",
|
"lint:eslint": "eslint . --cache --fix",
|
||||||
"lint:stylelint": "stylelint src/**/*.{css,scss,vue} --cache --fix --allow-empty-input",
|
"lint:stylelint": "stylelint src/**/*.{css,scss,vue} --cache --fix --allow-empty-input",
|
||||||
"postinstall": "simple-git-hooks && esno scripts/prepare.js",
|
"postinstall": "simple-git-hooks",
|
||||||
"preinstall": "npx only-allow pnpm",
|
"preinstall": "npx only-allow pnpm",
|
||||||
"commit": "git cz",
|
"commit": "git cz",
|
||||||
"release": "bumpp"
|
"release": "bumpp"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@headlessui/vue": "^1.7.16",
|
||||||
"@vueuse/core": "^10.5.0",
|
"@vueuse/core": "^10.5.0",
|
||||||
"@vueuse/integrations": "^10.5.0",
|
"@vueuse/integrations": "^10.5.0",
|
||||||
"axios": "^1.5.1",
|
"axios": "^1.5.1",
|
||||||
"dayjs": "^1.11.10",
|
"dayjs": "^1.11.10",
|
||||||
|
"defu": "^6.1.3",
|
||||||
"element-plus": "^2.4.1",
|
"element-plus": "^2.4.1",
|
||||||
"eruda": "^3.0.1",
|
"eruda": "^3.0.1",
|
||||||
|
"floating-vue": "2.0.0-beta.24",
|
||||||
"hotkeys-js": "^3.12.0",
|
"hotkeys-js": "^3.12.0",
|
||||||
"lodash-es": "^4.17.21",
|
"lodash-es": "^4.17.21",
|
||||||
"mitt": "^3.0.1",
|
"mitt": "^3.0.1",
|
||||||
"mockjs": "^1.1.0",
|
"mockjs": "^1.1.0",
|
||||||
"nprogress": "^0.2.0",
|
"nprogress": "^0.2.0",
|
||||||
|
"overlayscrollbars-vue": "^0.5.6",
|
||||||
"path-browserify": "^1.0.1",
|
"path-browserify": "^1.0.1",
|
||||||
"path-to-regexp": "^6.2.1",
|
"path-to-regexp": "^6.2.1",
|
||||||
"pinia": "^2.1.7",
|
"pinia": "^2.1.7",
|
||||||
"qs": "^6.11.2",
|
"qs": "^6.11.2",
|
||||||
|
"scule": "^1.0.0",
|
||||||
"vconsole": "^3.15.1",
|
"vconsole": "^3.15.1",
|
||||||
"vue": "^3.3.7",
|
"vue": "^3.3.7",
|
||||||
|
"vue-m-message": "^4.0.2",
|
||||||
"vue-router": "^4.2.5"
|
"vue-router": "^4.2.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@antfu/eslint-config": "1.0.0-beta.27",
|
"@antfu/eslint-config": "1.0.0-beta.29",
|
||||||
"@iconify-json/ep": "^1.1.12",
|
"@iconify/json": "^2.2.133",
|
||||||
"@iconify/vue": "^4.1.1",
|
"@iconify/vue": "^4.1.1",
|
||||||
|
"@types/lodash-es": "^4.17.10",
|
||||||
"@types/nprogress": "^0.2.2",
|
"@types/nprogress": "^0.2.2",
|
||||||
"@types/path-browserify": "^1.0.1",
|
"@types/path-browserify": "^1.0.1",
|
||||||
"@types/qs": "^6.9.9",
|
"@types/qs": "^6.9.9",
|
||||||
@ -54,11 +63,12 @@
|
|||||||
"esno": "^0.17.0",
|
"esno": "^0.17.0",
|
||||||
"fs-extra": "^11.1.1",
|
"fs-extra": "^11.1.1",
|
||||||
"http-server": "^14.1.1",
|
"http-server": "^14.1.1",
|
||||||
|
"inquirer": "^9.2.11",
|
||||||
"lint-staged": "^15.0.2",
|
"lint-staged": "^15.0.2",
|
||||||
"npm-run-all": "^4.1.5",
|
"npm-run-all": "^4.1.5",
|
||||||
"plop": "^4.0.0",
|
"plop": "^4.0.0",
|
||||||
"postcss-html": "^1.5.0",
|
"postcss-html": "^1.5.0",
|
||||||
"sass": "^1.69.4",
|
"sass": "^1.69.5",
|
||||||
"simple-git-hooks": "^2.9.0",
|
"simple-git-hooks": "^2.9.0",
|
||||||
"stylelint": "^15.11.0",
|
"stylelint": "^15.11.0",
|
||||||
"stylelint-config-standard-scss": "^11.0.0",
|
"stylelint-config-standard-scss": "^11.0.0",
|
||||||
@ -68,14 +78,14 @@
|
|||||||
"svgo": "^3.0.2",
|
"svgo": "^3.0.2",
|
||||||
"terser": "^5.22.0",
|
"terser": "^5.22.0",
|
||||||
"typescript": "^5.2.2",
|
"typescript": "^5.2.2",
|
||||||
"unplugin-auto-import": "^0.16.6",
|
"unocss": "^0.57.1",
|
||||||
|
"unplugin-auto-import": "^0.16.7",
|
||||||
"unplugin-vue-components": "^0.25.2",
|
"unplugin-vue-components": "^0.25.2",
|
||||||
"vite": "^4.5.0",
|
"vite": "^4.5.0",
|
||||||
"vite-plugin-banner": "^0.7.1",
|
"vite-plugin-banner": "^0.7.1",
|
||||||
"vite-plugin-compression": "^0.5.1",
|
"vite-plugin-compression": "^0.5.1",
|
||||||
"vite-plugin-mock": "^2.9.8",
|
"vite-plugin-mock": "^2.9.8",
|
||||||
"vite-plugin-pages": "^0.31.0",
|
"vite-plugin-pages": "^0.31.0",
|
||||||
"vite-plugin-spritesmith": "^0.1.1",
|
|
||||||
"vite-plugin-svg-icons": "^2.0.1",
|
"vite-plugin-svg-icons": "^2.0.1",
|
||||||
"vite-plugin-vue-inspector": "^4.0.0",
|
"vite-plugin-vue-inspector": "^4.0.0",
|
||||||
"vite-plugin-vue-meta-layouts": "^0.3.1",
|
"vite-plugin-vue-meta-layouts": "^0.3.1",
|
||||||
|
0
plop-templates/component/index.hbs
Normal file → Executable file
0
plop-templates/component/prompt.js
Normal file → Executable file
78
plop-templates/mock/mock.hbs
Executable file
@ -0,0 +1,78 @@
|
|||||||
|
import Mock from 'mockjs'
|
||||||
|
|
||||||
|
const AllList: any[] = []
|
||||||
|
for (let i = 0; i < 50; i++) {
|
||||||
|
AllList.push(Mock.mock({
|
||||||
|
id: '@id',
|
||||||
|
title: '@ctitle(10, 20)',
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
export default [
|
||||||
|
{
|
||||||
|
url: '/mock/{{#if relativePath}}{{ relativePath }}/{{/if}}{{ moduleName }}/list',
|
||||||
|
method: 'get',
|
||||||
|
response: (option: any) => {
|
||||||
|
const { title, from, limit } = option.query
|
||||||
|
const list = AllList.filter((item) => {
|
||||||
|
return title ? item.title.includes(title) : true
|
||||||
|
})
|
||||||
|
const pageList = list.filter((item, index) => {
|
||||||
|
return index >= ~~from && index < (~~from + ~~limit)
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
error: '',
|
||||||
|
status: 1,
|
||||||
|
data: {
|
||||||
|
list: pageList,
|
||||||
|
total: list.length,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: '/mock/{{#if relativePath}}{{ relativePath }}/{{/if}}{{ moduleName }}/detail',
|
||||||
|
method: 'get',
|
||||||
|
response: (option: any) => {
|
||||||
|
const info = AllList.filter(item => item.id === option.query.id)
|
||||||
|
return {
|
||||||
|
error: '',
|
||||||
|
status: 1,
|
||||||
|
data: info[0],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: '/mock/{{#if relativePath}}{{ relativePath }}/{{/if}}{{ moduleName }}/create',
|
||||||
|
method: 'post',
|
||||||
|
response: {
|
||||||
|
error: '',
|
||||||
|
status: 1,
|
||||||
|
data: {
|
||||||
|
isSuccess: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: '/mock/{{#if relativePath}}{{ relativePath }}/{{/if}}{{ moduleName }}/edit',
|
||||||
|
method: 'post',
|
||||||
|
response: {
|
||||||
|
error: '',
|
||||||
|
status: 1,
|
||||||
|
data: {
|
||||||
|
isSuccess: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: '/mock/{{#if relativePath}}{{ relativePath }}/{{/if}}{{ moduleName }}/delete',
|
||||||
|
method: 'post',
|
||||||
|
response: {
|
||||||
|
error: '',
|
||||||
|
status: 1,
|
||||||
|
data: {
|
||||||
|
isSuccess: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
43
plop-templates/mock/prompt.js
Executable file
@ -0,0 +1,43 @@
|
|||||||
|
const path = require('node:path')
|
||||||
|
const fs = require('node:fs')
|
||||||
|
|
||||||
|
function getFolder(path) {
|
||||||
|
const components = []
|
||||||
|
const files = fs.readdirSync(path)
|
||||||
|
files.forEach((item) => {
|
||||||
|
const stat = fs.lstatSync(`${path}/${item}`)
|
||||||
|
if (stat.isDirectory() === true && item !== 'components') {
|
||||||
|
components.push(`${path}/${item}`)
|
||||||
|
components.push(...getFolder(`${path}/${item}`))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return components
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
description: '创建标准模块 Mock',
|
||||||
|
prompts: [
|
||||||
|
{
|
||||||
|
type: 'list',
|
||||||
|
name: 'path',
|
||||||
|
message: '请选择模块目录',
|
||||||
|
choices: getFolder('src/views'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
actions: (data) => {
|
||||||
|
const pathArr = path.relative('src/views', data.path).split('\\')
|
||||||
|
const moduleName = pathArr.pop()
|
||||||
|
const relativePath = pathArr.join('/')
|
||||||
|
const actions = []
|
||||||
|
actions.push({
|
||||||
|
type: 'add',
|
||||||
|
path: pathArr.length === 0 ? 'src/mock/{{moduleName}}.ts' : `src/mock/${pathArr.join('.')}.{{moduleName}}.ts`,
|
||||||
|
templateFile: 'plop-templates/mock/mock.hbs',
|
||||||
|
data: {
|
||||||
|
relativePath,
|
||||||
|
moduleName,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return actions
|
||||||
|
},
|
||||||
|
}
|
0
plop-templates/page/index.hbs
Normal file → Executable file
0
plop-templates/page/prompt.js
Normal file → Executable file
10
plop-templates/store/index.hbs
Normal file → Executable file
@ -1,10 +1,12 @@
|
|||||||
const use{{ properCase name }}Store = defineStore(
|
const use{{ properCase name }}Store = defineStore(
|
||||||
// 唯一ID
|
// 唯一ID
|
||||||
'{{ camelCase name }}',
|
'{{ camelCase name }}',
|
||||||
{
|
() => {
|
||||||
state: () => ({}),
|
const someThing = ref(0)
|
||||||
getters: {},
|
|
||||||
actions: {},
|
return {
|
||||||
|
someThing,
|
||||||
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
0
plop-templates/store/prompt.js
Normal file → Executable file
0
plopfile.js
Normal file → Executable file
2102
pnpm-lock.yaml
Normal file → Executable file
2
postcss.config.js
Normal file → Executable file
@ -1,4 +1,4 @@
|
|||||||
module.exports = {
|
export default {
|
||||||
plugins: {
|
plugins: {
|
||||||
autoprefixer: {},
|
autoprefixer: {},
|
||||||
},
|
},
|
||||||
|
0
public/browser_upgrade/chrome.png
Normal file → Executable file
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 3.7 KiB |
0
public/browser_upgrade/edge.png
Normal file → Executable file
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 4.0 KiB |
0
public/browser_upgrade/index.css
Normal file → Executable file
BIN
public/favicon.ico
Normal file → Executable file
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 17 KiB |
0
public/loading.css
Normal file → Executable file
80
scripts/generate.icons.ts
Executable file
@ -0,0 +1,80 @@
|
|||||||
|
import path from 'node:path'
|
||||||
|
import process from 'node:process'
|
||||||
|
import fs from 'fs-extra'
|
||||||
|
import inquirer from 'inquirer'
|
||||||
|
import { lookupCollection, lookupCollections } from '@iconify/json'
|
||||||
|
|
||||||
|
async function generateIcons() {
|
||||||
|
// 拿到全部图标集的原始数据
|
||||||
|
const raw = await lookupCollections()
|
||||||
|
|
||||||
|
let lastChoose = fs.readFileSync(path.resolve(process.cwd(), 'src/iconify/index.json'), 'utf-8')
|
||||||
|
lastChoose = JSON.parse(lastChoose)
|
||||||
|
|
||||||
|
// 取出可使用的图标集数据用于 inquirer 选择,并按名称排序
|
||||||
|
const collections = Object.entries(raw).map(([id, item]) => ({
|
||||||
|
...item,
|
||||||
|
id,
|
||||||
|
})).sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分别会在对应目录下生成以下文件,其中(1)(3)用于离线下载并安装图标,(2)用于图标选择器使用
|
||||||
|
* (1) src/iconify/index.json 记录用户 inquirer 的交互信息
|
||||||
|
* (2) src/iconify/data.json 包含多个图标集数据,仅记录图标名
|
||||||
|
* (3) public/icons/*-raw.json 多个图标集的原始数据,独立存放,用于离线使用
|
||||||
|
*/
|
||||||
|
inquirer.prompt([
|
||||||
|
{
|
||||||
|
type: 'checkbox',
|
||||||
|
message: '请选择需要生成的图标集',
|
||||||
|
name: 'collections',
|
||||||
|
choices: collections.map(item => ({
|
||||||
|
name: `${item.name} (${item.total} icons)`,
|
||||||
|
value: item.id,
|
||||||
|
})),
|
||||||
|
default: lastChoose.collections,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'confirm',
|
||||||
|
name: 'isOfflineUse',
|
||||||
|
message: '是否需要离线使用',
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
]).then(async (answers) => {
|
||||||
|
await fs.writeJSON(
|
||||||
|
path.resolve(process.cwd(), 'src/iconify/index.json'),
|
||||||
|
{
|
||||||
|
collections: answers.collections,
|
||||||
|
isOfflineUse: answers.isOfflineUse,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const outputDir = path.resolve(process.cwd(), 'public/icons')
|
||||||
|
await fs.ensureDir(outputDir)
|
||||||
|
await fs.emptyDir(outputDir)
|
||||||
|
|
||||||
|
const collectionsMeta: object[] = []
|
||||||
|
for (const info of answers.collections) {
|
||||||
|
const setData = await lookupCollection(info)
|
||||||
|
|
||||||
|
collectionsMeta.push({
|
||||||
|
prefix: setData.prefix,
|
||||||
|
info: setData.info,
|
||||||
|
icons: Object.keys(setData.icons),
|
||||||
|
})
|
||||||
|
|
||||||
|
const offlineFilePath = path.join(outputDir, `${info}-raw.json`)
|
||||||
|
|
||||||
|
if (answers.isOfflineUse) {
|
||||||
|
await fs.writeJSON(offlineFilePath, setData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await fs.writeJSON(
|
||||||
|
path.resolve(process.cwd(), 'src/iconify/data.json'),
|
||||||
|
collectionsMeta,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
generateIcons()
|
@ -1,16 +0,0 @@
|
|||||||
import path from 'node:path'
|
|
||||||
import process from 'node:process'
|
|
||||||
import fs from 'fs-extra'
|
|
||||||
import { icons } from '@iconify-json/ep'
|
|
||||||
|
|
||||||
async function prepare() {
|
|
||||||
const outputDir = path.resolve(process.cwd(), 'public/icons')
|
|
||||||
await fs.ensureDir(outputDir)
|
|
||||||
await fs.emptyDir(outputDir)
|
|
||||||
await fs.writeJSON(
|
|
||||||
path.resolve(outputDir, 'icons.json'),
|
|
||||||
icons,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
prepare()
|
|
@ -1,66 +0,0 @@
|
|||||||
{
|
|
||||||
// Default options
|
|
||||||
'functions': true,
|
|
||||||
'variableNameTransforms': ['dasherize']
|
|
||||||
}
|
|
||||||
|
|
||||||
{{#block "sprites"}}
|
|
||||||
{{#each sprites}}
|
|
||||||
${{../spritesheet_info.strings.name}}-sprite-{{strings.name}}: ({{px.x}}, {{px.y}}, {{px.offset_x}}, {{px.offset_y}}, {{px.width}}, {{px.height}}, {{px.total_width}}, {{px.total_height}}, '{{{escaped_image}}}', '{{name}}');
|
|
||||||
{{/each}}
|
|
||||||
|
|
||||||
${{spritesheet_info.strings.name}}-sprites: (
|
|
||||||
{{#each sprites}}
|
|
||||||
{{strings.name}}: ${{../spritesheet_info.strings.name}}-sprite-{{strings.name}},
|
|
||||||
{{/each}}
|
|
||||||
);
|
|
||||||
{{/block}}
|
|
||||||
|
|
||||||
{{#block "sprite-functions"}}
|
|
||||||
{{#if options.functions}}
|
|
||||||
@mixin {{spritesheet_info.strings.name}}-sprite-width($sprite) {
|
|
||||||
width: nth($sprite, 5);
|
|
||||||
}
|
|
||||||
|
|
||||||
@mixin {{spritesheet_info.strings.name}}-sprite-height($sprite) {
|
|
||||||
height: nth($sprite, 6);
|
|
||||||
}
|
|
||||||
|
|
||||||
@mixin {{spritesheet_info.strings.name}}-sprite-position($sprite) {
|
|
||||||
$sprite-offset-x: nth($sprite, 3);
|
|
||||||
$sprite-offset-y: nth($sprite, 4);
|
|
||||||
background-position: $sprite-offset-x $sprite-offset-y;
|
|
||||||
}
|
|
||||||
|
|
||||||
@mixin {{spritesheet_info.strings.name}}-sprite-size($sprite) {
|
|
||||||
background-size: nth($sprite, 7) nth($sprite, 8);
|
|
||||||
}
|
|
||||||
|
|
||||||
@mixin {{spritesheet_info.strings.name}}-sprite-image($sprite) {
|
|
||||||
$sprite-image: nth($sprite, 9);
|
|
||||||
background-image: url(#{$sprite-image});
|
|
||||||
}
|
|
||||||
|
|
||||||
@mixin {{spritesheet_info.strings.name}}-sprite($name) {
|
|
||||||
@include {{spritesheet_info.strings.name}}-sprite-image(map-get(${{spritesheet_info.strings.name}}-sprites, #{$name}));
|
|
||||||
@include {{spritesheet_info.strings.name}}-sprite-position(map-get(${{spritesheet_info.strings.name}}-sprites, #{$name}));
|
|
||||||
@include {{spritesheet_info.strings.name}}-sprite-size(map-get(${{spritesheet_info.strings.name}}-sprites, #{$name}));
|
|
||||||
@include {{spritesheet_info.strings.name}}-sprite-width(map-get(${{spritesheet_info.strings.name}}-sprites, #{$name}));
|
|
||||||
@include {{spritesheet_info.strings.name}}-sprite-height(map-get(${{spritesheet_info.strings.name}}-sprites, #{$name}));
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
}
|
|
||||||
{{/if}}
|
|
||||||
{{/block}}
|
|
||||||
|
|
||||||
{{#block "spritesheet-functions"}}
|
|
||||||
{{#if options.functions}}
|
|
||||||
@mixin all-{{spritesheet_info.strings.name}}-sprites() {
|
|
||||||
@each $key, $val in ${{spritesheet_info.strings.name}}-sprites {
|
|
||||||
$sprite-name: nth($val, 10);
|
|
||||||
.{{spritesheet_info.strings.name}}-#{$sprite-name}-sprites {
|
|
||||||
@include {{spritesheet_info.strings.name}}-sprite($key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
{{/if}}
|
|
||||||
{{/block}}
|
|
33
src/App.vue
Normal file → Executable file
@ -1,22 +1,20 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import eruda from 'eruda'
|
import eruda from 'eruda'
|
||||||
import VConsole from 'vconsole'
|
import VConsole from 'vconsole'
|
||||||
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
|
||||||
import hotkeys from 'hotkeys-js'
|
import hotkeys from 'hotkeys-js'
|
||||||
|
import elementPlusLocaleZhCN from 'element-plus/es/locale/lang/zh-cn.mjs'
|
||||||
import eventBus from './utils/eventBus'
|
import eventBus from './utils/eventBus'
|
||||||
import useSettingsStore from '@/store/modules/settings'
|
import useSettingsStore from '@/store/modules/settings'
|
||||||
|
import useMenuStore from '@/store/modules/menu'
|
||||||
|
|
||||||
const settingsStore = useSettingsStore()
|
const settingsStore = useSettingsStore()
|
||||||
|
const menuStore = useMenuStore()
|
||||||
const { auth } = useAuth()
|
const { auth } = useAuth()
|
||||||
|
|
||||||
const buttonConfig = ref({
|
|
||||||
autoInsertSpace: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
// 侧边栏主导航当前实际宽度
|
// 侧边栏主导航当前实际宽度
|
||||||
const mainSidebarActualWidth = computed(() => {
|
const mainSidebarActualWidth = computed(() => {
|
||||||
let actualWidth = Number.parseInt(getComputedStyle(document.documentElement).getPropertyValue('--g-main-sidebar-width'))
|
let actualWidth = Number.parseInt(getComputedStyle(document.documentElement).getPropertyValue('--g-main-sidebar-width'))
|
||||||
if (['head', 'single'].includes(settingsStore.settings.menu.menuMode)) {
|
if (settingsStore.settings.menu.menuMode === 'single' || (settingsStore.settings.menu.menuMode === 'head' && settingsStore.mode !== 'mobile')) {
|
||||||
actualWidth = 0
|
actualWidth = 0
|
||||||
}
|
}
|
||||||
return `${actualWidth}px`
|
return `${actualWidth}px`
|
||||||
@ -25,12 +23,22 @@ const mainSidebarActualWidth = computed(() => {
|
|||||||
// 侧边栏次导航当前实际宽度
|
// 侧边栏次导航当前实际宽度
|
||||||
const subSidebarActualWidth = computed(() => {
|
const subSidebarActualWidth = computed(() => {
|
||||||
let actualWidth = Number.parseInt(getComputedStyle(document.documentElement).getPropertyValue('--g-sub-sidebar-width'))
|
let actualWidth = Number.parseInt(getComputedStyle(document.documentElement).getPropertyValue('--g-sub-sidebar-width'))
|
||||||
if (settingsStore.settings.menu.subMenuCollapse) {
|
if (settingsStore.settings.menu.subMenuCollapse && settingsStore.mode !== 'mobile') {
|
||||||
actualWidth = 64
|
actualWidth = Number.parseInt(getComputedStyle(document.documentElement).getPropertyValue('--g-sub-sidebar-collapse-width'))
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
menuStore.sidebarMenus.length === 1
|
||||||
|
&& (
|
||||||
|
!menuStore.sidebarMenus[0].children
|
||||||
|
|| menuStore.sidebarMenus[0]?.children.every(item => item.meta?.sidebar === false)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
actualWidth = 0
|
||||||
}
|
}
|
||||||
return `${actualWidth}px`
|
return `${actualWidth}px`
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 设置网页 title
|
||||||
watch([
|
watch([
|
||||||
() => settingsStore.settings.app.enableDynamicTitle,
|
() => settingsStore.settings.app.enableDynamicTitle,
|
||||||
() => settingsStore.title,
|
() => settingsStore.title,
|
||||||
@ -44,6 +52,7 @@ watch([
|
|||||||
}
|
}
|
||||||
}, {
|
}, {
|
||||||
immediate: true,
|
immediate: true,
|
||||||
|
deep: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
@ -61,7 +70,7 @@ import.meta.env.VITE_APP_DEBUG_TOOL === 'vconsole' && new VConsole()
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<el-config-provider :locale="zhCn" :size="settingsStore.settings.app.elementSize" :button="buttonConfig">
|
<ElConfigProvider :locale="elementPlusLocaleZhCN" :button="{ autoInsertSpace: true }">
|
||||||
<RouterView
|
<RouterView
|
||||||
v-slot="{ Component, route }"
|
v-slot="{ Component, route }"
|
||||||
:style="{
|
:style="{
|
||||||
@ -70,8 +79,8 @@ import.meta.env.VITE_APP_DEBUG_TOOL === 'vconsole' && new VConsole()
|
|||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<component :is="Component" v-if="auth(route.meta.auth ?? '')" />
|
<component :is="Component" v-if="auth(route.meta.auth ?? '')" />
|
||||||
<not-allowed v-else />
|
<NotAllowed v-else />
|
||||||
</RouterView>
|
</RouterView>
|
||||||
<system-info />
|
<SystemInfo />
|
||||||
</el-config-provider>
|
</ElConfigProvider>
|
||||||
</template>
|
</template>
|
||||||
|
25
src/api/index.ts
Normal file → Executable file
@ -1,7 +1,7 @@
|
|||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
|
|
||||||
// import qs from 'qs'
|
// import qs from 'qs'
|
||||||
import { ElMessage } from 'element-plus'
|
import Message from 'vue-m-message'
|
||||||
import useUserStore from '@/store/modules/user'
|
import useUserStore from '@/store/modules/user'
|
||||||
|
|
||||||
const api = axios.create({
|
const api = axios.create({
|
||||||
@ -12,13 +12,13 @@ const api = axios.create({
|
|||||||
|
|
||||||
api.interceptors.request.use(
|
api.interceptors.request.use(
|
||||||
(request) => {
|
(request) => {
|
||||||
|
// 全局拦截请求发送前提交的参数
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
/**
|
// 设置请求头
|
||||||
* 全局拦截请求发送前提交的参数
|
if (request.headers) {
|
||||||
* 以下代码为示例,在请求头里带上 token 信息
|
if (userStore.isLogin) {
|
||||||
*/
|
request.headers.Token = userStore.token
|
||||||
if (userStore.isLogin && request.headers) {
|
}
|
||||||
request.headers.Token = userStore.token
|
|
||||||
}
|
}
|
||||||
// 是否将 POST 请求参数进行字符串化处理
|
// 是否将 POST 请求参数进行字符串化处理
|
||||||
if (request.method === 'post') {
|
if (request.method === 'post') {
|
||||||
@ -40,8 +40,10 @@ api.interceptors.response.use(
|
|||||||
*/
|
*/
|
||||||
if (response.data.status === 1) {
|
if (response.data.status === 1) {
|
||||||
if (response.data.error !== '') {
|
if (response.data.error !== '') {
|
||||||
// 这里做错误提示,如果使用了 element plus 则可以使用 Message 进行提示
|
// 错误提示
|
||||||
// ElMessage.error(options)
|
Message.error(response.data.error, {
|
||||||
|
zIndex: 2000,
|
||||||
|
})
|
||||||
return Promise.reject(response.data)
|
return Promise.reject(response.data)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -61,9 +63,8 @@ api.interceptors.response.use(
|
|||||||
else if (message.includes('Request failed with status code')) {
|
else if (message.includes('Request failed with status code')) {
|
||||||
message = `接口${message.substr(message.length - 3)}异常`
|
message = `接口${message.substr(message.length - 3)}异常`
|
||||||
}
|
}
|
||||||
ElMessage({
|
Message.error(message, {
|
||||||
message,
|
zIndex: 2000,
|
||||||
type: 'error',
|
|
||||||
})
|
})
|
||||||
return Promise.reject(error)
|
return Promise.reject(error)
|
||||||
},
|
},
|
||||||
|
0
src/api/modules/app.ts
Normal file → Executable file
0
src/api/modules/user.ts
Normal file → Executable file
0
src/assets/icons/403.svg
Normal file → Executable file
Before Width: | Height: | Size: 6.2 KiB After Width: | Height: | Size: 6.2 KiB |
0
src/assets/icons/404.svg
Normal file → Executable file
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 6.5 KiB |
@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" class="icon" viewBox="0 0 1024 1024"><path d="M768 298.667h170.667V384h-256V128H768v170.667zM341.333 384h-256v-85.333H256V128h85.333v256zM768 725.333V896h-85.333V640h256v85.333H768zM341.333 640v256H256V725.333H85.333V640h256z"/></svg>
|
|
Before Width: | Height: | Size: 299 B |
@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" class="icon" viewBox="0 0 1024 1024"><path d="M682.667 128h256v256h-85.334V213.333H682.667V128zm-597.334 0h256v85.333H170.667V384H85.333V128zm768 682.667V640h85.334v256h-256v-85.333h170.666zm-682.666 0h170.666V896h-256V640h85.334v170.667z"/></svg>
|
|
Before Width: | Height: | Size: 312 B |
0
src/assets/icons/image-load-fail.svg
Normal file → Executable file
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" class="icon" viewBox="0 0 1024 1024"><path d="M512.213 958.99 63.595 510.93l84.025-84.023 300.584 302.222V63.546h127.94V729.13l299.652-302.222 84.01 84.024-447.593 448.058z"/></svg>
|
|
Before Width: | Height: | Size: 244 B |
@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" class="icon" viewBox="0 0 1024 1024"><path d="M916.21 242.526v215.58H204.8l194.021-194.022-75.453-75.452L0 512l323.368 323.368 75.453-75.452L204.8 565.895H1024V242.526z"/></svg>
|
|
Before Width: | Height: | Size: 240 B |
@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" class="icon" viewBox="0 0 1024 1024"><path d="m959.804 511.604-84.009 84.024-299.652-302.222V958.99h-127.94V293.406L147.62 595.63l-84.025-84.024L512.213 63.546l447.591 448.058z"/></svg>
|
|
Before Width: | Height: | Size: 248 B |
@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" class="icon" viewBox="0 0 1024 1024"><path d="M165.415 827.077c-11.815 0-19.692-7.877-19.692-19.692V214.646c0-11.815 7.877-19.692 19.692-19.692h159.508c7.877 0 17.723 3.938 23.63 9.846l228.432 287.508c7.877 9.846 7.877 25.6 0 37.415l-230.4 287.508c-5.908 7.877-15.754 11.815-25.6 11.815l-155.57-1.97zm706.954-334.77L641.97 206.77c-9.846-11.815-27.569-15.754-41.354-3.938l-45.292 37.415c-13.785 9.846-15.754 29.539-3.938 41.354L738.462 512 551.385 744.37c-9.847 11.815-7.877 31.507 3.938 41.353l45.292 37.415c13.785 9.847 29.539 7.877 41.354-3.938l230.4-287.508c7.877-15.754 7.877-29.538 0-39.384z"/></svg>
|
|
Before Width: | Height: | Size: 670 B |
@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" class="icon" viewBox="0 0 1024 1024"><path d="M487 71.425a75 75 0 0 1 72.575 0l335.7 185.5a75 75 0 0 1 38.75 65.65V690.85a75 75 0 0 1-38.75 65.625l-335.7 185.55a75 75 0 0 1-72.55 0l-335.7-185.5a75 75 0 0 1-38.75-65.675V322.6a75 75 0 0 1 38.75-65.6L487 71.4v.025zM859 322.6 523.275 137.1l-335.7 185.5v368.25l335.7 185.5 335.75-185.5V322.6zm-601.75 37.1A37.5 37.5 0 0 1 308.2 345l215.1 118.875L738.45 345a37.5 37.5 0 0 1 36.25 65.625l-213.9 118.25V764.55a37.5 37.5 0 0 1-75 0v-235.7l-213.9-118.2a37.5 37.5 0 0 1-14.65-51v.05z"/></svg>
|
|
Before Width: | Height: | Size: 597 B |
@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" class="icon" viewBox="0 0 1024 1024"><path d="M913.6 135.2H380.2c-26.7 0-44.4 17.8-44.4 44.4V224c0 26.7 17.8 44.4 44.4 44.4h533.3c26.7 0 44.4-17.8 44.4-44.4v-44.4c.1-26.6-17.7-44.4-44.3-44.4zm-755.6 0h-44.4c-22.2 0-44.4 22.2-44.4 44.4V224c0 22.2 22.2 44.4 44.4 44.4H158c22.2 0 44.4-22.2 44.4-44.4v-44.4c.1-22.2-22.1-44.4-44.4-44.4zm0 311.1h-44.4c-22.2 0-44.4 22.2-44.4 44.4v44.4c0 22.2 22.2 44.4 44.4 44.4H158c22.2 0 44.4-22.2 44.4-44.4v-44.4c.1-22.2-22.1-44.4-44.4-44.4zm755.6 0H380.2c-26.7 0-44.4 17.8-44.4 44.4v44.4c0 26.7 17.8 44.4 44.4 44.4h533.3c26.7 0 44.4-17.8 44.4-44.4v-44.4c.1-26.6-17.7-44.4-44.3-44.4zM158 757.4h-44.4c-22.2 0-44.4 22.2-44.4 44.4v44.4c0 22.2 22.2 44.4 44.4 44.4H158c22.2 0 44.4-22.2 44.4-44.4v-44.4c.1-22.2-22.1-44.4-44.4-44.4zm755.6 0H380.2c-26.7 0-44.4 17.8-44.4 44.4v44.4c0 26.7 17.8 44.4 44.4 44.4h533.3c26.7 0 44.4-17.8 44.4-44.4v-44.4c.1-26.6-17.7-44.4-44.3-44.4z"/></svg>
|
|
Before Width: | Height: | Size: 971 B |
0
src/assets/icons/toolbar-collapse.svg
Normal file → Executable file
Before Width: | Height: | Size: 998 B After Width: | Height: | Size: 998 B |
0
src/assets/images/login-banner.png
Normal file → Executable file
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
0
src/assets/images/logo.png
Normal file → Executable file
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 4.0 KiB |
@ -1,9 +0,0 @@
|
|||||||
// 暗黑模式
|
|
||||||
@forward "element-plus/theme-chalk/src/dark/var.scss" with (
|
|
||||||
$bg-color: (
|
|
||||||
"page": #0a0a0a,
|
|
||||||
"": #141414,
|
|
||||||
"overlay": #1d1e1f,
|
|
||||||
)
|
|
||||||
);
|
|
||||||
@use "element-plus/theme-chalk/src/dark/css-vars.scss" as *;
|
|
@ -1,9 +0,0 @@
|
|||||||
// @forward "element-plus/theme-chalk/src/common/var.scss" with (
|
|
||||||
// $colors: (
|
|
||||||
// "primary": (
|
|
||||||
// "base": green
|
|
||||||
// )
|
|
||||||
// )
|
|
||||||
// );
|
|
||||||
@use "element-plus/theme-chalk/src/index.scss" as *;
|
|
||||||
@use "./dark.scss";
|
|
92
src/assets/styles/globals.scss
Normal file → Executable file
@ -1,21 +1,49 @@
|
|||||||
@use "./themes.scss";
|
// 页面布局 CSS 变量
|
||||||
@use "./element-plus/index.scss";
|
|
||||||
|
|
||||||
// 全局变量
|
|
||||||
:root {
|
:root {
|
||||||
// 头部宽度(默认自适应宽度,可固定宽度,固定宽度后为居中显示)
|
|
||||||
--g-header-width: 100%;
|
|
||||||
// 头部高度
|
// 头部高度
|
||||||
--g-header-height: 70px;
|
--g-header-height: 80px;
|
||||||
// 侧边栏宽度
|
// 侧边栏宽度
|
||||||
--g-main-sidebar-width: 70px;
|
--g-main-sidebar-width: 80px;
|
||||||
--g-sub-sidebar-width: 220px;
|
--g-sub-sidebar-width: 220px;
|
||||||
// 侧边栏Logo高度
|
--g-sub-sidebar-collapse-width: 64px;
|
||||||
|
// 侧边栏 Logo 区域高度
|
||||||
--g-sidebar-logo-height: 50px;
|
--g-sidebar-logo-height: 50px;
|
||||||
// 顶栏高度
|
// 工具栏高度
|
||||||
--g-topbar-height: 50px;
|
--g-toolbar-height: 50px;
|
||||||
}
|
}
|
||||||
// 全局样式
|
|
||||||
|
// 明暗模式 CSS 变量
|
||||||
|
/* stylelint-disable-next-line no-duplicate-selectors */
|
||||||
|
:root {
|
||||||
|
&::view-transition-old(root),
|
||||||
|
&::view-transition-new(root) {
|
||||||
|
animation: none;
|
||||||
|
mix-blend-mode: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::view-transition-old(root) {
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::view-transition-new(root) {
|
||||||
|
z-index: 9999;
|
||||||
|
}
|
||||||
|
|
||||||
|
--g-box-shadow-color: rgb(0 0 0 / 12%);
|
||||||
|
|
||||||
|
&.dark {
|
||||||
|
&::view-transition-old(root) {
|
||||||
|
z-index: 9999;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::view-transition-new(root) {
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
--g-box-shadow-color: rgb(0 0 0 / 72%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
width: 12px;
|
width: 12px;
|
||||||
height: 12px;
|
height: 12px;
|
||||||
@ -43,6 +71,7 @@ body {
|
|||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
background-color: var(--g-container-bg);
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
font-family: Lato, "PingFang SC", "Microsoft YaHei", sans-serif;
|
font-family: Lato, "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||||
-webkit-tap-highlight-color: transparent;
|
-webkit-tap-highlight-color: transparent;
|
||||||
@ -55,6 +84,7 @@ body {
|
|||||||
* {
|
* {
|
||||||
box-sizing: inherit;
|
box-sizing: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 右侧内容区针对fixed元素,有横向铺满的需求,可在fixed元素上设置 [data-fixed-calc-width]
|
// 右侧内容区针对fixed元素,有横向铺满的需求,可在fixed元素上设置 [data-fixed-calc-width]
|
||||||
[data-fixed-calc-width] {
|
[data-fixed-calc-width] {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@ -74,15 +104,37 @@ body {
|
|||||||
textarea {
|
textarea {
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
}
|
}
|
||||||
// 列表页全局样式
|
|
||||||
.el-table.list-table {
|
|
||||||
margin: 20px 0;
|
|
||||||
|
|
||||||
.el-button + .el-dropdown {
|
/* Overrides Floating Vue */
|
||||||
margin-left: 10px;
|
.v-popper--theme-dropdown,
|
||||||
}
|
.v-popper--theme-tooltip {
|
||||||
|
--at-apply: inline-flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-loading-mask {
|
.v-popper--theme-dropdown .v-popper__inner,
|
||||||
z-index: 900 !important;
|
.v-popper--theme-tooltip .v-popper__inner {
|
||||||
|
--at-apply: bg-white dark:bg-stone-8 text-dark dark:text-white rounded shadow ring-1 ring-gray-200 dark:ring-gray-800 border border-solid border-stone/20 text-xs font-normal;
|
||||||
|
|
||||||
|
box-shadow: 0 6px 30px rgb(0 0 0 / 10%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-popper--theme-tooltip .v-popper__arrow-inner,
|
||||||
|
.v-popper--theme-dropdown .v-popper__arrow-inner {
|
||||||
|
visibility: visible;
|
||||||
|
|
||||||
|
--at-apply: border-white dark:border-stone-8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-popper--theme-tooltip .v-popper__arrow-outer,
|
||||||
|
.v-popper--theme-dropdown .v-popper__arrow-outer {
|
||||||
|
--at-apply: border-stone/20;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-popper--theme-tooltip.v-popper--shown,
|
||||||
|
.v-popper--theme-tooltip.v-popper--shown * {
|
||||||
|
transition: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-overlayscrollbars-contents] {
|
||||||
|
overscroll-behavior: contain;
|
||||||
}
|
}
|
||||||
|
8
src/assets/styles/nprogress.scss
Normal file → Executable file
@ -8,7 +8,7 @@
|
|||||||
left: 0;
|
left: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 2px;
|
height: 2px;
|
||||||
background: var(--g-nprogress-color);
|
background: rgb(var(--ui-primary));
|
||||||
}
|
}
|
||||||
|
|
||||||
.peg {
|
.peg {
|
||||||
@ -19,7 +19,7 @@
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: rotate(3deg) translate(0, -4px);
|
transform: rotate(3deg) translate(0, -4px);
|
||||||
box-shadow: 0 0 10px var(--g-nprogress-color), 0 0 5px var(--g-nprogress-color);
|
box-shadow: 0 0 10px rgb(var(--ui-primary)), 0 0 5px rgb(var(--ui-primary));
|
||||||
}
|
}
|
||||||
|
|
||||||
.spinner {
|
.spinner {
|
||||||
@ -35,9 +35,9 @@
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
border: solid 2px transparent;
|
border: solid 2px transparent;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
|
border-top-color: rgb(var(--ui-primary));
|
||||||
|
border-left-color: rgb(var(--ui-primary));
|
||||||
animation: nprogress-spinner 400ms linear infinite;
|
animation: nprogress-spinner 400ms linear infinite;
|
||||||
border-top-color: var(--g-nprogress-color);
|
|
||||||
border-left-color: var(--g-nprogress-color);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
0
src/assets/styles/resources/utils.scss
Normal file → Executable file
0
src/assets/styles/resources/variables.scss
Normal file → Executable file
@ -1,124 +0,0 @@
|
|||||||
:root {
|
|
||||||
--g-box-shadow-color: rgb(0 0 0 / 12%);
|
|
||||||
|
|
||||||
/* ----- 应用 ----- */
|
|
||||||
// 应用背景色
|
|
||||||
--g-app-bg: var(--el-bg-color);
|
|
||||||
// 主区域背景色
|
|
||||||
--g-main-bg: var(--el-bg-color-page);
|
|
||||||
|
|
||||||
/* ----- 顶部 ----- */
|
|
||||||
// 背景色
|
|
||||||
--g-header-bg: #222b45;
|
|
||||||
// 文字颜色
|
|
||||||
--g-header-color: #fff;
|
|
||||||
// 导航文字颜色
|
|
||||||
--g-header-menu-color: #fff;
|
|
||||||
// 导航鼠标悬浮文字颜色
|
|
||||||
--g-header-menu-hover-color: #fff;
|
|
||||||
// 导航鼠标悬浮背景色
|
|
||||||
--g-header-menu-hover-bg: #334067;
|
|
||||||
// 导航选中文字颜色
|
|
||||||
--g-header-menu-active-color: #fff;
|
|
||||||
// 导航选中背景色
|
|
||||||
--g-header-menu-active-bg: #334067;
|
|
||||||
|
|
||||||
/* ----- 主导航 ----- */
|
|
||||||
// 背景色
|
|
||||||
--g-main-sidebar-bg: #222b45;
|
|
||||||
// 菜单文字颜色
|
|
||||||
--g-main-sidebar-menu-color: #eee;
|
|
||||||
// 菜单鼠标悬浮文字颜色
|
|
||||||
--g-main-sidebar-menu-hover-color: #fff;
|
|
||||||
// 菜单鼠标悬浮背景色
|
|
||||||
--g-main-sidebar-menu-hover-bg: #334067;
|
|
||||||
// 菜单选中文字颜色
|
|
||||||
--g-main-sidebar-menu-active-color: #fff;
|
|
||||||
// 菜单选中背景色
|
|
||||||
--g-main-sidebar-menu-active-bg: #334067;
|
|
||||||
|
|
||||||
/* ----- 次导航 ----- */
|
|
||||||
// 背景色
|
|
||||||
--g-sub-sidebar-bg: #fff;
|
|
||||||
// 菜单背景色
|
|
||||||
--g-sub-sidebar-menu-bg: #e8e8e8;
|
|
||||||
// 菜单文字颜色
|
|
||||||
--g-sub-sidebar-menu-color: var(--el-text-color-primary);
|
|
||||||
// 菜单鼠标悬浮文字颜色
|
|
||||||
--g-sub-sidebar-menu-hover-color: var(--el-text-color-primary);
|
|
||||||
// 菜单鼠标悬浮背景色
|
|
||||||
--g-sub-sidebar-menu-hover-bg: #e1e1e1;
|
|
||||||
// 菜单选中文字颜色
|
|
||||||
--g-sub-sidebar-menu-active-color: #fff;
|
|
||||||
// 菜单选中背景色
|
|
||||||
--g-sub-sidebar-menu-active-bg: var(--el-color-primary);
|
|
||||||
|
|
||||||
/* ----- 顶栏 ----- */
|
|
||||||
--g-toolbar-bg: var(--el-bg-color);
|
|
||||||
|
|
||||||
/* ----- 进度条 ----- */
|
|
||||||
--g-nprogress-color: var(--el-color-primary);
|
|
||||||
|
|
||||||
/* ----- 暗黑模式 ----- */
|
|
||||||
&.dark {
|
|
||||||
--g-box-shadow-color: rgb(0 0 0 / 72%);
|
|
||||||
|
|
||||||
/* ----- 应用 ----- */
|
|
||||||
// 应用背景色
|
|
||||||
--g-app-bg: var(--el-bg-color);
|
|
||||||
// 主区域背景色
|
|
||||||
--g-main-bg: var(--el-bg-color-page);
|
|
||||||
|
|
||||||
/* ----- 顶部 ----- */
|
|
||||||
// 背景色
|
|
||||||
--g-header-bg: var(--el-bg-color);
|
|
||||||
// 文字颜色
|
|
||||||
--g-header-color: #fff;
|
|
||||||
// 导航文字颜色
|
|
||||||
--g-header-menu-color: #ccc;
|
|
||||||
// 导航鼠标悬浮文字颜色
|
|
||||||
--g-header-menu-hover-color: #fff;
|
|
||||||
// 导航鼠标悬浮背景色
|
|
||||||
--g-header-menu-hover-bg: var(--el-color-primary-light-5);
|
|
||||||
// 导航选中文字颜色
|
|
||||||
--g-header-menu-active-color: #fff;
|
|
||||||
// 导航选中背景色
|
|
||||||
--g-header-menu-active-bg: var(--el-color-primary-light-3);
|
|
||||||
|
|
||||||
/* ----- 主导航 ----- */
|
|
||||||
// 背景色
|
|
||||||
--g-main-sidebar-bg: var(--el-bg-color);
|
|
||||||
// 菜单文字颜色
|
|
||||||
--g-main-sidebar-menu-color: #ccc;
|
|
||||||
// 菜单鼠标悬浮文字颜色
|
|
||||||
--g-main-sidebar-menu-hover-color: #fff;
|
|
||||||
// 菜单鼠标悬浮背景色
|
|
||||||
--g-main-sidebar-menu-hover-bg: var(--el-color-primary-light-5);
|
|
||||||
// 菜单选中文字颜色
|
|
||||||
--g-main-sidebar-menu-active-color: #fff;
|
|
||||||
// 菜单选中背景色
|
|
||||||
--g-main-sidebar-menu-active-bg: var(--el-color-primary-light-3);
|
|
||||||
|
|
||||||
/* ----- 次导航 ----- */
|
|
||||||
// 背景色
|
|
||||||
--g-sub-sidebar-bg: var(--el-bg-color-page);
|
|
||||||
// 菜单背景色
|
|
||||||
--g-sub-sidebar-menu-bg: var(--el-bg-color);
|
|
||||||
// 菜单文字颜色
|
|
||||||
--g-sub-sidebar-menu-color: var(--el-text-color-secondary);
|
|
||||||
// 菜单鼠标悬浮文字颜色
|
|
||||||
--g-sub-sidebar-menu-hover-color: var(--el-text-color-primary);
|
|
||||||
// 菜单鼠标悬浮背景色
|
|
||||||
--g-sub-sidebar-menu-hover-bg: var(--el-color-primary-light-5);
|
|
||||||
// 菜单选中文字颜色
|
|
||||||
--g-sub-sidebar-menu-active-color: var(--el-text-color-primary);
|
|
||||||
// 菜单选中背景色
|
|
||||||
--g-sub-sidebar-menu-active-bg: var(--el-color-primary-light-3);
|
|
||||||
|
|
||||||
/* ----- 顶栏 ----- */
|
|
||||||
--g-toolbar-bg: var(--el-bg-color);
|
|
||||||
|
|
||||||
/* ----- 进度条 ----- */
|
|
||||||
--g-nprogress-color: var(--el-color-primary);
|
|
||||||
}
|
|
||||||
}
|
|
0
src/components/Auth/index.vue
Normal file → Executable file
0
src/components/AuthAll/index.vue
Normal file → Executable file
10
src/components/FileUpload/index.vue
Normal file → Executable file
@ -60,7 +60,7 @@ const onSuccess: UploadProps['onSuccess'] = (res, file, fileList) => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<el-upload
|
<ElUpload
|
||||||
:headers="headers"
|
:headers="headers"
|
||||||
:action="action"
|
:action="action"
|
||||||
:data="data"
|
:data="data"
|
||||||
@ -68,12 +68,12 @@ const onSuccess: UploadProps['onSuccess'] = (res, file, fileList) => {
|
|||||||
:before-upload="beforeUpload"
|
:before-upload="beforeUpload"
|
||||||
:on-exceed="onExceed"
|
:on-exceed="onExceed"
|
||||||
:on-success="onSuccess"
|
:on-success="onSuccess"
|
||||||
:file-list="files as UploadUserFile[]"
|
:file-list="files"
|
||||||
:limit="max"
|
:limit="max"
|
||||||
drag
|
drag
|
||||||
>
|
>
|
||||||
<div class="slot">
|
<div class="slot">
|
||||||
<svg-icon name="ep:upload-filled" class="el-icon--upload" />
|
<SvgIcon name="ep:upload-filled" class="el-icon--upload" />
|
||||||
<div class="el-upload__text">
|
<div class="el-upload__text">
|
||||||
将文件拖到此处,或<em>点击上传</em>
|
将文件拖到此处,或<em>点击上传</em>
|
||||||
</div>
|
</div>
|
||||||
@ -81,11 +81,11 @@ const onSuccess: UploadProps['onSuccess'] = (res, file, fileList) => {
|
|||||||
<template #tip>
|
<template #tip>
|
||||||
<div v-if="!notip" class="el-upload__tip">
|
<div v-if="!notip" class="el-upload__tip">
|
||||||
<div style="display: inline-block;">
|
<div style="display: inline-block;">
|
||||||
<el-alert :title="`上传文件支持 ${ext.join(' / ')} 格式,单个文件大小不超过 ${size}MB,且文件数量不超过 ${max} 个`" type="info" show-icon :closable="false" />
|
<ElAlert :title="`上传文件支持 ${ext.join(' / ')} 格式,单个文件大小不超过 ${size}MB,且文件数量不超过 ${max} 个`" type="info" show-icon :closable="false" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-upload>
|
</ElUpload>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
26
src/components/FixedActionBar/index.vue
Normal file → Executable file
@ -27,27 +27,11 @@ function onScroll() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="actionbar" :class="{ shadow: !isBottom }" data-fixed-calc-width>
|
<div
|
||||||
|
class="z-4 bottom-0 p-5 text-center bg-[var(--g-container-bg)] transition" :class="[
|
||||||
|
isBottom ? 'shadow-[0_0_1px_0_var(--g-box-shadow-color)]' : 'shadow-[0_-10px_10px_-10px_var(--g-box-shadow-color)]',
|
||||||
|
]" data-fixed-calc-width
|
||||||
|
>
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
.actionbar {
|
|
||||||
z-index: 4;
|
|
||||||
bottom: 0;
|
|
||||||
padding: 20px;
|
|
||||||
text-align: center;
|
|
||||||
background-color: var(--g-app-bg);
|
|
||||||
box-shadow: 0 0 1px 0 var(--g-box-shadow-color);
|
|
||||||
transition: all 0.3s, var(--el-transition-box-shadow);
|
|
||||||
|
|
||||||
&.shadow {
|
|
||||||
box-shadow: 0 -10px 10px -10px var(--g-box-shadow-color);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.el-form-item) {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
6
src/components/ImagePreview/index.vue
Normal file → Executable file
@ -25,13 +25,13 @@ const realHeight = computed(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<el-image :src="src" fit="cover" :style="`width:${realWidth};height:${realHeight};`" :preview-src-list="[src]" preview-teleported>
|
<ElImage :src="src" fit="cover" :style="`width:${realWidth};height:${realHeight};`" :preview-src-list="[src]" preview-teleported>
|
||||||
<template #error>
|
<template #error>
|
||||||
<div class="image-slot">
|
<div class="image-slot">
|
||||||
<svg-icon name="image-load-fail" />
|
<SvgIcon name="image-load-fail" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-image>
|
</ElImage>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
26
src/components/ImageUpload/index.vue
Normal file → Executable file
@ -89,7 +89,7 @@ const onSuccess: UploadProps['onSuccess'] = (res) => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="upload-container">
|
<div class="upload-container">
|
||||||
<el-upload
|
<ElUpload
|
||||||
:show-file-list="false"
|
:show-file-list="false"
|
||||||
:headers="headers"
|
:headers="headers"
|
||||||
:action="action"
|
:action="action"
|
||||||
@ -101,37 +101,37 @@ const onSuccess: UploadProps['onSuccess'] = (res) => {
|
|||||||
drag
|
drag
|
||||||
class="image-upload"
|
class="image-upload"
|
||||||
>
|
>
|
||||||
<el-image v-if="url === ''" :src="url === '' ? placeholder : url" :style="`width:${width}px;height:${height}px;`" fit="fill">
|
<ElImage v-if="url === ''" :src="url === '' ? placeholder : url" :style="`width:${width}px;height:${height}px;`" fit="fill">
|
||||||
<template #error>
|
<template #error>
|
||||||
<div class="image-slot" :style="`width:${width}px;height:${height}px;`">
|
<div class="image-slot" :style="`width:${width}px;height:${height}px;`">
|
||||||
<svg-icon name="ep:plus" />
|
<SvgIcon name="ep:plus" class="icon" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-image>
|
</ElImage>
|
||||||
<div v-else class="image">
|
<div v-else class="image">
|
||||||
<el-image :src="url" :style="`width:${width}px;height:${height}px;`" fit="fill" />
|
<ElImage :src="url" :style="`width:${width}px;height:${height}px;`" fit="fill" />
|
||||||
<div class="mask">
|
<div class="mask">
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<span title="预览" @click.stop="preview">
|
<span title="预览" @click.stop="preview">
|
||||||
<svg-icon name="ep:zoom-in" />
|
<SvgIcon name="ep:zoom-in" class="icon" />
|
||||||
</span>
|
</span>
|
||||||
<span title="移除" @click.stop="remove">
|
<span title="移除" @click.stop="remove">
|
||||||
<svg-icon name="ep:delete" />
|
<SvgIcon name="ep:delete" class="icon" />
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-show="url === '' && uploadData.progress.percent" class="progress" :style="`width:${width}px;height:${height}px;`">
|
<div v-show="url === '' && uploadData.progress.percent" class="progress" :style="`width:${width}px;height:${height}px;`">
|
||||||
<el-image :src="uploadData.progress.preview" :style="`width:${width}px;height:${height}px;`" fit="fill" />
|
<ElImage :src="uploadData.progress.preview" :style="`width:${width}px;height:${height}px;`" fit="fill" />
|
||||||
<el-progress type="circle" :width="Math.min(width, height) * 0.8" :percentage="uploadData.progress.percent" />
|
<ElProgress type="circle" :width="Math.min(width, height) * 0.8" :percentage="uploadData.progress.percent" />
|
||||||
</div>
|
</div>
|
||||||
</el-upload>
|
</ElUpload>
|
||||||
<div v-if="!notip" class="el-upload__tip">
|
<div v-if="!notip" class="el-upload__tip">
|
||||||
<div style="display: inline-block;">
|
<div style="display: inline-block;">
|
||||||
<el-alert :title="`上传图片支持 ${ext.join(' / ')} 格式,且图片大小不超过 ${size}MB,建议图片尺寸为 ${width}*${height}`" type="info" show-icon :closable="false" />
|
<ElAlert :title="`上传图片支持 ${ext.join(' / ')} 格式,且图片大小不超过 ${size}MB,建议图片尺寸为 ${width}*${height}`" type="info" show-icon :closable="false" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<el-image-viewer v-if="uploadData.imageViewerVisible" :url-list="[url]" teleported @close="previewClose" />
|
<ElImageViewer v-if="uploadData.imageViewerVisible" :url-list="[url]" teleported @close="previewClose" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -214,7 +214,7 @@ const onSuccess: UploadProps['onSuccess'] = (res) => {
|
|||||||
color: var(--el-text-color-placeholder);
|
color: var(--el-text-color-placeholder);
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
|
|
||||||
i {
|
.icon {
|
||||||
font-size: 30px;
|
font-size: 30px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
28
src/components/ImagesUpload/index.vue
Normal file → Executable file
@ -36,7 +36,7 @@ const props = withDefaults(
|
|||||||
|
|
||||||
const emits = defineEmits<{
|
const emits = defineEmits<{
|
||||||
'update:url': [
|
'update:url': [
|
||||||
value: string[],
|
url: string[],
|
||||||
]
|
]
|
||||||
'onSuccess': [
|
'onSuccess': [
|
||||||
res: any,
|
res: any,
|
||||||
@ -108,25 +108,25 @@ const onSuccess: UploadProps['onSuccess'] = (res) => {
|
|||||||
<template>
|
<template>
|
||||||
<div class="upload-container">
|
<div class="upload-container">
|
||||||
<div v-for="(item, index) in (url as string[])" :key="index" class="images">
|
<div v-for="(item, index) in (url as string[])" :key="index" class="images">
|
||||||
<el-image v-if="index < max" :src="item" :style="`width:${width}px;height:${height}px;`" fit="cover" />
|
<ElImage v-if="index < max" :src="item" :style="`width:${width}px;height:${height}px;`" fit="cover" />
|
||||||
<div class="mask">
|
<div class="mask">
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<span title="预览" @click="preview(index)">
|
<span title="预览" @click="preview(index)">
|
||||||
<svg-icon name="ep:zoom-in" />
|
<SvgIcon name="ep:zoom-in" class="icon" />
|
||||||
</span>
|
</span>
|
||||||
<span title="移除" @click="remove(index)">
|
<span title="移除" @click="remove(index)">
|
||||||
<svg-icon name="ep:delete" />
|
<SvgIcon name="ep:delete" class="icon" />
|
||||||
</span>
|
</span>
|
||||||
<span v-show="url.length > 1" title="左移" :class="{ disabled: index === 0 }" @click="move(index, 'left')">
|
<span v-show="url.length > 1" title="左移" :class="{ disabled: index === 0 }" @click="move(index, 'left')">
|
||||||
<svg-icon name="ep:back" />
|
<SvgIcon name="ep:back" class="icon" />
|
||||||
</span>
|
</span>
|
||||||
<span v-show="url.length > 1" title="右移" :class="{ disabled: index === url.length - 1 }" @click="move(index, 'right')">
|
<span v-show="url.length > 1" title="右移" :class="{ disabled: index === url.length - 1 }" @click="move(index, 'right')">
|
||||||
<svg-icon name="ep:right" />
|
<SvgIcon name="ep:right" class="icon" />
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<el-upload
|
<ElUpload
|
||||||
v-show="url.length < max"
|
v-show="url.length < max"
|
||||||
:show-file-list="false"
|
:show-file-list="false"
|
||||||
:headers="headers"
|
:headers="headers"
|
||||||
@ -140,19 +140,19 @@ const onSuccess: UploadProps['onSuccess'] = (res) => {
|
|||||||
class="images-upload"
|
class="images-upload"
|
||||||
>
|
>
|
||||||
<div class="image-slot" :style="`width:${width}px;height:${height}px;`">
|
<div class="image-slot" :style="`width:${width}px;height:${height}px;`">
|
||||||
<svg-icon name="ep:plus" />
|
<SvgIcon name="ep:plus" class="icon" />
|
||||||
</div>
|
</div>
|
||||||
<div v-show="uploadData.progress.percent" class="progress" :style="`width:${width}px;height:${height}px;`">
|
<div v-show="uploadData.progress.percent" class="progress" :style="`width:${width}px;height:${height}px;`">
|
||||||
<el-image :src="uploadData.progress.preview" :style="`width:${width}px;height:${height}px;`" fit="fill" />
|
<ElImage :src="uploadData.progress.preview" :style="`width:${width}px;height:${height}px;`" fit="fill" />
|
||||||
<el-progress type="circle" :width="Math.min(width, height) * 0.8" :percentage="uploadData.progress.percent" />
|
<ElProgress type="circle" :width="Math.min(width, height) * 0.8" :percentage="uploadData.progress.percent" />
|
||||||
</div>
|
</div>
|
||||||
</el-upload>
|
</ElUpload>
|
||||||
<div v-if="!notip" class="el-upload__tip">
|
<div v-if="!notip" class="el-upload__tip">
|
||||||
<div style="display: inline-block;">
|
<div style="display: inline-block;">
|
||||||
<el-alert :title="`上传图片支持 ${ext.join(' / ')} 格式,单张图片大小不超过 ${size}MB,建议图片尺寸为 ${width}*${height},且图片数量不超过 ${max} 张`" type="info" show-icon :closable="false" />
|
<ElAlert :title="`上传图片支持 ${ext.join(' / ')} 格式,单张图片大小不超过 ${size}MB,建议图片尺寸为 ${width}*${height},且图片数量不超过 ${max} 张`" type="info" show-icon :closable="false" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<el-image-viewer v-if="uploadData.imageViewerVisible" :url-list="url as string[]" :initial-index="uploadData.dialogImageIndex" teleported @close="previewClose" />
|
<ElImageViewer v-if="uploadData.imageViewerVisible" :url-list="url as string[]" :initial-index="uploadData.dialogImageIndex" teleported @close="previewClose" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -243,7 +243,7 @@ const onSuccess: UploadProps['onSuccess'] = (res) => {
|
|||||||
color: var(--el-text-color-placeholder);
|
color: var(--el-text-color-placeholder);
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
|
|
||||||
i {
|
.icon {
|
||||||
font-size: 30px;
|
font-size: 30px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
49
src/components/NotAllowed/index.vue
Normal file → Executable file
@ -30,45 +30,20 @@ function goBack() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="notallowed">
|
<div class="flex items-center justify-between flex-col lg:flex-row lg:gap-12 absolute left-[50%] top-[50%] -translate-x-50% -translate-y-50%">
|
||||||
<svg-icon name="403" class="icon" />
|
<SvgIcon name="403" class="text-[300px] lg:text-[400px]" />
|
||||||
<div class="content">
|
<div class="flex flex-col gap-4">
|
||||||
<h1>403</h1>
|
<h1 class="m-0 text-6xl font-sans">
|
||||||
<div class="desc">
|
403
|
||||||
|
</h1>
|
||||||
|
<div class="desc mx-0 text-stone-5 text-xl">
|
||||||
抱歉,你无权访问该页面
|
抱歉,你无权访问该页面
|
||||||
</div>
|
</div>
|
||||||
<el-button type="primary" @click="goBack">
|
<div>
|
||||||
{{ data.countdown }} 秒后,返回首页
|
<HButton @click="goBack">
|
||||||
</el-button>
|
{{ data.countdown }} 秒后,返回首页
|
||||||
|
</HButton>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
.notallowed {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
width: 700px;
|
|
||||||
|
|
||||||
@include position-center(xy);
|
|
||||||
|
|
||||||
.icon {
|
|
||||||
font-size: 400px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content {
|
|
||||||
h1 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 72px;
|
|
||||||
color: var(--el-text-color-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.desc {
|
|
||||||
margin: 20px 0 30px;
|
|
||||||
font-size: 20px;
|
|
||||||
color: var(--el-text-color-secondary);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
67
src/components/PageHeader/index.vue
Normal file → Executable file
@ -3,73 +3,30 @@ defineOptions({
|
|||||||
name: 'PageHeader',
|
name: 'PageHeader',
|
||||||
})
|
})
|
||||||
|
|
||||||
withDefaults(
|
defineProps<{
|
||||||
defineProps<{
|
title?: string
|
||||||
title: string
|
content?: string
|
||||||
content?: string
|
}>()
|
||||||
}>(),
|
|
||||||
{
|
|
||||||
content: '',
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
const slots = useSlots()
|
const slots = useSlots()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="page-header">
|
<div class="page-header flex items-center justify-between flex-wrap gap-5 mb-5 px-5 py-4 bg-[var(--g-container-bg)] transition-background-color-300">
|
||||||
<div class="main">
|
<div class="main flex-[1_1_70%]">
|
||||||
<div class="title">
|
<div class="text-2xl">
|
||||||
{{ title }}
|
<slot name="title">
|
||||||
|
{{ title }}
|
||||||
|
</slot>
|
||||||
</div>
|
</div>
|
||||||
<div class="content">
|
<div class="mt-2 text-sm text-stone-5 empty:hidden">
|
||||||
<slot name="content">
|
<slot name="content">
|
||||||
{{ content }}
|
{{ content }}
|
||||||
</slot>
|
</slot>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="slots.default" class="sub">
|
<div v-if="slots.default" class="flex-none ml-a">
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
.page-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 20px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
padding: 16px 20px;
|
|
||||||
background-color: var(--g-app-bg);
|
|
||||||
transition: background-color 0.3s;
|
|
||||||
|
|
||||||
.main {
|
|
||||||
flex: 1 1 70%;
|
|
||||||
|
|
||||||
.title {
|
|
||||||
font-size: 22px;
|
|
||||||
color: var(--el-text-color-primary);
|
|
||||||
transition: var(--el-transition-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.content {
|
|
||||||
margin-top: 10px;
|
|
||||||
font-size: 14px;
|
|
||||||
color: var(--el-text-color-secondary);
|
|
||||||
transition: var(--el-transition-color);
|
|
||||||
|
|
||||||
&:empty {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.sub {
|
|
||||||
flex: none;
|
|
||||||
margin-left: auto;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
68
src/components/PageMain/index.vue
Normal file → Executable file
@ -18,72 +18,30 @@ const props = withDefaults(
|
|||||||
|
|
||||||
const titleSlot = !!useSlots().title
|
const titleSlot = !!useSlots().title
|
||||||
|
|
||||||
const collaspeData = ref(props.collaspe)
|
const isCollaspe = ref(props.collaspe)
|
||||||
function unCollaspe() {
|
function unCollaspe() {
|
||||||
collaspeData.value = false
|
isCollaspe.value = false
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="page-main" :class="{
|
class="page-main flex flex-col relative m-4 bg-[var(--g-container-bg)] transition-background-color-300" :class="{
|
||||||
'is-collaspe': collaspeData,
|
'of-hidden': isCollaspe,
|
||||||
}" :style="{
|
}" :style="{
|
||||||
height: collaspeData ? height : '',
|
height: isCollaspe ? height : '',
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<div v-if="titleSlot || title" class="title-container">
|
<div v-if="titleSlot || title" class="title-container px-5 py-4 border-b-1 border-b-solid border-b-[var(--g-bg)] transition-border-color-300">
|
||||||
<slot v-if="titleSlot" name="title" />
|
<slot name="title">
|
||||||
<template v-else>
|
|
||||||
{{ title }}
|
{{ title }}
|
||||||
</template>
|
</slot>
|
||||||
</div>
|
</div>
|
||||||
<slot />
|
<div class="main-container p-5">
|
||||||
<div v-if="collaspeData" class="collaspe" title="展开" @click="unCollaspe">
|
<slot />
|
||||||
<svg-icon name="ep:arrow-down" />
|
</div>
|
||||||
|
<div v-if="isCollaspe" class="collaspe absolute bottom-0 w-full pt-10 pb-2 text-center cursor-pointer bg-gradient-to-b from-transparent to-[var(--g-container-bg)]" @click="unCollaspe">
|
||||||
|
<SvgIcon name="ep:arrow-down" class="text-xl op-30 hover:op-100 transition-opacity" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
.page-main {
|
|
||||||
position: relative;
|
|
||||||
margin: 20px;
|
|
||||||
padding: 20px;
|
|
||||||
background-color: var(--g-app-bg);
|
|
||||||
transition: background-color 0.3s;
|
|
||||||
|
|
||||||
&.is-collaspe {
|
|
||||||
overflow: hidden;
|
|
||||||
|
|
||||||
.collaspe {
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
bottom: 0;
|
|
||||||
width: 100%;
|
|
||||||
padding: 40px 0 10px;
|
|
||||||
text-align: center;
|
|
||||||
font-size: 24px;
|
|
||||||
color: var(--el-text-color-primary);
|
|
||||||
text-shadow: 0 0 1px var(--el-text-color-primary);
|
|
||||||
background: linear-gradient(to bottom, transparent, var(--g-app-bg));
|
|
||||||
transition: background 0.3s, var(--el-transition-color);
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: var(--el-text-color-secondary);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.title-container {
|
|
||||||
width: calc(100% + 40px);
|
|
||||||
padding: 14px 20px;
|
|
||||||
margin-left: -20px;
|
|
||||||
margin-top: -20px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
|
||||||
transition: var(--el-transition-border);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
2
src/components/PcasCascader/index.vue
Normal file → Executable file
@ -145,5 +145,5 @@ function codeToAny(codes: string[], dictionarie: any[] = pcasData.value): any {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<el-cascader v-model="myValue" :options="pcasData as any[]" :props="{ value: 'code', label: 'name' }" :disabled="disabled" clearable filterable />
|
<ElCascader v-model="myValue" :options="pcasData as any[]" :props="{ value: 'code', label: 'name' }" :disabled="disabled" clearable filterable />
|
||||||
</template>
|
</template>
|
||||||
|
2
src/components/PcasCascader/pcas-code.json
Normal file → Executable file
48
src/components/SearchBar/index.vue
Normal file → Executable file
@ -39,45 +39,17 @@ function toggle() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="search-container" :class="{ 'has-bg': background }">
|
<div
|
||||||
|
class="relative" :class="{
|
||||||
|
'py-4': showToggle,
|
||||||
|
'px-4 bg-[var(--g-bg)] transition': background,
|
||||||
|
}"
|
||||||
|
>
|
||||||
<slot :fold="isFold" />
|
<slot :fold="isFold" />
|
||||||
<div v-if="showToggle" class="toggle">
|
<div v-if="showToggle" class="absolute left-0 bottom-0 w-full text-center translate-y-1/2">
|
||||||
<el-button text size="small" @click="toggle">
|
<button class="outline-none cursor-pointer inline-flex items-center px-2 h-5 border-size-0 font-medium text-xs rounded select-none bg-[var(--g-bg)]" @click="toggle">
|
||||||
<template #icon>
|
<SvgIcon :name="isFold ? 'ep:caret-bottom' : 'ep:caret-top' " />
|
||||||
<svg-icon :name="isFold ? 'ep:caret-bottom' : 'ep:caret-top' " />
|
</button>
|
||||||
</template>
|
|
||||||
{{ isFold ? '展开' : '收起' }}
|
|
||||||
</el-button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
.search-container {
|
|
||||||
position: relative;
|
|
||||||
|
|
||||||
&.has-bg {
|
|
||||||
padding: 20px;
|
|
||||||
background-color: var(--el-fill-color-lighter);
|
|
||||||
transition: background-color 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.el-form) {
|
|
||||||
margin-bottom: -10px;
|
|
||||||
|
|
||||||
.el-select {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.el-date-editor {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle {
|
|
||||||
position: relative;
|
|
||||||
text-align: center;
|
|
||||||
margin-bottom: -10px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
54
src/components/SvgIcon/index.vue
Normal file → Executable file
@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Icon } from '@iconify/vue'
|
import { Icon } from '@iconify/vue'
|
||||||
|
import { isOfflineUse } from '@/iconify/index.json'
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
name: 'SvgIcon',
|
name: 'SvgIcon',
|
||||||
@ -7,12 +8,38 @@ defineOptions({
|
|||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
name: string
|
name: string
|
||||||
|
async?: boolean
|
||||||
flip?: 'horizontal' | 'vertical' | 'both'
|
flip?: 'horizontal' | 'vertical' | 'both'
|
||||||
rotate?: number
|
rotate?: number
|
||||||
color?: string
|
color?: string
|
||||||
size?: string | number
|
size?: string | number
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const outputType = computed(() => {
|
||||||
|
if (props.name.indexOf('i-') === 0) {
|
||||||
|
return (props.async || isOfflineUse) ? 'svg' : 'css'
|
||||||
|
}
|
||||||
|
else if (props.name.includes(':')) {
|
||||||
|
return 'svg'
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return 'custom'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const outputName = computed(() => {
|
||||||
|
if (props.name.indexOf('i-') === 0) {
|
||||||
|
let conversionName = props.name
|
||||||
|
if (props.async || isOfflineUse) {
|
||||||
|
conversionName = conversionName.replace('i-', '')
|
||||||
|
}
|
||||||
|
return conversionName
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return props.name
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const style = computed(() => {
|
const style = computed(() => {
|
||||||
const transform = []
|
const transform = []
|
||||||
if (props.flip) {
|
if (props.flip) {
|
||||||
@ -41,28 +68,11 @@ const style = computed(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<i class="icon" :style="style">
|
<i class="h-[1em] w-[1em] leading-[1em] flex-inline justify-center items-center relative fill-current" :style="style">
|
||||||
<Icon v-if="name.indexOf('ep:') === 0" :icon="name" />
|
<i v-if="outputType === 'css'" :class="outputName" />
|
||||||
<svg v-else aria-hidden="true">
|
<Icon v-else-if="outputType === 'svg'" :icon="outputName" />
|
||||||
<use :xlink:href="`#icon-${name}`" />
|
<svg v-else h-1em w-1em aria-hidden="true">
|
||||||
|
<use :xlink:href="`#icon-${outputName}`" />
|
||||||
</svg>
|
</svg>
|
||||||
</i>
|
</i>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
.icon {
|
|
||||||
height: 1em;
|
|
||||||
width: 1em;
|
|
||||||
line-height: 1em;
|
|
||||||
display: inline-flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
position: relative;
|
|
||||||
fill: currentcolor;
|
|
||||||
|
|
||||||
svg {
|
|
||||||
height: 1em;
|
|
||||||
width: 1em;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
72
src/components/SystemInfo/index.vue
Normal file → Executable file
@ -13,36 +13,44 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<HSlideover v-model="isShow" title="系统信息">
|
||||||
<el-drawer v-model="isShow" title="系统信息" direction="rtl" :size="360">
|
<div class="px-4">
|
||||||
<el-descriptions direction="vertical" :column="1" border>
|
<h2 class="text-lg font-bold m-0">
|
||||||
<el-descriptions-item label="最后编译时间" align="center">
|
最后编译时间
|
||||||
{{ lastBuildTime }}
|
</h2>
|
||||||
</el-descriptions-item>
|
<div class="my-4 text-lg text-center font-sans">
|
||||||
</el-descriptions>
|
{{ lastBuildTime }}
|
||||||
<el-descriptions title="生产环境依赖" :column="1" size="small" border>
|
</div>
|
||||||
<el-descriptions-item v-for="(val, key) in (pkg.dependencies as object)" :key="key" :label="key">
|
</div>
|
||||||
{{ val }}
|
<div class="px-4">
|
||||||
</el-descriptions-item>
|
<h2 class="text-lg font-bold m-0">
|
||||||
</el-descriptions>
|
生产环境依赖
|
||||||
<el-descriptions title="开发环境依赖" :column="1" size="small" border>
|
</h2>
|
||||||
<el-descriptions-item v-for="(val, key) in (pkg.devDependencies as object)" :key="key" :label="key">
|
<ul class="list-none pl-0 text-sm">
|
||||||
{{ val }}
|
<li v-for="(val, key) in (pkg.dependencies as object)" :key="key" class="py-1.5 px-2 rounded flex items-center justify-between hover:bg-stone-1 dark:hover:bg-stone-9">
|
||||||
</el-descriptions-item>
|
<div class="font-bold">
|
||||||
</el-descriptions>
|
{{ key }}
|
||||||
</el-drawer>
|
</div>
|
||||||
</div>
|
<div class="font-sans">
|
||||||
|
{{ val }}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="px-4">
|
||||||
|
<h2 class="text-lg font-bold m-0">
|
||||||
|
开发环境依赖
|
||||||
|
</h2>
|
||||||
|
<ul class="list-none pl-0 text-sm">
|
||||||
|
<li v-for="(val, key) in (pkg.devDependencies as object)" :key="key" class="py-1.5 px-2 rounded flex items-center justify-between hover:bg-stone-1 dark:hover:bg-stone-9">
|
||||||
|
<div class="font-bold">
|
||||||
|
{{ key }}
|
||||||
|
</div>
|
||||||
|
<div class="font-sans">
|
||||||
|
{{ val }}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</HSlideover>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
:deep(.el-drawer__header) {
|
|
||||||
margin-bottom: initial;
|
|
||||||
padding-bottom: 20px;
|
|
||||||
border-bottom: 1px solid var(--el-border-color);
|
|
||||||
transition: var(--el-transition-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.el-descriptions) {
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
29
src/components/Trend/index.vue
Normal file → Executable file
@ -29,35 +29,10 @@ const isUp = computed(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div :class="`trend ${isUp ? 'up' : 'down'}`">
|
<div class="flex items-center transition" :class="`${isUp ? 'c-green' : 'c-red'}`">
|
||||||
<span v-if="prefix" class="prefix">{{ prefix }}</span>
|
<span v-if="prefix" class="prefix">{{ prefix }}</span>
|
||||||
<span class="text">{{ value }}</span>
|
<span class="text">{{ value }}</span>
|
||||||
<span v-if="suffix" class="suffix">{{ suffix }}</span>
|
<span v-if="suffix" class="suffix">{{ suffix }}</span>
|
||||||
<svg-icon name="ep:caret-top" />
|
<SvgIcon name="ep:caret-top" :rotate="isUp ? 0 : 180" class="ml-1 transition" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
.trend {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
transition: var(--el-transition-color);
|
|
||||||
|
|
||||||
&.up {
|
|
||||||
color: var(--el-color-success);
|
|
||||||
}
|
|
||||||
|
|
||||||
&.down {
|
|
||||||
color: var(--el-color-danger);
|
|
||||||
|
|
||||||
.icon {
|
|
||||||
transform: rotate(180deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon {
|
|
||||||
margin-left: 5px;
|
|
||||||
transition: transform 0.3s;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
@ -1,6 +0,0 @@
|
|||||||
import { addCollection } from '@iconify/vue'
|
|
||||||
|
|
||||||
export async function downloadAndInstall() {
|
|
||||||
const data = Object.freeze(await fetch('./icons/icons.json').then(r => r.json()))
|
|
||||||
addCollection(data)
|
|
||||||
}
|
|
1
src/iconify/data.json
Executable file
1
src/iconify/index.json
Executable file
@ -0,0 +1 @@
|
|||||||
|
{ "collections": ["ant-design", "ep", "flagpack", "icon-park", "mdi", "ri", "logos", "twemoji", "vscode-icons"], "isOfflineUse": false }
|
9
src/iconify/index.ts
Executable file
@ -0,0 +1,9 @@
|
|||||||
|
import { addCollection } from '@iconify/vue'
|
||||||
|
import data from './data.json'
|
||||||
|
|
||||||
|
export async function downloadAndInstall(name: string) {
|
||||||
|
const data = Object.freeze(await fetch(`./icons/${name}-raw.json`).then(r => r.json()))
|
||||||
|
addCollection(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const icons = data.sort((a, b) => a.info.name.localeCompare(b.info.name))
|
639
src/layouts/components/AppSetting/index.vue
Normal file → Executable file
@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useClipboard } from '@vueuse/core'
|
import { useClipboard } from '@vueuse/core'
|
||||||
import { ElMessage } from 'element-plus'
|
import Message from 'vue-m-message'
|
||||||
import eventBus from '@/utils/eventBus'
|
import eventBus from '@/utils/eventBus'
|
||||||
import useSettingsStore from '@/store/modules/settings'
|
import useSettingsStore from '@/store/modules/settings'
|
||||||
import useMenuStore from '@/store/modules/menu'
|
import useMenuStore from '@/store/modules/menu'
|
||||||
@ -16,6 +16,15 @@ const menuStore = useMenuStore()
|
|||||||
|
|
||||||
const isShow = ref(false)
|
const isShow = ref(false)
|
||||||
|
|
||||||
|
const isDark = computed({
|
||||||
|
get() {
|
||||||
|
return settingsStore.settings.app.colorScheme === 'dark'
|
||||||
|
},
|
||||||
|
set(value) {
|
||||||
|
settingsStore.settings.app.colorScheme = value ? 'dark' : 'light'
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
watch(() => settingsStore.settings.menu.menuMode, (value) => {
|
watch(() => settingsStore.settings.menu.menuMode, (value) => {
|
||||||
if (value === 'single') {
|
if (value === 'single') {
|
||||||
menuStore.setActived(0)
|
menuStore.setActived(0)
|
||||||
@ -35,7 +44,9 @@ const { copy, copied, isSupported } = useClipboard()
|
|||||||
|
|
||||||
watch(copied, (val) => {
|
watch(copied, (val) => {
|
||||||
if (val) {
|
if (val) {
|
||||||
ElMessage.success('复制成功,请粘贴到 src/settings.ts 文件中!')
|
Message.success('复制成功,请粘贴到 src/settings.ts 文件中!', {
|
||||||
|
zIndex: 2000,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -45,463 +56,339 @@ function handleCopy() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<HSlideover v-model="isShow" title="应用配置">
|
||||||
<el-drawer v-model="isShow" title="应用配置" direction="rtl" :size="360">
|
<div class="px-4 py-2 rounded-2 text-sm/6 c-rose bg-rose/20 ">
|
||||||
<el-alert title="应用配置可实时预览效果,但只是临时生效,要想真正作用于项目,可以点击下方的“复制配置”按钮,并将配置粘贴到 src/settings.ts 文件中。同时建议在生产环境隐藏应用配置功能。" type="error" :closable="false" />
|
<p class="my-1">
|
||||||
<el-divider>颜色主题</el-divider>
|
应用配置可实时预览效果,但只是临时生效,要想真正应用于项目,可以点击下方的「复制配置」按钮,并将配置粘贴到 src/settings.ts 文件中。
|
||||||
<div class="color-scheme">
|
</p>
|
||||||
<div class="switch" :class="settingsStore.settings.app.colorScheme" @click="settingsStore.settings.app.colorScheme = settingsStore.settings.app.colorScheme === 'dark' ? 'light' : 'dark'">
|
<p class="my-1">
|
||||||
<svg-icon :name="settingsStore.settings.app.colorScheme === 'light' ? 'ep:sunny' : 'ep:moon'" />
|
注意:在生产环境中应关闭该模块。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="divider">
|
||||||
|
颜色主题风格
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-center items-center pb-4">
|
||||||
|
<HToggle v-model="isDark" on-icon="ri:sun-line" off-icon="ri:moon-line" />
|
||||||
|
</div>
|
||||||
|
<div v-if="settingsStore.mode === 'pc'" class="divider">
|
||||||
|
导航栏模式
|
||||||
|
</div>
|
||||||
|
<div v-if="settingsStore.mode === 'pc'" class="menu-mode">
|
||||||
|
<HTooltip text="侧边栏模式 (含主导航)" placement="bottom" :delay="500">
|
||||||
|
<div class="mode mode-side" :class="{ active: settingsStore.settings.menu.menuMode === 'side' }" @click="settingsStore.settings.menu.menuMode = 'side'">
|
||||||
|
<div class="mode-container" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</HTooltip>
|
||||||
<el-divider v-if="settingsStore.mode === 'pc'">
|
<HTooltip text="顶部模式" placement="bottom" :delay="500">
|
||||||
导航栏模式
|
<div class="mode mode-head" :class="{ active: settingsStore.settings.menu.menuMode === 'head' }" @click="settingsStore.settings.menu.menuMode = 'head'">
|
||||||
</el-divider>
|
<div class="mode-container" />
|
||||||
<div v-if="settingsStore.mode === 'pc'" class="menu-mode">
|
|
||||||
<el-tooltip content="侧边栏模式(含主导航)" placement="top" :show-after="500">
|
|
||||||
<div class="mode mode-side" :class="{ active: settingsStore.settings.menu.menuMode === 'side' }" @click="settingsStore.settings.menu.menuMode = 'side'">
|
|
||||||
<div class="mode-container" />
|
|
||||||
<svg-icon name="ep:check" />
|
|
||||||
</div>
|
|
||||||
</el-tooltip>
|
|
||||||
<el-tooltip content="顶部模式" placement="top" :show-after="500">
|
|
||||||
<div class="mode mode-head" :class="{ active: settingsStore.settings.menu.menuMode === 'head' }" @click="settingsStore.settings.menu.menuMode = 'head'">
|
|
||||||
<div class="mode-container" />
|
|
||||||
<svg-icon name="ep:check" />
|
|
||||||
</div>
|
|
||||||
</el-tooltip>
|
|
||||||
<el-tooltip content="侧边栏模式(不含主导航)" placement="top" :show-after="500">
|
|
||||||
<div class="mode mode-single" :class="{ active: settingsStore.settings.menu.menuMode === 'single' }" @click="settingsStore.settings.menu.menuMode = 'single'">
|
|
||||||
<div class="mode-container" />
|
|
||||||
<svg-icon name="ep:check" />
|
|
||||||
</div>
|
|
||||||
</el-tooltip>
|
|
||||||
</div>
|
|
||||||
<el-divider>导航栏</el-divider>
|
|
||||||
<div class="setting-item">
|
|
||||||
<div class="label">
|
|
||||||
主导航切换跳转
|
|
||||||
<el-tooltip content="开启该功能后,切换主导航时,页面自动跳转至该主导航下,次导航里第一个导航" placement="top">
|
|
||||||
<svg-icon name="ep:question-filled" />
|
|
||||||
</el-tooltip>
|
|
||||||
</div>
|
</div>
|
||||||
<el-switch v-model="settingsStore.settings.menu.switchMainMenuAndPageJump" :disabled="['single'].includes(settingsStore.settings.menu.menuMode)" />
|
</HTooltip>
|
||||||
</div>
|
<HTooltip text="侧边栏模式 (不含主导航)" placement="bottom" :delay="500">
|
||||||
<div class="setting-item">
|
<div class="mode mode-single" :class="{ active: settingsStore.settings.menu.menuMode === 'single' }" @click="settingsStore.settings.menu.menuMode = 'single'">
|
||||||
<div class="label">
|
<div class="mode-container" />
|
||||||
次导航保持展开一个
|
|
||||||
<el-tooltip content="开启该功能后,次导航只保持单个菜单的展开" placement="top">
|
|
||||||
<svg-icon name="ep:question-filled" />
|
|
||||||
</el-tooltip>
|
|
||||||
</div>
|
</div>
|
||||||
<el-switch v-model="settingsStore.settings.menu.subMenuUniqueOpened" />
|
</HTooltip>
|
||||||
|
</div>
|
||||||
|
<div class="divider">
|
||||||
|
导航栏
|
||||||
|
</div>
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="label">
|
||||||
|
主导航切换跳转
|
||||||
|
<HTooltip text="开启该功能后,切换主导航时,页面自动跳转至该主导航下,次导航里第一个导航">
|
||||||
|
<SvgIcon name="ri:question-line" />
|
||||||
|
</HTooltip>
|
||||||
</div>
|
</div>
|
||||||
<div class="setting-item">
|
<HToggle v-model="settingsStore.settings.menu.switchMainMenuAndPageJump" :disabled="['single'].includes(settingsStore.settings.menu.menuMode)" />
|
||||||
<div class="label">
|
</div>
|
||||||
次导航是否折叠
|
<div class="setting-item">
|
||||||
</div>
|
<div class="label">
|
||||||
<el-switch v-model="settingsStore.settings.menu.subMenuCollapse" />
|
次导航保持展开一个
|
||||||
|
<HTooltip text="开启该功能后,次导航只保持单个菜单的展开">
|
||||||
|
<SvgIcon name="ri:question-line" />
|
||||||
|
</HTooltip>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="settingsStore.mode === 'pc'" class="setting-item">
|
<HToggle v-model="settingsStore.settings.menu.subMenuUniqueOpened" />
|
||||||
<div class="label">
|
</div>
|
||||||
显示次导航折叠按钮
|
<div class="setting-item">
|
||||||
</div>
|
<div class="label">
|
||||||
<el-switch v-model="settingsStore.settings.menu.enableSubMenuCollapseButton" />
|
次导航是否折叠
|
||||||
</div>
|
</div>
|
||||||
<div class="setting-item">
|
<HToggle v-model="settingsStore.settings.menu.subMenuCollapse" />
|
||||||
<div class="label">
|
</div>
|
||||||
是否启用快捷键
|
<div v-if="settingsStore.mode === 'pc'" class="setting-item">
|
||||||
</div>
|
<div class="label">
|
||||||
<el-switch v-model="settingsStore.settings.menu.enableHotkeys" :disabled="['single'].includes(settingsStore.settings.menu.menuMode)" />
|
显示次导航折叠按钮
|
||||||
</div>
|
</div>
|
||||||
<el-divider>顶栏</el-divider>
|
<HToggle v-model="settingsStore.settings.menu.enableSubMenuCollapseButton" />
|
||||||
<div class="setting-item">
|
</div>
|
||||||
<div class="label">
|
<div class="setting-item">
|
||||||
模式
|
<div class="label">
|
||||||
</div>
|
是否启用快捷键
|
||||||
<el-radio-group v-model="settingsStore.settings.topbar.mode" size="small">
|
|
||||||
<el-radio-button label="static">
|
|
||||||
静止
|
|
||||||
</el-radio-button>
|
|
||||||
<el-radio-button label="fixed">
|
|
||||||
固定
|
|
||||||
</el-radio-button>
|
|
||||||
<el-radio-button label="sticky">
|
|
||||||
粘性
|
|
||||||
</el-radio-button>
|
|
||||||
</el-radio-group>
|
|
||||||
</div>
|
</div>
|
||||||
<el-divider>工具栏</el-divider>
|
<HToggle v-model="settingsStore.settings.menu.enableHotkeys" :disabled="['single'].includes(settingsStore.settings.menu.menuMode)" />
|
||||||
<div v-if="settingsStore.mode === 'pc'" class="setting-item">
|
</div>
|
||||||
<div class="label">
|
<div class="divider">
|
||||||
全屏
|
顶栏
|
||||||
<el-tooltip content="该功能使用场景极少,用户习惯于通过窗口“最大化”功能来扩大显示区域,以显示更多内容,并且使用 F11 键也可以进入全屏效果" placement="top">
|
</div>
|
||||||
<svg-icon name="ep:question-filled" />
|
<div class="setting-item">
|
||||||
</el-tooltip>
|
<div class="label">
|
||||||
</div>
|
模式
|
||||||
<el-switch v-model="settingsStore.settings.toolbar.enableFullscreen" />
|
|
||||||
</div>
|
</div>
|
||||||
<div class="setting-item">
|
<HCheckList
|
||||||
<div class="label">
|
v-model="settingsStore.settings.topbar.mode" :options="[
|
||||||
页面刷新
|
{ label: '静止', value: 'static' },
|
||||||
<el-tooltip content="开启时会阻止原生 F5 键刷新功能,并采用框架提供的刷新模式进行页面刷新" placement="top">
|
{ label: '固定', value: 'fixed' },
|
||||||
<svg-icon name="ep:question-filled" />
|
{ label: '粘性', value: 'sticky' },
|
||||||
</el-tooltip>
|
]"
|
||||||
</div>
|
/>
|
||||||
<el-switch v-model="settingsStore.settings.toolbar.enablePageReload" />
|
</div>
|
||||||
|
<div class="divider">
|
||||||
|
工具栏
|
||||||
|
</div>
|
||||||
|
<div v-if="settingsStore.mode === 'pc'" class="setting-item">
|
||||||
|
<div class="label">
|
||||||
|
全屏
|
||||||
</div>
|
</div>
|
||||||
<div class="setting-item">
|
<HToggle v-model="settingsStore.settings.toolbar.enableFullscreen" />
|
||||||
<div class="label">
|
</div>
|
||||||
颜色主题
|
<div class="setting-item">
|
||||||
<el-tooltip content="开启后可在明亮/暗黑模式中切换" placement="top">
|
<div class="label">
|
||||||
<svg-icon name="ep:question-filled" />
|
页面刷新
|
||||||
</el-tooltip>
|
<HTooltip text="使用框架内提供的刷新功能进行页面刷新">
|
||||||
</div>
|
<SvgIcon name="ri:question-line" />
|
||||||
<el-switch v-model="settingsStore.settings.toolbar.enableColorScheme" />
|
</HTooltip>
|
||||||
</div>
|
</div>
|
||||||
<el-divider v-if="settingsStore.mode === 'pc'">
|
<HToggle v-model="settingsStore.settings.toolbar.enablePageReload" />
|
||||||
面包屑导航
|
</div>
|
||||||
</el-divider>
|
<div class="setting-item">
|
||||||
<div v-if="settingsStore.mode === 'pc'" class="setting-item">
|
<div class="label">
|
||||||
<div class="label">
|
颜色主题
|
||||||
是否启用
|
<HTooltip text="开启后可在明亮/暗黑模式中切换">
|
||||||
</div>
|
<SvgIcon name="ri:question-line" />
|
||||||
<el-switch v-model="settingsStore.settings.breadcrumb.enable" />
|
</HTooltip>
|
||||||
</div>
|
</div>
|
||||||
<el-divider>导航搜索</el-divider>
|
<HToggle v-model="settingsStore.settings.toolbar.enableColorScheme" />
|
||||||
<div class="setting-item">
|
</div>
|
||||||
<div class="label">
|
<div v-if="settingsStore.mode === 'pc'" class="divider">
|
||||||
是否启用
|
面包屑导航
|
||||||
<el-tooltip content="对导航进行快捷搜索" placement="top">
|
</div>
|
||||||
<svg-icon name="ep:question-filled" />
|
<div v-if="settingsStore.mode === 'pc'" class="setting-item">
|
||||||
</el-tooltip>
|
<div class="label">
|
||||||
</div>
|
是否启用
|
||||||
<el-switch v-model="settingsStore.settings.navSearch.enable" />
|
|
||||||
</div>
|
</div>
|
||||||
<div class="setting-item">
|
<HToggle v-model="settingsStore.settings.breadcrumb.enable" />
|
||||||
<div class="label">
|
</div>
|
||||||
是否启用快捷键
|
<div class="divider">
|
||||||
</div>
|
页面
|
||||||
<el-switch v-model="settingsStore.settings.navSearch.enableHotkeys" :disabled="!settingsStore.settings.navSearch.enable" />
|
</div>
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="label">
|
||||||
|
是否启用快捷键
|
||||||
</div>
|
</div>
|
||||||
<el-divider>底部版权</el-divider>
|
<HToggle v-model="settingsStore.settings.mainPage.enableHotkeys" />
|
||||||
<div class="setting-item">
|
</div>
|
||||||
<div class="label">
|
<div class="divider">
|
||||||
是否启用
|
导航搜索
|
||||||
</div>
|
</div>
|
||||||
<el-switch v-model="settingsStore.settings.copyright.enable" />
|
<div class="setting-item">
|
||||||
|
<div class="label">
|
||||||
|
是否启用
|
||||||
|
<HTooltip text="对导航进行快捷搜索">
|
||||||
|
<SvgIcon name="ri:question-line" />
|
||||||
|
</HTooltip>
|
||||||
</div>
|
</div>
|
||||||
<div class="setting-item">
|
<HToggle v-model="settingsStore.settings.navSearch.enable" />
|
||||||
<div class="label">
|
</div>
|
||||||
日期
|
<div class="setting-item">
|
||||||
</div>
|
<div class="label">
|
||||||
<el-input v-model="settingsStore.settings.copyright.dates" size="small" :disabled="!settingsStore.settings.copyright.enable" />
|
是否启用快捷键
|
||||||
</div>
|
</div>
|
||||||
<div class="setting-item">
|
<HToggle v-model="settingsStore.settings.navSearch.enableHotkeys" :disabled="!settingsStore.settings.navSearch.enable" />
|
||||||
<div class="label">
|
</div>
|
||||||
公司
|
<div class="divider">
|
||||||
</div>
|
底部版权
|
||||||
<el-input v-model="settingsStore.settings.copyright.company" size="small" :disabled="!settingsStore.settings.copyright.enable" />
|
</div>
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="label">
|
||||||
|
是否启用
|
||||||
</div>
|
</div>
|
||||||
<div class="setting-item">
|
<HToggle v-model="settingsStore.settings.copyright.enable" />
|
||||||
<div class="label">
|
</div>
|
||||||
网址
|
<div class="setting-item">
|
||||||
</div>
|
<div class="label">
|
||||||
<el-input v-model="settingsStore.settings.copyright.website" size="small" :disabled="!settingsStore.settings.copyright.enable" />
|
日期
|
||||||
</div>
|
</div>
|
||||||
<div class="setting-item">
|
<HInput v-model="settingsStore.settings.copyright.dates" :disabled="!settingsStore.settings.copyright.enable" />
|
||||||
<div class="label">
|
</div>
|
||||||
备案
|
<div class="setting-item">
|
||||||
</div>
|
<div class="label">
|
||||||
<el-input v-model="settingsStore.settings.copyright.beian" size="small" :disabled="!settingsStore.settings.copyright.enable" />
|
公司
|
||||||
</div>
|
</div>
|
||||||
<el-divider>主页</el-divider>
|
<HInput v-model="settingsStore.settings.copyright.company" :disabled="!settingsStore.settings.copyright.enable" />
|
||||||
<div class="setting-item">
|
</div>
|
||||||
<div class="label">
|
<div class="setting-item">
|
||||||
是否开启
|
<div class="label">
|
||||||
<el-tooltip content="该功能开启时,登录成功默认进入主页,反之则默认进入导航栏里第一个导航页面" placement="top">
|
网址
|
||||||
<svg-icon name="ep:question-filled" />
|
|
||||||
</el-tooltip>
|
|
||||||
</div>
|
|
||||||
<el-switch v-model="settingsStore.settings.home.enable" />
|
|
||||||
</div>
|
</div>
|
||||||
<div class="setting-item">
|
<HInput v-model="settingsStore.settings.copyright.website" :disabled="!settingsStore.settings.copyright.enable" />
|
||||||
<div class="label">
|
</div>
|
||||||
主页名称
|
<div class="setting-item">
|
||||||
</div>
|
<div class="label">
|
||||||
<el-input v-model="settingsStore.settings.home.title" size="small" />
|
备案
|
||||||
</div>
|
</div>
|
||||||
<el-divider>其它</el-divider>
|
<HInput v-model="settingsStore.settings.copyright.beian" :disabled="!settingsStore.settings.copyright.enable" />
|
||||||
<div class="setting-item">
|
</div>
|
||||||
<div class="label">
|
<div class="divider">
|
||||||
组件尺寸
|
主页
|
||||||
<el-tooltip content="全局设置 Element Plus 组件的默认尺寸大小" placement="top">
|
</div>
|
||||||
<svg-icon name="ep:question-filled" />
|
<div class="setting-item">
|
||||||
</el-tooltip>
|
<div class="label">
|
||||||
</div>
|
是否启用
|
||||||
<el-radio-group v-model="settingsStore.settings.app.elementSize" size="small">
|
<HTooltip text="该功能开启时,登录成功默认进入主页,反之则默认进入导航栏里第一个导航页面">
|
||||||
<el-radio-button label="large">
|
<SvgIcon name="ri:question-line" />
|
||||||
较大
|
</HTooltip>
|
||||||
</el-radio-button>
|
|
||||||
<el-radio-button label="default">
|
|
||||||
默认
|
|
||||||
</el-radio-button>
|
|
||||||
<el-radio-button label="small">
|
|
||||||
稍小
|
|
||||||
</el-radio-button>
|
|
||||||
</el-radio-group>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="setting-item">
|
<HToggle v-model="settingsStore.settings.home.enable" />
|
||||||
<div class="label">
|
</div>
|
||||||
是否启用权限
|
<div class="setting-item">
|
||||||
</div>
|
<div class="label">
|
||||||
<el-switch v-model="settingsStore.settings.app.enablePermission" />
|
主页名称
|
||||||
|
<HTooltip text="开启国际化时,该设置无效">
|
||||||
|
<SvgIcon name="ri:question-line" />
|
||||||
|
</HTooltip>
|
||||||
</div>
|
</div>
|
||||||
<div class="setting-item">
|
<HInput v-model="settingsStore.settings.home.title" />
|
||||||
<div class="label">
|
</div>
|
||||||
载入进度条
|
<div class="divider">
|
||||||
<el-tooltip content="该功能开启时,跳转路由会看到页面顶部有进度条" placement="top">
|
其它
|
||||||
<svg-icon name="ep:question-filled" />
|
</div>
|
||||||
</el-tooltip>
|
<div class="setting-item">
|
||||||
</div>
|
<div class="label">
|
||||||
<el-switch v-model="settingsStore.settings.app.enableProgress" />
|
是否启用权限
|
||||||
</div>
|
</div>
|
||||||
<div class="setting-item">
|
<HToggle v-model="settingsStore.settings.app.enablePermission" />
|
||||||
<div class="label">
|
</div>
|
||||||
动态标题
|
<div class="setting-item">
|
||||||
<el-tooltip content="该功能开启时,页面标题会显示当前路由标题,格式为“页面标题 - 网站名称”;关闭时则显示网站名称,网站名称在项目根目录下 .env.* 文件里配置" placement="top">
|
<div class="label">
|
||||||
<svg-icon name="ep:question-filled" />
|
载入进度条
|
||||||
</el-tooltip>
|
<HTooltip text="该功能开启时,跳转路由会看到页面顶部有进度条">
|
||||||
</div>
|
<SvgIcon name="ri:question-line" />
|
||||||
<el-switch v-model="settingsStore.settings.app.enableDynamicTitle" />
|
</HTooltip>
|
||||||
</div>
|
</div>
|
||||||
<template v-if="isSupported" #footer>
|
<HToggle v-model="settingsStore.settings.app.enableProgress" />
|
||||||
<el-button type="primary" @click="handleCopy">
|
</div>
|
||||||
<template #icon>
|
<div class="setting-item">
|
||||||
<svg-icon name="ep:document-copy" />
|
<div class="label">
|
||||||
</template>
|
动态标题
|
||||||
复制配置
|
<HTooltip text="该功能开启时,页面标题会显示当前路由标题,格式为“页面标题 - 网站名称”;关闭时则显示网站名称,网站名称在项目根目录下 .env.* 文件里配置">
|
||||||
</el-button>
|
<SvgIcon name="ri:question-line" />
|
||||||
</template>
|
</HTooltip>
|
||||||
</el-drawer>
|
</div>
|
||||||
</div>
|
<HToggle v-model="settingsStore.settings.app.enableDynamicTitle" />
|
||||||
|
</div>
|
||||||
|
<template v-if="isSupported" #footer>
|
||||||
|
<HButton block @click="handleCopy">
|
||||||
|
<SvgIcon name="ep:document-copy" />
|
||||||
|
复制配置
|
||||||
|
</HButton>
|
||||||
|
</template>
|
||||||
|
</HSlideover>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
:deep(.el-drawer__header) {
|
.divider {
|
||||||
margin-bottom: initial;
|
--at-apply: flex items-center justify-between gap-4 my-4 text-nowrap text-sm font-500;
|
||||||
padding-bottom: 20px;
|
|
||||||
border-bottom: 1px solid var(--el-border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.el-drawer__footer) {
|
&::before,
|
||||||
padding: 20px;
|
&::after {
|
||||||
border-top: 1px solid var(--el-border-color);
|
--at-apply: content-empty w-full h-1px bg-stone-2 dark:bg-stone-6;
|
||||||
transition: var(--el-transition-border);
|
|
||||||
|
|
||||||
.el-button {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.el-divider) {
|
|
||||||
margin: 36px 0 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.color-scheme {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding-bottom: 10px;
|
|
||||||
|
|
||||||
$width: 50px;
|
|
||||||
|
|
||||||
.switch {
|
|
||||||
width: $width;
|
|
||||||
height: 30px;
|
|
||||||
border-radius: 15px;
|
|
||||||
cursor: pointer;
|
|
||||||
background-color: var(--el-fill-color-darker);
|
|
||||||
transition: background-color 0.3s;
|
|
||||||
|
|
||||||
&.dark {
|
|
||||||
.icon {
|
|
||||||
margin-left: calc($width - 24px - 3px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon {
|
|
||||||
margin: 3px;
|
|
||||||
padding: 5px;
|
|
||||||
font-size: 24px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background-color: var(--el-fill-color-lighter);
|
|
||||||
transition: margin-left 0.3s, background-color 0.3s;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-mode {
|
.menu-mode {
|
||||||
display: flex;
|
--at-apply: flex items-center justify-center gap-4 pb-4;
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding-bottom: 10px;
|
|
||||||
|
|
||||||
.mode {
|
.mode {
|
||||||
position: relative;
|
--at-apply: relative w-16 h-12 rounded-2 ring-1 ring-stone-2 dark:ring-stone-7 cursor-pointer transition;
|
||||||
width: 80px;
|
|
||||||
height: 55px;
|
|
||||||
margin: 10px;
|
|
||||||
border-radius: 5px;
|
|
||||||
overflow: hidden;
|
|
||||||
cursor: pointer;
|
|
||||||
background-color: var(--g-app-bg);
|
|
||||||
box-shadow: 0 0 5px 1px var(--el-border-color-lighter);
|
|
||||||
transition: 0.2s;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
box-shadow: 0 0 5px 1px var(--el-border-color-darker);
|
|
||||||
}
|
|
||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
box-shadow: 0 0 0 2px var(--el-color-primary);
|
--at-apply: ring-ui-primary ring-2;
|
||||||
}
|
}
|
||||||
|
|
||||||
&::before,
|
&::before,
|
||||||
&::after,
|
&::after,
|
||||||
.mode-container {
|
.mode-container {
|
||||||
pointer-events: none;
|
--at-apply: absolute pointer-events-none;
|
||||||
position: absolute;
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.mode-container::before {
|
&::before {
|
||||||
content: "";
|
--at-apply: content-empty bg-ui-primary;
|
||||||
position: absolute;
|
}
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
&::after {
|
||||||
background-color: var(--g-sub-sidebar-menu-active-bg);
|
--at-apply: content-empty bg-ui-primary/60;
|
||||||
opacity: 0.2;
|
}
|
||||||
|
|
||||||
|
.mode-container {
|
||||||
|
--at-apply: bg-ui-primary/20 border-dashed border-ui-primary;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
--at-apply: content-empty absolute w-full h-full;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&-side {
|
&-side {
|
||||||
&::before {
|
&::before {
|
||||||
content: "";
|
--at-apply: top-2 bottom-2 left-2 w-2 rounded-tl-1 rounded-bl-1;
|
||||||
top: 5px;
|
|
||||||
left: 5px;
|
|
||||||
bottom: 5px;
|
|
||||||
width: 10px;
|
|
||||||
background-color: var(--g-sub-sidebar-menu-active-bg);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&::after {
|
&::after {
|
||||||
content: "";
|
--at-apply: top-2 bottom-2 left-4.5 w-3;
|
||||||
top: 5px;
|
|
||||||
left: 20px;
|
|
||||||
bottom: 5px;
|
|
||||||
width: 15px;
|
|
||||||
background-color: var(--g-sub-sidebar-menu-active-bg);
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.mode-container {
|
.mode-container {
|
||||||
inset: 5px 5px 5px 40px;
|
--at-apply: inset-t-2 inset-r-2 inset-b-2 inset-l-8 rounded-tr-1 rounded-br-1;
|
||||||
border: 1px dashed var(--g-sub-sidebar-menu-active-bg);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&-head {
|
&-head {
|
||||||
&::before {
|
&::before {
|
||||||
content: "";
|
--at-apply: top-2 left-2 right-2 h-2 rounded-tl-1 rounded-tr-1;
|
||||||
top: 5px;
|
|
||||||
left: 5px;
|
|
||||||
right: 5px;
|
|
||||||
height: 10px;
|
|
||||||
background-color: var(--g-sub-sidebar-menu-active-bg);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&::after {
|
&::after {
|
||||||
content: "";
|
--at-apply: top-4.5 left-2 bottom-2 w-3 rounded-bl-1;
|
||||||
top: 20px;
|
|
||||||
left: 5px;
|
|
||||||
bottom: 5px;
|
|
||||||
width: 15px;
|
|
||||||
background-color: var(--g-sub-sidebar-menu-active-bg);
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.mode-container {
|
.mode-container {
|
||||||
inset: 20px 5px 5px 25px;
|
--at-apply: inset-t-4.5 inset-r-2 inset-b-2 inset-l-5.5 rounded-br-1;
|
||||||
border: 1px dashed var(--g-sub-sidebar-menu-active-bg);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&-single {
|
&-single {
|
||||||
&::before {
|
&::after {
|
||||||
content: "";
|
--at-apply: top-2 left-2 bottom-2 w-3 rounded-tl-1 rounded-bl-1;
|
||||||
position: absolute;
|
|
||||||
top: 5px;
|
|
||||||
left: 5px;
|
|
||||||
bottom: 5px;
|
|
||||||
width: 15px;
|
|
||||||
background-color: var(--g-sub-sidebar-menu-active-bg);
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.mode-container {
|
.mode-container {
|
||||||
inset: 5px 5px 5px 25px;
|
--at-apply: inset-t-2 inset-r-2 inset-b-2 inset-l-5.5 rounded-tr-1 rounded-br-1;
|
||||||
border: 1px dashed var(--g-sub-sidebar-menu-active-bg);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
i {
|
|
||||||
position: absolute;
|
|
||||||
right: 10px;
|
|
||||||
bottom: 10px;
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.active i {
|
|
||||||
display: block;
|
|
||||||
color: var(--el-color-primary);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.setting-item {
|
.setting-item {
|
||||||
display: flex;
|
--at-apply: flex items-center justify-between gap-4 px-4 py-2 rounded-2 transition hover:bg-stone-1 dark:hover:bg-stone-9;
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin: 5px 0;
|
|
||||||
padding: 5px 10px;
|
|
||||||
border-radius: 5px;
|
|
||||||
transition: all 0.3s;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: var(--el-fill-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.label {
|
.label {
|
||||||
font-size: 14px;
|
--at-apply: flex items-center flex-shrink-0 gap-2 text-sm;
|
||||||
color: var(--el-text-color-regular);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
i {
|
i {
|
||||||
margin-left: 4px;
|
--at-apply: text-xl text-orange cursor-help;
|
||||||
font-size: 17px;
|
|
||||||
color: var(--el-color-warning);
|
|
||||||
cursor: help;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-switch {
|
|
||||||
height: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.el-input {
|
|
||||||
width: 150px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
45
src/layouts/components/BackTop/index.vue
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineOptions({
|
||||||
|
name: 'BackTop',
|
||||||
|
})
|
||||||
|
|
||||||
|
const transitionClass = {
|
||||||
|
enterActiveClass: 'ease-out duration-300',
|
||||||
|
enterFromClass: 'opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95',
|
||||||
|
enterToClass: 'opacity-100 translate-y-0 sm:scale-100',
|
||||||
|
leaveActiveClass: 'ease-in duration-200',
|
||||||
|
leaveFromClass: 'opacity-100 translate-y-0 sm:scale-100',
|
||||||
|
leaveToClass: 'opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95',
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener('scroll', handleScroll)
|
||||||
|
handleScroll()
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener('scroll', handleScroll)
|
||||||
|
})
|
||||||
|
|
||||||
|
const scrollTop = ref<number | null>(null)
|
||||||
|
function handleScroll() {
|
||||||
|
scrollTop.value = document.documentElement.scrollTop
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClick() {
|
||||||
|
document.documentElement.scrollTo({
|
||||||
|
top: 0,
|
||||||
|
behavior: 'smooth',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<Transition v-bind="transitionClass">
|
||||||
|
<div v-if="scrollTop && scrollTop >= 200" class="fixed z-1000 right-4 bottom-4 w-12 h-12 flex justify-center items-center rounded-full cursor-pointer shadow-lg ring-1 ring-inset ring-stone-3 dark:ring-stone-7 bg-white dark:bg-dark hover:bg-stone-1 dark:hover:bg-dark/50" @click="handleClick">
|
||||||
|
<SvgIcon name="icon-park-outline:to-top-one" :size="24" />
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
21
src/layouts/components/Breadcrumb/index.vue
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex items-center text-sm">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
:deep(.breadcrumb-item) {
|
||||||
|
&:first-child {
|
||||||
|
.separator {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
.text {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
38
src/layouts/components/Breadcrumb/item.vue
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { RouteLocationRaw } from 'vue-router'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
to?: RouteLocationRaw
|
||||||
|
replace?: boolean
|
||||||
|
separator?: string
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
separator: '/',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
function onClick() {
|
||||||
|
if (props.to) {
|
||||||
|
props.replace ? router.replace(props.to) : router.push(props.to)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="breadcrumb-item flex items-center text-dark dark:text-white">
|
||||||
|
<span class="separator mx-2">
|
||||||
|
{{ separator }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="text flex items-center opacity-60"
|
||||||
|
:class="{
|
||||||
|
'is-link cursor-pointer transition-opacity hover:opacity-100': !!props.to,
|
||||||
|
}" @click="onClick"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
33
src/layouts/components/Copyright/index.vue
Normal file → Executable file
@ -5,14 +5,13 @@ defineOptions({
|
|||||||
name: 'Copyright',
|
name: 'Copyright',
|
||||||
})
|
})
|
||||||
|
|
||||||
const route = useRoute()
|
|
||||||
const settingsStore = useSettingsStore()
|
const settingsStore = useSettingsStore()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<footer v-if="route.meta.copyright ?? settingsStore.settings.copyright.enable" class="copyright">
|
<footer v-if="settingsStore.settings.copyright.enable" class="copyright">
|
||||||
<span>Copyright</span>
|
<span>Copyright</span>
|
||||||
<span class="icon">©</span>
|
<SvgIcon name="ri:copyright-line" :size="18" />
|
||||||
<span v-if="settingsStore.settings.copyright.dates">{{ settingsStore.settings.copyright.dates }}</span>
|
<span v-if="settingsStore.settings.copyright.dates">{{ settingsStore.settings.copyright.dates }}</span>
|
||||||
<template v-if="settingsStore.settings.copyright.company">
|
<template v-if="settingsStore.settings.copyright.company">
|
||||||
<a v-if="settingsStore.settings.copyright.website" :href="settingsStore.settings.copyright.website" target="_blank" rel="noopener">{{ settingsStore.settings.copyright.company }}</a>
|
<a v-if="settingsStore.settings.copyright.website" :href="settingsStore.settings.copyright.website" target="_blank" rel="noopener">{{ settingsStore.settings.copyright.company }}</a>
|
||||||
@ -23,32 +22,20 @@ const settingsStore = useSettingsStore()
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
footer {
|
.copyright {
|
||||||
display: flex;
|
--at-apply: flex items-center justify-center flex-wrap my-4 px-4 text-sm text-stone-5;
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
margin: 40px 0 20px;
|
|
||||||
color: var(--el-text-color-secondary);
|
|
||||||
font-size: 14px;
|
|
||||||
|
|
||||||
span,
|
span,
|
||||||
a {
|
a {
|
||||||
padding: 0 5px;
|
--at-apply: px-1;
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
text-decoration: none;
|
|
||||||
color: var(--el-text-color-secondary);
|
|
||||||
transition: var(--el-transition-color);
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: var(--el-text-color-primary);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon {
|
.icon {
|
||||||
padding: 0;
|
--at-apply: mx-1;
|
||||||
font-size: 18px;
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
--at-apply: text-center no-underline text-stone-5 hover:text-dark dark:hover:text-light transition;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
106
src/layouts/components/Header/index.vue
Normal file → Executable file
@ -1,8 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import Logo from '../Logo/index.vue'
|
import Logo from '../Logo/index.vue'
|
||||||
import Tools from '../Tools/index.vue'
|
import Tools from '../Tools/index.vue'
|
||||||
import useSettingsStore from '@/store/modules/settings'
|
|
||||||
import useMenuStore from '@/store/modules/menu'
|
import useMenuStore from '@/store/modules/menu'
|
||||||
|
import useSettingsStore from '@/store/modules/settings'
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
name: 'LayoutHeader',
|
name: 'LayoutHeader',
|
||||||
@ -13,57 +13,74 @@ const menuStore = useMenuStore()
|
|||||||
|
|
||||||
const { switchTo } = useMenu()
|
const { switchTo } = useMenu()
|
||||||
|
|
||||||
const navRef = ref()
|
const menuRef = ref()
|
||||||
|
|
||||||
// 顶部模式鼠标滚动
|
// 顶部模式鼠标滚动
|
||||||
function handlerMouserScroll(event: WheelEvent) {
|
function handlerMouserScroll(event: WheelEvent) {
|
||||||
navRef.value.scrollBy({
|
menuRef.value.scrollBy({
|
||||||
left: (event.deltaY || event.detail) > 0 ? 50 : -50,
|
left: (event.deltaY || event.detail) > 0 ? 50 : -50,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<transition name="header">
|
<Transition name="header">
|
||||||
<header v-if="settingsStore.mode === 'pc' && settingsStore.settings.menu.menuMode === 'head'">
|
<header v-if="settingsStore.mode === 'pc' && settingsStore.settings.menu.menuMode === 'head'">
|
||||||
<div class="header-container">
|
<div class="header-container">
|
||||||
<div class="main">
|
<div class="main">
|
||||||
<Logo />
|
<Logo class="title" />
|
||||||
<!-- 顶部模式 -->
|
<div ref="menuRef" class="menu-container" @wheel.prevent="handlerMouserScroll">
|
||||||
<div ref="navRef" class="nav" @wheel.prevent="handlerMouserScroll">
|
<!-- 顶部模式 -->
|
||||||
<template v-for="(item, index) in menuStore.allMenus" :key="index">
|
<div class="menu flex of-hidden transition-all">
|
||||||
<div v-if="item.children && item.children.length !== 0" class="item-container" :class="{ active: index === menuStore.actived }">
|
<template v-for="(item, index) in menuStore.allMenus" :key="index">
|
||||||
<div class="item" @click="switchTo(index)">
|
<div
|
||||||
<svg-icon v-if="item.meta?.icon" :name="item.meta.icon" />
|
class="menu-item relative transition-all" :class="{
|
||||||
<span v-if="item.meta?.title">{{ item.meta.title }}</span>
|
active: index === menuStore.actived,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="item.children && item.children.length !== 0" class="menu-item-container w-full h-full flex justify-between items-center gap-1 px-5 py-4 transition-all cursor-pointer group text-[var(--g-header-menu-color)] hover:(text-[var(--g-header-menu-hover-color)] bg-[var(--g-header-menu-hover-bg)])" :class="{
|
||||||
|
'text-[var(--g-header-menu-active-color)]! bg-[var(--g-header-menu-active-bg)]!': index === menuStore.actived,
|
||||||
|
}" :title="item.meta?.title" @click="switchTo(index)"
|
||||||
|
>
|
||||||
|
<div class="inline-flex flex-col justify-center items-center flex-1">
|
||||||
|
<SvgIcon v-if="item.meta?.icon" :name="item.meta?.icon" :size="20" class="menu-item-container-icon transition-transform group-hover:scale-120" async />
|
||||||
|
<span class="flex-1 text-sm w-full text-center truncate transition-width transition-height transition-opacity">
|
||||||
|
{{ item.meta?.title }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
</template>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Tools />
|
<Tools />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
</transition>
|
</Transition>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
header {
|
header {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
z-index: 1000;
|
z-index: 2000;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
margin: 0 auto;
|
||||||
padding: 0 20px;
|
padding: 0 20px;
|
||||||
|
width: 100%;
|
||||||
height: var(--g-header-height);
|
height: var(--g-header-height);
|
||||||
color: var(--g-header-color);
|
color: var(--g-header-color);
|
||||||
background-color: var(--g-header-bg);
|
background-color: var(--g-header-bg);
|
||||||
transition: background-color 0.3s, var(--el-transition-color);
|
box-shadow: -1px 0 0 0 var(--g-border-color), 1px 0 0 0 var(--g-border-color), 0 1px 0 0 var(--g-border-color);
|
||||||
|
transition: background-color 0.3s;
|
||||||
|
|
||||||
.header-container {
|
.header-container {
|
||||||
width: var(--g-header-width);
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -79,13 +96,7 @@ header {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (max-width: var(--g-header-width)) {
|
:deep(a.title) {
|
||||||
.header-container {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.title) {
|
|
||||||
position: relative;
|
position: relative;
|
||||||
width: inherit;
|
width: inherit;
|
||||||
height: inherit;
|
height: inherit;
|
||||||
@ -104,13 +115,11 @@ header {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav {
|
.menu-container {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
|
||||||
width: 0;
|
|
||||||
height: 100%;
|
|
||||||
margin: 0 30px;
|
margin: 0 30px;
|
||||||
padding: 0 20px;
|
padding: 0 20px;
|
||||||
|
height: 100%;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
mask-image: linear-gradient(to right, transparent, #000 20px, #000 calc(100% - 20px), transparent);
|
mask-image: linear-gradient(to right, transparent, #000 20px, #000 calc(100% - 20px), transparent);
|
||||||
|
|
||||||
@ -121,45 +130,32 @@ header {
|
|||||||
&::-webkit-scrollbar {
|
&::-webkit-scrollbar {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.item-container {
|
.menu {
|
||||||
position: relative;
|
display: inline-flex;
|
||||||
display: flex;
|
height: 100%;
|
||||||
width: initial;
|
background-color: var(--g-header-bg);
|
||||||
|
|
||||||
.item {
|
:deep(.menu-item) {
|
||||||
display: flex;
|
.menu-item-container {
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
flex-direction: column;
|
|
||||||
padding: 0 5px;
|
|
||||||
width: 80px;
|
width: 80px;
|
||||||
height: 100%;
|
padding: 0 5px;
|
||||||
cursor: pointer;
|
|
||||||
color: var(--g-header-menu-color);
|
color: var(--g-header-menu-color);
|
||||||
background-color: var(--g-header-bg);
|
background-color: var(--g-header-menu-bg);
|
||||||
transition: background-color 0.3s, var(--el-transition-color);
|
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: var(--g-header-menu-hover-color);
|
color: var(--g-header-menu-hover-color);
|
||||||
background-color: var(--g-header-menu-hover-bg);
|
background-color: var(--g-header-menu-hover-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon {
|
.menu-item-container-icon {
|
||||||
font-size: 24px;
|
transform: scale(1);
|
||||||
vertical-align: middle;
|
font-size: 24px !important;
|
||||||
}
|
|
||||||
|
|
||||||
span {
|
|
||||||
text-align: center;
|
|
||||||
vertical-align: middle;
|
|
||||||
word-break: break-all;
|
|
||||||
|
|
||||||
@include text-overflow(1, false);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.active .item {
|
&.active .menu-item-container {
|
||||||
color: var(--g-header-menu-active-color);
|
color: var(--g-header-menu-active-color);
|
||||||
background-color: var(--g-header-menu-active-bg);
|
background-color: var(--g-header-menu-active-bg);
|
||||||
}
|
}
|
||||||
|
69
src/layouts/components/HotkeysIntro/index.vue
Normal file → Executable file
@ -18,38 +18,39 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<HDialog v-model="isShow" title="快捷键介绍">
|
||||||
<el-drawer v-model="isShow" title="快捷键介绍" direction="rtl" :size="360">
|
<div class="px-4">
|
||||||
<el-descriptions title="全局" :column="1" border>
|
<div class="grid md:grid-cols-2 gap-2">
|
||||||
<el-descriptions-item label="查看系统信息">
|
<div>
|
||||||
{{ settingsStore.os === 'mac' ? '⌥' : 'Alt' }} + I
|
<h2 class="text-lg font-bold m-0">
|
||||||
</el-descriptions-item>
|
全局
|
||||||
<el-descriptions-item v-if="settingsStore.settings.navSearch.enable && settingsStore.settings.navSearch.enableHotkeys" label="唤起导航搜索">
|
</h2>
|
||||||
{{ settingsStore.os === 'mac' ? '⌥' : 'Alt' }} + S
|
<ul class="list-none pl-4 text-sm">
|
||||||
</el-descriptions-item>
|
<li class="py-1">
|
||||||
</el-descriptions>
|
<HKbd>{{ settingsStore.os === 'mac' ? '⌥' : 'Alt' }}</HKbd>
|
||||||
<el-descriptions v-if="settingsStore.settings.menu.enableHotkeys && ['side', 'head'].includes(settingsStore.settings.menu.menuMode)" title="主导航" :column="1" border>
|
<HKbd>I</HKbd>
|
||||||
<el-descriptions-item label="激活下一个主导航">
|
查看系统信息
|
||||||
{{ settingsStore.os === 'mac' ? '⌥' : 'Alt' }} + `
|
</li>
|
||||||
</el-descriptions-item>
|
<li v-if="settingsStore.settings.navSearch.enable && settingsStore.settings.navSearch.enableHotkeys" class="py-1">
|
||||||
</el-descriptions>
|
<HKbd>{{ settingsStore.os === 'mac' ? '⌥' : 'Alt' }}</HKbd>
|
||||||
</el-drawer>
|
<HKbd>S</HKbd>
|
||||||
</div>
|
唤起导航搜索
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div v-if="settingsStore.settings.menu.enableHotkeys && ['side', 'head'].includes(settingsStore.settings.menu.menuMode)">
|
||||||
|
<h2 class="text-lg font-bold m-0">
|
||||||
|
主导航
|
||||||
|
</h2>
|
||||||
|
<ul class="list-none pl-4 text-sm">
|
||||||
|
<li class="py-1">
|
||||||
|
<HKbd>{{ settingsStore.os === 'mac' ? '⌥' : 'Alt' }}</HKbd>
|
||||||
|
<HKbd>`</HKbd>
|
||||||
|
激活下一个主导航
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</HDialog>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
:deep(.el-drawer__header) {
|
|
||||||
margin-bottom: initial;
|
|
||||||
padding-bottom: 20px;
|
|
||||||
border-bottom: 1px solid var(--el-border-color);
|
|
||||||
transition: var(--el-transition-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.el-descriptions) {
|
|
||||||
margin-bottom: 20px;
|
|
||||||
|
|
||||||
.el-descriptions__label {
|
|
||||||
width: 200px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
57
src/layouts/components/Logo/index.vue
Normal file → Executable file
@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import imgLogo from '@/assets/images/logo.png'
|
|
||||||
import useSettingsStore from '@/store/modules/settings'
|
import useSettingsStore from '@/store/modules/settings'
|
||||||
|
import imgLogo from '@/assets/images/logo.png'
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
name: 'Logo',
|
name: 'Logo',
|
||||||
@ -23,58 +23,15 @@ const title = ref(import.meta.env.VITE_APP_TITLE)
|
|||||||
const logo = ref(imgLogo)
|
const logo = ref(imgLogo)
|
||||||
|
|
||||||
const to = computed(() => {
|
const to = computed(() => {
|
||||||
const rtn: {
|
return {
|
||||||
name?: string
|
...(settingsStore.settings.home.enable && { name: 'home' }),
|
||||||
} = {}
|
|
||||||
if (settingsStore.settings.home.enable) {
|
|
||||||
rtn.name = 'home'
|
|
||||||
}
|
}
|
||||||
return rtn
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<router-link :to="to" class="title" :class="{ 'is-link': settingsStore.settings.home.enable }" :title="title">
|
<RouterLink :to="to" class="flex-center gap-2 w-inherit h-[var(--g-sidebar-logo-height)] px-3 text-inherit no-underline" :class="{ 'cursor-pointer': settingsStore.settings.home.enable }" :title="title">
|
||||||
<img v-if="showLogo" :src="logo" class="logo">
|
<img v-if="showLogo" :src="logo" class="logo w-[30px] h-[30px] object-contain">
|
||||||
<span v-if="showTitle">{{ title }}</span>
|
<span v-if="showTitle" class="block font-bold truncate">{{ title }}</span>
|
||||||
</router-link>
|
</RouterLink>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
.title {
|
|
||||||
position: fixed;
|
|
||||||
z-index: 1000;
|
|
||||||
top: 0;
|
|
||||||
width: inherit;
|
|
||||||
padding: 0 10px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
height: var(--g-sidebar-logo-height);
|
|
||||||
text-align: center;
|
|
||||||
overflow: hidden;
|
|
||||||
text-decoration: none;
|
|
||||||
|
|
||||||
&.is-link {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo {
|
|
||||||
width: 30px;
|
|
||||||
height: 30px;
|
|
||||||
object-fit: contain;
|
|
||||||
|
|
||||||
& + span {
|
|
||||||
margin-left: 10px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
span {
|
|
||||||
display: block;
|
|
||||||
font-weight: bold;
|
|
||||||
color: #fff;
|
|
||||||
|
|
||||||
@include text-overflow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
107
src/layouts/components/MainSidebar/index.vue
Normal file → Executable file
@ -14,89 +14,88 @@ const { switchTo } = useMenu()
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<transition name="main-sidebar">
|
<Transition name="main-sidebar">
|
||||||
<div v-if="settingsStore.settings.menu.menuMode === 'side' || (settingsStore.mode === 'mobile' && settingsStore.settings.menu.menuMode !== 'single')" class="main-sidebar-container">
|
<div v-if="settingsStore.settings.menu.menuMode === 'side' || (settingsStore.mode === 'mobile' && settingsStore.settings.menu.menuMode !== 'single')" class="main-sidebar-container">
|
||||||
<Logo :show-title="false" class="sidebar-logo" />
|
<Logo :show-title="false" class="sidebar-logo" />
|
||||||
<!-- 侧边栏模式(含主导航) -->
|
<!-- 侧边栏模式(含主导航) -->
|
||||||
<div class="nav">
|
<div class="menu flex flex-col of-hidden transition-all">
|
||||||
<template v-for="(item, index) in menuStore.allMenus">
|
<template v-for="(item, index) in menuStore.allMenus" :key="index">
|
||||||
<div
|
<div
|
||||||
v-if="item.children && item.children.length !== 0" :key="index" class="item" :class="{
|
class="menu-item relative transition-all" :class="{
|
||||||
active: index === menuStore.actived,
|
active: index === menuStore.actived,
|
||||||
}" :title="item.meta?.title ?? '[ 无标题 ]'" @click="switchTo(index)"
|
}"
|
||||||
>
|
>
|
||||||
<svg-icon v-if="item.meta?.icon" :name="item.meta.icon" />
|
<div
|
||||||
<span>{{ item.meta?.title ?? '[ 无标题 ]' }}</span>
|
v-if="item.children && item.children.length !== 0" class="menu-item-container w-full h-full flex justify-between items-center gap-1 px-2! py-4 transition-all cursor-pointer group text-[var(--g-main-sidebar-menu-color)] hover:(text-[var(--g-main-sidebar-menu-hover-color)] bg-[var(--g-main-sidebar-menu-hover-bg)])" :class="{
|
||||||
|
'text-[var(--g-main-sidebar-menu-active-color)]! bg-[var(--g-main-sidebar-menu-active-bg)]!': index === menuStore.actived,
|
||||||
|
}" :title="item.meta?.title" @click="switchTo(index)"
|
||||||
|
>
|
||||||
|
<div class="inline-flex flex-col justify-center items-center flex-1 gap-[2px] w-full">
|
||||||
|
<SvgIcon v-if="item.meta?.icon" :name="item.meta?.icon" :size="20" class="menu-item-container-icon transition-transform group-hover:scale-120" async />
|
||||||
|
<span class="flex-1 text-sm w-full text-center truncate transition-width transition-height transition-opacity">
|
||||||
|
{{ item.meta?.title }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</transition>
|
</Transition>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.main-sidebar-container {
|
.main-sidebar-container {
|
||||||
overflow-x: hidden;
|
display: flex;
|
||||||
overflow-y: auto;
|
flex-direction: column;
|
||||||
overscroll-behavior: contain;
|
|
||||||
|
|
||||||
// firefox隐藏滚动条
|
|
||||||
scrollbar-width: none;
|
|
||||||
|
|
||||||
// chrome隐藏滚动条
|
|
||||||
&::-webkit-scrollbar {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
width: var(--g-main-sidebar-width);
|
width: var(--g-main-sidebar-width);
|
||||||
color: var(--g-main-sidebar-menu-color);
|
color: var(--g-main-sidebar-menu-color);
|
||||||
background-color: var(--g-main-sidebar-bg);
|
background-color: var(--g-main-sidebar-bg);
|
||||||
transition: background-color 0.3s, var(--el-transition-color);
|
box-shadow: 1px 0 0 0 var(--g-border-color);
|
||||||
|
transition: background-color 0.3s, color 0.3s, box-shadow 0.3s;
|
||||||
|
|
||||||
.sidebar-logo {
|
.sidebar-logo {
|
||||||
transition: 0.3s;
|
|
||||||
background-color: var(--g-main-sidebar-bg);
|
background-color: var(--g-main-sidebar-bg);
|
||||||
|
transition: background-color 0.3s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav {
|
.menu {
|
||||||
width: inherit;
|
flex: 1;
|
||||||
padding-top: var(--g-sidebar-logo-height);
|
width: initial;
|
||||||
|
overflow: hidden auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
|
||||||
.item {
|
// firefox隐藏滚动条
|
||||||
display: flex;
|
scrollbar-width: none;
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
flex-direction: column;
|
|
||||||
text-align: center;
|
|
||||||
height: 60px;
|
|
||||||
padding: 0 5px;
|
|
||||||
cursor: pointer;
|
|
||||||
color: var(--g-main-sidebar-menu-color);
|
|
||||||
background-color: var(--g-main-sidebar-bg);
|
|
||||||
transition: background-color 0.3s, var(--el-transition-color);
|
|
||||||
|
|
||||||
&:hover {
|
// chrome隐藏滚动条
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.menu-item) {
|
||||||
|
.menu-item-container {
|
||||||
|
height: 60px;
|
||||||
|
padding: 0 5px;
|
||||||
|
color: var(--g-main-sidebar-menu-color);
|
||||||
|
background-color: var(--g-main-sidebar-menu-bg);
|
||||||
|
|
||||||
|
.menu-item-container-icon {
|
||||||
|
transform: scale(1);
|
||||||
|
font-size: 24px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover .menu-item-container {
|
||||||
color: var(--g-main-sidebar-menu-hover-color);
|
color: var(--g-main-sidebar-menu-hover-color);
|
||||||
background-color: var(--g-main-sidebar-menu-hover-bg);
|
background-color: var(--g-main-sidebar-menu-hover-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.active {
|
&.active .menu-item-container {
|
||||||
color: var(--g-main-sidebar-menu-active-color);
|
color: var(--g-main-sidebar-menu-active-color) !important;
|
||||||
background-color: var(--g-main-sidebar-menu-active-bg);
|
background-color: var(--g-main-sidebar-menu-active-bg) !important;
|
||||||
}
|
|
||||||
|
|
||||||
.icon {
|
|
||||||
margin: 0 auto;
|
|
||||||
font-size: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
span {
|
|
||||||
text-align: center;
|
|
||||||
font-size: 14px;
|
|
||||||
|
|
||||||
@include text-overflow(1, false);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -105,7 +104,7 @@ const { switchTo } = useMenu()
|
|||||||
// 主侧边栏动画
|
// 主侧边栏动画
|
||||||
.main-sidebar-enter-active,
|
.main-sidebar-enter-active,
|
||||||
.main-sidebar-leave-active {
|
.main-sidebar-leave-active {
|
||||||
transition: transform 0.3s;
|
transition: 0.3s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-sidebar-enter-from,
|
.main-sidebar-enter-from,
|
||||||
|
182
src/layouts/components/Menu/index.vue
Normal file
@ -0,0 +1,182 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import SubMenu from './sub.vue'
|
||||||
|
import Item from './item.vue'
|
||||||
|
import type { MenuInjection, MenuProps } from './types'
|
||||||
|
import { rootMenuInjectionKey } from './types'
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: 'MainMenu',
|
||||||
|
})
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<MenuProps>(),
|
||||||
|
{
|
||||||
|
accordion: true,
|
||||||
|
defaultOpeneds: () => [],
|
||||||
|
mode: 'vertical',
|
||||||
|
collapse: false,
|
||||||
|
showCollapseName: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const activeIndex = ref<MenuInjection['activeIndex']>(props.value)
|
||||||
|
const items = ref<MenuInjection['items']>({})
|
||||||
|
const subMenus = ref<MenuInjection['subMenus']>({})
|
||||||
|
const openedMenus = ref<MenuInjection['openedMenus']>(props.defaultOpeneds.slice(0))
|
||||||
|
const mouseInMenu = ref<MenuInjection['mouseInMenu']>([])
|
||||||
|
const isMenuPopup = computed<MenuInjection['isMenuPopup']>(() => {
|
||||||
|
return props.mode === 'horizontal' || (props.mode === 'vertical' && props.collapse)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 解析传入的 menu 数据,并保存到 items 和 subMenus 对象中
|
||||||
|
function initItems(menu: MenuProps['menu'], parentPaths: string[] = []) {
|
||||||
|
menu.forEach((item) => {
|
||||||
|
const index = item.path ?? JSON.stringify(item)
|
||||||
|
if (item.children) {
|
||||||
|
const indexPath = [...parentPaths, index]
|
||||||
|
subMenus.value[index] = {
|
||||||
|
index,
|
||||||
|
indexPath,
|
||||||
|
active: false,
|
||||||
|
}
|
||||||
|
initItems(item.children, indexPath)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
items.value[index] = {
|
||||||
|
index,
|
||||||
|
indexPath: parentPaths,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const openMenu: MenuInjection['openMenu'] = (index, indexPath) => {
|
||||||
|
if (openedMenus.value.includes(index)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (props.accordion) {
|
||||||
|
openedMenus.value = openedMenus.value.filter(key => indexPath.includes(key))
|
||||||
|
}
|
||||||
|
openedMenus.value.push(index)
|
||||||
|
}
|
||||||
|
const closeMenu: MenuInjection['closeMenu'] = (index) => {
|
||||||
|
if (Array.isArray(index)) {
|
||||||
|
nextTick(() => {
|
||||||
|
closeMenu(index.at(-1)!)
|
||||||
|
if (index.length > 1) {
|
||||||
|
closeMenu(index.slice(0, -1))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Object.keys(subMenus.value).forEach((item) => {
|
||||||
|
if (subMenus.value[item].indexPath.includes(index)) {
|
||||||
|
openedMenus.value = openedMenus.value.filter(item => item !== index)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSubMenusActive(index: string) {
|
||||||
|
Object.keys(subMenus.value).forEach((index) => {
|
||||||
|
subMenus.value[index].active = false
|
||||||
|
})
|
||||||
|
items.value[index]?.indexPath.forEach((index) => {
|
||||||
|
subMenus.value[index].active = true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMenuItemClick: MenuInjection['handleMenuItemClick'] = (index, meta) => {
|
||||||
|
if (props.mode === 'horizontal' || props.collapse) {
|
||||||
|
openedMenus.value = []
|
||||||
|
}
|
||||||
|
activeIndex.value = index
|
||||||
|
setSubMenusActive(index)
|
||||||
|
if (meta?.link) {
|
||||||
|
window.open(meta.link)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
router.push(index)
|
||||||
|
}
|
||||||
|
const handleSubMenuClick: MenuInjection['handleSubMenuClick'] = (index, indexPath) => {
|
||||||
|
if (openedMenus.value.includes(index)) {
|
||||||
|
closeMenu(index)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
openMenu(index, indexPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initMenu() {
|
||||||
|
const activeItem = activeIndex.value && items.value[activeIndex.value]
|
||||||
|
setSubMenusActive(activeIndex.value)
|
||||||
|
if (!activeItem || props.collapse) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 展开该菜单项的路径上所有子菜单
|
||||||
|
activeItem.indexPath.forEach((index) => {
|
||||||
|
const subMenu = subMenus.value[index]
|
||||||
|
subMenu && openMenu(index, subMenu.indexPath)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.menu, (val) => {
|
||||||
|
initItems(val)
|
||||||
|
initMenu()
|
||||||
|
}, {
|
||||||
|
deep: true,
|
||||||
|
immediate: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => props.value, (currentValue) => {
|
||||||
|
if (!items.value[currentValue]) {
|
||||||
|
activeIndex.value = ''
|
||||||
|
}
|
||||||
|
const item = items.value[currentValue] || (activeIndex.value && items.value[activeIndex.value]) || items.value[props.value]
|
||||||
|
if (item) {
|
||||||
|
activeIndex.value = item.index
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
activeIndex.value = currentValue
|
||||||
|
}
|
||||||
|
initMenu()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => props.collapse, (value) => {
|
||||||
|
if (value) {
|
||||||
|
openedMenus.value = []
|
||||||
|
}
|
||||||
|
initMenu()
|
||||||
|
})
|
||||||
|
|
||||||
|
provide(rootMenuInjectionKey, reactive({
|
||||||
|
props,
|
||||||
|
items,
|
||||||
|
subMenus,
|
||||||
|
activeIndex,
|
||||||
|
openedMenus,
|
||||||
|
mouseInMenu,
|
||||||
|
isMenuPopup,
|
||||||
|
openMenu,
|
||||||
|
closeMenu,
|
||||||
|
handleMenuItemClick,
|
||||||
|
handleSubMenuClick,
|
||||||
|
}))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="flex flex-col of-hidden transition-all" :class="{
|
||||||
|
'w-[200px]': !isMenuPopup && props.mode === 'vertical',
|
||||||
|
'w-[64px]': isMenuPopup && props.mode === 'vertical',
|
||||||
|
'h-[80px]': props.mode === 'horizontal',
|
||||||
|
'flex-row! w-auto': isMenuPopup && props.mode === 'horizontal',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<template v-for="(item, index) in menu" :key="index">
|
||||||
|
<SubMenu v-if="item.children?.length" :menu="item" :unique-key="[item.path ?? JSON.stringify(item)]" />
|
||||||
|
<Item v-else :item="item" :unique-key="[item.path ?? JSON.stringify(item)]" />
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
80
src/layouts/components/Menu/item.vue
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { SubMenuItemProps } from './types'
|
||||||
|
import { rootMenuInjectionKey } from './types'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<SubMenuItemProps>(),
|
||||||
|
{
|
||||||
|
level: 0,
|
||||||
|
subMenu: false,
|
||||||
|
expand: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const rootMenu = inject(rootMenuInjectionKey)!
|
||||||
|
|
||||||
|
const itemRef = ref<HTMLElement>()
|
||||||
|
|
||||||
|
const isActived = computed(() => {
|
||||||
|
return props.subMenu
|
||||||
|
? rootMenu.subMenus[props.uniqueKey[props.uniqueKey.length - 1]]?.active
|
||||||
|
: rootMenu.activeIndex === props.uniqueKey[props.uniqueKey.length - 1]
|
||||||
|
})
|
||||||
|
|
||||||
|
const isItemActive = computed(() => {
|
||||||
|
return isActived.value && (!props.subMenu || rootMenu.isMenuPopup)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 缩进样式
|
||||||
|
const indentStyle = computed(() => {
|
||||||
|
return !rootMenu.isMenuPopup
|
||||||
|
? `padding-left: ${20 * (props.level ?? 0)}px`
|
||||||
|
: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
ref: itemRef,
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
ref="itemRef" class="menu-item relative transition-all" :class="{
|
||||||
|
active: isItemActive,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="menu-item-container w-full h-full flex justify-between items-center gap-1 px-5 py-4 transition-all cursor-pointer group text-[var(--g-sub-sidebar-menu-color)] hover:(text-[var(--g-sub-sidebar-menu-hover-color)] bg-[var(--g-sub-sidebar-menu-hover-bg)])" :class="{
|
||||||
|
'text-[var(--g-sub-sidebar-menu-active-color)]! bg-[var(--g-sub-sidebar-menu-active-bg)]!': isItemActive,
|
||||||
|
'px-2!': rootMenu.isMenuPopup && level === 0,
|
||||||
|
}" :title="item.meta?.title"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="inline-flex justify-center items-center gap-[12px] flex-1" :class="{
|
||||||
|
'flex-col gap-none': rootMenu.isMenuPopup && level === 0,
|
||||||
|
'flex-col gap-[2px]!': rootMenu.isMenuPopup && level === 0 && rootMenu.props.showCollapseName,
|
||||||
|
'w-full': rootMenu.isMenuPopup && level === 0 && rootMenu.props.showCollapseName && rootMenu.props.mode === 'vertical',
|
||||||
|
'w-[50px]': rootMenu.isMenuPopup && level === 0 && rootMenu.props.showCollapseName && rootMenu.props.mode === 'horizontal',
|
||||||
|
}" :style="indentStyle"
|
||||||
|
>
|
||||||
|
<SvgIcon v-if="props.item.meta?.icon" :name="props.item.meta.icon" :size="20" class="menu-item-container-icon transition-transform group-hover:scale-120" async />
|
||||||
|
<span
|
||||||
|
v-if="!(rootMenu.isMenuPopup && level === 0 && !rootMenu.props.showCollapseName)" class="flex-1 text-sm w-0 truncate transition-width transition-height transition-opacity"
|
||||||
|
:class="{
|
||||||
|
'opacity-0 w-0 h-0': rootMenu.isMenuPopup && level === 0 && !rootMenu.props.showCollapseName,
|
||||||
|
'w-full text-center': rootMenu.isMenuPopup && level === 0 && rootMenu.props.showCollapseName,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
{{ item.meta?.title }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<i
|
||||||
|
v-if="subMenu && !(rootMenu.isMenuPopup && level === 0)" class="relative w-[10px] ml-1 before:(content-empty absolute w-[6px] h-[1.5px] bg-current -translate-y-[1px] transition-transform-200) after:(content-empty absolute w-[6px] h-[1.5px] bg-current -translate-y-[1px] transition-transform-200)" :class="[
|
||||||
|
expand ? 'before:(-rotate-45 -translate-x-[2px]) after:(rotate-45 translate-x-[2px])' : 'before:(rotate-45 -translate-x-[2px]) after:(-rotate-45 translate-x-[2px])',
|
||||||
|
rootMenu.isMenuPopup && level === 0 && 'opacity-0',
|
||||||
|
rootMenu.isMenuPopup && level !== 0 && '-rotate-90 -top-[1.5px]',
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
197
src/layouts/components/Menu/sub.vue
Normal file
@ -0,0 +1,197 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useTimeoutFn } from '@vueuse/core'
|
||||||
|
import type { OverlayScrollbarsComponentRef } from 'overlayscrollbars-vue'
|
||||||
|
import { OverlayScrollbarsComponent } from 'overlayscrollbars-vue'
|
||||||
|
import Item from './item.vue'
|
||||||
|
import type { SubMenuProps } from './types'
|
||||||
|
import { rootMenuInjectionKey } from './types'
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: 'SubMenu',
|
||||||
|
})
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<SubMenuProps>(),
|
||||||
|
{
|
||||||
|
level: 0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const index = props.menu.path ?? JSON.stringify(props.menu)
|
||||||
|
const itemRef = shallowRef()
|
||||||
|
const subMenuRef = shallowRef<OverlayScrollbarsComponentRef>()
|
||||||
|
const rootMenu = inject(rootMenuInjectionKey)!
|
||||||
|
|
||||||
|
const opened = computed(() => {
|
||||||
|
return rootMenu.openedMenus.includes(props.uniqueKey[props.uniqueKey.length - 1])
|
||||||
|
})
|
||||||
|
|
||||||
|
const transitionEvent = computed(() => {
|
||||||
|
return rootMenu.isMenuPopup
|
||||||
|
? {
|
||||||
|
enter(el: HTMLElement) {
|
||||||
|
if (el.offsetHeight > window.innerHeight) {
|
||||||
|
el.style.height = `${window.innerHeight}px`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
afterEnter: () => {},
|
||||||
|
beforeLeave: (el: HTMLElement) => {
|
||||||
|
el.style.overflow = 'hidden'
|
||||||
|
el.style.maxHeight = `${el.offsetHeight}px`
|
||||||
|
},
|
||||||
|
leave: (el: HTMLElement) => {
|
||||||
|
el.style.maxHeight = '0'
|
||||||
|
},
|
||||||
|
afterLeave(el: HTMLElement) {
|
||||||
|
el.style.overflow = ''
|
||||||
|
el.style.maxHeight = ''
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
enter(el: HTMLElement) {
|
||||||
|
const memorizedHeight = el.offsetHeight
|
||||||
|
el.style.maxHeight = '0'
|
||||||
|
el.style.overflow = 'hidden'
|
||||||
|
void el.offsetHeight
|
||||||
|
el.style.maxHeight = `${memorizedHeight}px`
|
||||||
|
},
|
||||||
|
afterEnter(el: HTMLElement) {
|
||||||
|
el.style.overflow = ''
|
||||||
|
el.style.maxHeight = ''
|
||||||
|
},
|
||||||
|
beforeLeave(el: HTMLElement) {
|
||||||
|
el.style.overflow = 'hidden'
|
||||||
|
el.style.maxHeight = `${el.offsetHeight}px`
|
||||||
|
},
|
||||||
|
leave(el: HTMLElement) {
|
||||||
|
el.style.maxHeight = '0'
|
||||||
|
},
|
||||||
|
afterLeave(el: HTMLElement) {
|
||||||
|
el.style.overflow = ''
|
||||||
|
el.style.maxHeight = ''
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const transitionClass = computed(() => {
|
||||||
|
return rootMenu.isMenuPopup
|
||||||
|
? {
|
||||||
|
enterActiveClass: 'ease-in-out duration-300',
|
||||||
|
enterFromClass: 'opacity-0 translate-x-4',
|
||||||
|
enterToClass: 'opacity-100',
|
||||||
|
leaveActiveClass: 'ease-in-out duration-300',
|
||||||
|
leaveFromClass: 'opacity-100',
|
||||||
|
leaveToClass: 'opacity-0',
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
enterActiveClass: 'ease-in-out duration-300',
|
||||||
|
enterFromClass: 'opacity-0',
|
||||||
|
enterToClass: 'opacity-100',
|
||||||
|
leaveActiveClass: 'ease-in-out duration-300',
|
||||||
|
leaveFromClass: 'opacity-100',
|
||||||
|
leaveToClass: 'opacity-0',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const hasChildren = computed(() => {
|
||||||
|
let flag = true
|
||||||
|
if (props.menu.children) {
|
||||||
|
if (props.menu.children.every((item: any) => item.meta?.sidebar === false)) {
|
||||||
|
flag = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
flag = false
|
||||||
|
}
|
||||||
|
return flag
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleClick() {
|
||||||
|
if (rootMenu.isMenuPopup && hasChildren.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (hasChildren.value) {
|
||||||
|
rootMenu.handleSubMenuClick(index, props.uniqueKey)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
rootMenu.handleMenuItemClick(index, props.menu.meta)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let timeout: (() => void) | undefined
|
||||||
|
|
||||||
|
function handleMouseenter() {
|
||||||
|
if (!rootMenu.isMenuPopup) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rootMenu.mouseInMenu = props.uniqueKey
|
||||||
|
timeout?.()
|
||||||
|
;({ stop: timeout } = useTimeoutFn(() => {
|
||||||
|
if (hasChildren.value) {
|
||||||
|
rootMenu.openMenu(index, props.uniqueKey)
|
||||||
|
nextTick(() => {
|
||||||
|
const el = itemRef.value.ref
|
||||||
|
let top = 0
|
||||||
|
let left = 0
|
||||||
|
if (rootMenu.props.mode === 'vertical' || props.level !== 0) {
|
||||||
|
top = el.getBoundingClientRect().top + el.scrollTop
|
||||||
|
left = el.getBoundingClientRect().left + el.getBoundingClientRect().width
|
||||||
|
if (top + subMenuRef.value!.getElement()!.offsetHeight > window.innerHeight) {
|
||||||
|
top = window.innerHeight - subMenuRef.value!.getElement()!.offsetHeight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
top = el.getBoundingClientRect().top + el.getBoundingClientRect().height
|
||||||
|
left = el.getBoundingClientRect().left
|
||||||
|
if (top + subMenuRef.value!.getElement()!.offsetHeight > window.innerHeight) {
|
||||||
|
subMenuRef.value!.getElement()!.style.height = `${window.innerHeight - top}px`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
subMenuRef.value!.getElement()!.style.top = `${top}px`
|
||||||
|
subMenuRef.value!.getElement()!.style.left = `${left}px`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
const path = props.menu.children ? rootMenu.subMenus[index].indexPath.at(-1)! : rootMenu.items[index].indexPath.at(-1)!
|
||||||
|
rootMenu.openMenu(path, rootMenu.subMenus[path].indexPath)
|
||||||
|
}
|
||||||
|
}, 300))
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMouseleave() {
|
||||||
|
if (!rootMenu.isMenuPopup) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rootMenu.mouseInMenu = []
|
||||||
|
timeout?.()
|
||||||
|
;({ stop: timeout } = useTimeoutFn(() => {
|
||||||
|
if (rootMenu.mouseInMenu.length === 0) {
|
||||||
|
rootMenu.closeMenu(props.uniqueKey)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
if (hasChildren.value) {
|
||||||
|
!rootMenu.mouseInMenu.includes(props.uniqueKey.at(-1)!) && rootMenu.closeMenu(props.uniqueKey.at(-1)!)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 300))
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Item ref="itemRef" :unique-key="uniqueKey" :item="menu" :level="level" :sub-menu="hasChildren" :expand="opened" @click="handleClick" @mouseenter="handleMouseenter" @mouseleave="handleMouseleave" />
|
||||||
|
<Teleport v-if="hasChildren" to="body" :disabled="!rootMenu.isMenuPopup">
|
||||||
|
<Transition v-bind="transitionClass" v-on="transitionEvent">
|
||||||
|
<OverlayScrollbarsComponent
|
||||||
|
v-if="opened" ref="subMenuRef" :options="{ scrollbars: { visibility: 'hidden' } }" defer class="sub-menu" :class="{
|
||||||
|
'bg-[var(--g-sub-sidebar-bg)]': rootMenu.isMenuPopup,
|
||||||
|
'ring-1 ring-stone-2 dark:ring-stone-8 shadow-xl fixed z-3000 w-[200px]': rootMenu.isMenuPopup,
|
||||||
|
'mx-2': rootMenu.isMenuPopup && (rootMenu.props.mode === 'vertical' || level !== 0),
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<template v-for="item in menu.children" :key="item.path">
|
||||||
|
<SubMenu v-if="item.meta?.sidebar !== false" :unique-key="[...uniqueKey, item.path]" :menu="item" :level="level + 1" />
|
||||||
|
</template>
|
||||||
|
</OverlayScrollbarsComponent>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
48
src/layouts/components/Menu/types.ts
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import { createInjectionKey } from '@/utils/injectionKeys'
|
||||||
|
import type { Menu } from '#/global'
|
||||||
|
|
||||||
|
export interface MenuItem {
|
||||||
|
index: string
|
||||||
|
indexPath: string[]
|
||||||
|
active?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MenuProps {
|
||||||
|
menu: Menu.recordRaw[]
|
||||||
|
value: string
|
||||||
|
accordion?: boolean
|
||||||
|
defaultOpeneds?: string[]
|
||||||
|
mode?: 'horizontal' | 'vertical'
|
||||||
|
collapse?: boolean
|
||||||
|
showCollapseName?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MenuInjection {
|
||||||
|
props: MenuProps
|
||||||
|
items: Record<string, MenuItem>
|
||||||
|
subMenus: Record<string, MenuItem>
|
||||||
|
activeIndex: MenuProps['value']
|
||||||
|
openedMenus: string[]
|
||||||
|
mouseInMenu: string[]
|
||||||
|
isMenuPopup: boolean
|
||||||
|
openMenu: (index: string, indexPath: string[]) => void
|
||||||
|
closeMenu: (index: string | string[]) => void
|
||||||
|
handleMenuItemClick: (index: string, meta?: Menu.recordRaw['meta']) => void
|
||||||
|
handleSubMenuClick: (index: string, indexPath: string[]) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const rootMenuInjectionKey = createInjectionKey<MenuInjection>('rootMenu')
|
||||||
|
|
||||||
|
export interface SubMenuProps {
|
||||||
|
uniqueKey: string[]
|
||||||
|
menu: Menu.recordRaw
|
||||||
|
level?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubMenuItemProps {
|
||||||
|
uniqueKey: string[]
|
||||||
|
item: Menu.recordRaw
|
||||||
|
level?: number
|
||||||
|
subMenu?: boolean
|
||||||
|
expand?: boolean
|
||||||
|
}
|
450
src/layouts/components/Search/index.vue
Normal file → Executable file
@ -1,19 +1,44 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { Dialog, DialogDescription, DialogPanel, TransitionChild, TransitionRoot } from '@headlessui/vue'
|
||||||
|
import type { OverlayScrollbarsComponentRef } from 'overlayscrollbars-vue'
|
||||||
|
import { OverlayScrollbarsComponent } from 'overlayscrollbars-vue'
|
||||||
import { cloneDeep } from 'lodash-es'
|
import { cloneDeep } from 'lodash-es'
|
||||||
import hotkeys from 'hotkeys-js'
|
import hotkeys from 'hotkeys-js'
|
||||||
import type { RouteRecordRaw } from 'vue-router'
|
import type { RouteRecordRaw } from 'vue-router'
|
||||||
|
import Breadcrumb from '../Breadcrumb/index.vue'
|
||||||
|
import BreadcrumbItem from '../Breadcrumb/item.vue'
|
||||||
|
import { resolveRoutePath } from '@/utils'
|
||||||
import eventBus from '@/utils/eventBus'
|
import eventBus from '@/utils/eventBus'
|
||||||
import useSettingsStore from '@/store/modules/settings'
|
import useSettingsStore from '@/store/modules/settings'
|
||||||
import useRouteStore from '@/store/modules/route'
|
import useRouteStore from '@/store/modules/route'
|
||||||
import useMenuStore from '@/store/modules/menu'
|
import useMenuStore from '@/store/modules/menu'
|
||||||
import type { Menu } from '#/global'
|
import type { Menu } from '@/types/global'
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
name: 'Search',
|
name: 'Search',
|
||||||
})
|
})
|
||||||
|
|
||||||
const router = useRouter()
|
const overlayTransitionClass = ref({
|
||||||
|
enter: 'ease-in-out duration-500',
|
||||||
|
enterFrom: 'opacity-0',
|
||||||
|
enterTo: 'opacity-100',
|
||||||
|
leave: 'ease-in-out duration-500',
|
||||||
|
leaveFrom: 'opacity-100',
|
||||||
|
leaveTo: 'opacity-0',
|
||||||
|
})
|
||||||
|
|
||||||
|
const transitionClass = computed(() => {
|
||||||
|
return {
|
||||||
|
enter: 'ease-out duration-300',
|
||||||
|
enterFrom: 'opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95',
|
||||||
|
enterTo: 'opacity-100 translate-y-0 sm:scale-100',
|
||||||
|
leave: 'ease-in duration-200',
|
||||||
|
leaveFrom: 'opacity-100 translate-y-0 sm:scale-100',
|
||||||
|
leaveTo: 'opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
const settingsStore = useSettingsStore()
|
const settingsStore = useSettingsStore()
|
||||||
const routeStore = useRouteStore()
|
const routeStore = useRouteStore()
|
||||||
const menuStore = useMenuStore()
|
const menuStore = useMenuStore()
|
||||||
@ -30,13 +55,11 @@ const isShow = ref(false)
|
|||||||
const searchInput = ref('')
|
const searchInput = ref('')
|
||||||
const sourceList = ref<listTypes[]>([])
|
const sourceList = ref<listTypes[]>([])
|
||||||
const actived = ref(-1)
|
const actived = ref(-1)
|
||||||
|
const isScrollbarsInit = ref(false)
|
||||||
|
|
||||||
const searchInputRef = ref()
|
const searchInputRef = ref()
|
||||||
const searchResultRef = ref()
|
const searchResultRef = ref<OverlayScrollbarsComponentRef>()
|
||||||
const searchResultItemRef = ref<any>([])
|
const searchResultItemRef = ref<HTMLElement[]>([])
|
||||||
function setSearchResultItemRef(el: any) {
|
|
||||||
return searchResultItemRef.value.push(el)
|
|
||||||
}
|
|
||||||
onBeforeUpdate(() => {
|
onBeforeUpdate(() => {
|
||||||
searchResultItemRef.value = []
|
searchResultItemRef.value = []
|
||||||
})
|
})
|
||||||
@ -60,22 +83,7 @@ const resultList = computed(() => {
|
|||||||
if (item.path.includes(searchInput.value)) {
|
if (item.path.includes(searchInput.value)) {
|
||||||
flag = true
|
flag = true
|
||||||
}
|
}
|
||||||
if (item.breadcrumb.some((b) => {
|
if (item.breadcrumb.some((b: { title: any }) => b.title.includes(searchInput.value))) {
|
||||||
let flag = false
|
|
||||||
if (b) {
|
|
||||||
if (typeof b === 'function') {
|
|
||||||
if (b().includes(searchInput.value)) {
|
|
||||||
flag = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
if (b.includes(searchInput.value)) {
|
|
||||||
flag = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return flag
|
|
||||||
})) {
|
|
||||||
flag = true
|
flag = true
|
||||||
}
|
}
|
||||||
return flag
|
return flag
|
||||||
@ -85,40 +93,35 @@ const resultList = computed(() => {
|
|||||||
|
|
||||||
watch(() => isShow.value, (val) => {
|
watch(() => isShow.value, (val) => {
|
||||||
if (val) {
|
if (val) {
|
||||||
document.body.classList.add('overflow-hidden')
|
searchInput.value = ''
|
||||||
searchResultRef.value.scrollTop = 0
|
actived.value = -1
|
||||||
// 当搜索显示的时候绑定上、下、回车快捷键,隐藏的时候再解绑。另外当 input 处于 focus 状态时,采用 vue 来绑定键盘事件
|
// 当搜索显示的时候绑定上、下、回车快捷键,隐藏的时候再解绑。另外当 input 处于 focus 状态时,采用 vue 来绑定键盘事件
|
||||||
hotkeys('up', keyUp)
|
hotkeys('up', keyUp)
|
||||||
hotkeys('down', keyDown)
|
hotkeys('down', keyDown)
|
||||||
hotkeys('enter', keyEnter)
|
hotkeys('enter', keyEnter)
|
||||||
setTimeout(() => {
|
|
||||||
searchInputRef.value.focus()
|
|
||||||
}, 500)
|
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
document.body.classList.remove('overflow-hidden')
|
|
||||||
hotkeys.unbind('up', keyUp)
|
hotkeys.unbind('up', keyUp)
|
||||||
hotkeys.unbind('down', keyDown)
|
hotkeys.unbind('down', keyDown)
|
||||||
hotkeys.unbind('enter', keyEnter)
|
hotkeys.unbind('enter', keyEnter)
|
||||||
setTimeout(() => {
|
|
||||||
searchInput.value = ''
|
|
||||||
actived.value = -1
|
|
||||||
}, 500)
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
watch(() => resultList.value, () => {
|
watch(() => resultList.value, () => {
|
||||||
actived.value = -1
|
actived.value = -1
|
||||||
searchResultItemRef.value = []
|
|
||||||
handleScroll()
|
handleScroll()
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
eventBus.on('global-search-toggle', () => {
|
eventBus.on('global-search-toggle', () => {
|
||||||
|
if (!isShow.value) {
|
||||||
|
initSourceList()
|
||||||
|
}
|
||||||
isShow.value = !isShow.value
|
isShow.value = !isShow.value
|
||||||
})
|
})
|
||||||
hotkeys('alt+s', (e) => {
|
hotkeys('alt+s', (e) => {
|
||||||
if (settingsStore.settings.navSearch.enable && settingsStore.settings.navSearch.enableHotkeys) {
|
if (settingsStore.settings.navSearch.enable && settingsStore.settings.navSearch.enableHotkeys) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
initSourceList()
|
||||||
isShow.value = true
|
isShow.value = true
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@ -128,9 +131,14 @@ onMounted(() => {
|
|||||||
isShow.value = false
|
isShow.value = false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
initSourceList()
|
||||||
|
})
|
||||||
|
|
||||||
|
function initSourceList() {
|
||||||
|
sourceList.value = []
|
||||||
if (settingsStore.settings.app.routeBaseOn !== 'filesystem') {
|
if (settingsStore.settings.app.routeBaseOn !== 'filesystem') {
|
||||||
routeStore.routes.forEach((item) => {
|
routeStore.routes.forEach((item) => {
|
||||||
item.children && getSourceList(item.children)
|
item.children && getSourceList(item.children as RouteRecordRaw[])
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
@ -138,7 +146,7 @@ onMounted(() => {
|
|||||||
getSourceListByMenus(item.children)
|
getSourceListByMenus(item.children)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
|
||||||
function hasChildren(item: RouteRecordRaw) {
|
function hasChildren(item: RouteRecordRaw) {
|
||||||
let flag = true
|
let flag = true
|
||||||
@ -147,18 +155,22 @@ function hasChildren(item: RouteRecordRaw) {
|
|||||||
}
|
}
|
||||||
return flag
|
return flag
|
||||||
}
|
}
|
||||||
function getSourceList(arr: RouteRecordRaw[], basePath?: string, icon?: string, breadcrumb?: (string | (() => string))[]) {
|
function getSourceList(arr: RouteRecordRaw[], basePath?: string, icon?: string, breadcrumb?: { title?: string | (() => string) }[]) {
|
||||||
arr.forEach((item) => {
|
arr.forEach((item) => {
|
||||||
if (item.meta?.sidebar !== false) {
|
if (item.meta?.sidebar !== false) {
|
||||||
const breadcrumbTemp = cloneDeep(breadcrumb) || []
|
const breadcrumbTemp = cloneDeep(breadcrumb) || []
|
||||||
if (item.children && hasChildren(item)) {
|
if (item.children && hasChildren(item)) {
|
||||||
item.meta?.title && breadcrumbTemp.push(item.meta?.title)
|
breadcrumbTemp.push({
|
||||||
getSourceList(item.children, basePath ? [basePath, item.path].join('/') : item.path, item.meta?.icon ?? icon, breadcrumbTemp)
|
title: item.meta?.title,
|
||||||
|
})
|
||||||
|
getSourceList(item.children, resolveRoutePath(basePath, item.path), item.meta?.icon ?? icon, breadcrumbTemp)
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
item.meta?.title && breadcrumbTemp.push(item.meta?.title)
|
breadcrumbTemp.push({
|
||||||
|
title: item.meta?.title,
|
||||||
|
})
|
||||||
sourceList.value.push({
|
sourceList.value.push({
|
||||||
path: basePath ? [basePath, item.path].join('/') : item.path,
|
path: resolveRoutePath(basePath, item.path),
|
||||||
icon: item.meta?.icon ?? icon,
|
icon: item.meta?.icon ?? icon,
|
||||||
title: item.meta?.title,
|
title: item.meta?.title,
|
||||||
link: item.meta?.link,
|
link: item.meta?.link,
|
||||||
@ -168,15 +180,19 @@ function getSourceList(arr: RouteRecordRaw[], basePath?: string, icon?: string,
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
function getSourceListByMenus(arr: Menu.recordRaw[], icon?: string, breadcrumb?: (string | (() => string))[]) {
|
function getSourceListByMenus(arr: Menu.recordRaw[], icon?: string, breadcrumb?: { title?: string | (() => string) }[]) {
|
||||||
arr.forEach((item) => {
|
arr.forEach((item) => {
|
||||||
const breadcrumbTemp = cloneDeep(breadcrumb) || []
|
const breadcrumbTemp = cloneDeep(breadcrumb) || []
|
||||||
if (item.children && item.children.length > 0) {
|
if (item.children && item.children.length > 0) {
|
||||||
item.meta?.title && breadcrumbTemp.push(item.meta?.title)
|
breadcrumbTemp.push({
|
||||||
|
title: item.meta?.title,
|
||||||
|
})
|
||||||
getSourceListByMenus(item.children, item.meta?.icon ?? icon, breadcrumbTemp)
|
getSourceListByMenus(item.children, item.meta?.icon ?? icon, breadcrumbTemp)
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
item.meta?.title && breadcrumbTemp.push(item.meta?.title)
|
breadcrumbTemp.push({
|
||||||
|
title: item.meta?.title,
|
||||||
|
})
|
||||||
sourceList.value.push({
|
sourceList.value.push({
|
||||||
icon: item.meta?.icon ?? icon,
|
icon: item.meta?.icon ?? icon,
|
||||||
title: item.meta?.title,
|
title: item.meta?.title,
|
||||||
@ -207,27 +223,31 @@ function keyDown() {
|
|||||||
}
|
}
|
||||||
function keyEnter() {
|
function keyEnter() {
|
||||||
if (actived.value !== -1) {
|
if (actived.value !== -1) {
|
||||||
searchResultItemRef.value[actived.value].click()
|
searchResultItemRef.value.find(item => Number.parseInt(item.dataset.index!) === actived.value)?.click()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// FIXME 列表数据变更后,快捷键滚动失效
|
||||||
function handleScroll() {
|
function handleScroll() {
|
||||||
let scrollTo = 0
|
if (searchResultRef.value) {
|
||||||
if (actived.value !== -1) {
|
const contentDom = searchResultRef.value.osInstance()!.elements().content
|
||||||
scrollTo = searchResultRef.value.scrollTop
|
let scrollTo = 0
|
||||||
const activedOffsetTop = searchResultItemRef.value[actived.value].offsetTop
|
if (actived.value !== -1) {
|
||||||
const activedClientHeight = searchResultItemRef.value[actived.value].clientHeight
|
scrollTo = contentDom.scrollTop
|
||||||
const searchScrollTop = searchResultRef.value.scrollTop
|
const activedOffsetTop = searchResultItemRef.value.find(item => Number.parseInt(item.dataset.index!) === actived.value)?.offsetTop ?? 0
|
||||||
const searchClientHeight = searchResultRef.value.clientHeight
|
const activedClientHeight = searchResultItemRef.value.find(item => Number.parseInt(item.dataset.index!) === actived.value)?.clientHeight ?? 0
|
||||||
if (activedOffsetTop + activedClientHeight > searchScrollTop + searchClientHeight) {
|
const searchScrollTop = contentDom.scrollTop
|
||||||
scrollTo = activedOffsetTop + activedClientHeight - searchClientHeight
|
const searchClientHeight = contentDom.clientHeight
|
||||||
}
|
if (activedOffsetTop + activedClientHeight > searchScrollTop + searchClientHeight) {
|
||||||
else if (activedOffsetTop <= searchScrollTop) {
|
scrollTo = activedOffsetTop + activedClientHeight - searchClientHeight
|
||||||
scrollTo = activedOffsetTop
|
}
|
||||||
|
else if (activedOffsetTop <= searchScrollTop) {
|
||||||
|
scrollTo = activedOffsetTop
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
contentDom.scrollTo({
|
||||||
|
top: scrollTo,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
searchResultRef.value.scrollTo({
|
|
||||||
top: scrollTo,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function pageJump(path: listTypes['path'], link: listTypes['link']) {
|
function pageJump(path: listTypes['path'], link: listTypes['link']) {
|
||||||
@ -237,248 +257,82 @@ function pageJump(path: listTypes['path'], link: listTypes['link']) {
|
|||||||
else {
|
else {
|
||||||
router.push(path)
|
router.push(path)
|
||||||
}
|
}
|
||||||
|
isShow.value = false
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div id="search" :class="{ searching: isShow }" @click="isShow && eventBus.emit('global-search-toggle')">
|
<TransitionRoot as="template" :show="isShow" @after-leave="isScrollbarsInit = false">
|
||||||
<div class="container">
|
<Dialog :initial-focus="searchInputRef" class="fixed inset-0 flex z-2000" @close="isShow && eventBus.emit('global-search-toggle')">
|
||||||
<div class="search-box" @click.stop>
|
<TransitionChild as="template" v-bind="overlayTransitionClass">
|
||||||
<el-input ref="searchInputRef" v-model="searchInput" placeholder="搜索页面,支持标题、URL模糊查询" clearable @keydown.esc="eventBus.emit('global-search-toggle')" @keydown.up.prevent="keyUp" @keydown.down.prevent="keyDown" @keydown.enter.prevent="keyEnter">
|
<div class="fixed inset-0 transition-opacity bg-stone-200/75 dark:bg-stone-8/75 backdrop-blur-sm" />
|
||||||
<template #prefix>
|
</TransitionChild>
|
||||||
<svg-icon name="ep:search" />
|
<div class="fixed inset-0">
|
||||||
</template>
|
<div class="flex h-full items-end sm:items-center justify-center text-center p-4 sm:p-0">
|
||||||
</el-input>
|
<TransitionChild as="template" v-bind="transitionClass">
|
||||||
<div v-if="settingsStore.mode === 'pc'" class="tips">
|
<DialogPanel class="relative text-left w-full sm:max-w-2xl h-full max-h-4/5 flex flex-col">
|
||||||
<div class="tip">
|
<div class="flex flex-col bg-white dark:bg-stone-8 rounded-xl shadow-xl overflow-y-auto">
|
||||||
<el-tag type="info" size="large">
|
<div class="flex items-center px-4 py-3" border-b="~ solid stone-2 dark:stone-7">
|
||||||
{{ settingsStore.os === 'mac' ? '⌥' : 'Alt' }} + S
|
<SvgIcon name="ep:search" :size="18" class="text-stone-5" />
|
||||||
</el-tag>
|
<input ref="searchInputRef" v-model="searchInput" placeholder="搜索页面,支持标题、URL模糊查询" class="w-full focus:outline-none border-0 rounded-md placeholder-stone-4 dark:placeholder-stone-5 text-base px-3 bg-transparent text-dark dark:text-white" @keydown.esc="eventBus.emit('global-search-toggle')" @keydown.up.prevent="keyUp" @keydown.down.prevent="keyDown" @keydown.enter.prevent="keyEnter">
|
||||||
<el-tag type="info" size="large">
|
</div>
|
||||||
唤醒搜索面板
|
<DialogDescription class="relative m-0 of-y-hidden">
|
||||||
</el-tag>
|
<OverlayScrollbarsComponent ref="searchResultRef" :options="{ scrollbars: { autoHide: 'leave', autoHideDelay: 300 } }" class="h-full" @os-initialized="isScrollbarsInit = true">
|
||||||
</div>
|
<template v-if="isScrollbarsInit">
|
||||||
<div class="tip">
|
<template v-if="resultList.length > 0">
|
||||||
<el-tag type="info" size="large">
|
<a v-for="(item, index) in resultList" ref="searchResultItemRef" :key="item.path" class="flex items-center cursor-pointer" :class="{ 'bg-stone-2/40 dark:bg-stone-7/40': index === actived }" :data-index="index" @click="pageJump(item.path, item.link)" @mouseover="actived = index">
|
||||||
<svg-icon name="search-up" />
|
<SvgIcon v-if="item.icon" :name="item.icon" :size="20" class="basis-16 transition" :class="{ 'scale-120 text-ui-primary': index === actived }" />
|
||||||
</el-tag>
|
<div class="flex-1 flex flex-col gap-1 px-4 py-3 truncate" border-l="~ solid stone-2 dark:stone-7">
|
||||||
<el-tag type="info" size="large">
|
<div class="text-base font-bold truncate">{{ item.title ?? '[ 无标题 ]' }}</div>
|
||||||
<svg-icon name="search-down" />
|
<Breadcrumb v-if="item.breadcrumb.length" class="truncate">
|
||||||
</el-tag>
|
<BreadcrumbItem v-for="(bc, bcIndex) in item.breadcrumb" :key="bcIndex" class="text-xs">
|
||||||
<el-tag type="info" size="large">
|
{{ bc.title ?? '[ 无标题 ]' }}
|
||||||
切换搜索结果
|
</BreadcrumbItem>
|
||||||
</el-tag>
|
</Breadcrumb>
|
||||||
</div>
|
</div>
|
||||||
<div class="tip">
|
</a>
|
||||||
<el-tag type="info" size="large">
|
</template>
|
||||||
<svg-icon name="search-enter" />
|
<template v-else>
|
||||||
</el-tag>
|
<div flex="center col" py-6 text-stone-5>
|
||||||
<el-tag type="info" size="large">
|
<SvgIcon name="tabler:mood-empty" :size="40" />
|
||||||
访问页面
|
<p text-base m-2>
|
||||||
</el-tag>
|
没有找到你想要的
|
||||||
</div>
|
</p>
|
||||||
<div class="tip">
|
</div>
|
||||||
<el-tag type="info" size="large">
|
</template>
|
||||||
ESC
|
</template>
|
||||||
</el-tag>
|
</OverlayScrollbarsComponent>
|
||||||
<el-tag type="info" size="large">
|
</DialogDescription>
|
||||||
退出
|
<div v-if="settingsStore.mode === 'pc'" class="px-4 py-3 flex justify-between" border-t="~ solid stone-2 dark:stone-7">
|
||||||
</el-tag>
|
<div class="flex gap-8">
|
||||||
</div>
|
<div class="inline-flex items-center gap-1 text-xs">
|
||||||
|
<HKbd>
|
||||||
|
<SvgIcon name="ion:md-return-left" :size="14" />
|
||||||
|
</HKbd>
|
||||||
|
<span>访问</span>
|
||||||
|
</div>
|
||||||
|
<div class="inline-flex items-center gap-1 text-xs">
|
||||||
|
<HKbd>
|
||||||
|
<SvgIcon name="ant-design:caret-up-filled" :size="14" />
|
||||||
|
</HKbd>
|
||||||
|
<HKbd>
|
||||||
|
<SvgIcon name="ant-design:caret-down-filled" :size="14" />
|
||||||
|
</HKbd>
|
||||||
|
<span>切换</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="settingsStore.settings.navSearch.enableHotkeys" class="inline-flex items-center gap-1 text-xs">
|
||||||
|
<HKbd>
|
||||||
|
ESC
|
||||||
|
</HKbd>
|
||||||
|
<span>退出</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogPanel>
|
||||||
|
</TransitionChild>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div ref="searchResultRef" class="result">
|
</Dialog>
|
||||||
<a v-for="(item, index) in resultList" :key="item.path" :ref="setSearchResultItemRef" class="item" :class="{ actived: index === actived }" @click="pageJump(item.path, item.link)" @mouseover="actived = index">
|
</TransitionRoot>
|
||||||
<svg-icon v-if="item.icon" :name="item.icon" />
|
|
||||||
<div class="info">
|
|
||||||
<div class="title">
|
|
||||||
{{ item.title ?? '[ 无标题 ]' }}
|
|
||||||
</div>
|
|
||||||
<div class="breadcrumb">
|
|
||||||
<span v-for="(bc, bcIndex) in item.breadcrumb" :key="bcIndex">
|
|
||||||
{{ bc ?? '[ 无标题 ]' }}
|
|
||||||
<svg-icon name="ep:arrow-right" />
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="path">{{ item.path }}</div>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
#search {
|
|
||||||
position: fixed;
|
|
||||||
z-index: 2000;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
background-image: radial-gradient(transparent 1px, rgb(0 0 0 / 30%) 1px);
|
|
||||||
background-size: 4px 4px;
|
|
||||||
backdrop-filter: saturate(50%) blur(4px);
|
|
||||||
transition: all 0.2s;
|
|
||||||
opacity: 0;
|
|
||||||
visibility: hidden;
|
|
||||||
|
|
||||||
&.searching {
|
|
||||||
opacity: 1;
|
|
||||||
visibility: visible;
|
|
||||||
|
|
||||||
.container {
|
|
||||||
transform: initial;
|
|
||||||
filter: initial;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.container {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
max-width: 800px;
|
|
||||||
height: 100%;
|
|
||||||
margin: 0 auto;
|
|
||||||
transition: all 0.2s;
|
|
||||||
transform: scale(1.1);
|
|
||||||
filter: blur(10px);
|
|
||||||
|
|
||||||
.search-box {
|
|
||||||
margin: 50px 20px 20px;
|
|
||||||
|
|
||||||
:deep(.el-input__inner) {
|
|
||||||
height: 52px;
|
|
||||||
line-height: 52px;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.el-input__icon) {
|
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tips {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
padding-top: 20px;
|
|
||||||
font-weight: bold;
|
|
||||||
|
|
||||||
.tip {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
margin: 0 20px;
|
|
||||||
|
|
||||||
.el-tag {
|
|
||||||
margin: 0 5px;
|
|
||||||
|
|
||||||
&:first-child {
|
|
||||||
margin-left: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:last-child {
|
|
||||||
margin-right: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.result {
|
|
||||||
position: relative;
|
|
||||||
margin: 0 20px 50px;
|
|
||||||
border-radius: 5px;
|
|
||||||
overflow: auto;
|
|
||||||
background-color: var(--el-bg-color);
|
|
||||||
box-shadow: 0 0 0 1px var(--el-border-color-darker);
|
|
||||||
|
|
||||||
.item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
text-decoration: none;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.3s;
|
|
||||||
|
|
||||||
&.actived {
|
|
||||||
background-color: var(--el-bg-color-page);
|
|
||||||
|
|
||||||
.icon {
|
|
||||||
color: var(--el-color-primary);
|
|
||||||
transform: scale(1.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.info {
|
|
||||||
border-left-color: var(--el-border-color);
|
|
||||||
|
|
||||||
.title {
|
|
||||||
color: var(--el-text-color-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.breadcrumb,
|
|
||||||
.path {
|
|
||||||
color: var(--el-text-color-regular);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
> .icon {
|
|
||||||
flex: 0 0 66px;
|
|
||||||
text-align: center;
|
|
||||||
color: var(--el-color-info);
|
|
||||||
font-size: 20px;
|
|
||||||
transition: all 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info {
|
|
||||||
flex: 1;
|
|
||||||
height: 70px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: space-around;
|
|
||||||
border-left: 1px solid var(--el-border-color-lighter);
|
|
||||||
padding: 5px 10px 7px;
|
|
||||||
transition: all 0.3s;
|
|
||||||
|
|
||||||
@include text-overflow(1, true);
|
|
||||||
|
|
||||||
.title {
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: bold;
|
|
||||||
color: var(--el-text-color-regular);
|
|
||||||
|
|
||||||
@include text-overflow(1, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
.breadcrumb,
|
|
||||||
.path {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--el-text-color-secondary);
|
|
||||||
transition: all 0.3s;
|
|
||||||
|
|
||||||
@include text-overflow(1, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
.breadcrumb {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
span {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
.icon {
|
|
||||||
margin: 0 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:last-child i {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
@ -1,152 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { resolveRoutePath } from '@/utils'
|
|
||||||
import useSettingsStore from '@/store/modules/settings'
|
|
||||||
import type { Menu } from '#/global'
|
|
||||||
|
|
||||||
defineOptions({
|
|
||||||
name: 'SidebarItem',
|
|
||||||
})
|
|
||||||
|
|
||||||
const props = withDefaults(
|
|
||||||
defineProps<{
|
|
||||||
item: Menu.recordRaw
|
|
||||||
basePath?: string
|
|
||||||
}>(),
|
|
||||||
{
|
|
||||||
basePath: '',
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
const settingsStore = useSettingsStore()
|
|
||||||
|
|
||||||
const hasChildren = computed(() => {
|
|
||||||
let flag = true
|
|
||||||
if (props.item.children) {
|
|
||||||
if (props.item.children.every(item => item.meta?.sidebar === false)) {
|
|
||||||
flag = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
flag = false
|
|
||||||
}
|
|
||||||
return flag
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="sidebar-item">
|
|
||||||
<router-link v-if="!hasChildren" v-slot="{ href, navigate, isActive, isExactActive }" custom :to="resolveRoutePath(basePath, item.path)">
|
|
||||||
<a :href="item.meta?.link ? item.meta.link : href" :class="[isActive && 'router-link-active', isExactActive && 'router-link-exact-active']" :target="item.meta?.link ? '_blank' : '_self'" @click="navigate">
|
|
||||||
<el-menu-item :title="item.meta?.title ?? '[ 无标题 ]'" :index="resolveRoutePath(basePath, item.path || '')">
|
|
||||||
<svg-icon v-if="item.meta?.icon" :name="item.meta.icon" class="title-icon" />
|
|
||||||
<span class="title">{{ item.meta?.title ?? '[ 无标题 ]' }}</span>
|
|
||||||
</el-menu-item>
|
|
||||||
</a>
|
|
||||||
</router-link>
|
|
||||||
<el-sub-menu v-else :title="item.meta?.title ?? '[ 无标题 ]'" :index="settingsStore.settings.app.routeBaseOn !== 'filesystem' ? resolveRoutePath(basePath, item.path) : JSON.stringify(item)">
|
|
||||||
<template #title>
|
|
||||||
<svg-icon v-if="item.meta?.icon" :name="item.meta.icon" class="title-icon" />
|
|
||||||
<span class="title">{{ item.meta?.title ?? '[ 无标题 ]' }}</span>
|
|
||||||
</template>
|
|
||||||
<template v-for="route in (item.children as Menu.recordRaw[])">
|
|
||||||
<SidebarItem v-if="route.meta?.sidebar !== false" :key="route.path" :item="route" :base-path="resolveRoutePath(basePath, item.path)" />
|
|
||||||
</template>
|
|
||||||
</el-sub-menu>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
:deep(.el-menu-item),
|
|
||||||
:deep(.el-menu-item span.title),
|
|
||||||
:deep(.el-sub-menu__title),
|
|
||||||
:deep(.el-sub-menu__title span.title) {
|
|
||||||
flex: 1;
|
|
||||||
vertical-align: inherit;
|
|
||||||
|
|
||||||
@include text-overflow;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.el-menu-item) {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.el-sub-menu),
|
|
||||||
:deep(.el-menu-item) {
|
|
||||||
.title-icon {
|
|
||||||
width: 20px;
|
|
||||||
font-size: 20px;
|
|
||||||
vertical-align: -0.25em;
|
|
||||||
transition: transform 0.3s;
|
|
||||||
color: unset;
|
|
||||||
}
|
|
||||||
|
|
||||||
.title-icon + .title {
|
|
||||||
margin-left: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover > .title-icon,
|
|
||||||
.el-sub-menu__title:hover > .title-icon {
|
|
||||||
transform: scale(1.2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
cursor: pointer;
|
|
||||||
color: inherit;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.el-sub-menu__title {
|
|
||||||
> .badge {
|
|
||||||
&-dot {
|
|
||||||
right: 40px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&-text {
|
|
||||||
right: 40px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
.el-menu--inline {
|
|
||||||
background-color: var(--g-sub-sidebar-menu-bg) !important;
|
|
||||||
|
|
||||||
.el-menu-item,
|
|
||||||
.el-sub-menu > .el-sub-menu__title {
|
|
||||||
color: var(--g-sub-sidebar-menu-color);
|
|
||||||
background-color: var(--g-sub-sidebar-menu-bg) !important;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: var(--g-sub-sidebar-menu-hover-color) !important;
|
|
||||||
background-color: var(--g-sub-sidebar-menu-hover-bg) !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.el-menu-item,
|
|
||||||
.el-sub-menu__title {
|
|
||||||
color: var(--g-sub-sidebar-menu-color) !important;
|
|
||||||
background: transparent !important;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: var(--g-sub-sidebar-menu-hover-color) !important;
|
|
||||||
background-color: var(--g-sub-sidebar-menu-hover-bg) !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.el-menu-item.is-active,
|
|
||||||
.el-menu--collapse .el-sub-menu.is-active > .el-sub-menu__title,
|
|
||||||
.el-sub-menu .el-menu--inline .el-menu-item.is-active {
|
|
||||||
color: var(--g-sub-sidebar-menu-active-color) !important;
|
|
||||||
background-color: var(--g-sub-sidebar-menu-active-bg) !important;
|
|
||||||
|
|
||||||
.icon,
|
|
||||||
.el-sub-menu__icon-arrow {
|
|
||||||
color: unset;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
143
src/layouts/components/SubSidebar/index.vue
Normal file → Executable file
@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import Logo from '../Logo/index.vue'
|
import Logo from '../Logo/index.vue'
|
||||||
import SidebarItem from '../SidebarItem/index.vue'
|
import Menu from '../Menu/index.vue'
|
||||||
import useSettingsStore from '@/store/modules/settings'
|
import useSettingsStore from '@/store/modules/settings'
|
||||||
import useMenuStore from '@/store/modules/menu'
|
import useMenuStore from '@/store/modules/menu'
|
||||||
|
|
||||||
@ -18,59 +18,60 @@ const sidebarScrollTop = ref(0)
|
|||||||
function onSidebarScroll(e: Event) {
|
function onSidebarScroll(e: Event) {
|
||||||
sidebarScrollTop.value = (e.target as HTMLElement).scrollTop
|
sidebarScrollTop.value = (e.target as HTMLElement).scrollTop
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const enableSidebar = computed(() => {
|
||||||
|
return settingsStore.mode === 'mobile' || (
|
||||||
|
['side', 'head', 'single'].includes(settingsStore.settings.menu.menuMode)
|
||||||
|
&& menuStore.sidebarMenus.length !== 0
|
||||||
|
&& !(
|
||||||
|
!menuStore.sidebarMenus[0].children
|
||||||
|
|| menuStore.sidebarMenus[0]?.children.every(item => item.meta?.sidebar === false)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-if="['side', 'head', 'single'].includes(settingsStore.settings.menu.menuMode) || settingsStore.mode === 'mobile'" class="sub-sidebar-container" :class="{ 'is-collapse': settingsStore.mode === 'pc' && settingsStore.settings.menu.subMenuCollapse }" @scroll="onSidebarScroll">
|
<div
|
||||||
|
v-if="enableSidebar" class="sub-sidebar-container" :class="{
|
||||||
|
'is-collapse': settingsStore.mode === 'pc' && settingsStore.settings.menu.subMenuCollapse,
|
||||||
|
}"
|
||||||
|
>
|
||||||
<Logo
|
<Logo
|
||||||
:show-logo="settingsStore.settings.menu.menuMode === 'single'" class="sidebar-logo" :class="{
|
:show-logo="settingsStore.settings.menu.menuMode === 'single'" class="sidebar-logo" :class="{
|
||||||
'sidebar-logo-bg': settingsStore.settings.menu.menuMode === 'single',
|
'sidebar-logo-bg': settingsStore.settings.menu.menuMode === 'single',
|
||||||
'shadow': sidebarScrollTop,
|
|
||||||
}"
|
}"
|
||||||
/>
|
/>
|
||||||
<transition-group name="sub-sidebar">
|
<div
|
||||||
<template v-for="(mainItem, mainIndex) in menuStore.allMenus" :key="mainIndex">
|
class="sub-sidebar flex-1 transition-shadow-300" :class="{
|
||||||
<div v-show="mainIndex === menuStore.actived">
|
shadow: sidebarScrollTop,
|
||||||
<el-menu
|
}" @scroll="onSidebarScroll"
|
||||||
:unique-opened="settingsStore.settings.menu.subMenuUniqueOpened" :default-openeds="menuStore.defaultOpenedPaths" :default-active="route.meta.activeMenu || route.path" :collapse="settingsStore.mode === 'pc' && settingsStore.settings.menu.subMenuCollapse" :collapse-transition="false" :class="{
|
>
|
||||||
'is-collapse-without-logo': settingsStore.settings.menu.menuMode !== 'single' && settingsStore.settings.menu.subMenuCollapse,
|
<TransitionGroup name="sub-sidebar">
|
||||||
}"
|
<template v-for="(mainItem, mainIndex) in menuStore.allMenus" :key="mainIndex">
|
||||||
>
|
<div v-show="mainIndex === menuStore.actived">
|
||||||
<template v-for="(item, index) in mainItem.children">
|
<Menu :menu="mainItem.children" :value="route.meta.activeMenu || route.path" :default-openeds="menuStore.defaultOpenedPaths" :accordion="settingsStore.settings.menu.subMenuUniqueOpened" :collapse="settingsStore.mode === 'pc' && settingsStore.settings.menu.subMenuCollapse" class="menu" />
|
||||||
<SidebarItem v-if="item.meta?.sidebar !== false" :key="item.path || index" :item="item" :base-path="item.path" />
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-menu>
|
</TransitionGroup>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
|
||||||
</transition-group>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.sub-sidebar-container {
|
.sub-sidebar-container {
|
||||||
overflow-x: hidden;
|
display: flex;
|
||||||
overflow-y: auto;
|
flex-direction: column;
|
||||||
overscroll-behavior: contain;
|
|
||||||
|
|
||||||
// firefox隐藏滚动条
|
|
||||||
scrollbar-width: none;
|
|
||||||
|
|
||||||
// chrome隐藏滚动条
|
|
||||||
&::-webkit-scrollbar {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
width: var(--g-sub-sidebar-width);
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0;
|
left: 0;
|
||||||
top: 0;
|
top: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
|
width: var(--g-sub-sidebar-width);
|
||||||
background-color: var(--g-sub-sidebar-bg);
|
background-color: var(--g-sub-sidebar-bg);
|
||||||
box-shadow: 10px 0 10px -10px var(--g-box-shadow-color);
|
transition: background-color 0.3s, left 0.3s, width 0.3s;
|
||||||
transition: background-color 0.3s, var(--el-transition-box-shadow), left 0.3s, width 0.3s;
|
|
||||||
|
|
||||||
&.is-collapse {
|
&.is-collapse {
|
||||||
width: 64px;
|
width: var(--g-sub-sidebar-collapse-width);
|
||||||
|
|
||||||
.sidebar-logo {
|
.sidebar-logo {
|
||||||
&:not(.sidebar-logo-bg) {
|
&:not(.sidebar-logo-bg) {
|
||||||
@ -84,77 +85,43 @@ function onSidebarScroll(e: Event) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-logo {
|
.sidebar-logo {
|
||||||
transition: box-shadow 0.2s, background-color 0.3s, color 0.3s;
|
|
||||||
background-color: var(--g-sub-sidebar-bg);
|
background-color: var(--g-sub-sidebar-bg);
|
||||||
|
transition: background-color 0.3s;
|
||||||
&:not(.sidebar-logo-bg) {
|
|
||||||
:deep(span) {
|
|
||||||
color: var(--g-sub-sidebar-menu-color);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.sidebar-logo-bg {
|
&.sidebar-logo-bg {
|
||||||
background-color: var(--g-main-sidebar-bg);
|
background-color: var(--g-sub-sidebar-logo-bg);
|
||||||
}
|
|
||||||
|
|
||||||
&.shadow {
|
:deep(span) {
|
||||||
box-shadow: 0 10px 10px -10px var(--g-box-shadow-color);
|
color: var(--g-sub-sidebar-logo-color);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-menu {
|
.sub-sidebar {
|
||||||
border-right: 0;
|
overflow: hidden auto;
|
||||||
padding-top: var(--g-sidebar-logo-height);
|
overscroll-behavior: contain;
|
||||||
transition: border-color 0.3s, background-color 0.3s, color 0.3s, padding-top 0.3s;
|
|
||||||
background-color: var(--g-sub-sidebar-bg);
|
|
||||||
|
|
||||||
&:not(.el-menu--collapse) {
|
// firefox隐藏滚动条
|
||||||
width: inherit;
|
scrollbar-width: none;
|
||||||
|
|
||||||
|
// chrome隐藏滚动条
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.is-collapse-without-logo {
|
&.shadow {
|
||||||
padding-top: 0;
|
box-shadow: inset 0 10px 10px -10px var(--g-box-shadow-color);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&.el-menu--collapse {
|
.menu {
|
||||||
:deep(.title-icon) {
|
width: 100%;
|
||||||
margin-right: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.el-menu-item),
|
|
||||||
:deep(.el-sub-menu__title) {
|
|
||||||
span,
|
|
||||||
.el-sub-menu__icon-arrow {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.menu-radius:not(.el-menu--collapse) {
|
|
||||||
.sidebar-item {
|
|
||||||
padding: 0 10px;
|
|
||||||
|
|
||||||
&:first-child {
|
|
||||||
padding-top: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:last-child {
|
|
||||||
padding-bottom: 10px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.el-menu--inline),
|
|
||||||
:deep(.el-menu-item),
|
|
||||||
:deep(.el-sub-menu__title) {
|
|
||||||
border-radius: 10px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 次侧边栏动画
|
// 次侧边栏动画
|
||||||
.sub-sidebar-enter-active {
|
.sub-sidebar-enter-active {
|
||||||
transition: opacity 0.3s, transform 0.3s;
|
transition: 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sub-sidebar-enter-from,
|
.sub-sidebar-enter-from,
|
||||||
|
156
src/layouts/components/Tools/index.vue
Normal file → Executable file
@ -16,112 +16,88 @@ const userStore = useUserStore()
|
|||||||
const mainPage = useMainPage()
|
const mainPage = useMainPage()
|
||||||
const { isFullscreen, toggle } = useFullscreen()
|
const { isFullscreen, toggle } = useFullscreen()
|
||||||
|
|
||||||
function userCommand(command: 'home' | 'setting' | 'hotkeys' | 'logout') {
|
function toggleColorScheme(event: MouseEvent) {
|
||||||
switch (command) {
|
const { startViewTransition } = useViewTransition(() => {
|
||||||
case 'home':
|
settingsStore.setColorScheme(settingsStore.settings.app.colorScheme === 'dark' ? 'light' : 'dark')
|
||||||
router.push({
|
})
|
||||||
name: 'home',
|
startViewTransition()?.ready.then(() => {
|
||||||
})
|
const x = event.clientX
|
||||||
break
|
const y = event.clientY
|
||||||
case 'setting':
|
const endRadius = Math.hypot(
|
||||||
router.push({
|
Math.max(x, innerWidth - x),
|
||||||
name: 'personalSetting',
|
Math.max(y, innerHeight - y),
|
||||||
})
|
)
|
||||||
break
|
const clipPath = [
|
||||||
case 'hotkeys':
|
`circle(0px at ${x}px ${y}px)`,
|
||||||
eventBus.emit('global-hotkeys-intro-toggle')
|
`circle(${endRadius}px at ${x}px ${y}px)`,
|
||||||
break
|
]
|
||||||
case 'logout':
|
document.documentElement.animate(
|
||||||
userStore.logout()
|
{
|
||||||
break
|
clipPath: settingsStore.settings.app.colorScheme !== 'dark' ? clipPath : clipPath.reverse(),
|
||||||
}
|
},
|
||||||
|
{
|
||||||
|
duration: 300,
|
||||||
|
easing: 'ease-out',
|
||||||
|
pseudoElement: settingsStore.settings.app.colorScheme !== 'dark' ? '::view-transition-new(root)' : '::view-transition-old(root)',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const avatarError = ref(false)
|
||||||
|
watch(() => userStore.avatar, () => {
|
||||||
|
if (avatarError.value) {
|
||||||
|
avatarError.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="tools">
|
<div class="tools flex items-center gap-4 px-4 whitespace-nowrap">
|
||||||
<div class="buttons">
|
<span v-if="settingsStore.settings.navSearch.enable && settingsStore.mode === 'pc'" class="group inline-flex items-center gap-1 px-2 py-1.5 rounded-2 text-dark dark:text-white bg-stone-1 dark:bg-stone-9 ring-inset ring-stone-3 dark:ring-stone-7 hover:ring-1 cursor-pointer transition" @click="eventBus.emit('global-search-toggle')">
|
||||||
<span v-if="settingsStore.settings.navSearch.enable" class="item" @click="eventBus.emit('global-search-toggle')">
|
<SvgIcon name="ri:search-line" />
|
||||||
<svg-icon name="ep:search" />
|
<span class="text-sm text-stone-5 group-hover:text-dark dark:group-hover:text-white transition">搜索</span>
|
||||||
|
<HKbd v-if="settingsStore.settings.navSearch.enableHotkeys" class="ml-2">{{ settingsStore.os === 'mac' ? '⌥' : 'Alt' }} S</HKbd>
|
||||||
|
</span>
|
||||||
|
<div class="flex items-center empty:hidden">
|
||||||
|
<span v-if="settingsStore.settings.navSearch.enable && settingsStore.mode === 'mobile'" class="item" @click="eventBus.emit('global-search-toggle')">
|
||||||
|
<SvgIcon name="ri:search-line" />
|
||||||
</span>
|
</span>
|
||||||
<span v-if="settingsStore.mode === 'pc' && settingsStore.settings.toolbar.enableFullscreen" class="item" @click="toggle">
|
<span v-if="settingsStore.mode === 'pc' && settingsStore.settings.toolbar.enableFullscreen" class="item" @click="toggle">
|
||||||
<svg-icon :name="isFullscreen ? 'fullscreen-exit' : 'fullscreen'" />
|
<SvgIcon :name="isFullscreen ? 'ri:fullscreen-exit-line' : 'ri:fullscreen-line'" />
|
||||||
</span>
|
</span>
|
||||||
<span v-if="settingsStore.settings.toolbar.enablePageReload" class="item" @click="mainPage.reload()">
|
<span v-if="settingsStore.settings.toolbar.enablePageReload" class="item" @click="mainPage.reload()">
|
||||||
<svg-icon name="ep:refresh-right" />
|
<SvgIcon name="iconoir:refresh-double" />
|
||||||
</span>
|
</span>
|
||||||
<span v-if="settingsStore.settings.toolbar.enableColorScheme" class="item" @click="settingsStore.setColorScheme(settingsStore.settings.app.colorScheme === 'dark' ? 'light' : 'dark')">
|
<span v-if="settingsStore.settings.toolbar.enableColorScheme" class="item" @click="toggleColorScheme">
|
||||||
<svg-icon v-show="settingsStore.settings.app.colorScheme === 'light'" name="ep:sunny" />
|
<SvgIcon :name="settingsStore.settings.app.colorScheme === 'light' ? 'ri:sun-line' : 'ri:moon-line'" />
|
||||||
<svg-icon v-show="settingsStore.settings.app.colorScheme === 'dark'" name="ep:moon" />
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<el-dropdown class="user-container" size="default" @command="userCommand">
|
<HDropdownMenu
|
||||||
<div class="user-wrapper">
|
:items="[
|
||||||
<el-avatar size="small">
|
[
|
||||||
<svg-icon name="ep:user-filled" />
|
{ label: settingsStore.settings.home.title, handle: () => router.push({ name: 'home' }), hide: !settingsStore.settings.home.enable },
|
||||||
</el-avatar>
|
],
|
||||||
|
[
|
||||||
|
{ label: '快捷键介绍', handle: () => eventBus.emit('global-hotkeys-intro-toggle'), hide: settingsStore.mode !== 'pc' },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ label: '退出登录', handle: () => userStore.logout() },
|
||||||
|
],
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<div flex-center gap-1 cursor-pointer>
|
||||||
|
<img v-if="userStore.avatar && !avatarError" :src="userStore.avatar" :onerror="() => (avatarError = true)" class="w-[24px] h-[24px] rounded-full">
|
||||||
|
<SvgIcon v-else name="carbon:user-avatar-filled-alt" :size="24" class="text-gray-400" />
|
||||||
{{ userStore.account }}
|
{{ userStore.account }}
|
||||||
<svg-icon name="ep:caret-bottom" />
|
<SvgIcon name="ep:caret-bottom" />
|
||||||
</div>
|
</div>
|
||||||
<template #dropdown>
|
</HDropdownMenu>
|
||||||
<el-dropdown-menu class="user-dropdown">
|
|
||||||
<el-dropdown-item v-if="settingsStore.settings.home.enable" command="home">
|
|
||||||
{{ settingsStore.settings.home.title }}
|
|
||||||
</el-dropdown-item>
|
|
||||||
<el-dropdown-item command="setting">
|
|
||||||
个人设置
|
|
||||||
</el-dropdown-item>
|
|
||||||
<el-dropdown-item v-if="settingsStore.mode === 'pc'" divided command="hotkeys">
|
|
||||||
快捷键介绍
|
|
||||||
</el-dropdown-item>
|
|
||||||
<el-dropdown-item divided command="logout">
|
|
||||||
退出登录
|
|
||||||
</el-dropdown-item>
|
|
||||||
</el-dropdown-menu>
|
|
||||||
</template>
|
|
||||||
</el-dropdown>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.tools {
|
.item {
|
||||||
display: flex;
|
--at-apply: flex px-2 py-1 cursor-pointer;
|
||||||
align-items: center;
|
|
||||||
padding: 0 20px;
|
|
||||||
white-space: nowrap;
|
|
||||||
|
|
||||||
.buttons {
|
|
||||||
margin-right: 20px;
|
|
||||||
|
|
||||||
.item {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
height: 24px;
|
|
||||||
width: 34px;
|
|
||||||
cursor: pointer;
|
|
||||||
vertical-align: middle;
|
|
||||||
|
|
||||||
.icon {
|
|
||||||
color: var(--el-text-color-primary);
|
|
||||||
transition: var(--el-transition-color);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.user-container) {
|
|
||||||
display: inline-block;
|
|
||||||
height: 24px;
|
|
||||||
line-height: 24px;
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
.user-wrapper {
|
|
||||||
.el-avatar {
|
|
||||||
vertical-align: middle;
|
|
||||||
margin-top: -2px;
|
|
||||||
margin-right: 4px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|