openxiangda-cli 2.0.0-alpha.169 → 2.0.0-alpha.170

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.
Files changed (36) hide show
  1. package/package.json +5 -5
  2. package/template/AGENTS.md +38 -24
  3. package/template/README.md +14 -17
  4. package/template/apps/web/e2e/component-defaults-fixture.css +15 -0
  5. package/template/apps/web/e2e/data-api-live-fixture.tsx +3 -3
  6. package/template/apps/web/e2e/field-protocol-fixture.tsx +48 -11
  7. package/template/apps/web/e2e/field-protocol.spec.ts +90 -0
  8. package/template/apps/web/e2e/mobile-files.spec.ts +93 -0
  9. package/template/apps/web/e2e/mobile-reference-fixture.tsx +314 -0
  10. package/template/apps/web/e2e/mobile-reference.spec.ts +360 -0
  11. package/template/apps/web/e2e/mobile-selection.spec.ts +199 -0
  12. package/template/apps/web/e2e/resource-experience-fixture.tsx +54 -9
  13. package/template/apps/web/e2e/resource-platform-mock.ts +202 -0
  14. package/template/apps/web/e2e/resources.spec.ts +77 -240
  15. package/template/apps/web/e2e/standard-admin.spec.ts +441 -0
  16. package/template/apps/web/e2e/workflow.spec.ts +2 -2
  17. package/template/apps/web/field-protocol.e2e.html +7 -0
  18. package/template/apps/web/mobile-reference.e2e.html +27 -0
  19. package/template/apps/web/package.json +1 -1
  20. package/template/apps/web/resource-experience.e2e.html +1 -0
  21. package/template/apps/web/src/document.css +5 -0
  22. package/template/apps/web/src/main.tsx +1 -0
  23. package/template/apps/web/src/user-end/tailwind.css +1 -13
  24. package/template/apps/web/test/contracts.test.ts +7 -6
  25. package/template/apps/web/vite.config.ts +1 -0
  26. package/template/appspec/app.md +27 -1
  27. package/template/openxiangda.config.ts +2 -24
  28. package/template/package.json +3 -4
  29. package/template/apps/server/Dockerfile +0 -20
  30. package/template/apps/server/package.json +0 -27
  31. package/template/apps/server/src/app.module.ts +0 -23
  32. package/template/apps/server/src/main.ts +0 -5
  33. package/template/apps/server/test/smoke.test.ts +0 -17
  34. package/template/apps/server/tsconfig.json +0 -9
  35. package/template/apps/web/e2e/theme-contract-fixture.css +0 -15
  36. package/template/scripts/verify-template-budget.mjs +0 -49
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openxiangda-cli",
3
- "version": "2.0.0-alpha.169",
3
+ "version": "2.0.0-alpha.170",
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.84",
25
- "openxiangda-devkit-core": "2.0.0-alpha.107",
26
- "openxiangda-mcp": "2.0.0-alpha.107",
27
- "openxiangda-skill-kit": "2.0.0-alpha.132"
24
+ "openxiangda-contracts": "2.0.0-alpha.85",
25
+ "openxiangda-devkit-core": "2.0.0-alpha.108",
26
+ "openxiangda-mcp": "2.0.0-alpha.108",
27
+ "openxiangda-skill-kit": "2.0.0-alpha.133"
28
28
  },
