create-windy 0.6.20 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +16 -4
- package/package.json +1 -1
- package/template/.windy-template.json +2 -2
- package/template/apps/server/src/agent/remote-runtime.test.ts +36 -0
- package/template/apps/server/src/agent/remote-runtime.ts +15 -7
- 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,5 +1,6 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import { onMounted, shallowRef, watch } from "vue";
|
|
2
|
+
import { computed, onMounted, shallowRef, useTemplateRef, watch } from "vue";
|
|
3
|
+
import { RouterLink } from "vue-router";
|
|
3
4
|
import { PlusIcon } from "@lucide/vue";
|
|
4
5
|
import {
|
|
5
6
|
PASSWORD_POLICY,
|
|
@@ -15,9 +16,21 @@ import {
|
|
|
15
16
|
SelectTrigger,
|
|
16
17
|
SelectValue,
|
|
17
18
|
} from "@/components/ui/select";
|
|
18
|
-
import {
|
|
19
|
+
import { type SystemRecord } from "@/services/system-api";
|
|
19
20
|
import { loadPasswordPolicy } from "@/services/auth-api";
|
|
21
|
+
import PagedSelectionList from "../PagedSelectionList.vue";
|
|
22
|
+
import { departmentOptions, loadSelectionRecords } from "../selection-options";
|
|
23
|
+
import { useAccessSnapshot } from "@/composables/useAccessSnapshot";
|
|
24
|
+
import { evaluateWebAccess } from "@/layout/navigation";
|
|
25
|
+
import CreateRoleDialog from "./CreateRoleDialog.vue";
|
|
20
26
|
import CreateDepartmentDialog from "./CreateDepartmentDialog.vue";
|
|
27
|
+
import {
|
|
28
|
+
normalizeUserForm,
|
|
29
|
+
isMaskedField,
|
|
30
|
+
readString,
|
|
31
|
+
readError,
|
|
32
|
+
accountFields,
|
|
33
|
+
} from "./user-account-draft";
|
|
21
34
|
import UserRoleSelector from "./UserRoleSelector.vue";
|
|
22
35
|
|
|
23
36
|
interface DepartmentOption {
|
|
@@ -39,10 +52,49 @@ const emit = defineEmits<{
|
|
|
39
52
|
}>();
|
|
40
53
|
|
|
41
54
|
const form = shallowRef<Record<string, unknown>>({});
|
|
42
|
-
const
|
|
55
|
+
const baseline = shallowRef("");
|
|
56
|
+
const dirty = computed(() => JSON.stringify(form.value) !== baseline.value);
|
|
57
|
+
const access = useAccessSnapshot();
|
|
58
|
+
const canManageRoles = computed(
|
|
59
|
+
() =>
|
|
60
|
+
access.snapshot.value &&
|
|
61
|
+
evaluateWebAccess(access.snapshot.value, {
|
|
62
|
+
permissionKey: "system.role.manage",
|
|
63
|
+
featureKey: "system.role",
|
|
64
|
+
}).allowed,
|
|
65
|
+
);
|
|
66
|
+
const departmentChoices = computed(() => departmentOptions(departments.value));
|
|
67
|
+
function markSaved() {
|
|
68
|
+
form.value = { ...form.value, temporaryPassword: "" };
|
|
69
|
+
baseline.value = JSON.stringify(form.value);
|
|
70
|
+
}
|
|
71
|
+
const departments = shallowRef<SystemRecord[]>([]);
|
|
43
72
|
const loading = shallowRef(false);
|
|
44
73
|
const error = shallowRef("");
|
|
45
74
|
const departmentDialogOpen = shallowRef(false);
|
|
75
|
+
const roleDialogOpen = shallowRef(false);
|
|
76
|
+
interface NestedDialogHandle {
|
|
77
|
+
beforeClose(): boolean | Promise<boolean>;
|
|
78
|
+
}
|
|
79
|
+
const departmentDialog = useTemplateRef<NestedDialogHandle>("departmentDialog");
|
|
80
|
+
const roleDialog = useTemplateRef<NestedDialogHandle>("roleDialog");
|
|
81
|
+
async function beforeClose(): Promise<boolean> {
|
|
82
|
+
if (
|
|
83
|
+
departmentDialogOpen.value &&
|
|
84
|
+
!(await departmentDialog.value?.beforeClose())
|
|
85
|
+
)
|
|
86
|
+
return false;
|
|
87
|
+
if (roleDialogOpen.value && !(await roleDialog.value?.beforeClose()))
|
|
88
|
+
return false;
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
const roleSelector = useTemplateRef<{ reload(): Promise<void> }>(
|
|
92
|
+
"roleSelector",
|
|
93
|
+
);
|
|
94
|
+
async function selectCreatedRole(role: SystemRecord) {
|
|
95
|
+
update("roleIds", [...new Set([...selectedRoleIds(), role.id])]);
|
|
96
|
+
await roleSelector.value?.reload();
|
|
97
|
+
}
|
|
46
98
|
const governedTouched = shallowRef<Record<string, boolean>>({});
|
|
47
99
|
const passwordPolicy = shallowRef<PasswordPolicy>(PASSWORD_POLICY);
|
|
48
100
|
|
|
@@ -51,6 +103,7 @@ watch(
|
|
|
51
103
|
() => {
|
|
52
104
|
governedTouched.value = {};
|
|
53
105
|
form.value = normalizeUserForm(props.defaults, props.item);
|
|
106
|
+
baseline.value = JSON.stringify(form.value);
|
|
54
107
|
},
|
|
55
108
|
{ immediate: true },
|
|
56
109
|
);
|
|
@@ -71,31 +124,18 @@ function update(key: string, value: unknown) {
|
|
|
71
124
|
if (key === "email" || key === "phone") {
|
|
72
125
|
governedTouched.value = { ...governedTouched.value, [key]: true };
|
|
73
126
|
}
|
|
74
|
-
form.value = {
|
|
127
|
+
form.value = {
|
|
128
|
+
...form.value,
|
|
129
|
+
[key]:
|
|
130
|
+
key === "roleIds" && Array.isArray(value) ? [...value].sort() : value,
|
|
131
|
+
};
|
|
75
132
|
}
|
|
76
133
|
|
|
77
134
|
async function loadDepartments() {
|
|
78
135
|
loading.value = true;
|
|
79
136
|
error.value = "";
|
|
80
137
|
try {
|
|
81
|
-
|
|
82
|
-
page: 1,
|
|
83
|
-
pageSize: 100,
|
|
84
|
-
status: "active",
|
|
85
|
-
sort: "order,name",
|
|
86
|
-
});
|
|
87
|
-
departments.value = result.items.flatMap((item) =>
|
|
88
|
-
typeof item.code === "string" && typeof item.name === "string"
|
|
89
|
-
? [
|
|
90
|
-
{
|
|
91
|
-
id: item.id,
|
|
92
|
-
code: item.code,
|
|
93
|
-
name: item.name,
|
|
94
|
-
parentId: readString(item.parentId),
|
|
95
|
-
},
|
|
96
|
-
]
|
|
97
|
-
: [],
|
|
98
|
-
);
|
|
138
|
+
departments.value = await loadSelectionRecords("departments", false);
|
|
99
139
|
} catch (currentError) {
|
|
100
140
|
error.value = readError(currentError);
|
|
101
141
|
} finally {
|
|
@@ -112,12 +152,39 @@ function selectedRoleIds(): string[] {
|
|
|
112
152
|
}
|
|
113
153
|
|
|
114
154
|
async function selectCreatedDepartment(department: DepartmentOption) {
|
|
115
|
-
|
|
155
|
+
departments.value = [
|
|
156
|
+
...departments.value.filter(({ id }) => id !== department.id),
|
|
157
|
+
{ ...department, status: "active" },
|
|
158
|
+
];
|
|
116
159
|
update("departmentId", department.id);
|
|
160
|
+
await loadDepartments();
|
|
117
161
|
}
|
|
118
162
|
|
|
119
163
|
function submit() {
|
|
120
|
-
|
|
164
|
+
error.value = "";
|
|
165
|
+
if (
|
|
166
|
+
!String(form.value.username || "").trim() ||
|
|
167
|
+
!String(form.value.displayName || "").trim() ||
|
|
168
|
+
!form.value.departmentId ||
|
|
169
|
+
!selectedRoleIds().length
|
|
170
|
+
) {
|
|
171
|
+
error.value = "请填写用户名、姓名,选择所属部门和至少一个角色。";
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
const password = String(form.value.temporaryPassword || "");
|
|
175
|
+
if (
|
|
176
|
+
(!props.item || password) &&
|
|
177
|
+
(password.length < passwordPolicy.value.minLength ||
|
|
178
|
+
password.length > passwordPolicy.value.maxLength)
|
|
179
|
+
) {
|
|
180
|
+
error.value = `密码须为 ${passwordPolicy.value.minLength}-${passwordPolicy.value.maxLength} 个字符。`;
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
const payload: Record<string, unknown> = {
|
|
184
|
+
...form.value,
|
|
185
|
+
username: String(form.value.username).trim(),
|
|
186
|
+
displayName: String(form.value.displayName).trim(),
|
|
187
|
+
};
|
|
121
188
|
for (const key of ["email", "phone"]) {
|
|
122
189
|
if (isMaskedField(props.item?.[key]) && !governedTouched.value[key]) {
|
|
123
190
|
delete payload[key];
|
|
@@ -126,61 +193,31 @@ function submit() {
|
|
|
126
193
|
emit("submit", payload);
|
|
127
194
|
}
|
|
128
195
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
value && typeof value === "object" && "value" in value
|
|
138
|
-
? (value as Record<string, unknown>).value
|
|
139
|
-
: typeof value === "string"
|
|
140
|
-
? value
|
|
141
|
-
: "";
|
|
142
|
-
}
|
|
143
|
-
return result;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
function isMaskedField(value: unknown): boolean {
|
|
147
|
-
return Boolean(
|
|
148
|
-
value &&
|
|
149
|
-
typeof value === "object" &&
|
|
150
|
-
((value as Record<string, unknown>).kind === "masked" ||
|
|
151
|
-
(value as Record<string, unknown>).kind === "hidden"),
|
|
152
|
-
);
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
function readString(value: unknown): string | undefined {
|
|
156
|
-
return typeof value === "string" ? value : undefined;
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
function readError(value: unknown): string {
|
|
160
|
-
return value instanceof Error ? value.message : String(value);
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
defineExpose({ submit });
|
|
196
|
+
defineExpose({
|
|
197
|
+
submit,
|
|
198
|
+
beforeClose,
|
|
199
|
+
markSaved,
|
|
200
|
+
get dirty() {
|
|
201
|
+
return dirty.value;
|
|
202
|
+
},
|
|
203
|
+
});
|
|
164
204
|
</script>
|
|
165
205
|
|
|
166
206
|
<template>
|
|
167
207
|
<div class="space-y-4">
|
|
168
208
|
<div class="grid gap-4 sm:grid-cols-2">
|
|
169
209
|
<label
|
|
170
|
-
v-for="field in
|
|
171
|
-
|
|
172
|
-
['displayName', '姓名', '例如 张三'],
|
|
173
|
-
['phone', '手机号', '可选'],
|
|
174
|
-
['email', '邮箱', '可选'],
|
|
175
|
-
]"
|
|
176
|
-
:key="field[0]"
|
|
210
|
+
v-for="field in accountFields"
|
|
211
|
+
:key="field.key"
|
|
177
212
|
class="grid gap-2 text-sm font-medium"
|
|
178
213
|
>
|
|
179
|
-
<span>{{ field
|
|
214
|
+
<span>{{ field.label }}</span>
|
|
180
215
|
<Input
|
|
181
|
-
:model-value="String(form[field
|
|
182
|
-
:placeholder="field
|
|
183
|
-
|
|
216
|
+
:model-value="String(form[field.key] || '')"
|
|
217
|
+
:placeholder="field.placeholder"
|
|
218
|
+
:type="field.type"
|
|
219
|
+
:maxlength="field.maxLength"
|
|
220
|
+
@update:model-value="update(field.key, String($event))"
|
|
184
221
|
/>
|
|
185
222
|
</label>
|
|
186
223
|
</div>
|
|
@@ -197,28 +234,35 @@ defineExpose({ submit });
|
|
|
197
234
|
<PlusIcon data-icon="inline-start" />新建部门
|
|
198
235
|
</Button>
|
|
199
236
|
</div>
|
|
200
|
-
<
|
|
201
|
-
:
|
|
237
|
+
<PagedSelectionList
|
|
238
|
+
:items="departmentChoices"
|
|
239
|
+
:model-value="
|
|
240
|
+
readString(form.departmentId) ? [String(form.departmentId)] : []
|
|
241
|
+
"
|
|
242
|
+
label="所属部门"
|
|
202
243
|
:disabled="loading"
|
|
203
|
-
@update:model-value="update('departmentId',
|
|
204
|
-
|
|
205
|
-
<SelectTrigger class="w-full" aria-label="所属部门">
|
|
206
|
-
<SelectValue
|
|
207
|
-
:placeholder="loading ? '正在加载部门…' : '请选择部门'"
|
|
208
|
-
/>
|
|
209
|
-
</SelectTrigger>
|
|
210
|
-
<SelectContent>
|
|
211
|
-
<SelectItem
|
|
212
|
-
v-for="department in departments"
|
|
213
|
-
:key="department.id"
|
|
214
|
-
:value="department.id"
|
|
215
|
-
>
|
|
216
|
-
{{ department.name }}({{ department.code }})
|
|
217
|
-
</SelectItem>
|
|
218
|
-
</SelectContent>
|
|
219
|
-
</Select>
|
|
244
|
+
@update:model-value="update('departmentId', $event[0])"
|
|
245
|
+
/>
|
|
220
246
|
</div>
|
|
247
|
+
<p class="text-xs text-muted-foreground">
|
|
248
|
+
账号通过角色获得功能权限与数据范围;下方展示每个角色的权限摘要。修改角色会影响所有使用该角色的账号。
|
|
249
|
+
<RouterLink
|
|
250
|
+
v-if="canManageRoles"
|
|
251
|
+
to="/admin/system/roles"
|
|
252
|
+
class="text-primary underline"
|
|
253
|
+
>前往权限策略管理</RouterLink
|
|
254
|
+
>
|
|
255
|
+
</p>
|
|
256
|
+
<Button
|
|
257
|
+
v-if="canManageRoles"
|
|
258
|
+
type="button"
|
|
259
|
+
variant="outline"
|
|
260
|
+
@click="roleDialogOpen = true"
|
|
261
|
+
>
|
|
262
|
+
<PlusIcon data-icon="inline-start" />新建角色并选择权限
|
|
263
|
+
</Button>
|
|
221
264
|
<UserRoleSelector
|
|
265
|
+
ref="roleSelector"
|
|
222
266
|
:model-value="selectedRoleIds()"
|
|
223
267
|
:permissions="permissions"
|
|
224
268
|
@update:model-value="update('roleIds', $event)"
|
|
@@ -236,7 +280,7 @@ defineExpose({ submit });
|
|
|
236
280
|
@update:model-value="update('temporaryPassword', String($event))"
|
|
237
281
|
/>
|
|
238
282
|
<span class="text-xs font-normal text-muted-foreground">
|
|
239
|
-
|
|
283
|
+
首次登录须修改初始密码。
|
|
240
284
|
</span>
|
|
241
285
|
</label>
|
|
242
286
|
<label class="grid gap-2 text-sm font-medium">
|
|
@@ -259,7 +303,17 @@ defineExpose({ submit });
|
|
|
259
303
|
</label>
|
|
260
304
|
</div>
|
|
261
305
|
<p v-if="error" class="text-sm text-destructive">{{ error }}</p>
|
|
306
|
+
<CreateRoleDialog
|
|
307
|
+
ref="roleDialog"
|
|
308
|
+
managed-navigation
|
|
309
|
+
v-if="canManageRoles"
|
|
310
|
+
v-model:open="roleDialogOpen"
|
|
311
|
+
:permissions="permissions"
|
|
312
|
+
@created="selectCreatedRole"
|
|
313
|
+
/>
|
|
262
314
|
<CreateDepartmentDialog
|
|
315
|
+
ref="departmentDialog"
|
|
316
|
+
managed-navigation
|
|
263
317
|
v-if="departmentCreateMode"
|
|
264
318
|
v-model:open="departmentDialogOpen"
|
|
265
319
|
:mode="departmentCreateMode"
|
package/template/apps/web/src/pages/system/components/user-account/UserAccountForm.webtest.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { defineComponent } from "vue";
|
|
2
2
|
import { afterEach, describe, expect, test, vi } from "vitest";
|
|
3
3
|
import { flushPromises, mount } from "@vue/test-utils";
|
|
4
|
+
import CreateRoleDialog from "./CreateRoleDialog.vue";
|
|
4
5
|
import UserAccountForm from "./UserAccountForm.vue";
|
|
5
6
|
|
|
6
7
|
const api = vi.hoisted(() => ({
|
|
@@ -19,6 +20,17 @@ vi.mock("@/services/auth-api", () => ({
|
|
|
19
20
|
loadPasswordPolicy: api.loadPasswordPolicy,
|
|
20
21
|
}));
|
|
21
22
|
|
|
23
|
+
vi.mock("@/composables/useAccessSnapshot", () => ({
|
|
24
|
+
useAccessSnapshot: () => ({
|
|
25
|
+
snapshot: {
|
|
26
|
+
value: {
|
|
27
|
+
actor: { permissionKeys: ["system.role.manage"] },
|
|
28
|
+
features: [{ key: "system.role", enabled: true, allowed: true }],
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
}),
|
|
32
|
+
}));
|
|
33
|
+
|
|
22
34
|
afterEach(() => vi.clearAllMocks());
|
|
23
35
|
|
|
24
36
|
describe("UserAccountForm", () => {
|
|
@@ -51,6 +63,7 @@ describe("UserAccountForm", () => {
|
|
|
51
63
|
},
|
|
52
64
|
global: {
|
|
53
65
|
stubs: {
|
|
66
|
+
RouterLink: { template: "<a><slot /></a>" },
|
|
54
67
|
CreateDepartmentDialog: dialogStub,
|
|
55
68
|
UserRoleSelector: { template: "<div />" },
|
|
56
69
|
},
|
|
@@ -60,6 +73,10 @@ describe("UserAccountForm", () => {
|
|
|
60
73
|
|
|
61
74
|
const dialog = wrapper.getComponent(dialogStub);
|
|
62
75
|
expect(dialog.props("open")).toBe(false);
|
|
76
|
+
expect(wrapper.get('input[type="email"]').attributes("maxlength")).toBe(
|
|
77
|
+
"160",
|
|
78
|
+
);
|
|
79
|
+
expect(wrapper.get('input[type="tel"]').attributes("maxlength")).toBe("32");
|
|
63
80
|
const openButton = wrapper
|
|
64
81
|
.findAll("button")
|
|
65
82
|
.find((button) => button.text() === "新建部门");
|
|
@@ -68,4 +85,82 @@ describe("UserAccountForm", () => {
|
|
|
68
85
|
expect(dialog.props("open")).toBe(true);
|
|
69
86
|
expect(wrapper.text()).not.toContain("新建并选中部门");
|
|
70
87
|
});
|
|
88
|
+
test("新角色创建后保留账户草稿并追加选择,重新加载角色选项", async () => {
|
|
89
|
+
api.list.mockResolvedValue({
|
|
90
|
+
items: [
|
|
91
|
+
{ id: "dept_root", name: "总部", code: "root", status: "active" },
|
|
92
|
+
],
|
|
93
|
+
total: 1,
|
|
94
|
+
page: 1,
|
|
95
|
+
pageSize: 10,
|
|
96
|
+
});
|
|
97
|
+
api.loadPasswordPolicy.mockResolvedValue({ minLength: 12, maxLength: 128 });
|
|
98
|
+
const reload = vi.fn(async () => {});
|
|
99
|
+
const beforeClose = vi.fn(async () => false);
|
|
100
|
+
const wrapper = mount(UserAccountForm, {
|
|
101
|
+
props: {
|
|
102
|
+
defaults: {},
|
|
103
|
+
item: {
|
|
104
|
+
id: "account",
|
|
105
|
+
username: "original",
|
|
106
|
+
displayName: "原姓名",
|
|
107
|
+
departmentId: "dept_root",
|
|
108
|
+
roleIds: ["role_old"],
|
|
109
|
+
status: "active",
|
|
110
|
+
},
|
|
111
|
+
permissions: [],
|
|
112
|
+
},
|
|
113
|
+
global: {
|
|
114
|
+
stubs: {
|
|
115
|
+
CreateRoleDialog: defineComponent({
|
|
116
|
+
name: "CreateRoleDialog",
|
|
117
|
+
setup(_props, { expose }) {
|
|
118
|
+
expose({ beforeClose });
|
|
119
|
+
return {};
|
|
120
|
+
},
|
|
121
|
+
props: { open: Boolean },
|
|
122
|
+
emits: ["created", "update:open"],
|
|
123
|
+
template: "<div />",
|
|
124
|
+
}),
|
|
125
|
+
UserRoleSelector: defineComponent({
|
|
126
|
+
setup(_props, { expose }) {
|
|
127
|
+
expose({ reload });
|
|
128
|
+
return {};
|
|
129
|
+
},
|
|
130
|
+
template: "<div />",
|
|
131
|
+
}),
|
|
132
|
+
RouterLink: { template: "<a><slot /></a>" },
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
await flushPromises();
|
|
137
|
+
await wrapper
|
|
138
|
+
.get('input[placeholder="例如 张三"]')
|
|
139
|
+
.setValue("尚未保存的姓名");
|
|
140
|
+
await wrapper
|
|
141
|
+
.findAll("button")
|
|
142
|
+
.find((button) => button.text() === "新建角色并选择权限")!
|
|
143
|
+
.trigger("click");
|
|
144
|
+
const dialog = wrapper.getComponent(CreateRoleDialog);
|
|
145
|
+
expect(dialog.props("open")).toBe(true);
|
|
146
|
+
await expect(
|
|
147
|
+
(
|
|
148
|
+
wrapper.vm as unknown as { beforeClose(): Promise<boolean> }
|
|
149
|
+
).beforeClose(),
|
|
150
|
+
).resolves.toBe(false);
|
|
151
|
+
expect(beforeClose).toHaveBeenCalledOnce();
|
|
152
|
+
dialog.vm.$emit("created", {
|
|
153
|
+
id: "role_new",
|
|
154
|
+
name: "新角色",
|
|
155
|
+
status: "active",
|
|
156
|
+
});
|
|
157
|
+
await flushPromises();
|
|
158
|
+
(wrapper.vm as unknown as { submit(): void }).submit();
|
|
159
|
+
expect(reload).toHaveBeenCalledOnce();
|
|
160
|
+
expect(wrapper.emitted("submit")?.at(-1)?.[0]).toMatchObject({
|
|
161
|
+
displayName: "尚未保存的姓名",
|
|
162
|
+
roleIds: ["role_new", "role_old"],
|
|
163
|
+
});
|
|
164
|
+
wrapper.unmount();
|
|
165
|
+
});
|
|
71
166
|
});
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, shallowRef, watch } from "vue";
|
|
3
|
+
import { Button } from "@/components/ui/button";
|
|
4
|
+
import {
|
|
5
|
+
Dialog,
|
|
6
|
+
DialogContent,
|
|
7
|
+
DialogHeader,
|
|
8
|
+
DialogTitle,
|
|
9
|
+
DialogDescription,
|
|
10
|
+
DialogTrigger,
|
|
11
|
+
} from "@/components/ui/dialog";
|
|
12
|
+
import {
|
|
13
|
+
Select,
|
|
14
|
+
SelectContent,
|
|
15
|
+
SelectItem,
|
|
16
|
+
SelectTrigger,
|
|
17
|
+
SelectValue,
|
|
18
|
+
} from "@/components/ui/select";
|
|
19
|
+
import { useAccessSnapshot } from "@/composables/useAccessSnapshot";
|
|
20
|
+
import { evaluateWebAccess } from "@/layout/navigation";
|
|
21
|
+
import PagedSelectionList from "../PagedSelectionList.vue";
|
|
22
|
+
import { loadSelectionRecords } from "../selection-options";
|
|
23
|
+
import type { SystemRecord } from "@/services/system-api";
|
|
24
|
+
const emit = defineEmits<{
|
|
25
|
+
change: [
|
|
26
|
+
filters: { departmentId?: string; roleId?: string; status?: string },
|
|
27
|
+
];
|
|
28
|
+
}>();
|
|
29
|
+
const access = useAccessSnapshot();
|
|
30
|
+
const departmentId = shallowRef("");
|
|
31
|
+
const roleId = shallowRef("");
|
|
32
|
+
const status = shallowRef("all");
|
|
33
|
+
const departments = shallowRef<SystemRecord[]>([]);
|
|
34
|
+
const roles = shallowRef<SystemRecord[]>([]);
|
|
35
|
+
const error = shallowRef("");
|
|
36
|
+
const departmentAllowed = computed(() => allowed("system.department"));
|
|
37
|
+
const roleAllowed = computed(() => allowed("system.role"));
|
|
38
|
+
function allowed(key: "system.department" | "system.role") {
|
|
39
|
+
const snapshot = access.snapshot.value;
|
|
40
|
+
return Boolean(
|
|
41
|
+
snapshot &&
|
|
42
|
+
evaluateWebAccess(snapshot, {
|
|
43
|
+
featureKey: key,
|
|
44
|
+
permissionKey: `${key}.read`,
|
|
45
|
+
}).allowed,
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
function choices(items: SystemRecord[]) {
|
|
49
|
+
return items.map((item) => ({
|
|
50
|
+
id: item.id,
|
|
51
|
+
label: String(item.name || item.code || item.id),
|
|
52
|
+
description: `${String(item.code || "")}${item.status === "disabled" ? " · 已停用" : ""}`,
|
|
53
|
+
}));
|
|
54
|
+
}
|
|
55
|
+
const departmentChoices = computed(() => choices(departments.value));
|
|
56
|
+
const roleChoices = computed(() => choices(roles.value));
|
|
57
|
+
const departmentLabel = computed(
|
|
58
|
+
() =>
|
|
59
|
+
departmentChoices.value.find((item) => item.id === departmentId.value)
|
|
60
|
+
?.label || "全部部门",
|
|
61
|
+
);
|
|
62
|
+
const roleLabel = computed(
|
|
63
|
+
() =>
|
|
64
|
+
roleChoices.value.find((item) => item.id === roleId.value)?.label ||
|
|
65
|
+
"全部角色",
|
|
66
|
+
);
|
|
67
|
+
watch(
|
|
68
|
+
[departmentAllowed, roleAllowed],
|
|
69
|
+
async ([canReadDepartments, canReadRoles], _, onCleanup) => {
|
|
70
|
+
let active = true;
|
|
71
|
+
onCleanup(() => {
|
|
72
|
+
active = false;
|
|
73
|
+
});
|
|
74
|
+
error.value = "";
|
|
75
|
+
const results = await Promise.allSettled([
|
|
76
|
+
canReadDepartments
|
|
77
|
+
? loadSelectionRecords("departments", false)
|
|
78
|
+
: Promise.resolve([]),
|
|
79
|
+
canReadRoles ? loadSelectionRecords("roles", false) : Promise.resolve([]),
|
|
80
|
+
]);
|
|
81
|
+
if (!active) return;
|
|
82
|
+
departments.value =
|
|
83
|
+
results[0].status === "fulfilled" ? results[0].value : [];
|
|
84
|
+
roles.value = results[1].status === "fulfilled" ? results[1].value : [];
|
|
85
|
+
if (results.some((result) => result.status === "rejected"))
|
|
86
|
+
error.value = "部分筛选选项加载失败,请刷新重试。";
|
|
87
|
+
},
|
|
88
|
+
{ immediate: true },
|
|
89
|
+
);
|
|
90
|
+
watch([departmentId, roleId, status], () =>
|
|
91
|
+
emit("change", {
|
|
92
|
+
departmentId: departmentId.value || undefined,
|
|
93
|
+
roleId: roleId.value || undefined,
|
|
94
|
+
status: status.value === "all" ? undefined : status.value,
|
|
95
|
+
}),
|
|
96
|
+
);
|
|
97
|
+
function reset() {
|
|
98
|
+
departmentId.value = "";
|
|
99
|
+
roleId.value = "";
|
|
100
|
+
status.value = "all";
|
|
101
|
+
}
|
|
102
|
+
</script>
|
|
103
|
+
<template>
|
|
104
|
+
<div class="flex flex-wrap items-center gap-2">
|
|
105
|
+
<Dialog v-if="departmentAllowed">
|
|
106
|
+
<DialogTrigger as-child
|
|
107
|
+
><Button variant="outline" aria-label="按部门筛选">{{
|
|
108
|
+
departmentLabel
|
|
109
|
+
}}</Button></DialogTrigger
|
|
110
|
+
>
|
|
111
|
+
<DialogContent class="sm:max-w-lg">
|
|
112
|
+
<DialogHeader
|
|
113
|
+
><DialogTitle>筛选部门</DialogTitle
|
|
114
|
+
><DialogDescription
|
|
115
|
+
>选择部门后立即筛选账户列表。</DialogDescription
|
|
116
|
+
></DialogHeader
|
|
117
|
+
>
|
|
118
|
+
<PagedSelectionList
|
|
119
|
+
:items="departmentChoices"
|
|
120
|
+
:model-value="departmentId ? [departmentId] : []"
|
|
121
|
+
label="筛选部门"
|
|
122
|
+
clearable
|
|
123
|
+
@update:model-value="departmentId = $event[0] || ''"
|
|
124
|
+
/>
|
|
125
|
+
</DialogContent>
|
|
126
|
+
</Dialog>
|
|
127
|
+
<Dialog v-if="roleAllowed">
|
|
128
|
+
<DialogTrigger as-child
|
|
129
|
+
><Button variant="outline" aria-label="按角色筛选">{{
|
|
130
|
+
roleLabel
|
|
131
|
+
}}</Button></DialogTrigger
|
|
132
|
+
>
|
|
133
|
+
<DialogContent class="sm:max-w-lg">
|
|
134
|
+
<DialogHeader
|
|
135
|
+
><DialogTitle>筛选角色</DialogTitle
|
|
136
|
+
><DialogDescription
|
|
137
|
+
>选择角色后立即筛选账户列表。</DialogDescription
|
|
138
|
+
></DialogHeader
|
|
139
|
+
>
|
|
140
|
+
<PagedSelectionList
|
|
141
|
+
:items="roleChoices"
|
|
142
|
+
:model-value="roleId ? [roleId] : []"
|
|
143
|
+
label="筛选角色"
|
|
144
|
+
clearable
|
|
145
|
+
@update:model-value="roleId = $event[0] || ''"
|
|
146
|
+
/>
|
|
147
|
+
</DialogContent>
|
|
148
|
+
</Dialog>
|
|
149
|
+
<Select v-model="status"
|
|
150
|
+
><SelectTrigger class="w-32" aria-label="按账户状态筛选"
|
|
151
|
+
><SelectValue /></SelectTrigger
|
|
152
|
+
><SelectContent
|
|
153
|
+
><SelectItem value="all">全部状态</SelectItem
|
|
154
|
+
><SelectItem value="active">启用</SelectItem
|
|
155
|
+
><SelectItem value="disabled">停用</SelectItem></SelectContent
|
|
156
|
+
></Select
|
|
157
|
+
>
|
|
158
|
+
<Button
|
|
159
|
+
v-if="departmentId || roleId || status !== 'all'"
|
|
160
|
+
variant="ghost"
|
|
161
|
+
@click="reset"
|
|
162
|
+
>清空筛选</Button
|
|
163
|
+
>
|
|
164
|
+
<p v-if="error" class="w-full text-sm text-destructive" role="alert">
|
|
165
|
+
{{ error }}
|
|
166
|
+
</p>
|
|
167
|
+
</div>
|
|
168
|
+
</template>
|