修复收藏/系统消息/通话记录批次的代码审查问题
- 通话结果"已拒绝"文案按视角区分(拒绝方看到"已拒绝"而非"对方已拒绝") - 通话记录页补充移动端返回头部与加载失败重试态 - 挂断时按是否已接通传递正确的挂断原因(cancelled) - 系统消息"全部标为已读"翻页加循环护栏,防止游标卡死;按钮可见性改用真实未读总数 - 收藏批量删除/转发在部分失败时保留失败项以便重试,而非无条件退出多选模式 - 收藏卡片边框/分类文字/侧栏图标颜色token修正
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import { useInfiniteQuery } from '@tanstack/react-query';
|
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
import { Icon } from '@/app/Icon';
|
import { Icon } from '@/app/Icon';
|
||||||
import { getCallHistory, type CallHistoryItem } from '@/lib/api/calls';
|
import { getCallHistory, type CallHistoryItem } from '@/lib/api/calls';
|
||||||
@@ -45,12 +45,30 @@ export function CallHistoryView() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full flex-col overflow-y-auto bg-surface-content">
|
<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">
|
{/* `/calls`, like `/favorites`, is only reachable on a narrow viewport via 设置(我的)>通话记录
|
||||||
|
(SettingsLayout.tsx's MOBILE_SHORTCUTS) -- same "back returns there" convention
|
||||||
|
FavoritesLayout's own mobile header already uses, and the same gap this file didn't have
|
||||||
|
until now (there was no way back from here at all short of a system back gesture). */}
|
||||||
|
<header className="grid h-15 flex-none grid-cols-[44px_1fr_44px] items-center border-b border-border-panel bg-white px-1 md:hidden">
|
||||||
|
<Link to="/settings" className="grid h-9 w-9 place-items-center text-text-heading">
|
||||||
|
<Icon name="arrow_back_ios_new" className="text-[18px]" />
|
||||||
|
</Link>
|
||||||
|
<strong className="truncate text-center text-base font-semibold text-text-heading">通话记录</strong>
|
||||||
|
<span />
|
||||||
|
</header>
|
||||||
|
<header className="hidden h-15 flex-none items-center border-b border-border-panel bg-white px-6 md:flex">
|
||||||
<strong className="text-base font-semibold text-text-heading">通话记录</strong>
|
<strong className="text-base font-semibold text-text-heading">通话记录</strong>
|
||||||
</header>
|
</header>
|
||||||
<div className="flex flex-col gap-2 p-6">
|
<div className="flex flex-col gap-2 p-6">
|
||||||
{query.isLoading ? (
|
{query.isLoading ? (
|
||||||
<div className="py-10 text-center text-sm text-text-tertiary-4">加载中…</div>
|
<div className="py-10 text-center text-sm text-text-tertiary-4">加载中…</div>
|
||||||
|
) : query.isError ? (
|
||||||
|
<div className="flex flex-col items-center gap-2 py-10 text-sm text-danger-text">
|
||||||
|
<span>加载失败,请重试</span>
|
||||||
|
<button type="button" onClick={() => query.refetch()} className="text-accent">
|
||||||
|
重试
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
) : items.length === 0 ? (
|
) : items.length === 0 ? (
|
||||||
<div className="py-10 text-center text-sm text-text-tertiary-4">暂无通话记录</div>
|
<div className="py-10 text-center text-sm text-text-tertiary-4">暂无通话记录</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -375,8 +375,11 @@ function EndedCallOverlay() {
|
|||||||
return { label: durationMs > 0 ? `通话时长 ${formatCallDuration(durationMs)}` : '通话已结束', danger: false };
|
return { label: durationMs > 0 ? `通话时长 ${formatCallDuration(durationMs)}` : '通话已结束', danger: false };
|
||||||
case 'missed':
|
case 'missed':
|
||||||
return { label: wasCaller ? '对方未接听,已取消' : '未接听', danger: true };
|
return { label: wasCaller ? '对方未接听,已取消' : '未接听', danger: true };
|
||||||
|
// `wasCaller` here is `false` exactly when this device is the one that just clicked
|
||||||
|
// "拒绝" (rejectCall() only runs during `phase==='incoming'`) -- same distinction `missed`
|
||||||
|
// right above already makes, "对方已拒绝" is backwards on the decliner's own screen.
|
||||||
case 'declined':
|
case 'declined':
|
||||||
return { label: '对方已拒绝', danger: true };
|
return { label: wasCaller ? '对方已拒绝' : '已拒绝', danger: true };
|
||||||
case 'disconnected':
|
case 'disconnected':
|
||||||
return { label: '通话连接异常,已结束', danger: true };
|
return { label: '通话连接异常,已结束', danger: true };
|
||||||
case 'busy':
|
case 'busy':
|
||||||
|
|||||||
@@ -193,7 +193,7 @@ function FavoriteCard({ item, selectMode, selected, onToggle }: { item: Favorite
|
|||||||
{body}
|
{body}
|
||||||
{selectMode ? <SelectionCheckbox selected={selected} className="absolute -right-1 -top-1" /> : null}
|
{selectMode ? <SelectionCheckbox selected={selected} className="absolute -right-1 -top-1" /> : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between border-t border-border-list-item pt-2.5 text-xs text-text-tertiary-3">
|
<div className="flex items-center justify-between border-t border-border-row pt-2.5 text-xs text-text-tertiary-3">
|
||||||
<SourceLine source={item.source} />
|
<SourceLine source={item.source} />
|
||||||
<span className="flex-none">{formatEventTimestamp(new Date(item.collected_at))}</span>
|
<span className="flex-none">{formatEventTimestamp(new Date(item.collected_at))}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -271,8 +271,16 @@ export function FavoritesLayout() {
|
|||||||
},
|
},
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
invalidateAll();
|
invalidateAll();
|
||||||
exitSelectMode();
|
// Exiting select mode unconditionally used to clear `selected` even on a PARTIAL failure --
|
||||||
if (data?.failed?.length) setError(`${data.failed.length} 项删除失败,请重试`);
|
// the error toast named a count ("N项删除失败") but gave no way to act on it, so retrying
|
||||||
|
// meant re-selecting from scratch by memory. Only actually-failed ids stay selected instead,
|
||||||
|
// primed for an immediate retry.
|
||||||
|
if (data?.failed?.length) {
|
||||||
|
setSelected(new Set(data.failed.map((f) => f.favorite_id)));
|
||||||
|
setError(`${data.failed.length} 项删除失败,请重试`);
|
||||||
|
} else {
|
||||||
|
exitSelectMode();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onError: (err) => setError(err instanceof Error ? err.message : '删除失败'),
|
onError: (err) => setError(err instanceof Error ? err.message : '删除失败'),
|
||||||
});
|
});
|
||||||
@@ -284,10 +292,13 @@ export function FavoritesLayout() {
|
|||||||
forwardFavorites([...selected], targetTopics, comment),
|
forwardFavorites([...selected], targetTopics, comment),
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
setForwarding(false);
|
setForwarding(false);
|
||||||
exitSelectMode();
|
|
||||||
// Same `failed[]` gap as deleteMutation above (favorites.ts:101) -- forwarding to 3 targets can
|
// Same `failed[]` gap as deleteMutation above (favorites.ts:101) -- forwarding to 3 targets can
|
||||||
// succeed for 2 and fail for 1, and silently reporting "success" either way hides that.
|
// succeed for 2 and fail for 1, and silently reporting "success" either way (including
|
||||||
|
// clearing the favorites selection) hid that AND left nothing to retry from. The picker
|
||||||
|
// itself still closes (which of several targets failed isn't tracked here) but the source
|
||||||
|
// favorites stay selected so re-opening "转发" immediately can retry against the same set.
|
||||||
if (data.failed?.length) setError(`${data.failed.length} 项转发失败,请重试`);
|
if (data.failed?.length) setError(`${data.failed.length} 项转发失败,请重试`);
|
||||||
|
else exitSelectMode();
|
||||||
},
|
},
|
||||||
onError: (err) => setError(err instanceof Error ? err.message : '转发失败'),
|
onError: (err) => setError(err instanceof Error ? err.message : '转发失败'),
|
||||||
});
|
});
|
||||||
@@ -335,7 +346,7 @@ export function FavoritesLayout() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => switchCategory(c.key)}
|
onClick={() => switchCategory(c.key)}
|
||||||
className={`flex-none whitespace-nowrap rounded-2xl px-3.5 py-1.5 text-[13px] ${
|
className={`flex-none whitespace-nowrap rounded-2xl px-3.5 py-1.5 text-[13px] ${
|
||||||
category === c.key ? 'bg-accent text-white' : 'bg-[#f1f2f4] text-text-body'
|
category === c.key ? 'bg-accent text-white' : 'bg-[#f1f2f4] text-text-tertiary-1'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{c.label}
|
{c.label}
|
||||||
@@ -379,7 +390,7 @@ export function FavoritesLayout() {
|
|||||||
category === c.key ? 'bg-accent-selected-bg text-accent' : 'text-text-body hover:bg-[#f6f8fb]'
|
category === c.key ? 'bg-accent-selected-bg text-accent' : 'text-text-body hover:bg-[#f6f8fb]'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Icon name={c.icon} className="text-[18px]" />
|
<Icon name={c.icon} className={`text-[18px] ${category === c.key ? '' : 'text-text-tertiary-4'}`} />
|
||||||
<span className="flex-1 text-left">{c.label}</span>
|
<span className="flex-1 text-left">{c.label}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -117,10 +117,22 @@ export function SystemMessagesView() {
|
|||||||
// one page of notifications (a real, confirmed-live-possible infinite hang, not just a stale
|
// one page of notifications (a real, confirmed-live-possible infinite hang, not just a stale
|
||||||
// comment claiming otherwise). Re-reading the cache's own `next_cursor` each iteration instead
|
// comment claiming otherwise). Re-reading the cache's own `next_cursor` each iteration instead
|
||||||
// of trusting a captured object's field is what actually terminates.
|
// of trusting a captured object's field is what actually terminates.
|
||||||
|
// `fetchNextPage()` swallows its own request failures (TanStack Query v5's QueryObserver
|
||||||
|
// catches internally rather than rejecting) -- a page that fails to load leaves `next_cursor`
|
||||||
|
// exactly where it was, so a plain `while (cursor)` would retry the SAME stuck page forever on
|
||||||
|
// any transient failure (expired token, network blip), leaving `markingAll` stuck true and
|
||||||
|
// this button permanently showing "标记中…" with nothing telling the user why. Capped so a
|
||||||
|
// failure surfaces as an error instead of hanging.
|
||||||
let cursor = queryClient.getQueryData<typeof query.data>(['system-notifications'])?.pages.at(-1)?.next_cursor;
|
let cursor = queryClient.getQueryData<typeof query.data>(['system-notifications'])?.pages.at(-1)?.next_cursor;
|
||||||
while (cursor) {
|
let guard = 0;
|
||||||
|
while (cursor && guard++ < 200) {
|
||||||
await query.fetchNextPage();
|
await query.fetchNextPage();
|
||||||
cursor = queryClient.getQueryData<typeof query.data>(['system-notifications'])?.pages.at(-1)?.next_cursor;
|
const next = queryClient.getQueryData<typeof query.data>(['system-notifications'])?.pages.at(-1)?.next_cursor;
|
||||||
|
if (next === cursor) {
|
||||||
|
setError('翻页失败,请重试');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cursor = next;
|
||||||
}
|
}
|
||||||
const allItems = queryClient.getQueryData<typeof query.data>(['system-notifications'])?.pages.flatMap((p) => p.items) ?? [];
|
const allItems = queryClient.getQueryData<typeof query.data>(['system-notifications'])?.pages.flatMap((p) => p.items) ?? [];
|
||||||
// Each item's own failure already surfaces via readMutation's onError above (mutateAsync fires
|
// Each item's own failure already surfaces via readMutation's onError above (mutateAsync fires
|
||||||
@@ -155,7 +167,11 @@ export function SystemMessagesView() {
|
|||||||
all-pages total NavRail's own badge already trusts (and now invalidated by readMutation's
|
all-pages total NavRail's own badge already trusts (and now invalidated by readMutation's
|
||||||
onSuccess below, so it won't go stale either) -- preferred over the loaded-only count
|
onSuccess below, so it won't go stale either) -- preferred over the loaded-only count
|
||||||
whenever it's available. */}
|
whenever it's available. */}
|
||||||
{(bootstrap.data?.entry_badges?.system_unread_count ?? unread.length) ? (
|
{/* `??` only guards against a MISSING bootstrap count, not a STALE one -- bootstrap is
|
||||||
|
`staleTime: Infinity` and only ever invalidated in the "count goes down" direction
|
||||||
|
(readMutation's onSuccess below), so a cached `0` from before any new notification
|
||||||
|
arrived would otherwise out-vote a real non-empty `unread` this render actually has. */}
|
||||||
|
{Math.max(bootstrap.data?.entry_badges?.system_unread_count ?? 0, unread.length) ? (
|
||||||
<button type="button" onClick={markAllRead} disabled={markingAll} className="text-sm text-accent disabled:opacity-60">
|
<button type="button" onClick={markAllRead} disabled={markingAll} className="text-sm text-accent disabled:opacity-60">
|
||||||
{markingAll ? '标记中…' : '全部标为已读'}
|
{markingAll ? '标记中…' : '全部标为已读'}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -152,7 +152,12 @@ export const useCallStore = create<CallState>((set, get) => ({
|
|||||||
|
|
||||||
hangUp: () => {
|
hangUp: () => {
|
||||||
const { topic, seq, callId, connectedAt } = get();
|
const { topic, seq, callId, connectedAt } = get();
|
||||||
if (topic && seq != null && callId) sendCallHangup(getTinode().getTopic(topic), seq, callId);
|
// `rejectCall()` right above already covers the callee-declines-before-accepting case, so
|
||||||
|
// `hangUp()` seeing `!connectedAt` only ever means the CALLER is giving up on their own
|
||||||
|
// still-ringing outgoing call (manual cancel, or the 60s ring-timeout below firing this same
|
||||||
|
// function) -- IM-前后端对接文档.md's own wire example for exactly that case is
|
||||||
|
// `{note event:"hang-up", payload:{reason:"cancelled"}}`, which this never actually sent.
|
||||||
|
if (topic && seq != null && callId) sendCallHangup(getTinode().getTopic(topic), seq, callId, connectedAt ? undefined : 'cancelled');
|
||||||
leaveCall();
|
leaveCall();
|
||||||
get()._endWithReason(connectedAt ? 'finished' : 'missed');
|
get()._endWithReason(connectedAt ? 'finished' : 'missed');
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user