- 手机号搜索加好友时来源永远上报"search"(账号搜索),NewFriendsView 早就有phone的文案映射但一直用不到——现在按searchType正确区分 - 添加好友弹层三个快捷按钮布局改成横向(原来是纵向堆叠,和设计稿方向 不一致),图标颜色改成强调色 - "已选择"状态标签的圆角从胶囊改成6px小圆角,和同弹层其它按钮风格一致 - 通讯录头部"加好友"按钮补上移动端尺寸(40px/桌面36px),之前和旁边 FIXED_ENTRIES的断点处理不一致 - 我的二维码/名片二维码生成失败没有重试入口,只能关掉弹层重开——补上 重试按钮,同时处理"铸造失败"和"canvas绘制失败"两种情况 - 通讯录域几乎所有列表(好友列表/新朋友/群聊列表/黑名单/好友主页/加 好友搜索)都把请求失败误判成"没有数据",统一补上isError态+重试按 钮;好友主页额外区分403(资料保护)/404(真的不存在)/其它错误三种情况, 之前网络错误也会显示"未找到该用户" Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
166 lines
8.3 KiB
TypeScript
166 lines
8.3 KiB
TypeScript
import { useEffect, useRef, useState } from 'react';
|
||
|
||
import { useQuery } from '@tanstack/react-query';
|
||
|
||
import { Icon } from '@/app/Icon';
|
||
import { createGroupQRCode, createMyQRCode, createUserQRCode, type GroupQRCode, type MyQRCode } from '@/lib/api/qrcodes';
|
||
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
|
||
import { useEscapeKey } from '@/lib/hooks';
|
||
|
||
interface QRCodeModalProps {
|
||
/** Omit both to show the CURRENT user's own code; pass a friend's user_id/account to show theirs
|
||
* (README's "二维码名片" row on a friend's profile). */
|
||
identifier?: string;
|
||
/** 03-group.dc.html #1a's `qr_code_2` icon on the group-info card (admin/owner-visible group-join
|
||
* code) -- mutually exclusive with `identifier`, a group is never also a user. */
|
||
groupTopic?: string;
|
||
onClose: () => void;
|
||
}
|
||
|
||
/**
|
||
* README §5.4/§8.2's "我的二维码"/"二维码名片", plus the group-join code (03-group.dc.html #1a). The
|
||
* QR image encodes `qr_payload` (`im://qr?token=...&purpose=add_friend|join_group`), never a raw
|
||
* user/group id -- confirmed live for the personal-code path that this is a real, working backend
|
||
* feature (`POST /me/qrcodes` / `POST /users/{id}/qrcodes`), not a documented-but-unbuilt one; the
|
||
* group-code path (`POST /groups/{topic}/qrcodes`) is the same documented shape (docs/IM-前后端对接
|
||
* 文档.md:1590,1737) but not yet live-verified against a real deployment. Re-mints a fresh token on
|
||
* every mount (POST semantics, not cached) since each one is short-lived (~24h) and single-purpose.
|
||
*/
|
||
export function QRCodeModal({ identifier, groupTopic, onClose }: QRCodeModalProps) {
|
||
useEscapeKey(onClose);
|
||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||
const [drawError, setDrawError] = useState('');
|
||
const query = useQuery<MyQRCode | GroupQRCode>({
|
||
queryKey: ['qrcode', groupTopic ? `group:${groupTopic}` : identifier || 'me'],
|
||
queryFn: (): Promise<MyQRCode | GroupQRCode> => (groupTopic ? createGroupQRCode(groupTopic) : identifier ? createUserQRCode(identifier) : createMyQRCode()),
|
||
});
|
||
|
||
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('二维码生成失败'));
|
||
};
|
||
|
||
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;
|
||
const title = groupTopic ? '群二维码' : identifier ? '二维码名片' : '我的二维码';
|
||
const fileBaseName = groupTopic ? '群二维码' : identifier ? '二维码名片' : '我的二维码';
|
||
const caption = groupTopic ? '扫一扫上面的二维码,申请加入群聊' : '扫一扫上面的二维码,加我为好友';
|
||
|
||
const canvasToBlob = (): Promise<Blob | null> => new Promise((resolve) => canvasRef.current?.toBlob((b) => resolve(b), 'image/png'));
|
||
|
||
const saveToAlbum = async () => {
|
||
const blob = await canvasToBlob();
|
||
if (!blob) return;
|
||
const url = URL.createObjectURL(blob);
|
||
const link = document.createElement('a');
|
||
link.href = url;
|
||
link.download = `${fileBaseName}.png`;
|
||
link.click();
|
||
URL.revokeObjectURL(url);
|
||
};
|
||
|
||
const shareToFriend = async () => {
|
||
const blob = await canvasToBlob();
|
||
if (!blob) return;
|
||
const file = new File([blob], 'qrcode.png', { type: 'image/png' });
|
||
// Real capability check, not just feature-detecting `navigator.share` itself -- some browsers
|
||
// implement `share()` for URLs/text only and reject a `files` payload at call time, so
|
||
// `canShare({ files })` is the one call that actually answers "can this share an image".
|
||
if (navigator.canShare?.({ files: [file] })) {
|
||
try {
|
||
await navigator.share({ files: [file], title: fileBaseName });
|
||
} catch {
|
||
// AbortError (user cancelled the native share sheet) or any other failure -- either way
|
||
// there's nothing useful to surface, the share sheet's own UI already communicated the outcome.
|
||
}
|
||
} else {
|
||
await saveToAlbum();
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#22262d]/25" onClick={onClose}>
|
||
<div className="flex w-[360px] flex-col rounded-xl bg-white shadow-modal" onClick={(e) => e.stopPropagation()}>
|
||
<header className="flex h-13 flex-none items-center justify-between border-b border-border-list-item px-5">
|
||
<strong className="text-[15px] font-semibold text-text-heading">{title}</strong>
|
||
<button type="button" onClick={onClose} className="grid h-8 w-8 place-items-center text-text-tertiary-4">
|
||
<Icon name="close" />
|
||
</button>
|
||
</header>
|
||
<div className="flex flex-col items-center gap-3 px-6 py-7">
|
||
{profile ? (
|
||
<>
|
||
<span className={`grid h-14 w-14 flex-none place-items-center rounded-xl text-xl font-medium text-white ${avatarBgClass(profile.user_id)}`}>
|
||
{avatarLetter(profile.display_name)}
|
||
</span>
|
||
<strong className="text-base font-semibold text-text-heading">{profile.display_name}</strong>
|
||
{profile.public_account ? <span className="text-xs text-text-tertiary-2">ID:{profile.public_account}</span> : null}
|
||
</>
|
||
) : group ? (
|
||
<>
|
||
<span className={`grid h-14 w-14 flex-none place-items-center rounded-xl text-xl font-medium text-white ${avatarBgClass(group.topic)}`}>
|
||
{avatarLetter(group.name)}
|
||
</span>
|
||
<strong className="text-base font-semibold text-text-heading">{group.name}</strong>
|
||
<span className="text-xs text-text-tertiary-2">{group.member_count} 人</span>
|
||
</>
|
||
) : null}
|
||
<div className="mt-2 grid h-[220px] w-[220px] place-items-center rounded-lg border border-border-panel">
|
||
{query.isLoading ? (
|
||
<span className="text-sm text-text-tertiary-4">生成中…</span>
|
||
) : query.isError || drawError ? (
|
||
<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} />
|
||
)}
|
||
</div>
|
||
<span className="text-xs text-text-tertiary-3">{caption}</span>
|
||
{/* No mock anywhere in this design handoff draws these two buttons (grepped every
|
||
screens/*.dc.html) -- added on the strength of a near-universal QR-display convention
|
||
(save/share), not a documented spec. Reuses the `share` icon the add-friend chip
|
||
(04-contacts.dc.html:101) already uses for the same verb elsewhere in this app. */}
|
||
{query.data && !drawError ? (
|
||
<div className="mt-1 flex gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={saveToAlbum}
|
||
className="flex h-8.5 items-center gap-1.5 rounded-md border border-[#d5d9df] px-3.5 text-[13px] text-text-secondary"
|
||
>
|
||
<Icon name="download" className="text-[16px]" />
|
||
保存到相册
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={shareToFriend}
|
||
className="flex h-8.5 items-center gap-1.5 rounded-md border border-[#d5d9df] px-3.5 text-[13px] text-text-secondary"
|
||
>
|
||
<Icon name="share" className="text-[16px]" />
|
||
分享好友
|
||
</button>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|