openxiangda-cli 2.0.0-alpha.68 → 2.0.0-alpha.70

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.
@@ -8,6 +8,9 @@ import {
8
8
  type DataQuery,
9
9
  type DataRecord,
10
10
  type DataResourceSurface,
11
+ type DataTransactionOperation,
12
+ type DataTransactionRequest,
13
+ type DataTransactionResult,
11
14
  type DirectoryEntryPage,
12
15
  type DirectoryResolveRequest,
13
16
  } from 'openxiangda-contracts/browser';
@@ -15,7 +18,7 @@ import { applicationCode, runtimeMount } from './runtime-meta';
15
18
  import { useSyncExternalStore } from 'react';
16
19
 
17
20
  interface PlatformEnvelope<T> {
18
- code: number;
21
+ code: number | string;
19
22
  message?: string;
20
23
  errorCode?: string;
21
24
  requestId?: string;
@@ -27,22 +30,22 @@ const globalRequestListeners = new Set<() => void>();
27
30
 
28
31
  function beginGlobalRequest() {
29
32
  globalRequestCount += 1;
30
- globalRequestListeners.forEach(listener => listener());
33
+ globalRequestListeners.forEach((listener) => listener());
31
34
  }
32
35
 
33
36
  function endGlobalRequest() {
34
37
  globalRequestCount = Math.max(0, globalRequestCount - 1);
35
- globalRequestListeners.forEach(listener => listener());
38
+ globalRequestListeners.forEach((listener) => listener());
36
39
  }
37
40
 
38
41
  export function useGlobalRequestLoading() {
39
42
  return useSyncExternalStore(
40
- listener => {
43
+ (listener) => {
41
44
  globalRequestListeners.add(listener);
42
45
  return () => globalRequestListeners.delete(listener);
43
46
  },
44
47
  () => globalRequestCount > 0,
45
- () => false,
48
+ () => false
46
49
  );
47
50
  }
48
51
 
@@ -59,12 +62,19 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
59
62
  },
60
63
  });
61
64
  const payload = (await response.json().catch(() => null)) as PlatformEnvelope<T> | T | null;
62
- const envelope = payload && typeof payload === 'object' && 'code' in payload ? payload as PlatformEnvelope<T> : null;
65
+ const envelope = payload && typeof payload === 'object' && 'code' in payload ? (payload as PlatformEnvelope<T>) : null;
63
66
  if (!response.ok || (envelope && envelope.code !== 200)) {
64
67
  const requestId = envelope?.requestId ? ` (requestId: ${envelope.requestId})` : '';
65
- throw new Error(`${envelope?.errorCode || `HTTP_${response.status}`}: ${envelope?.message || '平台请求失败'}${requestId}`);
68
+ throw Object.assign(
69
+ new Error(`${envelope?.errorCode || `HTTP_${response.status}`}: ${envelope?.message || '平台请求失败'}${requestId}`),
70
+ {
71
+ code: envelope?.errorCode || `HTTP_${response.status}`,
72
+ status: response.status,
73
+ data: envelope?.data ?? null,
74
+ }
75
+ );
66
76
  }
67
- return envelope ? envelope.data : payload as T;
77
+ return envelope ? envelope.data : (payload as T);
68
78
  } finally {
69
79
  endGlobalRequest();
70
80
  }
