initial commit

This commit is contained in:
陈文彬
2020-09-28 20:19:10 +08:00
commit 2f6253cfb6
436 changed files with 26843 additions and 0 deletions

View 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');
};
},
});

View 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';

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

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

View 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;
}
}
}

View 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>
);
},
});

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

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

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

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

View 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;
}

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

View 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';

View 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;
}

View 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>
);
};
},
});

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

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

View 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
View 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;
}>;

View 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';

View 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;
}
}
}
}
}

View 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>
);
};
},
});

View 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,
},
};

View 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;
};

View File

@@ -0,0 +1,3 @@
export { default as Description } from './src/index';
export * from './src/types';
export { useDescription } from './src/useDescription';

View 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());
},
});

View 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,
},
};

View 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];

View 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];
}

View File

@@ -0,0 +1,4 @@
export { default as BasicDrawer } from './src/BasicDrawer';
export { useDrawer, useDrawerInner } from './src/useDrawer';
export * from './src/types';

View 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>
);
};
},
});

View 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;
}
}

View 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,
};

View 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;
}

View 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);
},
},
];
};

View 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>
);
},
});

View File

@@ -0,0 +1,2 @@
export { default as Dropdown } from './Dropdown';
export * from './types';

View 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: () => [],
},
});

View File

@@ -0,0 +1,8 @@
export interface DropMenu {
to?: string;
icon?: string;
event: string | number;
text: string;
disabled?: boolean;
divider?: boolean;
}

View 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';

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

View 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>
</>
);
};
},
});

View 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>
)
);
};
},
});

View 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 };

View 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();

View 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);
});
}

View 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];
}

View 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;
}

View 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 },
};
});
}

View 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>,
};

View 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;
}

View 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;
}

View 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';

View 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.

View 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%;
}

View 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)} />
);
},
});

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

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

View File

@@ -0,0 +1,2 @@
export { default as BasicLoading } from './BasicLoading.vue';
export { default as FullLoading } from './FullLoading.vue';

View File

@@ -0,0 +1,8 @@
import { SizeEnum } from '/@/enums/sizeEnum';
export interface BasicLoadingProps {
// 提示语
tip: string;
size: SizeEnum;
}

View File

@@ -0,0 +1 @@
export { default as BasicMenu } from './src/BasicMenu';

View 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>
);
};
},
});

View 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>
),
];
};
},
});

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

View 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
);
}

View 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();
}
}

View 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
View 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[];
}

View 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 };
}

View 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 };
}

View 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';

View 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>
);
},
});

View 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>;
};
},
});

View 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>
);
};
},
});

View 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;
}
}

View 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>,
},
});

View 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;
}

View 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);
},
},
];
};

View File

@@ -0,0 +1 @@
export { createImgPreview } from './src/functional';

View 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);
}

View 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;
}
}
}

View 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>
);
};
},
});

View 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,
},
};

View 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;
}

View File

@@ -0,0 +1,2 @@
export { default as QrCode } from './src/index.vue';
export * from './src/types';

View 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';
}
}

View 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;
};
}

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

View File

@@ -0,0 +1,5 @@
// 参考 qr-code-with-logo 进行ts版本修改
import { toCanvas } from './toCanvas';
export * from './types';
export { toCanvas };

View 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>;
};

View 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;
}

View File

@@ -0,0 +1,5 @@
/**
* copy from element-ui
*/
export { default as Scrollbar } from './src/Scrollbar';

View 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>
);
};
},
});

View 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>;
};
},
});

View 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
View 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;
}

View 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;
}

View 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;
}
}
}
}

View 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>
);
};
},
});

View 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