create-windy 0.2.16 → 0.2.18

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 (69) hide show
  1. package/README.md +4 -3
  2. package/dist/cli.js +71 -22
  3. package/package.json +1 -1
  4. package/template/.dockerignore +5 -0
  5. package/template/.windy-template.json +2 -2
  6. package/template/AGENTS.md +11 -1
  7. package/template/README.md +8 -5
  8. package/template/_gitignore +5 -0
  9. package/template/apps/server/Dockerfile +4 -5
  10. package/template/apps/server/src/index.ts +8 -14
  11. package/template/apps/server/src/settings/drizzle-watermark-repository.integration.test.ts +57 -0
  12. package/template/apps/server/src/settings/drizzle-watermark-repository.ts +62 -0
  13. package/template/apps/server/src/settings/runtime.ts +30 -3
  14. package/template/apps/server/src/settings/watermark-repository.ts +32 -0
  15. package/template/apps/server/src/settings/watermark-routes.test.ts +203 -0
  16. package/template/apps/server/src/settings/watermark-routes.ts +134 -0
  17. package/template/apps/web/Dockerfile +1 -2
  18. package/template/apps/web/runtime-server.test.ts +4 -4
  19. package/template/apps/web/src/composables/useWatermarkSettings.ts +75 -33
  20. package/template/apps/web/src/composables/useWatermarkSettings.webtest.ts +86 -0
  21. package/template/apps/web/src/composables/watermark-settings.ts +9 -35
  22. package/template/apps/web/src/composables/watermark-settings.webtest.ts +8 -32
  23. package/template/apps/web/src/layout/GlobalWatermark.layering.webtest.ts +81 -0
  24. package/template/apps/web/src/layout/GlobalWatermark.vue +34 -31
  25. package/template/apps/web/src/layout/GlobalWatermark.webtest.ts +58 -7
  26. package/template/apps/web/src/pages/settings/GlobalSettingsPage.vue +1 -1
  27. package/template/apps/web/src/pages/settings/WatermarkSettingsSection.vue +40 -18
  28. package/template/apps/web/src/services/platform-settings-api.ts +25 -2
  29. package/template/apps/web/vite.config.ts +3 -1
  30. package/template/docker-compose.yml +19 -6
  31. package/template/docs/platform/platform-shell-settings.md +24 -3
  32. package/template/docs/platform/web-system-crud.md +2 -2
  33. package/template/drizzle.config.ts +1 -1
  34. package/template/package.json +6 -2
  35. package/template/packages/ai-client/index.ts +1 -0
  36. package/template/packages/{agent-client → ai-client}/package.json +4 -5
  37. package/template/packages/{agent-client/src → ai-client/src/agent/client}/client.test.ts +3 -3
  38. package/template/packages/{agent-client/src → ai-client/src/agent/client}/client.ts +2 -2
  39. package/template/packages/{agent-client/src → ai-client/src/agent/client}/error.ts +1 -4
  40. package/template/packages/ai-client/src/agent/client/index.ts +3 -0
  41. package/template/packages/{agent-client/src → ai-client/src/agent/client}/run-protocol.test.ts +2 -2
  42. package/template/packages/{agent-client/src → ai-client/src/agent/client}/run.ts +1 -1
  43. package/template/packages/{agent-client/src → ai-client/src/agent/client}/types.ts +1 -1
  44. package/template/packages/{agent-contracts/src → ai-client/src/agent/contracts}/contracts.test.ts +1 -1
  45. package/template/packages/ai-client/src/agent/contracts/index.ts +7 -0
  46. package/template/packages/ai-client/src/agent/index.ts +14 -0
  47. package/template/packages/{agent-client → ai-client}/tsconfig.json +3 -2
  48. package/template/packages/database/drizzle/0031_panoramic_typhoid_mary.sql +12 -0
  49. package/template/packages/database/drizzle/meta/0031_snapshot.json +7387 -0
  50. package/template/packages/database/drizzle/meta/_journal.json +8 -1
  51. package/template/packages/database/src/schema/platform-settings.ts +16 -1
  52. package/template/packages/modules/src/system-api-permissions.ts +4 -0
  53. package/template/packages/modules/src/system-api-watermark.ts +26 -0
  54. package/template/packages/modules/src/system-features.ts +1 -1
  55. package/template/packages/modules/src/system-watermark-policy.test.ts +31 -0
  56. package/template/packages/shared/src/platform-settings.ts +13 -0
  57. package/template/packages/agent-client/index.ts +0 -3
  58. package/template/packages/agent-contracts/index.ts +0 -7
  59. package/template/packages/agent-contracts/package.json +0 -24
  60. package/template/packages/agent-contracts/tsconfig.json +0 -16
  61. /package/template/packages/{agent-client/src → ai-client/src/agent/client}/event-queue.ts +0 -0
  62. /package/template/packages/{agent-client/src → ai-client/src/agent/client}/http.ts +0 -0
  63. /package/template/packages/{agent-contracts/src → ai-client/src/agent/contracts}/errors.ts +0 -0
  64. /package/template/packages/{agent-contracts/src → ai-client/src/agent/contracts}/events.ts +0 -0
  65. /package/template/packages/{agent-contracts/src → ai-client/src/agent/contracts}/operation.ts +0 -0
  66. /package/template/packages/{agent-contracts/src → ai-client/src/agent/contracts}/runs.ts +0 -0
  67. /package/template/packages/{agent-contracts/src → ai-client/src/agent/contracts}/sse.ts +0 -0
  68. /package/template/packages/{agent-contracts/src → ai-client/src/agent/contracts}/usage.ts +0 -0
  69. /package/template/packages/{agent-contracts/src → ai-client/src/agent/contracts}/validation.ts +0 -0
