修复收藏/系统消息/通话记录批次的代码审查问题
- 通话结果"已拒绝"文案按视角区分(拒绝方看到"已拒绝"而非"对方已拒绝") - 通话记录页补充移动端返回头部与加载失败重试态 - 挂断时按是否已接通传递正确的挂断原因(cancelled) - 系统消息"全部标为已读"翻页加循环护栏,防止游标卡死;按钮可见性改用真实未读总数 - 收藏批量删除/转发在部分失败时保留失败项以便重试,而非无条件退出多选模式 - 收藏卡片边框/分类文字/侧栏图标颜色token修正
This commit is contained in:
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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();
|
||||
// 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();
|
||||
if (data?.failed?.length) setError(`${data.failed.length} 项删除失败,请重试`);
|
||||
}
|
||||
},
|
||||
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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -152,7 +152,12 @@ export const useCallStore = create<CallState>((set, get) => ({
|
||||
|
||||
hangUp: () => {
|
||||
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();
|
||||
get()._endWithReason(connectedAt ? 'finished' : 'missed');
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user