create-windy 0.4.0 → 0.4.1

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 (24) hide show
  1. package/dist/cli.js +5 -0
  2. package/package.json +1 -1
  3. package/template/.windy-template.json +2 -2
  4. package/template/apps/server/src/settings/drizzle-watermark-repository.integration.test.ts +4 -0
  5. package/template/apps/server/src/settings/drizzle-watermark-repository.ts +11 -3
  6. package/template/apps/server/src/settings/watermark-routes.test.ts +54 -1
  7. package/template/apps/server/src/settings/watermark-routes.ts +29 -1
  8. package/template/apps/web/src/composables/useWatermarkSettings.webtest.ts +14 -0
  9. package/template/apps/web/src/composables/watermark-settings.ts +40 -11
  10. package/template/apps/web/src/composables/watermark-settings.webtest.ts +19 -6
  11. package/template/apps/web/src/layout/GlobalWatermark.layering.webtest.ts +2 -0
  12. package/template/apps/web/src/layout/GlobalWatermark.vue +4 -8
  13. package/template/apps/web/src/layout/GlobalWatermark.webtest.ts +12 -5
  14. package/template/apps/web/src/pages/settings/WatermarkPreview.vue +72 -0
  15. package/template/apps/web/src/pages/settings/WatermarkPreview.webtest.ts +55 -0
  16. package/template/apps/web/src/pages/settings/WatermarkSettingsSection.vue +80 -24
  17. package/template/apps/web/src/pages/settings/WatermarkSettingsSection.webtest.ts +115 -0
  18. package/template/docs/platform/platform-shell-settings.md +15 -10
  19. package/template/docs/platform/web-system-crud.md +1 -1
  20. package/template/packages/database/drizzle/0044_sweet_tarantula.sql +6 -0
  21. package/template/packages/database/drizzle/meta/0044_snapshot.json +8349 -0
  22. package/template/packages/database/drizzle/meta/_journal.json +7 -0
  23. package/template/packages/database/src/schema/platform-settings.ts +3 -0
  24. package/template/packages/shared/src/platform-settings.ts +17 -0
package/dist/cli.js CHANGED
@@ -7524,6 +7524,11 @@ var recipes = [
7524
7524
  id: "starter-0.3.19-to-0.4.0",
7525
7525
  from: "0.3.19",
7526
7526
  to: "0.4.0"
7527
+ },
7528
+ {
7529
+ id: "starter-0.4.0-to-0.4.1",
7530
+ from: "0.4.0",
7531
+ to: "0.4.1"
7527
7532
  }
7528
7533
  ];
7529
7534
  function resolveRecipeChain(sourceVersion, targetVersion) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-windy",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
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": "50d32b9470dbf55110aba320338f5e54ed896c77",
4
- "generatorVersion": "0.4.0",
3
+ "templateCommit": "67920c05352fe2d560c018f6b26e5e81e78291b0",
4
+ "generatorVersion": "0.4.1",
5
5
  "modules": [
6
6
  {
7
7
  "name": "system",
@@ -42,6 +42,8 @@ describePostgres("PostgreSQL watermark policy repository", () => {
42
42
  density: "high",
43
43
  contentFields: ["username", "phone"],
44
44
  customContent: "内部资料",
45
+ opacityPercent: 16,
46
+ fontSize: 20,
45
47
  },
46
48
  "user_settings",
47
49
  );
@@ -56,6 +58,8 @@ describePostgres("PostgreSQL watermark policy repository", () => {
56
58
  density: "high",
57
59
  contentFields: ["username", "phone"],
58
60
  customContent: "内部资料",
61
+ opacityPercent: 16,
62
+ fontSize: 20,
59
63
  });
60
64
  });
61
65
  });
