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
+112
View File
@@ -0,0 +1,112 @@
import { useInfiniteQuery } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { Icon } from '@/app/Icon';
import { getCallHistory, type CallHistoryItem } from '@/lib/api/calls';
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
import { formatEventTimestamp } from '@/lib/messages/time';
import { useChatList } from '@/lib/tinode/hooks';
import { useCallStore } from '@/stores/calls';
function formatDuration(ms: number | undefined): string {
const totalSeconds = Math.round((ms || 0) / 1000);
return `${String(Math.floor(totalSeconds / 60)).padStart(2, '0')}:${String(totalSeconds % 60).padStart(2, '0')}`;
}
/** Direction-dependent, same reasoning as dispatch.ts's callResultDisplay: a `missed` result reads
* as "未接听" from the callee's own history and "已取消" from the caller's. */
function statusLabel(item: CallHistoryItem): string {
const result = item.result || item.status;
const incoming = item.direction === 'incoming';
if (result === 'finished') return formatDuration(item.duration_ms);
if (result === 'missed') return incoming ? '未接听' : '已取消';
if (result === 'declined') return '已拒绝';
if (result === 'disconnected') return '异常结束';
return result;
}
/** README §7 doesn't spec a dedicated call-history screen beyond the "最近通话" idea implicit in
* NavRail's own 通话记录 entry -- this is a plain list off the confirmed real `GET /v1/im/calls`
* endpoint, whose response shape (see calls.ts's own comment) turned out completely different
* from the doc's own minimal example once checked live: `peer_user_id` (a real `usr...` id, unlike
* `topic`, which comes back `p2p...`-hashed and unusable for navigation/re-dialing) is resolved to
* a display name via the chat list, same lookup ChatListColumn itself uses.
*/
export function CallHistoryView() {
const navigate = useNavigate();
const contacts = useChatList();
const query = useInfiniteQuery({
queryKey: ['call-history'],
queryFn: ({ pageParam }) => getCallHistory(pageParam),
initialPageParam: '',
getNextPageParam: (lastPage) => lastPage.next_cursor || undefined,
});
const items = query.data?.pages.flatMap((p) => p.items) ?? [];
return (
<div className="flex h-full w-full flex-col overflow-y-auto bg-surface-content">
<header className="flex h-15 flex-none items-center border-b border-border-panel bg-white px-6">
<strong className="text-base font-semibold text-text-heading">通话记录</strong>
</header>
<div className="flex flex-col gap-2 p-6">
{query.isLoading ? (
<div className="py-10 text-center text-sm text-text-tertiary-4">加载中…</div>
) : items.length === 0 ? (
<div className="py-10 text-center text-sm text-text-tertiary-4">暂无通话记录</div>
) : (
items.map((item) => {
const result = item.result || item.status;
const incoming = item.direction === 'incoming';
// Mock only reds out a genuine "未接听" (a call that rang and was never answered) --
// "已取消"(I cancelled before pickup)/"对方忙线中"/"异常结束" are neutral outcomes, not
// something the viewer missed and should feel warned about. This used to redden EVERY
// non-'finished' result alike.
const isMissed = result === 'missed' && incoming;
const name = contacts.find((c) => c.topic === item.peer_user_id)?.public?.fn || item.peer_user_id;
const ts = item.started_at || item.created_at;
return (
<div key={`${item.call_id}`} className="flex items-center gap-3 rounded-xl bg-white px-4 py-3">
<button
type="button"
onClick={() => navigate(`/messages/${item.peer_user_id}`)}
className="flex min-w-0 flex-1 items-center gap-3 text-left"
>
<span className={`grid h-11 w-11 flex-none place-items-center rounded-lg text-base font-medium text-white ${avatarBgClass(item.peer_user_id)}`}>
{avatarLetter(name)}
</span>
<span className="flex min-w-0 flex-col gap-0.5">
<strong className="truncate text-[15px] font-medium text-text-body">{name}</strong>
<span className={`flex items-center gap-1 truncate text-xs ${isMissed ? 'text-danger-text' : 'text-text-tertiary-2'}`}>
<Icon name={incoming ? 'call_received' : 'call_made'} className="text-sm" />
{statusLabel(item)}
{ts ? ` · ${formatEventTimestamp(new Date(ts))}` : ''}
</span>
</span>
</button>
<button
type="button"
onClick={() => {
if (useCallStore.getState().phase === 'idle') useCallStore.getState().startCall(item.peer_user_id, name, item.call_type);
}}
className="grid h-9 w-9 flex-none place-items-center rounded-lg bg-surface-input text-accent"
>
<Icon name={item.call_type === 'video' ? 'videocam' : 'call'} className="text-lg" />
</button>
</div>
);
})
)}
{query.hasNextPage ? (
<button
type="button"
onClick={() => query.fetchNextPage()}
disabled={query.isFetchingNextPage}
className="py-2 text-center text-sm text-accent disabled:opacity-60"
>
{query.isFetchingNextPage ? '加载中…' : '加载更多'}
</button>
) : null}
</div>
</div>
);
}
+422
View File
@@ -0,0 +1,422 @@
import { useEffect, useRef, useState } from 'react';
import { Icon } from '@/app/Icon';
import { Toast } from '@/app/Toast';
import { playLocalVideo, playRemoteVideo } from '@/lib/calls/agora-client';
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
import { formatCallDuration } from '@/lib/messages/dispatch';
import { useCallStore } from '@/stores/calls';
function useElapsedSeconds(connectedAt: number | null): number {
const [elapsed, setElapsed] = useState(0);
useEffect(() => {
if (!connectedAt) {
setElapsed(0);
return undefined;
}
const tick = () => setElapsed(Math.floor((Date.now() - connectedAt) / 1000));
tick();
const timer = window.setInterval(tick, 1000);
return () => window.clearInterval(timer);
}, [connectedAt]);
return elapsed;
}
function formatElapsed(totalSeconds: number): string {
const m = Math.floor(totalSeconds / 60);
const s = totalSeconds % 60;
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
}
function CircleButton({
icon,
active,
danger,
label,
onClick,
size = 'h-13 w-13',
iconSize = 'text-2xl',
}: {
icon: string;
active?: boolean;
danger?: boolean;
label: string;
onClick: () => void;
size?: string;
iconSize?: string;
}) {
return (
<button type="button" onClick={onClick} aria-label={label} className="flex flex-col items-center gap-1.5">
<span
className={`grid ${size} place-items-center rounded-full ${iconSize} text-white ${
danger ? 'bg-[#f34b46]' : active ? 'bg-white !text-[#22262d]' : 'bg-white/[0.16]'
}`}
>
<Icon name={icon} />
</span>
<span className="text-xs text-white">{label}</span>
</button>
);
}
/** README §7's incoming-call card (top-right floating, 360px). `w-[360px]` alone overflows a phone-
* width viewport (confirmed live: 360px + the 24px right offset already exceeds a 375px screen with
* no room for a left margin at all) -- `max-w-[calc(100vw-32px)]` caps it to the viewport with a
* 16px gutter on each side below the point where the fixed width alone would fit. */
function IncomingCallCard() {
const { topic, peerName, callType, acceptCall, rejectCall } = useCallStore();
return (
<div className="fixed right-4 top-4 z-[60] w-[360px] max-w-[calc(100vw-32px)] rounded-[14px] bg-white p-5 shadow-[0_16px_48px_rgba(0,0,0,0.2)] sm:right-6 sm:top-6">
<div className="flex items-center gap-3">
<span className={`grid h-13 w-13 flex-none place-items-center rounded-xl text-xl font-medium text-white ${avatarBgClass(topic || peerName)}`}>
{avatarLetter(peerName)}
</span>
<div className="flex min-w-0 flex-col">
<strong className="truncate text-[15px] font-semibold text-text-heading">{peerName}</strong>
<span className="text-xs text-text-tertiary-2">邀请你{callType === 'video' ? '视频' : '语音'}通话</span>
</div>
</div>
<div className="mt-4 flex justify-end gap-3">
<button type="button" onClick={rejectCall} aria-label="拒绝" className="grid h-[42px] w-[42px] place-items-center rounded-full bg-[#f34b46] text-xl text-white">
<Icon name="call_end" />
</button>
<button type="button" onClick={acceptCall} aria-label="接听" className="grid h-[42px] w-[42px] place-items-center rounded-full bg-[#1fb36a] text-xl text-white">
<Icon name={callType === 'video' ? 'videocam' : 'call'} />
</button>
</div>
</div>
);
}
/** Mobile-only full-screen ringing view (06-calls.dc.html `#1g`, "移动·语音来电" -- the same layout
* serves a video invite too, just swapping the subtitle/accept icon). Desktop keeps the small
* top-right floating `IncomingCallCard` above, unchanged; `CallRoot` renders exactly one of the two
* per breakpoint. Built as its own component rather than a responsive fork of `IncomingCallCard`
* because the two share no DOM structure at all (a corner card vs. a full-bleed ringing screen). */
function MobileIncomingCall() {
const { topic, peerName, callType, acceptCall, rejectCall } = useCallStore();
return (
<div className="fixed inset-0 z-[60] flex flex-col items-center bg-gradient-to-b from-[#1f2a3d] to-[#111826] text-white">
<span
className={`mt-32 grid h-[120px] w-[120px] place-items-center rounded-[28px] text-[46px] font-medium text-white ${avatarBgClass(topic || peerName)}`}
style={{ boxShadow: '0 0 0 14px rgba(43,182,115,0.18), 0 0 0 32px rgba(43,182,115,0.08)' }}
>
{avatarLetter(peerName)}
</span>
<strong className="mt-8 text-2xl font-semibold">{peerName}</strong>
<span className="mt-2 text-[15px] text-white/70">邀请你{callType === 'video' ? '视频' : '语音'}通话…</span>
<div className="mb-16 mt-auto flex gap-24">
<button type="button" onClick={rejectCall} aria-label="拒绝" className="flex flex-col items-center gap-2.5 text-[13px]">
<span className="grid h-18 w-18 place-items-center rounded-full bg-[#f34b46] text-[32px]">
<Icon name="call_end" />
</span>
拒绝
</button>
<button type="button" onClick={acceptCall} aria-label="接听" className="flex flex-col items-center gap-2.5 text-[13px]">
<span className="grid h-18 w-18 place-items-center rounded-full bg-[#1fb36a] text-[32px]">
<Icon name={callType === 'video' ? 'videocam' : 'call'} />
</span>
接听
</button>
</div>
</div>
);
}
/** README §7's minimized floating bar (280px). Call stays live (Agora channel untouched) --
* minimizing is pure local UI state, matching the old app's own real behavior (CallPanel is never
* unmounted between full-screen and minimized). */
function MinimizedCallBar() {
const { topic, peerName, connectedAt, muted, toggleMinimize, toggleMute, hangUp } = useCallStore();
const elapsed = useElapsedSeconds(connectedAt);
return (
// `bottom-[106px] sm:bottom-6`: same viewport-vs-Outlet-box fixed-positioning issue as Toast.tsx
// and FriendProfileView's mobile action bar -- below 640px this would otherwise sit on top of
// AppShell's persistent 82px BottomTabBar (a minimized call can be reached at any viewport width,
// there's no `sm:hidden` gate on this component to make the overlap moot).
<div className="fixed bottom-[106px] right-6 z-[60] flex w-[280px] items-center gap-3 rounded-xl bg-[#1b2231] px-4 py-3 text-white sm:bottom-6">
<button type="button" onClick={toggleMinimize} aria-label="展开通话窗口" className="flex flex-1 items-center gap-3 text-left">
<span className={`grid h-9 w-9 flex-none place-items-center rounded-lg text-sm font-medium text-white ${avatarBgClass(topic || peerName)}`}>
{avatarLetter(peerName)}
</span>
<span className="flex min-w-0 flex-col">
<strong className="truncate text-sm font-medium">{peerName}</strong>
<span className="text-xs text-success">{connectedAt ? `通话中 · ${formatElapsed(elapsed)}` : '正在连接…'}</span>
</span>
</button>
<button type="button" onClick={toggleMute} aria-label={muted ? '取消静音' : '静音'} className="grid h-8 w-8 flex-none place-items-center rounded-full bg-white/10">
<Icon name={muted ? 'mic_off' : 'mic'} className="text-base" />
</button>
<button type="button" onClick={hangUp} aria-label="挂断" className="grid h-8 w-8 flex-none place-items-center rounded-full bg-[#f34b46]">
<Icon name="call_end" className="text-base" />
</button>
</div>
);
}
/** README §7's in-call window. Deliberately an in-app overlay, not a true separate OS window (the
* design calls for one) -- the old app's own real CallPanel is also just an overlay, never a
* separate window/component instance, and keeping the Agora client/tracks in one JS realm avoids a
* cross-window signaling bus this rewrite has no other need for. */
function CallWindow() {
const {
topic,
callType,
phase,
peerName,
connectedAt,
muted,
cameraOff,
hasRemoteVideo,
hasLocalVideo,
minimized,
mediaError,
toggleMinimize,
toggleMute,
toggleCamera,
hangUp,
} = useCallStore();
const remoteRef = useRef<HTMLDivElement>(null);
const localRef = useRef<HTMLDivElement>(null);
const elapsed = useElapsedSeconds(connectedAt);
useEffect(() => {
if (remoteRef.current) playRemoteVideo(remoteRef.current);
}, [hasRemoteVideo]);
useEffect(() => {
if (localRef.current && hasLocalVideo && !cameraOff) playLocalVideo(localRef.current);
}, [hasLocalVideo, cameraOff]);
if (minimized) return <MinimizedCallBar />;
const statusLabel = phase === 'connected' ? `${formatElapsed(elapsed)} · 网络良好` : '正在等待对方接听…';
// 06-calls.dc.html #1b (呼出等待中) labels this same button "取消", matching the protocol doc's own
// "发起方取消发送 hang-up:cancelled" wording for the pre-connect case -- it was hardcoded to "挂断"
// at all 3 call sites below regardless of phase, which is only actually correct once connected.
const hangUpLabel = phase === 'connected' ? '挂断' : '取消';
if (callType === 'audio') {
return (
<div className="fixed inset-0 z-[60] flex flex-col items-center justify-between bg-gradient-to-b from-[#1f2a3d] to-[#111826] py-16 text-white">
<button type="button" onClick={toggleMinimize} aria-label="最小化" className="absolute left-5 top-5 text-white/70">
<Icon name="expand_more" className="text-2xl" />
</button>
<div className="flex flex-col items-center gap-4">
<span
// Mock `06-calls.dc.html #1b`: `border-radius:24px` (a squircle), not a perfect circle --
// the mobile equivalent of this same avatar (MobileIncomingCall) already gets this right.
className={`grid h-[100px] w-[100px] place-items-center rounded-[24px] text-3xl font-medium text-white ${avatarBgClass(topic || peerName)}`}
style={{ boxShadow: '0 0 0 12px rgba(43,182,115,0.18), 0 0 0 28px rgba(43,182,115,0.08)' }}
>
{avatarLetter(peerName)}
</span>
<strong className="text-center text-xl font-semibold">{peerName}</strong>
<span className="text-sm text-white/60">{statusLabel}</span>
</div>
<div className="flex gap-8">
<CircleButton icon={muted ? 'mic_off' : 'mic'} active={muted} label="静音" onClick={toggleMute} />
<CircleButton icon="call_end" danger label={hangUpLabel} onClick={hangUp} />
<CircleButton icon="volume_up" label="扬声器" onClick={() => {}} />
</div>
<Toast message={mediaError || ''} variant="error" onDone={() => useCallStore.setState({ mediaError: null })} />
</div>
);
}
return (
<div
className="fixed inset-0 z-[60] text-white"
style={{ background: 'radial-gradient(ellipse at 50% 40%, #3b4a63, #1b2231)' }}
>
{/* Desktop header (06-calls.dc.html #1a): name + live duration/network status inline. `z-10`
(here and on every other overlay piece below) is deliberate, not decorative -- without it,
this and the real `remoteRef` video div right below are BOTH `position:absolute` with no
z-index, so plain DOM order would decide stacking and the video (which comes second) would
paint over this header the moment real remote video is actually playing. That case never
got exercised live (this sandbox has no real camera, see the calls verification note in the
plan file), so it's a latent bug worth closing here rather than leaving for whoever first
tests a real 2-camera call to rediscover. */}
<div className="absolute inset-x-0 top-0 z-10 hidden items-center justify-between bg-gradient-to-b from-black/40 to-transparent px-6 py-4 md:flex">
<div className="flex flex-col">
<strong className="text-base font-semibold">{peerName}</strong>
<span className="flex items-center gap-1 text-xs text-white/70">
{phase === 'connected' ? (
<>
{formatElapsed(elapsed)} · <Icon name="network_wifi" className="text-sm text-[#1fb36a]" /> 网络良好
</>
) : (
'正在等待对方接听…'
)}
</span>
</div>
<div className="flex items-center gap-3 text-white/80">
<button type="button" onClick={toggleMinimize} aria-label="最小化">
<Icon name="picture_in_picture_alt" className="text-xl" />
</button>
</div>
</div>
{/* Mobile header (06-calls.dc.html #1f): icon-only row, no room for the name/duration text --
those move into the centered overlay below (no remote video yet) or the small pill just
under this row (once real video is playing). 翻转摄像头 lives here instead of the bottom
control grid, matching the mock exactly. */}
<div className="absolute inset-x-0 top-0 z-10 flex items-center justify-between px-4 py-1.5 md:hidden">
<button type="button" onClick={toggleMinimize} aria-label="最小化" className="grid h-10 w-10 place-items-center">
<Icon name="picture_in_picture_alt" className="text-xl" />
</button>
<button
type="button"
onClick={() => void useCallStore.getState().switchCamera()}
aria-label="翻转摄像头"
className="grid h-10 w-10 place-items-center"
>
<Icon name="flip_camera_ios" className="text-xl" />
</button>
</div>
{hasRemoteVideo ? (
<div className="absolute left-1/2 top-16 z-10 -translate-x-1/2 whitespace-nowrap rounded-full bg-black/40 px-3 py-1 text-xs md:hidden">
{peerName}
{phase === 'connected' ? ` · ${formatElapsed(elapsed)}` : ''}
</div>
) : null}
<div className="absolute inset-0">
{hasRemoteVideo ? (
<div ref={remoteRef} className="h-full w-full [&>video]:h-full [&>video]:w-full [&>video]:object-cover" />
) : (
<>
{/* Desktop fallback (unchanged): dead-centered avatar, name/status stay in the header above. */}
<div className="hidden h-full w-full place-items-center md:grid">
<div className="flex flex-col items-center gap-3">
{/* Mock `06-calls.dc.html #1a`: `border-radius:28px`, not a perfect circle. */}
<span
className={`grid h-[120px] w-[120px] place-items-center rounded-[28px] text-4xl font-medium text-white ${avatarBgClass(topic || peerName)}`}
>
{avatarLetter(peerName)}
</span>
<span className="text-sm text-white/70">{phase === 'connected' ? '对方已关闭摄像头' : ''}</span>
</div>
</div>
{/* Mobile fallback (06-calls.dc.html #1f): avatar + name + duration together, since mobile
has no header info bar to carry the name instead. */}
<div className="flex h-full w-full flex-col items-center pt-[150px] md:hidden">
<span
className={`grid h-[110px] w-[110px] place-items-center rounded-[26px] text-4xl font-medium text-white ${avatarBgClass(topic || peerName)}`}
>
{avatarLetter(peerName)}
</span>
<strong className="mt-2 text-xl font-semibold">{peerName}</strong>
<span className="mt-1 text-sm text-white/70">
{phase === 'connected' ? formatElapsed(elapsed) : '正在等待对方接听…'}
</span>
{/* Desktop's version of this same fallback state shows this explanation (line 285
above); mobile silently dropped it, showing only the elapsed-time line with no
indication of WHY there's no video. */}
{phase === 'connected' ? <span className="text-sm text-white/70">对方已关闭摄像头</span> : null}
</div>
</>
)}
</div>
{/* Local self-preview: one shared element (not a desktop/mobile fork) so the live Agora track
stays attached to a single `ref` -- only its position/size differs by breakpoint, via plain
responsive classes, restoring the desktop values under `md:`. */}
{!cameraOff ? (
<div
ref={localRef}
className="absolute right-4 top-[110px] z-10 h-40 w-27 overflow-hidden rounded-[14px] border-2 border-white/30 bg-black/40 [&>video]:h-full [&>video]:w-full [&>video]:object-cover md:right-5 md:top-[72px] md:h-[132px] md:w-[200px] md:rounded-xl"
/>
) : null}
{/* Desktop bottom bar (unchanged): 5 controls incl. 共享屏幕. */}
<div className="absolute inset-x-0 bottom-8 z-10 hidden justify-center gap-6 md:flex">
<CircleButton icon={muted ? 'mic_off' : 'mic'} active={muted} label="静音" onClick={toggleMute} />
<CircleButton icon={cameraOff ? 'videocam_off' : 'videocam'} active={cameraOff} label="摄像头" onClick={toggleCamera} />
<CircleButton icon="volume_up" label="扬声器" onClick={() => {}} />
<CircleButton icon="screen_share" label="共享屏幕" onClick={() => {}} />
<CircleButton icon="call_end" danger label={hangUpLabel} onClick={hangUp} />
</div>
{/* Mobile bottom bar (06-calls.dc.html #1f): 3-over-1 grid, no 共享屏幕 (not in this mock) and
bigger touch targets (60px / 68px for hang-up) matching the mock's exact button sizes. */}
<div className="absolute inset-x-0 bottom-12 z-10 grid grid-cols-3 gap-y-6 px-8 md:hidden">
<CircleButton icon={muted ? 'mic_off' : 'mic'} active={muted} label="静音" onClick={toggleMute} size="h-15 w-15" iconSize="text-[26px]" />
<CircleButton
icon={cameraOff ? 'videocam_off' : 'videocam'}
active={cameraOff}
label="摄像头"
onClick={toggleCamera}
size="h-15 w-15"
iconSize="text-[26px]"
/>
<CircleButton icon="volume_up" label="扬声器" onClick={() => {}} size="h-15 w-15" iconSize="text-[26px]" />
<span />
<CircleButton icon="call_end" danger label={hangUpLabel} onClick={hangUp} size="h-17 w-17" iconSize="text-[30px]" />
<span />
</div>
<Toast message={mediaError || ''} variant="error" onDone={() => useCallStore.setState({ mediaError: null })} />
</div>
);
}
/** Brief (3s, see stores/calls.ts's `_endWithReason`) full-screen result card shown right after any
* call ends -- same icon/text vocabulary as the permanent chat-bubble record (dispatch.ts's
* callResultDisplay) so the live moment and the message history agree on how a call is described. */
function EndedCallOverlay() {
const { topic, peerName, callType, endReason, connectedAt, wasCaller } = useCallStore();
// Computed once (not on every re-render) -- this overlay is only ever on screen for a fixed 3s
// window, so a live-ticking duration isn't needed and re-deriving it from Date.now() on each
// render would make the displayed value drift depending on when React happens to re-render.
const [durationMs] = useState(() => (connectedAt ? Date.now() - connectedAt : 0));
const icon = callType === 'video' ? 'videocam' : 'call';
const { label, danger } = (() => {
switch (endReason) {
case 'finished':
return { label: durationMs > 0 ? `通话时长 ${formatCallDuration(durationMs)}` : '通话已结束', danger: false };
case 'missed':
return { label: wasCaller ? '对方未接听,已取消' : '未接听', danger: true };
case 'declined':
return { label: '对方已拒绝', danger: true };
case 'disconnected':
return { label: '通话连接异常,已结束', danger: true };
case 'busy':
return { label: '对方忙线中', danger: true };
default:
return { label: '通话已结束', danger: false };
}
})();
return (
<div className="fixed inset-0 z-[60] flex flex-col items-center justify-center gap-4 bg-gradient-to-b from-[#1f2a3d] to-[#111826] text-white">
{/* Same 24px squircle as the audio in-call avatar (same 100x100 size, same visual family --
no mock draws this exact result-overlay state, so this follows the nearest one that does). */}
<span className={`grid h-[100px] w-[100px] place-items-center rounded-[24px] text-3xl font-medium text-white ${avatarBgClass(topic || peerName)}`}>
{avatarLetter(peerName)}
</span>
<strong className="text-xl font-semibold">{peerName}</strong>
<span className={`flex items-center gap-1.5 text-sm ${danger ? 'text-[#f34b46]' : 'text-white/70'}`}>
<Icon name={danger ? 'phone_missed' : icon} className="text-base" />
{label}
</span>
</div>
);
}
/** Mounted once in AppShell. */
export function CallRoot() {
const { phase, minimized } = useCallStore();
if (phase === 'incoming')
return (
<>
<div className="hidden md:contents">
<IncomingCallCard />
</div>
<div className="contents md:hidden">
<MobileIncomingCall />
</div>
</>
);
if (phase === 'outgoing' || phase === 'connected') return minimized ? <MinimizedCallBar /> : <CallWindow />;
if (phase === 'ended') return <EndedCallOverlay />;
return null;
}