openxiangda 1.0.32 → 1.0.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/lib/skills.js +7 -2
  2. package/openxiangda-skills/SKILL.md +5 -0
  3. package/openxiangda-skills/references/architecture-patterns.md +2 -2
  4. package/openxiangda-skills/references/best-practices.md +37 -12
  5. package/openxiangda-skills/references/forms/component-registry.md +1 -0
  6. package/openxiangda-skills/references/forms/form-schema.md +8 -0
  7. package/openxiangda-skills/references/pages/workspace-structure.md +1 -0
  8. package/openxiangda-skills/skills/openxiangda-form/SKILL.md +8 -1
  9. package/openxiangda-skills/skills/openxiangda-page/SKILL.md +1 -0
  10. package/openxiangda-skills/skills/openxiangda-permission-settings/SKILL.md +1 -1
  11. package/package.json +1 -1
  12. package/templates/sy-lowcode-app-workspace/examples/best-practices/catalog.json +1 -1
  13. package/templates/sy-lowcode-app-workspace/examples/best-practices/decision-guide.md +11 -6
  14. package/templates/sy-lowcode-app-workspace/examples/best-practices/src/domain/role-governance/permissions.test.ts +24 -7
  15. package/templates/sy-lowcode-app-workspace/examples/best-practices/src/domain/role-governance/permissions.ts +13 -0
  16. package/templates/sy-lowcode-app-workspace/examples/best-practices/src/domain/role-governance/types.ts +8 -6
  17. package/templates/sy-lowcode-app-workspace/examples/best-practices/src/domain/service-ticket/permissions.test.ts +5 -5
  18. package/templates/sy-lowcode-app-workspace/examples/best-practices/src/domain/service-ticket/permissions.ts +7 -5
  19. package/templates/sy-lowcode-app-workspace/examples/best-practices/src/domain/service-ticket/state-machine.test.ts +1 -1
  20. package/templates/sy-lowcode-app-workspace/examples/best-practices/src/domain/service-ticket/state-machine.ts +3 -3
  21. package/templates/sy-lowcode-app-workspace/examples/best-practices/src/domain/service-ticket/ticket-query.test.ts +2 -2
  22. package/templates/sy-lowcode-app-workspace/examples/best-practices/src/domain/service-ticket/ticket-query.ts +8 -8
  23. package/templates/sy-lowcode-app-workspace/examples/best-practices/src/domain/service-ticket/types.ts +12 -10
  24. package/templates/sy-lowcode-app-workspace/examples/best-practices/src/forms/app-role/schema.ts +40 -6
  25. package/templates/sy-lowcode-app-workspace/examples/best-practices/src/forms/customer-profile/schema.ts +9 -0
  26. package/templates/sy-lowcode-app-workspace/examples/best-practices/src/forms/service-ticket/schema.ts +54 -16
  27. package/templates/sy-lowcode-app-workspace/examples/best-practices/src/pages/service-ticket-ops/components/TicketDetailDrawer.tsx +6 -3
  28. package/templates/sy-lowcode-app-workspace/examples/best-practices/src/pages/service-ticket-ops/components/TicketTableActions.tsx +6 -4
  29. package/templates/sy-lowcode-app-workspace/examples/best-practices/src/resources/permissions/form-groups/service-ticket-college.json +2 -2
  30. package/templates/sy-lowcode-app-workspace/examples/best-practices/src/resources/permissions/roles.json +2 -2
  31. package/templates/sy-lowcode-app-workspace/examples/best-practices/src/shared/services/service-ticket.ts +4 -4
  32. package/templates/sy-lowcode-app-workspace/examples/forms/customer/schema.ts +1 -0
  33. package/templates/sy-lowcode-app-workspace/src/shared/form-schema.ts +2 -0
package/lib/skills.js CHANGED
@@ -302,7 +302,12 @@ function installOneSkill(spec, skillsDir) {
302
302
  }
303
303
 
