create-windy 0.2.31 → 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.
Files changed (30) hide show
  1. package/dist/cli.js +5 -2
  2. package/package.json +1 -1
  3. package/template/.windy-template.json +2 -2
  4. package/template/AGENTS.md +7 -0
  5. package/template/apps/server/src/settings/drizzle-watermark-repository.integration.test.ts +4 -0
  6. package/template/apps/server/src/settings/drizzle-watermark-repository.ts +6 -0
  7. package/template/apps/server/src/settings/watermark-routes.test.ts +13 -3
  8. package/template/apps/server/src/settings/watermark-routes.ts +41 -3
  9. package/template/apps/web/src/app/auth/login-page.ts +4 -0
  10. package/template/apps/web/src/composables/useLoginFlow.webtest.ts +91 -0
  11. package/template/apps/web/src/composables/useWatermarkSettings.ts +7 -3
  12. package/template/apps/web/src/composables/useWatermarkSettings.webtest.ts +14 -0
  13. package/template/apps/web/src/composables/watermark-settings.ts +131 -9
  14. package/template/apps/web/src/composables/watermark-settings.webtest.ts +73 -7
  15. package/template/apps/web/src/layout/GlobalWatermark.layering.webtest.ts +3 -1
  16. package/template/apps/web/src/layout/GlobalWatermark.vue +24 -11
  17. package/template/apps/web/src/layout/GlobalWatermark.webtest.ts +25 -11
  18. package/template/apps/web/src/pages/auth/login-page-extension.ts +9 -0
  19. package/template/apps/web/src/pages/auth/login-page-extension.webtest.ts +10 -0
  20. package/template/apps/web/src/pages/settings/WatermarkSettingsSection.vue +102 -4
  21. package/template/apps/web/src/router/index.ts +2 -2
  22. package/template/apps/web/src/router/shared-scaffold-neutrality.webtest.ts +3 -2
  23. package/template/docs/development/custom-login-page.md +90 -0
  24. package/template/docs/platform/platform-shell-settings.md +14 -9
  25. package/template/docs/platform/web-system-crud.md +1 -1
  26. package/template/packages/database/drizzle/0034_funny_mesmero.sql +5 -0
  27. package/template/packages/database/drizzle/meta/0034_snapshot.json +7253 -0
  28. package/template/packages/database/drizzle/meta/_journal.json +7 -0
  29. package/template/packages/database/src/schema/platform-settings.ts +13 -0
  30. package/template/packages/shared/src/platform-settings.ts +17 -1
package/dist/cli.js CHANGED
@@ -1114,7 +1114,7 @@ function rewriteCustomerDockerfile(source) {
1114
1114
  return !supplierWorkspaceCopies.includes(line) && !supplierWorkspacePaths.some((path) => line.includes(path)) && !line.includes("deploy/production/server-entrypoint.sh");
1115
1115
  });
1116
1116
  return lines.join(`
1117
- `).replace("COPY package.json bun.lock ./", "COPY package.json ./").replace("RUN bun install --frozen-lockfile", "RUN bun install");
1117
+ `);
1118
1118
  }
1119
1119
 
1120
1120
  // src/code-quality.ts
@@ -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",
@@ -4361,7 +4362,9 @@ var recipes = [
4361
4362
  { id: "starter-0.2.27-to-0.2.28", from: "0.2.27", to: "0.2.28" },
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
- { id: "starter-0.2.30-to-0.2.31", from: "0.2.30", to: "0.2.31" }
4365
+ { id: "starter-0.2.30-to-0.2.31", from: "0.2.30", to: "0.2.31" },
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" }
4365
4368
  ];
4366
4369
  function resolveRecipeChain(sourceVersion, targetVersion) {
4367
4370
  if (sourceVersion === targetVersion)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-windy",
3
- "version": "0.2.31",
3
+ "version": "0.2.33",
4
4
  "description": "创建单组织、单租户、私有部署优先的 Windy 企业 Dashboard Starter",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "templateCommit": "5d781a968ffc243b9c29c1ee31382cd61a5f4ab7",
4
- "generatorVersion": "0.2.31",
3
+ "templateCommit": "395761d19b6dac6f4135547bedf996e3a53249a7",
4
+ "generatorVersion": "0.2.33",
5
5
  "modules": [
6
6
  {
7
7
  "name": "system",
@@ -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: false,
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: ["businessPagesEnabled", "customContent"],
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: false,
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 ["enabled", "businessPagesEnabled", "customContent"].filter(
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,4 @@
1
+ import PlatformLoginPage from "@/pages/auth/LoginPage.vue";
2
+ import { defineLoginPageComponent } from "@/pages/auth/login-page-extension";
3
+
4
+ export const loginPageComponent = defineLoginPageComponent(PlatformLoginPage);
@@ -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
- () => policy.value.customContent || defaultWatermarkContent(actor.value),
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 type { PlatformWatermarkPolicy } from "@southwind-ai/shared";
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
- export function defaultWatermarkContent(
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 username = actor.username?.trim() || actor.name.trim();
9
- const identity =
10
- actor.phone?.trim() ||
11
- actor.email?.trim() ||
12
- actor.displayName?.trim() ||
13
- actor.name.trim();
14
- return [...new Set([username, identity].filter(Boolean))].join(" · ");
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("&", "&amp;")
143
+ .replaceAll("<", "&lt;")
144
+ .replaceAll(">", "&gt;")
145
+ .replaceAll('"', "&quot;")
146
+ .replaceAll("'", "&apos;");
147
+ }