openxiangda-cli 2.0.0-alpha.116 → 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/README.md CHANGED
@@ -7,7 +7,10 @@ The CLI disables user, development and JIT plugin loading. A clean build removes
7
7
  AI-native development clients use the same pinned executable in pre-command
8
8
  stdio mode: `pnpm exec openxiangda --mcp-stdio --cwd <workspace>`. They can read
9
9
  `openxiangda://workspace/contracts` or call `contract_describe` to obtain the
10
- compiler-owned `aiCatalog` and `aiCatalogDigest`. This transport does not add a
11
- another executable and applications must not implement a second MCP server.
10
+ compiler-owned `aiCatalog`, `aiCatalogDigest` and the typed copy-once
11
+ `adminNavigationAuthoring.suggestion`. Copy its expression into
12
+ `frontend.admin.navigation`; runtime never applies it automatically. This
13
+ transport does not add another executable and applications must not implement
14
+ a second MCP server.
12
15
 
13
16
  `dev` starts local Web/Nest hot reload and a loopback connected proxy. `deploy` defaults to test; production explicitly reuses a successful test AppVersion. Application binding, initialization, generation, tests and builds are internal stages rather than separate commands.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openxiangda-cli",
3
- "version": "2.0.0-alpha.116",
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.55",
25
- "openxiangda-devkit-core": "2.0.0-alpha.72",
26
- "openxiangda-mcp": "2.0.0-alpha.72",
27
- "openxiangda-skill-kit": "2.0.0-alpha.91"
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,8 +3,8 @@
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. Do not create another router, menu, 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.
7
- - Declare each resource once in `openxiangda.config.ts` with only `code`, `name`, `fields` and optional 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.
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
+ - 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.
10
10
  - Add NestJS only for a named business action that needs a cross-resource transaction, an invariant or an external side effect.
