create-windy 0.2.31 → 0.2.33

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 (30) hide show
  1. package/dist/cli.js +5 -2
  2. package/package.json +1 -1
  3. package/template/.windy-template.json +2 -2
  4. package/template/AGENTS.md +7 -0
  5. package/template/apps/server/src/settings/drizzle-watermark-repository.integration.test.ts +4 -0
  6. package/template/apps/server/src/settings/drizzle-watermark-repository.ts +6 -0
  7. package/template/apps/server/src/settings/watermark-routes.test.ts +13 -3
  8. package/template/apps/server/src/settings/watermark-routes.ts +41 -3
  9. package/template/apps/web/src/app/auth/login-page.ts +4 -0
  10. package/template/apps/web/src/composables/useLoginFlow.webtest.ts +91 -0
  11. package/template/apps/web/src/composables/useWatermarkSettings.ts +7 -3
  12. package/template/apps/web/src/composables/useWatermarkSettings.webtest.ts +14 -0
  13. package/template/apps/web/src/composables/watermark-settings.ts +131 -9
  14. package/template/apps/web/src/composables/watermark-settings.webtest.ts +73 -7
  15. package/template/apps/web/src/layout/GlobalWatermark.layering.webtest.ts +3 -1
  16. package/template/apps/web/src/layout/GlobalWatermark.vue +24 -11
  17. package/template/apps/web/src/layout/GlobalWatermark.webtest.ts +25 -11
  18. package/template/apps/web/src/pages/auth/login-page-extension.ts +9 -0
  19. package/template/apps/web/src/pages/auth/login-page-extension.webtest.ts +10 -0
  20. package/template/apps/web/src/pages/settings/WatermarkSettingsSection.vue +102 -4
  21. package/template/apps/web/src/router/index.ts +2 -2
  22. package/template/apps/web/src/router/shared-scaffold-neutrality.webtest.ts +3 -2
  23. package/template/docs/development/custom-login-page.md +90 -0
  24. package/template/docs/platform/platform-shell-settings.md +14 -9
  25. package/template/docs/platform/web-system-crud.md +1 -1
  26. package/template/packages/database/drizzle/0034_funny_mesmero.sql +5 -0
  27. package/template/packages/database/drizzle/meta/0034_snapshot.json +7253 -0
  28. package/template/packages/database/drizzle/meta/_journal.json +7 -0
  29. package/template/packages/database/src/schema/platform-settings.ts +13 -0
  30. package/template/packages/shared/src/platform-settings.ts +17 -1
@@ -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">
@@ -6,9 +6,9 @@ import {
6
6
  } from "vue-router";
7
7
  import DashboardPage from "@/pages/dashboard/DashboardPage.vue";
8
8
  import HomePage from "@/app/home/HomePage.vue";
9
+ import { loginPageComponent } from "@/app/auth/login-page";
9
10
  import AccessDeniedPage from "@/pages/AccessDeniedPage.vue";
10
11
  import ServiceUnavailablePage from "@/pages/ServiceUnavailablePage.vue";
11
- import LoginPage from "@/pages/auth/LoginPage.vue";
12
12
  import ChangePasswordPage from "@/pages/auth/ChangePasswordPage.vue";
13
13
  // @windy-module system.license begin
14
14
  import LicensePage from "@/pages/license/LicensePage.vue";
