openxiangda-cli 2.0.0-alpha.172 → 2.0.0-alpha.174

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openxiangda-cli",
3
- "version": "2.0.0-alpha.172",
3
+ "version": "2.0.0-alpha.174",
4
4
  "description": "Thin application-level CLI for OpenXiangda 2.0.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -21,10 +21,10 @@
21
21
  ],
22
22
  "dependencies": {
23
23
  "@oclif/core": "4.13.3",
24
- "openxiangda-contracts": "2.0.0-alpha.86",
25
- "openxiangda-devkit-core": "2.0.0-alpha.110",
26
- "openxiangda-mcp": "2.0.0-alpha.110",
27
- "openxiangda-skill-kit": "2.0.0-alpha.135"
24
+ "openxiangda-contracts": "2.0.0-alpha.87",
25
+ "openxiangda-devkit-core": "2.0.0-alpha.111",
26
+ "openxiangda-mcp": "2.0.0-alpha.111",
27
+ "openxiangda-skill-kit": "2.0.0-alpha.136"
28
28
  },
29
29
  "devDependencies": {
30
30
  "tsx": "4.23.12",
@@ -75,6 +75,9 @@ test.describe('mobile managed files', () => {
75
75
  await expect(page.locator('.adm-image-viewer-indicator')).toHaveText('2 / 2');
76
76
  await expect(page.locator('.adm-image-viewer-image-wrapper img').last()).toHaveJSProperty('naturalWidth', 640);
77
77
  await page.screenshot({ path: info.outputPath('mobile-image-preview.png') });
78
+ const imageDownload = page.waitForEvent('download');
79
+ await page.getByRole('button', { name: '下载', exact: true }).click();
80
+ expect((await imageDownload).suggestedFilename()).toBe('第二张.png');
78
81
  await page.getByRole('button', { name: '关闭预览', exact: true }).click();
79
82
  await expect(page.getByRole('button', { name: '关闭预览', exact: true })).toHaveCount(0);
80
83
  let downloadAttempts = 0;
@@ -335,7 +335,10 @@ test('renders canonical semantic labels in the real generated resource list', as
335
335
  const detail = page.getByRole('dialog');
336
336
  await expect(detail).toBeVisible();
337
337
  await expect(detail).toContainText('组织信息演示');
338
- await expect(detail.locator('.oxa-form-section')).toHaveCount(2);
338
+ const frame = detail.locator('[data-detail-frame="standard"]');
339
+ await expect(frame).toBeVisible();
340
+ await expect(frame.locator('.oxa-record-detail-card')).toHaveCount(2);
341
+ await expect(frame.locator('.oxa-record-detail-card').getByRole('heading', { level: 2 })).toHaveText(['栏目设置', '管理信息']);
339
342
  });
340
343
 
341
344
  test('renders the canonical first field on the mobile generated list', async ({ page }) => {
@@ -307,7 +307,7 @@ test('record reads can retry and reconnect refresh does not overwrite an edit or
307
307
  await page.context().setOffline(false);
308
308
  await expect.poll(() => state.reads).toBeGreaterThan(reads);
309
309
  await expect(drawer.getByLabel('栏目名称', { exact: true })).toHaveValue('我的修改');
310
- await drawer.getByRole('button', { name: '提交' }).click();
310
+ await drawer.getByRole('button', { name: '保存' }).click();
311
311
  await expect(drawer.getByText('保存失败', { exact: true })).toBeVisible();
312
312
  expect(state.writes.at(-1).expectedRevision).toBe(1);
313
313
  await expect(drawer.getByLabel('栏目名称', { exact: true })).toHaveValue('我的修改');
@@ -464,18 +464,30 @@ test('direct entry is independent and adapts desktop URLs to touch controls with
464
464
  await expect(page.getByRole('textbox', { name: /栏目名称/ })).toHaveValue('保留未暂存输入');
465
465
  });
466
466
 
467
- test('details open readonly in a drawer and closing unsaved entry has no confirmation or write', async ({ page }) => {
467
+ test('details open readonly in a drawer and closing unsaved entry has no confirmation or write', async ({ page }, testInfo) => {
468
468
  const state = await editablePlatform(page);
469
469
  await page.goto('/resource-experience.e2e.html?primary=business');
470
470
  const initialUrl = page.url();
471
471
  await page.getByRole('button', { name: '查看', exact: true }).first().click();
472
472
  const detail = page.getByRole('dialog', { name: '内容栏目详情' });
473
473
  await expect(detail).toBeVisible();
474
- await expect(detail.locator('.oxa-form-sections')).toBeVisible();
475
- await expect(detail.getByText('业务记录 1', { exact: true })).toBeVisible();
474
+ await expect(detail.locator('.oxa-record-detail-sections')).toBeVisible();
475
+ await expect(detail.getByRole('heading', { name: '业务记录 1', exact: true })).toBeVisible();
476
476
  await expect(detail.getByRole('textbox')).toHaveCount(0);
477
477
  await expect(detail.getByRole('button', { name: '提交', exact: true })).toHaveCount(0);
478
478
  expect(page.url()).toBe(initialUrl);
479
+ await expect(detail).toHaveCSS('width', '850px');
480
+ await page.screenshot({ path: testInfo.outputPath('ordinary-detail-drawer-850.png'), animations: 'disabled' });
481
+ await detail.getByRole('button', { name: '编辑', exact: true }).first().click();
482
+ const edit = page.getByRole('dialog', { name: '编辑数据' });
483
+ await expect(page.getByRole('dialog')).toHaveCount(1);
484
+ await expect(edit.locator('.oxa-record-detail.is-editing')).toBeVisible();
485
+ await edit.getByLabel('栏目名称', { exact: true }).fill('普通原地编辑');
486
+ await page.screenshot({ path: testInfo.outputPath('ordinary-detail-inline-edit.png'), animations: 'disabled' });
487
+ await edit.getByRole('button', { name: '关闭', exact: true }).click();
488
+ await expect(detail).toBeVisible();
489
+ await expect(detail.getByRole('heading', { name: '业务记录 1', exact: true })).toBeVisible();
490
+ expect(page.url()).toBe(initialUrl);
479
491
  await detail.getByRole('button', { name: '关闭', exact: true }).click();
480
492
  await page.getByRole('button', { name: '新增', exact: true }).click();
481
493
  const entry = page.getByRole('dialog', { name: '新增数据' });
@@ -81,7 +81,7 @@ function Entry() {
81
81
  const [dismissed, setDismissed] = useState(false);
82
82
  const location = useLocation();
83
83
  if (mode === 'list') return <GeneratedResourcePage resourceCode={code} paths={paths} />;
84
- if (mode === 'mobile-detail') return <Routes><Route path={paths.detail} element={<GeneratedResourcePage resourceCode={code} mode="detail" variant="mobile" paths={paths} />} /></Routes>;
84
+ if (mode === 'mobile-detail' || mode === 'desktop-detail') return <Routes><Route path={paths.detail} element={<GeneratedResourcePage resourceCode={code} mode="detail" variant={mode === 'mobile-detail' ? 'mobile' : 'desktop'} paths={paths} />} /></Routes>;
85
85
  return <>
86
86
  <button onClick={() => setRender(value => value + 1)}>重新渲染父页面</button>
87
87
  <output data-testid="parent-render">{render}</output>
@@ -94,7 +94,7 @@ function Entry() {
94
94
 
95
95
  ReactDOM.createRoot(document.getElementById('root')!).render(
96
96
  <React.StrictMode>
97
- <OpenXiangdaUiProvider><AntdApp><MemoryRouter initialEntries={[mode === 'mobile-detail' ? `${paths.list}/record-1` : paths.list]}><RuntimeBoundary>
97
+ <OpenXiangdaUiProvider><AntdApp><MemoryRouter initialEntries={[(mode === 'mobile-detail' || mode === 'desktop-detail') ? `${paths.list}/record-1` : paths.list]}><RuntimeBoundary>
98
98
  <OpenXiangdaResourceDefinitionsProvider definitions={definitions}>
99
99
  <OpenXiangdaWorkflowDefinitionsProvider definitions={workflowDefinitions}>
100
100
  <Refine dataProvider={createApplicationProvider(definitions)} resources={[{ name: code, list: paths.list, show: paths.detail }]}>
@@ -19,7 +19,7 @@ async function workflowEntryPlatform(page: Page) {
19
19
  const state = {
20
20
  admin: true,
21
21
  record: { id: 'record-1', revision: 8, title: '原业务申请', amount: 28600, decision: '已同意', internalNote: '不得显示', systemValue: '内部标识', attachments: [], richText: '<p>原说明</p>' },
22
- complexFields: false, uploads: [] as any[], childQueries: [] as any[],
22
+ complexFields: false, uploads: [] as any[], childQueries: [] as any[], auditReads: 0,
23
23
  children: [
24
24
  { id: 'line-1', revision: 3, purchaseId: 'record-1', sortOrder: 0, name: '原明细甲', quantity: 2, hiddenNote: '不得显示的明细值', detailNote: '保留详情说明' },
25
25
  { id: 'line-2', revision: 4, purchaseId: 'record-1', sortOrder: 1, name: '原明细乙', quantity: 3, hiddenNote: '不得显示的明细值', detailNote: '第二项说明' },
@@ -58,6 +58,27 @@ async function workflowEntryPlatform(page: Page) {
58
58
  }
59
59
  if (path.endsWith('/auth/surface')) return ok({ csrfToken: 'workflow-entry-csrf' });
60
60
  if (path.includes('/native/admin-list/preferences/')) return ok(null);
61
+ if (path.endsWith(`/workflow/records/${resourceCode}/${state.record.id}/detail`)) {
62
+ const childFields = { name: { label: '品名', type: 'text.short', widget: 'text', ...fieldCapabilities }, quantity: { label: '数量', type: 'number.integer', widget: 'number', ...fieldCapabilities }, detailNote: { label: '仅详情说明', type: 'text.short', widget: 'text', ...fieldCapabilities } };
63
+ const fields = { ...correctionFields, decision: { label: '审批结论', type: 'text.short', widget: 'readonly', ...fieldCapabilities }, ...(state.complexFields ? { items: { label: '采购明细', type: 'subtable', widget: 'subtable', subtable: { resourceCode: 'purchase-lines', foreignKey: 'purchaseId', orderField: 'sortOrder', maxRows: 20 }, ...fieldCapabilities } } : {}) };
64
+ const instance = { id: instanceId, appCode, environmentKey: 'preproduction', status: 'approved', outcome: 'approved', startedAt: '2026-09-06T01:00:00Z', completedAt: '2026-09-06T02:38:00Z', dataRef: { resourceCode, recordId: state.record.id } };
65
+ const surface = { schemaVersion: 'openxiangda.workflow-surface/v2', protocolVersion: 'workflow_surface_v2', engineVersion: '2.0', surfaceRevision: 'a'.repeat(64), instanceSequence: 4, instance, task: null, operations: [],
66
+ detailNavigation: { desktopPath: `/admin/purchases/${state.record.id}`, mobilePath: `/m/admin/purchases/${state.record.id}`, custom: false },
67
+ presentation: { summary: { title: '采购申请', initiatorDisplayName: '陈晨', departmentDisplayName: '办公室', submittedAt: instance.startedAt }, businessDetail: {
68
+ status: 'ready', resourceCode, resourceName: '采购申请', recordId: state.record.id, requestedRevision: state.record.revision, sourceRevision: state.record.revision,
69
+ surface: { mutationOwner: 'workflow', fields, detail: { layout: 'sections', fieldOrder: Object.keys(fields) } }, record: state.record,
70
+ subtables: state.complexFields ? { items: { resourceCode: 'purchase-lines', surface: { fields: childFields, detail: { fieldOrder: Object.keys(childFields) } }, rows: state.children, total: state.children.length } } : {}, projectionDigest: 'c'.repeat(64),
71
+ } },
72
+ };
73
+ const timeline = { engineVersion: '2.0', instanceId, instanceSequence: 4, timelineRevision: 'b'.repeat(64), flow: { nodes: [], edges: [] }, items: [], display: { entries: [] } };
74
+ return ok({ schemaVersion: 'openxiangda.workflow-detail-surface/v2', protocolVersion: 'workflow_detail_surface_v2', detailRevision: 'd'.repeat(64), instanceSequence: 4, timelineRevision: timeline.timelineRevision, surface, timeline,
75
+ currentStatus: { code: 'approved', label: '已同意', tone: 'success' }, nodes: [], handlingRecords: [], operations: [], navigationContext: { desktopReturnPath: '/admin/purchases', mobileReturnPath: '/m/admin/purchases' } });
76
+ }
77
+ if (path.endsWith(`/workflow/instances/${instanceId}/data-audit`)) {
78
+ if (request.headers()['x-openxiangda-csrf-token'] !== 'workflow-entry-csrf') return fail(403, 'WORKFLOW_V2_CSRF_INVALID');
79
+ state.auditReads += 1;
80
+ return ok({ schemaVersion: 'openxiangda.data-audit-page/v2', items: [], total: 0, offset: 0, limit: 50 });
81
+ }
61
82
  if (path.endsWith('/native/data/purchase-lines/query')) {
62
83
  state.childQueries.push(request.postDataJSON());
63
84
  return ok({ schemaVersion: 'openxiangda.data-page/v2', resourceCode: 'purchase-lines', items: state.children, total: state.children.length, offset: 0, limit: 20 });
@@ -185,7 +206,7 @@ async function workflowEntryPlatform(page: Page) {
185
206
 
186
207
  async function openCorrection(page: Page, search = '') {
187
208
  await page.goto(`/workflow-entry.e2e.html${search}`);
188
- await page.getByRole('button', { name: '编辑', exact: true }).click();
209
+ await page.getByRole('button', { name: '编辑', exact: true }).first().click();
189
210
  const drawer = page.getByRole('dialog', { name: '编辑数据' });
190
211
  await expect(drawer.getByLabel('申请名称', { exact: true })).toHaveValue('原业务申请');
191
212
  await expect(drawer.getByLabel('申请金额', { exact: true })).toHaveValue('28600');
@@ -199,14 +220,14 @@ test('ordinary workflow editing uses the shared form and opens readonly detail a
199
220
  await expect(drawer.getByText(/更正|审计|重新审批/)).toHaveCount(0);
200
221
  for (const label of ['隐藏字段', '系统字段', '审批结论', '审批附件']) await expect(drawer.getByLabel(label, { exact: true })).toHaveCount(0);
201
222
  await drawer.getByLabel('申请名称', { exact: true }).fill('已更正的业务申请');
202
- await drawer.getByRole('button', { name: '提交' }).dblclick();
223
+ await drawer.getByRole('button', { name: '保存' }).dblclick();
203
224
  await expect(drawer.getByRole('button', { name: '关闭', exact: true })).toBeDisabled();
204
225
  await page.keyboard.press('Escape');
205
226
  await expect(drawer).toBeVisible();
206
- const detail = page.getByRole('dialog', { name: '采购申请详情' });
207
- await expect(detail.getByText('已更正的业务申请', { exact: true })).toBeVisible();
227
+ const detail = page.getByRole('dialog', { name: '申请详情' });
228
+ await expect(detail.getByRole('heading', { name: '已更正的业务申请', exact: true })).toBeVisible();
208
229
  await expect(detail.locator('input,textarea')).toHaveCount(0);
209
- await expect(detail.getByText('已同意', { exact: true })).toBeVisible();
230
+ await expect(detail.locator('.oxa-record-detail-hero').getByText('已同意', { exact: true })).toBeVisible();
210
231
  expect(state.correctionWrites).toHaveLength(1);
211
232
  expect(state.correctionWrites[0].operations[0]).toMatchObject({ expectedRevision: 8, data: { title: '已更正的业务申请', amount: 28600 } });
212
233
  expect(Object.keys(state.correctionWrites[0].operations[0].data).sort()).toEqual(['amount', 'title']);
@@ -221,11 +242,11 @@ test('an uncertain workflow edit preserves input and replays the original revisi
221
242
  state.loseCorrectionResponse = true;
222
243
  const drawer = await openCorrection(page);
223
244
  await drawer.getByLabel('申请名称', { exact: true }).fill('网络重试后保留的申请');
224
- await drawer.getByRole('button', { name: '提交' }).click();
245
+ await drawer.getByRole('button', { name: '保存' }).click();
225
246
  await expect(drawer.getByText('响应暂时不可用,请重试')).toBeVisible();
226
247
  await expect(drawer.getByLabel('申请名称', { exact: true })).toHaveValue('网络重试后保留的申请');
227
- await drawer.getByRole('button', { name: '提交' }).click();
228
- await expect(page.getByRole('dialog', { name: '采购申请详情' }).getByText('网络重试后保留的申请', { exact: true })).toBeVisible();
248
+ await drawer.getByRole('button', { name: '保存' }).click();
249
+ await expect(page.getByRole('dialog', { name: '申请详情' }).getByRole('heading', { name: '网络重试后保留的申请', exact: true })).toBeVisible();
229
250
  expect(state.correctionWrites).toHaveLength(2);
230
251
  expect(state.correctionWrites[1]).toEqual(state.correctionWrites[0]);
231
252
  expect(state.correctionEffects).toBe(1);
@@ -238,9 +259,9 @@ test('CAS conflicts retain the original form and revision without a hidden reloa
238
259
  const initialReads = state.correctionReads;
239
260
  state.record.revision = 9;
240
261
  await drawer.getByLabel('申请名称', { exact: true }).fill('尚未覆盖并发修改');
241
- await drawer.getByRole('button', { name: '提交' }).click();
262
+ await drawer.getByRole('button', { name: '保存' }).click();
242
263
  await expect(drawer.getByText('数据已发生变化,请关闭后重新核对')).toBeVisible();
243
- await drawer.getByRole('button', { name: '提交' }).click();
264
+ await drawer.getByRole('button', { name: '保存' }).click();
244
265
  await expect.poll(() => state.correctionWrites.length).toBe(2);
245
266
  await expect(drawer.getByLabel('申请名称', { exact: true })).toHaveValue('尚未覆盖并发修改');
246
267
  expect(state.correctionWrites[1]).toEqual(state.correctionWrites[0]);
@@ -255,7 +276,7 @@ test('revoked admin authority fails saving and hides editing when identity is re
255
276
  const drawer = await openCorrection(page);
256
277
  state.admin = false;
257
278
  await drawer.getByLabel('申请名称', { exact: true }).fill('撤权后不能保存');
258
- await drawer.getByRole('button', { name: '提交' }).click();
279
+ await drawer.getByRole('button', { name: '保存' }).click();
259
280
  await expect(drawer.getByText('当前用户无权编辑数据')).toBeVisible();
260
281
  await expect(drawer.getByLabel('申请名称', { exact: true })).toHaveValue('撤权后不能保存');
261
282
  await page.reload();
@@ -268,7 +289,7 @@ test('record edit load retries and rejects a Surface for another environment or
268
289
  const state = await workflowEntryPlatform(page);
269
290
  state.correctionReadFailure = true;
270
291
  await page.goto('/workflow-entry.e2e.html');
271
- await page.getByRole('button', { name: '编辑', exact: true }).click();
292
+ await page.getByRole('button', { name: '编辑', exact: true }).first().click();
272
293
  const drawer = page.getByRole('dialog', { name: '编辑数据' });
273
294
  await expect(drawer.getByText('暂时无法读取编辑表单')).toBeVisible();
274
295
  state.correctionReadFailure = false;
@@ -309,8 +330,8 @@ test('workflow launch retries one command, blocks close while processing and ope
309
330
  await page.keyboard.press('Escape');
310
331
  await expect(drawer).toBeVisible();
311
332
  state.processStatus = 'started';
312
- const detail = page.getByRole('dialog', { name: '采购申请详情' });
313
- await expect(detail.getByText('抽屉新增的申请', { exact: true })).toBeVisible();
333
+ const detail = page.getByRole('dialog', { name: '申请详情' });
334
+ await expect(detail.getByRole('heading', { name: '抽屉新增的申请', exact: true })).toBeVisible();
314
335
  await expect(detail.locator('input,textarea')).toHaveCount(0);
315
336
  expect(state.launches).toHaveLength(2);
316
337
  expect(state.launches[1]).toEqual(state.launches[0]);
@@ -374,11 +395,25 @@ test('supplementary answers are guarded and resume completion without resubmitti
374
395
  expect(state.launches).toHaveLength(1);
375
396
  });
376
397
 
398
+ test('workflow change history uses the acquired Surface CSRF token', async ({ page }) => {
399
+ const state = await workflowEntryPlatform(page);
400
+ await page.goto('/workflow-entry.e2e.html');
401
+ await page.getByRole('button', { name: '查看', exact: true }).click();
402
+ const detail = page.getByRole('dialog', { name: '申请详情' });
403
+ await detail.getByRole('tab', { name: '变更记录' }).click();
404
+ await expect.poll(() => state.auditReads).toBeGreaterThan(0);
405
+ await expect(detail.getByText('暂无变更记录', { exact: true })).toBeVisible();
406
+ await expect(detail.getByText(/变更记录读取失败/)).toHaveCount(0);
407
+ });
408
+
377
409
  test('detail switches to the ordinary full-width editor and back with only one dialog', async ({ page }, testInfo) => {
378
410
  const state = await workflowEntryPlatform(page);
379
411
  await page.goto('/workflow-entry.e2e.html');
380
412
  await page.getByRole('button', { name: '查看', exact: true }).click();
381
- const detail = page.getByRole('dialog', { name: '采购申请详情' });
413
+ const detail = page.getByRole('dialog', { name: '申请详情' });
414
+ await expect(detail.getByRole('heading', { name: '原业务申请', exact: true })).toBeVisible();
415
+ await expect(detail).toHaveCSS('width', '850px');
416
+ await page.screenshot({ path: testInfo.outputPath('workflow-detail-drawer-850.png'), animations: 'disabled' });
382
417
  await detail.getByRole('button', { name: '全屏', exact: true }).click();
383
418
  await expect.poll(async () => Math.round((await detail.boundingBox())!.width)).toBe(page.viewportSize()!.width);
384
419
  await detail.getByRole('button', { name: '编辑', exact: true }).click();
@@ -390,28 +425,50 @@ test('detail switches to the ordinary full-width editor and back with only one d
390
425
  await expect.poll(async () => Math.round((await editor.boundingBox())!.width)).toBe(page.viewportSize()!.width);
391
426
  await editor.getByLabel('申请名称', { exact: true }).fill('直接编辑的数据');
392
427
  await page.screenshot({ path: testInfo.outputPath('workflow-ordinary-editor-fullscreen.png'), animations: 'disabled' });
393
- await editor.getByRole('button', { name: '提交', exact: true }).click();
394
- await expect(detail.getByText('直接编辑的数据', { exact: true })).toBeVisible();
428
+ await editor.getByRole('button', { name: '保存', exact: true }).click();
429
+ await expect(detail.getByRole('heading', { name: '直接编辑的数据', exact: true })).toBeVisible();
395
430
  await expect(page.getByRole('dialog')).toHaveCount(1);
396
431
  await expect(detail.getByRole('button', { name: '退出全屏' })).toBeVisible();
397
432
  expect(state.correctionWrites[0].operations[0].id).toBe('record-1');
398
433
  expect(state.launches).toEqual([]);
399
434
  });
400
435
 
436
+ test('standalone record detail keeps the approved content width and cancels editing in place', async ({ page }, testInfo) => {
437
+ const state = await workflowEntryPlatform(page);
438
+ await page.setViewportSize({ width: 1440, height: 900 });
439
+ await page.goto('/workflow-entry.e2e.html?mode=desktop-detail');
440
+ const heading = page.getByRole('heading', { name: '原业务申请', exact: true });
441
+ await expect(heading).toBeVisible();
442
+ await expect(page.getByRole('dialog')).toHaveCount(0);
443
+ await expect(page.locator('.oxa-record-detail-body > .oxa-record-detail-width')).toHaveCSS('width', '1056px');
444
+ await expect(page.locator('.oxa-record-detail-hero h1')).toHaveCSS('font-size', '23px');
445
+ await expect(page.getByRole('tab')).toHaveCount(3);
446
+ await page.screenshot({ path: testInfo.outputPath('workflow-detail-standalone-1056.png'), animations: 'disabled' });
447
+ await page.getByRole('button', { name: '编辑', exact: true }).first().click();
448
+ await page.getByLabel('申请名称', { exact: true }).fill('取消的更改');
449
+ await page.screenshot({ path: testInfo.outputPath('workflow-detail-standalone-inline-edit.png'), animations: 'disabled' });
450
+ await page.getByRole('button', { name: /取\s*消/ }).click();
451
+ await expect(heading).toBeVisible();
452
+ expect(state.correctionWrites).toHaveLength(0);
453
+ expect(state.launches).toHaveLength(0);
454
+ });
455
+
401
456
  test('mobile detail edits on the current page and returns to the same readonly record', async ({ page }, testInfo) => {
402
457
  const state = await workflowEntryPlatform(page);
403
458
  await page.setViewportSize({ width: 390, height: 844 });
404
459
  await page.goto('/workflow-entry.e2e.html?mode=mobile-detail');
405
- await expect(page.getByText('原业务申请', { exact: true })).toBeVisible();
406
- await page.getByRole('button', { name: '编辑', exact: true }).click();
460
+ await expect(page.getByRole('heading', { name: '原业务申请', exact: true })).toBeVisible();
461
+ await expect(page.locator('.oxa-record-detail-hero h1')).toHaveCSS('font-size', '20px');
462
+ await page.screenshot({ path: testInfo.outputPath('workflow-detail-mobile-390.png'), animations: 'disabled' });
463
+ await page.getByRole('button', { name: '编辑', exact: true }).first().click();
407
464
  await expect(page.getByRole('dialog')).toHaveCount(0);
408
465
  const form = page.locator('.oxa-mobile-form');
409
466
  await expect(form.getByRole('textbox', { name: '申请名称', exact: true })).toHaveValue('原业务申请');
410
467
  await form.getByRole('textbox', { name: '申请名称', exact: true }).fill('移动页面直接修改');
411
468
  expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
412
469
  await page.screenshot({ path: testInfo.outputPath('workflow-ordinary-editor-mobile.png'), animations: 'disabled' });
413
- await form.getByRole('button', { name: '提交', exact: true }).click();
414
- await expect(page.getByText('移动页面直接修改', { exact: true })).toBeVisible();
470
+ await form.getByRole('button', { name: '保存', exact: true }).click();
471
+ await expect(page.getByRole('heading', { name: '移动页面直接修改', exact: true })).toBeVisible();
415
472
  await expect(page.getByRole('dialog')).toHaveCount(0);
416
473
  await expect(page.locator('.oxa-readonly-form input')).toHaveCount(0);
417
474
  expect(state.record.id).toBe('record-1');
@@ -423,30 +480,27 @@ test('ordinary workflow editing reuses Native uploads, rich text and parent-chil
423
480
  state.complexFields = true;
424
481
  const editor = await openCorrection(page, '?fields=complex');
425
482
  const subtable = editor.locator('.oxa-subtable-field');
426
- await expect(subtable.getByText('原明细甲', { exact: true })).toBeVisible();
483
+ await expect(subtable.getByLabel('品名', { exact: true }).first()).toHaveValue('原明细甲');
427
484
  await expect(subtable.getByText('仅详情说明', { exact: true })).toHaveCount(0);
428
485
  await expect(subtable.getByText('隐藏明细字段', { exact: true })).toHaveCount(0);
429
486
  await editor.locator('input[type="file"]').first().setInputFiles({ name: '申请说明.txt', mimeType: 'text/plain', buffer: Buffer.from('说明') });
430
487
  await expect(editor.getByText('申请说明.txt', { exact: true })).toBeVisible();
431
488
  await editor.getByRole('textbox', { name: '富文本内容' }).fill('直接修改富文本说明');
432
- await subtable.getByRole('row').filter({ hasText: '原明细甲' }).getByRole('button', { name: '编辑', exact: true }).click();
433
- const rowEditor = page.getByRole('dialog', { name: '编辑明细' });
434
- await expect(rowEditor.getByLabel('仅详情说明', { exact: true })).toHaveCount(0);
435
- await expect(rowEditor.getByLabel('隐藏明细字段', { exact: true })).toHaveCount(0);
436
- await rowEditor.getByLabel('品名', { exact: true }).fill('修改后的明细甲');
437
- await rowEditor.getByRole('button', { name: '确 定' }).click();
438
- await subtable.getByRole('row').filter({ hasText: '原明细乙' }).getByRole('button', { name: '删除', exact: true }).click();
439
- await subtable.getByRole('button', { name: 'plus 添加' }).click();
440
- const newRow = page.getByRole('dialog', { name: '添加明细' });
441
- await newRow.getByLabel('品名', { exact: true }).fill('新增明细丙');
442
- await newRow.getByLabel('数量', { exact: true }).fill('5');
443
- await newRow.getByRole('button', { name: '确 定' }).click();
489
+ await subtable.getByLabel('品名', { exact: true }).first().fill('');
490
+ await editor.getByRole('button', { name: '保存', exact: true }).click();
491
+ await expect(editor.getByText('请完善子表单第 1 项')).toBeVisible();
492
+ expect(state.correctionWrites).toHaveLength(0);
493
+ await subtable.getByLabel('品名', { exact: true }).first().fill('修改后的明细甲');
494
+ await subtable.getByRole('row').nth(2).getByRole('button', { name: '删除', exact: true }).click();
495
+ await subtable.getByRole('button', { name: '新增一项' }).click();
496
+ await subtable.getByLabel('品名', { exact: true }).nth(1).fill('新增明细丙');
497
+ await subtable.getByLabel('数量', { exact: true }).nth(1).fill('5');
444
498
  await expect(page.getByRole('dialog')).toHaveCount(1);
445
499
  await page.screenshot({ path: testInfo.outputPath('workflow-ordinary-editor-attachments-subtable.png'), animations: 'disabled' });
446
- await editor.getByRole('button', { name: '提交', exact: true }).click();
447
- await expect(page.getByRole('dialog', { name: '采购申请详情' })).toBeVisible();
448
- await expect(page.getByRole('dialog', { name: '采购申请详情' }).getByText('保留详情说明', { exact: true })).toBeVisible();
449
- await expect(page.getByRole('dialog', { name: '采购申请详情' }).getByText('不得显示的明细值', { exact: true })).toHaveCount(0);
500
+ await editor.getByRole('button', { name: '保存', exact: true }).click();
501
+ await expect(page.getByRole('dialog', { name: '申请详情' })).toBeVisible();
502
+ await expect(page.getByRole('dialog', { name: '申请详情' }).getByText('保留详情说明', { exact: true })).toBeVisible();
503
+ await expect(page.getByRole('dialog', { name: '申请详情' }).getByText('不得显示的明细值', { exact: true })).toHaveCount(0);
450
504
  expect(state.uploads).toMatchObject([{ fieldCode: 'attachments', recordId: 'record-1' }]);
451
505
  const operations = state.correctionWrites[0].operations;
452
506
  expect(operations[0]).toMatchObject({ operation: 'update', id: 'record-1', expectedRevision: 8, data: { attachments: [{ id: 'file-1' }] } });
@@ -467,18 +521,18 @@ test('mobile child editing selects form fields while readonly details retain the
467
521
  state.complexFields = true;
468
522
  await page.setViewportSize({ width: 390, height: 844 });
469
523
  await page.goto('/workflow-entry.e2e.html?mode=mobile-detail&fields=complex');
470
- await expect(page.getByRole('textbox', { name: '仅详情说明', exact: true }).first()).toHaveValue('保留详情说明');
524
+ await expect(page.locator('.oxa-workflow-subtable-mobile')).toContainText('保留详情说明');
471
525
  await expect(page.getByLabel('隐藏明细字段', { exact: true })).toHaveCount(0);
472
- await page.getByRole('button', { name: '编辑', exact: true }).click();
526
+ await page.getByRole('button', { name: '编辑', exact: true }).first().click();
473
527
  const form = page.locator('.oxa-mobile-form');
474
528
  const childName = form.getByRole('textbox', { name: '品名', exact: true }).first();
475
529
  await expect(childName).toHaveValue('原明细甲');
476
530
  await expect(form.getByLabel('仅详情说明', { exact: true })).toHaveCount(0);
477
531
  await expect(form.getByLabel('隐藏明细字段', { exact: true })).toHaveCount(0);
478
532
  await childName.fill('移动明细修改');
479
- await form.getByRole('button', { name: '提交', exact: true }).click();
480
- await expect(page.getByRole('textbox', { name: '仅详情说明', exact: true }).first()).toHaveValue('保留详情说明');
481
- await expect(page.getByRole('textbox', { name: '品名', exact: true }).first()).toHaveValue('移动明细修改');
533
+ await form.getByRole('button', { name: '保存', exact: true }).click();
534
+ await expect(page.locator('.oxa-workflow-subtable-mobile')).toContainText('保留详情说明');
535
+ await expect(page.locator('.oxa-workflow-subtable-mobile')).toContainText('移动明细修改');
482
536
  await expect(page.getByRole('dialog')).toHaveCount(0);
483
537
  expect(state.correctionWrites[0].operations.slice(1)).toEqual([
484
538
  { operation: 'update', resourceCode: 'purchase-lines', id: 'line-1', expectedRevision: 3, data: { name: '移动明细修改' } },
@@ -526,7 +526,7 @@ function processCommandSurface() {
526
526
  };
527
527
  }
528
528
 
529
- function todoPage(offset = 0) {
529
+ function todoPage(offset = 0, view = 'all') {
530
530
  const first = {
531
531
  messageId: '33333333-3333-4333-8333-333333333333',
532
532
  recipientId: '44444444-4444-4444-8444-444444444444',
@@ -560,7 +560,7 @@ function todoPage(offset = 0) {
560
560
  schemaVersion: 'openxiangda.application-todo-center/v2',
561
561
  appCode,
562
562
  environmentKey: 'preproduction',
563
- view: 'pending',
563
+ view,
564
564
  counts: { pending: 1, informational: 2, completed: 3, unread: 1 },
565
565
  items: [offset > 0 ? second : first],
566
566
  total: 2,
@@ -649,7 +649,7 @@ async function mockWorkflow(
649
649
  return route.fulfill({
650
650
  contentType: 'application/json',
651
651
  body: JSON.stringify(
652
- envelope(todoPage(Number(url.searchParams.get('offset') || 0))),
652
+ envelope(todoPage(Number(url.searchParams.get('offset') || 0), url.searchParams.get('view') || 'all')),
653
653
  ),
654
654
  });
655
655
  }
@@ -709,6 +709,7 @@ async function mockWorkflow(
709
709
  });
710
710
  }
711
711
  if (path.endsWith('/workflow/work-center/items')) {
712
+ const view = url.searchParams.get('view') || 'pending';
712
713
  return route.fulfill({
713
714
  contentType: 'application/json',
714
715
  body: JSON.stringify(
@@ -716,11 +717,12 @@ async function mockWorkflow(
716
717
  engineVersion: '2.0',
717
718
  authorizationMode: 'current_user_role_union',
718
719
  authorizationDigest: 'digest-1',
719
- status: 'pending',
720
+ view,
721
+ counts: { created: 1, pending: completed ? 0 : 1, handled: completed ? 1 : 0, cc: 1 },
720
722
  total: 1,
721
723
  limit: 20,
722
724
  offset: 0,
723
- items: [task(completed, options.customDetail)],
725
+ items: [{ ...task(completed, options.customDetail), id: view === 'pending' ? taskId : instanceId, itemKind: view === 'pending' ? 'task' : 'instance', instanceId, taskId: view === 'pending' ? taskId : null, workflowTitle: '采购审批', taskTitle: '负责人审批', instanceStatus: completed ? 'approved' : 'running', resourceCode: 'purchase-orders', recordId: 'purchase-1', occurredAt: '2026-08-25T01:00:00.000Z' }],
724
726
  })
725
727
  ),
726
728
  });
@@ -803,9 +805,55 @@ test('renders a paged desktop work center from the current-user role union', asy
803
805
  }) => {
804
806
  await mockWorkflow(page);
805
807
  await page.goto('/workflow-experience.e2e.html?initial=/work-center');
806
- await expect(page.getByRole('heading', { name: '审批工作台' })).toBeVisible();
807
- await expect(page.getByRole('link', { name: '采购申请审批' })).toBeVisible();
808
- await expect(page.getByText('PO-2026-0825')).toBeVisible();
808
+ await expect(page.getByRole('heading', { name: '待办中心' })).toBeVisible();
809
+ await expect(page.getByRole('button', { name: '采购申请审批' })).toBeVisible();
810
+ await expect(page.getByText('采购审批', { exact: true })).toBeVisible();
811
+ for (const [view, label] of [['created', '我创建的'], ['handled', '我已处理'], ['cc', '抄送我的'], ['pending', '待我审批']]) {
812
+ const request = page.waitForRequest(request => new URL(request.url()).pathname.endsWith('/workflow/work-center/items') && new URL(request.url()).searchParams.get('view') === view);
813
+ await page.getByRole('tab', { name: new RegExp(label) }).click();
814
+ await request;
815
+ await expect(page.getByRole('button', { name: '采购申请审批' })).toBeVisible();
816
+ }
817
+ });
818
+
819
+ test('a current task handler can send CC with the Surface instance command and multiple directory users', async ({ page }) => {
820
+ await mockWorkflow(page);
821
+ const current = detail();
822
+ const cc = {
823
+ key: 'cc', kind: 'workflow_command', label: '抄送', group: 'instance_control', audience: 'participant',
824
+ placement: 'overflow', emphasis: 'neutral', tone: 'neutral', visible: true, enabled: true,
825
+ inputSchema: { type: 'object', additionalProperties: false, required: ['userIds'], properties: {
826
+ userIds: { type: 'array', title: '抄送给', minItems: 1, maxItems: 20, uniqueItems: true, items: { type: 'string' } },
827
+ comment: { type: 'string', title: '留言', maxLength: 4000 },
828
+ } },
829
+ uiSchema: { presentation: 'dialog', confirmText: '确认抄送', properties: { userIds: { component: 'user_select', mode: 'multiple' } } },
830
+ execute: { method: 'POST', href: `/openxiangda-api/v2/applications/${appCode}/workflow/instances/${instanceId}/commands/cc`, idempotencyRequired: true },
831
+ refresh: ['surface', 'timeline', 'work_center'],
832
+ };
833
+ const augmented = { ...current, operations: [...current.operations, cc], surface: { ...current.surface, operations: [...current.surface.operations, cc] } };
834
+ await page.route('**/workflow/tasks/*/detail', route => route.fulfill({ json: envelope(augmented) }));
835
+ await page.route('**/directory/users?**', route => route.fulfill({ json: envelope({ items: [{ id: 'cc-user-1', label: '陈老师' }, { id: 'cc-user-2', label: '李老师' }], nextCursor: null }) }));
836
+ let writes = 0;
837
+ await page.route('**/workflow/instances/*/commands/cc', async route => {
838
+ writes += 1;
839
+ expect(route.request().postDataJSON()).toMatchObject({ commandToken: current.surface.commandToken, input: { userIds: ['cc-user-1', 'cc-user-2'], comment: '请查阅' } });
840
+ expect(route.request().postDataJSON().idempotencyKey).toBeTruthy();
841
+ expect(route.request().headers()['x-openxiangda-csrf-token']).toBe('workflow-csrf-1');
842
+ await route.fulfill({ json: envelope({ status: 'completed', advanced: false, instanceId }) });
843
+ });
844
+ await page.goto(workflowFixtureUrl(`/tasks/${taskId}`));
845
+ await page.getByRole('button', { name: '更多操作' }).click();
846
+ await page.getByRole('button', { name: '抄送', exact: true }).click();
847
+ await page.getByRole('button', { name: '确认抄送' }).click();
848
+ expect(writes).toBe(0);
849
+ const select = page.getByRole('dialog').getByRole('combobox');
850
+ await select.fill('老师');
851
+ await page.getByTitle('陈老师', { exact: true }).click();
852
+ await page.getByTitle('李老师', { exact: true }).click();
853
+ await page.getByLabel('留言').fill('请查阅');
854
+ await page.getByRole('button', { name: '确认抄送' }).click();
855
+ await expect.poll(() => writes).toBe(1);
856
+ await expect(page.getByRole('button', { name: /同\s*意/ })).toBeVisible();
809
857
  });
810
858
 
811
859
  test('renders the generic desktop and mobile application todo center without a second router', async ({
@@ -813,7 +861,7 @@ test('renders the generic desktop and mobile application todo center without a s
813
861
  }) => {
814
862
  await mockWorkflow(page);
815
863
  await page.goto('/workflow-experience.e2e.html?initial=/todos');
816
- await expect(page.getByRole('heading', { name: '待办中心' })).toBeVisible();
864
+ await expect(page.getByRole('heading', { name: '消息中心' })).toBeVisible();
817
865
  await expect(page.getByTestId('standard-frame-desktop')).toHaveCount(0);
818
866
  await expect(page.locator('.oxa-todo-table')).toBeVisible();
819
867
  await expect(page.locator('.oxa-todo-preview-card')).toHaveCount(0);
@@ -881,7 +929,7 @@ test('selects paired application todo renderers while the platform owns data, pa
881
929
  await page.getByRole('button', { name: '切换未读' }).click();
882
930
  await unreadRequest;
883
931
  await expect(page.getByTestId('custom-todo-query')).toContainText(
884
- 'pending/true/all',
932
+ 'all/true/all',
885
933
  );
886
934
 
887
935
  await page.getByRole('button', { name: '设备采购申请待审批' }).click();
@@ -970,7 +1018,7 @@ test('routes desktop and mobile work-center entries to the declared custom detai
970
1018
  }) => {
971
1019
  await mockWorkflow(page, { customDetail: true });
972
1020
  await page.goto('/workflow-experience.e2e.html?initial=/work-center');
973
- await page.getByRole('link', { name: '采购申请审批' }).click();
1021
+ await page.getByRole('button', { name: '采购申请审批' }).click();
974
1022
  await expect(page.getByTestId('custom-workflow-detail')).toContainText(
975
1023
  `${instanceId} / ${taskId}`
976
1024
  );
@@ -1008,7 +1056,7 @@ test('renders a standalone desktop detail on the canonical user URL', async ({
1008
1056
  await page.goto(
1009
1057
  workflowFixtureUrl(`/tasks/${taskId}?from=todo#approval`),
1010
1058
  );
1011
- await expect(page.locator('.oxa-workflow-desktop-standalone-page')).toBeVisible();
1059
+ await expect(page.locator('.oxa-record-detail-desktop.is-page')).toBeVisible();
1012
1060
  await expect(page).toHaveURL(
1013
1061
  new RegExp(`/tasks/${taskId}\\?from=todo#approval$`),
1014
1062
  );
@@ -1019,6 +1067,7 @@ test('renders a standalone desktop detail on the canonical user URL', async ({
1019
1067
  await expect(page.getByText('业务数据已变化')).toHaveCount(0);
1020
1068
  await expect(page.getByText('流程信息')).toHaveCount(0);
1021
1069
  await expect(page.getByText('操作记录')).toHaveCount(0);
1070
+ await page.getByRole('tab', { name: '审批历史' }).click();
1022
1071
  await expect(page.getByText('由值班负责人继续处理')).toHaveCount(1);
1023
1072
  await expect(page.getByRole('button', { name: /同\s*意/ })).toBeVisible();
1024
1073
  await expect(page.getByRole('button', { name: /拒\s*绝/ })).toBeVisible();
@@ -1042,8 +1091,9 @@ test('renders the independent mobile task and executes only a Surface operation'
1042
1091
  await mockWorkflow(page);
1043
1092
  await page.setViewportSize({ width: 390, height: 844 });
1044
1093
  await page.goto(`/workflow-experience.e2e.html?initial=/m/tasks/${taskId}`);
1045
- await expect(page.locator('.oxa-workflow-mobile-page')).toBeVisible();
1094
+ await expect(page.locator('.oxa-record-detail-mobile')).toBeVisible();
1046
1095
  await expect(page.getByText('¥30,000')).toBeVisible();
1096
+ await page.getByRole('tab', { name: '审批历史' }).click();
1047
1097
  await expect(page.getByText('财务负责人').first()).toBeVisible();
1048
1098
  await expect(page.getByRole('button', { name: /同\s*意/ })).toBeVisible();
1049
1099
  await page.getByRole('button', { name: /同\s*意/ }).click();
@@ -1064,7 +1114,7 @@ test('shows an explicit authorization error instead of an empty work center', as
1064
1114
  await expect(page.getByRole('alert')).toContainText('403: forbidden', {
1065
1115
  timeout: 30_000,
1066
1116
  });
1067
- await expect(page.getByRole('link', { name: '采购申请审批' })).toHaveCount(0);
1117
+ await expect(page.getByRole('button', { name: '采购申请审批' })).toHaveCount(0);
1068
1118
  });
1069
1119
 
1070
1120
  test('negotiates direct standard routes after BrowserRouter listener setup', async ({
@@ -1112,7 +1162,7 @@ test('negotiates direct standard routes after BrowserRouter listener setup', asy
1112
1162
  await page.goto(
1113
1163
  workflowFixtureUrl(`/tasks/${taskId}?from=todo#approval`),
1114
1164
  );
1115
- await expect(page.locator('.oxa-workflow-detail-mobile-native')).toBeVisible();
1165
+ await expect(page.locator('.oxa-record-detail-mobile')).toBeVisible();
1116
1166
  await expect(page).toHaveURL(
1117
1167
  new RegExp(`/m/tasks/${taskId}\\?from=todo#approval$`),
1118
1168
  );
@@ -1120,7 +1170,7 @@ test('negotiates direct standard routes after BrowserRouter listener setup', asy
1120
1170
  await page.goto(
1121
1171
  workflowFixtureUrl(`/workflows/${instanceId}?from=todo#timeline`),
1122
1172
  );
1123
- await expect(page.locator('.oxa-workflow-detail-mobile-native')).toBeVisible();
1173
+ await expect(page.locator('.oxa-record-detail-mobile')).toBeVisible();
1124
1174
  await expect(page).toHaveURL(
1125
1175
  new RegExp(`/m/workflows/${instanceId}\\?from=todo#timeline$`),
1126
1176
  );
@@ -1146,7 +1196,7 @@ test('negotiates launch pairs in both directions and preserves the mount basenam
1146
1196
  );
1147
1197
 
1148
1198
  await page.setViewportSize({ width: 1_440, height: 900 });
1149
- await expect(page.locator('.oxa-workflow-submission-desktop')).toBeVisible();
1199
+ await expect(page.locator('.oxa-workflow-entry')).toBeVisible();
1150
1200
  await expect(page).toHaveURL(
1151
1201
  new RegExp(
1152
1202
  `${base}/workflows/purchase-approval/start\\?source=todo#launch$`,
@@ -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.98",
17
+ "openxiangda": "2.0.0-alpha.100",
18
18
  "react": "19.2.8",
19
19
  "react-dom": "19.2.8",
20
20
  "react-router-dom": "7.8.2"
@@ -22,7 +22,7 @@
22
22
  "build": "pnpm --recursive build"
23
23
  },
24
24
  "devDependencies": {
25
- "openxiangda": "2.0.0-alpha.98",
25
+ "openxiangda": "2.0.0-alpha.100",
26
26
  "typescript": "5.9.3"
27
27
  }
28
28
  }