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,313 @@
1
+ import type { WorkOrder } from "@southwind-ai/example-work-order";
2
+ import type { DataScopeQuerySpecification } from "../data-access/query-specification.js";
3
+ import {
4
+ OpenSearchClient,
5
+ type OpenSearchFetcher,
6
+ OpenSearchRequestError,
7
+ readOpenSearchConfig,
8
+ } from "../search/opensearch-client.js";
9
+
10
+ interface SearchHit {
11
+ _id: string;
12
+ _score?: number;
13
+ sort?: Array<string | number>;
14
+ }
15
+
16
+ interface SearchResponse {
17
+ hits: { hits: SearchHit[] };
18
+ }
19
+
20
+ interface BulkResponse {
21
+ errors: boolean;
22
+ }
23
+
24
+ export interface WorkOrderIndexSearchResult {
25
+ hits: Array<{ id: string; score?: number }>;
26
+ nextCursor?: string;
27
+ }
28
+
29
+ export class WorkOrderOpenSearchIndex {
30
+ private synchronized = false;
31
+ private synchronizing?: Promise<void>;
32
+
33
+ constructor(
34
+ private readonly client: OpenSearchClient,
35
+ private readonly indexName: string,
36
+ ) {}
37
+
38
+ async synchronize(
39
+ snapshot: () => Promise<WorkOrder[]>,
40
+ signal?: AbortSignal,
41
+ ): Promise<void> {
42
+ if (this.synchronized) return;
43
+ if (!this.synchronizing) {
44
+ this.synchronizing = snapshot()
45
+ .then((items) => this.replaceAll(items, signal))
46
+ .then(() => {
47
+ this.synchronized = true;
48
+ })
49
+ .finally(() => {
50
+ this.synchronizing = undefined;
51
+ });
52
+ }
53
+ await this.synchronizing;
54
+ }
55
+
56
+ async upsert(item: WorkOrder): Promise<void> {
57
+ if (!this.synchronized) return;
58
+ try {
59
+ if (item.status !== "active") {
60
+ await this.remove(item.id);
61
+ return;
62
+ }
63
+ await this.ensureIndex();
64
+ await this.client.request(
65
+ "PUT",
66
+ `/${this.index()}/_doc/${encodeURIComponent(item.id)}?refresh=wait_for`,
67
+ document(item),
68
+ );
69
+ } catch (error) {
70
+ this.synchronized = false;
71
+ throw error;
72
+ }
73
+ }
74
+
75
+ async remove(id: string): Promise<void> {
76
+ if (!this.synchronized) return;
77
+ try {
78
+ await this.client.request(
79
+ "DELETE",
80
+ `/${this.index()}/_doc/${encodeURIComponent(id)}?refresh=wait_for`,
81
+ undefined,
82
+ );
83
+ } catch (error) {
84
+ if (!(error instanceof OpenSearchRequestError && error.status === 404)) {
85
+ this.synchronized = false;
86
+ throw error;
87
+ }
88
+ }
89
+ }
90
+
91
+ async search(
92
+ text: string,
93
+ limit: number,
94
+ cursor: string | undefined,
95
+ scope: DataScopeQuerySpecification,
96
+ signal?: AbortSignal,
97
+ ): Promise<WorkOrderIndexSearchResult> {
98
+ const response = await this.client.request<SearchResponse>(
99
+ "POST",
100
+ `/${this.index()}/_search`,
101
+ {
102
+ size: limit,
103
+ track_total_hits: false,
104
+ ...(cursor ? { search_after: decodeCursor(cursor) } : {}),
105
+ sort: [{ _score: "desc" }, { updatedAt: "desc" }, { _id: "asc" }],
106
+ query: {
107
+ bool: {
108
+ must: [
109
+ {
110
+ bool: {
111
+ should: [
112
+ {
113
+ multi_match: {
114
+ query: text,
115
+ fields: ["title^2", "description"],
116
+ },
117
+ },
118
+ {
119
+ wildcard: {
120
+ "title.raw": {
121
+ value: `*${escapeWildcard(text)}*`,
122
+ case_insensitive: true,
123
+ boost: 2,
124
+ },
125
+ },
126
+ },
127
+ {
128
+ wildcard: {
129
+ "description.raw": {
130
+ value: `*${escapeWildcard(text)}*`,
131
+ case_insensitive: true,
132
+ },
133
+ },
134
+ },
135
+ ],
136
+ minimum_should_match: 1,
137
+ },
138
+ },
139
+ ],
140
+ filter: [{ term: { status: "active" } }, scopeFilter(scope)],
141
+ },
142
+ },
143
+ },
144
+ signal,
145
+ );
146
+ const hits = response.hits.hits;
147
+ const last = hits.at(-1)?.sort;
148
+ return {
149
+ hits: hits.map((hit) => ({
150
+ id: hit._id,
151
+ ...(hit._score !== undefined ? { score: hit._score } : {}),
152
+ })),
153
+ ...(hits.length === limit && last
154
+ ? { nextCursor: encodeCursor(last) }
155
+ : {}),
156
+ };
157
+ }
158
+
159
+ async health(signal?: AbortSignal) {
160
+ const health = await this.client.health(signal);
161
+ return {
162
+ status:
163
+ health.status === "red"
164
+ ? ("unavailable" as const)
165
+ : this.synchronized
166
+ ? ("healthy" as const)
167
+ : ("degraded" as const),
168
+ checkedAt: new Date().toISOString(),
169
+ ...(!this.synchronized ? { message: "OpenSearch 索引等待同步" } : {}),
170
+ };
171
+ }
172
+
173
+ private async replaceAll(items: WorkOrder[], signal?: AbortSignal) {
174
+ await this.ensureIndex(signal);
175
+ await this.client.request(
176
+ "POST",
177
+ `/${this.index()}/_delete_by_query?refresh=true&conflicts=proceed`,
178
+ { query: { match_all: {} } },
179
+ signal,
180
+ );
181
+ const active = items.filter(({ status }) => status === "active");
182
+ if (!active.length) return;
183
+ const lines = active.flatMap((item) => [
184
+ JSON.stringify({ index: { _index: this.indexName, _id: item.id } }),
185
+ JSON.stringify(document(item)),
186
+ ]);
187
+ const result = await this.client.request<BulkResponse>(
188
+ "POST",
189
+ "/_bulk?refresh=wait_for",
190
+ `${lines.join("\n")}\n`,
191
+ signal,
192
+ "application/x-ndjson",
193
+ );
194
+ if (result.errors) throw new Error("OpenSearch 批量索引存在失败项");
195
+ }
196
+
197
+ private async ensureIndex(signal?: AbortSignal) {
198
+ try {
199
+ await this.client.request("HEAD", `/${this.index()}`, undefined, signal);
200
+ } catch (error) {
201
+ if (!(error instanceof OpenSearchRequestError && error.status === 404)) {
202
+ throw error;
203
+ }
204
+ await this.client.request(
205
+ "PUT",
206
+ `/${this.index()}`,
207
+ {
208
+ settings: { number_of_shards: 1, number_of_replicas: 0 },
209
+ mappings: {
210
+ dynamic: "strict",
211
+ properties: {
212
+ title: { type: "text", fields: { raw: { type: "wildcard" } } },
213
+ description: {
214
+ type: "text",
215
+ fields: { raw: { type: "wildcard" } },
216
+ },
217
+ ownerId: { type: "keyword" },
218
+ departmentId: { type: "keyword" },
219
+ tenantId: { type: "keyword" },
220
+ status: { type: "keyword" },
221
+ updatedAt: { type: "date" },
222
+ },
223
+ },
224
+ },
225
+ signal,
226
+ );
227
+ }
228
+ }
229
+
230
+ private index() {
231
+ return encodeURIComponent(this.indexName);
232
+ }
233
+ }
234
+
235
+ export function createWorkOrderOpenSearchIndex(
236
+ environment: Record<string, string | undefined>,
237
+ fetcher: OpenSearchFetcher = fetch,
238
+ ): WorkOrderOpenSearchIndex | undefined {
239
+ const config = readOpenSearchConfig(environment);
240
+ if (!config) return undefined;
241
+ const indexName =
242
+ environment.OPENSEARCH_WORK_ORDER_INDEX?.trim() || "windy-work-orders-v1";
243
+ if (!/^[a-z0-9][a-z0-9_-]{0,127}$/.test(indexName)) {
244
+ throw new Error("OPENSEARCH_WORK_ORDER_INDEX 格式无效");
245
+ }
246
+ return new WorkOrderOpenSearchIndex(
247
+ new OpenSearchClient(config, fetcher),
248
+ indexName,
249
+ );
250
+ }
251
+
252
+ function document(item: WorkOrder) {
253
+ return {
254
+ title: item.title,
255
+ description: item.description,
256
+ ownerId: item.ownerId,
257
+ departmentId: item.departmentId || "",
258
+ tenantId: "single-tenant",
259
+ status: item.status,
260
+ updatedAt: item.updatedAt,
261
+ };
262
+ }
263
+
264
+ function scopeFilter(
265
+ scope: DataScopeQuerySpecification,
266
+ ): Record<string, unknown> {
267
+ if (scope.branches.some(({ kind }) => kind === "all"))
268
+ return { match_all: {} };
269
+ const should: Record<string, unknown>[] = [];
270
+ for (const branch of scope.branches) {
271
+ if (branch.kind === "tenant") {
272
+ should.push({ term: { tenantId: branch.tenantId } });
273
+ }
274
+ if (branch.kind === "owner") {
275
+ should.push({ term: { ownerId: branch.ownerId } });
276
+ }
277
+ if (branch.kind === "dimension" && branch.dimension === "departmentId") {
278
+ should.push({ terms: { departmentId: branch.values } });
279
+ }
280
+ }
281
+ return should.length
282
+ ? { bool: { should, minimum_should_match: 1 } }
283
+ : { match_none: {} };
284
+ }
285
+
286
+ function escapeWildcard(value: string) {
287
+ return value
288
+ .replaceAll("\\", "\\\\")
289
+ .replaceAll("*", "\\*")
290
+ .replaceAll("?", "\\?");
291
+ }
292
+
293
+ function encodeCursor(sort: Array<string | number>) {
294
+ return Buffer.from(JSON.stringify(sort)).toString("base64url");
295
+ }
296
+
297
+ function decodeCursor(value: string): Array<string | number> {
298
+ try {
299
+ const parsed = JSON.parse(Buffer.from(value, "base64url").toString("utf8"));
300
+ if (
301
+ !Array.isArray(parsed) ||
302
+ parsed.length !== 3 ||
303
+ parsed.some(
304
+ (item) => typeof item !== "string" && typeof item !== "number",
305
+ )
306
+ ) {
307
+ throw new Error();
308
+ }
309
+ return parsed;
310
+ } catch {
311
+ throw new Error("OpenSearch 搜索游标无效");
312
+ }
313
+ }
@@ -5,9 +5,12 @@ import type {
5
5
  ModuleSearchQuery,
6
6
  } from "@southwind-ai/server-sdk";
