openxiangda-cli 2.0.0-alpha.84 → 2.0.0-alpha.86

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 (62) hide show
  1. package/package.json +4 -4
  2. package/template/AGENTS.md +4 -4
  3. package/template/README.md +2 -2
  4. package/template/apps/server/package.json +1 -1
  5. package/template/apps/web/data-api-live.e2e.html +15 -0
  6. package/template/apps/web/e2e/data-api-live-fixture.tsx +141 -0
  7. package/template/apps/web/e2e/data-api-live.spec.ts +138 -0
  8. package/template/apps/web/e2e/field-protocol-fixture.tsx +280 -0
  9. package/template/apps/web/e2e/field-protocol.spec.ts +100 -0
  10. package/template/apps/web/e2e/resources.spec.ts +258 -20
  11. package/template/apps/web/field-protocol.e2e.html +12 -0
  12. package/template/apps/web/package.json +1 -1
  13. package/template/apps/web/playwright.config.ts +3 -1
  14. package/template/apps/web/scripts/check.mjs +8 -5
  15. package/template/apps/web/scripts/verify-build.mjs +1 -1
  16. package/template/apps/web/src/AuthoritativeSelector.tsx +165 -132
  17. package/template/apps/web/src/FilePreviewPage.tsx +30 -9
  18. package/template/apps/web/src/RoleSubjectSelect.tsx +128 -0
  19. package/template/apps/web/src/Shell.tsx +93 -6
  20. package/template/apps/web/src/components/platform-fields/AddressField.tsx +338 -0
  21. package/template/apps/web/src/components/platform-fields/AttachmentFileList.tsx +25 -6
  22. package/template/apps/web/src/components/platform-fields/CascadeField.tsx +49 -0
  23. package/template/apps/web/src/components/platform-fields/DateTimeField.tsx +148 -0
  24. package/template/apps/web/src/components/platform-fields/JsonField.tsx +77 -0
  25. package/template/apps/web/src/components/platform-fields/LocationField.tsx +164 -0
  26. package/template/apps/web/src/components/platform-fields/PlatformDirectoryPicker.tsx +19 -44
  27. package/template/apps/web/src/components/platform-fields/ResourceReferenceField.tsx +67 -0
  28. package/template/apps/web/src/components/platform-fields/RichTextField.tsx +196 -0
  29. package/template/apps/web/src/components/platform-fields/SignatureField.tsx +315 -0
  30. package/template/apps/web/src/components/platform-fields/SubtableField.tsx +553 -0
  31. package/template/apps/web/src/components/platform-fields/address-value.ts +80 -0
  32. package/template/apps/web/src/components/platform-fields/cascade-value.ts +66 -0
  33. package/template/apps/web/src/components/platform-fields/directory-value.ts +53 -0
  34. package/template/apps/web/src/components/platform-fields/field-form-codec.ts +98 -0
  35. package/template/apps/web/src/components/platform-fields/location-value.ts +87 -0
  36. package/template/apps/web/src/components/platform-fields/resource-query.ts +131 -0
  37. package/template/apps/web/src/components/platform-fields/rich-text-value.ts +59 -0
  38. package/template/apps/web/src/components/platform-fields/subtable-value.ts +187 -0
  39. package/template/apps/web/src/components/resource/GeneratedResourceCrud.tsx +194 -108
  40. package/template/apps/web/src/components/resource/ResourceBatchActions.tsx +8 -1
  41. package/template/apps/web/src/components/resource/SurfaceFields.tsx +385 -79
  42. package/template/apps/web/src/components/resource/generated-resource-definition.ts +18 -0
  43. package/template/apps/web/src/components/resource/resource-import.ts +4 -4
  44. package/template/apps/web/src/data-provider.ts +59 -34
  45. package/template/apps/web/src/platform-client.ts +385 -111
  46. package/template/apps/web/src/runtime.tsx +166 -14
  47. package/template/apps/web/src/styles.css +264 -0
  48. package/template/apps/web/test/address-field.test.ts +66 -0
  49. package/template/apps/web/test/cascade-value.test.ts +68 -0
  50. package/template/apps/web/test/contracts.test.ts +29 -5
  51. package/template/apps/web/test/directory-value.test.ts +55 -0
  52. package/template/apps/web/test/field-bounds.test.ts +17 -0
  53. package/template/apps/web/test/field-form-codec.test.ts +85 -0
  54. package/template/apps/web/test/location-field.test.ts +83 -0
  55. package/template/apps/web/test/resource-import.test.ts +8 -7
  56. package/template/apps/web/test/resource-query.test.ts +144 -0
  57. package/template/apps/web/test/rich-json-field.test.ts +53 -0
  58. package/template/apps/web/test/signature-serial-field.test.ts +40 -0
  59. package/template/apps/web/test/subtable-value.test.ts +144 -0
  60. package/template/package.json +3 -3
  61. package/template/packages/contracts/src/generated.ts +69 -0
  62. package/template/scripts/verify-template-budget.mjs +11 -4
