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.
@@ -4,8 +4,9 @@ import zhCN from 'antd/locale/zh_CN';
4
4
  import React from 'react';
5
5
  import ReactDOM from 'react-dom/client';
6
6
  import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
7
- import { instrumentProvider } from './data-provider';
7
+ import { applicationProvider } from './data-provider';
8
8
  import { CollegePage } from './CollegePage';
9
+ import { GeneratedResourcePage } from './components/resource/GeneratedResourceCrud';
9
10
  import { FilePreviewPage } from './FilePreviewPage';
10
11
  import { InstrumentDetailPage } from './InstrumentDetailPage';
11
12
  import { InstrumentFormPage } from './InstrumentFormPage';
@@ -13,6 +14,7 @@ import { InstrumentListPage } from './InstrumentListPage';
13
14
  import { RuntimeBoundary } from './runtime';
14
15
  import { applicationBasename } from './runtime-meta';
15
16
  import './styles.css';
17
+ import { resourceDefinitions } from '../../../packages/contracts/src/generated.js';
16
18
 
17
19
  ReactDOM.createRoot(document.getElementById('root')!).render(
18
20
  <React.StrictMode>
@@ -37,7 +39,7 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
37
39
  <RuntimeBoundary>
38
40
  <BrowserRouter basename={applicationBasename()}>
39
41
  <Refine
40
- dataProvider={instrumentProvider}
42
+ dataProvider={applicationProvider}
41
43
  resources={[
42
44
  {
43
45
  name: 'instruments',
@@ -51,6 +53,10 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
51
53
  <Routes>
52
54
  <Route path="/" element={<Navigate replace to="/instruments" />} />
53
55
  <Route path="/instruments" element={<InstrumentListPage />} />
56
+ <Route
57
+ path="/m/instruments"
58
+ element={<InstrumentListPage variant="mobile" />}
59
+ />
54
60
  <Route path="/colleges" element={<CollegePage />} />
55
61
  <Route
56
62
  path="/files/:resourceCode/:fileId/preview"
@@ -68,6 +74,30 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
68
74
  path="/instruments/:id"
69
75
  element={<InstrumentDetailPage />}
70
76
  />
77
+ <Route
78
+ path="/m/instruments/:id"
79
+ element={<InstrumentDetailPage variant="mobile" />}
80
+ />
81
+ <Route
82
+ path="/m/instruments/new"
83
+ element={<InstrumentFormPage mode="create" variant="mobile" />}
84
+ />
85
+ <Route
86
+ path="/m/instruments/:id/edit"
87
+ element={<InstrumentFormPage mode="edit" variant="mobile" />}
88
+ />
89
+ {Object.entries(resourceDefinitions)
90
+ .filter(([, definition]) => definition.surface.generated !== false)
91
+ .flatMap(([resourceCode]) => [
92
+ <Route key={`${resourceCode}-list`} path={`/${resourceCode}`} element={<GeneratedResourcePage resourceCode={resourceCode} />} />,
93
+ <Route key={`${resourceCode}-new`} path={`/${resourceCode}/new`} element={<GeneratedResourcePage mode="create" resourceCode={resourceCode} />} />,
94
+ <Route key={`${resourceCode}-detail`} path={`/${resourceCode}/:id`} element={<GeneratedResourcePage mode="detail" resourceCode={resourceCode} />} />,
95
+ <Route key={`${resourceCode}-edit`} path={`/${resourceCode}/:id/edit`} element={<GeneratedResourcePage mode="edit" resourceCode={resourceCode} />} />,
96
+ <Route key={`${resourceCode}-mobile-list`} path={`/m/${resourceCode}`} element={<GeneratedResourcePage resourceCode={resourceCode} variant="mobile" />} />,
97
+ <Route key={`${resourceCode}-mobile-new`} path={`/m/${resourceCode}/new`} element={<GeneratedResourcePage mode="create" resourceCode={resourceCode} variant="mobile" />} />,
98
+ <Route key={`${resourceCode}-mobile-detail`} path={`/m/${resourceCode}/:id`} element={<GeneratedResourcePage mode="detail" resourceCode={resourceCode} variant="mobile" />} />,
99
+ <Route key={`${resourceCode}-mobile-edit`} path={`/m/${resourceCode}/:id/edit`} element={<GeneratedResourcePage mode="edit" resourceCode={resourceCode} variant="mobile" />} />,
100
+ ])}
71
101
  </Routes>
72
102
  </Refine>
73
103
  </BrowserRouter>
@@ -11,9 +11,10 @@ import {
11
11
  type DirectoryResolveRequest,
12
12
  type NativeScopeValuePage,
13
13
  type NativeScopeValueResolveRequest,
14
+ type DataResourceSurface,
14
15
  } from 'openxiangda-contracts/browser';
15
16
  import type { InstrumentListQuery, InstrumentRecord } from './instrument';
16
- import { isInstrumentResourceField } from './fields';
17
+ import { instrumentSurface, isInstrumentResourceField } from './fields';
17
18
  import { applicationCode, runtimeMount } from './runtime-meta';
18
19
 
19
20
  const resourceCode = 'instruments';
@@ -273,7 +274,11 @@ export const instrumentDataApi: InstrumentDataApiAdapter = {
273
274
  // The current DataQuery contract has AND filters but no grouped OR. Keep
274
275
  // this honest single-field search until the platform adds a native OR AST.
275
276
  if (query.keyword?.trim()) {
276
- filters.push({ field: 'chineseName', operator: 'ilike', value: `%${query.keyword.trim()}%` });
277
+ const searchableFields = instrumentSurface.list?.searchableFields || [];
278
+ const field = searchableFields.includes(query.searchField || '')
279
+ ? query.searchField!
280
+ : searchableFields[0] || 'chineseName';
281
+ filters.push({ field, operator: 'ilike', value: `%${query.keyword.trim()}%` });
277
282
  }
278
283
  if (query.collegeId) filters.push({ field: 'collegeId', operator: 'eq', value: query.collegeId });
279
284
  if (query.instrumentAdminId) filters.push({ field: 'instrumentAdminIds', operator: 'cs', value: [query.instrumentAdminId] });
@@ -378,6 +383,94 @@ export const instrumentDataApi: InstrumentDataApiAdapter = {
378
383
  },
379
384
  };
380
385
 
386
+ export interface GenericResourceQuery {
387
+ page: number;
388
+ pageSize: number;
389
+ keyword?: string;
390
+ searchField?: string;
391
+ filters?: Record<string, unknown>;
392
+ sort?: { field: string; order: 'asc' | 'desc' };
393
+ }
394
+
395
+ /** Native CRUD adapter used by resources that opt into generated pages. */
396
+ export function createNativeResourceClient(
397
+ code: string,
398
+ surface: DataResourceSurface
399
+ ) {
400
+ const base = dataBase(code);
401
+ const declaredFields = new Set(Object.keys(surface.fields));
402
+ const assertField = (field: string) => {
403
+ if (!declaredFields.has(field)) {
404
+ throw new Error(`OPENXIANGDA_RESOURCE_QUERY_FIELD_NOT_DECLARED:${code}:${field}`);
405
+ }
406
+ return field;
407
+ };
408
+ const filtersFor = (query: GenericResourceQuery) => {
409
+ const filters: NonNullable<DataQuery['filters']> = [];
410
+ if (query.keyword?.trim()) {
411
+ const searchable = surface.list?.searchableFields || [];
412
+ const field = searchable.includes(query.searchField || '') ? query.searchField! : searchable[0];
413
+ if (field) filters.push({ field: assertField(field), operator: 'ilike', value: `%${query.keyword.trim()}%` });
414
+ }
415
+ for (const [fieldCode, value] of Object.entries(query.filters || {})) {
416
+ if (value === undefined || value === null || value === '') continue;
417
+ const field = surface.fields[assertField(fieldCode)];
418
+ if (Array.isArray(value) && value.length === 2 && ['date', 'datetime', 'number', 'money', 'percent'].includes(field?.widget || '')) {
419
+ if (value[0] !== undefined && value[0] !== null && value[0] !== '') filters.push({ field: fieldCode, operator: 'gte', value: value[0] });
420
+ if (value[1] !== undefined && value[1] !== null && value[1] !== '') filters.push({ field: fieldCode, operator: 'lte', value: value[1] });
421
+ } else if (Array.isArray(value) && ['multi', 'directory-user', 'directory-department'].includes(field?.widget || '')) {
422
+ filters.push({ field: fieldCode, operator: 'cs', value });
423
+ } else {
424
+ filters.push({ field: fieldCode, operator: 'eq', value });
425
+ }
426
+ }
427
+ return filters;
428
+ };
429
+ return {
430
+ async list(query: GenericResourceQuery) {
431
+ const sort = query.sort || surface.list?.defaultSort || { field: Object.keys(surface.fields)[0] || 'id', order: 'asc' as const };
432
+ const body: DataQuery = {
433
+ schemaVersion: SCHEMA_VERSIONS.dataQuery,
434
+ filters: filtersFor(query),
435
+ order: [{ field: assertField(sort.field), direction: sort.order || 'asc' }],
436
+ limit: query.pageSize,
437
+ offset: (query.page - 1) * query.pageSize,
438
+ };
439
+ const page = await request<DataPage<Record<string, unknown>>>(`${base}/query`, {
440
+ method: 'POST',
441
+ body: JSON.stringify({ ...body, environmentKey: currentEnvironmentKey() }),
442
+ });
443
+ return { rows: page.items, total: page.total };
444
+ },
445
+ async get(id: string) {
446
+ const record = await request<DataRecord<Record<string, unknown>>>(`${base}/records/${encodeURIComponent(id)}?environmentKey=${encodeURIComponent(currentEnvironmentKey())}`);
447
+ return record.data;
448
+ },
449
+ async audit(id: string) {
450
+ return await request<DataAuditPage>(`${base}/records/${encodeURIComponent(id)}/audit?limit=10&offset=0&environmentKey=${encodeURIComponent(currentEnvironmentKey())}`);
451
+ },
452
+ async create(data: Record<string, unknown>) {
453
+ const record = await request<DataRecord<Record<string, unknown>>>(`${base}/records`, { method: 'POST', body: JSON.stringify({ environmentKey: currentEnvironmentKey(), data }) });
454
+ return record.data;
455
+ },
456
+ async update(id: string, expectedRevision: number, data: Record<string, unknown>) {
457
+ const record = await request<DataRecord<Record<string, unknown>>>(`${base}/records/${encodeURIComponent(id)}/update`, { method: 'POST', body: JSON.stringify({ environmentKey: currentEnvironmentKey(), expectedRevision, data }) });
458
+ return record.data;
459
+ },
460
+ async remove(id: string, expectedRevision: number) {
461
+ const record = await request<DataRecord<Record<string, unknown>>>(`${base}/records/${encodeURIComponent(id)}/delete`, { method: 'POST', body: JSON.stringify({ environmentKey: currentEnvironmentKey(), expectedRevision }) });
462
+ return record.data;
463
+ },
464
+ async upload(fieldCode: string, file: File, recordId?: string) {
465
+ assertField(fieldCode);
466
+ const plan = await request<DataFileUploadPlan>(`${base}/files/uploads/initiate`, { method: 'POST', body: JSON.stringify({ environmentKey: currentEnvironmentKey(), fieldCode, fileName: file.name, fileSize: file.size, contentType: file.type || 'application/octet-stream', ...(recordId ? { recordId } : {}) }) });
467
+ const uploaded = await fetch(plan.uploadUrl, { method: plan.uploadMethod, headers: plan.headers, body: file });
468
+ if (!uploaded.ok) throw new Error(`FILE_UPLOAD_FAILED: HTTP_${uploaded.status}`);
469
+ return await request<DataFileRef>(`${base}/files/${encodeURIComponent(plan.file.id)}/complete`, { method: 'POST', body: JSON.stringify({ environmentKey: currentEnvironmentKey() }) });
470
+ },
471
+ };
472
+ }
473
+
381
474
  export function dataFileContentUrl(
382
475
  resource: string,
383
476
  fileId: string,
@@ -218,6 +218,103 @@ body {
218
218
  font-size: 12px;
219
219
  }
220
220
 
221
+ .oxa-mobile-page {
222
+ min-height: 100vh;
223
+ box-sizing: border-box;
224
+ background: #f3f7fd;
225
+ padding: 16px;
226
+ }
227
+
228
+ .oxa-mobile-header {
229
+ display: grid;
230
+ gap: 6px;
231
+ margin-bottom: 14px;
232
+ }
233
+
234
+ .oxa-mobile-header .ant-typography {
235
+ margin: 0;
236
+ }
237
+
238
+ .oxa-mobile-form {
239
+ margin-top: 14px;
240
+ }
241
+
242
+ .oxa-mobile-field .ant-form-item-control-input-content {
243
+ min-height: 44px;
244
+ }
245
+
246
+ .oxa-mobile-input {
247
+ width: 100%;
248
+ min-height: 42px;
249
+ box-sizing: border-box;
250
+ border: 1px solid #d9e2f0;
251
+ border-radius: 8px;
252
+ background: #fff;
253
+ padding: 9px 11px;
254
+ color: #172033;
255
+ font: inherit;
256
+ }
257
+
258
+ .oxa-mobile-input:focus {
259
+ border-color: #1677ff;
260
+ outline: 2px solid rgb(22 119 255 / 15%);
261
+ }
262
+
263
+ .oxa-mobile-filter,
264
+ .oxa-mobile-pagination,
265
+ .oxa-mobile-record-heading,
266
+ .oxa-mobile-record-fields {
267
+ display: grid;
268
+ gap: 10px;
269
+ }
270
+
271
+ .oxa-mobile-filter {
272
+ margin: 14px 0;
273
+ }
274
+
275
+ .oxa-mobile-record-list {
276
+ display: grid;
277
+ gap: 12px;
278
+ margin-top: 14px;
279
+ }
280
+
281
+ .oxa-mobile-record-card {
282
+ border-color: #dfe6ef;
283
+ }
284
+
285
+ .oxa-mobile-record-heading {
286
+ grid-template-columns: minmax(0, 1fr) auto;
287
+ align-items: center;
288
+ }
289
+
290
+ .oxa-mobile-record-heading a {
291
+ overflow: hidden;
292
+ color: #1677ff;
293
+ font-size: 16px;
294
+ font-weight: 650;
295
+ text-overflow: ellipsis;
296
+ white-space: nowrap;
297
+ }
298
+
299
+ .oxa-mobile-record-fields {
300
+ grid-template-columns: repeat(2, minmax(0, 1fr));
301
+ margin: 12px 0;
302
+ }
303
+
304
+ .oxa-mobile-record-fields > div {
305
+ min-width: 0;
306
+ }
307
+
308
+ .oxa-mobile-pagination {
309
+ grid-template-columns: 1fr auto 1fr;
310
+ align-items: center;
311
+ margin-top: 14px;
312
+ }
313
+
314
+ .oxa-mobile-pagination .ant-btn:last-child {
315
+ justify-self: end;
316
+ }
317
+
221
318
  .oxa-current-user-chevron {
222
319
  color: #65748a;
223
320
  font-size: 12px;
@@ -5,7 +5,7 @@ import {
5
5
  instrumentFieldAllowed,
6
6
  sanitizeInstrumentValues,
7
7
  } from '../src/InstrumentForm';
8
- import { instrumentFields } from '../src/fields';
8
+ import { instrumentFields, instrumentSurface } from '../src/fields';
9
9
  import {
10
10
  browseDepartmentTree,
11
11
  browseDepartmentUsers,
@@ -49,6 +49,43 @@ test('the golden module exposes exactly 30 fields and five restricted fields', (
49
49
  );
50
50
  });
51
51
 
52
+ test('the instrument list, search, and filters are declared by the resource surface', () => {
53
+ assert.deepEqual(
54
+ Object.entries(instrumentSurface.fields)
55
+ .filter(([, field]) => field.list)
56
+ .map(([key]) => key)
57
+ .sort(),
58
+ [
59
+ 'chineseName',
60
+ 'collegeId',
61
+ 'image',
62
+ 'instrumentAdminIds',
63
+ 'instrumentCode',
64
+ 'instrumentStatus',
65
+ 'openToOutside',
66
+ 'specModel',
67
+ 'assetCode',
68
+ ].sort()
69
+ );
70
+ assert.deepEqual(instrumentSurface.list?.searchableFields, [
71
+ 'chineseName',
72
+ 'instrumentCode',
73
+ 'assetCode',
74
+ ]);
75
+ assert.ok(instrumentSurface.list?.filterFields?.includes('assetValue'));
76
+ const list = readFileSync(
77
+ new URL('../src/InstrumentListPage.tsx', import.meta.url),
78
+ 'utf8'
79
+ );
80
+ const client = readFileSync(
81
+ new URL('../src/platform-client.ts', import.meta.url),
82
+ 'utf8'
83
+ );
84
+ assert.match(list, /listSurfaceFields/);
85
+ assert.match(list, /searchableFields/);
86
+ assert.match(client, /query\.searchField/);
87
+ });
88
+
52
89
  test('application shell and instrument surfaces match the approved navigation model', () => {
53
90
  const shell = readFileSync(new URL('../src/Shell.tsx', import.meta.url), 'utf8');
54
91
  const list = readFileSync(
@@ -60,6 +97,10 @@ test('application shell and instrument surfaces match the approved navigation mo
60
97
  new URL('../src/InstrumentForm.tsx', import.meta.url),
61
98
  'utf8'
62
99
  );
100
+ const formPage = readFileSync(
101
+ new URL('../src/InstrumentFormPage.tsx', import.meta.url),
102
+ 'utf8'
103
+ );
63
104
  const colleges = readFileSync(
64
105
  new URL('../src/CollegePage.tsx', import.meta.url),
65
106
  'utf8'
@@ -72,10 +113,22 @@ test('application shell and instrument surfaces match the approved navigation mo
72
113
  new URL('../src/components/platform-fields/AttachmentFileList.tsx', import.meta.url),
73
114
  'utf8'
74
115
  );
116
+ const surfaceFields = readFileSync(
117
+ new URL('../src/components/resource/SurfaceFields.tsx', import.meta.url),
118
+ 'utf8'
119
+ );
75
120
  const preview = readFileSync(
76
121
  new URL('../src/FilePreviewPage.tsx', import.meta.url),
77
122
  'utf8'
78
123
  );
124
+ const standardPages = readFileSync(
125
+ new URL('../src/components/resource/StandardResourcePages.tsx', import.meta.url),
126
+ 'utf8'
127
+ );
128
+ const generatedCrud = readFileSync(
129
+ new URL('../src/components/resource/GeneratedResourceCrud.tsx', import.meta.url),
130
+ 'utf8'
131
+ );
79
132
  for (const marker of [
80
133
  '仪器资源管理',
81
134
  '工作台',
@@ -94,15 +147,35 @@ test('application shell and instrument surfaces match the approved navigation mo
94
147
  assert.doesNotMatch(shell, /验证本地 Nest|roleCodes\.join|environment\.key/);
95
148
  assert.doesNotMatch(list, /<Drawer|surface="drawer"/);
96
149
  assert.match(routes, /path="\/instruments\/new"[\s\S]*<InstrumentFormPage mode="create"/);
150
+ assert.match(routes, /path="\/m\/instruments\/new"[\s\S]*variant="mobile"/);
151
+ assert.match(routes, /path="\/m\/instruments"[\s\S]*variant="mobile"/);
152
+ assert.match(routes, /path="\/m\/instruments\/:id"[\s\S]*variant="mobile"/);
153
+ assert.match(routes, /path="\/m\/instruments\/:id\/edit"[\s\S]*variant="mobile"/);
97
154
  assert.match(colleges, /<Drawer/);
98
155
  assert.match(colleges, /size=\{520\}/);
99
156
  assert.match(directory, /width=\{kind === 'user' \? 980 : 760\}/);
100
157
  assert.match(directory, /browseDepartmentTree|browseDepartmentUsers/);
101
158
  assert.match(directory, /选择成员|选择部门|组织架构|已选/);
102
159
  assert.match(form, /oxa-form-full/);
103
- assert.match(form, /PlatformDirectoryPicker/);
104
- assert.match(form, /oxa-file-dropzone/);
160
+ assert.match(form, /SurfaceFieldControl/);
161
+ assert.match(surfaceFields, /PlatformDirectoryPicker/);
162
+ assert.match(surfaceFields, /oxa-file-dropzone/);
105
163
  assert.match(form, /oxa-detail-layout/);
164
+ assert.match(list, /<ResourceListPage/);
165
+ assert.match(formPage, /ResourceFormPage/);
166
+ assert.match(formPage, /MobileResourceFormPage/);
167
+ assert.match(list, /MobileResourceListPage/);
168
+ assert.match(surfaceFields, /SurfaceFilterControl/);
169
+ assert.match(form, /MobileSurfaceFieldControl/);
170
+ assert.match(
171
+ readFileSync(new URL('../src/InstrumentDetailPage.tsx', import.meta.url), 'utf8'),
172
+ /<ResourceDetailPage/
173
+ );
174
+ assert.match(standardPages, /批量事务合同落地后启用/);
175
+ assert.match(standardPages, /DataResourceSurface/);
176
+ assert.match(generatedCrud, /resourceDefinitions/);
177
+ assert.match(generatedCrud, /createNativeResourceClient/);
178
+ assert.match(generatedCrud, /MobileResourceDetailPage/);
106
179
  assert.match(routes, /path="\/files\/:resourceCode\/:fileId\/preview"/);
107
180
  assert.match(attachment, /Image\.PreviewGroup/);
108
181
  assert.match(attachment, /attachmentPreviewPath/);
@@ -125,7 +198,10 @@ test('college, user, and department selectors have authoritative owners only', (
125
198
  new URL('../../../platform/data/colleges.ts', import.meta.url),
126
199
  'utf8'
127
200
  );
128
- const fields = readFileSync(new URL('../src/fields.ts', import.meta.url), 'utf8');
201
+ const fields = readFileSync(
202
+ new URL('../../../platform/data/instrument-surface.ts', import.meta.url),
203
+ 'utf8'
204
+ );
129
205
  assert.match(appConfig, /valueType:\s*'uuid'/);
130
206
  assert.match(appConfig, /kind:\s*'native_resource'/);
131
207
  assert.match(appConfig, /resourceCode:\s*'colleges'/);
@@ -21,9 +21,9 @@
21
21
  "build": "pnpm --recursive build"
22
22
  },
23
23
  "devDependencies": {
24
- "openxiangda-cli": "2.0.0-alpha.59",
25
- "openxiangda-contracts": "2.0.0-alpha.28",
26
- "openxiangda-devkit-core": "2.0.0-alpha.35",
24
+ "openxiangda-cli": "2.0.0-alpha.61",
25
+ "openxiangda-contracts": "2.0.0-alpha.29",
26
+ "openxiangda-devkit-core": "2.0.0-alpha.37",
27
27
  "typescript": "5.9.3"
28
28
  }
29
29
  }