fix(web): code review round 1 remaining UI fixes

- EmojiPanel: add missing 手势/物品 categories, 12-column grid per README §3.3
- DetailColumn: add 投诉举报 row, real ID/region/签名 via new getUserProfile
  API, chevron_right icons now use --color-icon-arrow token (were tertiary
  text color), regroup rows so dividers only sit between sections not every
  row (matches design-code reference exactly)
- MessageBubble/ConversationView: right-clicked message bubble now gets the
  spec's outline while its context menu is open
- ChatListColumn/AppShell: pinned 系统消息/文件助手 rows using confirmed
  bootstrap entries/entry_badges topic names ("sys"/"slf"); route to the
  existing SectionPlaceholder pattern since their own dedicated layouts are
  Phase 8, not built yet

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-09-08 03:58:06 +08:00
co-authored by Claude Sonnet 5
parent a7da4dc079
commit 93d4077d1e
9 changed files with 191 additions and 42 deletions
+55 -5
View File
@@ -3,6 +3,7 @@ import { useState } from 'react';
import { Link, useParams } from 'react-router-dom';
import { Icon } from '@/app/Icon';
import { useBootstrap } from '@/lib/api/hooks';
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
import { formatMessagePreview } from '@/lib/messages/preview';
import { formatListTime } from '@/lib/messages/time';
@@ -10,20 +11,45 @@ import { useChatList } from '@/lib/tinode/hooks';
import { useSessionStore } from '@/stores/session';
/**
* Chat-list row sort: by `touched` (last activity) descending. Pinned-conversations-first isn't
* wired yet -- that's driven by the P2P conversation-settings API (GET/PATCH /conversations/{topic}/
* settings {pinned}), which the detail panel doesn't build until its "置顶聊天" toggle lands. The
* fixed 系统消息/文件助手 rows (README §2.1) are also still missing -- pinning them correctly needs
* to know which topic name(s) those actually are on this backend, which hasn't been confirmed yet.
* README §2.1's two always-pinned utility rows. Real, confirmed backend facts (docs/IM-前后端对接
* 文档.md:909): topic names are fixed ("slf"/"sys"), but 系统消息's unread comes from a separate
* notifications table (not the topic itself) and 文件助手's own dedicated 3-column layout (README
* §9.2) is Phase 8, not built yet -- so both route to the same SectionPlaceholder-backed "later
* phase" destinations the rest of the nav already uses (contacts/favorites/calls/settings), rather
* than a half-working ConversationView that assumes a regular P2P/group contact.
*/
const PINNED_ENTRIES = [
{
key: 'system_messages' as const,
badgeKey: 'system_unread_count' as const,
label: '系统消息',
icon: 'notifications',
route: '/chat/system',
colorClass: 'bg-warn',
},
{
key: 'file_assistant' as const,
badgeKey: 'file_assistant_unread_count' as const,
label: '文件助手',
icon: 'folder',
route: '/files',
colorClass: 'bg-success',
},
];
/** Chat-list row sort: by `touched` (last activity) descending. Pinned-conversations-first isn't
* wired yet -- that's driven by the P2P conversation-settings API (GET/PATCH /conversations/{topic}/
* settings {pinned}), which the detail panel doesn't build until its "置顶聊天" toggle lands. */
export function ChatListColumn() {
const { topicId } = useParams();
const myUserId = useSessionStore((s) => s.myUserId);
const contacts = useChatList();
const bootstrap = useBootstrap();
const [query, setQuery] = useState('');
const sorted = [...contacts].sort((a, b) => (b.touched?.getTime() ?? 0) - (a.touched?.getTime() ?? 0));
const trimmedQuery = query.trim();
const filtered = trimmedQuery ? sorted.filter((c) => (c.public?.fn || c.topic || '').toLowerCase().includes(trimmedQuery.toLowerCase())) : sorted;
const pinned = PINNED_ENTRIES.filter((entry) => bootstrap.data?.entries?.[entry.key]?.enabled);
return (
<aside className="flex h-full w-full flex-none flex-col border-r border-border-panel bg-white md:w-[300px] lg:w-80">
@@ -41,6 +67,30 @@ export function ChatListColumn() {
/>
</div>
<div className="flex flex-1 flex-col overflow-y-auto">
{pinned.map((entry) => {
const unread = bootstrap.data?.entry_badges?.[entry.badgeKey] || 0;
return (
<Link
key={entry.key}
to={entry.route}
className="grid min-h-[68px] grid-cols-[44px_minmax(0,1fr)_auto] items-center gap-3 border-b border-list-item bg-white px-4 py-2.5 no-underline hover:bg-[#f6f8fb]"
>
<span className={`grid h-11 w-11 place-items-center rounded-lg text-white ${entry.colorClass}`}>
<Icon name={entry.icon} className="text-[22px]" />
</span>
<span className="flex min-w-0 flex-col gap-1">
<strong className="truncate text-[15px] font-medium text-[#272b31]">{entry.label}</strong>
</span>
<span className="flex flex-none flex-col items-end justify-between gap-1.5">
{unread ? (
<b className="grid h-[18px] min-w-[18px] place-items-center rounded-full bg-unread px-1.5 text-[11px] font-medium text-white">
{unread}
</b>
) : null}
</span>
</Link>
);
})}
{filtered.length === 0 ? (
<div className="flex flex-1 flex-col items-center justify-center gap-2 text-text-tertiary-4">
<Icon name={trimmedQuery ? 'search_off' : 'forum'} className="!text-4xl" />