391 lines
14 KiB
TypeScript
391 lines
14 KiB
TypeScript
"use client";
|
||
|
||
import {
|
||
Code2,
|
||
Globe,
|
||
Loader2,
|
||
MessageSquare,
|
||
Save,
|
||
Sparkles,
|
||
UserPlus,
|
||
} from "lucide-react";
|
||
import { useState } from "react";
|
||
import {
|
||
AdminPageHeader,
|
||
AdminSection,
|
||
AdminSettingRow,
|
||
AdminSwitch,
|
||
} from "@/components/admin";
|
||
import { apiUpdateSiteSettings, type PublicSettings } from "@/lib/api";
|
||
import { toast } from "@/lib/toast";
|
||
|
||
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 [codeAutoFold, setCodeAutoFold] = useState(
|
||
initial.code_block_auto_fold !== false
|
||
);
|
||
const [foldLines, setFoldLines] = useState(
|
||
Number.isFinite(initial.code_block_fold_lines) ? initial.code_block_fold_lines : 5
|
||
);
|
||
const [savedFoldLines, setSavedFoldLines] = useState(
|
||
Number.isFinite(initial.code_block_fold_lines) ? initial.code_block_fold_lines : 5
|
||
);
|
||
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 [togglingFold, setTogglingFold] = useState(false);
|
||
const [savingFoldLines, setSavingFoldLines] = useState(false);
|
||
const [uiAnimations, setUiAnimations] = useState(initial.ui_animations !== false);
|
||
const [animCodeFold, setAnimCodeFold] = useState(initial.anim_code_fold !== false);
|
||
const [animSmoothScroll, setAnimSmoothScroll] = useState(
|
||
initial.anim_smooth_scroll !== false
|
||
);
|
||
const [animChrome, setAnimChrome] = useState(initial.anim_chrome !== false);
|
||
const [togglingMotion, setTogglingMotion] = useState(false);
|
||
|
||
const dirty =
|
||
siteName.trim() !== savedName || siteDescription.trim() !== savedDesc;
|
||
const foldLinesDirty = foldLines !== savedFoldLines;
|
||
|
||
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 toggleCodeFold = async () => {
|
||
if (togglingFold) return;
|
||
const next = !codeAutoFold;
|
||
setTogglingFold(true);
|
||
try {
|
||
const res = await apiUpdateSiteSettings({ code_block_auto_fold: next });
|
||
setCodeAutoFold(res.code_block_auto_fold);
|
||
toast(
|
||
res.code_block_auto_fold
|
||
? `已开启代码块自动折叠(超过 ${res.code_block_fold_lines} 行)`
|
||
: "已关闭代码块自动折叠",
|
||
"ok"
|
||
);
|
||
} catch (e) {
|
||
toast(e instanceof Error ? e.message : "保存失败");
|
||
} finally {
|
||
setTogglingFold(false);
|
||
}
|
||
};
|
||
|
||
const saveFoldLines = async () => {
|
||
if (savingFoldLines) return;
|
||
const raw = Math.round(Number(foldLines));
|
||
const n = Number.isFinite(raw) ? Math.max(0, Math.min(100, raw)) : 5;
|
||
setSavingFoldLines(true);
|
||
try {
|
||
const res = await apiUpdateSiteSettings({ code_block_fold_lines: n });
|
||
setFoldLines(res.code_block_fold_lines);
|
||
setSavedFoldLines(res.code_block_fold_lines);
|
||
toast(
|
||
res.code_block_fold_lines === 0
|
||
? "折叠预览已设为 0 行(无预览)"
|
||
: `折叠阈值已设为超过 ${res.code_block_fold_lines} 行`,
|
||
"ok"
|
||
);
|
||
} catch (e) {
|
||
toast(e instanceof Error ? e.message : "保存失败");
|
||
} finally {
|
||
setSavingFoldLines(false);
|
||
}
|
||
};
|
||
|
||
const toggleMotionField = async (
|
||
key: "ui_animations" | "anim_code_fold" | "anim_smooth_scroll" | "anim_chrome",
|
||
next: boolean
|
||
) => {
|
||
if (togglingMotion) return;
|
||
setTogglingMotion(true);
|
||
try {
|
||
const res = await apiUpdateSiteSettings({ [key]: next });
|
||
setUiAnimations(res.ui_animations);
|
||
setAnimCodeFold(res.anim_code_fold);
|
||
setAnimSmoothScroll(res.anim_smooth_scroll);
|
||
setAnimChrome(res.anim_chrome);
|
||
const labels: Record<typeof key, [string, string]> = {
|
||
ui_animations: ["已开启界面动画", "已关闭界面动画(全站减少动态效果)"],
|
||
anim_code_fold: ["已开启代码折叠动画", "已关闭代码折叠动画"],
|
||
anim_smooth_scroll: ["已开启平滑滚动", "已关闭平滑滚动"],
|
||
anim_chrome: ["已开启装饰入场动画", "已关闭装饰入场动画"],
|
||
};
|
||
toast(next ? labels[key][0] : labels[key][1], "ok");
|
||
} catch (e) {
|
||
toast(e instanceof Error ? e.message : "保存失败");
|
||
} finally {
|
||
setTogglingMotion(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="max-w-3xl">
|
||
<AdminPageHeader
|
||
icon={Globe}
|
||
title="站点设置"
|
||
description="配置站点名称、简介与注册/评论开关;主题色请到「外观」页调整"
|
||
/>
|
||
|
||
<div className="flex flex-col gap-4">
|
||
<AdminSection
|
||
title="基本信息"
|
||
description="用于页眉品牌、页脚版权与 SEO 元信息"
|
||
>
|
||
<label className="field-label" htmlFor="site-name">
|
||
站点名称
|
||
</label>
|
||
<input
|
||
id="site-name"
|
||
className="field mb-4"
|
||
maxLength={32}
|
||
value={siteName}
|
||
onChange={(e) => setSiteName(e.target.value)}
|
||
placeholder="姜十三论坛"
|
||
/>
|
||
|
||
<label className="field-label" htmlFor="site-desc">
|
||
站点简介
|
||
</label>
|
||
<textarea
|
||
id="site-desc"
|
||
className="field min-h-[96px] mb-4"
|
||
maxLength={160}
|
||
value={siteDescription}
|
||
onChange={(e) => setSiteDescription(e.target.value)}
|
||
placeholder="一句话介绍社区定位…"
|
||
/>
|
||
|
||
<div className="flex justify-end">
|
||
<button
|
||
type="button"
|
||
className="btn btn-primary btn-sm"
|
||
disabled={!dirty || saving}
|
||
onClick={() => void saveIdentity()}
|
||
>
|
||
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
|
||
保存基本信息
|
||
</button>
|
||
</div>
|
||
</AdminSection>
|
||
|
||
<AdminSettingRow
|
||
title={
|
||
<>
|
||
<UserPlus size={15} /> 开放注册
|
||
</>
|
||
}
|
||
description="关闭后,游客将无法提交注册;已有账号仍可登录。适用于内测或维护期。"
|
||
control={
|
||
<AdminSwitch
|
||
checked={allowRegister}
|
||
disabled={togglingReg}
|
||
onChange={() => void toggleRegister()}
|
||
label="开放注册"
|
||
/>
|
||
}
|
||
/>
|
||
|
||
<AdminSettingRow
|
||
title={
|
||
<>
|
||
<MessageSquare size={15} /> 开放评论
|
||
</>
|
||
}
|
||
description="关闭后全站禁止发表新评论与回复,已有评论仍可浏览。适用于 ICP/IPC 备案审核等合规需要;前台仅提示「暂停评论」,不会展示备案事由。"
|
||
control={
|
||
<AdminSwitch
|
||
checked={allowComments}
|
||
disabled={togglingComments}
|
||
onChange={() => void toggleComments()}
|
||
label="开放评论"
|
||
/>
|
||
}
|
||
/>
|
||
|
||
<AdminSettingRow
|
||
title={
|
||
<>
|
||
<Code2 size={15} /> 代码块自动折叠
|
||
</>
|
||
}
|
||
description="开启后,帖子与评论中超过阈值行数的 Markdown 代码块默认折叠,读者可点「展开全部」。关闭则始终完整展示。"
|
||
control={
|
||
<AdminSwitch
|
||
checked={codeAutoFold}
|
||
disabled={togglingFold}
|
||
onChange={() => void toggleCodeFold()}
|
||
label="代码块自动折叠"
|
||
/>
|
||
}
|
||
>
|
||
{codeAutoFold ? (
|
||
<>
|
||
<label className="field-label" htmlFor="fold-lines">
|
||
折叠行数阈值
|
||
</label>
|
||
<p className="meta text-[12.5px] mb-2.5 leading-relaxed">
|
||
代码块行数超过该值时折叠;折叠后约露出同样行数。设为 0 表示无预览。范围 0–100,默认 5。
|
||
</p>
|
||
<div className="flex flex-wrap items-center gap-2.5">
|
||
<input
|
||
id="fold-lines"
|
||
type="number"
|
||
min={0}
|
||
max={100}
|
||
className="field w-24"
|
||
value={foldLines}
|
||
onChange={(e) => setFoldLines(Number(e.target.value))}
|
||
aria-label="折叠行数阈值"
|
||
/>
|
||
<span className="meta text-[12.5px]">行</span>
|
||
<button
|
||
type="button"
|
||
className="btn btn-line btn-sm"
|
||
disabled={!foldLinesDirty || savingFoldLines}
|
||
onClick={() => void saveFoldLines()}
|
||
>
|
||
{savingFoldLines ? (
|
||
<Loader2 size={14} className="animate-spin" />
|
||
) : (
|
||
<Save size={14} />
|
||
)}
|
||
保存
|
||
</button>
|
||
</div>
|
||
</>
|
||
) : null}
|
||
</AdminSettingRow>
|
||
|
||
<AdminSettingRow
|
||
title={
|
||
<>
|
||
<Sparkles size={15} /> 界面动画
|
||
</>
|
||
}
|
||
description="总开关关闭后全站即时切换(等同减少动态效果)。系统若已开启「减少动态效果」,始终优先生效。开启总开关后可细调子项。"
|
||
control={
|
||
<AdminSwitch
|
||
checked={uiAnimations}
|
||
disabled={togglingMotion}
|
||
onChange={() => void toggleMotionField("ui_animations", !uiAnimations)}
|
||
label="界面动画总开关"
|
||
/>
|
||
}
|
||
>
|
||
{uiAnimations ? (
|
||
<div className="space-y-4">
|
||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||
<div className="min-w-0 flex-1">
|
||
<p className="text-[13.5px] font-medium" style={{ color: "var(--ink)" }}>
|
||
代码折叠动画
|
||
</p>
|
||
<p className="meta text-[12.5px] mt-0.5 leading-relaxed">
|
||
代码块展开/收起的高度过渡与跟滚。
|
||
</p>
|
||
</div>
|
||
<AdminSwitch
|
||
checked={animCodeFold}
|
||
disabled={togglingMotion}
|
||
onChange={() => void toggleMotionField("anim_code_fold", !animCodeFold)}
|
||
label="代码折叠动画"
|
||
/>
|
||
</div>
|
||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||
<div className="min-w-0 flex-1">
|
||
<p className="text-[13.5px] font-medium" style={{ color: "var(--ink)" }}>
|
||
平滑滚动
|
||
</p>
|
||
<p className="meta text-[12.5px] mt-0.5 leading-relaxed">
|
||
目录跳转、回顶、新内容提示等 scroll 行为。
|
||
</p>
|
||
</div>
|
||
<AdminSwitch
|
||
checked={animSmoothScroll}
|
||
disabled={togglingMotion}
|
||
onChange={() =>
|
||
void toggleMotionField("anim_smooth_scroll", !animSmoothScroll)
|
||
}
|
||
label="平滑滚动"
|
||
/>
|
||
</div>
|
||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||
<div className="min-w-0 flex-1">
|
||
<p className="text-[13.5px] font-medium" style={{ color: "var(--ink)" }}>
|
||
装饰入场
|
||
</p>
|
||
<p className="meta text-[12.5px] mt-0.5 leading-relaxed">
|
||
列表入场、Toast、点赞弹出、在线脉冲等装饰性动画。
|
||
</p>
|
||
</div>
|
||
<AdminSwitch
|
||
checked={animChrome}
|
||
disabled={togglingMotion}
|
||
onChange={() => void toggleMotionField("anim_chrome", !animChrome)}
|
||
label="装饰入场"
|
||
/>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</AdminSettingRow>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|