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.
- package/README.md +4 -3
- package/dist/cli.js +534 -76
- package/package.json +1 -1
- package/template/.windy-template.json +2 -2
- package/template/AGENTS.md +11 -1
- package/template/README.md +8 -5
- package/template/apps/server/Dockerfile +3 -3
- package/template/apps/server/src/index.ts +8 -14
- package/template/apps/server/src/settings/drizzle-watermark-repository.integration.test.ts +57 -0
- package/template/apps/server/src/settings/drizzle-watermark-repository.ts +62 -0
- package/template/apps/server/src/settings/runtime.ts +30 -3
- package/template/apps/server/src/settings/watermark-repository.ts +32 -0
- package/template/apps/server/src/settings/watermark-routes.test.ts +203 -0
- package/template/apps/server/src/settings/watermark-routes.ts +134 -0
- package/template/apps/web/runtime-server.test.ts +4 -4
- package/template/apps/web/src/composables/useWatermarkSettings.ts +75 -33
- package/template/apps/web/src/composables/useWatermarkSettings.webtest.ts +86 -0
- package/template/apps/web/src/composables/watermark-settings.ts +9 -35
- package/template/apps/web/src/composables/watermark-settings.webtest.ts +8 -32
- package/template/apps/web/src/layout/GlobalWatermark.layering.webtest.ts +81 -0
- package/template/apps/web/src/layout/GlobalWatermark.vue +34 -31
- package/template/apps/web/src/layout/GlobalWatermark.webtest.ts +58 -7
- package/template/apps/web/src/pages/settings/GlobalSettingsPage.vue +1 -1
- package/template/apps/web/src/pages/settings/WatermarkSettingsSection.vue +40 -18
- package/template/apps/web/src/services/platform-settings-api.ts +25 -2
- package/template/apps/web/vite.config.ts +3 -1
- package/template/docker-compose.yml +19 -6
- package/template/docs/platform/platform-shell-settings.md +24 -3
- package/template/docs/platform/web-system-crud.md +2 -2
- package/template/drizzle.config.ts +1 -1
- package/template/package.json +4 -1
- package/template/packages/database/drizzle/0031_panoramic_typhoid_mary.sql +12 -0
- package/template/packages/database/drizzle/meta/0031_snapshot.json +7387 -0
- package/template/packages/database/drizzle/meta/_journal.json +8 -1
- package/template/packages/database/src/schema/platform-settings.ts +16 -1
- package/template/packages/modules/src/system-api-permissions.ts +4 -0
- package/template/packages/modules/src/system-api-watermark.ts +26 -0
- package/template/packages/modules/src/system-features.ts +1 -1
- package/template/packages/modules/src/system-watermark-policy.test.ts +31 -0
- 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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
6
|
-
parseWatermarkSettings,
|
|
7
|
-
type WatermarkSettings,
|
|
14
|
+
normalizeWatermarkPolicy,
|
|
8
15
|
} from "./watermark-settings";
|
|
9
16
|
|
|
10
|
-
const
|
|
11
|
-
|
|
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
|
-
|
|
34
|
+
policy: readonly(policy),
|
|
37
35
|
content,
|
|
38
|
-
|
|
39
|
-
|
|
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
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
|
76
|
+
async function save(
|
|
77
|
+
next: PlatformWatermarkPolicy,
|
|
78
|
+
): Promise<PlatformWatermarkPolicy> {
|
|
79
|
+
saving.value = true;
|
|
80
|
+
error.value = "";
|
|
52
81
|
try {
|
|
53
|
-
|
|
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
|
|
29
|
-
value:
|
|
30
|
-
):
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
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
|
-
|
|
35
|
-
|
|
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
|
-
|
|
20
|
-
()
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
<
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
98
|
-
...state.
|
|
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
|