openxiangda-cli 2.0.0-alpha.85 → 2.0.0-alpha.89

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.
Files changed (65) hide show
  1. package/package.json +4 -4
  2. package/template/AGENTS.md +4 -4
  3. package/template/README.md +4 -4
  4. package/template/apps/server/package.json +1 -1
  5. package/template/apps/server/src/app.module.ts +14 -1
  6. package/template/apps/server/test/smoke.test.ts +3 -1
  7. package/template/apps/web/data-api-live.e2e.html +15 -0
  8. package/template/apps/web/e2e/data-api-live-fixture.tsx +141 -0
  9. package/template/apps/web/e2e/data-api-live.spec.ts +138 -0
  10. package/template/apps/web/e2e/field-protocol-fixture.tsx +280 -0
  11. package/template/apps/web/e2e/field-protocol.spec.ts +100 -0
  12. package/template/apps/web/e2e/resources.spec.ts +258 -20
  13. package/template/apps/web/field-protocol.e2e.html +12 -0
  14. package/template/apps/web/package.json +1 -1
  15. package/template/apps/web/playwright.config.ts +3 -1
  16. package/template/apps/web/scripts/check.mjs +8 -5
  17. package/template/apps/web/scripts/verify-build.mjs +1 -1
  18. package/template/apps/web/src/AuthoritativeSelector.tsx +165 -132
  19. package/template/apps/web/src/FilePreviewPage.tsx +30 -9
  20. package/template/apps/web/src/RoleSubjectSelect.tsx +128 -0
  21. package/template/apps/web/src/Shell.tsx +93 -6
  22. package/template/apps/web/src/components/platform-fields/AddressField.tsx +338 -0
  23. package/template/apps/web/src/components/platform-fields/AttachmentFileList.tsx +25 -6
  24. package/template/apps/web/src/components/platform-fields/CascadeField.tsx +49 -0
  25. package/template/apps/web/src/components/platform-fields/DateTimeField.tsx +148 -0
  26. package/template/apps/web/src/components/platform-fields/JsonField.tsx +77 -0
  27. package/template/apps/web/src/components/platform-fields/LocationField.tsx +164 -0
  28. package/template/apps/web/src/components/platform-fields/PlatformDirectoryPicker.tsx +19 -44
  29. package/template/apps/web/src/components/platform-fields/ResourceReferenceField.tsx +67 -0
  30. package/template/apps/web/src/components/platform-fields/RichTextField.tsx +196 -0
  31. package/template/apps/web/src/components/platform-fields/SignatureField.tsx +315 -0
  32. package/template/apps/web/src/components/platform-fields/SubtableField.tsx +553 -0
  33. package/template/apps/web/src/components/platform-fields/address-value.ts +80 -0
  34. package/template/apps/web/src/components/platform-fields/cascade-value.ts +66 -0
  35. package/template/apps/web/src/components/platform-fields/directory-value.ts +53 -0
  36. package/template/apps/web/src/components/platform-fields/field-form-codec.ts +98 -0
  37. package/template/apps/web/src/components/platform-fields/location-value.ts +87 -0
  38. package/template/apps/web/src/components/platform-fields/resource-query.ts +131 -0
  39. package/template/apps/web/src/components/platform-fields/rich-text-value.ts +59 -0
  40. package/template/apps/web/src/components/platform-fields/subtable-value.ts +187 -0
  41. package/template/apps/web/src/components/resource/GeneratedResourceCrud.tsx +194 -108
  42. package/template/apps/web/src/components/resource/ResourceBatchActions.tsx +8 -1
  43. package/template/apps/web/src/components/resource/SurfaceFields.tsx +385 -79
  44. package/template/apps/web/src/components/resource/generated-resource-definition.ts +18 -0
  45. package/template/apps/web/src/components/resource/resource-import.ts +4 -4
  46. package/template/apps/web/src/data-provider.ts +59 -34
  47. package/template/apps/web/src/platform-client.ts +341 -136
  48. package/template/apps/web/src/runtime.tsx +156 -17
  49. package/template/apps/web/src/styles.css +264 -0
  50. package/template/apps/web/test/address-field.test.ts +66 -0
  51. package/template/apps/web/test/cascade-value.test.ts +68 -0
  52. package/template/apps/web/test/contracts.test.ts +29 -5
  53. package/template/apps/web/test/directory-value.test.ts +55 -0
  54. package/template/apps/web/test/field-bounds.test.ts +17 -0
  55. package/template/apps/web/test/field-form-codec.test.ts +85 -0
  56. package/template/apps/web/test/location-field.test.ts +83 -0
  57. package/template/apps/web/test/resource-import.test.ts +8 -7
  58. package/template/apps/web/test/resource-query.test.ts +144 -0
  59. package/template/apps/web/test/rich-json-field.test.ts +53 -0
  60. package/template/apps/web/test/signature-serial-field.test.ts +40 -0
  61. package/template/apps/web/test/subtable-value.test.ts +144 -0
  62. package/template/openxiangda.config.ts +1 -1
  63. package/template/package.json +3 -3
  64. package/template/packages/contracts/src/generated.ts +95 -0
  65. 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
+ }