@@ -4,8 +4,12 @@ import {
4
4
  type DataFilePreview,
5
5
  type DataFileRef,
6
6
  type DataFileUploadPlan,
7
+ type DataFieldSourcePage,
8
+ type DataFieldSourceQuery,
9
+ type DataExportRequest,
7
10
  type DataPage,
8
11
  type DataQuery,
12
+ type DataWhere,
9
13
  type DataRecord,
10
14
  type DataResourceSurface,
11
15
  type DataTransactionOperation,
@@ -13,9 +17,22 @@ import {
13
17
  type DataTransactionResult,
14
18
  type DirectoryEntryPage,
15
19
  type DirectoryResolveRequest,
20
+ type RoleSessionContext,
21
+ type RoleSubjectChoice,
22
+ type RoleSubjectChoicePage,
23
+ type ResourceReferenceValue,
16
24
  } from 'openxiangda-contracts/browser';
17
- import { applicationCode, runtimeMount } from './runtime-meta';
18
25
  import { useSyncExternalStore } from 'react';
26
+ import {
27
+ buildResourceWhere,
28
+ type GenericResourceQuery,
29
+ } from './components/platform-fields/resource-query';
30
+ export type { GenericResourceQuery } from './components/platform-fields/resource-query';
31
+ import {
32
+ applicationApiPath,
33
+ applicationCode,
34
+ runtimeMount,
35
+ } from './runtime-meta';
19
36
 
20
37
  interface PlatformEnvelope<T> {
21
38
  code: number | string;
@@ -52,14 +69,18 @@ export function useGlobalRequestLoading() {
52
69
  async function request<T>(path: string, init?: RequestInit): Promise<T> {
53
70
  beginGlobalRequest();
54
71
  try {
72
+ const headers = new Headers(init?.headers);
73
+ if (!headers.has('accept')) headers.set('accept', 'application/json');
74
+ if (init?.body && !headers.has('content-type')) {
75
+ headers.set('content-type', 'application/json');
76
+ }
77
+ if (activeRoleSessionId && roleSessionBoundPath(path)) {
78
+ headers.set('x-openxiangda-role-session-id', activeRoleSessionId);
79
+ }
55
80
  const response = await fetch(path, {
56
81
  credentials: 'include',
57
82
  ...init,
58
- headers: {
59
- accept: 'application/json',
60
- ...(init?.body ? { 'content-type': 'application/json' } : {}),
61
- ...init?.headers,
62
- },
83
+ headers,
63
84
  });
64
85
  const payload = (await response.json().catch(() => null)) as PlatformEnvelope<T> | T | null;
65
86
  const envelope = payload && typeof payload === 'object' && 'code' in payload ? (payload as PlatformEnvelope<T>) : null;
@@ -80,11 +101,74 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
80
101
  }
81
102
  }
82
103
 
104
+ async function requestBlob(path: string, init?: RequestInit) {
105
+ beginGlobalRequest();
106
+ try {
107
+ const headers = new Headers(init?.headers);
108
+ headers.set('accept', 'text/csv');
109
+ if (init?.body && !headers.has('content-type')) {
110
+ headers.set('content-type', 'application/json');
111
+ }
112
+ if (activeRoleSessionId && roleSessionBoundPath(path)) {
113
+ headers.set('x-openxiangda-role-session-id', activeRoleSessionId);
114
+ }
115
+ const response = await fetch(path, {
116
+ credentials: 'include',
117
+ ...init,
118
+ headers,
119
+ });
120
+ if (!response.ok) {
121
+ const payload = (await response.json().catch(() => null)) as PlatformEnvelope<unknown> | null;
122
+ const requestId = payload?.requestId ? ` (requestId: ${payload.requestId})` : '';
123
+ throw Object.assign(
124
+ new Error(`${payload?.errorCode || `HTTP_${response.status}`}: ${payload?.message || '平台导出失败'}${requestId}`),
125
+ {
126
+ code: payload?.errorCode || `HTTP_${response.status}`,
127
+ status: response.status,
128
+ data: payload?.data ?? null,
129
+ }
130
+ );
131
+ }
132
+ const disposition = response.headers.get('content-disposition') || '';
133
+ const encodedName = /filename\*=UTF-8''([^;]+)/i.exec(disposition)?.[1];
134
+ return {
135
+ blob: await response.blob(),
136
+ fileName: encodedName ? decodeURIComponent(encodedName) : undefined,
137
+ rowLimit: Number(response.headers.get('x-openxiangda-export-row-limit') || 10_000),
138
+ };
139
+ } finally {
140
+ endGlobalRequest();
141
+ }
142
+ }
143
+
144
+ function roleSessionBoundPath(path: string) {
145
+ if (path.startsWith(applicationServiceBase())) return true;
146
+ const mount = runtimeMount();
147
+ return Boolean(
148
+ mount &&
149
+ path.startsWith(
150
+ `/service/openxiangda-app-api/v2/${encodeURIComponent(mount.appCode)}/${encodeURIComponent(mount.environmentKey)}/`
151
+ )
152
+ );
153
+ }
154
+
155
+ export async function requestApplicationApi<T>(
156
+ path: string,
157
+ init?: RequestInit
158
+ ) {
159
+ return await request<T>(applicationApiPath(path), init);
160
+ }
161
+
83
162
  export interface RuntimeIdentity {
84
163
  userId: string;
85
164
  roleCodes: string[];
86
165
  capabilityCodes: string[];
87
166
  isAppSuperAdmin: boolean;
167
+ identityScope: string;
168
+ subjectProfile: RoleSessionContext['subjectProfile'] | null;
169
+ currentRoleSubject: RoleSubjectChoice | null;
170
+ roleSubjectPage: RoleSubjectChoicePage | null;
171
+ roleSwitchingAvailable: boolean;
88
172
  environment: {
89
173
  id: string;
90
174
  key: 'preproduction' | 'production';
@@ -101,35 +185,217 @@ interface ConnectedCurrent {
101
185
  principal: Pick<RuntimeIdentity, 'userId' | 'roleCodes' | 'capabilityCodes' | 'isAppSuperAdmin'>;
102
186
  }
103
187
 
104
- interface DeployedCurrent {
105
- schemaVersion: 'openxiangda.current-user/v2';
106
- appCode: string;
107
- environment: RuntimeIdentity['environment'];
108
- principal: { type: 'user' } & Pick<RuntimeIdentity, 'userId' | 'roleCodes' | 'capabilityCodes' | 'isAppSuperAdmin'>;
188
+ export interface RuntimeAuthorization {
189
+ state: RoleSessionContext['state'];
190
+ identity: RuntimeIdentity | null;
191
+ roleContext: RoleSessionContext | null;
109
192
  }
110
193
 
111
194
  let activeIdentity: RuntimeIdentity | undefined;
195
+ let activeRoleSessionId: string | undefined;
112
196
 
113
- export async function loadCurrentIdentity(): Promise<RuntimeIdentity> {
197
+ function applicationServiceBase() {
198
+ return `/service/openxiangda-api/v2/applications/${applicationCode()}`;
199
+ }
200
+
201
+ function validateRoleContext(context: RoleSessionContext): RuntimeAuthorization {
202
+ const mount = runtimeMount();
203
+ if (
204
+ !mount ||
205
+ context.schemaVersion !== SCHEMA_VERSIONS.roleSessionContext ||
206
+ context.environment.key !== mount.environmentKey
207
+ ) {
208
+ throw new Error('OPENXIANGDA_ROLE_SESSION_CONTEXT_INVALID');
209
+ }
210
+ if (context.state !== 'active') {
211
+ if (context.roleSession || context.principal || context.currentRoleSubject) {
212
+ throw new Error('OPENXIANGDA_ROLE_SESSION_CONTEXT_INACTIVE_INVALID');
213
+ }
214
+ activeIdentity = undefined;
215
+ activeRoleSessionId = undefined;
216
+ return { state: context.state, identity: null, roleContext: context };
217
+ }
218
+ if (
219
+ !context.roleSession ||
220
+ !context.principal ||
221
+ !context.currentRoleSubject ||
222
+ !context.identityScope ||
223
+ !context.principal.activeRoleCode ||
224
+ context.principal.appCode !== mount.appCode ||
225
+ context.principal.roleSessionId !== context.roleSession.id ||
226
+ context.principal.activeRoleCode !== context.currentRoleSubject.role.code
227
+ ) {
228
+ throw new Error('OPENXIANGDA_ROLE_SESSION_CONTEXT_ACTIVE_INVALID');
229
+ }
230
+ activeRoleSessionId = context.roleSession.id;
231
+ activeIdentity = {
232
+ userId: context.principal.userId,
233
+ roleCodes: [context.principal.activeRoleCode],
234
+ capabilityCodes: context.principal.capabilities,
235
+ isAppSuperAdmin: context.principal.isAppSuperAdmin,
236
+ identityScope: context.identityScope,
237
+ subjectProfile: context.subjectProfile,
238
+ currentRoleSubject: context.currentRoleSubject,
239
+ roleSubjectPage: context.roleSubjectPage,
240
+ roleSwitchingAvailable: true,
241
+ environment: {
242
+ id: context.environment.id,
243
+ key: context.environment.key,
244
+ activeAppVersionId: context.environment.activeAppVersionId,
245
+ headRevision: context.environment.headRevision,
246
+ authzRevisionId: context.principal.authzRevisionId,
247
+ authzVersion: context.principal.authzVersion,
248
+ scopeDataVersion: context.principal.scopeDataVersion,
249
+ },
250
+ };
251
+ return { state: context.state, identity: activeIdentity, roleContext: context };
252
+ }
253
+
254
+ export async function loadRuntimeAuthorization(): Promise<RuntimeAuthorization> {
114
255
  const mount = runtimeMount();
115
256
  if (mount) {
116
- const current = await request<DeployedCurrent>(
117
- `${nativeBase()}/current-user?environmentKey=${encodeURIComponent(mount.environmentKey)}`
257
+ const context = await request<RoleSessionContext>(
258
+ `${nativeBase()}/authz/role-session/context?environmentKey=${encodeURIComponent(mount.environmentKey)}`
118
259
  );
260
+ return validateRoleContext(context);
261
+ }
262
+ const current = await request<ConnectedCurrent>(
263
+ `${applicationServiceBase()}/dev-sessions/current`
264
+ );
265
+ activeRoleSessionId = undefined;
266
+ activeIdentity = {
267
+ ...current.principal,
268
+ identityScope: `connected-dev:${applicationCode()}:${current.environment.key}:${current.principal.userId}`,
269
+ subjectProfile: null,
270
+ currentRoleSubject: null,
271
+ roleSubjectPage: null,
272
+ roleSwitchingAvailable: false,
273
+ environment: current.environment,
274
+ };
275
+ return { state: 'active', identity: activeIdentity, roleContext: null };
276
+ }
277
+
278
+ export async function loadRoleSubjects(input: {
279
+ keyword?: string;
280
+ cursor?: string;
281
+ limit?: number;
282
+ } = {}) {
283
+ const query = new URLSearchParams({
284
+ environmentKey: currentEnvironmentKey(),
285
+ limit: String(Math.min(Math.max(input.limit || 20, 1), 50)),
286
+ });
287
+ if (input.keyword?.trim()) query.set('keyword', input.keyword.trim());
288
+ if (input.cursor) query.set('cursor', input.cursor);
289
+ return request<RoleSubjectChoicePage>(
290
+ `${nativeBase()}/authz/role-session/context/role-subjects?${query}`
291
+ );
292
+ }
293
+
294
+ export async function switchRuntimeRole(
295
+ targetRoleSubjectKey: string,
296
+ expectedRoleSessionId: string | null
297
+ ) {
298
+ const context = await request<RoleSessionContext>(
299
+ `${nativeBase()}/authz/role-session/context/switch`,
300
+ {
301
+ method: 'POST',
302
+ body: JSON.stringify({
303
+ targetRoleSubjectKey,
304
+ expectedRoleSessionId,
305
+ environmentKey: currentEnvironmentKey(),
306
+ }),
307
+ }
308
+ );
309
+ const authorization = validateRoleContext(context);
310
+ if (!authorization.identity) {
311
+ throw new Error('OPENXIANGDA_ROLE_SESSION_SWITCH_INACTIVE');
312
+ }
313
+ publishRoleSessionInvalidation();
314
+ return authorization;
315
+ }
316
+
317
+ const ROLE_SESSION_CHANNEL = 'openxiangda.role-session-context/v2';
318
+ const ROLE_SESSION_STORAGE_KEY =
319
+ 'openxiangda.role-session-context-invalidated/v2';
320
+
321
+ type RoleSessionInvalidation = {
322
+ type: 'role-session-context-invalidated';
323
+ appCode: string;
324
+ environmentKey: 'preproduction' | 'production';
325
+ recordedAt: number;
326
+ };
327
+
328
+ function validRoleSessionInvalidation(
329
+ value: unknown
330
+ ): value is RoleSessionInvalidation {
331
+ if (!value || typeof value !== 'object') return false;
332
+ const candidate = value as Partial<RoleSessionInvalidation>;
333
+ return (
334
+ candidate.type === 'role-session-context-invalidated' &&
335
+ typeof candidate.appCode === 'string' &&
336
+ (candidate.environmentKey === 'preproduction' ||
337
+ candidate.environmentKey === 'production') &&
338
+ typeof candidate.recordedAt === 'number'
339
+ );
340
+ }
341
+
342
+ function publishRoleSessionInvalidation() {
343
+ const mount = runtimeMount();
344
+ if (!mount || typeof window === 'undefined') return;
345
+ const event: RoleSessionInvalidation = {
346
+ type: 'role-session-context-invalidated',
347
+ appCode: mount.appCode,
348
+ environmentKey: mount.environmentKey,
349
+ recordedAt: Date.now(),
350
+ };
351
+ try {
352
+ const channel = new BroadcastChannel(ROLE_SESSION_CHANNEL);
353
+ channel.postMessage(event);
354
+ channel.close();
355
+ } catch {
356
+ // Storage below is the cross-tab fallback.
357
+ }
358
+ try {
359
+ window.localStorage.setItem(ROLE_SESSION_STORAGE_KEY, JSON.stringify(event));
360
+ } catch {
361
+ // The active tab has already updated from the server response.
362
+ }
363
+ }
364
+
365
+ export function subscribeRoleSessionInvalidation(listener: () => void) {
366
+ const mount = runtimeMount();
367
+ if (!mount || typeof window === 'undefined') return () => undefined;
368
+ const receive = (value: unknown) => {
119
369
  if (
120
- current.schemaVersion !== 'openxiangda.current-user/v2' ||
121
- current.appCode !== mount.appCode ||
122
- current.environment.key !== mount.environmentKey ||
123
- current.principal.type !== 'user'
370
+ validRoleSessionInvalidation(value) &&
371
+ value.appCode === mount.appCode &&
372
+ value.environmentKey === mount.environmentKey
124
373
  ) {
125
- throw new Error('OPENXIANGDA_DEPLOYED_CURRENT_USER_INVALID');
374
+ activeIdentity = undefined;
375
+ activeRoleSessionId = undefined;
376
+ listener();
126
377
  }
127
- activeIdentity = { ...current.principal, environment: current.environment };
128
- return activeIdentity;
378
+ };
379
+ let channel: BroadcastChannel | undefined;
380
+ try {
381
+ channel = new BroadcastChannel(ROLE_SESSION_CHANNEL);
382
+ channel.onmessage = event => receive(event.data);
383
+ } catch {
384
+ channel = undefined;
129
385
  }
130
- const current = await request<ConnectedCurrent>(`/service/openxiangda-api/v2/applications/${applicationCode()}/dev-sessions/current`);
131
- activeIdentity = { ...current.principal, environment: current.environment };
132
- return activeIdentity;
386
+ const onStorage = (event: StorageEvent) => {
387
+ if (event.key !== ROLE_SESSION_STORAGE_KEY || !event.newValue) return;
388
+ try {
389
+ receive(JSON.parse(event.newValue));
390
+ } catch {
391
+ // Invalid browser signals never alter the server-owned identity.
392
+ }
393
+ };
394
+ window.addEventListener('storage', onStorage);
395
+ return () => {
396
+ channel?.close();
397
+ window.removeEventListener('storage', onStorage);
398
+ };
133
399
  }
134
400
 
135
401
  export async function logoutCurrentUser() {
@@ -137,6 +403,7 @@ export async function logoutCurrentUser() {
137
403
  method: 'POST',
138
404
  });
139
405
  activeIdentity = undefined;
406
+ activeRoleSessionId = undefined;
140
407
  }
141
408
 
142
409
  function currentEnvironmentKey() {
@@ -147,6 +414,23 @@ function currentEnvironmentKey() {
147
414
 
148
415
  export type DirectoryKind = 'user' | 'department';
149
416
 
417
+ export interface ChinaDivisionListItem {
418
+ adcode: string;
419
+ citycode?: string;
420
+ name: string;
421
+ level?: string;
422
+ center?: string;
423
+ parentAdcode?: string;
424
+ hasChildren: boolean;
425
+ }
426
+
427
+ export async function loadChinaDivisions(parentAdcode?: string) {
428
+ const params = new URLSearchParams();
429
+ if (parentAdcode) params.set('parentAdcode', parentAdcode);
430
+ const suffix = params.size ? `?${params}` : '';
431
+ return await request<ChinaDivisionListItem[]>(`/china-divisions/${suffix}`);
432
+ }
433
+
150
434
  export async function searchDirectory(kind: DirectoryKind, options: { keyword: string; cursor?: string }) {
151
435
  const params = new URLSearchParams({
152
436
  environmentKey: currentEnvironmentKey(),
@@ -193,66 +477,37 @@ export async function resolveDirectory(kind: DirectoryKind, ids: string[]) {
193
477
  );
194
478
  }
195
479
 
196
- export async function searchResource(resourceCode: string, labelField: string, options: { keyword: string; cursor?: string }) {
197
- const pageSize = 20;
198
- const offset = options.cursor ? Number(options.cursor) : 0;
199
- const query: DataQuery = {
200
- schemaVersion: SCHEMA_VERSIONS.dataQuery,
201
- filters: options.keyword.trim()
202
- ? [
203
- {
204
- field: labelField,
205
- operator: 'ilike',
206
- value: `%${options.keyword.trim()}%`,
207
- },
208
- ]
209
- : [],
210
- order: [{ field: labelField, direction: 'asc' }],
211
- limit: pageSize,
212
- offset: Number.isSafeInteger(offset) && offset >= 0 ? offset : 0,
480
+ export async function searchResource(
481
+ resourceCode: string,
482
+ fieldCode: string,
483
+ options: {
484
+ operation: 'create' | 'update';
485
+ keyword: string;
486
+ cursor?: string;
487
+ bindings?: Record<string, unknown>;
488
+ }
489
+ ) {
490
+ const query: DataFieldSourceQuery = {
491
+ schemaVersion: SCHEMA_VERSIONS.dataFieldSourceQuery,
492
+ operation: options.operation,
493
+ ...(options.keyword.trim() ? { keyword: options.keyword.trim() } : {}),
494
+ ...(options.cursor ? { cursor: options.cursor } : {}),
495
+ ...(options.bindings && Object.keys(options.bindings).length
496
+ ? { bindings: options.bindings }
497
+ : {}),
213
498
  };
214
- const page = await request<DataPage<Record<string, unknown>>>(`${dataBase(resourceCode)}/query`, {
499
+ return await request<DataFieldSourcePage>(
500
+ `${nativeBase()}/data-resources/${encodeURIComponent(
501
+ resourceCode
502
+ )}/fields/${encodeURIComponent(fieldCode)}/source/query`,
503
+ {
215
504
  method: 'POST',
216
505
  body: JSON.stringify({
217
506
  ...query,
218
507
  environmentKey: currentEnvironmentKey(),
219
508
  }),
220
- });
221
- return {
222
- items: page.items.map((item) => ({
223
- id: String(item.id),
224
- label: String(item[labelField] ?? item.id),
225
- selectable: true,
226
- })),
227
- nextCursor: page.items.length >= pageSize ? String(page.offset + page.items.length) : null,
228
- };
229
- }
230
-
231
- export async function resolveResource(resourceCode: string, ids: string[], labelField: string) {
232
- if (ids.length === 0) throw new Error('OPENXIANGDA_RESOURCE_RESOLVE_IDS_REQUIRED');
233
- if (ids.length > 50) throw new Error('OPENXIANGDA_RESOURCE_RESOLVE_IDS_EXCEEDED');
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
- })
509
+ }
245
510
  );
246
- return { items, nextCursor: null };
247
- }
248
-
249
- export interface GenericResourceQuery {
250
- page: number;
251
- pageSize: number;
252
- keyword?: string;
253
- searchField?: string;
254
- filters?: Record<string, unknown>;
255
- sort?: { field: string; order: 'asc' | 'desc' };
256
511
  }
257
512
 
258
513
  export function createNativeResourceClient(code: string, surface: DataResourceSurface) {
@@ -262,38 +517,6 @@ export function createNativeResourceClient(code: string, surface: DataResourceSu
262
517
  if (!declaredFields.has(field)) throw new Error(`OPENXIANGDA_RESOURCE_QUERY_FIELD_NOT_DECLARED:${code}:${field}`);
263
518
  return field;
264
519
  };
265
- const filtersFor = (query: GenericResourceQuery) => {
266
- const filters: NonNullable<DataQuery['filters']> = [];
267
- if (query.keyword?.trim()) {
268
- const searchable = surface.list?.searchableFields || [];
269
- const field = searchable.includes(query.searchField || '') ? query.searchField! : searchable[0];
270
- if (field)
271
- filters.push({
272
- field: assertField(field),
273
- operator: 'ilike',
274
- value: `%${query.keyword.trim()}%`,
275
- });
276
- }
277
- for (const [fieldCode, value] of Object.entries(query.filters || {})) {
278
- if (value === undefined || value === null || value === '') continue;
279
- // A list state can outlive a resource route during client-side navigation.
280
- // Ignore filters from the previous resource instead of constructing an
281
- // invalid Native Data query for the current resource.
282
- if (!declaredFields.has(fieldCode)) continue;
283
- const field = surface.fields[fieldCode];
284
- if (Array.isArray(value) && value.length === 2 && ['date', 'datetime', 'number', 'money', 'percent'].includes(field?.widget || '')) {
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] });
289
- } else if (Array.isArray(value) && ['multi', 'directory-user', 'directory-department', 'resource'].includes(field?.widget || '')) {
290
- filters.push({ field: fieldCode, operator: 'cs', value });
291
- } else {
292
- filters.push({ field: fieldCode, operator: 'eq', value });
293
- }
294
- }
295
- return filters;
296
- };
297
520
  return {
298
521
  async list(query: GenericResourceQuery) {
299
522
  const fallbackSort = surface.list?.defaultSort || {
@@ -302,9 +525,10 @@ export function createNativeResourceClient(code: string, surface: DataResourceSu
302
525
  };
303
526
  const requestedSort = query.sort || fallbackSort;
304
527
  const sort = declaredFields.has(requestedSort.field) ? requestedSort : fallbackSort;
528
+ const where = buildResourceWhere(code, surface, query);
305
529
  const body: DataQuery = {
306
530
  schemaVersion: SCHEMA_VERSIONS.dataQuery,
307
- filters: filtersFor(query),
531
+ ...(where ? { where } : {}),
308
532
  order: [{ field: sort.field, direction: sort.order || 'asc' }],
309
533
  limit: query.pageSize,
310
534
  offset: (query.page - 1) * query.pageSize,
@@ -318,6 +542,28 @@ export function createNativeResourceClient(code: string, surface: DataResourceSu
318
542
  });
319
543
  return { rows: page.items, total: page.total };
320
544
  },
545
+ async exportCsv(query: GenericResourceQuery, select: string[]) {
546
+ const fallbackSort = surface.list?.defaultSort || {
547
+ field: Object.keys(surface.fields)[0] || 'id',
548
+ order: 'asc' as const,
549
+ };
550
+ const requestedSort = query.sort || fallbackSort;
551
+ const sort = declaredFields.has(requestedSort.field) ? requestedSort : fallbackSort;
552
+ const where = buildResourceWhere(code, surface, query);
553
+ const body: DataExportRequest = {
554
+ schemaVersion: SCHEMA_VERSIONS.dataExportRequest,
555
+ select: select.map(assertField),
556
+ ...(where ? { where } : {}),
557
+ order: [{ field: sort.field, direction: sort.order || 'asc' }],
558
+ };
559
+ return await requestBlob(`${base}/export`, {
560
+ method: 'POST',
561
+ body: JSON.stringify({
562
+ ...body,
563
+ environmentKey: currentEnvironmentKey(),
564
+ }),
565
+ });
566
+ },
321
567
  async get(id: string) {
322
568
  const record = await request<DataRecord<Record<string, unknown>>>(
323
569
  `${base}/records/${encodeURIComponent(id)}?environmentKey=${encodeURIComponent(currentEnvironmentKey())}`
@@ -418,6 +664,10 @@ export function dataFileContentUrl(resource: string, fileId: string, disposition
418
664
  return `${dataBase(resource)}/files/${encodeURIComponent(fileId)}/content?${query}`;
419
665
  }
420
666
 
667
+ export function dataRichTextImageSource(resource: string, fileId: string) {
668
+ return `${dataBase(resource)}/files/${encodeURIComponent(fileId)}/content?disposition=inline`;
669
+ }
670
+
421
671
  export async function loadDataFilePreview(resource: string, fileId: string) {
422
672
  return request<DataFilePreview>(
423
673
  `${dataBase(resource)}/files/${encodeURIComponent(fileId)}/preview?environmentKey=${encodeURIComponent(currentEnvironmentKey())}`
@@ -425,10 +675,34 @@ export async function loadDataFilePreview(resource: string, fileId: string) {
425
675
  }
426
676
 
427
677
  export async function fetchDataFileBlob(resource: string, fileId: string) {
678
+ const headers = new Headers({ accept: 'application/octet-stream,*/*' });
679
+ if (activeRoleSessionId) {
680
+ headers.set('x-openxiangda-role-session-id', activeRoleSessionId);
681
+ }
428
682
  const response = await fetch(dataFileContentUrl(resource, fileId, 'inline'), {
429
683
  credentials: 'include',
430
- headers: { accept: 'application/octet-stream,*/*' },
684
+ headers,
431
685
  });
432
686
  if (!response.ok) throw new Error(`FILE_CONTENT_READ_FAILED: HTTP_${response.status}`);
433
687
  return response.blob();
434
688
  }
689
+
690
+ export async function downloadDataFile(
691
+ resource: string,
692
+ fileId: string,
693
+ fileName: string
694
+ ) {
695
+ const blob = await fetchDataFileBlob(resource, fileId);
696
+ const url = URL.createObjectURL(blob);
697
+ try {
698
+ const anchor = document.createElement('a');
699
+ anchor.href = url;
700
+ anchor.download = fileName;
701
+ anchor.rel = 'noopener';
702
+ document.body.append(anchor);
703
+ anchor.click();
704
+ anchor.remove();
705
+ } finally {
706
+ window.setTimeout(() => URL.revokeObjectURL(url), 0);
707
+ }
708
+ }