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.
- package/package.json +4 -4
- package/template/AGENTS.md +13 -7
- package/template/README.md +8 -8
- package/template/apps/server/package.json +1 -1
- package/template/apps/web/package.json +1 -1
- package/template/apps/web/scripts/check.mjs +18 -13
- package/template/apps/web/src/components/resource/GeneratedResourceCrud.tsx +339 -163
- package/template/apps/web/src/components/resource/ResourceBatchActions.tsx +319 -0
- package/template/apps/web/src/components/resource/resource-import.ts +205 -0
- package/template/apps/web/src/platform-client.ts +188 -40
- package/template/apps/web/src/styles.css +37 -8
- package/template/apps/web/test/contracts.test.ts +16 -6
- package/template/apps/web/test/resource-import.test.ts +142 -0
- package/template/openxiangda.config.ts +2 -1
- package/template/package.json +4 -3
- package/template/scripts/verify-template-budget.mjs +5 -0
|
@@ -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
|
|
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>(
|
|
107
|
-
|
|
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({
|
|
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({
|
|
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({
|
|
148
|
-
|
|
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 = {
|
|
156
|
-
|
|
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()
|
|
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`, {
|
|
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 => ({
|
|
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(
|
|
180
|
-
|
|
181
|
-
|
|
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)
|
|
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] !== '')
|
|
218
|
-
|
|
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 || {
|
|
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 = {
|
|
233
|
-
|
|
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>>>(
|
|
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>(
|
|
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`, {
|
|
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`, {
|
|
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`, {
|
|
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`, {
|
|
258
|
-
|
|
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`, {
|
|
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({
|
|
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>(
|
|
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'), {
|
|
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
|
-
|
|
13
|
-
|
|
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
|
|
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(
|
|
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
|
|
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(
|
|
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,
|
|
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(
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
+
});
|