create-windy 0.4.0 → 0.5.0

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 (194) hide show
  1. package/dist/cli.js +263 -121
  2. package/package.json +1 -1
  3. package/template/.windy-template.json +2 -2
  4. package/template/apps/agent-server/adk_web_agents/canonical_debug/agent.py +29 -9
  5. package/template/apps/agent-server/adk_web_agents/services.py +25 -0
  6. package/template/apps/agent-server/src/southwind_agent_server/adk_adapter.py +40 -11
  7. package/template/apps/agent-server/src/southwind_agent_server/adk_debug_sessions.py +325 -0
  8. package/template/apps/agent-server/src/southwind_agent_server/adk_model.py +70 -6
  9. package/template/apps/agent-server/src/southwind_agent_server/adk_tools.py +1 -1
  10. package/template/apps/agent-server/src/southwind_agent_server/app.py +16 -4
  11. package/template/apps/agent-server/src/southwind_agent_server/approval_resolution.py +30 -0
  12. package/template/apps/agent-server/src/southwind_agent_server/contracts.py +9 -0
  13. package/template/apps/agent-server/src/southwind_agent_server/deterministic.py +2 -1
  14. package/template/apps/agent-server/src/southwind_agent_server/engine.py +3 -1
  15. package/template/apps/agent-server/src/southwind_agent_server/openai_engine.py +95 -130
  16. package/template/apps/agent-server/src/southwind_agent_server/openai_engine_validation.py +133 -0
  17. package/template/apps/agent-server/src/southwind_agent_server/openai_provider.py +89 -20
  18. package/template/apps/agent-server/src/southwind_agent_server/postgres_report_insert.py +34 -0
  19. package/template/apps/agent-server/src/southwind_agent_server/postgres_report_queries.py +234 -0
  20. package/template/apps/agent-server/src/southwind_agent_server/postgres_report_replay.py +44 -0
  21. package/template/apps/agent-server/src/southwind_agent_server/postgres_report_status.py +83 -0
  22. package/template/apps/agent-server/src/southwind_agent_server/postgres_report_store.py +331 -0
  23. package/template/apps/agent-server/src/southwind_agent_server/postgres_report_summary.py +304 -0
  24. package/template/apps/agent-server/src/southwind_agent_server/report_lifecycle.py +217 -0
  25. package/template/apps/agent-server/src/southwind_agent_server/report_metrics.py +149 -0
  26. package/template/apps/agent-server/src/southwind_agent_server/report_presenter.py +280 -0
  27. package/template/apps/agent-server/src/southwind_agent_server/report_routes.py +126 -0
  28. package/template/apps/agent-server/src/southwind_agent_server/report_store.py +344 -0
  29. package/template/apps/agent-server/src/southwind_agent_server/report_types.py +111 -0
  30. package/template/apps/agent-server/src/southwind_agent_server/runtime_factory.py +31 -2
  31. package/template/apps/agent-server/src/southwind_agent_server/service.py +56 -51
  32. package/template/apps/agent-server/src/southwind_agent_server/signal_events.py +18 -1
  33. package/template/apps/agent-server/tests/test_adk_adapter.py +7 -9
  34. package/template/apps/agent-server/tests/test_adk_debug_sessions.py +215 -0
  35. package/template/apps/agent-server/tests/test_adk_web_canonical_debug.py +48 -1
  36. package/template/apps/agent-server/tests/test_openai_provider.py +58 -6
  37. package/template/apps/agent-server/tests/test_postgres_report_status.py +59 -0
  38. package/template/apps/agent-server/tests/test_postgres_report_summary.py +298 -0
  39. package/template/apps/agent-server/tests/test_provider_attempt_reporting.py +152 -0
  40. package/template/apps/agent-server/tests/test_report_attempt_lifecycle.py +126 -0
  41. package/template/apps/agent-server/tests/test_report_resilience.py +181 -0
  42. package/template/apps/agent-server/tests/test_report_store.py +286 -0
  43. package/template/apps/agent-server/tests/test_report_summary_parity.py +115 -0
  44. package/template/apps/agent-server/tests/test_report_terminal_sync.py +177 -0
  45. package/template/apps/server/src/agent/debug-session-facade-provider-failure.test.ts +269 -0
  46. package/template/apps/server/src/agent/debug-session-facade.test.ts +335 -0
  47. package/template/apps/server/src/agent/debug-session-facade.ts +302 -0
  48. package/template/apps/server/src/agent/debug-session-id.test.ts +37 -0
  49. package/template/apps/server/src/agent/debug-session-id.ts +37 -0
  50. package/template/apps/server/src/agent/debug-session-provider-reader.ts +54 -0
  51. package/template/apps/server/src/agent/debug-session-routes.test.ts +64 -0
  52. package/template/apps/server/src/agent/debug-session-routes.ts +73 -0
  53. package/template/apps/server/src/agent/debug-session-validation.ts +106 -0
  54. package/template/apps/server/src/agent/drizzle-run-binding-repository.ts +2 -0
  55. package/template/apps/server/src/agent/provider-input-governance.test.ts +18 -0
  56. package/template/apps/server/src/agent/provider-input-governance.ts +37 -1
  57. package/template/apps/server/src/agent/remote-runtime.test.ts +7 -0
  58. package/template/apps/server/src/agent/remote-runtime.ts +1 -0
  59. package/template/apps/server/src/agent/run-authorization.test.ts +98 -0
  60. package/template/apps/server/src/agent/run-authorization.ts +32 -1
  61. package/template/apps/server/src/agent/run-binding-repository.ts +1 -0
  62. package/template/apps/server/src/agent/run-contracts.ts +5 -0
  63. package/template/apps/server/src/agent/run-facade-adk-debug.test.ts +39 -0
  64. package/template/apps/server/src/agent/run-facade.test.ts +78 -0
  65. package/template/apps/server/src/agent/run-facade.ts +33 -3
  66. package/template/apps/server/src/agent/run-input-commitment.ts +50 -0
  67. package/template/apps/server/src/agent/run-input-verification.ts +41 -0
  68. package/template/apps/server/src/agent/runtime-bootstrap-report.test.ts +136 -0
  69. package/template/apps/server/src/agent/runtime-bootstrap.ts +66 -1
  70. package/template/apps/server/src/agent/tool-host.test.ts +1 -0
  71. package/template/apps/server/src/agent/unavailable-routes.ts +4 -0
  72. package/template/apps/server/src/ai-runtime-report/actor-directory.ts +94 -0
  73. package/template/apps/server/src/ai-runtime-report/actor-handle.test.ts +27 -0
  74. package/template/apps/server/src/ai-runtime-report/actor-handle.ts +81 -0
  75. package/template/apps/server/src/ai-runtime-report/contracts.ts +91 -0
  76. package/template/apps/server/src/ai-runtime-report/remote-parser.ts +212 -0
  77. package/template/apps/server/src/ai-runtime-report/remote-port.ts +138 -0
  78. package/template/apps/server/src/ai-runtime-report/routes.ts +172 -0
  79. package/template/apps/server/src/ai-runtime-report/scope.ts +63 -0
  80. package/template/apps/server/src/ai-runtime-report/service.test.ts +271 -0
  81. package/template/apps/server/src/ai-runtime-report/service.ts +206 -0
  82. package/template/apps/server/src/license/runtime-audit-reliability.test.ts +2 -0
  83. package/template/apps/server/src/license/runtime-service.test.ts +6 -0
  84. package/template/apps/server/src/module-host/ai-operation-port.test.ts +63 -0
  85. package/template/apps/server/src/module-host/ai-operation-port.ts +30 -5
  86. package/template/apps/server/src/module-host/debug-session-provider-registration.test.ts +162 -0
  87. package/template/apps/server/src/module-host/debug-session-provider-registration.ts +84 -0
  88. package/template/apps/server/src/module-host/initialize-contracts.ts +7 -0
  89. package/template/apps/server/src/module-host/initialize.ts +6 -197
  90. package/template/apps/server/src/module-host/request-context.ts +1 -0
  91. package/template/apps/server/src/module-host/scoped-host.ts +214 -0
  92. package/template/apps/server/src/module-host.ts +1 -0
  93. package/template/apps/server/src/runtime-adk-debug.test.ts +25 -1
  94. package/template/apps/server/src/runtime-adk-debug.ts +8 -2
  95. package/template/apps/server/src/settings/drizzle-watermark-repository.integration.test.ts +4 -0
  96. package/template/apps/server/src/settings/drizzle-watermark-repository.ts +11 -3
  97. package/template/apps/server/src/settings/watermark-routes.test.ts +54 -1
  98. package/template/apps/server/src/settings/watermark-routes.ts +29 -1
  99. package/template/apps/server/src/system/operations.test.ts +54 -0
  100. package/template/apps/server/src/system/operations.ts +23 -1
  101. package/template/apps/server/src/system/seed.test.ts +4 -0
  102. package/template/apps/server/src/system/system-resource-monitoring.test.ts +82 -0
  103. package/template/apps/server/src/system/system-resource-monitoring.ts +300 -0
  104. package/template/apps/web/src/composables/useWatermarkSettings.webtest.ts +14 -0
  105. package/template/apps/web/src/composables/watermark-settings.ts +40 -11
  106. package/template/apps/web/src/composables/watermark-settings.webtest.ts +19 -6
  107. package/template/apps/web/src/layout/AppSidebar.vue +6 -1
  108. package/template/apps/web/src/layout/AppSidebar.webtest.ts +2 -0
  109. package/template/apps/web/src/layout/GlobalWatermark.layering.webtest.ts +2 -0
  110. package/template/apps/web/src/layout/GlobalWatermark.vue +4 -8
  111. package/template/apps/web/src/layout/GlobalWatermark.webtest.ts +12 -5
  112. package/template/apps/web/src/pages/auth/LoginPage.vue +6 -1
  113. package/template/apps/web/src/pages/settings/BrandingSettingsSection.vue +20 -3
  114. package/template/apps/web/src/pages/settings/BrandingSettingsSection.webtest.ts +80 -0
  115. package/template/apps/web/src/pages/settings/WatermarkPreview.vue +72 -0
  116. package/template/apps/web/src/pages/settings/WatermarkPreview.webtest.ts +55 -0
  117. package/template/apps/web/src/pages/settings/WatermarkSettingsSection.vue +80 -24
  118. package/template/apps/web/src/pages/settings/WatermarkSettingsSection.webtest.ts +115 -0
  119. package/template/apps/web/src/pages/system/AiRuntimePage.vue +129 -0
  120. package/template/apps/web/src/pages/system/SystemOperationsPage.vue +2 -1
  121. package/template/apps/web/src/pages/system/ai-runtime-presenter.ts +60 -0
  122. package/template/apps/web/src/pages/system/ai-runtime-presenter.webtest.ts +48 -0
  123. package/template/apps/web/src/pages/system/audit-time-range.ts +34 -2
  124. package/template/apps/web/src/pages/system/components/AuditLogFilters.vue +4 -3
  125. package/template/apps/web/src/pages/system/components/AuditLogFilters.webtest.ts +26 -1
  126. package/template/apps/web/src/pages/system/components/OperationGpuMonitor.vue +98 -0
  127. package/template/apps/web/src/pages/system/components/OperationGpuMonitor.webtest.ts +61 -0
  128. package/template/apps/web/src/pages/system/components/OperationServiceMonitor.vue +53 -0
  129. package/template/apps/web/src/pages/system/components/OperationServiceMonitor.webtest.ts +20 -1
  130. package/template/apps/web/src/pages/system/components/SystemResourcePage.webtest.ts +14 -1
  131. package/template/apps/web/src/pages/system/components/ai-runtime/AiRuntimeActivityChart.vue +85 -0
  132. package/template/apps/web/src/pages/system/components/ai-runtime/AiRuntimeCharts.webtest.ts +82 -0
  133. package/template/apps/web/src/pages/system/components/ai-runtime/AiRuntimeFilters.vue +98 -0
  134. package/template/apps/web/src/pages/system/components/ai-runtime/AiRuntimeFilters.webtest.ts +27 -0
  135. package/template/apps/web/src/pages/system/components/ai-runtime/AiRuntimeRunDetailDialog.vue +168 -0
  136. package/template/apps/web/src/pages/system/components/ai-runtime/AiRuntimeRunTable.vue +130 -0
  137. package/template/apps/web/src/pages/system/components/ai-runtime/AiRuntimeSummaryCards.vue +82 -0
  138. package/template/apps/web/src/pages/system/components/ai-runtime/AiRuntimeSummaryCards.webtest.ts +102 -0
  139. package/template/apps/web/src/pages/system/components/ai-runtime/AiRuntimeTokenByModelChart.vue +101 -0
  140. package/template/apps/web/src/pages/system/composables/useAiRuntimeReport.ts +185 -0
  141. package/template/apps/web/src/pages/system/composables/useAiRuntimeReport.webtest.ts +158 -0
  142. package/template/apps/web/src/pages/system/composables/useSystemResource.ts +21 -7
  143. package/template/apps/web/src/router/error-navigation.ts +22 -0
  144. package/template/apps/web/src/router/index.ts +12 -23
  145. package/template/apps/web/src/router/platform-admin-route-bindings.ts +4 -0
  146. package/template/apps/web/src/services/ai-runtime-api.ts +164 -0
  147. package/template/apps/web/src/services/ai-runtime-api.webtest.ts +111 -0
  148. package/template/apps/web/src/services/operations-api.ts +28 -0
  149. package/template/docker-compose.agent-debug.yml +1 -1
  150. package/template/docs/architecture/ai-runtime.md +2 -1
  151. package/template/docs/architecture/module-host.md +3 -0
  152. package/template/docs/platform/agent-run-attestation.md +65 -0
  153. package/template/docs/platform/agent-runtime-observability.md +135 -0
  154. package/template/docs/platform/agent-runtime.md +47 -3
  155. package/template/docs/platform/operations-overview.md +13 -0
  156. package/template/docs/platform/platform-authentication.md +4 -3
  157. package/template/docs/platform/platform-shell-settings.md +15 -10
  158. package/template/docs/platform/web-system-crud.md +1 -1
  159. package/template/packages/config/package.json +1 -1
  160. package/template/packages/database/drizzle/0044_sweet_tarantula.sql +6 -0
  161. package/template/packages/database/drizzle/0045_tiny_cassandra_nova.sql +65 -0
  162. package/template/packages/database/drizzle/0046_tranquil_living_mummy.sql +6 -0
  163. package/template/packages/database/drizzle/0047_oval_luke_cage.sql +18 -0
  164. package/template/packages/database/drizzle/meta/0044_snapshot.json +8349 -0
  165. package/template/packages/database/drizzle/meta/0045_snapshot.json +8814 -0
  166. package/template/packages/database/drizzle/meta/0046_snapshot.json +8820 -0
  167. package/template/packages/database/drizzle/meta/0047_snapshot.json +8835 -0
  168. package/template/packages/database/drizzle/meta/_journal.json +28 -0
  169. package/template/packages/database/package.json +1 -1
  170. package/template/packages/database/src/agent-runtime-schema.test.ts +54 -0
  171. package/template/packages/database/src/schema/agent-runtime.ts +114 -0
  172. package/template/packages/database/src/schema/platform-settings.ts +3 -0
  173. package/template/packages/jobs/package.json +1 -1
  174. package/template/packages/modules/package.json +1 -1
  175. package/template/packages/modules/src/system-admin-routes.ts +10 -0
  176. package/template/packages/modules/src/system-admin-ui-catalog.ts +1 -1
  177. package/template/packages/modules/src/system-api-control-plane.ts +17 -1
  178. package/template/packages/modules/src/system-audit-actions.ts +10 -0
  179. package/template/packages/modules/src/system-features.ts +8 -0
  180. package/template/packages/modules/src/system-module-catalog.test.ts +35 -0
  181. package/template/packages/modules/src/system-module-catalog.ts +15 -4
  182. package/template/packages/modules/src/system-modules-access.test.ts +69 -0
  183. package/template/packages/modules/src/system-modules.ts +13 -0
  184. package/template/packages/modules/src/system-permissions.ts +2 -0
  185. package/template/packages/server-sdk/index.ts +11 -0
  186. package/template/packages/server-sdk/package.json +2 -1
  187. package/template/packages/server-sdk/src/ai-operation-port.ts +5 -0
  188. package/template/packages/server-sdk/src/debug-session-provider.ts +63 -0
  189. package/template/packages/server-sdk/src/module-host.ts +5 -0
  190. package/template/packages/shared/index.ts +3 -0
  191. package/template/packages/shared/package.json +1 -1
  192. package/template/packages/shared/src/ai-runtime-report.ts +134 -0
  193. package/template/packages/shared/src/platform-settings.ts +17 -0
  194. package/template/scripts/lib/postgres-regression.ts +201 -0
