create-windy 0.6.21 → 0.7.0

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 (124) hide show
  1. package/dist/cli.js +11 -4
  2. package/package.json +1 -1
  3. package/template/.windy-template.json +2 -2
  4. package/template/apps/server/src/audit/policy.ts +6 -1
  5. package/template/apps/server/src/auth/bootstrap-admin.ts +10 -1
  6. package/template/apps/server/src/auth/composition.ts +5 -0
  7. package/template/apps/server/src/auth/credential-authorization.test.ts +257 -0
  8. package/template/apps/server/src/auth/credential-routes.test.ts +1 -0
  9. package/template/apps/server/src/auth/credential-service.ts +47 -12
  10. package/template/apps/server/src/auth/drizzle-repository.ts +13 -5
  11. package/template/apps/server/src/auth/drizzle-session-operations.ts +1 -0
  12. package/template/apps/server/src/auth/repository.ts +9 -6
  13. package/template/apps/server/src/auth/routes.test-support.ts +1 -0
  14. package/template/apps/server/src/auth/runtime.ts +4 -0
  15. package/template/apps/server/src/auth/service-options.ts +2 -0
  16. package/template/apps/server/src/auth/service.ts +2 -0
  17. package/template/apps/server/src/auth/user-credential-access.ts +37 -0
  18. package/template/apps/server/src/foundation-activation.test.ts +24 -1
  19. package/template/apps/server/src/foundation.ts +0 -2
  20. package/template/apps/server/src/index.ts +1 -0
  21. package/template/apps/server/src/runtime-development.ts +2 -0
  22. package/template/apps/server/src/runtime.test.ts +13 -4
  23. package/template/apps/server/src/system/department-organization.ts +58 -59
  24. package/template/apps/server/src/system/department-read-routes.ts +54 -0
  25. package/template/apps/server/src/system/department-routes.test.ts +27 -19
  26. package/template/apps/server/src/system/department-routes.ts +95 -33
  27. package/template/apps/server/src/system/department-transition-safety.test.ts +148 -0
  28. package/template/apps/server/src/system/department-validation.ts +79 -0
  29. package/template/apps/server/src/system/drizzle-repository.ts +14 -0
  30. package/template/apps/server/src/system/drizzle-system-configs.ts +2 -0
  31. package/template/apps/server/src/system/identity-read-model.ts +105 -0
  32. package/template/apps/server/src/system/identity-role-assignment.test.ts +199 -0
  33. package/template/apps/server/src/system/identity-role-assignment.ts +148 -0
  34. package/template/apps/server/src/system/identity-route-config.ts +29 -7
  35. package/template/apps/server/src/system/identity-write-security.test.ts +127 -0
  36. package/template/apps/server/src/system/identity-write-validation.ts +170 -0
  37. package/template/apps/server/src/system/organization-read-security.test.ts +221 -0
  38. package/template/apps/server/src/system/redis-health.test.ts +1 -1
  39. package/template/apps/server/src/system/repository.ts +29 -2
  40. package/template/apps/server/src/system/resource-routes.ts +116 -72
  41. package/template/apps/server/src/system/route-audit.test.ts +117 -0
  42. package/template/apps/server/src/system/route-helpers.ts +14 -0
  43. package/template/apps/server/src/system/route-types.ts +11 -2
  44. package/template/apps/server/src/system/routes.test.ts +0 -68
  45. package/template/apps/server/src/system/routes.ts +29 -2
  46. package/template/apps/server/src/system/user-account-policy.test.ts +3 -1
  47. package/template/apps/server/src/system/user-account-policy.ts +26 -9
  48. package/template/apps/web/src/pages/system/SystemResourceRoutePage.vue +27 -13
  49. package/template/apps/web/src/pages/system/SystemResourceRoutePage.webtest.ts +68 -0
  50. package/template/apps/web/src/pages/system/components/PagedSelectionList.vue +131 -0
  51. package/template/apps/web/src/pages/system/components/PagedSelectionList.webtest.ts +32 -0
  52. package/template/apps/web/src/pages/system/components/SystemRecordDetailDialog.vue +67 -0
  53. package/template/apps/web/src/pages/system/components/SystemResourceEditor.vue +114 -38
  54. package/template/apps/web/src/pages/system/components/SystemResourceEditor.webtest.ts +180 -0
  55. package/template/apps/web/src/pages/system/components/SystemResourceFilters.vue +8 -0
  56. package/template/apps/web/src/pages/system/components/SystemResourceForm.vue +8 -2
  57. package/template/apps/web/src/pages/system/components/SystemResourcePage.department.webtest.ts +89 -0
  58. package/template/apps/web/src/pages/system/components/SystemResourcePage.vue +42 -46
  59. package/template/apps/web/src/pages/system/components/SystemResourcePage.webtest.ts +12 -55
  60. package/template/apps/web/src/pages/system/components/SystemResourceTable.vue +14 -2
  61. package/template/apps/web/src/pages/system/components/department/DepartmentOrganizationForm.vue +121 -85
  62. package/template/apps/web/src/pages/system/components/department/DepartmentOrganizationForm.webtest.ts +57 -0
  63. package/template/apps/web/src/pages/system/components/department/DepartmentOrganizationTable.vue +227 -0
  64. package/template/apps/web/src/pages/system/components/department/DepartmentOrganizationTable.webtest.ts +62 -0
  65. package/template/apps/web/src/pages/system/components/department/DepartmentTransitionDialog.vue +72 -52
  66. package/template/apps/web/src/pages/system/components/role-policy/RolePermissionTree.vue +150 -79
  67. package/template/apps/web/src/pages/system/components/role-policy/RolePolicyForm.vue +98 -8
  68. package/template/apps/web/src/pages/system/components/role-policy/RolePolicyForm.webtest.ts +108 -0
  69. package/template/apps/web/src/pages/system/components/selection-options.ts +71 -0
  70. package/template/apps/web/src/pages/system/components/selection-options.webtest.ts +49 -0
  71. package/template/apps/web/src/pages/system/components/user-account/CreateDepartmentDialog.vue +19 -27
  72. package/template/apps/web/src/pages/system/components/user-account/CreateDepartmentDialog.webtest.ts +2 -6
  73. package/template/apps/web/src/pages/system/components/user-account/CreateRoleDialog.vue +128 -0
  74. package/template/apps/web/src/pages/system/components/user-account/CreateRoleDialog.webtest.ts +76 -0
  75. package/template/apps/web/src/pages/system/components/user-account/RolePermissionSummary.vue +61 -3
  76. package/template/apps/web/src/pages/system/components/user-account/UserAccountForm.vue +144 -90
  77. package/template/apps/web/src/pages/system/components/user-account/UserAccountForm.webtest.ts +95 -0
  78. package/template/apps/web/src/pages/system/components/user-account/UserListFilters.vue +168 -0
  79. package/template/apps/web/src/pages/system/components/user-account/UserRoleSelector.vue +33 -57
  80. package/template/apps/web/src/pages/system/components/user-account/UserRoleSelector.webtest.ts +4 -4
  81. package/template/apps/web/src/pages/system/components/user-account/save-user-account.ts +4 -1
  82. package/template/apps/web/src/pages/system/components/user-account/user-account-draft.ts +81 -0
  83. package/template/apps/web/src/pages/system/composables/useDepartmentTransitions.ts +6 -10
  84. package/template/apps/web/src/pages/system/composables/useResourceEditor.ts +72 -0
  85. package/template/apps/web/src/pages/system/composables/useResourceEditor.webtest.ts +110 -0
  86. package/template/apps/web/src/pages/system/composables/useSystemResource.ts +27 -1
  87. package/template/apps/web/src/pages/system/resource-config.ts +2 -0
  88. package/template/apps/web/src/services/system-api.ts +25 -0
  89. package/template/docs/architecture/foundation-infrastructure.md +12 -25
  90. package/template/docs/development/crud-generator.md +1 -1
  91. package/template/docs/operations/legacy-data-migration.md +73 -0
  92. package/template/docs/platform/bulk-data-jobs.md +1 -1
  93. package/template/docs/platform/module-configuration.md +2 -2
  94. package/template/docs/platform/organization-membership.md +37 -6
  95. package/template/docs/platform/scheduled-task-executor.md +1 -1
  96. package/template/package.json +2 -1
  97. package/template/packages/config/index.test.ts +1 -5
  98. package/template/packages/config/package.json +2 -2
  99. package/template/packages/config/src/platform-boundary.test.ts +39 -0
  100. package/template/packages/config/src/platform.ts +0 -28
  101. package/template/packages/database/drizzle/0063_license_project_lifecycle.sql +28 -0
  102. package/template/packages/database/drizzle/meta/0063_snapshot.json +15053 -0
  103. package/template/packages/database/drizzle/meta/_journal.json +7 -0
  104. package/template/packages/database/package.json +2 -2
  105. package/template/packages/database/src/agent-runtime-schema.test.ts +5 -1
  106. package/template/packages/database/src/schema/index.ts +1 -0
  107. package/template/packages/database/src/schema/license-issuance-requests.ts +59 -0
  108. package/template/packages/database/src/schema/license-v1.ts +5 -0
  109. package/template/packages/jobs/package.json +2 -2
  110. package/template/packages/modules/package.json +1 -1
  111. package/template/packages/modules/src/system-api-permissions.ts +14 -0
  112. package/template/packages/modules/src/system-features.ts +0 -11
  113. package/template/packages/modules/src/system-module-catalog.test.ts +4 -0
  114. package/template/packages/modules/src/system-module-catalog.ts +0 -1
  115. package/template/packages/server-sdk/index.ts +1 -49
  116. package/template/packages/server-sdk/package.json +3 -2
  117. package/template/packages/server-sdk/src/ai-runtime-readiness-exports.ts +49 -0
  118. package/template/packages/shared/index.ts +2 -1
  119. package/template/packages/shared/package.json +1 -1
  120. package/template/packages/shared/src/license-console-capabilities.ts +163 -0
  121. package/template/packages/shared/src/license-console.ts +94 -0
  122. package/template/packages/shared/src/license.ts +5 -0
  123. package/template/docs/adapters/ruoyi-vue-compatibility.md +0 -93
  124. package/template/packages/shared/src/ruoyi.ts +0 -98
