"use client";
import {
Globe,
Loader2,
MessageSquare,
Save,
UserPlus,
MessagesSquare,
ExternalLink,
Shield,
Mail,
HardDrive,
Ban,
Wrench,
Palette,
FileText,
Paperclip,
Image as ImageIcon,
} from "lucide-react";
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import {
AdminExtChipEditor,
AdminField,
AdminPageHeader,
AdminSection,
AdminStepper,
AdminSwitch,
} from "@/components/admin";
import { apiUpdateSiteSettings, type PublicSettings } from "@/lib/api";
import { toast } from "@/lib/toast";
function extsEqual(a: string[], b: string[]) {
if (a.length !== b.length) return false;
return a.every((v, i) => v === b[i]);
}
type SectionId =
| "basic"
| "content"
| "brand"
| "footer"
| "security"
| "mail"
| "storage"
| "words"
| "maintenance";
const NAV: { id: SectionId; label: string; ready?: boolean }[] = [
{ id: "basic", label: "基本信息", ready: true },
{ id: "content", label: "内容与互动", ready: true },
{ id: "brand", label: "品牌与 SEO" },
{ id: "footer", label: "页脚与合规" },
{ id: "security", label: "安全与限流" },
{ id: "mail", label: "邮件" },
{ id: "storage", label: "对象存储" },
{ id: "words", label: "敏感词" },
{ id: "maintenance", label: "系统维护" },
];
function SwitchRow({
title,
description,
control,
bordered,
}: {
title: ReactNode;
description: string;
control: ReactNode;
bordered?: boolean;
}) {
return (
);
}
function ComingSoonCard({
title,
description,
icon,
}: {
title: string;
description: string;
icon: ReactNode;
}) {
return (
);
}
export default function SettingsAdmin({ initial }: { initial: PublicSettings }) {
const [siteName, setSiteName] = useState(initial.site_name);
const [siteDescription, setSiteDescription] = useState(initial.site_description);
const [allowRegister, setAllowRegister] = useState(initial.allow_register);
const [allowComments, setAllowComments] = useState(initial.allow_comments !== false);
const [allowMessages, setAllowMessages] = useState(initial.allow_messages !== false);
const [postLinkNewTab, setPostLinkNewTab] = useState(initial.post_link_new_tab !== false);
const [savedName, setSavedName] = useState(initial.site_name);
const [savedDesc, setSavedDesc] = useState(initial.site_description);
const [saving, setSaving] = useState(false);
const [togglingReg, setTogglingReg] = useState(false);
const [togglingComments, setTogglingComments] = useState(false);
const [togglingMessages, setTogglingMessages] = useState(false);
const [togglingLink, setTogglingLink] = useState(false);
const [active, setActive] = useState("basic");
const [extLimit, setExtLimit] = useState(initial.attachment_ext_limit !== false);
const [exts, setExts] = useState(() => [...(initial.attachment_exts || [])]);
const [savedExts, setSavedExts] = useState(() => [...(initial.attachment_exts || [])]);
const [attMaxMB, setAttMaxMB] = useState(initial.attachment_max_mb || 20);
const [attMaxCount, setAttMaxCount] = useState(initial.attachment_max_count || 10);
const [imageMaxMB, setImageMaxMB] = useState(initial.image_max_mb || 5);
const [togglingExtLimit, setTogglingExtLimit] = useState(false);
const [savingLimits, setSavingLimits] = useState(false);
const [savingExts, setSavingExts] = useState(false);
const dirty = siteName.trim() !== savedName || siteDescription.trim() !== savedDesc;
const extsDirty = useMemo(() => !extsEqual(exts, savedExts), [exts, savedExts]);
useEffect(() => {
const ids = NAV.map((n) => n.id);
const observer = new IntersectionObserver(
(entries) => {
const visible = entries
.filter((e) => e.isIntersecting)
.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top);
if (visible[0]?.target?.id) {
const id = visible[0].target.id.replace(/^settings-/, "") as SectionId;
if (ids.includes(id)) setActive(id);
}
},
{ rootMargin: "-20% 0px -60% 0px", threshold: [0, 0.25, 0.5] }
);
ids.forEach((id) => {
const el = document.getElementById(`settings-${id}`);
if (el) observer.observe(el);
});
return () => observer.disconnect();
}, []);
const scrollTo = (id: SectionId) => {
setActive(id);
document.getElementById(`settings-${id}`)?.scrollIntoView({ behavior: "smooth", block: "start" });
};
const saveIdentity = async () => {
if (saving) return;
const name = siteName.trim();
if (!name) {
toast("站点名称不能为空");
return;
}
setSaving(true);
try {
const res = await apiUpdateSiteSettings({
site_name: name,
site_description: siteDescription.trim(),
});
setSiteName(res.site_name);
setSiteDescription(res.site_description);
setSavedName(res.site_name);
setSavedDesc(res.site_description);
toast("站点信息已保存", "ok");
} catch (e) {
toast(e instanceof Error ? e.message : "保存失败");
} finally {
setSaving(false);
}
};
const toggleRegister = async () => {
if (togglingReg) return;
const next = !allowRegister;
setTogglingReg(true);
try {
const res = await apiUpdateSiteSettings({ allow_register: next });
setAllowRegister(res.allow_register);
toast(res.allow_register ? "已开放注册" : "已关闭注册", "ok");
} catch (e) {
toast(e instanceof Error ? e.message : "保存失败");
} finally {
setTogglingReg(false);
}
};
const toggleComments = async () => {
if (togglingComments) return;
const next = !allowComments;
setTogglingComments(true);
try {
const res = await apiUpdateSiteSettings({ allow_comments: next });
setAllowComments(res.allow_comments);
toast(res.allow_comments ? "已开放评论" : "已关闭评论", "ok");
} catch (e) {
toast(e instanceof Error ? e.message : "保存失败");
} finally {
setTogglingComments(false);
}
};
const toggleMessages = async () => {
if (togglingMessages) return;
const next = !allowMessages;
setTogglingMessages(true);
try {
const res = await apiUpdateSiteSettings({ allow_messages: next });
setAllowMessages(res.allow_messages);
toast(res.allow_messages ? "已开放消息" : "已关闭消息", "ok");
} catch (e) {
toast(e instanceof Error ? e.message : "保存失败");
} finally {
setTogglingMessages(false);
}
};
const togglePostLinkNewTab = async () => {
if (togglingLink) return;
const next = !postLinkNewTab;
setTogglingLink(true);
try {
const res = await apiUpdateSiteSettings({ post_link_new_tab: next });
setPostLinkNewTab(res.post_link_new_tab !== false);
toast(res.post_link_new_tab !== false ? "帖子与评论外链将在新标签打开" : "帖子与评论外链将在当前页打开", "ok");
} catch (e) {
toast(e instanceof Error ? e.message : "保存失败");
} finally {
setTogglingLink(false);
}
};
const toggleExtLimit = async () => {
if (togglingExtLimit) return;
const next = !extLimit;
setTogglingExtLimit(true);
try {
const res = await apiUpdateSiteSettings({ attachment_ext_limit: next });
setExtLimit(res.attachment_ext_limit !== false);
toast(res.attachment_ext_limit !== false ? "已开启附件类型限制" : "已关闭附件类型限制", "ok");
} catch (e) {
toast(e instanceof Error ? e.message : "保存失败");
} finally {
setTogglingExtLimit(false);
}
};
const saveUploadLimits = async (patch: {
attachment_max_mb?: number;
attachment_max_count?: number;
image_max_mb?: number;
}) => {
if (savingLimits) return;
setSavingLimits(true);
try {
const res = await apiUpdateSiteSettings(patch);
setAttMaxMB(res.attachment_max_mb);
setAttMaxCount(res.attachment_max_count);
setImageMaxMB(res.image_max_mb);
toast("上传限额已保存", "ok");
} catch (e) {
toast(e instanceof Error ? e.message : "保存失败");
} finally {
setSavingLimits(false);
}
};
// 步进器手输会连续触发 onChange,防抖后再写库
const limitsTimer = useRef | null>(null);
const scheduleLimitSave = (patch: {
attachment_max_mb?: number;
attachment_max_count?: number;
image_max_mb?: number;
}) => {
if (limitsTimer.current) clearTimeout(limitsTimer.current);
limitsTimer.current = setTimeout(() => {
void saveUploadLimits(patch);
}, 450);
};
useEffect(() => {
return () => {
if (limitsTimer.current) clearTimeout(limitsTimer.current);
};
}, []);
const saveExts = async () => {
if (savingExts || !extsDirty) return;
setSavingExts(true);
try {
const res = await apiUpdateSiteSettings({ attachment_exts: exts });
setExts([...res.attachment_exts]);
setSavedExts([...res.attachment_exts]);
toast("附件扩展名已保存", "ok");
} catch (e) {
toast(e instanceof Error ? e.message : "保存失败");
} finally {
setSavingExts(false);
}
};
return (
开放注册
>
}
description="关闭后,游客将无法提交注册;已有账号仍可登录。适用于内测或维护期。"
control={
void toggleRegister()}
label="开放注册"
/>
}
/>
开放评论
>
}
description="关闭后前台不展示评论入口、评论数量与已有评论,并提示本站不支持评论。"
control={
void toggleComments()}
label="开放评论"
/>
}
/>
开放消息
>
}
description="关闭后前台不展示私聊与群聊入口;已有会话由后台消息管理继续监管。"
control={
void toggleMessages()}
label="开放消息"
/>
}
/>
外链新标签打开
>
}
description="开启后,帖子与评论中的 Markdown 外链在新标签打开;关闭则当前页跳转。"
control={
void togglePostLinkNewTab()}
label="外链新标签打开"
/>
}
/>
附件单文件上限
>
}
description="发帖附件体积上限。过大可能拖垮进程,并须把反代 / Next 请求体上限调到不少于该值。"
control={
{
setAttMaxMB(n);
scheduleLimitSave({ attachment_max_mb: n });
}}
/>
}
/>
{attMaxMB >= 50 ? (
当前 ≥50MB:请确认服务器内存与反代 body 上限已同步调大,否则用户仍会先撞上网关 413。
) : null}
每帖附件个数
>
}
description="单帖(含草稿)最多可绑定的附件数量。"
control={
{
setAttMaxCount(n);
scheduleLimitSave({ attachment_max_count: n });
}}
/>
}
/>
正文插图上限
>
}
description="Markdown 拖拽/粘贴图片(仅 JPEG / PNG / WebP)。插图会在服务端解析头部。"
control={
{
setImageMaxMB(n);
scheduleLimitSave({ image_max_mb: n });
}}
/>
}
/>
{imageMaxMB >= 10 ? (
插图 ≥10MB:解码头部更耗内存,建议保持较小值。
) : null}
限制附件扩展名
>
}
description={
extLimit
? "仅允许下方列表中的扩展名;安装包 / 脚本 / 网页可按需保留。"
: "任意格式可传,仍受体积上限与下载沙箱约束。"
}
control={
void toggleExtLimit()}
label="限制附件扩展名"
/>
}
/>
允许的扩展名
}
/>
}
/>
} />
}
/>
} />
}
/>
);
}