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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openxiangda-cli",
3
- "version": "2.0.0-alpha.62",
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.30",
27
- "openxiangda-devkit-core": "2.0.0-alpha.38",
28
- "openxiangda-mcp": "2.0.0-alpha.38"
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 pnpm --filter @app/server... install --frozen-lockfile --ignore-scripts
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
@@ -15,7 +15,7 @@
15
15
  "@nestjs/common": "11.1.29",
16
16
  "@nestjs/core": "11.1.29",
17
17
  "@nestjs/platform-fastify": "11.1.29",
18
- "openxiangda-nest": "2.0.0-alpha.36",
18
+ "openxiangda-nest": "2.0.0-alpha.37",
19
19
  "reflect-metadata": "0.2.2",
20
20
  "rxjs": "7.8.2"
21
21
  },
@@ -16,7 +16,7 @@
16
16
  "antd": "6.4.3",
17
17
  "dayjs": "1.11.18",
18
18
  "docx-preview": "0.3.7",
19
- "openxiangda-contracts": "2.0.0-alpha.30",
19
+ "openxiangda-contracts": "2.0.0-alpha.31",
20
20
  "react": "19.2.8",
21
21
  "react-dom": "19.2.8",
22
22
  "react-router-dom": "7.8.2",
@@ -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
- '请输入至少 2 个字符'
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={setOpen}
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={() => message.info('导出将使用当前 Data API 查询条件')}
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.isLoading}
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.isLoading ? <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>}
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.isLoading} mode={mode} notFound={mode === 'edit' && !record} recordSummary={record ? `${name} · 当前版本 ${record.revision}` : undefined} resource={code} surface={surface} title={name}>
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 => <SurfaceFieldControl 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>
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
- useEffect(() => { if (!record) return; void createNativeResourceClient(definition.code, definition.surface).audit(record.id).then(page => setAuditEntries(page.items || []), () => setAuditEntries([])); }, [definition, record]);
302
- 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`} title="最近变更">{auditEntries.length ? auditEntries.slice(0, 5).map(entry => <div className="oxa-audit-entry" key={`${entry.occurredAt}-${entry.operation}`}><strong>{entry.operation}</strong><span>{new Date(entry.occurredAt).toLocaleString('zh-CN')}</span></div>) : <Typography.Text type="secondary">暂无变更记录</Typography.Text>}</Card></aside></div> : null;
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.isLoading && !record) ? query.query.error?.message || '记录不存在' : undefined, hero, loading: query.query.isLoading, 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 };
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 { AuthoritativeSelector, ResolvedValueText } from '../../AuthoritativeSelector';
25
- import { AttachmentFileList } from '../platform-fields/AttachmentFileList';
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
- <AuthoritativeSelector
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
- async function request<T>(path: string, init?: RequestInit): Promise<T> {
24
- const response = await fetch(path, {
25
- credentials: 'include',
26
- ...init,
27
- headers: {
28
- accept: 'application/json',
29
- ...(init?.body ? { 'content-type': 'application/json' } : {}),
30
- ...init?.headers,
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
- const payload = (await response.json().catch(() => null)) as PlatformEnvelope<T> | T | null;
34
- const envelope = payload && typeof payload === 'object' && 'code' in payload ? payload as PlatformEnvelope<T> : null;
35
- if (!response.ok || (envelope && envelope.code !== 200)) {
36
- throw new Error(`${envelope?.errorCode || `HTTP_${response.status}`}: ${envelope?.message || '平台请求失败'}`);
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', () => {
@@ -21,9 +21,9 @@
21
21
  "build": "pnpm --recursive build"
22
22
  },
23
23
  "devDependencies": {
24
- "openxiangda-cli": "2.0.0-alpha.62",
25
- "openxiangda-contracts": "2.0.0-alpha.30",
26
- "openxiangda-devkit-core": "2.0.0-alpha.38",
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
  }