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
@@ -1,44 +1,114 @@
1
- import { Alert, Button, Result, Spin } from 'antd';
2
- import { createContext, useContext, useEffect, useMemo, useState } from 'react';
3
- import { loadCurrentIdentity, type RuntimeIdentity } from './platform-client';
1
+ import { Alert, Button, Result, Space, Spin, Typography } from 'antd';
2
+ import {
3
+ createContext,
4
+ useCallback,
5
+ useContext,
6
+ useEffect,
7
+ useMemo,
8
+ useState,
9
+ } from 'react';
10
+ import {
11
+ loadRuntimeAuthorization,
12
+ subscribeRoleSessionInvalidation,
13
+ switchRuntimeRole,
14
+ type RuntimeAuthorization,
15
+ type RuntimeIdentity,
16
+ } from './platform-client';
17
+ import { RoleSubjectSelect } from './RoleSubjectSelect';
4
18
  import { runtimeMount } from './runtime-meta';
5
19
 
6
20
  interface RuntimeValue {
7
21
  identity: RuntimeIdentity;
22
+ authorization: RuntimeAuthorization;
23
+ identityEpoch: number;
24
+ switchingRole: boolean;
8
25
  hasCapability(code: string): boolean;
26
+ switchRole(targetRoleSubjectKey: string): Promise<void>;
9
27
  }
10
28
 
11
29
  const RuntimeContext = createContext<RuntimeValue | null>(null);
12
30
 
