Files
jiang13-bbs/frontend/components/AvatarPicker.tsx
freefire b9ba8cb8c9 feat: 类型帖互动、积分经济与发帖体验
补齐问答采纳/重开、投票匿名与有票禁编、悬赏过期退回、抽奖回帖开奖,并接入积分账本与发帖附件可见性。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-16 03:17:07 +08:00

100 lines
3.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { Camera } from "lucide-react";
import Avatar from "@/components/Avatar";
import AvatarCropModal from "@/components/AvatarCropModal";
import { emitUserUpdate, onUserUpdate } from "@/lib/userEvents";
interface AvatarPickerProps {
name: string;
src: string;
size?: number;
/** 外部拖入的文件:非空时打开裁剪弹窗并进入裁剪 */
incomingFile?: File | null;
onIncomingConsumed?: () => void;
}
/**
* 可点击的头像(资料设置表单内的唯一修改入口)。
*
* 同步策略(三条链路缺一不可):
* 1. 弹窗内上传/选用/删除成功 → 本组件 state 立即更新(本地即时反馈)
* 2. emitUserUpdate 广播 → Header 等同页其他客户端组件立即更新
* 3. router.refresh() → 服务端渲染区域(Hero、帖子列表等)重新取数
* 另外监听广播,使页面上若存在多个 picker 实例也保持一致;
* 并随 src prop(SSR 校正)同步,避免 useState 初始值造成数据漂移。
*/
export default function AvatarPicker({
name, src, size = 56, incomingFile, onIncomingConsumed,
}: AvatarPickerProps) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [current, setCurrent] = useState(src);
const [seedFile, setSeedFile] = useState<File | null>(null);
// SSR props 校正(router.refresh 后服务端数据回流)
useEffect(() => setCurrent(src), [src]);
// 同页其他位置(或另一实例)改了头像 → 同步本实例
useEffect(() => onUserUpdate((patch) => {
if (typeof patch.avatar === "string") setCurrent(patch.avatar);
}), []);
// 外层拖入:打开弹窗并交给裁剪流程
useEffect(() => {
if (!incomingFile) return;
setSeedFile(incomingFile);
setOpen(true);
}, [incomingFile]);
const handleChanged = (avatar: string) => {
setCurrent(avatar);
setOpen(false);
setSeedFile(null);
emitUserUpdate({ avatar });
router.refresh();
};
const handleSeedConsumed = () => {
setSeedFile(null);
onIncomingConsumed?.();
};
return (
<>
<button
type="button"
onClick={() => setOpen(true)}
className="group relative inline-flex items-center justify-center shrink-0 p-0 rounded-full cursor-pointer outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--panel)]"
style={{ width: size, height: size, lineHeight: 0 }}
data-tip="更换头像"
aria-label="更换头像"
>
<Avatar name={name} src={current || undefined} size={size} />
{/* hover/focus 遮罩:与头像严格重合(按钮宽高固定 = size) */}
<span
className="absolute inset-0 m-0 rounded-full flex flex-col items-center justify-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none"
style={{ background: "color-mix(in srgb, var(--ink) 55%, transparent)", color: "#fff" }}
>
<Camera size={Math.round(size * 0.27)} />
<span className="text-[10px] font-bold leading-none">更换</span>
</span>
</button>
<AvatarCropModal
open={open}
onClose={() => {
setOpen(false);
setSeedFile(null);
}}
currentAvatar={current}
onChanged={handleChanged}
seedFile={seedFile}
onSeedConsumed={handleSeedConsumed}
/>
</>
);
}