@ -0,0 +1,4 @@ |
||||
NODE_ENV=production |
||||
VUE_APP_PREVIEW=false |
||||
VUE_APP_API_BASE_URL=/api/hto-mp/v1 |
||||
PORT=8998 |
@ -0,0 +1,2 @@ |
||||
VITE_APP_ENV = dev |
||||
VITE_APP_TITLE = 我是标题 |
@ -0,0 +1 @@ |
||||
VITE_APP_ENV = production |
@ -0,0 +1,16 @@ |
||||
|
||||
*.sh |
||||
node_modules |
||||
*.md |
||||
*.woff |
||||
*.ttf |
||||
.vscode |
||||
.idea |
||||
dist |
||||
/public |
||||
/docs |
||||
.husky |
||||
.local |
||||
/bin |
||||
Dockerfile |
||||
.npmrc |
@ -0,0 +1,78 @@ |
||||
// @ts-check
|
||||
const { defineConfig } = require('eslint-define-config'); |
||||
module.exports = defineConfig({ |
||||
root: true, |
||||
env: { |
||||
browser: true, |
||||
node: true, |
||||
es6: true, |
||||
}, |
||||
parser: 'vue-eslint-parser', |
||||
parserOptions: { |
||||
parser: '@typescript-eslint/parser', |
||||
ecmaVersion: 2020, |
||||
sourceType: 'module', |
||||
jsxPragma: 'React', |
||||
ecmaFeatures: { |
||||
jsx: true, |
||||
}, |
||||
}, |
||||
extends: [ |
||||
'plugin:vue/vue3-recommended', |
||||
'plugin:@typescript-eslint/recommended', |
||||
'prettier', |
||||
'plugin:prettier/recommended', |
||||
'plugin:jest/recommended', |
||||
], |
||||
rules: { |
||||
'vue/script-setup-uses-vars': 'error', |
||||
'@typescript-eslint/ban-ts-ignore': 'off', |
||||
'@typescript-eslint/explicit-function-return-type': 'off', |
||||
'@typescript-eslint/no-explicit-any': 'off', |
||||
'@typescript-eslint/no-var-requires': 'off', |
||||
'@typescript-eslint/no-empty-function': 'off', |
||||
'vue/custom-event-name-casing': 'off', |
||||
'no-use-before-define': 'off', |
||||
'@typescript-eslint/no-use-before-define': 'off', |
||||
'@typescript-eslint/ban-ts-comment': 'off', |
||||
'@typescript-eslint/ban-types': 'off', |
||||
'@typescript-eslint/no-non-null-assertion': 'off', |
||||
'@typescript-eslint/explicit-module-boundary-types': 'off', |
||||
'@typescript-eslint/no-unused-vars': [ |
||||
'error', |
||||
{ |
||||
argsIgnorePattern: '^_', |
||||
varsIgnorePattern: '^_', |
||||
}, |
||||
], |
||||
'no-unused-vars': [ |
||||
'error', |
||||
{ |
||||
argsIgnorePattern: '^_', |
||||
varsIgnorePattern: '^_', |
||||
}, |
||||
], |
||||
'space-before-function-paren': 'off', |
||||
|
||||
'vue/attributes-order': 'off', |
||||
'vue/one-component-per-file': 'off', |
||||
'vue/html-closing-bracket-newline': 'off', |
||||
'vue/max-attributes-per-line': 'off', |
||||
'vue/multiline-html-element-content-newline': 'off', |
||||
'vue/singleline-html-element-content-newline': 'off', |
||||
'vue/attribute-hyphenation': 'off', |
||||
'vue/require-default-prop': 'off', |
||||
'vue/html-self-closing': [ |
||||
'error', |
||||
{ |
||||
html: { |
||||
void: 'always', |
||||
normal: 'never', |
||||
component: 'always', |
||||
}, |
||||
svg: 'always', |
||||
math: 'always', |
||||
}, |
||||
], |
||||
}, |
||||
}); |
@ -0,0 +1,31 @@ |
||||
node_modules |
||||
.DS_Store |
||||
dist |
||||
.cache |
||||
|
||||
package-lock.json |
||||
yarn.lock |
||||
|
||||
.local |
||||
# local env files |
||||
.env.local |
||||
.env.*.local |
||||
.eslintcache |
||||
|
||||
# Log files |
||||
npm-debug.log* |
||||
yarn-debug.log* |
||||
yarn-error.log* |
||||
pnpm-debug.log* |
||||
|
||||
# Editor directories and files |
||||
.idea |
||||
# .vscode |
||||
*.suo |
||||
*.ntvs* |
||||
*.njsproj |
||||
*.sln |
||||
*.sw? |
||||
|
||||
**/src/auto-imports.d.ts |
||||
**/src/components.d.ts |
@ -0,0 +1,6 @@ |
||||
#!/bin/sh |
||||
|
||||
# shellcheck source=./_/husky.sh |
||||
. "$(dirname "$0")/_/husky.sh" |
||||
|
||||
npx --no-install commitlint --edit "$1" |
@ -0,0 +1,9 @@ |
||||
#!/bin/sh |
||||
command_exists () { |
||||
command -v "$1" >/dev/null 2>&1 |
||||
} |
||||
|
||||
# Workaround for Windows 10, Git Bash and Yarn |
||||
if command_exists winpty && test -t 1; then |
||||
exec < /dev/tty |
||||
fi |
@ -0,0 +1,7 @@ |
||||
module.exports = { |
||||
'*.{js,jsx,ts,tsx}': ['eslint --fix', 'prettier --write'], |
||||
'{!(package)*.json,*.code-snippets,.!(browserslist)*rc}': ['prettier --write--parser json'], |
||||
'package.json': ['prettier --write'], |
||||
'*.vue': ['eslint --fix', 'prettier --write'], |
||||
'*.{scss,less,styl,html}': ['prettier --write'], |
||||
}; |
@ -0,0 +1,8 @@ |
||||
#!/bin/sh |
||||
. "$(dirname "$0")/_/husky.sh" |
||||
. "$(dirname "$0")/common.sh" |
||||
|
||||
[ -n "$CI" ] && exit 0 |
||||
|
||||
# Format and submit code according to lintstagedrc.js configuration |
||||
npm run lint:lint-staged |
@ -0,0 +1,8 @@ |
||||
# 提升一些依赖包至 node_modules |
||||
# 解决部分包模块not found的问题 |
||||
# 用于配合 pnpm |
||||
#shamefully-hoist = true |
||||
|
||||
# node-sass 下载问题 |
||||
registry = "https://registry.npm.taobao.org" |
||||
sass_binary_site="https://npm.taobao.org/mirrors/node-sass/" |
@ -0,0 +1,18 @@ |
||||
**/*.svg |
||||
**/*.ico |
||||
package.json |
||||
/dist |
||||
.DS_Store |
||||
.eslintignore |
||||
*.png |
||||
*.toml |
||||
.editorconfig |
||||
.gitignore |
||||
.prettierignore |
||||
LICENSE |
||||
.eslintcache |
||||
*.lock |
||||
yarn-error.log |
||||
/public |
||||
**/node_modules/** |
||||
.npmrc |
@ -0,0 +1,3 @@ |
||||
{ |
||||
"recommendations": ["johnsoncodehk.volar"] |
||||
} |
@ -0,0 +1,35 @@ |
||||
# [1.0.0](https://github.com/js-banana/vite-vue3-ts/compare/v1.1.0...v1.0.0) (2022-08-09) |
||||
|
||||
# [1.0.0](https://github.com/js-banana/vite-vue3-ts/compare/v1.1.0...v1.0.0) (2022-08-09) |
||||
|
||||
# [1.0.0](https://github.com/js-banana/vite-vue3-ts/compare/v1.1.0...v1.0.0) (2022-08-09) |
||||
|
||||
# [1.0.0](https://github.com/js-banana/vite-vue3-ts/compare/v1.1.0...v1.0.0) (2022-08-09) |
||||
|
||||
# [1.0.0](https://github.com/js-banana/vite-vue3-ts/compare/v1.1.0...v1.0.0) (2022-08-09) |
||||
|
||||
### Bug Fixes |
||||
|
||||
- nav ([a66af47](https://github.com/js-banana/vite-vue3-ts/commit/a66af4704c00fac48fcd6d30f1fa05f3c883aad9)) |
||||
- **router:** 域名二级目录的路由配置优化 ([9b8c887](https://github.com/js-banana/vite-vue3-ts/commit/9b8c8876d61ebbdda7b16cc10aa19083517eceb2)) |
||||
- **SideMenu:** 修复菜单文本与图标居中 ([60bafa0](https://github.com/js-banana/vite-vue3-ts/commit/60bafa0711b2f44df76f2979399ac95998576d67)) |
||||
|
||||
### Features |
||||
|
||||
- **.env:** 增加环境变量配置文件 ([403029c](https://github.com/js-banana/vite-vue3-ts/commit/403029cb0ad703f4ea464a81876987a64b570f37)) |
||||
- 调整权限逻辑,补充 v-role 指令 ([9a9598b](https://github.com/js-banana/vite-vue3-ts/commit/9a9598b2bb85a5c8baf5a08c56efd0e308514b96)) |
||||
- 添加路由动效,抽离 Breadcrumb 组件 ([d32087c](https://github.com/js-banana/vite-vue3-ts/commit/d32087c9f9490f6245589fce42342b19e3068b5e)) |
||||
- 增加 Table 使用 demo,完善文档说明,优化 Table API,保持与官方 antv 一致 ([159e0da](https://github.com/js-banana/vite-vue3-ts/commit/159e0da34c2897d4d2a3433ac793cfa3c4521cf2)) |
||||
- vite2.x => vite3.x 工具链生态相关升级更新 ([820c02e](https://github.com/js-banana/vite-vue3-ts/commit/820c02e0f1eec256bf4fc9884cb25f2631fa803e)) |
||||
|
||||
### Performance Improvements |
||||
|
||||
- 路由模式由 hash 调整为 history ([e37f2f6](https://github.com/js-banana/vite-vue3-ts/commit/e37f2f60291241ec4ab30134afd46b0dd83815a8)) |
||||
|
||||
## [0.0.1](https://github.com/js-banana/vite-vue3-ts/compare/219fe493bd2623c0abfab0e4ef48a2a12838ccdf...v0.0.1) (2021-12-13) |
||||
|
||||
### Features |
||||
|
||||
- **app:** 生产环境 mock、功能组件、路由完善 ([74b1983](https://github.com/js-banana/vite-vue3-ts/commit/74b1983c7a946b2fb1a95afcd3870a13db96fa9b)) |
||||
- **mock:** mock 数据编写 ([219fe49](https://github.com/js-banana/vite-vue3-ts/commit/219fe493bd2623c0abfab0e4ef48a2a12838ccdf)) |
||||
- update app and docs ([b61d9ce](https://github.com/js-banana/vite-vue3-ts/commit/b61d9cea26c522850eca74cb0833d5efd90c52c1)) |
@ -0,0 +1,21 @@ |
||||
MIT License |
||||
|
||||
Copyright (c) 2020-present, JS-banana |
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy |
||||
of this software and associated documentation files (the "Software"), to deal |
||||
in the Software without restriction, including without limitation the rights |
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell |
||||
copies of the Software, and to permit persons to whom the Software is |
||||
furnished to do so, subject to the following conditions: |
||||
|
||||
The above copyright notice and this permission notice shall be included in all |
||||
copies or substantial portions of the Software. |
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR |
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, |
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE |
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER |
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, |
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE |
||||
SOFTWARE. |
@ -0,0 +1,245 @@ |
||||
# vite-vue3-ts |
||||
|
||||
[![ci](https://github.com/JS-banana/vite-vue3-ts/actions/workflows/deploy.yml/badge.svg)](https://github.com/JS-banana/vite-vue3-ts/actions/workflows/deploy.yml) |
||||
|
||||
## 介绍 |
||||
|
||||
一个使用 `vite` + `vue3` + `pinia` + `ant-design-vue` + `typescript` 完整技术路线开发的项目,秒级开发更新启动、新的`vue3 composition api` 结合 `setup`纵享丝滑般的开发体验、全新的 `pinia`状态管理器和优秀的设计体验(`1k`的size)、`antd`无障碍过渡使用UI组件库 `ant-design-vue`、安全高效的 `typescript`类型支持、代码规范验证、多级别的权限管理~ |
||||
|
||||
相关文章:<https://juejin.cn/post/7041188884864040991> |
||||
|
||||
本项目相关改动及更新见【[更新记录](#更新记录)↓↓↓】 |
||||
|
||||
## 特性 |
||||
|
||||
- ✨脚手架工具:高效、快速的 **Vite** |
||||
- 🔥前端框架:眼下最时髦的 **Vue3** |
||||
- 🍍状态管理器:`vue3`新秀 **Pinia**,犹如 `react zustand`般的体验,友好的api和异步处理 |
||||
- 🏆开发语言:政治正确 **TypeScript** |
||||
- 🎉UI组件:`antd`开发者无障碍过渡使用 **ant-design-vue**,熟悉的配方熟悉的味道 |
||||
- 🎨css样式:**less** 、`postcss` |
||||
- 📖代码规范:**Eslint**、**Prettier**、**Commitlint** |
||||
- 🔒权限管理:页面级、菜单级、按钮级、接口级 |
||||
- ✊依赖按需加载:**unplugin-auto-import**,可自动导入使用到的`vue`、`vue-router`等依赖 |
||||
- 💪组件按需导入:**unplugin-vue-components**,无论是第三方UI组件还是自定义组件都可实现自动按需导入以及`TS`语法提示 |
||||
|
||||
## 项目目录 |
||||
|
||||
```js |
||||
├── .husky // husky git hooks配置目录 |
||||
├── _ // husky 脚本生成的目录文件 |
||||
├── commit-msg // commit-msg钩子,用于验证 message格式 |
||||
├── pre-commit // pre-commit钩子,主要是和eslint配合 |
||||
├── config // 全局配置文件 |
||||
├── vite // vite 相关配置 |
||||
├── constant.ts // 项目配置 |
||||
├── themeConfig.ts // 主题配置 |
||||
├── dist // 默认的 build 输出目录 |
||||
├── mock // 前端数据mock |
||||
├── public // vite项目下的静态目录 |
||||
└── src // 源码目录 |
||||
├── api // 接口相关 |
||||
├── assets // 公共的文件(如image、css、font等) |
||||
├── components // 项目组件 |
||||
├── directives // 自定义 指令 |
||||
├── enums // 自定义 常量(枚举写法) |
||||
├── hooks // 自定义 hooks |
||||
├── layout // 全局布局 |
||||
├── router // 路由 |
||||
├── store // 状态管理器 |
||||
├── utils // 工具库 |
||||
├── views // 页面模块目录 |
||||
├── login // login页面模块 |
||||
├── ... |
||||
├── App.vue // vue顶层文件 |
||||
├── auto-imports.d.ts // unplugin-auto-import 插件生成 |
||||
├── components.d.d.ts // unplugin-vue-components 插件生成 |
||||
├── main.ts // 项目入口文件 |
||||
├── shimes-vue.d.ts // vite默认ts类型文件 |
||||
├── types // 项目type类型定义文件夹 |
||||
├── .editorconfig // IDE格式规范 |
||||
├── .env // 环境变量 |
||||
├── .eslintignore // eslint忽略 |
||||
├── .eslintrc // eslint配置文件 |
||||
├── .gitignore // git忽略 |
||||
├── .npmrc // npm配置文件 |
||||
├── .prettierignore // prettierc忽略 |
||||
├── .prettierrc // prettierc配置文件 |
||||
├── index.html // 入口文件 |
||||
├── LICENSE.md // LICENSE |
||||
├── package.json // package |
||||
├── pnpm-lock.yaml // pnpm-lock |
||||
├── postcss.config.js // postcss |
||||
├── README.md // README |
||||
├── tsconfig.json // typescript配置文件 |
||||
└── vite.config.ts // vite |
||||
``` |
||||
|
||||
## 使用说明 |
||||
|
||||
> 简要说明: |
||||
> |
||||
> 随着vite3.x的发布,本项目针对该依赖的相关生态做了升级,详情见分支 [feat-vite3.x](https://github.com/JS-banana/vite-vue3-ts/tree/feat-vite3.x) |
||||
> |
||||
> 需要指出的是vite3.x要求node14.18及以上,详情见 [从 v2 迁移](https://cn.vitejs.dev/guide/migration.html) |
||||
|
||||
1. 克隆本项目 |
||||
|
||||
```sh |
||||
git clone https://github.com/JS-banana/vite-vue3-ts.git |
||||
``` |
||||
|
||||
2. 安装依赖 |
||||
|
||||
```sh |
||||
# 推荐使用 pnpm |
||||
pnpm install |
||||
# 没有安装的直接安装 |
||||
npm install -g pnpm |
||||
``` |
||||
|
||||
3. 启动项目 |
||||
|
||||
```sh |
||||
pnpm serve |
||||
# or |
||||
pnpm dev |
||||
``` |
||||
|
||||
4. 部署 |
||||
|
||||
```sh |
||||
# 检查TS类型然后构建打包 |
||||
pnpm build:check |
||||
# 跳过检查直接构建打包 |
||||
pnpm build |
||||
# 预览 |
||||
pnpm preview |
||||
``` |
||||
|
||||
### 数据模拟 |
||||
|
||||
为了实现更多元化和真实数据展示,使用了Mock+fakerjs进行数据模拟,fakerjs的功能极其强大,几乎可以定制任何类型数据,本项目里做了部分演示,源码见`mock/table.ts` |
||||
|
||||
### ant-design-vue 2.x升级到3.x的说明 |
||||
|
||||
Table组件: |
||||
|
||||
在2.x版本的时候,Table组件主要通过 `columns`属性,配置字段 `slots: { customRender: 'action' }`进行自定义插槽,做到制定内容的内容,基于此,本项目Table组件封装的内部实现为`<template v-if="column.slots?.customRender === 'action'">`。 |
||||
|
||||
但是在3.x之后,官方舍弃了 columns中的`slots`属性,因此该方式需要做出调整,不过,我们的整体思路不变,即,确定一个通用且唯一的key为判断属性即可,以实现对不同内容的区别渲染。 |
||||
|
||||
目前的方案打算以columns中的`key` prop属性作为唯一判断的key,Table组件封装内部实现如`<template v-if="column.key === 'action'">`,因此,在columns中默认以`dataIndex`作为渲染key,而`key`作为我们的自定义渲染插槽内容的key。 |
||||
|
||||
官网API介绍如下: |
||||
|
||||
| Property | Description | Type | Default | |
||||
| --------- | ------------------------------------------------------------------------------------ | --------------- | ------- | |
||||
| dataIndex | Display field of the data record, support nest path by string array | string/string[] | - | |
||||
| key | Unique key of this column, you can ignore this prop if you've set a unique dataIndex | string | - | |
||||
|
||||
### Table高级组件的基本用法 |
||||
|
||||
> 详情及源码见:./src/components/Table/index.vue |
||||
|
||||
几个核心API: |
||||
|
||||
| Property | Description | Type | Default | |
||||
| ---------------- | ------------------------------------------------------------------------------------------- | -------------------- | ------- | |
||||
| url | 接受一个异步请求函数,Table内部会自动接管状态(包括发起请求、分页参数等) | Promise函数 | - | |
||||
| columns | 配置列表项 | 与官方API一致 | - | |
||||
| hiddenFilter | 是否展示Table上面的筛选项组件 | Boolean | - | |
||||
| tableActions | 自定义action slot(需要在columns中指定`key: 'action'`) | ref/reactive类型数组 | - | |
||||
| button | 筛选组件中的独立按钮(如:新增用户) | ref/reactive类型对象 | - | |
||||
| items | 筛选组件中的表单项(如:选择角色、搜索名称,会自动生成查询按钮,并接管到Table中的参数状态) | ref/reactive类型数组 | - | |
||||
| tableFilterModel | 筛选组件中的表单项数据模型(配合items使用,为了确定key) | ref/reactive类型对象 | - | |
||||
|
||||
```html |
||||
<template> |
||||
<Table |
||||
ref="ELRef" |
||||
:url="fetchApi.list" |
||||
:columns="columns" |
||||
:actions="tableActions" |
||||
:button="tableFilterButton" |
||||
:items="tableFilterItems" |
||||
:model="tableFilterModel" |
||||
/> |
||||
</template> |
||||
``` |
||||
|
||||
使用ref调用Table提供的方法API: |
||||
|
||||
| Property | Description | Type | Default | |
||||
| -------- | -------------------------------------------------------------------------------------------- | -------------- | ------- | |
||||
| refresh | 不接受参数,使用当前的参数发起更新请求(在新增或删除、修改一条数据后,调用该方法进行列表更新 | ()=>void | - | |
||||
| run | 接受参数,当需要与自定义参数合并时,并发起新的请求,使用此方 | (params)=>void | - | |
||||
|
||||
```js |
||||
const refresh = () => ELRef.value?.refresh(); |
||||
// const run = (args) => ELRef.value.run(args); |
||||
``` |
||||
|
||||
### 特别说明 |
||||
|
||||
`config/vite/plugin/mock.ts` 中的 `configMockPlugin` 方法,属性`prodEnabled` 在生产环境一定要关闭,不然会把大量的mock代码,如fakerjs中的一些源码打包进构建包内。 |
||||
|
||||
**本项目这里为了做演示,是手动开启了的,为了能在线上部署的时候查看mock数据,实际开发中一定注意关闭!!!** |
||||
|
||||
## 效果图 |
||||
|
||||
![vite-vue3-3](https://cdn.jsdelivr.net/gh/JS-banana/images/vuepress/vite-vue3-3.jpg) |
||||
|
||||
![vite-vue3-4](https://cdn.jsdelivr.net/gh/JS-banana/images/vuepress/vite-vue3-4.jpg) |
||||
|
||||
## 更新记录 |
||||
|
||||
- 2022.01.18 |
||||
- 增加环境变量配置文件 `.env`/`.env.development`/`.env.production` |
||||
- 2022.03.09 |
||||
- 为了优化服务器构建,移除 `auto-imports.d.ts`、`components.d.ts`的git记录,加入`.gitignore` |
||||
- 域名二级目录的路由配置优化 `history: createWebHistory(import.meta.env.BASE_URL)` |
||||
- 路由模式由 hash调整为 history |
||||
- 2022.05.07 |
||||
- 添加路由动效`transition`,优化用户体验,并抽离封装`Breadcrumb`组件 |
||||
- 添加权限指令`v-role`,调整权限逻辑,目前权限指令包括`v-role`/`v-auth` |
||||
- `Table`相关组件有所改动,同步迭代了一些功能点,包括优化项 |
||||
- 建议配合该篇文章食用[多级别权限设计思考及实战](https://ssscode.com/pages/ff7971/) |
||||
- 2022.06.21 |
||||
- `ant-design-vue`升级到`3.x`版本,同步更新改动了一些API |
||||
- `dayjs`替换`moment` |
||||
- 2022.07.24 |
||||
- ✔完善`Table`组件,更新了一些在项目中迭代的优化 |
||||
- ✔优化`Table`相关`API`,遵循`ant-design-vue3.x`官方用法进行迭代 |
||||
- ✔新增`Table`使用demo,增加各API用法示例,基本涵盖大部分用法 |
||||
- ✔新增`fakerjs`数据mock,配合`Mockjs`完善并增强对不同数据类型和场景的模拟 |
||||
- ✔完善文档,新增使用说明 |
||||
- [PR](https://github.com/JS-banana/vite-vue3-ts/pull/6) |
||||
- 2022.07.30 |
||||
- vite相关工具链升级到3.x |
||||
- 现在你必须使用 Node 14.18+ / 16+ 版本。 |
||||
- 详情见分支 [feat-vite3.x](https://github.com/JS-banana/vite-vue3-ts/tree/feat-vite3.x) |
||||
- 原有的vite2.x版本见分支 [feat-vite2.x](https://github.com/JS-banana/vite-vue3-ts/tree/feat-vite2.x) |
||||
- 现在master主分支为最新的vite3.x版本 |
||||
|
||||
## 计划 |
||||
|
||||
- [x] `ant-design-vue` 升级到 3.x版本 |
||||
- [ ] 主题换肤功能 |
||||
- [ ] 引入 `tailwindcss` |
||||
|
||||
## 交流 |
||||
|
||||
你可以关注我公众号(前端小帅),加我微信交流,一起沟通学习~ |
||||
|
||||
<table> |
||||
<tr> |
||||
<td valign="top"> |
||||
<img height="160" alt="公众号:前端小帅" src="https://cdn.jsdelivr.net/gh/JS-banana/images/vuepress/4.png" /> |
||||
</td> |
||||
</tr> |
||||
</table> |
||||
|
||||
## 感谢star |
||||
|
||||
[![Stargazers over time](https://starchart.cc/JS-banana/vite-vue3-ts.svg)](https://starchart.cc/JS-banana/vite-vue3-ts) |
@ -0,0 +1,45 @@ |
||||
// feat:新增功能
|
||||
// fix:bug 修复
|
||||
// docs:文档更新
|
||||
// style:不影响程序逻辑的代码修改(修改空白字符,格式缩进,补全缺失的分号等,没有改变代码逻辑)
|
||||
// refactor:重构代码(既没有新增功能,也没有修复 bug)
|
||||
// perf:性能, 体验优化
|
||||
// test:新增测试用例或是更新现有测试
|
||||
// build:主要目的是修改项目构建系统(例如 glup,webpack,rollup 的配置等)的提交
|
||||
// ci:主要目的是修改项目继续集成流程(例如 Travis,Jenkins,GitLab CI,Circle等)的提交
|
||||
// chore:不属于以上类型的其他类,比如构建流程, 依赖管理
|
||||
// revert:回滚某个更早之前的提交
|
||||
|
||||
module.exports = { |
||||
ignores: [(commit) => commit.includes('init')], |
||||
extends: ['@commitlint/config-conventional'], |
||||
rules: { |
||||
'body-leading-blank': [2, 'always'], |
||||
'footer-leading-blank': [1, 'always'], |
||||
'header-max-length': [2, 'always', 108], |
||||
'subject-empty': [2, 'never'], |
||||
'type-empty': [2, 'never'], |
||||
'subject-case': [0], |
||||
'type-enum': [ |
||||
2, |
||||
'always', |
||||
[ |
||||
'feat', |
||||
'fix', |
||||
'perf', |
||||
'style', |
||||
'docs', |
||||
'test', |
||||
'refactor', |
||||
'build', |
||||
'ci', |
||||
'chore', |
||||
'revert', |
||||
'wip', |
||||
'workflow', |
||||
'types', |
||||
'release', |
||||
], |
||||
], |
||||
}, |
||||
}; |
@ -0,0 +1,33 @@ |
||||
/** |
||||
* @name Config |
||||
* @description 项目配置 |
||||
*/ |
||||
|
||||
// 应用名
|
||||
export const APP_TITLE = 'Vite-Vue3-Admin'; |
||||
|
||||
// 本地服务端口
|
||||
export const VITE_PORT = 3000; |
||||
|
||||
// prefix
|
||||
export const API_PREFIX = '/api'; |
||||
|
||||
// serve
|
||||
export const API_BASE_URL = '/api'; |
||||
export const API_TARGET_URL = 'http://localhost:8080'; |
||||
|
||||
// mock
|
||||
export const MOCK_API_BASE_URL = '/mock/api'; |
||||
export const MOCK_API_TARGET_URL = 'http://localhost:3000'; |
||||
|
||||
// iconfontUrl
|
||||
export const ICONFONTURL = '//at.alicdn.com/t/font_3004192_9jmc1z9neiw.js'; // 去色版
|
||||
|
||||
// 包依赖分析
|
||||
export const ANALYSIS = true; |
||||
|
||||
// 代码压缩
|
||||
export const COMPRESSION = true; |
||||
|
||||
// 删除 console
|
||||
export const VITE_DROP_CONSOLE = true; |
@ -0,0 +1,30 @@ |
||||
import { getThemeVariables } from 'ant-design-vue/dist/theme'; |
||||
|
||||
// @primary-color: #1890ff; // 全局主色
|
||||
// @link-color: #1890ff; // 链接色
|
||||
// @success-color: #52c41a; // 成功色
|
||||
// @warning-color: #faad14; // 警告色
|
||||
// @error-color: #f5222d; // 错误色
|
||||
// @font-size-base: 14px; // 主字号
|
||||
// @heading-color: rgba(0, 0, 0, 0.85); // 标题色
|
||||
// @text-color: rgba(0, 0, 0, 0.65); // 主文本色
|
||||
// @text-color-secondary: rgba(0, 0, 0, 0.45); // 次文本色
|
||||
// @disabled-color: rgba(0, 0, 0, 0.25); // 失效色
|
||||
// @border-radius-base: 4px; // 组件/浮层圆角
|
||||
// @border-color-base: #d9d9d9; // 边框色
|
||||
// @box-shadow-base: 0 2px 8px rgba(0, 0, 0, 0.15); // 浮层阴影
|
||||
|
||||
/** |
||||
* less global variable |
||||
*/ |
||||
export function generateModifyVars(dark = false) { |
||||
const modifyVars = getThemeVariables({ dark }); |
||||
return { |
||||
...modifyVars, |
||||
// Used for global import to avoid the need to import each style file separately
|
||||
// reference: Avoid repeated references
|
||||
// hack: `${modifyVars.hack} @import (reference) "${resolve('src/design/config.less')}";`,
|
||||
'primary-color': '#3860F4', |
||||
'link-color': '#3860F4', |
||||
}; |
||||
} |
@ -0,0 +1,27 @@ |
||||
/** |
||||
* @name configManualChunk |
||||
* @description chunk 拆包优化 |
||||
*/ |
||||
|
||||
const vendorLibs: { match: string[]; output: string }[] = [ |
||||
{ |
||||
match: ['ant-design-vue'], |
||||
output: 'antdv', |
||||
}, |
||||
{ |
||||
match: ['echarts'], |
||||
output: 'echarts', |
||||
}, |
||||
]; |
||||
|
||||
// pnpm安装的依赖,获取到的路径名称是拼接而成且比较长的
|
||||
// vite-vue3-ts/node_modules/.pnpm/registry.npmmirror.com+ant-design-vue@3.2.7_vue@3.2.23/node_modules/ant-design-vue/es/card/style/index.js
|
||||
export const configManualChunk = (id: string) => { |
||||
if (/[\\/]node_modules[\\/]/.test(id)) { |
||||
const matchItem = vendorLibs.find((item) => { |
||||
const reg = new RegExp(`[\\/]node_modules[\\/]_?(${item.match.join('|')})(.*)`, 'ig'); |
||||
return reg.test(id); |
||||
}); |
||||
return matchItem ? matchItem.output : null; |
||||
} |
||||
}; |
@ -0,0 +1,11 @@ |
||||
/** |
||||
* @name AutoImportDeps |
||||
* @description 按需加载,自动引入依赖 |
||||
*/ |
||||
import AutoImport from 'unplugin-auto-import/vite'; |
||||
|
||||
export const AutoImportDeps = () => |
||||
AutoImport({ |
||||
imports: ['vue', 'vue-router'], |
||||
dts: 'src/auto-imports.d.ts', |
||||
}); |
@ -0,0 +1,41 @@ |
||||
/** |
||||
* @name autoRegistryComponents |
||||
* @description 按需加载,自动引入组件 |
||||
*/ |
||||
import { AntDesignVueResolver } from 'unplugin-vue-components/resolvers'; |
||||
import Components from 'unplugin-vue-components/vite'; |
||||
|
||||
export const autoRegistryComponents = () => { |
||||
return Components({ |
||||
// relative paths to the directory to search for components.
|
||||
dirs: ['src/components'], |
||||
|
||||
// valid file extensions for components.
|
||||
extensions: ['vue'], |
||||
// search for subdirectories
|
||||
deep: true, |
||||
// resolvers for custom components
|
||||
resolvers: [AntDesignVueResolver({ importStyle: 'less' })], |
||||
|
||||
// generate `components.d.ts` global declarations,
|
||||
// also accepts a path for custom filename
|
||||
// dts: false,
|
||||
dts: 'src/components.d.ts', |
||||
|
||||
// Allow subdirectories as namespace prefix for components.
|
||||
directoryAsNamespace: false, |
||||
// Subdirectory paths for ignoring namespace prefixes
|
||||
// works when `directoryAsNamespace: true`
|
||||
globalNamespaces: [], |
||||
|
||||
// auto import for directives
|
||||
// default: `true` for Vue 3, `false` for Vue 2
|
||||
// Babel is needed to do the transformation for Vue 2, it's disabled by default for performance concerns.
|
||||
// To install Babel, run: `npm install -D @babel/parser @babel/traverse`
|
||||
directives: true, |
||||
|
||||
// filters for transforming targets
|
||||
include: [/\.vue$/, /\.vue\?vue/], |
||||
exclude: [/[\\/]node_modules[\\/]/, /[\\/]\.git[\\/]/, /[\\/]\.nuxt[\\/]/], |
||||
}); |
||||
}; |
@ -0,0 +1,17 @@ |
||||
/** |
||||
* Used to package and output gzip. Note that this does not work properly in Vite, the specific reason is still being investigated |
||||
* https://github.com/anncwb/vite-plugin-compression
|
||||
*/ |
||||
import type { Plugin } from 'vite'; |
||||
import compressPlugin from 'vite-plugin-compression'; |
||||
import { COMPRESSION } from '../../constant'; |
||||
|
||||
export function configCompressPlugin(): Plugin | Plugin[] { |
||||
if (COMPRESSION) { |
||||
return compressPlugin({ |
||||
ext: '.gz', |
||||
deleteOriginFile: false, |
||||
}) as Plugin; |
||||
} |
||||
return []; |
||||
} |
@ -0,0 +1,44 @@ |
||||
import type { Plugin } from 'vite'; |
||||
import vue from '@vitejs/plugin-vue'; |
||||
import vueJsx from '@vitejs/plugin-vue-jsx'; |
||||
// import legacy from '@vitejs/plugin-legacy';
|
||||
import { configStyleImportPlugin } from './styleImport'; |
||||
import { configSvgIconsPlugin } from './svgIcons'; |
||||
import { autoRegistryComponents } from './component'; |
||||
import { AutoImportDeps } from './autoImport'; |
||||
import { configMockPlugin } from './mock'; |
||||
import { configVisualizerConfig } from './visualizer'; |
||||
import { configCompressPlugin } from './compress'; |
||||
|
||||
export function createVitePlugins(isBuild: boolean) { |
||||
const vitePlugins: (Plugin | Plugin[])[] = [ |
||||
// vue支持
|
||||
vue(), |
||||
// JSX支持
|
||||
vueJsx(), |
||||
// 自动按需引入组件
|
||||
autoRegistryComponents(), |
||||
// 自动按需引入依赖
|
||||
AutoImportDeps(), |
||||
]; |
||||
|
||||
// @vitejs/plugin-legacy
|
||||
// isBuild && vitePlugins.push(legacy());
|
||||
|
||||
// rollup-plugin-gzip
|
||||
isBuild && vitePlugins.push(configCompressPlugin()); |
||||
|
||||
// vite-plugin-svg-icons
|
||||
vitePlugins.push(configSvgIconsPlugin(isBuild)); |
||||
|
||||
// vite-plugin-mock
|
||||
vitePlugins.push(configMockPlugin(isBuild)); |
||||
|
||||
// rollup-plugin-visualizer
|
||||
vitePlugins.push(configVisualizerConfig()); |
||||
|
||||
// vite-plugin-style-import
|
||||
vitePlugins.push(configStyleImportPlugin(isBuild)); |
||||
|
||||
return vitePlugins; |
||||
} |
@ -0,0 +1,23 @@ |
||||
/** |
||||
* Mock plugin for development and production. |
||||
* https://github.com/anncwb/vite-plugin-mock
|
||||
*/ |
||||
import { viteMockServe } from 'vite-plugin-mock'; |
||||
|
||||
export function configMockPlugin(isBuild: boolean) { |
||||
return viteMockServe({ |
||||
ignore: /^\_/, |
||||
mockPath: 'mock', |
||||
localEnabled: !isBuild, |
||||
prodEnabled: isBuild, // 为了演示,线上开启 mock,实际开发请关闭,会影响打包体积
|
||||
// 开发环境无需关心
|
||||
// injectCode 只受prodEnabled影响
|
||||
// https://github.com/anncwb/vite-plugin-mock/issues/9
|
||||
// 下面这段代码会被注入 main.ts
|
||||
injectCode: ` |
||||
import { setupProdMockServer } from '../mock/_createProdMockServer'; |
||||
|
||||
setupProdMockServer(); |
||||
`,
|
||||
}); |
||||
} |
@ -0,0 +1,78 @@ |
||||
/** |
||||
* Introduces component library styles on demand. |
||||
* https://github.com/anncwb/vite-plugin-style-import
|
||||
*/ |
||||
import styleImport from 'vite-plugin-style-import'; |
||||
|
||||
export function configStyleImportPlugin(isBuild: boolean) { |
||||
if (!isBuild) { |
||||
return []; |
||||
} |
||||
const styleImportPlugin = styleImport({ |
||||
libs: [ |
||||
{ |
||||
libraryName: 'ant-design-vue', |
||||
esModule: true, |
||||
resolveStyle: (name) => { |
||||
// 这里是无需额外引入样式文件的“子组件”列表
|
||||
const ignoreList = [ |
||||
'anchor-link', |
||||
'sub-menu', |
||||
'menu-item', |
||||
'menu-item-group', |
||||
'breadcrumb-item', |
||||
'breadcrumb-separator', |
||||
'form-item', |
||||
'step', |
||||
'select-option', |
||||
'select-opt-group', |
||||
'card-grid', |
||||
'card-meta', |
||||
'collapse-panel', |
||||
'descriptions-item', |
||||
'list-item', |
||||
'list-item-meta', |
||||
'table-column', |
||||
'table-column-group', |
||||
'tab-pane', |
||||
'tab-content', |
||||
'timeline-item', |
||||
'tree-node', |
||||
'skeleton-input', |
||||
'skeleton-avatar', |
||||
'skeleton-title', |
||||
'skeleton-paragraph', |
||||
'skeleton-image', |
||||
'skeleton-button', |
||||
]; |
||||
// 这里是需要额外引入样式的子组件列表
|
||||
// 单独引入子组件时需引入组件样式,否则会在打包后导致子组件样式丢失
|
||||
const replaceList = { |
||||
'typography-text': 'typography', |
||||
'typography-title': 'typography', |
||||
'typography-paragraph': 'typography', |
||||
'typography-link': 'typography', |
||||
'dropdown-button': 'dropdown', |
||||
'input-password': 'input', |
||||
'input-search': 'input', |
||||
'input-group': 'input', |
||||
'radio-group': 'radio', |
||||
'checkbox-group': 'checkbox', |
||||
'layout-sider': 'layout', |
||||
'layout-content': 'layout', |
||||
'layout-footer': 'layout', |
||||
'layout-header': 'layout', |
||||
'month-picker': 'date-picker', |
||||
}; |
||||
|
||||
return ignoreList.includes(name) |
||||
? '' |
||||
: replaceList.hasOwnProperty(name) |
||||
? `ant-design-vue/es/${replaceList[name]}/style/index` |
||||
: `ant-design-vue/es/${name}/style/index`; |
||||
}, |
||||
}, |
||||
], |
||||
}); |
||||
return styleImportPlugin; |
||||
} |
@ -0,0 +1,17 @@ |
||||
/** |
||||
* Vite Plugin for fast creating SVG sprites. |
||||
* https://github.com/anncwb/vite-plugin-svg-icons
|
||||
*/ |
||||
|
||||
import SvgIconsPlugin from 'vite-plugin-svg-icons'; |
||||
import path from 'path'; |
||||
|
||||
export function configSvgIconsPlugin(isBuild: boolean) { |
||||
const svgIconsPlugin = SvgIconsPlugin({ |
||||
iconDirs: [path.resolve(process.cwd(), 'src/assets/icons')], |
||||
svgoOptions: isBuild, |
||||
// default
|
||||
symbolId: 'icon-[dir]-[name]', |
||||
}); |
||||
return svgIconsPlugin; |
||||
} |
@ -0,0 +1,17 @@ |
||||
/** |
||||
* Package file volume analysis |
||||
*/ |
||||
import visualizer from 'rollup-plugin-visualizer'; |
||||
import { ANALYSIS } from '../../constant'; |
||||
|
||||
export function configVisualizerConfig() { |
||||
if (ANALYSIS) { |
||||
return visualizer({ |
||||
filename: './node_modules/.cache/visualizer/stats.html', |
||||
open: true, |
||||
gzipSize: true, |
||||
brotliSize: true, |
||||
}) as Plugin; |
||||
} |
||||
return []; |
||||
} |
@ -0,0 +1,30 @@ |
||||
/** |
||||
* Generate proxy |
||||
*/ |
||||
|
||||
import { |
||||
API_BASE_URL, |
||||
API_TARGET_URL, |
||||
MOCK_API_BASE_URL, |
||||
MOCK_API_TARGET_URL, |
||||
} from '../../config/constant'; |
||||
import { ProxyOptions } from 'vite'; |
||||
|
||||
type ProxyTargetList = Record<string, ProxyOptions>; |
||||
|
||||
const ret: ProxyTargetList = { |
||||
// test
|
||||
[API_BASE_URL]: { |
||||
target: API_TARGET_URL, |
||||
changeOrigin: true, |
||||
//rewrite: (path) => path.replace(new RegExp(`^${API_BASE_URL}`), ''),
|
||||
}, |
||||
// mock
|
||||
[MOCK_API_BASE_URL]: { |
||||
target: MOCK_API_TARGET_URL, |
||||
changeOrigin: true, |
||||
rewrite: (path) => path.replace(new RegExp(`^${MOCK_API_BASE_URL}`), '/api'), |
||||
}, |
||||
}; |
||||
|
||||
export default ret; |
@ -0,0 +1,13 @@ |
||||
<!DOCTYPE html> |
||||
<html lang="en"> |
||||
<head> |
||||
<meta charset="UTF-8" /> |
||||
<link rel="icon" href="/favicon.ico" /> |
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
||||
<title>liuh's tools</title> |
||||
</head> |
||||
<body> |
||||
<div id="app"></div> |
||||
<script type="module" src="/src/main.ts"></script> |
||||
</body> |
||||
</html> |
@ -0,0 +1,18 @@ |
||||
import { createProdMockServer } from 'vite-plugin-mock/es/createProdMockServer'; |
||||
|
||||
const modules = import.meta.globEager('./**/*.ts'); |
||||
|
||||
const mockModules: any[] = []; |
||||
Object.keys(modules).forEach((key) => { |
||||
if (key.includes('/_')) { |
||||
return; |
||||
} |
||||
mockModules.push(...modules[key].default); |
||||
}); |
||||
|
||||
/** |
||||
* Used in a production environment. Need to manually import all modules
|
||||
*/ |
||||
export function setupProdMockServer() { |
||||
createProdMockServer(mockModules); |
||||
} |
@ -0,0 +1,60 @@ |
||||
// Interface data format used to return a unified format
|
||||
|
||||
export function resultSuccess<T = Recordable>(result: T, { message = 'ok' } = {}) { |
||||
return { |
||||
code: 0, |
||||
result, |
||||
message, |
||||
type: 'success', |
||||
}; |
||||
} |
||||
|
||||
export function resultPageSuccess<T = any>( |
||||
page: number, |
||||
pageSize: number, |
||||
list: T[], |
||||
{ message = 'ok' } = {}, |
||||
) { |
||||
const pageData = pagination(page, pageSize, list); |
||||
|
||||
return { |
||||
...resultSuccess({ |
||||
items: pageData, |
||||
total: list.length, |
||||
}), |
||||
message, |
||||
}; |
||||
} |
||||
|
||||
export function resultError(message = 'Request failed', { code = -1, result = null } = {}) { |
||||
return { |
||||
code, |
||||
result, |
||||
message, |
||||
type: 'error', |
||||
}; |
||||
} |
||||
|
||||
export function pagination<T = any>(pageNo: number, pageSize: number, array: T[]): T[] { |
||||
const offset = (pageNo - 1) * Number(pageSize); |
||||
const ret = |
||||
offset + Number(pageSize) >= array.length |
||||
? array.slice(offset, array.length) |
||||
: array.slice(offset, offset + Number(pageSize)); |
||||
return ret; |
||||
} |
||||
|
||||
export interface requestParams { |
||||
method: string; |
||||
body: any; |
||||
headers?: { authorization?: string }; |
||||
query: any; |
||||
} |
||||
|
||||
/** |
||||
* @description 本函数用于从request数据中获取token,请根据项目的实际情况修改 |
||||
* |
||||
*/ |
||||
export function getRequestToken({ headers }: requestParams): string | undefined { |
||||
return headers?.authorization; |
||||
} |
@ -0,0 +1,32 @@ |
||||
import Mock from 'mockjs'; |
||||
|
||||
const list = Mock.mock({ |
||||
'items|60': [ |
||||
{ |
||||
id: '@id', |
||||
url: '@url', |
||||
ip: '@ip', |
||||
protocol: '@protocol', |
||||
'host|1': [80, 443], |
||||
domain: '@domain', |
||||
email: '@email', |
||||
}, |
||||
], |
||||
}); |
||||
|
||||
export default [ |
||||
{ |
||||
url: '/v1/common/page_one/list', |
||||
method: 'get', |
||||
response: () => { |
||||
const items = list.items; |
||||
return { |
||||
code: 0, |
||||
result: { |
||||
total: items.length, |
||||
list: items, |
||||
}, |
||||
}; |
||||
}, |
||||
}, |
||||
]; |
@ -0,0 +1,89 @@ |
||||
import Mock from 'mockjs'; |
||||
import { resultSuccess } from './_util'; |
||||
|
||||
const list = Mock.mock({ |
||||
'items|30': [ |
||||
{ |
||||
id: '@id', |
||||
title: '@ctitle', |
||||
mobile: '@phone', |
||||
name: '@cname', |
||||
description: '@cparagraph', |
||||
created_at: '@datetime', |
||||
updated_at: '@datetime', |
||||
age: '@natural(10,50)', |
||||
color: '@color', |
||||
email: '@email', |
||||
}, |
||||
], |
||||
}); |
||||
|
||||
const data = { |
||||
hu_num: 42, |
||||
yun_num: 87755, |
||||
ce_num: 3, |
||||
create_time: 1636352741, |
||||
online_num: 101, |
||||
total_num: 110, |
||||
seven_days: [ |
||||
{ |
||||
id: 9, |
||||
num: 7, |
||||
time: '20211130', |
||||
}, |
||||
{ |
||||
id: 8, |
||||
num: 80, |
||||
time: '20211129', |
||||
}, |
||||
{ |
||||
id: 0, |
||||
num: 280, |
||||
time: '20211128', |
||||
}, |
||||
{ |
||||
id: 0, |
||||
num: 0, |
||||
time: '20211127', |
||||
}, |
||||
{ |
||||
id: 7, |
||||
num: 5, |
||||
time: '20211126', |
||||
}, |
||||
{ |
||||
id: 6, |
||||
num: 20, |
||||
time: '20211125', |
||||
}, |
||||
{ |
||||
id: 5, |
||||
num: 5, |
||||
time: '20211124', |
||||
}, |
||||
], |
||||
}; |
||||
|
||||
export default [ |
||||
{ |
||||
url: '/v1/home/info', |
||||
method: 'get', |
||||
response: () => { |
||||
return resultSuccess(data); |
||||
}, |
||||
}, |
||||
{ |
||||
url: '/v1/home/list', |
||||
method: 'get', |
||||
response: () => { |
||||
const items = list.items; |
||||
return { |
||||
code: 0, |
||||
result: { |
||||
total: items.length, |
||||
list: items, |
||||
}, |
||||
}; |
||||
}, |
||||
}, |
||||
]; |
@ -0,0 +1,119 @@ |
||||
import Mock from 'mockjs'; |
||||
import { faker } from '@faker-js/faker'; |
||||
import { getRequestToken, resultError } from './_util'; |
||||
import { createFakeUserList } from './user'; |
||||
|
||||
enum URL { |
||||
table = '/table/list', |
||||
list = '/v1/node/nodelist', |
||||
} |
||||
|
||||
const data = Mock.mock({ |
||||
'items|30': [ |
||||
{ |
||||
id: '@id', |
||||
title: '@sentence(10, 20)', |
||||
account: '@phone', |
||||
true_name: '@name', |
||||
created_at: '@datetime', |
||||
role_name: '@name', |
||||
}, |
||||
], |
||||
}); |
||||
|
||||
const NAMES = ['节点1', '节点2', '节点3', '节点4']; |
||||
const DATA_names = ['机构1', '机构2', '机构3']; |
||||
|
||||
const DATA_blockList = Mock.mock({ |
||||
'items|23': [ |
||||
{ |
||||
'id|+1': 1, |
||||
node_name: () => faker.helpers.arrayElement(NAMES), |
||||
institutions_name: () => faker.helpers.arrayElement(DATA_names), |
||||
ip: () => faker.internet.ip(), |
||||
port: () => faker.internet.port(), |
||||
nodeRole: () => faker.helpers.arrayElement(['普通节点', '管理节点']), |
||||
is_consensus: () => faker.helpers.arrayElement(['是', '否']), |
||||
create_time: () => faker.date.past(2, new Date().toISOString()), |
||||
status: () => faker.helpers.arrayElement(['正常', '异常']), |
||||
isSelf: () => faker.datatype.boolean(), |
||||
}, |
||||
], |
||||
}); |
||||
|
||||
export default [ |
||||
{ |
||||
url: URL.table, |
||||
method: 'get', |
||||
response: () => { |
||||
const items = data.items; |
||||
return { |
||||
code: 0, |
||||
result: { |
||||
total: items.length, |
||||
list: items, |
||||
}, |
||||
}; |
||||
}, |
||||
}, |
||||
{ |
||||
url: URL.list, |
||||
method: 'get', |
||||
response: (request) => { |
||||
let items = DATA_blockList.items.map((n) => { |
||||
return { |
||||
...n, |
||||
node_name: n.nodeRole === '创世节点' ? '创世节点' : n.node_name, |
||||
institutions_name: |
||||
n.nodeRole === '管理节点' |
||||
? '管理' |
||||
: ['节点1', '节点2'].includes(n.node_name) |
||||
? n.node_name.replace('节点', '机构') |
||||
: n.institutions_name, |
||||
}; |
||||
}); |
||||
|
||||
const token = getRequestToken(request); |
||||
if (!token) return resultError('Invalid token'); |
||||
const checkUser = createFakeUserList().find((item) => item.token === token); |
||||
if (checkUser?.role === 0) { |
||||
items = [ |
||||
{ |
||||
id: 14, |
||||
node_name: '节点1', |
||||
institutions_name: '机构1', |
||||
ip: '147.174.206.1', |
||||
port: 26042, |
||||
nodeRole: '普通节点', |
||||
is_consensus: '否', |
||||
create_time: '2021-02-25T02:27:18.151Z', |
||||
status: '正常', |
||||
isSelf: true, |
||||
isUpgrade: true, |
||||
}, |
||||
{ |
||||
id: 15, |
||||
node_name: '节点2', |
||||
institutions_name: '机构2', |
||||
ip: '147.174.6.190', |
||||
port: 26042, |
||||
nodeRole: '普通节点', |
||||
is_consensus: '是', |
||||
create_time: '2021-03-25T02:25:18.151Z', |
||||
status: '正常', |
||||
isSelf: false, |
||||
isUpgrade: false, |
||||
}, |
||||
]; |
||||
} |
||||
|
||||
return { |
||||
code: 0, |
||||
result: { |
||||
total: items.length, |
||||
list: items, |
||||
}, |
||||
}; |
||||
}, |
||||
}, |
||||
]; |
@ -0,0 +1,98 @@ |
||||
import { MockMethod } from 'vite-plugin-mock'; |
||||
import { resultError, resultSuccess, getRequestToken, requestParams } from './_util'; |
||||
|
||||
export function createFakeUserList() { |
||||
return [ |
||||
{ |
||||
userId: '1', |
||||
username: 'admin', |
||||
realName: 'sssgoEasy Admin', |
||||
avatar: '', |
||||
desc: 'manager', |
||||
password: '123456', |
||||
token: 'fakeToken1', |
||||
auths: [], |
||||
modules: [], |
||||
is_admin: 1, |
||||
role_name: '管理员角色', |
||||
mobile: 13000000000, |
||||
last_login: '2021-11-11 12:00', |
||||
role: 1, // 管理
|
||||
}, |
||||
{ |
||||
userId: '2', |
||||
username: 'test', |
||||
password: '123456', |
||||
realName: 'test user', |
||||
avatar: '', |
||||
desc: 'tester', |
||||
token: 'fakeToken2', |
||||
auths: [], |
||||
modules: ['home', 'website'], |
||||
is_admin: 0, |
||||
role_name: '普通用户角色', |
||||
mobile: 18000000000, |
||||
last_login: '2021-11-11 12:12', |
||||
role: 0, // 普通
|
||||
}, |
||||
]; |
||||
} |
||||
|
||||
export default [ |
||||
// mock user login
|
||||
{ |
||||
url: '/v1/user/login', |
||||
timeout: 200, |
||||
method: 'post', |
||||
response: ({ body }) => { |
||||
const { username, password } = body; |
||||
const checkUser = createFakeUserList().find( |
||||
(item) => item.username === username && password === item.password, |
||||
); |
||||
if (!checkUser) { |
||||
return resultError('Incorrect account or password!'); |
||||
} |
||||
return resultSuccess(checkUser); |
||||
}, |
||||
}, |
||||
{ |
||||
url: '/v1/user/permission', |
||||
method: 'get', |
||||
response: (request: requestParams) => { |
||||
const token = getRequestToken(request); |
||||
if (!token) return resultError('Invalid token'); |
||||
const checkUser = createFakeUserList().find((item) => item.token === token); |
||||
if (!checkUser) { |
||||
return resultError('The corresponding user information was not obtained!'); |
||||
} |
||||
return resultSuccess(checkUser); |
||||
}, |
||||
}, |
||||
{ |
||||
url: '/v1/user/logout', |
||||
timeout: 200, |
||||
method: 'get', |
||||
response: (request: requestParams) => { |
||||
const token = getRequestToken(request); |
||||
if (!token) return resultError('Invalid token'); |
||||
const checkUser = createFakeUserList().find((item) => item.token === token); |
||||
if (!checkUser) { |
||||
return resultError('Invalid token!'); |
||||
} |
||||
return resultSuccess(undefined, { message: 'Token has been destroyed' }); |
||||
}, |
||||
}, |
||||
{ |
||||
url: '/v1/account/info', |
||||
method: 'get', |
||||
response: (request: requestParams) => { |
||||
const token = getRequestToken(request); |
||||
if (!token) return resultError('Invalid token'); |
||||
const checkUser = createFakeUserList().find((item) => item.token === token); |
||||
if (!checkUser) { |
||||
return resultError('The corresponding user information was not obtained!'); |
||||
} |
||||
return resultSuccess(checkUser); |
||||
}, |
||||
}, |
||||
] as MockMethod[]; |
@ -0,0 +1,99 @@ |
||||
{ |
||||
"name": "vite-vue3-ts", |
||||
"version": "1.0.0", |
||||
"license": "MIT", |
||||
"description": "a vite + vue3 + pinia + typescript + ant-design-vue template", |
||||
"keywords": [ |
||||
"vite", |
||||
"vue3", |
||||
"vue-router", |
||||
"setup", |
||||
"typescript", |
||||
"pinia", |
||||
"ant-design-vue", |
||||
"template" |
||||
], |
||||
"homepage": "https://github.com/js-banana/vite-vue3-ts", |
||||
"repository": { |
||||
"type": "git", |
||||
"url": "git+https://github.com/js-banana/vite-vue3-ts.git" |
||||
}, |
||||
"engines": { |
||||
"node": ">=14" |
||||
}, |
||||
"scripts": { |
||||
"bootstrap": "pnpm install", |
||||
"check": "vue-tsc --noEmit", |
||||
"serve": "npm run dev", |
||||
"dev": "vite", |
||||
"dev:development": "vite --mode development", |
||||
"build:production": "vue-tsc --noEmit && vite build --mode production", |
||||
"build": "vite build", |
||||
"build:check": "vue-tsc --noEmit && vite build", |
||||
"build:github": "vite build --base=/vite-vue3-ts/", |
||||
"build:no-cache": "pnpm clean:cache && npm run build", |
||||
"preview": "vite preview", |
||||
"clean:cache": "rimraf node_modules/.cache/ && rimraf node_modules/.vite", |
||||
"clean:lib": "rimraf node_modules", |
||||
"lint:eslint": "eslint --cache --max-warnings 0 \"{src,mock}/**/*.{vue,ts,tsx}\" --fix", |
||||
"lint:prettier": "prettier --write \"src/**/*.{js,json,tsx,css,less,scss,vue,html,md}\"", |
||||
"lint:lint-staged": "lint-staged -c ./.husky/lintstagedrc.js", |
||||
"changelog": "conventional-changelog -p angular -i CHANGELOG.md -s && npx prettier CHANGELOG.md --write && git add CHANGELOG.md", |
||||
"prepare": "husky install" |
||||
}, |
||||
"dependencies": { |
||||
"@ant-design/icons-vue": "^6.0.1", |
||||
"@vueuse/core": "^6.8.0", |
||||
"ant-design-vue": "^3.2.7", |
||||
"axios": "^0.24.0", |
||||
"dayjs": "^1.11.3", |
||||
"echarts": "^5.2.2", |
||||
"lodash-es": "^4.17.21", |
||||
"pinia": "^2.0.3", |
||||
"sanitize.css": "^13.0.0", |
||||
"vue": "^3.2.16", |
||||
"vue-request": "^1.2.3", |
||||
"vue-router": "^4.0.12" |
||||
}, |
||||
"devDependencies": { |
||||
"@commitlint/cli": "^15.0.0", |
||||
"@commitlint/config-conventional": "^15.0.0", |
||||
"@faker-js/faker": "^7.3.0", |
||||
"@types/lodash-es": "^4.17.5", |
||||
"@typescript-eslint/eslint-plugin": "^4.31.1", |
||||
"@typescript-eslint/parser": "^4.31.1", |
||||
"@vitejs/plugin-legacy": "^2.0.0", |
||||
"@vitejs/plugin-vue": "^3.0.1", |
||||
"@vitejs/plugin-vue-jsx": "^2.0.0", |
||||
"autoprefixer": "^10.4.0", |
||||
"conventional-changelog-cli": "^2.2.2", |
||||
"eslint": "^7.32.0", |
||||
"eslint-config-prettier": "^8.3.0", |
||||
"eslint-define-config": "~1.0.9", |
||||
"eslint-plugin-jest": "^25.3.0", |
||||
"eslint-plugin-prettier": "^4.0.0", |
||||
"eslint-plugin-vue": "^7.17.0", |
||||
"husky": "^7.0.4", |
||||
"jest": "^27.4.2", |
||||
"less": "^4.1.2", |
||||
"lint-staged": "^11.2.6", |
||||
"mockjs": "^1.1.0", |
||||
"postcss": "^8.3.11", |
||||
"postcss-html": "^1.2.0", |
||||
"postcss-less": "^5.0.0", |
||||
"prettier": "^2.4.1", |
||||
"rimraf": "^3.0.2", |
||||
"rollup-plugin-visualizer": "^5.7.1", |
||||
"terser": "^5.14.2", |
||||
"typescript": "^4.4.3", |
||||
"unplugin-auto-import": "^0.4.20", |
||||
"unplugin-vue-components": "^0.17.21", |
||||
"vite": "^3.0.4", |
||||
"vite-plugin-compression": "^0.3.6", |
||||
"vite-plugin-mock": "^2.9.6", |
||||
"vite-plugin-style-import": "^1.4.0", |
||||
"vite-plugin-svg-icons": "^1.0.5", |
||||
"vue-eslint-parser": "^7.11.0", |
||||
"vue-tsc": "^0.3.0" |
||||
} |
||||
} |
@ -0,0 +1,5 @@ |
||||
module.exports = { |
||||
plugins: { |
||||
autoprefixer: {}, |
||||
}, |
||||
}; |
@ -0,0 +1,10 @@ |
||||
module.exports = { |
||||
printWidth: 100, |
||||
semi: true, |
||||
vueIndentScriptAndStyle: true, |
||||
singleQuote: true, |
||||
trailingComma: 'all', |
||||
proseWrap: 'never', |
||||
htmlWhitespaceSensitivity: 'strict', |
||||
endOfLine: 'auto', |
||||
}; |
After Width: | Height: | Size: 17 KiB |
@ -0,0 +1,26 @@ |
||||
<template> |
||||
<ConfigProvider :locale="zhCN"> |
||||
<router-view /> |
||||
</ConfigProvider> |
||||
</template> |
||||
|
||||
<script lang="ts" setup> |
||||
import { ConfigProvider } from 'ant-design-vue'; |
||||
import zhCN from 'ant-design-vue/es/locale/zh_CN'; |
||||
import { useTitle } from '/@/hooks/useTitle'; |
||||
|
||||
// date-picker 国际化失效问题 |
||||
// 引入dist下的文件:import 'moment/dist/locale/zh-cn' |
||||
// 确保 moment版本一致 |
||||
// import moment from 'moment'; |
||||
// import 'moment/dist/locale/zh-cn'; |
||||
// moment.locale('zh_CN'); |
||||
|
||||
import dayjs from 'dayjs'; |
||||
import 'dayjs/locale/zh-cn'; |
||||
dayjs.locale('zh-cn'); |
||||
|
||||
useTitle(); |
||||
|
||||
console.log('my config env: ', import.meta.env); |
||||
</script> |
@ -0,0 +1,13 @@ |
||||
import { ReqParams, ResResult } from './model'; |
||||
import { get } from '/@/utils/http'; |
||||
|
||||
enum URL { |
||||
page_one_list = '/v1/common/page_one/list', |
||||
list = '/v1/node/nodelist', |
||||
} |
||||
|
||||
const page_one_list = async (data: ReqParams) => get<ResResult>({ url: URL.page_one_list, data }); |
||||
|
||||
const node_list = async (data: ReqParams) => get<ResResult>({ url: URL.list, data }); |
||||
|
||||
export default { page_one_list, node_list }; |
@ -0,0 +1,14 @@ |
||||
export interface ReqParams { |
||||
limit: number; |
||||
page: number; |
||||
} |
||||
|
||||
export interface ResResult { |
||||
id: number; |
||||
url: string; |
||||
ip: string; |
||||
protocol: string; |
||||
host: number; |
||||
domain: string; |
||||
email: string; |
||||
} |
@ -0,0 +1,6 @@ |
||||
// 接口返回 形状
|
||||
export interface ResData<T> { |
||||
code: number; |
||||
message: string; |
||||
result: T; |
||||
} |
@ -0,0 +1,13 @@ |
||||
import { ReqParams, ResInfoList, ResResult } from './model'; |
||||
import { get } from '/@/utils/http'; |
||||
|
||||
enum URL { |
||||
list = '/v1/home/list', |
||||
info = '/v1/home/info', |
||||
} |
||||
|
||||
const list = async (data: ReqParams) => get<ResResult>({ url: URL.list, data }); |
||||
|
||||
const info = async () => get<ResInfoList>({ url: URL.info }); |
||||
|
||||
export default { list, info }; |
@ -0,0 +1,32 @@ |
||||
export interface ReqParams { |
||||
limit: number; |
||||
page: number; |
||||
} |
||||
|
||||
export interface ResResult { |
||||
id: string; |
||||
title: string; |
||||
name: string; |
||||
description: string; |
||||
created_at: string; |
||||
updated_at: string; |
||||
age: number; |
||||
color: string; |
||||
email: string; |
||||
} |
||||
|
||||
interface ResInfoListItem { |
||||
id: number; |
||||
num: number; |
||||
time: string; |
||||
} |
||||
|
||||
export interface ResInfoList { |
||||
hu_num: number; |
||||
yun_num: number; |
||||
ce_num: number; |
||||
create_time: number; |
||||
online_num: number; |
||||
total_num: number; |
||||
seven_days: ResInfoListItem[]; |
||||
} |
@ -0,0 +1,18 @@ |
||||
/** |
||||
* @name account |
||||
* @description 系统设置 - 账户模块 |
||||
*/ |
||||
|
||||
import { ReqAccount, ResAccount } from './model'; |
||||
import { get, post } from '/@/utils/http'; |
||||
|
||||
enum URL { |
||||
update = '/v1/account/edit', |
||||
account = '/v1/account/info', |
||||
} |
||||
|
||||
const account = async () => get<ResAccount>({ url: URL.account }); |
||||
|
||||
const update = async (data: ReqAccount) => post<any>({ url: URL.update, data }); |
||||
|
||||
export default { account, update }; |
@ -0,0 +1,16 @@ |
||||
export interface ReqAccount { |
||||
id: number; |
||||
account?: string; |
||||
password?: string; |
||||
} |
||||
|
||||
export interface ResAccount { |
||||
account: string; |
||||
last_login: string; |
||||
mobile: string; |
||||
role_name: string; |
||||
true_name: string; |
||||
user_id: number; |
||||
} |
||||
|
||||
export type ResPermission = { auths: Array<string> }; |
@ -0,0 +1,13 @@ |
||||
import { ReqAuth, ReqParams, ResResult } from './model'; |
||||
import { get, post } from '/@/utils/http'; |
||||
|
||||
enum URL { |
||||
login = '/v1/user/login', |
||||
permission = '/v1/user/permission', |
||||
} |
||||
|
||||
const login = async (data: ReqParams) => post<ResResult>({ url: URL.login, data }); |
||||
|
||||
const permission = async () => get<ReqAuth>({ url: URL.permission }); |
||||
|
||||
export default { login, permission }; |
@ -0,0 +1,16 @@ |
||||
export interface ReqParams { |
||||
mobile: 'string'; |
||||
password: 'string'; |
||||
} |
||||
|
||||
export interface ReqAuth { |
||||
auths: string[]; |
||||
modules: string[]; |
||||
is_admin?: 0 | 1; |
||||
} |
||||
|
||||
export interface ResResult { |
||||
login_status: number; |
||||
st: string; |
||||
token: string; |
||||
} |
After Width: | Height: | Size: 535 B |
After Width: | Height: | Size: 1.4 KiB |
After Width: | Height: | Size: 1.8 KiB |
After Width: | Height: | Size: 822 B |
After Width: | Height: | Size: 1.8 KiB |
After Width: | Height: | Size: 19 KiB |
After Width: | Height: | Size: 93 KiB |
After Width: | Height: | Size: 12 KiB |
@ -0,0 +1,20 @@ |
||||
<template> |
||||
<template v-if="message"> |
||||
<a-alert :message="message" :type="type" /> |
||||
<a-divider class="line" /> |
||||
</template> |
||||
</template> |
||||
<script lang="ts" setup> |
||||
import { PropType } from 'vue'; |
||||
|
||||
defineProps({ |
||||
message: { |
||||
type: String, |
||||
default: '', |
||||
}, |
||||
type: { |
||||
type: String as PropType<'success' | 'info' | 'warning' | 'error'>, |
||||
default: 'error', |
||||
}, |
||||
}); |
||||
</script> |
@ -0,0 +1,46 @@ |
||||
<template> |
||||
<a-card> |
||||
<a-breadcrumb :routes="breadcrumb"> |
||||
<template #itemRender="{ route }"> |
||||
<span class="font14 color-666"> |
||||
{{ route.breadcrumbName }} |
||||
</span> |
||||
</template> |
||||
</a-breadcrumb> |
||||
<h2 class="font18 marT13 rowSC link" @click="handleBreadcrumb"> |
||||
<LeftOutlined /> |
||||
<span class="marL10">{{ title }}</span> |
||||
</h2> |
||||
</a-card> |
||||
</template> |
||||
<script setup lang="ts"> |
||||
import { LeftOutlined } from '@ant-design/icons-vue'; |
||||
import { Route } from 'ant-design-vue/es/breadcrumb/Breadcrumb'; |
||||
import { useBreadcrumbTitle } from '/@/hooks/useBreadcrumbTitle'; |
||||
|
||||
const { title } = useBreadcrumbTitle(); |
||||
const router = useRouter(); |
||||
|
||||
const emits = defineEmits(['handleClick']); |
||||
|
||||
const breadcrumb = computed( |
||||
() => |
||||
router.currentRoute.value.matched |
||||
.filter((n) => !['/', '/app'].includes(n.path)) |
||||
.map((item) => { |
||||
return { |
||||
path: item.path, |
||||
breadcrumbName: item.meta.title || '', |
||||
}; |
||||
}) as Route[], |
||||
); |
||||
|
||||
const handleBreadcrumb = () => { |
||||
emits('handleClick', breadcrumb.value); |
||||
}; |
||||
</script> |
||||
<style scoped> |
||||
.link { |
||||
text-decoration: none; |
||||
} |
||||
</style> |
@ -0,0 +1,54 @@ |
||||
<template> |
||||
<icon-font :style="iconStyle" :class="iconClass" :type="iconType" /> |
||||
</template> |
||||
<script lang="ts"> |
||||
import { createFromIconfontCN } from '@ant-design/icons-vue'; |
||||
import { ICONFONTURL } from '../../../config/constant'; |
||||
|
||||
const IconFont = createFromIconfontCN({ |
||||
scriptUrl: ICONFONTURL, |
||||
}); |
||||
|
||||
export default defineComponent({ |
||||
components: { |
||||
IconFont, |
||||
}, |
||||
props: { |
||||
type: { |
||||
type: String, |
||||
required: true, |
||||
}, |
||||
className: { |
||||
type: String, |
||||
default: '', |
||||
}, |
||||
align: { |
||||
type: String, |
||||
default: '', |
||||
}, |
||||
size: { |
||||
type: String, |
||||
default: '18px', |
||||
}, |
||||
}, |
||||
setup(props) { |
||||
return { |
||||
iconType: computed(() => `icon-a-SimpleChainlianmenglianjichubantubiao_${props.type}`), |
||||
iconClass: computed(() => (props.className ? `my-icon ${props.className}` : 'my-icon')), |
||||
iconStyle: computed(() => { |
||||
const style = {}; |
||||
if (props.align) style['vertical-align'] = props.align; |
||||
if (props.size) style['font-size'] = props.size; |
||||
return style; |
||||
}), |
||||
}; |
||||
}, |
||||
}); |
||||
</script> |
||||
<style scoped> |
||||
.my-icon { |
||||
font-size: 18px; |
||||
vertical-align: middle; |
||||
fill: currentColor; |
||||
} |
||||
</style> |
@ -0,0 +1,49 @@ |
||||
<template> |
||||
<a-modal :width="640" :visible="visible" @cancel="handleCancel" :footer="null"> |
||||
<template #title> |
||||
<span class="font18">{{ title }}</span> |
||||
</template> |
||||
<slot></slot> |
||||
<a-row> |
||||
<a-col style="width: 110px" /> |
||||
<a-col :span="16" class="rowE"> |
||||
<a-space> |
||||
<a-button @click="handleCancel">取消</a-button> |
||||
<a-button :loading="loading" @click="handleSubmit" type="primary">{{ okText }}</a-button> |
||||
</a-space> |
||||
</a-col> |
||||
</a-row> |
||||
</a-modal> |
||||
</template> |
||||
<script lang="ts"> |
||||
export default defineComponent({ |
||||
props: { |
||||
title: { |
||||
type: String, |
||||
default: '', |
||||
}, |
||||
visible: { |
||||
type: Boolean, |
||||
default: false, |
||||
}, |
||||
loading: { |
||||
type: Boolean, |
||||
default: false, |
||||
}, |
||||
okText: { |
||||
type: String, |
||||
default: '创建', |
||||
}, |
||||
}, |
||||
emits: ['cancel', 'ok'], |
||||
setup(_, { emit }) { |
||||
const handleCancel = () => emit('cancel'); |
||||
const handleSubmit = () => emit('ok'); |
||||
|
||||
return { |
||||
handleCancel, |
||||
handleSubmit, |
||||
}; |
||||
}, |
||||
}); |
||||
</script> |
@ -0,0 +1,220 @@ |
||||
<template> |
||||
<div class="table-component"> |
||||
<!-- filter --> |
||||
<TableFilter |
||||
:button="tableFilterButton" |
||||
:items="tableFilterItems" |
||||
:model="tableFilterModel" |
||||
:hiddenFilter="hiddenFilter" |
||||
:pagination="null" |
||||
@onSearch="onSearch" |
||||
/> |
||||
<!-- table --> |
||||
<a-table |
||||
:class="['ant-table-striped', { border: hasBordered }]" |
||||
:rowClassName="(_, index) => (index % 2 === 1 ? 'table-striped' : '')" |
||||
:dataSource="dataSource" |
||||
:columns="columns" |
||||
:rowKey="(record) => record.id" |
||||
:pagination="pagination" |
||||
:loading="loading" |
||||
@change="handleTableChange" |
||||
:scroll="scroll" |
||||
> |
||||
<!-- slot 写法自定义 操作列 --> |
||||
<!-- <template #[item]="data" v-for="item in Object.keys($slots)" :key="item"> |
||||
<slot :name="item" v-bind="data || {}"></slot> |
||||
</template> --> |
||||
<template #bodyCell="{ column, text, index, record }"> |
||||
<template v-if="column.key === 'toIndex'"> |
||||
<span>{{ index + 1 }}</span> |
||||
</template> |
||||
<template v-if="column.key === 'toDate'"> |
||||
<span>{{ text ? formatDate(text) : '-' }}</span> |
||||
</template> |
||||
<template v-if="column.key === 'toDateTime'"> |
||||
<span>{{ text ? formatDate(text, 'time') : '-' }}</span> |
||||
</template> |
||||
<!-- 函数式写法自定义 操作列 --> |
||||
<template v-if="column.key === 'action'"> |
||||
<template v-for="(action, index) in getActions" :key="`${index}-${action.label}`"> |
||||
<!-- 气泡确认框 --> |
||||
<a-popconfirm |
||||
v-if="action.enable" |
||||
:title="action?.title" |
||||
@confirm="action?.onConfirm(record)" |
||||
@cancel="action?.onCancel(record)" |
||||
> |
||||
<a @click.prevent="() => {}" :type="action.type">{{ action.label }}</a> |
||||
</a-popconfirm> |
||||
<span v-else-if="!action.permission">——</span> |
||||
<!-- 按钮 --> |
||||
<a v-else @click="action?.onClick(record)" :type="action.type">{{ action.label }}</a> |
||||
<!-- 分割线 --> |
||||
<a-divider type="vertical" v-if="index < getActions.length - 1" /> |
||||
</template> |
||||
</template> |
||||
</template> |
||||
</a-table> |
||||
</div> |
||||
</template> |
||||
<script lang="ts"> |
||||
import { FilterValue } from 'ant-design-vue/es/table/interface'; |
||||
import dayjs from 'dayjs'; |
||||
import { usePagination } from 'vue-request'; |
||||
import { formatToDate, formatToDateTime } from '/@/utils/dateUtil'; |
||||
import { usePermission } from '/@/hooks/usePermission'; |
||||
import { useRole } from '/@/hooks/useRole'; |
||||
import { TablePaginationConfig } from 'ant-design-vue/lib/table/interface'; |
||||
|
||||
// const req = () => new Promise((resolve) => resolve({ total: 0, list: [] })); |
||||
|
||||
export default defineComponent({ |
||||
props: [ |
||||
'bordered', |
||||
'hiddenFilter', |
||||
'url' /* 请求接口 promise */, |
||||
'columns' /* Table组件:columns 不包含操作列 */, |
||||
'actions' /* Table组件:操作列 */, |
||||
'button' /* Filter筛选列组件:交互按钮 */, |
||||
'items' /* Filter筛选列组件:包含的项 */, |
||||
'model' /* Filter筛选列组件:form model */, |
||||
'resKey', |
||||
'scroll', |
||||
], |
||||
// emits: ['onSearch'], |
||||
setup(props) { |
||||
const { hasPermission } = usePermission(); |
||||
const { hasRole } = useRole(); |
||||
const { |
||||
data: dataSource, |
||||
run, |
||||
loading, |
||||
current, |
||||
pageSize, |
||||
total, |
||||
refresh, |
||||
} = usePagination(props.url, { |
||||
// formatResult: (res: any) => (props.resKey ? res[props.resKey.list] : res.list), |
||||
pagination: { |
||||
pageSizeKey: 'limit', |
||||
currentKey: 'page', |
||||
}, |
||||
}); |
||||
|
||||
const hasBordered = computed(() => props.bordered ?? true); |
||||
|
||||
const listData = computed( |
||||
() => (dataSource.value as unknown as Indexable)?.[props?.resKey?.list || 'list'] || [], |
||||
); |
||||
|
||||
const pagination = computed(() => ({ |
||||
total: total.value, |
||||
current: current.value, |
||||
pageSize: pageSize.value, |
||||
showQuickJumper: true, |
||||
showSizeChanger: true, |
||||
showTotal: () => h('span', {}, `共 ${total.value} 条`), |
||||
})); |
||||
|
||||
const handleTableChange = ( |
||||
pag: TablePaginationConfig, |
||||
filters: Record<string, FilterValue | null>, |
||||
sorter: any, |
||||
) => { |
||||
run({ |
||||
limit: pag!.pageSize!, |
||||
page: pag?.current, |
||||
sortField: sorter.field, |
||||
sortOrder: sorter.order, |
||||
...filters, |
||||
}); |
||||
}; |
||||
|
||||
// action 操作列 |
||||
const getActions = computed(() => { |
||||
return ( |
||||
(toRaw(props.actions) || []) |
||||
// .filter((action) => hasPermission(action.auth)) |
||||
.map((action) => { |
||||
const { popConfirm } = action; |
||||
return { |
||||
type: 'link', |
||||
...action, |
||||
...(popConfirm || {}), |
||||
enable: !!popConfirm, |
||||
permission: hasPermission(action.auth) && hasRole(action.role), |
||||
}; |
||||
}) |
||||
); |
||||
}); |
||||
|
||||
// filter |
||||
const tableFilterModel = computed(() => props.model); |
||||
const tableFilterButton = computed(() => props.button); |
||||
const tableFilterItems = computed(() => props.items); |
||||
const onSearch = () => { |
||||
const args = toRaw(tableFilterModel.value) || {}; |
||||
|
||||
// 日期格式处理 |
||||
if (args) { |
||||
Object.keys(args).map((key) => { |
||||
if (args[key] && dayjs.isDayjs(args[key])) { |
||||
args[key] = formatToDate(args[key]); |
||||
} |
||||
}); |
||||
} |
||||
|
||||
run({ page: current.value, limit: pageSize.value, ...args }); |
||||
}; |
||||
|
||||
// 日期格式化 |
||||
const formatDate = (val: string, type: 'date' | 'time' = 'date') => { |
||||
const formatFn = type === 'date' ? formatToDate : formatToDateTime; |
||||
return val.length === 10 ? formatFn(Number(val) * 1000) : formatFn(val); |
||||
}; |
||||
|
||||
return { |
||||
dataSource: listData, |
||||
loading, |
||||
pagination, |
||||
hasBordered, |
||||
handleTableChange, |
||||
run, |
||||
refresh, |
||||
getActions, |
||||
// filter |
||||
tableFilterModel, |
||||
tableFilterButton, |
||||
tableFilterItems, |
||||
onSearch, |
||||
formatDate, |
||||
}; |
||||
}, |
||||
}); |
||||
</script> |
||||
<style lang="less" scoped> |
||||
.ant-table-striped :deep(.table-striped) td { |
||||
background-color: #fafafa; |
||||
} |
||||
.ant-table-striped :deep(.ant-table-pagination.ant-pagination) { |
||||
margin: 30px auto; |
||||
width: 100%; |
||||
text-align: center; |
||||
.ant-pagination-prev, |
||||
.ant-pagination-next { |
||||
.anticon { |
||||
vertical-align: 1.5px; |
||||
} |
||||
} |
||||
} |
||||
.ant-table-striped :deep(.ant-pagination-item-active) { |
||||
background: #3860f4; |
||||
a { |
||||
color: #ffffff; |
||||
} |
||||
} |
||||
.border { |
||||
border: 0.5px solid rgba(210, 210, 210, 0.5); |
||||
} |
||||
</style> |
@ -0,0 +1,132 @@ |
||||
<template> |
||||
<a-card v-if="!hasHidden" :body-style="{ padding: '0 0 24px 0' }" :bordered="false"> |
||||
<a-form class="form-container" layout="horizontal" :model="formModel"> |
||||
<a-row type="flex"> |
||||
<a-col v-if="button" flex="100px"> |
||||
<span class="text" v-if="button.type === 'text'">{{ button.label }}</span> |
||||
<!-- <a-button v-else v-bind="button" v-auth="button.auth" @click="button?.onClick">{{ |
||||
button.label |
||||
}}</a-button> --> |
||||
<a-button v-if="getButton.permission" v-bind="button" @click="button?.onClick">{{ |
||||
button.label |
||||
}}</a-button> |
||||
</a-col> |
||||
<a-col flex="auto" class="rowE"> |
||||
<a-space> |
||||
<template v-for="item in getItems" :key="item.name"> |
||||
<a-form-item :name="item.name"> |
||||
<a-select |
||||
v-if="item.type === 'select'" |
||||
:key="`select-${item.name}`" |
||||
v-bind="item" |
||||
v-model:value="formModel[item.name]" |
||||
class="default-select-w" |
||||
/> |
||||
<a-date-picker |
||||
v-else-if="item.type === 'date'" |
||||
:key="`date-${item.name}`" |
||||
v-bind="item" |
||||
v-model:value="formModel[item.name]" |
||||
class="default-select-w" |
||||
/> |
||||
<a-input-search |
||||
v-else |
||||
v-bind="item" |
||||
:key="`input-${item.name}`" |
||||
v-model:value="formModel[item.name]" |
||||
@search="handleSubmit" |
||||
class="default-search-w" |
||||
> |
||||
<template #enterButton> |
||||
<a-button type="primary">查询</a-button> |
||||
</template> |
||||
</a-input-search> |
||||
</a-form-item> |
||||
</template> |
||||
</a-space> |
||||
</a-col> |
||||
</a-row> |
||||
</a-form> |
||||
</a-card> |
||||
</template> |
||||
<script lang="ts"> |
||||
import { usePermission } from '/@/hooks/usePermission'; |
||||
import { useRole } from '/@/hooks/useRole'; |
||||
|
||||
export default defineComponent({ |
||||
props: ['hiddenFilter', 'button', 'items', 'model'], |
||||
emits: ['onSearch'], |
||||
|
||||
setup(props, { emit }) { |
||||
const { hasPermission } = usePermission(); |
||||
const { hasRole } = useRole(); |
||||
|
||||
const formModel = reactive(props.model || {}); |
||||
|
||||
const getItems = computed(() => { |
||||
return (props.items || []).map((item) => { |
||||
return { |
||||
type: 'input', |
||||
...item, |
||||
}; |
||||
}); |
||||
}); |
||||
|
||||
const handleSubmit = () => { |
||||
emit('onSearch'); |
||||
}; |
||||
|
||||
// onMounted(() => console.log(`hiddenFilter`, props.hiddenFilter)); |
||||
|
||||
const hasHidden = ref(props.hiddenFilter); |
||||
|
||||
watchEffect(() => { |
||||
// 如果都不存在 |
||||
if (!props.button && !props.items) { |
||||
hasHidden.value = true; |
||||
} |
||||
}); |
||||
|
||||
const getButton = computed(() => { |
||||
const plainObj = toRaw(props.button) || {}; |
||||
return { |
||||
...plainObj, |
||||
permission: hasPermission(plainObj.auth) && hasRole(plainObj.role), |
||||
}; |
||||
}); |
||||
|
||||
return { |
||||
formModel, |
||||
getItems, |
||||
hasHidden, |
||||
getButton, |
||||
handleSubmit, |
||||
}; |
||||
}, |
||||
}); |
||||
</script> |
||||
<style lang="less" scoped> |
||||
.form-container { |
||||
.default-select-w { |
||||
width: 120px; |
||||
} |
||||
.default-search-w { |
||||
width: 290px; |
||||
} |
||||
// & :deep(.ant-input) { |
||||
// height: 36px; |
||||
// } |
||||
// & :deep(.ant-select) { |
||||
// height: 36px; |
||||
// } |
||||
// & :deep(.ant-btn) { |
||||
// height: 36px; |
||||
// } |
||||
} |
||||
.text { |
||||
font-size: 16px; |
||||
font-weight: 600; |
||||
line-height: 22px; |
||||
color: rgba(0, 0, 0, 0.85); |
||||
} |
||||
</style> |
@ -0,0 +1,96 @@ |
||||
<template> |
||||
<div> |
||||
<a-upload |
||||
name="file" |
||||
v-model:file-list="fileList" |
||||
v-bind="uploadCof" |
||||
@change="handleChange" |
||||
:accept="accept" |
||||
> |
||||
<a-button v-if="fileList.length < 1"> |
||||
<upload-outlined style="vertical-align: 2px" /> |
||||
上传文件 |
||||
</a-button> |
||||
</a-upload> |
||||
</div> |
||||
</template> |
||||
<script setup lang="ts"> |
||||
import { useMessage } from '/@/hooks/useMessage'; |
||||
import { UploadOutlined } from '@ant-design/icons-vue'; |
||||
|
||||
const { createMessage } = useMessage(); |
||||
|
||||
// props |
||||
const props = defineProps({ |
||||
accept: { |
||||
type: String, |
||||
default: () => { |
||||
return '.doc'; |
||||
}, |
||||
}, |
||||
size: { |
||||
type: Number, |
||||
default: () => { |
||||
return null; |
||||
}, |
||||
}, |
||||
}); |
||||
|
||||
// emits |
||||
const emits = defineEmits(['uploadRemoved', 'uploadSuccess']); |
||||
|
||||
// uploadCof |
||||
const uploadCof = reactive({ |
||||
headers: { |
||||
Authorization: localStorage.getItem('x-auth-token'), |
||||
}, |
||||
action: location.origin + '/api/v1/upload', |
||||
}); |
||||
|
||||
const fileList = ref([]); |
||||
|
||||
const bytesToSize = (bytes = 0) => { |
||||
return bytes === 0 ? 0 : bytes / 1024 / 1024; |
||||
}; |
||||
|
||||
const handleChange = (info) => { |
||||
// 上传中 |
||||
if (info.file.status !== 'uploading') { |
||||
console.log(info.file, info.fileList); |
||||
if (props.size) { |
||||
if (props.size < bytesToSize(info.file.size)) { |
||||
createMessage.warn('文件大小不能超过' + props.size + 'M'); |
||||
return false; |
||||
} |
||||
} else if (info.fileList.length > 1) { |
||||
info.fileList.length = 1; |
||||
createMessage.warn('一次仅可以上传一个文件,请完成这次操作之后再上传'); |
||||
return false; |
||||
} else if (info.file.status === 'removed') { |
||||
createMessage.success('删除成功,请上传新的合约文件'); |
||||
info.fileList.length = 0; |
||||
emits('uploadRemoved', ''); |
||||
fileList.value = []; |
||||
return false; |
||||
} |
||||
} |
||||
|
||||
// 完成上传 |
||||
if (info.file.status === 'done') { |
||||
if (info.file.type.indexOf('image') !== -1) { |
||||
createMessage.error('请上传正确的格式类型'); |
||||
info.fileList.length = 0; |
||||
return false; |
||||
} |
||||
emits('uploadSuccess', { |
||||
data: info.file.response.result, |
||||
fileType: info.file.name, |
||||
}); |
||||
|
||||
createMessage.success(`${info.file.name} 上传成功`); |
||||
} else if (info.file.status === 'error') { |
||||
info.fileList.length = 0; |
||||
createMessage.error(`${info.file.name} 上传失败,请稍后再试`); |
||||
} |
||||
}; |
||||
</script> |
@ -0,0 +1,11 @@ |
||||
/** |
||||
* Configure and register global directives |
||||
*/ |
||||
import type { App } from 'vue'; |
||||
import { setupPermissionDirective } from './permission'; |
||||
import { setupRoleDirective } from './role'; |
||||
|
||||
export function setupGlobDirectives(app: App) { |
||||
setupPermissionDirective(app); |
||||
setupRoleDirective(app); |
||||
} |
@ -0,0 +1,32 @@ |
||||
/** |
||||
* Global authority directive |
||||
* Used for fine-grained control of component permissions |
||||
* @Example v-auth="RoleEnum.TEST" |
||||
*/ |
||||
import type { App, Directive, DirectiveBinding } from 'vue'; |
||||
|
||||
import { usePermission } from '/@/hooks/usePermission'; |
||||
|
||||
function isAuth(el: Element, binding: any) { |
||||
const { hasPermission } = usePermission(); |
||||
|
||||
const value = binding.value; |
||||
if (!value) return; |
||||
if (!hasPermission(value)) { |
||||
el.parentNode?.removeChild(el); |
||||
} |
||||
} |
||||
|
||||
const mounted = (el: Element, binding: DirectiveBinding<any>) => { |
||||
isAuth(el, binding); |
||||
}; |
||||
|
||||
const authDirective: Directive = { |
||||
mounted, |
||||
}; |
||||
|
||||
export function setupPermissionDirective(app: App) { |
||||
app.directive('auth', authDirective); |
||||
} |
||||
|
||||
export default authDirective; |
@ -0,0 +1,51 @@ |
||||
/** |
||||
* Global authority directive |
||||
* 角色控制: 银行 0 / 监管 1 |
||||
* @Example v-role="role" |
||||
*/ |
||||
import type { App, Directive, DirectiveBinding } from 'vue'; |
||||
import { useRole } from '/@/hooks/useRole'; |
||||
import intersection from 'lodash-es/intersection'; |
||||
|
||||
// 操作按钮无权限时,替换展示内容
|
||||
function replaceHtml(parentNode: HTMLElement | null) { |
||||
if (!parentNode) return; |
||||
|
||||
const child = document.createElement('span'); |
||||
// 只过滤 Table里的操作按钮
|
||||
const classNames = ['ant-space-item', 'ant-table-row-cell-break-word']; |
||||
const parentNodeText = |
||||
intersection(classNames, parentNode?.className?.split(' ')).length > 0 ? '——' : ''; |
||||
// console.dir(parentNode);
|
||||
child.innerHTML = parentNodeText; |
||||
child.style.color = 'rgba(0,0,0,.08)'; |
||||
parentNode?.appendChild(child); |
||||
} |
||||
|
||||
function isAuth(el: Element, binding: any) { |
||||
const { hasRole } = useRole(); |
||||
const value = binding.value; |
||||
// 过滤 undefined、null
|
||||
if (value == null) return; |
||||
// 权限验证
|
||||
|
||||
if (!hasRole(value)) { |
||||
const parentNode = el.parentNode; |
||||
el.parentNode?.removeChild(el); |
||||
replaceHtml(parentNode as any); |
||||
} |
||||
} |
||||
|
||||
const mounted = (el: Element, binding: DirectiveBinding<any>) => { |
||||
isAuth(el, binding); |
||||
}; |
||||
|
||||
const authDirective: Directive = { |
||||
mounted, |
||||
}; |
||||
|
||||
export function setupRoleDirective(app: App) { |
||||
app.directive('role', authDirective); |
||||
} |
||||
|
||||
export default authDirective; |
@ -0,0 +1,27 @@ |
||||
/** |
||||
* @name AuthEnum |
||||
* @description 权限,配合指令 v-auth 使用 |
||||
* @Example v-auth="AuthEnum.user_create" |
||||
*/ |
||||
|
||||
export enum AuthEnum { |
||||
/** |
||||
* 用户 |
||||
*/ |
||||
// 新增用户
|
||||
user_create = '/v1/user/create', |
||||
// 编辑用户
|
||||
user_update = '/v1/user/update', |
||||
// 删除用户
|
||||
user_delete = '/v1/user/delete', |
||||
|
||||
/** |
||||
* 角色 |
||||
*/ |
||||
// 新增角色
|
||||
role_create = '/v1/role/create', |
||||
// 修改角色
|
||||
role_update = '/v1/role/update', |
||||
// 删除角色
|
||||
role_delete = '/v1/role/delete', |
||||
} |
@ -0,0 +1,38 @@ |
||||
/** |
||||
* @name useBreadcrumbTitle |
||||
* @description 修改面包屑Title |
||||
* @param isAddOn 是否为添加 on注册 |
||||
*/ |
||||
|
||||
import mitt from '/@/utils/mitt'; |
||||
import { onMounted, onUnmounted, ref } from 'vue'; |
||||
import { useRoute } from 'vue-router'; |
||||
|
||||
const emitter = mitt(); |
||||
|
||||
const key = Symbol(); |
||||
|
||||
export const useBreadcrumbTitle = (isAddOn = true) => { |
||||
const route = useRoute(); |
||||
const title = ref(route.meta.title); |
||||
|
||||
watch( |
||||
() => route.meta.title, |
||||
(val) => { |
||||
title.value = val; |
||||
}, |
||||
); |
||||
|
||||
const changeTitle = (val: string) => (title.value = val); |
||||
|
||||
onMounted(() => isAddOn && emitter.on(key, changeTitle)); |
||||
|
||||
onUnmounted(() => isAddOn && emitter.off(key, changeTitle)); |
||||
|
||||
const setBreadcrumbTitle = (title: string) => emitter.emit(key, title); |
||||
|
||||
return { |
||||
title, |
||||
setBreadcrumbTitle, |
||||
}; |
||||
}; |
@ -0,0 +1,108 @@ |
||||
import type { EChartsOption } from 'echarts'; |
||||
import type { Ref } from 'vue'; |
||||
import { useTimeoutFn } from '/@/hooks/useTimeout'; |
||||
import { Fn, tryOnUnmounted } from '@vueuse/core'; |
||||
import { unref, nextTick, computed, ref } from 'vue'; |
||||
import { useDebounceFn } from '@vueuse/core'; |
||||
import { useEventListener } from '/@/hooks/useEventListener'; |
||||
import echarts from '/@/utils/echarts'; |
||||
|
||||
export function useECharts( |
||||
elRef: Ref<HTMLDivElement>, |
||||
theme: 'light' | 'dark' | 'default' = 'default', |
||||
) { |
||||
const getDarkMode = computed(() => { |
||||
return theme; |
||||
}); |
||||
let chartInstance: echarts.ECharts | null = null; |
||||
let resizeFn: Fn = resize; |
||||
const cacheOptions = ref({}) as Ref<EChartsOption>; |
||||
let removeResizeFn: Fn = () => {}; |
||||
|
||||
resizeFn = useDebounceFn(resize, 200); |
||||
|
||||
const getOptions = computed(() => { |
||||
return { |
||||
backgroundColor: 'transparent', |
||||
...cacheOptions.value, |
||||
} as EChartsOption; |
||||
}); |
||||
|
||||
function initCharts() { |
||||
const el = unref(elRef); |
||||
if (!el || !unref(el)) { |
||||
return; |
||||
} |
||||
|
||||
chartInstance = echarts.init(el); |
||||
const { removeEvent } = useEventListener({ |
||||
el: window, |
||||
name: 'resize', |
||||
listener: resizeFn, |
||||
}); |
||||
removeResizeFn = removeEvent; |
||||
if (el.offsetHeight === 0) { |
||||
useTimeoutFn(() => { |
||||
resizeFn(); |
||||
}, 30); |
||||
} |
||||
} |
||||
|
||||
function setOptions(options: EChartsOption, clear = true) { |
||||
cacheOptions.value = options; |
||||
if (unref(elRef)?.offsetHeight === 0) { |
||||
useTimeoutFn(() => { |
||||
setOptions(unref(getOptions)); |
||||
}, 30); |
||||
return; |
||||
} |
||||
nextTick(() => { |
||||
useTimeoutFn(() => { |
||||
if (!chartInstance) { |
||||
initCharts(); |
||||
|
||||
if (!chartInstance) return; |
||||
} |
||||
clear && chartInstance?.clear(); |
||||
|
||||
chartInstance?.setOption(unref(getOptions)); |
||||
}, 30); |
||||
}); |
||||
} |
||||
|
||||
function resize() { |
||||
chartInstance?.resize(); |
||||
} |
||||
|
||||
watch( |
||||
() => getDarkMode.value, |
||||
() => { |
||||
if (chartInstance) { |
||||
chartInstance.dispose(); |
||||
initCharts(); |
||||
setOptions(cacheOptions.value); |
||||
} |
||||
}, |
||||
); |
||||
|
||||
tryOnUnmounted(() => { |
||||
if (!chartInstance) return; |
||||
removeResizeFn(); |
||||
chartInstance.dispose(); |
||||
chartInstance = null; |
||||
}); |
||||
|
||||
function getInstance(): echarts.ECharts | null { |
||||
if (!chartInstance) { |
||||
initCharts(); |
||||
} |
||||
return chartInstance; |
||||
} |
||||
|
||||
return { |
||||
setOptions, |
||||
resize, |
||||
echarts, |
||||
getInstance, |
||||
}; |
||||
} |
@ -0,0 +1,58 @@ |
||||
import type { Ref } from 'vue'; |
||||
import { ref, watch, unref } from 'vue'; |
||||
import { useThrottleFn, useDebounceFn } from '@vueuse/core'; |
||||
|
||||
export type RemoveEventFn = () => void; |
||||
export interface UseEventParams { |
||||
el?: Element | Ref<Element | undefined> | Window | any; |
||||
name: string; |
||||
listener: EventListener; |
||||
options?: boolean | AddEventListenerOptions; |
||||
autoRemove?: boolean; |
||||
isDebounce?: boolean; |
||||
wait?: number; |
||||
} |
||||
export function useEventListener({ |
||||
el = window, |
||||
name, |
||||
listener, |
||||
options, |
||||
autoRemove = true, |
||||
isDebounce = true, |
||||
wait = 80, |
||||
}: UseEventParams): { removeEvent: RemoveEventFn } { |
||||
/* eslint-disable-next-line */ |
||||
let remove: RemoveEventFn = () => {}; |
||||
const isAddRef = ref(false); |
||||
|
||||
if (el) { |
||||
const element = ref(el as Element) as Ref<Element>; |
||||
|
||||
const handler = isDebounce ? useDebounceFn(listener, wait) : useThrottleFn(listener, wait); |
||||
const realHandler = wait ? handler : listener; |
||||
const removeEventListener = (e: Element) => { |
||||
isAddRef.value = true; |
||||
e.removeEventListener(name, realHandler, options); |
||||
}; |
||||
const addEventListener = (e: Element) => e.addEventListener(name, realHandler, options); |
||||
|
||||
const removeWatch = watch( |
||||
element, |
||||
(v, _ov, cleanUp) => { |
||||
if (v) { |
||||
!unref(isAddRef) && addEventListener(v); |
||||
cleanUp(() => { |
||||
autoRemove && removeEventListener(v); |
||||
}); |
||||
} |
||||
}, |
||||
{ immediate: true }, |
||||
); |
||||
|
||||
remove = () => { |
||||
removeEventListener(element.value); |
||||
removeWatch(); |
||||
}; |
||||
} |
||||
return { removeEvent: remove }; |
||||
} |
@ -0,0 +1,125 @@ |
||||
import type { ModalFunc, ModalFuncProps } from 'ant-design-vue/lib/modal/Modal'; |
||||
|
||||
import { Modal, message as Message, notification } from 'ant-design-vue'; |
||||
import { InfoCircleFilled, CheckCircleFilled, CloseCircleFilled } from '@ant-design/icons-vue'; |
||||
|
||||
import { NotificationArgsProps, ConfigProps } from 'ant-design-vue/lib/notification'; |
||||
import { isString } from '/@/utils/is'; |
||||
|
||||
// 手动引入 message样式
|
||||
import 'ant-design-vue/es/message/style'; |
||||
import 'ant-design-vue/es/notification/style'; |
||||
|
||||
export interface NotifyApi { |
||||
info(config: NotificationArgsProps): void; |
||||
success(config: NotificationArgsProps): void; |
||||
error(config: NotificationArgsProps): void; |
||||
warn(config: NotificationArgsProps): void; |
||||
warning(config: NotificationArgsProps): void; |
||||
open(args: NotificationArgsProps): void; |
||||
close(key: String): void; |
||||
config(options: ConfigProps): void; |
||||
destroy(): void; |
||||
} |
||||
|
||||
export declare type NotificationPlacement = 'topLeft' | 'topRight' | 'bottomLeft' | 'bottomRight'; |
||||
export declare type IconType = 'success' | 'info' | 'error' | 'warning'; |
||||
export interface ModalOptionsEx extends Omit<ModalFuncProps, 'iconType'> { |
||||
iconType: 'warning' | 'success' | 'error' | 'info'; |
||||
} |
||||
export type ModalOptionsPartial = Partial<ModalOptionsEx> & Pick<ModalOptionsEx, 'content'>; |
||||
|
||||
interface ConfirmOptions { |
||||
info: ModalFunc; |
||||
success: ModalFunc; |
||||
error: ModalFunc; |
||||
warn: ModalFunc; |
||||
warning: ModalFunc; |
||||
} |
||||
|
||||
function getIcon(iconType: string) { |
||||
if (iconType === 'warning') { |
||||
return <InfoCircleFilled class="modal-icon-warning" />; |
||||
} else if (iconType === 'success') { |
||||
return <CheckCircleFilled class="modal-icon-success" />; |
||||
} else if (iconType === 'info') { |
||||
return <InfoCircleFilled class="modal-icon-info" />; |
||||
} else { |
||||
return <CloseCircleFilled class="modal-icon-error" />; |
||||
} |
||||
} |
||||
|
||||
function renderContent({ content }: Pick<ModalOptionsEx, 'content'>) { |
||||
if (isString(content)) { |
||||
return <div innerHTML={`<div>${content as string}</div>`}></div>; |
||||
} else { |
||||
return content; |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* @description: Create confirmation box |
||||
*/ |
||||
function createConfirm(options: ModalOptionsEx): ConfirmOptions { |
||||
const iconType = options.iconType || 'warning'; |
||||
Reflect.deleteProperty(options, 'iconType'); |
||||
const opt: ModalFuncProps = { |
||||
centered: true, |
||||
icon: getIcon(iconType), |
||||
...options, |
||||
content: renderContent(options), |
||||
}; |
||||
return Modal.confirm(opt) as unknown as ConfirmOptions; |
||||
} |
||||
|
||||
const getBaseOptions = () => { |
||||
return { |
||||
okText: '确定', |
||||
centered: true, |
||||
}; |
||||
}; |
||||
|
||||
function createModalOptions(options: ModalOptionsPartial, icon: string): ModalOptionsPartial { |
||||
return { |
||||
...getBaseOptions(), |
||||
...options, |
||||
content: renderContent(options), |
||||
icon: getIcon(icon), |
||||
}; |
||||
} |
||||
|
||||
function createSuccessModal(options: ModalOptionsPartial) { |
||||
return Modal.success(createModalOptions(options, 'success')); |
||||
} |
||||
|
||||
function createErrorModal(options: ModalOptionsPartial) { |
||||
return Modal.error(createModalOptions(options, 'close')); |
||||
} |
||||
|
||||
function createInfoModal(options: ModalOptionsPartial) { |
||||
return Modal.info(createModalOptions(options, 'info')); |
||||
} |
||||
|
||||
function createWarningModal(options: ModalOptionsPartial) { |
||||
return Modal.warning(createModalOptions(options, 'warning')); |
||||
} |
||||
|
||||
notification.config({ |
||||
placement: 'topRight', |
||||
duration: 3, |
||||
}); |
||||
|
||||
/** |
||||
* @description: message |
||||
*/ |
||||
export function useMessage() { |
||||
return { |
||||
createMessage: Message, |
||||
notification: notification as NotifyApi, |
||||
createConfirm: createConfirm, |
||||
createSuccessModal, |
||||
createErrorModal, |
||||
createInfoModal, |
||||
createWarningModal, |
||||
}; |
||||
} |
@ -0,0 +1,35 @@ |
||||
/** |
||||
* @name usePermission |
||||
* @description 处理权限 |
||||
*/ |
||||
|
||||
import intersection from 'lodash-es/intersection'; |
||||
import { isArray } from '../utils/is'; |
||||
import { usePermissioStore } from '/@/store/modules/permission'; |
||||
|
||||
export function usePermission() { |
||||
const permissioStore = usePermissioStore(); |
||||
|
||||
function hasPermission(value?: string | string[], def = true): boolean { |
||||
// Visible by default
|
||||
if (!value) { |
||||
return def; |
||||
} |
||||
|
||||
if (permissioStore.getIsAdmin === 1) { |
||||
return true; |
||||
} |
||||
|
||||
if (!isArray(value)) { |
||||
return permissioStore.getAuths?.includes(value); |
||||
} |
||||
|
||||
if (isArray(value)) { |
||||
return intersection(value, permissioStore.getAuths).length > 0; |
||||
} |
||||
|
||||
return true; |
||||
} |
||||
|
||||
return { hasPermission }; |
||||
} |
@ -0,0 +1,27 @@ |
||||
/** |
||||
* @name useRole |
||||
* @description 处理角色权限 |
||||
*/ |
||||
|
||||
import { usePermissioStore } from '/@/store/modules/permission'; |
||||
|
||||
export function useRole() { |
||||
const permissioStore = usePermissioStore(); |
||||
|
||||
function hasRole(value?: string | string[], def = true): boolean { |
||||
if (value == null) { |
||||
return def; |
||||
} |
||||
|
||||
if (typeof value === 'boolean') { |
||||
return value; |
||||
} |
||||
|
||||
if (typeof value === 'number') { |
||||
return permissioStore.getRole === value; |
||||
} |
||||
return def; |
||||
} |
||||
|
||||
return { hasRole }; |
||||
} |
@ -0,0 +1,45 @@ |
||||
import { ref, watch } from 'vue'; |
||||
import { Fn, tryOnUnmounted } from '@vueuse/core'; |
||||
import { isFunction } from '/@/utils/is'; |
||||
|
||||
export function useTimeoutFn(handle: Fn, wait: number, native = false) { |
||||
if (!isFunction(handle)) { |
||||
throw new Error('handle is not Function!'); |
||||
} |
||||
|
||||
const { readyRef, stop, start } = useTimeoutRef(wait); |
||||
if (native) { |
||||
handle(); |
||||
} else { |
||||
watch( |
||||
readyRef, |
||||
(maturity) => { |
||||
maturity && handle(); |
||||
}, |
||||
{ immediate: false }, |
||||
); |
||||
} |
||||
return { readyRef, stop, start }; |
||||
} |
||||
|
||||
export function useTimeoutRef(wait: number) { |
||||
const readyRef = ref(false); |
||||
|
||||
let timer: TimeoutHandle; |
||||
function stop(): void { |
||||
readyRef.value = false; |
||||
timer && window.clearTimeout(timer); |
||||
} |
||||
function start(): void { |
||||
stop(); |
||||
timer = setTimeout(() => { |
||||
readyRef.value = true; |
||||
}, wait); |
||||
} |
||||
|
||||
start(); |
||||
|
||||
tryOnUnmounted(stop); |
||||
|
||||
return { readyRef, stop, start }; |
||||
} |
@ -0,0 +1,23 @@ |
||||
import { watch, unref } from 'vue'; |
||||
import { useTitle as usePageTitle } from '@vueuse/core'; |
||||
import { useRouter } from 'vue-router'; |
||||
|
||||
/** |
||||
* Listening to page changes and dynamically changing site titles |
||||
*/ |
||||
export function useTitle() { |
||||
const { currentRoute } = useRouter(); |
||||
|
||||
const pageTitle = usePageTitle(); |
||||
|
||||
watch( |
||||
[() => currentRoute.value.path], |
||||
() => { |
||||
const route = unref(currentRoute); |
||||
|
||||
const tTitle = route?.meta?.title as string; |
||||
pageTitle.value = tTitle; |
||||
}, |
||||
{ immediate: true }, |
||||
); |
||||
} |
@ -0,0 +1,50 @@ |
||||
<template> |
||||
<a-layout-header class="header"> |
||||
<div class="logo-wrap"> |
||||
<router-link :to="{ path: '/' }"> |
||||
<img :src="logo" class="logo" /> |
||||
<h1 class="title"> {{ APP_TITLE }} </h1> |
||||
<!-- <span class="subTitle">基础版</span> --> |
||||
</router-link> |
||||
</div> |
||||
<div> |
||||
<RightContent /> |
||||
</div> |
||||
</a-layout-header> |
||||
</template> |
||||
<script setup lang="ts"> |
||||
import RightContent from './RightContent.vue'; |
||||
import logo from '/@/assets/images/logo.png'; |
||||
import { APP_TITLE } from '../../../../config/constant'; |
||||
</script> |
||||
<style lang="less" scoped> |
||||
.header { |
||||
display: flex; |
||||
align-items: center; |
||||
justify-content: space-between; |
||||
height: 100%; |
||||
height: 80px; |
||||
padding: 0 16px; |
||||
background: #ffffff; |
||||
box-shadow: 0 1px 4px #00152914; |
||||
z-index: 1; |
||||
.logo-wrap { |
||||
height: 100%; |
||||
a { |
||||
display: flex; |
||||
align-items: center; |
||||
height: 100%; |
||||
} |
||||
.title { |
||||
margin: 0; |
||||
margin-left: 12px; |
||||
font-size: 18px; |
||||
color: #000000; |
||||
} |
||||
.logo { |
||||
width: 48px; |
||||
height: 48px; |
||||
} |
||||
} |
||||
} |
||||
</style> |
@ -0,0 +1,88 @@ |
||||
<template> |
||||
<div class="sys-setting"> |
||||
<a-dropdown placement="bottom"> |
||||
<template #overlay> |
||||
<a-menu :selectedKeys="selectedKeys" class="menu-box"> |
||||
<a-menu-item v-for="item in navs" :key="item.path" @click="handleRoute(item?.path)"> |
||||
<template #icon> |
||||
<Icon align="1px" size="20px" :type="item.icon" /> |
||||
</template> |
||||
<span>{{ item.name }}</span> |
||||
</a-menu-item> |
||||
</a-menu> |
||||
</template> |
||||
<Space class="wrap" align="baseline" direction="horizontal"> |
||||
<Icon align="2px" type="xitongshezhi" /> |
||||
<span class="setting">系统设置</span> |
||||
<Icon align="2px" type="xialajiantou" /> |
||||
</Space> |
||||
</a-dropdown> |
||||
</div> |
||||
</template> |
||||
|
||||
<script setup lang="ts"> |
||||
import { Space } from 'ant-design-vue'; |
||||
import { useUserStore } from '/@/store/modules/user'; |
||||
import { navs as myNavs } from './constant'; |
||||
import { usePermissioStore } from '/@/store/modules/permission'; |
||||
|
||||
const store = useUserStore(); |
||||
const permissioStore = usePermissioStore(); |
||||
const router = useRouter(); |
||||
|
||||
const navs = ref(myNavs); |
||||
const selectedKeys = ref<string[]>([]); |
||||
|
||||
watchEffect(() => { |
||||
const modules = permissioStore.getModules; |
||||
if (modules.length && permissioStore.getIsAdmin === 0) { |
||||
navs.value = unref(navs).filter((n) => (n.auth ? modules.includes(n.auth) : true)); |
||||
} |
||||
}); |
||||
|
||||
watchEffect(() => { |
||||
if (router.currentRoute) { |
||||
const matched = router.currentRoute.value.matched.concat(); |
||||
selectedKeys.value = matched.filter((r) => r.name !== 'index').map((r) => r.path); |
||||
} |
||||
}); |
||||
|
||||
const handleRoute = (path?: string) => { |
||||
if (path) return router.push(path); |
||||
// 退出登录 |
||||
store.logout(); |
||||
}; |
||||
</script> |
||||
|
||||
<style lang="less" scoped> |
||||
.sys-setting { |
||||
height: 100%; |
||||
display: flex; |
||||
justify-content: center; |
||||
padding-right: 16px; |
||||
.wrap { |
||||
height: 55px; |
||||
|
||||
.setting { |
||||
font-size: 16px; |
||||
font-weight: 600; |
||||
line-height: 22px; |
||||
color: rgba(0, 0, 0, 0.85); |
||||
margin: 0 8px 0 4px; |
||||
} |
||||
} |
||||
.my-icon { |
||||
font-size: 18px !important; |
||||
} |
||||
} |
||||
.menu-box :deep(.ant-dropdown-menu-item) { |
||||
width: 142px; |
||||
height: 42px; |
||||
line-height: 42px; |
||||
padding: 0 16px; |
||||
} |
||||
.menu-box :deep(.ant-dropdown-menu-item-selected) { |
||||
background: #eaeffe; |
||||
color: #3860f4; |
||||
} |
||||
</style> |
@ -0,0 +1,125 @@ |
||||
import { Layout, Menu, Space } from 'ant-design-vue'; |
||||
import { MenuUnfoldOutlined, MenuFoldOutlined } from '@ant-design/icons-vue'; |
||||
import Icon from '/@/components/Icon/index.vue'; |
||||
import { PropType, h, Transition } from 'vue'; |
||||
import { MenuDataItem } from '../utils/typings'; |
||||
import { router } from '/@/router'; |
||||
import './index.less'; |
||||
|
||||
export default defineComponent({ |
||||
name: 'BaseMenu', |
||||
props: { |
||||
theme: { |
||||
type: String, |
||||
default: 'light', |
||||
}, |
||||
menuWidth: { |
||||
type: Number, |
||||
default: 208, |
||||
}, |
||||
menuData: { |
||||
type: Array as PropType<MenuDataItem[]>, |
||||
default: () => [], |
||||
}, |
||||
}, |
||||
setup(props) { |
||||
const state = reactive<any>({ |
||||
collapsed: false, // default value
|
||||
openKeys: [], |
||||
selectedKeys: [], |
||||
}); |
||||
|
||||
watchEffect(() => { |
||||
if (router.currentRoute) { |
||||
const matched = router.currentRoute.value.matched.concat(); |
||||
state.selectedKeys = matched.filter((r) => r.name !== 'index').map((r) => r.path); |
||||
state.openKeys = matched |
||||
.filter((r) => r.path !== router.currentRoute.value.path) |
||||
.map((r) => r.path); |
||||
} |
||||
}); |
||||
|
||||
const onSelect = (e: { key: string; item: { props: { routeid: number } } } | any) => { |
||||
router.push(e.key); |
||||
}; |
||||
|
||||
const getIcon = (type?: string) => |
||||
type ? <Icon type={type} className="sideMenu-icon" /> : null; |
||||
|
||||
// 构建树结构
|
||||
const makeTreeDom = (data: MenuDataItem[]): JSX.Element[] => { |
||||
return data.map((item: MenuDataItem) => { |
||||
if (item.children) { |
||||
return ( |
||||
<Menu.SubMenu |
||||
key={item.path} |
||||
title={ |
||||
<> |
||||
{getIcon(item.meta?.icon as string)} |
||||
<span>{item.meta?.title}</span> |
||||
</> |
||||
} |
||||
> |
||||
{makeTreeDom(item.children)} |
||||
</Menu.SubMenu> |
||||
); |
||||
} |
||||
return ( |
||||
<Menu.Item key={item.path}> |
||||
{getIcon(item.meta?.icon as string)} |
||||
<span>{item.meta?.title}</span> |
||||
</Menu.Item> |
||||
); |
||||
}); |
||||
}; |
||||
|
||||
return () => { |
||||
return ( |
||||
<Layout.Sider |
||||
width={208} |
||||
collapsedWidth={54} |
||||
class="my-sideMenu-sider" |
||||
theme="light" |
||||
trigger={null} |
||||
breakpoint="lg" |
||||
onBreakpoint={(val) => (state.collapsed = val)} |
||||
collapsible |
||||
collapsed={state.collapsed} |
||||
// collapsedWidth={48}
|
||||
> |
||||
{/* logo */} |
||||
<Transition name="fade-top"> |
||||
{!state.collapsed && ( |
||||
<div class="my-sideMenu-sider_logo"> |
||||
<Space align="center" class="link"> |
||||
<Icon type="guanlipingtai" size="20px" align="0px" /> |
||||
<span class="font16 nowrap">管理平台</span> |
||||
</Space> |
||||
</div> |
||||
)} |
||||
</Transition> |
||||
{/* menu */} |
||||
<Menu |
||||
theme="light" |
||||
mode="inline" |
||||
selectedKeys={state.selectedKeys} |
||||
{...(state.collapsed ? {} : { openKeys: state.openKeys })} |
||||
onOpenChange={(keys: any[]) => (state.openKeys = keys)} |
||||
onSelect={onSelect} |
||||
class="my-sideMenu-sider_menu" |
||||
> |
||||
{makeTreeDom(props.menuData)} |
||||
</Menu> |
||||
{/* footer */} |
||||
<div class="my-sideMenu-sider_footer"> |
||||
{h(state.collapsed ? MenuUnfoldOutlined : MenuFoldOutlined, { |
||||
className: 'trigger', |
||||
style: { fontSize: 16 }, |
||||
onClick: () => (state.collapsed = !state.collapsed), |
||||
})} |
||||
</div> |
||||
</Layout.Sider> |
||||
); |
||||
}; |
||||
}, |
||||
}); |
@ -0,0 +1,31 @@ |
||||
export const navs = [ |
||||
// {
|
||||
// icon: 'yonghuguanli',
|
||||
// name: '用户管理',
|
||||
// path: '/sys/user',
|
||||
// auth: 'user',
|
||||
// },
|
||||
// {
|
||||
// icon: 'jiaoseguanli',
|
||||
// name: '角色管理',
|
||||
// path: '/sys/role',
|
||||
// auth: 'role',
|
||||
// },
|
||||
// {
|
||||
// icon: 'xitongrizhi',
|
||||
// name: '系统日志',
|
||||
// path: '/sys/logs',
|
||||
// auth: 'log',
|
||||
// },
|
||||
{ |
||||
icon: 'zhanghaozhongxin', |
||||
name: '账号中心', |
||||
path: '/sys/account', |
||||
auth: '', |
||||
}, |
||||
{ |
||||
icon: 'tuichudenglu_huaban1fuben17', |
||||
name: '退出登录', |
||||
auth: '', |
||||
}, |
||||
]; |
@ -0,0 +1,30 @@ |
||||
.my-sideMenu-sider { |
||||
&_logo { |
||||
margin: 16px 0; |
||||
padding: 0 16px; |
||||
height: 23px; |
||||
overflow: hidden; |
||||
} |
||||
&_menu { |
||||
flex: 1 1 0%; |
||||
height: calc(100% - 55px); |
||||
overflow: hidden auto; |
||||
} |
||||
&_footer { |
||||
border-top: 1px solid #f0f0f0; |
||||
width: 100%; |
||||
position: absolute; |
||||
left: 0; |
||||
bottom: 0; |
||||
height: 40px; |
||||
padding: 0 16px; |
||||
display: flex; |
||||
align-items: center; |
||||
.trigger:hover { |
||||
color: #3860f4; |
||||
} |
||||
} |
||||
.sideMenu-icon { |
||||
vertical-align: 1px; |
||||
} |
||||
} |
@ -0,0 +1,50 @@ |
||||
<template> |
||||
<div class="video-box"> |
||||
<video |
||||
class="video-background" |
||||
preload="auto" |
||||
loop |
||||
playsinline |
||||
autoplay |
||||
src="https://v.api.aa1.cn/api/api-fj/video/0089.mp4%20%E5%AE%98%E7%BD%91api.aa1.cn%E5%85%8D%E8%B4%B9%E8%A7%86%E9%A2%91API.mp4" |
||||
tabindex="-1" |
||||
:muted="muted" |
||||
></video> |
||||
</div> |
||||
</template> |
||||
<script setup lang="ts"> |
||||
import { get } from '/@/utils/http'; |
||||
let muted = ref(true); |
||||
const refreshBackgroundURl = () => { |
||||
let getVideoUrl = 'http://localhost:3000/api/aaData/data'; |
||||
get({ url: getVideoUrl }).then((res: any) => { |
||||
console.log(res); |
||||
}); |
||||
}; |
||||
onBeforeMount(() => refreshBackgroundURl()); |
||||
</script> |
||||
<style lang="less" scoped> |
||||
.video-box { |
||||
position: absolute; |
||||
top: 0; |
||||
left: 0; |
||||
width: 100vw; |
||||
height: 100vh; |
||||
background-color: #c1cff7; |
||||
/*进行视频裁剪*/ |
||||
overflow: hidden; |
||||
} |
||||
|
||||
.video-box .video-background { |
||||
position: absolute; |
||||
left: 50%; |
||||
top: 50%; |
||||
/*保证视频内容始终居中*/ |
||||
transform: translate(-50%, -50%); |
||||
width: 100%; |
||||
height: 100%; |
||||
/*保证视频充满屏幕*/ |
||||
object-fit: cover; |
||||
min-height: 800px; |
||||
} |
||||
</style> |
@ -0,0 +1,31 @@ |
||||
<template> |
||||
<a-layout class="basicLayout-content"> |
||||
<a-layout-content> |
||||
<a-card> |
||||
<router-view v-slot="{ Component, route }"> |
||||
<transition name="fade-slide" mode="out-in" appear> |
||||
<div :key="route.name"> |
||||
<component :is="Component" /> |
||||
</div> |
||||
</transition> |
||||
</router-view> |
||||
</a-card> |
||||
</a-layout-content> |
||||
</a-layout> |
||||
<VideoBox></VideoBox> |
||||
</template> |
||||
<script setup lang="ts"> |
||||
import VideoBox from './components/videoBox.vue'; |
||||
</script> |
||||
<style lang="less" scoped> |
||||
.basicLayout-content { |
||||
height: calc(100vh); |
||||
width: calc(100vw - 200px); |
||||
max-width: 1280px; |
||||
margin: 0 auto; |
||||
overflow-y: auto; |
||||
padding: 24px; |
||||
background: rgba(32, 33, 333, 0.4); |
||||
opacity: 0.8; |
||||
} |
||||
</style> |
@ -0,0 +1,47 @@ |
||||
import type { RouteRecord, RouteRecordRaw } from 'vue-router'; |
||||
|
||||
type IRouteRecordRaw = RouteRecordRaw & { childrenPaths?: string[] }; |
||||
|
||||
// 过滤路由属性 hideInMenu hideChildInMenu
|
||||
export function clearMenuItem(menusData: RouteRecord[] | RouteRecordRaw[]): RouteRecordRaw[] { |
||||
const filterHideMenus = menusData |
||||
.map((item: RouteRecord | RouteRecordRaw) => { |
||||
const finalItem = { ...item }; |
||||
if (!finalItem.name || finalItem.meta?.hideInMenu) { |
||||
return null; |
||||
} |
||||
|
||||
if (finalItem && finalItem?.children) { |
||||
if ( |
||||
!finalItem.meta?.hideChildInMenu && |
||||
finalItem.children.some( |
||||
(child: RouteRecord | RouteRecordRaw) => child && child.name && !child.meta?.hideInMenu, |
||||
) |
||||
) { |
||||
return { |
||||
...item, |
||||
children: clearMenuItem(finalItem.children), |
||||
}; |
||||
} |
||||
delete finalItem.children; |
||||
} |
||||
return finalItem; |
||||
}) |
||||
.filter((item) => item) as IRouteRecordRaw[]; |
||||
|
||||
//
|
||||
|
||||
return filterHideMenus; |
||||
} |
||||
|
||||
// 存在二级菜单时,过滤掉重复的并在一级菜单显示的 item
|
||||
export const filterRoutes = (menusData: RouteRecordRaw[]): RouteRecordRaw[] => { |
||||
const filterRoutes: string[] = []; |
||||
menusData.forEach((n) => { |
||||
if (n.children) { |
||||
n.children.forEach(({ path }) => filterRoutes.push(path)); |
||||
} |
||||
}); |
||||
|
||||
return menusData.filter(({ path }) => !filterRoutes.includes(path)); |
||||
}; |
@ -0,0 +1,59 @@ |
||||
import type { VNode } from 'vue'; |
||||
|
||||
export type WithFalse<T> = T | false; |
||||
|
||||
export type TargetType = '_blank' | '_self' | unknown; |
||||
|
||||
export interface MetaRecord { |
||||
/** |
||||
* @name 菜单的icon |
||||
*/ |
||||
icon?: string | VNode; |
||||
/** |
||||
* @type 有 children 的菜单的组件类型 可选值 'group' |
||||
*/ |
||||
type?: string; |
||||
/** |
||||
* @name 自定义菜单的国际化 key,如果没有则返回自身 |
||||
*/ |
||||
title?: string; |
||||
/** |
||||
* @name 内建授权信息 |
||||
*/ |
||||
authority?: string | string[]; |
||||
/** |
||||
* @name 打开目标位置 '_blank' | '_self' | null | undefined |
||||
*/ |
||||
target?: TargetType; |
||||
/** |
||||
* @name 在菜单中隐藏子节点 |
||||
*/ |
||||
hideChildInMenu?: boolean; |
||||
/** |
||||
* @name 在菜单中隐藏自己和子节点 |
||||
*/ |
||||
hideInMenu?: boolean; |
||||
/** |
||||
* @name disable 菜单选项 |
||||
*/ |
||||
disabled?: boolean; |
||||
/** |
||||
* @name 隐藏自己,并且将子节点提升到与自己平级 |
||||
*/ |
||||
flatMenu?: boolean; |
||||
|
||||
[key: string]: any; |
||||
} |
||||
|
||||
export interface MenuDataItem { |
||||
/** |
||||
* @name 用于标定选中的值,默认是 path |
||||
*/ |
||||
path: string; |
||||
name?: string | symbol; |
||||
meta?: MetaRecord; |
||||
/** |
||||
* @name 子菜单 |
||||
*/ |
||||
children?: MenuDataItem[]; |
||||
} |
@ -0,0 +1,3 @@ |
||||
<template> |
||||
<router-view /> |
||||
</template> |
@ -0,0 +1,30 @@ |
||||
// import 'ant-design-vue/dist/antd.css';
|
||||
import 'sanitize.css'; |
||||
// import 'sanitize.css/forms.css';
|
||||
// import 'sanitize.css/typography.css';
|
||||
import '/@/styles/index.less'; |
||||
|
||||
import { createApp } from 'vue'; |
||||
import App from './App.vue'; |
||||
import { router } from './router'; |
||||
import { store } from './store'; |
||||
import { setupGlobDirectives } from './directives'; |
||||
import './router/permission'; |
||||
// import { setupComponents } from './plugin';
|
||||
|
||||
const app = createApp(App); |
||||
|
||||
app.use(store); |
||||
|
||||
app.use(router); |
||||
|
||||
// Register global directive
|
||||
setupGlobDirectives(app); |
||||
|
||||
// Register UI components
|
||||
// setupComponents(app);
|
||||
|
||||
// 全局属性
|
||||
// app.config.globalProperties.AuthEnum = AuthEnum;
|
||||
|
||||
app.mount('#app'); |
@ -0,0 +1,62 @@ |
||||
/** |
||||
* 手动引入组件注册 |
||||
* 如果在意unplugin-vue-components插件的自动引入性能问题,可以考虑该方式 |
||||
*/ |
||||
import { |
||||
Alert, |
||||
Avatar, |
||||
Breadcrumb, |
||||
Button, |
||||
Card, |
||||
Col, |
||||
DatePicker, |
||||
Divider, |
||||
Dropdown, |
||||
Form, |
||||
Input, |
||||
Layout, |
||||
Menu, |
||||
Popconfirm, |
||||
Row, |
||||
Select, |
||||
Space, |
||||
Spin, |
||||
Table as AntdTable, |
||||
} from 'ant-design-vue'; |
||||
|
||||
import type { App } from 'vue'; |
||||
|
||||
import Icon from '/@/components/Icon/index.vue'; |
||||
import Modal from '/@/components/Modal/index.vue'; |
||||
import Table from '/@/components/Table/index.vue'; |
||||
import TableFilter from '/@/components/TableFilter/index.vue'; |
||||
import Upload from '/@/components/Upload/index.vue'; |
||||
|
||||
export function setupComponents(app: App<Element>) { |
||||
app.component('Icon', Icon); |
||||
app.component('Modal', Modal); |
||||
app.component('Table', Table); |
||||
app.component('TableFilter', TableFilter); |
||||
app.component('Upload', Upload); |
||||
|
||||
app |
||||
.use(Alert) |
||||
.use(Avatar) |
||||
.use(Breadcrumb) |
||||
.use(Button) |
||||
.use(Card) |
||||
.use(Col) |
||||
.use(DatePicker) |
||||
.use(Divider) |
||||
.use(Dropdown) |
||||
.use(Form) |
||||
.use(Input) |
||||
.use(Layout) |
||||
.use(Menu) |
||||
.use(Popconfirm) |
||||
.use(Row) |
||||
.use(Select) |
||||
.use(Space) |
||||
.use(Spin) |
||||
.use(AntdTable); |
||||
} |
@ -0,0 +1,12 @@ |
||||
import { createRouter, createWebHistory } from 'vue-router'; |
||||
import routes from './router.config'; |
||||
|
||||
// app router
|
||||
export const router = createRouter({ |
||||
// 解决 二级路径存在时,路径地址路由不匹配的问题
|
||||
// https://juejin.cn/post/7051826951463370760#heading-27
|
||||
history: createWebHistory(import.meta.env.BASE_URL), |
||||
routes, |
||||
strict: true, |
||||
scrollBehavior: () => ({ left: 0, top: 0 }), |
||||
}); |
@ -0,0 +1,49 @@ |
||||
/** |
||||
* @name permission |
||||
* @description 全局路由过滤、权限过滤 |
||||
*/ |
||||
|
||||
import { router } from '.'; |
||||
import { getToken } from '../utils/auth'; |
||||
import { usePermissioStoreWithOut } from '/@/store/modules/permission'; |
||||
|
||||
const permissioStore = usePermissioStoreWithOut(); |
||||
const whiteList = ['/login']; // no redirect whitelist
|
||||
|
||||
// router.beforeEach(async (to: any, _, next) => {
|
||||
// next();
|
||||
// // const hasToken = getToken();
|
||||
// // if (hasToken) {
|
||||
// // // 已登录
|
||||
// // if (to.path === '/login') {
|
||||
// // next({ path: '/' });
|
||||
// // } else {
|
||||
// // //是否获取过用户信息
|
||||
// // const isGetUserInfo = permissioStore.getIsGetUserInfo;
|
||||
// // if (isGetUserInfo) {
|
||||
// // next();
|
||||
// // } else {
|
||||
// // // 没有获取,请求数据
|
||||
// // await permissioStore.fetchAuths();
|
||||
// // // 过滤权限路由
|
||||
// // const routes = await permissioStore.buildRoutesAction();
|
||||
// // // 404 路由一定要放在 权限路由后面
|
||||
// // routes.forEach((route) => {
|
||||
// // router.addRoute(route);
|
||||
// // });
|
||||
// // // hack 方法
|
||||
// // // 不使用 next() 是因为,在执行完 router.addRoute 后,
|
||||
// // // 原本的路由表内还没有添加进去的路由,会 No match
|
||||
// // // replace 使路由从新进入一遍,进行匹配即可
|
||||
// // next({ ...to, replace: true });
|
||||
// // }
|
||||
// // }
|
||||
// // } else {
|
||||
// // // 未登录
|
||||
// // if (whiteList.indexOf(to.path) !== -1) {
|
||||
// // next();
|
||||
// // } else {
|
||||
// // next('/login');
|
||||
// // }
|
||||
// // }
|
||||
// });
|
@ -0,0 +1,116 @@ |
||||
import BasicLayout from '/@/layouts/BasicLayout/index.vue'; |
||||
import BlankLayout from '/@/layouts/BlankLayout.vue'; |
||||
import type { RouteRecordRaw } from 'vue-router'; |
||||
|
||||
export const accessRoutes: RouteRecordRaw[] = [ |
||||
{ |
||||
path: '/', |
||||
name: 'index', |
||||
component: BasicLayout, |
||||
redirect: '/index', |
||||
meta: { title: '管理平台' }, |
||||
children: [ |
||||
{ |
||||
path: '/index', |
||||
component: () => import('/@/views/home/index.vue'), |
||||
name: 'home', |
||||
meta: { |
||||
title: '首页', |
||||
icon: 'liulanqi', |
||||
// auth: ['home'],
|
||||
}, |
||||
}, |
||||
// {
|
||||
// path: '/app/website',
|
||||
// name: 'website',
|
||||
// component: () => import('/@/views/website/index.vue'),
|
||||
// meta: {
|
||||
// title: '网站管理',
|
||||
// keepAlive: true,
|
||||
// icon: 'jiedianguanli',
|
||||
// auth: ['website'],
|
||||
// },
|
||||
// },
|
||||
// {
|
||||
// path: '/app/table-demo',
|
||||
// name: 'table-demo',
|
||||
// component: () => import('/@/views/table-demo/index.vue'),
|
||||
// meta: {
|
||||
// title: '表格用法',
|
||||
// keepAlive: true,
|
||||
// icon: 'rili',
|
||||
// },
|
||||
// },
|
||||
// {
|
||||
// path: '/app/others',
|
||||
// name: 'others',
|
||||
// component: BlankLayout,
|
||||
// redirect: '/app/others/about',
|
||||
// meta: {
|
||||
// title: '其他菜单',
|
||||
// icon: 'shurumimadenglu',
|
||||
// auth: ['others'],
|
||||
// },
|
||||
// children: [
|
||||
// {
|
||||
// path: '/app/others/about',
|
||||
// name: 'about',
|
||||
// component: () => import('/@/views/others/about/index.vue'),
|
||||
// meta: { title: '关于', keepAlive: true, hiddenWrap: true },
|
||||
// },
|
||||
// {
|
||||
// path: '/app/others/antdv',
|
||||
// name: 'antdv',
|
||||
// component: () => import('/@/views/others/antdv/index.vue'),
|
||||
// meta: { title: '组件', keepAlive: true, breadcrumb: true },
|
||||
// },
|
||||
// ],
|
||||
// },
|
||||
// {
|
||||
// path: '/sys/account',
|
||||
// name: 'account',
|
||||
// component: () => import('/@/views/account/index.vue'),
|
||||
// meta: { title: '用户管理', keepAlive: true, breadcrumb: true },
|
||||
// },
|
||||
], |
||||
}, |
||||
]; |
||||
|
||||
const constantRoutes: RouteRecordRaw[] = [ |
||||
// {
|
||||
// path: '/login',
|
||||
// component: () => import('/@/views/login/index.vue'),
|
||||
// name: 'login',
|
||||
// meta: { title: '登录' },
|
||||
// },
|
||||
...accessRoutes, |
||||
]; |
||||
|
||||
export const publicRoutes = [ |
||||
{ |
||||
path: '/redirect', |
||||
component: BlankLayout, |
||||
children: [ |
||||
{ |
||||
path: '/redirect/:path(.*)', |
||||
component: () => import('/@/views/redirect/index'), |
||||
}, |
||||
], |
||||
}, |
||||
{ |
||||
path: '/:pathMatch(.*)', |
||||
redirect: '/404', |
||||
}, |
||||
{ |
||||
path: '/404', |
||||
component: () => import('/@/views/404.vue'), |
||||
}, |
||||
]; |
||||
|
||||
// /**
|
||||
// * 基础路由
|
||||
// * @type { *[] }
|
||||
// */
|
||||
// export const constantRouterMap = [];
|
||||
|
||||
export default constantRoutes; |
@ -0,0 +1,5 @@ |
||||
declare module '*.vue' { |
||||
import { defineComponent } from 'vue'; |
||||
const Component: ReturnType<typeof defineComponent>; |
||||
export default Component; |
||||
} |
@ -0,0 +1,9 @@ |
||||
import { createPinia } from 'pinia'; |
||||
|
||||
const store = createPinia(); |
||||
|
||||
export { store }; |
||||
|
||||
// 因为 pinia的实现也是通过vue的各种api(ref/reactive/computed等)
|
||||
// 所以,不要求一定要在Vue上挂载注册,可以随便在组件中使用,组件外使用也有对应方案
|
||||
// 不过,app.use(store) 可以把store实例挂载到Vue上使用
|
@ -0,0 +1,45 @@ |
||||
import { defineStore } from 'pinia'; |
||||
import { store } from '/@/store'; |
||||
import fetchApi from '/@/api/home'; |
||||
import { ResInfoList } from '/@/api/home/model'; |
||||
|
||||
interface HomeState { |
||||
info: Nullable<ResInfoList>; |
||||
} |
||||
|
||||
export const useHomeStore = defineStore({ |
||||
id: 'app-home', |
||||
state: (): HomeState => ({ |
||||
// info
|
||||
info: null, |
||||
}), |
||||
getters: { |
||||
getInfo(): Nullable<ResInfoList> { |
||||
return this.info || null; |
||||
}, |
||||
}, |
||||
actions: { |
||||
setInfo(info: Nullable<ResInfoList>) { |
||||
this.info = info; |
||||
}, |
||||
resetState() { |
||||
this.info = null; |
||||
}, |
||||
/** |
||||
* @description: login |
||||
*/ |
||||
async fetchInfo() { |
||||
const res = await fetchApi.info(); |
||||
if (res) { |
||||
// save token
|
||||
this.setInfo(res); |
||||
} |
||||
return res; |
||||
}, |
||||
}, |
||||
}); |
||||
|
||||
// Need to be used outside the setup
|
||||
export function useHomeStoreWithOut() { |
||||
return useHomeStore(store); |
||||
} |
@ -0,0 +1,134 @@ |
||||
import { defineStore } from 'pinia'; |
||||
import { store } from '/@/store'; |
||||
import fetchApi from '/@/api/user'; |
||||
import { RouteRecordRaw } from 'vue-router'; |
||||
import constantRoutes, { accessRoutes, publicRoutes } from '/@/router/router.config'; |
||||
import { filterAsyncRoutes } from '/@/utils/permission'; |
||||
|
||||
interface PermissioState { |
||||
isGetUserInfo: boolean; // 是否获取过用户信息
|
||||
isAdmin: 0 | 1; // 是否为管理员
|
||||
auths: string[]; // 当前用户权限
|
||||
modules: string[]; // 模块权限
|
||||
role: 0 | 1; |
||||
} |
||||
|
||||
export const usePermissioStore = defineStore({ |
||||
id: 'app-permission', |
||||
state: (): PermissioState => ({ |
||||
// isGetUserInfo
|
||||
isGetUserInfo: false, |
||||
// isAdmin
|
||||
isAdmin: 0, |
||||
// auths
|
||||
auths: [], |
||||
// modules
|
||||
modules: [], |
||||
// role 0-银行 1-银保监
|
||||
role: 0, |
||||
}), |
||||
getters: { |
||||
getAuths(): string[] { |
||||
return this.auths; |
||||
}, |
||||
getRole(): 0 | 1 { |
||||
return this.role; |
||||
}, |
||||
getModules(): string[] { |
||||
return this.modules; |
||||
}, |
||||
getIsAdmin(): 0 | 1 { |
||||
return this.isAdmin; |
||||
}, |
||||
getIsGetUserInfo(): boolean { |
||||
return this.isGetUserInfo; |
||||
}, |
||||
}, |
||||
actions: { |
||||
setAuth(auths: string[], modules: string[]) { |
||||
this.auths = auths; |
||||
this.isGetUserInfo = true; |
||||
this.modules = modules; |
||||
}, |
||||
setIsAdmin(isAdmin: 0 | 1) { |
||||
this.isAdmin = isAdmin; |
||||
}, |
||||
resetState() { |
||||
this.isGetUserInfo = false; |
||||
this.isAdmin = 0; |
||||
this.auths = []; |
||||
this.modules = []; |
||||
this.role = 0; |
||||
}, |
||||
|
||||
/** |
||||
* @name fetchAuths |
||||
* @description 获取当前用户权限 |
||||
*/ |
||||
async fetchAuths() { |
||||
const res = await fetchApi.permission(); |
||||
if (res) { |
||||
this.setAuth(res.auths, res.modules); |
||||
this.setIsAdmin(res.is_admin || 0); |
||||
} |
||||
return res; |
||||
}, |
||||
|
||||
/** |
||||
* @name buildRoutesAction |
||||
* @description: 获取路由 |
||||
*/ |
||||
async buildRoutesAction(): Promise<RouteRecordRaw[]> { |
||||
// 404 路由一定要放在 权限路由后面
|
||||
let routes: RouteRecordRaw[] = [...constantRoutes, ...accessRoutes, ...publicRoutes]; |
||||
|
||||
if (this.getIsAdmin !== 1) { |
||||
// 普通用户
|
||||
// 1. 方案一:过滤每个路由模块涉及的接口权限,判断是否展示该路由
|
||||
// 2. 方案二:直接检索接口权限列表是否包含该路由模块,不做细分,axios同一拦截
|
||||
routes = [ |
||||
...constantRoutes, |
||||
...filterAsyncRoutes(accessRoutes, this.modules), |
||||
...publicRoutes, |
||||
]; |
||||
} |
||||
|
||||
return routes; |
||||
}, |
||||
|
||||
// /**
|
||||
// * @name buildRoutesAction
|
||||
// * @description: 获取路由
|
||||
// */
|
||||
// buildRoutesAction(): RouteRecordRaw[] {
|
||||
// // this.isGetUserInfo = true;
|
||||
// this.setIsGetUserInfo(true);
|
||||
|
||||
// // 404 路由一定要放在 权限路由后面
|
||||
// let routes: RouteRecordRaw[] = [...constantRoutes, ...accessRoutes, ...publicRoutes];
|
||||
|
||||
// // 1. 角色权限过滤:0-银行 1-银保监
|
||||
// let filterRoutes = filterRouteByRole(cloneDeep(accessRoutes), this.role);
|
||||
// // let filterRoutes = routes;
|
||||
|
||||
// // 2. 菜单权限过滤:
|
||||
// // 管理员直接跳过
|
||||
// if (this.getIsAdmin === 0) {
|
||||
// const filterRoutesByAuth = filterAsyncRoutes(cloneDeep(filterRoutes), this.modules);
|
||||
// filterRoutes = filterRoutesByAuth;
|
||||
// }
|
||||
|
||||
// // 普通用户
|
||||
// // 1. 方案一:过滤每个路由模块涉及的接口权限,判断是否展示该路由
|
||||
// // 2. 方案二:直接检索接口权限列表是否包含该路由模块,不做细分,axios同一拦截
|
||||
// routes = [...constantRoutes, ...filterRoutes, ...publicRoutes];
|
||||
|
||||
// return routes;
|
||||
// },
|
||||
}, |
||||
}); |
||||
|
||||
// Need to be used outside the setup
|
||||
export function usePermissioStoreWithOut() { |
||||
return usePermissioStore(store); |
||||
} |
@ -0,0 +1,62 @@ |
||||
/** |
||||
* @name sysAccount |
||||
* @description 系统设置-账户模块 |
||||
*/ |
||||
import { defineStore } from 'pinia'; |
||||
import { store } from '/@/store'; |
||||
import fetchApi from '/@/api/sys/account'; |
||||
import { ReqAccount, ResAccount } from '/@/api/sys/account/model'; |
||||
// import { encryptByDES } from '/@/utils/crypto';
|
||||
|
||||
type AccountInfoTy = ResAccount | null; |
||||
|
||||
interface UserState { |
||||
info: AccountInfoTy; |
||||
} |
||||
|
||||
export const useSysAccountStore = defineStore({ |
||||
id: 'sys-account', |
||||
state: (): UserState => ({ |
||||
info: null, |
||||
}), |
||||
getters: { |
||||
getAccount(): AccountInfoTy { |
||||
return this.info; |
||||
}, |
||||
}, |
||||
actions: { |
||||
setAccount(info: AccountInfoTy) { |
||||
this.info = info; |
||||
}, |
||||
resetState() { |
||||
this.info = null; |
||||
}, |
||||
|
||||
/** |
||||
* @description: fetchRole |
||||
*/ |
||||
async fetchAccount() { |
||||
const res = await fetchApi.account(); |
||||
if (res) { |
||||
this.setAccount(res); |
||||
} |
||||
return res !== undefined; |
||||
}, |
||||
|
||||
/** |
||||
* @description: fetchAccountUpdate |
||||
*/ |
||||
async fetchAccountUpdate(params: ReqAccount) { |
||||
// if (params.password) {
|
||||
// params.password = encryptByDES(params.password);
|
||||
// }
|
||||
const res = await fetchApi.update(params); |
||||
return res !== undefined; |
||||
}, |
||||
}, |
||||
}); |
||||
|
||||
// Need to be used outside the setup
|
||||
export function useSysAccountStoreWithOut() { |
||||
return useSysAccountStore(store); |
||||
} |
@ -0,0 +1,69 @@ |
||||
import { defineStore } from 'pinia'; |
||||
import { store } from '/@/store'; |
||||
import { ReqParams } from '/@/api/user/model'; |
||||
import fetchApi from '/@/api/user'; |
||||
// import { encryptByDES } from '/@/utils/crypto';
|
||||
import { getToken, setToken, removeToken } from '/@/utils/auth'; |
||||
import { router } from '/@/router'; |
||||
|
||||
interface UserState { |
||||
token: string; |
||||
auths: string[]; |
||||
} |
||||
|
||||
export const useUserStore = defineStore({ |
||||
id: 'app-user', |
||||
state: (): UserState => ({ |
||||
// token
|
||||
token: '', |
||||
// auths
|
||||
auths: [], |
||||
}), |
||||
getters: { |
||||
getToken(): string { |
||||
return this.token || getToken(); |
||||
}, |
||||
}, |
||||
actions: { |
||||
setToken(info: string) { |
||||
this.token = info ?? ''; // for null or undefined value
|
||||
setToken(info); |
||||
}, |
||||
setAuth(auths: string[]) { |
||||
this.auths = auths; |
||||
}, |
||||
resetState() { |
||||
this.token = ''; |
||||
this.auths = []; |
||||
}, |
||||
/** |
||||
* @description: login |
||||
*/ |
||||
async login(params: ReqParams) { |
||||
// 密码加密
|
||||
// params.password = encryptByDES(params.password);
|
||||
const res = await fetchApi.login(params); |
||||
if (res) { |
||||
// save token
|
||||
this.setToken(res.token); |
||||
} |
||||
return res; |
||||
}, |
||||
|
||||
/** |
||||
* @description: logout |
||||
*/ |
||||
async logout() { |
||||
this.resetState(); |
||||
removeToken(); |
||||
router.replace('/login'); |
||||
// 路由表重置
|
||||
location.reload(); |
||||
}, |
||||
}, |
||||
}); |
||||
|
||||
// Need to be used outside the setup
|
||||
export function useUserStoreWithOut() { |
||||
return useUserStore(store); |
||||
} |
@ -0,0 +1,5 @@ |
||||
// |
||||
|
||||
// .ant-layout-content{ |
||||
|
||||
// } |