openxiangda-cli 2.0.0-alpha.62 → 2.0.0-alpha.66
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 +204 -12
- 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 +46 -14
- package/template/apps/web/src/styles.css +282 -0
- package/template/apps/web/test/contracts.test.ts +12 -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.66",
|
|
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,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
|
+
ArrowRightOutlined,
|
|
2
3
|
DeleteOutlined,
|
|
3
4
|
EditOutlined,
|
|
4
5
|
EyeOutlined,
|
|
@@ -8,6 +9,7 @@ import {
|
|
|
8
9
|
import { useCreate, useDelete, useList, useOne, useUpdate } from '@refinedev/core';
|
|
9
10
|
import {
|
|
10
11
|
App,
|
|
12
|
+
Alert,
|
|
11
13
|
Button,
|
|
12
14
|
Card,
|
|
13
15
|
Descriptions,
|
|
@@ -45,6 +47,7 @@ import {
|
|
|
45
47
|
type SurfaceField,
|
|
46
48
|
} from './SurfaceFields';
|
|
47
49
|
import { createNativeResourceClient, type GenericResourceQuery } from '../../platform-client';
|
|
50
|
+
import { ResolvedValueText } from '../../AuthoritativeSelector';
|
|
48
51
|
import { useRuntime } from '../../runtime';
|
|
49
52
|
|
|
50
53
|
type ResourceRecord = Record<string, unknown> & {
|
|
@@ -119,6 +122,129 @@ function fieldWritable(
|
|
|
119
122
|
return mode === 'create';
|
|
120
123
|
}
|
|
121
124
|
|
|
125
|
+
function auditOperationLabel(operation: DataAuditEntry['operation']) {
|
|
126
|
+
return operation === 'created'
|
|
127
|
+
? '创建'
|
|
128
|
+
: operation === 'deleted'
|
|
129
|
+
? '删除'
|
|
130
|
+
: '更新';
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function auditValue(field: SurfaceField, value: unknown) {
|
|
134
|
+
if (value === undefined || value === null || value === '') {
|
|
135
|
+
return <Typography.Text type="secondary">-</Typography.Text>;
|
|
136
|
+
}
|
|
137
|
+
return <SurfaceFieldValue field={field} value={value} />;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function auditChangeFields(entry: DataAuditEntry, surface: DataResourceSurface) {
|
|
141
|
+
const fields = fieldsBySection(surface).flatMap(group => group.fields);
|
|
142
|
+
const source = entry.changes || entry.record || entry.before || {};
|
|
143
|
+
return fields.filter(field => Object.prototype.hasOwnProperty.call(source, field.key));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function exportCellValue(field: SurfaceField, value: unknown) {
|
|
147
|
+
if (value === undefined || value === null || value === '') return '';
|
|
148
|
+
if (field.widget === 'boolean') return value ? '是' : '否';
|
|
149
|
+
if (field.options && !Array.isArray(value)) {
|
|
150
|
+
return field.options.find(option => option.value === value)?.label || String(value);
|
|
151
|
+
}
|
|
152
|
+
if (Array.isArray(value)) {
|
|
153
|
+
return value.map(item => field.options?.find(option => option.value === item)?.label || String(item)).join('、');
|
|
154
|
+
}
|
|
155
|
+
if (field.widget === 'date' || field.widget === 'datetime') {
|
|
156
|
+
const parsed = new Date(String(value));
|
|
157
|
+
if (!Number.isNaN(parsed.getTime())) return parsed.toLocaleString('zh-CN', { hour12: false });
|
|
158
|
+
}
|
|
159
|
+
if (typeof value === 'object' && value && 'id' in value) return String((value as { id: unknown }).id);
|
|
160
|
+
return String(value);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function csvCell(value: string) {
|
|
164
|
+
return /[\r\n,"]/.test(value) ? `"${value.replaceAll('"', '""')}"` : value;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function exportResourceRows(
|
|
168
|
+
code: string,
|
|
169
|
+
name: string,
|
|
170
|
+
surface: DataResourceSurface,
|
|
171
|
+
query: Partial<GenericResourceQuery>,
|
|
172
|
+
sort: { field: string; order: 'asc' | 'desc' },
|
|
173
|
+
) {
|
|
174
|
+
const client = createNativeResourceClient(code, surface);
|
|
175
|
+
const fields = listSurfaceFields(surface.fields);
|
|
176
|
+
const rows: ResourceRecord[] = [];
|
|
177
|
+
const pageSize = 500;
|
|
178
|
+
let page = 1;
|
|
179
|
+
let total = 0;
|
|
180
|
+
do {
|
|
181
|
+
const result = await client.list({
|
|
182
|
+
page,
|
|
183
|
+
pageSize,
|
|
184
|
+
keyword: query.keyword,
|
|
185
|
+
searchField: query.searchField,
|
|
186
|
+
filters: query.filters,
|
|
187
|
+
sort,
|
|
188
|
+
});
|
|
189
|
+
rows.push(...(result.rows as ResourceRecord[]));
|
|
190
|
+
total = result.total;
|
|
191
|
+
page += 1;
|
|
192
|
+
} while (rows.length < total && rows.length < 10000 && page <= 20);
|
|
193
|
+
const header = fields.map(field => csvCell(field.label)).join(',');
|
|
194
|
+
const body = rows.map(row => fields.map(field => csvCell(exportCellValue(field, row[field.key]))).join(','));
|
|
195
|
+
const blob = new Blob([`\uFEFF${[header, ...body].join('\r\n')}`], { type: 'text/csv;charset=utf-8' });
|
|
196
|
+
const url = URL.createObjectURL(blob);
|
|
197
|
+
const anchor = document.createElement('a');
|
|
198
|
+
anchor.href = url;
|
|
199
|
+
anchor.download = `${name}-${dayjs().format('YYYYMMDD-HHmmss')}.csv`;
|
|
200
|
+
anchor.click();
|
|
201
|
+
URL.revokeObjectURL(url);
|
|
202
|
+
return { count: rows.length, truncated: rows.length < total };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function AuditEntry({ entry, surface }: { entry: DataAuditEntry; surface: DataResourceSurface }) {
|
|
206
|
+
const fields = auditChangeFields(entry, surface);
|
|
207
|
+
const changes = entry.changes || entry.record || entry.before || {};
|
|
208
|
+
return (
|
|
209
|
+
<div className="oxa-audit-entry">
|
|
210
|
+
<div className="oxa-audit-heading">
|
|
211
|
+
<strong>{auditOperationLabel(entry.operation)}</strong>
|
|
212
|
+
<Typography.Text type="secondary">版本 {entry.revision}</Typography.Text>
|
|
213
|
+
</div>
|
|
214
|
+
<div className="oxa-audit-meta">
|
|
215
|
+
<span>操作人</span>
|
|
216
|
+
{entry.actor.principalType === 'application' ? (
|
|
217
|
+
<Typography.Text>应用服务{entry.actor.oauthClientId ? `(${entry.actor.oauthClientId})` : ''}</Typography.Text>
|
|
218
|
+
) : (
|
|
219
|
+
<ResolvedValueText source="user" value={entry.actor.userId} />
|
|
220
|
+
)}
|
|
221
|
+
<span>时间</span>
|
|
222
|
+
<Typography.Text>{new Date(entry.occurredAt).toLocaleString('zh-CN')}</Typography.Text>
|
|
223
|
+
</div>
|
|
224
|
+
{fields.length ? (
|
|
225
|
+
<div className="oxa-audit-changes">
|
|
226
|
+
{fields.map(field => {
|
|
227
|
+
const before = entry.operation === 'created' ? undefined : entry.before?.[field.key];
|
|
228
|
+
const after = entry.operation === 'deleted' ? undefined : changes[field.key];
|
|
229
|
+
return (
|
|
230
|
+
<div className="oxa-audit-change" key={field.key}>
|
|
231
|
+
<Typography.Text strong>{field.label}</Typography.Text>
|
|
232
|
+
<div className="oxa-audit-change-values">
|
|
233
|
+
{auditValue(field, before)}
|
|
234
|
+
<ArrowRightOutlined />
|
|
235
|
+
{auditValue(field, after)}
|
|
236
|
+
</div>
|
|
237
|
+
</div>
|
|
238
|
+
);
|
|
239
|
+
})}
|
|
240
|
+
</div>
|
|
241
|
+
) : (
|
|
242
|
+
<Typography.Text type="secondary">未发生字段变化</Typography.Text>
|
|
243
|
+
)}
|
|
244
|
+
</div>
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
|
|
122
248
|
function useGeneratedList(definition: GeneratedDefinition) {
|
|
123
249
|
const { code, surface } = definition;
|
|
124
250
|
const defaultSort = surface.list?.defaultSort || {
|
|
@@ -170,6 +296,7 @@ function GeneratedDesktopList({ definition }: { definition: GeneratedDefinition
|
|
|
170
296
|
const state = useGeneratedList(definition);
|
|
171
297
|
const { code, name, surface, capabilities } = definition;
|
|
172
298
|
const fields = listSurfaceFields(surface.fields);
|
|
299
|
+
const [exporting, setExporting] = useState(false);
|
|
173
300
|
const remove = useDelete();
|
|
174
301
|
const rows = (state.list.result.data || []) as unknown as ResourceRecord[];
|
|
175
302
|
const columns = useMemo<TableColumnsType<ResourceRecord>>(() => [
|
|
@@ -199,17 +326,37 @@ function GeneratedDesktopList({ definition }: { definition: GeneratedDefinition
|
|
|
199
326
|
),
|
|
200
327
|
},
|
|
201
328
|
], [capabilities.delete, capabilities.update, code, fields, hasCapability, message, name, navigate, remove, state.list.query]);
|
|
329
|
+
const handleExport = async () => {
|
|
330
|
+
setExporting(true);
|
|
331
|
+
try {
|
|
332
|
+
const result = await exportResourceRows(code, name, surface, state.query, state.sort);
|
|
333
|
+
message.success(result.truncated ? `已导出 ${result.count} 条(最多导出 10000 条)` : `已导出 ${result.count} 条`);
|
|
334
|
+
} catch (error) {
|
|
335
|
+
message.error(error instanceof Error ? error.message : '导出失败');
|
|
336
|
+
} finally {
|
|
337
|
+
setExporting(false);
|
|
338
|
+
}
|
|
339
|
+
};
|
|
202
340
|
return (
|
|
203
341
|
<ResourceListPage
|
|
204
342
|
createCapability={capabilities.create}
|
|
205
343
|
createPath={`/${code}/new`}
|
|
206
344
|
description={`由 ${code} 资源 Surface 自动生成的标准 CRUD 页面`}
|
|
207
|
-
onExport={() =>
|
|
345
|
+
onExport={() => void handleExport()}
|
|
208
346
|
readCapability={capabilities.read}
|
|
209
347
|
resource={code}
|
|
210
348
|
surface={surface}
|
|
211
349
|
title={name}
|
|
212
350
|
>
|
|
351
|
+
{state.list.query.isError && (
|
|
352
|
+
<Alert
|
|
353
|
+
action={<Button onClick={() => void state.list.query.refetch()} size="small">重试</Button>}
|
|
354
|
+
description={state.list.query.error instanceof Error ? state.list.query.error.message : '请稍后重试或联系平台管理员'}
|
|
355
|
+
message="数据读取失败"
|
|
356
|
+
showIcon
|
|
357
|
+
type="error"
|
|
358
|
+
/>
|
|
359
|
+
)}
|
|
213
360
|
<div className="oxa-filter-grid">
|
|
214
361
|
<Select allowClear options={(surface.list?.searchableFields || []).map(key => ({ label: fieldFor(surface, key).label, value: key }))} placeholder="搜索字段" value={state.draft.searchField} onChange={value => state.setDraft(item => ({ ...item, searchField: value }))} />
|
|
215
362
|
<Input allowClear prefix={<SearchOutlined />} placeholder="关键词" value={state.draft.keyword} onChange={event => state.setDraft(item => ({ ...item, keyword: event.target.value }))} />
|
|
@@ -218,14 +365,14 @@ function GeneratedDesktopList({ definition }: { definition: GeneratedDefinition
|
|
|
218
365
|
return <SurfaceFilterControl key={key} field={field} value={state.draft.filters?.[key]} onChange={value => state.setDraft(item => ({ ...item, filters: { ...item.filters, [key]: value } }))} />;
|
|
219
366
|
})}
|
|
220
367
|
<Space>
|
|
221
|
-
<Button type="primary" icon={<SearchOutlined />} onClick={() => { state.setPage(1); state.setQuery(state.draft); }}>查询</Button>
|
|
368
|
+
<Button loading={state.list.query.isFetching} type="primary" icon={<SearchOutlined />} onClick={() => { state.setPage(1); state.setQuery(state.draft); }}>查询</Button>
|
|
222
369
|
<Button onClick={() => { state.setDraft({}); state.setQuery({}); state.setPage(1); }}>重置</Button>
|
|
223
370
|
</Space>
|
|
224
371
|
</div>
|
|
225
372
|
<Table<ResourceRecord>
|
|
226
373
|
columns={columns}
|
|
227
374
|
dataSource={rows}
|
|
228
|
-
loading={state.list.query.
|
|
375
|
+
loading={state.list.query.isFetching}
|
|
229
376
|
locale={{ emptyText: <Empty description={`暂无${name}`} /> }}
|
|
230
377
|
rowKey="id"
|
|
231
378
|
onChange={(_, __, sorter) => { const item = Array.isArray(sorter) ? sorter[0] : sorter; if (item?.field && item.order) state.setSort({ field: String(item.field), order: item.order === 'ascend' ? 'asc' : 'desc' }); }}
|
|
@@ -238,17 +385,39 @@ function GeneratedDesktopList({ definition }: { definition: GeneratedDefinition
|
|
|
238
385
|
function GeneratedMobileList({ definition }: { definition: GeneratedDefinition }) {
|
|
239
386
|
const navigate = useNavigate();
|
|
240
387
|
const { hasCapability } = useRuntime();
|
|
388
|
+
const { message } = App.useApp();
|
|
241
389
|
const { code, name, surface, capabilities } = definition;
|
|
242
390
|
const state = useGeneratedList(definition);
|
|
391
|
+
const [exporting, setExporting] = useState(false);
|
|
243
392
|
const rows = (state.list.result.data || []) as unknown as ResourceRecord[];
|
|
244
393
|
const fields = listSurfaceFields(surface.fields).slice(0, 4);
|
|
394
|
+
const handleExport = async () => {
|
|
395
|
+
setExporting(true);
|
|
396
|
+
try {
|
|
397
|
+
const result = await exportResourceRows(code, name, surface, state.query, state.sort);
|
|
398
|
+
message.success(result.truncated ? `已导出 ${result.count} 条(最多导出 10000 条)` : `已导出 ${result.count} 条`);
|
|
399
|
+
} catch (error) {
|
|
400
|
+
message.error(error instanceof Error ? error.message : '导出失败');
|
|
401
|
+
} finally {
|
|
402
|
+
setExporting(false);
|
|
403
|
+
}
|
|
404
|
+
};
|
|
245
405
|
return (
|
|
246
|
-
<MobileResourceListPage createCapability={capabilities.create} createPath={`/${code}/new`} description={`${code} 资源`} readCapability={capabilities.read} resource={code} surface={surface} title={name}>
|
|
406
|
+
<MobileResourceListPage createCapability={capabilities.create} createPath={`/${code}/new`} description={`${code} 资源`} onExport={() => void handleExport()} readCapability={capabilities.read} resource={code} surface={surface} title={name}>
|
|
407
|
+
{state.list.query.isError && (
|
|
408
|
+
<Alert
|
|
409
|
+
action={<Button onClick={() => void state.list.query.refetch()} size="small">重试</Button>}
|
|
410
|
+
description={state.list.query.error instanceof Error ? state.list.query.error.message : '请稍后重试或联系平台管理员'}
|
|
411
|
+
message="数据读取失败"
|
|
412
|
+
showIcon
|
|
413
|
+
type="error"
|
|
414
|
+
/>
|
|
415
|
+
)}
|
|
247
416
|
<div className="oxa-mobile-filter">
|
|
248
417
|
<Input allowClear placeholder="关键词" value={state.draft.keyword} onChange={event => state.setDraft(item => ({ ...item, keyword: event.target.value }))} onPressEnter={() => { state.setPage(1); state.setQuery(state.draft); }} />
|
|
249
|
-
<Button icon={<SearchOutlined />} onClick={() => { state.setPage(1); state.setQuery(state.draft); }} type="primary">查询</Button>
|
|
418
|
+
<Button loading={state.list.query.isFetching} icon={<SearchOutlined />} onClick={() => { state.setPage(1); state.setQuery(state.draft); }} type="primary">查询</Button>
|
|
250
419
|
</div>
|
|
251
|
-
{state.list.query.
|
|
420
|
+
{state.list.query.isFetching ? <div className="oxa-page-loading"><Spin /></div> : rows.length ? <div className="oxa-mobile-record-list">{rows.map(record => <Card className="oxa-mobile-record-card" key={record.id} size="small"><div className="oxa-mobile-record-heading"><a onClick={() => navigate(`/${code}/${record.id}`)}>{String(record[fields[0]?.key || 'id'] || '-')}</a></div><div className="oxa-mobile-record-fields">{fields.slice(1).map(field => <div key={field.key}><Typography.Text type="secondary">{field.label}</Typography.Text><div><SurfaceFieldValue field={field} value={record[field.key]} /></div></div>)}</div><Space><Button onClick={() => navigate(`/${code}/${record.id}`)} size="small">查看</Button>{hasCapability(capabilities.update) && <Button onClick={() => navigate(`/${code}/${record.id}/edit`)} size="small">编辑</Button>}</Space></Card>)}</div> : <Card><Empty description={`暂无${name}`} /></Card>}
|
|
252
421
|
<div className="oxa-mobile-pagination"><Button disabled={state.page <= 1} onClick={() => state.setPage(item => item - 1)}>上一页</Button><Typography.Text>第 {state.page} 页</Typography.Text><Button disabled={rows.length < (surface.list?.defaultPageSize || 20)} onClick={() => state.setPage(item => item + 1)}>下一页</Button></div>
|
|
253
422
|
</MobileResourceListPage>
|
|
254
423
|
);
|
|
@@ -269,6 +438,7 @@ function GeneratedResourceFormPage({ definition, mode, variant }: { definition:
|
|
|
269
438
|
const listPath = `/${code}`;
|
|
270
439
|
const detailPath = `/${code}/${id}`;
|
|
271
440
|
const Page = variant === 'mobile' ? MobileResourceFormPage : ResourceFormPage;
|
|
441
|
+
const FieldControl = variant === 'mobile' ? MobileSurfaceFieldControl : SurfaceFieldControl;
|
|
272
442
|
if (!hasCapability(mode === 'create' ? capabilities.create : capabilities.update)) return <Result status="403" title="当前平台用户无此操作权限" />;
|
|
273
443
|
const submit = async (values: Record<string, unknown>) => {
|
|
274
444
|
const next = normalizeFormValues(
|
|
@@ -283,10 +453,10 @@ function GeneratedResourceFormPage({ definition, mode, variant }: { definition:
|
|
|
283
453
|
else { const result = await update.mutateAsync({ resource: code, id, values: next, meta: { expectedRevision: record?.revision } }); message.success('修改已保存'); navigate(`/${code}/${result.data.id}`); }
|
|
284
454
|
};
|
|
285
455
|
return (
|
|
286
|
-
<Page backLabel={mode === 'edit' ? `返回${name}详情` : `返回${name}`} backPath={mode === 'edit' ? detailPath : listPath} capability={mode === 'create' ? capabilities.create : capabilities.update} detailPath={record ? detailPath : undefined} loading={mode === 'edit' && query.query.
|
|
456
|
+
<Page backLabel={mode === 'edit' ? `返回${name}详情` : `返回${name}`} backPath={mode === 'edit' ? detailPath : listPath} capability={mode === 'create' ? capabilities.create : capabilities.update} detailPath={record ? detailPath : undefined} loading={mode === 'edit' && query.query.isFetching} mode={mode} notFound={mode === 'edit' && !record} recordSummary={record ? `${name} · 当前版本 ${record.revision}` : undefined} resource={code} surface={surface} title={name}>
|
|
287
457
|
<Form className={variant === 'mobile' ? 'oxa-mobile-form' : 'oxa-form-full'} form={form} initialValues={undefined} layout="vertical" onFinish={values => void submit(values as Record<string, unknown>)}>
|
|
288
|
-
<div className="oxa-sections">{fieldsBySection(surface).map(group => <Card className="oxa-section-card" key={group.section} title={group.section === 'default' ? '基本信息' : group.section}><div className="oxa-grid">{group.fields.map(field => <
|
|
289
|
-
<div className="oxa-actions"><Button onClick={() => navigate(mode === 'edit' ? detailPath : listPath)}>取消</Button><Button htmlType="submit" type="primary">{mode === 'create' ? '保存' : '保存修改'}</Button></div>
|
|
458
|
+
<div className="oxa-sections">{fieldsBySection(surface).map(group => <Card className="oxa-section-card" key={group.section} title={group.section === 'default' ? '基本信息' : group.section}><div className="oxa-grid">{group.fields.map(field => <FieldControl key={field.key} disabled={!fieldWritable(field, mode, hasCapability, identity.isAppSuperAdmin)} field={field} operation={mode === 'create' ? 'create' : 'update'} recordId={record?.id} renderers={{ upload: (current, file, recordId) => createNativeResourceClient(code, surface).upload(current.key, file, recordId) }} />)}</div></Card>)}</div>
|
|
459
|
+
<div className="oxa-actions"><Button onClick={() => navigate(mode === 'edit' ? detailPath : listPath)}>取消</Button><Button htmlType="submit" loading={create.mutation.isPending || update.mutation.isPending} type="primary">{mode === 'create' ? '保存' : '保存修改'}</Button></div>
|
|
290
460
|
</Form>
|
|
291
461
|
</Page>
|
|
292
462
|
);
|
|
@@ -298,9 +468,31 @@ function GeneratedResourceDetailPage({ definition, variant }: { definition: Gene
|
|
|
298
468
|
const query = useOne<ResourceRecord>({ resource: definition.code, id, queryOptions: { retry: false } });
|
|
299
469
|
const record = query.result;
|
|
300
470
|
const [auditEntries, setAuditEntries] = useState<DataAuditEntry[]>([]);
|
|
301
|
-
|
|
302
|
-
const
|
|
471
|
+
const [auditLoading, setAuditLoading] = useState(false);
|
|
472
|
+
const [auditError, setAuditError] = useState('');
|
|
473
|
+
useEffect(() => {
|
|
474
|
+
if (!record) return;
|
|
475
|
+
let active = true;
|
|
476
|
+
setAuditLoading(true);
|
|
477
|
+
setAuditError('');
|
|
478
|
+
void createNativeResourceClient(definition.code, definition.surface).audit(record.id)
|
|
479
|
+
.then(page => {
|
|
480
|
+
if (active) setAuditEntries(page.items || []);
|
|
481
|
+
})
|
|
482
|
+
.catch(reason => {
|
|
483
|
+
if (!active) return;
|
|
484
|
+
setAuditEntries([]);
|
|
485
|
+
setAuditError(reason instanceof Error ? reason.message : String(reason));
|
|
486
|
+
})
|
|
487
|
+
.finally(() => {
|
|
488
|
+
if (active) setAuditLoading(false);
|
|
489
|
+
});
|
|
490
|
+
return () => {
|
|
491
|
+
active = false;
|
|
492
|
+
};
|
|
493
|
+
}, [definition.code, definition.surface, record?.id]);
|
|
494
|
+
const content = record ? <div className="oxa-detail-layout"> <div className="oxa-detail-main">{fieldsBySection(definition.surface).map(group => <Card className="oxa-section-card" key={group.section} title={group.section === 'default' ? '基本信息' : group.section}><Descriptions column={{ xs: 1, sm: 2, lg: 3 }} items={group.fields.map(field => ({ key: field.key, label: field.label, children: <SurfaceFieldValue field={field} value={record[field.key]} /> }))} /></Card>)}</div><aside className="oxa-detail-aside"><Card className="oxa-section-card" id={`${definition.code}-audit`} loading={auditLoading} title="最近变更">{auditError ? <Alert message="变更记录读取失败" description={auditError} type="error" showIcon /> : auditEntries.length ? auditEntries.slice(0, 5).map(entry => <AuditEntry entry={entry} key={entry.id} surface={definition.surface} />) : <Typography.Text type="secondary">暂无变更记录</Typography.Text>}</Card></aside></div> : null;
|
|
303
495
|
const hero = record ? <div><Typography.Title level={variant === 'mobile' ? 3 : 2}>{definition.name}详情</Typography.Title><Typography.Text type="secondary">{record.id} · 版本 {record.revision}</Typography.Text></div> : null;
|
|
304
|
-
const props = { backLabel: `返回${definition.name}`, backPath: `/${definition.code}`, editCapability: definition.capabilities.update, editPath: `/${definition.code}/${id}/edit`, error: query.query.isError || (!query.query.
|
|
496
|
+
const props = { backLabel: `返回${definition.name}`, backPath: `/${definition.code}`, editCapability: definition.capabilities.update, editPath: `/${definition.code}/${id}/edit`, error: query.query.isError || (!query.query.isFetching && !record) ? query.query.error?.message || '记录不存在' : undefined, hero, loading: query.query.isFetching, meta: record ? <><span>版本 {record.revision}</span><span>最近更新 {record.updated_at ? new Date(record.updated_at).toLocaleString('zh-CN') : '-'}</span></> : null, readCapability: definition.capabilities.read, resource: definition.code, surface: definition.surface, title: definition.name };
|
|
305
497
|
return variant === 'mobile' ? <MobileResourceDetailPage {...props} auditTargetId={`${definition.code}-audit`}>{content}</MobileResourceDetailPage> : <ResourceDetailPage {...props} auditTargetId={`${definition.code}-audit`}>{content}</ResourceDetailPage>;
|
|
306
498
|
}
|
|
@@ -21,8 +21,12 @@ import type {
|
|
|
21
21
|
DataFileRef,
|
|
22
22
|
} from 'openxiangda-contracts/browser';
|
|
23
23
|
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
|
24
|
-
import {
|
|
25
|
-
|
|
24
|
+
import {
|
|
25
|
+
AuthoritativeSelector,
|
|
26
|
+
MobileAuthoritativeSelector,
|
|
27
|
+
ResolvedValueText,
|
|
28
|
+
} from '../../AuthoritativeSelector';
|
|
29
|
+
import { AttachmentFileList, formatManagedFileSize } from '../platform-fields/AttachmentFileList';
|
|
26
30
|
import { PlatformDirectoryPicker } from '../platform-fields/PlatformDirectoryPicker';
|
|
27
31
|
|
|
28
32
|
export type SurfaceField = DataFieldSurface & { key: string };
|
|
@@ -321,7 +325,7 @@ export function MobileSurfaceFieldControl({
|
|
|
321
325
|
break;
|
|
322
326
|
case 'resource':
|
|
323
327
|
control = (
|
|
324
|
-
<
|
|
328
|
+
<MobileAuthoritativeSelector
|
|
325
329
|
disabled={disabled}
|
|
326
330
|
labelField={field.reference?.kind === 'resource' ? field.reference.labelField : undefined}
|
|
327
331
|
multiple={field.multiple}
|
|
@@ -340,6 +344,7 @@ export function MobileSurfaceFieldControl({
|
|
|
340
344
|
field={field}
|
|
341
345
|
maxCount={field.maxCount}
|
|
342
346
|
multiple={field.multiple}
|
|
347
|
+
mobile
|
|
343
348
|
onUpload={renderers?.upload}
|
|
344
349
|
recordId={recordId}
|
|
345
350
|
/>
|
|
@@ -407,6 +412,20 @@ function isFileRef(value: unknown): value is DataFileRef {
|
|
|
407
412
|
return Boolean(value && typeof value === 'object' && 'id' in value);
|
|
408
413
|
}
|
|
409
414
|
|
|
415
|
+
type MobilePendingUpload = {
|
|
416
|
+
id: string;
|
|
417
|
+
file: File;
|
|
418
|
+
status: 'uploading' | 'error';
|
|
419
|
+
error?: string;
|
|
420
|
+
};
|
|
421
|
+
|
|
422
|
+
let mobileUploadSequence = 0;
|
|
423
|
+
|
|
424
|
+
function createMobileUploadId() {
|
|
425
|
+
mobileUploadSequence += 1;
|
|
426
|
+
return `mobile-upload-${Date.now()}-${mobileUploadSequence}`;
|
|
427
|
+
}
|
|
428
|
+
|
|
410
429
|
function ManagedFileField({
|
|
411
430
|
field,
|
|
412
431
|
value,
|
|
@@ -417,6 +436,7 @@ function ManagedFileField({
|
|
|
417
436
|
maxCount = 1,
|
|
418
437
|
accept,
|
|
419
438
|
onUpload,
|
|
439
|
+
mobile = false,
|
|
420
440
|
}: {
|
|
421
441
|
field: SurfaceField;
|
|
422
442
|
value?: DataFileRef | DataFileRef[];
|
|
@@ -427,9 +447,13 @@ function ManagedFileField({
|
|
|
427
447
|
maxCount?: number;
|
|
428
448
|
accept?: string | string[];
|
|
429
449
|
onUpload?: SurfaceFieldRenderers['upload'];
|
|
450
|
+
mobile?: boolean;
|
|
430
451
|
}) {
|
|
431
452
|
const [uploading, setUploading] = useState(false);
|
|
432
453
|
const [uploadError, setUploadError] = useState('');
|
|
454
|
+
const [mobilePending, setMobilePending] = useState<MobilePendingUpload[]>([]);
|
|
455
|
+
const mobileInputRef = useRef<HTMLInputElement | null>(null);
|
|
456
|
+
const mobileActiveUploads = useRef(0);
|
|
433
457
|
const refs = useMemo(() => (Array.isArray(value) ? value : value ? [value] : []), [value]);
|
|
434
458
|
const refsRef = useRef(refs);
|
|
435
459
|
useEffect(() => {
|
|
@@ -438,6 +462,91 @@ function ManagedFileField({
|
|
|
438
462
|
if (!onUpload) {
|
|
439
463
|
return <Alert type="info" showIcon message="该文件字段尚未配置上传能力" />;
|
|
440
464
|
}
|
|
465
|
+
if (mobile) {
|
|
466
|
+
const acceptValue = Array.isArray(accept) ? accept.join(',') : accept;
|
|
467
|
+
const uploadOne = async (item: MobilePendingUpload) => {
|
|
468
|
+
setMobilePending(current => current.map(entry => entry.id === item.id ? { ...entry, status: 'uploading', error: undefined } : entry));
|
|
469
|
+
mobileActiveUploads.current += 1;
|
|
470
|
+
setUploading(true);
|
|
471
|
+
try {
|
|
472
|
+
const uploaded = await onUpload(field, item.file, recordId);
|
|
473
|
+
const next = multiple ? [...refsRef.current, uploaded].slice(0, maxCount) : uploaded;
|
|
474
|
+
refsRef.current = Array.isArray(next) ? next : next ? [next] : [];
|
|
475
|
+
onChange?.(next);
|
|
476
|
+
setMobilePending(current => current.filter(entry => entry.id !== item.id));
|
|
477
|
+
} catch (error) {
|
|
478
|
+
setMobilePending(current => current.map(entry => entry.id === item.id ? { ...entry, status: 'error', error: error instanceof Error ? error.message : String(error) } : entry));
|
|
479
|
+
} finally {
|
|
480
|
+
mobileActiveUploads.current = Math.max(0, mobileActiveUploads.current - 1);
|
|
481
|
+
setUploading(mobileActiveUploads.current > 0);
|
|
482
|
+
}
|
|
483
|
+
};
|
|
484
|
+
const selectFiles = (files: File[]) => {
|
|
485
|
+
const pendingCount = mobilePending.length;
|
|
486
|
+
const available = Math.max(0, maxCount - refsRef.current.length - pendingCount);
|
|
487
|
+
const selected = multiple ? files.slice(0, available) : files.slice(0, 1);
|
|
488
|
+
if (!selected.length) return;
|
|
489
|
+
const items = selected.map(file => ({ id: createMobileUploadId(), file, status: 'uploading' as const }));
|
|
490
|
+
setMobilePending(current => multiple ? [...current, ...items] : items);
|
|
491
|
+
items.forEach(item => void uploadOne(item));
|
|
492
|
+
};
|
|
493
|
+
return (
|
|
494
|
+
<div className="oxa-file-field oxa-mobile-file-field">
|
|
495
|
+
<input
|
|
496
|
+
ref={mobileInputRef}
|
|
497
|
+
accept={acceptValue}
|
|
498
|
+
disabled={disabled || uploading || mobilePending.some(item => item.status === 'uploading') || refs.length + mobilePending.length >= maxCount}
|
|
499
|
+
multiple={multiple}
|
|
500
|
+
onChange={event => {
|
|
501
|
+
selectFiles(Array.from(event.target.files || []));
|
|
502
|
+
event.currentTarget.value = '';
|
|
503
|
+
}}
|
|
504
|
+
style={{ display: 'none' }}
|
|
505
|
+
type="file"
|
|
506
|
+
/>
|
|
507
|
+
{(!maxCount || refs.length + mobilePending.length < maxCount) && (
|
|
508
|
+
<Button
|
|
509
|
+
className="oxa-mobile-file-button"
|
|
510
|
+
disabled={disabled || uploading || mobilePending.some(item => item.status === 'uploading')}
|
|
511
|
+
icon={<UploadOutlined />}
|
|
512
|
+
onClick={() => mobileInputRef.current?.click()}
|
|
513
|
+
>
|
|
514
|
+
上传文件
|
|
515
|
+
</Button>
|
|
516
|
+
)}
|
|
517
|
+
{mobilePending.length > 0 && (
|
|
518
|
+
<div className="oxa-mobile-pending-files">
|
|
519
|
+
{mobilePending.map(item => (
|
|
520
|
+
<div className="oxa-mobile-pending-file" key={item.id}>
|
|
521
|
+
<span className="oxa-mobile-pending-file-copy">
|
|
522
|
+
<strong className="oxa-mobile-pending-file-name" title={item.file.name}>{item.file.name}</strong>
|
|
523
|
+
<small>{formatManagedFileSize(item.file.size)} · {item.status === 'error' ? '上传失败' : '上传中…'}</small>
|
|
524
|
+
</span>
|
|
525
|
+
{item.status === 'error' ? (
|
|
526
|
+
<>
|
|
527
|
+
<span className="oxa-mobile-pending-error">{item.error || '上传失败'}</span>
|
|
528
|
+
<Button onClick={() => void uploadOne(item)} size="small" type="link">重试</Button>
|
|
529
|
+
<Button onClick={() => setMobilePending(current => current.filter(entry => entry.id !== item.id))} size="small" type="link">移除</Button>
|
|
530
|
+
</>
|
|
531
|
+
) : (
|
|
532
|
+
<span className="oxa-mobile-pending-status">上传中…</span>
|
|
533
|
+
)}
|
|
534
|
+
</div>
|
|
535
|
+
))}
|
|
536
|
+
</div>
|
|
537
|
+
)}
|
|
538
|
+
{refs.length ? (
|
|
539
|
+
<AttachmentFileList files={refs} onRemove={file => {
|
|
540
|
+
const next = refs.filter(item => item.id !== file.id);
|
|
541
|
+
refsRef.current = next;
|
|
542
|
+
onChange?.(multiple ? next : undefined);
|
|
543
|
+
}} removable={!disabled} />
|
|
544
|
+
) : (
|
|
545
|
+
<div className="oxa-file-empty">尚未上传文件</div>
|
|
546
|
+
)}
|
|
547
|
+
</div>
|
|
548
|
+
);
|
|
549
|
+
}
|
|
441
550
|
const uploadProps = {
|
|
442
551
|
accept: Array.isArray(accept) ? accept.join(',') : accept,
|
|
443
552
|
disabled,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Refine } from '@refinedev/core';
|
|
2
|
-
import { App as AntdApp, ConfigProvider } from 'antd';
|
|
2
|
+
import { App as AntdApp, ConfigProvider, Spin } from 'antd';
|
|
3
3
|
import zhCN from 'antd/locale/zh_CN';
|
|
4
4
|
import React from 'react';
|
|
5
5
|
import ReactDOM from 'react-dom/client';
|
|
@@ -11,11 +11,22 @@ import { FilePreviewPage } from './FilePreviewPage';
|
|
|
11
11
|
import { EmptyApplicationPage } from './Shell';
|
|
12
12
|
import { RuntimeBoundary } from './runtime';
|
|
13
13
|
import { applicationBasename } from './runtime-meta';
|
|
14
|
+
import { useGlobalRequestLoading } from './platform-client';
|
|
14
15
|
import './styles.css';
|
|
15
16
|
|
|
16
17
|
const codes = Object.keys(resourceDefinitions);
|
|
17
18
|
const firstPath = codes[0] ? `/${codes[0]}` : '/';
|
|
18
19
|
|
|
20
|
+
function GlobalRequestLoading() {
|
|
21
|
+
const loading = useGlobalRequestLoading();
|
|
22
|
+
return loading ? (
|
|
23
|
+
<div aria-live="polite" className="oxa-global-request-loading">
|
|
24
|
+
<Spin size="small" />
|
|
25
|
+
<span>正在加载…</span>
|
|
26
|
+
</div>
|
|
27
|
+
) : null;
|
|
28
|
+
}
|
|
29
|
+
|
|
19
30
|
const resourceRoutes = codes.flatMap(resourceCode => [
|
|
20
31
|
<Route key={`${resourceCode}-list`} path={`/${resourceCode}`} element={<GeneratedResourcePage resourceCode={resourceCode} />} />,
|
|
21
32
|
<Route key={`${resourceCode}-new`} path={`/${resourceCode}/new`} element={<GeneratedResourcePage mode="create" resourceCode={resourceCode} />} />,
|
|
@@ -31,6 +42,7 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
|
|
|
31
42
|
<React.StrictMode>
|
|
32
43
|
<ConfigProvider locale={zhCN} theme={{ token: { borderRadius: 8, colorBgLayout: '#f3f7fd', colorPrimary: '#1677ff', colorText: '#172033', fontSize: 14 }, components: { Card: { headerFontSize: 16 }, Layout: { bodyBg: '#f3f7fd', headerBg: '#ffffff', siderBg: '#ffffff' }, Menu: { itemBorderRadius: 8, itemSelectedBg: '#eaf3ff' } } }}>
|
|
33
44
|
<AntdApp>
|
|
45
|
+
<GlobalRequestLoading />
|
|
34
46
|
<RuntimeBoundary>
|
|
35
47
|
<BrowserRouter basename={applicationBasename()}>
|
|
36
48
|
<Refine dataProvider={applicationProvider} resources={codes.map(code => ({ name: code, list: `/${code}`, create: `/${code}/new`, edit: `/${code}/:id/edit`, show: `/${code}/:id` }))}>
|
|
@@ -12,30 +12,62 @@ import {
|
|
|
12
12
|
type DirectoryResolveRequest,
|
|
13
13
|
} from 'openxiangda-contracts/browser';
|
|
14
14
|
import { applicationCode, runtimeMount } from './runtime-meta';
|
|
15
|
+
import { useSyncExternalStore } from 'react';
|
|
15
16
|
|
|
16
17
|
interface PlatformEnvelope<T> {
|
|
17
18
|
code: number;
|
|
18
19
|
message?: string;
|
|
19
20
|
errorCode?: string;
|
|
21
|
+
requestId?: string;
|
|
20
22
|
data: T;
|
|
21
23
|
}
|
|
22
24
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
25
|
+
let globalRequestCount = 0;
|
|
26
|
+
const globalRequestListeners = new Set<() => void>();
|
|
27
|
+
|
|
28
|
+
function beginGlobalRequest() {
|
|
29
|
+
globalRequestCount += 1;
|
|
30
|
+
globalRequestListeners.forEach(listener => listener());
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function endGlobalRequest() {
|
|
34
|
+
globalRequestCount = Math.max(0, globalRequestCount - 1);
|
|
35
|
+
globalRequestListeners.forEach(listener => listener());
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function useGlobalRequestLoading() {
|
|
39
|
+
return useSyncExternalStore(
|
|
40
|
+
listener => {
|
|
41
|
+
globalRequestListeners.add(listener);
|
|
42
|
+
return () => globalRequestListeners.delete(listener);
|
|
31
43
|
},
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
44
|
+
() => globalRequestCount > 0,
|
|
45
|
+
() => false,
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
|
50
|
+
beginGlobalRequest();
|
|
51
|
+
try {
|
|
52
|
+
const response = await fetch(path, {
|
|
53
|
+
credentials: 'include',
|
|
54
|
+
...init,
|
|
55
|
+
headers: {
|
|
56
|
+
accept: 'application/json',
|
|
57
|
+
...(init?.body ? { 'content-type': 'application/json' } : {}),
|
|
58
|
+
...init?.headers,
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
const payload = (await response.json().catch(() => null)) as PlatformEnvelope<T> | T | null;
|
|
62
|
+
const envelope = payload && typeof payload === 'object' && 'code' in payload ? payload as PlatformEnvelope<T> : null;
|
|
63
|
+
if (!response.ok || (envelope && envelope.code !== 200)) {
|
|
64
|
+
const requestId = envelope?.requestId ? ` (requestId: ${envelope.requestId})` : '';
|
|
65
|
+
throw new Error(`${envelope?.errorCode || `HTTP_${response.status}`}: ${envelope?.message || '平台请求失败'}${requestId}`);
|
|
66
|
+
}
|
|
67
|
+
return envelope ? envelope.data : payload as T;
|
|
68
|
+
} finally {
|
|
69
|
+
endGlobalRequest();
|
|
37
70
|
}
|
|
38
|
-
return envelope ? envelope.data : payload as T;
|
|
39
71
|
}
|
|
40
72
|
|
|
41
73
|
export interface RuntimeIdentity {
|
|
@@ -24,6 +24,25 @@ body {
|
|
|
24
24
|
place-items: center;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
.oxa-global-request-loading {
|
|
28
|
+
position: fixed;
|
|
29
|
+
z-index: 1200;
|
|
30
|
+
top: 14px;
|
|
31
|
+
left: 50%;
|
|
32
|
+
display: inline-flex;
|
|
33
|
+
align-items: center;
|
|
34
|
+
gap: 8px;
|
|
35
|
+
padding: 7px 13px;
|
|
36
|
+
border: 1px solid #dbe7f7;
|
|
37
|
+
border-radius: 999px;
|
|
38
|
+
background: rgba(255, 255, 255, 0.96);
|
|
39
|
+
box-shadow: 0 8px 24px rgba(31, 59, 102, 0.15);
|
|
40
|
+
color: #355070;
|
|
41
|
+
font-size: 13px;
|
|
42
|
+
pointer-events: none;
|
|
43
|
+
transform: translateX(-50%);
|
|
44
|
+
}
|
|
45
|
+
|
|
27
46
|
.oxa-app-layout {
|
|
28
47
|
min-height: 100vh;
|
|
29
48
|
background: #f3f7fd;
|
|
@@ -1075,6 +1094,61 @@ body {
|
|
|
1075
1094
|
.oxa-audit-entry {
|
|
1076
1095
|
display: grid;
|
|
1077
1096
|
gap: 4px;
|
|
1097
|
+
padding: 10px 0;
|
|
1098
|
+
border-bottom: 1px solid #edf0f5;
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
.oxa-audit-entry:last-child {
|
|
1102
|
+
border-bottom: 0;
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
.oxa-audit-heading,
|
|
1106
|
+
.oxa-audit-meta,
|
|
1107
|
+
.oxa-audit-change-values {
|
|
1108
|
+
display: flex;
|
|
1109
|
+
align-items: center;
|
|
1110
|
+
gap: 8px;
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
.oxa-audit-heading {
|
|
1114
|
+
justify-content: space-between;
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
.oxa-audit-meta {
|
|
1118
|
+
flex-wrap: wrap;
|
|
1119
|
+
color: #7b879b;
|
|
1120
|
+
font-size: 12px;
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
.oxa-audit-meta .ant-typography {
|
|
1124
|
+
font-size: 12px;
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
.oxa-audit-changes {
|
|
1128
|
+
display: grid;
|
|
1129
|
+
gap: 7px;
|
|
1130
|
+
margin-top: 4px;
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
.oxa-audit-change {
|
|
1134
|
+
display: grid;
|
|
1135
|
+
gap: 3px;
|
|
1136
|
+
padding: 7px 8px;
|
|
1137
|
+
border-radius: 6px;
|
|
1138
|
+
background: #f7f9fc;
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
.oxa-audit-change-values {
|
|
1142
|
+
min-width: 0;
|
|
1143
|
+
color: #47566f;
|
|
1144
|
+
font-size: 12px;
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
.oxa-audit-change-values > :not(svg) {
|
|
1148
|
+
min-width: 0;
|
|
1149
|
+
overflow: hidden;
|
|
1150
|
+
text-overflow: ellipsis;
|
|
1151
|
+
white-space: nowrap;
|
|
1078
1152
|
}
|
|
1079
1153
|
|
|
1080
1154
|
.oxa-audit-entry > span {
|
|
@@ -1082,6 +1156,214 @@ body {
|
|
|
1082
1156
|
font-size: 12px;
|
|
1083
1157
|
}
|
|
1084
1158
|
|
|
1159
|
+
.oxa-mobile-reference-trigger {
|
|
1160
|
+
display: flex;
|
|
1161
|
+
width: 100%;
|
|
1162
|
+
min-height: 42px;
|
|
1163
|
+
align-items: center;
|
|
1164
|
+
justify-content: space-between;
|
|
1165
|
+
gap: 8px;
|
|
1166
|
+
padding: 9px 12px;
|
|
1167
|
+
border: 1px solid #d9dfe9;
|
|
1168
|
+
border-radius: 8px;
|
|
1169
|
+
background: #fff;
|
|
1170
|
+
color: #1f2b3d;
|
|
1171
|
+
font: inherit;
|
|
1172
|
+
text-align: left;
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
.oxa-mobile-reference-trigger:disabled {
|
|
1176
|
+
background: #f5f7fa;
|
|
1177
|
+
color: #9aa5b5;
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
.oxa-mobile-reference-placeholder {
|
|
1181
|
+
color: #9aa5b5;
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
.oxa-mobile-reference-backdrop {
|
|
1185
|
+
position: fixed;
|
|
1186
|
+
z-index: 1200;
|
|
1187
|
+
inset: 0;
|
|
1188
|
+
display: flex;
|
|
1189
|
+
align-items: flex-end;
|
|
1190
|
+
background: rgb(15 23 42 / 42%);
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
.oxa-mobile-reference-sheet {
|
|
1194
|
+
display: grid;
|
|
1195
|
+
width: 100%;
|
|
1196
|
+
max-height: min(78vh, 640px);
|
|
1197
|
+
gap: 12px;
|
|
1198
|
+
padding: 16px 16px max(16px, env(safe-area-inset-bottom));
|
|
1199
|
+
overflow: auto;
|
|
1200
|
+
border-radius: 16px 16px 0 0;
|
|
1201
|
+
background: #fff;
|
|
1202
|
+
box-shadow: 0 -8px 32px rgb(15 23 42 / 18%);
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
.oxa-mobile-reference-header {
|
|
1206
|
+
display: flex;
|
|
1207
|
+
align-items: center;
|
|
1208
|
+
justify-content: space-between;
|
|
1209
|
+
color: #1f2b3d;
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
.oxa-mobile-reference-header button,
|
|
1213
|
+
.oxa-mobile-reference-more,
|
|
1214
|
+
.oxa-mobile-reference-confirm {
|
|
1215
|
+
border: 0;
|
|
1216
|
+
background: transparent;
|
|
1217
|
+
color: #2d6cdf;
|
|
1218
|
+
font: inherit;
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
.oxa-mobile-reference-search {
|
|
1222
|
+
width: 100%;
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
.oxa-mobile-reference-options {
|
|
1226
|
+
display: grid;
|
|
1227
|
+
gap: 4px;
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
.oxa-mobile-reference-option {
|
|
1231
|
+
display: flex;
|
|
1232
|
+
min-height: 48px;
|
|
1233
|
+
align-items: center;
|
|
1234
|
+
gap: 10px;
|
|
1235
|
+
padding: 8px 4px;
|
|
1236
|
+
border: 0;
|
|
1237
|
+
border-radius: 8px;
|
|
1238
|
+
background: transparent;
|
|
1239
|
+
color: #1f2b3d;
|
|
1240
|
+
font: inherit;
|
|
1241
|
+
text-align: left;
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
.oxa-mobile-reference-option.is-selected {
|
|
1245
|
+
background: #edf4ff;
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
.oxa-mobile-reference-option:disabled {
|
|
1249
|
+
color: #aab3c0;
|
|
1250
|
+
opacity: 0.7;
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
.oxa-mobile-reference-option-copy {
|
|
1254
|
+
display: grid;
|
|
1255
|
+
min-width: 0;
|
|
1256
|
+
flex: 1;
|
|
1257
|
+
gap: 2px;
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
.oxa-mobile-reference-option-copy small {
|
|
1261
|
+
overflow: hidden;
|
|
1262
|
+
color: #7b879b;
|
|
1263
|
+
font-size: 12px;
|
|
1264
|
+
text-overflow: ellipsis;
|
|
1265
|
+
white-space: nowrap;
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
.oxa-mobile-reference-loading,
|
|
1269
|
+
.oxa-mobile-reference-empty,
|
|
1270
|
+
.oxa-mobile-reference-error {
|
|
1271
|
+
padding: 12px 4px;
|
|
1272
|
+
color: #7b879b;
|
|
1273
|
+
font-size: 13px;
|
|
1274
|
+
text-align: center;
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
.oxa-mobile-reference-error {
|
|
1278
|
+
color: #cf3c46;
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
.oxa-mobile-reference-more,
|
|
1282
|
+
.oxa-mobile-reference-confirm {
|
|
1283
|
+
min-height: 42px;
|
|
1284
|
+
border-radius: 8px;
|
|
1285
|
+
background: #f0f5ff;
|
|
1286
|
+
font-weight: 600;
|
|
1287
|
+
text-align: center;
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
.oxa-mobile-reference-confirm {
|
|
1291
|
+
background: #2d6cdf;
|
|
1292
|
+
color: #fff;
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
.oxa-mobile-file-field {
|
|
1296
|
+
display: grid;
|
|
1297
|
+
gap: 8px;
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
.oxa-mobile-file-button {
|
|
1301
|
+
display: inline-flex !important;
|
|
1302
|
+
width: 100%;
|
|
1303
|
+
min-height: 42px;
|
|
1304
|
+
align-items: center;
|
|
1305
|
+
justify-content: center;
|
|
1306
|
+
border: 1px solid #d9dfe9 !important;
|
|
1307
|
+
border-radius: 8px;
|
|
1308
|
+
background: #fff !important;
|
|
1309
|
+
color: #2d6cdf;
|
|
1310
|
+
font-weight: 600;
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
.oxa-mobile-file-button.ant-btn:disabled {
|
|
1314
|
+
background: #f5f7fa;
|
|
1315
|
+
color: #9aa5b5;
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
.oxa-mobile-pending-files {
|
|
1319
|
+
display: grid;
|
|
1320
|
+
gap: 8px;
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
.oxa-mobile-pending-file {
|
|
1324
|
+
display: flex;
|
|
1325
|
+
min-height: 42px;
|
|
1326
|
+
align-items: center;
|
|
1327
|
+
gap: 8px;
|
|
1328
|
+
padding: 7px 9px;
|
|
1329
|
+
border: 1px solid #dbe3ee;
|
|
1330
|
+
border-radius: 8px;
|
|
1331
|
+
background: #fbfcfe;
|
|
1332
|
+
font-size: 13px;
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
.oxa-mobile-pending-file-name {
|
|
1336
|
+
min-width: 0;
|
|
1337
|
+
overflow: hidden;
|
|
1338
|
+
text-overflow: ellipsis;
|
|
1339
|
+
white-space: nowrap;
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
.oxa-mobile-pending-file-copy {
|
|
1343
|
+
display: grid;
|
|
1344
|
+
min-width: 0;
|
|
1345
|
+
flex: 1;
|
|
1346
|
+
gap: 2px;
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
.oxa-mobile-pending-file-copy small {
|
|
1350
|
+
color: #7b879b;
|
|
1351
|
+
font-size: 12px;
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
.oxa-mobile-pending-status {
|
|
1355
|
+
color: #2d6cdf;
|
|
1356
|
+
white-space: nowrap;
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
.oxa-mobile-pending-error {
|
|
1360
|
+
max-width: 42%;
|
|
1361
|
+
overflow: hidden;
|
|
1362
|
+
color: #cf3c46;
|
|
1363
|
+
text-overflow: ellipsis;
|
|
1364
|
+
white-space: nowrap;
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1085
1367
|
@media (max-width: 1280px) {
|
|
1086
1368
|
.oxa-grid,
|
|
1087
1369
|
.oxa-filter-grid {
|
|
@@ -20,9 +20,21 @@ test('standard surfaces keep authoritative directory/resource selectors and mobi
|
|
|
20
20
|
const crud = readFileSync(new URL('../src/components/resource/GeneratedResourceCrud.tsx', import.meta.url), 'utf8');
|
|
21
21
|
assert.match(surface, /PlatformDirectoryPicker/);
|
|
22
22
|
assert.match(surface, /source="resource"/);
|
|
23
|
+
assert.match(surface, /MobileAuthoritativeSelector/);
|
|
24
|
+
assert.match(surface, /oxa-mobile-file-field/);
|
|
25
|
+
assert.match(surface, /mobilePending/);
|
|
26
|
+
assert.match(surface, /上传中/);
|
|
27
|
+
assert.match(surface, /重试/);
|
|
28
|
+
assert.match(surface, /AttachmentFileList files=\{refs\}/);
|
|
23
29
|
assert.match(selector, /searchResource|resolveResource/);
|
|
30
|
+
assert.match(selector, /role="dialog"/);
|
|
31
|
+
assert.match(selector, /oxa-mobile-reference-sheet/);
|
|
24
32
|
assert.match(crud, /MobileResourceListPage/);
|
|
25
33
|
assert.match(crud, /fieldWritable/);
|
|
34
|
+
assert.match(crud, /exportResourceRows/);
|
|
35
|
+
assert.match(crud, /text\/csv;charset=utf-8/);
|
|
36
|
+
assert.match(crud, /数据读取失败/);
|
|
37
|
+
assert.match(crud, /state\.list\.query\.refetch/);
|
|
26
38
|
});
|
|
27
39
|
|
|
28
40
|
test('runtime metadata stays mount-scoped', () => {
|
package/template/package.json
CHANGED
|
@@ -21,9 +21,9 @@
|
|
|
21
21
|
"build": "pnpm --recursive build"
|
|
22
22
|
},
|
|
23
23
|
"devDependencies": {
|
|
24
|
-
"openxiangda-cli": "2.0.0-alpha.
|
|
25
|
-
"openxiangda-contracts": "2.0.0-alpha.
|
|
26
|
-
"openxiangda-devkit-core": "2.0.0-alpha.
|
|
24
|
+
"openxiangda-cli": "2.0.0-alpha.66",
|
|
25
|
+
"openxiangda-contracts": "2.0.0-alpha.31",
|
|
26
|
+
"openxiangda-devkit-core": "2.0.0-alpha.41",
|
|
27
27
|
"typescript": "5.9.3"
|
|
28
28
|
}
|
|
29
29
|
}
|