openxiangda 1.0.21 → 1.0.24
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 +723 -11
- 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/automation-v3.md +2 -0
- package/openxiangda-skills/references/best-practices.md +163 -0
- package/openxiangda-skills/references/connector-resources.md +3 -0
- package/openxiangda-skills/references/notifications.md +80 -0
- package/openxiangda-skills/references/openxiangda-api.md +45 -0
- package/openxiangda-skills/references/pages/page-sdk.md +1 -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 +11 -7
- package/openxiangda-skills/skills/openxiangda-core/SKILL.md +22 -4
- package/openxiangda-skills/skills/openxiangda-form/SKILL.md +6 -1
- package/openxiangda-skills/skills/openxiangda-page/SKILL.md +9 -1
- package/openxiangda-skills/skills/openxiangda-permission-settings/SKILL.md +3 -0
- package/openxiangda-skills/skills/openxiangda-workflow-automation/SKILL.md +9 -0
- package/package.json +1 -1
- package/packages/sdk/dist/runtime/index.cjs +34 -2
- package/packages/sdk/dist/runtime/index.cjs.map +1 -1
- package/packages/sdk/dist/runtime/index.d.mts +66 -1
- package/packages/sdk/dist/runtime/index.d.ts +66 -1
- package/packages/sdk/dist/runtime/index.mjs +34 -2
- package/packages/sdk/dist/runtime/index.mjs.map +1 -1
- 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 +30 -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 +1 -0
- package/templates/sy-lowcode-app-workspace/src/dev/App.tsx +11 -1
- package/templates/sy-lowcode-app-workspace/tsconfig.examples.json +24 -0
|
@@ -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,30 @@
|
|
|
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
|
+
- Keep page styles in `styles.css`; avoid large inline style objects.
|
|
28
|
+
- Keep reusable visual states in shared components: status tags, query states,
|
|
29
|
+
confirmation triggers, and operation timelines.
|
|
30
|
+
- 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
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { TicketOperator, TicketRecord } from "./types";
|
|
2
|
+
import { getAvailableTicketActions } from "./state-machine";
|
|
3
|
+
|
|
4
|
+
export function canViewTicket(ticket: TicketRecord, operator: TicketOperator) {
|
|
5
|
+
if (operator.roleCodes.includes("app_admin")) return true;
|
|
6
|
+
if (ticket.ownerUserId === operator.userId) return true;
|
|
7
|
+
if (ticket.collegeId && operator.collegeIds?.includes(ticket.collegeId)) return true;
|
|
8
|
+
if (ticket.classId && operator.classIds?.includes(ticket.classId)) return true;
|
|
9
|
+
if (
|
|
10
|
+
ticket.ownerDeptId &&
|
|
11
|
+
operator.departmentIds?.includes(ticket.ownerDeptId)
|
|
12
|
+
) {
|
|
13
|
+
return true;
|
|
14
|
+
}
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function getTicketUiPermissions(ticket: TicketRecord, operator: TicketOperator) {
|
|
19
|
+
return {
|
|
20
|
+
canView: canViewTicket(ticket, operator),
|
|
21
|
+
actions: getAvailableTicketActions(ticket, operator),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
assertTicketTransition,
|
|
6
|
+
getAvailableTicketActions,
|
|
7
|
+
getNextTicketStatus,
|
|
8
|
+
} from "./state-machine";
|
|
9
|
+
import type { TicketOperator, TicketRecord } from "./types";
|
|
10
|
+
|
|
11
|
+
const operator: TicketOperator = {
|
|
12
|
+
userId: "u_001",
|
|
13
|
+
roleCodes: ["ticket_operator"],
|
|
14
|
+
departmentIds: ["dept_lab"],
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const ticket: TicketRecord = {
|
|
18
|
+
formInstanceId: "ticket_001",
|
|
19
|
+
title: "设备故障",
|
|
20
|
+
status: "new",
|
|
21
|
+
ownerDeptId: "dept_lab",
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
describe("service-ticket state machine", () => {
|
|
25
|
+
it("returns deterministic next status", () => {
|
|
26
|
+
assert.equal(getNextTicketStatus("new", "accept"), "accepted");
|
|
27
|
+
assert.equal(getNextTicketStatus("resolved", "close"), "closed");
|
|
28
|
+
assert.equal(getNextTicketStatus("closed", "start"), null);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("filters available actions by status and operator permission", () => {
|
|
32
|
+
assert.deepEqual(getAvailableTicketActions(ticket, operator), [
|
|
33
|
+
"accept",
|
|
34
|
+
"cancel",
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
assert.deepEqual(
|
|
38
|
+
getAvailableTicketActions(ticket, {
|
|
39
|
+
userId: "u_002",
|
|
40
|
+
roleCodes: ["student"],
|
|
41
|
+
departmentIds: ["dept_other"],
|
|
42
|
+
}),
|
|
43
|
+
[],
|
|
44
|
+
);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("throws clear errors for unauthorized or invalid transitions", () => {
|
|
48
|
+
assert.throws(
|
|
49
|
+
() =>
|
|
50
|
+
assertTicketTransition(ticket, "accept", {
|
|
51
|
+
userId: "u_002",
|
|
52
|
+
roleCodes: [],
|
|
53
|
+
departmentIds: [],
|
|
54
|
+
}),
|
|
55
|
+
/无权操作/,
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
assert.throws(
|
|
59
|
+
() => assertTicketTransition({ ...ticket, status: "closed" }, "start", operator),
|
|
60
|
+
/不允许执行动作/,
|
|
61
|
+
);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { TicketAction, TicketOperator, TicketRecord, TicketStatus } from "./types";
|
|
2
|
+
|
|
3
|
+
export const ticketStatusLabels: Record<TicketStatus, string> = {
|
|
4
|
+
new: "新建",
|
|
5
|
+
accepted: "已受理",
|
|
6
|
+
processing: "处理中",
|
|
7
|
+
paused: "挂起",
|
|
8
|
+
resolved: "已解决",
|
|
9
|
+
closed: "已关闭",
|
|
10
|
+
cancelled: "已取消",
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export const ticketActionLabels: Record<TicketAction, string> = {
|
|
14
|
+
accept: "受理",
|
|
15
|
+
start: "开始处理",
|
|
16
|
+
pause: "挂起",
|
|
17
|
+
resume: "恢复",
|
|
18
|
+
resolve: "解决",
|
|
19
|
+
close: "关闭",
|
|
20
|
+
cancel: "取消",
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const transitionTable: Record<TicketStatus, Partial<Record<TicketAction, TicketStatus>>> = {
|
|
24
|
+
new: { accept: "accepted", cancel: "cancelled" },
|
|
25
|
+
accepted: { start: "processing", cancel: "cancelled" },
|
|
26
|
+
processing: { pause: "paused", resolve: "resolved" },
|
|
27
|
+
paused: { resume: "processing", cancel: "cancelled" },
|
|
28
|
+
resolved: { close: "closed" },
|
|
29
|
+
closed: {},
|
|
30
|
+
cancelled: {},
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export function getNextTicketStatus(
|
|
34
|
+
status: TicketStatus,
|
|
35
|
+
action: TicketAction,
|
|
36
|
+
): TicketStatus | null {
|
|
37
|
+
return transitionTable[status]?.[action] || null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function canOperateTicket(ticket: TicketRecord, operator: TicketOperator) {
|
|
41
|
+
if (operator.roleCodes.includes("app_admin")) return true;
|
|
42
|
+
if (ticket.ownerUserId && ticket.ownerUserId === operator.userId) return true;
|
|
43
|
+
if (
|
|
44
|
+
ticket.ownerDeptId &&
|
|
45
|
+
operator.departmentIds?.includes(ticket.ownerDeptId)
|
|
46
|
+
) {
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function getAvailableTicketActions(
|
|
53
|
+
ticket: TicketRecord,
|
|
54
|
+
operator: TicketOperator,
|
|
55
|
+
): TicketAction[] {
|
|
56
|
+
if (!canOperateTicket(ticket, operator)) return [];
|
|
57
|
+
return Object.keys(transitionTable[ticket.status] || {}) as TicketAction[];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function assertTicketTransition(
|
|
61
|
+
ticket: TicketRecord,
|
|
62
|
+
action: TicketAction,
|
|
63
|
+
operator: TicketOperator,
|
|
64
|
+
) {
|
|
65
|
+
if (!canOperateTicket(ticket, operator)) {
|
|
66
|
+
throw new Error("当前用户无权操作该工单");
|
|
67
|
+
}
|
|
68
|
+
const nextStatus = getNextTicketStatus(ticket.status, action);
|
|
69
|
+
if (!nextStatus) {
|
|
70
|
+
throw new Error(`状态 ${ticket.status} 不允许执行动作 ${action}`);
|
|
71
|
+
}
|
|
72
|
+
return nextStatus;
|
|
73
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
|
|
4
|
+
import { buildTicketFilterGroup } from "./ticket-query";
|
|
5
|
+
|
|
6
|
+
describe("service-ticket query builder", () => {
|
|
7
|
+
it("builds structured filters instead of browser-side filtering", () => {
|
|
8
|
+
const group = buildTicketFilterGroup({
|
|
9
|
+
keyword: "离心机",
|
|
10
|
+
statuses: ["new", "processing"],
|
|
11
|
+
priorities: ["urgent"],
|
|
12
|
+
collegeId: "college_science",
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
assert.equal(group.logic, "AND");
|
|
16
|
+
assert.deepEqual(
|
|
17
|
+
group.rules.map((item) => item.key),
|
|
18
|
+
["status", "priority", "collegeId"],
|
|
19
|
+
);
|
|
20
|
+
assert.equal(group.conditions.length, 1);
|
|
21
|
+
assert.equal(group.conditions[0]?.logic, "OR");
|
|
22
|
+
assert.deepEqual(
|
|
23
|
+
group.conditions[0]?.rules.map((item) => item.key),
|
|
24
|
+
["title", "description"],
|
|
25
|
+
);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("omits empty search fields", () => {
|
|
29
|
+
const group = buildTicketFilterGroup({ keyword: " " });
|
|
30
|
+
|
|
31
|
+
assert.deepEqual(group.rules, []);
|
|
32
|
+
assert.deepEqual(group.conditions, []);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { TicketSearchState } from "./types";
|
|
2
|
+
|
|
3
|
+
export type FilterLogic = "AND" | "OR";
|
|
4
|
+
|
|
5
|
+
export interface FilterRule {
|
|
6
|
+
id: string;
|
|
7
|
+
key: string;
|
|
8
|
+
componentName: string;
|
|
9
|
+
operator: string;
|
|
10
|
+
value: unknown;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface FilterGroup {
|
|
14
|
+
id: string;
|
|
15
|
+
logic: FilterLogic;
|
|
16
|
+
rules: FilterRule[];
|
|
17
|
+
conditions: FilterGroup[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const rule = (
|
|
21
|
+
key: string,
|
|
22
|
+
componentName: string,
|
|
23
|
+
operator: string,
|
|
24
|
+
value: unknown,
|
|
25
|
+
): FilterRule => ({
|
|
26
|
+
id: `${key}_${operator}`,
|
|
27
|
+
key,
|
|
28
|
+
componentName,
|
|
29
|
+
operator,
|
|
30
|
+
value,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
export function buildTicketFilterGroup(search: TicketSearchState): FilterGroup {
|
|
34
|
+
const group: FilterGroup = {
|
|
35
|
+
id: "ticket_filters",
|
|
36
|
+
logic: "AND",
|
|
37
|
+
rules: [],
|
|
38
|
+
conditions: [],
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
if (search.statuses?.length) {
|
|
42
|
+
group.rules.push(rule("status", "SelectField", "IN", search.statuses));
|
|
43
|
+
}
|
|
44
|
+
if (search.priorities?.length) {
|
|
45
|
+
group.rules.push(rule("priority", "SelectField", "IN", search.priorities));
|
|
46
|
+
}
|
|
47
|
+
if (search.ownerUserId) {
|
|
48
|
+
group.rules.push(rule("ownerUserId", "TextField", "EQ", search.ownerUserId));
|
|
49
|
+
}
|
|
50
|
+
if (search.ownerDeptId) {
|
|
51
|
+
group.rules.push(rule("ownerDeptId", "TextField", "EQ", search.ownerDeptId));
|
|
52
|
+
}
|
|
53
|
+
if (search.collegeId) {
|
|
54
|
+
group.rules.push(rule("collegeId", "TextField", "EQ", search.collegeId));
|
|
55
|
+
}
|
|
56
|
+
if (search.classId) {
|
|
57
|
+
group.rules.push(rule("classId", "TextField", "EQ", search.classId));
|
|
58
|
+
}
|
|
59
|
+
if (search.keyword?.trim()) {
|
|
60
|
+
const keyword = search.keyword.trim();
|
|
61
|
+
group.conditions.push({
|
|
62
|
+
id: "keyword_or",
|
|
63
|
+
logic: "OR",
|
|
64
|
+
rules: [
|
|
65
|
+
rule("title", "TextField", "CONTAINS", keyword),
|
|
66
|
+
rule("description", "TextAreaField", "CONTAINS", keyword),
|
|
67
|
+
],
|
|
68
|
+
conditions: [],
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return group;
|
|
73
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
export type TicketStatus =
|
|
2
|
+
| "new"
|
|
3
|
+
| "accepted"
|
|
4
|
+
| "processing"
|
|
5
|
+
| "paused"
|
|
6
|
+
| "resolved"
|
|
7
|
+
| "closed"
|
|
8
|
+
| "cancelled";
|
|
9
|
+
|
|
10
|
+
export type TicketAction =
|
|
11
|
+
| "accept"
|
|
12
|
+
| "start"
|
|
13
|
+
| "pause"
|
|
14
|
+
| "resume"
|
|
15
|
+
| "resolve"
|
|
16
|
+
| "close"
|
|
17
|
+
| "cancel";
|
|
18
|
+
|
|
19
|
+
export type TicketPriority = "urgent" | "high" | "normal" | "low";
|
|
20
|
+
|
|
21
|
+
export interface TicketRecord {
|
|
22
|
+
formInstanceId: string;
|
|
23
|
+
title: string;
|
|
24
|
+
status: TicketStatus;
|
|
25
|
+
priority?: TicketPriority;
|
|
26
|
+
ownerUserId?: string;
|
|
27
|
+
ownerDeptId?: string;
|
|
28
|
+
collegeId?: string;
|
|
29
|
+
classId?: string;
|
|
30
|
+
requester?: { label: string; value: string };
|
|
31
|
+
currentOwner?: { label: string; value: string };
|
|
32
|
+
description?: string;
|
|
33
|
+
lastActionAt?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface TicketActionLog {
|
|
37
|
+
ticketId: string;
|
|
38
|
+
action: TicketAction;
|
|
39
|
+
fromStatus?: TicketStatus;
|
|
40
|
+
toStatus: TicketStatus;
|
|
41
|
+
operatorId: string;
|
|
42
|
+
operatorName?: string;
|
|
43
|
+
comment?: string;
|
|
44
|
+
operatedAt: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface TicketOperator {
|
|
48
|
+
userId: string;
|
|
49
|
+
userName?: string;
|
|
50
|
+
roleCodes: string[];
|
|
51
|
+
collegeIds?: string[];
|
|
52
|
+
classIds?: string[];
|
|
53
|
+
departmentIds?: string[];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface TicketSearchState {
|
|
57
|
+
keyword?: string;
|
|
58
|
+
statuses?: TicketStatus[];
|
|
59
|
+
priorities?: TicketPriority[];
|
|
60
|
+
ownerUserId?: string;
|
|
61
|
+
ownerDeptId?: string;
|
|
62
|
+
collegeId?: string;
|
|
63
|
+
classId?: string;
|
|
64
|
+
}
|
package/templates/sy-lowcode-app-workspace/examples/best-practices/src/forms/app-role/page.tsx
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from "./schema";
|
package/templates/sy-lowcode-app-workspace/examples/best-practices/src/forms/app-role/schema.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { createFormSchema } from "@/shared/form-schema";
|
|
2
|
+
|
|
3
|
+
export default createFormSchema({
|
|
4
|
+
formMeta: {
|
|
5
|
+
formUuid: "",
|
|
6
|
+
appType: process.env.OPENXIANGDA_APP_TYPE || "APP_XXXX",
|
|
7
|
+
title: "应用角色维护",
|
|
8
|
+
},
|
|
9
|
+
fields: [
|
|
10
|
+
{
|
|
11
|
+
fieldId: "roleCode",
|
|
12
|
+
componentName: "TextField",
|
|
13
|
+
label: "角色编码",
|
|
14
|
+
required: true,
|
|
15
|
+
placeholder: "如 college_admin",
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
fieldId: "roleName",
|
|
19
|
+
componentName: "TextField",
|
|
20
|
+
label: "角色名称",
|
|
21
|
+
required: true,
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
fieldId: "members",
|
|
25
|
+
componentName: "UserSelectField",
|
|
26
|
+
label: "成员",
|
|
27
|
+
multiple: true,
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
fieldId: "collegeId",
|
|
31
|
+
componentName: "TextField",
|
|
32
|
+
label: "学院 ID",
|
|
33
|
+
tips: "与业务表冗余字段配合,用于条件式数据权限。",
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
fieldId: "classId",
|
|
37
|
+
componentName: "TextField",
|
|
38
|
+
label: "班级 ID",
|
|
39
|
+
tips: "与业务表冗余字段配合,用于条件式数据权限。",
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
fieldId: "enabled",
|
|
43
|
+
componentName: "RadioField",
|
|
44
|
+
label: "启用状态",
|
|
45
|
+
defaultValue: { label: "启用", value: "enabled" },
|
|
46
|
+
options: [
|
|
47
|
+
{ label: "启用", value: "enabled" },
|
|
48
|
+
{ label: "停用", value: "disabled" },
|
|
49
|
+
],
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
fieldId: "lastSyncedAt",
|
|
53
|
+
componentName: "DateField",
|
|
54
|
+
label: "最近同步时间",
|
|
55
|
+
},
|
|
56
|
+
],
|
|
57
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from "./schema";
|