7
7
  import { requestActorFromModule } from "../module-host/request-context.js";
8
+ import { DataAccessDeniedError } from "../data-access/errors.js";
9
+ import { platformDataActor } from "../data-access/platform-actor.js";
8
10
  import { SearchProtocolError } from "../search/errors.js";
9
11
  import { maskSearchText } from "../search/masking.js";
10
12
  import type { WorkOrderService } from "./service.js";
13
+ import type { WorkOrderOpenSearchIndex } from "./opensearch-index.js";
11
14
 
12
15
  export const WORK_ORDER_SEARCH_PROVIDER_KEY = "work-order.ticket.search";
13
16
  export const WORK_ORDER_SEARCH_PROVIDER_VERSION = "0.1.0";
@@ -20,13 +23,19 @@ export class WorkOrderSearchProvider implements ModuleSearchProvider {
20
23
  readonly key = WORK_ORDER_SEARCH_PROVIDER_KEY;
21
24
  readonly providerVersion = WORK_ORDER_SEARCH_PROVIDER_VERSION;
22
25
 
23
- constructor(private readonly requireService: () => WorkOrderService) {}
26
+ constructor(
27
+ private readonly requireService: () => WorkOrderService,
28
+ private readonly searchIndex?: WorkOrderOpenSearchIndex,
29
+ ) {}
24
30
 
25
31
  async search(
26
32
  request: ModuleSearchQuery,
27
33
  context: ModuleSearchContext,
28
34
  ): Promise<ModuleSearchPage> {
29
35
  if (request.signal.aborted) throw new Error("search aborted");
36
+ if (this.searchIndex) {
37
+ return this.searchOpenSearch(request, context);
38
+ }
30
39
  const page = parseProviderCursor(request.cursor);
31
40
  const result = await this.requireService().list(
32
41
  { q: request.text, page, pageSize: request.limit },
@@ -48,11 +57,61 @@ export class WorkOrderSearchProvider implements ModuleSearchProvider {
48
57
  }
49
58
 
50
59
  async health(signal: AbortSignal) {
60
+ if (this.searchIndex) return this.searchIndex.health(signal);
51
61
  return {
52
62
  status: signal.aborted ? ("unavailable" as const) : ("healthy" as const),
53
63
  checkedAt: new Date().toISOString(),
54
64
  };
55
65
  }
66
+
67
+ private async searchOpenSearch(
68
+ request: ModuleSearchQuery,
69
+ context: ModuleSearchContext,
70
+ ): Promise<ModuleSearchPage> {
71
+ const service = this.requireService();
72
+ await this.searchIndex!.synchronize(
73
+ () =>
74
+ service.searchIndexSnapshot(
75
+ platformDataActor("search_work_order_index", "工单搜索索引同步"),
76
+ ),
77
+ request.signal,
78
+ );
79
+ const actor = requestActorFromModule(context.actor);
80
+ const result = await this.searchIndex!.search(
81
+ request.text,
82
+ request.limit,
83
+ request.cursor,
84
+ await service.searchScope(actor),
85
+ request.signal,
86
+ );
87
+ const items = await Promise.all(
88
+ result.hits.map(async (hit) => {
89
+ try {
90
+ const item = await service.get(hit.id, actor);
91
+ return item ? { item, score: hit.score } : undefined;
92
+ } catch (error) {
93
+ if (error instanceof DataAccessDeniedError) return undefined;
94
+ throw error;
95
+ }
96
+ }),
97
+ );
98
+ return {
99
+ hits: items.flatMap((entry) =>
100
+ entry
101
+ ? [
102
+ {
103
+ id: entry.item.id,
104
+ resourceType: "work-order",
105
+ maskedTitle: maskSearchText(entry.item.title),
106
+ maskedExcerpt: maskSearchText(entry.item.description, 8),
107
+ ...(entry.score !== undefined ? { score: entry.score } : {}),
108
+ },
109
+ ]
110
+ : [],
111
+ ),
112
+ nextCursor: result.nextCursor,
113
+ };
114
+ }
56
115
  }
57
116
 
58
117
  function parseProviderCursor(value: string | undefined): number {
@@ -6,22 +6,26 @@ import type {
6
6
  } from "@southwind-ai/example-work-order";
7
7
  import { DataScopedResourceService } from "../data-access/scoped-resource.js";
8
8
  import { departmentDescendantResolver } from "../data-access/department-tree.js";
9
+ import { isServerScopeContext } from "../data-access/context.js";
10
+ import { DataAccessDeniedError } from "../data-access/errors.js";
11
+ import { buildDataScopeQuerySpecification } from "../data-access/query-specification.js";
9
12
  import type { RequestActor } from "../guards.js";
10
13
  import type { ManagedDepartment } from "../system/entities.js";
11
14
  import type { EntityRepository } from "../system/repository.js";
12
15
  import type { ScopedEntityRepository } from "../data-access/scoped-repository.js";
16
+ import type { WorkOrderOpenSearchIndex } from "./opensearch-index.js";
13
17
 
14
18
  export class WorkOrderService {
15
19
  private readonly access: DataScopedResourceService<WorkOrder>;
20
+ private readonly descendantIds: (departmentId: string) => Promise<string[]>;
16
21
 
17
22
  constructor(
18
23
  private readonly repository: ScopedEntityRepository<WorkOrder>,
19
24
  departments: EntityRepository<ManagedDepartment>,
25
+ private readonly searchIndex?: WorkOrderOpenSearchIndex,
20
26
  ) {
21
- this.access = new DataScopedResourceService(
22
- repository,
23
- departmentDescendantResolver(departments),
24
- );
27
+ this.descendantIds = departmentDescendantResolver(departments);
28
+ this.access = new DataScopedResourceService(repository, this.descendantIds);
25
29
  }
26
30
 
27
31
  list(query: WorkOrderListQuery, actor: RequestActor) {
@@ -41,8 +45,8 @@ export class WorkOrderService {
41
45
  return this.access.get(id, actor);
42
46
  }
43
47
 
44
- create(input: CreateWorkOrderInput, actor: RequestActor) {
45
- return this.access.create(
48
+ async create(input: CreateWorkOrderInput, actor: RequestActor) {
49
+ const item = await this.access.create(
46
50
  {
47
51
  ...input,
48
52
  workflowStatus: "open",
@@ -52,14 +56,33 @@ export class WorkOrderService {
52
56
  },
53
57
  actor,
54
58
  );
59
+ await this.updateIndex(item);
60
+ return item;
61
+ }
62
+
63
+ async update(id: string, input: UpdateWorkOrderInput, actor: RequestActor) {
64
+ const item = await this.access.update(id, input, actor);
65
+ if (item) await this.updateIndex(item);
66
+ return item;
55
67
  }
56
68
 
57
- update(id: string, input: UpdateWorkOrderInput, actor: RequestActor) {
58
- return this.access.update(id, input, actor);
69
+ async archive(id: string, actor: RequestActor) {
70
+ const item = await this.access.softDelete(id, actor);
71
+ if (item) await this.updateIndex(item);
72
+ return item;
59
73
  }
60
74
 
61
- archive(id: string, actor: RequestActor) {
62
- return this.access.softDelete(id, actor);
75
+ searchIndexSnapshot(actor: RequestActor) {
76
+ return this.access.all({ status: "active" }, [], actor);
77
+ }
78
+
79
+ searchScope(actor: RequestActor) {
80
+ if (!isServerScopeContext(actor.scopeContext)) {
81
+ throw new DataAccessDeniedError();
82
+ }
83
+ return buildDataScopeQuerySpecification(actor.scopeContext, {
84
+ descendants: this.descendantIds,
85
+ });
63
86
  }
64
87
 
65
88
  async operationalSnapshot(
@@ -79,4 +102,13 @@ export class WorkOrderService {
79
102
  ).length,
80
103
  };
81
104
  }
105
+
106
+ private async updateIndex(item: WorkOrder): Promise<void> {
107
+ if (!this.searchIndex) return;
108
+ try {
109
+ await this.searchIndex.upsert(item);
110
+ } catch {
111
+ // OpenSearch 是可重建投影;写入失败不回滚已经提交的业务事实。
112
+ }
113
+ }
82
114
  }
@@ -0,0 +1,9 @@
1
+ import PlatformErrorPage from "@/pages/errors/PlatformErrorPage.vue";
2
+ import { defineErrorPageComponents } from "@/pages/errors/error-page-extension";
3
+
4
+ export const errorPageComponents = defineErrorPageComponents({
5
+ notFound: PlatformErrorPage,
6
+ internal: PlatformErrorPage,
7
+ maintenance: PlatformErrorPage,
8
+ network: PlatformErrorPage,
9
+ });
@@ -1,12 +1,19 @@
1
1
  <script setup lang="ts">
2
2
  import { computed, shallowRef } from "vue";
3
3
  import { KeyRoundIcon, LoaderCircleIcon } from "@lucide/vue";
4
- import { PASSWORD_POLICY } from "@southwind-ai/shared";
4
+ import { PASSWORD_POLICY, type PasswordPolicy } from "@southwind-ai/shared";
5
5
  import { Button } from "@/components/ui/button";
6
6
  import { Input } from "@/components/ui/input";
7
7
  import type { PasswordChangeInput } from "@/services/auth-api";
8
8
 
9
- const props = defineProps<{ error?: string; loading?: boolean }>();
9
+ const props = withDefaults(
10
+ defineProps<{
11
+ error?: string;
12
+ loading?: boolean;
13
+ policy?: PasswordPolicy;
14
+ }>(),
15
+ { policy: () => PASSWORD_POLICY },
16
+ );
10
17
  const emit = defineEmits<{ submit: [input: PasswordChangeInput] }>();
11
18
 
12
19
  const currentPassword = shallowRef("");
@@ -14,11 +21,11 @@ const newPassword = shallowRef("");
14
21
  const confirmation = shallowRef("");
15
22
  const newPasswordError = computed(() => {
16
23
  if (!newPassword.value) return "";
17
- if (newPassword.value.length < PASSWORD_POLICY.minLength) {
18
- return `新密码至少需要 ${PASSWORD_POLICY.minLength} 个字符,当前为 ${newPassword.value.length} 个。`;
24
+ if (newPassword.value.length < props.policy.minLength) {
25
+ return `新密码至少需要 ${props.policy.minLength} 个字符,当前为 ${newPassword.value.length} 个。`;
19
26
  }
20
- if (newPassword.value.length > PASSWORD_POLICY.maxLength) {
21
- return `新密码不能超过 ${PASSWORD_POLICY.maxLength} 个字符。`;
27
+ if (newPassword.value.length > props.policy.maxLength) {
28
+ return `新密码不能超过 ${props.policy.maxLength} 个字符。`;
22
29
  }
23
30
  if (currentPassword.value && newPassword.value === currentPassword.value) {
24
31
  return "新密码不能与当前密码相同。";
@@ -70,8 +77,8 @@ function submit() {
70
77
  v-model="newPassword"
71
78
  type="password"
72
79
  autocomplete="new-password"
73
- :minlength="PASSWORD_POLICY.minLength"
74
- :maxlength="PASSWORD_POLICY.maxLength"
80
+ :minlength="policy.minLength"
81
+ :maxlength="policy.maxLength"
75
82
  :aria-describedby="
76
83
  newPasswordError
77
84
  ? 'new-password-help new-password-error'
@@ -82,9 +89,7 @@ function submit() {
82
89
  required
83
90
  />
84
91
  <p id="new-password-help" class="text-xs text-muted-foreground">
85
- 密码要求:{{ PASSWORD_POLICY.minLength }}-{{
86
- PASSWORD_POLICY.maxLength
87
- }}
92
+ 密码要求:{{ policy.minLength }}-{{ policy.maxLength }}
88
93
  个字符,且不能与当前密码相同。
89
94
  </p>
90
95
  <p
@@ -40,4 +40,18 @@ describe("首次改密表单", () => {
40
40
  ],
41
41
  ]);
42
42
  });
43
+
44
+ test("使用服务端下发的密码长度策略", async () => {
45
+ const wrapper = mount(PasswordChangeForm, {
46
+ props: { policy: { minLength: 16, maxLength: 32 } },
47
+ });
48
+
49
+ await wrapper.get("#current-password").setValue("initial-password");
50
+ await wrapper.get("#new-password").setValue("twelve-chars");
51
+
52
+ expect(wrapper.get("#new-password-help").text()).toContain("16-32");
53
+ expect(wrapper.get("[role='alert']").text()).toContain(
54
+ "至少需要 16 个字符",
55
+ );
56
+ });
43
57
  });
@@ -24,6 +24,7 @@ const props = withDefaults(
24
24
  submitDisabled?: boolean;
25
25
  contentClass?: string;
26
26
  preventOutsideClose?: boolean;
27
+ beforeClose?: () => boolean | Promise<boolean>;
27
28
  }>(),
28
29
  {
29
30
  title: "编辑信息",
@@ -41,7 +42,8 @@ const emit = defineEmits<{
41
42
  cancel: [];
42
43
  }>();
43
44
 
44
- function handleCancel() {
45
+ async function handleCancel() {
46
+ if (!(await canClose())) return;
45
47
  emit("cancel");
46
48
  open.value = false;
47
49
  }
@@ -49,10 +51,22 @@ function handleCancel() {
49
51
  function handleInteractOutside(event: { preventDefault(): void }) {
50
52
  if (props.preventOutsideClose) event.preventDefault();
51
53
  }
54
+
55
+ async function handleOpenChange(value: boolean) {
56
+ if (value) {
57
+ open.value = true;
58
+ return;
59
+ }
60
+ if (await canClose()) open.value = false;
61
+ }
62
+
63
+ async function canClose() {
64
+ return props.beforeClose ? await props.beforeClose() : true;
65
+ }
52
66
  </script>
53
67
 
54
68
  <template>
55
- <Dialog v-model:open="open">
69
+ <Dialog :open="open" @update:open="handleOpenChange">
56
70
  <DialogContent
57
71
  :class="contentClass"
58
72
  @interact-outside="handleInteractOutside"
@@ -1,7 +1,7 @@
1
1
  import { afterEach, describe, expect, test, vi } from "vitest";
2
2
  import { flushPromises, mount } from "@vue/test-utils";
3
3
  import FormDialog from "./FormDialog.vue";
4
- import { DialogContent } from "@/components/ui/dialog";
4
+ import { Dialog, DialogContent } from "@/components/ui/dialog";
5
5
 
6
6
  afterEach(() => {
7
7
  document.body.innerHTML = "";
@@ -51,4 +51,23 @@ describe("Modal 布局", () => {
51
51
 
52
52
  expect(preventDefault).toHaveBeenCalledTimes(1);
53
53
  });
54
+
55
+ test("关闭前置检查拒绝时保持打开,通过后才通知父组件关闭", async () => {
56
+ const beforeClose = vi
57
+ .fn<() => boolean | Promise<boolean>>()
58
+ .mockReturnValueOnce(false)
59
+ .mockReturnValueOnce(true);
60
+ const wrapper = mount(FormDialog, {
61
+ props: { open: true, beforeClose },
62
+ });
63
+
64
+ wrapper.getComponent(Dialog).vm.$emit("update:open", false);
65
+ await flushPromises();
66
+ expect(wrapper.emitted("update:open")).toBeUndefined();
67
+
68
+ wrapper.getComponent(Dialog).vm.$emit("update:open", false);
69
+ await flushPromises();
70
+ expect(wrapper.emitted("update:open")).toEqual([[false]]);
71
+ expect(beforeClose).toHaveBeenCalledTimes(2);
72
+ });
54
73
  });