create-windy 0.2.34 → 0.3.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 (237) hide show
  1. package/dist/cli.js +167 -17
  2. package/package.json +1 -1
  3. package/template/.windy-template.json +2 -2
  4. package/template/AGENTS.md +5 -0
  5. package/template/README.md +3 -1
  6. package/template/apps/agent-server/Dockerfile +1 -0
  7. package/template/apps/agent-server/README.md +18 -5
  8. package/template/apps/agent-server/adk_web_agents/__init__.py +1 -0
  9. package/template/apps/agent-server/adk_web_agents/safe_debug/__init__.py +3 -0
  10. package/template/apps/agent-server/adk_web_agents/safe_debug/agent.py +40 -0
  11. package/template/apps/agent-server/pyproject.toml +1 -1
  12. package/template/apps/agent-server/src/southwind_agent_server/adk_adapter.py +191 -16
  13. package/template/apps/agent-server/src/southwind_agent_server/adk_failure.py +13 -0
  14. package/template/apps/agent-server/src/southwind_agent_server/adk_model.py +225 -0
  15. package/template/apps/agent-server/src/southwind_agent_server/adk_tools.py +76 -0
  16. package/template/apps/agent-server/src/southwind_agent_server/app.py +1 -2
  17. package/template/apps/agent-server/tests/test_adk_adapter.py +305 -0
  18. package/template/apps/agent-server/tests/test_adk_web_agent.py +12 -0
  19. package/template/apps/server/package.json +4 -1
  20. package/template/apps/server/src/agent/run-facade.ts +2 -7
  21. package/template/apps/server/src/agent/tool-registry.ts +2 -6
  22. package/template/apps/server/src/application-services.ts +50 -0
  23. package/template/apps/server/src/audit/drizzle-log-store.ts +25 -0
  24. package/template/apps/server/src/audit/search-summary.ts +2 -6
  25. package/template/apps/server/src/auth/credential-restriction.ts +1 -0
  26. package/template/apps/server/src/auth/password.ts +30 -21
  27. package/template/apps/server/src/auth/routes.test.ts +44 -3
  28. package/template/apps/server/src/auth/routes.ts +6 -1
  29. package/template/apps/server/src/auth/runtime.ts +4 -1
  30. package/template/apps/server/src/auth/service.ts +25 -7
  31. package/template/apps/server/src/configuration/bootstrap.ts +20 -1
  32. package/template/apps/server/src/configuration/definition.test.ts +71 -0
  33. package/template/apps/server/src/configuration/definition.ts +242 -12
  34. package/template/apps/server/src/configuration/drizzle-repository.ts +36 -2
  35. package/template/apps/server/src/configuration/repository.ts +27 -2
  36. package/template/apps/server/src/configuration/routes.ts +4 -1
  37. package/template/apps/server/src/configuration/service.test.ts +100 -0
  38. package/template/apps/server/src/configuration/service.ts +50 -10
  39. package/template/apps/server/src/data-access/scoped-repository.integration.test.ts +3 -2
  40. package/template/apps/server/src/data-governance/export/service.ts +2 -6
  41. package/template/apps/server/src/example-modules.ts +4 -1
  42. package/template/apps/server/src/guards.ts +18 -5
  43. package/template/apps/server/src/http/request-source.test.ts +85 -0
  44. package/template/apps/server/src/http/request-source.ts +90 -0
  45. package/template/apps/server/src/index.ts +23 -6
  46. package/template/apps/server/src/license/routes.ts +2 -0
  47. package/template/apps/server/src/license/runtime-service.ts +10 -11
  48. package/template/apps/server/src/module-bootstrap.ts +2 -2
  49. package/template/apps/server/src/module-host/request-context.ts +6 -6
  50. package/template/apps/server/src/module-host/storage-scope.ts +2 -9
  51. package/template/apps/server/src/module-host/user-directory-port.ts +2 -6
  52. package/template/apps/server/src/notification/channel-adapter.ts +46 -0
  53. package/template/apps/server/src/notification/delivery-policy.test.ts +59 -0
  54. package/template/apps/server/src/notification/delivery-policy.ts +37 -0
  55. package/template/apps/server/src/notification/delivery-repository.ts +198 -0
  56. package/template/apps/server/src/notification/delivery-worker.test.ts +87 -0
  57. package/template/apps/server/src/notification/delivery-worker.ts +88 -0
  58. package/template/apps/server/src/notification/drizzle-management.ts +180 -0
  59. package/template/apps/server/src/notification/drizzle-query.ts +160 -0
  60. package/template/apps/server/src/notification/drizzle-repository.integration.test.ts +301 -0
  61. package/template/apps/server/src/notification/drizzle-repository.ts +156 -90
  62. package/template/apps/server/src/notification/extension-routes.test.ts +119 -0
  63. package/template/apps/server/src/notification/extension-routes.ts +111 -0
  64. package/template/apps/server/src/notification/memory-delivery-repository.ts +137 -0
  65. package/template/apps/server/src/notification/memory-support.ts +72 -0
  66. package/template/apps/server/src/notification/preference-repository.ts +95 -0
  67. package/template/apps/server/src/notification/recipient-directory.ts +104 -0
  68. package/template/apps/server/src/notification/repository.test.ts +171 -0
  69. package/template/apps/server/src/notification/repository.ts +179 -32
  70. package/template/apps/server/src/notification/revision-routes.test.ts +186 -0
  71. package/template/apps/server/src/notification/route-support.ts +225 -0
  72. package/template/apps/server/src/notification/routes.test.ts +182 -0
  73. package/template/apps/server/src/notification/routes.ts +225 -132
  74. package/template/apps/server/src/notification/service.test.ts +104 -0
  75. package/template/apps/server/src/notification/service.ts +140 -0
  76. package/template/apps/server/src/notification/smtp-adapter.test.ts +65 -0
  77. package/template/apps/server/src/notification/smtp-adapter.ts +68 -0
  78. package/template/apps/server/src/persistence.integration.test.ts +41 -2
  79. package/template/apps/server/src/route-guards.ts +1 -0
  80. package/template/apps/server/src/runtime-audit-list.test.ts +39 -0
  81. package/template/apps/server/src/runtime-development.ts +1 -0
  82. package/template/apps/server/src/runtime.test.ts +23 -9
  83. package/template/apps/server/src/runtime.ts +39 -2
  84. package/template/apps/server/src/scheduler/audit.ts +2 -11
  85. package/template/apps/server/src/scheduler/definitions.test.ts +6 -0
  86. package/template/apps/server/src/scheduler/definitions.ts +7 -0
  87. package/template/apps/server/src/scheduler/recovery-service.ts +2 -11
  88. package/template/apps/server/src/scheduler/routes.ts +2 -7
  89. package/template/apps/server/src/search/opensearch-client.test.ts +45 -0
  90. package/template/apps/server/src/search/opensearch-client.ts +145 -0
  91. package/template/apps/server/src/search/service.ts +2 -6
  92. package/template/apps/server/src/settings/routes.ts +15 -3
  93. package/template/apps/server/src/settings/runtime.ts +10 -1
  94. package/template/apps/server/src/storage/runtime.ts +13 -0
  95. package/template/apps/server/src/system/audit-query.ts +33 -4
  96. package/template/apps/server/src/system/audit-routes.test.ts +58 -4
  97. package/template/apps/server/src/system/audit-routes.ts +6 -5
  98. package/template/apps/server/src/system/department-organization.ts +320 -0
  99. package/template/apps/server/src/system/department-routes.test.ts +207 -48
  100. package/template/apps/server/src/system/department-routes.ts +114 -33
  101. package/template/apps/server/src/system/drizzle-repository.ts +15 -7
  102. package/template/apps/server/src/system/drizzle-scoped-repository.ts +5 -3
  103. package/template/apps/server/src/system/drizzle-system.ts +37 -9
  104. package/template/apps/server/src/system/drizzle-transaction-context.ts +14 -0
  105. package/template/apps/server/src/system/identity-route-config.ts +7 -2
  106. package/template/apps/server/src/system/mutation-runner.ts +23 -0
  107. package/template/apps/server/src/system/organization-postgres.integration.test.ts +176 -0
  108. package/template/apps/server/src/system/resource-access-response.ts +12 -6
  109. package/template/apps/server/src/system/role-department-scope-policy.ts +55 -0
  110. package/template/apps/server/src/system/route-helpers.ts +2 -5
  111. package/template/apps/server/src/system/route-types.ts +12 -3
  112. package/template/apps/server/src/system/routes-validation.test.ts +26 -0
  113. package/template/apps/server/src/system/routes.test.ts +10 -2
  114. package/template/apps/server/src/system/routes.ts +70 -36
  115. package/template/apps/server/src/system/seed.ts +7 -0
  116. package/template/apps/server/src/work-order/data-scope.integration.test.ts +9 -2
  117. package/template/apps/server/src/work-order/opensearch-index.test.ts +97 -0
  118. package/template/apps/server/src/work-order/opensearch-index.ts +313 -0
  119. package/template/apps/server/src/work-order/search-provider.ts +60 -1
  120. package/template/apps/server/src/work-order/service.ts +42 -10
  121. package/template/apps/web/src/app/error-pages.ts +9 -0
  122. package/template/apps/web/src/components/auth/PasswordChangeForm.vue +16 -11
  123. package/template/apps/web/src/components/auth/PasswordChangeForm.webtest.ts +14 -0
  124. package/template/apps/web/src/components/common/FormDialog.vue +16 -2
  125. package/template/apps/web/src/components/common/ModalLayout.webtest.ts +20 -1
  126. package/template/apps/web/src/composables/useGlobalSearch.ts +68 -0
  127. package/template/apps/web/src/composables/usePlatformSettings.ts +8 -7
  128. package/template/apps/web/src/layout/GlobalSearchResults.vue +58 -0
  129. package/template/apps/web/src/layout/NavigationSearchDialog.vue +42 -6
  130. package/template/apps/web/src/layout/NavigationSearchDialog.webtest.ts +71 -0
  131. package/template/apps/web/src/layout/NotificationMenu.vue +73 -31
  132. package/template/apps/web/src/layout/NotificationMenu.webtest.ts +44 -1
  133. package/template/apps/web/src/layout/NotificationMenuItem.vue +61 -0
  134. package/template/apps/web/src/layout/NotificationPreferencesDialog.vue +181 -0
  135. package/template/apps/web/src/layout/NotificationPreferencesDialog.webtest.ts +80 -0
  136. package/template/apps/web/src/lib/date-time.ts +22 -10
  137. package/template/apps/web/src/lib/date-time.webtest.ts +9 -1
  138. package/template/apps/web/src/main.ts +14 -1
  139. package/template/apps/web/src/pages/auth/ChangePasswordPage.vue +16 -1
  140. package/template/apps/web/src/pages/configuration/components/ModuleConfigVersionList.vue +1 -0
  141. package/template/apps/web/src/pages/configuration/components/ModuleConfigurationEditor.vue +10 -0
  142. package/template/apps/web/src/pages/configuration/components/ModuleConfigurationWorkbench.vue +37 -6
  143. package/template/apps/web/src/pages/errors/PlatformErrorPage.vue +115 -0
  144. package/template/apps/web/src/pages/errors/PlatformErrorPage.webtest.ts +44 -0
  145. package/template/apps/web/src/pages/errors/error-page-extension.ts +26 -0
  146. package/template/apps/web/src/pages/system/SystemNotificationsPage.vue +136 -68
  147. package/template/apps/web/src/pages/system/SystemNotificationsPage.webtest.ts +224 -12
  148. package/template/apps/web/src/pages/system/audit-log-presenter.ts +86 -0
  149. package/template/apps/web/src/pages/system/audit-time-range.ts +28 -0
  150. package/template/apps/web/src/pages/system/components/AuditActionCell.vue +10 -69
  151. package/template/apps/web/src/pages/system/components/AuditActionCell.webtest.ts +20 -27
  152. package/template/apps/web/src/pages/system/components/AuditLogDetailDialog.vue +134 -0
  153. package/template/apps/web/src/pages/system/components/AuditLogDetailDialog.webtest.ts +76 -0
  154. package/template/apps/web/src/pages/system/components/AuditLogFilters.vue +116 -52
  155. package/template/apps/web/src/pages/system/components/AuditLogFilters.webtest.ts +52 -15
  156. package/template/apps/web/src/pages/system/components/NotificationHistoryDialog.vue +129 -0
  157. package/template/apps/web/src/pages/system/components/NotificationManagementCard.vue +91 -0
  158. package/template/apps/web/src/pages/system/components/NotificationPublishDialog.vue +121 -0
  159. package/template/apps/web/src/pages/system/components/NotificationPublishForm.vue +162 -1
  160. package/template/apps/web/src/pages/system/components/SystemResourceActions.vue +68 -0
  161. package/template/apps/web/src/pages/system/components/SystemResourceEditor.vue +74 -0
  162. package/template/apps/web/src/pages/system/components/SystemResourceFilters.vue +3 -4
  163. package/template/apps/web/src/pages/system/components/SystemResourcePage.vue +91 -98
  164. package/template/apps/web/src/pages/system/components/SystemResourcePage.webtest.ts +68 -0
  165. package/template/apps/web/src/pages/system/components/SystemResourceTable.vue +20 -1
  166. package/template/apps/web/src/pages/system/components/SystemResourceTable.webtest.ts +28 -7
  167. package/template/apps/web/src/pages/system/components/department/DepartmentOrganizationForm.vue +210 -0
  168. package/template/apps/web/src/pages/system/components/department/DepartmentTransitionDialog.vue +112 -0
  169. package/template/apps/web/src/pages/system/components/user-account/UserAccountForm.vue +17 -4
  170. package/template/apps/web/src/pages/system/composables/useDepartmentTransitions.ts +79 -0
  171. package/template/apps/web/src/pages/system/composables/useNotificationPermissions.ts +14 -0
  172. package/template/apps/web/src/pages/system/composables/useSystemResource.ts +2 -0
  173. package/template/apps/web/src/pages/system/composables/useSystemResource.webtest.ts +15 -2
  174. package/template/apps/web/src/pages/system/resource-config.ts +12 -4
  175. package/template/apps/web/src/router/error-navigation.ts +50 -0
  176. package/template/apps/web/src/router/error-navigation.webtest.ts +106 -0
  177. package/template/apps/web/src/router/index.ts +71 -0
  178. package/template/apps/web/src/router/index.webtest.ts +29 -0
  179. package/template/apps/web/src/router/manifest-routes.webtest.ts +4 -0
  180. package/template/apps/web/src/router/shared-scaffold-neutrality.webtest.ts +12 -0
  181. package/template/apps/web/src/services/auth-api.ts +5 -0
  182. package/template/apps/web/src/services/http.ts +43 -6
  183. package/template/apps/web/src/services/http.webtest.ts +70 -0
  184. package/template/apps/web/src/services/notification-api.ts +85 -2
  185. package/template/apps/web/src/services/notification-api.webtest.ts +110 -0
  186. package/template/apps/web/src/services/search-api.ts +39 -0
  187. package/template/apps/web/src/services/system-api.ts +18 -0
  188. package/template/apps/web/vitest.config.ts +1 -0
  189. package/template/docker-compose.yml +66 -0
  190. package/template/docs/architecture/ai-runtime.md +14 -5
  191. package/template/docs/architecture/search-provider-protocol.md +38 -2
  192. package/template/docs/development/custom-error-pages.md +78 -0
  193. package/template/docs/development/custom-login-page.md +3 -3
  194. package/template/docs/platform/agent-runtime.md +32 -6
  195. package/template/docs/platform/audit-reliability.md +46 -2
  196. package/template/docs/platform/module-configuration.md +13 -4
  197. package/template/docs/platform/notifications.md +67 -11
  198. package/template/docs/platform/organization-membership.md +17 -3
  199. package/template/docs/platform/system-crud-api.md +16 -0
  200. package/template/docs/platform/web-system-crud.md +6 -2
  201. package/template/package.json +4 -0
  202. package/template/packages/config/index.test.ts +45 -0
  203. package/template/packages/config/package.json +1 -1
  204. package/template/packages/config/src/platform.ts +169 -48
  205. package/template/packages/database/drizzle/0035_material_nightshade.sql +10 -0
  206. package/template/packages/database/drizzle/0036_hesitant_speed.sql +35 -0
  207. package/template/packages/database/drizzle/0037_absent_nick_fury.sql +2 -0
  208. package/template/packages/database/drizzle/0038_uneven_wasp.sql +38 -0
  209. package/template/packages/database/drizzle/meta/0035_snapshot.json +7331 -0
  210. package/template/packages/database/drizzle/meta/0036_snapshot.json +7436 -0
  211. package/template/packages/database/drizzle/meta/0037_snapshot.json +7457 -0
  212. package/template/packages/database/drizzle/meta/0038_snapshot.json +7749 -0
  213. package/template/packages/database/drizzle/meta/_journal.json +28 -0
  214. package/template/packages/database/package.json +1 -1
  215. package/template/packages/database/src/schema/identity.ts +7 -0
  216. package/template/packages/database/src/schema/notifications.ts +135 -0
  217. package/template/packages/database/src/schema-notification-delivery.test.ts +54 -0
  218. package/template/packages/database/src/schema-workflow-notification.test.ts +40 -1
  219. package/template/packages/modules/package.json +1 -1
  220. package/template/packages/modules/src/system-api-permissions.ts +54 -0
  221. package/template/packages/modules/src/system-audit-actions.ts +10 -0
  222. package/template/packages/modules/src/system-features.ts +1 -1
  223. package/template/packages/modules/src/system-module-catalog.ts +2 -1
  224. package/template/packages/modules/src/system-modules.test.ts +13 -1
  225. package/template/packages/modules/src/system-modules.ts +12 -0
  226. package/template/packages/modules/src/system-permissions.ts +9 -5
  227. package/template/packages/server-sdk/package.json +1 -1
  228. package/template/packages/shared/package.json +1 -1
  229. package/template/packages/shared/src/audit.ts +25 -0
  230. package/template/packages/shared/src/module-configuration.ts +5 -0
  231. package/template/packages/shared/src/notification.ts +92 -1
  232. package/template/packages/shared/src/password.ts +7 -2
  233. package/template/packages/shared/src/platform-settings.ts +2 -0
  234. package/template/packages/storage/package.json +1 -1
  235. package/template/packages/storage/src/upload-service.test.ts +16 -0
  236. package/template/packages/storage/src/upload-service.ts +5 -1
  237. package/template/scripts/notification-postgres-regression.ts +27 -0
