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.
- package/dist/cli.js +5 -0
- package/package.json +1 -1
- package/template/.windy-template.json +2 -2
- package/template/apps/server/src/settings/drizzle-watermark-repository.integration.test.ts +4 -0
- package/template/apps/server/src/settings/drizzle-watermark-repository.ts +11 -3
- package/template/apps/server/src/settings/watermark-routes.test.ts +54 -1
- package/template/apps/server/src/settings/watermark-routes.ts +29 -1
- package/template/apps/web/src/composables/useWatermarkSettings.webtest.ts +14 -0
- package/template/apps/web/src/composables/watermark-settings.ts +40 -11
- package/template/apps/web/src/composables/watermark-settings.webtest.ts +19 -6
- package/template/apps/web/src/layout/GlobalWatermark.layering.webtest.ts +2 -0
- package/template/apps/web/src/layout/GlobalWatermark.vue +4 -8
- package/template/apps/web/src/layout/GlobalWatermark.webtest.ts +12 -5
- package/template/apps/web/src/pages/settings/WatermarkPreview.vue +72 -0
- package/template/apps/web/src/pages/settings/WatermarkPreview.webtest.ts +55 -0
- package/template/apps/web/src/pages/settings/WatermarkSettingsSection.vue +80 -24
- package/template/apps/web/src/pages/settings/WatermarkSettingsSection.webtest.ts +115 -0
- package/template/docs/platform/platform-shell-settings.md +15 -10
- package/template/docs/platform/web-system-crud.md +1 -1
- package/template/packages/database/drizzle/0044_sweet_tarantula.sql +6 -0
- package/template/packages/database/drizzle/meta/0044_snapshot.json +8349 -0
- package/template/packages/database/drizzle/meta/_journal.json +7 -0
- package/template/packages/database/src/schema/platform-settings.ts +3 -0
- 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
|
@@ -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.
|
|
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
|
|
63
|
-
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: [
|
|
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.
|
|
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
|
|
63
|
-
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(
|
|
84
|
-
|
|
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 +
|
|
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 *
|
|
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="${
|
|
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)) * (
|
|
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('"', """)
|
|
146
166
|
.replaceAll("'", "'");
|
|
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:
|
|
93
|
-
businessPagesEnabled:
|
|
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:
|
|
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
|
});
|
|
@@ -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
|
|
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
|
|
118
|
+
const legacyWrapper = mount(GlobalWatermark, {
|
|
115
119
|
props: { scope: "business" },
|
|
116
120
|
});
|
|
117
121
|
|
|
118
122
|
expect(
|
|
119
|
-
|
|
120
|
-
).toBe(
|
|
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>
|