openxiangda-cli 2.0.0-alpha.89 → 2.0.0-alpha.90
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/bin/run.js +1 -0
- package/dist/commands/skill.d.ts +26 -0
- package/dist/commands/skill.d.ts.map +1 -0
- package/dist/commands/skill.js +43 -0
- package/dist/commands/skill.js.map +1 -0
- package/dist/create-workspace.d.ts.map +1 -1
- package/dist/create-workspace.js +3 -3
- package/dist/create-workspace.js.map +1 -1
- package/dist/toolchain-capsule.d.ts.map +1 -1
- package/dist/toolchain-capsule.js +4 -8
- package/dist/toolchain-capsule.js.map +1 -1
- package/package.json +6 -7
- package/template/AGENTS.md +2 -1
- package/template/README.md +3 -3
- package/template/apps/server/package.json +1 -1
- package/template/apps/server/src/app.module.ts +1 -1
- package/template/apps/server/src/main.ts +1 -1
- package/template/apps/web/e2e/client-fixture.ts +5 -0
- package/template/apps/web/e2e/data-api-live-fixture.tsx +19 -14
- package/template/apps/web/e2e/field-protocol-fixture.tsx +87 -20
- package/template/apps/web/e2e/field-protocol.spec.ts +86 -0
- package/template/apps/web/e2e/resources.spec.ts +20 -181
- package/template/apps/web/field-protocol.e2e.html +3 -0
- package/template/apps/web/package.json +2 -5
- package/template/apps/web/src/main.tsx +12 -60
- package/template/apps/web/test/contracts.test.ts +52 -104
- package/template/openxiangda.config.ts +1 -1
- package/template/package.json +1 -3
- package/template/packages/contracts/src/generated.ts +1 -0
- package/template/scripts/verify-template-budget.mjs +2 -2
- package/template/apps/web/src/AuthoritativeSelector.tsx +0 -661
- package/template/apps/web/src/FilePreviewPage.tsx +0 -324
- package/template/apps/web/src/RoleSubjectSelect.tsx +0 -128
- package/template/apps/web/src/Shell.tsx +0 -445
- package/template/apps/web/src/appearance.tsx +0 -147
- package/template/apps/web/src/components/platform-fields/AddressField.tsx +0 -338
- package/template/apps/web/src/components/platform-fields/AttachmentFileList.tsx +0 -245
- package/template/apps/web/src/components/platform-fields/CascadeField.tsx +0 -49
- package/template/apps/web/src/components/platform-fields/DateTimeField.tsx +0 -148
- package/template/apps/web/src/components/platform-fields/JsonField.tsx +0 -77
- package/template/apps/web/src/components/platform-fields/LocationField.tsx +0 -164
- package/template/apps/web/src/components/platform-fields/PlatformDirectoryPicker.tsx +0 -673
- package/template/apps/web/src/components/platform-fields/ResourceReferenceField.tsx +0 -67
- package/template/apps/web/src/components/platform-fields/RichTextField.tsx +0 -196
- package/template/apps/web/src/components/platform-fields/SignatureField.tsx +0 -315
- package/template/apps/web/src/components/platform-fields/SubtableField.tsx +0 -553
- package/template/apps/web/src/components/platform-fields/address-value.ts +0 -80
- package/template/apps/web/src/components/platform-fields/cascade-value.ts +0 -66
- package/template/apps/web/src/components/platform-fields/directory-value.ts +0 -53
- package/template/apps/web/src/components/platform-fields/field-form-codec.ts +0 -98
- package/template/apps/web/src/components/platform-fields/location-value.ts +0 -87
- package/template/apps/web/src/components/platform-fields/resource-query.ts +0 -131
- package/template/apps/web/src/components/platform-fields/rich-text-value.ts +0 -59
- package/template/apps/web/src/components/platform-fields/subtable-value.ts +0 -187
- package/template/apps/web/src/components/resource/GeneratedResourceCrud.tsx +0 -1184
- package/template/apps/web/src/components/resource/ResourceBatchActions.tsx +0 -326
- package/template/apps/web/src/components/resource/StandardResourcePages.tsx +0 -392
- package/template/apps/web/src/components/resource/SurfaceFields.tsx +0 -915
- package/template/apps/web/src/components/resource/generated-resource-definition.ts +0 -18
- package/template/apps/web/src/components/resource/resource-import.ts +0 -205
- package/template/apps/web/src/data-provider.ts +0 -72
- package/template/apps/web/src/platform-client.ts +0 -708
- package/template/apps/web/src/runtime-meta.ts +0 -75
- package/template/apps/web/src/runtime.tsx +0 -227
- package/template/apps/web/src/styles.css +0 -1941
- package/template/apps/web/test/address-field.test.ts +0 -66
- package/template/apps/web/test/cascade-value.test.ts +0 -68
- package/template/apps/web/test/directory-value.test.ts +0 -55
- package/template/apps/web/test/field-bounds.test.ts +0 -17
- package/template/apps/web/test/field-form-codec.test.ts +0 -85
- package/template/apps/web/test/location-field.test.ts +0 -83
- package/template/apps/web/test/resource-import.test.ts +0 -143
- package/template/apps/web/test/resource-query.test.ts +0 -144
- package/template/apps/web/test/rich-json-field.test.ts +0 -53
- package/template/apps/web/test/signature-serial-field.test.ts +0 -40
- package/template/apps/web/test/subtable-value.test.ts +0 -144
|
@@ -1,67 +0,0 @@
|
|
|
1
|
-
import { Form } from 'antd';
|
|
2
|
-
import type {
|
|
3
|
-
DataFieldSurface,
|
|
4
|
-
ResourceReferenceValue,
|
|
5
|
-
} from 'openxiangda-contracts/browser';
|
|
6
|
-
import {
|
|
7
|
-
AuthoritativeSelector,
|
|
8
|
-
MobileAuthoritativeSelector,
|
|
9
|
-
} from '../../AuthoritativeSelector';
|
|
10
|
-
|
|
11
|
-
export function ResourceReferenceField({
|
|
12
|
-
field,
|
|
13
|
-
fieldCode,
|
|
14
|
-
resourceCode,
|
|
15
|
-
operation,
|
|
16
|
-
disabled,
|
|
17
|
-
mobile,
|
|
18
|
-
value,
|
|
19
|
-
onChange,
|
|
20
|
-
}: {
|
|
21
|
-
field: DataFieldSurface;
|
|
22
|
-
fieldCode: string;
|
|
23
|
-
resourceCode?: string;
|
|
24
|
-
operation: 'create' | 'update';
|
|
25
|
-
disabled?: boolean;
|
|
26
|
-
mobile?: boolean;
|
|
27
|
-
value?: ResourceReferenceValue | ResourceReferenceValue[];
|
|
28
|
-
onChange?: (
|
|
29
|
-
value: ResourceReferenceValue | ResourceReferenceValue[] | undefined
|
|
30
|
-
) => void;
|
|
31
|
-
}) {
|
|
32
|
-
const form = Form.useFormInstance();
|
|
33
|
-
const formValues = Form.useWatch([], form) as
|
|
34
|
-
| Record<string, unknown>
|
|
35
|
-
| undefined;
|
|
36
|
-
const bindings = Object.fromEntries(
|
|
37
|
-
(field.source?.filters || []).flatMap(filter =>
|
|
38
|
-
'binding' in filter
|
|
39
|
-
? [[filter.binding.field, formValues?.[filter.binding.field]]]
|
|
40
|
-
: []
|
|
41
|
-
)
|
|
42
|
-
);
|
|
43
|
-
const Selector = mobile
|
|
44
|
-
? MobileAuthoritativeSelector
|
|
45
|
-
: AuthoritativeSelector;
|
|
46
|
-
return (
|
|
47
|
-
<Selector
|
|
48
|
-
bindings={bindings}
|
|
49
|
-
disabled={disabled}
|
|
50
|
-
fieldCode={fieldCode}
|
|
51
|
-
multiple={field.type === 'resource-ref.multiple'}
|
|
52
|
-
onChange={next =>
|
|
53
|
-
onChange?.(
|
|
54
|
-
next as
|
|
55
|
-
| ResourceReferenceValue
|
|
56
|
-
| ResourceReferenceValue[]
|
|
57
|
-
| undefined
|
|
58
|
-
)
|
|
59
|
-
}
|
|
60
|
-
operation={operation}
|
|
61
|
-
placeholder={`搜索并选择${field.label}`}
|
|
62
|
-
resourceCode={resourceCode}
|
|
63
|
-
source="resource"
|
|
64
|
-
value={value}
|
|
65
|
-
/>
|
|
66
|
-
);
|
|
67
|
-
}
|
|
@@ -1,196 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
BoldOutlined,
|
|
3
|
-
FileImageOutlined,
|
|
4
|
-
ItalicOutlined,
|
|
5
|
-
LinkOutlined,
|
|
6
|
-
OrderedListOutlined,
|
|
7
|
-
StrikethroughOutlined,
|
|
8
|
-
UnderlineOutlined,
|
|
9
|
-
UnorderedListOutlined,
|
|
10
|
-
} from '@ant-design/icons';
|
|
11
|
-
import { Button, Space, Tooltip } from 'antd';
|
|
12
|
-
import type { DataFileRef } from 'openxiangda-contracts/browser';
|
|
13
|
-
import { useEffect, useRef, useState } from 'react';
|
|
14
|
-
import { dataRichTextImageSource } from '../../platform-client';
|
|
15
|
-
import { sanitizeRichText } from './rich-text-value';
|
|
16
|
-
|
|
17
|
-
const INLINE_IMAGE_ACCEPT = 'image/png,image/jpeg,image/webp,image/gif';
|
|
18
|
-
const INLINE_IMAGE_MAX_BYTES = 10 * 1024 * 1024;
|
|
19
|
-
const INLINE_IMAGE_MAX_COUNT = 20;
|
|
20
|
-
|
|
21
|
-
const TOOLS = [
|
|
22
|
-
{ command: 'bold', label: '加粗', icon: <BoldOutlined /> },
|
|
23
|
-
{ command: 'italic', label: '斜体', icon: <ItalicOutlined /> },
|
|
24
|
-
{ command: 'underline', label: '下划线', icon: <UnderlineOutlined /> },
|
|
25
|
-
{ command: 'strikeThrough', label: '删除线', icon: <StrikethroughOutlined /> },
|
|
26
|
-
{ command: 'insertOrderedList', label: '有序列表', icon: <OrderedListOutlined /> },
|
|
27
|
-
{ command: 'insertUnorderedList', label: '无序列表', icon: <UnorderedListOutlined /> },
|
|
28
|
-
] as const;
|
|
29
|
-
|
|
30
|
-
export function RichTextValueDisplay({ value }: { value: string }) {
|
|
31
|
-
return (
|
|
32
|
-
<div
|
|
33
|
-
className="oxa-rich-text-value"
|
|
34
|
-
dangerouslySetInnerHTML={{ __html: value }}
|
|
35
|
-
/>
|
|
36
|
-
);
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
export function RichTextField({
|
|
40
|
-
value = '',
|
|
41
|
-
onChange,
|
|
42
|
-
disabled = false,
|
|
43
|
-
mobile = false,
|
|
44
|
-
onUpload,
|
|
45
|
-
resourceCode,
|
|
46
|
-
}: {
|
|
47
|
-
value?: string;
|
|
48
|
-
onChange?: (value: string) => void;
|
|
49
|
-
disabled?: boolean;
|
|
50
|
-
mobile?: boolean;
|
|
51
|
-
onUpload?: (file: File) => Promise<DataFileRef>;
|
|
52
|
-
resourceCode?: string;
|
|
53
|
-
}) {
|
|
54
|
-
const editor = useRef<HTMLDivElement | null>(null);
|
|
55
|
-
const imageInput = useRef<HTMLInputElement | null>(null);
|
|
56
|
-
const [imageUploading, setImageUploading] = useState(false);
|
|
57
|
-
const [imageError, setImageError] = useState('');
|
|
58
|
-
|
|
59
|
-
useEffect(() => {
|
|
60
|
-
if (editor.current && editor.current.innerHTML !== value) {
|
|
61
|
-
editor.current.innerHTML = value;
|
|
62
|
-
}
|
|
63
|
-
}, [value]);
|
|
64
|
-
|
|
65
|
-
const emit = () => {
|
|
66
|
-
if (!editor.current) return;
|
|
67
|
-
const normalized = sanitizeRichText(editor.current.innerHTML);
|
|
68
|
-
if (editor.current.innerHTML !== normalized) editor.current.innerHTML = normalized;
|
|
69
|
-
onChange?.(normalized);
|
|
70
|
-
};
|
|
71
|
-
|
|
72
|
-
const command = (name: string, argument?: string) => {
|
|
73
|
-
editor.current?.focus();
|
|
74
|
-
document.execCommand(name, false, argument);
|
|
75
|
-
emit();
|
|
76
|
-
};
|
|
77
|
-
|
|
78
|
-
const insertLink = () => {
|
|
79
|
-
const url = window.prompt('链接地址');
|
|
80
|
-
if (url && /^(?:https?:|mailto:|tel:)/i.test(url.trim())) {
|
|
81
|
-
command('createLink', url.trim());
|
|
82
|
-
}
|
|
83
|
-
};
|
|
84
|
-
|
|
85
|
-
const insertImage = async (file: File) => {
|
|
86
|
-
if (!editor.current || !onUpload || !resourceCode) return;
|
|
87
|
-
const selection = window.getSelection();
|
|
88
|
-
const selectedRange =
|
|
89
|
-
selection?.rangeCount && editor.current.contains(selection.anchorNode)
|
|
90
|
-
? selection.getRangeAt(0).cloneRange()
|
|
91
|
-
: null;
|
|
92
|
-
try {
|
|
93
|
-
setImageError('');
|
|
94
|
-
setImageUploading(true);
|
|
95
|
-
if (!INLINE_IMAGE_ACCEPT.split(',').includes(file.type)) {
|
|
96
|
-
throw new Error('仅支持 PNG、JPEG、WebP 或 GIF 图片');
|
|
97
|
-
}
|
|
98
|
-
if (file.size > INLINE_IMAGE_MAX_BYTES) {
|
|
99
|
-
throw new Error('单张图片不能超过 10MB');
|
|
100
|
-
}
|
|
101
|
-
if (editor.current.querySelectorAll('img').length >= INLINE_IMAGE_MAX_COUNT) {
|
|
102
|
-
throw new Error('每个富文本最多插入 20 张图片');
|
|
103
|
-
}
|
|
104
|
-
const uploaded = await onUpload(file);
|
|
105
|
-
const image = document.createElement('img');
|
|
106
|
-
image.src = dataRichTextImageSource(resourceCode, uploaded.id);
|
|
107
|
-
image.alt = file.name;
|
|
108
|
-
const range = selectedRange || document.createRange();
|
|
109
|
-
if (!selectedRange) {
|
|
110
|
-
range.selectNodeContents(editor.current);
|
|
111
|
-
range.collapse(false);
|
|
112
|
-
}
|
|
113
|
-
range.deleteContents();
|
|
114
|
-
range.insertNode(image);
|
|
115
|
-
range.setStartAfter(image);
|
|
116
|
-
range.collapse(true);
|
|
117
|
-
selection?.removeAllRanges();
|
|
118
|
-
selection?.addRange(range);
|
|
119
|
-
emit();
|
|
120
|
-
} catch (error) {
|
|
121
|
-
setImageError(error instanceof Error ? error.message : String(error));
|
|
122
|
-
} finally {
|
|
123
|
-
setImageUploading(false);
|
|
124
|
-
}
|
|
125
|
-
};
|
|
126
|
-
|
|
127
|
-
return (
|
|
128
|
-
<div className={`oxa-rich-text-field${mobile ? ' oxa-mobile-rich-text-field' : ''}`}>
|
|
129
|
-
<Space className="oxa-rich-text-toolbar" size={4} wrap>
|
|
130
|
-
{TOOLS.map(tool => (
|
|
131
|
-
<Tooltip key={tool.command} title={tool.label}>
|
|
132
|
-
<Button
|
|
133
|
-
aria-label={tool.label}
|
|
134
|
-
disabled={disabled}
|
|
135
|
-
icon={tool.icon}
|
|
136
|
-
onMouseDown={event => {
|
|
137
|
-
event.preventDefault();
|
|
138
|
-
command(tool.command);
|
|
139
|
-
}}
|
|
140
|
-
size="small"
|
|
141
|
-
/>
|
|
142
|
-
</Tooltip>
|
|
143
|
-
))}
|
|
144
|
-
<Tooltip title="插入链接">
|
|
145
|
-
<Button
|
|
146
|
-
aria-label="插入链接"
|
|
147
|
-
disabled={disabled}
|
|
148
|
-
icon={<LinkOutlined />}
|
|
149
|
-
onMouseDown={event => {
|
|
150
|
-
event.preventDefault();
|
|
151
|
-
insertLink();
|
|
152
|
-
}}
|
|
153
|
-
size="small"
|
|
154
|
-
/>
|
|
155
|
-
</Tooltip>
|
|
156
|
-
{onUpload && resourceCode && (
|
|
157
|
-
<Tooltip title="插入图片">
|
|
158
|
-
<Button
|
|
159
|
-
aria-label="插入图片"
|
|
160
|
-
disabled={disabled}
|
|
161
|
-
icon={<FileImageOutlined />}
|
|
162
|
-
loading={imageUploading}
|
|
163
|
-
onMouseDown={event => {
|
|
164
|
-
event.preventDefault();
|
|
165
|
-
imageInput.current?.click();
|
|
166
|
-
}}
|
|
167
|
-
size="small"
|
|
168
|
-
/>
|
|
169
|
-
</Tooltip>
|
|
170
|
-
)}
|
|
171
|
-
</Space>
|
|
172
|
-
<input
|
|
173
|
-
accept={INLINE_IMAGE_ACCEPT}
|
|
174
|
-
onChange={event => {
|
|
175
|
-
const file = event.currentTarget.files?.[0];
|
|
176
|
-
event.currentTarget.value = '';
|
|
177
|
-
if (file) void insertImage(file);
|
|
178
|
-
}}
|
|
179
|
-
ref={imageInput}
|
|
180
|
-
style={{ display: 'none' }}
|
|
181
|
-
type="file"
|
|
182
|
-
/>
|
|
183
|
-
{imageError && <div className="oxa-rich-text-error" role="alert">{imageError}</div>}
|
|
184
|
-
<div
|
|
185
|
-
aria-label="富文本内容"
|
|
186
|
-
className="oxa-rich-text-editor"
|
|
187
|
-
contentEditable={!disabled}
|
|
188
|
-
onBlur={emit}
|
|
189
|
-
onInput={emit}
|
|
190
|
-
ref={editor}
|
|
191
|
-
role="textbox"
|
|
192
|
-
suppressContentEditableWarning
|
|
193
|
-
/>
|
|
194
|
-
</div>
|
|
195
|
-
);
|
|
196
|
-
}
|
|
@@ -1,315 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
ClearOutlined,
|
|
3
|
-
DeleteOutlined,
|
|
4
|
-
EditOutlined,
|
|
5
|
-
} from '@ant-design/icons';
|
|
6
|
-
import {
|
|
7
|
-
Alert,
|
|
8
|
-
Button,
|
|
9
|
-
Drawer,
|
|
10
|
-
Image,
|
|
11
|
-
Modal,
|
|
12
|
-
Space,
|
|
13
|
-
Spin,
|
|
14
|
-
Typography,
|
|
15
|
-
} from 'antd';
|
|
16
|
-
import type {
|
|
17
|
-
DataFileRef,
|
|
18
|
-
StableSignaturePoint,
|
|
19
|
-
StableSignatureValue,
|
|
20
|
-
UserReferenceValue,
|
|
21
|
-
} from 'openxiangda-contracts/browser';
|
|
22
|
-
import {
|
|
23
|
-
useCallback,
|
|
24
|
-
useEffect,
|
|
25
|
-
useRef,
|
|
26
|
-
useState,
|
|
27
|
-
type PointerEvent as ReactPointerEvent,
|
|
28
|
-
type ReactNode,
|
|
29
|
-
} from 'react';
|
|
30
|
-
import { fetchDataFileBlob } from '../../platform-client';
|
|
31
|
-
|
|
32
|
-
const CANVAS_HEIGHT = 240;
|
|
33
|
-
|
|
34
|
-
async function sha256(blob: Blob) {
|
|
35
|
-
const digest = await crypto.subtle.digest('SHA-256', await blob.arrayBuffer());
|
|
36
|
-
return [...new Uint8Array(digest)]
|
|
37
|
-
.map(byte => byte.toString(16).padStart(2, '0'))
|
|
38
|
-
.join('');
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function managedFile(file: DataFileRef): StableSignatureValue['file'] {
|
|
42
|
-
return {
|
|
43
|
-
id: file.id,
|
|
44
|
-
name: file.name,
|
|
45
|
-
size: file.size,
|
|
46
|
-
contentType: file.contentType,
|
|
47
|
-
};
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
function SignatureImage({
|
|
51
|
-
value,
|
|
52
|
-
resourceCode,
|
|
53
|
-
}: {
|
|
54
|
-
value: StableSignatureValue;
|
|
55
|
-
resourceCode?: string;
|
|
56
|
-
}) {
|
|
57
|
-
const [source, setSource] = useState('');
|
|
58
|
-
const [loading, setLoading] = useState(Boolean(resourceCode));
|
|
59
|
-
|
|
60
|
-
useEffect(() => {
|
|
61
|
-
let active = true;
|
|
62
|
-
let objectUrl = '';
|
|
63
|
-
if (!resourceCode) return;
|
|
64
|
-
setLoading(true);
|
|
65
|
-
fetchDataFileBlob(resourceCode, value.file.id)
|
|
66
|
-
.then(blob => {
|
|
67
|
-
objectUrl = URL.createObjectURL(blob);
|
|
68
|
-
if (active) setSource(objectUrl);
|
|
69
|
-
})
|
|
70
|
-
.catch(() => {
|
|
71
|
-
if (active) setSource('');
|
|
72
|
-
})
|
|
73
|
-
.finally(() => {
|
|
74
|
-
if (active) setLoading(false);
|
|
75
|
-
});
|
|
76
|
-
return () => {
|
|
77
|
-
active = false;
|
|
78
|
-
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
|
79
|
-
};
|
|
80
|
-
}, [resourceCode, value.file.id]);
|
|
81
|
-
|
|
82
|
-
if (loading) return <Spin size="small" />;
|
|
83
|
-
return source ? (
|
|
84
|
-
<Image alt="业务签名" className="oxa-signature-image" preview src={source} />
|
|
85
|
-
) : (
|
|
86
|
-
<Typography.Text>{value.file.name}</Typography.Text>
|
|
87
|
-
);
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
export function SignatureValueDisplay({
|
|
91
|
-
value,
|
|
92
|
-
resourceCode,
|
|
93
|
-
}: {
|
|
94
|
-
value: StableSignatureValue;
|
|
95
|
-
resourceCode?: string;
|
|
96
|
-
}) {
|
|
97
|
-
return (
|
|
98
|
-
<div className="oxa-signature-value">
|
|
99
|
-
<SignatureImage resourceCode={resourceCode} value={value} />
|
|
100
|
-
<Typography.Text type="secondary">
|
|
101
|
-
{value.signer?.label || '业务签名'} · {new Date(value.signedAt).toLocaleString()}
|
|
102
|
-
</Typography.Text>
|
|
103
|
-
<Typography.Text className="oxa-signature-hash" type="secondary">
|
|
104
|
-
SHA-256 {value.hash.slice(0, 16)}...
|
|
105
|
-
</Typography.Text>
|
|
106
|
-
</div>
|
|
107
|
-
);
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
export function SignatureField({
|
|
111
|
-
value,
|
|
112
|
-
onChange,
|
|
113
|
-
onUpload,
|
|
114
|
-
signer,
|
|
115
|
-
resourceCode,
|
|
116
|
-
disabled = false,
|
|
117
|
-
mobile = false,
|
|
118
|
-
}: {
|
|
119
|
-
value?: StableSignatureValue;
|
|
120
|
-
onChange?: (value: StableSignatureValue | undefined) => void;
|
|
121
|
-
onUpload?: (file: File) => Promise<DataFileRef>;
|
|
122
|
-
signer?: UserReferenceValue;
|
|
123
|
-
resourceCode?: string;
|
|
124
|
-
disabled?: boolean;
|
|
125
|
-
mobile?: boolean;
|
|
126
|
-
}) {
|
|
127
|
-
const canvas = useRef<HTMLCanvasElement | null>(null);
|
|
128
|
-
const drawing = useRef(false);
|
|
129
|
-
const points = useRef<StableSignaturePoint[]>([]);
|
|
130
|
-
const [open, setOpen] = useState(false);
|
|
131
|
-
const [drawn, setDrawn] = useState(false);
|
|
132
|
-
const [saving, setSaving] = useState(false);
|
|
133
|
-
const [error, setError] = useState('');
|
|
134
|
-
|
|
135
|
-
const prepare = useCallback(() => {
|
|
136
|
-
const element = canvas.current;
|
|
137
|
-
if (!element) return;
|
|
138
|
-
const width = Math.max(1, element.getBoundingClientRect().width || 640);
|
|
139
|
-
const ratio = window.devicePixelRatio || 1;
|
|
140
|
-
element.width = Math.floor(width * ratio);
|
|
141
|
-
element.height = Math.floor(CANVAS_HEIGHT * ratio);
|
|
142
|
-
const context = element.getContext('2d');
|
|
143
|
-
if (!context) return;
|
|
144
|
-
context.setTransform(ratio, 0, 0, ratio, 0, 0);
|
|
145
|
-
context.fillStyle = '#fff';
|
|
146
|
-
context.fillRect(0, 0, width, CANVAS_HEIGHT);
|
|
147
|
-
context.lineCap = 'round';
|
|
148
|
-
context.lineJoin = 'round';
|
|
149
|
-
context.lineWidth = mobile ? 3 : 2.4;
|
|
150
|
-
context.strokeStyle = '#111827';
|
|
151
|
-
}, [mobile]);
|
|
152
|
-
|
|
153
|
-
useEffect(() => {
|
|
154
|
-
if (!open) return;
|
|
155
|
-
points.current = [];
|
|
156
|
-
setDrawn(false);
|
|
157
|
-
setError('');
|
|
158
|
-
requestAnimationFrame(() => requestAnimationFrame(prepare));
|
|
159
|
-
}, [open, prepare]);
|
|
160
|
-
|
|
161
|
-
const point = (event: ReactPointerEvent<HTMLCanvasElement>) => {
|
|
162
|
-
const rect = event.currentTarget.getBoundingClientRect();
|
|
163
|
-
return {
|
|
164
|
-
x: event.clientX - rect.left,
|
|
165
|
-
y: event.clientY - rect.top,
|
|
166
|
-
t: Date.now(),
|
|
167
|
-
};
|
|
168
|
-
};
|
|
169
|
-
|
|
170
|
-
const clear = () => {
|
|
171
|
-
points.current = [];
|
|
172
|
-
setDrawn(false);
|
|
173
|
-
setError('');
|
|
174
|
-
prepare();
|
|
175
|
-
};
|
|
176
|
-
|
|
177
|
-
const save = async () => {
|
|
178
|
-
if (!canvas.current || !points.current.length) {
|
|
179
|
-
setError('请先完成签名');
|
|
180
|
-
return;
|
|
181
|
-
}
|
|
182
|
-
if (!onUpload) {
|
|
183
|
-
setError('当前签名字段未配置托管文件上传能力');
|
|
184
|
-
return;
|
|
185
|
-
}
|
|
186
|
-
setSaving(true);
|
|
187
|
-
setError('');
|
|
188
|
-
try {
|
|
189
|
-
const blob = await new Promise<Blob | null>(resolve =>
|
|
190
|
-
canvas.current?.toBlob(resolve, 'image/png')
|
|
191
|
-
);
|
|
192
|
-
if (!blob) throw new Error('生成签名 PNG 失败');
|
|
193
|
-
const hash = await sha256(blob);
|
|
194
|
-
const file = new File([blob], `signature-${Date.now()}.png`, {
|
|
195
|
-
type: 'image/png',
|
|
196
|
-
});
|
|
197
|
-
const uploaded = await onUpload(file);
|
|
198
|
-
onChange?.({
|
|
199
|
-
file: managedFile(uploaded),
|
|
200
|
-
...(signer ? { signer } : {}),
|
|
201
|
-
signedAt: new Date().toISOString(),
|
|
202
|
-
points: points.current.slice(),
|
|
203
|
-
hash,
|
|
204
|
-
});
|
|
205
|
-
setOpen(false);
|
|
206
|
-
} catch (reason) {
|
|
207
|
-
setError(reason instanceof Error ? reason.message : String(reason));
|
|
208
|
-
} finally {
|
|
209
|
-
setSaving(false);
|
|
210
|
-
}
|
|
211
|
-
};
|
|
212
|
-
|
|
213
|
-
const canvasContent = (
|
|
214
|
-
<div className="oxa-signature-pad">
|
|
215
|
-
<canvas
|
|
216
|
-
aria-label="手写签名画布"
|
|
217
|
-
className="oxa-signature-canvas"
|
|
218
|
-
onPointerCancel={() => { drawing.current = false; }}
|
|
219
|
-
onPointerDown={event => {
|
|
220
|
-
event.preventDefault();
|
|
221
|
-
event.currentTarget.setPointerCapture(event.pointerId);
|
|
222
|
-
drawing.current = true;
|
|
223
|
-
const next = point(event);
|
|
224
|
-
points.current.push(next);
|
|
225
|
-
const context = event.currentTarget.getContext('2d');
|
|
226
|
-
if (context) {
|
|
227
|
-
context.beginPath();
|
|
228
|
-
context.arc(next.x, next.y, 1, 0, Math.PI * 2);
|
|
229
|
-
context.fillStyle = '#111827';
|
|
230
|
-
context.fill();
|
|
231
|
-
}
|
|
232
|
-
setDrawn(true);
|
|
233
|
-
}}
|
|
234
|
-
onPointerMove={event => {
|
|
235
|
-
event.preventDefault();
|
|
236
|
-
if (!drawing.current) return;
|
|
237
|
-
const previous = points.current[points.current.length - 1];
|
|
238
|
-
const next = point(event);
|
|
239
|
-
const context = event.currentTarget.getContext('2d');
|
|
240
|
-
if (context && previous) {
|
|
241
|
-
context.beginPath();
|
|
242
|
-
context.moveTo(previous.x, previous.y);
|
|
243
|
-
context.lineTo(next.x, next.y);
|
|
244
|
-
context.stroke();
|
|
245
|
-
}
|
|
246
|
-
points.current.push(next);
|
|
247
|
-
}}
|
|
248
|
-
onPointerUp={event => {
|
|
249
|
-
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
|
250
|
-
event.currentTarget.releasePointerCapture(event.pointerId);
|
|
251
|
-
}
|
|
252
|
-
drawing.current = false;
|
|
253
|
-
}}
|
|
254
|
-
ref={canvas}
|
|
255
|
-
/>
|
|
256
|
-
{error && <Alert showIcon title={error} type="error" />}
|
|
257
|
-
<Button disabled={saving} icon={<ClearOutlined />} onClick={clear}>
|
|
258
|
-
清空画布
|
|
259
|
-
</Button>
|
|
260
|
-
</div>
|
|
261
|
-
);
|
|
262
|
-
|
|
263
|
-
const dialog: ReactNode = mobile ? (
|
|
264
|
-
<Drawer
|
|
265
|
-
destroyOnHidden
|
|
266
|
-
extra={<Button disabled={!drawn} loading={saving} onClick={() => void save()} type="primary">保存签名</Button>}
|
|
267
|
-
onClose={() => setOpen(false)}
|
|
268
|
-
open={open}
|
|
269
|
-
placement="bottom"
|
|
270
|
-
size="85vh"
|
|
271
|
-
title="手写签名"
|
|
272
|
-
>
|
|
273
|
-
{canvasContent}
|
|
274
|
-
</Drawer>
|
|
275
|
-
) : (
|
|
276
|
-
<Modal
|
|
277
|
-
destroyOnHidden
|
|
278
|
-
okButtonProps={{ disabled: !drawn }}
|
|
279
|
-
okText="保存签名"
|
|
280
|
-
onCancel={() => setOpen(false)}
|
|
281
|
-
onOk={() => void save()}
|
|
282
|
-
open={open}
|
|
283
|
-
confirmLoading={saving}
|
|
284
|
-
title="手写签名"
|
|
285
|
-
width={720}
|
|
286
|
-
>
|
|
287
|
-
{canvasContent}
|
|
288
|
-
</Modal>
|
|
289
|
-
);
|
|
290
|
-
|
|
291
|
-
return (
|
|
292
|
-
<div className="oxa-signature-field">
|
|
293
|
-
{value ? (
|
|
294
|
-
<SignatureValueDisplay resourceCode={resourceCode} value={value} />
|
|
295
|
-
) : (
|
|
296
|
-
<Typography.Text type="secondary">尚未签名</Typography.Text>
|
|
297
|
-
)}
|
|
298
|
-
{!disabled && (
|
|
299
|
-
<Space>
|
|
300
|
-
<Button icon={<EditOutlined />} onClick={() => setOpen(true)}>
|
|
301
|
-
{value ? '重新签名' : '开始签名'}
|
|
302
|
-
</Button>
|
|
303
|
-
{value && (
|
|
304
|
-
<Button
|
|
305
|
-
aria-label="清除签名"
|
|
306
|
-
icon={<DeleteOutlined />}
|
|
307
|
-
onClick={() => onChange?.(undefined)}
|
|
308
|
-
/>
|
|
309
|
-
)}
|
|
310
|
-
</Space>
|
|
311
|
-
)}
|
|
312
|
-
{dialog}
|
|
313
|
-
</div>
|
|
314
|
-
);
|
|
315
|
-
}
|