From 4d764b91d0abe0342b9d9bd3e66196d70648dc87 Mon Sep 17 00:00:00 2001 From: lijianxiong <1518062161@qq.com> Date: Sun, 24 Aug 2025 04:05:50 +0800 Subject: [PATCH 1/5] =?UTF-8?q?fix=EF=BC=9A=E4=BF=AE=E5=A4=8D=E8=A1=A8?= =?UTF-8?q?=E5=8D=95=E5=88=86=E7=BB=84=E6=A0=87=E9=A2=98=E6=A0=8F=E5=85=B3?= =?UTF-8?q?=E9=97=AD=E6=A8=A1=E5=BC=8F=E9=85=8D=E7=BD=AE=E5=90=8E=E6=9C=AA?= =?UTF-8?q?=E7=94=9F=E6=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../form/form-detail/form-group-panel/form-group-panel.scss | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/control/form/form-detail/form-group-panel/form-group-panel.scss b/src/control/form/form-detail/form-group-panel/form-group-panel.scss index b02875a9..4c7f119c 100644 --- a/src/control/form/form-detail/form-group-panel/form-group-panel.scss +++ b/src/control/form/form-detail/form-group-panel/form-group-panel.scss @@ -76,12 +76,12 @@ $form-group-content: ( // 折叠相关样式 @include b(form-group-collapse) { - >.#{bem(form-group-content)} { + >.#{bem(form-group__content)} { display: block; } @include when(collapse) { - >.#{bem(form-group-content)} { + >.#{bem(form-group__content)} { display: none; } } -- Gitee From 737161f4f6047c98b127e67ed00330c77dc007ec Mon Sep 17 00:00:00 2001 From: lijianxiong <1518062161@qq.com> Date: Sun, 24 Aug 2025 04:08:16 +0800 Subject: [PATCH 2/5] =?UTF-8?q?fix=EF=BC=9A=E4=BF=AE=E5=A4=8D=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E5=99=A8=E7=A7=BB=E5=8A=A8=E7=AB=AF=E5=A4=9A=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E9=80=89=E6=8B=A9=E6=8A=9B=E5=80=BC=E4=B8=8E=E8=A7=A3?= =?UTF-8?q?=E6=9E=90=E5=80=BC=E9=80=BB=E8=BE=91=E9=94=99=E8=AF=AF=EF=BC=8C?= =?UTF-8?q?=E5=90=8C=E6=AD=A5PC=E7=AB=AF=E5=A4=9A=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E9=80=89=E6=8B=A9=E7=BC=96=E8=BE=91=E5=99=A8=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../data-picker/ibiz-mpicker/ibiz-mpicker.tsx | 207 ++++++++++++++---- 1 file changed, 164 insertions(+), 43 deletions(-) diff --git a/src/editor/data-picker/ibiz-mpicker/ibiz-mpicker.tsx b/src/editor/data-picker/ibiz-mpicker/ibiz-mpicker.tsx index 0f7d2290..11264505 100644 --- a/src/editor/data-picker/ibiz-mpicker/ibiz-mpicker.tsx +++ b/src/editor/data-picker/ibiz-mpicker/ibiz-mpicker.tsx @@ -4,6 +4,7 @@ import { getEditorEmits, useNamespace, } from '@ibiz-template/vue3-util'; +import { clone } from 'lodash-es'; import './ibiz-mpicker.scss'; import { PickerEditorController } from '../picker-editor.controller'; import { IBizDataMPicker } from '../../common/data-mpicker/ibiz-data-mpicker'; @@ -44,53 +45,135 @@ export const IBizMPicker = defineComponent({ value: item[c.keyName] || item.srfkey, srfmajortext: item[c.textName] || item.srfmajortext, text: item[c.textName] || item.srfmajortext, + [c.keyName]: item[c.keyName] || item.srfkey, + [c.textName]: item[c.textName] || item.srfmajortext, ...item, }; }; - // 监听传入值 - watch( - () => props.value, - newVal => { - curValue.value = []; - selectItems.value = []; - if (newVal) { - selectItems.value = JSON.parse(newVal as string).map(formatter); - selectItems.value.forEach((item: IData) => { - curValue.value.push(item.srfkey); - // 选项没有的时候补充选项 - const index = items.value.findIndex(i => - Object.is(i.srfkey, item.srfkey), - ); - if (index < 0) { - items.value.push(formatter(item)); + const resetCurValue = () => { + curValue.value = []; + selectItems.value = []; + if (props.value) { + if (c.model.valueType === 'OBJECTS') { + (props.value as Array).forEach((item: IData) => { + const _item = clone(item); + Object.assign(_item, { + [c.keyName]: item[c.objectIdField as string], + [c.textName]: item[c.objectNameField as string], + }); + if (c.objectValueField) { + Object.assign(_item, { + ...item[c.objectValueField], + }); + delete _item[c.objectValueField]; + } + if (_item[c.keyName]) { + selectItems.value.push(formatter(_item)); } }); + } else if (c.objectIdField && c.model.valueSeparator) { + const values = (props.value as string).split(c.model.valueSeparator); + values.forEach((value: string) => { + selectItems.value.push( + formatter({ + [c.keyName]: value, + }), + ); + }); + } else { + try { + selectItems.value = JSON.parse(props.value as string).map( + formatter, + ); + } catch (error) { + ibiz.log.error( + ibiz.i18n.t('editor.mpicker.simpleErr', { value: props.value }), + ); + } } + selectItems.value.forEach((item: IData) => { + curValue.value.push(item[c.keyName]); + // 选项没有的时候补充选项 + const index = items.value.findIndex( + i => + Object.is(i[c.keyName], item[c.keyName]) || + Object.is(i.srfkey, item.srfkey), // 兼容写法,适配移动端老逻辑 + ); + if (index < 0) { + items.value.push(formatter(item)); + } + }); + } + }; + + // 监听传入值 + watch( + () => props.value, + () => { + resetCurValue(); }, { immediate: true, deep: true }, ); // 处理视图关闭,往外抛值 - const handleOpenViewClose = (result: IData[]) => { + const handleOpenViewClose = async (result: IData[]) => { // 抛出值集合 - const valArr: IData[] = []; + const selects: IData[] = []; if (result && Array.isArray(result)) { - result.forEach((select: IData) => { - // 回显并且回来的选中值只有srfkey和srfmajortext,所以|| - const formattedItem = formatter(select); - valArr.push(formattedItem); - - // 选项不存在的补充到选项里 + const calcPromises = result.map(async select => { + const item = formatter(select); + // 选择树视图特殊处理 + if (select.srfnodeid) { + Object.assign(item, select._deData); + } + const dataItems = await c.calcFillDataItems(item); + const res = {}; + dataItems.forEach(dataItem => { + Object.assign(res, { [dataItem.id]: dataItem.value }); + }); + return res; + }); + const dataItemsList = await Promise.all(calcPromises); + result.forEach((select: IData, _index: number) => { + Object.assign(select, { + ...formatter(select), + [c.keyName]: select[c.keyName] ? select[c.keyName] : select.srfkey, + [c.textName]: select[c.textName] + ? select[c.textName] + : select.srfmajortext, + }); + const data = dataItemsList[_index]; + if (c.model.valueType === 'OBJECTS') { + selects.push({ ...c.handleObjectParams(select), ...data }); + } else if (c.objectIdField && c.model.valueSeparator) { + selects.push(select[c.keyName]); + } else { + selects.push({ + [c.keyName]: select[c.keyName], + [c.textName]: select[c.textName], + ...data, + }); + } const index = items.value.findIndex(item => Object.is(item[c.keyName], select[c.keyName]), ); if (index < 0) { - items.value.push(formattedItem); + items.value.push(select); } }); } - const value = valArr.length > 0 ? JSON.stringify(valArr) : ''; + let value: string | Array | null = null; + if (selects.length > 0) { + if (c.model.valueType === 'OBJECTS') { + value = selects; + } else { + value = + c.objectIdField && c.model.valueSeparator + ? selects.join(c.model.valueSeparator) + : JSON.stringify(selects); + } + } emit('change', value); }; @@ -112,23 +195,60 @@ export const IBizMPicker = defineComponent({ }; // 下拉选中回调 - const onSelect = (selects: string[]) => { - const valArr: Array = []; - if (selects.length > 0) { - selects.forEach((select: string) => { - const findItem = items.value.find(item => - Object.is(item.srfkey, select), + const onSelect = async (selects: string[]) => { + const val: Array = []; + let value: string | Array | null = null; + const selections = selects.map((select: string) => { + let index = items.value.findIndex(item => + Object.is(item[c.keyName], select), + ); + let item: IData = {}; + if (index >= 0) { + item = items.value[index]; + } else { + index = selectItems.value.findIndex((selectItem: IData) => + Object.is(selectItem[c.keyName], select), ); - if (findItem) { - valArr.push(formatter(findItem)); + if (index >= 0) { + item = selectItems.value[index]; } + } + return item; + }); + const calcPromises = selections.map(async select => { + const dataItems = await c.calcFillDataItems(select); + const res = {}; + dataItems.forEach(dataItem => { + Object.assign(res, { [dataItem.id]: dataItem.value }); }); - const value = valArr.length > 0 ? JSON.stringify(valArr) : ''; - selectItems.value = valArr; - emit('change', value); - } else { - emit('change', ''); + return res; + }); + const dataItemsList = await Promise.all(calcPromises); + selections.forEach((item: IData, index: number) => { + const data = dataItemsList[index]; + if (c.model.valueType === 'OBJECTS') { + val.push({ ...c.handleObjectParams(item), ...data }); + } else if (c.objectIdField && c.model.valueSeparator) { + val.push(item[c.keyName]); + } else { + val.push({ + [c.keyName]: item[c.keyName], + [c.textName]: item[c.textName], + ...data, + }); + } + }); + if (val.length > 0) { + if (c.model.valueType === 'OBJECTS') { + value = val; + } else { + value = + c.objectIdField && c.model.valueSeparator + ? val.join(c.model.valueSeparator) + : JSON.stringify(val); + } } + emit('change', value); }; // 搜索 @@ -157,7 +277,7 @@ export const IBizMPicker = defineComponent({ selectItems.value.forEach((select: IData) => { if (c.model.valueType === 'OBJECTS') { val.push(c.handleObjectParams(select)); - } else if (c.objectIdField) { + } else if (c.objectIdField && c.model.valueSeparator) { val.push(select[c.keyName]); } else { val.push({ @@ -170,9 +290,10 @@ export const IBizMPicker = defineComponent({ if (c.model.valueType === 'OBJECTS') { value = val; } else { - value = c.objectIdField - ? val.join(c.model.valueSeparator) - : JSON.stringify(val); + value = + c.objectIdField && c.model.valueSeparator + ? val.join(c.model.valueSeparator) + : JSON.stringify(val); } } emit('change', value); -- Gitee From 4717437d39b4de764d68063b1d7b319fc1fe26a7 Mon Sep 17 00:00:00 2001 From: lijianxiong <1518062161@qq.com> Date: Sun, 24 Aug 2025 04:14:55 +0800 Subject: [PATCH 3/5] =?UTF-8?q?fix=EF=BC=9A=E4=BF=AE=E5=A4=8D=E9=80=89?= =?UTF-8?q?=E6=8B=A9=E9=9D=A2=E6=9D=BF=E5=8F=96=E6=B6=88=E9=80=89=E4=B8=AD?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E6=97=B6=E7=AE=80=E5=8D=95=E5=88=97=E8=A1=A8?= =?UTF-8?q?=E6=9C=AA=E6=9B=B4=E6=96=B0=E6=B8=85=E7=A9=BA=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E4=B8=94=E5=A7=8B=E7=BB=88=E7=BC=93=E5=AD=98=EF=BC=8C=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E7=A7=BB=E5=8A=A8=E7=AB=AF=E9=80=89=E6=8B=A9=E9=9D=A2?= =?UTF-8?q?=E6=9D=BF=E5=9B=9E=E6=98=BE=E9=80=BB=E8=BE=91=E5=BC=82=E5=B8=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/view-engine/mob-mpickup-view-engine.ts | 69 +++++++++++++--------- src/view-engine/mob-pickup-view.engine.ts | 16 +++++ 2 files changed, 58 insertions(+), 27 deletions(-) diff --git a/src/view-engine/mob-mpickup-view-engine.ts b/src/view-engine/mob-mpickup-view-engine.ts index 53915f8c..1b894f1b 100644 --- a/src/view-engine/mob-mpickup-view-engine.ts +++ b/src/view-engine/mob-mpickup-view-engine.ts @@ -141,30 +141,11 @@ export class MobMPickupViewEngine extends MobPickupViewEngine { /** * 选择面板激活数据 * - * @param {IData[]} data - * @memberof MobMPickupViewEngine - */ - public pickupViewPanelDataActive(data: IData[]): void { - const allData = this.simpleList.getAllData(); - const items = [...allData, ...data]; - // 去重items - const uniqueItems = this.handleUniqueItems(items); - this.simpleList.setData(uniqueItems); - } - - /** - * 去重数组 - * - * @protected - * @param {IData[]} arr - * @return {*} {IData[]} * @memberof MobMPickupViewEngine */ - protected handleUniqueItems(arr: IData[]): IData[] { - const res = new Map(); - return arr.filter( - (item: IData) => !res.has(item.srfkey) && res.set(item.srfkey, 1), - ); + public async pickupViewPanelDataActive(): Promise { + const selectedData = await this.pickupViewPanel.getSelectedData(); + await this.handlePushSimpleListItems(selectedData); } /** @@ -213,10 +194,33 @@ export class MobMPickupViewEngine extends MobPickupViewEngine { * @param {IData[]} data * @memberof MobMPickupViewEngine */ - public handlePushSimpleListItems(data: IData[]): void { - const allData = this.simpleList.getAllData(); - const items = this.calcDuplicateData(allData, data); - this.simpleList.setData(items); + public async handlePushSimpleListItems(data: IData[]): Promise { + // 每次添加的都是多数据部件当前页数据,因此需先将原来简单列表的当前页数据过滤掉 + const items = await this.pickupViewPanel.getAllData(); + // 过滤出非当前页数据 + const selectItems = this.simpleList + .getAllData() + .filter(selected => !items.some(item => item.srfkey === selected.srfkey)); + // 将多数据部件当前页数据加入简单列表选中 + selectItems.push(...data); + // 去重items + const uniqueItems = this.handleUniqueItems(selectItems); + this.setSelectedData(uniqueItems); + } + + /** + * 去重数组 + * + * @protected + * @param {IData[]} arr + * @return {*} {IData[]} + * @memberof MobMPickupViewEngine + */ + protected handleUniqueItems(arr: IData[]): IData[] { + const res = new Map(); + return arr.filter( + (item: IData) => !res.has(item.srfkey) && res.set(item.srfkey, 1), + ); } /** @@ -239,7 +243,7 @@ export class MobMPickupViewEngine extends MobPickupViewEngine { * @memberof MobMPickupViewEngine */ public removeAll(): void { - this.simpleList.setData([]); + this.setSelectedData([]); } /** @@ -271,6 +275,17 @@ export class MobMPickupViewEngine extends MobPickupViewEngine { items.splice(index, 1); } }); + this.setSelectedData(items); + } + + /** + * @description 设置选中数据 + * @protected + * @param {IData[]} items + * @memberof MPickupViewEngine + */ + protected setSelectedData(items: IData[]): void { + super.setSelectedData(items); this.simpleList.setData(items); } diff --git a/src/view-engine/mob-pickup-view.engine.ts b/src/view-engine/mob-pickup-view.engine.ts index 5b6602e8..b0a01344 100644 --- a/src/view-engine/mob-pickup-view.engine.ts +++ b/src/view-engine/mob-pickup-view.engine.ts @@ -57,6 +57,10 @@ export class MobPickupViewEngine extends ViewEngineBase { this.view.slotProps.pickupviewpanel = {}; } this.view.slotProps.pickupviewpanel.singleSelect = true; + if (this.view.params.selecteddata) { + this.selectedData = JSON.parse(this.view.params.selecteddata); + delete this.view.params.selecteddata; + } } /** @@ -73,6 +77,18 @@ export class MobPickupViewEngine extends ViewEngineBase { this.pickupViewPanel.evt.on('onDataActive', event => { this.pickupViewPanelDataActive(event.data); }); + this.setSelectedData(this.selectedData); + } + + /** + * @description 设置选中数据 + * @protected + * @param {IData[]} items + * @memberof PickupViewEngine + */ + protected setSelectedData(items: IData[]): void { + this.selectedData = items; + this.pickupViewPanel.setSelectedData(items); } /** -- Gitee From ddee7e5ff4909e541aa1defaa4acf207cd1ab8e9 Mon Sep 17 00:00:00 2001 From: lijianxiong <1518062161@qq.com> Date: Sun, 24 Aug 2025 04:15:20 +0800 Subject: [PATCH 4/5] =?UTF-8?q?feat=EF=BC=9A=E6=96=B0=E5=A2=9E=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E5=99=A8=E7=94=B5=E5=AD=90=E7=AD=BE=E5=90=8D=EF=BC=8C?= =?UTF-8?q?=E7=BC=96=E8=BE=91=E5=99=A8=E6=A0=B7=E5=BC=8F=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E5=90=8D=E7=A7=B0=E4=B8=BA=EF=BC=9ASIGNATURE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/common/index.ts | 2 + src/editor/index.ts | 14 +- src/editor/text-box/index.ts | 1 + src/editor/text-box/signature/signature.scss | 153 +++++++ src/editor/text-box/signature/signature.tsx | 393 ++++++++++++++++++ .../text-box/text-box-editor.provider.ts | 3 + src/locale/en/index.ts | 11 + src/locale/zh-CN/index.ts | 10 + 8 files changed, 586 insertions(+), 1 deletion(-) create mode 100644 src/editor/text-box/signature/signature.scss create mode 100644 src/editor/text-box/signature/signature.tsx diff --git a/src/common/index.ts b/src/common/index.ts index 7afdfeee..3b7d53e2 100644 --- a/src/common/index.ts +++ b/src/common/index.ts @@ -6,6 +6,7 @@ import { IBizRouterView, IBizControlShell, IBizBadge, + IBizSignaturePad, } from '@ibiz-template/vue3-util'; import { App } from 'vue'; import { IBizActionToolbar } from './action-toolbar/action-toolbar'; @@ -54,6 +55,7 @@ export const IBizCommonComponents = { v.component(IBizMdCtrlSetting.name, IBizMdCtrlSetting); v.component(IBizPreviewImage.name, IBizPreviewImage); v.component(IBizCropping.name, IBizCropping); + v.component(IBizSignaturePad.name, IBizSignaturePad); }, }; diff --git a/src/editor/index.ts b/src/editor/index.ts index 47b9d4cc..89104b38 100644 --- a/src/editor/index.ts +++ b/src/editor/index.ts @@ -2,7 +2,12 @@ import { registerEditorProvider } from '@ibiz-template/runtime'; import { App, defineAsyncComponent } from 'vue'; import { NotSupportedEditor } from './not-supported-editor/not-supported-editor'; import { IBizSpan, SpanEditorProvider } from './span'; -import { IBizInput, IBizInputNumber, TextBoxEditorProvider } from './text-box'; +import { + IBizInput, + IBizInputNumber, + IBizSignature, + TextBoxEditorProvider, +} from './text-box'; import { IBizDropdown, IBizEmojiPicker, @@ -48,6 +53,7 @@ export const IBizEditor = { v.component(NotSupportedEditor.name, NotSupportedEditor); v.component(IBizInput.name, IBizInput); v.component(IBizInputNumber.name, IBizInputNumber); + v.component(IBizSignature.name, IBizSignature); v.component(IBizSpan.name, IBizSpan); v.component(IBizSwitch.name, IBizSwitch); v.component(IBizRadio.name, IBizRadio); @@ -114,6 +120,12 @@ export const IBizEditor = { registerEditorProvider('TEXTAREA', () => textBoxEditorProvider); registerEditorProvider('NUMBER', () => new TextBoxEditorProvider('NUMBER')); + // 电子签名 + registerEditorProvider( + 'MOBTEXT_SIGNATURE', + () => new TextBoxEditorProvider('SIGNATURE'), + ); + // 下拉列表框 registerEditorProvider( 'MOBDROPDOWNLIST', diff --git a/src/editor/text-box/index.ts b/src/editor/text-box/index.ts index 08d66c6b..8cee89a0 100644 --- a/src/editor/text-box/index.ts +++ b/src/editor/text-box/index.ts @@ -1,4 +1,5 @@ export { IBizInput } from './input/input'; export { IBizInputNumber } from './ibiz-input-number/ibiz-input-number'; +export { IBizSignature } from './signature/signature'; export * from './text-box-editor.controller'; export * from './text-box-editor.provider'; diff --git a/src/editor/text-box/signature/signature.scss b/src/editor/text-box/signature/signature.scss new file mode 100644 index 00000000..53a08bc1 --- /dev/null +++ b/src/editor/text-box/signature/signature.scss @@ -0,0 +1,153 @@ +/* stylelint-disable length-zero-no-unit */ +$signature-overlay: ( + // 适配浏览器安全距离 + 'padding-bottom': 0px +); + +@include b('signature') { + position: relative; + width: 100%; + height: 100%; + overflow: hidden; + border: none; + border-radius: getCssVar('border-radius', 'large'); + + @include e('modal') { + position: absolute; + z-index: 6; + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + + @include m('background') { + width: 100%; + height: 100%; + background-color: getCssVar('color', 'black'); + opacity: 0.1; + } + @include m('caption') { + position: absolute; + color: getCssVar('color', 'text', 2); + } + @include m('toolbar') { + position: absolute; + top: getCssVar('spacing', 'tight'); + right: getCssVar('spacing', 'tight'); + + .van-button { + width: getCssVar('spacing', 'loose'); + height: auto; + height: getCssVar('spacing', 'loose'); + background-color: var(--van-uploader-delete-background); + border: var(--van-uploader-delete-background); + opacity: 0.6; + + & + .van-button { + margin-left: getCssVar('spacing', 'loose'); + } + } + } + } + + @include e('content') { + width: 100%; + height: 100%; + background-repeat: no-repeat; + background-position: center center; + background-size: contain; + } + + // 只读逻辑,所有按钮不显示 + @include e('readonly') { + .#{bem('signature__modal--toolbar')} { + display: none; + } + } +} + +@include b('signature-overlay') { + @include set-component-css-var('signature-overlay', $signature-overlay); + + position: absolute; + z-index: 1; + width: 0; + height: 0; + background-color: getCssVar(color, bg, 1); + opacity: 0; + + @include when('full-screen') { + position: fixed; + inset: 0; + z-index: 9999; + width: 100vw; + height: 100vh; + opacity: 1; + + @include e('container') { + position: absolute; + top: 0; + left: 0; + width: calc(100vh - #{getCssVar('signature-overlay', 'padding-bottom')}); + height: 100vw; + transform: rotate(90deg) translateY(-100%); + transform-origin: top left; + } + @include e('header') { + position: absolute; + z-index: 6; + padding: getCssVar('spacing', 'extra-tight'); + text-align: left; + } + + @include e('footer') { + position: absolute; + right: getCssVar('spacing', 'tight'); + bottom: getCssVar('spacing', 'base'); + z-index: 6; + + .van-button { + height: auto; + padding: getCssVar('spacing', 'tight') getCssVar('spacing', 'loose'); + font-size: getCssVar('font-size', 'header-6'); + + & + .van-button { + margin-left: getCssVar('spacing', 'loose'); + } + } + } + + @include e('pad') { + $some-value: 100vw; + $some-value2: 100vh; + + position: absolute; + top: 0; + left: 0; + width: 100vw; + height: calc(100vh - #{getCssVar('signature-overlay', 'padding-bottom')}); + transform: rotate(-90deg) translateX(-100%); + transform-origin: top left; + } + } + + // 只读逻辑,所有按钮不显示 + @include when('readonly') { + .#{bem('signature-pad-container__header')}, + .#{bem('signature-pad-container__footer')} .van-button + .van-button { + display: none; + } + + .#{bem('signature-pad')} { + pointer-events: none; + } + } + + // 禁用逻辑,不能再签名,按钮禁用 + @include when('disabled') { + .#{bem('signature-pad')} { + pointer-events: none; + } + } +} diff --git a/src/editor/text-box/signature/signature.tsx b/src/editor/text-box/signature/signature.tsx new file mode 100644 index 00000000..02cb6a97 --- /dev/null +++ b/src/editor/text-box/signature/signature.tsx @@ -0,0 +1,393 @@ +import { defineComponent, ref, watch, Ref, nextTick } from 'vue'; +import { getPlatformType, PlatformType } from '@ibiz-template/runtime'; +import { + getEditorEmits, + getInputProps, + useNamespace, +} from '@ibiz-template/vue3-util'; +import { base64ToBlob, CoreConst, getAppCookie } from '@ibiz-template/core'; +import { TextBoxEditorController } from '../text-box-editor.controller'; +import './signature.scss'; + +/** + * 电子签名 + * + * @description 用于在业务系统中采集、展示和保存用户签名信息。基于`移动端文本框`编辑器进行扩展,编辑器样式代码名称为:SIGNATURE + * @primary + * @editorparams {name:mode,parameterType:'img' | 'file',defaultvalue:'img',description:指定签名的保存格式。当值为'img'时,直接保存为Base64格式的图片URL(以data:协议开头);当值为'file'时,系统会先将签名图片上传至服务器,再保存服务器返回的文件元信息(包含文件ID和名称)} + * @editorparams {name:buttons,parameterType:"Array<{label: string;type: string;buttonType?: string}>","defaultvalue":[{"label":"撤销","type":"UNDO"},{"label":"重写","type":"REWRITE"},{"label":"确认","type":"CONFIRM","buttonType":"PRIMARY"}],description:配置全屏签名面板的操作按钮。数组中每个对象表示一个按钮:label为按钮显示文本;type为按钮触发的事件类型(UNDO-撤销上一步,REWRITE-清空重写,CONFIRM-确认保存);buttonType可选,指定按钮样式(支持PRIMARY/SUCCESS/DEFAULT/DANGER/WARNING)。示例:仅显示确认按钮可配置为[{"label":"确认","type":"CONFIRM","buttonType":"PRIMARY"}]} + * @editorparams {name:dotsize,parameterType:'number',defaultvalue:0,description:点的大小(单位:像素)。控制点击画布时生成的点的尺寸,0表示根据线条宽度自动计算点的大小} + * @editorparams {name:minwidth,parameterType:'number',defaultvalue:2,description:线条最小宽度(单位:像素)。控制签名线条的最细宽度,绘制速度越快,线条越接近此值} + * @editorparams {name:maxwidth,parameterType:'number',defaultvalue:2,description:线条最大宽度(单位:像素)。控制签名线条的最粗宽度,绘制速度越慢,线条越接近此值} + * @editorparams {name:pencolor,parameterType:'string',defaultvalue:'black',description:画笔颜色。签名轨迹的颜色,可接受CSS颜色格式(如#ff0000、rgb(255,0,0)等)} + * @editorparams {name:velocityfilterweight,parameterType:'number',defaultvalue:0.7,description:速度过滤权重。用于平滑处理绘制速度的计算,影响线条粗细随速度的变化幅度。值越接近1,当前速度对线条粗细影响越大;值越小,线条过渡越平滑} + * @editorparams {name:mindistance,parameterType:'number',defaultvalue:5,description:连续点最小距离(单位:像素)。当连续两个绘制点的距离小于此值时,不记录新点,用于减少冗余数据并优化绘制流畅度} + * @editorparams {name:backgroundcolor,parameterType:'string',defaultvalue:'rgba(0,0,0,0)',description:画布背景色。签名画布的背景颜色,导出图片时会包含此背景,可接受CSS颜色格式} + * @editorparams {name:throttle,parameterType:'number',defaultvalue:16,description:事件节流时间(单位:毫秒)。限制绘制事件的触发频率,避免高频操作导致性能问题} + * @editorparams {name:canvascontextoptions,parameterType:'object',defaultvalue:'{}',description:Canvas 2D上下文初始化配置。用于设置Canvas渲染上下文的额外参数(如alpha、willReadFrequently等CanvasRenderingContext2DSettings属性)} + * @ignoreprops overflowMode + * @ignoreemits infoTextChange + */ +export const IBizSignature = defineComponent({ + name: 'IBizSignature', + props: getInputProps(), + emits: getEditorEmits(), + setup(props, { emit }) { + const ns = useNamespace('signature'); + const c = props.controller; + const editorModel = c.model; + + // 请求头 + const headers: Ref = ref({ + [`${ibiz.env.tokenHeader}Authorization`]: `${ + ibiz.env.tokenPrefix + }Bearer ${getAppCookie(CoreConst.TOKEN)}`, + }); + // 上传文件路径 + const uploadUrl: Ref = ref(''); + // 下载文件路径 + const downloadUrl: Ref = ref(''); + const signatureRef = ref(); + const fullScreen = ref(false); + const currentDataURL = ref(''); + const currentVal = ref(''); + + let saveMode: 'img' | 'file' = 'img'; + // 按钮配置数组 + let buttons = [ + { + label: ibiz.i18n.t('editor.signature.undo'), + type: 'UNDO', + }, + { + label: ibiz.i18n.t('editor.signature.rewrite'), + type: 'REWRITE', + }, + { + label: ibiz.i18n.t('editor.signature.confirm'), + type: 'CONFIRM', + buttonType: 'PRIMARY', + }, + ]; + + if (editorModel.editorParams) { + if (editorModel.editorParams.mode) { + saveMode = editorModel.editorParams.mode; + } + if (editorModel.editorParams.buttons) { + try { + buttons = JSON.parse(editorModel.editorParams.buttons); + } catch (error) { + ibiz.log.error(error); + } + } + } + + /** + * 获取安全距离具体值 + * @return {*} {string} + */ + const getSafeDistance = (): string => { + switch (getPlatformType()) { + case PlatformType.ANDROID: + return '52px'; + case PlatformType.IOS: + return '82px'; + default: + return '0px'; + } + }; + + /** + * 重置画布,根据当前数据重新加载签名 + * @return {*} + */ + const restCavans = (): void => { + signatureRef.value?.updateSignaturePad(() => { + nextTick(() => { + if (currentDataURL.value) { + signatureRef.value?.signaturePad.fromDataURL(currentDataURL.value, { + // 旋转适配横屏 + rotation: 90, + }); + } else { + signatureRef.value?.signaturePad.clear(); + } + }); + }); + }; + + /** + * 处理当前值,加载初始签名数据 + * @return {*} + */ + const handleCurrentVal = async (): Promise => { + if (currentVal.value) { + ibiz.loading.showRedirect(); + if (saveMode === 'img') { + currentDataURL.value = currentVal.value; + } else if (downloadUrl.value) { + const fileData = JSON.parse(currentVal.value)[0]; + const _url = downloadUrl.value.replace('%fileId%', fileData.id); + try { + const fileBlob = await ibiz.util.file.requestFile(_url); + // 通过文件流创建下载链接 + const dataUrl = + await signatureRef.value?.signaturePad.blobToDataURL( + fileBlob as Blob, + ); + currentDataURL.value = dataUrl; + } catch (error) { + ibiz.log.error(error); + } + } + signatureRef.value?.signaturePad.loadImage(currentDataURL.value, () => { + ibiz.loading.hideRedirect(); + }); + } + }; + + // data响应式变更基础路径 + watch( + () => props.data, + newVal => { + if (newVal) { + const urls = ibiz.util.file.calcFileUpDownUrl( + c.context, + c.params, + newVal, + c.editorParams, + ); + uploadUrl.value = urls.uploadUrl; + downloadUrl.value = urls.downloadUrl; + } + }, + { immediate: true, deep: true }, + ); + + watch( + () => props.value, + async (newVal, oldVal) => { + if (newVal !== oldVal) { + if (!newVal) { + currentVal.value = ''; + } else { + // 适配保存模式 + currentVal.value = newVal as string; + } + await handleCurrentVal(); + } + }, + { immediate: true }, + ); + + /** + * 处理抛值 + * @param {string} _value + */ + const handleEmit = (_value: string): void => { + emit('change', _value); + }; + + /** + * 处理图片上传 (文件模式) + * @param {string} dataURL + * @return {*} + */ + const handleUpload = async (dataURL: string): Promise => { + const blob = base64ToBlob(dataURL); + const file = await ibiz.util.file.fileUpload( + uploadUrl.value, + blob, + headers.value, + ); + + return file; + }; + + /** + * 处理遮罩层点击事件 + * @return {*} + */ + const handleModalClick = (): void => { + fullScreen.value = true; + restCavans(); + }; + + /** + * 处理移除签名事件 + * @param {MouseEvent} _evt + */ + const handleRemove = (_evt: MouseEvent): void => { + _evt.stopPropagation(); + signatureRef.value?.signaturePad.clear(); + currentDataURL.value = ''; + handleEmit(''); + }; + + /** + * 处理画板容器点击事件 + * @return {*} + */ + const handlePadContainerClick = (): void => { + if (props.disabled || props.readonly) fullScreen.value = false; + }; + + /** + * 处理文件变化事件 + * @param {string} dataURL + */ + const handleFileChange = async (dataURL: string): Promise => { + const file = await handleUpload(dataURL); + handleEmit(JSON.stringify([{ name: file.name, id: file.id }])); + }; + + /** + * 处理确认事件 + * @return {*} + */ + const handleConfirm = (): void => { + fullScreen.value = false; + + // 当为空白,或者未进行过签名时,不保存 + if ( + signatureRef.value?.signaturePad.isEmpty() || + !signatureRef.value?.signaturePad.isRedrawn() + ) + return; + + const dataURL = signatureRef.value?.signaturePad.toDataURL(undefined, { + // 旋转为述评适配预览 + rotation: -90, + }); + currentDataURL.value = dataURL; + switch (saveMode) { + case 'file': + handleFileChange(dataURL); + break; + case 'img': + default: + handleEmit(dataURL); + break; + } + }; + + /** + * 处理按钮点击事件 + * @param {string} _type + */ + const handleButtonClick = (_type: string): void => { + switch (_type) { + case 'UNDO': + signatureRef.value?.signaturePad.undoLastStep(); + break; + case 'REWRITE': + signatureRef.value?.signaturePad.clear(); + break; + case 'CONFIRM': + handleConfirm(); + break; + default: + break; + } + }; + + return { + c, + ns, + signatureRef, + fullScreen, + currentDataURL, + buttons, + getSafeDistance, + handleModalClick, + handleConfirm, + handleRemove, + handlePadContainerClick, + handleButtonClick, + }; + }, + render() { + return ( +
+
+
+ +
+ {this.currentDataURL && ( + + )} +
+ {!this.currentDataURL && ( + + {ibiz.i18n.t('editor.signature.addSignature')} + + )} +
+
+
+
+
+ {ibiz.i18n.t('editor.signature.signaturePrompt')} +
+ + + +
+ {this.buttons.map(_btn => { + return ( + this.handleButtonClick(_btn.type)} + > + {_btn.label} + + ); + })} +
+
+
+
+ ); + }, +}); diff --git a/src/editor/text-box/text-box-editor.provider.ts b/src/editor/text-box/text-box-editor.provider.ts index f1ad042f..60cb5ccd 100644 --- a/src/editor/text-box/text-box-editor.provider.ts +++ b/src/editor/text-box/text-box-editor.provider.ts @@ -24,6 +24,9 @@ export class TextBoxEditorProvider implements IEditorProvider { if (editorType === 'NUMBER') { this.formEditor = 'IBizInputNumber'; this.gridEditor = 'IBizInputNumber'; + } else if (editorType === 'SIGNATURE') { + this.formEditor = 'IBizSignature'; + this.gridEditor = 'IBizSignature'; } } diff --git a/src/locale/en/index.ts b/src/locale/en/index.ts index 1ac71618..c7d3aadd 100644 --- a/src/locale/en/index.ts +++ b/src/locale/en/index.ts @@ -64,6 +64,17 @@ export default { formItemContainer: { more: 'More', }, + mpicker: { + simpleErr: + 'The value of the SIMPLE type address bar {props.value} does not meet the requirements of a JSON string', + }, + signature: { + undo: 'Undo', + rewrite: 'Rewrite', + confirm: 'Confirm', + addSignature: 'Click here to add signature', + signaturePrompt: 'Please write horizontally in the blank area', + }, }, // 部件 control: { diff --git a/src/locale/zh-CN/index.ts b/src/locale/zh-CN/index.ts index c88300c4..40979e3a 100644 --- a/src/locale/zh-CN/index.ts +++ b/src/locale/zh-CN/index.ts @@ -200,6 +200,16 @@ export default { dateRangePicker: { selectRange: '请选择日期范围', }, + mpicker: { + simpleErr: 'SIMPLE类型地址栏值格式{props.value}不符合JSON字符串要求', + }, + signature: { + undo: '撤销', + rewrite: '重写', + confirm: '确认', + addSignature: '点击此处添加签名', + signaturePrompt: '请在空白区域内横向书写', + }, }, // 多语言 locale: { -- Gitee From 665aba741d1803d2105e29689e40b3a3f809fbff Mon Sep 17 00:00:00 2001 From: lijianxiong <1518062161@qq.com> Date: Sun, 24 Aug 2025 04:16:05 +0800 Subject: [PATCH 5/5] =?UTF-8?q?feat=EF=BC=9A=E6=9B=B4=E6=96=B0CHANGELOG?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fb30403..f1062183 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,13 @@ - 新增应用主题配置 - 第三方登录组件支持通过自定义补充参数指定需要展示的第三方登录方式 - 新增应用挂载时添加水印逻辑,适配应用全局参数 watermark(应用水印配置参数),可在应用自定义参数中配置,配置示例包括启用水印(globalConfig.watermark.enable=true)、设置显示文本(globalConfig.watermark.text=测试内容) +- 新增编辑器电子签名,编辑器样式代码名称为:SIGNATURE + +### Fixed + +- 修复表单分组标题栏关闭模式配置后未生效 +- 修复编辑器移动端多数据选择抛值与解析值逻辑错误,同步PC端多数据选择编辑器逻辑 +- 修复选择面板取消选中数据时简单列表未更新清空数据且始终缓存,修复移动端选择面板回显逻辑异常 ## [0.0.54] - 2025-02-28 -- Gitee