create-windy 0.6.20 → 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 (126) hide show
  1. package/dist/cli.js +16 -4
  2. package/package.json +1 -1
  3. package/template/.windy-template.json +2 -2
  4. package/template/apps/server/src/agent/remote-runtime.test.ts +36 -0
  5. package/template/apps/server/src/agent/remote-runtime.ts +15 -7
  6. package/template/apps/server/src/audit/policy.ts +6 -1
  7. package/template/apps/server/src/auth/bootstrap-admin.ts +10 -1
  8. package/template/apps/server/src/auth/composition.ts +5 -0
  9. package/template/apps/server/src/auth/credential-authorization.test.ts +257 -0
  10. package/template/apps/server/src/auth/credential-routes.test.ts +1 -0
  11. package/template/apps/server/src/auth/credential-service.ts +47 -12
  12. package/template/apps/server/src/auth/drizzle-repository.ts +13 -5
  13. package/template/apps/server/src/auth/drizzle-session-operations.ts +1 -0
  14. package/template/apps/server/src/auth/repository.ts +9 -6
  15. package/template/apps/server/src/auth/routes.test-support.ts +1 -0
  16. package/template/apps/server/src/auth/runtime.ts +4 -0
  17. package/template/apps/server/src/auth/service-options.ts +2 -0
  18. package/template/apps/server/src/auth/service.ts +2 -0
  19. package/template/apps/server/src/auth/user-credential-access.ts +37 -0
  20. package/template/apps/server/src/foundation-activation.test.ts +24 -1
  21. package/template/apps/server/src/foundation.ts +0 -2
  22. package/template/apps/server/src/index.ts +1 -0
  23. package/template/apps/server/src/runtime-development.ts +2 -0
  24. package/template/apps/server/src/runtime.test.ts +13 -4
  25. package/template/apps/server/src/system/department-organization.ts +58 -59
  26. package/template/apps/server/src/system/department-read-routes.ts +54 -0
  27. package/template/apps/server/src/system/department-routes.test.ts +27 -19
  28. package/template/apps/server/src/system/department-routes.ts +95 -33
  29. package/template/apps/server/src/system/department-transition-safety.test.ts +148 -0
  30. package/template/apps/server/src/system/department-validation.ts +79 -0
  31. package/template/apps/server/src/system/drizzle-repository.ts +14 -0
  32. package/template/apps/server/src/system/drizzle-system-configs.ts +2 -0
  33. package/template/apps/server/src/system/identity-read-model.ts +105 -0
  34. package/template/apps/server/src/system/identity-role-assignment.test.ts +199 -0
  35. package/template/apps/server/src/system/identity-role-assignment.ts +148 -0
  36. package/template/apps/server/src/system/identity-route-config.ts +29 -7
  37. package/template/apps/server/src/system/identity-write-security.test.ts +127 -0
  38. package/template/apps/server/src/system/identity-write-validation.ts +170 -0
  39. package/template/apps/server/src/system/organization-read-security.test.ts +221 -0
  40. package/template/apps/server/src/system/redis-health.test.ts +1 -1
  41. package/template/apps/server/src/system/repository.ts +29 -2
  42. package/template/apps/server/src/system/resource-routes.ts +116 -72
  43. package/template/apps/server/src/system/route-audit.test.ts +117 -0
  44. package/template/apps/server/src/system/route-helpers.ts +14 -0
  45. package/template/apps/server/src/system/route-types.ts +11 -2
  46. package/template/apps/server/src/system/routes.test.ts +0 -68
  47. package/template/apps/server/src/system/routes.ts +29 -2
  48. package/template/apps/server/src/system/user-account-policy.test.ts +3 -1
  49. package/template/apps/server/src/system/user-account-policy.ts +26 -9
  50. package/template/apps/web/src/pages/system/SystemResourceRoutePage.vue +27 -13
  51. package/template/apps/web/src/pages/system/SystemResourceRoutePage.webtest.ts +68 -0
  52. package/template/apps/web/src/pages/system/components/PagedSelectionList.vue +131 -0
  53. package/template/apps/web/src/pages/system/components/PagedSelectionList.webtest.ts +32 -0
  54. package/template/apps/web/src/pages/system/components/SystemRecordDetailDialog.vue +67 -0
  55. package/template/apps/web/src/pages/system/components/SystemResourceEditor.vue +114 -38
  56. package/template/apps/web/src/pages/system/components/SystemResourceEditor.webtest.ts +180 -0
  57. package/template/apps/web/src/pages/system/components/SystemResourceFilters.vue +8 -0
  58. package/template/apps/web/src/pages/system/components/SystemResourceForm.vue +8 -2
  59. package/template/apps/web/src/pages/system/components/SystemResourcePage.department.webtest.ts +89 -0
  60. package/template/apps/web/src/pages/system/components/SystemResourcePage.vue +42 -46
  61. package/template/apps/web/src/pages/system/components/SystemResourcePage.webtest.ts +12 -55
  62. package/template/apps/web/src/pages/system/components/SystemResourceTable.vue +14 -2
  63. package/template/apps/web/src/pages/system/components/department/DepartmentOrganizationForm.vue +121 -85
  64. package/template/apps/web/src/pages/system/components/department/DepartmentOrganizationForm.webtest.ts +57 -0
  65. package/template/apps/web/src/pages/system/components/department/DepartmentOrganizationTable.vue +227 -0
  66. package/template/apps/web/src/pages/system/components/department/DepartmentOrganizationTable.webtest.ts +62 -0
  67. package/template/apps/web/src/pages/system/components/department/DepartmentTransitionDialog.vue +72 -52
  68. package/template/apps/web/src/pages/system/components/role-policy/RolePermissionTree.vue +150 -79
  69. package/template/apps/web/src/pages/system/components/role-policy/RolePolicyForm.vue +98 -8
  70. package/template/apps/web/src/pages/system/components/role-policy/RolePolicyForm.webtest.ts +108 -0
  71. package/template/apps/web/src/pages/system/components/selection-options.ts +71 -0
  72. package/template/apps/web/src/pages/system/components/selection-options.webtest.ts +49 -0
  73. package/template/apps/web/src/pages/system/components/user-account/CreateDepartmentDialog.vue +19 -27
  74. package/template/apps/web/src/pages/system/components/user-account/CreateDepartmentDialog.webtest.ts +2 -6
  75. package/template/apps/web/src/pages/system/components/user-account/CreateRoleDialog.vue +128 -0
  76. package/template/apps/web/src/pages/system/components/user-account/CreateRoleDialog.webtest.ts +76 -0
  77. package/template/apps/web/src/pages/system/components/user-account/RolePermissionSummary.vue +61 -3
  78. package/template/apps/web/src/pages/system/components/user-account/UserAccountForm.vue +144 -90
  79. package/template/apps/web/src/pages/system/components/user-account/UserAccountForm.webtest.ts +95 -0
  80. package/template/apps/web/src/pages/system/components/user-account/UserListFilters.vue +168 -0
  81. package/template/apps/web/src/pages/system/components/user-account/UserRoleSelector.vue +33 -57
  82. package/template/apps/web/src/pages/system/components/user-account/UserRoleSelector.webtest.ts +4 -4
  83. package/template/apps/web/src/pages/system/components/user-account/save-user-account.ts +4 -1
  84. package/template/apps/web/src/pages/system/components/user-account/user-account-draft.ts +81 -0
  85. package/template/apps/web/src/pages/system/composables/useDepartmentTransitions.ts +6 -10
  86. package/template/apps/web/src/pages/system/composables/useResourceEditor.ts +72 -0
  87. package/template/apps/web/src/pages/system/composables/useResourceEditor.webtest.ts +110 -0
  88. package/template/apps/web/src/pages/system/composables/useSystemResource.ts +27 -1
  89. package/template/apps/web/src/pages/system/resource-config.ts +2 -0
  90. package/template/apps/web/src/services/system-api.ts +25 -0
  91. package/template/docs/architecture/foundation-infrastructure.md +12 -25
  92. package/template/docs/development/crud-generator.md +1 -1
  93. package/template/docs/operations/legacy-data-migration.md +73 -0
  94. package/template/docs/platform/bulk-data-jobs.md +1 -1
  95. package/template/docs/platform/module-configuration.md +2 -2
  96. package/template/docs/platform/organization-membership.md +37 -6
  97. package/template/docs/platform/scheduled-task-executor.md +1 -1
  98. package/template/package.json +2 -1
  99. package/template/packages/config/index.test.ts +1 -5
  100. package/template/packages/config/package.json +2 -2
  101. package/template/packages/config/src/platform-boundary.test.ts +39 -0
  102. package/template/packages/config/src/platform.ts +0 -28
  103. package/template/packages/database/drizzle/0063_license_project_lifecycle.sql +28 -0
  104. package/template/packages/database/drizzle/meta/0063_snapshot.json +15053 -0
  105. package/template/packages/database/drizzle/meta/_journal.json +7 -0
  106. package/template/packages/database/package.json +2 -2
  107. package/template/packages/database/src/agent-runtime-schema.test.ts +5 -1
  108. package/template/packages/database/src/schema/index.ts +1 -0
  109. package/template/packages/database/src/schema/license-issuance-requests.ts +59 -0
  110. package/template/packages/database/src/schema/license-v1.ts +5 -0
  111. package/template/packages/jobs/package.json +2 -2
  112. package/template/packages/modules/package.json +1 -1
  113. package/template/packages/modules/src/system-api-permissions.ts +14 -0
  114. package/template/packages/modules/src/system-features.ts +0 -11
  115. package/template/packages/modules/src/system-module-catalog.test.ts +4 -0
  116. package/template/packages/modules/src/system-module-catalog.ts +0 -1
  117. package/template/packages/server-sdk/index.ts +1 -49
  118. package/template/packages/server-sdk/package.json +3 -2
  119. package/template/packages/server-sdk/src/ai-runtime-readiness-exports.ts +49 -0
  120. package/template/packages/shared/index.ts +2 -1
  121. package/template/packages/shared/package.json +1 -1
  122. package/template/packages/shared/src/license-console-capabilities.ts +163 -0
  123. package/template/packages/shared/src/license-console.ts +94 -0
  124. package/template/packages/shared/src/license.ts +5 -0
  125. package/template/docs/adapters/ruoyi-vue-compatibility.md +0 -93
  126. package/template/packages/shared/src/ruoyi.ts +0 -98
