openxiangda 1.0.22 → 1.0.25
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 +28 -10
- package/lib/cli.js +351 -20
- package/lib/workspace-init.js +13 -0
- package/openxiangda-skills/SKILL.md +26 -10
- package/openxiangda-skills/references/architecture-patterns.md +44 -22
- package/openxiangda-skills/references/best-practices.md +180 -0
- package/openxiangda-skills/references/component-guide.md +34 -8
- package/openxiangda-skills/references/pages/publish-flow.md +26 -0
- package/openxiangda-skills/references/pages/workspace-structure.md +5 -3
- package/openxiangda-skills/references/workspace-state.md +6 -0
- package/openxiangda-skills/skills/openxiangda-app/SKILL.md +12 -7
- package/openxiangda-skills/skills/openxiangda-core/SKILL.md +34 -4
- package/openxiangda-skills/skills/openxiangda-form/SKILL.md +13 -1
- package/openxiangda-skills/skills/openxiangda-page/SKILL.md +22 -1
- package/openxiangda-skills/skills/openxiangda-permission-settings/SKILL.md +3 -0
- package/openxiangda-skills/skills/openxiangda-workflow-automation/SKILL.md +7 -0
- package/package.json +1 -1
- package/packages/sdk/src/build-source/scripts/publish-all.mjs +44 -5
- package/packages/sdk/src/build-source/scripts/utils/incremental.mjs +95 -0
- package/packages/sdk/src/build-source/scripts/utils/incremental.test.ts +62 -0
- package/templates/sy-lowcode-app-workspace/examples/best-practices/README.md +32 -0
- package/templates/sy-lowcode-app-workspace/examples/best-practices/catalog.json +61 -0
- package/templates/sy-lowcode-app-workspace/examples/best-practices/decision-guide.md +44 -0
- package/templates/sy-lowcode-app-workspace/examples/best-practices/design-style.md +36 -0
- package/templates/sy-lowcode-app-workspace/examples/best-practices/module-structure.md +48 -0
- package/templates/sy-lowcode-app-workspace/examples/best-practices/src/domain/role-governance/index.ts +2 -0
- package/templates/sy-lowcode-app-workspace/examples/best-practices/src/domain/role-governance/permissions.test.ts +35 -0
- package/templates/sy-lowcode-app-workspace/examples/best-practices/src/domain/role-governance/permissions.ts +24 -0
- package/templates/sy-lowcode-app-workspace/examples/best-practices/src/domain/role-governance/types.ts +17 -0
- package/templates/sy-lowcode-app-workspace/examples/best-practices/src/domain/service-ticket/index.ts +4 -0
- package/templates/sy-lowcode-app-workspace/examples/best-practices/src/domain/service-ticket/permissions.test.ts +42 -0
- package/templates/sy-lowcode-app-workspace/examples/best-practices/src/domain/service-ticket/permissions.ts +23 -0
- package/templates/sy-lowcode-app-workspace/examples/best-practices/src/domain/service-ticket/state-machine.test.ts +63 -0
- package/templates/sy-lowcode-app-workspace/examples/best-practices/src/domain/service-ticket/state-machine.ts +73 -0
- package/templates/sy-lowcode-app-workspace/examples/best-practices/src/domain/service-ticket/ticket-query.test.ts +34 -0
- package/templates/sy-lowcode-app-workspace/examples/best-practices/src/domain/service-ticket/ticket-query.ts +73 -0
- package/templates/sy-lowcode-app-workspace/examples/best-practices/src/domain/service-ticket/types.ts +64 -0
- package/templates/sy-lowcode-app-workspace/examples/best-practices/src/forms/app-role/page.tsx +1 -0
- package/templates/sy-lowcode-app-workspace/examples/best-practices/src/forms/app-role/schema.ts +57 -0
- package/templates/sy-lowcode-app-workspace/examples/best-practices/src/forms/customer-profile/page.tsx +1 -0
- package/templates/sy-lowcode-app-workspace/examples/best-practices/src/forms/customer-profile/schema.ts +83 -0
- package/templates/sy-lowcode-app-workspace/examples/best-practices/src/forms/service-ticket/page.tsx +1 -0
- package/templates/sy-lowcode-app-workspace/examples/best-practices/src/forms/service-ticket/schema.ts +97 -0
- package/templates/sy-lowcode-app-workspace/examples/best-practices/src/forms/ticket-action-log/page.tsx +1 -0
- package/templates/sy-lowcode-app-workspace/examples/best-practices/src/forms/ticket-action-log/schema.ts +65 -0
- package/templates/sy-lowcode-app-workspace/examples/best-practices/src/js-code-nodes/daily_ticket_digest/index.ts +44 -0
- package/templates/sy-lowcode-app-workspace/examples/best-practices/src/js-code-nodes/sync_roles_to_platform/index.ts +33 -0
- package/templates/sy-lowcode-app-workspace/examples/best-practices/src/pages/interactive-workbench/App.tsx +7 -0
- package/templates/sy-lowcode-app-workspace/examples/best-practices/src/pages/interactive-workbench/WorkbenchPage.tsx +36 -0
- package/templates/sy-lowcode-app-workspace/examples/best-practices/src/pages/interactive-workbench/components/ConfigPanel.tsx +34 -0
- package/templates/sy-lowcode-app-workspace/examples/best-practices/src/pages/interactive-workbench/components/PreviewPanel.tsx +17 -0
- package/templates/sy-lowcode-app-workspace/examples/best-practices/src/pages/interactive-workbench/index.tsx +10 -0
- package/templates/sy-lowcode-app-workspace/examples/best-practices/src/pages/interactive-workbench/page.config.ts +9 -0
- package/templates/sy-lowcode-app-workspace/examples/best-practices/src/pages/interactive-workbench/reducer.ts +29 -0
- package/templates/sy-lowcode-app-workspace/examples/best-practices/src/pages/interactive-workbench/styles.css +24 -0
- package/templates/sy-lowcode-app-workspace/examples/best-practices/src/pages/mobile-portal-shell/App.tsx +7 -0
- package/templates/sy-lowcode-app-workspace/examples/best-practices/src/pages/mobile-portal-shell/MobilePortalShell.tsx +31 -0
- package/templates/sy-lowcode-app-workspace/examples/best-practices/src/pages/mobile-portal-shell/index.tsx +10 -0
- package/templates/sy-lowcode-app-workspace/examples/best-practices/src/pages/mobile-portal-shell/modules/MobileHome.tsx +13 -0
- package/templates/sy-lowcode-app-workspace/examples/best-practices/src/pages/mobile-portal-shell/page.config.ts +14 -0
- package/templates/sy-lowcode-app-workspace/examples/best-practices/src/pages/mobile-portal-shell/routes.ts +13 -0
- package/templates/sy-lowcode-app-workspace/examples/best-practices/src/pages/mobile-portal-shell/styles.css +11 -0
- package/templates/sy-lowcode-app-workspace/examples/best-practices/src/pages/pc-portal-shell/App.tsx +7 -0
- package/templates/sy-lowcode-app-workspace/examples/best-practices/src/pages/pc-portal-shell/PcPortalShell.tsx +35 -0
- package/templates/sy-lowcode-app-workspace/examples/best-practices/src/pages/pc-portal-shell/components/PortalMetric.tsx +11 -0
- package/templates/sy-lowcode-app-workspace/examples/best-practices/src/pages/pc-portal-shell/index.tsx +10 -0
- package/templates/sy-lowcode-app-workspace/examples/best-practices/src/pages/pc-portal-shell/modules/HomeModule.tsx +25 -0
- package/templates/sy-lowcode-app-workspace/examples/best-practices/src/pages/pc-portal-shell/modules/TicketsModule.tsx +14 -0
- package/templates/sy-lowcode-app-workspace/examples/best-practices/src/pages/pc-portal-shell/page.config.ts +14 -0
- package/templates/sy-lowcode-app-workspace/examples/best-practices/src/pages/pc-portal-shell/routes.ts +19 -0
- package/templates/sy-lowcode-app-workspace/examples/best-practices/src/pages/pc-portal-shell/styles.css +35 -0
- package/templates/sy-lowcode-app-workspace/examples/best-practices/src/pages/service-ticket-ops/App.tsx +7 -0
- package/templates/sy-lowcode-app-workspace/examples/best-practices/src/pages/service-ticket-ops/TicketOpsPage.tsx +105 -0
- package/templates/sy-lowcode-app-workspace/examples/best-practices/src/pages/service-ticket-ops/components/TicketActionTimeline.tsx +22 -0
- package/templates/sy-lowcode-app-workspace/examples/best-practices/src/pages/service-ticket-ops/components/TicketDetailDrawer.tsx +41 -0
- package/templates/sy-lowcode-app-workspace/examples/best-practices/src/pages/service-ticket-ops/components/TicketTableActions.tsx +55 -0
- package/templates/sy-lowcode-app-workspace/examples/best-practices/src/pages/service-ticket-ops/index.tsx +10 -0
- package/templates/sy-lowcode-app-workspace/examples/best-practices/src/pages/service-ticket-ops/page.config.ts +9 -0
- package/templates/sy-lowcode-app-workspace/examples/best-practices/src/pages/service-ticket-ops/styles.css +35 -0
- package/templates/sy-lowcode-app-workspace/examples/best-practices/src/resources/automations/daily-ticket-digest/automation.json +25 -0
- package/templates/sy-lowcode-app-workspace/examples/best-practices/src/resources/automations/daily-ticket-digest/trigger.json +9 -0
- package/templates/sy-lowcode-app-workspace/examples/best-practices/src/resources/notifications/daily-ticket-digest.json +24 -0
- package/templates/sy-lowcode-app-workspace/examples/best-practices/src/resources/permissions/form-groups/service-ticket-college.json +21 -0
- package/templates/sy-lowcode-app-workspace/examples/best-practices/src/resources/permissions/roles.json +17 -0
- package/templates/sy-lowcode-app-workspace/examples/best-practices/src/resources/workflows/expense-approval-workflow.json +48 -0
- package/templates/sy-lowcode-app-workspace/examples/best-practices/src/shared/components/ConfirmAction.tsx +22 -0
- package/templates/sy-lowcode-app-workspace/examples/best-practices/src/shared/components/QueryState.tsx +37 -0
- package/templates/sy-lowcode-app-workspace/examples/best-practices/src/shared/components/StatusTag.tsx +20 -0
- package/templates/sy-lowcode-app-workspace/examples/best-practices/src/shared/hooks/useTicketOps.ts +96 -0
- package/templates/sy-lowcode-app-workspace/examples/best-practices/src/shared/services/role-governance.ts +48 -0
- package/templates/sy-lowcode-app-workspace/examples/best-practices/src/shared/services/service-ticket.ts +113 -0
- package/templates/sy-lowcode-app-workspace/package.json +2 -0
- package/templates/sy-lowcode-app-workspace/src/dev/App.tsx +11 -1
- package/templates/sy-lowcode-app-workspace/tsconfig.examples.json +24 -0
|
@@ -34,6 +34,8 @@ Use role codes in permission group JSON. Bind existing role IDs only inside the
|
|
|
34
34
|
openxiangda permission role-bind sales --role-id <id> --profile dev
|
|
35
35
|
```
|
|
36
36
|
|
|
37
|
+
For dynamic multi-role apps, do not hardcode role behavior only in page code. Create a role maintenance form, sync it to platform roles with automation / JS_CODE, and add redundant ownership fields to business forms (`collegeId`, `classId`, `ownerDeptId`, `ownerUserId`, `roleCode`). Use page permission groups for entry visibility and form permission groups with condition-based data permissions for real data isolation.
|
|
38
|
+
|
|
37
39
|
## Page Permission Groups
|
|
38
40
|
|
|
39
41
|
Page permission groups control menu/page visibility:
|
|
@@ -92,5 +94,6 @@ Do not store platform-specific public access IDs locally. The CLI resolves by `a
|
|
|
92
94
|
## References
|
|
93
95
|
|
|
94
96
|
- Permission model and examples: `../../references/permissions-settings.md`
|
|
97
|
+
- Dynamic role governance and data-isolation pattern: `../../references/best-practices.md`
|
|
95
98
|
- Profile-isolated IDs: `../../references/workspace-state.md`
|
|
96
99
|
- API fields: `../../references/openxiangda-api.md`
|
|
@@ -7,6 +7,12 @@ description: Build, validate, publish, enable, and inspect OpenXiangda workflow
|
|
|
7
7
|
|
|
8
8
|
Use this skill when the user asks for approval workflows, workflow forms, automation rules, scheduled jobs, form-event triggers, or process-triggered automation in OpenXiangda.
|
|
9
9
|
|
|
10
|
+
## Architecture Boundary
|
|
11
|
+
|
|
12
|
+
Most business lifecycle flows are not workflows. For ordinary status changes such as `pending -> processing -> resolved -> closed`, use a normal form, a `status` field, redundant responsibility fields, an action-log form, a domain state machine, a service method, and optional automation / JS_CODE.
|
|
13
|
+
|
|
14
|
+
Create workflow definitions only when the scenario has real approval semantics: approvers, approval tasks, agree/reject actions, approval opinions, node-level permissions, process records, or approval-node side effects.
|
|
15
|
+
|
|
10
16
|
## Required Context
|
|
11
17
|
|
|
12
18
|
Before any write:
|
|
@@ -101,6 +107,7 @@ Use `automation disable` before risky edits. Published automations create a draf
|
|
|
101
107
|
|
|
102
108
|
## References
|
|
103
109
|
|
|
110
|
+
- Best-practice architecture and status-flow boundary: `../../references/best-practices.md`
|
|
104
111
|
- Workflow v3 JSON: `../../references/workflow-v3.md`
|
|
105
112
|
- Automation v3 JSON and triggers: `../../references/automation-v3.md`
|
|
106
113
|
- Notification resources and runtime calls: `../../references/notifications.md`
|
package/package.json
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
normalizeOnly,
|
|
18
18
|
planIncrementalPublish,
|
|
19
19
|
printPlan,
|
|
20
|
+
resolveGitChangedWorkspaceTargets,
|
|
20
21
|
} from "./utils/incremental.mjs";
|
|
21
22
|
|
|
22
23
|
const require = createRequire(import.meta.url);
|
|
@@ -36,11 +37,8 @@ const dryRun = Boolean(args["dry-run"]);
|
|
|
36
37
|
const targetForm = args.form || "";
|
|
37
38
|
const targetPage = args.page || "";
|
|
38
39
|
const force = Boolean(args.force);
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
: targetPage
|
|
42
|
-
? [`pages/${targetPage}`]
|
|
43
|
-
: normalizeOnly(args.only);
|
|
40
|
+
const changedOnly = Boolean(args.changed || args["changed-only"]);
|
|
41
|
+
const changedSince = args.since || "HEAD";
|
|
44
42
|
|
|
45
43
|
if (args.help || args.h) {
|
|
46
44
|
console.log(`
|
|
@@ -54,6 +52,8 @@ publish-all - 同步、构建、上传并注册应用工作区产物
|
|
|
54
52
|
--form <name> 只发布指定表单
|
|
55
53
|
--page <name> 只发布指定代码页
|
|
56
54
|
--only <list> 只发布指定模块,如 forms/customer,pages/dashboard
|
|
55
|
+
--changed 只发布 git 变更触达的 src/forms/* 和 src/pages/*
|
|
56
|
+
--since <ref> 配合 --changed 使用,默认 HEAD
|
|
57
57
|
--force 忽略增量缓存,强制发布
|
|
58
58
|
--help, -h 显示帮助信息
|
|
59
59
|
`);
|
|
@@ -64,6 +64,45 @@ if (targetForm && targetPage) {
|
|
|
64
64
|
console.error("❌ --form 和 --page 不能同时使用,请分两次发布");
|
|
65
65
|
process.exit(1);
|
|
66
66
|
}
|
|
67
|
+
if (changedOnly && (targetForm || targetPage || args.only)) {
|
|
68
|
+
console.error("❌ --changed 不能与 --form、--page 或 --only 同时使用");
|
|
69
|
+
process.exit(1);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const changedTargets = changedOnly
|
|
73
|
+
? resolveGitChangedWorkspaceTargets({ since: changedSince })
|
|
74
|
+
: null;
|
|
75
|
+
if (changedTargets && !changedTargets.available) {
|
|
76
|
+
console.error(`❌ 无法读取 git 变更: ${changedTargets.error}`);
|
|
77
|
+
process.exit(1);
|
|
78
|
+
}
|
|
79
|
+
if (changedTargets?.globalFiles?.length) {
|
|
80
|
+
console.warn(
|
|
81
|
+
`[publish] --changed 检测到 shared/config 变更: ${changedTargets.globalFiles.join(", ")}`,
|
|
82
|
+
);
|
|
83
|
+
console.warn(
|
|
84
|
+
"[publish] 将只发布直接变更的 forms/pages;若 shared 改动影响其他模块,请显式使用 --only 或不传 --changed。",
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
if (changedTargets?.resourceFiles?.length) {
|
|
88
|
+
console.warn(
|
|
89
|
+
`[publish] --changed 检测到资源变更: ${changedTargets.resourceFiles.join(", ")}`,
|
|
90
|
+
);
|
|
91
|
+
console.warn("[publish] publish-all 只处理 forms/pages;资源请通过 openxiangda resource plan|publish 处理。");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const only = changedOnly
|
|
95
|
+
? changedTargets.only
|
|
96
|
+
: targetForm
|
|
97
|
+
? [`forms/${targetForm}`]
|
|
98
|
+
: targetPage
|
|
99
|
+
? [`pages/${targetPage}`]
|
|
100
|
+
: normalizeOnly(args.only);
|
|
101
|
+
|
|
102
|
+
if (changedOnly && only.length === 0) {
|
|
103
|
+
console.log("✅ 没有检测到 src/forms 或 src/pages 的 git 变更,跳过发布");
|
|
104
|
+
process.exit(0);
|
|
105
|
+
}
|
|
67
106
|
|
|
68
107
|
const buildId = process.env.APP_BUILD_ID || createBuildId();
|
|
69
108
|
const childEnv = {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
2
3
|
import fs from "node:fs";
|
|
3
4
|
import path from "node:path";
|
|
4
5
|
import { rootDir } from "./load-config.mjs";
|
|
@@ -124,6 +125,100 @@ export function normalizeOnly(value) {
|
|
|
124
125
|
.filter(Boolean);
|
|
125
126
|
}
|
|
126
127
|
|
|
128
|
+
function runGit(args) {
|
|
129
|
+
const result = spawnSync("git", args, {
|
|
130
|
+
cwd: rootDir,
|
|
131
|
+
encoding: "utf-8",
|
|
132
|
+
});
|
|
133
|
+
if (result.error || result.status !== 0) {
|
|
134
|
+
return {
|
|
135
|
+
ok: false,
|
|
136
|
+
error: result.error?.message || result.stderr || result.stdout || "git command failed",
|
|
137
|
+
stdout: "",
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
return {
|
|
141
|
+
ok: true,
|
|
142
|
+
error: "",
|
|
143
|
+
stdout: result.stdout || "",
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function uniqueSorted(values) {
|
|
148
|
+
return [...new Set(values)].sort((left, right) => left.localeCompare(right));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function parseGitNameOnly(stdout) {
|
|
152
|
+
return String(stdout || "")
|
|
153
|
+
.split(/\r?\n/)
|
|
154
|
+
.map((item) => item.trim().replace(/\\/g, "/"))
|
|
155
|
+
.filter(Boolean);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function workspaceTargetForFile(filePath) {
|
|
159
|
+
const matched = filePath.match(/^src\/(forms|pages)\/([^/]+)\//);
|
|
160
|
+
if (!matched) return null;
|
|
161
|
+
return `${matched[1]}/${matched[2]}`;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function isGlobalWorkspaceFile(filePath) {
|
|
165
|
+
return (
|
|
166
|
+
filePath === "package.json" ||
|
|
167
|
+
filePath === "pnpm-lock.yaml" ||
|
|
168
|
+
filePath === "package-lock.json" ||
|
|
169
|
+
filePath === "yarn.lock" ||
|
|
170
|
+
filePath === "app-workspace.config.ts" ||
|
|
171
|
+
filePath === "tailwind.config.cjs" ||
|
|
172
|
+
filePath === "postcss.config.cjs" ||
|
|
173
|
+
filePath === "vite.config.ts" ||
|
|
174
|
+
filePath === "tsconfig.json" ||
|
|
175
|
+
filePath === "src/index.css" ||
|
|
176
|
+
filePath.startsWith("src/shared/")
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function resolveGitChangedWorkspaceTargets(options = {}) {
|
|
181
|
+
const since = options.since || "HEAD";
|
|
182
|
+
const insideWorkTree = runGit(["rev-parse", "--is-inside-work-tree"]);
|
|
183
|
+
if (!insideWorkTree.ok || insideWorkTree.stdout.trim() !== "true") {
|
|
184
|
+
return {
|
|
185
|
+
available: false,
|
|
186
|
+
error: insideWorkTree.error || "not a git worktree",
|
|
187
|
+
files: [],
|
|
188
|
+
only: [],
|
|
189
|
+
resourceFiles: [],
|
|
190
|
+
globalFiles: [],
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const changed = runGit(["diff", "--name-only", "--relative", since, "--"]);
|
|
195
|
+
if (!changed.ok) {
|
|
196
|
+
return {
|
|
197
|
+
available: false,
|
|
198
|
+
error: changed.error,
|
|
199
|
+
files: [],
|
|
200
|
+
only: [],
|
|
201
|
+
resourceFiles: [],
|
|
202
|
+
globalFiles: [],
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const untracked = runGit(["ls-files", "--others", "--exclude-standard"]);
|
|
207
|
+
const files = uniqueSorted([
|
|
208
|
+
...parseGitNameOnly(changed.stdout),
|
|
209
|
+
...(untracked.ok ? parseGitNameOnly(untracked.stdout) : []),
|
|
210
|
+
]);
|
|
211
|
+
|
|
212
|
+
return {
|
|
213
|
+
available: true,
|
|
214
|
+
error: "",
|
|
215
|
+
files,
|
|
216
|
+
only: uniqueSorted(files.map(workspaceTargetForFile).filter(Boolean)),
|
|
217
|
+
resourceFiles: files.filter((item) => item.startsWith("src/resources/")),
|
|
218
|
+
globalFiles: files.filter(isGlobalWorkspaceFile),
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
127
222
|
function createIncrementalPlan(modules, options = {}, cache = null) {
|
|
128
223
|
const only = normalizeOnly(options.only);
|
|
129
224
|
const selected = only.length
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
|
+
import { spawnSync } from "node:child_process";
|
|
4
5
|
|
|
5
6
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
7
|
|
|
@@ -31,6 +32,24 @@ function createWorkspace() {
|
|
|
31
32
|
return dir;
|
|
32
33
|
}
|
|
33
34
|
|
|
35
|
+
function runGit(workspaceRoot: string, args: string[]) {
|
|
36
|
+
const result = spawnSync("git", args, {
|
|
37
|
+
cwd: workspaceRoot,
|
|
38
|
+
encoding: "utf-8",
|
|
39
|
+
});
|
|
40
|
+
if (result.status !== 0) {
|
|
41
|
+
throw new Error(`git ${args.join(" ")} failed: ${result.stderr || result.stdout}`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function commitWorkspace(workspaceRoot: string) {
|
|
46
|
+
runGit(workspaceRoot, ["init"]);
|
|
47
|
+
runGit(workspaceRoot, ["config", "user.email", "test@example.com"]);
|
|
48
|
+
runGit(workspaceRoot, ["config", "user.name", "Test User"]);
|
|
49
|
+
runGit(workspaceRoot, ["add", "."]);
|
|
50
|
+
runGit(workspaceRoot, ["commit", "-m", "initial"]);
|
|
51
|
+
}
|
|
52
|
+
|
|
34
53
|
async function loadIncremental(rootDir: string) {
|
|
35
54
|
const previousRoot = process.env.LOWCODE_WORKSPACE_ROOT;
|
|
36
55
|
process.env.LOWCODE_WORKSPACE_ROOT = rootDir;
|
|
@@ -75,4 +94,47 @@ describe("incremental publish cache", () => {
|
|
|
75
94
|
|
|
76
95
|
expect(incremental.planIncrementalPublish(modules).changed).toHaveLength(0);
|
|
77
96
|
});
|
|
97
|
+
|
|
98
|
+
it("resolves git changed files to workspace publish targets", async () => {
|
|
99
|
+
const workspaceRoot = createWorkspace();
|
|
100
|
+
commitWorkspace(workspaceRoot);
|
|
101
|
+
|
|
102
|
+
fs.writeFileSync(
|
|
103
|
+
path.join(workspaceRoot, "src", "pages", "dashboard", "index.tsx"),
|
|
104
|
+
"export default function Dashboard() { return 'changed'; }\n",
|
|
105
|
+
"utf-8",
|
|
106
|
+
);
|
|
107
|
+
fs.mkdirSync(path.join(workspaceRoot, "src", "forms", "customer"), {
|
|
108
|
+
recursive: true,
|
|
109
|
+
});
|
|
110
|
+
fs.writeFileSync(
|
|
111
|
+
path.join(workspaceRoot, "src", "forms", "customer", "schema.ts"),
|
|
112
|
+
"export default { code: 'customer' };\n",
|
|
113
|
+
"utf-8",
|
|
114
|
+
);
|
|
115
|
+
fs.mkdirSync(path.join(workspaceRoot, "src", "resources"), {
|
|
116
|
+
recursive: true,
|
|
117
|
+
});
|
|
118
|
+
fs.writeFileSync(
|
|
119
|
+
path.join(workspaceRoot, "src", "resources", "menus.json"),
|
|
120
|
+
"[]\n",
|
|
121
|
+
"utf-8",
|
|
122
|
+
);
|
|
123
|
+
fs.mkdirSync(path.join(workspaceRoot, "src", "shared"), {
|
|
124
|
+
recursive: true,
|
|
125
|
+
});
|
|
126
|
+
fs.writeFileSync(
|
|
127
|
+
path.join(workspaceRoot, "src", "shared", "format.ts"),
|
|
128
|
+
"export const format = String;\n",
|
|
129
|
+
"utf-8",
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
const incremental = await loadIncremental(workspaceRoot);
|
|
133
|
+
const changed = incremental.resolveGitChangedWorkspaceTargets();
|
|
134
|
+
|
|
135
|
+
expect(changed.available).toBe(true);
|
|
136
|
+
expect(changed.only).toEqual(["forms/customer", "pages/dashboard"]);
|
|
137
|
+
expect(changed.resourceFiles).toEqual(["src/resources/menus.json"]);
|
|
138
|
+
expect(changed.globalFiles).toEqual(["src/shared/format.ts"]);
|
|
139
|
+
});
|
|
78
140
|
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# OpenXiangda Best Practices
|
|
2
|
+
|
|
3
|
+
These examples are intentionally placed under `examples/best-practices/`.
|
|
4
|
+
They are copied by `workspace init`, but they are not scanned by
|
|
5
|
+
`lowcode-workspace build` or `openxiangda workspace publish`.
|
|
6
|
+
|
|
7
|
+
Copy the pieces you need into `src/`:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
cp -R examples/best-practices/src/domain/service-ticket src/domain/
|
|
11
|
+
cp -R examples/best-practices/src/shared/services/service-ticket.ts src/shared/services/
|
|
12
|
+
cp -R examples/best-practices/src/pages/service-ticket-ops src/pages/
|
|
13
|
+
cp -R examples/best-practices/src/forms/service-ticket src/forms/
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Run `pnpm examples:check` after editing examples, and run the normal
|
|
17
|
+
`pnpm check` after copying code into `src/`.
|
|
18
|
+
|
|
19
|
+
## Core Rules
|
|
20
|
+
|
|
21
|
+
- Keep view code thin. Pages compose components, hooks, and services.
|
|
22
|
+
- Put business rules in `domain/`; keep it free of React, SDK, and UI imports.
|
|
23
|
+
- Put platform calls in `shared/services/`.
|
|
24
|
+
- Put reusable loading, empty, error, status, and confirmation UI in
|
|
25
|
+
`shared/components/`.
|
|
26
|
+
- Use state fields and operation logs for business lifecycle flows.
|
|
27
|
+
- Use workflow only for real approval tasks.
|
|
28
|
+
- Query with pagination and structured conditions. Avoid large page sizes,
|
|
29
|
+
client-side filtering, and broad `searchKeyWord` queries.
|
|
30
|
+
|
|
31
|
+
Read `decision-guide.md`, `module-structure.md`, and `design-style.md` before
|
|
32
|
+
using any template.
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 1,
|
|
3
|
+
"publishedByDefault": false,
|
|
4
|
+
"copyInto": "src/",
|
|
5
|
+
"templates": [
|
|
6
|
+
{
|
|
7
|
+
"code": "customer-profile",
|
|
8
|
+
"type": "form",
|
|
9
|
+
"path": "src/forms/customer-profile",
|
|
10
|
+
"description": "Standard form schema covering field types, validation, users, departments, attachments, and subforms."
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
"code": "service-ticket-lifecycle",
|
|
14
|
+
"type": "state-flow",
|
|
15
|
+
"path": "src/domain/service-ticket",
|
|
16
|
+
"description": "Lifecycle state machine with ticket and action-log forms. Does not use workflow."
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"code": "service-ticket-ops",
|
|
20
|
+
"type": "page",
|
|
21
|
+
"path": "src/pages/service-ticket-ops",
|
|
22
|
+
"description": "DataManagementList-based ops page with modular actions, drawer, timeline, hook, and service."
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"code": "role-governance",
|
|
26
|
+
"type": "permissions",
|
|
27
|
+
"path": "src/domain/role-governance",
|
|
28
|
+
"description": "Dynamic role maintenance, role sync, redundant ownership fields, and permission-group resource examples."
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"code": "pc-portal-shell",
|
|
32
|
+
"type": "app-shell-page",
|
|
33
|
+
"path": "src/pages/pc-portal-shell",
|
|
34
|
+
"description": "PC app-shell entry with routes, modules, shared domain usage, and no hardcoded view URLs."
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"code": "mobile-portal-shell",
|
|
38
|
+
"type": "app-shell-page",
|
|
39
|
+
"path": "src/pages/mobile-portal-shell",
|
|
40
|
+
"description": "Mobile app-shell entry sharing domain/service logic with the PC portal."
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
"code": "interactive-workbench",
|
|
44
|
+
"type": "page",
|
|
45
|
+
"path": "src/pages/interactive-workbench",
|
|
46
|
+
"description": "Pure interaction workbench with reducer state, modular panels, and unified query states."
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
"code": "expense-approval-workflow",
|
|
50
|
+
"type": "workflow",
|
|
51
|
+
"path": "src/resources/workflows/expense-approval-workflow.json",
|
|
52
|
+
"description": "True approval workflow example. Use only when approval tasks are required."
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
"code": "daily-ticket-digest",
|
|
56
|
+
"type": "automation",
|
|
57
|
+
"path": "src/resources/automations/daily-ticket-digest",
|
|
58
|
+
"description": "Scheduled JS_CODE automation that queries paginated data and sends notification resources."
|
|
59
|
+
}
|
|
60
|
+
]
|
|
61
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# Decision Guide
|
|
2
|
+
|
|
3
|
+
## Page And Data Shape
|
|
4
|
+
|
|
5
|
+
Use a form when you need a data table. Each form schema defines fields,
|
|
6
|
+
validation, storage shape, and generated platform data APIs.
|
|
7
|
+
|
|
8
|
+
Use a code page when you need a workbench, portal, dashboard, cross-form
|
|
9
|
+
composition, custom list actions, or complex interaction.
|
|
10
|
+
|
|
11
|
+
Use `DataManagementList` for list/search/export/batch-management pages. Extend
|
|
12
|
+
it with row actions, custom renderers, and drawers instead of rebuilding
|
|
13
|
+
pagination, export, and filters from scratch.
|
|
14
|
+
|
|
15
|
+
## State Flow Is Not Workflow
|
|
16
|
+
|
|
17
|
+
Most business "processes" are lifecycle state changes:
|
|
18
|
+
|
|
19
|
+
- work tickets: new -> accepted -> processing -> resolved -> closed
|
|
20
|
+
- orders: draft -> submitted -> paid -> fulfilled -> completed
|
|
21
|
+
- assets: available -> reserved -> in_use -> maintenance -> retired
|
|
22
|
+
|
|
23
|
+
Use a normal form with a `status` field, ownership fields, and an operation-log
|
|
24
|
+
form. Define allowed transitions in `domain/<feature>/state-machine.ts`, and
|
|
25
|
+
execute changes through a service method that updates state and writes logs.
|
|
26
|
+
|
|
27
|
+
Use workflow only when the platform must create approval tasks with approvers,
|
|
28
|
+
approval comments, agree/reject actions, copy nodes, node field permissions, and
|
|
29
|
+
approval history.
|
|
30
|
+
|
|
31
|
+
## Automation And JS_CODE
|
|
32
|
+
|
|
33
|
+
Use automation or workflow JS_CODE when logic must run on the backend after a
|
|
34
|
+
trigger: scheduled scans, cross-form synchronization, notification fan-out,
|
|
35
|
+
external HTTP calls, or platform role synchronization.
|
|
36
|
+
|
|
37
|
+
Frontend code must not be responsible for data isolation or background jobs.
|
|
38
|
+
|
|
39
|
+
## Permissions
|
|
40
|
+
|
|
41
|
+
For dynamic multi-role apps, create a business role table and synchronize it to
|
|
42
|
+
platform app roles. Add redundant ownership fields to business forms, such as
|
|
43
|
+
`collegeId`, `classId`, `ownerDeptId`, and `ownerUserId`. Use form permission
|
|
44
|
+
groups with condition-style data permissions to enforce data isolation.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Design Style
|
|
2
|
+
|
|
3
|
+
OpenXiangda business apps should feel like focused operational tools.
|
|
4
|
+
|
|
5
|
+
## Interaction Defaults
|
|
6
|
+
|
|
7
|
+
- Every async page has loading, refreshing, empty, error, and submit-pending
|
|
8
|
+
states.
|
|
9
|
+
- List refresh should keep existing rows visible and show a small refresh state.
|
|
10
|
+
- Mutating actions need confirmation, pending feedback, success feedback, and a
|
|
11
|
+
failure refresh or rollback path.
|
|
12
|
+
- Mobile actions should use a bottom action area, drawer, or action sheet; do
|
|
13
|
+
not copy a dense PC table toolbar to mobile.
|
|
14
|
+
|
|
15
|
+
## Layout Defaults
|
|
16
|
+
|
|
17
|
+
- Use dense but readable workbench layouts for admin and ops pages.
|
|
18
|
+
- Put high-value filters near the list, not in hidden configuration pages.
|
|
19
|
+
- Use status tags, responsibility fields, and latest action summaries for
|
|
20
|
+
lifecycle data.
|
|
21
|
+
- Do not build marketing-style hero pages for business tools.
|
|
22
|
+
|
|
23
|
+
## Styling Rules
|
|
24
|
+
|
|
25
|
+
- Use platform CSS variables, Tailwind semantic classes, Ant Design, and
|
|
26
|
+
antd-mobile.
|
|
27
|
+
- Use mature packages for mature interactions: antd controls instead of native
|
|
28
|
+
inputs, ECharts for charts, GSAP for complex animation timelines, and
|
|
29
|
+
maintained drag/drop or virtual-list libraries when those behaviors are
|
|
30
|
+
required.
|
|
31
|
+
- Research package docs and current maintenance before adding a new dependency;
|
|
32
|
+
write business adapters instead of copying library internals.
|
|
33
|
+
- Keep page styles in `styles.css`; avoid large inline style objects.
|
|
34
|
+
- Keep reusable visual states in shared components: status tags, query states,
|
|
35
|
+
confirmation triggers, and operation timelines.
|
|
36
|
+
- Do not override private Ant Design class names.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# Module Structure
|
|
2
|
+
|
|
3
|
+
Use this structure for substantial features:
|
|
4
|
+
|
|
5
|
+
```text
|
|
6
|
+
src/
|
|
7
|
+
domain/<feature>/
|
|
8
|
+
types.ts
|
|
9
|
+
state-machine.ts
|
|
10
|
+
permissions.ts
|
|
11
|
+
query.ts
|
|
12
|
+
index.ts
|
|
13
|
+
shared/
|
|
14
|
+
services/<feature>.ts
|
|
15
|
+
hooks/use<Feature>.ts
|
|
16
|
+
components/
|
|
17
|
+
QueryState.tsx
|
|
18
|
+
StatusTag.tsx
|
|
19
|
+
pages/<feature>/
|
|
20
|
+
page.config.ts
|
|
21
|
+
index.tsx
|
|
22
|
+
App.tsx
|
|
23
|
+
styles.css
|
|
24
|
+
components/
|
|
25
|
+
forms/<feature>/
|
|
26
|
+
schema.ts
|
|
27
|
+
page.tsx
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Dependency Direction
|
|
31
|
+
|
|
32
|
+
```text
|
|
33
|
+
pages -> shared/hooks -> shared/services -> domain
|
|
34
|
+
forms -> shared -> domain
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
- `domain/` has no React, Ant Design, SDK, or page imports.
|
|
38
|
+
- `shared/` does not import from `pages/`.
|
|
39
|
+
- `pages/` compose modules and call hooks; they do not build complex query
|
|
40
|
+
payloads inline.
|
|
41
|
+
- PC and mobile views can have different components and styles, but they reuse
|
|
42
|
+
the same domain and service logic.
|
|
43
|
+
|
|
44
|
+
## File Size
|
|
45
|
+
|
|
46
|
+
Avoid large single-file pages. If a page grows beyond roughly 250 lines, split
|
|
47
|
+
table actions, drawers, timelines, query builders, hooks, and styles into
|
|
48
|
+
separate files.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
|
|
4
|
+
import { buildConditionDataPermission, buildRoleCode } from "./permissions";
|
|
5
|
+
|
|
6
|
+
describe("role-governance permission helpers", () => {
|
|
7
|
+
it("normalizes dynamic role codes", () => {
|
|
8
|
+
assert.equal(buildRoleCode({ roleCode: " College Admin " }), "college_admin");
|
|
9
|
+
assert.equal(buildRoleCode({ roleCode: "class advisor" }), "class_advisor");
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("builds condition-based data permissions from redundant fields", () => {
|
|
13
|
+
const permission = buildConditionDataPermission({
|
|
14
|
+
collegeId: "college_science",
|
|
15
|
+
classId: "",
|
|
16
|
+
ownerDeptId: "dept_lab",
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
assert.equal(permission.type, "condition");
|
|
20
|
+
assert.deepEqual(permission.condition.rules, [
|
|
21
|
+
{
|
|
22
|
+
field: "collegeId",
|
|
23
|
+
componentType: "Text",
|
|
24
|
+
op: "=",
|
|
25
|
+
value: "college_science",
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
field: "ownerDeptId",
|
|
29
|
+
componentType: "Text",
|
|
30
|
+
op: "=",
|
|
31
|
+
value: "dept_lab",
|
|
32
|
+
},
|
|
33
|
+
]);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { AppRoleRecord, DataOwnershipFields } from "./types";
|
|
2
|
+
|
|
3
|
+
export function buildRoleCode(role: Pick<AppRoleRecord, "roleCode">) {
|
|
4
|
+
return role.roleCode.trim().replace(/\s+/g, "_").toLowerCase();
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function buildConditionDataPermission(scope: DataOwnershipFields) {
|
|
8
|
+
const rules = Object.entries(scope)
|
|
9
|
+
.filter(([, value]) => Boolean(value))
|
|
10
|
+
.map(([field, value]) => ({
|
|
11
|
+
field,
|
|
12
|
+
componentType: "Text",
|
|
13
|
+
op: "=",
|
|
14
|
+
value,
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
return {
|
|
18
|
+
type: "condition" as const,
|
|
19
|
+
condition: {
|
|
20
|
+
logic: "AND" as const,
|
|
21
|
+
rules,
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export interface AppRoleRecord {
|
|
2
|
+
formInstanceId: string;
|
|
3
|
+
roleCode: string;
|
|
4
|
+
roleName: string;
|
|
5
|
+
members?: Array<{ label: string; value: string }>;
|
|
6
|
+
collegeId?: string;
|
|
7
|
+
classId?: string;
|
|
8
|
+
enabled?: { label: string; value: "enabled" | "disabled" };
|
|
9
|
+
lastSyncedAt?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface DataOwnershipFields {
|
|
13
|
+
collegeId?: string;
|
|
14
|
+
classId?: string;
|
|
15
|
+
ownerDeptId?: string;
|
|
16
|
+
ownerUserId?: string;
|
|
17
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
|
|
4
|
+
import { canViewTicket, getTicketUiPermissions } from "./permissions";
|
|
5
|
+
import type { TicketOperator, TicketRecord } from "./types";
|
|
6
|
+
|
|
7
|
+
const ticket: TicketRecord = {
|
|
8
|
+
formInstanceId: "ticket_001",
|
|
9
|
+
title: "实验室报修",
|
|
10
|
+
status: "accepted",
|
|
11
|
+
ownerUserId: "owner_001",
|
|
12
|
+
ownerDeptId: "dept_lab",
|
|
13
|
+
collegeId: "college_science",
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
describe("service-ticket permission helpers", () => {
|
|
17
|
+
it("allows admins and redundant ownership matches", () => {
|
|
18
|
+
assert.equal(canViewTicket(ticket, user({ roleCodes: ["app_admin"] })), true);
|
|
19
|
+
assert.equal(canViewTicket(ticket, user({ userId: "owner_001" })), true);
|
|
20
|
+
assert.equal(canViewTicket(ticket, user({ collegeIds: ["college_science"] })), true);
|
|
21
|
+
assert.equal(canViewTicket(ticket, user({ departmentIds: ["dept_lab"] })), true);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("keeps action visibility tied to operation permission", () => {
|
|
25
|
+
const allowed = getTicketUiPermissions(ticket, user({ departmentIds: ["dept_lab"] }));
|
|
26
|
+
assert.equal(allowed.canView, true);
|
|
27
|
+
assert.deepEqual(allowed.actions, ["start", "cancel"]);
|
|
28
|
+
|
|
29
|
+
const denied = getTicketUiPermissions(ticket, user({ userId: "other" }));
|
|
30
|
+
assert.equal(denied.canView, false);
|
|
31
|
+
assert.deepEqual(denied.actions, []);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
function user(input: Partial<TicketOperator>): TicketOperator {
|
|
36
|
+
return {
|
|
37
|
+
userId: "u_001",
|
|
38
|
+
roleCodes: [],
|
|
39
|
+
departmentIds: [],
|
|
40
|
+
...input,
|
|
41
|
+
};
|
|
42
|
+
}
|