@@ -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; never 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
- - 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`; 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.
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.30",
18
+ "openxiangda": "2.0.0-alpha.33",
19
19
  "reflect-metadata": "0.2.2",
20
20
  "rxjs": "7.8.2"
21
21
  },
@@ -35,7 +35,14 @@ export const liveDefinition: GeneratedResourceDefinition = {
35
35
  name: '字段协议记录',
36
36
  capabilities,
37
37
  surface: {
38
- generated: true,
38
+ mutationOwner: 'native',
39
+ generated: {
40
+ list: true,
41
+ detail: true,
42
+ create: true,
43
+ update: true,
44
+ delete: true,
45
+ },
39
46
  fields: {
40
47
  text_short: {
41
48
  label: '标题',
@@ -63,7 +63,14 @@ const fields: DataResourceSurface['fields'] = {
63
63
  };
64
64
 
65
65
  const surface: DataResourceSurface = {
66
- generated: true,
66
+ mutationOwner: 'native',
67
+ generated: {
68
+ list: true,
69
+ detail: true,
70
+ create: true,
71
+ update: true,
72
+ delete: true,
73
+ },
67
74
  fields,
68
75
  list: {
69
76
  defaultPageSize: 20,
@@ -145,7 +152,6 @@ const adminContributions = defineAdminContributions(
145
152
  path: '/admin/operations/content',
146
153
  label: '内容业务操作',
147
154
  surface: 'admin',
148
- navigation: 'menu',
149
155
  capability: 'app:openxiangda-application:operations:read',
150
156
  tabPersistence: 'session',
151
157
  keepAlive: 'none',
@@ -179,6 +185,179 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
179
185
  <OpenXiangdaApplication
180
186
  appCode="openxiangda-application"
181
187
  appName="资源体验验收"
188
+ adminNavigation={[
189
+ {
190
+ code: 'resources',
191
+ label: '数据管理',
192
+ icon: 'database',
193
+ order: 0,
194
+ items: [
195
+ { pageCode: 'resource:resource-01:list', label: '内容栏目', order: 0 },
196
+ { pageCode: 'resource:resource-02:list', label: '内容文章', order: 1 },
197
+ { pageCode: 'resource:resource-03:list', label: '内容专题', order: 2 },
198
+ { pageCode: 'resource:resource-04:list', label: '会员档案', order: 3 },
199
+ { pageCode: 'resource:resource-05:list', label: '门户轮播', order: 4 },
200
+ { pageCode: 'resource:resource-06:list', label: '工会小组', order: 5 },
201
+ { pageCode: 'resource:resource-07:list', label: '场地预约', order: 6 },
202
+ {
203
+ pageCode: 'resource:resource-08:list',
204
+ label: '场地不可用时段',
205
+ order: 7,
206
+ },
207
+ { pageCode: 'resource:resource-09:list', label: '场地档案', order: 8 },
208
+ { pageCode: 'resource:resource-10:list', label: '通知公告', order: 9 },
209
+ { pageCode: 'resource:resource-11:list', label: '活动报名', order: 10 },
210
+ { pageCode: 'resource:resource-12:list', label: '资料下载', order: 11 },
211
+ { pageCode: 'operation:content-operations', order: 12 },
212
+ ],
213
+ },
214
+ ]}
215
+ adminPages={[
216
+ {
217
+ code: 'resource:resource-01:list',
218
+ kind: 'resource-list',
219
+ path: '/resource-01',
220
+ label: '资源 01',
221
+ navigationEligible: true,
222
+ capability: resourceDefinitions['resource-01'].capabilities.read,
223
+ resourceCode: 'resource-01',
224
+ },
225
+ {
226
+ code: 'resource:resource-01:detail',
227
+ kind: 'resource-detail',
228
+ path: '/resource-01/:id',
229
+ label: '资源 01 详情',
230
+ navigationEligible: false,
231
+ capability: resourceDefinitions['resource-01'].capabilities.read,
232
+ resourceCode: 'resource-01',
233
+ },
234
+ {
235
+ code: 'resource:resource-01:create',
236
+ kind: 'resource-create',
237
+ path: '/resource-01/new',
238
+ label: '新增资源 01',
239
+ navigationEligible: false,
240
+ capability: resourceDefinitions['resource-01'].capabilities.create,
241
+ resourceCode: 'resource-01',
242
+ },
243
+ {
244
+ code: 'resource:resource-01:update',
245
+ kind: 'resource-update',
246
+ path: '/resource-01/:id/edit',
247
+ label: '编辑资源 01',
248
+ navigationEligible: false,
249
+ capability: resourceDefinitions['resource-01'].capabilities.update,
250
+ resourceCode: 'resource-01',
251
+ },
252
+ {
253
+ code: 'resource:resource-02:list',
254
+ kind: 'resource-list',
255
+ path: '/resource-02',
256
+ label: '内容文章',
257
+ navigationEligible: true,
258
+ capability: resourceDefinitions['resource-02'].capabilities.read,
259
+ resourceCode: 'resource-02',
260
+ },
261
+ {
262
+ code: 'resource:resource-03:list',
263
+ kind: 'resource-list',
264
+ path: '/resource-03',
265
+ label: '内容专题',
266
+ navigationEligible: true,
267
+ capability: resourceDefinitions['resource-03'].capabilities.read,
268
+ resourceCode: 'resource-03',
269
+ },
270
+ {
271
+ code: 'resource:resource-04:list',
272
+ kind: 'resource-list',
273
+ path: '/resource-04',
274
+ label: '会员档案',
275
+ navigationEligible: true,
276
+ capability: resourceDefinitions['resource-04'].capabilities.read,
277
+ resourceCode: 'resource-04',
278
+ },
279
+ {
280
+ code: 'resource:resource-05:list',
281
+ kind: 'resource-list',
282
+ path: '/resource-05',
283
+ label: '门户轮播',
284
+ navigationEligible: true,
285
+ capability: resourceDefinitions['resource-05'].capabilities.read,
286
+ resourceCode: 'resource-05',
287
+ },
288
+ {
289
+ code: 'resource:resource-06:list',
290
+ kind: 'resource-list',
291
+ path: '/resource-06',
292
+ label: '工会小组',
293
+ navigationEligible: true,
294
+ capability: resourceDefinitions['resource-06'].capabilities.read,
295
+ resourceCode: 'resource-06',
296
+ },
297
+ {
298
+ code: 'resource:resource-07:list',
299
+ kind: 'resource-list',
300
+ path: '/resource-07',
301
+ label: '场地预约',
302
+ navigationEligible: true,
303
+ capability: resourceDefinitions['resource-07'].capabilities.read,
304
+ resourceCode: 'resource-07',
305
+ },
306
+ {
307
+ code: 'resource:resource-08:list',
308
+ kind: 'resource-list',
309
+ path: '/resource-08',
310
+ label: '场地不可用时段',
311
+ navigationEligible: true,
312
+ capability: resourceDefinitions['resource-08'].capabilities.read,
313
+ resourceCode: 'resource-08',
314
+ },
315
+ {
316
+ code: 'resource:resource-09:list',
317
+ kind: 'resource-list',
318
+ path: '/resource-09',
319
+ label: '场地档案',
320
+ navigationEligible: true,
321
+ capability: resourceDefinitions['resource-09'].capabilities.read,
322
+ resourceCode: 'resource-09',
323
+ },
324
+ {
325
+ code: 'resource:resource-10:list',
326
+ kind: 'resource-list',
327
+ path: '/resource-10',
328
+ label: '通知公告',
329
+ navigationEligible: true,
330
+ capability: resourceDefinitions['resource-10'].capabilities.read,
331
+ resourceCode: 'resource-10',
332
+ },
333
+ {
334
+ code: 'resource:resource-11:list',
335
+ kind: 'resource-list',
336
+ path: '/resource-11',
337
+ label: '活动报名',
338
+ navigationEligible: true,
339
+ capability: resourceDefinitions['resource-11'].capabilities.read,
340
+ resourceCode: 'resource-11',
341
+ },
342
+ {
343
+ code: 'resource:resource-12:list',
344
+ kind: 'resource-list',
345
+ path: '/resource-12',
346
+ label: '资料下载',
347
+ navigationEligible: true,
348
+ capability: resourceDefinitions['resource-12'].capabilities.read,
349
+ resourceCode: 'resource-12',
350
+ },
351
+ {
352
+ code: 'operation:content-operations',
353
+ kind: 'operation',
354
+ path: '/admin/operations/content',
355
+ label: '内容业务操作',
356
+ navigationEligible: true,
357
+ capability: 'app:openxiangda-application:operations:read',
358
+ routeCode: 'content-operations',
359
+ },
360
+ ]}
182
361
  contributions={adminContributions}
183
362
  resourceDefinitions={resourceDefinitions}
184
363
  />
@@ -327,16 +327,18 @@ test('updates the current user avatar through the platform-owned profile contrac
327
327
 
328
328
  test('preserves the left menu scroll position when switching resource pages', async ({ page }) => {
329
329
  await mockPlatform(page, true, runtimeBase, true);
330
+ await page.setViewportSize({ width: 1280, height: 400 });
330
331
  await page.goto('/resource-experience.e2e.html');
331
332
  const scroller = page.locator('.oxa-menu-scroll');
332
333
  await scroller.evaluate(node => {
333
- const menu = node.querySelector('.oxa-menu') as HTMLElement | null;
334
- if (menu) menu.style.minHeight = '1400px';
335
334
  node.scrollTop = 240;
336
335
  node.dispatchEvent(new Event('scroll'));
337
336
  });
338
337
  await expect.poll(() => scroller.evaluate(node => node.scrollTop)).toBe(240);
339
- await page.locator('.oxa-sider').getByText('资料下载', { exact: true }).click();
338
+ await page
339
+ .locator('.oxa-sider')
340
+ .getByText('资料下载', { exact: true })
341
+ .evaluate(node => (node as HTMLElement).click());
340
342
  await expect(page).toHaveURL('/resource-12');
341
343
  await expect.poll(() => scroller.evaluate(node => node.scrollTop)).toBe(240);
342
344
  });
@@ -0,0 +1,144 @@
1
+ import React from 'react';
2
+ import ReactDOM from 'react-dom/client';
3
+ import {
4
+ defineApplicationContributions,
5
+ defineWorkflowLaunchContributions,
6
+ OpenXiangdaApplication,
7
+ type AdminNavigationInput,
8
+ type AdminPagesInput,
9
+ } from 'openxiangda/react';
10
+ import { useParams, useSearchParams } from 'react-router-dom';
11
+ import { appCode } from '../../../packages/contracts/src/generated.js';
12
+ import 'openxiangda/react/styles.css';
13
+
14
+ const workflowDefinitions = [
15
+ {
16
+ code: 'purchase-approval',
17
+ title: '采购审批',
18
+ launch: { mode: 'standalone' },
19
+ },
20
+ ] as const;
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
+
58
+ const adminPages = [
59
+ {
60
+ code: 'application:todo-center',
61
+ kind: 'application-todo-center',
62
+ path: '/admin/todos',
63
+ label: '待办中心',
64
+ navigationEligible: true,
65
+ },
66
+ {
67
+ code: 'workflow:work-center',
68
+ kind: 'workflow-work-center',
69
+ path: '/admin/work-center',
70
+ label: '我的审批',
71
+ navigationEligible: true,
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
+ },
81
+ {
82
+ code: 'workflow:task',
83
+ kind: 'workflow-task',
84
+ path: '/admin/tasks/:taskId',
85
+ label: '审批任务',
86
+ navigationEligible: false,
87
+ },
88
+ {
89
+ code: 'workflow:instance',
90
+ kind: 'workflow-instance',
91
+ path: '/admin/workflows/:instanceId',
92
+ label: '流程详情',
93
+ navigationEligible: false,
94
+ },
95
+ ] as const satisfies AdminPagesInput;
96
+
97
+ const adminNavigation = [
98
+ {
99
+ code: 'workflow-center',
100
+ label: '流程中心',
101
+ icon: 'workflow',
102
+ 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
+ ],
112
+ },
113
+ ] as const satisfies AdminNavigationInput;
114
+
115
+ const initialPath =
116
+ new URLSearchParams(window.location.search).get('initial') ||
117
+ '/admin/work-center';
118
+ if (location.pathname.endsWith('/workflow-experience.e2e.html')) {
119
+ history.replaceState({}, '', initialPath);
120
+ }
121
+
122
+ ReactDOM.createRoot(document.getElementById('root')!).render(
123
+ <React.StrictMode>
124
+ <OpenXiangdaApplication
125
+ appCode={appCode}
126
+ appName="流程体验验收"
127
+ adminNavigation={adminNavigation}
128
+ adminPages={adminPages}
129
+ contributions={applicationContributions}
130
+ resourceDefinitions={{}}
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
+ })}
142
+ />
143
+ </React.StrictMode>
144
+ );
@@ -1,14 +1,27 @@
1
1
  import { expect, test, type Page } from '@playwright/test';
2
2
  import { appCode } from '../../../packages/contracts/src/generated.js';
3
3
 
4
- const runtimeBase = `/view/${appCode}`;
5
4
  const taskId = '11111111-1111-4111-8111-111111111111';
6
5
  const instanceId = '22222222-2222-4222-8222-222222222222';
7
6
  function envelope(data: unknown, code = 200) {
8
7
  return { code, message: code === 200 ? 'success' : 'forbidden', data };
9
8
  }
10
9
 
11
- 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) {
12
25
  return {
13
26
  schemaVersion: 'openxiangda.workflow-task/v2',
14
27
  engineVersion: '2.0',
@@ -30,16 +43,18 @@ function task(completed = false) {
30
43
  dataRef: { resourceCode: 'purchase-orders', recordId: 'purchase-1' },
31
44
  createdAt: '2026-08-25T01:00:00.000Z',
32
45
  updatedAt: '2026-08-25T01:10:00.000Z',
46
+ detailNavigation: detailNavigation(customDetail),
33
47
  };
34
48
  }
35
49
 
36
- function surface(completed = false) {
50
+ function surface(completed = false, customDetail = false) {
37
51
  return {
38
52
  schemaVersion: 'openxiangda.workflow-surface/v2',
39
53
  protocolVersion: 'workflow_surface_v2',
40
54
  surfaceRevision: completed ? 'revision-2' : 'revision-1',
41
55
  engineVersion: '2.0',
42
56
  instanceSequence: completed ? 4 : 3,
57
+ detailNavigation: detailNavigation(customDetail),
43
58
  navigationTarget: {
44
59
  kind: 'resource_record',
45
60
  appCode,
@@ -73,7 +88,7 @@ function surface(completed = false) {
73
88
  startedAt: '2026-08-25T01:00:00.000Z',
74
89
  completedAt: completed ? '2026-08-25T01:12:00.000Z' : null,
75
90
  },
76
- task: completed ? null : task(false),
91
+ task: completed ? null : task(false, customDetail),
77
92
  presentation: {
78
93
  status: { label: completed ? '已同意' : '待审批', tone: 'warning' },
79
94
  layout: 'approval_detail',
@@ -118,7 +133,7 @@ function surface(completed = false) {
118
133
  },
119
134
  refresh: ['surface', 'timeline', 'work_center'],
120
135
  },
121
- ...['reject', 'return', 'transfer', 'add_assignee'].map((key) => ({
136
+ ...['reject', 'return', 'transfer', 'add_assignee'].map(key => ({
122
137
  key,
123
138
  kind: 'workflow_command',
124
139
  label: {
@@ -185,20 +200,87 @@ function timeline(completed = false) {
185
200
  };
186
201
  }
187
202
 
188
- 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
+ ) {
189
282
  let completed = false;
190
- await page.route(`**${runtimeBase}/**`, async (route) => {
191
- if (route.request().resourceType() !== 'document') return route.continue();
192
- const response = await route.fetch();
193
- const body = (await response.text()).replace(
194
- '<head>',
195
- `<head><meta name="openxiangda-runtime-base" content="${runtimeBase}/" />` +
196
- `<meta name="openxiangda-app-code" content="${appCode}" />` +
197
- '<meta name="openxiangda-environment" content="preproduction" />',
198
- );
199
- await route.fulfill({ response, body });
200
- });
201
- await page.route('**/service/**', async (route) => {
283
+ await page.route('**/service/**', async route => {
202
284
  const url = new URL(route.request().url());
203
285
  const path = url.pathname;
204
286
  if (path.endsWith('/native/authz/current')) {
@@ -241,7 +323,7 @@ async function mockWorkflow(page: Page, options: { forbidden?: boolean } = {}) {
241
323
  identityScope:
242
324
  'user-union:preproduction-id:acceptance-user:authz-1',
243
325
  },
244
- }),
326
+ })
245
327
  ),
246
328
  });
247
329
  }
@@ -258,6 +340,71 @@ async function mockWorkflow(page: Page, options: { forbidden?: boolean } = {}) {
258
340
  body: JSON.stringify(envelope(null, 403)),
259
341
  });
260
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
+ }
261
408
  if (path.endsWith('/workflow/work-center/items')) {
262
409
  return route.fulfill({
263
410
  contentType: 'application/json',
@@ -270,15 +417,25 @@ async function mockWorkflow(page: Page, options: { forbidden?: boolean } = {}) {
270
417
  total: 1,
271
418
  limit: 20,
272
419
  offset: 0,
273
- items: [task(completed)],
274
- }),
420
+ items: [task(completed, options.customDetail)],
421
+ })
275
422
  ),
276
423
  });
277
424
  }
278
425
  if (path.endsWith(`/workflow/tasks/${taskId}/surface`)) {
279
426
  return route.fulfill({
280
427
  contentType: 'application/json',
281
- 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
+ ),
282
439
  });
283
440
  }
284
441
  if (path.endsWith(`/workflow/instances/${instanceId}/timeline`)) {
@@ -306,18 +463,93 @@ test('renders a paged desktop work center from the current-user role union', asy
306
463
  page,
307
464
  }) => {
308
465
  await mockWorkflow(page);
309
- await page.goto(`${runtimeBase}/admin/work-center`);
466
+ await page.goto('/workflow-experience.e2e.html?initial=/admin/work-center');
310
467
  await expect(page.getByRole('heading', { name: '审批工作台' })).toBeVisible();
311
468
  await expect(page.getByRole('link', { name: '采购申请审批' })).toBeVisible();
312
469
  await expect(page.getByText('PO-2026-0825')).toBeVisible();
313
470
  });
314
471
 
472
+ test('renders the generic desktop and mobile application todo center without a second router', async ({
473
+ page,
474
+ }) => {
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 });
494
+ await page.setViewportSize({ width: 390, height: 844 });
495
+ await page.goto(
496
+ '/workflow-experience.e2e.html?initial=/m/workflows/purchase-approval/start'
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
+
315
547
  test('renders the independent mobile task and executes only a Surface operation', async ({
316
548
  page,
317
549
  }) => {
318
550
  await mockWorkflow(page);
319
551
  await page.setViewportSize({ width: 390, height: 844 });
320
- await page.goto(`${runtimeBase}/m/tasks/${taskId}`);
552
+ await page.goto(`/workflow-experience.e2e.html?initial=/m/tasks/${taskId}`);
321
553
  await expect(page.locator('.oxa-workflow-mobile-page')).toBeVisible();
322
554
  await expect(page.getByText('¥28,600')).toBeVisible();
323
555
  await expect(page.getByText('财务负责人').first()).toBeVisible();
@@ -334,7 +566,7 @@ test('shows an explicit authorization error instead of an empty work center', as
334
566
  }) => {
335
567
  test.setTimeout(45_000);
336
568
  await mockWorkflow(page, { forbidden: true });
337
- await page.goto(`${runtimeBase}/admin/work-center`, {
569
+ await page.goto('/workflow-experience.e2e.html?initial=/admin/work-center', {
338
570
  waitUntil: 'domcontentloaded',
339
571
  });
340
572
  await expect(page.getByRole('alert')).toContainText('HTTP_403: forbidden', {
@@ -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.30",
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,8 @@
1
1
  import React from 'react';
2
2
  import ReactDOM from 'react-dom/client';
3
3
  import {
4
- defineAdminContributions,
4
+ defineApplicationContributions,
5
+ defineWorkflowLaunchContributions,
5
6
  OpenXiangdaApplication,
6
7
  } from 'openxiangda/react';
7
8
  import {
@@ -9,24 +10,32 @@ import {
9
10
  appName,
10
11
  appPerspectives,
11
12
  appRoutes,
13
+ adminNavigation,
14
+ adminPages,
12
15
  resourceDefinitions,
13
- workflowCodes,
16
+ workflowDefinitions,
14
17
  } from '../../../packages/contracts/src/generated.js';
15
18
  import 'openxiangda/react/styles.css';
16
19
 
17
- const adminContributions = defineAdminContributions(appRoutes, {
20
+ const applicationContributions = defineApplicationContributions(appRoutes, {
18
21
  pages: {},
19
22
  });
23
+ const workflowContributions = defineWorkflowLaunchContributions(
24
+ workflowDefinitions,
25
+ {},
26
+ );
20
27
 
21
28
  ReactDOM.createRoot(document.getElementById('root')!).render(
22
29
  <React.StrictMode>
23
30
  <OpenXiangdaApplication
24
31
  appCode={appCode}
25
32
  appName={appName}
26
- contributions={adminContributions}
33
+ adminNavigation={adminNavigation}
34
+ adminPages={adminPages}
35
+ contributions={applicationContributions}
27
36
  perspectives={appPerspectives}
28
37
  resourceDefinitions={resourceDefinitions}
29
- workflows={workflowCodes}
38
+ workflows={workflowContributions}
30
39
  />
31
40
  </React.StrictMode>,
32
41
  );
@@ -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 = [
@@ -96,6 +96,28 @@ test('published E2E harnesses do not write evidence into the platform source rep
96
96
  assert.doesNotMatch(workflowSource, /docs\/architecture|evidenceDirectory/);
97
97
  });
98
98
 
99
+ test('E2E experience fixtures explicitly own every tested admin route', () => {
100
+ const resourceFixture = readFileSync(
101
+ new URL('../e2e/resource-experience-fixture.tsx', import.meta.url),
102
+ 'utf8'
103
+ );
104
+ for (let index = 1; index <= 12; index += 1) {
105
+ const code = `resource-${String(index).padStart(2, '0')}`;
106
+ const pageCode = `resource:${code}:list`;
107
+ assert.match(resourceFixture, new RegExp(`pageCode: '${pageCode}'`));
108
+ assert.match(resourceFixture, new RegExp(`code: '${pageCode}'`));
109
+ }
110
+
111
+ const workflowFixture = readFileSync(
112
+ new URL('../e2e/workflow-experience-fixture.tsx', import.meta.url),
113
+ 'utf8'
114
+ );
115
+ assert.match(workflowFixture, /code: 'workflow:work-center'/);
116
+ assert.match(workflowFixture, /code: 'workflow:task'/);
117
+ assert.match(workflowFixture, /code: 'workflow:instance'/);
118
+ assert.match(workflowFixture, /pageCode: 'workflow:work-center'/);
119
+ });
120
+
99
121
  test('browser acceptance serializes the prewarmed Vite HTML entrypoints', () => {
100
122
  const viteConfig = readFileSync(new URL('../vite.config.ts', import.meta.url), 'utf8');
101
123
  const playwrightConfig = readFileSync(
@@ -109,6 +131,7 @@ test('browser acceptance serializes the prewarmed Vite HTML entrypoints', () =>
109
131
  'data-api-live.e2e.html',
110
132
  'field-protocol.e2e.html',
111
133
  'resource-experience.e2e.html',
134
+ 'workflow-experience.e2e.html',
112
135
  ]);
113
136
  for (const entry of htmlEntries) {
114
137
  assert.ok(viteConfig.includes(`'${entry}'`), `missing optimizeDeps entry ${entry}`);
@@ -14,6 +14,7 @@ export default defineConfig({
14
14
  'data-api-live.e2e.html',
15
15
  'field-protocol.e2e.html',
16
16
  'resource-experience.e2e.html',
17
+ 'workflow-experience.e2e.html',
17
18
  ],
18
19
  },
19
20
  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 experience acceptance</title>
10
+ </head>
11
+ <body>
12
+ <div id="root"></div>
13
+ <script type="module" src="/e2e/workflow-experience-fixture.tsx"></script>
14
+ </body>
15
+ </html>
@@ -1,10 +1,14 @@
1
- import { defineOpenXiangdaApp } from 'openxiangda/config';
1
+ import { defineAdminNavigation, defineOpenXiangdaApp } from 'openxiangda/config';
2
2
 
3
3
  /** Clean application scaffold: declare resources and fields, then generate CRUD. */
