前端和后端源码,合并到一个git仓库中,方便用户下载,避免前后端不匹配的问题

This commit is contained in:
JEECG
2024-06-23 10:39:52 +08:00
parent bb918b742e
commit 0325e34dcb
1439 changed files with 171106 additions and 0 deletions

View File

@ -0,0 +1,15 @@
import { withInstall } from '/@/utils';
import appLogo from './src/AppLogo.vue';
import appProvider from './src/AppProvider.vue';
import appSearch from './src/search/AppSearch.vue';
import appLocalePicker from './src/AppLocalePicker.vue';
import appDarkModeToggle from './src/AppDarkModeToggle.vue';
export { useAppProviderContext } from './src/useAppContext';
export const AppLogo = withInstall(appLogo);
export const AppProvider = withInstall(appProvider);
export const AppSearch = withInstall(appSearch);
export const AppLocalePicker = withInstall(appLocalePicker);
export const AppDarkModeToggle = withInstall(appDarkModeToggle);

View File

@ -0,0 +1,76 @@
<template>
<div v-if="getShowDarkModeToggle" :class="getClass" @click="toggleDarkMode">
<div :class="`${prefixCls}-inner`"> </div>
<SvgIcon size="14" name="sun" />
<SvgIcon size="14" name="moon" />
</div>
</template>
<script lang="ts" setup>
import { computed, unref } from 'vue';
import { SvgIcon } from '/@/components/Icon';
import { useDesign } from '/@/hooks/web/useDesign';
import { useRootSetting } from '/@/hooks/setting/useRootSetting';
import { updateHeaderBgColor, updateSidebarBgColor } from '/@/logics/theme/updateBackground';
import { updateDarkTheme } from '/@/logics/theme/dark';
import { ThemeEnum } from '/@/enums/appEnum';
const { prefixCls } = useDesign('dark-switch');
const { getDarkMode, setDarkMode, getShowDarkModeToggle } = useRootSetting();
const isDark = computed(() => getDarkMode.value === ThemeEnum.DARK);
const getClass = computed(() => [
prefixCls,
{
[`${prefixCls}--dark`]: unref(isDark),
},
]);
function toggleDarkMode() {
const darkMode = getDarkMode.value === ThemeEnum.DARK ? ThemeEnum.LIGHT : ThemeEnum.DARK;
setDarkMode(darkMode);
updateDarkTheme(darkMode);
updateHeaderBgColor();
updateSidebarBgColor();
}
</script>
<style lang="less" scoped>
@prefix-cls: ~'@{namespace}-dark-switch';
html[data-theme='dark'] {
.@{prefix-cls} {
border: 1px solid rgb(196, 188, 188);
}
}
.@{prefix-cls} {
position: relative;
display: flex;
width: 50px;
height: 26px;
padding: 0 6px;
margin-left: auto;
cursor: pointer;
background-color: #151515;
border-radius: 30px;
justify-content: space-between;
align-items: center;
&-inner {
position: absolute;
z-index: 1;
width: 18px;
height: 18px;
background-color: #fff;
border-radius: 50%;
transition: transform 0.5s, background-color 0.5s;
will-change: transform;
}
&--dark {
.@{prefix-cls}-inner {
transform: translateX(calc(100% + 2px));
}
}
}
</style>

View File

@ -0,0 +1,76 @@
<!--
* @Author: Vben
* @Description: Multi-language switching component
-->
<template>
<Dropdown
placement="bottom"
:trigger="['click']"
:dropMenuList="localeList"
:selectedKeys="selectedKeys"
@menuEvent="handleMenuEvent"
overlayClassName="app-locale-picker-overlay"
>
<span class="cursor-pointer flex items-center">
<Icon icon="ion:language" />
<span v-if="showText" class="ml-1">{{ getLocaleText }}</span>
</span>
</Dropdown>
</template>
<script lang="ts" setup>
import type { LocaleType } from '/#/config';
import type { DropMenu } from '/@/components/Dropdown';
import { ref, watchEffect, unref, computed } from 'vue';
import { Dropdown } from '/@/components/Dropdown';
import { Icon } from '/@/components/Icon';
import { useLocale } from '/@/locales/useLocale';
import { localeList } from '/@/settings/localeSetting';
const props = defineProps({
/**
* Whether to display text
*/
showText: { type: Boolean, default: true },
/**
* Whether to refresh the interface when changing
*/
reload: { type: Boolean },
});
const selectedKeys = ref<string[]>([]);
const { changeLocale, getLocale } = useLocale();
const getLocaleText = computed(() => {
const key = selectedKeys.value[0];
if (!key) {
return '';
}
return localeList.find((item) => item.event === key)?.text;
});
watchEffect(() => {
selectedKeys.value = [unref(getLocale)];
});
async function toggleLocale(lang: LocaleType | string) {
await changeLocale(lang as LocaleType);
selectedKeys.value = [lang as string];
props.reload && location.reload();
}
function handleMenuEvent(menu: DropMenu) {
if (unref(getLocale) === menu.event) {
return;
}
toggleLocale(menu.event as string);
}
</script>
<style lang="less">
.app-locale-picker-overlay {
.ant-dropdown-menu-item {
min-width: 160px;
}
}
</style>

View File

@ -0,0 +1,93 @@
<!--
* @Author: Jeecg
* @Description: logo component
-->
<template>
<div class="anticon" :class="getAppLogoClass" @click="goHome">
<img src="../../../assets/images/logo.png" />
<div class="ml-2 truncate md:opacity-100" :class="getTitleClass" v-show="showTitle">
{{ shortTitle }}
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, unref } from 'vue';
import { useGlobSetting } from '/@/hooks/setting';
import { useGo } from '/@/hooks/web/usePage';
import { useMenuSetting } from '/@/hooks/setting/useMenuSetting';
import { useDesign } from '/@/hooks/web/useDesign';
import { PageEnum } from '/@/enums/pageEnum';
import { useUserStore } from '/@/store/modules/user';
const props = defineProps({
/**
* The theme of the current parent component
*/
theme: { type: String, validator: (v: string) => ['light', 'dark'].includes(v) },
/**
* Whether to show title
*/
showTitle: { type: Boolean, default: true },
/**
* The title is also displayed when the menu is collapsed
*/
alwaysShowTitle: { type: Boolean },
});
const { prefixCls } = useDesign('app-logo');
const { getCollapsedShowTitle } = useMenuSetting();
const userStore = useUserStore();
const { title, shortTitle } = useGlobSetting();
const go = useGo();
const getAppLogoClass = computed(() => [prefixCls, props.theme, { 'collapsed-show-title': unref(getCollapsedShowTitle) }]);
const getTitleClass = computed(() => [
`${prefixCls}__title`,
{
'xs:opacity-0': !props.alwaysShowTitle,
},
]);
function goHome() {
go(userStore.getUserInfo.homePath || PageEnum.BASE_HOME);
}
</script>
<style lang="less" scoped>
@prefix-cls: ~'@{namespace}-app-logo';
.@{prefix-cls} {
display: flex;
align-items: center;
padding-left: 7px;
cursor: pointer;
transition: all 0.2s ease;
//左侧菜单模式和左侧菜单混合模式加渐变背景色
&.jeecg-layout-mix-sider-logo,&.jeecg-layout-menu-logo{
background:@sider-logo-bg-color;
}
// &.light {
// border-bottom: 1px solid @border-color-base;
// }
&.collapsed-show-title {
padding-left: 20px;
}
&.light &__title {
color: @primary-color;
}
&.dark &__title {
color: @white;
}
&__title {
font-size: 18px;
font-weight: 600;
transition: all 0.5s;
line-height: normal;
}
}
</style>

View File

@ -0,0 +1,77 @@
<script lang="ts">
import { defineComponent, toRefs, ref, unref } from 'vue';
import { createAppProviderContext } from './useAppContext';
import { createBreakpointListen } from '/@/hooks/event/useBreakpoint';
import { prefixCls } from '/@/settings/designSetting';
import { useAppStore } from '/@/store/modules/app';
import { MenuModeEnum, MenuTypeEnum } from '/@/enums/menuEnum';
const props = {
/**
* class style prefix
*/
prefixCls: { type: String, default: prefixCls },
};
export default defineComponent({
name: 'AppProvider',
inheritAttrs: false,
props,
setup(props, { slots }) {
const isMobile = ref(false);
const isSetState = ref(false);
const appStore = useAppStore();
// Monitor screen breakpoint information changes
createBreakpointListen(({ screenMap, sizeEnum, width }) => {
const lgWidth = screenMap.get(sizeEnum.LG);
if (lgWidth) {
isMobile.value = width.value - 1 < lgWidth;
}
handleRestoreState();
});
const { prefixCls } = toRefs(props);
// Inject variables into the global
createAppProviderContext({ prefixCls, isMobile });
/**
* Used to maintain the state before the window changes
*/
function handleRestoreState() {
if (unref(isMobile)) {
if (!unref(isSetState)) {
isSetState.value = true;
const {
menuSetting: { type: menuType, mode: menuMode, collapsed: menuCollapsed, split: menuSplit },
} = appStore.getProjectConfig;
appStore.setProjectConfig({
menuSetting: {
type: MenuTypeEnum.SIDEBAR,
mode: MenuModeEnum.INLINE,
split: false,
},
});
appStore.setBeforeMiniInfo({ menuMode, menuCollapsed, menuType, menuSplit });
}
} else {
if (unref(isSetState)) {
isSetState.value = false;
const { menuMode, menuCollapsed, menuType, menuSplit } = appStore.getBeforeMiniInfo;
appStore.setProjectConfig({
menuSetting: {
type: menuType,
mode: menuMode,
collapsed: menuCollapsed,
split: menuSplit,
},
});
}
}
}
return () => slots.default?.();
},
});
</script>

View File

@ -0,0 +1,33 @@
<script lang="tsx">
import { defineComponent, ref, unref } from 'vue';
import { Tooltip } from 'ant-design-vue';
import { SearchOutlined } from '@ant-design/icons-vue';
import AppSearchModal from './AppSearchModal.vue';
import { useI18n } from '/@/hooks/web/useI18n';
export default defineComponent({
name: 'AppSearch',
setup() {
const showModal = ref(false);
const { t } = useI18n();
function changeModal(show: boolean) {
showModal.value = show;
}
return () => {
return (
<div class="p-1" onClick={changeModal.bind(null, true)}>
<Tooltip>
{{
title: () => t('common.searchText'),
default: () => <SearchOutlined />,
}}
</Tooltip>
<AppSearchModal onClose={changeModal.bind(null, false)} visible={unref(showModal)} />
</div>
);
};
},
});
</script>

View File

@ -0,0 +1,55 @@
<template>
<div :class="`${prefixCls}`">
<AppSearchKeyItem :class="`${prefixCls}-item`" icon="ant-design:enter-outlined" />
<span>{{ t('component.app.toSearch') }}</span>
<AppSearchKeyItem :class="`${prefixCls}-item`" icon="ion:arrow-up-outline" />
<AppSearchKeyItem :class="`${prefixCls}-item`" icon="ion:arrow-down-outline" />
<span>{{ t('component.app.toNavigate') }}</span>
<AppSearchKeyItem :class="`${prefixCls}-item`" icon="mdi:keyboard-esc" />
<span>{{ t('common.closeText') }}</span>
</div>
</template>
<script lang="ts" setup>
import AppSearchKeyItem from './AppSearchKeyItem.vue';
import { useDesign } from '/@/hooks/web/useDesign';
import { useI18n } from '/@/hooks/web/useI18n';
const { prefixCls } = useDesign('app-search-footer');
const { t } = useI18n();
</script>
<style lang="less" scoped>
@prefix-cls: ~'@{namespace}-app-search-footer';
.@{prefix-cls} {
position: relative;
display: flex;
height: 44px;
padding: 0 16px;
font-size: 12px;
color: #666;
background-color: @component-background;
border-top: 1px solid @border-color-base;
border-radius: 0 0 16px 16px;
align-items: center;
flex-shrink: 0;
&-item {
display: flex;
width: 20px;
height: 18px;
padding-bottom: 2px;
margin-right: 0.4em;
background-color: linear-gradient(-225deg, #d5dbe4, #f8f8f8);
border-radius: 2px;
box-shadow: inset 0 -2px 0 0 #cdcde6, inset 0 0 1px 1px #fff, 0 1px 2px 1px rgba(30, 35, 90, 0.4);
align-items: center;
justify-content: center;
&:nth-child(2),
&:nth-child(3),
&:nth-child(6) {
margin-left: 14px;
}
}
}
</style>

View File

@ -0,0 +1,11 @@
<template>
<span :class="$attrs.class">
<Icon :icon="icon" />
</span>
</template>
<script lang="ts" setup>
import { Icon } from '/@/components/Icon';
defineProps({
icon: String,
});
</script>

View File

@ -0,0 +1,260 @@
<template>
<Teleport to="body">
<transition name="zoom-fade" mode="out-in">
<div :class="getClass" @click.stop v-if="visible">
<div :class="`${prefixCls}-content`" v-click-outside="handleClose">
<div :class="`${prefixCls}-input__wrapper`">
<a-input :class="`${prefixCls}-input`" :placeholder="t('common.searchText')" ref="inputRef" allow-clear @change="handleSearch">
<template #prefix>
<SearchOutlined />
</template>
</a-input>
<span :class="`${prefixCls}-cancel`" @click="handleClose">
{{ t('common.cancelText') }}
</span>
</div>
<div :class="`${prefixCls}-not-data`" v-show="getIsNotData">
{{ t('component.app.searchNotData') }}
</div>
<ul :class="`${prefixCls}-list`" v-show="!getIsNotData" ref="scrollWrap">
<li
:ref="setRefs(index)"
v-for="(item, index) in searchResult"
:key="item.path"
:data-index="index"
@mouseenter="handleMouseenter"
@click="handleEnter"
:class="[
`${prefixCls}-list__item`,
{
[`${prefixCls}-list__item--active`]: activeIndex === index,
},
]"
>
<div :class="`${prefixCls}-list__item-icon`">
<Icon :icon="item.icon || 'mdi:form-select'" :size="20" />
</div>
<div :class="`${prefixCls}-list__item-text`">
{{ item.name }}
</div>
<div :class="`${prefixCls}-list__item-enter`">
<Icon icon="ant-design:enter-outlined" :size="20" />
</div>
</li>
</ul>
<AppSearchFooter />
</div>
</div>
</transition>
</Teleport>
</template>
<script lang="ts" setup>
import { computed, unref, ref, watch, nextTick } from 'vue';
import { SearchOutlined } from '@ant-design/icons-vue';
import AppSearchFooter from './AppSearchFooter.vue';
import Icon from '/@/components/Icon';
// @ts-ignore
import vClickOutside from '/@/directives/clickOutside';
import { useDesign } from '/@/hooks/web/useDesign';
import { useRefs } from '/@/hooks/core/useRefs';
import { useMenuSearch } from './useMenuSearch';
import { useI18n } from '/@/hooks/web/useI18n';
import { useAppInject } from '/@/hooks/web/useAppInject';
const props = defineProps({
visible: { type: Boolean },
});
const emit = defineEmits(['close']);
const scrollWrap = ref(null);
const inputRef = ref<Nullable<HTMLElement>>(null);
const { t } = useI18n();
const { prefixCls } = useDesign('app-search-modal');
const [refs, setRefs] = useRefs();
const { getIsMobile } = useAppInject();
const { handleSearch, searchResult, keyword, activeIndex, handleEnter, handleMouseenter } = useMenuSearch(refs, scrollWrap, emit);
const getIsNotData = computed(() => !keyword || unref(searchResult).length === 0);
const getClass = computed(() => {
return [
prefixCls,
{
[`${prefixCls}--mobile`]: unref(getIsMobile),
},
];
});
watch(
() => props.visible,
(visible: boolean) => {
visible &&
nextTick(() => {
unref(inputRef)?.focus();
});
}
);
function handleClose() {
searchResult.value = [];
emit('close');
}
</script>
<style lang="less" scoped>
@prefix-cls: ~'@{namespace}-app-search-modal';
@footer-prefix-cls: ~'@{namespace}-app-search-footer';
.@{prefix-cls} {
position: fixed;
top: 0;
left: 0;
z-index: 800;
display: flex;
width: 100%;
height: 100%;
padding-top: 50px;
background-color: rgba(0, 0, 0, 0.25);
justify-content: center;
&--mobile {
padding: 0;
> div {
width: 100%;
}
.@{prefix-cls}-input {
width: calc(100% - 38px);
}
.@{prefix-cls}-cancel {
display: inline-block;
}
.@{prefix-cls}-content {
width: 100%;
height: 100%;
border-radius: 0;
}
.@{footer-prefix-cls} {
display: none;
}
.@{prefix-cls}-list {
height: calc(100% - 80px);
max-height: unset;
&__item {
&-enter {
opacity: 0 !important;
}
}
}
}
&-content {
position: relative;
width: 632px;
margin: 0 auto auto auto;
background-color: @component-background;
border-radius: 16px;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
flex-direction: column;
}
&-input__wrapper {
display: flex;
padding: 14px 14px 0 14px;
justify-content: space-between;
align-items: center;
}
&-input {
width: 100%;
height: 48px;
font-size: 1.3em;
color: #1c1e21;
border-radius: 6px;
span[role='img'] {
color: #999;
}
}
&-cancel {
display: none;
font-size: 1em;
color: #666;
}
&-not-data {
display: flex;
width: 100%;
height: 100px;
font-size: 0.9;
color: rgb(150 159 175);
align-items: center;
justify-content: center;
}
&-list {
max-height: 472px;
padding: 0 14px;
padding-bottom: 20px;
margin: 0 auto;
margin-top: 14px;
overflow: auto;
&__item {
position: relative;
display: flex;
width: 100%;
height: 56px;
padding-bottom: 4px;
padding-left: 14px;
margin-top: 8px;
font-size: 14px;
color: @text-color-base;
cursor: pointer;
// background-color: @component-background;
border-radius: 4px;
box-shadow: 0 1px 3px 0 #d4d9e1;
align-items: center;
> div:first-child,
> div:last-child {
display: flex;
align-items: center;
}
&--active {
color: #fff;
background-color: @primary-color;
.@{prefix-cls}-list__item-enter {
opacity: 1;
}
}
&-icon {
width: 30px;
}
&-text {
flex: 1;
}
&-enter {
width: 30px;
opacity: 0;
}
}
}
}
</style>

View File

@ -0,0 +1,183 @@
import type { Menu } from '/@/router/types';
import { ref, onBeforeMount, unref, Ref, nextTick } from 'vue';
import { getMenus } from '/@/router/menus';
import { cloneDeep } from 'lodash-es';
import { filter, forEach } from '/@/utils/helper/treeHelper';
import { useGo } from '/@/hooks/web/usePage';
import { useScrollTo } from '/@/hooks/event/useScrollTo';
import { onKeyStroke, useDebounceFn } from '@vueuse/core';
import { useI18n } from '/@/hooks/web/useI18n';
import { URL_HASH_TAB } from '/@/utils';
export interface SearchResult {
name: string;
path: string;
icon?: string;
internalOrExternal: boolean;
}
// Translate special characters
function transform(c: string) {
const code: string[] = ['$', '(', ')', '*', '+', '.', '[', ']', '?', '\\', '^', '{', '}', '|'];
return code.includes(c) ? `\\${c}` : c;
}
function createSearchReg(key: string) {
const keys = [...key].map((item) => transform(item));
const str = ['', ...keys, ''].join('.*');
return new RegExp(str, 'i');
}
export function useMenuSearch(refs: Ref<HTMLElement[]>, scrollWrap: Ref<ElRef>, emit: EmitType) {
const searchResult = ref<SearchResult[]>([]);
const keyword = ref('');
const activeIndex = ref(-1);
let menuList: Menu[] = [];
const { t } = useI18n();
const go = useGo();
const handleSearch = useDebounceFn(search, 200);
onBeforeMount(async () => {
const list = await getMenus();
menuList = cloneDeep(list);
forEach(menuList, (item) => {
item.name = t(item.name);
});
});
function search(e: ChangeEvent) {
e?.stopPropagation();
const key = e.target.value;
keyword.value = key.trim();
if (!key) {
searchResult.value = [];
return;
}
const reg = createSearchReg(unref(keyword));
const filterMenu = filter(menuList, (item) => {
// 【issues/33】包含子菜单时不添加到搜索队列
if (Array.isArray(item.children)) {
return false;
}
return reg.test(item.name) && !item.hideMenu;
});
searchResult.value = handlerSearchResult(filterMenu, reg);
activeIndex.value = 0;
}
function handlerSearchResult(filterMenu: Menu[], reg: RegExp, parent?: Menu) {
const ret: SearchResult[] = [];
filterMenu.forEach((item) => {
const { name, path, icon, children, hideMenu, meta, internalOrExternal } = item;
if (!hideMenu && reg.test(name) && (!children?.length || meta?.hideChildrenInMenu)) {
ret.push({
name: parent?.name ? `${parent.name} > ${name}` : name,
path,
icon,
internalOrExternal
});
}
if (!meta?.hideChildrenInMenu && Array.isArray(children) && children.length) {
ret.push(...handlerSearchResult(children, reg, item));
}
});
return ret;
}
// Activate when the mouse moves to a certain line
function handleMouseenter(e: any) {
const index = e.target.dataset.index;
activeIndex.value = Number(index);
}
// Arrow key up
function handleUp() {
if (!searchResult.value.length) return;
activeIndex.value--;
if (activeIndex.value < 0) {
activeIndex.value = searchResult.value.length - 1;
}
handleScroll();
}
// Arrow key down
function handleDown() {
if (!searchResult.value.length) return;
activeIndex.value++;
if (activeIndex.value > searchResult.value.length - 1) {
activeIndex.value = 0;
}
handleScroll();
}
// When the keyboard up and down keys move to an invisible place
// the scroll bar needs to scroll automatically
function handleScroll() {
const refList = unref(refs);
if (!refList || !Array.isArray(refList) || refList.length === 0 || !unref(scrollWrap)) {
return;
}
const index = unref(activeIndex);
const currentRef = refList[index];
if (!currentRef) {
return;
}
const wrapEl = unref(scrollWrap);
if (!wrapEl) {
return;
}
const scrollHeight = currentRef.offsetTop + currentRef.offsetHeight;
const wrapHeight = wrapEl.offsetHeight;
const { start } = useScrollTo({
el: wrapEl,
duration: 100,
to: scrollHeight - wrapHeight,
});
start();
}
// enter keyboard event
async function handleEnter() {
if (!searchResult.value.length) {
return;
}
const result = unref(searchResult);
const index = unref(activeIndex);
if (result.length === 0 || index < 0) {
return;
}
const to = result[index];
handleClose();
await nextTick();
// update-begin--author:liaozhiyang---date:20230803---for【QQYUN-8369】搜索区分大小写外部链接新页打开
if (to.internalOrExternal) {
// update-begin--author:liaozhiyang---date:20240402---for:【QQYUN-8773】配置外部网址在顶部菜单模式和搜索打不开
const path = to.path.replace(URL_HASH_TAB, '#');
window.open(path, '_blank');
// update-end--author:liaozhiyang---date:20240402---for:【QQYUN-8773】配置外部网址在顶部菜单模式和搜索打不开
} else {
go(to.path);
}
// update-end--author:liaozhiyang---date:20230803---for【QQYUN-8369】搜索区分大小写外部链接新页打开
}
// close search modal
function handleClose() {
searchResult.value = [];
emit('close');
}
// enter search
onKeyStroke('Enter', handleEnter);
// Monitor keyboard arrow keys
onKeyStroke('ArrowUp', handleUp);
onKeyStroke('ArrowDown', handleDown);
// esc close
onKeyStroke('Escape', handleClose);
return { handleSearch, searchResult, keyword, activeIndex, handleMouseenter, handleEnter };
}

View File

@ -0,0 +1,17 @@
import { InjectionKey, Ref } from 'vue';
import { createContext, useContext } from '/@/hooks/core/useContext';
export interface AppProviderContextProps {
prefixCls: Ref<string>;
isMobile: Ref<boolean>;
}
const key: InjectionKey<AppProviderContextProps> = Symbol();
export function createAppProviderContext(context: AppProviderContextProps) {
return createContext<AppProviderContextProps>(context, key);
}
export function useAppProviderContext() {
return useContext<AppProviderContextProps>(key);
}

View File

@ -0,0 +1,4 @@
import { withInstall } from '/@/utils';
import authority from './src/Authority.vue';
export const Authority = withInstall(authority);

View File

@ -0,0 +1,45 @@
<!--
Access control component for fine-grained access control.
-->
<script lang="ts">
import type { PropType } from 'vue';
import { defineComponent } from 'vue';
import { RoleEnum } from '/@/enums/roleEnum';
import { usePermission } from '/@/hooks/web/usePermission';
import { getSlot } from '/@/utils/helper/tsxHelper';
export default defineComponent({
name: 'Authority',
props: {
/**
* Specified role is visible
* When the permission mode is the role mode, the value value can pass the role value.
* When the permission mode is background, the value value can pass the code permission value
* @default ''
*/
value: {
type: [Number, Array, String] as PropType<RoleEnum | RoleEnum[] | string | string[]>,
default: '',
},
},
setup(props, { slots }) {
const { hasPermission } = usePermission();
/**
* Render role button
*/
function renderAuth() {
const { value } = props;
if (!value) {
return getSlot(slots);
}
return hasPermission(value) ? getSlot(slots) : null;
}
return () => {
// Role-based value control
return renderAuth();
};
},
});
</script>

View File

@ -0,0 +1,8 @@
import { withInstall } from '/@/utils';
import basicArrow from './src/BasicArrow.vue';
import basicTitle from './src/BasicTitle.vue';
import basicHelp from './src/BasicHelp.vue';
export const BasicArrow = withInstall(basicArrow);
export const BasicTitle = withInstall(basicTitle);
export const BasicHelp = withInstall(basicHelp);

View File

@ -0,0 +1,84 @@
<!--
* @Author: Vben
* @Description: Arrow component with animation
-->
<template>
<span :class="getClass">
<Icon icon="ion:chevron-forward" :style="$attrs.iconStyle" />
</span>
</template>
<script lang="ts" setup>
import { computed } from 'vue';
import { Icon } from '/@/components/Icon';
import { useDesign } from '/@/hooks/web/useDesign';
const props = defineProps({
/**
* Arrow expand state
*/
expand: { type: Boolean },
/**
* Arrow up by default
*/
up: { type: Boolean },
/**
* Arrow down by default
*/
down: { type: Boolean },
/**
* Cancel padding/margin for inline
*/
inset: { type: Boolean },
});
const { prefixCls } = useDesign('basic-arrow');
// get component class
const getClass = computed(() => {
const { expand, up, down, inset } = props;
return [
prefixCls,
{
[`${prefixCls}--active`]: expand,
up,
inset,
down,
},
];
});
</script>
<style lang="less" scoped>
@prefix-cls: ~'@{namespace}-basic-arrow';
.@{prefix-cls} {
display: inline-block;
cursor: pointer;
transform: rotate(0deg);
transition: all 0.3s ease 0.1s;
transform-origin: center center;
&--active {
transform: rotate(90deg);
}
&.inset {
line-height: 0px;
}
&.up {
transform: rotate(-90deg);
}
&.down {
transform: rotate(90deg);
}
&.up.@{prefix-cls}--active {
transform: rotate(90deg);
}
&.down.@{prefix-cls}--active {
transform: rotate(-90deg);
}
}
</style>

View File

@ -0,0 +1,112 @@
<script lang="tsx">
import type { CSSProperties, PropType } from 'vue';
import { defineComponent, computed, unref } from 'vue';
import { Tooltip } from 'ant-design-vue';
import { InfoCircleOutlined } from '@ant-design/icons-vue';
import { getPopupContainer } from '/@/utils';
import { isString, isArray } from '/@/utils/is';
import { getSlot } from '/@/utils/helper/tsxHelper';
import { useDesign } from '/@/hooks/web/useDesign';
const props = {
/**
* Help text max-width
* @default: 600px
*/
maxWidth: { type: String, default: '600px' },
/**
* Whether to display the serial number
* @default: false
*/
showIndex: { type: Boolean },
/**
* Help text font color
* @default: #ffffff
*/
color: { type: String, default: '#ffffff' },
/**
* Help text font size
* @default: 14px
*/
fontSize: { type: String, default: '14px' },
/**
* Help text list
*/
placement: { type: String, default: 'right' },
/**
* Help text list
*/
text: { type: [Array, String] as PropType<string[] | string> },
};
export default defineComponent({
name: 'BasicHelp',
components: { Tooltip },
props,
setup(props, { slots }) {
const { prefixCls } = useDesign('basic-help');
const getTooltipStyle = computed((): CSSProperties => ({ color: props.color, fontSize: props.fontSize }));
const getOverlayStyle = computed((): CSSProperties => ({ maxWidth: props.maxWidth }));
function renderTitle() {
const textList = props.text;
if (isString(textList)) {
return <p>{textList}</p>;
}
if (isArray(textList)) {
return textList.map((text, index) => {
return (
<p key={text}>
<>
{props.showIndex ? `${index + 1}. ` : ''}
{text}
</>
</p>
);
});
}
return null;
}
return () => {
return (
<Tooltip
overlayClassName={`${prefixCls}__wrap`}
title={<div style={unref(getTooltipStyle)}>{renderTitle()}</div>}
autoAdjustOverflow={true}
overlayStyle={unref(getOverlayStyle)}
placement={props.placement as 'right'}
getPopupContainer={() => getPopupContainer()}
>
<span class={prefixCls}>{getSlot(slots) || <InfoCircleOutlined />}</span>
</Tooltip>
);
};
},
});
</script>
<style lang="less">
@prefix-cls: ~'@{namespace}-basic-help';
.@{prefix-cls} {
display: inline-block;
margin-left: 6px;
font-size: 14px;
color: @text-color-help-dark;
cursor: pointer;
&:hover {
color: @primary-color;
}
&__wrap {
p {
margin-bottom: 0;
}
}
}
</style>

View File

@ -0,0 +1,80 @@
<template>
<span :class="getClass">
<slot></slot>
<BasicHelp :class="`${prefixCls}-help`" v-if="helpMessage" :text="helpMessage" />
</span>
</template>
<script lang="ts" setup>
import type { PropType } from 'vue';
import { useSlots, computed } from 'vue';
import BasicHelp from './BasicHelp.vue';
import { useDesign } from '/@/hooks/web/useDesign';
const props = defineProps({
/**
* Help text list or string
* @default: ''
*/
helpMessage: {
type: [String, Array] as PropType<string | string[]>,
default: '',
},
/**
* Whether the color block on the left side of the title
* @default: false
*/
span: { type: Boolean },
/**
* Whether to default the text, that is, not bold
* @default: false
*/
normal: { type: Boolean },
});
const { prefixCls } = useDesign('basic-title');
const slots = useSlots();
const getClass = computed(() => [
prefixCls,
{ [`${prefixCls}-show-span`]: props.span && slots.default },
{ [`${prefixCls}-normal`]: props.normal },
]);
</script>
<style lang="less" scoped>
@prefix-cls: ~'@{namespace}-basic-title';
.@{prefix-cls} {
position: relative;
display: flex;
padding-left: 7px;
font-size: 16px;
font-weight: 500;
line-height: 24px;
color: @text-color-base;
cursor: move;
user-select: none;
&.is-drawer {
cursor: default;
}
&-normal {
font-size: 14px;
font-weight: 500;
}
&-show-span::before {
position: absolute;
top: 4px;
left: 0;
width: 3px;
height: 16px;
margin-right: 4px;
background-color: @primary-color;
content: '';
}
&-help {
margin-left: 10px;
}
}
</style>

View File

@ -0,0 +1,11 @@
import { withInstall } from '/@/utils';
import type { ExtractPropTypes } from 'vue';
import button from './src/BasicButton.vue';
import jUploadButton from './src/JUploadButton.vue';
import popConfirmButton from './src/PopConfirmButton.vue';
import { buttonProps } from './src/props';
export const Button = withInstall(button);
export const JUploadButton = withInstall(jUploadButton);
export const PopConfirmButton = withInstall(popConfirmButton);
export declare type ButtonProps = Partial<ExtractPropTypes<typeof buttonProps>>;

View File

@ -0,0 +1,41 @@
<template>
<Button v-bind="getBindValue" :class="getButtonClass" @click="onClick">
<template v-if="preIcon" #icon>
<Icon :icon="preIcon" :size="iconSize" />
</template>
<template #default="data">
<slot v-bind="data || {}"></slot>
<Icon :icon="postIcon" v-if="postIcon" :size="iconSize" />
</template>
</Button>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
export default defineComponent({
name: 'AButton',
inheritAttrs: false,
});
</script>
<script lang="ts" setup>
import { computed, unref } from 'vue';
import { Button } from 'ant-design-vue';
import Icon from '/@/components/Icon/src/Icon.vue';
import { buttonProps } from './props';
import { useAttrs } from '/@/hooks/core/useAttrs';
const props = defineProps(buttonProps);
// get component class
const attrs = useAttrs({ excludeDefaultKeys: false });
const getButtonClass = computed(() => {
const { color, disabled } = props;
return [
{
[`ant-btn-${color}`]: !!color,
[`is-disabled`]: disabled,
},
];
});
// get inherit binding value
const getBindValue = computed(() => ({ ...unref(attrs), ...props }));
</script>

View File

@ -0,0 +1,41 @@
<template>
<a-upload name="file" :showUploadList="false" :customRequest="(file) => onClick(file)">
<Button :type="type" :class="getButtonClass">
<template #default="data">
<Icon :icon="preIcon" v-if="preIcon" :size="iconSize" />
<slot v-bind="data || {}"></slot>
<Icon :icon="postIcon" v-if="postIcon" :size="iconSize" />
</template>
</Button>
</a-upload>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
export default defineComponent({
name: 'JUploadButton',
inheritAttrs: false,
});
</script>
<script lang="ts" setup>
import { computed, unref } from 'vue';
import { Button } from 'ant-design-vue';
import Icon from '/@/components/Icon/src/Icon.vue';
import { buttonProps } from './props';
import { useAttrs } from '/@/hooks/core/useAttrs';
const props = defineProps(buttonProps);
// get component class
const attrs = useAttrs({ excludeDefaultKeys: false });
const getButtonClass = computed(() => {
const { color, disabled } = props;
return [
{
[`ant-btn-${color}`]: !!color,
[`is-disabled`]: disabled,
},
];
});
// get inherit binding value
const getBindValue = computed(() => ({ ...unref(attrs), ...props }));
</script>

