openxiangda 1.0.37 → 1.0.39
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 +2 -1
- package/lib/cli.js +135 -5
- package/openxiangda-skills/SKILL.md +2 -2
- package/openxiangda-skills/references/connector-resources.md +1 -1
- package/openxiangda-skills/references/data-views.md +130 -5
- package/openxiangda-skills/references/pages/page-sdk.md +62 -2
- package/openxiangda-skills/references/resource-manifest-cheatsheet.md +37 -1
- package/openxiangda-skills/skills/openxiangda-page/SKILL.md +1 -1
- package/package.json +1 -1
- package/packages/sdk/dist/runtime/index.cjs +151 -1
- package/packages/sdk/dist/runtime/index.cjs.map +1 -1
- package/packages/sdk/dist/runtime/index.d.mts +18 -1
- package/packages/sdk/dist/runtime/index.d.ts +18 -1
- package/packages/sdk/dist/runtime/index.mjs +151 -1
- package/packages/sdk/dist/runtime/index.mjs.map +1 -1
package/README.md
CHANGED
|
@@ -110,7 +110,7 @@ openxiangda workspace bind --profile dev --app-type APP_XXXX
|
|
|
110
110
|
|
|
111
111
|
工程化资源放在工作区 `src/resources/` 下,由 `openxiangda resource validate|plan|publish|pull` 管理。`workspace publish` 会先构建并注册 workspace 表单/页面,再执行非破坏性资源 upsert,这样菜单、权限组、流程和表单设置可以解析最新的 profile-local ID。需要删除平台中 manifest 未声明的资源时,显式传 `--prune`。连接器页面运行时通过 `sdk.connector.invoke()` / `sdk.connector.call("connector.api")` 调用平台运行时接口,第三方密钥只保存在后端连接器配置中。
|
|
112
112
|
|
|
113
|
-
|
|
113
|
+
多表只读查询和固定口径统计优先声明 `src/resources/data-views/*.json` 数据视图,而不是在页面里手写多次单表查询再拼数据。默认数据视图是行级联表视图,适合工单+客户、订单+商品、项目+成员、报表列表、跨页面复用查询等读多写少场景;`viewType: "aggregate"` 是统计聚合视图,适合按客户、状态、月份等维度预聚合 count/sum/avg/min/max。发布时 CLI 会把 `formCode` 解析为当前 profile 的 `formUuid`,平台创建 PostgreSQL materialized view;页面通过 `sdk.dataView.query(code, params)` 查询行级视图,通过 `sdk.dataView.stats(code, params)` 查询聚合视图,也可以用 `sdk.dataSource.run()` 路由 `dataView.query` / `dataView.stats`。发布前应为常用筛选、排序、统计维度和时间桶声明 `indexes`,并确认用户能接受的刷新延迟;默认不要设置低于 5 分钟的定时刷新。数据视图只读,刷新后才反映源表变化,不适合单表 CRUD、写回源表、强实时状态、临时 BI 查询或简单 linkedForm 下拉。
|
|
114
114
|
|
|
115
115
|
常用数据视图命令:
|
|
116
116
|
|
|
@@ -122,6 +122,7 @@ openxiangda data-view list --profile dev
|
|
|
122
122
|
openxiangda data-view status ticket_with_customer --profile dev
|
|
123
123
|
openxiangda data-view refresh ticket_with_customer --profile dev
|
|
124
124
|
openxiangda data-view query ticket_with_customer --query-json query.json --profile dev
|
|
125
|
+
openxiangda data-view stats ticket_stats_by_customer --query-json stats-query.json --profile dev
|
|
125
126
|
```
|
|
126
127
|
|
|
127
128
|
AI-authored automation can use code-first resources:
|
package/lib/cli.js
CHANGED
|
@@ -107,7 +107,7 @@ Usage:
|
|
|
107
107
|
openxiangda automation logs <instanceId> [--automation <automationCode|automationId>] [--summary] [--redact] [--json]
|
|
108
108
|
openxiangda automation diagnose <automationCode|automationId> [--redact] [--json]
|
|
109
109
|
openxiangda automation publish|enable|disable <automationCode|automationId>
|
|
110
|
-
openxiangda data-view list|status|refresh|query <dataViewCode> [--profile name] [--json]
|
|
110
|
+
openxiangda data-view list|status|refresh|query|stats <dataViewCode> [--profile name] [--json]
|
|
111
111
|
openxiangda permission role-list|role-create|role-bind
|
|
112
112
|
openxiangda permission page-group-list|page-group-create|page-group-bind
|
|
113
113
|
openxiangda permission form-group-list|form-group-create|form-group-bind
|
|
@@ -2052,7 +2052,24 @@ async function dataView(args) {
|
|
|
2052
2052
|
return;
|
|
2053
2053
|
}
|
|
2054
2054
|
|
|
2055
|
-
|
|
2055
|
+
if (subcommand === 'stats') {
|
|
2056
|
+
const [code] = positional;
|
|
2057
|
+
if (!code) fail('用法: openxiangda data-view stats <dataViewCode> [--query-json file]');
|
|
2058
|
+
const data = await requestWithAuth(
|
|
2059
|
+
config,
|
|
2060
|
+
target.profileName,
|
|
2061
|
+
`/openxiangda-api/v1/apps/${encodeURIComponent(target.appType)}/data-views/${encodeURIComponent(code)}/stats`,
|
|
2062
|
+
{
|
|
2063
|
+
method: 'POST',
|
|
2064
|
+
body: buildDataViewQueryBody(flags),
|
|
2065
|
+
}
|
|
2066
|
+
);
|
|
2067
|
+
if (flags.json) return writeJson(data);
|
|
2068
|
+
print(JSON.stringify(data, null, 2));
|
|
2069
|
+
return;
|
|
2070
|
+
}
|
|
2071
|
+
|
|
2072
|
+
fail('用法: openxiangda data-view list|status|refresh|query|stats');
|
|
2056
2073
|
}
|
|
2057
2074
|
|
|
2058
2075
|
async function permission(args) {
|
|
@@ -2675,7 +2692,7 @@ async function commands(args) {
|
|
|
2675
2692
|
'menu list|create|bind|delete',
|
|
2676
2693
|
'workflow list|create|bind|pull|publish|delete|validate',
|
|
2677
2694
|
'automation list|create|bind|pull|publish|unpublish|enable|disable|delete|validate|cron-validate',
|
|
2678
|
-
'data-view list|status|refresh|query',
|
|
2695
|
+
'data-view list|status|refresh|query|stats',
|
|
2679
2696
|
'permission role-list|role-create|role-bind|role-users|role-add-users',
|
|
2680
2697
|
'permission page-group-list|page-group-create|page-group-bind',
|
|
2681
2698
|
'permission form-group-list|form-group-create|form-group-bind|form-summary|menu-permissions',
|
|
@@ -3379,11 +3396,24 @@ function validateResourceItem(kind, item, errors, warnings) {
|
|
|
3379
3396
|
}
|
|
3380
3397
|
if (kind === 'dataViews') {
|
|
3381
3398
|
const definition = item.definition || item;
|
|
3399
|
+
const viewType = String(definition.viewType || 'row').toLowerCase();
|
|
3382
3400
|
if (!item.name && !definition.name) errors.push(`${label}: 缺少 name`);
|
|
3383
3401
|
if (!definition.base) errors.push(`${label}: 缺少 base`);
|
|
3384
|
-
if (
|
|
3385
|
-
|
|
3402
|
+
if (viewType === 'aggregate') {
|
|
3403
|
+
if (Array.isArray(definition.select) && definition.select.length > 0) {
|
|
3404
|
+
errors.push(`${label}: 聚合数据视图不能同时使用 select`);
|
|
3405
|
+
}
|
|
3406
|
+
if (!Array.isArray(definition.measures) || definition.measures.length === 0) {
|
|
3407
|
+
errors.push(`${label}: 缺少 measures`);
|
|
3408
|
+
}
|
|
3409
|
+
} else if (viewType === 'row') {
|
|
3410
|
+
if (!Array.isArray(definition.select) || definition.select.length === 0) {
|
|
3411
|
+
errors.push(`${label}: 缺少 select`);
|
|
3412
|
+
}
|
|
3413
|
+
} else {
|
|
3414
|
+
errors.push(`${label}: 不支持的 viewType: ${definition.viewType}`);
|
|
3386
3415
|
}
|
|
3416
|
+
validateDataViewPerformance(label, definition, errors, warnings);
|
|
3387
3417
|
}
|
|
3388
3418
|
if (kind === 'pagePermissionGroups' && !item.name) errors.push(`${label}: 缺少 name`);
|
|
3389
3419
|
if (kind === 'formPermissionGroups') {
|
|
@@ -3405,6 +3435,105 @@ function validateResourceItem(kind, item, errors, warnings) {
|
|
|
3405
3435
|
}
|
|
3406
3436
|
}
|
|
3407
3437
|
|
|
3438
|
+
function validateDataViewPerformance(label, definition, errors, warnings) {
|
|
3439
|
+
const viewType = String(definition.viewType || 'row').toLowerCase();
|
|
3440
|
+
if (viewType !== 'row' && viewType !== 'aggregate') return;
|
|
3441
|
+
const outputAliases = collectDataViewOutputAliases(definition, viewType);
|
|
3442
|
+
const outputAliasSet = new Set(outputAliases);
|
|
3443
|
+
const indexes = Array.isArray(definition.indexes)
|
|
3444
|
+
? definition.indexes.filter(index => Array.isArray(index.fields) && index.fields.length > 0)
|
|
3445
|
+
: [];
|
|
3446
|
+
|
|
3447
|
+
for (const index of indexes) {
|
|
3448
|
+
for (const field of index.fields || []) {
|
|
3449
|
+
if (!outputAliasSet.has(field)) {
|
|
3450
|
+
errors.push(`${label}: 索引字段不在输出字段中: ${field}`);
|
|
3451
|
+
}
|
|
3452
|
+
}
|
|
3453
|
+
}
|
|
3454
|
+
|
|
3455
|
+
if (!indexes.length) {
|
|
3456
|
+
warnings.push(
|
|
3457
|
+
viewType === 'aggregate'
|
|
3458
|
+
? `${label}: 聚合数据视图未声明 indexes;请优先索引常用维度、时间桶或排序字段,避免统计查询扫描物化视图`
|
|
3459
|
+
: `${label}: 未声明 indexes;请为常用 filters/order 字段声明索引,避免运行时查询扫描物化视图`
|
|
3460
|
+
);
|
|
3461
|
+
}
|
|
3462
|
+
|
|
3463
|
+
if (viewType === 'aggregate') {
|
|
3464
|
+
const dimensionAliases = new Set(
|
|
3465
|
+
(Array.isArray(definition.dimensions) ? definition.dimensions : [])
|
|
3466
|
+
.map(item => item && item.as)
|
|
3467
|
+
.filter(Boolean)
|
|
3468
|
+
);
|
|
3469
|
+
const indexesCoverDimension =
|
|
3470
|
+
dimensionAliases.size === 0 ||
|
|
3471
|
+
indexes.some(index => index.fields.some(field => dimensionAliases.has(field)));
|
|
3472
|
+
if (indexes.length && !indexesCoverDimension) {
|
|
3473
|
+
warnings.push(`${label}: 聚合数据视图 indexes 未覆盖任何 dimension;按维度筛选或排序时可能扫描物化视图`);
|
|
3474
|
+
}
|
|
3475
|
+
if ((definition.measures || []).some(isCountDistinctMeasure)) {
|
|
3476
|
+
warnings.push(`${label}: 聚合数据视图使用 countDistinct;高基数字段刷新成本较高,请确认数据量和刷新频率`);
|
|
3477
|
+
}
|
|
3478
|
+
}
|
|
3479
|
+
|
|
3480
|
+
const refresh = definition.refresh && typeof definition.refresh === 'object' ? definition.refresh : {};
|
|
3481
|
+
if (refresh.mode === 'scheduled') {
|
|
3482
|
+
const intervalMs = estimateCronIntervalMsFromExpression(refresh.cron);
|
|
3483
|
+
if (intervalMs !== null && intervalMs < 5 * 60 * 1000) {
|
|
3484
|
+
warnings.push(`${label}: 数据视图刷新间隔低于 5 分钟;请先确认业务时间敏感度,高频刷新会增加源表和物化视图压力`);
|
|
3485
|
+
}
|
|
3486
|
+
}
|
|
3487
|
+
}
|
|
3488
|
+
|
|
3489
|
+
function collectDataViewOutputAliases(definition, viewType) {
|
|
3490
|
+
if (viewType === 'aggregate') {
|
|
3491
|
+
return [
|
|
3492
|
+
...(Array.isArray(definition.dimensions) ? definition.dimensions : []),
|
|
3493
|
+
...(Array.isArray(definition.measures) ? definition.measures : []),
|
|
3494
|
+
]
|
|
3495
|
+
.map(item => item && item.as)
|
|
3496
|
+
.filter(Boolean);
|
|
3497
|
+
}
|
|
3498
|
+
return (Array.isArray(definition.select) ? definition.select : [])
|
|
3499
|
+
.map(item => item && item.as)
|
|
3500
|
+
.filter(Boolean);
|
|
3501
|
+
}
|
|
3502
|
+
|
|
3503
|
+
function isCountDistinctMeasure(measure) {
|
|
3504
|
+
const normalized = String(measure?.type || '')
|
|
3505
|
+
.toLowerCase()
|
|
3506
|
+
.replace(/[-_]/g, '');
|
|
3507
|
+
return normalized === 'countdistinct' || normalized === 'distinctcount';
|
|
3508
|
+
}
|
|
3509
|
+
|
|
3510
|
+
function estimateCronIntervalMsFromExpression(cron) {
|
|
3511
|
+
const parts = String(cron || '')
|
|
3512
|
+
.trim()
|
|
3513
|
+
.split(/\s+/)
|
|
3514
|
+
.filter(Boolean);
|
|
3515
|
+
if (parts.length < 5) return null;
|
|
3516
|
+
const hasSeconds = parts.length >= 6;
|
|
3517
|
+
const seconds = hasSeconds ? parts[0] : '0';
|
|
3518
|
+
const minutes = hasSeconds ? parts[1] : parts[0];
|
|
3519
|
+
if (seconds === '*') return 1000;
|
|
3520
|
+
const secondStep = parseCronStep(seconds);
|
|
3521
|
+
if (secondStep) return secondStep * 1000;
|
|
3522
|
+
if (seconds === '0') {
|
|
3523
|
+
if (minutes === '*') return 60 * 1000;
|
|
3524
|
+
const minuteStep = parseCronStep(minutes);
|
|
3525
|
+
if (minuteStep) return minuteStep * 60 * 1000;
|
|
3526
|
+
}
|
|
3527
|
+
return null;
|
|
3528
|
+
}
|
|
3529
|
+
|
|
3530
|
+
function parseCronStep(field) {
|
|
3531
|
+
const match = String(field || '').match(/^(?:\*|0)\/(\d+)$/);
|
|
3532
|
+
if (!match) return null;
|
|
3533
|
+
const value = Number(match[1]);
|
|
3534
|
+
return Number.isFinite(value) && value > 0 ? value : null;
|
|
3535
|
+
}
|
|
3536
|
+
|
|
3408
3537
|
const NOTIFICATION_CHANNELS = new Set([
|
|
3409
3538
|
'inapp',
|
|
3410
3539
|
'email',
|
|
@@ -5936,6 +6065,7 @@ function buildDataViewQueryBody(flags = {}) {
|
|
|
5936
6065
|
if (fields.length > 0) body.fields = fields;
|
|
5937
6066
|
if (flags['filters-json']) body.filters = readJsonArg(flags['filters-json'], 'filters-json');
|
|
5938
6067
|
if (flags['filter-json']) body.filters = readJsonArg(flags['filter-json'], 'filter-json');
|
|
6068
|
+
if (flags['having-json']) body.having = readJsonArg(flags['having-json'], 'having-json');
|
|
5939
6069
|
if (flags['condition-type']) body.conditionType = String(flags['condition-type']).toUpperCase();
|
|
5940
6070
|
if (flags.keyword || flags.search) body.searchKeyWord = flags.keyword || flags.search;
|
|
5941
6071
|
if (flags.page) body.currentPage = Number(flags.page);
|
|
@@ -28,7 +28,7 @@ For any user-facing page (form pages, workflow form pages, custom code pages), O
|
|
|
28
28
|
| 角色 / 权限组 / 字段权限 / 数据范围 / 公开访问 | `openxiangda-permission-settings` | `openxiangda permission ...` / `openxiangda settings ...` |
|
|
29
29
|
| 看应用结构 / 快照 / 对比 / 诊断 / 排查 / 报错 | `openxiangda-inspect` | `openxiangda app snapshot <APP_XXX> --profile <name> --json` |
|
|
30
30
|
| 登录 / 切换平台 / profile / token / whoami | `openxiangda-core` | `openxiangda env --profile <name>` / `openxiangda auth status` |
|
|
31
|
-
|
|
|
31
|
+
| 多表只读联表 / 固定口径统计 / 看板指标 | `openxiangda-form` (data view) | declare `src/resources/data-views/<code>.json` → `resource publish` |
|
|
32
32
|
| 调外部 / 第三方 API / 钉钉 / 自建系统 | `openxiangda-page` (connector) | declare `src/resources/connectors/<code>.json` → `sdk.connector.invoke` |
|
|
33
33
|
|
|
34
34
|
### Hard rules — always
|
|
@@ -129,7 +129,7 @@ When the user provides a root domain such as `https://yida.wisejob.cn/`, use it
|
|
|
129
129
|
- All visible copy in forms and pages must be written for end users. Do not put implementation notes, developer explanations, schema descriptions, or "this area is generated by..." text into page sections, cards, labels, tips, or empty states.
|
|
130
130
|
- Use logical resource codes in local files. Platform-specific IDs such as `formUuid`, `pageId`, `workflowId`, and `automationId` must be isolated by profile.
|
|
131
131
|
- Put engineering-managed resources in `src/resources/` and use `openxiangda resource validate|plan|publish|pull`. `workspace publish` publishes workspace forms/pages first, then runs non-destructive resource upsert. Only pass `--prune` when the user explicitly wants local manifests to delete platform-side extras.
|
|
132
|
-
- For repeated read-only multi-form queries, create a data view manifest in `src/resources/data-views
|
|
132
|
+
- For repeated read-only multi-form queries or predefined dashboard metrics, create a data view manifest in `src/resources/data-views/`. Use row views plus `sdk.dataView.query` for joined lists/lookups; use `viewType: "aggregate"` plus `sdk.dataView.stats` for count/sum/avg/min/max statistics. Before choosing scheduled refresh, confirm the user's freshness tolerance; default to manual or 10 minutes and above unless fresher data is explicitly required. Add `indexes` for common runtime filters/order fields, aggregate dimensions, and time buckets. Do not use data views for single-form CRUD, simple linkedForm selects, real-time writes, write-back, or ad-hoc BI. Read `references/data-views.md` before designing one.
|
|
133
133
|
- For external APIs, create a connector manifest in `src/resources/connectors/` and call it from pages through `sdk.connector`; never put third-party API keys in page source.
|
|
134
134
|
- Before scaffolding a real app, complex page, data management page, portal shell, status lifecycle, role governance, or automation, read `references/best-practices.md` and pick the architecture first. Prefer copying the relevant pattern from `examples/best-practices/` into `src/` instead of generating a single large page file.
|
|
135
135
|
- Before publishing to another platform, verify the workspace is bound for that profile. Resource IDs from one profile must not be reused for another profile.
|
|
@@ -28,7 +28,7 @@ Connector manifests use stable `code` values. The platform maps connector `code`
|
|
|
28
28
|
|
|
29
29
|
Notification manifests live under `src/resources/notifications/` and contain `templates` plus `typeConfigs`. See `notifications.md` before generating reminders or message templates.
|
|
30
30
|
|
|
31
|
-
Data view manifests live under `src/resources/data-views/` and define read-only materialized views for repeated multi-form joins. Use
|
|
31
|
+
Data view manifests live under `src/resources/data-views/` and define read-only materialized views for repeated multi-form joins or fixed aggregate statistics. Use row views with `sdk.dataView.query` for joined list/report/lookup sources, and aggregate views with `sdk.dataView.stats` for dashboard metrics where refresh lag is acceptable. Before generating one, confirm freshness tolerance and add indexes for common filters, sort fields, dimensions, and date buckets. Do not use them for single-form CRUD, simple linkedForm selects, real-time writes, write-back, or ad-hoc BI. See `data-views.md` before generating one.
|
|
32
32
|
|
|
33
33
|
```json
|
|
34
34
|
{
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
# Data View Resources
|
|
2
2
|
|
|
3
|
-
Data views are OpenXiangda-managed read-only PostgreSQL materialized views. They turn repeated multi-form joins into
|
|
3
|
+
Data views are OpenXiangda-managed read-only PostgreSQL materialized views. They turn repeated multi-form joins and predefined aggregate statistics into published resources under `src/resources/data-views/`.
|
|
4
4
|
|
|
5
5
|
Use a data view when the app needs read-only joined data from multiple forms, such as:
|
|
6
6
|
|
|
7
7
|
- Ticket list with customer name, owner, SLA, and status fields.
|
|
8
8
|
- Order list with product, customer, and payment fields.
|
|
9
9
|
- Project dashboard combining project, member, task, and risk forms.
|
|
10
|
+
- Dashboard statistics such as monthly ticket count, order amount by customer, or task count by status.
|
|
10
11
|
- Reusable lookup or report data consumed by several pages or automations.
|
|
11
12
|
- Large list pages where repeated client-side cross-form joins would be slow or inconsistent.
|
|
12
13
|
|
|
@@ -16,13 +17,18 @@ Do not use a data view for:
|
|
|
16
17
|
- Simple one-form dropdown options. Use `SelectField` with `optionSource.type: "linkedForm"`.
|
|
17
18
|
- Writes or write-back. Data views are read-only.
|
|
18
19
|
- Strong real-time views after every form update. Data views update after manual or scheduled refresh.
|
|
19
|
-
- Raw SQL, incremental refresh, source-table trigger refresh,
|
|
20
|
+
- Raw SQL, incremental refresh, source-table trigger refresh, ad-hoc BI/pivot/window analysis, or write-heavy realtime dashboards.
|
|
20
21
|
|
|
21
22
|
## Authoring
|
|
22
23
|
|
|
23
24
|
Place manifests in `src/resources/data-views/*.json`. Use logical `formCode` values in source files. The CLI resolves them to the current profile's `formUuid` during `resource publish`.
|
|
24
25
|
|
|
25
|
-
|
|
26
|
+
Two view types are supported:
|
|
27
|
+
|
|
28
|
+
- `viewType: "row"` or omitted: row-level joined materialized view using `select`.
|
|
29
|
+
- `viewType: "aggregate"`: grouped statistics materialized view using `dimensions` and `measures`; query it with `stats`.
|
|
30
|
+
|
|
31
|
+
Row view example:
|
|
26
32
|
|
|
27
33
|
```json
|
|
28
34
|
{
|
|
@@ -61,13 +67,67 @@ Minimal example:
|
|
|
61
67
|
}
|
|
62
68
|
```
|
|
63
69
|
|
|
70
|
+
Aggregate statistics example:
|
|
71
|
+
|
|
72
|
+
```json
|
|
73
|
+
{
|
|
74
|
+
"code": "ticket_stats_by_customer",
|
|
75
|
+
"name": "Ticket Stats By Customer",
|
|
76
|
+
"viewType": "aggregate",
|
|
77
|
+
"base": { "formCode": "service_ticket", "alias": "ticket" },
|
|
78
|
+
"joins": [
|
|
79
|
+
{
|
|
80
|
+
"type": "left",
|
|
81
|
+
"formCode": "customer",
|
|
82
|
+
"alias": "customer",
|
|
83
|
+
"on": [
|
|
84
|
+
{
|
|
85
|
+
"left": "ticket.customer.value",
|
|
86
|
+
"op": "=",
|
|
87
|
+
"right": "customer.form_instance_id"
|
|
88
|
+
}
|
|
89
|
+
]
|
|
90
|
+
}
|
|
91
|
+
],
|
|
92
|
+
"dimensions": [
|
|
93
|
+
{ "field": "customer.name", "as": "customerName" },
|
|
94
|
+
{ "field": "ticket.created_at", "as": "createdMonth", "bucket": "month" }
|
|
95
|
+
],
|
|
96
|
+
"measures": [
|
|
97
|
+
{ "type": "count", "as": "ticketCount" },
|
|
98
|
+
{ "type": "sum", "field": "ticket.amount", "as": "totalAmount" },
|
|
99
|
+
{ "type": "avg", "field": "ticket.amount", "as": "avgAmount" }
|
|
100
|
+
],
|
|
101
|
+
"having": { "field": "ticketCount", "op": ">", "value": 0 },
|
|
102
|
+
"indexes": [{ "fields": ["customerName", "createdMonth"] }],
|
|
103
|
+
"refresh": { "mode": "scheduled", "cron": "0 */10 * * * *" },
|
|
104
|
+
"permissionGroups": [
|
|
105
|
+
{
|
|
106
|
+
"code": "ticket_stats_query",
|
|
107
|
+
"name": "Ticket Stats Query",
|
|
108
|
+
"roles": ["manager"],
|
|
109
|
+
"operations": ["query"]
|
|
110
|
+
}
|
|
111
|
+
]
|
|
112
|
+
}
|
|
113
|
+
```
|
|
114
|
+
|
|
64
115
|
Field reference rules:
|
|
65
116
|
|
|
66
117
|
- Use `alias.field`, for example `ticket.title`.
|
|
67
118
|
- Use system fields directly, such as `form_instance_id`, `created_at`, `updated_at`, `created_by`, and `tenant_id`.
|
|
68
119
|
- For option-like JSON fields that store `{ label, value }`, use `.value` for joins and `.label` for display when needed.
|
|
69
120
|
- Every `select` item must have an explicit output alias in `as`.
|
|
70
|
-
-
|
|
121
|
+
- Aggregate `dimensions` and `measures` also need explicit output aliases in `as`.
|
|
122
|
+
- Runtime `fields`, `filters`, `having`, `order`, indexes, field permissions, and row permissions reference output aliases, not source field references.
|
|
123
|
+
|
|
124
|
+
Aggregate rules:
|
|
125
|
+
|
|
126
|
+
- Measures support `count`, `countDistinct`, `sum`, `avg`, `min`, and `max`.
|
|
127
|
+
- `sum` and `avg` only support `NumberField`.
|
|
128
|
+
- Date buckets support `hour`, `day`, `week`, `month`, `quarter`, and `year`, and only apply to `DateField`, `created_at`, and `updated_at`.
|
|
129
|
+
- `where` filters source rows before grouping; `having` filters aggregate output aliases after grouping.
|
|
130
|
+
- Use aggregate views for stable dashboard/query shapes. They are not an ad-hoc BI query engine.
|
|
71
131
|
|
|
72
132
|
Join rules:
|
|
73
133
|
|
|
@@ -79,6 +139,7 @@ Filters:
|
|
|
79
139
|
|
|
80
140
|
- Definition `where` filters source rows before materialization and uses source references such as `ticket.status`.
|
|
81
141
|
- Runtime query filters use output aliases such as `ticketTitle`.
|
|
142
|
+
- Aggregate definition `having` and runtime stats `having` also use output aliases such as `ticketCount`.
|
|
82
143
|
- Supported operators include `=`, `!=`, `<>`, `>`, `>=`, `<`, `<=`, `contains`, `notContains`, `in`, `isEmpty`, and `isNotEmpty`, with aliases such as `eq`, `neq`, `gte`, `lte`, `like`, `is_null`, and `is_not_null`.
|
|
83
144
|
|
|
84
145
|
Refresh:
|
|
@@ -86,12 +147,45 @@ Refresh:
|
|
|
86
147
|
- `manual` means refresh only when an administrator runs refresh or the resource is recreated.
|
|
87
148
|
- `scheduled` uses cron, for example `0 */10 * * * *`.
|
|
88
149
|
- Query results may be stale. Show or inspect `lastRefreshedAt` when freshness matters.
|
|
150
|
+
- Do not choose a high-frequency schedule by default. Ask or infer how stale the data may be before setting `scheduled`.
|
|
89
151
|
|
|
90
152
|
Indexes:
|
|
91
153
|
|
|
92
154
|
- Index output aliases that pages filter or sort by.
|
|
155
|
+
- Row views should index stable IDs and common list filters/sort fields.
|
|
156
|
+
- Aggregate views should index dimensions and date buckets used by charts, filters, drill-downs, or ordering.
|
|
157
|
+
- Do not index every output field or every measure; extra indexes make refresh slower.
|
|
93
158
|
- Use `unique: true` only when the output field combination is truly unique.
|
|
94
159
|
|
|
160
|
+
## Performance And Freshness
|
|
161
|
+
|
|
162
|
+
Data views move expensive joins and aggregations from page runtime to materialized-view refresh time. They reduce repeated page pressure only when the view shape, refresh cadence, and indexes are chosen deliberately.
|
|
163
|
+
|
|
164
|
+
Before creating a data view, confirm these points:
|
|
165
|
+
|
|
166
|
+
- **Freshness tolerance**: ask whether users need near-real-time data, 10-30 minute freshness, hourly/daily reports, or manual snapshots.
|
|
167
|
+
- **Query shape**: list the fields users will filter, sort, group, or drill down by. These should become output aliases and usually indexes.
|
|
168
|
+
- **Data scope**: if the business only needs active, recent, or in-scope records, put that rule in definition `where` so old/source-irrelevant rows are filtered before materialization.
|
|
169
|
+
- **Visible fields**: output only fields the page or permission rules need. Use hidden scope aliases for data permissions when needed, but do not build one huge catch-all view.
|
|
170
|
+
|
|
171
|
+
Refresh cadence guidance:
|
|
172
|
+
|
|
173
|
+
- Use `manual` for admin diagnostics, imported snapshots, low-change reference data, or data that is refreshed after an intentional operation.
|
|
174
|
+
- Use `scheduled` every 10-30 minutes for normal dashboards, joined report lists, and recurring management views.
|
|
175
|
+
- Use hourly or daily schedules for leadership reports, historical statistics, and large aggregate views.
|
|
176
|
+
- Avoid intervals below 5 minutes unless the user explicitly confirms the time sensitivity and expected source-table volume. High-frequency refreshes can pressure both source tables and the materialized view.
|
|
177
|
+
- For strong real-time behavior after each form save, do not use a data view; query the source form directly or design a different backend workflow.
|
|
178
|
+
|
|
179
|
+
Index guidance:
|
|
180
|
+
|
|
181
|
+
- Index only materialized-view output aliases, not source references such as `customer.name`.
|
|
182
|
+
- For row views, index IDs used in drill-down (`ticketId`, `customerId`) and common filters/order fields (`statusValue`, `ownerId`, `createdAt`).
|
|
183
|
+
- For aggregate views, index the dimensions/time buckets used in runtime `filters`, `having` drill-downs, and `order` (`customerId`, `statusValue`, `createdMonth`).
|
|
184
|
+
- Keep compound indexes aligned with common query prefixes. For example, dashboard filters by customer then month should use `["customerId", "createdMonth"]`.
|
|
185
|
+
- `searchKeyWord` across many text columns is inherently heavier than structured filters. Prefer explicit `filters` on indexed aliases.
|
|
186
|
+
- `countDistinct` and high-cardinality dimensions are refresh-heavy. Confirm data volume and avoid very frequent schedules.
|
|
187
|
+
- Show `lastRefreshedAt` on pages or in diagnostics when users may notice refresh delay.
|
|
188
|
+
|
|
95
189
|
## Permissions
|
|
96
190
|
|
|
97
191
|
Management APIs require `app:data-view:manage`.
|
|
@@ -105,6 +199,7 @@ Permission group behavior:
|
|
|
105
199
|
- Missing or empty `fieldPermissions` means all output fields are visible.
|
|
106
200
|
- When multiple permission groups match, field permissions are most permissive: a field is visible if any matched group allows it.
|
|
107
201
|
- `dataPermission` is a row condition over output aliases.
|
|
202
|
+
- Hidden output aliases can still be used by `dataPermission`; this is useful for scope keys that should restrict rows but not be returned to pages.
|
|
108
203
|
- Multiple matched row conditions are ORed.
|
|
109
204
|
- If any matched group has no row condition, rows are unrestricted.
|
|
110
205
|
|
|
@@ -127,6 +222,8 @@ openxiangda data-view status ticket_with_customer --profile dev
|
|
|
127
222
|
openxiangda data-view refresh ticket_with_customer --profile dev
|
|
128
223
|
openxiangda data-view query ticket_with_customer --profile dev --fields ticketId,customerName
|
|
129
224
|
openxiangda data-view query ticket_with_customer --profile dev --query-json query.json
|
|
225
|
+
openxiangda data-view stats ticket_stats_by_customer --profile dev --fields customerName,ticketCount
|
|
226
|
+
openxiangda data-view stats ticket_stats_by_customer --profile dev --query-json stats-query.json
|
|
130
227
|
```
|
|
131
228
|
|
|
132
229
|
## Runtime SDK
|
|
@@ -145,6 +242,23 @@ const response = await sdk.dataView.query("ticket_with_customer", {
|
|
|
145
242
|
})
|
|
146
243
|
```
|
|
147
244
|
|
|
245
|
+
Aggregate stats query:
|
|
246
|
+
|
|
247
|
+
```ts
|
|
248
|
+
const response = await sdk.dataView.stats("ticket_stats_by_customer", {
|
|
249
|
+
fields: ["customerName", "createdMonth", "ticketCount", "totalAmount"],
|
|
250
|
+
filters: [
|
|
251
|
+
{ field: "customerName", operator: "contains", value: keyword },
|
|
252
|
+
],
|
|
253
|
+
having: [
|
|
254
|
+
{ field: "ticketCount", operator: ">", value: 0 },
|
|
255
|
+
],
|
|
256
|
+
order: [{ field: "ticketCount", isAsc: "n" }],
|
|
257
|
+
currentPage: 1,
|
|
258
|
+
pageSize: 20,
|
|
259
|
+
})
|
|
260
|
+
```
|
|
261
|
+
|
|
148
262
|
The response data is:
|
|
149
263
|
|
|
150
264
|
```ts
|
|
@@ -170,6 +284,15 @@ export default {
|
|
|
170
284
|
defaultFilter: [
|
|
171
285
|
{ field: "ticketTitle", operator: "isNotEmpty" }
|
|
172
286
|
]
|
|
287
|
+
},
|
|
288
|
+
{
|
|
289
|
+
key: "ticketStats",
|
|
290
|
+
type: "dataView.stats",
|
|
291
|
+
code: "ticket_stats_by_customer",
|
|
292
|
+
fields: ["customerName", "ticketCount"],
|
|
293
|
+
defaultHaving: [
|
|
294
|
+
{ field: "ticketCount", operator: ">", value: 0 }
|
|
295
|
+
]
|
|
173
296
|
}
|
|
174
297
|
]
|
|
175
298
|
}
|
|
@@ -193,7 +316,8 @@ List page:
|
|
|
193
316
|
|
|
194
317
|
Dashboard:
|
|
195
318
|
|
|
196
|
-
- Use
|
|
319
|
+
- Use aggregate data views for stable metrics such as counts, totals, averages, status distribution, and time buckets.
|
|
320
|
+
- Use row data views for drill-down lists behind those metrics.
|
|
197
321
|
- Keep refresh scheduled.
|
|
198
322
|
- Show `lastRefreshedAt` when business users care about freshness.
|
|
199
323
|
|
|
@@ -206,6 +330,7 @@ Reusable lookup:
|
|
|
206
330
|
Automation diagnosis:
|
|
207
331
|
|
|
208
332
|
- Query the data view with `openxiangda data-view query --query-json`.
|
|
333
|
+
- Query aggregate views with `openxiangda data-view stats --query-json`.
|
|
209
334
|
- Refresh manually after bulk imports or test data resets.
|
|
210
335
|
|
|
211
336
|
## Troubleshooting
|
|
@@ -6,11 +6,15 @@ Guidelines:
|
|
|
6
6
|
|
|
7
7
|
- Do not hardcode `/openxiangda-api` calls inside end-user page components unless the page is explicitly an admin tool.
|
|
8
8
|
- Prefer SDK modules for form data, user context, permissions, and platform navigation.
|
|
9
|
-
- Use `sdk.dataView.query` for published read-only multi-form data views.
|
|
9
|
+
- Use `sdk.dataView.query` for published read-only multi-form row data views. Use `sdk.dataView.stats` for aggregate data views declared with `viewType: "aggregate"`.
|
|
10
|
+
- Data view runtime queries can filter, sort, paginate, and select only output aliases.
|
|
11
|
+
- Keep data view page calls paginated and field-scoped. Runtime filters/order should use aliases that the data view indexes, especially for aggregate dimensions and date buckets.
|
|
10
12
|
- Use `sdk.dataSource.run()` with a page data source descriptor when the page config should own the data view code, default fields, or default filters.
|
|
11
13
|
- Use `sdk.connector.invoke`, `sdk.connector.call("connector.api")`, or `sdk.connector.download` for external services. The SDK calls the platform runtime connector endpoint; it must not call third-party domains directly.
|
|
12
14
|
- Use `sdk.notification.sendByType` and `batchSendByType` for reusable business messages. Custom notification types must be declared in `src/resources/notifications/` and published with `openxiangda resource publish`.
|
|
13
15
|
- For the current user's department hierarchy, use `sdk.department.getCurrentUserParentDepartments()`; do not hardcode `GET /department/:id/parentDepartments` in page code.
|
|
16
|
+
- Use `sdk.auth.logoutAndRedirect({ loginUrl })` for user logout when the page should return after login. It calls the platform logout endpoint, appends the current page URL as `callback`, and redirects to the login URL. Use `sdk.auth.logout()` only when the page wants to handle redirect itself.
|
|
17
|
+
- Use `sdk.role.getMyRoles()`, `sdk.role.getCurrentRole()`, and `sdk.role.switchAppRole()` for current-user app role switching. Pass `roleId: ""` to switch back to all app roles.
|
|
14
18
|
- Keep API calls behind small local functions so generated UI stays testable.
|
|
15
19
|
- Treat user context and tenant context as runtime-provided values.
|
|
16
20
|
|
|
@@ -30,6 +34,23 @@ const response = await sdk.dataView.query("ticket_with_customer", {
|
|
|
30
34
|
})
|
|
31
35
|
```
|
|
32
36
|
|
|
37
|
+
Data view stats:
|
|
38
|
+
|
|
39
|
+
```ts
|
|
40
|
+
const response = await sdk.dataView.stats("ticket_stats_by_customer", {
|
|
41
|
+
fields: ["customerName", "ticketCount", "totalAmount"],
|
|
42
|
+
filters: [
|
|
43
|
+
{ field: "customerName", operator: "contains", value: keyword },
|
|
44
|
+
],
|
|
45
|
+
having: [
|
|
46
|
+
{ field: "ticketCount", operator: ">", value: 0 },
|
|
47
|
+
],
|
|
48
|
+
order: [{ field: "ticketCount", isAsc: "n" }],
|
|
49
|
+
currentPage: 1,
|
|
50
|
+
pageSize: 20,
|
|
51
|
+
})
|
|
52
|
+
```
|
|
53
|
+
|
|
33
54
|
Data view data source descriptor:
|
|
34
55
|
|
|
35
56
|
```ts
|
|
@@ -43,6 +64,15 @@ export default {
|
|
|
43
64
|
defaultFilter: [
|
|
44
65
|
{ field: "ticketTitle", operator: "isNotEmpty" }
|
|
45
66
|
]
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
key: "ticketStats",
|
|
70
|
+
type: "dataView.stats",
|
|
71
|
+
code: "ticket_stats_by_customer",
|
|
72
|
+
fields: ["customerName", "ticketCount"],
|
|
73
|
+
defaultHaving: [
|
|
74
|
+
{ field: "ticketCount", operator: ">", value: 0 }
|
|
75
|
+
]
|
|
46
76
|
}
|
|
47
77
|
]
|
|
48
78
|
}
|
|
@@ -55,4 +85,34 @@ const response = await sdk.dataSource.run("tickets", {
|
|
|
55
85
|
})
|
|
56
86
|
```
|
|
57
87
|
|
|
58
|
-
Data view filters and fields use output aliases such as `customerName`, not source references such as `customer.name`. If the page only needs one form, prefer `sdk.form.advancedSearch`. If the page only needs a simple one-form dropdown, prefer linkedForm options.
|
|
88
|
+
Data view filters, having, and fields use output aliases such as `customerName` or `ticketCount`, not source references such as `customer.name`. If the page only needs one form, prefer `sdk.form.advancedSearch`. If the page only needs a simple one-form dropdown, prefer linkedForm options.
|
|
89
|
+
|
|
90
|
+
Logout and current-user role switching:
|
|
91
|
+
|
|
92
|
+
```ts
|
|
93
|
+
await sdk.auth.logoutAndRedirect({
|
|
94
|
+
loginUrl: "/login",
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
await sdk.auth.logoutAndRedirect({
|
|
98
|
+
loginUrl: "/login",
|
|
99
|
+
callbackParamName: "redirect",
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
await sdk.auth.logout()
|
|
103
|
+
|
|
104
|
+
const roles = await sdk.role.getMyRoles({ scope: "app" })
|
|
105
|
+
const currentRole = await sdk.role.getCurrentRole({ scope: "app" })
|
|
106
|
+
|
|
107
|
+
await sdk.role.switchAppRole({
|
|
108
|
+
roleId: roles.result?.[0]?.id || "",
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
await sdk.role.switchAppRole({
|
|
112
|
+
roleId: "",
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
await sdk.role.switchPlatformRole({
|
|
116
|
+
roleId: "platform-admin",
|
|
117
|
+
})
|
|
118
|
+
```
|
|
@@ -61,6 +61,14 @@ const data = await sdk.connector.call("crm.getCustomer", { body: { keyword } });
|
|
|
61
61
|
|
|
62
62
|
## 2. Data View — `src/resources/data-views/<code>.json`
|
|
63
63
|
|
|
64
|
+
选择规则:
|
|
65
|
+
|
|
66
|
+
- 多表只读联表列表 / lookup / 报表明细:用行级 data view,页面调用 `sdk.dataView.query`。
|
|
67
|
+
- 固定口径统计 / 看板指标 / 图表数据源:用 `viewType: "aggregate"`,页面调用 `sdk.dataView.stats`。
|
|
68
|
+
- ECharts 或自定义 dashboard 只负责展示;统计口径稳定时,数据源优先沉淀成 aggregate data view。
|
|
69
|
+
- 发布前为常用筛选、排序、维度和时间桶声明 `indexes`,并确认刷新延迟是否能被业务接受。
|
|
70
|
+
- 不用于单表 CRUD、简单 `linkedForm` 下拉、强实时、写回或临时 BI 查询。
|
|
71
|
+
|
|
64
72
|
```json
|
|
65
73
|
{
|
|
66
74
|
"code": "ticket_with_customer",
|
|
@@ -101,7 +109,35 @@ const list = await sdk.dataView.query("ticket_with_customer", {
|
|
|
101
109
|
});
|
|
102
110
|
```
|
|
103
111
|
|
|
104
|
-
|
|
112
|
+
统计聚合视图:
|
|
113
|
+
|
|
114
|
+
```json
|
|
115
|
+
{
|
|
116
|
+
"code": "ticket_stats_by_customer",
|
|
117
|
+
"name": "Ticket Stats By Customer",
|
|
118
|
+
"viewType": "aggregate",
|
|
119
|
+
"base": { "formCode": "service_ticket", "alias": "ticket" },
|
|
120
|
+
"dimensions": [
|
|
121
|
+
{ "field": "ticket.customer.value", "as": "customerId" },
|
|
122
|
+
{ "field": "ticket.created_at", "as": "createdMonth", "bucket": "month" }
|
|
123
|
+
],
|
|
124
|
+
"measures": [
|
|
125
|
+
{ "type": "count", "as": "ticketCount" },
|
|
126
|
+
{ "type": "sum", "field": "ticket.amount", "as": "totalAmount" }
|
|
127
|
+
],
|
|
128
|
+
"having": { "field": "ticketCount", "op": ">", "value": 0 },
|
|
129
|
+
"indexes": [{ "fields": ["customerId", "createdMonth"] }]
|
|
130
|
+
}
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
```ts
|
|
134
|
+
const stats = await sdk.dataView.stats("ticket_stats_by_customer", {
|
|
135
|
+
fields: ["customerId", "createdMonth", "ticketCount", "totalAmount"],
|
|
136
|
+
having: [{ field: "ticketCount", op: ">", value: 0 }],
|
|
137
|
+
});
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
性能要点:索引只能引用输出 alias;行级视图索引常用 filter/order 字段;聚合视图索引 dimensions/date buckets;`countDistinct` 和高基数维度刷新成本较高;低于 5 分钟的 scheduled refresh 需要用户明确确认时间敏感度。完整规则见 [`data-views.md`](data-views.md)。
|
|
105
141
|
|
|
106
142
|
## 3. Notification — `src/resources/notifications/<code>.json`
|
|
107
143
|
|
|
@@ -33,7 +33,7 @@ openxiangda workspace publish --profile <name> --page <pageCode>
|
|
|
33
33
|
- ✅ Default to **native Tailwind utilities** for new pages (`bg-white`, `border-slate-200`, `grid-cols-[240px_1fr]`, ...) and `cssIsolation: "none"`. Keep namespace/shadow only for explicit legacy compatibility.
|
|
34
34
|
- ✅ Split complex pages into `domain/`, `shared/services/`, `shared/hooks/`, `components/`, route/config files, `styles.css` (see `references/best-practices.md` and `references/architecture-patterns.md`).
|
|
35
35
|
- ✅ List/detail/CRUD pages: follow `DataManagementList` pattern from `references/architecture-patterns.md`. Pagination + structured `filterGroup` + `OR`. Never fetch huge `pageSize` and filter in browser.
|
|
36
|
-
- ✅ For external APIs use `src/resources/connectors/<code>.json` + `sdk.connector.invoke()`; for joined read-only
|
|
36
|
+
- ✅ For external APIs use `src/resources/connectors/<code>.json` + `sdk.connector.invoke()`; for joined read-only lists use row data views + `sdk.dataView.query`; for stable dashboard metrics use aggregate data views + `sdk.dataView.stats`.
|
|
37
37
|
- ❌ Single-file giant pages. Split per `references/best-practices.md`.
|
|
38
38
|
- ❌ Hardcoded `/view/...&isRenderNav=false` URLs scattered through page code; use the runtime navigation helper.
|
|
39
39
|
- ❌ shadcn token classes like `bg-card` / `text-muted-foreground` / `text-foreground` unless explicitly configured.
|