create-windy 0.2.17 → 0.2.18
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 +68 -18
- 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
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { PlatformWatermarkPolicy } from "@southwind-ai/shared";
|
|
3
|
+
import { createFoundationSnapshot } from "../foundation.js";
|
|
4
|
+
import type { RequestActor } from "../guards.js";
|
|
5
|
+
import { createServerRuntime } from "../runtime.js";
|
|
6
|
+
import type { RouteApp } from "../system/route-types.js";
|
|
7
|
+
import {
|
|
8
|
+
InMemoryWatermarkPolicyRepository,
|
|
9
|
+
type WatermarkPolicyRepository,
|
|
10
|
+
} from "./watermark-repository.js";
|
|
11
|
+
import { registerWatermarkPolicyRoutes } from "./watermark-routes.js";
|
|
12
|
+
|
|
13
|
+
class FakeRouteApp implements RouteApp {
|
|
14
|
+
readonly handlers = new Map<string, (context: unknown) => unknown>();
|
|
15
|
+
|
|
16
|
+
get(path: string, handler: (context: unknown) => unknown): void {
|
|
17
|
+
this.handlers.set(`GET ${path}`, handler);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
post(): void {}
|
|
21
|
+
|
|
22
|
+
put(path: string, handler: (context: unknown) => unknown): void {
|
|
23
|
+
this.handlers.set(`PUT ${path}`, handler);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
delete(): void {}
|
|
27
|
+
|
|
28
|
+
invoke(method: "GET" | "PUT", path: string, context: unknown = {}): unknown {
|
|
29
|
+
const handler = this.handlers.get(`${method} ${path}`);
|
|
30
|
+
if (!handler) throw new Error(`未注册路由 ${method} ${path}`);
|
|
31
|
+
return handler(context);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe("平台水印策略路由", () => {
|
|
36
|
+
test("公开端点无需登录即可读取全局策略", async () => {
|
|
37
|
+
const fixture = await createFixture(anonymousActor());
|
|
38
|
+
|
|
39
|
+
const policy = (await fixture.app.invoke(
|
|
40
|
+
"GET",
|
|
41
|
+
"/platform/watermark-policy",
|
|
42
|
+
)) as PlatformWatermarkPolicy;
|
|
43
|
+
|
|
44
|
+
expect(policy).toEqual({
|
|
45
|
+
enabled: true,
|
|
46
|
+
businessPagesEnabled: false,
|
|
47
|
+
customContent: "",
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("管理员保存的策略对公开端点生效并写入审计", async () => {
|
|
52
|
+
const fixture = await createFixture(settingsActor(true));
|
|
53
|
+
const input: PlatformWatermarkPolicy = {
|
|
54
|
+
enabled: true,
|
|
55
|
+
businessPagesEnabled: true,
|
|
56
|
+
customContent: "内部资料",
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const updated = (await fixture.app.invoke(
|
|
60
|
+
"PUT",
|
|
61
|
+
"/system/settings/watermark",
|
|
62
|
+
{
|
|
63
|
+
guardContext: fixture.guardContext,
|
|
64
|
+
body: input,
|
|
65
|
+
params: {},
|
|
66
|
+
query: {},
|
|
67
|
+
},
|
|
68
|
+
)) as PlatformWatermarkPolicy;
|
|
69
|
+
const publicPolicy = (await fixture.app.invoke(
|
|
70
|
+
"GET",
|
|
71
|
+
"/platform/watermark-policy",
|
|
72
|
+
)) as PlatformWatermarkPolicy;
|
|
73
|
+
|
|
74
|
+
expect(updated).toEqual(input);
|
|
75
|
+
expect(publicPolicy).toEqual(input);
|
|
76
|
+
expect(fixture.runtime.auditSink.list()).toEqual(
|
|
77
|
+
expect.arrayContaining([
|
|
78
|
+
expect.objectContaining({
|
|
79
|
+
action: "config.update",
|
|
80
|
+
target: { type: "platform-watermark", id: "platform" },
|
|
81
|
+
metadata: expect.objectContaining({
|
|
82
|
+
changedFields: ["businessPagesEnabled", "customContent"],
|
|
83
|
+
}),
|
|
84
|
+
}),
|
|
85
|
+
]),
|
|
86
|
+
);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("只读管理员可以读取策略但不能修改", async () => {
|
|
90
|
+
const fixture = await createFixture(settingsActor(false));
|
|
91
|
+
|
|
92
|
+
const readable = (await fixture.app.invoke(
|
|
93
|
+
"GET",
|
|
94
|
+
"/system/settings/watermark",
|
|
95
|
+
{ guardContext: fixture.guardContext, params: {}, query: {} },
|
|
96
|
+
)) as PlatformWatermarkPolicy;
|
|
97
|
+
const denied = (await update(fixture, {
|
|
98
|
+
enabled: false,
|
|
99
|
+
businessPagesEnabled: false,
|
|
100
|
+
customContent: "",
|
|
101
|
+
})) as Response;
|
|
102
|
+
|
|
103
|
+
expect(readable.enabled).toBe(true);
|
|
104
|
+
expect(denied.status).toBe(403);
|
|
105
|
+
await expect(denied.json()).resolves.toMatchObject({
|
|
106
|
+
reason: "missing-permission",
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("Feature 关闭时拒绝管理读取但公开策略仍可读取", async () => {
|
|
111
|
+
const fixture = await createFixture(settingsActor(true), false);
|
|
112
|
+
|
|
113
|
+
const denied = (await fixture.app.invoke(
|
|
114
|
+
"GET",
|
|
115
|
+
"/system/settings/watermark",
|
|
116
|
+
{ guardContext: fixture.guardContext, params: {}, query: {} },
|
|
117
|
+
)) as Response;
|
|
118
|
+
const publicPolicy = (await fixture.app.invoke(
|
|
119
|
+
"GET",
|
|
120
|
+
"/platform/watermark-policy",
|
|
121
|
+
)) as PlatformWatermarkPolicy;
|
|
122
|
+
|
|
123
|
+
expect(denied.status).toBe(403);
|
|
124
|
+
expect(publicPolicy.enabled).toBe(true);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("非法策略返回 400 且不会覆盖已保存值", async () => {
|
|
128
|
+
const fixture = await createFixture(settingsActor(true));
|
|
129
|
+
|
|
130
|
+
const response = (await update(fixture, {
|
|
131
|
+
enabled: true,
|
|
132
|
+
businessPagesEnabled: true,
|
|
133
|
+
customContent: "x".repeat(81),
|
|
134
|
+
})) as Response;
|
|
135
|
+
|
|
136
|
+
expect(response.status).toBe(400);
|
|
137
|
+
expect(await fixture.repository.get()).toEqual({
|
|
138
|
+
enabled: true,
|
|
139
|
+
businessPagesEnabled: false,
|
|
140
|
+
customContent: "",
|
|
141
|
+
});
|
|
142
|
+
expect(fixture.runtime.auditSink.list()).toHaveLength(0);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
async function update(
|
|
147
|
+
fixture: Awaited<ReturnType<typeof createFixture>>,
|
|
148
|
+
body: PlatformWatermarkPolicy,
|
|
149
|
+
) {
|
|
150
|
+
return fixture.app.invoke("PUT", "/system/settings/watermark", {
|
|
151
|
+
guardContext: fixture.guardContext,
|
|
152
|
+
body,
|
|
153
|
+
params: {},
|
|
154
|
+
query: {},
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function createFixture(actor: RequestActor, featureEnabled = true) {
|
|
159
|
+
const snapshot = createFoundationSnapshot();
|
|
160
|
+
const repository: WatermarkPolicyRepository =
|
|
161
|
+
new InMemoryWatermarkPolicyRepository();
|
|
162
|
+
const runtime = createServerRuntime(
|
|
163
|
+
{},
|
|
164
|
+
{
|
|
165
|
+
actorResolver: { resolve: () => actor },
|
|
166
|
+
featureResolver: {
|
|
167
|
+
resolve: (key) => ({
|
|
168
|
+
key,
|
|
169
|
+
enabled: featureEnabled,
|
|
170
|
+
visible: "visible",
|
|
171
|
+
}),
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
);
|
|
175
|
+
const app = new FakeRouteApp();
|
|
176
|
+
registerWatermarkPolicyRoutes(app, repository, runtime, snapshot.features);
|
|
177
|
+
const guardContext = await runtime.createGuardContext(
|
|
178
|
+
new Request("http://localhost/api/system/settings/watermark"),
|
|
179
|
+
);
|
|
180
|
+
return { app, repository, runtime, guardContext };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function settingsActor(canManage: boolean): RequestActor {
|
|
184
|
+
return {
|
|
185
|
+
id: "user_settings",
|
|
186
|
+
type: "user",
|
|
187
|
+
name: "配置管理员",
|
|
188
|
+
roleCodes: ["admin"],
|
|
189
|
+
permissionKeys: ["system.watermark.read"].concat(
|
|
190
|
+
canManage ? "system.watermark.manage" : [],
|
|
191
|
+
),
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function anonymousActor(): RequestActor {
|
|
196
|
+
return {
|
|
197
|
+
id: "anonymous",
|
|
198
|
+
type: "anonymous",
|
|
199
|
+
name: "匿名用户",
|
|
200
|
+
roleCodes: [],
|
|
201
|
+
permissionKeys: [],
|
|
202
|
+
};
|
|
203
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ApiPermissionBinding,
|
|
3
|
+
FeatureFlagDefinition,
|
|
4
|
+
PlatformWatermarkPolicy,
|
|
5
|
+
} from "@southwind-ai/shared";
|
|
6
|
+
import { evaluateRouteGuards, findFeature } from "../route-guards.js";
|
|
7
|
+
import type { createServerRuntime } from "../runtime.js";
|
|
8
|
+
import { resourceEvent, routeContext } from "../system/route-helpers.js";
|
|
9
|
+
import type { RouteApp } from "../system/route-types.js";
|
|
10
|
+
import type { WatermarkPolicyRepository } from "./watermark-repository.js";
|
|
11
|
+
|
|
12
|
+
type ServerRuntime = ReturnType<typeof createServerRuntime>;
|
|
13
|
+
|
|
14
|
+
const READ_BINDING = watermarkBinding("GET", "system.watermark.read");
|
|
15
|
+
const MANAGE_BINDING = watermarkBinding("PUT", "system.watermark.manage");
|
|
16
|
+
|
|
17
|
+
export function registerWatermarkPolicyRoutes(
|
|
18
|
+
app: RouteApp,
|
|
19
|
+
repository: WatermarkPolicyRepository,
|
|
20
|
+
runtime: ServerRuntime,
|
|
21
|
+
features: FeatureFlagDefinition[],
|
|
22
|
+
): void {
|
|
23
|
+
const feature = findFeature(features, "system.watermark");
|
|
24
|
+
|
|
25
|
+
app.get("/platform/watermark-policy", () => repository.get());
|
|
26
|
+
|
|
27
|
+
app.get("/system/settings/watermark", async (rawContext) => {
|
|
28
|
+
const { guardContext } = routeContext(rawContext);
|
|
29
|
+
const denied = await guard(runtime, guardContext, READ_BINDING, feature);
|
|
30
|
+
return denied || repository.get();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
app.put("/system/settings/watermark", async (rawContext) => {
|
|
34
|
+
const { body, guardContext } = routeContext(rawContext);
|
|
35
|
+
const denied = await guard(runtime, guardContext, MANAGE_BINDING, feature);
|
|
36
|
+
if (denied) return denied;
|
|
37
|
+
|
|
38
|
+
const parsed = parseWatermarkPolicy(body);
|
|
39
|
+
if (!parsed.ok) {
|
|
40
|
+
return Response.json(
|
|
41
|
+
{ error: "INVALID_WATERMARK_POLICY", message: parsed.message },
|
|
42
|
+
{ status: 400 },
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const before = await repository.get();
|
|
47
|
+
const updated = await repository.update(
|
|
48
|
+
parsed.value,
|
|
49
|
+
guardContext.actor.id,
|
|
50
|
+
);
|
|
51
|
+
await runtime.auditSink.recordCriticalEvent(
|
|
52
|
+
resourceEvent(
|
|
53
|
+
guardContext,
|
|
54
|
+
"config.update",
|
|
55
|
+
"platform-watermark",
|
|
56
|
+
"platform",
|
|
57
|
+
{ changedFields: changedFields(before, updated) },
|
|
58
|
+
),
|
|
59
|
+
);
|
|
60
|
+
return updated;
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function guard(
|
|
65
|
+
runtime: ServerRuntime,
|
|
66
|
+
context: ReturnType<typeof routeContext>["guardContext"],
|
|
67
|
+
permission: ApiPermissionBinding,
|
|
68
|
+
feature: FeatureFlagDefinition | undefined,
|
|
69
|
+
): Promise<Response | undefined> {
|
|
70
|
+
const decision = await evaluateRouteGuards(runtime, context, {
|
|
71
|
+
permission,
|
|
72
|
+
feature,
|
|
73
|
+
});
|
|
74
|
+
if (decision.allowed) return undefined;
|
|
75
|
+
return Response.json(
|
|
76
|
+
{ error: "FORBIDDEN", reason: decision.reason },
|
|
77
|
+
{ status: 403 },
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function watermarkBinding(
|
|
82
|
+
method: "GET" | "PUT",
|
|
83
|
+
permissionKey: string,
|
|
84
|
+
): ApiPermissionBinding {
|
|
85
|
+
return {
|
|
86
|
+
method,
|
|
87
|
+
path: "/api/system/settings/watermark",
|
|
88
|
+
permissionKey,
|
|
89
|
+
featureKey: "system.watermark",
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
type WatermarkParseResult =
|
|
94
|
+
| { ok: true; value: PlatformWatermarkPolicy }
|
|
95
|
+
| { ok: false; message: string };
|
|
96
|
+
|
|
97
|
+
function parseWatermarkPolicy(body: unknown): WatermarkParseResult {
|
|
98
|
+
if (!body || typeof body !== "object") {
|
|
99
|
+
return { ok: false, message: "请求体必须是对象" };
|
|
100
|
+
}
|
|
101
|
+
const source = body as Record<string, unknown>;
|
|
102
|
+
if (
|
|
103
|
+
typeof source.enabled !== "boolean" ||
|
|
104
|
+
typeof source.businessPagesEnabled !== "boolean"
|
|
105
|
+
) {
|
|
106
|
+
return { ok: false, message: "水印开关必须是布尔值" };
|
|
107
|
+
}
|
|
108
|
+
if (typeof source.customContent !== "string") {
|
|
109
|
+
return { ok: false, message: "自定义内容必须是字符串" };
|
|
110
|
+
}
|
|
111
|
+
const customContent = source.customContent.trim();
|
|
112
|
+
if (customContent.length > 80) {
|
|
113
|
+
return { ok: false, message: "自定义内容不能超过 80 个字符" };
|
|
114
|
+
}
|
|
115
|
+
return {
|
|
116
|
+
ok: true,
|
|
117
|
+
value: {
|
|
118
|
+
enabled: source.enabled,
|
|
119
|
+
businessPagesEnabled: source.businessPagesEnabled,
|
|
120
|
+
customContent,
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function changedFields(
|
|
126
|
+
before: PlatformWatermarkPolicy,
|
|
127
|
+
after: PlatformWatermarkPolicy,
|
|
128
|
+
): string[] {
|
|
129
|
+
return ["enabled", "businessPagesEnabled", "customContent"].filter(
|
|
130
|
+
(field) =>
|
|
131
|
+
before[field as keyof PlatformWatermarkPolicy] !==
|
|
132
|
+
after[field as keyof PlatformWatermarkPolicy],
|
|
133
|
+
);
|
|
134
|
+
}
|
|
@@ -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
|
}
|