create-windy 0.2.32 → 0.2.34

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 (94) hide show
  1. package/dist/cli.js +9 -1
  2. package/package.json +1 -1
  3. package/template/.agents/skills/windy-business-module/SKILL.md +10 -1
  4. package/template/.windy-template.json +2 -2
  5. package/template/AGENTS.md +11 -0
  6. package/template/apps/agent-server/README.md +10 -0
  7. package/template/apps/agent-server/src/southwind_agent_server/app.py +62 -1
  8. package/template/apps/agent-server/src/southwind_agent_server/contracts.py +35 -0
  9. package/template/apps/agent-server/src/southwind_agent_server/engine.py +2 -0
  10. package/template/apps/agent-server/src/southwind_agent_server/openai_engine.py +31 -3
  11. package/template/apps/agent-server/src/southwind_agent_server/openai_provider.py +22 -9
  12. package/template/apps/agent-server/src/southwind_agent_server/provider_audit.py +144 -0
  13. package/template/apps/agent-server/src/southwind_agent_server/provider_governance.py +227 -0
  14. package/template/apps/agent-server/src/southwind_agent_server/service.py +1 -0
  15. package/template/apps/agent-server/tests/test_api.py +16 -1
  16. package/template/apps/agent-server/tests/test_openai_provider.py +79 -0
  17. package/template/apps/agent-server/tests/test_provider_governance.py +215 -0
  18. package/template/apps/server/src/agent/provider-input-governance.test.ts +82 -0
  19. package/template/apps/server/src/agent/provider-input-governance.ts +140 -0
  20. package/template/apps/server/src/agent/remote-runtime.test.ts +22 -0
  21. package/template/apps/server/src/agent/remote-runtime.ts +1 -0
  22. package/template/apps/server/src/agent/run-contracts.ts +2 -0
  23. package/template/apps/server/src/agent/run-facade.test.ts +52 -2
  24. package/template/apps/server/src/agent/run-facade.ts +36 -2
  25. package/template/apps/server/src/index.ts +28 -45
  26. package/template/apps/server/src/module-host/initialize-contracts.ts +6 -0
  27. package/template/apps/server/src/module-host/initialize.ts +30 -44
  28. package/template/apps/server/src/module-host/request-context.ts +23 -17
  29. package/template/apps/server/src/module-host/resource-file-policies.test.ts +197 -0
  30. package/template/apps/server/src/module-host/resource-file-policies.ts +108 -0
  31. package/template/apps/server/src/module-host/route-declaration.ts +45 -0
  32. package/template/apps/server/src/module-host/route-installer.ts +27 -3
  33. package/template/apps/server/src/module-host/storage-port.test.ts +170 -1
  34. package/template/apps/server/src/module-host/storage-port.ts +134 -1
  35. package/template/apps/server/src/module-host/storage-scope.ts +96 -0
  36. package/template/apps/server/src/module-host/tool-handlers.test.ts +305 -0
  37. package/template/apps/server/src/module-host/tool-handlers.ts +38 -4
  38. package/template/apps/server/src/module-host/user-directory-composition.test.ts +126 -0
  39. package/template/apps/server/src/module-host/user-directory-port.test.ts +238 -0
  40. package/template/apps/server/src/module-host/user-directory-port.ts +143 -0
  41. package/template/apps/server/src/module-host.ts +4 -0
  42. package/template/apps/server/src/runtime-development.ts +1 -0
  43. package/template/apps/server/src/settings/drizzle-watermark-repository.integration.test.ts +4 -0
  44. package/template/apps/server/src/settings/drizzle-watermark-repository.ts +6 -0
  45. package/template/apps/server/src/settings/watermark-routes.test.ts +13 -3
  46. package/template/apps/server/src/settings/watermark-routes.ts +41 -3
  47. package/template/apps/server/src/system/built-in-roles.ts +1 -0
  48. package/template/apps/web/src/app/auth/login-page.ts +4 -0
  49. package/template/apps/web/src/composables/useLoginFlow.webtest.ts +91 -0
  50. package/template/apps/web/src/composables/useWatermarkSettings.ts +7 -3
  51. package/template/apps/web/src/composables/useWatermarkSettings.webtest.ts +14 -0
  52. package/template/apps/web/src/composables/watermark-settings.ts +131 -9
  53. package/template/apps/web/src/composables/watermark-settings.webtest.ts +73 -7
  54. package/template/apps/web/src/layout/GlobalWatermark.layering.webtest.ts +3 -1
  55. package/template/apps/web/src/layout/GlobalWatermark.vue +24 -11
  56. package/template/apps/web/src/layout/GlobalWatermark.webtest.ts +25 -11
  57. package/template/apps/web/src/pages/auth/login-page-extension.ts +9 -0
  58. package/template/apps/web/src/pages/auth/login-page-extension.webtest.ts +10 -0
  59. package/template/apps/web/src/pages/settings/WatermarkSettingsSection.vue +102 -4
  60. package/template/apps/web/src/router/index.ts +2 -2
  61. package/template/apps/web/src/router/shared-scaffold-neutrality.webtest.ts +3 -2
  62. package/template/docker-compose.yml +2 -0
  63. package/template/docs/architecture/ai-egress.md +49 -7
  64. package/template/docs/architecture/ai-runtime.md +20 -1
  65. package/template/docs/architecture/data-scope-query-enforcement.md +4 -0
  66. package/template/docs/architecture/object-storage.md +22 -2
  67. package/template/docs/development/custom-login-page.md +90 -0
  68. package/template/docs/platform/agent-runtime.md +44 -1
  69. package/template/docs/platform/business-user-directory.md +82 -0
  70. package/template/docs/platform/platform-shell-settings.md +14 -9
  71. package/template/docs/platform/web-system-crud.md +1 -1
  72. package/template/packages/database/drizzle/0034_funny_mesmero.sql +5 -0
  73. package/template/packages/database/drizzle/meta/0034_snapshot.json +7253 -0
  74. package/template/packages/database/drizzle/meta/_journal.json +7 -0
  75. package/template/packages/database/src/schema/platform-settings.ts +13 -0
  76. package/template/packages/modules/package.json +1 -1
  77. package/template/packages/modules/src/ai-operation-composition.test.ts +68 -0
  78. package/template/packages/modules/src/ai-operation-composition.ts +115 -0
  79. package/template/packages/modules/src/manifest.ts +26 -0
  80. package/template/packages/modules/src/provider-data-policy-composition.test.ts +74 -0
  81. package/template/packages/modules/src/system-audit-actions.ts +5 -0
  82. package/template/packages/modules/src/system-features.ts +5 -0
  83. package/template/packages/modules/src/system-module-catalog.test.ts +16 -0
  84. package/template/packages/modules/src/system-module-catalog.ts +26 -3
  85. package/template/packages/modules/src/system-permissions.ts +2 -0
  86. package/template/packages/server-sdk/index.ts +11 -0
  87. package/template/packages/server-sdk/package.json +3 -2
  88. package/template/packages/server-sdk/src/ai-operation-registration.ts +2 -1
  89. package/template/packages/server-sdk/src/module-host.ts +8 -0
  90. package/template/packages/server-sdk/src/request-context.ts +2 -0
  91. package/template/packages/server-sdk/src/storage-port.ts +66 -0
  92. package/template/packages/server-sdk/src/tool-registration.ts +6 -0
  93. package/template/packages/server-sdk/src/user-directory-port.ts +42 -0
  94. package/template/packages/shared/src/platform-settings.ts +17 -1
