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

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 (39) hide show
  1. package/dist/commands/create.d.ts.map +1 -1
  2. package/dist/commands/create.js +13 -5
  3. package/dist/commands/create.js.map +1 -1
  4. package/dist/create-workspace.d.ts +6 -1
  5. package/dist/create-workspace.d.ts.map +1 -1
  6. package/dist/create-workspace.js +12 -9
  7. package/dist/create-workspace.js.map +1 -1
  8. package/package.json +4 -4
  9. package/template/README.md +3 -1
  10. package/template/apps/server/package.json +1 -1
  11. package/template/apps/web/e2e/instruments.spec.ts +1197 -9
  12. package/template/apps/web/package.json +4 -2
  13. package/template/apps/web/scripts/check.mjs +2 -2
  14. package/template/apps/web/scripts/verify-build.mjs +14 -2
  15. package/template/apps/web/src/AuthoritativeSelector.tsx +371 -0
  16. package/template/apps/web/src/CollegePage.tsx +181 -0
  17. package/template/apps/web/src/FilePreviewPage.tsx +303 -0
  18. package/template/apps/web/src/InstrumentDetailPage.tsx +94 -20
  19. package/template/apps/web/src/InstrumentForm.tsx +347 -94
  20. package/template/apps/web/src/InstrumentFormPage.tsx +49 -9
  21. package/template/apps/web/src/InstrumentListPage.tsx +30 -35
  22. package/template/apps/web/src/Shell.tsx +229 -41
  23. package/template/apps/web/src/components/platform-fields/AttachmentFileList.tsx +226 -0
  24. package/template/apps/web/src/components/platform-fields/PlatformDirectoryPicker.tsx +698 -0
  25. package/template/apps/web/src/data-provider.ts +1 -1
  26. package/template/apps/web/src/fields.ts +17 -6
  27. package/template/apps/web/src/instrument.ts +1 -1
  28. package/template/apps/web/src/main.tsx +21 -1
  29. package/template/apps/web/src/platform-client.ts +248 -2
  30. package/template/apps/web/src/runtime-meta.ts +15 -0
  31. package/template/apps/web/src/runtime.tsx +4 -2
  32. package/template/apps/web/src/styles.css +1007 -28
  33. package/template/apps/web/test/contracts.test.ts +473 -10
  34. package/template/openxiangda.config.ts +24 -5
  35. package/template/package.json +3 -3
  36. package/template/packages/contracts/src/generated.ts +22 -0
  37. package/template/platform/data/colleges.ts +21 -0
  38. package/template/platform/data/instruments.ts +1 -1
  39. package/template/scripts/verify-template-budget.mjs +2 -2
@@ -8,7 +8,10 @@ export type FieldKind =
8
8
  | 'multi'
9
9
  | 'email'
10
10
  | 'phone'
11
- | 'file';
11
+ | 'file'
12
+ | 'scope'
13
+ | 'directory-user'
14
+ | 'directory-department';
12
15
 
