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

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.
Files changed (40) hide show
  1. package/dist/create-workspace.d.ts +1 -0
  2. package/dist/create-workspace.d.ts.map +1 -1
  3. package/dist/create-workspace.js +1 -0
  4. package/dist/create-workspace.js.map +1 -1
  5. package/package.json +4 -4
  6. package/template/AGENTS.md +1 -1
  7. package/template/README.md +7 -7
  8. package/template/apps/server/package.json +1 -1
  9. package/template/apps/server/src/app.module.ts +1 -3
  10. package/template/apps/server/test/smoke.test.ts +6 -23
  11. package/template/apps/web/e2e/resources.spec.ts +20 -0
  12. package/template/apps/web/index.html +1 -1
  13. package/template/apps/web/package.json +1 -1
  14. package/template/apps/web/scripts/check.mjs +5 -2
  15. package/template/apps/web/src/AuthoritativeSelector.tsx +38 -29
  16. package/template/apps/web/src/Shell.tsx +39 -211
  17. package/template/apps/web/src/components/platform-fields/AttachmentFileList.tsx +1 -1
  18. package/template/apps/web/src/components/resource/GeneratedResourceCrud.tsx +306 -0
  19. package/template/apps/web/src/components/resource/StandardResourcePages.tsx +431 -0
  20. package/template/apps/web/src/components/resource/SurfaceFields.tsx +500 -0
  21. package/template/apps/web/src/data-provider.ts +36 -31
  22. package/template/apps/web/src/main.tsx +24 -54
  23. package/template/apps/web/src/platform-client.ts +131 -382
  24. package/template/apps/web/src/runtime-meta.ts +1 -1
  25. package/template/apps/web/src/styles.css +100 -3
  26. package/template/apps/web/test/contracts.test.ts +31 -693
  27. package/template/openxiangda.config.ts +14 -81
  28. package/template/package.json +3 -3
  29. package/template/packages/contracts/src/generated.ts +7 -130
  30. package/template/apps/server/src/instrument-context.controller.ts +0 -25
  31. package/template/apps/web/e2e/instruments.spec.ts +0 -1338
  32. package/template/apps/web/src/CollegePage.tsx +0 -181
  33. package/template/apps/web/src/InstrumentDetailPage.tsx +0 -134
  34. package/template/apps/web/src/InstrumentForm.tsx +0 -575
  35. package/template/apps/web/src/InstrumentFormPage.tsx +0 -117
  36. package/template/apps/web/src/InstrumentListPage.tsx +0 -385
  37. package/template/apps/web/src/fields.ts +0 -83
  38. package/template/apps/web/src/instrument.ts +0 -55
  39. package/template/platform/data/colleges.ts +0 -21
  40. package/template/platform/data/instruments.ts +0 -81
@@ -1,69 +1,18 @@
1
- import {
2
- ApartmentOutlined,
3
- AppstoreOutlined,
4
- BookOutlined,
5
- DownOutlined,
6
- ExperimentOutlined,
7
- HomeOutlined,
8
- LeftOutlined,
9
- MenuUnfoldOutlined,
10
- SettingOutlined,
11
- TeamOutlined,
12
- } from '@ant-design/icons';
13
- import {
14
- Avatar,
15
- Breadcrumb,
16
- Button,
17
- Layout,
18
- Menu,
19
- Space,
20
- Typography,
21
- type MenuProps,
22
- } from 'antd';
1
+ import { AppstoreOutlined, DatabaseOutlined, LeftOutlined, MenuUnfoldOutlined, SettingOutlined } from '@ant-design/icons';
2
+ import { Avatar, Breadcrumb, Button, Empty, Layout, Menu, Space, Typography, type MenuProps } from 'antd';
23
3
  import type { ReactNode } from 'react';
24
4
  import { useEffect, useMemo, useState } from 'react';
25
5
  import { useLocation, useNavigate } from 'react-router-dom';
6
+ import { resourceDefinitions } from '../../../packages/contracts/src/generated.js';
26
7
  import { resolveDirectory } from './platform-client';
27
8
  import { useRuntime } from './runtime';
28
9
 
29
10
  const { Content, Header, Sider } = Layout;
11
+ type Definition = { code: string; name: string; capabilities: { read: string }; surface: { generated?: boolean } };
12
+ const definitions = resourceDefinitions as unknown as Record<string, Definition>;
30
13
 