13
31
  export function RuntimeBoundary({ children }: { children: React.ReactNode }) {
14
- const [identity, setIdentity] = useState<RuntimeIdentity>();
32
+ const [authorization, setAuthorization] = useState<RuntimeAuthorization>();
15
33
  const [error, setError] = useState<Error>();
16
34
  const [attempt, setAttempt] = useState(0);
35
+ const [identityEpoch, setIdentityEpoch] = useState(0);
36
+ const [switchingRole, setSwitchingRole] = useState(false);
37
+
38
+ useEffect(
39
+ () =>
40
+ subscribeRoleSessionInvalidation(() => {
41
+ setAuthorization(undefined);
42
+ setError(undefined);
43
+ setAttempt(value => value + 1);
44
+ }),
45
+ []
46
+ );
47
+
17
48
  useEffect(() => {
18
49
  let active = true;
50
+ setAuthorization(undefined);
19
51
  setError(undefined);
20
- void loadCurrentIdentity().then(
21
- value => active && setIdentity(value),
22
- reason => active && setError(reason instanceof Error ? reason : new Error(String(reason)))
52
+ void loadRuntimeAuthorization().then(
53
+ value => {
54
+ if (!active) return;
55
+ setAuthorization(value);
56
+ setIdentityEpoch(epoch => epoch + 1);
57
+ },
58
+ reason =>
59
+ active &&
60
+ setError(reason instanceof Error ? reason : new Error(String(reason)))
23
61
  );
24
62
  return () => {
25
63
  active = false;
26
64
  };
27
65
  }, [attempt]);
66
+
67
+ const switchRole = useCallback(
68
+ async (targetRoleSubjectKey: string) => {
69
+ if (!authorization?.roleContext) {
70
+ throw new Error('OPENXIANGDA_ROLE_SWITCH_UNAVAILABLE');
71
+ }
72
+ setSwitchingRole(true);
73
+ try {
74
+ const next = await switchRuntimeRole(
75
+ targetRoleSubjectKey,
76
+ authorization.roleContext.roleSession?.id || null
77
+ );
78
+ setAuthorization(next);
79
+ setIdentityEpoch(epoch => epoch + 1);
80
+ } catch (reason) {
81
+ setAuthorization(undefined);
82
+ setAttempt(value => value + 1);
83
+ throw reason;
84
+ } finally {
85
+ setSwitchingRole(false);
86
+ }
87
+ },
88
+ [authorization]
89
+ );
90
+
91
+ const identity = authorization?.identity || null;
28
92
  const value = useMemo<RuntimeValue | null>(
29
93
  () =>
30
- identity
94
+ identity && authorization
31
95
  ? {
32
96
  identity,
97
+ authorization,
98
+ identityEpoch,
99
+ switchingRole,
33
100
  hasCapability: code =>
34
- identity.isAppSuperAdmin || identity.capabilityCodes.includes(code),
101
+ identity.isAppSuperAdmin ||
102
+ identity.capabilityCodes.includes(code),
103
+ switchRole,
35
104
  }
36
105
  : null,
37
- [identity]
106
+ [authorization, identity, identityEpoch, switchRole, switchingRole]
38
107
  );
39
108
  const production =
40
109
  identity?.environment.key === 'production' ||
41
110
  runtimeMount()?.environmentKey === 'production';
111
+
42
112
  return (
43
113
  <>
44
114
  {production && (
@@ -53,21 +123,103 @@ export function RuntimeBoundary({ children }: { children: React.ReactNode }) {
53
123
  {error ? (
54
124
  <Result
55
125
  status="error"
56
- title="无法读取平台当前用户"
126
+ title="无法读取当前应用角色"
57
127
  subTitle={error.message}
58
- extra={<Button onClick={() => setAttempt(value => value + 1)}>重试</Button>}
128
+ extra={
129
+ <Button onClick={() => setAttempt(value => value + 1)}>
130
+ 重试
131
+ </Button>
132
+ }
133
+ />
134
+ ) : authorization?.state === 'selection_required' &&
135
+ authorization.roleContext ? (
136
+ <InitialRoleSelection
137
+ authorization={authorization}
138
+ switching={switchingRole}
139
+ onSelect={switchRole}
140
+ />
141
+ ) : authorization?.state === 'unassigned' ? (
142
+ <Result
143
+ status="403"
144
+ title="尚未分配应用角色"
145
+ subTitle="请联系应用管理员分配当前环境中的应用角色后重试。"
146
+ extra={
147
+ <Button onClick={() => setAttempt(value => value + 1)}>
148
+ 重新检查
149
+ </Button>
150
+ }
59
151
  />
60
152
  ) : value ? (
61
- <RuntimeContext.Provider value={value}>{children}</RuntimeContext.Provider>
153
+ <RuntimeContext.Provider
154
+ key={`${value.identity.identityScope}:${identityEpoch}`}
155
+ value={value}
156
+ >
157
+ {children}
158
+ </RuntimeContext.Provider>
62
159
  ) : (
63
160
  <div className="oxa-loading">
64
- <Spin description="正在读取平台身份与权限并集" />
161
+ <Spin description="正在读取当前应用角色与权限" />
65
162
  </div>
66
163
  )}
67
164
  </>
68
165
  );
69
166
  }
70
167
 
168
+ function InitialRoleSelection({
169
+ authorization,
170
+ switching,
171
+ onSelect,
172
+ }: {
173
+ authorization: RuntimeAuthorization;
174
+ switching: boolean;
175
+ onSelect: (targetRoleSubjectKey: string) => Promise<void>;
176
+ }) {
177
+ const [selected, setSelected] = useState('');
178
+ const [selectionError, setSelectionError] = useState('');
179
+ const roleSubjectPage = authorization.roleContext?.roleSubjectPage;
180
+ const submit = async () => {
181
+ if (!selected) return;
182
+ setSelectionError('');
183
+ try {
184
+ await onSelect(selected);
185
+ } catch (reason) {
186
+ setSelectionError(
187
+ reason instanceof Error ? reason.message : '应用角色切换失败'
188
+ );
189
+ }
190
+ };
191
+ return (
192
+ <Result
193
+ status="info"
194
+ title="选择当前应用角色"
195
+ subTitle="本次应用访问只使用一个角色;进入应用后仍可从个人菜单切换。"
196
+ extra={
197
+ <Space align="start" direction="vertical" size="middle">
198
+ {roleSubjectPage && (
199
+ <RoleSubjectSelect
200
+ disabled={switching}
201
+ initialPage={roleSubjectPage}
202
+ onChange={setSelected}
203
+ value={selected}
204
+ />
205
+ )}
206
+ {selectionError && (
207
+ <Typography.Text type="danger">{selectionError}</Typography.Text>
208
+ )}
209
+ <Button
210
+ disabled={!selected}
211
+ loading={switching}
212
+ onClick={() => void submit()}
213
+ type="primary"
214
+ >
215
+ 进入应用
216
+ </Button>
217
+ </Space>
218
+ }
219
+ />
220
+ );
221
+ }
222
+
71
223
  export function useRuntime() {
72
224
  const value = useContext(RuntimeContext);
73
225
  if (!value) throw new Error('OPENXIANGDA_RUNTIME_NOT_READY');
@@ -314,6 +314,33 @@ body {
314
314
  box-shadow: none;
315
315
  }
316
316
 
317
+ .oxa-role-switch-select {
318
+ width: 100%;
319
+ }
320
+
321
+ .oxa-role-switch-option {
322
+ display: grid;
323
+ min-width: 0;
324
+ gap: 2px;
325
+ padding: 2px 0;
326
+ }
327
+
328
+ .oxa-role-switch-option strong,
329
+ .oxa-role-switch-option span {
330
+ overflow: hidden;
331
+ text-overflow: ellipsis;
332
+ white-space: nowrap;
333
+ }
334
+
335
+ .oxa-role-switch-option span {
336
+ color: var(--oxa-shell-text-secondary, #595959);
337
+ font-size: 12px;
338
+ }
339
+
340
+ .oxa-role-switch-help {
341
+ margin: 10px 0 0 !important;
342
+ }
343
+
317
344
  .oxa-route-history {
318
345
  position: sticky;
319
346
  z-index: 14;
@@ -978,6 +1005,13 @@ body {
978
1005
  color: var(--oxa-shell-primary, #1677ff);
979
1006
  }
980
1007
 
1008
+ .oxa-file-thumbnail {
1009
+ display: block;
1010
+ width: 32px;
1011
+ height: 32px;
1012
+ object-fit: cover;
1013
+ }
1014
+
981
1015
  .oxa-file-meta {
982
1016
  display: grid;
983
1017
  min-width: 0;
@@ -1033,6 +1067,236 @@ body {
1033
1067
  text-align: center;
1034
1068
  }
1035
1069
 
1070
+ .oxa-location-field {
1071
+ display: grid;
1072
+ gap: 10px;
1073
+ }
1074
+
1075
+ .oxa-location-field .ant-alert {
1076
+ max-width: 520px;
1077
+ }
1078
+
1079
+ .oxa-location-value {
1080
+ display: inline-grid;
1081
+ max-width: 100%;
1082
+ gap: 2px;
1083
+ }
1084
+
1085
+ .oxa-location-value > span {
1086
+ overflow-wrap: anywhere;
1087
+ }
1088
+
1089
+ .oxa-location-value-readonly {
1090
+ min-width: 160px;
1091
+ }
1092
+
1093
+ .oxa-address-field {
1094
+ display: grid;
1095
+ gap: 8px;
1096
+ width: 100%;
1097
+ }
1098
+
1099
+ .oxa-address-field > .ant-cascader,
1100
+ .oxa-address-field > .ant-select {
1101
+ width: 100%;
1102
+ }
1103
+
1104
+ .oxa-address-value {
1105
+ display: inline-flex;
1106
+ flex-direction: column;
1107
+ gap: 2px;
1108
+ }
1109
+
1110
+ .oxa-mobile-address-field {
1111
+ grid-template-columns: minmax(0, 1fr) auto;
1112
+ }
1113
+
1114
+ .oxa-mobile-address-field > .ant-btn:first-child {
1115
+ min-width: 0;
1116
+ overflow: hidden;
1117
+ text-align: left;
1118
+ text-overflow: ellipsis;
1119
+ white-space: nowrap;
1120
+ }
1121
+
1122
+ .oxa-address-mobile-levels {
1123
+ display: grid;
1124
+ gap: 12px;
1125
+ }
1126
+
1127
+ .oxa-address-mobile-levels > .ant-select {
1128
+ width: 100%;
1129
+ }
1130
+
1131
+ .oxa-address-mobile-actions {
1132
+ display: flex;
1133
+ justify-content: flex-end;
1134
+ margin-top: 20px;
1135
+ width: 100%;
1136
+ }
1137
+
1138
+ .oxa-rich-text-field,
1139
+ .oxa-json-field {
1140
+ display: grid;
1141
+ gap: 8px;
1142
+ width: 100%;
1143
+ }
1144
+
1145
+ .oxa-rich-text-toolbar {
1146
+ border: 1px solid var(--oxa-shell-border-strong, #d9d9d9);
1147
+ border-bottom: 0;
1148
+ border-radius: 6px 6px 0 0;
1149
+ padding: 6px;
1150
+ }
1151
+
1152
+ .oxa-rich-text-editor {
1153
+ border: 1px solid var(--oxa-shell-border-strong, #d9d9d9);
1154
+ border-radius: 0 0 6px 6px;
1155
+ min-height: 160px;
1156
+ overflow-wrap: anywhere;
1157
+ padding: 10px 12px;
1158
+ }
1159
+
1160
+ .oxa-rich-text-editor:focus {
1161
+ border-color: var(--oxa-shell-primary, #1677ff);
1162
+ box-shadow: 0 0 0 2px rgb(5 145 255 / 10%);
1163
+ outline: 0;
1164
+ }
1165
+
1166
+ .oxa-rich-text-value {
1167
+ max-width: 100%;
1168
+ overflow-wrap: anywhere;
1169
+ }
1170
+
1171
+ .oxa-rich-text-value img {
1172
+ height: auto;
1173
+ max-width: 100%;
1174
+ }
1175
+
1176
+ .oxa-mobile-rich-text-field .oxa-rich-text-editor {
1177
+ min-height: 220px;
1178
+ }
1179
+
1180
+ .oxa-json-field textarea,
1181
+ .oxa-json-value {
1182
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
1183
+ }
1184
+
1185
+ .oxa-json-value {
1186
+ margin: 0;
1187
+ max-height: 320px;
1188
+ max-width: 100%;
1189
+ overflow: auto;
1190
+ white-space: pre-wrap;
1191
+ word-break: break-word;
1192
+ }
1193
+
1194
+ .oxa-json-valid {
1195
+ color: var(--oxa-shell-success, #389e0d);
1196
+ }
1197
+
1198
+ .oxa-signature-field,
1199
+ .oxa-signature-value,
1200
+ .oxa-signature-pad {
1201
+ display: grid;
1202
+ gap: 10px;
1203
+ width: 100%;
1204
+ }
1205
+
1206
+ .oxa-signature-value {
1207
+ justify-items: start;
1208
+ }
1209
+
1210
+ .oxa-signature-image {
1211
+ max-height: 180px;
1212
+ max-width: min(100%, 520px);
1213
+ object-fit: contain;
1214
+ }
1215
+
1216
+ .oxa-signature-hash {
1217
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
1218
+ overflow-wrap: anywhere;
1219
+ }
1220
+
1221
+ .oxa-signature-canvas {
1222
+ background: #fff;
1223
+ border: 1px solid var(--oxa-shell-border-strong, #d9d9d9);
1224
+ border-radius: 6px;
1225
+ height: 240px;
1226
+ touch-action: none;
1227
+ user-select: none;
1228
+ width: 100%;
1229
+ }
1230
+
1231
+ .oxa-subtable-field {
1232
+ width: 100%;
1233
+ min-width: 0;
1234
+ }
1235
+
1236
+ .oxa-subtable-toolbar {
1237
+ display: flex;
1238
+ min-height: 32px;
1239
+ align-items: center;
1240
+ justify-content: space-between;
1241
+ margin-bottom: 8px;
1242
+ }
1243
+
1244
+ .oxa-subtable-loading {
1245
+ display: flex;
1246
+ min-height: 88px;
1247
+ align-items: center;
1248
+ justify-content: center;
1249
+ }
1250
+
1251
+ .oxa-mobile-subtable-list {
1252
+ border-top: 1px solid #e5e7eb;
1253
+ }
1254
+
1255
+ .oxa-mobile-subtable-row {
1256
+ padding: 12px 0;
1257
+ border-bottom: 1px solid #e5e7eb;
1258
+ }
1259
+
1260
+ .oxa-mobile-subtable-values {
1261
+ display: grid;
1262
+ gap: 10px;
1263
+ margin-bottom: 8px;
1264
+ }
1265
+
1266
+ .oxa-mobile-subtable-values > div {
1267
+ display: grid;
1268
+ grid-template-columns: minmax(88px, 32%) minmax(0, 1fr);
1269
+ gap: 8px;
1270
+ align-items: start;
1271
+ }
1272
+
1273
+ .oxa-mobile-subtable-editor {
1274
+ display: grid;
1275
+ gap: 4px;
1276
+ }
1277
+
1278
+ .oxa-mobile-number-field {
1279
+ align-items: center;
1280
+ border: 1px solid var(--oxa-shell-border-strong, #d9d9d9);
1281
+ border-radius: 6px;
1282
+ display: grid;
1283
+ gap: 8px;
1284
+ grid-template-columns: auto minmax(0, 1fr) auto;
1285
+ min-height: 40px;
1286
+ padding: 0 10px;
1287
+ }
1288
+
1289
+ .oxa-mobile-number-field > input {
1290
+ border: 0;
1291
+ min-width: 0;
1292
+ outline: 0;
1293
+ width: 100%;
1294
+ }
1295
+
1296
+ .oxa-mobile-location-field .ant-space {
1297
+ width: 100%;
1298
+ }
1299
+
1036
1300
  .oxa-dictionary-drawer .ant-drawer-body {
1037
1301
  padding: 22px;
1038
1302
  }
@@ -0,0 +1,66 @@
1
+ import assert from 'node:assert/strict';
2
+ import { readFileSync } from 'node:fs';
3
+ import test from 'node:test';
4
+ import {
5
+ addressControlValue,
6
+ addressDisplay,
7
+ addressStoredValue,
8
+ deepestAddressPredicate,
9
+ } from '../src/components/platform-fields/address-value';
10
+
11
+ const path = [
12
+ { label: '浙江省', value: '330000' },
13
+ { label: '杭州市', value: '330100' },
14
+ { label: '西湖区', value: '330106' },
15
+ { label: '翠苑街道', value: '330106012' },
16
+ ];
17
+
18
+ test('stores a complete administrative snapshot with a stable full address', () => {
19
+ const value = addressStoredValue(path, '文一路 1 号');
20
+ assert.deepEqual(value, {
21
+ country: { label: '中国', value: '100000' },
22
+ province: path[0],
23
+ city: path[1],
24
+ district: path[2],
25
+ street: path[3],
26
+ detail: '文一路 1 号',
27
+ fullAddress: '浙江省杭州市西湖区翠苑街道文一路 1 号',
28
+ });
29
+ assert.deepEqual(addressControlValue(value), path.map(item => item.value));
30
+ assert.equal(addressDisplay(value), '浙江省杭州市西湖区翠苑街道文一路 1 号');
31
+ });
32
+
33
+ test('deduplicates adjacent municipality labels while retaining both snapshots', () => {
34
+ const value = addressStoredValue([
35
+ { label: '上海市', value: '310000' },
36
+ { label: '上海市', value: '310100' },
37
+ { label: '浦东新区', value: '310115' },
38
+ ]);
39
+ assert.equal(value?.fullAddress, '上海市浦东新区');
40
+ assert.equal(value?.province?.value, '310000');
41
+ assert.equal(value?.city?.value, '310100');
42
+ });
43
+
44
+ test('uses the deepest selected stable code for Data API filtering', () => {
45
+ assert.deepEqual(
46
+ deepestAddressPredicate(addressStoredValue(path.slice(0, 3))),
47
+ { path: 'district.value', value: '330106' }
48
+ );
49
+ assert.equal(addressStoredValue([], 'address only'), undefined);
50
+ });
51
+
52
+ test('address field has lazy desktop and explicit mobile controls', () => {
53
+ const field = readFileSync(
54
+ new URL('../src/components/platform-fields/AddressField.tsx', import.meta.url),
55
+ 'utf8'
56
+ );
57
+ const client = readFileSync(
58
+ new URL('../src/platform-client.ts', import.meta.url),
59
+ 'utf8'
60
+ );
61
+ assert.match(field, /loadData=/);
62
+ assert.match(field, /loadChinaDivisions/);
63
+ assert.match(field, /placement="bottom"/);
64
+ assert.match(field, /ADDRESS_LEVELS\.map/);
65
+ assert.match(client, /\/china-divisions\//);
66
+ });
@@ -0,0 +1,68 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import type { DataFieldOption } from 'openxiangda-contracts/browser';
4
+ import {
5
+ cascadeControlValue,
6
+ cascadeStoredValue,
7
+ cascadeTerminalValues,
8
+ } from '../src/components/platform-fields/cascade-value';
9
+
10
+ const options: DataFieldOption[] = [
11
+ {
12
+ value: 'equipment',
13
+ label: '设备',
14
+ children: [
15
+ {
16
+ value: 'optical',
17
+ label: '光学设备',
18
+ children: [{ value: 'microscope', label: '显微镜' }],
19
+ },
20
+ { value: 'electrical', label: '电气设备' },
21
+ ],
22
+ },
23
+ ];
24
+
25
+ test('stores every labeled node for a cascade single path', () => {
26
+ const value = cascadeStoredValue(
27
+ options,
28
+ ['equipment', 'optical', 'microscope'],
29
+ false
30
+ );
31
+ assert.deepEqual(value, [
32
+ { value: 'equipment', label: '设备' },
33
+ { value: 'optical', label: '光学设备' },
34
+ { value: 'microscope', label: '显微镜' },
35
+ ]);
36
+ assert.deepEqual(cascadeControlValue(value, false), [
37
+ 'equipment',
38
+ 'optical',
39
+ 'microscope',
40
+ ]);
41
+ assert.deepEqual(cascadeTerminalValues(value, false), ['microscope']);
42
+ });
43
+
44
+ test('stores independent complete paths for cascade multiple', () => {
45
+ const value = cascadeStoredValue(
46
+ options,
47
+ [
48
+ ['equipment', 'optical', 'microscope'],
49
+ ['equipment', 'electrical'],
50
+ ],
51
+ true
52
+ );
53
+ assert.deepEqual(cascadeTerminalValues(value, true), [
54
+ 'microscope',
55
+ 'electrical',
56
+ ]);
57
+ assert.deepEqual(cascadeControlValue(value, true), [
58
+ ['equipment', 'optical', 'microscope'],
59
+ ['equipment', 'electrical'],
60
+ ]);
61
+ });
62
+
63
+ test('drops forged paths that do not exist in declared options', () => {
64
+ assert.equal(
65
+ cascadeStoredValue(options, ['equipment', 'missing'], false),
66
+ undefined
67
+ );
68
+ });