openxiangda-cli 2.0.0-alpha.117 → 2.0.0-alpha.119

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.117",
3
+ "version": "2.0.0-alpha.119",
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.56",
25
- "openxiangda-devkit-core": "2.0.0-alpha.73",
26
- "openxiangda-mcp": "2.0.0-alpha.73",
27
- "openxiangda-skill-kit": "2.0.0-alpha.92"
24
+ "openxiangda-contracts": "2.0.0-alpha.58",
25
+ "openxiangda-devkit-core": "2.0.0-alpha.75",
26
+ "openxiangda-mcp": "2.0.0-alpha.75",
27
+ "openxiangda-skill-kit": "2.0.0-alpha.94"
28
28
  },
29
29
  "devDependencies": {
30
30
  "tsx": "4.23.12",
@@ -3,7 +3,7 @@
3
3
  - Use only the workspace-pinned CLI: `pnpm openxiangda <command>`. Never invoke a bare global `openxiangda` inside a 2.0 task.
4
4
  - `appspec/` is the optional OpenXiangda 2.0 business-intent layer. Before changing observable behavior, read the bounded index with `pnpm openxiangda spec context --json`, then select only the relevant stable ID; use no ChangeSpec for L0 work, one short ChangeSpec for L1, and add permission/rollback/concurrency detail only for L2/L3. Before close, the user—not AI—confirms `currentSpec=merged` or `not-applicable`. AppSpec is advisory and never a release gate. Never read, import or migrate 1.x `openspec/` or SDD.
5
5
  - Use Vite, React Router, Refine Core and Ant Design. Do not add Umi, ProComponents or another admin shell.
6
- - Keep generated admin routes and custom actions inside the platform Shell. Bind generated `appRoutes` to local operation pages with `defineAdminContributions`, and use only its typed `toolbar`, `row` and `detail` resource slots. Declare the complete editable menu with `defineAdminNavigation` and its page/group helpers; the Shell renders only generated `adminNavigation` references and permissions only filter them. Do not create another router, menu store, layout, identity provider, permission store or copied CRUD page; route/action access uses capability or `allOf`/`anyOf`, while Data/App API authorization remains server-owned.
6
+ - Bind every generated `appRoutes` entry to its local page with `defineApplicationContributions`; desktop `admin` routes stay inside the platform Shell, while `user` routes render without an admin Shell for independent mobile/user experiences. Use only its typed `toolbar`, `row` and `detail` resource slots for generated resource actions. Declare the complete editable admin menu with `defineAdminNavigation` and its page/group helpers; the Shell renders only generated `adminNavigation` references and permissions only filter them. Do not create another router, menu store, layout, identity provider, permission store or copied CRUD page; route/action access uses capability or `allOf`/`anyOf`, while Data/App API and Workflow authorization remain server-owned.
7
7
  - Declare each resource once in `openxiangda.config.ts` with only `code`, `name`, `fields` and optional `mutationOwner`, generated/list/layout/data-policy settings. Each field owns type, label, required state, Surface flags, reference/file metadata and access. Resource codes are lower kebab-case. Use `native` for direct Data API mutations, `action`, `readonly` or `workflow` for non-Native ownership; never grant or generate Native mutation for a non-Native owner.
8
8
  - Never write resource-level `schemaVersion`, `appCode`, `schema`, `surface`, `capabilities`, `fieldPolicies` or `platform/data` modules. The compiler derives the strict DataResource, CRUD capabilities, Surface and AI Schema. The application manifest still starts with its one top-level `schemaVersion: 3`.
9
9
  - Ordinary list/get/create/update/delete, filters, export and batch operations use the platform Native Data API. Do not create Function CRUD or NestJS wrappers.
@@ -20,6 +20,6 @@
20
20
  - `option.*`, `user.*`, `department.*` and `resource-ref.*` values never collapse to scalar IDs. Single values store one labeled object and multiple values store object arrays. `resource-ref.*` JSON is not a foreign key, trusted target snapshot or automatically refreshed copy; current target state is read by `resourceCode` plus `value`. A resource source `labelField` must be `text.short` or `text.long`; `serial-number` is allowed in source search, description and snapshot fields, but not as the label. `location` accepts only exact WGS84 coordinates captured by DingTalk or browser geolocation; it has no manual input or `manual` source. Roles that consume directory-backed fields explicitly include `app:<app-code>:directory:read`.
21
21
  - Derive role grants with `resourceCapabilityCodes(appCode, resourceCode)`. Declare current-user rows only with `currentUserDataPolicy(...)`; do not invent operators, values or alternate current-user spellings.
22
22
  - Do not add compatibility aliases, migration branches or silent fallbacks for an earlier 2.0 alpha contract. Replace an incorrect contract and regenerate the application.
23
- - Standard Workflow and Notification Hub are optional 2.0 modules. Enable them only through the canonical `openxiangda.config.ts` declarations and generated clients; declare each Workflow launch as `standalone`, `custom-page`, `hidden-handoff` or `work-center-only`, put only self-contained standalone launch pages in navigation, and bind durable save handlers with `defineWorkflowLaunchContributions` without repeating generated titles. Never depend on temporary router location state or copy 1.x workflow/message code, tables, APIs, templates or callbacks into this workspace. Keep ordinary CRUD and application-specific state machines independent of them.
23
+ - Standard Workflow and Notification Hub are optional 2.0 modules. Enable them only through the canonical `openxiangda.config.ts` declarations and generated clients; declare each Workflow launch as `standalone`, `custom-page`, `hidden-handoff` or `work-center-only`, put only self-contained standalone launch pages in navigation, and bind durable save handlers with `defineWorkflowLaunchContributions` without repeating generated titles. Desktop and mobile standard launch pages load the same canonical Workflow launch Surface. To replace Workflow detail, declare both desktop and mobile `detailRouteCode` values whose routes contain exactly `:instanceId`; Work Center, the application todo center and notification channels consume the platform-resolved paths, while every Surface and command remains server-authorized. Add the standard current-user todo page only with `adminApplicationTodoCenterPage()`; never query Notification Hub management APIs from a user page. Never depend on temporary router location state or copy 1.x workflow/message code, tables, APIs, templates or callbacks into this workspace. Keep ordinary CRUD and application-specific state machines independent of them.
24
24
  - Run `pnpm openxiangda check --json` after contract changes and read `data.sealedArtifact`; check never seals, and an older `.openxiangda/build/app-package.json` is not the current check result. Use `pnpm openxiangda accept --plan <file>` only for optional real preproduction identity acceptance; it never blocks delivery. Deploy with `pnpm openxiangda deploy`, inspect with `pnpm openxiangda status` and `pnpm openxiangda logs`, and use the platform rollback command rather than mutating K3s directly.
25
25
  - AI-native clients start the workspace protocol through the same pinned binary: `pnpm exec openxiangda --mcp-stdio --cwd <workspace>`. Read `openxiangda://workspace/contracts` or call `contract_describe`; for first-time menu authoring, copy `data.adminNavigationAuthoring.suggestion.expression` once into `frontend.admin.navigation` with its listed `openxiangda/config` imports, then edit that application-owned declaration. Never treat the proposal as runtime discovery. When AppSpec is enabled, also read `openxiangda://workspace/appspec` or call `appspec_context`. Require `aiCatalog` and `aiCatalogDigest` to match the normal compiler output. This is a stdio transport mode, not another application-owned server. Never write an application MCP server, Catalog file, preview store or second authorization path.
@@ -18,6 +18,14 @@ pnpm openxiangda dev
18
18
 
19
19
  字段组件、CRUD Renderer、Shell、Runtime 与 Data/Directory/File/App Client 都由 `openxiangda/react`、`openxiangda/field-kit` 和 `openxiangda/core` 统一提供,应用模板不复制这些实现。Nest 只在需要复杂事务或外部集成时增加 App API。标准客户端显式携带当前环境、使用同源 Cookie;业务代码不读取或持久化 Token、平台内部身份标识与授权结果。
20
20
 
21
+ 同一应用的自定义后台页、PC 用户页和移动用户页统一声明在
22
+ `frontend.routes`,再用 `defineApplicationContributions(appRoutes, { pages })`
23
+ 逐项绑定本地组件并传给 `OpenXiangdaApplication`。后者是唯一的
24
+ BrowserRouter、RuntimeBoundary、Refine、generated routes 和后台 Shell
25
+ 所有者;用户页可以拥有独立布局,但不要再建 router/Shell。要使用通用应用待办中心,
26
+ 在显式后台导航组中加入 `adminApplicationTodoCenterPage()`;平台生成
27
+ `/admin/todos` 与 `/m/todos`,只读取当前用户的消息/待办投影并统一解析目标页面。
28
+
21
29
  前端一次读取当前登录用户在当前环境的完整应用角色并集和 capability。应用可在 `perspectives` 中声明工作视角;视角只投影页面、行和字段的读取范围,不改变真实身份,也不收窄新增、编辑、删除、流程与自定义动作权限。标准 Data API 自动识别视角;只有绕过标准 Data API 的自定义 Nest 查询才需要通过 `@CurrentPerspective()` 显式实现同等读取投影。字段默认继承资源 read/create/update;`access` 数组为 all-of,`false` 为明确拒绝,无权字段在提交前从 payload 删除。
22
30
 
23
31
  ```bash
@@ -15,7 +15,7 @@
15
15
  "@nestjs/common": "11.1.29",
16
16
  "@nestjs/core": "11.1.29",
17
17
  "@nestjs/platform-fastify": "11.1.29",
18
- "openxiangda": "2.0.0-alpha.31",
18
+ "openxiangda": "2.0.0-alpha.33",
19
19
  "reflect-metadata": "0.2.2",
20
20
  "rxjs": "7.8.2"
21
21
  },
@@ -1,11 +1,13 @@
1
1
  import React from 'react';
2
2
  import ReactDOM from 'react-dom/client';
3
3
  import {
4
+ defineApplicationContributions,
4
5
  defineWorkflowLaunchContributions,
5
6
  OpenXiangdaApplication,
6
7
  type AdminNavigationInput,
7
8
  type AdminPagesInput,
8
9
  } from 'openxiangda/react';
10
+ import { useParams, useSearchParams } from 'react-router-dom';
9
11
  import { appCode } from '../../../packages/contracts/src/generated.js';
10
12
  import 'openxiangda/react/styles.css';
11
13
 
@@ -13,11 +15,54 @@ const workflowDefinitions = [
13
15
  {
14
16
  code: 'purchase-approval',
15
17
  title: '采购审批',
16
- launch: { mode: 'work-center-only' },
18
+ launch: { mode: 'standalone' },
17
19
  },
18
20
  ] as const;
19
21
 
22
+ const detailRoutes = {
23
+ desktop: {
24
+ code: 'purchase-detail',
25
+ path: '/admin/operations/purchases/:instanceId',
26
+ label: '采购审批详情',
27
+ surface: 'admin',
28
+ tabPersistence: 'none',
29
+ keepAlive: 'none',
30
+ },
31
+ mobile: {
32
+ code: 'purchase-detail-mobile',
33
+ path: '/m/purchases/:instanceId',
34
+ label: '移动采购审批详情',
35
+ surface: 'user',
36
+ tabPersistence: 'none',
37
+ keepAlive: 'none',
38
+ },
39
+ } as const;
40
+
41
+ function CustomWorkflowDetail() {
42
+ const { instanceId } = useParams();
43
+ const [search] = useSearchParams();
44
+ return (
45
+ <div data-testid="custom-workflow-detail">
46
+ 自定义流程详情 {instanceId} / {search.get('taskId') || 'instance'}
47
+ </div>
48
+ );
49
+ }
50
+
51
+ const applicationContributions = defineApplicationContributions(detailRoutes, {
52
+ pages: {
53
+ desktop: CustomWorkflowDetail,
54
+ mobile: CustomWorkflowDetail,
55
+ },
56
+ });
57
+
20
58
  const adminPages = [
59
+ {
60
+ code: 'application:todo-center',
61
+ kind: 'application-todo-center',
62
+ path: '/admin/todos',
63
+ label: '待办中心',
64
+ navigationEligible: true,
65
+ },
21
66
  {
22
67
  code: 'workflow:work-center',
23
68
  kind: 'workflow-work-center',
@@ -25,6 +70,14 @@ const adminPages = [
25
70
  label: '我的审批',
26
71
  navigationEligible: true,
27
72
  },
73
+ {
74
+ code: 'workflow:purchase-approval:launch',
75
+ kind: 'workflow-launch',
76
+ path: '/admin/workflows/purchase-approval/new',
77
+ label: '发起采购审批',
78
+ navigationEligible: true,
79
+ workflowCode: 'purchase-approval',
80
+ },
28
81
  {
29
82
  code: 'workflow:task',
30
83
  kind: 'workflow-task',
@@ -47,7 +100,15 @@ const adminNavigation = [
47
100
  label: '流程中心',
48
101
  icon: 'workflow',
49
102
  order: 0,
50
- items: [{ pageCode: 'workflow:work-center', label: '我的审批', order: 0 }],
103
+ items: [
104
+ { pageCode: 'application:todo-center', label: '待办中心', order: 0 },
105
+ { pageCode: 'workflow:work-center', label: '我的审批', order: 1 },
106
+ {
107
+ pageCode: 'workflow:purchase-approval:launch',
108
+ label: '发起采购审批',
109
+ order: 2,
110
+ },
111
+ ],
51
112
  },
52
113
  ] as const satisfies AdminNavigationInput;
53
114
 
@@ -65,8 +126,19 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
65
126
  appName="流程体验验收"
66
127
  adminNavigation={adminNavigation}
67
128
  adminPages={adminPages}
129
+ contributions={applicationContributions}
68
130
  resourceDefinitions={{}}
69
- workflows={defineWorkflowLaunchContributions(workflowDefinitions, {})}
131
+ workflows={defineWorkflowLaunchContributions(workflowDefinitions, {
132
+ 'purchase-approval': async () => ({
133
+ businessKey: 'PO-2026-0825',
134
+ dataRef: {
135
+ resourceCode: 'purchase-orders',
136
+ recordId: 'purchase-1',
137
+ dataRevision: '8',
138
+ },
139
+ facts: { amount: 28600, applicant: '张三' },
140
+ }),
141
+ })}
70
142
  />
71
143
  </React.StrictMode>
72
144
  );
@@ -7,7 +7,21 @@ function envelope(data: unknown, code = 200) {
7
7
  return { code, message: code === 200 ? 'success' : 'forbidden', data };
8
8
  }
9
9
 
10
- function task(completed = false) {
10
+ function detailNavigation(custom = false) {
11
+ return custom
12
+ ? {
13
+ custom: true,
14
+ desktopPath: `/admin/operations/purchases/${instanceId}?taskId=${taskId}`,
15
+ mobilePath: `/m/purchases/${instanceId}?taskId=${taskId}`,
16
+ }
17
+ : {
18
+ custom: false,
19
+ desktopPath: `/admin/tasks/${taskId}`,
20
+ mobilePath: `/m/tasks/${taskId}`,
21
+ };
22
+ }
23
+
24
+ function task(completed = false, customDetail = false) {
11
25
  return {
12
26
  schemaVersion: 'openxiangda.workflow-task/v2',
13
27
  engineVersion: '2.0',
@@ -29,16 +43,18 @@ function task(completed = false) {
29
43
  dataRef: { resourceCode: 'purchase-orders', recordId: 'purchase-1' },
30
44
  createdAt: '2026-08-25T01:00:00.000Z',
31
45
  updatedAt: '2026-08-25T01:10:00.000Z',
46
+ detailNavigation: detailNavigation(customDetail),
32
47
  };
33
48
  }
34
49
 
35
- function surface(completed = false) {
50
+ function surface(completed = false, customDetail = false) {
36
51
  return {
37
52
  schemaVersion: 'openxiangda.workflow-surface/v2',
38
53
  protocolVersion: 'workflow_surface_v2',
39
54
  surfaceRevision: completed ? 'revision-2' : 'revision-1',
40
55
  engineVersion: '2.0',
41
56
  instanceSequence: completed ? 4 : 3,
57
+ detailNavigation: detailNavigation(customDetail),
42
58
  navigationTarget: {
43
59
  kind: 'resource_record',
44
60
  appCode,
@@ -72,7 +88,7 @@ function surface(completed = false) {
72
88
  startedAt: '2026-08-25T01:00:00.000Z',
73
89
  completedAt: completed ? '2026-08-25T01:12:00.000Z' : null,
74
90
  },
75
- task: completed ? null : task(false),
91
+ task: completed ? null : task(false, customDetail),
76
92
  presentation: {
77
93
  status: { label: completed ? '已同意' : '待审批', tone: 'warning' },
78
94
  layout: 'approval_detail',
@@ -117,7 +133,7 @@ function surface(completed = false) {
117
133
  },
118
134
  refresh: ['surface', 'timeline', 'work_center'],
119
135
  },
120
- ...['reject', 'return', 'transfer', 'add_assignee'].map((key) => ({
136
+ ...['reject', 'return', 'transfer', 'add_assignee'].map(key => ({
121
137
  key,
122
138
  kind: 'workflow_command',
123
139
  label: {
@@ -184,9 +200,87 @@ function timeline(completed = false) {
184
200
  };
185
201
  }
186
202
 
187
- async function mockWorkflow(page: Page, options: { forbidden?: boolean } = {}) {
203
+ function launchSurface() {
204
+ return {
205
+ schemaVersion: 'openxiangda.workflow-launch-surface/v2',
206
+ protocolVersion: 'workflow_launch_surface_v2',
207
+ surfaceRevision: 'launch-revision-1',
208
+ engineVersion: '2.0',
209
+ appCode,
210
+ environmentKey: 'preproduction',
211
+ environmentId: 'preproduction-id',
212
+ workflowCode: 'purchase-approval',
213
+ title: '采购审批',
214
+ launchMode: 'standalone',
215
+ inputSchema: { type: 'object', properties: {} },
216
+ head: {
217
+ workflowRevision: 4,
218
+ definitionVersion: 1,
219
+ bindingVersion: 1,
220
+ nativeRevision: 3,
221
+ contractRevisionId: 'contract-revision-1',
222
+ },
223
+ paths: {
224
+ desktop: '/admin/workflows/purchase-approval/new',
225
+ mobile: '/m/workflows/purchase-approval/start',
226
+ },
227
+ prepare: {
228
+ method: 'POST',
229
+ href: '/workflow/definitions/purchase-approval/prepare-start',
230
+ },
231
+ start: {
232
+ method: 'POST',
233
+ href: '/workflow/instances',
234
+ idempotencyRequired: true,
235
+ },
236
+ };
237
+ }
238
+
239
+ function todoPage() {
240
+ return {
241
+ schemaVersion: 'openxiangda.application-todo-center/v2',
242
+ appCode,
243
+ environmentKey: 'preproduction',
244
+ view: 'pending',
245
+ counts: { pending: 1, informational: 2, completed: 3, unread: 1 },
246
+ items: [
247
+ {
248
+ messageId: '33333333-3333-4333-8333-333333333333',
249
+ recipientId: '44444444-4444-4444-8444-444444444444',
250
+ state: 'action_required',
251
+ interactionState: 'unread',
252
+ title: '设备采购申请待审批',
253
+ summary: '张三提交了一笔办公设备采购申请',
254
+ fields: [
255
+ { code: 'amount', label: '申请金额', value: '¥28,600' },
256
+ { code: 'department', label: '申请部门', value: '理学院' },
257
+ ],
258
+ actions: [{ code: 'open', label: '去处理' }],
259
+ sourceKind: 'PLATFORM_EVENT',
260
+ sourceLabel: '流程中心',
261
+ occurredAt: '2026-08-28T01:00:00.000Z',
262
+ updatedAt: '2026-08-28T01:01:00.000Z',
263
+ navigation: {
264
+ desktopPath: `/admin/operations/purchases/${instanceId}?taskId=${taskId}`,
265
+ mobilePath: `/m/purchases/${instanceId}?taskId=${taskId}`,
266
+ external: false,
267
+ navigationUnavailable: false,
268
+ },
269
+ },
270
+ ],
271
+ total: 1,
272
+ limit: 12,
273
+ offset: 0,
274
+ nextOffset: null,
275
+ };
276
+ }
277
+
278
+ async function mockWorkflow(
279
+ page: Page,
280
+ options: { forbidden?: boolean; customDetail?: boolean } = {}
281
+ ) {
188
282
  let completed = false;
189
- await page.route('**/service/**', async (route) => {
283
+ await page.route('**/service/**', async route => {
190
284
  const url = new URL(route.request().url());
191
285
  const path = url.pathname;
192
286
  if (path.endsWith('/native/authz/current')) {
@@ -229,7 +323,7 @@ async function mockWorkflow(page: Page, options: { forbidden?: boolean } = {}) {
229
323
  identityScope:
230
324
  'user-union:preproduction-id:acceptance-user:authz-1',
231
325
  },
232
- }),
326
+ })
233
327
  ),
234
328
  });
235
329
  }
@@ -246,6 +340,71 @@ async function mockWorkflow(page: Page, options: { forbidden?: boolean } = {}) {
246
340
  body: JSON.stringify(envelope(null, 403)),
247
341
  });
248
342
  }
343
+ if (path.endsWith('/todos') && route.request().method() === 'GET') {
344
+ return route.fulfill({
345
+ contentType: 'application/json',
346
+ body: JSON.stringify(envelope(todoPage())),
347
+ });
348
+ }
349
+ if (path.includes('/todos/') && path.endsWith('/interactions')) {
350
+ return route.fulfill({
351
+ contentType: 'application/json',
352
+ body: JSON.stringify(
353
+ envelope({
354
+ accepted: true,
355
+ duplicate: false,
356
+ interactionState: 'clicked',
357
+ })
358
+ ),
359
+ });
360
+ }
361
+ if (
362
+ path.endsWith(
363
+ '/workflow/definitions/purchase-approval/launch-surface'
364
+ )
365
+ ) {
366
+ return route.fulfill({
367
+ contentType: 'application/json',
368
+ body: JSON.stringify(envelope(launchSurface())),
369
+ });
370
+ }
371
+ if (
372
+ path.endsWith(
373
+ '/workflow/definitions/purchase-approval/prepare-start'
374
+ )
375
+ ) {
376
+ return route.fulfill({
377
+ contentType: 'application/json',
378
+ body: JSON.stringify(
379
+ envelope({
380
+ schemaVersion: 'openxiangda.workflow-preparation/v2',
381
+ preparationId: 'preparation-1',
382
+ status: 'ready',
383
+ requirements: [],
384
+ preparationToken: 'preparation-token-1',
385
+ preview: { nodes: [] },
386
+ expiresAt: '2026-08-28T02:00:00.000Z',
387
+ definitionVersion: 1,
388
+ bindingVersion: 1,
389
+ inputHash: 'input-hash-1',
390
+ })
391
+ ),
392
+ });
393
+ }
394
+ if (
395
+ path.endsWith('/workflow/instances') &&
396
+ route.request().method() === 'POST'
397
+ ) {
398
+ return route.fulfill({
399
+ contentType: 'application/json',
400
+ body: JSON.stringify(
401
+ envelope({
402
+ ...surface(false, options.customDetail).instance,
403
+ id: instanceId,
404
+ })
405
+ ),
406
+ });
407
+ }
249
408
  if (path.endsWith('/workflow/work-center/items')) {
250
409
  return route.fulfill({
251
410
  contentType: 'application/json',
@@ -258,15 +417,25 @@ async function mockWorkflow(page: Page, options: { forbidden?: boolean } = {}) {
258
417
  total: 1,
259
418
  limit: 20,
260
419
  offset: 0,
261
- items: [task(completed)],
262
- }),
420
+ items: [task(completed, options.customDetail)],
421
+ })
263
422
  ),
264
423
  });
265
424
  }
266
425
  if (path.endsWith(`/workflow/tasks/${taskId}/surface`)) {
267
426
  return route.fulfill({
268
427
  contentType: 'application/json',
269
- body: JSON.stringify(envelope(surface(completed))),
428
+ body: JSON.stringify(
429
+ envelope(surface(completed, options.customDetail))
430
+ ),
431
+ });
432
+ }
433
+ if (path.endsWith(`/workflow/instances/${instanceId}/surface`)) {
434
+ return route.fulfill({
435
+ contentType: 'application/json',
436
+ body: JSON.stringify(
437
+ envelope(surface(completed, options.customDetail))
438
+ ),
270
439
  });
271
440
  }
272
441
  if (path.endsWith(`/workflow/instances/${instanceId}/timeline`)) {
@@ -294,22 +463,93 @@ test('renders a paged desktop work center from the current-user role union', asy
294
463
  page,
295
464
  }) => {
296
465
  await mockWorkflow(page);
297
- await page.goto(
298
- '/workflow-experience.e2e.html?initial=/admin/work-center'
299
- );
466
+ await page.goto('/workflow-experience.e2e.html?initial=/admin/work-center');
300
467
  await expect(page.getByRole('heading', { name: '审批工作台' })).toBeVisible();
301
468
  await expect(page.getByRole('link', { name: '采购申请审批' })).toBeVisible();
302
469
  await expect(page.getByText('PO-2026-0825')).toBeVisible();
303
470
  });
304
471
 
305
- test('renders the independent mobile task and executes only a Surface operation', async ({
472
+ test('renders the generic desktop and mobile application todo center without a second router', async ({
306
473
  page,
307
474
  }) => {
308
475
  await mockWorkflow(page);
476
+ await page.goto('/workflow-experience.e2e.html?initial=/admin/todos');
477
+ await expect(page.getByRole('heading', { name: '待办中心' })).toBeVisible();
478
+ await expect(page.getByText('设备采购申请待审批').first()).toBeVisible();
479
+ await expect(page.getByText('¥28,600').first()).toBeVisible();
480
+
481
+ await page.setViewportSize({ width: 390, height: 844 });
482
+ await page.goto('/workflow-experience.e2e.html?initial=/m/todos');
483
+ await expect(page.locator('.oxa-todo-center-mobile')).toBeVisible();
484
+ await page.getByRole('button', { name: '去处理' }).click();
485
+ await expect(page.getByTestId('custom-workflow-detail')).toContainText(
486
+ `${instanceId} / ${taskId}`
487
+ );
488
+ });
489
+
490
+ test('launches the same workflow from the canonical mobile Surface and follows custom detail routing', async ({
491
+ page,
492
+ }) => {
493
+ await mockWorkflow(page, { customDetail: true });
309
494
  await page.setViewportSize({ width: 390, height: 844 });
310
495
  await page.goto(
311
- `/workflow-experience.e2e.html?initial=/m/tasks/${taskId}`
496
+ '/workflow-experience.e2e.html?initial=/m/workflows/purchase-approval/start'
312
497
  );
498
+ await expect(page.locator('.oxa-workflow-submission-mobile')).toBeVisible();
499
+ await expect(page.getByRole('heading', { name: '采购审批' })).toBeVisible();
500
+ await page.getByRole('button', { name: '保存业务数据并预检' }).click();
501
+ await expect(page.getByRole('button', { name: '提交审批' })).toBeVisible();
502
+ await page.getByRole('button', { name: '提交审批' }).click();
503
+ await expect(page.getByTestId('custom-workflow-detail')).toContainText(
504
+ `${instanceId} / ${taskId}`
505
+ );
506
+ await expect(page).toHaveURL(
507
+ new RegExp(`/m/purchases/${instanceId}\\?taskId=${taskId}$`)
508
+ );
509
+ });
510
+
511
+ test('routes desktop and mobile work-center entries to the declared custom detail pages', async ({
512
+ page,
513
+ }) => {
514
+ await mockWorkflow(page, { customDetail: true });
515
+ await page.goto('/workflow-experience.e2e.html?initial=/admin/work-center');
516
+ await page.getByRole('link', { name: '采购申请审批' }).click();
517
+ await expect(page.getByTestId('custom-workflow-detail')).toContainText(
518
+ `${instanceId} / ${taskId}`
519
+ );
520
+ await expect(page).toHaveURL(
521
+ new RegExp(`/admin/operations/purchases/${instanceId}\\?taskId=${taskId}$`)
522
+ );
523
+
524
+ await page.setViewportSize({ width: 390, height: 844 });
525
+ await page.goto('/workflow-experience.e2e.html?initial=/m/work-center');
526
+ await page.getByText('采购申请审批').click();
527
+ await expect(page.getByTestId('custom-workflow-detail')).toContainText(
528
+ `${instanceId} / ${taskId}`
529
+ );
530
+ await expect(page).toHaveURL(
531
+ new RegExp(`/m/purchases/${instanceId}\\?taskId=${taskId}$`)
532
+ );
533
+ });
534
+
535
+ test('redirects a direct standard task URL to the declared custom detail page', async ({
536
+ page,
537
+ }) => {
538
+ await mockWorkflow(page, { customDetail: true });
539
+ await page.goto(
540
+ `/workflow-experience.e2e.html?initial=/admin/tasks/${taskId}`
541
+ );
542
+ await expect(page.getByTestId('custom-workflow-detail')).toContainText(
543
+ `${instanceId} / ${taskId}`
544
+ );
545
+ });
546
+
547
+ test('renders the independent mobile task and executes only a Surface operation', async ({
548
+ page,
549
+ }) => {
550
+ await mockWorkflow(page);
551
+ await page.setViewportSize({ width: 390, height: 844 });
552
+ await page.goto(`/workflow-experience.e2e.html?initial=/m/tasks/${taskId}`);
313
553
  await expect(page.locator('.oxa-workflow-mobile-page')).toBeVisible();
314
554
  await expect(page.getByText('¥28,600')).toBeVisible();
315
555
  await expect(page.getByText('财务负责人').first()).toBeVisible();
@@ -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.31",
17
+ "openxiangda": "2.0.0-alpha.33",
18
18
  "react": "19.2.8",
19
19
  "react-dom": "19.2.8",
20
20
  "react-router-dom": "7.8.2"
@@ -1,7 +1,7 @@
1
1
  import React from 'react';
2
2
  import ReactDOM from 'react-dom/client';
3
3
  import {
4
- defineAdminContributions,
4
+ defineApplicationContributions,
5
5
  defineWorkflowLaunchContributions,
6
6
  OpenXiangdaApplication,
7
7
  } from 'openxiangda/react';
@@ -17,7 +17,7 @@ import {
17
17
  } from '../../../packages/contracts/src/generated.js';
18
18
  import 'openxiangda/react/styles.css';
19
19
 
20
- const adminContributions = defineAdminContributions(appRoutes, {
20
+ const applicationContributions = defineApplicationContributions(appRoutes, {
21
21
  pages: {},
22
22
  });
23
23
  const workflowContributions = defineWorkflowLaunchContributions(
@@ -32,7 +32,7 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
32
32
  appName={appName}
33
33
  adminNavigation={adminNavigation}
34
34
  adminPages={adminPages}
35
- contributions={adminContributions}
35
+ contributions={applicationContributions}
36
36
  perspectives={appPerspectives}
37
37
  resourceDefinitions={resourceDefinitions}
38
38
  workflows={workflowContributions}
@@ -28,8 +28,8 @@ test('clean template contains only application declarations and one React entry'
28
28
  );
29
29
  assert.match(config, /data:\s*\{\s*resources:/);
30
30
  assert.match(source, /OpenXiangdaApplication/);
31
- assert.match(source, /defineAdminContributions\(appRoutes/);
32
- assert.match(source, /contributions=\{adminContributions\}/);
31
+ assert.match(source, /defineApplicationContributions\(appRoutes/);
32
+ assert.match(source, /contributions=\{applicationContributions\}/);
33
33
  assert.match(source, /from 'openxiangda\/react'/);
34
34
  assert.match(source, /openxiangda\/react\/styles\.css/);
35
35
  const legacyMarkers = [
@@ -22,7 +22,7 @@
22
22
  "build": "pnpm --recursive build"
23
23
  },
24
24
  "devDependencies": {
25
- "openxiangda": "2.0.0-alpha.31",
25
+ "openxiangda": "2.0.0-alpha.33",
26
26
  "typescript": "5.9.3"
27
27
  }
28
28
  }