create-windy 0.2.17 → 0.2.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 (40) hide show
  1. package/README.md +4 -3
  2. package/dist/cli.js +534 -76
  3. package/package.json +1 -1
  4. package/template/.windy-template.json +2 -2
  5. package/template/AGENTS.md +11 -1
  6. package/template/README.md +8 -5
  7. package/template/apps/server/Dockerfile +3 -3
  8. package/template/apps/server/src/index.ts +8 -14
  9. package/template/apps/server/src/settings/drizzle-watermark-repository.integration.test.ts +57 -0
  10. package/template/apps/server/src/settings/drizzle-watermark-repository.ts +62 -0
  11. package/template/apps/server/src/settings/runtime.ts +30 -3
  12. package/template/apps/server/src/settings/watermark-repository.ts +32 -0
  13. package/template/apps/server/src/settings/watermark-routes.test.ts +203 -0
  14. package/template/apps/server/src/settings/watermark-routes.ts +134 -0
  15. package/template/apps/web/runtime-server.test.ts +4 -4
  16. package/template/apps/web/src/composables/useWatermarkSettings.ts +75 -33
  17. package/template/apps/web/src/composables/useWatermarkSettings.webtest.ts +86 -0
  18. package/template/apps/web/src/composables/watermark-settings.ts +9 -35
  19. package/template/apps/web/src/composables/watermark-settings.webtest.ts +8 -32
  20. package/template/apps/web/src/layout/GlobalWatermark.layering.webtest.ts +81 -0
  21. package/template/apps/web/src/layout/GlobalWatermark.vue +34 -31
  22. package/template/apps/web/src/layout/GlobalWatermark.webtest.ts +58 -7
  23. package/template/apps/web/src/pages/settings/GlobalSettingsPage.vue +1 -1
  24. package/template/apps/web/src/pages/settings/WatermarkSettingsSection.vue +40 -18
  25. package/template/apps/web/src/services/platform-settings-api.ts +25 -2
  26. package/template/apps/web/vite.config.ts +3 -1
  27. package/template/docker-compose.yml +19 -6
  28. package/template/docs/platform/platform-shell-settings.md +24 -3
  29. package/template/docs/platform/web-system-crud.md +2 -2
  30. package/template/drizzle.config.ts +1 -1
  31. package/template/package.json +4 -1
  32. package/template/packages/database/drizzle/0031_panoramic_typhoid_mary.sql +12 -0
  33. package/template/packages/database/drizzle/meta/0031_snapshot.json +7387 -0
  34. package/template/packages/database/drizzle/meta/_journal.json +8 -1
  35. package/template/packages/database/src/schema/platform-settings.ts +16 -1
  36. package/template/packages/modules/src/system-api-permissions.ts +4 -0
  37. package/template/packages/modules/src/system-api-watermark.ts +26 -0
  38. package/template/packages/modules/src/system-features.ts +1 -1
  39. package/template/packages/modules/src/system-watermark-policy.test.ts +31 -0
  40. package/template/packages/shared/src/platform-settings.ts +13 -0
