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
+154
View File
@@ -0,0 +1,154 @@
import { useEffect, useRef, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Icon } from '@/app/Icon';
import { createGroupQRCode, createMyQRCode, createUserQRCode, type GroupQRCode, type MyQRCode } from '@/lib/api/qrcodes';
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
import { useEscapeKey } from '@/lib/hooks';
interface QRCodeModalProps {
/** Omit both to show the CURRENT user's own code; pass a friend's user_id/account to show theirs
* (README's "二维码名片" row on a friend's profile). */
identifier?: string;
/** 03-group.dc.html #1a's `qr_code_2` icon on the group-info card (admin/owner-visible group-join
* code) -- mutually exclusive with `identifier`, a group is never also a user. */
groupTopic?: string;
onClose: () => void;
}
/**
* README §5.4/§8.2's "我的二维码"/"二维码名片", plus the group-join code (03-group.dc.html #1a). The
* QR image encodes `qr_payload` (`im://qr?token=...&purpose=add_friend|join_group`), never a raw
* user/group id -- confirmed live for the personal-code path that this is a real, working backend
* feature (`POST /me/qrcodes` / `POST /users/{id}/qrcodes`), not a documented-but-unbuilt one; the
* group-code path (`POST /groups/{topic}/qrcodes`) is the same documented shape (docs/IM-前后端对接
* 文档.md:1590,1737) but not yet live-verified against a real deployment. Re-mints a fresh token on
* every mount (POST semantics, not cached) since each one is short-lived (~24h) and single-purpose.
*/
export function QRCodeModal({ identifier, groupTopic, onClose }: QRCodeModalProps) {
useEscapeKey(onClose);
const canvasRef = useRef<HTMLCanvasElement>(null);
const [drawError, setDrawError] = useState('');
const query = useQuery<MyQRCode | GroupQRCode>({
queryKey: ['qrcode', groupTopic ? `group:${groupTopic}` : identifier || 'me'],
queryFn: (): Promise<MyQRCode | GroupQRCode> => (groupTopic ? createGroupQRCode(groupTopic) : identifier ? createUserQRCode(identifier) : createMyQRCode()),
});
useEffect(() => {
if (!query.data || !canvasRef.current) return;
const canvas = canvasRef.current;
// Dynamically imported (like agora-rtc-sdk-ng in agora-client.ts) so a QR-generating library
// isn't in every visitor's main bundle when most sessions never open this modal.
import('qrcode')
.then(({ default: QRCode }) => QRCode.toCanvas(canvas, query.data.qr_payload, { width: 200, margin: 1, color: { dark: '#22262d', light: '#ffffff' } }))
.catch(() => setDrawError('二维码生成失败'));
}, [query.data]);
const profile = query.data && 'display_profile' in query.data ? query.data.display_profile : undefined;
const group = query.data && 'group_snapshot' in query.data ? query.data.group_snapshot : undefined;
const title = groupTopic ? '群二维码' : identifier ? '二维码名片' : '我的二维码';
const fileBaseName = groupTopic ? '群二维码' : identifier ? '二维码名片' : '我的二维码';
const caption = groupTopic ? '扫一扫上面的二维码,申请加入群聊' : '扫一扫上面的二维码,加我为好友';
const canvasToBlob = (): Promise<Blob | null> => new Promise((resolve) => canvasRef.current?.toBlob((b) => resolve(b), 'image/png'));
const saveToAlbum = async () => {
const blob = await canvasToBlob();
if (!blob) return;
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${fileBaseName}.png`;
link.click();
URL.revokeObjectURL(url);
};
const shareToFriend = async () => {
const blob = await canvasToBlob();
if (!blob) return;
const file = new File([blob], 'qrcode.png', { type: 'image/png' });
// Real capability check, not just feature-detecting `navigator.share` itself -- some browsers
// implement `share()` for URLs/text only and reject a `files` payload at call time, so
// `canShare({ files })` is the one call that actually answers "can this share an image".
if (navigator.canShare?.({ files: [file] })) {
try {
await navigator.share({ files: [file], title: fileBaseName });
} catch {
// AbortError (user cancelled the native share sheet) or any other failure -- either way
// there's nothing useful to surface, the share sheet's own UI already communicated the outcome.
}
} else {
await saveToAlbum();
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#22262d]/25" onClick={onClose}>
<div className="flex w-[360px] flex-col rounded-xl bg-white shadow-modal" onClick={(e) => e.stopPropagation()}>
<header className="flex h-13 flex-none items-center justify-between border-b border-border-list-item px-5">
<strong className="text-[15px] font-semibold text-text-heading">{title}</strong>
<button type="button" onClick={onClose} className="grid h-8 w-8 place-items-center text-text-tertiary-4">
<Icon name="close" />
</button>
</header>
<div className="flex flex-col items-center gap-3 px-6 py-7">
{profile ? (
<>
<span className={`grid h-14 w-14 flex-none place-items-center rounded-xl text-xl font-medium text-white ${avatarBgClass(profile.user_id)}`}>
{avatarLetter(profile.display_name)}
</span>
<strong className="text-base font-semibold text-text-heading">{profile.display_name}</strong>
{profile.public_account ? <span className="text-xs text-text-tertiary-2">ID:{profile.public_account}</span> : null}
</>
) : group ? (
<>
<span className={`grid h-14 w-14 flex-none place-items-center rounded-xl text-xl font-medium text-white ${avatarBgClass(group.topic)}`}>
{avatarLetter(group.name)}
</span>
<strong className="text-base font-semibold text-text-heading">{group.name}</strong>
<span className="text-xs text-text-tertiary-2">{group.member_count} 人</span>
</>
) : null}
<div className="mt-2 grid h-[220px] w-[220px] place-items-center rounded-lg border border-border-panel">
{query.isLoading ? (
<span className="text-sm text-text-tertiary-4">生成中…</span>
) : query.isError || drawError ? (
<div className="flex flex-col items-center gap-2 px-4 text-center">
<Icon name="error_outline" className="text-2xl text-danger-text" />
<span className="text-xs text-text-tertiary-4">{drawError || '二维码生成失败,请重试'}</span>
</div>
) : (
<canvas ref={canvasRef} />
)}
</div>
<span className="text-xs text-text-tertiary-3">{caption}</span>
{/* No mock anywhere in this design handoff draws these two buttons (grepped every
screens/*.dc.html) -- added on the strength of a near-universal QR-display convention
(save/share), not a documented spec. Reuses the `share` icon the add-friend chip
(04-contacts.dc.html:101) already uses for the same verb elsewhere in this app. */}
{query.data && !drawError ? (
<div className="mt-1 flex gap-2">
<button
type="button"
onClick={saveToAlbum}
className="flex h-8.5 items-center gap-1.5 rounded-md border border-[#d5d9df] px-3.5 text-[13px] text-text-secondary"
>
<Icon name="download" className="text-[16px]" />
保存到相册
</button>
<button
type="button"
onClick={shareToFriend}
className="flex h-8.5 items-center gap-1.5 rounded-md border border-[#d5d9df] px-3.5 text-[13px] text-text-secondary"
>
<Icon name="share" className="text-[16px]" />
分享好友
</button>
</div>
) : null}
</div>
</div>
</div>
);
}