openxiangda-cli 2.0.0-alpha.84 → 2.0.0-alpha.86
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -4
- package/template/AGENTS.md +4 -4
- package/template/README.md +2 -2
- package/template/apps/server/package.json +1 -1
- package/template/apps/web/data-api-live.e2e.html +15 -0
- package/template/apps/web/e2e/data-api-live-fixture.tsx +141 -0
- package/template/apps/web/e2e/data-api-live.spec.ts +138 -0
- package/template/apps/web/e2e/field-protocol-fixture.tsx +280 -0
- package/template/apps/web/e2e/field-protocol.spec.ts +100 -0
- package/template/apps/web/e2e/resources.spec.ts +258 -20
- package/template/apps/web/field-protocol.e2e.html +12 -0
- package/template/apps/web/package.json +1 -1
- package/template/apps/web/playwright.config.ts +3 -1
- package/template/apps/web/scripts/check.mjs +8 -5
- package/template/apps/web/scripts/verify-build.mjs +1 -1
- package/template/apps/web/src/AuthoritativeSelector.tsx +165 -132
- package/template/apps/web/src/FilePreviewPage.tsx +30 -9
- package/template/apps/web/src/RoleSubjectSelect.tsx +128 -0
- package/template/apps/web/src/Shell.tsx +93 -6
- package/template/apps/web/src/components/platform-fields/AddressField.tsx +338 -0
- package/template/apps/web/src/components/platform-fields/AttachmentFileList.tsx +25 -6
- package/template/apps/web/src/components/platform-fields/CascadeField.tsx +49 -0
- package/template/apps/web/src/components/platform-fields/DateTimeField.tsx +148 -0
- package/template/apps/web/src/components/platform-fields/JsonField.tsx +77 -0
- package/template/apps/web/src/components/platform-fields/LocationField.tsx +164 -0
- package/template/apps/web/src/components/platform-fields/PlatformDirectoryPicker.tsx +19 -44
- package/template/apps/web/src/components/platform-fields/ResourceReferenceField.tsx +67 -0
- package/template/apps/web/src/components/platform-fields/RichTextField.tsx +196 -0
- package/template/apps/web/src/components/platform-fields/SignatureField.tsx +315 -0
- package/template/apps/web/src/components/platform-fields/SubtableField.tsx +553 -0
- package/template/apps/web/src/components/platform-fields/address-value.ts +80 -0
- package/template/apps/web/src/components/platform-fields/cascade-value.ts +66 -0
- package/template/apps/web/src/components/platform-fields/directory-value.ts +53 -0
- package/template/apps/web/src/components/platform-fields/field-form-codec.ts +98 -0
- package/template/apps/web/src/components/platform-fields/location-value.ts +87 -0
- package/template/apps/web/src/components/platform-fields/resource-query.ts +131 -0
- package/template/apps/web/src/components/platform-fields/rich-text-value.ts +59 -0
- package/template/apps/web/src/components/platform-fields/subtable-value.ts +187 -0
- package/template/apps/web/src/components/resource/GeneratedResourceCrud.tsx +194 -108
- package/template/apps/web/src/components/resource/ResourceBatchActions.tsx +8 -1
- package/template/apps/web/src/components/resource/SurfaceFields.tsx +385 -79
- package/template/apps/web/src/components/resource/generated-resource-definition.ts +18 -0
- package/template/apps/web/src/components/resource/resource-import.ts +4 -4
- package/template/apps/web/src/data-provider.ts +59 -34
- package/template/apps/web/src/platform-client.ts +385 -111
- package/template/apps/web/src/runtime.tsx +166 -14
- package/template/apps/web/src/styles.css +264 -0
- package/template/apps/web/test/address-field.test.ts +66 -0
- package/template/apps/web/test/cascade-value.test.ts +68 -0
- package/template/apps/web/test/contracts.test.ts +29 -5
- package/template/apps/web/test/directory-value.test.ts +55 -0
- package/template/apps/web/test/field-bounds.test.ts +17 -0
- package/template/apps/web/test/field-form-codec.test.ts +85 -0
- package/template/apps/web/test/location-field.test.ts +83 -0
- package/template/apps/web/test/resource-import.test.ts +8 -7
- package/template/apps/web/test/resource-query.test.ts +144 -0
- package/template/apps/web/test/rich-json-field.test.ts +53 -0
- package/template/apps/web/test/signature-serial-field.test.ts +40 -0
- package/template/apps/web/test/subtable-value.test.ts +144 -0
- package/template/package.json +3 -3
- package/template/packages/contracts/src/generated.ts +69 -0
- package/template/scripts/verify-template-budget.mjs +11 -4
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.86",
|
|
4
4
|
"description": "Thin application-level CLI for OpenXiangda 2.0.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -23,9 +23,9 @@
|
|
|
23
23
|
],
|
|
24
24
|
"dependencies": {
|
|
25
25
|
"@oclif/core": "4.13.3",
|
|
26
|
-
"openxiangda-contracts": "2.0.0-alpha.
|
|
27
|
-
"openxiangda-devkit-core": "2.0.0-alpha.
|
|
28
|
-
"openxiangda-mcp": "2.0.0-alpha.
|
|
26
|
+
"openxiangda-contracts": "2.0.0-alpha.40",
|
|
27
|
+
"openxiangda-devkit-core": "2.0.0-alpha.53",
|
|
28
|
+
"openxiangda-mcp": "2.0.0-alpha.53"
|
|
29
29
|
},
|
|
30
30
|
"devDependencies": {
|
|
31
31
|
"tsx": "4.23.12",
|
package/template/AGENTS.md
CHANGED
|
@@ -7,13 +7,13 @@
|
|
|
7
7
|
- 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.
|
|
8
8
|
- Add NestJS only for a named business action that needs a cross-resource transaction, an invariant or an external side effect.
|
|
9
9
|
- 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.
|
|
10
|
-
- Use the current logged-in user and the union of application roles.
|
|
10
|
+
- Use the current logged-in user and the union of that user's application roles. The generated shell automatically carries the platform's opaque authorization context for Data, Directory and App API requests. Business code must not read or persist that identifier, a platform Token, or authorization results, and must not implement a second identity path.
|
|
11
11
|
- Fields inherit the resource read/create/update capabilities. Use field `access` only to tighten them; arrays are all-of and `false` is explicit deny. There is no `write` fallback.
|
|
12
|
-
- Field
|
|
12
|
+
- Field `type` is semantic, never a hand-authored database type. The supported catalog is exactly `text.short`, `text.long`, `text.rich`, `number.integer`, `number.decimal`, `boolean`, `date`, `time`, `datetime`, `date-range`, `datetime-range`, `option.single`, `option.multiple`, `cascade.single`, `cascade.multiple`, `user.single`, `user.multiple`, `department.single`, `department.multiple`, `resource-ref.single`, `resource-ref.multiple`, `file`, `image`, `signature`, `address`, `location`, `json`, `serial-number`, `uuid` and `subtable`. The compiler alone derives PostgreSQL columns, constraints and indexes.
|
|
13
13
|
- Declare every custom operation capability in `authz.capabilities` with `kind: 'backend'`, then reference that same code from the operation and its allowed roles. Generated resource CRUD capabilities do not go in this catalog.
|
|
14
14
|
- 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.
|
|
15
|
-
- Desktop and mobile pages share values, validation and authorization, but use separate renderers.
|
|
16
|
-
-
|
|
15
|
+
- Desktop and mobile pages share values, validation and authorization, but use separate renderers. Options, members, departments and resource references store their complete display snapshots; attachments, images and signatures use platform-managed file references.
|
|
16
|
+
- `option.*`, `user.*`, `department.*` and `resource-ref.*` values never collapse to scalar IDs. Single values store one labeled snapshot and multiple values store snapshot arrays. `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`.
|
|
17
17
|
- Derive role grants with `resourceCapabilityCodes(appCode, resourceCode)`. Declare current-user rows only with `currentUserDataPolicy(...)`; do not invent operators, values or alternate current-user spellings.
|
|
18
18
|
- 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.
|
|
19
19
|
- Run `pnpm openxiangda check` after contract changes. 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.
|
package/template/README.md
CHANGED
|
@@ -12,9 +12,9 @@ pnpm openxiangda dev
|
|
|
12
12
|
|
|
13
13
|
成员、部门和资源关系分别使用 `reference: { kind: 'directory-user' }`、`directory-department`、`resource`,只保存稳定 ID,不复制目录数据。单选成员字段使用 `string`,单选部门和资源引用使用 `uuid`,多选引用统一使用 `json`。需要目录选择器的业务角色必须显式拥有 `app:<app-code>:directory:read` 能力。用 `resourceCapabilityCodes` 生成资源 CRUD 权限,用 `currentUserDataPolicy` 声明唯一的当前用户行规则。
|
|
14
14
|
|
|
15
|
-
所有 CRUD 位于 `apps/web/src/platform-client.ts` 的 Data API adapter;Nest 只在需要复杂事务或外部集成时增加 App API。选择器显式携带当前环境、使用同源 Cookie
|
|
15
|
+
所有 CRUD 位于 `apps/web/src/platform-client.ts` 的 Data API adapter;Nest 只在需要复杂事务或外部集成时增加 App API。选择器显式携带当前环境、使用同源 Cookie。标准客户端在内存中恢复平台 RoleSession,并自动为 Data、Directory、文件和 App API 请求携带 header;业务代码不读取或持久化 Token、RoleSession 标识与授权结果。
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
前端读取当前单一应用角色和 capability 做展示保护;多角色用户从当前应用个人菜单切换,切换后页面、数据和操作上下文整体替换,不做权限并集。服务端始终权威。字段默认继承资源 read/create/update;`access` 数组为 all-of,`false` 为明确拒绝,无权字段在提交前从 payload 删除。
|
|
18
18
|
|
|
19
19
|
```bash
|
|
20
20
|
pnpm openxiangda check
|
|
@@ -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="native-projection-app" />
|
|
8
|
+
<meta name="openxiangda-environment" content="production" />
|
|
9
|
+
<title>OpenXiangda Standard Data API Live Acceptance</title>
|
|
10
|
+
</head>
|
|
11
|
+
<body>
|
|
12
|
+
<div id="root"></div>
|
|
13
|
+
<script type="module" src="/e2e/data-api-live-fixture.tsx"></script>
|
|
14
|
+
</body>
|
|
15
|
+
</html>
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { Refine } from '@refinedev/core';
|
|
2
|
+
import { App as AntdApp } from 'antd';
|
|
3
|
+
import React from 'react';
|
|
4
|
+
import ReactDOM from 'react-dom/client';
|
|
5
|
+
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
|
6
|
+
import { createApplicationProvider } from '../src/data-provider';
|
|
7
|
+
import { GeneratedResourcePage } from '../src/components/resource/GeneratedResourceCrud';
|
|
8
|
+
import type { GeneratedResourceDefinition } from '../src/components/resource/generated-resource-definition';
|
|
9
|
+
import { AppearanceProvider } from '../src/appearance';
|
|
10
|
+
import { RuntimeBoundary } from '../src/runtime';
|
|
11
|
+
import '../src/styles.css';
|
|
12
|
+
|
|
13
|
+
const code = 'field-records';
|
|
14
|
+
const capabilityBase = `app:native-projection-app:data:${code}`;
|
|
15
|
+
const capabilities = {
|
|
16
|
+
read: `${capabilityBase}:read`,
|
|
17
|
+
create: `${capabilityBase}:create`,
|
|
18
|
+
update: `${capabilityBase}:update`,
|
|
19
|
+
delete: `${capabilityBase}:delete`,
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
function fieldCapabilities() {
|
|
23
|
+
return {
|
|
24
|
+
readCapabilities: [capabilities.read],
|
|
25
|
+
createCapabilities: [capabilities.create],
|
|
26
|
+
updateCapabilities: [capabilities.update],
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const liveDefinition: GeneratedResourceDefinition = {
|
|
31
|
+
code,
|
|
32
|
+
name: '字段协议记录',
|
|
33
|
+
capabilities,
|
|
34
|
+
surface: {
|
|
35
|
+
generated: true,
|
|
36
|
+
fields: {
|
|
37
|
+
text_short: {
|
|
38
|
+
label: '标题',
|
|
39
|
+
type: 'text.short',
|
|
40
|
+
widget: 'text',
|
|
41
|
+
requiredHint: true,
|
|
42
|
+
maxLength: 120,
|
|
43
|
+
list: true,
|
|
44
|
+
searchable: true,
|
|
45
|
+
sortable: true,
|
|
46
|
+
...fieldCapabilities(),
|
|
47
|
+
},
|
|
48
|
+
option_single: {
|
|
49
|
+
label: '单选快照',
|
|
50
|
+
type: 'option.single',
|
|
51
|
+
widget: 'radio',
|
|
52
|
+
options: [
|
|
53
|
+
{ label: 'Open', value: 'open' },
|
|
54
|
+
{ label: 'Closed', value: 'closed' },
|
|
55
|
+
],
|
|
56
|
+
list: true,
|
|
57
|
+
...fieldCapabilities(),
|
|
58
|
+
},
|
|
59
|
+
option_multiple: {
|
|
60
|
+
label: '多选快照',
|
|
61
|
+
type: 'option.multiple',
|
|
62
|
+
widget: 'checkbox',
|
|
63
|
+
options: [
|
|
64
|
+
{ label: 'Open', value: 'open' },
|
|
65
|
+
{ label: 'Urgent', value: 'urgent' },
|
|
66
|
+
],
|
|
67
|
+
list: true,
|
|
68
|
+
...fieldCapabilities(),
|
|
69
|
+
},
|
|
70
|
+
location_value: {
|
|
71
|
+
label: '精确定位',
|
|
72
|
+
type: 'location',
|
|
73
|
+
widget: 'location',
|
|
74
|
+
list: true,
|
|
75
|
+
...fieldCapabilities(),
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
list: {
|
|
79
|
+
defaultPageSize: 20,
|
|
80
|
+
searchableFields: ['text_short'],
|
|
81
|
+
filterFields: ['option_single'],
|
|
82
|
+
defaultSort: { field: 'text_short', order: 'asc' },
|
|
83
|
+
},
|
|
84
|
+
form: { layout: 'sections' },
|
|
85
|
+
detail: { layout: 'sections' },
|
|
86
|
+
mobile: { enabled: true },
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const definitions = { [code]: liveDefinition };
|
|
91
|
+
const provider = createApplicationProvider(definitions);
|
|
92
|
+
const initialPath =
|
|
93
|
+
new URLSearchParams(window.location.search).get('initial') || `/${code}`;
|
|
94
|
+
|
|
95
|
+
function resourcePage(mode: 'list' | 'create' | 'edit' | 'detail') {
|
|
96
|
+
return (
|
|
97
|
+
<GeneratedResourcePage
|
|
98
|
+
definition={liveDefinition}
|
|
99
|
+
mode={mode}
|
|
100
|
+
resourceCode={code}
|
|
101
|
+
/>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
ReactDOM.createRoot(document.getElementById('root')!).render(
|
|
106
|
+
<React.StrictMode>
|
|
107
|
+
<AppearanceProvider>
|
|
108
|
+
<AntdApp>
|
|
109
|
+
<RuntimeBoundary>
|
|
110
|
+
<MemoryRouter initialEntries={[initialPath]}>
|
|
111
|
+
<Refine
|
|
112
|
+
dataProvider={provider}
|
|
113
|
+
resources={[
|
|
114
|
+
{
|
|
115
|
+
name: code,
|
|
116
|
+
list: `/${code}`,
|
|
117
|
+
create: `/${code}/new`,
|
|
118
|
+
edit: `/${code}/:id/edit`,
|
|
119
|
+
show: `/${code}/:id`,
|
|
120
|
+
},
|
|
121
|
+
]}
|
|
122
|
+
>
|
|
123
|
+
<Routes>
|
|
124
|
+
<Route path={`/${code}`} element={resourcePage('list')} />
|
|
125
|
+
<Route path={`/${code}/new`} element={resourcePage('create')} />
|
|
126
|
+
<Route
|
|
127
|
+
path={`/${code}/:id`}
|
|
128
|
+
element={resourcePage('detail')}
|
|
129
|
+
/>
|
|
130
|
+
<Route
|
|
131
|
+
path={`/${code}/:id/edit`}
|
|
132
|
+
element={resourcePage('edit')}
|
|
133
|
+
/>
|
|
134
|
+
</Routes>
|
|
135
|
+
</Refine>
|
|
136
|
+
</MemoryRouter>
|
|
137
|
+
</RuntimeBoundary>
|
|
138
|
+
</AntdApp>
|
|
139
|
+
</AppearanceProvider>
|
|
140
|
+
</React.StrictMode>
|
|
141
|
+
);
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { expect, test } from '@playwright/test';
|
|
2
|
+
|
|
3
|
+
const liveEnabled = process.env.OPENXIANGDA_LIVE_DATA_API === '1';
|
|
4
|
+
const title = 'UI live field record';
|
|
5
|
+
|
|
6
|
+
test.describe('@live-data-api standard resource UI', () => {
|
|
7
|
+
test.setTimeout(90_000);
|
|
8
|
+
test.skip(
|
|
9
|
+
!liveEnabled,
|
|
10
|
+
'Requires the disposable PostgreSQL Native Data API acceptance bridge.'
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
test.beforeEach(async ({ context }) => {
|
|
14
|
+
await context.addCookies([
|
|
15
|
+
{
|
|
16
|
+
name: 'openxiangda-field-ui-role',
|
|
17
|
+
value: 'super',
|
|
18
|
+
domain: '127.0.0.1',
|
|
19
|
+
path: '/',
|
|
20
|
+
},
|
|
21
|
+
]);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('runs create, list, query, get, update, audit and delete through the standard page', async ({
|
|
25
|
+
context,
|
|
26
|
+
page,
|
|
27
|
+
}) => {
|
|
28
|
+
await context.grantPermissions(['geolocation']);
|
|
29
|
+
await context.setGeolocation({
|
|
30
|
+
longitude: 120.123456,
|
|
31
|
+
latitude: 30.234567,
|
|
32
|
+
accuracy: 4,
|
|
33
|
+
});
|
|
34
|
+
await page.goto('/data-api-live.e2e.html?initial=/field-records');
|
|
35
|
+
await expect(page.locator('.oxa-list-surface')).toBeVisible();
|
|
36
|
+
|
|
37
|
+
await page.getByRole('button', { name: '新增字段协议记录' }).click();
|
|
38
|
+
await expect(page.getByRole('heading', { name: '新增字段协议记录' })).toBeVisible();
|
|
39
|
+
await page.getByLabel('标题').fill(title);
|
|
40
|
+
await page.getByRole('radio', { name: 'Open' }).check();
|
|
41
|
+
const openCheckbox = page.getByRole('checkbox', { name: 'Open' });
|
|
42
|
+
const urgentCheckbox = page.getByRole('checkbox', { name: 'Urgent' });
|
|
43
|
+
await openCheckbox.click();
|
|
44
|
+
await urgentCheckbox.click();
|
|
45
|
+
await expect(openCheckbox).toBeChecked();
|
|
46
|
+
await expect(urgentCheckbox).toBeChecked();
|
|
47
|
+
await page.getByRole('button', { name: '浏览器定位' }).click();
|
|
48
|
+
await expect(page.getByText('120.123456, 30.234567')).toBeVisible();
|
|
49
|
+
await page.getByRole('button', { name: /^保\s*存$/ }).click();
|
|
50
|
+
|
|
51
|
+
await expect(page.locator('.oxa-list-surface')).toBeVisible();
|
|
52
|
+
await page.locator('.oxa-search-panel .ant-select').first().click();
|
|
53
|
+
await page.getByText('标题', { exact: true }).last().click();
|
|
54
|
+
await page.getByPlaceholder('关键词').fill(title);
|
|
55
|
+
await page.getByRole('button', { name: /查询/ }).click();
|
|
56
|
+
const createdRow = page.getByRole('row').filter({ hasText: title });
|
|
57
|
+
await expect(createdRow).toHaveCount(1);
|
|
58
|
+
await expect(createdRow).toContainText('Open');
|
|
59
|
+
await expect(createdRow).toContainText('Urgent');
|
|
60
|
+
await expect(createdRow).toContainText('120.123456, 30.234567');
|
|
61
|
+
|
|
62
|
+
await createdRow.getByRole('button', { name: '查看' }).click();
|
|
63
|
+
await expect(page.getByRole('heading', { name: '字段协议记录详情' })).toBeVisible();
|
|
64
|
+
await expect(page.getByText(title, { exact: true })).toBeVisible();
|
|
65
|
+
await expect(page.getByText(/浏览器定位/)).toBeVisible();
|
|
66
|
+
await page.locator('.oxa-audit-collapse .ant-collapse-header').click();
|
|
67
|
+
await expect(page.getByText('创建', { exact: true })).toBeVisible();
|
|
68
|
+
await page.screenshot({
|
|
69
|
+
fullPage: true,
|
|
70
|
+
path: 'test-results/data-api-live-detail.png',
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
await page.getByRole('button', { name: /编辑/ }).click();
|
|
74
|
+
await page.getByLabel('标题').fill(`${title} updated`);
|
|
75
|
+
await page.getByRole('radio', { name: 'Closed' }).check();
|
|
76
|
+
await page.getByRole('checkbox', { name: 'Open' }).click();
|
|
77
|
+
await expect(page.getByRole('checkbox', { name: 'Open' })).not.toBeChecked();
|
|
78
|
+
await page.getByRole('button', { name: /^保存修改$/ }).click();
|
|
79
|
+
await expect(page.getByText(`${title} updated`, { exact: true })).toBeVisible();
|
|
80
|
+
await expect(page.getByText('Closed', { exact: true })).toBeVisible();
|
|
81
|
+
|
|
82
|
+
await page.getByRole('button', { name: '返回列表' }).click();
|
|
83
|
+
const updatedRow = page
|
|
84
|
+
.getByRole('row')
|
|
85
|
+
.filter({ hasText: `${title} updated` });
|
|
86
|
+
await expect(updatedRow).toHaveCount(1);
|
|
87
|
+
await updatedRow.getByRole('button', { name: '删除' }).click();
|
|
88
|
+
await page.getByRole('button', { name: /^确\s*定$/ }).click();
|
|
89
|
+
await expect(updatedRow).toHaveCount(0);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('applies PostgreSQL row scope to the standard list and query UI', async ({
|
|
93
|
+
context,
|
|
94
|
+
page,
|
|
95
|
+
}) => {
|
|
96
|
+
await context.clearCookies();
|
|
97
|
+
await context.addCookies([
|
|
98
|
+
{
|
|
99
|
+
name: 'openxiangda-field-ui-role',
|
|
100
|
+
value: 'restricted',
|
|
101
|
+
domain: '127.0.0.1',
|
|
102
|
+
path: '/',
|
|
103
|
+
},
|
|
104
|
+
]);
|
|
105
|
+
await page.goto('/data-api-live.e2e.html?initial=/field-records');
|
|
106
|
+
await expect(page.locator('.oxa-list-surface')).toBeVisible();
|
|
107
|
+
await page.locator('.oxa-search-panel .ant-select').first().click();
|
|
108
|
+
await page.getByText('标题', { exact: true }).last().click();
|
|
109
|
+
await page.getByPlaceholder('关键词').fill('UI RLS');
|
|
110
|
+
await page.getByRole('button', { name: /查询/ }).click();
|
|
111
|
+
await expect(page.getByText('UI RLS visible', { exact: true })).toBeVisible();
|
|
112
|
+
await expect(page.getByText('UI RLS hidden', { exact: true })).toHaveCount(0);
|
|
113
|
+
await page.screenshot({
|
|
114
|
+
fullPage: true,
|
|
115
|
+
path: 'test-results/data-api-live-rls.png',
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('fails closed in the standard page when the role has no read capability', async ({
|
|
120
|
+
context,
|
|
121
|
+
page,
|
|
122
|
+
}) => {
|
|
123
|
+
await context.clearCookies();
|
|
124
|
+
await context.addCookies([
|
|
125
|
+
{
|
|
126
|
+
name: 'openxiangda-field-ui-role',
|
|
127
|
+
value: 'denied',
|
|
128
|
+
domain: '127.0.0.1',
|
|
129
|
+
path: '/',
|
|
130
|
+
},
|
|
131
|
+
]);
|
|
132
|
+
await page.goto('/data-api-live.e2e.html?initial=/field-records');
|
|
133
|
+
await expect(
|
|
134
|
+
page.getByText('当前平台用户无字段协议记录页面权限', { exact: true })
|
|
135
|
+
).toBeVisible();
|
|
136
|
+
await expect(page.locator('.oxa-list-surface')).toHaveCount(0);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import { ConfigProvider, Form, Typography } from 'antd';
|
|
2
|
+
import type { DataFieldSurface, DataFileRef } from 'openxiangda-contracts/browser';
|
|
3
|
+
import React from 'react';
|
|
4
|
+
import ReactDOM from 'react-dom/client';
|
|
5
|
+
import {
|
|
6
|
+
MobileSurfaceFieldControl,
|
|
7
|
+
SurfaceFieldControl,
|
|
8
|
+
SurfaceFieldValue,
|
|
9
|
+
type SurfaceField,
|
|
10
|
+
type SurfaceFieldRenderers,
|
|
11
|
+
} from '../src/components/resource/SurfaceFields';
|
|
12
|
+
import '../src/styles.css';
|
|
13
|
+
|
|
14
|
+
const options = [
|
|
15
|
+
{ label: '启用', value: 'enabled' },
|
|
16
|
+
{ label: '停用', value: 'disabled' },
|
|
17
|
+
];
|
|
18
|
+
const source = {
|
|
19
|
+
kind: 'resource' as const,
|
|
20
|
+
resourceCode: 'field-lookups',
|
|
21
|
+
labelField: 'name',
|
|
22
|
+
searchFields: ['name'],
|
|
23
|
+
snapshotFields: ['code'],
|
|
24
|
+
};
|
|
25
|
+
const field = (
|
|
26
|
+
key: string,
|
|
27
|
+
type: string,
|
|
28
|
+
widget: string,
|
|
29
|
+
extra: Record<string, unknown> = {}
|
|
30
|
+
) =>
|
|
31
|
+
({
|
|
32
|
+
key,
|
|
33
|
+
label: key,
|
|
34
|
+
type,
|
|
35
|
+
widget,
|
|
36
|
+
readCapabilities: [],
|
|
37
|
+
createCapabilities: [],
|
|
38
|
+
updateCapabilities: [],
|
|
39
|
+
...extra,
|
|
40
|
+
} as unknown as SurfaceField);
|
|
41
|
+
|
|
42
|
+
const fields = [
|
|
43
|
+
field('单行文本', 'text.short', 'text', { maxLength: 120 }),
|
|
44
|
+
field('邮箱', 'text.short', 'email'),
|
|
45
|
+
field('电话', 'text.short', 'phone'),
|
|
46
|
+
field('多行文本', 'text.long', 'textarea'),
|
|
47
|
+
field('富文本', 'text.rich', 'rich-text'),
|
|
48
|
+
field('整数', 'number.integer', 'number', { scale: 0 }),
|
|
49
|
+
field('小数', 'number.decimal', 'number', { scale: 2 }),
|
|
50
|
+
field('金额', 'number.decimal', 'money', { scale: 2 }),
|
|
51
|
+
field('百分比', 'number.decimal', 'percent', { scale: 2 }),
|
|
52
|
+
field('布尔', 'boolean', 'switch'),
|
|
53
|
+
field('日期', 'date', 'date'),
|
|
54
|
+
field('时间', 'time', 'time', { timePrecision: 'second' }),
|
|
55
|
+
field('日期时间', 'datetime', 'datetime'),
|
|
56
|
+
field('日期范围', 'date-range', 'date-range'),
|
|
57
|
+
field('日期时间范围', 'datetime-range', 'datetime-range'),
|
|
58
|
+
field('静态单选下拉', 'option.single', 'select', { options }),
|
|
59
|
+
field('静态多选下拉', 'option.multiple', 'multi-select', { options }),
|
|
60
|
+
field('单选按钮', 'option.single', 'radio', { options }),
|
|
61
|
+
field('复选框', 'option.multiple', 'checkbox', { options }),
|
|
62
|
+
field('动态单选下拉', 'resource-ref.single', 'select', { source }),
|
|
63
|
+
field('动态多选下拉', 'resource-ref.multiple', 'multi-select', { source }),
|
|
64
|
+
field('级联单选', 'cascade.single', 'cascade', {
|
|
65
|
+
options: [
|
|
66
|
+
{
|
|
67
|
+
label: '设备',
|
|
68
|
+
value: 'equipment',
|
|
69
|
+
children: [{ label: '显微镜', value: 'microscope' }],
|
|
70
|
+
},
|
|
71
|
+
],
|
|
72
|
+
}),
|
|
73
|
+
field('级联多选', 'cascade.multiple', 'cascade', {
|
|
74
|
+
options: [
|
|
75
|
+
{
|
|
76
|
+
label: '设备',
|
|
77
|
+
value: 'equipment',
|
|
78
|
+
children: [{ label: '显微镜', value: 'microscope' }],
|
|
79
|
+
},
|
|
80
|
+
],
|
|
81
|
+
}),
|
|
82
|
+
field('成员单选', 'user.single', 'directory-user'),
|
|
83
|
+
field('成员多选', 'user.multiple', 'directory-user'),
|
|
84
|
+
field('部门单选', 'department.single', 'directory-department'),
|
|
85
|
+
field('部门多选', 'department.multiple', 'directory-department'),
|
|
86
|
+
field('资源单选', 'resource-ref.single', 'resource', { source }),
|
|
87
|
+
field('资源多选', 'resource-ref.multiple', 'resource', { source }),
|
|
88
|
+
field('附件', 'file', 'attachment', {
|
|
89
|
+
accept: ['.pdf', 'text/plain'],
|
|
90
|
+
maxCount: 3,
|
|
91
|
+
maxSizeMb: 20,
|
|
92
|
+
}),
|
|
93
|
+
field('图片', 'image', 'image', {
|
|
94
|
+
accept: ['image/*'],
|
|
95
|
+
maxCount: 3,
|
|
96
|
+
maxSizeMb: 20,
|
|
97
|
+
}),
|
|
98
|
+
field('业务签名', 'signature', 'signature'),
|
|
99
|
+
field('地址', 'address', 'address'),
|
|
100
|
+
field('精确定位', 'location', 'location'),
|
|
101
|
+
field('UUID', 'uuid', 'readonly'),
|
|
102
|
+
field('JSON', 'json', 'json'),
|
|
103
|
+
field('流水号', 'serial-number', 'readonly'),
|
|
104
|
+
field('子表', 'subtable', 'subtable'),
|
|
105
|
+
] satisfies Array<DataFieldSurface & { key: string }>;
|
|
106
|
+
|
|
107
|
+
const user = { label: '张三', value: 'user-1', employeeNo: 'E1001' };
|
|
108
|
+
const department = {
|
|
109
|
+
label: '理学院',
|
|
110
|
+
value: 'college-a',
|
|
111
|
+
fullPath: '学校 / 理学院',
|
|
112
|
+
};
|
|
113
|
+
const reference = {
|
|
114
|
+
label: '显微镜',
|
|
115
|
+
value: 'resource-1',
|
|
116
|
+
resourceCode: 'field-lookups',
|
|
117
|
+
snapshot: { code: 'M-001' },
|
|
118
|
+
};
|
|
119
|
+
const fileRef: DataFileRef = {
|
|
120
|
+
id: '11111111-1111-4111-8111-111111111111',
|
|
121
|
+
name: '验收附件.pdf',
|
|
122
|
+
size: 1280,
|
|
123
|
+
contentType: 'application/pdf',
|
|
124
|
+
};
|
|
125
|
+
const values: Record<string, unknown> = {
|
|
126
|
+
单行文本: '仪器验收记录',
|
|
127
|
+
邮箱: 'user@example.com',
|
|
128
|
+
电话: '13800000000',
|
|
129
|
+
多行文本: '第一行\n第二行',
|
|
130
|
+
富文本: '<p>已保存的<strong>富文本</strong></p>',
|
|
131
|
+
整数: -7,
|
|
132
|
+
小数: 12.34,
|
|
133
|
+
金额: 5600.5,
|
|
134
|
+
百分比: 98.5,
|
|
135
|
+
布尔: true,
|
|
136
|
+
日期: '2026-08-24',
|
|
137
|
+
时间: '10:20:30',
|
|
138
|
+
日期时间: '2026-08-24T02:20:30.000Z',
|
|
139
|
+
日期范围: { start: '2026-08-01', end: '2026-08-31' },
|
|
140
|
+
日期时间范围: {
|
|
141
|
+
start: '2026-08-01T00:00:00.000Z',
|
|
142
|
+
end: '2026-08-31T23:59:59.000Z',
|
|
143
|
+
},
|
|
144
|
+
静态单选下拉: options[0],
|
|
145
|
+
静态多选下拉: options,
|
|
146
|
+
单选按钮: options[0],
|
|
147
|
+
复选框: options,
|
|
148
|
+
动态单选下拉: reference,
|
|
149
|
+
动态多选下拉: [reference],
|
|
150
|
+
级联单选: [
|
|
151
|
+
{ label: '设备', value: 'equipment' },
|
|
152
|
+
{ label: '显微镜', value: 'microscope' },
|
|
153
|
+
],
|
|
154
|
+
级联多选: [
|
|
155
|
+
[
|
|
156
|
+
{ label: '设备', value: 'equipment' },
|
|
157
|
+
{ label: '显微镜', value: 'microscope' },
|
|
158
|
+
],
|
|
159
|
+
],
|
|
160
|
+
成员单选: user,
|
|
161
|
+
成员多选: [user],
|
|
162
|
+
部门单选: department,
|
|
163
|
+
部门多选: [department],
|
|
164
|
+
资源单选: reference,
|
|
165
|
+
资源多选: [reference],
|
|
166
|
+
附件: [fileRef],
|
|
167
|
+
图片: [
|
|
168
|
+
{
|
|
169
|
+
...fileRef,
|
|
170
|
+
id: '22222222-2222-4222-8222-222222222222',
|
|
171
|
+
name: '验收图片.png',
|
|
172
|
+
contentType: 'image/png',
|
|
173
|
+
width: 640,
|
|
174
|
+
height: 480,
|
|
175
|
+
},
|
|
176
|
+
],
|
|
177
|
+
业务签名: {
|
|
178
|
+
file: {
|
|
179
|
+
...fileRef,
|
|
180
|
+
id: '33333333-3333-4333-8333-333333333333',
|
|
181
|
+
name: 'signature.png',
|
|
182
|
+
contentType: 'image/png',
|
|
183
|
+
},
|
|
184
|
+
signer: user,
|
|
185
|
+
signedAt: '2026-08-24T02:20:30.000Z',
|
|
186
|
+
hash: 'a'.repeat(64),
|
|
187
|
+
points: [{ x: 1, y: 1, t: 1 }],
|
|
188
|
+
},
|
|
189
|
+
地址: {
|
|
190
|
+
country: { label: '中国', value: 'CN' },
|
|
191
|
+
province: { label: '浙江省', value: '330000' },
|
|
192
|
+
city: { label: '杭州市', value: '330100' },
|
|
193
|
+
district: { label: '西湖区', value: '330106' },
|
|
194
|
+
detail: '文一路 1 号',
|
|
195
|
+
fullAddress: '浙江省杭州市西湖区文一路 1 号',
|
|
196
|
+
},
|
|
197
|
+
精确定位: {
|
|
198
|
+
source: 'browser',
|
|
199
|
+
longitude: 120.123456,
|
|
200
|
+
latitude: 30.234567,
|
|
201
|
+
accuracy: 5,
|
|
202
|
+
capturedAt: '2026-08-24T02:20:30.000Z',
|
|
203
|
+
},
|
|
204
|
+
UUID: '44444444-4444-4444-8444-444444444444',
|
|
205
|
+
JSON: { status: 'ok', nested: { count: 1 } },
|
|
206
|
+
流水号: 'FP-001000',
|
|
207
|
+
子表: [{ item_name: '第一行' }, { item_name: '第二行' }],
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
const renderers: SurfaceFieldRenderers = {
|
|
211
|
+
signer: user,
|
|
212
|
+
upload: async (_field, file) => ({
|
|
213
|
+
id: crypto.randomUUID(),
|
|
214
|
+
name: file.name,
|
|
215
|
+
size: file.size,
|
|
216
|
+
contentType: file.type,
|
|
217
|
+
}),
|
|
218
|
+
renderSubtable: () => (
|
|
219
|
+
<div className="field-protocol-subtable">2 行子表 · 新增 · 删除 · 排序</div>
|
|
220
|
+
),
|
|
221
|
+
renderValue: ({ field: item, value }) =>
|
|
222
|
+
item.type === 'subtable'
|
|
223
|
+
? `${Array.isArray(value) ? value.length : 0} 行子表`
|
|
224
|
+
: undefined,
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
function AcceptancePage() {
|
|
228
|
+
const mobile = new URLSearchParams(location.search).get('mode') === 'mobile';
|
|
229
|
+
const Control = mobile ? MobileSurfaceFieldControl : SurfaceFieldControl;
|
|
230
|
+
return (
|
|
231
|
+
<ConfigProvider theme={{ token: { borderRadius: 6 } }}>
|
|
232
|
+
<main className={`field-protocol-page ${mobile ? 'is-mobile' : 'is-desktop'}`}>
|
|
233
|
+
<header>
|
|
234
|
+
<Typography.Title level={mobile ? 4 : 2}>
|
|
235
|
+
Field Kit 协议验收
|
|
236
|
+
</Typography.Title>
|
|
237
|
+
<Typography.Text type="secondary">
|
|
238
|
+
{mobile ? '移动编辑态' : '桌面编辑态与已存快照只读态'}
|
|
239
|
+
</Typography.Text>
|
|
240
|
+
</header>
|
|
241
|
+
<section aria-label={mobile ? '移动编辑字段' : '桌面编辑字段'} className="field-protocol-edit">
|
|
242
|
+
<Form initialValues={values} layout="vertical">
|
|
243
|
+
<div className="field-protocol-grid">
|
|
244
|
+
{fields.map(item => (
|
|
245
|
+
<div data-field-code={item.key} key={item.key}>
|
|
246
|
+
<Control
|
|
247
|
+
disabled={false}
|
|
248
|
+
field={item}
|
|
249
|
+
operation="create"
|
|
250
|
+
renderers={renderers}
|
|
251
|
+
resourceCode="field-records"
|
|
252
|
+
/>
|
|
253
|
+
</div>
|
|
254
|
+
))}
|
|
255
|
+
</div>
|
|
256
|
+
</Form>
|
|
257
|
+
</section>
|
|
258
|
+
{!mobile && (
|
|
259
|
+
<section aria-label="已存快照只读字段" className="field-protocol-readonly">
|
|
260
|
+
<Typography.Title level={3}>已存快照</Typography.Title>
|
|
261
|
+
<div className="field-protocol-read-grid">
|
|
262
|
+
{fields.map(item => (
|
|
263
|
+
<div data-read-field={item.key} key={item.key}>
|
|
264
|
+
<strong>{item.label}</strong>
|
|
265
|
+
<SurfaceFieldValue field={item} renderers={renderers} value={values[item.key]} />
|
|
266
|
+
</div>
|
|
267
|
+
))}
|
|
268
|
+
</div>
|
|
269
|
+
</section>
|
|
270
|
+
)}
|
|
271
|
+
</main>
|
|
272
|
+
</ConfigProvider>
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
ReactDOM.createRoot(document.getElementById('root')!).render(
|
|
277
|
+
<React.StrictMode>
|
|
278
|
+
<AcceptancePage />
|
|
279
|
+
</React.StrictMode>
|
|
280
|
+
);
|