@@ -2,13 +2,11 @@
2
2
  import { computed, shallowRef, watch } from "vue";
3
3
  import FormDialog from "@/components/common/FormDialog.vue";
4
4
  import {
5
- Select,
6
- SelectContent,
7
- SelectItem,
8
- SelectTrigger,
9
- SelectValue,
10
- } from "@/components/ui/select";
11
- import { listSystemResource, type SystemRecord } from "@/services/system-api";
5
+ previewDepartmentTransition,
6
+ type SystemRecord,
7
+ } from "@/services/system-api";
8
+ import PagedSelectionList from "../PagedSelectionList.vue";
9
+ import { departmentOptions, loadSelectionRecords } from "../selection-options";
12
10
 
13
11
  const open = defineModel<boolean>("open", { default: false });
14
12
  const props = defineProps<{
@@ -16,17 +14,29 @@ const props = defineProps<{
16
14
  loading?: boolean;
17
15
  }>();
18
16
  const emit = defineEmits<{
19
- confirm: [targetDepartmentId: string];
17
+ confirm: [targetDepartmentId: string | undefined];
20
18
  }>();
21
19
 
22
20
  const targetDepartmentId = shallowRef<string>();
23
21
  const departments = shallowRef<SystemRecord[]>([]);
24
22
  const error = shallowRef("");
25
23
  const targets = computed(() =>
26
- departments.value.filter(
27
- (department) =>
28
- department.id !== props.department?.id &&
29
- !isDescendant(department.id, props.department?.id),
24
+ departmentOptions(departments.value, props.department?.id),
25
+ );
26
+ const fetching = shallowRef(false);
27
+ const preview = shallowRef<{
28
+ childDepartmentCount: number;
29
+ userCount: number;
30
+ roleCount: number;
31
+ }>();
32
+
33
+ const requiresTarget = computed(() =>
34
+ Boolean(
35
+ preview.value &&
36
+ preview.value.childDepartmentCount +
37
+ preview.value.userCount +
38
+ preview.value.roleCount >
39
+ 0,
30
40
  ),
31
41
  );
32
42
 
@@ -38,39 +48,34 @@ watch(open, (value) => {
38
48
 
39
49
  async function loadDepartments() {
40
50
  error.value = "";
51
+ fetching.value = true;
52
+ preview.value = undefined;
41
53
  try {
42
- const result = await listSystemResource("departments", {
43
- page: 1,
44
- pageSize: 100,
45
- status: "active",
46
- sort: "order,name",
47
- });
48
- departments.value = result.items;
54
+ if (!props.department?.parentId) return;
55
+ const [records, impact] = await Promise.all([
56
+ loadSelectionRecords("departments", false),
57
+ previewDepartmentTransition(props.department.id),
58
+ ]);
59
+ departments.value = records;
60
+ preview.value = impact;
49
61
  } catch (currentError) {
50
62
  error.value =
51
63
  currentError instanceof Error
52
64
  ? currentError.message
53
65
  : String(currentError);
66
+ } finally {
67
+ fetching.value = false;
54
68
  }
55
69
  }
56
70
 
57
71
  function submit() {
58
- if (targetDepartmentId.value) emit("confirm", targetDepartmentId.value);
59
- }
60
-
61
- function isDescendant(candidateId: string, ancestorId: unknown): boolean {
62
- if (typeof ancestorId !== "string") return false;
63
- const byId = new Map(
64
- departments.value.map((department) => [department.id, department]),
65
- );
66
- const visited = new Set<string>();
67
- let current = byId.get(candidateId);
68
- while (typeof current?.parentId === "string" && !visited.has(current.id)) {
69
- visited.add(current.id);
70
- if (current.parentId === ancestorId) return true;
71
- current = byId.get(current.parentId);
72
- }
73
- return false;
72
+ if (
73
+ (!requiresTarget.value || targetDepartmentId.value) &&
74
+ preview.value &&
75
+ !fetching.value &&
76
+ !props.loading
77
+ )
78
+ emit("confirm", targetDepartmentId.value);
74
79
  }
75
80
  </script>
76
81
 
@@ -81,7 +86,13 @@ function isDescendant(candidateId: string, ancestorId: unknown): boolean {
81
86
  description="删除前会把下级部门、用户归属和角色自定义部门范围迁移到指定部门。"
82
87
  submit-text="迁移并删除"
83
88
  :loading="loading"
84
- :submit-disabled="!department?.parentId || !targetDepartmentId"
89
+ :before-close="() => !loading"
90
+ :submit-disabled="
91
+ !department?.parentId ||
92
+ (requiresTarget && !targetDepartmentId) ||
93
+ fetching ||
94
+ !preview
95
+ "
85
96
  @submit="submit"
86
97
  >
87
98
  <p
@@ -90,23 +101,32 @@ function isDescendant(candidateId: string, ancestorId: unknown): boolean {
90
101
  >
91
102
  平台唯一根组织不能删除。
92
103
  </p>
93
- <label v-else class="grid gap-2 text-sm font-medium">
104
+ <div v-else class="grid gap-2 text-sm font-medium">
94
105
  <span>迁移目标</span>
95
- <Select v-model="targetDepartmentId">
96
- <SelectTrigger class="w-full" aria-label="删除部门迁移目标">
97
- <SelectValue placeholder="请选择启用部门" />
98
- </SelectTrigger>
99
- <SelectContent>
100
- <SelectItem
101
- v-for="department in targets"
102
- :key="department.id"
103
- :value="department.id"
104
- >
105
- {{ String(department.name || department.code) }}
106
- </SelectItem>
107
- </SelectContent>
108
- </Select>
109
- </label>
106
+ <p v-if="fetching" class="text-sm text-muted-foreground">
107
+ 正在读取迁移影响…
108
+ </p>
109
+ <p v-else-if="preview" class="text-sm">
110
+ 将迁移 {{ preview.childDepartmentCount }} 个直属下级部门、{{
111
+ preview.userCount
112
+ }}
113
+ 个账号归属、{{ preview.roleCount }} 个角色数据范围。
114
+ </p>
115
+ <p
116
+ v-if="preview && !requiresTarget"
117
+ class="text-sm text-muted-foreground"
118
+ >
119
+ 没有关联数据,可直接删除。
120
+ </p>
121
+ <PagedSelectionList
122
+ :items="targets"
123
+ :model-value="targetDepartmentId ? [targetDepartmentId] : []"
124
+ label="迁移目标"
125
+ clearable
126
+ :disabled="fetching || loading || !preview"
127
+ @update:model-value="targetDepartmentId = $event[0]"
128
+ />
129
+ </div>
110
130
  <p v-if="error" class="text-sm text-destructive">{{ error }}</p>
111
131
  </FormDialog>
112
132
  </template>
@@ -1,6 +1,8 @@
1
1
  <script setup lang="ts">
2
- import { computed } from "vue";
2
+ import { computed, shallowRef, watch } from "vue";
3
3
  import type { PermissionDefinition } from "@southwind-ai/shared";
4
+ import { Input } from "@/components/ui/input";
5
+ import { Button } from "@/components/ui/button";
4
6
  import { Checkbox } from "@/components/ui/checkbox";
5
7
  import {
6
8
  Tooltip,
@@ -23,7 +25,36 @@ const emit = defineEmits<{
23
25
  "update:modelValue": [value: string[]];
24
26
  }>();
25
27
 
26
- const groups = computed(() => groupPermissions(props.permissions));
28
+ const keyword = shallowRef("");
29
+ const page = shallowRef(1);
30
+ const filtered = computed(() =>
31
+ props.permissions.filter((permission) =>
32
+ [
33
+ permission.key,
34
+ permission.label,
35
+ permission.moduleLabel,
36
+ permission.resourceLabel,
37
+ permission.description,
38
+ ]
39
+ .join(" ")
40
+ .toLocaleLowerCase()
41
+ .includes(keyword.value.trim().toLocaleLowerCase()),
42
+ ),
43
+ );
44
+ const pageCount = computed(() =>
45
+ Math.max(1, Math.ceil(filtered.value.length / 10)),
46
+ );
47
+ const groups = computed(() =>
48
+ groupPermissions(
49
+ filtered.value.slice((page.value - 1) * 10, page.value * 10),
50
+ ),
51
+ );
52
+ watch(keyword, () => {
53
+ page.value = 1;
54
+ });
55
+ watch(pageCount, (count) => {
56
+ page.value = Math.min(page.value, count);
57
+ });
27
58
  const unknownKeys = computed(() =>
28
59
  unknownPermissionKeys(props.modelValue, props.permissions),
29
60
  );
@@ -53,92 +84,132 @@ function toggleKeys(keys: string[], checked: boolean) {
53
84
  <template>
54
85
  <TooltipProvider :delay-duration="0">
55
86
  <div class="space-y-4 rounded-lg border bg-muted/20 p-4">
56
- <section v-for="group in groups" :key="group.key" class="space-y-3">
57
- <label class="flex items-center gap-2 text-sm font-semibold">
58
- <Checkbox
59
- :data-testid="`permission-module-${group.key}`"
60
- :model-value="checkedState(groupKeys(group.resources))"
61
- @update:model-value="
62
- toggleKeys(groupKeys(group.resources), $event === true)
63
- "
64
- />
65
- {{ group.label }}
66
- </label>
67
- <div class="grid gap-3 pl-6 lg:grid-cols-2">
68
- <div
69
- v-for="resource in group.resources"
70
- :key="resource.key"
71
- class="rounded-md border bg-background p-3"
72
- >
73
- <label class="flex items-center gap-2 text-sm font-medium">
74
- <Checkbox
75
- :data-testid="`permission-resource-${resource.key}`"
76
- :model-value="
77
- checkedState(resource.permissions.map(({ key }) => key))
78
- "
79
- @update:model-value="
80
- toggleKeys(
81
- resource.permissions.map(({ key }) => key),
82
- $event === true,
83
- )
84
- "
85
- />
86
- {{ resource.label }}
87
- </label>
88
- <div class="mt-3 flex flex-wrap gap-x-5 gap-y-3 pl-6">
89
- <label
90
- v-for="permission in resource.permissions"
91
- :key="permission.key"
92
- class="flex items-center gap-2 text-sm text-muted-foreground"
93
- >
87
+ <Input
88
+ v-model="keyword"
89
+ @keydown.enter.prevent
90
+ aria-label="搜索功能权限"
91
+ placeholder="搜索权限名称、模块或权限 Key"
92
+ />
93
+ <p class="text-xs text-muted-foreground">
94
+ 已选择
95
+ {{ modelValue.length }}
96
+ 项;模块与资源勾选仅作用于本页,翻页保留已有选择。
97
+ </p>
98
+ <p v-if="!filtered.length" class="text-sm text-muted-foreground">
99
+ 暂无匹配权限。
100
+ </p>
101
+ <section v-for="group in groups" :key="group.key" class="space-y-3">
102
+ <label class="flex items-center gap-2 text-sm font-semibold">
103
+ <Checkbox
104
+ :data-testid="`permission-module-${group.key}`"
105
+ :model-value="checkedState(groupKeys(group.resources))"
106
+ @update:model-value="
107
+ toggleKeys(groupKeys(group.resources), $event === true)
108
+ "
109
+ />
110
+ {{ group.label }}
111
+ </label>
112
+ <div class="grid gap-3 pl-6 lg:grid-cols-2">
113
+ <div
114
+ v-for="resource in group.resources"
115
+ :key="resource.key"
116
+ class="rounded-md border bg-background p-3"
117
+ >
118
+ <label class="flex items-center gap-2 text-sm font-medium">
94
119
  <Checkbox
95
- :data-testid="`permission-${permission.key}`"
96
- :model-value="modelValue.includes(permission.key)"
120
+ :data-testid="`permission-resource-${resource.key}`"
121
+ :model-value="
122
+ checkedState(resource.permissions.map(({ key }) => key))
123
+ "
97
124
  @update:model-value="
98
- toggleKeys([permission.key], $event === true)
125
+ toggleKeys(
126
+ resource.permissions.map(({ key }) => key),
127
+ $event === true,
128
+ )
99
129
  "
100
130
  />
101
- <Tooltip>
102
- <TooltipTrigger as-child>
103
- <span
104
- class="cursor-help underline decoration-dotted underline-offset-4"
105
- >
106
- {{ permission.label }}
107
- </span>
108
- </TooltipTrigger>
109
- <TooltipContent class="max-w-xs space-y-1">
110
- <p class="font-mono text-xs">{{ permission.key }}</p>
111
- <p v-if="permission.description">
112
- {{ permission.description }}
113
- </p>
114
- </TooltipContent>
115
- </Tooltip>
131
+ {{ resource.label }}
116
132
  </label>
133
+ <div class="mt-3 flex flex-wrap gap-x-5 gap-y-3 pl-6">
134
+ <label
135
+ v-for="permission in resource.permissions"
136
+ :key="permission.key"
137
+ class="flex items-center gap-2 text-sm text-muted-foreground"
138
+ >
139
+ <Checkbox
140
+ :data-testid="`permission-${permission.key}`"
141
+ :model-value="modelValue.includes(permission.key)"
142
+ @update:model-value="
143
+ toggleKeys([permission.key], $event === true)
144
+ "
145
+ />
146
+ <Tooltip>
147
+ <TooltipTrigger as-child>
148
+ <span
149
+ class="cursor-help underline decoration-dotted underline-offset-4"
150
+ >
151
+ {{ permission.label }}
152
+ </span>
153
+ </TooltipTrigger>
154
+ <TooltipContent class="max-w-xs space-y-1">
155
+ <p class="font-mono text-xs">{{ permission.key }}</p>
156
+ <p v-if="permission.description">
157
+ {{ permission.description }}
158
+ </p>
159
+ </TooltipContent>
160
+ </Tooltip>
161
+ </label>
162
+ </div>
117
163
  </div>
118
164
  </div>
119
- </div>
120
- </section>
165
+ </section>
121
166
 
122
- <section
123
- v-if="unknownKeys.length"
124
- class="space-y-3 rounded-md border border-amber-500/30 bg-amber-500/5 p-3"
125
- >
126
- <p class="text-sm font-medium">未注册的历史权限</p>
127
- <p class="text-xs text-muted-foreground">
128
- 这些权限不在当前 Manifest 中。取消勾选会从角色策略中移除。
129
- </p>
130
- <label
131
- v-for="key in unknownKeys"
132
- :key="key"
133
- class="flex items-center gap-2 text-sm"
167
+ <div
168
+ class="flex flex-wrap items-center justify-between gap-2 text-xs text-muted-foreground"
169
+ >
170
+ <span
171
+ >共 {{ filtered.length }} 条,每页 10 条,第 {{ page }} /
172
+ {{ pageCount }} 页</span
173
+ >
174
+ <div class="flex gap-2">
175
+ <Button
176
+ type="button"
177
+ size="sm"
178
+ variant="outline"
179
+ :disabled="page <= 1"
180
+ @click="page--"
181
+ >上一页</Button
182
+ >
183
+ <Button
184
+ type="button"
185
+ size="sm"
186
+ variant="outline"
187
+ :disabled="page >= pageCount"
188
+ @click="page++"
189
+ >下一页</Button
190
+ >
191
+ </div>
192
+ </div>
193
+ <section
194
+ v-if="unknownKeys.length"
195
+ class="space-y-3 rounded-md border border-amber-500/30 bg-amber-500/5 p-3"
134
196
  >
135
- <Checkbox
136
- :model-value="true"
137
- @update:model-value="toggleKeys([key], $event === true)"
138
- />
139
- <code>{{ key }}</code>
140
- </label>
141
- </section>
197
+ <p class="text-sm font-medium">未注册的历史权限</p>
198
+ <p class="text-xs text-muted-foreground">
199
+ 这些权限不在当前 Manifest 中。取消勾选会从角色策略中移除。
200
+ </p>
201
+ <label
202
+ v-for="key in unknownKeys"
203
+ :key="key"
204
+ class="flex items-center gap-2 text-sm"
205
+ >
206
+ <Checkbox
207
+ :model-value="true"
208
+ @update:model-value="toggleKeys([key], $event === true)"
209
+ />
210
+ <code>{{ key }}</code>
211
+ </label>
212
+ </section>
142
213
  </div>
143
214
  </TooltipProvider>
144
215
  </template>
@@ -1,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- import { shallowRef, watch } from "vue";
2
+ import { computed, onMounted, shallowRef, watch } from "vue";
3
3
  import type { DataScope, PermissionDefinition } from "@southwind-ai/shared";
4
4
  import { Input } from "@/components/ui/input";
5
5
  import {
@@ -11,6 +11,8 @@ import {
11
11
  } from "@/components/ui/select";
12
12
  import type { SystemRecord } from "@/services/system-api";
13
13
  import { normalizePermissionKeys } from "./permission-tree";
14
+ import PagedSelectionList from "../PagedSelectionList.vue";
15
+ import { loadSelectionRecords, departmentOptions } from "../selection-options";
14
16
  import RolePermissionTree from "./RolePermissionTree.vue";
15
17
 
16
18
  interface RolePolicyDraft extends Record<string, unknown> {
@@ -18,6 +20,8 @@ interface RolePolicyDraft extends Record<string, unknown> {
18
20
  name: string;
19
21
  dataScope: DataScope;
20
22
  permissionKeys: string[];
23
+ departmentIds: string[];
24
+ menuKeys: string[];
21
25
  }
22
26
 
23
27
  const props = defineProps<{
@@ -31,25 +35,62 @@ const emit = defineEmits<{
31
35
  }>();
32
36
 
33
37
  const form = shallowRef<RolePolicyDraft>(createDraft());
38
+ const baseline = shallowRef("");
39
+ const dirty = computed(() => JSON.stringify(form.value) !== baseline.value);
40
+ const error = shallowRef("");
41
+ const departmentError = shallowRef("");
42
+ const departments = shallowRef<SystemRecord[]>([]);
43
+ const choices = computed(() => departmentOptions(departments.value));
44
+ function markSaved() {
45
+ baseline.value = JSON.stringify(form.value);
46
+ }
47
+ async function loadDepartments() {
48
+ try {
49
+ departments.value = await loadSelectionRecords("departments", false);
50
+ } catch (cause) {
51
+ departmentError.value =
52
+ cause instanceof Error ? cause.message : String(cause);
53
+ }
54
+ }
55
+ onMounted(() => {
56
+ if (form.value.dataScope === "custom") void loadDepartments();
57
+ });
58
+ watch(
59
+ () => form.value.dataScope,
60
+ (scope) => {
61
+ if (scope === "custom" && !departments.value.length) void loadDepartments();
62
+ },
63
+ );
34
64
 
35
65
  watch(
36
66
  () => [props.item, props.defaults] as const,
37
67
  () => {
38
68
  form.value = createDraft({ ...props.defaults, ...(props.item || {}) });
69
+ markSaved();
39
70
  },
40
71
  { immediate: true },
41
72
  );
42
73
 
43
74
  function createDraft(source: Record<string, unknown> = {}): RolePolicyDraft {
44
75
  return {
45
- ...source,
76
+ menuKeys: Array.isArray(source.menuKeys)
77
+ ? source.menuKeys
78
+ .filter((key): key is string => typeof key === "string")
79
+ .sort()
80
+ : [],
81
+ status: typeof source.status === "string" ? source.status : "active",
82
+ departmentIds: Array.isArray(source.departmentIds)
83
+ ? source.departmentIds
84
+ .filter((id): id is string => typeof id === "string")
85
+ .sort()
86
+ : [],
46
87
  code: String(source.code || ""),
47
88
  name: String(source.name || ""),
48
89
  dataScope: readDataScope(source.dataScope),
49
90
  permissionKeys: Array.isArray(source.permissionKeys)
50
- ? source.permissionKeys.filter(
51
- (key): key is string => typeof key === "string",
52
- )
91
+ ? source.permissionKeys
92
+ .filter((key): key is string => typeof key === "string")
93
+ .sort()
53
94
  : [],
54
95
  };
55
96
  }
@@ -67,17 +108,37 @@ function readDataScope(value: unknown): DataScope {
67
108
  }
68
109
 
69
110
  function update(key: keyof RolePolicyDraft, value: unknown) {
70
- form.value = { ...form.value, [key]: value };
111
+ form.value = {
112
+ ...form.value,
113
+ [key]: Array.isArray(value) ? [...value].sort() : value,
114
+ };
71
115
  }
72
116
 
73
117
  function submit() {
118
+ error.value = "";
119
+ if (!form.value.code.trim() || !form.value.name.trim()) {
120
+ error.value = "请填写角色编码和名称。";
121
+ return;
122
+ }
123
+ if (form.value.dataScope === "custom" && !form.value.departmentIds.length) {
124
+ error.value = "自定义数据范围须选择至少一个部门。";
125
+ return;
126
+ }
74
127
  emit("submit", {
75
128
  ...form.value,
76
129
  permissionKeys: normalizePermissionKeys(form.value.permissionKeys),
130
+ departmentIds:
131
+ form.value.dataScope === "custom" ? form.value.departmentIds : [],
77
132
  });
78
133
  }
79
134
 
80
- defineExpose({ submit });
135
+ defineExpose({
136
+ submit,
137
+ markSaved,
138
+ get dirty() {
139
+ return dirty.value;
140
+ },
141
+ });
81
142
  </script>
82
143
 
83
144
  <template>
@@ -100,6 +161,21 @@ defineExpose({ submit });
100
161
  />
101
162
  </label>
102
163
  </div>
164
+ <label class="grid gap-2 text-sm font-medium">
165
+ <span>角色状态</span>
166
+ <Select
167
+ :model-value="String(form.status)"
168
+ @update:model-value="update('status', $event)"
169
+ >
170
+ <SelectTrigger class="w-full" aria-label="角色状态"
171
+ ><SelectValue
172
+ /></SelectTrigger>
173
+ <SelectContent
174
+ ><SelectItem value="active">启用</SelectItem
175
+ ><SelectItem value="disabled">停用</SelectItem></SelectContent
176
+ >
177
+ </Select>
178
+ </label>
103
179
  <label class="grid gap-2 text-sm font-medium">
104
180
  <span>数据范围</span>
105
181
  <Select
@@ -117,11 +193,25 @@ defineExpose({ submit });
117
193
  </SelectContent>
118
194
  </Select>
119
195
  </label>
196
+ <div v-if="form.dataScope === 'custom'" class="grid gap-2">
197
+ <p v-if="departmentError" class="text-sm text-destructive">
198
+ {{ departmentError }}
199
+ </p>
200
+ <PagedSelectionList
201
+ :items="choices"
202
+ :model-value="form.departmentIds"
203
+ label="数据范围部门"
204
+ multiple
205
+ @update:model-value="update('departmentIds', $event)"
206
+ />
207
+ </div>
208
+ <p v-if="error" class="text-sm text-destructive">{{ error }}</p>
120
209
  <div class="grid gap-2">
121
210
  <div>
122
211
  <p class="text-sm font-medium">功能权限</p>
123
212
  <p class="text-xs text-muted-foreground">
124
- 按模块、资源或单个操作级联选择。
213
+ 从当前 Manifest 注册的全部功能权限中手动选择;每页 10
214
+ 条,搜索或翻页保留勾选。
125
215
  </p>
126
216
  </div>
127
217
  <RolePermissionTree