@allbluecn/admin-app 0.1.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 (276) hide show
  1. package/package.json +86 -0
  2. package/src/client.tsx +31 -0
  3. package/src/components/admin/admin-user-menu.tsx +90 -0
  4. package/src/components/admin/developer-log.tsx +356 -0
  5. package/src/components/ai-provider/model-list-editor.tsx +147 -0
  6. package/src/components/ai-provider/provider-form.tsx +434 -0
  7. package/src/components/ai-provider/provider-icon.tsx +43 -0
  8. package/src/components/ai-provider/provider-logo.tsx +87 -0
  9. package/src/components/ai-provider/provider-table.tsx +258 -0
  10. package/src/components/auth/admin-login-form.tsx +214 -0
  11. package/src/components/auth/auth-brand-panel.tsx +60 -0
  12. package/src/components/auth/auth-header.tsx +38 -0
  13. package/src/components/auth/auth-layout.tsx +66 -0
  14. package/src/components/auth/auth-providers.tsx +34 -0
  15. package/src/components/auth/icon-input.tsx +46 -0
  16. package/src/components/auth/oauth-section.tsx +51 -0
  17. package/src/components/auth/password-input.tsx +67 -0
  18. package/src/components/common/page-header.tsx +42 -0
  19. package/src/components/common/placeholder-page.tsx +42 -0
  20. package/src/components/component/component-form-dialog.tsx +348 -0
  21. package/src/components/component/component-import-dialog.tsx +235 -0
  22. package/src/components/component/component-table.tsx +153 -0
  23. package/src/components/data-table/data-table-pagination.tsx +114 -0
  24. package/src/components/data-table/data-table.tsx +218 -0
  25. package/src/components/data-table/table-detail-layout.tsx +99 -0
  26. package/src/components/dependency/changelog-dialog.tsx +108 -0
  27. package/src/components/dependency/columns.tsx +232 -0
  28. package/src/components/dependency/dependency-table.tsx +368 -0
  29. package/src/components/dependency/edit-dialog.tsx +200 -0
  30. package/src/components/knowledge-public/bookmark-form-dialog.tsx +215 -0
  31. package/src/components/knowledge-public/compliance-form-dialog.tsx +308 -0
  32. package/src/components/knowledge-public/legal-form-dialog.tsx +315 -0
  33. package/src/components/layout/sidebar-02/app-sidebar.tsx +160 -0
  34. package/src/components/layout/sidebar-02/index.tsx +33 -0
  35. package/src/components/layout/sidebar-02/logo.tsx +40 -0
  36. package/src/components/layout/sidebar-02/nav-main.tsx +153 -0
  37. package/src/components/layout/sidebar-02/nav-notifications.tsx +78 -0
  38. package/src/components/layout/sidebar-02/team-switcher.tsx +106 -0
  39. package/src/components/magicui/animated-theme-toggler.tsx +272 -0
  40. package/src/components/magicui/rainbow-button.tsx +78 -0
  41. package/src/components/market/columns.tsx +151 -0
  42. package/src/components/market/market-app-form.tsx +237 -0
  43. package/src/components/market/market-app-list.tsx +156 -0
  44. package/src/components/monitoring/error-list.tsx +143 -0
  45. package/src/components/monitoring/health-cards.tsx +152 -0
  46. package/src/components/monitoring/metrics-stats.tsx +184 -0
  47. package/src/components/monitoring/request-log-table.tsx +179 -0
  48. package/src/components/providers/theme-toggle.tsx +59 -0
  49. package/src/components/settings/avatar/avatar-config.ts +46 -0
  50. package/src/components/ui/alert-dialog.tsx +216 -0
  51. package/src/components/ui/alert.tsx +76 -0
  52. package/src/components/ui/avatar.tsx +129 -0
  53. package/src/components/ui/badge.tsx +66 -0
  54. package/src/components/ui/button.tsx +84 -0
  55. package/src/components/ui/card.tsx +120 -0
  56. package/src/components/ui/checkbox.tsx +50 -0
  57. package/src/components/ui/collapsible.tsx +50 -0
  58. package/src/components/ui/dialog.tsx +185 -0
  59. package/src/components/ui/dropdown-menu.tsx +286 -0
  60. package/src/components/ui/field.tsx +255 -0
  61. package/src/components/ui/input-group.tsx +171 -0
  62. package/src/components/ui/input.tsx +36 -0
  63. package/src/components/ui/label.tsx +41 -0
  64. package/src/components/ui/pagination.tsx +146 -0
  65. package/src/components/ui/select.tsx +209 -0
  66. package/src/components/ui/separator.tsx +45 -0
  67. package/src/components/ui/sheet.tsx +164 -0
  68. package/src/components/ui/sidebar.tsx +719 -0
  69. package/src/components/ui/skeleton.tsx +30 -0
  70. package/src/components/ui/sonner.tsx +37 -0
  71. package/src/components/ui/switch.tsx +48 -0
  72. package/src/components/ui/table.tsx +133 -0
  73. package/src/components/ui/textarea.tsx +35 -0
  74. package/src/components/ui/tooltip.tsx +74 -0
  75. package/src/components/user/columns.tsx +153 -0
  76. package/src/components/user/settings-activity-log.tsx +358 -0
  77. package/src/components/user/settings-profile/constants.ts +42 -0
  78. package/src/components/user/settings-profile/types.ts +47 -0
  79. package/src/components/user/settings-profile.tsx +664 -0
  80. package/src/components/user/user-create-dialog.tsx +137 -0
  81. package/src/components/user/user-filters.tsx +117 -0
  82. package/src/hooks/use-mobile.ts +36 -0
  83. package/src/instrument.client.ts +34 -0
  84. package/src/lib/anyhub/capabilities.ts +59 -0
  85. package/src/lib/anyhub/types.ts +43 -0
  86. package/src/lib/auth-client.ts +83 -0
  87. package/src/lib/auth-utils.ts +24 -0
  88. package/src/lib/auth.config.ts +264 -0
  89. package/src/lib/avatar.ts +41 -0
  90. package/src/lib/i18n/client.ts +32 -0
  91. package/src/lib/i18n/config.ts +40 -0
  92. package/src/lib/i18n/detect.ts +43 -0
  93. package/src/lib/i18n/locales/en/admin-ai.json +128 -0
  94. package/src/lib/i18n/locales/en/admin-billing.json +56 -0
  95. package/src/lib/i18n/locales/en/admin-users.json +88 -0
  96. package/src/lib/i18n/locales/en/common.json +877 -0
  97. package/src/lib/i18n/locales/en/enterprise.json +208 -0
  98. package/src/lib/i18n/locales/zh/admin-ai.json +128 -0
  99. package/src/lib/i18n/locales/zh/admin-billing.json +56 -0
  100. package/src/lib/i18n/locales/zh/admin-users.json +88 -0
  101. package/src/lib/i18n/locales/zh/common.json +877 -0
  102. package/src/lib/i18n/locales/zh/enterprise.json +208 -0
  103. package/src/lib/i18n/server.ts +18 -0
  104. package/src/lib/i18n/types.ts +5 -0
  105. package/src/lib/monitoring-benchmarks.ts +85 -0
  106. package/src/lib/monitoring-descriptions.ts +78 -0
  107. package/src/lib/prisma.ts +18 -0
  108. package/src/lib/router-context.ts +38 -0
  109. package/src/lib/session.ts +38 -0
  110. package/src/lib/utils.ts +23 -0
  111. package/src/providers/index.tsx +45 -0
  112. package/src/routeTree.gen.ts +1199 -0
  113. package/src/router.tsx +51 -0
  114. package/src/routes/__root.tsx +170 -0
  115. package/src/routes/_nav/ai-providers/$id/edit/route.lazy.tsx +74 -0
  116. package/src/routes/_nav/ai-providers/$id/edit/route.tsx +35 -0
  117. package/src/routes/_nav/ai-providers/index/route.lazy.tsx +33 -0
  118. package/src/routes/_nav/ai-providers/new/route.lazy.tsx +31 -0
  119. package/src/routes/_nav/ai-providers/new/route.tsx +20 -0
  120. package/src/routes/_nav/ai-providers/route.lazy.tsx +26 -0
  121. package/src/routes/_nav/ai-providers/route.tsx +31 -0
  122. package/src/routes/_nav/apps/route.lazy.tsx +30 -0
  123. package/src/routes/_nav/apps/route.tsx +20 -0
  124. package/src/routes/_nav/audit/route.lazy.tsx +100 -0
  125. package/src/routes/_nav/audit/route.tsx +25 -0
  126. package/src/routes/_nav/billing/invoices/route.lazy.tsx +86 -0
  127. package/src/routes/_nav/billing/invoices/route.tsx +25 -0
  128. package/src/routes/_nav/billing/route.lazy.tsx +48 -0
  129. package/src/routes/_nav/billing/route.tsx +27 -0
  130. package/src/routes/_nav/billing/subscriptions/$subscriptionId/route.lazy.tsx +160 -0
  131. package/src/routes/_nav/billing/subscriptions/$subscriptionId/route.tsx +25 -0
  132. package/src/routes/_nav/billing/subscriptions/route.lazy.tsx +113 -0
  133. package/src/routes/_nav/billing/subscriptions/route.tsx +28 -0
  134. package/src/routes/_nav/compliance/route.lazy.tsx +184 -0
  135. package/src/routes/_nav/compliance/route.tsx +25 -0
  136. package/src/routes/_nav/components/route.lazy.tsx +184 -0
  137. package/src/routes/_nav/components/route.tsx +40 -0
  138. package/src/routes/_nav/dashboard/route.lazy.tsx +33 -0
  139. package/src/routes/_nav/dashboard/route.tsx +20 -0
  140. package/src/routes/_nav/dependencies/route.lazy.tsx +66 -0
  141. package/src/routes/_nav/dependencies/route.tsx +32 -0
  142. package/src/routes/_nav/developer-logs/route.lazy.tsx +6 -0
  143. package/src/routes/_nav/developer-logs/route.tsx +14 -0
  144. package/src/routes/_nav/docs/route.lazy.tsx +30 -0
  145. package/src/routes/_nav/docs/route.tsx +20 -0
  146. package/src/routes/_nav/knowledge-public/bookmarks/route.lazy.tsx +355 -0
  147. package/src/routes/_nav/knowledge-public/bookmarks/route.tsx +40 -0
  148. package/src/routes/_nav/knowledge-public/compliance/route.lazy.tsx +245 -0
  149. package/src/routes/_nav/knowledge-public/compliance/route.tsx +40 -0
  150. package/src/routes/_nav/knowledge-public/legal/route.lazy.tsx +242 -0
  151. package/src/routes/_nav/knowledge-public/legal/route.tsx +40 -0
  152. package/src/routes/_nav/licenses/$licenseId/route.lazy.tsx +235 -0
  153. package/src/routes/_nav/licenses/$licenseId/route.tsx +25 -0
  154. package/src/routes/_nav/licenses/new/route.lazy.tsx +237 -0
  155. package/src/routes/_nav/licenses/route.lazy.tsx +143 -0
  156. package/src/routes/_nav/licenses/route.tsx +25 -0
  157. package/src/routes/_nav/market/route.lazy.tsx +31 -0
  158. package/src/routes/_nav/market/route.tsx +20 -0
  159. package/src/routes/_nav/monitoring/ai-usage/route.lazy.tsx +305 -0
  160. package/src/routes/_nav/monitoring/ai-usage/route.tsx +3 -0
  161. package/src/routes/_nav/monitoring/errors/route.lazy.tsx +111 -0
  162. package/src/routes/_nav/monitoring/health/route.lazy.tsx +260 -0
  163. package/src/routes/_nav/monitoring/logs/route.lazy.tsx +165 -0
  164. package/src/routes/_nav/monitoring/metrics/route.lazy.tsx +240 -0
  165. package/src/routes/_nav/monitoring/overview/route.lazy.tsx +138 -0
  166. package/src/routes/_nav/monitoring/route.lazy.tsx +68 -0
  167. package/src/routes/_nav/monitoring/route.tsx +20 -0
  168. package/src/routes/_nav/monitoring/sentry/route.lazy.tsx +399 -0
  169. package/src/routes/_nav/monitoring/sentry/route.tsx +20 -0
  170. package/src/routes/_nav/permissions/route.lazy.tsx +504 -0
  171. package/src/routes/_nav/permissions/route.tsx +26 -0
  172. package/src/routes/_nav/route.lazy.tsx +31 -0
  173. package/src/routes/_nav/route.tsx +34 -0
  174. package/src/routes/_nav/settings/route.lazy.tsx +30 -0
  175. package/src/routes/_nav/settings/route.tsx +20 -0
  176. package/src/routes/_nav/sso/route.lazy.tsx +145 -0
  177. package/src/routes/_nav/sso/route.tsx +25 -0
  178. package/src/routes/_nav/users/$userId/route.lazy.tsx +432 -0
  179. package/src/routes/_nav/users/$userId/route.tsx +27 -0
  180. package/src/routes/_nav/users/-components/users-table.tsx +38 -0
  181. package/src/routes/_nav/users/route.lazy.tsx +74 -0
  182. package/src/routes/_nav/users/route.tsx +43 -0
  183. package/src/routes/api/compliance-download/$jobId.ts +35 -0
  184. package/src/routes/api/observability/errors.ts +41 -0
  185. package/src/routes/api/observability/health.ts +30 -0
  186. package/src/routes/api.auth.$.ts +31 -0
  187. package/src/routes/api.sentry-test.ts +33 -0
  188. package/src/routes/index/route.lazy.tsx +22 -0
  189. package/src/routes/index/route.tsx +24 -0
  190. package/src/routes/login/route.lazy.tsx +46 -0
  191. package/src/routes/login/route.tsx +28 -0
  192. package/src/routes/sentry-tunnel.ts +61 -0
  193. package/src/routes/setup/route.lazy.tsx +94 -0
  194. package/src/routes/setup/route.tsx +28 -0
  195. package/src/server/__tests__/admin-ai-alerts.test.ts +113 -0
  196. package/src/server/__tests__/licenses-issue.test.ts +98 -0
  197. package/src/server/admin-ai-alerts.ts +177 -0
  198. package/src/server/admin-ai-usage.ts +134 -0
  199. package/src/server/auth-helpers.ts +51 -0
  200. package/src/server/entitlements.ts +24 -0
  201. package/src/server/knowledge-public/canonical-id.ts +55 -0
  202. package/src/server/knowledge-public/github-sync.ts +86 -0
  203. package/src/server/license-issue.ts +78 -0
  204. package/src/server/middlewares/request.ts +26 -0
  205. package/src/server/observability/debug-middleware.ts +39 -0
  206. package/src/server/observability/error-reporter.ts +99 -0
  207. package/src/server/observability/health-check.ts +140 -0
  208. package/src/server/observability/index.ts +33 -0
  209. package/src/server/observability/logger.ts +49 -0
  210. package/src/server/observability/metrics.ts +105 -0
  211. package/src/server/observability/request-middleware.ts +125 -0
  212. package/src/server/observability/timing.ts +42 -0
  213. package/src/server/pro-services.ts +32 -0
  214. package/src/server/serverFns/admin-billing.ts +94 -0
  215. package/src/server/serverFns/ai-provider/create.ts +92 -0
  216. package/src/server/serverFns/ai-provider/delete.ts +42 -0
  217. package/src/server/serverFns/ai-provider/get.ts +35 -0
  218. package/src/server/serverFns/ai-provider/list.ts +92 -0
  219. package/src/server/serverFns/ai-provider/update.ts +73 -0
  220. package/src/server/serverFns/anyhub/market.ts +64 -0
  221. package/src/server/serverFns/audit.ts +57 -0
  222. package/src/server/serverFns/auth/session.ts +26 -0
  223. package/src/server/serverFns/auth/setup.ts +73 -0
  224. package/src/server/serverFns/compliance.ts +85 -0
  225. package/src/server/serverFns/components/import.ts +197 -0
  226. package/src/server/serverFns/components/list.ts +51 -0
  227. package/src/server/serverFns/components/mutations.ts +84 -0
  228. package/src/server/serverFns/dependency/helpers.ts +245 -0
  229. package/src/server/serverFns/dependency/list.ts +127 -0
  230. package/src/server/serverFns/dependency/sync.ts +123 -0
  231. package/src/server/serverFns/dependency/update.ts +51 -0
  232. package/src/server/serverFns/developer-log-auto-generate.ts +176 -0
  233. package/src/server/serverFns/developer-logs.ts +132 -0
  234. package/src/server/serverFns/knowledge-public/bookmarks/create.ts +53 -0
  235. package/src/server/serverFns/knowledge-public/bookmarks/delete.ts +31 -0
  236. package/src/server/serverFns/knowledge-public/bookmarks/detail.ts +32 -0
  237. package/src/server/serverFns/knowledge-public/bookmarks/list.ts +66 -0
  238. package/src/server/serverFns/knowledge-public/bookmarks/sync-all-github.ts +56 -0
  239. package/src/server/serverFns/knowledge-public/bookmarks/update.ts +50 -0
  240. package/src/server/serverFns/knowledge-public/compliance/create.ts +58 -0
  241. package/src/server/serverFns/knowledge-public/compliance/delete.ts +31 -0
  242. package/src/server/serverFns/knowledge-public/compliance/detail.ts +32 -0
  243. package/src/server/serverFns/knowledge-public/compliance/list.ts +59 -0
  244. package/src/server/serverFns/knowledge-public/compliance/update.ts +53 -0
  245. package/src/server/serverFns/knowledge-public/legal/create.ts +62 -0
  246. package/src/server/serverFns/knowledge-public/legal/delete.ts +31 -0
  247. package/src/server/serverFns/knowledge-public/legal/detail.ts +32 -0
  248. package/src/server/serverFns/knowledge-public/legal/list.ts +60 -0
  249. package/src/server/serverFns/knowledge-public/legal/update.ts +55 -0
  250. package/src/server/serverFns/licenses.ts +92 -0
  251. package/src/server/serverFns/locale.ts +10 -0
  252. package/src/server/serverFns/monitoring/check-ai-alerts.ts +13 -0
  253. package/src/server/serverFns/monitoring/get-ai-usage-overview.ts +25 -0
  254. package/src/server/serverFns/monitoring/get-errors.ts +43 -0
  255. package/src/server/serverFns/monitoring/get-health-settings.ts +35 -0
  256. package/src/server/serverFns/monitoring/get-health.ts +55 -0
  257. package/src/server/serverFns/monitoring/get-metrics.ts +46 -0
  258. package/src/server/serverFns/monitoring/get-request-logs.ts +66 -0
  259. package/src/server/serverFns/monitoring/report-error.ts +55 -0
  260. package/src/server/serverFns/monitoring/reset.ts +70 -0
  261. package/src/server/serverFns/permissions.ts +214 -0
  262. package/src/server/serverFns/sentry/config.ts +88 -0
  263. package/src/server/serverFns/sso-config.ts +83 -0
  264. package/src/server/serverFns/user/create.ts +40 -0
  265. package/src/server/serverFns/user/delete.ts +29 -0
  266. package/src/server/serverFns/user/detail.ts +45 -0
  267. package/src/server/serverFns/user/list.ts +56 -0
  268. package/src/server/serverFns/user/reset-password.ts +28 -0
  269. package/src/server/serverFns/user/update-role.ts +26 -0
  270. package/src/server/serverFns/user/update-status.ts +26 -0
  271. package/src/server.ts +27 -0
  272. package/src/start.ts +85 -0
  273. package/src/styles/_keyframe-animations.scss +91 -0
  274. package/src/styles/_variables.scss +296 -0
  275. package/src/styles/globals.css +200 -0
  276. package/src/types/start-authjs.d.ts +27 -0
