Files
im_webapp/web/src/features/settings/ProfileSettingsPage.tsx
T
Your NameandClaude Sonnet 5 97e8d7144f fix(web): 第9轮审查批次4——设置域一批问题
- 头像上传从未带purpose=user_avatar:之前是照抄一个已经修复的旧后端
  bug(问题28)绕开的写法,现在确认接口早就支持这个purpose了,补上——
  uploadFile()新增可选purpose参数,不影响其它已有调用点(聊天附件/群
  文件继续用topic的chat_file路径)
- 桌面卡片行分隔线6处错用了移动端专属的border-list-item token,改成
  桌面正确的border-row
- 退出登录按钮外边距/设置目录导航图标尺寸(18px→20px)与设计稿对齐
- 地区字段补上maxLength兜底
- 修改密码没有用<form>包裹,输入框按回车没反应——补上表单+onSubmit
- 记录一条此前没进清单的后端缺口:"加我为好友时需要验证"这个隐私开关
  设计稿画了,但响应体里根本没有对应字段

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 12:46:48 +08:00

283 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 `<input>` 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<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(function InlineField(props, ref) {
return (
<input
{...props}
ref={ref}
className={`w-full border-0 bg-transparent p-0 text-sm text-text-body outline-none [&::-webkit-calendar-picker-indicator]:hidden ${props.className || ''}`}
/>
);
});
/**
* 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<HTMLInputElement>(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<HTMLInputElement>) => {
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 <div className="p-10 text-sm text-text-tertiary-4">加载中…</div>;
// 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 (
<div className="flex flex-col items-center gap-2 p-10 text-sm text-danger-text">
<span>加载失败,请重试</span>
<button type="button" onClick={() => query.refetch()} className="text-accent">
重试
</button>
</div>
);
}
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 (
<div className="mx-auto w-full max-w-[720px] px-5 py-8 md:px-10">
<h2 className="mb-5 text-xl font-semibold text-text-heading">个人资料</h2>
<div className="flex flex-col gap-5">
<div className="flex items-center gap-5 rounded-xl bg-white px-7 py-6">
<div className="relative flex-none">
<Avatar id={myUserId || ''} name={draft.nickname} avatarFileId={query.data?.avatar_file_id} className="h-20 w-20 rounded-[20px] text-2xl" />
<label className="absolute -bottom-1 -right-1 grid h-[26px] w-[26px] cursor-pointer place-items-center rounded-full bg-white text-text-tertiary-1 shadow-btn-sm">
<Icon name="photo_camera" className="text-[15px]" />
<input
type="file"
accept="image/png,image/jpeg"
onChange={handlePickAvatar}
className="hidden"
disabled={uploadingAvatar || saveMutation.isPending}
/>
</label>
</div>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<strong className="truncate text-lg font-semibold text-text-heading">{draft.nickname}</strong>
<span className="truncate text-[13px] text-text-tertiary-2">
ID:{query.data?.public_account} · {uploadingAvatar ? '上传中…' : '支持 JPG/PNG,不超过 5MB'}
</span>
</div>
<button
type="button"
onClick={() => setShowQrCode(true)}
className="flex h-9 flex-none items-center gap-1.5 rounded-lg border border-[#d5d9df] px-2.5 text-[13px] text-text-tertiary-1 sm:px-3.5"
>
<Icon name="qr_code_2" className="text-[18px]" />
<span className="hidden sm:inline">我的二维码</span>
</button>
</div>
{showQrCode ? <QRCodeModal onClose={() => setShowQrCode(false)} /> : null}
<div className="rounded-xl bg-white text-sm">
<div className="grid grid-cols-[80px_1fr_auto] items-center gap-x-2 border-b border-border-row px-7 py-4 md:grid-cols-[120px_1fr_auto]">
<span className="text-text-secondary">昵称</span>
<InlineField
value={draft.nickname}
onChange={(e) => setDraft({ ...draft, nickname: e.target.value })}
maxLength={query.data?.field_rules?.nickname?.max_length || 32}
/>
<Icon name="edit" className="text-[20px] text-icon-arrow" />
</div>
<div className="grid grid-cols-[80px_1fr_auto] items-center gap-x-2 border-b border-border-row px-7 py-4 md:grid-cols-[120px_1fr_auto]">
<span className="text-text-secondary">性别</span>
<div className="flex overflow-hidden rounded-md border border-[#d5d9df] text-xs" style={{ width: 'fit-content' }}>
{GENDER_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => setDraft({ ...draft, gender: opt.value })}
className={`px-3.5 py-1.5 ${draft.gender === opt.value ? 'bg-accent text-white' : 'text-text-secondary'}`}
>
{opt.label}
</button>
))}
</div>
<span />
</div>
<div className="grid grid-cols-[80px_1fr_auto] items-center gap-x-2 border-b border-border-row px-7 py-4 md:grid-cols-[120px_1fr_auto]">
<span className="text-text-secondary">出生日期</span>
<InlineField
ref={birthDateRef}
type="date"
value={draft.birth_date}
max={new Date().toISOString().slice(0, 10)}
onChange={(e) => setDraft({ ...draft, birth_date: e.target.value })}
/>
<button type="button" onClick={() => birthDateRef.current?.showPicker?.()} className="grid place-items-center text-icon-arrow">
<Icon name="calendar_today" className="text-[20px]" />
</button>
</div>
<div className="grid grid-cols-[80px_1fr_auto] items-center gap-x-2 border-b border-border-row px-7 py-4 md:grid-cols-[120px_1fr_auto]">
<span className="text-text-secondary">地区</span>
<InlineField value={draft.region} onChange={(e) => setDraft({ ...draft, region: e.target.value })} placeholder="未填写" maxLength={50} />
<Icon name="chevron_right" className="text-[20px] text-icon-arrow" />
</div>
<div className="grid grid-cols-[80px_1fr_auto] items-start gap-x-2 px-7 py-4 md:grid-cols-[120px_1fr_auto]">
<span className="pt-2 text-text-secondary">个性签名</span>
<div className="relative">
<textarea
value={draft.signature}
onChange={(e) => setDraft({ ...draft, signature: e.target.value.slice(0, maxSignatureLength) })}
rows={3}
className="min-h-[60px] w-full resize-none rounded-lg bg-surface-input p-3 pr-14 text-sm outline-none"
/>
<span className="absolute bottom-2 right-3 text-xs text-text-tertiary-3">
{draft.signature.length}/{maxSignatureLength}
</span>
</div>
<span />
</div>
</div>
<div className="flex justify-end gap-2.5">
<button
type="button"
onClick={() =>
query.data &&
setDraft({
nickname: query.data.nickname,
gender: query.data.gender || '',
birth_date: query.data.birth_date || '',
region: query.data.region || '',
signature: query.data.signature || '',
})
}
disabled={!dirty}
className="h-9.5 rounded-lg border border-[#d5d9df] px-5 text-sm text-text-tertiary-1 disabled:opacity-50"
>
取消
</button>
<button
type="button"
onClick={() => saveMutation.mutate()}
disabled={!dirty || saveMutation.isPending || uploadingAvatar}
className="h-9.5 rounded-lg bg-btn px-5 text-sm font-medium text-white disabled:bg-btn-disabled"
>
{saveMutation.isPending ? '保存中…' : '保存'}
</button>
</div>
</div>
<Toast message={error} variant="error" onDone={() => setError('')} />
</div>
);
}