openxiangda-cli 2.0.0-alpha.169 → 2.0.0-alpha.170

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/package.json +5 -5
  2. package/template/AGENTS.md +38 -24
  3. package/template/README.md +14 -17
  4. package/template/apps/web/e2e/component-defaults-fixture.css +15 -0
  5. package/template/apps/web/e2e/data-api-live-fixture.tsx +3 -3
  6. package/template/apps/web/e2e/field-protocol-fixture.tsx +48 -11
  7. package/template/apps/web/e2e/field-protocol.spec.ts +90 -0
  8. package/template/apps/web/e2e/mobile-files.spec.ts +93 -0
  9. package/template/apps/web/e2e/mobile-reference-fixture.tsx +314 -0
  10. package/template/apps/web/e2e/mobile-reference.spec.ts +360 -0
  11. package/template/apps/web/e2e/mobile-selection.spec.ts +199 -0
  12. package/template/apps/web/e2e/resource-experience-fixture.tsx +54 -9
  13. package/template/apps/web/e2e/resource-platform-mock.ts +202 -0
  14. package/template/apps/web/e2e/resources.spec.ts +77 -240
  15. package/template/apps/web/e2e/standard-admin.spec.ts +441 -0
  16. package/template/apps/web/e2e/workflow.spec.ts +2 -2
  17. package/template/apps/web/field-protocol.e2e.html +7 -0
  18. package/template/apps/web/mobile-reference.e2e.html +27 -0
  19. package/template/apps/web/package.json +1 -1
  20. package/template/apps/web/resource-experience.e2e.html +1 -0
  21. package/template/apps/web/src/document.css +5 -0
  22. package/template/apps/web/src/main.tsx +1 -0
  23. package/template/apps/web/src/user-end/tailwind.css +1 -13
  24. package/template/apps/web/test/contracts.test.ts +7 -6
  25. package/template/apps/web/vite.config.ts +1 -0
  26. package/template/appspec/app.md +27 -1
  27. package/template/openxiangda.config.ts +2 -24
  28. package/template/package.json +3 -4
  29. package/template/apps/server/Dockerfile +0 -20
  30. package/template/apps/server/package.json +0 -27
  31. package/template/apps/server/src/app.module.ts +0 -23
  32. package/template/apps/server/src/main.ts +0 -5
  33. package/template/apps/server/test/smoke.test.ts +0 -17
  34. package/template/apps/server/tsconfig.json +0 -9
  35. package/template/apps/web/e2e/theme-contract-fixture.css +0 -15
  36. package/template/scripts/verify-template-budget.mjs +0 -49