@@ -1,8 +1,9 @@
1
1
  <script setup lang="ts">
2
2
  import type { PermissionDefinition } from "@southwind-ai/shared";
3
- import { onMounted, shallowRef } from "vue";
4
- import { Checkbox } from "@/components/ui/checkbox";
5
- import { listSystemResource } from "@/services/system-api";
3
+ import { computed, onMounted, shallowRef } from "vue";
4
+ import PagedSelectionList from "../PagedSelectionList.vue";
5
+ import { loadSelectionRecords } from "../selection-options";
6
+
6
7
  import RolePermissionSummary from "./RolePermissionSummary.vue";
7
8
 
8
9
  interface RoleOption {
@@ -28,13 +29,8 @@ async function loadRoles() {
28
29
  loading.value = true;
29
30
  error.value = "";
30
31
  try {
31
- const result = await listSystemResource("roles", {
32
- page: 1,
33
- pageSize: 100,
34
- status: "active",
35
- sort: "name",
36
- });
37
- roles.value = result.items.flatMap((item) =>
32
+ const records = await loadSelectionRecords("roles");
33
+ roles.value = records.flatMap((item) =>
38
34
  typeof item.code === "string" && typeof item.name === "string"
39
35
  ? [
40
36
  {
@@ -62,11 +58,15 @@ async function loadRoles() {
62
58
  }
63
59
  }
64
60
 
65
- function toggle(id: string, checked: boolean | "indeterminate") {
66
- const selected = new Set(props.modelValue);
67
- if (checked === true) selected.add(id);
68
- else selected.delete(id);
69
- emit("update:modelValue", [...selected]);
61
+ const options = computed(() =>
62
+ roles.value.map((role) => ({
63
+ id: role.id,
64
+ label: role.name,
65
+ description: `${role.code} · ${dataScopeLabel(role.dataScope)}`,
66
+ })),
67
+ );
68
+ function rolePermissions(id: string): string[] {
69
+ return roles.value.find((role) => role.id === id)?.permissionKeys || [];
70
70
  }
71
71
 
72
72
  function dataScopeLabel(scope: string): string {
@@ -81,52 +81,28 @@ function dataScopeLabel(scope: string): string {
81
81
  }[scope] || scope
82
82
  );
83
83
  }
84
+ defineExpose({ reload: loadRoles });
84
85
  </script>
85
86
 
86
87
  <template>
87
88
  <div class="grid gap-2">
88
- <div class="flex items-center justify-between gap-3">
89
- <label class="text-sm font-medium">账号角色</label>
90
- <span class="text-xs text-muted-foreground">至少选择一个</span>
91
- </div>
92
- <div class="grid gap-3 rounded-lg border p-3" aria-label="账号角色">
93
- <p v-if="loading" class="text-sm text-muted-foreground">正在加载角色…</p>
94
- <p v-else-if="error" class="text-sm text-destructive">
95
- {{ error }}
96
- </p>
97
- <template v-else>
98
- <article
99
- v-for="role in roles"
100
- :key="role.id"
101
- class="rounded-lg border p-3 transition-colors"
102
- :class="
103
- modelValue.includes(role.id)
104
- ? 'border-primary/50 bg-primary/5'
105
- : 'hover:bg-muted/40'
106
- "
107
- >
108
- <label class="flex cursor-pointer items-start gap-3">
109
- <Checkbox
110
- :model-value="modelValue.includes(role.id)"
111
- :aria-label="role.name"
112
- @update:model-value="toggle(role.id, $event)"
113
- />
114
- <span class="grid gap-0.5 text-sm">
115
- <span class="font-medium">{{ role.name }}</span>
116
- <span class="text-xs text-muted-foreground">
117
- {{ role.code }} · {{ dataScopeLabel(role.dataScope) }}
118
- </span>
119
- </span>
120
- </label>
121
- <RolePermissionSummary
122
- :permission-keys="role.permissionKeys"
123
- :permissions="permissions"
124
- />
125
- </article>
126
- <p v-if="!roles.length" class="text-sm text-muted-foreground">
127
- 暂无可选角色。
128
- </p>
89
+ <label class="text-sm font-medium">账号角色(至少选择一个)</label>
90
+ <p v-if="loading" class="text-sm text-muted-foreground">正在加载角色…</p>
91
+ <p v-else-if="error" class="text-sm text-destructive">{{ error }}</p>
92
+ <PagedSelectionList
93
+ v-else
94
+ :items="options"
95
+ :model-value="modelValue"
96
+ label="账号角色"
97
+ multiple
98
+ @update:model-value="emit('update:modelValue', $event)"
99
+ >
100
+ <template #detail="{ id }">
101
+ <RolePermissionSummary
102
+ :permission-keys="rolePermissions(id)"
103
+ :permissions="permissions"
104
+ />
129
105
  </template>
130
- </div>
106
+ </PagedSelectionList>
131
107
  </div>
132
108
  </template>
@@ -27,7 +27,7 @@ describe("UserRoleSelector", () => {
27
27
  ],
28
28
  total: 1,
29
29
  page: 1,
30
- pageSize: 100,
30
+ pageSize: 10,
31
31
  });
32
32
  const wrapper = mount(UserRoleSelector, {
33
33
  props: {
@@ -58,15 +58,15 @@ describe("UserRoleSelector", () => {
58
58
 
59
59
  expect(api.list).toHaveBeenCalledWith(
60
60
  "roles",
61
- expect.objectContaining({ status: "active", pageSize: 100 }),
61
+ expect.objectContaining({ status: "active", pageSize: 10 }),
62
62
  );
63
63
  expect(wrapper.text()).toContain("部门负责人");
64
64
  expect(wrapper.text()).toContain("本部门及下级");
65
65
  expect(wrapper.text()).toContain("查看 2 项功能权限");
66
66
  await wrapper.get("summary").trigger("click");
67
67
  expect(wrapper.text()).toContain("用户账号");
68
- expect(wrapper.text()).toContain("查看用户、管理用户");
69
- await wrapper.get('[role="checkbox"]').trigger("click");
68
+ expect(wrapper.text()).toContain("管理用户、查看用户");
69
+ await wrapper.get('input[type="checkbox"]').setValue(true);
70
70
  expect(wrapper.emitted("update:modelValue")?.at(-1)).toEqual([
71
71
  ["role_manager"],
72
72
  ]);
@@ -18,9 +18,11 @@ export async function saveUserAccount(
18
18
  id: string | undefined,
19
19
  save: SaveResource,
20
20
  toast: AccountToast,
21
+ onAccountSaved?: (account: SystemRecord) => void,
22
+ requireInitialPassword = !id,
21
23
  ): Promise<SystemRecord | undefined> {
22
24
  const password = readPassword(payload.temporaryPassword);
23
- if (!id && !password) {
25
+ if (requireInitialPassword && !password) {
24
26
  toast.error("请设置一次性初始密码", {
25
27
  description: "密码至少 12 个字符,创建后首次登录即可修改。",
26
28
  });
@@ -29,6 +31,7 @@ export async function saveUserAccount(
29
31
  const accountPayload = { ...payload };
30
32
  delete accountPayload.temporaryPassword;
31
33
  const saved = await save(accountPayload, id);
34
+ if (saved) onAccountSaved?.(saved);
32
35
  if (!saved || !password) return saved || undefined;
33
36
  try {
34
37
  await resetSystemUserPassword(saved.id, password);
@@ -0,0 +1,81 @@
1
+ import type { SystemRecord } from "@/services/system-api";
2
+
3
+ export function normalizeUserForm(
4
+ defaults: Record<string, unknown>,
5
+ item?: SystemRecord,
6
+ ): Record<string, unknown> {
7
+ const source = { ...defaults, ...(item || {}) };
8
+ const result: Record<string, unknown> = Object.fromEntries(
9
+ [
10
+ "username",
11
+ "displayName",
12
+ "phone",
13
+ "email",
14
+ "departmentId",
15
+ "roleIds",
16
+ "status",
17
+ ].map((key) => [key, source[key]]),
18
+ );
19
+ result.roleIds = Array.isArray(result.roleIds)
20
+ ? [...result.roleIds].sort()
21
+ : [];
22
+ result.temporaryPassword = "";
23
+ for (const key of ["email", "phone"]) {
24
+ const value = item?.[key];
25
+ result[key] =
26
+ value && typeof value === "object" && "value" in value
27
+ ? (value as Record<string, unknown>).value
28
+ : typeof value === "string"
29
+ ? value
30
+ : "";
31
+ }
32
+ return result;
33
+ }
34
+
35
+ export function isMaskedField(value: unknown): boolean {
36
+ return Boolean(
37
+ value &&
38
+ typeof value === "object" &&
39
+ ((value as Record<string, unknown>).kind === "masked" ||
40
+ (value as Record<string, unknown>).kind === "hidden"),
41
+ );
42
+ }
43
+
44
+ export function readString(value: unknown): string | undefined {
45
+ return typeof value === "string" ? value : undefined;
46
+ }
47
+
48
+ export function readError(value: unknown): string {
49
+ return value instanceof Error ? value.message : String(value);
50
+ }
51
+
52
+ export const accountFields = [
53
+ {
54
+ key: "username",
55
+ label: "用户名",
56
+ placeholder: "例如 zhangsan",
57
+ type: "text",
58
+ maxLength: 80,
59
+ },
60
+ {
61
+ key: "displayName",
62
+ label: "姓名",
63
+ placeholder: "例如 张三",
64
+ type: "text",
65
+ maxLength: 120,
66
+ },
67
+ {
68
+ key: "phone",
69
+ label: "手机号",
70
+ placeholder: "可选",
71
+ type: "tel",
72
+ maxLength: 32,
73
+ },
74
+ {
75
+ key: "email",
76
+ label: "邮箱",
77
+ placeholder: "可选",
78
+ type: "email",
79
+ maxLength: 160,
80
+ },
81
+ ] as const;
@@ -13,16 +13,14 @@ export function useDepartmentTransitions(options: {
13
13
  async function disable(
14
14
  department: SystemRecord,
15
15
  targetDepartmentId: string | undefined,
16
+ updates?: Record<string, unknown>,
16
17
  ): Promise<boolean> {
17
- if (!targetDepartmentId) {
18
- toast.error("请选择迁移目标");
19
- return false;
20
- }
21
18
  return transition(
22
19
  department,
23
20
  "disable",
24
21
  targetDepartmentId,
25
- "部门已停用,关联数据已迁移",
22
+ "部门资料已保存,关联数据已迁移并停用",
23
+ updates,
26
24
  );
27
25
  }
28
26
 
@@ -34,10 +32,6 @@ export function useDepartmentTransitions(options: {
34
32
  toast.error("平台唯一根组织不能删除");
35
33
  return false;
36
34
  }
37
- if (!targetDepartmentId) {
38
- toast.error("请选择迁移目标");
39
- return false;
40
- }
41
35
  return transition(
42
36
  department,
43
37
  "delete",
@@ -49,14 +43,16 @@ export function useDepartmentTransitions(options: {
49
43
  async function transition(
50
44
  department: SystemRecord,
51
45
  action: "disable" | "delete",
52
- targetDepartmentId: string,
46
+ targetDepartmentId: string | undefined,
53
47
  successMessage: string,
48
+ updates?: Record<string, unknown>,
54
49
  ): Promise<boolean> {
55
50
  loading.value = true;
56
51
  try {
57
52
  await transitionDepartment(department.id, {
58
53
  action,
59
54
  targetDepartmentId,
55
+ ...(updates ? { updates } : {}),
60
56
  });
61
57
  await options.reload();
62
58
  toast.success(successMessage);
@@ -0,0 +1,72 @@
1
+ import { shallowRef } from "vue";
2
+ import type { SystemRecord } from "@/services/system-api";
3
+ import { useAppToast } from "@/composables/useAppToast";
4
+ import { saveUserAccount } from "../components/user-account/save-user-account";
5
+
6
+ type SaveResource = (
7
+ payload: Record<string, unknown>,
8
+ id?: string,
9
+ ) => Promise<SystemRecord | false | undefined>;
10
+ export function useResourceEditor(options: {
11
+ key: string;
12
+ save: SaveResource;
13
+ disable: (
14
+ item: SystemRecord,
15
+ target: string | undefined,
16
+ updates?: Record<string, unknown>,
17
+ ) => Promise<boolean>;
18
+ mutated: () => void;
19
+ }) {
20
+ const open = shallowRef(false);
21
+ const item = shallowRef<SystemRecord>();
22
+ const savedAccountId = shallowRef<string>();
23
+ const loading = shallowRef(false);
24
+ const toast = useAppToast();
25
+ function edit(record?: SystemRecord) {
26
+ item.value = record;
27
+ savedAccountId.value = record?.id;
28
+ open.value = true;
29
+ }
30
+ async function submit(payload: Record<string, unknown>): Promise<boolean> {
31
+ if (loading.value) return false;
32
+ loading.value = true;
33
+ try {
34
+ const normalized = { ...payload };
35
+ delete normalized._transitionTargetDepartmentId;
36
+ if (
37
+ options.key === "departments" &&
38
+ item.value &&
39
+ item.value.status !== "disabled" &&
40
+ payload.status === "disabled"
41
+ ) {
42
+ const target =
43
+ typeof payload._transitionTargetDepartmentId === "string"
44
+ ? payload._transitionTargetDepartmentId
45
+ : undefined;
46
+ const updates: Record<string, unknown> = {};
47
+ for (const key of ["name", "code", "order", "parentId"]) {
48
+ if (Object.hasOwn(normalized, key)) updates[key] = normalized[key];
49
+ }
50
+ return await options.disable(item.value, target, updates);
51
+ }
52
+ const saved =
53
+ options.key === "users"
54
+ ? await saveUserAccount(
55
+ normalized,
56
+ savedAccountId.value,
57
+ options.save,
58
+ toast,
59
+ (record) => {
60
+ savedAccountId.value = record.id;
61
+ },
62
+ !item.value,
63
+ )
64
+ : await options.save(normalized, item.value?.id);
65
+ if (saved) options.mutated();
66
+ return Boolean(saved);
67
+ } finally {
68
+ loading.value = false;
69
+ }
70
+ }
71
+ return { open, item, loading, edit, submit };
72
+ }
@@ -0,0 +1,110 @@
1
+ import { describe, expect, test, vi } from "vitest";
2
+ import { flushPromises } from "@vue/test-utils";
3
+ import { useResourceEditor } from "./useResourceEditor";
4
+ const api = vi.hoisted(() => ({ reset: vi.fn() }));
5
+ vi.mock("@/services/system-api", async () => ({
6
+ ...(await vi.importActual<typeof import("@/services/system-api")>(
7
+ "@/services/system-api",
8
+ )),
9
+ resetSystemUserPassword: api.reset,
10
+ }));
11
+ vi.mock("@/composables/useAppToast", () => ({
12
+ useAppToast: () => ({ success: vi.fn(), error: vi.fn() }),
13
+ }));
14
+ const payload = {
15
+ username: "alice",
16
+ displayName: "小王",
17
+ departmentId: "dept_1",
18
+ roleIds: ["role_1"],
19
+ temporaryPassword: "TemporaryPass123",
20
+ };
21
+
22
+ describe("账户编辑保存闭环", () => {
23
+ test("初始密码失败后重试更新已创建账号,不再发起新增", async () => {
24
+ const save = vi
25
+ .fn()
26
+ .mockResolvedValue({ id: "user_created", username: "alice" });
27
+ api.reset
28
+ .mockRejectedValueOnce(new Error("暂时不可用"))
29
+ .mockResolvedValueOnce({ success: true });
30
+ const mutated = vi.fn();
31
+ const editor = useResourceEditor({
32
+ key: "users",
33
+ save,
34
+ disable: vi.fn(),
35
+ mutated,
36
+ });
37
+ editor.edit();
38
+ expect(await editor.submit(payload)).toBe(false);
39
+ expect(editor.open.value).toBe(true);
40
+ expect(editor.item.value).toBeUndefined();
41
+ expect(save.mock.calls[0]?.[1]).toBeUndefined();
42
+ expect(mutated).not.toHaveBeenCalled();
43
+ expect(await editor.submit(payload)).toBe(true);
44
+ expect(save.mock.calls[1]?.[1]).toBe("user_created");
45
+ expect(api.reset).toHaveBeenLastCalledWith(
46
+ "user_created",
47
+ payload.temporaryPassword,
48
+ );
49
+ expect(mutated).toHaveBeenCalledTimes(1);
50
+ });
51
+
52
+ test("资料保存结束后,密码请求仍保持loading且阻止重复提交", async () => {
53
+ let finishPassword: (() => void) | undefined;
54
+ api.reset.mockImplementationOnce(
55
+ () =>
56
+ new Promise<void>((resolve) => {
57
+ finishPassword = resolve;
58
+ }),
59
+ );
60
+ const save = vi.fn().mockResolvedValue({ id: "user_created" });
61
+ const editor = useResourceEditor({
62
+ key: "users",
63
+ save,
64
+ disable: vi.fn(),
65
+ mutated: vi.fn(),
66
+ });
67
+ editor.edit();
68
+ const saving = editor.submit(payload);
69
+ await flushPromises();
70
+ expect(editor.loading.value).toBe(true);
71
+ expect(await editor.submit(payload)).toBe(false);
72
+ expect(save).toHaveBeenCalledTimes(1);
73
+ finishPassword?.();
74
+ expect(await saving).toBe(true);
75
+ expect(editor.loading.value).toBe(false);
76
+ });
77
+
78
+ test("部门同时修改资料并停用,将资料放入同一迁移请求", async () => {
79
+ const disable = vi.fn().mockResolvedValue(true);
80
+ const save = vi.fn();
81
+ const editor = useResourceEditor({
82
+ key: "departments",
83
+ save,
84
+ disable,
85
+ mutated: vi.fn(),
86
+ });
87
+ const original = {
88
+ id: "dept_1",
89
+ name: "旧名称",
90
+ status: "active",
91
+ parentId: "root",
92
+ };
93
+ editor.edit(original);
94
+ const updates = {
95
+ name: "新名称",
96
+ code: "sales",
97
+ order: 25,
98
+ parentId: "root",
99
+ };
100
+ expect(
101
+ await editor.submit({
102
+ ...updates,
103
+ status: "disabled",
104
+ _transitionTargetDepartmentId: "dept_2",
105
+ }),
106
+ ).toBe(true);
107
+ expect(disable).toHaveBeenCalledWith(original, "dept_2", updates);
108
+ expect(save).not.toHaveBeenCalled();
109
+ });
110
+ });
@@ -12,6 +12,7 @@ import {
12
12
  exportSystemResource,
13
13
  importSystemResource,
14
14
  listSystemResource,
15
+ listDepartmentOrganization,
15
16
  type SystemRecord,
16
17
  updateSystemResource,
17
18
  } from "@/services/system-api";
@@ -42,6 +43,11 @@ export function useSystemResource(options: UseSystemResourceOptions) {
42
43
  const page = shallowRef(1);
43
44
  const pageSize = shallowRef(DEFAULT_PAGE_SIZE);
44
45
  const q = shallowRef("");
46
+ const identityFilters = shallowRef<{
47
+ departmentId?: string;
48
+ roleId?: string;
49
+ status?: string;
50
+ }>({});
45
51
  const status = shallowRef("");
46
52
  const auditFilters = shallowRef<SystemResourceFilters>(
47
53
  initialResourceFilters(options.apiResource),
@@ -68,10 +74,19 @@ export function useSystemResource(options: UseSystemResourceOptions) {
68
74
  toValue(options.importable) !== false,
69
75
  );
70
76
 
77
+ let reloadVersion = 0;
71
78
  async function reload() {
79
+ const requestVersion = ++reloadVersion;
72
80
  loading.value = true;
73
81
  error.value = "";
74
82
  try {
83
+ if (options.apiResource === "departments") {
84
+ const result = await listDepartmentOrganization();
85
+ if (requestVersion !== reloadVersion) return;
86
+ items.value = result.items;
87
+ total.value = result.total;
88
+ return;
89
+ }
75
90
  const result = await listSystemResource(options.apiResource, {
76
91
  page: page.value,
77
92
  pageSize: pageSize.value,
@@ -79,19 +94,22 @@ export function useSystemResource(options: UseSystemResourceOptions) {
79
94
  action: options.searchParameter === "action" ? q.value : undefined,
80
95
  ...auditFilters.value,
81
96
  status: status.value,
97
+ ...identityFilters.value,
82
98
  sort: toValue(options.sort) || "-updatedAt",
83
99
  });
100
+ if (requestVersion !== reloadVersion) return;
84
101
  items.value = result.items;
85
102
  total.value = result.total;
86
103
  page.value = result.page;
87
104
  pageSize.value = result.pageSize;
88
105
  } catch (currentError) {
106
+ if (requestVersion !== reloadVersion) return;
89
107
  error.value =
90
108
  currentError instanceof Error
91
109
  ? currentError.message
92
110
  : String(currentError);
93
111
  } finally {
94
- loading.value = false;
112
+ if (requestVersion === reloadVersion) loading.value = false;
95
113
  }
96
114
  }
97
115
 
@@ -149,6 +167,7 @@ export function useSystemResource(options: UseSystemResourceOptions) {
149
167
  ...auditFilters.value,
150
168
  ...exportOptions.value,
151
169
  status: status.value,
170
+ ...identityFilters.value,
152
171
  sort: toValue(options.sort) || "-updatedAt",
153
172
  });
154
173
  downloadJson(filename, payload);
@@ -204,6 +223,12 @@ export function useSystemResource(options: UseSystemResourceOptions) {
204
223
  void reload();
205
224
  }
206
225
 
226
+ function setIdentityFilters(filters: typeof identityFilters.value) {
227
+ identityFilters.value = filters;
228
+ page.value = 1;
229
+ void reload();
230
+ }
231
+
207
232
  function setExportOptions(options: typeof exportOptions.value) {
208
233
  exportOptions.value = options;
209
234
  }
@@ -257,6 +282,7 @@ export function useSystemResource(options: UseSystemResourceOptions) {
257
282
  setKeyword,
258
283
  setStatus,
259
284
  setAuditFilters,
285
+ setIdentityFilters,
260
286
  setExportOptions,
261
287
  setPage,
262
288
  setPageSize,
@@ -73,6 +73,8 @@ export const systemResourceConfigs: Record<
73
73
  { key: "username", label: "用户名" },
74
74
  { key: "displayName", label: "姓名" },
75
75
  { key: "phone", label: "手机号", format: "governed-value" },
76
+ { key: "departmentName", label: "所属部门" },
77
+ { key: "roleNames", label: "角色" },
76
78
  { key: "status", label: "状态" },
77
79
  ],
78
80
  fields: [
@@ -17,6 +17,8 @@ export interface SystemListQuery {
17
17
  occurredAfter?: string;
18
18
  occurredBefore?: string;
19
19
  status?: string;
20
+ departmentId?: string;
21
+ roleId?: string;
20
22
  sort?: string;
21
23
  includeScheduler?: boolean;
22
24
  }
@@ -46,6 +48,8 @@ export function listSystemResource(resource: string, query: SystemListQuery) {
46
48
  if (query.occurredAfter) params.set("occurredAfter", query.occurredAfter);
47
49
  if (query.occurredBefore) params.set("occurredBefore", query.occurredBefore);
48
50
  if (query.status) params.set("status", query.status);
51
+ if (query.departmentId) params.set("departmentId", query.departmentId);
52
+ if (query.roleId) params.set("roleId", query.roleId);
49
53
  if (query.sort) params.set("sort", query.sort);
50
54
  if (query.includeScheduler) params.set("includeScheduler", "true");
51
55
  return apiRequest<ApiPage<SystemRecord>>(
@@ -63,6 +67,8 @@ export function exportSystemResource(resource: string, query: SystemListQuery) {
63
67
  if (query.occurredAfter) params.set("occurredAfter", query.occurredAfter);
64
68
  if (query.occurredBefore) params.set("occurredBefore", query.occurredBefore);
65
69
  if (query.status) params.set("status", query.status);
70
+ if (query.departmentId) params.set("departmentId", query.departmentId);
71
+ if (query.roleId) params.set("roleId", query.roleId);
66
72
  if (query.sort) params.set("sort", query.sort);
67
73
  if (query.includeScheduler) params.set("includeScheduler", "true");
68
74
  return apiRequest<SystemExportPayload>(
@@ -160,6 +166,7 @@ export function transitionDepartment(
160
166
  payload: {
161
167
  action: "disable" | "delete";
162
168
  targetDepartmentId?: string;
169
+ updates?: Record<string, unknown>;
163
170
  },
164
171
  ) {
165
172
  return apiRequest<{
@@ -172,3 +179,21 @@ export function transitionDepartment(
172
179
  body: JSON.stringify(payload),
173
180
  });
174
181
  }
182
+
183
+ export function listDepartmentOrganization() {
184
+ return apiRequest<{ items: SystemRecord[]; total: number }>(
185
+ "/system/departments/organization",
186
+ );
187
+ }
188
+
189
+ export interface DepartmentTransitionPreview {
190
+ childDepartmentCount: number;
191
+ userCount: number;
192
+ roleCount: number;
193
+ }
194
+
195
+ export function previewDepartmentTransition(departmentId: string) {
196
+ return apiRequest<DepartmentTransitionPreview>(
197
+ `/system/departments/${departmentId}/transition-preview`,
198
+ );
199
+ }