feat: 完成头像上传、站点主题配置与UI优化
- 新增附件模型与上传服务,拆分头像上传/管理逻辑 - 重构用户资料接口,移除直接修改头像字段,改用专用上传接口 - 添加站点公开设置API与管理员外观配置页 - 全局替换hover背景为accent-soft,优化交互一致性 - 完善头像组件,支持图片加载失败回退与src更新重置 - 新增用户状态事件总线,解决同页头像更新不及时问题 - 调整依赖包顺序与CI依赖版本 - 重构板块列表接口,新增帖子计数 - 迁移账号设置到用户主页tab,合并重复代码
This commit is contained in:
75
frontend/components/AvatarPicker.tsx
Normal file
75
frontend/components/AvatarPicker.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
"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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user