@@ -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
 
@@ -25,7 +25,12 @@ const developmentAuth = developmentAuthStatus();
25
25
  <div class="w-full max-w-md space-y-4">
26
26
  <div class="flex items-center justify-center gap-3">
27
27
  <span
28
- class="flex size-10 items-center justify-center rounded-xl bg-primary text-primary-foreground"
28
+ class="flex size-10 items-center justify-center rounded-xl"
29
+ :class="
30
+ platform.branding.value.logoUrl
31
+ ? 'bg-transparent text-foreground'
32
+ : 'bg-primary text-primary-foreground'
33
+ "
29
34
  >
30
35
  <img
31
36
  v-if="platform.branding.value.logoUrl"
@@ -13,6 +13,12 @@ const access = useAccessSnapshot();
13
13
  const toast = useAppToast();
14
14
  const softwareName = shallowRef("");
15
15
  const logoUrl = shallowRef("");
16
+ const logoUrlInput = computed({
17
+ get: () => (logoUrl.value.startsWith("data:") ? "" : logoUrl.value),
18
+ set: (value: string) => {
19
+ logoUrl.value = value;
20
+ },
21
+ });
16
22
  const description = shallowRef("");
17
23
  const readingLogo = shallowRef(false);
18
24
  const loaded = shallowRef(false);
