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
@@ -0,0 +1,108 @@
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 RolePolicyForm from "./RolePolicyForm.vue";
5
+ import RolePermissionTree from "./RolePermissionTree.vue";
6
+ import PagedSelectionList from "../PagedSelectionList.vue";
7
+
8
+ const api = vi.hoisted(() => ({ listSystemResource: vi.fn() }));
9
+ vi.mock("@/services/system-api", () => api);
10
+ afterEach(() => vi.clearAllMocks());
11
+
12
+ interface FormHandle {
13
+ dirty: boolean;
14
+ submit(): void;
15
+ markSaved(): void;
16
+ }
17
+
18
+ describe("角色授权表单", () => {
19
+ test("新增 Manifest 权限可选择,自定义范围携带部门并保留菜单字段", async () => {
20
+ api.listSystemResource.mockResolvedValue({
21
+ items: [{ id: "dept-root", name: "总部", status: "active" }],
22
+ total: 1,
23
+ page: 1,
24
+ pageSize: 10,
25
+ });
26
+ const wrapper = mount(RolePolicyForm, {
27
+ props: {
28
+ defaults: {},
29
+ item: {
30
+ id: "role-one",
31
+ code: "reviewer",
32
+ name: "审核员",
33
+ dataScope: "custom",
34
+ permissionKeys: [],
35
+ departmentIds: ["dept-root"],
36
+ menuKeys: ["business.report"],
37
+ createdAt: "2026-09-10",
38
+ },
39
+ permissions: [
40
+ {
41
+ key: "business.report.export",
42
+ label: "导出报表",
43
+ module: "business",
44
+ moduleLabel: "业务",
45
+ resource: "report",
46
+ resourceLabel: "报表",
47
+ action: "export",
48
+ },
49
+ ],
50
+ },
51
+ });
52
+ await flushPromises();
53
+ const handle = wrapper.vm as unknown as FormHandle;
54
+ expect(handle.dirty).toBe(false);
55
+ await wrapper
56
+ .get('[data-testid="permission-business.report.export"]')
57
+ .trigger("click");
58
+ expect(handle.dirty).toBe(true);
59
+ expect(
60
+ wrapper.getComponent(PagedSelectionList).props("modelValue"),
61
+ ).toEqual(["dept-root"]);
62
+ handle.submit();
63
+ expect(wrapper.emitted("submit")?.at(-1)?.[0]).toEqual({
64
+ code: "reviewer",
65
+ name: "审核员",
66
+ status: "active",
67
+ dataScope: "custom",
68
+ departmentIds: ["dept-root"],
69
+ permissionKeys: ["business.report.export"],
70
+ menuKeys: ["business.report"],
71
+ });
72
+ wrapper.getComponent(RolePermissionTree).vm.$emit("update:modelValue", []);
73
+ await wrapper.vm.$nextTick();
74
+ expect(handle.dirty).toBe(false);
75
+ wrapper
76
+ .getComponent(RolePermissionTree)
77
+ .vm.$emit("update:modelValue", ["business.report.export"]);
78
+ await wrapper.vm.$nextTick();
79
+ handle.markSaved();
80
+ expect(handle.dirty).toBe(false);
81
+ wrapper.getComponent(Select).vm.$emit("update:modelValue", "disabled");
82
+ await wrapper.vm.$nextTick();
83
+ handle.submit();
84
+ expect(wrapper.emitted("submit")?.at(-1)?.[0]).toMatchObject({
85
+ status: "disabled",
86
+ });
87
+ });
88
+
89
+ test("自定义数据范围未选部门时保持编辑状态且不提交", async () => {
90
+ api.listSystemResource.mockResolvedValue({
91
+ items: [],
92
+ total: 0,
93
+ page: 1,
94
+ pageSize: 10,
95
+ });
96
+ const wrapper = mount(RolePolicyForm, {
97
+ props: {
98
+ defaults: { code: "role", name: "角色", dataScope: "custom" },
99
+ permissions: [],
100
+ },
101
+ });
102
+ await flushPromises();
103
+ (wrapper.vm as unknown as FormHandle).submit();
104
+ expect(wrapper.emitted("submit")).toBeUndefined();
105
+ await wrapper.vm.$nextTick();
106
+ expect(wrapper.text()).toContain("自定义数据范围须选择至少一个部门");
107
+ });
108
+ });
@@ -0,0 +1,71 @@
1
+ import { listSystemResource, type SystemRecord } from "@/services/system-api";
2
+
3
+ /** 完整读取组织关系,分页展示不能改变后代判断或已选集合。 */
4
+ export async function loadSelectionRecords(
5
+ resource: "departments" | "roles",
6
+ activeOnly = true,
7
+ ): Promise<SystemRecord[]> {
8
+ const records = new Map<string, SystemRecord>();
9
+ let page = 1;
10
+ while (true) {
11
+ const result = await listSystemResource(resource, {
12
+ page,
13
+ pageSize: 10,
14
+ status: activeOnly ? "active" : undefined,
15
+ sort: resource === "departments" ? "order,name,id" : "name,id",
16
+ });
17
+ for (const item of result.items) records.set(item.id, item);
18
+ if (result.page * result.pageSize >= result.total) break;
19
+ if (!result.items.length || result.page < page)
20
+ throw new Error("选项分页数据不完整,请刷新后重试。");
21
+ page = result.page + 1;
22
+ }
23
+ return [...records.values()];
24
+ }
25
+
26
+ export function departmentOptions(
27
+ records: SystemRecord[],
28
+ excludedId?: string,
29
+ ) {
30
+ const byId = new Map(records.map((record) => [record.id, record]));
31
+ return records
32
+ .filter((record) => {
33
+ if (
34
+ (record.status && record.status !== "active") ||
35
+ record.id === excludedId
36
+ )
37
+ return false;
38
+ const visited = new Set<string>();
39
+ let current: SystemRecord | undefined = record;
40
+ while (current && typeof current.parentId === "string") {
41
+ if (current.parentId === excludedId || visited.has(current.id))
42
+ return false;
43
+ visited.add(current.id);
44
+ current = byId.get(current.parentId);
45
+ }
46
+ return true;
47
+ })
48
+ .map((record) => ({
49
+ id: record.id,
50
+ label: String(record.name || record.code),
51
+ description: `${departmentPath(record, byId)} · ${String(record.code || "")}`,
52
+ }));
53
+ }
54
+
55
+ function departmentPath(
56
+ record: SystemRecord,
57
+ byId: Map<string, SystemRecord>,
58
+ ): string {
59
+ const names: string[] = [];
60
+ const visited = new Set<string>();
61
+ let current: SystemRecord | undefined = record;
62
+ while (current && !visited.has(current.id)) {
63
+ visited.add(current.id);
64
+ names.unshift(String(current.name || current.code || current.id));
65
+ current =
66
+ typeof current.parentId === "string"
67
+ ? byId.get(current.parentId)
68
+ : undefined;
69
+ }
70
+ return names.join(" / ");
71
+ }
@@ -0,0 +1,49 @@
1
+ import { afterEach, describe, expect, test, vi } from "vitest";
2
+ import { departmentOptions, loadSelectionRecords } from "./selection-options";
3
+
4
+ const api = vi.hoisted(() => ({ listSystemResource: vi.fn() }));
5
+ vi.mock("@/services/system-api", () => api);
6
+ afterEach(() => vi.clearAllMocks());
7
+
8
+ describe("组织与角色选项", () => {
9
+ test("完整读取超过一百条记录,每个请求十条", async () => {
10
+ const items = Array.from({ length: 105 }, (_, index) => ({
11
+ id: `dept-${index}`,
12
+ name: `部门 ${index}`,
13
+ status: "active",
14
+ }));
15
+ api.listSystemResource.mockImplementation(
16
+ (_resource: string, query: { page: number; pageSize: number }) =>
17
+ Promise.resolve({
18
+ items: items.slice((query.page - 1) * 10, query.page * 10),
19
+ total: items.length,
20
+ ...query,
21
+ }),
22
+ );
23
+ expect(await loadSelectionRecords("departments")).toHaveLength(105);
24
+ expect(api.listSystemResource).toHaveBeenCalledTimes(11);
25
+ expect(api.listSystemResource).toHaveBeenLastCalledWith(
26
+ "departments",
27
+ expect.objectContaining({ page: 11, pageSize: 10 }),
28
+ );
29
+ });
30
+ test("利用完整组织链排除自身、后代和停用部门", () => {
31
+ const records = [
32
+ { id: "root", name: "总部", status: "active" },
33
+ { id: "source", parentId: "root", status: "active" },
34
+ { id: "child", parentId: "source", status: "disabled" },
35
+ { id: "grandchild", parentId: "child", status: "active" },
36
+ {
37
+ id: "target",
38
+ name: "销售部",
39
+ code: "sales",
40
+ parentId: "root",
41
+ status: "active",
42
+ },
43
+ ];
44
+ expect(departmentOptions(records, "source").map(({ id }) => id)).toEqual([
45
+ "root",
46
+ "target",
47
+ ]);
48
+ });
49
+ });
@@ -11,13 +11,8 @@ import { routerKey } from "vue-router";
11
11
  import FormDialog from "@/components/common/FormDialog.vue";
