Compare commits

..

No commits in common. "main" and "v0.9" have entirely different histories.
main ... v0.9

170 changed files with 2548 additions and 5670 deletions

View File

@ -1,8 +0,0 @@
/node_modules
/.git
/.gitignore
/.vscode
/.DS_Store
/*.md
/dist

24
.env
View File

@ -1,26 +1,14 @@
# 项目根目录
VITE_BASE_URL = /
VITE_BASE_URL=/
# 项目名称
VITE_APP_NAME = Nova - Admin
# 路由模式 web hash
VITE_APP_NAME=Nova - Admin
# 路由模式
VITE_ROUTE_MODE = web
# 路由加载模式 static dynamic
VITE_ROUTE_LOAD_MODE = static
# 权限路由模式: static dynamic
VITE_AUTH_ROUTE_MODE=static
# 设置登陆后跳转地址
VITE_HOME_PATH = /dashboard/workbench
# 本地存储前缀
VITE_STORAGE_PREFIX =
# 版权信息
VITE_COPYRIGHT_INFO = Copyright © 2024 chansee97
# 自动刷新token
VITE_AUTO_REFRESH_TOKEN = N
# 默认多语言 enUS | zhCN
VITE_DEFAULT_LANG = enUS
VITE_STORAGE_PREFIX=

6
.env.test Normal file
View File

@ -0,0 +1,6 @@
# 是否开启压缩资源
VITE_BUILD_COMPRESS=N
# 压缩算法 gzip | brotliCompress | deflate | deflateRaw
VITE_COMPRESS_TYPE=gzip

View File

@ -1,42 +0,0 @@
name: 🐞 Bug report
description: Create a report to help us improve
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to fill out this bug report!
- type: textarea
id: bug-description
attributes:
label: Description
description: Please explain clearly how the bug reappears. If possible, it is best to add the cause of the problem.
placeholder: bug description
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected
validations:
required: true
- type: textarea
id: additional-comments
attributes:
label: Additional comments
description: e.g. some background/context of how you ran into this bug.
- type: checkboxes
id: checkboxes
attributes:
label: Validations
description: Before submitting the issue, please make sure you do the following
options:
- label: Ensure this issue not a bug proposal.
required: true
- label: Read the [docs](https://nova-admin-docs.pages.dev/).
required: true
- label: Check that there isn't [already an issue](https://github.com/chansee97/nova-admin/issues) that descript the same thing to avoid creating a duplicate.
required: true

View File

@ -1 +0,0 @@
blank_issues_enabled: false

View File

@ -1,45 +0,0 @@
name: ✨ New feature
description: Propose a new feature to be added to Nova-admin
body:
- type: markdown
attributes:
value: |
Thanks for your interest in the project and taking the time to fill out this feature report!
- type: textarea
id: feature-description
attributes:
label: Description
description: Clear and concise description of the problem. Please make the reason and usecases as detailed as possible. If you intend to submit a PR for this issue, tell us in the description. Thanks!
placeholder: As a developer using Nova-admin I want [goal / wish] so that [benefit]...
validations:
required: true
- type: textarea
id: suggested-solution
attributes:
label: Suggestion
description: In module [xy] we could provide following implementation...
validations:
required: true
- type: textarea
id: additional-context
attributes:
label: Additional context
description: Any other context or screenshots about the feature request here.
- type: checkboxes
id: checkboxes
attributes:
label: Validations
description: Before submitting the issue, please make sure you do the following
options:
- label: Ensure this issue not a feature proposal.
required: true
- label: Read the [docs](https://nova-admin-docs.pages.dev/).
required: true
- label: Check that there isn't [already an issue](https://github.com/chansee97/nova-admin/issues) that descript the same thing to avoid creating a duplicate.
required: true

View File

@ -1,31 +0,0 @@
name: 👓 Others
description: Create an issue for Nova-admin
body:
- type: markdown
attributes:
value: |
Thanks for your interest in the project and taking the time to create this issue!
- type: textarea
id: description
attributes:
label: Description
description: Clear and concise description of the issue. Thanks!
placeholder: There are some thing I want to ...
validations:
required: true
- type: checkboxes
id: checkboxes
attributes:
label: Validations
description: Before submitting the issue, please make sure you do the following
options:
- label: Ensure this issue neither a bug report nor a feature proposal.
required: true
- label: Read the [docs](https://nova-admin-docs.pages.dev/).
required: true
- label: Check that there isn't [already an issue](https://github.com/chansee97/nova-admin/issues) that descript the same thing to avoid creating a duplicate.
required: true

View File

@ -15,12 +15,11 @@ jobs:
- uses: actions/checkout@v3
with:
fetch-depth: 0
node-version: 20.x
- uses: actions/setup-node@v3
with:
node-version: 20.x
node-version: 16.x
- run: npx changelogithub
- run: npx changelogithub # or changelogithub@0.12 if ensure the stable result
env:
GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}

11
.gitignore vendored
View File

@ -25,9 +25,8 @@ stats.html
*.sln
*.sw?
components.d.ts
auto-imports.d.ts
auto-proxy.d.ts
# Lock files
*-lock.yaml
/src/typings/components.d.ts
/src/typings/auto-imports.d.ts
pnpm-lock.yaml
package-lock.json
yarn.lock

View File

@ -10,7 +10,6 @@
"antfu.iconify",
"kisstkondoros.vscode-gutter-preview",
"antfu.unocss",
"vue.volar",
"tu6ge.naive-ui-intelligence"
"vue.volar"
]
}

76
.vscode/settings.json vendored
View File

@ -1,4 +1,6 @@
{
// Enable the ESlint flat config support
"eslint.experimental.useFlatConfig": true,
// Disable the default formatter, use eslint instead
"prettier.enable": false,
"editor.formatOnSave": false,
@ -9,16 +11,46 @@
},
// Silent the stylistic rules in you IDE, but still auto fix them
"eslint.rules.customizations": [
{ "rule": "style/*", "severity": "off" },
{ "rule": "format/*", "severity": "off" },
{ "rule": "*-indent", "severity": "off" },
{ "rule": "*-spacing", "severity": "off" },
{ "rule": "*-spaces", "severity": "off" },
{ "rule": "*-order", "severity": "off" },
{ "rule": "*-dangle", "severity": "off" },
{ "rule": "*-newline", "severity": "off" },
{ "rule": "*quotes", "severity": "off" },
{ "rule": "*semi", "severity": "off" }
{
"rule": "style/*",
"severity": "off"
},
{
"rule": "format/*",
"severity": "off"
},
{
"rule": "*-indent",
"severity": "off"
},
{
"rule": "*-spacing",
"severity": "off"
},
{
"rule": "*-spaces",
"severity": "off"
},
{
"rule": "*-order",
"severity": "off"
},
{
"rule": "*-dangle",
"severity": "off"
},
{
"rule": "*-newline",
"severity": "off"
},
{
"rule": "*quotes",
"severity": "off"
},
{
"rule": "*semi",
"severity": "off"
}
],
// Enable eslint for all supported languages
"eslint.validate": [
@ -32,16 +64,7 @@
"json",
"jsonc",
"yaml",
"toml",
"xml",
"gql",
"graphql",
"astro",
"css",
"less",
"scss",
"pcss",
"postcss"
"toml"
],
"i18n-ally.sourceLanguage": "zh_CN",
"i18n-ally.displayLanguage": "zh_CN",
@ -51,16 +74,5 @@
"i18n-ally.localesPaths": [
"locales"
],
// File collapse
"explorer.fileNesting.enabled": true,
"explorer.fileNesting.expand": false,
"explorer.fileNesting.patterns": {
"*.ts": "$(capture).test.ts, $(capture).test.tsx, $(capture).spec.ts, $(capture).spec.tsx, $(capture).d.ts",
"*.tsx": "$(capture).test.ts, $(capture).test.tsx, $(capture).spec.ts, $(capture).spec.tsx,$(capture).d.ts",
"*.env": "$(capture).env.*",
"README.md": "README*,CHANGELOG*,LICENSE,CNAME",
"package.json": "pnpm-lock.yaml,pnpm-workspace.yaml,.gitattributes,.gitignore,.gitpod.yml,.npmrc,.browserslistrc,.node-version,.git*,.tazerc.json",
"eslint.config.js": ".eslintignore,.prettierignore,.stylelintignore,.commitlintrc.*,.prettierrc.*,stylelint.config.*,.lintstagedrc.mjs,cspell.json",
"docker-compose.product.yml": ".dockerignore,nginx.conf"
}
"commentTranslate.source": "Google"
}

View File

@ -5,8 +5,7 @@
<div align="center">
<img src="https://img.shields.io/github/license/chansee97/nova-admin"/>
<img src="https://badgen.net/github/stars/chansee97/nova-admin?icon=github"/>
<img src="https://gitee.com/chansee97/nova-admin/badge/star.svg"/>
<img src="https://img.shields.io/github/stars/chansee97/nova-admin"/>
<img src="https://img.shields.io/github/forks/chansee97/nova-admin"/>
</div>
@ -19,19 +18,19 @@
[Nova-admin](https://github.com/chansee97/nova-admin) is a clean and concise back-end management template based on Vue3, Vite5, Typescript, and Naive UI. It implements complete functionality in a simple way, while also considering code standards, readability, and avoiding excessive encapsulation to facilitate secondary development.
- [Nova-Admin preview](https://nova-admin.pages.dev/)
- [Nova-Admin docs](https://nova-admin-docs.pages.dev/)
- [Nova-Admin preview](https://nova-admin-site.netlify.app/)
- [Nova-Admin docs](https://nova-admin-docs.netlify.app/)
## Features
- Developed based on the latest technology stack including Vue3, Vite6, TypeScript, NaiveUI, Unocss, etc.
- Developed based on the latest technology stack including Vue3, Vite5, TypeScript, NaiveUI, Unocss, etc.
- Based on [alova](https://alova.js.org/) encapsulation and configuration, providing unified response handling and multi-scenario capabilities.
- Comprehensive front-end and back-end permission management solution.
- Supports local static routes and dynamically generated routes from the back end, with easy route configuration.
- Secondary encapsulation of commonly used components to meet basic work requirements.
- Dark theme adaptation, maintaining the Naive style for interface aesthetics.
- Only performs eslint validation during submission without excessive restrictions for simpler development.
- Flexible and configurable interface layout based on [pro-naive-ui](https://github.com/Zheng-Changfu/pro-naive-ui)
- Flexible and configurable interface style layout.
- Multilanguage (i18n) support.
## Project preview
@ -48,16 +47,13 @@
- [Gitee](https://gitee.com/chansee97/nova-admin)
- [Github](https://github.com/chansee97/nova-admin)
## Interface document
## Related projects
This project uses ApiFox for interface mock, check the online documentation for more interface details
[online aipfox docs](https://nova-admin.apifox.cn)
- [Nova-admin-nest](https://github.com/chansee97/nove-admin-nest) (under development) Nova-Admin supporting background project based on TS, NestJs, typeorm
## Install and use
The local development environment is recommended to use pnpm 10.x, Node.js version 21.x.
It is recommended to directly download the compressed package from [Releases](https://github.com/chansee97/nova-admin/releases)
The local development environment is recommended to use pnpm 8.x, Node.js must be version 20.x.
```bash
# install dependencies
@ -71,26 +67,20 @@ pnpm build
```
You can deploy **nova-admin** in a production environment using docker-compose.
```bash
# Build product
docker compose -f docker-compose.product.yml up --build -d
```
> The nginx.conf provided is for reference only. You can adjust it according to your own needs.
## Interface document
## Related projects
- [Nova-admin-nest](https://github.com/chansee97/nova-admin-nest) (under development) Nova-Admin supporting background project based on TS, NestJs, typeorm
This project uses ApiFox for interface mock, check the online documentation for more interface details
[online aipfox docs](https://apifox.com/apidoc/shared-2b1abeb5-6e78-425e-a4ff-d7277ae83bf0)
## Learn to communicate
Nova-Admin is a completely open-source and free project. It is still being optimized and iterated. It is designed to help developers more conveniently develop medium and large management systems. If you have any questions, please ask questions in the QQ exchange group.
| Q-Group | wechat-Group |
| :--: |:--: |
| <img src="https://cdn.jsdelivr.net/gh/chansee97/static/nova-admin/q-group.png" width=170> |<img src="https://cdn.jsdelivr.net/gh/chansee97/static/wechat.png" width=170>|
| Q-Group | wechat-Group |wechat |
| :--: |:--: |:--: |
| <img src="https://cdn.jsdelivr.net/gh/chansee97/static/nova-admin/q-group.png" width=170> | <img src="https://cdn.jsdelivr.net/gh/chansee97/static/nova-admin/wx-group.png" width=170>|<img src="https://cdn.jsdelivr.net/gh/chansee97/static/wechat.png" width=170>|
> Please indicate the purpose of adding WeChat.
> The WeChat group QR code is invalid, please add me as a friend.
## Contribution

View File

@ -5,8 +5,7 @@
<div align="center">
<img src="https://img.shields.io/github/license/chansee97/nova-admin"/>
<img src="https://badgen.net/github/stars/chansee97/nova-admin?icon=github"/>
<img src="https://gitee.com/chansee97/nova-admin/badge/star.svg"/>
<img src="https://img.shields.io/github/stars/chansee97/nova-admin"/>
<img src="https://img.shields.io/github/forks/chansee97/nova-admin"/>
</div>
@ -19,19 +18,19 @@
[Nova-admin](https://github.com/chansee97/nova-admin)是一个基于Vue3、Vite5、Typescript、Naive UI, 简洁干净后台管理模板,用简单的方式实现完整功能,并尽可能的考虑代码规范,易读易理解无过度封装,方便二次开发。
- [Nova-Admin 预览](https://nova-admin.pages.dev/)
- [Nova-Admin 文档](https://nova-admin-docs.pages.dev/)
- [Nova-Admin 预览](https://nova-admin-site.netlify.app/)
- [Nova-Admin 文档](https://nova-admin-docs.netlify.app/)
## 特性
- 基于Vue3、Vite6、TypeScript、NaiveUI、Unocss等最新技术栈开发
- 基于Vue3、Vite5、TypeScript、NaiveUI、Unocss等最新技术栈开发
- 基于[alova](https://alova.js.org/)封装和配置,提供统一的响应处理和多场景能力
- 完善的前后端权限管理方案
- 支持本地静态路由和后台返回动态路由,路由简单易配置
- 对日常使用频率较高的组件二次封装,满足基础工作需求
- 黑暗主题适配, 界面样式保持Naive风格
- 仅在提交时进行eslint校验没有过多限制开发更简便
- 基于[pro-naive-ui](https://github.com/Zheng-Changfu/pro-naive-ui)的界面布局灵活可配置
- 界面样式布局灵活可配置
- 多语言i18n支持
## 项目预览
@ -48,16 +47,13 @@
- [Gitee](https://gitee.com/chansee97/nova-admin)
- [Github](https://github.com/chansee97/nova-admin)
## 接口文档
## 相关项目
本项目使用ApiFox进行接口mock查看在线文档以了解更多接口详情
[在线apifox文档](https://nova-admin.apifox.cn)
- [Nova-admin-nest](https://github.com/chansee97/nove-admin-nest) (开发中)基于TS, NestJs, typeorm的Nova-Admin配套后台项目
## 安装使用
本地开发环境建议使用 pnpm 10.x 、Node.js 21.x
推荐直接下载[Releases](https://github.com/chansee97/nova-admin/releases)压缩包
本地开发环境建议使用 pnpm 8.x 、Node.js 必须 20.x
```bash
# install dependencies
@ -71,26 +67,20 @@ pnpm build
```
在生产环境也可以使用 docker-compose 部署 **nova-admin**
```bash
# Build product
docker compose -f docker-compose.product.yml up --build -d
```
> 关于 nginx.conf 只供参考,你可以根据自己的需求进行调整。
## 接口文档
## 相关项目
- [Nova-admin-nest](https://github.com/chansee97/nova-admin-nest) (开发中)基于TS, NestJs, typeorm的Nova-Admin配套后台项目
本项目使用ApiFox进行接口mock查看在线文档以了解更多接口详情
[在线apifox文档](https://apifox.com/apidoc/shared-2b1abeb5-6e78-425e-a4ff-d7277ae83bf0)
## 学习交流
Nova-Admin 是完全开源免费的项目,目前仍然在优化迭代中,旨在帮助开发者更方便地进行中大型管理系统开发,有使用问题欢迎在交流群内提问。
| Q群 | 微信群 |
| :--: |:--: |
| <img src="https://cdn.jsdelivr.net/gh/chansee97/static/nova-admin/q-group.png" width=170> |<img src="https://cdn.jsdelivr.net/gh/chansee97/static/wechat.png" width=170>|
| Q群 | 微信群 | 个人微信 |
| :--: |:--: |:--: |
| <img src="https://cdn.jsdelivr.net/gh/chansee97/static/nova-admin/q-group.png" width=170> | <img src="https://cdn.jsdelivr.net/gh/chansee97/static/nova-admin/wx-group.png" width=170>|<img src="https://cdn.jsdelivr.net/gh/chansee97/static/wechat.png" width=170>|
> 添加微信请注明来意
> 微信群二维码失效请加我为好友
## 贡献
@ -111,7 +101,6 @@ Nova-Admin 是完全开源免费的项目,目前仍然在优化迭代中,旨
<a href="https://github.com/chansee97/nova-admin/graphs/contributors">
<img src="https://contrib.rocks/image?repo=chansee97/nova-admin" alt="contributors" />
</a>
## Star 历史
[![Star History Chart](https://api.star-history.com/svg?repos=chansee97/nova-admin&type=Date)](https://star-history.com/#chansee97/nova-admin&Date)

View File

@ -1,226 +0,0 @@
import type { ProxyOptions, UserConfig } from 'vite'
import { mkdirSync, writeFileSync } from 'node:fs'
import { dirname } from 'node:path'
/** 服务配置接口 */
interface ServiceConfig {
[key: string]: string
}
/** 服务环境类型 */
type ServiceEnvType = string
/** 完整的服务配置类型 */
interface FullServiceConfig {
[key: ServiceEnvType]: ServiceConfig
}
/** 代理项接口 */
interface ProxyItem {
/** 代理路径 */
path: string
/** 原始地址 */
rawPath: string
}
/** 代理地址映射接口 */
interface ProxyMapping {
[serviceName: string]: ProxyItem
}
/** 插件选项接口 */
export interface ServiceProxyPluginOptions {
/** 服务配置对象(必填) */
serviceConfig: FullServiceConfig
/** 代理路径前缀(可选,默认为 'proxy-' */
proxyPrefix?: string
/** 是否启用代理配置 */
enableProxy?: boolean
/** 环境变量名(可选,默认为 '__URL_MAP__' */
envName?: string
/** d.ts 类型文件生成路径(可选,如果传入路径则在该路径生成 d.ts 类型文件) */
dts?: string
}
export default function createServiceProxyPlugin(options: ServiceProxyPluginOptions) {
const {
serviceConfig,
proxyPrefix = 'proxy-',
enableProxy = true,
envName = '__URL_MAP__',
dts,
} = options
return {
name: 'vite-auto-proxy',
config(config: UserConfig, { mode, command }: { mode: string, command: 'build' | 'serve' }) {
// 只在开发环境serve命令时生成代理配置
const isDev = command === 'serve'
// 在非开发环境也注入空的代理映射,避免运行时错误
if (!config.define) {
config.define = {}
}
if (!enableProxy || !isDev) {
const rawMapping: ProxyMapping = {}
const envConfig = serviceConfig[mode]
if (envConfig) {
Object.entries(envConfig).forEach(([serviceName, serviceUrl]) => {
rawMapping[serviceName] = {
path: serviceUrl,
rawPath: serviceUrl,
}
})
console.warn(`[auto-proxy] 已加载 ${Object.keys(envConfig).length} 个服务地址`)
}
else {
console.warn(`[auto-proxy] 未找到环境 "${mode}" 的配置`)
}
config.define[envName] = JSON.stringify(rawMapping)
// 生成 d.ts 类型文件(如果指定了路径)
if (dts) {
generateDtsFile(rawMapping, dts, envName)
}
return
}
console.warn(`[auto-proxy] 已加载${mode}模式 ${Object.keys(serviceConfig[mode]).length} 个服务地址`)
const { proxyConfig, proxyMapping } = generateProxyFromServiceConfig(serviceConfig, mode, proxyPrefix)
Object.entries(proxyMapping).forEach(([serviceName, proxyItem]) => {
console.warn(`[auto-proxy] 服务: ${serviceName} | 代理地址: ${proxyItem.path} | 实际地址: ${proxyItem.rawPath}`)
})
if (proxyConfig && Object.keys(proxyConfig).length > 0) {
// 确保 server 对象存在
if (!config.server) {
config.server = {}
}
// 合并代理配置
config.server.proxy = {
...config.server.proxy,
...proxyConfig,
}
config.define[envName] = JSON.stringify(proxyMapping)
console.warn(`[auto-proxy] 代理映射已注入到 ${envName}`)
// 生成 d.ts 类型文件(如果指定了路径)
if (dts) {
generateDtsFile(proxyMapping, dts, envName)
}
}
else {
console.warn(`[auto-proxy] 未生成任何代理配置`)
config.define[envName] = JSON.stringify({})
// 生成空的 d.ts 类型文件(如果指定了路径)
if (dts) {
generateDtsFile({}, dts, envName)
}
}
},
}
}
function generateProxyFromServiceConfig(
serviceConfig: FullServiceConfig,
mode: ServiceEnvType,
proxyPrefix: string,
): { proxyConfig: Record<string, ProxyOptions>, proxyMapping: ProxyMapping } {
try {
// 获取当前环境的配置
const envConfig = serviceConfig[mode]
if (!envConfig) {
console.warn(`[auto-proxy] 未找到环境 "${mode}" 的配置,使用 development 配置`)
const defaultConfig = serviceConfig.development
if (!defaultConfig) {
console.error(`[auto-proxy] 也未找到 development 配置`)
return { proxyConfig: {}, proxyMapping: {} }
}
return generateProxyFromConfig(defaultConfig, proxyPrefix)
}
return generateProxyFromConfig(envConfig, proxyPrefix)
}
catch (error) {
console.error(`[auto-proxy] 生成代理配置失败:`, (error as Error).message)
return { proxyConfig: {}, proxyMapping: {} }
}
}
function generateProxyFromConfig(
envConfig: ServiceConfig,
proxyPrefix: string,
): { proxyConfig: Record<string, ProxyOptions>, proxyMapping: ProxyMapping } {
const proxyConfig: Record<string, ProxyOptions> = {}
const proxyMapping: ProxyMapping = {}
Object.entries(envConfig).forEach(([serviceName, serviceUrl]) => {
if (typeof serviceUrl === 'string' && serviceUrl.trim()) {
const proxyPath = `/${proxyPrefix}${serviceName}`
const isWs = serviceUrl.startsWith('ws://') || serviceUrl.startsWith('wss://')
// 生成代理配置
proxyConfig[proxyPath] = {
target: serviceUrl,
changeOrigin: true,
ws: isWs,
rewrite: (path: string): string => path.replace(new RegExp(`^/${proxyPrefix}${serviceName}`), ''),
}
// 生成代理映射
proxyMapping[serviceName] = {
path: proxyPath,
rawPath: serviceUrl,
}
}
})
return { proxyConfig, proxyMapping }
}
function generateDtsFile(
mapping: ProxyMapping,
outputPath: string,
envName: string,
) {
try {
const serviceNames = Object.keys(mapping).map(name => `'${name}'`).join(' | ')
const serviceNameType = serviceNames || 'never'
const dtsContent = `/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// Generated by auto-proxy
// biome-ignore lint: disable
export {}
type serviceName = ${serviceNameType}
declare global {
const ${envName}: {
[K in serviceName]: {
path: string
rawPath: string
}
}
}
`
const dir = dirname(outputPath)
if (dir) {
mkdirSync(dir, { recursive: true })
}
writeFileSync(outputPath, dtsContent, 'utf-8')
}
catch (error) {
console.error(`[auto-proxy] 生成 d.ts 文件失败:`, (error as Error).message)
}
}

