openxiangda-cli 2.0.0-alpha.56 → 2.0.0-alpha.58

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 (36) 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/.dockerignore +11 -0
  10. package/template/README.md +3 -1
  11. package/template/apps/server/package.json +1 -1
  12. package/template/apps/web/e2e/instruments.spec.ts +912 -9
  13. package/template/apps/web/package.json +1 -1
  14. package/template/apps/web/scripts/check.mjs +2 -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/InstrumentDetailPage.tsx +94 -20
  18. package/template/apps/web/src/InstrumentForm.tsx +450 -88
  19. package/template/apps/web/src/InstrumentFormPage.tsx +49 -9
  20. package/template/apps/web/src/InstrumentListPage.tsx +30 -35
  21. package/template/apps/web/src/Shell.tsx +229 -41
  22. package/template/apps/web/src/components/platform-fields/PlatformDirectoryPicker.tsx +698 -0
  23. package/template/apps/web/src/data-provider.ts +1 -1
  24. package/template/apps/web/src/fields.ts +17 -6
  25. package/template/apps/web/src/instrument.ts +1 -1
  26. package/template/apps/web/src/main.tsx +16 -1
  27. package/template/apps/web/src/platform-client.ts +213 -2
  28. package/template/apps/web/src/runtime.tsx +4 -2
  29. package/template/apps/web/src/styles.css +869 -29
  30. package/template/apps/web/test/contracts.test.ts +369 -10
  31. package/template/openxiangda.config.ts +24 -5
  32. package/template/package.json +3 -3
  33. package/template/packages/contracts/src/generated.ts +22 -0
  34. package/template/platform/data/colleges.ts +21 -0
  35. package/template/platform/data/instruments.ts +1 -1
  36. package/template/scripts/verify-template-budget.mjs +1 -1
@@ -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,7 @@ 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';
8
9
  import { InstrumentDetailPage } from './InstrumentDetailPage';
9
10
  import { InstrumentFormPage } from './InstrumentFormPage';
10
11
  import { InstrumentListPage } from './InstrumentListPage';
@@ -16,7 +17,20 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
16
17
  <React.StrictMode>
17
18
  <ConfigProvider
18
19
  locale={zhCN}
19
- theme={{ token: { borderRadius: 6, colorPrimary: '#1677ff' } }}
20
+ theme={{
21
+ token: {
22
+ borderRadius: 8,
23
+ colorBgLayout: '#f3f7fd',
24
+ colorPrimary: '#1677ff',
25
+ colorText: '#172033',
26
+ fontSize: 14,
27
+ },
28
+ components: {
29
+ Card: { headerFontSize: 16 },
30
+ Layout: { bodyBg: '#f3f7fd', headerBg: '#ffffff', siderBg: '#ffffff' },
31
+ Menu: { itemBorderRadius: 8, itemSelectedBg: '#eaf3ff' },
32
+ },
33
+ }}
20
34
  >
21
35
  <AntdApp>
22
36
  <RuntimeBoundary>
@@ -36,6 +50,7 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
36
50
  <Routes>
37
51
  <Route path="/" element={<Navigate replace to="/instruments" />} />
38
52
  <Route path="/instruments" element={<InstrumentListPage />} />
53
+ <Route path="/colleges" element={<CollegePage />} />
39
54
  <Route
40
55
  path="/instruments/new"
41
56
  element={<InstrumentFormPage mode="create" />}
@@ -2,17 +2,23 @@ import {
2
2
  SCHEMA_VERSIONS,
3
3
  type DataFileRef,
4
4
  type DataFileUploadPlan,
5
+ type DataAuditPage,
5
6
  type DataPage,
6
7
  type DataQuery,
7
8
  type DataRecord,
9
+ type DirectoryEntryPage,
10
+ type DirectoryResolveRequest,
11
+ type NativeScopeValuePage,
12
+ type NativeScopeValueResolveRequest,
8
13
  } from 'openxiangda-contracts/browser';
9
14
  import type { InstrumentListQuery, InstrumentRecord } from './instrument';
15
+ import { isInstrumentResourceField } from './fields';
10
16
  import { applicationCode, runtimeMount } from './runtime-meta';
11
17
 
12
18
  const resourceCode = 'instruments';
13
19
  const nativeBase = () =>
14
20
  `/service/openxiangda-api/v2/applications/${applicationCode()}/native`;
15
- const dataBase = () => `${nativeBase()}/data/${resourceCode}`;
21
+ const dataBase = (code = resourceCode) => `${nativeBase()}/data/${code}`;
16
22
 
