create-windy 0.6.21 → 0.8.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 (129) hide show
  1. package/README.md +6 -1
  2. package/dist/cli.js +19 -7
  3. package/package.json +1 -1
  4. package/template/.windy-template.json +2 -2
  5. package/template/AGENTS.md +1 -1
  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/docker-compose.yml +3 -3
  92. package/template/docs/architecture/foundation-infrastructure.md +12 -25
  93. package/template/docs/development/crud-generator.md +1 -1
  94. package/template/docs/operations/legacy-data-migration.md +73 -0
  95. package/template/docs/platform/bulk-data-jobs.md +1 -1
  96. package/template/docs/platform/module-configuration.md +3 -3
  97. package/template/docs/platform/organization-membership.md +38 -7
  98. package/template/docs/platform/platform-authentication.md +1 -1
  99. package/template/docs/platform/scheduled-task-executor.md +1 -1
  100. package/template/package.json +2 -1
  101. package/template/packages/config/index.test.ts +1 -5
  102. package/template/packages/config/package.json +2 -2
  103. package/template/packages/config/src/platform-boundary.test.ts +39 -0
  104. package/template/packages/config/src/platform.ts +0 -28
  105. package/template/packages/database/drizzle/0063_license_project_lifecycle.sql +28 -0
  106. package/template/packages/database/drizzle/meta/0063_snapshot.json +15053 -0
  107. package/template/packages/database/drizzle/meta/_journal.json +7 -0
  108. package/template/packages/database/package.json +2 -2
  109. package/template/packages/database/src/agent-runtime-schema.test.ts +5 -1
  110. package/template/packages/database/src/schema/index.ts +1 -0
  111. package/template/packages/database/src/schema/license-issuance-requests.ts +59 -0
  112. package/template/packages/database/src/schema/license-v1.ts +5 -0
  113. package/template/packages/jobs/package.json +2 -2
  114. package/template/packages/modules/package.json +1 -1
  115. package/template/packages/modules/src/system-api-permissions.ts +14 -0
  116. package/template/packages/modules/src/system-features.ts +0 -11
  117. package/template/packages/modules/src/system-module-catalog.test.ts +4 -0
  118. package/template/packages/modules/src/system-module-catalog.ts +0 -1
  119. package/template/packages/server-sdk/index.ts +1 -49
  120. package/template/packages/server-sdk/package.json +3 -2
  121. package/template/packages/server-sdk/src/ai-runtime-readiness-exports.ts +49 -0
  122. package/template/packages/shared/index.ts +2 -1
  123. package/template/packages/shared/package.json +1 -1
  124. package/template/packages/shared/src/license-console-capabilities.ts +163 -0
  125. package/template/packages/shared/src/license-console.ts +94 -0
  126. package/template/packages/shared/src/license.ts +5 -0
  127. package/template/scripts/lib/postgres-regression.ts +1 -1
  128. package/template/docs/adapters/ruoyi-vue-compatibility.md +0 -93
  129. package/template/packages/shared/src/ruoyi.ts +0 -98
@@ -17,15 +17,21 @@ const access = useAccessSnapshot();
17
17
  const canToggleFeatures = computed(() =>
18
18
  access.snapshot.value ? canToggleFeatureFlags(access.snapshot.value) : false,
19
19
  );
20
+ function canAccess(featureKey: string, permissionKey: string): boolean {
21
+ const snapshot = access.snapshot.value;
22
+ return Boolean(
23
+ snapshot &&
24
+ evaluateWebAccess(snapshot, { featureKey, permissionKey }).allowed,
25
+ );
26
+ }
20
27
  const canManageMenus = computed(() =>
21
- Boolean(
22
- access.snapshot.value?.actor.permissionKeys.includes("system.menu.manage"),
23
- ),
28
+ canAccess("system.menu", "system.menu.manage"),
24
29
  );
25
30
  const departmentCreateMode = computed<"create" | "manage" | undefined>(() => {
26
- const permissions = access.snapshot.value?.actor.permissionKeys || [];
27
- if (permissions.includes("system.department.create")) return "create";
28
- if (permissions.includes("system.department.manage")) return "manage";
31
+ if (canAccess("system.department", "system.department.create"))
32
+ return "create";
33
+ if (canAccess("system.department", "system.department.manage"))
34
+ return "manage";
29
35
  return undefined;
30
36
  });
