ProfileModal.tsx 11 KB

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