29
29
  "devDependencies": {
30
30
  "tsx": "4.23.12",
@@ -1,35 +1,20 @@
1
1
  # OpenXiangda 2.0 Application Agent Contract
2
2
 
3
3
  - Use only the workspace-pinned CLI: `pnpm openxiangda <command>`. Never invoke a bare global `openxiangda` inside a 2.0 task.
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
- - Use Vite, React Router, Refine Core and Ant Design. Do not add Umi, ProComponents or another admin shell.
6
- - The platform `openxiangda/react` owns the only admin `ConfigProvider` and the
7
- `light`/`dark`/`system` appearance preference. Admin pages use Ant Design's
8
- `defaultAlgorithm`/`darkAlgorithm`, `theme.useToken()`, or generated
9
- `--oxa-antd-*` CSS variables. Do not add a nested theme provider, theme
10
- store, palette editor, `--oxa-color-*`/`--oxa-shell-*` aliases, or fixed
11
- white/black/neutral structural colors. Future palettes are centralized
12
- platform seed configurations; alpha contracts are replaced directly without
13
- compatibility aliases. User-end pages are outside this admin visual contract
14
- and may use Tailwind CSS and their own product design tokens/components, but
15
- must not mutate the admin provider or Seed.
16
- - Use the complete Ant Design Seed/Map/Alias/Component model on admin pages:
17
- platform-owned Seed values feed the selected algorithm; admin code consumes
18
- generated Alias values for colors, spacing, typography, control dimensions,
19
- motion, shadows, z-index and breakpoints. Never hand-write Map values or
20
- introduce an admin token registry. Component tokens are only for
21
- platform-approved non-color dimensions and typography.
22
- - The template includes Tailwind CSS v4 for user-end pages. Keep its `@theme`
23
- and utility classes in the user-end layer, omit Tailwind preflight from the
24
- admin shell, and do not make user-end utilities depend on `--oxa-antd-*`.
4
+ - Maintain business goals, task pages, permission decisions and acceptance examples in the existing `appspec/app.md`; complex modules may have focused capability records. `appspec/` is the 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
+ - Use Vite, React Router, Refine Core and platform Field Kit. Use Ant Design for PC and the scoped openxiangda/mobile adapter for touch controls; wrap custom mobile pages in MobileSurface and import openxiangda/mobile/styles.css. Do not import the antd-mobile root with its global reset. Do not author raw business input/select/textarea/contentEditable elements. Keep mobile entrypoints under src/mobile/ or name them Mobile*.tsx / *.mobile.tsx so openxiangda check can inspect their local dependency graph. Do not add Umi, ProComponents or another admin shell.
6
+ - Use Ant Design's default component appearance on PC and the platform's scoped mobile adapter for touch controls. The UI provider supplies Chinese locale and contextual feedback only. Do not generate appearance preferences, color configuration, palette registries, visual state stores or document-wide color effects. Prefer platform components and keep CSS scoped to the component.
7
+ - The template includes Tailwind CSS v4 utilities for user pages. The application owns its minimal document reset; omit preflight from the shared runtime and do not scaffold custom palettes.
25
8
  - 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. Generated resource CRUD routes are compiler-owned under `/admin/resources/<resourceCode>...` and `/m/admin/resources/<resourceCode>...`; never recreate root resource paths, aliases or redirects. Explicit routes that have the same canonical shape as another explicit or generated route fail compilation, even when dynamic parameter names differ. Use only the 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.
26
9
  - Application login is optional `frontend.authentication`: existing platform users only, registration rejected, exact desktop `/login` and mobile `/m/login`. Bind generated `authenticationSurfaces` to separate PC/mobile renderers through `defineApplicationContributions`; renderers own only brand visuals and call `ApplicationLoginSurfaceProps`. The platform alone owns passwords, providers, OAuth state/callbacks, secure cookies, current identity and authorization. Never put login in protected `appRoutes`, call a v1 auth route, store tokens, create users/roles, or add another Router/identity provider. Keep QA in generated `platformAuthManifest`, outside the protected route denominator.
27
10
  - Branded standard user pages use the optional exact `standardUserSurfaces` contribution from `openxiangda/react`: provide separate desktop/mobile `frame` and `applicationTodoCenter` renderers or omit the property entirely. The platform still owns `/todos`, `/m/todos`, Workflow routes, `RuntimeBoundary`, current-user Notification Hub data, query/load/interaction callbacks and navigation. Never add a second Router, Todo API client, identity store or token prop.
28
11
  - External users without platform accounts use only an exact static `surface: 'user'` route declared through `frontend.publicAccess`. Declare the one resource, bounded field sets, required operations, draft limits and named duplicate validations; consume only generated `anonymousPublicAccess` and `createAnonymousPublicClient`. `own.list`/`own.read` mean records submitted by the same platform-issued HttpOnly browser credential, not a verified natural person, and another browser or cleared cookie intentionally loses access. Never create a guest role/user, call the general Native Data API, expose an anonymous upload path, store identity locally or derive ownership from IP, user-agent or fingerprint.
29
- - Declare each resource once in `openxiangda.config.ts` with only `code`, `name`, `fields` and optional `mutationOwner`, generated/list/layout/data-policy settings. A Workflow subject resource may additionally declare `detailRouteCode: { desktop, mobile }`; both values reference explicit authenticated user routes that require the resource read capability, use the correct `/m` family and contain exactly one dynamic record parameter. This is the only business-record navigation source: never infer `/<resourceCode>/<recordId>` or add an alias. 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.
12
+ - Define storage with `defineDataModel` in `modules/<business>/models.ts`; compose modules with `defineApplicationModule`. Select standard pages explicitly through `crud`, `defineResourceList` and `defineResourceForm`. Models without `crud` receive no pages or navigation. A business task page may consume several models. The root `openxiangda.config.ts` composes modules, routes and roles; it must not grow into one giant business definition. Existing `data.resources` remains a lower-level input to the same compiler. Keep internal fields `hidden: true`; this is presentation, never authorization. `system: true` marks server-owned values and hides them by default; `hidden: false` may show a business status or serial number. Form/detail/list field selections are exhaustive and ordered. A Workflow subject resource may declare explicit desktop/mobile `detailRouteCode` as documented in the workflow contract.
30
13
  - 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`.
31
14
  - 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.
32
- - Add NestJS only for a named business action that needs a cross-resource transaction, an invariant or an external side effect.
15
+ - Standard PC lists use one toolbar, on-demand filters/column settings and drawer create/edit over the retained list. Full-page entry uses the same platform form. Declare shared form/detail groups with `crud[].sections: [{ title, fields }]`; each view's field selection remains exhaustive and ordered. Small forms may omit groups. Keep only business columns by default, use lightweight sections and let platform components handle pending, errors and dirty cancellation. Do not recreate list/form layouts in application code.
16
+ - Add NestJS only for a named business action that needs a cross-resource transaction, an invariant or an external side effect. The blank app contains no apps/server source or Nest dependency importer. Declare backend.enabled or a server operation, then run pnpm openxiangda check or pnpm dev to initialize and install the optional backend once; existing source is never regenerated. Standard platform workflow definitions and activations do not require Nest.
17
+ - One model can declare up to 20 named standard views with `crud[].code` and `name`. Each selects its own list/form/detail fields, sections, generated operations and mobile availability. Bind navigation with `adminResourcePage(modelCode, { viewCode })`; only an unnamed CRUD declaration generates the original default routes. Views share model values, access and Data API ownership. Include writable required fields in create forms or set `generated.create: false`. Keep platform display preferences and authenticated drafts scoped to the selected view; do not copy models or invent a second draft store.
33
18
  - Every interactive business action binds `@OpenXiangdaOperation(operation)` and injects `OpenXiangdaBusinessDataApiService`, `OpenXiangdaBusinessNotificationService`, or `OpenXiangdaStandardOperations`. The platform checks the action capability once at App API ingress; the trusted backend then has full Data/managed business-notification access only to its exact application/environment while audit retains the initiating user and action. Do not author `authorizationJSON`, forward user tokens, grant ordinary users `app:notification2:send`, or reapply the user's resource, row and field permissions inside the action.
34
19
  - A NestJS backend declares only `enabled`, `isolation: 'shared' | 'dedicated'` and `resourceProfile: 'light' | 'standard'`. Never put raw Kubernetes resources, replicas, ports or environment maps in application metadata; the platform owns capacity and scaling.
35
20
  - Use the current logged-in user and the union of that user's application roles. An app for every logged-in platform user may declare one existing package role through `authz.authenticatedUserRoleCode`; the platform materializes it on first access and unions it with manual and business-projected roles. Do not fabricate a browser fallback role or use a business membership resource for this universal audience. An optional declared Perspective projects only read visibility (pages, rows and fields); create/update/delete, workflows and custom actions continue to authorize against the complete union. Standard Data API calls inherit `X-OpenXiangda-Perspective` automatically. Use `@CurrentPerspective()` only when custom Nest code reads outside the standard Data API, and apply an equivalent read projection explicitly. Business code must not persist a platform Token or authorization result and must not implement a second identity path.
@@ -41,8 +26,37 @@
41
26
  - Visitor duplicate protection uses `createVisitorReservation({ duplicateMatch: { fieldCode: submittedValue }, ... })`. `duplicateMatch` is a non-empty value map, never a field-name array, and no mutable pre-read is allowed.
42
27
  - Desktop and mobile pages share values, validation and authorization, but use separate renderers. Options, members and departments store display snapshots; resource references store direct JSON display values; attachments, images and signatures use platform-managed file references.
43
28
  - `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`.
44
- - Derive role grants with `resourceCapabilityCodes(appCode, resourceCode)`. Grant a Native resource's `read` capability to every role allowed to enter its generated page; the compiler adds enabled generated create/update/delete capabilities by default. Tighten only exceptional roles with `deniedCapabilities`, which is compile-time authoring input and is removed from the sealed role. Non-Native mutation owners never receive this expansion. Declare current-user rows only with `currentUserDataPolicy(...)`; do not invent operators, values or alternate current-user spellings.
29
+ - Use `resourceRoleCapabilities(appCode, resourceCode, "read" | "manage" | [operations])` for explicit role grants. A read grant never expands into create/update/delete when a page is added. Confirm the business permission matrix before implementing multi-role behavior; `manage` is only for roles explicitly allowed all CRUD operations. Use `currentUserDataPolicy(...)` for current-user row scope.
45
30
  - 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.
46
31
  - Standard Workflow and Notification Hub are optional 2.0 modules. Enable them only through canonical `openxiangda.config.ts` declarations and generated clients. `standalone`/`hidden-handoff` default to the compiler-owned process operation; action-owned submission must instead declare `launch.submission.kind: 'named-operation'` with sealed create/existing input and output bindings so the standard PC/mobile page calls the original Named Action and accepts an explicit no-Workflow result. Never add a browser save callback or call Workflow prepare/start directly. Custom pages launch only through a verified Named Action using `OpenXiangdaBusinessProcessService`. PC and mobile render the same `ProcessCommandSurface` independently and recover only by `commandId`. To replace Workflow detail, declare both desktop and mobile `detailRouteCode` values whose routes contain exactly `:instanceId`. Add the current-user todo page only with `frontend.user.applicationTodoCenter: true`; never query Notification Hub management APIs from a user page.
47
32
  - 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.
48
33
  - 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.
34
+
35
+ - For an ordinary CRUD module, omit unused backend/platform blocks; the platform owns CRUD. `pnpm dev` uses connected development and starts Nest only when enabled or required by declared server capabilities. Run interface tests for changed business rules and browser tests for the actual PC/mobile create/edit/save/read journeys; passing HTTP tests alone is not acceptance.
36
+
37
+
38
+ ## Standard data entry and list controls
39
+
40
+ Use the platform standard list for nested AND/OR conditions, display-column
41
+ selection/order/freezing and multiple sorting. Set `requiresSelection: true` on
42
+ selection-dependent toolbar contributions. Preserve hidden fields and query /
43
+ export parity. Column changes preview immediately and save to current-user
44
+ platform preferences explicitly.
45
+
46
+ Standard forms use Save draft / Submit. Consume platform authenticated drafts;
47
+ do not create application draft tables, localStorage or Nest draft endpoints.
48
+ PC drawer controls are Full screen / New page / Close, with a platform draft
49
+ handoff to the new page. Mobile entry uses grouped touch-field rows, a simple
50
+ title and a fixed action footer, with bottom sheets for drafts and recovery.
51
+ Do not add a mobile Back to list action or desktop form controls. Preserve edit
52
+ revisions and draft CAS; successful submission consumes the draft atomically.
53
+
54
+ ## Optional application backend
55
+
56
+ The default scaffold contains Web and shared contracts only. Declare
57
+ `backend: { enabled: true }` or application server capabilities, then use the
58
+ existing `pnpm openxiangda check` / `pnpm dev` loop. The tool initializes Nest
59
+ source and its exact dependencies once, preserves authored source, and retries
60
+ an interrupted dependency install. Standard platform workflow activation does
61
+ not imply application Nest. See `docs/backend.md` and the runnable
62
+ `examples/business-action-extension` for SDK ownership and transaction retries.
@@ -1,6 +1,6 @@
1
1
  # OpenXiangda 2.0 应用模板
2
2
 
3
- 这是干净的资源 CRUD 模板:Vite + React Router + Refine Core + Ant Design 前端、NestJS 平台启动层,以及平台 Data/AuthZ 声明。
3
+ 这是从业务任务开始的应用模板:Vite + React Router + Refine Core,优先消费平台 Field Kit。PC 使用 Ant Design,移动录入使用 Ant Design Mobile。标准 CRUD 直接调用平台 Data API,默认没有 Nest 后端源码和依赖。声明服务端执行能力后,`pnpm openxiangda check` 或 `pnpm dev` 才会初始化 `apps/server` 并安装依赖。
4
4
 
5
5
  ```bash
6
6
  pnpm openxiangda dev
@@ -8,13 +8,13 @@ pnpm openxiangda dev
8
8
 
9
9
  当前应用只直接依赖一个精确版本的 `openxiangda` 根包,并在 lockfile 中锁定其内部实现包。创建另一个应用时使用当前安装的 OpenXiangda 2.0 Skill 给出的精确根包版本,不使用 `latest`、`alpha` 或全局命令。
10
10
 
11
- `appspec/app.md` 是可选的业务意图总纲,帮助人和 AI 理解当前需求,但不是发布门禁。没有行为变化
11
+ `appspec/app.md` 是默认保留的业务意图总纲,帮助人和 AI 理解当前需求,但不是发布门禁。没有行为变化
12
12
  时不写变更;小改动最多维护一份短 ChangeSpec。按需使用
13
13
  `pnpm openxiangda spec context --json`、`spec new` 和 `spec close`,不要引入 1.x `openspec/SDD`。
14
14
 
15
- 在 `openxiangda.config.ts` `data.resources` 中只声明资源 `code`、`name`、`fields` 和可选列表/布局/数据策略。每个字段一次写完语义 `type`、`label`、`required`、列表/筛选标记、`options`/`source`/`file` `access`;不要再写 `schema`、`surface`、PostgreSQL 类型、资源 capabilities、资源 fieldPolicies 或 `platform/data` 模块。编译器据此生成标准后台列表、筛选、表单、详情、审计、文件上传、独立移动端页面、Data API 查询计划和 AI Schema。
15
+ 按业务模块拆分文件:`modules/<业务>/models.ts` 定义 `defineDataModel`,`views.ts` 定义 `defineResourceList` / `defineResourceForm`,模块入口用 `defineApplicationModule` 组合。只有显式放入模块 `crud` 的模型才生成标准页;辅助模型可以完全没有页面,业务任务页也可以消费多个模型。根 `openxiangda.config.ts` 只组合模块、页面和权限。列表、表单、详情可分别选择字段,未选择字段不会自动补回。`hidden: true` 隐藏内部字段,但不改变其服务端权限。
16
16
 
17
- 成员、部门分别声明 `user.single`/`user.multiple` 和 `department.single`/`department.multiple`;同应用动态资源声明 `resource-ref.single`/`resource-ref.multiple` 及编译期固定的 `source`。这些字段都保存完整 `{label,value,...}` 显示快照,资源引用还保存 `resourceCode` 和声明的 `snapshot` 字段;查询和权限以稳定 `value` 为比较键。资源来源的 `labelField` 只能引用 `text.short` 或 `text.long`;流水号可用于搜索、描述和快照,不能作为显示标签。需要目录选择器的业务角色必须显式拥有 `app:<app-code>:directory:read` 能力。用 `resourceCapabilityCodes` 生成资源 CRUD 权限,用 `currentUserDataPolicy` 声明唯一的当前用户行规则。
17
+ 成员、部门分别声明 `user.single`/`user.multiple` 和 `department.single`/`department.multiple`;同应用动态资源声明 `resource-ref.single`/`resource-ref.multiple` 及编译期固定的 `source`。这些字段都保存完整 `{label,value,...}` 显示快照,资源引用还保存 `resourceCode` 和声明的 `snapshot` 字段;查询和权限以稳定 `value` 为比较键。资源来源的 `labelField` 只能引用 `text.short` 或 `text.long`;流水号可用于搜索、描述和快照,不能作为显示标签。需要目录选择器的业务角色必须显式拥有 `app:<app-code>:directory:read` 能力。用 `resourceRoleCapabilities(appCode, resourceCode, "read" | "manage" | [operations])` 显式选择角色权限;只读角色不会因生成页面而自动获得写权限,用 `currentUserDataPolicy` 声明唯一的当前用户行规则。
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
 
@@ -26,19 +26,12 @@ BrowserRouter、RuntimeBoundary、Refine、generated routes 和后台 Shell
26
26
  声明 `frontend.user.applicationTodoCenter: true`;平台生成 `/todos` 与 `/m/todos`,
27
27
  只读取当前用户的消息/待办投影并统一解析目标页面。
28
28
 
29
- 自定义后台页使用 `OpenXiangdaAdminPage` 作为页面根;Ant Design 组件自动继承平台
30
- `ConfigProvider`,图表等 TypeScript 渲染值读取 `theme.useToken()`,自定义 CSS
31
- 只使用平台生成的 `--oxa-antd-*` CSS 变量。不要为后台页新建主题 Provider、主题状态
32
- 或颜色别名,也不要把白色、黑色、浅灰色写死为页面、卡片、边框或文字颜色。品牌色只能
33
- 用于品牌和有明确语义的数据分类,不能替代结构色。主题只由平台的 Ant Design
34
- `defaultAlgorithm`/`darkAlgorithm` 生成;Seed 由平台集中维护,Map/Alias、间距、排版、
35
- 动效、阴影、层级、断点和组件尺寸均从算法令牌读取。未来配色扩展只能修改平台集中维护
36
- 的 seed,不得在应用内新建 token registry 或任意颜色输入。
29
+ 自定义后台页使用 `OpenXiangdaAdminPage` 作为页面根,优先使用平台组件与 Ant Design
30
+ 默认组件。平台只统一中文语言和消息上下文,不提供外观偏好或配色配置。组件样式应限定
31
+ 在自身范围,文档级 margin/height 重置由 `apps/web/src/document.css` 负责。
37
32
 
38
- 用户端页面不属于这份后台主题合同,可以使用 Tailwind CSS 和自己的产品组件/设计令牌;
39
- 用户端不应依赖 `--oxa-antd-*`,也不能修改后台 Provider 或平台 Seed
40
- 模板已预装 Tailwind CSS v4 与 Vite 插件;在用户端页面直接使用 utility class,
41
- 并在 `apps/web/src/user-end/tailwind.css` 的 `@theme` 中集中维护用户端产品令牌。
33
+ 模板提供 Tailwind CSS v4 默认工具类,供用户端业务页面编排布局;不引入全局 preflight,
34
+ 不生成额外色板。移动业务输入使用 `openxiangda/mobile` 和平台 Field Kit
42
35
 
43
36
  登录声明位于 `frontend.authentication`,只允许现有平台用户并拒绝注册。桌面 `/login`
44
37
  与移动 `/m/login` 是独立生成的 `authenticationSurfaces`,不计入受保护的
@@ -70,4 +63,8 @@ pnpm openxiangda status
70
63
  pnpm openxiangda logs
71
64
  ```
72
65
 
73
- `deploy` 使用本模板的 `apps/server/Dockerfile` 自动构建并推送 `linux/amd64` 镜像,再把不可变 digest 写入 AppPackage。纯 CRUD 应用可以保持 backend disabled;复杂事务再启用 Nest。
66
+ 启用后端时,`deploy` 使用按需生成的 `apps/server/Dockerfile` 自动构建并推送 `linux/amd64` 镜像,再把不可变 digest 写入 AppPackage。纯 CRUD 应用可以保持 backend disabled;复杂事务再启用 Nest。
67
+
68
+ 业务录入优先使用 `openxiangda/field-kit`,其余 PC 控件使用 `antd`,移动控件使用 `antd-mobile`。应用源码不能直接写原生 input/select/textarea/contentEditable。移动入口放在 `src/mobile/`,或使用 `Mobile*.tsx` / `*.mobile.tsx`,`openxiangda check` 会连同本地共享依赖一起检查。该检查是开发反馈,不添加运行时安全校验。
69
+
70
+ 先在 `appspec/app.md` 记录任务页面和角色矩阵,再生成界面。测试只为业务规则和关键风险建立单元/接口用例;实际 PC、移动端的选择、校验、提交、回显、无权操作由浏览器验收,不能用编译通过或接口 200 替代。
@@ -0,0 +1,15 @@
1
+ .component-defaults-page {
2
+ display: grid;
3
+ align-content: start;
4
+ gap: 16px;
5
+ padding: 24px;
6
+ }
7
+
8
+ .component-defaults-panel {
9
+ border: 1px solid var(--ant-color-border-secondary);
10
+ border-radius: var(--ant-border-radius);
11
+ background: var(--ant-color-bg-container);
12
+ padding: 24px;
13
+ color: var(--ant-color-text);
14
+ box-shadow: var(--ant-box-shadow-tertiary);
15
+ }
@@ -4,7 +4,7 @@ import React from 'react';
4
4
  import ReactDOM from 'react-dom/client';
5
5
  import { MemoryRouter, Route, Routes } from 'react-router-dom';
6
6
  import {
7
- AppearanceProvider,
7
+ OpenXiangdaUiProvider,
8
8
  createApplicationProvider,
9
9
  GeneratedResourcePage,
10
10
  OpenXiangdaResourceDefinitionsProvider,
@@ -124,7 +124,7 @@ function resourcePage(mode: 'list' | 'create' | 'edit' | 'detail') {
124
124
  ReactDOM.createRoot(document.getElementById('root')!).render(
125
125
  <React.StrictMode>
126
126
  <OpenXiangdaResourceDefinitionsProvider definitions={definitions}>
127
- <AppearanceProvider>
127
+ <OpenXiangdaUiProvider>
128
128
  <AntdApp>
129
129
  <RuntimeBoundary>
130
130
  <MemoryRouter initialEntries={[initialPath]}>
@@ -156,7 +156,7 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
156
156
  </MemoryRouter>
157
157
  </RuntimeBoundary>
158
158
  </AntdApp>
159
- </AppearanceProvider>
159
+ </OpenXiangdaUiProvider>
160
160
  </OpenXiangdaResourceDefinitionsProvider>
161
161
  </React.StrictMode>
162
162
  );
@@ -1,13 +1,15 @@
1
- import { Button, ConfigProvider, Form, Typography } from 'antd';
1
+ import { App, Button, ConfigProvider, Form, Typography } from 'antd';
2
2
  import type {
3
3
  DataAuditEntry,
4
4
  DataFieldSurface,
5
5
  DataFileRef,
6
6
  } from 'openxiangda/core';
7
- import React from 'react';
7
+ import React, { useState } from 'react';
8
8
  import ReactDOM from 'react-dom/client';
9
9
  import {
10
10
  auditFieldChange,
11
+ fieldValueForData,
12
+ fieldValueForForm,
11
13
  MobileSurfaceFieldControl,
12
14
  SurfaceAuditFieldValue,
13
15
  SurfaceFieldControl,
@@ -15,7 +17,13 @@ import {
15
17
  type SurfaceField,
16
18
  type SurfaceFieldRenderers,
17
19
  } from 'openxiangda/field-kit';
18
- import 'openxiangda/react/styles.css';
20
+ import { Button as MobileButton, Input as MobileInput, MobileSurface, Popup } from 'openxiangda/mobile';
21
+
22
+ if (new URLSearchParams(location.search).get('mode') === 'mobile-surface') {
23
+ await import('openxiangda/mobile/styles.css');
24
+ } else {
25
+ await import('openxiangda/react/styles.css');
26
+ }
19
27
 
20
28
  const options = [
21
29
  { label: '启用', value: 'enabled' },
@@ -75,8 +83,9 @@ const fields = [
75
83
  {
76
84
  label: '设备',
77
85
  value: 'equipment',
78
- children: [{ label: '显微镜', value: 'microscope' }],
86
+ children: [{ label: '显微镜', value: 'microscope' }, { label: '光谱仪', value: 'spectrometer' }],
79
87
  },
88
+ { label: '服务', value: 'service', children: [{ label: '培训', value: 'training' }] },
80
89
  ],
81
90
  }),
82
91
  field('级联多选', 'cascade.multiple', 'cascade', {
@@ -84,8 +93,9 @@ const fields = [
84
93
  {
85
94
  label: '设备',
86
95
  value: 'equipment',
87
- children: [{ label: '显微镜', value: 'microscope' }],
96
+ children: [{ label: '显微镜', value: 'microscope' }, { label: '光谱仪', value: 'spectrometer' }],
88
97
  },
98
+ { label: '服务', value: 'service', children: [{ label: '培训', value: 'training' }] },
89
99
  ],
90
100
  }),
91
101
  field('成员单选', 'user.single', 'directory-user'),
@@ -242,13 +252,19 @@ const auditEntry: DataAuditEntry = {
242
252
 
243
253
  const renderers: SurfaceFieldRenderers = {
244
254
  signer: user,
245
- upload: async (_field, file) => ({
255
+ upload: async (_field, file) => {
256
+ if (new URLSearchParams(location.search).has('controlledUploads')) {
257
+ const response = await fetch('/__field-upload-fixture', { method: 'POST', body: file, headers: { 'x-file-name': encodeURIComponent(file.name) } });
258
+ if (!response.ok) throw new Error(await response.text());
259
+ }
260
+ return ({
246
261
  schemaVersion: 'openxiangda.data-file-ref/v2',
247
262
  id: crypto.randomUUID(),
248
263
  name: file.name,
249
264
  size: file.size,
250
265
  contentType: file.type,
251
- }),
266
+ });
267
+ },
252
268
  renderSubtable: () => (
253
269
  <div className="field-protocol-subtable">2 行子表 · 新增 · 删除 · 排序</div>
254
270
  ),
@@ -258,11 +274,28 @@ const renderers: SurfaceFieldRenderers = {
258
274
  : undefined,
259
275
  };
260
276
 
277
+ function MobileFacadeAcceptance() {
278
+ const [open, setOpen] = useState(false);
279
+ const [value, setValue] = useState('');
280
+ return <MobileSurface>
281
+ <MobileInput aria-label="平台移动输入" value={value} onChange={setValue} />
282
+ <MobileButton onClick={() => setOpen(true)}>打开移动弹层</MobileButton>
283
+ <Popup visible={open} onMaskClick={() => setOpen(false)}>
284
+ <div data-mobile-popup>
285
+ <p>已输入:{value}</p>
286
+ <MobileButton onClick={() => setOpen(false)}>关闭弹层</MobileButton>
287
+ </div>
288
+ </Popup>
289
+ </MobileSurface>;
290
+ }
291
+
261
292
  function AcceptancePage() {
262
293
  const mobile = new URLSearchParams(location.search).get('mode') === 'mobile';
263
294
  const Control = mobile ? MobileSurfaceFieldControl : SurfaceFieldControl;
295
+ const [form] = Form.useForm();
296
+ const [saved, setSaved] = useState<Record<string, unknown>>();
264
297
  return (
265
- <ConfigProvider theme={{ token: { borderRadius: 6 } }}>
298
+ <ConfigProvider><App>
266
299
  <main className={`field-protocol-page ${mobile ? 'is-mobile' : 'is-desktop'}`}>
267
300
  <header>
268
301
  <Typography.Title level={mobile ? 4 : 2}>
@@ -273,7 +306,9 @@ function AcceptancePage() {
273
306
  </Typography.Text>
274
307
  </header>
275
308
  <section aria-label={mobile ? '移动编辑字段' : '桌面编辑字段'} className="field-protocol-edit">
276
- <Form initialValues={values} layout="vertical" onFinish={() => undefined}>
309
+ <Form form={form} initialValues={values} layout="vertical" onFinish={current => {
310
+ setSaved(Object.fromEntries(fields.map(item => [item.key, fieldValueForData(item, current[item.key])])));
311
+ }}>
277
312
  <div className="field-protocol-grid">
278
313
  {fields.map(item => (
279
314
  <div data-field-code={item.key} key={item.key}>
@@ -288,6 +323,8 @@ function AcceptancePage() {
288
323
  ))}
289
324
  </div>
290
325
  <Button htmlType="submit">验证表单</Button>
326
+ {saved && <Button onClick={() => form.setFieldsValue(Object.fromEntries(fields.map(item => [item.key, fieldValueForForm(item, saved[item.key])])))}>重新载入已保存值</Button>}
327
+ <output data-saved-values>{saved ? JSON.stringify(saved) : ''}</output>
291
328
  </Form>
292
329
  </section>
293
330
  {!mobile && (
@@ -339,12 +376,12 @@ function AcceptancePage() {
339
376
  </section>
340
377
  )}
341
378
  </main>
342
- </ConfigProvider>
379
+ </App></ConfigProvider>
343
380
  );
344
381
  }
345
382
 
346
383
  ReactDOM.createRoot(document.getElementById('root')!).render(
347
384
  <React.StrictMode>
348
- <AcceptancePage />
385
+ {new URLSearchParams(location.search).get('mode') === 'mobile-surface' ? <MobileFacadeAcceptance /> : <AcceptancePage />}
349
386
  </React.StrictMode>
350
387
  );
@@ -112,6 +112,7 @@ test.describe('Field Kit protocol acceptance', () => {
112
112
  await expect(saveSignature).toBeEnabled();
113
113
  await saveSignature.click();
114
114
  await expect(page.locator('[data-field-code="业务签名"]')).not.toContainText('尚未签名');
115
+ await expect(page.getByRole('dialog', { name: '手写签名' })).toBeHidden();
115
116
 
116
117
  expect(pageErrors).toEqual([]);
117
118
 
@@ -179,6 +180,64 @@ test.describe('Field Kit protocol acceptance', () => {
179
180
  }
180
181
  });
181
182
 
183
+ test('mobile selection, numeric validation and save/reload keep canonical values', async ({ page }) => {
184
+ const pageErrors: string[] = [];
185
+ page.on('pageerror', error => pageErrors.push(error.message));
186
+ await page.setViewportSize({ width: 390, height: 844 });
187
+ await page.goto('/field-protocol.e2e.html?mode=mobile');
188
+ const field = (label: string) => page.locator(`[data-field-code="${label}"]`);
189
+ await field('单行文本').getByRole('textbox').fill('移动录入验证');
190
+ await field('整数').getByRole('textbox').fill('1.5');
191
+ await page.getByRole('button', { name: '验证表单', exact: true }).click();
192
+ await expect(field('整数').getByRole('alert')).toContainText('整数');
193
+ await expect(page.locator('[data-saved-values]')).toBeEmpty();
194
+ await field('整数').getByRole('textbox').fill('12');
195
+ await field('小数').getByRole('textbox').fill('12.75');
196
+ await field('金额').getByRole('textbox').fill('');
197
+ const moneyInput = await field('金额').getByRole('textbox').boundingBox();
198
+ const currency = await field('金额').locator('[aria-hidden="true"]').first().boundingBox();
199
+ expect(moneyInput!.height).toBeGreaterThanOrEqual(24);
200
+ // Currency must stay beside the editable value, even with the full React stylesheet.
201
+ expect(currency!.y + currency!.height / 2).toBeGreaterThan(moneyInput!.y);
202
+ expect(currency!.y + currency!.height / 2).toBeLessThan(moneyInput!.y + moneyInput!.height);
203
+
204
+ const choice = field('静态单选下拉');
205
+ const oldChoice = await choice.getByRole('button', { name: '选择静态单选下拉' }).innerText();
206
+ await choice.getByRole('button', { name: '选择静态单选下拉' }).click();
207
+ await choice.getByText('停用', { exact: true }).last().click();
208
+ await choice.getByRole('button', { name: '取消', exact: true }).click();
209
+ await expect(choice.getByRole('button', { name: '选择静态单选下拉' })).toHaveText(oldChoice);
210
+ await choice.getByRole('button', { name: '选择静态单选下拉' }).click();
211
+ await choice.getByText('停用', { exact: true }).last().click();
212
+ await choice.getByRole('button', { name: '确定', exact: true }).click();
213
+ await expect(choice.getByRole('button', { name: '选择静态单选下拉' })).toHaveText('停用');
214
+
215
+ await field('日期').getByRole('button', { name: '选择日期', exact: true }).click();
216
+ await field('日期').getByText('确定', { exact: true }).click();
217
+ await field('时间').getByRole('button', { name: '选择时间', exact: true }).click();
218
+ await field('时间').getByText('确定', { exact: true }).click();
219
+ await page.getByRole('button', { name: '验证表单', exact: true }).click();
220
+ const result = page.locator('[data-saved-values]');
221
+ await expect(result).toContainText('移动录入验证');
222
+ const stored = JSON.parse(await result.innerText());
223
+ expect(stored['整数']).toBe(12);
224
+ expect(stored['小数']).toBe(12.75);
225
+ expect(stored['金额']).toBeNull();
226
+ expect(stored['静态单选下拉']).toEqual({ label: '停用', value: 'disabled' });
227
+ expect(stored['日期']).toMatch(/^\d{4}-\d{2}-\d{2}$/);
228
+ expect(stored['时间']).toMatch(/^\d{2}:\d{2}:\d{2}$/);
229
+ await field('单行文本').getByRole('textbox').fill('未保存修改');
230
+ await page.getByRole('button', { name: '重新载入已保存值' }).click();
231
+ await expect(field('单行文本').getByRole('textbox')).toHaveValue('移动录入验证');
232
+ await expect(field('小数').getByRole('textbox')).toHaveValue('12.75');
233
+ await field('日期').getByRole('button', { name: '清空日期', exact: true }).click();
234
+ await page.getByRole('button', { name: '验证表单', exact: true }).click();
235
+ await expect.poll(async () => JSON.parse(await result.innerText())['日期']).toBeNull();
236
+ expect(pageErrors).toEqual([]);
237
+ await page.evaluate(() => window.scrollTo(0, 0));
238
+ await page.screenshot({ path: 'test-results/foundation-mobile-controls.png' });
239
+ });
240
+
182
241
  test('renders every mobile control without horizontal overflow', async ({ page }) => {
183
242
  await page.setViewportSize({ width: 390, height: 844 });
184
243
  await page.goto('/field-protocol.e2e.html?mode=mobile');
@@ -192,4 +251,35 @@ test.describe('Field Kit protocol acceptance', () => {
192
251
  ).toBe(true);
193
252
  await page.screenshot({ fullPage: true, path: 'test-results/field-protocol-mobile.png' });
194
253
  });
254
+
255
+ test('mobile default components and popups do not reset the surrounding page', async ({ page }) => {
256
+ const pageErrors: string[] = [];
257
+ page.on('pageerror', error => pageErrors.push(error.message));
258
+ await page.setViewportSize({ width: 390, height: 844 });
259
+ await page.goto('/field-protocol.e2e.html?mode=mobile-surface');
260
+ const outsideStyles = () => page.evaluate(() => ({
261
+ background: getComputedStyle(document.documentElement).backgroundColor,
262
+ color: getComputedStyle(document.body).color,
263
+ fontSize: getComputedStyle(document.body).fontSize,
264
+ fontFamily: getComputedStyle(document.body).fontFamily,
265
+ link: getComputedStyle(document.getElementById('outside-mobile')!).color,
266
+ mobilePrimary: getComputedStyle(document.documentElement).getPropertyValue('--adm-color-primary'),
267
+ }));
268
+ const baseline = {
269
+ background: 'rgb(248, 249, 250)', color: 'rgb(31, 32, 33)',
270
+ fontSize: '17px', fontFamily: 'monospace', link: 'rgb(101, 42, 140)', mobilePrimary: '',
271
+ };
272
+ expect(await outsideStyles()).toEqual(baseline);
273
+ await page.getByRole('textbox', { name: '平台移动输入' }).fill('弹层继承当前页面');
274
+ await page.getByRole('button', { name: '打开移动弹层' }).click();
275
+ const popup = page.locator('.oxa-mobile-scope .adm-popup-body');
276
+ await expect(popup).toBeVisible();
277
+ await expect(popup).toContainText('弹层继承当前页面');
278
+ await expect(popup).toHaveCSS('background-color', 'rgb(255, 255, 255)');
279
+ expect(await outsideStyles()).toEqual(baseline);
280
+ await popup.getByRole('button', { name: '关闭弹层' }).click();
281
+ await expect(popup).toBeHidden();
282
+ expect(await outsideStyles()).toEqual(baseline);
283
+ expect(pageErrors).toEqual([]);
284
+ });
195
285
  });
@@ -0,0 +1,93 @@
1
+ import { expect, test, type Page } from '@playwright/test';
2
+
3
+ const field = (page: Page, name: string) => page.locator(`[data-field-code="${name}"]`);
4
+ const file = (name: string) => ({ name, mimeType: 'text/plain', buffer: Buffer.from('acceptance') });
5
+ async function submit(page: Page) {
6
+ await page.getByRole('button', { name: '验证表单', exact: true }).click();
7
+ await expect(page.locator('[data-saved-values]')).not.toBeEmpty();
8
+ return JSON.parse(await page.locator('[data-saved-values]').innerText());
9
+ }
10
+
11
+ test.describe('mobile managed files', () => {
12
+ test.beforeEach(async ({ page }) => {
13
+ await page.setViewportSize({ width: 390, height: 844 });
14
+ await page.route('**/china-divisions/**', route => route.fulfill({ json: [] }));
15
+ await page.route('**/files/*/content?**', route => route.fulfill({ contentType: 'image/svg+xml', body:
16
+ '<svg xmlns="http://www.w3.org/2000/svg" width="640" height="480" viewBox="0 0 640 480"><rect width="640" height="480" fill="#f1f3f5"/><circle cx="470" cy="120" r="45" fill="#c6d3df"/><path d="M0 480L220 180L410 480M230 480L450 250L640 480" fill="#95a9bb"/><text x="30" y="50" font-family="sans-serif" font-size="24" fill="#34495e">Image preview fixture</text></svg>' }));
17
+ });
18
+
19
+ test('failed uploads retry, removed pending results stay removed and limits do not truncate', async ({ page }, info) => {
20
+ const errors: string[] = [];
21
+ page.on('pageerror', error => errors.push(error.message));
22
+ let attempts = 0;
23
+ let release!: () => void;
24
+ const held = new Promise<void>(resolve => { release = resolve; });
25
+ await page.route('**/__field-upload-fixture', async route => {
26
+ const name = decodeURIComponent(route.request().headers()['x-file-name']);
27
+ if (name === '待移除.txt') await held;
28
+ if (name === '重试.txt' && ++attempts === 1) return route.fulfill({ status: 503, body: '网络暂不可用,请重试。已填写内容会保留。' });
29
+ await route.fulfill({ status: 204 });
30
+ });
31
+ await page.goto('/field-protocol.e2e.html?mode=mobile&controlledUploads=1');
32
+ const attachment = field(page, '附件');
33
+ await attachment.locator('input[type=file]').setInputFiles(file('重试.txt'));
34
+ await expect(attachment.getByRole('alert')).toContainText('网络暂不可用');
35
+ await expect(attachment.locator('.ant-btn')).toHaveCount(0);
36
+ await expect(attachment.locator('input[type=file]')).toBeHidden();
37
+ await attachment.scrollIntoViewIfNeeded();
38
+ await page.screenshot({ path: info.outputPath('mobile-file-retry.png') });
39
+ await attachment.getByRole('button', { name: '重试', exact: true }).click();
40
+ await expect(attachment.getByRole('button', { name: '预览重试.txt', exact: true })).toBeVisible();
41
+ expect(attempts).toBe(2);
42
+ await attachment.locator('input[type=file]').setInputFiles(file('待移除.txt'));
43
+ const pending = attachment.locator('.oxa-mobile-upload-card');
44
+ await expect(pending).toContainText('上传中');
45
+ await pending.getByRole('button', { name: '移除', exact: true }).click();
46
+ release();
47
+ await expect(attachment.locator('.oxa-mobile-upload-card')).toHaveCount(0);
48
+ await attachment.locator('input[type=file]').setInputFiles([file('超额一.txt'), file('超额二.txt')]);
49
+ await expect(attachment.getByRole('alert')).toContainText('还可选择 1 个文件');
50
+ const saved = await submit(page);
51
+ expect(saved['附件'].map((item: { name: string }) => item.name)).toEqual(['验收附件.pdf', '重试.txt']);
52
+ await page.getByRole('button', { name: '重新载入已保存值' }).click();
53
+ await expect(attachment.getByRole('button', { name: '预览重试.txt', exact: true })).toBeVisible();
54
+ await attachment.getByRole('button', { name: '移除重试.txt', exact: true }).click();
55
+ expect((await submit(page))['附件']).toHaveLength(1);
56
+ expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
57
+ expect(errors).toEqual([]);
58
+ });
59
+
60
+ test('mobile image preview, close and attachment download use existing file APIs', async ({ page }, info) => {
61
+ const errors: string[] = [];
62
+ page.on('pageerror', error => errors.push(error.message));
63
+ await page.route('**/files/*/preview?**', route => route.fulfill({ json: {
64
+ canPreview: true, previewType: 'image', file: { name: '验收图片.png' },
65
+ } }));
66
+ await page.goto('/field-protocol.e2e.html?mode=mobile');
67
+ const images = field(page, '图片');
68
+ await expect(images.getByRole('button', { name: '上传图片', exact: true })).toBeVisible();
69
+ await images.locator('input[type=file]').setInputFiles({ name: '第二张.png', mimeType: 'image/png', buffer: Buffer.from('fixture-image') });
70
+ await expect(images.getByRole('button', { name: '预览第二张.png', exact: true })).toBeVisible();
71
+ await expect(images.getByRole('button', { name: '预览验收图片.png', exact: true })).toBeVisible();
72
+ await images.getByRole('button', { name: '预览第二张.png', exact: true }).click();
73
+ await expect(page.getByRole('button', { name: '关闭预览', exact: true })).toBeVisible();
74
+ await expect(page.locator('.ant-image-preview')).toHaveCount(0);
75
+ await expect(page.locator('.adm-image-viewer-indicator')).toHaveText('2 / 2');
76
+ await expect(page.locator('.adm-image-viewer-image-wrapper img').last()).toHaveJSProperty('naturalWidth', 640);
77
+ await page.screenshot({ path: info.outputPath('mobile-image-preview.png') });
78
+ await page.getByRole('button', { name: '关闭预览', exact: true }).click();
79
+ await expect(page.getByRole('button', { name: '关闭预览', exact: true })).toHaveCount(0);
80
+ let downloadAttempts = 0;
81
+ await page.route('**/files/11111111-1111-4111-8111-111111111111/content?**', route => {
82
+ downloadAttempts += 1;
83
+ return downloadAttempts === 1 ? route.fulfill({ status: 503, body: 'Unavailable' }) : route.fulfill({ body: 'download acceptance', contentType: 'application/pdf' });
84
+ });
85
+ await field(page, '附件').getByRole('button', { name: '下载验收附件.pdf', exact: true }).click();
86
+ await expect(page.getByText('FILE_CONTENT_READ_FAILED: HTTP_503', { exact: true })).toBeVisible();
87
+ await expect(field(page, '附件').getByRole('button', { name: '预览验收附件.pdf', exact: true })).toBeVisible();
88
+ const download = page.waitForEvent('download');
89
+ await field(page, '附件').getByRole('button', { name: '下载验收附件.pdf', exact: true }).click();
90
+ expect((await download).suggestedFilename()).toBe('验收附件.pdf');
91
+ expect(errors).toEqual([]);
92
+ });
93
+ });