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.
- package/dist/cli.js +11 -4
- package/package.json +1 -1
- package/template/.windy-template.json +2 -2
- package/template/apps/server/src/audit/policy.ts +6 -1
- package/template/apps/server/src/auth/bootstrap-admin.ts +10 -1
- package/template/apps/server/src/auth/composition.ts +5 -0
- package/template/apps/server/src/auth/credential-authorization.test.ts +257 -0
- package/template/apps/server/src/auth/credential-routes.test.ts +1 -0
- package/template/apps/server/src/auth/credential-service.ts +47 -12
- package/template/apps/server/src/auth/drizzle-repository.ts +13 -5
- package/template/apps/server/src/auth/drizzle-session-operations.ts +1 -0
- package/template/apps/server/src/auth/repository.ts +9 -6
- package/template/apps/server/src/auth/routes.test-support.ts +1 -0
- package/template/apps/server/src/auth/runtime.ts +4 -0
- package/template/apps/server/src/auth/service-options.ts +2 -0
- package/template/apps/server/src/auth/service.ts +2 -0
- package/template/apps/server/src/auth/user-credential-access.ts +37 -0
- package/template/apps/server/src/foundation-activation.test.ts +24 -1
- package/template/apps/server/src/foundation.ts +0 -2
- package/template/apps/server/src/index.ts +1 -0
- package/template/apps/server/src/runtime-development.ts +2 -0
- package/template/apps/server/src/runtime.test.ts +13 -4
- package/template/apps/server/src/system/department-organization.ts +58 -59
- package/template/apps/server/src/system/department-read-routes.ts +54 -0
- package/template/apps/server/src/system/department-routes.test.ts +27 -19
- package/template/apps/server/src/system/department-routes.ts +95 -33
- package/template/apps/server/src/system/department-transition-safety.test.ts +148 -0
- package/template/apps/server/src/system/department-validation.ts +79 -0
- package/template/apps/server/src/system/drizzle-repository.ts +14 -0
- package/template/apps/server/src/system/drizzle-system-configs.ts +2 -0
- package/template/apps/server/src/system/identity-read-model.ts +105 -0
- package/template/apps/server/src/system/identity-role-assignment.test.ts +199 -0
- package/template/apps/server/src/system/identity-role-assignment.ts +148 -0
- package/template/apps/server/src/system/identity-route-config.ts +29 -7
- package/template/apps/server/src/system/identity-write-security.test.ts +127 -0
- package/template/apps/server/src/system/identity-write-validation.ts +170 -0
- package/template/apps/server/src/system/organization-read-security.test.ts +221 -0
- package/template/apps/server/src/system/redis-health.test.ts +1 -1
- package/template/apps/server/src/system/repository.ts +29 -2
- package/template/apps/server/src/system/resource-routes.ts +116 -72
- package/template/apps/server/src/system/route-audit.test.ts +117 -0
- package/template/apps/server/src/system/route-helpers.ts +14 -0
- package/template/apps/server/src/system/route-types.ts +11 -2
- package/template/apps/server/src/system/routes.test.ts +0 -68
- package/template/apps/server/src/system/routes.ts +29 -2
- package/template/apps/server/src/system/user-account-policy.test.ts +3 -1
- package/template/apps/server/src/system/user-account-policy.ts +26 -9
- package/template/apps/web/src/pages/system/SystemResourceRoutePage.vue +27 -13
- package/template/apps/web/src/pages/system/SystemResourceRoutePage.webtest.ts +68 -0
- package/template/apps/web/src/pages/system/components/PagedSelectionList.vue +131 -0
- package/template/apps/web/src/pages/system/components/PagedSelectionList.webtest.ts +32 -0
- package/template/apps/web/src/pages/system/components/SystemRecordDetailDialog.vue +67 -0
- package/template/apps/web/src/pages/system/components/SystemResourceEditor.vue +114 -38
- package/template/apps/web/src/pages/system/components/SystemResourceEditor.webtest.ts +180 -0
- package/template/apps/web/src/pages/system/components/SystemResourceFilters.vue +8 -0
- package/template/apps/web/src/pages/system/components/SystemResourceForm.vue +8 -2
- package/template/apps/web/src/pages/system/components/SystemResourcePage.department.webtest.ts +89 -0
- package/template/apps/web/src/pages/system/components/SystemResourcePage.vue +42 -46
- package/template/apps/web/src/pages/system/components/SystemResourcePage.webtest.ts +12 -55
- package/template/apps/web/src/pages/system/components/SystemResourceTable.vue +14 -2
- package/template/apps/web/src/pages/system/components/department/DepartmentOrganizationForm.vue +121 -85
- package/template/apps/web/src/pages/system/components/department/DepartmentOrganizationForm.webtest.ts +57 -0
- package/template/apps/web/src/pages/system/components/department/DepartmentOrganizationTable.vue +227 -0
- package/template/apps/web/src/pages/system/components/department/DepartmentOrganizationTable.webtest.ts +62 -0
- package/template/apps/web/src/pages/system/components/department/DepartmentTransitionDialog.vue +72 -52
- package/template/apps/web/src/pages/system/components/role-policy/RolePermissionTree.vue +150 -79
- package/template/apps/web/src/pages/system/components/role-policy/RolePolicyForm.vue +98 -8
- package/template/apps/web/src/pages/system/components/role-policy/RolePolicyForm.webtest.ts +108 -0
- package/template/apps/web/src/pages/system/components/selection-options.ts +71 -0
- package/template/apps/web/src/pages/system/components/selection-options.webtest.ts +49 -0
- package/template/apps/web/src/pages/system/components/user-account/CreateDepartmentDialog.vue +19 -27
- package/template/apps/web/src/pages/system/components/user-account/CreateDepartmentDialog.webtest.ts +2 -6
- package/template/apps/web/src/pages/system/components/user-account/CreateRoleDialog.vue +128 -0
- package/template/apps/web/src/pages/system/components/user-account/CreateRoleDialog.webtest.ts +76 -0
- package/template/apps/web/src/pages/system/components/user-account/RolePermissionSummary.vue +61 -3
- package/template/apps/web/src/pages/system/components/user-account/UserAccountForm.vue +144 -90
- package/template/apps/web/src/pages/system/components/user-account/UserAccountForm.webtest.ts +95 -0
- package/template/apps/web/src/pages/system/components/user-account/UserListFilters.vue +168 -0
- package/template/apps/web/src/pages/system/components/user-account/UserRoleSelector.vue +33 -57
- package/template/apps/web/src/pages/system/components/user-account/UserRoleSelector.webtest.ts +4 -4
- package/template/apps/web/src/pages/system/components/user-account/save-user-account.ts +4 -1
- package/template/apps/web/src/pages/system/components/user-account/user-account-draft.ts +81 -0
- package/template/apps/web/src/pages/system/composables/useDepartmentTransitions.ts +6 -10
- package/template/apps/web/src/pages/system/composables/useResourceEditor.ts +72 -0
- package/template/apps/web/src/pages/system/composables/useResourceEditor.webtest.ts +110 -0
- package/template/apps/web/src/pages/system/composables/useSystemResource.ts +27 -1
- package/template/apps/web/src/pages/system/resource-config.ts +2 -0
- package/template/apps/web/src/services/system-api.ts +25 -0
- package/template/docs/architecture/foundation-infrastructure.md +12 -25
- package/template/docs/development/crud-generator.md +1 -1
- package/template/docs/operations/legacy-data-migration.md +73 -0
- package/template/docs/platform/bulk-data-jobs.md +1 -1
- package/template/docs/platform/module-configuration.md +2 -2
- package/template/docs/platform/organization-membership.md +37 -6
- package/template/docs/platform/scheduled-task-executor.md +1 -1
- package/template/package.json +2 -1
- package/template/packages/config/index.test.ts +1 -5
- package/template/packages/config/package.json +2 -2
- package/template/packages/config/src/platform-boundary.test.ts +39 -0
- package/template/packages/config/src/platform.ts +0 -28
- package/template/packages/database/drizzle/0063_license_project_lifecycle.sql +28 -0
- package/template/packages/database/drizzle/meta/0063_snapshot.json +15053 -0
- package/template/packages/database/drizzle/meta/_journal.json +7 -0
- package/template/packages/database/package.json +2 -2
- package/template/packages/database/src/agent-runtime-schema.test.ts +5 -1
- package/template/packages/database/src/schema/index.ts +1 -0
- package/template/packages/database/src/schema/license-issuance-requests.ts +59 -0
- package/template/packages/database/src/schema/license-v1.ts +5 -0
- package/template/packages/jobs/package.json +2 -2
- package/template/packages/modules/package.json +1 -1
- package/template/packages/modules/src/system-api-permissions.ts +14 -0
- package/template/packages/modules/src/system-features.ts +0 -11
- package/template/packages/modules/src/system-module-catalog.test.ts +4 -0
- package/template/packages/modules/src/system-module-catalog.ts +0 -1
- package/template/packages/server-sdk/index.ts +1 -49
- package/template/packages/server-sdk/package.json +3 -2
- package/template/packages/server-sdk/src/ai-runtime-readiness-exports.ts +49 -0
- package/template/packages/shared/index.ts +2 -1
- package/template/packages/shared/package.json +1 -1
- package/template/packages/shared/src/license-console-capabilities.ts +163 -0
- package/template/packages/shared/src/license-console.ts +94 -0
- package/template/packages/shared/src/license.ts +5 -0
- package/template/docs/adapters/ruoyi-vue-compatibility.md +0 -93
- 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
|
|
5
|
-
import {
|
|
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
|
|
32
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
<
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
:
|
|
103
|
-
|
|
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
|
-
</
|
|
106
|
+
</PagedSelectionList>
|
|
131
107
|
</div>
|
|
132
108
|
</template>
|
package/template/apps/web/src/pages/system/components/user-account/UserRoleSelector.webtest.ts
CHANGED
|
@@ -27,7 +27,7 @@ describe("UserRoleSelector", () => {
|
|
|
27
27
|
],
|
|
28
28
|
total: 1,
|
|
29
29
|
page: 1,
|
|
30
|
-
pageSize:
|
|
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:
|
|
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('[
|
|
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 (
|
|
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
|
+
}
|