@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,664 @@
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 { Camera, Loader2, Save, X } from "lucide-react";
19
+ import { useCallback, useRef, useState } from "react";
20
+ import { cn } from "@/lib/utils";
21
+ import { Button } from "@/components/ui/button";
22
+ import {
23
+ Card,
24
+ CardContent,
25
+ CardDescription,
26
+ CardHeader,
27
+ CardTitle,
28
+ } from "@/components/ui/card";
29
+ import {
30
+ Field,
31
+ FieldContent,
32
+ FieldDescription,
33
+ FieldError,
34
+ FieldLabel,
35
+ } from "@/components/ui/field";
36
+ import {
37
+ InputGroup,
38
+ InputGroupInput,
39
+ } from "@/components/ui/input-group";
40
+ import { Separator } from "@/components/ui/separator";
41
+ import { Textarea } from "@/components/ui/textarea";
42
+ import {
43
+ defaultSocialPlatforms,
44
+ socialPlatformLabels,
45
+ } from "./settings-profile/constants";
46
+ import type {
47
+ ProfileData,
48
+ SettingsProfileProps,
49
+ } from "./settings-profile/types";
50
+
51
+ export default function SettingsProfile({
52
+ profile,
53
+ onSave,
54
+ onEmailChange,
55
+ onAvatarUpload,
56
+ onAvatarRemove,
57
+ className,
58
+ showEmailVerification = true,
59
+ readOnly = false,
60
+ title = "个人信息",
61
+ description = "管理你的资料与头像",
62
+ actions,
63
+ }: SettingsProfileProps) {
64
+ const [isSaving, setIsSaving] = useState(false);
65
+ const [isUploadingAvatar, setIsUploadingAvatar] = useState(false);
66
+ const [isChangingEmail, setIsChangingEmail] = useState(false);
67
+ const [showEmailChangeForm, setShowEmailChangeForm] = useState(false);
68
+ const [avatarPreview, setAvatarPreview] = useState<string | null>(
69
+ profile?.avatar || null
70
+ );
71
+ const [errors, setErrors] = useState<Record<string, string>>({});
72
+
73
+ const [formData, setFormData] = useState<ProfileData>({
74
+ name: profile?.name || "",
75
+ email: profile?.email || "",
76
+ bio: profile?.bio || "",
77
+ location: profile?.location || "",
78
+ website: profile?.website || "",
79
+ socialLinks: profile?.socialLinks || [],
80
+ });
81
+
82
+ const [emailChangeData, setEmailChangeData] = useState({
83
+ newEmail: "",
84
+ currentPassword: "",
85
+ });
86
+
87
+ const fileInputRef = useRef<HTMLInputElement>(null);
88
+ const avatarFileRef = useRef<File | null>(null);
89
+
90
+ const handleAvatarSelect = useCallback(
91
+ async (file: File) => {
92
+ if (!file.type.startsWith("image/")) {
93
+ setErrors({ avatar: "Please select an image file" });
94
+ return;
95
+ }
96
+
97
+ if (file.size > 5 * 1024 * 1024) {
98
+ setErrors({ avatar: "Image size must be less than 5MB" });
99
+ return;
100
+ }
101
+
102
+ avatarFileRef.current = file;
103
+ const reader = new FileReader();
104
+ reader.onloadend = () => {
105
+ setAvatarPreview(reader.result as string);
106
+ };
107
+ reader.readAsDataURL(file);
108
+
109
+ if (onAvatarUpload) {
110
+ setIsUploadingAvatar(true);
111
+ try {
112
+ const avatarUrl = await onAvatarUpload(file);
113
+ setAvatarPreview(avatarUrl);
114
+ setErrors({});
115
+ } catch (error) {
116
+ setErrors({
117
+ avatar:
118
+ error instanceof Error
119
+ ? error.message
120
+ : "Failed to upload avatar",
121
+ });
122
+ } finally {
123
+ setIsUploadingAvatar(false);
124
+ }
125
+ }
126
+ },
127
+ [onAvatarUpload]
128
+ );
129
+
130
+ const handleAvatarClick = () => {
131
+ fileInputRef.current?.click();
132
+ };
133
+
134
+ const handleAvatarRemove = async () => {
135
+ if (onAvatarRemove) {
136
+ setIsUploadingAvatar(true);
137
+ try {
138
+ await onAvatarRemove();
139
+ setAvatarPreview(null);
140
+ avatarFileRef.current = null;
141
+ setErrors({});
142
+ } catch (error) {
143
+ setErrors({
144
+ avatar:
145
+ error instanceof Error ? error.message : "Failed to remove avatar",
146
+ });
147
+ } finally {
148
+ setIsUploadingAvatar(false);
149
+ }
150
+ } else {
151
+ setAvatarPreview(null);
152
+ avatarFileRef.current = null;
153
+ }
154
+ };
155
+
156
+ const handleDragOver = useCallback((e: React.DragEvent) => {
157
+ e.preventDefault();
158
+ e.stopPropagation();
159
+ }, []);
160
+
161
+ const handleDrop = useCallback(
162
+ (e: React.DragEvent) => {
163
+ e.preventDefault();
164
+ e.stopPropagation();
165
+
166
+ const file = e.dataTransfer.files[0];
167
+ if (file) {
168
+ handleAvatarSelect(file);
169
+ }
170
+ },
171
+ [handleAvatarSelect]
172
+ );
173
+
174
+ const handleFileInputChange = useCallback(
175
+ (e: React.ChangeEvent<HTMLInputElement>) => {
176
+ const file = e.target.files?.[0];
177
+ if (file) {
178
+ handleAvatarSelect(file);
179
+ }
180
+ },
181
+ [handleAvatarSelect]
182
+ );
183
+
184
+ const handleSave = async () => {
185
+ setErrors({});
186
+
187
+ if (!formData.name.trim()) {
188
+ setErrors({ name: "Name is required" });
189
+ return;
190
+ }
191
+
192
+ if (!formData.email.trim()) {
193
+ setErrors({ email: "Email is required" });
194
+ return;
195
+ }
196
+
197
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
198
+ if (!emailRegex.test(formData.email)) {
199
+ setErrors({ email: "Please enter a valid email address" });
200
+ return;
201
+ }
202
+
203
+ if (formData.website && formData.website.trim()) {
204
+ try {
205
+ new URL(formData.website);
206
+ } catch {
207
+ setErrors({ website: "Please enter a valid URL" });
208
+ return;
209
+ }
210
+ }
211
+
212
+ setIsSaving(true);
213
+ try {
214
+ await onSave?.(formData);
215
+ } catch (error) {
216
+ setErrors({
217
+ _general:
218
+ error instanceof Error ? error.message : "Failed to save profile",
219
+ });
220
+ } finally {
221
+ setIsSaving(false);
222
+ }
223
+ };
224
+
225
+ const handleEmailChange = async () => {
226
+ setErrors({});
227
+
228
+ if (!emailChangeData.newEmail.trim()) {
229
+ setErrors({ newEmail: "New email is required" });
230
+ return;
231
+ }
232
+
233
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
234
+ if (!emailRegex.test(emailChangeData.newEmail)) {
235
+ setErrors({ newEmail: "Please enter a valid email address" });
236
+ return;
237
+ }
238
+
239
+ if (!emailChangeData.currentPassword.trim()) {
240
+ setErrors({ currentPassword: "Current password is required" });
241
+ return;
242
+ }
243
+
244
+ setIsChangingEmail(true);
245
+ try {
246
+ await onEmailChange?.(
247
+ emailChangeData.newEmail,
248
+ emailChangeData.currentPassword
249
+ );
250
+ setFormData((prev) => ({ ...prev, email: emailChangeData.newEmail }));
251
+ setEmailChangeData({ newEmail: "", currentPassword: "" });
252
+ setShowEmailChangeForm(false);
253
+ setErrors({});
254
+ } catch (error) {
255
+ setErrors({
256
+ emailChange:
257
+ error instanceof Error ? error.message : "Failed to change email",
258
+ });
259
+ } finally {
260
+ setIsChangingEmail(false);
261
+ }
262
+ };
263
+
264
+ const updateSocialLink = (platform: string, url: string) => {
265
+ setFormData((prev) => {
266
+ const socialLinks = prev.socialLinks || [];
267
+ const existingIndex = socialLinks.findIndex(
268
+ (link) => link.platform === platform
269
+ );
270
+ const updatedLinks = [...socialLinks];
271
+
272
+ if (url.trim()) {
273
+ if (existingIndex >= 0) {
274
+ updatedLinks[existingIndex] = { platform, url };
275
+ } else {
276
+ updatedLinks.push({ platform, url });
277
+ }
278
+ } else if (existingIndex >= 0) {
279
+ updatedLinks.splice(existingIndex, 1);
280
+ }
281
+
282
+ return { ...prev, socialLinks: updatedLinks };
283
+ });
284
+ };
285
+
286
+ const getSocialLink = (platform: string): string =>
287
+ formData.socialLinks?.find((link) => link.platform === platform)?.url || "";
288
+
289
+ return (
290
+ <Card className={cn("w-full shadow-xs", className)}>
291
+ <CardHeader>
292
+ <div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
293
+ <div className="flex min-w-0 flex-1 flex-col gap-2">
294
+ <CardTitle className="wrap-break-word">{title}</CardTitle>
295
+ <CardDescription className="wrap-break-word">
296
+ {description}
297
+ </CardDescription>
298
+ </div>
299
+ {actions ? (
300
+ <div className="flex shrink-0 flex-wrap gap-2">{actions}</div>
301
+ ) : (
302
+ !readOnly && (
303
+ <div className="flex shrink-0 gap-2">
304
+ <Button
305
+ className="w-full sm:w-auto"
306
+ disabled={isSaving}
307
+ onClick={handleSave}
308
+ type="button"
309
+ >
310
+ {isSaving ? (
311
+ <>
312
+ <Loader2 className="size-4 animate-spin" />
313
+ <span className="whitespace-nowrap">保存中…</span>
314
+ </>
315
+ ) : (
316
+ <>
317
+ <Save className="size-4" />
318
+ <span className="whitespace-nowrap">保存更改</span>
319
+ </>
320
+ )}
321
+ </Button>
322
+ </div>
323
+ )
324
+ )}
325
+ </div>
326
+ </CardHeader>
327
+ <CardContent>
328
+ <div className="flex flex-col gap-6">
329
+ {errors._general && (
330
+ <div className="rounded-lg border border-destructive/50 bg-destructive/10 p-3">
331
+ <p className="text-destructive text-sm">{errors._general}</p>
332
+ </div>
333
+ )}
334
+
335
+ {/* Avatar Upload */}
336
+ {!(readOnly && !avatarPreview) && (
337
+ <div className="flex flex-col gap-4">
338
+ <FieldLabel>头像</FieldLabel>
339
+ <div className="flex flex-col gap-4 sm:flex-row sm:items-center">
340
+ <div
341
+ className={cn(
342
+ "relative flex size-24 shrink-0 items-center justify-center overflow-hidden rounded-full border-2 border-dashed transition-colors",
343
+ readOnly && "cursor-default border-muted bg-muted/30",
344
+ !readOnly && (isUploadingAvatar
345
+ ? "border-primary bg-primary/5"
346
+ : "border-muted bg-muted/30 hover:border-primary/50"),
347
+ !readOnly && "cursor-pointer"
348
+ )}
349
+ {...(readOnly
350
+ ? {}
351
+ : {
352
+ onClick: handleAvatarClick,
353
+ onDragOver: handleDragOver,
354
+ onDrop: handleDrop,
355
+ })}
356
+ >
357
+ {avatarPreview ? (
358
+ <>
359
+ <img
360
+ alt="头像"
361
+ className="size-full object-cover"
362
+ src={avatarPreview}
363
+ />
364
+ {!readOnly && isUploadingAvatar && (
365
+ <div className="absolute inset-0 flex items-center justify-center bg-background/80">
366
+ <Loader2 className="size-6 animate-spin text-primary" />
367
+ </div>
368
+ )}
369
+ </>
370
+ ) : (
371
+ <Camera className="size-8 text-muted-foreground" />
372
+ )}
373
+ </div>
374
+ {!readOnly && (
375
+ <div className="flex flex-1 flex-col gap-2">
376
+ <div className="flex flex-col gap-2 sm:flex-row">
377
+ <Button
378
+ onClick={handleAvatarClick}
379
+ type="button"
380
+ variant="outline"
381
+ >
382
+ <Camera className="size-4" />
383
+ {avatarPreview ? "更换头像" : "上传头像"}
384
+ </Button>
385
+ {avatarPreview && (
386
+ <Button
387
+ onClick={handleAvatarRemove}
388
+ type="button"
389
+ variant="outline"
390
+ >
391
+ <X className="size-4" />
392
+ 移除
393
+ </Button>
394
+ )}
395
+ </div>
396
+ <p className="text-muted-foreground text-xs">
397
+ 拖拽图片到此,或点击选择。最大 5MB。
398
+ </p>
399
+ {errors.avatar && (
400
+ <p className="text-destructive text-xs">{errors.avatar}</p>
401
+ )}
402
+ </div>
403
+ )}
404
+ {!readOnly && (
405
+ <input
406
+ accept="image/*"
407
+ className="hidden"
408
+ onChange={handleFileInputChange}
409
+ ref={fileInputRef}
410
+ type="file"
411
+ />
412
+ )}
413
+ </div>
414
+ </div>
415
+ )}
416
+
417
+ <Separator />
418
+
419
+ {/* Basic Information */}
420
+ <div className="flex flex-col gap-4">
421
+ <Field>
422
+ <FieldLabel htmlFor="name">
423
+ 姓名 <span className="text-destructive">*</span>
424
+ </FieldLabel>
425
+ <FieldContent>
426
+ <InputGroup>
427
+ <InputGroupInput
428
+ id="name"
429
+ onChange={(e) =>
430
+ setFormData((prev) => ({ ...prev, name: e.target.value }))
431
+ }
432
+ placeholder="请输入姓名"
433
+ readOnly={readOnly}
434
+ value={formData.name}
435
+ />
436
+ </InputGroup>
437
+ {errors.name && <FieldError>{errors.name}</FieldError>}
438
+ </FieldContent>
439
+ </Field>
440
+
441
+ <Field>
442
+ <FieldLabel htmlFor="email">邮箱</FieldLabel>
443
+ <FieldContent>
444
+ <div className="flex flex-col gap-2">
445
+ <InputGroup>
446
+ <InputGroupInput
447
+ disabled={showEmailChangeForm}
448
+ id="email"
449
+ onChange={(e) =>
450
+ setFormData((prev) => ({
451
+ ...prev,
452
+ email: e.target.value,
453
+ }))
454
+ }
455
+ placeholder="your.email@example.com"
456
+ readOnly={readOnly}
457
+ type="email"
458
+ value={formData.email}
459
+ />
460
+ </InputGroup>
461
+ {showEmailVerification && !readOnly && (
462
+ <Button
463
+ className="w-full sm:w-auto"
464
+ onClick={() =>
465
+ setShowEmailChangeForm(!showEmailChangeForm)
466
+ }
467
+ type="button"
468
+ variant="outline"
469
+ >
470
+ {showEmailChangeForm ? "取消" : "修改邮箱"}
471
+ </Button>
472
+ )}
473
+ </div>
474
+ {errors.email && <FieldError>{errors.email}</FieldError>}
475
+ </FieldContent>
476
+ </Field>
477
+
478
+ {showEmailChangeForm && !readOnly && (
479
+ <div className="flex flex-col gap-4 rounded-lg border bg-muted/30 p-4">
480
+ <Field>
481
+ <FieldLabel htmlFor="new-email">
482
+ 新邮箱 <span className="text-destructive">*</span>
483
+ </FieldLabel>
484
+ <FieldContent>
485
+ <InputGroup>
486
+ <InputGroupInput
487
+ id="new-email"
488
+ onChange={(e) =>
489
+ setEmailChangeData((prev) => ({
490
+ ...prev,
491
+ newEmail: e.target.value,
492
+ }))
493
+ }
494
+ placeholder="new.email@example.com"
495
+ type="email"
496
+ value={emailChangeData.newEmail}
497
+ />
498
+ </InputGroup>
499
+ {errors.newEmail && (
500
+ <FieldError>{errors.newEmail}</FieldError>
501
+ )}
502
+ </FieldContent>
503
+ </Field>
504
+
505
+ <Field>
506
+ <FieldLabel htmlFor="current-password">
507
+ 当前密码 <span className="text-destructive">*</span>
508
+ </FieldLabel>
509
+ <FieldContent>
510
+ <InputGroup>
511
+ <InputGroupInput
512
+ id="current-password"
513
+ onChange={(e) =>
514
+ setEmailChangeData((prev) => ({
515
+ ...prev,
516
+ currentPassword: e.target.value,
517
+ }))
518
+ }
519
+ placeholder="请输入当前密码"
520
+ type="password"
521
+ value={emailChangeData.currentPassword}
522
+ />
523
+ </InputGroup>
524
+ {errors.currentPassword && (
525
+ <FieldError>{errors.currentPassword}</FieldError>
526
+ )}
527
+ {errors.emailChange && (
528
+ <FieldError>{errors.emailChange}</FieldError>
529
+ )}
530
+ </FieldContent>
531
+ </Field>
532
+
533
+ <Button
534
+ className="w-full sm:w-auto"
535
+ disabled={isChangingEmail}
536
+ onClick={handleEmailChange}
537
+ type="button"
538
+ >
539
+ {isChangingEmail ? (
540
+ <>
541
+ <Loader2 className="size-4 animate-spin" />
542
+ 修改中…
543
+ </>
544
+ ) : (
545
+ "更新邮箱"
546
+ )}
547
+ </Button>
548
+ </div>
549
+ )}
550
+
551
+ {!(readOnly && !formData.bio?.trim()) && (
552
+ <Field>
553
+ <FieldLabel htmlFor="bio">简介</FieldLabel>
554
+ <FieldContent>
555
+ <Textarea
556
+ id="bio"
557
+ onChange={(e) =>
558
+ setFormData((prev) => ({ ...prev, bio: e.target.value }))
559
+ }
560
+ placeholder="介绍一下自己…"
561
+ readOnly={readOnly}
562
+ rows={4}
563
+ value={formData.bio || ""}
564
+ />
565
+ {!readOnly && (
566
+ <FieldDescription>
567
+ 简短描述自己(最多 500 字符)
568
+ </FieldDescription>
569
+ )}
570
+ </FieldContent>
571
+ </Field>
572
+ )}
573
+
574
+ {!(readOnly && !formData.location?.trim()) && (
575
+ <Field>
576
+ <FieldLabel htmlFor="location">所在地</FieldLabel>
577
+ <FieldContent>
578
+ <InputGroup>
579
+ <InputGroupInput
580
+ id="location"
581
+ onChange={(e) =>
582
+ setFormData((prev) => ({
583
+ ...prev,
584
+ location: e.target.value,
585
+ }))
586
+ }
587
+ placeholder="城市, 国家"
588
+ readOnly={readOnly}
589
+ value={formData.location || ""}
590
+ />
591
+ </InputGroup>
592
+ </FieldContent>
593
+ </Field>
594
+ )}
595
+
596
+ {!(readOnly && !formData.website?.trim()) && (
597
+ <Field>
598
+ <FieldLabel htmlFor="website">网站</FieldLabel>
599
+ <FieldContent>
600
+ <InputGroup>
601
+ <InputGroupInput
602
+ id="website"
603
+ onChange={(e) =>
604
+ setFormData((prev) => ({
605
+ ...prev,
606
+ website: e.target.value,
607
+ }))
608
+ }
609
+ placeholder="https://example.com"
610
+ readOnly={readOnly}
611
+ type="url"
612
+ value={formData.website || ""}
613
+ />
614
+ </InputGroup>
615
+ {errors.website && <FieldError>{errors.website}</FieldError>}
616
+ </FieldContent>
617
+ </Field>
618
+ )}
619
+ </div>
620
+
621
+ <Separator />
622
+
623
+ {/* Social Links */}
624
+ {defaultSocialPlatforms
625
+ .filter(
626
+ (platform) => !readOnly || getSocialLink(platform.id).trim()
627
+ )
628
+ .length > 0 && (
629
+ <div className="flex flex-col gap-4">
630
+ <FieldLabel>社交链接</FieldLabel>
631
+ <div className="flex flex-col gap-3">
632
+ {defaultSocialPlatforms
633
+ .filter(
634
+ (platform) => !readOnly || getSocialLink(platform.id).trim()
635
+ )
636
+ .map((platform) => (
637
+ <Field key={platform.id}>
638
+ <FieldLabel htmlFor={`social-${platform.id}`}>
639
+ {socialPlatformLabels[platform.id] || platform.label}
640
+ </FieldLabel>
641
+ <FieldContent>
642
+ <InputGroup>
643
+ <InputGroupInput
644
+ id={`social-${platform.id}`}
645
+ onChange={(e) =>
646
+ updateSocialLink(platform.id, e.target.value)
647
+ }
648
+ placeholder={platform.placeholder}
649
+ readOnly={readOnly}
650
+ type="url"
651
+ value={getSocialLink(platform.id)}
652
+ />
653
+ </InputGroup>
654
+ </FieldContent>
655
+ </Field>
656
+ ))}
657
+ </div>
658
+ </div>
659
+ )}
660
+ </div>
661
+ </CardContent>
662
+ </Card>
663
+ );
664
+ }