import { forwardRef, useEffect, useRef, useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { Avatar } from '@/app/Avatar'; import { Icon } from '@/app/Icon'; import { Toast } from '@/app/Toast'; import { QRCodeModal } from '@/features/contacts/QRCodeModal'; import { getMyProfile, updateMyProfile } from '@/lib/api/profile'; import { uploadFile } from '@/lib/api/upload'; import { useSessionStore } from '@/stores/session'; const GENDER_OPTIONS: { value: 'male' | 'female' | 'other'; label: string }[] = [ { value: 'male', label: '男' }, { value: 'female', label: '女' }, { value: 'other', label: '保密' }, ]; const DEFAULT_MAX_SIGNATURE_LENGTH = 60; /** Plain-text-looking field: a real `` with all box chrome stripped so it reads as the * design's "label / value / edit-affordance-icon" row instead of a boxed form control, while * staying genuinely editable (click anywhere in the value cell to type). */ const InlineField = forwardRef>(function InlineField(props, ref) { return ( ); }); /** * README §8.2 / screens/07-profile-settings.dc.html#1a. Avatar upload reuses upload.ts's native * `/v0/file/u/` channel (the one every other upload in this app already goes through), NOT * `POST /v1/im/public/uploads` -- that's a different, pre-login-only channel for enterprise * registration/tenant feedback (`lib/api/tenant.ts`'s `uploadPublicFile`, whose own `purpose` type * is explicitly `'enterprise_license' | 'tenant_entry_feedback'`), never meant for an authenticated * user's own avatar. Passes `purpose: 'user_avatar'` explicitly per docs/IM-前后端对接文档.md:2293 * -- this used to be omitted entirely (traced to `purpose=user_avatar` 400ing on THIS channel too, * docs/IM-后端问题清单.md 问题28, since confirmed fixed: a real PNG upload with that purpose now * returns 200). */ export function ProfileSettingsPage() { const queryClient = useQueryClient(); const myUserId = useSessionStore((s) => s.myUserId); const query = useQuery({ queryKey: ['my-profile'], queryFn: getMyProfile }); const [draft, setDraft] = useState<{ nickname: string; gender: string; birth_date: string; region: string; signature: string } | null>(null); const [error, setError] = useState(''); const [uploadingAvatar, setUploadingAvatar] = useState(false); const [showQrCode, setShowQrCode] = useState(false); const birthDateRef = useRef(null); useEffect(() => { if (query.data && !draft) { setDraft({ nickname: query.data.nickname, gender: query.data.gender || '', birth_date: query.data.birth_date || '', region: query.data.region || '', signature: query.data.signature || '', }); } }, [query.data, draft]); // saveMutation and avatarMutation both PATCH the same optimistic-locked resource. The disabled= // props below on the avatar input/save button stop the two from ever being pending at the same // time, closing the race where both could send the SAME stale `query.data.version` and whichever // response resolves last would win the cache. Independent of that: EITHER mutation's own response // can itself be a PARTIAL profile object -- the doc's own worked example for this endpoint (line // 2492) returns only `{avatar_file_id,nickname,signature,version,updated_at}`, omitting // `birth_date`/`gender`/`region`/`public_account`/`field_rules` entirely, and avatarMutation's own // request body is deliberately partial (`{avatar_file_id,version}` only) so its response is the // most likely of the two to come back partial too. A blind `setQueryData(key, data)` replace would // erase those fields from the cache after a single avatar change -- merging onto the existing // cached object (not just overwriting it) is what actually prevents that regardless of exactly how // partial a given response turns out to be. const saveMutation = useMutation({ mutationFn: () => { if (!query.data || !draft) throw new Error('尚未加载'); return updateMyProfile({ ...draft, version: query.data.version }); }, onSuccess: (data) => { queryClient.setQueryData(['my-profile'], (prev: typeof query.data) => (prev ? { ...prev, ...data } : data)); queryClient.invalidateQueries({ queryKey: ['me'] }); }, // IM-前后端对接文档.md 10.3 (版本冲突先拉最新资料再由用户确认): a stale `version` (e.g. edited // on another tab/device) 400s here, but the cached `query.data.version` used to just sit there // unchanged -- every retry would resend the exact same stale version and 400 again forever, the // toast's own "请刷新后重试" copy notwithstanding, since nothing actually refetched. onError: (err) => { setError(err instanceof Error ? err.message : '保存失败'); queryClient.invalidateQueries({ queryKey: ['my-profile'] }); }, }); const avatarMutation = useMutation({ mutationFn: async (file: File) => { const uploaded = await uploadFile(file, undefined, 'user_avatar'); if (!query.data) throw new Error('尚未加载'); return updateMyProfile({ avatar_file_id: uploaded.file_id, version: query.data.version }); }, onSuccess: (data) => { queryClient.setQueryData(['my-profile'], (prev: typeof query.data) => (prev ? { ...prev, ...data } : data)); queryClient.invalidateQueries({ queryKey: ['me'] }); setUploadingAvatar(false); }, onError: (err) => { setError(err instanceof Error ? err.message : '头像上传失败'); setUploadingAvatar(false); }, }); const handlePickAvatar = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; e.target.value = ''; if (!file) return; if (file.size > 5 * 1024 * 1024) { setError('图片大小不能超过 5MB'); return; } setUploadingAvatar(true); avatarMutation.mutate(file); }; if (query.isLoading) return
加载中…
; // Same gap as PrivacySettingsPage's own query: `draft` only ever gets set from a successful fetch // (the effect above is gated on `query.data`), so a genuine fetch failure left `isLoading` false // but `draft` permanently null -- falling through to the exact same "加载中…" text forever, with no // way to retry. if (query.isError || !draft) { return (
加载失败,请重试
); } const maxSignatureLength = query.data?.field_rules?.signature?.max_length || DEFAULT_MAX_SIGNATURE_LENGTH; const dirty = query.data && (draft.nickname !== query.data.nickname || draft.gender !== (query.data.gender || '') || draft.birth_date !== (query.data.birth_date || '') || draft.region !== (query.data.region || '') || draft.signature !== (query.data.signature || '')); return (

个人资料

{draft.nickname} ID:{query.data?.public_account} · {uploadingAvatar ? '上传中…' : '支持 JPG/PNG,不超过 5MB'}
{showQrCode ? setShowQrCode(false)} /> : null}
昵称 setDraft({ ...draft, nickname: e.target.value })} maxLength={query.data?.field_rules?.nickname?.max_length || 32} />
性别
{GENDER_OPTIONS.map((opt) => ( ))}
出生日期 setDraft({ ...draft, birth_date: e.target.value })} />
地区 setDraft({ ...draft, region: e.target.value })} placeholder="未填写" maxLength={50} />
个性签名