openxiangda-cli 2.0.0-alpha.62 → 2.0.0-alpha.67
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -4
- package/template/apps/server/Dockerfile +2 -1
- package/template/apps/server/package.json +1 -1
- package/template/apps/web/package.json +1 -1
- package/template/apps/web/src/AuthoritativeSelector.tsx +262 -14
- package/template/apps/web/src/components/resource/GeneratedResourceCrud.tsx +220 -21
- package/template/apps/web/src/components/resource/SurfaceFields.tsx +112 -3
- package/template/apps/web/src/main.tsx +13 -1
- package/template/apps/web/src/platform-client.ts +55 -17
- package/template/apps/web/src/styles.css +282 -0
- package/template/apps/web/test/contracts.test.ts +17 -0
- package/template/package.json +3 -3
|
@@ -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
|
|
125
|
-
|
|
126
|
-
|
|
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(
|
|
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:
|
|
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
|
-
|
|
156
|
-
if (mode === '
|
|
157
|
-
return <
|
|
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={() =>
|
|
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.
|
|
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.
|
|
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.
|
|
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 => <
|
|
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
|
-
|
|
302
|
-
const
|
|
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.
|
|
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 {
|
|
25
|
-
|
|
24
|
+
import {
|
|
25
|
+
AuthoritativeSelector,
|
|
26
|
+
MobileAuthoritativeSelector,
|
|
27
|
+
ResolvedValueText,
|
|
28
|
+
} from '../../AuthoritativeSelector';
|
|
29
|
+
import { AttachmentFileList, formatManagedFileSize } from '../platform-fields/AttachmentFileList';
|
|
26
30
|
import { PlatformDirectoryPicker } from '../platform-fields/PlatformDirectoryPicker';
|
|
27
31
|
|
|
28
32
|
export type SurfaceField = DataFieldSurface & { key: string };
|
|
@@ -321,7 +325,7 @@ export function MobileSurfaceFieldControl({
|
|
|
321
325
|
break;
|
|
322
326
|
case 'resource':
|
|
323
327
|
control = (
|
|
324
|
-
<
|
|
328
|
+
<MobileAuthoritativeSelector
|
|
325
329
|
disabled={disabled}
|
|
326
330
|
labelField={field.reference?.kind === 'resource' ? field.reference.labelField : undefined}
|
|
327
331
|
multiple={field.multiple}
|
|
@@ -340,6 +344,7 @@ export function MobileSurfaceFieldControl({
|
|
|
340
344
|
field={field}
|
|
341
345
|
maxCount={field.maxCount}
|
|
342
346
|
multiple={field.multiple}
|
|
347
|
+
mobile
|
|
343
348
|
onUpload={renderers?.upload}
|
|
344
349
|
recordId={recordId}
|
|
345
350
|
/>
|
|
@@ -407,6 +412,20 @@ function isFileRef(value: unknown): value is DataFileRef {
|
|
|
407
412
|
return Boolean(value && typeof value === 'object' && 'id' in value);
|
|
408
413
|
}
|
|
409
414
|
|
|
415
|
+
type MobilePendingUpload = {
|
|
416
|
+
id: string;
|
|
417
|
+
file: File;
|
|
418
|
+
status: 'uploading' | 'error';
|
|
419
|
+
error?: string;
|
|
420
|
+
};
|
|
421
|
+
|
|
422
|
+
let mobileUploadSequence = 0;
|
|
423
|
+
|
|
424
|
+
function createMobileUploadId() {
|
|
425
|
+
mobileUploadSequence += 1;
|
|
426
|
+
return `mobile-upload-${Date.now()}-${mobileUploadSequence}`;
|
|
427
|
+
}
|
|
428
|
+
|
|
410
429
|
function ManagedFileField({
|
|
411
430
|
field,
|
|
412
431
|
value,
|
|
@@ -417,6 +436,7 @@ function ManagedFileField({
|
|
|
417
436
|
maxCount = 1,
|
|
418
437
|
accept,
|
|
419
438
|
onUpload,
|
|
439
|
+
mobile = false,
|
|
420
440
|
}: {
|
|
421
441
|
field: SurfaceField;
|
|
422
442
|
value?: DataFileRef | DataFileRef[];
|
|
@@ -427,9 +447,13 @@ function ManagedFileField({
|
|
|
427
447
|
maxCount?: number;
|
|
428
448
|
accept?: string | string[];
|
|
429
449
|
onUpload?: SurfaceFieldRenderers['upload'];
|
|
450
|
+
mobile?: boolean;
|
|
430
451
|
}) {
|
|
431
452
|
const [uploading, setUploading] = useState(false);
|
|
432
453
|
const [uploadError, setUploadError] = useState('');
|
|
454
|
+
const [mobilePending, setMobilePending] = useState<MobilePendingUpload[]>([]);
|
|
455
|
+
const mobileInputRef = useRef<HTMLInputElement | null>(null);
|
|
456
|
+
const mobileActiveUploads = useRef(0);
|
|
433
457
|
const refs = useMemo(() => (Array.isArray(value) ? value : value ? [value] : []), [value]);
|
|
434
458
|
const refsRef = useRef(refs);
|
|
435
459
|
useEffect(() => {
|
|
@@ -438,6 +462,91 @@ function ManagedFileField({
|
|
|
438
462
|
if (!onUpload) {
|
|
439
463
|
return <Alert type="info" showIcon message="该文件字段尚未配置上传能力" />;
|
|
440
464
|
}
|
|
465
|
+
if (mobile) {
|
|
466
|
+
const acceptValue = Array.isArray(accept) ? accept.join(',') : accept;
|
|
467
|
+
const uploadOne = async (item: MobilePendingUpload) => {
|
|
468
|
+
setMobilePending(current => current.map(entry => entry.id === item.id ? { ...entry, status: 'uploading', error: undefined } : entry));
|
|
469
|
+
mobileActiveUploads.current += 1;
|
|
470
|
+
setUploading(true);
|
|
471
|
+
try {
|
|
472
|
+
const uploaded = await onUpload(field, item.file, recordId);
|
|
473
|
+
const next = multiple ? [...refsRef.current, uploaded].slice(0, maxCount) : uploaded;
|
|
474
|
+
refsRef.current = Array.isArray(next) ? next : next ? [next] : [];
|
|
475
|
+
onChange?.(next);
|
|
476
|
+
setMobilePending(current => current.filter(entry => entry.id !== item.id));
|
|
477
|
+
} catch (error) {
|
|
478
|
+
setMobilePending(current => current.map(entry => entry.id === item.id ? { ...entry, status: 'error', error: error instanceof Error ? error.message : String(error) } : entry));
|
|
479
|
+
} finally {
|
|
480
|
+
mobileActiveUploads.current = Math.max(0, mobileActiveUploads.current - 1);
|
|
481
|
+
setUploading(mobileActiveUploads.current > 0);
|
|
482
|
+
}
|
|
483
|
+
};
|
|
484
|
+
const selectFiles = (files: File[]) => {
|
|
485
|
+
const pendingCount = mobilePending.length;
|
|
486
|
+
const available = Math.max(0, maxCount - refsRef.current.length - pendingCount);
|
|
487
|
+
const selected = multiple ? files.slice(0, available) : files.slice(0, 1);
|
|
488
|
+
if (!selected.length) return;
|
|
489
|
+
const items = selected.map(file => ({ id: createMobileUploadId(), file, status: 'uploading' as const }));
|
|
490
|
+
setMobilePending(current => multiple ? [...current, ...items] : items);
|
|
491
|
+
items.forEach(item => void uploadOne(item));
|
|
492
|
+
};
|
|
493
|
+
return (
|
|
494
|
+
<div className="oxa-file-field oxa-mobile-file-field">
|
|
495
|
+
<input
|
|
496
|
+
ref={mobileInputRef}
|
|
497
|
+
accept={acceptValue}
|
|
498
|
+
disabled={disabled || uploading || mobilePending.some(item => item.status === 'uploading') || refs.length + mobilePending.length >= maxCount}
|
|
499
|
+
multiple={multiple}
|
|
500
|
+
onChange={event => {
|
|
501
|
+
selectFiles(Array.from(event.target.files || []));
|
|
502
|
+
event.currentTarget.value = '';
|
|
503
|
+
}}
|
|
504
|
+
style={{ display: 'none' }}
|
|
505
|
+
type="file"
|
|
506
|
+
/>
|
|
507
|
+
{(!maxCount || refs.length + mobilePending.length < maxCount) && (
|
|
508
|
+
<Button
|
|
509
|
+
className="oxa-mobile-file-button"
|
|
510
|
+
disabled={disabled || uploading || mobilePending.some(item => item.status === 'uploading')}
|
|
511
|
+
icon={<UploadOutlined />}
|
|
512
|
+
onClick={() => mobileInputRef.current?.click()}
|
|
513
|
+
>
|
|
514
|
+
上传文件
|
|
515
|
+
</Button>
|
|
516
|
+
)}
|
|
517
|
+
{mobilePending.length > 0 && (
|
|
518
|
+
<div className="oxa-mobile-pending-files">
|
|
519
|
+
{mobilePending.map(item => (
|
|
520
|
+
<div className="oxa-mobile-pending-file" key={item.id}>
|
|
521
|
+
<span className="oxa-mobile-pending-file-copy">
|
|
522
|
+
<strong className="oxa-mobile-pending-file-name" title={item.file.name}>{item.file.name}</strong>
|
|
523
|
+
<small>{formatManagedFileSize(item.file.size)} · {item.status === 'error' ? '上传失败' : '上传中…'}</small>
|
|
524
|
+
</span>
|
|
525
|
+
{item.status === 'error' ? (
|
|
526
|
+
<>
|
|
527
|
+
<span className="oxa-mobile-pending-error">{item.error || '上传失败'}</span>
|
|
528
|
+
<Button onClick={() => void uploadOne(item)} size="small" type="link">重试</Button>
|
|
529
|
+
<Button onClick={() => setMobilePending(current => current.filter(entry => entry.id !== item.id))} size="small" type="link">移除</Button>
|
|
530
|
+
</>
|
|
531
|
+
) : (
|
|
532
|
+
<span className="oxa-mobile-pending-status">上传中…</span>
|
|
533
|
+
)}
|
|
534
|
+
</div>
|
|
535
|
+
))}
|
|
536
|
+
</div>
|
|
537
|
+
)}
|
|
538
|
+
{refs.length ? (
|
|
539
|
+
<AttachmentFileList files={refs} onRemove={file => {
|
|
540
|
+
const next = refs.filter(item => item.id !== file.id);
|
|
541
|
+
refsRef.current = next;
|
|
542
|
+
onChange?.(multiple ? next : undefined);
|
|
543
|
+
}} removable={!disabled} />
|
|
544
|
+
) : (
|
|
545
|
+
<div className="oxa-file-empty">尚未上传文件</div>
|
|
546
|
+
)}
|
|
547
|
+
</div>
|
|
548
|
+
);
|
|
549
|
+
}
|
|
441
550
|
const uploadProps = {
|
|
442
551
|
accept: Array.isArray(accept) ? accept.join(',') : accept,
|
|
443
552
|
disabled,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Refine } from '@refinedev/core';
|
|
2
|
-
import { App as AntdApp, ConfigProvider } from 'antd';
|
|
2
|
+
import { App as AntdApp, ConfigProvider, Spin } from 'antd';
|
|
3
3
|
import zhCN from 'antd/locale/zh_CN';
|
|
4
4
|
import React from 'react';
|
|
5
5
|
import ReactDOM from 'react-dom/client';
|
|
@@ -11,11 +11,22 @@ import { FilePreviewPage } from './FilePreviewPage';
|
|
|
11
11
|
import { EmptyApplicationPage } from './Shell';
|
|
12
12
|
import { RuntimeBoundary } from './runtime';
|
|
13
13
|
import { applicationBasename } from './runtime-meta';
|
|
14
|
+
import { useGlobalRequestLoading } from './platform-client';
|
|
14
15
|
import './styles.css';
|
|
15
16
|
|
|
16
17
|
const codes = Object.keys(resourceDefinitions);
|
|
17
18
|
const firstPath = codes[0] ? `/${codes[0]}` : '/';
|
|
18
19
|
|
|
20
|
+
function GlobalRequestLoading() {
|
|
21
|
+
const loading = useGlobalRequestLoading();
|
|
22
|
+
return loading ? (
|
|
23
|
+
<div aria-live="polite" className="oxa-global-request-loading">
|
|
24
|
+
<Spin size="small" />
|
|
25
|
+
<span>正在加载…</span>
|
|
26
|
+
</div>
|
|
27
|
+
) : null;
|
|
28
|
+
}
|
|
29
|
+
|
|
19
30
|
const resourceRoutes = codes.flatMap(resourceCode => [
|
|
20
31
|
<Route key={`${resourceCode}-list`} path={`/${resourceCode}`} element={<GeneratedResourcePage resourceCode={resourceCode} />} />,
|
|
21
32
|
<Route key={`${resourceCode}-new`} path={`/${resourceCode}/new`} element={<GeneratedResourcePage mode="create" resourceCode={resourceCode} />} />,
|
|
@@ -31,6 +42,7 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
|
|
|
31
42
|
<React.StrictMode>
|
|
32
43
|
<ConfigProvider locale={zhCN} theme={{ token: { borderRadius: 8, colorBgLayout: '#f3f7fd', colorPrimary: '#1677ff', colorText: '#172033', fontSize: 14 }, components: { Card: { headerFontSize: 16 }, Layout: { bodyBg: '#f3f7fd', headerBg: '#ffffff', siderBg: '#ffffff' }, Menu: { itemBorderRadius: 8, itemSelectedBg: '#eaf3ff' } } }}>
|
|
33
44
|
<AntdApp>
|
|
45
|
+
<GlobalRequestLoading />
|
|
34
46
|
<RuntimeBoundary>
|
|
35
47
|
<BrowserRouter basename={applicationBasename()}>
|
|
36
48
|
<Refine dataProvider={applicationProvider} resources={codes.map(code => ({ name: code, list: `/${code}`, create: `/${code}/new`, edit: `/${code}/:id/edit`, show: `/${code}/:id` }))}>
|