- 头像上传从未带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>
283 lines
14 KiB
TypeScript
283 lines
14 KiB
TypeScript
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>
|
||
);
|
||
}
|