feat(web): complete groups/contacts/favorites/settings/calls/system domains + extensive review-round fixes

Rounds out the full rewrite with the remaining feature domains: group
management (admin panel, mute, files, announcements, QR-based invite/join),
contacts (friend profile, add-friend, blocklist, new-friends), favorites
(real media rendering across text/image/video/voice/file/link), settings
(profile/security/privacy/general/about), calls (Agora-backed audio/video
signaling + call history), and system messages/file assistant.

Also folds in a large batch of live-verified fixes surfaced by repeated
fresh code-review passes and hands-on E2E testing against the real
backend, including: WS reconnect not resubscribing any topic after a
disconnect, missing x-im-client-mid on every send path (protocol
compliance for retry dedup), group mute/member-settings/self-profile
endpoint corrections, relationship-state type fixes across search/QR/
friend-profile, tenant-switch logout gap, password-policy now read from
the real per-tenant config instead of guessed, NavRail highlight/
aria-current desync, several call-state-machine races, and responsive
layout fixes across all four breakpoints (mobile/tablet/laptop/desktop)
including a full mobile rebuild of favorites, call UI, and the settings
home/sub-list split.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-09-09 20:16:33 +08:00
co-authored by Claude Sonnet 5
parent 72b7b79caf
commit 5c3f3b7653
89 changed files with 10158 additions and 442 deletions
@@ -0,0 +1,271 @@
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, NOT `POST /v1/im/public/uploads?purpose=user_avatar` -- confirmed
* live-broken (docs/IM-后端问题清单.md 问题28: that endpoint 400s on `purpose=user_avatar`
* specifically), and the old app's own real avatar-upload code never goes through it either.
*/
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'] });
},
onError: (err) => setError(err instanceof Error ? err.message : '保存失败'),
});
const avatarMutation = useMutation({
mutationFn: async (file: File) => {
const uploaded = await uploadFile(file);
const fileId = uploaded.url.split('/').pop() || '';
if (!query.data) throw new Error('尚未加载');
return updateMyProfile({ avatar_file_id: fileId, 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-list-item 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-list-item 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-list-item 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-list-item 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="未填写" />
<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>
);
}