View File

@ -0,0 +1,56 @@
<script lang="ts">
import { computed, defineComponent, h, unref } from 'vue';
import BasicButton from './BasicButton.vue';
import { Popconfirm } from 'ant-design-vue';
import { extendSlots } from '/@/utils/helper/tsxHelper';
import { omit } from 'lodash-es';
import { useAttrs } from '/@/hooks/core/useAttrs';
import { useI18n } from '/@/hooks/web/useI18n';
import { propTypes } from '/@/utils/propTypes';
const props = {
class: propTypes.any,
/**
* Whether to enable the drop-down menu
* @default: true
*/
enable: {
type: Boolean,
default: true,
},
};
export default defineComponent({
name: 'PopButton',
inheritAttrs: false,
props,
setup(props, { slots }) {
const { t } = useI18n();
const attrs = useAttrs();
// get inherit binding value
const getBindValues = computed(() => {
return Object.assign(
{
okText: t('common.okText'),
cancelText: t('common.cancelText'),
},
{ ...props, ...unref(attrs) }
);
});
return () => {
const bindValues = omit(unref(getBindValues), 'icon');
const btnBind = omit(bindValues, 'title') as Recordable;
if (btnBind.disabled) btnBind.color = '';
const Button = h(BasicButton, btnBind, extendSlots(slots));
// If it is not enabled, it is a normal button
if (!props.enable) {
return Button;
}
return h(Popconfirm, bindValues, { default: () => Button });
};
},
});
</script>

View File

@ -0,0 +1,21 @@
export const buttonProps = {
color: { type: String, validator: (v) => ['error', 'warning', 'success', ''].includes(v) },
loading: { type: Boolean },
disabled: { type: Boolean },
/**
* Text before icon.
*/
preIcon: { type: String },
/**
* Text after icon.
*/
postIcon: { type: String },
type: { type: String },
/**
* preIcon and postIcon icon size.
* @default: 15
*/
iconSize: { type: Number, default: 15 },
isUpload: { type: Boolean, default: false },
onClick: { type: Function as PropType<(...args) => any>, default: null },
};

View File

@ -0,0 +1,4 @@
import { withInstall } from '/@/utils';
import cardList from './src/CardList.vue';
export const CardList = withInstall(cardList);

View File

@ -0,0 +1,164 @@
<template>
<div class="p-2">
<div class="bg-white mb-2 p-4">
<BasicForm @register="registerForm" />
</div>
{{ sliderProp.width }}
<div class="bg-white p-2">
<List :grid="{ gutter: 5, xs: 1, sm: 2, md: 4, lg: 4, xl: 6, xxl: grid }" :data-source="data" :pagination="paginationProp">
<template #header>
<div class="flex justify-end space-x-2"
><slot name="header"></slot>
<Tooltip>
<template #title>
<div class="w-50">每行显示数量</div><Slider id="slider" v-bind="sliderProp" v-model:value="grid" @change="sliderChange"
/></template>
<Button><TableOutlined /></Button>
</Tooltip>
<Tooltip @click="fetch">
<template #title>刷新</template>
<Button><RedoOutlined /></Button>
</Tooltip>
</div>
</template>
<template #renderItem="{ item }">
<ListItem>
<Card>
<template #title></template>
<template #cover>
<div :class="height">
<Image :src="item.imgs[0]" />
</div>
</template>
<template class="ant-card-actions" #actions>
<!-- <SettingOutlined key="setting" />-->
<EditOutlined key="edit" />
<Dropdown
:trigger="['hover']"
:dropMenuList="[
{
text: '删除',
event: '1',
popConfirm: {
title: '是否确认删除',
confirm: handleDelete.bind(null, item.id),
},
},
]"
popconfirm
>
<EllipsisOutlined key="ellipsis" />
</Dropdown>
</template>
<CardMeta>
<template #title>
<TypographyText :content="item.name" :ellipsis="{ tooltip: item.address }" />
</template>
<template #avatar>
<Avatar :src="item.avatar" />
</template>
<template #description>{{ item.time }}</template>
</CardMeta>
</Card>
</ListItem>
</template>
</List>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, onMounted, ref } from 'vue';
import { EditOutlined, EllipsisOutlined, RedoOutlined, TableOutlined } from '@ant-design/icons-vue';
import { List, Card, Image, Typography, Tooltip, Slider, Avatar } from 'ant-design-vue';
import { Dropdown } from '/@/components/Dropdown';
import { BasicForm, useForm } from '/@/components/Form';
import { propTypes } from '/@/utils/propTypes';
import { Button } from '/@/components/Button';
import { isFunction } from '/@/utils/is';
import { useSlider, grid } from './data';
const ListItem = List.Item;
const CardMeta = Card.Meta;
const TypographyText = Typography.Text;
// 获取slider属性
const sliderProp = computed(() => useSlider(4));
// 组件接收参数
const props = defineProps({
// 请求API的参数
params: propTypes.object.def({}),
//api
api: propTypes.func,
});
//暴露内部方法
const emit = defineEmits(['getMethod', 'delete']);
//数据
const data = ref([]);
// 切换每行个数
// cover图片自适应高度
//修改pageSize并重新请求数据
const height = computed(() => {
return `h-${120 - grid.value * 6}`;
});
//表单
const [registerForm, { validate }] = useForm({
schemas: [{ field: 'type', component: 'Input', label: '类型' }],
labelWidth: 80,
baseColProps: { span: 6 },
actionColOptions: { span: 24 },
autoSubmitOnEnter: true,
submitFunc: handleSubmit,
});
//表单提交
async function handleSubmit() {
const data = await validate();
await fetch(data);
}
function sliderChange(n) {
pageSize.value = n * 4;
fetch();
}
// 自动请求并暴露内部方法
onMounted(() => {
fetch();
emit('getMethod', fetch);
});
async function fetch(p = {}) {
const { api, params } = props;
if (api && isFunction(api)) {
const res = await api({ ...params, page: page.value, pageSize: pageSize.value, ...p });
data.value = res.items;
total.value = res.total;
}
}
//分页相关
const page = ref(1);
const pageSize = ref(36);
const total = ref(0);
const paginationProp = ref({
showSizeChanger: false,
showQuickJumper: true,
pageSize,
current: page,
total,
showTotal: (total) => `总 ${total} 条`,
onChange: pageChange,
onShowSizeChange: pageSizeChange,
});
function pageChange(p, pz) {
page.value = p;
pageSize.value = pz;
fetch();
}
function pageSizeChange(current, size) {
pageSize.value = size;
fetch();
}
async function handleDelete(id) {
emit('delete', id);
}
</script>

View File

@ -0,0 +1,25 @@
import { ref } from 'vue';
//每行个数
export const grid = ref(12);
// slider属性
export const useSlider = (min = 6, max = 12) => {
// 每行显示个数滑动条
const getMarks = () => {
const l = {};
for (let i = min; i < max + 1; i++) {
l[i] = {
style: {
color: '#fff',
},
label: i,
};
}
return l;
};
return {
min,
max,
marks: getMarks(),
step: 1,
};
};

View File

@ -0,0 +1,4 @@
import { withInstall } from '/@/utils';
import clickOutSide from './src/ClickOutSide.vue';
export const ClickOutSide = withInstall(clickOutSide);

View File

@ -0,0 +1,19 @@
<template>
<div ref="wrap">
<slot></slot>
</div>
</template>
<script lang="ts" setup>
import { ref, onMounted } from 'vue';
import { onClickOutside } from '@vueuse/core';
const emit = defineEmits(['mounted', 'clickOutside']);
const wrap = ref<ElRef>(null);
onClickOutside(wrap, () => {
emit('clickOutside');
});
onMounted(() => {
emit('mounted');
});
</script>

View File

@ -0,0 +1,4 @@
import { withInstall } from '/@/utils';
import codeEditor from './src/CodeEditor.vue';
export const CodeEditor = withInstall(codeEditor);

View File

@ -0,0 +1,49 @@
<template>
<div class="h-full">
<CodeMirrorEditor :value="getValue" @change="handleValueChange" :mode="mode" :readonly="readonly" />
</div>
</template>
<script lang="ts">
const MODE = {
JSON: 'application/json',
html: 'htmlmixed',
js: 'javascript',
};
</script>
<script lang="ts" setup>
import { computed } from 'vue';
import CodeMirrorEditor from './codemirror/CodeMirror.vue';
import { isString } from '/@/utils/is';
const props = defineProps({
value: { type: [Object, String] as PropType<Record<string, any> | string> },
mode: { type: String, default: MODE.JSON },
readonly: { type: Boolean },
autoFormat: { type: Boolean, default: true },
});
const emit = defineEmits(['change', 'update:value', 'format-error']);
const getValue = computed(() => {
const { value, mode, autoFormat } = props;
if (!autoFormat || mode !== MODE.JSON) {
return value as string;
}
let result = value;
if (isString(value)) {
try {
result = JSON.parse(value);
} catch (e) {
emit('format-error', value);
return value as string;
}
}
return JSON.stringify(result, null, 2);
});
function handleValueChange(v) {
emit('update:value', v);
emit('change', v);
}
</script>

View File

@ -0,0 +1,102 @@
<template>
<div class="relative !h-full w-full overflow-hidden" ref="el"> </div>
</template>
<script lang="ts" setup>
import { ref, onMounted, onUnmounted, watchEffect, watch, unref, nextTick } from 'vue';
import { useDebounceFn } from '@vueuse/core';
import { useAppStore } from '/@/store/modules/app';
import { useWindowSizeFn } from '/@/hooks/event/useWindowSizeFn';
import CodeMirror from 'codemirror';
// css
import './codemirror.css';
import 'codemirror/theme/idea.css';
import 'codemirror/theme/material-palenight.css';
// modes
import 'codemirror/mode/javascript/javascript';
import 'codemirror/mode/css/css';
import 'codemirror/mode/htmlmixed/htmlmixed';
const props = defineProps({
mode: { type: String, default: 'application/json' },
value: { type: String, default: '' },
readonly: { type: Boolean, default: false },
});
const emit = defineEmits(['change']);
const el = ref();
let editor: Nullable<CodeMirror.Editor>;
const debounceRefresh = useDebounceFn(refresh, 100);
const appStore = useAppStore();
watch(
() => props.value,
async (value) => {
await nextTick();
const oldValue = editor?.getValue();
if (value !== oldValue) {
editor?.setValue(value ? value : '');
}
},
{ flush: 'post' }
);
watchEffect(() => {
editor?.setOption('mode', props.mode);
});
watch(
() => appStore.getDarkMode,
async () => {
setTheme();
},
{
immediate: true,
}
);
function setTheme() {
unref(editor)?.setOption('theme', appStore.getDarkMode === 'light' ? 'idea' : 'material-palenight');
}
function refresh() {
editor?.refresh();
}
async function init() {
const addonOptions = {
autoCloseBrackets: true,
autoCloseTags: true,
foldGutter: true,
gutters: ['CodeMirror-linenumbers'],
};
editor = CodeMirror(el.value!, {
value: '',
mode: props.mode,
readOnly: props.readonly,
tabSize: 2,
theme: 'material-palenight',
lineWrapping: true,
lineNumbers: true,
...addonOptions,
});
editor?.setValue(props.value);
setTheme();
editor?.on('change', () => {
emit('change', editor?.getValue());
});
}
onMounted(async () => {
await nextTick();
init();
useWindowSizeFn(debounceRefresh);
});
onUnmounted(() => {
editor = null;
});
</script>

View File

@ -0,0 +1,21 @@
import CodeMirror from 'codemirror';
import './codemirror.css';
import 'codemirror/theme/idea.css';
import 'codemirror/theme/material-palenight.css';
// import 'codemirror/addon/lint/lint.css';
// modes
import 'codemirror/mode/javascript/javascript';
import 'codemirror/mode/css/css';
import 'codemirror/mode/htmlmixed/htmlmixed';
// addons
// import 'codemirror/addon/edit/closebrackets';
// import 'codemirror/addon/edit/closetag';
// import 'codemirror/addon/comment/comment';
// import 'codemirror/addon/fold/foldcode';
// import 'codemirror/addon/fold/foldgutter';
// import 'codemirror/addon/fold/brace-fold';
// import 'codemirror/addon/fold/indent-fold';
// import 'codemirror/addon/lint/json-lint';
// import 'codemirror/addon/fold/comment-fold';
export { CodeMirror };

View File

@ -0,0 +1,539 @@
/* BASICS */
.CodeMirror {
--base: #545281;
--comment: hsl(210, 25%, 60%);
--keyword: #af4ab1;
--variable: #0055d1;
--function: #c25205;
--string: #2ba46d;
--number: #c25205;
--tags: #d00;
--qualifier: #ff6032;
--important: var(--string);
position: relative;
height: auto;
height: 100%;
overflow: hidden;
font-family: var(--font-code);
background: white;
direction: ltr;
}
/* PADDING */
.CodeMirror-lines {
min-height: 1px; /* prevents collapsing before first draw */
padding: 4px 0; /* Vertical padding around content */
cursor: text;
}
.CodeMirror-scrollbar-filler,
.CodeMirror-gutter-filler {
background-color: white; /* The little square between H and V scrollbars */
}
/* GUTTER */
.CodeMirror-gutters {
position: absolute;
top: 0;
left: 0;
z-index: 3;
min-height: 100%;
white-space: nowrap;
background-color: transparent;
border-right: 1px solid #ddd;
}
.CodeMirror-linenumber {
min-width: 20px;
padding: 0 3px 0 5px;
color: var(--comment);
text-align: right;
white-space: nowrap;
opacity: 0.6;
}
.CodeMirror-guttermarker {
color: black;
}
.CodeMirror-guttermarker-subtle {
color: #999;
}
/* FOLD GUTTER */
.CodeMirror-foldmarker {
font-family: arial;
line-height: 0.3;
color: #414141;
text-shadow: #f96 1px 1px 2px, #f96 -1px -1px 2px, #f96 1px -1px 2px, #f96 -1px 1px 2px;
cursor: pointer;
}
.CodeMirror-foldgutter {
width: 0.7em;
}
.CodeMirror-foldgutter-open,
.CodeMirror-foldgutter-folded {
cursor: pointer;
}
.CodeMirror-foldgutter-open::after,
.CodeMirror-foldgutter-folded::after {
position: relative;
top: -0.1em;
display: inline-block;
font-size: 0.8em;
content: '>';
opacity: 0.8;
transform: rotate(90deg);
transition: transform 0.2s;
}
.CodeMirror-foldgutter-folded::after {
transform: none;
}
/* CURSOR */
.CodeMirror-cursor {
position: absolute;
width: 0;
pointer-events: none;
border-right: none;
border-left: 1px solid black;
}
/* Shown when moving in bi-directional text */
.CodeMirror div.CodeMirror-secondarycursor {
border-left: 1px solid silver;
}
.cm-fat-cursor .CodeMirror-cursor {
width: auto;
background: #7e7;
border: 0 !important;
}
.cm-fat-cursor div.CodeMirror-cursors {
z-index: 1;
}
.cm-fat-cursor-mark {
background-color: rgba(20, 255, 20, 0.5);
-webkit-animation: blink 1.06s steps(1) infinite;
-moz-animation: blink 1.06s steps(1) infinite;
animation: blink 1.06s steps(1) infinite;
}
.cm-animate-fat-cursor {
width: auto;
background-color: #7e7;
border: 0;
-webkit-animation: blink 1.06s steps(1) infinite;
-moz-animation: blink 1.06s steps(1) infinite;
animation: blink 1.06s steps(1) infinite;
}
@-moz-keyframes blink {
50% {
background-color: transparent;
}
}
@-webkit-keyframes blink {
50% {
background-color: transparent;
}
}
@keyframes blink {
50% {
background-color: transparent;
}
}
.cm-tab {
display: inline-block;
text-decoration: inherit;
}
.CodeMirror-rulers {
position: absolute;
top: -50px;
right: 0;
bottom: -20px;
left: 0;
overflow: hidden;
}
.CodeMirror-ruler {
position: absolute;
top: 0;
bottom: 0;
border-left: 1px solid #ccc;
}
/* DEFAULT THEME */
.cm-s-default.CodeMirror {
background-color: transparent;
}
.cm-s-default .cm-header {
color: blue;
}
.cm-s-default .cm-quote {
color: #090;
}
.cm-negative {
color: #d44;
}
.cm-positive {
color: #292;
}
.cm-header,
.cm-strong {
font-weight: bold;
}
.cm-em {
font-style: italic;
}
.cm-link {
text-decoration: underline;
}
.cm-strikethrough {
text-decoration: line-through;
}
.cm-s-default .cm-atom,
.cm-s-default .cm-def,
.cm-s-default .cm-property,
.cm-s-default .cm-variable-2,
.cm-s-default .cm-variable-3,
.cm-s-default .cm-punctuation {
color: var(--base);
}
.cm-s-default .cm-hr,
.cm-s-default .cm-comment {
color: var(--comment);
}
.cm-s-default .cm-attribute,
.cm-s-default .cm-keyword {
color: var(--keyword);
}
.cm-s-default .cm-variable {
color: var(--variable);
}
.cm-s-default .cm-bracket,
.cm-s-default .cm-tag {
color: var(--tags);
}
.cm-s-default .cm-number {
color: var(--number);
}
.cm-s-default .cm-string,
.cm-s-default .cm-string-2 {
color: var(--string);
}
.cm-s-default .cm-type {
color: #085;
}
.cm-s-default .cm-meta {
color: #555;
}
.cm-s-default .cm-qualifier {
color: var(--qualifier);
}
.cm-s-default .cm-builtin {
color: #7539ff;
}
.cm-s-default .cm-link {
color: var(--flash);
}
.cm-s-default .cm-error {
color: #ff008c;
}
.cm-invalidchar {
color: #ff008c;
}
.CodeMirror-composing {
border-bottom: 2px solid;
}
/* Default styles for common addons */
div.CodeMirror span.CodeMirror-matchingbracket {
color: #0b0;
}
div.CodeMirror span.CodeMirror-nonmatchingbracket {
color: #a22;
}
.CodeMirror-matchingtag {
background: rgba(255, 150, 0, 0.3);
}
.CodeMirror-activeline-background {
background: #e8f2ff;
}
/* STOP */
/* The rest of this file contains styles related to the mechanics of
the editor. You probably shouldn't touch them. */
.CodeMirror-scroll {
position: relative;
height: 100%;
padding-bottom: 30px;
margin-right: -30px;
/* 30px is the magic margin used to hide the element's real scrollbars */
/* See overflow: hidden in .CodeMirror */
margin-bottom: -30px;
overflow: scroll !important; /* Things will break if this is overridden */
outline: none; /* Prevent dragging from highlighting the element */
}
.CodeMirror-sizer {
position: relative;
margin-bottom: 20px !important;
border-right: 30px solid transparent;
}
/* The fake, visible scrollbars. Used to force redraw during scrolling
before actual scrolling happens, thus preventing shaking and
flickering artifacts. */
.CodeMirror-vscrollbar,
.CodeMirror-hscrollbar,
.CodeMirror-scrollbar-filler,
.CodeMirror-gutter-filler {
position: absolute;
z-index: 6;
display: none;
}
.CodeMirror-vscrollbar {
top: 0;
right: 0;
overflow-x: hidden;
overflow-y: scroll;
}
.CodeMirror-hscrollbar {
bottom: 0;
left: 0;
overflow-x: scroll;
overflow-y: hidden;
}
.CodeMirror-scrollbar-filler {
right: 0;
bottom: 0;
}
.CodeMirror-gutter-filler {
bottom: 0;
left: 0;
}
.CodeMirror-gutter {
display: inline-block;
height: 100%;
margin-bottom: -30px;
white-space: normal;
vertical-align: top;
}
.CodeMirror-gutter-wrapper {
position: absolute;
z-index: 4;
background: none !important;
border: none !important;
}
.CodeMirror-gutter-background {
position: absolute;
top: 0;
bottom: 0;
z-index: 4;
}
.CodeMirror-gutter-elt {
position: absolute;
z-index: 4;
cursor: default;
}
.CodeMirror-gutter-wrapper ::selection {
background-color: transparent;
}
.CodeMirror-gutter-wrapper ::-moz-selection {
background-color: transparent;
}
.CodeMirror pre {
position: relative;
z-index: 2;
padding: 0 4px; /* Horizontal padding of content */
margin: 0;
overflow: visible;
font-family: inherit;
font-size: inherit;
line-height: inherit;
color: inherit;
word-wrap: normal;
white-space: pre;
background: transparent;
border-width: 0;
/* Reset some styles that the rest of the page might have set */
-moz-border-radius: 0;
-webkit-border-radius: 0;
border-radius: 0;
-webkit-tap-highlight-color: transparent;
-webkit-font-variant-ligatures: contextual;
font-variant-ligatures: contextual;
}
.CodeMirror-wrap pre {
word-break: normal;
word-wrap: break-word;
white-space: pre-wrap;
}
.CodeMirror-linebackground {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 0;
}
.CodeMirror-linewidget {
position: relative;
z-index: 2;
padding: 0.1px; /* Force widget margins to stay inside of the container */
}
.CodeMirror-rtl pre {
direction: rtl;
}
.CodeMirror-code {
outline: none;
}
/* Force content-box sizing for the elements where we expect it */
.CodeMirror-scroll,
.CodeMirror-sizer,
.CodeMirror-gutter,
.CodeMirror-gutters,
.CodeMirror-linenumber {
-moz-box-sizing: content-box;
box-sizing: content-box;
}
.CodeMirror-measure {
position: absolute;
width: 100%;
height: 0;
overflow: hidden;
visibility: hidden;
}
.CodeMirror-measure pre {
position: static;
}
div.CodeMirror-cursors {
position: relative;
z-index: 3;
visibility: hidden;
}
div.CodeMirror-dragcursors {
visibility: visible;
}
.CodeMirror-focused div.CodeMirror-cursors {
visibility: visible;
}
.CodeMirror-selected {
background: #d9d9d9;
}
.CodeMirror-focused .CodeMirror-selected {
background: #d7d4f0;
}
.CodeMirror-crosshair {
cursor: crosshair;
}
.CodeMirror-line::selection,
.CodeMirror-line > span::selection,
.CodeMirror-line > span > span::selection {
background: #d7d4f0;
}
.CodeMirror-line::-moz-selection,
.CodeMirror-line > span::-moz-selection,
.CodeMirror-line > span > span::-moz-selection {
background: #d7d4f0;
}
.cm-searching {
background-color: #ffa;
background-color: rgba(255, 255, 0, 0.4);
}
/* Used to force a border model for a node */
.cm-force-border {
padding-right: 0.1px;
}
@media print {
/* Hide the cursor when printing */
.CodeMirror div.CodeMirror-cursors {
visibility: hidden;
}
}
/* See issue #2901 */
.cm-tab-wrap-hack::after {
content: '';
}
/* Help users use markselection to safely style text background */
span.CodeMirror-selectedtext {
background: none;
}

View File

@ -0,0 +1,5 @@
export enum MODE {
JSON = 'application/json',
HTML = 'htmlmixed',
JS = 'javascript',
}

View File

@ -0,0 +1,10 @@
import { withInstall } from '/@/utils';
import collapseContainer from './src/collapse/CollapseContainer.vue';
import scrollContainer from './src/ScrollContainer.vue';
import lazyContainer from './src/LazyContainer.vue';
export const CollapseContainer = withInstall(collapseContainer);
export const ScrollContainer = withInstall(scrollContainer);
export const LazyContainer = withInstall(lazyContainer);
export * from './src/typing';

View File

@ -0,0 +1,138 @@
<template>
<transition-group class="h-full w-full" v-bind="$attrs" ref="elRef" :name="transitionName" :tag="tag" mode="out-in">
<div key="component" v-if="isInit">
<slot :loading="loading"></slot>
</div>
<div key="skeleton" v-else>
<slot name="skeleton" v-if="$slots.skeleton"></slot>
<Skeleton v-else />
</div>
</transition-group>
</template>
<script lang="ts">
import type { PropType } from 'vue';
import { defineComponent, reactive, onMounted, ref, toRef, toRefs } from 'vue';
import { Skeleton } from 'ant-design-vue';
import { useTimeoutFn } from '/@/hooks/core/useTimeout';
import { useIntersectionObserver } from '/@/hooks/event/useIntersectionObserver';
interface State {
isInit: boolean;
loading: boolean;
intersectionObserverInstance: IntersectionObserver | null;
}
const props = {
/**
* Waiting time, if the time is specified, whether visible or not, it will be automatically loaded after the specified time
*/
timeout: { type: Number },
/**
* The viewport where the component is located.
* If the component is scrolling in the page container, the viewport is the container
*/
viewport: {
type: (typeof window !== 'undefined' ? window.HTMLElement : Object) as PropType<HTMLElement>,
default: () => null,
},
/**
* Preload threshold, css unit
*/
threshold: { type: String, default: '0px' },
/**
* The scroll direction of the viewport, vertical represents the vertical direction, horizontal represents the horizontal direction
*/
direction: {
type: String,
default: 'vertical',
validator: (v) => ['vertical', 'horizontal'].includes(v),
},
/**
* The label name of the outer container that wraps the component
*/
tag: { type: String, default: 'div' },
maxWaitingTime: { type: Number, default: 80 },
/**
* transition name
*/
transitionName: { type: String, default: 'lazy-container' },
};
export default defineComponent({
name: 'LazyContainer',
components: { Skeleton },
inheritAttrs: false,
props,
emits: ['init'],
setup(props, { emit }) {
const elRef = ref();
const state = reactive<State>({
isInit: false,
loading: false,
intersectionObserverInstance: null,
});
onMounted(() => {
immediateInit();
initIntersectionObserver();
});
// If there is a set delay time, it will be executed immediately
function immediateInit() {
const { timeout } = props;
timeout &&
useTimeoutFn(() => {
init();
}, timeout);
}
function init() {
state.loading = true;
useTimeoutFn(() => {
if (state.isInit) return;
state.isInit = true;
emit('init');
}, props.maxWaitingTime || 80);
}
function initIntersectionObserver() {
const { timeout, direction, threshold } = props;
if (timeout) return;
// According to the scrolling direction to construct the viewport margin, used to load in advance
let rootMargin = '0px';
switch (direction) {
case 'vertical':
rootMargin = `${threshold} 0px`;
break;
case 'horizontal':
rootMargin = `0px ${threshold}`;
break;
}
try {
const { stop, observer } = useIntersectionObserver({
rootMargin,
target: toRef(elRef.value, '$el'),
onIntersect: (entries: any[]) => {
const isIntersecting = entries[0].isIntersecting || entries[0].intersectionRatio;
if (isIntersecting) {
init();
if (observer) {
stop();
}
}
},
root: toRef(props, 'viewport'),
});
} catch (e) {
init();
}
}
return {
elRef,
...toRefs(state),
};
},
});
</script>

View File