@@ -30,6 +30,7 @@ export class DrizzleWatermarkPolicyRepository implements WatermarkPolicyReposito
30
30
  .values({
31
31
  id: PLATFORM_WATERMARK_ID,
32
32
  ...policy,
33
+ businessPagesEnabled: policy.enabled,
33
34
  createdBy: actorId,
34
35
  updatedBy: actorId,
35
36
  })
@@ -37,10 +38,12 @@ export class DrizzleWatermarkPolicyRepository implements WatermarkPolicyReposito
37
38
  target: platformWatermarkPolicies.id,
38
39
  set: {
39
40
  enabled: policy.enabled,
40
- businessPagesEnabled: policy.businessPagesEnabled,
41
+ businessPagesEnabled: policy.enabled,
41
42
  density: policy.density,
42
43
  contentFields: policy.contentFields,
43
44
  customContent: policy.customContent,
45
+ opacityPercent: policy.opacityPercent,
46
+ fontSize: policy.fontSize,
44
47
  updatedAt: new Date(),
45
48
  updatedBy: actorId,
46
49
  },
@@ -57,12 +60,17 @@ function toPolicy(row: {
57
60
  density: PlatformWatermarkPolicy["density"];
58
61
  contentFields: PlatformWatermarkPolicy["contentFields"];
59
62
  customContent: string;
63
+ opacityPercent: number;
64
+ fontSize: number;
60
65
  }): PlatformWatermarkPolicy {
66
+ const enabled = row.enabled && row.businessPagesEnabled;
61
67
  return {
62
- enabled: row.enabled,
63
- businessPagesEnabled: row.businessPagesEnabled,
68
+ enabled,
69
+ businessPagesEnabled: enabled,
64
70
  density: row.density,
65
71
  contentFields: row.contentFields,
66
72
  customContent: row.customContent,
73
+ opacityPercent: row.opacityPercent,
74
+ fontSize: row.fontSize,
67
75
  };
68
76
  }
@@ -47,6 +47,8 @@ describe("平台水印策略路由", () => {
47
47
  density: "medium",
48
48
  contentFields: ["username", "displayName"],
49
49
  customContent: "",
50
+ opacityPercent: 8,
51
+ fontSize: 14,
50
52
  });
51
53
  });
52
54
 
@@ -58,6 +60,8 @@ describe("平台水印策略路由", () => {
58
60
  density: "high",
59
61
  contentFields: ["username", "phone", "custom"],
60
62
  customContent: "内部资料",
63
+ opacityPercent: 16,
64
+ fontSize: 20,
61
65
  };
62
66
 
63
67
  const updated = (await fixture.app.invoke(
@@ -83,7 +87,13 @@ describe("平台水印策略路由", () => {
83
87
  action: "config.update",
84
88
  target: { type: "platform-watermark", id: "platform" },
85
89
  metadata: expect.objectContaining({
86
- changedFields: ["density", "contentFields", "customContent"],
90
+ changedFields: [
91
+ "density",
92
+ "contentFields",
93
+ "customContent",
94
+ "opacityPercent",
95
+ "fontSize",
96
+ ],
87
97
  }),
88
98
  }),
89
99
  ]),
@@ -104,6 +114,8 @@ describe("平台水印策略路由", () => {
104
114
  density: "low",
105
115
  contentFields: ["username"],
106
116
  customContent: "",
117
+ opacityPercent: 8,
118
+ fontSize: 14,
107
119
  })) as Response;
108
120
 
109
121
  expect(readable.enabled).toBe(true);
@@ -139,6 +151,8 @@ describe("平台水印策略路由", () => {
139
151
  density: "medium",
140
152
  contentFields: ["username"],
141
153
  customContent: "x".repeat(81),
154
+ opacityPercent: 8,
155
+ fontSize: 14,
142
156
  })) as Response;
143
157
 
144
158
  expect(response.status).toBe(400);
@@ -148,9 +162,48 @@ describe("平台水印策略路由", () => {
148
162
  density: "medium",
149
163
  contentFields: ["username", "displayName"],
150
164
  customContent: "",
165
+ opacityPercent: 8,
166
+ fontSize: 14,
151
167
  });