@@ -145,11 +151,17 @@ defineExpose({
145
151
  </Button>
146
152
  </div>
147
153
  <Input
148
- v-model="logoUrl"
154
+ v-model="logoUrlInput"
149
155
  :disabled="!canManage"
150
- maxlength="1398128"
156
+ maxlength="2048"
151
157
  placeholder="或填写 https://example.com/logo.png"
152
158
  />
159
+ <span
160
+ v-if="logoUrl.startsWith('data:')"
161
+ class="text-xs font-normal text-muted-foreground"
162
+ >
163
+ 当前使用本地图片,编码内容不在输入框中显示。
164
+ </span>
153
165
  <span class="text-xs font-normal text-muted-foreground">
154
166
  本地文件支持 PNG、JPEG、WebP,最大
155
167
  {{ maxLocalLogoBytes / 1024 }} KiB;也可使用 HTTPS 或站内路径。
@@ -159,7 +171,12 @@ defineExpose({
159
171
  <p class="mb-3 text-xs font-medium text-muted-foreground">预览</p>
160
172
  <div class="flex items-center gap-3">
161
173
  <div
162
- class="flex size-12 items-center justify-center overflow-hidden rounded-lg bg-primary text-primary-foreground"
174
+ class="flex size-12 items-center justify-center overflow-hidden rounded-lg"
175
+ :class="
176
+ logoUrl
177
+ ? 'bg-transparent text-foreground'
178
+ : 'bg-primary text-primary-foreground'
179
+ "
163
180
  >
164
181
  <img
165
182
  v-if="logoUrl"
@@ -0,0 +1,80 @@
1
+ import { flushPromises, mount } from "@vue/test-utils";
2
+ import { beforeEach, describe, expect, test, vi } from "vitest";
3
+ import { shallowRef } from "vue";
4
+ import BrandingSettingsSection from "./BrandingSettingsSection.vue";
5
+
6
+ const mocks = vi.hoisted(() => ({
7
+ refresh: vi.fn(),
8
+ save: vi.fn(),
9
+ success: vi.fn(),
10
+ error: vi.fn(),
11
+ }));
12
+
13
+ vi.mock("@/composables/useAccessSnapshot", () => ({
14
+ useAccessSnapshot: () => ({
15
+ snapshot: shallowRef({
16
+ actor: { permissionKeys: ["system.settings.manage"] },
17
+ }),
18
+ }),
19
+ }));
20
+
21
+ vi.mock("@/composables/useAppToast", () => ({
22
+ useAppToast: () => ({ success: mocks.success, error: mocks.error }),
23
+ }));
24
+
25
+ vi.mock("@/composables/usePlatformSettings", () => ({
26
+ usePlatformSettings: () => ({
27
+ refresh: mocks.refresh,
28
+ save: mocks.save,
29
+ saving: shallowRef(false),
30
+ error: shallowRef(""),
31
+ }),
32
+ }));
33
+
34
+ describe("品牌基本信息", () => {
35
+ beforeEach(() => {
36
+ mocks.refresh.mockReset();
37
+ mocks.save.mockReset();
38
+ mocks.success.mockReset();
39
+ mocks.error.mockReset();
40
+ });
41
+
42
+ test("本地 Logo 保留预览但不在地址输入框暴露 Base64,且不添加底色", async () => {
43
+ const localLogo = "data:image/png;base64,AA==";
44
+ mocks.refresh.mockResolvedValue({
45
+ softwareName: "利剑",
46
+ logoUrl: localLogo,
47
+ description: "企业级 Dashboard 产品基座",
48
+ });
49
+
50
+ const wrapper = mount(BrandingSettingsSection);
51
+ await flushPromises();
52
+
53
+ expect(
54
+ (wrapper.get('input[placeholder^="或填写"]').element as HTMLInputElement)
55
+ .value,
56
+ ).toBe("");
57
+ expect(wrapper.get('img[alt="Logo 预览"]').attributes("src")).toBe(
58
+ localLogo,
59
+ );
60
+ expect(wrapper.text()).toContain("编码内容不在输入框中显示");
61
+ const preview = wrapper.get('img[alt="Logo 预览"]').element.parentElement;
62
+ expect(preview?.classList.contains("bg-transparent")).toBe(true);
63
+ expect(preview?.classList.contains("bg-primary")).toBe(false);
64
+ });
65
+
66
+ test("外部或站内 Logo 地址仍可编辑", async () => {
67
+ mocks.refresh.mockResolvedValue({
68
+ softwareName: "Windy",
69
+ logoUrl: "/assets/logo.png",
70
+ });
71
+
72
+ const wrapper = mount(BrandingSettingsSection);
73
+ await flushPromises();
74
+
75
+ expect(
76
+ (wrapper.get('input[placeholder^="或填写"]').element as HTMLInputElement)
77
+ .value,
78
+ ).toBe("/assets/logo.png");
79
+ });
80
+ });
@@ -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>
@@ -0,0 +1,55 @@
1
+ import { mount } from "@vue/test-utils";
2
+ import { describe, expect, test } from "vitest";
3
+ import WatermarkPreview from "./WatermarkPreview.vue";
4
+
5
+ describe("水印即时预览", () => {
6
+ test("外观草稿变化后立即复用实际平铺算法更新", async () => {
7
+ const wrapper = mount(WatermarkPreview, {
8
+ props: {
9
+ enabled: true,
10
+ content: "zhangsan · 张三",
11
+ density: "medium",
12
+ opacityPercent: 8,
13
+ fontSize: 14,
14
+ },
15
+ });
16
+ const initialStyle =
17
+ wrapper
18
+ .get('[data-testid="watermark-preview-pattern"]')
19
+ .attributes("style") ?? "";
20
+
21
+ expect(initialStyle).toContain("opacity: 0.08");
22
+ expect(decodeURIComponent(initialStyle)).toContain('font-size="14"');
23
+
24
+ await wrapper.setProps({
25
+ content: "张三 · 内部资料",
26
+ opacityPercent: 20,
27
+ fontSize: 24,
28
+ });
29
+ const updatedStyle =
30
+ wrapper
31
+ .get('[data-testid="watermark-preview-pattern"]')
32
+ .attributes("style") ?? "";
33
+
34
+ expect(updatedStyle).toContain("opacity: 0.2");
35
+ expect(decodeURIComponent(updatedStyle)).toContain('font-size="24"');
36
+ expect(decodeURIComponent(updatedStyle)).toContain("内部资料");
37
+ });
38
+
39
+ test("关闭唯一开关时即时显示停用状态", () => {
40
+ const wrapper = mount(WatermarkPreview, {
41
+ props: {
42
+ enabled: false,
43
+ content: "zhangsan",
44
+ density: "medium",
45
+ opacityPercent: 8,
46
+ fontSize: 14,
47
+ },
48
+ });
49
+
50
+ expect(wrapper.text()).toContain("水印已关闭");
51
+ expect(
52
+ wrapper.find('[data-testid="watermark-preview-pattern"]').exists(),
53
+ ).toBe(false);
54
+ });
55
+ });
@@ -15,12 +15,16 @@ import { useAccessSnapshot } from "@/composables/useAccessSnapshot";
15
15
  import { useAppToast } from "@/composables/useAppToast";
16
16
  import { useAuthSession } from "@/composables/useAuthSession";
17
17
  import { useWatermarkSettings } from "@/composables/useWatermarkSettings";
18
+ import { watermarkContent } from "@/composables/watermark-settings";
18
19
  import { evaluateWebAccess } from "@/layout/navigation";
19
20
  import {
20
21
  DEFAULT_PLATFORM_WATERMARK_POLICY,
22
+ WATERMARK_FONT_SIZE_RANGE,
23
+ WATERMARK_OPACITY_RANGE,
21
24
  type WatermarkContentField,
22
25
  type WatermarkDensity,
23
26
  } from "@southwind-ai/shared";
27
+ import WatermarkPreview from "./WatermarkPreview.vue";
24
28
 
25
29
  const densityOptions: { value: WatermarkDensity; label: string }[] = [
26
30
  { value: "low", label: "低" },
@@ -44,14 +48,13 @@ const access = useAccessSnapshot();
44
48
  const toast = useAppToast();
45
49
  const watermark = useWatermarkSettings(auth.actor);
46
50
  const enabled = shallowRef(watermark.policy.value.enabled);
47
- const businessPagesEnabled = shallowRef(
48
- watermark.policy.value.businessPagesEnabled,
49
- );
50
51
  const density = shallowRef<WatermarkDensity>(watermark.policy.value.density);
51
52
  const contentFields = shallowRef<WatermarkContentField[]>([
52
53
  ...watermark.policy.value.contentFields,
53
54
  ]);
54
55
  const customContent = shallowRef(watermark.policy.value.customContent);
56
+ const opacityPercent = shallowRef(watermark.policy.value.opacityPercent);
57
+ const fontSize = shallowRef(watermark.policy.value.fontSize);
55
58
  const saving = shallowRef(false);
56
59
  const baseline = shallowRef(serializeForm());
57
60
  const canManage = computed(() => {
@@ -70,6 +73,14 @@ const isDirty = computed(
70
73
  const formDisabled = computed(
71
74
  () => !canManage.value || saving.value || watermark.loading.value,
72
75
  );
76
+ const previewContent = computed(
77
+ () =>
78
+ watermarkContent(
79
+ auth.actor.value,
80
+ contentFields.value,
81
+ customContent.value,
82
+ ) || "当前用户",
83
+ );
73
84
 
74
85
  onMounted(async () => {
75
86
  try {
@@ -87,10 +98,12 @@ async function save(): Promise<boolean> {
87
98
  try {
88
99
  await watermark.save({
89
100
  enabled: enabled.value,
90
- businessPagesEnabled: businessPagesEnabled.value,
101
+ businessPagesEnabled: enabled.value,
91
102
  density: density.value,
92
103
  contentFields: contentFields.value,
93
104
  customContent: customContent.value,
105
+ opacityPercent: opacityPercent.value,
106
+ fontSize: fontSize.value,
94
107
  });
95
108
  applyPolicyToForm();
96
109
  toast.success("水印设置已保存");
@@ -107,30 +120,32 @@ async function save(): Promise<boolean> {
107
120
 
108
121
  function reset() {
109
122
  enabled.value = DEFAULT_PLATFORM_WATERMARK_POLICY.enabled;
110
- businessPagesEnabled.value =
111
- DEFAULT_PLATFORM_WATERMARK_POLICY.businessPagesEnabled;
112
123
  density.value = DEFAULT_PLATFORM_WATERMARK_POLICY.density;
113
124
  contentFields.value = [...DEFAULT_PLATFORM_WATERMARK_POLICY.contentFields];
114
125
  customContent.value = DEFAULT_PLATFORM_WATERMARK_POLICY.customContent;
126
+ opacityPercent.value = DEFAULT_PLATFORM_WATERMARK_POLICY.opacityPercent;
127
+ fontSize.value = DEFAULT_PLATFORM_WATERMARK_POLICY.fontSize;
115
128
  toast.success("已恢复为默认值,保存后生效");
116
129
  }
117
130
 
118
131
  function applyPolicyToForm() {
119
132
  enabled.value = watermark.policy.value.enabled;
120
- businessPagesEnabled.value = watermark.policy.value.businessPagesEnabled;
121
133
  density.value = watermark.policy.value.density;
122
134
  contentFields.value = [...watermark.policy.value.contentFields];
123
135
  customContent.value = watermark.policy.value.customContent;
136
+ opacityPercent.value = watermark.policy.value.opacityPercent;
137
+ fontSize.value = watermark.policy.value.fontSize;
124
138
  baseline.value = serializeForm();
125
139
  }
126
140
 
127
141
  function serializeForm() {
128
142
  return JSON.stringify({
129
143
  enabled: enabled.value,
130
- businessPagesEnabled: businessPagesEnabled.value,
131
144
  density: density.value,
132
145
  contentFields: [...contentFields.value].sort(),
133
146
  customContent: customContent.value.trim(),
147
+ opacityPercent: opacityPercent.value,
148
+ fontSize: fontSize.value,
134
149
  });
135
150
  }
136
151
 
@@ -161,29 +176,19 @@ defineExpose({
161
176
  </p>
162
177
  </div>
163
178
  <div class="grid gap-5">
164
- <label
165
- class="flex items-center justify-between gap-4 text-sm font-medium"
166
- >
167
- <span>显示页面水印</span>
168
- <Switch
169
- v-model="enabled"
170
- :disabled="formDisabled"
171
- aria-label="显示页面水印"
172
- />
173
- </label>
174
179
  <label
175
180
  class="flex items-center justify-between gap-4 text-sm font-medium"
176
181
  >
177
182
  <span>
178
- <span class="block">覆盖业务页面</span>
183
+ <span class="block">显示页面水印</span>
179
184
  <span class="mt-1 block text-xs font-normal text-muted-foreground">
180
- 开启后,首页及使用业务布局的页面也会显示水印。
185
+ 开启后,管理页面和业务页面统一显示水印。
181
186
  </span>
182
187
  </span>
183
188
  <Switch
184
- v-model="businessPagesEnabled"
185
- :disabled="formDisabled || !enabled"
186
- aria-label="在业务页面显示水印"
189
+ v-model="enabled"
190
+ :disabled="formDisabled"
191
+ aria-label="显示页面水印"
187
192
  />
188
193
  </label>
189
194
  <label class="grid gap-2 text-sm font-medium">
@@ -207,7 +212,7 @@ defineExpose({
207
212
  </span>
208
213
  </label>
209
214
  <fieldset class="grid gap-3" :disabled="formDisabled || !enabled">
210
- <legend class="mb-1 text-sm font-medium">水印内容</legend>
215
+ <legend class="mb-1 text-sm font-medium">水印字段(可任意组合)</legend>
211
216
  <label
212
217
  v-for="option in contentOptions"
213
218
  :key="option.value"
@@ -243,6 +248,57 @@ defineExpose({
243
248
  最多 80 个字符,与勾选的用户身份内容一同显示。
244
249
  </span>
245
250
  </label>
251
+ <div class="grid gap-5 sm:grid-cols-2">
252
+ <label class="grid gap-2 text-sm font-medium">
253
+ <span class="flex items-center justify-between gap-3">
254
+ <span>不透明度</span>
255
+ <output for="watermark-opacity">{{ opacityPercent }}%</output>
256
+ </span>
257
+ <input
258
+ id="watermark-opacity"
259
+ v-model.number="opacityPercent"
260
+ type="range"
261
+ :min="WATERMARK_OPACITY_RANGE.min"
262
+ :max="WATERMARK_OPACITY_RANGE.max"
263
+ :step="WATERMARK_OPACITY_RANGE.step"
264
+ :disabled="formDisabled || !enabled"
265
+ aria-label="水印不透明度"
266
+ class="w-full accent-primary"
267
+ />
268
+ <span class="text-xs font-normal text-muted-foreground">
269
+ 可设置 {{ WATERMARK_OPACITY_RANGE.min }}% 至
270
+ {{ WATERMARK_OPACITY_RANGE.max }}%。
271
+ </span>
272
+ </label>
273
+ <label class="grid gap-2 text-sm font-medium">
274
+ <span class="flex items-center justify-between gap-3">
275
+ <span>字体大小</span>
276
+ <output for="watermark-font-size">{{ fontSize }}px</output>
277
+ </span>
278
+ <input
279
+ id="watermark-font-size"
280
+ v-model.number="fontSize"
281
+ type="range"
282
+ :min="WATERMARK_FONT_SIZE_RANGE.min"
283
+ :max="WATERMARK_FONT_SIZE_RANGE.max"
284
+ :step="WATERMARK_FONT_SIZE_RANGE.step"
285
+ :disabled="formDisabled || !enabled"
286
+ aria-label="水印字体大小"
287
+ class="w-full accent-primary"
288
+ />
289
+ <span class="text-xs font-normal text-muted-foreground">
290
+ 可设置 {{ WATERMARK_FONT_SIZE_RANGE.min }}px 至
291
+ {{ WATERMARK_FONT_SIZE_RANGE.max }}px。
292
+ </span>
293
+ </label>
294
+ </div>
295
+ <WatermarkPreview
296
+ :enabled="enabled"
297
+ :content="previewContent"
298
+ :density="density"
299
+ :opacity-percent="opacityPercent"
300
+ :font-size="fontSize"
301
+ />
246
302
  <div v-if="canManage" class="flex justify-end gap-3 border-t pt-5">
247
303
  <Button variant="outline" :disabled="formDisabled" @click="reset">
248
304
  恢复默认
@@ -0,0 +1,115 @@
1
+ import { flushPromises, mount } from "@vue/test-utils";
2
+ import { beforeEach, describe, expect, test, vi } from "vitest";
3
+ import WatermarkSettingsSection from "./WatermarkSettingsSection.vue";
4
+
5
+ const state = vi.hoisted(() => {
6
+ const actor = {
7
+ id: "user_settings",
8
+ type: "user" as const,
9
+ name: "张三",
10
+ username: "zhangsan",
11
+ displayName: "张三",
12
+ email: "zs@example.com",
13
+ roleCodes: ["admin"],
14
+ permissionKeys: ["system.watermark.read", "system.watermark.manage"],
15
+ };
16
+ return {
17
+ actor: { value: actor },
18
+ access: {
19
+ value: {
20
+ actor,
21
+ menus: [],
22
+ permissions: [],
23
+ features: [
24
+ {
25
+ key: "system.watermark",
26
+ enabled: true,
27
+ visible: "visible" as const,
28
+ allowed: true,
29
+ reason: "enabled",
30
+ },
31
+ ],
32
+ },
33
+ },
34
+ policy: {
35
+ value: {
36
+ enabled: true,
37
+ businessPagesEnabled: true,
38
+ density: "medium" as const,
39
+ contentFields: ["username", "displayName"] as const,
40
+ customContent: "",
41
+ opacityPercent: 8,
42
+ fontSize: 14,
43
+ },
44
+ },
45
+ loadManaged: vi.fn(),
46
+ save: vi.fn(),
47
+ };
48
+ });
49
+
50
+ vi.mock("@/composables/useAccessSnapshot", () => ({
51
+ useAccessSnapshot: () => ({
52
+ snapshot: state.access,
53
+ ensureLoaded: vi.fn(async () => state.access.value),
54
+ }),
55
+ }));
56
+
57
+ vi.mock("@/composables/useAuthSession", () => ({
58
+ useAuthSession: () => ({ actor: state.actor }),
59
+ }));
60
+
61
+ vi.mock("@/composables/useAppToast", () => ({
62
+ useAppToast: () => ({
63
+ success: vi.fn(),
64
+ error: vi.fn(),
65
+ }),
66
+ }));
67
+
68
+ vi.mock("@/composables/useWatermarkSettings", () => ({
69
+ useWatermarkSettings: () => ({
70
+ policy: state.policy,
71
+ loading: { value: false },
72
+ loadManaged: state.loadManaged,
73
+ save: state.save,
74
+ }),
75
+ }));
76
+
77
+ beforeEach(() => {
78
+ state.loadManaged.mockReset();
79
+ state.loadManaged.mockResolvedValue(state.policy.value);
80
+ state.save.mockReset();
81
+ });
82
+
83
+ describe("水印设置表单", () => {
84
+ test("只呈现一个开关且外观草稿立即更新预览", async () => {
85
+ const wrapper = mount(WatermarkSettingsSection);
86
+ await flushPromises();
87
+
88
+ expect(wrapper.text()).not.toContain("覆盖业务页面");
89
+ expect(wrapper.findAll('[aria-label="显示页面水印"]')).toHaveLength(1);
90
+
91
+ await wrapper.get('input[aria-label="水印不透明度"]').setValue("20");
92
+ await wrapper.get('input[aria-label="水印字体大小"]').setValue("24");
93
+
94
+ const previewStyle =
95
+ wrapper
96
+ .get('[data-testid="watermark-preview-pattern"]')
97
+ .attributes("style") ?? "";
98
+ expect(previewStyle).toContain("opacity: 0.2");
99
+ expect(decodeURIComponent(previewStyle)).toContain('font-size="24"');
100
+ expect(state.save).not.toHaveBeenCalled();
101
+ });
102
+
103
+ test("任意勾选身份字段后立即进入预览内容", async () => {
104
+ const wrapper = mount(WatermarkSettingsSection);
105
+ await flushPromises();
106
+
107
+ await wrapper.get('[aria-label="邮箱"]').trigger("click");
108
+
109
+ const previewStyle =
110
+ wrapper
111
+ .get('[data-testid="watermark-preview-pattern"]')
112
+ .attributes("style") ?? "";
113
+ expect(decodeURIComponent(previewStyle)).toContain("zs@example.com");
114
+ });
115
+ });