@ -0,0 +1,93 @@
<template>
<Scrollbar ref="scrollbarRef" class="scroll-container" v-bind="$attrs">
<slot></slot>
</Scrollbar>
</template>
<script lang="ts">
import { defineComponent, ref, unref, nextTick } from 'vue';
import { Scrollbar, ScrollbarType } from '/@/components/Scrollbar';
import { useScrollTo } from '/@/hooks/event/useScrollTo';
export default defineComponent({
name: 'ScrollContainer',
components: { Scrollbar },
setup() {
const scrollbarRef = ref<Nullable<ScrollbarType>>(null);
/**
* Scroll to the specified position
*/
function scrollTo(to: number, duration = 500) {
const scrollbar = unref(scrollbarRef);
if (!scrollbar) {
return;
}
nextTick(() => {
const wrap = unref(scrollbar.wrap);
if (!wrap) {
return;
}
const { start } = useScrollTo({
el: wrap,
to,
duration,
});
start();
});
}
function getScrollWrap() {
const scrollbar = unref(scrollbarRef);
if (!scrollbar) {
return null;
}
return scrollbar.wrap;
}
/**
* Scroll to the bottom
*/
function scrollBottom() {
const scrollbar = unref(scrollbarRef);
if (!scrollbar) {
return;
}
nextTick(() => {
const wrap = unref(scrollbar.wrap) as any;
if (!wrap) {
return;
}
const scrollHeight = wrap.scrollHeight as number;
const { start } = useScrollTo({
el: 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,105 @@
<template>
<div :class="prefixCls">
<CollapseHeader v-bind="$props" :prefixCls="prefixCls" :show="show" @expand="handleExpand">
<template #title>
<slot name="title"></slot>
</template>
<template #action>
<slot name="action"></slot>
</template>
</CollapseHeader>
<div class="p-2">
<CollapseTransition :enable="canExpan">
<Skeleton v-if="loading" :active="loading" />
<div :class="`${prefixCls}__body`" v-else v-show="show">
<slot></slot>
</div>
</CollapseTransition>
</div>
<div :class="`${prefixCls}__footer`" v-if="$slots.footer">
<slot name="footer"></slot>
</div>
</div>
</template>
<script lang="ts" setup>
import type { PropType } from 'vue';
import { ref } from 'vue';
// component
import { Skeleton } from 'ant-design-vue';
import { CollapseTransition } from '/@/components/Transition';
import CollapseHeader from './CollapseHeader.vue';
import { triggerWindowResize } from '/@/utils/event';
// hook
import { useTimeoutFn } from '/@/hooks/core/useTimeout';
import { useDesign } from '/@/hooks/web/useDesign';
const props = defineProps({
title: { type: String, default: '' },
loading: { type: Boolean },
/**
* Can it be expanded
*/
canExpan: { type: Boolean, default: true },
/**
* Warm reminder on the right side of the title
*/
helpMessage: {
type: [Array, String] as PropType<string[] | string>,
default: '',
},
/**
* Whether to trigger window.resize when expanding and contracting,
* Can adapt to tables and forms, when the form shrinks, the form triggers resize to adapt to the height
*/
triggerWindowResize: { type: Boolean },
/**
* Delayed loading time
*/
lazyTime: { type: Number, default: 0 },
});
const show = ref(true);
const { prefixCls } = useDesign('collapse-container');
/**
* @description: Handling development events
*/
function handleExpand() {
show.value = !show.value;
if (props.triggerWindowResize) {
// 200 milliseconds here is because the expansion has animation,
useTimeoutFn(triggerWindowResize, 200);
}
}
</script>
<style lang="less">
@prefix-cls: ~'@{namespace}-collapse-container';
.@{prefix-cls} {
background-color: @component-background;
border-radius: 2px;
transition: all 0.3s ease-in-out;
&__header {
display: flex;
height: 32px;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid @border-color-light;
}
&__footer {
border-top: 1px solid @border-color-light;
}
&__action {
display: flex;
text-align: right;
flex: 1;
align-items: center;
justify-content: flex-end;
}
}
</style>

View File

@ -0,0 +1,38 @@
<template>
<div :class="[`${prefixCls}__header px-2 py-5`, $attrs.class]">
<BasicTitle :helpMessage="helpMessage" normal>
<template v-if="title">
{{ title }}
</template>
<template v-else>
<slot name="title"></slot>
</template>
</BasicTitle>
<div :class="`${prefixCls}__action`">
<slot name="action"></slot>
<BasicArrow v-if="canExpan" up :expand="show" @click="$emit('expand')" />
</div>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import { BasicArrow, BasicTitle } from '/@/components/Basic';
const props = {
prefixCls: { type: String },
helpMessage: {
type: [Array, String] as PropType<string[] | string>,
default: '',
},
title: { type: String },
show: { type: Boolean },
canExpan: { type: Boolean },
};
export default defineComponent({
components: { BasicArrow, BasicTitle },
inheritAttrs: false,
props,
emits: ['expand'],
});
</script>

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,3 @@
export { createContextMenu, destroyContextMenu } from './src/createContextMenu';
export * from './src/typing';

View File

@ -0,0 +1,196 @@
<script lang="tsx">
import type { ContextMenuItem, ItemContentProps, Axis } from './typing';
import type { FunctionalComponent, CSSProperties } from 'vue';
import { defineComponent, nextTick, onMounted, computed, ref, unref, onUnmounted } from 'vue';
import Icon from '/@/components/Icon';
import { Menu, Divider } from 'ant-design-vue';
const prefixCls = 'context-menu';
const props = {
width: { type: Number, default: 156 },
customEvent: { type: Object as PropType<Event>, default: null },
styles: { type: Object as PropType<CSSProperties> },
showIcon: { type: Boolean, default: true },
axis: {
// The position of the right mouse button click
type: Object as PropType<Axis>,
default() {
return { x: 0, y: 0 };
},
},
items: {
// The most important list, if not, will not be displayed
type: Array as PropType<ContextMenuItem[]>,
default() {
return [];
},
},
};
const ItemContent: FunctionalComponent<ItemContentProps> = (props) => {
const { item } = props;
return (
<span style="display: inline-block; width: 100%; " class="px-4" onClick={props.handler.bind(null, item)}>
{props.showIcon && item.icon && <Icon class="mr-2" icon={item.icon} />}
<span>{item.label}</span>
</span>
);
};
export default defineComponent({
name: 'ContextMenu',
props,
setup(props) {
const wrapRef = ref(null);
const showRef = ref(false);
const getStyle = computed((): CSSProperties => {
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;
const left = body.clientWidth < x + menuWidth ? x - menuWidth : x;
const top = body.clientHeight < y + menuHeight ? y - menuHeight : y;
return {
...styles,
width: `${width}px`,
left: `${left + 1}px`,
top: `${top + 1}px`,
};
});
onMounted(() => {
nextTick(() => (showRef.value = true));
});
onUnmounted(() => {
const el = unref(wrapRef);
el && document.body.removeChild(el);
});
function handleAction(item: ContextMenuItem, e: MouseEvent) {
const { handler, disabled } = item;
if (disabled) {
return;
}
showRef.value = false;
e?.stopPropagation();
e?.preventDefault();
handler?.();
}
function renderMenuItem(items: ContextMenuItem[]) {
return items.map((item) => {
const { disabled, label, children, divider = false } = item;
const contentProps = {
item,
handler: handleAction,
showIcon: props.showIcon,
};
if (!children || children.length === 0) {
return (
<>
<Menu.Item disabled={disabled} class={`${prefixCls}__item`} key={label}>
<ItemContent {...contentProps} />
</Menu.Item>
{divider ? <Divider key={`d-${label}`} /> : null}
</>
);
}
if (!unref(showRef)) return null;
return (
<Menu.SubMenu key={label} disabled={disabled} popupClassName={`${prefixCls}__popup`}>
{{
title: () => <ItemContent {...contentProps} />,
default: () => renderMenuItem(children),
}}
</Menu.SubMenu>
);
});
}
return () => {
if (!unref(showRef)) {
return null;
}
const { items } = props;
return (
<Menu inlineIndent={12} mode="vertical" class={prefixCls} ref={wrapRef} style={unref(getStyle)}>
{renderMenuItem(items)}
</Menu>
);
};
},
});
</script>
<style lang="less">
@default-height: 42px !important;
@small-height: 36px !important;
@large-height: 36px !important;
.item-style() {
li {
display: inline-block;
width: 100%;
height: @default-height;
margin: 0 !important;
line-height: @default-height;
span {
line-height: @default-height;
}
> div {
margin: 0 !important;
}
&:not(.ant-menu-item-disabled):hover {
color: @text-color-base;
background-color: @item-hover-bg;
}
}
}
.context-menu {
position: fixed;
top: 0;
left: 0;
z-index: 200;
display: block;
width: 156px;
margin: 0;
list-style: none;
background-color: @component-background;
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;
.item-style();
.ant-divider {
margin: 0 0;
}
&__popup {
.ant-divider {
margin: 0 0;
}
.item-style();
}
.ant-menu-submenu-title,
.ant-menu-item {
padding: 0 !important;
}
}
</style>

View File

@ -0,0 +1,75 @@
import contextMenuVue from './ContextMenu.vue';
import { isClient } from '/@/utils/is';
import { CreateContextOptions, ContextMenuProps } from './typing';
import { createVNode, render } from 'vue';
const menuManager: {
domList: Element[];
resolve: Fn;
} = {
domList: [],
resolve: () => {},
};
export const createContextMenu = function (options: CreateContextOptions) {
const { event } = options || {};
event && event?.preventDefault();
if (!isClient) {
return;
}
return new Promise((resolve) => {
const body = document.body;
const container = document.createElement('div');
const propsData: Partial<ContextMenuProps> = {};
if (options.styles) {
propsData.styles = options.styles;
}
if (options.items) {
propsData.items = options.items;
}
if (options.event) {
propsData.customEvent = event;
propsData.axis = { x: event.clientX, y: event.clientY };
}
const vm = createVNode(contextMenuVue, propsData);
render(vm, container);
const handleClick = function () {
menuManager.resolve('');
};
menuManager.domList.push(container);
const remove = function () {
menuManager.domList.forEach((dom: Element) => {
try {
dom && body.removeChild(dom);
} catch (error) {}
});
body.removeEventListener('click', handleClick);
body.removeEventListener('scroll', handleClick);
};
menuManager.resolve = function (arg) {
remove();
resolve(arg);
};
remove();
body.appendChild(container);
body.addEventListener('click', handleClick);
body.addEventListener('scroll', handleClick);
});
};
export const destroyContextMenu = function () {
if (menuManager) {
menuManager.resolve('');
menuManager.domList = [];
}
};

View File

@ -0,0 +1,35 @@
export interface Axis {
x: number;
y: number;
}
export interface ContextMenuItem {
label: string;
icon?: string;
disabled?: boolean;
handler?: Fn;
divider?: boolean;
children?: ContextMenuItem[];
}
export interface CreateContextOptions {
event: MouseEvent;
icon?: string;
styles?: any;
items?: ContextMenuItem[];
}
export interface ContextMenuProps {
event?: MouseEvent;
styles?: any;
items: ContextMenuItem[];
customEvent?: MouseEvent;
axis?: Axis;
width?: number;
showIcon?: boolean;
}
export interface ItemContentProps {
showIcon: boolean | undefined;
item: ContextMenuItem;
handler: Fn;
}

View File

@ -0,0 +1,6 @@
import { withInstall } from '/@/utils';
import countButton from './src/CountButton.vue';
import countdownInput from './src/CountdownInput.vue';
export const CountdownInput = withInstall(countdownInput);
export const CountButton = withInstall(countButton);

View File

@ -0,0 +1,73 @@
<template>
<Button v-bind="$attrs" :disabled="isStart" @click="handleStart" :loading="loading">
{{ getButtonText }}
</Button>
<!-- 图片验证码弹窗 -->
<CaptchaModal @register="captchaRegisterModal" @ok="handleStart" />
</template>
<script lang="ts">
import { defineComponent, ref, watchEffect, computed, unref } from 'vue';
import { Button } from 'ant-design-vue';
import { useCountdown } from './useCountdown';
import { isFunction } from '/@/utils/is';
import { useI18n } from '/@/hooks/web/useI18n';
import { useModal } from "@/components/Modal";
import { createAsyncComponent } from "@/utils/factory/createAsyncComponent";
import { ExceptionEnum } from "@/enums/exceptionEnum";
const CaptchaModal = createAsyncComponent(() => import('/@/components/jeecg/captcha/CaptchaModal.vue'));
const [captchaRegisterModal, { openModal: openCaptchaModal }] = useModal();
const props = {
value: { type: [Object, Number, String, Array] },
count: { type: Number, default: 60 },
beforeStartFunc: {
type: Function as PropType<() => Promise<boolean>>,
default: null,
},
};
export default defineComponent({
name: 'CountButton',
components: { Button, CaptchaModal },
props,
setup(props) {
const loading = ref(false);
const { currentCount, isStart, start, reset } = useCountdown(props.count);
const { t } = useI18n();
const getButtonText = computed(() => {
return !unref(isStart) ? t('component.countdown.normalText') : t('component.countdown.sendText', [unref(currentCount)]);
});
watchEffect(() => {
props.value === undefined && reset();
});
/**
* @description: Judge whether there is an external function before execution, and decide whether to start after execution
*/
async function handleStart() {
const { beforeStartFunc } = props;
if (beforeStartFunc && isFunction(beforeStartFunc)) {
loading.value = true;
try {
//update-begin---author:wangshuai---date:2024-04-18---for:【QQYUN-9005】同一个IP1分钟超过5次短信则提示需要验证码---
const canStart = await beforeStartFunc().catch((res) =>{
if(res.code === ExceptionEnum.PHONE_SMS_FAIL_CODE){
openCaptchaModal(true, {});
}
});
//update-end---author:wangshuai---date:2024-04-18---for:【QQYUN-9005】同一个IP1分钟超过5次短信则提示需要验证码---
canStart && start();
} finally {
loading.value = false;
}
} else {
start();
}
}
return { handleStart, currentCount, loading, getButtonText, isStart, captchaRegisterModal };
},
});
</script>

View File

@ -0,0 +1,54 @@
<template>
<a-input v-bind="$attrs" :class="prefixCls" :size="size" :value="state">
<template #addonAfter>
<CountButton :size="size" :count="count" :value="state" :beforeStartFunc="sendCodeApi" />
</template>
<template #[item]="data" v-for="item in Object.keys($slots).filter((k) => k !== 'addonAfter')">
<slot :name="item" v-bind="data || {}"></slot>
</template>
</a-input>
</template>
<script lang="ts">
import { defineComponent, PropType } from 'vue';
import CountButton from './CountButton.vue';
import { useDesign } from '/@/hooks/web/useDesign';
import { useRuleFormItem } from '/@/hooks/component/useFormItemSingle';
const props = {
value: { type: String },
size: { type: String, validator: (v) => ['default', 'large', 'small'].includes(v) },
count: { type: Number, default: 60 },
sendCodeApi: {
type: Function as PropType<() => Promise<boolean>>,
default: null,
},
};
export default defineComponent({
name: 'CountDownInput',
components: { CountButton },
inheritAttrs: false,
props,
setup(props) {
const { prefixCls } = useDesign('countdown-input');
const [state] = useRuleFormItem(props);
return { prefixCls, state };
},
});
</script>
<style lang="less">
@prefix-cls: ~'@{namespace}-countdown-input';
.@{prefix-cls} {
.ant-input-group-addon {
padding-right: 0;
background-color: transparent;
border: none;
button {
font-size: 14px;
}
}
}
</style>

View File

@ -0,0 +1,51 @@
import { ref, unref } from 'vue';
import { tryOnUnmounted } from '@vueuse/core';
export function useCountdown(count: number) {
const currentCount = ref(count);
const isStart = ref(false);
let timerId: ReturnType<typeof setInterval> | null;
function clear() {
timerId && window.clearInterval(timerId);
}
function stop() {
isStart.value = false;
clear();
timerId = null;
}
function start() {
if (unref(isStart) || !!timerId) {
return;
}
isStart.value = true;
timerId = setInterval(() => {
if (unref(currentCount) === 1) {
stop();
currentCount.value = count;
} else {
currentCount.value -= 1;
}
}, 1000);
}
function reset() {
currentCount.value = count;
stop();
}
function restart() {
reset();
start();
}
tryOnUnmounted(() => {
reset();
});
return { start, reset, restart, clear, stop, currentCount, isStart };
}

View File

@ -0,0 +1,4 @@
import { withInstall } from '/@/utils';
import countTo from './src/CountTo.vue';
export const CountTo = withInstall(countTo);

View File

@ -0,0 +1,110 @@
<template>
<span :style="{ color }">
{{ value }}
</span>
</template>
<script lang="ts">
import { defineComponent, ref, computed, watchEffect, unref, onMounted, watch } from 'vue';
import { useTransition, TransitionPresets } from '@vueuse/core';
import { isNumber } from '/@/utils/is';
const props = {
startVal: { type: Number, default: 0 },
endVal: { type: Number, default: 2021 },
duration: { type: Number, default: 1500 },
autoplay: { type: Boolean, default: true },
decimals: {
type: Number,
default: 0,
validator(value: number) {
return value >= 0;
},
},
prefix: { type: String, default: '' },
suffix: { type: String, default: '' },
separator: { type: String, default: ',' },
decimal: { type: String, default: '.' },
/**
* font color
*/
color: { type: String },
/**
* Turn on digital animation
*/
useEasing: { type: Boolean, default: true },
/**
* Digital animation
*/
transition: { type: String, default: 'linear' },
};
export default defineComponent({
name: 'CountTo',
props,
emits: ['onStarted', 'onFinished'],
setup(props, { emit }) {
const source = ref(props.startVal);
const disabled = ref(false);
let outputValue = useTransition(source);
const value = computed(() => formatNumber(unref(outputValue)));
watchEffect(() => {
source.value = props.startVal;
});
watch([() => props.startVal, () => props.endVal], () => {
if (props.autoplay) {
start();
}
});
onMounted(() => {
props.autoplay && start();
});
function start() {
run();
source.value = props.endVal;
}
function reset() {
source.value = props.startVal;
run();
}
function run() {
outputValue = useTransition(source, {
disabled,
duration: props.duration,
onFinished: () => emit('onFinished'),
onStarted: () => emit('onStarted'),
...(props.useEasing ? { transition: TransitionPresets[props.transition] } : {}),
});
}
function formatNumber(num: number | string) {
if (!num && num !== 0) {
return '';
}
const { decimals, decimal, separator, suffix, prefix } = props;
num = Number(num).toFixed(decimals);
num += '';
const x = num.split('.');
let x1 = x[0];
const x2 = x.length > 1 ? decimal + x[1] : '';
const rgx = /(\d+)(\d{3})/;
if (separator && !isNumber(separator)) {
while (rgx.test(x1)) {
x1 = x1.replace(rgx, '$1' + separator + '$2');
}
}
return prefix + x1 + x2 + suffix;
}
return { value, start, reset };
},
});
</script>

View File

@ -0,0 +1,7 @@
import { withInstall } from '/@/utils';
import cropperImage from './src/Cropper.vue';
import avatarCropper from './src/CropperAvatar.vue';
export * from './src/typing';
export const CropperImage = withInstall(cropperImage);
export const CropperAvatar = withInstall(avatarCropper);

View File

@ -0,0 +1,237 @@
<template>
<BasicModal
v-bind="$attrs"
@register="register"
:title="t('component.cropper.modalTitle')"
width="800px"
:canFullscreen="false"
@ok="handleOk"
:okText="t('component.cropper.okText')"
>
<div :class="prefixCls">
<div :class="`${prefixCls}-left`">
<div :class="`${prefixCls}-cropper`">
<CropperImage v-if="src" :src="src" height="300px" :circled="circled" @cropend="handleCropend" @ready="handleReady" />
</div>
<div :class="`${prefixCls}-toolbar`">
<Upload :fileList="[]" accept="image/*" :beforeUpload="handleBeforeUpload">
<Tooltip :title="t('component.cropper.selectImage')" placement="bottom">
<a-button size="small" preIcon="ant-design:upload-outlined" type="primary" />
</Tooltip>
</Upload>
<Space>
<Tooltip :title="t('component.cropper.btn_reset')" placement="bottom">
<a-button type="primary" preIcon="ant-design:reload-outlined" size="small" :disabled="!src" @click="handlerToolbar('reset')" />
</Tooltip>
<Tooltip :title="t('component.cropper.btn_rotate_left')" placement="bottom">
<a-button
type="primary"
preIcon="ant-design:rotate-left-outlined"
size="small"
:disabled="!src"
@click="handlerToolbar('rotate', -45)"
/>
</Tooltip>
<Tooltip :title="t('component.cropper.btn_rotate_right')" placement="bottom">
<a-button
type="primary"
preIcon="ant-design:rotate-right-outlined"
size="small"
:disabled="!src"
@click="handlerToolbar('rotate', 45)"
/>
</Tooltip>
<Tooltip :title="t('component.cropper.btn_scale_x')" placement="bottom">
<a-button type="primary" preIcon="vaadin:arrows-long-h" size="small" :disabled="!src" @click="handlerToolbar('scaleX')" />
</Tooltip>
<Tooltip :title="t('component.cropper.btn_scale_y')" placement="bottom">
<a-button type="primary" preIcon="vaadin:arrows-long-v" size="small" :disabled="!src" @click="handlerToolbar('scaleY')" />
</Tooltip>
<Tooltip :title="t('component.cropper.btn_zoom_in')" placement="bottom">
<a-button type="primary" preIcon="ant-design:zoom-in-outlined" size="small" :disabled="!src" @click="handlerToolbar('zoom', 0.1)" />
</Tooltip>
<Tooltip :title="t('component.cropper.btn_zoom_out')" placement="bottom">
<a-button type="primary" preIcon="ant-design:zoom-out-outlined" size="small" :disabled="!src" @click="handlerToolbar('zoom', -0.1)" />
</Tooltip>
</Space>
</div>
</div>
<div :class="`${prefixCls}-right`">
<div :class="`${prefixCls}-preview`">
<img :src="previewSource" v-if="previewSource" :alt="t('component.cropper.preview')" />
</div>
<template v-if="previewSource">
<div :class="`${prefixCls}-group`">
<Avatar :src="previewSource" size="large" />
<Avatar :src="previewSource" :size="48" />
<Avatar :src="previewSource" :size="64" />
<Avatar :src="previewSource" :size="80" />
</div>
</template>
</div>
</div>
</BasicModal>
</template>
<script lang="ts">
import type { CropendResult, Cropper } from './typing';
import { defineComponent, ref } from 'vue';
import CropperImage from './Cropper.vue';
import { Space, Upload, Avatar, Tooltip } from 'ant-design-vue';
import { useDesign } from '/@/hooks/web/useDesign';
import { BasicModal, useModalInner } from '/@/components/Modal';
import { dataURLtoBlob } from '/@/utils/file/base64Conver';
import { isFunction } from '/@/utils/is';
import { useI18n } from '/@/hooks/web/useI18n';
type apiFunParams = { file: Blob; name: string; filename: string };
const props = {
circled: { type: Boolean, default: true },
uploadApi: {
type: Function as PropType<(params: apiFunParams) => Promise<any>>,
},
};
export default defineComponent({
name: 'CropperModal',
components: { BasicModal, Space, CropperImage, Upload, Avatar, Tooltip },
props,
emits: ['uploadSuccess', 'register'],
setup(props, { emit }) {
let filename = '';
const src = ref('');
const previewSource = ref('');
const cropper = ref<Cropper>();
let scaleX = 1;
let scaleY = 1;
const { prefixCls } = useDesign('cropper-am');
const [register, { closeModal, setModalProps }] = useModalInner();
const { t } = useI18n();
// Block upload
function handleBeforeUpload(file: File) {
const reader = new FileReader();
reader.readAsDataURL(file);
src.value = '';
previewSource.value = '';
reader.onload = function (e) {
src.value = (e.target?.result as string) ?? '';
filename = file.name;
};
return false;
}
function handleCropend({ imgBase64 }: CropendResult) {
previewSource.value = imgBase64;
}
function handleReady(cropperInstance: Cropper) {
cropper.value = cropperInstance;
}
function handlerToolbar(event: string, arg?: number) {
if (event === 'scaleX') {
scaleX = arg = scaleX === -1 ? 1 : -1;
}
if (event === 'scaleY') {
scaleY = arg = scaleY === -1 ? 1 : -1;
}
cropper?.value?.[event]?.(arg);
}
async function handleOk() {
const uploadApi = props.uploadApi;
if (uploadApi && isFunction(uploadApi)) {
const blob = dataURLtoBlob(previewSource.value);
try {
setModalProps({ confirmLoading: true });
const result = await uploadApi({ name: 'file', file: blob, filename });
emit('uploadSuccess', {
source: previewSource.value,
data: result.data || result.message,
});
closeModal();
} finally {
setModalProps({ confirmLoading: false });
}
}
}
return {
t,
prefixCls,
src,
register,
previewSource,
handleBeforeUpload,
handleCropend,
handleReady,
handlerToolbar,
handleOk,
};
},
});
</script>
<style lang="less">
@prefix-cls: ~'@{namespace}-cropper-am';
.@{prefix-cls} {
display: flex;
&-left,
&-right {
height: 340px;
}
&-left {
width: 55%;
}
&-right {
width: 45%;
}
&-cropper {
height: 300px;
background: #eee;
background-image: linear-gradient(45deg, rgba(0, 0, 0, 0.25) 25%, transparent 0, transparent 75%, rgba(0, 0, 0, 0.25) 0),
linear-gradient(45deg, rgba(0, 0, 0, 0.25) 25%, transparent 0, transparent 75%, rgba(0, 0, 0, 0.25) 0);
background-position: 0 0, 12px 12px;
background-size: 24px 24px;
}
&-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 10px;
}
&-preview {
width: 220px;
height: 220px;
margin: 0 auto;
overflow: hidden;
border: 1px solid @border-color-base;
border-radius: 50%;
img {
width: 100%;
height: 100%;
}
}
&-group {
display: flex;
padding-top: 8px;
margin-top: 8px;
border-top: 1px solid @border-color-base;
justify-content: space-around;
align-items: center;
}
}
</style>

View File

@ -0,0 +1,181 @@
<template>
<div :class="getClass" :style="getWrapperStyle">
<img v-show="isReady" ref="imgElRef" :src="src" :alt="alt" :crossorigin="crossorigin" :style="getImageStyle" />
</div>
</template>
<script lang="ts">
import type { CSSProperties } from 'vue';
import { defineComponent, onMounted, ref, unref, computed, onUnmounted } from 'vue';
import Cropper from 'cropperjs';
import 'cropperjs/dist/cropper.css';
import { useDesign } from '/@/hooks/web/useDesign';
import { useDebounceFn } from '@vueuse/core';
type Options = Cropper.Options;
const defaultOptions: Options = {
aspectRatio: 1,
zoomable: true,
zoomOnTouch: true,
zoomOnWheel: true,
cropBoxMovable: true,
cropBoxResizable: true,
toggleDragModeOnDblclick: true,
autoCrop: true,
background: true,
highlight: true,
center: true,
responsive: true,
restore: true,
checkCrossOrigin: true,
checkOrientation: true,
scalable: true,
modal: true,
guides: true,
movable: true,
rotatable: true,
};
const props = {
src: { type: String, required: true },
alt: { type: String },
circled: { type: Boolean, default: false },
realTimePreview: { type: Boolean, default: true },
height: { type: [String, Number], default: '360px' },
crossorigin: {
type: String as PropType<'' | 'anonymous' | 'use-credentials' | undefined>,
default: undefined,
},
imageStyle: { type: Object as PropType<CSSProperties>, default: () => ({}) },
options: { type: Object as PropType<Options>, default: () => ({}) },
};
export default defineComponent({
name: 'CropperImage',
props,
emits: ['cropend', 'ready', 'cropendError'],
setup(props, { attrs, emit }) {
const imgElRef = ref<ElRef<HTMLImageElement>>();
const cropper = ref<Nullable<Cropper>>();
const isReady = ref(false);
const { prefixCls } = useDesign('cropper-image');
const debounceRealTimeCroppered = useDebounceFn(realTimeCroppered, 80);
const getImageStyle = computed((): CSSProperties => {
return {
height: props.height,
maxWidth: '100%',
...props.imageStyle,
};
});
const getClass = computed(() => {
return [
prefixCls,
attrs.class,
{
[`${prefixCls}--circled`]: props.circled,
},
];
});
const getWrapperStyle = computed((): CSSProperties => {
return { height: `${props.height}`.replace(/px/, '') + 'px' };
});
onMounted(init);
onUnmounted(() => {
cropper.value?.destroy();
});
async function init() {
const imgEl = unref(imgElRef);
if (!imgEl) {
return;
}
cropper.value = new Cropper(imgEl, {
...defaultOptions,
ready: () => {
isReady.value = true;
realTimeCroppered();
emit('ready', cropper.value);
},
crop() {
debounceRealTimeCroppered();
},
zoom() {
debounceRealTimeCroppered();
},
cropmove() {
debounceRealTimeCroppered();
},
...props.options,
});
}
// Real-time display preview
function realTimeCroppered() {
props.realTimePreview && croppered();
}
// event: return base64 and width and height information after cropping
function croppered() {
if (!cropper.value) {
return;
}
let imgInfo = cropper.value.getData();
const canvas = props.circled ? getRoundedCanvas() : cropper.value.getCroppedCanvas();
canvas.toBlob((blob) => {
if (!blob) {
return;
}
let fileReader: FileReader = new FileReader();
fileReader.readAsDataURL(blob);
fileReader.onloadend = (e) => {
emit('cropend', {
imgBase64: e.target?.result ?? '',
imgInfo,
});
};
fileReader.onerror = () => {
emit('cropendError');
};
}, 'image/png');
}
// Get a circular picture canvas
function getRoundedCanvas() {
const sourceCanvas = cropper.value!.getCroppedCanvas();
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d')!;
const width = sourceCanvas.width;
const height = sourceCanvas.height;
canvas.width = width;
canvas.height = height;
context.imageSmoothingEnabled = true;
context.drawImage(sourceCanvas, 0, 0, width, height);
context.globalCompositeOperation = 'destination-in';
context.beginPath();
context.arc(width / 2, height / 2, Math.min(width, height) / 2, 0, 2 * Math.PI, true);
context.fill();
return canvas;
}
return { getClass, imgElRef, getWrapperStyle, getImageStyle, isReady, croppered };
},
});
</script>
<style lang="less">
@prefix-cls: ~'@{namespace}-cropper-image';
.@{prefix-cls} {
&--circled {
.cropper-view-box,
.cropper-face {
border-radius: 50%;
}
}
}
</style>

View File

@ -0,0 +1,136 @@
<template>
<div :class="getClass" :style="getStyle">
<div :class="`${prefixCls}-image-wrapper`" :style="getImageWrapperStyle" @click="openModal">
<div :class="`${prefixCls}-image-mask`" :style="getImageWrapperStyle">
<Icon icon="ant-design:cloud-upload-outlined" :size="getIconWidth" :style="getImageWrapperStyle" color="#d6d6d6" />
</div>
<img :src="sourceValue" v-if="sourceValue" alt="avatar" />
</div>
<a-button :class="`${prefixCls}-upload-btn`" @click="openModal" v-if="showBtn" v-bind="btnProps">
{{ btnText ? btnText : t('component.cropper.selectImage') }}
</a-button>
<CopperModal @register="register" @uploadSuccess="handleUploadSuccess" :uploadApi="uploadApi" :src="sourceValue" />
</div>
</template>
<script lang="ts">
import { defineComponent, computed, CSSProperties, unref, ref, watchEffect, watch, PropType } from 'vue';
import CopperModal from './CopperModal.vue';
import { useDesign } from '/@/hooks/web/useDesign';
import { useModal } from '/@/components/Modal';
import { useMessage } from '/@/hooks/web/useMessage';
import { useI18n } from '/@/hooks/web/useI18n';
import type { ButtonProps } from '/@/components/Button';
import Icon from '/@/components/Icon';
const props = {
width: { type: [String, Number], default: '200px' },
value: { type: String },
showBtn: { type: Boolean, default: true },
btnProps: { type: Object as PropType<ButtonProps> },
btnText: { type: String, default: '' },
uploadApi: { type: Function as PropType<({ file: Blob, name: string }) => Promise<void>> },
};
export default defineComponent({
name: 'CropperAvatar',
components: { CopperModal, Icon },
props,
emits: ['update:value', 'change'],
setup(props, { emit, expose }) {
const sourceValue = ref(props.value || '');
const { prefixCls } = useDesign('cropper-avatar');
const [register, { openModal, closeModal }] = useModal();
const { createMessage } = useMessage();
const { t } = useI18n();
const getClass = computed(() => [prefixCls]);
const getWidth = computed(() => `${props.width}`.replace(/px/, '') + 'px');
const getIconWidth = computed(() => parseInt(`${props.width}`.replace(/px/, '')) / 2 + 'px');
const getStyle = computed((): CSSProperties => ({ width: unref(getWidth) }));
const getImageWrapperStyle = computed((): CSSProperties => ({ width: unref(getWidth), height: unref(getWidth) }));
watchEffect(() => {
sourceValue.value = props.value || '';
});
watch(
() => sourceValue.value,
(v: string) => {
emit('update:value', v);
}
);
function handleUploadSuccess({ source, data }) {
sourceValue.value = source;
emit('change', source, data);
createMessage.success(t('component.cropper.uploadSuccess'));
}
expose({ openModal: openModal.bind(null, true), closeModal });
return {
t,
prefixCls,
register,
openModal: openModal as any,
getIconWidth,
sourceValue,
getClass,
getImageWrapperStyle,
getStyle,
handleUploadSuccess,
};
},
});
</script>
<style lang="less" scoped>
@prefix-cls: ~'@{namespace}-cropper-avatar';
.@{prefix-cls} {
display: inline-block;
text-align: center;
&-image-wrapper {
overflow: hidden;
cursor: pointer;
background: @component-background;
border: 1px solid @border-color-base;
border-radius: 50%;
img {
width: 100%;
}
}
&-image-mask {
opacity: 0;
position: absolute;
width: inherit;
height: inherit;
border-radius: inherit;
border: inherit;
background: rgba(0, 0, 0, 0.4);
cursor: pointer;
-webkit-transition: opacity 0.4s;
transition: opacity 0.4s;
:deep(svg) {
margin: auto;
}
}
&-image-mask:hover {
opacity: 40;
}
&-upload-btn {
margin: 10px auto;
}
}
</style>

View File

@ -0,0 +1,8 @@
import type Cropper from 'cropperjs';
export interface CropendResult {
imgBase64: string;
imgInfo: Cropper.Data;
}
export type { Cropper };

View File

@ -0,0 +1,6 @@
import { withInstall } from '/@/utils';
import description from './src/Description.vue';
export * from './src/typing';
export { useDescription } from './src/useDescription';
export const Description = withInstall(description);

View File

@ -0,0 +1,181 @@
<script lang="tsx">
import type { DescriptionProps, DescInstance, DescItem } from './typing';
import type { DescriptionsProps } from 'ant-design-vue/es/descriptions/index';
import type { CSSProperties } from 'vue';
import type { CollapseContainerOptions } from '/@/components/Container/index';
import { defineComponent, computed, ref, unref } from 'vue';
import { get } from 'lodash-es';
import { Descriptions } from 'ant-design-vue';
import { CollapseContainer } from '/@/components/Container/index';
import { useDesign } from '/@/hooks/web/useDesign';
import { isFunction } from '/@/utils/is';
import { getSlot } from '/@/utils/helper/tsxHelper';
import { useAttrs } from '/@/hooks/core/useAttrs';
const props = {
useCollapse: { type: Boolean, default: true },
title: { type: String, default: '' },
size: {
type: String,
validator: (v) => ['small', 'default', 'middle', undefined].includes(v),
default: 'small',
},
bordered: { type: Boolean, default: true },
column: {
type: [Number, Object] as PropType<number | Recordable>,
default: () => {
return { xxl: 4, xl: 3, lg: 3, md: 3, sm: 2, xs: 1 };
},
},
collapseOptions: {
type: Object as PropType<CollapseContainerOptions>,
default: null,
},
schema: {
type: Array as PropType<DescItem[]>,
default: () => [],
},
data: { type: Object },
};
export default defineComponent({
name: 'Description',
props,
emits: ['register'],
setup(props, { slots, emit }) {
const propsRef = ref<Partial<DescriptionProps> | null>(null);
const { prefixCls } = useDesign('description');
const attrs = useAttrs();
// Custom title component: get title
const getMergeProps = computed(() => {
return {
...props,
...(unref(propsRef) as Recordable),
} as DescriptionProps;
});
const getProps = computed(() => {
const opt = {
...unref(getMergeProps),
title: undefined,
};
return opt as DescriptionProps;
});
/**
* @description: Whether to setting title
*/
const useWrapper = computed(() => !!unref(getMergeProps).title);
/**
* @description: Get configuration Collapse
*/
const getCollapseOptions = computed((): CollapseContainerOptions => {
return {
// Cannot be expanded by default
canExpand: false,
...unref(getProps).collapseOptions,
};
});
const getDescriptionsProps = computed(() => {
return { ...unref(attrs), ...unref(getProps) } as DescriptionsProps;
});
/**
* @description:设置desc
*/
function setDescProps(descProps: Partial<DescriptionProps>): void {
// Keep the last setDrawerProps
propsRef.value = { ...(unref(propsRef) as Recordable), ...descProps } as Recordable;
}
// Prevent line breaks
function renderLabel({ label, labelMinWidth, labelStyle }: DescItem) {
if (!labelStyle && !labelMinWidth) {
return label;
}
const labelStyles: CSSProperties = {
...labelStyle,
minWidth: `${labelMinWidth}px `,
};
return <div style={labelStyles}>{label}</div>;
}
function renderItem() {
const { schema, data } = unref(getProps);
return unref(schema)
.map((item) => {
const { render, field, span, show, contentMinWidth } = item;
if (show && isFunction(show) && !show(data)) {
return null;
}
const getContent = () => {
const _data = unref(getProps)?.data;
if (!_data) {
return null;
}
const getField = get(_data, field);
return isFunction(render) ? render(getField, _data) : getField ?? '';
};
const width = contentMinWidth;
return (
<Descriptions.Item label={renderLabel(item)} key={field} span={span}>
{() => {
if (!contentMinWidth) {
return getContent();
}
const style: CSSProperties = {
minWidth: `${width}px`,
};
return <div style={style}>{getContent()}</div>;
}}
</Descriptions.Item>
);
})
.filter((item) => !!item);
}
const renderDesc = () => {
return (
<Descriptions class={`${prefixCls}`} {...(unref(getDescriptionsProps) as any)}>
{renderItem()}
</Descriptions>
);
};
const renderContainer = () => {
const content = props.useCollapse ? renderDesc() : <div>{renderDesc()}</div>;
// Reduce the dom level
if (!props.useCollapse) {
return content;
}
const { canExpand, helpMessage } = unref(getCollapseOptions);
const { title } = unref(getMergeProps);
return (
<CollapseContainer title={title} canExpan={canExpand} helpMessage={helpMessage}>
{{
default: () => content,
action: () => getSlot(slots, 'action'),
}}
</CollapseContainer>
);
};
const methods: DescInstance = {
setDescProps,
};
emit('register', methods);
return () => (unref(useWrapper) ? renderContainer() : renderDesc());
},
});
</script>

View File

@ -0,0 +1,47 @@
import type { VNode, CSSProperties } from 'vue';
import type { CollapseContainerOptions } from '/@/components/Container/index';
import type { DescriptionsProps } from 'ant-design-vue/es/descriptions/index';
export interface DescItem {
labelMinWidth?: number;
contentMinWidth?: number;
labelStyle?: CSSProperties;
field: string;
label: string | VNode | JSX.Element;
// Merge column
span?: number;
show?: (...arg: any) => boolean;
// render
render?: (val: any, data: Recordable) => VNode | undefined | JSX.Element | Element | string | number;
}
export interface DescriptionProps extends DescriptionsProps {
// Whether to include the collapse component
useCollapse?: boolean;
/**
* item configuration
* @type DescItem
*/
schema: DescItem[];
/**
* 数据
* @type object
*/
data: Recordable;
/**
* Built-in CollapseContainer component configuration
* @type CollapseContainerOptions
*/
collapseOptions?: CollapseContainerOptions;
}
export interface DescInstance {
setDescProps(descProps: Partial<DescriptionProps>): void;
}
export type Register = (descInstance: DescInstance) => void;
/**
* @description:
*/
export type UseDescReturnType = [Register, DescInstance];

View File

@ -0,0 +1,28 @@
import type { DescriptionProps, DescInstance, UseDescReturnType } from './typing';
import { ref, getCurrentInstance, unref } from 'vue';
import { isProdMode } from '/@/utils/env';
export function useDescription(props?: Partial<DescriptionProps>): UseDescReturnType {
if (!getCurrentInstance()) {
throw new Error('useDescription() can only be used inside setup() or functional components!');
}
const desc = ref<Nullable<DescInstance>>(null);
const loaded = ref(false);
function register(instance: DescInstance) {
if (unref(loaded) && isProdMode()) {
return;
}
desc.value = instance;
props && instance.setDescProps(props);
loaded.value = true;
}
const methods: DescInstance = {
setDescProps: (descProps: Partial<DescriptionProps>): void => {
unref(desc)?.setDescProps(descProps);
},
};
return [register, methods];
}

View File

@ -0,0 +1,6 @@
import { withInstall } from '/@/utils';
import basicDrawer from './src/BasicDrawer.vue';
export const BasicDrawer = withInstall(basicDrawer);
export * from './src/typing';
export { useDrawer, useDrawerInner } from './src/useDrawer';

View File

@ -0,0 +1,255 @@
<template>
<Drawer :class="prefixCls" @close="onClose" v-bind="getBindValues">
<template #title v-if="!$slots.title">
<DrawerHeader :title="getMergeProps.title" :isDetail="isDetail" :showDetailBack="showDetailBack" @close="onClose">
<template #titleToolbar>
<slot name="titleToolbar"></slot>
</template>
</DrawerHeader>
</template>
<template v-else #title>
<slot name="title"></slot>
</template>
<ScrollContainer :style="getScrollContentStyle" v-loading="getLoading" :loading-tip="loadingText || t('common.loadingText')">
<slot></slot>
</ScrollContainer>
<DrawerFooter v-bind="getProps" @close="onClose" @ok="handleOk" :height="getFooterHeight">
<template #[item]="data" v-for="item in Object.keys($slots)">
<slot :name="item" v-bind="data || {}"></slot>
</template>
</DrawerFooter>
</Drawer>
</template>
<script lang="ts">
import type { DrawerInstance, DrawerProps } from './typing';
import type { CSSProperties } from 'vue';
import { defineComponent, ref, computed, watch, unref, nextTick, toRaw, getCurrentInstance } from 'vue';
import { Drawer } from 'ant-design-vue';
import { useI18n } from '/@/hooks/web/useI18n';
import { isFunction, isNumber } from '/@/utils/is';
import { deepMerge } from '/@/utils';
import DrawerFooter from './components/DrawerFooter.vue';
import DrawerHeader from './components/DrawerHeader.vue';
import { ScrollContainer } from '/@/components/Container';
import { basicProps } from './props';
import { useDesign } from '/@/hooks/web/useDesign';
import { useAttrs } from '/@/hooks/core/useAttrs';
import { cloneDeep } from 'lodash-es';
export default defineComponent({
components: { Drawer, ScrollContainer, DrawerFooter, DrawerHeader },
inheritAttrs: false,
props: basicProps,
emits: ['visible-change', 'open-change', 'ok', 'close', 'register'],
setup(props, { emit }) {
const visibleRef = ref(false);
const attrs = useAttrs();
const propsRef = ref<Partial<Nullable<DrawerProps>>>(null);
const { t } = useI18n();
const { prefixVar, prefixCls } = useDesign('basic-drawer');
const drawerInstance: DrawerInstance = {
setDrawerProps: setDrawerProps,
emitVisible: undefined,
};
const instance = getCurrentInstance();
instance && emit('register', drawerInstance, instance.uid);
const getMergeProps = computed((): DrawerProps => {
// update-begin--author:liaozhiyang---date:20240320---for【QQYUN-8389】vue3.4以上版本导致角色抽屉隐藏footer逻辑错误toRaw改成cloneDeep否则props的变化不会触发computed
return { ...deepMerge(cloneDeep(props), unref(propsRef)) };
// update-end--author:liaozhiyang---date:20240320---for【QQYUN-8389】vue3.4以上版本导致角色抽屉隐藏footer逻辑错误toRaw改成cloneDeep否则props的变化不会触发computed
});
const getProps = computed((): DrawerProps => {
// update-begin--author:liaozhiyang---date:20231218---for【QQYUN-6366】升级到antd4.x
const opt = {
placement: 'right',
...unref(attrs),
...unref(getMergeProps),
open: unref(visibleRef),
};
// update-end--author:liaozhiyang---date:20231218---for【QQYUN-6366】升级到antd4.x
opt.title = undefined;
let { isDetail, width, wrapClassName, getContainer } = opt;
if (isDetail) {
if (!width) {
opt.width = '100%';
}
const detailCls = `${prefixCls}__detail`;
wrapClassName = opt['class'] ? opt['class'] : wrapClassName;
opt.class = wrapClassName ? `${wrapClassName} ${detailCls}` : detailCls;
if (!getContainer) {
// TODO type error?
opt.getContainer = `.${prefixVar}-layout-content` as any;
}
}
console.log('getProps:opt',opt);
return opt as DrawerProps;
});
const getBindValues = computed((): DrawerProps => {
return {
...attrs,
...unref(getProps),
};
});
// Custom implementation of the bottom button,
const getFooterHeight = computed(() => {
const { footerHeight, showFooter } = unref(getProps);
if (showFooter && footerHeight) {
return isNumber(footerHeight) ? `${footerHeight}px` : `${footerHeight.replace('px', '')}px`;
}
return `0px`;
});
const getScrollContentStyle = computed((): CSSProperties => {
const footerHeight = unref(getFooterHeight);
return {
position: 'relative',
height: `calc(100% - ${footerHeight})`,
};
});
const getLoading = computed(() => {
return !!unref(getProps)?.loading;
});
watch(
() => props.visible,
(newVal, oldVal) => {
if (newVal !== oldVal) visibleRef.value = newVal;
},
{ deep: true }
);
watch(
() => props.open,
(newVal, oldVal) => {
if (newVal !== oldVal) visibleRef.value = newVal;
},
{ deep: true }
);
watch(
() => visibleRef.value,
(visible) => {
nextTick(() => {
emit('visible-change', visible);
emit('open-change', visible);
instance && drawerInstance.emitVisible?.(visible, instance.uid);
});
}
);
// Cancel event
async function onClose(e: Recordable) {
const { closeFunc } = unref(getProps);
emit('close', e);
if (closeFunc && isFunction(closeFunc)) {
const res = await closeFunc();
visibleRef.value = !res;
return;
}
visibleRef.value = false;
}
function setDrawerProps(props: Partial<DrawerProps>): void {
// Keep the last setDrawerProps
propsRef.value = deepMerge(unref(propsRef) || ({} as any), props);
if (Reflect.has(props, 'visible')) {
visibleRef.value = !!props.visible;
}
if (Reflect.has(props, 'open')) {
visibleRef.value = !!props.open;
}
}
function handleOk() {
emit('ok');
}
return {
onClose,
t,
prefixCls,
getMergeProps: getMergeProps as any,
getScrollContentStyle,
getProps: getProps as any,
getLoading,
getBindValues,
getFooterHeight,
handleOk,
};
},
});
</script>
<style lang="less">
@header-height: 60px;
@detail-header-height: 40px;
@prefix-cls: ~'@{namespace}-basic-drawer';
@prefix-cls-detail: ~'@{namespace}-basic-drawer__detail';
.@{prefix-cls} {
.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: @component-background;
.scrollbar__wrap {
padding: 16px !important;
margin-bottom: 0 !important;
}
> .scrollbar > .scrollbar__bar.is-horizontal {
display: none;
}
}
}
.@{prefix-cls-detail} {
position: absolute;
.ant-drawer-header {
width: 100%;
height: @detail-header-height;
padding: 0;
border-top: 1px solid @border-color-base;
box-sizing: border-box;
}
.ant-drawer-title {
height: 100%;
}
.ant-drawer-close {
height: @detail-header-height;
line-height: @detail-header-height;
}
.scrollbar__wrap {
padding: 0 !important;
}
.ant-drawer-body {
height: calc(100% - @detail-header-height);
}
}
</style>

View File

@ -0,0 +1,75 @@
<template>
<div :class="prefixCls" :style="getStyle" v-if="showFooter || $slots.footer">
<template v-if="!$slots.footer">
<slot name="insertFooter"></slot>
<a-button v-bind="cancelButtonProps" @click="handleClose" class="mr-2" v-if="showCancelBtn">
{{ cancelText }}
</a-button>
<slot name="centerFooter"></slot>
<a-button :type="okType" @click="handleOk" v-bind="okButtonProps" class="mr-2" :loading="confirmLoading" v-if="showOkBtn">
{{ okText }}
</a-button>
<slot name="appendFooter"></slot>
</template>
<template v-else>
<slot name="footer"></slot>
</template>
</div>
</template>
<script lang="ts">
import type { CSSProperties } from 'vue';
import { defineComponent, computed } from 'vue';
import { useDesign } from '/@/hooks/web/useDesign';
import { footerProps } from '../props';
export default defineComponent({
name: 'BasicDrawerFooter',
props: {
...footerProps,
height: {
type: String,
default: '60px',
},
},
emits: ['ok', 'close'],
setup(props, { emit }) {
const { prefixCls } = useDesign('basic-drawer-footer');
const getStyle = computed((): CSSProperties => {
const heightStr = `${props.height}`;
return {
height: heightStr,
lineHeight: heightStr,
};
});
function handleOk() {
emit('ok');
}
function handleClose() {
emit('close');
}
return { handleOk, prefixCls, handleClose, getStyle };
},
});
</script>
<style lang="less">
@prefix-cls: ~'@{namespace}-basic-drawer-footer';
@footer-height: 60px;
.@{prefix-cls} {
position: absolute;
bottom: 0;
width: 100%;
padding: 0 12px 0 20px;
text-align: right;
background-color: @component-background;
border-top: 1px solid @border-color-base;
> * {
margin-right: 8px;
}
}
</style>

View File

@ -0,0 +1,74 @@
<template>
<BasicTitle v-if="!isDetail" :class="[prefixCls, 'is-drawer']">
<slot name="title"></slot>
{{ !$slots.title ? title : '' }}
</BasicTitle>
<div :class="[prefixCls, `${prefixCls}--detail`]" v-else>
<span :class="`${prefixCls}__twrap`">
<span @click="handleClose" v-if="showDetailBack">
<ArrowLeftOutlined :class="`${prefixCls}__back`" />
</span>
<span v-if="title">{{ title }}</span>
</span>
<span :class="`${prefixCls}__toolbar`">
<slot name="titleToolbar"></slot>
</span>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import { BasicTitle } from '/@/components/Basic';
import { ArrowLeftOutlined } from '@ant-design/icons-vue';
import { useDesign } from '/@/hooks/web/useDesign';
import { propTypes } from '/@/utils/propTypes';
export default defineComponent({
name: 'BasicDrawerHeader',
components: { BasicTitle, ArrowLeftOutlined },
props: {
isDetail: propTypes.bool,
showDetailBack: propTypes.bool,
title: propTypes.string,
},
emits: ['close'],
setup(_, { emit }) {
const { prefixCls } = useDesign('basic-drawer-header');
function handleClose() {
emit('close');
}
return { prefixCls, handleClose };
},
});
</script>
<style lang="less">
@prefix-cls: ~'@{namespace}-basic-drawer-header';
@footer-height: 60px;
.@{prefix-cls} {
display: flex;
height: 100%;
align-items: center;
&__back {
padding: 0 12px;
cursor: pointer;
&:hover {
color: @primary-color;
}
}
&__twrap {
flex: 1;
}
&__toolbar {
padding-right: 50px;
}
}
</style>

View File

@ -0,0 +1,46 @@
import type { PropType } from 'vue';
import { useI18n } from '/@/hooks/web/useI18n';
const { t } = useI18n();
export const footerProps = {
confirmLoading: { type: Boolean },
/**
* @description: Show close button
*/
showCancelBtn: { type: Boolean, default: true },
cancelButtonProps: Object as PropType<Recordable>,
cancelText: { type: String, default: t('common.cancelText') },
/**
* @description: Show confirmation button
*/
showOkBtn: { type: Boolean, default: true },
okButtonProps: Object as PropType<Recordable>,
okText: { type: String, default: t('common.okText') },
okType: { type: String, default: 'primary' },
showFooter: { type: Boolean },
footerHeight: {
type: [String, Number] as PropType<string | number>,
default: 60,
},
};
export const basicProps = {
class: {type: [String, Object, Array]},
isDetail: { type: Boolean },
title: { type: String, default: '' },
loadingText: { type: String },
showDetailBack: { type: Boolean, default: true },
visible: { type: Boolean },
open: { type: Boolean },
loading: { type: Boolean },
maskClosable: { type: Boolean, default: true },
getContainer: {
type: [Object, String] as PropType<any>,
},
closeFunc: {
type: [Function, Object] as PropType<any>,
default: null,
},
destroyOnClose: { type: Boolean },
...footerProps,
};

View File

@ -0,0 +1,199 @@
import type { ButtonProps } from 'ant-design-vue/lib/button/buttonTypes';
import type { CSSProperties, VNodeChild, ComputedRef } from 'vue';
import type { ScrollContainerOptions } from '/@/components/Container/index';
export interface DrawerInstance {
setDrawerProps: (props: Partial<DrawerProps> | boolean) => void;
emitVisible?: (visible: boolean, uid: number) => void;
}
export interface ReturnMethods extends DrawerInstance {
openDrawer: <T = any>(visible?: boolean, data?: T, openOnSet?: boolean) => void;
closeDrawer: () => void;
getVisible?: ComputedRef<boolean>;
getOpen?: ComputedRef<boolean>;
}
export type RegisterFn = (drawerInstance: DrawerInstance, uuid?: string) => void;
export interface ReturnInnerMethods extends DrawerInstance {
closeDrawer: () => void;
changeLoading: (loading: boolean) => void;
changeOkLoading: (loading: boolean) => void;
getVisible?: ComputedRef<boolean>;
getOpen?: ComputedRef<boolean>;
}
export type UseDrawerReturnType = [RegisterFn, ReturnMethods];
export type UseDrawerInnerReturnType = [RegisterFn, ReturnInnerMethods];
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: ButtonProps; on: {} };
/**
* The cancel button props, follow jsx rules
* @type object
*/
cancelButtonProps: { props: ButtonProps; 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 {
isDetail?: boolean;
loading?: boolean;
showDetailBack?: boolean;
visible?: boolean;
open?: boolean;
/**
* Built-in ScrollContainer component configuration
* @type ScrollContainerOptions
*/
scrollOptions?: ScrollContainerOptions;
closeFunc?: () => Promise<any>;
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
*/
class?: string;
// 兼容老版本的写法后续可能会删除优先写class
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,156 @@
import type { UseDrawerReturnType, DrawerInstance, ReturnMethods, DrawerProps, UseDrawerInnerReturnType } from './typing';
import { ref, getCurrentInstance, unref, reactive, watchEffect, nextTick, toRaw, computed } from 'vue';
import { isProdMode } from '/@/utils/env';
import { isFunction } from '/@/utils/is';
import { tryOnUnmounted } from '@vueuse/core';
import { isEqual } from 'lodash-es';
import { error } from '/@/utils/log';
const dataTransferRef = reactive<any>({});
const visibleData = reactive<{ [key: number]: boolean }>({});
/**
* @description: Applicable to separate drawer and call outside
*/
export function useDrawer(): UseDrawerReturnType {
if (!getCurrentInstance()) {
throw new Error('useDrawer() can only be used inside setup() or functional components!');
}
const drawer = ref<DrawerInstance | null>(null);
const loaded = ref<Nullable<boolean>>(false);
const uid = ref<string>('');
function register(drawerInstance: DrawerInstance, uuid: string) {
isProdMode() &&
tryOnUnmounted(() => {
drawer.value = null;
loaded.value = null;
dataTransferRef[unref(uid)] = null;
});
if (unref(loaded) && isProdMode() && drawerInstance === unref(drawer)) {
return;
}
uid.value = uuid;
drawer.value = drawerInstance;
loaded.value = true;
drawerInstance.emitVisible = (visible: boolean, uid: number) => {
visibleData[uid] = visible;
};
}
const getInstance = () => {
const instance = unref(drawer);
if (!instance) {
error('useDrawer instance is undefined!');
}
return instance;
};
const methods: ReturnMethods = {
setDrawerProps: (props: Partial<DrawerProps>): void => {
getInstance()?.setDrawerProps(props);
},
getVisible: computed((): boolean => {
return visibleData[~~unref(uid)];
}),
getOpen: computed((): boolean => {
return visibleData[~~unref(uid)];
}),
openDrawer: <T = any>(visible = true, data?: T, openOnSet = true): void => {
// update-begin--author:liaozhiyang---date:20231218---for【QQYUN-6366】升级到antd4.x
getInstance()?.setDrawerProps({
open: visible,
});
// update-end--author:liaozhiyang---date:20231218---for【QQYUN-6366】升级到antd4.x
if (!data) return;
if (openOnSet) {
dataTransferRef[unref(uid)] = null;
dataTransferRef[unref(uid)] = toRaw(data);
return;
}
const equal = isEqual(toRaw(dataTransferRef[unref(uid)]), toRaw(data));
if (!equal) {
dataTransferRef[unref(uid)] = toRaw(data);
}
},
closeDrawer: () => {
// update-begin--author:liaozhiyang---date:20231218---for【QQYUN-6366】升级到antd4.x
getInstance()?.setDrawerProps({ open: false });
// update-end--author:liaozhiyang---date:20231218---for【QQYUN-6366】升级到antd4.x
},
};
return [register, methods];
}
export const useDrawerInner = (callbackFn?: Fn): UseDrawerInnerReturnType => {
const drawerInstanceRef = ref<Nullable<DrawerInstance>>(null);
const currentInstance = getCurrentInstance();
const uidRef = ref<string>('');
if (!getCurrentInstance()) {
throw new Error('useDrawerInner() can only be used inside setup() or functional components!');
}
const getInstance = () => {
const instance = unref(drawerInstanceRef);
if (!instance) {
error('useDrawerInner instance is undefined!');
return;
}
return instance;
};
const register = (modalInstance: DrawerInstance, uuid: string) => {
isProdMode() &&
tryOnUnmounted(() => {
drawerInstanceRef.value = null;
});
uidRef.value = uuid;
drawerInstanceRef.value = modalInstance;
currentInstance?.emit('register', modalInstance, uuid);
};
watchEffect(() => {
const data = dataTransferRef[unref(uidRef)];
if (!data) return;
if (!callbackFn || !isFunction(callbackFn)) return;
nextTick(() => {
callbackFn(data);
});
});
return [
register,
{
changeLoading: (loading = true) => {
getInstance()?.setDrawerProps({ loading });
},
changeOkLoading: (loading = true) => {
getInstance()?.setDrawerProps({ confirmLoading: loading });
},
getVisible: computed((): boolean => {
return visibleData[~~unref(uidRef)];
}),
getOpen: computed((): boolean => {
return visibleData[~~unref(uidRef)];
}),
closeDrawer: () => {
getInstance()?.setDrawerProps({ open: false });
},
setDrawerProps: (props: Partial<DrawerProps>) => {
getInstance()?.setDrawerProps(props);
},
},
];
};

View File

@ -0,0 +1,5 @@
import { withInstall } from '/@/utils';
import dropdown from './src/Dropdown.vue';
export * from './src/typing';
export const Dropdown = withInstall(dropdown);

View File

@ -0,0 +1,119 @@
<template>
<a-dropdown :class="[prefixCls]" :trigger="trigger" v-bind="$attrs">
<span>
<slot></slot>
</span>
<template #overlay>
<a-menu :class="[`${prefixCls}-menu`]" :selectedKeys="selectedKeys">
<template v-for="item in dropMenuList" :key="`${item.event}`">
<a-menu-item
v-bind="getAttr(item.event)"
@click="handleClickMenu(item)"
:disabled="item.disabled"
:class="[{ 'is-pop-confirm': item.popConfirm }, item.class ?? []]"
>
<!-- update-begin--author:liaozhiyang---date:20231110---for【issues/839】BasicTable表格的更多操作按钮禁用还能点击弹出气泡框 -->
<a-popconfirm :disabled="item.disabled" v-if="popconfirm && item.popConfirm" v-bind="getPopConfirmAttrs(item.popConfirm)">
<!-- update-end--author:liaozhiyang---date:20231110---for【issues/839】BasicTable表格的更多操作按钮禁用还能点击弹出气泡框 -->
<template #icon v-if="item.popConfirm.icon">
<Icon v-if="item.iconColor" :icon="item.popConfirm.icon" :color="item.iconColor" />
<Icon v-else :icon="item.popConfirm.icon" />
</template>
<div class="dropdown-event-area">
<Icon :icon="item.icon" v-if="item.icon && item.iconColor" :color="item.iconColor" />
<Icon :icon="item.icon" v-else-if="item.icon" />
<span class="ml-1">{{ item.text }}</span>
</div>
</a-popconfirm>
<!-- 设置动态插槽 -->
<template v-else-if="item.slot">
<slot :name="item.slot" :label="item.text"></slot>
</template>
<template v-else>
<Icon :icon="item.icon" v-if="item.icon && item.iconColor" :color="item.iconColor" />
<Icon :icon="item.icon" v-else-if="item.icon" />
<span class="ml-1">{{ item.text }}</span>
</template>
</a-menu-item>
<a-menu-divider v-if="item.divider" :key="`d-${item.event}`" />
</template>
</a-menu>
</template>
</a-dropdown>
</template>
<script lang="ts" setup>
import { computed, PropType, ref } from 'vue';
import type { DropMenu } from './typing';
import { Dropdown, Menu, Popconfirm } from 'ant-design-vue';
import { Icon } from '/@/components/Icon';
import { omit } from 'lodash-es';
import { isFunction } from '/@/utils/is';
import { useDesign } from '/@/hooks/web/useDesign';
const ADropdown = Dropdown;
const AMenu = Menu;
const AMenuItem = Menu.Item;
const AMenuDivider = Menu.Divider;
const APopconfirm = Popconfirm;
const { prefixCls } = useDesign('basic-dropdown');
const props = defineProps({
popconfirm: Boolean,
/**
* the trigger mode which executes the drop-down action
* @default ['hover']
* @type string[]
*/
trigger: {
type: [Array] as PropType<('contextmenu' | 'click' | 'hover')[]>,
default: () => {
return ['contextmenu'];
},
},
dropMenuList: {
type: Array as PropType<(DropMenu & Recordable)[]>,
default: () => [],
},
selectedKeys: {
type: Array as PropType<string[]>,
default: () => [],
},
});
const emit = defineEmits(['menuEvent']);
function handleClickMenu(item: DropMenu) {
const { event } = item;
const menu = props.dropMenuList.find((item) => `${item.event}` === `${event}`);
emit('menuEvent', menu);
item.onClick?.();
}
const getPopConfirmAttrs = computed(() => {
return (attrs) => {
const originAttrs = omit(attrs, ['confirm', 'cancel', 'icon']);
if (!attrs.onConfirm && attrs.confirm && isFunction(attrs.confirm)) originAttrs['onConfirm'] = attrs.confirm;
if (!attrs.onCancel && attrs.cancel && isFunction(attrs.cancel)) originAttrs['onCancel'] = attrs.cancel;
return originAttrs;
};
});
const getAttr = (key: string | number) => ({ key });
</script>
<style lang="less">
@prefix-cls: ~'@{namespace}-basic-dropdown';
.@{prefix-cls} {
// update-begin--author:sunjianlei---date:20220322---for: 【VUEN-180】更多下拉菜单只有点到字上才有效点到空白处什么都不会发生体验不好
&-menu .ant-dropdown-menu-item.is-pop-confirm {
padding: 0;
.dropdown-event-area {
padding: 5px 12px;
}
}
// update-end--author:sunjianlei---date:20220322---for: 【VUEN-180】更多下拉菜单只有点到字上才有效点到空白处什么都不会发生体验不好
}
</style>

View File

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

View File

@ -0,0 +1,35 @@
import 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';
export { default as ApiSelect } from './src/components/ApiSelect.vue';
export { default as RadioButtonGroup } from './src/components/RadioButtonGroup.vue';
export { default as ApiTreeSelect } from './src/components/ApiTreeSelect.vue';
export { default as ApiRadioGroup } from './src/components/ApiRadioGroup.vue';
//Jeecg自定义组件
export { default as JAreaLinkage } from './src/jeecg/components/JAreaLinkage.vue';
export { default as JSelectUser } from './src/jeecg/components/JSelectUser.vue';
export { default as JSelectDept } from './src/jeecg/components/JSelectDept.vue';
export { default as JCodeEditor } from './src/jeecg/components/JCodeEditor.vue';
export { default as JCategorySelect } from './src/jeecg/components/JCategorySelect.vue';
export { default as JSelectMultiple } from './src/jeecg/components/JSelectMultiple.vue';
export { default as JPopup } from './src/jeecg/components/JPopup.vue';
export { default as JAreaSelect } from './src/jeecg/components/JAreaSelect.vue';
export { JEasyCron, JEasyCronInner, JEasyCronModal } from '/@/components/Form/src/jeecg/components/JEasyCron';
export { default as JCheckbox } from './src/jeecg/components/JCheckbox.vue';
export { default as JInput } from './src/jeecg/components/JInput.vue';
export { default as JEllipsis } from './src/jeecg/components/JEllipsis.vue';
export { default as JDictSelectTag } from './src/jeecg/components/JDictSelectTag.vue';
export { default as JTreeSelect } from './src/jeecg/components/JTreeSelect.vue';
export { default as JSearchSelect } from './src/jeecg/components/JSearchSelect.vue';
export { default as JSelectUserByDept } from './src/jeecg/components/JSelectUserByDept.vue';
export { default as JEditor } from './src/jeecg/components/JEditor.vue';
export { default as JImageUpload } from './src/jeecg/components/JImageUpload.vue';
// Jeecg自定义校验
export { JCronValidator } from '/@/components/Form/src/jeecg/components/JEasyCron';
export { BasicForm };

View File

@ -0,0 +1,420 @@
<template>
<Form v-bind="getBindValue" :class="getFormClass" ref="formElRef" :model="formModel" @keypress.enter="handleEnterPress">
<Row v-bind="getRow">
<slot name="formHeader"></slot>
<template v-for="schema in getSchema" :key="schema.field">
<FormItem
:tableAction="tableAction"
:formActionType="formActionType"
:schema="schema"
:formProps="getProps"
:allDefaultValues="defaultValueRef"
:formModel="formModel"
:setFormModel="setFormModel"
:validateFields="validateFields"
:clearValidate="clearValidate"
v-auth="schema.auth"
>
<template #[item]="data" v-for="item in Object.keys($slots)">
<slot :name="item" v-bind="data || {}"></slot>
</template>
</FormItem>
</template>
<FormAction v-bind="getFormActionBindProps" @toggle-advanced="handleToggleAdvanced">
<template #[item]="data" v-for="item in ['resetBefore', 'submitBefore', 'advanceBefore', 'advanceAfter']">
<slot :name="item" v-bind="data || {}"></slot>
</template>
</FormAction>
<slot name="formFooter"></slot>
</Row>
</Form>
</template>
<script lang="ts">
import type { FormActionType, FormProps, FormSchema } from './types/form';
import type { AdvanceState } from './types/hooks';
import type { Ref } from 'vue';
import { defineComponent, reactive, ref, computed, unref, onMounted, watch, nextTick } from 'vue';
import { Form, Row } from 'ant-design-vue';
import FormItem from './components/FormItem.vue';
import FormAction from './components/FormAction.vue';
import { dateItemType } from './helper';
import { dateUtil } from '/@/utils/dateUtil';
// import { cloneDeep } from 'lodash-es';
import { deepMerge } from '/@/utils';
import { useFormValues } from './hooks/useFormValues';
import useAdvanced from './hooks/useAdvanced';
import { useFormEvents } from './hooks/useFormEvents';
import { createFormContext } from './hooks/useFormContext';
import { useAutoFocus } from './hooks/useAutoFocus';
import { useModalContext } from '/@/components/Modal';
import { basicProps } from './props';
import componentSetting from '/@/settings/componentSetting';
import { useDesign } from '/@/hooks/web/useDesign';
import dayjs from 'dayjs';
import { useDebounceFn } from '@vueuse/core';
export default defineComponent({
name: 'BasicForm',
components: { FormItem, Form, Row, FormAction },
props: basicProps,
emits: ['advanced-change', 'reset', 'submit', 'register'],
setup(props, { emit, attrs }) {
const formModel = reactive<Recordable>({});
const modalFn = useModalContext();
const advanceState = reactive<AdvanceState>({
// 默认是收起状态
isAdvanced: false,
hideAdvanceBtn: true,
isLoad: false,
actionSpan: 6,
});
const defaultValueRef = ref<Recordable>({});
const isInitedDefaultRef = ref(false);
const propsRef = ref<Partial<FormProps>>({});
const schemaRef = ref<Nullable<FormSchema[]>>(null);
const formElRef = ref<Nullable<FormActionType>>(null);
const { prefixCls } = useDesign('basic-form');
// Get the basic configuration of the form
const getProps = computed((): FormProps => {
let mergeProps = { ...props, ...unref(propsRef) } as FormProps;
//update-begin-author:sunjianlei date:20220923 for: 如果用户设置了labelWidth则使labelCol失效解决labelWidth设置无效的问题
if (mergeProps.labelWidth) {
mergeProps.labelCol = undefined;
}
//update-end-author:sunjianlei date:20220923 for: 如果用户设置了labelWidth则使labelCol失效解决labelWidth设置无效的问题
// update-begin--author:liaozhiyang---date:20231017---for【QQYUN-6566】BasicForm支持一行显示(inline)
if (mergeProps.layout === 'inline') {
if (mergeProps.labelCol === componentSetting.form.labelCol) {
mergeProps.labelCol = undefined;
}
if (mergeProps.wrapperCol === componentSetting.form.wrapperCol) {
mergeProps.wrapperCol = undefined;
}
}
// update-end--author:liaozhiyang---date:20231017---for【QQYUN-6566】BasicForm支持一行显示(inline)
return mergeProps;
});
const getFormClass = computed(() => {
return [
prefixCls,
{
[`${prefixCls}--compact`]: unref(getProps).compact,
'jeecg-form-detail-effect': unref(getProps).disabled
},
];
});
// Get uniform row style and Row configuration for the entire form
const getRow = computed((): Recordable => {
const { baseRowStyle = {}, rowProps } = unref(getProps);
return {
style: baseRowStyle,
...rowProps,
};
});
const getBindValue = computed(() => ({ ...attrs, ...props, ...unref(getProps) } as Recordable));
const getSchema = computed((): FormSchema[] => {
const schemas: FormSchema[] = unref(schemaRef) || (unref(getProps).schemas as any);
for (const schema of schemas) {
const { defaultValue, component, componentProps } = schema;
// handle date type
if (defaultValue && dateItemType.includes(component)) {
//update-begin---author:wangshuai ---date:20230410 for【issues/435】代码生成的日期控件赋默认值报错------------
let valueFormat:string = "";
if(componentProps){
valueFormat = componentProps?.valueFormat;
}
if(!valueFormat){
console.warn("未配置valueFormat,可能导致格式化错误");
}
//update-end---author:wangshuai ---date:20230410 for【issues/435】代码生成的日期控件赋默认值报错------------
if (!Array.isArray(defaultValue)) {
//update-begin---author:wangshuai ---date:20221124 for[issues/215]列表页查询框(日期选择框)设置初始时间,一进入页面时,后台报日期转换类型错误的------------
if(valueFormat){
// schema.defaultValue = dateUtil(defaultValue).format(valueFormat);
// update-begin--author:liaozhiyang---date:20240529---for【TV360X-346 】时间组件填写默认值有问题
schema.defaultValue = dateUtil(defaultValue, valueFormat).format(valueFormat);
// update-end--author:liaozhiyang---date:20240529---for【TV360X-346 】时间组件填写默认值有问题
}else{
schema.defaultValue = dateUtil(defaultValue);
}
//update-end---author:wangshuai ---date:20221124 for[issues/215]列表页查询框(日期选择框)设置初始时间,一进入页面时,后台报日期转换类型错误的------------
} else {
const def: dayjs.Dayjs[] = [];
defaultValue.forEach((item) => {
//update-begin---author:wangshuai ---date:20221124 for[issues/215]列表页查询框(日期选择框)设置初始时间,一进入页面时,后台报日期转换类型错误的------------
if(valueFormat){
// update-begin--author:liaozhiyang---date:20240529---for【TV360X-346 】时间组件填写默认值有问题
def.push(dateUtil(item, valueFormat).format(valueFormat));
// update-end--author:liaozhiyang---date:20240529---for【TV360X-346 】时间组件填写默认值有问题
}else{
def.push(dateUtil(item));
}
//update-end---author:wangshuai ---date:20221124 for[issues/215]列表页查询框(日期选择框)设置初始时间,一进入页面时,后台报日期转换类型错误的------------
});
// update-begin--author:liaozhiyang---date:20240328---for【issues/1114】rangepicker等时间控件报错vue3.4以上版本有问题)
def.forEach((item, index) => {
defaultValue[index] = item;
});
// update-end--author:liaozhiyang---date:20240328---for【issues/1114】rangepicker等时间控件报错vue3.4以上版本有问题)
}
}
}
if (unref(getProps).showAdvancedButton) {
return schemas.filter((schema) => schema.component !== 'Divider') as FormSchema[];
} else {
return schemas as FormSchema[];
}
});
const { handleToggleAdvanced } = useAdvanced({
advanceState,
emit,
getProps,
getSchema,
formModel,
defaultValueRef,
});
const { handleFormValues, initDefault } = useFormValues({
getProps,
defaultValueRef,
getSchema,
formModel,
});
useAutoFocus({
getSchema,
getProps,
isInitedDefault: isInitedDefaultRef,
formElRef: formElRef as Ref<FormActionType>,
});
const {
handleSubmit,
setFieldsValue,
clearValidate,
validate,
validateFields,
getFieldsValue,
updateSchema,
resetSchema,
appendSchemaByField,
removeSchemaByFiled,
resetFields,
scrollToField,
} = useFormEvents({
emit,
getProps,
formModel,
getSchema,
defaultValueRef,
formElRef: formElRef as Ref<FormActionType>,
schemaRef: schemaRef as Ref<FormSchema[]>,
handleFormValues,
});
createFormContext({
resetAction: resetFields,
submitAction: handleSubmit,
});
watch(
() => unref(getProps).model,
() => {
const { model } = unref(getProps);
if (!model) return;
setFieldsValue(model);
},
{
immediate: true,
}
);
watch(
() => unref(getProps).schemas,
(schemas) => {
resetSchema(schemas ?? []);
}
);
watch(
() => getSchema.value,
(schema) => {
nextTick(() => {
// Solve the problem of modal adaptive height calculation when the form is placed in the modal
modalFn?.redoModalHeight?.();
});
if (unref(isInitedDefaultRef)) {
return;
}
if (schema?.length) {
initDefault();
isInitedDefaultRef.value = true;
}
}
);
async function setProps(formProps: Partial<FormProps>): Promise<void> {
propsRef.value = deepMerge(unref(propsRef) || {}, formProps);
}
//update-begin-author:taoyan date:2022-11-28 for: QQYUN-3121 【优化】表单视图问题#scott测试 8、此功能未实现
const onFormSubmitWhenChange = useDebounceFn(handleSubmit, 300);
function setFormModel(key: string, value: any) {
formModel[key] = value;
// update-begin--author:liaozhiyang---date:20230922---for【issues/752】表单校验dynamicRules 无法 使用失去焦点后校验 trigger: 'blur'
// const { validateTrigger } = unref(getBindValue);
// if (!validateTrigger || validateTrigger === 'change') {
// validateFields([key]).catch((_) => {});
// }
// update-end--author:liaozhiyang---date:20230922---for【issues/752】表单校验dynamicRules 无法 使用失去焦点后校验 trigger: 'blur'
if(props.autoSearch === true){
onFormSubmitWhenChange();
}
}
//update-end-author:taoyan date:2022-11-28 for: QQYUN-3121 【优化】表单视图问题#scott测试 8、此功能未实现
function handleEnterPress(e: KeyboardEvent) {
const { autoSubmitOnEnter } = unref(getProps);
if (!autoSubmitOnEnter) return;
if (e.key === 'Enter' && e.target && e.target instanceof HTMLElement) {
const target: HTMLElement = e.target as HTMLElement;
if (target && target.tagName && target.tagName.toUpperCase() == 'INPUT') {
handleSubmit();
}
}
}
const formActionType: Partial<FormActionType> = {
getFieldsValue,
setFieldsValue,
resetFields,
updateSchema,
resetSchema,
setProps,
getProps,
removeSchemaByFiled,
appendSchemaByField,
clearValidate,
validateFields,
validate,
submit: handleSubmit,
scrollToField: scrollToField,
};
onMounted(() => {
initDefault();
emit('register', formActionType);
});
return {
getBindValue,
handleToggleAdvanced,
handleEnterPress,
formModel,
defaultValueRef,
advanceState,
getRow,
getProps,
formElRef,
getSchema,
formActionType: formActionType as any,
setFormModel,
getFormClass,
getFormActionBindProps: computed((): Recordable => ({ ...getProps.value, ...advanceState })),
...formActionType,
};
},
});
</script>
<style lang="less">
@prefix-cls: ~'@{namespace}-basic-form';
.@{prefix-cls} {
.ant-form-item {
&-label label::after {
margin: 0 6px 0 2px;
}
&-with-help {
margin-bottom: 0;
}
// update-begin--author:liaozhiyang---date:20240514---for【QQYUN-9241】form表单上下间距大点
//&:not(.ant-form-item-with-help) {
// margin-bottom: 24px;
//}
// update-begin--author:liaozhiyang---date:20240514---for【QQYUN-9241】form表单上下间距大点
// update-begin--author:liaozhiyang---date:20240620---for【TV360X-1420】校验时闪动
&-has-error {
margin-bottom: 24px;
}
// update-end--author:liaozhiyang---date:20240620---for【TV360X-1420】校验时闪动
&.suffix-item {
.ant-form-item-children {
display: flex;
}
.ant-form-item-control {
margin-top: 4px;
}
.suffix {
display: inline-flex;
padding-left: 6px;
margin-top: 1px;
line-height: 1;
align-items: center;
}
}
}
/*【美化表单】form的字体改小一号*/
/* .ant-form-item-label > label{
font-size: 13px;
}
.ant-form-item .ant-select {
font-size: 13px;
}
.ant-select-item-option-selected {
font-size: 13px;
}
.ant-select-item-option-content {
font-size: 13px;
}
.ant-input {
font-size: 13px;
}*/
/*【美化表单】form的字体改小一号*/
.ant-form-explain {
font-size: 14px;
}
&--compact {
.ant-form-item {
margin-bottom: 8px !important;
}
}
// update-begin--author:liaozhiyang---date:20231017---for【QQYUN-6566】BasicForm支持一行显示(inline)
&.ant-form-inline {
& > .ant-row {
.ant-col { width:auto !important; }
}
}
// update-end--author:liaozhiyang---date:20231017---for【QQYUN-6566】BasicForm支持一行显示(inline)
}
</style>

View File

@ -0,0 +1,183 @@
/**
* 目前实现了异步加载的组件清单
* JAreaLinkage
* JEditor
* JMarkdownEditor
* JCodeEditor
* JEasyCron
*/
import type { Component } from 'vue';
import type { ComponentType } from './types/index';
import { createAsyncComponent } from '/@/utils/factory/createAsyncComponent';
/**
* Component list, register here to setting it in the form
*/
import {
Input,
Select,
Radio,
Checkbox,
AutoComplete,
Cascader,
DatePicker,
InputNumber,
Switch,
TimePicker,
TreeSelect,
Slider,
Rate,
Divider,
} from 'ant-design-vue';
import ApiRadioGroup from './components/ApiRadioGroup.vue';
import RadioButtonGroup from './components/RadioButtonGroup.vue';
import ApiSelect from './components/ApiSelect.vue';
import ApiTreeSelect from './components/ApiTreeSelect.vue';
import { BasicUpload } from '/@/components/Upload';
import { StrengthMeter } from '/@/components/StrengthMeter';
import { IconPicker } from '/@/components/Icon';
import { CountdownInput } from '/@/components/CountDown';
//自定义组件
// import JAreaLinkage from './jeecg/components/JAreaLinkage.vue';
import JSelectUser from './jeecg/components/JSelectUser.vue';
import JSelectPosition from './jeecg/components/JSelectPosition.vue';
import JSelectRole from './jeecg/components/JSelectRole.vue';
import JImageUpload from './jeecg/components/JImageUpload.vue';
import JDictSelectTag from './jeecg/components/JDictSelectTag.vue';
import JSelectDept from './jeecg/components/JSelectDept.vue';
import JAreaSelect from './jeecg/components/JAreaSelect.vue';
import JEditor from './jeecg/components/JEditor.vue';
// import JMarkdownEditor from './jeecg/components/JMarkdownEditor.vue';
import JSelectInput from './jeecg/components/JSelectInput.vue';
// import JCodeEditor from './jeecg/components/JCodeEditor.vue';
import JCategorySelect from './jeecg/components/JCategorySelect.vue';
import JSelectMultiple from './jeecg/components/JSelectMultiple.vue';
import JPopup from './jeecg/components/JPopup.vue';
// update-begin--author:liaozhiyang---date:20240130---for【QQYUN-7961】popupDict字典
import JPopupDict from './jeecg/components/JPopupDict.vue';
// update-end--author:liaozhiyang---date:20240130---for【QQYUN-7961】popupDict字典
import JSwitch from './jeecg/components/JSwitch.vue';
import JTreeDict from './jeecg/components/JTreeDict.vue';
import JInputPop from './jeecg/components/JInputPop.vue';
// import { JEasyCron } from './jeecg/components/JEasyCron';
import JCheckbox from './jeecg/components/JCheckbox.vue';
import JInput from './jeecg/components/JInput.vue';
import JTreeSelect from './jeecg/components/JTreeSelect.vue';
import JEllipsis from './jeecg/components/JEllipsis.vue';
import JSelectUserByDept from './jeecg/components/JSelectUserByDept.vue';
import JUpload from './jeecg/components/JUpload/JUpload.vue';
import JSearchSelect from './jeecg/components/JSearchSelect.vue';
import JAddInput from './jeecg/components/JAddInput.vue';
import { Time } from '/@/components/Time';
import JRangeNumber from './jeecg/components/JRangeNumber.vue';
import UserSelect from './jeecg/components/userSelect/index.vue';
import JRangeDate from './jeecg/components/JRangeDate.vue'
import JRangeTime from './jeecg/components/JRangeTime.vue'
import JInputSelect from './jeecg/components/JInputSelect.vue'
import RoleSelectInput from './jeecg/components/roleSelect/RoleSelectInput.vue';
import {DatePickerInFilter, CascaderPcaInFilter} from "@/components/InFilter";
const componentMap = new Map<ComponentType, Component>();
componentMap.set('Time', Time);
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('ApiSelect', ApiSelect);
componentMap.set('TreeSelect', TreeSelect);
componentMap.set('ApiTreeSelect', ApiTreeSelect);
componentMap.set('ApiRadioGroup', ApiRadioGroup);
componentMap.set('Switch', Switch);
componentMap.set('RadioButtonGroup', RadioButtonGroup);
componentMap.set('RadioGroup', Radio.Group);
componentMap.set('Checkbox', Checkbox);
componentMap.set('CheckboxGroup', Checkbox.Group);
componentMap.set('Cascader', Cascader);
componentMap.set('Slider', Slider);
componentMap.set('Rate', Rate);
componentMap.set('DatePicker', DatePicker);
componentMap.set('MonthPicker', DatePicker.MonthPicker);
componentMap.set('RangePicker', DatePicker.RangePicker);
componentMap.set('WeekPicker', DatePicker.WeekPicker);
componentMap.set('TimePicker', TimePicker);
componentMap.set('DatePickerInFilter', DatePickerInFilter);
componentMap.set('StrengthMeter', StrengthMeter);
componentMap.set('IconPicker', IconPicker);
componentMap.set('InputCountDown', CountdownInput);
componentMap.set('Upload', BasicUpload);
componentMap.set('Divider', Divider);
//注册自定义组件
componentMap.set(
'JAreaLinkage',
createAsyncComponent(() => import('./jeecg/components/JAreaLinkage.vue'))
);
componentMap.set('JSelectPosition', JSelectPosition);
componentMap.set('JSelectUser', JSelectUser);
componentMap.set('JSelectRole', JSelectRole);
componentMap.set('JImageUpload', JImageUpload);
componentMap.set('JDictSelectTag', JDictSelectTag);
componentMap.set('JSelectDept', JSelectDept);
componentMap.set('JAreaSelect', JAreaSelect);
// componentMap.set(
// 'JEditor',
// createAsyncComponent(() => import('./jeecg/components/JEditor.vue'))
// );
componentMap.set('JEditor', JEditor);
componentMap.set(
'JMarkdownEditor',
createAsyncComponent(() => import('./jeecg/components/JMarkdownEditor.vue'))
);
componentMap.set('JSelectInput', JSelectInput);
componentMap.set(
'JCodeEditor',
createAsyncComponent(() => import('./jeecg/components/JCodeEditor.vue'))
);
componentMap.set('JCategorySelect', JCategorySelect);
componentMap.set('JSelectMultiple', JSelectMultiple);
componentMap.set('JPopup', JPopup);
// update-begin--author:liaozhiyang---date:20240130---for【QQYUN-7961】popupDict字典
componentMap.set('JPopupDict', JPopupDict);
// update-end--author:liaozhiyang---date:20240130---for【QQYUN-7961】popupDict字典
componentMap.set('JSwitch', JSwitch);
componentMap.set('JTreeDict', JTreeDict);
componentMap.set('JInputPop', JInputPop);
componentMap.set(
'JEasyCron',
createAsyncComponent(() => import('./jeecg/components/JEasyCron/EasyCronInput.vue'))
);
componentMap.set('JCheckbox', JCheckbox);
componentMap.set('JInput', JInput);
componentMap.set('JTreeSelect', JTreeSelect);
componentMap.set('JEllipsis', JEllipsis);
componentMap.set('JSelectUserByDept', JSelectUserByDept);
componentMap.set('JUpload', JUpload);
componentMap.set('JSearchSelect', JSearchSelect);
componentMap.set('JAddInput', JAddInput);
componentMap.set('JRangeNumber', JRangeNumber);
componentMap.set('CascaderPcaInFilter', CascaderPcaInFilter);
componentMap.set('UserSelect', UserSelect);
componentMap.set('RangeDate', JRangeDate);
componentMap.set('RangeTime', JRangeTime);
componentMap.set('RoleSelect', RoleSelectInput);
componentMap.set('JInputSelect', JInputSelect);
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,130 @@
<!--
* @Description:It is troublesome to implement radio button group in the form. So it is extracted independently as a separate component
-->
<template>
<RadioGroup v-bind="attrs" v-model:value="state" button-style="solid" @change="handleChange">
<template v-for="item in getOptions" :key="`${item.value}`">
<RadioButton v-if="props.isBtn" :value="item.value" :disabled="item.disabled">
{{ item.label }}
</RadioButton>
<Radio v-else :value="item.value" :disabled="item.disabled">
{{ item.label }}
</Radio>
</template>
</RadioGroup>
</template>
<script lang="ts">
import { defineComponent, PropType, ref, watchEffect, computed, unref, watch } from 'vue';
import { Radio } from 'ant-design-vue';
import { isFunction } from '/@/utils/is';
import { useRuleFormItem } from '/@/hooks/component/useFormItem';
import { useAttrs } from '/@/hooks/core/useAttrs';
import { propTypes } from '/@/utils/propTypes';
import { get, omit } from 'lodash-es';
import { useI18n } from '/@/hooks/web/useI18n';
type OptionsItem = { label: string; value: string | number | boolean; disabled?: boolean };
export default defineComponent({
name: 'ApiRadioGroup',
components: {
RadioGroup: Radio.Group,
RadioButton: Radio.Button,
Radio,
},
props: {
api: {
type: Function as PropType<(arg?: Recordable | string) => Promise<OptionsItem[]>>,
default: null,
},
params: {
type: [Object, String] as PropType<Recordable | string>,
default: () => ({}),
},
value: {
type: [String, Number, Boolean] as PropType<string | number | boolean>,
},
isBtn: {
type: [Boolean] as PropType<boolean>,
default: false,
},
numberToString: propTypes.bool,
resultField: propTypes.string.def(''),
labelField: propTypes.string.def('label'),
valueField: propTypes.string.def('value'),
immediate: propTypes.bool.def(true),
},
emits: ['options-change', 'change'],
setup(props, { emit }) {
const options = ref<OptionsItem[]>([]);
const loading = ref(false);
const isFirstLoad = ref(true);
const emitData = ref<any[]>([]);
const attrs = useAttrs();
const { t } = useI18n();
// Embedded in the form, just use the hook binding to perform form verification
const [state] = useRuleFormItem(props);
// Processing options value
const getOptions = computed(() => {
const { labelField, valueField, numberToString } = props;
return unref(options).reduce((prev, next: Recordable) => {
if (next) {
const value = next[valueField];
prev.push({
label: next[labelField],
value: numberToString ? `${value}` : value,
...omit(next, [labelField, valueField]),
});
}
return prev;
}, [] as OptionsItem[]);
});
watchEffect(() => {
props.immediate && fetch();
});
watch(
() => props.params,
() => {
!unref(isFirstLoad) && fetch();
},
{ deep: true }
);
async function fetch() {
const api = props.api;
if (!api || !isFunction(api)) return;
options.value = [];
try {
loading.value = true;
const res = await api(props.params);
if (Array.isArray(res)) {
options.value = res;
emitChange();
return;
}
if (props.resultField) {
options.value = get(res, props.resultField) || [];
}
emitChange();
} catch (error) {
console.warn(error);
} finally {
loading.value = false;
}
}
function emitChange() {
emit('options-change', unref(getOptions));
}
function handleChange(_, ...args) {
emitData.value = args;
}
return { state, getOptions, attrs, loading, t, handleChange, props };
},
});
</script>

View File

@ -0,0 +1,176 @@
<template>
<Select @dropdownVisibleChange="handleFetch" v-bind="attrs_" @change="handleChange" :options="getOptions" v-model:value="state">
<template #[item]="data" v-for="item in Object.keys($slots)">
<slot :name="item" v-bind="data || {}"></slot>
</template>
<template #suffixIcon v-if="loading">
<LoadingOutlined spin />
</template>
<template #notFoundContent v-if="loading">
<span>
<LoadingOutlined spin class="mr-1" />
{{ t('component.form.apiSelectNotFound') }}
</span>
</template>
</Select>
</template>
<script lang="ts">
import { defineComponent, PropType, ref, watchEffect, computed, unref, watch } from 'vue';
import { Select } from 'ant-design-vue';
import { isFunction } from '/@/utils/is';
import { useRuleFormItem } from '/@/hooks/component/useFormItem';
import { useAttrs } from '/@/hooks/core/useAttrs';
import { get, omit } from 'lodash-es';
import { LoadingOutlined } from '@ant-design/icons-vue';
import { useI18n } from '/@/hooks/web/useI18n';
import { propTypes } from '/@/utils/propTypes';
type OptionsItem = { label: string; value: string; disabled?: boolean };
export default defineComponent({
name: 'ApiSelect',
components: {
Select,
LoadingOutlined,
},
inheritAttrs: false,
props: {
value: [Array, Object, String, Number],
numberToString: propTypes.bool,
api: {
type: Function as PropType<(arg?: Recordable) => Promise<OptionsItem[]>>,
default: null,
},
// api params
params: {
type: Object as PropType<Recordable>,
default: () => ({}),
},
// support xxx.xxx.xx
resultField: propTypes.string.def(''),
labelField: propTypes.string.def('label'),
valueField: propTypes.string.def('value'),
immediate: propTypes.bool.def(true),
},
emits: ['options-change', 'change'],
setup(props, { emit }) {
const options = ref<OptionsItem[]>([]);
const loading = ref(false);
const isFirstLoad = ref(true);
const emitData = ref<any[]>([]);
const attrs = useAttrs();
const { t } = useI18n();
// Embedded in the form, just use the hook binding to perform form verification
const [state, setState] = useRuleFormItem(props, 'value', 'change', emitData);
// update-begin--author:liaozhiyang---date:20230830---for【QQYUN-6308】解决警告
let vModalValue: any;
const attrs_ = computed(() => {
let obj: any = unref(attrs) || {};
if (obj && obj['onUpdate:value']) {
vModalValue = obj['onUpdate:value'];
delete obj['onUpdate:value'];
}
// update-begin--author:liaozhiyang---date:20231017---for【issues/5467】ApiSelect修复覆盖了用户传递的方法
if (obj['filterOption'] === undefined) {
// update-begin--author:liaozhiyang---date:20230904---for【issues/5305】无法按照预期进行搜索
obj['filterOption'] = (inputValue, option) => {
if (typeof option['label'] === 'string') {
return option['label'].toLowerCase().indexOf(inputValue.toLowerCase()) != -1;
} else {
return true;
}
};
// update-end--author:liaozhiyang---date:20230904---for【issues/5305】无法按照预期进行搜索
}
// update-end--author:liaozhiyang---date:20231017---for【issues/5467】ApiSelect修复覆盖了用户传递的方法
return obj;
});
// update-begin--author:liaozhiyang---date:20230830---for【QQYUN-6308】解决警告
const getOptions = computed(() => {
const { labelField, valueField, numberToString } = props;
return unref(options).reduce((prev, next: Recordable) => {
if (next) {
const value = next[valueField];
prev.push({
...omit(next, [labelField, valueField]),
label: next[labelField],
value: numberToString ? `${value}` : value,
});
}
return prev;
}, [] as OptionsItem[]);
});
// update-begin--author:liaozhiyang---date:20240509---for【issues/6191】apiSelect多次请求
props.immediate && fetch();
// update-end--author:liaozhiyang---date:20240509---for【issues/6191】apiSelect多次请求
watch(
() => props.params,
() => {
!unref(isFirstLoad) && fetch();
},
{ deep: true }
);
//监听数值修改,查询数据
watchEffect(() => {
props.value && handleFetch();
});
async function fetch() {
const api = props.api;
if (!api || !isFunction(api)) return;
options.value = [];
try {
loading.value = true;
const res = await api(props.params);
if (Array.isArray(res)) {
options.value = res;
emitChange();
return;
}
if (props.resultField) {
options.value = get(res, props.resultField) || [];
}
emitChange();
} catch (error) {
console.warn(error);
} finally {
loading.value = false;
//--@updateBy-begin----author:liusq---date:20210914------for:判断选择模式multiple多选情况下的value值空的情况下需要设置为数组------
unref(attrs).mode == 'multiple' && !Array.isArray(unref(state)) && setState([]);
//--@updateBy-end----author:liusq---date:20210914------for:判断选择模式multiple多选情况下的value值空的情况下需要设置为数组------
//update-begin---author:wangshuai ---date:20230505 for初始化value值如果是多选字符串的情况下显示不出来------------
initValue();
//update-end---author:wangshuai ---date:20230505 for初始化value值如果是多选字符串的情况下显示不出来------------
}
}
function initValue() {
let value = props.value;
if (value && typeof value === 'string' && value != 'null' && value != 'undefined') {
state.value = value.split(',');
}
}
async function handleFetch() {
if (!props.immediate && unref(isFirstLoad)) {
await fetch();
isFirstLoad.value = false;
}
}
function emitChange() {
emit('options-change', unref(getOptions));
}
function handleChange(_, ...args) {
vModalValue && vModalValue(_);
emitData.value = args;
}
return { state, attrs_, attrs, getOptions, loading, t, handleFetch, handleChange };
},
});
</script>

View File

@ -0,0 +1,88 @@
<template>
<a-tree-select v-bind="getAttrs" @change="handleChange">
<template #[item]="data" v-for="item in Object.keys($slots)">
<slot :name="item" v-bind="data || {}"></slot>
</template>
<template #suffixIcon v-if="loading">
<LoadingOutlined spin />
</template>
</a-tree-select>
</template>
<script lang="ts">
import { computed, defineComponent, watch, ref, onMounted, unref } from 'vue';
import { TreeSelect } from 'ant-design-vue';
import { isArray, isFunction } from '/@/utils/is';
import { get } from 'lodash-es';
import { propTypes } from '/@/utils/propTypes';
import { LoadingOutlined } from '@ant-design/icons-vue';
export default defineComponent({
name: 'ApiTreeSelect',
components: { ATreeSelect: TreeSelect, LoadingOutlined },
props: {
api: { type: Function as PropType<(arg?: Recordable) => Promise<Recordable>> },
params: { type: Object },
immediate: { type: Boolean, default: true },
resultField: propTypes.string.def(''),
},
emits: ['options-change', 'change'],
setup(props, { attrs, emit }) {
const treeData = ref<Recordable[]>([]);
const isFirstLoaded = ref<Boolean>(false);
const loading = ref(false);
const getAttrs = computed(() => {
return {
...(props.api ? { treeData: unref(treeData) } : {}),
...attrs,
};
});
function handleChange(...args) {
emit('change', ...args);
}
watch(
() => props.params,
() => {
//update-begin---author:wangshuai---date:2024-02-28---for:【QQYUN-8346】 ApiTreeSelect组件入参变化时不及时刷新数据 #1054---
unref(isFirstLoaded) && fetch();
//update-end---author:wangshuai---date:2024-02-28---for:【QQYUN-8346】 ApiTreeSelect组件入参变化时不及时刷新数据 #1054---
},
{ deep: true }
);
watch(
() => props.immediate,
(v) => {
v && !isFirstLoaded.value && fetch();
}
);
onMounted(() => {
props.immediate && fetch();
});
async function fetch() {
const { api } = props;
if (!api || !isFunction(api)) return;
loading.value = true;
treeData.value = [];
let result;
try {
result = await api(props.params);
} catch (e) {
console.error(e);
}
loading.value = false;
if (!result) return;
if (!isArray(result)) {
result = get(result, props.resultField);
}
treeData.value = (result as Recordable[]) || [];
isFirstLoaded.value = true;
emit('options-change', treeData.value);
}
return { getAttrs, loading, handleChange };
},
});
</script>

View File

@ -0,0 +1,128 @@
<template>
<a-col v-bind="actionColOpt" v-if="showActionButtonGroup">
<div style="width: 100%" :style="{ textAlign: actionColOpt.style.textAlign }">
<FormItem>
<!-- update-begin-author:zyf Date:20211213 for调换按钮前后位置-->
<slot name="submitBefore"></slot>
<Button type="primary" class="mr-2" v-bind="getSubmitBtnOptions" @click="submitAction" v-if="showSubmitButton">
{{ getSubmitBtnOptions.text }}
</Button>
<slot name="resetBefore"></slot>
<Button type="default" class="mr-2" v-bind="getResetBtnOptions" @click="resetAction" v-if="showResetButton">
{{ getResetBtnOptions.text }}
</Button>
<!-- update-end-author:zyf Date:20211213 for调换按钮前后位置-->
<slot name="advanceBefore"></slot>
<Button type="link" size="small" @click="toggleAdvanced" v-if="showAdvancedButton && !hideAdvanceBtn">
{{ isAdvanced ? t('component.form.putAway') : t('component.form.unfold') }}
<BasicArrow class="ml-1" :expand="!isAdvanced" up />
</Button>
<slot name="advanceAfter"></slot>
</FormItem>
</div>
</a-col>
</template>
<script lang="ts">
import type { ColEx } from '../types/index';
//import type { ButtonProps } from 'ant-design-vue/es/button/buttonTypes';
import { defineComponent, computed, PropType } from 'vue';
import { Form, Col } from 'ant-design-vue';
import { Button, ButtonProps } from '/@/components/Button';
import { BasicArrow } from '/@/components/Basic';
import { useFormContext } from '../hooks/useFormContext';
import { useI18n } from '/@/hooks/web/useI18n';
import { propTypes } from '/@/utils/propTypes';
type ButtonOptions = Partial<ButtonProps> & { text: string };
export default defineComponent({
name: 'BasicFormAction',
components: {
FormItem: Form.Item,
Button,
BasicArrow,
[Col.name]: Col,
},
props: {
showActionButtonGroup: propTypes.bool.def(true),
showResetButton: propTypes.bool.def(true),
showSubmitButton: propTypes.bool.def(true),
showAdvancedButton: propTypes.bool.def(true),
resetButtonOptions: {
type: Object as PropType<ButtonOptions>,
default: () => ({}),
},
submitButtonOptions: {
type: Object as PropType<ButtonOptions>,
default: () => ({}),
},
actionColOptions: {
type: Object as PropType<Partial<ColEx>>,
default: () => ({}),
},
actionSpan: propTypes.number.def(6),
isAdvanced: propTypes.bool,
hideAdvanceBtn: propTypes.bool,
layout: propTypes.oneOf(['horizontal', 'vertical', 'inline']).def('horizontal'),
},
emits: ['toggle-advanced'],
setup(props, { emit }) {
const { t } = useI18n();
const actionColOpt = computed(() => {
const { showAdvancedButton, actionSpan: span, actionColOptions } = props;
const actionSpan = 24 - span;
const advancedSpanObj = showAdvancedButton ? { span: actionSpan < 6 ? 24 : actionSpan } : {};
// update-begin--author:liaozhiyang---date:20240105---for【QQYUN-6566】BasicForm支持一行显示(inline)
const defaultSpan = props.layout == 'inline' ? {} : { span: showAdvancedButton ? 6 : 4 };
// update-end--author:liaozhiyang---date:20240105---for【QQYUN-6566】BasicForm支持一行显示(inline)
const actionColOpt: Partial<ColEx> = {
style: { textAlign: 'right' },
...defaultSpan,
...advancedSpanObj,
...actionColOptions,
};
return actionColOpt;
});
const getResetBtnOptions = computed((): ButtonOptions => {
return Object.assign(
{
text: t('common.resetText'),
preIcon: 'ic:baseline-restart-alt',
},
props.resetButtonOptions
);
});
const getSubmitBtnOptions = computed(() => {
return Object.assign(
{},
{
text: t('common.queryText'),
preIcon: 'ant-design:search-outlined',
},
props.submitButtonOptions
);
});
function toggleAdvanced() {
emit('toggle-advanced');
}
return {
t,
actionColOpt,
getResetBtnOptions,
getSubmitBtnOptions,
toggleAdvanced,
...useFormContext(),
};
},
});
</script>

View File

@ -0,0 +1,513 @@
<script lang="tsx">
import { NamePath, ValidateOptions } from 'ant-design-vue/lib/form/interface';
import type { PropType, Ref } from 'vue';
import type { FormActionType, FormProps } from '../types/form';
import type { FormSchema } from '../types/form';
import type { ValidationRule } from 'ant-design-vue/lib/form/Form';
import type { TableActionType } from '/@/components/Table';
import { defineComponent, computed, unref, toRefs } from 'vue';
import { Form, Col, Divider } from 'ant-design-vue';
import { componentMap } from '../componentMap';
import { BasicHelp } from '/@/components/Basic';
import { isBoolean, isFunction, isNull } from '/@/utils/is';
import { getSlot } from '/@/utils/helper/tsxHelper';
import { createPlaceholderMessage, setComponentRuleType } from '../helper';
import { upperFirst, cloneDeep } from 'lodash-es';
import { useItemLabelWidth } from '../hooks/useLabelWidth';
import { useI18n } from '/@/hooks/web/useI18n';
import { useAppInject } from '/@/hooks/web/useAppInject';
import { usePermission } from '/@/hooks/web/usePermission';
import Middleware from './Middleware.vue';
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<Recordable>,
default: () => ({}),
},
formModel: {
type: Object as PropType<Recordable>,
default: () => ({}),
},
setFormModel: {
type: Function as PropType<(key: string, value: any) => void>,
default: null,
},
validateFields: {
type: Function as PropType<(nameList?: NamePath[] | undefined, options?: ValidateOptions) => Promise<any>>,
default: null,
},
tableAction: {
type: Object as PropType<TableActionType>,
},
formActionType: {
type: Object as PropType<FormActionType>,
},
// update-begin--author:liaozhiyang---date:20240605---for【TV360X-857】解决禁用状态下触发校验
clearValidate: {
type: Function,
default: null,
},
// update-end-author:liaozhiyang---date:20240605---for【TV360X-857】解决禁用状态下触发校验
},
setup(props, { slots }) {
const { t } = useI18n();
const { schema, formProps } = toRefs(props) as {
schema: Ref<FormSchema>;
formProps: Ref<FormProps>;
};
const itemLabelWidthProp = useItemLabelWidth(schema, formProps);
const getValues = computed(() => {
const { allDefaultValues, formModel, schema } = props;
const { mergeDynamicData } = props.formProps;
return {
field: schema.field,
model: formModel,
values: {
...mergeDynamicData,
...allDefaultValues,
...formModel,
} as Recordable,
schema: schema,
};
});
const getComponentsProps = computed(() => {
const { schema, tableAction, formModel, formActionType } = props;
let { componentProps = {} } = schema;
if (isFunction(componentProps)) {
componentProps = componentProps({ schema, tableAction, formModel, formActionType }) ?? {};
}
if (schema.component === 'Divider') {
//update-begin---author:wangshuai---date:2023-09-22---for:【QQYUN-6603】分割线标题位置显示不正确---
componentProps = Object.assign({ type: 'horizontal',orientation:'left', plain: true, }, componentProps);
//update-end---author:wangshuai---date:2023-09-22---for:【QQYUN-6603】分割线标题位置显示不正确---
}
return componentProps as Recordable;
});
const getDisable = computed(() => {
const { disabled: globDisabled } = props.formProps;
// update-begin--author:liaozhiyang---date:20240530---for【TV360X-594】表单全局禁用则dynamicDisabled不生效
if (!!globDisabled) {
return globDisabled;
}
// update-end--author:liaozhiyang---date:20240530---for【TV360X-594】表单全局禁用则dynamicDisabled不生效
const { dynamicDisabled } = props.schema;
const { disabled: itemDisabled = false } = unref(getComponentsProps);
let disabled = !!globDisabled || itemDisabled;
if (isBoolean(dynamicDisabled)) {
disabled = dynamicDisabled;
}
if (isFunction(dynamicDisabled)) {
disabled = dynamicDisabled(unref(getValues));
}
return disabled;
});
// update-begin--author:liaozhiyang---date:20240308---for【QQYUN-8377】formSchema props支持动态修改
const getDynamicPropsValue = computed(() => {
const { dynamicPropsVal, dynamicPropskey } = props.schema;
if (dynamicPropskey == null) {
return null;
} else {
const { [dynamicPropskey]: itemValue } = unref(getComponentsProps);
let value = itemValue;
if (isFunction(dynamicPropsVal)) {
value = dynamicPropsVal(unref(getValues));
return value;
}
}
});
// update-end--author:liaozhiyang---date:20240308---for【QQYUN-8377】formSchema props支持动态修改
function getShow(): { isShow: boolean; isIfShow: boolean } {
const { show, ifShow } = props.schema;
const { showAdvancedButton } = props.formProps;
const itemIsAdvanced = showAdvancedButton ? (isBoolean(props.schema.isAdvanced) ? props.schema.isAdvanced : true) : true;
let isShow = true;
let isIfShow = true;
if (isBoolean(show)) {
isShow = show;
}
if (isBoolean(ifShow)) {
isIfShow = ifShow;
}
if (isFunction(show)) {
isShow = show(unref(getValues));
}
if (isFunction(ifShow)) {
isIfShow = ifShow(unref(getValues));
}
isShow = isShow && itemIsAdvanced;
return { isShow, isIfShow };
}
// update-begin--author:liaozhiyang---date:20240530---for【TV360X-434】validator校验执行两次
let vSwitchArr: any = [],
prevValidatorArr: any = [];
const hijackValidator = (rules) => {
vSwitchArr = [];
prevValidatorArr = [];
rules.forEach((item, index) => {
const fn = item.validator;
vSwitchArr.push(true);
prevValidatorArr.push(null);
if (isFunction(fn)) {
item.validator = (rule, value, callback) => {
if (vSwitchArr[index]) {
vSwitchArr[index] = false;
setTimeout(() => {
vSwitchArr[index] = true;
}, 100);
const result = fn(rule, value, callback);
prevValidatorArr[index] = result;
return result;
} else {
return prevValidatorArr[index];
}
};
}
});
};
// update-end--author:liaozhiyang---date:20240530---for【TV360X-434】validator校验执行两次
function handleRules(): ValidationRule[] {
const { rules: defRules = [], component, rulesMessageJoinLabel, label, dynamicRules, required, auth, field } = props.schema;
// update-begin--author:liaozhiyang---date:20240605---for【TV360X-857】解决禁用状态下触发校验
const { disabled: globDisabled } = props.formProps;
const { disabled: itemDisabled = false } = unref(getComponentsProps);
if (!!globDisabled || !!itemDisabled) {
props.clearValidate(field);
return [];
}
// update-end--author:liaozhiyang---date:20240605---for【TV360X-857】解决禁用状态下触发校验
// update-begin--author:liaozhiyang---date:20240531---for【TV360X-842】必填项v-auth、show隐藏的情况下表单无法提交
const { hasPermission } = usePermission();
const { isShow } = getShow();
if ((auth && !hasPermission(auth)) || !isShow) {
return [];
}
// update-end--author:liaozhiyang---date:20240531---for【TV360X-842】必填项v-auth、show隐藏的情况下表单无法提交
if (isFunction(dynamicRules)) {
// update-begin--author:liaozhiyang---date:20240514---for【issues/1244】标识了必填但是必填标识没显示
const ruleArr = dynamicRules(unref(getValues)) as ValidationRule[];
if (required) {
ruleArr.unshift({ required: true });
}
// update-begin--author:liaozhiyang---date:20240530---for【TV360X-434】validator校验执行两次
hijackValidator(ruleArr);
// update-end--author:liaozhiyang---date:20240530---for【TV360X-434】validator校验执行两次
return ruleArr;
// update-end--author:liaozhiyang---date:20240514---for【issues/1244】标识了必填但是必填标识没显示
}
let rules: ValidationRule[] = cloneDeep(defRules) as ValidationRule[];
const { rulesMessageJoinLabel: globalRulesMessageJoinLabel } = props.formProps;
const joinLabel = Reflect.has(props.schema, 'rulesMessageJoinLabel') ? rulesMessageJoinLabel : globalRulesMessageJoinLabel;
const defaultMsg = createPlaceholderMessage(component) + `${joinLabel ? label : ''}`;
function validator(rule: any, value: any) {
const msg = rule.message || defaultMsg;
if (value === undefined || isNull(value)) {
// 空值
return Promise.reject(msg);
} else if (Array.isArray(value) && value.length === 0) {
// 数组类型
return Promise.reject(msg);
} else if (typeof value === 'string' && value.trim() === '') {
// 空字符串
return Promise.reject(msg);
} else if (
typeof value === 'object' &&
Reflect.has(value, 'checked') &&
Reflect.has(value, 'halfChecked') &&
Array.isArray(value.checked) &&
Array.isArray(value.halfChecked) &&
value.checked.length === 0 &&
value.halfChecked.length === 0
) {
// 非关联选择的tree组件
return Promise.reject(msg);
}
return Promise.resolve();
}
const getRequired = isFunction(required) ? required(unref(getValues)) : required;
if ((!rules || rules.length === 0) && getRequired) {
rules = [{ required: getRequired, validator }];
}
const requiredRuleIndex: number = rules.findIndex((rule) => Reflect.has(rule, 'required') && !Reflect.has(rule, 'validator'));
if (requiredRuleIndex !== -1) {
const rule = rules[requiredRuleIndex];
const { isShow } = getShow();
if (!isShow) {
rule.required = false;
}
if (component) {
//update-begin---author:wangshuai---date:2024-02-01---for:【QQYUN-8176】编辑表单中,校验必填时,如果组件是ApiSelect,打开编辑页面时,即使该字段有值,也会提示请选择---
//https://github.com/vbenjs/vue-vben-admin/pull/3082 github修复原文
/*if (!Reflect.has(rule, 'type')) {
rule.type = component === 'InputNumber' ? 'number' : 'string';
}*/
//update-end---author:wangshuai---date:2024-02-01---for:【QQYUN-8176】编辑表单中,校验必填时,如果组件是ApiSelect,打开编辑页面时,即使该字段有值,也会提示请选择---
rule.message = rule.message || defaultMsg;
if (component.includes('Input') || component.includes('Textarea')) {
rule.whitespace = true;
}
const valueFormat = unref(getComponentsProps)?.valueFormat;
setComponentRuleType(rule, component, valueFormat);
}
}
// Maximum input length rule check
const characterInx = rules.findIndex((val) => val.max);
if (characterInx !== -1 && !rules[characterInx].validator) {
rules[characterInx].message = rules[characterInx].message || t('component.form.maxTip', [rules[characterInx].max] as Recordable);
}
// update-begin--author:liaozhiyang---date:20241226---for【QQYUN-7495】pattern由字符串改成正则传递给antd因使用InputNumber时发现正则无效
rules.forEach((item) => {
if (typeof item.pattern === 'string') {
try {
const reg = new Function('item', `return ${item.pattern}`)(item);
if (Object.prototype.toString.call(reg) === '[object RegExp]') {
item.pattern = reg;
} else {
item.pattern = new RegExp(item.pattern);
}
} catch (error) {
item.pattern = new RegExp(item.pattern);
}
}
});
// update-end--author:liaozhiyang---date:20231226---for【QQYUN-7495】pattern由字符串改成正则传递给antd因使用InputNumber时发现正则无效
// update-begin--author:liaozhiyang---date:20240530---for【TV360X-434】validator校验执行两次
hijackValidator(rules);
// update-end--author:liaozhiyang---date:20240530---for【TV360X-434】validator校验执行两次
return rules;
}
function renderComponent() {
const { renderComponentContent, component, field, changeEvent = 'change', valueField, componentProps, dynamicRules } = props.schema;
const isCheck = component && ['Switch', 'Checkbox'].includes(component);
// update-begin--author:liaozhiyang---date:20231013---for【QQYUN-6679】input去空格
let isTrim = false;
if (component === 'Input' && componentProps && componentProps.trim) {
isTrim = true;
}
// update-end--author:liaozhiyang---date:20231013---for【QQYUN-6679】input去空格
const eventKey = `on${upperFirst(changeEvent)}`;
// update-begin--author:liaozhiyang---date:20230922---for【issues/752】表单校验dynamicRules 无法 使用失去焦点后校验 trigger: 'blur'
const on = {
[eventKey]: (...args: Nullable<Recordable>[]) => {
const [e] = args;
if (propsData[eventKey]) {
propsData[eventKey](...args);
}
const target = e ? e.target : null;
// update-begin--author:liaozhiyang---date:20231013---for【QQYUN-6679】input去空格
let value;
if (target) {
if (isCheck) {
value = target.checked;
} else {
value = isTrim ? target.value.trim() : target.value;
}
} else {
value = e;
}
// update-end--author:liaozhiyang---date:20231013---for【QQYUN-6679】input去空格
props.setFormModel(field, value);
// update-begin--author:liaozhiyang---date:20240522---for【TV360X-341】有值之后必填校验不消失
props.validateFields([field]).catch((_) => {});
// update-end--author:liaozhiyang---date:20240522--for【TV360X-341】有值之后必填校验不消失
},
// onBlur: () => {
// props.validateFields([field], { triggerName: 'blur' }).catch((_) => {});
// },
};
// update-end--author:liaozhiyang---date:20230922---for【issues/752】表单校验dynamicRules 无法 使用失去焦点后校验 trigger: 'blur'
const Comp = componentMap.get(component) as ReturnType<typeof defineComponent>;
const { autoSetPlaceHolder, size } = props.formProps;
const propsData: Recordable = {
allowClear: true,
getPopupContainer: (trigger: Element) => {
return trigger?.parentNode;
},
size,
...unref(getComponentsProps),
disabled: unref(getDisable),
};
// update-begin--author:liaozhiyang---date:20240308---for【QQYUN-8377】formSchema props支持动态修改
const dynamicPropskey = props.schema.dynamicPropskey;
if (dynamicPropskey) {
propsData[dynamicPropskey] = unref(getDynamicPropsValue);
}
// update-end--author:liaozhiyang---date:20240308---for【QQYUN-8377】formSchema props支持动态修改
const isCreatePlaceholder = !propsData.disabled && autoSetPlaceHolder;
// RangePicker place是一个数组
if (isCreatePlaceholder && component !== 'RangePicker' && component) {
//自动设置placeholder
propsData.placeholder = unref(getComponentsProps)?.placeholder || createPlaceholderMessage(component) + props.schema.label;
}
propsData.codeField = field;
propsData.formValues = unref(getValues);
const bindValue: Recordable = {
[valueField || (isCheck ? 'checked' : 'value')]: props.formModel[field],
};
const compAttr: Recordable = {
...propsData,
...on,
...bindValue,
};
if (!renderComponentContent) {
return <Comp {...compAttr} />;
}
const compSlot = isFunction(renderComponentContent)
? { ...renderComponentContent(unref(getValues)) }
: {
default: () => renderComponentContent,
};
return <Comp {...compAttr}>{compSlot}</Comp>;
}
/**
*渲染Label
* @updateBy:zyf
*/
function renderLabelHelpMessage() {
//update-begin-author:taoyan date:2022-9-7 for: VUEN-2061【样式】online表单超出4个 .. 省略显示
//label宽度支持自定义
const { label, helpMessage, helpComponentProps, subLabel, labelLength } = props.schema;
let showLabel: string = label + '';
// update-begin--author:liaozhiyang---date:20240517---for【TV360X-98】label展示的文字必须和labelLength配置一致
if (labelLength) {
showLabel = showLabel.substr(0, labelLength);
}
// update-end--author:liaozhiyang---date:20240517---for【TV360X-98】label展示的文字必须和labelLength配置一致
const titleObj = { title: label };
const renderLabel = subLabel ? (
<span>
{label} <span class="text-secondary">{subLabel}</span>
</span>
) : labelLength ? (
<label {...titleObj}>{showLabel}</label>
) : (
label
);
//update-end-author:taoyan date:2022-9-7 for: VUEN-2061【样式】online表单超出4个 .. 省略显示
const getHelpMessage = isFunction(helpMessage) ? helpMessage(unref(getValues)) : helpMessage;
if (!getHelpMessage || (Array.isArray(getHelpMessage) && getHelpMessage.length === 0)) {
return renderLabel;
}
return (
<span>
{renderLabel}
<BasicHelp placement="top" class="mx-1" text={getHelpMessage} {...helpComponentProps} />
</span>
);
}
function renderItem() {
const { itemProps, slot, render, field, suffix, component } = props.schema;
const { labelCol, wrapperCol } = unref(itemLabelWidthProp);
const { colon } = props.formProps;
if (component === 'Divider') {
return (
<Col span={24}>
<Divider {...unref(getComponentsProps)}>{renderLabelHelpMessage()}</Divider>
</Col>
);
} else {
const getContent = () => {
return slot ? getSlot(slots, slot, unref(getValues)) : render ? render(unref(getValues)) : renderComponent();
};
const showSuffix = !!suffix;
const getSuffix = isFunction(suffix) ? suffix(unref(getValues)) : suffix;
return (
<Form.Item
name={field}
colon={colon}
class={{ 'suffix-item': showSuffix }}
{...(itemProps as Recordable)}
label={renderLabelHelpMessage()}
rules={handleRules()}
// update-begin--author:liaozhiyang---date:20240514---for【issues/1244】标识了必填但是必填标识没显示
validateFirst = { true }
// update-end--author:liaozhiyang---date:20240514---for【issues/1244】标识了必填但是必填标识没显示
labelCol={labelCol}
wrapperCol={wrapperCol}
>
<div style="display:flex">
{/* author: sunjianlei for: 【VUEN-744】此处加上 width: 100%; 因为要防止组件宽度超出 FormItem */}
{/* update-begin--author:liaozhiyang---date:20240510---for【TV360X-719】表单校验不通过项滚动到可视区内 */}
<Middleware>{getContent()}</Middleware>
{/* update-end--author:liaozhiyang---date:20240510---for【TV360X-719】表单校验不通过项滚动到可视区内 */}
{showSuffix && <span class="suffix">{getSuffix}</span>}
</div>
</Form.Item>
);
}
}
return () => {
const { colProps = {}, colSlot, renderColContent, component } = props.schema;
if (!componentMap.has(component)) {
return null;
}
const { baseColProps = {} } = props.formProps;
// update-begin--author:liaozhiyang---date:20230803---for【issues-641】调整表格搜索表单的span配置无效
const { getIsMobile } = useAppInject();
let realColProps;
realColProps = { ...baseColProps, ...colProps };
if (colProps['span'] && !unref(getIsMobile)) {
['xs', 'sm', 'md', 'lg', 'xl', 'xxl'].forEach((name) => delete realColProps[name]);
}
// update-end--author:liaozhiyang---date:20230803---for【issues-641】调整表格搜索表单的span配置无效
const { isIfShow, isShow } = getShow();
const values = unref(getValues);
const getContent = () => {
return colSlot ? getSlot(slots, colSlot, values) : renderColContent ? renderColContent(values) : renderItem();
};
return (
isIfShow && (
<Col {...realColProps} v-show={isShow}>
{getContent()}
</Col>
)
);
};
},
});
</script>

View File

@ -0,0 +1,16 @@
<template>
<div :id="formItemId" style="flex: 1; width: 100%">
<slot></slot>
</div>
</template>
<script setup>
import { Form } from 'ant-design-vue';
import { computed } from 'vue';
const formItemContext = Form.useInjectFormItemContext();
const formItemId = computed(() => {
return formItemContext.id.value;
});
</script>
<style lang="scss" scoped></style>

View File

@ -0,0 +1,57 @@
<!--
* @Description:It is troublesome to implement radio button group in the form. So it is extracted independently as a separate component
-->
<template>
<RadioGroup v-bind="attrs" v-model:value="state" button-style="solid">
<template v-for="item in getOptions" :key="`${item.value}`">
<RadioButton :value="item.value" :disabled="item.disabled">
{{ item.label }}
</RadioButton>
</template>
</RadioGroup>
</template>
<script lang="ts">
import { defineComponent, PropType, computed } from 'vue';
import { Radio } from 'ant-design-vue';
import { isString } from '/@/utils/is';
import { useRuleFormItem } from '/@/hooks/component/useFormItem';
import { useAttrs } from '/@/hooks/core/useAttrs';
type OptionsItem = { label: string; value: string | number | boolean; disabled?: boolean };
type RadioItem = string | OptionsItem;
export default defineComponent({
name: 'RadioButtonGroup',
components: {
RadioGroup: Radio.Group,
RadioButton: Radio.Button,
},
props: {
value: {
type: [String, Number, Boolean] as PropType<string | number | boolean>,
},
options: {
type: Array as PropType<RadioItem[]>,
default: () => [],
},
},
setup(props) {
const attrs = useAttrs();
// Embedded in the form, just use the hook binding to perform form verification
const [state] = useRuleFormItem(props);
// Processing options value
const getOptions = computed((): OptionsItem[] => {
const { options } = props;
if (!options || options?.length === 0) return [];
const isStringArr = options.some((item) => isString(item));
if (!isStringArr) return options as OptionsItem[];
return options.map((item) => ({ label: item, value: item })) as OptionsItem[];
});
return { state, getOptions, attrs };
},
});
</script>

View File

@ -0,0 +1,204 @@
<template>
<div :class="formDisabled ? 'jeecg-form-container-disabled jeecg-form-detail-effect' : ''">
<fieldset :disabled="formDisabled">
<slot name="detail"></slot>
</fieldset>
<slot name="edit"> </slot>
<fieldset disabled>
<slot></slot>
</fieldset>
</div>
</template>
<script lang="ts">
import { defineComponent, ref, watch } from 'vue';
export default defineComponent({
name: 'JForm',
props: {
disabled: {
type: Boolean,
default: false,
required: false,
},
},
setup(props, { emit }) {
const formDisabled = ref<boolean>(props.disabled);
watch(
() => props.disabled,
(value) => {
formDisabled.value = value;
}
);
return {
formDisabled,
};
},
});
</script>
<style scoped lang="less">
.jeecg-form-container-disabled {
cursor: not-allowed;
}
.jeecg-form-container-disabled fieldset[disabled] {
-ms-pointer-events: none;
pointer-events: none;
}
.jeecg-form-container-disabled :deep(.ant-select) {
-ms-pointer-events: none;
pointer-events: none;
}
// update-begin--author:liaozhiyang---date:20240605---for【TV360X-857】online代码生成详情样式调整
// begin antdv 禁用样式
// .jeecg-form-container-disabled :deep(.ant-input-number),
// .jeecg-form-container-disabled :deep(.ant-input),
// .jeecg-form-container-disabled :deep(.ant-input-password),
// .jeecg-form-container-disabled :deep(.ant-select-single .ant-select-selector),
// .jeecg-form-container-disabled :deep(.ant-radio-wrapper .ant-radio-checked .ant-radio-inner),
// .jeecg-form-container-disabled :deep(.ant-switch),
// .jeecg-form-container-disabled :deep(.ant-picker),
// .jeecg-form-container-disabled :deep(.ant-select:not(.ant-select-customize-input) .ant-select-selector),
// .jeecg-form-container-disabled :deep(.ant-input-affix-wrapper),
// .jeecg-form-container-disabled :deep(.tox .tox-toolbar__group),
// .jeecg-form-container-disabled :deep(.tox .tox-edit-area__iframe),
// .jeecg-form-container-disabled :deep(.vditor-toolbar),
// .jeecg-form-container-disabled :deep(.vditor-preview),
// .jeecg-form-container-disabled :deep(.jeecg-tinymce-img-upload) {
// background: rgba(51, 51, 51, 0.04);
// }
// .jeecg-form-container-disabled :deep(.ant-radio-wrapper),
// .jeecg-form-container-disabled :deep(.ant-checkbox-wrapper),
// .jeecg-form-container-disabled :deep(.ant-btn) {
// color: rgba(0, 0, 0, 0.65);
// }
// .jeecg-form-container-disabled :deep(.ant-radio-wrapper .ant-radio-inner:after),
// .jeecg-form-container-disabled :deep(.ant-checkbox-checked .ant-checkbox-inner) {
// background-color: rgba(51, 51, 51, 0.25);
// }
// .jeecg-form-container-disabled :deep(.ant-radio-inner),
// .jeecg-form-container-disabled :deep(.ant-checkbox-inner) {
// border-color: rgba(51, 51, 51, 0.25) !important;
// }
// .jeecg-form-container-disabled :deep(.ant-input-password > .ant-input),
// .jeecg-form-container-disabled :deep(.ant-input-affix-wrapper .ant-input) {
// background: none;
// }
html[data-theme='light'] {
.jeecg-form-detail-effect {
:deep(.ant-select-selector),
:deep(.ant-btn),
:deep(.ant-input),
:deep(.ant-input-affix-wrapper),
:deep(.ant-picker),
:deep(.ant-input-number) {
color: #606266 !important;
}
:deep(.ant-select) {
color: #606266 !important;
}
:deep(.ant-select-selection-item-content),:deep(.ant-select-selection-item),:deep(input) {
color: #606266 !important;
}
:deep(.ant-radio-wrapper),
:deep(.ant-checkbox-wrapper),
:deep(.ant-btn) {
color: rgba(0, 0, 0, 0.65);
}
:deep(.ant-radio-wrapper .ant-radio-inner:after),
:deep(.ant-checkbox-checked .ant-checkbox-inner) {
color: #606266 !important;
}
:deep(.ant-radio-inner),
:deep(.ant-checkbox-inner) {
border-color: rgba(51, 51, 51, 0.25) !important;
background-color: rgba(51, 51, 51, 0.04) !important;
}
:deep(.ant-checkbox-checked .ant-checkbox-inner::after), :deep(.ant-tree-checkbox-checked .ant-tree-checkbox-inner::after){
border-color: rgba(51, 51, 51, 0.25) !important;
}
:deep(.ant-switch) {
background-color: rgba(51, 51, 51, 0.25);
}
:deep(.tox .tox-toolbar__group),
:deep(.tox .tox-edit-area__iframe),
:deep(.vditor-toolbar),
:deep(.vditor-preview),
:deep(.jeecg-tinymce-img-upload) {
background: rgba(51, 51, 51, 0.04);
}
}
}
html[data-theme='dark'] {
.jeecg-form-detail-effect {
:deep(.ant-select-selector),
:deep(.ant-btn),
:deep(.ant-input),
:deep(.ant-input-affix-wrapper),
:deep(.ant-picker),
:deep(.ant-input-number) {
color: rgba(255, 255, 255, 0.25) !important;
//background-color: rgba(255, 255, 255, 0.08) !important;
}
:deep(.ant-select) {
color: rgba(255, 255, 255, 0.25) !important;
}
:deep(.ant-select-selection-item-content),:deep(.ant-select-selection-item),:deep(input) {
color: rgba(255, 255, 255, 0.25) !important;
}
:deep(.ant-radio-wrapper),
:deep(.ant-checkbox-wrapper){
color: rgba(255, 255, 255, 0.25);
}
:deep(.ant-radio-wrapper .ant-radio-inner:after),
:deep(.ant-checkbox-checked .ant-checkbox-inner) {
background-color: rgba(255, 255, 255, 0.08);
}
:deep(.ant-radio-inner),
:deep(.ant-checkbox-inner) {
border-color: #424242 !important;
background-color: rgba(255, 255, 255, 0.08);
}
:deep(.ant-switch) {
background-color: rgba(51, 51, 51, 0.25);
opacity: 0.65;
}
:deep(.tox .tox-toolbar__group),
:deep(.tox .tox-edit-area__iframe),
:deep(.vditor-toolbar),
:deep(.vditor-preview),
:deep(.jeecg-tinymce-img-upload) {
background: rgba(51, 51, 51, 0.04);
}
}
}
// end antdv 禁用样式
// update-begin--author:liaozhiyang---date:20240605---for【TV360X-857】online代码生成详情样式调整
.jeecg-form-container-disabled :deep(.ant-upload-select) {
cursor: grabbing;
}
.jeecg-form-container-disabled :deep(.ant-upload-list) {
cursor: grabbing;
}
.jeecg-form-container-disabled fieldset[disabled] :deep(.ant-upload-list){
// -ms-pointer-events: auto !important;
// pointer-events: auto !important;
}
.jeecg-form-container-disabled fieldset[disabled] iframe {
-ms-pointer-events: auto !important;
pointer-events: auto !important;
}
.jeecg-form-container-disabled :deep(.ant-upload-list-item-actions .anticon-delete),
.jeecg-form-container-disabled :deep(.ant-upload-list-item .anticon-close) {
display: none;
}
.jeecg-form-container-disabled :deep(.vditor-sv) {
display: none !important;
background: rgba(51, 51, 51, 0.04);
}
</style>

View File

@ -0,0 +1,88 @@
import type { ValidationRule } from 'ant-design-vue/lib/form/Form';
import type { ComponentType } from './types/index';
import { useI18n } from '/@/hooks/web/useI18n';
import { dateUtil } from '/@/utils/dateUtil';
import { isNumber, isObject } from '/@/utils/is';
const { t } = useI18n();
/**
* @description: 生成placeholder
*/
export function createPlaceholderMessage(component: ComponentType) {
if (component.includes('Input') || component.includes('Complete')) {
return t('common.inputText');
}
if (component.includes('Picker')) {
return t('common.chooseText');
}
if (
component.includes('Select') ||
component.includes('Cascader') ||
component.includes('Checkbox') ||
component.includes('Radio') ||
component.includes('Switch')
) {
// return `请选择${label}`;
return t('common.chooseText');
}
return '';
}
const DATE_TYPE = ['DatePicker', 'MonthPicker', 'WeekPicker', 'TimePicker'];
function genType() {
return [...DATE_TYPE, 'RangePicker'];
}
export function setComponentRuleType(rule: ValidationRule, component: ComponentType, valueFormat: string) {
//update-begin---author:wangshuai---date:2024-02-01---for:【QQYUN-8176】编辑表单中,校验必填时,如果组件是ApiSelect,打开编辑页面时,即使该字段有值,也会提示请选择---
//https://github.com/vbenjs/vue-vben-admin/pull/3082 github修复原文
if (Reflect.has(rule, 'type')) {
return;
}
//update-end---author:wangshuai---date:2024-02-01---for:【QQYUN-8176】编辑表单中,校验必填时,如果组件是ApiSelect,打开编辑页面时,即使该字段有值,也会提示请选择---
if (['DatePicker', 'MonthPicker', 'WeekPicker', 'TimePicker'].includes(component)) {
rule.type = valueFormat ? 'string' : 'object';
} else if (['RangePicker', 'Upload', 'CheckboxGroup', 'TimePicker'].includes(component)) {
rule.type = 'array';
} else if (['InputNumber'].includes(component)) {
rule.type = 'number';
}
}
export function processDateValue(attr: Recordable, component: string) {
const { valueFormat, value } = attr;
if (valueFormat) {
attr.value = isObject(value) ? dateUtil(value).format(valueFormat) : value;
} else if (DATE_TYPE.includes(component) && value) {
attr.value = dateUtil(attr.value);
}
}
export function handleInputNumberValue(component?: ComponentType, val?: any) {
if (!component) return val;
if (['Input', 'InputPassword', 'InputSearch', 'InputTextArea'].includes(component)) {
return val && isNumber(val) ? `${val}` : val;
}
return val;
}
/**
*liaozhiyang
*2023-12-26
*某些组件的传值需要把字符串类型转成数值类型
*/
export function handleInputStringValue(component?: ComponentType, val?: any) {
if (!component) return val;
// update-begin--author:liaozhiyang---date:20240517---for【TV360X-13】InputNumber设置精确3位小数传入''变成了0.00
if (['InputNumber'].includes(component) && typeof val === 'string' && val != '') {
return Number(val);
}
// update-end--author:liaozhiyang---date:20240517---for【TV360X-13】InputNumber设置精确3位小数传入''变成了0.00
return val;
}
/**
* 时间字段
*/
export const dateItemType = genType();

View File

@ -0,0 +1,164 @@
import type { ColEx } from '../types';
import type { AdvanceState } from '../types/hooks';
import type { ComputedRef, Ref } from 'vue';
import type { FormProps, FormSchema } from '../types/form';
import { computed, unref, watch } from 'vue';
import { isBoolean, isFunction, isNumber, isObject } from '/@/utils/is';
import { useBreakpoint } from '/@/hooks/event/useBreakpoint';
import { useDebounceFn } from '@vueuse/core';
const BASIC_COL_LEN = 24;
interface UseAdvancedContext {
advanceState: AdvanceState;
emit: EmitType;
getProps: ComputedRef<FormProps>;
getSchema: ComputedRef<FormSchema[]>;
formModel: Recordable;
defaultValueRef: Ref<Recordable>;
}
export default function ({ advanceState, emit, getProps, getSchema, formModel, defaultValueRef }: UseAdvancedContext) {
const { realWidthRef, screenEnum, screenRef } = useBreakpoint();
const getEmptySpan = computed((): number => {
if (!advanceState.isAdvanced) {
return 0;
}
// For some special cases, you need to manually specify additional blank lines
const emptySpan = unref(getProps).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 debounceUpdateAdvanced = useDebounceFn(updateAdvanced, 30);
watch(
[() => unref(getSchema), () => advanceState.isAdvanced, () => unref(realWidthRef)],
() => {
const { showAdvancedButton } = unref(getProps);
if (showAdvancedButton) {
debounceUpdateAdvanced();
}
},
{ immediate: true }
);
function getAdvanced(itemCol: Partial<ColEx>, itemColSum = 0, isLastAction = false, index = 0) {
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;
}
let autoAdvancedCol = unref(getProps).autoAdvancedCol ?? 3;
if (isLastAction) {
advanceState.hideAdvanceBtn = unref(getSchema).length <= autoAdvancedCol;
// update-begin--author:sunjianlei---date:20211108---for: 注释掉该逻辑使小于等于2行时也显示展开收起按钮
/* if (itemColSum <= BASIC_COL_LEN * 2) {
// 小于等于2行时不显示折叠和展开按钮
advanceState.hideAdvanceBtn = true;
advanceState.isAdvanced = true;
} else */
// update-end--author:sunjianlei---date:20211108---for: 注释掉该逻辑使小于等于2行时也显示展开收起按钮
if (itemColSum > BASIC_COL_LEN * 2 && itemColSum <= BASIC_COL_LEN * (unref(getProps).autoAdvancedLine || 3)) {
advanceState.hideAdvanceBtn = false;
// 默认超过 3 行折叠
} else if (!advanceState.isLoad) {
advanceState.isLoad = true;
advanceState.isAdvanced = !advanceState.isAdvanced;
// update-begin--author:sunjianlei---date:20211108---for: 如果总列数大于 autoAdvancedCol就默认折叠
if (unref(getSchema).length > autoAdvancedCol) {
advanceState.hideAdvanceBtn = false;
advanceState.isAdvanced = false;
}
// update-end--author:sunjianlei---date:20211108---for: 如果总列数大于 autoAdvancedCol就默认折叠
}
return { isAdvanced: advanceState.isAdvanced, itemColSum };
}
if (itemColSum > BASIC_COL_LEN * (unref(getProps).alwaysShowLines || 1)) {
return { isAdvanced: advanceState.isAdvanced, itemColSum };
} else if (!advanceState.isAdvanced && index + 1 > autoAdvancedCol) {
// 如果当前是收起状态,并且当前列下标 > autoAdvancedCol就隐藏
return { isAdvanced: false, itemColSum };
} else {
// The first line is always displayed
return { isAdvanced: true, itemColSum };
}
}
function updateAdvanced() {
let itemColSum = 0;
let realItemColSum = 0;
const { baseColProps = {} } = unref(getProps);
const schemas = unref(getSchema);
for (let i = 0; i < schemas.length; i++) {
const schema = schemas[i];
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: {
...unref(defaultValueRef),
...formModel,
},
});
}
if (isShow && (colProps || baseColProps)) {
const { itemColSum: sum, isAdvanced } = getAdvanced({ ...baseColProps, ...colProps }, itemColSum, false, i);
itemColSum = sum || 0;
if (isAdvanced) {
realItemColSum = itemColSum;
}
schema.isAdvanced = isAdvanced;
}
}
advanceState.actionSpan = (realItemColSum % BASIC_COL_LEN) + unref(getEmptySpan);
getAdvanced(unref(getProps).actionColOptions || { span: BASIC_COL_LEN }, itemColSum, true);
emit('advanced-change');
}
function handleToggleAdvanced() {
advanceState.isAdvanced = !advanceState.isAdvanced;
}
return { handleToggleAdvanced };
}

View File

@ -0,0 +1,35 @@
import type { ComputedRef, Ref } from 'vue';
import type { FormSchema, FormActionType, FormProps } from '../types/form';
import { unref, nextTick, watchEffect } from 'vue';
interface UseAutoFocusContext {
getSchema: ComputedRef<FormSchema[]>;
getProps: ComputedRef<FormProps>;
isInitedDefault: Ref<boolean>;
formElRef: Ref<FormActionType>;
}
export async function useAutoFocus({ getSchema, getProps, formElRef, isInitedDefault }: UseAutoFocusContext) {
watchEffect(async () => {
if (unref(isInitedDefault) || !unref(getProps).autoFocusFirstItem) {
return;
}
await nextTick();
const schemas = unref(getSchema);
const formEl = unref(formElRef);
const el = (formEl as any)?.$el as HTMLElement;
if (!formEl || !el || !schemas || schemas.length === 0) {
return;
}
const firstItem = schemas[0];
// Only open when the first form item is input type
if (!firstItem.component.includes('Input')) {
return;
}
const inputEl = el.querySelector('.ant-row:first-child input') as Nullable<HTMLInputElement>;
if (!inputEl) return;
inputEl?.focus();
});
}

View File

@ -0,0 +1,11 @@
import type { ComponentType } from '../types/index';
import { tryOnUnmounted } from '@vueuse/core';
import { add, del } from '../componentMap';
import type { Component } from 'vue';
export function useComponentRegister(compName: ComponentType, comp: Component) {
add(compName, comp);
tryOnUnmounted(() => {
del(compName);
});
}

View File

@ -0,0 +1,159 @@
import type { FormProps, FormActionType, UseFormReturnType, FormSchema } from '../types/form';
import type { NamePath, ValidateOptions } from 'ant-design-vue/lib/form/interface';
import type { DynamicProps } from '/#/utils';
import { handleRangeValue } from '../utils/formUtils';
import { ref, onUnmounted, unref, nextTick, watch } from 'vue';
import { isProdMode } from '/@/utils/env';
import { error } from '/@/utils/log';
import { getDynamicProps, getValueType } from '/@/utils';
import { add } from "/@/components/Form/src/componentMap";
//集成online专用控件
import { OnlineSelectCascade, LinkTableCard, LinkTableSelect } from '@jeecg/online';
export declare type ValidateFields = (nameList?: NamePath[], options?: ValidateOptions) => Promise<Recordable>;
type Props = Partial<DynamicProps<FormProps>>;
export function useForm(props?: Props): UseFormReturnType {
const formRef = ref<Nullable<FormActionType>>(null);
const loadedRef = ref<Nullable<boolean>>(false);
//集成online专用控件
add("OnlineSelectCascade", OnlineSelectCascade)
add("LinkTableCard", LinkTableCard)
add("LinkTableSelect", LinkTableSelect)
async function getForm() {
const form = unref(formRef);
if (!form) {
error('The form instance has not been obtained, please make sure that the form has been rendered when performing the form operation!');
}
await nextTick();
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;
loadedRef.value = true;
watch(
() => props,
() => {
props && instance.setProps(getDynamicProps(props));
},
{
immediate: true,
deep: true,
}
);
}
const methods: FormActionType = {
scrollToField: async (name: NamePath, options?: ScrollOptions | undefined) => {
const form = await getForm();
form.scrollToField(name, options);
},
setProps: async (formProps: Partial<FormProps>) => {
const form = await getForm();
form.setProps(formProps);
},
updateSchema: async (data: Partial<FormSchema> | Partial<FormSchema>[]) => {
const form = await getForm();
form.updateSchema(data);
},
resetSchema: async (data: Partial<FormSchema> | Partial<FormSchema>[]) => {
const form = await getForm();
form.resetSchema(data);
},
clearValidate: async (name?: string | string[]) => {
const form = await getForm();
form.clearValidate(name);
},
resetFields: async () => {
getForm().then(async (form) => {
await form.resetFields();
});
},
removeSchemaByFiled: async (field: string | string[]) => {
unref(formRef)?.removeSchemaByFiled(field);
},
// TODO promisify
getFieldsValue: <T>() => {
//update-begin-author:taoyan date:2022-7-5 for: VUEN-1341【流程】编码方式 流程节点编辑表单时,填写数据报错 包括用户组件、部门组件、省市区
let values = unref(formRef)?.getFieldsValue() as T;
if(values){
Object.keys(values).map(key=>{
if (values[key] instanceof Array) {
// update-begin-author:sunjianlei date:20221205 for: 【issues/4330】判断如果是对象数组则不拼接
let isObject = typeof (values[key][0] || '') === 'object';
if (!isObject) {
values[key] = values[key].join(',');
}
// update-end-author:sunjianlei date:20221205 for: 【issues/4330】判断如果是对象数组则不拼接
}
});
}
return values;
//update-end-author:taoyan date:2022-7-5 for: VUEN-1341【流程】编码方式 流程节点编辑表单时,填写数据报错 包括用户组件、部门组件、省市区
},
setFieldsValue: async <T>(values: T) => {
const form = await getForm();
form.setFieldsValue<T>(values);
},
appendSchemaByField: async (schema: FormSchema, prefixField: string | undefined, first: boolean) => {
const form = await getForm();
form.appendSchemaByField(schema, prefixField, first);
},
submit: async (): Promise<any> => {
const form = await getForm();
return form.submit();
},
/**
* 表单验证并返回表单值
* @update:添加表单值转换逻辑
* @updateBy:zyf
* @updateDate:2021-09-02
*/
validate: async (nameList?: NamePath[]): Promise<Recordable> => {
const form = await getForm();
let getProps = props || form.getProps;
let values = form.validate(nameList).then((values) => {
for (let key in values) {
if (values[key] instanceof Array) {
let valueType = getValueType(getProps, key);
if (valueType === 'string') {
values[key] = values[key].join(',');
}
}
}
//--@updateBy-begin----author:liusq---date:20210916------for:处理区域事件字典信息------
return handleRangeValue(getProps, values);
//--@updateBy-end----author:liusq---date:20210916------for:处理区域事件字典信息------
});
return values;
},
validateFields: async (nameList?: NamePath[], options?: ValidateOptions): Promise<Recordable> => {
const form = await getForm();
return form.validateFields(nameList, options);
},
};
return [register, methods];
}

View File

@ -0,0 +1,17 @@
import type { InjectionKey } from 'vue';
import { createContext, useContext } from '/@/hooks/core/useContext';
export interface FormContextProps {
resetAction: () => Promise<void>;
submitAction: () => Promise<void>;
}
const key: InjectionKey<FormContextProps> = Symbol();
export function createFormContext(context: FormContextProps) {
return createContext<FormContextProps>(context, key);
}
export function useFormContext() {
return useContext<FormContextProps>(key);
}

View File

@ -0,0 +1,279 @@
import type { ComputedRef, Ref } from 'vue';
import type { FormProps, FormSchema, FormActionType } from '../types/form';
import type { NamePath, ValidateOptions } from 'ant-design-vue/lib/form/interface';
import { unref, toRaw } from 'vue';
import { isArray, isFunction, isObject, isString } from '/@/utils/is';
import { deepMerge, getValueType } from '/@/utils';
import { dateItemType, handleInputNumberValue, handleInputStringValue } from '../helper';
import { dateUtil } from '/@/utils/dateUtil';
import { cloneDeep, uniqBy } from 'lodash-es';
import { error } from '/@/utils/log';
interface UseFormActionContext {
emit: EmitType;
getProps: ComputedRef<FormProps>;
getSchema: ComputedRef<FormSchema[]>;
formModel: Recordable;
defaultValueRef: Ref<Recordable>;
formElRef: Ref<FormActionType>;
schemaRef: Ref<FormSchema[]>;
handleFormValues: Fn;
}
export function useFormEvents({
emit,
getProps,
formModel,
getSchema,
defaultValueRef,
formElRef,
schemaRef,
handleFormValues,
}: UseFormActionContext) {
async function resetFields(): Promise<void> {
const { resetFunc, submitOnReset } = unref(getProps);
resetFunc && isFunction(resetFunc) && (await resetFunc());
const formEl = unref(formElRef);
if (!formEl) return;
Object.keys(formModel).forEach((key) => {
formModel[key] = defaultValueRef.value[key];
});
clearValidate();
emit('reset', toRaw(formModel));
submitOnReset && handleSubmit();
}
/**
* @description: Set form value
*/
async function setFieldsValue(values: Recordable): Promise<void> {
const fields = unref(getSchema)
.map((item) => item.field)
.filter(Boolean);
const validKeys: string[] = [];
Object.keys(values).forEach((key) => {
const schema = unref(getSchema).find((item) => item.field === key);
let value = values[key];
//antd3升级后online表单时间控件选中值报js错 TypeError: Reflect.has called on non-object
if(!(values instanceof Object)){
return;
}
const hasKey = Reflect.has(values, key);
value = handleInputNumberValue(schema?.component, value);
// update-begin--author:liaozhiyang---date:20231226---for【QQYUN-7535】popup回填字段inputNumber组件验证错误
value = handleInputStringValue(schema?.component, value);
// update-end--author:liaozhiyang---date:20231226---for【QQYUN-7535】popup回填字段inputNumber组件验证错误
// 0| '' is allow
if (hasKey && fields.includes(key)) {
// time type
if (itemIsDateType(key)) {
if (Array.isArray(value)) {
const arr: any[] = [];
for (const ele of value) {
arr.push(ele ? dateUtil(ele) : null);
}
formModel[key] = arr;
} else {
const { componentProps } = schema || {};
let _props = componentProps as any;
if (typeof componentProps === 'function') {
_props = _props({ formModel });
}
formModel[key] = value ? (_props?.valueFormat ? value : dateUtil(value)) : null;
}
} else {
formModel[key] = value;
}
validKeys.push(key);
}
});
validateFields(validKeys).catch((_) => {});
}
/**
* @description: Delete based on field name
*/
async function removeSchemaByFiled(fields: string | string[]): Promise<void> {
const schemaList: FormSchema[] = cloneDeep(unref(getSchema));
if (!fields) {
return;
}
let fieldList: string[] = isString(fields) ? [fields] : fields;
if (isString(fields)) {
fieldList = [fields];
}
for (const field of fieldList) {
_removeSchemaByFiled(field, schemaList);
}
schemaRef.value = schemaList;
}
/**
* @description: Delete based on field name
*/
function _removeSchemaByFiled(field: string, schemaList: FormSchema[]): void {
if (isString(field)) {
const index = schemaList.findIndex((schema) => schema.field === field);
if (index !== -1) {
delete formModel[field];
schemaList.splice(index, 1);
}
}
}
/**
* @description: Insert after a certain field, if not insert the last
*/
async function appendSchemaByField(schema: FormSchema, prefixField?: string, first = false) {
const schemaList: FormSchema[] = cloneDeep(unref(getSchema));
const index = schemaList.findIndex((schema) => schema.field === prefixField);
const hasInList = schemaList.some((item) => item.field === prefixField || schema.field);
if (!hasInList) return;
if (!prefixField || index === -1 || first) {
first ? schemaList.unshift(schema) : schemaList.push(schema);
schemaRef.value = schemaList;
return;
}
if (index !== -1) {
schemaList.splice(index + 1, 0, schema);
}
schemaRef.value = schemaList;
}
async function resetSchema(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) => item.component === 'Divider' || (Reflect.has(item, 'field') && item.field));
if (!hasField) {
error('All children of the form Schema array that need to be updated must contain the `field` field');
return;
}
schemaRef.value = updateData as FormSchema[];
}
async 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) => item.component === 'Divider' || (Reflect.has(item, 'field') && item.field));
if (!hasField) {
error('All children of the form Schema array that need to be updated must contain the `field` field');
return;
}
const schema: FormSchema[] = [];
updateData.forEach((item) => {
unref(getSchema).forEach((val) => {
if (val.field === item.field) {
const newSchema = deepMerge(val, item);
schema.push(newSchema as FormSchema);
} else {
schema.push(val);
}
});
});
schemaRef.value = uniqBy(schema, 'field');
}
function getFieldsValue(): Recordable {
const formEl = unref(formElRef);
if (!formEl) return {};
return handleFormValues(toRaw(unref(formModel)));
}
/**
* @description: Is it time
*/
function itemIsDateType(key: string) {
return unref(getSchema).some((item) => {
return item.field === key ? dateItemType.includes(item.component) : false;
});
}
async function validateFields(nameList?: NamePath[] | undefined, options?: ValidateOptions) {
return unref(formElRef)?.validateFields(nameList, options);
}
async function validate(nameList?: NamePath[] | undefined) {
return await unref(formElRef)?.validate(nameList);
}
async function clearValidate(name?: string | string[]) {
await unref(formElRef)?.clearValidate(name);
}
async function scrollToField(name: NamePath, options?: ScrollOptions | undefined) {
await unref(formElRef)?.scrollToField(name, options);
}
/**
* @description: Form submission
*/
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 validate();
//update-begin---author:zhangdaihao Date:20140212 for[bug号]树机构调整------------
//--updateBy-begin----author:zyf---date:20211206------for:对查询表单提交的数组处理成字符串------
for (let key in values) {
if (values[key] instanceof Array) {
let valueType = getValueType(getProps, key);
if (valueType === 'string') {
values[key] = values[key].join(',');
}
}
}
//--updateBy-end----author:zyf---date:20211206------for:对查询表单提交的数组处理成字符串------
const res = handleFormValues(values);
emit('submit', res);
} catch (error) {
//update-begin-author:taoyan date:2022-11-4 for: 列表查询表单会触发校验错误导致重置失败,原因不明
emit('submit', {});
console.error('query form validate error, please ignore!', error)
//throw new Error(error);
//update-end-author:taoyan date:2022-11-4 for: 列表查询表单会触发校验错误导致重置失败,原因不明
}
}
return {
handleSubmit,
clearValidate,
validate,
validateFields,
getFieldsValue,
updateSchema,
resetSchema,
appendSchemaByField,
removeSchemaByFiled,
resetFields,
setFieldsValue,
scrollToField,
};
}