31
37
  // @windy-module system.bulk-data begin
@@ -46,17 +52,25 @@ const optionalResourceProps = computed(() => ({
46
52
  }));
47
53
  const config = computed(() => {
48
54
  const base = systemResourceConfigs[props.resourceKey];
49
- const permissionKeys = access.snapshot.value?.actor.permissionKeys || [];
50
- return props.resourceKey === "features" && !canToggleFeatures.value
55
+ if (props.resourceKey === "features" && !canToggleFeatures.value)
56
+ return { ...base, readonly: true };
57
+ const capabilities: Partial<Record<SystemResourceKey, string>> = {
58
+ users: "system.user",
59
+ roles: "system.role",
60
+ departments: "system.department",
61
+ };
62
+ const capability = capabilities[props.resourceKey];
63
+ return capability && !canAccess(capability, `${capability}.manage`)
51
64
  ? { ...base, readonly: true }
52
- : props.resourceKey === "users" &&
53
- !permissionKeys.includes("system.user.manage")
54
- ? { ...base, readonly: true }
55
- : base;
65
+ : base;
56
66
  });
57
67
 
58
68
  function handleMutation() {
59
- if (props.resourceKey === "features" || props.resourceKey === "menus") {
69
+ if (
70
+ ["features", "menus", "users", "roles", "departments"].includes(
71
+ props.resourceKey,
72
+ )
73
+ ) {
60
74
  void access.refresh();
61
75
  }
62
76
  }
@@ -4,6 +4,7 @@ import { shallowRef } from "vue";
4
4
  import type { WebAccessSnapshot } from "@/layout/navigation";
5
5
  import MenuManagementPage from "./components/menu-management/MenuManagementPage.vue";
6
6
  import SystemResourceRoutePage from "./SystemResourceRoutePage.vue";
7
+ import SystemResourcePage from "./components/SystemResourcePage.vue";
7
8
 
8
9
  const api = vi.hoisted(() => ({ list: vi.fn(), refreshAccess: vi.fn() }));
9
10
 
@@ -97,4 +98,71 @@ describe("SystemResourceRoutePage", () => {
97
98
  expect(api.refreshAccess).toHaveBeenCalledOnce();
98
99
  wrapper.unmount();
99
100
  });
101
+ test("部门管理同时遵守权限和Feature决定编辑入口", async () => {
102
+ const previous = snapshot.value;
103
+ snapshot.value = {
104
+ ...previous,
105
+ actor: {
106
+ ...previous.actor,
107
+ permissionKeys: [
108
+ "system.department.read",
109
+ "system.department.manage",
110
+ "system.department.create",
111
+ ],
112
+ },
113
+ features: [
114
+ {
115
+ key: "system.department",
116
+ enabled: false,
117
+ visible: "visible",
118
+ allowed: false,
119
+ reason: "feature-disabled",
120
+ },
121
+ ],
122
+ };
123
+ const wrapper = mount(SystemResourceRoutePage, {
124
+ props: { resourceKey: "departments" },
125
+ global: { stubs: { SystemResourcePage: true } },
126
+ });
127
+ expect(
128
+ wrapper.getComponent(SystemResourcePage).props("config").readonly,
129
+ ).toBe(true);
130
+ expect(
131
+ wrapper.getComponent(SystemResourcePage).props("departmentCreateMode"),
132
+ ).toBeUndefined();
133
+ snapshot.value = {
134
+ ...snapshot.value,
135
+ features: [
136
+ {
137
+ key: "system.department",
138
+ enabled: true,
139
+ visible: "visible",
140
+ allowed: true,
141
+ reason: "allowed",
142
+ },
143
+ ],
144
+ };
145
+ await flushPromises();
146
+ expect(
147
+ wrapper.getComponent(SystemResourcePage).props("config").readonly,
148
+ ).not.toBe(true);
149
+ expect(
150
+ wrapper.getComponent(SystemResourcePage).props("departmentCreateMode"),
151
+ ).toBe("create");
152
+ wrapper.unmount();
153
+ snapshot.value = previous;
154
+ });
155
+
156
+ test.each(["users", "roles", "departments"] as const)(
157
+ "%s 保存后刷新当前访问快照",
158
+ (resourceKey) => {
159
+ const wrapper = mount(SystemResourceRoutePage, {
160
+ props: { resourceKey },
161
+ global: { stubs: { SystemResourcePage: true } },
162
+ });
163
+ wrapper.getComponent(SystemResourcePage).vm.$emit("mutated");
164
+ expect(api.refreshAccess).toHaveBeenCalledOnce();
165
+ wrapper.unmount();
166
+ },
167
+ );
100
168
  });
