feat: 完成头像上传、站点主题配置与UI优化

- 新增附件模型与上传服务,拆分头像上传/管理逻辑
- 重构用户资料接口,移除直接修改头像字段,改用专用上传接口
- 添加站点公开设置API与管理员外观配置页
- 全局替换hover背景为accent-soft,优化交互一致性
- 完善头像组件,支持图片加载失败回退与src更新重置
- 新增用户状态事件总线,解决同页头像更新不及时问题
- 调整依赖包顺序与CI依赖版本
- 重构板块列表接口,新增帖子计数
- 迁移账号设置到用户主页tab,合并重复代码
This commit is contained in:
2026-09-15 01:06:12 +08:00
parent f58b2af993
commit 24c6dea38c
48 changed files with 2513 additions and 432 deletions

View File

@@ -332,7 +332,7 @@ export default function AnnouncementAdmin() {
<Link
href={`/announcement/${a.id}`}
target="_blank"
className="w-8 h-8 inline-flex items-center justify-center rounded-full transition-colors hover:bg-[var(--panel-2)]"
className="w-8 h-8 inline-flex items-center justify-center rounded-full transition-colors hover:bg-[var(--accent-soft)] focus-visible:bg-[var(--accent-soft)]"
style={{ color: "var(--ink-3)" }}
title="查看"
aria-label="查看公告"
@@ -342,7 +342,7 @@ export default function AnnouncementAdmin() {
)}
<button
onClick={() => startEdit(a)}
className="w-8 h-8 inline-flex items-center justify-center rounded-full transition-colors hover:bg-[var(--panel-2)]"
className="w-8 h-8 inline-flex items-center justify-center rounded-full transition-colors hover:bg-[var(--accent-soft)] focus-visible:bg-[var(--accent-soft)]"
style={{ color: "var(--ink-3)" }}
title="编辑"
aria-label="编辑公告"

View File