@@ -0,0 +1,308 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // AllBlue - 理想之海
3
+ // Copyright (C) 2026 AllBlue Contributors
4
+ //
5
+ // This program is free software: you can redistribute it and/or modify
6
+ // it under the terms of the GNU Affero General Public License as published by
7
+ // the Free Software Foundation, either version 3 of the License, or
8
+ // (at your option) any later version.
9
+ //
10
+ // This program is distributed in the hope that it will be useful,
11
+ // but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ // GNU Affero General Public License for more details.
14
+ //
15
+ // You should have received a copy of the GNU Affero General Public License
16
+ // along with this program. If not, see <https://www.gnu.org/licenses/>.
17
+
18
+ import { useState, useEffect } from 'react'
19
+ import { Loader2, PlusIcon, Trash2Icon } from 'lucide-react'
20
+ import { toast } from '@/components/ui/sonner'
21
+ import { Button } from '@/components/ui/button'
22
+ import { Input } from '@/components/ui/input'
23
+ import { Label } from '@/components/ui/label'
24
+ import { Textarea } from '@/components/ui/textarea'
25
+ import {
26
+ Dialog,
27
+ DialogContent,
28
+ DialogFooter,
29
+ DialogHeader,
30
+ DialogTitle,
31
+ } from '@/components/ui/dialog'
32
+ import { adminCreateComplianceFn } from '@/server/serverFns/knowledge-public/compliance/create'
33
+ import { adminUpdateComplianceFn } from '@/server/serverFns/knowledge-public/compliance/update'
34
+ import { adminGetComplianceDetailFn } from '@/server/serverFns/knowledge-public/compliance/detail'
35
+
36
+ export interface ComplianceRow {
37
+ id: string
38
+ canonicalId: string
39
+ title: string
40
+ industry: string
41
+ regulatoryBody: string | null
42
+ region: string | null
43
+ summary: string | null
44
+ requirements: Array<{ title: string; detail: string }> | null
45
+ sourceUrl: string | null
46
+ tags: string[]
47
+ visibility: string
48
+ userId: string
49
+ createdAt: Date
50
+ }
51
+
52
+ interface ComplianceFormDialogProps {
53
+ open: boolean
54
+ onOpenChange: (open: boolean) => void
55
+ item?: ComplianceRow | null
56
+ onSuccess: () => void
57
+ }
58
+
59
+ const EMPTY = {
60
+ title: '',
61
+ code: '',
62
+ industry: '',
63
+ regulatoryBody: '',
64
+ region: '',
65
+ summary: '',
66
+ content: '',
67
+ sourceUrl: '',
68
+ tags: '',
69
+ }
70
+
71
+ export function ComplianceFormDialog({ open, onOpenChange, item, onSuccess }: ComplianceFormDialogProps) {
72
+ const isEdit = !!item
73
+ const [form, setForm] = useState(EMPTY)
74
+ const [requirements, setRequirements] = useState<Array<{ title: string; detail: string }>>([])
75
+ const [saving, setSaving] = useState(false)
76
+ const [loading, setLoading] = useState(false)
77
+
78
+ useEffect(() => {
79
+ if (!open) return
80
+ if (item) {
81
+ setLoading(true)
82
+ adminGetComplianceDetailFn({ data: { id: item.id } })
83
+ .then((d) => {
84
+ setForm({
85
+ title: d.title ?? '',
86
+ code: '',
87
+ industry: d.industry ?? '',
88
+ regulatoryBody: d.regulatoryBody ?? '',
89
+ region: d.region ?? '',
90
+ summary: d.summary ?? '',
91
+ content: d.content ?? '',
92
+ sourceUrl: d.sourceUrl ?? '',
93
+ tags: ((d.tags as string[]) ?? []).join(', '),
94
+ })
95
+ setRequirements(
96
+ Array.isArray(d.requirements)
97
+ ? (d.requirements as Array<{ title: string; detail: string }>)
98
+ : [],
99
+ )
100
+ })
101
+ .catch((e) => toast.error(e instanceof Error ? e.message : '加载失败'))
102
+ .finally(() => setLoading(false))
103
+ } else {
104
+ setForm(EMPTY)
105
+ setRequirements([])
106
+ }
107
+ }, [open, item])
108
+
109
+ function updateReq(i: number, key: 'title' | 'detail', val: string) {
110
+ setRequirements((rs) => rs.map((r, idx) => (idx === i ? { ...r, [key]: val } : r)))
111
+ }
112
+ function addReq() {
113
+ setRequirements((rs) => [...rs, { title: '', detail: '' }])
114
+ }
115
+ function removeReq(i: number) {
116
+ setRequirements((rs) => rs.filter((_, idx) => idx !== i))
117
+ }
118
+
119
+ async function handleSubmit(e: React.FormEvent) {
120
+ e.preventDefault()
121
+ if (!form.title.trim()) {
122
+ toast.error('请输入标题')
123
+ return
124
+ }
125
+ if (!form.industry.trim()) {
126
+ toast.error('请输入所属行业')
127
+ return
128
+ }
129
+ setSaving(true)
130
+ try {
131
+ const payload = {
132
+ title: form.title.trim(),
133
+ code: form.code.trim() || undefined,
134
+ industry: form.industry.trim(),
135
+ regulatoryBody: form.regulatoryBody.trim() || undefined,
136
+ region: form.region.trim() || undefined,
137
+ summary: form.summary.trim() || undefined,
138
+ content: form.content.trim() || undefined,
139
+ requirements: requirements.filter((r) => r.title.trim() || r.detail.trim()),
140
+ sourceUrl: form.sourceUrl.trim() || '',
141
+ tags: form.tags.split(',').map((t) => t.trim()).filter(Boolean),
142
+ }
143
+ if (isEdit && item) {
144
+ await adminUpdateComplianceFn({ data: { id: item.id, ...payload } })
145
+ toast.success('合规要求已更新')
146
+ } else {
147
+ await adminCreateComplianceFn({ data: payload })
148
+ toast.success('合规要求已创建')
149
+ }
150
+ onSuccess()
151
+ onOpenChange(false)
152
+ } catch (err) {
153
+ toast.error(err instanceof Error ? err.message : '保存失败')
154
+ } finally {
155
+ setSaving(false)
156
+ }
157
+ }
158
+
159
+ return (
160
+ <Dialog open={open} onOpenChange={onOpenChange}>
161
+ <DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
162
+ <DialogHeader>
163
+ <DialogTitle>{isEdit ? '编辑合规要求' : '新建合规要求'}</DialogTitle>
164
+ </DialogHeader>
165
+ {loading ? (
166
+ <div className="flex h-40 items-center justify-center">
167
+ <Loader2 className="size-6 animate-spin text-muted-foreground" />
168
+ </div>
169
+ ) : (
170
+ <form onSubmit={handleSubmit}>
171
+ <div className="grid gap-4 py-1">
172
+ <div className="grid gap-2">
173
+ <Label>标题 *</Label>
174
+ <Input
175
+ value={form.title}
176
+ onChange={(e) => setForm((f) => ({ ...f, title: e.target.value }))}
177
+ placeholder="如:支付卡行业数据安全标准"
178
+ />
179
+ </div>
180
+ <div className="grid grid-cols-3 gap-4">
181
+ <div className="grid gap-2">
182
+ <Label>发文字号</Label>
183
+ <Input
184
+ value={form.code}
185
+ onChange={(e) => setForm((f) => ({ ...f, code: e.target.value }))}
186
+ placeholder="如:PCI DSS 4.0"
187
+ />
188
+ </div>
189
+ <div className="grid gap-2">
190
+ <Label>所属行业 *</Label>
191
+ <Input
192
+ value={form.industry}
193
+ onChange={(e) => setForm((f) => ({ ...f, industry: e.target.value }))}
194
+ placeholder="如:金融"
195
+ />
196
+ </div>
197
+ <div className="grid gap-2">
198
+ <Label>地区</Label>
199
+ <Input
200
+ value={form.region}
201
+ onChange={(e) => setForm((f) => ({ ...f, region: e.target.value }))}
202
+ placeholder="如:全球"
203
+ />
204
+ </div>
205
+ </div>
206
+ <div className="grid grid-cols-2 gap-4">
207
+ <div className="grid gap-2">
208
+ <Label>监管机构</Label>
209
+ <Input
210
+ value={form.regulatoryBody}
211
+ onChange={(e) => setForm((f) => ({ ...f, regulatoryBody: e.target.value }))}
212
+ placeholder="如:PCI SSC"
213
+ />
214
+ </div>
215
+ <div className="grid gap-2">
216
+ <Label>来源链接</Label>
217
+ <Input
218
+ value={form.sourceUrl}
219
+ onChange={(e) => setForm((f) => ({ ...f, sourceUrl: e.target.value }))}
220
+ placeholder="https://"
221
+ />
222
+ </div>
223
+ </div>
224
+ <div className="grid gap-2">
225
+ <Label>摘要</Label>
226
+ <Textarea
227
+ value={form.summary}
228
+ onChange={(e) => setForm((f) => ({ ...f, summary: e.target.value }))}
229
+ rows={2}
230
+ placeholder="一句话简介"
231
+ />
232
+ </div>
233
+
234
+ <div className="grid gap-2">
235
+ <div className="flex items-center justify-between">
236
+ <Label>合规要求条目</Label>
237
+ <Button type="button" variant="outline" size="sm" onClick={addReq}>
238
+ <PlusIcon className="mr-1 size-3.5" />
239
+ 添加条目
240
+ </Button>
241
+ </div>
242
+ {requirements.length === 0 ? (
243
+ <p className="text-sm text-muted-foreground">暂无条目,点击「添加条目」。</p>
244
+ ) : (
245
+ <div className="space-y-2">
246
+ {requirements.map((r, i) => (
247
+ <div key={i} className="flex items-start gap-2">
248
+ <div className="grid flex-1 gap-1.5">
249
+ <Input
250
+ value={r.title}
251
+ onChange={(e) => updateReq(i, 'title', e.target.value)}
252
+ placeholder="要求标题"
253
+ />
254
+ <Textarea
255
+ value={r.detail}
256
+ onChange={(e) => updateReq(i, 'detail', e.target.value)}
257
+ rows={2}
258
+ placeholder="具体说明"
259
+ />
260
+ </div>
261
+ <Button
262
+ type="button"
263
+ variant="ghost"
264
+ size="icon"
265
+ onClick={() => removeReq(i)}
266
+ className="mt-1 shrink-0 text-muted-foreground hover:text-destructive"
267
+ >
268
+ <Trash2Icon className="size-4" />
269
+ </Button>
270
+ </div>
271
+ ))}
272
+ </div>
273
+ )}
274
+ </div>
275
+
276
+ <div className="grid gap-2">
277
+ <Label>正文内容</Label>
278
+ <Textarea
279
+ value={form.content}
280
+ onChange={(e) => setForm((f) => ({ ...f, content: e.target.value }))}
281
+ rows={6}
282
+ placeholder="补充说明内容"
283
+ className="font-mono text-sm"
284
+ />
285
+ </div>
286
+ <div className="grid gap-2">
287
+ <Label>标签(逗号分隔)</Label>
288
+ <Input
289
+ value={form.tags}
290
+ onChange={(e) => setForm((f) => ({ ...f, tags: e.target.value }))}
291
+ placeholder="如:金融, 安全"
292
+ />
293
+ </div>
294
+ </div>
295
+ <DialogFooter>
296
+ <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
297
+ 取消
298
+ </Button>
299
+ <Button type="submit" disabled={saving}>
300
+ {saving ? '保存中...' : '保存'}
301
+ </Button>
302
+ </DialogFooter>
303
+ </form>
304
+ )}
305
+ </DialogContent>
306
+ </Dialog>
307
+ )
308
+ }
@@ -0,0 +1,315 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // AllBlue - 理想之海
3
+ // Copyright (C) 2026 AllBlue Contributors
4
+ //
5
+ // This program is free software: you can redistribute it and/or modify
6
+ // it under the terms of the GNU Affero General Public License as published by
7
+ // the Free Software Foundation, either version 3 of the License, or
8
+ // (at your option) any later version.
9
+ //
10
+ // This program is distributed in the hope that it will be useful,
11
+ // but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ // GNU Affero General Public License for more details.
14
+ //
15
+ // You should have received a copy of the GNU Affero General Public License
16
+ // along with this program. If not, see <https://www.gnu.org/licenses/>.
17
+
18
+ import { useState, useEffect } from 'react'
19
+ import { useTranslation } from 'react-i18next'
20
+ import { Loader2 } from 'lucide-react'
21
+ import { toast } from '@/components/ui/sonner'
22
+ import { Button } from '@/components/ui/button'
23
+ import { Input } from '@/components/ui/input'
24
+ import { Label } from '@/components/ui/label'
25
+ import { Textarea } from '@/components/ui/textarea'
26
+ import {
27
+ Select,
28
+ SelectContent,
29
+ SelectItem,
30
+ SelectTrigger,
31
+ SelectValue,
32
+ } from '@/components/ui/select'
33
+ import {
34
+ Dialog,
35
+ DialogContent,
36
+ DialogFooter,
37
+ DialogHeader,
38
+ DialogTitle,
39
+ } from '@/components/ui/dialog'
40
+ import { adminCreateLegalFn } from '@/server/serverFns/knowledge-public/legal/create'
41
+ import { adminUpdateLegalFn } from '@/server/serverFns/knowledge-public/legal/update'
42
+ import { adminGetLegalDetailFn } from '@/server/serverFns/knowledge-public/legal/detail'
43
+
44
+ export interface LegalRow {
45
+ id: string
46
+ canonicalId: string
47
+ title: string
48
+ jurisdiction: string
49
+ region: string | null
50
+ country: string | null
51
+ category: string
52
+ summary: string | null
53
+ issuingAuthority: string | null
54
+ effectiveDate: Date | null
55
+ sourceUrl: string | null
56
+ tags: string[]
57
+ visibility: string
58
+ userId: string
59
+ createdAt: Date
60
+ }
61
+
62
+ const JURISDICTIONS: Array<{ value: 'CN_NATIONAL' | 'CN_LOCAL' | 'OVERSEAS' }> = [
63
+ { value: 'CN_NATIONAL' },
64
+ { value: 'CN_LOCAL' },
65
+ { value: 'OVERSEAS' },
66
+ ]
67
+
68
+ interface LegalFormDialogProps {
69
+ open: boolean
70
+ onOpenChange: (open: boolean) => void
71
+ item?: LegalRow | null
72
+ onSuccess: () => void
73
+ }
74
+
75
+ const EMPTY = {
76
+ title: '',
77
+ code: '',
78
+ jurisdiction: 'CN_NATIONAL',
79
+ region: '',
80
+ country: '',
81
+ category: '',
82
+ issuingAuthority: '',
83
+ effectiveDate: '',
84
+ summary: '',
85
+ content: '',
86
+ sourceUrl: '',
87
+ tags: '',
88
+ }
89
+
90
+ const JURISDICTION_KEYS = ['CN_NATIONAL', 'CN_LOCAL', 'OVERSEAS'] as const
91
+
92
+ export function LegalFormDialog({ open, onOpenChange, item, onSuccess }: LegalFormDialogProps) {
93
+ const { t } = useTranslation('common')
94
+ const isEdit = !!item
95
+ const [form, setForm] = useState(EMPTY)
96
+ const [saving, setSaving] = useState(false)
97
+ const [loading, setLoading] = useState(false)
98
+
99
+ useEffect(() => {
100
+ if (!open) return
101
+ if (item) {
102
+ setLoading(true)
103
+ adminGetLegalDetailFn({ data: { id: item.id } })
104
+ .then((d) => {
105
+ setForm({
106
+ title: d.title ?? '',
107
+ code: '',
108
+ jurisdiction: (d.jurisdiction as string) ?? 'CN_NATIONAL',
109
+ region: d.region ?? '',
110
+ country: d.country ?? '',
111
+ category: d.category ?? '',
112
+ issuingAuthority: d.issuingAuthority ?? '',
113
+ effectiveDate: d.effectiveDate
114
+ ? new Date(d.effectiveDate).toISOString().slice(0, 10)
115
+ : '',
116
+ summary: d.summary ?? '',
117
+ content: d.content ?? '',
118
+ sourceUrl: d.sourceUrl ?? '',
119
+ tags: ((d.tags as string[]) ?? []).join(', '),
120
+ })
121
+ })
122
+ .catch((e) => toast.error(e instanceof Error ? e.message : t('legalForm.loadFailed')))
123
+ .finally(() => setLoading(false))
124
+ } else {
125
+ setForm(EMPTY)
126
+ }
127
+ }, [open, item])
128
+
129
+ async function handleSubmit(e: React.FormEvent) {
130
+ e.preventDefault()
131
+ if (!form.title.trim()) {
132
+ toast.error(t('legalForm.titleRequired'))
133
+ return
134
+ }
135
+ if (!form.category.trim()) {
136
+ toast.error(t('legalForm.categoryRequired'))
137
+ return
138
+ }
139
+ setSaving(true)
140
+ try {
141
+ const payload = {
142
+ title: form.title.trim(),
143
+ code: form.code.trim() || undefined,
144
+ jurisdiction: form.jurisdiction as 'CN_NATIONAL' | 'CN_LOCAL' | 'OVERSEAS',
145
+ region: form.region.trim() || undefined,
146
+ country: form.country.trim() || undefined,
147
+ category: form.category.trim(),
148
+ issuingAuthority: form.issuingAuthority.trim() || undefined,
149
+ effectiveDate: form.effectiveDate ? new Date(form.effectiveDate) : null,
150
+ summary: form.summary.trim() || undefined,
151
+ content: form.content.trim() || undefined,
152
+ sourceUrl: form.sourceUrl.trim() || '',
153
+ tags: form.tags.split(',').map((tag) => tag.trim()).filter(Boolean),
154
+ }
155
+ if (isEdit && item) {
156
+ await adminUpdateLegalFn({ data: { id: item.id, ...payload } })
157
+ toast.success(t('legalForm.updated'))
158
+ } else {
159
+ await adminCreateLegalFn({ data: payload })
160
+ toast.success(t('legalForm.created'))
161
+ }
162
+ onSuccess()
163
+ onOpenChange(false)
164
+ } catch (err) {
165
+ toast.error(err instanceof Error ? err.message : t('legalForm.saveFailed'))
166
+ } finally {
167
+ setSaving(false)
168
+ }
169
+ }
170
+
171
+ return (
172
+ <Dialog open={open} onOpenChange={onOpenChange}>
173
+ <DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
174
+ <DialogHeader>
175
+ <DialogTitle>{isEdit ? t('legalForm.edit') : t('legalForm.create')}</DialogTitle>
176
+ </DialogHeader>
177
+ {loading ? (
178
+ <div className="flex h-40 items-center justify-center">
179
+ <Loader2 className="size-6 animate-spin text-muted-foreground" />
180
+ </div>
181
+ ) : (
182
+ <form onSubmit={handleSubmit}>
183
+ <div className="grid gap-4 py-1">
184
+ <div className="grid gap-2">
185
+ <Label>{t('legalForm.title')}</Label>
186
+ <Input
187
+ value={form.title}
188
+ onChange={(e) => setForm((f) => ({ ...f, title: e.target.value }))}
189
+ placeholder={t('legalForm.titlePlaceholder')}
190
+ />
191
+ </div>
192
+ <div className="grid grid-cols-2 gap-4">
193
+ <div className="grid gap-2">
194
+ <Label>{t('legalForm.code')}</Label>
195
+ <Input
196
+ value={form.code}
197
+ onChange={(e) => setForm((f) => ({ ...f, code: e.target.value }))}
198
+ placeholder={t('legalForm.codePlaceholder')}
199
+ />
200
+ </div>
201
+ <div className="grid gap-2">
202
+ <Label>{t('legalForm.jurisdiction')}</Label>
203
+ <Select
204
+ value={form.jurisdiction}
205
+ onValueChange={(v) => setForm((f) => ({ ...f, jurisdiction: v }))}
206
+ >
207
+ <SelectTrigger className="w-full">
208
+ <SelectValue />
209
+ </SelectTrigger>
210
+ <SelectContent>
211
+ {JURISDICTIONS.map((j) => (
212
+ <SelectItem key={j.value} value={j.value}>
213
+ {t(`legalForm.jurisdictions.${j.value}`)}
214
+ </SelectItem>
215
+ ))}
216
+ </SelectContent>
217
+ </Select>
218
+ </div>
219
+ </div>
220
+ <div className="grid grid-cols-3 gap-4">
221
+ <div className="grid gap-2">
222
+ <Label>{t('legalForm.category')}</Label>
223
+ <Input
224
+ value={form.category}
225
+ onChange={(e) => setForm((f) => ({ ...f, category: e.target.value }))}
226
+ placeholder={t('legalForm.categoryPlaceholder')}
227
+ />
228
+ </div>
229
+ <div className="grid gap-2">
230
+ <Label>{t('legalForm.region')}</Label>
231
+ <Input
232
+ value={form.region}
233
+ onChange={(e) => setForm((f) => ({ ...f, region: e.target.value }))}
234
+ placeholder={t('legalForm.regionPlaceholder')}
235
+ />
236
+ </div>
237
+ <div className="grid gap-2">
238
+ <Label>{t('legalForm.country')}</Label>
239
+ <Input
240
+ value={form.country}
241
+ onChange={(e) => setForm((f) => ({ ...f, country: e.target.value }))}
242
+ placeholder={t('legalForm.countryPlaceholder')}
243
+ />
244
+ </div>
245
+ </div>
246
+ <div className="grid grid-cols-2 gap-4">
247
+ <div className="grid gap-2">
248
+ <Label>{t('legalForm.issuingAuthority')}</Label>
249
+ <Input
250
+ value={form.issuingAuthority}
251
+ onChange={(e) => setForm((f) => ({ ...f, issuingAuthority: e.target.value }))}
252
+ placeholder={t('legalForm.issuingAuthorityPlaceholder')}
253
+ />
254
+ </div>
255
+ <div className="grid gap-2">
256
+ <Label>{t('legalForm.effectiveDate')}</Label>
257
+ <Input
258
+ type="date"
259
+ value={form.effectiveDate}
260
+ onChange={(e) => setForm((f) => ({ ...f, effectiveDate: e.target.value }))}
261
+ />
262
+ </div>
263
+ </div>
264
+ <div className="grid gap-2">
265
+ <Label>{t('legalForm.summary')}</Label>
266
+ <Textarea
267
+ value={form.summary}
268
+ onChange={(e) => setForm((f) => ({ ...f, summary: e.target.value }))}
269
+ rows={2}
270
+ placeholder={t('legalForm.summaryPlaceholder')}
271
+ />
272
+ </div>
273
+ <div className="grid gap-2">
274
+ <Label>{t('legalForm.content')}</Label>
275
+ <Textarea
276
+ value={form.content}
277
+ onChange={(e) => setForm((f) => ({ ...f, content: e.target.value }))}
278
+ rows={8}
279
+ placeholder={t('legalForm.contentPlaceholder')}
280
+ className="font-mono text-sm"
281
+ />
282
+ </div>
283
+ <div className="grid grid-cols-2 gap-4">
284
+ <div className="grid gap-2">
285
+ <Label>{t('legalForm.sourceUrl')}</Label>
286
+ <Input
287
+ value={form.sourceUrl}
288
+ onChange={(e) => setForm((f) => ({ ...f, sourceUrl: e.target.value }))}
289
+ placeholder="https://"
290
+ />
291
+ </div>
292
+ <div className="grid gap-2">
293
+ <Label>{t('legalForm.tags')}</Label>
294
+ <Input
295
+ value={form.tags}
296
+ onChange={(e) => setForm((f) => ({ ...f, tags: e.target.value }))}
297
+ placeholder={t('legalForm.tagsPlaceholder')}
298
+ />
299
+ </div>
300
+ </div>
301
+ </div>
302
+ <DialogFooter>
303
+ <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
304
+ {t('common.cancel')}
305
+ </Button>
306
+ <Button type="submit" disabled={saving}>
307
+ {saving ? t('legalForm.saving') : t('common.save')}
308
+ </Button>
309
+ </DialogFooter>
310
+ </form>
311
+ )}
312
+ </DialogContent>
313
+ </Dialog>
314
+ )
315
+ }