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
@@ -0,0 +1,441 @@
1
+ import { expect, test, type Locator, type Page } from '@playwright/test';
2
+ import { mockPlatform } from './resource-platform-mock';
3
+
4
+ async function chooseOption(page: Page, control: Locator, name: string) {
5
+ await control.click();
6
+ const listId = await control.getAttribute('aria-controls');
7
+ expect(listId).toBeTruthy();
8
+ // Closing overlays can coexist during animation; select only this input's list.
9
+ await page.locator(`[id="${listId}"]`).getByRole('option', { name, exact: true }).click();
10
+ }
11
+
12
+ async function editablePlatform(page: Page, fixtureMode = 'business') {
13
+ const context = page.context();
14
+ await mockPlatform(context, true, undefined, true);
15
+ await context.route(/\/(?:admin|m\/admin)\/resources\//, async route => {
16
+ if (route.request().resourceType() !== 'document') return route.fallback();
17
+ const url = new URL(route.request().url()); url.pathname = '/resource-experience.e2e.html'; url.search = `?primary=${fixtureMode}`;
18
+ const response = await route.fetch({ url: url.href }); await route.fulfill({ response });
19
+ });
20
+ const records = Array.from({ length: 21 }, (_, index) => ({
21
+ id: `record-${index + 1}`, revision: 1, name: `业务记录 ${index + 1}`,
22
+ code: `R${index + 1}`, description: '', enabled: true, displayOrder: index,
23
+ created_at: '2026-09-05T00:00:00.000Z', updated_at: '2026-09-05T00:00:00.000Z',
24
+ }));
25
+ const state = {
26
+ records, writes: [] as any[], queries: [] as any[], reads: 0,
27
+ preferences: new Map<string, any>(),
28
+ failNextWrite: false, failReads: false, preference: null as any, failPreference: false, failDraft: false, drafts: [] as any[], draftWrites: [] as any[],
29
+ };
30
+ await context.route('**/native/admin-list/preferences/**', async route => {
31
+ const key = new URL(route.request().url()).pathname;
32
+ if (route.request().method() === 'PUT' && state.failPreference) return route.fulfill({ status: 500, json: { code: 500, message: '设置保存失败' } });
33
+ if (route.request().method() === 'PUT') { state.preference = route.request().postDataJSON(); state.preferences.set(key, state.preference); }
34
+ if (route.request().method() === 'DELETE') { state.preference = null; state.preferences.delete(key); }
35
+ await route.fulfill({ json: { code: 200, data: state.preferences.get(key) || null } });
36
+ });
37
+ await context.route('**/native/form-drafts/**', async route => {
38
+ const path = new URL(route.request().url()).pathname;
39
+ const body = route.request().method() === 'POST' ? route.request().postDataJSON() : {};
40
+ const ok = (data: any) => route.fulfill({ json: { code: 200, data } });
41
+ const conflict = () => route.fulfill({ status: 409, json: { code: 409, message: '草稿版本冲突' } });
42
+ if (path.endsWith('/save')) {
43
+ state.draftWrites.push(body);
44
+ if (state.failDraft) return route.fulfill({ status: 500, json: { code: 500, message: '暂存服务暂时不可用' } });
45
+ const current = state.drafts.find(item => item.id === body.id);
46
+ if ((current?.revision || 0) !== body.expectedRevision || (current && (current.viewCode || '') !== (body.viewCode || ''))) return conflict();
47
+ const draft = { ...body, revision: (current?.revision || 0) + 1, updatedAt: '2026-09-05T07:06:00.000Z', expiresAt: '2026-12-04T07:06:00.000Z' };
48
+ state.drafts = [draft, ...state.drafts.filter(item => item.id !== body.id)]; return ok(draft);
49
+ }
50
+ if (path.endsWith('/delete')) { state.drafts = state.drafts.filter(item => item.id !== body.id); return ok({ deleted: true }); }
51
+ if (path.endsWith('/submit')) {
52
+ const draft = state.drafts.find(item => item.id === body.id);
53
+ if (!draft || draft.revision !== body.expectedRevision || (draft.viewCode || '') !== (body.viewCode || '')) return conflict();
54
+ const operation = body.operations[0]; state.writes.push(operation);
55
+ const existing = records.find(item => item.id === operation.id);
56
+ if (existing && existing.revision !== operation.expectedRevision) return conflict();
57
+ const record = existing || { ...records[0], id: `record-${records.length + 1}` };
58
+ Object.assign(record, operation.data, { revision: (existing?.revision || 0) + 1 }); if (!existing) records.push(record);
59
+ state.drafts = state.drafts.filter(item => item.id !== body.id);
60
+ return ok({ items: [{ operation: operation.operation, id: record.id, revision: record.revision }] });
61
+ }
62
+ const query = new URL(route.request().url()).searchParams;
63
+ return ok({ items: state.drafts.filter(item => item.mode === query.get('mode') && (item.recordId || '') === (query.get('recordId') || '') && (item.viewCode || '') === (query.get('viewCode') || '')), limit: 20, retentionDays: 90 });
64
+ });
65
+ await context.route('**/native/data/resource-01/**', async route => {
66
+ const request = route.request();
67
+ const path = new URL(request.url()).pathname;
68
+ if (path.endsWith('/query')) {
69
+ const body = request.postDataJSON();
70
+ state.queries.push(body);
71
+ return route.fulfill({ json: { code: 200, data: {
72
+ schemaVersion: 'openxiangda.data-page/v2', resourceCode: 'resource-01',
73
+ items: records.slice(body.offset, body.offset + body.limit), total: records.length,
74
+ offset: body.offset, limit: body.limit,
75
+ } } });
76
+ }
77
+ const recordId = path.match(/\/records\/([^/]+)/)?.[1];
78
+ const current = records.find(record => record.id === recordId);
79
+ if (request.method() === 'GET' && current) {
80
+ state.reads += 1;
81
+ if (state.failReads) {
82
+ return route.fulfill({ status: 500, json: { code: 500, message: '暂时无法读取,请重试' } });
83
+ }
84
+ return route.fulfill({ json: { code: 200, data: { schemaVersion: 'openxiangda.data-record/v2', data: current } } });
85
+ }
86
+ if (request.method() === 'POST' && (path.endsWith('/records') || path.endsWith('/update'))) {
87
+ const body = request.postDataJSON();
88
+ state.writes.push(body);
89
+ if (state.failNextWrite || (current && body.expectedRevision !== current.revision)) {
90
+ state.failNextWrite = false;
91
+ return route.fulfill({ status: 409, json: { code: 409, message: '记录有冲突,请检查后重试' } });
92
+ }
93
+ // Keep the request in flight long enough to exercise double submission.
94
+ await new Promise(resolve => setTimeout(resolve, 350));
95
+ const saved = current || { ...records[0], id: `record-${records.length + 1}` };
96
+ Object.assign(saved, body.data, { revision: current ? current.revision + 1 : 1 });
97
+ if (!current) records.push(saved);
98
+ return route.fulfill({ json: { code: 200, data: { schemaVersion: 'openxiangda.data-record/v2', data: saved } } });
99
+ }
100
+ return route.fallback();
101
+ });
102
+ return state;
103
+ }
104
+
105
+ test('named views share records while keeping columns, drawer routes and drafts separate', async ({ page }, testInfo) => {
106
+ const state = await editablePlatform(page, 'named');
107
+ const base = '/admin/resources/resource-01/views';
108
+ await page.goto(`${base}/quick`);
109
+ await expect(page.getByRole('columnheader', { name: '栏目名称' })).toBeVisible();
110
+ await expect(page.getByRole('columnheader', { name: '启用状态' })).toHaveCount(0);
111
+ await page.getByRole('button', { name: '显示列', exact: true }).click();
112
+ const settings = page.getByRole('region', { name: '显示列设置' });
113
+ await settings.getByRole('checkbox', { name: '创建时间', exact: true }).check();
114
+ await page.getByRole('button', { name: '显示列', exact: true }).click();
115
+ await page.getByRole('button', { name: '保存显示列配置' }).click();
116
+ await expect(page.getByText('你调整了显示列配置')).toHaveCount(0);
117
+ expect([...state.preferences.keys()]).toHaveLength(1);
118
+ expect(decodeURIComponent([...state.preferences.keys()][0])).toContain(':view:quick');
119
+ await page.getByRole('button', { name: '新增', exact: true }).click();
120
+ const drawer = page.getByRole('dialog', { name: '新增数据' });
121
+ await expect(drawer.getByRole('heading', { name: '登记信息' })).toBeVisible();
122
+ await expect(drawer.getByLabel('栏目说明', { exact: true })).toHaveCount(0);
123
+ await drawer.getByLabel('栏目名称', { exact: true }).fill('简要登记的草稿');
124
+ await drawer.getByRole('button', { name: '暂存', exact: true }).click();
125
+ await expect.poll(() => state.drafts.length).toBe(1);
126
+ expect(state.drafts[0].viewCode).toBe('quick');
127
+ await drawer.getByRole('button', { name: '关闭', exact: true }).click();
128
+ await page.locator('.oxa-sider').getByText('完整管理', { exact: true }).click();
129
+ await expect(page).toHaveURL(`${base}/complete`);
130
+ await expect(page.getByRole('columnheader', { name: '启用状态' })).toBeVisible();
131
+ await expect(page.getByRole('columnheader', { name: '创建时间' })).toHaveCount(0);
132
+ await page.getByRole('button', { name: '新增', exact: true }).click();
133
+ await expect(drawer.getByLabel('栏目名称', { exact: true })).toBeEmpty();
134
+ await expect(drawer.getByLabel('栏目说明', { exact: true })).toBeVisible();
135
+ await expect(page.getByRole('dialog', { name: '暂存数据导入' })).toHaveCount(0);
136
+ await page.screenshot({ path: testInfo.outputPath('named-complete-drawer.png'), animations: 'disabled' });
137
+ await drawer.getByRole('button', { name: '关闭', exact: true }).click();
138
+ await page.locator('.oxa-sider').getByText('简要登记', { exact: true }).click();
139
+ await expect(page.getByRole('columnheader', { name: '创建时间' })).toBeVisible();
140
+ await page.getByRole('button', { name: '新增', exact: true }).click();
141
+ await page.getByRole('button', { name: '载入草稿' }).click();
142
+ await expect(drawer.getByLabel('栏目名称', { exact: true })).toHaveValue('简要登记的草稿');
143
+ const opened = page.context().waitForEvent('page');
144
+ await drawer.getByRole('button', { name: '新开页面' }).click();
145
+ const nextPage = await opened;
146
+ await expect(nextPage).toHaveURL(/\/views\/quick\/new\?draft=/);
147
+ await expect(nextPage.getByLabel('栏目名称', { exact: true })).toHaveValue('简要登记的草稿');
148
+ await expect(nextPage.getByLabel('栏目说明', { exact: true })).toHaveCount(0);
149
+ await nextPage.screenshot({ path: testInfo.outputPath('named-quick-new-page.png'), fullPage: true, animations: 'disabled' });
150
+ await nextPage.getByRole('button', { name: '提交', exact: true }).click();
151
+ await expect(nextPage).toHaveURL(`${base}/quick`);
152
+ expect(state.writes).toHaveLength(1);
153
+ expect(state.writes[0].resourceCode).toBe('resource-01');
154
+ expect(state.records.at(-1)?.name).toBe('简要登记的草稿');
155
+ expect(state.drafts).toHaveLength(0);
156
+ });
157
+
158
+ test('mobile named forms retain their grouping and return to the same view after submit', async ({ page }, testInfo) => {
159
+ const state = await editablePlatform(page, 'named');
160
+ await page.setViewportSize({ width: 390, height: 844 });
161
+ await page.goto('/m/admin/resources/resource-01/views/complete/new');
162
+ const form = page.locator('.oxa-mobile-form');
163
+ await expect(form.getByRole('heading', { name: '栏目信息' })).toBeVisible();
164
+ await expect(form.getByRole('heading', { name: '管理设置' })).toBeVisible();
165
+ await expect(page.getByRole('button', { name: '返回列表' })).toHaveCount(0);
166
+ await form.getByRole('textbox', { name: '栏目名称', exact: true }).fill('移动端完整登记');
167
+ await form.getByRole('textbox', { name: '栏目说明', exact: true }).fill('和简要登记共用一份数据');
168
+ await form.getByRole('button', { name: '暂存', exact: true }).click();
169
+ await expect.poll(() => state.drafts.length).toBe(1);
170
+ expect(state.drafts[0].viewCode).toBe('complete');
171
+ await page.reload();
172
+ await page.getByRole('button', { name: '载入草稿' }).click();
173
+ await expect(form.getByRole('textbox', { name: '栏目名称', exact: true })).toHaveValue('移动端完整登记');
174
+ expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
175
+ await page.screenshot({ path: testInfo.outputPath('named-mobile-complete-form.png'), fullPage: true, animations: 'disabled' });
176
+ await form.getByRole('button', { name: '提交', exact: true }).click();
177
+ await expect(page).toHaveURL('/m/admin/resources/resource-01/views/complete');
178
+ expect(state.writes).toHaveLength(1);
179
+ expect(state.drafts).toHaveLength(0);
180
+ });
181
+
182
+ test('uses a collapsible menu and a single list toolbar with optional audit columns', async ({ page }, testInfo) => {
183
+ const state = await editablePlatform(page);
184
+ await page.goto('/resource-experience.e2e.html?primary=business');
185
+ await expect(page.locator('.oxa-list-toolbar')).toHaveCount(1);
186
+ await expect(page.getByRole('navigation', { name: '页面访问历史' })).toHaveCount(0);
187
+ await expect(page.locator('.oxa-main h1, .oxa-main h2')).toHaveCount(0);
188
+ const group = page.locator('.ant-menu-submenu-title').filter({ hasText: '数据管理' });
189
+ await group.click();
190
+ await expect(page.locator('.oxa-sider').getByText('内容栏目', { exact: true })).toBeHidden();
191
+ await group.click();
192
+ await expect(page.locator('.oxa-sider').getByText('内容栏目', { exact: true })).toBeVisible();
193
+ await expect(page.getByRole('columnheader', { name: '创建时间' })).toHaveCount(0);
194
+ await expect(page.getByRole('columnheader', { name: '栏目名称' })).toBeVisible();
195
+ await page.screenshot({ path: testInfo.outputPath('standard-list.png'), fullPage: true, animations: 'disabled' });
196
+ await page.getByRole('button', { name: '显示列', exact: true }).click();
197
+ const settings = page.getByRole('region', { name: '显示列设置' });
198
+ await settings.getByRole('checkbox', { name: '创建时间', exact: true }).check();
199
+ await settings.getByRole('button', { name: '冻结栏目名称', exact: true }).click();
200
+ await expect(page.getByRole('columnheader', { name: '栏目名称' })).toHaveClass(/ant-table-cell-fix-start/);
201
+ await settings.getByRole('button', { name: '上移创建时间' }).click();
202
+ state.failPreference = true;
203
+ await page.getByRole('button', { name: '显示列', exact: true }).click();
204
+ await page.getByRole('button', { name: '保存显示列配置' }).click();
205
+ await expect(page.getByText('你调整了显示列配置')).toBeVisible();
206
+ state.failPreference = false;
207
+ await page.getByRole('button', { name: '保存显示列配置' }).click();
208
+ await expect(page.getByText('你调整了显示列配置')).toHaveCount(0);
209
+ expect(state.preference.columns.find((column: any) => column.key === 'created_at').visible).toBe(true);
210
+ expect(state.preference.columns.find((column: any) => column.key === 'name').fixed).toBe('left');
211
+ await page.reload();
212
+ await expect(page.getByRole('columnheader', { name: '创建时间' })).toBeVisible();
213
+ await expect(page.getByRole('columnheader', { name: '栏目名称' })).toHaveClass(/ant-table-cell-fix-start/);
214
+ await page.getByRole('button', { name: '显示列', exact: true }).click();
215
+ await settings.getByRole('button', { name: '恢复默认' }).click();
216
+ await expect(page.getByRole('columnheader', { name: '创建时间' })).toHaveCount(0);
217
+
218
+ });
219
+
220
+ test('grouped drawer validates, preserves input after failure and refreshes the same list after one retry', async ({ page }, testInfo) => {
221
+ const state = await editablePlatform(page);
222
+ const errors: string[] = [];
223
+ page.on('pageerror', error => errors.push(error.message));
224
+ await page.goto('/resource-experience.e2e.html?primary=business');
225
+ const search = page.getByRole('searchbox', { name: '搜索数据' });
226
+ await search.fill('业务');
227
+ await search.press('Enter');
228
+ await page.locator('.ant-pagination-item-2').click();
229
+ await page.getByRole('button', { name: '新增', exact: true }).click();
230
+ const drawer = page.getByRole('dialog', { name: '新增数据' });
231
+ await expect(drawer.getByRole('heading', { name: '栏目设置' })).toBeVisible();
232
+ await expect(drawer.getByRole('heading', { name: '管理信息' })).toBeVisible();
233
+ await expect(drawer.locator('.oxa-section-card')).toHaveCount(0);
234
+ await drawer.getByRole('button', { name: '提交', exact: true }).click();
235
+ await expect(drawer.locator('.ant-form-item-explain-error')).toBeVisible();
236
+ expect(state.writes).toHaveLength(0);
237
+ await drawer.getByLabel('栏目名称', { exact: true }).fill('业务新增记录');
238
+ await drawer.getByLabel('栏目编码', { exact: true }).fill('NEW-1');
239
+ await drawer.getByLabel('栏目说明', { exact: true }).fill('失败后仍保留的内容');
240
+ await expect(drawer.locator('.ant-form-item-explain-error')).toHaveCount(0);
241
+ await page.screenshot({ path: testInfo.outputPath('standard-grouped-drawer.png'), animations: 'disabled' });
242
+ state.failNextWrite = true;
243
+ await drawer.getByRole('button', { name: '提交', exact: true }).click();
244
+ await expect(drawer.getByText('保存失败', { exact: true })).toBeVisible();
245
+ await expect(drawer.getByLabel('栏目名称', { exact: true })).toHaveValue('业务新增记录');
246
+ await expect(drawer.getByLabel('栏目说明', { exact: true })).toHaveValue('失败后仍保留的内容');
247
+ await drawer.getByRole('button', { name: '提交', exact: true }).dblclick({ delay: 30 });
248
+ await expect(drawer).toBeHidden();
249
+ expect(state.writes).toHaveLength(2);
250
+ expect(state.records.at(-1)?.name).toBe('业务新增记录');
251
+ await expect(search).toHaveValue('业务');
252
+ await expect(page.locator('.ant-pagination-item-2')).toHaveClass(/ant-pagination-item-active/);
253
+ await expect.poll(() => state.queries.at(-1)?.offset).toBe(10);
254
+ expect(JSON.stringify(state.queries.at(-1)?.where)).toContain('业务');
255
+ expect(errors).toEqual([]);
256
+ });
257
+
258
+ test('drawer full screen preserves input, close protects edits and new page restores a saved draft', async ({ page }, testInfo) => {
259
+ const state = await editablePlatform(page);
260
+ await page.goto('/resource-experience.e2e.html?primary=business');
261
+ await page.getByRole('button', { name: '新增', exact: true }).click();
262
+ let drawer = page.getByRole('dialog', { name: '新增数据' });
263
+ await drawer.getByRole('button', { name: '关闭', exact: true }).click();
264
+ await expect(drawer).toBeHidden();
265
+ await page.getByRole('button', { name: '新增', exact: true }).click();
266
+ drawer = page.getByRole('dialog', { name: '新增数据' });
267
+ await drawer.getByLabel('栏目名称', { exact: true }).fill('新页面继续填写');
268
+ await drawer.getByRole('button', { name: '全屏', exact: true }).click();
269
+ await expect.poll(async () => (await drawer.boundingBox())!.width).toBe(page.viewportSize()!.width);
270
+ await drawer.getByRole('button', { name: '退出全屏', exact: true }).click();
271
+ await drawer.getByRole('button', { name: '关闭', exact: true }).click();
272
+ await page.getByRole('button', { name: '继续填写' }).click();
273
+ await expect(drawer.getByLabel('栏目名称', { exact: true })).toHaveValue('新页面继续填写');
274
+ const opened = page.context().waitForEvent('page');
275
+ await drawer.getByRole('button', { name: '新开页面' }).click();
276
+ const nextPage = await opened;
277
+ await expect(nextPage).toHaveURL(/\/admin\/resources\/resource-01\/new\?draft=/);
278
+ await expect(nextPage.locator('.oxa-topbar')).toContainText('新增资源 01');
279
+ await expect(nextPage.getByLabel('栏目名称', { exact: true })).toHaveValue('新页面继续填写');
280
+ await expect(drawer).toBeHidden();
281
+ expect(state.writes).toHaveLength(0);
282
+ expect(state.drafts).toHaveLength(1);
283
+ await nextPage.screenshot({ path: testInfo.outputPath('standard-new-page-draft.png'), fullPage: true, animations: 'disabled' });
284
+ await nextPage.getByRole('button', { name: '提交', exact: true }).click();
285
+ await expect(nextPage).toHaveURL('/admin/resources/resource-01');
286
+ expect(state.writes).toHaveLength(1); expect(state.drafts).toHaveLength(0);
287
+ });
288
+
289
+ test('record reads can retry and reconnect refresh does not overwrite an edit or its revision', async ({ page }) => {
290
+ const state = await editablePlatform(page);
291
+ await page.goto('/resource-experience.e2e.html?primary=business');
292
+ state.failReads = true;
293
+ await page.getByRole('button', { name: '编辑', exact: true }).first().click();
294
+ const drawer = page.getByRole('dialog', { name: '编辑数据' });
295
+ await expect(drawer.getByText('数据读取失败', { exact: true })).toBeVisible();
296
+ state.failReads = false;
297
+ await drawer.getByRole('button', { name: '重试' }).click();
298
+ await expect(drawer.getByLabel('栏目名称', { exact: true })).toHaveValue('业务记录 1');
299
+ await drawer.getByLabel('栏目名称', { exact: true }).fill('我的修改');
300
+ state.records[0].name = '另一个用户的修改';
301
+ state.records[0].revision = 2;
302
+ const reads = state.reads;
303
+ await page.context().setOffline(true);
304
+ await page.context().setOffline(false);
305
+ await expect.poll(() => state.reads).toBeGreaterThan(reads);
306
+ await expect(drawer.getByLabel('栏目名称', { exact: true })).toHaveValue('我的修改');
307
+ await drawer.getByRole('button', { name: '提交' }).click();
308
+ await expect(drawer.getByText('保存失败', { exact: true })).toBeVisible();
309
+ expect(state.writes.at(-1).expectedRevision).toBe(1);
310
+ await expect(drawer.getByLabel('栏目名称', { exact: true })).toHaveValue('我的修改');
311
+ });
312
+
313
+ test('mobile grouped entry keeps one column and saves through the same Data API', async ({ page }, testInfo) => {
314
+ const state = await editablePlatform(page);
315
+ const errors: string[] = [];
316
+ page.on('pageerror', error => errors.push(error.message));
317
+ await page.setViewportSize({ width: 390, height: 844 });
318
+ await page.goto('/resource-experience.e2e.html?primary=business');
319
+ await page.evaluate(() => {
320
+ history.pushState({}, '', '/m/admin/resources/resource-01/new');
321
+ window.dispatchEvent(new PopStateEvent('popstate'));
322
+ });
323
+ const form = page.locator('.oxa-mobile-form');
324
+ await expect(form.getByRole('heading', { name: '栏目设置' })).toBeVisible();
325
+ await expect(page.getByRole('button', { name: '返回列表' })).toHaveCount(0);
326
+ await expect(form.getByRole('button', { name: '取消', exact: true })).toHaveCount(0);
327
+ await form.getByRole('textbox', { name: '栏目名称', exact: true }).fill('移动端新增记录');
328
+ await form.getByRole('textbox', { name: '栏目说明', exact: true }).fill('使用平台移动输入组件');
329
+ expect(await form.locator('.oxa-grid').first().evaluate(element => getComputedStyle(element).gridTemplateColumns.split(' ').length)).toBe(1);
330
+ expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
331
+ await page.screenshot({ path: testInfo.outputPath('standard-mobile-form.png'), animations: 'disabled' });
332
+ await form.getByRole('button', { name: '提交', exact: true }).click();
333
+ await expect(page).toHaveURL('/m/admin/resources/resource-01');
334
+ expect(state.writes).toHaveLength(1);
335
+ expect(state.records.at(-1)?.name).toBe('移动端新增记录');
336
+ expect(errors).toEqual([]);
337
+ });
338
+
339
+ test('advanced grouped conditions, multiple sort priorities and selection-only actions work together', async ({ page }, testInfo) => {
340
+ const state = await editablePlatform(page);
341
+ await page.goto('/resource-experience.e2e.html?primary=business');
342
+ await expect(page.getByRole('button', { name: /发布选中/ })).toHaveCount(0);
343
+ await page.locator('tbody').getByRole('checkbox').first().check();
344
+ await expect(page.getByRole('button', { name: '发布选中栏目(1)' })).toBeVisible();
345
+ await page.locator('tbody').getByRole('checkbox').first().uncheck();
346
+ await expect(page.getByRole('button', { name: /发布选中/ })).toHaveCount(0);
347
+ await page.getByRole('button', { name: '筛选', exact: true }).click();
348
+ const filter = page.getByRole('dialog', { name: '筛选' });
349
+ await filter.getByText('满足任一', { exact: true }).click();
350
+ await filter.getByRole('button', { name: '添加条件', exact: true }).click();
351
+ await filter.getByPlaceholder('筛选栏目名称').fill('记录 1');
352
+ await filter.getByRole('button', { name: '添加分组', exact: true }).click();
353
+ const nested = filter.locator('.oxa-condition-nested');
354
+ await chooseOption(page, nested.getByRole('combobox', { name: '筛选字段' }), '启用状态');
355
+ await nested.locator('.oxa-condition-value').getByRole('combobox').click();
356
+ await page.locator('.ant-select-dropdown:visible').getByText('否', { exact: true }).click();
357
+ await nested.getByRole('button', { name: '添加条件', exact: true }).click();
358
+ await chooseOption(page, nested.getByRole('combobox', { name: '筛选字段' }).nth(1), '显示顺序');
359
+ await nested.getByRole('spinbutton').fill('0');
360
+ await page.screenshot({ path: testInfo.outputPath('advanced-condition-groups.png'), animations: 'disabled' });
361
+ await filter.getByRole('button', { name: '应用筛选' }).click();
362
+ await expect.poll(() => state.queries.at(-1)?.where).toEqual({ and: [{ or: [
363
+ { field: 'name', operator: 'contains', value: '记录 1' },
364
+ { and: [{ field: 'enabled', operator: 'eq', value: false }, { field: 'displayOrder', operator: 'eq', value: 0 }] },
365
+ ] }] });
366
+ await page.getByRole('button', { name: '排序', exact: true }).click();
367
+ const sort = page.getByRole('region', { name: '排序设置' });
368
+ await sort.getByRole('button', { name: '清空', exact: true }).click();
369
+ await sort.getByRole('button', { name: '添加排序规则' }).click();
370
+ await chooseOption(page, sort.getByRole('combobox', { name: '第1排序字段' }), '栏目名称');
371
+ await sort.getByText('降序', { exact: true }).click();
372
+ await sort.getByRole('button', { name: '添加排序规则' }).click();
373
+ await chooseOption(page, sort.getByRole('combobox', { name: '第2排序字段' }), '显示顺序');
374
+ await page.screenshot({ path: testInfo.outputPath('multiple-sort-rules.png'), animations: 'disabled' });
375
+ await sort.getByRole('button', { name: '确定', exact: true }).click();
376
+ await expect.poll(() => state.queries.at(-1)?.order).toEqual([{ field: 'name', direction: 'desc' }, { field: 'displayOrder', direction: 'asc' }]);
377
+ const exported = page.waitForRequest(request => request.url().includes('/export'));
378
+ await page.getByRole('button', { name: '导出', exact: true }).click();
379
+ const body = (await exported).postDataJSON(); expect(body.where).toEqual(state.queries.at(-1).where); expect(body.order).toEqual(state.queries.at(-1).order);
380
+ });
381
+
382
+ test('partial draft save failure retains input; PC draft box resumes and deletes', async ({ page }, testInfo) => {
383
+ const state = await editablePlatform(page);
384
+ await page.goto('/resource-experience.e2e.html?primary=business');
385
+ await page.getByRole('button', { name: '新增', exact: true }).click();
386
+ let drawer = page.getByRole('dialog', { name: '新增数据' });
387
+ await drawer.getByLabel('栏目说明', { exact: true }).fill('必填项尚未完成也可暂存');
388
+ state.failDraft = true;
389
+ await drawer.getByRole('button', { name: '暂存', exact: true }).click();
390
+ await expect(drawer.getByText(/暂存服务暂时不可用/)).toBeVisible();
391
+ await expect(drawer.getByLabel('栏目说明', { exact: true })).toHaveValue('必填项尚未完成也可暂存');
392
+ state.failDraft = false;
393
+ await drawer.getByRole('button', { name: '暂存', exact: true }).click();
394
+ await expect(drawer.getByText('已暂存,可从草稿箱继续编辑')).toBeVisible();
395
+ expect(state.writes).toHaveLength(0); expect(state.drafts).toHaveLength(1);
396
+ await drawer.getByRole('button', { name: '关闭', exact: true }).click();
397
+ await page.getByRole('button', { name: '新增', exact: true }).click();
398
+ drawer = page.getByRole('dialog', { name: '新增数据' });
399
+ await page.getByRole('button', { name: '载入草稿' }).click();
400
+ await expect(drawer.getByLabel('栏目说明', { exact: true })).toHaveValue('必填项尚未完成也可暂存');
401
+ await drawer.getByRole('button', { name: '草稿箱', exact: true }).click();
402
+ const box = page.getByRole('dialog', { name: /草稿箱/ });
403
+ await expect(box.getByRole('button', { name: '继续编辑' })).toBeVisible();
404
+ await page.screenshot({ path: testInfo.outputPath('desktop-draft-box.png'), animations: 'disabled' });
405
+ await box.getByRole('button', { name: '删除', exact: true }).click();
406
+ await page.getByRole('button', { name: '删除草稿', exact: true }).click();
407
+ await expect(box.getByRole('button', { name: '继续编辑' })).toHaveCount(0);
408
+ expect(state.drafts).toHaveLength(0);
409
+ });
410
+
411
+ test('mobile partial drafts restore from a bottom sheet and submit once', async ({ page }, testInfo) => {
412
+ const state = await editablePlatform(page);
413
+ await page.addInitScript(() => { Object.defineProperty(crypto, 'randomUUID', { value: undefined, configurable: true }); });
414
+ await page.setViewportSize({ width: 390, height: 844 });
415
+ await page.goto('/resource-experience.e2e.html?primary=business');
416
+ await page.evaluate(() => { history.pushState({}, '', '/m/admin/resources/resource-01/new'); window.dispatchEvent(new PopStateEvent('popstate')); });
417
+ const form = page.locator('.oxa-mobile-form');
418
+ await form.getByRole('textbox', { name: '栏目说明', exact: true }).fill('手机暂存内容');
419
+ await form.getByRole('button', { name: '暂存', exact: true }).click();
420
+ await expect(page.getByText('已暂存,可从草稿箱继续编辑')).toBeVisible();
421
+ expect(state.writes).toHaveLength(0);
422
+ await page.reload();
423
+ const prompt = page.getByRole('dialog', { name: '载入暂存数据' });
424
+ await expect(prompt).toBeVisible();
425
+ await expect.poll(async () => { const box = await prompt.boundingBox(); return box ? Math.abs(box.y + box.height - 844) : Infinity; }).toBeLessThan(2);
426
+ expect(await prompt.locator('xpath=..').evaluate(el => el.className)).not.toContain('ant-modal');
427
+ await page.screenshot({ path: testInfo.outputPath('mobile-draft-restore.png'), animations: 'disabled' });
428
+ await prompt.getByRole('button', { name: '载入草稿' }).click();
429
+ await expect(form.getByRole('textbox', { name: '栏目说明', exact: true })).toHaveValue('手机暂存内容');
430
+ await page.getByRole('button', { name: '草稿箱', exact: true }).click();
431
+ const draftBox = page.getByRole('dialog', { name: /草稿箱/ });
432
+ await expect(draftBox.locator('.oxa-draft-card')).toBeVisible();
433
+ await expect.poll(async () => { const box = await draftBox.boundingBox(); return box ? Math.abs(box.y + box.height - 844) : Infinity; }).toBeLessThan(2);
434
+ await expect(draftBox.getByRole('button', { name: '继续编辑' })).toBeInViewport();
435
+ await page.screenshot({ path: testInfo.outputPath('mobile-draft-box.png'), animations: 'disabled' });
436
+ await page.getByRole('button', { name: '关闭弹层' }).click();
437
+ await form.getByRole('textbox', { name: '栏目名称', exact: true }).fill('手机恢复后提交');
438
+ await form.getByRole('button', { name: '提交', exact: true }).click();
439
+ await expect(page).toHaveURL('/m/admin/resources/resource-01');
440
+ expect(state.writes).toHaveLength(1); expect(state.drafts).toHaveLength(0);
441
+ });
@@ -684,7 +684,7 @@ async function mockWorkflow(
684
684
  schemaVersion: 'openxiangda.standard-process.commit/v2',
685
685
  environmentKey: 'preproduction',
686
686
  workflowCode: 'purchase-approval',
687
- mutation: { kind: 'create', data: { amount: '28600' } },
687
+ mutation: { kind: 'create', data: { amount: 28600 } },
688
688
  });
