mirror of
https://github.com/chansee97/nova-admin.git
synced 2025-12-26 13:34:16 +08:00
Compare commits
No commits in common. "main" and "v0.9" have entirely different histories.
@ -1,8 +0,0 @@
|
||||
/node_modules
|
||||
/.git
|
||||
/.gitignore
|
||||
/.vscode
|
||||
/.DS_Store
|
||||
/*.md
|
||||
/dist
|
||||
|
||||
24
.env
24
.env
@ -1,26 +1,14 @@
|
||||
# 项目根目录
|
||||
VITE_BASE_URL = /
|
||||
|
||||
VITE_BASE_URL=/
|
||||
# 项目名称
|
||||
VITE_APP_NAME = Nova - Admin
|
||||
|
||||
# 路由模式 web | hash
|
||||
VITE_APP_NAME=Nova - Admin
|
||||
# 路由模式
|
||||
VITE_ROUTE_MODE = web
|
||||
|
||||
# 路由加载模式 static | dynamic
|
||||
VITE_ROUTE_LOAD_MODE = static
|
||||
# 权限路由模式: static | dynamic
|
||||
VITE_AUTH_ROUTE_MODE=static
|
||||
|
||||
# 设置登陆后跳转地址
|
||||
VITE_HOME_PATH = /dashboard/workbench
|
||||
|
||||
# 本地存储前缀
|
||||
VITE_STORAGE_PREFIX =
|
||||
|
||||
# 版权信息
|
||||
VITE_COPYRIGHT_INFO = Copyright © 2024 chansee97
|
||||
|
||||
# 自动刷新token
|
||||
VITE_AUTO_REFRESH_TOKEN = N
|
||||
|
||||
# 默认多语言 enUS | zhCN
|
||||
VITE_DEFAULT_LANG = enUS
|
||||
VITE_STORAGE_PREFIX=
|
||||
|
||||
6
.env.test
Normal file
6
.env.test
Normal file
@ -0,0 +1,6 @@
|
||||
# 是否开启压缩资源
|
||||
VITE_BUILD_COMPRESS=N
|
||||
|
||||
# 压缩算法 gzip | brotliCompress | deflate | deflateRaw
|
||||
VITE_COMPRESS_TYPE=gzip
|
||||
|
||||
42
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
42
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
@ -1,42 +0,0 @@
|
||||
name: 🐞 Bug report
|
||||
description: Create a report to help us improve
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to fill out this bug report!
|
||||
|
||||
- type: textarea
|
||||
id: bug-description
|
||||
attributes:
|
||||
label: Description
|
||||
description: Please explain clearly how the bug reappears. If possible, it is best to add the cause of the problem.
|
||||
placeholder: bug description
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Expected
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: additional-comments
|
||||
attributes:
|
||||
label: Additional comments
|
||||
description: e.g. some background/context of how you ran into this bug.
|
||||
|
||||
- type: checkboxes
|
||||
id: checkboxes
|
||||
attributes:
|
||||
label: Validations
|
||||
description: Before submitting the issue, please make sure you do the following
|
||||
options:
|
||||
- label: Ensure this issue not a bug proposal.
|
||||
required: true
|
||||
- label: Read the [docs](https://nova-admin-docs.pages.dev/).
|
||||
required: true
|
||||
- label: Check that there isn't [already an issue](https://github.com/chansee97/nova-admin/issues) that descript the same thing to avoid creating a duplicate.
|
||||
required: true
|
||||
1
.github/ISSUE_TEMPLATE/config.yml
vendored
1
.github/ISSUE_TEMPLATE/config.yml
vendored
@ -1 +0,0 @@
|
||||
blank_issues_enabled: false
|
||||
45
.github/ISSUE_TEMPLATE/feature-request.yml
vendored
45
.github/ISSUE_TEMPLATE/feature-request.yml
vendored
@ -1,45 +0,0 @@
|
||||
name: ✨ New feature
|
||||
|
||||
description: Propose a new feature to be added to Nova-admin
|
||||
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for your interest in the project and taking the time to fill out this feature report!
|
||||
|
||||
- type: textarea
|
||||
id: feature-description
|
||||
attributes:
|
||||
label: Description
|
||||
description: Clear and concise description of the problem. Please make the reason and usecases as detailed as possible. If you intend to submit a PR for this issue, tell us in the description. Thanks!
|
||||
placeholder: As a developer using Nova-admin I want [goal / wish] so that [benefit]...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: suggested-solution
|
||||
attributes:
|
||||
label: Suggestion
|
||||
description: In module [xy] we could provide following implementation...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: additional-context
|
||||
attributes:
|
||||
label: Additional context
|
||||
description: Any other context or screenshots about the feature request here.
|
||||
|
||||
- type: checkboxes
|
||||
id: checkboxes
|
||||
attributes:
|
||||
label: Validations
|
||||
description: Before submitting the issue, please make sure you do the following
|
||||
options:
|
||||
- label: Ensure this issue not a feature proposal.
|
||||
required: true
|
||||
- label: Read the [docs](https://nova-admin-docs.pages.dev/).
|
||||
required: true
|
||||
- label: Check that there isn't [already an issue](https://github.com/chansee97/nova-admin/issues) that descript the same thing to avoid creating a duplicate.
|
||||
required: true
|
||||
31
.github/ISSUE_TEMPLATE/others.yml
vendored
31
.github/ISSUE_TEMPLATE/others.yml
vendored
@ -1,31 +0,0 @@
|
||||
name: 👓 Others
|
||||
|
||||
description: Create an issue for Nova-admin
|
||||
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for your interest in the project and taking the time to create this issue!
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Description
|
||||
description: Clear and concise description of the issue. Thanks!
|
||||
placeholder: There are some thing I want to ...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: checkboxes
|
||||
id: checkboxes
|
||||
attributes:
|
||||
label: Validations
|
||||
description: Before submitting the issue, please make sure you do the following
|
||||
options:
|
||||
- label: Ensure this issue neither a bug report nor a feature proposal.
|
||||
required: true
|
||||
- label: Read the [docs](https://nova-admin-docs.pages.dev/).
|
||||
required: true
|
||||
- label: Check that there isn't [already an issue](https://github.com/chansee97/nova-admin/issues) that descript the same thing to avoid creating a duplicate.
|
||||
required: true
|
||||
5
.github/workflows/release.yml
vendored
5
.github/workflows/release.yml
vendored
@ -15,12 +15,11 @@ jobs:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
node-version: 20.x
|
||||
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 20.x
|
||||
node-version: 16.x
|
||||
|
||||
- run: npx changelogithub
|
||||
- run: npx changelogithub # or changelogithub@0.12 if ensure the stable result
|
||||
env:
|
||||
GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
|
||||
|
||||
11
.gitignore
vendored
11
.gitignore
vendored
@ -25,9 +25,8 @@ stats.html
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
components.d.ts
|
||||
auto-imports.d.ts
|
||||
auto-proxy.d.ts
|
||||
|
||||
# Lock files
|
||||
*-lock.yaml
|
||||
/src/typings/components.d.ts
|
||||
/src/typings/auto-imports.d.ts
|
||||
pnpm-lock.yaml
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
|
||||
3
.vscode/extensions.json
vendored
3
.vscode/extensions.json
vendored
@ -10,7 +10,6 @@
|
||||
"antfu.iconify",
|
||||
"kisstkondoros.vscode-gutter-preview",
|
||||
"antfu.unocss",
|
||||
"vue.volar",
|
||||
"tu6ge.naive-ui-intelligence"
|
||||
"vue.volar"
|
||||
]
|
||||
}
|
||||
|
||||
76
.vscode/settings.json
vendored
76
.vscode/settings.json
vendored
@ -1,4 +1,6 @@
|
||||
{
|
||||
// Enable the ESlint flat config support
|
||||
"eslint.experimental.useFlatConfig": true,
|
||||
// Disable the default formatter, use eslint instead
|
||||
"prettier.enable": false,
|
||||
"editor.formatOnSave": false,
|
||||
@ -9,16 +11,46 @@
|
||||
},
|
||||
// Silent the stylistic rules in you IDE, but still auto fix them
|
||||
"eslint.rules.customizations": [
|
||||
{ "rule": "style/*", "severity": "off" },
|
||||
{ "rule": "format/*", "severity": "off" },
|
||||
{ "rule": "*-indent", "severity": "off" },
|
||||
{ "rule": "*-spacing", "severity": "off" },
|
||||
{ "rule": "*-spaces", "severity": "off" },
|
||||
{ "rule": "*-order", "severity": "off" },
|
||||
{ "rule": "*-dangle", "severity": "off" },
|
||||
{ "rule": "*-newline", "severity": "off" },
|
||||
{ "rule": "*quotes", "severity": "off" },
|
||||
{ "rule": "*semi", "severity": "off" }
|
||||
{
|
||||
"rule": "style/*",
|
||||
"severity": "off"
|
||||
},
|
||||
{
|
||||
"rule": "format/*",
|
||||
"severity": "off"
|
||||
},
|
||||
{
|
||||
"rule": "*-indent",
|
||||
"severity": "off"
|
||||
},
|
||||
{
|
||||
"rule": "*-spacing",
|
||||
"severity": "off"
|
||||
},
|
||||
{
|
||||
"rule": "*-spaces",
|
||||
"severity": "off"
|
||||
},
|
||||
{
|
||||
"rule": "*-order",
|
||||
"severity": "off"
|
||||
},
|
||||
{
|
||||
"rule": "*-dangle",
|
||||
"severity": "off"
|
||||
},
|
||||
{
|
||||
"rule": "*-newline",
|
||||
"severity": "off"
|
||||
},
|
||||
{
|
||||
"rule": "*quotes",
|
||||
"severity": "off"
|
||||
},
|
||||
{
|
||||
"rule": "*semi",
|
||||
"severity": "off"
|
||||
}
|
||||
],
|
||||
// Enable eslint for all supported languages
|
||||
"eslint.validate": [
|
||||
@ -32,16 +64,7 @@
|
||||
"json",
|
||||
"jsonc",
|
||||
"yaml",
|
||||
"toml",
|
||||
"xml",
|
||||
"gql",
|
||||
"graphql",
|
||||
"astro",
|
||||
"css",
|
||||
"less",
|
||||
"scss",
|
||||
"pcss",
|
||||
"postcss"
|
||||
"toml"
|
||||
],
|
||||
"i18n-ally.sourceLanguage": "zh_CN",
|
||||
"i18n-ally.displayLanguage": "zh_CN",
|
||||
@ -51,16 +74,5 @@
|
||||
"i18n-ally.localesPaths": [
|
||||
"locales"
|
||||
],
|
||||
// File collapse
|
||||
"explorer.fileNesting.enabled": true,
|
||||
"explorer.fileNesting.expand": false,
|
||||
"explorer.fileNesting.patterns": {
|
||||
"*.ts": "$(capture).test.ts, $(capture).test.tsx, $(capture).spec.ts, $(capture).spec.tsx, $(capture).d.ts",
|
||||
"*.tsx": "$(capture).test.ts, $(capture).test.tsx, $(capture).spec.ts, $(capture).spec.tsx,$(capture).d.ts",
|
||||
"*.env": "$(capture).env.*",
|
||||
"README.md": "README*,CHANGELOG*,LICENSE,CNAME",
|
||||
"package.json": "pnpm-lock.yaml,pnpm-workspace.yaml,.gitattributes,.gitignore,.gitpod.yml,.npmrc,.browserslistrc,.node-version,.git*,.tazerc.json",
|
||||
"eslint.config.js": ".eslintignore,.prettierignore,.stylelintignore,.commitlintrc.*,.prettierrc.*,stylelint.config.*,.lintstagedrc.mjs,cspell.json",
|
||||
"docker-compose.product.yml": ".dockerignore,nginx.conf"
|
||||
}
|
||||
"commentTranslate.source": "Google"
|
||||
}
|
||||
|
||||
40
README.md
40
README.md
@ -5,8 +5,7 @@
|
||||
|
||||
<div align="center">
|
||||
<img src="https://img.shields.io/github/license/chansee97/nova-admin"/>
|
||||
<img src="https://badgen.net/github/stars/chansee97/nova-admin?icon=github"/>
|
||||
<img src="https://gitee.com/chansee97/nova-admin/badge/star.svg"/>
|
||||
<img src="https://img.shields.io/github/stars/chansee97/nova-admin"/>
|
||||
<img src="https://img.shields.io/github/forks/chansee97/nova-admin"/>
|
||||
</div>
|
||||
|
||||
@ -19,19 +18,19 @@
|
||||
|
||||
[Nova-admin](https://github.com/chansee97/nova-admin) is a clean and concise back-end management template based on Vue3, Vite5, Typescript, and Naive UI. It implements complete functionality in a simple way, while also considering code standards, readability, and avoiding excessive encapsulation to facilitate secondary development.
|
||||
|
||||
- [Nova-Admin preview](https://nova-admin.pages.dev/)
|
||||
- [Nova-Admin docs](https://nova-admin-docs.pages.dev/)
|
||||
- [Nova-Admin preview](https://nova-admin-site.netlify.app/)
|
||||
- [Nova-Admin docs](https://nova-admin-docs.netlify.app/)
|
||||
|
||||
## Features
|
||||
|
||||
- Developed based on the latest technology stack including Vue3, Vite6, TypeScript, NaiveUI, Unocss, etc.
|
||||
- Developed based on the latest technology stack including Vue3, Vite5, TypeScript, NaiveUI, Unocss, etc.
|
||||
- Based on [alova](https://alova.js.org/) encapsulation and configuration, providing unified response handling and multi-scenario capabilities.
|
||||
- Comprehensive front-end and back-end permission management solution.
|
||||
- Supports local static routes and dynamically generated routes from the back end, with easy route configuration.
|
||||
- Secondary encapsulation of commonly used components to meet basic work requirements.
|
||||
- Dark theme adaptation, maintaining the Naive style for interface aesthetics.
|
||||
- Only performs eslint validation during submission without excessive restrictions for simpler development.
|
||||
- Flexible and configurable interface layout based on [pro-naive-ui](https://github.com/Zheng-Changfu/pro-naive-ui)
|
||||
- Flexible and configurable interface style layout.
|
||||
- Multilanguage (i18n) support.
|
||||
|
||||
## Project preview
|
||||
@ -48,16 +47,13 @@
|
||||
- [Gitee](https://gitee.com/chansee97/nova-admin)
|
||||
- [Github](https://github.com/chansee97/nova-admin)
|
||||
|
||||
## Interface document
|
||||
## Related projects
|
||||
|
||||
This project uses ApiFox for interface mock, check the online documentation for more interface details
|
||||
[online aipfox docs](https://nova-admin.apifox.cn)
|
||||
- [Nova-admin-nest](https://github.com/chansee97/nove-admin-nest) (under development) Nova-Admin supporting background project based on TS, NestJs, typeorm
|
||||
|
||||
## Install and use
|
||||
|
||||
The local development environment is recommended to use pnpm 10.x, Node.js version 21.x.
|
||||
|
||||
It is recommended to directly download the compressed package from [Releases](https://github.com/chansee97/nova-admin/releases)
|
||||
The local development environment is recommended to use pnpm 8.x, Node.js must be version 20.x.
|
||||
|
||||
```bash
|
||||
# install dependencies
|
||||
@ -71,26 +67,20 @@ pnpm build
|
||||
|
||||
```
|
||||
|
||||
You can deploy **nova-admin** in a production environment using docker-compose.
|
||||
```bash
|
||||
# Build product
|
||||
docker compose -f docker-compose.product.yml up --build -d
|
||||
```
|
||||
> The nginx.conf provided is for reference only. You can adjust it according to your own needs.
|
||||
## Interface document
|
||||
|
||||
## Related projects
|
||||
|
||||
- [Nova-admin-nest](https://github.com/chansee97/nova-admin-nest) (under development) Nova-Admin supporting background project based on TS, NestJs, typeorm
|
||||
This project uses ApiFox for interface mock, check the online documentation for more interface details
|
||||
[online aipfox docs](https://apifox.com/apidoc/shared-2b1abeb5-6e78-425e-a4ff-d7277ae83bf0)
|
||||
|
||||
## Learn to communicate
|
||||
|
||||
Nova-Admin is a completely open-source and free project. It is still being optimized and iterated. It is designed to help developers more conveniently develop medium and large management systems. If you have any questions, please ask questions in the QQ exchange group.
|
||||
|
||||
| Q-Group | wechat-Group |
|
||||
| :--: |:--: |
|
||||
| <img src="https://cdn.jsdelivr.net/gh/chansee97/static/nova-admin/q-group.png" width=170> |<img src="https://cdn.jsdelivr.net/gh/chansee97/static/wechat.png" width=170>|
|
||||
| Q-Group | wechat-Group |wechat |
|
||||
| :--: |:--: |:--: |
|
||||
| <img src="https://cdn.jsdelivr.net/gh/chansee97/static/nova-admin/q-group.png" width=170> | <img src="https://cdn.jsdelivr.net/gh/chansee97/static/nova-admin/wx-group.png" width=170>|<img src="https://cdn.jsdelivr.net/gh/chansee97/static/wechat.png" width=170>|
|
||||
|
||||
> Please indicate the purpose of adding WeChat.
|
||||
> The WeChat group QR code is invalid, please add me as a friend.
|
||||
|
||||
## Contribution
|
||||
|
||||
|
||||
@ -5,8 +5,7 @@
|
||||
|
||||
<div align="center">
|
||||
<img src="https://img.shields.io/github/license/chansee97/nova-admin"/>
|
||||
<img src="https://badgen.net/github/stars/chansee97/nova-admin?icon=github"/>
|
||||
<img src="https://gitee.com/chansee97/nova-admin/badge/star.svg"/>
|
||||
<img src="https://img.shields.io/github/stars/chansee97/nova-admin"/>
|
||||
<img src="https://img.shields.io/github/forks/chansee97/nova-admin"/>
|
||||
</div>
|
||||
|
||||
@ -19,19 +18,19 @@
|
||||
|
||||
[Nova-admin](https://github.com/chansee97/nova-admin)是一个基于Vue3、Vite5、Typescript、Naive UI, 简洁干净后台管理模板,用简单的方式实现完整功能,并尽可能的考虑代码规范,易读易理解无过度封装,方便二次开发。
|
||||
|
||||
- [Nova-Admin 预览](https://nova-admin.pages.dev/)
|
||||
- [Nova-Admin 文档](https://nova-admin-docs.pages.dev/)
|
||||
- [Nova-Admin 预览](https://nova-admin-site.netlify.app/)
|
||||
- [Nova-Admin 文档](https://nova-admin-docs.netlify.app/)
|
||||
|
||||
## 特性
|
||||
|
||||
- 基于Vue3、Vite6、TypeScript、NaiveUI、Unocss等最新技术栈开发
|
||||
- 基于Vue3、Vite5、TypeScript、NaiveUI、Unocss等最新技术栈开发
|
||||
- 基于[alova](https://alova.js.org/)封装和配置,提供统一的响应处理和多场景能力
|
||||
- 完善的前后端权限管理方案
|
||||
- 支持本地静态路由和后台返回动态路由,路由简单易配置
|
||||
- 对日常使用频率较高的组件二次封装,满足基础工作需求
|
||||
- 黑暗主题适配, 界面样式保持Naive风格
|
||||
- 仅在提交时进行eslint校验,没有过多限制,开发更简便
|
||||
- 基于[pro-naive-ui](https://github.com/Zheng-Changfu/pro-naive-ui)的界面布局,灵活可配置
|
||||
- 界面样式布局灵活可配置
|
||||
- 多语言(i18n)支持
|
||||
|
||||
## 项目预览
|
||||
@ -48,16 +47,13 @@
|
||||
- [Gitee](https://gitee.com/chansee97/nova-admin)
|
||||
- [Github](https://github.com/chansee97/nova-admin)
|
||||
|
||||
## 接口文档
|
||||
## 相关项目
|
||||
|
||||
本项目使用ApiFox进行接口mock,查看在线文档以了解更多接口详情
|
||||
[在线apifox文档](https://nova-admin.apifox.cn)
|
||||
- [Nova-admin-nest](https://github.com/chansee97/nove-admin-nest) (开发中)基于TS, NestJs, typeorm的Nova-Admin配套后台项目
|
||||
|
||||
## 安装使用
|
||||
|
||||
本地开发环境建议使用 pnpm 10.x 、Node.js 21.x
|
||||
|
||||
推荐直接下载[Releases](https://github.com/chansee97/nova-admin/releases)压缩包
|
||||
本地开发环境建议使用 pnpm 8.x 、Node.js 必须 20.x
|
||||
|
||||
```bash
|
||||
# install dependencies
|
||||
@ -71,26 +67,20 @@ pnpm build
|
||||
|
||||
```
|
||||
|
||||
在生产环境也可以使用 docker-compose 部署 **nova-admin**
|
||||
```bash
|
||||
# Build product
|
||||
docker compose -f docker-compose.product.yml up --build -d
|
||||
```
|
||||
> 关于 nginx.conf 只供参考,你可以根据自己的需求进行调整。
|
||||
## 接口文档
|
||||
|
||||
## 相关项目
|
||||
|
||||
- [Nova-admin-nest](https://github.com/chansee97/nova-admin-nest) (开发中)基于TS, NestJs, typeorm的Nova-Admin配套后台项目
|
||||
本项目使用ApiFox进行接口mock,查看在线文档以了解更多接口详情
|
||||
[在线apifox文档](https://apifox.com/apidoc/shared-2b1abeb5-6e78-425e-a4ff-d7277ae83bf0)
|
||||
|
||||
## 学习交流
|
||||
|
||||
Nova-Admin 是完全开源免费的项目,目前仍然在优化迭代中,旨在帮助开发者更方便地进行中大型管理系统开发,有使用问题欢迎在交流群内提问。
|
||||
|
||||
| Q群 | 微信群 |
|
||||
| :--: |:--: |
|
||||
| <img src="https://cdn.jsdelivr.net/gh/chansee97/static/nova-admin/q-group.png" width=170> |<img src="https://cdn.jsdelivr.net/gh/chansee97/static/wechat.png" width=170>|
|
||||
| Q群 | 微信群 | 个人微信 |
|
||||
| :--: |:--: |:--: |
|
||||
| <img src="https://cdn.jsdelivr.net/gh/chansee97/static/nova-admin/q-group.png" width=170> | <img src="https://cdn.jsdelivr.net/gh/chansee97/static/nova-admin/wx-group.png" width=170>|<img src="https://cdn.jsdelivr.net/gh/chansee97/static/wechat.png" width=170>|
|
||||
|
||||
> 添加微信请注明来意
|
||||
> 微信群二维码失效请加我为好友
|
||||
|
||||
## 贡献
|
||||
|
||||
@ -111,7 +101,6 @@ Nova-Admin 是完全开源免费的项目,目前仍然在优化迭代中,旨
|
||||
<a href="https://github.com/chansee97/nova-admin/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=chansee97/nova-admin" alt="contributors" />
|
||||
</a>
|
||||
|
||||
## Star 历史
|
||||
|
||||
[](https://star-history.com/#chansee97/nova-admin&Date)
|
||||
|
||||
@ -1,226 +0,0 @@
|
||||
import type { ProxyOptions, UserConfig } from 'vite'
|
||||
import { mkdirSync, writeFileSync } from 'node:fs'
|
||||
import { dirname } from 'node:path'
|
||||
|
||||
/** 服务配置接口 */
|
||||
interface ServiceConfig {
|
||||
[key: string]: string
|
||||
}
|
||||
|
||||
/** 服务环境类型 */
|
||||
type ServiceEnvType = string
|
||||
|
||||
/** 完整的服务配置类型 */
|
||||
interface FullServiceConfig {
|
||||
[key: ServiceEnvType]: ServiceConfig
|
||||
}
|
||||
|
||||
/** 代理项接口 */
|
||||
interface ProxyItem {
|
||||
/** 代理路径 */
|
||||
path: string
|
||||
/** 原始地址 */
|
||||
rawPath: string
|
||||
}
|
||||
|
||||
/** 代理地址映射接口 */
|
||||
interface ProxyMapping {
|
||||
[serviceName: string]: ProxyItem
|
||||
}
|
||||
|
||||
/** 插件选项接口 */
|
||||
export interface ServiceProxyPluginOptions {
|
||||
/** 服务配置对象(必填) */
|
||||
serviceConfig: FullServiceConfig
|
||||
/** 代理路径前缀(可选,默认为 'proxy-') */
|
||||
proxyPrefix?: string
|
||||
/** 是否启用代理配置 */
|
||||
enableProxy?: boolean
|
||||
/** 环境变量名(可选,默认为 '__URL_MAP__') */
|
||||
envName?: string
|
||||
/** d.ts 类型文件生成路径(可选,如果传入路径则在该路径生成 d.ts 类型文件) */
|
||||
dts?: string
|
||||
}
|
||||
|
||||
export default function createServiceProxyPlugin(options: ServiceProxyPluginOptions) {
|
||||
const {
|
||||
serviceConfig,
|
||||
proxyPrefix = 'proxy-',
|
||||
enableProxy = true,
|
||||
envName = '__URL_MAP__',
|
||||
dts,
|
||||
} = options
|
||||
|
||||
return {
|
||||
name: 'vite-auto-proxy',
|
||||
config(config: UserConfig, { mode, command }: { mode: string, command: 'build' | 'serve' }) {
|
||||
// 只在开发环境(serve命令)时生成代理配置
|
||||
const isDev = command === 'serve'
|
||||
|
||||
// 在非开发环境也注入空的代理映射,避免运行时错误
|
||||
if (!config.define) {
|
||||
config.define = {}
|
||||
}
|
||||
|
||||
if (!enableProxy || !isDev) {
|
||||
const rawMapping: ProxyMapping = {}
|
||||
const envConfig = serviceConfig[mode]
|
||||
|
||||
if (envConfig) {
|
||||
Object.entries(envConfig).forEach(([serviceName, serviceUrl]) => {
|
||||
rawMapping[serviceName] = {
|
||||
path: serviceUrl,
|
||||
rawPath: serviceUrl,
|
||||
}
|
||||
})
|
||||
console.warn(`[auto-proxy] 已加载 ${Object.keys(envConfig).length} 个服务地址`)
|
||||
}
|
||||
else {
|
||||
console.warn(`[auto-proxy] 未找到环境 "${mode}" 的配置`)
|
||||
}
|
||||
|
||||
config.define[envName] = JSON.stringify(rawMapping)
|
||||
|
||||
// 生成 d.ts 类型文件(如果指定了路径)
|
||||
if (dts) {
|
||||
generateDtsFile(rawMapping, dts, envName)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
console.warn(`[auto-proxy] 已加载${mode}模式 ${Object.keys(serviceConfig[mode]).length} 个服务地址`)
|
||||
|
||||
const { proxyConfig, proxyMapping } = generateProxyFromServiceConfig(serviceConfig, mode, proxyPrefix)
|
||||
|
||||
Object.entries(proxyMapping).forEach(([serviceName, proxyItem]) => {
|
||||
console.warn(`[auto-proxy] 服务: ${serviceName} | 代理地址: ${proxyItem.path} | 实际地址: ${proxyItem.rawPath}`)
|
||||
})
|
||||
|
||||
if (proxyConfig && Object.keys(proxyConfig).length > 0) {
|
||||
// 确保 server 对象存在
|
||||
if (!config.server) {
|
||||
config.server = {}
|
||||
}
|
||||
|
||||
// 合并代理配置
|
||||
config.server.proxy = {
|
||||
...config.server.proxy,
|
||||
...proxyConfig,
|
||||
}
|
||||
config.define[envName] = JSON.stringify(proxyMapping)
|
||||
console.warn(`[auto-proxy] 代理映射已注入到 ${envName}`)
|
||||
|
||||
// 生成 d.ts 类型文件(如果指定了路径)
|
||||
if (dts) {
|
||||
generateDtsFile(proxyMapping, dts, envName)
|
||||
}
|
||||
}
|
||||
else {
|
||||
console.warn(`[auto-proxy] 未生成任何代理配置`)
|
||||
config.define[envName] = JSON.stringify({})
|
||||
|
||||
// 生成空的 d.ts 类型文件(如果指定了路径)
|
||||
if (dts) {
|
||||
generateDtsFile({}, dts, envName)
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function generateProxyFromServiceConfig(
|
||||
serviceConfig: FullServiceConfig,
|
||||
mode: ServiceEnvType,
|
||||
proxyPrefix: string,
|
||||
): { proxyConfig: Record<string, ProxyOptions>, proxyMapping: ProxyMapping } {
|
||||
try {
|
||||
// 获取当前环境的配置
|
||||
const envConfig = serviceConfig[mode]
|
||||
if (!envConfig) {
|
||||
console.warn(`[auto-proxy] 未找到环境 "${mode}" 的配置,使用 development 配置`)
|
||||
const defaultConfig = serviceConfig.development
|
||||
if (!defaultConfig) {
|
||||
console.error(`[auto-proxy] 也未找到 development 配置`)
|
||||
return { proxyConfig: {}, proxyMapping: {} }
|
||||
}
|
||||
return generateProxyFromConfig(defaultConfig, proxyPrefix)
|
||||
}
|
||||
|
||||
return generateProxyFromConfig(envConfig, proxyPrefix)
|
||||
}
|
||||
catch (error) {
|
||||
console.error(`[auto-proxy] 生成代理配置失败:`, (error as Error).message)
|
||||
return { proxyConfig: {}, proxyMapping: {} }
|
||||
}
|
||||
}
|
||||
|
||||
function generateProxyFromConfig(
|
||||
envConfig: ServiceConfig,
|
||||
proxyPrefix: string,
|
||||
): { proxyConfig: Record<string, ProxyOptions>, proxyMapping: ProxyMapping } {
|
||||
const proxyConfig: Record<string, ProxyOptions> = {}
|
||||
const proxyMapping: ProxyMapping = {}
|
||||
|
||||
Object.entries(envConfig).forEach(([serviceName, serviceUrl]) => {
|
||||
if (typeof serviceUrl === 'string' && serviceUrl.trim()) {
|
||||
const proxyPath = `/${proxyPrefix}${serviceName}`
|
||||
|
||||
const isWs = serviceUrl.startsWith('ws://') || serviceUrl.startsWith('wss://')
|
||||
// 生成代理配置
|
||||
proxyConfig[proxyPath] = {
|
||||
target: serviceUrl,
|
||||
changeOrigin: true,
|
||||
ws: isWs,
|
||||
rewrite: (path: string): string => path.replace(new RegExp(`^/${proxyPrefix}${serviceName}`), ''),
|
||||
}
|
||||
|
||||
// 生成代理映射
|
||||
proxyMapping[serviceName] = {
|
||||
path: proxyPath,
|
||||
rawPath: serviceUrl,
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return { proxyConfig, proxyMapping }
|
||||
}
|
||||
|
||||
function generateDtsFile(
|
||||
mapping: ProxyMapping,
|
||||
outputPath: string,
|
||||
envName: string,
|
||||
) {
|
||||
try {
|
||||
const serviceNames = Object.keys(mapping).map(name => `'${name}'`).join(' | ')
|
||||
const serviceNameType = serviceNames || 'never'
|
||||
|
||||
const dtsContent = `/* eslint-disable */
|
||||
/* prettier-ignore */
|
||||
// @ts-nocheck
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
// Generated by auto-proxy
|
||||
// biome-ignore lint: disable
|
||||
export {}
|
||||
|
||||
type serviceName = ${serviceNameType}
|
||||
|
||||
declare global {
|
||||
const ${envName}: {
|
||||
[K in serviceName]: {
|
||||
path: string
|
||||
rawPath: string
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const dir = dirname(outputPath)
|
||||
if (dir) {
|
||||
mkdirSync(dir, { recursive: true })
|
||||
}
|
||||
writeFileSync(outputPath, dtsContent, 'utf-8')
|
||||
}
|
||||
catch (error) {
|
||||
console.error(`[auto-proxy] 生成 d.ts 文件失败:`, (error as Error).message)
|
||||
}
|
||||
}
|
||||
@ -1,17 +1,16 @@
|
||||
import UnoCSS from '@unocss/vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import vueJsx from '@vitejs/plugin-vue-jsx'
|
||||
import Components from 'unplugin-vue-components/vite'
|
||||
import AutoImport from 'unplugin-auto-import/vite'
|
||||
import { NaiveUiResolver } from 'unplugin-vue-components/resolvers'
|
||||
import viteCompression from 'vite-plugin-compression'
|
||||
import Icons from 'unplugin-icons/vite'
|
||||
import { FileSystemIconLoader } from 'unplugin-icons/loaders'
|
||||
|
||||
// https://github.com/antfu/unplugin-icons
|
||||
import IconsResolver from 'unplugin-icons/resolver'
|
||||
import Icons from 'unplugin-icons/vite'
|
||||
import { NaiveUiResolver } from 'unplugin-vue-components/resolvers'
|
||||
import Components from 'unplugin-vue-components/vite'
|
||||
import viteCompression from 'vite-plugin-compression'
|
||||
import VueDevTools from 'vite-plugin-vue-devtools'
|
||||
import AutoProxy from './autoProxy'
|
||||
import { serviceConfig } from '../service.config'
|
||||
|
||||
/**
|
||||
* @description: 设置vite插件配置
|
||||
* @param {*} env - 环境变量配置
|
||||
@ -22,29 +21,13 @@ export function createVitePlugins(env: ImportMetaEnv) {
|
||||
// support vue
|
||||
vue(),
|
||||
vueJsx(),
|
||||
VueDevTools(),
|
||||
|
||||
// support unocss
|
||||
UnoCSS(),
|
||||
|
||||
// auto import api of lib
|
||||
AutoImport({
|
||||
imports: [
|
||||
'vue',
|
||||
'vue-router',
|
||||
'pinia',
|
||||
'@vueuse/core',
|
||||
'vue-i18n',
|
||||
{
|
||||
'naive-ui': [
|
||||
'useDialog',
|
||||
'useMessage',
|
||||
'useNotification',
|
||||
'useLoadingBar',
|
||||
'useModal',
|
||||
],
|
||||
},
|
||||
],
|
||||
imports: ['vue', 'vue-router', 'pinia', '@vueuse/core', 'vue-i18n'],
|
||||
include: [
|
||||
/\.[tj]sx?$/,
|
||||
/\.vue$/,
|
||||
@ -79,12 +62,6 @@ export function createVitePlugins(env: ImportMetaEnv) {
|
||||
),
|
||||
},
|
||||
}),
|
||||
|
||||
AutoProxy({
|
||||
enableProxy: env.VITE_HTTP_PROXY === 'Y',
|
||||
serviceConfig,
|
||||
dts: 'src/typings/auto-proxy.d.ts',
|
||||
}),
|
||||
]
|
||||
// use compression
|
||||
if (env.VITE_BUILD_COMPRESS === 'Y') {
|
||||
|
||||
32
build/proxy.ts
Normal file
32
build/proxy.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import type { ProxyOptions } from 'vite'
|
||||
import { mapEntries } from 'radash'
|
||||
|
||||
export function generateProxyPattern(envConfig: Record<string, string>) {
|
||||
return mapEntries(envConfig, (key, value) => {
|
||||
return [
|
||||
key,
|
||||
{
|
||||
value,
|
||||
proxy: `/proxy-${key}`,
|
||||
},
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @description: 生成vite代理字段
|
||||
* @param {*} envConfig - 环境变量配置
|
||||
*/
|
||||
export function createViteProxy(envConfig: Record<string, string>) {
|
||||
const proxyMap = generateProxyPattern(envConfig)
|
||||
return mapEntries(proxyMap, (key, value) => {
|
||||
return [
|
||||
value.proxy,
|
||||
{
|
||||
target: value.value,
|
||||
changeOrigin: true,
|
||||
rewrite: (path: string) => path.replace(new RegExp(`^${value.proxy}`), ''),
|
||||
},
|
||||
]
|
||||
}) as Record<string, string | ProxyOptions>
|
||||
}
|
||||
@ -1,8 +0,0 @@
|
||||
services:
|
||||
nova-admin:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./docker/dockerfile.product
|
||||
container_name: nova-admin
|
||||
ports:
|
||||
- 80:80
|
||||
@ -1,23 +0,0 @@
|
||||
FROM node:20-slim AS base
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN corepack enable
|
||||
COPY . /app
|
||||
WORKDIR /app
|
||||
|
||||
FROM base AS prod-deps
|
||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --prod --frozen-lockfile
|
||||
|
||||
FROM base AS builder
|
||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
|
||||
RUN pnpm run build
|
||||
|
||||
FROM nginx:1.23.1-alpine
|
||||
|
||||
WORKDIR /www
|
||||
|
||||
COPY --from=builder /app/dist/ .
|
||||
|
||||
COPY ./nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
@ -2,21 +2,4 @@
|
||||
import antfu from '@antfu/eslint-config'
|
||||
|
||||
// https://github.com/antfu/eslint-config
|
||||
export default antfu(
|
||||
{
|
||||
typescript: {
|
||||
overrides: {
|
||||
'perfectionist/sort-exports': 'off',
|
||||
'perfectionist/sort-imports': 'off',
|
||||
'ts/no-unused-expressions': ['error', { allowShortCircuit: true }],
|
||||
},
|
||||
},
|
||||
vue: {
|
||||
overrides: {
|
||||
'vue/no-unused-refs': 'off', // 暂时关闭,等待vue-lint的分支合并
|
||||
'vue/no-reserved-component-names': 'off',
|
||||
'vue/component-definition-name-casing': 'off',
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
export default antfu()
|
||||
|
||||
@ -9,6 +9,7 @@
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="appLoading"></div>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
|
||||
@ -3,18 +3,14 @@
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Confirm",
|
||||
"close": "Closure",
|
||||
"reload": "Refresh",
|
||||
"choose": "Choose",
|
||||
"navigate": "Navigate",
|
||||
"inputPlaceholder": "please enter",
|
||||
"selectPlaceholder": "please choose"
|
||||
"reload": "Refresh"
|
||||
},
|
||||
"app": {
|
||||
"loginOut": "Login out",
|
||||
"loginOutContent": "Confirm to log out of current account?",
|
||||
"loginOutTitle": "Sign out",
|
||||
"userCenter": "Personal center",
|
||||
"light": "Light",
|
||||
"lignt": "Light",
|
||||
"dark": "Dark",
|
||||
"system": "System",
|
||||
"backTop": "Back to top",
|
||||
@ -42,7 +38,6 @@
|
||||
"themeSetting": "Theme settings",
|
||||
"todos": "Todos",
|
||||
"toggleFullScreen": "Toggle full screen",
|
||||
"togglContentFullScreen": "Toggle content full screen",
|
||||
"topProgress": "Top progress",
|
||||
"transitionFadeBottom": "Bottom fade",
|
||||
"transitionFadeScale": "Scale fade",
|
||||
@ -59,12 +54,8 @@
|
||||
"backHome": "Back to the homepage",
|
||||
"getRouteError": "Failed to obtain route, please try again later.",
|
||||
"layoutSetting": "Layout settings",
|
||||
"verticalLayout": "Vertical layout",
|
||||
"horizontalLayout": "Horizontal layout",
|
||||
"twoColumnLayout": "Two column layout",
|
||||
"mixedTwoColumnLayout": "Mixed two column layout",
|
||||
"sidebarLayout": "Sidebar layout",
|
||||
"mixedSidebarLayout": "Mixed sidebar layout"
|
||||
"leftMenu": "Left menu",
|
||||
"topMenu": "Top menu"
|
||||
},
|
||||
"login": {
|
||||
"signInTitle": "Login",
|
||||
@ -92,33 +83,34 @@
|
||||
"route": {
|
||||
"appRoot": "Home",
|
||||
"cardList": "Card list",
|
||||
"draggableList": "Draggable list",
|
||||
"commonList": "Common list",
|
||||
"dashboard": "Dashboard",
|
||||
"demo": "Function example",
|
||||
"fetch": "Request example",
|
||||
"list": "List",
|
||||
"monitor": "Monitoring",
|
||||
"multi": "Multi-level menu",
|
||||
"multi2": "Multi-level menu subpage",
|
||||
"multi2Detail": "Details page of multi-level menu",
|
||||
"multi3": "multi-level menu",
|
||||
"multi4": "Multi-level menu 3-1",
|
||||
"test": "Multi-level menu",
|
||||
"test2": "Multi-level menu subpage",
|
||||
"test2Detail": "Details page of multi-level menu",
|
||||
"test3": "multi-level menu",
|
||||
"test4": "Multi-level menu 3-1",
|
||||
"workbench": "Workbench",
|
||||
"QRCode": "QR code",
|
||||
"about": "About",
|
||||
"clipboard": "Clipboard",
|
||||
"demo403": "403",
|
||||
"demo404": "404",
|
||||
"demo500": "500",
|
||||
"dictionarySetting": "Dictionary settings",
|
||||
"documents": "Document",
|
||||
"documentsVite": "Vite",
|
||||
"documentsVue": "Vue",
|
||||
"documentsVueuse": "VueUse (external link)",
|
||||
"documentsNova": "Nova docs",
|
||||
"documentsPublic": "Public page (external link)",
|
||||
"docments": "Document",
|
||||
"docmentsVite": "Vite",
|
||||
"docmentsVue": "Vue",
|
||||
"docmentsVueuse": "VueUse (external link)",
|
||||
"echarts": "Echarts",
|
||||
"editor": "Editor",
|
||||
"editorMd": "MarkDown editor",
|
||||
"editorRich": "Rich text editor",
|
||||
"error": "Exception page",
|
||||
"icons": "Icon",
|
||||
"justSuper": "Supervisible",
|
||||
"map": "Map",
|
||||
@ -127,9 +119,7 @@
|
||||
"permissionDemo": "Permissions example",
|
||||
"setting": "System settings",
|
||||
"userCenter": "Personal Center",
|
||||
"accountSetting": "User settings",
|
||||
"cascader": "Administrative region selection",
|
||||
"dict": "Dictionary example"
|
||||
"accountSetting": "User settings"
|
||||
},
|
||||
"http": {
|
||||
"400": "Syntax error in the request",
|
||||
@ -149,15 +139,7 @@
|
||||
"components": {
|
||||
"iconSelector": {
|
||||
"inputPlaceholder": "Select target icon",
|
||||
"searchPlaceholder": "Search icon",
|
||||
"clearIcon": "Clear icon",
|
||||
"selectorTitle": "Icon selection"
|
||||
},
|
||||
"copyText": {
|
||||
"message": "Copied successfully",
|
||||
"tooltip": "Copy",
|
||||
"unsupportedError": "Your browser does not support Clipboard API",
|
||||
"unpermittedError": "Crrently not permitted to use Clipboard API"
|
||||
"searchPlaceholder": "Search icon"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,24 +3,19 @@
|
||||
"confirm": "确认",
|
||||
"cancel": "取消",
|
||||
"reload": "刷新",
|
||||
"close": "关闭",
|
||||
"choose": "选择",
|
||||
"navigate": "切换",
|
||||
"inputPlaceholder": "请输入",
|
||||
"selectPlaceholder": "请选择"
|
||||
"close": "关闭"
|
||||
},
|
||||
"app": {
|
||||
"loginOut": "退出登录",
|
||||
"loginOutTitle": "退出登录",
|
||||
"loginOutContent": "确认退出当前账号?",
|
||||
"userCenter": "个人中心",
|
||||
"light": "浅色",
|
||||
"lignt": "浅色",
|
||||
"dark": "深色",
|
||||
"system": "跟随系统",
|
||||
"backTop": "返回顶部",
|
||||
"toggleSider": "切换侧边栏",
|
||||
"toggleFullScreen": "切换全屏",
|
||||
"togglContentFullScreen": "切换内容全屏",
|
||||
"notificationsTips": "消息通知",
|
||||
"notifications": "通知",
|
||||
"messages": "消息",
|
||||
@ -59,12 +54,8 @@
|
||||
"backHome": "回到首页",
|
||||
"getRouteError": "获取路由失败,请稍后再试",
|
||||
"layoutSetting": "布局设置",
|
||||
"verticalLayout": "竖向布局",
|
||||
"horizontalLayout": "横向布局",
|
||||
"twoColumnLayout": "双栏布局",
|
||||
"mixedTwoColumnLayout": "混合双栏布局",
|
||||
"sidebarLayout": "侧边栏布局",
|
||||
"mixedSidebarLayout": "双栏布局"
|
||||
"leftMenu": "左侧菜单",
|
||||
"topMenu": "顶部菜单"
|
||||
},
|
||||
"http": {
|
||||
"400": "请求出现语法错误",
|
||||
@ -83,16 +74,8 @@
|
||||
},
|
||||
"components": {
|
||||
"iconSelector": {
|
||||
"selectorTitle": "图标选择",
|
||||
"inputPlaceholder": "选择目标图标",
|
||||
"searchPlaceholder": "搜索图标",
|
||||
"clearIcon": "清除图标"
|
||||
},
|
||||
"copyText": {
|
||||
"tooltip": "复制",
|
||||
"message": "复制成功",
|
||||
"unsupportedError": "您的浏览器不支持剪贴板API",
|
||||
"unpermittedError": "目前不允许使用剪贴板API"
|
||||
"searchPlaceholder": "搜索图标"
|
||||
}
|
||||
},
|
||||
"login": {
|
||||
@ -123,15 +106,14 @@
|
||||
"dashboard": "仪表盘",
|
||||
"workbench": "工作台",
|
||||
"monitor": "监控页",
|
||||
"multi": "多级菜单演示",
|
||||
"multi2": "多级菜单子页",
|
||||
"multi2Detail": "多级菜单的详情页",
|
||||
"multi3": "多级菜单",
|
||||
"multi4": "多级菜单3-1",
|
||||
"test": "多级菜单演示",
|
||||
"test2": "多级菜单子页",
|
||||
"test2Detail": "多级菜单的详情页",
|
||||
"test3": "多级菜单",
|
||||
"test4": "多级菜单3-1",
|
||||
"list": "列表页",
|
||||
"commonList": "常用列表",
|
||||
"cardList": "卡片列表",
|
||||
"draggableList": "拖拽列表",
|
||||
"demo": "功能示例",
|
||||
"fetch": "请求示例",
|
||||
"echarts": "Echarts示例",
|
||||
@ -142,22 +124,22 @@
|
||||
"clipboard": "剪贴板",
|
||||
"icons": "图标",
|
||||
"QRCode": "二维码",
|
||||
"documents": "文档",
|
||||
"documentsVue": "Vue",
|
||||
"documentsVite": "Vite",
|
||||
"documentsVueuse": "VueUse(外链)",
|
||||
"documentsNova": "Nova 文档",
|
||||
"documentsPublic": "公共示例页(外链)",
|
||||
"docments": "文档",
|
||||
"docmentsVue": "Vue",
|
||||
"docmentsVite": "Vite",
|
||||
"docmentsVueuse": "VueUse(外链)",
|
||||
"permission": "权限",
|
||||
"permissionDemo": "权限示例",
|
||||
"justSuper": "super可见",
|
||||
"error": "异常页",
|
||||
"demo403": "403",
|
||||
"demo404": "404",
|
||||
"demo500": "500",
|
||||
"setting": "系统设置",
|
||||
"accountSetting": "用户设置",
|
||||
"dictionarySetting": "字典设置",
|
||||
"menuSetting": "菜单设置",
|
||||
"userCenter": "个人中心",
|
||||
"about": "关于",
|
||||
"cascader": "省市区联动",
|
||||
"dict": "字典示例"
|
||||
"about": "关于"
|
||||
}
|
||||
}
|
||||
|
||||
17
netlify.toml
Normal file
17
netlify.toml
Normal file
@ -0,0 +1,17 @@
|
||||
[build]
|
||||
publish = "dist"
|
||||
command = "vite build --mode prod"
|
||||
|
||||
[build.environment]
|
||||
NODE_VERSION = "20"
|
||||
|
||||
[[redirects]]
|
||||
from = "/*"
|
||||
to = "/index.html"
|
||||
status = 200
|
||||
|
||||
[[headers]]
|
||||
for = "/manifest.webmanifest"
|
||||
|
||||
[headers.values]
|
||||
Content-Type = "application/manifest+json"
|
||||
66
nginx.conf
66
nginx.conf
@ -1,66 +0,0 @@
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
|
||||
# 启用 gzip 压缩
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 10240;
|
||||
gzip_proxied expired no-cache no-store private auth;
|
||||
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml application/javascript;
|
||||
gzip_disable "MSIE [1-6]\.";
|
||||
|
||||
# 设定 MIME types
|
||||
include /etc/nginx/mime.types;
|
||||
|
||||
# 基本安全设定
|
||||
add_header X-Frame-Options "SAMEORIGIN";
|
||||
add_header X-XSS-Protection "1; mode=block";
|
||||
add_header X-Content-Type-Options "nosniff";
|
||||
|
||||
# 增加伺服器效能的配置
|
||||
client_max_body_size 100M;
|
||||
client_body_buffer_size 128k;
|
||||
proxy_connect_timeout 90;
|
||||
proxy_send_timeout 90;
|
||||
proxy_read_timeout 90;
|
||||
proxy_buffer_size 4k;
|
||||
proxy_buffers 4 32k;
|
||||
proxy_busy_buffers_size 64k;
|
||||
|
||||
location / {
|
||||
root /www;
|
||||
index index.html;
|
||||
try_files $uri $uri/ /index.html;
|
||||
|
||||
# 设定快取控制
|
||||
location ~* \.(jpg|jpeg|png|gif|ico|css|js)$ {
|
||||
expires 30d;
|
||||
add_header Cache-Control "public, no-transform";
|
||||
}
|
||||
|
||||
# 动态内容不快取
|
||||
location = /index.html {
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate";
|
||||
add_header Pragma "no-cache";
|
||||
expires -1;
|
||||
}
|
||||
|
||||
# 错误处理
|
||||
proxy_next_upstream error timeout invalid_header http_500 http_502 http_503 http_504;
|
||||
proxy_intercept_errors on;
|
||||
|
||||
# 基本的代理设定
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# 禁止访问隐藏文件
|
||||
location ~ /\. {
|
||||
deny all;
|
||||
access_log off;
|
||||
log_not_found off;
|
||||
}
|
||||
}
|
||||
84
package.json
84
package.json
@ -1,9 +1,9 @@
|
||||
{
|
||||
"name": "nova-admin",
|
||||
"type": "module",
|
||||
"version": "0.9.18",
|
||||
"version": "0.9.0",
|
||||
"private": true,
|
||||
"description": "a clean and concise back-end management template based on Vue3, Vite5, Typescript, and Naive UI.",
|
||||
"description": "",
|
||||
"author": {
|
||||
"name": "chansee97",
|
||||
"email": "chen.dev@foxmail.com",
|
||||
@ -38,58 +38,64 @@
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "vite --mode dev --port 9980",
|
||||
"dev:prod": "vite --mode production",
|
||||
"build": "vite build",
|
||||
"build:dev": "vite build --mode dev",
|
||||
"dev:test": "vite --mode test",
|
||||
"dev:prod": "vite --mode prod",
|
||||
"build": "vue-tsc --noEmit && vite build --mode prod",
|
||||
"build:dev": "vue-tsc --noEmit && vite build --mode dev",
|
||||
"build:test": "vue-tsc --noEmit && vite build --mode test",
|
||||
"preview": "vite preview --port 9981",
|
||||
"lint": "eslint . && vue-tsc --noEmit",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"lint:check": "npx @eslint/config-inspector",
|
||||
"sizecheck": "npx vite-bundle-visualizer"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vueuse/core": "^13.6.0",
|
||||
"alova": "^3.3.4",
|
||||
"@alova/scene-vue": "^1.4.6",
|
||||
"@tinymce/tinymce-vue": "^5.1.1",
|
||||
"@vueuse/core": "^10.9.0",
|
||||
"alova": "^2.19.0",
|
||||
"colord": "^2.9.3",
|
||||
"echarts": "^5.6.0",
|
||||
"md-editor-v3": "^5.6.1",
|
||||
"pinia": "^3.0.3",
|
||||
"pinia-plugin-persistedstate": "^4.4.1",
|
||||
"pro-naive-ui": "^2.4.3",
|
||||
"quill": "^2.0.3",
|
||||
"radash": "^12.1.1",
|
||||
"vue": "^3.5.18",
|
||||
"vue-draggable-plus": "^0.6.0",
|
||||
"vue-i18n": "^11.1.11",
|
||||
"vue-router": "^4.5.1"
|
||||
"echarts": "^5.5.0",
|
||||
"md-editor-v3": "^4.11.3",
|
||||
"performant-array-to-tree": "^1.11.0",
|
||||
"pinia": "^2.1.7",
|
||||
"pinia-plugin-persist": "^1.0.0",
|
||||
"qs": "^6.12.0",
|
||||
"radash": "^12.1.0",
|
||||
"vue": "^3.4.21",
|
||||
"vue-i18n": "^9.11.1",
|
||||
"vue-router": "^4.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@antfu/eslint-config": "^5.0.0",
|
||||
"@iconify-json/icon-park-outline": "^1.2.2",
|
||||
"@iconify/vue": "^5.0.0",
|
||||
"@types/node": "^24.1.0",
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"@vitejs/plugin-vue-jsx": "^5.0.1",
|
||||
"eslint": "^9.29.0",
|
||||
"lint-staged": "^16.1.2",
|
||||
"naive-ui": "^2.42.0",
|
||||
"sass": "^1.89.2",
|
||||
"simple-git-hooks": "^2.13.1",
|
||||
"typescript": "^5.8.3",
|
||||
"unocss": "^66.3.3",
|
||||
"unplugin-auto-import": "^19.3.0",
|
||||
"unplugin-icons": "^22.2.0",
|
||||
"unplugin-vue-components": "^28.8.0",
|
||||
"vite": "^7.0.6",
|
||||
"vite-bundle-visualizer": "^1.2.1",
|
||||
"@antfu/eslint-config": "^2.13.3",
|
||||
"@iconify-json/icon-park-outline": "^1.1.15",
|
||||
"@iconify/vue": "^4.1.1",
|
||||
"@types/node": "^20.12.7",
|
||||
"@types/qs": "^6.9.14",
|
||||
"@vitejs/plugin-vue": "^5.0.4",
|
||||
"@vitejs/plugin-vue-jsx": "^3.1.0",
|
||||
"eslint": "^9.0.0",
|
||||
"lint-staged": "^15.2.2",
|
||||
"naive-ui": "^2.38.1",
|
||||
"sass": "^1.75.0",
|
||||
"simple-git-hooks": "^2.11.1",
|
||||
"typescript": "^5.4.5",
|
||||
"unocss": "^0.59.1",
|
||||
"unplugin-auto-import": "^0.17.5",
|
||||
"unplugin-icons": "^0.18.5",
|
||||
"unplugin-vue-components": "^0.26.0",
|
||||
"vite": "^5.2.8",
|
||||
"vite-bundle-visualizer": "^1.1.0",
|
||||
"vite-plugin-compression": "^0.5.1",
|
||||
"vite-plugin-vue-devtools": "8.0.0",
|
||||
"vue-tsc": "^3.0.5"
|
||||
"vue-tsc": "^2.0.12"
|
||||
},
|
||||
"simple-git-hooks": {
|
||||
"pre-commit": "pnpm lint-staged"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*": "eslint --fix"
|
||||
},
|
||||
"volta": {
|
||||
"node": "20.12.2"
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +0,0 @@
|
||||
ignoredBuiltDependencies:
|
||||
- '@parcel/watcher'
|
||||
- esbuild
|
||||
- simple-git-hooks
|
||||
- vue-demi
|
||||
- unrs-resolver
|
||||
@ -1,9 +1,12 @@
|
||||
/** 不同请求服务的环境配置 */
|
||||
export const serviceConfig: Record<ServiceEnvType, Record<string, string>> = {
|
||||
dev: {
|
||||
url: 'http://localhost:3000',
|
||||
url: 'https://mock.apifox.com/m1/4071143-0-default',
|
||||
},
|
||||
production: {
|
||||
url: 'https://mock.apifox.cn/m1/4071143-0-default',
|
||||
test: {
|
||||
url: 'https://mock.apifox.com/m1/4071143-0-default',
|
||||
},
|
||||
prod: {
|
||||
url: 'https://mock.apifox.com/m1/4071143-0-default',
|
||||
},
|
||||
}
|
||||
|
||||
30
src/App.vue
30
src/App.vue
@ -1,18 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import AppMain from './AppMain.vue'
|
||||
import AppLoading from './components/common/AppLoading.vue'
|
||||
import { darkTheme } from 'naive-ui'
|
||||
import { useAppStore } from './store'
|
||||
import { naiveI18nOptions } from '@/utils'
|
||||
|
||||
// 使用 Suspense 处理异步组件加载
|
||||
const appStore = useAppStore()
|
||||
|
||||
const naiveLocale = computed(() => {
|
||||
return naiveI18nOptions[appStore.lang] ? naiveI18nOptions[appStore.lang] : naiveI18nOptions.enUS
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Suspense>
|
||||
<!-- 异步组件 -->
|
||||
<AppMain />
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<template #fallback>
|
||||
<AppLoading />
|
||||
</template>
|
||||
</Suspense>
|
||||
<n-config-provider
|
||||
class="wh-full" inline-theme-disabled :theme="appStore.colorMode === 'dark' ? darkTheme : null"
|
||||
:locale="naiveLocale.locale" :date-locale="naiveLocale.dateLocale" :theme-overrides="appStore.theme"
|
||||
>
|
||||
<naive-provider>
|
||||
<router-view />
|
||||
<Watermark :show-watermark="appStore.showWatermark" />
|
||||
</naive-provider>
|
||||
</n-config-provider>
|
||||
</template>
|
||||
|
||||
@ -1,57 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { App } from 'vue'
|
||||
import { installRouter } from '@/router'
|
||||
import { installPinia } from '@/store'
|
||||
import { naiveI18nOptions } from '@/utils'
|
||||
import { darkTheme } from 'naive-ui'
|
||||
import { useAppStore } from './store'
|
||||
|
||||
// 创建异步初始化 Promise - 这会让组件变成异步组件
|
||||
const initializationPromise = (async () => {
|
||||
// 获取当前应用实例
|
||||
const app = getCurrentInstance()?.appContext.app
|
||||
if (!app) {
|
||||
throw new Error('Failed to get app instance')
|
||||
}
|
||||
|
||||
// 注册模块 Pinia
|
||||
await installPinia(app)
|
||||
|
||||
// 注册模块 Vue-router
|
||||
await installRouter(app)
|
||||
|
||||
// 注册模块 指令/静态资源
|
||||
const modules = import.meta.glob<{ install: (app: App) => void }>('./modules/*.ts', {
|
||||
eager: true,
|
||||
})
|
||||
|
||||
Object.values(modules).forEach(module => app.use(module))
|
||||
|
||||
return true
|
||||
})()
|
||||
|
||||
// 等待初始化完成 - 这使得整个 setup 函数变成异步的
|
||||
await initializationPromise
|
||||
|
||||
const appStore = useAppStore()
|
||||
|
||||
const naiveLocale = computed(() => {
|
||||
return naiveI18nOptions[appStore.lang] ? naiveI18nOptions[appStore.lang] : naiveI18nOptions.enUS
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-config-provider
|
||||
class="wh-full"
|
||||
inline-theme-disabled
|
||||
:theme="appStore.colorMode === 'dark' ? darkTheme : null"
|
||||
:locale="naiveLocale.locale"
|
||||
:date-locale="naiveLocale.dateLocale"
|
||||
:theme-overrides="appStore.theme"
|
||||
>
|
||||
<naive-provider>
|
||||
<router-view />
|
||||
<Watermark :show-watermark="appStore.showWatermark" />
|
||||
</naive-provider>
|
||||
</n-config-provider>
|
||||
</template>
|
||||
1
src/assets/svg/error-403.svg
Normal file
1
src/assets/svg/error-403.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 71 KiB |
1
src/assets/svg/error-500.svg
Normal file
1
src/assets/svg/error-500.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 41 KiB |
@ -2,36 +2,34 @@
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<naive-provider>
|
||||
<div id="loading-container">
|
||||
<div class="boxes">
|
||||
<div class="box">
|
||||
<div />
|
||||
<div />
|
||||
<div />
|
||||
<div />
|
||||
</div>
|
||||
<div class="box">
|
||||
<div />
|
||||
<div />
|
||||
<div />
|
||||
<div />
|
||||
</div>
|
||||
<div class="box">
|
||||
<div />
|
||||
<div />
|
||||
<div />
|
||||
<div />
|
||||
</div>
|
||||
<div class="box">
|
||||
<div />
|
||||
<div />
|
||||
<div />
|
||||
<div />
|
||||
</div>
|
||||
<div id="loading-container">
|
||||
<div class="boxes">
|
||||
<div class="box">
|
||||
<div />
|
||||
<div />
|
||||
<div />
|
||||
<div />
|
||||
</div>
|
||||
<div class="box">
|
||||
<div />
|
||||
<div />
|
||||
<div />
|
||||
<div />
|
||||
</div>
|
||||
<div class="box">
|
||||
<div />
|
||||
<div />
|
||||
<div />
|
||||
<div />
|
||||
</div>
|
||||
<div class="box">
|
||||
<div />
|
||||
<div />
|
||||
<div />
|
||||
<div />
|
||||
</div>
|
||||
</div>
|
||||
</naive-provider>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { useAppStore } from '@/store'
|
||||
import IconAuto from '~icons/icon-park-outline/laptop-computer'
|
||||
import IconMoon from '~icons/icon-park-outline/moon'
|
||||
import IconSun from '~icons/icon-park-outline/sun-one'
|
||||
import { NFlex } from 'naive-ui'
|
||||
import { useAppStore } from '@/store'
|
||||
import IconSun from '~icons/icon-park-outline/sun-one'
|
||||
import IconMoon from '~icons/icon-park-outline/moon'
|
||||
import IconAuto from '~icons/icon-park-outline/laptop-computer'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@ -12,7 +12,7 @@ const appStore = useAppStore()
|
||||
const options = computed(() => {
|
||||
return [
|
||||
{
|
||||
label: t('app.light'),
|
||||
label: t('app.lignt'),
|
||||
value: 'light',
|
||||
icon: IconSun,
|
||||
},
|
||||
|
||||
36
src/components/common/ErrorTip.vue
Normal file
36
src/components/common/ErrorTip.vue
Normal file
@ -0,0 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
/** 异常类型 403 404 500 */
|
||||
type: '403' | '404' | '500'
|
||||
}>()
|
||||
const router = useRouter()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex-col-center h-full">
|
||||
<img
|
||||
v-if="type === '403'"
|
||||
src="@/assets/svg/error-403.svg"
|
||||
alt=""
|
||||
class="w-1/3"
|
||||
>
|
||||
<img
|
||||
v-if="type === '404'"
|
||||
src="@/assets/svg/error-404.svg"
|
||||
alt=""
|
||||
class="w-1/3"
|
||||
>
|
||||
<img
|
||||
v-if="type === '500'"
|
||||
src="@/assets/svg/error-500.svg"
|
||||
alt=""
|
||||
class="w-1/3"
|
||||
>
|
||||
<n-button
|
||||
type="primary"
|
||||
@click="router.push('/')"
|
||||
>
|
||||
{{ $t('app.backHome') }}
|
||||
</n-button>
|
||||
</div>
|
||||
</template>
|
||||
@ -3,14 +3,14 @@ interface Props {
|
||||
message: string
|
||||
}
|
||||
|
||||
const { message } = defineProps<Props>()
|
||||
const props = defineProps<Props>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-tooltip :show-arrow="false" trigger="hover">
|
||||
<template #trigger>
|
||||
<icon-park-outline-help class="op-50 cursor-help" />
|
||||
<icon-park-outline-help />
|
||||
</template>
|
||||
{{ message }}
|
||||
{{ props.message }}
|
||||
</n-tooltip>
|
||||
</template>
|
||||
|
||||
@ -1,188 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const {
|
||||
disabled = false,
|
||||
} = defineProps<Props>()
|
||||
|
||||
interface IconList {
|
||||
prefix: string
|
||||
icons: string[]
|
||||
title: string
|
||||
total: number
|
||||
categories?: Record<string, string[]>
|
||||
uncategorized?: string[]
|
||||
}
|
||||
const value = defineModel('value', { type: String })
|
||||
|
||||
// 包含的图标库系列名,更多:https://icon-sets.iconify.design/
|
||||
const nameList = ['icon-park-outline', 'carbon', 'ant-design']
|
||||
|
||||
// 获取单个图标库数据
|
||||
async function fetchIconList(name: string): Promise<IconList> {
|
||||
return await fetch(`https://api.iconify.design/collection?prefix=${name}`).then(res => res.json())
|
||||
}
|
||||
|
||||
// 获取所有图标库数据
|
||||
async function fetchIconAllList(nameList: string[]) {
|
||||
// 并行请求所有图标列表
|
||||
const targets = await Promise.all(nameList.map(fetchIconList))
|
||||
|
||||
// 处理每个返回的图标数据
|
||||
const iconList = targets.map((item) => {
|
||||
const icons = [
|
||||
...(item.categories ? Object.values(item.categories).flat() : []),
|
||||
...(item.uncategorized ? Object.values(item.uncategorized).flat() : []),
|
||||
]
|
||||
return { ...item, icons }
|
||||
})
|
||||
|
||||
// 处理本地图标
|
||||
const svgNames = Object.keys(import.meta.glob('@/assets/svg-icons/*.svg')).map(
|
||||
path => path.split('/').pop()?.replace('.svg', ''),
|
||||
).filter(Boolean) as string[] // 过滤掉 undefined 并断言为 string[]
|
||||
|
||||
// 在数组开头添加
|
||||
iconList.unshift({
|
||||
prefix: 'local',
|
||||
title: 'Local Icons',
|
||||
icons: svgNames,
|
||||
total: svgNames.length,
|
||||
uncategorized: svgNames,
|
||||
})
|
||||
|
||||
return iconList
|
||||
}
|
||||
|
||||
const iconList = shallowRef<IconList[]>([])
|
||||
|
||||
onMounted(async () => {
|
||||
iconList.value = await fetchIconAllList(nameList)
|
||||
})
|
||||
|
||||
// 当前tab
|
||||
const currentTab = shallowRef(0)
|
||||
// 当前tag
|
||||
const currentTag = shallowRef('')
|
||||
|
||||
// 搜索图标输入框值
|
||||
const searchValue = ref('')
|
||||
|
||||
// 当前页数
|
||||
const currentPage = shallowRef(1)
|
||||
|
||||
// 切换tab
|
||||
function handleChangeTab(index: number) {
|
||||
currentTab.value = index
|
||||
currentTag.value = ''
|
||||
currentPage.value = 1
|
||||
}
|
||||
|
||||
// 选择分类tag
|
||||
function handleSelectIconTag(icon: string) {
|
||||
currentTag.value = currentTag.value === icon ? '' : icon
|
||||
currentPage.value = 1
|
||||
}
|
||||
|
||||
// 包含当前分类或所有图标列表
|
||||
const icons = computed(() => {
|
||||
if (!iconList.value[currentTab.value])
|
||||
return []
|
||||
const hasTag = !!currentTag.value
|
||||
return hasTag
|
||||
? iconList.value[currentTab.value]?.categories?.[currentTag.value] || [] // 使用可选链
|
||||
: iconList.value[currentTab.value].icons || []
|
||||
})
|
||||
|
||||
// 符合搜索条件的图标列表
|
||||
const filteredIcons = computed(() => {
|
||||
return icons.value?.filter(i => i.includes(searchValue.value)) || []
|
||||
})
|
||||
|
||||
// 当前页显示的图标
|
||||
const visibleIcons = computed(() => {
|
||||
return filteredIcons.value.slice((currentPage.value - 1) * 200, currentPage.value * 200)
|
||||
})
|
||||
|
||||
const showModal = ref(false)
|
||||
|
||||
// 选择图标
|
||||
function handleSelectIcon(icon: string) {
|
||||
value.value = icon
|
||||
showModal.value = false
|
||||
}
|
||||
|
||||
// 清除图标
|
||||
function clearIcon() {
|
||||
value.value = ''
|
||||
showModal.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-input-group disabled>
|
||||
<n-button v-if="value" :disabled="disabled" type="primary">
|
||||
<template #icon>
|
||||
<nova-icon :icon="value" />
|
||||
</template>
|
||||
</n-button>
|
||||
<n-input :value="value" readonly :placeholder="$t('components.iconSelector.inputPlaceholder')" />
|
||||
<n-button type="primary" ghost :disabled="disabled" @click="showModal = true">
|
||||
{{ $t('common.choose') }}
|
||||
</n-button>
|
||||
</n-input-group>
|
||||
<n-modal
|
||||
v-model:show="showModal" preset="card" :title="$t('components.iconSelector.selectorTitle')" size="small" class="w-800px" :bordered="false"
|
||||
>
|
||||
<template #header-extra>
|
||||
<n-button type="warning" size="small" ghost @click="clearIcon">
|
||||
{{ $t('components.iconSelector.clearIcon') }}
|
||||
</n-button>
|
||||
</template>
|
||||
|
||||
<n-tabs :value="currentTab" type="line" animated placement="left" @update:value="handleChangeTab">
|
||||
<n-tab-pane v-for="(list, index) in iconList" :key="list.prefix" :name="index" :tab="list.title">
|
||||
<n-flex vertical>
|
||||
<n-flex size="small">
|
||||
<n-tag
|
||||
v-for="(_v, k) in list.categories" :key="k"
|
||||
:checked="currentTag === k" round checkable size="small"
|
||||
@update:checked="handleSelectIconTag(k)"
|
||||
>
|
||||
{{ k }}
|
||||
</n-tag>
|
||||
</n-flex>
|
||||
|
||||
<n-input
|
||||
v-model:value="searchValue" type="text" clearable
|
||||
:placeholder="$t('components.iconSelector.searchPlaceholder')"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<n-flex :size="2">
|
||||
<n-el
|
||||
v-for="(icon) in visibleIcons" :key="icon"
|
||||
class="hover:(text-[var(--primary-color)] ring-1) ring-[var(--primary-color)] p-1 rounded flex-center"
|
||||
:title="`${list.prefix}:${icon}`"
|
||||
@click="handleSelectIcon(`${list.prefix}:${icon}`)"
|
||||
>
|
||||
<nova-icon :icon="`${list.prefix}:${icon}`" :size="24" />
|
||||
</n-el>
|
||||
<n-empty v-if="visibleIcons.length === 0" class="w-full" />
|
||||
</n-flex>
|
||||
</div>
|
||||
|
||||
<n-flex justify="center">
|
||||
<n-pagination
|
||||
v-model:page="currentPage"
|
||||
:item-count="filteredIcons.length"
|
||||
:page-size="200"
|
||||
/>
|
||||
</n-flex>
|
||||
</n-flex>
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</n-modal>
|
||||
</template>
|
||||
35
src/components/common/IconSelect/icons.ts
Normal file
35
src/components/common/IconSelect/icons.ts
Normal file
@ -0,0 +1,35 @@
|
||||
export const icons: string[] = [
|
||||
'icon-park-outline:ad-product',
|
||||
'icon-park-outline:all-application',
|
||||
'icon-park-outline:hamburger-button',
|
||||
'icon-park-outline:setting',
|
||||
'icon-park-outline:add-one',
|
||||
'icon-park-outline:reduce-one',
|
||||
'icon-park-outline:close-one',
|
||||
'icon-park-outline:help',
|
||||
'icon-park-outline:info',
|
||||
'icon-park-outline:grid-four',
|
||||
'icon-park-outline:key-two',
|
||||
'icon-park-outline:write',
|
||||
'icon-park-outline:fire',
|
||||
'icon-park-outline:memory-card-one',
|
||||
'icon-park-outline:coupon',
|
||||
'icon-park-outline:ticket-one',
|
||||
'icon-park-outline:pay-code-two',
|
||||
'icon-park-outline:wallet-one',
|
||||
'icon-park-outline:gift',
|
||||
'icon-park-outline:mail',
|
||||
'icon-park-outline:log',
|
||||
'icon-park-outline:people',
|
||||
'icon-park-outline:alarm-clock',
|
||||
'ic:baseline-filter-1',
|
||||
'ic:baseline-filter-2',
|
||||
'ic:baseline-filter-3',
|
||||
'ic:baseline-filter-4',
|
||||
'ic:baseline-filter-5',
|
||||
'ic:baseline-filter-6',
|
||||
'ic:baseline-filter-7',
|
||||
'ic:baseline-filter-8',
|
||||
'ic:baseline-filter-9',
|
||||
'ic:baseline-filter-9-plus',
|
||||
]
|
||||
51
src/components/common/IconSelect/index.vue
Normal file
51
src/components/common/IconSelect/index.vue
Normal file
@ -0,0 +1,51 @@
|
||||
<script setup lang="ts">
|
||||
import { icons } from './icons'
|
||||
|
||||
interface Props {
|
||||
disabled?: boolean
|
||||
}
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
disabled: false,
|
||||
})
|
||||
|
||||
const value = defineModel('value', { type: String })
|
||||
const searchValue = ref('')
|
||||
const showPopover = ref(false)
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const iconList = computed(() => icons.filter(item => item.includes(searchValue.value)))
|
||||
|
||||
function handleSelectIcon(icon: string) {
|
||||
value.value = icon
|
||||
showPopover.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-popover v-model:show="showPopover" placement="bottom" trigger="click" :disabled="props.disabled">
|
||||
<template #trigger>
|
||||
<n-input :value="value" readonly :placeholder="t('components.iconSelector.inputPlaceholder')">
|
||||
<template #suffix>
|
||||
<nova-icon :icon="value" />
|
||||
</template>
|
||||
</n-input>
|
||||
</template>
|
||||
<template #header>
|
||||
<n-input v-model:value="searchValue" type="text" :placeholder="t('components.iconSelector.searchPlaceholder')" />
|
||||
</template>
|
||||
<div class="w-400px">
|
||||
<div v-if="iconList.length > 0" class="grid grid-cols-9 h-auto overflow-auto gap-1">
|
||||
<div
|
||||
v-for="(item, index) in iconList" :key="index" class="border border-gray-200 m-2px p-5px flex-center"
|
||||
@click="handleSelectIcon(item)"
|
||||
>
|
||||
<nova-icon :icon="item" :size="24" />
|
||||
</div>
|
||||
</div>
|
||||
<n-empty v-else class="w-full" />
|
||||
</div>
|
||||
</n-popover>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@ -11,36 +11,20 @@ interface iconPorps {
|
||||
/* 图标深度 */
|
||||
depth?: 1 | 2 | 3 | 4 | 5
|
||||
}
|
||||
const { size = 18, icon } = defineProps<iconPorps>()
|
||||
|
||||
const isLocal = computed(() => {
|
||||
return icon && icon.startsWith('local:')
|
||||
const props = withDefaults(defineProps<iconPorps>(), {
|
||||
size: 18,
|
||||
})
|
||||
|
||||
function getLocalIcon(icon: string) {
|
||||
const svgName = icon.replace('local:', '')
|
||||
const svg = import.meta.glob<string>('@/assets/svg-icons/*.svg', {
|
||||
query: '?raw',
|
||||
import: 'default',
|
||||
eager: true,
|
||||
})
|
||||
|
||||
return svg[`/src/assets/svg-icons/${svgName}.svg`]
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-icon
|
||||
v-if="icon"
|
||||
:size="size"
|
||||
:depth="depth"
|
||||
:color="color"
|
||||
v-if="props.icon"
|
||||
:size="props.size"
|
||||
:depth="props.depth"
|
||||
:color="props.color"
|
||||
>
|
||||
<template v-if="isLocal">
|
||||
<i v-html="getLocalIcon(icon)" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<Icon :icon="icon" />
|
||||
</template>
|
||||
<Icon :icon="props.icon" />
|
||||
</n-icon>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
||||
@ -1,15 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
count?: number
|
||||
}
|
||||
const {
|
||||
count = 0,
|
||||
} = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
change: [page: number, pageSize: number] // 具名元组语法
|
||||
}>()
|
||||
|
||||
const props = defineProps({
|
||||
count: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
})
|
||||
const emit = defineEmits(['change'])
|
||||
const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const displayOrder: Array<'pages' | 'size-picker' | 'quick-jumper'> = ['size-picker', 'pages']
|
||||
@ -21,11 +17,10 @@ function changePage() {
|
||||
|
||||
<template>
|
||||
<n-pagination
|
||||
v-if="count > 0"
|
||||
v-if="props.count > 0"
|
||||
v-model:page="page"
|
||||
v-model:page-size="pageSize"
|
||||
:page-sizes="[10, 20, 30, 50]"
|
||||
:item-count="count"
|
||||
:item-count="props.count"
|
||||
:display-order="displayOrder"
|
||||
show-size-picker
|
||||
@update-page="changePage"
|
||||
|
||||
@ -3,15 +3,16 @@ interface Props {
|
||||
showWatermark: boolean
|
||||
text?: string
|
||||
}
|
||||
const {
|
||||
text = 'Watermark',
|
||||
} = defineProps<Props>()
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
showWatermark: false,
|
||||
text: 'Watermark',
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-watermark
|
||||
v-if="showWatermark"
|
||||
:content="text"
|
||||
v-if="props.showWatermark"
|
||||
:content="props.text"
|
||||
cross
|
||||
fullscreen
|
||||
:font-size="16"
|
||||
|
||||
@ -1,23 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
const props = defineProps<{
|
||||
maxLength?: string
|
||||
}
|
||||
const { maxLength } = defineProps<Props>()
|
||||
const modelValue = defineModel<string>('value')
|
||||
}>()
|
||||
const modelValue = defineModel<string>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="modelValue" class="inline-flex items-center gap-0.5em">
|
||||
<n-ellipsis :style="{ 'max-width': maxLength || '12em' }">
|
||||
<n-ellipsis :style="{ 'max-width': props.maxLength || '12em' }">
|
||||
{{ modelValue }}
|
||||
</n-ellipsis>
|
||||
<n-tooltip trigger="hover">
|
||||
<template #trigger>
|
||||
<span v-copy="modelValue" class="cursor-pointer">
|
||||
<icon-park-outline-copy />
|
||||
</span>
|
||||
</template>
|
||||
{{ $t('components.copyText.tooltip') }}
|
||||
</n-tooltip>
|
||||
<span v-copy="modelValue" class="cursor-pointer">
|
||||
<icon-park-outline-copy />
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -1,16 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
import type { ToolbarNames } from 'md-editor-v3'
|
||||
|
||||
import { useAppStore } from '@/store'
|
||||
|
||||
import { MdEditor } from 'md-editor-v3'
|
||||
|
||||
// https://imzbf.github.io/md-editor-v3/zh-CN/docs
|
||||
import 'md-editor-v3/lib/style.css'
|
||||
import { useAppStore } from '@/store'
|
||||
|
||||
const model = defineModel<string>()
|
||||
const props = defineProps<{
|
||||
modelValue: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const appStore = useAppStore()
|
||||
|
||||
const data = useVModel(props, 'modelValue', emit)
|
||||
|
||||
const theme = computed(() => {
|
||||
return appStore.colorMode ? 'dark' : 'light'
|
||||
})
|
||||
|
||||
const toolbarsExclude: ToolbarNames[] = [
|
||||
'mermaid',
|
||||
'katex',
|
||||
@ -22,7 +32,7 @@ const toolbarsExclude: ToolbarNames[] = [
|
||||
|
||||
<template>
|
||||
<MdEditor
|
||||
v-model="model" :theme="appStore.colorMode" :toolbars-exclude="toolbarsExclude"
|
||||
v-model="data" :theme="theme" :toolbars-exclude="toolbarsExclude"
|
||||
/>
|
||||
</template>
|
||||
|
||||
|
||||
@ -1,107 +1,77 @@
|
||||
<script setup lang="ts">
|
||||
import Quill from 'quill'
|
||||
import { useTemplateRef } from 'vue'
|
||||
import 'quill/dist/quill.snow.css'
|
||||
import Editor from '@tinymce/tinymce-vue'
|
||||
|
||||
defineOptions({
|
||||
name: 'RichTextEditor',
|
||||
})
|
||||
const props = defineProps<{
|
||||
modelValue: string
|
||||
}>()
|
||||
|
||||
const { disabled } = defineProps<Props>()
|
||||
interface Props {
|
||||
disabled?: boolean
|
||||
}
|
||||
const model = defineModel<string>()
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
let editorInst = null
|
||||
const data = useVModel(props, 'modelValue', emit)
|
||||
|
||||
const editorModel = ref<string>()
|
||||
function imagesUploadHandler(blobInfo: any, _progress: number) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', blobInfo.blob())
|
||||
fetch('www.example.com/upload', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
.then((response) => {
|
||||
if (!response.ok)
|
||||
throw new Error('上传失败')
|
||||
|
||||
onMounted(() => {
|
||||
initEditor()
|
||||
})
|
||||
|
||||
const editorRef = useTemplateRef<HTMLElement>('editorRef')
|
||||
function initEditor() {
|
||||
const options = {
|
||||
modules: {
|
||||
toolbar: [
|
||||
{ header: [1, 2, 3, 4, 5, 6, false] }, // 标题
|
||||
'bold', // 加粗
|
||||
'italic', // 斜体
|
||||
'strike', // 删除线
|
||||
{ size: ['small', false, 'large', 'huge'] }, // 字体大小
|
||||
{ font: [] }, // 字体种类
|
||||
{ color: [] }, // 字体颜色、
|
||||
{ background: [] }, // 字体背景颜色
|
||||
'link', // 插入链接
|
||||
'image', // 插入图片
|
||||
'blockquote', // 引用
|
||||
'link', // 超链接
|
||||
'image', // 插入图片
|
||||
'video', // 插入视频
|
||||
{ list: 'bullet' }, // 无序列表
|
||||
{ list: 'ordered' }, // 有序列表
|
||||
{ script: 'sub' }, // 下标
|
||||
{ script: 'super' }, // 上标
|
||||
{ align: [] }, // 对齐方式
|
||||
'formula', // 公式
|
||||
'clean', // remove formatting button
|
||||
],
|
||||
},
|
||||
|
||||
placeholder: 'Insert text here ...',
|
||||
theme: 'snow',
|
||||
}
|
||||
const quill = new Quill(editorRef.value!, options)
|
||||
|
||||
quill.on('text-change', (_delta, _oldDelta, _source) => {
|
||||
editorModel.value = quill.getSemanticHTML()
|
||||
// 处理上传成功后的响应数据
|
||||
resolve('上传成功')
|
||||
})
|
||||
.catch((error) => {
|
||||
// 处理上传失败的情况
|
||||
reject(error)
|
||||
})
|
||||
})
|
||||
|
||||
if (disabled)
|
||||
quill.enable(false)
|
||||
|
||||
editorInst = quill
|
||||
|
||||
if (model.value)
|
||||
setContents(model.value)
|
||||
}
|
||||
const initConfig = {
|
||||
language: 'zh_CN', // 语言类型
|
||||
min_height: 700,
|
||||
content_css: 'dark',
|
||||
placeholder: '请输入内容', // textarea中的提示信息
|
||||
branding: false,
|
||||
font_formats:
|
||||
'微软雅黑=Microsoft YaHei,Helvetica Neue,PingFang SC,sans-serif;苹果苹方=PingFang SC,Microsoft YaHei,sans-serif;宋体=simsun,serif;仿宋体=FangSong,serif;黑体=SimHei,sans-serif;Arial=arial,helvetica,sans-serif;Arial Black=arial black,avant garde;Book Antiqua=book antiqua,palatino;', // 字体样式
|
||||
plugins:
|
||||
'print preview searchreplace autolink directionality visualblocks visualchars fullscreen code codesample table charmap hr pagebreak nonbreaking anchor insertdatetime advlist lists wordcount textpattern autosave emoticons', // 插件配置 axupimgs indent2em
|
||||
toolbar: [
|
||||
'fullscreen undo redo restoredraft | cut copy paste pastetext | forecolor backcolor bold italic underline strikethrough anchor | alignleft aligncenter alignright alignjustify outdent indent | bullist numlist | blockquote subscript superscript removeformat ',
|
||||
'styleselect formatselect fontselect fontsizeselect | table emoticons charmap hr pagebreak insertdatetime selectall visualblocks | code preview | indent2em lineheight formatpainter',
|
||||
],
|
||||
paste_data_images: true, // 图片是否可粘贴
|
||||
// 此处为图片上传处理函数
|
||||
images_upload_handler: imagesUploadHandler,
|
||||
|
||||
function setContents(html: string) {
|
||||
editorInst!.setContents(editorInst!.clipboard.convert({ html }))
|
||||
}
|
||||
|
||||
watch(
|
||||
() => model.value,
|
||||
(newValue, _oldValue) => {
|
||||
if (newValue && newValue !== editorModel.value) {
|
||||
setContents(newValue)
|
||||
}
|
||||
else if (!newValue) {
|
||||
setContents('')
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
watch(editorModel, (newValue, oldValue) => {
|
||||
if (newValue && newValue !== oldValue)
|
||||
model.value = newValue
|
||||
|
||||
else if (!newValue)
|
||||
editorInst!.setContents([])
|
||||
})
|
||||
|
||||
watch(
|
||||
() => disabled,
|
||||
(newValue, _oldValue) => {
|
||||
editorInst!.enable(!newValue)
|
||||
},
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => editorInst = null)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="editorRef" />
|
||||
<div class="tinymce-boxz">
|
||||
<Editor
|
||||
v-model="data"
|
||||
api-key="no-api"
|
||||
:init="initConfig"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.tinymce-boxz > textarea {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 隐藏apikey没有绑定这个域名的提示 */
|
||||
.tox-notifications-container .tox-notification--warning {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.tox.tox-tinymce {
|
||||
max-width: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1,27 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { CascaderOption } from 'naive-ui'
|
||||
|
||||
defineOptions({
|
||||
name: 'PcaCascader',
|
||||
})
|
||||
|
||||
// 获取原始数据,数据来源仓库 https://github.com/modood/Administrative-divisions-of-China
|
||||
const pcaCode = shallowRef<CascaderOption[]>()
|
||||
async function fetchPcaCode() {
|
||||
return await fetch('https://cdn.jsdelivr.net/gh/modood/Administrative-divisions-of-China/dist/pca-code.json').then(res => res.json())
|
||||
}
|
||||
onMounted(async () => {
|
||||
pcaCode.value = await fetchPcaCode()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-cascader
|
||||
:options="pcaCode"
|
||||
value-field="code"
|
||||
label-field="name"
|
||||
check-strategy="all"
|
||||
filterable
|
||||
clearable
|
||||
/>
|
||||
</template>
|
||||
@ -1,3 +1,7 @@
|
||||
// export const genderLabels: Record<NonNullable<CommonList.GenderType>, string> = {
|
||||
// 0: '女',
|
||||
// 1: '男',
|
||||
// }
|
||||
/** Gender */
|
||||
export enum Gender {
|
||||
male,
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import type { App, Directive } from 'vue'
|
||||
import { $t } from '@/utils'
|
||||
|
||||
interface CopyHTMLElement extends HTMLElement {
|
||||
_copyText: string
|
||||
@ -11,12 +10,12 @@ export function install(app: App) {
|
||||
|
||||
function clipboardEnable() {
|
||||
if (!isSupported.value) {
|
||||
window.$message.error($t('components.copyText.unsupportedError'))
|
||||
window.$message.error('Your browser does not support Clipboard API')
|
||||
return false
|
||||
}
|
||||
|
||||
if (permissionWrite.value === 'denied') {
|
||||
window.$message.error($t('components.copyText.unpermittedError'))
|
||||
if (permissionWrite.value !== 'granted') {
|
||||
window.$message.error('Currently not permitted to use Clipboard API')
|
||||
return false
|
||||
}
|
||||
return true
|
||||
@ -26,7 +25,7 @@ export function install(app: App) {
|
||||
if (!clipboardEnable())
|
||||
return
|
||||
copy(this._copyText)
|
||||
window.$message.success($t('components.copyText.message'))
|
||||
window.$message.success('复制成功')
|
||||
}
|
||||
|
||||
function updataClipboard(el: CopyHTMLElement, text: string) {
|
||||
|
||||
@ -4,7 +4,7 @@ import { usePermission } from '@/hooks'
|
||||
export function install(app: App) {
|
||||
const { hasPermission } = usePermission()
|
||||
|
||||
function updatapermission(el: HTMLElement, permission: Entity.RoleType | Entity.RoleType[]) {
|
||||
function updatapermission(el: HTMLElement, permission: Auth.RoleType | Auth.RoleType[]) {
|
||||
if (!permission)
|
||||
throw new Error('v-permissson Directive with no explicit role attached')
|
||||
|
||||
@ -12,7 +12,7 @@ export function install(app: App) {
|
||||
el.parentElement?.removeChild(el)
|
||||
}
|
||||
|
||||
const permissionDirective: Directive<HTMLElement, Entity.RoleType | Entity.RoleType[]> = {
|
||||
const permissionDirective: Directive<HTMLElement, Auth.RoleType | Auth.RoleType[]> = {
|
||||
mounted(el, binding) {
|
||||
updatapermission(el, binding.value)
|
||||
},
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
export * from './useBoolean'
|
||||
export * from './useLoading'
|
||||
export * from './useEcharts'
|
||||
export * from './usePermission'
|
||||
|
||||
@ -1,3 +1,6 @@
|
||||
import * as echarts from 'echarts/core'
|
||||
import { BarChart, LineChart, PieChart, RadarChart } from 'echarts/charts'
|
||||
|
||||
// 系列类型的定义后缀都为 SeriesOption
|
||||
import type {
|
||||
BarSeriesOption,
|
||||
@ -5,6 +8,7 @@ import type {
|
||||
PieSeriesOption,
|
||||
RadarSeriesOption,
|
||||
} from 'echarts/charts'
|
||||
|
||||
// 组件类型的定义后缀都为 ComponentOption
|
||||
import type {
|
||||
DatasetComponentOption,
|
||||
@ -14,9 +18,6 @@ import type {
|
||||
ToolboxComponentOption,
|
||||
TooltipComponentOption,
|
||||
} from 'echarts/components'
|
||||
import { useAppStore } from '@/store'
|
||||
import { BarChart, LineChart, PieChart, RadarChart } from 'echarts/charts'
|
||||
|
||||
import {
|
||||
DatasetComponent, // 数据集组件
|
||||
GridComponent,
|
||||
@ -26,11 +27,10 @@ import {
|
||||
TooltipComponent,
|
||||
TransformComponent, // 内置数据转换器组件 (filter, sort)
|
||||
} from 'echarts/components'
|
||||
import * as echarts from 'echarts/core'
|
||||
|
||||
import { LabelLayout, UniversalTransition } from 'echarts/features'
|
||||
import { CanvasRenderer } from 'echarts/renderers'
|
||||
import { useTemplateRef } from 'vue'
|
||||
import { useAppStore } from '@/store'
|
||||
|
||||
// 通过 ComposeOption 来组合出一个只有必须组件和图表的 Option 类型
|
||||
export type ECOption = echarts.ComposeOption<
|
||||
@ -66,61 +66,73 @@ echarts.use([
|
||||
|
||||
/**
|
||||
* Echarts hooks函数
|
||||
* @param options - 图表配置
|
||||
* @description 按需引入图表组件,没注册的组件需要先引入
|
||||
*/
|
||||
export function useEcharts(ref: string, chartOptions: Ref<ECOption>) {
|
||||
const el = useTemplateRef<HTMLLIElement>(ref)
|
||||
|
||||
export function useEcharts(options: Ref<ECOption>) {
|
||||
const appStore = useAppStore()
|
||||
|
||||
const domRef = ref<HTMLElement>()
|
||||
|
||||
let chart: echarts.ECharts | null = null
|
||||
|
||||
const { width, height } = useElementSize(el)
|
||||
const initialSize = { width: 0, height: 0 }
|
||||
const { width, height } = useElementSize(domRef, initialSize)
|
||||
|
||||
const isRendered = () => Boolean(el && chart)
|
||||
function canRender() {
|
||||
return initialSize.width > 0 && initialSize.height > 0
|
||||
}
|
||||
|
||||
function isRendered() {
|
||||
return Boolean(domRef.value && chart)
|
||||
}
|
||||
async function render() {
|
||||
// 宽或高不存在时不渲染
|
||||
if (!width || !height)
|
||||
return
|
||||
|
||||
const chartTheme = appStore.colorMode ? 'dark' : 'light'
|
||||
await nextTick()
|
||||
if (el) {
|
||||
chart = echarts.init(el.value, chartTheme)
|
||||
update(chartOptions.value)
|
||||
if (domRef.value) {
|
||||
chart = echarts.init(domRef.value, chartTheme)
|
||||
update(options.value)
|
||||
}
|
||||
}
|
||||
|
||||
async function update(updateOptions: ECOption) {
|
||||
if (isRendered()) {
|
||||
chart!.setOption({ backgroundColor: 'transparent', ...updateOptions })
|
||||
}
|
||||
function update(updateOptions: ECOption) {
|
||||
if (isRendered())
|
||||
chart!.setOption({ ...updateOptions, backgroundColor: 'transparent' })
|
||||
}
|
||||
|
||||
function resize() {
|
||||
chart?.resize()
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
chart?.dispose()
|
||||
chart = null
|
||||
}
|
||||
|
||||
watch([width, height], async ([newWidth, newHeight]) => {
|
||||
if (isRendered() && newWidth && newHeight)
|
||||
chart?.resize()
|
||||
const sizeWatch = watch([width, height], async ([newWidth, newHeight]) => {
|
||||
initialSize.width = newWidth
|
||||
initialSize.height = newHeight
|
||||
if (newWidth === 0 && newHeight === 0) {
|
||||
// 节点被删除 将chart置为空
|
||||
chart = null
|
||||
}
|
||||
if (!canRender())
|
||||
return
|
||||
if (isRendered())
|
||||
resize()
|
||||
else await render()
|
||||
})
|
||||
|
||||
watch(chartOptions, (newValue) => {
|
||||
const OptionWatch = watch(options, (newValue) => {
|
||||
update(newValue)
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
render()
|
||||
})
|
||||
onUnmounted(() => {
|
||||
sizeWatch()
|
||||
OptionWatch()
|
||||
destroy()
|
||||
})
|
||||
|
||||
return {
|
||||
destroy,
|
||||
update,
|
||||
domRef,
|
||||
}
|
||||
}
|
||||
|
||||
15
src/hooks/useLoading.ts
Normal file
15
src/hooks/useLoading.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { useBoolean } from './useBoolean'
|
||||
|
||||
export function useLoading(initValue = false) {
|
||||
const {
|
||||
bool: loading,
|
||||
setTrue: startLoading,
|
||||
setFalse: endLoading,
|
||||
} = useBoolean(initValue)
|
||||
|
||||
return {
|
||||
loading,
|
||||
startLoading,
|
||||
endLoading,
|
||||
}
|
||||
}
|
||||
@ -1,12 +1,12 @@
|
||||
import { useAuthStore } from '@/store'
|
||||
import { isArray, isString } from 'radash'
|
||||
import { useAuthStore } from '@/store'
|
||||
|
||||
/** 权限判断 */
|
||||
export function usePermission() {
|
||||
const authStore = useAuthStore()
|
||||
|
||||
function hasPermission(
|
||||
permission?: Entity.RoleType | Entity.RoleType[],
|
||||
permission: Auth.RoleType | Auth.RoleType[] | undefined,
|
||||
) {
|
||||
if (!permission)
|
||||
return true
|
||||
@ -15,16 +15,13 @@ export function usePermission() {
|
||||
return false
|
||||
const { role } = authStore.userInfo
|
||||
|
||||
// 角色为super可直接通过
|
||||
let has = role.includes('super')
|
||||
let has = role === 'super'
|
||||
if (!has) {
|
||||
if (isArray(permission))
|
||||
// 角色为数组, 判断是否有交集
|
||||
has = permission.some(i => role.includes(i))
|
||||
has = permission.includes(role)
|
||||
|
||||
if (isString(permission))
|
||||
// 角色为字符串, 判断是否包含
|
||||
has = role.includes(permission)
|
||||
has = permission === role
|
||||
}
|
||||
return has
|
||||
}
|
||||
|
||||
@ -1,65 +0,0 @@
|
||||
import type { NScrollbar } from 'naive-ui'
|
||||
import { ref, type Ref, watchEffect } from 'vue'
|
||||
import { throttle } from 'radash'
|
||||
|
||||
export function useTabScroll(currentTabPath: Ref<string>) {
|
||||
const scrollbar = ref<InstanceType<typeof NScrollbar>>()
|
||||
const safeArea = ref(150)
|
||||
|
||||
const handleTabSwitch = (distance: number) => {
|
||||
scrollbar.value?.scrollTo({
|
||||
left: distance,
|
||||
behavior: 'smooth',
|
||||
})
|
||||
}
|
||||
|
||||
const scrollToCurrentTab = () => {
|
||||
nextTick(() => {
|
||||
const currentTabElement = document.querySelector(`[data-tab-path="${currentTabPath.value}"]`) as HTMLElement
|
||||
const tabBarScrollWrapper = document.querySelector('.tab-bar-scroller-wrapper .n-scrollbar-container')
|
||||
const tabBarScrollContent = document.querySelector('.tab-bar-scroller-content')
|
||||
|
||||
if (currentTabElement && tabBarScrollContent && tabBarScrollWrapper) {
|
||||
const tabLeft = currentTabElement.offsetLeft
|
||||
const tabBarLeft = tabBarScrollWrapper.scrollLeft
|
||||
const wrapperWidth = tabBarScrollWrapper.getBoundingClientRect().width
|
||||
const tabWidth = currentTabElement.getBoundingClientRect().width
|
||||
const containerPR = Number.parseFloat(window.getComputedStyle(tabBarScrollContent).paddingRight)
|
||||
|
||||
if (tabLeft + tabWidth + safeArea.value + containerPR > wrapperWidth + tabBarLeft) {
|
||||
handleTabSwitch(tabLeft + tabWidth + containerPR - wrapperWidth + safeArea.value)
|
||||
}
|
||||
else if (tabLeft - safeArea.value < tabBarLeft) {
|
||||
handleTabSwitch(tabLeft - safeArea.value)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleScroll = throttle({ interval: 120 }, (step: number) => {
|
||||
scrollbar.value?.scrollBy({
|
||||
left: step * 400,
|
||||
behavior: 'smooth',
|
||||
})
|
||||
})
|
||||
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
e.preventDefault()
|
||||
if (Math.abs(e.deltaY) > Math.abs(e.deltaX)) {
|
||||
handleScroll(e.deltaY > 0 ? 1 : -1)
|
||||
}
|
||||
}
|
||||
|
||||
watchEffect(() => {
|
||||
if (currentTabPath.value) {
|
||||
scrollToCurrentTab()
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
scrollbar,
|
||||
onWheel,
|
||||
safeArea,
|
||||
handleTabSwitch,
|
||||
}
|
||||
}
|
||||
@ -1,35 +0,0 @@
|
||||
import type { NDataTable } from 'naive-ui'
|
||||
import { useDraggable } from 'vue-draggable-plus'
|
||||
|
||||
export function useTableDrag<T = unknown>(params: {
|
||||
tableRef: Ref<InstanceType<typeof NDataTable> | undefined>
|
||||
data: Ref<T[]>
|
||||
onRowDrag: (rows: T[]) => void
|
||||
}) {
|
||||
const tableEl = computed(() => params.tableRef?.value?.$el as HTMLElement)
|
||||
const tableBodyRef = ref<HTMLElement | undefined>(undefined)
|
||||
|
||||
const { start } = useDraggable(tableBodyRef, params.data, {
|
||||
immediate: false,
|
||||
animation: 150,
|
||||
handle: '.drag-handle',
|
||||
onEnd: (event) => {
|
||||
const { oldIndex, newIndex } = event
|
||||
const start = Math.min(oldIndex!, newIndex!)
|
||||
const end = Math.max(oldIndex!, newIndex!) - start + 1
|
||||
const changedRows = [...params.data.value].splice(start, end)
|
||||
params.onRowDrag(unref([...changedRows]))
|
||||
},
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
while (!tableBodyRef.value) {
|
||||
tableBodyRef.value = tableEl.value?.querySelector('tbody') || undefined
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
}
|
||||
})
|
||||
|
||||
watchOnce(() => tableBodyRef.value, (el) => {
|
||||
el && start()
|
||||
})
|
||||
}
|
||||
@ -1,26 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { useAppStore, useRouteStore } from '@/store'
|
||||
|
||||
const appStore = useAppStore()
|
||||
const routeStore = useRouteStore()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-el
|
||||
class="h-full"
|
||||
:class="[
|
||||
appStore.layoutMode === 'full-content' ? 'p-0' : 'p-16px',
|
||||
]"
|
||||
style="background-color: var(--action-color);"
|
||||
>
|
||||
<router-view
|
||||
v-slot="{ Component, route }"
|
||||
>
|
||||
<transition :name="appStore.transitionAnimation" mode="out-in">
|
||||
<keep-alive :include="routeStore.cacheRoutes">
|
||||
<component :is="Component" v-if="appStore.loadFlag" :key="route.fullPath" />
|
||||
</keep-alive>
|
||||
</transition>
|
||||
</router-view>
|
||||
</n-el>
|
||||
</template>
|
||||
@ -1,120 +1,47 @@
|
||||
<script setup lang="ts">
|
||||
import type { ProLayoutMode } from 'pro-naive-ui'
|
||||
import type { LayoutMode } from '@/store/app'
|
||||
|
||||
const value = defineModel<ProLayoutMode>('value', { required: true })
|
||||
const value = defineModel<LayoutMode>('value', { required: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="selector-wapper gap-4">
|
||||
<n-tooltip placement="top" trigger="hover">
|
||||
<div class="flex-center gap-4">
|
||||
<n-tooltip placement="bottom" trigger="hover">
|
||||
<template #trigger>
|
||||
<n-el
|
||||
:class="{
|
||||
'outline outline-2': value === 'vertical',
|
||||
'outline outline-2': value === 'leftMenu',
|
||||
}"
|
||||
class="grid grid-cols-[20%_1fr] outline-[var(--primary-color)] hover:(outline outline-2) cursor-pointer"
|
||||
@click="value = 'vertical'"
|
||||
class="grid grid-cols-[20%_1fr] grid-rows-[20%_1fr] outline-[var(--primary-color)] hover:(outline outline-2)"
|
||||
@click="value = 'leftMenu'"
|
||||
>
|
||||
<div class="bg-[var(--primary-color)]" />
|
||||
<div class="bg-[var(--divider-color)]" />
|
||||
</n-el>
|
||||
</template>
|
||||
<span> {{ $t('app.verticalLayout') }} </span>
|
||||
</n-tooltip>
|
||||
|
||||
<n-tooltip placement="top" trigger="hover">
|
||||
<template #trigger>
|
||||
<n-el
|
||||
:class="{
|
||||
'outline outline-2': value === 'horizontal',
|
||||
}"
|
||||
class="grid grid-rows-[30%_1fr] outline-[var(--primary-color)] hover:(outline outline-2) cursor-pointer"
|
||||
@click="value = 'horizontal'"
|
||||
>
|
||||
<div class="bg-[var(--primary-color)]" />
|
||||
<div class="bg-[var(--divider-color)]" />
|
||||
</n-el>
|
||||
</template>
|
||||
<span> {{ $t('app.horizontalLayout') }} </span>
|
||||
</n-tooltip>
|
||||
|
||||
<n-tooltip placement="top" trigger="hover">
|
||||
<template #trigger>
|
||||
<n-el
|
||||
:class="{
|
||||
'outline outline-2': value === 'two-column',
|
||||
}"
|
||||
class="grid grid-cols-[10%_15%_1fr] outline-[var(--primary-color)] hover:(outline outline-2) cursor-pointer"
|
||||
@click="value = 'two-column'"
|
||||
>
|
||||
<div class="bg-[var(--primary-color)]" />
|
||||
<div class="bg-[var(--primary-color)] row-span-2" />
|
||||
<div class="bg-[var(--primary-color-suppl)]" />
|
||||
<div class="bg-[var(--divider-color)]" />
|
||||
</n-el>
|
||||
</template>
|
||||
<span> {{ $t('app.twoColumnLayout') }} </span>
|
||||
<span> {{ $t('app.leftMenu') }} </span>
|
||||
</n-tooltip>
|
||||
|
||||
<n-tooltip placement="bottom" trigger="hover">
|
||||
<template #trigger>
|
||||
<n-el
|
||||
:class="{
|
||||
'outline outline-2': value === 'mixed-two-column',
|
||||
'outline outline-2': value === 'topMenu',
|
||||
}"
|
||||
class="grid grid-cols-[10%_15%_1fr] grid-rows-[20%_1fr] outline-[var(--primary-color)] hover:(outline outline-2) cursor-pointer"
|
||||
@click="value = 'mixed-two-column'"
|
||||
class="grid grid-rows-[30%_1fr] outline-[var(--primary-color)] hover:(outline outline-2)"
|
||||
@click="value = 'topMenu'"
|
||||
>
|
||||
<div class="bg-[var(--primary-color-suppl)] row-span-2" />
|
||||
<div class="bg-[var(--primary-color-suppl)] row-span-2" />
|
||||
<div class="bg-[var(--primary-color)]" />
|
||||
<div class="bg-[var(--divider-color)]" />
|
||||
</n-el>
|
||||
</template>
|
||||
<span> {{ $t('app.mixedTwoColumnLayout') }} </span>
|
||||
</n-tooltip>
|
||||
|
||||
<n-tooltip placement="bottom" trigger="hover">
|
||||
<template #trigger>
|
||||
<n-el
|
||||
:class="{
|
||||
'outline outline-2': value === 'sidebar',
|
||||
}"
|
||||
class="grid grid-cols-[20%_1fr] grid-rows-[20%_1fr] outline-[var(--primary-color)] hover:(outline outline-2) cursor-pointer"
|
||||
@click="value = 'sidebar'"
|
||||
>
|
||||
<div class="bg-[var(--divider-color)] col-span-2" />
|
||||
<div class="bg-[var(--primary-color)]" />
|
||||
<div class="bg-[var(--divider-color)]" />
|
||||
</n-el>
|
||||
</template>
|
||||
<span> {{ $t('app.sidebarLayout') }} </span>
|
||||
</n-tooltip>
|
||||
|
||||
<n-tooltip placement="bottom" trigger="hover">
|
||||
<template #trigger>
|
||||
<n-el
|
||||
:class="{
|
||||
'outline outline-2': value === 'mixed-sidebar',
|
||||
}"
|
||||
class="grid grid-cols-[20%_1fr] grid-rows-[20%_1fr] outline-[var(--primary-color)] hover:(outline outline-2) cursor-pointer"
|
||||
@click="value = 'mixed-sidebar'"
|
||||
>
|
||||
<div class="bg-[var(--primary-color)] col-span-2" />
|
||||
<div class="bg-[var(--primary-color-suppl)]" />
|
||||
<div class="bg-[var(--divider-color)]" />
|
||||
</n-el>
|
||||
</template>
|
||||
<span> {{ $t('app.mixedSidebarLayout') }} </span>
|
||||
<span> {{ $t('app.topMenu') }} </span>
|
||||
</n-tooltip>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.selector-wapper{
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
<style lang="scss">
|
||||
.grid{
|
||||
height: 60px;
|
||||
width: 86px;
|
||||
|
||||
@ -1,38 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import Search from '../header/Search.vue'
|
||||
import Notices from '../header/Notices.vue'
|
||||
import UserCenter from '../header/UserCenter.vue'
|
||||
import Setting from './Setting.vue'
|
||||
|
||||
const showDrawer = defineModel<boolean>('show', { default: false })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-drawer
|
||||
v-model:show="showDrawer"
|
||||
:width="280"
|
||||
placement="right"
|
||||
:mask-closable="true"
|
||||
:close-on-esc="true"
|
||||
>
|
||||
<n-drawer-content :native-scrollbar="false" :body-content-style="{ padding: '0' }">
|
||||
<template #header>
|
||||
<div class="flex items-center">
|
||||
<UserCenter />
|
||||
<div class="ml-auto" />
|
||||
<Search />
|
||||
<Notices />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<slot />
|
||||
|
||||
<template #footer>
|
||||
<DarkModeSwitch />
|
||||
<LangsSwitch />
|
||||
<div class="ml-auto" />
|
||||
<Setting />
|
||||
</template>
|
||||
</n-drawer-content>
|
||||
</n-drawer>
|
||||
</template>
|
||||
@ -1,8 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
list?: Entity.Message[]
|
||||
list?: Message.List[]
|
||||
}
|
||||
const { list } = defineProps<Props>()
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
interface Emits {
|
||||
@ -13,7 +13,7 @@ interface Emits {
|
||||
<template>
|
||||
<n-scrollbar style="height: 400px">
|
||||
<n-list hoverable clickable>
|
||||
<n-list-item v-for="(item) in list" :key="item.id" @click="emit('read', item.id)">
|
||||
<n-list-item v-for="(item) in props.list" :key="item.id" @click="emit('read', item.id)">
|
||||
<n-thing content-indented :class="{ 'opacity-30': item.isRead }">
|
||||
<template #header>
|
||||
<n-ellipsis :line-clamp="1">
|
||||
|
||||
@ -1,18 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { useAppStore } from '@/store'
|
||||
|
||||
const appStore = useAppStore()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-tooltip placement="bottom" trigger="hover">
|
||||
<template #trigger>
|
||||
<CommonWrapper @click="appStore.showSetting = !appStore.showSetting">
|
||||
<div>
|
||||
<icon-park-outline-setting-two />
|
||||
</div>
|
||||
</CommonWrapper>
|
||||
</template>
|
||||
<span>{{ $t('app.setting') }}</span>
|
||||
</n-tooltip>
|
||||
</template>
|
||||
@ -1,143 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { useAppStore } from '@/store'
|
||||
import LayoutSelector from './LayoutSelector.vue'
|
||||
|
||||
const appStore = useAppStore()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const transitionSelectorOptions = computed(() => {
|
||||
return [
|
||||
{
|
||||
label: t('app.transitionNull'),
|
||||
value: '',
|
||||
},
|
||||
{
|
||||
label: t('app.transitionFadeSlide'),
|
||||
value: 'fade-slide',
|
||||
},
|
||||
{
|
||||
label: t('app.transitionFadeBottom'),
|
||||
value: 'fade-bottom',
|
||||
},
|
||||
{
|
||||
label: t('app.transitionFadeScale'),
|
||||
value: 'fade-scale',
|
||||
},
|
||||
{
|
||||
label: t('app.transitionZoomFade'),
|
||||
value: 'zoom-fade',
|
||||
},
|
||||
{
|
||||
label: t('app.transitionZoomOut'),
|
||||
value: 'zoom-out',
|
||||
},
|
||||
{
|
||||
label: t('app.transitionSoft'),
|
||||
value: 'fade',
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
const palette = [
|
||||
'#ffb8b8',
|
||||
'#d03050',
|
||||
'#F0A020',
|
||||
'#fff200',
|
||||
'#ffda79',
|
||||
'#18A058',
|
||||
'#006266',
|
||||
'#22a6b3',
|
||||
'#18dcff',
|
||||
'#2080F0',
|
||||
'#c56cf0',
|
||||
'#be2edd',
|
||||
'#706fd3',
|
||||
'#4834d4',
|
||||
'#130f40',
|
||||
'#4b4b4b',
|
||||
]
|
||||
|
||||
function resetSetting() {
|
||||
window.$dialog.warning({
|
||||
title: t('app.resetSettingTitle'),
|
||||
content: t('app.resetSettingContent'),
|
||||
positiveText: t('common.confirm'),
|
||||
negativeText: t('common.cancel'),
|
||||
onPositiveClick: () => {
|
||||
appStore.resetAlltheme()
|
||||
window.$message.success(t('app.resetSettingMeaasge'))
|
||||
},
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-drawer v-model:show="appStore.showSetting" :width="360">
|
||||
<n-drawer-content :title="t('app.systemSetting')" closable>
|
||||
<n-space vertical>
|
||||
<n-divider>{{ $t('app.layoutSetting') }}</n-divider>
|
||||
<LayoutSelector v-model:value="appStore.layoutMode" />
|
||||
<n-divider>{{ $t('app.themeSetting') }}</n-divider>
|
||||
<n-space justify="space-between">
|
||||
{{ $t('app.colorWeak') }}
|
||||
<n-switch :value="appStore.colorWeak" @update:value="appStore.toggleColorWeak" />
|
||||
</n-space>
|
||||
<n-space justify="space-between">
|
||||
{{ $t('app.blackAndWhite') }}
|
||||
<n-switch :value="appStore.grayMode" @update:value="appStore.toggleGrayMode" />
|
||||
</n-space>
|
||||
<n-space align="center" justify="space-between">
|
||||
{{ $t('app.themeColor') }}
|
||||
<n-color-picker
|
||||
v-model:value="appStore.primaryColor" class="w-10em" :swatches="palette"
|
||||
@update:value="appStore.setPrimaryColor"
|
||||
/>
|
||||
</n-space>
|
||||
<n-space align="center" justify="space-between">
|
||||
{{ $t('app.pageTransition') }}
|
||||
<n-select
|
||||
v-model:value="appStore.transitionAnimation" class="w-10em"
|
||||
:options="transitionSelectorOptions" @update:value="appStore.reloadPage"
|
||||
/>
|
||||
</n-space>
|
||||
|
||||
<n-divider>{{ $t('app.interfaceDisplay') }}</n-divider>
|
||||
<n-space justify="space-between">
|
||||
{{ $t('app.logoDisplay') }}
|
||||
<n-switch v-model:value="appStore.showLogo" />
|
||||
</n-space>
|
||||
<n-space justify="space-between">
|
||||
{{ $t('app.topProgress') }}
|
||||
<n-switch v-model:value="appStore.showProgress" />
|
||||
</n-space>
|
||||
<n-space justify="space-between">
|
||||
{{ $t('app.multitab') }}
|
||||
<n-switch v-model:value="appStore.showTabs" />
|
||||
</n-space>
|
||||
<n-space justify="space-between">
|
||||
{{ $t('app.bottomCopyright') }}
|
||||
<n-switch v-model:value="appStore.showFooter" />
|
||||
</n-space>
|
||||
<n-space justify="space-between">
|
||||
{{ $t('app.breadcrumb') }}
|
||||
<n-switch v-model:value="appStore.showBreadcrumb" />
|
||||
</n-space>
|
||||
<n-space justify="space-between">
|
||||
{{ $t('app.BreadcrumbIcon') }}
|
||||
<n-switch v-model:value="appStore.showBreadcrumbIcon" />
|
||||
</n-space>
|
||||
<n-space justify="space-between">
|
||||
{{ $t('app.watermake') }}
|
||||
<n-switch v-model:value="appStore.showWatermark" />
|
||||
</n-space>
|
||||
</n-space>
|
||||
|
||||
<template #footer>
|
||||
<n-button type="error" @click="resetSetting">
|
||||
{{ $t('app.reset') }}
|
||||
</n-button>
|
||||
</template>
|
||||
</n-drawer-content>
|
||||
</n-drawer>
|
||||
</template>
|
||||
@ -2,26 +2,18 @@
|
||||
import { useAppStore } from '@/store'
|
||||
|
||||
const appStore = useAppStore()
|
||||
|
||||
useMagicKeys({
|
||||
passive: false,
|
||||
onEventFired(e) {
|
||||
if (e.key === 'F11' && e.type === 'keydown') {
|
||||
e.preventDefault()
|
||||
appStore.toggleFullScreen()
|
||||
}
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-tooltip placement="bottom" trigger="hover">
|
||||
<template #trigger>
|
||||
<CommonWrapper @click="appStore.toggleFullScreen">
|
||||
<icon-park-outline-off-screen v-if="appStore.fullScreen" />
|
||||
<icon-park-outline-full-screen v-else />
|
||||
<CommonWrapper @click="appStore.toggleFullScreen()">
|
||||
<icon-park-outline-off-screen-two v-if="appStore.fullScreen" />
|
||||
<icon-park-outline-full-screen-two v-else />
|
||||
</CommonWrapper>
|
||||
</template>
|
||||
<span>{{ $t('app.toggleFullScreen') }}</span>
|
||||
</n-tooltip>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
import { group } from 'radash'
|
||||
import NoticeList from '../common/NoticeList.vue'
|
||||
|
||||
const MassageData = ref<Entity.Message[]>([
|
||||
const MassageData = ref<Message.List[]>([
|
||||
{
|
||||
id: 0,
|
||||
type: 0,
|
||||
|
||||
@ -1,219 +1,59 @@
|
||||
<script setup lang="ts">
|
||||
import { useBoolean } from '@/hooks'
|
||||
import { useAppStore, useRouteStore } from '@/store'
|
||||
import { NFlex, NTag, NText } from 'naive-ui'
|
||||
import { useRouteStore } from '@/store'
|
||||
import { renderIcon } from '@/utils'
|
||||
|
||||
const appStore = useAppStore()
|
||||
const routeStore = useRouteStore()
|
||||
|
||||
// 搜索值
|
||||
const searchValue = ref('')
|
||||
|
||||
// 选中索引
|
||||
const selectedIndex = ref<number>(0)
|
||||
|
||||
const { bool: showModal, setTrue: openModal, setFalse: closeModal, toggle: toggleModal } = useBoolean(false)
|
||||
|
||||
// 鼠标和键盘操作切换锁,防止鼠标和键盘操作冲突
|
||||
const { bool: keyboardFlag, setTrue: setKeyboardTrue, setFalse: setKeyboardFalse } = useBoolean(false)
|
||||
|
||||
const { ctrl_k, arrowup, arrowdown, enter/* keys you want to monitor */ } = useMagicKeys({
|
||||
passive: false,
|
||||
onEventFired(e) {
|
||||
if (e.ctrlKey && e.key === 'k' && e.type === 'keydown')
|
||||
e.preventDefault()
|
||||
},
|
||||
})
|
||||
|
||||
// 监听全局热键
|
||||
watchEffect(() => {
|
||||
if (ctrl_k.value)
|
||||
toggleModal()
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// 计算符合条件的菜单选项
|
||||
const options = computed(() => {
|
||||
if (!searchValue.value)
|
||||
return []
|
||||
|
||||
return routeStore.rowRoutes.filter((item) => {
|
||||
const conditions = [
|
||||
t(`route.${String(item.name)}`, item.title || item.name)?.includes(searchValue.value),
|
||||
t(`route.${String(item.name)}`, item['meta.title'] || item.name)?.includes(searchValue.value),
|
||||
item.path?.includes(searchValue.value),
|
||||
]
|
||||
return conditions.some(condition => !item.hide && condition)
|
||||
return conditions.some(condition => condition)
|
||||
}).map((item) => {
|
||||
return {
|
||||
label: t(`route.${String(item.name)}`, item.title || item.name),
|
||||
label: t(`route.${String(item.name)}`, item['meta.title'] || item.name),
|
||||
value: item.path,
|
||||
icon: item.icon,
|
||||
icon: item['meta.icon'],
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
function renderLabel(option: any) {
|
||||
return h(NFlex, {}, {
|
||||
default: () => [
|
||||
h(NTag, { size: 'small', type: 'primary', bordered: false }, { icon: renderIcon(option.icon), default: () => option.label }),
|
||||
h(NText, { depth: 3 }, { default: () => option.value }),
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// 关闭回调
|
||||
function handleClose() {
|
||||
searchValue.value = ''
|
||||
selectedIndex.value = 0
|
||||
closeModal()
|
||||
}
|
||||
|
||||
// 输入框改变,索引重置
|
||||
function handleInputChange() {
|
||||
selectedIndex.value = 0
|
||||
}
|
||||
|
||||
// 选择菜单选项
|
||||
function handleSelect(value: string) {
|
||||
handleClose()
|
||||
router.push(value)
|
||||
nextTick(() => {
|
||||
searchValue.value = ''
|
||||
})
|
||||
}
|
||||
|
||||
watchEffect(() => {
|
||||
// 没有打开弹窗或没有搜索结果时,不操作
|
||||
if (!showModal.value || !options.value.length)
|
||||
return
|
||||
|
||||
// 设置键盘操作锁,设置后不会被动触发mouseover
|
||||
setKeyboardTrue()
|
||||
if (arrowup.value)
|
||||
handleArrowup()
|
||||
|
||||
if (arrowdown.value)
|
||||
handleArrowdown()
|
||||
|
||||
if (enter.value)
|
||||
handleEnter()
|
||||
})
|
||||
|
||||
const scrollbarRef = ref()
|
||||
|
||||
// 上箭头操作
|
||||
function handleArrowup() {
|
||||
if (selectedIndex.value === 0)
|
||||
selectedIndex.value = options.value.length - 1
|
||||
|
||||
else
|
||||
selectedIndex.value--
|
||||
|
||||
handleScroll(selectedIndex.value)
|
||||
}
|
||||
|
||||
// 下箭头操作
|
||||
function handleArrowdown() {
|
||||
if (selectedIndex.value === options.value.length - 1)
|
||||
selectedIndex.value = 0
|
||||
|
||||
else
|
||||
selectedIndex.value++
|
||||
|
||||
handleScroll(selectedIndex.value)
|
||||
}
|
||||
|
||||
function handleScroll(currentIndex: number) {
|
||||
// 保持6个选项在可视区域内,6个后开始滚动
|
||||
const keepIndex = 5
|
||||
// 单个元素的高度,包括了元素的gap和容器的padding
|
||||
const elHeight = 70
|
||||
const distance = currentIndex * elHeight > keepIndex * elHeight ? currentIndex * elHeight - keepIndex * elHeight : 0
|
||||
scrollbarRef.value?.scrollTo({
|
||||
top: distance,
|
||||
})
|
||||
}
|
||||
// 回车键操作
|
||||
function handleEnter() {
|
||||
const target = options.value[selectedIndex.value]
|
||||
if (target)
|
||||
handleSelect(target.value)
|
||||
}
|
||||
|
||||
// 鼠标移入操作
|
||||
function handleMouseEnter(index: number) {
|
||||
if (keyboardFlag.value)
|
||||
return
|
||||
|
||||
selectedIndex.value = index
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CommonWrapper @click="openModal">
|
||||
<icon-park-outline-search />
|
||||
<n-tag v-if="!appStore.isMobile" round size="small" class="font-mono cursor-pointer">
|
||||
CtrlK
|
||||
</n-tag>
|
||||
</CommonWrapper>
|
||||
<n-modal
|
||||
v-model:show="showModal"
|
||||
class="w-560px fixed top-60px inset-x-0 max-w-full"
|
||||
size="small"
|
||||
preset="card"
|
||||
:segmented="{
|
||||
content: true,
|
||||
footer: true,
|
||||
}"
|
||||
:closable="false"
|
||||
@after-leave="handleClose"
|
||||
<n-auto-complete
|
||||
v-model:value="searchValue" class="w-20em m-r-1em" :input-props="{
|
||||
autocomplete: 'disabled',
|
||||
}" :options="options" :render-label="renderLabel" :placeholder="$t('app.searchPlaceholder')" clearable @select="handleSelect"
|
||||
>
|
||||
<template #header>
|
||||
<n-input v-model:value="searchValue" :placeholder="$t('app.searchPlaceholder')" clearable size="large" @input="handleInputChange">
|
||||
<template #prefix>
|
||||
<n-icon>
|
||||
<icon-park-outline-search />
|
||||
</n-icon>
|
||||
</template>
|
||||
</n-input>
|
||||
<template #prefix>
|
||||
<n-icon>
|
||||
<icon-park-outline-search />
|
||||
</n-icon>
|
||||
</template>
|
||||
<n-scrollbar ref="scrollbarRef" class="h-450px">
|
||||
<ul
|
||||
v-if="options.length"
|
||||
class="flex flex-col gap-8px p-8px p-r-3"
|
||||
>
|
||||
<n-el
|
||||
v-for="(option, index) in options"
|
||||
:key="option.value" tag="li" role="option"
|
||||
class="cursor-pointer shadow h-62px"
|
||||
:class="{ 'text-[var(--base-color)] bg-[var(--primary-color-hover)]': index === selectedIndex }"
|
||||
@click="handleSelect(option.value)"
|
||||
@mouseenter="handleMouseEnter(index)"
|
||||
@mousemove="setKeyboardFalse"
|
||||
>
|
||||
<div class="grid grid-rows-2 grid-cols-[40px_1fr_30px] h-full p-2">
|
||||
<div class="row-span-2 place-self-center">
|
||||
<nova-icon :icon="option.icon" />
|
||||
</div>
|
||||
<span>{{ option.label }}</span>
|
||||
<icon-park-outline-right class="row-span-2 place-self-center" />
|
||||
<span class="op-70">{{ option.value }}</span>
|
||||
</div>
|
||||
</n-el>
|
||||
</ul>
|
||||
|
||||
<n-empty v-else size="large" class="h-450px flex-center" />
|
||||
</n-scrollbar>
|
||||
|
||||
<template #footer>
|
||||
<n-flex>
|
||||
<div class="flex-y-center gap-1">
|
||||
<svg width="15" height="15" aria-label="Enter key" role="img"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2"><path d="M12 3.53088v3c0 1-1 2-2 2H4M7 11.53088l-3-3 3-3" /></g></svg>
|
||||
<span>{{ $t('common.choose') }}</span>
|
||||
</div>
|
||||
<div class="flex-y-center gap-1">
|
||||
<svg width="15" height="15" aria-label="Arrow down" role="img"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2"><path d="M7.5 3.5v8M10.5 8.5l-3 3-3-3" /></g></svg>
|
||||
<svg width="15" height="15" aria-label="Arrow up" role="img"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2"><path d="M7.5 11.5v-8M10.5 6.5l-3-3-3 3" /></g></svg>
|
||||
<span>{{ $t('common.navigate') }}</span>
|
||||
</div>
|
||||
<div class="flex-y-center gap-1">
|
||||
<svg width="15" height="15" aria-label="Escape key" role="img"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2"><path d="M13.6167 8.936c-.1065.3583-.6883.962-1.4875.962-.7993 0-1.653-.9165-1.653-2.1258v-.5678c0-1.2548.7896-2.1016 1.653-2.1016.8634 0 1.3601.4778 1.4875 1.0724M9 6c-.1352-.4735-.7506-.9219-1.46-.8972-.7092.0246-1.344.57-1.344 1.2166s.4198.8812 1.3445.9805C8.465 7.3992 8.968 7.9337 9 8.5c.032.5663-.454 1.398-1.4595 1.398C6.6593 9.898 6 9 5.963 8.4851m-1.4748.5368c-.2635.5941-.8099.876-1.5443.876s-1.7073-.6248-1.7073-2.204v-.4603c0-1.0416.721-2.131 1.7073-2.131.9864 0 1.6425 1.031 1.5443 2.2492h-2.956" /></g></svg>
|
||||
<span>{{ $t('common.close') }}</span>
|
||||
</div>
|
||||
</n-flex>
|
||||
</template>
|
||||
</n-modal>
|
||||
</n-auto-complete>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
||||
158
src/layouts/components/header/Setting.vue
Normal file
158
src/layouts/components/header/Setting.vue
Normal file
@ -0,0 +1,158 @@
|
||||
<script setup lang="ts">
|
||||
import LayoutSelector from '../common/LayoutSelector.vue'
|
||||
import { useAppStore } from '@/store'
|
||||
|
||||
const appStore = useAppStore()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const drawerActive = ref(false)
|
||||
function openSetting() {
|
||||
drawerActive.value = !drawerActive.value
|
||||
}
|
||||
|
||||
const transitionSelectorOptions = computed(() => {
|
||||
return [
|
||||
{
|
||||
label: t('app.transitionNull'),
|
||||
value: '',
|
||||
},
|
||||
{
|
||||
label: t('app.transitionFadeSlide'),
|
||||
value: 'fade-slide',
|
||||
},
|
||||
{
|
||||
label: t('app.transitionFadeBottom'),
|
||||
value: 'fade-bottom',
|
||||
},
|
||||
{
|
||||
label: t('app.transitionFadeScale'),
|
||||
value: 'fade-scale',
|
||||
},
|
||||
{
|
||||
label: t('app.transitionZoomFade'),
|
||||
value: 'zoom-fade',
|
||||
},
|
||||
{
|
||||
label: t('app.transitionZoomOut'),
|
||||
value: 'zoom-out',
|
||||
},
|
||||
{
|
||||
label: t('app.transitionSoft'),
|
||||
value: 'fade',
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
const palette = [
|
||||
'#ffb8b8',
|
||||
'#d03050',
|
||||
'#F0A020',
|
||||
'#fff200',
|
||||
'#ffda79',
|
||||
'#18A058',
|
||||
'#006266',
|
||||
'#22a6b3',
|
||||
'#18dcff',
|
||||
'#2080F0',
|
||||
'#c56cf0',
|
||||
'#be2edd',
|
||||
'#706fd3',
|
||||
'#4834d4',
|
||||
'#130f40',
|
||||
'#4b4b4b',
|
||||
]
|
||||
|
||||
function resetSetting() {
|
||||
window.$dialog.warning({
|
||||
title: t('app.resetSettingTitle'),
|
||||
content: t('app.resetSettingContent'),
|
||||
positiveText: t('common.confirm'),
|
||||
negativeText: t('common.cancel'),
|
||||
onPositiveClick: () => {
|
||||
appStore.resetAlltheme()
|
||||
window.$message.success(t('app.resetSettingMeaasge'))
|
||||
},
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-tooltip placement="bottom" trigger="hover">
|
||||
<template #trigger>
|
||||
<CommonWrapper @click="openSetting">
|
||||
<div>
|
||||
<icon-park-outline-setting-two />
|
||||
<n-drawer v-model:show="drawerActive" :width="360">
|
||||
<n-drawer-content :title="t('app.systemSetting')" closable>
|
||||
<n-space vertical>
|
||||
<n-divider>{{ $t('app.layoutSetting') }}</n-divider>
|
||||
<LayoutSelector v-model:value="appStore.layoutMode" />
|
||||
<n-divider>{{ $t('app.themeSetting') }}</n-divider>
|
||||
<n-space justify="space-between">
|
||||
{{ $t('app.colorWeak') }}
|
||||
<n-switch :value="appStore.colorWeak" @update:value="appStore.toggleColorWeak" />
|
||||
</n-space>
|
||||
<n-space justify="space-between">
|
||||
{{ $t('app.blackAndWhite') }}
|
||||
<n-switch :value="appStore.grayMode" @update:value="appStore.toggleGrayMode" />
|
||||
</n-space>
|
||||
<n-space align="center" justify="space-between">
|
||||
{{ $t('app.themeColor') }}
|
||||
<n-color-picker
|
||||
v-model:value="appStore.primaryColor" class="w-10em" :swatches="palette"
|
||||
@update:value="appStore.setPrimaryColor"
|
||||
/>
|
||||
</n-space>
|
||||
<n-space align="center" justify="space-between">
|
||||
{{ $t('app.pageTransition') }}
|
||||
<n-select
|
||||
v-model:value="appStore.transitionAnimation" class="w-10em"
|
||||
:options="transitionSelectorOptions" @update:value="appStore.reloadPage"
|
||||
/>
|
||||
</n-space>
|
||||
|
||||
<n-divider>{{ $t('app.interfaceDisplay') }}</n-divider>
|
||||
<n-space justify="space-between">
|
||||
{{ $t('app.logoDisplay') }}
|
||||
<n-switch v-model:value="appStore.showLogo" />
|
||||
</n-space>
|
||||
<n-space justify="space-between">
|
||||
{{ $t('app.topProgress') }}
|
||||
<n-switch v-model:value="appStore.showProgress" />
|
||||
</n-space>
|
||||
<n-space justify="space-between">
|
||||
{{ $t('app.multitab') }}
|
||||
<n-switch v-model:value="appStore.showTabs" />
|
||||
</n-space>
|
||||
<n-space justify="space-between">
|
||||
{{ $t('app.bottomCopyright') }}
|
||||
<n-switch v-model:value="appStore.showFooter" />
|
||||
</n-space>
|
||||
<n-space justify="space-between">
|
||||
{{ $t('app.breadcrumb') }}
|
||||
<n-switch v-model:value="appStore.showBreadcrumb" />
|
||||
</n-space>
|
||||
<n-space justify="space-between">
|
||||
{{ $t('app.BreadcrumbIcon') }}
|
||||
<n-switch v-model:value="appStore.showBreadcrumbIcon" />
|
||||
</n-space>
|
||||
<n-space justify="space-between">
|
||||
{{ $t('app.watermake') }}
|
||||
<n-switch v-model:value="appStore.showWatermark" />
|
||||
</n-space>
|
||||
</n-space>
|
||||
|
||||
<template #footer>
|
||||
<n-button type="error" @click="resetSetting">
|
||||
{{ $t('app.reset') }}
|
||||
</n-button>
|
||||
</template>
|
||||
</n-drawer-content>
|
||||
</n-drawer>
|
||||
</div>
|
||||
</CommonWrapper>
|
||||
</template>
|
||||
<span>{{ $t('app.setting') }}</span>
|
||||
</n-tooltip>
|
||||
</template>
|
||||
@ -1,14 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { useAuthStore } from '@/store'
|
||||
import { renderIcon } from '@/utils/icon'
|
||||
import IconBookOpen from '~icons/icon-park-outline/book-open'
|
||||
import { useAuthStore } from '@/store'
|
||||
import IconGithub from '~icons/icon-park-outline/github'
|
||||
import IconLogout from '~icons/icon-park-outline/logout'
|
||||
import IconUser from '~icons/icon-park-outline/user'
|
||||
import IconLogout from '~icons/icon-park-outline/logout'
|
||||
import IconBookOpen from '~icons/icon-park-outline/book-open'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const { userInfo, logout } = useAuthStore()
|
||||
const { userInfo, resetAuthStore } = useAuthStore()
|
||||
const router = useRouter()
|
||||
|
||||
const options = computed(() => {
|
||||
@ -56,12 +56,12 @@ function handleSelect(key: string | number) {
|
||||
positiveText: t('common.confirm'),
|
||||
negativeText: t('common.cancel'),
|
||||
onPositiveClick: () => {
|
||||
logout()
|
||||
resetAuthStore()
|
||||
},
|
||||
})
|
||||
}
|
||||
if (key === 'userCenter')
|
||||
router.push('/user-center')
|
||||
router.push('/userCenter')
|
||||
|
||||
if (key === 'guthub')
|
||||
window.open('https://github.com/chansee97/nova-admin')
|
||||
@ -70,7 +70,7 @@ function handleSelect(key: string | number) {
|
||||
window.open('https://gitee.com/chansee97/nova-admin')
|
||||
|
||||
if (key === 'docs')
|
||||
window.open('https://nova-admin-docs.pages.dev/')
|
||||
window.open('https://nova-admin-docs.netlify.app/')
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -82,7 +82,7 @@ function handleSelect(key: string | number) {
|
||||
>
|
||||
<n-avatar
|
||||
round
|
||||
class="cursor-pointer"
|
||||
|
||||
:src="userInfo?.avatar"
|
||||
>
|
||||
<template #fallback>
|
||||
|
||||
@ -1,29 +1,33 @@
|
||||
import BackTop from './common/BackTop.vue'
|
||||
import Setting from './common/Setting.vue'
|
||||
import SettingDrawer from './common/SettingDrawer.vue'
|
||||
import Logo from './common/Logo.vue'
|
||||
import MobileDrawer from './common/MobileDrawer.vue'
|
||||
/* 侧边栏组件 */
|
||||
import Logo from './sider/Logo.vue'
|
||||
import Menu from './sider/Menu.vue'
|
||||
|
||||
/* 头部栏组件 */
|
||||
import Breadcrumb from './header/Breadcrumb.vue'
|
||||
import CollapaseButton from './header/CollapaseButton.vue'
|
||||
import FullScreen from './header/FullScreen.vue'
|
||||
import Setting from './header/Setting.vue'
|
||||
import Notices from './header/Notices.vue'
|
||||
import Search from './header/Search.vue'
|
||||
import UserCenter from './header/UserCenter.vue'
|
||||
import Search from './header/Search.vue'
|
||||
|
||||
/* 标签栏组件 */
|
||||
import TabBar from './tab/TabBar.vue'
|
||||
|
||||
/* 其他组件 */
|
||||
// 返回顶部
|
||||
import BackTop from './common/BackTop.vue'
|
||||
|
||||
export {
|
||||
BackTop,
|
||||
Breadcrumb,
|
||||
CollapaseButton,
|
||||
FullScreen,
|
||||
Menu,
|
||||
Logo,
|
||||
MobileDrawer,
|
||||
Notices,
|
||||
Search,
|
||||
FullScreen,
|
||||
Setting,
|
||||
SettingDrawer,
|
||||
TabBar,
|
||||
Notices,
|
||||
UserCenter,
|
||||
Search,
|
||||
TabBar,
|
||||
BackTop,
|
||||
}
|
||||
|
||||
@ -5,16 +5,6 @@ const router = useRouter()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const name = import.meta.env.VITE_APP_NAME
|
||||
|
||||
const hidenLogoText = computed(() => {
|
||||
if (['sidebar', 'mixed-sidebar', 'horizontal'].includes(appStore.layoutMode)) {
|
||||
return false
|
||||
}
|
||||
if (['two-column', 'mixed-two-column'].includes(appStore.layoutMode)) {
|
||||
return true
|
||||
}
|
||||
return appStore.collapsed
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -24,7 +14,7 @@ const hidenLogoText = computed(() => {
|
||||
>
|
||||
<svg-icons-logo class="text-1.5em" />
|
||||
<span
|
||||
v-show="!hidenLogoText"
|
||||
v-show="!appStore.collapsed"
|
||||
class="text-ellipsis overflow-hidden whitespace-nowrap"
|
||||
>{{ name }}</span>
|
||||
</div>
|
||||
19
src/layouts/components/sider/Menu.vue
Normal file
19
src/layouts/components/sider/Menu.vue
Normal file
@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import { useAppStore } from '@/store'
|
||||
import { useRouteStore } from '@/store/route'
|
||||
|
||||
const appStore = useAppStore()
|
||||
const routesStore = useRouteStore()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-menu
|
||||
:collapsed="appStore.collapsed"
|
||||
:indent="20"
|
||||
:collapsed-width="64"
|
||||
:options="routesStore.menus"
|
||||
:value="routesStore.activeMenu"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@ -1,50 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { useAppStore } from '@/store'
|
||||
|
||||
const appStore = useAppStore()
|
||||
|
||||
let previousLayoutMode = appStore.layoutMode
|
||||
|
||||
function enterFullContent() {
|
||||
previousLayoutMode = appStore.layoutMode
|
||||
appStore.layoutMode = 'full-content'
|
||||
}
|
||||
|
||||
function exitFullContent() {
|
||||
// 如果是全屏或者数据不存在,则恢复为默认的vertical
|
||||
if (previousLayoutMode === 'full-content' || !previousLayoutMode) {
|
||||
previousLayoutMode = 'vertical'
|
||||
}
|
||||
appStore.layoutMode = previousLayoutMode
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-tooltip v-if="!appStore.isMobile" placement="bottom" trigger="hover">
|
||||
<template #trigger>
|
||||
<CommonWrapper @click="enterFullContent">
|
||||
<icon-park-outline-full-screen-one />
|
||||
</CommonWrapper>
|
||||
</template>
|
||||
{{ $t('app.togglContentFullScreen') }}
|
||||
</n-tooltip>
|
||||
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="appStore.layoutMode === 'full-content'"
|
||||
class="fixed top-4 right-0 z-[9999]"
|
||||
>
|
||||
<n-tooltip placement="left" trigger="hover">
|
||||
<template #trigger>
|
||||
<n-el
|
||||
class="bg-[var(--primary-color)] c-[var(--base-color)] rounded-l-lg shadow-lg p-2 cursor-pointer"
|
||||
@click="exitFullContent"
|
||||
>
|
||||
<icon-park-outline-off-screen-one />
|
||||
</n-el>
|
||||
</template>
|
||||
{{ $t('app.togglContentFullScreen') }}
|
||||
</n-tooltip>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
@ -9,7 +9,6 @@ const { t } = useI18n()
|
||||
function renderDropTabsLabel(option: any) {
|
||||
return t(`route.${String(option.name)}`, option.meta.title)
|
||||
}
|
||||
|
||||
function renderDropTabsIcon(option: any) {
|
||||
return renderIcon(option.meta.icon)!()
|
||||
}
|
||||
@ -27,7 +26,6 @@ function handleDropTabs(key: string, option: any) {
|
||||
:render-icon="renderDropTabsIcon"
|
||||
trigger="click"
|
||||
size="small"
|
||||
key-field="fullPath"
|
||||
@select="handleDropTabs"
|
||||
>
|
||||
<CommonWrapper>
|
||||
|
||||
@ -1,28 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import type { RouteLocationNormalized } from 'vue-router'
|
||||
import Reload from './Reload.vue'
|
||||
import DropTabs from './DropTabs.vue'
|
||||
import { useAppStore, useTabStore } from '@/store'
|
||||
import { useTabScroll } from '@/hooks/useTabScroll'
|
||||
import { useDraggable } from 'vue-draggable-plus'
|
||||
import IconRedo from '~icons/icon-park-outline/redo'
|
||||
import IconClose from '~icons/icon-park-outline/close'
|
||||
import IconDelete from '~icons/icon-park-outline/delete-four'
|
||||
import IconFullwith from '~icons/icon-park-outline/fullwidth'
|
||||
import IconRedo from '~icons/icon-park-outline/redo'
|
||||
import IconLeft from '~icons/icon-park-outline/to-left'
|
||||
import IconRight from '~icons/icon-park-outline/to-right'
|
||||
import ContentFullScreen from './ContentFullScreen.vue'
|
||||
import DropTabs from './DropTabs.vue'
|
||||
import Reload from './Reload.vue'
|
||||
import TabBarItem from './TabBarItem.vue'
|
||||
import IconFullwith from '~icons/icon-park-outline/fullwidth'
|
||||
|
||||
const tabStore = useTabStore()
|
||||
const { tabs } = storeToRefs(useTabStore())
|
||||
const appStore = useAppStore()
|
||||
|
||||
const { scrollbar, onWheel } = useTabScroll(computed(() => tabStore.currentTabPath))
|
||||
|
||||
const router = useRouter()
|
||||
function handleTab(route: RouteLocationNormalized) {
|
||||
router.push(route.fullPath)
|
||||
router.push(route.path)
|
||||
}
|
||||
function handleClose(path: string) {
|
||||
tabStore.closeTab(path)
|
||||
}
|
||||
const { t } = useI18n()
|
||||
const options = computed(() => {
|
||||
@ -74,16 +70,16 @@ function handleSelect(key: string) {
|
||||
appStore.reloadPage()
|
||||
},
|
||||
closeCurrent() {
|
||||
tabStore.closeTab(currentRoute.value.fullPath)
|
||||
tabStore.closeTab(currentRoute.value.path)
|
||||
},
|
||||
closeOther() {
|
||||
tabStore.closeOtherTabs(currentRoute.value.fullPath)
|
||||
tabStore.closeOtherTabs(currentRoute.value.path)
|
||||
},
|
||||
closeLeft() {
|
||||
tabStore.closeLeftTabs(currentRoute.value.fullPath)
|
||||
tabStore.closeLeftTabs(currentRoute.value.path)
|
||||
},
|
||||
closeRight() {
|
||||
tabStore.closeRightTabs(currentRoute.value.fullPath)
|
||||
tabStore.closeRightTabs(currentRoute.value.path)
|
||||
},
|
||||
closeAll() {
|
||||
tabStore.closeAllTabs()
|
||||
@ -104,53 +100,55 @@ function handleContextMenu(e: MouseEvent, route: RouteLocationNormalized) {
|
||||
function onClickoutside() {
|
||||
showDropdown.value = false
|
||||
}
|
||||
|
||||
const el = ref()
|
||||
|
||||
useDraggable(el, tabs, {
|
||||
animation: 150,
|
||||
ghostClass: 'ghost',
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-scrollbar ref="scrollbar" class="relative flex h-full tab-bar-scroller-wrapper" content-class="h-full pr-34 tab-bar-scroller-content" :x-scrollable="true" @wheel="onWheel">
|
||||
<div class="p-l-2 flex wh-full relative">
|
||||
<div class="flex items-end">
|
||||
<TabBarItem
|
||||
v-for="item in tabStore.pinTabs" :key="item.fullPath" :value="tabStore.currentTabPath" :route="item"
|
||||
@click="handleTab(item)"
|
||||
/>
|
||||
</div>
|
||||
<div ref="el" class="flex items-end flex-1">
|
||||
<TabBarItem
|
||||
v-for="item in tabStore.tabs"
|
||||
:key="item.fullPath"
|
||||
:value="tabStore.currentTabPath"
|
||||
:route="item"
|
||||
closable
|
||||
:data-tab-path="item.fullPath"
|
||||
@close="tabStore.closeTab"
|
||||
@click="handleTab(item)"
|
||||
@contextmenu="handleContextMenu($event, item)"
|
||||
/>
|
||||
<n-dropdown
|
||||
placement="bottom-start" trigger="manual" :x="x" :y="y" :options="options" :show="showDropdown"
|
||||
:on-clickoutside="onClickoutside" @select="handleSelect"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<n-el class="absolute right-0 top-0 flex items-center gap-1 bg-[var(--card-color)] h-full">
|
||||
<Reload />
|
||||
<ContentFullScreen />
|
||||
<DropTabs />
|
||||
</n-el>
|
||||
</n-scrollbar>
|
||||
<div class="wh-full flex items-end">
|
||||
<n-tabs
|
||||
type="card"
|
||||
size="small"
|
||||
:tabs-padding="15"
|
||||
:value="tabStore.currentTabPath"
|
||||
@close="handleClose"
|
||||
>
|
||||
<n-tab
|
||||
v-for="item in tabStore.pinTabs"
|
||||
:key="item.path"
|
||||
:name="item.path"
|
||||
@click="router.push(item.path)"
|
||||
>
|
||||
<div class="flex-x-center gap-2">
|
||||
<nova-icon :icon="item.meta.icon" /> {{ $t(`route.${String(item.name)}`, item.meta.title) }}
|
||||
</div>
|
||||
</n-tab>
|
||||
<n-tab
|
||||
v-for="item in tabStore.tabs"
|
||||
:key="item.path"
|
||||
closable
|
||||
:name="item.path as string"
|
||||
@click="handleTab(item)"
|
||||
@contextmenu="handleContextMenu($event, item)"
|
||||
>
|
||||
<div class="flex-x-center gap-2">
|
||||
<nova-icon :icon="item.meta.icon" /> {{ $t(`route.${String(item.name)}`, item.meta.title) }}
|
||||
</div>
|
||||
</n-tab>
|
||||
<template #suffix>
|
||||
<Reload />
|
||||
<DropTabs />
|
||||
</template>
|
||||
</n-tabs>
|
||||
<n-dropdown
|
||||
placement="bottom-start"
|
||||
trigger="manual"
|
||||
:x="x"
|
||||
:y="y"
|
||||
:options="options"
|
||||
:show="showDropdown"
|
||||
:on-clickoutside="onClickoutside"
|
||||
@select="handleSelect"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ghost {
|
||||
opacity: 0.5;
|
||||
background: #c4f6d5;
|
||||
}
|
||||
</style>
|
||||
<style scoped></style>./DropTabs.vue
|
||||
|
||||
@ -1,41 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { RouteLocationNormalized } from 'vue-router'
|
||||
|
||||
const { route, value, closable = false } = defineProps<{
|
||||
route: RouteLocationNormalized
|
||||
value: string
|
||||
closable?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: [string]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-el
|
||||
class="cursor-pointer p-x-4 p-y-2 m-x-2px b b-[--divider-color] b-b-[#0000] rounded-[--border-radius]"
|
||||
:class="[
|
||||
value === route.fullPath ? 'c-[--primary-color]' : 'c-[--text-color-2]',
|
||||
value === route.fullPath ? 'bg-[#0000]' : 'bg-[--tab-color]',
|
||||
closable && 'p-r-2',
|
||||
]"
|
||||
style="transition: box-shadow .3s var(--n-bezier), color .3s var(--n-bezier), background-color .3s var(--n-bezier), border-color .3s var(--n-bezier);"
|
||||
>
|
||||
<div class="flex-center gap-2 text-nowrap">
|
||||
<nova-icon :icon="route.meta.icon" />
|
||||
<span>{{ $t(`route.${String(route.name)}`, route.meta.title) }}</span>
|
||||
<button
|
||||
v-if="closable"
|
||||
type="button"
|
||||
class="bg-transparent h-18px w-18px flex-center text-[var(--close-icon-color)] hover:bg-[var(--close-color-hover)] rounded-3px"
|
||||
style="transition: background-color .3s var(--n-bezier), color .3s var(--n-bezier);"
|
||||
@click.stop="emit('close', route.fullPath)"
|
||||
>
|
||||
<n-icon size="14">
|
||||
<svg viewBox="0 0 12 12" version="1.1" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><g stroke="none" stroke-width="1" fill="none" fill-rule="evenodd"><g fill="currentColor" fill-rule="nonzero"><path d="M2.08859116,2.2156945 L2.14644661,2.14644661 C2.32001296,1.97288026 2.58943736,1.95359511 2.7843055,2.08859116 L2.85355339,2.14644661 L6,5.293 L9.14644661,2.14644661 C9.34170876,1.95118446 9.65829124,1.95118446 9.85355339,2.14644661 C10.0488155,2.34170876 10.0488155,2.65829124 9.85355339,2.85355339 L6.707,6 L9.85355339,9.14644661 C10.0271197,9.32001296 10.0464049,9.58943736 9.91140884,9.7843055 L9.85355339,9.85355339 C9.67998704,10.0271197 9.41056264,10.0464049 9.2156945,9.91140884 L9.14644661,9.85355339 L6,6.707 L2.85355339,9.85355339 C2.65829124,10.0488155 2.34170876,10.0488155 2.14644661,9.85355339 C1.95118446,9.65829124 1.95118446,9.34170876 2.14644661,9.14644661 L5.293,6 L2.14644661,2.85355339 C1.97288026,2.67998704 1.95359511,2.41056264 2.08859116,2.2156945 L2.14644661,2.14644661 L2.08859116,2.2156945 Z" /></g></g></svg>
|
||||
</n-icon>
|
||||
</button>
|
||||
</div>
|
||||
</n-el>
|
||||
</template>
|
||||
@ -1,148 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import { useAppStore, useRouteStore } from '@/store'
|
||||
import {
|
||||
BackTop,
|
||||
Breadcrumb,
|
||||
CollapaseButton,
|
||||
FullScreen,
|
||||
Logo,
|
||||
MobileDrawer,
|
||||
Notices,
|
||||
Search,
|
||||
Setting,
|
||||
SettingDrawer,
|
||||
TabBar,
|
||||
UserCenter,
|
||||
} from './components'
|
||||
import Content from './Content.vue'
|
||||
import { ProLayout, useLayoutMenu } from 'pro-naive-ui'
|
||||
import leftMenu from './leftMenu.layout.vue'
|
||||
import topMenu from './topMenu.layout.vue'
|
||||
import { useAppStore } from '@/store/app'
|
||||
|
||||
const route = useRoute()
|
||||
const appStore = useAppStore()
|
||||
const routeStore = useRouteStore()
|
||||
|
||||
const { layoutMode } = storeToRefs(useAppStore())
|
||||
|
||||
const {
|
||||
layout,
|
||||
activeKey,
|
||||
} = useLayoutMenu({
|
||||
mode: layoutMode,
|
||||
accordion: true,
|
||||
menus: routeStore.menus,
|
||||
})
|
||||
|
||||
watch(() => route.path, () => {
|
||||
activeKey.value = routeStore.activeMenu
|
||||
}, { immediate: true })
|
||||
|
||||
// 移动端抽屉控制
|
||||
const showMobileDrawer = ref(false)
|
||||
|
||||
const sidebarWidth = ref(240)
|
||||
const sidebarCollapsedWidth = ref(64)
|
||||
|
||||
const hasHorizontalMenu = computed(() => ['horizontal', 'mixed-two-column', 'mixed-sidebar'].includes(layoutMode.value))
|
||||
|
||||
const hidenCollapaseButton = computed(() => ['horizontal'].includes(layoutMode.value) || appStore.isMobile)
|
||||
const layoutMap = {
|
||||
leftMenu,
|
||||
topMenu,
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SettingDrawer />
|
||||
<ProLayout
|
||||
v-model:collapsed="appStore.collapsed"
|
||||
:mode="layoutMode"
|
||||
:is-mobile="appStore.isMobile"
|
||||
:show-logo="appStore.showLogo && !appStore.isMobile"
|
||||
:show-footer="appStore.showFooter"
|
||||
:show-tabbar="appStore.showTabs"
|
||||
nav-fixed
|
||||
show-nav
|
||||
show-sidebar
|
||||
:nav-height="60"
|
||||
:tabbar-height="45"
|
||||
:footer-height="40"
|
||||
:sidebar-width="sidebarWidth"
|
||||
:sidebar-collapsed-width="sidebarCollapsedWidth"
|
||||
>
|
||||
<template #logo>
|
||||
<Logo />
|
||||
</template>
|
||||
|
||||
<template #nav-left>
|
||||
<template v-if="appStore.isMobile">
|
||||
<Logo />
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div v-if="!hasHorizontalMenu || !hidenCollapaseButton" class="h-full flex-y-center gap-1 p-x-sm">
|
||||
<CollapaseButton v-if="!hidenCollapaseButton" />
|
||||
<Breadcrumb v-if="!hasHorizontalMenu" />
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<template #nav-center>
|
||||
<div class="h-full flex-y-center gap-1">
|
||||
<n-menu v-if="hasHorizontalMenu" v-bind="layout.horizontalMenuProps" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #nav-right>
|
||||
<div class="h-full flex-y-center gap-1 p-x-xl">
|
||||
<!-- 移动端:只显示菜单按钮 -->
|
||||
<template v-if="appStore.isMobile">
|
||||
<n-button
|
||||
quaternary
|
||||
@click="showMobileDrawer = true"
|
||||
>
|
||||
<template #icon>
|
||||
<n-icon size="18">
|
||||
<icon-park-outline-hamburger-button />
|
||||
</n-icon>
|
||||
</template>
|
||||
</n-button>
|
||||
</template>
|
||||
|
||||
<!-- 桌面端:显示完整功能组件 -->
|
||||
<template v-else>
|
||||
<Search />
|
||||
<Notices />
|
||||
<FullScreen />
|
||||
<DarkModeSwitch />
|
||||
<LangsSwitch />
|
||||
<Setting />
|
||||
<UserCenter />
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #sidebar>
|
||||
<n-menu v-bind="layout.verticalMenuProps" :collapsed-width="sidebarCollapsedWidth" />
|
||||
</template>
|
||||
|
||||
<template #sidebar-extra>
|
||||
<n-scrollbar class="flex-[1_0_0]">
|
||||
<n-menu v-bind="layout.verticalExtraMenuProps" :collapsed-width="sidebarCollapsedWidth" />
|
||||
</n-scrollbar>
|
||||
</template>
|
||||
|
||||
<template #tabbar>
|
||||
<TabBar />
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex-center h-full">
|
||||
{{ appStore.footerText }}
|
||||
</div>
|
||||
</template>
|
||||
<Content />
|
||||
<BackTop />
|
||||
<SettingDrawer />
|
||||
|
||||
<!-- 移动端功能抽屉 -->
|
||||
<MobileDrawer v-model:show="showMobileDrawer">
|
||||
<n-menu v-bind="layout.verticalMenuProps" />
|
||||
</MobileDrawer>
|
||||
</ProLayout>
|
||||
<component :is="layoutMap[appStore.layoutMode]" />
|
||||
</template>
|
||||
|
||||
94
src/layouts/leftMenu.layout.vue
Normal file
94
src/layouts/leftMenu.layout.vue
Normal file
@ -0,0 +1,94 @@
|
||||
<script lang="ts" setup>
|
||||
import {
|
||||
BackTop,
|
||||
Breadcrumb,
|
||||
CollapaseButton,
|
||||
FullScreen,
|
||||
Logo,
|
||||
Menu,
|
||||
Notices,
|
||||
Search,
|
||||
Setting,
|
||||
TabBar,
|
||||
UserCenter,
|
||||
} from './components'
|
||||
import { useAppStore, useRouteStore } from '@/store'
|
||||
|
||||
const routeStore = useRouteStore()
|
||||
const appStore = useAppStore()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-layout
|
||||
has-sider
|
||||
class="wh-full"
|
||||
embedded
|
||||
>
|
||||
<n-layout-sider
|
||||
bordered
|
||||
:collapsed="appStore.collapsed"
|
||||
collapse-mode="width"
|
||||
:collapsed-width="64"
|
||||
:width="240"
|
||||
content-style="display: flex;flex-direction: column;min-height:100%;"
|
||||
>
|
||||
<Logo v-if="appStore.showLogo" />
|
||||
<n-scrollbar class="flex-1">
|
||||
<Menu />
|
||||
</n-scrollbar>
|
||||
</n-layout-sider>
|
||||
<n-layout
|
||||
class="h-full flex flex-col"
|
||||
content-style="display: flex;flex-direction: column;min-height:100%;"
|
||||
embedded
|
||||
:native-scrollbar="false"
|
||||
>
|
||||
<n-layout-header bordered position="absolute" class="z-1">
|
||||
<div class="h-60px flex-y-center justify-between">
|
||||
<div class="flex-y-center h-full">
|
||||
<CollapaseButton />
|
||||
<Breadcrumb />
|
||||
</div>
|
||||
<div class="flex-y-center gap-1 h-full p-x-xl">
|
||||
<Search />
|
||||
<Notices />
|
||||
<FullScreen />
|
||||
<DarkModeSwitch />
|
||||
<LangsSwitch />
|
||||
<Setting />
|
||||
<UserCenter />
|
||||
</div>
|
||||
</div>
|
||||
<TabBar v-if="appStore.showTabs" class="h-45px" />
|
||||
</n-layout-header>
|
||||
<div class="flex-1 p-16px flex flex-col">
|
||||
<div class="h-60px" />
|
||||
<div v-if="appStore.showTabs" class="h-45px" />
|
||||
<router-view v-slot="{ Component, route }" class="flex-1">
|
||||
<transition
|
||||
:name="appStore.transitionAnimation"
|
||||
mode="out-in"
|
||||
>
|
||||
<keep-alive :include="routeStore.cacheRoutes">
|
||||
<component
|
||||
:is="Component"
|
||||
v-if="appStore.loadFlag"
|
||||
:key="route.fullPath"
|
||||
/>
|
||||
</keep-alive>
|
||||
</transition>
|
||||
</router-view>
|
||||
<div v-if="appStore.showFooter" class="h-40px" />
|
||||
</div>
|
||||
<n-layout-footer
|
||||
v-if="appStore.showFooter"
|
||||
bordered
|
||||
position="absolute"
|
||||
class="h-40px flex-center"
|
||||
>
|
||||
{{ appStore.footerText }}
|
||||
</n-layout-footer>
|
||||
<BackTop />
|
||||
</n-layout>
|
||||
</n-layout>
|
||||
</template>
|
||||
59
src/layouts/topMenu.layout.vue
Normal file
59
src/layouts/topMenu.layout.vue
Normal file
@ -0,0 +1,59 @@
|
||||
<script lang="ts" setup>
|
||||
import {
|
||||
BackTop,
|
||||
FullScreen,
|
||||
Logo,
|
||||
Menu,
|
||||
Notices,
|
||||
Search,
|
||||
Setting,
|
||||
TabBar,
|
||||
UserCenter,
|
||||
} from './components'
|
||||
import { useAppStore, useRouteStore } from '@/store'
|
||||
|
||||
const routeStore = useRouteStore()
|
||||
const appStore = useAppStore()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-layout class="wh-full" embedded>
|
||||
<n-layout
|
||||
class="h-full flex flex-col" content-style="display: flex;flex-direction: column;min-height:100%;"
|
||||
embedded :native-scrollbar="false"
|
||||
>
|
||||
<n-layout-header bordered position="absolute" class="z-1">
|
||||
<div class="h-60px flex-y-center justify-between shrink-0">
|
||||
<Logo v-if="appStore.showLogo" />
|
||||
<Menu mode="horizontal" responsive />
|
||||
<div class="flex-y-center gap-1 h-full p-x-xl">
|
||||
<Search />
|
||||
<Notices />
|
||||
<FullScreen />
|
||||
<DarkModeSwitch />
|
||||
<LangsSwitch />
|
||||
<Setting />
|
||||
<UserCenter />
|
||||
</div>
|
||||
</div>
|
||||
<TabBar v-if="appStore.showTabs" class="h-45px" />
|
||||
</n-layout-header>
|
||||
<div class="flex-1 p-16px flex flex-col">
|
||||
<div class="h-60px" />
|
||||
<div v-if="appStore.showTabs" class="h-45px" />
|
||||
<router-view v-slot="{ Component, route }" class="flex-1">
|
||||
<transition :name="appStore.transitionAnimation" mode="out-in">
|
||||
<keep-alive :include="routeStore.cacheRoutes">
|
||||
<component :is="Component" v-if="appStore.loadFlag" :key="route.fullPath" />
|
||||
</keep-alive>
|
||||
</transition>
|
||||
</router-view>
|
||||
<div v-if="appStore.showFooter" class="h-40px" />
|
||||
</div>
|
||||
<n-layout-footer v-if="appStore.showFooter" bordered position="absolute" class="h-40px flex-center">
|
||||
{{ appStore.footerText }}
|
||||
</n-layout-footer>
|
||||
<BackTop />
|
||||
</n-layout>
|
||||
</n-layout>
|
||||
</template>
|
||||
38
src/main.ts
38
src/main.ts
@ -1,5 +1,35 @@
|
||||
import App from './App.vue'
|
||||
import type { App } from 'vue'
|
||||
import AppVue from './App.vue'
|
||||
import AppLoading from './components/common/AppLoading.vue'
|
||||
import { installRouter } from '@/router'
|
||||
import { installPinia } from '@/store'
|
||||
|
||||
// 创建应用实例并挂载
|
||||
const app = createApp(App)
|
||||
app.mount('#app')
|
||||
async function setupApp() {
|
||||
// 载入全局loading加载状态
|
||||
const appLoading = createApp(AppLoading)
|
||||
appLoading.mount('#appLoading')
|
||||
|
||||
// 创建vue实例
|
||||
const app = createApp(AppVue)
|
||||
|
||||
// 注册模块Pinia
|
||||
await installPinia(app)
|
||||
|
||||
// 注册模块 Vue-router
|
||||
await installRouter(app)
|
||||
|
||||
/* 注册模块 指令/静态资源 */
|
||||
Object.values(
|
||||
import.meta.glob<{ install: (app: App) => void }>('./modules/*.ts', {
|
||||
eager: true,
|
||||
}),
|
||||
).map(i => app.use(i))
|
||||
|
||||
// 卸载载入动画
|
||||
appLoading.unmount()
|
||||
|
||||
// 挂载
|
||||
app.mount('#app')
|
||||
}
|
||||
|
||||
setupApp()
|
||||
|
||||
@ -1,24 +1,17 @@
|
||||
import type { App } from 'vue'
|
||||
import { local } from '@/utils'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import type { App } from 'vue'
|
||||
import enUS from '../../locales/en_US.json'
|
||||
import zhCN from '../../locales/zh_CN.json'
|
||||
|
||||
const { VITE_DEFAULT_LANG } = import.meta.env
|
||||
import { local } from '@/utils'
|
||||
|
||||
export const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: local.get('lang') || VITE_DEFAULT_LANG, // 默认显示语言
|
||||
fallbackLocale: VITE_DEFAULT_LANG,
|
||||
locale: local.get('lang') || 'enUS', // 默认显示语言
|
||||
fallbackLocale: 'enUS',
|
||||
messages: {
|
||||
zhCN,
|
||||
enUS,
|
||||
},
|
||||
// 缺失国际化键警告
|
||||
// missingWarn: false,
|
||||
|
||||
// 缺失回退内容警告
|
||||
fallbackWarn: false,
|
||||
})
|
||||
|
||||
export function install(app: App) {
|
||||
|
||||
@ -11,75 +11,46 @@ export function setupRouterGuard(router: Router) {
|
||||
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
// 判断是否是外链,如果是直接打开网页并拦截跳转
|
||||
if (to.meta.href) {
|
||||
window.open(to.meta.href)
|
||||
next(false) // 取消当前导航
|
||||
return
|
||||
if (to.meta.herf) {
|
||||
window.open(to.meta.herf)
|
||||
return false
|
||||
}
|
||||
// 开始 loadingBar
|
||||
appStore.showProgress && window.$loadingBar?.start()
|
||||
|
||||
// 判断有无TOKEN,登录鉴权
|
||||
const isLogin = Boolean(local.get('accessToken'))
|
||||
if (!isLogin) {
|
||||
if (to.name === 'login')
|
||||
next()
|
||||
|
||||
// 处理根路由重定向
|
||||
if (to.name === 'root') {
|
||||
if (isLogin) {
|
||||
// 已登录,重定向到首页
|
||||
next({ path: import.meta.env.VITE_HOME_PATH, replace: true })
|
||||
if (to.name !== 'login') {
|
||||
const redirect = to.name === '404' ? undefined : to.fullPath
|
||||
next({ path: '/login', query: { redirect } })
|
||||
}
|
||||
else {
|
||||
// 未登录,重定向到登录页
|
||||
next({ path: '/login', replace: true })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 如果是login路由,直接放行
|
||||
if (to.name === 'login') {
|
||||
// login页面不需要任何认证检查,直接放行
|
||||
// 继续执行后面的逻辑
|
||||
}
|
||||
// 如果路由明确设置了requiresAuth为false,直接放行
|
||||
else if (to.meta.requiresAuth === false) {
|
||||
// 明确设置为false的路由直接放行
|
||||
// 继续执行后面的逻辑
|
||||
}
|
||||
// 如果路由设置了requiresAuth为true,且用户未登录,重定向到登录页
|
||||
else if (to.meta.requiresAuth === true && !isLogin) {
|
||||
const redirect = to.name === 'not-found' ? undefined : to.fullPath
|
||||
next({ path: '/login', query: { redirect } })
|
||||
return
|
||||
return false
|
||||
}
|
||||
|
||||
// 判断路由有无进行初始化
|
||||
if (!routeStore.isInitAuthRoute && to.name !== 'login') {
|
||||
try {
|
||||
await routeStore.initAuthRoute()
|
||||
// 动态路由加载完回到根路由
|
||||
if (to.name === 'not-found') {
|
||||
// 等待权限路由加载好了,回到之前的路由,否则404
|
||||
next({
|
||||
path: to.fullPath,
|
||||
replace: true,
|
||||
query: to.query,
|
||||
hash: to.hash,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
catch {
|
||||
// 如果路由初始化失败(比如 401 错误),重定向到登录页
|
||||
const redirect = to.fullPath !== '/' ? to.fullPath : undefined
|
||||
next({ path: '/login', query: redirect ? { redirect } : undefined })
|
||||
return
|
||||
if (!routeStore.isInitAuthRoute) {
|
||||
await routeStore.initAuthRoute()
|
||||
// 动态路由加载完回到根路由
|
||||
if (to.name === '404') {
|
||||
// 等待权限路由加载好了,回到之前的路由,否则404
|
||||
next({
|
||||
path: to.fullPath,
|
||||
replace: true,
|
||||
query: to.query,
|
||||
hash: to.hash,
|
||||
})
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 如果用户已登录且访问login页面,重定向到首页
|
||||
if (to.name === 'login' && isLogin) {
|
||||
// 判断当前页是否在login,则定位去首页
|
||||
if (to.name === 'login') {
|
||||
next({ path: '/' })
|
||||
return
|
||||
return false
|
||||
}
|
||||
|
||||
next()
|
||||
@ -90,7 +61,7 @@ export function setupRouterGuard(router: Router) {
|
||||
// 添加tabs
|
||||
tabStore.addTab(to)
|
||||
// 设置高亮标签;
|
||||
tabStore.setCurrentTab(to.fullPath as string)
|
||||
tabStore.setCurrentTab(to.path as string)
|
||||
})
|
||||
|
||||
router.afterEach((to) => {
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import type { App } from 'vue'
|
||||
import { createRouter, createWebHashHistory, createWebHistory } from 'vue-router'
|
||||
import { setupRouterGuard } from './guard'
|
||||
import { routes } from './routes.inner'
|
||||
import { setupRouterGuard } from './guard'
|
||||
|
||||
const { VITE_ROUTE_MODE = 'hash', VITE_BASE_URL } = import.meta.env
|
||||
export const router = createRouter({
|
||||
|
||||
@ -5,42 +5,53 @@ export const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/',
|
||||
name: 'root',
|
||||
redirect: '/appRoot',
|
||||
// component: () => import('@/layouts/index'),
|
||||
children: [
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
component: () => import('@/views/build-in/login/index.vue'), // 注意这里要带上 文件后缀.vue
|
||||
component: () => import('@/views/login/index.vue'), // 注意这里要带上 文件后缀.vue
|
||||
meta: {
|
||||
title: '登录',
|
||||
withoutTab: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/public',
|
||||
name: 'publicAccess',
|
||||
component: () => import('@/views/build-in/public-access/index.vue'),
|
||||
path: '/403',
|
||||
name: '403',
|
||||
component: () => import('@/views/error/403/index.vue'),
|
||||
meta: {
|
||||
title: '公共访问示例',
|
||||
requiresAuth: false,
|
||||
title: '用户无权限',
|
||||
withoutTab: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/not-found',
|
||||
name: 'not-found',
|
||||
component: () => import('@/views/build-in/not-found/index.vue'),
|
||||
path: '/404',
|
||||
name: '404',
|
||||
component: () => import('@/views/error/404/index.vue'),
|
||||
meta: {
|
||||
title: '找不到页面',
|
||||
icon: 'icon-park-outline:ghost',
|
||||
withoutTab: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/500',
|
||||
name: '500',
|
||||
component: () => import('@/views/error/500/index.vue'),
|
||||
meta: {
|
||||
title: '服务器错误',
|
||||
icon: 'icon-park-outline:close-wifi',
|
||||
withoutTab: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
component: () => import('@/views/build-in/not-found/index.vue'),
|
||||
name: 'not-found',
|
||||
component: () => import('@/views/error/404/index.vue'),
|
||||
name: '404',
|
||||
meta: {
|
||||
title: '找不到页面',
|
||||
icon: 'icon-park-outline:ghost',
|
||||
|
||||
@ -1,418 +1,407 @@
|
||||
export const staticRoutes: AppRoute.RowRoute[] = [
|
||||
{
|
||||
name: 'dashboard',
|
||||
path: '/dashboard',
|
||||
title: '仪表盘',
|
||||
requiresAuth: true,
|
||||
icon: 'icon-park-outline:analysis',
|
||||
menuType: 'dir',
|
||||
componentPath: null,
|
||||
id: 1,
|
||||
pid: null,
|
||||
'name': 'dashboard',
|
||||
'path': '/dashboard',
|
||||
'meta.title': '仪表盘',
|
||||
'meta.requiresAuth': true,
|
||||
'meta.icon': 'icon-park-outline:analysis',
|
||||
'meta.menuType': 'dir',
|
||||
'componentPath': null,
|
||||
'id': 1,
|
||||
'pid': null,
|
||||
},
|
||||
{
|
||||
name: 'workbench',
|
||||
path: '/dashboard/workbench',
|
||||
title: '工作台',
|
||||
requiresAuth: true,
|
||||
icon: 'icon-park-outline:alarm',
|
||||
pinTab: true,
|
||||
menuType: 'page',
|
||||
componentPath: '/dashboard/workbench/index.vue',
|
||||
id: 101,
|
||||
pid: 1,
|
||||
'name': 'workbench',
|
||||
'path': '/dashboard/workbench',
|
||||
'meta.title': '工作台',
|
||||
'meta.requiresAuth': true,
|
||||
'meta.icon': 'icon-park-outline:alarm',
|
||||
'meta.pinTab': true,
|
||||
'meta.menuType': 'page',
|
||||
'componentPath': '/dashboard/workbench/index.vue',
|
||||
'id': 2,
|
||||
'pid': 1,
|
||||
},
|
||||
{
|
||||
name: 'monitor',
|
||||
path: '/dashboard/monitor',
|
||||
title: '监控页',
|
||||
requiresAuth: true,
|
||||
icon: 'icon-park-outline:anchor',
|
||||
menuType: 'page',
|
||||
componentPath: '/dashboard/monitor/index.vue',
|
||||
id: 102,
|
||||
pid: 1,
|
||||
'name': 'monitor',
|
||||
'path': '/dashboard/monitor',
|
||||
'meta.title': '监控页',
|
||||
'meta.requiresAuth': true,
|
||||
'meta.icon': 'icon-park-outline:anchor',
|
||||
'meta.menuType': 'page',
|
||||
'componentPath': '/dashboard/monitor/index.vue',
|
||||
'id': 3,
|
||||
'pid': 1,
|
||||
},
|
||||
{
|
||||
name: 'multi',
|
||||
path: '/multi',
|
||||
title: '多级菜单演示',
|
||||
requiresAuth: true,
|
||||
icon: 'icon-park-outline:list',
|
||||
menuType: 'dir',
|
||||
componentPath: null,
|
||||
id: 2,
|
||||
pid: null,
|
||||
'name': 'test',
|
||||
'path': '/test',
|
||||
'meta.title': '多级菜单演示',
|
||||
'meta.requiresAuth': true,
|
||||
'meta.icon': 'icon-park-outline:list',
|
||||
'meta.menuType': 'dir',
|
||||
'componentPath': null,
|
||||
'id': 4,
|
||||
'pid': null,
|
||||
},
|
||||
{
|
||||
name: 'multi2',
|
||||
path: '/multi/multi-2',
|
||||
title: '多级菜单子页',
|
||||
requiresAuth: true,
|
||||
icon: 'icon-park-outline:list',
|
||||
menuType: 'page',
|
||||
componentPath: '/demo/multi/multi-2/index.vue',
|
||||
id: 201,
|
||||
pid: 2,
|
||||
'name': 'test2',
|
||||
'path': '/test/test2',
|
||||
'meta.title': '多级菜单子页',
|
||||
'meta.requiresAuth': true,
|
||||
'meta.icon': 'icon-park-outline:list',
|
||||
'meta.menuType': 'page',
|
||||
'componentPath': '/test/test2/index.vue',
|
||||
'id': 6,
|
||||
'pid': 4,
|
||||
},
|
||||
{
|
||||
name: 'multi2-detail',
|
||||
path: '/multi/multi-2/detail',
|
||||
title: '菜单详情页',
|
||||
requiresAuth: false,
|
||||
icon: 'icon-park-outline:list',
|
||||
hide: true,
|
||||
activeMenu: '/multi/multi-2',
|
||||
menuType: 'page',
|
||||
componentPath: '/demo/multi/multi-2/detail/index.vue',
|
||||
id: 20101,
|
||||
pid: 2,
|
||||
'name': 'test2Detail',
|
||||
'path': '/test/test2/detail',
|
||||
'meta.title': '多级菜单的详情页',
|
||||
'meta.requiresAuth': true,
|
||||
'meta.icon': 'icon-park-outline:list',
|
||||
'meta.hide': true,
|
||||
'meta.activeMenu': '/test/test2',
|
||||
'meta.menuType': 'page',
|
||||
'componentPath': '/test/test2/detail/index.vue',
|
||||
'id': 7,
|
||||
'pid': 4,
|
||||
},
|
||||
{
|
||||
name: 'multi3',
|
||||
path: '/multi/multi-3',
|
||||
title: '多级菜单',
|
||||
requiresAuth: true,
|
||||
icon: 'icon-park-outline:list',
|
||||
menuType: 'dir',
|
||||
componentPath: null,
|
||||
id: 202,
|
||||
pid: 2,
|
||||
'name': 'test3',
|
||||
'path': '/test/test3',
|
||||
'meta.title': '多级菜单',
|
||||
'meta.requiresAuth': true,
|
||||
'meta.icon': 'icon-park-outline:list',
|
||||
'meta.menuType': 'dir',
|
||||
'componentPath': null,
|
||||
'id': 8,
|
||||
'pid': 4,
|
||||
},
|
||||
{
|
||||
name: 'multi4',
|
||||
path: '/multi/multi-3/multi-4',
|
||||
title: '多级菜单3-1',
|
||||
requiresAuth: true,
|
||||
icon: 'icon-park-outline:list',
|
||||
componentPath: '/demo/multi/multi-3/multi-4/index.vue',
|
||||
id: 20201,
|
||||
pid: 202,
|
||||
'name': 'test4',
|
||||
'path': '/test/test3/test4',
|
||||
'meta.title': '多级菜单3-1',
|
||||
'meta.requiresAuth': true,
|
||||
'meta.icon': 'icon-park-outline:list',
|
||||
'componentPath': '/test/test3/test4/index.vue',
|
||||
'id': 9,
|
||||
'pid': 8,
|
||||
},
|
||||
{
|
||||
name: 'list',
|
||||
path: '/list',
|
||||
title: '列表页',
|
||||
requiresAuth: true,
|
||||
icon: 'icon-park-outline:list-two',
|
||||
menuType: 'dir',
|
||||
componentPath: null,
|
||||
id: 3,
|
||||
pid: null,
|
||||
'name': 'list',
|
||||
'path': '/list',
|
||||
'meta.title': '列表页',
|
||||
'meta.requiresAuth': true,
|
||||
'meta.icon': 'icon-park-outline:list-two',
|
||||
'meta.menuType': 'dir',
|
||||
'componentPath': null,
|
||||
'id': 10,
|
||||
'pid': null,
|
||||
},
|
||||
{
|
||||
name: 'commonList',
|
||||
path: '/list/common-list',
|
||||
title: '常用列表',
|
||||
requiresAuth: true,
|
||||
icon: 'icon-park-outline:list-view',
|
||||
componentPath: '/demo/list/common-list/index.vue',
|
||||
id: 301,
|
||||
pid: 3,
|
||||
'name': 'commonList',
|
||||
'path': '/list/commonList',
|
||||
'meta.title': '常用列表',
|
||||
'meta.requiresAuth': true,
|
||||
'meta.icon': 'icon-park-outline:list-view',
|
||||
'componentPath': '/list/commonList/index.vue',
|
||||
'id': 11,
|
||||
'pid': 10,
|
||||
},
|
||||
{
|
||||
name: 'cardList',
|
||||
path: '/list/card-list',
|
||||
title: '卡片列表',
|
||||
requiresAuth: true,
|
||||
icon: 'icon-park-outline:view-grid-list',
|
||||
componentPath: '/demo/list/card-list/index.vue',
|
||||
id: 302,
|
||||
pid: 3,
|
||||
'name': 'cardList',
|
||||
'path': '/list/cardList',
|
||||
'meta.title': '卡片列表',
|
||||
'meta.requiresAuth': true,
|
||||
'meta.icon': 'icon-park-outline:view-grid-list',
|
||||
'componentPath': '/list/cardList/index.vue',
|
||||
'id': 12,
|
||||
'pid': 10,
|
||||
},
|
||||
{
|
||||
name: 'draggableList',
|
||||
path: '/list/draggable-list',
|
||||
title: '拖拽列表',
|
||||
requiresAuth: true,
|
||||
icon: 'icon-park-outline:menu-fold',
|
||||
componentPath: '/demo/list/draggable-list/index.vue',
|
||||
id: 303,
|
||||
pid: 3,
|
||||
'name': 'demo',
|
||||
'path': '/demo',
|
||||
'meta.title': '功能示例',
|
||||
'meta.requiresAuth': true,
|
||||
'meta.icon': 'icon-park-outline:application-one',
|
||||
'meta.menuType': 'dir',
|
||||
'componentPath': null,
|
||||
'id': 13,
|
||||
'pid': null,
|
||||
},
|
||||
{
|
||||
name: 'demo',
|
||||
path: '/demo',
|
||||
title: '功能示例',
|
||||
requiresAuth: true,
|
||||
icon: 'icon-park-outline:application-one',
|
||||
menuType: 'dir',
|
||||
componentPath: null,
|
||||
id: 4,
|
||||
pid: null,
|
||||
'name': 'fetch',
|
||||
'path': '/demo/fetch',
|
||||
'meta.title': '请求示例',
|
||||
'meta.requiresAuth': true,
|
||||
'meta.icon': 'icon-park-outline:international',
|
||||
'componentPath': '/demo/fetch/index.vue',
|
||||
'id': 5,
|
||||
'pid': 13,
|
||||
},
|
||||
{
|
||||
name: 'fetch',
|
||||
path: '/demo/fetch',
|
||||
title: '请求示例',
|
||||
requiresAuth: true,
|
||||
icon: 'icon-park-outline:international',
|
||||
componentPath: '/demo/fetch/index.vue',
|
||||
id: 401,
|
||||
pid: 4,
|
||||
'name': 'echarts',
|
||||
'path': '/demo/echarts',
|
||||
'meta.title': 'ECharts',
|
||||
'meta.requiresAuth': true,
|
||||
'meta.icon': 'icon-park-outline:chart-proportion',
|
||||
'componentPath': '/demo/echarts/index.vue',
|
||||
'id': 15,
|
||||
'pid': 13,
|
||||
},
|
||||
{
|
||||
name: 'echarts',
|
||||
path: '/demo/echarts',
|
||||
title: 'ECharts',
|
||||
requiresAuth: true,
|
||||
icon: 'icon-park-outline:chart-proportion',
|
||||
componentPath: '/demo/echarts/index.vue',
|
||||
id: 402,
|
||||
pid: 4,
|
||||
'name': 'map',
|
||||
'path': '/demo/map',
|
||||
'meta.title': '地图',
|
||||
'meta.requiresAuth': true,
|
||||
'meta.icon': 'carbon:map',
|
||||
'meta.keepAlive': true,
|
||||
'componentPath': '/demo/map/index.vue',
|
||||
'id': 17,
|
||||
'pid': 13,
|
||||
},
|
||||
{
|
||||
name: 'map',
|
||||
path: '/demo/map',
|
||||
title: '地图',
|
||||
requiresAuth: true,
|
||||
icon: 'carbon:map',
|
||||
keepAlive: true,
|
||||
componentPath: '/demo/map/index.vue',
|
||||
id: 403,
|
||||
pid: 4,
|
||||
'name': 'editor',
|
||||
'path': '/demo/editor',
|
||||
'meta.title': '编辑器',
|
||||
'meta.requiresAuth': true,
|
||||
'meta.icon': 'icon-park-outline:editor',
|
||||
'meta.menuType': 'dir',
|
||||
'componentPath': null,
|
||||
'id': 18,
|
||||
'pid': 13,
|
||||
},
|
||||
{
|
||||
name: 'editor',
|
||||
path: '/demo/editor',
|
||||
title: '编辑器',
|
||||
requiresAuth: true,
|
||||
icon: 'icon-park-outline:editor',
|
||||
menuType: 'dir',
|
||||
componentPath: null,
|
||||
id: 404,
|
||||
pid: 4,
|
||||
'name': 'editorMd',
|
||||
'path': '/demo/editor/md',
|
||||
'meta.title': 'MarkDown',
|
||||
'meta.requiresAuth': true,
|
||||
'meta.icon': 'ri:markdown-line',
|
||||
'componentPath': '/demo/editor/md/index.vue',
|
||||
'id': 19,
|
||||
'pid': 18,
|
||||
},
|
||||
{
|
||||
name: 'editorMd',
|
||||
path: '/demo/editor/md',
|
||||
title: 'MarkDown',
|
||||
requiresAuth: true,
|
||||
icon: 'ri:markdown-line',
|
||||
componentPath: '/demo/editor/md/index.vue',
|
||||
id: 40401,
|
||||
pid: 404,
|
||||
'name': 'editorRich',
|
||||
'path': '/demo/editor/rich',
|
||||
'meta.title': '富文本',
|
||||
'meta.requiresAuth': true,
|
||||
'meta.icon': 'icon-park-outline:edit-one',
|
||||
'componentPath': '/demo/editor/rich/index.vue',
|
||||
'id': 20,
|
||||
'pid': 18,
|
||||
},
|
||||
{
|
||||
name: 'editorRich',
|
||||
path: '/demo/editor/rich',
|
||||
title: '富文本',
|
||||
requiresAuth: true,
|
||||
icon: 'icon-park-outline:edit-one',
|
||||
componentPath: '/demo/editor/rich/index.vue',
|
||||
id: 40402,
|
||||
pid: 404,
|
||||
'name': 'clipboard',
|
||||
'path': '/demo/clipboard',
|
||||
'meta.title': '剪贴板',
|
||||
'meta.requiresAuth': true,
|
||||
'meta.icon': 'icon-park-outline:clipboard',
|
||||
'componentPath': '/demo/clipboard/index.vue',
|
||||
'id': 21,
|
||||
'pid': 13,
|
||||
},
|
||||
{
|
||||
name: 'clipboard',
|
||||
path: '/demo/clipboard',
|
||||
title: '剪贴板',
|
||||
requiresAuth: true,
|
||||
icon: 'icon-park-outline:clipboard',
|
||||
componentPath: '/demo/clipboard/index.vue',
|
||||
id: 405,
|
||||
pid: 4,
|
||||
'name': 'icons',
|
||||
'path': '/demo/icons',
|
||||
'meta.title': '图标',
|
||||
'meta.requiresAuth': true,
|
||||
'meta.icon': 'icon-park-outline:winking-face-with-open-eyes',
|
||||
'componentPath': '/demo/icons/index.vue',
|
||||
'id': 22,
|
||||
'pid': 13,
|
||||
},
|
||||
{
|
||||
name: 'icons',
|
||||
path: '/demo/icons',
|
||||
title: '图标',
|
||||
requiresAuth: true,
|
||||
icon: 'local:cool',
|
||||
componentPath: '/demo/icons/index.vue',
|
||||
id: 406,
|
||||
pid: 4,
|
||||
'name': 'QRCode',
|
||||
'path': '/demo/QRCode',
|
||||
'meta.title': '二维码',
|
||||
'meta.requiresAuth': true,
|
||||
'meta.icon': 'icon-park-outline:two-dimensional-code',
|
||||
'componentPath': '/demo/QRCode/index.vue',
|
||||
'id': 23,
|
||||
'pid': 13,
|
||||
},
|
||||
{
|
||||
name: 'QRCode',
|
||||
path: '/demo/qr-code',
|
||||
title: '二维码',
|
||||
requiresAuth: true,
|
||||
icon: 'icon-park-outline:two-dimensional-code',
|
||||
componentPath: '/demo/qr-code/index.vue',
|
||||
id: 407,
|
||||
pid: 4,
|
||||
'name': 'docments',
|
||||
'path': '/docments',
|
||||
'meta.title': '外链文档',
|
||||
'meta.requiresAuth': true,
|
||||
'meta.icon': 'icon-park-outline:file-doc',
|
||||
'meta.menuType': 'dir',
|
||||
'componentPath': null,
|
||||
'id': 24,
|
||||
'pid': null,
|
||||
},
|
||||
{
|
||||
name: 'cascader',
|
||||
path: '/demo/cascader',
|
||||
title: '省市区联动',
|
||||
requiresAuth: true,
|
||||
icon: 'icon-park-outline:add-subset',
|
||||
componentPath: '/demo/cascader/index.vue',
|
||||
id: 408,
|
||||
pid: 4,
|
||||
'name': 'docmentsVue',
|
||||
'path': '/docments/vue',
|
||||
'meta.title': 'Vue',
|
||||
'meta.requiresAuth': true,
|
||||
'meta.icon': 'logos:vue',
|
||||
'componentPath': '/docments/vue/index.vue',
|
||||
'id': 25,
|
||||
'pid': 24,
|
||||
},
|
||||
{
|
||||
name: 'dict',
|
||||
path: '/demo/dict',
|
||||
title: '字典示例',
|
||||
requiresAuth: true,
|
||||
icon: 'icon-park-outline:book-one',
|
||||
componentPath: '/demo/dict/index.vue',
|
||||
id: 409,
|
||||
pid: 4,
|
||||
'name': 'docmentsVite',
|
||||
'path': '/docments/vite',
|
||||
'meta.title': 'Vite',
|
||||
'meta.requiresAuth': true,
|
||||
'meta.icon': 'logos:vitejs',
|
||||
'componentPath': '/docments/vite/index.vue',
|
||||
'id': 26,
|
||||
'pid': 24,
|
||||
},
|
||||
{
|
||||
name: 'documents',
|
||||
path: '/documents',
|
||||
title: '外链文档',
|
||||
requiresAuth: true,
|
||||
icon: 'icon-park-outline:file-doc',
|
||||
menuType: 'dir',
|
||||
componentPath: null,
|
||||
id: 5,
|
||||
pid: null,
|
||||
'name': 'docmentsVueuse',
|
||||
'path': '/docments/vueuse',
|
||||
'meta.title': 'VueUse(外链)',
|
||||
'meta.requiresAuth': true,
|
||||
'meta.icon': 'logos:vueuse',
|
||||
'meta.herf': 'https://vueuse.org/guide/',
|
||||
'componentPath': 'null',
|
||||
'id': 27,
|
||||
'pid': 24,
|
||||
},
|
||||
{
|
||||
name: 'documentsVue',
|
||||
path: '/documents/vue',
|
||||
title: 'Vue',
|
||||
requiresAuth: true,
|
||||
icon: 'logos:vue',
|
||||
componentPath: '/demo/documents/vue/index.vue',
|
||||
id: 501,
|
||||
pid: 5,
|
||||
'name': 'permission',
|
||||
'path': '/permission',
|
||||
'meta.title': '权限',
|
||||
'meta.requiresAuth': true,
|
||||
'meta.icon': 'icon-park-outline:people-safe',
|
||||
'meta.menuType': 'dir',
|
||||
'componentPath': null,
|
||||
'id': 28,
|
||||
'pid': null,
|
||||
},
|
||||
{
|
||||
name: 'documentsVite',
|
||||
path: '/documents/vite',
|
||||
title: 'Vite',
|
||||
requiresAuth: true,
|
||||
icon: 'logos:vitejs',
|
||||
componentPath: '/demo/documents/vite/index.vue',
|
||||
id: 502,
|
||||
pid: 5,
|
||||
'name': 'permissionDemo',
|
||||
'path': '/permission/permission',
|
||||
'meta.title': '权限示例',
|
||||
'meta.requiresAuth': true,
|
||||
'meta.icon': 'icon-park-outline:right-user',
|
||||
'componentPath': '/permission/permission/index.vue',
|
||||
'id': 29,
|
||||
'pid': 28,
|
||||
},
|
||||
{
|
||||
name: 'documentsVueuse',
|
||||
path: '/documents/vue-use',
|
||||
title: 'VueUse(外链)',
|
||||
requiresAuth: true,
|
||||
icon: 'logos:vueuse',
|
||||
href: 'https://vueuse.org/guide/',
|
||||
componentPath: 'null',
|
||||
id: 503,
|
||||
pid: 5,
|
||||
},
|
||||
|
||||
{
|
||||
name: 'documentsNova',
|
||||
path: '/documents/nova',
|
||||
title: 'Nova docs',
|
||||
requiresAuth: true,
|
||||
icon: 'local:logo',
|
||||
href: 'https://nova-admin-docs.netlify.app/',
|
||||
componentPath: '2333333',
|
||||
id: 504,
|
||||
pid: 5,
|
||||
},
|
||||
{
|
||||
name: 'documentsPublic',
|
||||
path: '/documents/public',
|
||||
title: '公共示例页(外链)',
|
||||
requiresAuth: true,
|
||||
icon: 'local:logo',
|
||||
href: '/public',
|
||||
componentPath: 'null',
|
||||
id: 505,
|
||||
pid: 5,
|
||||
},
|
||||
{
|
||||
name: 'permission',
|
||||
path: '/permission',
|
||||
title: '权限',
|
||||
requiresAuth: true,
|
||||
icon: 'icon-park-outline:people-safe',
|
||||
menuType: 'dir',
|
||||
componentPath: null,
|
||||
id: 6,
|
||||
pid: null,
|
||||
},
|
||||
{
|
||||
name: 'permissionDemo',
|
||||
path: '/permission/permission',
|
||||
title: '权限示例',
|
||||
requiresAuth: true,
|
||||
icon: 'icon-park-outline:right-user',
|
||||
componentPath: '/demo/permission/permission/index.vue',
|
||||
id: 601,
|
||||
pid: 6,
|
||||
},
|
||||
{
|
||||
name: 'justSuper',
|
||||
path: '/permission/just-super',
|
||||
title: 'super可见',
|
||||
requiresAuth: true,
|
||||
roles: [
|
||||
'name': 'justSuper',
|
||||
'path': '/permission/justSuper',
|
||||
'meta.title': 'super可见',
|
||||
'meta.requiresAuth': true,
|
||||
'meta.roles': [
|
||||
'super',
|
||||
],
|
||||
icon: 'icon-park-outline:wrong-user',
|
||||
componentPath: '/demo/permission/just-super/index.vue',
|
||||
id: 602,
|
||||
pid: 6,
|
||||
'meta.icon': 'icon-park-outline:wrong-user',
|
||||
'componentPath': '/permission/justSuper/index.vue',
|
||||
'id': 30,
|
||||
'pid': 28,
|
||||
},
|
||||
{
|
||||
name: 'setting',
|
||||
path: '/setting',
|
||||
title: '系统设置',
|
||||
requiresAuth: true,
|
||||
icon: 'icon-park-outline:setting',
|
||||
menuType: 'dir',
|
||||
componentPath: null,
|
||||
id: 7,
|
||||
pid: null,
|
||||
'name': 'error',
|
||||
'path': '/error',
|
||||
'meta.title': '异常页',
|
||||
'meta.requiresAuth': true,
|
||||
'meta.icon': 'icon-park-outline:error-computer',
|
||||
'meta.menuType': 'dir',
|
||||
'componentPath': null,
|
||||
'id': 31,
|
||||
'pid': null,
|
||||
},
|
||||
{
|
||||
name: 'accountSetting',
|
||||
path: '/setting/account',
|
||||
title: '用户设置',
|
||||
requiresAuth: true,
|
||||
icon: 'icon-park-outline:every-user',
|
||||
componentPath: '/setting/account/index.vue',
|
||||
id: 701,
|
||||
pid: 7,
|
||||
'name': 'demo403',
|
||||
'path': '/error/403',
|
||||
'meta.title': '403',
|
||||
'meta.requiresAuth': true,
|
||||
'meta.icon': 'carbon:error',
|
||||
'meta.order': 3,
|
||||
'componentPath': '/error/403/index.vue',
|
||||
'id': 32,
|
||||
'pid': 31,
|
||||
},
|
||||
{
|
||||
name: 'dictionarySetting',
|
||||
path: '/setting/dictionary',
|
||||
title: '字典设置',
|
||||
requiresAuth: true,
|
||||
icon: 'icon-park-outline:book-one',
|
||||
componentPath: '/setting/dictionary/index.vue',
|
||||
id: 702,
|
||||
pid: 7,
|
||||
'name': 'demo404',
|
||||
'path': '/error/404',
|
||||
'meta.title': '404',
|
||||
'meta.requiresAuth': true,
|
||||
'meta.icon': 'icon-park-outline:error',
|
||||
'meta.order': 2,
|
||||
'componentPath': '/error/404/index.vue',
|
||||
'id': 33,
|
||||
'pid': 31,
|
||||
},
|
||||
{
|
||||
name: 'menuSetting',
|
||||
path: '/setting/menu',
|
||||
title: '菜单设置',
|
||||
requiresAuth: true,
|
||||
icon: 'icon-park-outline:application-menu',
|
||||
componentPath: '/setting/menu/index.vue',
|
||||
id: 703,
|
||||
pid: 7,
|
||||
'name': 'demo500',
|
||||
'path': '/error/500',
|
||||
'meta.title': '500',
|
||||
'meta.requiresAuth': true,
|
||||
'meta.icon': 'carbon:data-error',
|
||||
'meta.order': 1,
|
||||
'componentPath': '/error/500/index.vue',
|
||||
'id': 34,
|
||||
'pid': 31,
|
||||
},
|
||||
{
|
||||
name: 'about',
|
||||
path: '/about',
|
||||
title: '关于',
|
||||
requiresAuth: true,
|
||||
icon: 'icon-park-outline:info',
|
||||
componentPath: '/demo/about/index.vue',
|
||||
id: 8,
|
||||
pid: null,
|
||||
'name': 'setting',
|
||||
'path': '/setting',
|
||||
'meta.title': '系统设置',
|
||||
'meta.requiresAuth': true,
|
||||
'meta.icon': 'icon-park-outline:setting',
|
||||
'meta.menuType': 'dir',
|
||||
'componentPath': null,
|
||||
'id': 35,
|
||||
'pid': null,
|
||||
},
|
||||
{
|
||||
name: 'userCenter',
|
||||
path: '/user-center',
|
||||
title: '个人中心',
|
||||
requiresAuth: true,
|
||||
hide: true,
|
||||
icon: 'carbon:user-avatar-filled-alt',
|
||||
componentPath: '/build-in/user-center/index.vue',
|
||||
id: 999,
|
||||
pid: null,
|
||||
'name': 'accountSetting',
|
||||
'path': '/setting/account',
|
||||
'meta.title': '用户设置',
|
||||
'meta.requiresAuth': true,
|
||||
'meta.icon': 'icon-park-outline:every-user',
|
||||
'componentPath': '/setting/account/index.vue',
|
||||
'id': 36,
|
||||
'pid': 35,
|
||||
},
|
||||
{
|
||||
'name': 'dictionarySetting',
|
||||
'path': '/setting/dictionary',
|
||||
'meta.title': '字典设置',
|
||||
'meta.requiresAuth': true,
|
||||
'meta.icon': 'icon-park-outline:book-one',
|
||||
'componentPath': '/setting/dictionary/index.vue',
|
||||
'id': 37,
|
||||
'pid': 35,
|
||||
},
|
||||
{
|
||||
'name': 'menuSetting',
|
||||
'path': '/setting/menu',
|
||||
'meta.title': '菜单设置',
|
||||
'meta.requiresAuth': true,
|
||||
'meta.icon': 'icon-park-outline:application-menu',
|
||||
'componentPath': '/setting/menu/index.vue',
|
||||
'id': 38,
|
||||
'pid': 35,
|
||||
},
|
||||
{
|
||||
'name': 'userCenter',
|
||||
'path': '/userCenter',
|
||||
'meta.title': '个人中心',
|
||||
'meta.requiresAuth': true,
|
||||
'meta.icon': 'carbon:user-avatar-filled-alt',
|
||||
'componentPath': '/userCenter/index.vue',
|
||||
'id': 39,
|
||||
'pid': null,
|
||||
},
|
||||
{
|
||||
'name': 'about',
|
||||
'path': '/about',
|
||||
'meta.title': '关于',
|
||||
'meta.requiresAuth': true,
|
||||
'meta.icon': 'icon-park-outline:info',
|
||||
'componentPath': '/about/index.vue',
|
||||
'id': 40,
|
||||
'pid': null,
|
||||
},
|
||||
|
||||
]
|
||||
|
||||
@ -5,15 +5,15 @@ interface Ilogin {
|
||||
password: string
|
||||
}
|
||||
|
||||
export function fetchLogin(data: Ilogin) {
|
||||
const methodInstance = request.Post<Service.ResponseResult<Api.Login.Info>>('/login', data)
|
||||
export function fetchLogin(params: Ilogin) {
|
||||
const methodInstance = request.Post<Service.ResponseResult<ApiAuth.loginInfo>>('/login', params)
|
||||
methodInstance.meta = {
|
||||
authRole: null,
|
||||
}
|
||||
return methodInstance
|
||||
}
|
||||
export function fetchUpdateToken(data: any) {
|
||||
const method = request.Post<Service.ResponseResult<Api.Login.Info>>('/updateToken', data)
|
||||
const method = request.Post<Service.ResponseResult<ApiAuth.loginInfo>>('/updateToken', data)
|
||||
method.meta = {
|
||||
authRole: 'refreshToken',
|
||||
}
|
||||
@ -21,5 +21,5 @@ export function fetchUpdateToken(data: any) {
|
||||
}
|
||||
|
||||
export function fetchUserRoutes(params: { id: number }) {
|
||||
return request.Get<Service.ResponseResult<AppRoute.RowRoute[]>>('/getUserRoutes', { params })
|
||||
return request.Get<Service.ResponseResult<AppRoute.RowRoute[]> >('/getUserRoutes', { params })
|
||||
}
|
||||
|
||||
@ -1,26 +1,5 @@
|
||||
import { request } from '../http'
|
||||
|
||||
// 获取所有路由信息
|
||||
export function fetchAllRoutes() {
|
||||
return request.Get<Service.ResponseResult<AppRoute.RowRoute[]>>('/getUserRoutes')
|
||||
}
|
||||
|
||||
// 获取所有用户信息
|
||||
export function fetchUserPage() {
|
||||
return request.Get<Service.ResponseResult<Entity.User[]>>('/userPage')
|
||||
}
|
||||
// 获取所有角色列表
|
||||
export function fetchRoleList() {
|
||||
return request.Get<Service.ResponseResult<Entity.Role[]>>('/role/list')
|
||||
}
|
||||
|
||||
/**
|
||||
* 请求获取字典列表
|
||||
*
|
||||
* @param code - 字典编码,用于筛选特定的字典列表
|
||||
* @returns 返回的字典列表数据
|
||||
*/
|
||||
export function fetchDictList(code?: string) {
|
||||
const params = { code }
|
||||
return request.Get<Service.ResponseResult<Entity.Dict[]>>('/dict/list', { params })
|
||||
return request.Get<Service.ResponseResult<AppRoute.RowRoute[]> >('/getUserRoutes')
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { blankInstance, request } from '../http'
|
||||
|
||||
/* get方法测试 */
|
||||
export function fetchGet(params?: any) {
|
||||
export function fetachGet(params?: any) {
|
||||
return request.Get('/getAPI', { params })
|
||||
}
|
||||
|
||||
@ -36,7 +36,7 @@ export function withoutToken() {
|
||||
/* 接口数据转换 */
|
||||
export function dictData() {
|
||||
return request.Get('/getDictData', {
|
||||
transform(rawData, _headers) {
|
||||
transformData(rawData, _headers) {
|
||||
const response = rawData as any
|
||||
return {
|
||||
...response,
|
||||
@ -53,7 +53,7 @@ export function dictData() {
|
||||
export function getBlob(url: string) {
|
||||
const methodInstance = blankInstance.Get<Blob>(url)
|
||||
methodInstance.meta = {
|
||||
// 标识为blob数据
|
||||
// 标识为bolb数据
|
||||
isBlob: true,
|
||||
}
|
||||
return methodInstance
|
||||
@ -61,9 +61,12 @@ export function getBlob(url: string) {
|
||||
|
||||
/* 带进度的下载文件 */
|
||||
export function downloadFile(url: string) {
|
||||
const methodInstance = blankInstance.Get<Blob>(url)
|
||||
const methodInstance = blankInstance.Get<Blob>(url, {
|
||||
// 开启下载进度
|
||||
enableDownload: true,
|
||||
})
|
||||
methodInstance.meta = {
|
||||
// 标识为blob数据
|
||||
// 标识为bolb数据
|
||||
isBlob: true,
|
||||
}
|
||||
return methodInstance
|
||||
|
||||
@ -1,39 +1,30 @@
|
||||
import { local } from '@/utils'
|
||||
import { createAlova } from 'alova'
|
||||
import { createServerTokenAuthentication } from 'alova/client'
|
||||
import adapterFetch from 'alova/fetch'
|
||||
import VueHook from 'alova/vue'
|
||||
import type { VueHookType } from 'alova/vue'
|
||||
import {
|
||||
DEFAULT_ALOVA_OPTIONS,
|
||||
DEFAULT_BACKEND_OPTIONS,
|
||||
} from './config'
|
||||
import GlobalFetch from 'alova/GlobalFetch'
|
||||
import { createServerTokenAuthentication } from '@alova/scene-vue'
|
||||
import qs from 'qs'
|
||||
import {
|
||||
handleBusinessError,
|
||||
handleRefreshToken,
|
||||
handleResponseError,
|
||||
handleServiceResult,
|
||||
} from './handle'
|
||||
import {
|
||||
DEFAULT_ALOVA_OPTIONS,
|
||||
DEFAULT_BACKEND_OPTIONS,
|
||||
} from './config'
|
||||
import { local } from '@/utils'
|
||||
|
||||
const { onAuthRequired, onResponseRefreshToken } = createServerTokenAuthentication<VueHookType>({
|
||||
const { onAuthRequired, onResponseRefreshToken } = createServerTokenAuthentication({
|
||||
// 服务端判定token过期
|
||||
refreshTokenOnSuccess: {
|
||||
// 当服务端返回401时,表示token过期
|
||||
isExpired: async (response, method) => {
|
||||
const res = await response.clone().json()
|
||||
|
||||
const isExpired = method.meta && method.meta.isExpired
|
||||
return (response.status === 401 || res.code === 401) && !isExpired
|
||||
isExpired: (response, _method) => {
|
||||
return response.status === 401
|
||||
},
|
||||
|
||||
// 当token过期时触发,在此函数中触发刷新token
|
||||
handler: async (_response, method) => {
|
||||
// 此处采取限制,防止过期请求无限循环重发
|
||||
if (!method.meta)
|
||||
method.meta = { isExpired: true }
|
||||
else
|
||||
method.meta.isExpired = true
|
||||
|
||||
handler: async (_response, _method) => {
|
||||
await handleRefreshToken()
|
||||
},
|
||||
},
|
||||
@ -53,15 +44,15 @@ export function createAlovaInstance(
|
||||
|
||||
return createAlova({
|
||||
statesHook: VueHook,
|
||||
requestAdapter: adapterFetch(),
|
||||
cacheFor: null,
|
||||
requestAdapter: GlobalFetch(),
|
||||
localCache: null,
|
||||
baseURL: _alovaConfig.baseURL,
|
||||
timeout: _alovaConfig.timeout,
|
||||
|
||||
beforeRequest: onAuthRequired((method) => {
|
||||
if (method.meta?.isFormPost) {
|
||||
method.config.headers['Content-Type'] = 'application/x-www-form-urlencoded'
|
||||
method.data = new URLSearchParams(method.data as URLSearchParams).toString()
|
||||
method.data = qs.stringify(method.data)
|
||||
}
|
||||
alovaConfig.beforeRequest?.(method)
|
||||
}),
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import { $t } from '@/utils'
|
||||
|
||||
/** 默认实例的Aixos配置 */
|
||||
export const DEFAULT_ALOVA_OPTIONS = {
|
||||
// 请求超时时间,默认15秒
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
import { fetchUpdateToken } from '@/service'
|
||||
import { useAuthStore } from '@/store'
|
||||
import { local } from '@/utils'
|
||||
import {
|
||||
ERROR_NO_TIP_STATUS,
|
||||
ERROR_STATUS,
|
||||
} from './config'
|
||||
import { useAuthStore } from '@/store'
|
||||
import { fetchUpdateToken } from '@/service'
|
||||
import { local } from '@/utils'
|
||||
|
||||
type ErrorStatus = keyof typeof ERROR_STATUS
|
||||
|
||||
@ -70,13 +70,6 @@ export function handleServiceResult(data: any, isSuccess: boolean = true) {
|
||||
*/
|
||||
export async function handleRefreshToken() {
|
||||
const authStore = useAuthStore()
|
||||
const isAutoRefresh = import.meta.env.VITE_AUTO_REFRESH_TOKEN === 'Y'
|
||||
if (!isAutoRefresh) {
|
||||
await authStore.logout()
|
||||
return
|
||||
}
|
||||
|
||||
// 刷新token
|
||||
const { data } = await fetchUpdateToken({ refreshToken: local.get('refreshToken') })
|
||||
if (data) {
|
||||
local.set('accessToken', data.accessToken)
|
||||
@ -84,7 +77,7 @@ export async function handleRefreshToken() {
|
||||
}
|
||||
else {
|
||||
// 刷新失败,退出
|
||||
await authStore.logout()
|
||||
await authStore.resetAuthStore()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,7 +1,13 @@
|
||||
import { createAlovaInstance } from './alova'
|
||||
import { serviceConfig } from '@/../service.config'
|
||||
import { generateProxyPattern } from '@/../build/proxy'
|
||||
|
||||
const isHttpProxy = import.meta.env.DEV && import.meta.env.VITE_HTTP_PROXY === 'Y'
|
||||
|
||||
const { url } = generateProxyPattern(serviceConfig[import.meta.env.MODE])
|
||||
|
||||
export const request = createAlovaInstance({
|
||||
baseURL: __URL_MAP__.url.path,
|
||||
baseURL: isHttpProxy ? url.proxy : url.value,
|
||||
})
|
||||
|
||||
export const blankInstance = createAlovaInstance({
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
export * from './api/list'
|
||||
export * from './api/login'
|
||||
export * from './api/system'
|
||||
export * from './api/login'
|
||||
export * from './api/list'
|
||||
export * from './api/test'
|
||||
|
||||
@ -1,13 +1,11 @@
|
||||
import type { GlobalThemeOverrides } from 'naive-ui'
|
||||
import { local, setLocale } from '@/utils'
|
||||
import { colord } from 'colord'
|
||||
import { set } from 'radash'
|
||||
import themeConfig from './theme.json'
|
||||
import type { ProLayoutMode } from 'pro-naive-ui'
|
||||
import { local, setLocale } from '@/utils'
|
||||
|
||||
export type TransitionAnimation = '' | 'fade-slide' | 'fade-bottom' | 'fade-scale' | 'zoom-fade' | 'zoom-out'
|
||||
|
||||
const { VITE_DEFAULT_LANG, VITE_COPYRIGHT_INFO } = import.meta.env
|
||||
type TransitionAnimation = '' | 'fade-slide' | 'fade-bottom' | 'fade-scale' | 'zoom-fade' | 'zoom-out'
|
||||
export type LayoutMode = 'leftMenu' | 'topMenu'
|
||||
|
||||
const docEle = ref(document.documentElement)
|
||||
|
||||
@ -17,13 +15,11 @@ const { system, store } = useColorMode({
|
||||
emitAuto: true,
|
||||
})
|
||||
|
||||
const isMobile = useMediaQuery('(max-width: 700px)')
|
||||
|
||||
export const useAppStore = defineStore('app-store', {
|
||||
state: () => {
|
||||
return {
|
||||
footerText: VITE_COPYRIGHT_INFO,
|
||||
lang: VITE_DEFAULT_LANG,
|
||||
footerText: 'Copyright © 2024 chansee97',
|
||||
lang: 'enUS' as App.lang,
|
||||
theme: themeConfig as GlobalThemeOverrides,
|
||||
primaryColor: themeConfig.common.primaryColor,
|
||||
collapsed: false,
|
||||
@ -37,9 +33,8 @@ export const useAppStore = defineStore('app-store', {
|
||||
showBreadcrumb: true,
|
||||
showBreadcrumbIcon: true,
|
||||
showWatermark: false,
|
||||
showSetting: false,
|
||||
transitionAnimation: 'fade-slide' as TransitionAnimation,
|
||||
layoutMode: 'vertical' as ProLayoutMode,
|
||||
layoutMode: 'leftMenu' as LayoutMode,
|
||||
}
|
||||
},
|
||||
getters: {
|
||||
@ -52,9 +47,6 @@ export const useAppStore = defineStore('app-store', {
|
||||
fullScreen() {
|
||||
return isFullscreen.value
|
||||
},
|
||||
isMobile() {
|
||||
return isMobile.value
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
// 重置所有设置
|
||||
@ -67,12 +59,13 @@ export const useAppStore = defineStore('app-store', {
|
||||
this.loadFlag = true
|
||||
this.showLogo = true
|
||||
this.showTabs = true
|
||||
this.showLogo = true
|
||||
this.showFooter = true
|
||||
this.showBreadcrumb = true
|
||||
this.showBreadcrumbIcon = true
|
||||
this.showWatermark = false
|
||||
this.transitionAnimation = 'fade-slide'
|
||||
this.layoutMode = 'vertical'
|
||||
this.layoutMode = 'leftMenu'
|
||||
|
||||
// 重置所有配色
|
||||
this.setPrimaryColor(this.primaryColor)
|
||||
@ -84,7 +77,7 @@ export const useAppStore = defineStore('app-store', {
|
||||
},
|
||||
/* 设置主题色 */
|
||||
setPrimaryColor(color: string) {
|
||||
const brightenColor = colord(color).lighten(0.05).toHex()
|
||||
const brightenColor = colord(color).lighten(0.1).toHex()
|
||||
const darkenColor = colord(color).darken(0.05).toHex()
|
||||
set(this.theme, 'common.primaryColor', color)
|
||||
set(this.theme, 'common.primaryColorHover', brightenColor)
|
||||
@ -131,6 +124,11 @@ export const useAppStore = defineStore('app-store', {
|
||||
},
|
||||
},
|
||||
persist: {
|
||||
storage: localStorage,
|
||||
enabled: true,
|
||||
strategies: [
|
||||
{
|
||||
storage: localStorage,
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
import { router } from '@/router'
|
||||
import { fetchLogin } from '@/service'
|
||||
import { local } from '@/utils'
|
||||
import { useRouteStore } from './router'
|
||||
import { useRouteStore } from './route'
|
||||
import { useTabStore } from './tab'
|
||||
import { fetchLogin } from '@/service'
|
||||
import { router } from '@/router'
|
||||
import { local } from '@/utils'
|
||||
|
||||
interface AuthStatus {
|
||||
userInfo: Api.Login.Info | null
|
||||
userInfo: ApiAuth.loginInfo | null
|
||||
token: string
|
||||
}
|
||||
export const useAuthStore = defineStore('auth-store', {
|
||||
@ -23,7 +23,7 @@ export const useAuthStore = defineStore('auth-store', {
|
||||
},
|
||||
actions: {
|
||||
/* 登录退出,重置用户信息等 */
|
||||
async logout() {
|
||||
async resetAuthStore() {
|
||||
const route = unref(router.currentRoute)
|
||||
// 清除本地缓存
|
||||
this.clearAuthStorage()
|
||||
@ -33,7 +33,7 @@ export const useAuthStore = defineStore('auth-store', {
|
||||
// 清空标签栏数据
|
||||
const tabStore = useTabStore()
|
||||
tabStore.clearAllTabs()
|
||||
// 重置当前存储库
|
||||
// 重制当前存储库
|
||||
this.$reset()
|
||||
// 重定向到登录页
|
||||
if (route.meta.requiresAuth) {
|
||||
@ -53,21 +53,16 @@ export const useAuthStore = defineStore('auth-store', {
|
||||
|
||||
/* 用户登录 */
|
||||
async login(userName: string, password: string) {
|
||||
try {
|
||||
const { isSuccess, data } = await fetchLogin({ userName, password })
|
||||
if (!isSuccess)
|
||||
return
|
||||
const { isSuccess, data } = await fetchLogin({ userName, password })
|
||||
if (!isSuccess)
|
||||
return
|
||||
|
||||
// 处理登录信息
|
||||
await this.handleLoginInfo(data)
|
||||
}
|
||||
catch (e) {
|
||||
console.warn('[Login Error]:', e)
|
||||
}
|
||||
// 处理登录信息
|
||||
await this.handleAfterLogin(data)
|
||||
},
|
||||
|
||||
/* 处理登录返回的数据 */
|
||||
async handleLoginInfo(data: Api.Login.Info) {
|
||||
/* 登录后的处理函数 */
|
||||
async handleAfterLogin(data: ApiAuth.loginInfo) {
|
||||
// 将token和userInfo保存下来
|
||||
local.set('userInfo', data)
|
||||
local.set('accessToken', data.accessToken)
|
||||
|
||||
@ -1,58 +0,0 @@
|
||||
import { fetchDictList } from '@/service'
|
||||
import { session } from '@/utils'
|
||||
|
||||
export const useDictStore = defineStore('dict-store', {
|
||||
state: () => {
|
||||
return {
|
||||
dictMap: {} as DictMap,
|
||||
isInitDict: false,
|
||||
}
|
||||
},
|
||||
actions: {
|
||||
async dict(code: string) {
|
||||
// 调用前初始化
|
||||
if (!this.dictMap) {
|
||||
this.initDict()
|
||||
}
|
||||
|
||||
const targetDict = await this.getDict(code)
|
||||
|
||||
return {
|
||||
data: () => targetDict,
|
||||
enum: () => Object.fromEntries(targetDict.map(({ value, label }) => [value, label])),
|
||||
valueMap: () => Object.fromEntries(targetDict.map(({ value, ...data }) => [value, data])),
|
||||
labelMap: () => Object.fromEntries(targetDict.map(({ label, ...data }) => [label, data])),
|
||||
}
|
||||
},
|
||||
async getDict(code: string) {
|
||||
const isExist = Reflect.has(this.dictMap, code)
|
||||
|
||||
if (isExist) {
|
||||
return this.dictMap[code]
|
||||
}
|
||||
else {
|
||||
return await this.getDictByNet(code)
|
||||
}
|
||||
},
|
||||
|
||||
async getDictByNet(code: string) {
|
||||
const { data, isSuccess } = await fetchDictList(code)
|
||||
if (isSuccess) {
|
||||
Reflect.set(this.dictMap, code, data)
|
||||
// 同步至session
|
||||
session.set('dict', this.dictMap)
|
||||
return data
|
||||
}
|
||||
else {
|
||||
throw new Error(`Failed to get ${code} dictionary from network, check ${code} field or network`)
|
||||
}
|
||||
},
|
||||
initDict() {
|
||||
const dict = session.get('dict')
|
||||
if (dict) {
|
||||
Object.assign(this.dictMap, dict)
|
||||
}
|
||||
this.isInitDict = true
|
||||
},
|
||||
},
|
||||
})
|
||||
@ -1,15 +1,14 @@
|
||||
import type { App } from 'vue'
|
||||
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
|
||||
import piniaPluginPersist from 'pinia-plugin-persist'
|
||||
|
||||
export * from './app/index'
|
||||
export * from './auth'
|
||||
export * from './dict'
|
||||
export * from './router'
|
||||
export * from './route'
|
||||
export * from './tab'
|
||||
|
||||
// 安装pinia全局状态库
|
||||
export function installPinia(app: App) {
|
||||
const pinia = createPinia()
|
||||
pinia.use(piniaPluginPersistedstate)
|
||||
pinia.use(piniaPluginPersist)
|
||||
app.use(pinia)
|
||||
}
|
||||
|
||||
207
src/store/route.ts
Normal file
207
src/store/route.ts
Normal file
@ -0,0 +1,207 @@
|
||||
import type { MenuOption } from 'naive-ui'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { h } from 'vue'
|
||||
import { clone, construct, min } from 'radash'
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
import { $t, arrayToTree, local, renderIcon } from '@/utils'
|
||||
import { router } from '@/router'
|
||||
import { fetchUserRoutes } from '@/service'
|
||||
import { staticRoutes } from '@/router/routes.static'
|
||||
import { usePermission } from '@/hooks'
|
||||
import Layout from '@/layouts/index.vue'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
|
||||
interface RoutesStatus {
|
||||
isInitAuthRoute: boolean
|
||||
menus: AppRoute.Route[]
|
||||
rowRoutes: AppRoute.RowRoute[]
|
||||
activeMenu: string | null
|
||||
cacheRoutes: string[]
|
||||
}
|
||||
export const useRouteStore = defineStore('route-store', {
|
||||
state: (): RoutesStatus => {
|
||||
return {
|
||||
isInitAuthRoute: false,
|
||||
menus: [],
|
||||
rowRoutes: [],
|
||||
activeMenu: null,
|
||||
cacheRoutes: [],
|
||||
}
|
||||
},
|
||||
actions: {
|
||||
resetRouteStore() {
|
||||
this.resetRoutes()
|
||||
this.$reset()
|
||||
},
|
||||
resetRoutes() {
|
||||
router.removeRoute('appRoot')
|
||||
},
|
||||
// set the currently highlighted menu key
|
||||
setActiveMenu(key: string) {
|
||||
this.activeMenu = key
|
||||
},
|
||||
/* 生成侧边菜单的数据 */
|
||||
createMenus(userRoutes: AppRoute.RowRoute[]) {
|
||||
const resultMenus = clone(userRoutes).map(i => construct(i)) as AppRoute.Route[]
|
||||
|
||||
// filter menus that do not need to be displayed
|
||||
const visibleMenus = resultMenus.filter(route => !route.meta.hide)
|
||||
|
||||
// generate side menu
|
||||
this.menus = arrayToTree(this.transformAuthRoutesToMenus(visibleMenus))
|
||||
},
|
||||
|
||||
// render the returned routing table as a sidebar
|
||||
transformAuthRoutesToMenus(userRoutes: AppRoute.Route[]): MenuOption[] {
|
||||
const { hasPermission } = usePermission()
|
||||
// Filter out side menus without permission
|
||||
return userRoutes.filter(i => hasPermission(i.meta.roles))
|
||||
// Sort the menu according to the order size
|
||||
.sort((a, b) => {
|
||||
if (a.meta && a.meta.order && b.meta && b.meta.order)
|
||||
return a.meta.order - b.meta.order
|
||||
else if (a.meta && a.meta.order)
|
||||
return -1
|
||||
else if (b.meta && b.meta.order)
|
||||
return 1
|
||||
else return 0
|
||||
})
|
||||
|
||||
// Convert to side menu data structure
|
||||
.map((item) => {
|
||||
const target: MenuOption = {
|
||||
id: item.id,
|
||||
pid: item.pid,
|
||||
label:
|
||||
(!item.meta.menuType || item.meta.menuType === 'page')
|
||||
? () =>
|
||||
h(
|
||||
RouterLink,
|
||||
{
|
||||
to: {
|
||||
path: item.path,
|
||||
},
|
||||
},
|
||||
{ default: () => $t(`route.${String(item.name)}`, item.meta.title) },
|
||||
)
|
||||
: () => $t(`route.${String(item.name)}`, item.meta.title),
|
||||
key: item.path,
|
||||
icon: item.meta.icon ? renderIcon(item.meta.icon) : undefined,
|
||||
}
|
||||
return target
|
||||
})
|
||||
},
|
||||
createRoutes(routes: AppRoute.RowRoute[]) {
|
||||
const { hasPermission } = usePermission()
|
||||
|
||||
// Structure the meta field
|
||||
let resultRouter = clone(routes).map(i => construct(i)) as AppRoute.Route[]
|
||||
|
||||
// Route permission filtering
|
||||
resultRouter = resultRouter.filter(i => hasPermission(i.meta.roles))
|
||||
|
||||
// Generate an array of route names that need to be kept alive
|
||||
this.cacheRoutes = resultRouter.filter((i) => {
|
||||
return i.meta.keepAlive
|
||||
})
|
||||
.map(i => i.name)
|
||||
|
||||
// Generate routes, no need to import files for those with redirect
|
||||
const modules = import.meta.glob('@/views/**/*.vue')
|
||||
resultRouter = resultRouter.map((item: AppRoute.Route) => {
|
||||
if (item.componentPath && !item.redirect)
|
||||
item.component = modules[`/src/views${item.componentPath}`]
|
||||
return item
|
||||
})
|
||||
|
||||
// Generate route tree
|
||||
resultRouter = arrayToTree(resultRouter) as AppRoute.Route[]
|
||||
|
||||
const appRootRoute: RouteRecordRaw = {
|
||||
path: '/appRoot',
|
||||
name: 'appRoot',
|
||||
redirect: import.meta.env.VITE_HOME_PATH,
|
||||
component: Layout,
|
||||
meta: {
|
||||
title: '',
|
||||
icon: 'icon-park-outline:home',
|
||||
},
|
||||
children: [],
|
||||
}
|
||||
|
||||
// Set the correct redirect path for the route
|
||||
this.setRedirect(resultRouter)
|
||||
|
||||
// Insert the processed route into the root route
|
||||
appRootRoute.children = resultRouter as unknown as RouteRecordRaw[]
|
||||
router.addRoute(appRootRoute)
|
||||
},
|
||||
setRedirect(routes: AppRoute.Route[]) {
|
||||
routes.forEach((route) => {
|
||||
if (route.children) {
|
||||
if (!route.redirect) {
|
||||
// Filter out a collection of child elements that are not hidden
|
||||
const visibleChilds = route.children.filter(child => !child.meta.hide)
|
||||
|
||||
// Redirect page to the path of the first child element by default
|
||||
let target = visibleChilds[0]
|
||||
|
||||
// Filter out pages with the order attribute
|
||||
const orderChilds = visibleChilds.filter(child => child.meta.order)
|
||||
|
||||
if (orderChilds.length > 0)
|
||||
target = min(orderChilds, i => i.meta.order!) as AppRoute.Route
|
||||
|
||||
route.redirect = target.path
|
||||
}
|
||||
|
||||
this.setRedirect(route.children)
|
||||
}
|
||||
})
|
||||
},
|
||||
async initRouteInfo() {
|
||||
if (import.meta.env.VITE_AUTH_ROUTE_MODE === 'dynamic') {
|
||||
const userInfo = local.get('userInfo')
|
||||
|
||||
if (!userInfo || !userInfo.id) {
|
||||
const authStore = useAuthStore()
|
||||
authStore.resetAuthStore()
|
||||
return
|
||||
}
|
||||
|
||||
// Get user's route
|
||||
const { data } = await fetchUserRoutes({
|
||||
id: userInfo.id,
|
||||
})
|
||||
|
||||
if (!data)
|
||||
return
|
||||
|
||||
return data
|
||||
}
|
||||
else {
|
||||
this.rowRoutes = staticRoutes
|
||||
return staticRoutes
|
||||
}
|
||||
},
|
||||
async initAuthRoute() {
|
||||
this.isInitAuthRoute = false
|
||||
|
||||
// Initialize route information
|
||||
const rowRoutes = await this.initRouteInfo()
|
||||
if (!rowRoutes) {
|
||||
window.$message.error($t(`app.getRouteError`))
|
||||
return
|
||||
}
|
||||
this.rowRoutes = rowRoutes
|
||||
|
||||
// Generate actual route and insert
|
||||
this.createRoutes(rowRoutes)
|
||||
|
||||
// Generate side menu
|
||||
this.createMenus(rowRoutes)
|
||||
|
||||
this.isInitAuthRoute = true
|
||||
},
|
||||
},
|
||||
})
|
||||
@ -1,143 +0,0 @@
|
||||
import type { MenuOption } from 'naive-ui'
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
import { usePermission } from '@/hooks'
|
||||
import Layout from '@/layouts/index.vue'
|
||||
import { $t, arrayToTree, renderIcon } from '@/utils'
|
||||
import { clone, min, omit, pick } from 'radash'
|
||||
import { RouterLink } from 'vue-router'
|
||||
|
||||
const metaFields: AppRoute.MetaKeys[]
|
||||
= ['title', 'icon', 'requiresAuth', 'roles', 'keepAlive', 'hide', 'order', 'href', 'activeMenu', 'withoutTab', 'pinTab', 'menuType']
|
||||
|
||||
function standardizedRoutes(route: AppRoute.RowRoute[]) {
|
||||
return clone(route).map((i) => {
|
||||
const route = omit(i, metaFields)
|
||||
|
||||
Reflect.set(route, 'meta', pick(i, metaFields))
|
||||
return route
|
||||
}) as AppRoute.Route[]
|
||||
}
|
||||
|
||||
export function createRoutes(routes: AppRoute.RowRoute[]) {
|
||||
const { hasPermission } = usePermission()
|
||||
|
||||
// Structure the meta field
|
||||
let resultRouter = standardizedRoutes(routes)
|
||||
|
||||
// Route permission filtering
|
||||
resultRouter = resultRouter.filter(i => hasPermission(i.meta.roles))
|
||||
|
||||
// Generate routes, no need to import files for those with redirect
|
||||
const modules = import.meta.glob('@/views/**/*.vue')
|
||||
resultRouter = resultRouter.map((item: AppRoute.Route) => {
|
||||
if (item.componentPath && !item.redirect)
|
||||
item.component = modules[`/src/views${item.componentPath}`]
|
||||
return item
|
||||
})
|
||||
|
||||
// Generate route tree
|
||||
resultRouter = arrayToTree(resultRouter) as AppRoute.Route[]
|
||||
|
||||
const appRootRoute: RouteRecordRaw = {
|
||||
path: '/appRoot',
|
||||
name: 'appRoot',
|
||||
redirect: import.meta.env.VITE_HOME_PATH,
|
||||
component: Layout,
|
||||
meta: {
|
||||
title: '',
|
||||
icon: 'icon-park-outline:home',
|
||||
},
|
||||
children: [],
|
||||
}
|
||||
|
||||
// Set the correct redirect path for the route
|
||||
setRedirect(resultRouter)
|
||||
|
||||
// Insert the processed route into the root route
|
||||
appRootRoute.children = resultRouter as unknown as RouteRecordRaw[]
|
||||
return appRootRoute
|
||||
}
|
||||
|
||||
// Generate an array of route names that need to be kept alive
|
||||
export function generateCacheRoutes(routes: AppRoute.RowRoute[]) {
|
||||
return routes
|
||||
.filter(i => i.keepAlive)
|
||||
.map(i => i.name)
|
||||
}
|
||||
|
||||
function setRedirect(routes: AppRoute.Route[]) {
|
||||
routes.forEach((route) => {
|
||||
if (route.children) {
|
||||
if (!route.redirect) {
|
||||
// Filter out a collection of child elements that are not hidden
|
||||
const visibleChilds = route.children.filter(child => !child.meta.hide)
|
||||
|
||||
// Redirect page to the path of the first child element by default
|
||||
let target = visibleChilds[0]
|
||||
|
||||
// Filter out pages with the order attribute
|
||||
const orderChilds = visibleChilds.filter(child => child.meta.order)
|
||||
|
||||
if (orderChilds.length > 0)
|
||||
target = min(orderChilds, i => i.meta.order!) as AppRoute.Route
|
||||
|
||||
if (target)
|
||||
route.redirect = target.path
|
||||
}
|
||||
|
||||
setRedirect(route.children)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/* 生成侧边菜单的数据 */
|
||||
export function createMenus(userRoutes: AppRoute.RowRoute[]) {
|
||||
const resultMenus = standardizedRoutes(userRoutes)
|
||||
|
||||
// filter menus that do not need to be displayed
|
||||
const visibleMenus = resultMenus.filter(route => !route.meta.hide)
|
||||
|
||||
// generate side menu
|
||||
return arrayToTree(transformAuthRoutesToMenus(visibleMenus))
|
||||
}
|
||||
|
||||
// render the returned routing table as a sidebar
|
||||
function transformAuthRoutesToMenus(userRoutes: AppRoute.Route[]) {
|
||||
const { hasPermission } = usePermission()
|
||||
return userRoutes
|
||||
// Filter out side menus without permission
|
||||
.filter(i => hasPermission(i.meta.roles))
|
||||
// Sort the menu according to the order size
|
||||
.sort((a, b) => {
|
||||
if (a.meta && a.meta.order && b.meta && b.meta.order)
|
||||
return a.meta.order - b.meta.order
|
||||
else if (a.meta && a.meta.order)
|
||||
return -1
|
||||
else if (b.meta && b.meta.order)
|
||||
return 1
|
||||
else return 0
|
||||
})
|
||||
// Convert to side menu data structure
|
||||
.map((item) => {
|
||||
const target: MenuOption = {
|
||||
id: item.id,
|
||||
pid: item.pid,
|
||||
label:
|
||||
(!item.meta.menuType || item.meta.menuType === 'page')
|
||||
? () =>
|
||||
h(
|
||||
RouterLink,
|
||||
{
|
||||
to: {
|
||||
path: item.path,
|
||||
},
|
||||
},
|
||||
{ default: () => $t(`route.${String(item.name)}`, item.meta.title) },
|
||||
)
|
||||
: () => $t(`route.${String(item.name)}`, item.meta.title),
|
||||
key: item.path,
|
||||
icon: item.meta.icon ? renderIcon(item.meta.icon) : undefined,
|
||||
}
|
||||
return target
|
||||
})
|
||||
}
|
||||
@ -1,96 +0,0 @@
|
||||
import type { MenuOption } from 'naive-ui'
|
||||
import { router } from '@/router'
|
||||
import { staticRoutes } from '@/router/routes.static'
|
||||
import { fetchUserRoutes } from '@/service'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
import { $t, local } from '@/utils'
|
||||
import { createMenus, createRoutes, generateCacheRoutes } from './helper'
|
||||
|
||||
interface RoutesStatus {
|
||||
isInitAuthRoute: boolean
|
||||
menus: MenuOption[]
|
||||
rowRoutes: AppRoute.RowRoute[]
|
||||
activeMenu: string | null
|
||||
cacheRoutes: string[]
|
||||
}
|
||||
export const useRouteStore = defineStore('route-store', {
|
||||
state: (): RoutesStatus => {
|
||||
return {
|
||||
isInitAuthRoute: false,
|
||||
activeMenu: null,
|
||||
menus: [],
|
||||
rowRoutes: [],
|
||||
cacheRoutes: [],
|
||||
}
|
||||
},
|
||||
actions: {
|
||||
resetRouteStore() {
|
||||
this.resetRoutes()
|
||||
this.$reset()
|
||||
},
|
||||
resetRoutes() {
|
||||
if (router.hasRoute('appRoot'))
|
||||
router.removeRoute('appRoot')
|
||||
},
|
||||
// set the currently highlighted menu key
|
||||
setActiveMenu(key: string) {
|
||||
this.activeMenu = key
|
||||
},
|
||||
|
||||
async initRouteInfo() {
|
||||
if (import.meta.env.VITE_ROUTE_LOAD_MODE === 'dynamic') {
|
||||
try {
|
||||
// Get user's route
|
||||
const result = await fetchUserRoutes({
|
||||
id: 1,
|
||||
})
|
||||
|
||||
if (!result.isSuccess || !result.data) {
|
||||
throw new Error('Failed to fetch user routes')
|
||||
}
|
||||
|
||||
return result.data
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Failed to initialize route info:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
else {
|
||||
this.rowRoutes = staticRoutes
|
||||
return staticRoutes
|
||||
}
|
||||
},
|
||||
async initAuthRoute() {
|
||||
this.isInitAuthRoute = false
|
||||
|
||||
try {
|
||||
// Initialize route information
|
||||
const rowRoutes = await this.initRouteInfo()
|
||||
if (!rowRoutes) {
|
||||
const error = new Error('Failed to get route information')
|
||||
window.$message.error($t(`app.getRouteError`))
|
||||
throw error
|
||||
}
|
||||
this.rowRoutes = rowRoutes
|
||||
|
||||
// Generate actual route and insert
|
||||
const routes = createRoutes(rowRoutes)
|
||||
router.addRoute(routes)
|
||||
|
||||
// Generate side menu
|
||||
this.menus = createMenus(rowRoutes)
|
||||
|
||||
// Generate the route cache
|
||||
this.cacheRoutes = generateCacheRoutes(rowRoutes)
|
||||
|
||||
this.isInitAuthRoute = true
|
||||
}
|
||||
catch (error) {
|
||||
// 重置状态并重新抛出错误
|
||||
this.isInitAuthRoute = false
|
||||
throw error
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
@ -24,7 +24,7 @@ export const useTabStore = defineStore('tab-store', {
|
||||
return
|
||||
|
||||
// 如果标签名称已存在则不添加
|
||||
if (this.hasExistTab(route.fullPath as string))
|
||||
if (this.hasExistTab(route.path as string))
|
||||
return
|
||||
|
||||
// 根据meta.pinTab传递到不同的分组中
|
||||
@ -33,42 +33,42 @@ export const useTabStore = defineStore('tab-store', {
|
||||
else
|
||||
this.tabs.push(route)
|
||||
},
|
||||
async closeTab(fullPath: string) {
|
||||
async closeTab(path: string) {
|
||||
const tabsLength = this.tabs.length
|
||||
// 如果动态标签大于一个,才会标签跳转
|
||||
if (this.tabs.length > 1) {
|
||||
// 获取关闭的标签索引
|
||||
const index = this.getTabIndex(fullPath)
|
||||
const index = this.getTabIndex(path)
|
||||
const isLast = index + 1 === tabsLength
|
||||
// 如果是关闭的当前页面,路由跳转到原先标签的后一个标签
|
||||
if (this.currentTabPath === fullPath && !isLast) {
|
||||
if (this.currentTabPath === path && !isLast) {
|
||||
// 跳转到后一个标签
|
||||
router.push(this.tabs[index + 1].fullPath)
|
||||
router.push(this.tabs[index + 1].path)
|
||||
}
|
||||
else if (this.currentTabPath === fullPath && isLast) {
|
||||
else if (this.currentTabPath === path && isLast) {
|
||||
// 已经是最后一个了,就跳转前一个
|
||||
router.push(this.tabs[index - 1].fullPath)
|
||||
router.push(this.tabs[index - 1].path)
|
||||
}
|
||||
}
|
||||
// 删除标签
|
||||
this.tabs = this.tabs.filter((item) => {
|
||||
return item.fullPath !== fullPath
|
||||
return item.path !== path
|
||||
})
|
||||
// 删除后如果清空了,就跳转到默认首页
|
||||
if (tabsLength - 1 === 0)
|
||||
router.push('/')
|
||||
},
|
||||
|
||||
closeOtherTabs(fullPath: string) {
|
||||
const index = this.getTabIndex(fullPath)
|
||||
closeOtherTabs(path: string) {
|
||||
const index = this.getTabIndex(path)
|
||||
this.tabs = this.tabs.filter((item, i) => i === index)
|
||||
},
|
||||
closeLeftTabs(fullPath: string) {
|
||||
const index = this.getTabIndex(fullPath)
|
||||
closeLeftTabs(path: string) {
|
||||
const index = this.getTabIndex(path)
|
||||
this.tabs = this.tabs.filter((item, i) => i >= index)
|
||||
},
|
||||
closeRightTabs(fullPath: string) {
|
||||
const index = this.getTabIndex(fullPath)
|
||||
closeRightTabs(path: string) {
|
||||
const index = this.getTabIndex(path)
|
||||
this.tabs = this.tabs.filter((item, i) => i <= index)
|
||||
},
|
||||
clearAllTabs() {
|
||||
@ -80,27 +80,28 @@ export const useTabStore = defineStore('tab-store', {
|
||||
router.push('/')
|
||||
},
|
||||
|
||||
hasExistTab(fullPath: string) {
|
||||
hasExistTab(path: string) {
|
||||
const _tabs = [...this.tabs, ...this.pinTabs]
|
||||
return _tabs.some((item) => {
|
||||
return item.fullPath === fullPath
|
||||
return item.path === path
|
||||
})
|
||||
},
|
||||
/* 设置当前激活的标签 */
|
||||
setCurrentTab(fullPath: string) {
|
||||
this.currentTabPath = fullPath
|
||||
setCurrentTab(path: string) {
|
||||
this.currentTabPath = path
|
||||
},
|
||||
getTabIndex(fullPath: string) {
|
||||
getTabIndex(path: string) {
|
||||
return this.tabs.findIndex((item) => {
|
||||
return item.fullPath === fullPath
|
||||
return item.path === path
|
||||
})
|
||||
},
|
||||
modifyTab(fullPath: string, modifyFn: (route: RouteLocationNormalized) => void) {
|
||||
const index = this.getTabIndex(fullPath)
|
||||
modifyFn(this.tabs[index])
|
||||
},
|
||||
},
|
||||
persist: {
|
||||
storage: sessionStorage,
|
||||
enabled: true,
|
||||
strategies: [
|
||||
{
|
||||
storage: sessionStorage,
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
@import './reset.css';
|
||||
@import './transition.css';
|
||||
@import './naive.css';
|
||||
|
||||
html,
|
||||
body,
|
||||
@ -14,7 +13,3 @@ body,
|
||||
.gray-mode {
|
||||
filter: grayscale(100%);
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user