feat: 批量动态表单业务组件&卡片组件&代码编辑器组件&其他组件调整

This commit is contained in:
baiqi 2023-07-14 18:56:01 +08:00 committed by 刘瑞斌
parent dfea1f83f9
commit 9dc0ca96f8
14 changed files with 542 additions and 21 deletions

View File

@ -7,6 +7,7 @@ import configArcoStyleImportPlugin from './plugin/arcoStyleImport';
import configArcoResolverPlugin from './plugin/arcoResolver'; import configArcoResolverPlugin from './plugin/arcoResolver';
import { createSvgIconsPlugin } from 'vite-plugin-svg-icons'; import { createSvgIconsPlugin } from 'vite-plugin-svg-icons';
import vueSetupExtend from 'vite-plugin-vue-setup-extend'; import vueSetupExtend from 'vite-plugin-vue-setup-extend';
import monacoEditorPlugin from 'vite-plugin-monaco-editor';
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [
@ -22,6 +23,7 @@ export default defineConfig({
// 指定symbolId格式 // 指定symbolId格式
symbolId: 'icon-[name]', symbolId: 'icon-[name]',
}), }),
monacoEditorPlugin({}),
], ],
resolve: { resolve: {
alias: [ alias: [

View File

@ -0,0 +1,197 @@
<template>
<a-form ref="formRef" :model="form" layout="vertical">
<div class="mb-[16px] overflow-y-auto rounded-[4px] bg-[var(--color-fill-1)] p-[12px]">
<a-scrollbar class="overflow-y-auto" :style="{ 'max-height': props.maxHeight }">
<div class="flex flex-wrap items-start justify-between gap-[8px]">
<template v-for="(order, i) of form.list" :key="`form-item-${order}`">
<div class="flex w-full items-start justify-between gap-[8px]">
<a-form-item
v-for="item of props.models"
:key="`${item.filed}${order}`"
:field="`${item.filed}${order}`"
:class="i > 0 ? 'hidden-item' : 'mb-0 flex-1'"
:label="i === 0 && item.label ? t(item.label) : ''"
:rules="item.rules"
asterisk-position="end"
>
<a-input
v-if="item.type === 'input'"
v-model="form[`${item.filed}${order}`]"
class="mb-[4px] flex-1"
:placeholder="t(item.placeholder || '')"
:max-length="item.maxLength || 250"
allow-clear
/>
<a-input-number
v-if="item.type === 'inputNumber'"
v-model="form[`${item.filed}${order}`]"
class="mb-[4px] flex-1"
:placeholder="t(item.placeholder || '')"
:min="item.min"
:max="item.max || 9999999"
allow-clear
/>
</a-form-item>
<div
v-show="form.list.length > 1"
:class="[
'flex',
'h-full',
'w-[32px]',
'cursor-pointer',
'items-center',
'justify-center',
'text-[var(--color-text-brand)]',
i === 0 ? 'mt-[36px]' : 'mt-[5px]',
]"
@click="removeField(order, i)"
>
<icon-minus-circle />
</div>
</div>
</template>
</div>
</a-scrollbar>
<div v-if="props.formMode === 'create'" class="w-full">
<a-button class="px-0" type="text" @click="addField">
<template #icon>
<icon-plus class="text-[14px]" />
</template>
{{ t(props.addText) }}
</a-button>
</div>
</div>
</a-form>
</template>
<script setup lang="ts">
import { ref, watchEffect } from 'vue';
import { useI18n } from '@/hooks/useI18n';
import type { ValidatedError, FormInstance } from '@arco-design/web-vue';
import type { FormItemModel, FormMode, ValueType } from './types';
const { t } = useI18n();
const props = withDefaults(
defineProps<{
models: FormItemModel[];
formMode: FormMode;
addText: string;
maxHeight?: string;
valueType?: ValueType;
delimiter?: string; // valueType string ,
defaultVals?: Record<string, string | string[] | number[]>; //
}>(),
{
valueType: 'Array',
delimiter: ',',
maxHeight: '30vh',
}
);
const defaultForm = {
list: [0],
};
const form = ref<Record<string, any>>({ ...defaultForm });
const formRef = ref<FormInstance | null>(null);
/**
* 监测defaultVals和models的变化
* 初始化时通过models创建初始化表单
* 若defaultVals变化则说明当前是填充模式将清空之前的表单项填充传入的数据一般是表单编辑的时候
*/
watchEffect(() => {
props.models.forEach((e) => {
form.value[`${e.filed}0`] = e.type === 'inputNumber' ? null : '';
});
if (props.defaultVals) {
//
form.value = { list: [0] };
// defaultVals filed
const arr = Object.keys(props.defaultVals);
for (let i = 0; i < arr.length; i++) {
const filed = arr[i];
// filed
const dVals = props.defaultVals[filed];
// delimiter
const vals = Array.isArray(dVals) ? dVals : dVals.split(`${props.delimiter}`);
// filed
vals.forEach((val, order) => {
form.value[`${filed}${order}`] = val;
if (i === 0 && order > 0) {
//
form.value.list.push(order);
}
});
}
}
});
function getFormResult() {
const res: Record<string, any> = {};
props.models.forEach((e) => {
res[e.filed] = [];
});
form.value.list.forEach((e: number) => {
props.models.forEach((m) => {
res[m.filed].push(form.value[`${m.filed}${e}`]);
});
});
return res;
}
/**
* 触发表单校验
* @param cb 校验通过后执行回调
* @param isSubmit 是否需要将表单值拼接后传入回调函数
*/
function formValidate(cb: (res?: Record<string, string[] | string>) => void, isSubmit = true) {
formRef.value?.validate(async (errors: undefined | Record<string, ValidatedError>) => {
if (errors) {
return;
}
if (typeof cb === 'function') {
if (isSubmit) {
const res = getFormResult();
cb(props.valueType === 'Array' ? res : res.join(','));
return;
}
cb();
}
});
}
/**
* 添加表单项
*/
function addField() {
formValidate(() => {
const lastIndex = form.value.list.length - 1;
const lastOrder = form.value.list[lastIndex] + 1;
form.value.list.push(lastOrder); //
props.models.forEach((e) => {
form.value[`${e.filed}${lastOrder}`] = e.type === 'inputNumber' ? null : '';
});
}, false);
}
/**
* 移除表单项
* @param index 表单项的序号
* @param i 表单项对应 list 的下标
*/
function removeField(index: number, i: number) {
props.models.forEach((e) => {
delete form.value[`${e.filed}${index}`];
});
form.value.list.splice(i, 1);
}
defineExpose({
formValidate,
getFormResult,
});
</script>
<style lang="less" scoped></style>

View File

@ -0,0 +1,36 @@
import { FieldRule } from '@arco-design/web-vue';
export type FormItemType = 'input' | 'select' | 'inputNumber';
export type FormMode = 'create' | 'edit';
export type ValueType = 'Array' | 'string';
export interface FormItemModel {
filed: string;
type: FormItemType;
rules?: FieldRule[];
label?: string;
placeholder?: string;
min?: number;
max?: number;
maxLength?: number;
}
declare const _default: import('vue').DefineComponent<
{
models: FormItemModel[];
formMode: FormMode;
addText: string;
maxHeight?: string;
valueType?: ValueType;
delimiter?: string; // 当valueType为 string 类型时的分隔符,默认为英文逗号,
defaultVals?: Record<string, string[] | string>; // 当外层是编辑状态时,可传入已填充的数据
},
unknown,
import('vue').ComponentOptionsMixin,
import('vue').ComponentOptionsMixin,
{
formValidate: (cb: (res?: Record<string, string[] | string>) => void, isSubmit = true) => void;
}
>;
export declare type MsBatchFormInstance = InstanceType<typeof _default>;

View File

@ -4,19 +4,24 @@
<div class="back-btn" @click="back"><icon-arrow-left /></div> <div class="back-btn" @click="back"><icon-arrow-left /></div>
<div class="text-[var(--color-text-000)]">{{ props.title }}</div> <div class="text-[var(--color-text-000)]">{{ props.title }}</div>
</div> </div>
<a-divider /> <a-divider class="my-[16px]" />
<a-scrollbar class="mt-[16px]" style="overflow-y: auto; height: calc(100vh - 264px)"> <a-scrollbar class="mt-[16px]" style="overflow-y: auto; height: calc(100vh - 256px)">
<slot></slot> <slot></slot>
</a-scrollbar> </a-scrollbar>
<div <div
v-if="!hideFooter" v-if="!hideFooter"
class="m-[0_-24px_-24px] flex justify-end gap-[16px] p-[24px] shadow-[0_-1px_4px_rgba(2,2,2,0.1)]" class="relative z-10 m-[0_-24px_-24px] flex justify-end gap-[16px] p-[24px] shadow-[0_-1px_4px_rgba(2,2,2,0.1)]"
> >
<a-button type="secondary" @click="back">{{ t('mscard.defaultCancelText') }}</a-button> <div class="ml-0 mr-auto">
<a-button v-if="!props.hideContinue" type="secondary" @click="emit('saveAndContinue')"> <slot name="footerLeft"></slot>
{{ t('mscard.defaultSaveAndContinueText') }} </div>
</a-button> <slot name="footerRight">
<a-button type="primary" @click="emit('save')">{{ t('mscard.defaultConfirm') }}</a-button> <a-button type="secondary" @click="back">{{ t('mscard.defaultCancelText') }}</a-button>
<a-button v-if="!props.hideContinue" type="secondary" @click="emit('saveAndContinue')">
{{ t('mscard.defaultSaveAndContinueText') }}
</a-button>
<a-button type="primary" @click="emit('save')">{{ t('mscard.defaultConfirm') }}</a-button>
</slot>
</div> </div>
</div> </div>
</template> </template>
@ -64,7 +69,6 @@
background: linear-gradient(90deg, rgb(var(--primary-9)) 3.36%, #ffffff 100%); background: linear-gradient(90deg, rgb(var(--primary-9)) 3.36%, #ffffff 100%);
box-shadow: 0 0 7px rgb(15 0 78 / 9%); box-shadow: 0 0 7px rgb(15 0 78 / 9%);
.arco-icon { .arco-icon {
font-size: 20px !important;
color: rgb(var(--primary-5)); color: rgb(var(--primary-5));
} }
} }

View File

@ -0,0 +1,128 @@
<template>
<div ref="fullRef" class="rounded-[4px] bg-[var(--color-fill-1)] p-[12px]">
<div class="mb-[12px] flex justify-between pr-[12px]">
<slot name="title">
<span class="font-medium">{{ title }}</span>
</slot>
<div class="w-[96px] cursor-pointer text-right !text-[var(--color-text-4)]" @click="toggle">
<MsIcon v-if="isFullscreen" type="icon-icon_minify_outlined" />
<MsIcon v-else type="icon-icon_magnify_outlined" />
{{ t('msCodeEditor.fullScreen') }}
</div>
</div>
<div ref="codeEditBox" :class="['ms-code-editor', isFullscreen ? 'ms-code-editor-full-screen' : '']"></div>
</div>
</template>
<script lang="ts">
import { defineComponent, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { editorProps, CustomeTheme } from './types';
import './userWorker';
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
import { useFullscreen } from '@vueuse/core';
import MsCodeEditorTheme from './themes';
import { useI18n } from '@/hooks/useI18n';
export default defineComponent({
name: 'MonacoEditor',
props: editorProps,
emits: ['update:modelValue', 'change', 'editorMounted'],
setup(props, { emit }) {
const { t } = useI18n();
let editor: monaco.editor.IStandaloneCodeEditor;
const codeEditBox = ref();
const fullRef = ref<HTMLElement | null>();
const init = () => {
//
if (MsCodeEditorTheme[props.theme as CustomeTheme]) {
monaco.editor.defineTheme(props.theme, MsCodeEditorTheme[props.theme as CustomeTheme]);
}
editor = monaco.editor.create(codeEditBox.value, {
value: props.modelValue,
automaticLayout: true,
...props,
});
//
editor.onDidBlurEditorText(() => {
const value = editor.getValue(); //
emit('update:modelValue', value);
emit('change', value);
});
emit('editorMounted', editor);
};
const setEditBoxBg = () => {
const codeBgEl = document.querySelector('.monaco-editor-background');
if (codeBgEl) {
//
const computedStyle = window.getComputedStyle(codeBgEl);
//
const { backgroundColor } = computedStyle;
codeEditBox.value.style.backgroundColor = backgroundColor;
}
};
const { isFullscreen, toggle } = useFullscreen(fullRef);
watch(
() => props.modelValue,
(newValue) => {
if (editor) {
const value = editor.getValue();
if (newValue !== value) {
editor.setValue(newValue);
}
}
}
);
watch(
() => props.options,
(newValue) => {
editor.updateOptions(newValue);
},
{ deep: true }
);
// watch(
// () => props.language,
// (newValue) => {
// monaco.editor.setModelLanguage(editor.getModel()!, newValue);
// }
// );
onBeforeUnmount(() => {
editor.dispose();
});
onMounted(() => {
init();
setEditBoxBg();
});
return { codeEditBox, fullRef, isFullscreen, toggle, t };
},
});
</script>
<style lang="less" scoped>
.ms-code-editor {
@apply z-10;
padding: 16px 0;
width: v-bind(width);
height: v-bind(height);
&[data-mode-id='plaintext'] {
:deep(.mtk1) {
color: rgb(var(--primary-5));
}
}
}
.ms-code-editor-full-screen {
height: calc(100vh - 66px);
}
</style>

View File

@ -0,0 +1,3 @@
export default {
'msCodeEditor.fullScreen': 'FullScreen',
};

View File

@ -0,0 +1,3 @@
export default {
'msCodeEditor.fullScreen': '全屏',
};

View File

@ -0,0 +1,26 @@
import { rgbToHex } from '@/utils';
import { primaryVars } from '@/hooks/useThemeVars';
export default {
base: 'vs',
inherit: false,
rules: [],
colors: {
'editorLineNumber.foreground': rgbToHex(primaryVars.P2),
'editorLineNumber.activeForeground': rgbToHex(primaryVars.P4),
'editorCursor.background': rgbToHex(primaryVars.P5),
'editorCursor.foreground': rgbToHex(primaryVars.P5),
'editor.wordHighlightBackground': rgbToHex(primaryVars.P1),
'editor.selectionBackground': rgbToHex(primaryVars.P2),
'editor.lineHighlightBorder': rgbToHex(primaryVars.P1),
'editor.lineHighlightBackground': rgbToHex(primaryVars.P1),
'editor.rangeHighlightBackground': rgbToHex(primaryVars.P1),
'editor.findMatchBackground': rgbToHex(primaryVars.P2),
'editor.findMatchHighlightBackground': rgbToHex(primaryVars.P9),
'editor.findRangeHighlightBackground': rgbToHex(primaryVars.P5),
'scrollbarSlider.activeBackground': rgbToHex(primaryVars.P4),
'scrollbarSlider.background': rgbToHex(primaryVars.P2),
'scrollbarSlider.hoverBackground': rgbToHex(primaryVars.P3),
'scrollbar.shadow': rgbToHex(primaryVars.P2),
},
};

View File

@ -0,0 +1,9 @@
import MSText from './MS-text';
import type { CustomeTheme } from '../types';
const MsCodeEditorThemes: Record<CustomeTheme, any> = {
'MS-text': MSText,
};
export default MsCodeEditorThemes;

View File

@ -0,0 +1,82 @@
import { PropType } from 'vue';
export type CustomeTheme = 'MS-text';
export type Theme = 'vs' | 'hc-black' | 'vs-dark' | CustomeTheme;
export type FoldingStrategy = 'auto' | 'indentation';
export type RenderLineHighlight = 'all' | 'line' | 'none' | 'gutter';
export type Language =
| 'plaintext'
| 'javascript'
| 'typescript'
| 'css'
| 'less'
| 'sass'
| 'html'
| 'sql'
| 'json'
| 'java'
| 'python'
| 'xml'
| 'yaml'
| 'shell';
export interface Options {
automaticLayout: boolean; // 自适应布局
foldingStrategy: FoldingStrategy; // 折叠方式 auto | indentation
renderLineHighlight: RenderLineHighlight; // 行亮
selectOnLineNumbers: boolean; // 显示行号
minimap: {
// 关闭小地图
enabled: boolean;
};
readOnly: boolean; // 只读
fontSize: number; // 字体大小
scrollBeyondLastLine: boolean; // 取消代码后面一大段空白
overviewRulerBorder: boolean; // 不要滚动条的边框
}
export const editorProps = {
modelValue: {
type: String as PropType<string>,
default: null,
},
width: {
type: [String, Number] as PropType<string | number>,
default: '100%',
},
height: {
type: [String, Number] as PropType<string | number>,
default: '50vh',
},
language: {
type: String as PropType<Language>,
default: 'plaintext',
},
theme: {
type: String as PropType<Theme>,
validator(value: string): boolean {
return ['vs', 'hc-black', 'vs-dark', 'MS-text'].includes(value);
},
default: 'vs-dark',
},
options: {
type: Object as PropType<Options>,
default() {
return {
automaticLayout: true,
foldingStrategy: 'indentation',
renderLineHighlight: 'all',
selectOnLineNumbers: true,
minimap: {
enabled: true,
},
readOnly: false,
fontSize: 16,
scrollBeyondLastLine: false,
overviewRulerBorder: false,
};
},
},
title: {
type: String as PropType<string>,
},
};

View File

@ -0,0 +1,33 @@
import * as monaco from 'monaco-editor';
import EditorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker';
// import JsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker';
// import CssWorker from 'monaco-editor/esm/vs/language/css/css.worker?worker';
// import HtmlWorker from 'monaco-editor/esm/vs/language/html/html.worker?worker';
// import TsWorker from 'monaco-editor/esm/vs/language/typescript/ts.worker?worker';
// @ts-ignore
// eslint-disable-next-line no-restricted-globals
self.MonacoEnvironment = {
async getWorker(_: any, label: string) {
if (label === 'json') {
const JsonWorker = ((await import('monaco-editor/esm/vs/language/json/json.worker?worker')) as any).default;
return new JsonWorker();
}
if (label === 'css' || label === 'scss' || label === 'less') {
const CssWorker = ((await import('monaco-editor/esm/vs/language/css/css.worker?worker')) as any).default;
return new CssWorker();
}
if (label === 'html' || label === 'handlebars' || label === 'razor') {
const HtmlWorker = ((await import('monaco-editor/esm/vs/language/html/html.worker?worker')) as any).default;
return new HtmlWorker();
}
if (label === 'typescript' || label === 'javascript') {
const TsWorker = ((await import('monaco-editor/esm/vs/language/typescript/ts.worker?worker')) as any).default;
return new TsWorker();
}
return new EditorWorker();
},
};
monaco.languages.typescript.typescriptDefaults.setEagerModelSync(true);

View File

@ -18,21 +18,24 @@
</slot> </slot>
</template> </template>
<slot> <slot>
<MsDescription :descriptions="props.descriptions"></MsDescription> <MsDescription v-if="props.descriptions?.length > 0" :descriptions="props.descriptions"></MsDescription>
</slot> </slot>
</a-drawer> </a-drawer>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch } from 'vue'; import { ref, watch, defineAsyncComponent } from 'vue';
import MsDescription, { Description } from '@/components/pure/ms-description/index.vue'; import type { Description } from '@/components/pure/ms-description/index.vue';
//
const MsDescription = defineAsyncComponent(() => import('@/components/pure/ms-description/index.vue'));
interface DrawerProps { interface DrawerProps {
visible: boolean; visible: boolean;
title: string | undefined; title: string | undefined;
titleTag?: string; titleTag?: string;
titleTagColor?: string; titleTagColor?: string;
descriptions: Description[]; descriptions?: Description[];
footer?: boolean; footer?: boolean;
[key: string]: any; [key: string]: any;
} }
@ -75,8 +78,6 @@
} }
} }
.arco-drawer-footer { .arco-drawer-footer {
@apply text-left;
border-bottom: 1px solid var(--color-text-n8); border-bottom: 1px solid var(--color-text-n8);
} }
} }

View File

@ -1,9 +1,5 @@
<template> <template>
<icon-font <icon-font :type="props.type" :size="props.size || 14" />
:type="props.type"
:size="props.size || 14"
:class="props.color ? `text-[${props.color}]` : 'text-[var(--color-text-4)]'"
/>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
@ -15,7 +11,6 @@
size?: string | number; size?: string | number;
rotate?: number; rotate?: number;
spin?: boolean; spin?: boolean;
color?: string;
}>(); }>();
const IconFont = Icon.addFromIconFontCn({ const IconFont = Icon.addFromIconFontCn({

View File

@ -24,10 +24,12 @@
type UploadProps = Partial<{ type UploadProps = Partial<{
mainText: string; mainText: string;
subText: string; subText: string;
class: string;
multiple: boolean; multiple: boolean;
limit: number; limit: number;
imagePreview: boolean; imagePreview: boolean;
showFileList: boolean; showFileList: boolean;
[key: string]: any;
}> & { }> & {
accept: UploadType; accept: UploadType;
}; };