View File

@ -0,0 +1,59 @@
import { isArray, isFunction, isObject, isString, isNullOrUnDef } from '/@/utils/is';
import { unref } from 'vue';
import type { Ref, ComputedRef } from 'vue';
import type { FormProps, FormSchema } from '../types/form';
import dayjs from "dayjs";
import { set } from 'lodash-es';
import { handleRangeValue } from '/@/components/Form/src/utils/formUtils';
interface UseFormValuesContext {
defaultValueRef: Ref<any>;
getSchema: ComputedRef<FormSchema[]>;
getProps: ComputedRef<FormProps>;
formModel: Recordable;
}
export function useFormValues({ defaultValueRef, getSchema, formModel, getProps }: UseFormValuesContext) {
// Processing form values
function handleFormValues(values: Recordable) {
if (!isObject(values)) {
return {};
}
const res: Recordable = {};
for (const item of Object.entries(values)) {
let [, value] = item;
const [key] = item;
if (!key || (isArray(value) && value.length === 0) || isFunction(value)) {
continue;
}
const transformDateFunc = unref(getProps).transformDateFunc;
if (isObject(value)) {
value = transformDateFunc?.(value);
}
// 判断是否是dayjs实例
if (isArray(value) && dayjs.isDayjs(value[0]) && dayjs.isDayjs(value[1])) {
value = value.map((item) => transformDateFunc?.(item));
}
// Remove spaces
if (isString(value)) {
value = value.trim();
}
set(res, key, value);
}
return handleRangeValue(getProps, res);
}
function initDefault() {
const schemas = unref(getSchema);
const obj: Recordable = {};
schemas.forEach((item) => {
const { defaultValue } = item;
if (!isNullOrUnDef(defaultValue)) {
obj[item.field] = defaultValue;
formModel[item.field] = defaultValue;
}
});
defaultValueRef.value = obj;
}
return { handleFormValues, initDefault };
}

