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
@@ -8,7 +8,13 @@ import {
8
8
  SelectTrigger,
9
9
  SelectValue,
10
10
  } from "@/components/ui/select";
11
- import { listSystemResource, type SystemRecord } from "@/services/system-api";
11
+ import {
12
+ previewDepartmentTransition,
13
+ type SystemRecord,
14
+ } from "@/services/system-api";
15
+
16
+ import PagedSelectionList from "../PagedSelectionList.vue";
17
+ import { departmentOptions, loadSelectionRecords } from "../selection-options";
12
18
 
13
19
  const props = defineProps<{
14
20
  item?: SystemRecord;
@@ -27,34 +33,65 @@ const transitionTargetId = shallowRef<string>();
27
33
  const isRoot = computed(() => Boolean(props.item && !props.item.parentId));
28
34
  const isDisabling = computed(
29
35
  () =>
30
- props.item?.status === "active" &&
36
+ Boolean(props.item) &&
37
+ props.item?.status !== "disabled" &&
31
38
  form.value.status === "disabled" &&
32
39
  !isRoot.value,
33
40
  );
34
41
  const parentOptions = computed(() =>
35
- departments.value.filter(
36
- (department) =>
37
- department.id !== props.item?.id &&
38
- !isDescendant(department.id, props.item?.id),
39
- ),
42
+ departmentOptions(departments.value, props.item?.id),
43
+ );
44
+ const baseline = shallowRef("");
45
+ const dirty = computed(
46
+ () =>
47
+ JSON.stringify([form.value, transitionTargetId.value]) !== baseline.value,
40
48
  );
41
- const migrationOptions = computed(() =>
42
- departments.value.filter(
43
- (department) =>
44
- department.id !== props.item?.id &&
45
- !isDescendant(department.id, props.item?.id),
49
+ const preview = shallowRef<{
50
+ childDepartmentCount: number;
51
+ userCount: number;
52
+ roleCount: number;
53
+ }>();
54
+ const requiresTarget = computed(() =>
55
+ Boolean(
56
+ preview.value &&
57
+ preview.value.childDepartmentCount +
58
+ preview.value.userCount +
59
+ preview.value.roleCount >
60
+ 0,
46
61
  ),
47
62
  );
63
+ const previewLoading = shallowRef(false);
64
+ const previewError = shallowRef("");
65
+ function markSaved() {
66
+ baseline.value = JSON.stringify([form.value, transitionTargetId.value]);
67
+ }
68
+ watch(isDisabling, async (value) => {
69
+ preview.value = undefined;
70
+ previewError.value = "";
71
+ if (!value || !props.item) return;
72
+ previewLoading.value = true;
73
+ try {
74
+ preview.value = await previewDepartmentTransition(props.item.id);
75
+ } catch (cause) {
76
+ previewError.value = cause instanceof Error ? cause.message : String(cause);
77
+ } finally {
78
+ previewLoading.value = false;
79
+ }
80
+ });
48
81
 
49
82
  watch(
50
83
  () => [props.item, props.defaults] as const,
51
84
  () => {
52
- form.value = {
53
- ...props.defaults,
54
- ...(props.item || {}),
55
- status: props.item?.status || props.defaults.status || "active",
56
- };
85
+ const source = { ...props.defaults, ...(props.item || {}) };
86
+ form.value = Object.fromEntries(
87
+ ["code", "name", "parentId", "order", "status"].map((key) => [
88
+ key,
89
+ source[key],
90
+ ]),
91
+ );
92
+ form.value.status ||= "active";
57
93
  transitionTargetId.value = undefined;
94
+ markSaved();
58
95
  },
59
96
  { immediate: true },
60
97
  );
@@ -65,13 +102,7 @@ async function loadDepartments() {
65
102
  loading.value = true;
66
103
  error.value = "";
67
104
  try {
68
- const result = await listSystemResource("departments", {
69
- page: 1,
70
- pageSize: 100,
71
- status: "active",
72
- sort: "order,name",
73
- });
74
- departments.value = result.items;
105
+ departments.value = await loadSelectionRecords("departments", false);
75
106
  } catch (currentError) {
76
107
  error.value =
77
108
  currentError instanceof Error
@@ -87,6 +118,31 @@ function update(key: string, value: unknown) {
87
118
  }
88
119
 
89
120
  function submit() {
121
+ error.value = "";
122
+ if (
123
+ !String(form.value.code || "").trim() ||
124
+ !String(form.value.name || "").trim() ||
125
+ (!isRoot.value && !form.value.parentId)
126
+ ) {
127
+ error.value = "请填写部门编码、名称并选择上级部门。";
128
+ return;
129
+ }
130
+ if (
131
+ !Number.isInteger(Number(form.value.order)) ||
132
+ Number(form.value.order) < 0
133
+ ) {
134
+ error.value = "排序须为非负整数。";
135
+ return;
136
+ }
137
+ if (
138
+ isDisabling.value &&
139
+ (!preview.value ||
140
+ (requiresTarget.value && !transitionTargetId.value) ||
141
+ previewLoading.value)
142
+ ) {
143
+ error.value = "请等待影响预览加载成功;存在关联数据时须选择迁移目标。";
144
+ return;
145
+ }
90
146
  emit("submit", {
91
147
  ...form.value,
92
148
  ...(isDisabling.value
@@ -95,22 +151,13 @@ function submit() {
95
151
  });
96
152
  }
97
153
 
98
- function isDescendant(candidateId: string, ancestorId: unknown): boolean {
99
- if (typeof ancestorId !== "string") return false;
100
- const byId = new Map(
101
- departments.value.map((department) => [department.id, department]),
102
- );
103
- const visited = new Set<string>();
104
- let current = byId.get(candidateId);
105
- while (typeof current?.parentId === "string" && !visited.has(current.id)) {
106
- visited.add(current.id);
107
- if (current.parentId === ancestorId) return true;
108
- current = byId.get(current.parentId);
109
- }
110
- return false;
111
- }
112
-
113
- defineExpose({ submit });
154
+ defineExpose({
155
+ submit,
156
+ markSaved,
157
+ get dirty() {
158
+ return dirty.value;
159
+ },
160
+ });
114
161
  </script>
115
162
 
116
163
  <template>
@@ -131,35 +178,18 @@ defineExpose({ submit });
131
178
  @update:model-value="update('name', String($event))"
132
179
  />
133
180
  </label>
134
- <label class="grid gap-2 text-sm font-medium">
181
+ <div class="grid gap-2 text-sm font-medium">
135
182
  <span>上级部门</span>
136
- <Select
137
- :model-value="typeof form.parentId === 'string' ? form.parentId : ''"
138
- :disabled="loading || isRoot"
139
- @update:model-value="update('parentId', String($event))"
140
- >
141
- <SelectTrigger class="w-full" aria-label="上级部门">
142
- <SelectValue
143
- :placeholder="
144
- isRoot
145
- ? '平台唯一根组织'
146
- : loading
147
- ? '正在加载…'
148
- : '请选择上级部门'
149
- "
150
- />
151
- </SelectTrigger>
152
- <SelectContent>
153
- <SelectItem
154
- v-for="department in parentOptions"
155
- :key="department.id"
156
- :value="department.id"
157
- >
158
- {{ String(department.name || department.code) }}
159
- </SelectItem>
160
- </SelectContent>
161
- </Select>
162
- </label>
183
+ <p v-if="isRoot" class="text-sm text-muted-foreground">平台唯一根组织</p>
184
+ <PagedSelectionList
185
+ v-else
186
+ :items="parentOptions"
187
+ :model-value="typeof form.parentId === 'string' ? [form.parentId] : []"
188
+ label="上级部门"
189
+ :disabled="loading"
190
+ @update:model-value="update('parentId', $event[0])"
191
+ />
192
+ </div>
163
193
  <label class="grid gap-2 text-sm font-medium">
164
194
  <span>排序</span>
165
195
  <Input
@@ -185,26 +215,32 @@ defineExpose({ submit });
185
215
  </SelectContent>
186
216
  </Select>
187
217
  </label>
188
- <label v-if="isDisabling" class="grid gap-2 text-sm font-medium">
218
+ <div v-if="isDisabling" class="grid gap-2 text-sm font-medium">
189
219
  <span>关联数据迁移至</span>
190
- <Select v-model="transitionTargetId">
191
- <SelectTrigger class="w-full" aria-label="关联数据迁移目标">
192
- <SelectValue placeholder="请选择启用部门" />
193
- </SelectTrigger>
194
- <SelectContent>
195
- <SelectItem
196
- v-for="department in migrationOptions"
197
- :key="department.id"
198
- :value="department.id"
199
- >
200
- {{ String(department.name || department.code) }}
201
- </SelectItem>
202
- </SelectContent>
203
- </Select>
220
+ <p v-if="previewLoading" class="text-sm text-muted-foreground">
221
+ 正在读取迁移影响…
222
+ </p>
223
+ <p v-else-if="previewError" class="text-sm text-destructive">
224
+ {{ previewError }}
225
+ </p>
226
+ <p v-else-if="preview" class="text-sm">
227
+ 将迁移 {{ preview.childDepartmentCount }} 个直属下级部门、{{
228
+ preview.userCount
229
+ }}
230
+ 个账号归属、{{ preview.roleCount }} 个角色数据范围。
231
+ </p>
232
+ <PagedSelectionList
233
+ :items="parentOptions"
234
+ :model-value="transitionTargetId ? [transitionTargetId] : []"
235
+ label="关联数据迁移目标"
236
+ clearable
237
+ :disabled="loading || previewLoading || !preview"
238
+ @update:model-value="transitionTargetId = $event[0]"
239
+ />
204
240
  <span class="text-xs font-normal text-muted-foreground">
205
- 下级部门、用户归属和角色自定义部门范围会一起迁移。
241
+ 下级部门、用户归属和角色自定义部门范围会一起迁移;没有关联数据时可直接停用。
206
242
  </span>
207
- </label>
243
+ </div>
208
244
  <p v-if="error" class="text-sm text-destructive">{{ error }}</p>
209
245
  </div>
210
246
  </template>
@@ -0,0 +1,57 @@
1
+ import { flushPromises, mount } from "@vue/test-utils";
2
+ import { afterEach, describe, expect, test, vi } from "vitest";
3
+ import { Select } from "@/components/ui/select";
4
+ import DepartmentOrganizationForm from "./DepartmentOrganizationForm.vue";
5
+
6
+ const api = vi.hoisted(() => ({
7
+ listSystemResource: vi.fn(),
8
+ previewDepartmentTransition: vi.fn(),
9
+ }));
10
+ vi.mock("@/services/system-api", () => api);
11
+ afterEach(() => vi.clearAllMocks());
12
+
13
+ describe("部门停用预览", () => {
14
+ test.each([0, 1])(
15
+ "关联账号数 %s 时按预览决定是否要求迁移目标",
16
+ async (userCount) => {
17
+ api.listSystemResource.mockResolvedValue({
18
+ items: [{ id: "root", name: "总部", status: "active" }],
19
+ page: 1,
20
+ pageSize: 10,
21
+ total: 1,
22
+ });
23
+ api.previewDepartmentTransition.mockResolvedValue({
24
+ childDepartmentCount: 0,
25
+ userCount,
26
+ roleCount: 0,
27
+ });
28
+ const wrapper = mount(DepartmentOrganizationForm, {
29
+ props: {
30
+ defaults: {},
31
+ item: {
32
+ id: "dept",
33
+ code: "sales",
34
+ name: "销售部",
35
+ parentId: "root",
36
+ status: "active",
37
+ order: 10,
38
+ },
39
+ },
40
+ });
41
+ await flushPromises();
42
+ wrapper.getComponent(Select).vm.$emit("update:modelValue", "disabled");
43
+ await flushPromises();
44
+ (wrapper.vm as unknown as { submit(): void }).submit();
45
+ if (userCount) {
46
+ expect(wrapper.emitted("submit")).toBeUndefined();
47
+ } else {
48
+ expect(wrapper.emitted("submit")?.at(-1)?.[0]).toMatchObject({
49
+ code: "sales",
50
+ name: "销售部",
51
+ status: "disabled",
52
+ });
53
+ expect(wrapper.emitted("submit")?.at(-1)?.[0]).not.toHaveProperty("id");
54
+ }
55
+ },
56
+ );
57
+ });
@@ -0,0 +1,227 @@
1
+ <script setup lang="ts">
2
+ import { computed, shallowRef, watch } from "vue";
3
+ import { ChevronDownIcon, ChevronRightIcon } from "@lucide/vue";
4
+ import { Button } from "@/components/ui/button";
5
+ import { Badge } from "@/components/ui/badge";
6
+ import {
7
+ Table,
8
+ TableBody,
9
+ TableCell,
10
+ TableHead,
11
+ TableHeader,
12
+ TableRow,
13
+ } from "@/components/ui/table";
14
+ import DataPagination from "@/components/common/DataPagination.vue";
15
+ import type { SystemRecord } from "@/services/system-api";
16
+
17
+ const props = defineProps<{
18
+ items: readonly SystemRecord[];
19
+ keyword: string;
20
+ loading?: boolean;
21
+ editable?: boolean;
22
+ deletable?: boolean;
23
+ }>();
24
+ defineEmits<{
25
+ edit: [item: SystemRecord];
26
+ remove: [item: SystemRecord];
27
+ view: [item: SystemRecord];
28
+ }>();
29
+ const collapsed = shallowRef<ReadonlySet<string>>(new Set());
30
+ const page = shallowRef(1);
31
+ const pageSize = shallowRef(10);
32
+ const hierarchy = computed(() => {
33
+ const byId = new Map(props.items.map((item) => [item.id, item]));
34
+ const children = new Map<string, SystemRecord[]>();
35
+ for (const item of props.items) {
36
+ const parentId =
37
+ typeof item.parentId === "string" && byId.has(item.parentId)
38
+ ? item.parentId
39
+ : "";
40
+ const siblings = children.get(parentId) || [];
41
+ siblings.push(item);
42
+ children.set(parentId, siblings);
43
+ }
44
+ for (const siblings of children.values())
45
+ siblings.sort(
46
+ (a, b) =>
47
+ Number(a.order || 0) - Number(b.order || 0) ||
48
+ String(a.name).localeCompare(String(b.name), "zh-CN"),
49
+ );
50
+ const rows: Array<{
51
+ item: SystemRecord;
52
+ depth: number;
53
+ path: string;
54
+ hasChildren: boolean;
55
+ }> = [];
56
+ const visited = new Set<string>();
57
+ const keyword = props.keyword.trim().toLocaleLowerCase();
58
+ function visit(item: SystemRecord, depth: number, path: string[]) {
59
+ if (visited.has(item.id)) return;
60
+ visited.add(item.id);
61
+ const names = [...path, String(item.name || item.code || item.id)];
62
+ const childItems = children.get(item.id) || [];
63
+ if (
64
+ !keyword ||
65
+ [item.name, item.code, names.join(" / ")].some((value) =>
66
+ String(value || "")
67
+ .toLocaleLowerCase()
68
+ .includes(keyword),
69
+ )
70
+ ) {
71
+ rows.push({
72
+ item,
73
+ depth,
74
+ path: names.join(" / "),
75
+ hasChildren: childItems.length > 0,
76
+ });
77
+ }
78
+ if (keyword || !collapsed.value.has(item.id))
79
+ for (const child of childItems) visit(child, depth + 1, names);
80
+ }
81
+ for (const item of children.get("") || []) visit(item, 0, []);
82
+ return rows;
83
+ });
84
+ const visible = computed(() =>
85
+ hierarchy.value.slice(
86
+ (page.value - 1) * pageSize.value,
87
+ page.value * pageSize.value,
88
+ ),
89
+ );
90
+ watch(
91
+ () => props.keyword,
92
+ () => {
93
+ page.value = 1;
94
+ },
95
+ );
96
+ watch(
97
+ () => hierarchy.value.length,
98
+ (total) => {
99
+ page.value = Math.min(
100
+ page.value,
101
+ Math.max(1, Math.ceil(total / pageSize.value)),
102
+ );
103
+ },
104
+ );
105
+ function toggle(id: string) {
106
+ const next = new Set(collapsed.value);
107
+ if (next.has(id)) next.delete(id);
108
+ else next.add(id);
109
+ collapsed.value = next;
110
+ }
111
+ function setPageSize(size: number) {
112
+ pageSize.value = size;
113
+ page.value = 1;
114
+ }
115
+ </script>
116
+
117
+ <template>
118
+ <div class="space-y-4">
119
+ <div class="flex flex-wrap items-center justify-between gap-2">
120
+ <p class="text-sm text-muted-foreground">
121
+ 按部门层级展示;展开或收起下级,每页显示 {{ pageSize }} 条。
122
+ </p>
123
+ <div class="flex gap-2">
124
+ <Button variant="outline" size="sm" @click="collapsed = new Set()"
125
+ >展开全部</Button
126
+ >
127
+ <Button
128
+ variant="outline"
129
+ size="sm"
130
+ @click="collapsed = new Set(items.map((item) => item.id))"
131
+ >收起全部</Button
132
+ >
133
+ </div>
134
+ </div>
135
+ <div class="overflow-hidden rounded-md border" :aria-busy="loading">
136
+ <Table>
137
+ <TableHeader
138
+ ><TableRow>
139
+ <TableHead>部门</TableHead><TableHead>编码</TableHead
140
+ ><TableHead>部门路径</TableHead> <TableHead>直属成员</TableHead
141
+ ><TableHead>排序</TableHead><TableHead>状态</TableHead
142
+ ><TableHead class="text-right">操作</TableHead>
143
+ </TableRow></TableHeader
144
+ >
145
+ <TableBody>
146
+ <TableRow v-if="loading"
147
+ ><TableCell colspan="7" class="h-24 text-center"
148
+ >正在加载组织…</TableCell
149
+ ></TableRow
150
+ >
151
+ <TableRow v-else-if="!visible.length"
152
+ ><TableCell
153
+ colspan="7"
154
+ class="h-24 text-center text-muted-foreground"
155
+ >暂无匹配部门</TableCell
156
+ ></TableRow
157
+ >
158
+ <TableRow v-for="row in visible" v-else :key="row.item.id">
159
+ <TableCell>
160
+ <div
161
+ class="flex items-center gap-1"
162
+ :style="{ paddingLeft: `${Math.min(row.depth, 8) * 16}px` }"
163
+ >
164
+ <Button
165
+ v-if="row.hasChildren"
166
+ variant="ghost"
167
+ size="icon-sm"
168
+ :aria-expanded="!collapsed.has(row.item.id)"
169
+ :aria-label="`${collapsed.has(row.item.id) ? '展开' : '收起'}${row.item.name}的下级部门`"
170
+ :disabled="Boolean(keyword)"
171
+ @click="toggle(row.item.id)"
172
+ >
173
+ <ChevronRightIcon
174
+ v-if="collapsed.has(row.item.id) && !keyword"
175
+ /><ChevronDownIcon v-else />
176
+ </Button>
177
+ <span v-else class="w-8" aria-hidden="true" />
178
+ <span class="font-medium">{{ row.item.name }}</span>
179
+ </div>
180
+ </TableCell>
181
+ <TableCell>{{ row.item.code }}</TableCell
182
+ ><TableCell class="text-muted-foreground">{{ row.path }}</TableCell>
183
+ <TableCell>{{ row.item.userCount ?? "—" }}</TableCell
184
+ ><TableCell>{{ row.item.order ?? "—" }}</TableCell>
185
+ <TableCell
186
+ ><Badge variant="secondary">{{
187
+ row.item.status === "active" ? "启用" : "停用"
188
+ }}</Badge></TableCell
189
+ >
190
+ <TableCell
191
+ ><div class="flex justify-end gap-2">
192
+ <Button
193
+ variant="outline"
194
+ size="sm"
195
+ @click="
196
+ $emit('view', { ...row.item, departmentPath: row.path })
197
+ "
198
+ >详情</Button
199
+ >
200
+ <Button
201
+ v-if="editable"
202
+ variant="outline"
203
+ size="sm"
204
+ @click="$emit('edit', row.item)"
205
+ >编辑</Button
206
+ >
207
+ <Button
208
+ v-if="deletable && row.item.parentId"
209
+ variant="destructive"
210
+ size="sm"
211
+ @click="$emit('remove', row.item)"
212
+ >删除</Button
213
+ >
214
+ </div></TableCell
215
+ >
216
+ </TableRow>
217
+ </TableBody>
218
+ </Table>
219
+ </div>
220
+ <DataPagination
221
+ v-model:page="page"
222
+ :page-size="pageSize"
223
+ :total="hierarchy.length"
224
+ @update:page-size="setPageSize"
225
+ />
226
+ </div>
227
+ </template>
@@ -0,0 +1,62 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { mount } from "@vue/test-utils";
3
+ import DepartmentOrganizationTable from "./DepartmentOrganizationTable.vue";
4
+ const items = [
5
+ {
6
+ id: "root",
7
+ name: "总部",
8
+ code: "root",
9
+ order: 0,
10
+ status: "active",
11
+ userCount: 0,
12
+ },
13
+ ...Array.from({ length: 12 }, (_, index) => ({
14
+ id: `dept_${index}`,
15
+ parentId: "root",
16
+ name: `部门${index + 1}`,
17
+ code: `d${index}`,
18
+ order: index + 1,
19
+ status: "active",
20
+ userCount: index,
21
+ })),
22
+ ];
23
+ describe("部门组织树", () => {
24
+ test("默认每页10条,后续部门可翻页查看且保留完整路径", async () => {
25
+ const wrapper = mount(DepartmentOrganizationTable, {
26
+ props: { items, keyword: "", editable: true, deletable: true },
27
+ });
28
+ expect(wrapper.findAll("tbody tr")).toHaveLength(10);
29
+ expect(wrapper.text()).toContain("总部 / 部门9");
30
+ await wrapper.get('[data-testid="next-page"]').trigger("click");
31
+ expect(wrapper.findAll("tbody tr")).toHaveLength(3);
32
+ expect(wrapper.text()).toContain("总部 / 部门12");
33
+ const detail = wrapper
34
+ .findAll("button")
35
+ .find((button) => button.text() === "详情");
36
+ await detail?.trigger("click");
37
+ expect(wrapper.emitted("view")?.[0]?.[0]).toEqual(
38
+ expect.objectContaining({
39
+ departmentPath: "总部 / 部门10",
40
+ userCount: 9,
41
+ }),
42
+ );
43
+ wrapper.unmount();
44
+ });
45
+ test("收起层级重新计算分页,搜索能找到隐藏下级,根组织不显示删除", async () => {
46
+ const wrapper = mount(DepartmentOrganizationTable, {
47
+ props: { items, keyword: "", editable: true, deletable: true },
48
+ });
49
+ await wrapper
50
+ .get('button[aria-label="收起总部的下级部门"]')
51
+ .trigger("click");
52
+ expect(wrapper.findAll("tbody tr")).toHaveLength(1);
53
+ expect(wrapper.get("tbody").text()).not.toContain("删除");
54
+ await wrapper.setProps({ keyword: "部门12" });
55
+ expect(wrapper.findAll("tbody tr")).toHaveLength(1);
56
+ expect(wrapper.text()).toContain("总部 / 部门12");
57
+ await wrapper.setProps({ editable: false, deletable: false });
58
+ expect(wrapper.get("tbody").text()).not.toContain("编辑");
59
+ expect(wrapper.get("tbody").text()).not.toContain("删除");
60
+ wrapper.unmount();
61
+ });
62
+ });