304
304
  function copyRootSkill(stagingDir) {
305
- fs.cpSync(SOURCE_SKILLS_DIR, stagingDir, {
305
+ fs.mkdirSync(stagingDir, { recursive: true });
306
+ const skillMarkdown = fs
307
+ .readFileSync(path.join(SOURCE_SKILLS_DIR, 'SKILL.md'), 'utf8')
308
+ .replace(/`skills\/(openxiangda-[^`]+)\/SKILL\.md`/g, '`../$1/SKILL.md`');
309
+ fs.writeFileSync(path.join(stagingDir, 'SKILL.md'), skillMarkdown);
310
+ fs.cpSync(SOURCE_REFERENCES_DIR, path.join(stagingDir, 'references'), {
306
311
  recursive: true,
307
312
  dereference: false,
308
313
  });
@@ -368,4 +373,4 @@ module.exports = {
368
373
  getSkillStatusReport,
369
374
  installSkills,
370
375
  resolveSkillsDir,
371
- };
376
+ };
@@ -84,6 +84,11 @@ When the user provides a root domain such as `https://yida.wisejob.cn/`, use it
84
84
  - Never store token data in the project directory. User tokens live in `~/.openxiangda/profiles.json`; project state lives in `.openxiangda/state.json` and stores only IDs and mappings.
85
85
  - Shared workspace env values such as `APP_OSS_*` should live in `~/.openxiangda/.env` by default. Project `.env` files are only per-workspace overrides.
86
86
  - For suspected platform defects, bugs, or product optimization requests, ask the user to confirm first, then use `openxiangda feedback preview` and `openxiangda feedback submit --yes` to send a detailed DingTalk robot report. Include the command, error, relevant files, and logs/context files when available. The CLI redacts tokens, cookies, secrets, phone numbers, and emails before sending.
87
+ - For form pages, every visible field should have a concise user-facing `placeholder`. Use `tips` only for special constraints or non-obvious business rules; do not add tips to every field.
88
+ - Form pages should display only fields the user needs to see. Use `SelectField` / `RadioField` for enums and `AssociationFormField` for data maintained by another form, such as class, college, customer, or project. Do not make users maintain raw ID text fields.
89
+ - Permission scope keys, computed fields, sync fields, and developer/internal fields should be present only when needed for permissions or logic, and should normally be derived from a visible select/association field and marked `behavior: "HIDDEN"`.
90
+ - Do not add extra fields for platform system metadata such as creator, updater, creator department, updater department, created time, or updated time unless the user explicitly needs a separate business field; the platform already creates system fields for each form.
91
+ - 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.
87
92
  - Use logical resource codes in local files. Platform-specific IDs such as `formUuid`, `pageId`, `workflowId`, and `automationId` must be isolated by profile.
88
93
  - 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.
89
94
  - 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.
@@ -155,8 +155,8 @@ export default function InstrumentListPage() {
155
155
 
156
156
  ### 4.1 状态流转不是审批流
157
157
 
158
- - 普通业务流转默认使用普通表单:`status` 字段、责任人/归属冗余字段、操作日志表、domain 状态机、service 统一提交。
159
- - 每次状态变更都由 service 完成,统一写操作日志并更新责任人、归属、更新时间等冗余字段。
158
+ - 普通业务流转默认使用普通表单:`status` 字段、可维护的责任人/归属字段、必要的隐藏权限键、操作日志表、domain 状态机、service 统一提交。
159
+ - 每次状态变更都由 service 完成,统一写操作日志并更新责任人、归属、更新时间和隐藏权限键等派生字段。
160
160
  - workflow 只用于真实审批:审批人、审批任务、同意/驳回、审批意见、节点权限、流程记录明确存在的场景。
161
161
  - 不要把「待处理 / 处理中 / 已完成 / 已关闭」这类状态流转建成流程表单。
162
162
 
@@ -42,8 +42,10 @@ Most business processes are status lifecycles, not approval workflows.
42
42
  Use a normal form plus:
43
43
 
44
44
  - a `status` field
45
- - responsibility fields such as `ownerUserId`, `ownerDeptId`, `collegeId`,
46
- `classId`
45
+ - user-facing responsibility fields such as personnel, department, enum, or
46
+ association fields
47
+ - hidden permission scope keys, when form permission groups require scalar
48
+ matching
47
49
  - an action log form
48
50
  - a pure state machine in `domain/<feature>/state-machine.ts`
49
51
  - a service method that changes status and writes the log in one path
@@ -103,20 +105,43 @@ For apps with dynamic roles:
103
105
 
104
106
  1. Create an app role maintenance form, such as `app-role`.
105
107
  2. Use automation / JS_CODE to sync role records to platform roles.
106
- 3. Add redundant ownership fields to business forms, for example:
107
- - `collegeId`
108
- - `classId`
109
- - `ownerDeptId`
110
- - `ownerUserId`
111
- - `roleCode`
112
- 4. Create page permission groups for entry visibility.
113
- 5. Create form permission groups with condition-based data permissions for real
108
+ 3. Model visible scope fields with maintainable controls:
109
+ - personnel and departments use platform personnel/department fields
110
+ - enum scopes use `SelectField` / `RadioField` with `options`
111
+ - class, college, project, customer, and similar maintained records use
112
+ `AssociationFormField`
113
+ 4. Add hidden derived scope keys only when platform form permission conditions
114
+ require scalar matching, for example:
115
+ - `collegeScopeKey`
116
+ - `classScopeKey`
117
+ - `ownerDeptScopeKey`
118
+ - `ownerUserScopeKey`
119
+ 5. Create page permission groups for entry visibility.
120
+ 6. Create form permission groups with condition-based data permissions for real
114
121
  data isolation.
115
122
 
116
123
  Frontend button hiding is only user experience. It is not permission control.
117
124
  Every sensitive action must still be protected by platform role/form permission
118
125
  groups or backend-side JS_CODE checks.
119
126
 
127
+ ## Form Copy And Field Visibility
128
+
129
+ - Every visible form field should have a short, user-facing placeholder.
130
+ - Do not add tips to every field. Tips are only for special constraints,
131
+ unusual formats, compliance notes, or non-obvious business rules.
132
+ - Use select/radio controls for enums and association controls for values
133
+ maintained by other forms. Do not ask users to type raw IDs.
134
+ - Hide permission scope keys, computed, sync, and developer/internal fields with
135
+ `behavior: "HIDDEN"` when they must exist in the schema. Scope keys should be
136
+ derived from visible select/association/person/department fields, not typed by
137
+ users.
138
+ - Do not add separate creator, updater, creator department, updater department,
139
+ created time, or updated time fields unless the user explicitly needs a
140
+ separate business field. The platform creates system metadata for every form.
141
+ - All visible copy must be written for end users. Do not put implementation
142
+ notes, schema descriptions, or development explanations in sections, cards,
143
+ labels, tips, helper text, or empty states.
144
+
120
145
  ## Query Performance
121
146
 
122
147
  - Always use paginated APIs with `currentPage`, `pageSize`, sort, and structured
@@ -166,8 +191,8 @@ groups or backend-side JS_CODE checks.
166
191
  - `service-ticket-ops`: custom data management page based on
167
192
  `DataManagementList`, split into page, components, hook, query builder, and
168
193
  detail drawer.
169
- - `role-governance`: role maintenance form, role sync JS_CODE, redundant
170
- ownership fields, and permission-group resource examples.
194
+ - `role-governance`: role maintenance form, role sync JS_CODE, maintainable
195
+ scope fields, hidden permission keys, and permission-group resource examples.
171
196
  - `pc-portal-shell`: app-shell PC portal with routes, modules, components, and
172
197
  services.
173
198
  - `mobile-portal-shell`: app-shell mobile portal with mobile-only components
@@ -36,6 +36,7 @@ Rules:
36
36
  - Use field-level `rules` for validation. Required fields should set both `required: true` and `rules: [{ required: true, message: "..." }]` when a custom message is needed.
37
37
  - For option components (`SelectField`, `MultiSelectField`, `RadioField`, `CheckboxField`, `CascadeSelectField`), always provide an `options` array. Use `options: [{ value: "stable_code", label: "显示名" }]`; if options will be loaded elsewhere later, still emit `options: []` so the runtime does not crash on `options.map(...)`.
38
38
  - Do not rely on custom `relation` metadata alone for normal form pages. A `SelectField` with `relation` but no `options` can white-screen with `Cannot read properties of undefined (reading 'map')`. Use static `options`, `AssociationFormField`, or a custom code page that resolves linked records.
39
+ - Do not model business enums or data-source references as free text IDs. Use `SelectField` / `RadioField` with `options` for enums, and use `AssociationFormField` for records maintained by another form.
39
40
  - For `NumberField`, prefer explicit `min`, `max`, `precision`, and `unit` when the business meaning is constrained.
40
41
  - For `DateField`, use `mode: "date"` or `mode: "datetime"` and a stable `format` such as `YYYY-MM-DD` or `YYYY-MM-DD HH:mm`.
41
42
  - For `CascadeDateField`, store a date range and document whether the value is inclusive.
@@ -35,6 +35,7 @@ export default defineFormSchema({
35
35
  fieldId: "customer_type",
36
36
  componentName: "SelectField",
37
37
  label: "客户类型",
38
+ placeholder: "请选择客户类型",
38
39
  options: [
39
40
  { value: "enterprise", label: "企业客户" },
40
41
  { value: "individual", label: "个人客户" },
@@ -55,6 +56,13 @@ export default defineFormSchema({
55
56
 
56
57
  - Each persisted field needs a stable `fieldId`.
57
58
  - Field labels should be user-facing Chinese names when the app is Chinese.
59
+ - Each visible field should include a concise user-facing `placeholder`. Hidden/internal fields do not need placeholders.
60
+ - Use `tips` only for special constraints, unusual formats, compliance notes, or non-obvious business rules. Do not add tips to every field.
61
+ - Use `SelectField` / `RadioField` for enum values and always provide `options`.
62
+ - Use `AssociationFormField` for values maintained in another form or data source, such as class, college, customer, project, category, or asset. Do not ask users to type raw IDs in `TextField`.
63
+ - Keep only user-needed fields visible. If a scalar key is needed for permissions, synchronization, computed state, or internal logic, derive it from a visible select/association/person/department field and keep it in the schema with `behavior: "HIDDEN"`.
64
+ - Do not create duplicate platform system fields such as creator, updater, creator department, updater department, created time, or updated time unless the user explicitly needs a distinct business field. The platform already creates system metadata for every form.
65
+ - Labels, placeholders, tips, section titles, descriptions, and empty states must be end-user-facing. Do not write developer-facing implementation explanations into visible form copy.
58
66
  - Use platform-supported field components from `component-registry.md`.
59
67
  - Do not generate fields that are only visual layout containers.
60
68
  - Put validation rules on fields as `field.rules`. Do not put validation objects in top-level `schema.rules`.
@@ -38,3 +38,4 @@ Rules:
38
38
  - Shared components can live under `src/components/` when reused.
39
39
  - Build artifacts under `dist/` are generated and should not be hand edited.
40
40
  - Publish through `openxiangda workspace publish --profile <name>`.
41
+ - Visible page copy must be written for end users. Do not show implementation notes, schema explanations, or developer-only guidance inside page sections, cards, alerts, tooltips, or empty states.
@@ -74,8 +74,15 @@ Read these references only when writing or reviewing schema:
74
74
  ## Rules
75
75
 
76
76
  - Prefer deterministic `formCode` as local key; bind live `formUuid` under the active profile.
77
+ - Every visible field should have a concise, user-facing `placeholder` that tells the user what to enter or select.
78
+ - Use `tips` sparingly. Add tips only for special constraints, unusual formats, or non-obvious business rules; ordinary fields should not have tips.
79
+ - Use `SelectField` or `RadioField` for enumerable business values. Do not model enums as `TextField` values that users must type manually.
80
+ - Use `AssociationFormField` when the value is maintained by another form or data source, such as class, college, customer, project, category, or asset. Do not make users maintain raw ID text fields for those values.
81
+ - Display only fields the user needs to interact with. Permission scope keys, computed fields, sync fields, and developer/internal fields should normally be derived from visible select/association fields and stay in the schema with `behavior: "HIDDEN"` instead of appearing on the form page.
82
+ - Do not create separate fields for platform system metadata such as creator, updater, creator department, updater department, created time, or updated time unless the user explicitly asks for a separate business concept. The platform creates those system fields for every form.
83
+ - All labels, placeholders, tips, section titles, empty states, and helper text must be end-user-facing business copy. Do not write developer explanations or implementation notes into visible page copy.
77
84
  - For business lifecycles, model status with normal form fields (`status`, owner/responsibility fields, action-log relation fields) unless the scenario has real approval tasks. Do not create workflow forms for ordinary status transitions.
78
- - For permission isolation, add redundant ownership fields such as `collegeId`, `classId`, `ownerDeptId`, and `ownerUserId` at schema time so form permission groups can use structured conditions later.
85
+ - For permission isolation, collect user-facing ownership with select, personnel, department, or association fields. If form permission groups require scalar matching, add hidden derived keys such as `collegeScopeKey`, `classScopeKey`, `ownerDeptScopeKey`, and `ownerUserScopeKey`; never expose those keys as editable text inputs.
79
86
  - For multi-platform publishing, create or bind the form separately for each profile.
80
87
  - Do not copy `formUuid` from dev to prod unless the target platform explicitly already uses that ID.
81
88
  - Keep `schema.ts` and `page.tsx` as the source for fields/layout/rules and presentation; generated build output is not the source of truth.
@@ -71,6 +71,7 @@ Read these references only when editing page code:
71
71
  - Formal user-facing entries such as admin consoles, PC portals, and mobile portals must be app-shell code pages. Declare `entry: { mode: "app-shell", hidePlatformNav: true, defaultRoute: "<home-route>" }` in `page.config.ts`.
72
72
  - Do not generate single-file large pages. Split complex code pages into `domain/`, `shared/services/`, `shared/hooks/`, shared/page-local `components/`, route/config files, and `styles.css` as described in `../../references/best-practices.md`.
73
73
  - Keep view code thin. Page components call hooks/services; business rules, state transition rules, permission predicates, and query builders live outside TSX and are reusable by PC and mobile pages.
74
+ - All visible page copy must be end-user-facing business text. Do not put developer explanations, implementation notes, schema descriptions, or "this module is generated by..." text into sections, cards, alerts, empty states, tooltips, or helper copy.
74
75
  - Store live `pageId`, `routeKey`, and `legacyFormUuid` under the current profile only.
75
76
  - Use `openxiangda/runtime` for platform data access instead of hardcoding backend URLs in page code.
76
77
  - For reminders, alerts, and business messages, declare `src/resources/notifications/` first and call `sdk.notification`; do not hardcode notification API URLs.
@@ -34,7 +34,7 @@ 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.
37
+ For dynamic multi-role apps, do not hardcode role behavior only in page code. Create a role maintenance form and sync it to platform roles with automation / JS_CODE. Visible scope fields should be maintainable controls: personnel/department fields for people and orgs, `SelectField` / `RadioField` for enums, and `AssociationFormField` for records maintained by other forms. If form permission groups need scalar matching, derive hidden keys such as `collegeScopeKey`, `classScopeKey`, `ownerDeptScopeKey`, `ownerUserScopeKey`, and `roleCode`; do not expose raw ID text fields to users. Use page permission groups for entry visibility and form permission groups with condition-based data permissions for real data isolation.
38
38
 
39
39
  ## Page Permission Groups
40
40
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openxiangda",
3
- "version": "1.0.32",
3
+ "version": "1.0.34",
4
4
  "description": "OpenXiangda CLI, workspace build tools, runtime SDK, and form components.",
5
5
  "private": false,
6
6
  "bin": {
@@ -25,7 +25,7 @@
25
25
  "code": "role-governance",
26
26
  "type": "permissions",
27
27
  "path": "src/domain/role-governance",
28
- "description": "Dynamic role maintenance, role sync, redundant ownership fields, and permission-group resource examples."
28
+ "description": "Dynamic role maintenance, role sync, maintainable scope fields, hidden permission keys, and permission-group resource examples."
29
29
  },
30
30
  {
31
31
  "code": "pc-portal-shell",
@@ -20,9 +20,10 @@ Most business "processes" are lifecycle state changes:
20
20
  - orders: draft -> submitted -> paid -> fulfilled -> completed
21
21
  - assets: available -> reserved -> in_use -> maintenance -> retired
22
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.
23
+ Use a normal form with a `status` field, maintainable ownership/scope fields,
24
+ and an operation-log form. Define allowed transitions in
25
+ `domain/<feature>/state-machine.ts`, and execute changes through a service
26
+ method that updates state and writes logs.
26
27
 
27
28
  Use workflow only when the platform must create approval tasks with approvers,
28
29
  approval comments, agree/reject actions, copy nodes, node field permissions, and
@@ -39,6 +40,10 @@ Frontend code must not be responsible for data isolation or background jobs.
39
40
  ## Permissions
40
41
 
41
42
  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.
43
+ platform app roles. User-facing scope fields should be maintainable controls:
44
+ personnel/department fields for people and orgs, enum fields for fixed option
45
+ sets, and association fields for classes, colleges, projects, customers, and
46
+ other records maintained by forms. If the platform permission condition needs a
47
+ scalar value, derive a hidden scope key such as `collegeScopeKey` or
48
+ `ownerDeptScopeKey`. Use form permission groups with condition-style data
49
+ permissions to enforce data isolation.
@@ -1,7 +1,11 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { describe, it } from "node:test";
3
3
 
4
- import { buildConditionDataPermission, buildRoleCode } from "./permissions";
4
+ import {
5
+ buildConditionDataPermission,
6
+ buildRoleCode,
7
+ deriveRoleScopeKeys,
8
+ } from "./permissions";
5
9
 
6
10
  describe("role-governance permission helpers", () => {
7
11
  it("normalizes dynamic role codes", () => {
@@ -9,23 +13,36 @@ describe("role-governance permission helpers", () => {
9
13
  assert.equal(buildRoleCode({ roleCode: "class advisor" }), "class_advisor");
10
14
  });
11
15
 
12
- it("builds condition-based data permissions from redundant fields", () => {
16
+ it("derives hidden scope keys from maintainable association values", () => {
17
+ assert.deepEqual(
18
+ deriveRoleScopeKeys({
19
+ collegeScope: { label: "理学院", value: "college_science" },
20
+ classScope: { label: "2026 级一班", value: "class_2026_01" },
21
+ }),
22
+ {
23
+ collegeScopeKey: "college_science",
24
+ classScopeKey: "class_2026_01",
25
+ },
26
+ );
27
+ });
28
+
29
+ it("builds condition-based data permissions from hidden scope keys", () => {
13
30
  const permission = buildConditionDataPermission({
14
- collegeId: "college_science",
15
- classId: "",
16
- ownerDeptId: "dept_lab",
31
+ collegeScopeKey: "college_science",
32
+ classScopeKey: "",
33
+ ownerDeptScopeKey: "dept_lab",
17
34
  });
18
35
 
19
36
  assert.equal(permission.type, "condition");
20
37
  assert.deepEqual(permission.condition.rules, [
21
38
  {
22
- field: "collegeId",
39
+ field: "collegeScopeKey",
23
40
  componentType: "Text",
24
41
  op: "=",
25
42
  value: "college_science",
26
43
  },
27
44
  {
28
- field: "ownerDeptId",
45
+ field: "ownerDeptScopeKey",
29
46
  componentType: "Text",
30
47
  op: "=",
31
48
  value: "dept_lab",
@@ -4,6 +4,19 @@ export function buildRoleCode(role: Pick<AppRoleRecord, "roleCode">) {
4
4
  return role.roleCode.trim().replace(/\s+/g, "_").toLowerCase();
5
5
  }
6
6
 
7
+ function scopeKey(value: { value?: string | number } | undefined) {
8
+ return value?.value === undefined || value.value === null ? "" : String(value.value);
9
+ }
10
+
11
+ export function deriveRoleScopeKeys(
12
+ role: Pick<AppRoleRecord, "collegeScope" | "classScope">,
13
+ ) {
14
+ return {
15
+ collegeScopeKey: scopeKey(role.collegeScope),
16
+ classScopeKey: scopeKey(role.classScope),
17
+ };
18
+ }
19
+
7
20
  export function buildConditionDataPermission(scope: DataOwnershipFields) {
8
21
  const rules = Object.entries(scope)
9
22
  .filter(([, value]) => Boolean(value))
@@ -3,15 +3,17 @@ export interface AppRoleRecord {
3
3
  roleCode: string;
4
4
  roleName: string;
5
5
  members?: Array<{ label: string; value: string }>;
6
- collegeId?: string;
7
- classId?: string;
6
+ collegeScope?: { label: string; value: string };
7
+ classScope?: { label: string; value: string };
8
+ collegeScopeKey?: string;
9
+ classScopeKey?: string;
8
10
  enabled?: { label: string; value: "enabled" | "disabled" };
9
11
  lastSyncedAt?: string;
10
12
  }
11
13
 
12
14
  export interface DataOwnershipFields {
13
- collegeId?: string;
14
- classId?: string;
15
- ownerDeptId?: string;
16
- ownerUserId?: string;
15
+ collegeScopeKey?: string;
16
+ classScopeKey?: string;
17
+ ownerDeptScopeKey?: string;
18
+ ownerUserScopeKey?: string;
17
19
  }
@@ -8,16 +8,16 @@ const ticket: TicketRecord = {
8
8
  formInstanceId: "ticket_001",
9
9
  title: "实验室报修",
10
10
  status: "accepted",
11
- ownerUserId: "owner_001",
12
- ownerDeptId: "dept_lab",
13
- collegeId: "college_science",
11
+ ownerUserScopeKey: "owner_001",
12
+ ownerDeptScopeKey: "dept_lab",
13
+ collegeScopeKey: "college_science",
14
14
  };
15
15
 
16
16
  describe("service-ticket permission helpers", () => {
17
- it("allows admins and redundant ownership matches", () => {
17
+ it("allows admins and hidden scope-key matches", () => {
18
18
  assert.equal(canViewTicket(ticket, user({ roleCodes: ["app_admin"] })), true);
19
19
  assert.equal(canViewTicket(ticket, user({ userId: "owner_001" })), true);
20
- assert.equal(canViewTicket(ticket, user({ collegeIds: ["college_science"] })), true);
20
+ assert.equal(canViewTicket(ticket, user({ collegeScopeKeys: ["college_science"] })), true);
21
21
  assert.equal(canViewTicket(ticket, user({ departmentIds: ["dept_lab"] })), true);
22
22
  });
23
23
 
@@ -3,12 +3,14 @@ import { getAvailableTicketActions } from "./state-machine";
3
3
 
4
4
  export function canViewTicket(ticket: TicketRecord, operator: TicketOperator) {
5
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;
6
+ if (ticket.ownerUserScopeKey === operator.userId) return true;
7
+ if (ticket.collegeScopeKey && operator.collegeScopeKeys?.includes(ticket.collegeScopeKey)) {
8
+ return true;
9
+ }
10
+ if (ticket.classScopeKey && operator.classScopeKeys?.includes(ticket.classScopeKey)) return true;
9
11
  if (
10
- ticket.ownerDeptId &&
11
- operator.departmentIds?.includes(ticket.ownerDeptId)
12
+ ticket.ownerDeptScopeKey &&
13
+ operator.departmentIds?.includes(ticket.ownerDeptScopeKey)
12
14
  ) {
13
15
  return true;
14
16
  }
@@ -18,7 +18,7 @@ const ticket: TicketRecord = {
18
18
  formInstanceId: "ticket_001",
19
19
  title: "设备故障",
20
20
  status: "new",
21
- ownerDeptId: "dept_lab",
21
+ ownerDeptScopeKey: "dept_lab",
22
22
  };
23
23
 
24
24
  describe("service-ticket state machine", () => {
@@ -39,10 +39,10 @@ export function getNextTicketStatus(
39
39
 
40
40
  export function canOperateTicket(ticket: TicketRecord, operator: TicketOperator) {
41
41
  if (operator.roleCodes.includes("app_admin")) return true;
42
- if (ticket.ownerUserId && ticket.ownerUserId === operator.userId) return true;
42
+ if (ticket.ownerUserScopeKey && ticket.ownerUserScopeKey === operator.userId) return true;
43
43
  if (
44
- ticket.ownerDeptId &&
45
- operator.departmentIds?.includes(ticket.ownerDeptId)
44
+ ticket.ownerDeptScopeKey &&
45
+ operator.departmentIds?.includes(ticket.ownerDeptScopeKey)
46
46
  ) {
47
47
  return true;
48
48
  }
@@ -9,13 +9,13 @@ describe("service-ticket query builder", () => {
9
9
  keyword: "离心机",
10
10
  statuses: ["new", "processing"],
11
11
  priorities: ["urgent"],
12
- collegeId: "college_science",
12
+ collegeScopeKey: "college_science",
13
13
  });
14
14
 
15
15
  assert.equal(group.logic, "AND");
16
16
  assert.deepEqual(
17
17
  group.rules.map((item) => item.key),
18
- ["status", "priority", "collegeId"],
18
+ ["status", "priority", "collegeScopeKey"],
19
19
  );
20
20
  assert.equal(group.conditions.length, 1);
21
21
  assert.equal(group.conditions[0]?.logic, "OR");
@@ -44,17 +44,17 @@ export function buildTicketFilterGroup(search: TicketSearchState): FilterGroup {
44
44
  if (search.priorities?.length) {
45
45
  group.rules.push(rule("priority", "SelectField", "IN", search.priorities));
46
46
  }
47
- if (search.ownerUserId) {
48
- group.rules.push(rule("ownerUserId", "TextField", "EQ", search.ownerUserId));
47
+ if (search.ownerUserScopeKey) {
48
+ group.rules.push(rule("ownerUserScopeKey", "TextField", "EQ", search.ownerUserScopeKey));
49
49
  }
50
- if (search.ownerDeptId) {
51
- group.rules.push(rule("ownerDeptId", "TextField", "EQ", search.ownerDeptId));
50
+ if (search.ownerDeptScopeKey) {
51
+ group.rules.push(rule("ownerDeptScopeKey", "TextField", "EQ", search.ownerDeptScopeKey));
52
52
  }
53
- if (search.collegeId) {
54
- group.rules.push(rule("collegeId", "TextField", "EQ", search.collegeId));
53
+ if (search.collegeScopeKey) {
54
+ group.rules.push(rule("collegeScopeKey", "TextField", "EQ", search.collegeScopeKey));
55
55
  }
56
- if (search.classId) {
57
- group.rules.push(rule("classId", "TextField", "EQ", search.classId));
56
+ if (search.classScopeKey) {
57
+ group.rules.push(rule("classScopeKey", "TextField", "EQ", search.classScopeKey));
58
58
  }
59
59
  if (search.keyword?.trim()) {
60
60
  const keyword = search.keyword.trim();
@@ -23,10 +23,12 @@ export interface TicketRecord {
23
23
  title: string;
24
24
  status: TicketStatus;
25
25
  priority?: TicketPriority;
26
- ownerUserId?: string;
27
- ownerDeptId?: string;
28
- collegeId?: string;
29
- classId?: string;
26
+ ownerUserScopeKey?: string;
27
+ ownerDeptScopeKey?: string;
28
+ collegeScopeKey?: string;
29
+ classScopeKey?: string;
30
+ college?: { label: string; value: string };
31
+ classGroup?: { label: string; value: string };
30
32
  requester?: { label: string; value: string };
31
33
  currentOwner?: { label: string; value: string };
32
34
  description?: string;
@@ -48,8 +50,8 @@ export interface TicketOperator {
48
50
  userId: string;
49
51
  userName?: string;
50
52
  roleCodes: string[];
51
- collegeIds?: string[];
52
- classIds?: string[];
53
+ collegeScopeKeys?: string[];
54
+ classScopeKeys?: string[];
53
55
  departmentIds?: string[];
54
56
  }
55
57
 
@@ -57,8 +59,8 @@ export interface TicketSearchState {
57
59
  keyword?: string;
58
60
  statuses?: TicketStatus[];
59
61
  priorities?: TicketPriority[];
60
- ownerUserId?: string;
61
- ownerDeptId?: string;
62
- collegeId?: string;
63
- classId?: string;
62
+ ownerUserScopeKey?: string;
63
+ ownerDeptScopeKey?: string;
64
+ collegeScopeKey?: string;
65
+ classScopeKey?: string;
64
66
  }
@@ -19,29 +19,62 @@ export default createFormSchema({
19
19
  componentName: "TextField",
20
20
  label: "角色名称",
21
21
  required: true,
22
+ placeholder: "请输入角色名称",
22
23
  },
23
24
  {
24
25
  fieldId: "members",
25
26
  componentName: "UserSelectField",
26
27
  label: "成员",
27
28
  multiple: true,
29
+ placeholder: "请选择角色成员",
28
30
  },
29
31
  {
30
- fieldId: "collegeId",
32
+ fieldId: "collegeScope",
33
+ componentName: "AssociationFormField",
34
+ label: "适用学院",
35
+ placeholder: "请选择适用学院",
36
+ associationForm: {
37
+ appType: process.env.OPENXIANGDA_APP_TYPE || "APP_XXXX",
38
+ formUuid: "FORM_COLLEGE_PROFILE",
39
+ mainFieldId: "collegeName",
40
+ selectorColumns: [
41
+ { title: "学院名称", dataIndex: "collegeName" },
42
+ { title: "学院编码", dataIndex: "collegeCode" },
43
+ ],
44
+ },
45
+ },
46
+ {
47
+ fieldId: "classScope",
48
+ componentName: "AssociationFormField",
49
+ label: "适用班级",
50
+ placeholder: "请选择适用班级",
51
+ associationForm: {
52
+ appType: process.env.OPENXIANGDA_APP_TYPE || "APP_XXXX",
53
+ formUuid: "FORM_CLASS_PROFILE",
54
+ mainFieldId: "className",
55
+ selectorColumns: [
56
+ { title: "班级名称", dataIndex: "className" },
57
+ { title: "所属学院", dataIndex: "collegeName" },
58
+ ],
59
+ },
60
+ },
61
+ {
62
+ fieldId: "collegeScopeKey",
31
63
  componentName: "TextField",
32
- label: "学院 ID",
33
- tips: "与业务表冗余字段配合,用于条件式数据权限。",
64
+ label: "学院权限键",
65
+ behavior: "HIDDEN",
34
66
  },
35
67
  {
36
- fieldId: "classId",
68
+ fieldId: "classScopeKey",
37
69
  componentName: "TextField",
38
- label: "班级 ID",
39
- tips: "与业务表冗余字段配合,用于条件式数据权限。",
70
+ label: "班级权限键",
71
+ behavior: "HIDDEN",
40
72
  },
41
73
  {
42
74
  fieldId: "enabled",
43
75
  componentName: "RadioField",
44
76
  label: "启用状态",
77
+ placeholder: "请选择启用状态",
45
78
  defaultValue: { label: "启用", value: "enabled" },
46
79
  options: [
47
80
  { label: "启用", value: "enabled" },
@@ -52,6 +85,7 @@ export default createFormSchema({
52
85
  fieldId: "lastSyncedAt",
53
86
  componentName: "DateField",
54
87
  label: "最近同步时间",
88
+ behavior: "HIDDEN",
55
89
  },
56
90
  ],
57
91
  });
@@ -19,6 +19,7 @@ export default createFormSchema({
19
19
  componentName: "SelectField",
20
20
  label: "客户等级",
21
21
  required: true,
22
+ placeholder: "请选择客户等级",
22
23
  options: [
23
24
  { label: "重点客户", value: "key" },
24
25
  { label: "普通客户", value: "normal" },
@@ -30,45 +31,53 @@ export default createFormSchema({
30
31
  componentName: "UserSelectField",
31
32
  label: "负责人",
32
33
  required: true,
34
+ placeholder: "请选择负责人",
33
35
  },
34
36
  {
35
37
  fieldId: "ownerDept",
36
38
  componentName: "DepartmentSelectField",
37
39
  label: "负责部门",
38
40
  required: true,
41
+ placeholder: "请选择负责部门",
39
42
  },
40
43
  {
41
44
  fieldId: "contactPhone",
42
45
  componentName: "TextField",
43
46
  label: "联系电话",
47
+ placeholder: "请输入联系电话",
44
48
  rules: [{ preset: "phone", message: "请输入有效手机号" }],
45
49
  },
46
50
  {
47
51
  fieldId: "attachments",
48
52
  componentName: "AttachmentField",
49
53
  label: "附件",
54
+ placeholder: "请上传客户相关附件",
50
55
  maxCount: 5,
51
56
  },
52
57
  {
53
58
  fieldId: "contacts",
54
59
  componentName: "SubFormField",
55
60
  label: "联系人",
61
+ placeholder: "请添加联系人",
56
62
  columns: [
57
63
  {
58
64
  fieldId: "contactName",
59
65
  componentName: "TextField",
60
66
  label: "姓名",
61
67
  required: true,
68
+ placeholder: "请输入联系人姓名",
62
69
  },
63
70
  {
64
71
  fieldId: "contactRole",
65
72
  componentName: "TextField",
66
73
  label: "角色",
74
+ placeholder: "请输入联系人角色",
67
75
  },
68
76
  {
69
77
  fieldId: "contactMobile",
70
78
  componentName: "TextField",
71
79
  label: "手机",
80
+ placeholder: "请输入联系人手机",
72
81
  rules: [{ preset: "phone", message: "请输入有效手机号" }],
73
82
  },
74
83
  ],
@@ -12,12 +12,14 @@ export default createFormSchema({
12
12
  componentName: "TextField",
13
13
  label: "标题",
14
14
  required: true,
15
+ placeholder: "请输入工单标题",
15
16
  },
16
17
  {
17
18
  fieldId: "status",
18
19
  componentName: "SelectField",
19
20
  label: "状态",
20
21
  required: true,
22
+ placeholder: "请选择工单状态",
21
23
  defaultValue: { label: "新建", value: "new" },
22
24
  options: [
23
25
  { label: "新建", value: "new" },
@@ -33,6 +35,7 @@ export default createFormSchema({
33
35
  fieldId: "priority",
34
36
  componentName: "SelectField",
35
37
  label: "优先级",
38
+ placeholder: "请选择优先级",
36
39
  defaultValue: { label: "普通", value: "normal" },
37
40
  options: [
38
41
  { label: "紧急", value: "urgent" },
@@ -42,55 +45,90 @@ export default createFormSchema({
42
45
  ],
43
46
  },
44
47
  {
45
- fieldId: "ownerUserId",
46
- componentName: "TextField",
47
- label: "负责人 ID",
48
- tips: "权限隔离冗余字段,服务层从人员字段同步写入。",
48
+ fieldId: "currentOwner",
49
+ componentName: "UserSelectField",
50
+ label: "当前处理人",
51
+ placeholder: "请选择当前处理人",
52
+ },
53
+ {
54
+ fieldId: "college",
55
+ componentName: "AssociationFormField",
56
+ label: "所属学院",
57
+ placeholder: "请选择所属学院",
58
+ associationForm: {
59
+ appType: process.env.OPENXIANGDA_APP_TYPE || "APP_XXXX",
60
+ formUuid: "FORM_COLLEGE_PROFILE",
61
+ mainFieldId: "collegeName",
62
+ selectorColumns: [
63
+ { title: "学院名称", dataIndex: "collegeName" },
64
+ { title: "学院编码", dataIndex: "collegeCode" },
65
+ ],
66
+ },
49
67
  },
50
68
  {
51
- fieldId: "ownerDeptId",
69
+ fieldId: "classGroup",
70
+ componentName: "AssociationFormField",
71
+ label: "所属班级",
72
+ placeholder: "请选择所属班级",
73
+ associationForm: {
74
+ appType: process.env.OPENXIANGDA_APP_TYPE || "APP_XXXX",
75
+ formUuid: "FORM_CLASS_PROFILE",
76
+ mainFieldId: "className",
77
+ selectorColumns: [
78
+ { title: "班级名称", dataIndex: "className" },
79
+ { title: "所属学院", dataIndex: "collegeName" },
80
+ ],
81
+ },
82
+ },
83
+ {
84
+ fieldId: "ownerUserScopeKey",
52
85
  componentName: "TextField",
53
- label: "负责部门 ID",
54
- tips: "权限隔离冗余字段,权限组按此字段配置条件。",
86
+ label: "负责人权限键",
87
+ behavior: "HIDDEN",
55
88
  },
56
89
  {
57
- fieldId: "collegeId",
90
+ fieldId: "ownerDeptScopeKey",
58
91
  componentName: "TextField",
59
- label: "学院 ID",
60
- tips: "示例冗余字段,可换成业务组织维度。",
92
+ label: "负责部门权限键",
93
+ behavior: "HIDDEN",
61
94
  },
62
95
  {
63
- fieldId: "classId",
96
+ fieldId: "collegeScopeKey",
64
97
  componentName: "TextField",
65
- label: "班级 ID",
66
- tips: "示例冗余字段,可换成业务组织维度。",
98
+ label: "学院权限键",
99
+ behavior: "HIDDEN",
67
100
  },
68
101
  {
69
- fieldId: "currentOwner",
70
- componentName: "UserSelectField",
71
- label: "当前处理人",
102
+ fieldId: "classScopeKey",
103
+ componentName: "TextField",
104
+ label: "班级权限键",
105
+ behavior: "HIDDEN",
72
106
  },
73
107
  {
74
108
  fieldId: "requester",
75
109
  componentName: "UserSelectField",
76
110
  label: "提交人",
77
111
  required: true,
112
+ placeholder: "请选择提交人",
78
113
  },
79
114
  {
80
115
  fieldId: "description",
81
116
  componentName: "TextAreaField",
82
117
  label: "问题描述",
83
118
  required: true,
119
+ placeholder: "请描述问题现象、影响范围和期望处理结果",
84
120
  },
85
121
  {
86
122
  fieldId: "lastActionAt",
87
123
  componentName: "DateField",
88
124
  label: "最近操作时间",
125
+ behavior: "HIDDEN",
89
126
  },
90
127
  {
91
128
  fieldId: "attachments",
92
129
  componentName: "AttachmentField",
93
130
  label: "附件",
131
+ placeholder: "请上传问题相关附件",
94
132
  maxCount: 8,
95
133
  },
96
134
  ],
@@ -24,10 +24,13 @@ export function TicketDetailDrawer(props: {
24
24
  <StatusTag status={props.ticket.status} />
25
25
  </Descriptions.Item>
26
26
  <Descriptions.Item label="负责人">
27
- {props.ticket.currentOwner?.label || props.ticket.ownerUserId || "-"}
27
+ {props.ticket.currentOwner?.label || "-"}
28
28
  </Descriptions.Item>
29
- <Descriptions.Item label="归属部门">
30
- {props.ticket.ownerDeptId || "-"}
29
+ <Descriptions.Item label="所属学院">
30
+ {props.ticket.college?.label || "-"}
31
+ </Descriptions.Item>
32
+ <Descriptions.Item label="所属班级">
33
+ {props.ticket.classGroup?.label || "-"}
31
34
  </Descriptions.Item>
32
35
  </Descriptions>
33
36
  <Typography.Paragraph className="bp-ticket-detail__description">
@@ -14,10 +14,12 @@ const normalizeTicket = (record: any): TicketRecord => ({
14
14
  title: record.title || record.formData?.title || "未命名工单",
15
15
  status: record.status || record.formData?.status?.value || record.formData?.status || "new",
16
16
  priority: record.priority?.value || record.formData?.priority?.value,
17
- ownerUserId: record.ownerUserId || record.formData?.ownerUserId,
18
- ownerDeptId: record.ownerDeptId || record.formData?.ownerDeptId,
19
- collegeId: record.collegeId || record.formData?.collegeId,
20
- classId: record.classId || record.formData?.classId,
17
+ ownerUserScopeKey: record.ownerUserScopeKey || record.formData?.ownerUserScopeKey,
18
+ ownerDeptScopeKey: record.ownerDeptScopeKey || record.formData?.ownerDeptScopeKey,
19
+ collegeScopeKey: record.collegeScopeKey || record.formData?.collegeScopeKey,
20
+ classScopeKey: record.classScopeKey || record.formData?.classScopeKey,
21
+ college: record.college || record.formData?.college,
22
+ classGroup: record.classGroup || record.formData?.classGroup,
21
23
  requester: record.requester || record.formData?.requester,
22
24
  currentOwner: record.currentOwner || record.formData?.currentOwner,
23
25
  description: record.description || record.formData?.description,
@@ -10,10 +10,10 @@
10
10
  "logic": "AND",
11
11
  "rules": [
12
12
  {
13
- "field": "collegeId",
13
+ "field": "collegeScopeKey",
14
14
  "componentType": "Text",
15
15
  "op": "=",
16
- "value": "${ROLE_SCOPE_COLLEGE_ID}"
16
+ "value": "${ROLE_SCOPE_COLLEGE_KEY}"
17
17
  }
18
18
  ]
19
19
  }
@@ -7,11 +7,11 @@
7
7
  {
8
8
  "code": "college_admin",
9
9
  "name": "学院管理员",
10
- "description": "通过业务角色表动态维护,按 collegeId 隔离数据"
10
+ "description": "通过业务角色表动态维护,按学院权限键隔离数据"
11
11
  },
12
12
  {
13
13
  "code": "class_owner",
14
14
  "name": "班级负责人",
15
- "description": "通过业务角色表动态维护,按 classId 隔离数据"
15
+ "description": "通过业务角色表动态维护,按班级权限键隔离数据"
16
16
  }
17
17
  ]
@@ -58,10 +58,10 @@ export async function transitionTicket(
58
58
  data: {
59
59
  status: nextStatus,
60
60
  lastActionAt: operatedAt,
61
- ownerUserId: ticket.ownerUserId,
62
- ownerDeptId: ticket.ownerDeptId,
63
- collegeId: ticket.collegeId,
64
- classId: ticket.classId,
61
+ ownerUserScopeKey: ticket.ownerUserScopeKey,
62
+ ownerDeptScopeKey: ticket.ownerDeptScopeKey,
63
+ collegeScopeKey: ticket.collegeScopeKey,
64
+ classScopeKey: ticket.classScopeKey,
65
65
  },
66
66
  } as never);
67
67
 
@@ -26,6 +26,7 @@ export default createFormSchema({
26
26
  fieldId: "customer_type",
27
27
  componentName: "SelectField",
28
28
  label: "客户类型",
29
+ placeholder: "请选择客户类型",
29
30
  options: [
30
31
  { value: "enterprise", label: "企业客户" },
31
32
  { value: "individual", label: "个人客户" },
@@ -16,6 +16,7 @@ export type FormComponentName =
16
16
  | "ImageField"
17
17
  | "AddressField"
18
18
  | "CascadeSelectField"
19
+ | "AssociationFormField"
19
20
  | "LocationField"
20
21
  | "EditorField"
21
22
  | "JSONField"
@@ -33,6 +34,7 @@ export type FieldDefinition = {
33
34
  componentName: FormComponentName;
34
35
  label: string;
35
36
  required?: boolean;
37
+ behavior?: "NORMAL" | "READONLY" | "DISABLED" | "HIDDEN";
36
38
  rules?: Array<{
37
39
  required?: boolean;
38
40
  message?: string;