4
4
  export default defineOpenXiangdaApp({
5
5
  schemaVersion: 3,
6
6
  app: { code: 'openxiangda-application', name: 'OpenXiangda 应用' },
7
- frontend: { root: 'apps/web', routes: [] },
7
+ frontend: {
8
+ root: 'apps/web',
9
+ routes: [],
10
+ admin: { navigation: defineAdminNavigation([]) },
11
+ },
8
12
  backend: {
9
13
  root: 'apps/server',
10
14
  runtime: 'node',
@@ -22,7 +22,7 @@
22
22
  "build": "pnpm --recursive build"
23
23
  },
24
24
  "devDependencies": {
25
- "openxiangda": "2.0.0-alpha.30",
25
+ "openxiangda": "2.0.0-alpha.33",
26
26
  "typescript": "5.9.3"
27
27
  }
28
28
  }
@@ -17,6 +17,8 @@ export const capabilities = [
17
17
  ] as const;
18
18
  export const appOperations = {} as const;
19
19
  export const appRoutes = {} as const;
20
+ export const adminPages = [] as const;
21
+ export const adminNavigation = [] as const;
20
22
  export const eventTypes = [] as const;
21
23
  export const eventSubscriptionCodes = [] as const;
22
24
  export const eventHandlerManifest = {
@@ -27,6 +29,7 @@ export const eventHandlerManifest = {
27
29
  export const eventHandlers = {} as const;
28
30
  export const eventSchemas = [] as const;
29
31
  export const workflowCodes = [] as const;
32
+ export const workflowDefinitions = [] as const;
30
33
  export const notificationTemplateCodes = {
31
34
  applicationInformational: "application.informational.standard",
32
35
  } as const;
@@ -35,6 +38,8 @@ export type ResourceCode = (typeof resourceCodes)[number];
35
38
  export type Capability = (typeof capabilities)[number];
36
39
  export type AppOperation = (typeof appOperations)[keyof typeof appOperations];
37
40
  export type AppRoute = (typeof appRoutes)[keyof typeof appRoutes];
41
+ export type AdminPage = (typeof adminPages)[number];
42
+ export type AdminNavigationGroup = (typeof adminNavigation)[number];
38
43
  export type AppPerspective = (typeof appPerspectives)[number];
39
44
  export type EventType = (typeof eventTypes)[number];
40
45
  export type EventSubscriptionCode = (typeof eventSubscriptionCodes)[number];