View File

@ -0,0 +1,44 @@
import type { Ref } from 'vue';
import type { FormProps, FormSchema } from '../types/form';
import { computed, unref } from 'vue';
import { isNumber } from '/@/utils/is';
export function useItemLabelWidth(schemaItemRef: Ref<FormSchema>, propsRef: Ref<FormProps>) {
return computed(() => {
const schemaItem = unref(schemaItemRef);
const { labelCol = {}, wrapperCol = {} } = schemaItem.itemProps || {};
const { labelWidth, disabledLabelWidth } = schemaItem;
const { labelWidth: globalLabelWidth, labelCol: globalLabelCol, wrapperCol: globWrapperCol,layout } = unref(propsRef);
// update-begin--author:sunjianlei---date:20211104---for: 禁用全局 labelWidth不自动设置 textAlign --------
if (disabledLabelWidth) {
return { labelCol, wrapperCol };
}
// update-begin--author:sunjianlei---date:20211104---for: 禁用全局 labelWidth不自动设置 textAlign --------
// If labelWidth is set globally, all items setting
if (!globalLabelWidth && !labelWidth && !globalLabelCol) {
labelCol.style = {
textAlign: 'left',
};
return { labelCol, wrapperCol };
}
let width = labelWidth || globalLabelWidth;
const col = { ...globalLabelCol, ...labelCol };
const wrapCol = { ...globWrapperCol, ...wrapperCol };
if (width) {
width = isNumber(width) ? `${width}px` : width;
}
return {
labelCol: { style: { width: width ? width : '100%' }, ...col },
wrapperCol: {
style: { width: layout === 'vertical' ? '100%' : `calc(100% - ${width})` },
...wrapCol,
},
};
});
}

