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

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.171",
3
+ "version": "2.0.0-alpha.172",
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.85",
25
- "openxiangda-devkit-core": "2.0.0-alpha.109",
26
- "openxiangda-mcp": "2.0.0-alpha.109",
27
- "openxiangda-skill-kit": "2.0.0-alpha.134"
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"
28
28
  },
29
29
  "devDependencies": {
30
30
  "tsx": "4.23.12",
@@ -3,7 +3,7 @@ import { appCode, capabilities } from '../../../packages/contracts/src/generated
3
3
  const runtimeBase = `/view/${appCode}`;
4
4
  const allCapabilities = [...capabilities] as string[];
5
5
 
6
- function runtimeAuthorization(authorized: boolean, superAdmin = false) {
6
+ export function runtimeAuthorization(authorized: boolean, superAdmin = false) {
7
7
  return {
8
8
  schemaVersion: 'openxiangda.runtime-authorization/v2',
9
9
  state: 'active',
@@ -148,7 +148,7 @@ test('named views share records while keeping columns, drawer routes and drafts
148
148
  await expect(nextPage.getByLabel('栏目说明', { exact: true })).toHaveCount(0);
149
149
  await nextPage.screenshot({ path: testInfo.outputPath('named-quick-new-page.png'), fullPage: true, animations: 'disabled' });
150
150
  await nextPage.getByRole('button', { name: '提交', exact: true }).click();
151
- await expect(nextPage).toHaveURL(`${base}/quick`);
151
+ await expect(nextPage).toHaveURL(`${base}/quick/record-22`);
152
152
  expect(state.writes).toHaveLength(1);
153
153
  expect(state.writes[0].resourceCode).toBe('resource-01');
154
154
  expect(state.records.at(-1)?.name).toBe('简要登记的草稿');
@@ -174,7 +174,7 @@ test('mobile named forms retain their grouping and return to the same view after
174
174
  expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
175
175
  await page.screenshot({ path: testInfo.outputPath('named-mobile-complete-form.png'), fullPage: true, animations: 'disabled' });
176
176
  await form.getByRole('button', { name: '提交', exact: true }).click();
177
- await expect(page).toHaveURL('/m/admin/resources/resource-01/views/complete');
177
+ await expect(page).toHaveURL('/m/admin/resources/resource-01/views/complete/record-22');
178
178
  expect(state.writes).toHaveLength(1);
179
179
  expect(state.drafts).toHaveLength(0);
180
180
  });
@@ -285,7 +285,7 @@ test('drawer full screen preserves input, close discards edits and new page rest
285
285
  expect(state.drafts).toHaveLength(1);
286
286
  await nextPage.screenshot({ path: testInfo.outputPath('standard-new-page-draft.png'), fullPage: true, animations: 'disabled' });
287
287
  await nextPage.getByRole('button', { name: '提交', exact: true }).click();
288
- await expect(nextPage).toHaveURL('/admin/resources/resource-01');
288
+ await expect(nextPage).toHaveURL('/admin/resources/resource-01/record-22');
289
289
  expect(state.writes).toHaveLength(1); expect(state.drafts).toHaveLength(0);
290
290
  });
291
291
 
@@ -333,7 +333,7 @@ test('mobile grouped entry keeps one column and saves through the same Data API'
333
333
  expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
334
334
  await page.screenshot({ path: testInfo.outputPath('standard-mobile-form.png'), animations: 'disabled' });
335
335
  await form.getByRole('button', { name: '提交', exact: true }).click();
336
- await expect(page).toHaveURL('/m/admin/resources/resource-01');
336
+ await expect(page).toHaveURL('/m/admin/resources/resource-01/record-22');
337
337
  expect(state.writes).toHaveLength(1);
338
338
  expect(state.records.at(-1)?.name).toBe('移动端新增记录');
339
339
  expect(errors).toEqual([]);
@@ -430,7 +430,7 @@ test('mobile partial drafts restore from a bottom sheet and submit once', async
430
430
  await page.screenshot({ path: testInfo.outputPath('mobile-draft-restore.png'), animations: 'disabled' });
431
431
  await prompt.getByRole('button', { name: '载入草稿' }).click();
432
432
  await expect(form.getByRole('textbox', { name: '栏目说明', exact: true })).toHaveValue('手机暂存内容');
433
- await page.getByRole('button', { name: '草稿箱', exact: true }).click();
433
+ await page.getByRole('button', { name: /^草稿箱/ }).click();
434
434
  const draftBox = page.getByRole('dialog', { name: /草稿箱/ });
435
435
  await expect(draftBox.locator('.oxa-draft-card')).toBeVisible();
436
436
  await expect.poll(async () => { const box = await draftBox.boundingBox(); return box ? Math.abs(box.y + box.height - 844) : Infinity; }).toBeLessThan(2);
@@ -439,7 +439,7 @@ test('mobile partial drafts restore from a bottom sheet and submit once', async
439
439
  await page.getByRole('button', { name: '关闭弹层' }).click();
440
440
  await form.getByRole('textbox', { name: '栏目名称', exact: true }).fill('手机恢复后提交');
441
441
  await form.getByRole('button', { name: '提交', exact: true }).click();
442
- await expect(page).toHaveURL('/m/admin/resources/resource-01');
442
+ await expect(page).toHaveURL('/m/admin/resources/resource-01/record-22');
443
443
  expect(state.writes).toHaveLength(1); expect(state.drafts).toHaveLength(0);
444
444
  });
445
445
 
@@ -452,6 +452,11 @@ test('direct entry is independent and adapts desktop URLs to touch controls with
452
452
  await page.setViewportSize({ width: 390, height: 844 });
453
453
  await expect(page.locator('.oxa-mobile-form')).toBeVisible();
454
454
  await expect(page.locator('.oxa-mobile-entry')).toBeVisible();
455
+ await expect(page.locator('.oxa-mobile-entry')).toHaveCSS('padding-left', '0px');
456
+ const header = await page.locator('.oxa-mobile-header').boundingBox();
457
+ const entry = await page.locator('.oxa-mobile-entry').boundingBox();
458
+ expect(header?.x).toBe(entry?.x); expect(header?.width).toBe(entry?.width);
459
+ await expect(page.getByRole('button', { name: /^草稿箱/ })).toHaveCount(0);
455
460
  await expect(page.getByRole('textbox', { name: /栏目名称/ })).toHaveValue('保留未暂存输入');
456
461
  await expect(page.locator('.oxa-sider, .oxa-topbar')).toHaveCount(0);
457
462
  await page.setViewportSize({ width: 1280, height: 900 });
@@ -481,3 +486,14 @@ test('details open readonly in a drawer and closing unsaved entry has no confirm
481
486
  expect(state.writes).toHaveLength(0);
482
487
  expect(state.draftWrites).toHaveLength(0);
483
488
  });
489
+
490
+
491
+ test('edit uses current record without prompting for an existing saved draft', async ({ page }) => {
492
+ const state = await editablePlatform(page);
493
+ state.drafts.push({id:'edit-draft', mode:'update', recordId:'record-1', revision:1, recordRevision:1, values:{name:'旧草稿'}, updatedAt:'2026-09-05T07:06:00.000Z'});
494
+ await page.goto('/admin/resources/resource-01/record-1/edit');
495
+ await expect(page.getByRole('textbox', {name:/栏目名称/})).toHaveValue('业务记录 1');
496
+ await expect(page.getByRole('dialog', {name:'载入暂存数据'})).toHaveCount(0);
497
+ await page.getByRole('button', {name:'草稿箱', exact:true}).click();
498
+ await expect(page.getByRole('dialog', {name:/草稿箱/})).toContainText('旧草稿');
499
+ });
@@ -0,0 +1,107 @@
1
+ import { Refine } from '@refinedev/core';
2
+ import { App as AntdApp } from 'antd';
3
+ import React, { useState } from 'react';
4
+ import ReactDOM from 'react-dom/client';
5
+ import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
6
+ import {
7
+ createApplicationProvider, GeneratedResourcePage, OpenXiangdaResourceDefinitionsProvider,
8
+ OpenXiangdaUiProvider, OpenXiangdaWorkflowDefinitionsProvider, RuntimeBoundary, WorkflowSubmissionPage,
9
+ type GeneratedResourceDefinition,
10
+ } from 'openxiangda/react';
11
+ import 'openxiangda/react/styles.css';
12
+ import 'openxiangda/mobile/styles.css';
13
+
14
+ const code = 'purchase-orders';
15
+ const capabilityBase = `app:openxiangda-application:data:${code}`;
16
+ const capabilities = {
17
+ read: `${capabilityBase}:read`, create: `${capabilityBase}:create`,
18
+ update: `${capabilityBase}:update`, delete: `${capabilityBase}:delete`,
19
+ };
20
+ const fieldCapabilities = { readCapabilities: [capabilities.read], createCapabilities: [], updateCapabilities: [] };
21
+ const definition: GeneratedResourceDefinition = {
22
+ code, name: '采购申请', capabilities,
23
+ surface: {
24
+ mutationOwner: 'workflow',
25
+ generated: { list: true, detail: true, create: false, update: false, delete: false },
26
+ fields: {
27
+ title: { label: '申请名称', type: 'text.short', widget: 'text', requiredHint: true, list: true, ...fieldCapabilities },
28
+ amount: { label: '申请金额', type: 'number.decimal', widget: 'money', requiredHint: true, list: true, ...fieldCapabilities },
29
+ internalNote: { label: '隐藏字段', type: 'text.short', widget: 'text', ...fieldCapabilities },
30
+ systemValue: { label: '系统字段', type: 'text.short', widget: 'text', system: true, ...fieldCapabilities },
31
+ decision: { label: '审批结论', type: 'text.short', widget: 'readonly', ...fieldCapabilities },
32
+ attachments: { label: '审批附件', type: 'file', widget: 'attachment', ...fieldCapabilities },
33
+ },
34
+ list: { defaultPageSize: 20, fieldOrder: ['title', 'amount'] },
35
+ form: { layout: 'flat', fieldOrder: ['title', 'amount', 'systemValue', 'decision'] },
36
+ detail: { layout: 'flat', fieldOrder: ['title', 'amount', 'decision'] },
37
+ mobile: { enabled: true },
38
+ },
39
+ };
40
+ const childCode = 'purchase-lines';
41
+ const childCapability = `app:openxiangda-application:data:${childCode}`;
42
+ const childDefinition: GeneratedResourceDefinition = {
43
+ code: childCode, name: '采购明细',
44
+ capabilities: { read: `${childCapability}:read`, create: `${childCapability}:create`, update: `${childCapability}:update`, delete: `${childCapability}:delete` },
45
+ surface: {
46
+ mutationOwner: 'native', generated: { list: false, detail: false, create: false, update: false, delete: false },
47
+ fields: {
48
+ purchaseId: { label: '主记录', type: 'text.short', widget: 'text', ...fieldCapabilities },
49
+ sortOrder: { label: '顺序', type: 'number.integer', widget: 'number', ...fieldCapabilities },
50
+ name: { label: '品名', type: 'text.short', widget: 'text', requiredHint: true, ...fieldCapabilities },
51
+ quantity: { label: '数量', type: 'number.integer', widget: 'number', requiredHint: true, ...fieldCapabilities },
52
+ hiddenNote: { label: '隐藏明细字段', type: 'text.short', widget: 'text', hidden: true, ...fieldCapabilities },
53
+ detailNote: { label: '仅详情说明', type: 'text.short', widget: 'text', ...fieldCapabilities },
54
+ },
55
+ form: { layout: 'flat', fieldOrder: ['name', 'quantity', 'hiddenNote'] },
56
+ detail: { layout: 'flat', fieldOrder: ['name', 'quantity', 'detailNote', 'hiddenNote'] },
57
+ },
58
+ };
59
+ const definitions = { [code]: definition, [childCode]: childDefinition };
60
+ const workflowDefinitions = [{
61
+ code: 'purchase-approval', title: '采购审批', launch: { mode: 'standalone' },
62
+ subject: { resourceCode: code, factProjection: { amount: 'amount' }, summaryFields: ['amount'] },
63
+ processOperationCode: 'openxiangda.workflow.purchase-approval.submit',
64
+ }] as const;
65
+ const query = new URLSearchParams(window.location.search);
66
+ if (query.get('fields') === 'complex') {
67
+ definition.surface.fields.items = {
68
+ label: '采购明细', type: 'subtable', widget: 'subtable',
69
+ subtable: { resourceCode: childCode, foreignKey: 'purchaseId', orderField: 'sortOrder', maxRows: 20 },
70
+ ...fieldCapabilities,
71
+ };
72
+ definition.surface.detail!.fieldOrder!.push('items');
73
+ }
74
+ const mode = query.get('mode') || 'list';
75
+ const base = mode === 'mobile-detail' ? '/m/admin/purchases' : '/admin/purchases';
76
+ const paths = { fallback: '/admin', list: base, detail: `${base}/:id` };
77
+
78
+ function Entry() {
79
+ const [completions, setCompletions] = useState<string[]>([]);
80
+ const [render, setRender] = useState(0);
81
+ const [dismissed, setDismissed] = useState(false);
82
+ const location = useLocation();
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>;
85
+ return <>
86
+ <button onClick={() => setRender(value => value + 1)}>重新渲染父页面</button>
87
+ <output data-testid="parent-render">{render}</output>
88
+ <output data-testid="completions">{completions.join(',')}</output>
89
+ <output data-testid="route">{location.pathname}{location.search}</output>
90
+ {!dismissed && <WorkflowSubmissionPage workflowCode="purchase-approval" variant={mode === 'mobile' ? 'mobile' : 'desktop'}
91
+ onDismiss={() => setDismissed(true)} onCompleted={id => setCompletions(values => [...values, id])} />}
92
+ </>;
93
+ }
94
+
95
+ ReactDOM.createRoot(document.getElementById('root')!).render(
96
+ <React.StrictMode>
97
+ <OpenXiangdaUiProvider><AntdApp><MemoryRouter initialEntries={[mode === 'mobile-detail' ? `${paths.list}/record-1` : paths.list]}><RuntimeBoundary>
98
+ <OpenXiangdaResourceDefinitionsProvider definitions={definitions}>
99
+ <OpenXiangdaWorkflowDefinitionsProvider definitions={workflowDefinitions}>
100
+ <Refine dataProvider={createApplicationProvider(definitions)} resources={[{ name: code, list: paths.list, show: paths.detail }]}>
101
+ <Entry />
102
+ </Refine>
103
+ </OpenXiangdaWorkflowDefinitionsProvider>
104
+ </OpenXiangdaResourceDefinitionsProvider>
105
+ </RuntimeBoundary></MemoryRouter></AntdApp></OpenXiangdaUiProvider>
106
+ </React.StrictMode>,
107
+ );
@@ -0,0 +1,486 @@
1
+ import { expect, test, type Page } from '@playwright/test';
2
+ import { mockPlatform, runtimeAuthorization } from './resource-platform-mock';
3
+
4
+ const appCode = 'openxiangda-application';
5
+ const resourceCode = 'purchase-orders';
6
+ const commandId = '55555555-5555-4555-8555-555555555555';
7
+ const instanceId = '22222222-2222-4222-8222-222222222222';
8
+ const workflowBase = `/openxiangda-api/v2/applications/${appCode}/workflow`;
9
+ const processBase = `/openxiangda-api/v2/applications/${appCode}/business-process`;
10
+ const capability = `app:${appCode}:data:${resourceCode}:read`;
11
+ const fieldCapabilities = { readCapabilities: [capability], createCapabilities: [], updateCapabilities: [] };
12
+ const correctionFields = {
13
+ title: { label: '申请名称', type: 'text.short', widget: 'text', requiredHint: true, ...fieldCapabilities },
14
+ amount: { label: '申请金额', type: 'number.decimal', widget: 'money', requiredHint: true, ...fieldCapabilities },
15
+ };
16
+
17
+ async function workflowEntryPlatform(page: Page) {
18
+ await mockPlatform(page, false, undefined, true);
19
+ const state = {
20
+ admin: true,
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[],
23
+ children: [
24
+ { id: 'line-1', revision: 3, purchaseId: 'record-1', sortOrder: 0, name: '原明细甲', quantity: 2, hiddenNote: '不得显示的明细值', detailNote: '保留详情说明' },
25
+ { id: 'line-2', revision: 4, purchaseId: 'record-1', sortOrder: 1, name: '原明细乙', quantity: 3, hiddenNote: '不得显示的明细值', detailNote: '第二项说明' },
26
+ ],
27
+ correctionReads: 0, correctionWrites: [] as any[], correctionEffects: 0,
28
+ correctionReadFailure: false, wrongSurfaceScope: false, forbiddenEditable: false,
29
+ loseCorrectionResponse: false, loseLaunchResponse: false,
30
+ launches: [] as any[], launchEffects: 0, statusReads: 0, failStatusOnce: false,
31
+ processStatus: 'started' as 'accepted' | 'started' | 'awaiting_input',
32
+ answers: [] as any[], nativeWrites: [] as string[],
33
+ corrections: new Map<string, unknown>(), commands: new Map<string, unknown>(),
34
+ };
35
+ const file = { schemaVersion: 'openxiangda.data-file-ref/v2', id: 'file-1', name: '申请说明.txt', size: 6, contentType: 'text/plain' };
36
+ await page.route('**/fixture-file-upload', route => route.fulfill({ status: 200, body: '' }));
37
+ function command(status = state.processStatus) {
38
+ return {
39
+ schemaVersion: 'openxiangda.business-process.command/v2', id: commandId, appCode,
40
+ environmentKey: 'preproduction', operationCode: 'openxiangda.workflow.purchase-approval.submit',
41
+ idempotencyKey: 'fixture-command-key', workflowCode: 'purchase-approval', status,
42
+ revision: status === 'started' ? 4 : 1,
43
+ subject: { resourceCode, id: state.record.id, dataRevision: state.record.revision, factDigest: 'a'.repeat(64) },
44
+ definitionVersion: 1, bindingVersion: 1, requirements: [], answers: {}, preview: {},
45
+ workflowInstanceId: status === 'started' ? instanceId : null, attemptCount: 1, lastError: null,
46
+ replayed: false, createdAt: '2026-09-06T01:00:00.000Z', updatedAt: '2026-09-06T01:00:01.000Z',
47
+ };
48
+ }
49
+ await page.route('**/service/**', async route => {
50
+ const request = route.request();
51
+ const path = new URL(request.url()).pathname;
52
+ const ok = (data: unknown) => route.fulfill({ json: { code: 200, data } });
53
+ const fail = (status: number, message: string) => route.fulfill({ status, json: { code: status, message } });
54
+ if (path.endsWith('/native/authz/current')) {
55
+ const authorization = runtimeAuthorization(false, state.admin);
56
+ authorization.principal.capabilityCodes = [capability];
57
+ return ok(authorization);
58
+ }
59
+ if (path.endsWith('/auth/surface')) return ok({ csrfToken: 'workflow-entry-csrf' });
60
+ if (path.includes('/native/admin-list/preferences/')) return ok(null);
61
+ if (path.endsWith('/native/data/purchase-lines/query')) {
62
+ state.childQueries.push(request.postDataJSON());
63
+ return ok({ schemaVersion: 'openxiangda.data-page/v2', resourceCode: 'purchase-lines', items: state.children, total: state.children.length, offset: 0, limit: 20 });
64
+ }
65
+ if (path.endsWith(`/native/data/${resourceCode}/query`)) {
66
+ return ok({ schemaVersion: 'openxiangda.data-page/v2', resourceCode, items: [state.record], total: 1, offset: 0, limit: 20 });
67
+ }
68
+ if (path.includes(`/native/data/${resourceCode}/records/`) && request.method() === 'GET') {
69
+ return ok({ schemaVersion: 'openxiangda.data-record/v2', data: state.record });
70
+ }
71
+ if (path.endsWith(`/native/data/${resourceCode}/files/uploads/initiate`)) {
72
+ state.uploads.push(request.postDataJSON());
73
+ return ok({ schemaVersion: 'openxiangda.data-file-upload-plan/v2', resourceCode, fieldCode: 'attachments', file, uploadMethod: 'PUT', uploadUrl: new URL('/fixture-file-upload', request.url()).href, headers: {}, expiresAt: '2099-01-01T00:00:00.000Z' });
74
+ }
75
+ if (path.endsWith(`/native/data/${resourceCode}/files/file-1/complete`)) return ok(file);
76
+ if (path.includes(`/native/data/${resourceCode}/`) && request.method() !== 'GET') {
77
+ state.nativeWrites.push(path);
78
+ return fail(403, '流程记录不允许普通 CRUD 写入');
79
+ }
80
+ if (path.endsWith('/correction-surface')) {
81
+ state.correctionReads += 1;
82
+ if (!state.admin) return fail(403, '当前用户无权编辑数据');
83
+ if (state.correctionReadFailure) return fail(500, '暂时无法读取编辑表单');
84
+ const fields = state.complexFields ? {
85
+ ...correctionFields,
86
+ attachments: { label: '申请附件', type: 'file', widget: 'attachment', maxCount: 3, ...fieldCapabilities },
87
+ richText: { label: '申请说明', type: 'text.rich', widget: 'rich-text', ...fieldCapabilities },
88
+ items: { label: '采购明细', type: 'subtable', widget: 'subtable', subtable: { resourceCode: 'purchase-lines', foreignKey: 'purchaseId', orderField: 'sortOrder', maxRows: 20 }, ...fieldCapabilities },
89
+ } : correctionFields;
90
+ return ok({
91
+ schemaVersion: 'openxiangda.workflow-record-correction-surface/v2', appCode, resourceCode,
92
+ recordId: state.record.id, environmentKey: state.wrongSurfaceScope ? 'production' : 'preproduction',
93
+ record: state.record, expectedRevision: state.record.revision, preservesApprovalResult: true,
94
+ editableFields: state.forbiddenEditable ? ['title', 'amount', 'internalNote'] : Object.keys(fields),
95
+ surface: {
96
+ mutationOwner: 'workflow', fields,
97
+ form: { layout: 'flat', fieldOrder: Object.keys(fields) },
98
+ },
99
+ command: { method: 'POST', href: `${workflowBase}/records/${resourceCode}/${state.record.id}/corrections` },
100
+ });
101
+ }
102
+ if (path.endsWith('/corrections')) {
103
+ const input = request.postDataJSON();
104
+ state.correctionWrites.push(input);
105
+ expect(request.headers()['x-openxiangda-csrf-token']).toBe('workflow-entry-csrf');
106
+ if (!state.admin) return fail(403, '当前用户无权编辑数据');
107
+ const stored = state.corrections.get(input.idempotencyKey);
108
+ if (stored) return ok(stored);
109
+ if (input.operations[0].expectedRevision !== state.record.revision) return fail(409, '数据已发生变化,请关闭后重新核对');
110
+ for (const operation of input.operations.slice(1)) {
111
+ expect(Object.keys(operation.data || {}).every(key => ['name', 'quantity', 'purchaseId', 'sortOrder'].includes(key))).toBe(true);
112
+ const child = state.children.find(row => row.id === operation.id);
113
+ if (operation.operation !== 'create' && (!child || child.revision !== operation.expectedRevision)) return fail(409, '明细已发生变化,请重新核对');
114
+ }
115
+ await new Promise(resolve => setTimeout(resolve, 300));
116
+ Object.assign(state.record, input.operations[0].data, { revision: state.record.revision + 1 });
117
+ for (const operation of input.operations.slice(1)) {
118
+ if (operation.operation === 'create') state.children.push({ id: `line-${state.children.length + 3}`, revision: 1, ...operation.data });
119
+ else if (operation.operation === 'delete') state.children = state.children.filter(row => row.id !== operation.id);
120
+ else {
121
+ const child = state.children.find(row => row.id === operation.id)!;
122
+ Object.assign(child, operation.data, { revision: child.revision + 1 });
123
+ }
124
+ }
125
+ state.correctionEffects += 1;
126
+ const result = {
127
+ schemaVersion: 'openxiangda.workflow-record-correction-result/v2', preservesApprovalResult: true,
128
+ transaction: { replayed: false, items: [{ id: state.record.id, revision: state.record.revision }] },
129
+ };
130
+ state.corrections.set(input.idempotencyKey, result);
131
+ if (state.loseCorrectionResponse) { state.loseCorrectionResponse = false; return fail(500, '响应暂时不可用,请重试'); }
132
+ return ok(result);
133
+ }
134
+ if (path.endsWith('/workflow/definitions/purchase-approval/launch-surface')) {
135
+ const commit = { method: 'POST', href: `${processBase}/standard-commands`, idempotencyRequired: true };
136
+ return ok({
137
+ schemaVersion: 'openxiangda.workflow-launch-surface/v3', protocolVersion: 'workflow_launch_surface_v3',
138
+ surfaceRevision: 'launch-revision', engineVersion: '2.0', appCode, environmentKey: 'preproduction',
139
+ environmentId: 'preproduction-id', workflowCode: 'purchase-approval', title: '采购审批', launchMode: 'standalone',
140
+ subject: { resourceCode, factProjection: { amount: 'amount' }, summaryFields: ['amount'] },
141
+ inputSchema: { type: 'object', properties: {} },
142
+ head: { workflowRevision: 4, definitionVersion: 1, bindingVersion: 1, nativeRevision: 3, contractRevisionId: 'contract-1' },
143
+ paths: { desktop: '/workflows/purchase-approval/start', mobile: '/m/workflows/purchase-approval/start' },
144
+ submission: { kind: 'standard-process', processOperationCode: 'openxiangda.workflow.purchase-approval.submit', commit },
145
+ });
146
+ }
147
+ if (path.endsWith('/business-process/standard-commands')) {
148
+ const input = request.postDataJSON();
149
+ state.launches.push(input);
150
+ const stored = state.commands.get(input.idempotencyKey);
151
+ if (stored) return ok(stored);
152
+ await new Promise(resolve => setTimeout(resolve, 300));
153
+ Object.assign(state.record, input.mutation.data, { id: 'record-created', revision: 1, decision: '审批中' });
154
+ state.launchEffects += 1;
155
+ const result = command('accepted');
156
+ state.commands.set(input.idempotencyKey, result);
157
+ if (state.loseLaunchResponse) { state.loseLaunchResponse = false; return fail(500, '响应暂时不可用,请重试'); }
158
+ return ok(result);
159
+ }
160
+ if (path.endsWith(`/business-process/commands/${commandId}/surface`)) {
161
+ state.statusReads += 1;
162
+ if (state.failStatusOnce) { state.failStatusOnce = false; return fail(500, '状态暂时不可用'); }
163
+ return ok({
164
+ schemaVersion: 'openxiangda.process-command-surface/v2', surfaceRevision: 'b'.repeat(64), command: command(),
165
+ subject: { resourceCode, recordId: state.record.id, dataRevision: state.record.revision, form: { kind: 'native-resource-form', resourceCode, recordId: state.record.id } },
166
+ requirements: state.processStatus === 'awaiting_input' ? [{ id: 'department', kind: 'choose_department', title: '申请部门', required: true, candidates: [{ value: 'college', label: '理学院' }] }] : [],
167
+ resume: {
168
+ status: { method: 'GET', href: `${processBase}/commands/${commandId}` },
169
+ surface: { method: 'GET', href: `${processBase}/commands/${commandId}/surface` },
170
+ answer: state.processStatus === 'awaiting_input' ? { method: 'POST', href: `${processBase}/commands/${commandId}/answers`, expectedRevision: 1 } : null,
171
+ retry: null,
172
+ },
173
+ });
174
+ }
175
+ if (path.endsWith(`/business-process/commands/${commandId}/answers`)) {
176
+ state.answers.push(request.postDataJSON());
177
+ await new Promise(resolve => setTimeout(resolve, 300));
178
+ state.processStatus = 'started';
179
+ return ok(command());
180
+ }
181
+ return route.fallback();
182
+ });
183
+ return state;
184
+ }
185
+
186
+ async function openCorrection(page: Page, search = '') {
187
+ await page.goto(`/workflow-entry.e2e.html${search}`);
188
+ await page.getByRole('button', { name: '编辑', exact: true }).click();
189
+ const drawer = page.getByRole('dialog', { name: '编辑数据' });
190
+ await expect(drawer.getByLabel('申请名称', { exact: true })).toHaveValue('原业务申请');
191
+ await expect(drawer.getByLabel('申请金额', { exact: true })).toHaveValue('28600');
192
+ return drawer;
193
+ }
194
+
195
+ test('ordinary workflow editing uses the shared form and opens readonly detail after one guarded save', async ({ page }, testInfo) => {
196
+ const state = await workflowEntryPlatform(page);
197
+ const drawer = await openCorrection(page);
198
+ await expect(drawer.locator('.ant-alert')).toHaveCount(0);
199
+ await expect(drawer.getByText(/更正|审计|重新审批/)).toHaveCount(0);
200
+ for (const label of ['隐藏字段', '系统字段', '审批结论', '审批附件']) await expect(drawer.getByLabel(label, { exact: true })).toHaveCount(0);
201
+ await drawer.getByLabel('申请名称', { exact: true }).fill('已更正的业务申请');
202
+ await drawer.getByRole('button', { name: '提交' }).dblclick();
203
+ await expect(drawer.getByRole('button', { name: '关闭', exact: true })).toBeDisabled();
204
+ await page.keyboard.press('Escape');
205
+ await expect(drawer).toBeVisible();
206
+ const detail = page.getByRole('dialog', { name: '采购申请详情' });
207
+ await expect(detail.getByText('已更正的业务申请', { exact: true })).toBeVisible();
208
+ await expect(detail.locator('input,textarea')).toHaveCount(0);
209
+ await expect(detail.getByText('已同意', { exact: true })).toBeVisible();
210
+ expect(state.correctionWrites).toHaveLength(1);
211
+ expect(state.correctionWrites[0].operations[0]).toMatchObject({ expectedRevision: 8, data: { title: '已更正的业务申请', amount: 28600 } });
212
+ expect(Object.keys(state.correctionWrites[0].operations[0].data).sort()).toEqual(['amount', 'title']);
213
+ expect(state.correctionEffects).toBe(1);
214
+ expect(state.nativeWrites).toEqual([]);
215
+ expect(state.launches).toEqual([]);
216
+ await page.screenshot({ path: testInfo.outputPath('workflow-correction-readonly-detail.png'), animations: 'disabled' });
217
+ });
218
+
219
+ test('an uncertain workflow edit preserves input and replays the original revision and idempotency key', async ({ page }) => {
220
+ const state = await workflowEntryPlatform(page);
221
+ state.loseCorrectionResponse = true;
222
+ const drawer = await openCorrection(page);
223
+ await drawer.getByLabel('申请名称', { exact: true }).fill('网络重试后保留的申请');
224
+ await drawer.getByRole('button', { name: '提交' }).click();
225
+ await expect(drawer.getByText('响应暂时不可用,请重试')).toBeVisible();
226
+ 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();
229
+ expect(state.correctionWrites).toHaveLength(2);
230
+ expect(state.correctionWrites[1]).toEqual(state.correctionWrites[0]);
231
+ expect(state.correctionEffects).toBe(1);
232
+ expect(state.launches).toEqual([]);
233
+ });
234
+
235
+ test('CAS conflicts retain the original form and revision without a hidden reload or reapproval', async ({ page }) => {
236
+ const state = await workflowEntryPlatform(page);
237
+ const drawer = await openCorrection(page);
238
+ const initialReads = state.correctionReads;
239
+ state.record.revision = 9;
240
+ await drawer.getByLabel('申请名称', { exact: true }).fill('尚未覆盖并发修改');
241
+ await drawer.getByRole('button', { name: '提交' }).click();
242
+ await expect(drawer.getByText('数据已发生变化,请关闭后重新核对')).toBeVisible();
243
+ await drawer.getByRole('button', { name: '提交' }).click();
244
+ await expect.poll(() => state.correctionWrites.length).toBe(2);
245
+ await expect(drawer.getByLabel('申请名称', { exact: true })).toHaveValue('尚未覆盖并发修改');
246
+ expect(state.correctionWrites[1]).toEqual(state.correctionWrites[0]);
247
+ expect(state.correctionWrites[1].operations[0].expectedRevision).toBe(8);
248
+ expect(state.correctionReads).toBe(initialReads);
249
+ expect(state.correctionEffects).toBe(0);
250
+ expect(state.launches).toEqual([]);
251
+ });
252
+
253
+ test('revoked admin authority fails saving and hides editing when identity is reloaded', async ({ page }) => {
254
+ const state = await workflowEntryPlatform(page);
255
+ const drawer = await openCorrection(page);
256
+ state.admin = false;
257
+ await drawer.getByLabel('申请名称', { exact: true }).fill('撤权后不能保存');
258
+ await drawer.getByRole('button', { name: '提交' }).click();
259
+ await expect(drawer.getByText('当前用户无权编辑数据')).toBeVisible();
260
+ await expect(drawer.getByLabel('申请名称', { exact: true })).toHaveValue('撤权后不能保存');
261
+ await page.reload();
262
+ await expect(page.getByRole('button', { name: '查看', exact: true })).toBeVisible();
263
+ await expect(page.getByRole('button', { name: '编辑', exact: true })).toHaveCount(0);
264
+ expect(state.correctionEffects).toBe(0);
265
+ });
266
+
267
+ test('record edit load retries and rejects a Surface for another environment or a hidden editable field', async ({ page }) => {
268
+ const state = await workflowEntryPlatform(page);
269
+ state.correctionReadFailure = true;
270
+ await page.goto('/workflow-entry.e2e.html');
271
+ await page.getByRole('button', { name: '编辑', exact: true }).click();
272
+ const drawer = page.getByRole('dialog', { name: '编辑数据' });
273
+ await expect(drawer.getByText('暂时无法读取编辑表单')).toBeVisible();
274
+ state.correctionReadFailure = false;
275
+ state.wrongSurfaceScope = true;
276
+ await drawer.getByRole('button', { name: '重试', exact: true }).click();
277
+ await expect(drawer.getByText('OPENXIANGDA_WORKFLOW_CORRECTION_SURFACE_SCOPE_INVALID')).toBeVisible();
278
+ state.wrongSurfaceScope = false;
279
+ state.forbiddenEditable = true;
280
+ await drawer.getByRole('button', { name: '重试', exact: true }).click();
281
+ await expect(drawer.getByText('OPENXIANGDA_WORKFLOW_CORRECTION_FIELDS_INVALID')).toBeVisible();
282
+ await expect(drawer.locator('input,textarea')).toHaveCount(0);
283
+ state.forbiddenEditable = false;
284
+ await drawer.getByRole('button', { name: '重试', exact: true }).click();
285
+ await expect(drawer.getByLabel('申请名称', { exact: true })).toHaveValue('原业务申请');
286
+ expect(state.correctionWrites).toEqual([]);
287
+ });
288
+
289
+ test('workflow launch retries one command, blocks close while processing and opens readonly record detail', async ({ page }, testInfo) => {
290
+ const state = await workflowEntryPlatform(page);
291
+ state.admin = false;
292
+ state.loseLaunchResponse = true;
293
+ state.processStatus = 'accepted';
294
+ await page.goto('/workflow-entry.e2e.html');
295
+ await page.getByRole('button', { name: '新增流程申请' }).click();
296
+ const drawer = page.getByRole('dialog', { name: '采购审批' });
297
+ await expect(drawer.getByRole('link', { name: '新开页面' })).toHaveAttribute('href', '/workflows/purchase-approval/start');
298
+ await drawer.getByLabel('申请名称', { exact: true }).fill('抽屉新增的申请');
299
+ await drawer.getByLabel('申请金额', { exact: true }).fill('1200');
300
+ for (const label of ['隐藏字段', '系统字段', '审批结论', '审批附件']) await expect(drawer.getByLabel(label, { exact: true })).toHaveCount(0);
301
+ await drawer.getByRole('button', { name: '提交审批' }).dblclick();
302
+ await expect(drawer.getByRole('button', { name: '关闭', exact: true })).toBeDisabled();
303
+ await expect(page.getByText('响应暂时不可用,请重试')).toBeVisible();
304
+ await expect(drawer.getByLabel('申请名称', { exact: true })).toHaveValue('抽屉新增的申请');
305
+ await drawer.getByRole('button', { name: '提交审批' }).click();
306
+ await expect.poll(() => state.statusReads).toBeGreaterThan(0);
307
+ await expect(drawer.getByRole('link', { name: '新开页面' })).toHaveAttribute('href', `/workflows/purchase-approval/start?processCommandId=${commandId}`);
308
+ await expect(drawer.getByRole('button', { name: '关闭', exact: true })).toBeDisabled();
309
+ await page.keyboard.press('Escape');
310
+ await expect(drawer).toBeVisible();
311
+ state.processStatus = 'started';
312
+ const detail = page.getByRole('dialog', { name: '采购申请详情' });
313
+ await expect(detail.getByText('抽屉新增的申请', { exact: true })).toBeVisible();
314
+ await expect(detail.locator('input,textarea')).toHaveCount(0);
315
+ expect(state.launches).toHaveLength(2);
316
+ expect(state.launches[1]).toEqual(state.launches[0]);
317
+ expect(state.launchEffects).toBe(1);
318
+ expect(state.nativeWrites).toEqual([]);
319
+ expect(state.correctionWrites).toEqual([]);
320
+ await page.screenshot({ path: testInfo.outputPath('workflow-launch-readonly-detail.png'), animations: 'disabled' });
321
+ });
322
+
323
+ test('mobile launch completes once across parent renders and does not change the parent route', async ({ page }, testInfo) => {
324
+ const state = await workflowEntryPlatform(page);
325
+ await page.setViewportSize({ width: 390, height: 844 });
326
+ await page.goto('/workflow-entry.e2e.html?mode=mobile');
327
+ const drawer = page.getByRole('dialog', { name: '采购审批' });
328
+ await drawer.getByRole('textbox', { name: '申请名称', exact: true }).fill('移动流程申请');
329
+ await drawer.getByRole('textbox', { name: '申请金额', exact: true }).fill('320');
330
+ await page.screenshot({ path: testInfo.outputPath('workflow-launch-mobile.png'), animations: 'disabled' });
331
+ await drawer.getByRole('button', { name: '提交审批' }).click();
332
+ await expect(page.getByTestId('completions')).toHaveText('record-created');
333
+ await drawer.getByRole('button', { name: '关闭', exact: true }).click();
334
+ await page.getByRole('button', { name: '重新渲染父页面' }).click();
335
+ await expect(page.getByTestId('parent-render')).toHaveText('1');
336
+ await expect(page.getByTestId('completions')).toHaveText('record-created');
337
+ await expect(page.getByTestId('route')).toHaveText('/admin/purchases');
338
+ expect(state.launchEffects).toBe(1);
339
+ expect(state.nativeWrites).toEqual([]);
340
+ });
341
+
342
+ test('a failed command status read refreshes in the same drawer without another submission', async ({ page }) => {
343
+ const state = await workflowEntryPlatform(page);
344
+ state.failStatusOnce = true;
345
+ await page.goto('/workflow-entry.e2e.html?mode=callback');
346
+ const drawer = page.getByRole('dialog', { name: '采购审批' });
347
+ await drawer.getByLabel('申请名称', { exact: true }).fill('状态重试申请');
348
+ await drawer.getByLabel('申请金额', { exact: true }).fill('200');
349
+ await drawer.getByRole('button', { name: '提交审批' }).click();
350
+ await expect(drawer.getByText('暂时无法确认申请状态')).toBeVisible();
351
+ await drawer.getByRole('button', { name: '刷新提交状态' }).click();
352
+ await expect(page.getByTestId('completions')).toHaveText('record-created');
353
+ // A parent state update supplies a new callback while the drawer remains mounted.
354
+ await page.getByRole('button', { name: '重新渲染父页面' }).evaluate(element => element.click());
355
+ await expect(page.getByTestId('parent-render')).toHaveText('1');
356
+ await expect(page.getByTestId('completions')).toHaveText('record-created');
357
+ expect(state.launches).toHaveLength(1);
358
+ });
359
+
360
+ test('supplementary answers are guarded and resume completion without resubmitting approval', async ({ page }) => {
361
+ const state = await workflowEntryPlatform(page);
362
+ state.processStatus = 'awaiting_input';
363
+ await page.goto('/workflow-entry.e2e.html?mode=callback');
364
+ const drawer = page.getByRole('dialog', { name: '采购审批' });
365
+ await drawer.getByLabel('申请名称', { exact: true }).fill('需要补充部门');
366
+ await drawer.getByLabel('申请金额', { exact: true }).fill('800');
367
+ await drawer.getByRole('button', { name: '提交审批' }).click();
368
+ await drawer.getByRole('combobox', { name: '申请部门' }).click();
369
+ await page.locator('.ant-select-dropdown').getByText('理学院', { exact: true }).click();
370
+ await drawer.getByRole('button', { name: '提交补充信息' }).dblclick();
371
+ await expect(page.getByTestId('completions')).toHaveText('record-created');
372
+ expect(state.answers).toHaveLength(1);
373
+ expect(state.answers[0]).toMatchObject({ expectedRevision: 1, answers: { department: 'college' } });
374
+ expect(state.launches).toHaveLength(1);
375
+ });
376
+
377
+ test('detail switches to the ordinary full-width editor and back with only one dialog', async ({ page }, testInfo) => {
378
+ const state = await workflowEntryPlatform(page);
379
+ await page.goto('/workflow-entry.e2e.html');
380
+ await page.getByRole('button', { name: '查看', exact: true }).click();
381
+ const detail = page.getByRole('dialog', { name: '采购申请详情' });
382
+ await detail.getByRole('button', { name: '全屏', exact: true }).click();
383
+ await expect.poll(async () => Math.round((await detail.boundingBox())!.width)).toBe(page.viewportSize()!.width);
384
+ await detail.getByRole('button', { name: '编辑', exact: true }).click();
385
+ const editor = page.getByRole('dialog', { name: '编辑数据' });
386
+ await expect(editor.getByLabel('申请名称', { exact: true })).toHaveValue('原业务申请');
387
+ await expect(page.getByRole('dialog')).toHaveCount(1);
388
+ await expect(page.getByRole('dialog', { name: /编辑申请|更正/ })).toHaveCount(0);
389
+ await expect(editor.getByRole('button', { name: '退出全屏' })).toBeVisible();
390
+ await expect.poll(async () => Math.round((await editor.boundingBox())!.width)).toBe(page.viewportSize()!.width);
391
+ await editor.getByLabel('申请名称', { exact: true }).fill('直接编辑的数据');
392
+ 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();
395
+ await expect(page.getByRole('dialog')).toHaveCount(1);
396
+ await expect(detail.getByRole('button', { name: '退出全屏' })).toBeVisible();
397
+ expect(state.correctionWrites[0].operations[0].id).toBe('record-1');
398
+ expect(state.launches).toEqual([]);
399
+ });
400
+
401
+ test('mobile detail edits on the current page and returns to the same readonly record', async ({ page }, testInfo) => {
402
+ const state = await workflowEntryPlatform(page);
403
+ await page.setViewportSize({ width: 390, height: 844 });
404
+ 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();
407
+ await expect(page.getByRole('dialog')).toHaveCount(0);
408
+ const form = page.locator('.oxa-mobile-form');
409
+ await expect(form.getByRole('textbox', { name: '申请名称', exact: true })).toHaveValue('原业务申请');
410
+ await form.getByRole('textbox', { name: '申请名称', exact: true }).fill('移动页面直接修改');
411
+ expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
412
+ 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();
415
+ await expect(page.getByRole('dialog')).toHaveCount(0);
416
+ await expect(page.locator('.oxa-readonly-form input')).toHaveCount(0);
417
+ expect(state.record.id).toBe('record-1');
418
+ expect(state.launches).toEqual([]);
419
+ });
420
+
421
+ test('ordinary workflow editing reuses Native uploads, rich text and parent-child transaction plans', async ({ page }, testInfo) => {
422
+ const state = await workflowEntryPlatform(page);
423
+ state.complexFields = true;
424
+ const editor = await openCorrection(page, '?fields=complex');
425
+ const subtable = editor.locator('.oxa-subtable-field');
426
+ await expect(subtable.getByText('原明细甲', { exact: true })).toBeVisible();
427
+ await expect(subtable.getByText('仅详情说明', { exact: true })).toHaveCount(0);
428
+ await expect(subtable.getByText('隐藏明细字段', { exact: true })).toHaveCount(0);
429
+ await editor.locator('input[type="file"]').first().setInputFiles({ name: '申请说明.txt', mimeType: 'text/plain', buffer: Buffer.from('说明') });
430
+ await expect(editor.getByText('申请说明.txt', { exact: true })).toBeVisible();
431
+ 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();
444
+ await expect(page.getByRole('dialog')).toHaveCount(1);
445
+ 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);
450
+ expect(state.uploads).toMatchObject([{ fieldCode: 'attachments', recordId: 'record-1' }]);
451
+ const operations = state.correctionWrites[0].operations;
452
+ expect(operations[0]).toMatchObject({ operation: 'update', id: 'record-1', expectedRevision: 8, data: { attachments: [{ id: 'file-1' }] } });
453
+ expect(operations[0].data.richText).toContain('直接修改富文本说明');
454
+ expect(operations[0].data).not.toHaveProperty('items');
455
+ expect(operations).toEqual(expect.arrayContaining([
456
+ expect.objectContaining({ operation: 'delete', resourceCode: 'purchase-lines', id: 'line-2', expectedRevision: 4 }),
457
+ expect.objectContaining({ operation: 'update', resourceCode: 'purchase-lines', id: 'line-1', expectedRevision: 3, data: { name: '修改后的明细甲' } }),
458
+ expect.objectContaining({ operation: 'create', resourceCode: 'purchase-lines', data: { name: '新增明细丙', quantity: 5, purchaseId: 'record-1', sortOrder: 1 } }),
459
+ ]));
460
+ expect(state.correctionEffects).toBe(1);
461
+ expect(state.record.decision).toBe('已同意');
462
+ expect(state.launches).toEqual([]);
463
+ });
464
+
465
+ test('mobile child editing selects form fields while readonly details retain their own selected fields', async ({ page }) => {
466
+ const state = await workflowEntryPlatform(page);
467
+ state.complexFields = true;
468
+ await page.setViewportSize({ width: 390, height: 844 });
469
+ await page.goto('/workflow-entry.e2e.html?mode=mobile-detail&fields=complex');
470
+ await expect(page.getByRole('textbox', { name: '仅详情说明', exact: true }).first()).toHaveValue('保留详情说明');
471
+ await expect(page.getByLabel('隐藏明细字段', { exact: true })).toHaveCount(0);
472
+ await page.getByRole('button', { name: '编辑', exact: true }).click();
473
+ const form = page.locator('.oxa-mobile-form');
474
+ const childName = form.getByRole('textbox', { name: '品名', exact: true }).first();
475
+ await expect(childName).toHaveValue('原明细甲');
476
+ await expect(form.getByLabel('仅详情说明', { exact: true })).toHaveCount(0);
477
+ await expect(form.getByLabel('隐藏明细字段', { exact: true })).toHaveCount(0);
478
+ 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('移动明细修改');
482
+ await expect(page.getByRole('dialog')).toHaveCount(0);
483
+ expect(state.correctionWrites[0].operations.slice(1)).toEqual([
484
+ { operation: 'update', resourceCode: 'purchase-lines', id: 'line-1', expectedRevision: 3, data: { name: '移动明细修改' } },
485
+ ]);
486
+ });
@@ -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.97",
17
+ "openxiangda": "2.0.0-alpha.98",
18
18
  "react": "19.2.8",
19
19
  "react-dom": "19.2.8",
20
20
  "react-router-dom": "7.8.2"
@@ -144,6 +144,7 @@ test('browser acceptance serializes the prewarmed Vite HTML entrypoints', () =>
144
144
  'field-protocol.e2e.html',
145
145
  'mobile-reference.e2e.html',
146
146
  'resource-experience.e2e.html',
147
+ 'workflow-entry.e2e.html',
147
148
  'workflow-experience.e2e.html',
148
149
  ]);
149
150
  for (const entry of htmlEntries) {
@@ -17,6 +17,7 @@ export default defineConfig({
17
17
  'mobile-reference.e2e.html',
18
18
  'resource-experience.e2e.html',
19
19
  'workflow-experience.e2e.html',
20
+ 'workflow-entry.e2e.html',
20
21
  ],
21
22
  },
22
23
  server: {
@@ -0,0 +1,15 @@
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="openxiangda-application" />
8
+ <meta name="openxiangda-environment" content="preproduction" />
9
+ <title>Workflow entry acceptance</title>
10
+ </head>
11
+ <body>
12
+ <div id="root"></div>
13
+ <script type="module" src="/e2e/workflow-entry-fixture.tsx"></script>
14
+ </body>
15
+ </html>
@@ -22,7 +22,7 @@
22
22
  "build": "pnpm --recursive build"
23
23
  },
24
24
  "devDependencies": {
25
- "openxiangda": "2.0.0-alpha.97",
25
+ "openxiangda": "2.0.0-alpha.98",
26
26
  "typescript": "5.9.3"
27
27
  }
28
28
  }