Files
jiang13-bbs/frontend/components/AvatarPicker.tsx
freefire 8c5de13899 fix: 空评论列表 500,并完善撤回审计与角色说明
空切片 JSON 统一为数组;撤回保留正文供站长阅读,成员侧居中提示与重新编辑;头像支持拖放;角色弹窗补全职责说明并汇入封禁。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-16 00:23:55 +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 }}
aria-label="更换头像"
title="更换头像"
>
<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}
/>
</>
);
}