openxiangda-cli 2.0.0-alpha.62 → 2.0.0-alpha.67
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/apps/server/Dockerfile +2 -1
- package/template/apps/server/package.json +1 -1
- package/template/apps/web/package.json +1 -1
- package/template/apps/web/src/AuthoritativeSelector.tsx +262 -14
- package/template/apps/web/src/components/resource/GeneratedResourceCrud.tsx +220 -21
- package/template/apps/web/src/components/resource/SurfaceFields.tsx +112 -3
- package/template/apps/web/src/main.tsx +13 -1
- package/template/apps/web/src/platform-client.ts +55 -17
- package/template/apps/web/src/styles.css +282 -0
- package/template/apps/web/test/contracts.test.ts +17 -0
- package/template/package.json +3 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openxiangda-cli",
|
|
3
|
-
"version": "2.0.0-alpha.
|
|
3
|
+
"version": "2.0.0-alpha.67",
|
|
4
4
|
"description": "Thin application-level CLI for OpenXiangda 2.0.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -23,9 +23,9 @@
|
|
|
23
23
|
],
|
|
24
24
|
"dependencies": {
|
|
25
25
|
"@oclif/core": "4.13.3",
|
|
26
|
-
"openxiangda-contracts": "2.0.0-alpha.
|
|
27
|
-
"openxiangda-devkit-core": "2.0.0-alpha.
|
|
28
|
-
"openxiangda-mcp": "2.0.0-alpha.
|
|
26
|
+
"openxiangda-contracts": "2.0.0-alpha.31",
|
|
27
|
+
"openxiangda-devkit-core": "2.0.0-alpha.41",
|
|
28
|
+
"openxiangda-mcp": "2.0.0-alpha.41"
|
|
29
29
|
},
|
|
30
30
|
"devDependencies": {
|
|
31
31
|
"tsx": "4.23.12",
|
|
@@ -4,7 +4,8 @@ RUN corepack enable
|
|
|
4
4
|
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.base.json ./
|
|
5
5
|
COPY apps/server/package.json apps/server/package.json
|
|
6
6
|
COPY packages/contracts/package.json packages/contracts/package.json
|
|
7
|
-
RUN
|
|
7
|
+
RUN node -e "const fs=require('node:fs'); const p=JSON.parse(fs.readFileSync('package.json','utf8')); delete p.devDependencies; fs.writeFileSync('package.json', JSON.stringify(p));"
|
|
8
|
+
RUN pnpm --filter @app/server --filter @app/contracts install --no-frozen-lockfile --ignore-scripts
|
|
8
9
|
COPY apps/server apps/server
|
|
9
10
|
COPY packages/contracts packages/contracts
|
|
10
11
|
RUN pnpm --filter @app/server... build
|
|
@@ -107,6 +107,19 @@ function optionDescription(option: Option) {
|
|
|
107
107
|
return option.description || '';
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
+
export type AuthoritativeSelectorProps = {
|
|
111
|
+
value?: string | string[];
|
|
112
|
+
onChange?: (value: string | string[] | undefined) => void;
|
|
113
|
+
source: Source;
|
|
114
|
+
operation: 'create' | 'update';
|
|
115
|
+
multiple?: boolean;
|
|
116
|
+
disabled?: boolean;
|
|
117
|
+
id?: string;
|
|
118
|
+
placeholder: string;
|
|
119
|
+
resourceCode?: string;
|
|
120
|
+
labelField?: string;
|
|
121
|
+
};
|
|
122
|
+
|
|
110
123
|
export function AuthoritativeSelector({
|
|
111
124
|
value,
|
|
112
125
|
onChange,
|
|
@@ -118,18 +131,7 @@ export function AuthoritativeSelector({
|
|
|
118
131
|
placeholder,
|
|
119
132
|
resourceCode,
|
|
120
133
|
labelField,
|
|
121
|
-
}: {
|
|
122
|
-
value?: string | string[];
|
|
123
|
-
onChange?: (value: string | string[] | undefined) => void;
|
|
124
|
-
source: Source;
|
|
125
|
-
operation: 'create' | 'update';
|
|
126
|
-
multiple?: boolean;
|
|
127
|
-
disabled?: boolean;
|
|
128
|
-
id?: string;
|
|
129
|
-
placeholder: string;
|
|
130
|
-
resourceCode?: string;
|
|
131
|
-
labelField?: string;
|
|
132
|
-
}) {
|
|
134
|
+
}: AuthoritativeSelectorProps) {
|
|
133
135
|
const [options, setOptions] = useState<Option[]>([]);
|
|
134
136
|
const [keyword, setKeyword] = useState('');
|
|
135
137
|
const [cursor, setCursor] = useState<string | null>(null);
|
|
@@ -199,6 +201,30 @@ export function AuthoritativeSelector({
|
|
|
199
201
|
return () => window.clearTimeout(timer);
|
|
200
202
|
}, [disabled, keyword, labelField, operation, resourceCode, source, values]);
|
|
201
203
|
|
|
204
|
+
useEffect(() => {
|
|
205
|
+
if (disabled || !open || keyword.trim().length >= 2) return;
|
|
206
|
+
const sequence = ++requestSequence.current;
|
|
207
|
+
setLoading(true);
|
|
208
|
+
setError('');
|
|
209
|
+
void searchSource(source, operation, '', undefined, resourceCode, labelField)
|
|
210
|
+
.then(page => {
|
|
211
|
+
if (sequence !== requestSequence.current) return;
|
|
212
|
+
setOptions(current => {
|
|
213
|
+
const selected = current.filter(option => values.includes(option.value));
|
|
214
|
+
return mergeOptions(selected, page.items);
|
|
215
|
+
});
|
|
216
|
+
setCursor(page.nextCursor);
|
|
217
|
+
})
|
|
218
|
+
.catch(reason => {
|
|
219
|
+
if (sequence === requestSequence.current) {
|
|
220
|
+
setError(reason instanceof Error ? reason.message : String(reason));
|
|
221
|
+
}
|
|
222
|
+
})
|
|
223
|
+
.finally(() => {
|
|
224
|
+
if (sequence === requestSequence.current) setLoading(false);
|
|
225
|
+
});
|
|
226
|
+
}, [disabled, keyword, labelField, open, operation, resourceCode, source, values]);
|
|
227
|
+
|
|
202
228
|
const loadNext = () => {
|
|
203
229
|
if (!cursor || loading) return;
|
|
204
230
|
const nextCursor = cursor;
|
|
@@ -236,7 +262,7 @@ export function AuthoritativeSelector({
|
|
|
236
262
|
) : error ? (
|
|
237
263
|
<Typography.Text type="danger">{error}</Typography.Text>
|
|
238
264
|
) : keyword.trim().length < 2 ? (
|
|
239
|
-
'
|
|
265
|
+
'暂无可选数据,可输入至少 2 个字符搜索'
|
|
240
266
|
) : (
|
|
241
267
|
'无可选数据'
|
|
242
268
|
)
|
|
@@ -245,7 +271,10 @@ export function AuthoritativeSelector({
|
|
|
245
271
|
onChange?.(next || undefined);
|
|
246
272
|
if (!multiple) setOpen(false);
|
|
247
273
|
}}
|
|
248
|
-
onOpenChange={
|
|
274
|
+
onOpenChange={next => {
|
|
275
|
+
setOpen(next);
|
|
276
|
+
if (!next) setKeyword('');
|
|
277
|
+
}}
|
|
249
278
|
onPopupScroll={event => {
|
|
250
279
|
const element = event.currentTarget;
|
|
251
280
|
if (
|
|
@@ -319,6 +348,225 @@ export function AuthoritativeSelector({
|
|
|
319
348
|
);
|
|
320
349
|
}
|
|
321
350
|
|
|
351
|
+
/**
|
|
352
|
+
* Mobile reference picker. It deliberately avoids Ant Design Select so that
|
|
353
|
+
* resource references have a touch-friendly, full-width interaction on
|
|
354
|
+
* phones. The search and resolve calls are the same authoritative platform
|
|
355
|
+
* APIs used by the desktop selector.
|
|
356
|
+
*/
|
|
357
|
+
export function MobileAuthoritativeSelector({
|
|
358
|
+
value,
|
|
359
|
+
onChange,
|
|
360
|
+
source,
|
|
361
|
+
operation,
|
|
362
|
+
multiple = false,
|
|
363
|
+
disabled = false,
|
|
364
|
+
id,
|
|
365
|
+
placeholder,
|
|
366
|
+
resourceCode,
|
|
367
|
+
labelField,
|
|
368
|
+
}: AuthoritativeSelectorProps) {
|
|
369
|
+
const [options, setOptions] = useState<Option[]>([]);
|
|
370
|
+
const [keyword, setKeyword] = useState('');
|
|
371
|
+
const [cursor, setCursor] = useState<string | null>(null);
|
|
372
|
+
const [loading, setLoading] = useState(false);
|
|
373
|
+
const [error, setError] = useState('');
|
|
374
|
+
const [open, setOpen] = useState(false);
|
|
375
|
+
const [draftValues, setDraftValues] = useState<string[]>([]);
|
|
376
|
+
const requestSequence = useRef(0);
|
|
377
|
+
const values = useMemo(
|
|
378
|
+
() => (Array.isArray(value) ? value : value ? [value] : []),
|
|
379
|
+
[value]
|
|
380
|
+
);
|
|
381
|
+
|
|
382
|
+
useEffect(() => {
|
|
383
|
+
setDraftValues(values);
|
|
384
|
+
}, [open, values]);
|
|
385
|
+
|
|
386
|
+
useEffect(() => {
|
|
387
|
+
const missing = values.filter(
|
|
388
|
+
selected => !options.some(option => option.value === selected)
|
|
389
|
+
);
|
|
390
|
+
if (missing.length === 0) return;
|
|
391
|
+
let active = true;
|
|
392
|
+
setLoading(true);
|
|
393
|
+
void resolveSource(source, operation, missing, resourceCode, labelField)
|
|
394
|
+
.then(items => {
|
|
395
|
+
if (active) setOptions(current => mergeOptions(current, items));
|
|
396
|
+
})
|
|
397
|
+
.catch(reason => {
|
|
398
|
+
if (active) setError(reason instanceof Error ? reason.message : String(reason));
|
|
399
|
+
})
|
|
400
|
+
.finally(() => {
|
|
401
|
+
if (active) setLoading(false);
|
|
402
|
+
});
|
|
403
|
+
return () => {
|
|
404
|
+
active = false;
|
|
405
|
+
};
|
|
406
|
+
}, [labelField, operation, options, resourceCode, source, values]);
|
|
407
|
+
|
|
408
|
+
useEffect(() => {
|
|
409
|
+
if (disabled || !open) return;
|
|
410
|
+
const trimmed = keyword.trim();
|
|
411
|
+
if (trimmed.length > 0 && trimmed.length < 2) return;
|
|
412
|
+
const sequence = ++requestSequence.current;
|
|
413
|
+
const timer = window.setTimeout(() => {
|
|
414
|
+
setLoading(true);
|
|
415
|
+
setError('');
|
|
416
|
+
void searchSource(source, operation, trimmed, undefined, resourceCode, labelField)
|
|
417
|
+
.then(page => {
|
|
418
|
+
if (sequence !== requestSequence.current) return;
|
|
419
|
+
setOptions(current => {
|
|
420
|
+
const selected = current.filter(option => values.includes(option.value));
|
|
421
|
+
return mergeOptions(selected, page.items);
|
|
422
|
+
});
|
|
423
|
+
setCursor(page.nextCursor);
|
|
424
|
+
})
|
|
425
|
+
.catch(reason => {
|
|
426
|
+
if (sequence === requestSequence.current) {
|
|
427
|
+
setError(reason instanceof Error ? reason.message : String(reason));
|
|
428
|
+
}
|
|
429
|
+
})
|
|
430
|
+
.finally(() => {
|
|
431
|
+
if (sequence === requestSequence.current) setLoading(false);
|
|
432
|
+
});
|
|
433
|
+
}, trimmed ? 300 : 0);
|
|
434
|
+
return () => window.clearTimeout(timer);
|
|
435
|
+
}, [disabled, keyword, labelField, open, operation, resourceCode, source, values]);
|
|
436
|
+
|
|
437
|
+
const loadNext = () => {
|
|
438
|
+
if (!cursor || loading) return;
|
|
439
|
+
const nextCursor = cursor;
|
|
440
|
+
setLoading(true);
|
|
441
|
+
setError('');
|
|
442
|
+
void searchSource(source, operation, keyword.trim(), nextCursor, resourceCode, labelField)
|
|
443
|
+
.then(page => {
|
|
444
|
+
setOptions(current => mergeOptions(current, page.items));
|
|
445
|
+
setCursor(page.nextCursor);
|
|
446
|
+
})
|
|
447
|
+
.catch(reason => setError(reason instanceof Error ? reason.message : String(reason)))
|
|
448
|
+
.finally(() => setLoading(false));
|
|
449
|
+
};
|
|
450
|
+
|
|
451
|
+
const selectedItems = values
|
|
452
|
+
.map(selected => options.find(option => option.value === selected))
|
|
453
|
+
.filter((item): item is Option => Boolean(item));
|
|
454
|
+
const selectedLabel = selectedItems.length
|
|
455
|
+
? selectedItems.map(item => item.label).join('、')
|
|
456
|
+
: values.length
|
|
457
|
+
? (loading ? '读取中…' : values.join('、'))
|
|
458
|
+
: `请选择${placeholder}`;
|
|
459
|
+
|
|
460
|
+
const toggleValue = (nextValue: string) => {
|
|
461
|
+
const item = options.find(option => option.value === nextValue);
|
|
462
|
+
if (!item?.selectable) return;
|
|
463
|
+
if (!multiple) {
|
|
464
|
+
onChange?.(nextValue);
|
|
465
|
+
setOpen(false);
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
setDraftValues(current =>
|
|
469
|
+
current.includes(nextValue)
|
|
470
|
+
? current.filter(itemValue => itemValue !== nextValue)
|
|
471
|
+
: [...current, nextValue]
|
|
472
|
+
);
|
|
473
|
+
};
|
|
474
|
+
|
|
475
|
+
return (
|
|
476
|
+
<>
|
|
477
|
+
<button
|
|
478
|
+
aria-haspopup="dialog"
|
|
479
|
+
className="oxa-mobile-reference-trigger"
|
|
480
|
+
disabled={disabled}
|
|
481
|
+
id={id}
|
|
482
|
+
onClick={() => {
|
|
483
|
+
setDraftValues(values);
|
|
484
|
+
setKeyword('');
|
|
485
|
+
setOpen(true);
|
|
486
|
+
}}
|
|
487
|
+
type="button"
|
|
488
|
+
>
|
|
489
|
+
<span className={values.length ? '' : 'oxa-mobile-reference-placeholder'}>
|
|
490
|
+
{selectedLabel}
|
|
491
|
+
</span>
|
|
492
|
+
<span aria-hidden="true">⌄</span>
|
|
493
|
+
</button>
|
|
494
|
+
{open && (
|
|
495
|
+
<div
|
|
496
|
+
aria-label={placeholder}
|
|
497
|
+
aria-modal="true"
|
|
498
|
+
className="oxa-mobile-reference-backdrop"
|
|
499
|
+
onClick={() => setOpen(false)}
|
|
500
|
+
role="dialog"
|
|
501
|
+
>
|
|
502
|
+
<div className="oxa-mobile-reference-sheet" onClick={event => event.stopPropagation()}>
|
|
503
|
+
<div className="oxa-mobile-reference-header">
|
|
504
|
+
<strong>{placeholder}</strong>
|
|
505
|
+
<button onClick={() => setOpen(false)} type="button">关闭</button>
|
|
506
|
+
</div>
|
|
507
|
+
<input
|
|
508
|
+
autoFocus
|
|
509
|
+
className="oxa-mobile-input oxa-mobile-reference-search"
|
|
510
|
+
onChange={event => setKeyword(event.target.value)}
|
|
511
|
+
placeholder={`搜索${placeholder}`}
|
|
512
|
+
type="search"
|
|
513
|
+
value={keyword}
|
|
514
|
+
/>
|
|
515
|
+
{error && <div className="oxa-mobile-reference-error">{error}</div>}
|
|
516
|
+
{loading && <div className="oxa-mobile-reference-loading">加载中…</div>}
|
|
517
|
+
{!loading && !error && options.length === 0 && (
|
|
518
|
+
<div className="oxa-mobile-reference-empty">
|
|
519
|
+
{keyword.trim().length < 2 ? '请输入至少 2 个字符搜索' : '暂无可选数据'}
|
|
520
|
+
</div>
|
|
521
|
+
)}
|
|
522
|
+
<div aria-multiselectable={multiple} className="oxa-mobile-reference-options" role="listbox">
|
|
523
|
+
{options.map(option => {
|
|
524
|
+
const selected = (multiple ? draftValues : values).includes(option.value);
|
|
525
|
+
const description = optionDescription(option);
|
|
526
|
+
return (
|
|
527
|
+
<button
|
|
528
|
+
aria-selected={selected}
|
|
529
|
+
className={`oxa-mobile-reference-option${selected ? ' is-selected' : ''}`}
|
|
530
|
+
disabled={!option.selectable}
|
|
531
|
+
key={option.value}
|
|
532
|
+
onClick={() => toggleValue(option.value)}
|
|
533
|
+
role="option"
|
|
534
|
+
type="button"
|
|
535
|
+
>
|
|
536
|
+
{optionIcon(option)}
|
|
537
|
+
<span className="oxa-mobile-reference-option-copy">
|
|
538
|
+
<strong>{option.label}</strong>
|
|
539
|
+
{description && <small>{description}</small>}
|
|
540
|
+
</span>
|
|
541
|
+
{selected && <span aria-hidden="true">✓</span>}
|
|
542
|
+
</button>
|
|
543
|
+
);
|
|
544
|
+
})}
|
|
545
|
+
</div>
|
|
546
|
+
{cursor && (
|
|
547
|
+
<button className="oxa-mobile-reference-more" onClick={loadNext} type="button">
|
|
548
|
+
{loading ? '加载中…' : '加载更多'}
|
|
549
|
+
</button>
|
|
550
|
+
)}
|
|
551
|
+
{multiple && (
|
|
552
|
+
<button
|
|
553
|
+
className="oxa-mobile-reference-confirm"
|
|
554
|
+
onClick={() => {
|
|
555
|
+
onChange?.(draftValues.length ? draftValues : undefined);
|
|
556
|
+
setOpen(false);
|
|
557
|
+
}}
|
|
558
|
+
type="button"
|
|
559
|
+
>
|
|
560
|
+
确定({draftValues.length})
|
|
561
|
+
</button>
|
|
562
|
+
)}
|
|
563
|
+
</div>
|
|
564
|
+
</div>
|
|
565
|
+
)}
|
|
566
|
+
</>
|
|
567
|
+
);
|
|
568
|
+
}
|
|
569
|
+
|
|
322
570
|
export function ResolvedValueText({
|
|
323
571
|
source,
|
|
324
572
|
value,
|