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:
co-authored by
Claude Sonnet 5
parent
72b7b79caf
commit
5c3f3b7653
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user