@@ -0,0 +1,131 @@
1
+ <script setup lang="ts">
2
+ import { computed, shallowRef, useId, watch } from "vue";
3
+ import { Input } from "@/components/ui/input";
4
+ import { Button } from "@/components/ui/button";
5
+
6
+ const props = defineProps<{
7
+ items: { id: string; label: string; description?: string }[];
8
+ modelValue: string[];
9
+ label: string;
10
+ multiple?: boolean;
11
+ disabled?: boolean;
12
+ clearable?: boolean;
13
+ }>();
14
+ const emit = defineEmits<{ "update:modelValue": [value: string[]] }>();
15
+ const selectionName = useId();
16
+ const keyword = shallowRef("");
17
+ const page = shallowRef(1);
18
+ const filtered = computed(() => {
19
+ const query = keyword.value.trim().toLocaleLowerCase();
20
+ return props.items.filter((item) =>
21
+ `${item.label} ${item.description || ""}`
22
+ .toLocaleLowerCase()
23
+ .includes(query),
24
+ );
25
+ });
26
+ const pageCount = computed(() =>
27
+ Math.max(1, Math.ceil(filtered.value.length / 10)),
28
+ );
29
+ const visible = computed(() =>
30
+ filtered.value.slice((page.value - 1) * 10, page.value * 10),
31
+ );
32
+ const selectedNames = computed(() =>
33
+ props.modelValue
34
+ .map(
35
+ (id) =>
36
+ props.items.find((item) => item.id === id)?.label ||
37
+ `当前选项(${id})`,
38
+ )
39
+ .join("、"),
40
+ );
41
+ watch(keyword, () => {
42
+ page.value = 1;
43
+ });
44
+ watch(pageCount, (count) => {
45
+ page.value = Math.min(page.value, count);
46
+ });
47
+ function select(id: string, checked: boolean) {
48
+ if (props.disabled) return;
49
+ const selected = new Set(props.multiple ? props.modelValue : []);
50
+ if (checked) selected.add(id);
51
+ else selected.delete(id);
52
+ emit("update:modelValue", [...selected]);
53
+ }
54
+ </script>
55
+
56
+ <template>
57
+ <section class="grid gap-3 rounded-lg border p-3" :aria-label="label">
58
+ <Input
59
+ v-model="keyword"
60
+ @keydown.enter.prevent
61
+ :aria-label="`搜索${label}`"
62
+ :placeholder="`搜索${label}`"
63
+ :disabled="disabled"
64
+ />
65
+ <Button
66
+ v-if="clearable && modelValue.length"
67
+ type="button"
68
+ size="sm"
69
+ variant="ghost"
70
+ :disabled="disabled"
71
+ @click="emit('update:modelValue', [])"
72
+ >清空选择</Button
73
+ >
74
+ <p class="text-xs text-muted-foreground">
75
+ 已选 {{ modelValue.length }} 项<span v-if="selectedNames"
76
+ >:{{ selectedNames }}</span
77
+ >
78
+ </p>
79
+ <div v-for="item in visible" :key="item.id" class="rounded-md border p-3">
80
+ <label class="flex cursor-pointer items-start gap-3 text-sm">
81
+ <input
82
+ :type="multiple ? 'checkbox' : 'radio'"
83
+ :name="multiple ? undefined : selectionName"
84
+ :checked="modelValue.includes(item.id)"
85
+ :disabled="disabled"
86
+ :aria-label="item.label"
87
+ class="mt-1 accent-primary"
88
+ @change="select(item.id, ($event.target as HTMLInputElement).checked)"
89
+ />
90
+ <span class="grid gap-1"
91
+ ><span class="font-medium">{{ item.label }}</span
92
+ ><span
93
+ v-if="item.description"
94
+ class="break-all text-xs text-muted-foreground"
95
+ >{{ item.description }}</span
96
+ ></span
97
+ >
98
+ </label>
99
+ <slot name="detail" :id="item.id" />
100
+ </div>
101
+ <p v-if="!visible.length" class="text-sm text-muted-foreground">
102
+ 暂无匹配选项。
103
+ </p>
104
+ <div
105
+ class="flex flex-wrap items-center justify-between gap-2 text-xs text-muted-foreground"
106
+ >
107
+ <span
108
+ >共 {{ filtered.length }} 条,每页 10 条,第 {{ page }} /
109
+ {{ pageCount }} 页</span
110
+ >
111
+ <div class="flex gap-2">
112
+ <Button
113
+ type="button"
114
+ size="sm"
115
+ variant="outline"
116
+ :disabled="disabled || page <= 1"
117
+ @click="page--"
118
+ >上一页</Button
119
+ >
120
+ <Button
121
+ type="button"
122
+ size="sm"
123
+ variant="outline"
124
+ :disabled="disabled || page >= pageCount"
125
+ @click="page++"
126
+ >下一页</Button
127
+ >
128
+ </div>
129
+ </div>
130
+ </section>
131
+ </template>
@@ -0,0 +1,32 @@
1
+ import { mount } from "@vue/test-utils";
2
+ import { describe, expect, test } from "vitest";
3
+ import PagedSelectionList from "./PagedSelectionList.vue";
4
+
5
+ describe("分页选择", () => {
6
+ test("每页十项,搜索与翻页均保留已选项", async () => {
7
+ const items = Array.from({ length: 21 }, (_, index) => ({
8
+ id: `item-${index}`,
9
+ label: `部门 ${index}`,
10
+ }));
11
+ const wrapper = mount(PagedSelectionList, {
12
+ props: { items, modelValue: ["item-0"], label: "部门", multiple: true },
13
+ });
14
+ expect(wrapper.findAll('input[type="checkbox"]')).toHaveLength(10);
15
+ await wrapper
16
+ .findAll("button")
17
+ .find((button) => button.text() === "下一页")!
18
+ .trigger("click");
19
+ await wrapper.get('input[aria-label="部门 10"]').setValue(true);
20
+ expect(wrapper.emitted("update:modelValue")?.at(-1)).toEqual([
21
+ ["item-0", "item-10"],
22
+ ]);
23
+ await wrapper.setProps({ modelValue: ["item-0", "item-10"] });
24
+ await wrapper.get('input[aria-label="搜索部门"]').setValue("部门 20");
25
+ expect(wrapper.findAll('input[type="checkbox"]')).toHaveLength(1);
26
+ expect(wrapper.text()).toContain("已选 2 项:部门 0、部门 10");
27
+ await wrapper.get('input[aria-label="部门 20"]').setValue(true);
28
+ expect(wrapper.emitted("update:modelValue")?.at(-1)).toEqual([
29
+ ["item-0", "item-10", "item-20"],
30
+ ]);
31
+ });
32
+ });
@@ -0,0 +1,67 @@
1
+ <script setup lang="ts">
2
+ import { computed } from "vue";
3
+ import {
4
+ Dialog,
5
+ DialogContent,
6
+ DialogDescription,
7
+ DialogHeader,
8
+ DialogTitle,
9
+ } from "@/components/ui/dialog";
10
+ import type { SystemRecord } from "@/services/system-api";
11
+ import type { SystemResourceConfig } from "../resource-config";
12
+ const open = defineModel<boolean>("open", { default: false });
13
+ const props = defineProps<{
14
+ item?: SystemRecord;
15
+ config: SystemResourceConfig;
16
+ }>();
17
+ const fields = computed(() => {
18
+ const fields = [...props.config.columns];
19
+ const extras =
20
+ props.config.key === "users"
21
+ ? [{ key: "email", label: "邮箱" }]
22
+ : props.config.key === "departments"
23
+ ? [
24
+ { key: "departmentPath", label: "部门路径" },
25
+ { key: "childDepartmentCount", label: "直属下级部门" },
26
+ { key: "userCount", label: "直属成员" },
27
+ ]
28
+ : [
29
+ { key: "permissionKeys", label: "权限点" },
30
+ { key: "menuKeys", label: "菜单" },
31
+ ];
32
+ return [...fields, ...extras];
33
+ });
34
+ function display(key: string): string {
35
+ const value = props.item?.[key];
36
+ if (key === "status")
37
+ return value === "active" ? "启用" : value === "disabled" ? "停用" : "—";
38
+ if (value === null || value === undefined || value === "") return "—";
39
+ if (Array.isArray(value)) return value.length ? value.join("、") : "—";
40
+ if (typeof value === "object") {
41
+ const field = value as Record<string, unknown>;
42
+ return String(field.display || field.value || "—");
43
+ }
44
+ return String(value);
45
+ }
46
+ </script>
47
+ <template>
48
+ <Dialog v-model:open="open">
49
+ <DialogContent class="sm:max-w-2xl">
50
+ <DialogHeader
51
+ ><DialogTitle
52
+ >{{
53
+ item?.displayName || item?.name || config.title
54
+ }}详情</DialogTitle
55
+ ><DialogDescription
56
+ >查看当前记录及组织归属信息。</DialogDescription
57
+ ></DialogHeader
58
+ >
59
+ <dl class="grid max-h-[65vh] gap-4 overflow-y-auto sm:grid-cols-2">
60
+ <div v-for="field in fields" :key="field.key" class="space-y-1">
61
+ <dt class="text-sm text-muted-foreground">{{ field.label }}</dt>
62
+ <dd class="break-words text-sm">{{ display(field.key) }}</dd>
63
+ </div>
64
+ </dl>
65
+ </DialogContent>
66
+ </Dialog>
67
+ </template>
@@ -1,5 +1,14 @@
1
1
  <script setup lang="ts">