13
16
  export interface InstrumentField {
14
17
  key: string;
@@ -58,11 +61,11 @@ export const instrumentFields: InstrumentField[] = [
58
61
  { key: 'openToOutside', label: '是否对外开放', kind: 'boolean', section: 'usage', required: true },
59
62
  { key: 'subjectAreas', label: '学科领域', kind: 'multi', section: 'usage', options: options([['材料', 'material'], ['生命科学', 'life'], ['化学', 'chemistry'], ['物理', 'physics']]) },
60
63
  { key: 'platformProperty', label: '平台属性', kind: 'select', section: 'usage', options: options([['共享平台设备', 'shared'], ['课题组设备', 'research']]) },
61
- { key: 'collegeId', label: '所属学院', kind: 'select', section: 'usage', required: true, createCapability: 'app:instrument-center:instrument:field:college-id:create', updateCapability: 'app:instrument-center:instrument:field:college-id:update', options: options([['先进材料学院', 'college-am'], ['生命健康学院', 'college-lh'], ['公共测试中心', 'college-pt']]) },
62
- { key: 'manageDepartmentId', label: '管理部门', kind: 'select', section: 'management', required: true, options: options([['材料测试中心', 'dept-am-test'], ['生命平台中心', 'dept-lh-core'], ['公共测试中心', 'dept-public']]) },
63
- { key: 'useDepartmentId', label: '使用部门', kind: 'select', section: 'management', required: true, options: options([['材料测试中心', 'dept-am-test'], ['生命平台中心', 'dept-lh-core'], ['公共测试中心', 'dept-public']]) },
64
- { key: 'instrumentAdminIds', label: '仪器管理员', kind: 'multi', section: 'management', required: true, createCapability: 'app:instrument-center:instrument:field:instrument-admin-ids:create', updateCapability: 'app:instrument-center:instrument:field:instrument-admin-ids:update', options: options([['王老师', 'user-wang'], ['李老师', 'user-li'], ['陈老师', 'user-chen']]) },
65
- { key: 'contactUserIds', label: '联系人', kind: 'multi', section: 'management', required: true, options: options([['王老师', 'user-wang'], ['李老师', 'user-li'], ['陈老师', 'user-chen']]) },
64
+ { key: 'collegeId', label: '所属学院', kind: 'scope', section: 'usage', required: true, createCapability: 'app:instrument-center:instrument:field:college-id:create', updateCapability: 'app:instrument-center:instrument:field:college-id:update' },
65
+ { key: 'manageDepartmentId', label: '管理部门', kind: 'directory-department', section: 'management', required: true },
66
+ { key: 'useDepartmentId', label: '使用部门', kind: 'directory-department', section: 'management', required: true },
67
+ { key: 'instrumentAdminIds', label: '仪器管理员', kind: 'directory-user', section: 'management', required: true, multiple: true, createCapability: 'app:instrument-center:instrument:field:instrument-admin-ids:create', updateCapability: 'app:instrument-center:instrument:field:instrument-admin-ids:update' },
68
+ { key: 'contactUserIds', label: '联系人', kind: 'directory-user', section: 'management', required: true, multiple: true },
66
69
  { key: 'contactPhone', label: '联系电话', kind: 'phone', section: 'management', required: true },
67
70
  { key: 'contactEmail', label: '联系邮箱', kind: 'email', section: 'management' },
68
71
  { key: 'locationId', label: '存放点', kind: 'select', section: 'materials', options: options([['A 区 101', 'A-101'], ['B 区 203', 'B-203']]) },
@@ -70,3 +73,11 @@ export const instrumentFields: InstrumentField[] = [
70
73
  { key: 'serviceContent', label: '服务内容与主要功能', kind: 'textarea', section: 'materials' },
71
74
  { key: 'attachments', label: '仪器资料', kind: 'file', section: 'materials', multiple: true, maxCount: 8 },
72
75
  ];
76
+
77
+ const instrumentResourceFieldCodes = new Set(
78
+ instrumentFields.filter(field => !field.system).map(field => field.key)
79
+ );
80
+
81
+ export function isInstrumentResourceField(field: string) {
82
+ return instrumentResourceFieldCodes.has(field);
83
+ }
@@ -31,7 +31,7 @@ export interface InstrumentRecord extends Record<string, unknown> {
31
31
  address?: string;
32
32
  serviceContent?: string;
33
33
  attachments?: DataFileRef[];
34
- updatedAt?: string;
34
+ updated_at?: string;
35
35
  }
36
36
 
37
37
  export interface InstrumentListQuery {
@@ -5,6 +5,8 @@ import React from 'react';
5
5
  import ReactDOM from 'react-dom/client';
6
6
  import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
7
7
  import { instrumentProvider } from './data-provider';
8
+ import { CollegePage } from './CollegePage';
9
+ import { FilePreviewPage } from './FilePreviewPage';
8
10
  import { InstrumentDetailPage } from './InstrumentDetailPage';
9
11
  import { InstrumentFormPage } from './InstrumentFormPage';
10
12
  import { InstrumentListPage } from './InstrumentListPage';
@@ -16,7 +18,20 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
16
18
  <React.StrictMode>
17
19
  <ConfigProvider
18
20
  locale={zhCN}
19
- theme={{ token: { borderRadius: 6, colorPrimary: '#1677ff' } }}
21
+ theme={{
22
+ token: {
23
+ borderRadius: 8,
24
+ colorBgLayout: '#f3f7fd',
25
+ colorPrimary: '#1677ff',
26
+ colorText: '#172033',
27
+ fontSize: 14,
28
+ },
29
+ components: {
30
+ Card: { headerFontSize: 16 },
31
+ Layout: { bodyBg: '#f3f7fd', headerBg: '#ffffff', siderBg: '#ffffff' },
32
+ Menu: { itemBorderRadius: 8, itemSelectedBg: '#eaf3ff' },
33
+ },
34
+ }}
20
35
  >
21
36
  <AntdApp>
22
37
  <RuntimeBoundary>
@@ -36,6 +51,11 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
36
51
  <Routes>
37
52
  <Route path="/" element={<Navigate replace to="/instruments" />} />
38
53
  <Route path="/instruments" element={<InstrumentListPage />} />
54
+ <Route path="/colleges" element={<CollegePage />} />
55
+ <Route
56
+ path="/files/:resourceCode/:fileId/preview"
57
+ element={<FilePreviewPage />}
58
+ />
39
59
  <Route
40
60
  path="/instruments/new"
41
61
  element={<InstrumentFormPage mode="create" />}
@@ -1,18 +1,25 @@
1
1
  import {
2
2
  SCHEMA_VERSIONS,
3
+ type DataFilePreview,
3
4
  type DataFileRef,
4
5
  type DataFileUploadPlan,
6
+ type DataAuditPage,
5
7
  type DataPage,
6
8
  type DataQuery,
7
9
  type DataRecord,
10
+ type DirectoryEntryPage,
11
+ type DirectoryResolveRequest,
12
+ type NativeScopeValuePage,
13
+ type NativeScopeValueResolveRequest,
8
14
  } from 'openxiangda-contracts/browser';
9
15
  import type { InstrumentListQuery, InstrumentRecord } from './instrument';
16
+ import { isInstrumentResourceField } from './fields';
10
17
  import { applicationCode, runtimeMount } from './runtime-meta';
11
18
 
12
19
  const resourceCode = 'instruments';
13
20
  const nativeBase = () =>
14
21
  `/service/openxiangda-api/v2/applications/${applicationCode()}/native`;
15
- const dataBase = () => `${nativeBase()}/data/${resourceCode}`;
22
+ const dataBase = (code = resourceCode) => `${nativeBase()}/data/${code}`;
16
23
 
17
24
  interface PlatformEnvelope<T> {
18
25
  code: number;
@@ -135,9 +142,125 @@ function currentEnvironmentKey() {
135
142
  return key;
136
143
  }
137
144
 
145
+ export type DirectoryKind = 'user' | 'department';
146
+
147
+ export async function searchDirectory(
148
+ kind: DirectoryKind,
149
+ options: { keyword: string; cursor?: string }
150
+ ) {
151
+ const params = new URLSearchParams({
152
+ environmentKey: currentEnvironmentKey(),
153
+ keyword: options.keyword.trim(),
154
+ limit: '20',
155
+ });
156
+ if (options.cursor) params.set('cursor', options.cursor);
157
+ return request<DirectoryEntryPage>(
158
+ `${nativeBase().replace(/\/native$/, '')}/directory/${kind}s?${params}`
159
+ );
160
+ }
161
+
162
+ export async function browseDepartmentTree(options: {
163
+ parentId?: string;
164
+ cursor?: string;
165
+ }) {
166
+ const params = new URLSearchParams({
167
+ environmentKey: currentEnvironmentKey(),
168
+ limit: '100',
169
+ });
170
+ if (options.parentId) params.set('parentId', options.parentId);
171
+ if (options.cursor) params.set('offset', options.cursor);
172
+ return request<DirectoryEntryPage>(
173
+ `${nativeBase().replace(/\/native$/, '')}/directory/departments/tree?${params}`
174
+ );
175
+ }
176
+
177
+ export async function browseDepartmentUsers(
178
+ departmentId: string,
179
+ page = 1
180
+ ) {
181
+ const params = new URLSearchParams({
182
+ environmentKey: currentEnvironmentKey(),
183
+ page: String(page),
184
+ limit: '20',
185
+ });
186
+ return request<DirectoryEntryPage>(
187
+ `${nativeBase().replace(/\/native$/, '')}/directory/departments/${encodeURIComponent(
188
+ departmentId
189
+ )}/users?${params}`
190
+ );
191
+ }
192
+
193
+ export async function resolveDirectory(
194
+ kind: DirectoryKind,
195
+ ids: string[]
196
+ ) {
197
+ const uniqueIds = [...new Set(ids)];
198
+ const body: DirectoryResolveRequest = {
199
+ schemaVersion: SCHEMA_VERSIONS.directoryResolveRequest,
200
+ kind,
201
+ ids: uniqueIds,
202
+ };
203
+ if (body.ids.length === 0) {
204
+ throw new Error('OPENXIANGDA_DIRECTORY_RESOLVE_IDS_REQUIRED');
205
+ }
206
+ if (body.ids.length > 50) {
207
+ throw new Error('OPENXIANGDA_DIRECTORY_RESOLVE_IDS_EXCEEDED');
208
+ }
209
+ return request<DirectoryEntryPage>(
210
+ `${nativeBase().replace(/\/native$/, '')}/directory/resolve?environmentKey=${encodeURIComponent(
211
+ currentEnvironmentKey()
212
+ )}`,
213
+ { method: 'POST', body: JSON.stringify(body) }
214
+ );
215
+ }
216
+
217
+ const scopeValuesBase = () =>
218
+ `${nativeBase()}/data-resources/${resourceCode}/fields/collegeId/scope-values`;
219
+
220
+ export async function searchCollegeScope(
221
+ operation: 'create' | 'update',
222
+ options: { keyword: string; cursor?: string }
223
+ ) {
224
+ const params = new URLSearchParams({
225
+ environmentKey: currentEnvironmentKey(),
226
+ dimensionCode: 'college',
227
+ operation,
228
+ keyword: options.keyword.trim(),
229
+ limit: '20',
230
+ });
231
+ if (options.cursor) params.set('cursor', options.cursor);
232
+ return request<NativeScopeValuePage>(`${scopeValuesBase()}?${params}`);
233
+ }
234
+
235
+ export async function resolveCollegeScope(
236
+ operation: 'create' | 'update',
237
+ values: string[]
238
+ ) {
239
+ const uniqueValues = [...new Set(values)];
240
+ const body: NativeScopeValueResolveRequest = {
241
+ schemaVersion: SCHEMA_VERSIONS.nativeScopeValueResolveRequest,
242
+ dimensionCode: 'college',
243
+ operation,
244
+ values: uniqueValues,
245
+ };
246
+ if (body.values.length === 0) {
247
+ throw new Error('OPENXIANGDA_SCOPE_RESOLVE_VALUES_REQUIRED');
248
+ }
249
+ if (body.values.length > 50) {
250
+ throw new Error('OPENXIANGDA_SCOPE_RESOLVE_VALUES_EXCEEDED');
251
+ }
252
+ return request<NativeScopeValuePage>(
253
+ `${scopeValuesBase()}/resolve?environmentKey=${encodeURIComponent(
254
+ currentEnvironmentKey()
255
+ )}`,
256
+ { method: 'POST', body: JSON.stringify(body) }
257
+ );
258
+ }
259
+
138
260
  export interface InstrumentDataApiAdapter {
139
261
  list(query: InstrumentListQuery): Promise<{ rows: InstrumentRecord[]; total: number }>;
140
262
  get(id: string): Promise<InstrumentRecord>;
263
+ audit(id: string): Promise<DataAuditPage>;
141
264
  create(data: Partial<InstrumentRecord>): Promise<InstrumentRecord>;
142
265
  update(id: string, expectedRevision: number, data: Partial<InstrumentRecord>): Promise<InstrumentRecord>;
143
266
  remove(id: string, expectedRevision: number): Promise<InstrumentRecord>;
@@ -165,10 +288,11 @@ export const instrumentDataApi: InstrumentDataApiAdapter = {
165
288
  const body: DataQuery = {
166
289
  schemaVersion: SCHEMA_VERSIONS.dataQuery,
167
290
  filters,
168
- order: [{ field: String(query.sort?.field || 'updatedAt'), direction: query.sort?.order || 'desc' }],
291
+ order: [{ field: String(query.sort?.field || 'instrumentCode'), direction: query.sort?.order || 'asc' }],
169
292
  limit: query.pageSize,
170
293
  offset: (query.page - 1) * query.pageSize,
171
294
  };
295
+ assertInstrumentQueryFields(body);
172
296
  const page = await request<DataPage<InstrumentRecord>>(`${dataBase()}/query`, {
173
297
  method: 'POST',
174
298
  body: JSON.stringify({ ...body, environmentKey: currentEnvironmentKey() }),
@@ -183,6 +307,15 @@ export const instrumentDataApi: InstrumentDataApiAdapter = {
183
307
  );
184
308
  return record.data;
185
309
  },
310
+ async audit(id) {
311
+ return await request<DataAuditPage>(
312
+ `${dataBase()}/records/${encodeURIComponent(
313
+ id
314
+ )}/audit?limit=10&offset=0&environmentKey=${encodeURIComponent(
315
+ currentEnvironmentKey()
316
+ )}`
317
+ );
318
+ },
186
319
  async create(data) {
187
320
  const record = await request<DataRecord<InstrumentRecord>>(`${dataBase()}/records`, {
188
321
  method: 'POST',
@@ -244,3 +377,116 @@ export const instrumentDataApi: InstrumentDataApiAdapter = {
244
377
  );
245
378
  },
246
379
  };
380
+
381
+ export function dataFileContentUrl(
382
+ resource: string,
383
+ fileId: string,
384
+ disposition: 'attachment' | 'inline' = 'attachment'
385
+ ) {
386
+ const query = new URLSearchParams({
387
+ environmentKey: currentEnvironmentKey(),
388
+ disposition,
389
+ });
390
+ return `${dataBase(resource)}/files/${encodeURIComponent(
391
+ fileId
392
+ )}/content?${query.toString()}`;
393
+ }
394
+
395
+ export async function loadDataFilePreview(
396
+ resource: string,
397
+ fileId: string
398
+ ) {
399
+ return await request<DataFilePreview>(
400
+ `${dataBase(resource)}/files/${encodeURIComponent(
401
+ fileId
402
+ )}/preview?environmentKey=${encodeURIComponent(currentEnvironmentKey())}`
403
+ );
404
+ }
405
+
406
+ export async function fetchDataFileBlob(resource: string, fileId: string) {
407
+ const response = await fetch(dataFileContentUrl(resource, fileId, 'inline'), {
408
+ credentials: 'include',
409
+ headers: { accept: 'application/octet-stream,*/*' },
410
+ });
411
+ if (!response.ok) {
412
+ const payload = (await response.json().catch(() => null)) as
413
+ | PlatformEnvelope<null>
414
+ | null;
415
+ const code = payload?.errorCode || `HTTP_${response.status}`;
416
+ throw new Error(`${code}: ${payload?.message || '文件内容读取失败'}`);
417
+ }
418
+ return await response.blob();
419
+ }
420
+
421
+ export interface CollegeRecord extends Record<string, unknown> {
422
+ id: string;
423
+ revision: number;
424
+ name: string;
425
+ enabled: boolean;
426
+ }
427
+
428
+ export const collegeDataApi = {
429
+ async list(page = 1, pageSize = 20) {
430
+ const body: DataQuery = {
431
+ schemaVersion: SCHEMA_VERSIONS.dataQuery,
432
+ order: [{ field: 'name', direction: 'asc' }],
433
+ limit: pageSize,
434
+ offset: (page - 1) * pageSize,
435
+ };
436
+ const result = await request<DataPage<CollegeRecord>>(
437
+ `${dataBase('colleges')}/query`,
438
+ {
439
+ method: 'POST',
440
+ body: JSON.stringify({
441
+ ...body,
442
+ environmentKey: currentEnvironmentKey(),
443
+ }),
444
+ }
445
+ );
446
+ return { rows: result.items, total: result.total };
447
+ },
448
+ async create(data: Pick<CollegeRecord, 'name' | 'enabled'>) {
449
+ const record = await request<DataRecord<CollegeRecord>>(
450
+ `${dataBase('colleges')}/records`,
451
+ {
452
+ method: 'POST',
453
+ body: JSON.stringify({
454
+ environmentKey: currentEnvironmentKey(),
455
+ data,
456
+ }),
457
+ }
458
+ );
459
+ return record.data;
460
+ },
461
+ async update(
462
+ id: string,
463
+ expectedRevision: number,
464
+ data: Pick<CollegeRecord, 'name' | 'enabled'>
465
+ ) {
466
+ const record = await request<DataRecord<CollegeRecord>>(
467
+ `${dataBase('colleges')}/records/${encodeURIComponent(id)}/update`,
468
+ {
469
+ method: 'POST',
470
+ body: JSON.stringify({
471
+ environmentKey: currentEnvironmentKey(),
472
+ expectedRevision,
473
+ data,
474
+ }),
475
+ }
476
+ );
477
+ return record.data;
478
+ },
479
+ };
480
+
481
+ function assertInstrumentQueryFields(query: DataQuery) {
482
+ for (const field of [
483
+ ...(query.filters || []).map(filter => filter.field),
484
+ ...(query.order || []).map(order => order.field),
485
+ ]) {
486
+ if (!isInstrumentResourceField(field)) {
487
+ throw new Error(
488
+ `OPENXIANGDA_INSTRUMENT_QUERY_FIELD_NOT_DECLARED:${field}`
489
+ );
490
+ }
491
+ }
492
+ }
@@ -45,6 +45,21 @@ export function applicationBasename() {
45
45
  return resolveApplicationBasename(runtimeMount());
46
46
  }
47
47
 
48
+ export function attachmentPreviewPath(resourceCode: string, fileId: string) {
49
+ return resolveAttachmentPreviewPath(runtimeMount(), resourceCode, fileId);
50
+ }
51
+
52
+ export function resolveAttachmentPreviewPath(
53
+ mount: RuntimeMount | null,
54
+ resourceCode: string,
55
+ fileId: string
56
+ ) {
57
+ const base = resolveApplicationBasename(mount)?.replace(/\/+$/, '') || '';
58
+ return `${base}/files/${encodeURIComponent(resourceCode)}/${encodeURIComponent(
59
+ fileId
60
+ )}/preview`;
61
+ }
62
+
48
63
  export function resolveApplicationApiPath(
49
64
  mount: RuntimeMount | null,
50
65
  path: string
@@ -47,7 +47,7 @@ export function RuntimeBoundary({ children }: { children: React.ReactNode }) {
47
47
  showIcon
48
48
  type="error"
49
49
  data-testid="production-data-warning"
50
- message="正在使用正式数据:所有新增、编辑和删除都会影响生产环境"
50
+ title="正在使用正式数据:所有新增、编辑和删除都会影响生产环境"
51
51
  />
52
52
  )}
53
53
  {error ? (
@@ -60,7 +60,9 @@ export function RuntimeBoundary({ children }: { children: React.ReactNode }) {
60
60
  ) : value ? (
61
61
  <RuntimeContext.Provider value={value}>{children}</RuntimeContext.Provider>
62
62
  ) : (
63
- <div className="oxa-loading"><Spin tip="正在读取平台身份与权限并集" /></div>
63
+ <div className="oxa-loading">
64
+ <Spin description="正在读取平台身份与权限并集" />
65
+ </div>
64
66
  )}
65
67
  </>
66
68
  );