fix(web): 第9轮审查批次3——通讯录域一批问题

- 手机号搜索加好友时来源永远上报"search"(账号搜索),NewFriendsView
  早就有phone的文案映射但一直用不到——现在按searchType正确区分
- 添加好友弹层三个快捷按钮布局改成横向(原来是纵向堆叠,和设计稿方向
  不一致),图标颜色改成强调色
- "已选择"状态标签的圆角从胶囊改成6px小圆角,和同弹层其它按钮风格一致
- 通讯录头部"加好友"按钮补上移动端尺寸(40px/桌面36px),之前和旁边
  FIXED_ENTRIES的断点处理不一致
- 我的二维码/名片二维码生成失败没有重试入口,只能关掉弹层重开——补上
  重试按钮,同时处理"铸造失败"和"canvas绘制失败"两种情况
- 通讯录域几乎所有列表(好友列表/新朋友/群聊列表/黑名单/好友主页/加
  好友搜索)都把请求失败误判成"没有数据",统一补上isError态+重试按
  钮;好友主页额外区分403(资料保护)/404(真的不存在)/其它错误三种情况,
  之前网络错误也会显示"未找到该用户"

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-09-16 12:41:20 +08:00
co-authored by Claude Sonnet 5
parent 8f4b0de636
commit 18d21a03bb
7 changed files with 78 additions and 11 deletions
+17 -5
View File
@@ -47,7 +47,7 @@ export function AddFriendModal({ onClose, onSent, initialTarget }: AddFriendModa
// Tracks HOW `selected` was populated so the eventual sendFriendRequest can report the real
// provenance instead of always claiming "search" -- NewFriendsView's own SOURCE_LABEL map has a
// dedicated "扫码添加" entry for the qr case that was otherwise dead/unreachable.
const [selectedSource, setSelectedSource] = useState<'search' | 'qr'>('search');
const [selectedSource, setSelectedSource] = useState<'search' | 'phone' | 'qr'>('search');
const [message, setMessage] = useState('');
const [error, setError] = useState('');
const [sent, setSent] = useState(false);
@@ -194,9 +194,9 @@ export function AddFriendModal({ onClose, onSent, initialTarget }: AddFriendModa
key={action.label}
type="button"
onClick={action.onClick}
className="flex flex-col items-center gap-1.5 rounded-lg bg-surface-input py-3 text-xs text-text-secondary"
className="flex items-center justify-center gap-2 rounded-lg bg-surface-input py-3 text-xs text-text-secondary"
>
<Icon name={action.icon} className="text-xl text-text-tertiary-1" />
<Icon name={action.icon} className="text-xl text-accent" />
{action.label}
</button>
))}
@@ -208,6 +208,13 @@ export function AddFriendModal({ onClose, onSent, initialTarget }: AddFriendModa
<div className="mt-4 flex flex-col gap-2">
{searchQuery.isLoading ? (
<div className="py-6 text-center text-sm text-text-tertiary-4">搜索中…</div>
) : searchQuery.isError ? (
<div className="flex flex-col items-center gap-2 py-6 text-sm text-danger-text">
<span>搜索失败,请重试</span>
<button type="button" onClick={() => searchQuery.refetch()} className="text-accent">
重试
</button>
</div>
) : !searchQuery.data?.items.length ? (
<div className="py-6 text-center text-sm text-text-tertiary-4">未找到相关用户</div>
) : (
@@ -220,7 +227,12 @@ export function AddFriendModal({ onClose, onSent, initialTarget }: AddFriendModa
type="button"
disabled={!selectable}
onClick={() => {
setSelectedSource('search');
// `searchType` (already computed above from the query itself) is the real
// signal for which channel actually found this person -- NewFriendsView's
// own SOURCE_LABEL table already has a `phone` entry ("通过手机号搜索")
// that this path never reached because every search result was tagged
// 'search' regardless of how it was found.
setSelectedSource(searchType === 'phone' ? 'phone' : 'search');
setSelected(item);
}}
className="flex items-center gap-3 rounded-lg px-2 py-2 text-left disabled:opacity-70"
@@ -231,7 +243,7 @@ export function AddFriendModal({ onClose, onSent, initialTarget }: AddFriendModa
{item.public_account ? <span className="truncate text-xs text-text-tertiary-2">ID: {item.public_account}</span> : null}
</span>
{selectable ? (
<span className={`flex-none rounded-full px-2.5 py-1 text-xs ${isSelected ? 'bg-accent-soft-bg text-accent' : 'bg-surface-input text-text-tertiary-2'}`}>
<span className={`flex-none rounded-md px-2.5 py-1 text-xs ${isSelected ? 'bg-accent-soft-bg text-accent' : 'bg-surface-input text-text-tertiary-2'}`}>
{isSelected ? '已选择' : '选择'}
</span>
) : (
@@ -103,6 +103,13 @@ export function BlocklistView() {
<div className="flex flex-col gap-2.5 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>
) : (
@@ -65,7 +65,7 @@ export function ContactsListColumn() {
<aside className="flex h-full w-full flex-none flex-col border-r border-border-panel bg-white md:w-[300px] lg:w-80">
<header className="flex h-15 flex-none items-center justify-between px-5">
<h1 className="m-0 text-xl font-semibold text-text-heading">通讯录</h1>
<button type="button" onClick={() => setAddingFriend(true)} className="grid h-8 w-8 place-items-center text-text-heading">
<button type="button" onClick={() => setAddingFriend(true)} className="grid h-10 w-10 place-items-center text-text-heading md:h-9 md:w-9">
<Icon name="person_add_alt" />
</button>
</header>
@@ -119,6 +119,13 @@ export function ContactsListColumn() {
)}
{friendsQuery.isLoading ? (
<div className="flex flex-1 items-center justify-center text-sm text-text-tertiary-4">加载中…</div>
) : friendsQuery.isError ? (
<div className="flex flex-1 flex-col items-center justify-center gap-2 text-sm text-danger-text">
<span>加载失败,请重试</span>
<button type="button" onClick={() => friendsQuery.refetch()} className="text-accent">
重试
</button>
</div>
) : filtered.length === 0 ? (
<div className="flex flex-1 flex-col items-center justify-center gap-2 py-10 text-text-tertiary-4">
<Icon name={trimmedQuery ? 'search_off' : 'people_outline'} className="!text-4xl" />
@@ -70,12 +70,28 @@ export function FriendProfileView() {
// Confirmed live 2026-09-09: a 403 here comes back with reason PERMISSION_DENIED and message
// "profile is private" -- a real privacy setting on the other side, not a missing/deleted
// account. Collapsing it into "未找到该用户" would misrepresent an intentional privacy choice
// as if the user doesn't exist; every other failure (404, network error) keeps that fallback.
// as if the user doesn't exist.
const isPrivate = query.error instanceof ImApiError && query.error.status === 403;
// A genuine 404 is the only case that should actually read as "this user doesn't exist" --
// every OTHER failure (network error, 5xx, a transient timeout) used to fall into that same
// "未找到该用户" bucket too, telling the user an account is gone/never existed when the real
// problem is just that this one request failed and could succeed on retry.
const isNotFound = query.error instanceof ImApiError && query.error.status === 404;
return (
<div className="relative flex h-full w-full items-center justify-center bg-surface-content text-sm text-text-tertiary-4">
<div className="relative flex h-full w-full flex-col items-center justify-center gap-2 bg-surface-content text-sm text-text-tertiary-4">
{backLink}
{isPrivate ? '对方设置了资料保护,暂时无法查看' : '未找到该用户'}
{isPrivate ? (
'对方设置了资料保护,暂时无法查看'
) : isNotFound ? (
'未找到该用户'
) : (
<>
<span>加载失败,请重试</span>
<button type="button" onClick={() => query.refetch()} className="text-accent">
重试
</button>
</>
)}
</div>
);
}
@@ -66,6 +66,13 @@ export function GroupListView() {
<div className="flex flex-col gap-2.5 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>
) : filtered.length === 0 ? (
<div className="py-10 text-center text-sm text-text-tertiary-4">暂无群聊</div>
) : (
@@ -69,6 +69,13 @@ export function NewFriendsView() {
<div className="flex flex-col gap-3 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>
) : (
+13 -2
View File
@@ -35,15 +35,23 @@ export function QRCodeModal({ identifier, groupTopic, onClose }: QRCodeModalProp
queryFn: (): Promise<MyQRCode | GroupQRCode> => (groupTopic ? createGroupQRCode(groupTopic) : identifier ? createUserQRCode(identifier) : createMyQRCode()),
});
useEffect(() => {
const draw = () => {
if (!query.data || !canvasRef.current) return;
const canvas = canvasRef.current;
setDrawError('');
// Dynamically imported (like agora-rtc-sdk-ng in agora-client.ts) so a QR-generating library
// isn't in every visitor's main bundle when most sessions never open this modal.
import('qrcode')
.then(({ default: QRCode }) => QRCode.toCanvas(canvas, query.data.qr_payload, { width: 200, margin: 1, color: { dark: '#22262d', light: '#ffffff' } }))
.catch(() => setDrawError('二维码生成失败'));
}, [query.data]);
};
useEffect(draw, [query.data]);
const retry = () => {
if (query.isError) query.refetch();
else draw();
};
const profile = query.data && 'display_profile' in query.data ? query.data.display_profile : undefined;
const group = query.data && 'group_snapshot' in query.data ? query.data.group_snapshot : undefined;
@@ -117,6 +125,9 @@ export function QRCodeModal({ identifier, groupTopic, onClose }: QRCodeModalProp
<div className="flex flex-col items-center gap-2 px-4 text-center">
<Icon name="error_outline" className="text-2xl text-danger-text" />
<span className="text-xs text-text-tertiary-4">{drawError || '二维码生成失败,请重试'}</span>
<button type="button" onClick={retry} className="text-xs text-accent">
重试
</button>
</div>
) : (
<canvas ref={canvasRef} />