31
- type PageMeta = { title: string; breadcrumbs: string[]; selectedKey: string };
32
-
33
- function pageMeta(pathname: string): PageMeta {
34
- if (pathname === '/colleges') {
35
- return {
36
- title: '学院字典',
37
- breadcrumbs: ['首页', '基础数据', '学院字典'],
38
- selectedKey: '/colleges',
39
- };
40
- }
41
- if (pathname.endsWith('/new')) {
42
- return {
43
- title: '新增仪器',
44
- breadcrumbs: ['首页', '仪器管理', '仪器资源', '新增仪器'],
45
- selectedKey: '/instruments',
46
- };
47
- }
48
- if (pathname.endsWith('/edit')) {
49
- return {
50
- title: '编辑仪器',
51
- breadcrumbs: ['首页', '仪器管理', '仪器资源', '编辑仪器'],
52
- selectedKey: '/instruments',
53
- };
54
- }
55
- if (/^\/instruments\/[^/]+$/.test(pathname)) {
56
- return {
57
- title: '仪器详情',
58
- breadcrumbs: ['首页', '仪器管理', '仪器资源', '仪器详情'],
59
- selectedKey: '/instruments',
60
- };
61
- }
62
- return {
63
- title: '仪器资源',
64
- breadcrumbs: ['首页', '仪器管理', '仪器资源'],
65
- selectedKey: '/instruments',
66
- };
14
+ function currentDefinition(pathname: string) {
15
+ return Object.values(definitions).find(definition => pathname === `/${definition.code}` || pathname.startsWith(`/${definition.code}/`) || pathname === `/m/${definition.code}` || pathname.startsWith(`/m/${definition.code}/`));
67
16
  }
68
17
 
69
18
  export function Shell({ children }: { children: ReactNode }) {
@@ -72,169 +21,48 @@ export function Shell({ children }: { children: ReactNode }) {
72
21
  const navigate = useNavigate();
73
22
  const [collapsed, setCollapsed] = useState(false);
74
23
  const [user, setUser] = useState({ label: '当前用户', description: '平台用户' });
75
- const meta = pageMeta(location.pathname);
76
-
24
+ const definition = currentDefinition(location.pathname);
77
25
  useEffect(() => {
78
26
  let active = true;
79
- void resolveDirectory('user', [identity.userId])
80
- .then(page => {
81
- const entry = page.items[0];
82
- if (active && entry) {
83
- setUser({
84
- label: entry.label || '当前用户',
85
- description: entry.description || '平台用户',
86
- });
87
- }
88
- })
89
- .catch(() => {
90
- if (active) setUser({ label: '当前用户', description: '平台用户' });
91
- });
92
- return () => {
93
- active = false;
94
- };
27
+ void resolveDirectory('user', [identity.userId]).then(page => {
28
+ const entry = page.items[0];
29
+ if (active && entry) setUser({ label: entry.label || '当前用户', description: entry.description || '平台用户' });
30
+ }).catch(() => undefined);
31
+ return () => { active = false; };
95
32
  }, [identity.userId]);
96
-
97
- const menuItems = useMemo<MenuProps['items']>(
98
- () => [
99
- {
100
- key: 'overview',
101
- icon: <AppstoreOutlined />,
102
- label: '总览',
103
- children: [
104
- { key: 'workbench', icon: <HomeOutlined />, label: '工作台' },
105
- ],
106
- },
107
- {
108
- key: 'instrument-management',
109
- icon: <ExperimentOutlined />,
110
- label: '仪器管理',
111
- children: [
112
- {
113
- key: '/instruments',
114
- icon: <ExperimentOutlined />,
115
- label: '仪器资源',
116
- },
117
- {
118
- key: 'open-records',
119
- icon: <BookOutlined />,
120
- label: '开放记录',
121
- disabled: true,
122
- },
123
- ],
124
- },
125
- {
126
- key: 'base-data',
127
- icon: <ApartmentOutlined />,
128
- label: '基础数据',
129
- children: hasCapability('app:instrument-center:data:colleges:read')
130
- ? [
131
- {
132
- key: '/colleges',
133
- icon: <ApartmentOutlined />,
134
- label: '学院字典',
135
- },
136
- ]
137
- : [],
138
- },
139
- {
140
- key: 'system-settings',
141
- icon: <SettingOutlined />,
142
- label: '系统设置',
143
- children: [
144
- {
145
- key: 'application-admins',
146
- icon: <TeamOutlined />,
147
- label: '应用管理员',
148
- disabled: true,
149
- },
150
- ],
151
- },
152
- ],
153
- [hasCapability]
154
- );
155
-
33
+ const menuItems = useMemo<MenuProps['items']>(() => [
34
+ { key: 'overview', icon: <AppstoreOutlined />, label: '总览', children: [{ key: 'home', label: '首页' }] },
35
+ {
36
+ key: 'resources',
37
+ icon: <DatabaseOutlined />,
38
+ label: '数据管理',
39
+ children: Object.values(definitions)
40
+ .filter(item => item.surface.generated !== false && hasCapability(item.capabilities.read))
41
+ .map(item => ({ key: `/${item.code}`, icon: <DatabaseOutlined />, label: item.name })),
42
+ },
43
+ { key: 'settings', icon: <SettingOutlined />, label: '系统设置', children: [{ key: 'settings-disabled', label: '应用设置', disabled: true }] },
44
+ ], [hasCapability]);
156
45
  return (
157
46
  <Layout className="oxa-app-layout">
158
- <Sider
159
- className="oxa-sider"
160
- breakpoint="lg"
161
- collapsed={collapsed}
162
- collapsedWidth={76}
163
- collapsible
164
- onBreakpoint={setCollapsed}
165
- theme="light"
166
- trigger={null}
167
- width={240}
168
- >
169
- <div className="oxa-brand">
170
- <div aria-hidden className="oxa-brand-mark">
171
- <span />
172
- </div>
173
- {!collapsed && (
174
- <div className="oxa-brand-copy">
175
- <strong>仪器资源管理</strong>
176
- <span>仪器、学院与开放管理</span>
177
- </div>
178
- )}
179
- </div>
180
- <Menu
181
- className="oxa-menu"
182
- defaultOpenKeys={[
183
- 'overview',
184
- 'instrument-management',
185
- 'base-data',
186
- 'system-settings',
187
- ]}
188
- inlineCollapsed={collapsed}
189
- items={menuItems}
190
- mode="inline"
191
- onClick={({ key }) => {
192
- if (key === 'workbench' || key === '/instruments') {
193
- navigate('/instruments');
194
- } else if (key === '/colleges') {
195
- navigate('/colleges');
196
- }
197
- }}
198
- selectedKeys={[meta.selectedKey]}
199
- />
200
- <div className="oxa-sider-footer">
201
- <Button
202
- aria-label={collapsed ? '展开侧边栏' : '收起侧边栏'}
203
- block
204
- icon={collapsed ? <MenuUnfoldOutlined /> : <LeftOutlined />}
205
- onClick={() => setCollapsed(value => !value)}
206
- type="text"
207
- >
208
- {!collapsed && '收起侧边栏'}
209
- </Button>
210
- </div>
47
+ <Sider className="oxa-sider" collapsed={collapsed} collapsedWidth={76} collapsible onBreakpoint={setCollapsed} theme="light" trigger={null} width={240}>
48
+ <div className="oxa-brand"><div aria-hidden className="oxa-brand-mark"><span /></div>{!collapsed && <div className="oxa-brand-copy"><strong>OpenXiangda 应用</strong><span>标准资源管理工作台</span></div>}</div>
49
+ <Menu className="oxa-menu" defaultOpenKeys={['overview', 'resources', 'settings']} inlineCollapsed={collapsed} items={menuItems} mode="inline" onClick={({ key }) => {
50
+ if (key === 'home') navigate(Object.keys(definitions)[0] ? `/${Object.keys(definitions)[0]}` : '/');
51
+ else if (typeof key === 'string' && key.startsWith('/')) navigate(key);
52
+ }} selectedKeys={definition ? [`/${definition.code}`] : []} />
53
+ <div className="oxa-sider-footer"><Button aria-label={collapsed ? '展开侧边栏' : '收起侧边栏'} block icon={collapsed ? <MenuUnfoldOutlined /> : <LeftOutlined />} onClick={() => setCollapsed(value => !value)} type="text">{!collapsed && '收起侧边栏'}</Button></div>
211
54
  </Sider>
212
55
  <Layout className="oxa-workspace">
213
56
  <Header className="oxa-topbar">
214
- <div className="oxa-topbar-page">
215
- <Breadcrumb items={meta.breadcrumbs.map(title => ({ title }))} />
216
- <Typography.Title
217
- data-testid={meta.title === '仪器资源' ? 'instrument-title' : undefined}
218
- level={3}
219
- >
220
- {meta.title}
221
- </Typography.Title>
222
- </div>
223
- <Space className="oxa-current-user" size={12}>
224
- <Avatar className="oxa-current-user-avatar" size={42}>
225
- {user.label.slice(0, 1)}
226
- </Avatar>
227
- <div className="oxa-current-user-copy">
228
- <strong>{user.label}</strong>
229
- <span>{user.description}</span>
230
- </div>
231
- <DownOutlined className="oxa-current-user-chevron" />
232
- </Space>
57
+ <div className="oxa-topbar-page"><Breadcrumb items={[{ title: '首页' }, ...(definition ? [{ title: definition.name }] : [])]} /><Typography.Title level={3}>{definition?.name || '应用首页'}</Typography.Title></div>
58
+ <Space className="oxa-current-user" size={12}><Avatar className="oxa-current-user-avatar" size={42}>{user.label.slice(0, 1)}</Avatar><div className="oxa-current-user-copy"><strong>{user.label}</strong><span>{user.description}</span></div></Space>
233
59
  </Header>
234
- <Content className="oxa-content">
235
- <main className="oxa-main">{children}</main>
236
- </Content>
60
+ <Content className="oxa-content"><main className="oxa-main">{children}</main></Content>
237
61
  </Layout>
238
62
  </Layout>
239
63
  );
240
64
  }
65
+
66
+ export function EmptyApplicationPage() {
67
+ return <Shell><Empty description="还没有声明数据资源;请在 platform/data 中添加 DataResource" /></Shell>;
68
+ }
@@ -78,7 +78,7 @@ function openIsolatedWindow(url: string) {
78
78
 
79
79
  export function AttachmentFileList({
80
80
  files,
81
- resourceCode = 'instruments',
81
+ resourceCode = 'resources',
82
82
  removable = false,
83
83
  onRemove,
84
84
  }: {
@@ -0,0 +1,306 @@
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
+ ? (widget === 'datetime'
102
+ ? dayjs(value as never).toISOString()
103
+ : (value as { format: (pattern: string) => string }).format('YYYY-MM-DD'))
104
+ : value]
105
+ : [key, value];
106
+ })
107
+ );
108
+ }
109
+
110
+ function fieldWritable(
111
+ field: SurfaceField,
112
+ mode: 'create' | 'edit',
113
+ hasCapability: (code: string) => boolean,
114
+ isAppSuperAdmin: boolean
115
+ ) {
116
+ const capability = mode === 'create' ? field.createCapability : field.updateCapability;
117
+ if (isAppSuperAdmin) return true;
118
+ if (capability) return hasCapability(capability);
119
+ return mode === 'create';
120
+ }
121
+
122
+ function useGeneratedList(definition: GeneratedDefinition) {
123
+ const { code, surface } = definition;
124
+ const defaultSort = surface.list?.defaultSort || {
125
+ field: Object.keys(surface.fields)[0] || 'id',
126
+ order: 'asc' as const,
127
+ };
128
+ const [page, setPage] = useState(1);
129
+ const [pageSize, setPageSize] = useState(surface.list?.defaultPageSize || 20);
130
+ const [draft, setDraft] = useState<Partial<GenericResourceQuery>>({});
131
+ const [query, setQuery] = useState<Partial<GenericResourceQuery>>({});
132
+ const [sort, setSort] = useState({ field: defaultSort.field, order: defaultSort.order || 'asc' as const });
133
+ const list = useList<ResourceRecord>({
134
+ resource: code,
135
+ pagination: { currentPage: page, pageSize },
136
+ sorters: [sort],
137
+ meta: { query },
138
+ });
139
+ return { draft, list, page, pageSize, query, setDraft, setPage, setPageSize, setQuery, setSort, sort };
140
+ }
141
+
142
+ export function GeneratedResourcePage({
143
+ resourceCode,
144
+ mode = 'list',
145
+ variant = 'desktop',
146
+ }: {
147
+ resourceCode: string;
148
+ mode?: 'list' | 'create' | 'edit' | 'detail';
149
+ variant?: 'desktop' | 'mobile';
150
+ }) {
151
+ const definition = definitionFor(resourceCode);
152
+ if (!definition || definition.surface.generated === false) {
153
+ return <Result status="404" title="未声明可生成的资源页面" />;
154
+ }
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} />;
158
+ }
159
+
160
+ function GeneratedResourceListPage({ definition, variant }: { definition: GeneratedDefinition; variant: 'desktop' | 'mobile' }) {
161
+ return variant === 'mobile'
162
+ ? <GeneratedMobileList definition={definition} />
163
+ : <GeneratedDesktopList definition={definition} />;
164
+ }
165
+
166
+ function GeneratedDesktopList({ definition }: { definition: GeneratedDefinition }) {
167
+ const navigate = useNavigate();
168
+ const { message } = App.useApp();
169
+ const { hasCapability, identity } = useRuntime();
170
+ const state = useGeneratedList(definition);
171
+ const { code, name, surface, capabilities } = definition;
172
+ const fields = listSurfaceFields(surface.fields);
173
+ const remove = useDelete();
174
+ const rows = (state.list.result.data || []) as unknown as ResourceRecord[];
175
+ const columns = useMemo<TableColumnsType<ResourceRecord>>(() => [
176
+ ...fields.map(field => ({
177
+ title: field.label,
178
+ dataIndex: field.key,
179
+ key: field.key,
180
+ sorter: Boolean(field.sortable),
181
+ render: (value: unknown, record: ResourceRecord) => (
182
+ <SurfaceFieldValue field={field} value={value} renderers={{ renderValue: ({ value: current }) => field.key === fields[0]?.key ? <a onClick={() => navigate(`/${code}/${record.id}`)}>{String(current || '-')}</a> : undefined }} />
183
+ ),
184
+ })),
185
+ {
186
+ title: '更新时间',
187
+ dataIndex: 'updated_at',
188
+ render: value => value ? new Date(String(value)).toLocaleString('zh-CN') : '-',
189
+ },
190
+ {
191
+ title: '操作',
192
+ key: 'actions',
193
+ render: (_: unknown, record: ResourceRecord) => (
194
+ <Space>
195
+ <Button aria-label="查看" icon={<EyeOutlined />} onClick={() => navigate(`/${code}/${record.id}`)} size="small" />
196
+ {hasCapability(capabilities.update) && <Button aria-label="编辑" icon={<EditOutlined />} onClick={() => navigate(`/${code}/${record.id}/edit`)} size="small" />}
197
+ {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>}
198
+ </Space>
199
+ ),
200
+ },
201
+ ], [capabilities.delete, capabilities.update, code, fields, hasCapability, message, name, navigate, remove, state.list.query]);
202
+ return (
203
+ <ResourceListPage
204
+ createCapability={capabilities.create}
205
+ createPath={`/${code}/new`}
206
+ description={`由 ${code} 资源 Surface 自动生成的标准 CRUD 页面`}
207
+ onExport={() => message.info('导出将使用当前 Data API 查询条件')}
208
+ readCapability={capabilities.read}
209
+ resource={code}
210
+ surface={surface}
211
+ title={name}
212
+ >
213
+ <div className="oxa-filter-grid">
214
+ <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
+ <Input allowClear prefix={<SearchOutlined />} placeholder="关键词" value={state.draft.keyword} onChange={event => state.setDraft(item => ({ ...item, keyword: event.target.value }))} />
216
+ {(surface.list?.filterFields || []).map(key => {
217
+ const field = fieldFor(surface, key);
218
+ return <SurfaceFilterControl key={key} field={field} value={state.draft.filters?.[key]} onChange={value => state.setDraft(item => ({ ...item, filters: { ...item.filters, [key]: value } }))} />;
219
+ })}
220
+ <Space>
221
+ <Button type="primary" icon={<SearchOutlined />} onClick={() => { state.setPage(1); state.setQuery(state.draft); }}>查询</Button>
222
+ <Button onClick={() => { state.setDraft({}); state.setQuery({}); state.setPage(1); }}>重置</Button>
223
+ </Space>
224
+ </div>
225
+ <Table<ResourceRecord>
226
+ columns={columns}
227
+ dataSource={rows}
228
+ loading={state.list.query.isLoading}
229
+ locale={{ emptyText: <Empty description={`暂无${name}`} /> }}
230
+ rowKey="id"
231
+ 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' }); }}
232
+ pagination={{ current: state.page, pageSize: state.pageSize, total: state.list.result.total || 0, showSizeChanger: true, onChange: (next, size) => { state.setPage(next); state.setPageSize(size); } }}
233
+ />
234
+ </ResourceListPage>
235
+ );
236
+ }
237
+
238
+ function GeneratedMobileList({ definition }: { definition: GeneratedDefinition }) {
239
+ const navigate = useNavigate();
240
+ const { hasCapability } = useRuntime();
241
+ const { code, name, surface, capabilities } = definition;
242
+ const state = useGeneratedList(definition);
243
+ const rows = (state.list.result.data || []) as unknown as ResourceRecord[];
244
+ const fields = listSurfaceFields(surface.fields).slice(0, 4);
245
+ return (
246
+ <MobileResourceListPage createCapability={capabilities.create} createPath={`/${code}/new`} description={`${code} 资源`} readCapability={capabilities.read} resource={code} surface={surface} title={name}>
247
+ <div className="oxa-mobile-filter">
248
+ <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>
250
+ </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>}
252
+ <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
+ </MobileResourceListPage>
254
+ );
255
+ }
256
+
257
+ function GeneratedResourceFormPage({ definition, mode, variant }: { definition: GeneratedDefinition; mode: 'create' | 'edit'; variant: 'desktop' | 'mobile' }) {
258
+ const { id = '' } = useParams();
259
+ const navigate = useNavigate();
260
+ const { message } = App.useApp();
261
+ const { hasCapability, identity } = useRuntime();
262
+ const [form] = Form.useForm();
263
+ const create = useCreate<ResourceRecord>();
264
+ const update = useUpdate<ResourceRecord>();
265
+ const query = useOne<ResourceRecord>({ resource: definition.code, id, queryOptions: { enabled: mode === 'edit' } });
266
+ const record = query.result;
267
+ const { code, name, surface, capabilities } = definition;
268
+ useEffect(() => { if (record) form.setFieldsValue(normalizeRecordForForm(record, surface)); }, [form, record, surface]);
269
+ const listPath = `/${code}`;
270
+ const detailPath = `/${code}/${id}`;
271
+ const Page = variant === 'mobile' ? MobileResourceFormPage : ResourceFormPage;
272
+ if (!hasCapability(mode === 'create' ? capabilities.create : capabilities.update)) return <Result status="403" title="当前平台用户无此操作权限" />;
273
+ const submit = async (values: Record<string, unknown>) => {
274
+ const next = normalizeFormValues(
275
+ Object.fromEntries(
276
+ Object.entries(values).filter(([key]) =>
277
+ fieldWritable(surface.fields[key] ? { key, ...surface.fields[key] } : { key, label: key, widget: 'text' }, mode, hasCapability, identity.isAppSuperAdmin)
278
+ )
279
+ ),
280
+ surface
281
+ );
282
+ if (mode === 'create') { await create.mutateAsync({ resource: code, values: next }); message.success(`${name}已创建`); navigate(listPath); }
283
+ else { const result = await update.mutateAsync({ resource: code, id, values: next, meta: { expectedRevision: record?.revision } }); message.success('修改已保存'); navigate(`/${code}/${result.data.id}`); }
284
+ };
285
+ 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}>
287
+ <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>
290
+ </Form>
291
+ </Page>
292
+ );
293
+ }
294
+
295
+ function GeneratedResourceDetailPage({ definition, variant }: { definition: GeneratedDefinition; variant: 'desktop' | 'mobile' }) {
296
+ const { id = '' } = useParams();
297
+ const navigate = useNavigate();
298
+ const query = useOne<ResourceRecord>({ resource: definition.code, id, queryOptions: { retry: false } });
299
+ const record = query.result;
300
+ 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;
303
+ 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 };
305
+ return variant === 'mobile' ? <MobileResourceDetailPage {...props} auditTargetId={`${definition.code}-audit`}>{content}</MobileResourceDetailPage> : <ResourceDetailPage {...props} auditTargetId={`${definition.code}-audit`}>{content}</ResourceDetailPage>;
306
+ }