@@ -103,8 +113,15 @@ let activeIdentity: RuntimeIdentity | undefined;
103
113
  export async function loadCurrentIdentity(): Promise<RuntimeIdentity> {
104
114
  const mount = runtimeMount();
105
115
  if (mount) {
106
- const current = await request<DeployedCurrent>(`${nativeBase()}/current-user?environmentKey=${encodeURIComponent(mount.environmentKey)}`);
107
- if (current.schemaVersion !== 'openxiangda.current-user/v2' || current.appCode !== mount.appCode || current.environment.key !== mount.environmentKey || current.principal.type !== 'user') {
116
+ const current = await request<DeployedCurrent>(
117
+ `${nativeBase()}/current-user?environmentKey=${encodeURIComponent(mount.environmentKey)}`
118
+ );
119
+ if (
120
+ current.schemaVersion !== 'openxiangda.current-user/v2' ||
121
+ current.appCode !== mount.appCode ||
122
+ current.environment.key !== mount.environmentKey ||
123
+ current.principal.type !== 'user'
124
+ ) {
108
125
  throw new Error('OPENXIANGDA_DEPLOYED_CURRENT_USER_INVALID');
109
126
  }
110
127
  activeIdentity = { ...current.principal, environment: current.environment };
@@ -131,29 +148,49 @@ function currentEnvironmentKey() {
131
148
  export type DirectoryKind = 'user' | 'department';
132
149
 
133
150
  export async function searchDirectory(kind: DirectoryKind, options: { keyword: string; cursor?: string }) {
134
- const params = new URLSearchParams({ environmentKey: currentEnvironmentKey(), keyword: options.keyword.trim(), limit: '20' });
151
+ const params = new URLSearchParams({
152
+ environmentKey: currentEnvironmentKey(),
153
+ keyword: options.keyword.trim(),
154
+ limit: '20',
155
+ });
135
156
  if (options.cursor) params.set('cursor', options.cursor);
136
157
  return request<DirectoryEntryPage>(`${nativeBase().replace(/\/native$/, '')}/directory/${kind}s?${params}`);
137
158
  }
138
159
 
139
160
  export async function browseDepartmentTree(options: { parentId?: string; cursor?: string }) {
140
- const params = new URLSearchParams({ environmentKey: currentEnvironmentKey(), limit: '100' });
161
+ const params = new URLSearchParams({
162
+ environmentKey: currentEnvironmentKey(),
163
+ limit: '100',
164
+ });
141
165
  if (options.parentId) params.set('parentId', options.parentId);
142
166
  if (options.cursor) params.set('offset', options.cursor);
143
167
  return request<DirectoryEntryPage>(`${nativeBase().replace(/\/native$/, '')}/directory/departments/tree?${params}`);
144
168
  }
145
169
 
146
170
  export async function browseDepartmentUsers(departmentId: string, page = 1) {
147
- const params = new URLSearchParams({ environmentKey: currentEnvironmentKey(), page: String(page), limit: '20' });
148
- return request<DirectoryEntryPage>(`${nativeBase().replace(/\/native$/, '')}/directory/departments/${encodeURIComponent(departmentId)}/users?${params}`);
171
+ const params = new URLSearchParams({
172
+ environmentKey: currentEnvironmentKey(),
173
+ page: String(page),
174
+ limit: '20',
175
+ });
176
+ return request<DirectoryEntryPage>(
177
+ `${nativeBase().replace(/\/native$/, '')}/directory/departments/${encodeURIComponent(departmentId)}/users?${params}`
178
+ );
149
179
  }
150
180
 
151
181
  export async function resolveDirectory(kind: DirectoryKind, ids: string[]) {
152
182
  const uniqueIds = [...new Set(ids)];
153
183
  if (uniqueIds.length === 0) throw new Error('OPENXIANGDA_DIRECTORY_RESOLVE_IDS_REQUIRED');
154
184
  if (uniqueIds.length > 50) throw new Error('OPENXIANGDA_DIRECTORY_RESOLVE_IDS_EXCEEDED');
155
- const body: DirectoryResolveRequest = { schemaVersion: SCHEMA_VERSIONS.directoryResolveRequest, kind, ids: uniqueIds };
156
- return request<DirectoryEntryPage>(`${nativeBase().replace(/\/native$/, '')}/directory/resolve?environmentKey=${encodeURIComponent(currentEnvironmentKey())}`, { method: 'POST', body: JSON.stringify(body) });
185
+ const body: DirectoryResolveRequest = {
186
+ schemaVersion: SCHEMA_VERSIONS.directoryResolveRequest,
187
+ kind,
188
+ ids: uniqueIds,
189
+ };
190
+ return request<DirectoryEntryPage>(
191
+ `${nativeBase().replace(/\/native$/, '')}/directory/resolve?environmentKey=${encodeURIComponent(currentEnvironmentKey())}`,
192
+ { method: 'POST', body: JSON.stringify(body) }
193
+ );
157
194
  }
158
195
 
159
196
  export async function searchResource(resourceCode: string, labelField: string, options: { keyword: string; cursor?: string }) {
@@ -161,14 +198,32 @@ export async function searchResource(resourceCode: string, labelField: string, o
161
198
  const offset = options.cursor ? Number(options.cursor) : 0;
162
199
  const query: DataQuery = {
163
200
  schemaVersion: SCHEMA_VERSIONS.dataQuery,
164
- filters: options.keyword.trim() ? [{ field: labelField, operator: 'ilike', value: `%${options.keyword.trim()}%` }] : [],
201
+ filters: options.keyword.trim()
202
+ ? [
203
+ {
204
+ field: labelField,
205
+ operator: 'ilike',
206
+ value: `%${options.keyword.trim()}%`,
207
+ },
208
+ ]
209
+ : [],
165
210
  order: [{ field: labelField, direction: 'asc' }],
166
211
  limit: pageSize,
167
212
  offset: Number.isSafeInteger(offset) && offset >= 0 ? offset : 0,
168
213
  };
169
- const page = await request<DataPage<Record<string, unknown>>>(`${dataBase(resourceCode)}/query`, { method: 'POST', body: JSON.stringify({ ...query, environmentKey: currentEnvironmentKey() }) });
214
+ const page = await request<DataPage<Record<string, unknown>>>(`${dataBase(resourceCode)}/query`, {
215
+ method: 'POST',
216
+ body: JSON.stringify({
217
+ ...query,
218
+ environmentKey: currentEnvironmentKey(),
219
+ }),
220
+ });
170
221
  return {
171
- items: page.items.map(item => ({ id: String(item.id), label: String(item[labelField] ?? item.id), selectable: true })),
222
+ items: page.items.map((item) => ({
223
+ id: String(item.id),
224
+ label: String(item[labelField] ?? item.id),
225
+ selectable: true,
226
+ })),
172
227
  nextCursor: page.items.length >= pageSize ? String(page.offset + page.items.length) : null,
173
228
  };
174
229
  }
@@ -176,10 +231,18 @@ export async function searchResource(resourceCode: string, labelField: string, o
176
231
  export async function resolveResource(resourceCode: string, ids: string[], labelField: string) {
177
232
  if (ids.length === 0) throw new Error('OPENXIANGDA_RESOURCE_RESOLVE_IDS_REQUIRED');
178
233
  if (ids.length > 50) throw new Error('OPENXIANGDA_RESOURCE_RESOLVE_IDS_EXCEEDED');
179
- const items = await Promise.all(ids.map(async id => {
180
- const record = await request<DataRecord<Record<string, unknown>>>(`${dataBase(resourceCode)}/records/${encodeURIComponent(id)}?environmentKey=${encodeURIComponent(currentEnvironmentKey())}`);
181
- return { id, label: String(record.data[labelField] ?? id), selectable: true };
182
- }));
234
+ const items = await Promise.all(
235
+ ids.map(async (id) => {
236
+ const record = await request<DataRecord<Record<string, unknown>>>(
237
+ `${dataBase(resourceCode)}/records/${encodeURIComponent(id)}?environmentKey=${encodeURIComponent(currentEnvironmentKey())}`
238
+ );
239
+ return {
240
+ id,
241
+ label: String(record.data[labelField] ?? id),
242
+ selectable: true,
243
+ };
244
+ })
245
+ );
183
246
  return { items, nextCursor: null };
184
247
  }
185
248
 
@@ -204,7 +267,12 @@ export function createNativeResourceClient(code: string, surface: DataResourceSu
204
267
  if (query.keyword?.trim()) {
205
268
  const searchable = surface.list?.searchableFields || [];
206
269
  const field = searchable.includes(query.searchField || '') ? query.searchField! : searchable[0];
207
- if (field) filters.push({ field: assertField(field), operator: 'ilike', value: `%${query.keyword.trim()}%` });
270
+ if (field)
271
+ filters.push({
272
+ field: assertField(field),
273
+ operator: 'ilike',
274
+ value: `%${query.keyword.trim()}%`,
275
+ });
208
276
  }
209
277
  for (const [fieldCode, value] of Object.entries(query.filters || {})) {
210
278
  if (value === undefined || value === null || value === '') continue;
@@ -214,8 +282,10 @@ export function createNativeResourceClient(code: string, surface: DataResourceSu
214
282
  if (!declaredFields.has(fieldCode)) continue;
215
283
  const field = surface.fields[fieldCode];
216
284
  if (Array.isArray(value) && value.length === 2 && ['date', 'datetime', 'number', 'money', 'percent'].includes(field?.widget || '')) {
217
- if (value[0] !== undefined && value[0] !== null && value[0] !== '') filters.push({ field: fieldCode, operator: 'gte', value: value[0] });
218
- if (value[1] !== undefined && value[1] !== null && value[1] !== '') filters.push({ field: fieldCode, operator: 'lte', value: value[1] });
285
+ if (value[0] !== undefined && value[0] !== null && value[0] !== '')
286
+ filters.push({ field: fieldCode, operator: 'gte', value: value[0] });
287
+ if (value[1] !== undefined && value[1] !== null && value[1] !== '')
288
+ filters.push({ field: fieldCode, operator: 'lte', value: value[1] });
219
289
  } else if (Array.isArray(value) && ['multi', 'directory-user', 'directory-department', 'resource'].includes(field?.widget || '')) {
220
290
  filters.push({ field: fieldCode, operator: 'cs', value });
221
291
  } else {
@@ -226,42 +296,112 @@ export function createNativeResourceClient(code: string, surface: DataResourceSu
226
296
  };
227
297
  return {
228
298
  async list(query: GenericResourceQuery) {
229
- const fallbackSort = surface.list?.defaultSort || { field: Object.keys(surface.fields)[0] || 'id', order: 'asc' as const };
299
+ const fallbackSort = surface.list?.defaultSort || {
300
+ field: Object.keys(surface.fields)[0] || 'id',
301
+ order: 'asc' as const,
302
+ };
230
303
  const requestedSort = query.sort || fallbackSort;
231
304
  const sort = declaredFields.has(requestedSort.field) ? requestedSort : fallbackSort;
232
- const body: DataQuery = { schemaVersion: SCHEMA_VERSIONS.dataQuery, filters: filtersFor(query), order: [{ field: sort.field, direction: sort.order || 'asc' }], limit: query.pageSize, offset: (query.page - 1) * query.pageSize };
233
- const page = await request<DataPage<Record<string, unknown>>>(`${base}/query`, { method: 'POST', body: JSON.stringify({ ...body, environmentKey: currentEnvironmentKey() }) });
305
+ const body: DataQuery = {
306
+ schemaVersion: SCHEMA_VERSIONS.dataQuery,
307
+ filters: filtersFor(query),
308
+ order: [{ field: sort.field, direction: sort.order || 'asc' }],
309
+ limit: query.pageSize,
310
+ offset: (query.page - 1) * query.pageSize,
311
+ };
312
+ const page = await request<DataPage<Record<string, unknown>>>(`${base}/query`, {
313
+ method: 'POST',
314
+ body: JSON.stringify({
315
+ ...body,
316
+ environmentKey: currentEnvironmentKey(),
317
+ }),
318
+ });
234
319
  return { rows: page.items, total: page.total };
235
320
  },
236
321
  async get(id: string) {
237
- const record = await request<DataRecord<Record<string, unknown>>>(`${base}/records/${encodeURIComponent(id)}?environmentKey=${encodeURIComponent(currentEnvironmentKey())}`);
322
+ const record = await request<DataRecord<Record<string, unknown>>>(
323
+ `${base}/records/${encodeURIComponent(id)}?environmentKey=${encodeURIComponent(currentEnvironmentKey())}`
324
+ );
238
325
  return record.data;
239
326
  },
240
327
  async audit(id: string) {
241
- return request<DataAuditPage>(`${base}/records/${encodeURIComponent(id)}/audit?limit=10&offset=0&environmentKey=${encodeURIComponent(currentEnvironmentKey())}`);
328
+ return request<DataAuditPage>(
329
+ `${base}/records/${encodeURIComponent(id)}/audit?limit=10&offset=0&environmentKey=${encodeURIComponent(currentEnvironmentKey())}`
330
+ );
242
331
  },
243
332
  async create(data: Record<string, unknown>) {
244
- const record = await request<DataRecord<Record<string, unknown>>>(`${base}/records`, { method: 'POST', body: JSON.stringify({ environmentKey: currentEnvironmentKey(), data }) });
333
+ const record = await request<DataRecord<Record<string, unknown>>>(`${base}/records`, {
334
+ method: 'POST',
335
+ body: JSON.stringify({
336
+ environmentKey: currentEnvironmentKey(),
337
+ data,
338
+ }),
339
+ });
245
340
  return record.data;
246
341
  },
247
342
  async update(id: string, expectedRevision: number, data: Record<string, unknown>) {
248
- const record = await request<DataRecord<Record<string, unknown>>>(`${base}/records/${encodeURIComponent(id)}/update`, { method: 'POST', body: JSON.stringify({ environmentKey: currentEnvironmentKey(), expectedRevision, data }) });
343
+ const record = await request<DataRecord<Record<string, unknown>>>(`${base}/records/${encodeURIComponent(id)}/update`, {
344
+ method: 'POST',
345
+ body: JSON.stringify({
346
+ environmentKey: currentEnvironmentKey(),
347
+ expectedRevision,
348
+ data,
349
+ }),
350
+ });
249
351
  return record.data;
250
352
  },
251
353
  async remove(id: string, expectedRevision: number) {
252
- const record = await request<DataRecord<Record<string, unknown>>>(`${base}/records/${encodeURIComponent(id)}/delete`, { method: 'POST', body: JSON.stringify({ environmentKey: currentEnvironmentKey(), expectedRevision }) });
354
+ const record = await request<DataRecord<Record<string, unknown>>>(`${base}/records/${encodeURIComponent(id)}/delete`, {
355
+ method: 'POST',
356
+ body: JSON.stringify({
357
+ environmentKey: currentEnvironmentKey(),
358
+ expectedRevision,
359
+ }),
360
+ });
253
361
  return record.data;
254
362
  },
255
363
  async upload(fieldCode: string, file: File, recordId?: string) {
256
364
  assertField(fieldCode);
257
- 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 } : {}) }) });
258
- const uploaded = await fetch(plan.uploadUrl, { method: plan.uploadMethod, headers: plan.headers, body: file });
365
+ const plan = await request<DataFileUploadPlan>(`${base}/files/uploads/initiate`, {
366
+ method: 'POST',
367
+ body: JSON.stringify({
368
+ environmentKey: currentEnvironmentKey(),
369
+ fieldCode,
370
+ fileName: file.name,
371
+ fileSize: file.size,
372
+ contentType: file.type || 'application/octet-stream',
373
+ ...(recordId ? { recordId } : {}),
374
+ }),
375
+ });
376
+ const uploaded = await fetch(plan.uploadUrl, {
377
+ method: plan.uploadMethod,
378
+ headers: plan.headers,
379
+ body: file,
380
+ });
259
381
  if (!uploaded.ok) throw new Error(`FILE_UPLOAD_FAILED: HTTP_${uploaded.status}`);
260
- return request<DataFileRef>(`${base}/files/${encodeURIComponent(plan.file.id)}/complete`, { method: 'POST', body: JSON.stringify({ environmentKey: currentEnvironmentKey() }) });
382
+ return request<DataFileRef>(`${base}/files/${encodeURIComponent(plan.file.id)}/complete`, {
383
+ method: 'POST',
384
+ body: JSON.stringify({ environmentKey: currentEnvironmentKey() }),
385
+ });
261
386
  },
262
387
  };
263
388
  }
264
389
 
390
+ export async function transactNativeData(operations: DataTransactionOperation[], idempotencyKey: string = crypto.randomUUID()) {
391
+ const transaction: DataTransactionRequest = {
392
+ schemaVersion: SCHEMA_VERSIONS.dataTransactionRequest,
393
+ idempotencyKey,
394
+ operations,
395
+ };
396
+ return request<DataTransactionResult>(`${nativeBase()}/data/transactions`, {
397
+ method: 'POST',
398
+ body: JSON.stringify({
399
+ ...transaction,
400
+ environmentKey: currentEnvironmentKey(),
401
+ }),
402
+ });
403
+ }
404
+
265
405
  function nativeBase() {
266
406
  return `/service/openxiangda-api/v2/applications/${applicationCode()}/native`;
267
407
  }
@@ -271,16 +411,24 @@ function dataBase(code: string) {
271
411
  }
272
412
 
273
413
  export function dataFileContentUrl(resource: string, fileId: string, disposition: 'attachment' | 'inline' = 'attachment') {
274
- const query = new URLSearchParams({ environmentKey: currentEnvironmentKey(), disposition });
414
+ const query = new URLSearchParams({
415
+ environmentKey: currentEnvironmentKey(),
416
+ disposition,
417
+ });
275
418
  return `${dataBase(resource)}/files/${encodeURIComponent(fileId)}/content?${query}`;
276
419
  }
277
420
 
278
421
  export async function loadDataFilePreview(resource: string, fileId: string) {
279
- return request<DataFilePreview>(`${dataBase(resource)}/files/${encodeURIComponent(fileId)}/preview?environmentKey=${encodeURIComponent(currentEnvironmentKey())}`);
422
+ return request<DataFilePreview>(
423
+ `${dataBase(resource)}/files/${encodeURIComponent(fileId)}/preview?environmentKey=${encodeURIComponent(currentEnvironmentKey())}`
424
+ );
280
425
  }
281
426
 
282
427
  export async function fetchDataFileBlob(resource: string, fileId: string) {
283
- const response = await fetch(dataFileContentUrl(resource, fileId, 'inline'), { credentials: 'include', headers: { accept: 'application/octet-stream,*/*' } });
428
+ const response = await fetch(dataFileContentUrl(resource, fileId, 'inline'), {
429
+ credentials: 'include',
430
+ headers: { accept: 'application/octet-stream,*/*' },
431
+ });
284
432
  if (!response.ok) throw new Error(`FILE_CONTENT_READ_FAILED: HTTP_${response.status}`);
285
433
  return response.blob();
286
434
  }
@@ -8,9 +8,9 @@ body,
8
8
 
9
9
  body {
10
10
  color: var(--oxa-shell-text, #1f1f1f);
11
- font-family:
12
- -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue",
13
- Arial, "Noto Sans", "PingFang SC", "Microsoft YaHei", sans-serif;
11
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
12
+ "Helvetica Neue", Arial, "Noto Sans", "PingFang SC", "Microsoft YaHei",
13
+ sans-serif;
14
14
  }
15
15
 
16
16
  * {
@@ -386,7 +386,8 @@ body {
386
386
 
387
387
  .oxa-mobile-input:focus {
388
388
  border-color: var(--oxa-shell-primary, #1677ff);
389
- outline: 2px solid color-mix(in srgb, var(--oxa-shell-primary, #1677ff) 15%, transparent);
389
+ outline: 2px solid
390
+ color-mix(in srgb, var(--oxa-shell-primary, #1677ff) 15%, transparent);
390
391
  }
391
392
 
392
393
  .oxa-mobile-filter,
@@ -541,6 +542,24 @@ body {
541
542
  color: var(--oxa-shell-text-secondary, #595959);
542
543
  }
543
544
 
545
+ .oxa-batch-bar {
546
+ display: flex;
547
+ min-height: 52px;
548
+ align-items: center;
549
+ justify-content: space-between;
550
+ gap: 12px;
551
+ border-bottom: 1px solid var(--oxa-shell-border, #f0f0f0);
552
+ background: var(--ant-color-primary-bg, #e6f4ff);
553
+ padding: 8px 16px;
554
+ }
555
+
556
+ .oxa-import-errors {
557
+ max-height: 180px;
558
+ margin: 8px 0 0;
559
+ overflow: auto;
560
+ padding-left: 20px;
561
+ }
562
+
544
563
  .oxa-list-card .ant-table-wrapper {
545
564
  border: 0;
546
565
  border-radius: 0;
@@ -594,7 +613,11 @@ body {
594
613
  justify-content: flex-end;
595
614
  gap: 10px;
596
615
  border-top: 1px solid var(--oxa-shell-border, #f0f0f0);
597
- background: color-mix(in srgb, var(--oxa-shell-surface, #fff) 96%, transparent);
616
+ background: color-mix(
617
+ in srgb,
618
+ var(--oxa-shell-surface, #fff) 96%,
619
+ transparent
620
+ );
598
621
  padding: 14px 0;
599
622
  backdrop-filter: blur(8px);
600
623
  }
@@ -716,7 +739,8 @@ body {
716
739
  .oxa-directory-trigger:hover,
717
740
  .oxa-directory-trigger:focus-visible {
718
741
  border-color: var(--oxa-shell-primary, #1677ff);
719
- box-shadow: 0 0 0 2px color-mix(in srgb, var(--oxa-shell-primary, #1677ff) 10%, transparent);
742
+ box-shadow: 0 0 0 2px
743
+ color-mix(in srgb, var(--oxa-shell-primary, #1677ff) 10%, transparent);
720
744
  outline: 0;
721
745
  }
722
746
 
@@ -1047,7 +1071,11 @@ body {
1047
1071
  justify-content: space-between;
1048
1072
  gap: 20px;
1049
1073
  border-bottom: 1px solid var(--oxa-shell-border, #f0f0f0);
1050
- background: color-mix(in srgb, var(--oxa-shell-surface, #fff) 96%, transparent);
1074
+ background: color-mix(
1075
+ in srgb,
1076
+ var(--oxa-shell-surface, #fff) 96%,
1077
+ transparent
1078
+ );
1051
1079
  padding: 10px 20px;
1052
1080
  box-shadow: var(--oxa-shell-shadow, 0 1px 6px rgb(0 0 0 / 8%));
1053
1081
  backdrop-filter: blur(12px);
@@ -1597,7 +1625,8 @@ body {
1597
1625
  }
1598
1626
 
1599
1627
  .oxa-filter-actions,
1600
- .oxa-list-toolbar {
1628
+ .oxa-list-toolbar,
1629
+ .oxa-batch-bar {
1601
1630
  align-items: stretch;
1602
1631
  flex-direction: column;
1603
1632
  }
@@ -40,7 +40,7 @@ test('standard surfaces keep authoritative directory/resource selectors and mobi
40
40
  const client = readFileSync(new URL('../src/platform-client.ts', import.meta.url), 'utf8');
41
41
  assert.match(client, /declaredFields\.has\(requestedSort\.field\)/);
42
42
  assert.match(client, /if \(!declaredFields\.has\(fieldCode\)\) continue/);
43
- assert.match(client, /'\/api\/auth\/logout'/);
43
+ assert.match(client, /\/api\/auth\/logout/);
44
44
  });
45
45
 
46
46
  test('admin appearance is a bounded Ant Design theme with compact route chrome', () => {
@@ -74,16 +74,26 @@ test('standard desktop CRUD exposes compact filters, table tools, and lazy audit
74
74
  assert.match(crud, /densityMenu/);
75
75
  assert.match(crud, /列设置/);
76
76
  assert.match(crud, /密度/);
77
+ assert.match(crud, /ResourceImportButton/);
78
+ assert.match(crud, /ResourceBatchActions/);
79
+ const batch = readFileSync(new URL('../src/components/resource/ResourceBatchActions.tsx', import.meta.url), 'utf8');
80
+ assert.match(batch, /transactNativeData/);
81
+ assert.match(batch, /批量修改/);
82
+ assert.match(batch, /批量删除/);
83
+ assert.match(batch, /确认导入/);
77
84
  assert.match(crud, /!auditExpanded/);
78
85
  assert.match(crud, /<Collapse/);
79
86
  assert.match(crud, /查看创建、更新和删除的字段变化/);
80
87
  });
81
88
 
82
89
  test('runtime metadata stays mount-scoped', () => {
83
- const mount = readRuntimeMount(name => ({
84
- 'openxiangda-runtime-base': '/view/example-app/',
85
- 'openxiangda-app-code': 'example-app',
86
- 'openxiangda-environment': 'preproduction',
87
- }[name]));
90
+ const mount = readRuntimeMount(
91
+ (name) =>
92
+ ({
93
+ 'openxiangda-runtime-base': '/view/example-app/',
94
+ 'openxiangda-app-code': 'example-app',
95
+ 'openxiangda-environment': 'preproduction',
96
+ }[name])
97
+ );
88
98
  assert.equal(resolveApplicationBasename(mount), '/view/example-app');
89
99
  });
@@ -0,0 +1,142 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import type { DataResourceSurface } from 'openxiangda-contracts/browser';
4
+ import { parseResourceImportMatrix } from '../src/components/resource/resource-import';
5
+
6
+ const surface = {
7
+ generated: true,
8
+ fields: {
9
+ visitorName: { label: '访客姓名', widget: 'text', requiredHint: true },
10
+ visitorCount: { label: '来访人数', widget: 'number' },
11
+ approved: { label: '是否通过', widget: 'boolean' },
12
+ status: {
13
+ label: '状态',
14
+ widget: 'select',
15
+ options: [
16
+ { label: '待审核', value: 'pending' },
17
+ { label: '已通过', value: 'approved' },
18
+ ],
19
+ },
20
+ visitDate: { label: '来访日期', widget: 'date' },
21
+ attachments: { label: '附件', widget: 'file' },
22
+ },
23
+ } as unknown as DataResourceSurface;
24
+
25
+ test('maps exact labels and codes into one canonical create transaction', () => {
26
+ const preview = parseResourceImportMatrix(
27
+ '访客.xlsx',
28
+ [
29
+ ['访客姓名', 'visitorCount', '是否通过', '状态'],
30
+ ['张三', 2, '是', '待审核'],
31
+ ['李四', '3', '否', 'approved'],
32
+ ],
33
+ 'visitor-reservations',
34
+ surface,
35
+ ['visitorName', 'visitorCount', 'approved', 'status']
36
+ );
37
+
38
+ assert.deepEqual(preview.errors, []);
39
+ assert.deepEqual(preview.operations, [
40
+ {
41
+ operation: 'create',
42
+ resourceCode: 'visitor-reservations',
43
+ data: {
44
+ visitorName: '张三',
45
+ visitorCount: 2,
46
+ approved: true,
47
+ status: 'pending',
48
+ },
49
+ },
50
+ {
51
+ operation: 'create',
52
+ resourceCode: 'visitor-reservations',
53
+ data: {
54
+ visitorName: '李四',
55
+ visitorCount: 3,
56
+ approved: false,
57
+ status: 'approved',
58
+ },
59
+ },
60
+ ]);
61
+ });
62
+
63
+ test('preserves exact dates and rejects impossible calendar dates', () => {
64
+ const valid = parseResourceImportMatrix(
65
+ '日期.csv',
66
+ [
67
+ ['访客姓名', '来访日期'],
68
+ ['张三', '2026-08-23'],
69
+ ],
70
+ 'visitor-reservations',
71
+ surface,
72
+ ['visitorName', 'visitDate']
73
+ );
74
+ assert.deepEqual(valid.errors, []);
75
+ assert.deepEqual(valid.operations[0], {
76
+ operation: 'create',
77
+ resourceCode: 'visitor-reservations',
78
+ data: { visitorName: '张三', visitDate: '2026-08-23' },
79
+ });
80
+
81
+ const invalid = parseResourceImportMatrix(
82
+ '错误日期.csv',
83
+ [
84
+ ['访客姓名', '来访日期'],
85
+ ['张三', '2026-02-30'],
86
+ ],
87
+ 'visitor-reservations',
88
+ surface,
89
+ ['visitorName', 'visitDate']
90
+ );
91
+ assert.equal(invalid.operations.length, 0);
92
+ assert.ok(invalid.errors.some((error) => error.includes('日期格式无效')));
93
+ });
94
+
95
+ test('rejects values in a column without a header', () => {
96
+ const preview = parseResourceImportMatrix(
97
+ '空表头.csv',
98
+ [
99
+ ['访客姓名', ''],
100
+ ['张三', '未声明的数据'],
101
+ ],
102
+ 'visitor-reservations',
103
+ surface,
104
+ ['visitorName']
105
+ );
106
+
107
+ assert.equal(preview.operations.length, 0);
108
+ assert.ok(preview.errors.some((error) => error.includes('缺少表头')));
109
+ });
110
+
111
+ test('rejects unknown, managed-file and invalid cells before submission', () => {
112
+ const preview = parseResourceImportMatrix(
113
+ '错误.csv',
114
+ [
115
+ ['访客姓名', '是否通过', '附件', '任意列'],
116
+ ['', '不确定', 'https://untrusted.example/file', 'value'],
117
+ ],
118
+ 'visitor-reservations',
119
+ surface,
120
+ ['visitorName', 'approved', 'attachments']
121
+ );
122
+
123
+ assert.equal(preview.operations.length, 0);
124
+ assert.ok(preview.errors.some((error) => error.includes('不是已声明字段')));
125
+ assert.ok(preview.errors.some((error) => error.includes('必须通过平台上传组件')));
126
+ assert.ok(preview.errors.some((error) => error.includes('请填写是或否')));
127
+ assert.ok(preview.errors.some((error) => error.includes('访客姓名为必填项')));
128
+ });
129
+
130
+ test('keeps the atomic import boundary at one hundred rows', () => {
131
+ const preview = parseResourceImportMatrix(
132
+ '过大.csv',
133
+ [['访客姓名'], ...Array.from({ length: 101 }, (_, index) => [`访客 ${index + 1}`])],
134
+ 'visitor-reservations',
135
+ surface,
136
+ ['visitorName']
137
+ );
138
+
139
+ assert.equal(preview.rows.length, 100);
140
+ assert.equal(preview.operations.length, 0);
141
+ assert.ok(preview.errors.some((error) => error.includes('单次最多导入 100 行')));
142
+ });
@@ -10,7 +10,8 @@ export default defineOpenXiangdaApp({
10
10
  runtime: 'node',
11
11
  framework: 'nestjs',
12
12
  enabled: false,
13
- runMode: 'shared',
13
+ isolation: 'shared',
14
+ resourceProfile: 'light',
14
15
  secrets: [],
15
16
  operations: [],
16
17
  },