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:
co-authored by
Claude Sonnet 5
parent
72b7b79caf
commit
5c3f3b7653
@@ -0,0 +1,517 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { Toast } from '@/app/Toast';
|
||||
import { AudioAttachment } from '@/features/messages/AttachmentPreview';
|
||||
import { ForwardPickerModal } from '@/features/messages/ForwardPickerModal';
|
||||
import {
|
||||
batchDeleteFavorites,
|
||||
deleteFavorite,
|
||||
favoriteEntityData,
|
||||
forwardFavorites,
|
||||
getFavorites,
|
||||
type Favorite,
|
||||
type FavoriteType,
|
||||
} from '@/lib/api/favorites';
|
||||
import { useAuthedBlobUrl, useDebouncedValue } from '@/lib/hooks';
|
||||
import { fileTypeStyle, formatFileSize } from '@/lib/messages/attachments';
|
||||
import { formatEventTimestamp } from '@/lib/messages/time';
|
||||
import { useChatList } from '@/lib/tinode/hooks';
|
||||
|
||||
const CATEGORIES: { key: FavoriteType | 'all'; label: string; icon: string }[] = [
|
||||
{ key: 'all', label: '全部', icon: 'all_inbox' },
|
||||
{ key: 'text', label: '文本', icon: 'notes' },
|
||||
{ key: 'image', label: '图片', icon: 'image' },
|
||||
{ key: 'video', label: '视频', icon: 'videocam' },
|
||||
{ key: 'voice', label: '语音', icon: 'mic' },
|
||||
{ key: 'file', label: '文件', icon: 'description' },
|
||||
{ key: 'link', label: '链接', icon: 'link' },
|
||||
];
|
||||
|
||||
/**
|
||||
* README §6.1's footer "陈明远 · 项目协作群" names both the sender AND the conversation -- the
|
||||
* confirmed real `source` shape (doc line 1933) only carries `{topic, seq}`, no sender field, so
|
||||
* this can only resolve the CONVERSATION side (via the chat list's own contact cache, same lookup
|
||||
* ChatListColumn/ConversationView already do) and shows just that, not a fabricated sender name.
|
||||
*
|
||||
* Confirmed live: a P2P favorite's `source.topic` comes back in the server's internal `p2p...`-
|
||||
* hashed form (e.g. "p2pH5dvZ3ckWxq4XhSKX6MIow"), not the peer's `usr...` id the chat list and
|
||||
* every route in this app actually key P2P conversations by -- the SDK's own `Topic.topicType()`
|
||||
* doesn't even recognize a "p2p" prefix (its table only has usr/grp/new/nch/chn/sys/slf), so this
|
||||
* is a REST-API-only naming quirk, disconnected from the WS/SDK convention. There's no client-side
|
||||
* way to reverse that hash back to the peer's user id, so a chat-list lookup by topic can never
|
||||
* match it -- showing the raw hash string would be confusing, so this falls back to a plain label
|
||||
* for that case instead of guessing.
|
||||
*/
|
||||
function SourceLine({ source }: { source: Favorite['source'] }) {
|
||||
const contacts = useChatList();
|
||||
if (!source) return null;
|
||||
const known = contacts.find((c) => c.topic === source.topic)?.public?.fn;
|
||||
// Same reasoning as the p2p fallback below -- a group this account has never actually opened as a
|
||||
// real conversation (e.g. joined via an admin action, never chatted in) has no entry in the local
|
||||
// chat-list cache either, and showing the raw internal topic id (`grpXXXXXXXX`) to the user reads
|
||||
// as a broken/leaked implementation detail, not a "no data" state -- fall back to a generic label.
|
||||
const fallback = source.topic.startsWith('p2p') ? '单聊消息' : source.topic.startsWith('grp') ? '群聊' : source.topic;
|
||||
return <span className="truncate">{known || fallback}</span>;
|
||||
}
|
||||
|
||||
function FavoriteCard({ item, selectMode, selected, onToggle }: { item: Favorite; selectMode: boolean; selected: boolean; onToggle: () => void }) {
|
||||
const removed = item.availability !== 'available';
|
||||
|
||||
const body = (() => {
|
||||
if (removed) return <div className="text-sm text-text-tertiary-4">该内容已失效或被删除</div>;
|
||||
switch (item.type) {
|
||||
case 'text':
|
||||
return (
|
||||
<p className="m-0 line-clamp-[5] max-h-[88px] overflow-hidden text-sm leading-[1.6] text-text-body">
|
||||
{item.snapshot.text || (typeof item.snapshot.content === 'string' ? item.snapshot.content : '')}
|
||||
</p>
|
||||
);
|
||||
case 'image':
|
||||
case 'video': {
|
||||
const data = favoriteEntityData(item.snapshot.content, item.type === 'video' ? 'VD' : 'IM');
|
||||
// Video's own `val` is the actual video bytes (webm/mp4) -- not paintable as an <img> --
|
||||
// the paintable poster frame is the separate inline `data.preview` (confirmed live: base64
|
||||
// JPEG signature); image has no separate poster since the image itself IS the thumbnail.
|
||||
const inlineVal = typeof (item.type === 'video' ? data?.preview : data?.val) === 'string' ? ((item.type === 'video' ? data?.preview : data?.val) as string) : undefined;
|
||||
const inlineMime = item.type === 'video' ? 'image/jpeg' : typeof data?.mime === 'string' ? data.mime : undefined;
|
||||
return (
|
||||
<MediaPreview
|
||||
inlineVal={inlineVal}
|
||||
inlineMime={inlineMime}
|
||||
fileId={item.snapshot.thumbnail_file_id || item.snapshot.file_id}
|
||||
isVideo={item.type === 'video'}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case 'voice': {
|
||||
// Reuses the same real, working audio player chat bubbles use (AttachmentPreview.tsx) --
|
||||
// this card used to be static (a bare play_circle icon with no click handler at all), so a
|
||||
// favorited voice message could never actually be played from this view.
|
||||
const data = favoriteEntityData(item.snapshot.content, 'AU');
|
||||
return (
|
||||
// Stops the play/pause click from also bubbling up to the card's own onClick -- in
|
||||
// selectMode that outer handler toggles selection, and without this, tapping play would
|
||||
// simultaneously start/stop audio AND select/deselect the card.
|
||||
<div className="rounded-lg bg-surface-rail px-3 py-2.5" onClick={(e) => e.stopPropagation()}>
|
||||
<AudioAttachment
|
||||
data={{
|
||||
val: typeof data?.val === 'string' ? data.val : undefined,
|
||||
ref: typeof data?.ref === 'string' ? data.ref : item.snapshot.file_id ? `/v0/file/s/${item.snapshot.file_id}` : undefined,
|
||||
mime: typeof data?.mime === 'string' ? data.mime : undefined,
|
||||
duration: typeof data?.duration === 'number' ? data.duration : item.snapshot.duration_ms,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case 'file': {
|
||||
const data = favoriteEntityData(item.snapshot.content, 'EX');
|
||||
const name = (typeof data?.name === 'string' ? data.name : undefined) || item.snapshot.file?.name || item.snapshot.name || '';
|
||||
const size = (typeof data?.size === 'number' ? data.size : undefined) ?? item.snapshot.file?.size ?? item.snapshot.size ?? 0;
|
||||
const mime = (typeof data?.mime === 'string' ? data.mime : undefined) || item.snapshot.file?.mime || item.snapshot.mime;
|
||||
const style = fileTypeStyle(name, mime);
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="grid h-10 w-10 flex-none place-items-center rounded-lg" style={{ backgroundColor: style.bg }}>
|
||||
<Icon name={style.icon} className="text-[20px]" style={{ color: style.fg }} />
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-col">
|
||||
<span className="truncate text-sm font-medium text-text-body">{name}</span>
|
||||
<span className="text-xs text-text-tertiary-2">{formatFileSize(size)}</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case 'link': {
|
||||
// Unlike image/video/voice/file, a Drafty `LN` entity annotates a RANGE of the message's own
|
||||
// text with a hyperlink (see drafty.tsx's own LN case) -- it has no separate title/thumbnail
|
||||
// data of its own the way a standalone attachment entity does. So a "link" favorite's real
|
||||
// snapshot is most likely just the SAME whole-message wrapper every other type turned out to
|
||||
// be (Part D's finding), meaning its own top-level `text` field (identical source the 'text'
|
||||
// case above already reads) is the one thing actually guaranteed to be there -- the flat
|
||||
// `title`/`url`/`thumbnail_file_id` fields below have never been confirmed against real data
|
||||
// and are kept only as an optional enhancement layer, not the sole source. Without this
|
||||
// fallback, a real link favorite whose backend response doesn't happen to populate those flat
|
||||
// fields rendered a card with a completely empty title line.
|
||||
const text = item.snapshot.text || (typeof item.snapshot.content === 'string' ? item.snapshot.content : '');
|
||||
const title = item.snapshot.title || item.snapshot.url || text;
|
||||
// Only show a second line when it says something the title line doesn't already -- a
|
||||
// hostname alongside a real title/url is useful, but repeating `text` under itself when
|
||||
// that's ALSO what the title line fell back to would just be the same line twice.
|
||||
const subtitle = item.snapshot.url ? new URL(item.snapshot.url).hostname : title === text ? '' : text;
|
||||
return (
|
||||
<div className="flex items-start gap-3">
|
||||
{item.snapshot.thumbnail_file_id ? (
|
||||
<span className="h-14 w-14 flex-none overflow-hidden rounded-lg bg-surface-rail">
|
||||
<MediaPreview fileId={item.snapshot.thumbnail_file_id} isVideo={false} square />
|
||||
</span>
|
||||
) : (
|
||||
<span className="grid h-14 w-14 flex-none place-items-center rounded-lg bg-surface-rail">
|
||||
<Icon name="link" className="text-[20px] text-text-tertiary-3" />
|
||||
</span>
|
||||
)}
|
||||
<span className="flex min-w-0 flex-col">
|
||||
<span className="truncate text-sm font-medium text-text-body">{title}</span>
|
||||
{subtitle ? <span className="truncate text-xs text-text-tertiary-2">{subtitle}</span> : null}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
// A plain <div> (not <button>) -- the voice card nests AudioAttachment's own real <button> inside
|
||||
// this, and a <button> can't legally contain another <button> (React logs a hydration-nesting
|
||||
// error for it, confirmed live). role/tabIndex/onKeyDown restore button semantics only while this
|
||||
// is actually clickable (selectMode); outside selectMode it's inert, same as before.
|
||||
return (
|
||||
<div
|
||||
role={selectMode ? 'button' : undefined}
|
||||
tabIndex={selectMode ? 0 : undefined}
|
||||
onClick={selectMode ? onToggle : undefined}
|
||||
onKeyDown={
|
||||
selectMode
|
||||
? (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onToggle();
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
className={`flex flex-col gap-3 rounded-xl border-2 bg-white p-4 text-left ${selected ? 'border-accent' : 'border-transparent'}`}
|
||||
>
|
||||
<div className="relative">
|
||||
{body}
|
||||
{selectMode ? (
|
||||
<span
|
||||
className={`absolute -right-1 -top-1 grid h-5 w-5 place-items-center rounded-full text-[13px] text-white ${
|
||||
selected ? 'bg-accent' : 'border-[1.5px] border-[#c5cad2] bg-white'
|
||||
}`}
|
||||
>
|
||||
{selected ? <Icon name="check" className="text-[14px]" /> : null}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex items-center justify-between border-t border-border-list-item 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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MediaPreview({
|
||||
fileId,
|
||||
inlineVal,
|
||||
inlineMime,
|
||||
isVideo,
|
||||
square,
|
||||
}: {
|
||||
fileId?: string;
|
||||
inlineVal?: string;
|
||||
inlineMime?: string;
|
||||
isVideo: boolean;
|
||||
square?: boolean;
|
||||
}) {
|
||||
// Real favorites carry the thumbnail inline as base64 (see favorites.ts's `Favorite` doc comment);
|
||||
// `fileId` is kept as a fallback for a hypothetical external-ref-only variant, never observed live.
|
||||
const inline = inlineVal ? `data:${inlineMime || 'image/jpeg'};base64,${inlineVal}` : undefined;
|
||||
const remote = useAuthedBlobUrl(inline ? undefined : fileId ? `/v0/file/s/${fileId}` : undefined);
|
||||
const src = inline || remote.url;
|
||||
return (
|
||||
<div className={`relative overflow-hidden rounded-lg bg-surface-rail ${square ? 'h-14 w-14' : 'h-[120px] w-full'}`}>
|
||||
{src ? (
|
||||
<img src={src} alt="" className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<div className="grid h-full w-full place-items-center">
|
||||
<Icon name={remote.error ? 'broken_image' : 'image'} className="text-[24px] text-text-tertiary-3" />
|
||||
</div>
|
||||
)}
|
||||
{isVideo && src ? <Icon name="play_circle" className="absolute inset-0 m-auto text-[32px] text-white/90" /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* README §6.1 (3-column: Nav / 分类 260px / 内容). `favoriteMessages` (the create side, called from
|
||||
* a message's context menu) lives in messages.ts; this is the read/manage side, new in this pass.
|
||||
*/
|
||||
export function FavoritesLayout() {
|
||||
const queryClient = useQueryClient();
|
||||
const [category, setCategory] = useState<FavoriteType | 'all'>('all');
|
||||
const [query, setQuery] = useState('');
|
||||
const debouncedQuery = useDebouncedValue(query, 300);
|
||||
const [selectMode, setSelectMode] = useState(false);
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [forwarding, setForwarding] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const favoritesQuery = useInfiniteQuery({
|
||||
queryKey: ['favorites', category, debouncedQuery],
|
||||
queryFn: ({ pageParam }) => getFavorites(category, debouncedQuery, pageParam),
|
||||
initialPageParam: '',
|
||||
getNextPageParam: (lastPage) => lastPage.next_cursor || undefined,
|
||||
});
|
||||
const items = favoritesQuery.data?.pages.flatMap((p) => p.items) ?? [];
|
||||
|
||||
const invalidateAll = () => queryClient.invalidateQueries({ queryKey: ['favorites'] });
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
// Both API shapes are declared with an optional `failed[]` (favorites.ts:92) -- a 200 here only
|
||||
// means the request was accepted, not that every selected item was actually deleted. Returning
|
||||
// the real response (was previously discarded entirely) so onSuccess can check it, same pattern
|
||||
// already fixed for BlocklistView's batch-unblock/GroupAdminModal elsewhere in this app.
|
||||
mutationFn: async (ids: string[]) => {
|
||||
if (ids.length === 1) {
|
||||
await deleteFavorite(ids[0]);
|
||||
return null;
|
||||
}
|
||||
return batchDeleteFavorites(ids);
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
invalidateAll();
|
||||
exitSelectMode();
|
||||
if (data?.failed?.length) setError(`${data.failed.length} 项删除失败,请重试`);
|
||||
},
|
||||
onError: (err) => setError(err instanceof Error ? err.message : '删除失败'),
|
||||
});
|
||||
const forwardMutation = useMutation({
|
||||
// forwardFavorites already accepts multiple target_topics + a comment natively -- this used to
|
||||
// wrap a single topic in a 1-element array, silently discarding the picker's ability to select
|
||||
// several recipients or attach a comment.
|
||||
mutationFn: ({ targetTopics, comment }: { targetTopics: string[]; comment: string }) =>
|
||||
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.
|
||||
if (data.failed?.length) setError(`${data.failed.length} 项转发失败,请重试`);
|
||||
},
|
||||
onError: (err) => setError(err instanceof Error ? err.message : '转发失败'),
|
||||
});
|
||||
|
||||
// Switching category mid-multi-select used to leave the OLD category's selections in `selected`
|
||||
// silently intact (nothing in this state ever cleared them) -- the header's "已选择 N 项" count and
|
||||
// any 转发/删除 action would then silently include items the user can no longer even see. Category
|
||||
// switches always start a fresh selection instead.
|
||||
const switchCategory = (key: FavoriteType | 'all') => {
|
||||
setCategory(key);
|
||||
setSelected(new Set());
|
||||
};
|
||||
|
||||
// Same reasoning as switchCategory above, for the OTHER thing that changes which items are even
|
||||
// visible -- typing a search query re-filters the list just like picking a category does, and had
|
||||
// the identical gap (nothing here ever cleared `selected` when the query changed).
|
||||
const handleQueryChange = (value: string) => {
|
||||
setQuery(value);
|
||||
setSelected(new Set());
|
||||
};
|
||||
|
||||
const toggle = (id: string) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
const exitSelectMode = () => {
|
||||
setSelectMode(false);
|
||||
setSelected(new Set());
|
||||
};
|
||||
const bulkDelete = () => {
|
||||
if (selected.size === 0) return;
|
||||
if (!window.confirm(`删除已选择的 ${selected.size} 项收藏?`)) return;
|
||||
deleteMutation.mutate([...selected]);
|
||||
};
|
||||
|
||||
const categoryPicker = (
|
||||
<div className="flex gap-2 overflow-x-auto px-4 pb-3">
|
||||
{CATEGORIES.map((c) => (
|
||||
<button
|
||||
key={c.key}
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
{c.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
const searchBox = (
|
||||
<div className="flex h-9 items-center rounded-lg bg-surface-search px-3">
|
||||
<Icon name="search" className="text-[18px] text-text-placeholder-2" />
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => handleQueryChange(e.target.value)}
|
||||
placeholder="搜索收藏内容"
|
||||
className="ml-2 w-full bg-transparent text-sm text-text-body outline-none placeholder:text-text-placeholder-2"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full">
|
||||
{/* README §6.1's 3-column desktop layout (nav / 260px category rail / content). Below `md`
|
||||
there's no room to dock a 260px rail next to content at all -- screens/05-favorites-
|
||||
history.dc.html's own mobile mock (#1d) doesn't try to: it's a SINGLE screen with a
|
||||
horizontal pill category-switcher inline above the list, not a separate "pick a category"
|
||||
screen you navigate away from. This aside is desktop-only; its mobile equivalent is the
|
||||
pill row rendered inline in the content column below. */}
|
||||
<aside className="hidden h-full w-[260px] flex-none flex-col border-r border-border-panel bg-white md:flex">
|
||||
<header className="flex h-15 flex-none items-center px-5">
|
||||
<h1 className="m-0 text-xl font-semibold text-text-heading">我的收藏</h1>
|
||||
</header>
|
||||
<div className="mx-3 mb-2.5">{searchBox}</div>
|
||||
<div className="flex flex-col gap-0.5 px-3">
|
||||
{CATEGORIES.map((c) => (
|
||||
<button
|
||||
key={c.key}
|
||||
type="button"
|
||||
onClick={() => switchCategory(c.key)}
|
||||
className={`flex items-center gap-2.5 rounded-lg px-3 py-2.5 text-sm ${
|
||||
category === c.key ? 'bg-accent-selected-bg text-accent' : 'text-text-body hover:bg-[#f6f8fb]'
|
||||
}`}
|
||||
>
|
||||
<Icon name={c.icon} className="text-[18px]" />
|
||||
<span className="flex-1 text-left">{c.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className="flex h-full min-w-0 flex-1 flex-col bg-surface-content">
|
||||
{selectMode ? (
|
||||
<header className="flex h-15 flex-none items-center justify-between border-b border-border-panel bg-white px-4 md:px-6">
|
||||
<span className="text-sm text-text-secondary">
|
||||
已选择 <strong className="font-semibold text-accent">{selected.size}</strong> 项
|
||||
</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setForwarding(true)}
|
||||
disabled={selected.size === 0}
|
||||
className="flex h-8.5 items-center gap-1.5 whitespace-nowrap rounded-md border border-[#d5d9df] px-3.5 text-[13px] text-text-tertiary-1 disabled:opacity-40"
|
||||
>
|
||||
<Icon name="reply" className="text-[18px]" />
|
||||
转发
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={bulkDelete}
|
||||
disabled={selected.size === 0}
|
||||
className="flex h-8.5 items-center gap-1.5 whitespace-nowrap rounded-md border border-danger-border-soft px-3.5 text-[13px] text-danger-text disabled:opacity-40"
|
||||
>
|
||||
<Icon name="delete_outline" className="text-[18px]" />
|
||||
删除
|
||||
</button>
|
||||
<button type="button" onClick={exitSelectMode} className="grid h-8.5 place-items-center px-3.5 text-[13px] text-text-tertiary-1">
|
||||
取消
|
||||
</button>
|
||||
</span>
|
||||
</header>
|
||||
) : (
|
||||
<>
|
||||
{/* Mobile-only header -- `/favorites` is only reachable on a narrow viewport via
|
||||
设置(我的)>我的收藏 (SettingsLayout.tsx), so "back" returns there, matching every
|
||||
other narrow-only-reachable-via-parent-list page's own back-arrow convention. */}
|
||||
<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>
|
||||
{items.length ? (
|
||||
<button type="button" onClick={() => setSelectMode(true)} className="text-sm text-accent">
|
||||
多选
|
||||
</button>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
</header>
|
||||
<div className="flex-none border-b border-border-panel pt-3 md:hidden">
|
||||
<div className="px-4">{searchBox}</div>
|
||||
{categoryPicker}
|
||||
</div>
|
||||
|
||||
<header className="hidden h-15 flex-none items-center justify-between border-b border-border-panel bg-white px-6 md:flex">
|
||||
<strong className="text-base font-semibold text-text-heading">{CATEGORIES.find((c) => c.key === category)?.label}</strong>
|
||||
{items.length ? (
|
||||
<button type="button" onClick={() => setSelectMode(true)} className="text-sm text-accent">
|
||||
多选
|
||||
</button>
|
||||
) : null}
|
||||
</header>
|
||||
</>
|
||||
)}
|
||||
<div className="flex-1 overflow-y-auto p-4 md:p-6">
|
||||
{favoritesQuery.isLoading ? (
|
||||
<div className="py-10 text-center text-sm text-text-tertiary-4">加载中…</div>
|
||||
) : favoritesQuery.isError ? (
|
||||
<div className="flex flex-col items-center justify-center gap-2 py-20 text-danger-text">
|
||||
<Icon name="error_outline" className="!text-4xl" />
|
||||
<span className="text-sm">加载失败,请重试</span>
|
||||
<button type="button" onClick={() => favoritesQuery.refetch()} className="text-sm text-accent">
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center gap-2 py-20 text-text-tertiary-4">
|
||||
<Icon name="inventory_2" className="!text-4xl" />
|
||||
<span className="text-sm">暂无收藏内容</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* 1 column under 640px (matches the mobile mock's plain vertical card list exactly),
|
||||
3 at md+ (matches the desktop mock's 3-column grid); 2 in between is this app's own
|
||||
judgment call, not in either mock, for the range where 1 column would waste width
|
||||
and 3 would be as cramped as the bug this replaces. */}
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-3 md:gap-4">
|
||||
{items.map((item) => (
|
||||
<FavoriteCard
|
||||
key={item.favorite_id}
|
||||
item={item}
|
||||
selectMode={selectMode}
|
||||
selected={selected.has(item.favorite_id)}
|
||||
onToggle={() => toggle(item.favorite_id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{favoritesQuery.hasNextPage ? (
|
||||
<div className="pt-4 text-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => favoritesQuery.fetchNextPage()}
|
||||
disabled={favoritesQuery.isFetchingNextPage}
|
||||
className="text-sm text-accent disabled:opacity-60"
|
||||
>
|
||||
{favoritesQuery.isFetchingNextPage ? '加载中…' : '加载更多'}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{forwarding ? (
|
||||
<ForwardPickerModal
|
||||
onClose={() => setForwarding(false)}
|
||||
onPick={(targetTopics, comment) => forwardMutation.mutate({ targetTopics, comment })}
|
||||
/>
|
||||
) : null}
|
||||
<Toast message={error} variant="error" onDone={() => setError('')} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user