init project
This commit is contained in:
commit
5fb45f8f07
8
.dockerignore
Normal file
8
.dockerignore
Normal file
@ -0,0 +1,8 @@
|
||||
/node_modules
|
||||
/.git
|
||||
/.gitignore
|
||||
/.vscode
|
||||
/.DS_Store
|
||||
/*.md
|
||||
/dist
|
||||
|
||||
9
.editorconfig
Normal file
9
.editorconfig
Normal file
@ -0,0 +1,9 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
26
.env
Normal file
26
.env
Normal file
@ -0,0 +1,26 @@
|
||||
# 项目根目录
|
||||
VITE_BASE_URL = /
|
||||
|
||||
# 项目名称
|
||||
VITE_APP_NAME = Nova - Admin
|
||||
|
||||
# 路由模式 web | hash
|
||||
VITE_ROUTE_MODE = web
|
||||
|
||||
# 路由加载模式 static | dynamic
|
||||
VITE_ROUTE_LOAD_MODE = static
|
||||
|
||||
# 设置登陆后跳转地址
|
||||
VITE_HOME_PATH = /dashboard/workbench
|
||||
|
||||
# 本地存储前缀
|
||||
VITE_STORAGE_PREFIX =
|
||||
|
||||
# 版权信息
|
||||
VITE_COPYRIGHT_INFO = Copyright © 2024 chansee97
|
||||
|
||||
# 自动刷新token
|
||||
VITE_AUTO_REFRESH_TOKEN = Y
|
||||
|
||||
# 默认多语言 enUS | zhCN
|
||||
VITE_DEFAULT_LANG = enUS
|
||||
6
.env.prod
Normal file
6
.env.prod
Normal file
@ -0,0 +1,6 @@
|
||||
# 是否开启压缩资源
|
||||
VITE_BUILD_COMPRESS=N
|
||||
|
||||
# 压缩算法 gzip | brotliCompress | deflate | deflateRaw
|
||||
VITE_COMPRESS_TYPE=gzip
|
||||
|
||||
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
|
||||
|
||||
16
.gitattributes
vendored
Normal file
16
.gitattributes
vendored
Normal file
@ -0,0 +1,16 @@
|
||||
"*.vue" eol=lf
|
||||
"*.js" eol=lf
|
||||
"*.ts" eol=lf
|
||||
"*.jsx" eol=lf
|
||||
"*.tsx" eol=lf
|
||||
"*.cjs" eol=lf
|
||||
"*.cts" eol=lf
|
||||
"*.mjs" eol=lf
|
||||
"*.mts" eol=lf
|
||||
"*.json" eol=lf
|
||||
"*.html" eol=lf
|
||||
"*.css" eol=lf
|
||||
"*.less" eol=lf
|
||||
"*.scss" eol=lf
|
||||
"*.sass" eol=lf
|
||||
"*.styl" eol=lf
|
||||
42
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
Normal file
42
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
Normal file
@ -0,0 +1,42 @@
|
||||
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
Normal file
1
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@ -0,0 +1 @@
|
||||
blank_issues_enabled: false
|
||||
45
.github/ISSUE_TEMPLATE/feature-request.yml
vendored
Normal file
45
.github/ISSUE_TEMPLATE/feature-request.yml
vendored
Normal file
@ -0,0 +1,45 @@
|
||||
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
Normal file
31
.github/ISSUE_TEMPLATE/others.yml
vendored
Normal file
@ -0,0 +1,31 @@
|
||||
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
|
||||
26
.github/workflows/release.yml
vendored
Normal file
26
.github/workflows/release.yml
vendored
Normal file
@ -0,0 +1,26 @@
|
||||
name: Release
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
node-version: 20.x
|
||||
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 20.x
|
||||
|
||||
- run: npx changelogithub
|
||||
env:
|
||||
GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
|
||||
32
.gitignore
vendored
Normal file
32
.gitignore
vendored
Normal file
@ -0,0 +1,32 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
stats.html
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
!.vscode/settings.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
components.d.ts
|
||||
auto-imports.d.ts
|
||||
|
||||
# Lock files
|
||||
*-lock.yaml
|
||||
4
.npmrc
Normal file
4
.npmrc
Normal file
@ -0,0 +1,4 @@
|
||||
registry=https://registry.npmmirror.com/
|
||||
shamefully-hoist=true
|
||||
strict-peer-dependencies=false
|
||||
auto-install-peers=true
|
||||
15
.vscode/extensions.json
vendored
Normal file
15
.vscode/extensions.json
vendored
Normal file
@ -0,0 +1,15 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"mikestead.dotenv",
|
||||
"usernamehw.errorlens",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"eamodio.gitlens",
|
||||
"mhutchie.git-graph",
|
||||
"donjayamanne.githistory",
|
||||
"lokalise.i18n-ally",
|
||||
"antfu.iconify",
|
||||
"kisstkondoros.vscode-gutter-preview",
|
||||
"antfu.unocss",
|
||||
"vue.volar"
|
||||
]
|
||||
}
|
||||
66
.vscode/settings.json
vendored
Normal file
66
.vscode/settings.json
vendored
Normal file
@ -0,0 +1,66 @@
|
||||
{
|
||||
// Disable the default formatter, use eslint instead
|
||||
"prettier.enable": false,
|
||||
"editor.formatOnSave": false,
|
||||
// Auto fix
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": "explicit",
|
||||
"source.organizeImports": "never"
|
||||
},
|
||||
// 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" }
|
||||
],
|
||||
// Enable eslint for all supported languages
|
||||
"eslint.validate": [
|
||||
"javascript",
|
||||
"javascriptreact",
|
||||
"typescript",
|
||||
"typescriptreact",
|
||||
"vue",
|
||||
"html",
|
||||
"markdown",
|
||||
"json",
|
||||
"jsonc",
|
||||
"yaml",
|
||||
"toml",
|
||||
"xml",
|
||||
"gql",
|
||||
"graphql",
|
||||
"astro",
|
||||
"css",
|
||||
"less",
|
||||
"scss",
|
||||
"pcss",
|
||||
"postcss"
|
||||
],
|
||||
"i18n-ally.sourceLanguage": "zh_CN",
|
||||
"i18n-ally.displayLanguage": "zh_CN",
|
||||
"i18n-ally.enabledFrameworks": ["vue"],
|
||||
"i18n-ally.editor.preferEditor": true,
|
||||
"i18n-ally.keystyle": "nested",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
219
CLAUDE.md
Normal file
219
CLAUDE.md
Normal file
@ -0,0 +1,219 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
|
||||
### 第一部分:核心编程原则 (Guiding Principles)
|
||||
这是我们合作的顶层思想,指导所有具体的行为。
|
||||
可读性优先 (Readability First):始终牢记“代码是写给人看的,只是恰好机器可以执行”。清晰度高于一切。
|
||||
DRY (Don't Repeat Yourself):绝不复制代码片段。通过抽象(如函数、类、模块)来封装和复用通用逻辑。
|
||||
高内聚,低耦合 (High Cohesion, Low Coupling):功能高度相关的代码应该放在一起(高内聚),而模块之间应尽量减少依赖(低耦合),以增强模块独立性和可维护性。
|
||||
|
||||
|
||||
### 第二部分:具体执行指令 (Actionable Instructions)
|
||||
这是 Claude 在日常工作中需要严格遵守的具体操作指南。
|
||||
沟通与语言规范
|
||||
默认语言:请默认使用简体中文进行所有交流、解释和思考过程的陈述。
|
||||
代码与术语:所有代码实体(变量名、函数名、类名等)及技术术语(如库名、框架名、设计模式等)必须保持英文原文。
|
||||
注释规范:代码注释应使用中文。
|
||||
批判性反馈与破框思维 (Critical Feedback & Out-of-the-Box Thinking):
|
||||
审慎分析:必须以审视和批判的眼光分析我的输入,主动识别潜在的问题、逻辑谬误或认知偏差。
|
||||
坦率直言:需要明确、直接地指出我思考中的盲点,并提供显著超越我当前思考框架的建议,以挑战我的预设。
|
||||
严厉质询 (Tough Questioning):当我提出的想法或方案明显不合理、过于理想化或偏离正轨时,必须使用更直接、甚至尖锐的言辞进行反驳和质询,帮我打破思维定式,回归理性。
|
||||
开发与调试策略 (Development & Debugging Strategy)
|
||||
坚韧不拔的解决问题 (Tenacious Problem-Solving):当面对编译错误、逻辑不通或多次尝试失败时,绝不允许通过简化或伪造实现来“绕过”问题。
|
||||
逐个击破 (Incremental Debugging):必须坚持对错误和问题进行逐一分析、定位和修复。
|
||||
|
||||
### 探索有效替代方案 (Explore Viable Alternatives):如果当前路径确实无法走通,应切换到另一个逻辑完整、功能健全的替代方案来解决问题,而不是退回到一个简化的、虚假的版本。
|
||||
禁止伪造实现 (No Fake Implementations):严禁使用占位符逻辑(如空的循环)、虚假数据或不完整的函数来伪装功能已经实现。所有交付的代码都必须是意图明确且具备真实逻辑的。
|
||||
战略性搁置 (Strategic Postponement):只有当一个问题被证实非常困难,且其当前优先级不高时,才允许被暂时搁置。搁置时,必须以 TODO 形式在代码中或任务列表中明确标记,并清晰说明遇到的问题。在核心任务完成后,必须回过头来重新审视并解决这些被搁置的问题。
|
||||
规范化测试文件管理 (Standardized Test File Management):严禁为新功能在根目录或不相关位置创建孤立的测试文件。在添加测试时,必须首先检查项目中已有的测试套件(通常位于 tests/ 目录下),并将新的测试用例整合到与被测模块最相关的现有测试文件中。只有当确实没有合适的宿主文件时,才允许在 tests/ 目录下创建符合项目命名规范的新测试文件。
|
||||
项目与代码维护 (Project & Code Maintenance)
|
||||
统一文档维护 (Unified Documentation Maintenance):严禁为每个独立任务(如重构、功能实现)创建新的总结文档(例如 CODE_REFACTORING_SUMMARY.md)。在任务完成后,必须优先检查项目中已有的相关文档(如 README.md、既有的设计文档等),并将新的总结、变更或补充内容直接整合到现有文档中,维护其完整性和时效性。
|
||||
及时清理 (Timely Cleanup):在完成开发任务时,如果发现任何已无用(过时)的代码、文件或注释,应主动提出清理建议。
|
||||
|
||||
## 项目概述
|
||||
|
||||
Nova Admin 是一个基于 Vue3、Vite5、TypeScript 和 Naive UI 的简洁后台管理模板,实现了完整的认证、权限管理、路由管理等功能。
|
||||
|
||||
## 常用命令
|
||||
|
||||
### 开发环境
|
||||
```bash
|
||||
# 启动开发服务器 (端口 9980)
|
||||
pnpm dev
|
||||
|
||||
# 不同环境启动
|
||||
pnpm dev:test # 测试环境
|
||||
pnpm dev:prod # 生产环境
|
||||
```
|
||||
|
||||
### 构建项目
|
||||
```bash
|
||||
# 生产环境构建
|
||||
pnpm build
|
||||
|
||||
# 不同环境构建
|
||||
pnpm build:dev # 开发环境
|
||||
pnpm build:test # 测试环境
|
||||
```
|
||||
|
||||
### 代码检查
|
||||
```bash
|
||||
# 运行 ESLint 检查和类型检查
|
||||
pnpm lint
|
||||
|
||||
# 自动修复代码问题
|
||||
pnpm lint:fix
|
||||
|
||||
# 检查 ESLint 配置
|
||||
pnpm lint:check
|
||||
```
|
||||
|
||||
### 其他工具
|
||||
```bash
|
||||
# 预览构建结果 (端口 9981)
|
||||
pnpm preview
|
||||
|
||||
# 查看打包体积分析
|
||||
pnpm sizecheck
|
||||
```
|
||||
|
||||
## 项目架构
|
||||
|
||||
### 核心技术栈
|
||||
- **Vue 3.5.16** + **Composition API**
|
||||
- **Vite 6.3.5** 构建工具
|
||||
- **TypeScript 5.8.3** 类型安全
|
||||
- **Naive UI 2.41.1** 组件库
|
||||
- **Pinia 3.0.3** 状态管理
|
||||
- **Vue Router 4.5.1** 路由管理
|
||||
- **UnoCSS 66.2.0** 原子化CSS
|
||||
- **Alova 3.3.2** HTTP客户端
|
||||
|
||||
### 目录结构要点
|
||||
```
|
||||
src/
|
||||
├── store/ # Pinia状态管理
|
||||
│ ├── auth.ts # 认证状态(登录、用户信息)
|
||||
│ ├── router/ # 路由状态和菜单管理
|
||||
│ ├── tab.ts # 标签页状态
|
||||
│ └── app/ # 应用全局状态
|
||||
├── router/ # 路由配置
|
||||
│ ├── index.ts # 路由实例
|
||||
│ ├── guard.ts # 路由守卫
|
||||
│ ├── routes.inner.ts # 内置路由(登录、错误页等)
|
||||
│ └── routes.static.ts # 静态路由配置
|
||||
├── views/ # 页面组件
|
||||
├── layouts/ # 布局组件
|
||||
├── components/ # 通用组件
|
||||
├── service/ # API服务层
|
||||
│ ├── api/ # 接口定义
|
||||
│ └── http/ # HTTP配置
|
||||
├── hooks/ # 组合式函数
|
||||
├── utils/ # 工具函数
|
||||
├── typings/ # 类型定义
|
||||
└── constants/ # 常量定义
|
||||
```
|
||||
|
||||
## 核心系统架构
|
||||
|
||||
### 1. 认证系统
|
||||
- 基于JWT Token的认证机制
|
||||
- 支持双Token(AccessToken + RefreshToken)
|
||||
- 自动Token刷新和过期处理
|
||||
- 本地存储管理(localStorage)
|
||||
|
||||
**关键文件**:
|
||||
- `src/store/auth.ts` - 认证状态管理
|
||||
- `src/views/login/` - 登录页面组件
|
||||
- `src/service/api/login.ts` - 登录API接口
|
||||
|
||||
### 2. 权限系统
|
||||
- 基于角色的访问控制(RBAC)
|
||||
- 多层权限验证:路由级、组件级、API级
|
||||
- 权限指令 `v-permission` 和组合函数 `usePermission`
|
||||
- 支持super角色绕过所有权限检查
|
||||
|
||||
**关键文件**:
|
||||
- `src/hooks/usePermission.ts` - 权限验证组合函数
|
||||
- `src/directives/permission.ts` - 权限指令
|
||||
|
||||
### 3. 路由系统
|
||||
- 支持静态路由和动态路由
|
||||
- 路由守卫实现权限验证
|
||||
- 自动菜单生成和路由缓存
|
||||
- 多种布局模式支持
|
||||
|
||||
**关键文件**:
|
||||
- `src/router/guard.ts` - 路由守卫逻辑
|
||||
- `src/store/router/` - 路由状态管理
|
||||
- `src/store/router/helper.ts` - 路由处理工具函数
|
||||
|
||||
### 4. 状态管理
|
||||
- 使用Pinia进行状态管理
|
||||
- 支持状态持久化
|
||||
- 模块化状态设计
|
||||
|
||||
**状态模块**:
|
||||
- `authStore` - 用户认证状态
|
||||
- `routeStore` - 路由和菜单状态
|
||||
- `tabStore` - 标签页状态
|
||||
- `appStore` - 应用全局状态
|
||||
|
||||
## 重要配置文件
|
||||
|
||||
### 环境配置
|
||||
- 支持多环境配置:dev、test、prod
|
||||
- 环境变量通过 `.env.*` 文件管理
|
||||
- 服务配置通过 `service.config.ts` 统一管理
|
||||
|
||||
### 路由配置
|
||||
- 通过 `VITE_ROUTE_LOAD_MODE` 控制路由加载模式
|
||||
- 静态路由配置在 `src/router/routes.static.ts`
|
||||
- 动态路由通过API从后端获取
|
||||
|
||||
### 权限配置
|
||||
- 角色类型:super、admin、user、editor
|
||||
- 权限验证支持单角色和多角色
|
||||
- 默认无权限要求时直接通过
|
||||
|
||||
## 开发注意事项
|
||||
|
||||
### 添加新页面
|
||||
1. 在 `src/views/` 创建页面组件
|
||||
2. 在 `src/router/routes.static.ts` 添加路由配置
|
||||
3. 配置权限要求(roles字段)
|
||||
|
||||
### 添加新API
|
||||
1. 在 `src/service/api/` 定义接口
|
||||
2. 使用项目封装的alova实例
|
||||
3. 遵循统一的响应处理格式
|
||||
|
||||
### 状态管理
|
||||
1. 新增状态优先考虑使用现有store
|
||||
2. 需要持久化的状态使用pinia-plugin-persist
|
||||
3. 计算属性优先使用computed缓存
|
||||
|
||||
### 组件开发
|
||||
1. 优先使用Naive UI组件
|
||||
2. 自定义组件放在 `src/components/` 下
|
||||
3. 使用TypeScript定义组件Props和Emits
|
||||
|
||||
## 文档资源
|
||||
|
||||
项目包含完整的文档系统:
|
||||
- `doc/auth-system.md` - 认证系统文档
|
||||
- `doc/permission-system.md` - 权限系统文档
|
||||
- `doc/router-system.md` - 路由系统文档
|
||||
- `doc/architecture.md` - 整体架构文档
|
||||
|
||||
## API接口
|
||||
|
||||
项目使用ApiFox进行接口Mock,在线文档:https://nova-admin.apifox.cn
|
||||
|
||||
## 开发环境要求
|
||||
|
||||
- Node.js 21.x
|
||||
- pnpm 10.x
|
||||
- 现代浏览器支持ES6+
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2022 Rock chen
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
121
README.md
Normal file
121
README.md
Normal file
@ -0,0 +1,121 @@
|
||||
<div align="center">
|
||||
<img src="https://s2.loli.net/2023/10/27/WzQ4JLNV5epKh6X.png" style="width:150px"/>
|
||||
<h1>Nova Admin</h1>
|
||||
</div>
|
||||
|
||||
<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/forks/chansee97/nova-admin"/>
|
||||
</div>
|
||||
|
||||
<div align='center'>
|
||||
|
||||
English | [中文](./README.zh-CN.md)
|
||||
</div>
|
||||
|
||||
## Introduction
|
||||
|
||||
[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/)
|
||||
|
||||
## Features
|
||||
|
||||
- Developed based on the latest technology stack including Vue3, Vite6, 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 style layout.
|
||||
- Multilanguage (i18n) support.
|
||||
|
||||
## Project preview
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
## Repo
|
||||
|
||||
- [Gitee](https://gitee.com/chansee97/nova-admin)
|
||||
- [Github](https://github.com/chansee97/nova-admin)
|
||||
|
||||
## Interface document
|
||||
|
||||
This project uses ApiFox for interface mock, check the online documentation for more interface details
|
||||
[online aipfox docs](https://nova-admin.apifox.cn)
|
||||
|
||||
## 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)
|
||||
|
||||
```bash
|
||||
# install dependencies
|
||||
pnpm i
|
||||
|
||||
# Run
|
||||
pnpm dev
|
||||
|
||||
# Build product
|
||||
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.
|
||||
|
||||
## Related projects
|
||||
|
||||
- [Nova-admin-nest](https://github.com/chansee97/nova-admin-nest) (under development) Nova-Admin supporting background project based on TS, NestJs, typeorm
|
||||
|
||||
## 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>|
|
||||
|
||||
> Please indicate the purpose of adding WeChat.
|
||||
|
||||
## Contribution
|
||||
|
||||
If you find any issues or have suggestions for improvement, please create an [issue](nova-admin/issues/new) or submit a PR. We welcome your contributions!
|
||||
|
||||
## Support
|
||||
|
||||
If you feel that this project is helpful for your work or study, please help me order a ✨ Star, which will be a great encouragement and support for me, or you can buy me a cup of coffee below
|
||||
|
||||
| wechat | alipay |
|
||||
| :--: |:--: |
|
||||
| <img src="https://cdn.jsdelivr.net/gh/chansee97/static/sponsor-wechat.png" width=170> | <img src="https://cdn.jsdelivr.net/gh/chansee97/static/sponsor-alipay.png" width=170>|
|
||||
|
||||
## Contributors
|
||||
|
||||
Thanks for all their contributions!
|
||||
|
||||
<a href="https://github.com/chansee97/nova-admin/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=chansee97/nova-admin" alt="contributors" />
|
||||
</a>
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://star-history.com/#chansee97/nova-admin&Date)
|
||||
|
||||
## License
|
||||
|
||||
[MIT](LICENSE)
|
||||
121
README.zh-CN.md
Normal file
121
README.zh-CN.md
Normal file
@ -0,0 +1,121 @@
|
||||
<div align="center">
|
||||
<img src="https://s2.loli.net/2023/10/27/WzQ4JLNV5epKh6X.png" style="width:150px"/>
|
||||
<h1>Nova Admin</h1>
|
||||
</div>
|
||||
|
||||
<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/forks/chansee97/nova-admin"/>
|
||||
</div>
|
||||
|
||||
<div align='center'>
|
||||
|
||||
[English](./README.md) | 中文
|
||||
</div>
|
||||
|
||||
## 介绍
|
||||
|
||||
[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/)
|
||||
|
||||
## 特性
|
||||
|
||||
- 基于Vue3、Vite6、TypeScript、NaiveUI、Unocss等最新技术栈开发
|
||||
- 基于[alova](https://alova.js.org/)封装和配置,提供统一的响应处理和多场景能力
|
||||
- 完善的前后端权限管理方案
|
||||
- 支持本地静态路由和后台返回动态路由,路由简单易配置
|
||||
- 对日常使用频率较高的组件二次封装,满足基础工作需求
|
||||
- 黑暗主题适配, 界面样式保持Naive风格
|
||||
- 仅在提交时进行eslint校验,没有过多限制,开发更简便
|
||||
- 界面样式布局灵活可配置
|
||||
- 多语言(i18n)支持
|
||||
|
||||
## 项目预览
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
## 代码仓库
|
||||
|
||||
- [Gitee](https://gitee.com/chansee97/nova-admin)
|
||||
- [Github](https://github.com/chansee97/nova-admin)
|
||||
|
||||
## 接口文档
|
||||
|
||||
本项目使用ApiFox进行接口mock,查看在线文档以了解更多接口详情
|
||||
[在线apifox文档](https://nova-admin.apifox.cn)
|
||||
|
||||
## 安装使用
|
||||
|
||||
本地开发环境建议使用 pnpm 10.x 、Node.js 21.x
|
||||
|
||||
推荐直接下载[Releases](https://github.com/chansee97/nova-admin/releases)压缩包
|
||||
|
||||
```bash
|
||||
# install dependencies
|
||||
pnpm i
|
||||
|
||||
# Run
|
||||
pnpm dev
|
||||
|
||||
# Build product
|
||||
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配套后台项目
|
||||
|
||||
## 学习交流
|
||||
|
||||
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>|
|
||||
|
||||
> 添加微信请注明来意
|
||||
|
||||
## 贡献
|
||||
|
||||
如果您发现了任何问题或有改进建议,请创建一个[issue](nova-admin/issues/new)或提交一个PR。我们欢迎您的贡献!
|
||||
|
||||
## 支持
|
||||
|
||||
如果感觉本项目对你工作或学习有帮助,请帮我点一个✨Star,这将是对我极大的鼓励与支持, 也可以在下方请我喝一杯咖啡
|
||||
|
||||
| 微信 | 支付宝 |
|
||||
| :--: |:--: |
|
||||
| <img src="https://cdn.jsdelivr.net/gh/chansee97/static/sponsor-wechat.png" width=170> | <img src="https://cdn.jsdelivr.net/gh/chansee97/static/sponsor-alipay.png" width=170>|
|
||||
|
||||
## 贡献者
|
||||
|
||||
感谢他们的所做的一切贡献!
|
||||
|
||||
<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)
|
||||
|
||||
## 协议
|
||||
|
||||
[MIT](LICENSE)
|
||||
92
build/plugins.ts
Normal file
92
build/plugins.ts
Normal file
@ -0,0 +1,92 @@
|
||||
import UnoCSS from '@unocss/vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import vueJsx from '@vitejs/plugin-vue-jsx'
|
||||
import AutoImport from 'unplugin-auto-import/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'
|
||||
|
||||
/**
|
||||
* @description: 设置vite插件配置
|
||||
* @param {*} env - 环境变量配置
|
||||
* @return {*}
|
||||
*/
|
||||
export function createVitePlugins(env: ImportMetaEnv) {
|
||||
const plugins = [
|
||||
// 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',
|
||||
],
|
||||
},
|
||||
],
|
||||
include: [
|
||||
/\.[tj]sx?$/,
|
||||
/\.vue$/,
|
||||
/\.vue\?vue/,
|
||||
/\.md$/,
|
||||
],
|
||||
dts: 'src/typings/auto-imports.d.ts',
|
||||
}),
|
||||
|
||||
// auto import components lib
|
||||
Components({
|
||||
dts: 'src/typings/components.d.ts',
|
||||
resolvers: [
|
||||
IconsResolver({
|
||||
prefix: false,
|
||||
customCollections: [
|
||||
'svg-icons',
|
||||
],
|
||||
}),
|
||||
NaiveUiResolver(),
|
||||
],
|
||||
}),
|
||||
|
||||
// auto import iconify's icons
|
||||
Icons({
|
||||
defaultStyle: 'display:inline-block',
|
||||
compiler: 'vue3',
|
||||
customCollections: {
|
||||
'svg-icons': FileSystemIconLoader(
|
||||
'src/assets/svg-icons',
|
||||
svg => svg.replace(/^<svg /, '<svg fill="currentColor" width="1.2em" height="1.2em"'),
|
||||
),
|
||||
},
|
||||
}),
|
||||
]
|
||||
// use compression
|
||||
if (env.VITE_BUILD_COMPRESS === 'Y') {
|
||||
const { VITE_COMPRESS_TYPE = 'gzip' } = env
|
||||
plugins.push(viteCompression({
|
||||
algorithm: VITE_COMPRESS_TYPE, // 压缩算法
|
||||
}))
|
||||
}
|
||||
|
||||
return plugins
|
||||
}
|
||||
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>
|
||||
}
|
||||
506
doc/整体架构文档.md
Normal file
506
doc/整体架构文档.md
Normal file
@ -0,0 +1,506 @@
|
||||
# Nova Admin 整体架构文档
|
||||
|
||||
## 概述
|
||||
|
||||
Nova Admin 是一个基于 Vue 3 + TypeScript + Naive UI 的现代化后台管理系统,采用模块化架构设计,集成了完整的认证、权限、路由管理体系。
|
||||
|
||||
## 🏗️ 系统架构
|
||||
|
||||
###[权限管理系统文档.md](%E6%9D%83%E9%99%90%E7%AE%A1%E7%90%86%E7%B3%BB%E7%BB%9F%E6%96%87%E6%A1%A3.md) 核心架构图
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "前端应用层"
|
||||
A[用户界面] --> B[页面组件]
|
||||
B --> C[业务逻辑]
|
||||
end
|
||||
|
||||
subgraph "状态管理层"
|
||||
D[AuthStore<br/>认证状态] --> E[RouteStore<br/>路由状态]
|
||||
E --> F[TabStore<br/>标签状态]
|
||||
D --> G[AppStore<br/>应用状态]
|
||||
end
|
||||
|
||||
subgraph "路由控制层"
|
||||
H[路由守卫] --> I[权限验证]
|
||||
I --> J[路由初始化]
|
||||
J --> K[菜单生成]
|
||||
end
|
||||
|
||||
subgraph "服务层"
|
||||
L[HTTP请求] --> M[API接口]
|
||||
M --> N[数据转换]
|
||||
end
|
||||
|
||||
subgraph "持久化层"
|
||||
O[localStorage] --> P[用户信息]
|
||||
P --> Q[Token管理]
|
||||
Q --> R[路由缓存]
|
||||
end
|
||||
|
||||
A --> D
|
||||
C --> L
|
||||
H --> D
|
||||
D --> O
|
||||
E --> H
|
||||
K --> B
|
||||
```
|
||||
|
||||
### 技术栈
|
||||
|
||||
| 层级 | 技术选型 | 版本 | 说明 |
|
||||
|------|----------|------|------|
|
||||
| 框架 | Vue 3 | 3.x | 渐进式前端框架 |
|
||||
| 语言 | TypeScript | 5.x | 类型安全的 JavaScript |
|
||||
| 构建工具 | Vite | 4.x | 现代前端构建工具 |
|
||||
| UI组件库 | Naive UI | 2.x | Vue 3 组件库 |
|
||||
| 状态管理 | Pinia | 2.x | Vue 3 官方状态管理 |
|
||||
| 路由管理 | Vue Router | 4.x | Vue 3 官方路由 |
|
||||
| CSS预处理 | SCSS | - | CSS 扩展语言 |
|
||||
| 图标库 | Iconify | - | 统一图标解决方案 |
|
||||
|
||||
## 🔄 系统交互流程
|
||||
|
||||
### 1. 应用启动流程
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as 用户
|
||||
participant App as 应用
|
||||
participant Auth as AuthStore
|
||||
participant Route as RouteStore
|
||||
participant Guard as 路由守卫
|
||||
participant API as 后端API
|
||||
|
||||
U->>App: 访问应用
|
||||
App->>Auth: 检查本地Token
|
||||
|
||||
alt Token存在
|
||||
Auth->>Guard: 已登录状态
|
||||
Guard->>Route: 初始化路由
|
||||
Route->>API: 获取用户路由
|
||||
API-->>Route: 返回路由数据
|
||||
Route->>Route: 生成菜单和路由
|
||||
Route->>App: 渲染应用界面
|
||||
else Token不存在
|
||||
Auth->>Guard: 未登录状态
|
||||
Guard->>App: 重定向到登录页
|
||||
end
|
||||
```
|
||||
|
||||
### 2. 用户登录流程
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as 用户
|
||||
participant L as 登录页
|
||||
participant Auth as AuthStore
|
||||
participant API as 后端API
|
||||
participant Route as RouteStore
|
||||
participant Guard as 路由守卫
|
||||
|
||||
U->>L: 提交登录信息
|
||||
L->>Auth: 调用login方法
|
||||
Auth->>API: 发送登录请求
|
||||
API-->>Auth: 返回用户信息和Token
|
||||
Auth->>Auth: 保存到localStorage
|
||||
Auth->>Route: 初始化用户路由
|
||||
Route->>Route: 生成菜单和路由
|
||||
Auth->>Guard: 触发路由跳转
|
||||
Guard->>U: 重定向到首页
|
||||
```
|
||||
|
||||
### 3. 权限验证流程
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[用户访问] --> B{需要认证?}
|
||||
B -->|否| H[允许访问]
|
||||
B -->|是| C{用户已登录?}
|
||||
C -->|否| D[重定向登录页]
|
||||
C -->|是| E{角色权限匹配?}
|
||||
E -->|否| F[显示403错误]
|
||||
E -->|是| G{路由权限匹配?}
|
||||
G -->|否| F
|
||||
G -->|是| H
|
||||
H --> I[渲染页面]
|
||||
I --> J{组件权限检查}
|
||||
J -->|通过| K[显示组件]
|
||||
J -->|拒绝| L[隐藏组件]
|
||||
```
|
||||
|
||||
## 📦 模块架构
|
||||
|
||||
### 1. 认证模块 (Authentication)
|
||||
|
||||
**职责**: 用户身份验证和会话管理
|
||||
|
||||
```typescript
|
||||
// 核心组件
|
||||
AuthStore // 认证状态管理
|
||||
LoginAPI // 登录API接口
|
||||
TokenManager // Token管理工具
|
||||
LoginGuard // 登录路由守卫
|
||||
```
|
||||
|
||||
**数据流向**:
|
||||
```
|
||||
用户输入 → 登录验证 → Token生成 → 状态更新 → 路由跳转
|
||||
```
|
||||
|
||||
### 2. 权限模块 (Authorization)
|
||||
|
||||
**职责**: 基于角色的访问控制
|
||||
|
||||
```typescript
|
||||
// 核心组件
|
||||
usePermission // 权限验证Hook
|
||||
PermissionDirective // 权限指令
|
||||
RoleFilter // 角色过滤器
|
||||
MenuGenerator // 菜单生成器
|
||||
```
|
||||
|
||||
**数据流向**:
|
||||
```
|
||||
用户角色 → 权限计算 → 路由过滤 → 菜单生成 → 组件控制
|
||||
```
|
||||
|
||||
### 3. 路由模块 (Routing)
|
||||
|
||||
**职责**: 路由管理和页面导航
|
||||
|
||||
```typescript
|
||||
// 核心组件
|
||||
RouteStore // 路由状态管理
|
||||
RouteGuard // 路由守卫
|
||||
RouteHelper // 路由处理工具
|
||||
TabManager // 标签页管理
|
||||
```
|
||||
|
||||
**数据流向**:
|
||||
```
|
||||
路由配置 → 权限过滤 → 动态注册 → 菜单生成 → 页面渲染
|
||||
```
|
||||
|
||||
## 🎯 核心设计模式
|
||||
|
||||
### 1. 状态管理模式
|
||||
|
||||
采用 Pinia 实现响应式状态管理:
|
||||
|
||||
```typescript
|
||||
// 状态分离
|
||||
AuthStore // 认证相关状态
|
||||
RouteStore // 路由相关状态
|
||||
TabStore // 标签页状态
|
||||
AppStore // 全局应用状态
|
||||
```
|
||||
|
||||
### 2. 守卫模式
|
||||
|
||||
通过路由守卫实现横切关注点:
|
||||
|
||||
```typescript
|
||||
// 守卫链
|
||||
beforeEach // 认证检查、权限验证
|
||||
beforeResolve // 路由数据准备
|
||||
afterEach // 状态更新、页面标题设置
|
||||
```
|
||||
|
||||
### 3. 工厂模式
|
||||
|
||||
动态创建路由和菜单:
|
||||
|
||||
```typescript
|
||||
createRoutes() // 路由工厂
|
||||
createMenus() // 菜单工厂
|
||||
```
|
||||
|
||||
### 4. 策略模式
|
||||
|
||||
支持多种路由加载策略:
|
||||
|
||||
```typescript
|
||||
// 路由加载策略
|
||||
static // 静态路由模式
|
||||
dynamic // 动态路由模式
|
||||
```
|
||||
|
||||
## 🔧 配置管理
|
||||
|
||||
### 环境变量配置
|
||||
|
||||
```bash
|
||||
# 应用配置
|
||||
VITE_APP_NAME=Nova Admin
|
||||
VITE_APP_DESC=Vue3 Admin Template
|
||||
|
||||
# 路由配置
|
||||
VITE_ROUTE_LOAD_MODE=static
|
||||
VITE_HOME_PATH=/dashboard/monitor
|
||||
VITE_ROUTE_MODE=hash
|
||||
|
||||
# API配置
|
||||
VITE_SERVICE_ENV=dev
|
||||
VITE_HTTP_PROXY=Y
|
||||
```
|
||||
|
||||
### 运行时配置
|
||||
|
||||
```typescript
|
||||
// 路由配置
|
||||
const routeConfig = {
|
||||
loadMode: 'static', // 路由加载模式
|
||||
homePath: '/dashboard/monitor', // 默认首页
|
||||
cacheEnabled: true, // 路由缓存
|
||||
}
|
||||
|
||||
// 权限配置
|
||||
const permissionConfig = {
|
||||
superRole: 'super', // 超级管理员角色
|
||||
defaultRole: 'user', // 默认角色
|
||||
strictMode: true, // 严格模式
|
||||
}
|
||||
```
|
||||
|
||||
## 🛡️ 安全架构
|
||||
|
||||
### 1. 认证安全
|
||||
|
||||
- **JWT Token**: 无状态认证机制
|
||||
- **双Token机制**: AccessToken + RefreshToken
|
||||
- **Token过期处理**: 自动刷新机制
|
||||
- **本地存储**: 安全的localStorage使用
|
||||
|
||||
### 2. 权限安全
|
||||
|
||||
- **RBAC模型**: 基于角色的访问控制
|
||||
- **多层权限验证**: 路由级 + 组件级 + API级
|
||||
- **权限缓存**: 避免重复计算
|
||||
- **权限指令**: 细粒度控制
|
||||
|
||||
### 3. 路由安全
|
||||
|
||||
- **路由守卫**: 统一的访问控制
|
||||
- **重定向保护**: 防止无限重定向
|
||||
- **动态路由**: 按需加载和权限过滤
|
||||
- **404处理**: 友好的错误页面
|
||||
|
||||
## 📈 性能优化
|
||||
|
||||
### 1. 代码分割
|
||||
|
||||
```typescript
|
||||
// 路由懒加载
|
||||
const modules = import.meta.glob('@/views/**/*.vue')
|
||||
|
||||
// 组件异步加载
|
||||
component: () => import('@/views/dashboard/index.vue')
|
||||
```
|
||||
|
||||
### 2. 缓存机制
|
||||
|
||||
```typescript
|
||||
// 路由缓存
|
||||
keepAlive: true
|
||||
|
||||
// 组件缓存
|
||||
<router-view v-slot="{ Component }">
|
||||
<keep-alive :include="cacheRoutes">
|
||||
<component :is="Component" />
|
||||
</keep-alive>
|
||||
</router-view>
|
||||
```
|
||||
|
||||
### 3. 状态优化
|
||||
|
||||
```typescript
|
||||
// 计算属性缓存
|
||||
const isLogin = computed(() => Boolean(authStore.token))
|
||||
|
||||
// 响应式数据最小化
|
||||
const userInfo = readonly(authStore.userInfo)
|
||||
```
|
||||
|
||||
## 🔄 数据流管理
|
||||
|
||||
### 1. 单向数据流
|
||||
|
||||
```
|
||||
用户操作 → Action → State → View → 用户界面
|
||||
```
|
||||
|
||||
### 2. 状态同步
|
||||
|
||||
```typescript
|
||||
// 状态持久化
|
||||
localStorage ↔ Pinia Store ↔ 组件状态
|
||||
```
|
||||
|
||||
### 3. 异步处理
|
||||
|
||||
```typescript
|
||||
// 异步状态管理
|
||||
async initAuthRoute() {
|
||||
this.loading = true
|
||||
try {
|
||||
const routes = await fetchUserRoutes()
|
||||
this.routes = routes
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 🧪 测试策略
|
||||
|
||||
### 1. 单元测试
|
||||
|
||||
- 工具函数测试
|
||||
- Store 逻辑测试
|
||||
- 组件单元测试
|
||||
|
||||
### 2. 集成测试
|
||||
|
||||
- 路由跳转测试
|
||||
- 权限控制测试
|
||||
- 用户流程测试
|
||||
|
||||
### 3. E2E测试
|
||||
|
||||
- 完整用户场景
|
||||
- 跨页面交互
|
||||
- 权限边界测试
|
||||
|
||||
## 🚀 部署架构
|
||||
|
||||
### 1. 构建流程
|
||||
|
||||
```bash
|
||||
# 开发环境
|
||||
npm run dev
|
||||
|
||||
# 构建生产版本
|
||||
npm run build
|
||||
|
||||
# 预览生产版本
|
||||
npm run preview
|
||||
```
|
||||
|
||||
### 2. 部署选项
|
||||
|
||||
- **静态托管**: Vercel, Netlify
|
||||
- **CDN部署**: 阿里云OSS, 腾讯云COS
|
||||
- **容器化**: Docker + Nginx
|
||||
- **传统服务器**: Apache, Nginx
|
||||
|
||||
## 📊 监控体系
|
||||
|
||||
### 1. 性能监控
|
||||
|
||||
- 页面加载时间
|
||||
- 路由切换性能
|
||||
- 组件渲染时间
|
||||
- 内存使用情况
|
||||
|
||||
### 2. 错误监控
|
||||
|
||||
- JavaScript错误
|
||||
- 网络请求错误
|
||||
- 路由错误
|
||||
- 用户行为异常
|
||||
|
||||
### 3. 用户体验监控
|
||||
|
||||
- 页面访问统计
|
||||
- 用户操作路径
|
||||
- 功能使用频率
|
||||
- 错误率统计
|
||||
|
||||
## 🔮 扩展性设计
|
||||
|
||||
### 1. 插件系统
|
||||
|
||||
```typescript
|
||||
// 插件接口
|
||||
interface Plugin {
|
||||
install(app: App): void
|
||||
beforeMount?(): void
|
||||
afterMount?(): void
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 主题系统
|
||||
|
||||
```typescript
|
||||
// 主题配置
|
||||
interface ThemeConfig {
|
||||
primary: string
|
||||
secondary: string
|
||||
background: string
|
||||
text: string
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 国际化
|
||||
|
||||
```typescript
|
||||
// 多语言支持
|
||||
const messages = {
|
||||
'zh-CN': zhCN,
|
||||
'en-US': enUS,
|
||||
}
|
||||
```
|
||||
|
||||
## 🎯 最佳实践
|
||||
|
||||
### 1. 代码规范
|
||||
|
||||
- **ESLint**: 代码质量检查
|
||||
- **Prettier**: 代码格式化
|
||||
- **TypeScript**: 类型安全
|
||||
- **命名约定**: 统一的命名规则
|
||||
|
||||
### 2. 项目结构
|
||||
|
||||
```
|
||||
src/
|
||||
├── components/ # 通用组件
|
||||
├── views/ # 页面组件
|
||||
├── store/ # 状态管理
|
||||
├── router/ # 路由配置
|
||||
├── hooks/ # 组合式函数
|
||||
├── utils/ # 工具函数
|
||||
├── types/ # 类型定义
|
||||
└── assets/ # 静态资源
|
||||
```
|
||||
|
||||
### 3. 开发流程
|
||||
|
||||
1. **需求分析**: 明确功能需求
|
||||
2. **设计评审**: 技术方案评估
|
||||
3. **编码实现**: 遵循代码规范
|
||||
4. **测试验证**: 单元测试和集成测试
|
||||
5. **代码审查**: 团队代码评审
|
||||
6. **部署发布**: 自动化部署流程
|
||||
|
||||
## 📚 相关文档
|
||||
|
||||
- [登录鉴权系统文档](./登录鉴权系统文档)
|
||||
- [权限管理系统文档](./权限管理系统文档)
|
||||
- [路由管理系统文档](./路由管理系统文档)
|
||||
- [Vue 3 官方文档](https://vuejs.org/)
|
||||
- [Naive UI 组件库](https://www.naiveui.com/)
|
||||
- [Pinia 状态管理](https://pinia.vuejs.org/)
|
||||
|
||||
## 🤝 贡献指南
|
||||
|
||||
1. Fork 项目仓库
|
||||
2. 创建功能分支
|
||||
3. 提交代码变更
|
||||
4. 发起 Pull Request
|
||||
5. 代码审查和合并
|
||||
|
||||
---
|
||||
|
||||
*本文档最后更新时间: 2024年7月*
|
||||
389
doc/权限管理系统文档.md
Normal file
389
doc/权限管理系统文档.md
Normal file
@ -0,0 +1,389 @@
|
||||
# Nova Admin 权限系统文档
|
||||
|
||||
## 概述
|
||||
|
||||
Nova Admin 采用基于角色的访问控制(RBAC)模型,通过用户角色和权限指令实现细粒度的权限控制。系统支持路由级别和组件级别的权限验证。
|
||||
|
||||
## 🏗️ 权限架构
|
||||
|
||||
### 核心组件
|
||||
|
||||
1. **权限Hook** (`src/hooks/usePermission.ts`)
|
||||
2. **权限指令** (`src/directives/permission.ts`)
|
||||
3. **路由权限过滤** (`src/store/router/helper.ts`)
|
||||
4. **用户角色管理** (`src/store/auth.ts`)
|
||||
|
||||
## 🎭 角色系统
|
||||
|
||||
### 角色类型定义
|
||||
|
||||
```typescript
|
||||
type Entity.RoleType = string // 如: 'admin', 'user', 'super', 'editor'
|
||||
```
|
||||
|
||||
### 内置角色层级
|
||||
|
||||
1. **super**: 超级管理员,拥有所有权限
|
||||
2. **admin**: 管理员角色
|
||||
3. **user**: 普通用户角色
|
||||
4. **editor**: 编辑者角色
|
||||
|
||||
### 角色权限矩阵
|
||||
|
||||
| 功能模块 | super | admin | editor | user |
|
||||
|---------|-------|-------|--------|------|
|
||||
| 仪表盘 | ✅ | ✅ | ✅ | ✅ |
|
||||
| 用户管理 | ✅ | ✅ | ❌ | ❌ |
|
||||
| 系统设置 | ✅ | ✅ | ❌ | ❌ |
|
||||
| 内容编辑 | ✅ | ✅ | ✅ | ❌ |
|
||||
|
||||
## 🔐 权限验证机制
|
||||
|
||||
### 1. usePermission Hook
|
||||
|
||||
位置:`src/hooks/usePermission.ts`
|
||||
|
||||
#### 核心逻辑
|
||||
|
||||
```typescript
|
||||
function hasPermission(permission?: Entity.RoleType | Entity.RoleType[]) {
|
||||
if (!permission) return true // 无权限要求,直接通过
|
||||
|
||||
if (!authStore.userInfo) return false // 未登录,拒绝访问
|
||||
|
||||
const { role } = authStore.userInfo
|
||||
|
||||
// super 角色拥有所有权限
|
||||
let has = role.includes('super')
|
||||
|
||||
if (!has) {
|
||||
if (isArray(permission)) {
|
||||
// 权限为数组,判断是否有交集
|
||||
has = permission.some(i => role.includes(i))
|
||||
}
|
||||
if (isString(permission)) {
|
||||
// 权限为字符串,判断是否包含
|
||||
has = role.includes(permission)
|
||||
}
|
||||
}
|
||||
|
||||
return has
|
||||
}
|
||||
```
|
||||
|
||||
#### 使用方式
|
||||
|
||||
```typescript
|
||||
import { usePermission } from '@/hooks'
|
||||
|
||||
const { hasPermission } = usePermission()
|
||||
|
||||
// 检查单个角色
|
||||
if (hasPermission('admin')) {
|
||||
// 用户拥有 admin 角色
|
||||
}
|
||||
|
||||
// 检查多个角色(任一匹配)
|
||||
if (hasPermission(['admin', 'editor'])) {
|
||||
// 用户拥有 admin 或 editor 角色
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 权限指令
|
||||
|
||||
位置:`src/directives/permission.ts`
|
||||
|
||||
#### 指令实现
|
||||
|
||||
```typescript
|
||||
const permissionDirective: Directive<HTMLElement, Entity.RoleType | Entity.RoleType[]> = {
|
||||
mounted(el, binding) {
|
||||
updatapermission(el, binding.value)
|
||||
},
|
||||
updated(el, binding) {
|
||||
updatapermission(el, binding.value)
|
||||
},
|
||||
}
|
||||
|
||||
function updatapermission(el: HTMLElement, permission: Entity.RoleType | Entity.RoleType[]) {
|
||||
if (!permission) {
|
||||
throw new Error('v-permission Directive with no explicit role attached')
|
||||
}
|
||||
|
||||
if (!hasPermission(permission)) {
|
||||
el.parentElement?.removeChild(el) // 移除DOM元素
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 使用方式
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<!-- 只有 admin 角色可见 -->
|
||||
<button v-permission="'admin'">管理员功能</button>
|
||||
|
||||
<!-- admin 或 editor 角色可见 -->
|
||||
<div v-permission="['admin', 'editor']">
|
||||
编辑内容
|
||||
</div>
|
||||
|
||||
<!-- super 角色可见 -->
|
||||
<section v-permission="'super'">
|
||||
超级管理员专用区域
|
||||
</section>
|
||||
</template>
|
||||
```
|
||||
|
||||
## 🛣️ 路由权限控制
|
||||
|
||||
### 路由元信息配置
|
||||
|
||||
```typescript
|
||||
interface AppRoute.RowRoute {
|
||||
// ... 其他属性
|
||||
roles?: Entity.RoleType[] // 访问路由所需的角色
|
||||
requiresAuth?: boolean // 是否需要登录
|
||||
}
|
||||
```
|
||||
|
||||
### 权限路由示例
|
||||
|
||||
```typescript
|
||||
export const staticRoutes: AppRoute.RowRoute[] = [
|
||||
{
|
||||
name: 'userManagement',
|
||||
path: '/setting/account',
|
||||
title: '用户管理',
|
||||
requiresAuth: true,
|
||||
roles: ['super', 'admin'], // 只有 super 或 admin 可访问
|
||||
componentPath: '/setting/account/index.vue',
|
||||
},
|
||||
{
|
||||
name: 'superOnly',
|
||||
path: '/admin/super',
|
||||
title: '超级管理',
|
||||
requiresAuth: true,
|
||||
roles: ['super'], // 只有 super 可访问
|
||||
componentPath: '/admin/super/index.vue',
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### 路由权限过滤
|
||||
|
||||
在 `src/store/router/helper.ts` 中实现:
|
||||
|
||||
```typescript
|
||||
export function createRoutes(routes: AppRoute.RowRoute[]) {
|
||||
const { hasPermission } = usePermission()
|
||||
|
||||
// 权限过滤
|
||||
let resultRouter = standardizedRoutes(routes)
|
||||
resultRouter = resultRouter.filter(i => hasPermission(i.meta.roles))
|
||||
|
||||
// ... 其他处理
|
||||
return resultRouter
|
||||
}
|
||||
```
|
||||
|
||||
## 📋 权限验证流程
|
||||
|
||||
### 1. 路由级权限验证
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[用户访问路由] --> B{路由需要权限?}
|
||||
B -->|否| G[允许访问]
|
||||
B -->|是| C{用户已登录?}
|
||||
C -->|否| D[重定向到登录页]
|
||||
C -->|是| E{用户角色匹配?}
|
||||
E -->|否| F[显示403错误]
|
||||
E -->|是| G[允许访问]
|
||||
```
|
||||
|
||||
### 2. 组件级权限验证
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[组件渲染] --> B{使用了权限指令?}
|
||||
B -->|否| F[正常渲染]
|
||||
B -->|是| C{用户角色匹配?}
|
||||
C -->|否| D[移除DOM元素]
|
||||
C -->|是| E[正常显示元素]
|
||||
```
|
||||
|
||||
### 3. API级权限验证
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[调用API] --> B[请求携带Token]
|
||||
B --> C[后端验证Token]
|
||||
C --> D{Token有效?}
|
||||
D -->|否| E[返回401错误]
|
||||
D -->|是| F{用户权限足够?}
|
||||
F -->|否| G[返回403错误]
|
||||
F -->|是| H[返回数据]
|
||||
```
|
||||
|
||||
## 🛠️ 开发指南
|
||||
|
||||
### 1. 添加新角色
|
||||
|
||||
1. **定义角色类型**(如果使用TypeScript):
|
||||
```typescript
|
||||
type Entity.RoleType = 'super' | 'admin' | 'editor' | 'user' | 'newRole'
|
||||
```
|
||||
|
||||
2. **在用户信息中配置角色**:
|
||||
```typescript
|
||||
const userInfo = {
|
||||
id: 1,
|
||||
userName: 'testUser',
|
||||
role: ['newRole'], // 用户角色数组
|
||||
// ... 其他信息
|
||||
}
|
||||
```
|
||||
|
||||
3. **在路由中使用新角色**:
|
||||
```typescript
|
||||
{
|
||||
name: 'newFeature',
|
||||
path: '/new-feature',
|
||||
roles: ['newRole'], // 新角色权限
|
||||
// ... 其他配置
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 组件中权限检查
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { usePermission } from '@/hooks'
|
||||
|
||||
const { hasPermission } = usePermission()
|
||||
|
||||
// 编程式权限检查
|
||||
const canEdit = computed(() => hasPermission(['admin', 'editor']))
|
||||
const canDelete = computed(() => hasPermission('admin'))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<!-- 指令式权限控制 -->
|
||||
<button v-permission="'admin'">删除</button>
|
||||
|
||||
<!-- 编程式权限控制 -->
|
||||
<button v-if="canEdit">编辑</button>
|
||||
|
||||
<!-- 条件渲染 -->
|
||||
<div v-if="hasPermission('super')">
|
||||
超级管理员专用功能
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
### 3. 动态权限控制
|
||||
|
||||
```typescript
|
||||
// 动态检查权限
|
||||
function checkDynamicPermission(action: string) {
|
||||
const requiredRoles = getRequiredRolesForAction(action)
|
||||
return hasPermission(requiredRoles)
|
||||
}
|
||||
|
||||
// 根据权限显示菜单
|
||||
const visibleMenuItems = computed(() => {
|
||||
return menuItems.filter(item => hasPermission(item.requiredRoles))
|
||||
})
|
||||
```
|
||||
|
||||
## 🔧 配置选项
|
||||
|
||||
### 权限配置文件
|
||||
|
||||
可以创建权限配置文件来集中管理权限:
|
||||
|
||||
```typescript
|
||||
// src/config/permissions.ts
|
||||
export const PERMISSIONS = {
|
||||
USER_MANAGEMENT: ['super', 'admin'],
|
||||
CONTENT_EDIT: ['super', 'admin', 'editor'],
|
||||
VIEW_DASHBOARD: ['super', 'admin', 'editor', 'user'],
|
||||
SYSTEM_CONFIG: ['super'],
|
||||
} as const
|
||||
|
||||
// 使用配置
|
||||
hasPermission(PERMISSIONS.USER_MANAGEMENT)
|
||||
```
|
||||
|
||||
### 默认权限行为
|
||||
|
||||
```typescript
|
||||
// 默认情况下的权限行为
|
||||
const defaultPermissionBehavior = {
|
||||
// 未指定权限时的默认行为
|
||||
noPermissionRequired: true,
|
||||
|
||||
// 超级管理员绕过所有权限检查
|
||||
superAdminBypass: true,
|
||||
|
||||
// 权限检查失败时的行为
|
||||
onPermissionDenied: 'hide', // 'hide' | 'disable' | 'redirect'
|
||||
}
|
||||
```
|
||||
|
||||
## 🚨 最佳实践
|
||||
|
||||
### 1. 权限粒度
|
||||
|
||||
- **页面级权限**: 控制整个页面的访问
|
||||
- **功能级权限**: 控制页面内特定功能
|
||||
- **数据级权限**: 控制数据的增删改查
|
||||
|
||||
### 2. 权限设计原则
|
||||
|
||||
- **最小权限原则**: 默认拒绝,明确授权
|
||||
- **角色继承**: 高级角色包含低级角色的权限
|
||||
- **权限组合**: 支持多角色的权限组合
|
||||
|
||||
### 3. 安全考虑
|
||||
|
||||
- **前端权限仅用于UI控制**: 不能作为安全防护的唯一手段
|
||||
- **后端验证**: 所有敏感操作必须在后端验证权限
|
||||
- **权限缓存**: 合理缓存权限信息,避免频繁查询
|
||||
|
||||
## 🐛 常见问题
|
||||
|
||||
### 1. 权限指令不生效
|
||||
|
||||
检查以下几点:
|
||||
- 指令是否正确注册
|
||||
- 权限值是否正确传递
|
||||
- 用户角色信息是否正确加载
|
||||
|
||||
### 2. 动态路由权限问题
|
||||
|
||||
确保在路由初始化时正确过滤权限:
|
||||
```typescript
|
||||
// 在 routeStore.initAuthRoute() 中
|
||||
const routes = createRoutes(rowRoutes) // 这里会进行权限过滤
|
||||
```
|
||||
|
||||
### 3. 权限更新不及时
|
||||
|
||||
当用户权限发生变化时:
|
||||
```typescript
|
||||
// 重新初始化路由
|
||||
await routeStore.initAuthRoute()
|
||||
|
||||
// 刷新页面权限
|
||||
window.location.reload() // 或使用更优雅的方式
|
||||
```
|
||||
|
||||
## 📚 相关文档
|
||||
|
||||
- [登录鉴权文档](./登录鉴权系统文档)
|
||||
- [路由系统文档](./路由管理系统文档)
|
||||
- [整体架构文档](./整体架构文档)
|
||||
288
doc/登录鉴权系统文档.md
Normal file
288
doc/登录鉴权系统文档.md
Normal file
@ -0,0 +1,288 @@
|
||||
# Nova Admin 登录鉴权系统文档
|
||||
|
||||
## 概述
|
||||
|
||||
Nova Admin 采用基于 JWT Token 的登录鉴权机制,通过 Pinia Store 管理认证状态,结合路由守卫实现完整的身份验证和访问控制。
|
||||
|
||||
## 🏗️ 架构设计
|
||||
|
||||
### 核心组件
|
||||
|
||||
1. **认证状态管理** (`src/store/auth.ts`)
|
||||
2. **路由守卫** (`src/router/guard.ts`)
|
||||
3. **登录页面** (`src/views/login/`)
|
||||
4. **API 接口** (`src/service/api/login.ts`)
|
||||
5. **本地存储工具** (`src/utils/storage.ts`)
|
||||
|
||||
## 🔐 认证流程
|
||||
|
||||
### 1. 登录流程
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as 用户
|
||||
participant L as 登录页面
|
||||
participant A as AuthStore
|
||||
participant API as 后端API
|
||||
participant R as RouteStore
|
||||
participant Router as 路由系统
|
||||
|
||||
U->>L: 输入用户名密码
|
||||
L->>A: authStore.login(username, password)
|
||||
A->>API: fetchLogin(credentials)
|
||||
API-->>A: 返回用户信息和Token
|
||||
A->>A: 保存Token和用户信息到localStorage
|
||||
A->>R: routeStore.initAuthRoute()
|
||||
R->>R: 初始化用户路由和菜单
|
||||
A->>Router: 重定向到首页或redirect页面
|
||||
```
|
||||
|
||||
### 2. 登出流程
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as 用户
|
||||
participant A as AuthStore
|
||||
participant R as RouteStore
|
||||
participant T as TabStore
|
||||
participant Router as 路由系统
|
||||
|
||||
U->>A: authStore.logout()
|
||||
A->>A: 清除本地存储(Token, userInfo)
|
||||
A->>R: routeStore.resetRouteStore()
|
||||
A->>T: tabStore.clearAllTabs()
|
||||
A->>A: 重置Store状态
|
||||
A->>Router: 重定向到登录页
|
||||
```
|
||||
|
||||
## 📁 文件结构详解
|
||||
|
||||
### AuthStore (`src/store/auth.ts`)
|
||||
|
||||
负责管理用户认证状态和相关操作:
|
||||
|
||||
#### 状态管理
|
||||
|
||||
```typescript
|
||||
interface AuthStatus {
|
||||
userInfo: Api.Login.Info | null // 用户信息
|
||||
token: string // 访问令牌
|
||||
}
|
||||
```
|
||||
|
||||
#### 核心方法
|
||||
|
||||
- **`login(userName, password)`**: 执行登录操作
|
||||
- **`logout()`**: 执行登出操作
|
||||
- **`handleLoginInfo(data)`**: 处理登录成功后的数据
|
||||
- **`clearAuthStorage()`**: 清除本地认证存储
|
||||
|
||||
#### 计算属性
|
||||
|
||||
- **`isLogin`**: 基于 token 判断用户是否已登录
|
||||
|
||||
### 登录页面组件
|
||||
|
||||
#### 主要文件
|
||||
- `src/views/login/index.vue` - 登录页面容器
|
||||
- `src/views/login/components/Login/index.vue` - 登录表单组件
|
||||
- `src/views/login/components/Register/index.vue` - 注册组件
|
||||
- `src/views/login/components/ResetPwd/index.vue` - 重置密码组件
|
||||
|
||||
#### 登录表单特性
|
||||
- 表单验证(用户名和密码必填)
|
||||
- 记住密码功能
|
||||
- 加载状态提示
|
||||
- 国际化支持
|
||||
|
||||
### API 接口 (`src/service/api/login.ts`)
|
||||
|
||||
#### 核心接口
|
||||
|
||||
```typescript
|
||||
// 用户登录
|
||||
fetchLogin(data: { userName: string, password: string })
|
||||
|
||||
// 刷新Token
|
||||
fetchUpdateToken(data: any)
|
||||
|
||||
// 获取用户路由
|
||||
fetchUserRoutes(params: { id: number })
|
||||
```
|
||||
|
||||
## 🛡️ 数据存储
|
||||
|
||||
### localStorage 存储项
|
||||
|
||||
| 键名 | 说明 | 数据类型 |
|
||||
|-----|------|---------|
|
||||
| `accessToken` | 访问令牌 | string |
|
||||
| `refreshToken` | 刷新令牌 | string |
|
||||
| `userInfo` | 用户信息 | Api.Login.Info |
|
||||
| `loginAccount` | 记住的登录账号 | {account: string, pwd: string} |
|
||||
|
||||
### 用户信息结构
|
||||
|
||||
```typescript
|
||||
interface Api.Login.Info {
|
||||
accessToken: string
|
||||
refreshToken: string
|
||||
id: number
|
||||
userName: string
|
||||
role: Entity.RoleType[] // 用户角色数组
|
||||
// ... 其他用户信息
|
||||
}
|
||||
```
|
||||
|
||||
## 🔒 安全机制
|
||||
|
||||
### Token 管理
|
||||
|
||||
1. **双Token机制**
|
||||
- `accessToken`: 用于API请求认证
|
||||
- `refreshToken`: 用于刷新访问令牌
|
||||
|
||||
2. **Token 存储**
|
||||
- 使用 localStorage 持久化存储
|
||||
- 页面刷新后自动恢复登录状态
|
||||
|
||||
3. **Token 验证**
|
||||
- 路由守卫检查 Token 有效性
|
||||
- API 请求自动携带 Token
|
||||
|
||||
### 密码安全
|
||||
|
||||
1. **前端验证**
|
||||
- 必填验证
|
||||
- 格式验证(可扩展)
|
||||
|
||||
2. **后端安全**
|
||||
- 密码加密存储(由后端实现)
|
||||
- 登录失败次数限制(由后端实现)
|
||||
|
||||
## 🚦 路由保护
|
||||
|
||||
### 认证检查
|
||||
|
||||
路由守卫在 `beforeEach` 钩子中执行认证检查:
|
||||
|
||||
```typescript
|
||||
// 判断有无TOKEN,登录鉴权
|
||||
const isLogin = Boolean(local.get('accessToken'))
|
||||
|
||||
if (to.meta.requiresAuth === true && !isLogin) {
|
||||
const redirect = to.name === '404' ? undefined : to.fullPath
|
||||
next({ path: '/login', query: { redirect } })
|
||||
return
|
||||
}
|
||||
```
|
||||
|
||||
### 重定向机制
|
||||
|
||||
1. **未登录访问受保护路由**: 重定向到登录页,并保存原始路由
|
||||
2. **登录成功**: 重定向到原始路由或默认首页
|
||||
3. **已登录访问登录页**: 重定向到首页
|
||||
|
||||
## 🛠️ 开发指南
|
||||
|
||||
### 添加新的认证检查
|
||||
|
||||
1. **在路由元信息中设置认证要求**:
|
||||
```typescript
|
||||
{
|
||||
path: '/protected',
|
||||
meta: {
|
||||
requiresAuth: true // 需要登录
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2. **在组件中检查登录状态**:
|
||||
```typescript
|
||||
import { useAuthStore } from '@/store'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
if (!authStore.isLogin) {
|
||||
// 处理未登录状态
|
||||
}
|
||||
```
|
||||
|
||||
### 自定义登录逻辑
|
||||
|
||||
可以通过扩展 `AuthStore` 的 `login` 方法来实现自定义登录逻辑:
|
||||
|
||||
```typescript
|
||||
// 扩展登录验证
|
||||
async login(userName: string, password: string, captcha?: string) {
|
||||
try {
|
||||
// 添加验证码验证
|
||||
if (this.needCaptcha && !captcha) {
|
||||
throw new Error('需要验证码')
|
||||
}
|
||||
|
||||
const { isSuccess, data } = await fetchLogin({
|
||||
userName,
|
||||
password,
|
||||
captcha
|
||||
})
|
||||
|
||||
if (!isSuccess) return
|
||||
await this.handleLoginInfo(data)
|
||||
} catch (e) {
|
||||
console.warn('[Login Error]:', e)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 🔧 配置项
|
||||
|
||||
### 环境变量
|
||||
|
||||
- `VITE_ROUTE_LOAD_MODE`: 路由加载模式(static/dynamic)
|
||||
- `VITE_HOME_PATH`: 登录成功后的默认首页路径
|
||||
|
||||
### 默认配置
|
||||
|
||||
```typescript
|
||||
// 默认登录账号(开发环境)
|
||||
const formValue = ref({
|
||||
account: 'admin',
|
||||
pwd: '123456',
|
||||
})
|
||||
```
|
||||
|
||||
## 🚨 常见问题
|
||||
|
||||
### 1. Token 过期处理
|
||||
|
||||
系统会在请求拦截器中自动处理 Token 过期:
|
||||
- 检测到 Token 过期
|
||||
- 自动调用刷新 Token 接口
|
||||
- 重新发送原始请求
|
||||
|
||||
### 2. 路由初始化时机
|
||||
|
||||
确保在路由守卫中正确初始化路由:
|
||||
```typescript
|
||||
if (!routeStore.isInitAuthRoute) {
|
||||
await routeStore.initAuthRoute()
|
||||
// 处理 404 重新导航
|
||||
if (to.name === '404') {
|
||||
next({ path: to.fullPath, replace: true })
|
||||
return
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 登录状态丢失
|
||||
|
||||
检查以下几点:
|
||||
- localStorage 是否被清除
|
||||
- Token 是否过期
|
||||
- 网络请求是否正常
|
||||
|
||||
## 📚 相关文档
|
||||
|
||||
- [权限系统文档](./权限管理系统文档)
|
||||
- [路由系统文档](./路由管理系统文档)
|
||||
- [整体架构文档](./整体架构文档)
|
||||
603
doc/路由管理系统文档.md
Normal file
603
doc/路由管理系统文档.md
Normal file
@ -0,0 +1,603 @@
|
||||
# Nova Admin 路由系统文档
|
||||
|
||||
## 概述
|
||||
|
||||
Nova Admin 基于 Vue Router 4 构建了一套完整的路由管理系统,支持静态路由和动态路由,具备完善的路由守卫、权限控制、菜单生成等功能。
|
||||
|
||||
## 🏗️ 路由架构
|
||||
|
||||
### 核心组件
|
||||
|
||||
1. **路由配置** (`src/router/`)
|
||||
2. **路由守卫** (`src/router/guard.ts`)
|
||||
3. **路由状态管理** (`src/store/router/`)
|
||||
4. **菜单生成** (`src/store/router/helper.ts`)
|
||||
5. **标签页管理** (`src/store/tab.ts`)
|
||||
|
||||
### 文件结构
|
||||
|
||||
```
|
||||
src/router/
|
||||
├── index.ts # 路由主配置文件
|
||||
├── guard.ts # 路由守卫
|
||||
├── routes.inner.ts # 内置路由(登录、错误页等)
|
||||
└── routes.static.ts # 静态路由配置
|
||||
|
||||
src/store/router/
|
||||
├── index.ts # 路由状态管理
|
||||
└── helper.ts # 路由处理辅助函数
|
||||
```
|
||||
|
||||
## 🛣️ 路由类型
|
||||
|
||||
### 1. 内置路由 (Inner Routes)
|
||||
|
||||
位置:`src/router/routes.inner.ts`
|
||||
|
||||
包含系统基础路由:
|
||||
- 根路由 (`/`)
|
||||
- 登录页 (`/login`)
|
||||
- 错误页 (`/403`, `/404`, `/500`)
|
||||
- 通配符路由 (404 处理)
|
||||
|
||||
```typescript
|
||||
export const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/',
|
||||
name: 'root',
|
||||
redirect: '/appRoot',
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
component: () => import('@/views/login/index.vue'),
|
||||
meta: {
|
||||
title: '登录',
|
||||
withoutTab: true,
|
||||
},
|
||||
},
|
||||
// ... 错误页路由
|
||||
]
|
||||
```
|
||||
|
||||
### 2. 静态路由 (Static Routes)
|
||||
|
||||
位置:`src/router/routes.static.ts`
|
||||
|
||||
业务功能路由配置:
|
||||
|
||||
```typescript
|
||||
export const staticRoutes: AppRoute.RowRoute[] = [
|
||||
{
|
||||
name: 'monitor',
|
||||
path: '/dashboard/monitor',
|
||||
title: '仪表盘',
|
||||
requiresAuth: true,
|
||||
icon: 'icon-park-outline:anchor',
|
||||
menuType: 'page',
|
||||
componentPath: '/dashboard/monitor/index.vue',
|
||||
id: 3,
|
||||
pid: null,
|
||||
},
|
||||
// ... 其他业务路由
|
||||
]
|
||||
```
|
||||
|
||||
### 3. 动态路由 (Dynamic Routes)
|
||||
|
||||
通过 API 从后端获取的路由配置,结构与静态路由相同。
|
||||
|
||||
## 📋 路由数据结构
|
||||
|
||||
### AppRoute.RowRoute 接口
|
||||
|
||||
```typescript
|
||||
interface AppRoute.RowRoute {
|
||||
/** 路由名称 */
|
||||
name: string
|
||||
/** 路由路径 */
|
||||
path: string
|
||||
/** 页面标题 */
|
||||
title: string
|
||||
/** 是否需要认证 */
|
||||
requiresAuth?: boolean
|
||||
/** 访问角色 */
|
||||
roles?: Entity.RoleType[]
|
||||
/** 图标 */
|
||||
icon?: string
|
||||
/** 菜单类型 */
|
||||
menuType?: 'dir' | 'page'
|
||||
/** 组件路径 */
|
||||
componentPath?: string | null
|
||||
/** 路由ID */
|
||||
id: number
|
||||
/** 父路由ID */
|
||||
pid: number | null
|
||||
/** 是否隐藏 */
|
||||
hide?: boolean
|
||||
/** 排序权重 */
|
||||
order?: number
|
||||
/** 外链地址 */
|
||||
href?: string
|
||||
/** 激活菜单 */
|
||||
activeMenu?: string
|
||||
/** 不显示标签页 */
|
||||
withoutTab?: boolean
|
||||
/** 固定标签页 */
|
||||
pinTab?: boolean
|
||||
/** 是否缓存 */
|
||||
keepAlive?: boolean
|
||||
}
|
||||
```
|
||||
|
||||
### 路由元信息 (Meta)
|
||||
|
||||
```typescript
|
||||
interface RouteMeta {
|
||||
/** 页面标题 */
|
||||
title: string
|
||||
/** 图标 */
|
||||
icon?: string
|
||||
/** 是否需要认证 */
|
||||
requiresAuth?: boolean
|
||||
/** 访问角色 */
|
||||
roles?: Entity.RoleType[]
|
||||
/** 是否缓存 */
|
||||
keepAlive?: boolean
|
||||
/** 是否隐藏 */
|
||||
hide?: boolean
|
||||
/** 排序权重 */
|
||||
order?: number
|
||||
/** 外链地址 */
|
||||
href?: string
|
||||
/** 激活菜单 */
|
||||
activeMenu?: string
|
||||
/** 不显示标签页 */
|
||||
withoutTab?: boolean
|
||||
/** 固定标签页 */
|
||||
pinTab?: boolean
|
||||
/** 菜单类型 */
|
||||
menuType?: 'dir' | 'page'
|
||||
}
|
||||
```
|
||||
|
||||
## 🔄 路由处理流程
|
||||
|
||||
### 1. 路由初始化流程
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant App as 应用启动
|
||||
participant Router as 路由系统
|
||||
participant Guard as 路由守卫
|
||||
participant Store as RouteStore
|
||||
participant API as 后端API
|
||||
|
||||
App->>Router: 创建路由实例
|
||||
Router->>Guard: 安装路由守卫
|
||||
App->>Store: 初始化路由状态
|
||||
|
||||
Note over Router,Store: 用户访问路由时
|
||||
Router->>Guard: beforeEach 触发
|
||||
Guard->>Store: 检查路由是否初始化
|
||||
Store->>Store: initAuthRoute()
|
||||
|
||||
alt 动态路由模式
|
||||
Store->>API: fetchUserRoutes()
|
||||
API-->>Store: 返回用户路由数据
|
||||
else 静态路由模式
|
||||
Store->>Store: 使用 staticRoutes
|
||||
end
|
||||
|
||||
Store->>Store: createRoutes() 生成路由
|
||||
Store->>Router: addRoute() 注册路由
|
||||
Store->>Store: createMenus() 生成菜单
|
||||
Guard->>Router: next() 继续导航
|
||||
```
|
||||
|
||||
### 2. 路由守卫执行流程
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[路由跳转] --> B{外链路由?}
|
||||
B -->|是| C[打开新窗口]
|
||||
B -->|否| D[开始 LoadingBar]
|
||||
|
||||
D --> E{登录页?}
|
||||
E -->|是| F[直接放行]
|
||||
E -->|否| G{requiresAuth=false?}
|
||||
G -->|是| F
|
||||
G -->|否| H{需要认证且未登录?}
|
||||
H -->|是| I[重定向到登录页]
|
||||
H -->|否| J{路由已初始化?}
|
||||
|
||||
J -->|否| K[初始化路由]
|
||||
K --> L{是404页面?}
|
||||
L -->|是| M[重新导航到正确路径]
|
||||
L -->|否| N[继续导航]
|
||||
|
||||
J -->|是| O{已登录访问登录页?}
|
||||
O -->|是| P[重定向到首页]
|
||||
O -->|否| N
|
||||
|
||||
F --> N
|
||||
N --> Q[设置菜单高亮]
|
||||
Q --> R[添加标签页]
|
||||
R --> S[设置页面标题]
|
||||
S --> T[结束 LoadingBar]
|
||||
```
|
||||
|
||||
## 🎯 路由守卫详解
|
||||
|
||||
### beforeEach 守卫
|
||||
|
||||
位置:`src/router/guard.ts`
|
||||
|
||||
#### 主要功能
|
||||
|
||||
1. **外链处理**: 检测并打开外部链接
|
||||
2. **进度条控制**: 开始 LoadingBar
|
||||
3. **登录验证**: 检查用户认证状态
|
||||
4. **路由初始化**: 动态加载和注册路由
|
||||
5. **重定向处理**: 处理各种重定向场景
|
||||
|
||||
#### 核心逻辑
|
||||
|
||||
```typescript
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
// 1. 外链处理
|
||||
if (to.meta.href) {
|
||||
window.open(to.meta.href)
|
||||
next(false)
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 开始进度条
|
||||
appStore.showProgress && window.$loadingBar?.start()
|
||||
|
||||
// 3. 登录验证
|
||||
const isLogin = Boolean(local.get('accessToken'))
|
||||
|
||||
if (to.meta.requiresAuth === true && !isLogin) {
|
||||
const redirect = to.name === '404' ? undefined : to.fullPath
|
||||
next({ path: '/login', query: { redirect } })
|
||||
return
|
||||
}
|
||||
|
||||
// 4. 路由初始化
|
||||
if (!routeStore.isInitAuthRoute) {
|
||||
await routeStore.initAuthRoute()
|
||||
if (to.name === '404') {
|
||||
next({ path: to.fullPath, replace: true })
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 登录页重定向
|
||||
if (to.name === 'login' && isLogin) {
|
||||
next({ path: '/' })
|
||||
return
|
||||
}
|
||||
|
||||
next()
|
||||
})
|
||||
```
|
||||
|
||||
### beforeResolve 守卫
|
||||
|
||||
```typescript
|
||||
router.beforeResolve((to) => {
|
||||
// 设置菜单高亮
|
||||
routeStore.setActiveMenu(to.meta.activeMenu ?? to.fullPath)
|
||||
// 添加标签页
|
||||
tabStore.addTab(to)
|
||||
// 设置当前标签页
|
||||
tabStore.setCurrentTab(to.fullPath)
|
||||
})
|
||||
```
|
||||
|
||||
### afterEach 守卫
|
||||
|
||||
```typescript
|
||||
router.afterEach((to) => {
|
||||
// 设置页面标题
|
||||
document.title = `${to.meta.title} - ${title}`
|
||||
// 结束进度条
|
||||
appStore.showProgress && window.$loadingBar?.finish()
|
||||
})
|
||||
```
|
||||
|
||||
## 🏪 路由状态管理
|
||||
|
||||
### RouteStore 状态
|
||||
|
||||
```typescript
|
||||
interface RoutesStatus {
|
||||
/** 路由是否已初始化 */
|
||||
isInitAuthRoute: boolean
|
||||
/** 菜单数据 */
|
||||
menus: MenuOption[]
|
||||
/** 原始路由数据 */
|
||||
rowRoutes: AppRoute.RowRoute[]
|
||||
/** 当前激活菜单 */
|
||||
activeMenu: string | null
|
||||
/** 缓存路由 */
|
||||
cacheRoutes: string[]
|
||||
}
|
||||
```
|
||||
|
||||
### 核心方法
|
||||
|
||||
#### initAuthRoute()
|
||||
|
||||
初始化认证路由的完整流程:
|
||||
|
||||
```typescript
|
||||
async initAuthRoute() {
|
||||
this.isInitAuthRoute = false
|
||||
|
||||
// 1. 获取路由数据
|
||||
const rowRoutes = await this.initRouteInfo()
|
||||
if (!rowRoutes) return
|
||||
|
||||
this.rowRoutes = rowRoutes
|
||||
|
||||
// 2. 生成实际路由并注册
|
||||
const routes = createRoutes(rowRoutes)
|
||||
router.addRoute(routes)
|
||||
|
||||
// 3. 生成侧边菜单
|
||||
this.menus = createMenus(rowRoutes)
|
||||
|
||||
// 4. 生成路由缓存
|
||||
this.cacheRoutes = generateCacheRoutes(rowRoutes)
|
||||
|
||||
this.isInitAuthRoute = true
|
||||
}
|
||||
```
|
||||
|
||||
#### initRouteInfo()
|
||||
|
||||
根据配置模式获取路由信息:
|
||||
|
||||
```typescript
|
||||
async initRouteInfo() {
|
||||
if (import.meta.env.VITE_ROUTE_LOAD_MODE === 'dynamic') {
|
||||
// 动态路由:从 API 获取
|
||||
const userInfo = local.get('userInfo')
|
||||
const { data } = await fetchUserRoutes({ id: userInfo.id })
|
||||
return data
|
||||
} else {
|
||||
// 静态路由:使用本地配置
|
||||
this.rowRoutes = staticRoutes
|
||||
return staticRoutes
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 🎨 菜单生成
|
||||
|
||||
### 菜单生成流程
|
||||
|
||||
```typescript
|
||||
export function createMenus(routes: AppRoute.RowRoute[]) {
|
||||
const { hasPermission } = usePermission()
|
||||
|
||||
// 1. 权限过滤
|
||||
let resultMenus = routes.filter(i => hasPermission(i.roles))
|
||||
|
||||
// 2. 隐藏项过滤
|
||||
resultMenus = resultMenus.filter(i => !i.hide)
|
||||
|
||||
// 3. 转换菜单格式
|
||||
const menus = resultMenus.map(transformRouteToMenu)
|
||||
|
||||
// 4. 生成菜单树
|
||||
return arrayToTree(menus)
|
||||
}
|
||||
```
|
||||
|
||||
### 菜单数据结构
|
||||
|
||||
```typescript
|
||||
interface MenuOption {
|
||||
label: string // 菜单标题
|
||||
key: string // 菜单键值
|
||||
icon?: () => VNode // 菜单图标
|
||||
children?: MenuOption[] // 子菜单
|
||||
disabled?: boolean // 是否禁用
|
||||
}
|
||||
```
|
||||
|
||||
## 🏷️ 标签页系统
|
||||
|
||||
### TabStore 管理
|
||||
|
||||
标签页系统与路由系统紧密集成:
|
||||
|
||||
```typescript
|
||||
// 添加标签页
|
||||
tabStore.addTab(route)
|
||||
|
||||
// 设置当前标签页
|
||||
tabStore.setCurrentTab(route.fullPath)
|
||||
|
||||
// 关闭标签页
|
||||
tabStore.closeTab(fullPath)
|
||||
```
|
||||
|
||||
### 特殊标签页类型
|
||||
|
||||
1. **固定标签页** (`pinTab: true`): 不可关闭的标签页
|
||||
2. **无标签页** (`withoutTab: true`): 不显示在标签栏的页面
|
||||
|
||||
## ⚙️ 配置选项
|
||||
|
||||
### 环境变量
|
||||
|
||||
```bash
|
||||
# 路由加载模式
|
||||
VITE_ROUTE_LOAD_MODE=static|dynamic
|
||||
|
||||
# 默认首页路径
|
||||
VITE_HOME_PATH=/dashboard/monitor
|
||||
|
||||
# 应用名称
|
||||
VITE_APP_NAME=Nova Admin
|
||||
```
|
||||
|
||||
### 路由模式配置
|
||||
|
||||
```typescript
|
||||
// vite.config.ts 或 .env 文件
|
||||
const { VITE_ROUTE_MODE = 'hash', VITE_BASE_URL } = import.meta.env
|
||||
|
||||
export const router = createRouter({
|
||||
history: VITE_ROUTE_MODE === 'hash'
|
||||
? createWebHashHistory(VITE_BASE_URL)
|
||||
: createWebHistory(VITE_BASE_URL),
|
||||
routes,
|
||||
})
|
||||
```
|
||||
|
||||
## 🛠️ 开发指南
|
||||
|
||||
### 1. 添加新路由
|
||||
|
||||
#### 静态路由方式
|
||||
|
||||
在 `src/router/routes.static.ts` 中添加:
|
||||
|
||||
```typescript
|
||||
{
|
||||
name: 'newPage',
|
||||
path: '/new-page',
|
||||
title: '新页面',
|
||||
requiresAuth: true,
|
||||
icon: 'icon-park-outline:new',
|
||||
menuType: 'page',
|
||||
componentPath: '/new/page/index.vue',
|
||||
id: 100,
|
||||
pid: null,
|
||||
}
|
||||
```
|
||||
|
||||
#### 动态路由方式
|
||||
|
||||
通过后端 API 返回路由配置,格式与静态路由相同。
|
||||
|
||||
### 2. 路由权限配置
|
||||
|
||||
```typescript
|
||||
{
|
||||
name: 'adminPage',
|
||||
path: '/admin',
|
||||
title: '管理页面',
|
||||
requiresAuth: true,
|
||||
roles: ['admin', 'super'], // 权限控制
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 嵌套路由
|
||||
|
||||
```typescript
|
||||
// 父路由
|
||||
{
|
||||
name: 'parent',
|
||||
path: '/parent',
|
||||
title: '父级菜单',
|
||||
menuType: 'dir',
|
||||
componentPath: null,
|
||||
id: 1,
|
||||
pid: null,
|
||||
}
|
||||
|
||||
// 子路由
|
||||
{
|
||||
name: 'child',
|
||||
path: '/parent/child',
|
||||
title: '子级菜单',
|
||||
menuType: 'page',
|
||||
componentPath: '/parent/child/index.vue',
|
||||
id: 2,
|
||||
pid: 1, // 指向父路由ID
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 路由缓存
|
||||
|
||||
```typescript
|
||||
{
|
||||
name: 'cachedPage',
|
||||
path: '/cached',
|
||||
title: '缓存页面',
|
||||
keepAlive: true, // 启用路由缓存
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
## 🐛 常见问题
|
||||
|
||||
### 1. 动态路由不生效
|
||||
|
||||
检查:
|
||||
- 路由数据格式是否正确
|
||||
- 组件路径是否存在
|
||||
- 权限配置是否正确
|
||||
|
||||
### 2. 菜单不显示
|
||||
|
||||
检查:
|
||||
- `hide` 属性是否为 `true`
|
||||
- 权限验证是否通过
|
||||
- `menuType` 是否正确设置
|
||||
|
||||
### 3. 404 页面循环
|
||||
|
||||
确保:
|
||||
- 静态路由已正确配置
|
||||
- 路由初始化完成
|
||||
- 通配符路由放在最后
|
||||
|
||||
### 4. 标签页问题
|
||||
|
||||
检查:
|
||||
- `withoutTab` 设置
|
||||
- 路由路径是否正确
|
||||
- TabStore 状态是否正常
|
||||
|
||||
## 🎯 最佳实践
|
||||
|
||||
### 1. 路由命名规范
|
||||
|
||||
```typescript
|
||||
// 推荐命名方式
|
||||
name: 'userManagement' // 驼峰命名
|
||||
name: 'user-management' // 中划线命名
|
||||
name: 'user_management' // 下划线命名
|
||||
```
|
||||
|
||||
### 2. 路径规范
|
||||
|
||||
```typescript
|
||||
// 路径应该语义化
|
||||
path: '/user/management' // ✅ 好的
|
||||
path: '/user/mgmt' // ❌ 避免缩写
|
||||
path: '/u/m' // ❌ 避免单字符
|
||||
```
|
||||
|
||||
### 3. 权限设计
|
||||
|
||||
```typescript
|
||||
// 明确的权限配置
|
||||
roles: ['admin'] // ✅ 单角色
|
||||
roles: ['admin', 'manager'] // ✅ 多角色
|
||||
requiresAuth: true // ✅ 明确需要认证
|
||||
```
|
||||
|
||||
## 📚 相关文档
|
||||
|
||||
- [登录鉴权文档](./登录鉴权系统文档)
|
||||
- [权限系统文档](./权限管理系统文档)
|
||||
- [整体架构文档](./整体架构文档)
|
||||
8
docker-compose.product.yml
Normal file
8
docker-compose.product.yml
Normal file
@ -0,0 +1,8 @@
|
||||
services:
|
||||
nova-admin:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./docker/dockerfile.product
|
||||
container_name: nova-admin
|
||||
ports:
|
||||
- 80:80
|
||||
25
eslint.config.js
Normal file
25
eslint.config.js
Normal file
@ -0,0 +1,25 @@
|
||||
// eslint.config.js
|
||||
import antfu from '@antfu/eslint-config'
|
||||
|
||||
// https://github.com/antfu/eslint-config
|
||||
export default antfu(
|
||||
{
|
||||
ignores: [
|
||||
'doc/**/*.md', // 忽略文档目录下的Markdown文件
|
||||
],
|
||||
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',
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
17
index.html
Normal file
17
index.html
Normal file
@ -0,0 +1,17 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>%VITE_APP_NAME%</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="appLoading"></div>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
164
locales/en_US.json
Normal file
164
locales/en_US.json
Normal file
@ -0,0 +1,164 @@
|
||||
{
|
||||
"common": {
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Confirm",
|
||||
"close": "Closure",
|
||||
"reload": "Refresh",
|
||||
"choose": "Choose",
|
||||
"navigate": "Navigate",
|
||||
"inputPlaceholder": "please enter",
|
||||
"selectPlaceholder": "please choose"
|
||||
},
|
||||
"app": {
|
||||
"loginOut": "Login out",
|
||||
"loginOutContent": "Confirm to log out of current account?",
|
||||
"loginOutTitle": "Sign out",
|
||||
"userCenter": "Personal center",
|
||||
"light": "Light",
|
||||
"dark": "Dark",
|
||||
"system": "System",
|
||||
"backTop": "Back to top",
|
||||
"toggleSider": "Toggle sidebar",
|
||||
"BreadcrumbIcon": "Breadcrumbs icon",
|
||||
"blackAndWhite": "Black and white mode",
|
||||
"bottomCopyright": "Bottom copyright",
|
||||
"breadcrumb": "Bread crumbs",
|
||||
"colorWeak": "Color Weakness Mode",
|
||||
"interfaceDisplay": "Interface display",
|
||||
"logoDisplay": "LOGO display",
|
||||
"messages": "Messages",
|
||||
"multitab": "Display multiple tabs",
|
||||
"notifications": "Notify",
|
||||
"notificationsTips": "Notification",
|
||||
"pageTransition": "Page transition",
|
||||
"reset": "Reset",
|
||||
"resetSettingContent": "Confirm to reset all settings?",
|
||||
"resetSettingMeaasge": "Reset successful",
|
||||
"resetSettingTitle": "Reset settings",
|
||||
"searchPlaceholder": "Search page/path",
|
||||
"setting": "Setting",
|
||||
"systemSetting": "System settings",
|
||||
"themeColor": "Theme color",
|
||||
"themeSetting": "Theme settings",
|
||||
"todos": "Todos",
|
||||
"toggleFullScreen": "Toggle full screen",
|
||||
"togglContentFullScreen": "Toggle content full screen",
|
||||
"topProgress": "Top progress",
|
||||
"transitionFadeBottom": "Bottom fade",
|
||||
"transitionFadeScale": "Scale fade",
|
||||
"transitionFadeSlide": "Side fade",
|
||||
"transitionNull": "No transition",
|
||||
"transitionSoft": "Soft",
|
||||
"transitionZoomFade": "Expand fade out",
|
||||
"transitionZoomOut": "Zoom out",
|
||||
"watermake": "Watermark",
|
||||
"closeOther": "Close other",
|
||||
"closeAll": "Close all",
|
||||
"closeLeft": "Close left",
|
||||
"closeRight": "Close right",
|
||||
"backHome": "Back to the homepage",
|
||||
"getRouteError": "Failed to obtain route, please try again later.",
|
||||
"layoutSetting": "Layout settings",
|
||||
"leftMenu": "Left menu",
|
||||
"topMenu": "Top menu",
|
||||
"mixMenu": "Mix menu"
|
||||
},
|
||||
"login": {
|
||||
"signInTitle": "Login",
|
||||
"accountRuleTip": "Please enter account",
|
||||
"passwordRuleTip": "Please enter password",
|
||||
"or": "Or",
|
||||
"rememberMe": "Remember me",
|
||||
"forgotPassword": "Forget the password?",
|
||||
"signIn": "Sign in",
|
||||
"signUp": "Sign up",
|
||||
"noAccountText": "Don't have an account?",
|
||||
"accountPlaceholder": "Enter the account number",
|
||||
"checkPasswordPlaceholder": "Please enter password again",
|
||||
"checkPasswordRuleTip": "Please confirm password again",
|
||||
"haveAccountText": "Do you have an account?",
|
||||
"passwordPlaceholder": "Enter password",
|
||||
"readAndAgree": "I have read and agree",
|
||||
"registerTitle": "Register",
|
||||
"userAgreement": "User Agreement",
|
||||
"resetPassword": "Reset password",
|
||||
"resetPasswordPlaceholder": "Enter account/mobile phone number",
|
||||
"resetPasswordRuleTip": "Please enter your account/mobile phone number",
|
||||
"resetPasswordTitle": "Reset"
|
||||
},
|
||||
"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",
|
||||
"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)",
|
||||
"echarts": "Echarts",
|
||||
"editor": "Editor",
|
||||
"editorMd": "MarkDown editor",
|
||||
"editorRich": "Rich text editor",
|
||||
"error": "Exception page",
|
||||
"icons": "Icon",
|
||||
"justSuper": "Supervisible",
|
||||
"map": "Map",
|
||||
"menuSetting": "Menu Settings",
|
||||
"permission": "Permissions",
|
||||
"permissionDemo": "Permissions example",
|
||||
"setting": "System settings",
|
||||
"userCenter": "Personal Center",
|
||||
"accountSetting": "User settings",
|
||||
"cascader": "Administrative region selection",
|
||||
"dict": "Dictionary example"
|
||||
},
|
||||
"http": {
|
||||
"400": "Syntax error in the request",
|
||||
"401": "User unauthorized",
|
||||
"403": "Server refused access",
|
||||
"404": "Requested resource does not exist",
|
||||
"405": "Request method not allowed",
|
||||
"408": "Network request timed out",
|
||||
"500": "Internal server error",
|
||||
"501": "Server not implemented the requested functionality",
|
||||
"502": "Bad gateway",
|
||||
"503": "Service unavailable",
|
||||
"504": "Gateway timeout",
|
||||
"505": "HTTP version not supported for this request",
|
||||
"defaultTip": "Request error"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
164
locales/zh_CN.json
Normal file
164
locales/zh_CN.json
Normal file
@ -0,0 +1,164 @@
|
||||
{
|
||||
"common": {
|
||||
"confirm": "确认",
|
||||
"cancel": "取消",
|
||||
"reload": "刷新",
|
||||
"close": "关闭",
|
||||
"choose": "选择",
|
||||
"navigate": "切换",
|
||||
"inputPlaceholder": "请输入",
|
||||
"selectPlaceholder": "请选择"
|
||||
},
|
||||
"app": {
|
||||
"loginOut": "退出登录",
|
||||
"loginOutTitle": "退出登录",
|
||||
"loginOutContent": "确认退出当前账号?",
|
||||
"userCenter": "个人中心",
|
||||
"light": "浅色",
|
||||
"dark": "深色",
|
||||
"system": "跟随系统",
|
||||
"backTop": "返回顶部",
|
||||
"toggleSider": "切换侧边栏",
|
||||
"toggleFullScreen": "切换全屏",
|
||||
"togglContentFullScreen": "切换内容全屏",
|
||||
"notificationsTips": "消息通知",
|
||||
"notifications": "通知",
|
||||
"messages": "消息",
|
||||
"todos": "待办",
|
||||
"searchPlaceholder": "搜索页面/路径",
|
||||
"resetSettingTitle": "重置设置",
|
||||
"resetSettingContent": "确认重置所有设置?",
|
||||
"resetSettingMeaasge": "重置成功",
|
||||
"reset": "重置",
|
||||
"setting": "设置",
|
||||
"themeSetting": "主题设置",
|
||||
"colorWeak": "色弱模式",
|
||||
"blackAndWhite": "黑白模式",
|
||||
"themeColor": "主题色",
|
||||
"pageTransition": "页面过渡",
|
||||
"transitionNull": "无过渡",
|
||||
"transitionFadeSlide": "侧边淡出",
|
||||
"transitionFadeBottom": "底边淡出",
|
||||
"transitionFadeScale": "收缩淡出",
|
||||
"transitionZoomFade": "扩大淡出",
|
||||
"transitionZoomOut": "收缩",
|
||||
"transitionSoft": "柔和",
|
||||
"systemSetting": "系统设置",
|
||||
"interfaceDisplay": "界面显示",
|
||||
"logoDisplay": "LOGO显示",
|
||||
"topProgress": "顶部进度",
|
||||
"multitab": "多页签显示",
|
||||
"bottomCopyright": "底部版权",
|
||||
"breadcrumb": "面包屑",
|
||||
"BreadcrumbIcon": "面包屑图标",
|
||||
"watermake": "水印",
|
||||
"closeOther": "关闭其他",
|
||||
"closeLeft": "关闭左侧",
|
||||
"closeRight": "关闭右侧",
|
||||
"closeAll": "全部关闭",
|
||||
"backHome": "回到首页",
|
||||
"getRouteError": "获取路由失败,请稍后再试",
|
||||
"layoutSetting": "布局设置",
|
||||
"leftMenu": "左侧菜单",
|
||||
"topMenu": "顶部菜单",
|
||||
"mixMenu": "混合菜单"
|
||||
},
|
||||
"http": {
|
||||
"400": "请求出现语法错误",
|
||||
"401": "用户未授权",
|
||||
"403": "服务器拒绝访问",
|
||||
"404": "请求的资源不存在",
|
||||
"405": "请求方法未允许",
|
||||
"408": "网络请求超时",
|
||||
"500": "服务器内部错误",
|
||||
"501": "服务器未实现请求功能",
|
||||
"502": "错误网关",
|
||||
"503": "服务不可用",
|
||||
"504": "网关超时",
|
||||
"505": "http版本不支持该请求",
|
||||
"defaultTip": "请求错误"
|
||||
},
|
||||
"components": {
|
||||
"iconSelector": {
|
||||
"selectorTitle": "图标选择",
|
||||
"inputPlaceholder": "选择目标图标",
|
||||
"searchPlaceholder": "搜索图标",
|
||||
"clearIcon": "清除图标"
|
||||
},
|
||||
"copyText": {
|
||||
"tooltip": "复制",
|
||||
"message": "复制成功",
|
||||
"unsupportedError": "您的浏览器不支持剪贴板API",
|
||||
"unpermittedError": "目前不允许使用剪贴板API"
|
||||
}
|
||||
},
|
||||
"login": {
|
||||
"signInTitle": "登录",
|
||||
"accountPlaceholder": "输入账号",
|
||||
"passwordPlaceholder": "输入密码",
|
||||
"accountRuleTip": "请输入账户",
|
||||
"passwordRuleTip": "请输入密码",
|
||||
"or": "其他",
|
||||
"signIn": "登录",
|
||||
"rememberMe": "记住我",
|
||||
"forgotPassword": "忘记密码?",
|
||||
"signUp": "注册",
|
||||
"noAccountText": "你没有账户?",
|
||||
"haveAccountText": "已有账号?",
|
||||
"checkPasswordRuleTip": "请再次确认密码",
|
||||
"registerTitle": "注册",
|
||||
"checkPasswordPlaceholder": "请再次输入密码",
|
||||
"readAndAgree": "我已阅读并同意",
|
||||
"userAgreement": "用户协议",
|
||||
"resetPasswordTitle": "重置密码",
|
||||
"resetPasswordPlaceholder": "输入账号/手机号码",
|
||||
"resetPasswordRuleTip": "请输入账号/手机号码",
|
||||
"resetPassword": "重置密码"
|
||||
},
|
||||
"route": {
|
||||
"appRoot": "首页",
|
||||
"dashboard": "仪表盘",
|
||||
"workbench": "工作台",
|
||||
"monitor": "仪表盘",
|
||||
"multi": "多级菜单演示",
|
||||
"multi2": "多级菜单子页",
|
||||
"multi2Detail": "多级菜单的详情页",
|
||||
"multi3": "多级菜单",
|
||||
"multi4": "多级菜单3-1",
|
||||
"list": "列表页",
|
||||
"commonList": "常用列表",
|
||||
"cardList": "卡片列表",
|
||||
"draggableList": "拖拽列表",
|
||||
"demo": "功能示例",
|
||||
"fetch": "请求示例",
|
||||
"echarts": "Echarts示例",
|
||||
"map": "地图",
|
||||
"editor": "编辑器",
|
||||
"editorMd": "MarkDown编辑器",
|
||||
"editorRich": "富文本编辑器",
|
||||
"clipboard": "剪贴板",
|
||||
"icons": "图标",
|
||||
"QRCode": "二维码",
|
||||
"documents": "文档",
|
||||
"documentsVue": "Vue",
|
||||
"documentsVite": "Vite",
|
||||
"documentsVueuse": "VueUse(外链)",
|
||||
"documentsNova": "Nova 文档",
|
||||
"documentsPublic": "公共示例页(外链)",
|
||||
"permission": "权限",
|
||||
"permissionDemo": "权限示例",
|
||||
"justSuper": "super可见",
|
||||
"error": "异常页",
|
||||
"demo403": "403",
|
||||
"demo404": "404",
|
||||
"demo500": "500",
|
||||
"setting": "系统设置",
|
||||
"accountSetting": "用户设置",
|
||||
"dictionarySetting": "字典设置",
|
||||
"menuSetting": "菜单设置",
|
||||
"userCenter": "个人中心",
|
||||
"about": "关于",
|
||||
"cascader": "省市区联动",
|
||||
"dict": "字典示例"
|
||||
}
|
||||
}
|
||||
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
Normal file
66
nginx.conf
Normal file
@ -0,0 +1,66 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
10147
package-lock.json
generated
Normal file
10147
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
93
package.json
Normal file
93
package.json
Normal file
@ -0,0 +1,93 @@
|
||||
{
|
||||
"name": "nova-admin",
|
||||
"type": "module",
|
||||
"version": "0.9.15",
|
||||
"private": true,
|
||||
"description": "a clean and concise back-end management template based on Vue3, Vite5, Typescript, and Naive UI.",
|
||||
"author": {
|
||||
"name": "chansee97",
|
||||
"email": "chen.dev@foxmail.com",
|
||||
"url": "https://github.com/chansee97"
|
||||
},
|
||||
"license": "MIT",
|
||||
"homepage": "https://github.com/chansee97/nova-admin",
|
||||
"repository": {
|
||||
"url": "https://github.com/chansee97/nova-admin.git"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/chansee97/nova-admin/issues"
|
||||
},
|
||||
"keywords": [
|
||||
"Vue",
|
||||
"Vue3",
|
||||
"admin",
|
||||
"admin-template",
|
||||
"vue-admin",
|
||||
"vue-admin-template",
|
||||
"Vite5",
|
||||
"Vite",
|
||||
"vite-admin",
|
||||
"TypeScript",
|
||||
"TS",
|
||||
"NaiveUI",
|
||||
"naive-ui",
|
||||
"naive-admin",
|
||||
"NaiveUI-Admin",
|
||||
"naive-ui-admin",
|
||||
"UnoCSS"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "vite --mode dev --port 9980",
|
||||
"dev:test": "vite --mode test",
|
||||
"dev:prod": "vite --mode prod",
|
||||
"build": "vite build --mode prod",
|
||||
"build:dev": "vite build --mode dev",
|
||||
"build:test": "vite build --mode test",
|
||||
"preview": "vite preview --port 9981",
|
||||
"lint": "eslint . && vue-tsc --noEmit",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"lint:check": "npx @eslint/config-inspector",
|
||||
"sizecheck": "npx vite-bundle-visualizer"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vueuse/core": "^13.3.0",
|
||||
"alova": "^3.3.2",
|
||||
"colord": "^2.9.3",
|
||||
"pinia": "^3.0.3",
|
||||
"pinia-plugin-persistedstate": "^4.3.0",
|
||||
"radash": "^12.1.0",
|
||||
"vue": "^3.5.16",
|
||||
"vue-i18n": "^11.1.5",
|
||||
"vue-router": "^4.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@antfu/eslint-config": "^4.14.1",
|
||||
"@iconify-json/icon-park-outline": "^1.2.2",
|
||||
"@iconify/vue": "^4.3.0",
|
||||
"@types/node": "^24.0.1",
|
||||
"@vitejs/plugin-vue": "^5.2.4",
|
||||
"@vitejs/plugin-vue-jsx": "^4.2.0",
|
||||
"eslint": "^9.29.0",
|
||||
"lint-staged": "^16.1.2",
|
||||
"naive-ui": "^2.41.1",
|
||||
"sass": "^1.86.3",
|
||||
"md-editor-v3": "^5.6.1",
|
||||
"simple-git-hooks": "^2.13.0",
|
||||
"typescript": "^5.8.3",
|
||||
"unocss": "^66.2.0",
|
||||
"unplugin-auto-import": "^19.3.0",
|
||||
"unplugin-icons": "^22.1.0",
|
||||
"unplugin-vue-components": "^28.7.0",
|
||||
"vite": "^6.3.5",
|
||||
"vite-bundle-visualizer": "^1.2.1",
|
||||
"vite-plugin-compression": "^0.5.1",
|
||||
"vite-plugin-vue-devtools": "7.7.6",
|
||||
"vue-tsc": "^2.2.10"
|
||||
},
|
||||
"simple-git-hooks": {
|
||||
"pre-commit": "pnpm lint-staged"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*": "eslint --fix"
|
||||
}
|
||||
}
|
||||
1
public/favicon.svg
Normal file
1
public/favicon.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1" width="400" height="400" viewBox="0 0 400 400"><defs><clipPath id="master_svg0_9_38"><rect x="0" y="0" width="400" height="400" rx="0"/></clipPath></defs><g clip-path="url(#master_svg0_9_38)"><g><g><path d="M62.99998474121094,251.565L199.99998474121094,366L200.27298474121093,365.772L200.49998474121094,366L203.01798474121094,363.479L336.99998474121094,251.565L199.99998474121094,37L62.99998474121094,251.565ZM200.20898474121094,365.708L200.27298474121093,365.772L203.01798474121094,363.479L294.99998474121094,271.39099999999996L200.49998474121094,94L105.99998474121094,271.39099999999996L198.73798474121094,364.236L145.99998474121094,290.522L199.99998474121094,149L253.99998474121094,290.522L200.20898474121094,365.708Z" fill-rule="evenodd" fill="#56CB46" fill-opacity="1"/></g></g></g></svg>
|
||||
|
After Width: | Height: | Size: 902 B |
12
service.config.ts
Normal file
12
service.config.ts
Normal file
@ -0,0 +1,12 @@
|
||||
/** 不同请求服务的环境配置 */
|
||||
export const serviceConfig: Record<ServiceEnvType, Record<string, string>> = {
|
||||
dev: {
|
||||
url: 'https://mock.apifox.cn/m1/4071143-0-default',
|
||||
},
|
||||
test: {
|
||||
url: 'https://mock.apifox.cn/m1/4071143-0-default',
|
||||
},
|
||||
prod: {
|
||||
url: 'https://mock.apifox.cn/m1/4071143-0-default',
|
||||
},
|
||||
}
|
||||
23
src/App.vue
Normal file
23
src/App.vue
Normal file
@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import { naiveI18nOptions } from '@/utils'
|
||||
import { darkTheme } from 'naive-ui'
|
||||
import { useAppStore } from './store'
|
||||
|
||||
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 />
|
||||
</naive-provider>
|
||||
</n-config-provider>
|
||||
</template>
|
||||
1
src/assets/svg-icons/cool.svg
Normal file
1
src/assets/svg-icons/cool.svg
Normal file
@ -0,0 +1 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1678514274388" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1083" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M813.696 813.738667a426.666667 426.666667 0 1 0-603.434667 0 426.666667 426.666667 0 0 0 603.434667 0z" fill="#FFD264" p-id="1084"></path><path d="M735.232 147.797333A426.666667 426.666667 0 0 1 152.448 741.333333 426.666667 426.666667 0 1 0 735.232 147.797333z" fill="#FFC656" p-id="1085"></path><path d="M143.36 556.970667A396.16 396.16 0 0 1 853.333333 315.477333 396.202667 396.202667 0 1 0 195.968 754.432a393.898667 393.898667 0 0 1-52.608-197.461333z" fill="#FFD781" p-id="1086"></path><path d="M337.066667 605.866667a14.037333 14.037333 0 0 0-23.296 5.930666 14.037333 14.037333 0 0 0 0.426666 9.429334 213.845333 213.845333 0 0 0 395.477334 3.882666 14.464 14.464 0 0 0 0.469333-9.386666 14.037333 14.037333 0 0 0-23.210667-6.101334c-62.378667 60.501333-194.944 146.645333-349.866666-3.754666z" fill="#62422A" p-id="1087"></path><path d="M438.656 451.754667a42.197333 42.197333 0 0 0 8.533333-25.088c0-22.186667-18.730667-41.344-45.824-50.176h208.085334v75.264zM333.482667 376.490667h-5.632v2.005333c1.834667-0.725333 3.712-1.408 5.632-2.005333z" fill="#2D292A" p-id="1088"></path><path d="M303.317333 437.461333m-155.605333 0a155.605333 155.605333 0 1 0 311.210667 0 155.605333 155.605333 0 1 0-311.210667 0Z" fill="#474549" p-id="1089"></path><path d="M298.325333 421.632m-155.605333 0a155.605333 155.605333 0 1 0 311.210667 0 155.605333 155.605333 0 1 0-311.210667 0Z" fill="#2D292A" p-id="1090"></path><path d="M704.896 437.461333m-155.605333 0a155.605333 155.605333 0 1 0 311.210666 0 155.605333 155.605333 0 1 0-311.210666 0Z" fill="#474549" p-id="1091"></path><path d="M699.904 425.898667m-155.605333 0a155.605333 155.605333 0 1 0 311.210666 0 155.605333 155.605333 0 1 0-311.210666 0Z" fill="#2D292A" p-id="1092"></path></svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
1
src/assets/svg-icons/logo.svg
Normal file
1
src/assets/svg-icons/logo.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1" width="400" height="400" viewBox="0 0 400 400"><defs><clipPath id="master_svg0_9_38"><rect x="0" y="0" width="400" height="400" rx="0"/></clipPath></defs><g clip-path="url(#master_svg0_9_38)"><g><g><path d="M62.99998474121094,251.565L199.99998474121094,366L200.27298474121093,365.772L200.49998474121094,366L203.01798474121094,363.479L336.99998474121094,251.565L199.99998474121094,37L62.99998474121094,251.565ZM200.20898474121094,365.708L200.27298474121093,365.772L203.01798474121094,363.479L294.99998474121094,271.39099999999996L200.49998474121094,94L105.99998474121094,271.39099999999996L198.73798474121094,364.236L145.99998474121094,290.522L199.99998474121094,149L253.99998474121094,290.522L200.20898474121094,365.708Z" fill-rule="evenodd" fill="#56CB46" fill-opacity="1"/></g></g></g></svg>
|
||||
|
After Width: | Height: | Size: 902 B |
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 |
1
src/assets/svg/error-404.svg
Normal file
1
src/assets/svg/error-404.svg
Normal file
File diff suppressed because one or more lines are too long
|
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 |
237
src/components/common/AppLoading.vue
Normal file
237
src/components/common/AppLoading.vue
Normal file
@ -0,0 +1,237 @@
|
||||
<script setup lang="ts">
|
||||
</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>
|
||||
</div>
|
||||
</naive-provider>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
#loading-container {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
gap: 15vh;
|
||||
position: fixed;
|
||||
background-color: aliceblue;
|
||||
z-index: 1;
|
||||
}
|
||||
.boxes {
|
||||
--size: 48px;
|
||||
--duration: 800ms;
|
||||
height: calc(var(--size) * 2);
|
||||
width: calc(var(--size) * 3);
|
||||
position: relative;
|
||||
transform-style: preserve-3d;
|
||||
transform-origin: 50% 50%;
|
||||
margin-top: calc(var(--size) * 1.5 * -1);
|
||||
transform: rotateX(60deg) rotateZ(45deg) rotateY(0deg) translateZ(0px);
|
||||
}
|
||||
|
||||
.boxes .box {
|
||||
width: var(--size);
|
||||
height: var(--size);
|
||||
top: 0;
|
||||
left: 0;
|
||||
position: absolute;
|
||||
transform-style: preserve-3d;
|
||||
}
|
||||
|
||||
.boxes .box:nth-child(1) {
|
||||
transform: translate(100%, 0);
|
||||
-webkit-animation: box1 var(--duration) linear infinite;
|
||||
animation: box1 var(--duration) linear infinite;
|
||||
}
|
||||
|
||||
.boxes .box:nth-child(2) {
|
||||
transform: translate(0, 100%);
|
||||
-webkit-animation: box2 var(--duration) linear infinite;
|
||||
animation: box2 var(--duration) linear infinite;
|
||||
}
|
||||
|
||||
.boxes .box:nth-child(3) {
|
||||
transform: translate(100%, 100%);
|
||||
-webkit-animation: box3 var(--duration) linear infinite;
|
||||
animation: box3 var(--duration) linear infinite;
|
||||
}
|
||||
|
||||
.boxes .box:nth-child(4) {
|
||||
transform: translate(200%, 0);
|
||||
-webkit-animation: box4 var(--duration) linear infinite;
|
||||
animation: box4 var(--duration) linear infinite;
|
||||
}
|
||||
|
||||
.boxes .box > div {
|
||||
--background: #5c8df6;
|
||||
--top: auto;
|
||||
--right: auto;
|
||||
--bottom: auto;
|
||||
--left: auto;
|
||||
--translateZ: calc(var(--size) / 2);
|
||||
--rotateY: 0deg;
|
||||
--rotateX: 0deg;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--background);
|
||||
top: var(--top);
|
||||
right: var(--right);
|
||||
bottom: var(--bottom);
|
||||
left: var(--left);
|
||||
transform: rotateY(var(--rotateY)) rotateX(var(--rotateX)) translateZ(var(--translateZ));
|
||||
}
|
||||
|
||||
.boxes .box > div:nth-child(1) {
|
||||
--top: 0;
|
||||
--left: 0;
|
||||
}
|
||||
|
||||
.boxes .box > div:nth-child(2) {
|
||||
--background: #145af2;
|
||||
--right: 0;
|
||||
--rotateY: 90deg;
|
||||
}
|
||||
|
||||
.boxes .box > div:nth-child(3) {
|
||||
--background: #447cf5;
|
||||
--rotateX: -90deg;
|
||||
}
|
||||
|
||||
.boxes .box > div:nth-child(4) {
|
||||
--background: #dbe3f4;
|
||||
--top: 0;
|
||||
--left: 0;
|
||||
--translateZ: calc(var(--size) * 3 * -1);
|
||||
}
|
||||
|
||||
@-webkit-keyframes box1 {
|
||||
0%,
|
||||
50% {
|
||||
transform: translate(100%, 0);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translate(200%, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes box1 {
|
||||
0%,
|
||||
50% {
|
||||
transform: translate(100%, 0);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translate(200%, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@-webkit-keyframes box2 {
|
||||
0% {
|
||||
transform: translate(0, 100%);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translate(0, 0);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translate(100%, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes box2 {
|
||||
0% {
|
||||
transform: translate(0, 100%);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translate(0, 0);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translate(100%, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@-webkit-keyframes box3 {
|
||||
0%,
|
||||
50% {
|
||||
transform: translate(100%, 100%);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translate(0, 100%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes box3 {
|
||||
0%,
|
||||
50% {
|
||||
transform: translate(100%, 100%);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translate(0, 100%);
|
||||
}
|
||||
}
|
||||
|
||||
@-webkit-keyframes box4 {
|
||||
0% {
|
||||
transform: translate(200%, 0);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translate(200%, 100%);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translate(100%, 100%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes box4 {
|
||||
0% {
|
||||
transform: translate(200%, 0);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translate(200%, 100%);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translate(100%, 100%);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
27
src/components/common/CommonWrapper.vue
Normal file
27
src/components/common/CommonWrapper.vue
Normal file
@ -0,0 +1,27 @@
|
||||
<script setup lang="ts"></script>
|
||||
|
||||
<template>
|
||||
<n-el
|
||||
tag="div"
|
||||
class="el p-3 cursor-pointer rounded"
|
||||
>
|
||||
<n-flex
|
||||
align="center"
|
||||
:wrap="false"
|
||||
class="h-full"
|
||||
>
|
||||
<slot />
|
||||
</n-flex>
|
||||
</n-el>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.el {
|
||||
color: var(--n-text-color);
|
||||
transition: 0.3s var(--cubic-bezier-ease-in-out);
|
||||
}
|
||||
.el:hover {
|
||||
background-color: var(--button-color-2-hover);
|
||||
color: var(--n-text-color-hover);
|
||||
}
|
||||
</style>
|
||||
52
src/components/common/DarkModeSwitch.vue
Normal file
52
src/components/common/DarkModeSwitch.vue
Normal file
@ -0,0 +1,52 @@
|
||||
<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'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const appStore = useAppStore()
|
||||
|
||||
const options = computed(() => {
|
||||
return [
|
||||
{
|
||||
label: t('app.light'),
|
||||
value: 'light',
|
||||
icon: IconSun,
|
||||
},
|
||||
{
|
||||
label: t('app.dark'),
|
||||
value: 'dark',
|
||||
icon: IconMoon,
|
||||
},
|
||||
{
|
||||
label: t('app.system'),
|
||||
value: 'auto',
|
||||
icon: IconAuto,
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
function renderLabel(option: any) {
|
||||
return h(NFlex, { align: 'center' }, {
|
||||
default: () => [
|
||||
h(option.icon),
|
||||
option.label,
|
||||
],
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-popselect :value="appStore.storeColorMode" :render-label="renderLabel" :options="options" trigger="click" @update:value="appStore.setColorMode">
|
||||
<CommonWrapper>
|
||||
<icon-park-outline-moon v-if="appStore.storeColorMode === 'dark'" />
|
||||
<icon-park-outline-sun-one v-if="appStore.storeColorMode === 'light'" />
|
||||
<icon-park-outline-laptop-computer v-if="appStore.storeColorMode === 'auto'" />
|
||||
</CommonWrapper>
|
||||
</n-popselect>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
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>
|
||||
16
src/components/common/HelpInfo.vue
Normal file
16
src/components/common/HelpInfo.vue
Normal file
@ -0,0 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
message: string
|
||||
}
|
||||
|
||||
const { message } = defineProps<Props>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-tooltip :show-arrow="false" trigger="hover">
|
||||
<template #trigger>
|
||||
<icon-park-outline-help class="op-50 cursor-help" />
|
||||
</template>
|
||||
{{ message }}
|
||||
</n-tooltip>
|
||||
</template>
|
||||
188
src/components/common/IconSelect.vue
Normal file
188
src/components/common/IconSelect.vue
Normal file
@ -0,0 +1,188 @@
|
||||
<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>
|
||||
25
src/components/common/LangsSwitch.vue
Normal file
25
src/components/common/LangsSwitch.vue
Normal file
@ -0,0 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import { useAppStore } from '@/store'
|
||||
|
||||
const appStore = useAppStore()
|
||||
const options = [
|
||||
{
|
||||
label: 'English',
|
||||
value: 'enUS',
|
||||
},
|
||||
{
|
||||
label: '中文',
|
||||
value: 'zhCN',
|
||||
},
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-popselect :value="appStore.lang" :options="options" trigger="click" @update:value="appStore.setAppLang">
|
||||
<CommonWrapper>
|
||||
<icon-park-outline-translate />
|
||||
</CommonWrapper>
|
||||
</n-popselect>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
36
src/components/common/NaiveProvider.vue
Normal file
36
src/components/common/NaiveProvider.vue
Normal file
@ -0,0 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
import { useDialog, useLoadingBar, useMessage, useNotification } from 'naive-ui'
|
||||
|
||||
// 挂载naive组件的方法至window, 以便在路由钩子函数和请求函数里面调用
|
||||
function registerNaiveTools() {
|
||||
window.$loadingBar = useLoadingBar()
|
||||
window.$dialog = useDialog()
|
||||
window.$message = useMessage()
|
||||
window.$notification = useNotification()
|
||||
}
|
||||
|
||||
const NaiveProviderContent = defineComponent({
|
||||
name: 'NaiveProviderContent',
|
||||
setup() {
|
||||
registerNaiveTools()
|
||||
},
|
||||
render() {
|
||||
return h('div')
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-loading-bar-provider>
|
||||
<n-dialog-provider>
|
||||
<n-notification-provider>
|
||||
<n-message-provider>
|
||||
<slot />
|
||||
<NaiveProviderContent />
|
||||
</n-message-provider>
|
||||
</n-notification-provider>
|
||||
</n-dialog-provider>
|
||||
</n-loading-bar-provider>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
46
src/components/common/NovaIcon.vue
Normal file
46
src/components/common/NovaIcon.vue
Normal file
@ -0,0 +1,46 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue'
|
||||
|
||||
interface iconPorps {
|
||||
/* 图标名称 */
|
||||
icon?: string
|
||||
/* 图标颜色 */
|
||||
color?: string
|
||||
/* 图标大小 */
|
||||
size?: number
|
||||
/* 图标深度 */
|
||||
depth?: 1 | 2 | 3 | 4 | 5
|
||||
}
|
||||
const { size = 18, icon } = defineProps<iconPorps>()
|
||||
|
||||
const isLocal = computed(() => {
|
||||
return icon && icon.startsWith('local:')
|
||||
})
|
||||
|
||||
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"
|
||||
>
|
||||
<template v-if="isLocal">
|
||||
<i v-html="getLocalIcon(icon)" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<Icon :icon="icon" />
|
||||
</template>
|
||||
</n-icon>
|
||||
</template>
|
||||
36
src/components/common/Pagination.vue
Normal file
36
src/components/common/Pagination.vue
Normal file
@ -0,0 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
count?: number
|
||||
}
|
||||
const {
|
||||
count = 0,
|
||||
} = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
change: [page: number, pageSize: number] // 具名元组语法
|
||||
}>()
|
||||
|
||||
const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const displayOrder: Array<'pages' | 'size-picker' | 'quick-jumper'> = ['size-picker', 'pages']
|
||||
|
||||
function changePage() {
|
||||
emit('change', page.value, pageSize.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-pagination
|
||||
v-if="count > 0"
|
||||
v-model:page="page"
|
||||
v-model:page-size="pageSize"
|
||||
:page-sizes="[10, 20, 30, 50]"
|
||||
:item-count="count"
|
||||
:display-order="displayOrder"
|
||||
show-size-picker
|
||||
@update-page="changePage"
|
||||
@update-page-size="changePage"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
29
src/components/custom/Editor/MarkDownEditor/index.vue
Normal file
29
src/components/custom/Editor/MarkDownEditor/index.vue
Normal file
@ -0,0 +1,29 @@
|
||||
<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'
|
||||
|
||||
const model = defineModel<string>()
|
||||
|
||||
const appStore = useAppStore()
|
||||
|
||||
const toolbarsExclude: ToolbarNames[] = [
|
||||
'mermaid',
|
||||
'katex',
|
||||
'github',
|
||||
'htmlPreview',
|
||||
'catalog',
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<MdEditor
|
||||
v-model="model" :theme="appStore.colorMode" :toolbars-exclude="toolbarsExclude"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
107
src/components/custom/Editor/RichTextEditor/index.vue
Normal file
107
src/components/custom/Editor/RichTextEditor/index.vue
Normal file
@ -0,0 +1,107 @@
|
||||
<script setup lang="ts">
|
||||
import Quill from 'quill'
|
||||
import { useTemplateRef } from 'vue'
|
||||
import 'quill/dist/quill.snow.css'
|
||||
|
||||
defineOptions({
|
||||
name: 'RichTextEditor',
|
||||
})
|
||||
|
||||
const { disabled } = defineProps<Props>()
|
||||
interface Props {
|
||||
disabled?: boolean
|
||||
}
|
||||
const model = defineModel<string>()
|
||||
|
||||
let editorInst = null
|
||||
|
||||
const editorModel = ref<string>()
|
||||
|
||||
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()
|
||||
})
|
||||
|
||||
if (disabled)
|
||||
quill.enable(false)
|
||||
|
||||
editorInst = quill
|
||||
|
||||
if (model.value)
|
||||
setContents(model.value)
|
||||
}
|
||||
|
||||
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" />
|
||||
</template>
|
||||
12
src/constants/Regex.ts
Normal file
12
src/constants/Regex.ts
Normal file
@ -0,0 +1,12 @@
|
||||
/**
|
||||
* @description Some common rules
|
||||
* @link https://any-rule.vercel.app/
|
||||
*/
|
||||
|
||||
export enum Regex {
|
||||
Url = '^(((ht|f)tps?):\\\/\\\/)?([^!@#$%^&*?.\\s-]([^!@#$%^&*?.\\s]{0,63}[^!@#$%^&*?.\\s])?\\.)+[a-z]{2,6}\\\/?',
|
||||
|
||||
Email = '^(([^<>()[\\]\\\\.,;:\\s@"]+(\\.[^<>()[\\]\\\\.,;:\\s@"]+)*)|(".+"))@((\\[[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\])|(([a-zA-Z\\-0-9]+\\.)+[a-zA-Z]{2,}))$',
|
||||
|
||||
RouteName = '^[\\w_!@#$%^&*~-]+$',
|
||||
}
|
||||
5
src/constants/User.ts
Normal file
5
src/constants/User.ts
Normal file
@ -0,0 +1,5 @@
|
||||
/** Gender */
|
||||
export enum Gender {
|
||||
male,
|
||||
female,
|
||||
}
|
||||
2
src/constants/index.ts
Normal file
2
src/constants/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './Regex'
|
||||
export * from './User'
|
||||
49
src/directives/copy.ts
Normal file
49
src/directives/copy.ts
Normal file
@ -0,0 +1,49 @@
|
||||
import type { App, Directive } from 'vue'
|
||||
import { $t } from '@/utils'
|
||||
|
||||
interface CopyHTMLElement extends HTMLElement {
|
||||
_copyText: string
|
||||
}
|
||||
|
||||
export function install(app: App) {
|
||||
const { isSupported, copy } = useClipboard()
|
||||
const permissionWrite = usePermission('clipboard-write')
|
||||
|
||||
function clipboardEnable() {
|
||||
if (!isSupported.value) {
|
||||
window.$message.error($t('components.copyText.unsupportedError'))
|
||||
return false
|
||||
}
|
||||
|
||||
if (permissionWrite.value === 'denied') {
|
||||
window.$message.error($t('components.copyText.unpermittedError'))
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function copyHandler(this: any) {
|
||||
if (!clipboardEnable())
|
||||
return
|
||||
copy(this._copyText)
|
||||
window.$message.success($t('components.copyText.message'))
|
||||
}
|
||||
|
||||
function updataClipboard(el: CopyHTMLElement, text: string) {
|
||||
el._copyText = text
|
||||
el.addEventListener('click', copyHandler)
|
||||
}
|
||||
|
||||
const copyDirective: Directive<CopyHTMLElement, string> = {
|
||||
mounted(el, binding) {
|
||||
updataClipboard(el, binding.value)
|
||||
},
|
||||
updated(el, binding) {
|
||||
updataClipboard(el, binding.value)
|
||||
},
|
||||
unmounted(el) {
|
||||
el.removeEventListener('click', copyHandler)
|
||||
},
|
||||
}
|
||||
app.directive('copy', copyDirective)
|
||||
}
|
||||
24
src/directives/permission.ts
Normal file
24
src/directives/permission.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import type { App, Directive } from 'vue'
|
||||
import { usePermission } from '@/hooks'
|
||||
|
||||
export function install(app: App) {
|
||||
const { hasPermission } = usePermission()
|
||||
|
||||
function updatapermission(el: HTMLElement, permission: Entity.RoleType | Entity.RoleType[]) {
|
||||
if (!permission)
|
||||
throw new Error('v-permissson Directive with no explicit role attached')
|
||||
|
||||
if (!hasPermission(permission))
|
||||
el.parentElement?.removeChild(el)
|
||||
}
|
||||
|
||||
const permissionDirective: Directive<HTMLElement, Entity.RoleType | Entity.RoleType[]> = {
|
||||
mounted(el, binding) {
|
||||
updatapermission(el, binding.value)
|
||||
},
|
||||
updated(el, binding) {
|
||||
updatapermission(el, binding.value)
|
||||
},
|
||||
}
|
||||
app.directive('permission', permissionDirective)
|
||||
}
|
||||
2
src/hooks/index.ts
Normal file
2
src/hooks/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './useBoolean'
|
||||
export * from './usePermission'
|
||||
28
src/hooks/useBoolean.ts
Normal file
28
src/hooks/useBoolean.ts
Normal file
@ -0,0 +1,28 @@
|
||||
/**
|
||||
* boolean组合式函数
|
||||
* @param initValue 初始值
|
||||
*/
|
||||
export function useBoolean(initValue = false) {
|
||||
const bool = ref(initValue)
|
||||
|
||||
function setBool(value: boolean) {
|
||||
bool.value = value
|
||||
}
|
||||
function setTrue() {
|
||||
setBool(true)
|
||||
}
|
||||
function setFalse() {
|
||||
setBool(false)
|
||||
}
|
||||
function toggle() {
|
||||
setBool(!bool.value)
|
||||
}
|
||||
|
||||
return {
|
||||
bool,
|
||||
setBool,
|
||||
setTrue,
|
||||
setFalse,
|
||||
toggle,
|
||||
}
|
||||
}
|
||||
35
src/hooks/usePermission.ts
Normal file
35
src/hooks/usePermission.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import { useAuthStore } from '@/store'
|
||||
import { isArray, isString } from 'radash'
|
||||
|
||||
/** 权限判断 */
|
||||
export function usePermission() {
|
||||
const authStore = useAuthStore()
|
||||
|
||||
function hasPermission(
|
||||
permission?: Entity.RoleType | Entity.RoleType[],
|
||||
) {
|
||||
if (!permission)
|
||||
return true
|
||||
|
||||
if (!authStore.userInfo)
|
||||
return false
|
||||
const { role } = authStore.userInfo
|
||||
|
||||
// 角色为super可直接通过
|
||||
let has = role.includes('super')
|
||||
if (!has) {
|
||||
if (isArray(permission))
|
||||
// 角色为数组, 判断是否有交集
|
||||
has = permission.some(i => role.includes(i))
|
||||
|
||||
if (isString(permission))
|
||||
// 角色为字符串, 判断是否包含
|
||||
has = role.includes(permission)
|
||||
}
|
||||
return has
|
||||
}
|
||||
|
||||
return {
|
||||
hasPermission,
|
||||
}
|
||||
}
|
||||
65
src/hooks/useTabScroll.ts
Normal file
65
src/hooks/useTabScroll.ts
Normal file
@ -0,0 +1,65 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
14
src/layouts/components/common/BackTop.vue
Normal file
14
src/layouts/components/common/BackTop.vue
Normal file
@ -0,0 +1,14 @@
|
||||
<script setup lang="ts"></script>
|
||||
|
||||
<template>
|
||||
<n-back-top :bottom="80" :visibility-height="300">
|
||||
<n-tooltip placement="left" trigger="hover">
|
||||
<template #trigger>
|
||||
<div wh-full flex-center>
|
||||
<icon-park-outline-to-top />
|
||||
</div>
|
||||
</template>
|
||||
<span>{{ $t('app.backTop') }}</span>
|
||||
</n-tooltip>
|
||||
</n-back-top>
|
||||
</template>
|
||||
72
src/layouts/components/common/LayoutSelector.vue
Normal file
72
src/layouts/components/common/LayoutSelector.vue
Normal file
@ -0,0 +1,72 @@
|
||||
<script setup lang="ts">
|
||||
import type { LayoutMode } from '@/store/app'
|
||||
|
||||
const value = defineModel<LayoutMode>('value', { required: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex-center gap-4">
|
||||
<n-tooltip placement="bottom" trigger="hover">
|
||||
<template #trigger>
|
||||
<n-el
|
||||
:class="{
|
||||
'outline outline-2': value === 'leftMenu',
|
||||
}"
|
||||
class="grid grid-cols-[20%_1fr] outline-[var(--primary-color)] hover:(outline outline-2) cursor-pointer"
|
||||
@click="value = 'leftMenu'"
|
||||
>
|
||||
<div class="bg-[var(--primary-color)]" />
|
||||
<div class="bg-[var(--divider-color)]" />
|
||||
</n-el>
|
||||
</template>
|
||||
<span> {{ $t('app.leftMenu') }} </span>
|
||||
</n-tooltip>
|
||||
|
||||
<n-tooltip placement="bottom" trigger="hover">
|
||||
<template #trigger>
|
||||
<n-el
|
||||
:class="{
|
||||
'outline outline-2': value === 'topMenu',
|
||||
}"
|
||||
class="grid grid-rows-[30%_1fr] outline-[var(--primary-color)] hover:(outline outline-2) cursor-pointer"
|
||||
@click="value = 'topMenu'"
|
||||
>
|
||||
<div class="bg-[var(--primary-color)]" />
|
||||
<div class="bg-[var(--divider-color)]" />
|
||||
</n-el>
|
||||
</template>
|
||||
<span> {{ $t('app.topMenu') }} </span>
|
||||
</n-tooltip>
|
||||
|
||||
<n-tooltip placement="bottom" trigger="hover">
|
||||
<template #trigger>
|
||||
<n-el
|
||||
:class="{
|
||||
'outline outline-2': value === 'mixMenu',
|
||||
}"
|
||||
class="grid grid-cols-[20%_1fr] grid-rows-[20%_1fr] outline-[var(--primary-color)] hover:(outline outline-2) cursor-pointer"
|
||||
@click="value = 'mixMenu'"
|
||||
>
|
||||
<div class="bg-[var(--primary-color)] row-span-2" />
|
||||
<div class="bg-[var(--primary-color)]" />
|
||||
<div class="bg-[var(--divider-color)]" />
|
||||
</n-el>
|
||||
</template>
|
||||
<span> {{ $t('app.mixMenu') }} </span>
|
||||
</n-tooltip>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.grid{
|
||||
height: 60px;
|
||||
width: 86px;
|
||||
gap:0.4em;
|
||||
padding: 0.4em;
|
||||
box-shadow: var(--box-shadow-1);
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
.grid > div{
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
</style>
|
||||
45
src/layouts/components/common/NoticeList.vue
Normal file
45
src/layouts/components/common/NoticeList.vue
Normal file
@ -0,0 +1,45 @@
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
list?: Entity.Message[]
|
||||
}
|
||||
const { list } = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
interface Emits {
|
||||
(e: 'read', val: number): void
|
||||
}
|
||||
</script>
|
||||
|
||||
<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-thing content-indented :class="{ 'opacity-30': item.isRead }">
|
||||
<template #header>
|
||||
<n-ellipsis :line-clamp="1">
|
||||
{{ item.title }}
|
||||
</n-ellipsis>
|
||||
</template>
|
||||
<template #avatar>
|
||||
<nova-icon :icon="item.icon" :size="30" class="c-primary" />
|
||||
</template>
|
||||
<template v-if="item.tagTitle" #header-extra>
|
||||
<n-tag :bordered="false" :type="item.tagType" size="small">
|
||||
{{ item.tagTitle }}
|
||||
</n-tag>
|
||||
</template>
|
||||
<template v-if="item.description" #description>
|
||||
<n-ellipsis :line-clamp="2">
|
||||
{{ item.description }}
|
||||
</n-ellipsis>
|
||||
</template>
|
||||
<template #footer>
|
||||
{{ item.date }}
|
||||
</template>
|
||||
</n-thing>
|
||||
</n-list-item>
|
||||
</n-list>
|
||||
</n-scrollbar>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
18
src/layouts/components/common/Setting.vue
Normal file
18
src/layouts/components/common/Setting.vue
Normal file
@ -0,0 +1,18 @@
|
||||
<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>
|
||||
139
src/layouts/components/common/SettingDrawer.vue
Normal file
139
src/layouts/components/common/SettingDrawer.vue
Normal file
@ -0,0 +1,139 @@
|
||||
<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>
|
||||
|
||||
<template #footer>
|
||||
<n-button type="error" @click="resetSetting">
|
||||
{{ $t('app.reset') }}
|
||||
</n-button>
|
||||
</template>
|
||||
</n-drawer-content>
|
||||
</n-drawer>
|
||||
</template>
|
||||
50
src/layouts/components/header/Breadcrumb.vue
Normal file
50
src/layouts/components/header/Breadcrumb.vue
Normal file
@ -0,0 +1,50 @@
|
||||
<script setup lang="ts">
|
||||
import { useAppStore } from '@/store'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const routes = computed(() => {
|
||||
return route.matched
|
||||
})
|
||||
const appStore = useAppStore()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TransitionGroup v-if="appStore.showBreadcrumb" name="list" tag="ul" style="display: flex; gap:1em;">
|
||||
<n-el
|
||||
v-for="(item) in routes"
|
||||
:key="item.path"
|
||||
tag="li" style="
|
||||
color: var(--text-color-2);
|
||||
transition: 0.3s var(--cubic-bezier-ease-in-out);
|
||||
"
|
||||
class="flex-center gap-2 cursor-pointer split"
|
||||
@click="router.push(item.path)"
|
||||
>
|
||||
<nova-icon v-if="appStore.showBreadcrumbIcon" :icon="item.meta.icon" />
|
||||
<span class="whitespace-nowrap">{{ $t(`route.${String(item.name)}`, item.meta.title) }}</span>
|
||||
</n-el>
|
||||
</TransitionGroup>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.split:not(:first-child)::before {
|
||||
content: '/';
|
||||
padding-right:0.6em;
|
||||
}
|
||||
|
||||
.list-move,
|
||||
.list-enter-active,
|
||||
.list-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.list-enter-from,.list-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(-30px);
|
||||
}
|
||||
|
||||
.list-leave-active {
|
||||
position: absolute;
|
||||
}
|
||||
</style>
|
||||
19
src/layouts/components/header/CollapaseButton.vue
Normal file
19
src/layouts/components/header/CollapaseButton.vue
Normal file
@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import { useAppStore } from '@/store'
|
||||
|
||||
const appStore = useAppStore()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-tooltip placement="bottom" trigger="hover">
|
||||
<template #trigger>
|
||||
<CommonWrapper @click="appStore.toggleCollapse()">
|
||||
<icon-park-outline-menu-unfold v-if="appStore.collapsed" />
|
||||
<icon-park-outline-menu-fold v-else />
|
||||
</CommonWrapper>
|
||||
</template>
|
||||
<span>{{ $t('app.toggleSider') }}</span>
|
||||
</n-tooltip>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
27
src/layouts/components/header/FullScreen.vue
Normal file
27
src/layouts/components/header/FullScreen.vue
Normal file
@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
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>
|
||||
</template>
|
||||
<span>{{ $t('app.toggleFullScreen') }}</span>
|
||||
</n-tooltip>
|
||||
</template>
|
||||
143
src/layouts/components/header/Notices.vue
Normal file
143
src/layouts/components/header/Notices.vue
Normal file
@ -0,0 +1,143 @@
|
||||
<script setup lang="ts">
|
||||
import { group } from 'radash'
|
||||
import NoticeList from '../common/NoticeList.vue'
|
||||
|
||||
const MassageData = ref<Entity.Message[]>([
|
||||
{
|
||||
id: 0,
|
||||
type: 0,
|
||||
title: 'Admin 已经完成40%了!',
|
||||
icon: 'icon-park-outline:tips-one',
|
||||
tagTitle: '未开始',
|
||||
tagType: 'info',
|
||||
description: '项目稳定推进中,很快就能看到正式版了',
|
||||
date: '2022-2-2 12:22',
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
type: 0,
|
||||
title: 'Admin 已经添加通知功能!',
|
||||
icon: 'icon-park-outline:comment-one',
|
||||
tagTitle: '未开始',
|
||||
tagType: 'success',
|
||||
date: '2022-2-2 12:22',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
type: 0,
|
||||
title: 'Admin 已经添加路由功能!',
|
||||
icon: 'icon-park-outline:message-emoji',
|
||||
tagTitle: '未开始',
|
||||
tagType: 'warning',
|
||||
description: '项目稳定推进中...',
|
||||
date: '2022-2-5 18:32',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
type: 0,
|
||||
title:
|
||||
'Admin 已经添加菜单导航功能!Admin 已经添加菜单导航功能!Admin 已经添加菜单导航功能!Admin 已经添加菜单导航功能!',
|
||||
icon: 'icon-park-outline:tips-one',
|
||||
tagTitle: '未开始',
|
||||
tagType: 'error',
|
||||
description:
|
||||
'项目稳定推进中...项目稳定推进中...项目稳定推进中...项目稳定推进中...项目稳定推进中...项目稳定推进中...项目稳定推进中...',
|
||||
date: '2022-2-5 18:32',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
type: 0,
|
||||
title: 'Admin开始启动了!',
|
||||
icon: 'icon-park-outline:tips-one',
|
||||
tagTitle: '未开始',
|
||||
description: '项目稳定推进中...',
|
||||
date: '2022-2-5 18:32',
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
type: 1,
|
||||
title: '相见恨晚??',
|
||||
icon: 'icon-park-outline:comment',
|
||||
description: '项目稳定推进中,很快就能看到正式版了',
|
||||
date: '2022-2-2 12:22',
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
type: 1,
|
||||
title: '动态路由已完成!',
|
||||
icon: 'icon-park-outline:comment',
|
||||
description: '项目稳定推进中,很快就能看到正式版了',
|
||||
date: '2022-2-25 12:22',
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
type: 2,
|
||||
title: '接下来需要完善一些',
|
||||
icon: 'icon-park-outline:beach-umbrella',
|
||||
tagTitle: '未开始',
|
||||
description: '项目稳定推进中,很快就能看到正式版了',
|
||||
date: '2022-2-2 12:22',
|
||||
},
|
||||
])
|
||||
const currentTab = ref(0)
|
||||
function handleRead(id: number) {
|
||||
const data = MassageData.value.find(i => i.id === id)
|
||||
if (data)
|
||||
data.isRead = true
|
||||
window.$message.success(`id: ${id}`)
|
||||
}
|
||||
const massageCount = computed(() => {
|
||||
return MassageData.value.filter(i => !i.isRead).length
|
||||
})
|
||||
const groupMessage = computed(() => {
|
||||
return group(MassageData.value, i => i.type)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-popover placement="bottom" trigger="click" arrow-point-to-center class="!p-0">
|
||||
<template #trigger>
|
||||
<n-tooltip placement="bottom" trigger="hover">
|
||||
<template #trigger>
|
||||
<CommonWrapper>
|
||||
<n-badge :value="massageCount" :max="99" style="color: unset">
|
||||
<icon-park-outline-remind />
|
||||
</n-badge>
|
||||
</CommonWrapper>
|
||||
</template>
|
||||
<span>{{ $t('app.notificationsTips') }}</span>
|
||||
</n-tooltip>
|
||||
</template>
|
||||
<n-tabs v-model:value="currentTab" type="line" animated justify-content="space-evenly" class="w-390px">
|
||||
<n-tab-pane :name="0">
|
||||
<template #tab>
|
||||
<n-space class="w-130px" justify="center">
|
||||
{{ $t('app.notifications') }}
|
||||
<n-badge type="info" :value="groupMessage[0]?.filter(i => !i.isRead).length" :max="99" />
|
||||
</n-space>
|
||||
</template>
|
||||
<NoticeList :list="groupMessage[0]" @read="handleRead" />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane :name="1">
|
||||
<template #tab>
|
||||
<n-space class="w-130px" justify="center">
|
||||
{{ $t('app.messages') }}
|
||||
<n-badge type="warning" :value="groupMessage[1]?.filter(i => !i.isRead).length" :max="99" />
|
||||
</n-space>
|
||||
</template>
|
||||
<NoticeList :list="groupMessage[1]" @read="handleRead" />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane :name="2">
|
||||
<template #tab>
|
||||
<n-space class="w-130px" justify="center">
|
||||
{{ $t('app.todos') }}
|
||||
<n-badge type="error" :value="groupMessage[2]?.filter(i => !i.isRead).length" :max="99" />
|
||||
</n-space>
|
||||
</template>
|
||||
<NoticeList :list="groupMessage[2]" @read="handleRead" />
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</n-popover>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
217
src/layouts/components/header/Search.vue
Normal file
217
src/layouts/components/header/Search.vue
Normal file
@ -0,0 +1,217 @@
|
||||
<script setup lang="ts">
|
||||
import { useBoolean } from '@/hooks'
|
||||
import { useRouteStore } from '@/store'
|
||||
|
||||
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),
|
||||
item.path?.includes(searchValue.value),
|
||||
]
|
||||
return conditions.some(condition => !item.hide && condition)
|
||||
}).map((item) => {
|
||||
return {
|
||||
label: t(`route.${String(item.name)}`, item.title || item.name),
|
||||
value: item.path,
|
||||
icon: item.icon,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
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 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"
|
||||
size="small"
|
||||
preset="card"
|
||||
:segmented="{
|
||||
content: true,
|
||||
footer: true,
|
||||
}"
|
||||
:closable="false"
|
||||
@after-leave="handleClose"
|
||||
>
|
||||
<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>
|
||||
<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>
|
||||
</template>
|
||||
97
src/layouts/components/header/UserCenter.vue
Normal file
97
src/layouts/components/header/UserCenter.vue
Normal file
@ -0,0 +1,97 @@
|
||||
<script setup lang="ts">
|
||||
import { useAuthStore } from '@/store'
|
||||
import { renderIcon } from '@/utils/icon'
|
||||
import IconBookOpen from '~icons/icon-park-outline/book-open'
|
||||
import IconGithub from '~icons/icon-park-outline/github'
|
||||
import IconLogout from '~icons/icon-park-outline/logout'
|
||||
import IconUser from '~icons/icon-park-outline/user'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const { userInfo, logout } = useAuthStore()
|
||||
const router = useRouter()
|
||||
|
||||
const options = computed(() => {
|
||||
return [
|
||||
{
|
||||
label: t('app.userCenter'),
|
||||
key: 'userCenter',
|
||||
icon: () => h(IconUser),
|
||||
},
|
||||
{
|
||||
type: 'divider',
|
||||
key: 'd1',
|
||||
},
|
||||
{
|
||||
label: 'Github',
|
||||
key: 'guthub',
|
||||
icon: () => h(IconGithub),
|
||||
},
|
||||
{
|
||||
label: 'Gitee',
|
||||
key: 'gitee',
|
||||
icon: renderIcon('simple-icons:gitee'),
|
||||
},
|
||||
{
|
||||
label: 'Docs',
|
||||
key: 'docs',
|
||||
icon: () => h(IconBookOpen),
|
||||
},
|
||||
{
|
||||
type: 'divider',
|
||||
key: 'd1',
|
||||
},
|
||||
{
|
||||
label: t('app.loginOut'),
|
||||
key: 'loginOut',
|
||||
icon: () => h(IconLogout),
|
||||
},
|
||||
]
|
||||
})
|
||||
function handleSelect(key: string | number) {
|
||||
if (key === 'loginOut') {
|
||||
window.$dialog?.info({
|
||||
title: t('app.loginOutTitle'),
|
||||
content: t('app.loginOutContent'),
|
||||
positiveText: t('common.confirm'),
|
||||
negativeText: t('common.cancel'),
|
||||
onPositiveClick: () => {
|
||||
logout()
|
||||
},
|
||||
})
|
||||
}
|
||||
if (key === 'userCenter')
|
||||
router.push('/userCenter')
|
||||
|
||||
if (key === 'guthub')
|
||||
window.open('https://github.com/chansee97/nova-admin')
|
||||
|
||||
if (key === 'gitee')
|
||||
window.open('https://gitee.com/chansee97/nova-admin')
|
||||
|
||||
if (key === 'docs')
|
||||
window.open('https://nova-admin-docs.pages.dev/')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-dropdown
|
||||
trigger="click"
|
||||
:options="options"
|
||||
@select="handleSelect"
|
||||
>
|
||||
<n-avatar
|
||||
round
|
||||
class="cursor-pointer"
|
||||
:src="userInfo?.avatar"
|
||||
>
|
||||
<template #fallback>
|
||||
<div class="wh-full flex-center">
|
||||
<icon-park-outline-user />
|
||||
</div>
|
||||
</template>
|
||||
</n-avatar>
|
||||
</n-dropdown>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
30
src/layouts/components/index.ts
Normal file
30
src/layouts/components/index.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import BackTop from './common/BackTop.vue'
|
||||
import Setting from './common/Setting.vue'
|
||||
import SettingDrawer from './common/SettingDrawer.vue'
|
||||
|
||||
import Breadcrumb from './header/Breadcrumb.vue'
|
||||
import CollapaseButton from './header/CollapaseButton.vue'
|
||||
import FullScreen from './header/FullScreen.vue'
|
||||
import Notices from './header/Notices.vue'
|
||||
import Search from './header/Search.vue'
|
||||
import UserCenter from './header/UserCenter.vue'
|
||||
|
||||
import Logo from './sider/Logo.vue'
|
||||
import Menu from './sider/Menu.vue'
|
||||
|
||||
import TabBar from './tab/TabBar.vue'
|
||||
|
||||
export {
|
||||
BackTop,
|
||||
Breadcrumb,
|
||||
CollapaseButton,
|
||||
FullScreen,
|
||||
Logo,
|
||||
Menu,
|
||||
Notices,
|
||||
Search,
|
||||
Setting,
|
||||
SettingDrawer,
|
||||
TabBar,
|
||||
UserCenter,
|
||||
}
|
||||
23
src/layouts/components/sider/Logo.vue
Normal file
23
src/layouts/components/sider/Logo.vue
Normal file
@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import { useAppStore } from '@/store'
|
||||
|
||||
const router = useRouter()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const name = import.meta.env.VITE_APP_NAME
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="h-60px text-xl flex-center cursor-pointer gap-2 p-x-2"
|
||||
@click="router.push('/')"
|
||||
>
|
||||
<svg-icons-logo class="text-1.5em" />
|
||||
<span
|
||||
v-show="!appStore.collapsed"
|
||||
class="text-ellipsis overflow-hidden whitespace-nowrap"
|
||||
>{{ name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
28
src/layouts/components/sider/Menu.vue
Normal file
28
src/layouts/components/sider/Menu.vue
Normal file
@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import type { MenuInst } from 'naive-ui'
|
||||
import { useAppStore, useRouteStore } from '@/store'
|
||||
|
||||
const route = useRoute()
|
||||
const appStore = useAppStore()
|
||||
const routeStore = useRouteStore()
|
||||
|
||||
const menuInstRef = ref<MenuInst | null>(null)
|
||||
watch(
|
||||
() => route.path,
|
||||
() => {
|
||||
menuInstRef.value?.showOption(routeStore.activeMenu as string)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-menu
|
||||
ref="menuInstRef"
|
||||
:collapsed="appStore.collapsed"
|
||||
:indent="20"
|
||||
:collapsed-width="64"
|
||||
:options="routeStore.menus"
|
||||
:value="routeStore.activeMenu"
|
||||
/>
|
||||
</template>
|
||||
17
src/layouts/components/tab/ContentFullScreen.vue
Normal file
17
src/layouts/components/tab/ContentFullScreen.vue
Normal file
@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { useAppStore } from '@/store'
|
||||
|
||||
const appStore = useAppStore()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-tooltip placement="bottom" trigger="hover">
|
||||
<template #trigger>
|
||||
<CommonWrapper @click="appStore.contentFullScreen = !appStore.contentFullScreen">
|
||||
<icon-park-outline-off-screen-one v-if="appStore.contentFullScreen" />
|
||||
<icon-park-outline-full-screen-one v-else />
|
||||
</CommonWrapper>
|
||||
</template>
|
||||
<span>{{ $t('app.togglContentFullScreen') }}</span>
|
||||
</n-tooltip>
|
||||
</template>
|
||||
41
src/layouts/components/tab/DropTabs.vue
Normal file
41
src/layouts/components/tab/DropTabs.vue
Normal file
@ -0,0 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
import { useTabStore } from '@/store'
|
||||
import { renderIcon } from '@/utils'
|
||||
|
||||
const tabStore = useTabStore()
|
||||
|
||||
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)!()
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
function handleDropTabs(key: string, option: any) {
|
||||
router.push(option.path)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-dropdown
|
||||
:options="tabStore.allTabs"
|
||||
:render-label="renderDropTabsLabel"
|
||||
:render-icon="renderDropTabsIcon"
|
||||
trigger="click"
|
||||
size="small"
|
||||
key-field="fullPath"
|
||||
@select="handleDropTabs"
|
||||
>
|
||||
<CommonWrapper>
|
||||
<icon-park-outline-application-menu />
|
||||
</CommonWrapper>
|
||||
</n-dropdown>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
28
src/layouts/components/tab/Reload.vue
Normal file
28
src/layouts/components/tab/Reload.vue
Normal file
@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import { useAppStore } from '@/store'
|
||||
|
||||
const appStore = useAppStore()
|
||||
|
||||
const loading = ref(false)
|
||||
|
||||
function handleReload() {
|
||||
loading.value = true
|
||||
appStore.reloadPage()
|
||||
setTimeout(() => {
|
||||
loading.value = false
|
||||
}, 800)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-tooltip placement="bottom" trigger="hover">
|
||||
<template #trigger>
|
||||
<CommonWrapper @click="handleReload">
|
||||
<icon-park-outline-refresh :class="{ 'animate-spin': loading }" />
|
||||
</CommonWrapper>
|
||||
</template>
|
||||
<span>{{ $t('common.reload') }}</span>
|
||||
</n-tooltip>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
148
src/layouts/components/tab/TabBar.vue
Normal file
148
src/layouts/components/tab/TabBar.vue
Normal file
@ -0,0 +1,148 @@
|
||||
<script setup lang="ts">
|
||||
import type { RouteLocationNormalized } from 'vue-router'
|
||||
import { useAppStore, useTabStore } from '@/store'
|
||||
import { useTabScroll } from '@/hooks/useTabScroll'
|
||||
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'
|
||||
|
||||
const tabStore = useTabStore()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const { scrollbar, onWheel } = useTabScroll(computed(() => tabStore.currentTabPath))
|
||||
|
||||
const router = useRouter()
|
||||
function handleTab(route: RouteLocationNormalized) {
|
||||
router.push(route.fullPath)
|
||||
}
|
||||
const { t } = useI18n()
|
||||
const options = computed(() => {
|
||||
return [
|
||||
{
|
||||
label: t('common.reload'),
|
||||
key: 'reload',
|
||||
icon: () => h(IconRedo),
|
||||
},
|
||||
{
|
||||
label: t('common.close'),
|
||||
key: 'closeCurrent',
|
||||
icon: () => h(IconClose),
|
||||
},
|
||||
{
|
||||
label: t('app.closeOther'),
|
||||
key: 'closeOther',
|
||||
icon: () => h(IconDelete),
|
||||
},
|
||||
{
|
||||
label: t('app.closeLeft'),
|
||||
key: 'closeLeft',
|
||||
icon: () => h(IconLeft),
|
||||
},
|
||||
{
|
||||
label: t('app.closeRight'),
|
||||
key: 'closeRight',
|
||||
icon: () => h(IconRight),
|
||||
},
|
||||
{
|
||||
label: t('app.closeAll'),
|
||||
key: 'closeAll',
|
||||
icon: () => h(IconFullwith),
|
||||
},
|
||||
]
|
||||
})
|
||||
const showDropdown = ref(false)
|
||||
const x = ref(0)
|
||||
const y = ref(0)
|
||||
const currentRoute = ref()
|
||||
|
||||
function handleSelect(key: string) {
|
||||
showDropdown.value = false
|
||||
interface HandleFn {
|
||||
[key: string]: any
|
||||
}
|
||||
const handleFn: HandleFn = {
|
||||
reload() {
|
||||
appStore.reloadPage()
|
||||
},
|
||||
closeCurrent() {
|
||||
tabStore.closeTab(currentRoute.value.fullPath)
|
||||
},
|
||||
closeOther() {
|
||||
tabStore.closeOtherTabs(currentRoute.value.fullPath)
|
||||
},
|
||||
closeLeft() {
|
||||
tabStore.closeLeftTabs(currentRoute.value.fullPath)
|
||||
},
|
||||
closeRight() {
|
||||
tabStore.closeRightTabs(currentRoute.value.fullPath)
|
||||
},
|
||||
closeAll() {
|
||||
tabStore.closeAllTabs()
|
||||
},
|
||||
}
|
||||
handleFn[key]()
|
||||
}
|
||||
function handleContextMenu(e: MouseEvent, route: RouteLocationNormalized) {
|
||||
e.preventDefault()
|
||||
currentRoute.value = route
|
||||
showDropdown.value = false
|
||||
nextTick().then(() => {
|
||||
showDropdown.value = true
|
||||
x.value = e.clientX
|
||||
y.value = e.clientY
|
||||
})
|
||||
}
|
||||
function onClickoutside() {
|
||||
showDropdown.value = false
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-scrollbar ref="scrollbar" class="relative flex h-full tab-bar-scroller-wrapper" content-class="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 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>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ghost {
|
||||
opacity: 0.5;
|
||||
background: #c4f6d5;
|
||||
}
|
||||
</style>
|
||||
41
src/layouts/components/tab/TabBarItem.vue
Normal file
41
src/layouts/components/tab/TabBarItem.vue
Normal file
@ -0,0 +1,41 @@
|
||||
<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>
|
||||
19
src/layouts/index.vue
Normal file
19
src/layouts/index.vue
Normal file
@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import { useAppStore } from '@/store/app'
|
||||
import { SettingDrawer } from './components'
|
||||
import leftMenu from './leftMenu.layout.vue'
|
||||
import mixMenu from './mixMenu.layout.vue'
|
||||
import topMenu from './topMenu.layout.vue'
|
||||
|
||||
const appStore = useAppStore()
|
||||
const layoutMap = {
|
||||
leftMenu,
|
||||
topMenu,
|
||||
mixMenu,
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SettingDrawer />
|
||||
<component :is="layoutMap[appStore.layoutMode]" />
|
||||
</template>
|
||||
102
src/layouts/leftMenu.layout.vue
Normal file
102
src/layouts/leftMenu.layout.vue
Normal file
@ -0,0 +1,102 @@
|
||||
<script lang="ts" setup>
|
||||
import { useAppStore, useRouteStore } from '@/store'
|
||||
import {
|
||||
BackTop,
|
||||
Breadcrumb,
|
||||
CollapaseButton,
|
||||
FullScreen,
|
||||
Logo,
|
||||
Menu,
|
||||
Notices,
|
||||
Search,
|
||||
Setting,
|
||||
TabBar,
|
||||
UserCenter,
|
||||
} from './components'
|
||||
|
||||
const routeStore = useRouteStore()
|
||||
const appStore = useAppStore()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-layout
|
||||
has-sider
|
||||
class="wh-full"
|
||||
embedded
|
||||
>
|
||||
<n-layout-sider
|
||||
v-if="!appStore.contentFullScreen"
|
||||
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-999">
|
||||
<div v-if="!appStore.contentFullScreen" 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>
|
||||
<!-- 121 = 16 + 45 + 60 45是面包屑高度 60是标签栏高度 -->
|
||||
<!-- 56 = 16 + 40 40是页脚高度 -->
|
||||
<div
|
||||
class="flex-1 p-16px flex flex-col"
|
||||
:class="{
|
||||
'p-t-121px': appStore.showTabs,
|
||||
'p-b-56px': appStore.showFooter && !appStore.contentFullScreen,
|
||||
'p-t-76px': !appStore.showTabs,
|
||||
'p-t-61px': appStore.contentFullScreen,
|
||||
}"
|
||||
>
|
||||
<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>
|
||||
<n-layout-footer
|
||||
v-if="appStore.showFooter && !appStore.contentFullScreen"
|
||||
bordered
|
||||
position="absolute"
|
||||
class="h-40px flex-center"
|
||||
>
|
||||
{{ appStore.footerText }}
|
||||
</n-layout-footer>
|
||||
<BackTop />
|
||||
</n-layout>
|
||||
</n-layout>
|
||||
</template>
|
||||
160
src/layouts/mixMenu.layout.vue
Normal file
160
src/layouts/mixMenu.layout.vue
Normal file
@ -0,0 +1,160 @@
|
||||
<script lang="ts" setup>
|
||||
import type { MenuInst, MenuOption } from 'naive-ui'
|
||||
import { useAppStore, useRouteStore } from '@/store'
|
||||
import {
|
||||
BackTop,
|
||||
CollapaseButton,
|
||||
FullScreen,
|
||||
Logo,
|
||||
Notices,
|
||||
Search,
|
||||
Setting,
|
||||
TabBar,
|
||||
UserCenter,
|
||||
} from './components'
|
||||
|
||||
const routeStore = useRouteStore()
|
||||
const appStore = useAppStore()
|
||||
const pageRoute = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const menuInstRef = ref<MenuInst | null>(null)
|
||||
|
||||
watch(
|
||||
() => pageRoute.path,
|
||||
() => {
|
||||
menuInstRef.value?.showOption(routeStore.activeMenu as string)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const topMenu = ref<MenuOption[]>([])
|
||||
const activeTopMenu = ref<string>('')
|
||||
function handleTopMenu(rowMenu: MenuOption[]) {
|
||||
topMenu.value = rowMenu.map((i) => {
|
||||
const { icon, label, key } = i
|
||||
return {
|
||||
icon,
|
||||
label,
|
||||
key,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
handleTopMenu(routeStore.menus)
|
||||
|
||||
// 根据当前页面获取选中菜单和对应侧边菜单
|
||||
const currentMenuKey = pageRoute.matched[1].path
|
||||
handleSideMenu(currentMenuKey)
|
||||
activeTopMenu.value = currentMenuKey
|
||||
})
|
||||
|
||||
const sideMenu = ref<MenuOption[]>([])
|
||||
function handleSideMenu(key: string) {
|
||||
const routeMenu = routeStore.menus as MenuOption[]
|
||||
const targetMenu = routeMenu.find(i => i.key === key)
|
||||
if (targetMenu) {
|
||||
sideMenu.value = targetMenu.children ? targetMenu.children : [targetMenu]
|
||||
}
|
||||
}
|
||||
|
||||
function updateTopMenu(key: string) {
|
||||
handleSideMenu(key)
|
||||
activeTopMenu.value = key
|
||||
router.push(key)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-layout
|
||||
has-sider
|
||||
class="wh-full"
|
||||
embedded
|
||||
>
|
||||
<n-layout-sider
|
||||
v-if="!appStore.contentFullScreen"
|
||||
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">
|
||||
<n-menu
|
||||
ref="menuInstRef"
|
||||
:collapsed="appStore.collapsed"
|
||||
:indent="20"
|
||||
:collapsed-width="64"
|
||||
:options="sideMenu"
|
||||
:value="routeStore.activeMenu"
|
||||
/>
|
||||
</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-999">
|
||||
<div v-if="!appStore.contentFullScreen" class="h-60px flex-y-center justify-between">
|
||||
<CollapaseButton />
|
||||
<n-menu
|
||||
ref="menuInstRef"
|
||||
mode="horizontal"
|
||||
responsive
|
||||
:options="topMenu"
|
||||
:value="activeTopMenu"
|
||||
@update:value="updateTopMenu"
|
||||
/>
|
||||
<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"
|
||||
:class="{
|
||||
'p-t-121px': appStore.showTabs,
|
||||
'p-b-56px': appStore.showFooter && !appStore.contentFullScreen,
|
||||
'p-t-76px': !appStore.showTabs,
|
||||
'p-t-61px': appStore.contentFullScreen,
|
||||
}"
|
||||
>
|
||||
<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>
|
||||
<n-layout-footer
|
||||
v-if="appStore.showFooter && !appStore.contentFullScreen"
|
||||
bordered
|
||||
position="absolute"
|
||||
class="h-40px flex-center"
|
||||
>
|
||||
{{ appStore.footerText }}
|
||||
</n-layout-footer>
|
||||
<BackTop />
|
||||
</n-layout>
|
||||
</n-layout>
|
||||
</template>
|
||||
67
src/layouts/topMenu.layout.vue
Normal file
67
src/layouts/topMenu.layout.vue
Normal file
@ -0,0 +1,67 @@
|
||||
<script lang="ts" setup>
|
||||
import { useAppStore, useRouteStore } from '@/store'
|
||||
import {
|
||||
BackTop,
|
||||
FullScreen,
|
||||
Logo,
|
||||
Menu,
|
||||
Notices,
|
||||
Search,
|
||||
Setting,
|
||||
TabBar,
|
||||
UserCenter,
|
||||
} from './components'
|
||||
|
||||
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-999">
|
||||
<div v-if="!appStore.contentFullScreen" 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"
|
||||
:class="{
|
||||
'p-t-121px': appStore.showTabs,
|
||||
'p-b-56px': appStore.showFooter && !appStore.contentFullScreen,
|
||||
'p-t-76px': !appStore.showTabs,
|
||||
'p-t-61px': appStore.contentFullScreen,
|
||||
}"
|
||||
>
|
||||
<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>
|
||||
<n-layout-footer
|
||||
v-if="appStore.showFooter && !appStore.contentFullScreen"
|
||||
bordered position="absolute" class="h-40px flex-center"
|
||||
>
|
||||
{{ appStore.footerText }}
|
||||
</n-layout-footer>
|
||||
<BackTop />
|
||||
</n-layout>
|
||||
</n-layout>
|
||||
</template>
|
||||
35
src/main.ts
Normal file
35
src/main.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import type { App } from 'vue'
|
||||
import { installRouter } from '@/router'
|
||||
import { installPinia } from '@/store'
|
||||
import AppVue from './App.vue'
|
||||
import AppLoading from './components/common/AppLoading.vue'
|
||||
|
||||
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()
|
||||
6
src/modules/assets.ts
Normal file
6
src/modules/assets.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import 'uno.css'
|
||||
import '@/styles/index.css'
|
||||
|
||||
// 全局引入的静态资源
|
||||
export function install() {
|
||||
}
|
||||
10
src/modules/directives.ts
Normal file
10
src/modules/directives.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import type { App } from 'vue'
|
||||
|
||||
export function install(app: App) {
|
||||
/* 自动注册指令 */
|
||||
Object.values(
|
||||
import.meta.glob<{ install: (app: App) => void }>('@/directives/*.ts', {
|
||||
eager: true,
|
||||
}),
|
||||
).map(i => app.use(i))
|
||||
}
|
||||
26
src/modules/i18n.ts
Normal file
26
src/modules/i18n.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import type { App } from 'vue'
|
||||
import { local } from '@/utils'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import enUS from '../../locales/en_US.json'
|
||||
import zhCN from '../../locales/zh_CN.json'
|
||||
|
||||
const { VITE_DEFAULT_LANG } = import.meta.env
|
||||
|
||||
export const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: local.get('lang') || VITE_DEFAULT_LANG, // 默认显示语言
|
||||
fallbackLocale: VITE_DEFAULT_LANG,
|
||||
messages: {
|
||||
zhCN,
|
||||
enUS,
|
||||
},
|
||||
// 缺失国际化键警告
|
||||
// missingWarn: false,
|
||||
|
||||
// 缺失回退内容警告
|
||||
fallbackWarn: false,
|
||||
})
|
||||
|
||||
export function install(app: App) {
|
||||
app.use(i18n)
|
||||
}
|
||||
81
src/router/guard.ts
Normal file
81
src/router/guard.ts
Normal file
@ -0,0 +1,81 @@
|
||||
import type { Router } from 'vue-router'
|
||||
import { useAppStore, useRouteStore, useTabStore } from '@/store'
|
||||
import { local } from '@/utils'
|
||||
|
||||
const title = import.meta.env.VITE_APP_NAME
|
||||
|
||||
export function setupRouterGuard(router: Router) {
|
||||
const appStore = useAppStore()
|
||||
const routeStore = useRouteStore()
|
||||
const tabStore = useTabStore()
|
||||
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
// 判断是否是外链,如果是直接打开网页并拦截跳转
|
||||
if (to.meta.href) {
|
||||
window.open(to.meta.href)
|
||||
next(false) // 取消当前导航
|
||||
return
|
||||
}
|
||||
// 开始 loadingBar
|
||||
appStore.showProgress && window.$loadingBar?.start()
|
||||
|
||||
// 判断有无TOKEN,登录鉴权
|
||||
const isLogin = Boolean(local.get('accessToken'))
|
||||
|
||||
// 如果是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 === '404' ? undefined : to.fullPath
|
||||
next({ path: '/login', query: { redirect } })
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// 如果用户已登录且访问login页面,重定向到首页
|
||||
if (to.name === 'login' && isLogin) {
|
||||
next({ path: '/' })
|
||||
return
|
||||
}
|
||||
|
||||
next()
|
||||
})
|
||||
router.beforeResolve((to) => {
|
||||
// 设置菜单高亮
|
||||
routeStore.setActiveMenu(to.meta.activeMenu ?? to.fullPath)
|
||||
// 添加tabs
|
||||
tabStore.addTab(to)
|
||||
// 设置高亮标签;
|
||||
tabStore.setCurrentTab(to.fullPath as string)
|
||||
})
|
||||
|
||||
router.afterEach((to) => {
|
||||
// 修改网页标题
|
||||
document.title = `${to.meta.title} - ${title}`
|
||||
// 结束 loadingBar
|
||||
appStore.showProgress && window.$loadingBar?.finish()
|
||||
})
|
||||
}
|
||||
17
src/router/index.ts
Normal file
17
src/router/index.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import type { App } from 'vue'
|
||||
import { createRouter, createWebHashHistory, createWebHistory } from 'vue-router'
|
||||
import { setupRouterGuard } from './guard'
|
||||
import { routes } from './routes.inner'
|
||||
|
||||
const { VITE_ROUTE_MODE = 'hash', VITE_BASE_URL } = import.meta.env
|
||||
export const router = createRouter({
|
||||
history: VITE_ROUTE_MODE === 'hash' ? createWebHashHistory(VITE_BASE_URL) : createWebHistory(VITE_BASE_URL),
|
||||
routes,
|
||||
})
|
||||
// 安装vue路由
|
||||
export async function installRouter(app: App) {
|
||||
// 添加路由守卫
|
||||
setupRouterGuard(router)
|
||||
app.use(router)
|
||||
await router.isReady() // https://router.vuejs.org/zh/api/index.html#isready
|
||||
}
|
||||
61
src/router/routes.inner.ts
Normal file
61
src/router/routes.inner.ts
Normal file
@ -0,0 +1,61 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
|
||||
/* 页面中的一些固定路由,错误页等 */
|
||||
export const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/',
|
||||
name: 'root',
|
||||
redirect: '/appRoot',
|
||||
children: [
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
component: () => import('@/views/login/index.vue'), // 注意这里要带上 文件后缀.vue
|
||||
meta: {
|
||||
title: '登录',
|
||||
withoutTab: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/403',
|
||||
name: '403',
|
||||
component: () => import('@/views/error/403/index.vue'),
|
||||
meta: {
|
||||
title: '用户无权限',
|
||||
withoutTab: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
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/error/404/index.vue'),
|
||||
name: '404',
|
||||
meta: {
|
||||
title: '找不到页面',
|
||||
icon: 'icon-park-outline:ghost',
|
||||
withoutTab: true,
|
||||
},
|
||||
},
|
||||
|
||||
]
|
||||
34
src/router/routes.static.ts
Normal file
34
src/router/routes.static.ts
Normal file
@ -0,0 +1,34 @@
|
||||
export const staticRoutes: AppRoute.RowRoute[] = [
|
||||
{
|
||||
name: 'monitor',
|
||||
path: '/dashboard/monitor',
|
||||
title: '仪表盘',
|
||||
requiresAuth: true,
|
||||
icon: 'icon-park-outline:anchor',
|
||||
menuType: 'page',
|
||||
componentPath: '/dashboard/monitor/index.vue',
|
||||
id: 3,
|
||||
pid: null,
|
||||
},
|
||||
{
|
||||
name: 'setting',
|
||||
path: '/setting',
|
||||
title: '系统管理',
|
||||
requiresAuth: true,
|
||||
icon: 'icon-park-outline:setting',
|
||||
menuType: 'dir',
|
||||
componentPath: null,
|
||||
id: 35,
|
||||
pid: null,
|
||||
},
|
||||
{
|
||||
name: 'accountSetting',
|
||||
path: '/setting/account',
|
||||
title: '用户管理',
|
||||
requiresAuth: true,
|
||||
icon: 'icon-park-outline:every-user',
|
||||
componentPath: '/setting/account/index.vue',
|
||||
id: 36,
|
||||
pid: 35,
|
||||
},
|
||||
]
|
||||
25
src/service/api/login.ts
Normal file
25
src/service/api/login.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { request } from '../http'
|
||||
|
||||
interface Ilogin {
|
||||
userName: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export function fetchLogin(data: Ilogin) {
|
||||
const methodInstance = request.Post<Service.ResponseResult<Api.Login.Info>>('/login', data)
|
||||
methodInstance.meta = {
|
||||
authRole: null,
|
||||
}
|
||||
return methodInstance
|
||||
}
|
||||
export function fetchUpdateToken(data: any) {
|
||||
const method = request.Post<Service.ResponseResult<Api.Login.Info>>('/updateToken', data)
|
||||
method.meta = {
|
||||
authRole: 'refreshToken',
|
||||
}
|
||||
return method
|
||||
}
|
||||
|
||||
export function fetchUserRoutes(params: { id: number }) {
|
||||
return request.Get<Service.ResponseResult<AppRoute.RowRoute[]>>('/getUserRoutes', { params })
|
||||
}
|
||||
16
src/service/api/system.ts
Normal file
16
src/service/api/system.ts
Normal file
@ -0,0 +1,16 @@
|
||||
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')
|
||||
}
|
||||
|
||||
100
src/service/http/alova.ts
Normal file
100
src/service/http/alova.ts
Normal file
@ -0,0 +1,100 @@
|
||||
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 {
|
||||
handleBusinessError,
|
||||
handleRefreshToken,
|
||||
handleResponseError,
|
||||
handleServiceResult,
|
||||
} from './handle'
|
||||
|
||||
const { onAuthRequired, onResponseRefreshToken } = createServerTokenAuthentication<VueHookType>({
|
||||
// 服务端判定token过期
|
||||
refreshTokenOnSuccess: {
|
||||
// 当服务端返回401时,表示token过期
|
||||
isExpired: (response, method) => {
|
||||
const isExpired = method.meta && method.meta.isExpired
|
||||
return response.status === 401 && !isExpired
|
||||
},
|
||||
|
||||
// 当token过期时触发,在此函数中触发刷新token
|
||||
handler: async (_response, method) => {
|
||||
// 此处采取限制,防止过期请求无限循环重发
|
||||
if (!method.meta)
|
||||
method.meta = { isExpired: true }
|
||||
else
|
||||
method.meta.isExpired = true
|
||||
|
||||
await handleRefreshToken()
|
||||
},
|
||||
},
|
||||
// 添加token到请求头
|
||||
assignToken: (method) => {
|
||||
method.config.headers.Authorization = `Bearer ${local.get('accessToken')}`
|
||||
},
|
||||
})
|
||||
|
||||
// docs path of alova.js https://alova.js.org/
|
||||
export function createAlovaInstance(
|
||||
alovaConfig: Service.AlovaConfig,
|
||||
backendConfig?: Service.BackendConfig,
|
||||
) {
|
||||
const _backendConfig = { ...DEFAULT_BACKEND_OPTIONS, ...backendConfig }
|
||||
const _alovaConfig = { ...DEFAULT_ALOVA_OPTIONS, ...alovaConfig }
|
||||
|
||||
return createAlova({
|
||||
statesHook: VueHook,
|
||||
requestAdapter: adapterFetch(),
|
||||
cacheFor: 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()
|
||||
}
|
||||
alovaConfig.beforeRequest?.(method)
|
||||
}),
|
||||
responded: onResponseRefreshToken({
|
||||
// 请求成功的拦截器
|
||||
onSuccess: async (response, method) => {
|
||||
const { status } = response
|
||||
|
||||
if (status === 200) {
|
||||
// 返回blob数据
|
||||
if (method.meta?.isBlob)
|
||||
return response.blob()
|
||||
|
||||
// 返回json数据
|
||||
const apiData = await response.json()
|
||||
// 请求成功
|
||||
if (apiData[_backendConfig.codeKey] === _backendConfig.successCode)
|
||||
return handleServiceResult(apiData)
|
||||
|
||||
// 业务请求失败
|
||||
const errorResult = handleBusinessError(apiData, _backendConfig)
|
||||
return handleServiceResult(errorResult, false)
|
||||
}
|
||||
// 接口请求失败
|
||||
const errorResult = handleResponseError(response)
|
||||
return handleServiceResult(errorResult, false)
|
||||
},
|
||||
onError: (error, method) => {
|
||||
const tip = `[${method.type}] - [${method.url}] - ${error.message}`
|
||||
window.$message?.warning(tip)
|
||||
},
|
||||
|
||||
onComplete: async (_method) => {
|
||||
// 处理请求完成逻辑
|
||||
},
|
||||
}),
|
||||
})
|
||||
}
|
||||
35
src/service/http/config.ts
Normal file
35
src/service/http/config.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import { $t } from '@/utils'
|
||||
|
||||
/** 默认实例的Aixos配置 */
|
||||
export const DEFAULT_ALOVA_OPTIONS = {
|
||||
// 请求超时时间,默认15秒
|
||||
timeout: 15 * 1000,
|
||||
}
|
||||
|
||||
/** 默认实例的后端字段配置 */
|
||||
export const DEFAULT_BACKEND_OPTIONS = {
|
||||
codeKey: 'code',
|
||||
dataKey: 'data',
|
||||
msgKey: 'message',
|
||||
successCode: 200,
|
||||
}
|
||||
|
||||
/** 请求不成功各种状态的错误 */
|
||||
export const ERROR_STATUS = {
|
||||
default: $t('http.defaultTip'),
|
||||
400: $t('http.400'),
|
||||
401: $t('http.401'),
|
||||
403: $t('http.403'),
|
||||
404: $t('http.404'),
|
||||
405: $t('http.405'),
|
||||
408: $t('http.408'),
|
||||
500: $t('http.500'),
|
||||
501: $t('http.501'),
|
||||
502: $t('http.502'),
|
||||
503: $t('http.503'),
|
||||
504: $t('http.504'),
|
||||
505: $t('http.505'),
|
||||
}
|
||||
|
||||
/** 没有错误提示的code */
|
||||
export const ERROR_NO_TIP_STATUS = [10000]
|
||||
98
src/service/http/handle.ts
Normal file
98
src/service/http/handle.ts
Normal file
@ -0,0 +1,98 @@
|
||||
import { fetchUpdateToken } from '@/service'
|
||||
import { useAuthStore } from '@/store'
|
||||
import { local } from '@/utils'
|
||||
import {
|
||||
ERROR_NO_TIP_STATUS,
|
||||
ERROR_STATUS,
|
||||
} from './config'
|
||||
|
||||
type ErrorStatus = keyof typeof ERROR_STATUS
|
||||
|
||||
/**
|
||||
* @description: 处理请求成功,但返回后端服务器报错
|
||||
* @param {Response} response
|
||||
* @return {*}
|
||||
*/
|
||||
export function handleResponseError(response: Response) {
|
||||
const error: Service.RequestError = {
|
||||
errorType: 'Response Error',
|
||||
code: 0,
|
||||
message: ERROR_STATUS.default,
|
||||
data: null,
|
||||
}
|
||||
const errorCode: ErrorStatus = response.status as ErrorStatus
|
||||
const message = ERROR_STATUS[errorCode] || ERROR_STATUS.default
|
||||
Object.assign(error, { code: errorCode, message })
|
||||
|
||||
showError(error)
|
||||
|
||||
return error
|
||||
}
|
||||
|
||||
/**
|
||||
* @description:
|
||||
* @param {Record} data 接口返回的后台数据
|
||||
* @param {Service} config 后台字段配置
|
||||
* @return {*}
|
||||
*/
|
||||
export function handleBusinessError(data: Record<string, any>, config: Required<Service.BackendConfig>) {
|
||||
const { codeKey, msgKey } = config
|
||||
const error: Service.RequestError = {
|
||||
errorType: 'Business Error',
|
||||
code: data[codeKey],
|
||||
message: data[msgKey],
|
||||
data: data.data,
|
||||
}
|
||||
|
||||
showError(error)
|
||||
|
||||
return error
|
||||
}
|
||||
|
||||
/**
|
||||
* @description: 统一成功和失败返回类型
|
||||
* @param {any} data
|
||||
* @param {boolean} isSuccess
|
||||
* @return {*} result
|
||||
*/
|
||||
export function handleServiceResult(data: any, isSuccess: boolean = true) {
|
||||
const result = {
|
||||
isSuccess,
|
||||
errorType: null,
|
||||
...data,
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* @description: 处理接口token刷新
|
||||
* @return {*}
|
||||
*/
|
||||
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)
|
||||
local.set('refreshToken', data.refreshToken)
|
||||
}
|
||||
else {
|
||||
// 刷新失败,退出
|
||||
await authStore.logout()
|
||||
}
|
||||
}
|
||||
|
||||
export function showError(error: Service.RequestError) {
|
||||
// 如果error不需要提示,则跳过
|
||||
const code = Number(error.code)
|
||||
if (ERROR_NO_TIP_STATUS.includes(code))
|
||||
return
|
||||
|
||||
window.$message.error(error.message)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user