12
12
  import UnsavedChangesDialog from "@/components/common/UnsavedChangesDialog.vue";
13
13
  import { Input } from "@/components/ui/input";
14
- import {
15
- Select,
16
- SelectContent,
17
- SelectItem,
18
- SelectTrigger,
19
- SelectValue,
20
- } from "@/components/ui/select";
14
+ import { departmentOptions } from "../selection-options";
15
+ import PagedSelectionList from "../PagedSelectionList.vue";
21
16
  import { useUnsavedChangesConfirmation } from "@/composables/useUnsavedChangesConfirmation";
22
17
  import {
23
18
  createSystemResource,
@@ -35,12 +30,14 @@ interface DepartmentOption {
35
30
  const open = defineModel<boolean>("open", { default: false });
36
31
  const props = defineProps<{
37
32
  mode: "create" | "manage";
38
- departments: DepartmentOption[];
33
+ managedNavigation?: boolean;
34
+ departments: SystemRecord[];
39
35
  }>();
40
36
  const emit = defineEmits<{
41
37
  created: [department: DepartmentOption];
42
38
  }>();
43
39
 
40
+ const choices = computed(() => departmentOptions(props.departments));
44
41
  const name = shallowRef("");
45
42
  const code = shallowRef("");
46
43
  const parentId = shallowRef<string>();
@@ -63,9 +60,10 @@ watch(open, (value) => {
63
60
 
64
61
  onMounted(() => {
65
62
  window.addEventListener("beforeunload", preventDirtyUnload);
66
- removeRouteGuard = router?.beforeEach(() =>
67
- dirty.value ? confirmation.request(create) : true,
68
- );
63
+ if (!props.managedNavigation)
64
+ removeRouteGuard = router?.beforeEach(() =>
65
+ dirty.value ? confirmation.request(create) : true,
66
+ );
69
67
  });
70
68
 
71
69
  onBeforeUnmount(() => {
@@ -103,6 +101,7 @@ async function create(): Promise<boolean> {
103
101
  }
104
102
 
105
103
  function beforeClose() {
104
+ if (saving.value) return false;
106
105
  return dirty.value ? confirmation.request(create) : true;
107
106
  }
108
107
 
@@ -128,6 +127,7 @@ function readDepartment(record: SystemRecord): DepartmentOption {
128
127
  typeof record.parentId === "string" ? record.parentId : parentId.value,
129
128
  };
130
129
  }
130
+ defineExpose({ beforeClose });
131
131
  </script>
132
132
 
133
133
  <template>
@@ -153,23 +153,15 @@ function readDepartment(record: SystemRecord): DepartmentOption {
153
153
  <Input v-model="code" placeholder="例如 sales" />
154
154
  </label>
155
155
  </div>
156
- <label class="grid gap-2 text-sm font-medium">
156
+ <div class="grid gap-2 text-sm font-medium">
157
157
  <span>上级部门</span>
158
- <Select v-model="parentId">
159
- <SelectTrigger class="w-full" aria-label="上级部门">
160
- <SelectValue placeholder="请选择上级部门" />
161
- </SelectTrigger>
162
- <SelectContent>
163
- <SelectItem
164
- v-for="department in departments"
165
- :key="department.id"
166
- :value="department.id"
167
- >
168
- {{ department.name }}({{ department.code }})
169
- </SelectItem>
170
- </SelectContent>
171
- </Select>
172
- </label>
158
+ <PagedSelectionList
159
+ :items="choices"
160
+ :model-value="parentId ? [parentId] : []"
161
+ label="上级部门"
162
+ @update:model-value="parentId = $event[0]"
163
+ />
164
+ </div>
173
165
  </FormDialog>
174
166
 
175
167
  <UnsavedChangesDialog
@@ -52,15 +52,11 @@ describe("CreateDepartmentDialog", () => {
52
52
  template:
53
53
  '<input :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />',
54
54
  }),
55
- Select: defineComponent({
55
+ PagedSelectionList: defineComponent({
56
56
  emits: ["update:modelValue"],
57
57
  template:
58
- "<button data-testid=\"choose-parent\" @click=\"$emit('update:modelValue', 'dept_root')\"><slot /></button>",
58
+ "<button data-testid=\"choose-parent\" @click=\"$emit('update:modelValue', ['dept_root'])\">选择总部</button>",
59
59
  }),
60
- SelectTrigger: { template: "<span><slot /></span>" },
61
- SelectValue: { template: "<span />" },
62
- SelectContent: { template: "<span><slot /></span>" },
63
- SelectItem: { template: "<span><slot /></span>" },
64
60
  UnsavedChangesDialog: { template: "<div />" },
65
61
  },
66
62
  },
@@ -0,0 +1,128 @@
1
+ <script setup lang="ts">
2
+ import {
3
+ computed,
4
+ inject,
5
+ onBeforeUnmount,
6
+ onMounted,
7
+ shallowRef,
8
+ useTemplateRef,
9
+ } from "vue";
10
+ import { routerKey } from "vue-router";
11
+ import type { PermissionDefinition } from "@southwind-ai/shared";
12
+ import FormDialog from "@/components/common/FormDialog.vue";
13
+ import UnsavedChangesDialog from "@/components/common/UnsavedChangesDialog.vue";
14
+ import { useUnsavedChangesConfirmation } from "@/composables/useUnsavedChangesConfirmation";
15
+ import { createSystemResource, type SystemRecord } from "@/services/system-api";
16
+ import RolePolicyForm from "../role-policy/RolePolicyForm.vue";
17
+
18
+ const open = defineModel<boolean>("open", { default: false });
19
+ const props = defineProps<{
20
+ permissions: PermissionDefinition[];
21
+ managedNavigation?: boolean;
22
+ }>();
23
+ const emit = defineEmits<{ created: [role: SystemRecord] }>();
24
+ interface RoleFormHandle {
25
+ dirty: boolean;
26
+ submit(): void;
27
+ markSaved(): void;
28
+ }
29
+ const roleForm = useTemplateRef<RoleFormHandle>("roleForm");
30
+ const defaults = {
31
+ dataScope: "self",
32
+ permissionKeys: [],
33
+ departmentIds: [],
34
+ menuKeys: [],
35
+ status: "active",
36
+ };
37
+ const saving = shallowRef(false);
38
+ const error = shallowRef("");
39
+ const dirty = computed(() => open.value && Boolean(roleForm.value?.dirty));
40
+ const confirmation = useUnsavedChangesConfirmation();
41
+ const router = inject(routerKey, null);
42
+ let removeRouteGuard: (() => void) | undefined;
43
+ let submittedSave: Promise<boolean> | undefined;
44
+
45
+ async function create(payload: Record<string, unknown>): Promise<boolean> {
46
+ if (saving.value) return false;
47
+ if (payload.status !== "active") {
48
+ error.value = "新建并分配给账号的角色须为启用状态。";
49
+ return false;
50
+ }
51
+ saving.value = true;
52
+ error.value = "";
53
+ try {
54
+ const role = await createSystemResource("roles", payload);
55
+ roleForm.value?.markSaved();
56
+ emit("created", role);
57
+ open.value = false;
58
+ return true;
59
+ } catch (cause) {
60
+ error.value = cause instanceof Error ? cause.message : String(cause);
61
+ return false;
62
+ } finally {
63
+ saving.value = false;
64
+ }
65
+ }
66
+ function handleSubmit(payload: Record<string, unknown>) {
67
+ submittedSave = create(payload);
68
+ }
69
+ async function saveForm(): Promise<boolean> {
70
+ submittedSave = undefined;
71
+ roleForm.value?.submit();
72
+ return submittedSave ? await submittedSave : false;
73
+ }
74
+ function beforeClose() {
75
+ if (saving.value) return false;
76
+ return dirty.value ? confirmation.request(saveForm) : true;
77
+ }
78
+ function preventDirtyUnload(event: BeforeUnloadEvent) {
79
+ if (!dirty.value && !saving.value) return;
80
+ event.preventDefault();
81
+ event.returnValue = "";
82
+ }
83
+ onMounted(() => {
84
+ window.addEventListener("beforeunload", preventDirtyUnload);
85
+ if (!props.managedNavigation)
86
+ removeRouteGuard = router?.beforeEach(() =>
87
+ open.value ? beforeClose() : true,
88
+ );
89
+ });
90
+ onBeforeUnmount(() => {
91
+ window.removeEventListener("beforeunload", preventDirtyUnload);
92
+ removeRouteGuard?.();
93
+ });
94
+ defineExpose({ beforeClose });
95
+ </script>
96
+
97
+ <template>
98
+ <FormDialog
99
+ v-model:open="open"
100
+ title="新建角色并选择权限"
101
+ description="选择角色功能权限和数据范围,创建后会自动加入当前账号,账号草稿保持不变。"
102
+ submit-text="创建并选中"
103
+ content-class="sm:max-w-4xl"
104
+ :loading="saving"
105
+ :before-close="beforeClose"
106
+ @submit="saveForm"
107
+ >
108
+ <p v-if="error" class="text-sm text-destructive" role="alert">
109
+ {{ error }}
110
+ </p>
111
+ <fieldset class="contents" :disabled="saving">
112
+ <RolePolicyForm
113
+ v-if="open"
114
+ ref="roleForm"
115
+ :defaults="defaults"
116
+ :permissions="permissions"
117
+ @submit="handleSubmit"
118
+ />
119
+ </fieldset>
120
+ </FormDialog>
121
+ <UnsavedChangesDialog
122
+ :open="confirmation.open.value"
123
+ :saving="confirmation.saving.value"
124
+ description="角色信息尚未保存。取消会保留当前表单;保存成功后才会继续关闭或离开。"
125
+ @cancel="confirmation.cancel"
126
+ @save="confirmation.save"
127
+ />
128
+ </template>
@@ -0,0 +1,76 @@
1
+ import { defineComponent } from "vue";
2
+ import { flushPromises, mount } from "@vue/test-utils";
3
+ import { afterEach, describe, expect, test, vi } from "vitest";
4
+ import CreateRoleDialog from "./CreateRoleDialog.vue";
5
+ import FormDialog from "@/components/common/FormDialog.vue";
6
+
7
+ const api = vi.hoisted(() => ({
8
+ createSystemResource: vi.fn(),
9
+ listSystemResource: vi.fn(),
10
+ }));
11
+ vi.mock("@/services/system-api", () => api);
12
+ afterEach(() => vi.clearAllMocks());
13
+
14
+ describe("账号内新建授权角色", () => {
15
+ test("在独立对话框选择新功能权限,创建后回传完整角色且保存前保持脏状态", async () => {
16
+ const role = {
17
+ id: "role_report",
18
+ code: "report",
19
+ name: "报表审核员",
20
+ status: "active",
21
+ permissionKeys: ["business.report.export"],
22
+ };
23
+ api.createSystemResource.mockResolvedValue(role);
24
+ const wrapper = mount(CreateRoleDialog, {
25
+ props: {
26
+ open: true,
27
+ permissions: [
28
+ {
29
+ key: "business.report.export",
30
+ label: "导出报表",
31
+ module: "business",
32
+ moduleLabel: "业务",
33
+ resource: "report",
34
+ resourceLabel: "报表",
35
+ action: "export",
36
+ },
37
+ ],
38
+ },
39
+ global: {
40
+ stubs: {
41
+ FormDialog: defineComponent({
42
+ props: { open: Boolean, loading: Boolean, beforeClose: Function },
43
+ emits: ["submit", "update:open"],
44
+ template:
45
+ '<div><slot /><button data-testid="create-role" :disabled="loading" @click="$emit(\'submit\')">创建</button></div>',
46
+ }),
47
+ UnsavedChangesDialog: { template: "<div />" },
48
+ },
49
+ },
50
+ });
51
+ const inputs = wrapper.findAll("input");
52
+ await inputs[0]!.setValue("report");
53
+ await inputs[1]!.setValue("报表审核员");
54
+ await wrapper
55
+ .get('[data-testid="permission-business.report.export"]')
56
+ .trigger("click");
57
+ const unload = new Event("beforeunload", { cancelable: true });
58
+ window.dispatchEvent(unload);
59
+ expect(unload.defaultPrevented).toBe(true);
60
+ await wrapper.get('[data-testid="create-role"]').trigger("click");
61
+ await flushPromises();
62
+ expect(api.createSystemResource).toHaveBeenCalledWith("roles", {
63
+ code: "report",
64
+ name: "报表审核员",
65
+ status: "active",
66
+ dataScope: "self",
67
+ permissionKeys: ["business.report.export"],
68
+ departmentIds: [],
69
+ menuKeys: [],
70
+ });
71
+ expect(wrapper.emitted("created")?.at(-1)).toEqual([role]);
72
+ expect(wrapper.emitted("update:open")?.at(-1)).toEqual([false]);
73
+ expect(wrapper.getComponent(FormDialog).props("loading")).toBe(false);
74
+ wrapper.unmount();
75
+ });
76
+ });
@@ -1,5 +1,7 @@
1
1
  <script setup lang="ts">
2
- import { computed } from "vue";
2
+ import { computed, shallowRef, watch } from "vue";
3
+ import { Input } from "@/components/ui/input";
4
+ import { Button } from "@/components/ui/button";
3
5
  import type { PermissionDefinition } from "@southwind-ai/shared";
4
6
  import {
5
7
  groupPermissions,
@@ -11,13 +13,37 @@ const props = defineProps<{
11
13
  permissions: PermissionDefinition[];
12
14
  }>();
13
15
 
16
+ const keyword = shallowRef("");
17
+ const page = shallowRef(1);
18
+ const filteredKeys = computed(() =>
19
+ props.permissionKeys.filter((key) => {
20
+ const definition = props.permissions.find(
21
+ (permission) => permission.key === key,
22
+ );
23
+ return `${key} ${definition?.label || ""} ${definition?.resourceLabel || ""} ${definition?.moduleLabel || ""}`
24
+ .toLocaleLowerCase()
25
+ .includes(keyword.value.trim().toLocaleLowerCase());
26
+ }),
27
+ );
28
+ const pageCount = computed(() =>
29
+ Math.max(1, Math.ceil(filteredKeys.value.length / 10)),
30
+ );
31
+ const pageKeys = computed(() =>
32
+ filteredKeys.value.slice((page.value - 1) * 10, page.value * 10),
33
+ );
34
+ watch(keyword, () => {
35
+ page.value = 1;
36
+ });
37
+ watch(pageCount, (count) => {
38
+ page.value = Math.min(page.value, count);
39
+ });
14
40
  const knownPermissions = computed(() => {
15
- const selected = new Set(props.permissionKeys);
41
+ const selected = new Set(pageKeys.value);
16
42
  return props.permissions.filter(({ key }) => selected.has(key));
17
43
  });
18
44
  const groups = computed(() => groupPermissions(knownPermissions.value));
19
45
  const unknownKeys = computed(() =>
20
- unknownPermissionKeys(props.permissionKeys, props.permissions),
46
+ unknownPermissionKeys(pageKeys.value, props.permissions),
21
47
  );
22
48
  </script>
23
49
 
@@ -35,6 +61,12 @@ const unknownKeys = computed(() =>
35
61
  查看 {{ permissionKeys.length }} 项功能权限
36
62
  </summary>
37
63
  <div class="mt-3 grid gap-3">
64
+ <Input
65
+ v-model="keyword"
66
+ @keydown.enter.prevent
67
+ aria-label="搜索角色权限摘要"
68
+ placeholder="搜索功能权限"
69
+ />
38
70
  <section v-for="group in groups" :key="group.key" class="grid gap-2">
39
71
  <p class="font-medium text-foreground">{{ group.label }}</p>
40
72
  <div
@@ -54,6 +86,32 @@ const unknownKeys = computed(() =>
54
86
  </p>
55
87
  <code class="break-all leading-5">{{ unknownKeys.join("、") }}</code>
56
88
  </section>
89
+ <div
90
+ class="flex flex-wrap items-center justify-between gap-2 text-muted-foreground"
91
+ >
92
+ <span
93
+ >共 {{ filteredKeys.length }} 条,每页 10 条,第 {{ page }} /
94
+ {{ pageCount }} 页</span
95
+ >
96
+ <div class="flex gap-2">
97
+ <Button
98
+ type="button"
99
+ size="sm"
100
+ variant="outline"
101
+ :disabled="page <= 1"
102
+ @click="page--"
103
+ >上一页</Button
104
+ >
105
+ <Button
106
+ type="button"
107
+ size="sm"
108
+ variant="outline"
109
+ :disabled="page >= pageCount"
110
+ @click="page++"
111
+ >下一页</Button
112
+ >
113
+ </div>
114
+ </div>
57
115
  </div>
58
116
  </details>
59
117
  </template>