Compare commits

..

10 Commits

Author SHA1 Message Date
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
Soybean
db75c91400 chore(release): 0.1.2 2022-01-22 01:53:21 +08:00
Soybean
37092974d3 feat(projects): 添加缓存主题色 2022-01-22 01:46:56 +08:00
Soybean
1d63a83822 feat(projects): 添加页面缓存、记录在tab中的缓存页面的滚动条位置 2022-01-22 00:01:34 +08:00
91 changed files with 2770 additions and 458 deletions

View File

@@ -1,12 +1,13 @@
/** 请求环境配置 */
type ServiceEnv = {
[key in Service.HttpEnv]: {
type ServiceEnv = Record<
Service.HttpEnv,
{
/** 请求环境 */
env: Service.HttpEnv;
/** 请求地址 */
url: string;
};
};
}
>;
/** 请求的环境 */
export const serviceEnv: ServiceEnv = {

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,29 @@
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.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)
### Features
* **projects:** 添加缓存主题色 ([3709297](https://github.com/honghuangdc/soybean-admin/commit/37092974d37b2e661d4cbf9d27c89b5e99119cd7))
* **projects:** 添加页面缓存、记录在tab中的缓存页面的滚动条位置 ([1d63a83](https://github.com/honghuangdc/soybean-admin/commit/1d63a838226df4f48e7f2a15b5a05d4b496d3c69))
### [0.1.1](https://github.com/honghuangdc/soybean-admin/compare/v0.0.5...v0.1.1) (2022-01-20)

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两种请求函数
## 预览
@@ -32,15 +33,15 @@ Soybean Admin Thin 是Soybean Admin的精简版。
- [gitee](https://gitee.com/honghuangdc/soybean-admin)
## 项目示例图
![](https://i.loli.net/2021/11/24/pIhTKP7fdCqbVHl.png)
![](https://s2.loli.net/2022/01/24/ovK6Oyqr7gIMu2n.png)
![](https://i.loli.net/2021/11/24/gxRwsLnKi6IVp7C.png)
![](https://s2.loli.net/2022/01/24/O8loxYhMySHwGfJ.png)
![](https://i.loli.net/2021/11/24/UmVfjSJbxH6iYc2.png)
![](https://s2.loli.net/2022/01/24/HKwpJ7Ab6j8fVvk.png)
![](https://i.loli.net/2021/11/24/Uot1bcfGXiF726T.png)
![](https://s2.loli.net/2022/01/24/bqJRSDZHBv3jsif.png)
![](https://i.loli.net/2021/11/24/WzOIvlgJZaUtGm7.png)
![](https://s2.loli.net/2022/01/24/wXpHeau6UrSTWdF.png)
### 使用 Gitpod
@@ -108,7 +109,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/01/24/uX8KaGt7W2jbw6V.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

@@ -11,7 +11,8 @@ const routes: AuthRoute.Route[] = [
path: '/dashboard/analysis',
component: 'self',
meta: {
title: '分析页'
title: '分析页',
requiresAuth: true
}
},
{
@@ -20,13 +21,13 @@ const routes: AuthRoute.Route[] = [
component: 'self',
meta: {
title: '工作台',
requiresAuth: true,
permissions: ['super', 'admin']
}
}
],
meta: {
title: '仪表盘',
requiresAuth: true,
icon: 'carbon:dashboard',
order: 1
}
@@ -41,7 +42,8 @@ const routes: AuthRoute.Route[] = [
path: '/document/vue',
component: 'self',
meta: {
title: 'vue文档'
title: 'vue文档',
requiresAuth: true
}
},
{
@@ -49,7 +51,8 @@ const routes: AuthRoute.Route[] = [
path: '/document/vue-new',
component: 'self',
meta: {
title: 'vue文档(新版)'
title: 'vue文档(新版)',
requiresAuth: true
}
},
{
@@ -57,7 +60,8 @@ const routes: AuthRoute.Route[] = [
path: '/document/vite',
component: 'self',
meta: {
title: 'vite文档'
title: 'vite文档',
requiresAuth: true
}
},
{
@@ -65,7 +69,8 @@ const routes: AuthRoute.Route[] = [
path: '/document/naive',
component: 'self',
meta: {
title: 'naive文档'
title: 'naive文档',
requiresAuth: true
}
},
{
@@ -73,6 +78,7 @@ const routes: AuthRoute.Route[] = [
path: '/document/project',
meta: {
title: '项目文档(外链)',
requiresAuth: true,
href: 'https://docs.soybean.pro/'
}
}
@@ -84,15 +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: '关于',
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
}
},
{
@@ -105,7 +231,8 @@ const routes: AuthRoute.Route[] = [
path: '/exception/403',
component: 'self',
meta: {
title: '异常页403'
title: '异常页403',
requiresAuth: true
}
},
{
@@ -113,7 +240,8 @@ const routes: AuthRoute.Route[] = [
path: '/exception/404',
component: 'self',
meta: {
title: '异常页404'
title: '异常页404',
requiresAuth: true
}
},
{
@@ -121,7 +249,8 @@ const routes: AuthRoute.Route[] = [
path: '/exception/500',
component: 'self',
meta: {
title: '异常页500'
title: '异常页500',
requiresAuth: true
}
}
],
@@ -146,7 +275,8 @@ const routes: AuthRoute.Route[] = [
path: '/multi-menu/first/second',
component: 'self',
meta: {
title: '二级菜单'
title: '二级菜单',
requiresAuth: true
}
},
{
@@ -159,7 +289,8 @@ const routes: AuthRoute.Route[] = [
path: '/multi-menu/first/second-new/third',
component: 'self',
meta: {
title: '三级菜单'
title: '三级菜单',
requiresAuth: true
}
}
],
@@ -178,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
}
}
];
@@ -198,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.1",
"name": "soybean-admin",
"version": "0.9.1",
"scripts": {
"dev": "cross-env VITE_HTTP_ENV=test vite",
"dev:prod": "cross-env VITE_HTTP_ENV=prod vite",
@@ -26,7 +26,7 @@
"dependencies": {
"@antv/g2plot": "^2.4.7",
"@better-scroll/core": "^2.4.2",
"@vueuse/core": "^7.5.3",
"@vueuse/core": "^7.5.4",
"axios": "^0.25.0",
"clipboard": "^2.0.8",
"colord": "^2.9.2",
@@ -36,21 +36,28 @@
"lodash-es": "^4.17.21",
"naive-ui": "^2.24.1",
"pinia": "^2.0.9",
"print-js": "^1.6.0",
"qs": "^6.10.3",
"swiper": "^7.4.1",
"vditor": "^3.8.11",
"vue": "^3.2.26",
"vue-router": "^4.0.12"
"vue-router": "^4.0.12",
"wangeditor": "^4.7.11",
"xgplayer": "^2.31.4"
},
"devDependencies": {
"@commitlint/cli": "^16.0.3",
"@amap/amap-jsapi-types": "^0.0.8",
"@commitlint/cli": "^16.1.0",
"@commitlint/config-conventional": "^16.0.0",
"@iconify/json": "^1.1.458",
"@iconify/json": "^1.1.459",
"@iconify/vue": "^3.1.2",
"@types/bmapgl": "^0.0.5",
"@types/crypto-js": "^4.1.0",
"@types/node": "^17.0.10",
"@types/qs": "^6.9.7",
"@typescript-eslint/eslint-plugin": "^5.10.0",
"@typescript-eslint/parser": "^5.10.0",
"@vitejs/plugin-vue": "^2.0.1",
"@vitejs/plugin-vue": "^2.1.0",
"@vue/eslint-config-prettier": "^7.0.0",
"@vue/eslint-config-typescript": "^10.0.0",
"commitizen": "^4.2.4",
@@ -64,21 +71,21 @@
"eslint-plugin-prettier": "^4.0.0",
"eslint-plugin-vue": "^8.3.0",
"husky": "^7.0.4",
"lint-staged": "^12.2.1",
"lint-staged": "^12.3.1",
"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",
"typescript": "^4.5.4",
"typescript": "^4.5.5",
"unplugin-icons": "^0.13.0",
"unplugin-vue-components": "^0.17.13",
"unplugin-vue-components": "^0.17.14",
"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",
"vue-tsc": "^0.31.1",
"vueuc": "^0.4.23",
"windicss": "^3.4.3"
}

733
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -34,8 +34,8 @@ function initSvgLogo(id) {
function addThemeColorCssVars() {
const key = '__THEME_COLOR__';
const themeColor = '#1890ff';
const cssVars = window.localStorage.getItem(key) || `--primary-color: ${themeColor}`;
const themeColor = window.localStorage.getItem(key) || '#1890ff';
const cssVars = `--primary-color: ${themeColor}`;
document.documentElement.style.cssText = cssVars;
}

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

@@ -51,7 +51,7 @@ const { bool: isHover, setTrue, setFalse } = useBoolean();
const isIconActive = computed(() => props.isActive || isHover.value);
const buttonStyle = computed(() => {
const style: { [key: string]: string } = {};
const style: Record<string, string> = {};
if (isIconActive.value) {
style.color = props.primaryColor;
style.borderColor = addColorAlpha(props.primaryColor, 0.3);

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

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

@@ -1,3 +1,9 @@
/** 布局组件的名称 */
export enum EnumLayoutComponentName {
basic = 'basic-layout',
blank = 'blank-layout'
}
/** 登录模块 */
export enum EnumLoginModule {
'pwd-login' = '账密登录',

View File

@@ -1,4 +1,5 @@
import {
EnumLayoutComponentName,
EnumThemeLayoutMode,
EnumThemeTabMode,
EnumThemeHorizontalMenuPosition,
@@ -6,6 +7,9 @@ import {
EnumLoginModule
} from '@/enum';
/** 布局组件名称 */
export type LayoutComponentName = keyof typeof EnumLayoutComponentName;
/** 布局模式 */
export type ThemeLayoutMode = keyof typeof EnumThemeLayoutMode;

View File

@@ -23,4 +23,10 @@ export type GlobalBreadcrumb = DropdownOption & {
};
/** 多页签Tab的路由 */
export type GlobalTabRoute = Pick<RouteLocationNormalizedLoaded, 'name' | 'path' | 'meta'>;
export interface GlobalTabRoute extends Pick<RouteLocationNormalizedLoaded, 'name' | 'path' | 'meta'> {
/** 滚动的位置 */
scrollPosition: {
left: number;
top: number;
};
}

View File

@@ -13,6 +13,8 @@ export interface ThemeSetting {
themeColorList: string[];
/** 其他颜色 */
otherColor: ThemeOtherColor;
/** 是否自定义info的颜色(默认取比主题色深一级的颜色) */
isCustomizeInfoColor: boolean;
/** 固定头部和多页签 */
fixedHeaderAndTab: boolean;
/** 显示重载按钮 */

View File

@@ -1,6 +1,7 @@
<template>
<soybean-layout
:mode="mode"
:min-width="theme.layout.minWidth"
:fixed-header-and-tab="theme.fixedHeaderAndTab"
:header-height="theme.header.height"
:tab-visible="theme.tab.visible"

View File

@@ -3,16 +3,18 @@
:class="{ 'p-16px': showPadding }"
class="h-full bg-[#f6f9f8] dark:bg-[#101014] transition duration-300 ease-in-out"
>
<router-view v-slot="{ Component }">
<router-view v-slot="{ Component, route }">
<transition name="fade-slide" mode="out-in" appear>
<component :is="Component" v-if="app.reloadFlag" />
<keep-alive :include="routeStore.cacheRoutes">
<component :is="Component" v-if="app.reloadFlag" :key="route.path" />
</keep-alive>
</transition>
</router-view>
</div>
</template>
<script setup lang="ts">
import { useAppStore } from '@/store';
import { useAppStore, useRouteStore } from '@/store';
interface Props {
/** 显示padding */
@@ -24,5 +26,6 @@ withDefaults(defineProps<Props>(), {
});
const app = useAppStore();
const routeStore = useRouteStore();
</script>
<style scoped></style>

View File

@@ -33,13 +33,14 @@ interface Props {
const props = defineProps<Props>();
type LayoutConfig = {
[key in ThemeLayoutMode]: {
type LayoutConfig = Record<
ThemeLayoutMode,
{
placement: FollowerPlacement;
menuClass: string;
mainClass: string;
};
};
}
>;
const layoutConfig: LayoutConfig = {
vertical: {

View File

@@ -5,13 +5,17 @@
<color-checkbox :color="color" :checked="color === theme.themeColor" @click="theme.setThemeColor(color)" />
</n-grid-item>
</n-grid>
<n-button :block="true" :type="otherColorBtnType" class="mt-12px" @click="openModal">更多颜色</n-button>
<n-space :vertical="true" class="pt-12px">
<n-color-picker :value="theme.themeColor" :show-alpha="false" @update-value="theme.setThemeColor" />
<n-button :block="true" :type="otherColorBtnType" @click="openModal">更多颜色</n-button>
</n-space>
<color-modal :visible="visible" @close="closeModal" />
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { NDivider, NGrid, NGridItem, NButton } from 'naive-ui';
import { NDivider, NGrid, NGridItem, NSpace, NButton, NColorPicker } from 'naive-ui';
import { isInTraditionColors } from '@/settings';
import { useThemeStore } from '@/store';
import { useBoolean } from '@/hooks';
import { ColorCheckbox, ColorModal } from './components';
@@ -20,7 +24,7 @@ const theme = useThemeStore();
const { bool: visible, setTrue: openModal, setFalse: closeModal } = useBoolean();
const isInOther = computed(() => !theme.themeColorList.includes(theme.themeColor));
const isInOther = computed(() => isInTraditionColors(theme.themeColor));
const otherColorBtnType = computed(() => (isInOther.value ? 'primary' : 'default'));
</script>
<style scoped></style>

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

@@ -36,6 +36,7 @@ const style = computed(() => {
<style scoped>
.soybean-layout__main {
flex-grow: 1;
width: 100%;
transition-property: padding-left;
}
</style>

View File

@@ -17,6 +17,7 @@
v-bind="commonProps"
:fixed="fixedHeaderAndTab"
:z-index="tabZIndex"
:min-width="minWidth"
:top="headerHeight"
:height="tabHeight"
:padding-left="siderWidth"
@@ -46,6 +47,7 @@
v-bind="commonProps"
:fixed="fixedFooter"
:z-index="footerZIndex"
:min-width="minWidth"
:height="footerHeight"
:padding-left="siderWidth"
:style="footerTransform"

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

@@ -0,0 +1 @@
export * from './scroll';

View File

@@ -0,0 +1,33 @@
import type { RouterScrollBehavior } from 'vue-router';
import { useTabStore } from '@/store';
export const scrollBehavior: RouterScrollBehavior = (to, from) => {
return new Promise(resolve => {
const tab = useTabStore();
if (to.hash) {
resolve({
el: to.hash,
behavior: 'smooth'
});
}
const { left, top } = tab.getTabScrollPosition(to.path);
const scrollPosition = {
left,
top
};
const { scrollLeft, scrollTop } = document.documentElement;
const isFromCached = Boolean(from.meta.keepAlive);
if (isFromCached) {
tab.recordTabScrollPosition(from.path, { left: scrollLeft, top: scrollTop });
}
const duration = !scrollPosition.left && !scrollPosition.top ? 0 : 350;
setTimeout(() => {
resolve(scrollPosition);
}, duration);
});
};

View File

@@ -2,6 +2,7 @@ import type { App } from 'vue';
import { createRouter, createWebHistory, createWebHashHistory } from 'vue-router';
import { transformAuthRoutesToVueRoutes } from '@/utils';
import { constantRoutes } from './routes';
import { scrollBehavior } from './helpers';
import { createRouterGuard } from './guard';
const createHistoryFunc = import.meta.env.VITE_IS_VERCEL === '1' ? createWebHashHistory : createWebHistory;
@@ -9,7 +10,7 @@ const createHistoryFunc = import.meta.env.VITE_IS_VERCEL === '1' ? createWebHash
export const router = createRouter({
history: createHistoryFunc(import.meta.env.BASE_URL),
routes: transformAuthRoutesToVueRoutes(constantRoutes),
scrollBehavior: () => ({ left: 0, top: 0 })
scrollBehavior
});
export async function setupRouter(app: App) {

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

@@ -11,3 +11,10 @@ interface TraditionColor {
/** 中国传统颜色 */
export const traditionColors = colorJson as TraditionColor[];
export function isInTraditionColors(color: string) {
return traditionColors.some(item => {
const flag = item.data.some(v => v.color === color);
return flag;
});
}

View File

@@ -3,28 +3,29 @@ import type { ThemeSetting } from '@/interface';
const themeColorList = [
'#1890ff',
'#007AFF',
'#2d8cf0',
'#409EFF',
'#536dfe',
'#2d8cf0',
'#007AFF',
'#5ac8fa',
'#5856D6',
'#536dfe',
'#9c27b0',
'#AF52DE',
'#0096c7',
'#00C1D4',
'#009688',
'#5AC8FA',
'#34C759',
'#71EFA3',
'#43a047',
'#7cb342',
'#c0ca33',
'#78DEC7',
'#FC5404',
'#ee4f12',
'#FF9500',
'#fadb14',
'#FFCC00',
'#FF3B30',
'#FF2D55',
'#ff5c93',
'#9c27b0',
'#AF52DE'
'#e53935',
'#d81b60',
'#f4511e',
'#fb8c00',
'#ffb300',
'#fdd835',
'#6d4c41',
'#546e7a'
];
const defaultThemeSetting: ThemeSetting = {
@@ -47,6 +48,7 @@ const defaultThemeSetting: ThemeSetting = {
warning: '#faad14',
error: '#f5222d'
},
isCustomizeInfoColor: false,
fixedHeaderAndTab: true,
showReload: true,
header: {

View File

@@ -34,6 +34,9 @@ export const useAppStore = defineStore('app-store', {
} else {
this.reloadFlag = true;
}
setTimeout(() => {
document.documentElement.scrollTo({ left: 0, top: 0 });
}, 100);
},
/** 打开设置抽屉 */
openSettingDrawer() {

View File

@@ -1,7 +1,7 @@
import type { Router } from 'vue-router';
import { defineStore } from 'pinia';
import { fetchUserRoutes } from '@/service';
import { transformAuthRouteToMenu, transformAuthRoutesToVueRoutes } from '@/utils';
import { getUserInfo, transformAuthRouteToMenu, transformAuthRoutesToVueRoutes, getCacheRoutes } from '@/utils';
import type { GlobalMenuOption } from '@/interface';
import { useTabStore } from '../tab';
@@ -12,13 +12,16 @@ interface RouteState {
routeHomeName: AuthRoute.RouteKey;
/** 菜单 */
menus: GlobalMenuOption[];
/** 缓存的路由名称 */
cacheRoutes: string[];
}
export const useRouteStore = defineStore('route-store', {
state: (): RouteState => ({
isAddedDynamicRoute: false,
routeHomeName: 'dashboard_analysis',
menus: []
menus: [],
cacheRoutes: []
}),
actions: {
/**
@@ -28,7 +31,9 @@ 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);
@@ -38,6 +43,8 @@ export const useRouteStore = defineStore('route-store', {
router.addRoute(route);
});
this.cacheRoutes = getCacheRoutes(vueRoutes);
initHomeTab(data.home, router);
this.isAddedDynamicRoute = true;
}

View File

@@ -9,7 +9,11 @@ export function getTabRouteByVueRoute(route: RouteRecordNormalized | RouteLocati
const tabRoute: GlobalTabRoute = {
name: route.name,
path: route.path,
meta: route.meta
meta: route.meta,
scrollPosition: {
left: 0,
top: 0
}
};
return tabRoute;
}

View File

@@ -23,6 +23,10 @@ export const useTabStore = defineStore('tab-store', {
path: '/',
meta: {
title: 'root'
},
scrollPosition: {
left: 0,
top: 0
}
},
activeTab: ''
@@ -132,6 +136,32 @@ export const useTabStore = defineStore('tab-store', {
routerPush(path);
}
},
/**
* 记录tab滚动位置
* @param path - 路由path
* @param position - tab当前页的滚动位置
*/
recordTabScrollPosition(path: string, position: { left: number; top: number }) {
const index = getIndexInTabRoutes(this.tabs, path);
if (index > -1) {
this.tabs[index].scrollPosition = position;
}
},
/**
* 获取tab滚动位置
* @param path - 路由path
*/
getTabScrollPosition(path: string) {
const position = {
left: 0,
top: 0
};
const index = getIndexInTabRoutes(this.tabs, path);
if (index > -1) {
Object.assign(position, this.tabs[index].scrollPosition);
}
return position;
},
/** 初始化Tab状态 */
iniTabStore(currentRoute: RouteLocationNormalizedLoaded) {
const theme = useThemeStore();

View File

@@ -1,6 +1,16 @@
import type { GlobalThemeOverrides } from 'naive-ui';
import { kebabCase } from 'lodash-es';
import { getColorPalette, addColorAlpha } from '@/utils';
import { cloneDeep, kebabCase } from 'lodash-es';
import { themeSetting } from '@/settings';
import { getThemeColor, getColorPalette, addColorAlpha } from '@/utils';
/** 获取主题配置 */
export function getThemeSettings() {
const themeColor = getThemeColor() || themeSetting.themeColor;
const info = themeSetting.isCustomizeInfoColor ? themeSetting.otherColor.info : getColorPalette(themeColor, 7);
const otherColor = { ...themeSetting.otherColor, info };
const setting = cloneDeep({ ...themeSetting, themeColor, otherColor });
return setting;
}
type ColorType = 'primary' | 'info' | 'success' | 'warning' | 'error';
type ColorScene = '' | 'Suppl' | 'Hover' | 'Pressed' | 'Active';
@@ -37,8 +47,11 @@ function getThemeColors(colors: [ColorType, string][]) {
}
/** 获取naive的主题颜色 */
export function getNaiveThemeOverrides(colors: { [key in ColorType]: string }): GlobalThemeOverrides {
const { primary, info, success, warning, error } = colors;
export function getNaiveThemeOverrides(colors: Record<ColorType, string>): GlobalThemeOverrides {
const { primary, success, warning, error } = colors;
const info = themeSetting.isCustomizeInfoColor ? colors.info : getColorPalette(primary, 7);
const themeColors = getThemeColors([
['primary', primary],
['info', info],
@@ -70,7 +83,7 @@ export function addThemeCssVarsToHtml(themeVars: ThemeVars) {
style.push(`--${kebabCase(key)}: ${themeVars[key]}`);
});
const styleStr = style.join(';');
document.documentElement.style.cssText = styleStr;
document.documentElement.style.cssText += styleStr;
}
/** windicss 暗黑模式 */

View File

@@ -1,7 +1,5 @@
import { defineStore } from 'pinia';
import { darkTheme } from 'naive-ui';
import { cloneDeep } from 'lodash-es';
import { themeSetting } from '@/settings';
import type {
ThemeSetting,
ThemeLayoutMode,
@@ -9,12 +7,12 @@ import type {
ThemeHorizontalMenuPosition,
ThemeAnimateMode
} from '@/interface';
import { getNaiveThemeOverrides, addThemeCssVarsToHtml } from './helpers';
import { getThemeSettings, getNaiveThemeOverrides, addThemeCssVarsToHtml } from './helpers';
type ThemeState = ThemeSetting;
export const useThemeStore = defineStore('theme-store', {
state: (): ThemeState => cloneDeep(themeSetting),
state: (): ThemeState => getThemeSettings(),
getters: {
/** naiveUI的主题配置 */
naiveThemeOverrides(state) {

View File

@@ -1,7 +1,7 @@
import { watch, onUnmounted } from 'vue';
import { useOsTheme } from 'naive-ui';
import { useElementSize } from '@vueuse/core';
import { EnumStorageKey } from '@/enum';
import { setThemeColor } from '@/utils';
import { useThemeStore } from '../modules';
/** 订阅theme store */
@@ -14,7 +14,7 @@ export default function subscribeThemeStore() {
const stopThemeColor = watch(
() => theme.themeColor,
newValue => {
window.localStorage.setItem(EnumStorageKey['theme-color'], `--primary-color: ${newValue};`);
setThemeColor(newValue);
},
{ immediate: true }
);

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,14 +116,11 @@ 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>;
@@ -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

@@ -136,3 +136,11 @@ export function addColorAlpha(color: string, alpha: number) {
export function mixColor(firstColor: string, secondColor: string, ratio: number) {
return colord(firstColor).mix(secondColor, ratio).toHex();
}
/**
* 是否是白颜色
* @param color - 颜色
*/
export function isWhiteColor(color: string) {
return colord(color).isEqual('#ffffff');
}

View File

@@ -5,3 +5,4 @@ export * from './number';
export * from './object';
export * from './icon';
export * from './design-pattern';
export * from './theme';

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,4 +1,4 @@
/** 设置对象数据 */
export function objectAssign<T extends { [key: string]: any }>(target: T, source: Partial<T>) {
export function objectAssign<T extends Record<string, any>>(target: T, source: Partial<T>) {
Object.assign(target, source);
}

16
src/utils/common/theme.ts Normal file
View File

@@ -0,0 +1,16 @@
import { EnumStorageKey } from '@/enum';
/**
* 缓存主题颜色
* @param color
*/
export function setThemeColor(color: string) {
window.localStorage.setItem(EnumStorageKey['theme-color'], color);
}
/**
* 获取缓存的主题颜色
*/
export function getThemeColor() {
return window.localStorage.getItem(EnumStorageKey['theme-color']);
}

35
src/utils/router/cache.ts Normal file
View File

@@ -0,0 +1,35 @@
import type { RouteRecordRaw } from 'vue-router';
/**
* 获取缓存的路由对应组件的名称
* @param routes - 转换后的vue路由
*/
export function getCacheRoutes(routes: RouteRecordRaw[]) {
const cacheNames: string[] = [];
routes.forEach(route => {
// 只需要获取二级路由的缓存的组件名
if (hasChildren(route)) {
route.children!.forEach(item => {
if (isKeepAlive(item)) {
cacheNames.push(item.name as string);
}
});
}
});
return cacheNames;
}
/**
* 路由是否缓存
* @param route
*/
function isKeepAlive(route: RouteRecordRaw) {
return Boolean(route?.meta?.keepAlive);
}
/**
* 是否有二级路由
* @param route
*/
function hasChildren(route: RouteRecordRaw) {
return Boolean(route.children && route.children.length);
}

View File

@@ -1,4 +1,6 @@
import type { Component } from 'vue';
import { EnumLayoutComponentName } from '@/enum';
import { BasicLayout, BlankLayout } from '@/layouts';
import {
Login,
NoPermission,
@@ -10,10 +12,36 @@ import {
DocumentVueNew,
DocumentVite,
DocumentNaive,
About,
ComponentButton,
ComponentCard,
ComponentTable,
PluginMap,
PluginVideo,
PluginEditorQuill,
PluginEditorMarkdown,
PluginSwiper,
PluginCopy,
PluginIcon,
PluginPrint,
MultiMenuFirstSecond,
MultiMenuFirstSecondNewThird
MultiMenuFirstSecondNewThird,
About
} from '@/views';
import type { LayoutComponentName } from '@/interface';
type LayoutComponent = Record<LayoutComponentName, () => Promise<Component>>;
/**
* 获取页面导入的vue文件(懒加载的方式)
* @param layoutType - 布局类型
*/
export function getLayoutComponent(layoutType: LayoutComponentName) {
const layoutComponent: LayoutComponent = {
basic: BasicLayout,
blank: BlankLayout
};
return () => setViewComponentName(layoutComponent[layoutType], EnumLayoutComponentName[layoutType]);
}
/** 需要用到自身vue组件的页面 */
type ViewComponentKey = Exclude<
@@ -22,18 +50,20 @@ type ViewComponentKey = Exclude<
| 'dashboard'
| 'document'
| 'document_project'
| 'component'
| 'plugin'
| 'plugin_editor'
| 'multi-menu'
| 'multi-menu_first'
| 'multi-menu_first_second-new'
| 'exception'
>;
type ViewComponent = {
[key in ViewComponentKey]: () => Promise<Component>;
};
type ViewComponent = Record<ViewComponentKey, () => Promise<Component>>;
/**
* 获取页面导入的vue文件(懒加载的方式)
* @param routeKey - 路由key
*/
export function getViewComponent(routeKey: AuthRoute.RouteKey) {
const keys: ViewComponentKey[] = [
@@ -47,12 +77,23 @@ export function getViewComponent(routeKey: AuthRoute.RouteKey) {
'document_vue-new',
'document_vite',
'document_naive',
'about',
'multi-menu_first_second',
'multi-menu_first_second-new_third',
'component_button',
'component_card',
'component_table',
'plugin_map',
'plugin_video',
'plugin_editor_quill',
'plugin_editor_markdown',
'plugin_copy',
'plugin_icon',
'plugin_print',
'plugin_swiper',
'exception_403',
'exception_404',
'exception_500',
'multi-menu_first_second',
'multi-menu_first_second-new_third',
'about',
'not-found-page'
];
@@ -69,13 +110,24 @@ export function getViewComponent(routeKey: AuthRoute.RouteKey) {
'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,
component_button: ComponentButton,
component_card: ComponentCard,
component_table: ComponentTable,
plugin_map: PluginMap,
plugin_video: PluginVideo,
plugin_editor_quill: PluginEditorQuill,
plugin_editor_markdown: PluginEditorMarkdown,
plugin_copy: PluginCopy,
plugin_icon: PluginIcon,
plugin_print: PluginPrint,
plugin_swiper: PluginSwiper,
exception_403: NoPermission,
exception_404: NotFound,
exception_500: ServiceError,
about: About
'multi-menu_first_second': MultiMenuFirstSecond,
'multi-menu_first_second-new_third': MultiMenuFirstSecondNewThird,
about: About,
'not-found-page': NotFound
};
return () => setViewComponentName(viewComponent[key], key) as Promise<Component>;

View File

@@ -1,15 +1,13 @@
import type { RouteRecordRaw } from 'vue-router';
import { BasicLayout, BlankLayout } from '@/layouts';
import { consoleError } from '../common';
import { getViewComponent } from './component';
import { getLayoutComponent, getViewComponent } from './component';
type ComponentAction = {
[key in AuthRoute.RouteComponent]: () => void;
};
type ComponentAction = Record<AuthRoute.RouteComponent, () => void>;
/**
* 将权限路由转换成vue路由
* @param routes - 权限路由
* @description 所有多级路由都会被转换成二级路由
*/
export function transformAuthRoutesToVueRoutes(routes: AuthRoute.Route[]) {
return routes.map(route => transformAuthRouteToVueRoute(route)).flat(1);
@@ -38,10 +36,10 @@ function transformAuthRouteToVueRoute(item: AuthRoute.Route) {
if (hasComponent(item)) {
const action: ComponentAction = {
basic() {
itemRoute.component = BasicLayout;
itemRoute.component = getLayoutComponent('basic');
},
blank() {
itemRoute.component = BlankLayout;
itemRoute.component = getLayoutComponent('blank');
},
multi() {
// 多级路由一定有子路由
@@ -81,7 +79,7 @@ function transformAuthRouteToVueRoute(item: AuthRoute.Route) {
} else {
const parentPath = `${itemRoute.path}-parent` as AuthRoute.SingleRouteParentPath;
const layout = item.meta.singleLayout === 'basic' ? BasicLayout : BlankLayout;
const layout = item.meta.singleLayout === 'basic' ? getLayoutComponent('basic') : getLayoutComponent('blank');
const parentRoute: RouteRecordRaw = {
path: parentPath,
@@ -120,22 +118,42 @@ function transformAuthRouteToVueRoute(item: AuthRoute.Route) {
return resultRoute;
}
/**
* 是否有外链
* @param item - 权限路由
*/
function hasHref(item: AuthRoute.Route) {
return Boolean(item.meta.href);
}
/**
* 是否有动态路由path
* @param item - 权限路由
*/
function hasDynamicPath(item: AuthRoute.Route) {
return Boolean(item.meta.dynamicPath);
}
/**
* 是否有路由组件
* @param item - 权限路由
*/
function hasComponent(item: AuthRoute.Route) {
return Boolean(item.component);
}
/**
* 是否有子路由
* @param item - 权限路由
*/
function hasChildren(item: AuthRoute.Route) {
return Boolean(item.children && item.children.length);
}
/**
* 是否是单层级路由
* @param item - 权限路由
*/
function isSingleRoute(item: AuthRoute.Route) {
return Boolean(item.meta.singleLayout);
}

View File

@@ -1,4 +1,5 @@
export * from './helpers';
export * from './cache';
export * from './menu';
export * from './breadcrumb';
export * from './tab';

View File

@@ -12,7 +12,14 @@ export function getTabRoutes() {
const routes: GlobalTabRoute[] = [];
const data = getLocal<GlobalTabRoute[]>(EnumStorageKey['tab-routes']);
if (data) {
routes.push(...data);
const defaultTabRoutes = data.map(item => ({
...item,
scrollPosition: {
left: 0,
top: 0
}
}));
routes.push(...defaultTabRoutes);
}
return routes;
}

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

@@ -9,12 +9,8 @@ export interface PkgVersionInfo {
interface Package {
name: string;
version: string;
dependencies: {
[key: string]: string;
};
devDependencies: {
[key: string]: string;
};
dependencies: Record<string, string>;
devDependencies: Record<string, string>;
[key: string]: any;
}

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,5 @@
const ComponentButton = () => import('./button/index.vue');
const ComponentCard = () => import('./card/index.vue');
const ComponentTable = () => import('./table/index.vue');
export { ComponentButton, ComponentCard, ComponentTable };

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,5 +1,7 @@
export * from './system';
export * from './dashboard';
export * from './document';
export * from './about';
export * from './component';
export * from './plugin';
export * from './multi-menu';
export * from './about';

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>

19
src/views/plugin/index.ts Normal file
View File

@@ -0,0 +1,19 @@
const PluginMap = () => import('./map/index.vue');
const PluginVideo = () => import('./video/index.vue');
const PluginEditorQuill = () => import('./editor/quill/index.vue');
const PluginEditorMarkdown = () => import('./editor/markdown/index.vue');
const PluginSwiper = () => import('./swiper/index.vue');
const PluginCopy = () => import('./copy/index.vue');
const PluginIcon = () => import('./icon/index.vue');
const PluginPrint = () => import('./print/index.vue');
export {
PluginMap,
PluginVideo,
PluginEditorQuill,
PluginEditorMarkdown,
PluginSwiper,
PluginCopy,
PluginIcon,
PluginPrint
};

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

@@ -23,9 +23,7 @@ interface Props {
type: ExceptionType;
}
type ExceptionComponent = {
[key in ExceptionType]: Component;
};
type ExceptionComponent = Record<ExceptionType, Component>;
const props = defineProps<Props>();