2
- import { computed, useTemplateRef } from "vue";
2
+ import {
3
+ computed,
4
+ onBeforeUnmount,
5
+ onMounted,
6
+ shallowRef,
7
+ useTemplateRef,
8
+ } from "vue";
9
+ import { onBeforeRouteLeave, onBeforeRouteUpdate } from "vue-router";
10
+ import UnsavedChangesDialog from "@/components/common/UnsavedChangesDialog.vue";
11
+ import { useUnsavedChangesConfirmation } from "@/composables/useUnsavedChangesConfirmation";
3
12
  import type { PermissionDefinition } from "@southwind-ai/shared";
4
13
  import FormDialog from "@/components/common/FormDialog.vue";
5
14
  import type { SystemRecord } from "@/services/system-api";
@@ -17,16 +26,71 @@ const props = defineProps<{
17
26
  permissions?: PermissionDefinition[];
18
27
  departmentCreateMode?: "create" | "manage";
19
28
  loading?: boolean;
20
- }>();
21
- const emit = defineEmits<{
22
- submit: [payload: Record<string, unknown>];
29
+ error?: string;
30
+ save: (payload: Record<string, unknown>) => Promise<boolean>;
23
31
  }>();
24
32
 
25
33
  interface ResourceFormHandle {
26
34
  submit(): void;
35
+ dirty?: boolean;
36
+ markSaved?(): void;
37
+ beforeClose?(): boolean | Promise<boolean>;
27
38
  }