View File

@ -0,0 +1,123 @@
<template>
<div v-for="(param, index) in dynamicInput.params" :key="index" style="display: flex">
<a-input placeholder="请输入参数key" v-model:value="param.label" style="width: 30%; margin-bottom: 5px" @input="emitChange" />
<a-input placeholder="请输入参数value" v-model:value="param.value" style="width: 30%; margin: 0 0 5px 5px" @input="emitChange" />
<MinusCircleOutlined
v-if="dynamicInput.params.length > min"
class="dynamic-delete-button"
@click="remove(param)"
style="width: 50px"
></MinusCircleOutlined>
</div>
<div>
<a-button type="dashed" style="width: 60%" @click="add">
<PlusOutlined />
新增
</a-button>
</div>
</template>
<script lang="ts">
import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons-vue';
import { defineComponent, reactive, ref, UnwrapRef, watchEffect } from 'vue';
import { propTypes } from '/@/utils/propTypes';
import { isEmpty } from '/@/utils/is';
import { tryOnMounted, tryOnUnmounted } from '@vueuse/core';
interface Params {
label: string;
value: string;
}
export default defineComponent({
name: 'JAddInput',
props: {
value: propTypes.string.def(''),
//update-begin---author:wangshuai ---date:20220516 for[VUEN-1043]系统编码规则,最后一个输入框不能删除------------
//自定义删除按钮多少才会显示
min: propTypes.integer.def(1),
//update-end---author:wangshuai ---date:20220516 for[VUEN-1043]系统编码规则,最后一个输入框不能删除--------------
},
emits: ['change', 'update:value'],
setup(props, { emit }) {
//input动态数据
const dynamicInput: UnwrapRef<{ params: Params[] }> = reactive({ params: [] });
//删除Input
const remove = (item: Params) => {
let index = dynamicInput.params.indexOf(item);
if (index !== -1) {
dynamicInput.params.splice(index, 1);
}
emitChange();
};
//新增Input
const add = () => {
dynamicInput.params.push({
label: '',
value: '',
});
emitChange();
};
//监听传入数据value
watchEffect(() => {
initVal();
});
/**
* 初始化数值
*/
function initVal() {
console.log('props.value', props.value);
dynamicInput.params = [];
if (props.value && props.value.indexOf('{') == 0) {
let jsonObj = JSON.parse(props.value);
Object.keys(jsonObj).forEach((key) => {
dynamicInput.params.push({ label: key, value: jsonObj[key] });
});
}
}
/**
* 数值改变
*/
function emitChange() {
let obj = {};
if (dynamicInput.params.length > 0) {
dynamicInput.params.forEach((item) => {
obj[item['label']] = item['value'];
});
}
emit('change', isEmpty(obj) ? '' : JSON.stringify(obj));
emit('update:value', isEmpty(obj) ? '' : JSON.stringify(obj));
}
return {
dynamicInput,
emitChange,
remove,
add,
};
},
components: {
MinusCircleOutlined,
PlusOutlined,
},
});
</script>
<style scoped>
.dynamic-delete-button {
cursor: pointer;
position: relative;
top: 4px;
font-size: 24px;
color: #999;
transition: all 0.3s;
}
.dynamic-delete-button:hover {
color: #777;
}
.dynamic-delete-button[disabled] {
cursor: not-allowed;
opacity: 0.5;
}
</style>

