Compare commits

..

23 Commits

Author SHA1 Message Date
Soybean
114072277f chore(release): 0.9.2 2022-02-11 16:27:20 +08:00
Soybean
e65034d946 refactor(projects): 重构路由页面组件的导入 2022-02-11 16:15:50 +08:00
Soybean
a7a269d6a6 fix(projects): 修复项目配置拷贝功能 2022-02-07 23:22:55 +08:00
Soybean
2c9660fdbf fix(components): 修复Tab在移动端设备无法点击的问题 2022-02-07 15:46:35 +08:00
Soybean
e93b94cb24 fix(projects): 修复分析页和工作台的布局问题 2022-02-07 15:00:04 +08:00
Soybean
3befb22903 docs(projects): update README.md 2022-02-07 09:17:27 +08:00
Soybean
7ed5d0de2d docs(projects): update README.md 2022-02-07 09:16:18 +08:00
Soybean
47f2871cb5 docs(projects): update README.md 2022-02-03 17:48:04 +08:00
Soybean
852ddb64ad Merge pull request #37 from yanbowe/main
feat(projects): 迁移全局搜索菜单功能
2022-01-24 15:52:11 +08:00
yanbowen
7e1f9f1138 Merge branch 'honghuangdc:main' into main 2022-01-24 15:44:11 +08:00
yanbowen
554d7fd611 feat(projects): 迁移全局搜索菜单功能 2022-01-24 15:34:57 +08:00
Soybean
1797f29a79 Merge pull request #36 from dxxzst/main
build(projects): update .eslintignore
2022-01-24 14:28:37 +08:00
Grazing Wind
50063187ec Update .eslintignore
add package.json to .eslintignore
2022-01-24 13:57:42 +08:00
Soybean
b16721b2b7 build(projects): add license 2022-01-24 12:26:04 +08:00
Soybean
facc00e8b4 fix(projects): vite配置修复 2022-01-24 11:30:08 +08:00
Soybean
02c51e6fb9 docs(projects): update README.md 2022-01-24 01:28:35 +08:00
Soybean
be374089ba chore(release): 0.9.1 2022-01-24 00:54:16 +08:00
Soybean
68b42304d5 feat(projects): 新版重构完成 2022-01-24 00:53:51 +08:00
Soybean
32a7cc408e chore(release): 0.1.3 2022-01-24 00:01:51 +08:00
Soybean
651e58dcb6 refactor(projects): 细节完善 2022-01-24 00:00:59 +08:00
Soybean
b61b0ce25f fix(projects): 修复路由守卫的动态路由逻辑 2022-01-22 16:16:07 +08:00
Soybean
21bab1f7c3 fix(projects): 修复未登录时会调用获取用户路由的接口 2022-01-22 14:05:19 +08:00
Soybean
4f9d544d43 refactor(projects): 请求构造函数适配不同后端接口的数据结构 2022-01-22 03:25:41 +08:00
103 changed files with 4861 additions and 2331 deletions

View File

@@ -12,3 +12,4 @@ lib
.vscode
.local
!.env-config.ts
package.json

View File

@@ -155,7 +155,7 @@ module.exports = {
'@typescript-eslint/no-explicit-any': 0,
'@typescript-eslint/no-inferrable-types': 0,
'@typescript-eslint/no-non-null-assertion': 'off',
'@typescript-eslint/no-unused-vars': ['warn', { ignoreRestSiblings: true }],
'@typescript-eslint/no-unused-vars': ['warn', { ignoreRestSiblings: true, varsIgnorePattern: 'Ignored' }],
'@typescript-eslint/no-use-before-define': ['error', { classes: true, functions: false, typedefs: false }],
'@typescript-eslint/no-var-requires': 'off'
}

View File

