openxiangda-cli 2.0.0-alpha.85 → 2.0.0-alpha.86
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/package.json +4 -4
- package/template/AGENTS.md +4 -4
- package/template/README.md +2 -2
- package/template/apps/server/package.json +1 -1
- package/template/apps/web/data-api-live.e2e.html +15 -0
- package/template/apps/web/e2e/data-api-live-fixture.tsx +141 -0
- package/template/apps/web/e2e/data-api-live.spec.ts +138 -0
- package/template/apps/web/e2e/field-protocol-fixture.tsx +280 -0
- package/template/apps/web/e2e/field-protocol.spec.ts +100 -0
- package/template/apps/web/e2e/resources.spec.ts +258 -20
- package/template/apps/web/field-protocol.e2e.html +12 -0
- package/template/apps/web/package.json +1 -1
- package/template/apps/web/playwright.config.ts +3 -1
- package/template/apps/web/scripts/check.mjs +8 -5
- package/template/apps/web/scripts/verify-build.mjs +1 -1
- package/template/apps/web/src/AuthoritativeSelector.tsx +165 -132
- package/template/apps/web/src/FilePreviewPage.tsx +30 -9
- package/template/apps/web/src/RoleSubjectSelect.tsx +128 -0
- package/template/apps/web/src/Shell.tsx +93 -6
- package/template/apps/web/src/components/platform-fields/AddressField.tsx +338 -0
- package/template/apps/web/src/components/platform-fields/AttachmentFileList.tsx +25 -6
- package/template/apps/web/src/components/platform-fields/CascadeField.tsx +49 -0
- package/template/apps/web/src/components/platform-fields/DateTimeField.tsx +148 -0
- package/template/apps/web/src/components/platform-fields/JsonField.tsx +77 -0
- package/template/apps/web/src/components/platform-fields/LocationField.tsx +164 -0
- package/template/apps/web/src/components/platform-fields/PlatformDirectoryPicker.tsx +19 -44
- package/template/apps/web/src/components/platform-fields/ResourceReferenceField.tsx +67 -0
- package/template/apps/web/src/components/platform-fields/RichTextField.tsx +196 -0
- package/template/apps/web/src/components/platform-fields/SignatureField.tsx +315 -0
- package/template/apps/web/src/components/platform-fields/SubtableField.tsx +553 -0
- package/template/apps/web/src/components/platform-fields/address-value.ts +80 -0
- package/template/apps/web/src/components/platform-fields/cascade-value.ts +66 -0
- package/template/apps/web/src/components/platform-fields/directory-value.ts +53 -0
- package/template/apps/web/src/components/platform-fields/field-form-codec.ts +98 -0
- package/template/apps/web/src/components/platform-fields/location-value.ts +87 -0
- package/template/apps/web/src/components/platform-fields/resource-query.ts +131 -0
- package/template/apps/web/src/components/platform-fields/rich-text-value.ts +59 -0
- package/template/apps/web/src/components/platform-fields/subtable-value.ts +187 -0
- package/template/apps/web/src/components/resource/GeneratedResourceCrud.tsx +194 -108
- package/template/apps/web/src/components/resource/ResourceBatchActions.tsx +8 -1
- package/template/apps/web/src/components/resource/SurfaceFields.tsx +385 -79
- package/template/apps/web/src/components/resource/generated-resource-definition.ts +18 -0
- package/template/apps/web/src/components/resource/resource-import.ts +4 -4
- package/template/apps/web/src/data-provider.ts +59 -34
- package/template/apps/web/src/platform-client.ts +341 -136
- package/template/apps/web/src/runtime.tsx +156 -17
- package/template/apps/web/src/styles.css +264 -0
- package/template/apps/web/test/address-field.test.ts +66 -0
- package/template/apps/web/test/cascade-value.test.ts +68 -0
- package/template/apps/web/test/contracts.test.ts +29 -5
- package/template/apps/web/test/directory-value.test.ts +55 -0
- package/template/apps/web/test/field-bounds.test.ts +17 -0
- package/template/apps/web/test/field-form-codec.test.ts +85 -0
- package/template/apps/web/test/location-field.test.ts +83 -0
- package/template/apps/web/test/resource-import.test.ts +8 -7
- package/template/apps/web/test/resource-query.test.ts +144 -0
- package/template/apps/web/test/rich-json-field.test.ts +53 -0
- package/template/apps/web/test/signature-serial-field.test.ts +40 -0
- package/template/apps/web/test/subtable-value.test.ts +144 -0
- package/template/package.json +3 -3
- package/template/packages/contracts/src/generated.ts +69 -0
- package/template/scripts/verify-template-budget.mjs +11 -4
|
@@ -0,0 +1,196 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,315 @@
|
|
|
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
|
+
}
|