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
@@ -1,5 +1,6 @@
1
1
  <script setup lang="ts">
2
- import { onMounted, shallowRef, watch } from "vue";
2
+ import { computed, onMounted, shallowRef, useTemplateRef, watch } from "vue";
3
+ import { RouterLink } from "vue-router";
3
4
  import { PlusIcon } from "@lucide/vue";
4
5
  import {
5
6
  PASSWORD_POLICY,
@@ -15,9 +16,21 @@ import {
15
16
  SelectTrigger,
16
17
  SelectValue,
17
18
  } from "@/components/ui/select";
18
- import { listSystemResource, type SystemRecord } from "@/services/system-api";
19
+ import { type SystemRecord } from "@/services/system-api";
19
20
  import { loadPasswordPolicy } from "@/services/auth-api";
21
+ import PagedSelectionList from "../PagedSelectionList.vue";
22
+ import { departmentOptions, loadSelectionRecords } from "../selection-options";
23
+ import { useAccessSnapshot } from "@/composables/useAccessSnapshot";
24
+ import { evaluateWebAccess } from "@/layout/navigation";
25
+ import CreateRoleDialog from "./CreateRoleDialog.vue";
20
26
  import CreateDepartmentDialog from "./CreateDepartmentDialog.vue";
27
+ import {
28
+ normalizeUserForm,
29
+ isMaskedField,
30
+ readString,
31
+ readError,
32
+ accountFields,
33
+ } from "./user-account-draft";
21
34
  import UserRoleSelector from "./UserRoleSelector.vue";
22
35
 
23
36
  interface DepartmentOption {
@@ -39,10 +52,49 @@ const emit = defineEmits<{
39
52
  }>();
40
53
 
41
54
  const form = shallowRef<Record<string, unknown>>({});
42
- const departments = shallowRef<DepartmentOption[]>([]);
55
+ const baseline = shallowRef("");
56
+ const dirty = computed(() => JSON.stringify(form.value) !== baseline.value);
57
+ const access = useAccessSnapshot();
58
+ const canManageRoles = computed(
59
+ () =>
60
+ access.snapshot.value &&
61
+ evaluateWebAccess(access.snapshot.value, {
62
+ permissionKey: "system.role.manage",
63
+ featureKey: "system.role",
64
+ }).allowed,
65
+ );
66
+ const departmentChoices = computed(() => departmentOptions(departments.value));
67
+ function markSaved() {
68
+ form.value = { ...form.value, temporaryPassword: "" };
69
+ baseline.value = JSON.stringify(form.value);
70
+ }
71
+ const departments = shallowRef<SystemRecord[]>([]);
43
72
  const loading = shallowRef(false);
44
73
  const error = shallowRef("");
45
74
  const departmentDialogOpen = shallowRef(false);
75
+ const roleDialogOpen = shallowRef(false);
76
+ interface NestedDialogHandle {
77
+ beforeClose(): boolean | Promise<boolean>;
78
+ }
79
+ const departmentDialog = useTemplateRef<NestedDialogHandle>("departmentDialog");
80
+ const roleDialog = useTemplateRef<NestedDialogHandle>("roleDialog");
81
+ async function beforeClose(): Promise<boolean> {
82
+ if (
83
+ departmentDialogOpen.value &&
84
+ !(await departmentDialog.value?.beforeClose())
85
+ )
86
+ return false;
87
+ if (roleDialogOpen.value && !(await roleDialog.value?.beforeClose()))
88
+ return false;
89
+ return true;
90
+ }
91
+ const roleSelector = useTemplateRef<{ reload(): Promise<void> }>(
92
+ "roleSelector",
93
+ );
94
+ async function selectCreatedRole(role: SystemRecord) {
95
+ update("roleIds", [...new Set([...selectedRoleIds(), role.id])]);
96
+ await roleSelector.value?.reload();
97
+ }
46
98
  const governedTouched = shallowRef<Record<string, boolean>>({});
47
99
  const passwordPolicy = shallowRef<PasswordPolicy>(PASSWORD_POLICY);
48
100
 
@@ -51,6 +103,7 @@ watch(
51
103
  () => {
52
104
  governedTouched.value = {};
53
105
  form.value = normalizeUserForm(props.defaults, props.item);
106
+ baseline.value = JSON.stringify(form.value);
54
107
  },
55
108
  { immediate: true },
56
109
  );
@@ -71,31 +124,18 @@ function update(key: string, value: unknown) {
71
124
  if (key === "email" || key === "phone") {
72
125
  governedTouched.value = { ...governedTouched.value, [key]: true };
73
126
  }
74
- form.value = { ...form.value, [key]: value };
127
+ form.value = {
128
+ ...form.value,
129
+ [key]:
130
+ key === "roleIds" && Array.isArray(value) ? [...value].sort() : value,
131
+ };
75
132
  }
76
133
 
77
134
  async function loadDepartments() {
78
135
  loading.value = true;
79
136
  error.value = "";
80
137
  try {
81
- const result = await listSystemResource("departments", {
82
- page: 1,
83
- pageSize: 100,
84
- status: "active",
85
- sort: "order,name",
86
- });
87
- departments.value = result.items.flatMap((item) =>
88
- typeof item.code === "string" && typeof item.name === "string"
89
- ? [
90
- {
91
- id: item.id,
92
- code: item.code,
93
- name: item.name,
94
- parentId: readString(item.parentId),
95
- },
96
- ]
97
- : [],
98
- );
138
+ departments.value = await loadSelectionRecords("departments", false);
99
139
  } catch (currentError) {
100
140
  error.value = readError(currentError);
101
141
  } finally {
@@ -112,12 +152,39 @@ function selectedRoleIds(): string[] {
112
152
  }
113
153
 
114
154
  async function selectCreatedDepartment(department: DepartmentOption) {
115
- await loadDepartments();
155
+ departments.value = [
156
+ ...departments.value.filter(({ id }) => id !== department.id),
157
+ { ...department, status: "active" },
158
+ ];
116
159
  update("departmentId", department.id);
160
+ await loadDepartments();
117
161
  }
118
162
 
119
163
  function submit() {
120
- const payload = { ...form.value };
164
+ error.value = "";
165
+ if (
166
+ !String(form.value.username || "").trim() ||
167
+ !String(form.value.displayName || "").trim() ||
168
+ !form.value.departmentId ||
169
+ !selectedRoleIds().length
170
+ ) {
171
+ error.value = "请填写用户名、姓名,选择所属部门和至少一个角色。";
172
+ return;
173
+ }
174
+ const password = String(form.value.temporaryPassword || "");
175
+ if (
176
+ (!props.item || password) &&
177
+ (password.length < passwordPolicy.value.minLength ||
178
+ password.length > passwordPolicy.value.maxLength)
179
+ ) {
180
+ error.value = `密码须为 ${passwordPolicy.value.minLength}-${passwordPolicy.value.maxLength} 个字符。`;
181
+ return;
182
+ }
183
+ const payload: Record<string, unknown> = {
184
+ ...form.value,
185
+ username: String(form.value.username).trim(),
186
+ displayName: String(form.value.displayName).trim(),
187
+ };
121
188
  for (const key of ["email", "phone"]) {
122
189
  if (isMaskedField(props.item?.[key]) && !governedTouched.value[key]) {
123
190
  delete payload[key];
@@ -126,61 +193,31 @@ function submit() {
126
193
  emit("submit", payload);
127
194
  }
128
195
 
129
- function normalizeUserForm(
130
- defaults: Record<string, unknown>,
131
- item?: SystemRecord,
132
- ): Record<string, unknown> {
133
- const result = { ...defaults, ...(item || {}) };
134
- for (const key of ["email", "phone"]) {
135
- const value = item?.[key];
136
- result[key] =
137
- value && typeof value === "object" && "value" in value
138
- ? (value as Record<string, unknown>).value
139
- : typeof value === "string"
140
- ? value
141
- : "";
142
- }
143
- return result;
144
- }
145
-
146
- function isMaskedField(value: unknown): boolean {
147
- return Boolean(
148
- value &&
149
- typeof value === "object" &&
150
- ((value as Record<string, unknown>).kind === "masked" ||
151
- (value as Record<string, unknown>).kind === "hidden"),
152
- );
153
- }
154
-
155
- function readString(value: unknown): string | undefined {
156
- return typeof value === "string" ? value : undefined;
157
- }
158
-
159
- function readError(value: unknown): string {
160
- return value instanceof Error ? value.message : String(value);
161
- }
162
-
163
- defineExpose({ submit });
196
+ defineExpose({
197
+ submit,
198
+ beforeClose,
199
+ markSaved,
200
+ get dirty() {
201
+ return dirty.value;
202
+ },
203
+ });
164
204
  </script>
165
205
 
166
206
  <template>
167
207
  <div class="space-y-4">
168
208
  <div class="grid gap-4 sm:grid-cols-2">
169
209
  <label
170
- v-for="field in [
171
- ['username', '用户名', '例如 zhangsan'],
172
- ['displayName', '姓名', '例如 张三'],
173
- ['phone', '手机号', '可选'],
174
- ['email', '邮箱', '可选'],
175
- ]"
176
- :key="field[0]"
210
+ v-for="field in accountFields"
211
+ :key="field.key"
177
212
  class="grid gap-2 text-sm font-medium"
178
213
  >
179
- <span>{{ field[1] }}</span>
214
+ <span>{{ field.label }}</span>
180
215
  <Input
181
- :model-value="String(form[field[0]!] || '')"
182
- :placeholder="field[2]"
183
- @update:model-value="update(field[0]!, String($event))"
216
+ :model-value="String(form[field.key] || '')"
217
+ :placeholder="field.placeholder"
218
+ :type="field.type"
219
+ :maxlength="field.maxLength"
220
+ @update:model-value="update(field.key, String($event))"
184
221
  />
185
222
  </label>
186
223
  </div>
@@ -197,28 +234,35 @@ defineExpose({ submit });
197
234
  <PlusIcon data-icon="inline-start" />新建部门
198
235
  </Button>
199
236
  </div>
200
- <Select
201
- :model-value="readString(form.departmentId)"
237
+ <PagedSelectionList
238
+ :items="departmentChoices"
239
+ :model-value="
240
+ readString(form.departmentId) ? [String(form.departmentId)] : []
241
+ "
242
+ label="所属部门"
202
243
  :disabled="loading"
203
- @update:model-value="update('departmentId', String($event))"
204
- >
205
- <SelectTrigger class="w-full" aria-label="所属部门">
206
- <SelectValue
207
- :placeholder="loading ? '正在加载部门…' : '请选择部门'"
208
- />
209
- </SelectTrigger>
210
- <SelectContent>
211
- <SelectItem
212
- v-for="department in departments"
213
- :key="department.id"
214
- :value="department.id"
215
- >
216
- {{ department.name }}({{ department.code }})
217
- </SelectItem>
218
- </SelectContent>
219
- </Select>
244
+ @update:model-value="update('departmentId', $event[0])"
245
+ />
220
246
  </div>
247
+ <p class="text-xs text-muted-foreground">
248
+ 账号通过角色获得功能权限与数据范围;下方展示每个角色的权限摘要。修改角色会影响所有使用该角色的账号。
249
+ <RouterLink
250
+ v-if="canManageRoles"
251
+ to="/admin/system/roles"
252
+ class="text-primary underline"
253
+ >前往权限策略管理</RouterLink
254
+ >
255
+ </p>
256
+ <Button
257
+ v-if="canManageRoles"
258
+ type="button"
259
+ variant="outline"
260
+ @click="roleDialogOpen = true"
261
+ >
262
+ <PlusIcon data-icon="inline-start" />新建角色并选择权限
263
+ </Button>
221
264
  <UserRoleSelector
265
+ ref="roleSelector"
222
266
  :model-value="selectedRoleIds()"
223
267
  :permissions="permissions"
224
268
  @update:model-value="update('roleIds', $event)"
@@ -236,7 +280,7 @@ defineExpose({ submit });
236
280
  @update:model-value="update('temporaryPassword', String($event))"
237
281
  />
238
282
  <span class="text-xs font-normal text-muted-foreground">
239
- 保存时仅传给密码重置接口,不进入用户资料和审计内容。
283
+ 首次登录须修改初始密码。
240
284
  </span>
241
285
  </label>
242
286
  <label class="grid gap-2 text-sm font-medium">
@@ -259,7 +303,17 @@ defineExpose({ submit });
259
303
  </label>
260
304
  </div>
261
305
  <p v-if="error" class="text-sm text-destructive">{{ error }}</p>
306
+ <CreateRoleDialog
307
+ ref="roleDialog"
308
+ managed-navigation
309
+ v-if="canManageRoles"
310
+ v-model:open="roleDialogOpen"
311
+ :permissions="permissions"
312
+ @created="selectCreatedRole"
313
+ />
262
314
  <CreateDepartmentDialog
315
+ ref="departmentDialog"
316
+ managed-navigation
263
317
  v-if="departmentCreateMode"
264
318
  v-model:open="departmentDialogOpen"
265
319
  :mode="departmentCreateMode"
@@ -1,6 +1,7 @@
1
1
  import { defineComponent } from "vue";
2
2
  import { afterEach, describe, expect, test, vi } from "vitest";
3
3
  import { flushPromises, mount } from "@vue/test-utils";
4
+ import CreateRoleDialog from "./CreateRoleDialog.vue";
4
5
  import UserAccountForm from "./UserAccountForm.vue";
5
6
 
6
7
  const api = vi.hoisted(() => ({
@@ -19,6 +20,17 @@ vi.mock("@/services/auth-api", () => ({
19
20
  loadPasswordPolicy: api.loadPasswordPolicy,
20
21
  }));
21
22
 
23
+ vi.mock("@/composables/useAccessSnapshot", () => ({
24
+ useAccessSnapshot: () => ({
25
+ snapshot: {
26
+ value: {
27
+ actor: { permissionKeys: ["system.role.manage"] },
28
+ features: [{ key: "system.role", enabled: true, allowed: true }],
29
+ },
30
+ },
31
+ }),
32
+ }));
33
+
22
34
  afterEach(() => vi.clearAllMocks());
23
35
 
24
36
  describe("UserAccountForm", () => {
@@ -51,6 +63,7 @@ describe("UserAccountForm", () => {
51
63
  },
52
64
  global: {
53
65
  stubs: {
66
+ RouterLink: { template: "<a><slot /></a>" },
54
67
  CreateDepartmentDialog: dialogStub,
55
68
  UserRoleSelector: { template: "<div />" },
56
69
  },
@@ -60,6 +73,10 @@ describe("UserAccountForm", () => {
60
73
 
61
74
  const dialog = wrapper.getComponent(dialogStub);
62
75
  expect(dialog.props("open")).toBe(false);
76
+ expect(wrapper.get('input[type="email"]').attributes("maxlength")).toBe(
77
+ "160",
78
+ );
79
+ expect(wrapper.get('input[type="tel"]').attributes("maxlength")).toBe("32");
63
80
  const openButton = wrapper
64
81
  .findAll("button")
65
82
  .find((button) => button.text() === "新建部门");
@@ -68,4 +85,82 @@ describe("UserAccountForm", () => {
68
85
  expect(dialog.props("open")).toBe(true);
69
86
  expect(wrapper.text()).not.toContain("新建并选中部门");
70
87
  });
88
+ test("新角色创建后保留账户草稿并追加选择,重新加载角色选项", async () => {
89
+ api.list.mockResolvedValue({
90
+ items: [
91
+ { id: "dept_root", name: "总部", code: "root", status: "active" },
92
+ ],
93
+ total: 1,
94
+ page: 1,
95
+ pageSize: 10,
96
+ });
97
+ api.loadPasswordPolicy.mockResolvedValue({ minLength: 12, maxLength: 128 });
98
+ const reload = vi.fn(async () => {});
99
+ const beforeClose = vi.fn(async () => false);
100
+ const wrapper = mount(UserAccountForm, {
101
+ props: {
102
+ defaults: {},
103
+ item: {
104
+ id: "account",
105
+ username: "original",
106
+ displayName: "原姓名",
107
+ departmentId: "dept_root",
108
+ roleIds: ["role_old"],
109
+ status: "active",
110
+ },
111
+ permissions: [],
112
+ },
113
+ global: {
114
+ stubs: {
115
+ CreateRoleDialog: defineComponent({
116
+ name: "CreateRoleDialog",
117
+ setup(_props, { expose }) {
118
+ expose({ beforeClose });
119
+ return {};
120
+ },
121
+ props: { open: Boolean },
122
+ emits: ["created", "update:open"],
123
+ template: "<div />",
124
+ }),
125
+ UserRoleSelector: defineComponent({
126
+ setup(_props, { expose }) {
127
+ expose({ reload });
128
+ return {};
129
+ },
130
+ template: "<div />",
131
+ }),
132
+ RouterLink: { template: "<a><slot /></a>" },
133
+ },
134
+ },
135
+ });
136
+ await flushPromises();
137
+ await wrapper
138
+ .get('input[placeholder="例如 张三"]')
139
+ .setValue("尚未保存的姓名");
140
+ await wrapper
141
+ .findAll("button")
142
+ .find((button) => button.text() === "新建角色并选择权限")!
143
+ .trigger("click");
144
+ const dialog = wrapper.getComponent(CreateRoleDialog);
145
+ expect(dialog.props("open")).toBe(true);
146
+ await expect(
147
+ (
148
+ wrapper.vm as unknown as { beforeClose(): Promise<boolean> }
149
+ ).beforeClose(),
150
+ ).resolves.toBe(false);
151
+ expect(beforeClose).toHaveBeenCalledOnce();
152
+ dialog.vm.$emit("created", {
153
+ id: "role_new",
154
+ name: "新角色",
155
+ status: "active",
156
+ });
157
+ await flushPromises();
158
+ (wrapper.vm as unknown as { submit(): void }).submit();
159
+ expect(reload).toHaveBeenCalledOnce();
160
+ expect(wrapper.emitted("submit")?.at(-1)?.[0]).toMatchObject({
161
+ displayName: "尚未保存的姓名",
162
+ roleIds: ["role_new", "role_old"],
163
+ });
164
+ wrapper.unmount();
165
+ });
71
166
  });
@@ -0,0 +1,168 @@
1
+ <script setup lang="ts">
2
+ import { computed, shallowRef, watch } from "vue";
3
+ import { Button } from "@/components/ui/button";
4
+ import {
5
+ Dialog,
6
+ DialogContent,
7
+ DialogHeader,
8
+ DialogTitle,
9
+ DialogDescription,
10
+ DialogTrigger,
11
+ } from "@/components/ui/dialog";
12
+ import {
13
+ Select,
14
+ SelectContent,
15
+ SelectItem,
16
+ SelectTrigger,
17
+ SelectValue,
18
+ } from "@/components/ui/select";
19
+ import { useAccessSnapshot } from "@/composables/useAccessSnapshot";
20
+ import { evaluateWebAccess } from "@/layout/navigation";
21
+ import PagedSelectionList from "../PagedSelectionList.vue";
22
+ import { loadSelectionRecords } from "../selection-options";
23
+ import type { SystemRecord } from "@/services/system-api";
24
+ const emit = defineEmits<{
25
+ change: [
26
+ filters: { departmentId?: string; roleId?: string; status?: string },
27
+ ];
28
+ }>();
29
+ const access = useAccessSnapshot();
30
+ const departmentId = shallowRef("");
31
+ const roleId = shallowRef("");
32
+ const status = shallowRef("all");
33
+ const departments = shallowRef<SystemRecord[]>([]);
34
+ const roles = shallowRef<SystemRecord[]>([]);
35
+ const error = shallowRef("");
36
+ const departmentAllowed = computed(() => allowed("system.department"));
37
+ const roleAllowed = computed(() => allowed("system.role"));
38
+ function allowed(key: "system.department" | "system.role") {
39
+ const snapshot = access.snapshot.value;
40
+ return Boolean(
41
+ snapshot &&
42
+ evaluateWebAccess(snapshot, {
43
+ featureKey: key,
44
+ permissionKey: `${key}.read`,
45
+ }).allowed,
46
+ );
47
+ }
48
+ function choices(items: SystemRecord[]) {
49
+ return items.map((item) => ({
50
+ id: item.id,
51
+ label: String(item.name || item.code || item.id),
52
+ description: `${String(item.code || "")}${item.status === "disabled" ? " · 已停用" : ""}`,
53
+ }));
54
+ }
55
+ const departmentChoices = computed(() => choices(departments.value));
56
+ const roleChoices = computed(() => choices(roles.value));
57
+ const departmentLabel = computed(
58
+ () =>
59
+ departmentChoices.value.find((item) => item.id === departmentId.value)
60
+ ?.label || "全部部门",
61
+ );
62
+ const roleLabel = computed(
63
+ () =>
64
+ roleChoices.value.find((item) => item.id === roleId.value)?.label ||
65
+ "全部角色",
66
+ );
67
+ watch(
68
+ [departmentAllowed, roleAllowed],
69
+ async ([canReadDepartments, canReadRoles], _, onCleanup) => {
70
+ let active = true;
71
+ onCleanup(() => {
72
+ active = false;
73
+ });
74
+ error.value = "";
75
+ const results = await Promise.allSettled([
76
+ canReadDepartments
77
+ ? loadSelectionRecords("departments", false)
78
+ : Promise.resolve([]),
79
+ canReadRoles ? loadSelectionRecords("roles", false) : Promise.resolve([]),
80
+ ]);
81
+ if (!active) return;
82
+ departments.value =
83
+ results[0].status === "fulfilled" ? results[0].value : [];
84
+ roles.value = results[1].status === "fulfilled" ? results[1].value : [];
85
+ if (results.some((result) => result.status === "rejected"))
86
+ error.value = "部分筛选选项加载失败,请刷新重试。";
87
+ },
88
+ { immediate: true },
89
+ );
90
+ watch([departmentId, roleId, status], () =>
91
+ emit("change", {
92
+ departmentId: departmentId.value || undefined,
93
+ roleId: roleId.value || undefined,
94
+ status: status.value === "all" ? undefined : status.value,
95
+ }),
96
+ );
97
+ function reset() {
98
+ departmentId.value = "";
99
+ roleId.value = "";
100
+ status.value = "all";
101
+ }
102
+ </script>
103
+ <template>
104
+ <div class="flex flex-wrap items-center gap-2">
105
+ <Dialog v-if="departmentAllowed">
106
+ <DialogTrigger as-child
107
+ ><Button variant="outline" aria-label="按部门筛选">{{
108
+ departmentLabel
109
+ }}</Button></DialogTrigger
110
+ >
111
+ <DialogContent class="sm:max-w-lg">
112
+ <DialogHeader
113
+ ><DialogTitle>筛选部门</DialogTitle
114
+ ><DialogDescription
115
+ >选择部门后立即筛选账户列表。</DialogDescription
116
+ ></DialogHeader
117
+ >
118
+ <PagedSelectionList
119
+ :items="departmentChoices"
120
+ :model-value="departmentId ? [departmentId] : []"
121
+ label="筛选部门"
122
+ clearable
123
+ @update:model-value="departmentId = $event[0] || ''"
124
+ />
125
+ </DialogContent>
126
+ </Dialog>
127
+ <Dialog v-if="roleAllowed">
128
+ <DialogTrigger as-child
129
+ ><Button variant="outline" aria-label="按角色筛选">{{
130
+ roleLabel
131
+ }}</Button></DialogTrigger
132
+ >
133
+ <DialogContent class="sm:max-w-lg">
134
+ <DialogHeader
135
+ ><DialogTitle>筛选角色</DialogTitle
136
+ ><DialogDescription
137
+ >选择角色后立即筛选账户列表。</DialogDescription
138
+ ></DialogHeader
139
+ >
140
+ <PagedSelectionList
141
+ :items="roleChoices"
142
+ :model-value="roleId ? [roleId] : []"
143
+ label="筛选角色"
144
+ clearable
145
+ @update:model-value="roleId = $event[0] || ''"
146
+ />
147
+ </DialogContent>
148
+ </Dialog>
149
+ <Select v-model="status"
150
+ ><SelectTrigger class="w-32" aria-label="按账户状态筛选"
151
+ ><SelectValue /></SelectTrigger
152
+ ><SelectContent
153
+ ><SelectItem value="all">全部状态</SelectItem
154
+ ><SelectItem value="active">启用</SelectItem
155
+ ><SelectItem value="disabled">停用</SelectItem></SelectContent
156
+ ></Select
157
+ >
158
+ <Button
159
+ v-if="departmentId || roleId || status !== 'all'"
160
+ variant="ghost"
161
+ @click="reset"
162
+ >清空筛选</Button
163
+ >
164
+ <p v-if="error" class="w-full text-sm text-destructive" role="alert">
165
+ {{ error }}
166
+ </p>
167
+ </div>
168
+ </template>