689
689
  for (const forbidden of [
690
690
  'facts',
@@ -955,7 +955,7 @@ test('launches the same workflow from the canonical mobile Surface and follows c
955
955
  );
956
956
  await expect(page.locator('.oxa-workflow-submission-mobile')).toBeVisible();
957
957
  await expect(page.getByRole('heading', { name: '采购审批' })).toBeVisible();
958
- await page.getByRole('spinbutton', { name: '请输入申请金额' }).fill('28600');
958
+ await page.getByRole('textbox', { name: '申请金额', exact: true }).fill('28600');
959
959
  await page.getByRole('button', { name: '提交审批' }).click();
960
960
  await expect(page.getByTestId('custom-workflow-detail')).toContainText(
961
961
  `${instanceId} / ${taskId}`
@@ -7,8 +7,15 @@
7
7
  <meta name="openxiangda-app-code" content="field-protocol-fixture" />
8
8
  <meta name="openxiangda-environment" content="preproduction" />
9
9
  <title>Field protocol acceptance</title>
10
+ <style>
11
+ html { background-color: rgb(248, 249, 250); }
12
+ body { color: rgb(31, 32, 33); font-size: 17px; font-family: monospace; }
13
+ a { color: rgb(101, 42, 140); }
14
+ output[data-saved-values] { display: block; overflow-wrap: anywhere; }
15
+ </style>
10
16
  </head>
11
17
  <body>
18
+ <a id="outside-mobile" href="#root">页面外部链接</a>
12
19
  <div id="root"></div>
13
20
  <script type="module" src="/e2e/field-protocol-fixture.tsx"></script>
14
21
  </body>
@@ -0,0 +1,27 @@
1
+ <!doctype html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <meta name="openxiangda-runtime-base" content="/" />
7
+ <meta name="openxiangda-app-code" content="field-protocol-fixture" />
8
+ <meta name="openxiangda-environment" content="preproduction" />
9
+ <title>移动表单组件</title>
10
+ <style>
11
+ body { margin: 0; }
12
+ .reference-form { min-height: 100dvh; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
13
+ .reference-form .oxa-mobile-header { position: sticky; top: 0; z-index: 5; }
14
+ .reference-form .reference-group { margin: 0 12px 16px; padding: 0 16px; background: white; border-radius: 10px; }
15
+ .reference-form .reference-group > div:last-child .oxa-mobile-field { border-bottom: 0; }
16
+ .reference-form .reference-group-title { font-size: 14px; font-weight: 400; color: #888; padding: 0 24px; margin: 20px 0 10px; }
17
+ .reference-form .reference-content { padding-bottom: 86px; }
18
+ .reference-form [data-field-code] { scroll-margin-top: 82px; }
19
+ .reference-form output { display: none; }
20
+ .reference-form .reference-notice { padding: 8px 24px; color: #1677ff; }
21
+ </style>
22
+ </head>
23
+ <body>
24
+ <div id="root"></div>
25
+ <script type="module" src="/e2e/mobile-reference-fixture.tsx"></script>
26
+ </body>
27
+ </html>
@@ -14,7 +14,7 @@
14
14
  "@ant-design/icons": "6.2.3",
15
15
  "@refinedev/core": "5.0.12",
16
16
  "antd": "6.4.3",
17
- "openxiangda": "2.0.0-alpha.95",
17
+ "openxiangda": "2.0.0-alpha.96",
18
18
  "react": "19.2.8",
19
19
  "react-dom": "19.2.8",
20
20
  "react-router-dom": "7.8.2"
@@ -9,6 +9,7 @@
9
9
  <title>Resource experience acceptance</title>
10
10
  </head>
11
11
  <body>
12
+ <div id="outside-ui" style="height:0;overflow:hidden;font-family:monospace;color:rgb(31,32,33);background:rgb(248,249,250)">宿主页面</div>
12
13
  <div id="root"></div>
13
14
  <script type="module" src="/e2e/resource-experience-fixture.tsx"></script>
14
15
  </body>
@@ -0,0 +1,5 @@
1
+ /* The application owns the document reset; platform CSS stays scoped. */
2
+ html, body, #root {
3
+ min-height: 100%;
4
+ margin: 0;
5
+ }
@@ -21,6 +21,7 @@ import {
21
21
  resourceDefinitions,
22
22
  workflowDefinitions,
23
23
  } from '../../../packages/contracts/src/generated.js';
24
+ import './document.css';
24
25
  import './user-end/tailwind.css';
25
26
  import 'openxiangda/react/styles.css';
26
27
 
@@ -1,17 +1,5 @@
1
- /* User-end Tailwind foundation. Keep its reset and tokens separate from admin. */
1
+ /* User-page utilities only; the application owns its document reset. */
2
2
  @import "tailwindcss/theme.css";
3
3
  @import "tailwindcss/utilities.css";
4
4
 
5
5
  @source "../";
6
-
7
- @theme {
8
- --font-sans: Inter, "PingFang SC", "Microsoft YaHei", sans-serif;
9
- --font-display: Inter, "PingFang SC", "Microsoft YaHei", sans-serif;
10
- --color-user-brand-500: oklch(58.5% 0.205 256);
11
- --color-user-brand-600: oklch(51.5% 0.224 256);
12
- --color-user-surface: oklch(99% 0.002 256);
13
- --color-user-canvas: oklch(97.5% 0.006 256);
14
- --color-user-ink: oklch(25% 0.025 256);
15
- --color-user-muted: oklch(50% 0.025 256);
16
- --radius-user-card: 1rem;
17
- }
@@ -1,5 +1,5 @@
1
1
  import assert from 'node:assert/strict';
2
- import { readFileSync, readdirSync } from 'node:fs';
2
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
3
3
  import test from 'node:test';
4
4
  import { readRuntimeMount, resolveApplicationBasename } from 'openxiangda/core';
5
5
  import {
@@ -9,7 +9,7 @@ import {
9
9
  resourceDefinitions,
10
10
  } from '../../../packages/contracts/src/generated.js';
11
11
 
12
- test('clean template contains only application declarations and one React entry', () => {
12
+ test('clean template contains application declarations, document reset and one React entry', () => {
13
13
  const config = readFileSync(
14
14
  new URL('../../../openxiangda.config.ts', import.meta.url),
15
15
  'utf8'
@@ -21,7 +21,7 @@ test('clean template contains only application declarations and one React entry'
21
21
  .filter(entry => entry.isFile())
22
22
  .map(entry => entry.name)
23
23
  .sort();
24
- assert.deepEqual(productionSources, ['main.tsx']);
24
+ assert.deepEqual(productionSources, ['document.css', 'main.tsx']);
25
25
  assert.deepEqual(Object.keys(resourceDefinitions).sort(), [...resourceCodes].sort());
26
26
  assert.equal(
27
27
  notificationTemplateCodes.applicationInformational,
@@ -29,7 +29,7 @@ test('clean template contains only application declarations and one React entry'
29
29
  );
30
30
  assert.equal(platformAuthManifest?.protectedUserRouteCount, 2);
31
31
  assert.equal(platformAuthManifest?.surfaces.length, 2);
32
- assert.match(config, /data:\s*\{\s*resources:/);
32
+ assert.match(config, /defineOpenXiangdaApp/);
33
33
  assert.match(source, /OpenXiangdaApplication/);
34
34
  assert.match(source, /routes: appRoutes, authenticationSurfaces/);
35
35
  assert.match(source, /DefaultDesktopApplicationLoginSurface/);
@@ -53,7 +53,7 @@ test('application manifests expose one direct OpenXiangda dependency', () => {
53
53
  for (const relative of [
54
54
  '../../../package.json',
55
55
  '../package.json',
56
- '../../server/package.json',
56
+ ...(existsSync(new URL('../../server/package.json', import.meta.url)) ? ['../../server/package.json'] : []),
57
57
  ]) {
58
58
  const manifest = JSON.parse(
59
59
  readFileSync(new URL(relative, import.meta.url), 'utf8')
@@ -86,7 +86,7 @@ test('runtime metadata stays mount-scoped', () => {
86
86
 
87
87
  test('resource E2E harness owns the standard export download response', () => {
88
88
  const source = readFileSync(
89
- new URL('../e2e/resources.spec.ts', import.meta.url),
89
+ new URL('../e2e/resource-platform-mock.ts', import.meta.url),
90
90
  'utf8'
91
91
  );
92
92
  assert.match(source, /\/native\\\/data\\\/\(\[\^\/\]\+\)\\\/export\$/);
@@ -142,6 +142,7 @@ test('browser acceptance serializes the prewarmed Vite HTML entrypoints', () =>
142
142
  assert.deepEqual(htmlEntries, [
143
143
  'data-api-live.e2e.html',
144
144
  'field-protocol.e2e.html',
145
+ 'mobile-reference.e2e.html',
145
146
  'resource-experience.e2e.html',
146
147
  'workflow-experience.e2e.html',
147
148
  ]);
@@ -14,6 +14,7 @@ export default defineConfig({
14
14
  'index.html',
15
15
  'data-api-live.e2e.html',
16
16
  'field-protocol.e2e.html',
17
+ 'mobile-reference.e2e.html',
17
18
  'resource-experience.e2e.html',
18
19
  'workflow-experience.e2e.html',
19
20
  ],