@@ -0,0 +1,181 @@
1
+ <script setup lang="ts">
2
+ import type {
3
+ ExternalNotificationChannel,
4
+ UpdateNotificationPreferenceInput,
5
+ } from "@southwind-ai/shared";
6
+ import { computed, onBeforeUnmount, onMounted, shallowRef, watch } from "vue";
7
+ import { useRouter } from "vue-router";
8
+ import FormDialog from "@/components/common/FormDialog.vue";
9
+ import UnsavedChangesDialog from "@/components/common/UnsavedChangesDialog.vue";
10
+ import { Checkbox } from "@/components/ui/checkbox";
11
+ import { Input } from "@/components/ui/input";
12
+ import { useUnsavedChangesConfirmation } from "@/composables/useUnsavedChangesConfirmation";
13
+ import {
14
+ getNotificationPreference,
15
+ updateNotificationPreference,
16
+ } from "@/services/notification-api";
17
+
18
+ const open = defineModel<boolean>("open", { default: false });
19
+ const form = shallowRef(emptyPreference());
20
+ const baseline = shallowRef(emptyPreference());
21
+ const categoryText = shallowRef("");
22
+ const baselineCategoryText = shallowRef("");
23
+ const loading = shallowRef(false);
24
+ const error = shallowRef("");
25
+ const confirmation = useUnsavedChangesConfirmation();
26
+ const router = useRouter();
27
+ let removeRouteGuard: (() => void) | undefined;
28
+ const dirty = computed(
29
+ () =>
30
+ open.value &&
31
+ (JSON.stringify(form.value) !== JSON.stringify(baseline.value) ||
32
+ categoryText.value !== baselineCategoryText.value),
33
+ );
34
+
35
+ watch(open, (value) => {
36
+ if (value) void load();
37
+ });
38
+ onMounted(() => {
39
+ window.addEventListener("beforeunload", preventDirtyUnload);
40
+ removeRouteGuard = router.beforeEach(() =>
41
+ dirty.value ? confirmation.request(save) : true,
42
+ );
43
+ });
44
+ onBeforeUnmount(() => {
45
+ window.removeEventListener("beforeunload", preventDirtyUnload);
46
+ removeRouteGuard?.();
47
+ });
48
+
49
+ async function load() {
50
+ loading.value = true;
51
+ error.value = "";
52
+ try {
53
+ const preference = await getNotificationPreference();
54
+ form.value = {
55
+ enabledChannels: [...preference.enabledChannels],
56
+ disabledCategories: [...preference.disabledCategories],
57
+ };
58
+ baseline.value = structuredClone(form.value);
59
+ categoryText.value = preference.disabledCategories.join("、");
60
+ baselineCategoryText.value = categoryText.value;
61
+ } catch (currentError) {
62
+ error.value = message(currentError);
63
+ } finally {
64
+ loading.value = false;
65
+ }
66
+ }
67
+
68
+ async function save(): Promise<boolean> {
69
+ if (loading.value) return false;
70
+ loading.value = true;
71
+ error.value = "";
72
+ try {
73
+ const input = {
74
+ ...form.value,
75
+ disabledCategories: categoryText.value
76
+ .split(/[、,,]/)
77
+ .map((value) => value.trim())
78
+ .filter(Boolean),
79
+ };
80
+ const preference = await updateNotificationPreference(input);
81
+ form.value = {
82
+ enabledChannels: [...preference.enabledChannels],
83
+ disabledCategories: [...preference.disabledCategories],
84
+ };
85
+ baseline.value = structuredClone(form.value);
86
+ categoryText.value = preference.disabledCategories.join("、");
87
+ baselineCategoryText.value = categoryText.value;
88
+ open.value = false;
89
+ return true;
90
+ } catch (currentError) {
91
+ error.value = message(currentError);
92
+ return false;
93
+ } finally {
94
+ loading.value = false;
95
+ }
96
+ }
97
+
98
+ function toggle(
99
+ channel: ExternalNotificationChannel,
100
+ checked: boolean | "indeterminate",
101
+ ) {
102
+ const current = form.value.enabledChannels;
103
+ form.value = {
104
+ ...form.value,
105
+ enabledChannels:
106
+ checked === true
107
+ ? [...new Set([...current, channel])]
108
+ : current.filter((item) => item !== channel),
109
+ };
110
+ }
111
+
112
+ function beforeClose() {
113
+ return dirty.value ? confirmation.request(save) : true;
114
+ }
115
+
116
+ function preventDirtyUnload(event: BeforeUnloadEvent) {
117
+ if (!dirty.value) return;
118
+ event.preventDefault();
119
+ event.returnValue = "";
120
+ }
121
+
122
+ function emptyPreference(): UpdateNotificationPreferenceInput {
123
+ return { enabledChannels: [], disabledCategories: [] };
124
+ }
125
+
126
+ function message(errorValue: unknown) {
127
+ return errorValue instanceof Error ? errorValue.message : String(errorValue);
128
+ }
129
+ </script>
130
+
131
+ <template>
132
+ <FormDialog
133
+ v-model:open="open"
134
+ title="通知偏好"
135
+ description="站内通知始终保留;这里只控制普通类别的邮件和短信投递。安全等强制类别可绕过偏好。"
136
+ submit-text="保存偏好"
137
+ :loading="loading"
138
+ :before-close="beforeClose"
139
+ prevent-outside-close
140
+ content-class="sm:max-w-lg"
141
+ @submit="save"
142
+ >
143
+ <p v-if="error" class="text-sm text-destructive">{{ error }}</p>
144
+ <fieldset class="space-y-3">
145
+ <legend class="text-sm font-medium">启用外部渠道</legend>
146
+ <label
147
+ v-for="channel in ['email', 'sms'] as ExternalNotificationChannel[]"
148
+ :key="channel"
149
+ class="flex items-center gap-2 text-sm"
150
+ >
151
+ <Checkbox
152
+ :model-value="form.enabledChannels.includes(channel)"
153
+ @update:model-value="toggle(channel, $event)"
154
+ />
155
+ {{ channel === "email" ? "邮件" : "短信" }}
156
+ </label>
157
+ <p class="text-xs text-muted-foreground">
158
+ 短信需要业务项目提供供应商 Adapter;未配置时会记录为不可投递。
159
+ </p>
160
+ </fieldset>
161
+ <label class="block space-y-2 text-sm font-medium">
162
+ <span>不接收外部推送的普通分类</span>
163
+ <Input
164
+ v-model="categoryText"
165
+ maxlength="4000"
166
+ placeholder="例如:营销、运营活动"
167
+ />
168
+ <span class="block text-xs font-normal text-muted-foreground">
169
+ 使用顿号或逗号分隔;不会影响站内通知。
170
+ </span>
171
+ </label>
172
+ </FormDialog>
173
+
174
+ <UnsavedChangesDialog
175
+ :open="confirmation.open.value"
176
+ :saving="confirmation.saving.value"
177
+ description="通知偏好尚未保存。取消会留在编辑状态;保存成功后才会继续关闭或离开。"
178
+ @cancel="confirmation.cancel"
179
+ @save="confirmation.save"
180
+ />
181
+ </template>
@@ -0,0 +1,80 @@
1
+ import { flushPromises, mount } from "@vue/test-utils";
2
+ import { beforeEach, describe, expect, test, vi } from "vitest";
3
+ import FormDialog from "@/components/common/FormDialog.vue";
4
+ import NotificationPreferencesDialog from "./NotificationPreferencesDialog.vue";
5
+
6
+ const mocks = vi.hoisted(() => ({
7
+ get: vi.fn(),
8
+ update: vi.fn(),
9
+ beforeEach: vi.fn(() => vi.fn()),
10
+ }));
11
+
12
+ vi.mock("@/services/notification-api", () => ({
13
+ getNotificationPreference: mocks.get,
14
+ updateNotificationPreference: mocks.update,
15
+ }));
16
+
17
+ vi.mock("vue-router", () => ({
18
+ useRouter: () => ({ beforeEach: mocks.beforeEach }),
19
+ }));
20
+
21
+ describe("通知偏好", () => {
22
+ beforeEach(() => {
23
+ mocks.get.mockReset();
24
+ mocks.update.mockReset();
25
+ mocks.beforeEach.mockClear();
26
+ mocks.get.mockResolvedValue({
27
+ userId: "u1",
28
+ enabledChannels: [],
29
+ disabledCategories: [],
30
+ });
31
+ mocks.update.mockImplementation(async (input) => ({
32
+ userId: "u1",
33
+ ...input,
34
+ updatedAt: "2026-07-26T00:00:00.000Z",
35
+ }));
36
+ });
37
+
38
+ test("保存渠道和分类偏好,且站内通知边界保持可见", async () => {
39
+ const wrapper = mount(NotificationPreferencesDialog, {
40
+ props: { open: true },
41
+ attachTo: document.body,
42
+ });
43
+ await flushPromises();
44
+ expect(document.body.textContent).toContain("站内通知始终保留");
45
+ document.body
46
+ .querySelector<HTMLButtonElement>('[data-slot="checkbox"]')
47
+ ?.click();
48
+ const input = document.body.querySelector<HTMLInputElement>("input");
49
+ expect(input).not.toBeNull();
50
+ input!.value = "营销、运营活动";
51
+ input!.dispatchEvent(new Event("input", { bubbles: true }));
52
+ await flushPromises();
53
+ wrapper.getComponent(FormDialog).vm.$emit("submit");
54
+ await flushPromises();
55
+
56
+ expect(mocks.update).toHaveBeenCalledWith({
57
+ enabledChannels: ["email"],
58
+ disabledCategories: ["营销", "运营活动"],
59
+ });
60
+ expect(wrapper.getComponent(FormDialog).props("open")).toBe(false);
61
+ wrapper.unmount();
62
+ });
63
+
64
+ test("偏好草稿触发浏览器离开保护", async () => {
65
+ const wrapper = mount(NotificationPreferencesDialog, {
66
+ props: { open: true },
67
+ attachTo: document.body,
68
+ });
69
+ await flushPromises();
70
+ const input = document.body.querySelector<HTMLInputElement>("input");
71
+ expect(input).not.toBeNull();
72
+ input!.value = "尚未保存";
73
+ input!.dispatchEvent(new Event("input", { bubbles: true }));
74
+ await flushPromises();
75
+ const event = new Event("beforeunload", { cancelable: true });
76
+ window.dispatchEvent(event);
77
+ expect(event.defaultPrevented).toBe(true);
78
+ wrapper.unmount();
79
+ });
80
+ });
@@ -1,13 +1,25 @@
1
- const dateTimeFormatter = new Intl.DateTimeFormat("zh-CN", {
2
- timeZone: "Asia/Shanghai",
3
- year: "numeric",
4
- month: "2-digit",
5
- day: "2-digit",
6
- hour: "2-digit",
7
- minute: "2-digit",
8
- second: "2-digit",
9
- hourCycle: "h23",
10
- });
1
+ const DEFAULT_TIME_ZONE = "Asia/Shanghai";
2
+ let currentTimeZone = DEFAULT_TIME_ZONE;
3
+ let dateTimeFormatter = createFormatter(currentTimeZone);
4
+
5
+ function createFormatter(timeZone: string) {
6
+ return new Intl.DateTimeFormat("zh-CN", {
7
+ timeZone,
8
+ year: "numeric",
9
+ month: "2-digit",
10
+ day: "2-digit",
11
+ hour: "2-digit",
12
+ minute: "2-digit",
13
+ second: "2-digit",
14
+ hourCycle: "h23",
15
+ });
16
+ }
17
+
18
+ export function setDateTimeZone(timeZone: string): void {
19
+ if (timeZone === currentTimeZone) return;
20
+ dateTimeFormatter = createFormatter(timeZone);
21
+ currentTimeZone = timeZone;
22
+ }
11
23
 