View File

@ -1,17 +1,16 @@
import UnoCSS from '@unocss/vite'
import vue from '@vitejs/plugin-vue'
import vueJsx from '@vitejs/plugin-vue-jsx'
import Components from 'unplugin-vue-components/vite'
import AutoImport from 'unplugin-auto-import/vite'
import { NaiveUiResolver } from 'unplugin-vue-components/resolvers'
import viteCompression from 'vite-plugin-compression'
import Icons from 'unplugin-icons/vite'
import { FileSystemIconLoader } from 'unplugin-icons/loaders'
// https://github.com/antfu/unplugin-icons
import IconsResolver from 'unplugin-icons/resolver'
import Icons from 'unplugin-icons/vite'
import { NaiveUiResolver } from 'unplugin-vue-components/resolvers'
import Components from 'unplugin-vue-components/vite'
import viteCompression from 'vite-plugin-compression'
import VueDevTools from 'vite-plugin-vue-devtools'
import AutoProxy from './autoProxy'
import { serviceConfig } from '../service.config'
/**
* @description: vite插件配置
* @param {*} env -
@ -22,29 +21,13 @@ export function createVitePlugins(env: ImportMetaEnv) {
// support vue
vue(),
vueJsx(),
VueDevTools(),
// support unocss
UnoCSS(),
// auto import api of lib
AutoImport({
imports: [
'vue',
'vue-router',
'pinia',
'@vueuse/core',
'vue-i18n',
{
'naive-ui': [
'useDialog',
'useMessage',
'useNotification',
'useLoadingBar',
'useModal',
],
},
],
imports: ['vue', 'vue-router', 'pinia', '@vueuse/core', 'vue-i18n'],
include: [
/\.[tj]sx?$/,
/\.vue$/,
@ -79,12 +62,6 @@ export function createVitePlugins(env: ImportMetaEnv) {
),
},
}),
AutoProxy({
enableProxy: env.VITE_HTTP_PROXY === 'Y',
serviceConfig,
dts: 'src/typings/auto-proxy.d.ts',
}),
]
// use compression
if (env.VITE_BUILD_COMPRESS === 'Y') {

32
build/proxy.ts Normal file
View 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>
}

View File

@ -1,8 +0,0 @@
services:
nova-admin:
build:
context: .
dockerfile: ./docker/dockerfile.product
container_name: nova-admin
ports:
- 80:80

View File

@ -1,23 +0,0 @@
FROM node:20-slim AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
COPY . /app
WORKDIR /app
FROM base AS prod-deps
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --prod --frozen-lockfile
FROM base AS builder
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
RUN pnpm run build
FROM nginx:1.23.1-alpine
WORKDIR /www
COPY --from=builder /app/dist/ .
COPY ./nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80

View File

@ -2,21 +2,4 @@
import antfu from '@antfu/eslint-config'
// https://github.com/antfu/eslint-config
export default antfu(
{
typescript: {
overrides: {
'perfectionist/sort-exports': 'off',
'perfectionist/sort-imports': 'off',
'ts/no-unused-expressions': ['error', { allowShortCircuit: true }],
},
},
vue: {
overrides: {
'vue/no-unused-refs': 'off', // 暂时关闭等待vue-lint的分支合并
'vue/no-reserved-component-names': 'off',
'vue/component-definition-name-casing': 'off',
},
},
},
)
export default antfu()

View File

@ -9,6 +9,7 @@
</head>
<body>
<div id="appLoading"></div>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>

View File

@ -3,18 +3,14 @@
"cancel": "Cancel",
"confirm": "Confirm",
"close": "Closure",
"reload": "Refresh",
"choose": "Choose",
"navigate": "Navigate",
"inputPlaceholder": "please enter",
"selectPlaceholder": "please choose"
"reload": "Refresh"
},
"app": {
"loginOut": "Login out",
"loginOutContent": "Confirm to log out of current account?",
"loginOutTitle": "Sign out",
"userCenter": "Personal center",
"light": "Light",
"lignt": "Light",
"dark": "Dark",
"system": "System",
"backTop": "Back to top",
@ -42,7 +38,6 @@
"themeSetting": "Theme settings",
"todos": "Todos",
"toggleFullScreen": "Toggle full screen",
"togglContentFullScreen": "Toggle content full screen",
"topProgress": "Top progress",
"transitionFadeBottom": "Bottom fade",
"transitionFadeScale": "Scale fade",
@ -59,12 +54,8 @@
"backHome": "Back to the homepage",
"getRouteError": "Failed to obtain route, please try again later.",
"layoutSetting": "Layout settings",
"verticalLayout": "Vertical layout",
"horizontalLayout": "Horizontal layout",
"twoColumnLayout": "Two column layout",
"mixedTwoColumnLayout": "Mixed two column layout",
"sidebarLayout": "Sidebar layout",
"mixedSidebarLayout": "Mixed sidebar layout"
"leftMenu": "Left menu",
"topMenu": "Top menu"
},
"login": {
"signInTitle": "Login",
@ -92,33 +83,34 @@
"route": {
"appRoot": "Home",
"cardList": "Card list",
"draggableList": "Draggable list",
"commonList": "Common list",
"dashboard": "Dashboard",
"demo": "Function example",
"fetch": "Request example",
"list": "List",
"monitor": "Monitoring",
"multi": "Multi-level menu",
"multi2": "Multi-level menu subpage",
"multi2Detail": "Details page of multi-level menu",
"multi3": "multi-level menu",
"multi4": "Multi-level menu 3-1",
"test": "Multi-level menu",
"test2": "Multi-level menu subpage",
"test2Detail": "Details page of multi-level menu",
"test3": "multi-level menu",
"test4": "Multi-level menu 3-1",
"workbench": "Workbench",
"QRCode": "QR code",
"about": "About",
"clipboard": "Clipboard",
"demo403": "403",
"demo404": "404",
"demo500": "500",
"dictionarySetting": "Dictionary settings",
"documents": "Document",
"documentsVite": "Vite",
"documentsVue": "Vue",
"documentsVueuse": "VueUse (external link)",
"documentsNova": "Nova docs",
"documentsPublic": "Public page (external link)",
"docments": "Document",
"docmentsVite": "Vite",
"docmentsVue": "Vue",
"docmentsVueuse": "VueUse (external link)",
"echarts": "Echarts",
"editor": "Editor",
"editorMd": "MarkDown editor",
"editorRich": "Rich text editor",
"error": "Exception page",
"icons": "Icon",
"justSuper": "Supervisible",
"map": "Map",
@ -127,9 +119,7 @@
"permissionDemo": "Permissions example",
"setting": "System settings",
"userCenter": "Personal Center",
"accountSetting": "User settings",
"cascader": "Administrative region selection",
"dict": "Dictionary example"
"accountSetting": "User settings"
},
"http": {
"400": "Syntax error in the request",
@ -149,15 +139,7 @@
"components": {
"iconSelector": {
"inputPlaceholder": "Select target icon",
"searchPlaceholder": "Search icon",
"clearIcon": "Clear icon",
"selectorTitle": "Icon selection"
},
"copyText": {
"message": "Copied successfully",
"tooltip": "Copy",
"unsupportedError": "Your browser does not support Clipboard API",
"unpermittedError": "Crrently not permitted to use Clipboard API"
"searchPlaceholder": "Search icon"
}
}
}

View File

@ -3,24 +3,19 @@
"confirm": "确认",
"cancel": "取消",
"reload": "刷新",
"close": "关闭",
"choose": "选择",
"navigate": "切换",
"inputPlaceholder": "请输入",
"selectPlaceholder": "请选择"
"close": "关闭"
},
"app": {
"loginOut": "退出登录",
"loginOutTitle": "退出登录",
"loginOutContent": "确认退出当前账号?",
"userCenter": "个人中心",
"light": "浅色",
"lignt": "浅色",
"dark": "深色",
"system": "跟随系统",
"backTop": "返回顶部",
"toggleSider": "切换侧边栏",
"toggleFullScreen": "切换全屏",
"togglContentFullScreen": "切换内容全屏",
"notificationsTips": "消息通知",
"notifications": "通知",
"messages": "消息",
@ -59,12 +54,8 @@
"backHome": "回到首页",
"getRouteError": "获取路由失败,请稍后再试",
"layoutSetting": "布局设置",
"verticalLayout": "竖向布局",
"horizontalLayout": "横向布局",
"twoColumnLayout": "双栏布局",
"mixedTwoColumnLayout": "混合双栏布局",
"sidebarLayout": "侧边栏布局",
"mixedSidebarLayout": "双栏布局"
"leftMenu": "左侧菜单",
"topMenu": "顶部菜单"
},
"http": {
"400": "请求出现语法错误",
@ -83,16 +74,8 @@
},
"components": {
"iconSelector": {
"selectorTitle": "图标选择",
"inputPlaceholder": "选择目标图标",
"searchPlaceholder": "搜索图标",
"clearIcon": "清除图标"
},
"copyText": {
"tooltip": "复制",
"message": "复制成功",
"unsupportedError": "您的浏览器不支持剪贴板API",
"unpermittedError": "目前不允许使用剪贴板API"
"searchPlaceholder": "搜索图标"
}
},
"login": {
@ -123,15 +106,14 @@
"dashboard": "仪表盘",
"workbench": "工作台",
"monitor": "监控页",
"multi": "多级菜单演示",
"multi2": "多级菜单子页",
"multi2Detail": "多级菜单的详情页",
"multi3": "多级菜单",
"multi4": "多级菜单3-1",
"test": "多级菜单演示",
"test2": "多级菜单子页",
"test2Detail": "多级菜单的详情页",
"test3": "多级菜单",
"test4": "多级菜单3-1",
"list": "列表页",
"commonList": "常用列表",
"cardList": "卡片列表",
"draggableList": "拖拽列表",
"demo": "功能示例",
"fetch": "请求示例",
"echarts": "Echarts示例",
@ -142,22 +124,22 @@
"clipboard": "剪贴板",
"icons": "图标",
"QRCode": "二维码",
"documents": "文档",
"documentsVue": "Vue",
"documentsVite": "Vite",
"documentsVueuse": "VueUse外链",
"documentsNova": "Nova 文档",
"documentsPublic": "公共示例页(外链)",
"docments": "文档",
"docmentsVue": "Vue",
"docmentsVite": "Vite",
"docmentsVueuse": "VueUse外链",
"permission": "权限",
"permissionDemo": "权限示例",
"justSuper": "super可见",
"error": "异常页",
"demo403": "403",
"demo404": "404",
"demo500": "500",
"setting": "系统设置",
"accountSetting": "用户设置",
"dictionarySetting": "字典设置",
"menuSetting": "菜单设置",
"userCenter": "个人中心",
"about": "关于",
"cascader": "省市区联动",
"dict": "字典示例"
"about": "关于"
}
}

17
netlify.toml Normal file
View 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"

View File

@ -1,66 +0,0 @@
server {
listen 80;
listen [::]:80;
# 启用 gzip 压缩
gzip on;
gzip_vary on;
gzip_min_length 10240;
gzip_proxied expired no-cache no-store private auth;
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml application/javascript;
gzip_disable "MSIE [1-6]\.";
# 设定 MIME types
include /etc/nginx/mime.types;
# 基本安全设定
add_header X-Frame-Options "SAMEORIGIN";
add_header X-XSS-Protection "1; mode=block";
add_header X-Content-Type-Options "nosniff";
# 增加伺服器效能的配置
client_max_body_size 100M;
client_body_buffer_size 128k;
proxy_connect_timeout 90;
proxy_send_timeout 90;
proxy_read_timeout 90;
proxy_buffer_size 4k;
proxy_buffers 4 32k;
proxy_busy_buffers_size 64k;
location / {
root /www;
index index.html;
try_files $uri $uri/ /index.html;
# 设定快取控制
location ~* \.(jpg|jpeg|png|gif|ico|css|js)$ {
expires 30d;
add_header Cache-Control "public, no-transform";
}
# 动态内容不快取
location = /index.html {
add_header Cache-Control "no-store, no-cache, must-revalidate";
add_header Pragma "no-cache";
expires -1;
}
# 错误处理
proxy_next_upstream error timeout invalid_header http_500 http_502 http_503 http_504;
proxy_intercept_errors on;
# 基本的代理设定
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# 禁止访问隐藏文件
location ~ /\. {
deny all;
access_log off;
log_not_found off;
}
}

View File

@ -1,9 +1,9 @@
{
"name": "nova-admin",
"type": "module",
"version": "0.9.18",
"version": "0.9.0",
"private": true,
"description": "a clean and concise back-end management template based on Vue3, Vite5, Typescript, and Naive UI.",
"description": "",
"author": {
"name": "chansee97",
"email": "chen.dev@foxmail.com",
@ -38,58 +38,64 @@
],
"scripts": {
"dev": "vite --mode dev --port 9980",
"dev:prod": "vite --mode production",
"build": "vite build",
"build:dev": "vite build --mode dev",
"dev:test": "vite --mode test",
"dev:prod": "vite --mode prod",
"build": "vue-tsc --noEmit && vite build --mode prod",
"build:dev": "vue-tsc --noEmit && vite build --mode dev",
"build:test": "vue-tsc --noEmit && vite build --mode test",
"preview": "vite preview --port 9981",
"lint": "eslint . && vue-tsc --noEmit",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"lint:check": "npx @eslint/config-inspector",
"sizecheck": "npx vite-bundle-visualizer"
},
"dependencies": {
"@vueuse/core": "^13.6.0",
"alova": "^3.3.4",
"@alova/scene-vue": "^1.4.6",
"@tinymce/tinymce-vue": "^5.1.1",
"@vueuse/core": "^10.9.0",
"alova": "^2.19.0",
"colord": "^2.9.3",
"echarts": "^5.6.0",
"md-editor-v3": "^5.6.1",
"pinia": "^3.0.3",
"pinia-plugin-persistedstate": "^4.4.1",
"pro-naive-ui": "^2.4.3",
"quill": "^2.0.3",
"radash": "^12.1.1",
"vue": "^3.5.18",
"vue-draggable-plus": "^0.6.0",
"vue-i18n": "^11.1.11",
"vue-router": "^4.5.1"
"echarts": "^5.5.0",
"md-editor-v3": "^4.11.3",
"performant-array-to-tree": "^1.11.0",
"pinia": "^2.1.7",
"pinia-plugin-persist": "^1.0.0",
"qs": "^6.12.0",
"radash": "^12.1.0",
"vue": "^3.4.21",
"vue-i18n": "^9.11.1",
"vue-router": "^4.3.0"
},
"devDependencies": {
"@antfu/eslint-config": "^5.0.0",
"@iconify-json/icon-park-outline": "^1.2.2",
"@iconify/vue": "^5.0.0",
"@types/node": "^24.1.0",
"@vitejs/plugin-vue": "^6.0.1",
"@vitejs/plugin-vue-jsx": "^5.0.1",
"eslint": "^9.29.0",
"lint-staged": "^16.1.2",
"naive-ui": "^2.42.0",
"sass": "^1.89.2",
"simple-git-hooks": "^2.13.1",
"typescript": "^5.8.3",
"unocss": "^66.3.3",
"unplugin-auto-import": "^19.3.0",
"unplugin-icons": "^22.2.0",
"unplugin-vue-components": "^28.8.0",
"vite": "^7.0.6",
"vite-bundle-visualizer": "^1.2.1",
"@antfu/eslint-config": "^2.13.3",
"@iconify-json/icon-park-outline": "^1.1.15",
"@iconify/vue": "^4.1.1",
"@types/node": "^20.12.7",
"@types/qs": "^6.9.14",
"@vitejs/plugin-vue": "^5.0.4",
"@vitejs/plugin-vue-jsx": "^3.1.0",
"eslint": "^9.0.0",
"lint-staged": "^15.2.2",
"naive-ui": "^2.38.1",
"sass": "^1.75.0",
"simple-git-hooks": "^2.11.1",
"typescript": "^5.4.5",
"unocss": "^0.59.1",
"unplugin-auto-import": "^0.17.5",
"unplugin-icons": "^0.18.5",
"unplugin-vue-components": "^0.26.0",
"vite": "^5.2.8",
"vite-bundle-visualizer": "^1.1.0",
"vite-plugin-compression": "^0.5.1",
"vite-plugin-vue-devtools": "8.0.0",
"vue-tsc": "^3.0.5"
"vue-tsc": "^2.0.12"
},
"simple-git-hooks": {
"pre-commit": "pnpm lint-staged"
},
"lint-staged": {
"*": "eslint --fix"
},
"volta": {
"node": "20.12.2"
}
}

View File

@ -1,6 +0,0 @@
ignoredBuiltDependencies:
- '@parcel/watcher'
- esbuild
- simple-git-hooks
- vue-demi
- unrs-resolver

View File

@ -1,9 +1,12 @@
/** 不同请求服务的环境配置 */
export const serviceConfig: Record<ServiceEnvType, Record<string, string>> = {
dev: {
url: 'http://localhost:3000',
url: 'https://mock.apifox.com/m1/4071143-0-default',
},
production: {
url: 'https://mock.apifox.cn/m1/4071143-0-default',
test: {
url: 'https://mock.apifox.com/m1/4071143-0-default',
},
prod: {
url: 'https://mock.apifox.com/m1/4071143-0-default',
},
}

View File

@ -1,18 +1,24 @@
<script setup lang="ts">
import AppMain from './AppMain.vue'
import AppLoading from './components/common/AppLoading.vue'
import { darkTheme } from 'naive-ui'
import { useAppStore } from './store'
import { naiveI18nOptions } from '@/utils'
// 使 Suspense
const appStore = useAppStore()
const naiveLocale = computed(() => {
return naiveI18nOptions[appStore.lang] ? naiveI18nOptions[appStore.lang] : naiveI18nOptions.enUS
},
)
</script>
<template>
<Suspense>
<!-- 异步组件 -->
<AppMain />
<!-- 加载状态 -->
<template #fallback>
<AppLoading />
</template>
</Suspense>
<n-config-provider
class="wh-full" inline-theme-disabled :theme="appStore.colorMode === 'dark' ? darkTheme : null"
:locale="naiveLocale.locale" :date-locale="naiveLocale.dateLocale" :theme-overrides="appStore.theme"
>
<naive-provider>
<router-view />
<Watermark :show-watermark="appStore.showWatermark" />
</naive-provider>
</n-config-provider>
</template>

View File

@ -1,57 +0,0 @@
<script setup lang="ts">
import type { App } from 'vue'
import { installRouter } from '@/router'
import { installPinia } from '@/store'
import { naiveI18nOptions } from '@/utils'
import { darkTheme } from 'naive-ui'
import { useAppStore } from './store'
// Promise -
const initializationPromise = (async () => {
//
const app = getCurrentInstance()?.appContext.app
if (!app) {
throw new Error('Failed to get app instance')
}
// Pinia
await installPinia(app)
// Vue-router
await installRouter(app)
// /
const modules = import.meta.glob<{ install: (app: App) => void }>('./modules/*.ts', {
eager: true,
})
Object.values(modules).forEach(module => app.use(module))
return true
})()
// - 使 setup
await initializationPromise
const appStore = useAppStore()
const naiveLocale = computed(() => {
return naiveI18nOptions[appStore.lang] ? naiveI18nOptions[appStore.lang] : naiveI18nOptions.enUS
})
</script>
<template>
<n-config-provider
class="wh-full"
inline-theme-disabled
:theme="appStore.colorMode === 'dark' ? darkTheme : null"
:locale="naiveLocale.locale"
:date-locale="naiveLocale.dateLocale"
:theme-overrides="appStore.theme"
>
<naive-provider>
<router-view />
<Watermark :show-watermark="appStore.showWatermark" />
</naive-provider>
</n-config-provider>
</template>

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 60 KiB

View File

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 71 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 41 KiB

View File

@ -2,36 +2,34 @@
</script>
<template>
<naive-provider>
<div id="loading-container">
<div class="boxes">
<div class="box">
<div />
<div />
<div />
<div />
</div>
<div class="box">
<div />
<div />
<div />
<div />
</div>
<div class="box">
<div />
<div />
<div />
<div />
</div>
<div class="box">
<div />
<div />
<div />
<div />
</div>
<div id="loading-container">
<div class="boxes">
<div class="box">
<div />
<div />
<div />
<div />
</div>
<div class="box">
<div />
<div />
<div />
<div />
</div>
<div class="box">
<div />
<div />
<div />
<div />
</div>
<div class="box">
<div />
<div />
<div />
<div />
</div>
</div>
</naive-provider>
</div>
</template>
<style scoped>

View File

@ -1,9 +1,9 @@
<script setup lang="ts">
import { useAppStore } from '@/store'
import IconAuto from '~icons/icon-park-outline/laptop-computer'
import IconMoon from '~icons/icon-park-outline/moon'
import IconSun from '~icons/icon-park-outline/sun-one'
import { NFlex } from 'naive-ui'
import { useAppStore } from '@/store'
import IconSun from '~icons/icon-park-outline/sun-one'
import IconMoon from '~icons/icon-park-outline/moon'
import IconAuto from '~icons/icon-park-outline/laptop-computer'
const { t } = useI18n()
@ -12,7 +12,7 @@ const appStore = useAppStore()
const options = computed(() => {
return [
{
label: t('app.light'),
label: t('app.lignt'),
value: 'light',
icon: IconSun,
},

View 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>

View File

@ -3,14 +3,14 @@ interface Props {
message: string
}
const { message } = defineProps<Props>()
const props = defineProps<Props>()
</script>
<template>
<n-tooltip :show-arrow="false" trigger="hover">
<template #trigger>
<icon-park-outline-help class="op-50 cursor-help" />
<icon-park-outline-help />
</template>
{{ message }}
{{ props.message }}
</n-tooltip>
</template>

View File

@ -1,188 +0,0 @@
<script setup lang="ts">
interface Props {
disabled?: boolean
}
const {
disabled = false,
} = defineProps<Props>()
interface IconList {
prefix: string
icons: string[]
title: string
total: number
categories?: Record<string, string[]>
uncategorized?: string[]
}
const value = defineModel('value', { type: String })
// https://icon-sets.iconify.design/
const nameList = ['icon-park-outline', 'carbon', 'ant-design']
//
async function fetchIconList(name: string): Promise<IconList> {
return await fetch(`https://api.iconify.design/collection?prefix=${name}`).then(res => res.json())
}
//
async function fetchIconAllList(nameList: string[]) {
//
const targets = await Promise.all(nameList.map(fetchIconList))
//
const iconList = targets.map((item) => {
const icons = [
...(item.categories ? Object.values(item.categories).flat() : []),
...(item.uncategorized ? Object.values(item.uncategorized).flat() : []),
]
return { ...item, icons }
})
//
const svgNames = Object.keys(import.meta.glob('@/assets/svg-icons/*.svg')).map(
path => path.split('/').pop()?.replace('.svg', ''),
).filter(Boolean) as string[] // undefined string[]
//
iconList.unshift({
prefix: 'local',
title: 'Local Icons',
icons: svgNames,
total: svgNames.length,
uncategorized: svgNames,
})
return iconList
}
const iconList = shallowRef<IconList[]>([])
onMounted(async () => {
iconList.value = await fetchIconAllList(nameList)
})
// tab
const currentTab = shallowRef(0)
// tag
const currentTag = shallowRef('')
//
const searchValue = ref('')
//
const currentPage = shallowRef(1)
// tab
function handleChangeTab(index: number) {
currentTab.value = index
currentTag.value = ''
currentPage.value = 1
}
// tag
function handleSelectIconTag(icon: string) {
currentTag.value = currentTag.value === icon ? '' : icon
currentPage.value = 1
}
//
const icons = computed(() => {
if (!iconList.value[currentTab.value])
return []
const hasTag = !!currentTag.value
return hasTag
? iconList.value[currentTab.value]?.categories?.[currentTag.value] || [] // 使
: iconList.value[currentTab.value].icons || []
})
//
const filteredIcons = computed(() => {
return icons.value?.filter(i => i.includes(searchValue.value)) || []
})
//
const visibleIcons = computed(() => {
return filteredIcons.value.slice((currentPage.value - 1) * 200, currentPage.value * 200)
})
const showModal = ref(false)
//
function handleSelectIcon(icon: string) {
value.value = icon
showModal.value = false
}
//
function clearIcon() {
value.value = ''
showModal.value = false
}
</script>
<template>
<n-input-group disabled>
<n-button v-if="value" :disabled="disabled" type="primary">
<template #icon>
<nova-icon :icon="value" />
</template>
</n-button>
<n-input :value="value" readonly :placeholder="$t('components.iconSelector.inputPlaceholder')" />
<n-button type="primary" ghost :disabled="disabled" @click="showModal = true">
{{ $t('common.choose') }}
</n-button>
</n-input-group>
<n-modal
v-model:show="showModal" preset="card" :title="$t('components.iconSelector.selectorTitle')" size="small" class="w-800px" :bordered="false"
>
<template #header-extra>
<n-button type="warning" size="small" ghost @click="clearIcon">
{{ $t('components.iconSelector.clearIcon') }}
</n-button>
</template>
<n-tabs :value="currentTab" type="line" animated placement="left" @update:value="handleChangeTab">
<n-tab-pane v-for="(list, index) in iconList" :key="list.prefix" :name="index" :tab="list.title">
<n-flex vertical>
<n-flex size="small">
<n-tag
v-for="(_v, k) in list.categories" :key="k"
:checked="currentTag === k" round checkable size="small"
@update:checked="handleSelectIconTag(k)"
>
{{ k }}
</n-tag>
</n-flex>
<n-input
v-model:value="searchValue" type="text" clearable
:placeholder="$t('components.iconSelector.searchPlaceholder')"
/>
<div>
<n-flex :size="2">
<n-el
v-for="(icon) in visibleIcons" :key="icon"
class="hover:(text-[var(--primary-color)] ring-1) ring-[var(--primary-color)] p-1 rounded flex-center"
:title="`${list.prefix}:${icon}`"
@click="handleSelectIcon(`${list.prefix}:${icon}`)"
>
<nova-icon :icon="`${list.prefix}:${icon}`" :size="24" />
</n-el>
<n-empty v-if="visibleIcons.length === 0" class="w-full" />
</n-flex>
</div>
<n-flex justify="center">
<n-pagination
v-model:page="currentPage"
:item-count="filteredIcons.length"
:page-size="200"
/>
</n-flex>
</n-flex>
</n-tab-pane>
</n-tabs>
</n-modal>
</template>

View File

@ -0,0 +1,35 @@
export const icons: string[] = [
'icon-park-outline:ad-product',
'icon-park-outline:all-application',
'icon-park-outline:hamburger-button',
'icon-park-outline:setting',
'icon-park-outline:add-one',
'icon-park-outline:reduce-one',
'icon-park-outline:close-one',
'icon-park-outline:help',
'icon-park-outline:info',
'icon-park-outline:grid-four',
'icon-park-outline:key-two',
'icon-park-outline:write',
'icon-park-outline:fire',
'icon-park-outline:memory-card-one',
'icon-park-outline:coupon',
'icon-park-outline:ticket-one',
'icon-park-outline:pay-code-two',
'icon-park-outline:wallet-one',
'icon-park-outline:gift',
'icon-park-outline:mail',
'icon-park-outline:log',
'icon-park-outline:people',
'icon-park-outline:alarm-clock',
'ic:baseline-filter-1',
'ic:baseline-filter-2',
'ic:baseline-filter-3',
'ic:baseline-filter-4',
'ic:baseline-filter-5',
'ic:baseline-filter-6',
'ic:baseline-filter-7',
'ic:baseline-filter-8',
'ic:baseline-filter-9',
'ic:baseline-filter-9-plus',
]

View File

@ -0,0 +1,51 @@
<script setup lang="ts">
import { icons } from './icons'
interface Props {
disabled?: boolean
}
const props = withDefaults(defineProps<Props>(), {
disabled: false,
})
const value = defineModel('value', { type: String })
const searchValue = ref('')
const showPopover = ref(false)
const { t } = useI18n()
const iconList = computed(() => icons.filter(item => item.includes(searchValue.value)))
function handleSelectIcon(icon: string) {
value.value = icon
showPopover.value = false
}
</script>
<template>
<n-popover v-model:show="showPopover" placement="bottom" trigger="click" :disabled="props.disabled">
<template #trigger>
<n-input :value="value" readonly :placeholder="t('components.iconSelector.inputPlaceholder')">
<template #suffix>
<nova-icon :icon="value" />
</template>
</n-input>
</template>
<template #header>
<n-input v-model:value="searchValue" type="text" :placeholder="t('components.iconSelector.searchPlaceholder')" />
</template>
<div class="w-400px">
<div v-if="iconList.length > 0" class="grid grid-cols-9 h-auto overflow-auto gap-1">
<div
v-for="(item, index) in iconList" :key="index" class="border border-gray-200 m-2px p-5px flex-center"
@click="handleSelectIcon(item)"
>
<nova-icon :icon="item" :size="24" />
</div>
</div>
<n-empty v-else class="w-full" />
</div>
</n-popover>
</template>
<style scoped></style>

View File

@ -11,36 +11,20 @@ interface iconPorps {
/* 图标深度 */
depth?: 1 | 2 | 3 | 4 | 5
}
const { size = 18, icon } = defineProps<iconPorps>()
const isLocal = computed(() => {
return icon && icon.startsWith('local:')
const props = withDefaults(defineProps<iconPorps>(), {
size: 18,
})
function getLocalIcon(icon: string) {
const svgName = icon.replace('local:', '')
const svg = import.meta.glob<string>('@/assets/svg-icons/*.svg', {
query: '?raw',
import: 'default',
eager: true,
})
return svg[`/src/assets/svg-icons/${svgName}.svg`]
}
</script>
<template>
<n-icon
v-if="icon"
:size="size"
:depth="depth"
:color="color"
v-if="props.icon"
:size="props.size"
:depth="props.depth"
:color="props.color"
>
<template v-if="isLocal">
<i v-html="getLocalIcon(icon)" />
</template>
<template v-else>
<Icon :icon="icon" />
</template>
<Icon :icon="props.icon" />
</n-icon>
</template>
<style scoped></style>

View File

@ -1,15 +1,11 @@
<script setup lang="ts">
interface Props {
count?: number
}
const {
count = 0,
} = defineProps<Props>()
const emit = defineEmits<{
change: [page: number, pageSize: number] //
}>()
const props = defineProps({
count: {
type: Number,
default: 0,
},
})
const emit = defineEmits(['change'])
const page = ref(1)
const pageSize = ref(10)
const displayOrder: Array<'pages' | 'size-picker' | 'quick-jumper'> = ['size-picker', 'pages']
@ -21,11 +17,10 @@ function changePage() {
<template>
<n-pagination
v-if="count > 0"
v-if="props.count > 0"
v-model:page="page"
v-model:page-size="pageSize"
:page-sizes="[10, 20, 30, 50]"
:item-count="count"
:item-count="props.count"
:display-order="displayOrder"
show-size-picker
@update-page="changePage"

View File

@ -3,15 +3,16 @@ interface Props {
showWatermark: boolean
text?: string
}
const {
text = 'Watermark',
} = defineProps<Props>()
const props = withDefaults(defineProps<Props>(), {
showWatermark: false,
text: 'Watermark',
})
</script>
<template>
<n-watermark
v-if="showWatermark"
:content="text"
v-if="props.showWatermark"
:content="props.text"
cross
fullscreen
:font-size="16"

View File

@ -1,23 +1,17 @@
<script setup lang="ts">
interface Props {
const props = defineProps<{
maxLength?: string
}
const { maxLength } = defineProps<Props>()
const modelValue = defineModel<string>('value')
}>()
const modelValue = defineModel<string>()
</script>
<template>
<div v-if="modelValue" class="inline-flex items-center gap-0.5em">
<n-ellipsis :style="{ 'max-width': maxLength || '12em' }">
<n-ellipsis :style="{ 'max-width': props.maxLength || '12em' }">
{{ modelValue }}
</n-ellipsis>
<n-tooltip trigger="hover">
<template #trigger>
<span v-copy="modelValue" class="cursor-pointer">
<icon-park-outline-copy />
</span>
</template>
{{ $t('components.copyText.tooltip') }}
</n-tooltip>
<span v-copy="modelValue" class="cursor-pointer">
<icon-park-outline-copy />
</span>
</div>
</template>

View File

@ -1,16 +1,26 @@
<script setup lang="ts">
import type { ToolbarNames } from 'md-editor-v3'
import { useAppStore } from '@/store'
import { MdEditor } from 'md-editor-v3'
// https://imzbf.github.io/md-editor-v3/zh-CN/docs
import 'md-editor-v3/lib/style.css'
import { useAppStore } from '@/store'
const model = defineModel<string>()
const props = defineProps<{
modelValue: string
}>()
const emit = defineEmits(['update:modelValue'])
const appStore = useAppStore()
const data = useVModel(props, 'modelValue', emit)
const theme = computed(() => {
return appStore.colorMode ? 'dark' : 'light'
})
const toolbarsExclude: ToolbarNames[] = [
'mermaid',
'katex',
@ -22,7 +32,7 @@ const toolbarsExclude: ToolbarNames[] = [
<template>
<MdEditor
v-model="model" :theme="appStore.colorMode" :toolbars-exclude="toolbarsExclude"
v-model="data" :theme="theme" :toolbars-exclude="toolbarsExclude"
/>
</template>

View File

@ -1,107 +1,77 @@
<script setup lang="ts">
import Quill from 'quill'
import { useTemplateRef } from 'vue'
import 'quill/dist/quill.snow.css'
import Editor from '@tinymce/tinymce-vue'
defineOptions({
name: 'RichTextEditor',
})
const props = defineProps<{
modelValue: string
}>()
const { disabled } = defineProps<Props>()
interface Props {
disabled?: boolean
}
const model = defineModel<string>()
const emit = defineEmits(['update:modelValue'])
let editorInst = null
const data = useVModel(props, 'modelValue', emit)
const editorModel = ref<string>()
function imagesUploadHandler(blobInfo: any, _progress: number) {
return new Promise((resolve, reject) => {
const formData = new FormData()
formData.append('file', blobInfo.blob())
fetch('www.example.com/upload', {
method: 'POST',
body: formData,
})
.then((response) => {
if (!response.ok)
throw new Error('上传失败')
onMounted(() => {
initEditor()
})
const editorRef = useTemplateRef<HTMLElement>('editorRef')
function initEditor() {
const options = {
modules: {
toolbar: [
{ header: [1, 2, 3, 4, 5, 6, false] }, //
'bold', //
'italic', //
'strike', // 线
{ size: ['small', false, 'large', 'huge'] }, //
{ font: [] }, //
{ color: [] }, //
{ background: [] }, //
'link', //
'image', //
'blockquote', //
'link', //
'image', //
'video', //
{ list: 'bullet' }, //
{ list: 'ordered' }, //
{ script: 'sub' }, //
{ script: 'super' }, //
{ align: [] }, //
'formula', //
'clean', // remove formatting button
],
},
placeholder: 'Insert text here ...',
theme: 'snow',
}
const quill = new Quill(editorRef.value!, options)
quill.on('text-change', (_delta, _oldDelta, _source) => {
editorModel.value = quill.getSemanticHTML()
//
resolve('上传成功')
})
.catch((error) => {
//
reject(error)
})
})
if (disabled)
quill.enable(false)
editorInst = quill
if (model.value)
setContents(model.value)
}
const initConfig = {
language: 'zh_CN', //
min_height: 700,
content_css: 'dark',
placeholder: '请输入内容', // textarea
branding: false,
font_formats:
'微软雅黑=Microsoft YaHei,Helvetica Neue,PingFang SC,sans-serif;苹果苹方=PingFang SC,Microsoft YaHei,sans-serif;宋体=simsun,serif;仿宋体=FangSong,serif;黑体=SimHei,sans-serif;Arial=arial,helvetica,sans-serif;Arial Black=arial black,avant garde;Book Antiqua=book antiqua,palatino;', //
plugins:
'print preview searchreplace autolink directionality visualblocks visualchars fullscreen code codesample table charmap hr pagebreak nonbreaking anchor insertdatetime advlist lists wordcount textpattern autosave emoticons', // axupimgs indent2em
toolbar: [
'fullscreen undo redo restoredraft | cut copy paste pastetext | forecolor backcolor bold italic underline strikethrough anchor | alignleft aligncenter alignright alignjustify outdent indent | bullist numlist | blockquote subscript superscript removeformat ',
'styleselect formatselect fontselect fontsizeselect | table emoticons charmap hr pagebreak insertdatetime selectall visualblocks | code preview | indent2em lineheight formatpainter',
],
paste_data_images: true, //
//
images_upload_handler: imagesUploadHandler,
function setContents(html: string) {
editorInst!.setContents(editorInst!.clipboard.convert({ html }))
}
watch(
() => model.value,
(newValue, _oldValue) => {
if (newValue && newValue !== editorModel.value) {
setContents(newValue)
}
else if (!newValue) {
setContents('')
}
},
)
watch(editorModel, (newValue, oldValue) => {
if (newValue && newValue !== oldValue)
model.value = newValue
else if (!newValue)
editorInst!.setContents([])
})
watch(
() => disabled,
(newValue, _oldValue) => {
editorInst!.enable(!newValue)
},
)
onBeforeUnmount(() => editorInst = null)
</script>
<template>
<div ref="editorRef" />
<div class="tinymce-boxz">
<Editor
v-model="data"
api-key="no-api"
:init="initConfig"
/>
</div>
</template>
<style>
.tinymce-boxz > textarea {
display: none;
}
/* 隐藏apikey没有绑定这个域名的提示 */
.tox-notifications-container .tox-notification--warning {
display: none !important;
}
.tox.tox-tinymce {
max-width: 100%;
}
</style>

View File

@ -1,27 +0,0 @@
<script setup lang="ts">
import type { CascaderOption } from 'naive-ui'
defineOptions({
name: 'PcaCascader',
})
// https://github.com/modood/Administrative-divisions-of-China
const pcaCode = shallowRef<CascaderOption[]>()
async function fetchPcaCode() {
return await fetch('https://cdn.jsdelivr.net/gh/modood/Administrative-divisions-of-China/dist/pca-code.json').then(res => res.json())
}
onMounted(async () => {
pcaCode.value = await fetchPcaCode()
})
</script>
<template>
<n-cascader
:options="pcaCode"
value-field="code"
label-field="name"
check-strategy="all"
filterable
clearable
/>
</template>

View File

@ -1,3 +1,7 @@
// export const genderLabels: Record<NonNullable<CommonList.GenderType>, string> = {
// 0: '女',
// 1: '男',
// }
/** Gender */
export enum Gender {
male,

View File

@ -1,5 +1,4 @@
import type { App, Directive } from 'vue'
import { $t } from '@/utils'
interface CopyHTMLElement extends HTMLElement {
_copyText: string
@ -11,12 +10,12 @@ export function install(app: App) {
function clipboardEnable() {
if (!isSupported.value) {
window.$message.error($t('components.copyText.unsupportedError'))
window.$message.error('Your browser does not support Clipboard API')
return false
}
if (permissionWrite.value === 'denied') {
window.$message.error($t('components.copyText.unpermittedError'))
if (permissionWrite.value !== 'granted') {
window.$message.error('Currently not permitted to use Clipboard API')
return false
}
return true
@ -26,7 +25,7 @@ export function install(app: App) {
if (!clipboardEnable())
return
copy(this._copyText)
window.$message.success($t('components.copyText.message'))
window.$message.success('复制成功')
}
function updataClipboard(el: CopyHTMLElement, text: string) {

View File

@ -4,7 +4,7 @@ import { usePermission } from '@/hooks'
export function install(app: App) {
const { hasPermission } = usePermission()
function updatapermission(el: HTMLElement, permission: Entity.RoleType | Entity.RoleType[]) {
function updatapermission(el: HTMLElement, permission: Auth.RoleType | Auth.RoleType[]) {
if (!permission)
throw new Error('v-permissson Directive with no explicit role attached')
@ -12,7 +12,7 @@ export function install(app: App) {
el.parentElement?.removeChild(el)
}
const permissionDirective: Directive<HTMLElement, Entity.RoleType | Entity.RoleType[]> = {
const permissionDirective: Directive<HTMLElement, Auth.RoleType | Auth.RoleType[]> = {
mounted(el, binding) {
updatapermission(el, binding.value)
},

View File

@ -1,3 +1,4 @@
export * from './useBoolean'
export * from './useLoading'
export * from './useEcharts'
export * from './usePermission'

View File

@ -1,3 +1,6 @@
import * as echarts from 'echarts/core'
import { BarChart, LineChart, PieChart, RadarChart } from 'echarts/charts'
// 系列类型的定义后缀都为 SeriesOption
import type {
BarSeriesOption,
@ -5,6 +8,7 @@ import type {
PieSeriesOption,
RadarSeriesOption,
} from 'echarts/charts'
// 组件类型的定义后缀都为 ComponentOption
import type {
DatasetComponentOption,
@ -14,9 +18,6 @@ import type {
ToolboxComponentOption,
TooltipComponentOption,
} from 'echarts/components'
import { useAppStore } from '@/store'
import { BarChart, LineChart, PieChart, RadarChart } from 'echarts/charts'
import {
DatasetComponent, // 数据集组件
GridComponent,
@ -26,11 +27,10 @@ import {
TooltipComponent,
TransformComponent, // 内置数据转换器组件 (filter, sort)
} from 'echarts/components'
import * as echarts from 'echarts/core'
import { LabelLayout, UniversalTransition } from 'echarts/features'
import { CanvasRenderer } from 'echarts/renderers'
import { useTemplateRef } from 'vue'
import { useAppStore } from '@/store'
// 通过 ComposeOption 来组合出一个只有必须组件和图表的 Option 类型
export type ECOption = echarts.ComposeOption<
@ -66,61 +66,73 @@ echarts.use([
/**
* Echarts hooks函数
* @param options -
* @description
*/
export function useEcharts(ref: string, chartOptions: Ref<ECOption>) {
const el = useTemplateRef<HTMLLIElement>(ref)
export function useEcharts(options: Ref<ECOption>) {
const appStore = useAppStore()
const domRef = ref<HTMLElement>()
let chart: echarts.ECharts | null = null
const { width, height } = useElementSize(el)
const initialSize = { width: 0, height: 0 }
const { width, height } = useElementSize(domRef, initialSize)
const isRendered = () => Boolean(el && chart)
function canRender() {
return initialSize.width > 0 && initialSize.height > 0
}
function isRendered() {
return Boolean(domRef.value && chart)
}
async function render() {
// 宽或高不存在时不渲染
if (!width || !height)
return
const chartTheme = appStore.colorMode ? 'dark' : 'light'
await nextTick()
if (el) {
chart = echarts.init(el.value, chartTheme)
update(chartOptions.value)
if (domRef.value) {
chart = echarts.init(domRef.value, chartTheme)
update(options.value)
}
}
async function update(updateOptions: ECOption) {
if (isRendered()) {
chart!.setOption({ backgroundColor: 'transparent', ...updateOptions })
}
function update(updateOptions: ECOption) {
if (isRendered())
chart!.setOption({ ...updateOptions, backgroundColor: 'transparent' })
}
function resize() {
chart?.resize()
}
function destroy() {
chart?.dispose()
chart = null
}
watch([width, height], async ([newWidth, newHeight]) => {
if (isRendered() && newWidth && newHeight)
chart?.resize()
const sizeWatch = watch([width, height], async ([newWidth, newHeight]) => {
initialSize.width = newWidth
initialSize.height = newHeight
if (newWidth === 0 && newHeight === 0) {
// 节点被删除 将chart置为空
chart = null
}
if (!canRender())
return
if (isRendered())
resize()
else await render()
})
watch(chartOptions, (newValue) => {
const OptionWatch = watch(options, (newValue) => {
update(newValue)
})
onMounted(() => {
render()
})
onUnmounted(() => {
sizeWatch()
OptionWatch()
destroy()
})
return {
destroy,
update,
domRef,
}
}

15
src/hooks/useLoading.ts Normal file
View File

@ -0,0 +1,15 @@
import { useBoolean } from './useBoolean'
export function useLoading(initValue = false) {
const {
bool: loading,
setTrue: startLoading,
setFalse: endLoading,
} = useBoolean(initValue)
return {
loading,
startLoading,
endLoading,
}
}

View File

@ -1,12 +1,12 @@
import { useAuthStore } from '@/store'
import { isArray, isString } from 'radash'
import { useAuthStore } from '@/store'
/** 权限判断 */
export function usePermission() {
const authStore = useAuthStore()
function hasPermission(
permission?: Entity.RoleType | Entity.RoleType[],
permission: Auth.RoleType | Auth.RoleType[] | undefined,
) {
if (!permission)
return true
@ -15,16 +15,13 @@ export function usePermission() {
return false
const { role } = authStore.userInfo
// 角色为super可直接通过
let has = role.includes('super')
let has = role === 'super'
if (!has) {
if (isArray(permission))
// 角色为数组, 判断是否有交集
has = permission.some(i => role.includes(i))
has = permission.includes(role)
if (isString(permission))
// 角色为字符串, 判断是否包含
has = role.includes(permission)
has = permission === role
}
return has
}

View File

@ -1,65 +0,0 @@
import type { NScrollbar } from 'naive-ui'
import { ref, type Ref, watchEffect } from 'vue'
import { throttle } from 'radash'
export function useTabScroll(currentTabPath: Ref<string>) {
const scrollbar = ref<InstanceType<typeof NScrollbar>>()
const safeArea = ref(150)
const handleTabSwitch = (distance: number) => {
scrollbar.value?.scrollTo({
left: distance,
behavior: 'smooth',
})
}
const scrollToCurrentTab = () => {
nextTick(() => {
const currentTabElement = document.querySelector(`[data-tab-path="${currentTabPath.value}"]`) as HTMLElement
const tabBarScrollWrapper = document.querySelector('.tab-bar-scroller-wrapper .n-scrollbar-container')
const tabBarScrollContent = document.querySelector('.tab-bar-scroller-content')
if (currentTabElement && tabBarScrollContent && tabBarScrollWrapper) {
const tabLeft = currentTabElement.offsetLeft
const tabBarLeft = tabBarScrollWrapper.scrollLeft
const wrapperWidth = tabBarScrollWrapper.getBoundingClientRect().width
const tabWidth = currentTabElement.getBoundingClientRect().width
const containerPR = Number.parseFloat(window.getComputedStyle(tabBarScrollContent).paddingRight)
if (tabLeft + tabWidth + safeArea.value + containerPR > wrapperWidth + tabBarLeft) {
handleTabSwitch(tabLeft + tabWidth + containerPR - wrapperWidth + safeArea.value)
}
else if (tabLeft - safeArea.value < tabBarLeft) {
handleTabSwitch(tabLeft - safeArea.value)
}
}
})
}
const handleScroll = throttle({ interval: 120 }, (step: number) => {
scrollbar.value?.scrollBy({
left: step * 400,
behavior: 'smooth',
})
})
const onWheel = (e: WheelEvent) => {
e.preventDefault()
if (Math.abs(e.deltaY) > Math.abs(e.deltaX)) {
handleScroll(e.deltaY > 0 ? 1 : -1)
}
}
watchEffect(() => {
if (currentTabPath.value) {
scrollToCurrentTab()
}
})
return {
scrollbar,
onWheel,
safeArea,
handleTabSwitch,
}
}

View File

@ -1,35 +0,0 @@
import type { NDataTable } from 'naive-ui'
import { useDraggable } from 'vue-draggable-plus'
export function useTableDrag<T = unknown>(params: {
tableRef: Ref<InstanceType<typeof NDataTable> | undefined>
data: Ref<T[]>
onRowDrag: (rows: T[]) => void
}) {
const tableEl = computed(() => params.tableRef?.value?.$el as HTMLElement)
const tableBodyRef = ref<HTMLElement | undefined>(undefined)
const { start } = useDraggable(tableBodyRef, params.data, {
immediate: false,
animation: 150,
handle: '.drag-handle',
onEnd: (event) => {
const { oldIndex, newIndex } = event
const start = Math.min(oldIndex!, newIndex!)
const end = Math.max(oldIndex!, newIndex!) - start + 1
const changedRows = [...params.data.value].splice(start, end)
params.onRowDrag(unref([...changedRows]))
},
})
onMounted(async () => {
while (!tableBodyRef.value) {
tableBodyRef.value = tableEl.value?.querySelector('tbody') || undefined
await new Promise(resolve => setTimeout(resolve, 100))
}
})
watchOnce(() => tableBodyRef.value, (el) => {
el && start()
})
}

View File

@ -1,26 +0,0 @@
<script setup lang="ts">
import { useAppStore, useRouteStore } from '@/store'
const appStore = useAppStore()
const routeStore = useRouteStore()
</script>
<template>
<n-el
class="h-full"
:class="[
appStore.layoutMode === 'full-content' ? 'p-0' : 'p-16px',
]"
style="background-color: var(--action-color);"
>
<router-view
v-slot="{ Component, route }"
>
<transition :name="appStore.transitionAnimation" mode="out-in">
<keep-alive :include="routeStore.cacheRoutes">
<component :is="Component" v-if="appStore.loadFlag" :key="route.fullPath" />
</keep-alive>
</transition>
</router-view>
</n-el>
</template>

View File

@ -1,120 +1,47 @@
<script setup lang="ts">
import type { ProLayoutMode } from 'pro-naive-ui'
import type { LayoutMode } from '@/store/app'
const value = defineModel<ProLayoutMode>('value', { required: true })
const value = defineModel<LayoutMode>('value', { required: true })
</script>
<template>
<div class="selector-wapper gap-4">
<n-tooltip placement="top" trigger="hover">
<div class="flex-center gap-4">
<n-tooltip placement="bottom" trigger="hover">
<template #trigger>
<n-el
:class="{
'outline outline-2': value === 'vertical',
'outline outline-2': value === 'leftMenu',
}"
class="grid grid-cols-[20%_1fr] outline-[var(--primary-color)] hover:(outline outline-2) cursor-pointer"
@click="value = 'vertical'"
class="grid grid-cols-[20%_1fr] grid-rows-[20%_1fr] outline-[var(--primary-color)] hover:(outline outline-2)"
@click="value = 'leftMenu'"
>
<div class="bg-[var(--primary-color)]" />
<div class="bg-[var(--divider-color)]" />
</n-el>
</template>
<span> {{ $t('app.verticalLayout') }} </span>
</n-tooltip>
<n-tooltip placement="top" trigger="hover">
<template #trigger>
<n-el
:class="{
'outline outline-2': value === 'horizontal',
}"
class="grid grid-rows-[30%_1fr] outline-[var(--primary-color)] hover:(outline outline-2) cursor-pointer"
@click="value = 'horizontal'"
>
<div class="bg-[var(--primary-color)]" />
<div class="bg-[var(--divider-color)]" />
</n-el>
</template>
<span> {{ $t('app.horizontalLayout') }} </span>
</n-tooltip>
<n-tooltip placement="top" trigger="hover">
<template #trigger>
<n-el
:class="{
'outline outline-2': value === 'two-column',
}"
class="grid grid-cols-[10%_15%_1fr] outline-[var(--primary-color)] hover:(outline outline-2) cursor-pointer"
@click="value = 'two-column'"
>
<div class="bg-[var(--primary-color)]" />
<div class="bg-[var(--primary-color)] row-span-2" />
<div class="bg-[var(--primary-color-suppl)]" />
<div class="bg-[var(--divider-color)]" />
</n-el>
</template>
<span> {{ $t('app.twoColumnLayout') }} </span>
<span> {{ $t('app.leftMenu') }} </span>
</n-tooltip>
<n-tooltip placement="bottom" trigger="hover">
<template #trigger>
<n-el
:class="{
'outline outline-2': value === 'mixed-two-column',
'outline outline-2': value === 'topMenu',
}"
class="grid grid-cols-[10%_15%_1fr] grid-rows-[20%_1fr] outline-[var(--primary-color)] hover:(outline outline-2) cursor-pointer"
@click="value = 'mixed-two-column'"
class="grid grid-rows-[30%_1fr] outline-[var(--primary-color)] hover:(outline outline-2)"
@click="value = 'topMenu'"
>
<div class="bg-[var(--primary-color-suppl)] row-span-2" />
<div class="bg-[var(--primary-color-suppl)] row-span-2" />
<div class="bg-[var(--primary-color)]" />
<div class="bg-[var(--divider-color)]" />
</n-el>
</template>
<span> {{ $t('app.mixedTwoColumnLayout') }} </span>
</n-tooltip>
<n-tooltip placement="bottom" trigger="hover">
<template #trigger>
<n-el
:class="{
'outline outline-2': value === 'sidebar',
}"
class="grid grid-cols-[20%_1fr] grid-rows-[20%_1fr] outline-[var(--primary-color)] hover:(outline outline-2) cursor-pointer"
@click="value = 'sidebar'"
>
<div class="bg-[var(--divider-color)] col-span-2" />
<div class="bg-[var(--primary-color)]" />
<div class="bg-[var(--divider-color)]" />
</n-el>
</template>
<span> {{ $t('app.sidebarLayout') }} </span>
</n-tooltip>
<n-tooltip placement="bottom" trigger="hover">
<template #trigger>
<n-el
:class="{
'outline outline-2': value === 'mixed-sidebar',
}"
class="grid grid-cols-[20%_1fr] grid-rows-[20%_1fr] outline-[var(--primary-color)] hover:(outline outline-2) cursor-pointer"
@click="value = 'mixed-sidebar'"
>
<div class="bg-[var(--primary-color)] col-span-2" />
<div class="bg-[var(--primary-color-suppl)]" />
<div class="bg-[var(--divider-color)]" />
</n-el>
</template>
<span> {{ $t('app.mixedSidebarLayout') }} </span>
<span> {{ $t('app.topMenu') }} </span>
</n-tooltip>
</div>
</template>
<style lang="scss" scoped>
.selector-wapper{
display: grid;
grid-template-columns: repeat(3, 1fr);
}
<style lang="scss">
.grid{
height: 60px;
width: 86px;

View File

@ -1,38 +0,0 @@
<script setup lang="ts">
import Search from '../header/Search.vue'
import Notices from '../header/Notices.vue'
import UserCenter from '../header/UserCenter.vue'
import Setting from './Setting.vue'
const showDrawer = defineModel<boolean>('show', { default: false })
</script>
<template>
<n-drawer
v-model:show="showDrawer"
:width="280"
placement="right"
:mask-closable="true"
:close-on-esc="true"
>
<n-drawer-content :native-scrollbar="false" :body-content-style="{ padding: '0' }">
<template #header>
<div class="flex items-center">
<UserCenter />
<div class="ml-auto" />
<Search />
<Notices />
</div>
</template>
<slot />
<template #footer>
<DarkModeSwitch />
<LangsSwitch />
<div class="ml-auto" />
<Setting />
</template>
</n-drawer-content>
</n-drawer>
</template>

View File

@ -1,8 +1,8 @@
<script setup lang="ts">
interface Props {
list?: Entity.Message[]
list?: Message.List[]
}
const { list } = defineProps<Props>()
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
interface Emits {
@ -13,7 +13,7 @@ interface Emits {
<template>
<n-scrollbar style="height: 400px">
<n-list hoverable clickable>
<n-list-item v-for="(item) in list" :key="item.id" @click="emit('read', item.id)">
<n-list-item v-for="(item) in props.list" :key="item.id" @click="emit('read', item.id)">
<n-thing content-indented :class="{ 'opacity-30': item.isRead }">
<template #header>
<n-ellipsis :line-clamp="1">

View File

@ -1,18 +0,0 @@
<script setup lang="ts">
import { useAppStore } from '@/store'
const appStore = useAppStore()
</script>
<template>
<n-tooltip placement="bottom" trigger="hover">
<template #trigger>
<CommonWrapper @click="appStore.showSetting = !appStore.showSetting">
<div>
<icon-park-outline-setting-two />
</div>
</CommonWrapper>
</template>
<span>{{ $t('app.setting') }}</span>
</n-tooltip>
</template>

View File

@ -1,143 +0,0 @@
<script setup lang="ts">
import { useAppStore } from '@/store'
import LayoutSelector from './LayoutSelector.vue'
const appStore = useAppStore()
const { t } = useI18n()
const transitionSelectorOptions = computed(() => {
return [
{
label: t('app.transitionNull'),
value: '',
},
{
label: t('app.transitionFadeSlide'),
value: 'fade-slide',
},
{
label: t('app.transitionFadeBottom'),
value: 'fade-bottom',
},
{
label: t('app.transitionFadeScale'),
value: 'fade-scale',
},
{
label: t('app.transitionZoomFade'),
value: 'zoom-fade',
},
{
label: t('app.transitionZoomOut'),
value: 'zoom-out',
},
{
label: t('app.transitionSoft'),
value: 'fade',
},
]
})
const palette = [
'#ffb8b8',
'#d03050',
'#F0A020',
'#fff200',
'#ffda79',
'#18A058',
'#006266',
'#22a6b3',
'#18dcff',
'#2080F0',
'#c56cf0',
'#be2edd',
'#706fd3',
'#4834d4',
'#130f40',
'#4b4b4b',
]
function resetSetting() {
window.$dialog.warning({
title: t('app.resetSettingTitle'),
content: t('app.resetSettingContent'),
positiveText: t('common.confirm'),
negativeText: t('common.cancel'),
onPositiveClick: () => {
appStore.resetAlltheme()
window.$message.success(t('app.resetSettingMeaasge'))
},
})
}
</script>
<template>
<n-drawer v-model:show="appStore.showSetting" :width="360">
<n-drawer-content :title="t('app.systemSetting')" closable>
<n-space vertical>
<n-divider>{{ $t('app.layoutSetting') }}</n-divider>
<LayoutSelector v-model:value="appStore.layoutMode" />
<n-divider>{{ $t('app.themeSetting') }}</n-divider>
<n-space justify="space-between">
{{ $t('app.colorWeak') }}
<n-switch :value="appStore.colorWeak" @update:value="appStore.toggleColorWeak" />
</n-space>
<n-space justify="space-between">
{{ $t('app.blackAndWhite') }}
<n-switch :value="appStore.grayMode" @update:value="appStore.toggleGrayMode" />
</n-space>
<n-space align="center" justify="space-between">
{{ $t('app.themeColor') }}
<n-color-picker
v-model:value="appStore.primaryColor" class="w-10em" :swatches="palette"
@update:value="appStore.setPrimaryColor"
/>
</n-space>
<n-space align="center" justify="space-between">
{{ $t('app.pageTransition') }}
<n-select
v-model:value="appStore.transitionAnimation" class="w-10em"
:options="transitionSelectorOptions" @update:value="appStore.reloadPage"
/>
</n-space>
<n-divider>{{ $t('app.interfaceDisplay') }}</n-divider>
<n-space justify="space-between">
{{ $t('app.logoDisplay') }}
<n-switch v-model:value="appStore.showLogo" />
</n-space>
<n-space justify="space-between">
{{ $t('app.topProgress') }}
<n-switch v-model:value="appStore.showProgress" />
</n-space>
<n-space justify="space-between">
{{ $t('app.multitab') }}
<n-switch v-model:value="appStore.showTabs" />
</n-space>
<n-space justify="space-between">
{{ $t('app.bottomCopyright') }}
<n-switch v-model:value="appStore.showFooter" />
</n-space>
<n-space justify="space-between">
{{ $t('app.breadcrumb') }}
<n-switch v-model:value="appStore.showBreadcrumb" />
</n-space>
<n-space justify="space-between">
{{ $t('app.BreadcrumbIcon') }}
<n-switch v-model:value="appStore.showBreadcrumbIcon" />
</n-space>
<n-space justify="space-between">
{{ $t('app.watermake') }}
<n-switch v-model:value="appStore.showWatermark" />
</n-space>
</n-space>
<template #footer>
<n-button type="error" @click="resetSetting">
{{ $t('app.reset') }}
</n-button>
</template>
</n-drawer-content>
</n-drawer>
</template>

View File

@ -2,26 +2,18 @@
import { useAppStore } from '@/store'
const appStore = useAppStore()
useMagicKeys({
passive: false,
onEventFired(e) {
if (e.key === 'F11' && e.type === 'keydown') {
e.preventDefault()
appStore.toggleFullScreen()
}
},
})
</script>
<template>
<n-tooltip placement="bottom" trigger="hover">
<template #trigger>
<CommonWrapper @click="appStore.toggleFullScreen">
<icon-park-outline-off-screen v-if="appStore.fullScreen" />
<icon-park-outline-full-screen v-else />
<CommonWrapper @click="appStore.toggleFullScreen()">
<icon-park-outline-off-screen-two v-if="appStore.fullScreen" />
<icon-park-outline-full-screen-two v-else />
</CommonWrapper>
</template>
<span>{{ $t('app.toggleFullScreen') }}</span>
</n-tooltip>
</template>
<style scoped></style>

View File

@ -2,7 +2,7 @@
import { group } from 'radash'
import NoticeList from '../common/NoticeList.vue'
const MassageData = ref<Entity.Message[]>([
const MassageData = ref<Message.List[]>([
{
id: 0,
type: 0,

View File

@ -1,219 +1,59 @@
<script setup lang="ts">
import { useBoolean } from '@/hooks'
import { useAppStore, useRouteStore } from '@/store'
import { NFlex, NTag, NText } from 'naive-ui'
import { useRouteStore } from '@/store'
import { renderIcon } from '@/utils'
const appStore = useAppStore()
const routeStore = useRouteStore()
//
const searchValue = ref('')
//
const selectedIndex = ref<number>(0)
const { bool: showModal, setTrue: openModal, setFalse: closeModal, toggle: toggleModal } = useBoolean(false)
//
const { bool: keyboardFlag, setTrue: setKeyboardTrue, setFalse: setKeyboardFalse } = useBoolean(false)
const { ctrl_k, arrowup, arrowdown, enter/* keys you want to monitor */ } = useMagicKeys({
passive: false,
onEventFired(e) {
if (e.ctrlKey && e.key === 'k' && e.type === 'keydown')
e.preventDefault()
},
})
//
watchEffect(() => {
if (ctrl_k.value)
toggleModal()
})
const { t } = useI18n()
//
const options = computed(() => {
if (!searchValue.value)
return []
return routeStore.rowRoutes.filter((item) => {
const conditions = [
t(`route.${String(item.name)}`, item.title || item.name)?.includes(searchValue.value),
t(`route.${String(item.name)}`, item['meta.title'] || item.name)?.includes(searchValue.value),
item.path?.includes(searchValue.value),
]
return conditions.some(condition => !item.hide && condition)
return conditions.some(condition => condition)
}).map((item) => {
return {
label: t(`route.${String(item.name)}`, item.title || item.name),
label: t(`route.${String(item.name)}`, item['meta.title'] || item.name),
value: item.path,
icon: item.icon,
icon: item['meta.icon'],
}
})
})
function renderLabel(option: any) {
return h(NFlex, {}, {
default: () => [
h(NTag, { size: 'small', type: 'primary', bordered: false }, { icon: renderIcon(option.icon), default: () => option.label }),
h(NText, { depth: 3 }, { default: () => option.value }),
],
})
}
const router = useRouter()
//
function handleClose() {
searchValue.value = ''
selectedIndex.value = 0
closeModal()
}
//
function handleInputChange() {
selectedIndex.value = 0
}
//
function handleSelect(value: string) {
handleClose()
router.push(value)
nextTick(() => {
searchValue.value = ''
})
}
watchEffect(() => {
//
if (!showModal.value || !options.value.length)
return
// mouseover
setKeyboardTrue()
if (arrowup.value)
handleArrowup()
if (arrowdown.value)
handleArrowdown()
if (enter.value)
handleEnter()
})
const scrollbarRef = ref()
//
function handleArrowup() {
if (selectedIndex.value === 0)
selectedIndex.value = options.value.length - 1
else
selectedIndex.value--
handleScroll(selectedIndex.value)
}
//
function handleArrowdown() {
if (selectedIndex.value === options.value.length - 1)
selectedIndex.value = 0
else
selectedIndex.value++
handleScroll(selectedIndex.value)
}
function handleScroll(currentIndex: number) {
// 6,6
const keepIndex = 5
// gappadding
const elHeight = 70
const distance = currentIndex * elHeight > keepIndex * elHeight ? currentIndex * elHeight - keepIndex * elHeight : 0
scrollbarRef.value?.scrollTo({
top: distance,
})
}
//
function handleEnter() {
const target = options.value[selectedIndex.value]
if (target)
handleSelect(target.value)
}
//
function handleMouseEnter(index: number) {
if (keyboardFlag.value)
return
selectedIndex.value = index
}
</script>
<template>
<CommonWrapper @click="openModal">
<icon-park-outline-search />
<n-tag v-if="!appStore.isMobile" round size="small" class="font-mono cursor-pointer">
CtrlK
</n-tag>
</CommonWrapper>
<n-modal
v-model:show="showModal"
class="w-560px fixed top-60px inset-x-0 max-w-full"
size="small"
preset="card"
:segmented="{
content: true,
footer: true,
}"
:closable="false"
@after-leave="handleClose"
<n-auto-complete
v-model:value="searchValue" class="w-20em m-r-1em" :input-props="{
autocomplete: 'disabled',
}" :options="options" :render-label="renderLabel" :placeholder="$t('app.searchPlaceholder')" clearable @select="handleSelect"
>
<template #header>
<n-input v-model:value="searchValue" :placeholder="$t('app.searchPlaceholder')" clearable size="large" @input="handleInputChange">
<template #prefix>
<n-icon>
<icon-park-outline-search />
</n-icon>
</template>
</n-input>
<template #prefix>
<n-icon>
<icon-park-outline-search />
</n-icon>
</template>
<n-scrollbar ref="scrollbarRef" class="h-450px">
<ul
v-if="options.length"
class="flex flex-col gap-8px p-8px p-r-3"
>
<n-el
v-for="(option, index) in options"
:key="option.value" tag="li" role="option"
class="cursor-pointer shadow h-62px"
:class="{ 'text-[var(--base-color)] bg-[var(--primary-color-hover)]': index === selectedIndex }"
@click="handleSelect(option.value)"
@mouseenter="handleMouseEnter(index)"
@mousemove="setKeyboardFalse"
>
<div class="grid grid-rows-2 grid-cols-[40px_1fr_30px] h-full p-2">
<div class="row-span-2 place-self-center">
<nova-icon :icon="option.icon" />
</div>
<span>{{ option.label }}</span>
<icon-park-outline-right class="row-span-2 place-self-center" />
<span class="op-70">{{ option.value }}</span>
</div>
</n-el>
</ul>
<n-empty v-else size="large" class="h-450px flex-center" />
</n-scrollbar>
<template #footer>
<n-flex>
<div class="flex-y-center gap-1">
<svg width="15" height="15" aria-label="Enter key" role="img"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2"><path d="M12 3.53088v3c0 1-1 2-2 2H4M7 11.53088l-3-3 3-3" /></g></svg>
<span>{{ $t('common.choose') }}</span>
</div>
<div class="flex-y-center gap-1">
<svg width="15" height="15" aria-label="Arrow down" role="img"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2"><path d="M7.5 3.5v8M10.5 8.5l-3 3-3-3" /></g></svg>
<svg width="15" height="15" aria-label="Arrow up" role="img"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2"><path d="M7.5 11.5v-8M10.5 6.5l-3-3-3 3" /></g></svg>
<span>{{ $t('common.navigate') }}</span>
</div>
<div class="flex-y-center gap-1">
<svg width="15" height="15" aria-label="Escape key" role="img"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2"><path d="M13.6167 8.936c-.1065.3583-.6883.962-1.4875.962-.7993 0-1.653-.9165-1.653-2.1258v-.5678c0-1.2548.7896-2.1016 1.653-2.1016.8634 0 1.3601.4778 1.4875 1.0724M9 6c-.1352-.4735-.7506-.9219-1.46-.8972-.7092.0246-1.344.57-1.344 1.2166s.4198.8812 1.3445.9805C8.465 7.3992 8.968 7.9337 9 8.5c.032.5663-.454 1.398-1.4595 1.398C6.6593 9.898 6 9 5.963 8.4851m-1.4748.5368c-.2635.5941-.8099.876-1.5443.876s-1.7073-.6248-1.7073-2.204v-.4603c0-1.0416.721-2.131 1.7073-2.131.9864 0 1.6425 1.031 1.5443 2.2492h-2.956" /></g></svg>
<span>{{ $t('common.close') }}</span>
</div>
</n-flex>
</template>
</n-modal>
</n-auto-complete>
</template>
<style scoped></style>

View File

@ -0,0 +1,158 @@
<script setup lang="ts">
import LayoutSelector from '../common/LayoutSelector.vue'
import { useAppStore } from '@/store'
const appStore = useAppStore()
const { t } = useI18n()
const drawerActive = ref(false)
function openSetting() {
drawerActive.value = !drawerActive.value
}
const transitionSelectorOptions = computed(() => {
return [
{
label: t('app.transitionNull'),
value: '',
},
{
label: t('app.transitionFadeSlide'),
value: 'fade-slide',
},
{
label: t('app.transitionFadeBottom'),
value: 'fade-bottom',
},
{
label: t('app.transitionFadeScale'),
value: 'fade-scale',
},
{
label: t('app.transitionZoomFade'),
value: 'zoom-fade',
},
{
label: t('app.transitionZoomOut'),
value: 'zoom-out',
},
{
label: t('app.transitionSoft'),
value: 'fade',
},
]
})
const palette = [
'#ffb8b8',
'#d03050',
'#F0A020',
'#fff200',
'#ffda79',
'#18A058',
'#006266',
'#22a6b3',
'#18dcff',
'#2080F0',
'#c56cf0',
'#be2edd',
'#706fd3',
'#4834d4',
'#130f40',
'#4b4b4b',
]
function resetSetting() {
window.$dialog.warning({
title: t('app.resetSettingTitle'),
content: t('app.resetSettingContent'),
positiveText: t('common.confirm'),
negativeText: t('common.cancel'),
onPositiveClick: () => {
appStore.resetAlltheme()
window.$message.success(t('app.resetSettingMeaasge'))
},
})
}
</script>
<template>
<n-tooltip placement="bottom" trigger="hover">
<template #trigger>
<CommonWrapper @click="openSetting">
<div>
<icon-park-outline-setting-two />
<n-drawer v-model:show="drawerActive" :width="360">
<n-drawer-content :title="t('app.systemSetting')" closable>
<n-space vertical>
<n-divider>{{ $t('app.layoutSetting') }}</n-divider>
<LayoutSelector v-model:value="appStore.layoutMode" />
<n-divider>{{ $t('app.themeSetting') }}</n-divider>
<n-space justify="space-between">
{{ $t('app.colorWeak') }}
<n-switch :value="appStore.colorWeak" @update:value="appStore.toggleColorWeak" />
</n-space>
<n-space justify="space-between">
{{ $t('app.blackAndWhite') }}
<n-switch :value="appStore.grayMode" @update:value="appStore.toggleGrayMode" />
</n-space>
<n-space align="center" justify="space-between">
{{ $t('app.themeColor') }}
<n-color-picker
v-model:value="appStore.primaryColor" class="w-10em" :swatches="palette"
@update:value="appStore.setPrimaryColor"
/>
</n-space>
<n-space align="center" justify="space-between">
{{ $t('app.pageTransition') }}
<n-select
v-model:value="appStore.transitionAnimation" class="w-10em"
:options="transitionSelectorOptions" @update:value="appStore.reloadPage"
/>
</n-space>
<n-divider>{{ $t('app.interfaceDisplay') }}</n-divider>
<n-space justify="space-between">
{{ $t('app.logoDisplay') }}
<n-switch v-model:value="appStore.showLogo" />
</n-space>
<n-space justify="space-between">
{{ $t('app.topProgress') }}
<n-switch v-model:value="appStore.showProgress" />
</n-space>
<n-space justify="space-between">
{{ $t('app.multitab') }}
<n-switch v-model:value="appStore.showTabs" />
</n-space>
<n-space justify="space-between">
{{ $t('app.bottomCopyright') }}
<n-switch v-model:value="appStore.showFooter" />
</n-space>
<n-space justify="space-between">
{{ $t('app.breadcrumb') }}
<n-switch v-model:value="appStore.showBreadcrumb" />
</n-space>
<n-space justify="space-between">
{{ $t('app.BreadcrumbIcon') }}
<n-switch v-model:value="appStore.showBreadcrumbIcon" />
</n-space>
<n-space justify="space-between">
{{ $t('app.watermake') }}
<n-switch v-model:value="appStore.showWatermark" />
</n-space>
</n-space>
<template #footer>
<n-button type="error" @click="resetSetting">
{{ $t('app.reset') }}
</n-button>
</template>
</n-drawer-content>
</n-drawer>
</div>
</CommonWrapper>
</template>
<span>{{ $t('app.setting') }}</span>
</n-tooltip>
</template>

View File

@ -1,14 +1,14 @@
<script setup lang="ts">
import { useAuthStore } from '@/store'
import { renderIcon } from '@/utils/icon'
import IconBookOpen from '~icons/icon-park-outline/book-open'
import { useAuthStore } from '@/store'
import IconGithub from '~icons/icon-park-outline/github'
import IconLogout from '~icons/icon-park-outline/logout'
import IconUser from '~icons/icon-park-outline/user'
import IconLogout from '~icons/icon-park-outline/logout'
import IconBookOpen from '~icons/icon-park-outline/book-open'
const { t } = useI18n()
const { userInfo, logout } = useAuthStore()
const { userInfo, resetAuthStore } = useAuthStore()
const router = useRouter()
const options = computed(() => {
@ -56,12 +56,12 @@ function handleSelect(key: string | number) {
positiveText: t('common.confirm'),
negativeText: t('common.cancel'),
onPositiveClick: () => {
logout()
resetAuthStore()
},
})
}
if (key === 'userCenter')
router.push('/user-center')
router.push('/userCenter')
if (key === 'guthub')
window.open('https://github.com/chansee97/nova-admin')
@ -70,7 +70,7 @@ function handleSelect(key: string | number) {
window.open('https://gitee.com/chansee97/nova-admin')
if (key === 'docs')
window.open('https://nova-admin-docs.pages.dev/')
window.open('https://nova-admin-docs.netlify.app/')
}
</script>
@ -82,7 +82,7 @@ function handleSelect(key: string | number) {
>
<n-avatar
round
class="cursor-pointer"
:src="userInfo?.avatar"
>
<template #fallback>

View File

@ -1,29 +1,33 @@
import BackTop from './common/BackTop.vue'
import Setting from './common/Setting.vue'
import SettingDrawer from './common/SettingDrawer.vue'
import Logo from './common/Logo.vue'
import MobileDrawer from './common/MobileDrawer.vue'
/* 侧边栏组件 */
import Logo from './sider/Logo.vue'
import Menu from './sider/Menu.vue'
/* 头部栏组件 */
import Breadcrumb from './header/Breadcrumb.vue'
import CollapaseButton from './header/CollapaseButton.vue'
import FullScreen from './header/FullScreen.vue'
import Setting from './header/Setting.vue'
import Notices from './header/Notices.vue'
import Search from './header/Search.vue'
import UserCenter from './header/UserCenter.vue'
import Search from './header/Search.vue'
/* 标签栏组件 */
import TabBar from './tab/TabBar.vue'
/* 其他组件 */
// 返回顶部
import BackTop from './common/BackTop.vue'
export {
BackTop,
Breadcrumb,
CollapaseButton,
FullScreen,
Menu,
Logo,
MobileDrawer,
Notices,
Search,
FullScreen,
Setting,
SettingDrawer,
TabBar,
Notices,
UserCenter,
Search,
TabBar,
BackTop,
}

View File

@ -5,16 +5,6 @@ const router = useRouter()
const appStore = useAppStore()
const name = import.meta.env.VITE_APP_NAME
const hidenLogoText = computed(() => {
if (['sidebar', 'mixed-sidebar', 'horizontal'].includes(appStore.layoutMode)) {
return false
}
if (['two-column', 'mixed-two-column'].includes(appStore.layoutMode)) {
return true
}
return appStore.collapsed
})
</script>
<template>
@ -24,7 +14,7 @@ const hidenLogoText = computed(() => {
>
<svg-icons-logo class="text-1.5em" />
<span
v-show="!hidenLogoText"
v-show="!appStore.collapsed"
class="text-ellipsis overflow-hidden whitespace-nowrap"
>{{ name }}</span>
</div>

View File

@ -0,0 +1,19 @@
<script setup lang="ts">
import { useAppStore } from '@/store'
import { useRouteStore } from '@/store/route'
const appStore = useAppStore()
const routesStore = useRouteStore()
</script>
<template>
<n-menu
:collapsed="appStore.collapsed"
:indent="20"
:collapsed-width="64"
:options="routesStore.menus"
:value="routesStore.activeMenu"
/>
</template>
<style scoped></style>

View File

@ -1,50 +0,0 @@
<script setup lang="ts">
import { useAppStore } from '@/store'
const appStore = useAppStore()
let previousLayoutMode = appStore.layoutMode
function enterFullContent() {
previousLayoutMode = appStore.layoutMode
appStore.layoutMode = 'full-content'
}
function exitFullContent() {
// vertical
if (previousLayoutMode === 'full-content' || !previousLayoutMode) {
previousLayoutMode = 'vertical'
}
appStore.layoutMode = previousLayoutMode
}
</script>
<template>
<n-tooltip v-if="!appStore.isMobile" placement="bottom" trigger="hover">
<template #trigger>
<CommonWrapper @click="enterFullContent">
<icon-park-outline-full-screen-one />
</CommonWrapper>
</template>
{{ $t('app.togglContentFullScreen') }}
</n-tooltip>
<Teleport to="body">
<div
v-if="appStore.layoutMode === 'full-content'"
class="fixed top-4 right-0 z-[9999]"
>
<n-tooltip placement="left" trigger="hover">
<template #trigger>
<n-el
class="bg-[var(--primary-color)] c-[var(--base-color)] rounded-l-lg shadow-lg p-2 cursor-pointer"
@click="exitFullContent"
>
<icon-park-outline-off-screen-one />
</n-el>
</template>
{{ $t('app.togglContentFullScreen') }}
</n-tooltip>
</div>
</Teleport>
</template>

View File

@ -9,7 +9,6 @@ const { t } = useI18n()
function renderDropTabsLabel(option: any) {
return t(`route.${String(option.name)}`, option.meta.title)
}
function renderDropTabsIcon(option: any) {
return renderIcon(option.meta.icon)!()
}
@ -27,7 +26,6 @@ function handleDropTabs(key: string, option: any) {
:render-icon="renderDropTabsIcon"
trigger="click"
size="small"
key-field="fullPath"
@select="handleDropTabs"
>
<CommonWrapper>

View File

@ -1,28 +1,24 @@
<script setup lang="ts">
import type { RouteLocationNormalized } from 'vue-router'
import Reload from './Reload.vue'
import DropTabs from './DropTabs.vue'
import { useAppStore, useTabStore } from '@/store'
import { useTabScroll } from '@/hooks/useTabScroll'
import { useDraggable } from 'vue-draggable-plus'
import IconRedo from '~icons/icon-park-outline/redo'
import IconClose from '~icons/icon-park-outline/close'
import IconDelete from '~icons/icon-park-outline/delete-four'
import IconFullwith from '~icons/icon-park-outline/fullwidth'
import IconRedo from '~icons/icon-park-outline/redo'
import IconLeft from '~icons/icon-park-outline/to-left'
import IconRight from '~icons/icon-park-outline/to-right'
import ContentFullScreen from './ContentFullScreen.vue'
import DropTabs from './DropTabs.vue'
import Reload from './Reload.vue'
import TabBarItem from './TabBarItem.vue'
import IconFullwith from '~icons/icon-park-outline/fullwidth'
const tabStore = useTabStore()
const { tabs } = storeToRefs(useTabStore())
const appStore = useAppStore()
const { scrollbar, onWheel } = useTabScroll(computed(() => tabStore.currentTabPath))
const router = useRouter()
function handleTab(route: RouteLocationNormalized) {
router.push(route.fullPath)
router.push(route.path)
}
function handleClose(path: string) {
tabStore.closeTab(path)
}
const { t } = useI18n()
const options = computed(() => {
@ -74,16 +70,16 @@ function handleSelect(key: string) {
appStore.reloadPage()
},
closeCurrent() {
tabStore.closeTab(currentRoute.value.fullPath)
tabStore.closeTab(currentRoute.value.path)
},
closeOther() {
tabStore.closeOtherTabs(currentRoute.value.fullPath)
tabStore.closeOtherTabs(currentRoute.value.path)
},
closeLeft() {
tabStore.closeLeftTabs(currentRoute.value.fullPath)
tabStore.closeLeftTabs(currentRoute.value.path)
},
closeRight() {
tabStore.closeRightTabs(currentRoute.value.fullPath)
tabStore.closeRightTabs(currentRoute.value.path)
},
closeAll() {
tabStore.closeAllTabs()
@ -104,53 +100,55 @@ function handleContextMenu(e: MouseEvent, route: RouteLocationNormalized) {
function onClickoutside() {
showDropdown.value = false
}
const el = ref()
useDraggable(el, tabs, {
animation: 150,
ghostClass: 'ghost',
})
</script>
<template>
<n-scrollbar ref="scrollbar" class="relative flex h-full tab-bar-scroller-wrapper" content-class="h-full pr-34 tab-bar-scroller-content" :x-scrollable="true" @wheel="onWheel">
<div class="p-l-2 flex wh-full relative">
<div class="flex items-end">
<TabBarItem
v-for="item in tabStore.pinTabs" :key="item.fullPath" :value="tabStore.currentTabPath" :route="item"
@click="handleTab(item)"
/>
</div>
<div ref="el" class="flex items-end flex-1">
<TabBarItem
v-for="item in tabStore.tabs"
:key="item.fullPath"
:value="tabStore.currentTabPath"
:route="item"
closable
:data-tab-path="item.fullPath"
@close="tabStore.closeTab"
@click="handleTab(item)"
@contextmenu="handleContextMenu($event, item)"
/>
<n-dropdown
placement="bottom-start" trigger="manual" :x="x" :y="y" :options="options" :show="showDropdown"
:on-clickoutside="onClickoutside" @select="handleSelect"
/>
</div>
</div>
<n-el class="absolute right-0 top-0 flex items-center gap-1 bg-[var(--card-color)] h-full">
<Reload />
<ContentFullScreen />
<DropTabs />
</n-el>
</n-scrollbar>
<div class="wh-full flex items-end">
<n-tabs
type="card"
size="small"
:tabs-padding="15"
:value="tabStore.currentTabPath"
@close="handleClose"
>
<n-tab
v-for="item in tabStore.pinTabs"
:key="item.path"
:name="item.path"
@click="router.push(item.path)"
>
<div class="flex-x-center gap-2">
<nova-icon :icon="item.meta.icon" /> {{ $t(`route.${String(item.name)}`, item.meta.title) }}
</div>
</n-tab>
<n-tab
v-for="item in tabStore.tabs"
:key="item.path"
closable
:name="item.path as string"
@click="handleTab(item)"
@contextmenu="handleContextMenu($event, item)"
>
<div class="flex-x-center gap-2">
<nova-icon :icon="item.meta.icon" /> {{ $t(`route.${String(item.name)}`, item.meta.title) }}
</div>
</n-tab>
<template #suffix>
<Reload />
<DropTabs />
</template>
</n-tabs>
<n-dropdown
placement="bottom-start"
trigger="manual"
:x="x"
:y="y"
:options="options"
:show="showDropdown"
:on-clickoutside="onClickoutside"
@select="handleSelect"
/>
</div>
</template>
<style scoped>
.ghost {
opacity: 0.5;
background: #c4f6d5;
}
</style>
<style scoped></style>./DropTabs.vue

View File

@ -1,41 +0,0 @@
<script setup lang="ts">
import type { RouteLocationNormalized } from 'vue-router'
const { route, value, closable = false } = defineProps<{
route: RouteLocationNormalized
value: string
closable?: boolean
}>()
const emit = defineEmits<{
close: [string]
}>()
</script>
<template>
<n-el
class="cursor-pointer p-x-4 p-y-2 m-x-2px b b-[--divider-color] b-b-[#0000] rounded-[--border-radius]"
:class="[
value === route.fullPath ? 'c-[--primary-color]' : 'c-[--text-color-2]',
value === route.fullPath ? 'bg-[#0000]' : 'bg-[--tab-color]',
closable && 'p-r-2',
]"
style="transition: box-shadow .3s var(--n-bezier), color .3s var(--n-bezier), background-color .3s var(--n-bezier), border-color .3s var(--n-bezier);"
>
<div class="flex-center gap-2 text-nowrap">
<nova-icon :icon="route.meta.icon" />
<span>{{ $t(`route.${String(route.name)}`, route.meta.title) }}</span>
<button
v-if="closable"
type="button"
class="bg-transparent h-18px w-18px flex-center text-[var(--close-icon-color)] hover:bg-[var(--close-color-hover)] rounded-3px"
style="transition: background-color .3s var(--n-bezier), color .3s var(--n-bezier);"
@click.stop="emit('close', route.fullPath)"
>
<n-icon size="14">
<svg viewBox="0 0 12 12" version="1.1" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><g stroke="none" stroke-width="1" fill="none" fill-rule="evenodd"><g fill="currentColor" fill-rule="nonzero"><path d="M2.08859116,2.2156945 L2.14644661,2.14644661 C2.32001296,1.97288026 2.58943736,1.95359511 2.7843055,2.08859116 L2.85355339,2.14644661 L6,5.293 L9.14644661,2.14644661 C9.34170876,1.95118446 9.65829124,1.95118446 9.85355339,2.14644661 C10.0488155,2.34170876 10.0488155,2.65829124 9.85355339,2.85355339 L6.707,6 L9.85355339,9.14644661 C10.0271197,9.32001296 10.0464049,9.58943736 9.91140884,9.7843055 L9.85355339,9.85355339 C9.67998704,10.0271197 9.41056264,10.0464049 9.2156945,9.91140884 L9.14644661,9.85355339 L6,6.707 L2.85355339,9.85355339 C2.65829124,10.0488155 2.34170876,10.0488155 2.14644661,9.85355339 C1.95118446,9.65829124 1.95118446,9.34170876 2.14644661,9.14644661 L5.293,6 L2.14644661,2.85355339 C1.97288026,2.67998704 1.95359511,2.41056264 2.08859116,2.2156945 L2.14644661,2.14644661 L2.08859116,2.2156945 Z" /></g></g></svg>
</n-icon>
</button>
</div>
</n-el>
</template>

View File

@ -1,148 +1,15 @@
<script setup lang="ts">
import { useAppStore, useRouteStore } from '@/store'
import {
BackTop,
Breadcrumb,
CollapaseButton,
FullScreen,
Logo,
MobileDrawer,
Notices,
Search,
Setting,
SettingDrawer,
TabBar,
UserCenter,
} from './components'
import Content from './Content.vue'
import { ProLayout, useLayoutMenu } from 'pro-naive-ui'
import leftMenu from './leftMenu.layout.vue'
import topMenu from './topMenu.layout.vue'
import { useAppStore } from '@/store/app'
const route = useRoute()
const appStore = useAppStore()
const routeStore = useRouteStore()
const { layoutMode } = storeToRefs(useAppStore())
const {
layout,
activeKey,
} = useLayoutMenu({
mode: layoutMode,
accordion: true,
menus: routeStore.menus,
})
watch(() => route.path, () => {
activeKey.value = routeStore.activeMenu
}, { immediate: true })
//
const showMobileDrawer = ref(false)
const sidebarWidth = ref(240)
const sidebarCollapsedWidth = ref(64)
const hasHorizontalMenu = computed(() => ['horizontal', 'mixed-two-column', 'mixed-sidebar'].includes(layoutMode.value))
const hidenCollapaseButton = computed(() => ['horizontal'].includes(layoutMode.value) || appStore.isMobile)
const layoutMap = {
leftMenu,
topMenu,
}
</script>
<template>
<SettingDrawer />
<ProLayout
v-model:collapsed="appStore.collapsed"
:mode="layoutMode"
:is-mobile="appStore.isMobile"
:show-logo="appStore.showLogo && !appStore.isMobile"
:show-footer="appStore.showFooter"
:show-tabbar="appStore.showTabs"
nav-fixed
show-nav
show-sidebar
:nav-height="60"
:tabbar-height="45"
:footer-height="40"
:sidebar-width="sidebarWidth"
:sidebar-collapsed-width="sidebarCollapsedWidth"
>
<template #logo>
<Logo />
</template>
<template #nav-left>
<template v-if="appStore.isMobile">
<Logo />
</template>
<template v-else>
<div v-if="!hasHorizontalMenu || !hidenCollapaseButton" class="h-full flex-y-center gap-1 p-x-sm">
<CollapaseButton v-if="!hidenCollapaseButton" />
<Breadcrumb v-if="!hasHorizontalMenu" />
</div>
</template>
</template>
<template #nav-center>
<div class="h-full flex-y-center gap-1">
<n-menu v-if="hasHorizontalMenu" v-bind="layout.horizontalMenuProps" />
</div>
</template>
<template #nav-right>
<div class="h-full flex-y-center gap-1 p-x-xl">
<!-- 移动端只显示菜单按钮 -->
<template v-if="appStore.isMobile">
<n-button
quaternary
@click="showMobileDrawer = true"
>
<template #icon>
<n-icon size="18">
<icon-park-outline-hamburger-button />
</n-icon>
</template>
</n-button>
</template>
<!-- 桌面端显示完整功能组件 -->
<template v-else>
<Search />
<Notices />
<FullScreen />
<DarkModeSwitch />
<LangsSwitch />
<Setting />
<UserCenter />
</template>
</div>
</template>
<template #sidebar>
<n-menu v-bind="layout.verticalMenuProps" :collapsed-width="sidebarCollapsedWidth" />
</template>
<template #sidebar-extra>
<n-scrollbar class="flex-[1_0_0]">
<n-menu v-bind="layout.verticalExtraMenuProps" :collapsed-width="sidebarCollapsedWidth" />
</n-scrollbar>
</template>
<template #tabbar>
<TabBar />
</template>
<template #footer>
<div class="flex-center h-full">
{{ appStore.footerText }}
</div>
</template>
<Content />
<BackTop />
<SettingDrawer />
<!-- 移动端功能抽屉 -->
<MobileDrawer v-model:show="showMobileDrawer">
<n-menu v-bind="layout.verticalMenuProps" />
</MobileDrawer>
</ProLayout>
<component :is="layoutMap[appStore.layoutMode]" />
</template>

View File

@ -0,0 +1,94 @@
<script lang="ts" setup>
import {
BackTop,
Breadcrumb,
CollapaseButton,
FullScreen,
Logo,
Menu,
Notices,
Search,
Setting,
TabBar,
UserCenter,
} from './components'
import { useAppStore, useRouteStore } from '@/store'
const routeStore = useRouteStore()
const appStore = useAppStore()
</script>
<template>
<n-layout
has-sider
class="wh-full"
embedded
>
<n-layout-sider
bordered
:collapsed="appStore.collapsed"
collapse-mode="width"
:collapsed-width="64"
:width="240"
content-style="display: flex;flex-direction: column;min-height:100%;"
>
<Logo v-if="appStore.showLogo" />
<n-scrollbar class="flex-1">
<Menu />
</n-scrollbar>
</n-layout-sider>
<n-layout
class="h-full flex flex-col"
content-style="display: flex;flex-direction: column;min-height:100%;"
embedded
:native-scrollbar="false"
>
<n-layout-header bordered position="absolute" class="z-1">
<div class="h-60px flex-y-center justify-between">
<div class="flex-y-center h-full">
<CollapaseButton />
<Breadcrumb />
</div>
<div class="flex-y-center gap-1 h-full p-x-xl">
<Search />
<Notices />
<FullScreen />
<DarkModeSwitch />
<LangsSwitch />
<Setting />
<UserCenter />
</div>
</div>
<TabBar v-if="appStore.showTabs" class="h-45px" />
</n-layout-header>
<div class="flex-1 p-16px flex flex-col">
<div class="h-60px" />
<div v-if="appStore.showTabs" class="h-45px" />
<router-view v-slot="{ Component, route }" class="flex-1">
<transition
:name="appStore.transitionAnimation"
mode="out-in"
>
<keep-alive :include="routeStore.cacheRoutes">
<component
:is="Component"
v-if="appStore.loadFlag"
:key="route.fullPath"
/>
</keep-alive>
</transition>
</router-view>
<div v-if="appStore.showFooter" class="h-40px" />
</div>
<n-layout-footer
v-if="appStore.showFooter"
bordered
position="absolute"
class="h-40px flex-center"
>
{{ appStore.footerText }}
</n-layout-footer>
<BackTop />
</n-layout>
</n-layout>
</template>

View File

@ -0,0 +1,59 @@
<script lang="ts" setup>
import {
BackTop,
FullScreen,
Logo,
Menu,
Notices,
Search,
Setting,
TabBar,
UserCenter,
} from './components'
import { useAppStore, useRouteStore } from '@/store'
const routeStore = useRouteStore()
const appStore = useAppStore()
</script>
<template>
<n-layout class="wh-full" embedded>
<n-layout
class="h-full flex flex-col" content-style="display: flex;flex-direction: column;min-height:100%;"
embedded :native-scrollbar="false"
>
<n-layout-header bordered position="absolute" class="z-1">
<div class="h-60px flex-y-center justify-between shrink-0">
<Logo v-if="appStore.showLogo" />
<Menu mode="horizontal" responsive />
<div class="flex-y-center gap-1 h-full p-x-xl">
<Search />
<Notices />
<FullScreen />
<DarkModeSwitch />
<LangsSwitch />
<Setting />
<UserCenter />
</div>
</div>
<TabBar v-if="appStore.showTabs" class="h-45px" />
</n-layout-header>
<div class="flex-1 p-16px flex flex-col">
<div class="h-60px" />
<div v-if="appStore.showTabs" class="h-45px" />
<router-view v-slot="{ Component, route }" class="flex-1">
<transition :name="appStore.transitionAnimation" mode="out-in">
<keep-alive :include="routeStore.cacheRoutes">
<component :is="Component" v-if="appStore.loadFlag" :key="route.fullPath" />
</keep-alive>
</transition>
</router-view>
<div v-if="appStore.showFooter" class="h-40px" />
</div>
<n-layout-footer v-if="appStore.showFooter" bordered position="absolute" class="h-40px flex-center">
{{ appStore.footerText }}
</n-layout-footer>
<BackTop />
</n-layout>
</n-layout>
</template>

View File

@ -1,5 +1,35 @@
import App from './App.vue'
import type { App } from 'vue'
import AppVue from './App.vue'
import AppLoading from './components/common/AppLoading.vue'
import { installRouter } from '@/router'
import { installPinia } from '@/store'
// 创建应用实例并挂载
const app = createApp(App)
app.mount('#app')
async function setupApp() {
// 载入全局loading加载状态
const appLoading = createApp(AppLoading)
appLoading.mount('#appLoading')
// 创建vue实例
const app = createApp(AppVue)
// 注册模块Pinia
await installPinia(app)
// 注册模块 Vue-router
await installRouter(app)
/* 注册模块 指令/静态资源 */
Object.values(
import.meta.glob<{ install: (app: App) => void }>('./modules/*.ts', {
eager: true,
}),
).map(i => app.use(i))
// 卸载载入动画
appLoading.unmount()
// 挂载
app.mount('#app')
}
setupApp()

View File

@ -1,24 +1,17 @@
import type { App } from 'vue'
import { local } from '@/utils'
import { createI18n } from 'vue-i18n'
import type { App } from 'vue'
import enUS from '../../locales/en_US.json'
import zhCN from '../../locales/zh_CN.json'
const { VITE_DEFAULT_LANG } = import.meta.env
import { local } from '@/utils'
export const i18n = createI18n({
legacy: false,
locale: local.get('lang') || VITE_DEFAULT_LANG, // 默认显示语言
fallbackLocale: VITE_DEFAULT_LANG,
locale: local.get('lang') || 'enUS', // 默认显示语言
fallbackLocale: 'enUS',
messages: {
zhCN,
enUS,
},
// 缺失国际化键警告
// missingWarn: false,
// 缺失回退内容警告
fallbackWarn: false,
})
export function install(app: App) {

View File

@ -11,75 +11,46 @@ export function setupRouterGuard(router: Router) {
router.beforeEach(async (to, from, next) => {
// 判断是否是外链,如果是直接打开网页并拦截跳转
if (to.meta.href) {
window.open(to.meta.href)
next(false) // 取消当前导航
return
if (to.meta.herf) {
window.open(to.meta.herf)
return false
}
// 开始 loadingBar
appStore.showProgress && window.$loadingBar?.start()
// 判断有无TOKEN,登录鉴权
const isLogin = Boolean(local.get('accessToken'))
if (!isLogin) {
if (to.name === 'login')
next()
// 处理根路由重定向
if (to.name === 'root') {
if (isLogin) {
// 已登录,重定向到首页
next({ path: import.meta.env.VITE_HOME_PATH, replace: true })
if (to.name !== 'login') {
const redirect = to.name === '404' ? undefined : to.fullPath
next({ path: '/login', query: { redirect } })
}
else {
// 未登录,重定向到登录页
next({ path: '/login', replace: true })
}
return
}
// 如果是login路由直接放行
if (to.name === 'login') {
// login页面不需要任何认证检查直接放行
// 继续执行后面的逻辑
}
// 如果路由明确设置了requiresAuth为false直接放行
else if (to.meta.requiresAuth === false) {
// 明确设置为false的路由直接放行
// 继续执行后面的逻辑
}
// 如果路由设置了requiresAuth为true且用户未登录重定向到登录页
else if (to.meta.requiresAuth === true && !isLogin) {
const redirect = to.name === 'not-found' ? undefined : to.fullPath
next({ path: '/login', query: { redirect } })
return
return false
}
// 判断路由有无进行初始化
if (!routeStore.isInitAuthRoute && to.name !== 'login') {
try {
await routeStore.initAuthRoute()
// 动态路由加载完回到根路由
if (to.name === 'not-found') {
// 等待权限路由加载好了,回到之前的路由,否则404
next({
path: to.fullPath,
replace: true,
query: to.query,
hash: to.hash,
})
return
}
}
catch {
// 如果路由初始化失败(比如 401 错误),重定向到登录页
const redirect = to.fullPath !== '/' ? to.fullPath : undefined
next({ path: '/login', query: redirect ? { redirect } : undefined })
return
if (!routeStore.isInitAuthRoute) {
await routeStore.initAuthRoute()
// 动态路由加载完回到根路由
if (to.name === '404') {
// 等待权限路由加载好了,回到之前的路由,否则404
next({
path: to.fullPath,
replace: true,
query: to.query,
hash: to.hash,
})
return false
}
}
// 如果用户已登录且访问login页面重定向到首页
if (to.name === 'login' && isLogin) {
// 判断当前页是否在login,则定位去首页
if (to.name === 'login') {
next({ path: '/' })
return
return false
}
next()
@ -90,7 +61,7 @@ export function setupRouterGuard(router: Router) {
// 添加tabs
tabStore.addTab(to)
// 设置高亮标签;
tabStore.setCurrentTab(to.fullPath as string)
tabStore.setCurrentTab(to.path as string)
})
router.afterEach((to) => {

View File

@ -1,7 +1,7 @@
import type { App } from 'vue'
import { createRouter, createWebHashHistory, createWebHistory } from 'vue-router'
import { setupRouterGuard } from './guard'
import { routes } from './routes.inner'
import { setupRouterGuard } from './guard'
const { VITE_ROUTE_MODE = 'hash', VITE_BASE_URL } = import.meta.env
export const router = createRouter({

View File

@ -5,42 +5,53 @@ export const routes: RouteRecordRaw[] = [
{
path: '/',
name: 'root',
redirect: '/appRoot',
// component: () => import('@/layouts/index'),
children: [
],
},
{
path: '/login',
name: 'login',
component: () => import('@/views/build-in/login/index.vue'), // 注意这里要带上 文件后缀.vue
component: () => import('@/views/login/index.vue'), // 注意这里要带上 文件后缀.vue
meta: {
title: '登录',
withoutTab: true,
},
},
{
path: '/public',
name: 'publicAccess',
component: () => import('@/views/build-in/public-access/index.vue'),
path: '/403',
name: '403',
component: () => import('@/views/error/403/index.vue'),
meta: {
title: '公共访问示例',
requiresAuth: false,
title: '用户无权限',
withoutTab: true,
},
},
{
path: '/not-found',
name: 'not-found',
component: () => import('@/views/build-in/not-found/index.vue'),
path: '/404',
name: '404',
component: () => import('@/views/error/404/index.vue'),
meta: {
title: '找不到页面',
icon: 'icon-park-outline:ghost',
withoutTab: true,
},
},
{
path: '/500',
name: '500',
component: () => import('@/views/error/500/index.vue'),
meta: {
title: '服务器错误',
icon: 'icon-park-outline:close-wifi',
withoutTab: true,
},
},
{
path: '/:pathMatch(.*)*',
component: () => import('@/views/build-in/not-found/index.vue'),
name: 'not-found',
component: () => import('@/views/error/404/index.vue'),
name: '404',
meta: {
title: '找不到页面',
icon: 'icon-park-outline:ghost',

View File

@ -1,418 +1,407 @@
export const staticRoutes: AppRoute.RowRoute[] = [
{
name: 'dashboard',
path: '/dashboard',
title: '仪表盘',
requiresAuth: true,
icon: 'icon-park-outline:analysis',
menuType: 'dir',
componentPath: null,
id: 1,
pid: null,
'name': 'dashboard',
'path': '/dashboard',
'meta.title': '仪表盘',
'meta.requiresAuth': true,
'meta.icon': 'icon-park-outline:analysis',
'meta.menuType': 'dir',
'componentPath': null,
'id': 1,
'pid': null,
},
{
name: 'workbench',
path: '/dashboard/workbench',
title: '工作台',
requiresAuth: true,
icon: 'icon-park-outline:alarm',
pinTab: true,
menuType: 'page',
componentPath: '/dashboard/workbench/index.vue',
id: 101,
pid: 1,
'name': 'workbench',
'path': '/dashboard/workbench',
'meta.title': '工作台',
'meta.requiresAuth': true,
'meta.icon': 'icon-park-outline:alarm',
'meta.pinTab': true,
'meta.menuType': 'page',
'componentPath': '/dashboard/workbench/index.vue',
'id': 2,
'pid': 1,
},
{
name: 'monitor',
path: '/dashboard/monitor',
title: '监控页',
requiresAuth: true,
icon: 'icon-park-outline:anchor',
menuType: 'page',
componentPath: '/dashboard/monitor/index.vue',
id: 102,
pid: 1,
'name': 'monitor',
'path': '/dashboard/monitor',
'meta.title': '监控页',
'meta.requiresAuth': true,
'meta.icon': 'icon-park-outline:anchor',
'meta.menuType': 'page',
'componentPath': '/dashboard/monitor/index.vue',
'id': 3,
'pid': 1,
},
{
name: 'multi',
path: '/multi',
title: '多级菜单演示',
requiresAuth: true,
icon: 'icon-park-outline:list',
menuType: 'dir',
componentPath: null,
id: 2,
pid: null,
'name': 'test',
'path': '/test',
'meta.title': '多级菜单演示',
'meta.requiresAuth': true,
'meta.icon': 'icon-park-outline:list',
'meta.menuType': 'dir',
'componentPath': null,
'id': 4,
'pid': null,
},
{
name: 'multi2',
path: '/multi/multi-2',
title: '多级菜单子页',
requiresAuth: true,
icon: 'icon-park-outline:list',
menuType: 'page',
componentPath: '/demo/multi/multi-2/index.vue',
id: 201,
pid: 2,
'name': 'test2',
'path': '/test/test2',
'meta.title': '多级菜单子页',
'meta.requiresAuth': true,
'meta.icon': 'icon-park-outline:list',
'meta.menuType': 'page',
'componentPath': '/test/test2/index.vue',
'id': 6,
'pid': 4,
},
{
name: 'multi2-detail',
path: '/multi/multi-2/detail',
title: '菜单详情页',
requiresAuth: false,
icon: 'icon-park-outline:list',
hide: true,
activeMenu: '/multi/multi-2',
menuType: 'page',
componentPath: '/demo/multi/multi-2/detail/index.vue',
id: 20101,
pid: 2,
'name': 'test2Detail',
'path': '/test/test2/detail',
'meta.title': '多级菜单的详情页',
'meta.requiresAuth': true,
'meta.icon': 'icon-park-outline:list',
'meta.hide': true,
'meta.activeMenu': '/test/test2',
'meta.menuType': 'page',
'componentPath': '/test/test2/detail/index.vue',
'id': 7,
'pid': 4,
},
{
name: 'multi3',
path: '/multi/multi-3',
title: '多级菜单',
requiresAuth: true,
icon: 'icon-park-outline:list',
menuType: 'dir',
componentPath: null,
id: 202,
pid: 2,
'name': 'test3',
'path': '/test/test3',
'meta.title': '多级菜单',
'meta.requiresAuth': true,
'meta.icon': 'icon-park-outline:list',
'meta.menuType': 'dir',
'componentPath': null,
'id': 8,
'pid': 4,
},
{
name: 'multi4',
path: '/multi/multi-3/multi-4',
title: '多级菜单3-1',
requiresAuth: true,
icon: 'icon-park-outline:list',
componentPath: '/demo/multi/multi-3/multi-4/index.vue',
id: 20201,
pid: 202,
'name': 'test4',
'path': '/test/test3/test4',
'meta.title': '多级菜单3-1',
'meta.requiresAuth': true,
'meta.icon': 'icon-park-outline:list',
'componentPath': '/test/test3/test4/index.vue',
'id': 9,
'pid': 8,
},
{
name: 'list',
path: '/list',
title: '列表页',
requiresAuth: true,
icon: 'icon-park-outline:list-two',
menuType: 'dir',
componentPath: null,
id: 3,
pid: null,
'name': 'list',
'path': '/list',
'meta.title': '列表页',
'meta.requiresAuth': true,
'meta.icon': 'icon-park-outline:list-two',
'meta.menuType': 'dir',
'componentPath': null,
'id': 10,
'pid': null,
},
{
name: 'commonList',
path: '/list/common-list',
title: '常用列表',
requiresAuth: true,
icon: 'icon-park-outline:list-view',
componentPath: '/demo/list/common-list/index.vue',
id: 301,
pid: 3,
'name': 'commonList',
'path': '/list/commonList',
'meta.title': '常用列表',
'meta.requiresAuth': true,
'meta.icon': 'icon-park-outline:list-view',
'componentPath': '/list/commonList/index.vue',
'id': 11,
'pid': 10,
},
{
name: 'cardList',
path: '/list/card-list',
title: '卡片列表',
requiresAuth: true,
icon: 'icon-park-outline:view-grid-list',
componentPath: '/demo/list/card-list/index.vue',
id: 302,
pid: 3,
'name': 'cardList',
'path': '/list/cardList',
'meta.title': '卡片列表',
'meta.requiresAuth': true,
'meta.icon': 'icon-park-outline:view-grid-list',
'componentPath': '/list/cardList/index.vue',
'id': 12,
'pid': 10,
},
{
name: 'draggableList',
path: '/list/draggable-list',
title: '拖拽列表',
requiresAuth: true,
icon: 'icon-park-outline:menu-fold',
componentPath: '/demo/list/draggable-list/index.vue',
id: 303,
pid: 3,
'name': 'demo',
'path': '/demo',
'meta.title': '功能示例',
'meta.requiresAuth': true,
'meta.icon': 'icon-park-outline:application-one',
'meta.menuType': 'dir',
'componentPath': null,
'id': 13,
'pid': null,
},
{
name: 'demo',
path: '/demo',
title: '功能示例',
requiresAuth: true,
icon: 'icon-park-outline:application-one',
menuType: 'dir',
componentPath: null,
id: 4,
pid: null,
'name': 'fetch',
'path': '/demo/fetch',
'meta.title': '请求示例',
'meta.requiresAuth': true,
'meta.icon': 'icon-park-outline:international',
'componentPath': '/demo/fetch/index.vue',
'id': 5,
'pid': 13,
},
{
name: 'fetch',
path: '/demo/fetch',
title: '请求示例',
requiresAuth: true,
icon: 'icon-park-outline:international',
componentPath: '/demo/fetch/index.vue',
id: 401,
pid: 4,
'name': 'echarts',
'path': '/demo/echarts',
'meta.title': 'ECharts',
'meta.requiresAuth': true,
'meta.icon': 'icon-park-outline:chart-proportion',
'componentPath': '/demo/echarts/index.vue',
'id': 15,
'pid': 13,
},
{
name: 'echarts',
path: '/demo/echarts',
title: 'ECharts',
requiresAuth: true,
icon: 'icon-park-outline:chart-proportion',
componentPath: '/demo/echarts/index.vue',
id: 402,
pid: 4,
'name': 'map',
'path': '/demo/map',
'meta.title': '地图',
'meta.requiresAuth': true,
'meta.icon': 'carbon:map',
'meta.keepAlive': true,
'componentPath': '/demo/map/index.vue',
'id': 17,
'pid': 13,
},
{
name: 'map',
path: '/demo/map',
title: '地图',
requiresAuth: true,
icon: 'carbon:map',
keepAlive: true,
componentPath: '/demo/map/index.vue',
id: 403,
pid: 4,
'name': 'editor',
'path': '/demo/editor',
'meta.title': '编辑器',
'meta.requiresAuth': true,
'meta.icon': 'icon-park-outline:editor',
'meta.menuType': 'dir',
'componentPath': null,
'id': 18,
'pid': 13,
},
{
name: 'editor',
path: '/demo/editor',
title: '编辑器',
requiresAuth: true,
icon: 'icon-park-outline:editor',
menuType: 'dir',
componentPath: null,
id: 404,
pid: 4,
'name': 'editorMd',
'path': '/demo/editor/md',
'meta.title': 'MarkDown',
'meta.requiresAuth': true,
'meta.icon': 'ri:markdown-line',
'componentPath': '/demo/editor/md/index.vue',
'id': 19,
'pid': 18,
},
{
name: 'editorMd',
path: '/demo/editor/md',
title: 'MarkDown',
requiresAuth: true,
icon: 'ri:markdown-line',
componentPath: '/demo/editor/md/index.vue',
id: 40401,
pid: 404,
'name': 'editorRich',
'path': '/demo/editor/rich',
'meta.title': '富文本',
'meta.requiresAuth': true,
'meta.icon': 'icon-park-outline:edit-one',
'componentPath': '/demo/editor/rich/index.vue',
'id': 20,
'pid': 18,
},
{
name: 'editorRich',
path: '/demo/editor/rich',
title: '富文本',
requiresAuth: true,
icon: 'icon-park-outline:edit-one',
componentPath: '/demo/editor/rich/index.vue',
id: 40402,
pid: 404,
'name': 'clipboard',
'path': '/demo/clipboard',
'meta.title': '剪贴板',
'meta.requiresAuth': true,
'meta.icon': 'icon-park-outline:clipboard',
'componentPath': '/demo/clipboard/index.vue',
'id': 21,
'pid': 13,
},
{
name: 'clipboard',
path: '/demo/clipboard',
title: '剪贴板',
requiresAuth: true,
icon: 'icon-park-outline:clipboard',
componentPath: '/demo/clipboard/index.vue',
id: 405,
pid: 4,
'name': 'icons',
'path': '/demo/icons',
'meta.title': '图标',
'meta.requiresAuth': true,
'meta.icon': 'icon-park-outline:winking-face-with-open-eyes',
'componentPath': '/demo/icons/index.vue',
'id': 22,
'pid': 13,
},
{
name: 'icons',
path: '/demo/icons',
title: '图标',
requiresAuth: true,
icon: 'local:cool',
componentPath: '/demo/icons/index.vue',
id: 406,
pid: 4,
'name': 'QRCode',
'path': '/demo/QRCode',
'meta.title': '二维码',
'meta.requiresAuth': true,
'meta.icon': 'icon-park-outline:two-dimensional-code',
'componentPath': '/demo/QRCode/index.vue',
'id': 23,
'pid': 13,
},
{
name: 'QRCode',
path: '/demo/qr-code',
title: '二维码',
requiresAuth: true,
icon: 'icon-park-outline:two-dimensional-code',
componentPath: '/demo/qr-code/index.vue',
id: 407,
pid: 4,
'name': 'docments',
'path': '/docments',
'meta.title': '外链文档',
'meta.requiresAuth': true,
'meta.icon': 'icon-park-outline:file-doc',
'meta.menuType': 'dir',
'componentPath': null,
'id': 24,
'pid': null,
},
{
name: 'cascader',
path: '/demo/cascader',
title: '省市区联动',
requiresAuth: true,
icon: 'icon-park-outline:add-subset',
componentPath: '/demo/cascader/index.vue',
id: 408,
pid: 4,
'name': 'docmentsVue',
'path': '/docments/vue',
'meta.title': 'Vue',
'meta.requiresAuth': true,
'meta.icon': 'logos:vue',
'componentPath': '/docments/vue/index.vue',
'id': 25,
'pid': 24,
},
{
name: 'dict',
path: '/demo/dict',
title: '字典示例',
requiresAuth: true,
icon: 'icon-park-outline:book-one',
componentPath: '/demo/dict/index.vue',
id: 409,
pid: 4,
'name': 'docmentsVite',
'path': '/docments/vite',
'meta.title': 'Vite',
'meta.requiresAuth': true,
'meta.icon': 'logos:vitejs',
'componentPath': '/docments/vite/index.vue',
'id': 26,
'pid': 24,
},
{
name: 'documents',
path: '/documents',
title: '外链文档',
requiresAuth: true,
icon: 'icon-park-outline:file-doc',
menuType: 'dir',
componentPath: null,
id: 5,
pid: null,
'name': 'docmentsVueuse',
'path': '/docments/vueuse',
'meta.title': 'VueUse外链',
'meta.requiresAuth': true,
'meta.icon': 'logos:vueuse',
'meta.herf': 'https://vueuse.org/guide/',
'componentPath': 'null',
'id': 27,
'pid': 24,
},
{
name: 'documentsVue',
path: '/documents/vue',
title: 'Vue',
requiresAuth: true,
icon: 'logos:vue',
componentPath: '/demo/documents/vue/index.vue',
id: 501,
pid: 5,
'name': 'permission',
'path': '/permission',
'meta.title': '权限',
'meta.requiresAuth': true,
'meta.icon': 'icon-park-outline:people-safe',
'meta.menuType': 'dir',
'componentPath': null,
'id': 28,
'pid': null,
},
{
name: 'documentsVite',
path: '/documents/vite',
title: 'Vite',
requiresAuth: true,
icon: 'logos:vitejs',
componentPath: '/demo/documents/vite/index.vue',
id: 502,
pid: 5,
'name': 'permissionDemo',
'path': '/permission/permission',
'meta.title': '权限示例',
'meta.requiresAuth': true,
'meta.icon': 'icon-park-outline:right-user',
'componentPath': '/permission/permission/index.vue',
'id': 29,
'pid': 28,
},
{
name: 'documentsVueuse',
path: '/documents/vue-use',
title: 'VueUse外链',
requiresAuth: true,
icon: 'logos:vueuse',
href: 'https://vueuse.org/guide/',
componentPath: 'null',
id: 503,
pid: 5,
},
{
name: 'documentsNova',
path: '/documents/nova',
title: 'Nova docs',
requiresAuth: true,
icon: 'local:logo',
href: 'https://nova-admin-docs.netlify.app/',
componentPath: '2333333',
id: 504,
pid: 5,
},
{
name: 'documentsPublic',
path: '/documents/public',
title: '公共示例页(外链)',
requiresAuth: true,
icon: 'local:logo',
href: '/public',
componentPath: 'null',
id: 505,
pid: 5,
},
{
name: 'permission',
path: '/permission',
title: '权限',
requiresAuth: true,
icon: 'icon-park-outline:people-safe',
menuType: 'dir',
componentPath: null,
id: 6,
pid: null,
},
{
name: 'permissionDemo',
path: '/permission/permission',
title: '权限示例',
requiresAuth: true,
icon: 'icon-park-outline:right-user',
componentPath: '/demo/permission/permission/index.vue',
id: 601,
pid: 6,
},
{
name: 'justSuper',
path: '/permission/just-super',
title: 'super可见',
requiresAuth: true,
roles: [
'name': 'justSuper',
'path': '/permission/justSuper',
'meta.title': 'super可见',
'meta.requiresAuth': true,
'meta.roles': [
'super',
],
icon: 'icon-park-outline:wrong-user',
componentPath: '/demo/permission/just-super/index.vue',
id: 602,
pid: 6,
'meta.icon': 'icon-park-outline:wrong-user',
'componentPath': '/permission/justSuper/index.vue',
'id': 30,
'pid': 28,
},
{
name: 'setting',
path: '/setting',
title: '系统设置',
requiresAuth: true,
icon: 'icon-park-outline:setting',
menuType: 'dir',
componentPath: null,
id: 7,
pid: null,
'name': 'error',
'path': '/error',
'meta.title': '异常页',
'meta.requiresAuth': true,
'meta.icon': 'icon-park-outline:error-computer',
'meta.menuType': 'dir',
'componentPath': null,
'id': 31,
'pid': null,
},
{
name: 'accountSetting',
path: '/setting/account',
title: '用户设置',
requiresAuth: true,
icon: 'icon-park-outline:every-user',
componentPath: '/setting/account/index.vue',
id: 701,
pid: 7,
'name': 'demo403',
'path': '/error/403',
'meta.title': '403',
'meta.requiresAuth': true,
'meta.icon': 'carbon:error',
'meta.order': 3,
'componentPath': '/error/403/index.vue',
'id': 32,
'pid': 31,
},
{
name: 'dictionarySetting',
path: '/setting/dictionary',
title: '字典设置',
requiresAuth: true,
icon: 'icon-park-outline:book-one',
componentPath: '/setting/dictionary/index.vue',
id: 702,
pid: 7,
'name': 'demo404',
'path': '/error/404',
'meta.title': '404',
'meta.requiresAuth': true,
'meta.icon': 'icon-park-outline:error',
'meta.order': 2,
'componentPath': '/error/404/index.vue',
'id': 33,
'pid': 31,
},
{
name: 'menuSetting',
path: '/setting/menu',
title: '菜单设置',
requiresAuth: true,
icon: 'icon-park-outline:application-menu',
componentPath: '/setting/menu/index.vue',
id: 703,
pid: 7,
'name': 'demo500',
'path': '/error/500',
'meta.title': '500',
'meta.requiresAuth': true,
'meta.icon': 'carbon:data-error',
'meta.order': 1,
'componentPath': '/error/500/index.vue',
'id': 34,
'pid': 31,
},
{
name: 'about',
path: '/about',
title: '关于',
requiresAuth: true,
icon: 'icon-park-outline:info',
componentPath: '/demo/about/index.vue',
id: 8,
pid: null,
'name': 'setting',
'path': '/setting',
'meta.title': '系统设置',
'meta.requiresAuth': true,
'meta.icon': 'icon-park-outline:setting',
'meta.menuType': 'dir',
'componentPath': null,
'id': 35,
'pid': null,
},
{
name: 'userCenter',
path: '/user-center',
title: '个人中心',
requiresAuth: true,
hide: true,
icon: 'carbon:user-avatar-filled-alt',
componentPath: '/build-in/user-center/index.vue',
id: 999,
pid: null,
'name': 'accountSetting',
'path': '/setting/account',
'meta.title': '用户设置',
'meta.requiresAuth': true,
'meta.icon': 'icon-park-outline:every-user',
'componentPath': '/setting/account/index.vue',
'id': 36,
'pid': 35,
},
{
'name': 'dictionarySetting',
'path': '/setting/dictionary',
'meta.title': '字典设置',
'meta.requiresAuth': true,
'meta.icon': 'icon-park-outline:book-one',
'componentPath': '/setting/dictionary/index.vue',
'id': 37,
'pid': 35,
},
{
'name': 'menuSetting',
'path': '/setting/menu',
'meta.title': '菜单设置',
'meta.requiresAuth': true,
'meta.icon': 'icon-park-outline:application-menu',
'componentPath': '/setting/menu/index.vue',
'id': 38,
'pid': 35,
},
{
'name': 'userCenter',
'path': '/userCenter',
'meta.title': '个人中心',
'meta.requiresAuth': true,
'meta.icon': 'carbon:user-avatar-filled-alt',
'componentPath': '/userCenter/index.vue',
'id': 39,
'pid': null,
},
{
'name': 'about',
'path': '/about',
'meta.title': '关于',
'meta.requiresAuth': true,
'meta.icon': 'icon-park-outline:info',
'componentPath': '/about/index.vue',
'id': 40,
'pid': null,
},
]

View File

@ -5,15 +5,15 @@ interface Ilogin {
password: string
}
export function fetchLogin(data: Ilogin) {
const methodInstance = request.Post<Service.ResponseResult<Api.Login.Info>>('/login', data)
export function fetchLogin(params: Ilogin) {
const methodInstance = request.Post<Service.ResponseResult<ApiAuth.loginInfo>>('/login', params)
methodInstance.meta = {
authRole: null,
}
return methodInstance
}
export function fetchUpdateToken(data: any) {
const method = request.Post<Service.ResponseResult<Api.Login.Info>>('/updateToken', data)
const method = request.Post<Service.ResponseResult<ApiAuth.loginInfo>>('/updateToken', data)
method.meta = {
authRole: 'refreshToken',
}
@ -21,5 +21,5 @@ export function fetchUpdateToken(data: any) {
}
export function fetchUserRoutes(params: { id: number }) {
return request.Get<Service.ResponseResult<AppRoute.RowRoute[]>>('/getUserRoutes', { params })
return request.Get<Service.ResponseResult<AppRoute.RowRoute[]> >('/getUserRoutes', { params })
}

View File

@ -1,26 +1,5 @@
import { request } from '../http'
// 获取所有路由信息
export function fetchAllRoutes() {
return request.Get<Service.ResponseResult<AppRoute.RowRoute[]>>('/getUserRoutes')
}
// 获取所有用户信息
export function fetchUserPage() {
return request.Get<Service.ResponseResult<Entity.User[]>>('/userPage')
}
// 获取所有角色列表
export function fetchRoleList() {
return request.Get<Service.ResponseResult<Entity.Role[]>>('/role/list')
}
/**
*
*
* @param code -
* @returns
*/
export function fetchDictList(code?: string) {
const params = { code }
return request.Get<Service.ResponseResult<Entity.Dict[]>>('/dict/list', { params })
return request.Get<Service.ResponseResult<AppRoute.RowRoute[]> >('/getUserRoutes')
}

View File

@ -1,7 +1,7 @@
import { blankInstance, request } from '../http'
/* get方法测试 */
export function fetchGet(params?: any) {
export function fetachGet(params?: any) {
return request.Get('/getAPI', { params })
}
@ -36,7 +36,7 @@ export function withoutToken() {
/* 接口数据转换 */
export function dictData() {
return request.Get('/getDictData', {
transform(rawData, _headers) {
transformData(rawData, _headers) {
const response = rawData as any
return {
...response,
@ -53,7 +53,7 @@ export function dictData() {
export function getBlob(url: string) {
const methodInstance = blankInstance.Get<Blob>(url)
methodInstance.meta = {
// 标识为blob数据
// 标识为bolb数据
isBlob: true,
}
return methodInstance
@ -61,9 +61,12 @@ export function getBlob(url: string) {
/* 带进度的下载文件 */
export function downloadFile(url: string) {
const methodInstance = blankInstance.Get<Blob>(url)
const methodInstance = blankInstance.Get<Blob>(url, {
// 开启下载进度
enableDownload: true,
})
methodInstance.meta = {
// 标识为blob数据
// 标识为bolb数据
isBlob: true,
}
return methodInstance

View File

@ -1,39 +1,30 @@
import { local } from '@/utils'
import { createAlova } from 'alova'
import { createServerTokenAuthentication } from 'alova/client'
import adapterFetch from 'alova/fetch'
import VueHook from 'alova/vue'
import type { VueHookType } from 'alova/vue'
import {
DEFAULT_ALOVA_OPTIONS,
DEFAULT_BACKEND_OPTIONS,
} from './config'
import GlobalFetch from 'alova/GlobalFetch'
import { createServerTokenAuthentication } from '@alova/scene-vue'
import qs from 'qs'
import {
handleBusinessError,
handleRefreshToken,
handleResponseError,
handleServiceResult,
} from './handle'
import {
DEFAULT_ALOVA_OPTIONS,
DEFAULT_BACKEND_OPTIONS,
} from './config'
import { local } from '@/utils'
const { onAuthRequired, onResponseRefreshToken } = createServerTokenAuthentication<VueHookType>({
const { onAuthRequired, onResponseRefreshToken } = createServerTokenAuthentication({
// 服务端判定token过期
refreshTokenOnSuccess: {
// 当服务端返回401时表示token过期
isExpired: async (response, method) => {
const res = await response.clone().json()
const isExpired = method.meta && method.meta.isExpired
return (response.status === 401 || res.code === 401) && !isExpired
isExpired: (response, _method) => {
return response.status === 401
},
// 当token过期时触发在此函数中触发刷新token
handler: async (_response, method) => {
// 此处采取限制,防止过期请求无限循环重发
if (!method.meta)
method.meta = { isExpired: true }
else
method.meta.isExpired = true
handler: async (_response, _method) => {
await handleRefreshToken()
},
},
@ -53,15 +44,15 @@ export function createAlovaInstance(
return createAlova({
statesHook: VueHook,
requestAdapter: adapterFetch(),
cacheFor: null,
requestAdapter: GlobalFetch(),
localCache: null,
baseURL: _alovaConfig.baseURL,
timeout: _alovaConfig.timeout,
beforeRequest: onAuthRequired((method) => {
if (method.meta?.isFormPost) {
method.config.headers['Content-Type'] = 'application/x-www-form-urlencoded'
method.data = new URLSearchParams(method.data as URLSearchParams).toString()
method.data = qs.stringify(method.data)
}
alovaConfig.beforeRequest?.(method)
}),

View File

@ -1,5 +1,4 @@
import { $t } from '@/utils'
/** 默认实例的Aixos配置 */
export const DEFAULT_ALOVA_OPTIONS = {
// 请求超时时间,默认15秒

View File

@ -1,10 +1,10 @@
import { fetchUpdateToken } from '@/service'
import { useAuthStore } from '@/store'
import { local } from '@/utils'
import {
ERROR_NO_TIP_STATUS,
ERROR_STATUS,
} from './config'
import { useAuthStore } from '@/store'
import { fetchUpdateToken } from '@/service'
import { local } from '@/utils'
type ErrorStatus = keyof typeof ERROR_STATUS
@ -70,13 +70,6 @@ export function handleServiceResult(data: any, isSuccess: boolean = true) {
*/
export async function handleRefreshToken() {
const authStore = useAuthStore()
const isAutoRefresh = import.meta.env.VITE_AUTO_REFRESH_TOKEN === 'Y'
if (!isAutoRefresh) {
await authStore.logout()
return
}
// 刷新token
const { data } = await fetchUpdateToken({ refreshToken: local.get('refreshToken') })
if (data) {
local.set('accessToken', data.accessToken)
@ -84,7 +77,7 @@ export async function handleRefreshToken() {
}
else {
// 刷新失败,退出
await authStore.logout()
await authStore.resetAuthStore()
}
}

View File

@ -1,7 +1,13 @@
import { createAlovaInstance } from './alova'
import { serviceConfig } from '@/../service.config'
import { generateProxyPattern } from '@/../build/proxy'
const isHttpProxy = import.meta.env.DEV && import.meta.env.VITE_HTTP_PROXY === 'Y'
const { url } = generateProxyPattern(serviceConfig[import.meta.env.MODE])
export const request = createAlovaInstance({
baseURL: __URL_MAP__.url.path,
baseURL: isHttpProxy ? url.proxy : url.value,
})
export const blankInstance = createAlovaInstance({

View File

@ -1,4 +1,4 @@
export * from './api/list'
export * from './api/login'
export * from './api/system'
export * from './api/login'
export * from './api/list'
export * from './api/test'

View File

@ -1,13 +1,11 @@
import type { GlobalThemeOverrides } from 'naive-ui'
import { local, setLocale } from '@/utils'
import { colord } from 'colord'
import { set } from 'radash'
import themeConfig from './theme.json'
import type { ProLayoutMode } from 'pro-naive-ui'
import { local, setLocale } from '@/utils'
export type TransitionAnimation = '' | 'fade-slide' | 'fade-bottom' | 'fade-scale' | 'zoom-fade' | 'zoom-out'
const { VITE_DEFAULT_LANG, VITE_COPYRIGHT_INFO } = import.meta.env
type TransitionAnimation = '' | 'fade-slide' | 'fade-bottom' | 'fade-scale' | 'zoom-fade' | 'zoom-out'
export type LayoutMode = 'leftMenu' | 'topMenu'
const docEle = ref(document.documentElement)
@ -17,13 +15,11 @@ const { system, store } = useColorMode({
emitAuto: true,
})
const isMobile = useMediaQuery('(max-width: 700px)')
export const useAppStore = defineStore('app-store', {
state: () => {
return {
footerText: VITE_COPYRIGHT_INFO,
lang: VITE_DEFAULT_LANG,
footerText: 'Copyright © 2024 chansee97',
lang: 'enUS' as App.lang,
theme: themeConfig as GlobalThemeOverrides,
primaryColor: themeConfig.common.primaryColor,
collapsed: false,
@ -37,9 +33,8 @@ export const useAppStore = defineStore('app-store', {
showBreadcrumb: true,
showBreadcrumbIcon: true,
showWatermark: false,
showSetting: false,
transitionAnimation: 'fade-slide' as TransitionAnimation,
layoutMode: 'vertical' as ProLayoutMode,
layoutMode: 'leftMenu' as LayoutMode,
}
},
getters: {
@ -52,9 +47,6 @@ export const useAppStore = defineStore('app-store', {
fullScreen() {
return isFullscreen.value
},
isMobile() {
return isMobile.value
},
},
actions: {
// 重置所有设置
@ -67,12 +59,13 @@ export const useAppStore = defineStore('app-store', {
this.loadFlag = true
this.showLogo = true
this.showTabs = true
this.showLogo = true
this.showFooter = true
this.showBreadcrumb = true
this.showBreadcrumbIcon = true
this.showWatermark = false
this.transitionAnimation = 'fade-slide'
this.layoutMode = 'vertical'
this.layoutMode = 'leftMenu'
// 重置所有配色
this.setPrimaryColor(this.primaryColor)
@ -84,7 +77,7 @@ export const useAppStore = defineStore('app-store', {
},
/* 设置主题色 */
setPrimaryColor(color: string) {
const brightenColor = colord(color).lighten(0.05).toHex()
const brightenColor = colord(color).lighten(0.1).toHex()
const darkenColor = colord(color).darken(0.05).toHex()
set(this.theme, 'common.primaryColor', color)
set(this.theme, 'common.primaryColorHover', brightenColor)
@ -131,6 +124,11 @@ export const useAppStore = defineStore('app-store', {
},
},
persist: {
storage: localStorage,
enabled: true,
strategies: [
{
storage: localStorage,
},
],
},
})

View File

@ -1,11 +1,11 @@
import { router } from '@/router'
import { fetchLogin } from '@/service'
import { local } from '@/utils'
import { useRouteStore } from './router'
import { useRouteStore } from './route'
import { useTabStore } from './tab'
import { fetchLogin } from '@/service'
import { router } from '@/router'
import { local } from '@/utils'
interface AuthStatus {
userInfo: Api.Login.Info | null
userInfo: ApiAuth.loginInfo | null
token: string
}
export const useAuthStore = defineStore('auth-store', {
@ -23,7 +23,7 @@ export const useAuthStore = defineStore('auth-store', {
},
actions: {
/* 登录退出,重置用户信息等 */
async logout() {
async resetAuthStore() {
const route = unref(router.currentRoute)
// 清除本地缓存
this.clearAuthStorage()
@ -33,7 +33,7 @@ export const useAuthStore = defineStore('auth-store', {
// 清空标签栏数据
const tabStore = useTabStore()
tabStore.clearAllTabs()
// 重当前存储库
// 重当前存储库
this.$reset()
// 重定向到登录页
if (route.meta.requiresAuth) {
@ -53,21 +53,16 @@ export const useAuthStore = defineStore('auth-store', {
/* 用户登录 */
async login(userName: string, password: string) {
try {
const { isSuccess, data } = await fetchLogin({ userName, password })
if (!isSuccess)
return
const { isSuccess, data } = await fetchLogin({ userName, password })
if (!isSuccess)
return
// 处理登录信息
await this.handleLoginInfo(data)
}
catch (e) {
console.warn('[Login Error]:', e)
}
// 处理登录信息
await this.handleAfterLogin(data)
},
/* 处理登录返回的数据 */
async handleLoginInfo(data: Api.Login.Info) {
/* 登录后的处理函数 */
async handleAfterLogin(data: ApiAuth.loginInfo) {
// 将token和userInfo保存下来
local.set('userInfo', data)
local.set('accessToken', data.accessToken)

View File

@ -1,58 +0,0 @@
import { fetchDictList } from '@/service'
import { session } from '@/utils'
export const useDictStore = defineStore('dict-store', {
state: () => {
return {
dictMap: {} as DictMap,
isInitDict: false,
}
},
actions: {
async dict(code: string) {
// 调用前初始化
if (!this.dictMap) {
this.initDict()
}
const targetDict = await this.getDict(code)
return {
data: () => targetDict,
enum: () => Object.fromEntries(targetDict.map(({ value, label }) => [value, label])),
valueMap: () => Object.fromEntries(targetDict.map(({ value, ...data }) => [value, data])),
labelMap: () => Object.fromEntries(targetDict.map(({ label, ...data }) => [label, data])),
}
},
async getDict(code: string) {
const isExist = Reflect.has(this.dictMap, code)
if (isExist) {
return this.dictMap[code]
}
else {
return await this.getDictByNet(code)
}
},
async getDictByNet(code: string) {
const { data, isSuccess } = await fetchDictList(code)
if (isSuccess) {
Reflect.set(this.dictMap, code, data)
// 同步至session
session.set('dict', this.dictMap)
return data
}
else {
throw new Error(`Failed to get ${code} dictionary from network, check ${code} field or network`)
}
},
initDict() {
const dict = session.get('dict')
if (dict) {
Object.assign(this.dictMap, dict)
}
this.isInitDict = true
},
},
})

View File

@ -1,15 +1,14 @@
import type { App } from 'vue'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
import piniaPluginPersist from 'pinia-plugin-persist'
export * from './app/index'
export * from './auth'
export * from './dict'
export * from './router'
export * from './route'
export * from './tab'
// 安装pinia全局状态库
export function installPinia(app: App) {
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
pinia.use(piniaPluginPersist)
app.use(pinia)
}

207
src/store/route.ts Normal file
View File

@ -0,0 +1,207 @@
import type { MenuOption } from 'naive-ui'
import { RouterLink } from 'vue-router'
import { h } from 'vue'
import { clone, construct, min } from 'radash'
import type { RouteRecordRaw } from 'vue-router'
import { $t, arrayToTree, local, renderIcon } from '@/utils'
import { router } from '@/router'
import { fetchUserRoutes } from '@/service'
import { staticRoutes } from '@/router/routes.static'
import { usePermission } from '@/hooks'
import Layout from '@/layouts/index.vue'
import { useAuthStore } from '@/store/auth'
interface RoutesStatus {
isInitAuthRoute: boolean
menus: AppRoute.Route[]
rowRoutes: AppRoute.RowRoute[]
activeMenu: string | null
cacheRoutes: string[]
}
export const useRouteStore = defineStore('route-store', {
state: (): RoutesStatus => {
return {
isInitAuthRoute: false,
menus: [],
rowRoutes: [],
activeMenu: null,
cacheRoutes: [],
}
},
actions: {
resetRouteStore() {
this.resetRoutes()
this.$reset()
},
resetRoutes() {
router.removeRoute('appRoot')
},
// set the currently highlighted menu key
setActiveMenu(key: string) {
this.activeMenu = key
},
/* 生成侧边菜单的数据 */
createMenus(userRoutes: AppRoute.RowRoute[]) {
const resultMenus = clone(userRoutes).map(i => construct(i)) as AppRoute.Route[]
// filter menus that do not need to be displayed
const visibleMenus = resultMenus.filter(route => !route.meta.hide)
// generate side menu
this.menus = arrayToTree(this.transformAuthRoutesToMenus(visibleMenus))
},
// render the returned routing table as a sidebar
transformAuthRoutesToMenus(userRoutes: AppRoute.Route[]): MenuOption[] {
const { hasPermission } = usePermission()
// Filter out side menus without permission
return userRoutes.filter(i => hasPermission(i.meta.roles))
// Sort the menu according to the order size
.sort((a, b) => {
if (a.meta && a.meta.order && b.meta && b.meta.order)
return a.meta.order - b.meta.order
else if (a.meta && a.meta.order)
return -1
else if (b.meta && b.meta.order)
return 1
else return 0
})
// Convert to side menu data structure
.map((item) => {
const target: MenuOption = {
id: item.id,
pid: item.pid,
label:
(!item.meta.menuType || item.meta.menuType === 'page')
? () =>
h(
RouterLink,
{
to: {
path: item.path,
},
},
{ default: () => $t(`route.${String(item.name)}`, item.meta.title) },
)
: () => $t(`route.${String(item.name)}`, item.meta.title),
key: item.path,
icon: item.meta.icon ? renderIcon(item.meta.icon) : undefined,
}
return target
})
},
createRoutes(routes: AppRoute.RowRoute[]) {
const { hasPermission } = usePermission()
// Structure the meta field
let resultRouter = clone(routes).map(i => construct(i)) as AppRoute.Route[]
// Route permission filtering
resultRouter = resultRouter.filter(i => hasPermission(i.meta.roles))
// Generate an array of route names that need to be kept alive
this.cacheRoutes = resultRouter.filter((i) => {
return i.meta.keepAlive
})
.map(i => i.name)
// Generate routes, no need to import files for those with redirect
const modules = import.meta.glob('@/views/**/*.vue')
resultRouter = resultRouter.map((item: AppRoute.Route) => {
if (item.componentPath && !item.redirect)
item.component = modules[`/src/views${item.componentPath}`]
return item
})
// Generate route tree
resultRouter = arrayToTree(resultRouter) as AppRoute.Route[]
const appRootRoute: RouteRecordRaw = {
path: '/appRoot',
name: 'appRoot',
redirect: import.meta.env.VITE_HOME_PATH,
component: Layout,
meta: {
title: '',
icon: 'icon-park-outline:home',
},
children: [],
}
// Set the correct redirect path for the route
this.setRedirect(resultRouter)
// Insert the processed route into the root route
appRootRoute.children = resultRouter as unknown as RouteRecordRaw[]
router.addRoute(appRootRoute)
},
setRedirect(routes: AppRoute.Route[]) {
routes.forEach((route) => {
if (route.children) {
if (!route.redirect) {
// Filter out a collection of child elements that are not hidden
const visibleChilds = route.children.filter(child => !child.meta.hide)
// Redirect page to the path of the first child element by default
let target = visibleChilds[0]
// Filter out pages with the order attribute
const orderChilds = visibleChilds.filter(child => child.meta.order)
if (orderChilds.length > 0)
target = min(orderChilds, i => i.meta.order!) as AppRoute.Route
route.redirect = target.path
}
this.setRedirect(route.children)
}
})
},
async initRouteInfo() {
if (import.meta.env.VITE_AUTH_ROUTE_MODE === 'dynamic') {
const userInfo = local.get('userInfo')
if (!userInfo || !userInfo.id) {
const authStore = useAuthStore()
authStore.resetAuthStore()
return
}
// Get user's route
const { data } = await fetchUserRoutes({
id: userInfo.id,
})
if (!data)
return
return data
}
else {
this.rowRoutes = staticRoutes
return staticRoutes
}
},
async initAuthRoute() {
this.isInitAuthRoute = false
// Initialize route information
const rowRoutes = await this.initRouteInfo()
if (!rowRoutes) {
window.$message.error($t(`app.getRouteError`))
return
}
this.rowRoutes = rowRoutes
// Generate actual route and insert
this.createRoutes(rowRoutes)
// Generate side menu
this.createMenus(rowRoutes)
this.isInitAuthRoute = true
},
},
})

View File

@ -1,143 +0,0 @@
import type { MenuOption } from 'naive-ui'
import type { RouteRecordRaw } from 'vue-router'
import { usePermission } from '@/hooks'
import Layout from '@/layouts/index.vue'
import { $t, arrayToTree, renderIcon } from '@/utils'
import { clone, min, omit, pick } from 'radash'
import { RouterLink } from 'vue-router'
const metaFields: AppRoute.MetaKeys[]
= ['title', 'icon', 'requiresAuth', 'roles', 'keepAlive', 'hide', 'order', 'href', 'activeMenu', 'withoutTab', 'pinTab', 'menuType']
function standardizedRoutes(route: AppRoute.RowRoute[]) {
return clone(route).map((i) => {
const route = omit(i, metaFields)
Reflect.set(route, 'meta', pick(i, metaFields))
return route
}) as AppRoute.Route[]
}
export function createRoutes(routes: AppRoute.RowRoute[]) {
const { hasPermission } = usePermission()
// Structure the meta field
let resultRouter = standardizedRoutes(routes)
// Route permission filtering
resultRouter = resultRouter.filter(i => hasPermission(i.meta.roles))
// Generate routes, no need to import files for those with redirect
const modules = import.meta.glob('@/views/**/*.vue')
resultRouter = resultRouter.map((item: AppRoute.Route) => {
if (item.componentPath && !item.redirect)
item.component = modules[`/src/views${item.componentPath}`]
return item
})
// Generate route tree
resultRouter = arrayToTree(resultRouter) as AppRoute.Route[]
const appRootRoute: RouteRecordRaw = {
path: '/appRoot',
name: 'appRoot',
redirect: import.meta.env.VITE_HOME_PATH,
component: Layout,
meta: {
title: '',
icon: 'icon-park-outline:home',
},
children: [],
}
// Set the correct redirect path for the route
setRedirect(resultRouter)
// Insert the processed route into the root route
appRootRoute.children = resultRouter as unknown as RouteRecordRaw[]
return appRootRoute
}
// Generate an array of route names that need to be kept alive
export function generateCacheRoutes(routes: AppRoute.RowRoute[]) {
return routes
.filter(i => i.keepAlive)
.map(i => i.name)
}
function setRedirect(routes: AppRoute.Route[]) {
routes.forEach((route) => {
if (route.children) {
if (!route.redirect) {
// Filter out a collection of child elements that are not hidden
const visibleChilds = route.children.filter(child => !child.meta.hide)
// Redirect page to the path of the first child element by default
let target = visibleChilds[0]
// Filter out pages with the order attribute
const orderChilds = visibleChilds.filter(child => child.meta.order)
if (orderChilds.length > 0)
target = min(orderChilds, i => i.meta.order!) as AppRoute.Route
if (target)
route.redirect = target.path
}
setRedirect(route.children)
}
})
}
/* 生成侧边菜单的数据 */
export function createMenus(userRoutes: AppRoute.RowRoute[]) {
const resultMenus = standardizedRoutes(userRoutes)
// filter menus that do not need to be displayed
const visibleMenus = resultMenus.filter(route => !route.meta.hide)
// generate side menu
return arrayToTree(transformAuthRoutesToMenus(visibleMenus))
}
// render the returned routing table as a sidebar
function transformAuthRoutesToMenus(userRoutes: AppRoute.Route[]) {
const { hasPermission } = usePermission()
return userRoutes
// Filter out side menus without permission
.filter(i => hasPermission(i.meta.roles))
// Sort the menu according to the order size
.sort((a, b) => {
if (a.meta && a.meta.order && b.meta && b.meta.order)
return a.meta.order - b.meta.order
else if (a.meta && a.meta.order)
return -1
else if (b.meta && b.meta.order)
return 1
else return 0
})
// Convert to side menu data structure
.map((item) => {
const target: MenuOption = {
id: item.id,
pid: item.pid,
label:
(!item.meta.menuType || item.meta.menuType === 'page')
? () =>
h(
RouterLink,
{
to: {
path: item.path,
},
},
{ default: () => $t(`route.${String(item.name)}`, item.meta.title) },
)
: () => $t(`route.${String(item.name)}`, item.meta.title),
key: item.path,
icon: item.meta.icon ? renderIcon(item.meta.icon) : undefined,
}
return target
})
}

View File

@ -1,96 +0,0 @@
import type { MenuOption } from 'naive-ui'
import { router } from '@/router'
import { staticRoutes } from '@/router/routes.static'
import { fetchUserRoutes } from '@/service'
import { useAuthStore } from '@/store/auth'
import { $t, local } from '@/utils'
import { createMenus, createRoutes, generateCacheRoutes } from './helper'
interface RoutesStatus {
isInitAuthRoute: boolean
menus: MenuOption[]
rowRoutes: AppRoute.RowRoute[]
activeMenu: string | null
cacheRoutes: string[]
}
export const useRouteStore = defineStore('route-store', {
state: (): RoutesStatus => {
return {
isInitAuthRoute: false,
activeMenu: null,
menus: [],
rowRoutes: [],
cacheRoutes: [],
}
},
actions: {
resetRouteStore() {
this.resetRoutes()
this.$reset()
},
resetRoutes() {
if (router.hasRoute('appRoot'))
router.removeRoute('appRoot')
},
// set the currently highlighted menu key
setActiveMenu(key: string) {
this.activeMenu = key
},
async initRouteInfo() {
if (import.meta.env.VITE_ROUTE_LOAD_MODE === 'dynamic') {
try {
// Get user's route
const result = await fetchUserRoutes({
id: 1,
})
if (!result.isSuccess || !result.data) {
throw new Error('Failed to fetch user routes')
}
return result.data
}
catch (error) {
console.error('Failed to initialize route info:', error)
throw error
}
}
else {
this.rowRoutes = staticRoutes
return staticRoutes
}
},
async initAuthRoute() {
this.isInitAuthRoute = false
try {
// Initialize route information
const rowRoutes = await this.initRouteInfo()
if (!rowRoutes) {
const error = new Error('Failed to get route information')
window.$message.error($t(`app.getRouteError`))
throw error
}
this.rowRoutes = rowRoutes
// Generate actual route and insert
const routes = createRoutes(rowRoutes)
router.addRoute(routes)
// Generate side menu
this.menus = createMenus(rowRoutes)
// Generate the route cache
this.cacheRoutes = generateCacheRoutes(rowRoutes)
this.isInitAuthRoute = true
}
catch (error) {
// 重置状态并重新抛出错误
this.isInitAuthRoute = false
throw error
}
},
},
})

View File

@ -24,7 +24,7 @@ export const useTabStore = defineStore('tab-store', {
return
// 如果标签名称已存在则不添加
if (this.hasExistTab(route.fullPath as string))
if (this.hasExistTab(route.path as string))
return
// 根据meta.pinTab传递到不同的分组中
@ -33,42 +33,42 @@ export const useTabStore = defineStore('tab-store', {
else
this.tabs.push(route)
},
async closeTab(fullPath: string) {
async closeTab(path: string) {
const tabsLength = this.tabs.length
// 如果动态标签大于一个,才会标签跳转
if (this.tabs.length > 1) {
// 获取关闭的标签索引
const index = this.getTabIndex(fullPath)
const index = this.getTabIndex(path)
const isLast = index + 1 === tabsLength
// 如果是关闭的当前页面,路由跳转到原先标签的后一个标签
if (this.currentTabPath === fullPath && !isLast) {
if (this.currentTabPath === path && !isLast) {
// 跳转到后一个标签
router.push(this.tabs[index + 1].fullPath)
router.push(this.tabs[index + 1].path)
}
else if (this.currentTabPath === fullPath && isLast) {
else if (this.currentTabPath === path && isLast) {
// 已经是最后一个了,就跳转前一个
router.push(this.tabs[index - 1].fullPath)
router.push(this.tabs[index - 1].path)
}
}
// 删除标签
this.tabs = this.tabs.filter((item) => {
return item.fullPath !== fullPath
return item.path !== path
})
// 删除后如果清空了,就跳转到默认首页
if (tabsLength - 1 === 0)
router.push('/')
},
closeOtherTabs(fullPath: string) {
const index = this.getTabIndex(fullPath)
closeOtherTabs(path: string) {
const index = this.getTabIndex(path)
this.tabs = this.tabs.filter((item, i) => i === index)
},
closeLeftTabs(fullPath: string) {
const index = this.getTabIndex(fullPath)
closeLeftTabs(path: string) {
const index = this.getTabIndex(path)
this.tabs = this.tabs.filter((item, i) => i >= index)
},
closeRightTabs(fullPath: string) {
const index = this.getTabIndex(fullPath)
closeRightTabs(path: string) {
const index = this.getTabIndex(path)
this.tabs = this.tabs.filter((item, i) => i <= index)
},
clearAllTabs() {
@ -80,27 +80,28 @@ export const useTabStore = defineStore('tab-store', {
router.push('/')
},
hasExistTab(fullPath: string) {
hasExistTab(path: string) {
const _tabs = [...this.tabs, ...this.pinTabs]
return _tabs.some((item) => {
return item.fullPath === fullPath
return item.path === path
})
},
/* 设置当前激活的标签 */
setCurrentTab(fullPath: string) {
this.currentTabPath = fullPath
setCurrentTab(path: string) {
this.currentTabPath = path
},
getTabIndex(fullPath: string) {
getTabIndex(path: string) {
return this.tabs.findIndex((item) => {
return item.fullPath === fullPath
return item.path === path
})
},
modifyTab(fullPath: string, modifyFn: (route: RouteLocationNormalized) => void) {
const index = this.getTabIndex(fullPath)
modifyFn(this.tabs[index])
},
},
persist: {
storage: sessionStorage,
enabled: true,
strategies: [
{
storage: sessionStorage,
},
],
},
})

View File

@ -1,6 +1,5 @@
@import './reset.css';
@import './transition.css';
@import './naive.css';
html,
body,
@ -14,7 +13,3 @@ body,
.gray-mode {
filter: grayscale(100%);
}
.drag-handle {
cursor: move;
}

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