@@ -1,7 +1,7 @@
1
1
  import { describe, expect, test } from "vitest";
2
2
  import {
3
3
  defaultWatermarkContent,
4
- parseWatermarkSettings,
4
+ normalizeWatermarkPolicy,
5
5
  } from "./watermark-settings";
6
6
 
7
7
  describe("水印设置", () => {
@@ -24,41 +24,17 @@ describe("水印设置", () => {
24
24
  expect(defaultWatermarkContent(base)).toBe("zhangsan · 张三");
25
25
  });
26
26
 
27
- test("损坏或越界的浏览器设置安全回退", () => {
28
- expect(parseWatermarkSettings("broken")).toEqual({
29
- enabled: true,
30
- businessPagesEnabled: false,
31
- customContent: "",
32
- });
27
+ test("服务端策略内容会被规范化后渲染", () => {
33
28
  expect(
34
- parseWatermarkSettings(
35
- JSON.stringify({ enabled: false, customContent: "x".repeat(100) }),
36
- ),
29
+ normalizeWatermarkPolicy({
30
+ enabled: false,
31
+ businessPagesEnabled: true,
32
+ customContent: ` ${"x".repeat(100)} `,
33
+ }),
37
34
  ).toEqual({
38
35
  enabled: false,
39
- businessPagesEnabled: false,
40
- customContent: "x".repeat(80),
41
- });
42
- });
43
-
44
- test("业务页面覆盖默认关闭并兼容已保存的范围开关", () => {
45
- expect(parseWatermarkSettings(null)).toEqual({
46
- enabled: true,
47
- businessPagesEnabled: false,
48
- customContent: "",
49
- });
50
- expect(
51
- parseWatermarkSettings(
52
- JSON.stringify({
53
- enabled: true,
54
- businessPagesEnabled: true,
55
- customContent: "内部资料",
56
- }),
57
- ),
58
- ).toEqual({
59
- enabled: true,
60
36
  businessPagesEnabled: true,
61
- customContent: "内部资料",
37
+ customContent: "x".repeat(80),
62
38
  });
63
39
  });
64
40
  });
@@ -0,0 +1,81 @@
1
+ import { mount } from "@vue/test-utils";
2
+ import { afterEach, describe, expect, test, vi } from "vitest";
3
+ import GlobalWatermark from "./GlobalWatermark.vue";
4
+
5
+ const state = vi.hoisted(() => {
6
+ const actor = {
7
+ id: "user_1",
8
+ type: "user" as const,
9
+ name: "张三",
10
+ roleCodes: [],
11
+ permissionKeys: ["system.watermark.read"],
12
+ };
13
+ const policy = {
14
+ value: {
15
+ enabled: true,
16
+ businessPagesEnabled: false,
17
+ customContent: "",
18
+ },
19
+ };
20
+
21
+ return {
22
+ actor: { value: actor },
23
+ access: {
24
+ value: {
25
+ actor,
26
+ menus: [],
27
+ permissions: [],
28
+ features: [
29
+ {
30
+ key: "system.watermark",
31
+ enabled: true,
32
+ visible: "visible" as const,
33
+ allowed: true,
34
+ reason: "enabled",
35
+ },
36
+ ],
37
+ },
38
+ },
39
+ policy,
40
+ content: { value: "zhangsan · 张三" },
41
+ };
42
+ });
43
+
44
+ vi.mock("@/composables/useAccessSnapshot", () => ({
45
+ useAccessSnapshot: () => ({ snapshot: state.access }),
46
+ }));
47
+
48
+ vi.mock("@/composables/useAuthSession", () => ({
49
+ useAuthSession: () => ({ actor: state.actor }),
50
+ }));
51
+
52
+ vi.mock("@/composables/useWatermarkSettings", () => ({
53
+ useWatermarkSettings: () => ({
54
+ settings: state.policy,
55
+ policy: state.policy,
56
+ content: state.content,
57
+ ensureLoaded: vi.fn(async () => state.policy.value),
58
+ }),
59
+ }));
60
+
61
+ afterEach(() => {
62
+ document.body.replaceChildren();
63
+ });
64
+
65
+ describe("GlobalWatermark 层级", () => {
66
+ test("传送到 body 并使用不拦截交互的审计覆盖层", () => {
67
+ const wrapper = mount(GlobalWatermark);
68
+ const watermark = document.body.querySelector(
69
+ '[data-testid="global-watermark"]',
70
+ );
71
+
72
+ expect(watermark).not.toBeNull();
73
+ expect(watermark?.parentElement).toBe(document.body);
74
+ expect(watermark?.classList).toContain("isolate");
75
+ expect(watermark?.classList).toContain("z-[60]");
76
+ expect(watermark?.classList).toContain("transform-gpu");
77
+ expect(watermark?.classList).toContain("pointer-events-none");
78
+
79
+ wrapper.unmount();
80
+ });
81
+ });
@@ -1,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- import { computed } from "vue";
2
+ import { computed, onMounted } from "vue";
3
3
  import { useAuthSession } from "@/composables/useAuthSession";
4
4
  import { useAccessSnapshot } from "@/composables/useAccessSnapshot";
5
5
  import { useWatermarkSettings } from "@/composables/useWatermarkSettings";
@@ -16,40 +16,43 @@ const props = withDefaults(
16
16
  const auth = useAuthSession();
17
17
  const access = useAccessSnapshot();
18
18
  const watermark = useWatermarkSettings(auth.actor);
19
- const allowed = computed(
20
- () =>
21
- props.scope === "business" ||
22
- (access.snapshot.value
23
- ? evaluateWebAccess(access.snapshot.value, {
24
- permissionKey: "system.watermark.read",
25
- featureKey: "system.watermark",
26
- }).allowed
27
- : false),
28
- );
19
+ onMounted(() => {
20
+ void watermark.ensureLoaded();
21
+ });
22
+ const allowed = computed(() => {
23
+ const snapshot = access.snapshot.value;
24
+ if (!snapshot) return false;
25
+ return evaluateWebAccess(snapshot, {
26
+ featureKey: "system.watermark",
27
+ permissionKey:
28
+ props.scope === "admin" ? "system.watermark.read" : undefined,
29
+ }).allowed;
30
+ });
29
31
  const scopeEnabled = computed(
30
- () =>
31
- props.scope === "admin" || watermark.settings.value.businessPagesEnabled,
32
+ () => props.scope === "admin" || watermark.policy.value.businessPagesEnabled,
32
33
  );
33
34
  </script>
34
35
 
35
36
  <template>
36
- <div
37
- v-if="
38
- allowed &&
39
- scopeEnabled &&
40
- watermark.settings.value.enabled &&
41
- watermark.content.value
42
- "
43
- class="pointer-events-none fixed inset-0 z-30 grid grid-cols-3 grid-rows-6 overflow-hidden opacity-[0.075] dark:opacity-[0.11]"
44
- aria-hidden="true"
45
- data-testid="global-watermark"
46
- >
47
- <span
48
- v-for="index in 18"
49
- :key="index"
50
- class="flex -rotate-[22deg] items-center justify-center whitespace-nowrap px-8 text-sm font-medium text-foreground"
37
+ <Teleport to="body">
38
+ <div
39
+ v-if="
40
+ allowed &&
41
+ scopeEnabled &&
42
+ watermark.policy.value.enabled &&
43
+ watermark.content.value
44
+ "
45
+ 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]"
46
+ aria-hidden="true"
47
+ data-testid="global-watermark"
51
48
  >
52
- {{ watermark.content.value }}
53
- </span>
54
- </div>
49
+ <span
50
+ v-for="index in 18"
51
+ :key="index"
52
+ class="flex -rotate-[22deg] items-center justify-center whitespace-nowrap px-8 text-sm font-medium text-foreground"
53
+ >
54
+ {{ watermark.content.value }}
55
+ </span>
56
+ </div>
57
+ </Teleport>
55
58
  </template>
@@ -1,4 +1,4 @@
1
- import { mount } from "@vue/test-utils";
1
+ import { config, mount } from "@vue/test-utils";
2
2
  import { beforeEach, describe, expect, test, vi } from "vitest";
3
3
  import GlobalWatermark from "./GlobalWatermark.vue";
4
4
 
@@ -34,7 +34,7 @@ const state = vi.hoisted(() => ({
34
34
  permissionKeys: ["system.watermark.read"],
35
35
  },
36
36
  },
37
- settings: {
37
+ policy: {
38
38
  value: {
39
39
  enabled: true,
40
40
  businessPagesEnabled: false,
@@ -54,13 +54,19 @@ vi.mock("@/composables/useAuthSession", () => ({
54
54
 
55
55
  vi.mock("@/composables/useWatermarkSettings", () => ({
56
56
  useWatermarkSettings: () => ({
57
- settings: state.settings,
57
+ policy: state.policy,
58
58
  content: state.content,
59
+ ensureLoaded: vi.fn(async () => state.policy.value),
59
60
  }),
60
61
  }));
61
62
 
63
+ config.global.stubs = {
64
+ ...config.global.stubs,
65
+ teleport: true,
66
+ };
67
+
62
68
  beforeEach(() => {
63
- state.settings.value = {
69
+ state.policy.value = {
64
70
  enabled: true,
65
71
  businessPagesEnabled: false,
66
72
  customContent: "",
@@ -76,7 +82,7 @@ describe("GlobalWatermark", () => {
76
82
  );
77
83
  wrapper.unmount();
78
84
 
79
- state.settings.value = { ...state.settings.value, enabled: false };
85
+ state.policy.value = { ...state.policy.value, enabled: false };
80
86
  const disabledWrapper = mount(GlobalWatermark);
81
87
 
82
88
  expect(
@@ -94,8 +100,8 @@ describe("GlobalWatermark", () => {
94
100
  );
95
101
  wrapper.unmount();
96
102
 
97
- state.settings.value = {
98
- ...state.settings.value,
103
+ state.policy.value = {
104
+ ...state.policy.value,
99
105
  businessPagesEnabled: true,
100
106
  };
101
107
  const enabledWrapper = mount(GlobalWatermark, {
@@ -105,5 +111,50 @@ describe("GlobalWatermark", () => {
105
111
  expect(
106
112
  enabledWrapper.get('[data-testid="global-watermark"]').text(),
107
113
  ).toContain("zhangsan · 张三");
114
+ expect(
115
+ enabledWrapper.get('[data-testid="global-watermark"]').classes(),
116
+ ).toEqual(
117
+ expect.arrayContaining([
118
+ "isolate",
119
+ "z-[60]",
120
+ "transform-gpu",
121
+ "pointer-events-none",
122
+ ]),
123
+ );
124
+ });
125
+
126
+ test("业务页仍服从平台 Feature 最终有效态", () => {
127
+ state.policy.value = {
128
+ enabled: true,
129
+ businessPagesEnabled: true,
130
+ customContent: "",
131
+ };
132
+ state.access.value.features[0]!.allowed = false;
133
+
134
+ const wrapper = mount(GlobalWatermark, {
135
+ props: { scope: "business" },
136
+ });
137
+
138
+ expect(wrapper.find('[data-testid="global-watermark"]').exists()).toBe(
139
+ false,
140
+ );
141
+ state.access.value.features[0]!.allowed = true;
142
+ });
143
+
144
+ test("水印传送到 body,脱离应用壳的层叠上下文", () => {
145
+ const wrapper = mount(GlobalWatermark, {
146
+ global: {
147
+ stubs: {
148
+ teleport: false,
149
+ },
150
+ },
151
+ });
152
+
153
+ const watermark = document.body.querySelector(
154
+ '[data-testid="global-watermark"]',
155
+ );
156
+ expect(watermark).not.toBeNull();
157
+ expect(watermark?.parentElement).toBe(document.body);
158
+ wrapper.unmount();
108
159
  });
109
160
  });
@@ -143,7 +143,7 @@ function handleBeforeUnload(event: BeforeUnloadEvent) {
143
143
  <header class="space-y-2">
144
144
  <h1 class="text-3xl font-semibold tracking-tight">全局设置</h1>
145
145
  <p class="text-muted-foreground">
146
- 维护面向所有用户的平台品牌信息,以及当前浏览器的页面水印偏好。
146
+ 维护面向所有用户的平台品牌信息与全局页面水印策略。
147
147
  </p>
148
148
  </header>
149
149
  <Tabs
@@ -1,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- import { computed, shallowRef } from "vue";
2
+ import { computed, onMounted, shallowRef } from "vue";
3
3
  import { Button } from "@/components/ui/button";
4
4
  import { Input } from "@/components/ui/input";
5
5
  import { Switch } from "@/components/ui/switch";
@@ -8,16 +8,17 @@ import { useAppToast } from "@/composables/useAppToast";
8
8
  import { useAuthSession } from "@/composables/useAuthSession";
9
9
  import { useWatermarkSettings } from "@/composables/useWatermarkSettings";
10
10
  import { evaluateWebAccess } from "@/layout/navigation";
11
+ import { DEFAULT_PLATFORM_WATERMARK_POLICY } from "@southwind-ai/shared";
11
12
 
12
13
  const auth = useAuthSession();
13
14
  const access = useAccessSnapshot();
14
15
  const toast = useAppToast();
15
16
  const watermark = useWatermarkSettings(auth.actor);
16
- const enabled = shallowRef(watermark.settings.value.enabled);
17
+ const enabled = shallowRef(watermark.policy.value.enabled);
17
18
  const businessPagesEnabled = shallowRef(
18
- watermark.settings.value.businessPagesEnabled,
19
+ watermark.policy.value.businessPagesEnabled,
19
20
  );
20
- const customContent = shallowRef(watermark.settings.value.customContent);
21
+ const customContent = shallowRef(watermark.policy.value.customContent);
21
22
  const saving = shallowRef(false);
22
23
  const baseline = shallowRef(serializeForm());
23
24
  const canManage = computed(() => {
@@ -33,17 +34,30 @@ const canManage = computed(() => {
33
34
  const isDirty = computed(
34
35
  () => canManage.value && serializeForm() !== baseline.value,
35
36
  );
37
+ const formDisabled = computed(
38
+ () => !canManage.value || saving.value || watermark.loading.value,
39
+ );
40
+
41
+ onMounted(async () => {
42
+ try {
43
+ await watermark.loadManaged();
44
+ applyPolicyToForm();
45
+ } catch (error) {
46
+ toast.error("水印策略加载失败", {
47
+ description: error instanceof Error ? error.message : String(error),
48
+ });
49
+ }
50
+ });
36
51
 
37
52
  async function save(): Promise<boolean> {
38
53
  saving.value = true;
39
54
  try {
40
- watermark.update({
55
+ await watermark.save({
41
56
  enabled: enabled.value,
42
57
  businessPagesEnabled: businessPagesEnabled.value,
43
58
  customContent: customContent.value,
44
59
  });
45
- customContent.value = watermark.settings.value.customContent;
46
- baseline.value = serializeForm();
60
+ applyPolicyToForm();
47
61
  toast.success("水印设置已保存");
48
62
  return true;
49
63
  } catch (error) {
@@ -57,12 +71,20 @@ async function save(): Promise<boolean> {
57
71
  }
58
72
 
59
73
  function reset() {
60
- enabled.value = true;
61
- businessPagesEnabled.value = false;
62
- customContent.value = "";
74
+ enabled.value = DEFAULT_PLATFORM_WATERMARK_POLICY.enabled;
75
+ businessPagesEnabled.value =
76
+ DEFAULT_PLATFORM_WATERMARK_POLICY.businessPagesEnabled;
77
+ customContent.value = DEFAULT_PLATFORM_WATERMARK_POLICY.customContent;
63
78
  toast.success("已恢复为默认值,保存后生效");
64
79
  }
65
80
 
81
+ function applyPolicyToForm() {
82
+ enabled.value = watermark.policy.value.enabled;
83
+ businessPagesEnabled.value = watermark.policy.value.businessPagesEnabled;
84
+ customContent.value = watermark.policy.value.customContent;
85
+ baseline.value = serializeForm();
86
+ }
87
+
66
88
  function serializeForm() {
67
89
  return JSON.stringify({
68
90
  enabled: enabled.value,
@@ -82,7 +104,7 @@ defineExpose({
82
104
  <div class="mb-6 space-y-1">
83
105
  <h2 class="text-xl font-semibold">页面水印</h2>
84
106
  <p class="text-sm text-muted-foreground">
85
- 默认使用当前账号的用户名和手机、邮箱或姓名,帮助定位截图来源。
107
+ 该策略由平台统一管理并应用于所有用户,帮助定位截图来源。
86
108
  </p>
87
109
  </div>
88
110
  <div class="grid gap-5">
@@ -92,7 +114,7 @@ defineExpose({
92
114
  <span>显示页面水印</span>
93
115
  <Switch
94
116
  v-model="enabled"
95
- :disabled="!canManage"
117
+ :disabled="formDisabled"
96
118
  aria-label="显示页面水印"
97
119
  />
98
120
  </label>
@@ -107,7 +129,7 @@ defineExpose({
107
129
  </span>
108
130
  <Switch
109
131
  v-model="businessPagesEnabled"
110
- :disabled="!canManage || !enabled"
132
+ :disabled="formDisabled || !enabled"
111
133
  aria-label="在业务页面显示水印"
112
134
  />
113
135
  </label>
@@ -115,19 +137,19 @@ defineExpose({
115
137
  <span>自定义内容</span>
116
138
  <Input
117
139
  v-model="customContent"
118
- :disabled="!canManage"
140
+ :disabled="formDisabled"
119
141
  maxlength="80"
120
- :placeholder="watermark.content.value || '使用当前账号信息'"
142
+ :placeholder="watermark.content.value || '使用各用户的账号信息'"
121
143
  />
122
144
  <span class="text-xs font-normal text-muted-foreground">
123
- 留空时自动使用账号身份;该偏好只保存在当前浏览器。
145
+ 留空时为每位用户自动使用其账号身份;保存后全局生效。
124
146
  </span>
125
147
  </label>
126
148
  <div v-if="canManage" class="flex justify-end gap-3 border-t pt-5">
127
- <Button variant="outline" :disabled="saving" @click="reset">
149
+ <Button variant="outline" :disabled="formDisabled" @click="reset">
128
150
  恢复默认
129
151
  </Button>
130
- <Button :disabled="saving" @click="save">
152
+ <Button :disabled="formDisabled" @click="save">
131
153
  {{ saving ? "保存中…" : "保存水印设置" }}
132
154
  </Button>
133
155
  </div>
@@ -1,7 +1,13 @@
1
1
  import { apiRequest } from "./http";
2
- import type { PlatformBrandingSettings } from "@southwind-ai/shared";
2
+ import type {
3
+ PlatformBrandingSettings,
4
+ PlatformWatermarkPolicy,
5
+ } from "@southwind-ai/shared";
3
6
 
4
- export type { PlatformBrandingSettings } from "@southwind-ai/shared";
7
+ export type {
8
+ PlatformBrandingSettings,
9
+ PlatformWatermarkPolicy,
10
+ } from "@southwind-ai/shared";
5
11
 
6
12
  export function loadPublicBranding(): Promise<PlatformBrandingSettings> {
7
13
  return apiRequest<PlatformBrandingSettings>("/platform/branding");
@@ -19,3 +25,20 @@ export function updatePlatformBranding(
19
25
  body: JSON.stringify(settings),
20
26
  });
21
27
  }
28
+
29
+ export function loadPublicWatermarkPolicy(): Promise<PlatformWatermarkPolicy> {
30
+ return apiRequest<PlatformWatermarkPolicy>("/platform/watermark-policy");
31
+ }
32
+
33
+ export function loadPlatformWatermarkPolicy(): Promise<PlatformWatermarkPolicy> {
34
+ return apiRequest<PlatformWatermarkPolicy>("/system/settings/watermark");
35
+ }
36
+
37
+ export function updatePlatformWatermarkPolicy(
38
+ policy: PlatformWatermarkPolicy,
39
+ ): Promise<PlatformWatermarkPolicy> {
40
+ return apiRequest<PlatformWatermarkPolicy>("/system/settings/watermark", {
41
+ method: "PUT",
42
+ body: JSON.stringify(policy),
43
+ });
44
+ }
@@ -7,6 +7,7 @@ import { projectFingerprint, windyDevIdentityPlugin } from "./dev/dev-identity";
7
7
  const fingerprint =
8
8
  process.env.WINDY_DEV_FINGERPRINT || projectFingerprint(process.cwd());
9
9
  const devPort = Number(process.env.WINDY_DEV_PORT || 8_746);
10
+ const usePolling = process.env.WINDY_DEV_USE_POLLING === "1";
10
11
 
11
12
  export default defineConfig({
12
13
  plugins: [vue(), tailwindcss(), windyDevIdentityPlugin(fingerprint)],
@@ -19,8 +20,9 @@ export default defineConfig({
19
20
  host: process.env.VITE_DEV_HOST ?? "127.0.0.1",
20
21
  port: devPort,
21
22
  strictPort: true,
23
+ watch: usePolling ? { usePolling: true, interval: 200 } : undefined,
22
24
  proxy: {
23
- "/api": process.env.API_UPSTREAM ?? "http://localhost:3000",
25
+ "/api": process.env.API_UPSTREAM ?? "http://localhost:9747",
24
26
  },
25
27
  },
26
28
  });
@@ -6,7 +6,7 @@ services:
6
6
  POSTGRES_USER: windy
7
7
  POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?请在 .env 中设置 POSTGRES_PASSWORD}
8
8
  ports:
9
- - "5432:5432"
9
+ - "15432:5432"
10
10
  volumes:
11
11
  - postgres-data:/var/lib/postgresql/data
12
12
  healthcheck:
@@ -35,14 +35,15 @@ services:
35
35
  command: ["bun", "run", "--cwd", "apps/server", "dev"]
36
36
  environment:
37
37
  NODE_ENV: development
38
- PORT: 3000
38
+ PORT: 9747
39
39
  DATABASE_URL: postgresql://windy:${POSTGRES_PASSWORD}@postgres:5432/windy
40
40
  WINDY_BOOTSTRAP_ADMIN_PASSWORD: ${WINDY_BOOTSTRAP_ADMIN_PASSWORD:?请在 .env 中设置管理员初始密码}
41
41
  WINDY_AUDIT_QUEUE_FILE: /app/data/audit/recovery-queue.json
42
42
  WINDY_CONTAINERIZED: "1"
43
+ BUN_CONFIG_NO_CLEAR_TERMINAL_ON_RELOAD: "true"
43
44
  WINDY_SYSTEM_USAGE_PATHS: /app/data/audit
44
45
  ports:
45
- - "3000:3000"
46
+ - "9747:9747"
46
47
  volumes:
47
48
  - ./apps/server:/app/apps/server
48
49
  - ./packages:/app/packages
@@ -58,14 +59,26 @@ services:
58
59
  command: ["bun", "run", "--cwd", "apps/web", "dev"]
59
60
  environment:
60
61
  NODE_ENV: development
61
- WINDY_DEV_PORT: 9746
62
- API_UPSTREAM: http://server:3000
62
+ WINDY_DEV_PORT: 18746
63
+ WINDY_DEV_USE_POLLING: "1"
64
+ API_UPSTREAM: http://server:9747
63
65
  VITE_DEV_HOST: 0.0.0.0
64
66
  ports:
65
- - "9746:9746"
67
+ - "18746:18746"
66
68
  volumes:
67
69
  - ./apps/web:/app/apps/web
68
70
  - ./packages:/app/packages
71
+ healthcheck:
72
+ test:
73
+ [
74
+ "CMD",
75
+ "bun",
76
+ "-e",
77
+ "const r=await fetch('http://127.0.0.1:18746/-/windy-dev');process.exit(r.ok?0:1)",
78
+ ]
79
+ interval: 5s
80
+ timeout: 3s
81
+ retries: 6
69
82
  depends_on:
70
83
  - server
71
84
 
@@ -53,9 +53,30 @@
53
53
  - 权限:`system.watermark.read`、`system.watermark.manage`
54
54
  - 页面入口:全局设置 / 页面水印,不注册独立 Sidebar 菜单
55
55
 
56
- 只有 Feature 最终有效且当前用户具有读取权限时才渲染水印及全局设置中的水印区块。当前版本的显示开关与自定义内容是用户在当前浏览器的偏好;默认内容从当前 Actor 派生,顺序为用户名加手机、邮箱或姓名。
57
-
58
- 后续若引入平台强制水印策略,应由服务端持久化并审计,生效关系为:Feature 开启,且平台强制显示,或平台允许用户控制且个人偏好开启。普通用户不能覆盖“强制显示”。
56
+ 水印是平台级强制策略,不是用户偏好。`PlatformWatermarkPolicy` 包含总开关
57
+ `enabled`、业务页范围开关 `businessPagesEnabled` 与最长 80 字符的
58
+ `customContent`。策略由 Admin 的全局设置页面维护,写入
59
+ `platform_watermark_policies`;每次保存都记录 `config.update` 审计,目标为
60
+ `platform-watermark/platform`。用户应用端没有水印开关,也不使用 localStorage
61
+ 保存或覆盖策略。
62
+
63
+ 管理接口 `GET/PUT /api/system/settings/watermark` 分别经过
64
+ `system.watermark.read/manage`、Feature 与 License Guard。业务应用通过公开只读接口
65
+ `GET /api/platform/watermark-policy` 获取相同策略;公开响应只包含三个非敏感策略字段。
66
+ Feature 最终有效且 `enabled=true` 时 Admin 页面显示水印;业务页面还要求
67
+ `businessPagesEnabled=true`。Admin 设置区本身仍要求读取权限,但普通业务用户不需要
68
+ 水印读取权限,也不能覆盖全局策略。
69
+
70
+ `customContent` 留空时,客户端从当前 Actor 派生身份水印,顺序为用户名加手机、邮箱或
71
+ 姓名;填写后所有用户显示相同的管理员指定内容。水印通过 Vue `Teleport` 直接挂到
72
+ `body`,脱离应用壳的层叠上下文;稳定审计顶层类 `z-[60]` 高于平台统一使用的
73
+ `z-50` Sidebar、Drawer、Overlay 与 Modal,并配合 `isolate`、`transform-gpu`
74
+ 保证截图审计信息不会被浮层遮住。容器必须始终保留 `pointer-events-none` 与
75
+ `aria-hidden=true`,不能阻塞点击或进入辅助技术阅读顺序。
76
+
77
+ 数据库迁移 `0031_panoramic_typhoid_mary.sql` 新增策略表。历史版本只保存过浏览器偏好,
78
+ 没有可自动提升为全局策略的服务端真源;升级后采用默认策略(总开关开启、业务页关闭),
79
+ 管理员应在全局设置中确认范围并保存一次。
59
80
 
60
81
  ## 个人设置
61
82
 
@@ -64,7 +64,7 @@ POST /api/system/notifications
64
64
  DELETE /api/system/notifications/:id
65
65
  ```
66
66
 
67
- 开发服务把 `/api` 代理到 `http://localhost:3000`。默认不携带开发 Token;需要调试直通时必须同时显式设置:
67
+ 开发服务把 `/api` 代理到 `http://localhost:9747`。默认不携带开发 Token;需要调试直通时必须同时显式设置:
68
68
 
69
69
  ```text
70
70
  VITE_API_BASE=/api
@@ -83,7 +83,7 @@ VITE_DEV_ADMIN_TOKEN=<显式开发 Token>
83
83
  - Feature 管理页在列表最右侧操作栏直接显示启停开关,不再打开通用编辑弹窗;修改仍调用受 Guard 和关键审计保护的 Server API。
84
84
  - Feature 开关成功修改会写入 `feature.update`,可在审计日志页通过“功能开关变更”直接查看。
85
85
  - 系统资源表和字典后台任务表加载时保留表头与列宽,并显示带 `animate-pulse` 的骨架行;加载失败和空数据仍使用各自独立状态。
86
- - 全局水印默认开启,内容按“用户名 + 手机号 / 邮箱 / 姓名”的顺序从当前会话 Actor 派生。`system.watermark` Feature `system.watermark.read` 权限共同控制渲染及“全局设置”页内的水印区块;水印不注册独立 Sidebar 入口。用户可关闭或写入最多 80 个字符的自定义内容,偏好只保存在当前浏览器,不是平台级强制策略。服务端只向当前用户自己的会话响应补充这些身份字段。
86
+ - 全局水印默认开启,内容按“用户名 + 手机号 / 邮箱 / 姓名”的顺序从当前会话 Actor 派生。管理员在“全局设置”中维护持久化的总开关、业务页面范围和最多 80 个字符的自定义内容;保存写入审计并立即成为所有用户的只读策略。用户应用端不提供开关,也不使用 localStorage。`system.watermark` Feature 控制最终生效态,管理区另需 `system.watermark.read/manage` 权限。水印使用专用审计顶层覆盖 Sidebar、Drawer、Overlay Modal,同时保持 `pointer-events-none`。服务端只向当前用户自己的会话响应补充这些身份字段。
87
87
  - 站内通知由 PostgreSQL `platform_notifications` 和 `notification_reads` 持久化;管理人员可在 `/system/notifications` 广播发布和归档,用户从 Header 收件箱阅读正文并幂等写入已读回执。归档是生命周期操作,不物理删除通知或回执。当前发布范围是全体有阅读权限的用户,尚未提供组织、角色、用户定向和外部渠道投递。
88
88
  - 字典页已切换为后台 NDJSON 导入导出,展示本人任务状态、进度、重试和终态文件下载;其它系统资源仍保留小数据同步 JSON 入口。详细契约见 [批量导入导出后台任务](./bulk-data-jobs.md)。当前 artifact 存在 10 MiB、50,000 行和 256 KiB 单行上限,扩大容量前需要对象存储 Adapter。
89
89
  - 审计日志配置 `DATABASE_URL` 后读取 PostgreSQL `audit_logs`。可靠写入器提供有界重试、降级状态、关键事件本地恢复队列和健康信息;页面仍只读,且不会展示恢复队列中的事件正文。
@@ -11,6 +11,6 @@ export default defineConfig({
11
11
  },
12
12
  dbCredentials: {
13
13
  url:
14
- process.env.DATABASE_URL || "postgresql://localhost:5432/windy",
14
+ process.env.DATABASE_URL || "postgresql://localhost:15432/windy",
15
15
  },
16
16
  });