修复收藏/系统消息/通话记录批次的代码审查问题

- 通话结果"已拒绝"文案按视角区分(拒绝方看到"已拒绝"而非"对方已拒绝")
- 通话记录页补充移动端返回头部与加载失败重试态
- 挂断时按是否已接通传递正确的挂断原因(cancelled)
- 系统消息"全部标为已读"翻页加循环护栏,防止游标卡死;按钮可见性改用真实未读总数
- 收藏批量删除/转发在部分失败时保留失败项以便重试,而非无条件退出多选模式
- 收藏卡片边框/分类文字/侧栏图标颜色token修正
This commit is contained in:
Your Name
2026-09-16 12:54:40 +08:00
parent 97e8d7144f
commit 29a1f1c2fd
5 changed files with 67 additions and 14 deletions
+20 -2
View File
@@ -1,5 +1,5 @@
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 { getCallHistory, type CallHistoryItem } from '@/lib/api/calls';
@@ -45,12 +45,30 @@ export function CallHistoryView() {
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">
{/* `/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>
</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>
) : 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 ? (
<div className="py-10 text-center text-sm text-text-tertiary-4">暂无通话记录</div>
) : (
+4 -1
View File
@@ -375,8 +375,11 @@ function EndedCallOverlay() {
return { label: durationMs > 0 ? `通话时长 ${formatCallDuration(durationMs)}` : '通话已结束', danger: false };
case 'missed':
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':
return { label: '对方已拒绝', danger: true };
return { label: wasCaller ? '对方已拒绝' : '已拒绝', danger: true };
case 'disconnected':
return { label: '通话连接异常,已结束', danger: true };
case 'busy':
+18 -7
View File
@@ -193,7 +193,7 @@ function FavoriteCard({ item, selectMode, selected, onToggle }: { item: Favorite
{body}
{selectMode ? <SelectionCheckbox selected={selected} className="absolute -right-1 -top-1" /> : null}
</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} />
<span className="flex-none">{formatEventTimestamp(new Date(item.collected_at))}</span>
</div>
@@ -271,8 +271,16 @@ export function FavoritesLayout() {
},
onSuccess: (data) => {
invalidateAll();
exitSelectMode();
if (data?.failed?.length) setError(`${data.failed.length} 项删除失败,请重试`);
// Exiting select mode unconditionally used to clear `selected` even on a PARTIAL failure --
// 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 : '删除失败'),
});
@@ -284,10 +292,13 @@ export function FavoritesLayout() {
forwardFavorites([...selected], targetTopics, comment),
onSuccess: (data) => {
setForwarding(false);
exitSelectMode();
// 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} 项转发失败,请重试`);
else exitSelectMode();
},
onError: (err) => setError(err instanceof Error ? err.message : '转发失败'),
});
@@ -335,7 +346,7 @@ export function FavoritesLayout() {
type="button"
onClick={() => switchCategory(c.key)}
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}
@@ -379,7 +390,7 @@ export function FavoritesLayout() {
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>
</button>
))}
+19 -3
View File
@@ -117,10 +117,22 @@ export function SystemMessagesView() {
// 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
// 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;
while (cursor) {
let guard = 0;
while (cursor && guard++ < 200) {
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) ?? [];
// 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
onSuccess below, so it won't go stale either) -- preferred over the loaded-only count
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">
{markingAll ? '标记中…' : '全部标为已读'}
</button>