152
168
  expect(fixture.runtime.auditSink.list()).toHaveLength(0);
153
169
  });
170
+
171
+ test("旧业务范围状态会折叠到唯一开关", async () => {
172
+ const fixture = await createFixture(settingsActor(true));
173
+
174
+ const updated = (await update(fixture, {
175
+ enabled: true,
176
+ businessPagesEnabled: false,
177
+ density: "medium",
178
+ contentFields: ["email", "displayName"],
179
+ customContent: "",
180
+ opacityPercent: 8,
181
+ fontSize: 14,
182
+ })) as PlatformWatermarkPolicy;
183
+
184
+ expect(updated.enabled).toBe(true);
185
+ expect(updated.businessPagesEnabled).toBe(true);
186
+ expect(updated.contentFields).toEqual(["email", "displayName"]);
187
+ });
188
+
189
+ test("不透明度和字体大小超出范围时拒绝保存", async () => {
190
+ const fixture = await createFixture(settingsActor(true));
191
+
192
+ const response = (await update(fixture, {
193
+ enabled: true,
194
+ businessPagesEnabled: true,
195
+ density: "medium",
196
+ contentFields: ["username"],
197
+ customContent: "",
198
+ opacityPercent: 31,
199
+ fontSize: 14,
200
+ })) as Response;
201
+
202
+ expect(response.status).toBe(400);
203
+ await expect(response.json()).resolves.toMatchObject({
204
+ error: "INVALID_WATERMARK_POLICY",
205
+ });
206
+ });
154
207
  });
155
208
 
