create-windy 0.6.17 → 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.
Files changed (32) hide show
  1. package/dist/cli.js +98 -21
  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/ai-runtime.md +8 -15
  26. package/template/docs/platform/agent-runtime.md +5 -0
  27. package/template/packages/server-sdk/contracts/agent-runtime-compatibility/v1/offer.json +22 -0
  28. package/template/packages/server-sdk/index.ts +19 -0
  29. package/template/packages/server-sdk/package.json +2 -0
  30. package/template/packages/server-sdk/src/agent-runtime-compatibility.contract.test.ts +143 -0
  31. package/template/packages/server-sdk/src/agent-runtime-compatibility.public-api.test.ts +19 -0
  32. package/template/packages/server-sdk/src/agent-runtime-compatibility.ts +284 -0
@@ -0,0 +1,147 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { createAgentRuntimeCompatibilityOfferV1 } from "@southwind-ai/server-sdk";
3
+ import type { AgentRuntimeOperation } from "./run-contracts.js";
4
+ import { RemoteAgentRuntime } from "./remote-runtime.js";
5
+
6
+ const servers: Bun.Server<unknown>[] = [];
7
+
8
+ afterEach(() => {
9
+ for (const server of servers.splice(0)) server.stop(true);
10
+ });
11
+
12
+ describe("RemoteAgentRuntime compatibility", () => {
13
+ test("V1 协商成功后固定协议版本与 Offer 摘要", async () => {
14
+ let startHeaders: Headers | undefined;
15
+ const offer = createAgentRuntimeCompatibilityOfferV1(
16
+ "windy.direct.openai-compatible.v1",
17
+ );
18
+ const server = Bun.serve({
19
+ port: 0,
20
+ fetch(request) {
21
+ if (isCompatibilityRequest(request)) return Response.json(offer);
22
+ startHeaders = request.headers;
23
+ return accepted("run_pinned");
24
+ },
25
+ });
26
+ servers.push(server);
27
+ const runtime = remote(server, "require-v1");
28
+
29
+ await runtime.start(agentOperation(), "pinned-1");
30
+
31
+ expect(startHeaders?.get("x-windy-agent-runtime-protocol")).toBe("1");
32
+ expect(startHeaders?.get("x-windy-agent-runtime-offer-digest")).toBe(
33
+ offer.offerDigest,
34
+ );
35
+ expect(startHeaders?.get("authorization")).toBe("Bearer test-token");
36
+ });
37
+
38
+ test("prefer-v1 只对旧 Runtime 的 404 回退为 legacy", async () => {
39
+ let starts = 0;
40
+ const server = Bun.serve({
41
+ port: 0,
42
+ fetch(request) {
43
+ if (isCompatibilityRequest(request)) {
44
+ return new Response(null, { status: 404 });
45
+ }
46
+ starts += 1;
47
+ return accepted("run_legacy");
48
+ },
49
+ });
50
+ servers.push(server);
51
+
52
+ await expect(
53
+ remote(server, "prefer-v1").start(agentOperation(), "legacy-1"),
54
+ ).resolves.toEqual({
55
+ runId: "run_legacy",
56
+ status: "accepted",
57
+ eventsUrl: "/api/agent/runs/run_legacy/events",
58
+ });
59
+ expect(starts).toBe(1);
60
+ });
61
+
62
+ test("require-v1 拒绝旧 Runtime,畸形 Offer 也不会降级", async () => {
63
+ for (const response of [
64
+ new Response(null, { status: 404 }),
65
+ Response.json({ schemaVersion: 1 }),
66
+ ]) {
67
+ let starts = 0;
68
+ const server = Bun.serve({
69
+ port: 0,
70
+ fetch(request) {
71
+ if (isCompatibilityRequest(request)) return response.clone();
72
+ starts += 1;
73
+ return Response.json({});
74
+ },
75
+ });
76
+ servers.push(server);
77
+
78
+ await expect(
79
+ remote(server, "require-v1").start(agentOperation(), "required-1"),
80
+ ).rejects.toMatchObject({
81
+ code: "RUNTIME_UNAVAILABLE",
82
+ retryable: false,
83
+ });
84
+ expect(starts).toBe(0);
85
+ }
86
+ });
87
+ });
88
+
89
+ function remote(server: Bun.Server<unknown>, mode: "prefer-v1" | "require-v1") {
90
+ return new RemoteAgentRuntime({
91
+ baseUrl: server.url.toString(),
92
+ internalToken: "test-token",
93
+ toolHostUrl: "http://server.internal",
94
+ compatibilityMode: mode,
95
+ });
96
+ }
97
+
98
+ function isCompatibilityRequest(request: Request): boolean {
99
+ return new URL(request.url).pathname === "/api/agent/runtime/compatibility";
100
+ }
101
+
102
+ function accepted(runId: string): Response {
103
+ return Response.json({
104
+ runId,
105
+ status: "accepted",
106
+ eventsUrl: `/api/agent/runs/${runId}/events`,
107
+ });
108
+ }
109
+
110
+ function agentOperation(): AgentRuntimeOperation {
111
+ return {
112
+ kind: "agent",
113
+ operationKey: "business.answer",
114
+ input: { question: "你好" },
115
+ inputModalities: ["text"],
116
+ requiredCapabilities: ["text-generation"],
117
+ artifacts: [],
118
+ instructions: "回答业务问题",
119
+ userPrompt: "你好",
120
+ outputSchemaKey: "business.output.v1",
121
+ allowedTools: [],
122
+ limits: {
123
+ maxDurationSeconds: 60,
124
+ maxInputBytes: 4_096,
125
+ maxOutputBytes: 4_096,
126
+ },
127
+ providerEgress: {
128
+ mode: "configured-endpoint",
129
+ deploymentKey: "ai.openai-compatible",
130
+ },
131
+ providerDataPolicy: { inputFields: [], envelopeFields: [] },
132
+ toolNetworkPolicy: { mode: "forbidden" },
133
+ executionGrant: "grant_1",
134
+ release: {
135
+ releaseId: "release_test",
136
+ operationVersion: "1.0.0",
137
+ workflowVersion: "workflow-1",
138
+ promptVersion: "prompt-1",
139
+ routeVersion: "route-1",
140
+ outputSchemaVersion: "output-schema-1",
141
+ toolSchemaVersion: "schema-1",
142
+ manifestDigest: "0".repeat(64),
143
+ },
144
+ traceId: "trace_1",
145
+ reporting: { actorId: "user_1", actorType: "user" },
146
+ };
147
+ }
@@ -1,8 +1,16 @@
1
- import type {
2
- AgentRuntimeOperation,
3
- AgentRuntimePort,
4
- AgentRuntimeStartResponse,
1
+ import {
2
+ AgentRuntimeError,
3
+ type AgentRuntimeOperation,
4
+ type AgentRuntimePort,
5
+ type AgentRuntimeStartResponse,
5
6
  } from "./run-contracts.js";
7
+ import {
8
+ AGENT_RUNTIME_COMPATIBILITY_PATH_V1,
9
+ AGENT_RUNTIME_OFFER_DIGEST_HEADER_V1,
10
+ AGENT_RUNTIME_PROTOCOL_HEADER_V1,
11
+ localAgentRuntimeCompatibilityRequirementsV1,
12
+ negotiateAgentRuntimeCompatibilityV1,
13
+ } from "@southwind-ai/server-sdk";
6
14
  import { assertVisionReadinessPin } from "./vision-readiness-pin.js";
7
15
  import {
8
16
  parseRemoteStart,
@@ -15,6 +23,8 @@ export interface RemoteAgentRuntimeOptions {
15
23
  internalToken: string;
16
24
  toolHostUrl: string;
17
25
  timeoutMs?: number;
26
+ /** Legacy is the default for direct callers; platform composition opts into rolling V1. */
27
+ compatibilityMode?: "prefer-v1" | "require-v1";
18
28
  }
19
29
 
20
30
  export class RemoteAgentRuntime implements AgentRuntimePort {
@@ -33,6 +43,7 @@ export class RemoteAgentRuntime implements AgentRuntimePort {
33
43
  request: AgentRuntimeOperation,
34
44
  idempotencyKey: string,
35
45
  ): Promise<AgentRuntimeStartResponse> {
46
+ const compatibilityHeaders = await this.compatibilityHeaders();
36
47
  if (request.readinessPin) {
37
48
  await assertVisionReadinessPin(request.readinessPin, () => this.health());
38
49
  }
@@ -103,6 +114,7 @@ export class RemoteAgentRuntime implements AgentRuntimePort {
103
114
  headers: {
104
115
  "content-type": "application/json",
105
116
  "idempotency-key": idempotencyKey,
117
+ ...compatibilityHeaders,
106
118
  },
107
119
  body: JSON.stringify(remoteRequest),
108
120
  });
@@ -160,6 +172,38 @@ export class RemoteAgentRuntime implements AgentRuntimePort {
160
172
  return { deploymentKey, model };
161
173
  }
162
174
 
175
+ private async compatibilityHeaders(): Promise<Record<string, string>> {
176
+ const mode = this.options.compatibilityMode;
177
+ if (!mode) return {};
178
+ let response: Response;
179
+ try {
180
+ response = await this.request(AGENT_RUNTIME_COMPATIBILITY_PATH_V1);
181
+ } catch {
182
+ throw new AgentRuntimeError(
183
+ "RUNTIME_UNAVAILABLE",
184
+ "Agent Runtime compatibility 探测失败",
185
+ true,
186
+ );
187
+ }
188
+ if (response.status === 404 && mode === "prefer-v1") return {};
189
+ if (!response.ok) throw await remoteRuntimeError(response);
190
+ let value: unknown;
191
+ try {
192
+ value = await readBoundedCompatibilityOffer(response);
193
+ } catch {
194
+ throw incompatibleRuntime("CONTRACT_INVALID");
195
+ }
196
+ const negotiation = negotiateAgentRuntimeCompatibilityV1(
197
+ value,
198
+ localAgentRuntimeCompatibilityRequirementsV1(),
199
+ );
200
+ if (!negotiation.ok) throw incompatibleRuntime(negotiation.code);
201
+ return {
202
+ [AGENT_RUNTIME_PROTOCOL_HEADER_V1]: String(negotiation.protocolVersion),
203
+ [AGENT_RUNTIME_OFFER_DIGEST_HEADER_V1]: negotiation.offer.offerDigest,
204
+ };
205
+ }
206
+
163
207
  private async request(
164
208
  path: string,
165
209
  init: RequestInit = {},
@@ -187,6 +231,24 @@ export class RemoteAgentRuntime implements AgentRuntimePort {
187
231
  }
188
232
  }
189
233
 
234
+ function incompatibleRuntime(code: string): AgentRuntimeError {
235
+ return new AgentRuntimeError(
236
+ "RUNTIME_UNAVAILABLE",
237
+ `Agent Runtime compatibility 不匹配(${code})`,
238
+ false,
239
+ );
240
+ }
241
+
242
+ async function readBoundedCompatibilityOffer(
243
+ response: Response,
244
+ ): Promise<unknown> {
245
+ const bytes = new Uint8Array(await response.arrayBuffer());
246
+ if (bytes.byteLength > 64 * 1_024) {
247
+ throw new Error("Agent Runtime compatibility Offer 超过上限");
248
+ }
249
+ return JSON.parse(new TextDecoder().decode(bytes)) as unknown;
250
+ }
251
+
190
252
  function isRecord(value: unknown): value is Record<string, unknown> {
191
253
  return Boolean(value && typeof value === "object" && !Array.isArray(value));
192
254
  }
@@ -104,6 +104,7 @@ export function registerAgentPlatformRuntime(input: AgentPlatformRuntimeInput) {
104
104
  baseUrl: agentServerUrl,
105
105
  internalToken,
106
106
  toolHostUrl: process.env.AGENT_TOOL_HOST_URL || "http://server:9747",
107
+ compatibilityMode: "prefer-v1",
107
108
  });
108
109
  const clientAgentSessionRuntime = createClientAgentSessionRuntime({
109
110
  app: input.app,
@@ -34,6 +34,7 @@ const props = withDefaults(
34
34
  loading: false,
35
35
  submitDisabled: false,
36
36
  preventOutsideClose: false,
37
+ contentClass: "sm:max-w-2xl",
37
38
  },
38
39
  );
39
40
 
@@ -33,6 +33,7 @@ describe("Modal 布局", () => {
33
33
  "min-h-[min(12rem,calc(100dvh-2rem))]",
34
34
  );
35
35
  expect(content?.className).toContain("overflow-hidden");
36
+ expect(content?.className).toContain("sm:max-w-2xl");
36
37
  expect(body?.className).toContain("overflow-y-auto");
37
38
  expect(footer?.className).toContain("shrink-0");
38
39
 
@@ -44,7 +44,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits);
44
44
  v-bind="{ ...$attrs, ...forwarded }"
45
45
  :class="
46
46
  cn(
47
- 'bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 ring-foreground/10 fixed top-1/2 left-1/2 z-50 flex min-h-[min(12rem,calc(100dvh-2rem))] max-h-[calc(100dvh-2rem)] w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 flex-col gap-4 overflow-hidden rounded-xl p-4 text-sm ring-1 duration-100 outline-none sm:max-w-sm',
47
+ 'bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 ring-foreground/10 fixed top-1/2 left-1/2 z-50 flex min-h-[min(12rem,calc(100dvh-2rem))] max-h-[calc(100dvh-2rem)] w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 flex-col gap-4 overflow-hidden rounded-xl p-4 text-sm ring-1 duration-100 outline-none sm:max-w-xl',
48
48
  props.class,
49
49
  )
50
50
  "
@@ -35,7 +35,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits);
35
35
  <DialogContent
36
36
  :class="
37
37
  cn(
38
- 'relative z-50 grid w-full max-w-lg my-8 gap-4 border border-border bg-background p-6 shadow-lg duration-200 sm:rounded-lg md:w-full',
38
+ 'relative z-50 my-8 grid w-full max-w-2xl gap-4 border border-border bg-background p-6 shadow-lg duration-200 sm:rounded-lg md:w-full',
39
39
  props.class,
40
40
  )
41
41
  "
@@ -52,6 +52,7 @@ const title = computed(() => (props.item ? "编辑记录" : "新增记录"));
52
52
  ref="resourceForm"
53
53
  :item="item"
54
54
  :defaults="defaults"
55
+ :permissions="permissions || []"
55
56
  :department-create-mode="departmentCreateMode"
56
57
  @submit="emit('submit', $event)"
57
58
  />
@@ -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>