@@ -72,7 +72,7 @@ export const routes: RouteRecordRaw[] = [
72
72
  {
73
73
  path: "/login",
74
74
  name: "login",
75
- component: LoginPage,
75
+ component: loginPageComponent,
76
76
  meta: { title: "登录", layout: "auth", public: true, guestOnly: true },
77
77
  },
78
78
  {
@@ -5,10 +5,11 @@ import fixtureSource from "./router-test-fixtures.ts?raw";
5
5
  import { collectRouteAccess } from "./router-test-fixtures";
6
6
 
7
7
  describe("Router shared-scaffold 中性契约", () => {
8
- test("平台登录页使用中性共享页面", () => {
8
+ test("平台登录路由使用 project-owned 业务选择 seam", () => {
9
9
  expect(routerSource).toContain(
10
- 'import LoginPage from "@/pages/auth/LoginPage.vue";',
10
+ 'import { loginPageComponent } from "@/app/auth/login-page";',
11
11
  );
12
+ expect(routerSource).toContain("component: loginPageComponent");
12
13
  expect(fixtureSource).toContain("collectRouteAccess");
13
14
  });
14
15
 
@@ -0,0 +1,90 @@
1
+ # 业务项目定制登录页
2
+
3
+ Windy 允许业务项目替换完整登录页,但认证、会话和跳转安全仍复用平台能力。扩展入口是:
4
+
5
+ ```text
6
+ apps/web/src/app/auth/login-page.ts
7
+ ```
8
+
9
+ 该文件在 `.windy/files.json` 中属于 `project-owned`。Windy 升级会为旧项目新增它,
10
+ 但一旦文件存在,后续 `windy update` 只会保留,不会覆盖业务选择。平台默认登录页
11
+ `apps/web/src/pages/auth/LoginPage.vue` 仍是 `windy-managed`,Router 仍是
12
+ `shared-scaffold`;业务项目不应直接修改这两处。
13
+
14
+ ## 默认行为
15
+
16
+ 新项目和未定制的升级项目默认把 selector 指向平台登录页,因此视觉、品牌设置、开发
17
+ 认证提示和认证流程与原有 Windy 体验一致:
18
+
19
+ ```ts
20
+ import PlatformLoginPage from "@/pages/auth/LoginPage.vue";
21
+ import { defineLoginPageComponent } from "@/pages/auth/login-page-extension";
22
+
23
+ export const loginPageComponent = defineLoginPageComponent(PlatformLoginPage);
24
+ ```
25
+
26
+ `defineLoginPageComponent` 是类型化 Composition seam。Router 只消费这个稳定导出,
27
+ 不感知具体业务页面。
28
+
29
+ ## 完整业务页面
30
+
31
+ 将业务页面放在同一个 project-owned 目录,例如
32
+ `apps/web/src/app/auth/SharpSwordLoginPage.vue`,然后只修改 selector:
33
+
34
+ ```ts
35
+ import SharpSwordLoginPage from "./SharpSwordLoginPage.vue";
36
+ import { defineLoginPageComponent } from "@/pages/auth/login-page-extension";
37
+
38
+ export const loginPageComponent = defineLoginPageComponent(SharpSwordLoginPage);
39
+ ```
40
+
41
+ 业务 SFC 使用 Vue 3 Composition API 和 TypeScript。认证提交必须复用
42
+ `useLoginFlow()`,不要直接复制登录 API 或解析 `returnTo`:
43
+
44
+ ```vue
45
+ <script setup lang="ts">
46
+ import DevelopmentAuthWarning from "@/components/auth/DevelopmentAuthWarning.vue";
47
+ import LoginForm from "@/components/auth/LoginForm.vue";
48
+ import { useLoginFlow } from "@/composables/useLoginFlow";
49
+ import { developmentAuthStatus } from "@/services/http";
50
+
51
+ const flow = useLoginFlow();
52
+ const developmentAuth = developmentAuthStatus();
53
+ </script>
54
+
55
+ <template>
56
+ <main>
57
+ <DevelopmentAuthWarning v-if="developmentAuth.configured" />
58
+ <LoginForm
59
+ :loading="flow.loading.value"
60
+ :error="flow.submitError.value"
61
+ @submit="flow.submit"
62
+ />
63
+ </main>
64
+ </template>
65
+ ```
66
+
67
+ `useLoginFlow` 负责调用真实账号认证、重置 Access/License/Session 派生状态,并通过
68
+ `safeReturnTo` 只允许站内目标。它会拒绝外部 URL、协议相对 URL、反斜杠、控制字符、
69
+ 过长路径和登录页自循环。真实密码登录还会停用开发 Bearer Token;自定义页面应继续
70
+ 显示 `DevelopmentAuthWarning`,避免把本地直通误认为生产认证。
71
+
72
+ 品牌名、Logo 和说明可以通过 `usePlatformSettings()` 复用。业务页面可以完全重做
73
+ 布局和内容,但不应绕过 Router 的 `guestOnly` Guard、首次改密跳转或平台认证
74
+ composable。
75
+
76
+ ## 从 0.2.32 升级
77
+
78
+ 先提交当前业务改动并保持 Git 工作区干净,然后执行:
79
+
80
+ ```bash
81
+ bunx --bun create-windy@0.2.33 update --check --cwd .
82
+ bunx --bun create-windy@0.2.33 update --dry-run --cwd .
83
+ bunx --bun create-windy@0.2.33 update --cwd .
84
+ bun install
85
+ ```
86
+
87
+ 计划中应看到 selector 以 `project-owned` 新增,Router 通过三方合并接入 selector。
88
+ 若旧业务登录页曾放在 `apps/web/src/pages/auth/LoginPage.vue` 或直接改 Router,应将
89
+ 业务 SFC 移到 `apps/web/src/app/auth/`,改 selector 指向它,再恢复受管文件。后续升级
90
+ 会保留 selector 和业务 SFC。
@@ -54,29 +54,34 @@
54
54
  - 页面入口:全局设置 / 页面水印,不注册独立 Sidebar 菜单
55
55
 
56
56
  水印是平台级强制策略,不是用户偏好。`PlatformWatermarkPolicy` 包含总开关
57
- `enabled`、业务页范围开关 `businessPagesEnabled` 与最长 80 字符的
58
- `customContent`。策略由 Admin 的全局设置页面维护,写入
57
+ `enabled`、默认开启的业务页范围开关 `businessPagesEnabled`、`low / medium / high`
58
+ 密度、内容字段勾选与最长 80 字符的 `customContent`。可选内容为用户名、姓名、手机号、
59
+ 邮箱和自定义内容,至少保留一项;缺少已勾选的用户资料时不会渲染空片段,所有选项均无值时
60
+ 兜底使用用户名。策略由 Admin 的全局设置页面维护,写入
59
61
  `platform_watermark_policies`;每次保存都记录 `config.update` 审计,目标为
60
62
  `platform-watermark/platform`。用户应用端没有水印开关,也不使用 localStorage
61
63
  保存或覆盖策略。
62
64
 
63
65
  管理接口 `GET/PUT /api/system/settings/watermark` 分别经过
64
66
  `system.watermark.read/manage`、Feature 与 License Guard。业务应用通过公开只读接口
65
- `GET /api/platform/watermark-policy` 获取相同策略;公开响应只包含三个非敏感策略字段。
67
+ `GET /api/platform/watermark-policy` 获取相同策略;公开响应只包含上述非敏感策略字段。
66
68
  Feature 最终有效且 `enabled=true` 时 Admin 页面显示水印;业务页面还要求
67
69
  `businessPagesEnabled=true`。Admin 设置区本身仍要求读取权限,但普通业务用户不需要
68
70
  水印读取权限,也不能覆盖全局策略。
69
71
 
70
- `customContent` 留空时,客户端从当前 Actor 派生身份水印,顺序为用户名加手机、邮箱或
71
- 姓名;填写后所有用户显示相同的管理员指定内容。水印通过 Vue `Teleport` 直接挂到
72
- `body`,脱离应用壳的层叠上下文;稳定审计顶层类 `z-[60]` 高于平台统一使用的
72
+ 客户端按管理员勾选顺序从当前 Actor 派生身份水印,自定义内容只有被勾选时才加入。
73
+ 渲染算法先按字符视觉宽度自动换行,再根据旋转后文本包围盒与密度预设计算 SVG 平铺
74
+ 单元尺寸;每个单元都包含额外横纵安全间距,因此长文本和高密度模式不会让相邻水印重叠。
75
+ 水印通过 CSS Mask 平铺并由 Vue `Teleport` 直接挂到 `body`,脱离应用壳的层叠上下文;
76
+ 稳定审计顶层类 `z-[60]` 高于平台统一使用的
73
77
  `z-50` Sidebar、Drawer、Overlay 与 Modal,并配合 `isolate`、`transform-gpu`
74
78
  保证截图审计信息不会被浮层遮住。容器必须始终保留 `pointer-events-none` 与
75
79
  `aria-hidden=true`,不能阻塞点击或进入辅助技术阅读顺序。
76
80
 
77
- 数据库迁移 `0031_panoramic_typhoid_mary.sql` 新增策略表。历史版本只保存过浏览器偏好,
78
- 没有可自动提升为全局策略的服务端真源;升级后采用默认策略(总开关开启、业务页关闭),
79
- 管理员应在全局设置中确认范围并保存一次。
81
+ 数据库迁移 `0031_panoramic_typhoid_mary.sql` 新增策略表,
82
+ `0034_funny_mesmero.sql` 新增密度与内容字段。升级时,已有自定义文字的策略自动迁移为
83
+ 勾选“自定义内容”,避免改变既有展示;其它已有策略采用中密度与“用户名 + 姓名”。
84
+ 未持久化策略采用默认值(总开关开启、业务页开启、中密度、用户名加姓名)。
80
85
 
81
86
  ## 个人设置
82
87
 
@@ -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 派生。管理员在“全局设置”中维护持久化的总开关、业务页面范围和最多 80 个字符的自定义内容;保存写入审计并立即成为所有用户的只读策略。用户应用端不提供开关,也不使用 localStorage。`system.watermark` Feature 控制最终生效态,管理区另需 `system.watermark.read/manage` 权限。水印使用专用审计顶层覆盖 Sidebar、Drawer、Overlay 和 Modal,同时保持 `pointer-events-none`。服务端只向当前用户自己的会话响应补充这些身份字段。
86
+ - 全局水印与业务页面范围默认开启。管理员在“全局设置”中维护总开关、低/中/高密度,并勾选用户名、姓名、手机号、邮箱或最多 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`。可靠写入器提供有界重试、降级状态、关键事件本地恢复队列和健康信息;页面仍只读,且不会展示恢复队列中的事件正文。
@@ -0,0 +1,5 @@
1
+ ALTER TABLE "platform_watermark_policies" ADD COLUMN "density" varchar(16) DEFAULT 'medium' NOT NULL;--> statement-breakpoint
2
+ ALTER TABLE "platform_watermark_policies" ADD COLUMN "content_fields" jsonb DEFAULT '["username","displayName"]'::jsonb NOT NULL;--> statement-breakpoint
3
+ UPDATE "platform_watermark_policies"
4
+ SET "content_fields" = '["custom"]'::jsonb
5
+ WHERE btrim("custom_content") <> '';