create-windy 0.2.32 → 0.2.33
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/dist/cli.js +3 -1
- package/package.json +1 -1
- package/template/.windy-template.json +2 -2
- package/template/AGENTS.md +7 -0
- package/template/apps/server/src/settings/drizzle-watermark-repository.integration.test.ts +4 -0
- package/template/apps/server/src/settings/drizzle-watermark-repository.ts +6 -0
- package/template/apps/server/src/settings/watermark-routes.test.ts +13 -3
- package/template/apps/server/src/settings/watermark-routes.ts +41 -3
- package/template/apps/web/src/app/auth/login-page.ts +4 -0
- package/template/apps/web/src/composables/useLoginFlow.webtest.ts +91 -0
- package/template/apps/web/src/composables/useWatermarkSettings.ts +7 -3
- package/template/apps/web/src/composables/useWatermarkSettings.webtest.ts +14 -0
- package/template/apps/web/src/composables/watermark-settings.ts +131 -9
- package/template/apps/web/src/composables/watermark-settings.webtest.ts +73 -7
- package/template/apps/web/src/layout/GlobalWatermark.layering.webtest.ts +3 -1
- package/template/apps/web/src/layout/GlobalWatermark.vue +24 -11
- package/template/apps/web/src/layout/GlobalWatermark.webtest.ts +25 -11
- package/template/apps/web/src/pages/auth/login-page-extension.ts +9 -0
- package/template/apps/web/src/pages/auth/login-page-extension.webtest.ts +10 -0
- package/template/apps/web/src/pages/settings/WatermarkSettingsSection.vue +102 -4
- package/template/apps/web/src/router/index.ts +2 -2
- package/template/apps/web/src/router/shared-scaffold-neutrality.webtest.ts +3 -2
- package/template/docs/development/custom-login-page.md +90 -0
- package/template/docs/platform/platform-shell-settings.md +14 -9
- package/template/docs/platform/web-system-crud.md +1 -1
- package/template/packages/database/drizzle/0034_funny_mesmero.sql +5 -0
- package/template/packages/database/drizzle/meta/0034_snapshot.json +7253 -0
- package/template/packages/database/drizzle/meta/_journal.json +7 -0
- package/template/packages/database/src/schema/platform-settings.ts +13 -0
- package/template/packages/shared/src/platform-settings.ts +17 -1
package/dist/cli.js
CHANGED
|
@@ -1927,6 +1927,7 @@ var templateDocumentationFiles = [
|
|
|
1927
1927
|
"docs/architecture/object-storage.md",
|
|
1928
1928
|
"docs/architecture/search-provider-protocol.md",
|
|
1929
1929
|
"docs/development/crud-generator.md",
|
|
1930
|
+
"docs/development/custom-login-page.md",
|
|
1930
1931
|
"docs/license/license-offline-activation-code.md",
|
|
1931
1932
|
"docs/operations/observability.md",
|
|
1932
1933
|
"docs/platform/agent-tools.md",
|
|
@@ -4362,7 +4363,8 @@ var recipes = [
|
|
|
4362
4363
|
{ id: "starter-0.2.28-to-0.2.29", from: "0.2.28", to: "0.2.29" },
|
|
4363
4364
|
{ id: "starter-0.2.29-to-0.2.30", from: "0.2.29", to: "0.2.30" },
|
|
4364
4365
|
{ id: "starter-0.2.30-to-0.2.31", from: "0.2.30", to: "0.2.31" },
|
|
4365
|
-
{ id: "starter-0.2.31-to-0.2.32", from: "0.2.31", to: "0.2.32" }
|
|
4366
|
+
{ id: "starter-0.2.31-to-0.2.32", from: "0.2.31", to: "0.2.32" },
|
|
4367
|
+
{ id: "starter-0.2.32-to-0.2.33", from: "0.2.32", to: "0.2.33" }
|
|
4366
4368
|
];
|
|
4367
4369
|
function resolveRecipeChain(sourceVersion, targetVersion) {
|
|
4368
4370
|
if (sourceVersion === targetVersion)
|
package/package.json
CHANGED
package/template/AGENTS.md
CHANGED
|
@@ -39,11 +39,18 @@
|
|
|
39
39
|
对 `windy-managed` 路径仅在没有可用扩展点时做最小改动。
|
|
40
40
|
- 确需修改 Windy 受管代码时,使用独立小提交记录原因和影响;升级前先运行
|
|
41
41
|
`bun run windy update --dry-run`,根据三方合并计划处理冲突。
|
|
42
|
+
- 完整定制登录页时,在 project-owned 的
|
|
43
|
+
`apps/web/src/app/auth/login-page.ts` 注册业务组件;不要修改
|
|
44
|
+
`apps/web/src/pages/auth/LoginPage.vue` 或 Router。业务页面复用
|
|
45
|
+
`useLoginFlow`、`DevelopmentAuthWarning` 和平台品牌设置,保留安全
|
|
46
|
+
`returnTo`、真实认证与开发认证提示。
|
|
42
47
|
|
|
43
48
|
## 常用入口
|
|
44
49
|
|
|
45
50
|
- `.agents/skills/windy-business-module/SKILL.md`:新增或修改业务模块、Agent Tool
|
|
46
51
|
或 MCP 暴露时使用的仓库内开发流程。
|
|
52
|
+
- `docs/development/custom-login-page.md`:完整替换登录页的 ownership、认证复用和
|
|
53
|
+
升级迁移说明。
|
|
47
54
|
- `bun run dev`:复用 Docker 镜像启动 PostgreSQL、自动 migration、Server 与支持热更新的 Web。
|
|
48
55
|
- `bun run dev:build`:依赖、Dockerfile 或 lockfile 变化后重建并启动开发环境。
|
|
49
56
|
- `bun run dev:web`:启动 Web 开发服务。
|
|
@@ -39,6 +39,8 @@ describePostgres("PostgreSQL watermark policy repository", () => {
|
|
|
39
39
|
{
|
|
40
40
|
enabled: true,
|
|
41
41
|
businessPagesEnabled: true,
|
|
42
|
+
density: "high",
|
|
43
|
+
contentFields: ["username", "phone"],
|
|
42
44
|
customContent: "内部资料",
|
|
43
45
|
},
|
|
44
46
|
"user_settings",
|
|
@@ -51,6 +53,8 @@ describePostgres("PostgreSQL watermark policy repository", () => {
|
|
|
51
53
|
expect(await restarted.get()).toEqual({
|
|
52
54
|
enabled: true,
|
|
53
55
|
businessPagesEnabled: true,
|
|
56
|
+
density: "high",
|
|
57
|
+
contentFields: ["username", "phone"],
|
|
54
58
|
customContent: "内部资料",
|
|
55
59
|
});
|
|
56
60
|
});
|
|
@@ -38,6 +38,8 @@ export class DrizzleWatermarkPolicyRepository implements WatermarkPolicyReposito
|
|
|
38
38
|
set: {
|
|
39
39
|
enabled: policy.enabled,
|
|
40
40
|
businessPagesEnabled: policy.businessPagesEnabled,
|
|
41
|
+
density: policy.density,
|
|
42
|
+
contentFields: policy.contentFields,
|
|
41
43
|
customContent: policy.customContent,
|
|
42
44
|
updatedAt: new Date(),
|
|
43
45
|
updatedBy: actorId,
|
|
@@ -52,11 +54,15 @@ export class DrizzleWatermarkPolicyRepository implements WatermarkPolicyReposito
|
|
|
52
54
|
function toPolicy(row: {
|
|
53
55
|
enabled: boolean;
|
|
54
56
|
businessPagesEnabled: boolean;
|
|
57
|
+
density: PlatformWatermarkPolicy["density"];
|
|
58
|
+
contentFields: PlatformWatermarkPolicy["contentFields"];
|
|
55
59
|
customContent: string;
|
|
56
60
|
}): PlatformWatermarkPolicy {
|
|
57
61
|
return {
|
|
58
62
|
enabled: row.enabled,
|
|
59
63
|
businessPagesEnabled: row.businessPagesEnabled,
|
|
64
|
+
density: row.density,
|
|
65
|
+
contentFields: row.contentFields,
|
|
60
66
|
customContent: row.customContent,
|
|
61
67
|
};
|
|
62
68
|
}
|
|
@@ -43,7 +43,9 @@ describe("平台水印策略路由", () => {
|
|
|
43
43
|
|
|
44
44
|
expect(policy).toEqual({
|
|
45
45
|
enabled: true,
|
|
46
|
-
businessPagesEnabled:
|
|
46
|
+
businessPagesEnabled: true,
|
|
47
|
+
density: "medium",
|
|
48
|
+
contentFields: ["username", "displayName"],
|
|
47
49
|
customContent: "",
|
|
48
50
|
});
|
|
49
51
|
});
|
|
@@ -53,6 +55,8 @@ describe("平台水印策略路由", () => {
|
|
|
53
55
|
const input: PlatformWatermarkPolicy = {
|
|
54
56
|
enabled: true,
|
|
55
57
|
businessPagesEnabled: true,
|
|
58
|
+
density: "high",
|
|
59
|
+
contentFields: ["username", "phone", "custom"],
|
|
56
60
|
customContent: "内部资料",
|
|
57
61
|
};
|
|
58
62
|
|
|
@@ -79,7 +83,7 @@ describe("平台水印策略路由", () => {
|
|
|
79
83
|
action: "config.update",
|
|
80
84
|
target: { type: "platform-watermark", id: "platform" },
|
|
81
85
|
metadata: expect.objectContaining({
|
|
82
|
-
changedFields: ["
|
|
86
|
+
changedFields: ["density", "contentFields", "customContent"],
|
|
83
87
|
}),
|
|
84
88
|
}),
|
|
85
89
|
]),
|
|
@@ -97,6 +101,8 @@ describe("平台水印策略路由", () => {
|
|
|
97
101
|
const denied = (await update(fixture, {
|
|
98
102
|
enabled: false,
|
|
99
103
|
businessPagesEnabled: false,
|
|
104
|
+
density: "low",
|
|
105
|
+
contentFields: ["username"],
|
|
100
106
|
customContent: "",
|
|
101
107
|
})) as Response;
|
|
102
108
|
|
|
@@ -130,13 +136,17 @@ describe("平台水印策略路由", () => {
|
|
|
130
136
|
const response = (await update(fixture, {
|
|
131
137
|
enabled: true,
|
|
132
138
|
businessPagesEnabled: true,
|
|
139
|
+
density: "medium",
|
|
140
|
+
contentFields: ["username"],
|
|
133
141
|
customContent: "x".repeat(81),
|
|
134
142
|
})) as Response;
|
|
135
143
|
|
|
136
144
|
expect(response.status).toBe(400);
|
|
137
145
|
expect(await fixture.repository.get()).toEqual({
|
|
138
146
|
enabled: true,
|
|
139
|
-
businessPagesEnabled:
|
|
147
|
+
businessPagesEnabled: true,
|
|
148
|
+
density: "medium",
|
|
149
|
+
contentFields: ["username", "displayName"],
|
|
140
150
|
customContent: "",
|
|
141
151
|
});
|
|
142
152
|
expect(fixture.runtime.auditSink.list()).toHaveLength(0);
|
|
@@ -3,6 +3,10 @@ import type {
|
|
|
3
3
|
FeatureFlagDefinition,
|
|
4
4
|
PlatformWatermarkPolicy,
|
|
5
5
|
} from "@southwind-ai/shared";
|
|
6
|
+
import {
|
|
7
|
+
WATERMARK_CONTENT_FIELDS,
|
|
8
|
+
WATERMARK_DENSITIES,
|
|
9
|
+
} from "@southwind-ai/shared";
|
|
6
10
|
import { evaluateRouteGuards, findFeature } from "../route-guards.js";
|
|
7
11
|
import type { createServerRuntime } from "../runtime.js";
|
|
8
12
|
import { resourceEvent, routeContext } from "../system/route-helpers.js";
|
|
@@ -108,15 +112,43 @@ function parseWatermarkPolicy(body: unknown): WatermarkParseResult {
|
|
|
108
112
|
if (typeof source.customContent !== "string") {
|
|
109
113
|
return { ok: false, message: "自定义内容必须是字符串" };
|
|
110
114
|
}
|
|
115
|
+
if (
|
|
116
|
+
typeof source.density !== "string" ||
|
|
117
|
+
!WATERMARK_DENSITIES.includes(
|
|
118
|
+
source.density as (typeof WATERMARK_DENSITIES)[number],
|
|
119
|
+
)
|
|
120
|
+
) {
|
|
121
|
+
return { ok: false, message: "水印密度无效" };
|
|
122
|
+
}
|
|
123
|
+
if (
|
|
124
|
+
!Array.isArray(source.contentFields) ||
|
|
125
|
+
source.contentFields.length === 0 ||
|
|
126
|
+
!source.contentFields.every(
|
|
127
|
+
(field) =>
|
|
128
|
+
typeof field === "string" &&
|
|
129
|
+
WATERMARK_CONTENT_FIELDS.includes(
|
|
130
|
+
field as (typeof WATERMARK_CONTENT_FIELDS)[number],
|
|
131
|
+
),
|
|
132
|
+
)
|
|
133
|
+
) {
|
|
134
|
+
return { ok: false, message: "请至少选择一项有效的水印内容" };
|
|
135
|
+
}
|
|
111
136
|
const customContent = source.customContent.trim();
|
|
112
137
|
if (customContent.length > 80) {
|
|
113
138
|
return { ok: false, message: "自定义内容不能超过 80 个字符" };
|
|
114
139
|
}
|
|
140
|
+
const contentFields = [
|
|
141
|
+
...new Set(
|
|
142
|
+
source.contentFields as PlatformWatermarkPolicy["contentFields"],
|
|
143
|
+
),
|
|
144
|
+
];
|
|
115
145
|
return {
|
|
116
146
|
ok: true,
|
|
117
147
|
value: {
|
|
118
148
|
enabled: source.enabled,
|
|
119
149
|
businessPagesEnabled: source.businessPagesEnabled,
|
|
150
|
+
density: source.density as PlatformWatermarkPolicy["density"],
|
|
151
|
+
contentFields,
|
|
120
152
|
customContent,
|
|
121
153
|
},
|
|
122
154
|
};
|
|
@@ -126,9 +158,15 @@ function changedFields(
|
|
|
126
158
|
before: PlatformWatermarkPolicy,
|
|
127
159
|
after: PlatformWatermarkPolicy,
|
|
128
160
|
): string[] {
|
|
129
|
-
return [
|
|
161
|
+
return [
|
|
162
|
+
"enabled",
|
|
163
|
+
"businessPagesEnabled",
|
|
164
|
+
"density",
|
|
165
|
+
"contentFields",
|
|
166
|
+
"customContent",
|
|
167
|
+
].filter(
|
|
130
168
|
(field) =>
|
|
131
|
-
before[field as keyof PlatformWatermarkPolicy] !==
|
|
132
|
-
after[field as keyof PlatformWatermarkPolicy],
|
|
169
|
+
JSON.stringify(before[field as keyof PlatformWatermarkPolicy]) !==
|
|
170
|
+
JSON.stringify(after[field as keyof PlatformWatermarkPolicy]),
|
|
133
171
|
);
|
|
134
172
|
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, test, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
const mocks = vi.hoisted(() => ({
|
|
4
|
+
auth: {
|
|
5
|
+
error: { value: "" },
|
|
6
|
+
login: vi.fn(),
|
|
7
|
+
phase: { value: "anonymous" },
|
|
8
|
+
},
|
|
9
|
+
replace: vi.fn(),
|
|
10
|
+
resetAccessSnapshot: vi.fn(),
|
|
11
|
+
// @windy-module system.license begin
|
|
12
|
+
resetLicenseRuntime: vi.fn(),
|
|
13
|
+
// @windy-module system.license end
|
|
14
|
+
resetSessionContext: vi.fn(),
|
|
15
|
+
returnTo: "/admin?tab=active#top" as unknown,
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
vi.mock("vue-router", () => ({
|
|
19
|
+
useRoute: () => ({ query: { returnTo: mocks.returnTo } }),
|
|
20
|
+
useRouter: () => ({ replace: mocks.replace }),
|
|
21
|
+
}));
|
|
22
|
+
vi.mock("@/composables/useAccessSnapshot", () => ({
|
|
23
|
+
resetAccessSnapshot: mocks.resetAccessSnapshot,
|
|
24
|
+
}));
|
|
25
|
+
vi.mock("@/composables/useAuthSession", () => ({
|
|
26
|
+
useAuthSession: () => mocks.auth,
|
|
27
|
+
}));
|
|
28
|
+
// @windy-module system.license begin
|
|
29
|
+
vi.mock("@/composables/useLicenseRuntime", () => ({
|
|
30
|
+
resetLicenseRuntime: mocks.resetLicenseRuntime,
|
|
31
|
+
}));
|
|
32
|
+
// @windy-module system.license end
|
|
33
|
+
vi.mock("@/composables/useSessionContext", () => ({
|
|
34
|
+
resetSessionContext: mocks.resetSessionContext,
|
|
35
|
+
}));
|
|
36
|
+
|
|
37
|
+
import { useLoginFlow } from "./useLoginFlow";
|
|
38
|
+
|
|
39
|
+
describe("登录流程扩展契约", () => {
|
|
40
|
+
beforeEach(() => {
|
|
41
|
+
mocks.auth.error.value = "";
|
|
42
|
+
mocks.auth.phase.value = "anonymous";
|
|
43
|
+
mocks.auth.login.mockReset();
|
|
44
|
+
mocks.replace.mockReset();
|
|
45
|
+
mocks.resetAccessSnapshot.mockReset();
|
|
46
|
+
// @windy-module system.license begin
|
|
47
|
+
mocks.resetLicenseRuntime.mockReset();
|
|
48
|
+
// @windy-module system.license end
|
|
49
|
+
mocks.resetSessionContext.mockReset();
|
|
50
|
+
mocks.returnTo = "/admin?tab=active#top";
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("成功登录后重置会话派生状态并进入安全 returnTo", async () => {
|
|
54
|
+
mocks.auth.login.mockResolvedValue(undefined);
|
|
55
|
+
const flow = useLoginFlow();
|
|
56
|
+
|
|
57
|
+
await flow.submit({ username: "admin", password: "secret-password" });
|
|
58
|
+
|
|
59
|
+
expect(mocks.auth.login).toHaveBeenCalledWith({
|
|
60
|
+
username: "admin",
|
|
61
|
+
password: "secret-password",
|
|
62
|
+
});
|
|
63
|
+
expect(mocks.resetAccessSnapshot).toHaveBeenCalledOnce();
|
|
64
|
+
// @windy-module system.license begin
|
|
65
|
+
expect(mocks.resetLicenseRuntime).toHaveBeenCalledOnce();
|
|
66
|
+
// @windy-module system.license end
|
|
67
|
+
expect(mocks.resetSessionContext).toHaveBeenCalledOnce();
|
|
68
|
+
expect(mocks.replace).toHaveBeenCalledWith("/admin?tab=active#top");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("业务登录页不能借 returnTo 跳转到站外", async () => {
|
|
72
|
+
mocks.returnTo = "https://evil.example/phishing";
|
|
73
|
+
mocks.auth.login.mockResolvedValue(undefined);
|
|
74
|
+
const flow = useLoginFlow();
|
|
75
|
+
|
|
76
|
+
await flow.submit({ username: "admin", password: "secret-password" });
|
|
77
|
+
|
|
78
|
+
expect(mocks.replace).toHaveBeenCalledWith("/");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("认证失败保留当前页面并暴露服务端错误", async () => {
|
|
82
|
+
mocks.auth.error.value = "用户名或密码错误";
|
|
83
|
+
mocks.auth.login.mockRejectedValue(new Error("unauthorized"));
|
|
84
|
+
const flow = useLoginFlow();
|
|
85
|
+
|
|
86
|
+
await flow.submit({ username: "admin", password: "wrong-password" });
|
|
87
|
+
|
|
88
|
+
expect(flow.submitError.value).toBe("用户名或密码错误");
|
|
89
|
+
expect(mocks.replace).not.toHaveBeenCalled();
|
|
90
|
+
});
|
|
91
|
+
});
|
|
@@ -10,8 +10,8 @@ import {
|
|
|
10
10
|
type PlatformWatermarkPolicy,
|
|
11
11
|
} from "@southwind-ai/shared";
|
|
12
12
|
import {
|
|
13
|
-
defaultWatermarkContent,
|
|
14
13
|
normalizeWatermarkPolicy,
|
|
14
|
+
watermarkContent,
|
|
15
15
|
} from "./watermark-settings";
|
|
16
16
|
|
|
17
17
|
const policy = shallowRef<PlatformWatermarkPolicy>({
|
|
@@ -26,8 +26,12 @@ let pending: Promise<PlatformWatermarkPolicy> | undefined;
|
|
|
26
26
|
export function useWatermarkSettings(
|
|
27
27
|
actor: Readonly<Ref<WebAccessActor | undefined>>,
|
|
28
28
|
) {
|
|
29
|
-
const content = computed(
|
|
30
|
-
(
|
|
29
|
+
const content = computed(() =>
|
|
30
|
+
watermarkContent(
|
|
31
|
+
actor.value,
|
|
32
|
+
policy.value.contentFields,
|
|
33
|
+
policy.value.customContent,
|
|
34
|
+
),
|
|
31
35
|
);
|
|
32
36
|
|
|
33
37
|
return {
|
|
@@ -27,6 +27,8 @@ describe("全局水印策略状态", () => {
|
|
|
27
27
|
api.loadPublic.mockResolvedValue({
|
|
28
28
|
enabled: true,
|
|
29
29
|
businessPagesEnabled: true,
|
|
30
|
+
density: "high",
|
|
31
|
+
contentFields: ["custom"],
|
|
30
32
|
customContent: "全局审计",
|
|
31
33
|
});
|
|
32
34
|
const { useWatermarkSettings } = await import("./useWatermarkSettings");
|
|
@@ -37,6 +39,8 @@ describe("全局水印策略状态", () => {
|
|
|
37
39
|
expect(watermark.policy.value).toEqual({
|
|
38
40
|
enabled: true,
|
|
39
41
|
businessPagesEnabled: true,
|
|
42
|
+
density: "high",
|
|
43
|
+
contentFields: ["custom"],
|
|
40
44
|
customContent: "全局审计",
|
|
41
45
|
});
|
|
42
46
|
expect(localStorageRead).not.toHaveBeenCalled();
|
|
@@ -47,11 +51,15 @@ describe("全局水印策略状态", () => {
|
|
|
47
51
|
api.loadManaged.mockResolvedValue({
|
|
48
52
|
enabled: true,
|
|
49
53
|
businessPagesEnabled: false,
|
|
54
|
+
density: "medium",
|
|
55
|
+
contentFields: ["username", "displayName"],
|
|
50
56
|
customContent: "",
|
|
51
57
|
});
|
|
52
58
|
api.update.mockResolvedValue({
|
|
53
59
|
enabled: true,
|
|
54
60
|
businessPagesEnabled: true,
|
|
61
|
+
density: "high",
|
|
62
|
+
contentFields: ["username", "custom"],
|
|
55
63
|
customContent: " 内部资料 ",
|
|
56
64
|
});
|
|
57
65
|
const { useWatermarkSettings } = await import("./useWatermarkSettings");
|
|
@@ -69,17 +77,23 @@ describe("全局水印策略状态", () => {
|
|
|
69
77
|
await first.save({
|
|
70
78
|
enabled: true,
|
|
71
79
|
businessPagesEnabled: true,
|
|
80
|
+
density: "high",
|
|
81
|
+
contentFields: ["username", "custom"],
|
|
72
82
|
customContent: "内部资料",
|
|
73
83
|
});
|
|
74
84
|
|
|
75
85
|
expect(api.update).toHaveBeenCalledWith({
|
|
76
86
|
enabled: true,
|
|
77
87
|
businessPagesEnabled: true,
|
|
88
|
+
density: "high",
|
|
89
|
+
contentFields: ["username", "custom"],
|
|
78
90
|
customContent: "内部资料",
|
|
79
91
|
});
|
|
80
92
|
expect(second.policy.value).toEqual({
|
|
81
93
|
enabled: true,
|
|
82
94
|
businessPagesEnabled: true,
|
|
95
|
+
density: "high",
|
|
96
|
+
contentFields: ["username", "custom"],
|
|
83
97
|
customContent: "内部资料",
|
|
84
98
|
});
|
|
85
99
|
});
|
|
@@ -1,25 +1,147 @@
|
|
|
1
1
|
import type { WebAccessActor } from "@/layout/navigation";
|
|
2
|
-
import
|
|
2
|
+
import {
|
|
3
|
+
DEFAULT_PLATFORM_WATERMARK_POLICY,
|
|
4
|
+
WATERMARK_CONTENT_FIELDS,
|
|
5
|
+
WATERMARK_DENSITIES,
|
|
6
|
+
type PlatformWatermarkPolicy,
|
|
7
|
+
type WatermarkContentField,
|
|
8
|
+
type WatermarkDensity,
|
|
9
|
+
} from "@southwind-ai/shared";
|
|
3
10
|
|
|
4
|
-
|
|
11
|
+
const DENSITY_GAPS: Record<
|
|
12
|
+
WatermarkDensity,
|
|
13
|
+
{ horizontal: number; vertical: number }
|
|
14
|
+
> = {
|
|
15
|
+
low: { horizontal: 112, vertical: 76 },
|
|
16
|
+
medium: { horizontal: 72, vertical: 48 },
|
|
17
|
+
high: { horizontal: 36, vertical: 24 },
|
|
18
|
+
};
|
|
19
|
+
const FONT_SIZE = 14;
|
|
20
|
+
const LINE_HEIGHT = 22;
|
|
21
|
+
const ROTATION_RADIANS = (22 * Math.PI) / 180;
|
|
22
|
+
const MAX_LINE_WEIGHT = 32;
|
|
23
|
+
|
|
24
|
+
export function watermarkContent(
|
|
5
25
|
actor: WebAccessActor | undefined,
|
|
26
|
+
contentFields: readonly WatermarkContentField[],
|
|
27
|
+
customContent: string,
|
|
6
28
|
): string {
|
|
7
29
|
if (!actor || actor.type === "anonymous") return "";
|
|
8
|
-
const
|
|
9
|
-
|
|
10
|
-
actor.
|
|
11
|
-
actor.
|
|
12
|
-
actor.
|
|
13
|
-
|
|
14
|
-
|
|
30
|
+
const values: Record<WatermarkContentField, string> = {
|
|
31
|
+
username: actor.username?.trim() || actor.name.trim(),
|
|
32
|
+
displayName: actor.displayName?.trim() || actor.name.trim(),
|
|
33
|
+
phone: actor.phone?.trim() || "",
|
|
34
|
+
email: actor.email?.trim() || "",
|
|
35
|
+
custom: customContent.trim(),
|
|
36
|
+
};
|
|
37
|
+
const selected = [
|
|
38
|
+
...new Set(contentFields.map((field) => values[field]).filter(Boolean)),
|
|
39
|
+
];
|
|
40
|
+
return selected.length > 0
|
|
41
|
+
? selected.join(" · ")
|
|
42
|
+
: actor.username?.trim() || actor.name.trim();
|
|
15
43
|
}
|
|
16
44
|
|
|
17
45
|
export function normalizeWatermarkPolicy(
|
|
18
46
|
value: PlatformWatermarkPolicy,
|
|
19
47
|
): PlatformWatermarkPolicy {
|
|
48
|
+
const density = WATERMARK_DENSITIES.includes(value.density)
|
|
49
|
+
? value.density
|
|
50
|
+
: DEFAULT_PLATFORM_WATERMARK_POLICY.density;
|
|
51
|
+
const receivedFields = Array.isArray(value.contentFields)
|
|
52
|
+
? value.contentFields
|
|
53
|
+
: DEFAULT_PLATFORM_WATERMARK_POLICY.contentFields;
|
|
54
|
+
const contentFields = [
|
|
55
|
+
...new Set(
|
|
56
|
+
receivedFields.filter((field) =>
|
|
57
|
+
WATERMARK_CONTENT_FIELDS.includes(field),
|
|
58
|
+
),
|
|
59
|
+
),
|
|
60
|
+
];
|
|
20
61
|
return {
|
|
21
62
|
enabled: value.enabled,
|
|
22
63
|
businessPagesEnabled: value.businessPagesEnabled,
|
|
64
|
+
density,
|
|
65
|
+
contentFields:
|
|
66
|
+
contentFields.length > 0
|
|
67
|
+
? contentFields
|
|
68
|
+
: [...DEFAULT_PLATFORM_WATERMARK_POLICY.contentFields],
|
|
23
69
|
customContent: value.customContent.trim().slice(0, 80),
|
|
24
70
|
};
|
|
25
71
|
}
|
|
72
|
+
|
|
73
|
+
export interface WatermarkPattern {
|
|
74
|
+
image: string;
|
|
75
|
+
size: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function createWatermarkPattern(
|
|
79
|
+
content: string,
|
|
80
|
+
density: WatermarkDensity,
|
|
81
|
+
): WatermarkPattern {
|
|
82
|
+
const lines = wrapWatermarkContent(content);
|
|
83
|
+
const textWidth = Math.max(...lines.map(estimatedTextWidth));
|
|
84
|
+
const textHeight = lines.length * LINE_HEIGHT;
|
|
85
|
+
const rotatedWidth =
|
|
86
|
+
textWidth * Math.cos(ROTATION_RADIANS) +
|
|
87
|
+
textHeight * Math.sin(ROTATION_RADIANS);
|
|
88
|
+
const rotatedHeight =
|
|
89
|
+
textWidth * Math.sin(ROTATION_RADIANS) +
|
|
90
|
+
textHeight * Math.cos(ROTATION_RADIANS);
|
|
91
|
+
const gap = DENSITY_GAPS[density];
|
|
92
|
+
const width = Math.ceil(rotatedWidth + gap.horizontal);
|
|
93
|
+
const height = Math.ceil(rotatedHeight + gap.vertical);
|
|
94
|
+
const firstLineY = (height - textHeight) / 2 + FONT_SIZE;
|
|
95
|
+
const tspans = lines
|
|
96
|
+
.map(
|
|
97
|
+
(line, index) =>
|
|
98
|
+
`<tspan x="${width / 2}" y="${firstLineY + index * LINE_HEIGHT}">${escapeXml(line)}</tspan>`,
|
|
99
|
+
)
|
|
100
|
+
.join("");
|
|
101
|
+
const svg =
|
|
102
|
+
`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">` +
|
|
103
|
+
`<g transform="rotate(-22 ${width / 2} ${height / 2})">` +
|
|
104
|
+
`<text text-anchor="middle" font-family="system-ui,sans-serif" font-size="${FONT_SIZE}" font-weight="500" fill="black">${tspans}</text>` +
|
|
105
|
+
"</g></svg>";
|
|
106
|
+
return {
|
|
107
|
+
image: `url("data:image/svg+xml,${encodeURIComponent(svg)}")`,
|
|
108
|
+
size: `${width}px ${height}px`,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function wrapWatermarkContent(content: string): string[] {
|
|
113
|
+
const lines: string[] = [];
|
|
114
|
+
let current = "";
|
|
115
|
+
for (const character of content.trim()) {
|
|
116
|
+
const next = current + character;
|
|
117
|
+
if (current && textWeight(next) > MAX_LINE_WEIGHT) {
|
|
118
|
+
lines.push(current.trim());
|
|
119
|
+
current = character;
|
|
120
|
+
} else {
|
|
121
|
+
current = next;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (current.trim()) lines.push(current.trim());
|
|
125
|
+
return lines.length > 0 ? lines : [""];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function estimatedTextWidth(value: string): number {
|
|
129
|
+
return Math.max(1, textWeight(value)) * (FONT_SIZE * 0.56);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function textWeight(value: string): number {
|
|
133
|
+
return [...value].reduce(
|
|
134
|
+
(total, character) =>
|
|
135
|
+
total + (/[\u2e80-\u9fff\uff00-\uffef]/u.test(character) ? 1.75 : 1),
|
|
136
|
+
0,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function escapeXml(value: string): string {
|
|
141
|
+
return value
|
|
142
|
+
.replaceAll("&", "&")
|
|
143
|
+
.replaceAll("<", "<")
|
|
144
|
+
.replaceAll(">", ">")
|
|
145
|
+
.replaceAll('"', """)
|
|
146
|
+
.replaceAll("'", "'");
|
|
147
|
+
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { describe, expect, test } from "vitest";
|
|
2
2
|
import {
|
|
3
|
-
|
|
3
|
+
createWatermarkPattern,
|
|
4
4
|
normalizeWatermarkPolicy,
|
|
5
|
+
watermarkContent,
|
|
5
6
|
} from "./watermark-settings";
|
|
6
7
|
|
|
7
8
|
describe("水印设置", () => {
|
|
@@ -15,13 +16,74 @@ describe("水印设置", () => {
|
|
|
15
16
|
roleCodes: [],
|
|
16
17
|
permissionKeys: [],
|
|
17
18
|
};
|
|
18
|
-
expect(
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
19
|
+
expect(
|
|
20
|
+
watermarkContent(
|
|
21
|
+
{ ...base, phone: "13800138000" },
|
|
22
|
+
["username", "phone"],
|
|
23
|
+
"",
|
|
24
|
+
),
|
|
25
|
+
).toBe("zhangsan · 13800138000");
|
|
26
|
+
expect(
|
|
27
|
+
watermarkContent(
|
|
28
|
+
{ ...base, email: "zs@example.com" },
|
|
29
|
+
["username", "email"],
|
|
30
|
+
"",
|
|
31
|
+
),
|
|
32
|
+
).toBe("zhangsan · zs@example.com");
|
|
33
|
+
expect(watermarkContent(base, ["username", "displayName"], "")).toBe(
|
|
34
|
+
"zhangsan · 张三",
|
|
23
35
|
);
|
|
24
|
-
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("按勾选顺序组合身份和自定义内容并去重", () => {
|
|
39
|
+
expect(
|
|
40
|
+
watermarkContent(
|
|
41
|
+
{
|
|
42
|
+
id: "user_1",
|
|
43
|
+
type: "user",
|
|
44
|
+
name: "张三",
|
|
45
|
+
username: "zhangsan",
|
|
46
|
+
displayName: "张三",
|
|
47
|
+
roleCodes: [],
|
|
48
|
+
permissionKeys: [],
|
|
49
|
+
},
|
|
50
|
+
["displayName", "username", "custom"],
|
|
51
|
+
"内部资料",
|
|
52
|
+
),
|
|
53
|
+
).toBe("张三 · zhangsan · 内部资料");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("勾选字段在当前账号均为空时仍兜底显示用户名", () => {
|
|
57
|
+
expect(
|
|
58
|
+
watermarkContent(
|
|
59
|
+
{
|
|
60
|
+
id: "user_1",
|
|
61
|
+
type: "user",
|
|
62
|
+
name: "张三",
|
|
63
|
+
username: "zhangsan",
|
|
64
|
+
roleCodes: [],
|
|
65
|
+
permissionKeys: [],
|
|
66
|
+
},
|
|
67
|
+
["phone", "email", "custom"],
|
|
68
|
+
"",
|
|
69
|
+
),
|
|
70
|
+
).toBe("zhangsan");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("高密度缩小平铺单元且长内容自动换行以避免相邻水印重叠", () => {
|
|
74
|
+
const content =
|
|
75
|
+
"zhangsan · 张三 · 13800138000 · zs@example.com · 内部资料请勿外传";
|
|
76
|
+
const low = createWatermarkPattern(content, "low");
|
|
77
|
+
const high = createWatermarkPattern(content, "high");
|
|
78
|
+
const dimensions = (value: typeof low) =>
|
|
79
|
+
value.size.match(/\d+/g)!.map(Number);
|
|
80
|
+
|
|
81
|
+
expect(dimensions(high)[0]).toBeLessThan(dimensions(low)[0]!);
|
|
82
|
+
expect(dimensions(high)[1]).toBeLessThan(dimensions(low)[1]!);
|
|
83
|
+
expect(decodeURIComponent(high.image)).toContain("<tspan");
|
|
84
|
+
expect(
|
|
85
|
+
decodeURIComponent(high.image).match(/<tspan/g)?.length,
|
|
86
|
+
).toBeGreaterThan(1);
|
|
25
87
|
});
|
|
26
88
|
|
|
27
89
|
test("服务端策略内容会被规范化后渲染", () => {
|
|
@@ -29,11 +91,15 @@ describe("水印设置", () => {
|
|
|
29
91
|
normalizeWatermarkPolicy({
|
|
30
92
|
enabled: false,
|
|
31
93
|
businessPagesEnabled: true,
|
|
94
|
+
density: "high",
|
|
95
|
+
contentFields: ["phone", "phone"],
|
|
32
96
|
customContent: ` ${"x".repeat(100)} `,
|
|
33
97
|
}),
|
|
34
98
|
).toEqual({
|
|
35
99
|
enabled: false,
|
|
36
100
|
businessPagesEnabled: true,
|
|
101
|
+
density: "high",
|
|
102
|
+
contentFields: ["phone"],
|
|
37
103
|
customContent: "x".repeat(80),
|
|
38
104
|
});
|
|
39
105
|
});
|