@@ -15,7 +15,7 @@ describe("Web 生产同源 API 代理", () => {
15
15
  );
16
16
  const handler = createWebFetchHandler({
17
17
  distDir: "/tmp/not-used",
18
- apiUpstream: "http://server:3000",
18
+ apiUpstream: "http://server:9747",
19
19
  fetcher,
20
20
  });
21
21
 
@@ -30,7 +30,7 @@ describe("Web 生产同源 API 代理", () => {
30
30
  );
31
31
 
32
32
  const [target, init] = fetcher.mock.calls[0]!;
33
- expect(String(target)).toBe("http://server:3000/api/auth/session?fresh=1");
33
+ expect(String(target)).toBe("http://server:9747/api/auth/session?fresh=1");
34
34
  const headers = new Headers(init?.headers);
35
35
  expect(headers.get("cookie")).toBe("windy.sid=session");
36
36
  expect(headers.get("origin")).toBe("https://platform.example");
@@ -63,7 +63,7 @@ describe("Web 生产同源 API 代理", () => {
63
63
  const fetcher = mock(async () => Response.json({ status: "ready" }));
64
64
  const handler = createWebFetchHandler({
65
65
  distDir,
66
- apiUpstream: "http://server:3000",
66
+ apiUpstream: "http://server:9747",
67
67
  fetcher,
68
68
  });
69
69
  try {
@@ -77,7 +77,7 @@ describe("Web 生产同源 API 代理", () => {
77
77
  expect(ready.status).toBe(200);
78
78
  expect(ready.headers.get("x-request-id")).toBe("readiness-1");
79
79
  expect(String(fetcher.mock.calls[0]?.[0])).toBe(
80
- "http://server:3000/api/ready",
80
+ "http://server:9747/api/ready",
81
81
  );
82
82
  } finally {
83
83
  await rm(distDir, { recursive: true, force: true });
@@ -1,57 +1,99 @@
1
1
  import { computed, readonly, shallowRef, type Ref } from "vue";
2
2
  import type { WebAccessActor } from "@/layout/navigation";
3
+ import {
4
+ loadPlatformWatermarkPolicy,
5
+ loadPublicWatermarkPolicy,
6
+ updatePlatformWatermarkPolicy,
7
+ } from "@/services/platform-settings-api";
8
+ import {
9
+ DEFAULT_PLATFORM_WATERMARK_POLICY,
10
+ type PlatformWatermarkPolicy,
11
+ } from "@southwind-ai/shared";
3
12
  import {
4
13
  defaultWatermarkContent,
5
- defaultWatermarkSettings,
6
- parseWatermarkSettings,
7
- type WatermarkSettings,
14
+ normalizeWatermarkPolicy,
8
15
  } from "./watermark-settings";
9
16
 
10
- const storageKey = "windy.watermark.settings";
11
- const settings = shallowRef<WatermarkSettings>(loadSettings());
17
+ const policy = shallowRef<PlatformWatermarkPolicy>({
18
+ ...DEFAULT_PLATFORM_WATERMARK_POLICY,
19
+ });
20
+ const loading = shallowRef(false);
21
+ const saving = shallowRef(false);
22
+ const error = shallowRef("");
23
+ let loaded = false;
24
+ let pending: Promise<PlatformWatermarkPolicy> | undefined;
12
25
 
13
26
  export function useWatermarkSettings(
14
27
  actor: Readonly<Ref<WebAccessActor | undefined>>,
15
28
  ) {
16
29
  const content = computed(
17
- () =>
18
- settings.value.customContent.trim() ||
19
- defaultWatermarkContent(actor.value),
30
+ () => policy.value.customContent || defaultWatermarkContent(actor.value),
20
31
  );
21
32
 
22
- function update(next: WatermarkSettings) {
23
- settings.value = {
24
- enabled: next.enabled,
25
- businessPagesEnabled: next.businessPagesEnabled,
26
- customContent: next.customContent.trim().slice(0, 80),
27
- };
28
- persistSettings(settings.value);
29
- }
30
-
31
- function reset() {
32
- update({ ...defaultWatermarkSettings });
33
- }
34
-
35
33
  return {
36
- settings: readonly(settings),
34
+ policy: readonly(policy),
37
35
  content,
38
- update,
39
- reset,
36
+ loading: readonly(loading),
37
+ saving: readonly(saving),
38
+ error: readonly(error),
39
+ ensureLoaded: () => load(false, "public"),
40
+ refresh: () => load(true, "public"),
41
+ loadManaged: () => load(true, "managed"),
42
+ save,
40
43
  };
41
44
  }
42
45
 
43
- function loadSettings(): WatermarkSettings {
44
- try {
45
- return parseWatermarkSettings(localStorage.getItem(storageKey));
46
- } catch {
47
- return { ...defaultWatermarkSettings };
46
+ async function load(
47
+ force: boolean,
48
+ source: "public" | "managed",
49
+ ): Promise<PlatformWatermarkPolicy> {
50
+ if (pending) {
51
+ if (source === "public") return pending;
52
+ await pending;
48
53
  }
54
+ if (!force && loaded) return policy.value;
55
+ loading.value = true;
56
+ error.value = "";
57
+ const request =
58
+ source === "managed"
59
+ ? loadPlatformWatermarkPolicy()
60
+ : loadPublicWatermarkPolicy();
61
+ const operation = request
62
+ .then(applyPolicy)
63
+ .catch((currentError: unknown) => {
64
+ error.value = message(currentError);
65
+ if (source === "managed") throw currentError;
66
+ return policy.value;
67
+ })
68
+ .finally(() => {
69
+ loading.value = false;
70
+ if (pending === operation) pending = undefined;
71
+ });
72
+ pending = operation;
73
+ return operation;
49
74
  }
50
75
 
51
- function persistSettings(value: WatermarkSettings) {
76
+ async function save(
77
+ next: PlatformWatermarkPolicy,
78
+ ): Promise<PlatformWatermarkPolicy> {
79
+ saving.value = true;
80
+ error.value = "";
52
81
  try {
53
- localStorage.setItem(storageKey, JSON.stringify(value));
54
- } catch {
55
- // 禁用浏览器存储时,设置仍在当前页面会话中生效。
82
+ return applyPolicy(await updatePlatformWatermarkPolicy(next));
83
+ } catch (currentError) {
84
+ error.value = message(currentError);
85
+ throw currentError;
86
+ } finally {
87
+ saving.value = false;
56
88
  }
57
89
  }
90
+
91
+ function applyPolicy(next: PlatformWatermarkPolicy): PlatformWatermarkPolicy {
92
+ policy.value = normalizeWatermarkPolicy(next);
93
+ loaded = true;
94
+ return policy.value;
95
+ }
96
+
97
+ function message(value: unknown): string {
98
+ return value instanceof Error ? value.message : String(value);
99
+ }
@@ -0,0 +1,86 @@
1
+ import { shallowRef } from "vue";
2
+ import { beforeEach, describe, expect, test, vi } from "vitest";
3
+
4
+ const api = vi.hoisted(() => ({
5
+ loadPublic: vi.fn(),
6
+ loadManaged: vi.fn(),
7
+ update: vi.fn(),
8
+ }));
9
+
10
+ vi.mock("@/services/platform-settings-api", () => ({
11
+ loadPublicWatermarkPolicy: api.loadPublic,
12
+ loadPlatformWatermarkPolicy: api.loadManaged,
13
+ updatePlatformWatermarkPolicy: api.update,
14
+ }));
15
+
16
+ describe("全局水印策略状态", () => {
17
+ beforeEach(() => {
18
+ vi.resetModules();
19
+ api.loadPublic.mockReset();
20
+ api.loadManaged.mockReset();
21
+ api.update.mockReset();
22
+ });
23
+
24
+ test("业务端只读加载公开策略且不访问 localStorage", async () => {
25
+ const localStorageRead = vi.spyOn(Storage.prototype, "getItem");
26
+ const localStorageWrite = vi.spyOn(Storage.prototype, "setItem");
27
+ api.loadPublic.mockResolvedValue({
28
+ enabled: true,
29
+ businessPagesEnabled: true,
30
+ customContent: "全局审计",
31
+ });
32
+ const { useWatermarkSettings } = await import("./useWatermarkSettings");
33
+ const watermark = useWatermarkSettings(shallowRef(undefined));
34
+
35
+ await watermark.ensureLoaded();
36
+
37
+ expect(watermark.policy.value).toEqual({
38
+ enabled: true,
39
+ businessPagesEnabled: true,
40
+ customContent: "全局审计",
41
+ });
42
+ expect(localStorageRead).not.toHaveBeenCalled();
43
+ expect(localStorageWrite).not.toHaveBeenCalled();
44
+ });
45
+
46
+ test("管理端保存后更新所有调用方共享的策略", async () => {
47
+ api.loadManaged.mockResolvedValue({
48
+ enabled: true,
49
+ businessPagesEnabled: false,
50
+ customContent: "",
51
+ });
52
+ api.update.mockResolvedValue({
53
+ enabled: true,
54
+ businessPagesEnabled: true,
55
+ customContent: " 内部资料 ",
56
+ });
57
+ const { useWatermarkSettings } = await import("./useWatermarkSettings");
58
+ const actor = shallowRef({
59
+ id: "user_1",
60
+ type: "user" as const,
61
+ name: "张三",
62
+ roleCodes: [],
63
+ permissionKeys: [],
64
+ });
65
+ const first = useWatermarkSettings(actor);
66
+ const second = useWatermarkSettings(actor);
67
+
68
+ await first.loadManaged();
69
+ await first.save({
70
+ enabled: true,
71
+ businessPagesEnabled: true,
72
+ customContent: "内部资料",
73
+ });
74
+
75
+ expect(api.update).toHaveBeenCalledWith({
76
+ enabled: true,
77
+ businessPagesEnabled: true,
78
+ customContent: "内部资料",
79
+ });
80
+ expect(second.policy.value).toEqual({
81
+ enabled: true,
82
+ businessPagesEnabled: true,
83
+ customContent: "内部资料",
84
+ });
85
+ });
86
+ });
@@ -1,16 +1,5 @@
1
1
  import type { WebAccessActor } from "@/layout/navigation";
2
-
3
- export interface WatermarkSettings {
4
- enabled: boolean;
5
- businessPagesEnabled: boolean;
6
- customContent: string;
7
- }
8
-
9
- export const defaultWatermarkSettings: WatermarkSettings = {
10
- enabled: true,
11
- businessPagesEnabled: false,
12
- customContent: "",
13
- };
2
+ import type { PlatformWatermarkPolicy } from "@southwind-ai/shared";
14
3
 
15
4
  export function defaultWatermarkContent(
16
5
  actor: WebAccessActor | undefined,
@@ -25,27 +14,12 @@ export function defaultWatermarkContent(
25
14
  return [...new Set([username, identity].filter(Boolean))].join(" · ");
26
15
  }
27
16
 
28
- export function parseWatermarkSettings(
29
- value: string | null,
30
- ): WatermarkSettings {
31
- if (!value) return { ...defaultWatermarkSettings };
32
- try {
33
- const parsed = JSON.parse(value) as Record<string, unknown>;
34
- return {
35
- enabled:
36
- typeof parsed.enabled === "boolean"
37
- ? parsed.enabled
38
- : defaultWatermarkSettings.enabled,
39
- businessPagesEnabled:
40
- typeof parsed.businessPagesEnabled === "boolean"
41
- ? parsed.businessPagesEnabled
42
- : defaultWatermarkSettings.businessPagesEnabled,
43
- customContent:
44
- typeof parsed.customContent === "string"
45
- ? parsed.customContent.slice(0, 80)
46
- : "",
47
- };
48
- } catch {
49
- return { ...defaultWatermarkSettings };
50
- }
17
+ export function normalizeWatermarkPolicy(
18
+ value: PlatformWatermarkPolicy,
19
+ ): PlatformWatermarkPolicy {
20
+ return {
21
+ enabled: value.enabled,
22
+ businessPagesEnabled: value.businessPagesEnabled,
23
+ customContent: value.customContent.trim().slice(0, 80),
24
+ };
51
25
  }
@@ -1,7 +1,7 @@
1
1
  import { describe, expect, test } from "vitest";
2
2
  import {
3
3
  defaultWatermarkContent,
4
- parseWatermarkSettings,
4
+ normalizeWatermarkPolicy,
5
5
  } from "./watermark-settings";
6
6
 
7
7
  describe("水印设置", () => {
@@ -24,41 +24,17 @@ describe("水印设置", () => {
24
24
  expect(defaultWatermarkContent(base)).toBe("zhangsan · 张三");
25
25
  });
26
26
 
27
- test("损坏或越界的浏览器设置安全回退", () => {
28
- expect(parseWatermarkSettings("broken")).toEqual({
29
- enabled: true,
30
- businessPagesEnabled: false,
31
- customContent: "",
32
- });
27
+ test("服务端策略内容会被规范化后渲染", () => {
33
28
  expect(
34
- parseWatermarkSettings(
35
- JSON.stringify({ enabled: false, customContent: "x".repeat(100) }),
36
- ),
29
+ normalizeWatermarkPolicy({
30
+ enabled: false,
31
+ businessPagesEnabled: true,
32
+ customContent: ` ${"x".repeat(100)} `,
33
+ }),
37
34
  ).toEqual({
38
35
  enabled: false,
39
- businessPagesEnabled: false,
40
- customContent: "x".repeat(80),
41
- });
42
- });
43
-
44
- test("业务页面覆盖默认关闭并兼容已保存的范围开关", () => {
45
- expect(parseWatermarkSettings(null)).toEqual({
46
- enabled: true,
47
- businessPagesEnabled: false,
48
- customContent: "",
49
- });
50
- expect(
51
- parseWatermarkSettings(
52
- JSON.stringify({
53
- enabled: true,
54
- businessPagesEnabled: true,
55
- customContent: "内部资料",
56
- }),
57
- ),
58
- ).toEqual({
59
- enabled: true,
60
36
  businessPagesEnabled: true,
61
- customContent: "内部资料",
37
+ customContent: "x".repeat(80),
62
38
  });
63
39
  });
64
40
  });
@@ -0,0 +1,81 @@
1
+ import { mount } from "@vue/test-utils";
2
+ import { afterEach, describe, expect, test, vi } from "vitest";
3
+ import GlobalWatermark from "./GlobalWatermark.vue";
4
+
5
+ const state = vi.hoisted(() => {
6
+ const actor = {
7
+ id: "user_1",
8
+ type: "user" as const,
9
+ name: "张三",
10
+ roleCodes: [],
11
+ permissionKeys: ["system.watermark.read"],
12
+ };
13
+ const policy = {
14
+ value: {
15
+ enabled: true,
16
+ businessPagesEnabled: false,
17
+ customContent: "",
18
+ },
19
+ };
20
+
21
+ return {
22
+ actor: { value: actor },
23
+ access: {
24
+ value: {
25
+ actor,
26
+ menus: [],
27
+ permissions: [],
28
+ features: [
29
+ {
30
+ key: "system.watermark",
31
+ enabled: true,
32
+ visible: "visible" as const,
33
+ allowed: true,
34
+ reason: "enabled",
35
+ },
36
+ ],
37
+ },
38
+ },
39
+ policy,
40
+ content: { value: "zhangsan · 张三" },
41
+ };
42
+ });
43
+
44
+ vi.mock("@/composables/useAccessSnapshot", () => ({
45
+ useAccessSnapshot: () => ({ snapshot: state.access }),
46
+ }));
47
+
48
+ vi.mock("@/composables/useAuthSession", () => ({
49
+ useAuthSession: () => ({ actor: state.actor }),
50
+ }));
51
+
52
+ vi.mock("@/composables/useWatermarkSettings", () => ({
53
+ useWatermarkSettings: () => ({
54
+ settings: state.policy,
55
+ policy: state.policy,
56
+ content: state.content,
57
+ ensureLoaded: vi.fn(async () => state.policy.value),
58
+ }),
59
+ }));
60
+
61
+ afterEach(() => {
62
+ document.body.replaceChildren();
63
+ });
64
+
65
+ describe("GlobalWatermark 层级", () => {
66
+ test("传送到 body 并使用不拦截交互的审计覆盖层", () => {
67
+ const wrapper = mount(GlobalWatermark);
68
+ const watermark = document.body.querySelector(
69
+ '[data-testid="global-watermark"]',
70
+ );
71
+
72
+ expect(watermark).not.toBeNull();
73
+ expect(watermark?.parentElement).toBe(document.body);
74
+ expect(watermark?.classList).toContain("isolate");
75
+ expect(watermark?.classList).toContain("z-[60]");
76
+ expect(watermark?.classList).toContain("transform-gpu");
77
+ expect(watermark?.classList).toContain("pointer-events-none");
78
+
79
+ wrapper.unmount();
80
+ });
81
+ });
@@ -1,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- import { computed } from "vue";
2
+ import { computed, onMounted } from "vue";
3
3
  import { useAuthSession } from "@/composables/useAuthSession";
4
4
  import { useAccessSnapshot } from "@/composables/useAccessSnapshot";
5
5
  import { useWatermarkSettings } from "@/composables/useWatermarkSettings";
@@ -16,40 +16,43 @@ const props = withDefaults(
16
16
  const auth = useAuthSession();
17
17
  const access = useAccessSnapshot();
18
18
  const watermark = useWatermarkSettings(auth.actor);
19
- const allowed = computed(
20
- () =>
21
- props.scope === "business" ||
22
- (access.snapshot.value
23
- ? evaluateWebAccess(access.snapshot.value, {
24
- permissionKey: "system.watermark.read",
25
- featureKey: "system.watermark",
26
- }).allowed
27
- : false),
28
- );
19
+ onMounted(() => {
20
+ void watermark.ensureLoaded();
21
+ });
22
+ const allowed = computed(() => {
23
+ const snapshot = access.snapshot.value;
24
+ if (!snapshot) return false;
25
+ return evaluateWebAccess(snapshot, {
26
+ featureKey: "system.watermark",
27
+ permissionKey:
28
+ props.scope === "admin" ? "system.watermark.read" : undefined,
29
+ }).allowed;
30
+ });
29
31
  const scopeEnabled = computed(
30
- () =>
31
- props.scope === "admin" || watermark.settings.value.businessPagesEnabled,
32
+ () => props.scope === "admin" || watermark.policy.value.businessPagesEnabled,
32
33
  );
33
34
  </script>
34
35
 
35
36
  <template>
36
- <div
37
- v-if="
38
- allowed &&
39
- scopeEnabled &&
40
- watermark.settings.value.enabled &&
41
- watermark.content.value
42
- "
43
- class="pointer-events-none fixed inset-0 z-30 grid grid-cols-3 grid-rows-6 overflow-hidden opacity-[0.075] dark:opacity-[0.11]"
44
- aria-hidden="true"
45
- data-testid="global-watermark"
46
- >
47
- <span
48
- v-for="index in 18"
49
- :key="index"
50
- class="flex -rotate-[22deg] items-center justify-center whitespace-nowrap px-8 text-sm font-medium text-foreground"
37
+ <Teleport to="body">
38
+ <div
39
+ v-if="
40
+ allowed &&
41
+ scopeEnabled &&
42
+ watermark.policy.value.enabled &&
43
+ watermark.content.value
44
+ "
45
+ class="pointer-events-none fixed inset-0 isolate z-[60] grid transform-gpu grid-cols-3 grid-rows-6 overflow-hidden opacity-[0.075] dark:opacity-[0.11]"
46
+ aria-hidden="true"
47
+ data-testid="global-watermark"
51
48
  >
52
- {{ watermark.content.value }}
53
- </span>
54
- </div>
49
+ <span
50
+ v-for="index in 18"
51
+ :key="index"
52
+ class="flex -rotate-[22deg] items-center justify-center whitespace-nowrap px-8 text-sm font-medium text-foreground"
53
+ >
54
+ {{ watermark.content.value }}
55
+ </span>
56
+ </div>
57
+ </Teleport>
55
58
  </template>
@@ -1,4 +1,4 @@
1
- import { mount } from "@vue/test-utils";
1
+ import { config, mount } from "@vue/test-utils";
2
2
  import { beforeEach, describe, expect, test, vi } from "vitest";
3
3
  import GlobalWatermark from "./GlobalWatermark.vue";
4
4
 
@@ -34,7 +34,7 @@ const state = vi.hoisted(() => ({
34
34
  permissionKeys: ["system.watermark.read"],
35
35
  },
36
36
  },
37
- settings: {
37
+ policy: {
38
38
  value: {
39
39
  enabled: true,
40
40
  businessPagesEnabled: false,
@@ -54,13 +54,19 @@ vi.mock("@/composables/useAuthSession", () => ({
54
54
 
55
55
  vi.mock("@/composables/useWatermarkSettings", () => ({
56
56
  useWatermarkSettings: () => ({
57
- settings: state.settings,
57
+ policy: state.policy,
58
58
  content: state.content,
59
+ ensureLoaded: vi.fn(async () => state.policy.value),
59
60
  }),
60
61
  }));
61
62
 
63
+ config.global.stubs = {
64
+ ...config.global.stubs,
65
+ teleport: true,
66
+ };
67
+
62
68
  beforeEach(() => {
63
- state.settings.value = {
69
+ state.policy.value = {
64
70
  enabled: true,
65
71
  businessPagesEnabled: false,
66
72
  customContent: "",
@@ -76,7 +82,7 @@ describe("GlobalWatermark", () => {
76
82
  );
77
83
  wrapper.unmount();
78
84
 
79
- state.settings.value = { ...state.settings.value, enabled: false };
85
+ state.policy.value = { ...state.policy.value, enabled: false };
80
86
  const disabledWrapper = mount(GlobalWatermark);
81
87
 
82
88
  expect(
@@ -94,8 +100,8 @@ describe("GlobalWatermark", () => {
94
100
  );
95
101
  wrapper.unmount();
96
102
 
97
- state.settings.value = {
98
- ...state.settings.value,
103
+ state.policy.value = {
104
+ ...state.policy.value,
99
105
  businessPagesEnabled: true,
100
106
  };
101
107
  const enabledWrapper = mount(GlobalWatermark, {
@@ -105,5 +111,50 @@ describe("GlobalWatermark", () => {
105
111
  expect(
106
112
  enabledWrapper.get('[data-testid="global-watermark"]').text(),
107
113
  ).toContain("zhangsan · 张三");
114
+ expect(
115
+ enabledWrapper.get('[data-testid="global-watermark"]').classes(),
116
+ ).toEqual(
117
+ expect.arrayContaining([
118
+ "isolate",
119
+ "z-[60]",
120
+ "transform-gpu",
121
+ "pointer-events-none",
122
+ ]),
123
+ );
124
+ });
125
+
126
+ test("业务页仍服从平台 Feature 最终有效态", () => {
127
+ state.policy.value = {
128
+ enabled: true,
129
+ businessPagesEnabled: true,
130
+ customContent: "",
131
+ };
132
+ state.access.value.features[0]!.allowed = false;
133
+
134
+ const wrapper = mount(GlobalWatermark, {
135
+ props: { scope: "business" },
136
+ });
137
+
138
+ expect(wrapper.find('[data-testid="global-watermark"]').exists()).toBe(
139
+ false,
140
+ );
141
+ state.access.value.features[0]!.allowed = true;
142
+ });
143
+
144
+ test("水印传送到 body,脱离应用壳的层叠上下文", () => {
145
+ const wrapper = mount(GlobalWatermark, {
146
+ global: {
147
+ stubs: {
148
+ teleport: false,
149
+ },
150
+ },
151
+ });
152
+
153
+ const watermark = document.body.querySelector(
154
+ '[data-testid="global-watermark"]',
155
+ );
156
+ expect(watermark).not.toBeNull();
157
+ expect(watermark?.parentElement).toBe(document.body);
158
+ wrapper.unmount();
108
159
  });
109
160
  });
@@ -143,7 +143,7 @@ function handleBeforeUnload(event: BeforeUnloadEvent) {
143
143
  <header class="space-y-2">
144
144
  <h1 class="text-3xl font-semibold tracking-tight">全局设置</h1>
145
145
  <p class="text-muted-foreground">
146
- 维护面向所有用户的平台品牌信息,以及当前浏览器的页面水印偏好。
146
+ 维护面向所有用户的平台品牌信息与全局页面水印策略。
147
147
  </p>
148
148
  </header>
149
149
  <Tabs