diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f79b4d76899abaa6cf1ffbf8c882ec0009ebc0b..cb1cf16659827dd46f9516fad792c3a8b5e5e895 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ ## [Unreleased] +### Added + +- 新增电子签名编辑器样式,基于文本框编辑器进行扩展,编辑器样式代码名称为:SIGNATURE + ## [0.7.41-alpha.20] - 2025-08-24 ### Added diff --git a/src/common/index.ts b/src/common/index.ts index 7abd94e9bb089c37d258510730297e55af873f98..5ee4f2e09b4c6a729854b74c61ba2a0c778b2a1b 100644 --- a/src/common/index.ts +++ b/src/common/index.ts @@ -7,6 +7,7 @@ import { IBizRouterView, IBizViewShell, IBizBadge, + IBizSignaturePad, } from '@ibiz-template/vue3-util'; import { IBizActionToolbar } from './action-toolbar/action-toolbar'; import { IBizCol } from './col/col'; @@ -108,6 +109,7 @@ export const IBizCommonComponents = { v.component(IBizControlNavigation.name, IBizControlNavigation); v.component(IBizGanttSetting.name, IBizGanttSetting); v.component(IBizNavSplit.name, IBizNavSplit); + v.component(IBizSignaturePad.name, IBizSignaturePad); }, }; diff --git a/src/editor/index.ts b/src/editor/index.ts index f8a792547bc592b35bb4b22d8f7060cd7fe00b3b..cf9c31ace7c4619531ac19cad3e1c5dc40f9ccb5 100644 --- a/src/editor/index.ts +++ b/src/editor/index.ts @@ -6,6 +6,7 @@ import { IBizInput, IBizInputNumber, IBizInputIP, + IBizSignature, TextBoxEditorProvider, } from './text-box'; import { @@ -76,6 +77,7 @@ export const IBizEditor = { v.component(IBizInput.name, IBizInput); v.component(IBizInputNumber.name, IBizInputNumber); v.component(IBizInputIP.name, IBizInputIP); + v.component(IBizSignature.name, IBizSignature); v.component(IBizDropdown.name, IBizDropdown); v.component(IBizEmojiPicker.name, IBizEmojiPicker); v.component(IBizTreePicker.name, IBizTreePicker); @@ -152,6 +154,11 @@ export const IBizEditor = { ); registerEditorProvider('MOBTEXTAREA', () => textBoxEditorProvider); registerEditorProvider('MOBPASSWORD', () => textBoxEditorProvider); + // 电子签名 + registerEditorProvider( + 'TEXTBOX_SIGNATURE', + () => new TextBoxEditorProvider('SIGNATURE'), + ); // 下拉列表框 registerEditorProvider( 'DROPDOWNLIST', diff --git a/src/editor/text-box/index.ts b/src/editor/text-box/index.ts index 27d8a8fc99e1dbe58eef283c1989931b00f09300..055fb9bac13b42e13fc625ffa8f91b9b102cf2f2 100644 --- a/src/editor/text-box/index.ts +++ b/src/editor/text-box/index.ts @@ -1,5 +1,6 @@ export { IBizInput } from './input/input'; export { IBizInputNumber } from './ibiz-input-number/ibiz-input-number'; export { IBizInputIP } from './ibiz-input-ip/ibiz-input-ip'; +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 0000000000000000000000000000000000000000..9f299b334751f8507308cfa224d7486399efa2e8 --- /dev/null +++ b/src/editor/text-box/signature/signature.scss @@ -0,0 +1,47 @@ +$signature: ( + 'min-height': 200px, +); + +@include b('signature') { + @include set-component-css-var('signature', $signature); + + width: 100%; + height: 100%; + min-height: getCssVar('signature', 'min-height'); + + @include e('pad') { + + .#{bem('signature-pad__container')} { + background-color: getCssVar(color, bg, 0); + } + } + @include e('toolbar') { + display: flex; + align-items: center; + justify-content: flex-end; + width: 100%; + margin-top: getCssVar('spacing', 'base'); + } + @include m('readonly') { + pointer-events: none; + } + @include m('disabled') { + pointer-events: none; + } +} + +@include b(form-item) { + @include b(signature) { + @include when(show-default) { + &:hover { + .#{bem('signature__toolbar')} { + display: flex; + } + } + + .#{bem('signature__toolbar')} { + display: none; + } + } + } +} \ No newline at end of file diff --git a/src/editor/text-box/signature/signature.tsx b/src/editor/text-box/signature/signature.tsx new file mode 100644 index 0000000000000000000000000000000000000000..cae4b779382256c9f735995e6abc951a5ae93a81 --- /dev/null +++ b/src/editor/text-box/signature/signature.tsx @@ -0,0 +1,321 @@ +import { defineComponent, ref, watch, Ref, computed } from 'vue'; +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":"string","defaultvalue":"'\\[{\"label\":\"撤销\",\"type\":\"undo\"},{\"label\":\"重写\",\"type\":\"rewrite\"},{\"label\":\"确认\",\"type\":\"confirm\",\"buttonType\":\"primary\"}\\]'","description":"配置签名的操作按钮。JSON 字符串数组中每个对象表示一个按钮: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)"} + * @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":"readonly","parameterType":"boolean","defaultvalue":false,"description":"设置编辑器是否为只读态"} + * @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); + } + } + } + + // 是否显示表单默认内容 + const showFormDefaultContent = computed(() => { + if ( + props.controlParams && + props.controlParams.editmode === 'hover' && + !props.readonly + ) { + return true; + } + return false; + }); + + /** + * 处理当前值,加载初始签名数据 + * @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 || ''}`; + } + await handleCurrentVal(); + } + }, + { immediate: true }, + ); + + /** + * 处理抛值 + * @param {string} _value + */ + const handleEmit = (_value: string | null): void => { + emit('change', _value); + }; + + /** + * 获取文件类型 + * @param {string} dataURL + * @return {*} {BlobPropertyBag} + */ + const getFileType = (dataURL: string): BlobPropertyBag => { + const parts = dataURL.split(','); + const mime = parts[0].split(':')[1].split(';')[0]; + return { type: mime }; + }; + + /** + * 处理图片上传 (文件模式) + * @param {string} dataURL + * @return {*} + */ + const handleUpload = async (dataURL: string): Promise => { + const { type } = getFileType(dataURL); + const blob = base64ToBlob(dataURL); + const fileName = `signature_${Date.now()}.png`; + const file = new File([blob], fileName, { type }); + const fileInfo = await ibiz.util.file.fileUpload( + uploadUrl.value, + file, + headers.value, + ); + + return fileInfo; + }; + + /** + * 处理移除签名事件 + * @return {*} + */ + const handleRemove = (): void => { + signatureRef.value?.signaturePad.clear(); + currentDataURL.value = ''; + handleEmit(null); + }; + + /** + * 处理文件变化事件 + * @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.isRedrawn()) return; + + // 当为空白时,直接删除 + if (signatureRef.value?.signaturePad.isEmpty()) { + handleRemove(); + return; + } + + const dataURL = signatureRef.value?.signaturePad.toDataURL(); + + if (dataURL === currentDataURL.value) return; + + 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, + showFormDefaultContent, + handleButtonClick, + }; + }, + render() { + return ( +
+ + + {!this.readonly && ( +
+ {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 5487a10afcd282ec215506e3487b7cfaabbab552..4f066d033d8910d55e7512bcfda7d5c805bf9d56 100644 --- a/src/editor/text-box/text-box-editor.provider.ts +++ b/src/editor/text-box/text-box-editor.provider.ts @@ -29,6 +29,10 @@ export class TextBoxEditorProvider implements IEditorProvider { this.formEditor = 'IBizInputIP'; this.gridEditor = 'IBizInputIP'; } + if (editorType === 'SIGNATURE') { + this.formEditor = 'IBizSignature'; + this.gridEditor = 'IBizSignature'; + } } async createController( diff --git a/src/locale/en/index.ts b/src/locale/en/index.ts index df75a3ad27bf3327545b134aad666dbd08a1b071..9027535b40c3e1b1483d719676a71ca8364a5680 100644 --- a/src/locale/en/index.ts +++ b/src/locale/en/index.ts @@ -751,6 +751,13 @@ export default { expandAll: 'Expand all', collapseAll: 'Collapse all', }, + signature: { + undo: 'Undo', + rewrite: 'Rewrite', + confirm: 'Confirm', + addSignature: 'Click here to add signature', + signaturePrompt: 'Please write horizontally in the blank area', + }, }, panelComponent: { authUserinfo: { diff --git a/src/locale/zh-CN/index.ts b/src/locale/zh-CN/index.ts index 9732897cab7b03d6f09734a6befd82bdd193285c..325721d47f47af01fbcc9215618ed2ac6bd4948e 100644 --- a/src/locale/zh-CN/index.ts +++ b/src/locale/zh-CN/index.ts @@ -702,6 +702,13 @@ export default { expandAll: '全部展开', collapseAll: '全部收起', }, + signature: { + undo: '撤销', + rewrite: '重写', + confirm: '确认', + addSignature: '点击此处添加签名', + signaturePrompt: '请在空白区域内横向书写', + }, }, panelComponent: { authUserinfo: {