mirror of
https://github.com/vbenjs/vue-vben-admin.git
synced 2025-08-27 19:29:04 +08:00
initial commit
This commit is contained in:
59
src/components/Authority/index.tsx
Normal file
59
src/components/Authority/index.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { defineComponent, PropType, computed, unref } from 'vue';
|
||||
|
||||
import { PermissionModeEnum } from '/@/enums/appEnum';
|
||||
import { RoleEnum } from '/@/enums/roleEnum';
|
||||
import { usePermission } from '/@/hooks/web/usePermission';
|
||||
import { appStore } from '/@/store/modules/app';
|
||||
import { getSlot } from '/@/utils/helper/tsxHelper';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'Authority',
|
||||
props: {
|
||||
// 指定角色可见
|
||||
value: {
|
||||
type: [Number, Array, String] as PropType<RoleEnum | RoleEnum[]>,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
setup(props, { slots }) {
|
||||
const getModeRef = computed(() => {
|
||||
return appStore.getProjectConfig.permissionMode;
|
||||
});
|
||||
/**
|
||||
* 渲染角色按钮
|
||||
*/
|
||||
function renderRoleAuth() {
|
||||
const { value } = props;
|
||||
if (!value) {
|
||||
return getSlot(slots, 'default');
|
||||
}
|
||||
const { hasPermission } = usePermission();
|
||||
return hasPermission(value) ? getSlot(slots, 'default') : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染编码按钮
|
||||
* 这里只判断是否包含,具体实现可以根据项目自行写逻辑
|
||||
*/
|
||||
function renderCodeAuth() {
|
||||
const { value } = props;
|
||||
if (!value) {
|
||||
return getSlot(slots, 'default');
|
||||
}
|
||||
const { hasPermission } = usePermission();
|
||||
return hasPermission(value) ? getSlot(slots, 'default') : null;
|
||||
}
|
||||
return () => {
|
||||
const mode = unref(getModeRef);
|
||||
// 基于角色渲染
|
||||
if (mode === PermissionModeEnum.ROLE) {
|
||||
return renderRoleAuth();
|
||||
}
|
||||
// 基于后台编码渲染
|
||||
if (mode === PermissionModeEnum.BACK) {
|
||||
return renderCodeAuth();
|
||||
}
|
||||
return getSlot(slots, 'default');
|
||||
};
|
||||
},
|
||||
});
|
4
src/components/Basic/index.ts
Normal file
4
src/components/Basic/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { default as BasicArrow } from './src/BasicArrow.vue';
|
||||
export { default as BasicHelp } from './src/BasicHelp';
|
||||
export { default as BasicTitle } from './src/BasicTitle.vue';
|
||||
export { default as BasicEmpty } from './src/BasicEmpty.vue';
|
53
src/components/Basic/src/BasicArrow.vue
Normal file
53
src/components/Basic/src/BasicArrow.vue
Normal file
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<span :class="getClass">
|
||||
<RightOutlined />
|
||||
</span>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import type { PropType } from 'vue';
|
||||
|
||||
import { defineComponent, computed } from 'vue';
|
||||
|
||||
import { RightOutlined } from '@ant-design/icons-vue';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'BaseArrow',
|
||||
components: { RightOutlined },
|
||||
props: {
|
||||
// Expand contract, expand by default
|
||||
expand: {
|
||||
type: Boolean as PropType<boolean>,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const getClass = computed(() => {
|
||||
const preCls = 'base-arrow';
|
||||
const cls = [preCls];
|
||||
|
||||
props.expand && cls.push(`${preCls}__active`);
|
||||
return cls;
|
||||
});
|
||||
|
||||
return {
|
||||
getClass,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
<style lang="less" scoped>
|
||||
.base-arrow {
|
||||
transform: rotate(-90deg) !important;
|
||||
transition: all 0.3s ease 0.1s;
|
||||
transform-origin: center center;
|
||||
|
||||
&.right {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
&__active {
|
||||
transform: rotate(90deg) !important;
|
||||
transition: all 0.3s ease 0.1s !important;
|
||||
}
|
||||
}
|
||||
</style>
|
28
src/components/Basic/src/BasicEmpty.vue
Normal file
28
src/components/Basic/src/BasicEmpty.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<template>
|
||||
<Empty :image="image" :description="description" />
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';
|
||||
import { Empty } from 'ant-design-vue';
|
||||
|
||||
import emptySrc from '/@/assets/images/page_null.png';
|
||||
|
||||
export default defineComponent({
|
||||
extends: Empty as any,
|
||||
components: { Empty },
|
||||
props: {
|
||||
description: {
|
||||
type: String,
|
||||
default: '暂无内容',
|
||||
},
|
||||
image: {
|
||||
type: String,
|
||||
default: emptySrc,
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
return {};
|
||||
},
|
||||
});
|
||||
</script>
|
18
src/components/Basic/src/BasicHelp.less
Normal file
18
src/components/Basic/src/BasicHelp.less
Normal file
@@ -0,0 +1,18 @@
|
||||
@import (reference) '../../../design/index.less';
|
||||
|
||||
.base-help {
|
||||
display: inline-block;
|
||||
font-size: 14px;
|
||||
color: @text-color-help-dark;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: @primary-color;
|
||||
}
|
||||
|
||||
&__wrap {
|
||||
p {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
107
src/components/Basic/src/BasicHelp.tsx
Normal file
107
src/components/Basic/src/BasicHelp.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import type { PropType } from 'vue';
|
||||
|
||||
import { Tooltip } from 'ant-design-vue';
|
||||
import { InfoCircleOutlined } from '@ant-design/icons-vue';
|
||||
import { defineComponent, computed, unref } from 'vue';
|
||||
|
||||
import { getPopupContainer } from '/@/utils';
|
||||
|
||||
import { isString, isArray } from '/@/utils/is';
|
||||
import { getSlot } from '/@/utils/helper/tsxHelper';
|
||||
import './BasicHelp.less';
|
||||
export default defineComponent({
|
||||
name: 'BaseHelp',
|
||||
props: {
|
||||
// max-width
|
||||
maxWidth: {
|
||||
type: String as PropType<string>,
|
||||
default: '600px',
|
||||
},
|
||||
// Whether to display the serial number
|
||||
showIndex: {
|
||||
type: Boolean as PropType<boolean>,
|
||||
default: false,
|
||||
},
|
||||
// Text list
|
||||
text: {
|
||||
type: [Array, String] as PropType<string[] | string>,
|
||||
},
|
||||
// color
|
||||
color: {
|
||||
type: String as PropType<string>,
|
||||
default: '#ffffff',
|
||||
},
|
||||
fontSize: {
|
||||
type: String as PropType<string>,
|
||||
default: '14px',
|
||||
},
|
||||
absolute: {
|
||||
type: Boolean as PropType<boolean>,
|
||||
default: false,
|
||||
},
|
||||
// 定位
|
||||
position: {
|
||||
type: [Object] as PropType<any>,
|
||||
default: () => ({
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
}),
|
||||
},
|
||||
},
|
||||
setup(props, { slots }) {
|
||||
const getOverlayStyleRef = computed(() => {
|
||||
return {
|
||||
maxWidth: props.maxWidth,
|
||||
};
|
||||
});
|
||||
const getWrapStyleRef = computed(() => {
|
||||
return {
|
||||
color: props.color,
|
||||
fontSize: props.fontSize,
|
||||
};
|
||||
});
|
||||
const getMainStyleRef = computed(() => {
|
||||
return props.absolute ? props.position : {};
|
||||
});
|
||||
|
||||
/**
|
||||
* @description: 渲染内容
|
||||
*/
|
||||
const renderTitle = () => {
|
||||
const list = props.text;
|
||||
if (isString(list)) {
|
||||
return <p>{list}</p>;
|
||||
}
|
||||
if (isArray(list)) {
|
||||
return list.map((item, index) => {
|
||||
return (
|
||||
<p key={item}>
|
||||
{props.showIndex ? `${index + 1}. ` : ''}
|
||||
{item}
|
||||
</p>
|
||||
);
|
||||
});
|
||||
}
|
||||
return null;
|
||||
};
|
||||
return () => (
|
||||
<Tooltip
|
||||
title={(<div style={unref(getWrapStyleRef)}>{renderTitle()}</div>) as any}
|
||||
placement="right"
|
||||
overlayStyle={unref(getOverlayStyleRef)}
|
||||
autoAdjustOverflow={true}
|
||||
overlayClassName="base-help__wrap"
|
||||
getPopupContainer={() => getPopupContainer()}
|
||||
>
|
||||
{{
|
||||
default: () => (
|
||||
<span class="base-help" style={unref(getMainStyleRef)}>
|
||||
{getSlot(slots) || <InfoCircleOutlined />}
|
||||
</span>
|
||||
),
|
||||
}}
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
});
|
58
src/components/Basic/src/BasicTitle.vue
Normal file
58
src/components/Basic/src/BasicTitle.vue
Normal file
@@ -0,0 +1,58 @@
|
||||
<template>
|
||||
<span class="base-title" :class="{ 'show-span': showSpan && $slots.default }">
|
||||
<slot />
|
||||
<BaseHelp class="base-title__help" v-if="helpMessage" :text="helpMessage" />
|
||||
</span>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import type { PropType } from 'vue';
|
||||
|
||||
import { defineComponent } from 'vue';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'BaseTitle',
|
||||
props: {
|
||||
helpMessage: {
|
||||
type: [String, Array] as PropType<string | string[]>,
|
||||
default: '',
|
||||
},
|
||||
showSpan: {
|
||||
type: Boolean as PropType<boolean>,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
return {};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
<style lang="less" scoped>
|
||||
@import (reference) '../../../design/index.less';
|
||||
|
||||
.base-title {
|
||||
position: relative;
|
||||
display: flex;
|
||||
padding-left: 7px;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
line-height: 24px;
|
||||
color: @text-color-base;
|
||||
|
||||
.unselect();
|
||||
|
||||
&.show-span::before {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
left: 0;
|
||||
width: 3px;
|
||||
height: 16px;
|
||||
margin-right: 4px;
|
||||
background: @primary-color;
|
||||
content: '';
|
||||
}
|
||||
|
||||
&__help {
|
||||
margin-left: 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
100
src/components/Breadcrumb/Breadcrumb.vue
Normal file
100
src/components/Breadcrumb/Breadcrumb.vue
Normal file
@@ -0,0 +1,100 @@
|
||||
<template>
|
||||
<div ref="breadcrumbRef" class="breadcrumb">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import type { PropType } from 'vue';
|
||||
import { defineComponent, provide, ref } from 'vue';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'Breadcrumb',
|
||||
props: {
|
||||
separator: {
|
||||
type: String as PropType<string>,
|
||||
default: '/',
|
||||
},
|
||||
separatorClass: {
|
||||
type: String as PropType<string>,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const breadcrumbRef = ref<Nullable<HTMLElement>>(null);
|
||||
|
||||
provide('breadcrumb', props);
|
||||
|
||||
return {
|
||||
breadcrumbRef,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
<style lang="less">
|
||||
@import (reference) '../../design/index.less';
|
||||
|
||||
.breadcrumb {
|
||||
height: @header-height;
|
||||
padding-right: 20px;
|
||||
font-size: 14px;
|
||||
line-height: @header-height;
|
||||
// line-height: 1;
|
||||
|
||||
&::after,
|
||||
&::before {
|
||||
display: table;
|
||||
content: '';
|
||||
}
|
||||
|
||||
&::after {
|
||||
clear: both;
|
||||
}
|
||||
|
||||
&__separator {
|
||||
margin: 0 9px;
|
||||
font-weight: 700;
|
||||
color: @breadcrumb-item-normal-color;
|
||||
|
||||
&[class*='icon'] {
|
||||
margin: 0 6px;
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
|
||||
&__item {
|
||||
float: left;
|
||||
}
|
||||
|
||||
&__inner {
|
||||
color: @breadcrumb-item-normal-color;
|
||||
|
||||
&.is-link,
|
||||
a {
|
||||
font-weight: 700;
|
||||
color: @text-color-base;
|
||||
text-decoration: none;
|
||||
transition: color 0.2s cubic-bezier(0.645, 0.045, 0.355, 1);
|
||||
}
|
||||
|
||||
a:hover,
|
||||
&.is-link:hover {
|
||||
color: @primary-color;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
&__item:last-child .breadcrumb__inner,
|
||||
&__item:last-child &__inner a,
|
||||
&__item:last-child &__inner a:hover,
|
||||
&__item:last-child &__inner:hover {
|
||||
font-weight: 400;
|
||||
color: @breadcrumb-item-normal-color;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
&__item:last-child &__separator {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
62
src/components/Breadcrumb/BreadcrumbItem.vue
Normal file
62
src/components/Breadcrumb/BreadcrumbItem.vue
Normal file
@@ -0,0 +1,62 @@
|
||||
<template>
|
||||
<span class="breadcrumb__item">
|
||||
<span ref="linkRef" :class="['breadcrumb__inner', to || isLink ? 'is-link' : '']">
|
||||
<slot />
|
||||
</span>
|
||||
<i v-if="separatorClass" class="breadcrumb__separator" :class="separatorClass"></i>
|
||||
<span v-else class="breadcrumb__separator">{{ separator }}</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, inject, ref, onMounted, unref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useEvent } from '/@/hooks/event/useEvent';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'BreadcrumbItem',
|
||||
props: {
|
||||
to: {
|
||||
type: [String, Object],
|
||||
default: '',
|
||||
},
|
||||
replace: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isLink: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const linkRef = ref<Nullable<HTMLElement>>(null);
|
||||
const parent = inject('breadcrumb') as {
|
||||
separator: string;
|
||||
separatorClass: string;
|
||||
};
|
||||
const { push, replace } = useRouter();
|
||||
|
||||
onMounted(() => {
|
||||
const link = unref(linkRef);
|
||||
if (!link) return;
|
||||
useEvent({
|
||||
el: link,
|
||||
listener: () => {
|
||||
const { to } = props;
|
||||
if (!props.to) return;
|
||||
props.replace ? replace(to) : push(to);
|
||||
},
|
||||
name: 'click',
|
||||
wait: 0,
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
linkRef,
|
||||
separator: parent.separator && parent.separator,
|
||||
separatorClass: parent.separatorClass && parent.separatorClass,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
88
src/components/Button/index.vue
Normal file
88
src/components/Button/index.vue
Normal file
@@ -0,0 +1,88 @@
|
||||
<template>
|
||||
<Button v-bind="getBindValue" :class="[getColor, $attrs.class]">
|
||||
<template v-slot:[item] v-for="item in Object.keys($slots)">
|
||||
<slot :name="item" />
|
||||
</template>
|
||||
</Button>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import { PropType } from 'vue';
|
||||
|
||||
import { defineComponent, computed, unref } from 'vue';
|
||||
import { Button } from 'ant-design-vue';
|
||||
// import { extendSlots } from '/@/utils/helper/tsxHelper';
|
||||
import { useThrottle } from '/@/hooks/core/useThrottle';
|
||||
import { isFunction } from '/@/utils/is';
|
||||
export default defineComponent({
|
||||
name: 'AButton',
|
||||
inheritAttrs: false,
|
||||
components: { Button },
|
||||
props: {
|
||||
// 按钮类型
|
||||
type: {
|
||||
type: String as PropType<'primary' | 'default' | 'danger' | 'dashed' | 'link'>,
|
||||
default: 'default',
|
||||
},
|
||||
// 节流防抖类型 throttle debounce
|
||||
throttle: {
|
||||
type: String as PropType<'throttle' | 'debounce'>,
|
||||
default: 'throttle',
|
||||
},
|
||||
color: {
|
||||
type: String as PropType<'error' | 'warning' | 'success'>,
|
||||
},
|
||||
// 防抖节流时间
|
||||
throttleTime: {
|
||||
type: Number as PropType<number>,
|
||||
default: 0,
|
||||
},
|
||||
loading: {
|
||||
type: Boolean as PropType<boolean>,
|
||||
default: false,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean as PropType<boolean>,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
setup(props, { attrs }) {
|
||||
const getListeners = computed(() => {
|
||||
const { throttle, throttleTime = 0 } = props;
|
||||
// 是否开启节流防抖
|
||||
const throttleType = throttle!.toLowerCase();
|
||||
const isDebounce = throttleType === 'debounce';
|
||||
const openThrottle = ['throttle', 'debounce'].includes(throttleType) && throttleTime > 0;
|
||||
|
||||
const on: {
|
||||
onClick?: Fn;
|
||||
} = {};
|
||||
|
||||
if (attrs.onClick && isFunction(attrs.onClick) && openThrottle) {
|
||||
const [handler] = useThrottle(attrs.onClick as any, throttleTime!, {
|
||||
debounce: isDebounce,
|
||||
immediate: true,
|
||||
});
|
||||
on.onClick = handler;
|
||||
}
|
||||
|
||||
return {
|
||||
...attrs,
|
||||
...on,
|
||||
};
|
||||
});
|
||||
|
||||
const getColor = computed(() => {
|
||||
const res: string[] = [];
|
||||
const { color, disabled } = props;
|
||||
color && res.push(`ant-btn-${color}`);
|
||||
disabled && res.push('is-disabled');
|
||||
return res;
|
||||
});
|
||||
|
||||
const getBindValue = computed((): any => {
|
||||
return { ...unref(getListeners), ...props };
|
||||
});
|
||||
return { getBindValue, getColor };
|
||||
},
|
||||
});
|
||||
</script>
|
66
src/components/Button/types.ts
Normal file
66
src/components/Button/types.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { VNodeChild } from 'vue';
|
||||
|
||||
export interface BasicButtonProps {
|
||||
/**
|
||||
* can be set to primary ghost dashed danger(added in 2.7) or omitted (meaning default)
|
||||
* @default 'default'
|
||||
* @type string
|
||||
*/
|
||||
type?: 'primary' | 'danger' | 'dashed' | 'ghost' | 'default';
|
||||
|
||||
/**
|
||||
* set the original html type of button
|
||||
* @default 'button'
|
||||
* @type string
|
||||
*/
|
||||
htmlType?: 'button' | 'submit' | 'reset' | 'menu';
|
||||
|
||||
/**
|
||||
* set the icon of button
|
||||
* @type string
|
||||
*/
|
||||
icon?: VNodeChild | JSX.Element;
|
||||
|
||||
/**
|
||||
* can be set to circle or circle-outline or omitted
|
||||
* @type string
|
||||
*/
|
||||
shape?: 'circle' | 'circle-outline';
|
||||
|
||||
/**
|
||||
* can be set to small large or omitted
|
||||
* @default 'default'
|
||||
* @type string
|
||||
*/
|
||||
size?: 'small' | 'large' | 'default';
|
||||
|
||||
/**
|
||||
* set the loading status of button
|
||||
* @default false
|
||||
* @type boolean | { delay: number }
|
||||
*/
|
||||
loading?: boolean | { delay: number };
|
||||
|
||||
/**
|
||||
* disabled state of button
|
||||
* @default false
|
||||
* @type boolean
|
||||
*/
|
||||
disabled?: boolean;
|
||||
|
||||
/**
|
||||
* make background transparent and invert text and border colors, added in 2.7
|
||||
* @default false
|
||||
* @type boolean
|
||||
*/
|
||||
ghost?: boolean;
|
||||
|
||||
/**
|
||||
* option to fit button width to its parent width
|
||||
* @default false
|
||||
* @type boolean
|
||||
*/
|
||||
block?: boolean;
|
||||
|
||||
onClick?: (e?: Event) => void;
|
||||
}
|
21
src/components/ClickOutSide/index.vue
Normal file
21
src/components/ClickOutSide/index.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<template>
|
||||
<div ref="wrapRef"><slot /></div>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import type { Ref } from 'vue';
|
||||
import { defineComponent, ref } from 'vue';
|
||||
|
||||
import { useClickOutside } from '/@/hooks/web/useClickOutside';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'ClickOutSide',
|
||||
|
||||
setup(_, { emit }) {
|
||||
const wrapRef = ref<Nullable<HTMLDivElement | null>>(null);
|
||||
useClickOutside(wrapRef as Ref<HTMLDivElement>, () => {
|
||||
emit('clickOutside');
|
||||
});
|
||||
return { wrapRef };
|
||||
},
|
||||
});
|
||||
</script>
|
5
src/components/Container/index.ts
Normal file
5
src/components/Container/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { default as ScrollContainer } from './src/ScrollContainer.vue';
|
||||
export { default as CollapseContainer } from './src/collapse/CollapseContainer.vue';
|
||||
export { default as LazyContainer } from './src/LazyContainer';
|
||||
|
||||
export * from './src/types.d';
|
27
src/components/Container/src/LazyContainer.less
Normal file
27
src/components/Container/src/LazyContainer.less
Normal file
@@ -0,0 +1,27 @@
|
||||
.lazy-container-enter {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.lazy-container-enter-to {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.lazy-container-enter-from,
|
||||
.lazy-container-enter-active {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
transition: opacity 0.3s 0.2s;
|
||||
}
|
||||
|
||||
.lazy-container-leave {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.lazy-container-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.lazy-container-leave-active {
|
||||
transition: opacity 0.5s;
|
||||
}
|
200
src/components/Container/src/LazyContainer.tsx
Normal file
200
src/components/Container/src/LazyContainer.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
import type { PropType } from 'vue';
|
||||
|
||||
import {
|
||||
defineComponent,
|
||||
reactive,
|
||||
onMounted,
|
||||
ref,
|
||||
unref,
|
||||
onUnmounted,
|
||||
TransitionGroup,
|
||||
} from 'vue';
|
||||
|
||||
import { Skeleton } from 'ant-design-vue';
|
||||
import { useRaf } from '/@/hooks/event/useRaf';
|
||||
import { useTimeout } from '/@/hooks/core/useTimeout';
|
||||
import { getListeners, getSlot } from '/@/utils/helper/tsxHelper';
|
||||
|
||||
import './LazyContainer.less';
|
||||
|
||||
interface State {
|
||||
isInit: boolean;
|
||||
loading: boolean;
|
||||
intersectionObserverInstance: IntersectionObserver | null;
|
||||
}
|
||||
export default defineComponent({
|
||||
name: 'LazyContainer',
|
||||
emits: ['before-init', 'init'],
|
||||
props: {
|
||||
// 等待时间,如果指定了时间,不论可见与否,在指定时间之后自动加载
|
||||
timeout: {
|
||||
type: Number as PropType<number>,
|
||||
default: 8000,
|
||||
// default: 8000,
|
||||
},
|
||||
// 组件所在的视口,如果组件是在页面容器内滚动,视口就是该容器
|
||||
viewport: {
|
||||
type: (typeof window !== 'undefined' ? window.HTMLElement : Object) as PropType<HTMLElement>,
|
||||
default: () => null,
|
||||
},
|
||||
// 预加载阈值, css单位
|
||||
threshold: {
|
||||
type: String as PropType<string>,
|
||||
default: '0px',
|
||||
},
|
||||
|
||||
// 视口的滚动方向, vertical代表垂直方向,horizontal代表水平方向
|
||||
direction: {
|
||||
type: String as PropType<'vertical' | 'horizontal'>,
|
||||
default: 'vertical',
|
||||
},
|
||||
// 包裹组件的外层容器的标签名
|
||||
tag: {
|
||||
type: String as PropType<string>,
|
||||
default: 'div',
|
||||
},
|
||||
|
||||
maxWaitingTime: {
|
||||
type: Number as PropType<number>,
|
||||
default: 80,
|
||||
},
|
||||
|
||||
// 是否在不可见的时候销毁
|
||||
autoDestory: {
|
||||
type: Boolean as PropType<boolean>,
|
||||
default: false,
|
||||
},
|
||||
|
||||
// transition name
|
||||
transitionName: {
|
||||
type: String as PropType<string>,
|
||||
default: 'lazy-container',
|
||||
},
|
||||
},
|
||||
setup(props, { attrs, emit, slots }) {
|
||||
const elRef = ref<any>(null);
|
||||
const state = reactive<State>({
|
||||
isInit: false,
|
||||
loading: false,
|
||||
intersectionObserverInstance: null,
|
||||
});
|
||||
|
||||
// If there is a set delay time, it will be executed immediately
|
||||
function immediateInit() {
|
||||
const { timeout } = props;
|
||||
timeout &&
|
||||
useTimeout(() => {
|
||||
init();
|
||||
}, timeout);
|
||||
}
|
||||
|
||||
function init() {
|
||||
// At this point, the skeleton component is about to be switched
|
||||
emit('before-init');
|
||||
// At this point you can prepare to load the resources of the lazy-loaded component
|
||||
state.loading = true;
|
||||
|
||||
requestAnimationFrameFn(() => {
|
||||
state.isInit = true;
|
||||
emit('init');
|
||||
});
|
||||
}
|
||||
function requestAnimationFrameFn(callback: () => any) {
|
||||
// Prevent waiting too long without executing the callback
|
||||
// Set the maximum waiting time
|
||||
useTimeout(() => {
|
||||
if (state.isInit) {
|
||||
return;
|
||||
}
|
||||
callback();
|
||||
}, props.maxWaitingTime || 80);
|
||||
|
||||
const { requestAnimationFrame } = useRaf();
|
||||
|
||||
return requestAnimationFrame;
|
||||
}
|
||||
function initIntersectionObserver() {
|
||||
const { timeout, direction, threshold, viewport } = props;
|
||||
if (timeout) {
|
||||
return;
|
||||
}
|
||||
// According to the scrolling direction to construct the viewport margin, used to load in advance
|
||||
let rootMargin;
|
||||
switch (direction) {
|
||||
case 'vertical':
|
||||
rootMargin = `${threshold} 0px`;
|
||||
break;
|
||||
case 'horizontal':
|
||||
rootMargin = `0px ${threshold}`;
|
||||
break;
|
||||
}
|
||||
try {
|
||||
// Observe the intersection of the viewport and the component container
|
||||
state.intersectionObserverInstance = new window.IntersectionObserver(intersectionHandler, {
|
||||
rootMargin,
|
||||
root: viewport,
|
||||
threshold: [0, Number.MIN_VALUE, 0.01],
|
||||
});
|
||||
|
||||
const el = unref(elRef);
|
||||
|
||||
state.intersectionObserverInstance.observe(el.$el);
|
||||
} catch (e) {
|
||||
init();
|
||||
}
|
||||
}
|
||||
// Cross-condition change handling function
|
||||
function intersectionHandler(entries: any[]) {
|
||||
const isIntersecting = entries[0].isIntersecting || entries[0].intersectionRatio;
|
||||
if (isIntersecting) {
|
||||
init();
|
||||
if (state.intersectionObserverInstance) {
|
||||
const el = unref(elRef);
|
||||
state.intersectionObserverInstance.unobserve(el.$el);
|
||||
}
|
||||
}
|
||||
// else {
|
||||
// const { autoDestory } = props;
|
||||
// autoDestory && destory();
|
||||
// }
|
||||
}
|
||||
// function destory() {
|
||||
// emit('beforeDestory');
|
||||
// state.loading = false;
|
||||
// nextTick(() => {
|
||||
// emit('destory');
|
||||
// });
|
||||
// }
|
||||
|
||||
immediateInit();
|
||||
onMounted(() => {
|
||||
initIntersectionObserver();
|
||||
});
|
||||
onUnmounted(() => {
|
||||
// Cancel the observation before the component is destroyed
|
||||
if (state.intersectionObserverInstance) {
|
||||
const el = unref(elRef);
|
||||
state.intersectionObserverInstance.unobserve(el.$el);
|
||||
}
|
||||
});
|
||||
|
||||
function renderContent() {
|
||||
const { isInit, loading } = state;
|
||||
if (isInit) {
|
||||
return <div key="component">{getSlot(slots, 'default', { loading })}</div>;
|
||||
}
|
||||
if (slots.skeleton) {
|
||||
return <div key="skeleton">{getSlot(slots, 'skeleton') || <Skeleton />}</div>;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return () => {
|
||||
const { tag, transitionName } = props;
|
||||
return (
|
||||
<TransitionGroup ref={elRef} name={transitionName} tag={tag} {...getListeners(attrs)}>
|
||||
{() => renderContent()}
|
||||
</TransitionGroup>
|
||||
);
|
||||
};
|
||||
},
|
||||
});
|
80
src/components/Container/src/ScrollContainer.vue
Normal file
80
src/components/Container/src/ScrollContainer.vue
Normal file
@@ -0,0 +1,80 @@
|
||||
<template>
|
||||
<Scrollbar
|
||||
ref="scrollbarRef"
|
||||
:wrapClass="`scrollbar__wrap`"
|
||||
:viewClass="`scrollbar__view`"
|
||||
class="scroll-container"
|
||||
>
|
||||
<slot />
|
||||
</Scrollbar>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
// component
|
||||
import { defineComponent, ref, unref, nextTick } from 'vue';
|
||||
import { Scrollbar } from '/@/components/Scrollbar';
|
||||
|
||||
// hook
|
||||
import { useScrollTo } from '/@/hooks/event/useScrollTo';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'ScrollContainer',
|
||||
components: { Scrollbar },
|
||||
setup() {
|
||||
const scrollbarRef = ref<RefInstanceType<any>>(null);
|
||||
|
||||
function scrollTo(to: number, duration = 500) {
|
||||
const scrollbar = unref(scrollbarRef);
|
||||
if (!scrollbar) return;
|
||||
nextTick(() => {
|
||||
const { start } = useScrollTo({
|
||||
el: unref(scrollbar.$.wrap),
|
||||
to,
|
||||
duration,
|
||||
});
|
||||
start();
|
||||
});
|
||||
}
|
||||
|
||||
function getScrollWrap() {
|
||||
const scrollbar = unref(scrollbarRef);
|
||||
if (!scrollbar) return null;
|
||||
return scrollbar.$.wrap;
|
||||
}
|
||||
|
||||
function scrollBottom() {
|
||||
const scrollbar = unref(scrollbarRef);
|
||||
if (!scrollbar) return;
|
||||
nextTick(() => {
|
||||
const scrollHeight = scrollbar.$.wrap.scrollHeight as number;
|
||||
const { start } = useScrollTo({
|
||||
el: unref(scrollbar.$.wrap),
|
||||
to: scrollHeight,
|
||||
});
|
||||
start();
|
||||
});
|
||||
}
|
||||
return {
|
||||
scrollbarRef,
|
||||
scrollTo,
|
||||
scrollBottom,
|
||||
getScrollWrap,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
<style lang="less">
|
||||
.scroll-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
.scrollbar__wrap {
|
||||
margin-bottom: 18px !important;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.scrollbar__view {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
</style>
|
117
src/components/Container/src/collapse/CollapseContainer.vue
Normal file
117
src/components/Container/src/collapse/CollapseContainer.vue
Normal file
@@ -0,0 +1,117 @@
|
||||
<template>
|
||||
<div class="collapse-container p-2 bg:white rounded-sm">
|
||||
<CollapseHeader v-bind="$props" :show="show" @expand="handleExpand" />
|
||||
<CollapseTransition :enable="canExpan">
|
||||
<Skeleton v-if="loading" />
|
||||
<div class="collapse-container__body" v-else v-show="show">
|
||||
<LazyContainer :timeout="lazyTime" v-if="lazy">
|
||||
<slot />
|
||||
<template v-slot:skeleton>
|
||||
<slot name="lazySkeleton" />
|
||||
</template>
|
||||
</LazyContainer>
|
||||
<slot />
|
||||
</div>
|
||||
</CollapseTransition>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import type { PropType } from 'vue';
|
||||
|
||||
import { defineComponent, ref, unref } from 'vue';
|
||||
// component
|
||||
import { CollapseTransition } from '/@/components/Transition/index';
|
||||
import CollapseHeader from './CollapseHeader.vue';
|
||||
import { Skeleton } from 'ant-design-vue';
|
||||
|
||||
import LazyContainer from '../LazyContainer';
|
||||
|
||||
import { triggerWindowResize } from '/@/utils/event/triggerWindowResizeEvent';
|
||||
// hook
|
||||
import { useTimeout } from '/@/hooks/core/useTimeout';
|
||||
export default defineComponent({
|
||||
components: { Skeleton, LazyContainer, CollapseHeader, CollapseTransition },
|
||||
name: 'CollapseContainer',
|
||||
props: {
|
||||
// 标题
|
||||
title: {
|
||||
type: String as PropType<string>,
|
||||
default: '',
|
||||
},
|
||||
// 是否可以展开
|
||||
canExpan: {
|
||||
type: Boolean as PropType<boolean>,
|
||||
default: true,
|
||||
},
|
||||
// 标题右侧温馨提醒
|
||||
helpMessage: {
|
||||
type: [Array, String] as PropType<string[] | string>,
|
||||
default: '',
|
||||
},
|
||||
// 展开收缩的时候是否触发window.resize,
|
||||
// 可以适应表格和表单,当表单收缩起来,表格触发resize 自适应高度
|
||||
triggerWindowResize: {
|
||||
type: Boolean as PropType<boolean>,
|
||||
default: false,
|
||||
},
|
||||
loading: {
|
||||
type: Boolean as PropType<boolean>,
|
||||
default: false,
|
||||
},
|
||||
// 延时加载
|
||||
lazy: {
|
||||
type: Boolean as PropType<boolean>,
|
||||
default: false,
|
||||
},
|
||||
// 延时加载时间
|
||||
lazyTime: {
|
||||
type: Number as PropType<number>,
|
||||
default: 3000,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const showRef = ref(true);
|
||||
/**
|
||||
* @description: 处理开展事件
|
||||
*/
|
||||
function handleExpand() {
|
||||
const hasShow = !unref(showRef);
|
||||
showRef.value = hasShow;
|
||||
|
||||
if (props.triggerWindowResize) {
|
||||
// 这里200毫秒是因为展开有动画,
|
||||
useTimeout(triggerWindowResize, 200);
|
||||
}
|
||||
}
|
||||
return {
|
||||
show: showRef,
|
||||
handleExpand,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
<style lang="less">
|
||||
.collapse-container {
|
||||
padding: 10px;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
transition: all 0.3s ease-in-out;
|
||||
|
||||
&.no-shadow {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
height: 32px;
|
||||
margin-bottom: 10px;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&__action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
</style>
|
32
src/components/Container/src/collapse/CollapseHeader.vue
Normal file
32
src/components/Container/src/collapse/CollapseHeader.vue
Normal file
@@ -0,0 +1,32 @@
|
||||
<template>
|
||||
<div class="collapse-container__header">
|
||||
<BasicTitle :helpMessage="$attrs.helpMessage">
|
||||
<template v-if="$attrs.title">
|
||||
{{ $attrs.title }}
|
||||
</template>
|
||||
<template v-else>
|
||||
<slot name="title" />
|
||||
</template>
|
||||
</BasicTitle>
|
||||
|
||||
<div class="collapse-container__action">
|
||||
<slot name="action" />
|
||||
<BasicArrow v-if="$attrs.canExpan" :expand="$attrs.show" @click="handleExpand" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';
|
||||
import { BasicArrow } from '/@/components/Basic';
|
||||
import { BasicTitle } from '/@/components/Basic';
|
||||
export default defineComponent({
|
||||
inheritAttrs: false,
|
||||
components: { BasicArrow, BasicTitle },
|
||||
setup(_, { emit }) {
|
||||
function handleExpand() {
|
||||
emit('expand');
|
||||
}
|
||||
return { handleExpand };
|
||||
},
|
||||
});
|
||||
</script>
|
17
src/components/Container/src/types.d.ts
vendored
Normal file
17
src/components/Container/src/types.d.ts
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
export type ScrollType = 'default' | 'main';
|
||||
|
||||
export interface CollapseContainerOptions {
|
||||
canExpand?: boolean;
|
||||
title?: string;
|
||||
helpMessage?: Array<any> | string;
|
||||
}
|
||||
export interface ScrollContainerOptions {
|
||||
enableScroll?: boolean;
|
||||
type?: ScrollType;
|
||||
}
|
||||
|
||||
export type ScrollActionType = RefType<{
|
||||
scrollBottom: () => void;
|
||||
getScrollWrap: () => Nullable<HTMLElement>;
|
||||
scrollTo: (top: number) => void;
|
||||
}>;
|
65
src/components/ContextMenu/index.ts
Normal file
65
src/components/ContextMenu/index.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import contextMenuVue from './src/index';
|
||||
import { isClient } from '/@/utils/is';
|
||||
import { Options, Props } from './src/types';
|
||||
import { createApp } from 'vue';
|
||||
const menuManager: {
|
||||
doms: Element[];
|
||||
resolve: Fn;
|
||||
} = {
|
||||
doms: [],
|
||||
resolve: () => {},
|
||||
};
|
||||
export const createContextMenu = function (options: Options) {
|
||||
const { event } = options || {};
|
||||
try {
|
||||
event.preventDefault();
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
|
||||
if (!isClient) return;
|
||||
return new Promise((resolve) => {
|
||||
const wrapDom = document.createElement('div');
|
||||
const propsData: Partial<Props> = {};
|
||||
if (options.styles !== undefined) propsData.styles = options.styles;
|
||||
if (options.items !== undefined) propsData.items = options.items;
|
||||
if (options.event !== undefined) {
|
||||
propsData.customEvent = event;
|
||||
propsData.axis = { x: event.clientX, y: event.clientY };
|
||||
}
|
||||
createApp(contextMenuVue, propsData).mount(wrapDom);
|
||||
const bodyClick = function () {
|
||||
menuManager.resolve('');
|
||||
};
|
||||
const contextMenuDom = wrapDom.children[0];
|
||||
menuManager.doms.push(contextMenuDom);
|
||||
const remove = function () {
|
||||
menuManager.doms.forEach((dom: Element) => {
|
||||
try {
|
||||
document.body.removeChild(dom);
|
||||
} catch (error) {}
|
||||
});
|
||||
document.body.removeEventListener('click', bodyClick);
|
||||
document.body.removeEventListener('scroll', bodyClick);
|
||||
try {
|
||||
(wrapDom as any) = null;
|
||||
} catch (error) {}
|
||||
};
|
||||
menuManager.resolve = function (...arg: any) {
|
||||
resolve(arg[0]);
|
||||
remove();
|
||||
};
|
||||
remove();
|
||||
document.body.appendChild(contextMenuDom);
|
||||
document.body.addEventListener('click', bodyClick);
|
||||
document.body.addEventListener('scroll', bodyClick);
|
||||
});
|
||||
};
|
||||
export const unMountedContextMenu = function () {
|
||||
if (menuManager) {
|
||||
menuManager.resolve('');
|
||||
menuManager.doms = [];
|
||||
}
|
||||
};
|
||||
|
||||
export * from './src/types';
|
49
src/components/ContextMenu/src/index.less
Normal file
49
src/components/ContextMenu/src/index.less
Normal file
@@ -0,0 +1,49 @@
|
||||
@import (reference) '../../../design/index.less';
|
||||
|
||||
.context-menu {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 1500;
|
||||
display: block;
|
||||
width: 156px;
|
||||
min-width: 10rem;
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
background-color: #fff;
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
border-radius: 0.25rem;
|
||||
box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.14), 0 3px 1px -2px rgba(0, 0, 0, 0.1),
|
||||
0 1px 5px 0 rgba(0, 0, 0, 0.06);
|
||||
background-clip: padding-box;
|
||||
user-select: none;
|
||||
|
||||
&.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
&__item {
|
||||
a {
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
padding: 10px 14px;
|
||||
|
||||
&:hover {
|
||||
color: @text-color-base;
|
||||
background: #eee;
|
||||
}
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
a {
|
||||
color: @disabled-color;
|
||||
cursor: not-allowed;
|
||||
|
||||
&:hover {
|
||||
color: @disabled-color;
|
||||
background: unset;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
90
src/components/ContextMenu/src/index.tsx
Normal file
90
src/components/ContextMenu/src/index.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import {
|
||||
defineComponent,
|
||||
nextTick,
|
||||
onMounted,
|
||||
reactive,
|
||||
computed,
|
||||
ref,
|
||||
unref,
|
||||
onUnmounted,
|
||||
} from 'vue';
|
||||
import { props } from './props';
|
||||
import Icon from '/@/components/Icon';
|
||||
import type { ContextMenuItem } from './types';
|
||||
import './index.less';
|
||||
const prefixCls = 'context-menu';
|
||||
export default defineComponent({
|
||||
name: 'ContextMenu',
|
||||
props,
|
||||
setup(props) {
|
||||
const wrapRef = ref<Nullable<HTMLDivElement>>(null);
|
||||
const state = reactive({
|
||||
show: false,
|
||||
});
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
state.show = true;
|
||||
});
|
||||
});
|
||||
onUnmounted(() => {
|
||||
const el = unref(wrapRef);
|
||||
el && document.body.removeChild(el);
|
||||
});
|
||||
const getStyle = computed(() => {
|
||||
const { axis, items, styles, width } = props;
|
||||
const { x, y } = axis || { x: 0, y: 0 };
|
||||
const menuHeight = (items || []).length * 40;
|
||||
const menuWidth = width;
|
||||
const body = document.body;
|
||||
return {
|
||||
...(styles as any),
|
||||
width: `${width}px`,
|
||||
left: (body.clientWidth < x + menuWidth ? x - menuWidth : x) + 'px',
|
||||
top: (body.clientHeight < y + menuHeight ? y - menuHeight : y) + 'px',
|
||||
};
|
||||
});
|
||||
function handleAction(item: ContextMenuItem, e: MouseEvent) {
|
||||
const { handler, disabled } = item;
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
state.show = false;
|
||||
if (e) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
handler && handler();
|
||||
}
|
||||
function renderContent(item: ContextMenuItem) {
|
||||
const { icon, label } = item;
|
||||
|
||||
const { showIcon } = props;
|
||||
return (
|
||||
<span style="display: inline-block; width: 100%;">
|
||||
{showIcon && icon && <Icon class="mr-2" icon={icon} />}
|
||||
<span>{label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
function renderMenuItem(items: ContextMenuItem[]) {
|
||||
return items.map((item) => {
|
||||
const { disabled, label } = item;
|
||||
|
||||
return (
|
||||
<li class={`${prefixCls}__item ${disabled ? 'disabled' : ''}`} key={label}>
|
||||
<a onClick={handleAction.bind(null, item)}>{renderContent(item)}</a>
|
||||
</li>
|
||||
);
|
||||
});
|
||||
}
|
||||
return () => {
|
||||
const { items } = props;
|
||||
return (
|
||||
<ul class={[prefixCls, !state.show && 'hidden']} ref={wrapRef} style={unref(getStyle)}>
|
||||
{renderMenuItem(items)}
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
},
|
||||
});
|
40
src/components/ContextMenu/src/props.ts
Normal file
40
src/components/ContextMenu/src/props.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { PropType } from 'vue';
|
||||
import type { Axis, ContextMenuItem } from './types';
|
||||
export const props = {
|
||||
width: {
|
||||
type: Number as PropType<number>,
|
||||
default: 180,
|
||||
},
|
||||
customEvent: {
|
||||
type: Object as PropType<Event>,
|
||||
default: null,
|
||||
},
|
||||
// 样式
|
||||
styles: {
|
||||
type: Object as PropType<any>,
|
||||
default: null,
|
||||
},
|
||||
showIcon: {
|
||||
// 是否显示icon
|
||||
type: Boolean as PropType<boolean>,
|
||||
default: true,
|
||||
},
|
||||
axis: {
|
||||
// 鼠标右键点击的位置
|
||||
type: Object as PropType<Axis>,
|
||||
default() {
|
||||
return { x: 0, y: 0 };
|
||||
},
|
||||
},
|
||||
items: {
|
||||
// 最重要的列表,没有的话直接不显示
|
||||
type: Array as PropType<ContextMenuItem[]>,
|
||||
default() {
|
||||
return [];
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
type: Function as PropType<any>,
|
||||
default: null,
|
||||
},
|
||||
};
|
30
src/components/ContextMenu/src/types.ts
Normal file
30
src/components/ContextMenu/src/types.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
export interface Axis {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface ContextMenuItem {
|
||||
label: string;
|
||||
icon?: string;
|
||||
disabled?: boolean;
|
||||
handler?: Fn;
|
||||
divider?: boolean;
|
||||
children?: ContextMenuItem[];
|
||||
}
|
||||
export interface Options {
|
||||
event: MouseEvent;
|
||||
icon?: string;
|
||||
styles?: any;
|
||||
items?: ContextMenuItem[];
|
||||
}
|
||||
|
||||
export type Props = {
|
||||
resolve?: (...arg: any) => void;
|
||||
event?: MouseEvent;
|
||||
styles?: any;
|
||||
items: ContextMenuItem[];
|
||||
customEvent?: MouseEvent;
|
||||
axis?: Axis;
|
||||
width?: number;
|
||||
showIcon?: boolean;
|
||||
};
|
3
src/components/Description/index.ts
Normal file
3
src/components/Description/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as Description } from './src/index';
|
||||
export * from './src/types';
|
||||
export { useDescription } from './src/useDescription';
|
144
src/components/Description/src/index.tsx
Normal file
144
src/components/Description/src/index.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
import { defineComponent, computed, ref, unref } from 'vue';
|
||||
import { Descriptions } from 'ant-design-vue';
|
||||
import { CollapseContainer, CollapseContainerOptions } from '/@/components/Container/index';
|
||||
import type { DescOptions, DescInstance, DescItem } from './types';
|
||||
import descProps from './props';
|
||||
|
||||
import { isFunction } from '/@/utils/is';
|
||||
import { getSlot } from '/@/utils/helper/tsxHelper';
|
||||
import { cloneDeep } from 'lodash-es';
|
||||
import { deepMerge } from '/@/utils';
|
||||
|
||||
const prefixCls = 'description';
|
||||
export default defineComponent({
|
||||
props: descProps,
|
||||
emits: ['register'],
|
||||
setup(props, { attrs, slots, emit }) {
|
||||
// props来自设置
|
||||
const propsRef = ref<Partial<DescOptions> | null>(null);
|
||||
// 自定义title组件:获得title
|
||||
const getMergeProps = computed(() => {
|
||||
return {
|
||||
...props,
|
||||
...unref(propsRef),
|
||||
};
|
||||
});
|
||||
const getProps = computed(() => {
|
||||
const opt = {
|
||||
...props,
|
||||
...(unref(propsRef) || {}),
|
||||
title: undefined,
|
||||
};
|
||||
return opt;
|
||||
});
|
||||
/**
|
||||
* @description: 是否使用标题
|
||||
*/
|
||||
const useWrapper = computed(() => {
|
||||
return !!unref(getMergeProps).title;
|
||||
});
|
||||
/**
|
||||
* @description: 获取配置Collapse
|
||||
*/
|
||||
const getCollapseOptions = computed(
|
||||
(): CollapseContainerOptions => {
|
||||
return {
|
||||
// 默认不能展开
|
||||
canExpand: false,
|
||||
...unref(getProps).collapseOptions,
|
||||
};
|
||||
}
|
||||
);
|
||||
/**
|
||||
* @description:设置desc
|
||||
*/
|
||||
function setDescProps(descProps: Partial<DescOptions>): void {
|
||||
// 保留上一次的setDrawerProps
|
||||
const mergeProps = deepMerge(unref(propsRef) || {}, descProps);
|
||||
propsRef.value = cloneDeep(mergeProps);
|
||||
}
|
||||
const methods: DescInstance = {
|
||||
setDescProps,
|
||||
};
|
||||
emit('register', methods);
|
||||
|
||||
// 防止换行
|
||||
function renderLabel({ label, labelMinWidth, labelStyle }: DescItem) {
|
||||
if (!labelStyle && !labelMinWidth) {
|
||||
return label;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
...labelStyle,
|
||||
|
||||
minWidth: `${labelMinWidth}px`,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderItem() {
|
||||
const { schema } = unref(getProps);
|
||||
return unref(schema).map((item) => {
|
||||
const { render, field, span, show, contentMinWidth } = item;
|
||||
const { data } = unref(getProps) as any;
|
||||
if (show && isFunction(show) && !show(data)) {
|
||||
return null;
|
||||
}
|
||||
const getContent = () =>
|
||||
isFunction(render)
|
||||
? render(data && data[field], data)
|
||||
: unref(data) && unref(data)[field];
|
||||
|
||||
const width = contentMinWidth;
|
||||
return (
|
||||
<Descriptions.Item label={renderLabel(item)} key={field} span={span}>
|
||||
{() =>
|
||||
contentMinWidth ? (
|
||||
<div
|
||||
style={{
|
||||
minWidth: `${width}px`,
|
||||
}}
|
||||
>
|
||||
{getContent()}
|
||||
</div>
|
||||
) : (
|
||||
getContent()
|
||||
)
|
||||
}
|
||||
</Descriptions.Item>
|
||||
);
|
||||
});
|
||||
}
|
||||
const renderDesc = () => {
|
||||
return (
|
||||
<Descriptions class={`${prefixCls}`} {...{ ...attrs, ...unref(getProps) }}>
|
||||
{() => renderItem()}
|
||||
</Descriptions>
|
||||
);
|
||||
};
|
||||
const renderContainer = () => {
|
||||
const content = props.useCollapse ? renderDesc() : <div>{renderDesc()}</div>;
|
||||
// 减少dom层级
|
||||
return props.useCollapse ? (
|
||||
<CollapseContainer
|
||||
title={unref(getMergeProps).title}
|
||||
canExpan={unref(getCollapseOptions).canExpand}
|
||||
helpMessage={unref(getCollapseOptions).helpMessage}
|
||||
>
|
||||
{{
|
||||
default: () => content,
|
||||
action: () => getSlot(slots, 'action'),
|
||||
}}
|
||||
</CollapseContainer>
|
||||
) : (
|
||||
content
|
||||
);
|
||||
};
|
||||
|
||||
return () => (unref(useWrapper) ? renderContainer() : renderDesc());
|
||||
},
|
||||
});
|
39
src/components/Description/src/props.ts
Normal file
39
src/components/Description/src/props.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { PropType } from 'vue';
|
||||
import type { DescItem } from './types';
|
||||
|
||||
export default {
|
||||
useCollapse: {
|
||||
type: Boolean as PropType<boolean>,
|
||||
default: true,
|
||||
},
|
||||
title: {
|
||||
type: String as PropType<string>,
|
||||
default: '',
|
||||
},
|
||||
size: {
|
||||
type: String as PropType<'small' | 'default' | 'middle' | undefined>,
|
||||
default: 'small',
|
||||
},
|
||||
bordered: {
|
||||
type: Boolean as PropType<boolean>,
|
||||
default: true,
|
||||
},
|
||||
column: {
|
||||
type: [Number, Object] as PropType<number | any>,
|
||||
default: () => {
|
||||
return { xxl: 4, xl: 3, lg: 3, md: 3, sm: 2, xs: 1 };
|
||||
},
|
||||
},
|
||||
collapseOptions: {
|
||||
type: Object as PropType<any>,
|
||||
default: null,
|
||||
},
|
||||
schema: {
|
||||
type: Array as PropType<Array<DescItem>>,
|
||||
default: () => [],
|
||||
},
|
||||
data: {
|
||||
type: Object as PropType<any>,
|
||||
default: null,
|
||||
},
|
||||
};
|
95
src/components/Description/src/types.ts
Normal file
95
src/components/Description/src/types.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import type { VNode } from 'vue';
|
||||
import type { CollapseContainerOptions } from '/@/components/Container/index';
|
||||
|
||||
export interface DescItem {
|
||||
// 最小宽度
|
||||
labelMinWidth?: number;
|
||||
|
||||
contentMinWidth?: number;
|
||||
|
||||
labelStyle?: any;
|
||||
|
||||
field: string;
|
||||
label: string;
|
||||
// 和并列
|
||||
span?: number;
|
||||
show?: (...arg: any) => boolean;
|
||||
// render
|
||||
render?: (val: string, data: any) => VNode | undefined | Element | string | number;
|
||||
}
|
||||
|
||||
export interface DescOptions {
|
||||
// 是否包含collapse组件
|
||||
useCollapse?: boolean;
|
||||
/**
|
||||
* item配置
|
||||
* @type DescItem
|
||||
*/
|
||||
schema: DescItem[];
|
||||
/**
|
||||
* 数据
|
||||
* @type object
|
||||
*/
|
||||
data: object;
|
||||
/**
|
||||
* 内置的CollapseContainer组件配置
|
||||
* @type CollapseContainerOptions
|
||||
*/
|
||||
collapseOptions?: CollapseContainerOptions;
|
||||
/**
|
||||
* descriptions size type
|
||||
* @default 'default'
|
||||
* @type string
|
||||
*/
|
||||
size?: 'default' | 'middle' | 'small';
|
||||
|
||||
/**
|
||||
* custom prefixCls
|
||||
* @type string
|
||||
*/
|
||||
prefixCls?: string;
|
||||
|
||||
/**
|
||||
* whether descriptions have border
|
||||
* @default false
|
||||
* @type boolean
|
||||
*/
|
||||
bordered?: boolean;
|
||||
|
||||
/**
|
||||
* custom title
|
||||
* @type any
|
||||
*/
|
||||
title?: any;
|
||||
|
||||
/**
|
||||
* the number of descriptionsitem in one line
|
||||
* @default 3
|
||||
* @type number | object
|
||||
*/
|
||||
column?: number | object;
|
||||
|
||||
/**
|
||||
* descriptions layout
|
||||
* @default 'horizontal'
|
||||
* @type string
|
||||
*/
|
||||
layout?: 'horizontal' | 'vertical';
|
||||
|
||||
/**
|
||||
* whether have colon in descriptionsitem
|
||||
* @default true
|
||||
* @type boolean
|
||||
*/
|
||||
colon?: boolean;
|
||||
}
|
||||
|
||||
export interface DescInstance {
|
||||
setDescProps(descProps: Partial<DescOptions>): void;
|
||||
}
|
||||
|
||||
export type Register = (descInstance: DescInstance) => void;
|
||||
/**
|
||||
* @description:
|
||||
*/
|
||||
export type UseDescReturnType = [Register, DescInstance];
|
27
src/components/Description/src/useDescription.ts
Normal file
27
src/components/Description/src/useDescription.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { ref, getCurrentInstance, unref } from 'vue';
|
||||
import { isProdMode } from '/@/utils/env';
|
||||
|
||||
import type { DescOptions, DescInstance, UseDescReturnType } from './types';
|
||||
|
||||
export function useDescription(props?: Partial<DescOptions>): UseDescReturnType {
|
||||
if (!getCurrentInstance()) {
|
||||
throw new Error('Please put useDescription function in the setup function!');
|
||||
}
|
||||
const descRef = ref<DescInstance | null>(null);
|
||||
const loadedRef = ref(false);
|
||||
|
||||
function getDescription(instance: DescInstance) {
|
||||
if (unref(loadedRef) && isProdMode()) {
|
||||
return;
|
||||
}
|
||||
descRef.value = instance;
|
||||
props && instance.setDescProps(props);
|
||||
loadedRef.value = true;
|
||||
}
|
||||
const methods: DescInstance = {
|
||||
setDescProps: (descProps: Partial<DescOptions>): void => {
|
||||
unref(descRef)!.setDescProps(descProps);
|
||||
},
|
||||
};
|
||||
return [getDescription, methods];
|
||||
}
|
4
src/components/Drawer/index.ts
Normal file
4
src/components/Drawer/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { default as BasicDrawer } from './src/BasicDrawer';
|
||||
|
||||
export { useDrawer, useDrawerInner } from './src/useDrawer';
|
||||
export * from './src/types';
|
279
src/components/Drawer/src/BasicDrawer.tsx
Normal file
279
src/components/Drawer/src/BasicDrawer.tsx
Normal file
@@ -0,0 +1,279 @@
|
||||
import { Drawer, Row, Col, Button } from 'ant-design-vue';
|
||||
import {
|
||||
defineComponent,
|
||||
ref,
|
||||
computed,
|
||||
watchEffect,
|
||||
watch,
|
||||
unref,
|
||||
getCurrentInstance,
|
||||
nextTick,
|
||||
toRaw,
|
||||
} from 'vue';
|
||||
import { BasicTitle } from '/@/components/Basic';
|
||||
import { ScrollContainer, ScrollContainerOptions } from '/@/components/Container/index';
|
||||
import { FullLoading } from '/@/components/Loading/index';
|
||||
|
||||
import { getSlot } from '/@/utils/helper/tsxHelper';
|
||||
|
||||
import { DrawerInstance, DrawerProps, DrawerType } from './types';
|
||||
|
||||
import { basicProps } from './props';
|
||||
import { isFunction, isNumber } from '/@/utils/is';
|
||||
import { LeftOutlined } from '@ant-design/icons-vue';
|
||||
// import { appStore } from '/@/store/modules/app';
|
||||
// import { useRouter } from 'vue-router';
|
||||
import { buildUUID } from '/@/utils/uuid';
|
||||
import { deepMerge } from '/@/utils';
|
||||
import './index.less';
|
||||
|
||||
const prefixCls = 'basic-drawer';
|
||||
export default defineComponent({
|
||||
// inheritAttrs: false,
|
||||
props: basicProps,
|
||||
emits: ['visible-change', 'ok', 'close', 'register'],
|
||||
setup(props, { slots, emit, attrs }) {
|
||||
// const { currentRoute } = useRouter();
|
||||
const scrollRef = ref<any>(null);
|
||||
/**
|
||||
* @description: 获取配置ScrollContainer
|
||||
*/
|
||||
const getScrollOptions = computed(
|
||||
(): ScrollContainerOptions => {
|
||||
return {
|
||||
...(props.scrollOptions as any),
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
const visibleRef = ref(false);
|
||||
const propsRef = ref<Partial<DrawerProps> | null>(null);
|
||||
|
||||
// 自定义title组件:获得title
|
||||
const getMergeProps = computed((): any => {
|
||||
return deepMerge(toRaw(props), unref(propsRef));
|
||||
});
|
||||
|
||||
const getProps = computed(() => {
|
||||
const opt: any = {
|
||||
// @ts-ignore
|
||||
placement: 'right',
|
||||
...attrs,
|
||||
...props,
|
||||
...(unref(propsRef) as any),
|
||||
visible: unref(visibleRef),
|
||||
};
|
||||
opt.title = undefined;
|
||||
|
||||
if (opt.drawerType === DrawerType.DETAIL) {
|
||||
if (!opt.width) {
|
||||
opt.width = '100%';
|
||||
}
|
||||
opt.wrapClassName = opt.wrapClassName
|
||||
? `${opt.wrapClassName} ${prefixCls}__detail`
|
||||
: `${prefixCls}__detail`;
|
||||
// opt.maskClosable = false;
|
||||
if (!opt.getContainer) {
|
||||
opt.getContainer = `.default-layout__main`;
|
||||
}
|
||||
}
|
||||
return opt;
|
||||
});
|
||||
watchEffect(() => {
|
||||
visibleRef.value = props.visible;
|
||||
});
|
||||
watch(
|
||||
() => visibleRef.value,
|
||||
(visible) => {
|
||||
// appStore.commitLockMainScrollState(visible);
|
||||
nextTick(() => {
|
||||
emit('visible-change', visible);
|
||||
});
|
||||
},
|
||||
{
|
||||
immediate: false,
|
||||
}
|
||||
);
|
||||
|
||||
// watch(
|
||||
// () => currentRoute.value.path,
|
||||
// () => {
|
||||
// if (unref(visibleRef)) {
|
||||
// visibleRef.value = false;
|
||||
// }
|
||||
// }
|
||||
// );
|
||||
function scrollBottom() {
|
||||
const scroll = unref(scrollRef);
|
||||
if (scroll) {
|
||||
scroll.scrollBottom();
|
||||
}
|
||||
}
|
||||
function scrollTo(to: number) {
|
||||
const scroll = unref(scrollRef);
|
||||
if (scroll) {
|
||||
scroll.scrollTo(to);
|
||||
}
|
||||
}
|
||||
function getScrollWrap() {
|
||||
const scroll = unref(scrollRef);
|
||||
if (scroll) {
|
||||
return scroll.getScrollWrap();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
// 取消事件
|
||||
async function onClose(e: any) {
|
||||
const { closeFunc } = unref(getProps);
|
||||
emit('close', e);
|
||||
if (closeFunc && isFunction(closeFunc)) {
|
||||
const res = await closeFunc();
|
||||
res && (visibleRef.value = false);
|
||||
return;
|
||||
}
|
||||
visibleRef.value = false;
|
||||
}
|
||||
|
||||
function setDrawerProps(props: Partial<DrawerProps>): void {
|
||||
// 保留上一次的setDrawerProps
|
||||
propsRef.value = deepMerge(unref(propsRef) || {}, props);
|
||||
if (Reflect.has(props, 'visible')) {
|
||||
visibleRef.value = !!props.visible;
|
||||
}
|
||||
}
|
||||
|
||||
// 底部按钮自定义实现,
|
||||
const getFooterHeight = computed(() => {
|
||||
const { footerHeight, showFooter }: DrawerProps = unref(getProps);
|
||||
if (showFooter && footerHeight) {
|
||||
return isNumber(footerHeight) ? `${footerHeight}px` : `${footerHeight.replace('px', '')}px`;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
function renderFooter() {
|
||||
const {
|
||||
showCancelBtn,
|
||||
cancelButtonProps,
|
||||
cancelText,
|
||||
showOkBtn,
|
||||
okType,
|
||||
okText,
|
||||
okButtonProps,
|
||||
confirmLoading,
|
||||
showFooter,
|
||||
}: DrawerProps = unref(getProps);
|
||||
|
||||
return (
|
||||
getSlot(slots, 'footer') ||
|
||||
(showFooter && (
|
||||
<div class={`${prefixCls}__footer`}>
|
||||
{getSlot(slots, 'insertFooter')}
|
||||
|
||||
{showCancelBtn && (
|
||||
<Button {...cancelButtonProps} onClick={onClose} class="mr-2">
|
||||
{() => cancelText}
|
||||
</Button>
|
||||
)}
|
||||
{getSlot(slots, 'centerFooter')}
|
||||
{showOkBtn && (
|
||||
<Button
|
||||
type={okType}
|
||||
{...okButtonProps}
|
||||
loading={confirmLoading}
|
||||
onClick={() => {
|
||||
emit('ok');
|
||||
}}
|
||||
>
|
||||
{() => okText}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{getSlot(slots, 'appendFooter')}
|
||||
</div>
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
function renderHeader() {
|
||||
const { title } = unref(getMergeProps);
|
||||
return props.drawerType === DrawerType.DETAIL ? (
|
||||
getSlot(slots, 'title') || (
|
||||
<Row type="flex" align="middle" class={`${prefixCls}__detail-header`}>
|
||||
{() => (
|
||||
<>
|
||||
{props.showDetailBack && (
|
||||
<Col class="mx-2">
|
||||
{() => (
|
||||
<Button size="small" type="link" onClick={onClose}>
|
||||
{() => <LeftOutlined />}
|
||||
</Button>
|
||||
)}
|
||||
</Col>
|
||||
)}
|
||||
{title && (
|
||||
<Col style="flex:1" class={[`${prefixCls}__detail-title`, 'ellipsis', 'px-2']}>
|
||||
{() => title}
|
||||
</Col>
|
||||
)}
|
||||
{getSlot(slots, 'titleToolbar')}
|
||||
</>
|
||||
)}
|
||||
</Row>
|
||||
)
|
||||
) : (
|
||||
<BasicTitle>{() => title || getSlot(slots, 'title')}</BasicTitle>
|
||||
);
|
||||
}
|
||||
|
||||
const currentInstance = getCurrentInstance() as any;
|
||||
if (getCurrentInstance()) {
|
||||
currentInstance.scrollBottom = scrollBottom;
|
||||
currentInstance.scrollTo = scrollTo;
|
||||
currentInstance.getScrollWrap = getScrollWrap;
|
||||
}
|
||||
const drawerInstance: DrawerInstance = {
|
||||
setDrawerProps: setDrawerProps,
|
||||
};
|
||||
|
||||
const uuid = buildUUID();
|
||||
emit('register', drawerInstance, uuid);
|
||||
|
||||
return () => {
|
||||
const footerHeight = unref(getFooterHeight);
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
class={prefixCls}
|
||||
onClose={onClose}
|
||||
{...{
|
||||
...attrs,
|
||||
...unref(getProps),
|
||||
}}
|
||||
>
|
||||
{{
|
||||
title: () => renderHeader(),
|
||||
default: () => (
|
||||
<>
|
||||
<FullLoading
|
||||
absolute
|
||||
class={[!unref(getProps).loading ? 'hidden' : '']}
|
||||
tip="加载中..."
|
||||
/>
|
||||
<ScrollContainer
|
||||
ref={scrollRef}
|
||||
{...{ ...attrs, ...unref(getScrollOptions) }}
|
||||
style={{
|
||||
height: `calc(100% - ${footerHeight})`,
|
||||
}}
|
||||
>
|
||||
{() => getSlot(slots, 'default')}
|
||||
</ScrollContainer>
|
||||
{renderFooter()}
|
||||
</>
|
||||
),
|
||||
}}
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
},
|
||||
});
|
63
src/components/Drawer/src/index.less
Normal file
63
src/components/Drawer/src/index.less
Normal file
@@ -0,0 +1,63 @@
|
||||
@import (reference) '../../../design/index.less';
|
||||
@header-height: 50px;
|
||||
@footer-height: 60px;
|
||||
|
||||
.basic-drawer {
|
||||
.ant-drawer-wrapper-body {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ant-drawer-close {
|
||||
&:hover {
|
||||
color: @error-color;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-drawer-body {
|
||||
height: calc(100% - @header-height);
|
||||
padding: 0;
|
||||
background-color: @background-color-dark;
|
||||
|
||||
.scrollbar__wrap {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
&__detail {
|
||||
position: absolute;
|
||||
|
||||
&-header {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.ant-drawer-header {
|
||||
width: 100%;
|
||||
height: @header-height;
|
||||
padding: 0;
|
||||
border-top: 1px solid @border-color-base;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.ant-drawer-title {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.ant-drawer-close {
|
||||
height: @header-height;
|
||||
line-height: @header-height;
|
||||
}
|
||||
|
||||
.scrollbar__wrap {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__footer {
|
||||
height: @footer-height;
|
||||
padding: 0 26px;
|
||||
line-height: @footer-height;
|
||||
text-align: right;
|
||||
background: #fff;
|
||||
border-top: 1px solid @border-color-base;
|
||||
}
|
||||
}
|
85
src/components/Drawer/src/props.ts
Normal file
85
src/components/Drawer/src/props.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import type { PropType } from 'vue';
|
||||
import { DrawerType } from './types';
|
||||
// import {DrawerProps} from './types'
|
||||
export const footerProps = {
|
||||
confirmLoading: Boolean as PropType<boolean>,
|
||||
/**
|
||||
* @description: 显示关闭按钮
|
||||
*/
|
||||
showCancelBtn: {
|
||||
type: Boolean as PropType<boolean>,
|
||||
default: true,
|
||||
},
|
||||
cancelButtonProps: Object as PropType<any>,
|
||||
cancelText: {
|
||||
type: String as PropType<string>,
|
||||
default: '关闭',
|
||||
},
|
||||
/**
|
||||
* @description: 显示确认按钮
|
||||
*/
|
||||
showOkBtn: {
|
||||
type: Boolean as PropType<boolean>,
|
||||
default: true,
|
||||
},
|
||||
okButtonProps: Object as PropType<any>,
|
||||
okText: {
|
||||
type: String as PropType<string>,
|
||||
default: '保存',
|
||||
},
|
||||
okType: {
|
||||
type: String as PropType<string>,
|
||||
default: 'primary',
|
||||
},
|
||||
showFooter: {
|
||||
type: Boolean as PropType<boolean>,
|
||||
default: false,
|
||||
},
|
||||
footerHeight: {
|
||||
type: [String, Number] as PropType<string | number>,
|
||||
default: 60,
|
||||
},
|
||||
};
|
||||
export const basicProps = {
|
||||
drawerType: {
|
||||
type: Number as PropType<number>,
|
||||
default: DrawerType.DEFAULT,
|
||||
},
|
||||
title: {
|
||||
type: String as PropType<string>,
|
||||
default: '',
|
||||
},
|
||||
showDetailBack: {
|
||||
type: Boolean as PropType<boolean>,
|
||||
default: true,
|
||||
},
|
||||
visible: {
|
||||
type: Boolean as PropType<boolean>,
|
||||
default: false,
|
||||
},
|
||||
loading: {
|
||||
type: Boolean as PropType<boolean>,
|
||||
default: false,
|
||||
},
|
||||
maskClosable: {
|
||||
type: Boolean as PropType<boolean>,
|
||||
default: true,
|
||||
},
|
||||
getContainer: {
|
||||
type: [Object, String] as PropType<any>,
|
||||
},
|
||||
scrollOptions: {
|
||||
type: Object as PropType<any>,
|
||||
default: null,
|
||||
},
|
||||
closeFunc: {
|
||||
type: [Function, Object] as PropType<any>,
|
||||
default: null,
|
||||
},
|
||||
triggerWindowResize: {
|
||||
type: Boolean as PropType<boolean>,
|
||||
default: false,
|
||||
},
|
||||
destroyOnClose: Boolean as PropType<boolean>,
|
||||
...footerProps,
|
||||
};
|
194
src/components/Drawer/src/types.ts
Normal file
194
src/components/Drawer/src/types.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
import type { Button } from 'ant-design-vue/types/button/button';
|
||||
import type { CSSProperties, VNodeChild } from 'vue';
|
||||
import type { ScrollContainerOptions } from '/@/components/Container/index';
|
||||
|
||||
export interface DrawerInstance {
|
||||
setDrawerProps: (props: Partial<DrawerProps> | boolean) => void;
|
||||
}
|
||||
export interface ReturnMethods extends DrawerInstance {
|
||||
openDrawer: (visible?: boolean) => void;
|
||||
transferDrawerData: (data: any) => void;
|
||||
}
|
||||
export type RegisterFn = (drawerInstance: DrawerInstance, uuid?: string) => void;
|
||||
|
||||
export interface ReturnInnerMethods extends DrawerInstance {
|
||||
closeDrawer: () => void;
|
||||
changeLoading: (loading: boolean) => void;
|
||||
changeOkLoading: (loading: boolean) => void;
|
||||
receiveDrawerDataRef: any;
|
||||
}
|
||||
|
||||
export type UseDrawerReturnType = [RegisterFn, ReturnMethods];
|
||||
export type UseDrawerInnerReturnType = [RegisterFn, ReturnInnerMethods];
|
||||
export enum DrawerType {
|
||||
DETAIL,
|
||||
DEFAULT,
|
||||
}
|
||||
|
||||
export interface DrawerFooterProps {
|
||||
showOkBtn: boolean;
|
||||
showCancelBtn: boolean;
|
||||
/**
|
||||
* Text of the Cancel button
|
||||
* @default 'cancel'
|
||||
* @type string
|
||||
*/
|
||||
cancelText: string;
|
||||
/**
|
||||
* Text of the OK button
|
||||
* @default 'OK'
|
||||
* @type string
|
||||
*/
|
||||
okText: string;
|
||||
|
||||
/**
|
||||
* Button type of the OK button
|
||||
* @default 'primary'
|
||||
* @type string
|
||||
*/
|
||||
okType: 'primary' | 'danger' | 'dashed' | 'ghost' | 'default';
|
||||
/**
|
||||
* The ok button props, follow jsx rules
|
||||
* @type object
|
||||
*/
|
||||
okButtonProps: { props: Button; on: {} };
|
||||
|
||||
/**
|
||||
* The cancel button props, follow jsx rules
|
||||
* @type object
|
||||
*/
|
||||
cancelButtonProps: { props: Button; on: {} };
|
||||
/**
|
||||
* Whether to apply loading visual effect for OK button or not
|
||||
* @default false
|
||||
* @type boolean
|
||||
*/
|
||||
confirmLoading: boolean;
|
||||
|
||||
showFooter: boolean;
|
||||
footerHeight: string | number;
|
||||
}
|
||||
export interface DrawerProps extends DrawerFooterProps {
|
||||
drawerType: DrawerType;
|
||||
loading?: boolean;
|
||||
showDetailBack?: boolean;
|
||||
visible?: boolean;
|
||||
/**
|
||||
* 内置的ScrollContainer组件配置
|
||||
* @type ScrollContainerOptions
|
||||
*/
|
||||
scrollOptions?: ScrollContainerOptions;
|
||||
closeFunc?: () => Promise<void>;
|
||||
triggerWindowResize?: boolean;
|
||||
/**
|
||||
* Whether a close (x) button is visible on top right of the Drawer dialog or not.
|
||||
* @default true
|
||||
* @type boolean
|
||||
*/
|
||||
closable?: boolean;
|
||||
|
||||
/**
|
||||
* Whether to unmount child components on closing drawer or not.
|
||||
* @default false
|
||||
* @type boolean
|
||||
*/
|
||||
destroyOnClose?: boolean;
|
||||
|
||||
/**
|
||||
* Return the mounted node for Drawer.
|
||||
* @default 'body'
|
||||
* @type any ( HTMLElement| () => HTMLElement | string)
|
||||
*/
|
||||
getContainer?: () => HTMLElement | string;
|
||||
|
||||
/**
|
||||
* Whether to show mask or not.
|
||||
* @default true
|
||||
* @type boolean
|
||||
*/
|
||||
mask?: boolean;
|
||||
|
||||
/**
|
||||
* Clicking on the mask (area outside the Drawer) to close the Drawer or not.
|
||||
* @default true
|
||||
* @type boolean
|
||||
*/
|
||||
maskClosable?: boolean;
|
||||
|
||||
/**
|
||||
* Style for Drawer's mask element.
|
||||
* @default {}
|
||||
* @type object
|
||||
*/
|
||||
maskStyle?: CSSProperties;
|
||||
|
||||
/**
|
||||
* The title for Drawer.
|
||||
* @type any (string | slot)
|
||||
*/
|
||||
title?: VNodeChild | JSX.Element;
|
||||
|
||||
/**
|
||||
* The class name of the container of the Drawer dialog.
|
||||
* @type string
|
||||
*/
|
||||
wrapClassName?: string;
|
||||
|
||||
/**
|
||||
* Style of wrapper element which **contains mask** compare to `drawerStyle`
|
||||
* @type object
|
||||
*/
|
||||
wrapStyle?: CSSProperties;
|
||||
|
||||
/**
|
||||
* Style of the popup layer element
|
||||
* @type object
|
||||
*/
|
||||
drawerStyle?: CSSProperties;
|
||||
|
||||
/**
|
||||
* Style of floating layer, typically used for adjusting its position.
|
||||
* @type object
|
||||
*/
|
||||
bodyStyle?: CSSProperties;
|
||||
headerStyle?: CSSProperties;
|
||||
|
||||
/**
|
||||
* Width of the Drawer dialog.
|
||||
* @default 256
|
||||
* @type string | number
|
||||
*/
|
||||
width?: string | number;
|
||||
|
||||
/**
|
||||
* placement is top or bottom, height of the Drawer dialog.
|
||||
* @type string | number
|
||||
*/
|
||||
height?: string | number;
|
||||
|
||||
/**
|
||||
* The z-index of the Drawer.
|
||||
* @default 1000
|
||||
* @type number
|
||||
*/
|
||||
zIndex?: number;
|
||||
|
||||
/**
|
||||
* The placement of the Drawer.
|
||||
* @default 'right'
|
||||
* @type string
|
||||
*/
|
||||
placement?: 'top' | 'right' | 'bottom' | 'left';
|
||||
afterVisibleChange?: (visible?: boolean) => void;
|
||||
keyboard?: boolean;
|
||||
|
||||
/**
|
||||
* Specify a callback that will be called when a user clicks mask, close button or Cancel button.
|
||||
*/
|
||||
onClose?: (e?: Event) => void;
|
||||
}
|
||||
export interface DrawerActionType {
|
||||
scrollBottom: () => void;
|
||||
scrollTo: (to: number) => void;
|
||||
getScrollWrap: () => Element | null;
|
||||
}
|
100
src/components/Drawer/src/useDrawer.ts
Normal file
100
src/components/Drawer/src/useDrawer.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import type {
|
||||
UseDrawerReturnType,
|
||||
DrawerInstance,
|
||||
ReturnMethods,
|
||||
DrawerProps,
|
||||
UseDrawerInnerReturnType,
|
||||
} from './types';
|
||||
import { ref, getCurrentInstance, onUnmounted, unref, reactive, computed } from 'vue';
|
||||
import { isProdMode } from '/@/utils/env';
|
||||
|
||||
const dataTransferRef = reactive<any>({});
|
||||
/**
|
||||
* @description: 适用于将drawer独立出去,外面调用
|
||||
*/
|
||||
export function useDrawer(): UseDrawerReturnType {
|
||||
if (!getCurrentInstance()) {
|
||||
throw new Error('Please put useDrawer function in the setup function!');
|
||||
}
|
||||
const drawerRef = ref<DrawerInstance | null>(null);
|
||||
const loadedRef = ref<boolean | null>(false);
|
||||
const uidRef = ref<string>('');
|
||||
|
||||
function getDrawer(drawerInstance: DrawerInstance, uuid: string) {
|
||||
uidRef.value = uuid;
|
||||
isProdMode() &&
|
||||
onUnmounted(() => {
|
||||
drawerRef.value = null;
|
||||
loadedRef.value = null;
|
||||
dataTransferRef[unref(uidRef)] = null;
|
||||
});
|
||||
if (unref(loadedRef) && isProdMode() && drawerInstance === unref(drawerRef)) {
|
||||
return;
|
||||
}
|
||||
drawerRef.value = drawerInstance;
|
||||
loadedRef.value = true;
|
||||
}
|
||||
const getInstance = () => {
|
||||
const instance = unref(drawerRef);
|
||||
if (!instance) {
|
||||
throw new Error('instance is undefined!');
|
||||
}
|
||||
return instance;
|
||||
};
|
||||
const methods: ReturnMethods = {
|
||||
setDrawerProps: (props: Partial<DrawerProps>): void => {
|
||||
getInstance().setDrawerProps(props);
|
||||
},
|
||||
openDrawer: (visible = true): void => {
|
||||
getInstance().setDrawerProps({
|
||||
visible: visible,
|
||||
});
|
||||
},
|
||||
transferDrawerData(val: any) {
|
||||
dataTransferRef[unref(uidRef)] = val;
|
||||
},
|
||||
};
|
||||
|
||||
return [getDrawer, methods];
|
||||
}
|
||||
export const useDrawerInner = (): UseDrawerInnerReturnType => {
|
||||
const drawerInstanceRef = ref<DrawerInstance | null>(null);
|
||||
const currentInstall = getCurrentInstance();
|
||||
const uidRef = ref<string>('');
|
||||
|
||||
if (!currentInstall) {
|
||||
throw new Error('instance is undefined!');
|
||||
}
|
||||
const getInstance = () => {
|
||||
const instance = unref(drawerInstanceRef);
|
||||
if (!instance) {
|
||||
throw new Error('instance is undefined!');
|
||||
}
|
||||
return instance;
|
||||
};
|
||||
const register = (modalInstance: DrawerInstance, uuid: string) => {
|
||||
uidRef.value = uuid;
|
||||
drawerInstanceRef.value = modalInstance;
|
||||
currentInstall.emit('register', modalInstance);
|
||||
};
|
||||
return [
|
||||
register,
|
||||
{
|
||||
receiveDrawerDataRef: computed(() => {
|
||||
return dataTransferRef[unref(uidRef)];
|
||||
}),
|
||||
changeLoading: (loading = true) => {
|
||||
getInstance().setDrawerProps({ loading });
|
||||
},
|
||||
changeOkLoading: (loading = true) => {
|
||||
getInstance().setDrawerProps({ confirmLoading: loading });
|
||||
},
|
||||
closeDrawer: () => {
|
||||
getInstance().setDrawerProps({ visible: false });
|
||||
},
|
||||
setDrawerProps: (props: Partial<DrawerProps>) => {
|
||||
getInstance().setDrawerProps(props);
|
||||
},
|
||||
},
|
||||
];
|
||||
};
|
55
src/components/Dropdown/Dropdown.tsx
Normal file
55
src/components/Dropdown/Dropdown.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { defineComponent, computed, unref } from 'vue';
|
||||
import { Dropdown, Menu } from 'ant-design-vue';
|
||||
|
||||
import Icon from '/@/components/Icon/index';
|
||||
|
||||
import { basicDropdownProps } from './props';
|
||||
import { getSlot } from '/@/utils/helper/tsxHelper';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'Dropdown',
|
||||
props: basicDropdownProps,
|
||||
setup(props, { slots, emit, attrs }) {
|
||||
const getMenuList = computed(() => props.dropMenuList);
|
||||
|
||||
function handleClickMenu({ key }: any) {
|
||||
const menu = unref(getMenuList)[key];
|
||||
emit('menuEvent', menu);
|
||||
}
|
||||
|
||||
function renderMenus() {
|
||||
return (
|
||||
<Menu onClick={handleClickMenu}>
|
||||
{() => (
|
||||
<>
|
||||
{unref(getMenuList).map((item, index) => {
|
||||
const { disabled, icon, text, divider } = item;
|
||||
|
||||
return [
|
||||
<Menu.Item key={`${index}`} disabled={disabled}>
|
||||
{() => (
|
||||
<>
|
||||
{icon && <Icon icon={icon} />}
|
||||
<span class="ml-1">{text}</span>
|
||||
</>
|
||||
)}
|
||||
</Menu.Item>,
|
||||
divider && <Menu.Divider key={`d-${index}`} />,
|
||||
];
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
|
||||
return () => (
|
||||
<Dropdown trigger={props.trigger as any} {...attrs}>
|
||||
{{
|
||||
default: () => <span>{getSlot(slots)}</span>,
|
||||
overlay: () => renderMenus(),
|
||||
}}
|
||||
</Dropdown>
|
||||
);
|
||||
},
|
||||
});
|
2
src/components/Dropdown/index.ts
Normal file
2
src/components/Dropdown/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as Dropdown } from './Dropdown';
|
||||
export * from './types';
|
69
src/components/Dropdown/props.ts
Normal file
69
src/components/Dropdown/props.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import type { PropType } from 'vue';
|
||||
/**
|
||||
* @description: 基础表格参数配置
|
||||
*/
|
||||
export const dropdownProps = {
|
||||
/**
|
||||
* the trigger mode which executes the drop-down action
|
||||
* @default ['hover']
|
||||
* @type string[]
|
||||
*/
|
||||
trigger: {
|
||||
type: [Array] as PropType<string[]>,
|
||||
default: () => {
|
||||
return ['contextmenu'];
|
||||
},
|
||||
},
|
||||
|
||||
// /**
|
||||
// * the dropdown menu
|
||||
// * @type () => Menu
|
||||
// */
|
||||
// overlay: {
|
||||
// type: null,
|
||||
// },
|
||||
|
||||
// /**
|
||||
// * Class name of the dropdown root element
|
||||
// * @type string
|
||||
// */
|
||||
// overlayClassName: String,
|
||||
|
||||
// /**
|
||||
// * Style of the dropdown root element
|
||||
// * @type object
|
||||
// */
|
||||
// overlayStyle: Object,
|
||||
|
||||
// /**
|
||||
// * whether the dropdown menu is visible
|
||||
// * @type boolean
|
||||
// */
|
||||
// visible: Boolean,
|
||||
|
||||
// /**
|
||||
// * whether the dropdown menu is disabled
|
||||
// * @type boolean
|
||||
// */
|
||||
// disabled: Boolean,
|
||||
|
||||
// /**
|
||||
// * to set the ontainer of the dropdown menu. The default is to create a div element in body, you can reset it to the scrolling area and make a relative reposition.
|
||||
// * @default () => document.body
|
||||
// * @type Function
|
||||
// */
|
||||
// getPopupContainer: Function,
|
||||
|
||||
// /**
|
||||
// * placement of pop menu: bottomLeft bottomCenter bottomRight topLeft topCenter topRight
|
||||
// * @default 'bottomLeft'
|
||||
// * @type string
|
||||
// */
|
||||
// placement: String,
|
||||
};
|
||||
export const basicDropdownProps = Object.assign({}, dropdownProps, {
|
||||
dropMenuList: {
|
||||
type: Array as PropType<any[]>,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
8
src/components/Dropdown/types.ts
Normal file
8
src/components/Dropdown/types.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export interface DropMenu {
|
||||
to?: string;
|
||||
icon?: string;
|
||||
event: string | number;
|
||||
text: string;
|
||||
disabled?: boolean;
|
||||
divider?: boolean;
|
||||
}
|
7
src/components/Form/index.ts
Normal file
7
src/components/Form/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export { default as BasicForm } from './src/BasicForm.vue';
|
||||
|
||||
export * from './src/types/form';
|
||||
export * from './src/types/formItem';
|
||||
|
||||
export { useComponentRegister } from './src/hooks/useComponentRegister';
|
||||
export { useForm } from './src/hooks/useForm';
|
463
src/components/Form/src/BasicForm.vue
Normal file
463
src/components/Form/src/BasicForm.vue
Normal file
@@ -0,0 +1,463 @@
|
||||
<template>
|
||||
<Form v-bind="$attrs" ref="formElRef" :model="formModel">
|
||||
<Row :class="getProps.compact ? 'compact-form-row' : ''">
|
||||
<slot name="formHeader" />
|
||||
<template v-for="schema in getSchema" :key="schema.field">
|
||||
<FormItem
|
||||
:schema="schema"
|
||||
:formProps="getProps"
|
||||
:allDefaultValues="getAllDefaultValues"
|
||||
:formModel="formModel"
|
||||
>
|
||||
<template v-slot:[item] v-for="item in Object.keys($slots)">
|
||||
<slot :name="item" />
|
||||
</template>
|
||||
</FormItem>
|
||||
</template>
|
||||
<FormAction
|
||||
v-bind="{ ...getActionPropsRef, ...advanceState }"
|
||||
@toggle-advanced="handleToggleAdvanced"
|
||||
/>
|
||||
<slot name="formFooter" />
|
||||
</Row>
|
||||
</Form>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import type { FormActionType, FormProps, FormSchema } from './types/form';
|
||||
import type { Form as FormType, ValidateFields } from 'ant-design-vue/types/form/form';
|
||||
|
||||
import {
|
||||
defineComponent,
|
||||
reactive,
|
||||
ref,
|
||||
computed,
|
||||
unref,
|
||||
toRaw,
|
||||
watch,
|
||||
toRef,
|
||||
onMounted,
|
||||
} from 'vue';
|
||||
import { Form, Row } from 'ant-design-vue';
|
||||
import FormItem from './FormItem';
|
||||
import { basicProps } from './props';
|
||||
import { deepMerge, unique } from '/@/utils';
|
||||
import FormAction from './FormAction';
|
||||
|
||||
import { dateItemType } from './helper';
|
||||
import moment from 'moment';
|
||||
import { isArray, isBoolean, isFunction, isNumber, isObject, isString } from '/@/utils/is';
|
||||
import { cloneDeep } from 'lodash-es';
|
||||
import { useBreakpoint } from '/@/hooks/event/useBreakpoint';
|
||||
import { useThrottle } from '/@/hooks/core/useThrottle';
|
||||
import { useFormValues } from './hooks/useFormValues';
|
||||
import type { ColEx } from './types';
|
||||
import { NamePath } from 'ant-design-vue/types/form/form-item';
|
||||
const BASIC_COL_LEN = 24;
|
||||
|
||||
export default defineComponent({
|
||||
name: 'BasicForm',
|
||||
inheritAttrs: false,
|
||||
components: { FormItem, Form, Row, FormAction },
|
||||
props: basicProps,
|
||||
emits: ['advanced-change', 'reset', 'submit', 'register'],
|
||||
setup(props, { emit }) {
|
||||
let formModel = reactive({});
|
||||
const advanceState = reactive({
|
||||
isAdvanced: true,
|
||||
hideAdvanceBtn: false,
|
||||
isLoad: false,
|
||||
actionSpan: 6,
|
||||
});
|
||||
const propsRef = ref<Partial<FormProps>>({});
|
||||
const schemaRef = ref<FormSchema[] | null>(null);
|
||||
const formElRef = ref<Nullable<FormType>>(null);
|
||||
|
||||
const getMergePropsRef = computed(
|
||||
(): FormProps => {
|
||||
return deepMerge(toRaw(props), unref(propsRef));
|
||||
}
|
||||
);
|
||||
// 获取表单基本配置
|
||||
const getProps = computed(
|
||||
(): FormProps => {
|
||||
const resetAction = {
|
||||
onClick: resetFields,
|
||||
};
|
||||
const submitAction = {
|
||||
onClick: handleSubmit,
|
||||
};
|
||||
return {
|
||||
...unref(getMergePropsRef),
|
||||
resetButtonOptions: deepMerge(
|
||||
resetAction,
|
||||
unref(getMergePropsRef).resetButtonOptions || {}
|
||||
) as any,
|
||||
submitButtonOptions: deepMerge(
|
||||
submitAction,
|
||||
unref(getMergePropsRef).submitButtonOptions || {}
|
||||
) as any,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
const getActionPropsRef = computed(() => {
|
||||
const {
|
||||
resetButtonOptions,
|
||||
submitButtonOptions,
|
||||
showActionButtonGroup,
|
||||
showResetButton,
|
||||
showSubmitButton,
|
||||
showAdvancedButton,
|
||||
actionColOptions,
|
||||
} = unref(getProps);
|
||||
return {
|
||||
resetButtonOptions,
|
||||
submitButtonOptions,
|
||||
show: showActionButtonGroup,
|
||||
showResetButton,
|
||||
showSubmitButton,
|
||||
showAdvancedButton,
|
||||
actionColOptions,
|
||||
};
|
||||
});
|
||||
|
||||
const getSchema = computed((): FormSchema[] => {
|
||||
const schemas: FormSchema[] = unref(schemaRef) || (unref(getProps).schemas as any);
|
||||
for (const schema of schemas) {
|
||||
const { defaultValue, component } = schema;
|
||||
if (defaultValue && dateItemType.includes(component!)) {
|
||||
schema.defaultValue = moment(defaultValue);
|
||||
}
|
||||
}
|
||||
return schemas as FormSchema[];
|
||||
});
|
||||
|
||||
const getAllDefaultValues = computed(() => {
|
||||
const schemas = unref(getSchema);
|
||||
const obj: any = {};
|
||||
schemas.forEach((item) => {
|
||||
if (item.defaultValue) {
|
||||
obj[item.field] = item.defaultValue;
|
||||
(formModel as any)[item.field] = item.defaultValue;
|
||||
}
|
||||
});
|
||||
return obj;
|
||||
});
|
||||
const getEmptySpanRef = computed((): number => {
|
||||
if (!advanceState.isAdvanced) {
|
||||
return 0;
|
||||
}
|
||||
const emptySpan = unref(getMergePropsRef).emptySpan || 0;
|
||||
|
||||
if (isNumber(emptySpan)) {
|
||||
return emptySpan;
|
||||
}
|
||||
if (isObject(emptySpan)) {
|
||||
const { span = 0 } = emptySpan;
|
||||
const screen = unref(screenRef) as string;
|
||||
|
||||
const screenSpan = (emptySpan as any)[screen.toLowerCase()];
|
||||
return screenSpan || span || 0;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
const { realWidthRef, screenEnum, screenRef } = useBreakpoint();
|
||||
const [throttleUpdateAdvanced] = useThrottle(updateAdvanced, 30, { immediate: true });
|
||||
watch(
|
||||
[() => unref(getSchema), () => advanceState.isAdvanced, () => unref(realWidthRef)],
|
||||
() => {
|
||||
const { showAdvancedButton } = unref(getProps);
|
||||
if (showAdvancedButton) {
|
||||
throttleUpdateAdvanced();
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
function updateAdvanced() {
|
||||
let itemColSum = 0;
|
||||
let realItemColSum = 0;
|
||||
for (const schema of unref(getSchema)) {
|
||||
const { show, colProps } = schema;
|
||||
let isShow = true;
|
||||
|
||||
if (isBoolean(show)) {
|
||||
isShow = show;
|
||||
}
|
||||
|
||||
if (isFunction(show)) {
|
||||
isShow = show({
|
||||
schema: schema,
|
||||
model: formModel,
|
||||
field: schema.field,
|
||||
values: {
|
||||
...getAllDefaultValues,
|
||||
...formModel,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (isShow && colProps) {
|
||||
const { itemColSum: sum, isAdvanced } = getAdvanced(colProps, itemColSum);
|
||||
|
||||
itemColSum = sum || 0;
|
||||
if (isAdvanced) {
|
||||
realItemColSum = itemColSum;
|
||||
}
|
||||
schema.isAdvanced = isAdvanced;
|
||||
}
|
||||
}
|
||||
advanceState.actionSpan = (realItemColSum % BASIC_COL_LEN) + unref(getEmptySpanRef);
|
||||
getAdvanced(props.actionColOptions || { span: BASIC_COL_LEN }, itemColSum, true);
|
||||
emit('advanced-change');
|
||||
}
|
||||
function getAdvanced(itemCol: Partial<ColEx>, itemColSum = 0, isLastAction = false) {
|
||||
const width = unref(realWidthRef);
|
||||
|
||||
const mdWidth =
|
||||
parseInt(itemCol.md as string) ||
|
||||
parseInt(itemCol.xs as string) ||
|
||||
parseInt(itemCol.sm as string) ||
|
||||
(itemCol.span as number) ||
|
||||
BASIC_COL_LEN;
|
||||
const lgWidth = parseInt(itemCol.lg as string) || mdWidth;
|
||||
const xlWidth = parseInt(itemCol.xl as string) || lgWidth;
|
||||
const xxlWidth = parseInt(itemCol.xxl as string) || xlWidth;
|
||||
if (width <= screenEnum.LG) {
|
||||
itemColSum += mdWidth;
|
||||
} else if (width < screenEnum.XL) {
|
||||
itemColSum += lgWidth;
|
||||
} else if (width < screenEnum.XXL) {
|
||||
itemColSum += xlWidth;
|
||||
} else {
|
||||
itemColSum += xxlWidth;
|
||||
}
|
||||
if (isLastAction) {
|
||||
advanceState.hideAdvanceBtn = false;
|
||||
if (itemColSum <= BASIC_COL_LEN * 2) {
|
||||
// 小于等于2行时,不显示收起展开按钮
|
||||
advanceState.hideAdvanceBtn = true;
|
||||
advanceState.isAdvanced = true;
|
||||
} else if (
|
||||
itemColSum > BASIC_COL_LEN * 2 &&
|
||||
itemColSum <= BASIC_COL_LEN * (props.autoAdvancedLine || 3)
|
||||
) {
|
||||
advanceState.hideAdvanceBtn = false;
|
||||
|
||||
// 大于3行默认收起
|
||||
} else if (!advanceState.isLoad) {
|
||||
advanceState.isLoad = true;
|
||||
advanceState.isAdvanced = !advanceState.isAdvanced;
|
||||
}
|
||||
return { isAdvanced: advanceState.isAdvanced, itemColSum };
|
||||
}
|
||||
if (itemColSum > BASIC_COL_LEN) {
|
||||
return { isAdvanced: advanceState.isAdvanced, itemColSum };
|
||||
} else {
|
||||
// 第一行始终显示
|
||||
return { isAdvanced: true, itemColSum };
|
||||
}
|
||||
}
|
||||
|
||||
async function resetFields(): Promise<any> {
|
||||
const { resetFunc } = unref(getProps);
|
||||
resetFunc && isFunction(resetFunc) && (await resetFunc());
|
||||
const formEl = unref(formElRef);
|
||||
if (!formEl) return;
|
||||
Object.keys(formModel).forEach((key) => {
|
||||
(formModel as any)[key] = undefined;
|
||||
});
|
||||
const values = formEl.resetFields();
|
||||
emit('reset', toRaw(formModel));
|
||||
return values;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description: 设置表单值
|
||||
*/
|
||||
async function setFieldsValue(values: any): Promise<void> {
|
||||
const fields = unref(getSchema)
|
||||
.map((item) => item.field)
|
||||
.filter(Boolean);
|
||||
const formEl = unref(formElRef);
|
||||
Object.keys(values).forEach((key) => {
|
||||
const element = values[key];
|
||||
if (fields.includes(key) && element !== undefined && element !== null) {
|
||||
// 时间
|
||||
(formModel as any)[key] = itemIsDateType(key) ? moment(element) : element;
|
||||
if (formEl) {
|
||||
formEl.validateFields([key]);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @description: 表单提交
|
||||
*/
|
||||
async function handleSubmit(e?: Event): Promise<void> {
|
||||
e && e.preventDefault();
|
||||
const { submitFunc } = unref(getProps);
|
||||
if (submitFunc && isFunction(submitFunc)) {
|
||||
await submitFunc();
|
||||
return;
|
||||
}
|
||||
const formEl = unref(formElRef);
|
||||
if (!formEl) return;
|
||||
try {
|
||||
const values = await formEl.validate();
|
||||
const res = handleFormValues(values);
|
||||
emit('submit', res);
|
||||
} catch (error) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* @description: 根据字段名删除
|
||||
*/
|
||||
function removeSchemaByFiled(fields: string | string[]): void {
|
||||
const schemaList: FormSchema[] = cloneDeep(unref(getSchema));
|
||||
if (!fields) {
|
||||
return;
|
||||
}
|
||||
let fieldList: string[] = fields as string[];
|
||||
if (isString(fields)) {
|
||||
fieldList = [fields];
|
||||
}
|
||||
for (const field of fieldList) {
|
||||
_removeSchemaByFiled(field, schemaList);
|
||||
}
|
||||
schemaRef.value = schemaList as any;
|
||||
}
|
||||
/**
|
||||
* @description: 根据字段名删除
|
||||
*/
|
||||
function _removeSchemaByFiled(field: string, schemaList: FormSchema[]): void {
|
||||
if (isString(field)) {
|
||||
const index = schemaList.findIndex((schema) => schema.field === field);
|
||||
if (index !== -1) {
|
||||
schemaList.splice(index, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
/**
|
||||
* @description: 往某个字段后面插入,如果没有插入最后一个
|
||||
*/
|
||||
function appendSchemaByField(schema: FormSchema, prefixField?: string) {
|
||||
const schemaList: FormSchema[] = cloneDeep(unref(getSchema));
|
||||
|
||||
const index = schemaList.findIndex((schema) => schema.field === prefixField);
|
||||
const hasInList = schemaList.find((item) => item.field === schema.field);
|
||||
|
||||
if (hasInList) {
|
||||
return;
|
||||
}
|
||||
if (!prefixField || index === -1) {
|
||||
schemaList.push(schema);
|
||||
schemaRef.value = schemaList as any;
|
||||
return;
|
||||
}
|
||||
if (index !== -1) {
|
||||
schemaList.splice(index + 1, 0, schema);
|
||||
}
|
||||
schemaRef.value = schemaList as any;
|
||||
}
|
||||
|
||||
function updateSchema(data: Partial<FormSchema> | Partial<FormSchema>[]) {
|
||||
let updateData: Partial<FormSchema>[] = [];
|
||||
if (isObject(data)) {
|
||||
updateData.push(data as FormSchema);
|
||||
}
|
||||
if (isArray(data)) {
|
||||
updateData = [...data];
|
||||
}
|
||||
const hasField = updateData.every((item) => Reflect.has(item, 'field') && item.field);
|
||||
if (!hasField) {
|
||||
throw new Error('Must pass in the `field` field!');
|
||||
}
|
||||
const schema: FormSchema[] = [];
|
||||
updateData.forEach((item) => {
|
||||
unref(getSchema).forEach((val) => {
|
||||
if (val.field === item.field) {
|
||||
const newScheam = deepMerge(val, item);
|
||||
schema.push(newScheam as FormSchema);
|
||||
} else {
|
||||
schema.push(val);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
schemaRef.value = unique(schema, 'field') as any;
|
||||
}
|
||||
|
||||
function handleToggleAdvanced() {
|
||||
advanceState.isAdvanced = !advanceState.isAdvanced;
|
||||
}
|
||||
|
||||
const handleFormValues = useFormValues(
|
||||
toRef(props, 'transformDateFunc'),
|
||||
toRef(props, 'fieldMapToTime')
|
||||
);
|
||||
function getFieldsValue(): any {
|
||||
const formEl = unref(formElRef);
|
||||
if (!formEl) return;
|
||||
return handleFormValues(toRaw(unref(formModel)));
|
||||
}
|
||||
|
||||
/**
|
||||
* @description: 是否是时间
|
||||
*/
|
||||
function itemIsDateType(key: string) {
|
||||
return unref(getSchema).some((item) => {
|
||||
return item.field === key ? dateItemType.includes(item.component!) : false;
|
||||
});
|
||||
}
|
||||
/**
|
||||
* @description:设置表单
|
||||
*/
|
||||
function setProps(formProps: Partial<FormProps>): void {
|
||||
const mergeProps = deepMerge(unref(propsRef) || {}, formProps);
|
||||
propsRef.value = mergeProps;
|
||||
}
|
||||
|
||||
function validateFields(nameList?: NamePath[] | undefined) {
|
||||
if (!formElRef.value) return;
|
||||
return formElRef.value.validateFields(nameList);
|
||||
}
|
||||
function validate(nameList?: NamePath[] | undefined) {
|
||||
if (!formElRef.value) return;
|
||||
return formElRef.value.validate(nameList);
|
||||
}
|
||||
|
||||
function clearValidate(name: string | string[]) {
|
||||
if (!formElRef.value) return;
|
||||
formElRef.value.clearValidate(name);
|
||||
}
|
||||
|
||||
const methods: Partial<FormActionType> = {
|
||||
getFieldsValue,
|
||||
setFieldsValue,
|
||||
resetFields,
|
||||
updateSchema,
|
||||
setProps,
|
||||
removeSchemaByFiled,
|
||||
appendSchemaByField,
|
||||
clearValidate,
|
||||
validateFields: validateFields as ValidateFields,
|
||||
validate: validate as ValidateFields,
|
||||
};
|
||||
onMounted(() => {
|
||||
emit('register', methods);
|
||||
});
|
||||
return {
|
||||
handleToggleAdvanced,
|
||||
formModel,
|
||||
getActionPropsRef,
|
||||
getAllDefaultValues,
|
||||
advanceState,
|
||||
getProps,
|
||||
formElRef,
|
||||
getSchema,
|
||||
...methods,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
141
src/components/Form/src/FormAction.tsx
Normal file
141
src/components/Form/src/FormAction.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import { defineComponent, unref, computed, PropType } from 'vue';
|
||||
import { Form, Col } from 'ant-design-vue';
|
||||
import type { ColEx } from './types/index';
|
||||
import { getSlot } from '/@/utils/helper/tsxHelper';
|
||||
import Button from '/@/components/Button/index.vue';
|
||||
import { UpOutlined, DownOutlined } from '@ant-design/icons-vue';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'BasicFormAction',
|
||||
emits: ['toggle-advanced'],
|
||||
props: {
|
||||
show: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
showResetButton: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
showSubmitButton: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
showAdvancedButton: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
resetButtonOptions: {
|
||||
type: Object as PropType<any>,
|
||||
default: {},
|
||||
},
|
||||
submitButtonOptions: {
|
||||
type: Object as PropType<any>,
|
||||
default: {},
|
||||
},
|
||||
actionColOptions: {
|
||||
type: Object as PropType<any>,
|
||||
default: {},
|
||||
},
|
||||
actionSpan: {
|
||||
type: Number,
|
||||
default: 6,
|
||||
},
|
||||
isAdvanced: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
hideAdvanceBtn: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
setup(props, { slots, emit }) {
|
||||
const getResetBtnOptionsRef = computed(() => {
|
||||
return {
|
||||
text: '重置',
|
||||
...props.resetButtonOptions,
|
||||
};
|
||||
});
|
||||
const getSubmitBtnOptionsRef = computed(() => {
|
||||
return {
|
||||
text: '查询',
|
||||
// htmlType: 'submit',
|
||||
...props.submitButtonOptions,
|
||||
};
|
||||
});
|
||||
|
||||
const actionColOpt = computed(() => {
|
||||
const { showAdvancedButton, actionSpan: span, actionColOptions } = props;
|
||||
const actionSpan = 24 - span;
|
||||
const advancedSpanObj = showAdvancedButton ? { span: actionSpan < 6 ? 24 : actionSpan } : {};
|
||||
const actionColOpt: Partial<ColEx> = {
|
||||
span: showAdvancedButton ? 6 : 4,
|
||||
...actionColOptions,
|
||||
...advancedSpanObj,
|
||||
};
|
||||
return actionColOpt;
|
||||
});
|
||||
|
||||
function toggleAdvanced() {
|
||||
emit('toggle-advanced');
|
||||
}
|
||||
return () => {
|
||||
if (!props.show) {
|
||||
return;
|
||||
}
|
||||
const {
|
||||
showAdvancedButton,
|
||||
hideAdvanceBtn,
|
||||
isAdvanced,
|
||||
showResetButton,
|
||||
showSubmitButton,
|
||||
} = props;
|
||||
return (
|
||||
<>
|
||||
<Col {...unref(actionColOpt)} style={{ textAlign: 'right' }}>
|
||||
{() => (
|
||||
<Form.Item>
|
||||
{() => (
|
||||
<>
|
||||
{getSlot(slots, 'advanceBefore')}
|
||||
{showAdvancedButton && !hideAdvanceBtn && (
|
||||
<Button type="default" class="mr-2" onClick={toggleAdvanced}>
|
||||
{() => (
|
||||
<>
|
||||
{isAdvanced ? '收起' : '展开'}
|
||||
{isAdvanced ? (
|
||||
<UpOutlined class="advanced-icon" />
|
||||
) : (
|
||||
<DownOutlined class="advanced-icon" />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{getSlot(slots, 'resetBefore')}
|
||||
{showResetButton && (
|
||||
<Button type="default" class="mr-2" {...unref(getResetBtnOptionsRef)}>
|
||||
{() => unref(getResetBtnOptionsRef).text}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{getSlot(slots, 'submitBefore')}
|
||||
{showSubmitButton && (
|
||||
<Button type="primary" {...unref(getSubmitBtnOptionsRef)}>
|
||||
{() => unref(getSubmitBtnOptionsRef).text}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{getSlot(slots, 'submitAfter')}
|
||||
</>
|
||||
)}
|
||||
</Form.Item>
|
||||
)}
|
||||
</Col>
|
||||
</>
|
||||
);
|
||||
};
|
||||
},
|
||||
});
|
267
src/components/Form/src/FormItem.tsx
Normal file
267
src/components/Form/src/FormItem.tsx
Normal file
@@ -0,0 +1,267 @@
|
||||
import { defineComponent, computed, unref, toRef } from 'vue';
|
||||
import { Form, Col } from 'ant-design-vue';
|
||||
import { componentMap } from './componentMap';
|
||||
|
||||
import type { PropType } from 'vue';
|
||||
import type { FormProps } from './types/form';
|
||||
import type { FormSchema } from './types/form';
|
||||
import { isBoolean, isFunction } from '/@/utils/is';
|
||||
import { useItemLabelWidth } from './hooks/useLabelWidth';
|
||||
import { getSlot } from '/@/utils/helper/tsxHelper';
|
||||
import { BasicHelp } from '/@/components/Basic';
|
||||
import { createPlaceholderMessage } from './helper';
|
||||
import { upperFirst, cloneDeep } from 'lodash-es';
|
||||
import { ValidationRule } from 'ant-design-vue/types/form/form';
|
||||
export default defineComponent({
|
||||
name: 'BasicFormItem',
|
||||
inheritAttrs: false,
|
||||
props: {
|
||||
schema: {
|
||||
type: Object as PropType<FormSchema>,
|
||||
default: () => {},
|
||||
},
|
||||
formProps: {
|
||||
type: Object as PropType<FormProps>,
|
||||
default: {},
|
||||
},
|
||||
allDefaultValues: {
|
||||
type: Object as PropType<any>,
|
||||
default: {},
|
||||
},
|
||||
formModel: {
|
||||
type: Object as PropType<any>,
|
||||
default: {},
|
||||
},
|
||||
},
|
||||
setup(props, { slots }) {
|
||||
const itemLabelWidthRef = useItemLabelWidth(toRef(props, 'schema'), toRef(props, 'formProps'));
|
||||
|
||||
const getValuesRef = computed(() => {
|
||||
const { allDefaultValues, formModel, schema } = props;
|
||||
const { mergeDynamicData } = props.formProps;
|
||||
return {
|
||||
field: schema.field,
|
||||
model: formModel,
|
||||
values: {
|
||||
...mergeDynamicData,
|
||||
...allDefaultValues,
|
||||
...formModel,
|
||||
},
|
||||
schema: schema,
|
||||
};
|
||||
});
|
||||
const getShowRef = computed(() => {
|
||||
const { show, ifShow, isAdvanced } = props.schema;
|
||||
const { showAdvancedButton } = props.formProps;
|
||||
const itemIsAdvanced = showAdvancedButton ? !!isAdvanced : true;
|
||||
let isShow = true;
|
||||
let isIfShow = true;
|
||||
|
||||
if (isBoolean(show)) {
|
||||
isShow = show;
|
||||
}
|
||||
if (isBoolean(ifShow)) {
|
||||
isIfShow = ifShow;
|
||||
}
|
||||
if (isFunction(show)) {
|
||||
isShow = show(unref(getValuesRef));
|
||||
}
|
||||
if (isFunction(ifShow)) {
|
||||
isIfShow = ifShow(unref(getValuesRef));
|
||||
}
|
||||
isShow = isShow && itemIsAdvanced;
|
||||
return { isShow, isIfShow };
|
||||
});
|
||||
|
||||
const getDisableRef = computed(() => {
|
||||
const { disabled: globDisabled } = props.formProps;
|
||||
const { dynamicDisabled } = props.schema;
|
||||
let disabled = !!globDisabled;
|
||||
if (isBoolean(dynamicDisabled)) {
|
||||
disabled = dynamicDisabled;
|
||||
}
|
||||
|
||||
if (isFunction(dynamicDisabled)) {
|
||||
disabled = dynamicDisabled(unref(getValuesRef));
|
||||
}
|
||||
|
||||
return disabled;
|
||||
});
|
||||
|
||||
function handleRules(): ValidationRule[] {
|
||||
const {
|
||||
rules: defRules = [],
|
||||
component,
|
||||
rulesMessageJoinLabel,
|
||||
label,
|
||||
dynamicRules,
|
||||
} = props.schema;
|
||||
|
||||
if (isFunction(dynamicRules)) {
|
||||
return dynamicRules(unref(getValuesRef));
|
||||
}
|
||||
|
||||
const rules: ValidationRule[] = cloneDeep(defRules);
|
||||
const requiredRuleIndex: number = rules.findIndex(
|
||||
(rule) => Reflect.has(rule, 'required') && !Reflect.has(rule, 'validator')
|
||||
);
|
||||
const { rulesMessageJoinLabel: globalRulesMessageJoinLabel } = props.formProps;
|
||||
if (requiredRuleIndex !== -1) {
|
||||
const rule = rules[requiredRuleIndex];
|
||||
if (rule.required && component) {
|
||||
const joinLabel = Reflect.has(props.schema, 'rulesMessageJoinLabel')
|
||||
? rulesMessageJoinLabel
|
||||
: globalRulesMessageJoinLabel;
|
||||
rule.message =
|
||||
rule.message || createPlaceholderMessage(component) + `${joinLabel ? label : ''}`;
|
||||
if (component.includes('Input') || component.includes('Textarea')) {
|
||||
rule.whitespace = true;
|
||||
}
|
||||
if (
|
||||
component.includes('DatePicker') ||
|
||||
component.includes('MonthPicker') ||
|
||||
component.includes('WeekPicker') ||
|
||||
component.includes('TimePicker')
|
||||
) {
|
||||
rule.type = 'object';
|
||||
}
|
||||
if (component.includes('RangePicker')) {
|
||||
rule.type = 'array';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 最大输入长度规则校验
|
||||
const characterInx = rules.findIndex((val) => val.max);
|
||||
if (characterInx !== -1 && !rules[characterInx].validator) {
|
||||
rules[characterInx].message =
|
||||
rules[characterInx].message || `字符数应小于${rules[characterInx].max}位`;
|
||||
}
|
||||
return rules;
|
||||
}
|
||||
function renderComponent() {
|
||||
const {
|
||||
componentProps,
|
||||
renderComponentContent,
|
||||
component,
|
||||
field,
|
||||
changeEvent = 'change',
|
||||
} = props.schema;
|
||||
|
||||
const isCheck = component && ['Switch'].includes(component);
|
||||
|
||||
const eventKey = `on${upperFirst(changeEvent)}`;
|
||||
const on = {
|
||||
[eventKey]: (e: any) => {
|
||||
if (propsData[eventKey]) {
|
||||
propsData[eventKey](e);
|
||||
}
|
||||
if (e && e.target) {
|
||||
(props.formModel as any)[field] = e.target.value;
|
||||
} else {
|
||||
(props.formModel as any)[field] = e;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const Comp = componentMap.get(component);
|
||||
|
||||
const { autoSetPlaceHolder, size } = props.formProps;
|
||||
const propsData: any = {
|
||||
allowClear: true,
|
||||
getPopupContainer: (trigger: Element) => trigger.parentNode,
|
||||
size,
|
||||
...componentProps,
|
||||
disabled: unref(getDisableRef),
|
||||
};
|
||||
|
||||
const isCreatePlaceholder = !propsData.disabled && autoSetPlaceHolder;
|
||||
let placeholder;
|
||||
// RangePicker place为数组
|
||||
if (isCreatePlaceholder && component !== 'RangePicker' && component) {
|
||||
placeholder =
|
||||
(componentProps && componentProps.placeholder) || createPlaceholderMessage(component);
|
||||
}
|
||||
propsData.placeholder = placeholder;
|
||||
propsData.codeField = field;
|
||||
propsData.formValues = unref(getValuesRef);
|
||||
|
||||
const bindValue = {
|
||||
[isCheck ? 'checked' : 'value']: (props.formModel as any)[field],
|
||||
};
|
||||
return (
|
||||
<Comp {...propsData} {...on} {...bindValue}>
|
||||
{{
|
||||
...(renderComponentContent
|
||||
? renderComponentContent(unref(getValuesRef))
|
||||
: {
|
||||
default: () => '',
|
||||
}),
|
||||
}}
|
||||
</Comp>
|
||||
);
|
||||
}
|
||||
|
||||
function renderLabelHelpMessage() {
|
||||
const { label, helpMessage, helpComponentProps } = props.schema;
|
||||
if (!helpMessage || (Array.isArray(helpMessage) && helpMessage.length === 0)) {
|
||||
return label;
|
||||
}
|
||||
return (
|
||||
<span>
|
||||
{label}
|
||||
<BasicHelp class="mx-1" text={helpMessage} {...helpComponentProps} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
function renderItem() {
|
||||
const { itemProps, slot, render, field } = props.schema;
|
||||
const { labelCol, wrapperCol } = unref(itemLabelWidthRef);
|
||||
const { colon } = props.formProps;
|
||||
const getContent = () => {
|
||||
return slot
|
||||
? getSlot(slots, slot)
|
||||
: render
|
||||
? render(unref(getValuesRef))
|
||||
: renderComponent();
|
||||
};
|
||||
return (
|
||||
<Form.Item
|
||||
name={field}
|
||||
colon={colon}
|
||||
{...itemProps}
|
||||
label={renderLabelHelpMessage()}
|
||||
rules={handleRules()}
|
||||
labelCol={labelCol}
|
||||
wrapperCol={wrapperCol}
|
||||
>
|
||||
{() => getContent()}
|
||||
</Form.Item>
|
||||
);
|
||||
}
|
||||
return () => {
|
||||
const { colProps = {}, colSlot, renderColContent, component } = props.schema;
|
||||
if (!componentMap.has(component)) return null;
|
||||
const { baseColProps = {} } = props.formProps;
|
||||
|
||||
const realColProps = { ...baseColProps, ...colProps };
|
||||
|
||||
const { isIfShow, isShow } = unref(getShowRef);
|
||||
|
||||
const getContent = () => {
|
||||
return colSlot
|
||||
? getSlot(slots, colSlot)
|
||||
: renderColContent
|
||||
? renderColContent(unref(getValuesRef))
|
||||
: renderItem();
|
||||
};
|
||||
return (
|
||||
isIfShow && (
|
||||
<Col {...realColProps} class={!isShow ? 'hidden' : ''}>
|
||||
{() => getContent()}
|
||||
</Col>
|
||||
)
|
||||
);
|
||||
};
|
||||
},
|
||||
});
|
59
src/components/Form/src/componentMap.ts
Normal file
59
src/components/Form/src/componentMap.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { Component } from 'vue';
|
||||
/**
|
||||
* 组件列表,在这里注册才可以在表单使用
|
||||
*/
|
||||
import {
|
||||
Input,
|
||||
Select,
|
||||
Radio,
|
||||
Checkbox,
|
||||
AutoComplete,
|
||||
Cascader,
|
||||
DatePicker,
|
||||
InputNumber,
|
||||
Switch,
|
||||
TimePicker,
|
||||
TreeSelect,
|
||||
Transfer,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
import { ComponentType } from './types/index';
|
||||
|
||||
const componentMap = new Map<ComponentType, any>();
|
||||
|
||||
componentMap.set('Input', Input);
|
||||
componentMap.set('InputGroup', Input.Group);
|
||||
componentMap.set('InputPassword', Input.Password);
|
||||
componentMap.set('InputSearch', Input.Search);
|
||||
componentMap.set('InputTextArea', Input.TextArea);
|
||||
componentMap.set('InputNumber', InputNumber);
|
||||
componentMap.set('AutoComplete', AutoComplete);
|
||||
|
||||
componentMap.set('Select', Select);
|
||||
componentMap.set('SelectOptGroup', Select.OptGroup);
|
||||
componentMap.set('SelectOption', Select.Option);
|
||||
componentMap.set('TreeSelect', TreeSelect);
|
||||
componentMap.set('Transfer', Transfer);
|
||||
componentMap.set('Radio', Radio);
|
||||
componentMap.set('Switch', Switch);
|
||||
componentMap.set('RadioButton', Radio.Button);
|
||||
componentMap.set('RadioGroup', Radio.Group);
|
||||
componentMap.set('Checkbox', Checkbox);
|
||||
componentMap.set('CheckboxGroup', Checkbox.Group);
|
||||
componentMap.set('Cascader', Cascader);
|
||||
|
||||
componentMap.set('DatePicker', DatePicker);
|
||||
componentMap.set('MonthPicker', DatePicker.MonthPicker);
|
||||
componentMap.set('RangePicker', DatePicker.RangePicker);
|
||||
componentMap.set('WeekPicker', DatePicker.WeekPicker);
|
||||
componentMap.set('TimePicker', TimePicker);
|
||||
|
||||
export function add(compName: ComponentType, component: Component) {
|
||||
componentMap.set(compName, component);
|
||||
}
|
||||
|
||||
export function del(compName: ComponentType) {
|
||||
componentMap.delete(compName);
|
||||
}
|
||||
|
||||
export { componentMap };
|
30
src/components/Form/src/helper.ts
Normal file
30
src/components/Form/src/helper.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { ComponentType } from './types/index';
|
||||
/**
|
||||
* @description: 生成placeholder
|
||||
*/
|
||||
export function createPlaceholderMessage(component: ComponentType) {
|
||||
if (component.includes('Input') || component.includes('Complete')) {
|
||||
return '请输入';
|
||||
}
|
||||
if (component.includes('Picker') && !component.includes('Range')) {
|
||||
return '请选择';
|
||||
}
|
||||
if (
|
||||
component.includes('Select') ||
|
||||
component.includes('Cascader') ||
|
||||
component.includes('Checkbox') ||
|
||||
component.includes('Radio') ||
|
||||
component.includes('Switch')
|
||||
) {
|
||||
// return `请选择${label}`;
|
||||
return '请选择';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
function genType() {
|
||||
return ['DatePicker', 'MonthPicker', 'RangePicker', 'WeekPicker', 'TimePicker'];
|
||||
}
|
||||
/**
|
||||
* 时间字段
|
||||
*/
|
||||
export const dateItemType = genType();
|
10
src/components/Form/src/hooks/useComponentRegister.ts
Normal file
10
src/components/Form/src/hooks/useComponentRegister.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { tryOnUnmounted } from '/@/utils/helper/vueHelper';
|
||||
import { add, del } from '../componentMap';
|
||||
|
||||
import { ComponentType } from '../types/index';
|
||||
export function useComponentRegister(compName: ComponentType, comp: any) {
|
||||
add(compName, comp);
|
||||
tryOnUnmounted(() => {
|
||||
del(compName);
|
||||
});
|
||||
}
|
69
src/components/Form/src/hooks/useForm.ts
Normal file
69
src/components/Form/src/hooks/useForm.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { ref, onUnmounted, unref } from 'vue';
|
||||
|
||||
import { isInSetup } from '/@/utils/helper/vueHelper';
|
||||
|
||||
import type { FormProps, FormActionType, UseFormReturnType, FormSchema } from '../types/form';
|
||||
import { isProdMode } from '/@/utils/env';
|
||||
import type { NamePath } from 'ant-design-vue/types/form/form-item';
|
||||
import type { ValidateFields } from 'ant-design-vue/types/form/form';
|
||||
|
||||
export function useForm(props?: Partial<FormProps>): UseFormReturnType {
|
||||
isInSetup();
|
||||
const formRef = ref<FormActionType | null>(null);
|
||||
const loadedRef = ref<boolean | null>(false);
|
||||
function getForm() {
|
||||
const form = unref(formRef);
|
||||
if (!form) {
|
||||
throw new Error('formRef is Null');
|
||||
}
|
||||
return form as FormActionType;
|
||||
}
|
||||
function register(instance: FormActionType) {
|
||||
isProdMode() &&
|
||||
onUnmounted(() => {
|
||||
formRef.value = null;
|
||||
loadedRef.value = null;
|
||||
});
|
||||
if (unref(loadedRef) && isProdMode() && instance === unref(formRef)) return;
|
||||
formRef.value = instance;
|
||||
props && instance.setProps(props);
|
||||
loadedRef.value = true;
|
||||
}
|
||||
|
||||
const methods: FormActionType = {
|
||||
setProps: (formProps: Partial<FormProps>) => {
|
||||
getForm().setProps(formProps);
|
||||
},
|
||||
updateSchema: (data: Partial<FormSchema> | Partial<FormSchema>[]) => {
|
||||
getForm().updateSchema(data);
|
||||
},
|
||||
clearValidate: (name?: string | string[]) => {
|
||||
getForm().clearValidate(name);
|
||||
},
|
||||
resetFields: async () => {
|
||||
await getForm().resetFields();
|
||||
},
|
||||
removeSchemaByFiled: (field: string | string[]) => {
|
||||
getForm().removeSchemaByFiled(field);
|
||||
},
|
||||
getFieldsValue: () => {
|
||||
return getForm().getFieldsValue();
|
||||
},
|
||||
setFieldsValue: <T>(values: T) => {
|
||||
getForm().setFieldsValue<T>(values);
|
||||
},
|
||||
appendSchemaByField: (schema: FormSchema, prefixField?: string | undefined) => {
|
||||
getForm().appendSchemaByField(schema, prefixField);
|
||||
},
|
||||
submit: async (): Promise<any> => {
|
||||
return getForm().submit();
|
||||
},
|
||||
validate: ((async (nameList?: NamePath[]): Promise<any> => {
|
||||
return getForm().validate(nameList);
|
||||
}) as any) as ValidateFields,
|
||||
validateFields: ((async (nameList?: NamePath[]): Promise<any> => {
|
||||
return getForm().validate(nameList);
|
||||
}) as any) as ValidateFields,
|
||||
} as FormActionType;
|
||||
return [register, methods];
|
||||
}
|
62
src/components/Form/src/hooks/useFormValues.ts
Normal file
62
src/components/Form/src/hooks/useFormValues.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { isArray, isFunction, isObject, isString } from '/@/utils/is';
|
||||
import moment from 'moment';
|
||||
import { unref } from 'vue';
|
||||
import type { Ref } from 'vue';
|
||||
import type { FieldMapToTime } from '../types/form';
|
||||
|
||||
export function useFormValues(
|
||||
transformDateFuncRef: Ref<Fn>,
|
||||
fieldMapToTimeRef: Ref<FieldMapToTime>
|
||||
) {
|
||||
// 处理表单值
|
||||
function handleFormValues(values: any) {
|
||||
if (!isObject(values)) {
|
||||
return {};
|
||||
}
|
||||
const resMap: any = {};
|
||||
for (const item of Object.entries(values)) {
|
||||
let [, value] = item;
|
||||
const [key] = item;
|
||||
if ((isArray(value) && value.length === 0) || isFunction(value)) {
|
||||
continue;
|
||||
}
|
||||
const transformDateFunc = unref(transformDateFuncRef);
|
||||
if (isObject(value)) {
|
||||
value = transformDateFunc(value);
|
||||
}
|
||||
if (isArray(value) && value[0]._isAMomentObject && value[1]._isAMomentObject) {
|
||||
value = value.map((item) => transformDateFunc(item));
|
||||
}
|
||||
// 去除空格
|
||||
if (isString(value)) {
|
||||
value = value.trim();
|
||||
}
|
||||
resMap[key] = value;
|
||||
}
|
||||
return handleRangeTimeValue(resMap);
|
||||
}
|
||||
/**
|
||||
* @description: 处理时间区间参数
|
||||
*/
|
||||
function handleRangeTimeValue(values: any) {
|
||||
const fieldMapToTime = unref(fieldMapToTimeRef);
|
||||
|
||||
if (!fieldMapToTime || !Array.isArray(fieldMapToTime)) {
|
||||
return values;
|
||||
}
|
||||
|
||||
for (const [field, [startTimeKey, endTimeKey, format = 'YYYY-MM-DD']] of fieldMapToTime) {
|
||||
if (!field || !startTimeKey || !endTimeKey || !values[field]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const [startTime, endTime]: string[] = values[field];
|
||||
|
||||
values[startTimeKey] = moment(startTime).format(format);
|
||||
values[endTimeKey] = moment(endTime).format(format);
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
return handleFormValues;
|
||||
}
|
43
src/components/Form/src/hooks/useLabelWidth.ts
Normal file
43
src/components/Form/src/hooks/useLabelWidth.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { Ref } from 'vue';
|
||||
import type { FormProps, FormSchema } from '../types/form';
|
||||
|
||||
import { computed, unref } from 'vue';
|
||||
import { isNumber } from '/@/utils/is';
|
||||
|
||||
// export function useGlobalLabelWidth(propsRef: ComputedRef<FormProps>) {
|
||||
// return computed(() => {
|
||||
// const { labelWidth, labelCol, wrapperCol } = unref(propsRef);
|
||||
// if (!labelWidth) {
|
||||
// return { labelCol, wrapperCol };
|
||||
// }
|
||||
|
||||
// const width = isNumber(labelWidth) ? `${labelWidth}px` : labelWidth;
|
||||
// return {
|
||||
// labelCol: { style: { width }, span: 1, ...labelCol },
|
||||
// wrapperCol: { style: { width: `calc(100% - ${width})` }, span: 23, ...wrapperCol },
|
||||
// };
|
||||
// });
|
||||
// }
|
||||
|
||||
export function useItemLabelWidth(schemaItemRef: Ref<FormSchema>, propsRef: Ref<FormProps>) {
|
||||
return computed((): any => {
|
||||
const schemaItem = unref(schemaItemRef);
|
||||
const { labelCol = {}, wrapperCol = {} } = schemaItem.itemProps || {};
|
||||
const { labelWidth, disabledLabelWidth } = schemaItem;
|
||||
|
||||
const { labelWidth: globalLabelWidth } = unref(propsRef) as any;
|
||||
// 如果全局有设置labelWidth, 则所有item使用
|
||||
if ((!globalLabelWidth && !labelWidth) || disabledLabelWidth) {
|
||||
return { labelCol, wrapperCol };
|
||||
}
|
||||
let width = labelWidth || globalLabelWidth;
|
||||
|
||||
if (width) {
|
||||
width = isNumber(width) ? `${width}px` : width;
|
||||
}
|
||||
return {
|
||||
labelCol: { style: { width }, span: 1, ...labelCol },
|
||||
wrapperCol: { style: { width: `calc(100% - ${width})` }, span: 23, ...wrapperCol },
|
||||
};
|
||||
});
|
||||
}
|
107
src/components/Form/src/props.ts
Normal file
107
src/components/Form/src/props.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import type { FieldMapToTime, FormSchema } from './types/form';
|
||||
import type { PropType } from 'vue';
|
||||
import type { ColEx } from './types';
|
||||
|
||||
export const basicProps = {
|
||||
// 标签宽度 固定宽度
|
||||
labelWidth: {
|
||||
type: [Number, String] as PropType<number | string>,
|
||||
default: 0,
|
||||
},
|
||||
fieldMapToTime: {
|
||||
type: Array as PropType<FieldMapToTime>,
|
||||
default: () => [],
|
||||
},
|
||||
compact: Boolean as PropType<boolean>,
|
||||
// 表单配置规则
|
||||
schemas: {
|
||||
type: [Array] as PropType<FormSchema[]>,
|
||||
default: () => [],
|
||||
required: true,
|
||||
},
|
||||
mergeDynamicData: {
|
||||
type: Object as PropType<any>,
|
||||
default: null,
|
||||
},
|
||||
baseColProps: {
|
||||
type: Object as PropType<any>,
|
||||
},
|
||||
autoSetPlaceHolder: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
size: {
|
||||
type: String as PropType<'default' | 'small' | 'large'>,
|
||||
default: 'default',
|
||||
},
|
||||
// 禁用表单
|
||||
disabled: Boolean as PropType<boolean>,
|
||||
emptySpan: {
|
||||
type: [Number, Object] as PropType<number>,
|
||||
default: 0,
|
||||
},
|
||||
// 是否显示收起展开按钮
|
||||
showAdvancedButton: { type: Boolean as PropType<boolean>, default: false },
|
||||
// 转化时间
|
||||
transformDateFunc: {
|
||||
type: Function as PropType<Fn>,
|
||||
default: (date: any) => {
|
||||
return date._isAMomentObject ? date.format('YYYY-MM-DD HH:mm:ss') : date;
|
||||
},
|
||||
},
|
||||
rulesMessageJoinLabel: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
// 超过3行自动折叠
|
||||
autoAdvancedLine: {
|
||||
type: Number as PropType<number>,
|
||||
default: 3,
|
||||
},
|
||||
|
||||
// 是否显示操作按钮
|
||||
showActionButtonGroup: {
|
||||
type: Boolean as PropType<boolean>,
|
||||
default: true,
|
||||
},
|
||||
// 操作列Col配置
|
||||
actionColOptions: Object as PropType<ColEx>,
|
||||
// 显示重置按钮
|
||||
showResetButton: {
|
||||
type: Boolean as PropType<boolean>,
|
||||
default: true,
|
||||
},
|
||||
// 重置按钮配置
|
||||
resetButtonOptions: Object as PropType<any>,
|
||||
|
||||
// 显示确认按钮
|
||||
showSubmitButton: {
|
||||
type: Boolean as PropType<boolean>,
|
||||
default: true,
|
||||
},
|
||||
// 确认按钮配置
|
||||
submitButtonOptions: Object as PropType<any>,
|
||||
|
||||
// 自定义重置函数
|
||||
resetFunc: Function as PropType<Fn>,
|
||||
submitFunc: Function as PropType<Fn>,
|
||||
|
||||
// 以下为默认props
|
||||
hideRequiredMark: Boolean as PropType<boolean>,
|
||||
|
||||
labelCol: Object as PropType<ColEx>,
|
||||
|
||||
layout: {
|
||||
type: String as PropType<'horizontal' | 'vertical' | 'inline'>,
|
||||
default: 'horizontal',
|
||||
},
|
||||
|
||||
wrapperCol: Object as PropType<any>,
|
||||
|
||||
colon: {
|
||||
type: Boolean as PropType<boolean>,
|
||||
default: false,
|
||||
},
|
||||
|
||||
labelAlign: String as PropType<string>,
|
||||
};
|
159
src/components/Form/src/types/form.ts
Normal file
159
src/components/Form/src/types/form.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import type { Form, ValidationRule } from 'ant-design-vue/types/form/form';
|
||||
import type { VNode } from 'vue';
|
||||
import type { BasicButtonProps } from '/@/components/Button/types';
|
||||
import type { FormItem } from './formItem';
|
||||
import type { ColEx, ComponentType } from './index';
|
||||
|
||||
export type FieldMapToTime = [string, [string, string], string?][];
|
||||
|
||||
export interface RenderCallbackParams {
|
||||
schema: FormSchema;
|
||||
values: any;
|
||||
model: any;
|
||||
field: string;
|
||||
}
|
||||
export interface FormActionType extends Form {
|
||||
submit(): Promise<void>;
|
||||
setFieldsValue<T>(values: T): void;
|
||||
resetFields(): Promise<any>;
|
||||
getFieldsValue: () => any;
|
||||
clearValidate: (name?: string | string[]) => void;
|
||||
updateSchema(data: Partial<FormSchema> | Partial<FormSchema>[]): void;
|
||||
setProps(formProps: Partial<FormProps>): void;
|
||||
removeSchemaByFiled(field: string | string[]): void;
|
||||
appendSchemaByField(schema: FormSchema, prefixField?: string): void;
|
||||
}
|
||||
export type RegisterFn = (formInstance: FormActionType) => void;
|
||||
|
||||
export type UseFormReturnType = [RegisterFn, FormActionType];
|
||||
|
||||
export interface FormProps {
|
||||
// 整个表单所有项宽度
|
||||
labelWidth?: number | string;
|
||||
|
||||
// 整个表单通用Col配置
|
||||
labelCol?: Partial<ColEx>;
|
||||
// 整个表单通用Col配置
|
||||
wrapperCol?: Partial<ColEx>;
|
||||
|
||||
// 通用col配置
|
||||
baseColProps?: any;
|
||||
|
||||
// 表单配置规则
|
||||
schemas?: FormSchema[];
|
||||
// 用于合并到动态控制表单项的 函数values
|
||||
mergeDynamicData?: any;
|
||||
// 紧凑模式,用于搜索表单
|
||||
compact?: boolean;
|
||||
// 空白行span
|
||||
emptySpan?: number | Partial<ColEx>;
|
||||
// 表单内部组件大小
|
||||
size: 'default' | 'small' | 'large';
|
||||
// 是否禁用
|
||||
disabled?: boolean;
|
||||
// 时间区间字段映射成多个
|
||||
fieldMapToTime?: FieldMapToTime;
|
||||
// 自动设置placeholder
|
||||
autoSetPlaceHolder: boolean;
|
||||
// 校验信息是否加入label
|
||||
rulesMessageJoinLabel?: boolean;
|
||||
// 是否显示收起展开按钮
|
||||
showAdvancedButton?: boolean;
|
||||
// 超过指定行数自动收起
|
||||
autoAdvancedLine?: number;
|
||||
// 是否显示操作按钮
|
||||
showActionButtonGroup: boolean;
|
||||
|
||||
// 重置按钮配置
|
||||
resetButtonOptions: Partial<BasicButtonProps>;
|
||||
|
||||
// 确认按钮配置
|
||||
submitButtonOptions: Partial<BasicButtonProps>;
|
||||
|
||||
// 操作列配置
|
||||
actionColOptions: Partial<ColEx>;
|
||||
|
||||
// 显示重置按钮
|
||||
showResetButton: boolean;
|
||||
// 显示确认按钮
|
||||
showSubmitButton: boolean;
|
||||
|
||||
resetFunc: () => Promise<void>;
|
||||
submitFunc: () => Promise<void>;
|
||||
transformDateFunc: (date: any) => string;
|
||||
colon?: boolean;
|
||||
}
|
||||
export interface FormSchema {
|
||||
// 字段名
|
||||
field: string;
|
||||
changeEvent?: string;
|
||||
// 标签名
|
||||
label: string;
|
||||
// 文本右侧帮助文本
|
||||
helpMessage?: string | string[];
|
||||
// BaseHelp组件props
|
||||
helpComponentProps?: Partial<HelpComponentProps>;
|
||||
// label宽度,有传的话 itemProps配置的 labelCol 和WrapperCol会失效
|
||||
labelWidth?: string | number;
|
||||
// 禁用调有formModel全局设置的labelWidth,自己手动设置 labelCol和wrapperCol
|
||||
disabledLabelWidth?: boolean;
|
||||
// 组件
|
||||
component: ComponentType;
|
||||
// 组件参数
|
||||
componentProps?: any;
|
||||
|
||||
// 校验规则
|
||||
rules?: ValidationRule[];
|
||||
// 校验信息是否加入label
|
||||
rulesMessageJoinLabel?: boolean;
|
||||
|
||||
// 参考formModelItem
|
||||
itemProps?: Partial<FormItem>;
|
||||
|
||||
// formModelItem外层的col配置
|
||||
colProps?: Partial<ColEx>;
|
||||
|
||||
// 默认值
|
||||
defaultValue?: any;
|
||||
isAdvanced?: boolean;
|
||||
|
||||
// 配合详情组件
|
||||
span?: number;
|
||||
|
||||
ifShow?: boolean | ((renderCallbackParams: RenderCallbackParams) => boolean);
|
||||
|
||||
show?: boolean | ((renderCallbackParams: RenderCallbackParams) => boolean);
|
||||
|
||||
// 渲染form-item标签内的内容
|
||||
render?: (renderCallbackParams: RenderCallbackParams) => VNode | VNode[] | string;
|
||||
|
||||
// 渲染 col内容,需要外层包裹 form-item
|
||||
renderColContent?: (renderCallbackParams: RenderCallbackParams) => VNode | VNode[];
|
||||
|
||||
renderComponentContent?: (renderCallbackParams: RenderCallbackParams) => any;
|
||||
|
||||
// 自定义slot, 在 from-item内
|
||||
slot?: string;
|
||||
|
||||
// 自定义slot,类似renderColContent
|
||||
colSlot?: string;
|
||||
|
||||
dynamicDisabled?: boolean | ((renderCallbackParams: RenderCallbackParams) => boolean);
|
||||
|
||||
dynamicRules?: (renderCallbackParams: RenderCallbackParams) => ValidationRule[];
|
||||
}
|
||||
export interface HelpComponentProps {
|
||||
maxWidth: string;
|
||||
// 是否显示序号
|
||||
showIndex: boolean;
|
||||
// 文本列表
|
||||
text: any;
|
||||
// 颜色
|
||||
color: string;
|
||||
// 字体大小
|
||||
fontSize: string;
|
||||
icon: string;
|
||||
absolute: boolean;
|
||||
// 定位
|
||||
position: any;
|
||||
}
|
91
src/components/Form/src/types/formItem.ts
Normal file
91
src/components/Form/src/types/formItem.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import type { NamePath } from 'ant-design-vue/types/form/form-item';
|
||||
import type { Col } from 'ant-design-vue/types/grid/col';
|
||||
import type { VNodeChild } from 'vue';
|
||||
|
||||
export interface FormItem {
|
||||
/**
|
||||
* Used with label, whether to display : after label text.
|
||||
* @default true
|
||||
* @type boolean
|
||||
*/
|
||||
colon?: boolean;
|
||||
|
||||
/**
|
||||
* The extra prompt message. It is similar to help. Usage example: to display error message and prompt message at the same time.
|
||||
* @type any (string | slot)
|
||||
*/
|
||||
extra?: string | VNodeChild | JSX.Element;
|
||||
|
||||
/**
|
||||
* Used with validateStatus, this option specifies the validation status icon. Recommended to be used only with Input.
|
||||
* @default false
|
||||
* @type boolean
|
||||
*/
|
||||
hasFeedback?: boolean;
|
||||
|
||||
/**
|
||||
* The prompt message. If not provided, the prompt message will be generated by the validation rule.
|
||||
* @type any (string | slot)
|
||||
*/
|
||||
help?: string | VNodeChild | JSX.Element;
|
||||
|
||||
/**
|
||||
* Label test
|
||||
* @type any (string | slot)
|
||||
*/
|
||||
label?: string | VNodeChild | JSX.Element;
|
||||
|
||||
/**
|
||||
* The layout of label. You can set span offset to something like {span: 3, offset: 12} or sm: {span: 3, offset: 12} same as with <Col>
|
||||
* @type Col
|
||||
*/
|
||||
labelCol?: Col;
|
||||
|
||||
/**
|
||||
* Whether provided or not, it will be generated by the validation rule.
|
||||
* @default false
|
||||
* @type boolean
|
||||
*/
|
||||
required?: boolean;
|
||||
|
||||
/**
|
||||
* The validation status. If not provided, it will be generated by validation rule. options: 'success' 'warning' 'error' 'validating'
|
||||
* @type string
|
||||
*/
|
||||
validateStatus?: '' | 'success' | 'warning' | 'error' | 'validating';
|
||||
|
||||
/**
|
||||
* The layout for input controls, same as labelCol
|
||||
* @type Col
|
||||
*/
|
||||
wrapperCol?: Col;
|
||||
/**
|
||||
* Set sub label htmlFor.
|
||||
*/
|
||||
htmlFor?: string;
|
||||
/**
|
||||
* text align of label
|
||||
*/
|
||||
labelAlign?: 'left' | 'right';
|
||||
/**
|
||||
* a key of model. In the use of validate and resetFields method, the attribute is required
|
||||
*/
|
||||
name?: NamePath;
|
||||
/**
|
||||
* validation rules of form
|
||||
*/
|
||||
rules?: object | object[];
|
||||
/**
|
||||
* Whether to automatically associate form fields. In most cases, you can use automatic association.
|
||||
* If the conditions for automatic association are not met, you can manually associate them. See the notes below.
|
||||
*/
|
||||
autoLink?: boolean;
|
||||
/**
|
||||
* Whether stop validate on first rule of error for this field.
|
||||
*/
|
||||
validateFirst?: boolean;
|
||||
/**
|
||||
* When to validate the value of children node
|
||||
*/
|
||||
validateTrigger?: string | string[] | false;
|
||||
}
|
113
src/components/Form/src/types/index.ts
Normal file
113
src/components/Form/src/types/index.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { ColSpanType } from 'ant-design-vue/types/grid/col';
|
||||
|
||||
export interface ColEx {
|
||||
style: object;
|
||||
/**
|
||||
* raster number of cells to occupy, 0 corresponds to display: none
|
||||
* @default none (0)
|
||||
* @type ColSpanType
|
||||
*/
|
||||
span?: ColSpanType;
|
||||
|
||||
/**
|
||||
* raster order, used in flex layout mode
|
||||
* @default 0
|
||||
* @type ColSpanType
|
||||
*/
|
||||
order?: ColSpanType;
|
||||
|
||||
/**
|
||||
* the layout fill of flex
|
||||
* @default none
|
||||
* @type ColSpanType
|
||||
*/
|
||||
flex?: ColSpanType;
|
||||
|
||||
/**
|
||||
* the number of cells to offset Col from the left
|
||||
* @default 0
|
||||
* @type ColSpanType
|
||||
*/
|
||||
offset?: ColSpanType;
|
||||
|
||||
/**
|
||||
* the number of cells that raster is moved to the right
|
||||
* @default 0
|
||||
* @type ColSpanType
|
||||
*/
|
||||
push?: ColSpanType;
|
||||
|
||||
/**
|
||||
* the number of cells that raster is moved to the left
|
||||
* @default 0
|
||||
* @type ColSpanType
|
||||
*/
|
||||
pull?: ColSpanType;
|
||||
|
||||
/**
|
||||
* <576px and also default setting, could be a span value or an object containing above props
|
||||
* @type { span: ColSpanType, offset: ColSpanType } | ColSpanType
|
||||
*/
|
||||
xs?: { span: ColSpanType; offset: ColSpanType } | ColSpanType;
|
||||
|
||||
/**
|
||||
* ≥576px, could be a span value or an object containing above props
|
||||
* @type { span: ColSpanType, offset: ColSpanType } | ColSpanType
|
||||
*/
|
||||
sm?: { span: ColSpanType; offset: ColSpanType } | ColSpanType;
|
||||
|
||||
/**
|
||||
* ≥768px, could be a span value or an object containing above props
|
||||
* @type { span: ColSpanType, offset: ColSpanType } | ColSpanType
|
||||
*/
|
||||
md?: { span: ColSpanType; offset: ColSpanType } | ColSpanType;
|
||||
|
||||
/**
|
||||
* ≥992px, could be a span value or an object containing above props
|
||||
* @type { span: ColSpanType, offset: ColSpanType } | ColSpanType
|
||||
*/
|
||||
lg?: { span: ColSpanType; offset: ColSpanType } | ColSpanType;
|
||||
|
||||
/**
|
||||
* ≥1200px, could be a span value or an object containing above props
|
||||
* @type { span: ColSpanType, offset: ColSpanType } | ColSpanType
|
||||
*/
|
||||
xl?: { span: ColSpanType; offset: ColSpanType } | ColSpanType;
|
||||
|
||||
/**
|
||||
* ≥1600px, could be a span value or an object containing above props
|
||||
* @type { span: ColSpanType, offset: ColSpanType } | ColSpanType
|
||||
*/
|
||||
xxl?: { span: ColSpanType; offset: ColSpanType } | ColSpanType;
|
||||
}
|
||||
|
||||
export type ComponentType =
|
||||
| 'Input'
|
||||
| 'InputGroup'
|
||||
| 'InputPassword'
|
||||
| 'InputSearch'
|
||||
| 'InputTextArea'
|
||||
| 'InputNumber'
|
||||
| 'InputCountDown'
|
||||
| 'Select'
|
||||
| 'DictSelect'
|
||||
| 'SelectOptGroup'
|
||||
| 'SelectOption'
|
||||
| 'TreeSelect'
|
||||
| 'Transfer'
|
||||
| 'Radio'
|
||||
| 'RadioButton'
|
||||
| 'RadioGroup'
|
||||
| 'Checkbox'
|
||||
| 'CheckboxGroup'
|
||||
| 'AutoComplete'
|
||||
| 'Cascader'
|
||||
| 'DatePicker'
|
||||
| 'MonthPicker'
|
||||
| 'RangePicker'
|
||||
| 'WeekPicker'
|
||||
| 'TimePicker'
|
||||
| 'ImageUpload'
|
||||
| 'Switch'
|
||||
| 'StrengthMeter'
|
||||
| 'Render';
|
7
src/components/Icon/README.md
Normal file
7
src/components/Icon/README.md
Normal file
@@ -0,0 +1,7 @@
|
||||
### `Icon.vue`
|
||||
|
||||
```html
|
||||
<Icon icon="mdi:account" />
|
||||
```
|
||||
|
||||
The icon id follows the rules in [Iconify](https://iconify.design/) which you can use any icons from the supported icon sets.
|
14
src/components/Icon/index.less
Normal file
14
src/components/Icon/index.less
Normal file
@@ -0,0 +1,14 @@
|
||||
@import (reference) '../../design/index.less';
|
||||
|
||||
.app-iconify {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
span.iconify {
|
||||
display: block;
|
||||
min-width: 1em;
|
||||
min-height: 1em;
|
||||
background: @iconify-bg-color;
|
||||
border-radius: 100%;
|
||||
}
|
76
src/components/Icon/index.tsx
Normal file
76
src/components/Icon/index.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import type { PropType } from 'vue';
|
||||
import { defineComponent, ref, watch, onMounted, nextTick, unref, computed } from 'vue';
|
||||
import Iconify from '@purge-icons/generated';
|
||||
import { isString } from '/@/utils/is';
|
||||
import './index.less';
|
||||
export default defineComponent({
|
||||
name: 'GIcon',
|
||||
props: {
|
||||
// icon name
|
||||
icon: {
|
||||
type: String as PropType<string>,
|
||||
required: true,
|
||||
},
|
||||
// icon color
|
||||
color: {
|
||||
type: String as PropType<string>,
|
||||
},
|
||||
// icon size
|
||||
size: {
|
||||
type: [String, Number] as PropType<string | number>,
|
||||
default: 14,
|
||||
},
|
||||
prefix: {
|
||||
type: String as PropType<string>,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
setup(props, { attrs }) {
|
||||
const elRef = ref<Nullable<HTMLElement>>(null);
|
||||
|
||||
const getIconRef = computed(() => {
|
||||
const { icon, prefix } = props;
|
||||
return `${prefix ? prefix + ':' : ''}${icon}`;
|
||||
});
|
||||
const update = async () => {
|
||||
const el = unref(elRef);
|
||||
if (el) {
|
||||
await nextTick();
|
||||
const icon = unref(getIconRef);
|
||||
|
||||
const svg = Iconify.renderSVG(icon, {});
|
||||
|
||||
if (svg) {
|
||||
el.textContent = '';
|
||||
el.appendChild(svg);
|
||||
} else {
|
||||
const span = document.createElement('span');
|
||||
span.className = 'iconify';
|
||||
span.dataset.icon = icon;
|
||||
el.textContent = '';
|
||||
el.appendChild(span);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
watch(() => props.icon, update, { flush: 'post' });
|
||||
|
||||
const wrapStyleRef = computed((): any => {
|
||||
const { size, color } = props;
|
||||
let fs = size;
|
||||
if (isString(size)) {
|
||||
fs = parseInt(size, 10);
|
||||
}
|
||||
return {
|
||||
fontSize: `${fs}px`,
|
||||
color,
|
||||
};
|
||||
});
|
||||
|
||||
onMounted(update);
|
||||
|
||||
return () => (
|
||||
<div ref={elRef} class={[attrs.class, 'app-iconify']} style={unref(wrapStyleRef)} />
|
||||
);
|
||||
},
|
||||
});
|
49
src/components/Loading/BasicLoading.vue
Normal file
49
src/components/Loading/BasicLoading.vue
Normal file
@@ -0,0 +1,49 @@
|
||||
<template>
|
||||
<section class="flex justify-center items-center flex-col">
|
||||
<img
|
||||
src="/@/assets/images/loading.svg"
|
||||
alt=""
|
||||
:height="getLoadingIconSize"
|
||||
:width="getLoadingIconSize"
|
||||
/>
|
||||
<span class="mt-4" v-if="tip"> {{ tip }}</span>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import type { PropType } from 'vue';
|
||||
// components
|
||||
import { defineComponent, computed } from 'vue';
|
||||
|
||||
// hook
|
||||
|
||||
import { SizeEnum, sizeMap } from '/@/enums/sizeEnum';
|
||||
|
||||
import { BasicLoadingProps } from './type';
|
||||
|
||||
export default defineComponent({
|
||||
inheritAttrs: false,
|
||||
name: 'BasicLoading',
|
||||
props: {
|
||||
tip: {
|
||||
type: String as PropType<string>,
|
||||
default: '',
|
||||
},
|
||||
size: {
|
||||
type: String as PropType<SizeEnum>,
|
||||
default: SizeEnum.DEFAULT,
|
||||
validator: (v: SizeEnum): boolean => {
|
||||
return [SizeEnum.DEFAULT, SizeEnum.SMALL, SizeEnum.LARGE].includes(v);
|
||||
},
|
||||
},
|
||||
},
|
||||
setup(props: BasicLoadingProps) {
|
||||
const getLoadingIconSize = computed(() => {
|
||||
const { size } = props;
|
||||
return sizeMap.get(size);
|
||||
});
|
||||
|
||||
return { getLoadingIconSize };
|
||||
},
|
||||
});
|
||||
</script>
|
41
src/components/Loading/FullLoading.vue
Normal file
41
src/components/Loading/FullLoading.vue
Normal file
@@ -0,0 +1,41 @@
|
||||
<template>
|
||||
<section
|
||||
class="full-loading flex justify-center bg-mask-light items-center h-full w-full"
|
||||
:style="getStyle"
|
||||
>
|
||||
<BasicLoading :tip="tip" :size="SizeEnum.DEFAULT" />
|
||||
</section>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import type { PropType } from 'vue';
|
||||
import { defineComponent, computed } from 'vue';
|
||||
import BasicLoading from './BasicLoading.vue';
|
||||
|
||||
import { SizeEnum } from '/@/enums/sizeEnum';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'FullLoading',
|
||||
components: { BasicLoading },
|
||||
props: {
|
||||
tip: {
|
||||
type: String as PropType<string>,
|
||||
default: '',
|
||||
},
|
||||
absolute: Boolean as PropType<boolean>,
|
||||
},
|
||||
setup(props) {
|
||||
// 样式前缀
|
||||
const getStyle = computed((): any => {
|
||||
return props.absolute
|
||||
? {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 0,
|
||||
'z-index': 1,
|
||||
}
|
||||
: {};
|
||||
});
|
||||
return { getStyle, SizeEnum };
|
||||
},
|
||||
});
|
||||
</script>
|
2
src/components/Loading/index.ts
Normal file
2
src/components/Loading/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as BasicLoading } from './BasicLoading.vue';
|
||||
export { default as FullLoading } from './FullLoading.vue';
|
8
src/components/Loading/type.ts
Normal file
8
src/components/Loading/type.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { SizeEnum } from '/@/enums/sizeEnum';
|
||||
|
||||
export interface BasicLoadingProps {
|
||||
// 提示语
|
||||
tip: string;
|
||||
|
||||
size: SizeEnum;
|
||||
}
|
1
src/components/Menu/index.ts
Normal file
1
src/components/Menu/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as BasicMenu } from './src/BasicMenu';
|
260
src/components/Menu/src/BasicMenu.tsx
Normal file
260
src/components/Menu/src/BasicMenu.tsx
Normal file
@@ -0,0 +1,260 @@
|
||||
import type { MenuState } from './types';
|
||||
import type { Menu as MenuType } from '/@/router/types';
|
||||
|
||||
import {
|
||||
computed,
|
||||
defineComponent,
|
||||
unref,
|
||||
reactive,
|
||||
toRef,
|
||||
watch,
|
||||
onMounted,
|
||||
watchEffect,
|
||||
ref,
|
||||
} from 'vue';
|
||||
import { basicProps } from './props';
|
||||
import { Menu } from 'ant-design-vue';
|
||||
import { MenuModeEnum, MenuTypeEnum } from '/@/enums/menuEnum';
|
||||
import { menuStore } from '/@/store/modules/menu';
|
||||
import { getSlot } from '/@/utils/helper/tsxHelper';
|
||||
import { ScrollContainer } from '/@/components/Container/index';
|
||||
import SearchInput from './SearchInput.vue';
|
||||
import './index.less';
|
||||
import { menuHasChildren } from './helper';
|
||||
import MenuContent from './MenuContent';
|
||||
import { useSearchInput } from './useSearchInput';
|
||||
import { useOpenKeys } from './useOpenKeys';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { isFunction } from '/@/utils/is';
|
||||
import { getCurrentParentPath } from '/@/router/menus';
|
||||
export default defineComponent({
|
||||
name: 'BasicMenu',
|
||||
props: basicProps,
|
||||
emits: ['menuClick'],
|
||||
setup(props, { slots, emit }) {
|
||||
const currentParentPath = ref('');
|
||||
const menuState = reactive<MenuState>({
|
||||
defaultSelectedKeys: [],
|
||||
mode: props.mode,
|
||||
theme: computed(() => props.theme),
|
||||
openKeys: [],
|
||||
searchValue: '',
|
||||
selectedKeys: [],
|
||||
collapsedOpenKeys: [],
|
||||
});
|
||||
const { currentRoute } = useRouter();
|
||||
|
||||
const { handleInputChange, handleInputClick } = useSearchInput({
|
||||
flatMenusRef: toRef(props, 'flatItems'),
|
||||
emit: emit,
|
||||
menuState,
|
||||
handleMenuChange,
|
||||
});
|
||||
|
||||
const { handleOpenChange, resetKeys, setOpenKeys } = useOpenKeys(
|
||||
menuState,
|
||||
toRef(props, 'items'),
|
||||
toRef(props, 'flatItems'),
|
||||
toRef(props, 'isAppMenu')
|
||||
);
|
||||
|
||||
const getOpenKeys = computed(() => {
|
||||
if (props.isAppMenu) {
|
||||
return menuStore.getCollapsedState ? menuState.collapsedOpenKeys : menuState.openKeys;
|
||||
}
|
||||
return menuState.openKeys;
|
||||
});
|
||||
// menu外层样式
|
||||
const getMenuWrapStyle = computed((): any => {
|
||||
const { showLogo, search } = props;
|
||||
let offset = 0;
|
||||
if (search) {
|
||||
offset += 60;
|
||||
}
|
||||
if (showLogo) {
|
||||
offset += 54;
|
||||
}
|
||||
return {
|
||||
height: `calc(100% - ${offset}px)`,
|
||||
position: 'relative',
|
||||
};
|
||||
});
|
||||
|
||||
// 是否透明化左侧一级菜单
|
||||
const transparentMenuClass = computed(() => {
|
||||
const { type } = props;
|
||||
const { mode } = menuState;
|
||||
if (
|
||||
[MenuTypeEnum.MIX, MenuTypeEnum.SIDEBAR].includes(type) &&
|
||||
mode !== MenuModeEnum.HORIZONTAL
|
||||
) {
|
||||
return `basic-menu-bg__sidebar`;
|
||||
}
|
||||
if (
|
||||
(type === MenuTypeEnum.TOP_MENU && mode === MenuModeEnum.HORIZONTAL) ||
|
||||
props.appendClass
|
||||
) {
|
||||
return `basic-menu-bg__sidebar-hor`;
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
watch(
|
||||
() => currentRoute.value.name,
|
||||
(name: string) => {
|
||||
name !== 'Redirect' && handleMenuChange();
|
||||
getParentPath();
|
||||
}
|
||||
);
|
||||
watchEffect(() => {
|
||||
if (props.items) {
|
||||
handleMenuChange();
|
||||
}
|
||||
});
|
||||
|
||||
async function getParentPath() {
|
||||
const { appendClass } = props;
|
||||
if (!appendClass) return '';
|
||||
const parentPath = await getCurrentParentPath(unref(currentRoute).path);
|
||||
currentParentPath.value = parentPath;
|
||||
}
|
||||
|
||||
async function handleMenuClick(menu: MenuType) {
|
||||
const { beforeClickFn } = props;
|
||||
if (beforeClickFn && isFunction(beforeClickFn)) {
|
||||
const flag = await beforeClickFn(menu);
|
||||
if (!flag) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
const { path } = menu;
|
||||
menuState.selectedKeys = [path];
|
||||
emit('menuClick', menu);
|
||||
}
|
||||
function handleMenuChange() {
|
||||
const { flatItems } = props;
|
||||
if (!unref(flatItems) || flatItems.length === 0) {
|
||||
return;
|
||||
}
|
||||
const findMenu = flatItems.find((menu) => menu.path === unref(currentRoute).path);
|
||||
if (findMenu) {
|
||||
if (menuState.mode !== MenuModeEnum.HORIZONTAL) {
|
||||
setOpenKeys(findMenu);
|
||||
}
|
||||
menuState.selectedKeys = [findMenu.path];
|
||||
} else {
|
||||
resetKeys();
|
||||
}
|
||||
}
|
||||
// render menu item
|
||||
function renderMenuItem(menuList?: MenuType[], index = 1) {
|
||||
if (!menuList) {
|
||||
return;
|
||||
}
|
||||
const { appendClass } = props;
|
||||
const levelCls = `basic-menu-item__level${index} ${menuState.theme} `;
|
||||
|
||||
const showTitle = props.isAppMenu ? !menuStore.getCollapsedState : true;
|
||||
return menuList.map((menu) => {
|
||||
if (!menu) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isAppendActiveCls =
|
||||
appendClass && index === 1 && menu.path === unref(currentParentPath);
|
||||
// 没有子节点
|
||||
if (!menuHasChildren(menu)) {
|
||||
return (
|
||||
<Menu.Item
|
||||
key={menu.path}
|
||||
class={`${levelCls}${isAppendActiveCls ? ' top-active-menu ' : ''}`}
|
||||
onClick={handleMenuClick.bind(null, menu)}
|
||||
>
|
||||
{() => [
|
||||
<MenuContent
|
||||
item={menu}
|
||||
level={index}
|
||||
showTitle={showTitle}
|
||||
searchValue={menuState.searchValue}
|
||||
/>,
|
||||
]}
|
||||
</Menu.Item>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Menu.SubMenu key={menu.path} class={levelCls}>
|
||||
{{
|
||||
title: () => [
|
||||
<MenuContent
|
||||
showTitle={showTitle}
|
||||
item={menu}
|
||||
level={index}
|
||||
searchValue={menuState.searchValue}
|
||||
/>,
|
||||
],
|
||||
default: () => renderMenuItem(menu.children, index + 1),
|
||||
}}
|
||||
</Menu.SubMenu>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function renderMenu() {
|
||||
const isInline = props.mode === MenuModeEnum.INLINE;
|
||||
const { selectedKeys, defaultSelectedKeys, mode, theme } = menuState;
|
||||
|
||||
const inlineCollapsedObj = isInline
|
||||
? props.isAppMenu
|
||||
? {
|
||||
inlineCollapsed: menuStore.getCollapsedState,
|
||||
}
|
||||
: { inlineCollapsed: props.inlineCollapsed }
|
||||
: {};
|
||||
return (
|
||||
<Menu
|
||||
// forceSubMenuRender={true}
|
||||
selectedKeys={selectedKeys}
|
||||
defaultSelectedKeys={defaultSelectedKeys}
|
||||
mode={mode}
|
||||
openKeys={unref(getOpenKeys)}
|
||||
inlineIndent={props.inlineIndent}
|
||||
theme={unref(theme)}
|
||||
onOpenChange={handleOpenChange}
|
||||
class={['basic-menu', unref(transparentMenuClass)]}
|
||||
{...inlineCollapsedObj}
|
||||
>
|
||||
{{
|
||||
default: () => renderMenuItem(props.items, 1),
|
||||
}}
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
getParentPath();
|
||||
});
|
||||
return () => {
|
||||
const { getCollapsedState } = menuStore;
|
||||
const { mode } = props;
|
||||
|
||||
return mode === MenuModeEnum.HORIZONTAL ? (
|
||||
renderMenu()
|
||||
) : (
|
||||
<section class={`basic-menu-wrap`}>
|
||||
{getSlot(slots, 'header')}
|
||||
{props.search && (
|
||||
<SearchInput
|
||||
theme={props.theme}
|
||||
onChange={handleInputChange}
|
||||
onClick={handleInputClick}
|
||||
collapsed={getCollapsedState}
|
||||
/>
|
||||
)}
|
||||
<section style={unref(getMenuWrapStyle)}>
|
||||
<ScrollContainer>{() => renderMenu()}</ScrollContainer>
|
||||
</section>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
},
|
||||
});
|
61
src/components/Menu/src/MenuContent.tsx
Normal file
61
src/components/Menu/src/MenuContent.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import type { Menu as MenuType } from '/@/router/types';
|
||||
import type { PropType } from 'vue';
|
||||
|
||||
import { defineComponent } from 'vue';
|
||||
import Icon from '/@/components/Icon/index';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'MenuContent',
|
||||
props: {
|
||||
searchValue: {
|
||||
type: String as PropType<string>,
|
||||
default: '',
|
||||
},
|
||||
item: {
|
||||
type: Object as PropType<MenuType>,
|
||||
default: null,
|
||||
},
|
||||
showTitle: {
|
||||
type: Boolean as PropType<boolean>,
|
||||
default: true,
|
||||
},
|
||||
level: {
|
||||
type: Number as PropType<number>,
|
||||
default: 0,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
/**
|
||||
* @description: 渲染图标
|
||||
*/
|
||||
function renderIcon(icon: string) {
|
||||
return icon ? <Icon icon={icon} size={18} class="mr-1 menu-item-icon" /> : null;
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (!props.item) {
|
||||
return null;
|
||||
}
|
||||
const { showTitle, level } = props;
|
||||
const { name, icon } = props.item;
|
||||
const searchValue = props.searchValue || '';
|
||||
const index = name.indexOf(searchValue);
|
||||
|
||||
const beforeStr = name.substr(0, index);
|
||||
const afterStr = name.substr(index + searchValue.length);
|
||||
const show = level === 1 ? showTitle : true;
|
||||
return [
|
||||
renderIcon(icon!),
|
||||
index > -1 && searchValue ? (
|
||||
<span class={!show && 'hidden'}>
|
||||
{beforeStr}
|
||||
<span class={`basic-menu__keyword`}>{searchValue}</span>
|
||||
{afterStr}
|
||||
</span>
|
||||
) : (
|
||||
<span class={!show && 'hidden'}>{name}</span>
|
||||
),
|
||||
];
|
||||
};
|
||||
},
|
||||
});
|
123
src/components/Menu/src/SearchInput.vue
Normal file
123
src/components/Menu/src/SearchInput.vue
Normal file
@@ -0,0 +1,123 @@
|
||||
<template>
|
||||
<section class="menu-search-input" @Click="handleClick" :class="searchClass">
|
||||
<a-input-search
|
||||
placeholder="菜单搜索"
|
||||
class="menu-search-input__search"
|
||||
allowClear
|
||||
@change="handleChange"
|
||||
:disabled="collapsed"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import type { PropType } from 'vue';
|
||||
import { defineComponent, computed } from 'vue';
|
||||
import { MenuThemeEnum } from '/@/enums/menuEnum';
|
||||
|
||||
// hook
|
||||
import { useDebounce } from '/@/hooks/core/useDebounce';
|
||||
//
|
||||
export default defineComponent({
|
||||
name: 'BasicMenuSearchInput',
|
||||
props: {
|
||||
// 是否展开,用于左侧菜单
|
||||
collapsed: {
|
||||
type: Boolean as PropType<boolean>,
|
||||
default: true,
|
||||
},
|
||||
theme: {
|
||||
type: String as PropType<MenuThemeEnum>,
|
||||
},
|
||||
},
|
||||
setup(props, { emit }) {
|
||||
function emitChange(value?: string): void {
|
||||
emit('change', value);
|
||||
}
|
||||
const [debounceEmitChange] = useDebounce(emitChange, 200);
|
||||
/**
|
||||
* @description: 搜索
|
||||
*/
|
||||
function handleChange(e: ChangeEvent): void {
|
||||
const { collapsed } = props;
|
||||
if (collapsed) {
|
||||
return;
|
||||
}
|
||||
debounceEmitChange(e.target.value);
|
||||
}
|
||||
/**
|
||||
* @description: 点击时间
|
||||
*/
|
||||
function handleClick(): void {
|
||||
emit('click');
|
||||
}
|
||||
const searchClass = computed(() => {
|
||||
return props.theme ? `menu-search-input__search--${props.theme}` : '';
|
||||
});
|
||||
|
||||
return { handleClick, searchClass, handleChange };
|
||||
},
|
||||
});
|
||||
</script>
|
||||
<style lang="less">
|
||||
@import (reference) '../../../design/index.less';
|
||||
// 输入框背景颜色 深
|
||||
@input-dark-bg-color: #516085;
|
||||
|
||||
@icon-color: #c0c4cc;
|
||||
|
||||
.menu-search-input {
|
||||
margin: 12px 9px;
|
||||
|
||||
&__search--dark {
|
||||
// .setPlaceholder('.ant-input',#fff);
|
||||
|
||||
.ant-input {
|
||||
.set-bg();
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
.hide-outline();
|
||||
}
|
||||
}
|
||||
|
||||
.ant-input-search-icon,
|
||||
.ant-input-clear-icon {
|
||||
color: rgba(255, 255, 255, 0.6) !important;
|
||||
}
|
||||
|
||||
.ant-input-clear-icon {
|
||||
color: rgba(255, 255, 255, 0.3) !important;
|
||||
}
|
||||
}
|
||||
|
||||
&__search--light {
|
||||
.ant-input {
|
||||
color: @text-color-base;
|
||||
background: #fff;
|
||||
border: 0;
|
||||
outline: none;
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
.hide-outline();
|
||||
}
|
||||
}
|
||||
|
||||
.ant-input-search-icon {
|
||||
color: @icon-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.set-bg() {
|
||||
color: #fff;
|
||||
background: @input-dark-bg-color;
|
||||
border: 0;
|
||||
outline: none;
|
||||
}
|
||||
.hide-outline() {
|
||||
border: none;
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
</style>
|
12
src/components/Menu/src/helper.ts
Normal file
12
src/components/Menu/src/helper.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { Menu as MenuType } from '/@/router/types';
|
||||
|
||||
/**
|
||||
* @description: Whether the menu has child nodes
|
||||
*/
|
||||
export function menuHasChildren(menuTreeItem: MenuType): boolean {
|
||||
return (
|
||||
Reflect.has(menuTreeItem, 'children') &&
|
||||
!!menuTreeItem.children &&
|
||||
menuTreeItem.children.length > 0
|
||||
);
|
||||
}
|
246
src/components/Menu/src/index.less
Normal file
246
src/components/Menu/src/index.less
Normal file
@@ -0,0 +1,246 @@
|
||||
@import (reference) '../../../design/index.less';
|
||||
|
||||
.active-menu-style() {
|
||||
.ant-menu-item-selected,
|
||||
.ant-menu-submenu-popup.ant-menu-dark .ant-menu-item-selected {
|
||||
background: linear-gradient(
|
||||
118deg,
|
||||
rgba(@primary-color, 0.7),
|
||||
rgba(@primary-color, 1)
|
||||
) !important;
|
||||
border-radius: 2px;
|
||||
box-shadow: 0 0 4px 1px rgba(@primary-color, 0.7);
|
||||
}
|
||||
}
|
||||
|
||||
.basic-menu {
|
||||
&-wrap {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.menu-item-icon {
|
||||
vertical-align: text-top;
|
||||
}
|
||||
// 透明化背景
|
||||
&-bg__sidebar {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
&-bg__sidebar-hor {
|
||||
&.ant-menu-horizontal {
|
||||
display: flex;
|
||||
border: 0;
|
||||
align-items: center;
|
||||
|
||||
.basic-menu-item__level1 {
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
&.ant-menu-light {
|
||||
.ant-menu-item {
|
||||
&.basic-menu-item__level1 {
|
||||
height: 46px;
|
||||
line-height: 46px;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-menu-item:hover,
|
||||
.ant-menu-submenu:hover,
|
||||
.ant-menu-item-active,
|
||||
.ant-menu-submenu-active,
|
||||
.ant-menu-item-open,
|
||||
.ant-menu-submenu-open,
|
||||
.ant-menu-item-selected,
|
||||
.ant-menu-submenu-selected {
|
||||
color: @primary-color;
|
||||
border-bottom: 3px solid @primary-color;
|
||||
}
|
||||
|
||||
.ant-menu-item:hover,
|
||||
.ant-menu-item-active,
|
||||
.ant-menu:not(.ant-menu-inline) .ant-menu-submenu-open,
|
||||
.ant-menu-submenu-active,
|
||||
.ant-menu-submenu-title:hover {
|
||||
color: @primary-color;
|
||||
border-bottom: 3px solid @primary-color;
|
||||
}
|
||||
|
||||
// 有子菜单
|
||||
.ant-menu-submenu {
|
||||
&:hover {
|
||||
border-bottom: 3px solid @primary-color;
|
||||
}
|
||||
|
||||
&.ant-menu-selected,
|
||||
&.ant-menu-submenu-selected {
|
||||
border-bottom: 3px solid @primary-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.ant-menu-dark {
|
||||
background: transparent;
|
||||
|
||||
.ant-menu-item:hover,
|
||||
.ant-menu-item-active,
|
||||
.ant-menu:not(.ant-menu-inline) .ant-menu-submenu-open,
|
||||
.ant-menu-submenu-active,
|
||||
.ant-menu-submenu-title:hover {
|
||||
background: @top-menu-active-bg-color;
|
||||
border-radius: 6px 6px 0 0;
|
||||
}
|
||||
|
||||
.basic-menu-item__level1 {
|
||||
&.ant-menu-item-selected,
|
||||
&.ant-menu-submenu-selected {
|
||||
background: @top-menu-active-bg-color;
|
||||
border-radius: 6px 6px 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-menu-item {
|
||||
&.basic-menu-item__level1 {
|
||||
height: @header-height;
|
||||
line-height: @header-height;
|
||||
}
|
||||
}
|
||||
// 有子菜单
|
||||
.ant-menu-submenu {
|
||||
&.basic-menu-item__level1,
|
||||
.ant-menu-submenu-title {
|
||||
height: @header-height;
|
||||
line-height: @header-height;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 重置菜单项行高
|
||||
.ant-menu-item,
|
||||
.ant-menu-sub.ant-menu-inline > .ant-menu-item,
|
||||
.ant-menu-sub.ant-menu-inline > .ant-menu-submenu > .ant-menu-submenu-title {
|
||||
height: @app-menu-item-height;
|
||||
margin: 0;
|
||||
line-height: @app-menu-item-height;
|
||||
}
|
||||
|
||||
&.ant-menu-dark:not(.basic-menu-bg__sidebar-hor) {
|
||||
.active-menu-style();
|
||||
}
|
||||
|
||||
// 层级样式
|
||||
&.ant-menu-dark {
|
||||
.ant-menu-item {
|
||||
transition: unset;
|
||||
}
|
||||
|
||||
.ant-menu-item.ant-menu-item-selected.basic-menu-menu-item__level1,
|
||||
.ant-menu-submenu-selected.basic-menu-menu-item__level1 {
|
||||
color: @white;
|
||||
}
|
||||
|
||||
.basic-menu-item__level1 {
|
||||
margin-bottom: 0;
|
||||
|
||||
&.top-active-menu {
|
||||
color: @white;
|
||||
background: @top-menu-active-bg-color;
|
||||
border-radius: 6px 6px 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 2级菜单
|
||||
|
||||
.basic-menu-item__level2:not(.ant-menu-item-selected) {
|
||||
background-color: @sub-menu-item-dark-bg-color;
|
||||
}
|
||||
|
||||
.basic-menu-item__level2 {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
// 3级菜单
|
||||
.basic-menu-item__level3,
|
||||
.basic-menu__popup {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.basic-menu-item__level3:not(.ant-menu-item-selected) {
|
||||
background-color: @children-menu-item-dark-bg-color;
|
||||
}
|
||||
|
||||
.ant-menu-submenu-title {
|
||||
height: @app-menu-item-height;
|
||||
margin: 0;
|
||||
line-height: @app-menu-item-height;
|
||||
}
|
||||
}
|
||||
|
||||
&.ant-menu-light {
|
||||
.basic-menu-item__level1 {
|
||||
&.top-active-menu {
|
||||
color: @primary-color;
|
||||
border-bottom: 6px solid @primary-color;
|
||||
}
|
||||
}
|
||||
|
||||
&:not(.ant-menu-horizontal) {
|
||||
.ant-menu-item-selected {
|
||||
background: fade(@primary-color, 18%);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-menu-item.ant-menu-item-selected.basic-menu-menu-item__level1,
|
||||
.ant-menu-submenu-selected.basic-menu-menu-item__level1 {
|
||||
color: @primary-color;
|
||||
}
|
||||
}
|
||||
|
||||
// 关键字的颜色
|
||||
&__keyword {
|
||||
color: lighten(@primary-color, 20%);
|
||||
}
|
||||
|
||||
// 激活的子菜单样式
|
||||
.ant-menu-item.ant-menu-item-selected {
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
|
||||
// 触发器样式
|
||||
.ant-layout-sider {
|
||||
&-dark {
|
||||
.ant-layout-sider-trigger {
|
||||
color: darken(@white, 25%);
|
||||
background: @trigger-dark-bg-color;
|
||||
|
||||
&:hover {
|
||||
color: @white;
|
||||
background: @trigger-dark-hover-bg-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&-light {
|
||||
border-right: 1px solid rgba(221, 221, 221, 0.6);
|
||||
|
||||
.ant-layout-sider-trigger {
|
||||
color: @text-color-base;
|
||||
background: @trigger-light-bg-color;
|
||||
|
||||
&:hover {
|
||||
color: @text-color-base;
|
||||
background: @trigger-light-hover-bg-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-menu-dark {
|
||||
&.ant-menu-submenu-popup {
|
||||
> ul {
|
||||
background: @first-menu-item-dark-bg-color;
|
||||
}
|
||||
|
||||
.active-menu-style();
|
||||
}
|
||||
}
|
57
src/components/Menu/src/props.ts
Normal file
57
src/components/Menu/src/props.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import type { Menu } from '/@/router/types';
|
||||
import type { PropType } from 'vue';
|
||||
|
||||
import { MenuModeEnum, MenuTypeEnum, MenuThemeEnum } from '/@/enums/menuEnum';
|
||||
export const basicProps = {
|
||||
items: {
|
||||
type: Array as PropType<Menu[]>,
|
||||
default: () => [],
|
||||
},
|
||||
appendClass: {
|
||||
type: Boolean as PropType<boolean>,
|
||||
default: false,
|
||||
},
|
||||
flatItems: {
|
||||
type: Array as PropType<Menu[]>,
|
||||
default: () => [],
|
||||
},
|
||||
// 是否显示搜索框
|
||||
search: {
|
||||
type: Boolean as PropType<boolean>,
|
||||
default: true,
|
||||
},
|
||||
// 最好是4 倍数
|
||||
inlineIndent: {
|
||||
type: Number as PropType<number>,
|
||||
default: 20,
|
||||
},
|
||||
// 菜单组件的mode属性
|
||||
mode: {
|
||||
type: String as PropType<string>,
|
||||
default: MenuModeEnum.INLINE,
|
||||
},
|
||||
type: {
|
||||
type: String as PropType<MenuTypeEnum>,
|
||||
default: MenuTypeEnum.MIX,
|
||||
},
|
||||
theme: {
|
||||
type: String as PropType<string>,
|
||||
default: MenuThemeEnum.DARK,
|
||||
},
|
||||
showLogo: {
|
||||
type: Boolean as PropType<boolean>,
|
||||
default: false,
|
||||
},
|
||||
inlineCollapsed: {
|
||||
type: Boolean as PropType<boolean>,
|
||||
default: false,
|
||||
},
|
||||
isAppMenu: {
|
||||
type: Boolean as PropType<boolean>,
|
||||
default: true,
|
||||
},
|
||||
beforeClickFn: {
|
||||
type: Function as PropType<Fn>,
|
||||
default: null,
|
||||
},
|
||||
};
|
25
src/components/Menu/src/types.d.ts
vendored
Normal file
25
src/components/Menu/src/types.d.ts
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
export interface MenuState {
|
||||
// 默认选中的列表
|
||||
defaultSelectedKeys: string[];
|
||||
|
||||
// 模式
|
||||
mode: MenuModeEnum;
|
||||
|
||||
// 主题
|
||||
theme: ComputedRef<MenuThemeEnum> | MenuThemeEnum;
|
||||
|
||||
// 缩进
|
||||
inlineIndent?: number;
|
||||
|
||||
// 展开数组
|
||||
openKeys: string[];
|
||||
|
||||
// 搜索值
|
||||
searchValue: string;
|
||||
|
||||
// 当前选中的菜单项 key 数组
|
||||
selectedKeys: string[];
|
||||
|
||||
// 收缩状态下展开的数组
|
||||
collapsedOpenKeys: string[];
|
||||
}
|
52
src/components/Menu/src/useOpenKeys.ts
Normal file
52
src/components/Menu/src/useOpenKeys.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import type { Menu as MenuType } from '/@/router/types';
|
||||
import type { MenuState } from './types';
|
||||
import type { Ref } from 'vue';
|
||||
|
||||
import { unref } from 'vue';
|
||||
import { menuStore } from '/@/store/modules/menu';
|
||||
import { getAllParentPath } from '/@/utils/helper/menuHelper';
|
||||
|
||||
export function useOpenKeys(
|
||||
menuState: MenuState,
|
||||
menus: Ref<MenuType[]>,
|
||||
flatMenusRef: Ref<MenuType[]>,
|
||||
isAppMenu: Ref<boolean>
|
||||
) {
|
||||
/**
|
||||
* @description:设置展开
|
||||
*/
|
||||
function setOpenKeys(menu: MenuType) {
|
||||
const flatMenus = unref(flatMenusRef);
|
||||
menuState.openKeys = getAllParentPath(flatMenus, menu.path);
|
||||
}
|
||||
/**
|
||||
* @description: 重置值
|
||||
*/
|
||||
function resetKeys() {
|
||||
menuState.selectedKeys = [];
|
||||
menuState.openKeys = [];
|
||||
}
|
||||
|
||||
function handleOpenChange(openKeys: string[]) {
|
||||
const rootSubMenuKeys: string[] = [];
|
||||
for (const menu of unref(menus)) {
|
||||
const { children, path } = menu;
|
||||
if (children && children.length > 0) {
|
||||
rootSubMenuKeys.push(path);
|
||||
}
|
||||
}
|
||||
|
||||
if (!menuStore.getCollapsedState || !unref(isAppMenu)) {
|
||||
const latestOpenKey = openKeys.find((key) => menuState.openKeys.indexOf(key) === -1);
|
||||
|
||||
if (rootSubMenuKeys.indexOf(latestOpenKey as string) === -1) {
|
||||
menuState.openKeys = openKeys;
|
||||
} else {
|
||||
menuState.openKeys = latestOpenKey ? [latestOpenKey] : [];
|
||||
}
|
||||
} else {
|
||||
menuState.collapsedOpenKeys = openKeys;
|
||||
}
|
||||
}
|
||||
return { setOpenKeys, resetKeys, handleOpenChange };
|
||||
}
|
56
src/components/Menu/src/useSearchInput.ts
Normal file
56
src/components/Menu/src/useSearchInput.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import type { Menu as MenuType } from '/@/router/types';
|
||||
import type { MenuState } from './types';
|
||||
import type { Ref } from 'vue';
|
||||
|
||||
import { isString } from '/@/utils/is';
|
||||
import { unref } from 'vue';
|
||||
import { es6Unique } from '/@/utils';
|
||||
import { getAllParentPath } from '/@/utils/helper/menuHelper';
|
||||
|
||||
interface UseSearchInputOptions {
|
||||
menuState: MenuState;
|
||||
flatMenusRef: Ref<MenuType[]>;
|
||||
emit: EmitType;
|
||||
handleMenuChange: Fn;
|
||||
}
|
||||
export function useSearchInput({
|
||||
menuState,
|
||||
flatMenusRef,
|
||||
handleMenuChange,
|
||||
emit,
|
||||
}: UseSearchInputOptions) {
|
||||
/**
|
||||
* @description: 输入框搜索
|
||||
*/
|
||||
function handleInputChange(value?: string): void {
|
||||
if (!isString(value)) {
|
||||
value = (value as any).target.value;
|
||||
}
|
||||
if (!value) {
|
||||
handleMenuChange && handleMenuChange();
|
||||
}
|
||||
|
||||
menuState.searchValue = value || '';
|
||||
if (!value) {
|
||||
menuState.openKeys = [];
|
||||
return;
|
||||
}
|
||||
|
||||
const flatMenus = unref(flatMenusRef);
|
||||
let openKeys: string[] = [];
|
||||
for (const menu of flatMenus) {
|
||||
const { name, path } = menu;
|
||||
if (!name.includes(value)) {
|
||||
continue;
|
||||
}
|
||||
openKeys = openKeys.concat(getAllParentPath(flatMenus, path));
|
||||
}
|
||||
openKeys = es6Unique(openKeys);
|
||||
menuState.openKeys = openKeys;
|
||||
}
|
||||
// 搜索框点击
|
||||
function handleInputClick(e: any): void {
|
||||
emit('clickSearchInput', e);
|
||||
}
|
||||
return { handleInputChange, handleInputClick };
|
||||
}
|
5
src/components/Modal/index.ts
Normal file
5
src/components/Modal/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import './src/index.less';
|
||||
export { default as BasicModal } from './src/BasicModal';
|
||||
export { default as Modal } from './src/Modal';
|
||||
export { useModal, useModalInner } from './src/useModal';
|
||||
export * from './src/types';
|
230
src/components/Modal/src/BasicModal.tsx
Normal file
230
src/components/Modal/src/BasicModal.tsx
Normal file
@@ -0,0 +1,230 @@
|
||||
import type { ModalProps, ModalMethods } from './types';
|
||||
|
||||
import Modal from './Modal';
|
||||
import { Button } from 'ant-design-vue';
|
||||
import ModalWrapper from './ModalWrapper';
|
||||
import { BasicTitle } from '/@/components/Basic';
|
||||
import { defineComponent, computed, ref, watch, unref, watchEffect } from 'vue';
|
||||
|
||||
import { FullscreenExitOutlined, FullscreenOutlined, CloseOutlined } from '@ant-design/icons-vue';
|
||||
|
||||
import { basicProps } from './props';
|
||||
|
||||
import { getSlot, extendSlots } from '/@/utils/helper/tsxHelper';
|
||||
import { isFunction } from '/@/utils/is';
|
||||
import { deepMerge } from '/@/utils';
|
||||
import { buildUUID } from '/@/utils/uuid';
|
||||
|
||||
// import { triggerWindowResize } from '@/utils/event/triggerWindowResizeEvent';
|
||||
export default defineComponent({
|
||||
name: 'BasicModal',
|
||||
props: basicProps,
|
||||
emits: ['visible-change', 'height-change', 'cancel', 'ok', 'register'],
|
||||
setup(props, { slots, emit, attrs }) {
|
||||
const visibleRef = ref(false);
|
||||
|
||||
const propsRef = ref<Partial<ModalProps> | null>(null);
|
||||
|
||||
const modalWrapperRef = ref<any>(null);
|
||||
|
||||
// modal Bottom and top height
|
||||
const extHeightRef = ref(0);
|
||||
|
||||
// Unexpanded height of the popup
|
||||
const formerHeightRef = ref(0);
|
||||
|
||||
const fullScreenRef = ref(false);
|
||||
// Custom title component: get title
|
||||
const getMergeProps = computed(() => {
|
||||
return {
|
||||
...props,
|
||||
...(unref(propsRef) as any),
|
||||
};
|
||||
});
|
||||
// modal component does not need title
|
||||
const getProps = computed((): any => {
|
||||
const opt = {
|
||||
...props,
|
||||
...((unref(propsRef) || {}) as any),
|
||||
visible: unref(visibleRef),
|
||||
title: undefined,
|
||||
};
|
||||
const { wrapClassName = '' } = opt;
|
||||
const className = unref(fullScreenRef) ? `${wrapClassName} fullscreen-modal` : wrapClassName;
|
||||
return {
|
||||
...opt,
|
||||
wrapClassName: className,
|
||||
};
|
||||
});
|
||||
watchEffect(() => {
|
||||
visibleRef.value = !!props.visible;
|
||||
});
|
||||
watch(
|
||||
() => unref(visibleRef),
|
||||
(v) => {
|
||||
emit('visible-change', v);
|
||||
},
|
||||
{
|
||||
immediate: false,
|
||||
}
|
||||
);
|
||||
/**
|
||||
* @description: 渲染标题
|
||||
*/
|
||||
function renderTitle() {
|
||||
const { helpMessage } = unref(getProps);
|
||||
const { title } = unref(getMergeProps);
|
||||
return (
|
||||
<BasicTitle helpMessage={helpMessage}>
|
||||
{() => (slots.title ? getSlot(slots, 'title') : title)}
|
||||
</BasicTitle>
|
||||
);
|
||||
}
|
||||
|
||||
function renderContent() {
|
||||
const { useWrapper, loading, wrapperProps } = unref(getProps);
|
||||
return useWrapper ? (
|
||||
<ModalWrapper
|
||||
footerOffset={props.wrapperFooterOffset}
|
||||
fullScreen={unref(fullScreenRef)}
|
||||
ref={modalWrapperRef}
|
||||
loading={loading}
|
||||
visible={unref(visibleRef)}
|
||||
{...wrapperProps}
|
||||
onGetExtHeight={(height: number) => {
|
||||
extHeightRef.value = height;
|
||||
}}
|
||||
onHeightChange={(height: string) => {
|
||||
emit('height-change', height);
|
||||
}}
|
||||
>
|
||||
{() => getSlot(slots)}
|
||||
</ModalWrapper>
|
||||
) : (
|
||||
getSlot(slots)
|
||||
);
|
||||
}
|
||||
// 取消事件
|
||||
async function handleCancel(e: Event) {
|
||||
e.stopPropagation();
|
||||
if (props.closeFunc && isFunction(props.closeFunc)) {
|
||||
const isClose: boolean = await props.closeFunc();
|
||||
visibleRef.value = !isClose;
|
||||
return;
|
||||
}
|
||||
visibleRef.value = false;
|
||||
emit('cancel');
|
||||
}
|
||||
// 底部按钮自定义实现,
|
||||
function renderFooter() {
|
||||
const {
|
||||
showCancelBtn,
|
||||
cancelButtonProps,
|
||||
cancelText,
|
||||
showOkBtn,
|
||||
okType,
|
||||
okText,
|
||||
okButtonProps,
|
||||
confirmLoading,
|
||||
} = unref(getProps);
|
||||
|
||||
return (
|
||||
<>
|
||||
{getSlot(slots, 'insertFooter')}
|
||||
|
||||
{showCancelBtn && (
|
||||
<Button {...cancelButtonProps} onClick={handleCancel}>
|
||||
{() => cancelText}
|
||||
</Button>
|
||||
)}
|
||||
{getSlot(slots, 'centerdFooter')}
|
||||
{showOkBtn && (
|
||||
<Button
|
||||
type={okType as any}
|
||||
loading={confirmLoading}
|
||||
onClick={() => {
|
||||
emit('ok');
|
||||
}}
|
||||
{...okButtonProps}
|
||||
>
|
||||
{() => okText}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{getSlot(slots, 'appendFooter')}
|
||||
</>
|
||||
);
|
||||
}
|
||||
/**
|
||||
* @description: 关闭按钮
|
||||
*/
|
||||
function renderClose() {
|
||||
const { canFullscreen } = unref(getProps);
|
||||
if (!canFullscreen) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div class="custom-close-icon">
|
||||
{unref(fullScreenRef) ? (
|
||||
<FullscreenExitOutlined role="full" onClick={handleFullScreen} />
|
||||
) : (
|
||||
<FullscreenOutlined role="close" onClick={handleFullScreen} />
|
||||
)}
|
||||
<CloseOutlined onClick={handleCancel} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function handleFullScreen(e: Event) {
|
||||
e.stopPropagation();
|
||||
fullScreenRef.value = !unref(fullScreenRef);
|
||||
|
||||
const modalWrapper = unref(modalWrapperRef);
|
||||
if (modalWrapper) {
|
||||
const modalWrapSpinEl = (modalWrapper.$el as HTMLElement).querySelector(
|
||||
'.ant-spin-nested-loading'
|
||||
);
|
||||
if (modalWrapSpinEl) {
|
||||
if (!unref(formerHeightRef) && unref(fullScreenRef)) {
|
||||
formerHeightRef.value = (modalWrapSpinEl as HTMLElement).offsetHeight;
|
||||
console.log(formerHeightRef);
|
||||
}
|
||||
if (unref(fullScreenRef)) {
|
||||
(modalWrapSpinEl as HTMLElement).style.height = `${
|
||||
window.innerHeight - unref(extHeightRef)
|
||||
}px`;
|
||||
} else {
|
||||
(modalWrapSpinEl as HTMLElement).style.height = `${unref(formerHeightRef)}px`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/**
|
||||
* @description: 设置modal参数
|
||||
*/
|
||||
function setModalProps(props: Partial<ModalProps>): void {
|
||||
// Keep the last setModalProps
|
||||
propsRef.value = deepMerge(unref(propsRef) || {}, props);
|
||||
if (Reflect.has(props, 'visible')) {
|
||||
visibleRef.value = !!props.visible;
|
||||
}
|
||||
}
|
||||
|
||||
const modalMethods: ModalMethods = {
|
||||
setModalProps,
|
||||
};
|
||||
const uuid = buildUUID();
|
||||
emit('register', modalMethods, uuid);
|
||||
return () => (
|
||||
<Modal onCancel={handleCancel} {...{ ...attrs, ...props, ...unref(getProps) }}>
|
||||
{{
|
||||
...extendSlots(slots, ['default']),
|
||||
default: () => renderContent(),
|
||||
closeIcon: () => renderClose(),
|
||||
footer: () => renderFooter(),
|
||||
title: () => renderTitle(),
|
||||
}}
|
||||
</Modal>
|
||||
);
|
||||
},
|
||||
});
|
109
src/components/Modal/src/Modal.tsx
Normal file
109
src/components/Modal/src/Modal.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import { Modal } from 'ant-design-vue';
|
||||
import { defineComponent, watchEffect } from 'vue';
|
||||
import { basicProps } from './props';
|
||||
import { useTimeout } from '/@/hooks/core/useTimeout';
|
||||
import { extendSlots } from '/@/utils/helper/tsxHelper';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'Modal',
|
||||
inheritAttrs: false,
|
||||
props: basicProps,
|
||||
setup(props, { attrs, slots }) {
|
||||
const getStyle = (dom: any, attr: any) => {
|
||||
return getComputedStyle(dom)[attr];
|
||||
};
|
||||
const drag = (wrap: any) => {
|
||||
if (!wrap) return;
|
||||
wrap.setAttribute('data-drag', props.draggable);
|
||||
const dialogHeaderEl = wrap.querySelector('.ant-modal-header');
|
||||
const dragDom = wrap.querySelector('.ant-modal');
|
||||
|
||||
if (!dialogHeaderEl || !dragDom || !props.draggable) return;
|
||||
|
||||
dialogHeaderEl.style.cursor = 'move';
|
||||
|
||||
dialogHeaderEl.onmousedown = (e: any) => {
|
||||
// 鼠标按下,计算当前元素距离可视区的距离
|
||||
const disX = e.clientX;
|
||||
const disY = e.clientY;
|
||||
const screenWidth = document.body.clientWidth; // body当前宽度
|
||||
const screenHeight = document.documentElement.clientHeight; // 可见区域高度(应为body高度,可某些环境下无法获取)
|
||||
|
||||
const dragDomWidth = dragDom.offsetWidth; // 对话框宽度
|
||||
const dragDomheight = dragDom.offsetHeight; // 对话框高度
|
||||
|
||||
const minDragDomLeft = dragDom.offsetLeft;
|
||||
|
||||
const maxDragDomLeft = screenWidth - dragDom.offsetLeft - dragDomWidth;
|
||||
const minDragDomTop = dragDom.offsetTop;
|
||||
const maxDragDomTop = screenHeight - dragDom.offsetTop - dragDomheight;
|
||||
// 获取到的值带px 正则匹配替换
|
||||
const domLeft = getStyle(dragDom, 'left');
|
||||
const domTop = getStyle(dragDom, 'top');
|
||||
let styL = +domLeft;
|
||||
let styT = +domTop;
|
||||
|
||||
// 注意在ie中 第一次获取到的值为组件自带50% 移动之后赋值为px
|
||||
if (domLeft.includes('%')) {
|
||||
styL = +document.body.clientWidth * (+domLeft.replace(/%/g, '') / 100);
|
||||
styT = +document.body.clientHeight * (+domTop.replace(/%/g, '') / 100);
|
||||
} else {
|
||||
styL = +domLeft.replace(/px/g, '');
|
||||
styT = +domTop.replace(/px/g, '');
|
||||
}
|
||||
|
||||
document.onmousemove = function (e) {
|
||||
// 通过事件委托,计算移动的距离
|
||||
let left = e.clientX - disX;
|
||||
let top = e.clientY - disY;
|
||||
|
||||
// 边界处理
|
||||
if (-left > minDragDomLeft) {
|
||||
left = -minDragDomLeft;
|
||||
} else if (left > maxDragDomLeft) {
|
||||
left = maxDragDomLeft;
|
||||
}
|
||||
|
||||
if (-top > minDragDomTop) {
|
||||
top = -minDragDomTop;
|
||||
} else if (top > maxDragDomTop) {
|
||||
top = maxDragDomTop;
|
||||
}
|
||||
|
||||
// 移动当前元素
|
||||
dragDom.style.cssText += `;left:${left + styL}px;top:${top + styT}px;`;
|
||||
};
|
||||
|
||||
document.onmouseup = () => {
|
||||
document.onmousemove = null;
|
||||
document.onmouseup = null;
|
||||
};
|
||||
};
|
||||
};
|
||||
const handleDrag = () => {
|
||||
const dragWraps = document.querySelectorAll('.ant-modal-wrap');
|
||||
for (const wrap of dragWraps as any) {
|
||||
const display = getStyle(wrap, 'display');
|
||||
const draggable = wrap.getAttribute('data-drag');
|
||||
if (display !== 'none') {
|
||||
// 拖拽位置
|
||||
draggable === null && drag(wrap);
|
||||
}
|
||||
}
|
||||
};
|
||||
watchEffect(() => {
|
||||
if (!props.visible) {
|
||||
return;
|
||||
}
|
||||
// context.$nextTick();
|
||||
useTimeout(() => {
|
||||
handleDrag();
|
||||
}, 30);
|
||||
});
|
||||
|
||||
return () => {
|
||||
const propsData = { ...attrs, ...props } as any;
|
||||
return <Modal {...propsData}>{extendSlots(slots)}</Modal>;
|
||||
};
|
||||
},
|
||||
});
|
188
src/components/Modal/src/ModalWrapper.tsx
Normal file
188
src/components/Modal/src/ModalWrapper.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
import type { PropType } from 'vue';
|
||||
import type { ModalWrapperProps } from './types';
|
||||
|
||||
import {
|
||||
defineComponent,
|
||||
computed,
|
||||
ref,
|
||||
watchEffect,
|
||||
unref,
|
||||
watch,
|
||||
onMounted,
|
||||
nextTick,
|
||||
onUnmounted,
|
||||
} from 'vue';
|
||||
import { Spin } from 'ant-design-vue';
|
||||
import { ScrollContainer } from '/@/components/Container/index';
|
||||
|
||||
import { useWindowSizeFn } from '/@/hooks/event/useWindowSize';
|
||||
import { useTimeout } from '/@/hooks/core/useTimeout';
|
||||
|
||||
import { getSlot } from '/@/utils/helper/tsxHelper';
|
||||
import { useElResize } from '/@/hooks/event/useElResize';
|
||||
export default defineComponent({
|
||||
name: 'ModalWrapper',
|
||||
emits: ['heightChange', 'getExtHeight'],
|
||||
props: {
|
||||
loading: {
|
||||
type: Boolean as PropType<boolean>,
|
||||
default: false,
|
||||
},
|
||||
modalHeaderHeight: {
|
||||
type: Number as PropType<number>,
|
||||
default: 50,
|
||||
},
|
||||
modalFooterHeight: {
|
||||
type: Number as PropType<number>,
|
||||
default: 70,
|
||||
},
|
||||
minHeight: {
|
||||
type: Number as PropType<number>,
|
||||
default: 200,
|
||||
},
|
||||
footerOffset: {
|
||||
type: Number as PropType<number>,
|
||||
default: 0,
|
||||
},
|
||||
visible: {
|
||||
type: Boolean as PropType<boolean>,
|
||||
default: false,
|
||||
},
|
||||
fullScreen: {
|
||||
type: Boolean as PropType<boolean>,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
setup(props: ModalWrapperProps, { slots, emit }) {
|
||||
const wrapperRef = ref<HTMLElement | null>(null);
|
||||
const spinRef = ref<any>(null);
|
||||
const realHeightRef = ref(0);
|
||||
|
||||
const wrapStyle = computed(() => {
|
||||
return {
|
||||
minHeight: `${props.minHeight}px`,
|
||||
overflow: 'hidden',
|
||||
};
|
||||
});
|
||||
|
||||
// 重试次数
|
||||
let tryCount = 0;
|
||||
async function setModalHeight() {
|
||||
// 解决在弹窗关闭的时候监听还存在,导致再次打开弹窗没有高度
|
||||
// 加上这个,就必须在使用的时候传递父级的visible
|
||||
if (!props.visible) {
|
||||
return;
|
||||
}
|
||||
const wrapperRefDom = unref(wrapperRef);
|
||||
if (!wrapperRefDom) {
|
||||
return;
|
||||
}
|
||||
const bodyDom = wrapperRefDom.parentElement;
|
||||
if (!bodyDom) {
|
||||
return;
|
||||
}
|
||||
bodyDom.style.padding = '0';
|
||||
await nextTick();
|
||||
|
||||
try {
|
||||
const modalDom = bodyDom.parentElement && bodyDom.parentElement.parentElement;
|
||||
if (!modalDom) {
|
||||
return;
|
||||
}
|
||||
const modalRect = getComputedStyle(modalDom).top;
|
||||
const modalTop = Number.parseInt(modalRect);
|
||||
let maxHeight =
|
||||
window.innerHeight -
|
||||
modalTop * 2 +
|
||||
(props.footerOffset! || 0) -
|
||||
props.modalFooterHeight -
|
||||
props.modalHeaderHeight;
|
||||
|
||||
// 距离顶部过进会出现滚动条
|
||||
if (modalTop < 40) {
|
||||
maxHeight -= 26;
|
||||
}
|
||||
await nextTick();
|
||||
const spinEl = unref(spinRef);
|
||||
if (!spinEl) {
|
||||
useTimeout(() => {
|
||||
// retry
|
||||
if (tryCount < 3) {
|
||||
setModalHeight();
|
||||
}
|
||||
tryCount++;
|
||||
}, 10);
|
||||
return;
|
||||
}
|
||||
tryCount = 0;
|
||||
|
||||
const realHeight = (spinEl.$el.querySelector('.ant-spin-container') as HTMLElement)
|
||||
.scrollHeight;
|
||||
|
||||
// 16为 p-2和m-2 加起来为4,基础4, 4*4=16
|
||||
// 32 padding
|
||||
if (props.fullScreen) {
|
||||
realHeightRef.value =
|
||||
window.innerHeight - props.modalFooterHeight - props.modalHeaderHeight - 26;
|
||||
} else {
|
||||
realHeightRef.value = realHeight > maxHeight ? maxHeight : realHeight + 16 + 30;
|
||||
}
|
||||
emit('heightChange', unref(realHeightRef));
|
||||
nextTick(() => {
|
||||
const el = spinEl.$el;
|
||||
if (el) {
|
||||
el.style.height = `${unref(realHeightRef)}px`;
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
}
|
||||
function listenElResize() {
|
||||
const wrapper = unref(wrapperRef);
|
||||
if (!wrapper) return;
|
||||
const container = wrapper.querySelector('.ant-spin-container');
|
||||
if (!container) return;
|
||||
const [start, stop] = useElResize(container, () => {
|
||||
setModalHeight();
|
||||
});
|
||||
start();
|
||||
onUnmounted(() => {
|
||||
stop();
|
||||
});
|
||||
}
|
||||
nextTick(() => {});
|
||||
watchEffect(() => {
|
||||
setModalHeight();
|
||||
});
|
||||
watch(
|
||||
() => props.fullScreen,
|
||||
(v) => {
|
||||
!v && setModalHeight();
|
||||
}
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
const { modalHeaderHeight, modalFooterHeight } = props;
|
||||
emit('getExtHeight', modalHeaderHeight + modalFooterHeight);
|
||||
listenElResize();
|
||||
});
|
||||
|
||||
useWindowSizeFn(setModalHeight);
|
||||
|
||||
return () => {
|
||||
const height = unref(realHeightRef);
|
||||
return (
|
||||
<div ref={wrapperRef} style={unref(wrapStyle)}>
|
||||
<ScrollContainer>
|
||||
{() => (
|
||||
<Spin ref={spinRef} spinning={props.loading} style={{ height: `${height}px` }}>
|
||||
{() => getSlot(slots)}
|
||||
</Spin>
|
||||
)}
|
||||
</ScrollContainer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
},
|
||||
});
|
151
src/components/Modal/src/index.less
Normal file
151
src/components/Modal/src/index.less
Normal file
@@ -0,0 +1,151 @@
|
||||
@import (reference) '../../../design/index.less';
|
||||
|
||||
.fullscreen-modal {
|
||||
overflow: hidden;
|
||||
|
||||
.ant-modal {
|
||||
top: 0 !important;
|
||||
right: 0 !important;
|
||||
bottom: 0 !important;
|
||||
left: 0 !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-modal {
|
||||
width: 520px;
|
||||
padding-bottom: 0;
|
||||
|
||||
.ant-spin-nested-loading {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
&-title {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
line-height: 16px;
|
||||
|
||||
.base-title {
|
||||
cursor: move;
|
||||
}
|
||||
}
|
||||
|
||||
.custom-close-icon {
|
||||
display: flex;
|
||||
height: 95%;
|
||||
align-items: center;
|
||||
|
||||
> * {
|
||||
margin-left: 12px;
|
||||
}
|
||||
|
||||
& span:nth-child(1) {
|
||||
display: inline-block;
|
||||
padding: 10px;
|
||||
|
||||
&:hover {
|
||||
color: @primary-color;
|
||||
}
|
||||
}
|
||||
|
||||
& span:nth-child(2) {
|
||||
&:hover {
|
||||
color: @error-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-modal-body {
|
||||
// background: #f1f2f6;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
&-large {
|
||||
top: 60px;
|
||||
|
||||
&--mini {
|
||||
top: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
&-header {
|
||||
// padding: 12.5px 24px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
&-content {
|
||||
box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19);
|
||||
}
|
||||
|
||||
&-footer {
|
||||
padding: 10px 26px 26px 16px;
|
||||
// border-top: none;
|
||||
|
||||
button + button {
|
||||
margin-left: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
&-close {
|
||||
font-weight: normal;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&-close-x {
|
||||
display: inline-block;
|
||||
width: 96px;
|
||||
height: 56px;
|
||||
line-height: 56px;
|
||||
}
|
||||
|
||||
&-confirm-body {
|
||||
.ant-modal-confirm-content {
|
||||
color: @text-color-help-dark;
|
||||
|
||||
> * {
|
||||
color: @text-color-help-dark;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&-confirm-confirm.error .ant-modal-confirm-body > .anticon {
|
||||
color: @error-color;
|
||||
}
|
||||
|
||||
&-confirm-btns {
|
||||
.ant-btn:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&-confirm-info {
|
||||
.ant-modal-confirm-body > .anticon {
|
||||
color: @warning-color;
|
||||
}
|
||||
}
|
||||
|
||||
&-confirm-confirm.success {
|
||||
.ant-modal-confirm-body > .anticon {
|
||||
color: @success-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-modal-confirm .ant-modal-body {
|
||||
padding: 24px !important;
|
||||
}
|
||||
@media screen and (max-height: 600px) {
|
||||
.ant-modal {
|
||||
top: 60px;
|
||||
}
|
||||
}
|
||||
@media screen and (max-height: 540px) {
|
||||
.ant-modal {
|
||||
top: 30px;
|
||||
}
|
||||
}
|
||||
@media screen and (max-height: 480px) {
|
||||
.ant-modal {
|
||||
top: 10px;
|
||||
}
|
||||
}
|
122
src/components/Modal/src/props.ts
Normal file
122
src/components/Modal/src/props.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import type { PropType } from 'vue';
|
||||
export const modalProps = {
|
||||
visible: Boolean as PropType<boolean>,
|
||||
// open drag
|
||||
draggable: {
|
||||
type: Boolean as PropType<boolean>,
|
||||
default: true,
|
||||
},
|
||||
centered: {
|
||||
type: Boolean as PropType<boolean>,
|
||||
default: false,
|
||||
},
|
||||
cancelText: {
|
||||
type: String as PropType<string>,
|
||||
default: '关闭',
|
||||
},
|
||||
okText: {
|
||||
type: String as PropType<string>,
|
||||
default: '保存',
|
||||
},
|
||||
closeFunc: Function as PropType<() => Promise<boolean>>,
|
||||
};
|
||||
|
||||
export const basicProps = Object.assign({}, modalProps, {
|
||||
// Can it be full screen
|
||||
canFullscreen: {
|
||||
type: Boolean as PropType<boolean>,
|
||||
default: true,
|
||||
},
|
||||
// After enabling the wrapper, the bottom can be increased in height
|
||||
wrapperFooterOffset: {
|
||||
type: Number as PropType<number>,
|
||||
default: 0,
|
||||
},
|
||||
// Warm reminder message
|
||||
helpMessage: [String, Array] as PropType<string | string[]>,
|
||||
// Whether to use wrapper
|
||||
useWrapper: {
|
||||
type: Boolean as PropType<boolean>,
|
||||
default: true,
|
||||
},
|
||||
loading: {
|
||||
type: Boolean as PropType<boolean>,
|
||||
default: false,
|
||||
},
|
||||
/**
|
||||
* @description: Show close button
|
||||
*/
|
||||
showCancelBtn: {
|
||||
type: Boolean as PropType<boolean>,
|
||||
default: true,
|
||||
},
|
||||
/**
|
||||
* @description: Show confirmation button
|
||||
*/
|
||||
showOkBtn: {
|
||||
type: Boolean as PropType<boolean>,
|
||||
default: true,
|
||||
},
|
||||
|
||||
wrapperProps: Object as PropType<any>,
|
||||
|
||||
afterClose: Function as PropType<() => Promise<any>>,
|
||||
|
||||
bodyStyle: Object as PropType<any>,
|
||||
|
||||
closable: {
|
||||
type: Boolean as PropType<boolean>,
|
||||
default: true,
|
||||
},
|
||||
|
||||
closeIcon: Object as PropType<any>,
|
||||
|
||||
confirmLoading: Boolean as PropType<boolean>,
|
||||
|
||||
destroyOnClose: Boolean as PropType<boolean>,
|
||||
|
||||
footer: Object as PropType<any>,
|
||||
|
||||
getContainer: Function as PropType<() => any>,
|
||||
|
||||
mask: {
|
||||
type: Boolean as PropType<boolean>,
|
||||
default: true,
|
||||
},
|
||||
|
||||
maskClosable: {
|
||||
type: Boolean as PropType<boolean>,
|
||||
default: true,
|
||||
},
|
||||
keyboard: {
|
||||
type: Boolean as PropType<boolean>,
|
||||
default: true,
|
||||
},
|
||||
|
||||
maskStyle: Object as PropType<any>,
|
||||
|
||||
okType: {
|
||||
type: String as PropType<string>,
|
||||
default: 'primary',
|
||||
},
|
||||
|
||||
okButtonProps: Object as PropType<any>,
|
||||
|
||||
cancelButtonProps: Object as PropType<any>,
|
||||
|
||||
title: {
|
||||
type: String as PropType<string>,
|
||||
},
|
||||
|
||||
visible: Boolean as PropType<boolean>,
|
||||
|
||||
width: [String, Number] as PropType<string | number>,
|
||||
|
||||
wrapClassName: {
|
||||
type: String as PropType<string>,
|
||||
},
|
||||
|
||||
zIndex: {
|
||||
type: Number as PropType<number>,
|
||||
},
|
||||
});
|
195
src/components/Modal/src/types.ts
Normal file
195
src/components/Modal/src/types.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import type { ButtonProps } from 'ant-design-vue/types/button/button';
|
||||
import type { CSSProperties, VNodeChild } from 'vue';
|
||||
/**
|
||||
* @description: 弹窗对外暴露的方法
|
||||
*/
|
||||
export interface ModalMethods {
|
||||
setModalProps: (props: Partial<ModalProps>) => void;
|
||||
}
|
||||
|
||||
export type RegisterFn = (modalMethods: ModalMethods, uuid?: string) => void;
|
||||
export interface ReturnMethods extends ModalMethods {
|
||||
openModal: (props?: boolean) => void;
|
||||
transferModalData: (data: any) => void;
|
||||
}
|
||||
export type UseModalReturnType = [RegisterFn, ReturnMethods];
|
||||
|
||||
export interface ReturnInnerMethods extends ModalMethods {
|
||||
closeModal: () => void;
|
||||
changeLoading: (loading: boolean) => void;
|
||||
changeOkLoading: (loading: boolean) => void;
|
||||
receiveModalDataRef: any;
|
||||
}
|
||||
export type UseModalInnerReturnType = [RegisterFn, ReturnInnerMethods];
|
||||
|
||||
export interface ModalProps {
|
||||
// 启用wrapper后 底部可以适当增加高度
|
||||
wrapperFooterOffset?: number;
|
||||
draggable?: boolean;
|
||||
|
||||
// 是否可以进行全屏
|
||||
canFullscreen?: boolean;
|
||||
visible?: boolean;
|
||||
// 温馨提醒信息
|
||||
helpMessage: string | string[];
|
||||
|
||||
// 是否使用modalWrapper
|
||||
useWrapper: boolean;
|
||||
|
||||
loading: boolean;
|
||||
|
||||
wrapperProps: Omit<ModalWrapperProps, 'loading'>;
|
||||
|
||||
showOkBtn: boolean;
|
||||
showCancelBtn: boolean;
|
||||
closeFunc: () => Promise<any>;
|
||||
|
||||
/**
|
||||
* Specify a function that will be called when modal is closed completely.
|
||||
* @type Function
|
||||
*/
|
||||
afterClose?: () => any;
|
||||
|
||||
/**
|
||||
* Body style for modal body element. Such as height, padding etc.
|
||||
* @default {}
|
||||
* @type object
|
||||
*/
|
||||
bodyStyle?: CSSProperties;
|
||||
|
||||
/**
|
||||
* Text of the Cancel button
|
||||
* @default 'cancel'
|
||||
* @type string
|
||||
*/
|
||||
cancelText?: string;
|
||||
|
||||
/**
|
||||
* Centered Modal
|
||||
* @default false
|
||||
* @type boolean
|
||||
*/
|
||||
centered?: boolean;
|
||||
|
||||
/**
|
||||
* Whether a close (x) button is visible on top right of the modal dialog or not
|
||||
* @default true
|
||||
* @type boolean
|
||||
*/
|
||||
closable?: boolean;
|
||||
/**
|
||||
* Whether a close (x) button is visible on top right of the modal dialog or not
|
||||
*/
|
||||
closeIcon?: VNodeChild | JSX.Element;
|
||||
|
||||
/**
|
||||
* Whether to apply loading visual effect for OK button or not
|
||||
* @default false
|
||||
* @type boolean
|
||||
*/
|
||||
confirmLoading?: boolean;
|
||||
|
||||
/**
|
||||
* Whether to unmount child components on onClose
|
||||
* @default false
|
||||
* @type boolean
|
||||
*/
|
||||
destroyOnClose?: boolean;
|
||||
|
||||
/**
|
||||
* Footer content, set as :footer="null" when you don't need default buttons
|
||||
* @default OK and Cancel buttons
|
||||
* @type any (string | slot)
|
||||
*/
|
||||
footer?: VNodeChild | JSX.Element;
|
||||
|
||||
/**
|
||||
* Return the mount node for Modal
|
||||
* @default () => document.body
|
||||
* @type Function
|
||||
*/
|
||||
getContainer?: (instance: any) => HTMLElement;
|
||||
|
||||
/**
|
||||
* Whether show mask or not.
|
||||
* @default true
|
||||
* @type boolean
|
||||
*/
|
||||
mask?: boolean;
|
||||
|
||||
/**
|
||||
* Whether to close the modal dialog when the mask (area outside the modal) is clicked
|
||||
* @default true
|
||||
* @type boolean
|
||||
*/
|
||||
maskClosable?: boolean;
|
||||
|
||||
/**
|
||||
* Style for modal's mask element.
|
||||
* @default {}
|
||||
* @type object
|
||||
*/
|
||||
maskStyle?: CSSProperties;
|
||||
|
||||
/**
|
||||
* Text of the OK button
|
||||
* @default 'OK'
|
||||
* @type string
|
||||
*/
|
||||
okText?: string;
|
||||
|
||||
/**
|
||||
* Button type of the OK button
|
||||
* @default 'primary'
|
||||
* @type string
|
||||
*/
|
||||
okType?: 'primary' | 'danger' | 'dashed' | 'ghost' | 'default';
|
||||
|
||||
/**
|
||||
* The ok button props, follow jsx rules
|
||||
* @type object
|
||||
*/
|
||||
okButtonProps?: ButtonProps;
|
||||
|
||||
/**
|
||||
* The cancel button props, follow jsx rules
|
||||
* @type object
|
||||
*/
|
||||
cancelButtonProps?: ButtonProps;
|
||||
|
||||
/**
|
||||
* The modal dialog's title
|
||||
* @type any (string | slot)
|
||||
*/
|
||||
title?: VNodeChild | JSX.Element;
|
||||
|
||||
/**
|
||||
* Width of the modal dialog
|
||||
* @default 520
|
||||
* @type string | number
|
||||
*/
|
||||
width?: string | number;
|
||||
|
||||
/**
|
||||
* The class name of the container of the modal dialog
|
||||
* @type string
|
||||
*/
|
||||
wrapClassName?: string;
|
||||
|
||||
/**
|
||||
* The z-index of the Modal
|
||||
* @default 1000
|
||||
* @type number
|
||||
*/
|
||||
zIndex?: number;
|
||||
}
|
||||
|
||||
export interface ModalWrapperProps {
|
||||
footerOffset?: number;
|
||||
loading: boolean;
|
||||
modalHeaderHeight: number;
|
||||
modalFooterHeight: number;
|
||||
minHeight: number;
|
||||
visible: boolean;
|
||||
fullScreen: boolean;
|
||||
}
|
99
src/components/Modal/src/useModal.ts
Normal file
99
src/components/Modal/src/useModal.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import type {
|
||||
UseModalReturnType,
|
||||
ModalMethods,
|
||||
ModalProps,
|
||||
ReturnMethods,
|
||||
UseModalInnerReturnType,
|
||||
} from './types';
|
||||
import { ref, onUnmounted, unref, getCurrentInstance, reactive, computed } from 'vue';
|
||||
import { isProdMode } from '/@/utils/env';
|
||||
const dataTransferRef = reactive<any>({});
|
||||
|
||||
/**
|
||||
* @description: Applicable to independent modal and call outside
|
||||
*/
|
||||
export function useModal(): UseModalReturnType {
|
||||
if (!getCurrentInstance()) {
|
||||
throw new Error('Please put useModal function in the setup function!');
|
||||
}
|
||||
const modalRef = ref<Nullable<ModalMethods>>(null);
|
||||
const loadedRef = ref<Nullable<boolean>>(false);
|
||||
const uidRef = ref<string>('');
|
||||
function register(modalMethod: ModalMethods, uuid: string) {
|
||||
uidRef.value = uuid;
|
||||
isProdMode() &&
|
||||
onUnmounted(() => {
|
||||
modalRef.value = null;
|
||||
loadedRef.value = false;
|
||||
dataTransferRef[unref(uidRef)] = null;
|
||||
});
|
||||
if (unref(loadedRef) && isProdMode() && modalMethod === unref(modalRef)) {
|
||||
return;
|
||||
}
|
||||
modalRef.value = modalMethod;
|
||||
}
|
||||
const getInstance = () => {
|
||||
const instance = unref(modalRef);
|
||||
if (!instance) {
|
||||
throw new Error('instance is undefined!');
|
||||
}
|
||||
return instance;
|
||||
};
|
||||
|
||||
const methods: ReturnMethods = {
|
||||
setModalProps: (props: Partial<ModalProps>): void => {
|
||||
getInstance().setModalProps(props);
|
||||
},
|
||||
openModal: (visible = true): void => {
|
||||
getInstance().setModalProps({
|
||||
visible: visible,
|
||||
});
|
||||
},
|
||||
transferModalData(val: any) {
|
||||
dataTransferRef[unref(uidRef)] = val;
|
||||
},
|
||||
};
|
||||
return [register, methods];
|
||||
}
|
||||
|
||||
export const useModalInner = (): UseModalInnerReturnType => {
|
||||
const modalInstanceRef = ref<ModalMethods | null>(null);
|
||||
const currentInstall = getCurrentInstance();
|
||||
const uidRef = ref<string>('');
|
||||
|
||||
if (!currentInstall) {
|
||||
throw new Error('instance is undefined!');
|
||||
}
|
||||
const getInstance = () => {
|
||||
const instance = unref(modalInstanceRef);
|
||||
if (!instance) {
|
||||
throw new Error('instance is undefined!');
|
||||
}
|
||||
return instance;
|
||||
};
|
||||
const register = (modalInstance: ModalMethods, uuid: string) => {
|
||||
uidRef.value = uuid;
|
||||
modalInstanceRef.value = modalInstance;
|
||||
currentInstall.emit('register', modalInstance);
|
||||
};
|
||||
return [
|
||||
register,
|
||||
{
|
||||
receiveModalDataRef: computed(() => {
|
||||
return dataTransferRef[unref(uidRef)];
|
||||
}),
|
||||
changeLoading: (loading = true) => {
|
||||
getInstance().setModalProps({ loading });
|
||||
},
|
||||
changeOkLoading: (loading = true) => {
|
||||
getInstance().setModalProps({ confirmLoading: loading });
|
||||
},
|
||||
closeModal: () => {
|
||||
getInstance().setModalProps({ visible: false });
|
||||
},
|
||||
setModalProps: (props: Partial<ModalProps>) => {
|
||||
getInstance().setModalProps(props);
|
||||
},
|
||||
},
|
||||
];
|
||||
};
|
1
src/components/Preview/index.ts
Normal file
1
src/components/Preview/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { createImgPreview } from './src/functional';
|
21
src/components/Preview/src/functional.ts
Normal file
21
src/components/Preview/src/functional.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import ImgPreview from './index';
|
||||
import { isClient } from '/@/utils/is';
|
||||
|
||||
import type { Options, Props } from './types';
|
||||
|
||||
import { createApp } from 'vue';
|
||||
|
||||
export function createImgPreview(options: Options) {
|
||||
if (!isClient) return;
|
||||
const { imageList, show = true, index = 0 } = options;
|
||||
|
||||
const propsData: Partial<Props> = {};
|
||||
const wrapDom = document.createElement('div');
|
||||
propsData.imageList = imageList;
|
||||
propsData.show = show;
|
||||
propsData.index = index;
|
||||
const imgDom = createApp(ImgPreview, propsData);
|
||||
imgDom.mount(wrapDom);
|
||||
const imgPreviewDom = wrapDom.children[0];
|
||||
document.body.appendChild(imgPreviewDom);
|
||||
}
|
119
src/components/Preview/src/index.less
Normal file
119
src/components/Preview/src/index.less
Normal file
@@ -0,0 +1,119 @@
|
||||
@import (reference) '../../../design/index.less';
|
||||
|
||||
.img-preview {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 1000;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
user-select: none;
|
||||
|
||||
&-content {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
color: @white;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&-image {
|
||||
cursor: pointer;
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
|
||||
&__close {
|
||||
position: absolute;
|
||||
top: -40px;
|
||||
right: -40px;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
overflow: hidden;
|
||||
color: @white;
|
||||
cursor: pointer;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
border-radius: 50%;
|
||||
transition: all 0.2s;
|
||||
|
||||
&-icon {
|
||||
position: absolute;
|
||||
top: 46px;
|
||||
left: 16px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
}
|
||||
|
||||
&__index {
|
||||
position: absolute;
|
||||
bottom: 5%;
|
||||
left: 50%;
|
||||
padding: 0 22px;
|
||||
font-size: 16px;
|
||||
background: rgba(109, 109, 109, 0.6);
|
||||
border-radius: 15px;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
&__controller {
|
||||
position: absolute;
|
||||
bottom: 10%;
|
||||
left: 50%;
|
||||
display: flex;
|
||||
width: 260px;
|
||||
height: 44px;
|
||||
padding: 0 22px;
|
||||
margin-left: -139px;
|
||||
background: rgba(109, 109, 109, 0.6);
|
||||
border-radius: 22px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
&-item {
|
||||
padding: 0 9px;
|
||||
font-size: 24px;
|
||||
line-height: 44px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
img {
|
||||
width: 1em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__arrow {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
font-size: 28px;
|
||||
line-height: 50px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
border-radius: 50%;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
&.left {
|
||||
left: 50px;
|
||||
}
|
||||
|
||||
&.right {
|
||||
right: 50px;
|
||||
}
|
||||
}
|
||||
}
|
318
src/components/Preview/src/index.tsx
Normal file
318
src/components/Preview/src/index.tsx
Normal file
@@ -0,0 +1,318 @@
|
||||
import { defineComponent, ref, unref, computed, reactive, watch } from 'vue';
|
||||
|
||||
import { FadeTransition } from '/@/components/Transition/index';
|
||||
|
||||
import { basicProps } from './props';
|
||||
import { Props } from './types';
|
||||
import './index.less';
|
||||
|
||||
import { CloseOutlined, LeftOutlined, RightOutlined } from '@ant-design/icons-vue';
|
||||
|
||||
import resumeSvg from '/@/assets/svg/preview/resume.svg';
|
||||
import rotateSvg from '/@/assets/svg/preview/p-rotate.svg';
|
||||
import scaleSvg from '/@/assets/svg/preview/scale.svg';
|
||||
import unscaleSvg from '/@/assets/svg/preview/unscale.svg';
|
||||
import loadingSvg from '/@/assets/images/loading.svg';
|
||||
import unrotateSvg from '/@/assets/svg/preview/unrotate.svg';
|
||||
enum StatueEnum {
|
||||
LOADING,
|
||||
DONE,
|
||||
FAIL,
|
||||
}
|
||||
interface ImgState {
|
||||
currentUrl: string;
|
||||
imgScale: number;
|
||||
imgRotate: number;
|
||||
imgTop: number;
|
||||
imgLeft: number;
|
||||
currentIndex: number;
|
||||
status: StatueEnum;
|
||||
moveX: number;
|
||||
moveY: number;
|
||||
}
|
||||
|
||||
const prefixCls = 'img-preview';
|
||||
export default defineComponent({
|
||||
name: 'ImagePreview',
|
||||
props: basicProps,
|
||||
setup(props: Props) {
|
||||
const imgState = reactive<ImgState>({
|
||||
currentUrl: '',
|
||||
imgScale: 1,
|
||||
imgRotate: 0,
|
||||
imgTop: 0,
|
||||
imgLeft: 0,
|
||||
status: StatueEnum.LOADING,
|
||||
currentIndex: 0,
|
||||
moveX: 0,
|
||||
moveY: 0,
|
||||
});
|
||||
const wrapElRef = ref<HTMLDivElement | null>(null);
|
||||
const imgElRef = ref<HTMLImageElement | null>(null);
|
||||
|
||||
// 初始化
|
||||
function init() {
|
||||
initMouseWheel();
|
||||
const { index, imageList } = props;
|
||||
|
||||
if (!imageList || !imageList.length) {
|
||||
throw new Error('imageList is undefined');
|
||||
}
|
||||
imgState.currentIndex = index;
|
||||
handleIChangeImage(imageList[index]);
|
||||
}
|
||||
|
||||
// 重置
|
||||
function initState() {
|
||||
imgState.imgScale = 1;
|
||||
imgState.imgRotate = 0;
|
||||
imgState.imgTop = 0;
|
||||
imgState.imgLeft = 0;
|
||||
}
|
||||
|
||||
// 初始化鼠标滚轮事件
|
||||
function initMouseWheel() {
|
||||
const wrapEl = unref(wrapElRef);
|
||||
if (!wrapEl) {
|
||||
return;
|
||||
}
|
||||
(wrapEl as any).onmousewheel = scrollFunc;
|
||||
// 火狐浏览器没有onmousewheel事件,用DOMMouseScroll代替
|
||||
document.body.addEventListener('DOMMouseScroll', scrollFunc);
|
||||
// 禁止火狐浏览器下拖拽图片的默认事件
|
||||
document.ondragstart = function () {
|
||||
return false;
|
||||
};
|
||||
}
|
||||
|
||||
// 监听鼠标滚轮
|
||||
function scrollFunc(e: any) {
|
||||
e = e || window.event;
|
||||
e.delta = e.wheelDelta || -e.detail;
|
||||
|
||||
e.preventDefault();
|
||||
if (e.delta > 0) {
|
||||
// 滑轮向上滚动
|
||||
scaleFunc(0.015);
|
||||
}
|
||||
if (e.delta < 0) {
|
||||
// 滑轮向下滚动
|
||||
scaleFunc(-0.015);
|
||||
}
|
||||
}
|
||||
// 缩放函数
|
||||
function scaleFunc(num: number) {
|
||||
if (imgState.imgScale <= 0.2 && num < 0) return;
|
||||
imgState.imgScale += num;
|
||||
}
|
||||
|
||||
// 旋转图片
|
||||
function rotateFunc(deg: number) {
|
||||
imgState.imgRotate += deg;
|
||||
}
|
||||
|
||||
// 鼠标事件
|
||||
function handleMouseUp() {
|
||||
const imgEl = unref(imgElRef);
|
||||
if (!imgEl) return;
|
||||
imgEl.onmousemove = null;
|
||||
}
|
||||
|
||||
// 更换图片
|
||||
function handleIChangeImage(url: string) {
|
||||
imgState.status = StatueEnum.LOADING;
|
||||
const img = new Image();
|
||||
img.src = url;
|
||||
img.onload = () => {
|
||||
imgState.currentUrl = url;
|
||||
imgState.status = StatueEnum.DONE;
|
||||
};
|
||||
img.onerror = () => {
|
||||
imgState.status = StatueEnum.FAIL;
|
||||
};
|
||||
}
|
||||
|
||||
// 关闭
|
||||
function handleClose() {
|
||||
const { instance } = props;
|
||||
if (instance) {
|
||||
instance.show = false;
|
||||
}
|
||||
// 移除火狐浏览器下的鼠标滚动事件
|
||||
document.body.removeEventListener('DOMMouseScroll', scrollFunc);
|
||||
// 恢复火狐及Safari浏览器下的图片拖拽
|
||||
document.ondragstart = null;
|
||||
}
|
||||
// 图片复原
|
||||
function resume() {
|
||||
initState();
|
||||
}
|
||||
|
||||
// 上一页下一页
|
||||
function handleChange(direction: 'left' | 'right') {
|
||||
const { currentIndex } = imgState;
|
||||
const { imageList } = props;
|
||||
if (direction === 'left') {
|
||||
imgState.currentIndex--;
|
||||
if (currentIndex <= 0) {
|
||||
imgState.currentIndex = imageList.length - 1;
|
||||
}
|
||||
}
|
||||
if (direction === 'right') {
|
||||
imgState.currentIndex++;
|
||||
if (currentIndex >= imageList.length - 1) {
|
||||
imgState.currentIndex = 0;
|
||||
}
|
||||
}
|
||||
handleIChangeImage(imageList[imgState.currentIndex]);
|
||||
}
|
||||
|
||||
function handleAddMoveListener(e: MouseEvent) {
|
||||
e = e || window.event;
|
||||
imgState.moveX = e.clientX;
|
||||
imgState.moveY = e.clientY;
|
||||
const imgEl = unref(imgElRef);
|
||||
if (imgEl) {
|
||||
imgEl.onmousemove = moveFunc;
|
||||
}
|
||||
}
|
||||
|
||||
function moveFunc(e: MouseEvent) {
|
||||
e = e || window.event;
|
||||
e.preventDefault();
|
||||
const movementX = e.clientX - imgState.moveX;
|
||||
const movementY = e.clientY - imgState.moveY;
|
||||
imgState.imgLeft += movementX;
|
||||
imgState.imgTop += movementY;
|
||||
imgState.moveX = e.clientX;
|
||||
imgState.moveY = e.clientY;
|
||||
}
|
||||
|
||||
// 获取图片样式
|
||||
const getImageStyle = computed(() => {
|
||||
const { imgScale, imgRotate, imgTop, imgLeft } = imgState;
|
||||
return {
|
||||
transform: `scale(${imgScale}) rotate(${imgRotate}deg)`,
|
||||
marginTop: `${imgTop}px`,
|
||||
marginLeft: `${imgLeft}px`,
|
||||
};
|
||||
});
|
||||
|
||||
const getIsMultipleImage = computed(() => {
|
||||
const { imageList } = props;
|
||||
return imageList.length > 1;
|
||||
});
|
||||
watch(
|
||||
() => props.show,
|
||||
(show) => {
|
||||
if (show) {
|
||||
init();
|
||||
}
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
}
|
||||
);
|
||||
watch(
|
||||
() => props.imageList,
|
||||
() => {
|
||||
initState();
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
}
|
||||
);
|
||||
|
||||
const renderClose = () => {
|
||||
return (
|
||||
<div class={`${prefixCls}__close`} onClick={handleClose}>
|
||||
<CloseOutlined class={`${prefixCls}__close-icon`} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
const renderIndex = () => {
|
||||
if (!unref(getIsMultipleImage)) {
|
||||
return null;
|
||||
}
|
||||
const { currentIndex } = imgState;
|
||||
const { imageList } = props;
|
||||
return (
|
||||
<div class={`${prefixCls}__index`}>
|
||||
{currentIndex + 1} / {imageList.length}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderController = () => {
|
||||
return (
|
||||
<div class={`${prefixCls}__controller`}>
|
||||
<div class={`${prefixCls}__controller-item`} onClick={() => scaleFunc(-0.15)}>
|
||||
<img src={unscaleSvg} />
|
||||
</div>
|
||||
<div class={`${prefixCls}__controller-item`} onClick={() => scaleFunc(0.15)}>
|
||||
<img src={scaleSvg} />
|
||||
</div>
|
||||
<div class={`${prefixCls}__controller-item`} onClick={resume}>
|
||||
<img src={resumeSvg} />
|
||||
</div>
|
||||
<div class={`${prefixCls}__controller-item`} onClick={() => rotateFunc(-90)}>
|
||||
<img src={unrotateSvg} />
|
||||
</div>
|
||||
<div class={`${prefixCls}__controller-item`} onClick={() => rotateFunc(90)}>
|
||||
<img src={rotateSvg} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderArrow = (direction: 'left' | 'right') => {
|
||||
if (!unref(getIsMultipleImage)) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div class={[`${prefixCls}__arrow`, direction]} onClick={() => handleChange(direction)}>
|
||||
{direction === 'left' ? <LeftOutlined /> : <RightOutlined />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
return () => {
|
||||
return (
|
||||
<FadeTransition>
|
||||
{() =>
|
||||
props.show && (
|
||||
<div class={prefixCls} ref={wrapElRef} onMouseup={handleMouseUp}>
|
||||
<div class={`${prefixCls}-content`}>
|
||||
<img
|
||||
width="32"
|
||||
src={loadingSvg}
|
||||
v-show={imgState.status === StatueEnum.LOADING}
|
||||
class={`${prefixCls}-image`}
|
||||
/>
|
||||
<img
|
||||
v-show={imgState.status === StatueEnum.DONE}
|
||||
style={unref(getImageStyle)}
|
||||
class={`${prefixCls}-image`}
|
||||
ref={imgElRef}
|
||||
src={imgState.currentUrl}
|
||||
onMousedown={handleAddMoveListener}
|
||||
/>
|
||||
<img
|
||||
width="32"
|
||||
src={loadingSvg}
|
||||
v-show={imgState.status === StatueEnum.LOADING}
|
||||
class={`${prefixCls}-image`}
|
||||
/>
|
||||
{renderClose()}
|
||||
{renderIndex()}
|
||||
{renderController()}
|
||||
{renderArrow('left')}
|
||||
{renderArrow('right')}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</FadeTransition>
|
||||
);
|
||||
};
|
||||
},
|
||||
});
|
20
src/components/Preview/src/props.ts
Normal file
20
src/components/Preview/src/props.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { PropType } from 'vue';
|
||||
import { Props } from './types';
|
||||
export const basicProps = {
|
||||
show: {
|
||||
type: Boolean as PropType<boolean>,
|
||||
default: false,
|
||||
},
|
||||
instance: {
|
||||
type: Object as PropType<Props>,
|
||||
default: null,
|
||||
},
|
||||
imageList: {
|
||||
type: [Array] as PropType<string[]>,
|
||||
default: null,
|
||||
},
|
||||
index: {
|
||||
type: Number as PropType<number>,
|
||||
default: 0,
|
||||
},
|
||||
};
|
12
src/components/Preview/src/types.ts
Normal file
12
src/components/Preview/src/types.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export interface Options {
|
||||
show?: boolean;
|
||||
imageList: string[];
|
||||
index?: number;
|
||||
}
|
||||
|
||||
export interface Props {
|
||||
show: boolean;
|
||||
instance: Props;
|
||||
imageList: string[];
|
||||
index: number;
|
||||
}
|
2
src/components/Qrcode/index.ts
Normal file
2
src/components/Qrcode/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as QrCode } from './src/index.vue';
|
||||
export * from './src/types';
|
29
src/components/Qrcode/src/drawCanvas.ts
Normal file
29
src/components/Qrcode/src/drawCanvas.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { toCanvas } from 'qrcode';
|
||||
import type { QRCodeRenderersOptions } from 'qrcode';
|
||||
import { RenderQrCodeParams, ContentType } from './types';
|
||||
export const renderQrCode = ({ canvas, content, width = 0, options = {} }: RenderQrCodeParams) => {
|
||||
// 容错率,默认对内容少的二维码采用高容错率,内容多的二维码采用低容错率
|
||||
options.errorCorrectionLevel = options.errorCorrectionLevel || getErrorCorrectionLevel(content);
|
||||
|
||||
return getOriginWidth(content, options).then((_width: number) => {
|
||||
options.scale = width === 0 ? undefined : (width / _width) * 4;
|
||||
return toCanvas(canvas, content, options);
|
||||
});
|
||||
};
|
||||
|
||||
// 得到原QrCode的大小,以便缩放得到正确的QrCode大小
|
||||
function getOriginWidth(content: ContentType, options: QRCodeRenderersOptions) {
|
||||
const _canvas = document.createElement('canvas');
|
||||
return toCanvas(_canvas, content, options).then(() => _canvas.width);
|
||||
}
|
||||
|
||||
// 对于内容少的QrCode,增大容错率
|
||||
function getErrorCorrectionLevel(content: ContentType) {
|
||||
if (content.length > 36) {
|
||||
return 'M';
|
||||
} else if (content.length > 16) {
|
||||
return 'Q';
|
||||
} else {
|
||||
return 'H';
|
||||
}
|
||||
}
|
89
src/components/Qrcode/src/drawLogo.ts
Normal file
89
src/components/Qrcode/src/drawLogo.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { isString } from '/@/utils/is';
|
||||
import { RenderQrCodeParams, LogoType } from './types';
|
||||
export const drawLogo = ({ canvas, logo }: RenderQrCodeParams) => {
|
||||
if (!logo) {
|
||||
return new Promise((resolve) => {
|
||||
resolve((canvas as HTMLCanvasElement).toDataURL());
|
||||
});
|
||||
}
|
||||
|
||||
const canvasWidth = (canvas as HTMLCanvasElement).width;
|
||||
const {
|
||||
logoSize = 0.15,
|
||||
bgColor = '#ffffff',
|
||||
borderSize = 0.05,
|
||||
crossOrigin,
|
||||
borderRadius = 8,
|
||||
logoRadius = 0,
|
||||
} = logo as LogoType;
|
||||
|
||||
const logoSrc: string = isString(logo) ? logo : logo.src;
|
||||
const logoWidth = canvasWidth * logoSize;
|
||||
const logoXY = (canvasWidth * (1 - logoSize)) / 2;
|
||||
const logoBgWidth = canvasWidth * (logoSize + borderSize);
|
||||
const logoBgXY = (canvasWidth * (1 - logoSize - borderSize)) / 2;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
// logo 底色
|
||||
canvasRoundRect(ctx)(logoBgXY, logoBgXY, logoBgWidth, logoBgWidth, borderRadius);
|
||||
ctx.fillStyle = bgColor;
|
||||
ctx.fill();
|
||||
|
||||
// logo
|
||||
const image = new Image();
|
||||
if (crossOrigin || logoRadius) {
|
||||
image.setAttribute('crossOrigin', crossOrigin || 'anonymous');
|
||||
}
|
||||
image.src = logoSrc;
|
||||
|
||||
// 使用image绘制可以避免某些跨域情况
|
||||
const drawLogoWithImage = (image: CanvasImageSource) => {
|
||||
ctx.drawImage(image, logoXY, logoXY, logoWidth, logoWidth);
|
||||
};
|
||||
|
||||
// 使用canvas绘制以获得更多的功能
|
||||
const drawLogoWithCanvas = (image: HTMLImageElement) => {
|
||||
const canvasImage = document.createElement('canvas');
|
||||
canvasImage.width = logoXY + logoWidth;
|
||||
canvasImage.height = logoXY + logoWidth;
|
||||
const imageCanvas = canvasImage.getContext('2d');
|
||||
if (!imageCanvas || !ctx) return;
|
||||
imageCanvas.drawImage(image, logoXY, logoXY, logoWidth, logoWidth);
|
||||
|
||||
canvasRoundRect(ctx)(logoXY, logoXY, logoWidth, logoWidth, logoRadius);
|
||||
if (!ctx) return;
|
||||
const fillStyle = ctx.createPattern(canvasImage, 'no-repeat');
|
||||
if (fillStyle) {
|
||||
ctx.fillStyle = fillStyle;
|
||||
ctx.fill();
|
||||
}
|
||||
};
|
||||
|
||||
// 将 logo绘制到 canvas上
|
||||
return new Promise((resolve) => {
|
||||
image.onload = () => {
|
||||
logoRadius ? drawLogoWithCanvas(image) : drawLogoWithImage(image);
|
||||
resolve((canvas as HTMLCanvasElement).toDataURL());
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
// copy来的方法,用于绘制圆角
|
||||
function canvasRoundRect(ctx: CanvasRenderingContext2D) {
|
||||
return (x: number, y: number, w: number, h: number, r: number) => {
|
||||
const minSize = Math.min(w, h);
|
||||
if (r > minSize / 2) {
|
||||
r = minSize / 2;
|
||||
}
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + r, y);
|
||||
ctx.arcTo(x + w, y, x + w, y + h, r);
|
||||
ctx.arcTo(x + w, y + h, x, y + h, r);
|
||||
ctx.arcTo(x, y + h, x, y, r);
|
||||
ctx.arcTo(x, y, x + w, y, r);
|
||||
ctx.closePath();
|
||||
return ctx;
|
||||
};
|
||||
}
|
101
src/components/Qrcode/src/index.vue
Normal file
101
src/components/Qrcode/src/index.vue
Normal file
@@ -0,0 +1,101 @@
|
||||
<template>
|
||||
<div>
|
||||
<component :is="tag" ref="wrapRef"></component>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import { defineComponent, watchEffect, PropType, ref, unref } from 'vue';
|
||||
import { toCanvas, QRCodeRenderersOptions, LogoType } from './qrcodePlus';
|
||||
import { toDataURL } from 'qrcode';
|
||||
import { downloadByUrl } from '/@/utils/file/FileDownload';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'QrCode',
|
||||
emits: { done: (url: string) => !!url, error: (error: any) => !!error },
|
||||
props: {
|
||||
value: {
|
||||
type: [String, Array] as PropType<string | any[]>,
|
||||
default: null,
|
||||
},
|
||||
// 参数
|
||||
options: {
|
||||
type: Object as PropType<QRCodeRenderersOptions>,
|
||||
default: null,
|
||||
},
|
||||
// 宽度
|
||||
width: {
|
||||
type: Number as PropType<number>,
|
||||
default: 200,
|
||||
},
|
||||
// 中间logo图标
|
||||
logo: {
|
||||
type: [String, Object] as PropType<Partial<LogoType> | string>,
|
||||
default: '',
|
||||
},
|
||||
// img 不支持内嵌logo
|
||||
tag: {
|
||||
type: String as PropType<'canvas' | 'img'>,
|
||||
default: 'canvas',
|
||||
validator: (v: string) => ['canvas', 'img'].includes(v),
|
||||
},
|
||||
},
|
||||
setup(props, { emit }) {
|
||||
const wrapRef = ref<HTMLCanvasElement | HTMLImageElement | null>(null);
|
||||
const urlRef = ref<string>('');
|
||||
async function createQrcode() {
|
||||
try {
|
||||
const { tag, value, options = {}, width, logo } = props;
|
||||
const renderValue = String(value);
|
||||
const wrapEl = unref(wrapRef);
|
||||
|
||||
if (!wrapEl) return;
|
||||
|
||||
if (tag === 'canvas') {
|
||||
const url: string = await toCanvas({
|
||||
canvas: wrapEl,
|
||||
width,
|
||||
logo: logo as any,
|
||||
content: renderValue,
|
||||
options: options || {},
|
||||
});
|
||||
urlRef.value = url;
|
||||
emit('done', url);
|
||||
return;
|
||||
}
|
||||
|
||||
if (tag === 'img') {
|
||||
const url = await toDataURL(renderValue, {
|
||||
errorCorrectionLevel: 'H',
|
||||
width,
|
||||
...options,
|
||||
});
|
||||
(unref(wrapRef) as HTMLImageElement).src = url;
|
||||
urlRef.value = url;
|
||||
emit('done', url);
|
||||
}
|
||||
} catch (error) {
|
||||
emit('error', error);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* file download
|
||||
*/
|
||||
function download(fileName?: string) {
|
||||
const url = unref(urlRef);
|
||||
if (!url) return;
|
||||
downloadByUrl({
|
||||
url,
|
||||
fileName,
|
||||
});
|
||||
}
|
||||
|
||||
watchEffect(() => {
|
||||
setTimeout(() => {
|
||||
createQrcode();
|
||||
}, 30);
|
||||
});
|
||||
|
||||
return { wrapRef, download };
|
||||
},
|
||||
});
|
||||
</script>
|
5
src/components/Qrcode/src/qrcodePlus.ts
Normal file
5
src/components/Qrcode/src/qrcodePlus.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
// 参考 qr-code-with-logo 进行ts版本修改
|
||||
import { toCanvas } from './toCanvas';
|
||||
export * from './types';
|
||||
|
||||
export { toCanvas };
|
10
src/components/Qrcode/src/toCanvas.ts
Normal file
10
src/components/Qrcode/src/toCanvas.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { renderQrCode } from './drawCanvas';
|
||||
import { drawLogo } from './drawLogo';
|
||||
import { RenderQrCodeParams } from './types';
|
||||
export const toCanvas = (options: RenderQrCodeParams) => {
|
||||
return renderQrCode(options)
|
||||
.then(() => {
|
||||
return options;
|
||||
})
|
||||
.then(drawLogo) as Promise<string>;
|
||||
};
|
33
src/components/Qrcode/src/types.ts
Normal file
33
src/components/Qrcode/src/types.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { QRCodeSegment, QRCodeRenderersOptions } from 'qrcode';
|
||||
|
||||
export type ContentType = string | QRCodeSegment[];
|
||||
|
||||
export type { QRCodeRenderersOptions };
|
||||
|
||||
export type LogoType = {
|
||||
src: string;
|
||||
logoSize: number;
|
||||
borderColor: string;
|
||||
bgColor: string;
|
||||
borderSize: number;
|
||||
crossOrigin: string;
|
||||
borderRadius: number;
|
||||
logoRadius: number;
|
||||
};
|
||||
|
||||
export interface RenderQrCodeParams {
|
||||
canvas: any;
|
||||
content: ContentType;
|
||||
width?: number;
|
||||
options?: QRCodeRenderersOptions;
|
||||
logo?: LogoType | string;
|
||||
image?: HTMLImageElement;
|
||||
downloadName?: string;
|
||||
download?: boolean | Fn;
|
||||
}
|
||||
|
||||
export type ToCanvasFn = (options: RenderQrCodeParams) => Promise<unknown>;
|
||||
|
||||
export interface QrCodeActionType {
|
||||
download: (fileName?: string) => void;
|
||||
}
|
5
src/components/Scrollbar/index.ts
Normal file
5
src/components/Scrollbar/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* copy from element-ui
|
||||
*/
|
||||
|
||||
export { default as Scrollbar } from './src/Scrollbar';
|
106
src/components/Scrollbar/src/Bar.tsx
Normal file
106
src/components/Scrollbar/src/Bar.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import type { PropType } from 'vue';
|
||||
|
||||
import { renderThumbStyle, BAR_MAP } from './util';
|
||||
import { defineComponent, computed, unref, inject, Ref, reactive, ref, onBeforeUnmount } from 'vue';
|
||||
import { on, off } from '/@/utils/domUtils';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'Bar',
|
||||
props: {
|
||||
vertical: {
|
||||
type: Boolean as PropType<boolean>,
|
||||
default: false,
|
||||
},
|
||||
size: String as PropType<string>,
|
||||
move: Number as PropType<number>,
|
||||
},
|
||||
setup(props) {
|
||||
const thumbRef = ref<Nullable<HTMLDivElement>>(null);
|
||||
const elRef = ref<Nullable<HTMLDivElement>>(null);
|
||||
const commonState = reactive<KeyString>({});
|
||||
const getBarRef = computed(() => {
|
||||
return BAR_MAP[props.vertical ? 'vertical' : 'horizontal'];
|
||||
});
|
||||
const parentElRef = inject('scroll-bar-wrap') as Ref<Nullable<HTMLDivElement>>;
|
||||
|
||||
function clickThumbHandler(e: any) {
|
||||
const { ctrlKey, button, currentTarget } = e;
|
||||
// prevent click event of right button
|
||||
if (ctrlKey || button === 2 || !currentTarget) {
|
||||
return;
|
||||
}
|
||||
startDrag(e);
|
||||
const bar = unref(getBarRef);
|
||||
commonState[bar.axis] =
|
||||
currentTarget[bar.offset] -
|
||||
(e[bar.client as keyof typeof e] - currentTarget.getBoundingClientRect()[bar.direction]);
|
||||
}
|
||||
|
||||
function clickTrackHandler(e: any) {
|
||||
const bar = unref(getBarRef);
|
||||
const offset = Math.abs(e.target.getBoundingClientRect()[bar.direction] - e[bar.client]);
|
||||
const thumbEl = unref(thumbRef) as any;
|
||||
const parentEl = unref(parentElRef) as any;
|
||||
const el = unref(elRef) as any;
|
||||
if (!thumbEl || !el || !parentEl) return;
|
||||
const thumbHalf = thumbEl[bar.offset] / 2;
|
||||
const thumbPositionPercentage = ((offset - thumbHalf) * 100) / el[bar.offset];
|
||||
parentEl[bar.scroll] = (thumbPositionPercentage * parentEl[bar.scrollSize]) / 100;
|
||||
}
|
||||
|
||||
function startDrag(e: Event) {
|
||||
e.stopImmediatePropagation();
|
||||
commonState.cursorDown = true;
|
||||
|
||||
on(document, 'mousemove', mouseMoveDocumentHandler);
|
||||
on(document, 'mouseup', mouseUpDocumentHandler);
|
||||
document.onselectstart = () => false;
|
||||
}
|
||||
|
||||
function mouseMoveDocumentHandler(e: any) {
|
||||
if (commonState.cursorDown === false) return;
|
||||
const bar = unref(getBarRef);
|
||||
const prevPage = commonState[bar.axis];
|
||||
const el = unref(elRef) as any;
|
||||
const parentEl = unref(parentElRef) as any;
|
||||
const thumbEl = unref(thumbRef) as any;
|
||||
if (!prevPage || !el || !thumbEl || !parentEl) return;
|
||||
const rect = el.getBoundingClientRect() as any;
|
||||
const offset = (rect[bar.direction] - e[bar.client]) * -1;
|
||||
const thumbClickPosition = thumbEl[bar.offset] - prevPage;
|
||||
const thumbPositionPercentage = ((offset - thumbClickPosition) * 100) / el[bar.offset];
|
||||
|
||||
parentEl[bar.scroll] = (thumbPositionPercentage * parentEl[bar.scrollSize]) / 100;
|
||||
}
|
||||
|
||||
function mouseUpDocumentHandler() {
|
||||
const bar = unref(getBarRef);
|
||||
commonState.cursorDown = false;
|
||||
commonState[bar.axis] = 0;
|
||||
off(document, 'mousemove', mouseMoveDocumentHandler);
|
||||
document.onselectstart = null;
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
off(document, 'mouseup', mouseUpDocumentHandler);
|
||||
});
|
||||
return () => {
|
||||
const bar = unref(getBarRef);
|
||||
const { size, move } = props;
|
||||
return (
|
||||
<div
|
||||
class={['scrollbar__bar', 'is-' + bar.key]}
|
||||
onMousedown={clickTrackHandler}
|
||||
ref={elRef}
|
||||
>
|
||||
<div
|
||||
ref={thumbRef}
|
||||
class="scrollbar__thumb"
|
||||
onMousedown={clickThumbHandler}
|
||||
style={renderThumbStyle({ size, move, bar })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
},
|
||||
});
|
146
src/components/Scrollbar/src/Scrollbar.tsx
Normal file
146
src/components/Scrollbar/src/Scrollbar.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import { addResizeListener, removeResizeListener } from '/@/utils/event/resizeEvent';
|
||||
import scrollbarWidth from '/@/utils/scrollbarWidth';
|
||||
import { toObject } from './util';
|
||||
import Bar from './Bar';
|
||||
import { isString } from '/@/utils/is';
|
||||
import {
|
||||
defineComponent,
|
||||
PropType,
|
||||
unref,
|
||||
reactive,
|
||||
ref,
|
||||
provide,
|
||||
onMounted,
|
||||
nextTick,
|
||||
onBeforeUnmount,
|
||||
} from 'vue';
|
||||
import { getSlot } from '/@/utils/helper/tsxHelper';
|
||||
import { tryTsxEmit } from '/@/utils/helper/vueHelper';
|
||||
import './index.less';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'Scrollbar',
|
||||
props: {
|
||||
native: Boolean as PropType<boolean>,
|
||||
wrapStyle: {
|
||||
type: Object as PropType<any>,
|
||||
},
|
||||
wrapClass: { type: String as PropType<string>, required: false },
|
||||
viewClass: { type: String as PropType<string> },
|
||||
viewStyle: { type: Object as PropType<any> },
|
||||
noresize: Boolean as PropType<boolean>,
|
||||
tag: {
|
||||
type: String as PropType<string>,
|
||||
default: 'div',
|
||||
},
|
||||
},
|
||||
setup(props, { slots }) {
|
||||
const resizeRef = ref<Nullable<HTMLDivElement>>(null);
|
||||
const wrapElRef = ref<Nullable<HTMLDivElement>>(null);
|
||||
provide('scroll-bar-wrap', wrapElRef);
|
||||
const state = reactive({
|
||||
sizeWidth: '0',
|
||||
sizeHeight: '0',
|
||||
moveX: 0,
|
||||
moveY: 0,
|
||||
});
|
||||
|
||||
function handleScroll() {
|
||||
const warpEl = unref(wrapElRef);
|
||||
if (!warpEl) return;
|
||||
const { scrollTop, scrollLeft, clientHeight, clientWidth } = warpEl;
|
||||
|
||||
state.moveY = (scrollTop * 100) / clientHeight;
|
||||
state.moveX = (scrollLeft * 100) / clientWidth;
|
||||
}
|
||||
function update() {
|
||||
const warpEl = unref(wrapElRef);
|
||||
if (!warpEl) return;
|
||||
const { scrollHeight, scrollWidth, clientHeight, clientWidth } = warpEl;
|
||||
const heightPercentage = (clientHeight * 100) / scrollHeight;
|
||||
const widthPercentage = (clientWidth * 100) / scrollWidth;
|
||||
|
||||
state.sizeHeight = heightPercentage < 100 ? heightPercentage + '%' : '';
|
||||
state.sizeWidth = widthPercentage < 100 ? widthPercentage + '%' : '';
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
tryTsxEmit((instance) => {
|
||||
instance.wrap = unref(wrapElRef);
|
||||
});
|
||||
|
||||
const { native, noresize } = props;
|
||||
const resizeEl = unref(resizeRef);
|
||||
const warpEl = unref(wrapElRef);
|
||||
if (native || !resizeEl || !warpEl) return;
|
||||
nextTick(update);
|
||||
if (!noresize) {
|
||||
addResizeListener(resizeEl, update);
|
||||
addResizeListener(warpEl, update);
|
||||
}
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
const { native, noresize } = props;
|
||||
const resizeEl = unref(resizeRef);
|
||||
const warpEl = unref(wrapElRef);
|
||||
if (native || !resizeEl || !warpEl) return;
|
||||
if (!noresize) {
|
||||
removeResizeListener(resizeEl, update);
|
||||
removeResizeListener(warpEl, update);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
const { native, tag, viewClass, viewStyle, wrapClass, wrapStyle } = props;
|
||||
let style: any = wrapStyle;
|
||||
const gutter = scrollbarWidth();
|
||||
|
||||
if (gutter) {
|
||||
const gutterWith = `-${gutter}px`;
|
||||
const gutterStyle = `margin-bottom: ${gutterWith}; margin-right: ${gutterWith};`;
|
||||
|
||||
if (Array.isArray(wrapStyle)) {
|
||||
style = toObject(wrapStyle);
|
||||
style.marginRight = style.marginBottom = gutterWith;
|
||||
} else if (isString(wrapStyle)) {
|
||||
style += gutterStyle;
|
||||
} else {
|
||||
style = gutterStyle;
|
||||
}
|
||||
}
|
||||
|
||||
const Tag = tag as any;
|
||||
const view = (
|
||||
<Tag class={['scrollbar__view', viewClass]} style={viewStyle} ref={resizeRef}>
|
||||
{getSlot(slots)}
|
||||
</Tag>
|
||||
);
|
||||
const wrap = (
|
||||
<div
|
||||
ref={wrapElRef}
|
||||
style={style}
|
||||
onScroll={handleScroll}
|
||||
class={[wrapClass, 'scrollbar__wrap', gutter ? '' : 'scrollbar__wrap--hidden-default']}
|
||||
>
|
||||
{[view]}
|
||||
</div>
|
||||
);
|
||||
let nodes: any[] = [];
|
||||
const { moveX, sizeWidth, moveY, sizeHeight } = state;
|
||||
if (!native) {
|
||||
nodes = [
|
||||
wrap,
|
||||
/* eslint-disable */
|
||||
<Bar move={moveX} size={sizeWidth}></Bar>,
|
||||
<Bar vertical move={moveY} size={sizeHeight}></Bar>,
|
||||
];
|
||||
} else {
|
||||
nodes = [
|
||||
<div ref="wrap" class={[wrapClass, 'scrollbar__wrap']} style={style}>
|
||||
{[view]}
|
||||
</div>,
|
||||
];
|
||||
}
|
||||
return <div class="scrollbar">{nodes}</div>;
|
||||
};
|
||||
},
|
||||
});
|
69
src/components/Scrollbar/src/index.less
Normal file
69
src/components/Scrollbar/src/index.less
Normal file
@@ -0,0 +1,69 @@
|
||||
.scrollbar {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&__wrap {
|
||||
height: 100%;
|
||||
overflow: scroll;
|
||||
|
||||
&--hidden-default {
|
||||
scrollbar-width: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__thumb {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 0;
|
||||
height: 0;
|
||||
cursor: pointer;
|
||||
background-color: rgba(144, 147, 153, 0.3);
|
||||
border-radius: inherit;
|
||||
transition: 0.3s background-color;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(144, 147, 153, 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
&__bar {
|
||||
position: absolute;
|
||||
right: 2px;
|
||||
bottom: 2px;
|
||||
z-index: 1;
|
||||
border-radius: 4px;
|
||||
opacity: 0;
|
||||
-webkit-transition: opacity 120ms ease-out;
|
||||
transition: opacity 120ms ease-out;
|
||||
|
||||
&.is-vertical {
|
||||
top: 2px;
|
||||
width: 6px;
|
||||
|
||||
& > div {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
&.is-horizontal {
|
||||
left: 2px;
|
||||
height: 6px;
|
||||
|
||||
& > div {
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.scrollbar:active > .scrollbar__bar,
|
||||
.scrollbar:focus > .scrollbar__bar,
|
||||
.scrollbar:hover > .scrollbar__bar {
|
||||
opacity: 1;
|
||||
transition: opacity 280ms ease-out;
|
||||
}
|
14
src/components/Scrollbar/src/types.d.ts
vendored
Normal file
14
src/components/Scrollbar/src/types.d.ts
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
export interface BarMapItem {
|
||||
offset: string;
|
||||
scroll: string;
|
||||
scrollSize: string;
|
||||
size: string;
|
||||
key: string;
|
||||
axis: string;
|
||||
client: string;
|
||||
direction: string;
|
||||
}
|
||||
export interface BarMap {
|
||||
vertical: BarMapItem;
|
||||
horizontal: BarMapItem;
|
||||
}
|
49
src/components/Scrollbar/src/util.ts
Normal file
49
src/components/Scrollbar/src/util.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { BarMap } from './types';
|
||||
export const BAR_MAP: BarMap = {
|
||||
vertical: {
|
||||
offset: 'offsetHeight',
|
||||
scroll: 'scrollTop',
|
||||
scrollSize: 'scrollHeight',
|
||||
size: 'height',
|
||||
key: 'vertical',
|
||||
axis: 'Y',
|
||||
client: 'clientY',
|
||||
direction: 'top',
|
||||
},
|
||||
horizontal: {
|
||||
offset: 'offsetWidth',
|
||||
scroll: 'scrollLeft',
|
||||
scrollSize: 'scrollWidth',
|
||||
size: 'width',
|
||||
key: 'horizontal',
|
||||
axis: 'X',
|
||||
client: 'clientX',
|
||||
direction: 'left',
|
||||
},
|
||||
};
|
||||
|
||||
export function renderThumbStyle({ move, size, bar }) {
|
||||
const style = {} as any;
|
||||
const translate = `translate${bar.axis}(${move}%)`;
|
||||
|
||||
style[bar.size] = size;
|
||||
style.transform = translate;
|
||||
style.msTransform = translate;
|
||||
style.webkitTransform = translate;
|
||||
|
||||
return style;
|
||||
}
|
||||
|
||||
function extend<T, K>(to: T, _from: K): T & K {
|
||||
return Object.assign(to, _from);
|
||||
}
|
||||
|
||||
export function toObject<T>(arr: Array<T>): Record<string, T> {
|
||||
const res = {};
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
if (arr[i]) {
|
||||
extend(res, arr[i]);
|
||||
}
|
||||
}
|
||||
return res;
|
||||
}
|
69
src/components/StrengthMeter/index.less
Normal file
69
src/components/StrengthMeter/index.less
Normal file
@@ -0,0 +1,69 @@
|
||||
@import (reference) '../../design/index.less';
|
||||
|
||||
.streng-meter {
|
||||
position: relative;
|
||||
|
||||
&-bar {
|
||||
position: relative;
|
||||
height: 4px;
|
||||
margin: 10px auto 6px;
|
||||
background: @disabled-color;
|
||||
border-radius: 3px;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
display: block;
|
||||
width: 20%;
|
||||
height: inherit;
|
||||
background: transparent;
|
||||
border-color: @white;
|
||||
border-style: solid;
|
||||
border-width: 0 5px 0 5px;
|
||||
content: '';
|
||||
}
|
||||
|
||||
&::before {
|
||||
left: 20%;
|
||||
}
|
||||
|
||||
&::after {
|
||||
right: 20%;
|
||||
}
|
||||
|
||||
&__fill {
|
||||
position: absolute;
|
||||
width: 0;
|
||||
height: inherit;
|
||||
background: transparent;
|
||||
border-radius: inherit;
|
||||
transition: width 0.5s ease-in-out, background 0.25s;
|
||||
|
||||
&[data-score='0'] {
|
||||
width: 20%;
|
||||
background: darken(@error-color, 10%);
|
||||
}
|
||||
|
||||
&[data-score='1'] {
|
||||
width: 40%;
|
||||
background: @error-color;
|
||||
}
|
||||
|
||||
&[data-score='2'] {
|
||||
width: 60%;
|
||||
background: @warning-color;
|
||||
}
|
||||
|
||||
&[data-score='3'] {
|
||||
width: 80%;
|
||||
background: fade(@success-color, 50%);
|
||||
}
|
||||
|
||||
&[data-score='4'] {
|
||||
width: 100%;
|
||||
background: @success-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
83
src/components/StrengthMeter/index.tsx
Normal file
83
src/components/StrengthMeter/index.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { PropType } from 'vue';
|
||||
|
||||
import { defineComponent, computed, ref, watch, unref, watchEffect } from 'vue';
|
||||
|
||||
import { Input } from 'ant-design-vue';
|
||||
|
||||
import zxcvbn from 'zxcvbn';
|
||||
import { extendSlots } from '/@/utils/helper/tsxHelper';
|
||||
import './index.less';
|
||||
const prefixCls = 'streng-meter';
|
||||
export default defineComponent({
|
||||
name: 'StrengMeter',
|
||||
emits: ['score-change', 'change'],
|
||||
props: {
|
||||
value: {
|
||||
type: String as PropType<string>,
|
||||
default: undefined,
|
||||
},
|
||||
|
||||
userInputs: {
|
||||
type: Array as PropType<string[]>,
|
||||
default: () => [],
|
||||
},
|
||||
|
||||
showInput: {
|
||||
type: Boolean as PropType<boolean>,
|
||||
default: true,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean as PropType<boolean>,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
setup(props, { emit, attrs, slots }) {
|
||||
const innerValueRef = ref('');
|
||||
const getPasswordStrength = computed(() => {
|
||||
const { userInputs, disabled } = props;
|
||||
if (disabled) return null;
|
||||
const innerValue = unref(innerValueRef);
|
||||
const score = innerValue
|
||||
? zxcvbn(unref(innerValueRef), (userInputs as string[]) || null).score
|
||||
: null;
|
||||
emit('score-change', score);
|
||||
return score;
|
||||
});
|
||||
|
||||
function handleChange(e: ChangeEvent) {
|
||||
innerValueRef.value = e.target.value;
|
||||
}
|
||||
|
||||
watchEffect(() => {
|
||||
innerValueRef.value = props.value || '';
|
||||
});
|
||||
watch(
|
||||
() => unref(innerValueRef),
|
||||
(val) => {
|
||||
emit('change', val);
|
||||
}
|
||||
);
|
||||
|
||||
return () => {
|
||||
const { showInput, disabled } = props;
|
||||
return (
|
||||
<div class={prefixCls}>
|
||||
{showInput && (
|
||||
<Input.Password
|
||||
{...attrs}
|
||||
allowClear={true}
|
||||
value={unref(innerValueRef)}
|
||||
onChange={handleChange}
|
||||
disabled={disabled}
|
||||
>
|
||||
{extendSlots(slots)}
|
||||
</Input.Password>
|
||||
)}
|
||||
<div class={`${prefixCls}-bar`}>
|
||||
<div class={`${prefixCls}-bar__fill`} data-score={unref(getPasswordStrength)}></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
},
|
||||
});
|
28
src/components/Transition/index.ts
Normal file
28
src/components/Transition/index.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { createSimpleTransition, createJavascriptTransition } from './src/CreateTransition';
|
||||
|
||||
import ExpandTransitionGenerator from './src/ExpandTransition';
|
||||
|
||||
export { default as CollapseTransition } from './src/CollapseTransition';
|
||||
|
||||
export const FadeTransition = createSimpleTransition('fade-transition');
|
||||
export const ScaleTransition = createSimpleTransition('scale-transition');
|
||||
export const SlideYTransition = createSimpleTransition('slide-y-transition');
|
||||
export const ScrollYTransition = createSimpleTransition('scroll-y-transition');
|
||||
export const SlideYReverseTransition = createSimpleTransition('slide-y-reverse-transition');
|
||||
export const ScrollYReverseTransition = createSimpleTransition('scroll-y-reverse-transition');
|
||||
export const SlideXTransition = createSimpleTransition('slide-x-transition');
|
||||
export const ScrollXTransition = createSimpleTransition('scroll-x-transition');
|
||||
export const SlideXReverseTransition = createSimpleTransition('slide-x-reverse-transition');
|
||||
export const ScrollXReverseTransition = createSimpleTransition('scroll-x-reverse-transition');
|
||||
export const ScaleRotateTransition = createSimpleTransition('scale-rotate-transition');
|
||||
|
||||
// Javascript transitions
|
||||
export const ExpandTransition = createJavascriptTransition(
|
||||
'expand-transition',
|
||||
ExpandTransitionGenerator()
|
||||
);
|
||||
|
||||
export const ExpandXTransition = createJavascriptTransition(
|
||||
'expand-x-transition',
|
||||
ExpandTransitionGenerator('', true)
|
||||
);
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user