@@ -2,6 +2,36 @@
All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
### [0.9.2](https://github.com/honghuangdc/soybean-admin/compare/v0.9.1...v0.9.2) (2022-02-11)
### Features
* **projects:** 迁移全局搜索菜单功能 ([554d7fd](https://github.com/honghuangdc/soybean-admin/commit/554d7fd6114b9cf6df571c3cb02f4cb0cc6dcfd4))
### Bug Fixes
* **components:** 修复Tab在移动端设备无法点击的问题 ([2c9660f](https://github.com/honghuangdc/soybean-admin/commit/2c9660fdbf9a84e980db0aff5cd0aed0f75963ca))
* **projects:** 修复分析页和工作台的布局问题 ([e93b94c](https://github.com/honghuangdc/soybean-admin/commit/e93b94cb2435a130bb1d94a703328af342cd24c9))
* **projects:** 修复项目配置拷贝功能 ([a7a269d](https://github.com/honghuangdc/soybean-admin/commit/a7a269d6a61ccd25883e6bb69639d39e0260587d))
* **projects:** vite配置修复 ([facc00e](https://github.com/honghuangdc/soybean-admin/commit/facc00e8b4998dc8bd338e3b63a652b4bfe2ed3e))
### [0.9.1](https://github.com/honghuangdc/soybean-admin/compare/v0.1.3...v0.9.1) (2022-01-23)
### Features
* **projects:** 新版重构完成 ([68b4230](https://github.com/honghuangdc/soybean-admin/commit/68b42304d5964246775c7a82dcc1406c5db7a4e4))
### [0.1.3](https://github.com/honghuangdc/soybean-admin/compare/v0.1.2...v0.1.3) (2022-01-23)
### Bug Fixes
* **projects:** 修复未登录时会调用获取用户路由的接口 ([21bab1f](https://github.com/honghuangdc/soybean-admin/commit/21bab1f7c30611fe59dc91c7a73050ccb49a4658))
* **projects:** 修复路由守卫的动态路由逻辑 ([b61b0ce](https://github.com/honghuangdc/soybean-admin/commit/b61b0ce25fdcbaf29ca64cbcc467e12faa947625))
### [0.1.2](https://github.com/honghuangdc/soybean-admin/compare/v0.1.1...v0.1.2) (2022-01-21)

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2021 Soybean
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.

View File

@@ -1,21 +1,22 @@
<div align="center">
<img src="https://i.loli.net/2021/11/24/x5lLfuSnEawBAgi.png"/>
<h1>Soybean Admin Thin</h1>
<h1>Soybean Admin</h1>
</div>
[![license](https://img.shields.io/badge/license-MIT-green.svg)](./LICENSE)
## 简介
Soybean Admin Thin 是Soybean Admin的精简版
Soybean Admin 是一个基于 Vue3、Vite、TypeScript、Naive UI 的免费中后台模版它使用了最新的前端技术栈内置丰富的主题配置有着极高的代码规范基于mock实现的动态权限路由开箱即用的中后台前端解决方案也可用于学习参考
## 特性
- **最新技术栈**:使用 Vue3/vite2 等前端前沿技术开发, 使用高效率的npm包管理器pnpm
- **TypeScript**: 应用程序级 JavaScript 的语言
- **主题**:丰富可配置的主题
- **主题**:丰富可配置的主题、暗黑模式基于windicss的动态主题颜色
- **代码规范**:丰富的规范插件及极高的代码规范
- **路由配置**:简易的路由配置
- **权限路由**:简易的路由配置、基于mock的动态路由能快速实现后端动态路由
- **请求函数**完善的请求函数封装提供Promise和hooks两种请求函数
## 预览
@@ -31,16 +32,25 @@ Soybean Admin Thin 是Soybean Admin的精简版。
- [gitee](https://gitee.com/honghuangdc/soybean-admin)
### 温馨提示(老用户)
旧版代码在old分支等main分支稳定下来再删除old分支。
如果不是第一次进预览地址 soybean.pro新版的发布会导致有缓存退出用户重新登录即可。
thin分支相对于main分支少了插件示例其他都一样后面会适当精简一些代码。
## 项目示例图
![](https://i.loli.net/2021/11/24/pIhTKP7fdCqbVHl.png)
![](https://i.loli.net/2021/11/24/gxRwsLnKi6IVp7C.png)
![](https://s2.loli.net/2022/01/24/ovK6Oyqr7gIMu2n.png)
![](https://i.loli.net/2021/11/24/UmVfjSJbxH6iYc2.png)
![](https://s2.loli.net/2022/01/24/O8loxYhMySHwGfJ.png)
![](https://i.loli.net/2021/11/24/Uot1bcfGXiF726T.png)
![](https://s2.loli.net/2022/01/24/HKwpJ7Ab6j8fVvk.png)
![](https://i.loli.net/2021/11/24/WzOIvlgJZaUtGm7.png)
![](https://s2.loli.net/2022/01/24/bqJRSDZHBv3jsif.png)
![](https://s2.loli.net/2022/01/24/wXpHeau6UrSTWdF.png)
### 使用 Gitpod
@@ -108,7 +118,7 @@ pnpm i -g commitizen
- 微信交流群:
<div style="text-align:left">
<img src="https://s2.loli.net/2021/12/29/m65oExs7yHcPbKZ.jpg" style="width:200px" />
<img src="https://s2.loli.net/2022/02/07/hJWkOUAjpCQNwdf.jpg" style="width:200px" />
</div>
- QQ 群 `711301266`

View File

@@ -10,7 +10,7 @@ const apis: MockMethod[] = [
{
url: '/mock/getSmsCode',
method: 'post',
response: (): Service.BackendServiceResult<boolean> => {
response: (): Service.MockServiceResult<boolean> => {
return {
code: 200,
message: 'ok',
@@ -22,7 +22,7 @@ const apis: MockMethod[] = [
{
url: '/mock/loginByPwd',
method: 'post',
response: (): Service.BackendServiceResult<ApiAuth.Token> => {
response: (): Service.MockServiceResult<ApiAuth.Token> => {
return {
code: 200,
message: 'ok',
@@ -34,7 +34,7 @@ const apis: MockMethod[] = [
{
url: '/mock/loginByCode',
method: 'post',
response: (): Service.BackendServiceResult<ApiAuth.Token> => {
response: (): Service.MockServiceResult<ApiAuth.Token> => {
return {
code: 200,
message: 'ok',
@@ -46,7 +46,7 @@ const apis: MockMethod[] = [
{
url: '/mock/getUserInfo',
method: 'get',
response: (): Service.BackendServiceResult<ApiAuth.UserInfo> => {
response: (): Service.MockServiceResult<ApiAuth.UserInfo> => {
return {
code: 200,
message: 'ok',
@@ -62,7 +62,7 @@ const apis: MockMethod[] = [
{
url: '/mock/testToken',
method: 'post',
response: (option: any): Service.BackendServiceResult<true | null> => {
response: (option: any): Service.MockServiceResult<true | null> => {
if (option.headers?.authorization !== token.token) {
return {
code: 66666,
@@ -80,7 +80,7 @@ const apis: MockMethod[] = [
{
url: '/mock/updateToken',
method: 'post',
response: (): Service.BackendServiceResult<string> => {
response: (): Service.MockServiceResult<string> => {
return {
code: 200,
message: 'ok',

View File

@@ -90,16 +90,135 @@ const routes: AuthRoute.Route[] = [
}
},
{
name: 'about',
path: '/about',
component: 'self',
name: 'component',
path: '/component',
component: 'basic',
children: [
{
name: 'component_button',
path: '/component/button',
component: 'self',
meta: {
title: '按钮',
requiresAuth: true
}
},
{
name: 'component_card',
path: '/component/card',
component: 'self',
meta: {
title: '卡片',
requiresAuth: true
}
},
{
name: 'component_table',
path: '/component/table',
component: 'self',
meta: {
title: '表格',
requiresAuth: true
}
}
],
meta: {
title: '关于',
requiresAuth: true,
singleLayout: 'basic',
permissions: ['super', 'admin', 'test'],
icon: 'fluent:book-information-24-regular',
order: 7
title: '组件示例',
icon: 'fluent:app-store-24-regular',
order: 3
}
},
{
name: 'plugin',
path: '/plugin',
component: 'basic',
children: [
{
name: 'plugin_map',
path: '/plugin/map',
component: 'self',
meta: {
title: '地图',
requiresAuth: true
}
},
{
name: 'plugin_video',
path: '/plugin/video',
component: 'self',
meta: {
title: '视频',
requiresAuth: true
}
},
{
name: 'plugin_editor',
path: '/plugin/editor',
component: 'multi',
children: [
{
name: 'plugin_editor_quill',
path: '/plugin/editor/quill',
component: 'self',
meta: {
title: '富文本编辑器'
}
},
{
name: 'plugin_editor_markdown',
path: '/plugin/editor/markdown',
component: 'self',
meta: {
title: 'markdown编辑器'
}
}
],
meta: {
title: '编辑器',
requiresAuth: true
}
},
{
name: 'plugin_swiper',
path: '/plugin/swiper',
component: 'self',
meta: {
title: 'Swiper插件',
requiresAuth: true
}
},
{
name: 'plugin_copy',
path: '/plugin/copy',
component: 'self',
meta: {
title: '剪贴板',
requiresAuth: true
}
},
{
name: 'plugin_icon',
path: '/plugin/icon',
component: 'self',
meta: {
title: '图标',
requiresAuth: true
}
},
{
name: 'plugin_print',
path: '/plugin/print',
component: 'self',
meta: {
title: '打印',
requiresAuth: true
}
}
],
meta: {
title: '插件示例',
icon: 'clarity:plugin-line',
order: 4
}
},
{
@@ -190,6 +309,19 @@ const routes: AuthRoute.Route[] = [
icon: 'carbon:menu',
order: 6
}
},
{
name: 'about',
path: '/about',
component: 'self',
meta: {
title: '关于',
requiresAuth: true,
singleLayout: 'basic',
permissions: ['super', 'admin', 'test'],
icon: 'fluent:book-information-24-regular',
order: 7
}
}
];
@@ -210,7 +342,7 @@ const apis: MockMethod[] = [
{
url: '/mock/getUserRoutes',
method: 'post',
response: (): Service.BackendServiceResult => {
response: (): Service.MockServiceResult => {
return {
code: 200,
message: 'ok',

View File

@@ -1,6 +1,6 @@
{
"name": "soybean-admin-thin",
"version": "0.1.2",
"name": "soybean-admin",
"version": "0.9.2",
"scripts": {
"dev": "cross-env VITE_HTTP_ENV=test vite",
"dev:prod": "cross-env VITE_HTTP_ENV=prod vite",
@@ -26,60 +26,69 @@
"dependencies": {
"@antv/g2plot": "^2.4.7",
"@better-scroll/core": "^2.4.2",
"@vueuse/core": "^7.5.4",
"@vueuse/core": "^7.5.5",
"axios": "^0.25.0",
"clipboard": "^2.0.8",
"clipboard": "^2.0.10",
"colord": "^2.9.2",
"crypto-js": "^4.1.1",
"dayjs": "^1.10.7",
"form-data": "^4.0.0",
"lodash-es": "^4.17.21",
"naive-ui": "^2.24.1",
"pinia": "^2.0.9",
"naive-ui": "^2.25.1",
"pinia": "^2.0.11",
"print-js": "^1.6.0",
"qs": "^6.10.3",
"vue": "^3.2.26",
"vue-router": "^4.0.12"
"swiper": "^8.0.3",
"ua-parser-js": "^1.0.2",
"vditor": "^3.8.11",
"vue": "^3.2.29",
"vue-router": "^4.0.12",
"wangeditor": "^4.7.11",
"xgplayer": "^2.31.4"
},
"devDependencies": {
"@amap/amap-jsapi-types": "^0.0.8",
"@commitlint/cli": "^16.1.0",
"@commitlint/config-conventional": "^16.0.0",
"@iconify/json": "^1.1.459",
"@iconify/vue": "^3.1.2",
"@iconify/json": "^2.0.33",
"@iconify/vue": "^3.1.3",
"@types/bmapgl": "^0.0.5",
"@types/crypto-js": "^4.1.0",
"@types/node": "^17.0.10",
"@types/node": "^17.0.15",
"@types/qs": "^6.9.7",
"@typescript-eslint/eslint-plugin": "^5.10.0",
"@typescript-eslint/parser": "^5.10.0",
"@vitejs/plugin-vue": "^2.0.1",
"@types/ua-parser-js": "^0.7.36",
"@typescript-eslint/eslint-plugin": "^5.10.2",
"@typescript-eslint/parser": "^5.10.2",
"@vitejs/plugin-vue": "^2.1.0",
"@vue/eslint-config-prettier": "^7.0.0",
"@vue/eslint-config-typescript": "^10.0.0",
"commitizen": "^4.2.4",
"cross-env": "^7.0.3",
"cz-conventional-changelog": "^3.3.0",
"cz-customizable": "^6.3.0",
"eslint": "^8.7.0",
"eslint": "^8.8.0",
"eslint-config-airbnb-base": "^15.0.0",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-import": "^2.25.4",
"eslint-plugin-prettier": "^4.0.0",
"eslint-plugin-vue": "^8.3.0",
"eslint-plugin-vue": "^8.4.1",
"husky": "^7.0.4",
"lint-staged": "^12.2.2",
"lint-staged": "^12.3.3",
"mockjs": "^1.1.0",
"patch-package": "^6.4.7",
"postinstall-postinstall": "^2.1.0",
"prettier": "^2.5.1",
"rollup-plugin-visualizer": "^5.5.4",
"sass": "^1.49.0",
"sass": "^1.49.7",
"typescript": "^4.5.5",
"unplugin-icons": "^0.13.0",
"unplugin-vue-components": "^0.17.14",
"unplugin-vue-components": "^0.17.17",
"vite": "^2.7.13",
"vite-plugin-html": "^2.1.2",
"vite-plugin-mock": "^2.9.6",
"vite-plugin-windicss": "^1.6.3",
"vue-tsc": "^0.30.6",
"vueuc": "^0.4.23",
"vue-tsc": "^0.31.2",
"vueuc": "^0.4.25",
"windicss": "^3.4.3"
}
}

4478
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,97 @@
<template>
<div v-if="reloadFlag" class="relative">
<slot></slot>
<div v-show="showPlaceholder" class="absolute-lt w-full h-full" :class="placeholderClass">
<div v-show="loading" class="absolute-center">
<n-spin :show="true" :size="loadingSize" />
</div>
<div v-show="isEmpty" class="absolute-center">
<div class="relative" :class="emptyNetworkClass">
<svg-empty-data class="text-primary" />
<p class="absolute-lb w-full text-center">{{ emptyDesc }}</p>
</div>
</div>
<div v-show="!network" class="absolute-center">
<div
class="relative"
:class="[{ 'cursor-pointer': showNetworkReload }, emptyNetworkClass]"
@click="handleReload"
>
<svg-network-error class="text-primary" />
<p class="absolute-lb w-full text-center">{{ networkErrorDesc }}</p>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, watch, nextTick, onUnmounted } from 'vue';
import { NSpin } from 'naive-ui';
import { NETWORK_ERROR_MSG } from '@/config';
import { SvgEmptyData, SvgNetworkError } from '@/components';
import { useBoolean } from '@/hooks';
interface Props {
/** 是否加载 */
loading: boolean;
/** 是否为空 */
empty?: boolean;
/** 加载图标的大小 */
loadingSize?: 'small' | 'medium' | 'large';
/** 中间占位符的class */
placeholderClass?: string;
/** 空数据描述文本 */
emptyDesc?: string;
/** 空数据和网络异常占位class */
emptyNetworkClass?: string;
/** 显示网络异常的重试点击按钮 */
showNetworkReload?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
loading: false,
empty: false,
loadingSize: 'medium',
placeholderClass: 'bg-white',
emptyDesc: '暂无数据',
emptyNetworkClass: 'w-320px h-320px text-16px text-[#666]',
showNetworkReload: false
});
// 网络状态
const { bool: network, setBool: setNetwork } = useBoolean(window.navigator.onLine);
const { bool: reloadFlag, setBool: setReload } = useBoolean(true);
// 数据是否为空
const isEmpty = computed(() => props.empty && !props.loading && network.value);
const showPlaceholder = computed(() => props.loading || isEmpty.value || !network.value);
const networkErrorDesc = computed(() =>
props.showNetworkReload ? `${NETWORK_ERROR_MSG}, 点击重试` : NETWORK_ERROR_MSG
);
function handleReload() {
if (!props.showNetworkReload) return;
setReload(false);
nextTick(() => {
setReload(true);
});
}
const stopHandle = watch(
() => props.loading,
newValue => {
// 结束加载判断一下网络状态
if (!newValue) {
setNetwork(window.navigator.onLine);
}
}
);
onUnmounted(() => {
stopHandle();
});
</script>
<style scoped></style>

View File

@@ -1,3 +1,4 @@
import LoadingEmptyWrapper from './LoadingEmptyWrapper/index.vue';
import LoginAgreement from './LoginAgreement/index.vue';
export { LoginAgreement };
export { LoadingEmptyWrapper, LoginAgreement };

View File

@@ -30,8 +30,9 @@ function initBetterScroll() {
}
// 滚动元素发生变化刷新BS
const { width: wrapWidth } = useElementSize(bsWrap);
const { width, height } = useElementSize(bsContent);
watch([() => width.value, () => height.value], () => {
watch([() => wrapWidth.value, () => width.value, () => height.value], () => {
if (instance.value) {
instance.value.refresh();
}

View File

@@ -0,0 +1,15 @@
<template>
<web-site-link label="github地址" :link="link" />
</template>
<script setup lang="ts">
import WebSiteLink from '../WebSiteLink/index.vue';
interface Props {
/** github链接 */
link: string;
}
defineProps<Props>();
</script>
<style scoped></style>

View File

@@ -0,0 +1,77 @@
<template>
<n-popover placement="bottom-end" trigger="click">
<template #trigger>
<n-input v-model:value="modelValue" readonly placeholder="点击选择图标">
<template #suffix>
<Icon :icon="modelValue ? modelValue : emptyIcon" class="text-30px p-5px" />
</template>
</n-input>
</template>
<template #header>
<n-input v-model:value="searchValue" placeholder="搜索图标"></n-input>
</template>
<div v-if="iconsList.length > 0" class="grid grid-cols-9 h-auto overflow-auto">
<template v-for="iconItem in iconsList" :key="iconItem">
<Icon
:icon="iconItem"
class="border-1px border-[#d9d9d9] text-30px m-2px p-5px"
:style="{ 'border-color': modelValue === iconItem ? theme.themeColor : '' }"
@click="handleChange(iconItem)"
/>
</template>
</div>
<n-empty v-else class="w-306px" description="你什么也找不到" />
</n-popover>
</template>
<script lang="ts" setup>
import { ref, computed } from 'vue';
import { NPopover, NInput, NEmpty } from 'naive-ui';
import { Icon } from '@iconify/vue';
import { useThemeStore } from '@/store';
interface Props {
/** 选中的图标 */
value: string;
/** 图标列表 */
icons: string[];
/** 未选中图标 */
emptyIcon?: string;
}
interface Emits {
(e: 'update:value', val: string): void;
}
const props = withDefaults(defineProps<Props>(), {
emptyIcon: 'mdi:apps'
});
const emit = defineEmits<Emits>();
const theme = useThemeStore();
const searchValue = ref('');
const iconsList = computed(() => props.icons.filter(v => v.includes(searchValue.value)));
const modelValue = computed({
get() {
return props.value;
},
set(val: string) {
emit('update:value', val);
}
});
function handleChange(iconItem: string) {
modelValue.value = iconItem;
}
</script>
<style lang="scss" scoped>
:deep(.n-input-wrapper) {
padding-right: 0;
}
:deep(.n-input__suffix) {
border: 1px solid #d9d9d9;
}
</style>

View File

@@ -0,0 +1,20 @@
<template>
<p>
<span>{{ label }}</span>
<a class="text-blue-500" :href="link" target="_blank">
{{ link }}
</a>
</p>
</template>
<script setup lang="ts">
interface Props {
/** 网址名称 */
label: string;
/** 网址链接 */
link: string;
}
defineProps<Props>();
</script>
<style scoped></style>

View File

@@ -3,5 +3,8 @@ import ButtonTab from './ButtonTab/index.vue';
import ChromeTab from './ChromeTab/index.vue';
import CountTo from './CountTo/index.vue';
import ImageVerify from './ImageVerify/index.vue';
import WebSiteLink from './WebSiteLink/index.vue';
import GithubLink from './GithubLink/index.vue';
import IconSelect from './IconSelect/index.vue';
export { BetterScroll, ButtonTab, ChromeTab, CountTo, ImageVerify };
export { BetterScroll, ButtonTab, ChromeTab, CountTo, ImageVerify, WebSiteLink, GithubLink, IconSelect };

View File

@@ -1,4 +1,4 @@
import { useBreakpoints, breakpointsTailwind } from '@vueuse/core';
import UAParser from 'ua-parser-js';
interface AppInfo {
/** 项目名称 */
@@ -20,9 +20,9 @@ export function useAppInfo(): AppInfo {
};
}
/** 是否是移动端 */
export function useIsMobile() {
const breakpoints = useBreakpoints(breakpointsTailwind);
const isMobile = breakpoints.smaller('lg');
return isMobile;
/** 获取设备信息 */
export function useDeviceInfo() {
const parser = new UAParser();
const result = parser.getResult();
return result;
}

View File

@@ -0,0 +1 @@
export {};

View File

@@ -0,0 +1 @@
export {};

View File

@@ -1,2 +1,3 @@
export * from './service';
export * from './regexp';
export * from './map-sdk';

View File

@@ -0,0 +1,9 @@
/** 百度地图sdk地址 */
export const BAIDU_MAP_SDK_URL =
'https://api.map.baidu.com/getscript?v=3.0&ak=KSezYymXPth1DIGILRX3oYN9PxbOQQmU&services=&t=20210201100830&s=1';
/** 高德地图sdk地址 */
export const GAODE_MAP_SDK_URL = 'https://webapi.amap.com/maps?v=2.0&key=e7bd02bd504062087e6563daf4d6721d';
/** 腾讯地图sdk地址 */
export const TENCENT_MAP_SDK_URL = 'https://map.qq.com/api/gljs?v=1.exp&key=A6DBZ-KXPLW-JKSRY-ONZF4-CPHY3-K6BL7';

8
src/directives/index.ts Normal file
View File

@@ -0,0 +1,8 @@
import type { App } from 'vue';
import setupNetworkDirective from './network';
import setupLoginDirective from './login';
export function setupDirectives(app: App) {
setupNetworkDirective(app);
setupLoginDirective(app);
}

27
src/directives/login.ts Normal file
View File

@@ -0,0 +1,27 @@
import type { App, Directive } from 'vue';
import { useAuthStore } from '@/store';
import { useRouterPush } from '@/composables';
export default function setupLoginDirective(app: App) {
const auth = useAuthStore();
const { toLogin } = useRouterPush(false);
function listenerHandler(event: MouseEvent) {
if (!auth.isLogin) {
event.stopPropagation();
toLogin();
}
}
const loginDirective: Directive<HTMLElement, boolean | undefined> = {
mounted(el: HTMLElement, binding) {
if (binding.value === false) return;
el.addEventListener('click', listenerHandler, { capture: true });
},
unmounted(el: HTMLElement, binding) {
if (binding.value === false) return;
el.removeEventListener('click', listenerHandler);
}
};
app.directive('login', loginDirective);
}

25
src/directives/network.ts Normal file
View File

@@ -0,0 +1,25 @@
import type { App, Directive } from 'vue';
import { NETWORK_ERROR_MSG } from '@/config';
export default function setupNetworkDirective(app: App) {
function listenerHandler(event: MouseEvent) {
const hasNetwork = window.navigator.onLine;
if (!hasNetwork) {
window.$message?.error(NETWORK_ERROR_MSG);
event.stopPropagation();
}
}
const networkDirective: Directive<HTMLElement, boolean | undefined> = {
mounted(el: HTMLElement, binding) {
if (binding.value === false) return;
el.addEventListener('click', listenerHandler, { capture: true });
},
unmounted(el: HTMLElement, binding) {
if (binding.value === false) return;
el.removeEventListener('click', listenerHandler);
}
};
app.directive('network', networkDirective);
}

View File

@@ -0,0 +1 @@
export {};

View File

@@ -9,6 +9,7 @@
<header-menu />
</div>
<div class="flex justify-end h-full">
<global-search />
<github-site />
<full-screen />
<theme-mode />
@@ -22,6 +23,7 @@ import { DarkModeContainer } from '@/components';
import { useThemeStore } from '@/store';
import type { GlobalHeaderProps } from '@/interface';
import GlobalLogo from '../GlobalLogo/index.vue';
import GlobalSearch from '../GlobalSearch/index.vue';
import {
MenuCollapse,
GlobalBreadcrumb,

View File

@@ -0,0 +1,24 @@
<template>
<div class="px-24px h-44px flex-y-center">
<span class="mr-14px">
<icon-ant-design:enter-outlined class="icon text-20px p-2px mr-3px" />
确认
</span>
<span class="mr-14px">
<icon-mdi:arrow-up-thin class="icon text-20px p-2px mr-5px" />
<icon-mdi:arrow-down-thin class="icon text-20px p-2px mr-3px" />
切换
</span>
<span>
<icon-mdi:close class="icon text-20px p-2px mr-3px" />
关闭
</span>
</div>
</template>
<script lang="ts" setup></script>
<style lang="scss" scoped>
.icon {
box-shadow: inset 0 -2px #cdcde6, inset 0 0 1px 1px #fff, 0 1px 2px 1px #1e235a66;
}
</style>

View File

@@ -0,0 +1,136 @@
<template>
<n-modal
v-model:show="show"
:segmented="{ footer: 'soft' }"
:closable="false"
preset="card"
footer-style="padding: 0; margin: 0"
class="w-630px fixed top-50px left-1/2 transform -translate-x-1/2"
@after-leave="handleClose"
>
<n-input ref="inputRef" v-model:value="keyword" clearable placeholder="请输入关键词搜索" @input="handleSearch">
<template #prefix>
<icon-uil:search class="text-15px text-[#c2c2c2]" />
</template>
</n-input>
<div class="mt-20px">
<n-empty v-if="resultOptions.length === 0" description="暂无搜索结果" />
<search-result v-else v-model:value="activePath" :options="resultOptions" @enter="handleEnter" />
</div>
<template #footer>
<search-footer />
</template>
</n-modal>
</template>
<script lang="ts" setup>
import { ref, shallowRef, computed, watch, nextTick } from 'vue';
import { useRouter } from 'vue-router';
import { NModal, NInput, NEmpty } from 'naive-ui';
import { useDebounceFn, onKeyStroke } from '@vueuse/core';
import { useRouteStore } from '@/store';
import type { RouteList } from './types';
import SearchResult from './SearchResult.vue';
import SearchFooter from './SearchFooter.vue';
interface Props {
/** 弹窗显隐 */
value: boolean;
}
interface Emits {
(e: 'update:value', val: boolean): void;
}
const props = withDefaults(defineProps<Props>(), {});
const emit = defineEmits<Emits>();
const router = useRouter();
const routeStore = useRouteStore();
const keyword = ref('');
const activePath = ref('');
const resultOptions = shallowRef<RouteList[]>([]);
const inputRef = ref<HTMLInputElement | null>(null);
const handleSearch = useDebounceFn(search, 300);
const show = computed({
get() {
return props.value;
},
set(val: boolean) {
emit('update:value', val);
}
});
watch(show, async val => {
if (val) {
/** 自动聚焦 */
await nextTick();
inputRef.value?.focus();
}
});
/** 查询 */
function search() {
resultOptions.value = routeStore.menusList.filter(
menu => keyword.value && menu.meta?.title.toLocaleLowerCase().includes(keyword.value.toLocaleLowerCase().trim())
);
if (resultOptions.value?.length > 0) {
activePath.value = resultOptions.value[0].path;
} else {
activePath.value = '';
}
}
function handleClose() {
show.value = false;
/** 延时处理防止用户看到某些操作 */
setTimeout(() => {
resultOptions.value = [];
keyword.value = '';
}, 200);
}
/** key up */
function handleUp() {
const { length } = resultOptions.value;
if (length === 0) return;
const index = resultOptions.value.findIndex(item => item.path === activePath.value);
if (index === 0) {
activePath.value = resultOptions.value[length - 1].path;
} else {
activePath.value = resultOptions.value[index - 1].path;
}
}
/** key down */
function handleDown() {
const { length } = resultOptions.value;
if (length === 0) return;
const index = resultOptions.value.findIndex(item => item.path === activePath.value);
if (index + 1 === length) {
activePath.value = resultOptions.value[0].path;
} else {
activePath.value = resultOptions.value[index + 1].path;
}
}
/** key enter */
function handleEnter() {
const { length } = resultOptions.value;
if (length === 0 || activePath.value === '') return;
const item = resultOptions.value.find(item => item.path === activePath.value);
if (item?.meta?.href) {
window.open(activePath.value, '__blank');
} else {
router.push(activePath.value);
handleClose();
}
}
onKeyStroke('Escape', handleClose);
onKeyStroke('Enter', handleEnter);
onKeyStroke('ArrowUp', handleUp);
onKeyStroke('ArrowDown', handleDown);
</script>
<style lang="scss" scoped></style>

View File

@@ -0,0 +1,62 @@
<template>
<n-scrollbar>
<div class="pb-12px">
<template v-for="item in options" :key="item.path">
<div
class="bg-[#e5e7eb] dark:bg-dark h-56px mt-8px px-14px rounded-4px cursor-pointer flex-y-center justify-between"
:style="{
background: item.path === active ? theme.themeColor : '',
color: item.path === active ? '#fff' : ''
}"
@click="handleTo"
@mouseenter="handleMouse(item)"
>
<Icon :icon="item.meta?.icon ?? 'mdi:bookmark-minus-outline'" />
<span class="flex-1 ml-5px">{{ item.meta?.title }}</span>
<icon-ant-design:enter-outlined class="icon text-20px p-2px mr-3px" />
</div>
</template>
</div>
</n-scrollbar>
</template>
<script lang="ts" setup>
import { computed } from 'vue';
import { NScrollbar } from 'naive-ui';
import { Icon } from '@iconify/vue';
import { useThemeStore } from '@/store';
import type { RouteList } from './types';
interface Props {
value: string;
options: RouteList[];
}
interface Emits {
(e: 'update:value', val: string): void;
(e: 'enter'): void;
}
const props = withDefaults(defineProps<Props>(), {});
const emit = defineEmits<Emits>();
const active = computed({
get() {
return props.value;
},
set(val: string) {
emit('update:value', val);
}
});
const theme = useThemeStore();
/** 鼠标移入 */
async function handleMouse(item: RouteList) {
active.value = item.path;
}
function handleTo() {
emit('enter');
}
</script>
<style lang="scss" scoped></style>

View File

@@ -0,0 +1,3 @@
import SearchModal from './SearchModal.vue';
export { SearchModal };

View File

@@ -0,0 +1 @@
export type RouteList = AuthRoute.Route;

View File

@@ -0,0 +1,20 @@
<template>
<div>
<hover-container tooltip-content="搜索" class="w-40px h-full" @click="handleSearch">
<icon-uil:search class="text-20px text-[#666]" />
</hover-container>
<search-modal v-model:value="show" />
</div>
</template>
<script lang="ts" setup>
import { HoverContainer } from '@/components';
import { useBoolean } from '@/hooks';
import { SearchModal } from './components';
const { bool: show, toggle } = useBoolean();
function handleSearch() {
toggle();
}
</script>
<style lang="scss" scoped></style>

View File

@@ -1,7 +1,7 @@
<template>
<dark-mode-container class="global-tab flex-y-center w-full pl-16px" :style="{ height: theme.tab.height + 'px' }">
<div ref="bsWrapper" class="flex-1-hidden h-full">
<better-scroll ref="bsScroll" :options="{ scrollX: true, scrollY: false, click: isMobile }">
<better-scroll ref="bsScroll" :options="{ scrollX: true, scrollY: false, click: canClick }">
<tab-detail @scroll="handleScroll" />
</better-scroll>
</div>
@@ -15,20 +15,21 @@ import { useRoute } from 'vue-router';
import { useElementBounding } from '@vueuse/core';
import { DarkModeContainer, BetterScroll } from '@/components';
import { useThemeStore, useTabStore } from '@/store';
import { useIsMobile } from '@/composables';
import { useDeviceInfo } from '@/composables';
import type { ExposeBetterScroll } from '@/interface';
import { TabDetail, ReloadButton } from './components';
const route = useRoute();
const theme = useThemeStore();
const tab = useTabStore();
const deviceInfo = useDeviceInfo();
const bsWrapper = ref<HTMLElement>();
const { width: bsWrapperWidth, left: bsWrapperLeft } = useElementBounding(bsWrapper);
const bsScroll = ref<ExposeBetterScroll>();
const isMobile = useIsMobile();
const canClick = Boolean(deviceInfo.device.type);
function handleScroll(clientX: number) {
const currentX = clientX - bsWrapperLeft.value;

View File

@@ -1,7 +1,8 @@
<template>
<n-divider title-placement="center">主题配置</n-divider>
<textarea id="themeConfigCopyTarget" v-model="dataClipboardText" class="absolute opacity-0" />
<n-space vertical>
<div ref="copyRef" :data-clipboard-text="dataClipboardText">
<div ref="copyRef" data-clipboard-target="#themeConfigCopyTarget">
<n-button type="primary" :block="true">拷贝当前配置</n-button>
</div>
<n-button type="warning" :block="true" @click="handleResetConfig">重置当前配置</n-button>
@@ -17,6 +18,7 @@ import { useThemeStore } from '@/store';
const theme = useThemeStore();
const copyRef = ref<HTMLElement>();
const dataClipboardText = ref(getClipboardText());
function getClipboardText() {
@@ -29,8 +31,7 @@ function handleResetConfig() {
}
function clipboardEventListener() {
if (!copyRef.value) return;
const copy = new Clipboard(copyRef.value);
const copy = new Clipboard(copyRef.value!);
copy.on('success', () => {
window.$dialog?.success({
title: '操作成功',

View File

@@ -2,6 +2,7 @@ import { createApp } from 'vue';
import { setupAssets } from '@/plugins';
import { setupRouter } from '@/router';
import { setupStore } from '@/store';
import { setupDirectives } from '@/directives';
import App from './App.vue';
async function setupApp() {
@@ -13,6 +14,9 @@ async function setupApp() {
// 挂载pinia状态
setupStore(app);
// 挂载自定义vue指令
setupDirectives(app);
// 挂载路由
await setupRouter(app);

View File

@@ -1,4 +1,7 @@
import 'virtual:windi.css';
import 'swiper/css';
import 'swiper/css/navigation';
import 'swiper/css/pagination';
import '../styles/css/global.css';
/** 引入静态资源(全局引入css、字体等) */

View File

@@ -1,4 +1,3 @@
import setupAssets from './assets';
import setupInitSvgLogo from './logo';
export { setupAssets, setupInitSvgLogo };
export { setupAssets };

View File

@@ -1,28 +0,0 @@
/** 初始化加载效果的svg格式logo */
export default function setupInitSvgLogo(id: string) {
const svgStr = `<svg width="128px" height="128px" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px"
y="0px" viewBox="0 0 158.9 158.9" style="enable-background:new 0 0 158.9 158.9;" xml:space="preserve">
<path style="fill:none" d="M0,158.9C0,106.3,0,53.7,0,1.1C0,0.2,0.2,0,1.1,0c52.2,0,104.5,0,156.7,0c0.9,0,1.1,0.2,1.1,1.1
c0,52.2,0,104.5,0,156.7c0,0.9-0.2,1.1-1.1,1.1C105.2,158.8,52.6,158.8,0,158.9z" />
<path style="fill:currentColor" d="M81.3,55.9c-0.1-11.7-2.9-22.5-9.4-32.4c-1-1.5-2.1-2.9-2.5-4.7c-0.7-3.4,0.9-6.9,4-8.6c3-1.7,6.8-1.2,9.3,1.2
c2.4,2.6,4.4,5.6,5.9,8.8c4.7,8.9,7.6,18.6,8.4,28.6c1,12.5-0.7,25-5.2,36.7c-0.9,2.5-1.9,4.9-3,7.3c-0.3,0.4-0.3,1,0,1.4
c9.6,13.3,21.8,23,37.8,27.2c6.4,1.7,13.1,2.3,19.7,1.6c4.2-0.4,7.9,2.7,8.4,6.9c0.7,4.3-2.3,8.3-6.6,9c0,0,0,0-0.1,0
c-7.7,0.9-15.5,0.5-23-1.3c-13.9-3.1-26.7-10-36.9-19.9c-4.4-4.2-8.4-8.8-11.9-13.7c-0.5-0.8-1.4-1.2-2.3-1.1
c-9.5,0.7-18.8,3.3-27.4,7.6c-11.6,6-20.7,14.6-26.4,26.4c-0.7,1.9-2,3.5-3.7,4.7c-2.9,1.7-6.6,1.5-9.2-0.7c-2.8-2.2-3.8-6-2.4-9.3
c2.2-5.2,5.1-10.1,8.7-14.5c12.2-15.4,28.2-24.6,47.3-28.6c4-0.8,8.1-1.4,12.2-1.6c0.5,0,1-0.3,1.2-0.8c3.3-7.1,5.5-14.6,6.5-22.3
C81.1,61.2,81.3,58.6,81.3,55.9z" />
<path style="fill:currentColor" d="M136.3,108.3c-3.8-0.5-7.6-1.4-11.1-2.9c-7.7-2.8-14.4-7.5-19.7-13.8c-2.9-3.3-2.5-8.4,0.8-11.3
c1.4-1.2,3.1-1.9,4.9-1.9c2.5-0.1,5,1,6.5,2.9c4.9,5.6,11.6,9.4,18.9,10.8c1.5,0.2,3.1,0.6,4.5,1.2c3.2,1.8,4.8,5.6,3.8,9.2
C144,106.1,140.8,108.4,136.3,108.3z" />
<path style="fill:currentColor" d="M55.7,33.3c3,0.2,5.6,2.2,6.6,5c2.2,5.4,3.4,11.2,3.6,17c0.3,5.9-0.6,11.7-2.5,17.3c-2,5.8-8.2,7.8-12.9,4.2
c-2.6-2.2-3.6-5.8-2.4-9c1.4-4,1.9-8.2,1.7-12.4c-0.2-3.8-1-7.5-2.4-11C45.3,38.9,49.2,33.3,55.7,33.3z" />
<path style="fill:currentColor" d="M77.9,126.6c0,3.9-2.8,7.2-6.7,7.9c-7.8,1.5-14.8,5.9-19.7,12.2c-2.7,3.5-7.6,4.2-11.2,1.6
c-3.6-2.6-4.3-7.6-1.7-11.2c0.1-0.1,0.2-0.3,0.3-0.4c4.1-5.2,9.3-9.6,15.1-12.8c4.4-2.5,9.1-4.2,14-5.1
C73.3,117.7,77.9,121.3,77.9,126.6z" />
</svg>
`;
const appEl = document.querySelector(id);
const div = document.createElement('div');
div.innerHTML = svgStr;
appEl?.appendChild(div);
}

View File

@@ -0,0 +1,46 @@
import type { Router, RouteLocationNormalized, NavigationGuardNext } from 'vue-router';
import { routeName } from '@/router';
import { useRouteStore } from '@/store';
import { getToken } from '@/utils';
/**
* 动态路由
*/
export async function createDynamicRouteGuard(
to: RouteLocationNormalized,
_from: RouteLocationNormalized,
next: NavigationGuardNext,
router: Router
) {
const route = useRouteStore();
const isLogin = Boolean(getToken());
// 初始化动态路由
if (!route.isAddedDynamicRoute) {
// 未登录情况下直接回到登录页,登录成功后再加载动态路由
if (!isLogin) {
if (to.name === routeName('login')) {
next();
} else {
const redirect = to.fullPath;
next({ name: routeName('login'), query: { redirect } });
}
return false;
}
await route.initDynamicRoute(router);
if (to.name === routeName('not-found-page')) {
// 动态路由没有加载导致被not-found-page路由捕获等待动态路由加载好了回到之前的路由
next({ path: to.fullPath, replace: true, query: to.query });
return false;
}
// 动态路由已经加载仍然未找到重定向到not-found
if (to.name === routeName('not-found-page')) {
next({ name: routeName('not-found'), replace: true });
return false;
}
}
return true;
}

View File

@@ -1,6 +1,6 @@
import type { Router } from 'vue-router';
import { useTitle } from '@vueuse/core';
import { handlePagePermission } from './permission';
import { createPermissionGuard } from './permission';
/**
* 路由守卫函数
@@ -11,7 +11,7 @@ export function createRouterGuard(router: Router) {
// 开始 loadingBar
window.$loadingBar?.start();
// 页面跳转权限处理
await handlePagePermission(to, from, next, router);
await createPermissionGuard(to, from, next, router);
});
router.afterEach(to => {
// 设置document title

View File

@@ -1,39 +1,19 @@
import type { Router, RouteLocationNormalized, NavigationGuardNext } from 'vue-router';
import { routeName } from '@/router';
import { useAuthStore, useRouteStore } from '@/store';
import { useAuthStore } from '@/store';
import { exeStrategyActions, getToken } from '@/utils';
import { createDynamicRouteGuard } from './dynamic';
/** 处理路由页面的权限 */
export async function handlePagePermission(
export async function createPermissionGuard(
to: RouteLocationNormalized,
from: RouteLocationNormalized,
next: NavigationGuardNext,
router: Router
) {
const auth = useAuthStore();
const route = useRouteStore();
const isLogin = Boolean(getToken());
const permissions = to.meta.permissions || [];
const needLogin = Boolean(to.meta?.requiresAuth) || Boolean(permissions.length);
const hasPermission = !permissions.length || permissions.includes(auth.userInfo.userRole);
// 初始化动态路由
if (!route.isAddedDynamicRoute) {
await route.initDynamicRoute(router);
if (to.name === routeName('not-found-page')) {
// 动态路由没有加载导致被not-found-page路由捕获等待动态路由加载好了回到之前的路由
next({ path: to.fullPath, replace: true, query: to.query });
return;
}
}
// 动态路由已经加载仍然未找到重定向到not-found
if (to.name === routeName('not-found-page')) {
next({ name: routeName('not-found'), replace: true });
return;
}
// 动态路由
const permission = await createDynamicRouteGuard(to, from, next, router);
if (!permission) return;
// 外链路由, 从新标签打开,返回上一个路由
if (to.meta.href) {
@@ -42,6 +22,12 @@ export async function handlePagePermission(
return;
}
const auth = useAuthStore();
const isLogin = Boolean(getToken());
const permissions = to.meta.permissions || [];
const needLogin = Boolean(to.meta?.requiresAuth) || Boolean(permissions.length);
const hasPermission = !permissions.length || permissions.includes(auth.userInfo.userRole);
const actions: Common.StrategyAction[] = [
// 已登录状态跳转登录页,跳转至首页
[

View File

@@ -32,7 +32,7 @@ export function fetchUserInfo() {
* @param userId - 用户id
* @description 后端根据用户id查询到对应的角色类型并将路由筛选出对应角色的路由数据返回前端
*/
export function fetchUserRoutes(userId: string = 'soybean') {
export function fetchUserRoutes(userId: string) {
return mockRequest.post<ApiRoute.Route>('/getUserRoutes', { userId });
}

View File

@@ -1,7 +1,9 @@
import { createRequest } from './request';
import { serviceEnv } from '~/.env-config';
const { url } = serviceEnv[import.meta.env.VITE_HTTP_ENV];
const env = import.meta.env.VITE_HTTP_ENV || 'test';
const { url } = serviceEnv[env];
export const request = createRequest({ baseURL: url });

View File

@@ -1,5 +1,5 @@
import axios from 'axios';
import type { AxiosRequestConfig, AxiosInstance, AxiosError, CancelTokenStatic } from 'axios';
import type { AxiosRequestConfig, AxiosInstance, AxiosError } from 'axios';
import { REQUEST_TIMEOUT, REFRESH_TOKEN_CODE } from '@/config';
import {
getToken,
@@ -18,17 +18,28 @@ import { refreshToken } from './helpers';
export default class CustomAxiosInstance {
instance: AxiosInstance;
private backendSuccessCode = 200;
backendConfig: Service.BackendResultConfig;
cancelToken: CancelTokenStatic;
constructor(axiosConfig: AxiosRequestConfig) {
/**
*
* @param axiosConfig - axios配置
* @param backendSuccessCode - 后端业务上定义的成功请求的状态码
*/
constructor(
axiosConfig: AxiosRequestConfig,
backendConfig: Service.BackendResultConfig = {
codeKey: 'code',
dataKey: 'data',
msgKey: 'message',
successCode: 200
}
) {
this.backendConfig = backendConfig;
const defaultConfig: AxiosRequestConfig = {
timeout: REQUEST_TIMEOUT
};
Object.assign(defaultConfig, axiosConfig);
this.instance = axios.create(defaultConfig);
this.cancelToken = axios.CancelToken;
this.setInterceptor();
}
@@ -55,21 +66,22 @@ export default class CustomAxiosInstance {
async response => {
const { status } = response;
if (status === 200 || status < 300 || status === 304) {
const backend = response.data as Service.BackendServiceResult;
const backend = response.data;
const { codeKey, dataKey, successCode } = this.backendConfig;
// 请求成功
if (backend.code === this.backendSuccessCode) {
return handleServiceResult(null, backend.data);
if (backend[codeKey] === successCode) {
return handleServiceResult(null, backend[dataKey]);
}
// token失效, 刷新token
if (REFRESH_TOKEN_CODE.includes(backend.code)) {
if (REFRESH_TOKEN_CODE.includes(backend[codeKey])) {
const config = await refreshToken(response.config);
if (config) {
return this.instance.request(config);
}
}
const error = handleBackendError(backend);
const error = handleBackendError(backend, this.backendConfig);
return handleServiceResult(error, null);
}
const error = handleResponseError(response);

View File

@@ -16,9 +16,10 @@ interface RequestParam {
/**
* 创建请求
* @param axiosConfig - axios配置
* @param backendConfig - 后端接口字段配置
*/
export function createRequest(axiosConfig: AxiosRequestConfig) {
const customInstance = new CustomAxiosInstance(axiosConfig);
export function createRequest(axiosConfig: AxiosRequestConfig, backendConfig?: Service.BackendResultConfig) {
const customInstance = new CustomAxiosInstance(axiosConfig, backendConfig);
/**
* 异步promise请求
@@ -98,9 +99,10 @@ type RequestResultHook<T = any> = {
/**
* 创建hooks请求
* @param axiosConfig - axios配置
* @param backendConfig - 后端接口字段配置
*/
export function createHookRequest(axiosConfig: AxiosRequestConfig) {
const customInstance = new CustomAxiosInstance(axiosConfig);
export function createHookRequest(axiosConfig: AxiosRequestConfig, backendConfig?: Service.BackendResultConfig) {
const customInstance = new CustomAxiosInstance(axiosConfig, backendConfig);
/**
* hooks请求

View File

@@ -0,0 +1,142 @@
{
"darkMode": false,
"layout": {
"minWidth": 900,
"mode": "vertical",
"modeList": [
{
"value": "vertical",
"label": "左侧菜单模式"
},
{
"value": "vertical-mix",
"label": "左侧菜单混合模式"
},
{
"value": "horizontal",
"label": "顶部菜单模式"
},
{
"value": "horizontal-mix",
"label": "顶部菜单混合模式"
}
]
},
"themeColor": "#1890ff",
"themeColorList": [
"#1890ff",
"#409EFF",
"#2d8cf0",
"#007AFF",
"#5ac8fa",
"#5856D6",
"#536dfe",
"#9c27b0",
"#AF52DE",
"#0096c7",
"#00C1D4",
"#34C759",
"#43a047",
"#7cb342",
"#c0ca33",
"#78DEC7",
"#e53935",
"#d81b60",
"#f4511e",
"#fb8c00",
"#ffb300",
"#fdd835",
"#6d4c41",
"#546e7a"
],
"otherColor": {
"info": "#0099ad",
"success": "#52c41a",
"warning": "#faad14",
"error": "#f5222d"
},
"isCustomizeInfoColor": false,
"fixedHeaderAndTab": true,
"showReload": true,
"header": {
"height": 56,
"crumb": {
"visible": true,
"showIcon": true
}
},
"tab": {
"visible": true,
"height": 44,
"mode": "chrome",
"modeList": [
{
"value": "chrome",
"label": "谷歌风格"
},
{
"value": "button",
"label": "按钮风格"
}
],
"isCache": true
},
"sider": {
"width": 220,
"collapsedWidth": 64,
"mixWidth": 80,
"mixCollapsedWidth": 48,
"mixChildMenuWidth": 200
},
"menu": {
"horizontalPosition": "flex-start",
"horizontalPositionList": [
{
"value": "flex-start",
"label": "居左"
},
{
"value": "center",
"label": "居中"
},
{
"value": "flex-end",
"label": "居右"
}
]
},
"footer": {
"fixed": false,
"height": 48
},
"page": {
"animate": true,
"animateMode": "fade-slide",
"animateModeList": [
{
"value": "fade-slide",
"label": "滑动"
},
{
"value": "fade",
"label": "消退"
},
{
"value": "fade-bottom",
"label": "底部消退"
},
{
"value": "fade-scale",
"label": "缩放消退"
},
{
"value": "zoom-fade",
"label": "渐变"
},
{
"value": "zoom-out",
"label": "闪现"
}
]
}
}

View File

@@ -1,5 +1,6 @@
import { EnumThemeLayoutMode, EnumThemeTabMode, EnumThemeHorizontalMenuPosition, EnumThemeAnimateMode } from '@/enum';
import type { ThemeSetting } from '@/interface';
import jsonSetting from './theme.json';
const themeColorList = [
'#1890ff',
@@ -101,4 +102,4 @@ const defaultThemeSetting: ThemeSetting = {
}
};
export const themeSetting = defaultThemeSetting;
export const themeSetting = (jsonSetting as ThemeSetting) || defaultThemeSetting;

View File

@@ -1,7 +1,13 @@
import type { Router } from 'vue-router';
import { defineStore } from 'pinia';
import { fetchUserRoutes } from '@/service';
import { transformAuthRouteToMenu, transformAuthRoutesToVueRoutes, getCacheRoutes } from '@/utils';
import {
getUserInfo,
transformAuthRouteToMenu,
transformAuthRoutesToVueRoutes,
transformRouteToList,
getCacheRoutes
} from '@/utils';
import type { GlobalMenuOption } from '@/interface';
import { useTabStore } from '../tab';
@@ -12,6 +18,7 @@ interface RouteState {
routeHomeName: AuthRoute.RouteKey;
/** 菜单 */
menus: GlobalMenuOption[];
menusList: AuthRoute.Route[];
/** 缓存的路由名称 */
cacheRoutes: string[];
}
@@ -21,6 +28,7 @@ export const useRouteStore = defineStore('route-store', {
isAddedDynamicRoute: false,
routeHomeName: 'dashboard_analysis',
menus: [],
menusList: [],
cacheRoutes: []
}),
actions: {
@@ -31,10 +39,13 @@ export const useRouteStore = defineStore('route-store', {
async initDynamicRoute(router: Router) {
const { initHomeTab } = useTabStore();
const { data } = await fetchUserRoutes();
const { userId } = getUserInfo();
if (!userId) return;
const { data } = await fetchUserRoutes(userId);
if (data) {
this.routeHomeName = data.home;
this.menus = transformAuthRouteToMenu(data.routes);
this.menusList = transformRouteToList(data.routes);
const vueRoutes = transformAuthRoutesToVueRoutes(data.routes);
vueRoutes.forEach(route => {

9
src/typings/common/map.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
/// <reference types="@amap/amap-jsapi-types" />
/// <reference types="bmapgl" />
declare namespace BMap {
class Map extends BMapGL.Map {}
class Point extends BMapGL.Point {}
}
declare const TMap: any;

View File

@@ -22,15 +22,29 @@ declare namespace AuthRoute {
| 'document_vite'
| 'document_naive'
| 'document_project'
| 'component'
| 'component_button'
| 'component_card'
| 'component_table'
| 'plugin'
| 'plugin_map'
| 'plugin_video'
| 'plugin_editor'
| 'plugin_editor_quill'
| 'plugin_editor_markdown'
| 'plugin_copy'
| 'plugin_icon'
| 'plugin_print'
| 'plugin_swiper'
| 'exception'
| 'exception_403'
| 'exception_404'
| 'exception_500'
| 'multi-menu'
| 'multi-menu_first'
| 'multi-menu_first_second'
| 'multi-menu_first_second-new'
| 'multi-menu_first_second-new_third'
| 'exception'
| 'exception_403'
| 'exception_404'
| 'exception_500'
| 'about';
/** 路由的path */
@@ -55,20 +69,20 @@ declare namespace AuthRoute {
title: string;
/** 路由的动态路径 */
dynamicPath?: PathToDynamicPath<'/login'>;
/** 作为单路由的父级路由布局组件 */
/** 作为单路由的父级路由布局组件 */
singleLayout?: Extract<RouteComponent, 'basic' | 'blank'>;
/** 需要登录权限 */
requiresAuth?: boolean;
/** 哪些类型的用户有权限才能访问的路由 */
/** 哪些类型的用户有权限才能访问的路由(空的话则表示不需要权限) */
permissions?: Auth.RoleType[];
/** 缓存页面 */
keepAlive?: boolean;
/** 菜单和面包屑对应的图标 */
icon?: string;
/** 外链链接 */
href?: string;
/** 是否在菜单中隐藏 */
hide?: boolean;
/** 外链链接 */
href?: string;
/** 路由顺序,可用于菜单的排序 */
order?: number;
/** 表示是否是多级路由的中间级路由(用于转换路由数据时筛选多级路由的标识,定义路由时不用填写) */
@@ -102,19 +116,16 @@ declare namespace AuthRoute {
/** 单独一级路由的key (单独路由需要添加一个父级路由用于应用布局组件) */
type SingleRouteKey = Exclude<
GetSingleRouteKey<RouteKey>,
GetMultiRouteParentKey<RouteKey> | 'root' | 'not-found-page'
GetRouteFirstParentKey<RouteKey> | 'root' | 'not-found-page'
>;
/** 单独路由父级路由key */
type SingleRouteParentKey = `${SingleRouteKey}-parent`;
/** 单独路由path */
type SingleRoutePath = KeyToPath<SingleRouteKey>;
/** 单独路由父级路由path */
type SingleRouteParentPath = KeyToPath<SingleRouteParentKey>;
/** 路由key转换路由path */
type KeyToPath<Key extends RouteKey> = Key extends `${infer Left}_${infer Right}`
type KeyToPath<Key extends string> = Key extends `${infer Left}_${infer Right}`
? KeyToPath<`${Left}/${Right}`>
: `/${Key}`;
@@ -124,12 +135,12 @@ declare namespace AuthRoute {
| `${Path}/:module(${string})`
| `${Path}/:module(${string})?`;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
type GetSingleRouteKey<Key extends RouteKey> = Key extends `${infer Left}${RouteSplitMark}${infer Right}`
? never
: Key;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
type GetMultiRouteParentKey<Key extends RouteKey> = Key extends `${infer Left}${RouteSplitMark}${infer Right}`
/** 获取一级路由(包括有子路由的一级路由) */
type GetSingleRouteKey<Key extends RouteKey> =
Key extends `${infer IgnoredLeft}${RouteSplitMark}${infer IgnoredRight}` ? never : Key;
/** 获取子路由的一级父路由 */
type GetRouteFirstParentKey<Key extends RouteKey> = Key extends `${infer Left}${RouteSplitMark}${infer IgnoredRight}`
? Left
: never;
}

View File

@@ -23,14 +23,16 @@ declare namespace Service {
msg: string;
}
/** 后端接口返回的数据的类型 */
interface BackendServiceResult<T = any> {
/** 状态码 */
code: string | number;
/** 接口数据 */
data: T;
/** 接口消息 */
message: string;
/** 后端接口返回的数据结构配置 */
interface BackendResultConfig {
/** 表示后端请求状态码的属性字段 */
codeKey: string;
/** 表示后端请求数据的属性字段 */
dataKey: string;
/** 表示后端消息的属性字段 */
msgKey: string;
/** 后端业务上定义的成功请求的状态 */
successCode: number | string;
}
/** 自定义的请求成功结果 */
@@ -51,4 +53,14 @@ declare namespace Service {
/** 自定义的请求结果 */
type RequestResult<T = any> = SuccessResult<T> | FailedResult;
/** mock示例接口类型后端接口返回的数据的类型 */
interface MockServiceResult<T = any> {
/** 状态码 */
code: string | number;
/** 接口数据 */
data: T;
/** 接口消息 */
message: string;
}
}

View File

@@ -21,3 +21,14 @@ export function transformToTimeCountDown(seconds: number) {
const second = fillZero(seconds - minuteNum * SECONDS_A_MINUTE);
return `${minute}: ${second}`;
}
/**
* 获取指定整数范围内的随机整数
* @param start - 开始范围
* @param end - 结束范围
*/
export function getRandomInterger(end: number, start: number = 0) {
const range = end - start;
const random = Math.floor(Math.random() * range + start);
return random;
}

View File

@@ -1,21 +1,7 @@
import type { Component } from 'vue';
import { EnumLayoutComponentName } from '@/enum';
import { BasicLayout, BlankLayout } from '@/layouts';
import {
Login,
NoPermission,
NotFound,
ServiceError,
DashboardAnalysis,
DashboardWorkbench,
DocumentVue,
DocumentVueNew,
DocumentVite,
DocumentNaive,
About,
MultiMenuFirstSecond,
MultiMenuFirstSecondNewThird
} from '@/views';
import { views } from '@/views';
import type { LayoutComponentName } from '@/interface';
type LayoutComponent = Record<LayoutComponentName, () => Promise<Component>>;
@@ -32,69 +18,12 @@ export function getLayoutComponent(layoutType: LayoutComponentName) {
return () => setViewComponentName(layoutComponent[layoutType], EnumLayoutComponentName[layoutType]);
}
/** 需要用到自身vue组件的页面 */
type ViewComponentKey = Exclude<
AuthRoute.RouteKey,
| 'root'
| 'dashboard'
| 'document'
| 'document_project'
| 'multi-menu'
| 'multi-menu_first'
| 'multi-menu_first_second-new'
| 'exception'
>;
type ViewComponent = Record<ViewComponentKey, () => Promise<Component>>;
/**
* 获取页面导入的vue文件(懒加载的方式)
* @param routeKey - 路由key
*/
export function getViewComponent(routeKey: AuthRoute.RouteKey) {
const keys: ViewComponentKey[] = [
'login',
'no-permission',
'not-found',
'service-error',
'dashboard_analysis',
'dashboard_workbench',
'document_vue',
'document_vue-new',
'document_vite',
'document_naive',
'about',
'multi-menu_first_second',
'multi-menu_first_second-new_third',
'exception_403',
'exception_404',
'exception_500',
'not-found-page'
];
const key = keys.includes(routeKey as ViewComponentKey) ? (routeKey as ViewComponentKey) : 'not-found';
const viewComponent: ViewComponent = {
login: Login,
'no-permission': NoPermission,
'not-found': NotFound,
'service-error': ServiceError,
dashboard_analysis: DashboardAnalysis,
dashboard_workbench: DashboardWorkbench,
document_vue: DocumentVue,
'document_vue-new': DocumentVueNew,
document_vite: DocumentVite,
document_naive: DocumentNaive,
'multi-menu_first_second': MultiMenuFirstSecond,
'multi-menu_first_second-new_third': MultiMenuFirstSecondNewThird,
'not-found-page': NotFound,
exception_403: NoPermission,
exception_404: NotFound,
exception_500: ServiceError,
about: About
};
return () => setViewComponentName(viewComponent[key], key) as Promise<Component>;
return () => setViewComponentName(views[routeKey], routeKey) as Promise<Component>;
}
/** 给页面组件设置名称 */

View File

@@ -13,6 +13,20 @@ export function transformAuthRoutesToVueRoutes(routes: AuthRoute.Route[]) {
return routes.map(route => transformAuthRouteToVueRoute(route)).flat(1);
}
/** 将路由转换成菜单列表 */
export function transformRouteToList(routes: AuthRoute.Route[], treeMap: AuthRoute.Route[] = []) {
if (routes && routes.length === 0) return [];
return routes.reduce((acc, cur) => {
if (!cur.meta?.hide) {
acc.push(cur);
}
if (cur.children && cur.children.length > 0) {
transformRouteToList(cur.children, treeMap);
}
return acc;
}, treeMap);
}
/**
* 将单个权限路由转换成vue路由
* @param route - 权限路由

View File

@@ -87,11 +87,12 @@ export function handleResponseError(response: AxiosResponse) {
* 处理后端返回的错误(业务错误)
* @param backendResult - 后端接口的响应数据
*/
export function handleBackendError(backendResult: Service.BackendServiceResult) {
export function handleBackendError(backendResult: Record<string, any>, config: Service.BackendResultConfig) {
const { codeKey, msgKey } = config;
const error: Service.RequestError = {
type: 'backend',
code: backendResult.code,
msg: backendResult.message
code: backendResult[codeKey],
msg: backendResult[msgKey]
};
showErrorMsg(error);

View File

@@ -1,3 +0,0 @@
const About = () => import('./index.vue');
export { About };

View File

@@ -0,0 +1,576 @@
<template>
<div>
<n-card title="按钮" class="h-full shadow-sm rounded-16px">
<n-grid cols="s:1 m:2" responsive="screen" :x-gap="16" :y-gap="16">
<n-grid-item v-for="item in buttonExample" :key="item.id">
<n-card :title="item.label" class="min-h-180px">
<p v-if="item.desc" class="pb-16px">{{ item.desc }}</p>
<n-space>
<n-button
v-for="button in item.buttons"
:key="button.id"
v-bind="button.props"
:style="`--icon-margin: ${button.props.circle ? 0 : 6}px`"
>
<template v-if="button.icon" #icon>
<Icon :icon="button.icon" />
</template>
{{ button.label }}
</n-button>
</n-space>
</n-card>
</n-grid-item>
<n-grid-item class="h-180px">
<n-card title="加载中" class="h-full">
<p class="pb-16px">按钮有加载状态</p>
<n-space>
<n-button :loading="loading" type="primary" @click="startLoading">开始加载</n-button>
<n-button @click="endLoading">取消加载</n-button>
</n-space>
</n-card>
</n-grid-item>
</n-grid>
</n-card>
</div>
</template>
<script setup lang="ts">
import { NCard, NGrid, NGridItem, NSpace, NButton } from 'naive-ui';
import type { ButtonProps } from 'naive-ui';
import { Icon } from '@iconify/vue';
import { useLoading } from '@/hooks';
interface ButtonDetail {
id: number;
props: ButtonProps & { href?: string; target?: string };
label?: string;
icon?: string;
}
interface ButtonExample {
id: number;
label: string;
buttons: ButtonDetail[];
desc?: string;
}
const { loading, startLoading, endLoading } = useLoading();
const buttonExample: ButtonExample[] = [
{
id: 0,
label: '基础',
buttons: [
{
id: 0,
props: {},
label: 'Default'
},
{
id: 1,
props: { type: 'tertiary' },
label: 'Tertiary'
},
{
id: 2,
props: { type: 'primary' },
label: 'Primary'
},
{
id: 3,
props: { type: 'info' },
label: 'Info'
},
{
id: 4,
props: { type: 'success' },
label: 'Success'
},
{
id: 5,
props: { type: 'warning' },
label: 'Warning'
},
{
id: 6,
props: { type: 'error' },
label: 'Error'
}
],
desc: '按钮的 type 分别为 default、primary、info、success、warning 和 error。'
},
{
id: 1,
label: '次要按钮',
buttons: [
{
id: 0,
props: { strong: true, secondary: true },
label: 'Default'
},
{
id: 1,
props: { strong: true, secondary: true, type: 'tertiary' },
label: 'Tertiary'
},
{
id: 2,
props: { strong: true, secondary: true, type: 'primary' },
label: 'Primary'
},
{
id: 3,
props: { strong: true, secondary: true, type: 'info' },
label: 'Info'
},
{
id: 4,
props: { strong: true, secondary: true, type: 'success' },
label: 'Success'
},
{
id: 5,
props: { strong: true, secondary: true, type: 'warning' },
label: 'Warning'
},
{
id: 6,
props: { strong: true, secondary: true, type: 'error' },
label: 'Error'
},
{
id: 7,
props: { strong: true, secondary: true, round: true },
label: 'Default'
},
{
id: 8,
props: { strong: true, secondary: true, round: true, type: 'tertiary' },
label: 'Tertiary'
},
{
id: 9,
props: { strong: true, secondary: true, round: true, type: 'primary' },
label: 'Primary'
},
{
id: 10,
props: { strong: true, secondary: true, round: true, type: 'info' },
label: 'Info'
},
{
id: 11,
props: { strong: true, secondary: true, round: true, type: 'success' },
label: 'Success'
},
{
id: 12,
props: { strong: true, secondary: true, round: true, type: 'warning' },
label: 'Warning'
},
{
id: 13,
props: { strong: true, secondary: true, round: true, type: 'error' },
label: 'Error'
}
]
},
{
id: 2,
label: '次次要按钮',
buttons: [
{
id: 0,
props: { tertiary: true },
label: 'Default'
},
{
id: 1,
props: { tertiary: true, type: 'primary' },
label: 'Primary'
},
{
id: 2,
props: { tertiary: true, type: 'info' },
label: 'Info'
},
{
id: 3,
props: { tertiary: true, type: 'success' },
label: 'Success'
},
{
id: 4,
props: { tertiary: true, type: 'warning' },
label: 'Warning'
},
{
id: 5,
props: { tertiary: true, type: 'error' },
label: 'Error'
},
{
id: 6,
props: { tertiary: true, round: true },
label: 'Default'
},
{
id: 7,
props: { tertiary: true, round: true, type: 'primary' },
label: 'Primary'
},
{
id: 8,
props: { tertiary: true, round: true, type: 'info' },
label: 'Info'
},
{
id: 9,
props: { tertiary: true, round: true, type: 'success' },
label: 'Success'
},
{
id: 10,
props: { tertiary: true, round: true, type: 'warning' },
label: 'Warning'
},
{
id: 11,
props: { tertiary: true, round: true, type: 'error' },
label: 'Error'
}
]
},
{
id: 3,
label: '次次次要按钮',
buttons: [
{
id: 0,
props: { quaternary: true },
label: 'Default'
},
{
id: 1,
props: { quaternary: true, type: 'primary' },
label: 'Primary'
},
{
id: 2,
props: { quaternary: true, type: 'info' },
label: 'Info'
},
{
id: 3,
props: { quaternary: true, type: 'success' },
label: 'Success'
},
{
id: 4,
props: { quaternary: true, type: 'warning' },
label: 'Warning'
},
{
id: 5,
props: { quaternary: true, type: 'error' },
label: 'Error'
},
{
id: 6,
props: { quaternary: true, round: true },
label: 'Default'
},
{
id: 7,
props: { quaternary: true, round: true, type: 'primary' },
label: 'Primary'
},
{
id: 8,
props: { quaternary: true, round: true, type: 'info' },
label: 'Info'
},
{
id: 9,
props: { quaternary: true, round: true, type: 'success' },
label: 'Success'
},
{
id: 10,
props: { quaternary: true, round: true, type: 'warning' },
label: 'Warning'
},
{
id: 11,
props: { quaternary: true, round: true, type: 'error' },
label: 'Error'
}
]
},
{
id: 4,
label: '虚线按钮',
buttons: [
{
id: 0,
props: { dashed: true },
label: 'Default'
},
{
id: 1,
props: { dashed: true, type: 'tertiary' },
label: 'Tertiary'
},
{
id: 2,
props: { dashed: true, type: 'primary' },
label: 'Primary'
},
{
id: 3,
props: { dashed: true, type: 'info' },
label: 'Info'
},
{
id: 4,
props: { dashed: true, type: 'success' },
label: 'Success'
},
{
id: 5,
props: { dashed: true, type: 'warning' },
label: 'Warning'
},
{
id: 6,
props: { dashed: true, type: 'error' },
label: 'Error'
}
]
},
{
id: 5,
label: '尺寸',
buttons: [
{
id: 0,
props: { size: 'tiny', strong: true },
label: '小小'
},
{
id: 1,
props: { size: 'small', strong: true },
label: '小'
},
{
id: 2,
props: { size: 'medium', strong: true },
label: '不小'
},
{
id: 3,
props: { size: 'large', strong: true },
label: '不不小'
}
]
},
{
id: 6,
label: '文本按钮',
buttons: [
{
id: 0,
props: { text: true },
label: '那车头依然吐着烟',
icon: 'mdi:train'
}
]
},
{
id: 7,
label: '自定义标签按钮',
buttons: [
{
id: 0,
props: {
text: true,
tag: 'a',
href: 'https://github.com/honghuangdc/soybean-admin',
target: '_blank',
type: 'primary'
},
label: 'soybean-admin'
}
],
desc: '你可以把按钮渲染成不同的标签,比如 a标签 。'
},
{
id: 8,
label: '按钮禁用',
buttons: [
{
id: 0,
props: {
disabled: true
},
label: '不许点'
}
],
desc: '按钮可以被禁用'
},
{
id: 9,
label: '图标按钮',
buttons: [
{
id: 0,
props: {
secondary: true,
strong: true
},
label: '+100元',
icon: 'mdi:cash-100'
},
{
id: 0,
props: {
iconPlacement: 'right',
secondary: true,
strong: true
},
label: '+100元',
icon: 'mdi:cash-100'
}
],
desc: '在按钮上使用图标。'
},
{
id: 10,
label: '不同形状按钮',
buttons: [
{
id: 0,
props: {
circle: true
},
icon: 'mdi:cash-100'
},
{
id: 1,
props: {
round: true
},
label: '圆角'
},
{
id: 2,
props: {},
label: '方'
}
],
desc: '按钮拥有不同的形状。'
},
{
id: 11,
label: '透明背景按钮',
buttons: [
{
id: 0,
props: { ghost: true },
label: 'Default'
},
{
id: 1,
props: { ghost: true, type: 'tertiary' },
label: 'Tertiary'
},
{
id: 2,
props: { ghost: true, type: 'primary' },
label: 'Primary'
},
{
id: 3,
props: { ghost: true, type: 'info' },
label: 'Info'
},
{
id: 4,
props: { ghost: true, type: 'success' },
label: 'Success'
},
{
id: 5,
props: { ghost: true, type: 'warning' },
label: 'Warning'
},
{
id: 6,
props: { ghost: true, type: 'error' },
label: 'Error'
}
],
desc: 'Ghost 按钮有透明的背景。'
},
{
id: 12,
label: '自定义颜色',
buttons: [
{
id: 0,
props: {
color: '#8a2be2'
},
label: '#8a2be2',
icon: 'ic:baseline-color-lens'
},
{
id: 1,
props: {
color: '#ff69b4'
},
label: '#ff69b4',
icon: 'ic:baseline-color-lens'
},
{
id: 2,
props: {
color: '#8a2be2',
ghost: true
},
label: '#8a2be2',
icon: 'ic:baseline-color-lens'
},
{
id: 3,
props: {
color: '#ff69b4',
ghost: true
},
label: '#ff69b4',
icon: 'ic:baseline-color-lens'
},
{
id: 4,
props: {
color: '#8a2be2',
text: true
},
label: '#8a2be2',
icon: 'ic:baseline-color-lens'
},
{
id: 5,
props: {
color: '#ff69b4',
text: true
},
label: '#ff69b4',
icon: 'ic:baseline-color-lens'
}
],
desc: '这两个颜色看起来像毒蘑菇。'
}
];
</script>
<style scoped></style>

View File

@@ -0,0 +1,43 @@
<template>
<div>
<n-card title="卡片" class="h-full shadow-sm rounded-16px">
<n-space :vertical="true">
<n-card title="基本用法">
<p class="pb-16px">基础卡片</p>
<n-card title="卡片">卡片内容</n-card>
</n-card>
<n-card title="尺寸">
<p class="pb-16px">卡片有 smallmediumlargehuge 尺寸</p>
<n-space vertical>
<n-card title="小卡片" size="small">卡片内容</n-card>
<n-card title="中卡片" size="medium">卡片内容</n-card>
<n-card title="大卡片" size="large">卡片内容</n-card>
<n-card title="超大卡片" size="huge">卡片内容</n-card>
</n-space>
</n-card>
<n-card title="文本按钮">
<p class="pb-16px">
content footer 可以被 hard soft 分段action 可以被分段分段分割线会在区域的上方出现
</p>
<n-card
title="卡片分段示例"
:segmented="{
content: true,
footer: 'soft'
}"
>
<template #header-extra>#header-extra</template>
卡片内容
<template #footer>#footer</template>
<template #action>#action</template>
</n-card>
</n-card>
</n-space>
</n-card>
</div>
</template>
<script setup lang="ts">
import { NCard, NSpace } from 'naive-ui';
</script>
<style scoped></style>

View File

@@ -0,0 +1,85 @@
<template>
<div>
<n-card title="表格" class="h-full shadow-sm rounded-16px">
<n-space :vertical="true">
<n-space>
<n-button @click="getDataSource">有数据</n-button>
<n-button @click="getEmptyDataSource">空数据</n-button>
</n-space>
<loading-empty-wrapper class="h-480px" :loading="loading" :empty="empty">
<n-data-table :columns="columns" :data="dataSource" :flex-height="true" class="h-480px" />
</loading-empty-wrapper>
</n-space>
</n-card>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { NCard, NSpace, NButton, NDataTable } from 'naive-ui';
import type { DataTableColumn } from 'naive-ui';
import { LoadingEmptyWrapper } from '@/components';
import { useLoadingEmpty } from '@/hooks';
import { getRandomInterger } from '@/utils';
interface DataSource {
name: string;
age: number;
address: string;
}
const { loading, startLoading, endLoading, empty, setEmpty } = useLoadingEmpty();
const columns: DataTableColumn[] = [
{
title: 'Name',
key: 'name',
align: 'center'
},
{
title: 'Age',
key: 'age'
},
{
title: 'Address',
key: 'address'
}
];
const dataSource = ref<DataSource[]>([]);
function createDataSource(): DataSource[] {
return Array(100)
.fill(1)
.map((_item, index) => {
return {
name: `Name${index}`,
age: getRandomInterger(30, 20),
address: '中国'
};
});
}
function getDataSource() {
startLoading();
setTimeout(() => {
dataSource.value = createDataSource();
endLoading();
setEmpty(!dataSource.value.length);
}, 1000);
}
function getEmptyDataSource() {
startLoading();
setTimeout(() => {
dataSource.value = [];
endLoading();
setEmpty(!dataSource.value.length);
}, 1000);
}
onMounted(() => {
getDataSource();
});
</script>
<style scoped></style>

View File

@@ -1,6 +1,6 @@
<template>
<n-grid :x-gap="16" :y-gap="16" :item-responsive="true" responsive="screen">
<n-grid-item span="s:24 m:8">
<n-grid :x-gap="16" :y-gap="16" :item-responsive="true">
<n-grid-item span="0:24 640:24 1024:8">
<n-card title="时间线" :bordered="false" class="rounded-16px shadow-sm">
<div class="h-360px">
<n-timeline>
@@ -9,7 +9,7 @@
</div>
</n-card>
</n-grid-item>
<n-grid-item span="s:24 m:16">
<n-grid-item span="0:24 640:24 1024:16">
<n-card title="表格" :bordered="false" class="rounded-16px shadow-sm">
<div class="h-360px">
<n-data-table size="small" :columns="columns" :data="tableData" />

View File

@@ -1,6 +1,6 @@
<template>
<n-grid :x-gap="16" :y-gap="16" :item-responsive="true" responsive="screen">
<n-grid-item span="s:24 m:16">
<n-grid :x-gap="16" :y-gap="16" :item-responsive="true">
<n-grid-item span="0:24 640:24 1024:16">
<n-card :bordered="false" class="rounded-16px shadow-sm">
<div class="flex w-full h-360px">
<div class="w-200px h-full py-12px">
@@ -22,7 +22,7 @@
</div>
</n-card>
</n-grid-item>
<n-grid-item span="s:24 m:8">
<n-grid-item span="0:24 640:24 1024:8">
<n-card :bordered="false" class="rounded-16px shadow-sm">
<div ref="pieRef" class="w-full h-360px"></div>
</n-card>

View File

@@ -1,4 +0,0 @@
const DashboardAnalysis = () => import('./analysis/index.vue');
const DashboardWorkbench = () => import('./workbench/index.vue');
export { DashboardAnalysis, DashboardWorkbench };

View File

@@ -1,6 +1,6 @@
<template>
<n-grid :item-responsive="true" responsive="screen" :x-gap="16" :y-gap="16">
<n-grid-item span="s:24 m:16">
<n-grid :item-responsive="true" :x-gap="16" :y-gap="16">
<n-grid-item span="0:24 640:24 1024:16">
<n-space :vertical="true" :size="16">
<n-card title="项目主要技术栈" :bordered="false" size="small" class="shadow-sm rounded-16px">
<template #header-extra>
@@ -29,7 +29,7 @@
</n-card>
</n-space>
</n-grid-item>
<n-grid-item span="s:24 m:8">
<n-grid-item span="0:24 640:24 1024:8">
<n-space :vertical="true" :size="16">
<n-card title="快捷操作" :bordered="false" size="small" class="shadow-sm rounded-16px">
<n-grid :item-responsive="true" responsive="screen" cols="m:2 l:3" :x-gap="8" :y-gap="8">

View File

@@ -1,6 +0,0 @@
const DocumentVue = () => import('./vue/index.vue');
const DocumentVueNew = () => import('./vue-new/index.vue');
const DocumentVite = () => import('./vite/index.vue');
const DocumentNaive = () => import('./naive/index.vue');
export { DocumentVue, DocumentVueNew, DocumentVite, DocumentNaive };

View File

@@ -0,0 +1,8 @@
<template>
<exception-base type="403" />
</template>
<script lang="ts" setup>
import { ExceptionBase } from '../../system-view/components';
</script>
<style scoped></style>

View File

@@ -0,0 +1,8 @@
<template>
<exception-base type="404" />
</template>
<script lang="ts" setup>
import { ExceptionBase } from '../../system-view/components';
</script>
<style scoped></style>

View File

@@ -0,0 +1,8 @@
<template>
<exception-base type="500" />
</template>
<script lang="ts" setup>
import { ExceptionBase } from '../../system-view/components';
</script>
<style scoped></style>

View File

@@ -1,5 +1,31 @@
export * from './system';
export * from './dashboard';
export * from './document';
export * from './about';
export * from './multi-menu';
import type { Component } from 'vue';
type ViewComponent = Record<string, () => Promise<Component>>;
const importViews = import.meta.glob('./**/index.vue');
const COMPONENTS_KEY = 'components';
const PREFIX = './';
const SUFFIX = '/index.vue';
const PATH_SPLIT_MARK = '/';
const ROUTE_KEY_SPLIT_MARK = '_';
/** 系统的内置路由该文件夹名称不作为RouteKey */
const SYSTEM_VIEW = 'system-view_';
/** 过滤掉组件文件 */
const viewKeys = Object.keys(importViews).filter(key => !key.includes(COMPONENTS_KEY));
function getViewComponent() {
const components: ViewComponent = {};
viewKeys.forEach(key => {
const routeKey = key
.replace(PREFIX, '')
.replace(SUFFIX, '')
.replaceAll(PATH_SPLIT_MARK, ROUTE_KEY_SPLIT_MARK)
.replace(SYSTEM_VIEW, '');
components[routeKey] = importViews[key];
});
return components;
}
export const views = getViewComponent();

View File

@@ -1,4 +0,0 @@
const MultiMenuFirstSecond = () => import('./first/second/index.vue');
const MultiMenuFirstSecondNewThird = () => import('./first/second-new/third/index.vue');
export { MultiMenuFirstSecond, MultiMenuFirstSecondNewThird };

View File

@@ -0,0 +1,34 @@
<template>
<div class="h-full">
<n-card title="文本复制" class="h-full shadow-sm rounded-16px">
<n-input-group>
<n-input v-model:value="source" placeholder="请输入要复制的内容吧" />
<n-button type="primary" @click="handleCopy">复制</n-button>
</n-input-group>
</n-card>
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { NCard, NInputGroup, NInput, NButton, useMessage } from 'naive-ui';
import { useClipboard } from '@vueuse/core';
const source = ref('');
const message = useMessage();
const { copy, isSupported } = useClipboard();
function handleCopy() {
if (!isSupported) {
message.error('您的浏览器不支持Clipboard API');
return;
}
if (!source.value) {
message.error('请输入要复制的内容');
return;
}
copy(source.value);
message.success(`复制成功:${source.value}`);
}
</script>
<style scoped></style>

View File

@@ -0,0 +1,50 @@
<template>
<div class="h-full">
<n-card title="markdown插件" class="shadow-sm rounded-16px">
<div ref="domRef"></div>
<template #footer>
<github-link link="https://github.com/Vanessa219/vditor" />
</template>
</n-card>
</div>
</template>
<script setup lang="ts">
import { ref, watch, onMounted, onUnmounted } from 'vue';
import { NCard } from 'naive-ui';
import Vditor from 'vditor';
import 'vditor/src/assets/scss/index.scss';
import { GithubLink } from '@/components';
import { useThemeStore } from '@/store';
const theme = useThemeStore();
const vditor = ref<Vditor>();
const domRef = ref<HTMLElement>();
function renderVditor() {
vditor.value = new Vditor(domRef.value!, {
minHeight: 400,
theme: theme.darkMode ? 'dark' : 'classic',
icon: 'material',
cache: { enable: false }
});
}
const stopHandle = watch(
() => theme.darkMode,
newValue => {
const themeMode = newValue ? 'dark' : 'classic';
vditor.value?.setTheme(themeMode);
}
);
onMounted(() => {
renderVditor();
});
onUnmounted(() => {
stopHandle();
});
</script>
<style scoped></style>

View File

@@ -0,0 +1,44 @@
<template>
<div class="h-full">
<n-card title="富文本插件" class="shadow-sm rounded-16px">
<div ref="domRef" class="bg-white dark:bg-dark"></div>
<template #footer>
<github-link link="https://github.com/wangeditor-team/wangEditor" />
</template>
</n-card>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { NCard } from 'naive-ui';
import WangEditor from 'wangeditor';
import { GithubLink } from '@/components';
const editor = ref<WangEditor>();
const domRef = ref<HTMLElement>();
function renderWangEditor() {
editor.value = new WangEditor(domRef.value);
setEditorConfig();
editor.value.create();
}
function setEditorConfig() {
editor.value!.config.zIndex = 10;
}
onMounted(() => {
renderWangEditor();
});
</script>
<style scoped>
:deep(.w-e-toolbar) {
background: inherit !important;
border-color: #999 !important;
}
:deep(.w-e-text-container) {
background: inherit;
border-color: #999 !important;
}
</style>

View File

@@ -0,0 +1,32 @@
export const icons = [
'mdi:emoticon',
'mdi:ab-testing',
'ph:alarm',
'ph:android-logo',
'ph:align-bottom',
'ph:archive-box-light',
'uil:basketball',
'uil:brightness-plus',
'uil:capture',
'mdi:apps-box',
'mdi:alert',
'mdi:airballoon',
'mdi:airplane-edit',
'mdi:alpha-f-box-outline',
'mdi:arm-flex-outline',
'ic:baseline-10mp',
'ic:baseline-access-time',
'ic:baseline-brightness-4',
'ic:baseline-brightness-5',
'ic:baseline-credit-card',
'ic:baseline-filter-1',
'ic:baseline-filter-2',
'ic:baseline-filter-3',
'ic:baseline-filter-4',
'ic:baseline-filter-5',
'ic:baseline-filter-6',
'ic:baseline-filter-7',
'ic:baseline-filter-8',
'ic:baseline-filter-9',
'ic:baseline-filter-9-plus'
];

View File

@@ -0,0 +1,31 @@
<template>
<div class="h-full">
<n-card title="Icon组件示例" class="shadow-sm rounded-16px">
<div class="grid grid-cols-10">
<template v-for="item in icons" :key="item">
<div class="mt-5px flex-x-center">
<Icon :icon="item" class="text-30px" />
</div>
</template>
</div>
<div class="mt-50px">
<h1 class="mb-20px text-18px font-500">Icon图标选择器</h1>
<icon-select v-model:value="selectVal" :icons="icons" />
</div>
<template #footer>
<web-site-link label="iconify地址" link="https://icones.js.org/" class="mt-10px" />
</template>
</n-card>
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { NCard } from 'naive-ui';
import { Icon } from '@iconify/vue';
import { IconSelect, WebSiteLink } from '@/components';
import { icons } from './icons';
const selectVal = ref('');
</script>
<style scoped></style>

View File

@@ -0,0 +1,26 @@
<template>
<div ref="domRef" class="w-full h-full"></div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useScriptTag } from '@vueuse/core';
import { BAIDU_MAP_SDK_URL } from '@/config';
const { load } = useScriptTag(BAIDU_MAP_SDK_URL);
const domRef = ref<HTMLDivElement>();
async function renderBaiduMap() {
await load(true);
const map = new BMap.Map(domRef.value!);
const point = new BMap.Point(114.05834626586915, 22.546789983033168);
map.centerAndZoom(point, 15);
map.enableScrollWheelZoom();
}
onMounted(() => {
renderBaiduMap();
});
</script>
<style scoped></style>

View File

@@ -0,0 +1,29 @@
<template>
<div ref="domRef" class="w-full h-full"></div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useScriptTag } from '@vueuse/core';
import { GAODE_MAP_SDK_URL } from '@/config';
const { load } = useScriptTag(GAODE_MAP_SDK_URL);
const domRef = ref<HTMLDivElement>();
async function renderBaiduMap() {
await load(true);
const map = new AMap.Map(domRef.value!, {
zoom: 11,
center: [114.05834626586915, 22.546789983033168],
viewMode: '3D'
});
return map;
}
onMounted(() => {
renderBaiduMap();
});
</script>
<style scoped></style>

View File

@@ -0,0 +1,29 @@
<template>
<div ref="domRef"></div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useScriptTag } from '@vueuse/core';
import { TENCENT_MAP_SDK_URL } from '@/config';
const { load } = useScriptTag(TENCENT_MAP_SDK_URL);
const domRef = ref<HTMLDivElement | null>(null);
async function renderBaiduMap() {
await load(true);
const map = new TMap.Map(domRef.value!, {
center: new TMap.LatLng(39.98412, 116.307484),
zoom: 11,
viewMode: '3D'
});
return map;
}
onMounted(() => {
renderBaiduMap();
});
</script>
<style scoped></style>

View File

@@ -0,0 +1,5 @@
import BaiduMap from './BaiduMap.vue';
import GaodeMap from './GaodeMap.vue';
import TencentMap from './TencentMap.vue';
export { BaiduMap, GaodeMap, TencentMap };

View File

@@ -0,0 +1,29 @@
<template>
<div class="h-full">
<n-card title="地图插件" class="h-full shadow-sm rounded-16px" content-style="overflow:hidden">
<n-tabs type="line" class="flex-col-stretch h-full" pane-class="flex-1-hidden">
<n-tab-pane v-for="item in maps" :key="item.id" :name="item.id" :tab="item.label">
<component :is="item.component" />
</n-tab-pane>
</n-tabs>
</n-card>
</div>
</template>
<script setup lang="ts">
import type { Component } from 'vue';
import { NCard, NTabs, NTabPane } from 'naive-ui';
import { GaodeMap, TencentMap } from './components';
interface Map {
id: string;
label: string;
component: Component;
}
const maps: Map[] = [
{ id: 'gaode', label: '高德地图', component: GaodeMap },
{ id: 'tencent', label: '腾讯地图', component: TencentMap }
];
</script>
<style scoped></style>

View File

@@ -0,0 +1,40 @@
<template>
<div class="h-full">
<n-card title="打印" class="shadow-sm rounded-16px">
<n-button type="primary" class="mr-10px" @click="printTable">打印表格</n-button>
<n-button type="primary" @click="printImage">打印图片</n-button>
<template #footer>
<github-link label="printJS" link="https://github.com/crabbly/Print.js" class="mt-10px" />
</template>
</n-card>
</div>
</template>
<script lang="ts" setup>
import { NCard, NButton } from 'naive-ui';
import printJS from 'print-js';
import { GithubLink } from '@/components';
function printTable() {
printJS({
printable: [
{ name: 'soybean', wechat: 'honghuangdc', remark: '欢迎来技术交流' },
{ name: 'soybean', wechat: 'honghuangdc', remark: '欢迎来技术交流' }
],
properties: ['name', 'wechat', 'remark'],
type: 'json'
});
}
function printImage() {
printJS({
printable: [
'https://raw.githubusercontent.com/honghuangdc/project-assets/main/img/qq_qrcode.JPG',
'https://raw.githubusercontent.com/honghuangdc/project-assets/main/img/qq_qrcode.JPG'
],
type: 'image',
header: 'Multiple Images',
imageStyle: 'width:100%;'
});
}
</script>
<style scoped></style>

View File

@@ -0,0 +1,118 @@
<template>
<div>
<n-card title="Swiper插件" class="shadow-sm rounded-16px">
<n-space :vertical="true">
<github-link link="https://github.com/nolimits4web/swiper" />
<web-site-link label="vue3版文档地址" link="https://swiperjs.com/vue" />
<web-site-link label="插件demo地址" link="https://swiperjs.com/demos" />
</n-space>
<n-space :vertical="true">
<div v-for="item in swiperExample" :key="item.id">
<h3 class="py-24px text-24px font-bold">{{ item.label }}</h3>
<swiper v-bind="item.options">
<swiper-slide v-for="i in 5" :key="i">
<div class="flex-center h-240px border-1px border-[#999] text-18px font-bold">Slide{{ i }}</div>
</swiper-slide>
</swiper>
</div>
</n-space>
</n-card>
</div>
</template>
<script setup lang="ts">
import { NCard, NSpace } from 'naive-ui';
import SwiperCore, { Navigation, Pagination } from 'swiper';
import { Swiper, SwiperSlide } from 'swiper/vue';
import type { SwiperOptions } from 'swiper';
import { WebSiteLink, GithubLink } from '@/components';
type SwiperExampleOptions = Pick<
SwiperOptions,
| 'navigation'
| 'pagination'
| 'scrollbar'
| 'slidesPerView'
| 'slidesPerGroup'
| 'spaceBetween'
| 'direction'
| 'loop'
| 'loopFillGroupWithBlank'
>;
interface SwiperExample {
id: number;
label: string;
options: Partial<SwiperExampleOptions>;
}
SwiperCore.use([Navigation, Pagination]);
const swiperExample: SwiperExample[] = [
{ id: 0, label: 'Default', options: {} },
{
id: 1,
label: 'Navigation',
options: {
navigation: true
}
},
{
id: 2,
label: 'Pagination',
options: {
pagination: true
}
},
{
id: 3,
label: 'Pagination dynamic',
options: {
pagination: { dynamicBullets: true }
}
},
{
id: 4,
label: 'Pagination progress',
options: {
navigation: true,
pagination: {
type: 'progressbar'
}
}
},
{
id: 5,
label: 'Pagination fraction',
options: {
navigation: true,
pagination: {
type: 'fraction'
}
}
},
{
id: 6,
label: 'Slides per view',
options: {
pagination: {
clickable: true
},
slidesPerView: 3,
spaceBetween: 30
}
},
{
id: 7,
label: 'Infinite loop',
options: {
navigation: true,
pagination: {
clickable: true
},
loop: true
}
}
];
</script>
<style scoped></style>

View File

@@ -0,0 +1,37 @@
<template>
<div class="h-full">
<n-card title="视频播放器插件" class="h-full shadow-sm rounded-16px">
<div ref="domRef"></div>
</n-card>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue';
import { NCard } from 'naive-ui';
import Player from 'xgplayer';
const domRef = ref<HTMLElement>();
const player = ref<Player>();
function renderXgPlayer() {
const url = 'https://lf9-cdn-tos.bytecdntp.com/cdn/expire-1-M/byted-player-videos/1.0.0/xgplayer-demo.mp4';
player.value = new Player({
el: domRef.value!,
url,
playbackRate: [0.5, 0.75, 1, 1.5, 2]
});
}
function destroyXgPlayer() {
player.value?.destroy();
}
onMounted(() => {
renderXgPlayer();
});
onUnmounted(() => {
destroyXgPlayer();
});
</script>
<style scoped></style>

View File

@@ -0,0 +1,8 @@
<template>
<exception-base type="404" />
</template>
<script lang="ts" setup>
import { ExceptionBase } from '../components';
</script>
<style scoped></style>

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