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 (