- 新增附件模型与上传服务,拆分头像上传/管理逻辑 - 重构用户资料接口,移除直接修改头像字段,改用专用上传接口 - 添加站点公开设置API与管理员外观配置页 - 全局替换hover背景为accent-soft,优化交互一致性 - 完善头像组件,支持图片加载失败回退与src更新重置 - 新增用户状态事件总线,解决同页头像更新不及时问题 - 调整依赖包顺序与CI依赖版本 - 重构板块列表接口,新增帖子计数 - 迁移账号设置到用户主页tab,合并重复代码
76 lines
2.8 KiB
TypeScript
76 lines
2.8 KiB
TypeScript
"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;
|
||
}
|
||
|
||
/**
|
||
* 可点击的头像(资料设置表单内的唯一修改入口)。
|
||
*
|
||
* 同步策略(三条链路缺一不可):
|
||
* 1. 弹窗内上传/选用/删除成功 → 本组件 state 立即更新(本地即时反馈)
|
||
* 2. emitUserUpdate 广播 → Header 等同页其他客户端组件立即更新
|
||
* 3. router.refresh() → 服务端渲染区域(Hero、帖子列表等)重新取数
|
||
* 另外监听广播,使页面上若存在多个 picker 实例也保持一致;
|
||
* 并随 src prop(SSR 校正)同步,避免 useState 初始值造成数据漂移。
|
||
*/
|
||
export default function AvatarPicker({ name, src, size = 56 }: AvatarPickerProps) {
|
||
const router = useRouter();
|
||
const [open, setOpen] = useState(false);
|
||
const [current, setCurrent] = useState(src);
|
||
|
||
// SSR props 校正(router.refresh 后服务端数据回流)
|
||
useEffect(() => setCurrent(src), [src]);
|
||
|
||
// 同页其他位置(或另一实例)改了头像 → 同步本实例
|
||
useEffect(() => onUserUpdate((patch) => {
|
||
if (typeof patch.avatar === "string") setCurrent(patch.avatar);
|
||
}), []);
|
||
|
||
const handleChanged = (avatar: string) => {
|
||
setCurrent(avatar);
|
||
setOpen(false);
|
||
emitUserUpdate({ avatar });
|
||
router.refresh();
|
||
};
|
||
|
||
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)}
|
||
currentAvatar={current}
|
||
onChanged={handleChanged}
|
||
/>
|
||
</>
|
||
);
|
||
}
|