View File

@ -0,0 +1,136 @@
<template>
<Cascader v-bind="attrs" :value="cascaderValue" :options="getOptions" @change="handleChange" />
</template>
<script lang="ts">
import { defineComponent, PropType, ref, reactive, watchEffect, computed, unref, watch, onMounted } from 'vue';
import { Cascader } from 'ant-design-vue';
import { provinceAndCityData, regionData, provinceAndCityDataPlus, regionDataPlus } from '../../utils/areaDataUtil';
import { useRuleFormItem } from '/@/hooks/component/useFormItem';
import { propTypes } from '/@/utils/propTypes';
import { useAttrs } from '/@/hooks/core/useAttrs';
import { isArray } from '/@/utils/is';
export default defineComponent({
name: 'JAreaLinkage',
components: {
Cascader,
},
inheritAttrs: false,
props: {
value: propTypes.oneOfType([propTypes.object, propTypes.array, propTypes.string]),
//是否显示区县
showArea: propTypes.bool.def(true),
//是否是全部
showAll: propTypes.bool.def(false),
// 存储数据
saveCode: propTypes.oneOf(['province', 'city', 'region', 'all']).def('all'),
},
emits: ['options-change', 'change', 'update:value'],
setup(props, { emit, refs }) {
const emitData = ref<any[]>([]);
const attrs = useAttrs();
// const [state] = useRuleFormItem(props, 'value', 'change', emitData);
const cascaderValue = ref([]);
const getOptions = computed(() => {
if (props.showArea && props.showAll) {
return regionDataPlus;
}
if (props.showArea && !props.showAll) {
return regionData;
}
if (!props.showArea && !props.showAll) {
return provinceAndCityData;
}
if (!props.showArea && props.showAll) {
return provinceAndCityDataPlus;
}
});
/**
* 监听value变化
*/
watchEffect(() => {
// update-begin--author:liaozhiyang---date:20240612--for【TV360X-1223】省市区换新组件
if (props.value) {
initValue();
} else {
cascaderValue.value = [];
}
// update-end--author:liaozhiyang---date:20240612---for【TV360X-1223】省市区换新组件
});
/**
* 将字符串值转化为数组
*/
function initValue() {
let value = props.value ? props.value : [];
// update-begin--author:liaozhiyang---date:20240607---for【TV360X-501】省市区换新组件
if (value && typeof value === 'string' && value != 'null' && value != 'undefined') {
const arr = value.split(',');
cascaderValue.value = transform(arr);
} else if (isArray(value)) {
if (value.length) {
cascaderValue.value = transform(value);
} else {
cascaderValue.value = [];
}
}
// update-end--author:liaozhiyang---date:20240607---for【TV360X-501】省市区换新组件
}
function transform(arr) {
let result: any = [];
if (props.saveCode === 'region') {
// 81 香港、82 澳门
const regionCode = arr[0];
if (['82', '81'].includes(regionCode.substring(0, 2))) {
result = [`${regionCode.substring(0, 2)}0000`, regionCode];
} else {
result = [`${regionCode.substring(0, 2)}0000`, `${regionCode.substring(0, 2)}${regionCode.substring(2, 4)}00`, regionCode];
}
} else if (props.saveCode === 'city') {
const cityCode = arr[0];
result = [`${cityCode.substring(0, 2)}0000`, cityCode];
} else if (props.saveCode === 'province') {
const provinceCode = arr[0];
result = [provinceCode];
} else {
result = arr;
}
return result;
}
function handleChange(arr, ...args) {
// update-begin--author:liaozhiyang---date:20240607---for【TV360X-501】省市区换新组件
if (arr?.length) {
let result: any = [];
if (props.saveCode === 'region') {
// 可能只有两位(选择香港时,只有省区)
result = [arr[arr.length - 1]];
} else if (props.saveCode === 'city') {
result = [arr[1]];
} else if (props.saveCode === 'province') {
result = [arr[0]];
} else {
result = arr;
}
emit('change', result);
emit('update:value', result);
} else {
emit('change', arr);
emit('update:value', arr);
}
// update-end--author:liaozhiyang---date:20240607---for【TV360X-501】省市区换新组件
// emitData.value = args;
//update-begin-author:taoyan date:2022-6-27 for: VUEN-1424【vue3】树表、单表、jvxe、erp 、内嵌子表省市县 选择不上
// 上面改的v-model:value导致选中数据没有显示
// state.value = result;
//update-end-author:taoyan date:2022-6-27 for: VUEN-1424【vue3】树表、单表、jvxe、erp 、内嵌子表省市县 选择不上
}
return {
cascaderValue,
attrs,
regionData,
getOptions,
handleChange,
};
},
});
</script>

View File

@ -0,0 +1,168 @@
<template>
<a-form-item-rest>
<div class="area-select">
<!--省份-->
<a-select v-model:value="province" @change="proChange" allowClear :disabled="disabled">
<template v-for="item in provinceOptions" :key="`${item.value}`">
<a-select-option :value="item.value">{{ item.label }}</a-select-option>
</template>
</a-select>
<!--城市-->
<a-select v-if="level >= 2" v-model:value="city" @change="cityChange" :disabled="disabled">
<template v-for="item in cityOptions" :key="`${item.value}`">
<a-select-option :value="item.value">{{ item.label }}</a-select-option>
</template>
</a-select>
<!--地区-->
<a-select v-if="level >= 3" v-model:value="area" @change="areaChange" :disabled="disabled">
<template v-for="item in areaOptions" :key="`${item.value}`">
<a-select-option :value="item.value">{{ item.label }}</a-select-option>
</template>
</a-select>
</div>
</a-form-item-rest>
</template>
<script lang="ts">
import { defineComponent, PropType, ref, reactive, watchEffect, computed, unref, watch, onMounted, onUnmounted, toRefs } from 'vue';
import { propTypes } from '/@/utils/propTypes';
import { useRuleFormItem } from '/@/hooks/component/useFormItem';
import { provinceOptions, getDataByCode, getRealCode } from '../../utils/areaDataUtil';
export default defineComponent({
name: 'JAreaSelect',
props: {
value: [Array, String],
province: [String],
city: [String],
area: [String],
level: propTypes.number.def(3),
disabled: propTypes.bool.def(false),
codeField: propTypes.string,
size: propTypes.string,
placeholder: propTypes.string,
formValues: propTypes.any,
allowClear: propTypes.bool.def(false),
getPopupContainer: {
type: Function,
default: (node) => node?.parentNode,
},
},
emits: ['change', 'update:value','update:area','update:city','update:province'],
setup(props, { emit, refs }) {
const emitData = ref<any[]>([]);
//下拉框的选择值
const pca = reactive({
province: '',
city: '',
area: '',
});
//表单值
const [state] = useRuleFormItem(props, 'value', 'change', emitData);
//城市下拉框的选项
const cityOptions = computed(() => {
return pca.province ? getDataByCode(pca.province) : [];
});
//地区下拉框的选项
const areaOptions = computed(() => {
return pca.city ? getDataByCode(pca.city) : [];
});
/**
* 监听props值
*/
watchEffect(() => {
props && initValue();
});
/**
* 监听组件值变化
*/
watch(pca, (newVal) => {
if (!props.value) {
emit('update:province', pca.province);
emit('update:city', pca.city);
emit('update:area', pca.area);
}
});
/**
* 数据初始化
*/
function initValue() {
if (props.value) {
//传参是数组的情况下的处理
if (Array.isArray(props.value)) {
pca.province = props.value[0];
pca.city = props.value[1] ? props.value[1] : '';
pca.area = props.value[2] ? props.value[2] : '';
} else {
//传参是数值
let valueArr = getRealCode(props.value, props.level);
if (valueArr) {
pca.province = valueArr[0];
pca.city = props.level >= 2 && valueArr[1] ? valueArr[1] : '';
pca.area = props.level >= 3 && valueArr[2] ? valueArr[2] : '';
}
}
} else {
//绑定三个数据的情况
pca.province = props.province ? props.province : '';
pca.city = props.city ? props.city : '';
pca.area = props.area ? props.area : '';
}
}
/**
* 省份change事件
*/
function proChange(val) {
pca.city = val && getDataByCode(val)[0]?.value;
pca.area = pca.city && getDataByCode(pca.city)[0]?.value;
state.value = props.level <= 1 ? val : props.level <= 2 ? pca.city : pca.area;
emit('update:value', unref(state));
}
/**
* 城市change事件
*/
function cityChange(val) {
pca.area = val && getDataByCode(val)[0]?.value;
state.value = props.level <= 2 ? val : pca.area;
emit('update:value', unref(state));
}
/**
* 区域change事件
*/
function areaChange(val) {
state.value = val;
emit('update:value', unref(state));
}
return {
...toRefs(pca),
provinceOptions,
cityOptions,
areaOptions,
proChange,
cityChange,
areaChange,
};
},
});
</script>
<style lang="less" scoped>
.area-select {
width: 100%;
/* update-begin-author:taoyan date:2023-2-18 for: QQYUN-4292【online表单】高级查询 2.省市县样式问题 */
/* display: flex;*/
.ant-select {
width: calc(33.3% - 7px)
}
/* update-end-author:taoyan date:2023-2-18 for: QQYUN-4292【online表单】高级查询 2.省市县样式问题 */
.ant-select:not(:first-child) {
margin-left: 10px;
}
}
</style>

View File

@ -0,0 +1,264 @@
<!--下拉树-->
<template>
<a-tree-select
allowClear
labelInValue
style="width: 100%"
:disabled="disabled"
:dropdownStyle="{ maxHeight: '400px', overflow: 'auto' }"
:placeholder="placeholder"
:loadData="asyncLoadTreeData"
:value="treeValue"
:treeData="treeData"
:multiple="multiple"
@change="onChange"
>
</a-tree-select>
</template>
<script lang="ts">
import { defineComponent, ref, unref, watch, nextTick } from 'vue';
import { useRuleFormItem } from '/@/hooks/component/useFormItem';
import { propTypes } from '/@/utils/propTypes';
import { useAttrs } from '/@/hooks/core/useAttrs';
import { loadDictItem, loadTreeData } from '/@/api/common/api';
import { useMessage } from '/@/hooks/web/useMessage';
const { createMessage, createErrorModal } = useMessage();
export default defineComponent({
name: 'JCategorySelect',
components: {},
inheritAttrs: false,
props: {
value: propTypes.oneOfType([propTypes.string, propTypes.array]),
placeholder: {
type: String,
default: '请选择',
required: false,
},
disabled: {
type: Boolean,
default: false,
required: false,
},
condition: {
type: String,
default: '',
required: false,
},
// 是否支持多选
multiple: {
type: [Boolean, String],
default: false,
},
loadTriggleChange: {
type: Boolean,
default: false,
required: false,
},
pid: {
type: String,
default: '',
required: false,
},
pcode: {
type: String,
default: '',
required: false,
},
back: {
type: String,
default: '',
required: false,
},
},
emits: ['options-change', 'change', 'update:value'],
setup(props, { emit, refs }) {
console.info(props);
const emitData = ref<any[]>([]);
const treeData = ref<any[]>([]);
const treeValue = ref();
const attrs = useAttrs();
const [state, , , formItemContext] = useRuleFormItem(props, 'value', 'change', emitData);
watch(
() => props.value,
() => {
loadItemByCode();
},
{ deep: true }
);
watch(
() => props.pcode,
() => {
loadRoot();
},
{ deep: true, immediate: true }
);
function loadRoot() {
let param = {
pid: props.pid,
pcode: !props.pcode ? '0' : props.pcode,
condition: props.condition,
};
console.info(param);
loadTreeData(param).then((res) => {
if(res && res.length>0){
for (let i of res) {
i.value = i.key;
if (i.leaf == false) {
i.isLeaf = false;
} else if (i.leaf == true) {
i.isLeaf = true;
}
}
treeData.value = res;
}
});
}
function loadItemByCode() {
if (!props.value || props.value == '0') {
if(props.multiple){
treeValue.value = [];
}else{
treeValue.value = { value: null, label: null };
}
} else {
loadDictItem({ ids: props.value }).then((res) => {
let values = props.value.split(',');
treeValue.value = res.map((item, index) => ({
key: values[index],
value: values[index],
label: item,
}));
if(!props.multiple){
treeValue.value = treeValue.value[0];
}
onLoadTriggleChange(res[0]);
});
}
}
function onLoadTriggleChange(text) {
//只有单选才会触发
if (!props.multiple && props.loadTriggleChange) {
backValue(props.value, text);
}
}
function backValue(value, label) {
let obj = {};
if (props.back) {
obj[props.back] = label;
}
emit('change', value, obj);
emit("update:value",value)
}
function asyncLoadTreeData(treeNode) {
let dataRef = treeNode.dataRef;
return new Promise<void>((resolve) => {
if (treeNode.children && treeNode.children.length > 0) {
resolve();
return;
}
let pid = dataRef.key;
let param = {
pid: pid,
condition: props.condition,
};
loadTreeData(param).then((res) => {
if (res) {
for (let i of res) {
i.value = i.key;
if (i.leaf == false) {
i.isLeaf = false;
} else if (i.leaf == true) {
i.isLeaf = true;
}
}
addChildren(pid, res, treeData.value);
resolve();
}
});
});
}
function addChildren(pid, children, treeArray) {
if (treeArray && treeArray.length > 0) {
for (let item of treeArray) {
if (item.key == pid) {
if (!children || children.length == 0) {
item.isLeaf = true;
} else {
item.children = children;
}
break;
} else {
addChildren(pid, children, item.children);
}
}
}
}
function onChange(value) {
if (!value) {
emit('change', '');
treeValue.value = '';
emit("update:value",'')
} else if (Array.isArray(value)) {
let labels = [];
let values = value.map((item) => {
labels.push(item.label);
return item.value;
});
backValue(values.join(','), labels.join(','));
treeValue.value = value;
} else {
backValue(value.value, value.label);
treeValue.value = value;
}
// update-begin--author:liaozhiyang---date:20240429---for【QQYUN-9110】组件有值校验没消失
nextTick(() => {
formItemContext?.onFieldChange();
});
// update-end--author:liaozhiyang---date:20240429---for【QQYUN-9110】组件有值校验没消失
}
function getCurrTreeData() {
return treeData;
}
function validateProp() {
let mycondition = props.condition;
return new Promise((resolve, reject) => {
if (!mycondition) {
resolve();
} else {
try {
let test = JSON.parse(mycondition);
if (typeof test == 'object' && test) {
resolve();
} else {
createMessage.error('组件JTreeSelect-condition传值有误需要一个json字符串!');
reject();
}
} catch (e) {
createMessage.error('组件JTreeSelect-condition传值有误需要一个json字符串!');
reject();
}
}
});
}
return {
state,
attrs,
onChange,
treeData,
treeValue,
asyncLoadTreeData,
};
},
});
</script>

View File