156
209
  async function update(
@@ -6,6 +6,8 @@ import type {
6
6
  import {
7
7
  WATERMARK_CONTENT_FIELDS,
8
8
  WATERMARK_DENSITIES,
9
+ WATERMARK_FONT_SIZE_RANGE,
10
+ WATERMARK_OPACITY_RANGE,
9
11
  } from "@southwind-ai/shared";
10
12
  import { evaluateRouteGuards, findFeature } from "../route-guards.js";
11
13
  import type { createServerRuntime } from "../runtime.js";
@@ -137,6 +139,28 @@ function parseWatermarkPolicy(body: unknown): WatermarkParseResult {
137
139
  if (customContent.length > 80) {
138
140
  return { ok: false, message: "自定义内容不能超过 80 个字符" };
139
141
  }
142
+ if (
143
+ typeof source.opacityPercent !== "number" ||
144
+ !Number.isInteger(source.opacityPercent) ||
145
+ source.opacityPercent < WATERMARK_OPACITY_RANGE.min ||
146
+ source.opacityPercent > WATERMARK_OPACITY_RANGE.max
147
+ ) {
148
+ return {
149
+ ok: false,
150
+ message: `水印不透明度必须是 ${WATERMARK_OPACITY_RANGE.min}% 到 ${WATERMARK_OPACITY_RANGE.max}% 的整数`,
151
+ };
152
+ }
153
+ if (
154
+ typeof source.fontSize !== "number" ||
155
+ !Number.isInteger(source.fontSize) ||
156
+ source.fontSize < WATERMARK_FONT_SIZE_RANGE.min ||
157
+ source.fontSize > WATERMARK_FONT_SIZE_RANGE.max
158
+ ) {
159
+ return {
160
+ ok: false,
161
+ message: `水印字体大小必须是 ${WATERMARK_FONT_SIZE_RANGE.min}px 到 ${WATERMARK_FONT_SIZE_RANGE.max}px 的整数`,
162
+ };
163
+ }
140
164
  const contentFields = [
141
165
  ...new Set(
142
166
  source.contentFields as PlatformWatermarkPolicy["contentFields"],
@@ -146,10 +170,12 @@ function parseWatermarkPolicy(body: unknown): WatermarkParseResult {
146
170
  ok: true,
147
171
  value: {
148
172
  enabled: source.enabled,
149
- businessPagesEnabled: source.businessPagesEnabled,
173
+ businessPagesEnabled: source.enabled,
150
174
  density: source.density as PlatformWatermarkPolicy["density"],
151
175
  contentFields,
152
176
  customContent,
177
+ opacityPercent: source.opacityPercent,
178
+ fontSize: source.fontSize,
153
179
  },
154
180
  };
155
181
  }
@@ -164,6 +190,8 @@ function changedFields(
164
190
  "density",
165
191
  "contentFields",
166
192
  "customContent",
193
+ "opacityPercent",
194
+ "fontSize",
167
195
  ].filter(
168
196
  (field) =>
169
197
  JSON.stringify(before[field as keyof PlatformWatermarkPolicy]) !==
@@ -30,6 +30,8 @@ describe("全局水印策略状态", () => {
30
30
  density: "high",
31
31
  contentFields: ["custom"],
32
32
  customContent: "全局审计",
33
+ opacityPercent: 12,
34
+ fontSize: 18,
33
35
  });
34
36
  const { useWatermarkSettings } = await import("./useWatermarkSettings");
35
37
  const watermark = useWatermarkSettings(shallowRef(undefined));
@@ -42,6 +44,8 @@ describe("全局水印策略状态", () => {
42
44
  density: "high",
43
45
  contentFields: ["custom"],
44
46
  customContent: "全局审计",
47
+ opacityPercent: 12,
48
+ fontSize: 18,
45
49
  });
46
50
  expect(localStorageRead).not.toHaveBeenCalled();
47
51
  expect(localStorageWrite).not.toHaveBeenCalled();
@@ -54,6 +58,8 @@ describe("全局水印策略状态", () => {
54
58
  density: "medium",
55
59
  contentFields: ["username", "displayName"],
56
60
  customContent: "",
61
+ opacityPercent: 8,
62
+ fontSize: 14,
57
63
  });
58
64
  api.update.mockResolvedValue({
59
65
  enabled: true,
@@ -61,6 +67,8 @@ describe("全局水印策略状态", () => {
61
67
  density: "high",
62
68
  contentFields: ["username", "custom"],
63
69
  customContent: " 内部资料 ",
70
+ opacityPercent: 16,
71
+ fontSize: 20,
64
72
  });
65
73
  const { useWatermarkSettings } = await import("./useWatermarkSettings");
66
74
  const actor = shallowRef({
@@ -80,6 +88,8 @@ describe("全局水印策略状态", () => {
80
88
  density: "high",
81
89
  contentFields: ["username", "custom"],
82
90
  customContent: "内部资料",
91
+ opacityPercent: 16,
92
+ fontSize: 20,
83
93
  });
84
94
 
85
95
  expect(api.update).toHaveBeenCalledWith({
@@ -88,6 +98,8 @@ describe("全局水印策略状态", () => {
88
98
  density: "high",
89
99
  contentFields: ["username", "custom"],
90
100
  customContent: "内部资料",
101
+ opacityPercent: 16,
102
+ fontSize: 20,
91
103
  });
92
104
  expect(second.policy.value).toEqual({
93
105
  enabled: true,
@@ -95,6 +107,8 @@ describe("全局水印策略状态", () => {
95
107
  density: "high",
96
108
  contentFields: ["username", "custom"],
97
109
  customContent: "内部资料",
110
+ opacityPercent: 16,
111
+ fontSize: 20,
98
112
  });
99
113
  });
100
114
  });
@@ -3,6 +3,8 @@ import {
3
3
  DEFAULT_PLATFORM_WATERMARK_POLICY,
4
4
  WATERMARK_CONTENT_FIELDS,
5
5
  WATERMARK_DENSITIES,
6
+ WATERMARK_FONT_SIZE_RANGE,
7
+ WATERMARK_OPACITY_RANGE,
6
8
  type PlatformWatermarkPolicy,
7
9
  type WatermarkContentField,
8
10
  type WatermarkDensity,
@@ -16,8 +18,6 @@ const DENSITY_GAPS: Record<
16
18
  medium: { horizontal: 72, vertical: 48 },
17
19
  high: { horizontal: 36, vertical: 24 },
18
20
  };
19
- const FONT_SIZE = 14;
20
- const LINE_HEIGHT = 22;
21
21
  const ROTATION_RADIANS = (22 * Math.PI) / 180;
22
22
  const MAX_LINE_WEIGHT = 32;
23
23
 
@@ -58,15 +58,26 @@ export function normalizeWatermarkPolicy(
58
58
  ),
59
59
  ),
60
60
  ];
61
+ const enabled = Boolean(value.enabled && value.businessPagesEnabled);
61
62
  return {
62
- enabled: value.enabled,
63
- businessPagesEnabled: value.businessPagesEnabled,
63
+ enabled,
64
+ businessPagesEnabled: enabled,
64
65
  density,
65
66
  contentFields:
66
67
  contentFields.length > 0
67
68
  ? contentFields
68
69
  : [...DEFAULT_PLATFORM_WATERMARK_POLICY.contentFields],
69
70
  customContent: value.customContent.trim().slice(0, 80),
71
+ opacityPercent: boundedInteger(
72
+ value.opacityPercent,
73
+ WATERMARK_OPACITY_RANGE,
74
+ DEFAULT_PLATFORM_WATERMARK_POLICY.opacityPercent,
75
+ ),
76
+ fontSize: boundedInteger(
77
+ value.fontSize,
78
+ WATERMARK_FONT_SIZE_RANGE,
79
+ DEFAULT_PLATFORM_WATERMARK_POLICY.fontSize,
80
+ ),
70
81
  };
71
82
  }
72
83
 
@@ -78,10 +89,19 @@ export interface WatermarkPattern {
78
89
  export function createWatermarkPattern(
79
90
  content: string,
80
91
  density: WatermarkDensity,
92
+ fontSize: number,
81
93
  ): WatermarkPattern {
94
+ const safeFontSize = boundedInteger(
95
+ fontSize,
96
+ WATERMARK_FONT_SIZE_RANGE,
97
+ DEFAULT_PLATFORM_WATERMARK_POLICY.fontSize,
98
+ );
99
+ const lineHeight = Math.ceil(safeFontSize * 1.57);
82
100
  const lines = wrapWatermarkContent(content);
83
- const textWidth = Math.max(...lines.map(estimatedTextWidth));
84
- const textHeight = lines.length * LINE_HEIGHT;
101
+ const textWidth = Math.max(
102
+ ...lines.map((line) => estimatedTextWidth(line, safeFontSize)),
103
+ );
104
+ const textHeight = lines.length * lineHeight;
85
105
  const rotatedWidth =
86
106
  textWidth * Math.cos(ROTATION_RADIANS) +
87
107
  textHeight * Math.sin(ROTATION_RADIANS);
@@ -91,17 +111,17 @@ export function createWatermarkPattern(
91
111
  const gap = DENSITY_GAPS[density];
92
112
  const width = Math.ceil(rotatedWidth + gap.horizontal);
93
113
  const height = Math.ceil(rotatedHeight + gap.vertical);
94
- const firstLineY = (height - textHeight) / 2 + FONT_SIZE;
114
+ const firstLineY = (height - textHeight) / 2 + safeFontSize;
95
115
  const tspans = lines
96
116
  .map(
97
117
  (line, index) =>
98
- `<tspan x="${width / 2}" y="${firstLineY + index * LINE_HEIGHT}">${escapeXml(line)}</tspan>`,
118
+ `<tspan x="${width / 2}" y="${firstLineY + index * lineHeight}">${escapeXml(line)}</tspan>`,
99
119
  )
100
120
  .join("");
101
121
  const svg =
102
122
  `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">` +
103
123
  `<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>` +
124
+ `<text text-anchor="middle" font-family="system-ui,sans-serif" font-size="${safeFontSize}" font-weight="500" fill="black">${tspans}</text>` +
105
125
  "</g></svg>";
106
126
  return {
107
127
  image: `url("data:image/svg+xml,${encodeURIComponent(svg)}")`,
@@ -125,8 +145,8 @@ function wrapWatermarkContent(content: string): string[] {
125
145
  return lines.length > 0 ? lines : [""];
126
146
  }
127
147
 
128
- function estimatedTextWidth(value: string): number {
129
- return Math.max(1, textWeight(value)) * (FONT_SIZE * 0.56);
148
+ function estimatedTextWidth(value: string, fontSize: number): number {
149
+ return Math.max(1, textWeight(value)) * (fontSize * 0.56);
130
150
  }
131
151
 
132
152
  function textWeight(value: string): number {
@@ -145,3 +165,12 @@ function escapeXml(value: string): string {
145
165
  .replaceAll('"', "&quot;")
146
166
  .replaceAll("'", "&apos;");
147
167
  }
168
+
169
+ function boundedInteger(
170
+ value: number,
171
+ range: { readonly min: number; readonly max: number },
172
+ fallback: number,
173
+ ): number {
174
+ if (!Number.isFinite(value)) return fallback;
175
+ return Math.min(range.max, Math.max(range.min, Math.round(value)));
176
+ }
@@ -73,8 +73,8 @@ describe("水印设置", () => {
73
73
  test("高密度缩小平铺单元且长内容自动换行以避免相邻水印重叠", () => {
74
74
  const content =
75
75
  "zhangsan · 张三 · 13800138000 · zs@example.com · 内部资料请勿外传";
76
- const low = createWatermarkPattern(content, "low");
77
- const high = createWatermarkPattern(content, "high");
76
+ const low = createWatermarkPattern(content, "low", 14);
77
+ const high = createWatermarkPattern(content, "high", 14);
78
78
  const dimensions = (value: typeof low) =>
79
79
  value.size.match(/\d+/g)!.map(Number);
80
80
 
@@ -86,21 +86,34 @@ describe("水印设置", () => {
86
86
  ).toBeGreaterThan(1);
87
87
  });
88
88
 
89
- test("服务端策略内容会被规范化后渲染", () => {
89
+ test("字体大小会改变平铺尺寸并写入实际 SVG", () => {
90
+ const small = createWatermarkPattern("zhangsan · 张三", "medium", 12);
91
+ const large = createWatermarkPattern("zhangsan · 张三", "medium", 32);
92
+ const width = (value: typeof small) => Number(value.size.match(/\d+/)?.[0]);
93
+
94
+ expect(width(large)).toBeGreaterThan(width(small));
95
+ expect(decodeURIComponent(large.image)).toContain('font-size="32"');
96
+ });
97
+
98
+ test("服务端策略内容会被规范化后渲染且旧范围开关合并到总开关", () => {
90
99
  expect(
91
100
  normalizeWatermarkPolicy({
92
- enabled: false,
93
- businessPagesEnabled: true,
101
+ enabled: true,
102
+ businessPagesEnabled: false,
94
103
  density: "high",
95
104
  contentFields: ["phone", "phone"],
96
105
  customContent: ` ${"x".repeat(100)} `,
106
+ opacityPercent: 99,
107
+ fontSize: 2,
97
108
  }),
98
109
  ).toEqual({
99
110
  enabled: false,
100
- businessPagesEnabled: true,
111
+ businessPagesEnabled: false,
101
112
  density: "high",
102
113
  contentFields: ["phone"],
103
114
  customContent: "x".repeat(80),
115
+ opacityPercent: 30,
116
+ fontSize: 12,
104
117
  });
105
118
  });
106
119
  });
@@ -17,6 +17,8 @@ const state = vi.hoisted(() => {
17
17
  density: "medium",
18
18
  contentFields: ["username", "displayName"],
19
19
  customContent: "",
20
+ opacityPercent: 8,
21
+ fontSize: 14,
20
22
  },
21
23
  };
22
24
 
@@ -31,17 +31,16 @@ const allowed = computed(() => {
31
31
  props.scope === "admin" ? "system.watermark.read" : undefined,
32
32
  }).allowed;
33
33
  });
34
- const scopeEnabled = computed(
35
- () => props.scope === "admin" || watermark.policy.value.businessPagesEnabled,
36
- );
37
34
  const patternStyle = computed<StyleValue>(() => {
38
35
  const pattern = createWatermarkPattern(
39
36
  watermark.content.value,
40
37
  watermark.policy.value.density,
38
+ watermark.policy.value.fontSize,
41
39
  );
42
40
  return {
43
41
  "--watermark-image": pattern.image,
44
42
  "--watermark-size": pattern.size,
43
+ opacity: watermark.policy.value.opacityPercent / 100,
45
44
  };
46
45
  });
47
46
  </script>
@@ -50,12 +49,9 @@ const patternStyle = computed<StyleValue>(() => {
50
49
  <Teleport to="body">
51
50
  <div
52
51
  v-if="
53
- allowed &&
54
- scopeEnabled &&
55
- watermark.policy.value.enabled &&
56
- watermark.content.value
52
+ allowed && watermark.policy.value.enabled && watermark.content.value
57
53
  "
58
- class="global-watermark pointer-events-none fixed inset-0 isolate z-[60] transform-gpu bg-foreground opacity-[0.075] dark:opacity-[0.11]"
54
+ class="global-watermark pointer-events-none fixed inset-0 isolate z-[60] transform-gpu bg-foreground"
59
55
  :style="patternStyle"
60
56
  aria-hidden="true"
61
57
  data-testid="global-watermark"
@@ -41,6 +41,8 @@ const state = vi.hoisted(() => ({
41
41
  density: "medium" as const,
42
42
  contentFields: ["username", "displayName"] as const,
43
43
  customContent: "",
44
+ opacityPercent: 8,
45
+ fontSize: 14,
44
46
  },
45
47
  },
46
48
  content: { value: "zhangsan · 张三" },
@@ -77,11 +79,13 @@ beforeEach(() => {
77
79
  density: "medium" as const,
78
80
  contentFields: ["username", "displayName"] as const,
79
81
  customContent: "",
82
+ opacityPercent: 8,
83
+ fontSize: 14,
80
84
  };
81
85
  });
82
86
 
83
87
  describe("GlobalWatermark", () => {
84
- test("Admin 水印沿用原有总开关且不受业务页面范围影响", () => {
88
+ test("总开关统一控制 Admin 与业务页面水印", () => {
85
89
  const wrapper = mount(GlobalWatermark);
86
90
 
87
91
  expect(wrapper.find('[data-testid="global-watermark"]').exists()).toBe(
@@ -97,7 +101,7 @@ describe("GlobalWatermark", () => {
97
101
  ).toBe(false);
98
102
  });
99
103
 
100
- test("业务页面水印默认开启并可由持久化范围开关关闭", () => {
104
+ test("旧业务范围字段不再形成独立开关", () => {
101
105
  const wrapper = mount(GlobalWatermark, {
102
106
  props: { scope: "business" },
103
107
  });
@@ -111,13 +115,13 @@ describe("GlobalWatermark", () => {
111
115
  ...state.policy.value,
112
116
  businessPagesEnabled: false,
113
117
  };
114
- const disabledWrapper = mount(GlobalWatermark, {
118
+ const legacyWrapper = mount(GlobalWatermark, {
115
119
  props: { scope: "business" },
116
120
  });
117
121
 
118
122
  expect(
119
- disabledWrapper.find('[data-testid="global-watermark"]').exists(),
120
- ).toBe(false);
123
+ legacyWrapper.find('[data-testid="global-watermark"]').exists(),
124
+ ).toBe(true);
121
125
  });
122
126
 
123
127
  test("使用按内容和密度计算的平铺蒙版并保持审计层级", () => {
@@ -128,6 +132,7 @@ describe("GlobalWatermark", () => {
128
132
 
129
133
  expect(element.attributes("style")).toContain("--watermark-image:");
130
134
  expect(element.attributes("style")).toContain("--watermark-size:");
135
+ expect(element.attributes("style")).toContain("opacity: 0.08");
131
136
  expect(element.classes()).toEqual(
132
137
  expect.arrayContaining([
133
138
  "isolate",
@@ -145,6 +150,8 @@ describe("GlobalWatermark", () => {
145
150
  density: "medium",
146
151
  contentFields: ["username", "displayName"],
147
152
  customContent: "",
153
+ opacityPercent: 8,
154
+ fontSize: 14,
148
155
  };
149
156
  state.access.value.features[0]!.allowed = false;
150
157
 
@@ -0,0 +1,72 @@
1
+ <script setup lang="ts">
2
+ import { computed, type StyleValue } from "vue";
3
+ import { createWatermarkPattern } from "@/composables/watermark-settings";
4
+ import type { WatermarkDensity } from "@southwind-ai/shared";
5
+
6
+ const props = defineProps<{
7
+ enabled: boolean;
8
+ content: string;
9
+ density: WatermarkDensity;
10
+ opacityPercent: number;
11
+ fontSize: number;
12
+ }>();
13
+
14
+ const patternStyle = computed<StyleValue>(() => {
15
+ const pattern = createWatermarkPattern(
16
+ props.content,
17
+ props.density,
18
+ props.fontSize,
19
+ );
20
+ return {
21
+ "--watermark-image": pattern.image,
22
+ "--watermark-size": pattern.size,
23
+ opacity: props.opacityPercent / 100,
24
+ };
25
+ });
26
+ </script>
27
+
28
+ <template>
29
+ <section class="grid gap-3" aria-label="水印即时预览">
30
+ <div class="flex items-center justify-between gap-3">
31
+ <h3 class="text-sm font-medium">即时预览</h3>
32
+ <span class="text-xs text-muted-foreground">
33
+ {{ opacityPercent }}% · {{ fontSize }}px
34
+ </span>
35
+ </div>
36
+ <div
37
+ class="relative h-56 overflow-hidden rounded-xl border bg-muted/30"
38
+ data-testid="watermark-preview"
39
+ >
40
+ <div
41
+ v-if="enabled && content"
42
+ class="watermark-preview pointer-events-none absolute inset-0 bg-foreground"
43
+ :style="patternStyle"
44
+ aria-hidden="true"
45
+ data-testid="watermark-preview-pattern"
46
+ />
47
+ <div
48
+ v-else
49
+ class="absolute inset-0 grid place-items-center text-sm text-muted-foreground"
50
+ >
51
+ 水印已关闭
52
+ </div>
53
+ <div
54
+ class="absolute inset-x-6 bottom-6 rounded-lg border bg-background/90 p-4 shadow-sm"
55
+ >
56
+ <p class="text-sm font-medium">业务页面内容示意</p>
57
+ <p class="mt-1 text-xs text-muted-foreground">
58
+ 修改字段、密度、不透明度或字体大小后,此处立即呈现实际效果。
59
+ </p>
60
+ </div>
61
+ </div>
62
+ </section>
63
+ </template>
64
+
65
+ <style scoped>
66
+ .watermark-preview {
67
+ mask-image: var(--watermark-image);
68
+ mask-size: var(--watermark-size);
69
+ -webkit-mask-image: var(--watermark-image);
70
+ -webkit-mask-size: var(--watermark-size);
71
+ }
72
+ </style>