create-windy 0.6.18 → 0.6.19
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 +126 -54
- package/package.json +1 -1
- package/template/.windy-template.json +2 -2
- package/template/README.md +5 -2
- package/template/apps/agent-server/README.md +6 -0
- package/template/apps/agent-server/src/southwind_agent_server/app.py +23 -0
- package/template/apps/agent-server/src/southwind_agent_server/health_routes.py +9 -0
- package/template/apps/agent-server/src/southwind_agent_server/runtime_compatibility.py +89 -0
- package/template/apps/agent-server/tests/test_runtime_compatibility.py +119 -0
- package/template/apps/server/src/agent/remote-runtime-compatibility.test.ts +147 -0
- package/template/apps/server/src/agent/remote-runtime.ts +66 -4
- package/template/apps/server/src/agent/runtime-bootstrap.ts +1 -0
- package/template/apps/web/src/components/common/FormDialog.vue +1 -0
- package/template/apps/web/src/components/common/ModalLayout.webtest.ts +1 -0
- package/template/apps/web/src/components/ui/dialog/DialogContent.vue +1 -1
- package/template/apps/web/src/components/ui/dialog/DialogScrollContent.vue +1 -1
- package/template/apps/web/src/pages/system/components/SystemResourceEditor.vue +1 -0
- package/template/apps/web/src/pages/system/components/user-account/CreateDepartmentDialog.vue +182 -0
- package/template/apps/web/src/pages/system/components/user-account/CreateDepartmentDialog.webtest.ts +93 -0
- package/template/apps/web/src/pages/system/components/user-account/RolePermissionSummary.vue +59 -0
- package/template/apps/web/src/pages/system/components/user-account/UserAccountForm.vue +21 -71
- package/template/apps/web/src/pages/system/components/user-account/UserAccountForm.webtest.ts +71 -0
- package/template/apps/web/src/pages/system/components/user-account/UserRoleSelector.vue +48 -27
- package/template/apps/web/src/pages/system/components/user-account/UserRoleSelector.webtest.ts +30 -1
- package/template/docs/architecture/ai-runtime.md +8 -15
- package/template/docs/platform/agent-runtime.md +5 -0
- package/template/packages/server-sdk/contracts/agent-runtime-compatibility/v1/offer.json +22 -0
- package/template/packages/server-sdk/index.ts +19 -0
- package/template/packages/server-sdk/package.json +2 -0
- package/template/packages/server-sdk/src/agent-runtime-compatibility.contract.test.ts +143 -0
- package/template/packages/server-sdk/src/agent-runtime-compatibility.public-api.test.ts +19 -0
- package/template/packages/server-sdk/src/agent-runtime-compatibility.ts +284 -0
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import {
|
|
3
|
+
computed,
|
|
4
|
+
inject,
|
|
5
|
+
onBeforeUnmount,
|
|
6
|
+
onMounted,
|
|
7
|
+
shallowRef,
|
|
8
|
+
watch,
|
|
9
|
+
} from "vue";
|
|
10
|
+
import { routerKey } from "vue-router";
|
|
11
|
+
import FormDialog from "@/components/common/FormDialog.vue";
|
|
12
|
+
import UnsavedChangesDialog from "@/components/common/UnsavedChangesDialog.vue";
|
|
13
|
+
import { Input } from "@/components/ui/input";
|
|
14
|
+
import {
|
|
15
|
+
Select,
|
|
16
|
+
SelectContent,
|
|
17
|
+
SelectItem,
|
|
18
|
+
SelectTrigger,
|
|
19
|
+
SelectValue,
|
|
20
|
+
} from "@/components/ui/select";
|
|
21
|
+
import { useUnsavedChangesConfirmation } from "@/composables/useUnsavedChangesConfirmation";
|
|
22
|
+
import {
|
|
23
|
+
createSystemResource,
|
|
24
|
+
quickCreateDepartment,
|
|
25
|
+
type SystemRecord,
|
|
26
|
+
} from "@/services/system-api";
|
|
27
|
+
|
|
28
|
+
interface DepartmentOption {
|
|
29
|
+
id: string;
|
|
30
|
+
code: string;
|
|
31
|
+
name: string;
|
|
32
|
+
parentId?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const open = defineModel<boolean>("open", { default: false });
|
|
36
|
+
const props = defineProps<{
|
|
37
|
+
mode: "create" | "manage";
|
|
38
|
+
departments: DepartmentOption[];
|
|
39
|
+
}>();
|
|
40
|
+
const emit = defineEmits<{
|
|
41
|
+
created: [department: DepartmentOption];
|
|
42
|
+
}>();
|
|
43
|
+
|
|
44
|
+
const name = shallowRef("");
|
|
45
|
+
const code = shallowRef("");
|
|
46
|
+
const parentId = shallowRef<string>();
|
|
47
|
+
const saving = shallowRef(false);
|
|
48
|
+
const error = shallowRef("");
|
|
49
|
+
const confirmation = useUnsavedChangesConfirmation();
|
|
50
|
+
const router = inject(routerKey, null);
|
|
51
|
+
let removeRouteGuard: (() => void) | undefined;
|
|
52
|
+
|
|
53
|
+
const dirty = computed(
|
|
54
|
+
() => open.value && Boolean(name.value || code.value || parentId.value),
|
|
55
|
+
);
|
|
56
|
+
const valid = computed(() =>
|
|
57
|
+
Boolean(name.value.trim() && code.value.trim() && parentId.value),
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
watch(open, (value) => {
|
|
61
|
+
if (!value) reset();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
onMounted(() => {
|
|
65
|
+
window.addEventListener("beforeunload", preventDirtyUnload);
|
|
66
|
+
removeRouteGuard = router?.beforeEach(() =>
|
|
67
|
+
dirty.value ? confirmation.request(create) : true,
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
onBeforeUnmount(() => {
|
|
72
|
+
window.removeEventListener("beforeunload", preventDirtyUnload);
|
|
73
|
+
removeRouteGuard?.();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
async function create(): Promise<boolean> {
|
|
77
|
+
if (saving.value || !valid.value) return false;
|
|
78
|
+
saving.value = true;
|
|
79
|
+
error.value = "";
|
|
80
|
+
try {
|
|
81
|
+
const payload = {
|
|
82
|
+
code: code.value.trim(),
|
|
83
|
+
name: name.value.trim(),
|
|
84
|
+
parentId: parentId.value,
|
|
85
|
+
order: 100,
|
|
86
|
+
};
|
|
87
|
+
const created =
|
|
88
|
+
props.mode === "create"
|
|
89
|
+
? await quickCreateDepartment(payload)
|
|
90
|
+
: await createSystemResource("departments", payload);
|
|
91
|
+
emit("created", readDepartment(created));
|
|
92
|
+
open.value = false;
|
|
93
|
+
return true;
|
|
94
|
+
} catch (currentError) {
|
|
95
|
+
error.value =
|
|
96
|
+
currentError instanceof Error
|
|
97
|
+
? currentError.message
|
|
98
|
+
: String(currentError);
|
|
99
|
+
return false;
|
|
100
|
+
} finally {
|
|
101
|
+
saving.value = false;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function beforeClose() {
|
|
106
|
+
return dirty.value ? confirmation.request(create) : true;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function preventDirtyUnload(event: BeforeUnloadEvent) {
|
|
110
|
+
if (!dirty.value) return;
|
|
111
|
+
event.preventDefault();
|
|
112
|
+
event.returnValue = "";
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function reset() {
|
|
116
|
+
name.value = "";
|
|
117
|
+
code.value = "";
|
|
118
|
+
parentId.value = undefined;
|
|
119
|
+
error.value = "";
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function readDepartment(record: SystemRecord): DepartmentOption {
|
|
123
|
+
return {
|
|
124
|
+
id: record.id,
|
|
125
|
+
code: typeof record.code === "string" ? record.code : code.value.trim(),
|
|
126
|
+
name: typeof record.name === "string" ? record.name : name.value.trim(),
|
|
127
|
+
parentId:
|
|
128
|
+
typeof record.parentId === "string" ? record.parentId : parentId.value,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
</script>
|
|
132
|
+
|
|
133
|
+
<template>
|
|
134
|
+
<FormDialog
|
|
135
|
+
v-model:open="open"
|
|
136
|
+
title="新建部门"
|
|
137
|
+
description="创建成功后会自动选为该用户的所属部门。"
|
|
138
|
+
submit-text="创建并选中"
|
|
139
|
+
:loading="saving"
|
|
140
|
+
:submit-disabled="!valid"
|
|
141
|
+
:before-close="beforeClose"
|
|
142
|
+
content-class="sm:max-w-xl"
|
|
143
|
+
@submit="create"
|
|
144
|
+
>
|
|
145
|
+
<p v-if="error" class="text-sm text-destructive">{{ error }}</p>
|
|
146
|
+
<div class="grid gap-4 sm:grid-cols-2">
|
|
147
|
+
<label class="grid gap-2 text-sm font-medium">
|
|
148
|
+
<span>部门名称</span>
|
|
149
|
+
<Input v-model="name" placeholder="例如 销售部" />
|
|
150
|
+
</label>
|
|
151
|
+
<label class="grid gap-2 text-sm font-medium">
|
|
152
|
+
<span>部门编码</span>
|
|
153
|
+
<Input v-model="code" placeholder="例如 sales" />
|
|
154
|
+
</label>
|
|
155
|
+
</div>
|
|
156
|
+
<label class="grid gap-2 text-sm font-medium">
|
|
157
|
+
<span>上级部门</span>
|
|
158
|
+
<Select v-model="parentId">
|
|
159
|
+
<SelectTrigger class="w-full" aria-label="上级部门">
|
|
160
|
+
<SelectValue placeholder="请选择上级部门" />
|
|
161
|
+
</SelectTrigger>
|
|
162
|
+
<SelectContent>
|
|
163
|
+
<SelectItem
|
|
164
|
+
v-for="department in departments"
|
|
165
|
+
:key="department.id"
|
|
166
|
+
:value="department.id"
|
|
167
|
+
>
|
|
168
|
+
{{ department.name }}({{ department.code }})
|
|
169
|
+
</SelectItem>
|
|
170
|
+
</SelectContent>
|
|
171
|
+
</Select>
|
|
172
|
+
</label>
|
|
173
|
+
</FormDialog>
|
|
174
|
+
|
|
175
|
+
<UnsavedChangesDialog
|
|
176
|
+
:open="confirmation.open.value"
|
|
177
|
+
:saving="confirmation.saving.value"
|
|
178
|
+
description="部门信息尚未保存。取消会留在当前表单;保存成功后才会继续关闭或离开。"
|
|
179
|
+
@cancel="confirmation.cancel"
|
|
180
|
+
@save="confirmation.save"
|
|
181
|
+
/>
|
|
182
|
+
</template>
|
package/template/apps/web/src/pages/system/components/user-account/CreateDepartmentDialog.webtest.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { defineComponent } from "vue";
|
|
2
|
+
import { afterEach, describe, expect, test, vi } from "vitest";
|
|
3
|
+
import { flushPromises, mount } from "@vue/test-utils";
|
|
4
|
+
import CreateDepartmentDialog from "./CreateDepartmentDialog.vue";
|
|
5
|
+
|
|
6
|
+
const api = vi.hoisted(() => ({
|
|
7
|
+
quickCreateDepartment: vi.fn(),
|
|
8
|
+
createSystemResource: vi.fn(),
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
vi.mock("@/services/system-api", async () => {
|
|
12
|
+
const actual = await vi.importActual<typeof import("@/services/system-api")>(
|
|
13
|
+
"@/services/system-api",
|
|
14
|
+
);
|
|
15
|
+
return { ...actual, ...api };
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
vi.clearAllMocks();
|
|
20
|
+
document.body.innerHTML = "";
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe("CreateDepartmentDialog", () => {
|
|
24
|
+
test("在独立 Modal 中创建部门并回传新部门", async () => {
|
|
25
|
+
api.quickCreateDepartment.mockResolvedValue({
|
|
26
|
+
id: "dept_sales",
|
|
27
|
+
code: "sales",
|
|
28
|
+
name: "销售部",
|
|
29
|
+
parentId: "dept_root",
|
|
30
|
+
});
|
|
31
|
+
const wrapper = mount(CreateDepartmentDialog, {
|
|
32
|
+
attachTo: document.body,
|
|
33
|
+
props: {
|
|
34
|
+
open: true,
|
|
35
|
+
mode: "create",
|
|
36
|
+
departments: [{ id: "dept_root", code: "root", name: "总部" }],
|
|
37
|
+
},
|
|
38
|
+
global: {
|
|
39
|
+
stubs: {
|
|
40
|
+
FormDialog: defineComponent({
|
|
41
|
+
props: {
|
|
42
|
+
open: Boolean,
|
|
43
|
+
submitDisabled: Boolean,
|
|
44
|
+
},
|
|
45
|
+
emits: ["submit", "update:open"],
|
|
46
|
+
template:
|
|
47
|
+
'<div role="dialog"><slot /><button data-testid="submit" :disabled="submitDisabled" @click="$emit(\'submit\')">提交</button></div>',
|
|
48
|
+
}),
|
|
49
|
+
Input: defineComponent({
|
|
50
|
+
props: { modelValue: String },
|
|
51
|
+
emits: ["update:modelValue"],
|
|
52
|
+
template:
|
|
53
|
+
'<input :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />',
|
|
54
|
+
}),
|
|
55
|
+
Select: defineComponent({
|
|
56
|
+
emits: ["update:modelValue"],
|
|
57
|
+
template:
|
|
58
|
+
"<button data-testid=\"choose-parent\" @click=\"$emit('update:modelValue', 'dept_root')\"><slot /></button>",
|
|
59
|
+
}),
|
|
60
|
+
SelectTrigger: { template: "<span><slot /></span>" },
|
|
61
|
+
SelectValue: { template: "<span />" },
|
|
62
|
+
SelectContent: { template: "<span><slot /></span>" },
|
|
63
|
+
SelectItem: { template: "<span><slot /></span>" },
|
|
64
|
+
UnsavedChangesDialog: { template: "<div />" },
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
await flushPromises();
|
|
69
|
+
|
|
70
|
+
const inputs = wrapper.findAll("input");
|
|
71
|
+
await inputs[0]!.setValue("销售部");
|
|
72
|
+
await inputs[1]!.setValue("sales");
|
|
73
|
+
await wrapper.get('[data-testid="choose-parent"]').trigger("click");
|
|
74
|
+
await wrapper.get('[data-testid="submit"]').trigger("click");
|
|
75
|
+
await flushPromises();
|
|
76
|
+
|
|
77
|
+
expect(api.quickCreateDepartment).toHaveBeenCalledWith({
|
|
78
|
+
code: "sales",
|
|
79
|
+
name: "销售部",
|
|
80
|
+
parentId: "dept_root",
|
|
81
|
+
order: 100,
|
|
82
|
+
});
|
|
83
|
+
expect(wrapper.emitted("created")?.at(-1)).toEqual([
|
|
84
|
+
{
|
|
85
|
+
id: "dept_sales",
|
|
86
|
+
code: "sales",
|
|
87
|
+
name: "销售部",
|
|
88
|
+
parentId: "dept_root",
|
|
89
|
+
},
|
|
90
|
+
]);
|
|
91
|
+
wrapper.unmount();
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } from "vue";
|
|
3
|
+
import type { PermissionDefinition } from "@southwind-ai/shared";
|
|
4
|
+
import {
|
|
5
|
+
groupPermissions,
|
|
6
|
+
unknownPermissionKeys,
|
|
7
|
+
} from "../role-policy/permission-tree";
|
|
8
|
+
|
|
9
|
+
const props = defineProps<{
|
|
10
|
+
permissionKeys: string[];
|
|
11
|
+
permissions: PermissionDefinition[];
|
|
12
|
+
}>();
|
|
13
|
+
|
|
14
|
+
const knownPermissions = computed(() => {
|
|
15
|
+
const selected = new Set(props.permissionKeys);
|
|
16
|
+
return props.permissions.filter(({ key }) => selected.has(key));
|
|
17
|
+
});
|
|
18
|
+
const groups = computed(() => groupPermissions(knownPermissions.value));
|
|
19
|
+
const unknownKeys = computed(() =>
|
|
20
|
+
unknownPermissionKeys(props.permissionKeys, props.permissions),
|
|
21
|
+
);
|
|
22
|
+
</script>
|
|
23
|
+
|
|
24
|
+
<template>
|
|
25
|
+
<p
|
|
26
|
+
v-if="!permissionKeys.length"
|
|
27
|
+
class="mt-3 border-t pt-3 text-xs text-muted-foreground"
|
|
28
|
+
>
|
|
29
|
+
未分配功能权限
|
|
30
|
+
</p>
|
|
31
|
+
<details v-else class="mt-3 border-t pt-3 text-xs">
|
|
32
|
+
<summary
|
|
33
|
+
class="cursor-pointer select-none font-medium text-muted-foreground hover:text-foreground"
|
|
34
|
+
>
|
|
35
|
+
查看 {{ permissionKeys.length }} 项功能权限
|
|
36
|
+
</summary>
|
|
37
|
+
<div class="mt-3 grid gap-3">
|
|
38
|
+
<section v-for="group in groups" :key="group.key" class="grid gap-2">
|
|
39
|
+
<p class="font-medium text-foreground">{{ group.label }}</p>
|
|
40
|
+
<div
|
|
41
|
+
v-for="resource in group.resources"
|
|
42
|
+
:key="resource.key"
|
|
43
|
+
class="grid gap-1 sm:grid-cols-[8rem_1fr]"
|
|
44
|
+
>
|
|
45
|
+
<span class="text-muted-foreground">{{ resource.label }}</span>
|
|
46
|
+
<span class="leading-5">
|
|
47
|
+
{{ resource.permissions.map(({ label }) => label).join("、") }}
|
|
48
|
+
</span>
|
|
49
|
+
</div>
|
|
50
|
+
</section>
|
|
51
|
+
<section v-if="unknownKeys.length" class="grid gap-1">
|
|
52
|
+
<p class="font-medium text-amber-700 dark:text-amber-400">
|
|
53
|
+
未注册的历史权限
|
|
54
|
+
</p>
|
|
55
|
+
<code class="break-all leading-5">{{ unknownKeys.join("、") }}</code>
|
|
56
|
+
</section>
|
|
57
|
+
</div>
|
|
58
|
+
</details>
|
|
59
|
+
</template>
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { onMounted, shallowRef, watch } from "vue";
|
|
3
3
|
import { PlusIcon } from "@lucide/vue";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
PASSWORD_POLICY,
|
|
6
|
+
type PasswordPolicy,
|
|
7
|
+
type PermissionDefinition,
|
|
8
|
+
} from "@southwind-ai/shared";
|
|
5
9
|
import { Button } from "@/components/ui/button";
|
|
6
10
|
import { Input } from "@/components/ui/input";
|
|
7
11
|
import {
|
|
@@ -11,13 +15,9 @@ import {
|
|
|
11
15
|
SelectTrigger,
|
|
12
16
|
SelectValue,
|
|
13
17
|
} from "@/components/ui/select";
|
|
14
|
-
import {
|
|
15
|
-
createSystemResource,
|
|
16
|
-
listSystemResource,
|
|
17
|
-
quickCreateDepartment,
|
|
18
|
-
type SystemRecord,
|
|
19
|
-
} from "@/services/system-api";
|
|
18
|
+
import { listSystemResource, type SystemRecord } from "@/services/system-api";
|
|
20
19
|
import { loadPasswordPolicy } from "@/services/auth-api";
|
|
20
|
+
import CreateDepartmentDialog from "./CreateDepartmentDialog.vue";
|
|
21
21
|
import UserRoleSelector from "./UserRoleSelector.vue";
|
|
22
22
|
|
|
23
23
|
interface DepartmentOption {
|
|
@@ -30,6 +30,7 @@ interface DepartmentOption {
|
|
|
30
30
|
const props = defineProps<{
|
|
31
31
|
item?: SystemRecord;
|
|
32
32
|
defaults: Record<string, unknown>;
|
|
33
|
+
permissions: PermissionDefinition[];
|
|
33
34
|
departmentCreateMode?: "create" | "manage";
|
|
34
35
|
}>();
|
|
35
36
|
|
|
@@ -40,12 +41,8 @@ const emit = defineEmits<{
|
|
|
40
41
|
const form = shallowRef<Record<string, unknown>>({});
|
|
41
42
|
const departments = shallowRef<DepartmentOption[]>([]);
|
|
42
43
|
const loading = shallowRef(false);
|
|
43
|
-
const creating = shallowRef(false);
|
|
44
44
|
const error = shallowRef("");
|
|
45
|
-
const
|
|
46
|
-
const quickName = shallowRef("");
|
|
47
|
-
const quickCode = shallowRef("");
|
|
48
|
-
const quickParentId = shallowRef<string | undefined>();
|
|
45
|
+
const departmentDialogOpen = shallowRef(false);
|
|
49
46
|
const governedTouched = shallowRef<Record<string, boolean>>({});
|
|
50
47
|
const passwordPolicy = shallowRef<PasswordPolicy>(PASSWORD_POLICY);
|
|
51
48
|
|
|
@@ -114,32 +111,9 @@ function selectedRoleIds(): string[] {
|
|
|
114
111
|
: [];
|
|
115
112
|
}
|
|
116
113
|
|
|
117
|
-
async function
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
error.value = "";
|
|
121
|
-
try {
|
|
122
|
-
const payload = {
|
|
123
|
-
code: quickCode.value,
|
|
124
|
-
name: quickName.value,
|
|
125
|
-
parentId: quickParentId.value,
|
|
126
|
-
order: 100,
|
|
127
|
-
};
|
|
128
|
-
const created =
|
|
129
|
-
props.departmentCreateMode === "create"
|
|
130
|
-
? await quickCreateDepartment(payload)
|
|
131
|
-
: await createSystemResource("departments", payload);
|
|
132
|
-
await loadDepartments();
|
|
133
|
-
update("departmentId", created.id);
|
|
134
|
-
quickOpen.value = false;
|
|
135
|
-
quickName.value = "";
|
|
136
|
-
quickCode.value = "";
|
|
137
|
-
quickParentId.value = undefined;
|
|
138
|
-
} catch (currentError) {
|
|
139
|
-
error.value = readError(currentError);
|
|
140
|
-
} finally {
|
|
141
|
-
creating.value = false;
|
|
142
|
-
}
|
|
114
|
+
async function selectCreatedDepartment(department: DepartmentOption) {
|
|
115
|
+
await loadDepartments();
|
|
116
|
+
update("departmentId", department.id);
|
|
143
117
|
}
|
|
144
118
|
|
|
145
119
|
function submit() {
|
|
@@ -218,7 +192,7 @@ defineExpose({ submit });
|
|
|
218
192
|
type="button"
|
|
219
193
|
size="sm"
|
|
220
194
|
variant="ghost"
|
|
221
|
-
@click="
|
|
195
|
+
@click="departmentDialogOpen = true"
|
|
222
196
|
>
|
|
223
197
|
<PlusIcon data-icon="inline-start" />新建部门
|
|
224
198
|
</Button>
|
|
@@ -246,6 +220,7 @@ defineExpose({ submit });
|
|
|
246
220
|
</div>
|
|
247
221
|
<UserRoleSelector
|
|
248
222
|
:model-value="selectedRoleIds()"
|
|
223
|
+
:permissions="permissions"
|
|
249
224
|
@update:model-value="update('roleIds', $event)"
|
|
250
225
|
/>
|
|
251
226
|
<div class="grid gap-4 sm:grid-cols-2">
|
|
@@ -283,38 +258,13 @@ defineExpose({ submit });
|
|
|
283
258
|
</span>
|
|
284
259
|
</label>
|
|
285
260
|
</div>
|
|
286
|
-
<section
|
|
287
|
-
v-if="quickOpen"
|
|
288
|
-
class="space-y-3 rounded-lg border bg-muted/20 p-4"
|
|
289
|
-
>
|
|
290
|
-
<p class="text-sm font-medium">新建并选中部门</p>
|
|
291
|
-
<div class="grid gap-3 sm:grid-cols-2">
|
|
292
|
-
<Input v-model="quickName" placeholder="部门名称" />
|
|
293
|
-
<Input v-model="quickCode" placeholder="部门编码,例如 sales" />
|
|
294
|
-
</div>
|
|
295
|
-
<Select v-model="quickParentId">
|
|
296
|
-
<SelectTrigger class="w-full" aria-label="上级部门">
|
|
297
|
-
<SelectValue placeholder="请选择上级部门" />
|
|
298
|
-
</SelectTrigger>
|
|
299
|
-
<SelectContent>
|
|
300
|
-
<SelectItem
|
|
301
|
-
v-for="department in departments"
|
|
302
|
-
:key="department.id"
|
|
303
|
-
:value="department.id"
|
|
304
|
-
>
|
|
305
|
-
{{ department.name }}
|
|
306
|
-
</SelectItem>
|
|
307
|
-
</SelectContent>
|
|
308
|
-
</Select>
|
|
309
|
-
<Button
|
|
310
|
-
type="button"
|
|
311
|
-
size="sm"
|
|
312
|
-
:disabled="creating || !quickParentId"
|
|
313
|
-
@click="createDepartment"
|
|
314
|
-
>
|
|
315
|
-
{{ creating ? "创建中…" : "创建并选中" }}
|
|
316
|
-
</Button>
|
|
317
|
-
</section>
|
|
318
261
|
<p v-if="error" class="text-sm text-destructive">{{ error }}</p>
|
|
262
|
+
<CreateDepartmentDialog
|
|
263
|
+
v-if="departmentCreateMode"
|
|
264
|
+
v-model:open="departmentDialogOpen"
|
|
265
|
+
:mode="departmentCreateMode"
|
|
266
|
+
:departments="departments"
|
|
267
|
+
@created="selectCreatedDepartment"
|
|
268
|
+
/>
|
|
319
269
|
</div>
|
|
320
270
|
</template>
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { defineComponent } from "vue";
|
|
2
|
+
import { afterEach, describe, expect, test, vi } from "vitest";
|
|
3
|
+
import { flushPromises, mount } from "@vue/test-utils";
|
|
4
|
+
import UserAccountForm from "./UserAccountForm.vue";
|
|
5
|
+
|
|
6
|
+
const api = vi.hoisted(() => ({
|
|
7
|
+
list: vi.fn(),
|
|
8
|
+
loadPasswordPolicy: vi.fn(),
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
vi.mock("@/services/system-api", async () => {
|
|
12
|
+
const actual = await vi.importActual<typeof import("@/services/system-api")>(
|
|
13
|
+
"@/services/system-api",
|
|
14
|
+
);
|
|
15
|
+
return { ...actual, listSystemResource: api.list };
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
vi.mock("@/services/auth-api", () => ({
|
|
19
|
+
loadPasswordPolicy: api.loadPasswordPolicy,
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
afterEach(() => vi.clearAllMocks());
|
|
23
|
+
|
|
24
|
+
describe("UserAccountForm", () => {
|
|
25
|
+
test("新建部门使用独立 Modal,不在用户表单底部追加内容", async () => {
|
|
26
|
+
api.list.mockResolvedValue({
|
|
27
|
+
items: [{ id: "dept_root", code: "root", name: "总部" }],
|
|
28
|
+
total: 1,
|
|
29
|
+
page: 1,
|
|
30
|
+
pageSize: 100,
|
|
31
|
+
});
|
|
32
|
+
api.loadPasswordPolicy.mockResolvedValue({
|
|
33
|
+
minLength: 12,
|
|
34
|
+
maxLength: 128,
|
|
35
|
+
requireUppercase: true,
|
|
36
|
+
requireLowercase: true,
|
|
37
|
+
requireNumber: true,
|
|
38
|
+
requireSpecial: false,
|
|
39
|
+
});
|
|
40
|
+
const dialogStub = defineComponent({
|
|
41
|
+
name: "CreateDepartmentDialog",
|
|
42
|
+
props: { open: Boolean },
|
|
43
|
+
emits: ["update:open", "created"],
|
|
44
|
+
template: '<div data-testid="department-dialog" />',
|
|
45
|
+
});
|
|
46
|
+
const wrapper = mount(UserAccountForm, {
|
|
47
|
+
props: {
|
|
48
|
+
defaults: { roleIds: [], status: "active" },
|
|
49
|
+
permissions: [],
|
|
50
|
+
departmentCreateMode: "create",
|
|
51
|
+
},
|
|
52
|
+
global: {
|
|
53
|
+
stubs: {
|
|
54
|
+
CreateDepartmentDialog: dialogStub,
|
|
55
|
+
UserRoleSelector: { template: "<div />" },
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
await flushPromises();
|
|
60
|
+
|
|
61
|
+
const dialog = wrapper.getComponent(dialogStub);
|
|
62
|
+
expect(dialog.props("open")).toBe(false);
|
|
63
|
+
const openButton = wrapper
|
|
64
|
+
.findAll("button")
|
|
65
|
+
.find((button) => button.text() === "新建部门");
|
|
66
|
+
await openButton?.trigger("click");
|
|
67
|
+
|
|
68
|
+
expect(dialog.props("open")).toBe(true);
|
|
69
|
+
expect(wrapper.text()).not.toContain("新建并选中部门");
|
|
70
|
+
});
|
|
71
|
+
});
|
|
@@ -1,16 +1,22 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
+
import type { PermissionDefinition } from "@southwind-ai/shared";
|
|
2
3
|
import { onMounted, shallowRef } from "vue";
|
|
3
4
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
4
5
|
import { listSystemResource } from "@/services/system-api";
|
|
6
|
+
import RolePermissionSummary from "./RolePermissionSummary.vue";
|
|
5
7
|
|
|
6
8
|
interface RoleOption {
|
|
7
9
|
id: string;
|
|
8
10
|
code: string;
|
|
9
11
|
name: string;
|
|
10
12
|
dataScope: string;
|
|
13
|
+
permissionKeys: string[];
|
|
11
14
|
}
|
|
12
15
|
|
|
13
|
-
const props = defineProps<{
|
|
16
|
+
const props = defineProps<{
|
|
17
|
+
modelValue: string[];
|
|
18
|
+
permissions: PermissionDefinition[];
|
|
19
|
+
}>();
|
|
14
20
|
const emit = defineEmits<{ "update:modelValue": [value: string[]] }>();
|
|
15
21
|
const roles = shallowRef<RoleOption[]>([]);
|
|
16
22
|
const loading = shallowRef(false);
|
|
@@ -37,6 +43,11 @@ async function loadRoles() {
|
|
|
37
43
|
name: item.name,
|
|
38
44
|
dataScope:
|
|
39
45
|
typeof item.dataScope === "string" ? item.dataScope : "self",
|
|
46
|
+
permissionKeys: Array.isArray(item.permissionKeys)
|
|
47
|
+
? item.permissionKeys.filter(
|
|
48
|
+
(key): key is string => typeof key === "string",
|
|
49
|
+
)
|
|
50
|
+
: [],
|
|
40
51
|
},
|
|
41
52
|
]
|
|
42
53
|
: [],
|
|
@@ -78,34 +89,44 @@ function dataScopeLabel(scope: string): string {
|
|
|
78
89
|
<label class="text-sm font-medium">账号角色</label>
|
|
79
90
|
<span class="text-xs text-muted-foreground">至少选择一个</span>
|
|
80
91
|
</div>
|
|
81
|
-
<div
|
|
82
|
-
class="
|
|
83
|
-
|
|
84
|
-
>
|
|
85
|
-
<p v-if="loading" class="text-sm text-muted-foreground sm:col-span-2">
|
|
86
|
-
正在加载角色…
|
|
87
|
-
</p>
|
|
88
|
-
<p v-else-if="error" class="text-sm text-destructive sm:col-span-2">
|
|
92
|
+
<div class="grid gap-3 rounded-lg border p-3" aria-label="账号角色">
|
|
93
|
+
<p v-if="loading" class="text-sm text-muted-foreground">正在加载角色…</p>
|
|
94
|
+
<p v-else-if="error" class="text-sm text-destructive">
|
|
89
95
|
{{ error }}
|
|
90
96
|
</p>
|
|
91
|
-
<
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
97
|
+
<template v-else>
|
|
98
|
+
<article
|
|
99
|
+
v-for="role in roles"
|
|
100
|
+
:key="role.id"
|
|
101
|
+
class="rounded-lg border p-3 transition-colors"
|
|
102
|
+
:class="
|
|
103
|
+
modelValue.includes(role.id)
|
|
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>
|
|
129
|
+
</template>
|
|
109
130
|
</div>
|
|
110
131
|
</div>
|
|
111
132
|
</template>
|