Compare commits

..

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

138 changed files with 1716 additions and 3750 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 VITE_APP_NAME=Nova - Admin
# 路由模式
# 路由模式 web hash
VITE_ROUTE_MODE = web VITE_ROUTE_MODE = web
# 权限路由模式: static dynamic
# 路由加载模式 static dynamic VITE_AUTH_ROUTE_MODE=static
VITE_ROUTE_LOAD_MODE = static
# 设置登陆后跳转地址 # 设置登陆后跳转地址
VITE_HOME_PATH = /dashboard/workbench VITE_HOME_PATH = /dashboard/workbench
# 本地存储前缀 # 本地存储前缀
VITE_STORAGE_PREFIX = VITE_STORAGE_PREFIX=
# 版权信息
VITE_COPYRIGHT_INFO = Copyright © 2024 chansee97
# 自动刷新token
VITE_AUTO_REFRESH_TOKEN = Y
# 默认多语言 enUS | zhCN
VITE_DEFAULT_LANG = enUS

1
.github/FUNDING.yml vendored Normal file
View File

@ -0,0 +1 @@
custom: ['https://cdn.jsdelivr.net/gh/chansee97/static/sponsor-wechat.png', 'https://cdn.jsdelivr.net/gh/chansee97/static/sponsor-alipay.png']

View File

