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 +5 -2
- package/package.json +5 -5
- package/template/AGENTS.md +4 -4
- package/template/README.md +8 -0
- package/template/apps/server/package.json +1 -1
- package/template/apps/web/e2e/data-api-live-fixture.tsx +8 -1
- package/template/apps/web/e2e/resource-experience-fixture.tsx +181 -2
- package/template/apps/web/e2e/resources.spec.ts +5 -3
- package/template/apps/web/e2e/workflow-experience-fixture.tsx +144 -0
- package/template/apps/web/e2e/workflow.spec.ts +257 -25
- package/template/apps/web/package.json +1 -1
- package/template/apps/web/src/main.tsx +14 -5
- package/template/apps/web/test/contracts.test.ts +25 -2
- package/template/apps/web/vite.config.ts +1 -0
- package/template/apps/web/workflow-experience.e2e.html +15 -0
- package/template/openxiangda.config.ts +6 -2
- package/template/package.json +1 -1
- package/template/packages/contracts/src/generated.ts +5 -0
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
|
|
11
|
-
|
|
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.
|
|
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.
|
|
25
|
-
"openxiangda-devkit-core": "2.0.0-alpha.
|
|
26
|
-
"openxiangda-mcp": "2.0.0-alpha.
|
|
27
|
-
"openxiangda-skill-kit": "2.0.0-alpha.
|
|
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",
|
package/template/AGENTS.md
CHANGED
|
@@ -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
|
-
-
|
|
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`;
|
|
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.
|
package/template/README.md
CHANGED
|
@@ -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
|
|
@@ -35,7 +35,14 @@ export const liveDefinition: GeneratedResourceDefinition = {
|
|
|
35
35
|
name: '字段协议记录',
|
|
36
36
|
capabilities,
|
|
37
37
|
surface: {
|
|
38
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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', {
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import ReactDOM from 'react-dom/client';
|
|
3
3
|
import {
|
|
4
|
-
|
|
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
|
-
|
|
16
|
+
workflowDefinitions,
|
|
14
17
|
} from '../../../packages/contracts/src/generated.js';
|
|
15
18
|
import 'openxiangda/react/styles.css';
|
|
16
19
|
|
|
17
|
-
const
|
|
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
|
-
|
|
33
|
+
adminNavigation={adminNavigation}
|
|
34
|
+
adminPages={adminPages}
|
|
35
|
+
contributions={applicationContributions}
|
|
27
36
|
perspectives={appPerspectives}
|
|
28
37
|
resourceDefinitions={resourceDefinitions}
|
|
29
|
-
workflows={
|
|
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, /
|
|
32
|
-
assert.match(source, /contributions=\{
|
|
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}`);
|
|
@@ -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: {
|
|
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',
|
package/template/package.json
CHANGED
|
@@ -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];
|