@ -0,0 +1,119 @@
<template>
<a-checkbox-group v-bind="attrs" v-model:value="checkboxArray" :options="checkOptions" @change="handleChange">
<template #label="{label, value}">
<span :class="[useDicColor && getDicColor(value) ? 'colorText' : '']" :style="{ backgroundColor: `${getDicColor(value)}` }">{{ label }}</span>
</template>
</a-checkbox-group>
</template>
<script lang="ts">
import { defineComponent, computed, watch, watchEffect, ref, unref } from 'vue';
import { propTypes } from '/@/utils/propTypes';
import { useAttrs } from '/@/hooks/core/useAttrs';
import { initDictOptions } from '/@/utils/dict/index';
export default defineComponent({
name: 'JCheckbox',
props: {
value:propTypes.oneOfType([propTypes.string, propTypes.number]),
dictCode: propTypes.string,
useDicColor: propTypes.bool.def(false),
options: {
type: Array,
default: () => [],
},
},
emits: ['change', 'update:value'],
setup(props, { emit }) {
const attrs = useAttrs();
//checkbox选项
const checkOptions = ref<any[]>([]);
//checkbox数值
const checkboxArray = ref<any[]>([]);
/**
* 监听value
*/
watchEffect(() => {
//update-begin-author:taoyan date:2022-7-4 for:issues/I5E7YX AUTO在线表单进入功能测试之后一直卡在功能测试界面
let temp = props.value;
if(!temp && temp!==0){
checkboxArray.value = []
}else{
temp = temp + '';
checkboxArray.value = temp.split(',')
}
//update-end-author:taoyan date:2022-7-4 for:issues/I5E7YX AUTO在线表单进入功能测试之后一直卡在功能测试界面
//update-begin-author:taoyan date:20220401 for: 调用表单的 resetFields不会清空当前信息界面显示上一次的数据
if (props.value === '' || props.value === undefined) {
checkboxArray.value = [];
}
//update-end-author:taoyan date:20220401 for: 调用表单的 resetFields不会清空当前信息界面显示上一次的数据
});
/**
* 监听字典code
*/
watchEffect(() => {
props && initOptions();
});
/**
* 初始化选项
*/
async function initOptions() {
//根据options, 初始化选项
if (props.options && props.options.length > 0) {
checkOptions.value = props.options;
return;
}
//根据字典Code, 初始化选项
if (props.dictCode) {
const dictData = await initDictOptions(props.dictCode);
checkOptions.value = dictData.reduce((prev, next) => {
if (next) {
const value = next['value'];
prev.push({
label: next['text'],
value: value,
color: next['color'],
});
}
return prev;
}, []);
}
}
/**
* change事件
* @param $event
*/
function handleChange($event) {
emit('update:value', $event.join(','));
emit('change', $event.join(','));
}
const getDicColor = (value) => {
if (props.useDicColor) {
const findItem = checkOptions.value.find((item) => item.value == value);
if (findItem) {
return findItem.color;
}
}
return null;
};
return { checkboxArray, checkOptions, attrs, handleChange, getDicColor };
},
});
</script>
<style lang="less" scoped>
// update-begin--author:liaozhiyang---date:20230110---for【QQYUN-7799】字典组件原生组件除外加上颜色配置
.colorText {
display: inline-block;
height: 20px;
line-height: 20px;
padding: 0 6px;
border-radius: 8px;
background-color: red;
color: #fff;
font-size: 12px;
}
// update-begin--author:liaozhiyang---date:20230110---for【QQYUN-7799】字典组件原生组件除外加上颜色配置
</style>

View File

@ -0,0 +1,375 @@
<template>
<div ref="containerRef" v-bind="boxBindProps">
<!-- 全屏按钮 -->
<a-icon v-if="fullScreen" class="full-screen-icon" :type="fullScreenIcon" @click="onToggleFullScreen" />
<textarea ref="textarea" v-bind="getBindValue"></textarea>
</div>
</template>
<script lang="ts">
import { defineComponent, onMounted, reactive, ref, watch, unref, computed } from 'vue';
import { propTypes } from '/@/utils/propTypes';
import { useRuleFormItem } from '/@/hooks/component/useFormItem';
// 引入全局实例
import _CodeMirror, { EditorFromTextArea } from 'codemirror';
// 核心样式
import 'codemirror/lib/codemirror.css';
// 引入主题后还需要在 options 中指定主题才会生效
import 'codemirror/theme/idea.css';
// 需要引入具体的语法高亮库才会有对应的语法高亮效果
import 'codemirror/mode/javascript/javascript.js';
import 'codemirror/mode/css/css.js';
import 'codemirror/mode/xml/xml.js';
import 'codemirror/mode/clike/clike.js';
import 'codemirror/mode/markdown/markdown.js';
import 'codemirror/mode/python/python.js';
import 'codemirror/mode/r/r.js';
import 'codemirror/mode/shell/shell.js';
import 'codemirror/mode/sql/sql.js';
import 'codemirror/mode/swift/swift.js';
import 'codemirror/mode/vue/vue.js';
// 折叠资源引入:开始
import 'codemirror/addon/fold/foldgutter.css';
import 'codemirror/addon/fold/foldcode.js';
import 'codemirror/addon/fold/brace-fold.js';
import 'codemirror/addon/fold/comment-fold.js';
import 'codemirror/addon/fold/indent-fold.js';
import 'codemirror/addon/fold/foldgutter.js';
// 折叠资源引入:结束
//光标行背景高亮配置里面也需要styleActiveLine设置为true
import 'codemirror/addon/selection/active-line.js';
// 支持代码自动补全
import 'codemirror/addon/hint/show-hint.css';
import 'codemirror/addon/hint/show-hint.js';
import 'codemirror/addon/hint/anyword-hint.js';
// 匹配括号
import 'codemirror/addon/edit/matchbrackets';
import { useAttrs } from '/@/hooks/core/useAttrs';
import { useDesign } from '/@/hooks/web/useDesign';
import { isJsonObjectString } from '/@/utils/is.ts';
// 代码提示
import { useCodeHinting } from '../hooks/useCodeHinting';
import { useRootSetting } from '/@/hooks/setting/useRootSetting';
import { ThemeEnum } from '/@/enums/appEnum';
export default defineComponent({
name: 'JCodeEditor',
// 不将 attrs 的属性绑定到 html 标签上
inheritAttrs: false,
components: {},
props: {
value: propTypes.string.def(''),
height: propTypes.string.def('auto'),
disabled: propTypes.bool.def(false),
// 是否显示全屏按钮
fullScreen: propTypes.bool.def(false),
// 全屏以后的z-index
zIndex: propTypes.any.def(1500),
theme: propTypes.string.def('idea'),
language: propTypes.string.def(''),
// 代码提示
keywords: propTypes.array.def([]),
},
emits: ['change', 'update:value'],
setup(props, { emit }) {
const { getDarkMode } = useRootSetting();
const containerRef = ref(null);
const { prefixCls } = useDesign('code-editer');
const CodeMirror = window.CodeMirror || _CodeMirror;
const emitData = ref<object>();
//表单值
const [state] = useRuleFormItem(props, 'value', 'change', emitData);
const textarea = ref<HTMLTextAreaElement>();
let coder: Nullable<EditorFromTextArea> = null;
const attrs = useAttrs();
const height = ref(props.height);
const options = reactive({
// 缩进格式
tabSize: 2,
// 主题,对应主题库 JS 需要提前引入
// update-begin--author:liaozhiyang---date:20240327---for【QQYUN-8639】暗黑主题适配
theme: getDarkMode.value == ThemeEnum.DARK ? 'monokai' : props.theme,
// update-end--author:liaozhiyang---date:20240327---for【QQYUN-8639】暗黑主题适配
smartIndent: true, // 是否智能缩进
// 显示行号
lineNumbers: true,
line: true,
// 启用代码折叠相关功能:开始
foldGutter: true,
lineWrapping: true,
gutters: ['CodeMirror-linenumbers', 'CodeMirror-foldgutter', 'CodeMirror-lint-markers'],
// 启用代码折叠相关功能:结束
// 光标行高亮
styleActiveLine: true,
// update-begin--author:liaozhiyang---date:20231201---for【issues/869】JCodeEditor组件初始化时没有设置mode
mode: props.language,
// update-begin--author:liaozhiyang---date:20231201---for【issues/869】JCodeEditor组件初始化时没有设置mode
// update-begin--author:liaozhiyang---date:20240603---for【TV360X-898】代码生成之后的预览改成只读
readOnly: props.disabled,
// update-end--author:liaozhiyang---date:20240603---for【TV360X-898】代码生成之后的预览改成只读
// 匹配括号
matchBrackets: true,
extraKeys: {
// Tab: function autoFormat(editor) {
// //var totalLines = editor.lineCount();
// //editor.autoFormatRange({line:0, ch:0}, {line:totalLines});
// setValue(innerValue, false);
// },
'Cmd-/': (cm) => comment(cm),
'Ctrl-/': (cm) => comment(cm),
},
});
// 内部存储值,初始为 props.value
let innerValue = props.value ?? '';
// 全屏状态
const isFullScreen = ref(false);
const fullScreenIcon = computed(() => (isFullScreen.value ? 'fullscreen-exit' : 'fullscreen'));
// 外部盒子参数
const boxBindProps = computed(() => {
let _props = {
class: [
prefixCls,
'full-screen-parent',
'auto-height',
{
'full-screen': isFullScreen.value,
},
],
style: {},
};
if (isFullScreen.value) {
_props.style['z-index'] = props.zIndex;
}
return _props;
});
// update-begin--author:liaozhiyang---date:20230904---for【QQYUN-5955】online js增强加入代码提示
const { codeHintingMount, codeHintingRegistry } = useCodeHinting(CodeMirror, props.keywords, props.language);
codeHintingRegistry();
// update-end--author:liaozhiyang---date:20230904---for【QQYUN-5955】online js增强加入代码提示
/**
* 监听组件值
*/
watch(
() => props.value,
() => {
if (innerValue != props.value) {
setValue(props.value, false);
}
}
);
onMounted(() => {
initialize();
// update-begin--author:liaozhiyang---date:20240318---for【QQYUN-8473】代码编辑器首次加载会有遮挡
setTimeout(() => {
refresh();
}, 150);
// update-end--author:liaozhiyang---date:20240318---for【QQYUN-8473】代码编辑器首次加载会有遮挡
});
/**
* 组件赋值
* @param value
* @param trigger 是否触发 change 事件
*/
function setValue(value: string, trigger = true) {
if (value && isJsonObjectString(value)) {
value = JSON.stringify(JSON.parse(value), null, 2);
}
coder?.setValue(value ?? '');
innerValue = value;
trigger && emitChange(innerValue);
// update-begin--author:liaozhiyang---date:20240510---for【QQYUN-9231】代码编辑器有遮挡
setTimeout(() => {
refresh();
// 再次刷下防止小概率下遮挡问题
setTimeout(() => {
refresh();
}, 600);
}, 400);
// update-end--author:liaozhiyang---date:20240510---for【QQYUN-9231】代码编辑器有遮挡
}
//编辑器值修改事件
function onChange(obj) {
let value = obj.getValue();
innerValue = value || '';
if (props.value != innerValue) {
emitChange(innerValue);
}
}
function emitChange(value) {
emit('change', value);
emit('update:value', value);
}
//组件初始化
function initialize() {
coder = CodeMirror.fromTextArea(textarea.value!, options);
//绑定值修改事件
coder.on('change', onChange);
// 初始化成功时赋值一次
setValue(innerValue, false);
// update-begin--author:liaozhiyang---date:20230904---for【QQYUN-5955】online js增强加入代码提示
codeHintingMount(coder);
// update-end--author:liaozhiyang---date:20230904---for【QQYUN-5955】online js增强加入代码提示
}
// 切换全屏状态
function onToggleFullScreen() {
isFullScreen.value = !isFullScreen.value;
}
//update-begin-author:taoyan date:2022-5-9 for: codeEditor禁用功能
watch(
() => props.disabled,
(val) => {
if (coder) {
coder.setOption('readOnly', val);
}
}
);
//update-end-author:taoyan date:2022-5-9 for: codeEditor禁用功能
// 支持动态设置语言
watch(()=>props.language, (val)=>{
if(val && coder){
coder.setOption('mode', val);
}
});
const getBindValue = Object.assign({}, unref(props), unref(attrs));
//update-begin-author:taoyan date:2022-10-18 for: VUEN-2480【严重bug】online vue3测试的问题 8、online js增强样式问题
function refresh(){
if(coder){
coder.refresh();
}
}
//update-end-author:taoyan date:2022-10-18 for: VUEN-2480【严重bug】online vue3测试的问题 8、online js增强样式问题
/**
* 2024-04-01
* liaozhiyang
* 代码批量注释
*/
function comment(cm) {
var selection = cm.getSelection();
var start = cm.getCursor('start');
var end = cm.getCursor('end');
var isCommented = selection.startsWith('//');
if (isCommented) {
// 如果已经被注释,取消注释
cm.replaceRange(selection.replace(/\n\/\/\s/g, '\n').replace(/^\/\/\s/, ''), start, end);
} else {
// 添加注释
cm.replaceRange('// ' + selection.replace(/\n(?=.)/g, '\n// '), start, end);
}
}
return {
state,
textarea,
boxBindProps,
getBindValue,
setValue,
isFullScreen,
fullScreenIcon,
onToggleFullScreen,
refresh,
containerRef,
};
},
});
</script>
<style lang="less">
//noinspection LessUnresolvedVariable
@prefix-cls: ~'@{namespace}-code-editer';
.@{prefix-cls} {
&.auto-height {
.CodeMirror {
height: v-bind(height) !important;
min-height: 100px;
}
}
/* 全屏样式 */
&.full-screen-parent {
position: relative;
.full-screen-icon {
opacity: 0;
color: black;
width: 20px;
height: 20px;
line-height: 24px;
background-color: white;
position: absolute;
top: 2px;
right: 2px;
z-index: 9;
cursor: pointer;
transition: opacity 0.3s;
padding: 2px 0 0 1.5px;
}
&:hover {
.full-screen-icon {
opacity: 1;
&:hover {
background-color: rgba(255, 255, 255, 0.88);
}
}
}
&.full-screen {
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
padding: 8px;
background-color: #f5f5f5;
.full-screen-icon {
top: 12px;
right: 12px;
}
.full-screen-child,
.CodeMirror {
height: 100%;
max-height: 100%;
min-height: 100%;
}
}
.full-screen-child {
height: 100%;
}
}
/** VUEN-2344【vue3】这个样式有问题是不是加个边框 */
.CodeMirror{
border: 1px solid #ddd;
}
}
.CodeMirror-hints.idea,
.CodeMirror-hints.monokai {
z-index: 1001;
max-width: 600px;
max-height: 300px;
}
// update-begin--author:liaozhiyang---date:20240327---for【QQYUN-8639】暗黑主题适配
html[data-theme='dark'] {
.@{prefix-cls} {
.CodeMirror {
border: 1px solid #3a3a3a;
}
}
}
// update-end--author:liaozhiyang---date:20240327---for【QQYUN-8639】暗黑主题适配
</style>

View File

@ -0,0 +1,237 @@
<template>
<a-radio-group v-if="compType === CompTypeEnum.Radio" v-bind="attrs" v-model:value="state" @change="handleChangeRadio">
<template v-for="item in dictOptions" :key="`${item.value}`">
<a-radio :value="item.value">
<span :class="[useDicColor && item.color ? 'colorText' : '']" :style="{ backgroundColor: `${useDicColor && item.color}` }">
{{ item.label }}
</span>
</a-radio>
</template>
</a-radio-group>
<a-radio-group
v-else-if="compType === CompTypeEnum.RadioButton"
v-bind="attrs"
v-model:value="state"
buttonStyle="solid"
@change="handleChangeRadio"
>
<template v-for="item in dictOptions" :key="`${item.value}`">
<a-radio-button :value="item.value">
{{ item.label }}
</a-radio-button>
</template>
</a-radio-group>
<template v-else-if="compType === CompTypeEnum.Select">
<!-- 显示加载效果 -->
<a-input v-if="loadingEcho" readOnly placeholder="加载中">
<template #prefix>
<LoadingOutlined />
</template>
</a-input>
<a-select
v-else
:placeholder="placeholder"
v-bind="attrs"
v-model:value="state"
:filterOption="handleFilterOption"
:getPopupContainer="getPopupContainer"
:style="style"
@change="handleChange"
>
<a-select-option v-if="showChooseOption" :value="null">请选择…</a-select-option>
<template v-for="item in dictOptions" :key="`${item.value}`">
<a-select-option :value="item.value">
<span
:class="[useDicColor && item.color ? 'colorText' : '']"
:style="{ backgroundColor: `${useDicColor && item.color}` }"
:title="item.label"
>
{{ item.label }}
</span>
</a-select-option>
</template>
</a-select>
</template>
</template>
<script lang="ts">
import { defineComponent, PropType, ref, reactive, watchEffect, computed, unref, watch, onMounted, nextTick } from 'vue';
import { propTypes } from '/@/utils/propTypes';
import { useAttrs } from '/@/hooks/core/useAttrs';
import { initDictOptions } from '/@/utils/dict';
import { get, omit } from 'lodash-es';
import { useRuleFormItem } from '/@/hooks/component/useFormItem';
import { CompTypeEnum } from '/@/enums/CompTypeEnum';
import { LoadingOutlined } from '@ant-design/icons-vue';
export default defineComponent({
name: 'JDictSelectTag',
inheritAttrs: false,
components: { LoadingOutlined },
props: {
value: propTypes.oneOfType([propTypes.string, propTypes.number, propTypes.array]),
dictCode: propTypes.string,
type: propTypes.string,
placeholder: propTypes.string,
stringToNumber: propTypes.bool,
useDicColor: propTypes.bool.def(false),
getPopupContainer: {
type: Function,
default: (node) => node?.parentNode,
},
// 是否显示【请选择】选项
showChooseOption: propTypes.bool.def(true),
// 下拉项-online使用
options: {
type: Array,
default: [],
required: false,
},
style: propTypes.any,
},
emits: ['options-change', 'change','update:value'],
setup(props, { emit, refs }) {
const dictOptions = ref<any[]>([]);
const attrs = useAttrs();
const [state, , , formItemContext] = useRuleFormItem(props, 'value', 'change');
const getBindValue = Object.assign({}, unref(props), unref(attrs));
// 是否正在加载回显数据
const loadingEcho = ref<boolean>(false);
// 是否是首次加载回显,只有首次加载,才会显示 loading
let isFirstLoadEcho = true;
//组件类型
const compType = computed(() => {
return !props.type || props.type === 'list' ? 'select' : props.type;
});
/**
* 监听字典code
*/
watchEffect(() => {
if (props.dictCode) {
loadingEcho.value = isFirstLoadEcho;
isFirstLoadEcho = false;
initDictData().finally(() => {
loadingEcho.value = isFirstLoadEcho;
});
}
//update-begin-author:taoyan date: 如果没有提供dictCode 可以走options的配置--
if (!props.dictCode) {
dictOptions.value = props.options;
}
//update-end-author:taoyan date: 如果没有提供dictCode 可以走options的配置--
});
//update-begin-author:taoyan date:20220404 for: 使用useRuleFormItem定义的value会有一个问题如果不是操作设置的值而是代码设置的控件值而不能触发change事件
// 此处添加空值的change事件,即当组件调用地代码设置value为''也能触发change事件
watch(
() => props.value,
() => {
if (props.value === '') {
emit('change', '');
nextTick(() => formItemContext.onFieldChange());
}
}
);
//update-end-author:taoyan date:20220404 for: 使用useRuleFormItem定义的value会有一个问题如果不是操作设置的值而是代码设置的控件值而不能触发change事件
async function initDictData() {
let { dictCode, stringToNumber } = props;
//根据字典Code, 初始化字典数组
const dictData = await initDictOptions(dictCode);
dictOptions.value = dictData.reduce((prev, next) => {
if (next) {
const value = next['value'];
prev.push({
label: next['text'] || next['label'],
value: stringToNumber ? +value : value,
color: next['color'],
...omit(next, ['text', 'value', 'color']),
});
}
return prev;
}, []);
}
function handleChange(e) {
const { mode } = unref<Recordable>(getBindValue);
let changeValue:any;
// 兼容多选模式
//update-begin---author:wangshuai ---date:20230216 for[QQYUN-4290]公文发文:选择机关代字报错,是因为值改变触发了change事件三次导致数据发生改变------------
//采用一个值不然的话state值变换触发多个change
if (mode === 'multiple') {
changeValue = e?.target?.value ?? e;
// 过滤掉空值
if (changeValue == null || changeValue === '') {
changeValue = [];
}
if (Array.isArray(changeValue)) {
changeValue = changeValue.filter((item) => item != null && item !== '');
}
} else {
changeValue = e?.target?.value ?? e;
}
state.value = changeValue;
//update-begin---author:wangshuai ---date:20230403 for【issues/4507】JDictSelectTag组件使用时浏览器给出警告提示Expected Function, got Array------------
emit('update:value',changeValue)
//update-end---author:wangshuai ---date:20230403 for【issues/4507】JDictSelectTag组件使用时浏览器给出警告提示Expected Function, got Array述------------
//update-end---author:wangshuai ---date:20230216 for[QQYUN-4290]公文发文:选择机关代字报错,是因为值改变触发了change事件三次导致数据发生改变------------
// nextTick(() => formItemContext.onFieldChange());
}
/** 单选radio的值变化事件 */
function handleChangeRadio(e) {
state.value = e?.target?.value ?? e;
//update-begin---author:wangshuai ---date:20230504 for【issues/506】JDictSelectTag 组件 type="radio" 没有返回值------------
emit('update:value',e?.target?.value ?? e)
//update-end---author:wangshuai ---date:20230504 for【issues/506】JDictSelectTag 组件 type="radio" 没有返回值------------
}
/** 用于搜索下拉框中的内容 */
function handleFilterOption(input, option) {
// update-begin--author:liaozhiyang---date:20230914---for【QQYUN-6514】 配置的时候Y轴不能输入多个字段了控制台报错
if (typeof option.children === 'function') {
// 在 label 中搜索
let labelIf = option.children()[0]?.children.toLowerCase().indexOf(input.toLowerCase()) >= 0;
if (labelIf) {
return true;
}
}
// update-end--author:liaozhiyang---date:20230914---for【QQYUN-6514】 配置的时候Y轴不能输入多个字段了控制台报错
// 在 value 中搜索
return (option.value || '').toString().toLowerCase().indexOf(input.toLowerCase()) >= 0;
}
return {
state,
compType,
attrs,
loadingEcho,
getBindValue,
dictOptions,
CompTypeEnum,
handleChange,
handleChangeRadio,
handleFilterOption,
};
},
});
</script>
<style scoped lang="less">
// update-begin--author:liaozhiyang---date:20230110---for【QQYUN-7799】字典组件原生组件除外加上颜色配置
.colorText {
display: inline-block;
height: 20px;
line-height: 20px;
padding: 0 6px;
border-radius: 8px;
background-color: red;
color: #fff;
font-size: 12px;
}
// update-begin--author:liaozhiyang---date:20230110---for【QQYUN-7799】字典组件原生组件除外加上颜色配置
</style>

View File

@ -0,0 +1,319 @@
<template>
<div :class="`${prefixCls}`">
<div class="content">
<a-tabs :size="`small`" v-model:activeKey="activeKey">
<a-tab-pane tab="" key="second" v-if="!hideSecond">
<SecondUI v-model:value="second" :disabled="disabled" />
</a-tab-pane>
<a-tab-pane tab="" key="minute">
<MinuteUI v-model:value="minute" :disabled="disabled" />
</a-tab-pane>
<a-tab-pane tab="" key="hour">
<HourUI v-model:value="hour" :disabled="disabled" />
</a-tab-pane>
<a-tab-pane tab="" key="day">
<DayUI v-model:value="day" :week="week" :disabled="disabled" />
</a-tab-pane>
<a-tab-pane tab="" key="month">
<MonthUI v-model:value="month" :disabled="disabled" />
</a-tab-pane>
<a-tab-pane tab="" key="week">
<WeekUI v-model:value="week" :day="day" :disabled="disabled" />
</a-tab-pane>
<a-tab-pane tab="" key="year" v-if="!hideYear && !hideSecond">
<YearUI v-model:value="year" :disabled="disabled" />
</a-tab-pane>
</a-tabs>
<a-divider />
<!-- 执行时间预览 -->
<a-row :gutter="8">
<a-col :span="18" style="margin-top: 22px">
<a-row :gutter="8">
<a-col :span="8" style="margin-bottom: 12px">
<a-input v-model:value="inputValues.second" @blur="onInputBlur">
<template #addonBefore>
<span class="allow-click" @click="activeKey = 'second'">秒</span>
</template>
</a-input>
</a-col>
<a-col :span="8" style="margin-bottom: 12px">
<a-input v-model:value="inputValues.minute" @blur="onInputBlur">
<template #addonBefore>
<span class="allow-click" @click="activeKey = 'minute'">分</span>
</template>
</a-input>
</a-col>
<a-col :span="8" style="margin-bottom: 12px">
<a-input v-model:value="inputValues.hour" @blur="onInputBlur">
<template #addonBefore>
<span class="allow-click" @click="activeKey = 'hour'">时</span>
</template>
</a-input>
</a-col>
<a-col :span="8" style="margin-bottom: 12px">
<a-input v-model:value="inputValues.day" @blur="onInputBlur">
<template #addonBefore>
<span class="allow-click" @click="activeKey = 'day'">日</span>
</template>
</a-input>
</a-col>
<a-col :span="8" style="margin-bottom: 12px">
<a-input v-model:value="inputValues.month" @blur="onInputBlur">
<template #addonBefore>
<span class="allow-click" @click="activeKey = 'month'">月</span>
</template>
</a-input>
</a-col>
<a-col :span="8" style="margin-bottom: 12px">
<a-input v-model:value="inputValues.week" @blur="onInputBlur">
<template #addonBefore>
<span class="allow-click" @click="activeKey = 'week'">周</span>
</template>
</a-input>
</a-col>
<a-col :span="8">
<a-input v-model:value="inputValues.year" @blur="onInputBlur">
<template #addonBefore>
<span class="allow-click" @click="activeKey = 'year'">年</span>
</template>
</a-input>
</a-col>
<a-col :span="16">
<a-input v-model:value="inputValues.cron" @blur="onInputCronBlur">
<template #addonBefore>
<a-tooltip title="Cron表达式">式</a-tooltip>
</template>
</a-input>
</a-col>
</a-row>
</a-col>
<a-col :span="6">
<div>近十次执行时间(不含年)</div>
<a-textarea type="textarea" :value="preTimeList" :rows="5" />
</a-col>
</a-row>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, reactive, ref, watch, provide } from 'vue';
import { useDesign } from '/@/hooks/web/useDesign';
import CronParser from 'cron-parser';
import SecondUI from './tabs/SecondUI.vue';
import MinuteUI from './tabs/MinuteUI.vue';
import HourUI from './tabs/HourUI.vue';
import DayUI from './tabs/DayUI.vue';
import MonthUI from './tabs/MonthUI.vue';
import WeekUI from './tabs/WeekUI.vue';
import YearUI from './tabs/YearUI.vue';
import { cronEmits, cronProps } from './easy.cron.data';
import { dateFormat, simpleDebounce } from '/@/utils/common/compUtils';
const { prefixCls } = useDesign('easy-cron-inner');
provide('prefixCls', prefixCls);
const emit = defineEmits([...cronEmits]);
const props = defineProps({ ...cronProps });
const activeKey = ref(props.hideSecond ? 'minute' : 'second');
const second = ref('*');
const minute = ref('*');
const hour = ref('*');
const day = ref('*');
const month = ref('*');
const week = ref('?');
const year = ref('*');
const inputValues = reactive({
second: '',
minute: '',
hour: '',
day: '',
month: '',
week: '',
year: '',
cron: '',
});
const preTimeList = ref('执行预览,会忽略年份参数。');
// cron表达式
const cronValueInner = computed(() => {
let result: string[] = [];
if (!props.hideSecond) {
result.push(second.value ? second.value : '*');
}
result.push(minute.value ? minute.value : '*');
result.push(hour.value ? hour.value : '*');
result.push(day.value ? day.value : '*');
result.push(month.value ? month.value : '*');
result.push(week.value ? week.value : '?');
if (!props.hideYear && !props.hideSecond) result.push(year.value ? year.value : '*');
return result.join(' ');
});
// 不含年
const cronValueNoYear = computed(() => {
const v = cronValueInner.value;
if (props.hideYear || props.hideSecond) return v;
const vs = v.split(' ');
if (vs.length >= 6) {
// 转成 Quartz 的规则
vs[5] = convertWeekToQuartz(vs[5]);
}
return vs.slice(0, vs.length - 1).join(' ');
});
const calTriggerList = simpleDebounce(calTriggerListInner, 500);
watch(
() => props.value,
(newVal) => {
if (newVal === cronValueInner.value) {
return;
}
formatValue();
}
);
watch(cronValueInner, (newValue) => {
calTriggerList();
emitValue(newValue);
assignInput();
});
// watch(minute, () => {
// if (second.value === '*') {
// second.value = '0'
// }
// })
// watch(hour, () => {
// if (minute.value === '*') {
// minute.value = '0'
// }
// })
// watch(day, () => {
// if (day.value !== '?' && hour.value === '*') {
// hour.value = '0'
// }
// })
// watch(week, () => {
// if (week.value !== '?' && hour.value === '*') {
// hour.value = '0'
// }
// })
// watch(month, () => {
// if (day.value === '?' && week.value === '*') {
// week.value = '1'
// } else if (week.value === '?' && day.value === '*') {
// day.value = '1'
// }
// })
// watch(year, () => {
// if (month.value === '*') {
// month.value = '1'
// }
// })
assignInput();
formatValue();
calTriggerListInner();
function assignInput() {
inputValues.second = second.value;
inputValues.minute = minute.value;
inputValues.hour = hour.value;
inputValues.day = day.value;
inputValues.month = month.value;
inputValues.week = week.value;
inputValues.year = year.value;
inputValues.cron = cronValueInner.value;
}
function formatValue() {
if (!props.value) return;
const values = props.value.split(' ').filter((item) => !!item);
if (!values || values.length <= 0) return;
let i = 0;
if (!props.hideSecond) second.value = values[i++];
if (values.length > i) minute.value = values[i++];
if (values.length > i) hour.value = values[i++];
if (values.length > i) day.value = values[i++];
if (values.length > i) month.value = values[i++];
if (values.length > i) week.value = values[i++];
if (values.length > i) year.value = values[i];
assignInput();
}
// Quartz 的规则:
// 1 = 周日2 = 周一3 = 周二4 = 周三5 = 周四6 = 周五7 = 周六
function convertWeekToQuartz(week: string) {
let convert = (v: string) => {
if (v === '0') {
return '1';
}
if (v === '1') {
return '0';
}
return (Number.parseInt(v) - 1).toString();
};
// 匹配示例 1-7 or 1/7
let patten1 = /^([0-7])([-/])([0-7])$/;
// 匹配示例 1,4,7
let patten2 = /^([0-7])(,[0-7])+$/;
if (/^[0-7]$/.test(week)) {
return convert(week);
} else if (patten1.test(week)) {
return week.replace(patten1, ($0, before, separator, after) => {
if (separator === '/') {
return convert(before) + separator + after;
} else {
return convert(before) + separator + convert(after);
}
});
} else if (patten2.test(week)) {
return week
.split(',')
.map((v) => convert(v))
.join(',');
}
return week;
}
function calTriggerListInner() {
// 设置了回调函数
if (props.remote) {
props.remote(cronValueInner.value, +new Date(), (v) => {
preTimeList.value = v;
});
return;
}
const format = 'yyyy-MM-dd hh:mm:ss';
const options = {
currentDate: dateFormat(new Date(), format),
};
const iter = CronParser.parseExpression(cronValueNoYear.value, options);
const result: string[] = [];
for (let i = 1; i <= 10; i++) {
result.push(dateFormat(new Date(iter.next() as any), format));
}
preTimeList.value = result.length > 0 ? result.join('\n') : '无执行时间';
}
function onInputBlur() {
second.value = inputValues.second;
minute.value = inputValues.minute;
hour.value = inputValues.hour;
day.value = inputValues.day;
month.value = inputValues.month;
week.value = inputValues.week;
year.value = inputValues.year;
}
function onInputCronBlur(event) {
emitValue(event.target.value);
}
function emitValue(value) {
emit('change', value);
emit('update:value', value);
}
</script>
<style lang="less">
@import 'easy.cron.inner';
</style>

View File

@ -0,0 +1,63 @@
<template>
<div :class="`${prefixCls}`">
<a-input :placeholder="placeholder" v-model:value="editCronValue" :disabled="disabled">
<template #addonAfter>
<a class="open-btn" :disabled="disabled ? 'disabled' : null" @click="showConfigModal">
<Icon icon="ant-design:setting-outlined" />
<span>选择</span>
</a>
</template>
</a-input>
<EasyCronModal
@register="registerModal"
v-model:value="editCronValue"
:exeStartTime="exeStartTime"
:hideYear="hideYear"
:remote="remote"
:hideSecond="hideSecond"
/>
</div>
</template>
<script lang="ts" setup>
import { ref, watch } from 'vue';
import { useDesign } from '/@/hooks/web/useDesign';
import { useModal } from '/@/components/Modal';
import { propTypes } from '/@/utils/propTypes';
import Icon from '/@/components/Icon/src/Icon.vue';
import EasyCronModal from './EasyCronModal.vue';
import { cronEmits, cronProps } from './easy.cron.data';
const { prefixCls } = useDesign('easy-cron-input');
const emit = defineEmits([...cronEmits]);
const props = defineProps({
...cronProps,
placeholder: propTypes.string.def('请输入cron表达式'),
exeStartTime: propTypes.oneOfType([propTypes.number, propTypes.string, propTypes.object]).def(0),
});
const [registerModal, { openModal }] = useModal();
const editCronValue = ref(props.value);
watch(
() => props.value,
(newVal) => {
if (newVal !== editCronValue.value) {
editCronValue.value = newVal;
}
}
);
watch(editCronValue, (newVal) => {
emit('change', newVal);
emit('update:value', newVal);
});
function showConfigModal() {
if (!props.disabled) {
openModal();
}
}
</script>
<style lang="less">
@import 'easy.cron.input';
</style>

View File

@ -0,0 +1,28 @@
<template>
<BasicModal @register="registerModal" title="Cron表达式" width="800px" @ok="onOk">
<EasyCron v-bind="attrs" />
</BasicModal>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import { useAttrs } from '/@/hooks/core/useAttrs';
import { BasicModal, useModalInner } from '/@/components/Modal';
import EasyCron from './EasyCronInner.vue';
export default defineComponent({
name: 'EasyCronModal',
inheritAttrs: false,
components: { BasicModal, EasyCron },
setup() {
const attrs = useAttrs();
const [registerModal, { closeModal }] = useModalInner();
function onOk() {
closeModal();
}
return { attrs, registerModal, onOk };
},
});
</script>

View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2019 知行合一
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -0,0 +1,10 @@
import { propTypes } from '/@/utils/propTypes';
export const cronEmits = ['change', 'update:value'];
export const cronProps = {
value: propTypes.string.def(''),
disabled: propTypes.bool.def(false),
hideSecond: propTypes.bool.def(false),
hideYear: propTypes.bool.def(false),
remote: propTypes.func,
};

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