12
24
  export function formatDateTime(value: unknown): string {
13
25
  const date = value instanceof Date ? value : new Date(String(value || ""));
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, test } from "vitest";
2
- import { formatDateTime } from "./date-time";
2
+ import { formatDateTime, setDateTimeZone } from "./date-time";
3
3
 
4
4
  describe("formatDateTime", () => {
5
5
  test("固定使用 Asia/Shanghai 的 24 小时时间显示到秒", () => {
@@ -11,4 +11,12 @@ describe("formatDateTime", () => {
11
11
  test("无效时间显示占位符", () => {
12
12
  expect(formatDateTime("not-a-date")).toBe("-");
13
13
  });
14
+
15
+ test("运行时切换 IANA 时区后用于后续格式化", () => {
16
+ setDateTimeZone("UTC");
17
+ expect(formatDateTime("2026-07-14T01:05:06.000Z")).toBe(
18
+ "2026-07-14 01:05:06",
19
+ );
20
+ setDateTimeZone("Asia/Shanghai");
21
+ });
14
22
  });
@@ -2,10 +2,23 @@ import { createApp } from "vue";
2
2
  import App from "./App.vue";
3
3
  import { router } from "./router";
4
4
  import { installUnauthorizedNavigation } from "./router/auth-navigation";
5
- import { setUnauthorizedHandler } from "./services/http";
5
+ import {
6
+ installApiFailureNavigation,
7
+ installUnexpectedErrorNavigation,
8
+ } from "./router/error-navigation";
9
+ import { setApiFailureHandler, setUnauthorizedHandler } from "./services/http";
6
10
  import "./style.css";
7
11
 
8
12
  setUnauthorizedHandler(installUnauthorizedNavigation(router));
13
+ setApiFailureHandler(installApiFailureNavigation(router));
14
+ const navigateUnexpectedError = installUnexpectedErrorNavigation(router);
15
+ router.onError((_error, to) => {
16
+ if (to.meta.errorPage !== true) navigateUnexpectedError(to.fullPath);
17
+ });
9
18
  const app = createApp(App).use(router);
19
+ app.config.errorHandler = (error, _instance, info) => {
20
+ console.error("Web 页面运行异常", { error, info });
21
+ navigateUnexpectedError();
22
+ };
10
23
  await router.isReady();
11
24
  app.mount("#app");
@@ -1,5 +1,7 @@
1
1
  <script setup lang="ts">
2
2
  import { useRouter } from "vue-router";
3
+ import { onMounted, shallowRef } from "vue";
4
+ import { PASSWORD_POLICY, type PasswordPolicy } from "@southwind-ai/shared";
3
5
  import PasswordChangeForm from "@/components/auth/PasswordChangeForm.vue";
4
6
  import {
5
7
  Card,
@@ -10,11 +12,23 @@ import {
10
12
  } from "@/components/ui/card";
11
13
  import { useAuthSession } from "@/composables/useAuthSession";
12
14
  import { usePasswordChange } from "@/composables/usePasswordChange";
13
- import type { PasswordChangeInput } from "@/services/auth-api";
15
+ import {
16
+ loadPasswordPolicy,
17
+ type PasswordChangeInput,
18
+ } from "@/services/auth-api";
14
19
 
15
20
  const flow = usePasswordChange();
16
21
  const auth = useAuthSession();
17
22
  const router = useRouter();
23
+ const policy = shallowRef<PasswordPolicy>(PASSWORD_POLICY);
24
+
25
+ onMounted(async () => {
26
+ try {
27
+ policy.value = await loadPasswordPolicy();
28
+ } catch {
29
+ // The server enforces the policy; the packaged default keeps the form usable.
30
+ }
31
+ });
18
32
 
19
33
  async function submit(input: PasswordChangeInput) {
20
34
  if (!(await flow.submit(input))) return;
@@ -38,6 +52,7 @@ async function submit(input: PasswordChangeInput) {
38
52
  <PasswordChangeForm
39
53
  :loading="flow.loading.value"
40
54
  :error="flow.submitError.value"
55
+ :policy="policy"
41
56
  @submit="submit"
42
57
  />
43
58
  </CardContent>
@@ -26,6 +26,7 @@ const statusText: Record<ModuleConfigApplyStatus, string> = {
26
26
  applying: "应用中",
27
27
  effective: "已生效",
28
28
  failed: "应用失败",
29
+ "pending-restart": "等待重启",
29
30
  superseded: "已替代",
30
31
  };
31
32
  </script>
@@ -52,6 +52,9 @@ function updateInteger(path: string, event: Event) {
52
52
  <template v-if="summary.effectiveVersion">
53
53
  · 生效版本 v{{ summary.effectiveVersion }}
54
54
  </template>
55
+ <template v-if="summary.latestStatus === 'pending-restart'">
56
+ · v{{ summary.latestVersion }} 等待重启,表单显示该版本的目标值
57
+ </template>
55
58
  </CardDescription>
56
59
  </CardHeader>
57
60
  <CardContent class="space-y-5">
@@ -68,6 +71,13 @@ function updateInteger(path: string, event: Event) {
68
71
  <p class="text-xs text-muted-foreground">
69
72
  {{ field.description }}
70
73
  </p>
74
+ <p class="text-xs font-medium text-muted-foreground">
75
+ {{
76
+ field.applyMode === "restart"
77
+ ? "保存后等待服务重启生效"
78
+ : "保存后即时生效"
79
+ }}
80
+ </p>
71
81
  </div>
72
82
  <Switch
73
83
  v-if="field.input === 'boolean'"
@@ -1,5 +1,8 @@
1
1
  <script setup lang="ts">
2
- import type { ModuleConfigValues, ModuleConfigVersion } from "@southwind-ai/shared";
2
+ import type {
3
+ ModuleConfigValues,
4
+ ModuleConfigVersion,
5
+ } from "@southwind-ai/shared";
3
6
  import { computed, onBeforeUnmount, onMounted, shallowRef } from "vue";
4
7
  import { onBeforeRouteLeave } from "vue-router";
5
8
  import UnsavedChangesDialog from "@/components/common/UnsavedChangesDialog.vue";
@@ -7,6 +10,7 @@ import { useAccessSnapshot } from "@/composables/useAccessSnapshot";
7
10
  import { useAppToast } from "@/composables/useAppToast";
8
11
  import { useModuleConfigurations } from "@/composables/useModuleConfigurations";
9
12
  import { useUnsavedChangesConfirmation } from "@/composables/useUnsavedChangesConfirmation";
13
+ import { setDateTimeZone } from "@/lib/date-time";
10
14
  import ModuleConfigRollbackDialog from "./ModuleConfigRollbackDialog.vue";
11
15
  import ModuleConfigVersionList from "./ModuleConfigVersionList.vue";
12
16
  import ModuleConfigurationEditor from "./ModuleConfigurationEditor.vue";
@@ -80,14 +84,22 @@ async function save(): Promise<boolean> {
80
84
  { ...values.value },
81
85
  reason.value.trim() || undefined,
82
86
  );
83
- if (version.applyStatus !== "effective") {
87
+ if (
88
+ version.applyStatus !== "effective" &&
89
+ version.applyStatus !== "pending-restart"
90
+ ) {
84
91
  toast.error("新版本未能生效", {
85
92
  description: version.applyError || "请查看版本状态后重试。",
86
93
  });
87
94
  return false;
88
95
  }
96
+ applyLocalRuntimeProjection(version);
89
97
  resetDraft();
90
- toast.success(`配置版本 v${version.version} 已生效`);
98
+ toast.success(
99
+ version.applyStatus === "pending-restart"
100
+ ? `配置版本 v${version.version} 已保存,等待服务重启生效`
101
+ : `配置版本 v${version.version} 已生效`,
102
+ );
91
103
  return true;
92
104
  } catch {
93
105
  toast.error("配置版本应用失败", {
@@ -111,16 +123,24 @@ async function confirmRollback(rollbackReason: string) {
111
123
  target.version,
112
124
  rollbackReason,
113
125
  );
114
- if (version.applyStatus !== "effective") {
126
+ if (
127
+ version.applyStatus !== "effective" &&
128
+ version.applyStatus !== "pending-restart"
129
+ ) {
115
130
  toast.error("回滚版本未能生效", {
116
131
  description: version.applyError || "请查看版本状态后重试。",
117
132
  });
118
133
  return;
119
134
  }
135
+ applyLocalRuntimeProjection(version);
120
136
  rollbackOpen.value = false;
121
137
  rollbackTarget.value = undefined;
122
138
  resetDraft();
123
- toast.success(`回滚版本 v${version.version} 已生效`);
139
+ toast.success(
140
+ version.applyStatus === "pending-restart"
141
+ ? `回滚版本 v${version.version} 已保存,等待服务重启生效`
142
+ : `回滚版本 v${version.version} 已生效`,
143
+ );
124
144
  } catch {
125
145
  toast.error("创建回滚版本失败", {
126
146
  description: configurations.error.value || "请稍后重试。",
@@ -129,11 +149,22 @@ async function confirmRollback(rollbackReason: string) {
129
149
  }
130
150
 
131
151
  function resetDraft() {
132
- values.value = { ...(configurations.selected.value?.effectiveValues || {}) };
152
+ values.value = {
153
+ ...(configurations.selected.value?.pendingValues ||
154
+ configurations.selected.value?.effectiveValues ||
155
+ {}),
156
+ };
133
157
  baseline.value = serialize(values.value);
134
158
  reason.value = "";
135
159
  }
136
160
 
161
+ function applyLocalRuntimeProjection(version: ModuleConfigVersion) {
162
+ const timeZone = version.values["regional.timeZone"];
163
+ if (version.applyStatus === "effective" && typeof timeZone === "string") {
164
+ setDateTimeZone(timeZone);
165
+ }
166
+ }
167
+
137
168
  function confirmChanges(): Promise<boolean> {
138
169
  return dirty.value ? confirmation.request(save) : Promise.resolve(true);
139
170
  }
@@ -0,0 +1,115 @@
1
+ <script setup lang="ts">
2
+ import {
3
+ AlertTriangleIcon,
4
+ CloudOffIcon,
5
+ ConstructionIcon,
6
+ FileQuestionIcon,
7
+ } from "@lucide/vue";
8
+ import { computed, type Component } from "vue";
9
+ import { Button } from "@/components/ui/button";
10
+ import { Card, CardContent } from "@/components/ui/card";
11
+ import type { ErrorPageKind, ErrorPageProps } from "./error-page-extension";
12
+
13
+ interface ErrorPageContent {
14
+ eyebrow: string;
15
+ title: string;
16
+ description: string;
17
+ primaryLabel: string;
18
+ }
19
+
20
+ const props = defineProps<ErrorPageProps>();
21
+
22
+ const contentByKind: Record<ErrorPageKind, ErrorPageContent> = {
23
+ "not-found": {
24
+ eyebrow: "404",
25
+ title: "页面不存在",
26
+ description: "当前地址没有对应页面,可能已被移动、删除或输入有误。",
27
+ primaryLabel: "返回首页",
28
+ },
29
+ internal: {
30
+ eyebrow: "500",
31
+ title: "页面运行异常",
32
+ description:
33
+ "应用处理当前页面时发生异常。你可以重新进入原页面,或返回首页继续使用。",
34
+ primaryLabel: "重新进入",
35
+ },
36
+ maintenance: {
37
+ eyebrow: "维护中",
38
+ title: "服务正在维护",
39
+ description:
40
+ "服务端暂时停止处理请求。请稍后重试;如持续发生,请联系系统维护人员。",
41
+ primaryLabel: "重新连接",
42
+ },
43
+ network: {
44
+ eyebrow: "网络异常",
45
+ title: "无法连接服务",
46
+ description: "请检查当前网络与服务状态,连接恢复后再重新进入原页面。",
47
+ primaryLabel: "重新连接",
48
+ },
49
+ };
50
+
51
+ const iconByKind = {
52
+ "not-found": FileQuestionIcon,
53
+ internal: AlertTriangleIcon,
54
+ maintenance: ConstructionIcon,
55
+ network: CloudOffIcon,
56
+ } satisfies Record<ErrorPageKind, Component>;
57
+
58
+ const content = computed(() => contentByKind[props.kind]);
59
+ const icon = computed(() => iconByKind[props.kind]);
60
+ const primaryTarget = computed(() =>
61
+ props.kind === "not-found" ? "/" : props.from,
62
+ );
63
+ </script>
64
+
65
+ <template>
66
+ <main class="mx-auto flex min-h-svh w-full max-w-3xl items-center px-6 py-16">
67
+ <Card class="w-full overflow-hidden">
68
+ <CardContent class="grid gap-8 p-8 sm:grid-cols-[auto_1fr] sm:p-12">
69
+ <div
70
+ class="flex size-14 items-center justify-center rounded-2xl bg-muted text-muted-foreground"
71
+ aria-hidden="true"
72
+ >
73
+ <component :is="icon" class="size-7" />
74
+ </div>
75
+ <section class="space-y-5">
76
+ <div class="space-y-2">
77
+ <p class="text-sm font-semibold text-muted-foreground">
78
+ {{ content.eyebrow }}
79
+ </p>
80
+ <h1 class="text-3xl font-semibold tracking-tight">
81
+ {{ content.title }}
82
+ </h1>
83
+ <p class="max-w-xl leading-7 text-muted-foreground">
84
+ {{ content.description }}
85
+ </p>
86
+ </div>
87
+
88
+ <dl class="space-y-1 text-xs text-muted-foreground">
89
+ <div class="flex flex-wrap gap-2">
90
+ <dt>原请求地址</dt>
91
+ <dd class="break-all font-mono text-foreground">{{ from }}</dd>
92
+ </div>
93
+ <div v-if="requestId" class="flex flex-wrap gap-2">
94
+ <dt>Request ID</dt>
95
+ <dd class="break-all font-mono text-foreground">
96
+ {{ requestId }}
97
+ </dd>
98
+ </div>
99
+ </dl>
100
+
101
+ <div class="flex flex-wrap gap-3">
102
+ <Button as-child>
103
+ <RouterLink :to="primaryTarget">
104
+ {{ content.primaryLabel }}
105
+ </RouterLink>
106
+ </Button>
107
+ <Button v-if="primaryTarget !== '/'" as-child variant="outline">
108
+ <RouterLink to="/">返回首页</RouterLink>
109
+ </Button>
110
+ </div>
111
+ </section>
112
+ </CardContent>
113
+ </Card>
114
+ </main>
115
+ </template>
@@ -0,0 +1,44 @@
1
+ import { mount } from "@vue/test-utils";
2
+ import { describe, expect, test } from "vitest";
3
+ import PlatformErrorPage from "./PlatformErrorPage.vue";
4
+ import type { ErrorPageKind } from "./error-page-extension";
5
+
6
+ function render(kind: ErrorPageKind, requestId?: string) {
7
+ return mount(PlatformErrorPage, {
8
+ props: {
9
+ kind,
10
+ from: "/records/42",
11
+ requestId,
12
+ },
13
+ global: {
14
+ stubs: {
15
+ RouterLink: {
16
+ props: ["to"],
17
+ template: '<a :data-to="to"><slot /></a>',
18
+ },
19
+ },
20
+ },
21
+ });
22
+ }
23
+
24
+ describe("平台统一错误页", () => {
25
+ test.each([
26
+ ["not-found", "页面不存在", "404"],
27
+ ["internal", "页面运行异常", "500"],
28
+ ["maintenance", "服务正在维护", "维护中"],
29
+ ["network", "无法连接服务", "网络异常"],
30
+ ] as const)("展示 %s 的稳定中文语义", (kind, title, eyebrow) => {
31
+ const wrapper = render(kind);
32
+
33
+ expect(wrapper.text()).toContain(title);
34
+ expect(wrapper.text()).toContain(eyebrow);
35
+ expect(wrapper.text()).toContain("/records/42");
36
+ });
37
+
38
+ test("服务端 Request ID 可直接提供给支持人员", () => {
39
+ const wrapper = render("internal", "req-error-page-1");
40
+
41
+ expect(wrapper.text()).toContain("Request ID");
42
+ expect(wrapper.text()).toContain("req-error-page-1");
43
+ });
44
+ });