feat: 完成头像上传、站点主题配置与UI优化
- 新增附件模型与上传服务,拆分头像上传/管理逻辑 - 重构用户资料接口,移除直接修改头像字段,改用专用上传接口 - 添加站点公开设置API与管理员外观配置页 - 全局替换hover背景为accent-soft,优化交互一致性 - 完善头像组件,支持图片加载失败回退与src更新重置 - 新增用户状态事件总线,解决同页头像更新不及时问题 - 调整依赖包顺序与CI依赖版本 - 重构板块列表接口,新增帖子计数 - 迁移账号设置到用户主页tab,合并重复代码
This commit is contained in:
@@ -1,3 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
// 柔和多色头像:按用户名确定性取色,圆形软底,浅/暗主题在 globals.css 各自配色
|
||||
function hash(s: string) {
|
||||
let h = 0;
|
||||
@@ -18,15 +22,26 @@ export default function Avatar({
|
||||
}) {
|
||||
const idx = hash(name || "?") % 8;
|
||||
const initial = (name || "?").trim().charAt(0).toUpperCase();
|
||||
// 图片 404(如附件已被彻底删除)时回退到字母头像,不显示裂图
|
||||
const [imgFailed, setImgFailed] = useState(false);
|
||||
// 切换到新的图片地址时重置失败状态
|
||||
useEffect(() => setImgFailed(false), [src]);
|
||||
const showImg = !!src && !imgFailed;
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center justify-center shrink-0 select-none font-bold rounded-full overflow-hidden av-${idx} ${className}`}
|
||||
style={{ width: size, height: size, fontSize: size * 0.42 }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{src ? (
|
||||
{showImg ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={src} alt="" className="w-full h-full object-cover" />
|
||||
<img
|
||||
src={src}
|
||||
alt=""
|
||||
className="w-full h-full object-cover"
|
||||
onError={() => setImgFailed(true)}
|
||||
/>
|
||||
) : (
|
||||
initial
|
||||
)}
|
||||
|
||||
425
frontend/components/AvatarCropModal.tsx
Normal file
425
frontend/components/AvatarCropModal.tsx
Normal file
@@ -0,0 +1,425 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import Cropper, { type Area } from "react-easy-crop";
|
||||
import "react-easy-crop/react-easy-crop.css";
|
||||
import {
|
||||
X, UploadCloud, ZoomIn, Check, Loader2, Images, RotateCcw, Trash2, AlertTriangle, Crop,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
apiMyMedia, apiUploadAvatar, apiUseAvatar, apiDeleteAttachment,
|
||||
type MediaAttachment,
|
||||
} from "@/lib/api";
|
||||
import { getCroppedAvatarWebp } from "@/lib/cropImage";
|
||||
|
||||
interface AvatarCropModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
currentAvatar: string;
|
||||
/** 头像变更(新 URL)或被删除(空串)时触发 */
|
||||
onChanged: (avatar: string) => void;
|
||||
}
|
||||
|
||||
// 源文件大小保护(防止手机相册原图撑爆浏览器内存;最终限制以裁剪后 2MB 为准)
|
||||
const SOURCE_MAX_BYTES = 15 * 1024 * 1024;
|
||||
|
||||
export default function AvatarCropModal({
|
||||
open, onClose, currentAvatar, onChanged,
|
||||
}: AvatarCropModalProps) {
|
||||
const [media, setMedia] = useState<MediaAttachment[]>([]);
|
||||
const [imageSrc, setImageSrc] = useState<string | null>(null); // blob: 或同源 /uploads URL
|
||||
const [srcIsBlob, setSrcIsBlob] = useState(false);
|
||||
const [crop, setCrop] = useState({ x: 0, y: 0 });
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [pixelCrop, setPixelCrop] = useState<Area | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [confirmDeleteId, setConfirmDeleteId] = useState<number | null>(null);
|
||||
// 正在"直接选用"的历史头像 id(区别于裁剪上传,走 PUT /avatar/use)
|
||||
const [usingId, setUsingId] = useState<number | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const confirmTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// 打开时拉取媒体库
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setError("");
|
||||
apiMyMedia()
|
||||
.then((res) => setMedia(res.attachments || []))
|
||||
.catch(() => setMedia([]));
|
||||
}, [open]);
|
||||
|
||||
// 关闭时复位并释放本地 blob URL(媒体库的同源 URL 不能 revoke)
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setImageSrc((prev) => {
|
||||
if (prev && prev.startsWith("blob:")) URL.revokeObjectURL(prev);
|
||||
return null;
|
||||
});
|
||||
setSrcIsBlob(false);
|
||||
setCrop({ x: 0, y: 0 });
|
||||
setZoom(1);
|
||||
setPixelCrop(null);
|
||||
setError("");
|
||||
setBusy(false);
|
||||
setConfirmDeleteId(null);
|
||||
setUsingId(null);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (confirmTimer.current) clearTimeout(confirmTimer.current);
|
||||
}, []);
|
||||
|
||||
// ESC 关闭
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && !busy) onClose();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [open, busy, onClose]);
|
||||
|
||||
const onCropComplete = useCallback((_: Area, areaPixels: Area) => {
|
||||
setPixelCrop(areaPixels);
|
||||
}, []);
|
||||
|
||||
const enterCrop = (src: string, isBlob: boolean) => {
|
||||
setError("");
|
||||
setImageSrc(src);
|
||||
setSrcIsBlob(isBlob);
|
||||
setCrop({ x: 0, y: 0 });
|
||||
setZoom(1);
|
||||
};
|
||||
|
||||
/**
|
||||
* 点击媒体库图片:
|
||||
* - 成品头像(kind=avatar,本就是合规正方形 WebP):直接选用已有文件,
|
||||
* 走 PUT /avatar/use,绝不重新裁剪上传产生重复文件;
|
||||
* - 其他图片(kind=image,如帖子插图,可能非正方形):进入裁剪界面,
|
||||
* 裁剪后才生成新的头像文件上传。
|
||||
*/
|
||||
const handlePick = async (item: MediaAttachment) => {
|
||||
if (busy || item.url === currentAvatar) return;
|
||||
if (item.kind === "image") {
|
||||
enterCrop(item.url, false);
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setUsingId(item.id);
|
||||
setError("");
|
||||
try {
|
||||
const res = await apiUseAvatar(item.url);
|
||||
if (!res.url) {
|
||||
setError(res.error || "设置失败,请重试");
|
||||
return;
|
||||
}
|
||||
onChanged(res.url);
|
||||
} catch {
|
||||
setError("网络错误,请重试");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
setUsingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const onFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = "";
|
||||
if (!file) return;
|
||||
if (!file.type.startsWith("image/")) {
|
||||
setError("请选择图片文件(JPG / PNG / GIF / WebP)");
|
||||
return;
|
||||
}
|
||||
if (file.size > SOURCE_MAX_BYTES) {
|
||||
setError("原图不能超过 15MB,裁剪后的头像需在 2MB 以内");
|
||||
return;
|
||||
}
|
||||
enterCrop(URL.createObjectURL(file), true);
|
||||
};
|
||||
|
||||
const backToLibrary = () => {
|
||||
if (imageSrc && srcIsBlob) URL.revokeObjectURL(imageSrc);
|
||||
setImageSrc(null);
|
||||
setSrcIsBlob(false);
|
||||
setError("");
|
||||
};
|
||||
|
||||
const confirmUpload = async () => {
|
||||
if (!imageSrc || !pixelCrop || busy) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
// 浏览器端裁剪并以 WebP 导出(质量自适应,直到 ≤ 2MB)
|
||||
const blob = await getCroppedAvatarWebp(imageSrc, pixelCrop);
|
||||
const res = await apiUploadAvatar(blob);
|
||||
if (!res.url) {
|
||||
setError(res.error || "上传失败,请重试");
|
||||
return;
|
||||
}
|
||||
onChanged(res.url); // 父组件负责关窗、广播与刷新
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "上传失败,请重试");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (item: MediaAttachment) => {
|
||||
if (busy) return;
|
||||
// 两阶段确认:第一次点击进入红色确认态,3.5 秒内再点才执行
|
||||
if (confirmDeleteId !== item.id) {
|
||||
setConfirmDeleteId(item.id);
|
||||
if (confirmTimer.current) clearTimeout(confirmTimer.current);
|
||||
confirmTimer.current = setTimeout(() => setConfirmDeleteId(null), 3500);
|
||||
return;
|
||||
}
|
||||
if (confirmTimer.current) clearTimeout(confirmTimer.current);
|
||||
setConfirmDeleteId(null);
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const res = await apiDeleteAttachment(item.id);
|
||||
if (res.ok === undefined && res.error) {
|
||||
setError(res.error);
|
||||
return;
|
||||
}
|
||||
setMedia((prev) => prev.filter((m) => m.id !== item.id));
|
||||
// 删的正是当前头像:清空(回到字母头像)并关闭弹窗
|
||||
if (item.url === currentAvatar) {
|
||||
onChanged("");
|
||||
}
|
||||
} catch {
|
||||
setError("网络错误,请重试");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[90] flex items-center justify-center p-4"
|
||||
style={{ background: "color-mix(in srgb, var(--ink) 55%, transparent)" }}
|
||||
onClick={() => !busy && onClose()}
|
||||
>
|
||||
<div
|
||||
className="panel w-full max-w-[440px] rounded-2xl p-5"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="更换头像"
|
||||
>
|
||||
{/* 标题栏 */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-[15.5px] font-extrabold tracking-tight" style={{ color: "var(--ink)" }}>
|
||||
{imageSrc ? "裁剪头像" : "更换头像"}
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={busy}
|
||||
aria-label="关闭"
|
||||
className="inline-flex w-8 h-8 items-center justify-center rounded-full transition-colors hover:bg-[var(--accent-soft)] focus-visible:bg-[var(--accent-soft)] disabled:opacity-50"
|
||||
style={{ color: "var(--ink-3)" }}
|
||||
>
|
||||
<X size={17} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <div className="alert-error mb-4">{error}</div>}
|
||||
|
||||
{!imageSrc ? (
|
||||
<>
|
||||
{/* 选择新图片 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="w-full flex flex-col items-center justify-center gap-2 rounded-xl py-7 px-4 text-center transition-colors hover:border-[var(--accent)]"
|
||||
style={{
|
||||
border: "1.5px dashed var(--line-2)",
|
||||
background: "var(--panel-2)",
|
||||
color: "var(--ink-2)",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="inline-flex w-11 h-11 items-center justify-center rounded-full"
|
||||
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
|
||||
>
|
||||
<UploadCloud size={20} />
|
||||
</span>
|
||||
<span className="text-[13.5px] font-bold" style={{ color: "var(--ink)" }}>
|
||||
点击选择图片
|
||||
</span>
|
||||
<span className="text-[12px] leading-relaxed" style={{ color: "var(--ink-3)" }}>
|
||||
支持 JPG / PNG / GIF / WebP,裁剪后统一转为 WebP,大小不超过 2MB
|
||||
</span>
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={onFileChange}
|
||||
/>
|
||||
|
||||
{/* 媒体库:本人上传过的全部图片(头像 + 帖子插图) */}
|
||||
{media.length > 0 && (
|
||||
<div className="mt-5">
|
||||
<p
|
||||
className="flex items-center gap-1.5 text-[12px] font-bold tracking-[0.04em] mb-2.5"
|
||||
style={{ color: "var(--ink-3)" }}
|
||||
>
|
||||
<Images size={13} /> 我的图片(历史头像点击直接使用,其他图片点击后裁剪;右上角可删除)
|
||||
</p>
|
||||
<div className="grid grid-cols-6 gap-2">
|
||||
{media.map((item) => {
|
||||
const active = item.url === currentAvatar;
|
||||
const confirming = confirmDeleteId === item.id;
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
// self-center:禁用网格项默认的 align-self:stretch,
|
||||
// 否则它会与 aspect-ratio 冲突把容器拉高,光环变成竖椭圆
|
||||
className="relative group rounded-full justify-self-center self-center w-full"
|
||||
style={{
|
||||
aspectRatio: "1 / 1",
|
||||
boxShadow: active
|
||||
? "0 0 0 2px var(--panel), 0 0 0 4px var(--accent)"
|
||||
: "0 0 0 1px var(--line)",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handlePick(item)}
|
||||
disabled={busy || active}
|
||||
title={
|
||||
active
|
||||
? "当前头像"
|
||||
: item.kind === "image"
|
||||
? "裁剪后设为头像"
|
||||
: "直接设为头像(不会重复上传)"
|
||||
}
|
||||
// absolute inset-0:以正方形容器为确定包含块铺满,
|
||||
// 避免 h-full 在 aspect-ratio 尺寸上的百分比解析歧义
|
||||
className="absolute inset-0 rounded-full overflow-hidden bg-[var(--panel-2)] transition-transform hover:scale-105 disabled:cursor-default disabled:hover:scale-100"
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={item.url} alt="" className="w-full h-full object-cover" loading="lazy" />
|
||||
</button>
|
||||
|
||||
{/* hover 指示:成品头像=直接使用,其他图片=裁剪;选用中显示 spinner */}
|
||||
{!active && (
|
||||
<span
|
||||
className="pointer-events-none absolute inset-0 rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
style={{ background: "color-mix(in srgb, var(--ink) 45%, transparent)", color: "#fff" }}
|
||||
>
|
||||
{usingId === item.id ? (
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
) : item.kind === "image" ? (
|
||||
<Crop size={13} />
|
||||
) : (
|
||||
<Check size={14} strokeWidth={2.6} />
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* 当前头像勾选标记 */}
|
||||
{active && (
|
||||
<span
|
||||
className="pointer-events-none absolute inset-0 flex items-center justify-center rounded-full"
|
||||
style={{ background: "color-mix(in srgb, var(--accent) 35%, transparent)", color: "#fff" }}
|
||||
>
|
||||
<Check size={15} strokeWidth={3} />
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* 删除按钮(两阶段确认) */}
|
||||
{!active && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete(item);
|
||||
}}
|
||||
disabled={busy}
|
||||
aria-label={confirming ? "再次点击确认删除" : "删除图片"}
|
||||
title={confirming ? "再次点击确认彻底删除" : "彻底删除"}
|
||||
className="absolute -top-1.5 -right-1.5 w-5 h-5 rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity disabled:opacity-50"
|
||||
style={{
|
||||
background: confirming ? "var(--red, #dc2626)" : "var(--panel)",
|
||||
color: confirming ? "#fff" : "var(--ink-2)",
|
||||
border: "1px solid " + (confirming ? "var(--red, #dc2626)" : "var(--line-2)"),
|
||||
boxShadow: "0 1px 4px rgba(0,0,0,0.18)",
|
||||
}}
|
||||
>
|
||||
{confirming ? <AlertTriangle size={11} /> : <Trash2 size={10.5} />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* 裁剪区 */}
|
||||
<div
|
||||
className="relative w-full h-72 rounded-xl overflow-hidden"
|
||||
style={{ background: "#0c0d12" }}
|
||||
>
|
||||
<Cropper
|
||||
image={imageSrc}
|
||||
crop={crop}
|
||||
zoom={zoom}
|
||||
aspect={1}
|
||||
cropShape="round"
|
||||
showGrid={false}
|
||||
onCropChange={setCrop}
|
||||
onZoomChange={setZoom}
|
||||
onCropComplete={onCropComplete}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 缩放 */}
|
||||
<div className="flex items-center gap-3 mt-4">
|
||||
<span style={{ color: "var(--ink-3)" }}><ZoomIn size={15} /></span>
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={3}
|
||||
step={0.01}
|
||||
value={zoom}
|
||||
onChange={(e) => setZoom(Number(e.target.value))}
|
||||
className="flex-1 h-1.5 rounded-full appearance-none cursor-pointer"
|
||||
style={{ accentColor: "var(--accent)", background: "var(--panel-3)" }}
|
||||
aria-label="缩放"
|
||||
/>
|
||||
<span style={{ color: "var(--ink-3)" }}><ZoomIn size={19} /></span>
|
||||
</div>
|
||||
|
||||
{/* 输出说明 + 操作 */}
|
||||
<div className="flex items-center justify-between mt-5">
|
||||
<span className="text-[12px]" style={{ color: "var(--ink-3)" }}>
|
||||
输出 320×320 WebP
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button type="button" onClick={backToLibrary} disabled={busy} className="btn btn-line btn-sm">
|
||||
<RotateCcw size={13.5} /> 重选
|
||||
</button>
|
||||
<button type="button" onClick={confirmUpload} disabled={busy} className="btn btn-primary btn-sm">
|
||||
{busy ? <Loader2 size={14} className="animate-spin" /> : <Check size={14} />}
|
||||
{busy ? "上传中" : "确认上传"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -200,7 +200,7 @@ export default function CommentSection({
|
||||
<div key={c.id} id={`comment-${c.id}`} className="cmt-reply-row scroll-mt-24">
|
||||
<div className="flex gap-2.5">
|
||||
<Link href={`/u/${c.user.id}`} className="shrink-0" aria-label={c.user.nickname || c.user.username}>
|
||||
<Avatar name={c.user.nickname || c.user.username} size={28} />
|
||||
<Avatar name={c.user.nickname || c.user.username} src={c.user.avatar || undefined} size={28} />
|
||||
</Link>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
@@ -280,7 +280,7 @@ export default function CommentSection({
|
||||
>
|
||||
<div className="flex gap-3">
|
||||
<Link href={`/u/${c.user.id}`} className="shrink-0 mt-0.5" aria-label={c.user.nickname || c.user.username}>
|
||||
<Avatar name={c.user.nickname || c.user.username} size={38} />
|
||||
<Avatar name={c.user.nickname || c.user.username} src={c.user.avatar || undefined} size={38} />
|
||||
</Link>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2.5 mb-1.5">
|
||||
|
||||
@@ -19,9 +19,11 @@ import {
|
||||
User as UserIcon,
|
||||
ChevronDown,
|
||||
Megaphone,
|
||||
Palette,
|
||||
} from "lucide-react";
|
||||
import { apiLogout, apiMe, type User } from "@/lib/api";
|
||||
import { hasAuthCookieHint } from "@/lib/cookies";
|
||||
import { onUserUpdate } from "@/lib/userEvents";
|
||||
import NotificationBell from "./NotificationBell";
|
||||
import Avatar from "./Avatar";
|
||||
|
||||
@@ -53,6 +55,11 @@ export default function Header({
|
||||
setUnread(initialUnread);
|
||||
}, [initialUnread]);
|
||||
|
||||
// 同页内资料变更(如上传/删除头像)立即生效,不依赖 router.refresh 时序
|
||||
useEffect(() => onUserUpdate((patch) => {
|
||||
setUser((prev) => (prev ? { ...prev, ...patch } : prev));
|
||||
}), []);
|
||||
|
||||
// 挂载后静默校正一次(仅一次):覆盖 SSR 期间后端不可用、旧 Path refresh
|
||||
// 半登录等边缘情况。正常 F5 下结果与 SSR 一致,不产生可见闪动;
|
||||
// 无可读 CSRF cookie 说明必然是游客,跳过请求
|
||||
@@ -274,9 +281,14 @@ export default function Header({
|
||||
)}
|
||||
{user && <MobileLink href="/settings">账号设置</MobileLink>}
|
||||
{user?.role === "admin" && (
|
||||
<MobileLink href="/admin/announcements">
|
||||
<span className="inline-flex items-center gap-2"><Megaphone size={15} /> 公告管理</span>
|
||||
</MobileLink>
|
||||
<>
|
||||
<MobileLink href="/admin/announcements">
|
||||
<span className="inline-flex items-center gap-2"><Megaphone size={15} /> 公告管理</span>
|
||||
</MobileLink>
|
||||
<MobileLink href="/admin/appearance">
|
||||
<span className="inline-flex items-center gap-2"><Palette size={15} /> 外观设置</span>
|
||||
</MobileLink>
|
||||
</>
|
||||
)}
|
||||
{!user && <MobileLink href="/login">登录</MobileLink>}
|
||||
{!user && <MobileLink href="/register">注册</MobileLink>}
|
||||
@@ -373,7 +385,7 @@ function UserMenu({ user, onLogout }: { user: User; onLogout: () => void }) {
|
||||
}, [open]);
|
||||
|
||||
const itemCls =
|
||||
"w-full flex items-center gap-2.5 px-4 py-2.5 text-[13.5px] font-semibold transition-colors hover:bg-[var(--panel-2)]";
|
||||
"w-full flex items-center gap-2.5 px-4 py-2.5 text-[13.5px] font-semibold transition-colors hover:bg-[var(--accent-soft)] focus-visible:bg-[var(--accent-soft)]";
|
||||
|
||||
return (
|
||||
<div className="relative hidden lg:block" ref={menuRef}>
|
||||
@@ -387,7 +399,7 @@ function UserMenu({ user, onLogout }: { user: User; onLogout: () => void }) {
|
||||
background: open ? "var(--accent-soft)" : "var(--panel)",
|
||||
}}
|
||||
>
|
||||
<Avatar name={user.nickname || user.username} size={28} />
|
||||
<Avatar name={user.nickname || user.username} src={user.avatar || undefined} size={28} />
|
||||
<span
|
||||
className="text-[13px] font-semibold max-w-[90px] truncate"
|
||||
style={{ color: open ? "var(--accent)" : "var(--ink)" }}
|
||||
@@ -419,7 +431,7 @@ function UserMenu({ user, onLogout }: { user: User; onLogout: () => void }) {
|
||||
className="flex items-center gap-3 px-4 py-3.5"
|
||||
style={{ borderBottom: "1px solid var(--line)" }}
|
||||
>
|
||||
<Avatar name={user.nickname || user.username} size={38} />
|
||||
<Avatar name={user.nickname || user.username} src={user.avatar || undefined} size={38} />
|
||||
<div className="min-w-0">
|
||||
<p className="text-[14px] font-bold truncate" style={{ color: "var(--ink)" }}>
|
||||
{user.nickname}
|
||||
@@ -440,6 +452,9 @@ function UserMenu({ user, onLogout }: { user: User; onLogout: () => void }) {
|
||||
<Link href="/admin/announcements" onClick={() => setOpen(false)} className={itemCls} role="menuitem">
|
||||
<Megaphone size={15} style={{ color: "var(--accent)" }} /> 公告管理
|
||||
</Link>
|
||||
<Link href="/admin/appearance" onClick={() => setOpen(false)} className={itemCls} role="menuitem">
|
||||
<Palette size={15} style={{ color: "var(--accent)" }} /> 外观设置
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
<div className="p-1.5" style={{ borderTop: "1px solid var(--line)" }}>
|
||||
|
||||
@@ -55,7 +55,7 @@ export default function MobilePostBar({
|
||||
<button
|
||||
type="button"
|
||||
onClick={scrollToComments}
|
||||
className="w-11 h-11 flex items-center justify-center rounded-full transition-colors hover:bg-[var(--panel-2)]"
|
||||
className="w-11 h-11 flex items-center justify-center rounded-full transition-colors hover:bg-[var(--accent-soft)] focus-visible:bg-[var(--accent-soft)]"
|
||||
style={{ color: "var(--ink-2)" }}
|
||||
aria-label="跳到评论框"
|
||||
>
|
||||
@@ -69,7 +69,7 @@ export default function MobilePostBar({
|
||||
<button
|
||||
type="button"
|
||||
onClick={scrollToTop}
|
||||
className="w-11 h-11 flex items-center justify-center rounded-full transition-colors hover:bg-[var(--panel-2)] j13-toast-in"
|
||||
className="w-11 h-11 flex items-center justify-center rounded-full transition-colors hover:bg-[var(--accent-soft)] focus-visible:bg-[var(--accent-soft)] j13-toast-in"
|
||||
style={{ color: "var(--ink-2)" }}
|
||||
aria-label="回到顶部"
|
||||
>
|
||||
|
||||
@@ -163,10 +163,11 @@ export default function NotificationBell({ initialUnread = 0 }: { initialUnread?
|
||||
<button
|
||||
key={n.id}
|
||||
onClick={() => handleClickNotification(n)}
|
||||
className="w-full text-left px-4 py-3.5 flex items-start gap-3 transition-colors"
|
||||
className={`w-full text-left px-4 py-3.5 flex items-start gap-3 transition-colors hover:bg-[var(--accent-soft)] focus-visible:bg-[var(--accent-soft)] ${
|
||||
n.is_read ? "" : "bg-[var(--panel-2)]"
|
||||
}`}
|
||||
style={{
|
||||
borderBottom: "1px solid var(--line)",
|
||||
background: n.is_read ? "transparent" : "var(--panel-2)",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
|
||||
@@ -44,13 +44,13 @@ export default function PinnedPosts({
|
||||
<Link
|
||||
key={p.id}
|
||||
href={`/post/${p.id}`}
|
||||
className="group relative flex items-center gap-3 sm:gap-3.5 px-4 sm:px-5 py-3.5 transition-colors hover:bg-[var(--panel-2)] j13-fade-up"
|
||||
className="group relative flex items-center gap-3 sm:gap-3.5 px-4 sm:px-5 py-3.5 transition-colors hover:bg-[var(--accent-soft)] focus-visible:bg-[var(--accent-soft)] j13-fade-up"
|
||||
style={{
|
||||
...(i > 0 ? { borderTop: "1px solid var(--line)" } : {}),
|
||||
animationDelay: `${i * 35}ms`,
|
||||
}}
|
||||
>
|
||||
<Avatar name={p.user.nickname || p.user.username} size={36} />
|
||||
<Avatar name={p.user.nickname || p.user.username} src={p.user.avatar || undefined} size={36} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3
|
||||
className="text-[14.5px] font-bold leading-snug truncate transition-colors group-hover:text-[var(--accent)]"
|
||||
|
||||
@@ -102,7 +102,7 @@ export default function PostActions({ postId, authorId, pinned, recommended }: P
|
||||
if (!canManage) return null;
|
||||
|
||||
const itemCls =
|
||||
"w-full flex items-center gap-2.5 px-4 py-2.5 text-[13px] transition-colors hover:bg-[var(--panel-2)]";
|
||||
"w-full flex items-center gap-2.5 px-4 py-2.5 text-[13px] transition-colors hover:bg-[var(--accent-soft)] focus-visible:bg-[var(--accent-soft)]";
|
||||
|
||||
return (
|
||||
<div className="relative" ref={menuRef}>
|
||||
|
||||
@@ -31,7 +31,7 @@ export default function PostRow({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`group relative flex items-center px-4 sm:px-5 border-t transition-colors hover:bg-[var(--panel-2)] j13-fade-up ${
|
||||
className={`group relative flex items-center px-4 sm:px-5 border-t transition-colors hover:bg-[var(--accent-soft)] focus-within:bg-[var(--accent-soft)] j13-fade-up ${
|
||||
compact ? "gap-2.5 sm:gap-3" : "gap-3.5 sm:gap-4"
|
||||
}`}
|
||||
style={{
|
||||
@@ -47,7 +47,7 @@ export default function PostRow({
|
||||
aria-label={post.user.nickname}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<Avatar name={post.user.nickname || post.user.username} size={compact ? 38 : 46} />
|
||||
<Avatar name={post.user.nickname || post.user.username} src={post.user.avatar || undefined} size={compact ? 38 : 46} />
|
||||
</Link>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
|
||||
@@ -113,7 +113,7 @@ function Announcements({ items }: { items: AnnouncementSummary[] }) {
|
||||
<li key={a.id}>
|
||||
<Link
|
||||
href={`/announcement/${a.id}`}
|
||||
className="group block -mx-1.5 rounded-lg px-1.5 py-1.5 transition-colors hover:bg-[var(--panel-2)]"
|
||||
className="group block -mx-1.5 rounded-lg px-1.5 py-1.5 transition-colors hover:bg-[var(--accent-soft)] focus-visible:bg-[var(--accent-soft)]"
|
||||
>
|
||||
<span className="flex items-center justify-between gap-2">
|
||||
<span className={`j13-anno-tag is-${a.tag_color}`}>{a.tag}</span>
|
||||
@@ -194,7 +194,7 @@ function NewUsers({ users }: { users: NewUser[] }) {
|
||||
<li key={u.id}>
|
||||
<Link
|
||||
href={`/u/${u.id}`}
|
||||
className="flex flex-col items-center gap-1.5 rounded-lg px-1 py-1 transition-colors hover:bg-[var(--panel-2)]"
|
||||
className="flex flex-col items-center gap-1.5 rounded-lg px-1 py-1 transition-colors hover:bg-[var(--accent-soft)] focus-visible:bg-[var(--accent-soft)]"
|
||||
>
|
||||
<Avatar name={name} src={u.avatar} size={38} />
|
||||
<span
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { PenSquare, Home } from "lucide-react";
|
||||
import { PenSquare, Home, ChevronRight } from "lucide-react";
|
||||
import type { BoardWithCount, User } from "@/lib/api";
|
||||
|
||||
// 首页壳内板块切换:仅替换中栏帖子流,不离开工作台
|
||||
@@ -23,64 +23,62 @@ export function WorkspaceNav({ boards, user }: { boards: BoardWithCount[]; user:
|
||||
<PenSquare size={16} /> 发起讨论
|
||||
</Link>
|
||||
|
||||
{/* 「全部讨论」是重置筛选的动作入口,与板块目录分区展示 */}
|
||||
{/* 「全部讨论」是重置筛选的动作入口,与板块目录分区展示;卡片样式与下方板块一致 */}
|
||||
<div className="space-y-0.5">
|
||||
<Link
|
||||
href="/"
|
||||
className="j13-raillink"
|
||||
aria-current={activeBoard === 0 ? "page" : undefined}
|
||||
>
|
||||
<Home size={16} className="shrink-0" />
|
||||
<span className="flex-1 truncate">全部讨论</span>
|
||||
<Link href="/" className="j13-bcard" aria-current={activeBoard === 0 ? "page" : undefined}>
|
||||
<span
|
||||
className="j13-bcard-ico"
|
||||
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Home size={16} />
|
||||
</span>
|
||||
<span className="j13-bcard-name flex-1 truncate text-[13px] font-bold">
|
||||
全部讨论
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* 板块目录:发现型入口(介绍 + 讨论量),区别于帖子页面包屑的位置指引;
|
||||
点击不离开工作台,仅在中栏筛选该板块帖子流 */}
|
||||
{/* 板块目录:标题可点击进入板块汇总列表页(/boards);
|
||||
下方板块卡片点击不离开工作台,仅在中栏筛选该板块帖子流 */}
|
||||
<div>
|
||||
<p
|
||||
className="px-1 mb-2 flex items-center justify-between text-[11.5px] font-bold tracking-[0.08em] uppercase"
|
||||
style={{ color: "var(--ink-3)" }}
|
||||
<Link
|
||||
href="/boards"
|
||||
className="group px-1 mb-2 flex items-center justify-between text-[11.5px] font-bold tracking-[0.08em] uppercase text-[var(--ink-3)] hover:text-[var(--accent)] transition-colors"
|
||||
aria-label="进入板块目录列表页"
|
||||
>
|
||||
<span>板块目录</span>
|
||||
<span className="flex items-center">
|
||||
<span>板块目录</span>
|
||||
<ChevronRight
|
||||
size={12}
|
||||
className="ml-0.5 opacity-0 -translate-x-0.5 group-hover:opacity-100 group-hover:translate-x-0 transition-all"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
{boards.length > 0 && (
|
||||
<span className="tracking-normal font-semibold tabular-nums">{boards.length} 个</span>
|
||||
)}
|
||||
</p>
|
||||
</Link>
|
||||
<div className="space-y-2">
|
||||
{boards.map((b, i) => {
|
||||
const desc = b.description?.trim();
|
||||
return (
|
||||
<Link
|
||||
key={b.id}
|
||||
href={boardHref(b.id)}
|
||||
className="j13-bcard"
|
||||
aria-current={activeBoard === b.id ? "page" : undefined}
|
||||
aria-label={`${b.name}板块,${b.post_count} 篇讨论`}
|
||||
>
|
||||
<span
|
||||
className={`av-${i % 8} j13-bcard-ico`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{b.name.slice(0, 1)}
|
||||
{boards.map((b, i) => (
|
||||
<Link
|
||||
key={b.id}
|
||||
href={boardHref(b.id)}
|
||||
className="j13-bcard"
|
||||
aria-current={activeBoard === b.id ? "page" : undefined}
|
||||
aria-label={`${b.name}板块,${b.post_count} 篇讨论`}
|
||||
>
|
||||
<span className={`av-${i % 8} j13-bcard-ico`} aria-hidden="true">
|
||||
{b.name.slice(0, 1)}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 flex items-center justify-between gap-2">
|
||||
<span className="j13-bcard-name truncate text-[13px] font-bold">
|
||||
{b.name}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex items-center justify-between gap-2">
|
||||
<span
|
||||
className="truncate text-[13px] font-bold"
|
||||
style={{ color: "var(--ink)" }}
|
||||
>
|
||||
{b.name}
|
||||
</span>
|
||||
<span className="meta shrink-0 text-[11px] tabular-nums">
|
||||
{b.post_count} 篇
|
||||
</span>
|
||||
</span>
|
||||
{desc && <span className="j13-bcard-desc">{desc}</span>}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
<span className="meta shrink-0 text-[11px] tabular-nums">{b.post_count} 篇</span>
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user