28
39
 
29
40
  const resourceForm = useTemplateRef<ResourceFormHandle>("resourceForm");
41
+ const confirmation = useUnsavedChangesConfirmation();
42
+ const saving = shallowRef(false);
43
+ let submittedSave: Promise<boolean> | undefined;
44
+ const dirty = computed(() => open.value && Boolean(resourceForm.value?.dirty));
45
+ async function persist(payload: Record<string, unknown>): Promise<boolean> {
46
+ if (saving.value || props.loading) return false;
47
+ saving.value = true;
48
+ try {
49
+ const saved = await props.save(payload);
50
+ if (saved) {
51
+ resourceForm.value?.markSaved?.();
52
+ open.value = false;
53
+ }
54
+ return saved;
55
+ } finally {
56
+ saving.value = false;
57
+ }
58
+ }
59
+ function handleSubmit(payload: Record<string, unknown>) {
60
+ submittedSave = persist(payload);
61
+ }
62
+ async function saveForm(): Promise<boolean> {
63
+ if (saving.value || props.loading) return false;
64
+ if (
65
+ resourceForm.value?.beforeClose &&
66
+ !(await resourceForm.value.beforeClose())
67
+ )
68
+ return false;
69
+ submittedSave = undefined;
70
+ resourceForm.value?.submit();
71
+ return submittedSave ? await submittedSave : false;
72
+ }
73
+ async function beforeClose(): Promise<boolean> {
74
+ if (saving.value || props.loading) return false;
75
+ if (!open.value) return true;
76
+ if (
77
+ resourceForm.value?.beforeClose &&
78
+ !(await resourceForm.value.beforeClose())
79
+ )
80
+ return false;
81
+ return !dirty.value || (await confirmation.request(saveForm));
82
+ }
83
+ function preventDirtyUnload(event: BeforeUnloadEvent) {
84
+ if (!dirty.value && !saving.value && !props.loading) return;
85
+ event.preventDefault();
86
+ event.returnValue = "";
87
+ }
88
+ onBeforeRouteLeave(beforeClose);
89
+ onBeforeRouteUpdate(beforeClose);
90
+ onMounted(() => window.addEventListener("beforeunload", preventDirtyUnload));
91
+ onBeforeUnmount(() =>
92
+ window.removeEventListener("beforeunload", preventDirtyUnload),
93
+ );
30
94
  const title = computed(() => (props.item ? "编辑记录" : "新增记录"));
