openxiangda-cli 2.0.0-alpha.59 → 2.0.0-alpha.61

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.
@@ -0,0 +1,285 @@
1
+ import {
2
+ DeleteOutlined,
3
+ EditOutlined,
4
+ EyeOutlined,
5
+ PlusOutlined,
6
+ SearchOutlined,
7
+ } from '@ant-design/icons';
8
+ import { useCreate, useDelete, useList, useOne, useUpdate } from '@refinedev/core';
9
+ import {
10
+ App,
11
+ Button,
12
+ Card,
13
+ Descriptions,
14
+ Empty,
15
+ Form,
16
+ Input,
17
+ Popconfirm,
18
+ Result,
19
+ Select,
20
+ Space,
21
+ Spin,
22
+ Table,
23
+ Typography,
24
+ type TableColumnsType,
25
+ } from 'antd';
26
+ import dayjs from 'dayjs';
27
+ import type { DataAuditEntry, DataResourceSurface } from 'openxiangda-contracts/browser';
28
+ import { useEffect, useMemo, useState } from 'react';
29
+ import { useNavigate, useParams } from 'react-router-dom';
30
+ import { resourceDefinitions } from '../../../../../packages/contracts/src/generated.js';
31
+ import {
32
+ MobileResourceDetailPage,
33
+ MobileResourceFormPage,
34
+ MobileResourceListPage,
35
+ ResourceDetailPage,
36
+ ResourceFormPage,
37
+ ResourceListPage,
38
+ } from './StandardResourcePages';
39
+ import {
40
+ listSurfaceFields,
41
+ MobileSurfaceFieldControl,
42
+ SurfaceFieldControl,
43
+ SurfaceFieldValue,
44
+ SurfaceFilterControl,
45
+ type SurfaceField,
46
+ } from './SurfaceFields';
47
+ import { createNativeResourceClient, type GenericResourceQuery } from '../../platform-client';
48
+ import { useRuntime } from '../../runtime';
49
+
50
+ type ResourceRecord = Record<string, unknown> & {
51
+ id: string;
52
+ revision: number;
53
+ updated_at?: string;
54
+ };
55
+
56
+ type GeneratedDefinition = {
57
+ code: string;
58
+ name: string;
59
+ capabilities: { read: string; create: string; update: string; delete: string };
60
+ surface: DataResourceSurface;
61
+ };
62
+
63
+ const definitions = resourceDefinitions as unknown as Record<string, GeneratedDefinition>;
64
+
65
+ function definitionFor(resourceCode: string) {
66
+ return definitions[resourceCode];
67
+ }
68
+
69
+ function fieldFor(surface: DataResourceSurface, key: string): SurfaceField {
70
+ const field = surface.fields[key];
71
+ return field ? { key, ...field } : { key, label: key, widget: 'text' };
72
+ }
73
+
74
+ function fieldsBySection(surface: DataResourceSurface) {
75
+ const groups = new Map<string, SurfaceField[]>();
76
+ for (const [key, field] of Object.entries(surface.fields)) {
77
+ if (field.system) continue;
78
+ const section = field.section || 'default';
79
+ groups.set(section, [...(groups.get(section) || []), { key, ...field }]);
80
+ }
81
+ return [...groups.entries()].map(([section, fields]) => ({ section, fields }));
82
+ }
83
+
84
+ function normalizeRecordForForm(record: ResourceRecord, surface: DataResourceSurface) {
85
+ return Object.fromEntries(
86
+ Object.entries(record).map(([key, value]) => {
87
+ const widget = surface.fields[key]?.widget;
88
+ return widget === 'date' || widget === 'datetime'
89
+ ? [key, value ? dayjs(String(value)) : undefined]
90
+ : [key, value];
91
+ })
92
+ );
93
+ }
94
+
95
+ function normalizeFormValues(values: Record<string, unknown>, surface: DataResourceSurface) {
96
+ return Object.fromEntries(
97
+ Object.entries(values).map(([key, value]) => {
98
+ const widget = surface.fields[key]?.widget;
99
+ return widget === 'date' || widget === 'datetime'
100
+ ? [key, value && typeof value === 'object' && 'format' in value
101
+ ? (value as { format: (pattern: string) => string }).format(widget === 'datetime' ? 'YYYY-MM-DD HH:mm:ss' : 'YYYY-MM-DD')
102
+ : value]
103
+ : [key, value];
104
+ })
105
+ );
106
+ }
107
+
108
+ function useGeneratedList(definition: GeneratedDefinition) {
109
+ const { code, surface } = definition;
110
+ const defaultSort = surface.list?.defaultSort || {
111
+ field: Object.keys(surface.fields)[0] || 'id',
112
+ order: 'asc' as const,
113
+ };
114
+ const [page, setPage] = useState(1);
115
+ const [pageSize, setPageSize] = useState(surface.list?.defaultPageSize || 20);
116
+ const [draft, setDraft] = useState<Partial<GenericResourceQuery>>({});
117
+ const [query, setQuery] = useState<Partial<GenericResourceQuery>>({});
118
+ const [sort, setSort] = useState({ field: defaultSort.field, order: defaultSort.order || 'asc' as const });
119
+ const list = useList<ResourceRecord>({
120
+ resource: code,
121
+ pagination: { currentPage: page, pageSize },
122
+ sorters: [sort],
123
+ meta: { query },
124
+ });
125
+ return { draft, list, page, pageSize, query, setDraft, setPage, setPageSize, setQuery, setSort, sort };
126
+ }
127
+
128
+ export function GeneratedResourcePage({
129
+ resourceCode,
130
+ mode = 'list',
131
+ variant = 'desktop',
132
+ }: {
133
+ resourceCode: string;
134
+ mode?: 'list' | 'create' | 'edit' | 'detail';
135
+ variant?: 'desktop' | 'mobile';
136
+ }) {
137
+ const definition = definitionFor(resourceCode);
138
+ if (!definition || definition.surface.generated === false) {
139
+ return <Result status="404" title="未声明可生成的资源页面" />;
140
+ }
141
+ if (mode === 'list') return <GeneratedResourceListPage definition={definition} variant={variant} />;
142
+ if (mode === 'detail') return <GeneratedResourceDetailPage definition={definition} variant={variant} />;
143
+ return <GeneratedResourceFormPage definition={definition} mode={mode} variant={variant} />;
144
+ }
145
+
146
+ function GeneratedResourceListPage({ definition, variant }: { definition: GeneratedDefinition; variant: 'desktop' | 'mobile' }) {
147
+ return variant === 'mobile'
148
+ ? <GeneratedMobileList definition={definition} />
149
+ : <GeneratedDesktopList definition={definition} />;
150
+ }
151
+
152
+ function GeneratedDesktopList({ definition }: { definition: GeneratedDefinition }) {
153
+ const navigate = useNavigate();
154
+ const { message } = App.useApp();
155
+ const { hasCapability } = useRuntime();
156
+ const state = useGeneratedList(definition);
157
+ const { code, name, surface, capabilities } = definition;
158
+ const fields = listSurfaceFields(surface.fields);
159
+ const remove = useDelete();
160
+ const rows = (state.list.result.data || []) as unknown as ResourceRecord[];
161
+ const columns = useMemo<TableColumnsType<ResourceRecord>>(() => [
162
+ ...fields.map(field => ({
163
+ title: field.label,
164
+ dataIndex: field.key,
165
+ key: field.key,
166
+ sorter: Boolean(field.sortable),
167
+ render: (value: unknown, record: ResourceRecord) => (
168
+ <SurfaceFieldValue field={field} value={value} renderers={{ renderValue: ({ value: current }) => field.key === fields[0]?.key ? <a onClick={() => navigate(`/${code}/${record.id}`)}>{String(current || '-')}</a> : undefined }} />
169
+ ),
170
+ })),
171
+ {
172
+ title: '更新时间',
173
+ dataIndex: 'updated_at',
174
+ render: value => value ? new Date(String(value)).toLocaleString('zh-CN') : '-',
175
+ },
176
+ {
177
+ title: '操作',
178
+ key: 'actions',
179
+ render: (_: unknown, record: ResourceRecord) => (
180
+ <Space>
181
+ <Button aria-label="查看" icon={<EyeOutlined />} onClick={() => navigate(`/${code}/${record.id}`)} size="small" />
182
+ {hasCapability(capabilities.update) && <Button aria-label="编辑" icon={<EditOutlined />} onClick={() => navigate(`/${code}/${record.id}/edit`)} size="small" />}
183
+ {hasCapability(capabilities.delete) && <Popconfirm title={`确定删除${name}?`} onConfirm={() => remove.mutate({ resource: code, id: record.id, meta: { expectedRevision: record.revision } }, { onSuccess: () => { message.success('已删除'); void state.list.query.refetch(); } })}><Button aria-label="删除" danger icon={<DeleteOutlined />} size="small" /></Popconfirm>}
184
+ </Space>
185
+ ),
186
+ },
187
+ ], [capabilities.delete, capabilities.update, code, fields, hasCapability, message, name, navigate, remove, state.list.query]);
188
+ return (
189
+ <ResourceListPage
190
+ createCapability={capabilities.create}
191
+ createPath={`/${code}/new`}
192
+ description={`由 ${code} 资源 Surface 自动生成的标准 CRUD 页面`}
193
+ onExport={() => message.info('导出将使用当前 Data API 查询条件')}
194
+ readCapability={capabilities.read}
195
+ resource={code}
196
+ surface={surface}
197
+ title={name}
198
+ >
199
+ <div className="oxa-filter-grid">
200
+ <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 }))} />
201
+ <Input allowClear prefix={<SearchOutlined />} placeholder="关键词" value={state.draft.keyword} onChange={event => state.setDraft(item => ({ ...item, keyword: event.target.value }))} />
202
+ {(surface.list?.filterFields || []).map(key => {
203
+ const field = fieldFor(surface, key);
204
+ return <SurfaceFilterControl key={key} field={field} value={state.draft.filters?.[key]} onChange={value => state.setDraft(item => ({ ...item, filters: { ...item.filters, [key]: value } }))} />;
205
+ })}
206
+ <Space>
207
+ <Button type="primary" icon={<SearchOutlined />} onClick={() => { state.setPage(1); state.setQuery(state.draft); }}>查询</Button>
208
+ <Button onClick={() => { state.setDraft({}); state.setQuery({}); state.setPage(1); }}>重置</Button>
209
+ </Space>
210
+ </div>
211
+ <Table<ResourceRecord>
212
+ columns={columns}
213
+ dataSource={rows}
214
+ loading={state.list.query.isLoading}
215
+ locale={{ emptyText: <Empty description={`暂无${name}`} /> }}
216
+ rowKey="id"
217
+ 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' }); }}
218
+ pagination={{ current: state.page, pageSize: state.pageSize, total: state.list.result.total || 0, showSizeChanger: true, onChange: (next, size) => { state.setPage(next); state.setPageSize(size); } }}
219
+ />
220
+ </ResourceListPage>
221
+ );
222
+ }
223
+
224
+ function GeneratedMobileList({ definition }: { definition: GeneratedDefinition }) {
225
+ const navigate = useNavigate();
226
+ const { hasCapability } = useRuntime();
227
+ const { code, name, surface, capabilities } = definition;
228
+ const state = useGeneratedList(definition);
229
+ const rows = (state.list.result.data || []) as unknown as ResourceRecord[];
230
+ const fields = listSurfaceFields(surface.fields).slice(0, 4);
231
+ return (
232
+ <MobileResourceListPage createCapability={capabilities.create} createPath={`/${code}/new`} description={`${code} 资源`} readCapability={capabilities.read} resource={code} surface={surface} title={name}>
233
+ <div className="oxa-mobile-filter">
234
+ <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); }} />
235
+ <Button icon={<SearchOutlined />} onClick={() => { state.setPage(1); state.setQuery(state.draft); }} type="primary">查询</Button>
236
+ </div>
237
+ {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>}
238
+ <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>
239
+ </MobileResourceListPage>
240
+ );
241
+ }
242
+
243
+ function GeneratedResourceFormPage({ definition, mode, variant }: { definition: GeneratedDefinition; mode: 'create' | 'edit'; variant: 'desktop' | 'mobile' }) {
244
+ const { id = '' } = useParams();
245
+ const navigate = useNavigate();
246
+ const { message } = App.useApp();
247
+ const { hasCapability } = useRuntime();
248
+ const [form] = Form.useForm();
249
+ const create = useCreate<ResourceRecord>();
250
+ const update = useUpdate<ResourceRecord>();
251
+ const query = useOne<ResourceRecord>({ resource: definition.code, id, queryOptions: { enabled: mode === 'edit' } });
252
+ const record = query.result;
253
+ const { code, name, surface, capabilities } = definition;
254
+ useEffect(() => { if (record) form.setFieldsValue(normalizeRecordForForm(record, surface)); }, [form, record, surface]);
255
+ const listPath = `/${code}`;
256
+ const detailPath = `/${code}/${id}`;
257
+ const Page = variant === 'mobile' ? MobileResourceFormPage : ResourceFormPage;
258
+ if (!hasCapability(mode === 'create' ? capabilities.create : capabilities.update)) return <Result status="403" title="当前平台用户无此操作权限" />;
259
+ const submit = async (values: Record<string, unknown>) => {
260
+ const next = normalizeFormValues(values, surface);
261
+ if (mode === 'create') { await create.mutateAsync({ resource: code, values: next }); message.success(`${name}已创建`); navigate(listPath); }
262
+ else { const result = await update.mutateAsync({ resource: code, id, values: next, meta: { expectedRevision: record?.revision } }); message.success('修改已保存'); navigate(`/${code}/${result.data.id}`); }
263
+ };
264
+ return (
265
+ <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}>
266
+ <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>)}>
267
+ <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 => { const capability = mode === 'create' ? field.createCapability : field.updateCapability; return <SurfaceFieldControl key={field.key} disabled={Boolean(capability) && !hasCapability(capability!)} 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>
268
+ <div className="oxa-actions"><Button onClick={() => navigate(mode === 'edit' ? detailPath : listPath)}>取消</Button><Button htmlType="submit" type="primary">{mode === 'create' ? '保存' : '保存修改'}</Button></div>
269
+ </Form>
270
+ </Page>
271
+ );
272
+ }
273
+
274
+ function GeneratedResourceDetailPage({ definition, variant }: { definition: GeneratedDefinition; variant: 'desktop' | 'mobile' }) {
275
+ const { id = '' } = useParams();
276
+ const navigate = useNavigate();
277
+ const query = useOne<ResourceRecord>({ resource: definition.code, id, queryOptions: { retry: false } });
278
+ const record = query.result;
279
+ const [auditEntries, setAuditEntries] = useState<DataAuditEntry[]>([]);
280
+ useEffect(() => { if (!record) return; void createNativeResourceClient(definition.code, definition.surface).audit(record.id).then(page => setAuditEntries(page.items || []), () => setAuditEntries([])); }, [definition, record]);
281
+ 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;
282
+ 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;
283
+ 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 };
284
+ return variant === 'mobile' ? <MobileResourceDetailPage {...props} auditTargetId={`${definition.code}-audit`}>{content}</MobileResourceDetailPage> : <ResourceDetailPage {...props} auditTargetId={`${definition.code}-audit`}>{content}</ResourceDetailPage>;
285
+ }