openxiangda-cli 2.0.0-alpha.62 → 2.0.0-alpha.67

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,17 +122,146 @@ 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
- const defaultSort = surface.list?.defaultSort || {
125
- field: Object.keys(surface.fields)[0] || 'id',
126
- order: 'asc' as const,
127
- };
250
+ const defaultSortField = surface.list?.defaultSort?.field || Object.keys(surface.fields)[0] || 'id';
251
+ const defaultSortOrder = surface.list?.defaultSort?.order || 'asc';
252
+ const defaultPageSize = surface.list?.defaultPageSize || 20;
128
253
  const [page, setPage] = useState(1);
129
- const [pageSize, setPageSize] = useState(surface.list?.defaultPageSize || 20);
254
+ const [pageSize, setPageSize] = useState(defaultPageSize);
130
255
  const [draft, setDraft] = useState<Partial<GenericResourceQuery>>({});
131
256
  const [query, setQuery] = useState<Partial<GenericResourceQuery>>({});
132
- const [sort, setSort] = useState({ field: defaultSort.field, order: defaultSort.order || 'asc' as const });
257
+ const [sort, setSort] = useState({ field: defaultSortField, order: defaultSortOrder as 'asc' | 'desc' });
258
+ useEffect(() => {
259
+ setPage(1);
260
+ setPageSize(defaultPageSize);
261
+ setDraft({});
262
+ setQuery({});
263
+ setSort({ field: defaultSortField, order: defaultSortOrder as 'asc' | 'desc' });
264
+ }, [code, defaultPageSize, defaultSortField, defaultSortOrder]);
133
265
  const list = useList<ResourceRecord>({
134
266
  resource: code,
135
267
  pagination: { currentPage: page, pageSize },
@@ -152,9 +284,10 @@ export function GeneratedResourcePage({
152
284
  if (!definition || definition.surface.generated === false) {
153
285
  return <Result status="404" title="未声明可生成的资源页面" />;
154
286
  }
155
- if (mode === 'list') return <GeneratedResourceListPage definition={definition} variant={variant} />;
156
- if (mode === 'detail') return <GeneratedResourceDetailPage definition={definition} variant={variant} />;
157
- return <GeneratedResourceFormPage definition={definition} mode={mode} variant={variant} />;
287
+ const pageKey = `${variant}:${mode}:${resourceCode}`;
288
+ if (mode === 'list') return <GeneratedResourceListPage key={pageKey} definition={definition} variant={variant} />;
289
+ if (mode === 'detail') return <GeneratedResourceDetailPage key={pageKey} definition={definition} variant={variant} />;
290
+ return <GeneratedResourceFormPage key={pageKey} definition={definition} mode={mode} variant={variant} />;
158
291
  }
159
292
 
160
293
  function GeneratedResourceListPage({ definition, variant }: { definition: GeneratedDefinition; variant: 'desktop' | 'mobile' }) {
@@ -170,6 +303,7 @@ function GeneratedDesktopList({ definition }: { definition: GeneratedDefinition
170
303
  const state = useGeneratedList(definition);
171
304
  const { code, name, surface, capabilities } = definition;
172
305
  const fields = listSurfaceFields(surface.fields);
306
+ const [exporting, setExporting] = useState(false);
173
307
  const remove = useDelete();
174
308
  const rows = (state.list.result.data || []) as unknown as ResourceRecord[];
175
309
  const columns = useMemo<TableColumnsType<ResourceRecord>>(() => [
@@ -199,17 +333,37 @@ function GeneratedDesktopList({ definition }: { definition: GeneratedDefinition
199
333
  ),
200
334
  },
201
335
  ], [capabilities.delete, capabilities.update, code, fields, hasCapability, message, name, navigate, remove, state.list.query]);
336
+ const handleExport = async () => {
337
+ setExporting(true);
338
+ try {
339
+ const result = await exportResourceRows(code, name, surface, state.query, state.sort);
340
+ message.success(result.truncated ? `已导出 ${result.count} 条(最多导出 10000 条)` : `已导出 ${result.count} 条`);
341
+ } catch (error) {
342
+ message.error(error instanceof Error ? error.message : '导出失败');
343
+ } finally {
344
+ setExporting(false);
345
+ }
346
+ };
202
347
  return (
203
348
  <ResourceListPage
204
349
  createCapability={capabilities.create}
205
350
  createPath={`/${code}/new`}
206
351
  description={`由 ${code} 资源 Surface 自动生成的标准 CRUD 页面`}
207
- onExport={() => message.info('导出将使用当前 Data API 查询条件')}
352
+ onExport={() => void handleExport()}
208
353
  readCapability={capabilities.read}
209
354
  resource={code}
210
355
  surface={surface}
211
356
  title={name}
212
357
  >
358
+ {state.list.query.isError && (
359
+ <Alert
360
+ action={<Button onClick={() => void state.list.query.refetch()} size="small">重试</Button>}
361
+ description={state.list.query.error instanceof Error ? state.list.query.error.message : '请稍后重试或联系平台管理员'}
362
+ message="数据读取失败"
363
+ showIcon
364
+ type="error"
365
+ />
366
+ )}
213
367
  <div className="oxa-filter-grid">
214
368
  <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
369
  <Input allowClear prefix={<SearchOutlined />} placeholder="关键词" value={state.draft.keyword} onChange={event => state.setDraft(item => ({ ...item, keyword: event.target.value }))} />
@@ -218,14 +372,14 @@ function GeneratedDesktopList({ definition }: { definition: GeneratedDefinition
218
372
  return <SurfaceFilterControl key={key} field={field} value={state.draft.filters?.[key]} onChange={value => state.setDraft(item => ({ ...item, filters: { ...item.filters, [key]: value } }))} />;
219
373
  })}
220
374
  <Space>
221
- <Button type="primary" icon={<SearchOutlined />} onClick={() => { state.setPage(1); state.setQuery(state.draft); }}>查询</Button>
375
+ <Button loading={state.list.query.isFetching} type="primary" icon={<SearchOutlined />} onClick={() => { state.setPage(1); state.setQuery(state.draft); }}>查询</Button>
222
376
  <Button onClick={() => { state.setDraft({}); state.setQuery({}); state.setPage(1); }}>重置</Button>
223
377
  </Space>
224
378
  </div>
225
379
  <Table<ResourceRecord>
226
380
  columns={columns}
227
381
  dataSource={rows}
228
- loading={state.list.query.isLoading}
382
+ loading={state.list.query.isFetching}
229
383
  locale={{ emptyText: <Empty description={`暂无${name}`} /> }}
230
384
  rowKey="id"
231
385
  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 +392,39 @@ function GeneratedDesktopList({ definition }: { definition: GeneratedDefinition
238
392
  function GeneratedMobileList({ definition }: { definition: GeneratedDefinition }) {
239
393
  const navigate = useNavigate();
240
394
  const { hasCapability } = useRuntime();
395
+ const { message } = App.useApp();
241
396
  const { code, name, surface, capabilities } = definition;
242
397
  const state = useGeneratedList(definition);
398
+ const [exporting, setExporting] = useState(false);
243
399
  const rows = (state.list.result.data || []) as unknown as ResourceRecord[];
244
400
  const fields = listSurfaceFields(surface.fields).slice(0, 4);
401
+ const handleExport = async () => {
402
+ setExporting(true);
403
+ try {
404
+ const result = await exportResourceRows(code, name, surface, state.query, state.sort);
405
+ message.success(result.truncated ? `已导出 ${result.count} 条(最多导出 10000 条)` : `已导出 ${result.count} 条`);
406
+ } catch (error) {
407
+ message.error(error instanceof Error ? error.message : '导出失败');
408
+ } finally {
409
+ setExporting(false);
410
+ }
411
+ };
245
412
  return (
246
- <MobileResourceListPage createCapability={capabilities.create} createPath={`/${code}/new`} description={`${code} 资源`} readCapability={capabilities.read} resource={code} surface={surface} title={name}>
413
+ <MobileResourceListPage createCapability={capabilities.create} createPath={`/${code}/new`} description={`${code} 资源`} onExport={() => void handleExport()} readCapability={capabilities.read} resource={code} surface={surface} title={name}>
414
+ {state.list.query.isError && (
415
+ <Alert
416
+ action={<Button onClick={() => void state.list.query.refetch()} size="small">重试</Button>}
417
+ description={state.list.query.error instanceof Error ? state.list.query.error.message : '请稍后重试或联系平台管理员'}
418
+ message="数据读取失败"
419
+ showIcon
420
+ type="error"
421
+ />
422
+ )}
247
423
  <div className="oxa-mobile-filter">
248
424
  <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>
425
+ <Button loading={state.list.query.isFetching} icon={<SearchOutlined />} onClick={() => { state.setPage(1); state.setQuery(state.draft); }} type="primary">查询</Button>
250
426
  </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>}
427
+ {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
428
  <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
429
  </MobileResourceListPage>
254
430
  );
@@ -269,6 +445,7 @@ function GeneratedResourceFormPage({ definition, mode, variant }: { definition:
269
445
  const listPath = `/${code}`;
270
446
  const detailPath = `/${code}/${id}`;
271
447
  const Page = variant === 'mobile' ? MobileResourceFormPage : ResourceFormPage;
448
+ const FieldControl = variant === 'mobile' ? MobileSurfaceFieldControl : SurfaceFieldControl;
272
449
  if (!hasCapability(mode === 'create' ? capabilities.create : capabilities.update)) return <Result status="403" title="当前平台用户无此操作权限" />;
273
450
  const submit = async (values: Record<string, unknown>) => {
274
451
  const next = normalizeFormValues(
@@ -283,10 +460,10 @@ function GeneratedResourceFormPage({ definition, mode, variant }: { definition:
283
460
  else { const result = await update.mutateAsync({ resource: code, id, values: next, meta: { expectedRevision: record?.revision } }); message.success('修改已保存'); navigate(`/${code}/${result.data.id}`); }
284
461
  };
285
462
  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}>
463
+ <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
464
  <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>
465
+ <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>
466
+ <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
467
  </Form>
291
468
  </Page>
292
469
  );
@@ -298,9 +475,31 @@ function GeneratedResourceDetailPage({ definition, variant }: { definition: Gene
298
475
  const query = useOne<ResourceRecord>({ resource: definition.code, id, queryOptions: { retry: false } });
299
476
  const record = query.result;
300
477
  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;
478
+ const [auditLoading, setAuditLoading] = useState(false);
479
+ const [auditError, setAuditError] = useState('');
480
+ useEffect(() => {
481
+ if (!record) return;
482
+ let active = true;
483
+ setAuditLoading(true);
484
+ setAuditError('');
485
+ void createNativeResourceClient(definition.code, definition.surface).audit(record.id)
486
+ .then(page => {
487
+ if (active) setAuditEntries(page.items || []);
488
+ })
489
+ .catch(reason => {
490
+ if (!active) return;
491
+ setAuditEntries([]);
492
+ setAuditError(reason instanceof Error ? reason.message : String(reason));
493
+ })
494
+ .finally(() => {
495
+ if (active) setAuditLoading(false);
496
+ });
497
+ return () => {
498
+ active = false;
499
+ };
500
+ }, [definition.code, definition.surface, record?.id]);
501
+ 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
502
  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 };
503
+ 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
504
  return variant === 'mobile' ? <MobileResourceDetailPage {...props} auditTargetId={`${definition.code}-audit`}>{content}</MobileResourceDetailPage> : <ResourceDetailPage {...props} auditTargetId={`${definition.code}-audit`}>{content}</ResourceDetailPage>;
306
505
  }
@@ -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` }))}>