@ -36,7 +36,7 @@ body:
options: options:
- label: Ensure this issue not a bug proposal. - label: Ensure this issue not a bug proposal.
required: true required: true
- label: Read the [docs](https://nova-admin-docs.pages.dev/). - label: Read the [docs](https://nova-admin-docs.netlify.app/).
required: true 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. - 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 required: true

View File

@ -39,7 +39,7 @@ body:
options: options:
- label: Ensure this issue not a feature proposal. - label: Ensure this issue not a feature proposal.
required: true required: true
- label: Read the [docs](https://nova-admin-docs.pages.dev/). - label: Read the [docs](https://nova-admin-docs.netlify.app/).
required: true 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. - 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 required: true

View File

@ -25,7 +25,7 @@ body:
options: options:
- label: Ensure this issue neither a bug report nor a feature proposal. - label: Ensure this issue neither a bug report nor a feature proposal.
required: true required: true
- label: Read the [docs](https://nova-admin-docs.pages.dev/). - label: Read the [docs](https://nova-admin-docs.netlify.app/).
required: true 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. - 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 required: true

View File

@ -15,12 +15,11 @@ jobs:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
with: with:
fetch-depth: 0 fetch-depth: 0
node-version: 20.x
- uses: actions/setup-node@v3 - uses: actions/setup-node@v3
with: 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: env:
GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}

10
.gitignore vendored
View File

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

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 // Disable the default formatter, use eslint instead
"prettier.enable": false, "prettier.enable": false,
"editor.formatOnSave": false, "editor.formatOnSave": false,
@ -9,16 +11,46 @@
}, },
// Silent the stylistic rules in you IDE, but still auto fix them // Silent the stylistic rules in you IDE, but still auto fix them
"eslint.rules.customizations": [ "eslint.rules.customizations": [
{ "rule": "style/*", "severity": "off" }, {
{ "rule": "format/*", "severity": "off" }, "rule": "style/*",
{ "rule": "*-indent", "severity": "off" }, "severity": "off"
{ "rule": "*-spacing", "severity": "off" }, },
{ "rule": "*-spaces", "severity": "off" }, {
{ "rule": "*-order", "severity": "off" }, "rule": "format/*",
{ "rule": "*-dangle", "severity": "off" }, "severity": "off"
{ "rule": "*-newline", "severity": "off" }, },
{ "rule": "*quotes", "severity": "off" }, {
{ "rule": "*semi", "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 // Enable eslint for all supported languages
"eslint.validate": [ "eslint.validate": [
@ -32,16 +64,7 @@
"json", "json",
"jsonc", "jsonc",
"yaml", "yaml",
"toml", "toml"
"xml",
"gql",
"graphql",
"astro",
"css",
"less",
"scss",
"pcss",
"postcss"
], ],
"i18n-ally.sourceLanguage": "zh_CN", "i18n-ally.sourceLanguage": "zh_CN",
"i18n-ally.displayLanguage": "zh_CN", "i18n-ally.displayLanguage": "zh_CN",
@ -51,16 +74,5 @@
"i18n-ally.localesPaths": [ "i18n-ally.localesPaths": [
"locales" "locales"
], ],
// File collapse "commentTranslate.source": "Google"
"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"
}
} }

View File

@ -5,8 +5,7 @@
<div align="center"> <div align="center">
<img src="https://img.shields.io/github/license/chansee97/nova-admin"/> <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://img.shields.io/github/stars/chansee97/nova-admin"/>
<img src="https://gitee.com/chansee97/nova-admin/badge/star.svg"/>
<img src="https://img.shields.io/github/forks/chansee97/nova-admin"/> <img src="https://img.shields.io/github/forks/chansee97/nova-admin"/>
</div> </div>
@ -19,8 +18,8 @@
[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](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 preview](https://nova-admin-site.netlify.app/)
- [Nova-Admin docs](https://nova-admin-docs.pages.dev/) - [Nova-Admin docs](https://nova-admin-docs.netlify.app/)
## Features ## Features
@ -51,7 +50,7 @@
## Interface document ## Interface document
This project uses ApiFox for interface mock, check the online documentation for more interface details This project uses ApiFox for interface mock, check the online documentation for more interface details
[online aipfox docs](https://nova-admin.apifox.cn) [online aipfox docs](https://apifox.com/apidoc/shared-2b1abeb5-6e78-425e-a4ff-d7277ae83bf0)
## Install and use ## Install and use
@ -71,13 +70,6 @@ pnpm build
``` ```
You can deploy **nova-admin** in a production environment using docker-compose.
```bash
# Build product
docker compose -f docker-compose.product.yml up --build -d
```
> The nginx.conf provided is for reference only. You can adjust it according to your own needs.
## Related projects ## Related projects
- [Nova-admin-nest](https://github.com/chansee97/nove-admin-nest) (under development) Nova-Admin supporting background project based on TS, NestJs, typeorm - [Nova-admin-nest](https://github.com/chansee97/nove-admin-nest) (under development) Nova-Admin supporting background project based on TS, NestJs, typeorm
@ -86,11 +78,11 @@ docker compose -f docker-compose.product.yml up --build -d
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. 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 | | 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/wechat.png" width=170>| | <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 ## Contribution

View File

@ -5,8 +5,7 @@
<div align="center"> <div align="center">
<img src="https://img.shields.io/github/license/chansee97/nova-admin"/> <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://img.shields.io/github/stars/chansee97/nova-admin"/>
<img src="https://gitee.com/chansee97/nova-admin/badge/star.svg"/>
<img src="https://img.shields.io/github/forks/chansee97/nova-admin"/> <img src="https://img.shields.io/github/forks/chansee97/nova-admin"/>
</div> </div>
@ -19,8 +18,8 @@
[Nova-admin](https://github.com/chansee97/nova-admin)是一个基于Vue3、Vite5、Typescript、Naive UI, 简洁干净后台管理模板,用简单的方式实现完整功能,并尽可能的考虑代码规范,易读易理解无过度封装,方便二次开发。 [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-site.netlify.app/)
- [Nova-Admin 文档](https://nova-admin-docs.pages.dev/) - [Nova-Admin 文档](https://nova-admin-docs.netlify.app/)
## 特性 ## 特性
@ -51,7 +50,7 @@
## 接口文档 ## 接口文档
本项目使用ApiFox进行接口mock查看在线文档以了解更多接口详情 本项目使用ApiFox进行接口mock查看在线文档以了解更多接口详情
[在线apifox文档](https://nova-admin.apifox.cn) [在线apifox文档](https://apifox.com/apidoc/shared-2b1abeb5-6e78-425e-a4ff-d7277ae83bf0)
## 安装使用 ## 安装使用
@ -71,13 +70,6 @@ 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/nove-admin-nest) (开发中)基于TS, NestJs, typeorm的Nova-Admin配套后台项目 - [Nova-admin-nest](https://github.com/chansee97/nove-admin-nest) (开发中)基于TS, NestJs, typeorm的Nova-Admin配套后台项目
@ -86,11 +78,11 @@ docker compose -f docker-compose.product.yml up --build -d
Nova-Admin 是完全开源免费的项目,目前仍然在优化迭代中,旨在帮助开发者更方便地进行中大型管理系统开发,有使用问题欢迎在交流群内提问。 Nova-Admin 是完全开源免费的项目,目前仍然在优化迭代中,旨在帮助开发者更方便地进行中大型管理系统开发,有使用问题欢迎在交流群内提问。
| Q群 | 微信群 | | 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>| | <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>|
> 添加微信请注明来意 > 微信群二维码失效请加我为好友
## 贡献 ## 贡献

View File

@ -1,16 +1,16 @@
import UnoCSS from '@unocss/vite' import UnoCSS from '@unocss/vite'
import vue from '@vitejs/plugin-vue' import vue from '@vitejs/plugin-vue'
import vueJsx from '@vitejs/plugin-vue-jsx' import vueJsx from '@vitejs/plugin-vue-jsx'
import VueDevTools from 'vite-plugin-vue-devtools'
import Components from 'unplugin-vue-components/vite'
import AutoImport from 'unplugin-auto-import/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' import { FileSystemIconLoader } from 'unplugin-icons/loaders'
// https://github.com/antfu/unplugin-icons // https://github.com/antfu/unplugin-icons
import IconsResolver from 'unplugin-icons/resolver' import IconsResolver from 'unplugin-icons/resolver'
import Icons from 'unplugin-icons/vite'
import { NaiveUiResolver } from 'unplugin-vue-components/resolvers'
import Components from 'unplugin-vue-components/vite'
import viteCompression from 'vite-plugin-compression'
import VueDevTools from 'vite-plugin-vue-devtools'
/** /**
* @description: vite插件配置 * @description: vite插件配置
@ -29,22 +29,7 @@ export function createVitePlugins(env: ImportMetaEnv) {
// auto import api of lib // auto import api of lib
AutoImport({ AutoImport({
imports: [ imports: ['vue', 'vue-router', 'pinia', '@vueuse/core', 'vue-i18n'],
'vue',
'vue-router',
'pinia',
'@vueuse/core',
'vue-i18n',
{
'naive-ui': [
'useDialog',
'useMessage',
'useNotification',
'useLoadingBar',
'useModal',
],
},
],
include: [ include: [
/\.[tj]sx?$/, /\.[tj]sx?$/,
/\.vue$/, /\.vue$/,

View File

@ -1,8 +0,0 @@
services:
nove-admin:
build:
context: .
dockerfile: ./docker/dockerfile.product
container_name: nove-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' import antfu from '@antfu/eslint-config'
// https://github.com/antfu/eslint-config // https://github.com/antfu/eslint-config
export default antfu( 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',
},
},
},
)

View File

@ -5,16 +5,14 @@
"close": "Closure", "close": "Closure",
"reload": "Refresh", "reload": "Refresh",
"choose": "Choose", "choose": "Choose",
"navigate": "Navigate", "navigate": "Navigate"
"inputPlaceholder": "please enter",
"selectPlaceholder": "please choose"
}, },
"app": { "app": {
"loginOut": "Login out", "loginOut": "Login out",
"loginOutContent": "Confirm to log out of current account?", "loginOutContent": "Confirm to log out of current account?",
"loginOutTitle": "Sign out", "loginOutTitle": "Sign out",
"userCenter": "Personal center", "userCenter": "Personal center",
"light": "Light", "lignt": "Light",
"dark": "Dark", "dark": "Dark",
"system": "System", "system": "System",
"backTop": "Back to top", "backTop": "Back to top",
@ -42,7 +40,6 @@
"themeSetting": "Theme settings", "themeSetting": "Theme settings",
"todos": "Todos", "todos": "Todos",
"toggleFullScreen": "Toggle full screen", "toggleFullScreen": "Toggle full screen",
"togglContentFullScreen": "Toggle content full screen",
"topProgress": "Top progress", "topProgress": "Top progress",
"transitionFadeBottom": "Bottom fade", "transitionFadeBottom": "Bottom fade",
"transitionFadeScale": "Scale fade", "transitionFadeScale": "Scale fade",
@ -60,8 +57,7 @@
"getRouteError": "Failed to obtain route, please try again later.", "getRouteError": "Failed to obtain route, please try again later.",
"layoutSetting": "Layout settings", "layoutSetting": "Layout settings",
"leftMenu": "Left menu", "leftMenu": "Left menu",
"topMenu": "Top menu", "topMenu": "Top menu"
"mixMenu": "Mix menu"
}, },
"login": { "login": {
"signInTitle": "Login", "signInTitle": "Login",
@ -89,18 +85,17 @@
"route": { "route": {
"appRoot": "Home", "appRoot": "Home",
"cardList": "Card list", "cardList": "Card list",
"draggableList": "Draggable list",
"commonList": "Common list", "commonList": "Common list",
"dashboard": "Dashboard", "dashboard": "Dashboard",
"demo": "Function example", "demo": "Function example",
"fetch": "Request example", "fetch": "Request example",
"list": "List", "list": "List",
"monitor": "Monitoring", "monitor": "Monitoring",
"multi": "Multi-level menu", "test": "Multi-level menu",
"multi2": "Multi-level menu subpage", "test2": "Multi-level menu subpage",
"multi2Detail": "Details page of multi-level menu", "test2Detail": "Details page of multi-level menu",
"multi3": "multi-level menu", "test3": "multi-level menu",
"multi4": "Multi-level menu 3-1", "test4": "Multi-level menu 3-1",
"workbench": "Workbench", "workbench": "Workbench",
"QRCode": "QR code", "QRCode": "QR code",
"about": "About", "about": "About",
@ -109,11 +104,10 @@
"demo404": "404", "demo404": "404",
"demo500": "500", "demo500": "500",
"dictionarySetting": "Dictionary settings", "dictionarySetting": "Dictionary settings",
"documents": "Document", "docments": "Document",
"documentsVite": "Vite", "docmentsVite": "Vite",
"documentsVue": "Vue", "docmentsVue": "Vue",
"documentsVueuse": "VueUse (external link)", "docmentsVueuse": "VueUse (external link)",
"documentsNova": "Nova docs",
"echarts": "Echarts", "echarts": "Echarts",
"editor": "Editor", "editor": "Editor",
"editorMd": "MarkDown editor", "editorMd": "MarkDown editor",
@ -127,9 +121,7 @@
"permissionDemo": "Permissions example", "permissionDemo": "Permissions example",
"setting": "System settings", "setting": "System settings",
"userCenter": "Personal Center", "userCenter": "Personal Center",
"accountSetting": "User settings", "accountSetting": "User settings"
"cascader": "Administrative region selection",
"dict": "Dictionary example"
}, },
"http": { "http": {
"400": "Syntax error in the request", "400": "Syntax error in the request",
@ -152,12 +144,6 @@
"searchPlaceholder": "Search icon", "searchPlaceholder": "Search icon",
"clearIcon": "Clear icon", "clearIcon": "Clear icon",
"selectorTitle": "Icon selection" "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"
} }
} }
} }

View File

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

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", "name": "nova-admin",
"type": "module", "type": "module",
"version": "0.9.12", "version": "0.9.0",
"private": true, "private": true,
"description": "a clean and concise back-end management template based on Vue3, Vite5, Typescript, and Naive UI.", "description": "",
"author": { "author": {
"name": "chansee97", "name": "chansee97",
"email": "chen.dev@foxmail.com", "email": "chen.dev@foxmail.com",
@ -40,52 +40,55 @@
"dev": "vite --mode dev --port 9980", "dev": "vite --mode dev --port 9980",
"dev:test": "vite --mode test", "dev:test": "vite --mode test",
"dev:prod": "vite --mode prod", "dev:prod": "vite --mode prod",
"build": "vite build --mode prod", "build": "vue-tsc --noEmit && vite build --mode prod",
"build:dev": "vite build --mode dev", "build:dev": "vue-tsc --noEmit && vite build --mode dev",
"build:test": "vite build --mode test", "build:test": "vue-tsc --noEmit && vite build --mode test",
"preview": "vite preview --port 9981", "preview": "vite preview --port 9981",
"lint": "eslint . && vue-tsc --noEmit", "lint": "eslint .",
"lint:fix": "eslint . --fix", "lint:fix": "eslint . --fix",
"lint:check": "npx @eslint/config-inspector", "lint:check": "npx @eslint/config-inspector",
"sizecheck": "npx vite-bundle-visualizer" "sizecheck": "npx vite-bundle-visualizer"
}, },
"dependencies": { "dependencies": {
"@vueuse/core": "^13.0.0", "@alova/scene-vue": "^1.4.6",
"alova": "^3.2.10", "@tinymce/tinymce-vue": "^5.1.1",
"@vueuse/core": "^10.9.0",
"alova": "^2.19.0",
"colord": "^2.9.3", "colord": "^2.9.3",
"echarts": "^5.6.0", "echarts": "^5.5.0",
"md-editor-v3": "^5.4.5", "md-editor-v3": "^4.11.3",
"pinia": "^3.0.1", "performant-array-to-tree": "^1.11.0",
"pinia-plugin-persistedstate": "^4.2.0", "pinia": "^2.1.7",
"quill": "^2.0.3", "pinia-plugin-persist": "^1.0.0",
"qs": "^6.12.0",
"radash": "^12.1.0", "radash": "^12.1.0",
"vue": "^3.5.13", "vue": "^3.4.21",
"vue-draggable-plus": "^0.6.0", "vue-i18n": "^9.11.1",
"vue-i18n": "^11.1.2", "vue-router": "^4.3.0"
"vue-router": "^4.5.0"
}, },
"devDependencies": { "devDependencies": {
"@antfu/eslint-config": "^4.11.0", "@antfu/eslint-config": "^2.13.3",
"@iconify-json/icon-park-outline": "^1.2.2", "@iconify-json/icon-park-outline": "^1.1.15",
"@iconify/vue": "^4.3.0", "@iconify/vue": "^4.1.1",
"@types/node": "^22.14.0", "@types/node": "^20.12.7",
"@vitejs/plugin-vue": "^5.2.3", "@types/qs": "^6.9.14",
"@vitejs/plugin-vue-jsx": "^4.1.2", "@vitejs/plugin-vue": "^5.0.4",
"eslint": "^9.24.0", "@vitejs/plugin-vue-jsx": "^3.1.0",
"lint-staged": "^15.5.0", "eslint": "^9.0.0",
"naive-ui": "^2.41.0", "lint-staged": "^15.2.2",
"sass": "^1.86.3", "naive-ui": "^2.38.1",
"simple-git-hooks": "^2.12.1", "sass": "^1.75.0",
"typescript": "^5.8.3", "simple-git-hooks": "^2.11.1",
"unocss": "^0.65.4", "typescript": "^5.4.5",
"unplugin-auto-import": "^19.1.2", "unocss": "^0.59.1",
"unplugin-icons": "^22.1.0", "unplugin-auto-import": "^0.17.5",
"unplugin-vue-components": "^28.4.1", "unplugin-icons": "^0.18.5",
"vite": "^6.2.5", "unplugin-vue-components": "^0.26.0",
"vite-bundle-visualizer": "^1.2.1", "vite": "^5.2.8",
"vite-bundle-visualizer": "^1.1.0",
"vite-plugin-compression": "^0.5.1", "vite-plugin-compression": "^0.5.1",
"vite-plugin-vue-devtools": "7.7.2", "vite-plugin-vue-devtools": "7.1.2",
"vue-tsc": "^2.2.8" "vue-tsc": "^2.0.12"
}, },
"simple-git-hooks": { "simple-git-hooks": {
"pre-commit": "pnpm lint-staged" "pre-commit": "pnpm lint-staged"

View File

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

View File

@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { naiveI18nOptions } from '@/utils'
import { darkTheme } from 'naive-ui' import { darkTheme } from 'naive-ui'
import { useAppStore } from './store' import { useAppStore } from './store'
import { naiveI18nOptions } from '@/utils'
const appStore = useAppStore() const appStore = useAppStore()

View File

@ -2,7 +2,6 @@
</script> </script>
<template> <template>
<naive-provider>
<div id="loading-container"> <div id="loading-container">
<div class="boxes"> <div class="boxes">
<div class="box"> <div class="box">
@ -31,7 +30,6 @@
</div> </div>
</div> </div>
</div> </div>
</naive-provider>
</template> </template>
<style scoped> <style scoped>

View File

@ -1,9 +1,9 @@
<script setup lang="ts"> <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 { 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() const { t } = useI18n()
@ -12,7 +12,7 @@ const appStore = useAppStore()
const options = computed(() => { const options = computed(() => {
return [ return [
{ {
label: t('app.light'), label: t('app.lignt'),
value: 'light', value: 'light',
icon: IconSun, icon: IconSun,
}, },

View File

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

View File

@ -1,13 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { mapEntries } from 'radash'
interface Props { interface Props {
disabled?: boolean disabled?: boolean
} }
const { const props = withDefaults(defineProps<Props>(), {
disabled = false, disabled: false,
} = defineProps<Props>() })
interface IconList { interface IconList {
prefix: string prefix: string
@ -39,36 +37,11 @@ async function fetchIconAllList(nameList: string[]) {
return i return i
}) })
} }
// svg
function getSvgName(path: string) {
const regex = /\/([^/]+)\.svg$/
const match = path.match(regex)
if (match) {
const fileName = match[1]
return fileName
}
return path
}
// const iconLists = shallowRef<IconList[]>([])
function generateLocalIconList() {
const localSvgList = import.meta.glob('@/assets/svg-icons/*.svg', {
query: '?raw',
import: 'default',
eager: true,
})
return mapEntries(localSvgList, (key, value) => {
return [getSvgName(key), value]
})
}
const iconList = shallowRef<IconList[]>([])
const LocalIconList = shallowRef({})
onMounted(async () => { onMounted(async () => {
iconList.value = await fetchIconAllList(nameList) iconLists.value = await fetchIconAllList(nameList)
LocalIconList.value = generateLocalIconList()
}) })
// tab // tab
@ -97,15 +70,16 @@ function handleSelectIconTag(icon: string) {
const icons = computed(() => { const icons = computed(() => {
const hasTag = !!currentTag.value const hasTag = !!currentTag.value
if (hasTag) if (hasTag)
return iconList.value[currentTab.value]?.categories[currentTag.value] return iconLists.value[currentTab.value]?.categories[currentTag.value]
else else
return iconList.value[currentTab.value].icons return iconLists.value[currentTab.value].icons
}) })
// //
const visibleIcons = computed(() => { const visibleIcons = computed(() => {
return icons.value?.filter(i => i return icons.value
.includes(searchValue.value))?.slice((currentPage.value - 1) * 200, (currentPage.value) * 200) ?.filter(i => i.includes(searchValue.value))
?.slice((currentPage.value - 1) * 200, (currentPage.value) * 200)
}) })
const showModal = ref(false) const showModal = ref(false)
@ -125,13 +99,13 @@ function clearIcon() {
<template> <template>
<n-input-group disabled> <n-input-group disabled>
<n-button v-if="value" :disabled="disabled" type="primary"> <n-button v-if="value" :disabled="props.disabled" type="primary">
<template #icon> <template #icon>
<nova-icon :icon="value" /> <nova-icon :icon="value" />
</template> </template>
</n-button> </n-button>
<n-input :value="value" readonly :placeholder="$t('components.iconSelector.inputPlaceholder')" /> <n-input :value="value" readonly :placeholder="$t('components.iconSelector.inputPlaceholder')" />
<n-button type="primary" ghost :disabled="disabled" @click="showModal = true"> <n-button type="primary" ghost :disabled="props.disabled" @click="showModal = true">
{{ $t('common.choose') }} {{ $t('common.choose') }}
</n-button> </n-button>
</n-input-group> </n-input-group>
@ -145,19 +119,7 @@ function clearIcon() {
</template> </template>
<n-tabs :value="currentTab" type="line" animated placement="left" @update:value="handleChangeTab"> <n-tabs :value="currentTab" type="line" animated placement="left" @update:value="handleChangeTab">
<n-tab-pane name="local" tab="local"> <n-tab-pane v-for="(list, index) in iconLists" :key="list.prefix" :name="index" :tab="list.title">
<n-flex :size="2">
<n-el
v-for="(_icon, key) in LocalIconList" :key="key"
class="hover:(text-[var(--primary-color)] ring-1) ring-[var(--primary-color)] p-1 rounded flex-center"
:title="`local:${key}`"
@click="handleSelectIcon(`local:${key}`)"
>
<nova-icon :icon="`local:${key}`" :size="24" />
</n-el>
</n-flex>
</n-tab-pane>
<n-tab-pane v-for="(list, index) in iconList" :key="list.prefix" :name="index" :tab="list.title">
<n-flex vertical> <n-flex vertical>
<n-flex size="small"> <n-flex size="small">
<n-tag <n-tag
@ -174,7 +136,7 @@ function clearIcon() {
:placeholder="$t('components.iconSelector.searchPlaceholder')" :placeholder="$t('components.iconSelector.searchPlaceholder')"
/> />
<div> <div class="h-410px">
<n-flex :size="2"> <n-flex :size="2">
<n-el <n-el
v-for="(icon) in visibleIcons" :key="icon" v-for="(icon) in visibleIcons" :key="icon"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,107 +1,77 @@
<script setup lang="ts"> <script setup lang="ts">
import Quill from 'quill' import Editor from '@tinymce/tinymce-vue'
import { useTemplateRef } from 'vue'
import 'quill/dist/quill.snow.css'
defineOptions({ const props = defineProps<{
name: 'RichTextEditor', modelValue: string
}) }>()
const { disabled } = defineProps<Props>() const emit = defineEmits(['update:modelValue'])
interface Props {
disabled?: boolean
}
const model = defineModel<string>()
let editorInst = null const data = useVModel(props, 'modelValue', emit)
const editorModel = ref<string>() function imagesUploadHandler(blobInfo: any, _progress: number) {
return new Promise((resolve, reject) => {
onMounted(() => { const formData = new FormData()
initEditor() formData.append('file', blobInfo.blob())
}) fetch('www.example.com/upload', {
method: 'POST',
const editorRef = useTemplateRef<HTMLElement>('editorRef') body: formData,
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()
}) })
.then((response) => {
if (!response.ok)
throw new Error('上传失败')
if (disabled) //
quill.enable(false) resolve('上传成功')
})
editorInst = quill .catch((error) => {
//
if (model.value) reject(error)
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> </script>
<template> <template>
<div ref="editorRef" /> <div class="tinymce-boxz">
<Editor
v-model="data"
api-key="no-api"
:init="initConfig"
/>
</div>
</template> </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 */ /** Gender */
export enum Gender { export enum Gender {
male, male,

View File

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

View File

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

View File

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

View File

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

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,3 +1,4 @@
import { isArray, isString } from 'radash'
import { useAuthStore } from '@/store' import { useAuthStore } from '@/store'
/** 权限判断 */ /** 权限判断 */
@ -5,7 +6,7 @@ export function usePermission() {
const authStore = useAuthStore() const authStore = useAuthStore()
function hasPermission( function hasPermission(
permission?: Entity.RoleType[], permission: Auth.RoleType | Auth.RoleType[] | undefined,
) { ) {
if (!permission) if (!permission)
return true return true
@ -14,9 +15,13 @@ export function usePermission() {
return false return false
const { role } = authStore.userInfo const { role } = authStore.userInfo
let has = role.includes('super') let has = role === 'super'
if (!has) { if (!has) {
has = permission.every(i => role.includes(i)) if (isArray(permission))
has = permission.includes(role)
if (isString(permission))
has = permission === role
} }
return has return has
} }

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

@ -12,10 +12,11 @@ const value = defineModel<LayoutMode>('value', { required: true })
:class="{ :class="{
'outline outline-2': value === 'leftMenu', 'outline outline-2': value === 'leftMenu',
}" }"
class="grid grid-cols-[20%_1fr] outline-[var(--primary-color)] hover:(outline outline-2) cursor-pointer" class="grid grid-cols-[20%_1fr] grid-rows-[20%_1fr] outline-[var(--primary-color)] hover:(outline outline-2)"
@click="value = 'leftMenu'" @click="value = 'leftMenu'"
> >
<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)]" /> <div class="bg-[var(--divider-color)]" />
</n-el> </n-el>
</template> </template>
@ -28,7 +29,7 @@ const value = defineModel<LayoutMode>('value', { required: true })
:class="{ :class="{
'outline outline-2': value === 'topMenu', 'outline outline-2': value === 'topMenu',
}" }"
class="grid grid-rows-[30%_1fr] outline-[var(--primary-color)] hover:(outline outline-2) cursor-pointer" class="grid grid-rows-[30%_1fr] outline-[var(--primary-color)] hover:(outline outline-2)"
@click="value = 'topMenu'" @click="value = 'topMenu'"
> >
<div class="bg-[var(--primary-color)]" /> <div class="bg-[var(--primary-color)]" />
@ -37,23 +38,6 @@ const value = defineModel<LayoutMode>('value', { required: true })
</template> </template>
<span> {{ $t('app.topMenu') }} </span> <span> {{ $t('app.topMenu') }} </span>
</n-tooltip> </n-tooltip>
<n-tooltip placement="bottom" trigger="hover">
<template #trigger>
<n-el
:class="{
'outline outline-2': value === 'mixMenu',
}"
class="grid grid-cols-[20%_1fr] grid-rows-[20%_1fr] outline-[var(--primary-color)] hover:(outline outline-2) cursor-pointer"
@click="value = 'mixMenu'"
>
<div class="bg-[var(--primary-color)] row-span-2" />
<div class="bg-[var(--primary-color)]" />
<div class="bg-[var(--divider-color)]" />
</n-el>
</template>
<span> {{ $t('app.mixMenu') }} </span>
</n-tooltip>
</div> </div>
</template> </template>

View File

@ -1,8 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
interface Props { interface Props {
list?: Entity.Message[] list?: Message.List[]
} }
const { list } = defineProps<Props>() const props = defineProps<Props>()
const emit = defineEmits<Emits>() const emit = defineEmits<Emits>()
interface Emits { interface Emits {
@ -13,7 +13,7 @@ interface Emits {
<template> <template>
<n-scrollbar style="height: 400px"> <n-scrollbar style="height: 400px">
<n-list hoverable clickable> <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 }"> <n-thing content-indented :class="{ 'opacity-30': item.isRead }">
<template #header> <template #header>
<n-ellipsis :line-clamp="1"> <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' import { useAppStore } from '@/store'
const appStore = useAppStore() const appStore = useAppStore()
useMagicKeys({
passive: false,
onEventFired(e) {
if (e.key === 'F11' && e.type === 'keydown') {
e.preventDefault()
appStore.toggleFullScreen()
}
},
})
</script> </script>
<template> <template>
<n-tooltip placement="bottom" trigger="hover"> <n-tooltip placement="bottom" trigger="hover">
<template #trigger> <template #trigger>
<CommonWrapper @click="appStore.toggleFullScreen"> <CommonWrapper @click="appStore.toggleFullScreen()">
<icon-park-outline-off-screen v-if="appStore.fullScreen" /> <icon-park-outline-off-screen-two v-if="appStore.fullScreen" />
<icon-park-outline-full-screen v-else /> <icon-park-outline-full-screen-two v-else />
</CommonWrapper> </CommonWrapper>
</template> </template>
<span>{{ $t('app.toggleFullScreen') }}</span> <span>{{ $t('app.toggleFullScreen') }}</span>
</n-tooltip> </n-tooltip>
</template> </template>
<style scoped></style>

View File

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

View File

@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { useBoolean } from '@/hooks'
import { useRouteStore } from '@/store' import { useRouteStore } from '@/store'
import { useBoolean } from '@/hooks'
const routeStore = useRouteStore() const routeStore = useRouteStore()
@ -12,9 +12,6 @@ const selectedIndex = ref<number>(0)
const { bool: showModal, setTrue: openModal, setFalse: closeModal, toggle: toggleModal } = useBoolean(false) 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({ const { ctrl_k, arrowup, arrowdown, enter/* keys you want to monitor */ } = useMagicKeys({
passive: false, passive: false,
onEventFired(e) { onEventFired(e) {
@ -38,15 +35,15 @@ const options = computed(() => {
return routeStore.rowRoutes.filter((item) => { return routeStore.rowRoutes.filter((item) => {
const conditions = [ 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), item.path?.includes(searchValue.value),
] ]
return conditions.some(condition => !item.hide && condition) return conditions.some(condition => !item['meta.hide'] && condition)
}).map((item) => { }).map((item) => {
return { 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, value: item.path,
icon: item.icon, icon: item['meta.icon'],
} }
}) })
}) })
@ -79,8 +76,6 @@ watchEffect(() => {
if (!showModal.value || !options.value.length) if (!showModal.value || !options.value.length)
return return
// mouseover
setKeyboardTrue()
if (arrowup.value) if (arrowup.value)
handleArrowup() handleArrowup()
@ -101,7 +96,11 @@ function handleArrowup() {
else else
selectedIndex.value-- selectedIndex.value--
handleScroll(selectedIndex.value) nextTick(() => {
scrollbarRef.value?.scrollTo({
top: selectedIndex.value * 70,
})
})
} }
// //
@ -112,33 +111,19 @@ function handleArrowdown() {
else else
selectedIndex.value++ selectedIndex.value++
handleScroll(selectedIndex.value) nextTick(() => {
}
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({ scrollbarRef.value?.scrollTo({
top: distance, top: selectedIndex.value * 70,
})
}) })
} }
// //
function handleEnter() { function handleEnter() {
const target = options.value[selectedIndex.value] const target = options.value[selectedIndex.value]
if (target) if (target)
handleSelect(target.value) handleSelect(target.value)
} }
//
function handleMouseEnter(index: number) {
if (keyboardFlag.value)
return
selectedIndex.value = index
}
</script> </script>
<template> <template>
@ -149,7 +134,7 @@ function handleMouseEnter(index: number) {
</CommonWrapper> </CommonWrapper>
<n-modal <n-modal
v-model:show="showModal" v-model:show="showModal"
class="w-560px fixed top-60px inset-x-0" class="w-560px fixed top-100px inset-x-0"
size="small" size="small"
preset="card" preset="card"
:segmented="{ :segmented="{
@ -168,10 +153,10 @@ function handleMouseEnter(index: number) {
</template> </template>
</n-input> </n-input>
</template> </template>
<n-scrollbar ref="scrollbarRef" class="h-450px"> <n-scrollbar ref="scrollbarRef" class="h-600px">
<ul <ul
v-if="options.length" v-if="options.length"
class="flex flex-col gap-8px p-8px p-r-3" class="flex flex-col gap-8px p-1 p-r-3"
> >
<n-el <n-el
v-for="(option, index) in options" v-for="(option, index) in options"
@ -179,13 +164,10 @@ function handleMouseEnter(index: number) {
class="cursor-pointer shadow h-62px" class="cursor-pointer shadow h-62px"
:class="{ 'text-[var(--base-color)] bg-[var(--primary-color-hover)]': index === selectedIndex }" :class="{ 'text-[var(--base-color)] bg-[var(--primary-color-hover)]': index === selectedIndex }"
@click="handleSelect(option.value)" @click="handleSelect(option.value)"
@mouseenter="handleMouseEnter(index)" @mouseover.stop="selectedIndex = index"
@mousemove="setKeyboardFalse"
> >
<div class="grid grid-rows-2 grid-cols-[40px_1fr_30px] h-full p-2"> <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" class="row-span-2 place-self-center" />
<nova-icon :icon="option.icon" />
</div>
<span>{{ option.label }}</span> <span>{{ option.label }}</span>
<icon-park-outline-right class="row-span-2 place-self-center" /> <icon-park-outline-right class="row-span-2 place-self-center" />
<span class="op-70">{{ option.value }}</span> <span class="op-70">{{ option.value }}</span>
@ -193,7 +175,7 @@ function handleMouseEnter(index: number) {
</n-el> </n-el>
</ul> </ul>
<n-empty v-else size="large" class="h-450px flex-center" /> <n-empty v-else size="large" class="h-600px flex-center" />
</n-scrollbar> </n-scrollbar>
<template #footer> <template #footer>

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

View File

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

View File

@ -1,28 +1,19 @@
<script setup lang="ts"> <script setup lang="ts">
import type { MenuInst } from 'naive-ui' import { useAppStore } from '@/store'
import { useAppStore, useRouteStore } from '@/store' import { useRouteStore } from '@/store/route'
const route = useRoute()
const appStore = useAppStore() const appStore = useAppStore()
const routeStore = useRouteStore() const routesStore = useRouteStore()
const menuInstRef = ref<MenuInst | null>(null)
watch(
() => route.path,
() => {
menuInstRef.value?.showOption(routeStore.activeMenu as string)
},
{ immediate: true },
)
</script> </script>
<template> <template>
<n-menu <n-menu
ref="menuInstRef"
:collapsed="appStore.collapsed" :collapsed="appStore.collapsed"
:indent="20" :indent="20"
:collapsed-width="64" :collapsed-width="64"
:options="routeStore.menus" :options="routesStore.menus"
:value="routeStore.activeMenu" :value="routesStore.activeMenu"
/> />
</template> </template>
<style scoped></style>

View File

@ -1,17 +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.contentFullScreen = !appStore.contentFullScreen">
<icon-park-outline-off-screen-one v-if="appStore.contentFullScreen" />
<icon-park-outline-full-screen-one v-else />
</CommonWrapper>
</template>
<span>{{ $t('app.togglContentFullScreen') }}</span>
</n-tooltip>
</template>

View File

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

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,19 +1,15 @@
<script setup lang="ts"> <script setup lang="ts">
import { useAppStore } from '@/store/app'
import { SettingDrawer } from './components'
import leftMenu from './leftMenu.layout.vue' import leftMenu from './leftMenu.layout.vue'
import mixMenu from './mixMenu.layout.vue'
import topMenu from './topMenu.layout.vue' import topMenu from './topMenu.layout.vue'
import { useAppStore } from '@/store/app'
const appStore = useAppStore() const appStore = useAppStore()
const layoutMap = { const layoutMap = {
leftMenu, leftMenu,
topMenu, topMenu,
mixMenu,
} }
</script> </script>
<template> <template>
<SettingDrawer />
<component :is="layoutMap[appStore.layoutMode]" /> <component :is="layoutMap[appStore.layoutMode]" />
</template> </template>

View File

@ -1,5 +1,4 @@
<script lang="ts" setup> <script lang="ts" setup>
import { useAppStore, useRouteStore } from '@/store'
import { import {
BackTop, BackTop,
Breadcrumb, Breadcrumb,
@ -13,6 +12,7 @@ import {
TabBar, TabBar,
UserCenter, UserCenter,
} from './components' } from './components'
import { useAppStore, useRouteStore } from '@/store'
const routeStore = useRouteStore() const routeStore = useRouteStore()
const appStore = useAppStore() const appStore = useAppStore()
@ -25,7 +25,6 @@ const appStore = useAppStore()
embedded embedded
> >
<n-layout-sider <n-layout-sider
v-if="!appStore.contentFullScreen"
bordered bordered
:collapsed="appStore.collapsed" :collapsed="appStore.collapsed"
collapse-mode="width" collapse-mode="width"
@ -44,8 +43,8 @@ const appStore = useAppStore()
embedded embedded
:native-scrollbar="false" :native-scrollbar="false"
> >
<n-layout-header bordered position="absolute" class="z-999"> <n-layout-header bordered position="absolute" class="z-1">
<div v-if="!appStore.contentFullScreen" class="h-60px flex-y-center justify-between"> <div class="h-60px flex-y-center justify-between">
<div class="flex-y-center h-full"> <div class="flex-y-center h-full">
<CollapaseButton /> <CollapaseButton />
<Breadcrumb /> <Breadcrumb />
@ -62,17 +61,9 @@ const appStore = useAppStore()
</div> </div>
<TabBar v-if="appStore.showTabs" class="h-45px" /> <TabBar v-if="appStore.showTabs" class="h-45px" />
</n-layout-header> </n-layout-header>
<!-- 121 = 16 + 45 + 60 45是面包屑高度 60是标签栏高度 --> <div class="flex-1 p-16px flex flex-col">
<!-- 56 = 16 + 40 40是页脚高度 --> <div class="h-60px" />
<div <div v-if="appStore.showTabs" class="h-45px" />
class="flex-1 p-16px flex flex-col"
:class="{
'p-t-121px': appStore.showTabs,
'p-b-56px': appStore.showFooter && !appStore.contentFullScreen,
'p-t-76px': !appStore.showTabs,
'p-t-61px': appStore.contentFullScreen,
}"
>
<router-view v-slot="{ Component, route }" class="flex-1"> <router-view v-slot="{ Component, route }" class="flex-1">
<transition <transition
:name="appStore.transitionAnimation" :name="appStore.transitionAnimation"
@ -87,9 +78,10 @@ const appStore = useAppStore()
</keep-alive> </keep-alive>
</transition> </transition>
</router-view> </router-view>
<div v-if="appStore.showFooter" class="h-40px" />
</div> </div>
<n-layout-footer <n-layout-footer
v-if="appStore.showFooter && !appStore.contentFullScreen" v-if="appStore.showFooter"
bordered bordered
position="absolute" position="absolute"
class="h-40px flex-center" class="h-40px flex-center"

View File

@ -1,160 +0,0 @@
<script lang="ts" setup>
import type { MenuInst, MenuOption } from 'naive-ui'
import { useAppStore, useRouteStore } from '@/store'
import {
BackTop,
CollapaseButton,
FullScreen,
Logo,
Notices,
Search,
Setting,
TabBar,
UserCenter,
} from './components'
const routeStore = useRouteStore()
const appStore = useAppStore()
const pageRoute = useRoute()
const router = useRouter()
const menuInstRef = ref<MenuInst | null>(null)
watch(
() => pageRoute.path,
() => {
menuInstRef.value?.showOption(routeStore.activeMenu as string)
},
{ immediate: true },
)
const topMenu = ref<MenuOption[]>([])
const activeTopMenu = ref<string>('')
function handleTopMenu(rowMenu: MenuOption[]) {
topMenu.value = rowMenu.map((i) => {
const { icon, label, key } = i
return {
icon,
label,
key,
}
})
}
onMounted(() => {
handleTopMenu(routeStore.menus)
//
const currentMenuKey = pageRoute.matched[1].path
handleSideMenu(currentMenuKey)
activeTopMenu.value = currentMenuKey
})
const sideMenu = ref<MenuOption[]>([])
function handleSideMenu(key: string) {
const routeMenu = routeStore.menus as MenuOption[]
const targetMenu = routeMenu.find(i => i.key === key)
if (targetMenu) {
sideMenu.value = targetMenu.children ? targetMenu.children : [targetMenu]
}
}
function updateTopMenu(key: string) {
handleSideMenu(key)
activeTopMenu.value = key
router.push(key)
}
</script>
<template>
<n-layout
has-sider
class="wh-full"
embedded
>
<n-layout-sider
v-if="!appStore.contentFullScreen"
bordered
:collapsed="appStore.collapsed"
collapse-mode="width"
:collapsed-width="64"
:width="240"
content-style="display: flex;flex-direction: column;min-height:100%;"
>
<Logo v-if="appStore.showLogo" />
<n-scrollbar class="flex-1">
<n-menu
ref="menuInstRef"
:collapsed="appStore.collapsed"
:indent="20"
:collapsed-width="64"
:options="sideMenu"
:value="routeStore.activeMenu"
/>
</n-scrollbar>
</n-layout-sider>
<n-layout
class="h-full flex flex-col"
content-style="display: flex;flex-direction: column;min-height:100%;"
embedded
:native-scrollbar="false"
>
<n-layout-header bordered position="absolute" class="z-999">
<div v-if="!appStore.contentFullScreen" class="h-60px flex-y-center justify-between">
<CollapaseButton />
<n-menu
ref="menuInstRef"
mode="horizontal"
responsive
:options="topMenu"
:value="activeTopMenu"
@update:value="updateTopMenu"
/>
<div class="flex-y-center gap-1 h-full p-x-xl">
<Search />
<Notices />
<FullScreen />
<DarkModeSwitch />
<LangsSwitch />
<Setting />
<UserCenter />
</div>
</div>
<TabBar v-if="appStore.showTabs" class="h-45px" />
</n-layout-header>
<div
class="flex-1 p-16px flex flex-col"
:class="{
'p-t-121px': appStore.showTabs,
'p-b-56px': appStore.showFooter && !appStore.contentFullScreen,
'p-t-76px': !appStore.showTabs,
'p-t-61px': appStore.contentFullScreen,
}"
>
<router-view v-slot="{ Component, route }" class="flex-1">
<transition
:name="appStore.transitionAnimation"
mode="out-in"
>
<keep-alive :include="routeStore.cacheRoutes">
<component
:is="Component"
v-if="appStore.loadFlag"
:key="route.fullPath"
/>
</keep-alive>
</transition>
</router-view>
</div>
<n-layout-footer
v-if="appStore.showFooter && !appStore.contentFullScreen"
bordered
position="absolute"
class="h-40px flex-center"
>
{{ appStore.footerText }}
</n-layout-footer>
<BackTop />
</n-layout>
</n-layout>
</template>

View File

@ -1,5 +1,4 @@
<script lang="ts" setup> <script lang="ts" setup>
import { useAppStore, useRouteStore } from '@/store'
import { import {
BackTop, BackTop,
FullScreen, FullScreen,
@ -11,6 +10,7 @@ import {
TabBar, TabBar,
UserCenter, UserCenter,
} from './components' } from './components'
import { useAppStore, useRouteStore } from '@/store'
const routeStore = useRouteStore() const routeStore = useRouteStore()
const appStore = useAppStore() const appStore = useAppStore()
@ -22,8 +22,8 @@ const appStore = useAppStore()
class="h-full flex flex-col" content-style="display: flex;flex-direction: column;min-height:100%;" class="h-full flex flex-col" content-style="display: flex;flex-direction: column;min-height:100%;"
embedded :native-scrollbar="false" embedded :native-scrollbar="false"
> >
<n-layout-header bordered position="absolute" class="z-999"> <n-layout-header bordered position="absolute" class="z-1">
<div v-if="!appStore.contentFullScreen" class="h-60px flex-y-center justify-between shrink-0"> <div class="h-60px flex-y-center justify-between shrink-0">
<Logo v-if="appStore.showLogo" /> <Logo v-if="appStore.showLogo" />
<Menu mode="horizontal" responsive /> <Menu mode="horizontal" responsive />
<div class="flex-y-center gap-1 h-full p-x-xl"> <div class="flex-y-center gap-1 h-full p-x-xl">
@ -38,15 +38,9 @@ const appStore = useAppStore()
</div> </div>
<TabBar v-if="appStore.showTabs" class="h-45px" /> <TabBar v-if="appStore.showTabs" class="h-45px" />
</n-layout-header> </n-layout-header>
<div <div class="flex-1 p-16px flex flex-col">
class="flex-1 p-16px flex flex-col" <div class="h-60px" />
:class="{ <div v-if="appStore.showTabs" class="h-45px" />
'p-t-121px': appStore.showTabs,
'p-b-56px': appStore.showFooter && !appStore.contentFullScreen,
'p-t-76px': !appStore.showTabs,
'p-t-61px': appStore.contentFullScreen,
}"
>
<router-view v-slot="{ Component, route }" class="flex-1"> <router-view v-slot="{ Component, route }" class="flex-1">
<transition :name="appStore.transitionAnimation" mode="out-in"> <transition :name="appStore.transitionAnimation" mode="out-in">
<keep-alive :include="routeStore.cacheRoutes"> <keep-alive :include="routeStore.cacheRoutes">
@ -54,11 +48,9 @@ const appStore = useAppStore()
</keep-alive> </keep-alive>
</transition> </transition>
</router-view> </router-view>
<div v-if="appStore.showFooter" class="h-40px" />
</div> </div>
<n-layout-footer <n-layout-footer v-if="appStore.showFooter" bordered position="absolute" class="h-40px flex-center">
v-if="appStore.showFooter && !appStore.contentFullScreen"
bordered position="absolute" class="h-40px flex-center"
>
{{ appStore.footerText }} {{ appStore.footerText }}
</n-layout-footer> </n-layout-footer>
<BackTop /> <BackTop />

View File

@ -1,8 +1,8 @@
import type { App } from 'vue' import type { App } from 'vue'
import { installRouter } from '@/router'
import { installPinia } from '@/store'
import AppVue from './App.vue' import AppVue from './App.vue'
import AppLoading from './components/common/AppLoading.vue' import AppLoading from './components/common/AppLoading.vue'
import { installRouter } from '@/router'
import { installPinia } from '@/store'
async function setupApp() { async function setupApp() {
// 载入全局loading加载状态 // 载入全局loading加载状态

View File

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

View File

@ -61,7 +61,7 @@ export function setupRouterGuard(router: Router) {
// 添加tabs // 添加tabs
tabStore.addTab(to) tabStore.addTab(to)
// 设置高亮标签; // 设置高亮标签;
tabStore.setCurrentTab(to.fullPath as string) tabStore.setCurrentTab(to.path as string)
}) })
router.afterEach((to) => { router.afterEach((to) => {

View File

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

View File

@ -6,6 +6,7 @@ export const routes: RouteRecordRaw[] = [
path: '/', path: '/',
name: 'root', name: 'root',
redirect: '/appRoot', redirect: '/appRoot',
// component: () => import('@/layouts/index'),
children: [ children: [
], ],
}, },

View File

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

View File

@ -5,15 +5,15 @@ interface Ilogin {
password: string password: string
} }
export function fetchLogin(data: Ilogin) { export function fetchLogin(params: Ilogin) {
const methodInstance = request.Post<Service.ResponseResult<Api.Login.Info>>('/login', data) const methodInstance = request.Post<Service.ResponseResult<ApiAuth.loginInfo>>('/login', params)
methodInstance.meta = { methodInstance.meta = {
authRole: null, authRole: null,
} }
return methodInstance return methodInstance
} }
export function fetchUpdateToken(data: any) { 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 = { method.meta = {
authRole: 'refreshToken', authRole: 'refreshToken',
} }

View File

@ -1,26 +1,5 @@
import { request } from '../http' import { request } from '../http'
// 获取所有路由信息
export function fetchAllRoutes() { export function fetchAllRoutes() {
return request.Get<Service.ResponseResult<AppRoute.RowRoute[]>>('/getUserRoutes') 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 })
} }

View File

@ -36,7 +36,7 @@ export function withoutToken() {
/* 接口数据转换 */ /* 接口数据转换 */
export function dictData() { export function dictData() {
return request.Get('/getDictData', { return request.Get('/getDictData', {
transform(rawData, _headers) { transformData(rawData, _headers) {
const response = rawData as any const response = rawData as any
return { return {
...response, ...response,
@ -61,7 +61,10 @@ export function getBlob(url: string) {
/* 带进度的下载文件 */ /* 带进度的下载文件 */
export function downloadFile(url: string) { export function downloadFile(url: string) {
const methodInstance = blankInstance.Get<Blob>(url) const methodInstance = blankInstance.Get<Blob>(url, {
// 开启下载进度
enableDownload: true,
})
methodInstance.meta = { methodInstance.meta = {
// 标识为blob数据 // 标识为blob数据
isBlob: true, isBlob: true,

View File

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

View File

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

View File

@ -1,6 +1,6 @@
import { generateProxyPattern } from '@/../build/proxy'
import { serviceConfig } from '@/../service.config'
import { createAlovaInstance } from './alova' 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 isHttpProxy = import.meta.env.DEV && import.meta.env.VITE_HTTP_PROXY === 'Y'

View File

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

View File

@ -1,13 +1,11 @@
import type { GlobalThemeOverrides } from 'naive-ui' import type { GlobalThemeOverrides } from 'naive-ui'
import { local, setLocale } from '@/utils'
import { colord } from 'colord' import { colord } from 'colord'
import { set } from 'radash' import { set } from 'radash'
import themeConfig from './theme.json' import themeConfig from './theme.json'
import { local, setLocale } from '@/utils'
export type TransitionAnimation = '' | 'fade-slide' | 'fade-bottom' | 'fade-scale' | 'zoom-fade' | 'zoom-out' type TransitionAnimation = '' | 'fade-slide' | 'fade-bottom' | 'fade-scale' | 'zoom-fade' | 'zoom-out'
export type LayoutMode = 'leftMenu' | 'topMenu' | 'mixMenu' export type LayoutMode = 'leftMenu' | 'topMenu'
const { VITE_DEFAULT_LANG, VITE_COPYRIGHT_INFO } = import.meta.env
const docEle = ref(document.documentElement) const docEle = ref(document.documentElement)
@ -20,8 +18,8 @@ const { system, store } = useColorMode({
export const useAppStore = defineStore('app-store', { export const useAppStore = defineStore('app-store', {
state: () => { state: () => {
return { return {
footerText: VITE_COPYRIGHT_INFO, footerText: 'Copyright © 2024 chansee97',
lang: VITE_DEFAULT_LANG, lang: 'enUS' as App.lang,
theme: themeConfig as GlobalThemeOverrides, theme: themeConfig as GlobalThemeOverrides,
primaryColor: themeConfig.common.primaryColor, primaryColor: themeConfig.common.primaryColor,
collapsed: false, collapsed: false,
@ -35,10 +33,8 @@ export const useAppStore = defineStore('app-store', {
showBreadcrumb: true, showBreadcrumb: true,
showBreadcrumbIcon: true, showBreadcrumbIcon: true,
showWatermark: false, showWatermark: false,
showSetting: false,
transitionAnimation: 'fade-slide' as TransitionAnimation, transitionAnimation: 'fade-slide' as TransitionAnimation,
layoutMode: 'leftMenu' as LayoutMode, layoutMode: 'leftMenu' as LayoutMode,
contentFullScreen: false,
} }
}, },
getters: { getters: {
@ -63,13 +59,13 @@ export const useAppStore = defineStore('app-store', {
this.loadFlag = true this.loadFlag = true
this.showLogo = true this.showLogo = true
this.showTabs = true this.showTabs = true
this.showLogo = true
this.showFooter = true this.showFooter = true
this.showBreadcrumb = true this.showBreadcrumb = true
this.showBreadcrumbIcon = true this.showBreadcrumbIcon = true
this.showWatermark = false this.showWatermark = false
this.transitionAnimation = 'fade-slide' this.transitionAnimation = 'fade-slide'
this.layoutMode = 'leftMenu' this.layoutMode = 'leftMenu'
this.contentFullScreen = false
// 重置所有配色 // 重置所有配色
this.setPrimaryColor(this.primaryColor) this.setPrimaryColor(this.primaryColor)
@ -81,7 +77,7 @@ export const useAppStore = defineStore('app-store', {
}, },
/* 设置主题色 */ /* 设置主题色 */
setPrimaryColor(color: string) { 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() const darkenColor = colord(color).darken(0.05).toHex()
set(this.theme, 'common.primaryColor', color) set(this.theme, 'common.primaryColor', color)
set(this.theme, 'common.primaryColorHover', brightenColor) set(this.theme, 'common.primaryColorHover', brightenColor)
@ -128,6 +124,11 @@ export const useAppStore = defineStore('app-store', {
}, },
}, },
persist: { persist: {
enabled: true,
strategies: [
{
storage: localStorage, storage: localStorage,
}, },
],
},
}) })

View File

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

View File

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

View File

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

View File

@ -1,14 +0,0 @@
.n-modal-mask {
backdrop-filter: blur(2px);
}
/* 解决tabs组件不贴合下边缘问题 */
.v-x-scroll {
height: 100%;
}
/* 解决二维码尺寸问题 */
.n-qr-code{
height: unset !important;
width: unset !important;;
}

3
src/styles/navie.css Normal file
View File

@ -0,0 +1,3 @@
.n-modal-mask {
backdrop-filter: blur(2px);
}

40
src/typings/api.d.ts vendored Normal file
View File

@ -0,0 +1,40 @@
/* 接口类型数据 */
/** 后端返回的用户相关类型 */
declare namespace ApiAuth {
/* 登录返回的用户字段 */
interface loginInfo {
/** 用户id */
id: number
/** 用户名 */
userName: string
/* 用户头像 */
avatar?: string
/* 用户邮箱 */
email?: string
/* 用户昵称 */
nickname?: string
/* 用户电话 */
tel?: string
/** 用户角色类型 */
role: Auth.RoleType
/** 访问toekn */
accessToken: string
/** 刷新toekn */
refreshToken: string
}
}
declare namespace CommonList {
/* 返回的性别类型 */
type GenderType = '0' | '1' | null
interface UserList {
id: number
name: string
age: number
gender: GenderType
email: string
address: string
role: Auth.RoleType
disabled: boolean
}
}

View File

@ -1,17 +0,0 @@
/// <reference path="../global.d.ts"/>
namespace Api {
namespace Login {
/* 登录返回的用户字段, 该数据是根据用户表扩展而来, 部分字段可能需要覆盖例如id */
interface Info extends Entity.User {
/** 用户id */
id: number
/** 用户角色类型 */
role: Entity.RoleType[]
/** 访问toekn */
accessToken: string
/** 刷新toekn */
refreshToken: string
}
}
}

View File

@ -1,8 +1,11 @@
/// <reference path="../global.d.ts"/> /** 用户相关模块 */
declare namespace Auth {
/* 角色数据库表字段 */ /** 用户角色类型 */
namespace Entity { type RoleType = 'super' | 'admin' | 'user'
interface Message { }
/* 系统消息 */
declare namespace Message {
interface List {
id: number id: number
type: 0 | 1 | 2 type: 0 | 1 | 2
title: string title: string

View File

@ -1,13 +0,0 @@
/// <reference path="../global.d.ts"/>
/* 字典数据库表字段 */
namespace Entity {
interface Dict {
id?: number
isRoot?: 0 | 1
code: string
label: string
value?: number
}
}

View File

@ -1,13 +0,0 @@
/// <reference path="../global.d.ts"/>
/* 角色数据库表字段 */
namespace Entity {
type RoleType = 'super' | 'admin' | 'user'
interface Role {
/** 用户id */
id?: number
/** 用户名 */
role?: RoleType
}
}

View File

@ -1,28 +0,0 @@
/// <reference path="../global.d.ts"/>
/** 用户数据库表字段 */
namespace Entity {
interface User {
/** 用户id */
id?: number
/** 用户名 */
userName?: string
/* 用户头像 */
avatar?: string
/* 用户性别 */
gender?: 0 | 1
/* 用户邮箱 */
email?: string
/* 用户昵称 */
nickname?: string
/* 用户电话 */
tel?: string
/** 用户角色类型 */
role?: Entity.RoleType[]
/** 用户状态 */
status?: 0 | 1
/** 备注 */
remark?: string
}
}

11
src/typings/env.d.ts vendored
View File

@ -21,18 +21,13 @@ interface ImportMetaEnv {
| 'brotliCompress' | 'brotliCompress'
| 'deflate' | 'deflate'
| 'deflateRaw' | 'deflateRaw'
/** 路由模式 */ /** hash路由模式 */
readonly VITE_ROUTE_MODE?: 'hash' | 'web' readonly VITE_ROUTE_MODE?: 'hash' | 'web'
/** 路由加载模式 */ /** 路由加载模式 */
readonly VITE_ROUTE_LOAD_MODE: 'static' | 'dynamic' readonly VITE_AUTH_ROUTE_MODE: 'static' | 'dynamic'
/** 首次加载页面 */ /** 首次加载页面 */
readonly VITE_HOME_PATH: string readonly VITE_HOME_PATH: string
/** 版权信息 */
readonly VITE_COPYRIGHT_INFO: string
/** 是否自动刷新token */
readonly VITE_AUTO_REFRESH_TOKEN: 'Y' | 'N'
/** 默认语言 */
readonly VITE_DEFAULT_LANG: App.lang
/** 后端服务的环境类型 */ /** 后端服务的环境类型 */
readonly MODE: ServiceEnvType readonly MODE: ServiceEnvType
} }

View File

@ -1,12 +1,3 @@
/* 存放数据库实体表类型, 具体内容在 ./entities */
declare namespace Entity {
}
/* 各类接口返回的数据类型, 具体内容在 ./api */
declare namespace Api {
}
interface Window { interface Window {
$loadingBar: import('naive-ui').LoadingBarApi $loadingBar: import('naive-ui').LoadingBarApi
$dialog: import('naive-ui').DialogApi $dialog: import('naive-ui').DialogApi
@ -30,12 +21,12 @@ declare namespace NaiveUI {
declare namespace Storage { declare namespace Storage {
interface Session { interface Session {
dict: DictMap demoKey: string
} }
interface Local { interface Local {
/* 存储用户信息 */ /* 存储用户信息 */
userInfo: Api.Login.Info userInfo: ApiAuth.loginInfo
/* 存储访问token */ /* 存储访问token */
accessToken: string accessToken: string
/* 存储刷新token */ /* 存储刷新token */
@ -50,7 +41,3 @@ declare namespace Storage {
declare namespace App { declare namespace App {
type lang = 'zhCN' | 'enUS' type lang = 'zhCN' | 'enUS'
} }
interface DictMap {
[key: string]: Entity.Dict[]
}

View File

@ -10,7 +10,7 @@ declare namespace AppRoute {
/* 是否需要登录权限。 */ /* 是否需要登录权限。 */
requiresAuth?: boolean requiresAuth?: boolean
/* 可以访问的角色 */ /* 可以访问的角色 */
roles?: Entity.RoleType[] roles?: Auth.RoleType[]
/* 是否开启页面缓存 */ /* 是否开启页面缓存 */
keepAlive?: boolean keepAlive?: boolean
/* 有些路由我们并不想在菜单中显示,比如某些编辑页面。 */ /* 有些路由我们并不想在菜单中显示,比如某些编辑页面。 */
@ -29,8 +29,6 @@ declare namespace AppRoute {
menuType?: MenuType menuType?: MenuType
} }
type MetaKeys = keyof RouteMeta
interface baseRoute { interface baseRoute {
/** 路由名称(路由唯一标识) */ /** 路由名称(路由唯一标识) */
name: string name: string
@ -47,7 +45,9 @@ declare namespace AppRoute {
} }
/** 单个路由的类型结构(动态路由模式:后端返回此类型结构的路由) */ /** 单个路由的类型结构(动态路由模式:后端返回此类型结构的路由) */
type RowRoute = RouteMeta & baseRoute type RowRoute = {
[K in keyof RouteMeta as `meta.${K}`]?: RouteMeta[K]
} & baseRoute
/** /**
* *

View File

@ -1,37 +1,20 @@
/** import { arrayToTree as _arrayToTree } from 'performant-array-to-tree'
* import { omit } from 'radash'
* @param arr - id和pid属性pid表示父级id
* @returns
*/
export function arrayToTree(arr: any[]) {
// 初始化结果数组
const res: any = []
// 使用Map存储数组元素以id为键元素本身为值
const map = new Map()
// 遍历数组将每个元素以id为键存储到Map中 export function arrayToTree(data: any) {
arr.forEach((item) => { const rowTree = _arrayToTree(data, {
map.set(item.id, item) parentId: 'pid',
dataField: null,
}) })
// 再次遍历数组根据pid将元素组织成树形结构 const transform = (node: any) => {
arr.forEach((item) => { if (node.children.length > 0) {
// 获取当前元素的父级元素 return ({
const parent = item.pid && map.get(item.pid) ...node,
// 如果有父级元素 children: node.children.map(transform),
if (parent) {
// 如果父级元素已有子元素,则将当前元素追加到子元素数组中
if (parent?.children)
parent.children.push(item)
// 如果父级元素没有子元素,则创建子元素数组,并将当前元素作为第一个元素
else
parent.children = [item]
}
// 如果没有父级元素,则将当前元素直接添加到结果数组中
else {
res.push(item)
}
}) })
// 返回组织好的树形结构数组 }
return res return omit(node, ['children'])
}
return rowTree.map(transform)
} }

View File

@ -1,6 +1,6 @@
import type { NDateLocale, NLocale } from 'naive-ui' import type { NDateLocale, NLocale } from 'naive-ui'
import { i18n } from '@/modules/i18n'
import { dateZhCN, zhCN } from 'naive-ui' import { dateZhCN, zhCN } from 'naive-ui'
import { i18n } from '@/modules/i18n'
export function setLocale(locale: App.lang) { export function setLocale(locale: App.lang) {
i18n.global.locale.value = locale i18n.global.locale.value = locale

View File

@ -1,32 +1,6 @@
import { Icon } from '@iconify/vue' import { Icon } from '@iconify/vue'
import { NIcon } from 'naive-ui' import { NIcon } from 'naive-ui'
export function renderIcon(icon?: string, props?: import('naive-ui').IconProps) { export function renderIcon(icon: string, props?: import('naive-ui').IconProps) {
if (!icon) return () => h(NIcon, props, { default: () => h(Icon, { icon }) })
return
return () => createIcon(icon, props)
}
export function createIcon(icon?: string, props?: import('naive-ui').IconProps) {
if (!icon)
return
const isLocal = icon.startsWith('local:')
let innerIcon: any
if (isLocal) {
const svgName = icon.replace('local:', '')
const svg = import.meta.glob('@/assets/svg-icons/*.svg', {
query: '?raw',
import: 'default',
eager: true,
})
const target = svg[`/src/assets/svg-icons/${svgName}.svg`]
innerIcon = h(NIcon, { ...props, innerHTML: target })
}
else {
innerIcon = h(NIcon, props, { default: () => h(Icon, { icon }) })
}
return innerIcon
} }

View File

@ -1,4 +1,4 @@
export * from './icon'
export * from './storage' export * from './storage'
export * from './array' export * from './array'
export * from './i18n' export * from './i18n'
export * from './icon'

View File

@ -39,8 +39,9 @@ function createLocalStorage<T extends Storage.Local>() {
window.localStorage.removeItem(`${STORAGE_PREFIX}${String(key)}`) window.localStorage.removeItem(`${STORAGE_PREFIX}${String(key)}`)
} }
const clear = window.localStorage.clear function clear() {
window.localStorage.clear()
}
return { return {
set, set,
get, get,
@ -72,7 +73,9 @@ function createSessionStorage<T extends Storage.Session>() {
function remove(key: keyof T) { function remove(key: keyof T) {
window.sessionStorage.removeItem(`${STORAGE_PREFIX}${String(key)}`) window.sessionStorage.removeItem(`${STORAGE_PREFIX}${String(key)}`)
} }
const clear = window.sessionStorage.clear function clear() {
window.sessionStorage.clear()
}
return { return {
set, set,

View File

@ -5,7 +5,7 @@ import lib from '@/../package.json'
<template> <template>
<n-space vertical> <n-space vertical>
<n-card title="关于"> <n-card title="关于">
Nova-admin是一款基于Vue3+vite+TypeScript+NaiveUI的后台管理模板力求使用简约的代码实现完备功能降低学习门槛和维护成本让大家能早点下班做自己的事情 Nova-admin是一款基于Vue3+vite+TypeScript+NavieUI的后台管理模板力求使用简约的代码实现完备功能降低学习门槛和维护成本让大家能早点下班做自己的事情
</n-card> </n-card>
<n-card title="信息"> <n-card title="信息">
<n-descriptions <n-descriptions

View File

@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { type ECOption, useEcharts } from '@/hooks'
import { graphic } from 'echarts' import { graphic } from 'echarts'
import { type ECOption, useEcharts } from '@/hooks'
const lineOptions = ref<ECOption>({ const lineOptions = ref<ECOption>({
tooltip: { tooltip: {
@ -113,8 +113,7 @@ const lineOptions = ref<ECOption>({
data: [20, 71, 8, 50, 57, 32], data: [20, 71, 8, 50, 57, 32],
}], }],
}) as Ref<ECOption> }) as Ref<ECOption>
const { domRef: lineRef } = useEcharts(lineOptions)
useEcharts('lineRef', lineOptions)
</script> </script>
<template> <template>

View File

@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { type ECOption, useEcharts } from '@/hooks'
import { graphic } from 'echarts' import { graphic } from 'echarts'
import { type ECOption, useEcharts } from '@/hooks'
const chartData = [ const chartData = [
{ name: '1', value: 300 }, { name: '1', value: 300 },
@ -90,8 +90,7 @@ const option = ref<ECOption>({
}, },
}], }],
}) as Ref<ECOption> }) as Ref<ECOption>
const { domRef: lineRef } = useEcharts(option)
useEcharts('lineRef', option)
</script> </script>
<template> <template>

View File

@ -50,7 +50,7 @@ const option = ref<ECOption>({
}, },
], ],
}) as Ref<ECOption> }) as Ref<ECOption>
useEcharts('lineRef', option) const { domRef: lineRef } = useEcharts(option)
</script> </script>
<template> <template>

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