create-windy 0.6.18 → 0.6.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/dist/cli.js +133 -54
  2. package/package.json +1 -1
  3. package/template/.windy-template.json +2 -2
  4. package/template/README.md +5 -2
  5. package/template/apps/agent-server/README.md +6 -0
  6. package/template/apps/agent-server/src/southwind_agent_server/app.py +23 -0
  7. package/template/apps/agent-server/src/southwind_agent_server/health_routes.py +9 -0
  8. package/template/apps/agent-server/src/southwind_agent_server/runtime_compatibility.py +89 -0
  9. package/template/apps/agent-server/tests/test_runtime_compatibility.py +119 -0
  10. package/template/apps/server/src/agent/remote-runtime-compatibility.test.ts +147 -0
  11. package/template/apps/server/src/agent/remote-runtime.ts +66 -4
  12. package/template/apps/server/src/agent/runtime-bootstrap.ts +1 -0
  13. package/template/apps/web/src/components/common/FormDialog.vue +1 -0
  14. package/template/apps/web/src/components/common/ModalLayout.webtest.ts +1 -0
  15. package/template/apps/web/src/components/ui/dialog/DialogContent.vue +1 -1
  16. package/template/apps/web/src/components/ui/dialog/DialogScrollContent.vue +1 -1
  17. package/template/apps/web/src/pages/system/components/SystemResourceEditor.vue +1 -0
  18. package/template/apps/web/src/pages/system/components/user-account/CreateDepartmentDialog.vue +182 -0
  19. package/template/apps/web/src/pages/system/components/user-account/CreateDepartmentDialog.webtest.ts +93 -0
  20. package/template/apps/web/src/pages/system/components/user-account/RolePermissionSummary.vue +59 -0
  21. package/template/apps/web/src/pages/system/components/user-account/UserAccountForm.vue +21 -71
  22. package/template/apps/web/src/pages/system/components/user-account/UserAccountForm.webtest.ts +71 -0
  23. package/template/apps/web/src/pages/system/components/user-account/UserRoleSelector.vue +48 -27
  24. package/template/apps/web/src/pages/system/components/user-account/UserRoleSelector.webtest.ts +30 -1
  25. package/template/docs/architecture/agent-runtime-compatibility.md +107 -0
  26. package/template/docs/architecture/ai-runtime.md +8 -15
  27. package/template/docs/platform/agent-runtime.md +5 -0
  28. package/template/packages/server-sdk/contracts/agent-runtime-compatibility/v1/offer.json +22 -0
  29. package/template/packages/server-sdk/index.ts +19 -0
  30. package/template/packages/server-sdk/package.json +2 -0
  31. package/template/packages/server-sdk/src/agent-runtime-compatibility.contract.test.ts +143 -0
  32. package/template/packages/server-sdk/src/agent-runtime-compatibility.public-api.test.ts +19 -0
  33. 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>
@@ -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 { PASSWORD_POLICY, type PasswordPolicy } from "@southwind-ai/shared";
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 quickOpen = shallowRef(false);
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 createDepartment() {
118
- if (!props.departmentCreateMode || creating.value) return;
119
- creating.value = true;
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="quickOpen = !quickOpen"
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<{ modelValue: string[] }>();
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="grid gap-2 rounded-lg border p-3 sm:grid-cols-2"
83
- aria-label="账号角色"
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
- <label
92
- v-for="role in roles"
93
- v-else
94
- :key="role.id"
95
- class="flex cursor-pointer items-start gap-3 rounded-md p-2 hover:bg-muted/60"
96
- >
97
- <Checkbox
98
- :model-value="modelValue.includes(role.id)"
99
- :aria-label="role.name"
100
- @update:model-value="toggle(role.id, $event)"
101
- />
102
- <span class="grid gap-0.5 text-sm">
103
- <span class="font-medium">{{ role.name }}</span>
104
- <span class="text-xs text-muted-foreground">
105
- {{ role.code }} · {{ dataScopeLabel(role.dataScope) }}
106
- </span>
107
- </span>
108
- </label>
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>