17
23
  interface PlatformEnvelope<T> {
18
24
  code: number;
@@ -135,9 +141,125 @@ function currentEnvironmentKey() {
135
141
  return key;
136
142
  }
137
143
 
144
+ export type DirectoryKind = 'user' | 'department';
145
+
146
+ export async function searchDirectory(
147
+ kind: DirectoryKind,
148
+ options: { keyword: string; cursor?: string }
149
+ ) {
150
+ const params = new URLSearchParams({
151
+ environmentKey: currentEnvironmentKey(),
152
+ keyword: options.keyword.trim(),
153
+ limit: '20',
154
+ });
155
+ if (options.cursor) params.set('cursor', options.cursor);
156
+ return request<DirectoryEntryPage>(
157
+ `${nativeBase().replace(/\/native$/, '')}/directory/${kind}s?${params}`
158
+ );
159
+ }
160
+
161
+ export async function browseDepartmentTree(options: {
162
+ parentId?: string;
163
+ cursor?: string;
164
+ }) {
165
+ const params = new URLSearchParams({
166
+ environmentKey: currentEnvironmentKey(),
167
+ limit: '100',
168
+ });
169
+ if (options.parentId) params.set('parentId', options.parentId);
170
+ if (options.cursor) params.set('offset', options.cursor);
171
+ return request<DirectoryEntryPage>(
172
+ `${nativeBase().replace(/\/native$/, '')}/directory/departments/tree?${params}`
173
+ );
174
+ }
175
+
176
+ export async function browseDepartmentUsers(
177
+ departmentId: string,
178
+ page = 1
179
+ ) {
180
+ const params = new URLSearchParams({
181
+ environmentKey: currentEnvironmentKey(),
182
+ page: String(page),
183
+ limit: '20',
184
+ });
185
+ return request<DirectoryEntryPage>(
186
+ `${nativeBase().replace(/\/native$/, '')}/directory/departments/${encodeURIComponent(
187
+ departmentId
188
+ )}/users?${params}`
189
+ );
190
+ }
191
+
192
+ export async function resolveDirectory(
193
+ kind: DirectoryKind,
194
+ ids: string[]
195
+ ) {
196
+ const uniqueIds = [...new Set(ids)];
197
+ const body: DirectoryResolveRequest = {
198
+ schemaVersion: SCHEMA_VERSIONS.directoryResolveRequest,
199
+ kind,
200
+ ids: uniqueIds,
201
+ };
202
+ if (body.ids.length === 0) {
203
+ throw new Error('OPENXIANGDA_DIRECTORY_RESOLVE_IDS_REQUIRED');
204
+ }
205
+ if (body.ids.length > 50) {
206
+ throw new Error('OPENXIANGDA_DIRECTORY_RESOLVE_IDS_EXCEEDED');
207
+ }
208
+ return request<DirectoryEntryPage>(
209
+ `${nativeBase().replace(/\/native$/, '')}/directory/resolve?environmentKey=${encodeURIComponent(
210
+ currentEnvironmentKey()
211
+ )}`,
212
+ { method: 'POST', body: JSON.stringify(body) }
213
+ );
214
+ }
215
+
216
+ const scopeValuesBase = () =>
217
+ `${nativeBase()}/data-resources/${resourceCode}/fields/collegeId/scope-values`;
218
+
219
+ export async function searchCollegeScope(
220
+ operation: 'create' | 'update',
221
+ options: { keyword: string; cursor?: string }
222
+ ) {
223
+ const params = new URLSearchParams({
224
+ environmentKey: currentEnvironmentKey(),
225
+ dimensionCode: 'college',
226
+ operation,
227
+ keyword: options.keyword.trim(),
228
+ limit: '20',
229
+ });
230
+ if (options.cursor) params.set('cursor', options.cursor);
231
+ return request<NativeScopeValuePage>(`${scopeValuesBase()}?${params}`);
232
+ }
233
+
234
+ export async function resolveCollegeScope(
235
+ operation: 'create' | 'update',
236
+ values: string[]
237
+ ) {
238
+ const uniqueValues = [...new Set(values)];
239
+ const body: NativeScopeValueResolveRequest = {
240
+ schemaVersion: SCHEMA_VERSIONS.nativeScopeValueResolveRequest,
241
+ dimensionCode: 'college',
242
+ operation,
243
+ values: uniqueValues,
244
+ };
245
+ if (body.values.length === 0) {
246
+ throw new Error('OPENXIANGDA_SCOPE_RESOLVE_VALUES_REQUIRED');
247
+ }
248
+ if (body.values.length > 50) {
249
+ throw new Error('OPENXIANGDA_SCOPE_RESOLVE_VALUES_EXCEEDED');
250
+ }
251
+ return request<NativeScopeValuePage>(
252
+ `${scopeValuesBase()}/resolve?environmentKey=${encodeURIComponent(
253
+ currentEnvironmentKey()
254
+ )}`,
255
+ { method: 'POST', body: JSON.stringify(body) }
256
+ );
257
+ }
258
+
138
259
  export interface InstrumentDataApiAdapter {
139
260
  list(query: InstrumentListQuery): Promise<{ rows: InstrumentRecord[]; total: number }>;
140
261
  get(id: string): Promise<InstrumentRecord>;
262
+ audit(id: string): Promise<DataAuditPage>;
141
263
  create(data: Partial<InstrumentRecord>): Promise<InstrumentRecord>;
142
264
  update(id: string, expectedRevision: number, data: Partial<InstrumentRecord>): Promise<InstrumentRecord>;
143
265
  remove(id: string, expectedRevision: number): Promise<InstrumentRecord>;
@@ -165,10 +287,11 @@ export const instrumentDataApi: InstrumentDataApiAdapter = {
165
287
  const body: DataQuery = {
166
288
  schemaVersion: SCHEMA_VERSIONS.dataQuery,
167
289
  filters,
168
- order: [{ field: String(query.sort?.field || 'updatedAt'), direction: query.sort?.order || 'desc' }],
290
+ order: [{ field: String(query.sort?.field || 'instrumentCode'), direction: query.sort?.order || 'asc' }],
169
291
  limit: query.pageSize,
170
292
  offset: (query.page - 1) * query.pageSize,
171
293
  };
294
+ assertInstrumentQueryFields(body);
172
295
  const page = await request<DataPage<InstrumentRecord>>(`${dataBase()}/query`, {
173
296
  method: 'POST',
174
297
  body: JSON.stringify({ ...body, environmentKey: currentEnvironmentKey() }),
@@ -183,6 +306,15 @@ export const instrumentDataApi: InstrumentDataApiAdapter = {
183
306
  );
184
307
  return record.data;
185
308
  },
309
+ async audit(id) {
310
+ return await request<DataAuditPage>(
311
+ `${dataBase()}/records/${encodeURIComponent(
312
+ id
313
+ )}/audit?limit=10&offset=0&environmentKey=${encodeURIComponent(
314
+ currentEnvironmentKey()
315
+ )}`
316
+ );
317
+ },
186
318
  async create(data) {
187
319
  const record = await request<DataRecord<InstrumentRecord>>(`${dataBase()}/records`, {
188
320
  method: 'POST',
@@ -244,3 +376,82 @@ export const instrumentDataApi: InstrumentDataApiAdapter = {
244
376
  );
245
377
  },
246
378
  };
379
+
380
+ export function instrumentFileContentUrl(fileId: string) {
381
+ return `${dataBase()}/files/${encodeURIComponent(
382
+ fileId
383
+ )}/content?environmentKey=${encodeURIComponent(currentEnvironmentKey())}`;
384
+ }
385
+
386
+ export interface CollegeRecord extends Record<string, unknown> {
387
+ id: string;
388
+ revision: number;
389
+ name: string;
390
+ enabled: boolean;
391
+ }
392
+
393
+ export const collegeDataApi = {
394
+ async list(page = 1, pageSize = 20) {
395
+ const body: DataQuery = {
396
+ schemaVersion: SCHEMA_VERSIONS.dataQuery,
397
+ order: [{ field: 'name', direction: 'asc' }],
398
+ limit: pageSize,
399
+ offset: (page - 1) * pageSize,
400
+ };
401
+ const result = await request<DataPage<CollegeRecord>>(
402
+ `${dataBase('colleges')}/query`,
403
+ {
404
+ method: 'POST',
405
+ body: JSON.stringify({
406
+ ...body,
407
+ environmentKey: currentEnvironmentKey(),
408
+ }),
409
+ }
410
+ );
411
+ return { rows: result.items, total: result.total };
412
+ },
413
+ async create(data: Pick<CollegeRecord, 'name' | 'enabled'>) {
414
+ const record = await request<DataRecord<CollegeRecord>>(
415
+ `${dataBase('colleges')}/records`,
416
+ {
417
+ method: 'POST',
418
+ body: JSON.stringify({
419
+ environmentKey: currentEnvironmentKey(),
420
+ data,
421
+ }),
422
+ }
423
+ );
424
+ return record.data;
425
+ },
426
+ async update(
427
+ id: string,
428
+ expectedRevision: number,
429
+ data: Pick<CollegeRecord, 'name' | 'enabled'>
430
+ ) {
431
+ const record = await request<DataRecord<CollegeRecord>>(
432
+ `${dataBase('colleges')}/records/${encodeURIComponent(id)}/update`,
433
+ {
434
+ method: 'POST',
435
+ body: JSON.stringify({
436
+ environmentKey: currentEnvironmentKey(),
437
+ expectedRevision,
438
+ data,
439
+ }),
440
+ }
441
+ );
442
+ return record.data;
443
+ },
444
+ };
445
+
446
+ function assertInstrumentQueryFields(query: DataQuery) {
447
+ for (const field of [
448
+ ...(query.filters || []).map(filter => filter.field),
449
+ ...(query.order || []).map(order => order.field),
450
+ ]) {
451
+ if (!isInstrumentResourceField(field)) {
452
+ throw new Error(
453
+ `OPENXIANGDA_INSTRUMENT_QUERY_FIELD_NOT_DECLARED:${field}`
454
+ );
455
+ }
456
+ }
457
+ }
@@ -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
  );