@@ -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
+ }
@@ -1,7 +1,8 @@
1
1
  import { describe, expect, test } from "vitest";
2
2
  import {
3
- defaultWatermarkContent,
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(defaultWatermarkContent({ ...base, phone: "13800138000" })).toBe(
19
- "zhangsan · 13800138000",
20
- );
21
- expect(defaultWatermarkContent({ ...base, email: "zs@example.com" })).toBe(
22
- "zhangsan · zs@example.com",
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
- expect(defaultWatermarkContent(base)).toBe("zhangsan · 张三");
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
  });
@@ -13,7 +13,9 @@ const state = vi.hoisted(() => {
13
13
  const policy = {
14
14
  value: {
15
15
  enabled: true,
16
- businessPagesEnabled: false,
16
+ businessPagesEnabled: true,
17
+ density: "medium",
18
+ contentFields: ["username", "displayName"],
17
19
  customContent: "",
18
20
  },
19
21
  };
@@ -1,8 +1,9 @@
1
1
  <script setup lang="ts">
2
- import { computed, onMounted } from "vue";
2
+ import { computed, onMounted, type StyleValue } from "vue";
3
3
  import { useAuthSession } from "@/composables/useAuthSession";
4
4
  import { useAccessSnapshot } from "@/composables/useAccessSnapshot";
5
5
  import { useWatermarkSettings } from "@/composables/useWatermarkSettings";
6
+ import { createWatermarkPattern } from "@/composables/watermark-settings";
6
7
  import { evaluateWebAccess } from "./navigation";
7
8
 
8
9
  const props = withDefaults(
@@ -33,6 +34,16 @@ const allowed = computed(() => {
33
34
  const scopeEnabled = computed(
34
35
  () => props.scope === "admin" || watermark.policy.value.businessPagesEnabled,
35
36
  );
37
+ const patternStyle = computed<StyleValue>(() => {
38
+ const pattern = createWatermarkPattern(
39
+ watermark.content.value,
40
+ watermark.policy.value.density,
41
+ );
42
+ return {
43
+ "--watermark-image": pattern.image,
44
+ "--watermark-size": pattern.size,
45
+ };
46
+ });
36
47
  </script>
37
48
 
38
49
  <template>
@@ -44,17 +55,19 @@ const scopeEnabled = computed(
44
55
  watermark.policy.value.enabled &&
45
56
  watermark.content.value
46
57
  "
47
- class="pointer-events-none fixed inset-0 isolate z-[60] grid transform-gpu grid-cols-3 grid-rows-6 overflow-hidden opacity-[0.075] dark:opacity-[0.11]"
58
+ class="global-watermark pointer-events-none fixed inset-0 isolate z-[60] transform-gpu bg-foreground opacity-[0.075] dark:opacity-[0.11]"
59
+ :style="patternStyle"
48
60
  aria-hidden="true"
49
61
  data-testid="global-watermark"
50
- >
51
- <span
52
- v-for="index in 18"
53
- :key="index"
54
- class="flex -rotate-[22deg] items-center justify-center whitespace-nowrap px-8 text-sm font-medium text-foreground"
55
- >
56
- {{ watermark.content.value }}
57
- </span>
58
- </div>
62
+ />
59
63
  </Teleport>
60
64
  </template>
65
+
66
+ <style scoped>
67
+ .global-watermark {
68
+ mask-image: var(--watermark-image);
69
+ mask-size: var(--watermark-size);
70
+ -webkit-mask-image: var(--watermark-image);
71
+ -webkit-mask-size: var(--watermark-size);
72
+ }
73
+ </style>
@@ -37,7 +37,9 @@ const state = vi.hoisted(() => ({
37
37
  policy: {
38
38
  value: {
39
39
  enabled: true,
40
- businessPagesEnabled: false,
40
+ businessPagesEnabled: true,
41
+ density: "medium" as const,
42
+ contentFields: ["username", "displayName"] as const,
41
43
  customContent: "",
42
44
  },
43
45
  },
@@ -71,7 +73,9 @@ config.global.stubs = {
71
73
  beforeEach(() => {
72
74
  state.policy.value = {
73
75
  enabled: true,
74
- businessPagesEnabled: false,
76
+ businessPagesEnabled: true,
77
+ density: "medium" as const,
78
+ contentFields: ["username", "displayName"] as const,
75
79
  customContent: "",
76
80
  };
77
81
  });
@@ -93,30 +97,38 @@ describe("GlobalWatermark", () => {
93
97
  ).toBe(false);
94
98
  });
95
99
 
96
- test("业务页面水印默认关闭并由持久化范围开关启用", () => {
100
+ test("业务页面水印默认开启并可由持久化范围开关关闭", () => {
97
101
  const wrapper = mount(GlobalWatermark, {
98
102
  props: { scope: "business" },
99
103
  });
100
104
 
101
105
  expect(wrapper.find('[data-testid="global-watermark"]').exists()).toBe(
102
- false,
106
+ true,
103
107
  );
104
108
  wrapper.unmount();
105
109
 
106
110
  state.policy.value = {
107
111
  ...state.policy.value,
108
- businessPagesEnabled: true,
112
+ businessPagesEnabled: false,
109
113
  };
110
- const enabledWrapper = mount(GlobalWatermark, {
114
+ const disabledWrapper = mount(GlobalWatermark, {
111
115
  props: { scope: "business" },
112
116
  });
113
117
 
114
118
  expect(
115
- enabledWrapper.get('[data-testid="global-watermark"]').text(),
116
- ).toContain("zhangsan · 张三");
117
- expect(
118
- enabledWrapper.get('[data-testid="global-watermark"]').classes(),
119
- ).toEqual(
119
+ disabledWrapper.find('[data-testid="global-watermark"]').exists(),
120
+ ).toBe(false);
121
+ });
122
+
123
+ test("使用按内容和密度计算的平铺蒙版并保持审计层级", () => {
124
+ const wrapper = mount(GlobalWatermark, {
125
+ props: { scope: "business" },
126
+ });
127
+ const element = wrapper.get('[data-testid="global-watermark"]');
128
+
129
+ expect(element.attributes("style")).toContain("--watermark-image:");
130
+ expect(element.attributes("style")).toContain("--watermark-size:");
131
+ expect(element.classes()).toEqual(
120
132
  expect.arrayContaining([
121
133
  "isolate",
122
134
  "z-[60]",
@@ -130,6 +142,8 @@ describe("GlobalWatermark", () => {
130
142
  state.policy.value = {
131
143
  enabled: true,
132
144
  businessPagesEnabled: true,
145
+ density: "medium",
146
+ contentFields: ["username", "displayName"],
133
147
  customContent: "",
134
148
  };
135
149
  state.access.value.features[0]!.allowed = false;
@@ -0,0 +1,9 @@
1
+ import type { Component } from "vue";
2
+
3
+ export type LoginPageComponent = Component;
4
+
5
+ export function defineLoginPageComponent(
6
+ component: LoginPageComponent,
7
+ ): LoginPageComponent {
8
+ return component;
9
+ }
@@ -0,0 +1,10 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { defineLoginPageComponent } from "./login-page-extension";
3
+
4
+ describe("登录页组件注册 helper", () => {
5
+ test("业务组件保持自身 Vue Component 类型与引用", () => {
6
+ const BusinessLoginPage = { name: "BusinessLoginPage" };
7
+
8
+ expect(defineLoginPageComponent(BusinessLoginPage)).toBe(BusinessLoginPage);
9
+ });
10
+ });
@@ -1,14 +1,43 @@
1
1
  <script setup lang="ts">
2
2
  import { computed, onMounted, shallowRef } from "vue";
3
3
  import { Button } from "@/components/ui/button";
4
+ import { Checkbox } from "@/components/ui/checkbox";
4
5
  import { Input } from "@/components/ui/input";
6
+ import {
7
+ Select,
8
+ SelectContent,
9
+ SelectItem,
10
+ SelectTrigger,
11
+ SelectValue,
12
+ } from "@/components/ui/select";
5
13
  import { Switch } from "@/components/ui/switch";
6
14
  import { useAccessSnapshot } from "@/composables/useAccessSnapshot";
7
15
  import { useAppToast } from "@/composables/useAppToast";
8
16
  import { useAuthSession } from "@/composables/useAuthSession";
9
17
  import { useWatermarkSettings } from "@/composables/useWatermarkSettings";
10
18
  import { evaluateWebAccess } from "@/layout/navigation";
11
- import { DEFAULT_PLATFORM_WATERMARK_POLICY } from "@southwind-ai/shared";
19
+ import {
20
+ DEFAULT_PLATFORM_WATERMARK_POLICY,
21
+ type WatermarkContentField,
22
+ type WatermarkDensity,
23
+ } from "@southwind-ai/shared";
24
+
25
+ const densityOptions: { value: WatermarkDensity; label: string }[] = [
26
+ { value: "low", label: "低" },
27
+ { value: "medium", label: "中" },
28
+ { value: "high", label: "高" },
29
+ ];
30
+ const contentOptions: {
31
+ value: WatermarkContentField;
32
+ label: string;
33
+ description: string;
34
+ }[] = [
35
+ { value: "username", label: "用户名", description: "账号登录名" },
36
+ { value: "displayName", label: "姓名", description: "用户显示姓名" },
37
+ { value: "phone", label: "手机号", description: "已登记的手机号码" },
38
+ { value: "email", label: "邮箱", description: "已登记的邮箱地址" },
39
+ { value: "custom", label: "自定义内容", description: "平台统一文字" },
40
+ ];
12
41
 
13
42
  const auth = useAuthSession();
14
43
  const access = useAccessSnapshot();
@@ -18,6 +47,10 @@ const enabled = shallowRef(watermark.policy.value.enabled);
18
47
  const businessPagesEnabled = shallowRef(
19
48
  watermark.policy.value.businessPagesEnabled,
20
49
  );
50
+ const density = shallowRef<WatermarkDensity>(watermark.policy.value.density);
51
+ const contentFields = shallowRef<WatermarkContentField[]>([
52
+ ...watermark.policy.value.contentFields,
53
+ ]);
21
54
  const customContent = shallowRef(watermark.policy.value.customContent);
22
55
  const saving = shallowRef(false);
23
56
  const baseline = shallowRef(serializeForm());
@@ -55,6 +88,8 @@ async function save(): Promise<boolean> {
55
88
  await watermark.save({
56
89
  enabled: enabled.value,
57
90
  businessPagesEnabled: businessPagesEnabled.value,
91
+ density: density.value,
92
+ contentFields: contentFields.value,
58
93
  customContent: customContent.value,
59
94
  });
60
95
  applyPolicyToForm();
@@ -74,6 +109,8 @@ function reset() {
74
109
  enabled.value = DEFAULT_PLATFORM_WATERMARK_POLICY.enabled;
75
110
  businessPagesEnabled.value =
76
111
  DEFAULT_PLATFORM_WATERMARK_POLICY.businessPagesEnabled;
112
+ density.value = DEFAULT_PLATFORM_WATERMARK_POLICY.density;
113
+ contentFields.value = [...DEFAULT_PLATFORM_WATERMARK_POLICY.contentFields];
77
114
  customContent.value = DEFAULT_PLATFORM_WATERMARK_POLICY.customContent;
78
115
  toast.success("已恢复为默认值,保存后生效");
79
116
  }
@@ -81,6 +118,8 @@ function reset() {
81
118
  function applyPolicyToForm() {
82
119
  enabled.value = watermark.policy.value.enabled;
83
120
  businessPagesEnabled.value = watermark.policy.value.businessPagesEnabled;
121
+ density.value = watermark.policy.value.density;
122
+ contentFields.value = [...watermark.policy.value.contentFields];
84
123
  customContent.value = watermark.policy.value.customContent;
85
124
  baseline.value = serializeForm();
86
125
  }
@@ -89,10 +128,24 @@ function serializeForm() {
89
128
  return JSON.stringify({
90
129
  enabled: enabled.value,
91
130
  businessPagesEnabled: businessPagesEnabled.value,
131
+ density: density.value,
132
+ contentFields: [...contentFields.value].sort(),
92
133
  customContent: customContent.value.trim(),
93
134
  });
94
135
  }
95
136
 
137
+ function toggleContentField(field: WatermarkContentField, checked: boolean) {
138
+ if (checked) {
139
+ contentFields.value = [...new Set([...contentFields.value, field])];
140
+ return;
141
+ }
142
+ if (contentFields.value.length === 1) {
143
+ toast.error("请至少保留一项水印内容");
144
+ return;
145
+ }
146
+ contentFields.value = contentFields.value.filter((value) => value !== field);
147
+ }
148
+
96
149
  defineExpose({
97
150
  isDirty: () => isDirty.value,
98
151
  save,
@@ -134,15 +187,60 @@ defineExpose({
134
187
  />
135
188
  </label>
136
189
  <label class="grid gap-2 text-sm font-medium">
190
+ <span>水印密度</span>
191
+ <Select v-model="density" :disabled="formDisabled || !enabled">
192
+ <SelectTrigger aria-label="水印密度">
193
+ <SelectValue placeholder="选择密度" />
194
+ </SelectTrigger>
195
+ <SelectContent>
196
+ <SelectItem
197
+ v-for="option in densityOptions"
198
+ :key="option.value"
199
+ :value="option.value"
200
+ >
201
+ {{ option.label }}
202
+ </SelectItem>
203
+ </SelectContent>
204
+ </Select>
205
+ <span class="text-xs font-normal text-muted-foreground">
206
+ 系统会根据内容长度和密度自动计算安全间距,避免水印重叠。
207
+ </span>
208
+ </label>
209
+ <fieldset class="grid gap-3" :disabled="formDisabled || !enabled">
210
+ <legend class="mb-1 text-sm font-medium">水印内容</legend>
211
+ <label
212
+ v-for="option in contentOptions"
213
+ :key="option.value"
214
+ class="flex items-center gap-3 rounded-lg border p-3"
215
+ >
216
+ <Checkbox
217
+ :model-value="contentFields.includes(option.value)"
218
+ :aria-label="option.label"
219
+ @update:model-value="
220
+ toggleContentField(option.value, Boolean($event))
221
+ "
222
+ />
223
+ <span>
224
+ <span class="block text-sm font-medium">{{ option.label }}</span>
225
+ <span class="block text-xs text-muted-foreground">
226
+ {{ option.description }}
227
+ </span>
228
+ </span>
229
+ </label>
230
+ </fieldset>
231
+ <label
232
+ v-if="contentFields.includes('custom')"
233
+ class="grid gap-2 text-sm font-medium"
234
+ >
137
235
  <span>自定义内容</span>
138
236
  <Input
139
237
  v-model="customContent"
140
- :disabled="formDisabled"
238
+ :disabled="formDisabled || !enabled"
141
239
  maxlength="80"
142
- :placeholder="watermark.content.value || '使用各用户的账号信息'"
240
+ placeholder="例如:内部资料,请勿外传"
143
241
  />
144
242
  <span class="text-xs font-normal text-muted-foreground">
145
- 留空时为每位用户自动使用其账号身份;保存后全局生效。
243
+ 最多 80 个字符,与勾选的用户身份内容一同显示。
146
244
  </span>
147
245
  </label>
148
246
  <div v-if="canManage" class="flex justify-end gap-3 border-t pt-5">