31
95
  </script>
32
96
 
@@ -35,41 +99,53 @@ const title = computed(() => (props.item ? "编辑记录" : "新增记录"));
35
99
  v-model:open="open"
36
100
  :title="title"
37
101
  :description="config.description"
38
- :loading="loading"
102
+ :loading="loading || saving"
103
+ :before-close="beforeClose"
39
104
  :content-class="config.key === 'roles' ? 'sm:max-w-4xl' : undefined"
40
- @submit="resourceForm?.submit()"
105
+ @submit="saveForm"
41
106
  >
42
- <RolePolicyForm
43
- v-if="config.key === 'roles'"
44
- ref="resourceForm"
45
- :item="item"
46
- :defaults="defaults"
47
- :permissions="permissions || []"
48
- @submit="emit('submit', $event)"
49
- />
50
- <UserAccountForm
51
- v-else-if="config.key === 'users'"
52
- ref="resourceForm"
53
- :item="item"
54
- :defaults="defaults"
55
- :permissions="permissions || []"
56
- :department-create-mode="departmentCreateMode"
57
- @submit="emit('submit', $event)"
58
- />
59
- <DepartmentOrganizationForm
60
- v-else-if="config.key === 'departments'"
61
- ref="resourceForm"
62
- :item="item"
63
- :defaults="defaults"
64
- @submit="emit('submit', $event)"
65
- />
66
- <SystemResourceForm
67
- v-else
68
- ref="resourceForm"
69
- :fields="config.fields"
70
- :item="item"
71
- :defaults="defaults"
72
- @submit="emit('submit', $event)"
73
- />
107
+ <p v-if="error" class="text-sm text-destructive" role="alert">
108
+ {{ error }}
109
+ </p>
110
+ <fieldset class="contents" :disabled="loading || saving">
111
+ <RolePolicyForm
112
+ v-if="config.key === 'roles'"
113
+ ref="resourceForm"
114
+ :item="item"
115
+ :defaults="defaults"
116
+ :permissions="permissions || []"
117
+ @submit="handleSubmit"
118
+ />
119
+ <UserAccountForm
120
+ v-else-if="config.key === 'users'"
121
+ ref="resourceForm"
122
+ :item="item"
123
+ :defaults="defaults"
124
+ :permissions="permissions || []"
125
+ :department-create-mode="departmentCreateMode"
126
+ @submit="handleSubmit"
127
+ />
128
+ <DepartmentOrganizationForm
129
+ v-else-if="config.key === 'departments'"
130
+ ref="resourceForm"
131
+ :item="item"
132
+ :defaults="defaults"
133
+ @submit="handleSubmit"
134
+ />
135
+ <SystemResourceForm
136
+ v-else
137
+ ref="resourceForm"
138
+ :fields="config.fields"
139
+ :item="item"
140
+ :defaults="defaults"
141
+ @submit="handleSubmit"
142
+ />
143
+ </fieldset>
74
144
  </FormDialog>
145
+ <UnsavedChangesDialog
146
+ :open="confirmation.open.value"
147
+ :saving="confirmation.saving.value"
148
+ @cancel="confirmation.cancel"
149
+ @save="confirmation.save"
150
+ />
75
151
  </template>