ProfileModal.tsx 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334
  1. "use client";
  2. import { useCallback, useState, useEffect, useRef } from "react";
  3. import { Profile } from "@/features/agent/types";
  4. import {
  5. updateProfile as updateProfileApi,
  6. uploadAvatar as uploadAvatarApi,
  7. UpdateProfilePayload,
  8. } from "@/features/agent/services/profileApi";
  9. import { getAvatarUrl, getAvatarColor, getAvatarInitial } from "@/utils/avatar";
  10. import { Button } from "@/components/ui/button";
  11. import { Input } from "@/components/ui/input";
  12. import {
  13. Dialog,
  14. DialogContent,
  15. DialogHeader,
  16. DialogTitle,
  17. } from "@/components/ui/dialog";
  18. interface ProfileModalProps {
  19. profile: Profile | null;
  20. open: boolean;
  21. onClose: () => void;
  22. onUpdate: (profile: Profile) => void;
  23. }
  24. export function ProfileModal({
  25. profile,
  26. open,
  27. onClose,
  28. onUpdate,
  29. }: ProfileModalProps) {
  30. const [editingNickname, setEditingNickname] = useState(false);
  31. const [editingEmail, setEditingEmail] = useState(false);
  32. const [nickname, setNickname] = useState("");
  33. const [email, setEmail] = useState("");
  34. const [avatarPreview, setAvatarPreview] = useState<string | null>(null);
  35. const [saving, setSaving] = useState(false);
  36. const [uploading, setUploading] = useState(false);
  37. const [errorMessage, setErrorMessage] = useState("");
  38. const fileInputRef = useRef<HTMLInputElement>(null);
  39. // 当弹窗打开或 profile 变化时,初始化表单
  40. useEffect(() => {
  41. if (open && profile) {
  42. setNickname(profile.nickname || "");
  43. setEmail(profile.email || "");
  44. setAvatarPreview(profile.avatar_url || null);
  45. setEditingNickname(false);
  46. setEditingEmail(false);
  47. setErrorMessage("");
  48. }
  49. }, [open, profile]);
  50. // 选择头像文件
  51. const handleAvatarSelect = useCallback(
  52. async (event: React.ChangeEvent<HTMLInputElement>) => {
  53. const file = event.target.files?.[0];
  54. if (!file || !profile) {
  55. return;
  56. }
  57. // 验证文件类型
  58. const allowedTypes = ["image/jpeg", "image/jpg", "image/png", "image/gif"];
  59. if (!allowedTypes.includes(file.type)) {
  60. setErrorMessage("只支持上传图片文件(jpg、png、gif)");
  61. return;
  62. }
  63. // 验证文件大小(10MB)
  64. if (file.size > 10 * 1024 * 1024) {
  65. setErrorMessage("头像文件大小不能超过10MB");
  66. return;
  67. }
  68. // 预览头像
  69. const reader = new FileReader();
  70. reader.onload = (e) => {
  71. setAvatarPreview(e.target?.result as string);
  72. };
  73. reader.readAsDataURL(file);
  74. // 上传头像
  75. setUploading(true);
  76. setErrorMessage("");
  77. try {
  78. const updated = await uploadAvatarApi(profile.id, file);
  79. onUpdate(updated);
  80. setAvatarPreview(updated.avatar_url);
  81. } catch (error) {
  82. setErrorMessage((error as Error).message || "上传头像失败,请稍后重试");
  83. // 恢复原头像
  84. setAvatarPreview(profile.avatar_url || null);
  85. } finally {
  86. setUploading(false);
  87. }
  88. },
  89. [profile, onUpdate]
  90. );
  91. // 保存昵称
  92. const handleSaveNickname = useCallback(async () => {
  93. if (!profile || !nickname.trim()) {
  94. return;
  95. }
  96. setSaving(true);
  97. setErrorMessage("");
  98. try {
  99. const payload: UpdateProfilePayload = {
  100. nickname: nickname.trim() || undefined,
  101. };
  102. const updated = await updateProfileApi(profile.id, payload);
  103. onUpdate(updated);
  104. setEditingNickname(false);
  105. } catch (error) {
  106. setErrorMessage((error as Error).message || "保存失败,请稍后重试");
  107. } finally {
  108. setSaving(false);
  109. }
  110. }, [profile, nickname, onUpdate]);
  111. // 保存邮箱
  112. const handleSaveEmail = useCallback(async () => {
  113. if (!profile) {
  114. return;
  115. }
  116. setSaving(true);
  117. setErrorMessage("");
  118. try {
  119. const payload: UpdateProfilePayload = {
  120. email: email.trim() || undefined,
  121. };
  122. const updated = await updateProfileApi(profile.id, payload);
  123. onUpdate(updated);
  124. setEditingEmail(false);
  125. } catch (error) {
  126. setErrorMessage((error as Error).message || "保存失败,请稍后重试");
  127. } finally {
  128. setSaving(false);
  129. }
  130. }, [profile, email, onUpdate]);
  131. if (!profile) {
  132. return null;
  133. }
  134. const displayName = profile.nickname || profile.username;
  135. const avatarColor = getAvatarColor(profile.id);
  136. const displayInitial = getAvatarInitial(profile.username, profile.nickname);
  137. const fullAvatarUrl = getAvatarUrl(profile.avatar_url);
  138. return (
  139. <Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
  140. <DialogContent className="max-h-[90vh] overflow-y-auto scrollbar-auto">
  141. <DialogHeader>
  142. <DialogTitle>个人资料</DialogTitle>
  143. </DialogHeader>
  144. {/* 错误提示 */}
  145. {errorMessage && (
  146. <div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-600">
  147. {errorMessage}
  148. </div>
  149. )}
  150. {/* 头像区域 */}
  151. <div className="flex flex-col items-center mb-6">
  152. <div className="relative">
  153. {avatarPreview || fullAvatarUrl ? (
  154. <img
  155. src={avatarPreview || fullAvatarUrl || ""}
  156. alt={displayName}
  157. className="w-24 h-24 rounded-full object-cover border-4 border-gray-200"
  158. />
  159. ) : (
  160. <div
  161. className="w-24 h-24 rounded-full flex items-center justify-center text-white text-2xl font-semibold border-4 border-gray-200"
  162. style={{ backgroundColor: avatarColor }}
  163. >
  164. {displayInitial}
  165. </div>
  166. )}
  167. {uploading && (
  168. <div className="absolute inset-0 bg-black/50 rounded-full flex items-center justify-center">
  169. <div className="w-6 h-6 border-2 border-white border-t-transparent rounded-full animate-spin" />
  170. </div>
  171. )}
  172. </div>
  173. <input
  174. ref={fileInputRef}
  175. type="file"
  176. accept="image/jpeg,image/jpg,image/png,image/gif"
  177. className="hidden"
  178. onChange={handleAvatarSelect}
  179. disabled={uploading}
  180. />
  181. <Button
  182. onClick={() => fileInputRef.current?.click()}
  183. disabled={uploading}
  184. variant="default"
  185. size="default"
  186. className="mt-3"
  187. >
  188. {uploading ? "上传中..." : "更换头像"}
  189. </Button>
  190. </div>
  191. {/* 用户名(只读) */}
  192. <div className="mb-4">
  193. <div className="text-sm text-gray-500 mb-1">用户名</div>
  194. <div className="text-base text-gray-800">{profile.username}</div>
  195. </div>
  196. {/* 角色(只读) */}
  197. <div className="mb-4">
  198. <div className="text-sm text-gray-500 mb-1">角色</div>
  199. <div className="text-base text-gray-800">{profile.role}</div>
  200. </div>
  201. {/* 昵称(可编辑) */}
  202. <div className="mb-4">
  203. <div className="text-sm text-gray-500 mb-1 flex items-center justify-between">
  204. <span>昵称</span>
  205. {!editingNickname ? (
  206. <Button
  207. onClick={() => setEditingNickname(true)}
  208. variant="ghost"
  209. size="sm"
  210. className="text-xs h-auto py-0 px-1 text-blue-500 hover:text-blue-600"
  211. disabled={saving}
  212. >
  213. 编辑
  214. </Button>
  215. ) : (
  216. <div className="flex gap-2">
  217. <Button
  218. onClick={() => {
  219. setEditingNickname(false);
  220. setNickname(profile.nickname || "");
  221. }}
  222. variant="ghost"
  223. size="sm"
  224. className="text-xs h-auto py-0 px-1 text-gray-500 hover:text-gray-600"
  225. disabled={saving}
  226. >
  227. 取消
  228. </Button>
  229. <Button
  230. onClick={handleSaveNickname}
  231. variant="ghost"
  232. size="sm"
  233. className="text-xs h-auto py-0 px-1 text-blue-500 hover:text-blue-600"
  234. disabled={saving}
  235. >
  236. 保存
  237. </Button>
  238. </div>
  239. )}
  240. </div>
  241. {editingNickname ? (
  242. <Input
  243. type="text"
  244. value={nickname}
  245. onChange={(e) => setNickname(e.target.value)}
  246. placeholder="请输入昵称"
  247. disabled={saving}
  248. />
  249. ) : (
  250. <div className="text-base text-gray-800">
  251. {profile.nickname || "未设置"}
  252. </div>
  253. )}
  254. </div>
  255. {/* 邮箱(可编辑) */}
  256. <div className="mb-6">
  257. <div className="text-sm text-gray-500 mb-1 flex items-center justify-between">
  258. <span>邮箱</span>
  259. {!editingEmail ? (
  260. <Button
  261. onClick={() => setEditingEmail(true)}
  262. variant="ghost"
  263. size="sm"
  264. className="text-xs h-auto py-0 px-1 text-blue-500 hover:text-blue-600"
  265. disabled={saving}
  266. >
  267. 编辑
  268. </Button>
  269. ) : (
  270. <div className="flex gap-2">
  271. <Button
  272. onClick={() => {
  273. setEditingEmail(false);
  274. setEmail(profile.email || "");
  275. }}
  276. variant="ghost"
  277. size="sm"
  278. className="text-xs h-auto py-0 px-1 text-gray-500 hover:text-gray-600"
  279. disabled={saving}
  280. >
  281. 取消
  282. </Button>
  283. <Button
  284. onClick={handleSaveEmail}
  285. variant="ghost"
  286. size="sm"
  287. className="text-xs h-auto py-0 px-1 text-blue-500 hover:text-blue-600"
  288. disabled={saving}
  289. >
  290. 保存
  291. </Button>
  292. </div>
  293. )}
  294. </div>
  295. {editingEmail ? (
  296. <Input
  297. type="email"
  298. value={email}
  299. onChange={(e) => setEmail(e.target.value)}
  300. placeholder="请输入邮箱"
  301. disabled={saving}
  302. />
  303. ) : (
  304. <div className="text-base text-gray-800">
  305. {profile.email || "未设置"}
  306. </div>
  307. )}
  308. </div>
  309. </DialogContent>
  310. </Dialog>
  311. );
  312. }