@@ -1,4 +1,5 @@
1
- import { expect, test, type Page } from '@playwright/test';
1
+ import { expect, test } from '@playwright/test';
2
+ import { mockPlatform } from './resource-platform-mock';
2
3
  import {
3
4
  appCode,
4
5
  capabilities,
@@ -42,204 +43,6 @@ const semanticResourceItem = {
42
43
  displayOrder: 0,
43
44
  };
44
45
 
45
- function runtimeAuthorization(authorized: boolean, superAdmin = false) {
46
- return {
47
- schemaVersion: 'openxiangda.runtime-authorization/v2',
48
- state: 'active',
49
- environment: {
50
- id: 'preproduction-id',
51
- key: 'preproduction',
52
- activeAppVersionId: 'app-version-1',
53
- headRevision: 1,
54
- authzRevisionId: 'authz-1',
55
- authzVersion: 1,
56
- scopeDataVersion: 'scope-1',
57
- },
58
- subjectProfile: {
59
- schemaVersion: 'openxiangda.subject-profile/v2',
60
- userId: 'acceptance-user',
61
- displayName: '王老师',
62
- avatarUrl: null,
63
- jobNumber: 'T001',
64
- affiliatedDepartment: { id: 'department-1', name: '理学院' },
65
- },
66
- roles: [
67
- { code: 'college_admin', name: '学院管理员', source: 'package' },
68
- { code: 'instrument_admin', name: '仪器管理员', source: 'package' },
69
- ],
70
- principal: {
71
- type: 'user_union',
72
- userId: 'acceptance-user',
73
- roleCodes: ['college_admin', 'instrument_admin'],
74
- capabilityCodes: authorized
75
- ? [
76
- ...allCapabilities,
77
- 'app:openxiangda-application:operations:read',
78
- ]
79
- : [],
80
- isAppSuperAdmin: superAdmin,
81
- identityScope: 'user-union:preproduction-id:acceptance-user:authz-1',
82
- },
83
- };
84
- }
85
-
86
- async function mockPlatform(
87
- page: Page,
88
- authorized = true,
89
- targetRuntimeBase = runtimeBase,
90
- superAdmin = false,
91
- queryItemsByResource: Record<string, Array<Record<string, unknown>>> = {},
92
- ) {
93
- let contextReads = 0;
94
- let avatarCompletes = 0;
95
- const observed: Array<{ path: string; perspectiveCode: string }> = [];
96
- await page.route('https://upload.example.test/avatar-upload', route =>
97
- route.fulfill({ status: 200, body: '' })
98
- );
99
- await page.route(`**${targetRuntimeBase}/**`, async route => {
100
- if (route.request().resourceType() !== 'document') return route.continue();
101
- const response = await route.fetch();
102
- const body = (await response.text()).replace(
103
- '<head>',
104
- `<head><meta name="openxiangda-runtime-base" content="${targetRuntimeBase}/" />` +
105
- `<meta name="openxiangda-app-code" content="${appCode}" />` +
106
- '<meta name="openxiangda-environment" content="preproduction" />'
107
- );
108
- await route.fulfill({ response, body });
109
- });
110
- await page.route('**/service/**', async route => {
111
- const url = new URL(route.request().url());
112
- const request = route.request();
113
- const perspectiveCode = request.headers()['x-openxiangda-perspective'] || '';
114
- if (url.pathname.endsWith('/native/authz/current')) {
115
- contextReads += 1;
116
- return route.fulfill({
117
- contentType: 'application/json',
118
- body: JSON.stringify({
119
- code: 200,
120
- data: runtimeAuthorization(authorized, superAdmin),
121
- }),
122
- });
123
- }
124
- if (url.pathname.endsWith('/native/authz/profile/avatar/initiate')) {
125
- return route.fulfill({
126
- contentType: 'application/json',
127
- body: JSON.stringify({
128
- code: 200,
129
- data: {
130
- uploadUrl: 'https://upload.example.test/avatar-upload',
131
- uploadMethod: 'put',
132
- headers: { 'content-type': 'image/png' },
133
- objectName: 'images/tenant/app/user/avatar.png',
134
- },
135
- }),
136
- });
137
- }
138
- if (url.pathname.endsWith('/native/authz/profile/avatar/complete')) {
139
- avatarCompletes += 1;
140
- return route.fulfill({
141
- contentType: 'application/json',
142
- body: JSON.stringify({
143
- code: 200,
144
- data: {
145
- schemaVersion: 'openxiangda.subject-profile/v2',
146
- userId: 'acceptance-user',
147
- displayName: '王老师',
148
- avatarUrl: 'https://cdn.example.test/avatar/new.png',
149
- jobNumber: 'T001',
150
- affiliatedDepartment: { id: 'department-1', name: '理学院' },
151
- },
152
- }),
153
- });
154
- }
155
- const sourceQuery = url.pathname.match(
156
- /\/native\/data-resources\/([^/]+)\/fields\/([^/]+)\/source\/query$/
157
- );
158
- if (sourceQuery) {
159
- observed.push({ path: url.pathname, perspectiveCode });
160
- return route.fulfill({
161
- contentType: 'application/json',
162
- body: JSON.stringify({
163
- code: 200,
164
- data: {
165
- schemaVersion: 'openxiangda.data-field-source-page/v2',
166
- resourceCode: decodeURIComponent(sourceQuery[1]),
167
- fieldCode: decodeURIComponent(sourceQuery[2]),
168
- items: [],
169
- nextCursor: null,
170
- },
171
- }),
172
- });
173
- }
174
- const exportRequest = url.pathname.match(
175
- /\/native\/data\/([^/]+)\/export$/
176
- );
177
- if (exportRequest) {
178
- observed.push({ path: url.pathname, perspectiveCode });
179
- return route.fulfill({
180
- status: 200,
181
- contentType: 'text/csv; charset=utf-8',
182
- headers: {
183
- 'content-disposition': `attachment; filename="${decodeURIComponent(
184
- exportRequest[1]
185
- )}.csv"`,
186
- },
187
- body: 'id,name\nacceptance-1,OpenXiangda\n',
188
- });
189
- }
190
- const query = url.pathname.match(/\/native\/data\/([^/]+)\/query$/);
191
- if (query) {
192
- const resourceCode = decodeURIComponent(query[1]);
193
- const items = queryItemsByResource[resourceCode] || [];
194
- observed.push({ path: url.pathname, perspectiveCode });
195
- return route.fulfill({
196
- contentType: 'application/json',
197
- body: JSON.stringify({
198
- code: 200,
199
- data: {
200
- schemaVersion: 'openxiangda.data-page/v2',
201
- resourceCode,
202
- appVersionId: 'app-version-1',
203
- environmentHeadRevision: 1,
204
- items,
205
- total: items.length,
206
- limit: 500,
207
- offset: 0,
208
- },
209
- }),
210
- });
211
- }
212
- if (url.pathname.includes('/directory/')) {
213
- observed.push({ path: url.pathname, perspectiveCode });
214
- return route.fulfill({
215
- contentType: 'application/json',
216
- body: JSON.stringify({ code: 200, data: { items: [], nextCursor: null } }),
217
- });
218
- }
219
- if (url.pathname.includes('/openxiangda-app-api/v2/')) {
220
- observed.push({ path: url.pathname, perspectiveCode });
221
- return route.fulfill({
222
- contentType: 'application/json',
223
- body: JSON.stringify({ code: 200, data: { status: 'ok' } }),
224
- });
225
- }
226
- return route.fulfill({
227
- status: 404,
228
- contentType: 'application/json',
229
- body: JSON.stringify({ code: 404, message: 'not mocked' }),
230
- });
231
- });
232
- return {
233
- observed,
234
- get contextReads() {
235
- return contextReads;
236
- },
237
- get avatarCompletes() {
238
- return avatarCompletes;
239
- },
240
- };
241
- }
242
-
243
46
  test('renders the compiled desktop resource contract and shared workbench', async ({ page }) => {
244
47
  await mockPlatform(page);
245
48
  if (!codes.length) {
@@ -256,19 +59,18 @@ test('renders the compiled desktop resource contract and shared workbench', asyn
256
59
  await expect(page).toHaveURL(`${runtimeBase}/admin/resources/${code}`);
257
60
  await expect(page.locator('.oxa-sider').getByText(definition.name, { exact: true })).toBeVisible();
258
61
  await expect(page.getByRole('button', { name: /导出$/ })).toBeVisible();
259
- await expect(page.getByRole('button', { name: /列设置$/ })).toBeVisible();
260
- await expect(page.getByRole('button', { name: /密度$/ })).toBeVisible();
62
+ await expect(page.getByRole('button', { name: /显示列$/ })).toBeVisible();
261
63
  if ((definition.surface.list?.filterFields?.length || 0) > 2) {
262
- const more = page.getByRole('button', { name: /更多筛选/ });
64
+ const more = page.getByRole('button', { name: /^筛选/ });
263
65
  await expect(more).toBeVisible();
264
66
  await more.click();
265
- const modal = page.getByRole('dialog', { name: '更多筛选' });
67
+ const modal = page.getByRole('dialog', { name: '筛选' });
266
68
  await expect(modal).toBeVisible();
267
69
  await expect(modal.getByRole('button', { name: '应用筛选' })).toBeVisible();
268
70
  await expect
269
71
  .poll(async () => (await modal.boundingBox())?.width || 0)
270
72
  .toBeGreaterThan(700);
271
- await modal.getByRole('button', { name: '取消' }).click();
73
+ await modal.getByRole('button', { name: 'Close' }).click();
272
74
  }
273
75
  const downloadPromise = page.waitForEvent('download');
274
76
  await page.getByRole('button', { name: /导出$/ }).click();
@@ -283,15 +85,16 @@ test('renders the compiled desktop resource contract and shared workbench', asyn
283
85
  await expect(page.locator('.oxa-user-dropdown-profile')).toContainText('理学院');
284
86
  await expect(page.locator('.oxa-user-dropdown-roles')).toContainText('学院管理员');
285
87
  await expect(page.locator('.oxa-user-dropdown-roles')).not.toContainText('college_admin');
286
- await expect(page.getByText('跟随系统', { exact: true })).toBeVisible();
287
- await page.getByText('深色', { exact: true }).click();
288
- await expect(page.locator('html')).toHaveAttribute('data-oxa-theme', 'dark');
289
- await expect.poll(() => page.evaluate(() => localStorage.getItem('openxiangda.admin.appearance'))).toBe('dark');
88
+ await expect(page.getByText('外观', { exact: true })).toHaveCount(0);
89
+ await expect(page.getByText('跟随系统', { exact: true })).toHaveCount(0);
290
90
  });
291
91
 
292
- test('custom admin pages inherit tokens and follow live system appearance', async ({ page }) => {
92
+ test('default components ignore old preferences and leave the surrounding document unchanged', async ({ page }) => {
93
+ const errors: string[] = [];
94
+ page.on('pageerror', error => errors.push(error.message));
293
95
  await page.emulateMedia({ colorScheme: 'dark' });
294
- await mockPlatform(page);
96
+ await page.addInitScript(() => localStorage.setItem('openxiangda.admin.appearance', 'dark'));
97
+ const platform = await mockPlatform(page);
295
98
  await page.goto('/resource-experience.e2e.html');
296
99
  await page
297
100
  .locator('.oxa-sider')
@@ -299,23 +102,22 @@ test('custom admin pages inherit tokens and follow live system appearance', asyn
299
102
  .click();
300
103
 
301
104
  const customPage = page.getByTestId('custom-admin-page');
302
- const customPanel = customPage.locator('.theme-contract-panel');
105
+ const customPanel = customPage.locator('.component-defaults-panel');
303
106
  await expect(customPage).toBeVisible();
304
107
  await expect(customPanel).toBeVisible();
305
- await expect(page.locator('html')).toHaveAttribute('data-oxa-theme', 'dark');
306
- const readThemeColors = () =>
108
+ const readDefaultColors = () =>
307
109
  page.evaluate(() => {
308
110
  const host = document.querySelector('[data-testid="custom-admin-page"]');
309
- if (!host) throw new Error('CUSTOM_ADMIN_THEME_HOST_NOT_READY');
111
+ if (!host) throw new Error('CUSTOM_ADMIN_COMPONENT_HOST_NOT_READY');
310
112
  const probe = document.createElement('div');
311
113
  probe.style.cssText = [
312
114
  'position:fixed',
313
115
  'width:1px',
314
116
  'height:1px',
315
- 'background:var(--oxa-antd-color-bg-layout)',
316
- 'color:var(--oxa-antd-color-bg-container)',
317
- 'border:1px solid var(--oxa-antd-color-border-secondary)',
318
- 'outline:1px solid var(--oxa-antd-color-text)',
117
+ 'background:var(--ant-color-bg-layout)',
118
+ 'color:var(--ant-color-bg-container)',
119
+ 'border:1px solid var(--ant-color-border-secondary)',
120
+ 'outline:1px solid var(--ant-color-text)',
319
121
  ].join(';');
320
122
  host.appendChild(probe);
321
123
  const styles = getComputedStyle(probe);
@@ -328,19 +130,51 @@ test('custom admin pages inherit tokens and follow live system appearance', asyn
328
130
  probe.remove();
329
131
  return result;
330
132
  });
331
- const darkColors = await readThemeColors();
332
- await expect(customPage).toHaveCSS('background-color', darkColors.canvas);
333
- await expect(customPanel).toHaveCSS('background-color', darkColors.surface);
334
- await expect(customPanel).toHaveCSS('color', darkColors.text);
335
- await expect(customPanel).toHaveCSS('border-color', darkColors.border);
133
+ const defaults = await readDefaultColors();
134
+ expect(defaults.surface).toBe('rgb(255, 255, 255)');
135
+ await expect(customPage).toHaveCSS('background-color', defaults.canvas);
136
+ await expect(customPanel).toHaveCSS('background-color', defaults.surface);
137
+ await expect(customPanel).toHaveCSS('color', defaults.text);
138
+ await expect(customPanel).toHaveCSS('border-color', defaults.border);
139
+
140
+ const hostState = () => page.evaluate(() => {
141
+ const host = getComputedStyle(document.getElementById('outside-ui')!);
142
+ return {
143
+ font: host.fontFamily, color: host.color, background: host.backgroundColor,
144
+ boxSizing: host.boxSizing,
145
+ documentStyle: document.documentElement.getAttribute('style'),
146
+ documentAttributes: [...document.documentElement.attributes].map(attribute => attribute.name),
147
+ };
148
+ });
149
+ const outside = await hostState();
150
+ expect(outside).toEqual({
151
+ font: 'monospace', color: 'rgb(31, 32, 33)', background: 'rgb(248, 249, 250)',
152
+ boxSizing: 'content-box', documentStyle: null, documentAttributes: ['lang'],
153
+ });
336
154
 
337
155
  await page.emulateMedia({ colorScheme: 'light' });
338
- await expect(page.locator('html')).toHaveAttribute('data-oxa-theme', 'light');
339
- const lightColors = await readThemeColors();
340
- await expect(customPage).toHaveCSS('background-color', lightColors.canvas);
341
- await expect(customPanel).toHaveCSS('background-color', lightColors.surface);
342
- await expect(customPanel).toHaveCSS('color', lightColors.text);
343
- await expect(customPanel).toHaveCSS('border-color', lightColors.border);
156
+ expect(await readDefaultColors()).toEqual(defaults);
157
+ await expect(customPanel).toHaveCSS('background-color', defaults.surface);
158
+ expect(await hostState()).toEqual(outside);
159
+
160
+ const identityReads = platform.contextReads;
161
+ await page.getByRole('button', { name: '打开用户端' }).click();
162
+ await page.getByRole('textbox', { name: '用户端输入' }).fill('保留业务输入');
163
+ await page.getByRole('combobox').click();
164
+ const dropdown = page.locator('.ant-select-dropdown:visible');
165
+ await expect(dropdown).toHaveCSS('background-color', defaults.surface);
166
+ await dropdown.getByText('可用选项', { exact: true }).click();
167
+ await page.getByRole('button', { name: '打开用户弹窗' }).click();
168
+ const dialog = page.getByRole('dialog');
169
+ await expect(dialog).toBeVisible();
170
+ await expect(dialog.locator('.ant-modal-container')).toHaveCSS('background-color', defaults.surface);
171
+ await page.emulateMedia({ colorScheme: 'dark' });
172
+ await expect(dialog.locator('.ant-modal-container')).toHaveCSS('background-color', defaults.surface);
173
+ await dialog.getByRole('button', { name: '确 定' }).click();
174
+ await expect(page.getByRole('textbox', { name: '用户端输入' })).toHaveValue('保留业务输入');
175
+ expect(await hostState()).toEqual(outside);
176
+ expect(platform.contextReads).toBe(identityReads);
177
+ expect(errors).toEqual([]);
344
178
  });
345
179
 
346
180
  test('opens the stable admin entry under the preproduction gateway mount', async ({ page }) => {
@@ -436,7 +270,7 @@ test('mounts generated operation routes and typed actions in the unified Shell',
436
270
  await page.goto('/resource-experience.e2e.html');
437
271
  await expect(
438
272
  page.getByRole('button', { name: '发布选中栏目(0)' })
439
- ).toBeVisible();
273
+ ).toHaveCount(0);
440
274
  await page.locator('.oxa-sider').getByText('内容业务操作', { exact: true }).click();
441
275
  await expect(page).toHaveURL('/admin/operations/content');
442
276
  await expect(
@@ -557,8 +391,9 @@ test('fails closed on a direct operation route without its capability', async ({
557
391
  test('keeps platform implementation guidance out of generated forms', async ({ page }) => {
558
392
  await mockPlatform(page, true, runtimeBase, true);
559
393
  await page.goto('/resource-experience.e2e.html');
560
- await page.getByRole('button', { name: '新增内容栏目' }).click();
561
- await expect(page).toHaveURL('/admin/resources/resource-01/new');
394
+ await page.getByRole('button', { name: '新增', exact: true }).click();
395
+ await expect(page.getByRole('dialog', { name: '新增数据' })).toBeVisible();
396
+ await expect(page).toHaveURL('/admin/resources/resource-01');
562
397
  const formLabels = page.locator('.ant-form-item-label label');
563
398
  await expect(formLabels.first()).toBeVisible();
564
399
  const labels = await formLabels.allTextContents();
@@ -571,21 +406,23 @@ test('keeps platform implementation guidance out of generated forms', async ({ p
571
406
  await expect(page.getByText('当前角色不可修改此字段', { exact: true })).toHaveCount(0);
572
407
  });
573
408
 
574
- test('keeps primary filters compact and opens additional filters in a large modal', async ({ page }) => {
409
+ test('keeps one toolbar and opens all declared filters on demand', async ({ page }) => {
575
410
  await mockPlatform(page, true, runtimeBase, true);
576
411
  await page.goto('/resource-experience.e2e.html');
577
- await expect(page.locator('.oxa-filter-primary-item')).toHaveCount(2);
412
+ await expect(page.locator('.oxa-list-toolbar')).toHaveCount(1);
413
+ await expect(page.locator('.oxa-filter-primary-item')).toHaveCount(0);
578
414
  await expect(
579
415
  page.getByPlaceholder('搜索栏目名称或栏目编码')
580
416
  ).toBeVisible();
581
- const more = page.getByRole('button', { name: '更多筛选' });
417
+ const more = page.getByRole('button', { name: '筛选' });
582
418
  await more.click();
583
- const modal = page.getByRole('dialog', { name: '更多筛选' });
419
+ const modal = page.getByRole('dialog', { name: '筛选' });
584
420
  await expect(modal).toBeVisible();
585
- await expect(modal.locator('.oxa-more-filter-field')).toHaveCount(3);
586
- await expect(modal).toContainText('栏目管理员');
587
- await expect(modal).toContainText('配套设施');
588
- await expect(modal).toContainText('开放星期');
421
+ await modal.getByRole('button', { name: '添加条件', exact: true }).click();
422
+ await modal.getByRole('combobox', { name: '筛选字段' }).click();
423
+ await expect(page.getByRole('option', { name: '栏目管理员' })).toBeVisible();
424
+ await expect(page.getByRole('option', { name: '配套设施' })).toBeVisible();
425
+ await expect(page.getByRole('option', { name: '开放星期' })).toBeVisible();
589
426
  await expect
590
427
  .poll(async () => (await modal.boundingBox())?.width || 0)
591
428
  .toBeGreaterThan(700);