@@ -0,0 +1,288 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import {
Palette,
Check,
Loader2,
Save,
Undo2,
RotateCcw,
ChevronRight,
Pipette,
} from "lucide-react";
import { apiUpdateSiteSettings } from "@/lib/api";
import { toast } from "@/lib/toast";
import {
ACCENT_PRESETS,
DEFAULT_ACCENT,
applyAccentTheme,
deriveAccentPair,
findPreset,
normalizeHex,
} from "@/lib/theme";
// 后台「外观设置」:站点主题色预设 + 自定义取色 + 实时预览。
// 修改时立即对整页实时预览(仅本地),保存后才写库并对所有用户生效。
export default function AppearanceAdmin({ initialAccent }: { initialAccent: string }) {
const router = useRouter();
// 已保存值:空串=内置默认(靛蓝)
const [savedHex, setSavedHex] = useState(initialAccent || "");
const [draft, setDraft] = useState(initialAccent || DEFAULT_ACCENT);
const [saving, setSaving] = useState(false);
const savedRef = useRef(savedHex);
savedRef.current = savedHex;
const normalized = useMemo(() => normalizeHex(draft), [draft]);
const pair = useMemo(
() => (normalized ? deriveAccentPair(normalized) : null),
[normalized]
);
const preset = useMemo(() => (normalized ? findPreset(normalized) : null), [normalized]);
// 保存提交值:默认靛蓝落库为空串(前端不注入覆盖,保持内置令牌单一来源)
const submitHex = normalized && normalized !== DEFAULT_ACCENT ? normalized : "";
const dirty = (savedHex || DEFAULT_ACCENT) !== (normalized || "");
const invalid = draft.trim() !== "" && !normalized;
// 实时预览:合法颜色即应用;离开页面时还原为已保存值,避免未保存预览残留
useEffect(() => {
if (normalized) applyAccentTheme(normalized);
return () => {
applyAccentTheme(savedRef.current || null);
};
}, [normalized]);
const handleSave = async () => {
if (!normalized || saving) return;
setSaving(true);
try {
const res = await apiUpdateSiteSettings(submitHex);
const next = (res.accent || "").toLowerCase();
setSavedHex(next);
applyAccentTheme(next || null);
router.refresh();
toast(next ? "主题色已保存并对全站生效" : "已恢复默认主题色");
} catch (e) {
toast(e instanceof Error ? e.message : "保存失败", "error");
applyAccentTheme(savedRef.current || null);
} finally {
setSaving(false);
}
};
const discard = () => {
setDraft(savedHex || DEFAULT_ACCENT);
applyAccentTheme(savedHex || null);
};
return (
<div className="max-w-3xl mx-auto">
{/* 面包屑 */}
<nav className="meta mb-5 flex items-center gap-1.5 text-[13px]">
<Link href="/" className="hover:text-[var(--accent)] transition-colors">首页</Link>
<ChevronRight size={13} />
<span style={{ color: "var(--accent)" }}>外观设置</span>
</nav>
<header className="panel p-6 sm:p-7 mb-5 flex items-center gap-4">
<span
className="w-12 h-12 rounded-2xl flex items-center justify-center shrink-0"
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
>
<Palette size={24} />
</span>
<div>
<h1 className="text-xl font-extrabold" style={{ color: "var(--ink)" }}>外观设置</h1>
<p className="meta mt-1 text-[13px]">配置站点主题色,全站按钮、链接、选中态会统一联动</p>
</div>
</header>
{/* 预设色板 */}
<section className="j13-module p-5 sm:p-6 mb-5">
<h2 className="text-[15px] font-extrabold mb-1" style={{ color: "var(--ink)" }}>
预设主题色
</h2>
<p className="meta text-[12.5px] mb-4">浅/暗两套色板均已调校,保证文字对比度达标</p>
<div className="grid grid-cols-3 sm:grid-cols-6 gap-3">
{ACCENT_PRESETS.map((p) => {
const active = normalized === p.light;
const isDefault = p.light === DEFAULT_ACCENT;
return (
<button
key={p.key}
type="button"
onClick={() => setDraft(p.light)}
className="group flex flex-col items-center gap-2 py-3 rounded-[var(--r-sm)] border transition-colors"
style={{
borderColor: active ? "var(--accent)" : "var(--line)",
background: active ? "var(--accent-soft)" : "var(--panel)",
cursor: "pointer",
}}
aria-pressed={active}
title={isDefault ? `${p.name}(默认)` : p.name}
>
<span className="relative flex items-center justify-center">
{/* 上半圆=浅色模式,下半圆=暗色模式 */}
<span className="w-9 h-9 rounded-full overflow-hidden grid grid-rows-2 border-2 border-[var(--panel)] shadow-sm">
<span style={{ background: p.light }} />
<span style={{ background: p.dark }} />
</span>
{active && (
<span
className="absolute -bottom-1 -right-1 min-w-[18px] min-h-[18px] w-[18px] h-[18px] rounded-full flex items-center justify-center"
style={{ background: "var(--accent)", color: "var(--accent-on)" }}
>
<Check size={11} strokeWidth={3} />
</span>
)}
</span>
<span
className="text-[12px] font-bold leading-none"
style={{ color: active ? "var(--accent)" : "var(--ink-2)" }}
>
{p.name}
</span>
</button>
);
})}
</div>
</section>
{/* 自定义颜色 */}
<section className="j13-module p-5 sm:p-6 mb-5">
<h2 className="text-[15px] font-extrabold mb-1 flex items-center gap-1.5" style={{ color: "var(--ink)" }}>
<Pipette size={15} /> 自定义颜色
</h2>
<p className="meta text-[12.5px] mb-4">
取色器选色或输入 HEX 值;浅色下对比度过低时会自动校正,保证按钮与文字可读
</p>
<div className="flex flex-wrap items-center gap-3">
{/* 原生取色器 */}
<label
className="relative w-12 h-12 rounded-[var(--r-sm)] overflow-hidden border cursor-pointer shrink-0"
style={{ borderColor: "var(--line-2)" }}
title="打开取色器"
>
<input
type="color"
className="absolute inset-0 w-[200%] h-[200%] -translate-x-1/4 -translate-y-1/4 cursor-pointer border-0 p-0 bg-transparent"
value={normalized || DEFAULT_ACCENT}
onChange={(e) => setDraft(e.target.value)}
aria-label="自定义主题色取色器"
/>
</label>
{/* HEX 文本输入 */}
<div className="flex flex-col gap-1">
<div className="flex items-center rounded-[var(--r-sm)] border bg-[var(--panel)] px-3 h-12 font-mono text-[14px] w-40"
style={{ borderColor: invalid ? "var(--danger)" : "var(--line-2)" }}
>
<span style={{ color: "var(--ink-3)" }}>#</span>
<input
value={draft.startsWith("#") ? draft.slice(1) : draft}
onChange={(e) => setDraft("#" + e.target.value.replace(/[^0-9a-fA-F#]/g, ""))}
className="flex-1 min-w-0 bg-transparent outline-none ml-1 uppercase"
style={{ color: "var(--ink)" }}
maxLength={7}
spellCheck={false}
aria-label="主题色 HEX 值"
/>
</div>
</div>
{pair && (
<div className="flex items-center gap-2 text-[12px]" style={{ color: "var(--ink-3)" }}>
<span className="inline-flex items-center gap-1.5">
<span className="w-3.5 h-3.5 rounded-full border" style={{ background: pair.light, borderColor: "var(--line)" }} />
浅色 {pair.light}
</span>
<span className="inline-flex items-center gap-1.5">
<span className="w-3.5 h-3.5 rounded-full border" style={{ background: pair.dark, borderColor: "var(--line)" }} />
暗色 {pair.dark}
</span>
</div>
)}
</div>
{invalid && (
<p className="mt-2.5 text-[12.5px]" style={{ color: "var(--danger)" }}>
颜色格式无效,请输入 6 位 HEX 值(如 3B5BDB)
</p>
)}
{pair?.adjusted && (
<p className="mt-2.5 text-[12.5px]" style={{ color: "var(--gold)" }}>
该颜色与白底对比度不足,浅色模式已自动校正为 {pair.light}(暗色模式同步派生 {pair.dark})
</p>
)}
{preset && preset.light === DEFAULT_ACCENT && (
<p className="mt-2.5 text-[12.5px] meta">当前为内置默认色,保存时会清除自定义配置</p>
)}
</section>
{/* 实时预览(整页已实时换肤,此处展示典型组件) */}
<section className="j13-module p-5 sm:p-6 mb-6">
<h2 className="text-[15px] font-extrabold mb-4" style={{ color: "var(--ink)" }}>实时预览</h2>
<div className="rounded-[var(--r-sm)] border p-4 space-y-4" style={{ borderColor: "var(--line)", background: "var(--bg)" }}>
<div className="flex flex-wrap items-center gap-2.5">
<button type="button" className="btn btn-primary btn-sm">
<Save size={14} /> 主按钮
</button>
<button type="button" className="btn btn-line btn-sm">次要按钮</button>
<a className="text-[13px] font-bold" style={{ color: "var(--accent)" }} href="#preview" onClick={(e) => e.preventDefault()}>
文字链接示例
</a>
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="j13-tab" aria-selected="false">未选中</span>
<span className="j13-tab" aria-selected="true">选中标签</span>
<span
className="inline-flex items-center rounded-full px-3.5 py-2 text-[13.5px] font-semibold"
style={{ background: "var(--nav-active-bg)", color: "var(--nav-active-ink)" }}
>
导航选中态
</span>
</div>
<div className="max-w-xs">
<div className="j13-bcard" aria-current="page">
<span className="j13-bcard-ico" style={{ background: "var(--accent-soft)", color: "var(--accent)" }}>
<Palette size={16} />
</span>
<span className="j13-bcard-name text-[13px] font-bold flex-1">选中的板块卡片</span>
</div>
</div>
</div>
</section>
{/* 操作栏 */}
<div className="flex items-center justify-between gap-3 sticky bottom-5 panel !p-3.5 pl-5 z-10">
<p className="text-[12.5px] meta hidden sm:block">
{dirty ? "有未保存的改动,整页正处于实时预览状态" : "设置已是保存状态"}
</p>
<div className="flex items-center gap-2.5 ml-auto">
{dirty && (
<button type="button" className="btn btn-line btn-sm" onClick={discard}>
<Undo2 size={14} /> 撤销改动
</button>
)}
<button
type="button"
className="btn btn-line btn-sm"
onClick={() => {
setDraft(DEFAULT_ACCENT);
}}
>
<RotateCcw size={14} /> 恢复默认
</button>
<button
type="button"
className="btn btn-primary btn-sm"
onClick={handleSave}
disabled={!normalized || saving || !dirty}
>
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
保存并全站生效
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,43 @@
import type { Metadata } from "next";
import Link from "next/link";
import { cookies } from "next/headers";
import { ShieldAlert } from "lucide-react";
import { authCookieHeader } from "@/lib/cookies";
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
import AppearanceAdmin from "./AppearanceAdmin";
export const metadata: Metadata = {
title: "外观设置",
robots: { index: false, follow: false },
};
// 站点外观(主题色)管理:权限以 Go 端 RequireAdmin 为唯一判定,
// 非管理员 SSR 直出无权限提示,写接口仍由后端 403 兜底。
export default async function AdminAppearancePage() {
const cookie = authCookieHeader(await cookies());
const me = await getMeCached(cookie || undefined);
if (me.user?.role !== "admin") {
return (
<div className="max-w-md mx-auto panel p-8 text-center mt-10">
<span
className="inline-flex w-14 h-14 rounded-full items-center justify-center mb-4"
style={{ background: "var(--gold-soft)", color: "var(--gold)" }}
>
<ShieldAlert size={26} />
</span>
<h1 className="text-lg font-extrabold" style={{ color: "var(--ink)" }}>
需要管理员权限
</h1>
<p className="meta mt-2 text-[13px]">仅站点管理员可以配置站点外观。</p>
<Link href="/" className="btn btn-primary mt-6">
返回首页
</Link>
</div>
);
}
// 主题色为公开设置:当前已保存值随 SSR 直出,避免面板初始闪动
const { accent } = await getPublicSettingsCached();
return <AppearanceAdmin initialAccent={accent || ""} />;
}