Files
jiang13-bbs/frontend/app/admin/settings/SettingsAdmin.tsx
freefire 809d8f1df7 feat(settings): 后台可配置 Markdown 代码块自动折叠与阈值行数
支持开关与 0–100 行阈值(0 为无预览),经站点设置与 WS 热更新;短代码在视口内收起时不再回滚浏览位置。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-16 06:43:37 +08:00

292 lines
9.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState } from "react";
import {
Code2,
Globe,
Loader2,
Save,
UserPlus,
} from "lucide-react";
import {
apiUpdateSiteSettings,
type PublicSettings,
} from "@/lib/api";
import { toast } from "@/lib/toast";
function Switch({
checked,
disabled,
onChange,
label,
}: {
checked: boolean;
disabled?: boolean;
onChange: () => void;
label: string;
}) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
aria-label={label}
disabled={disabled}
onClick={onChange}
className="shrink-0 relative h-7 w-12 rounded-full transition-colors disabled:opacity-60"
style={{ background: checked ? "var(--accent)" : "var(--line-2)" }}
>
<span
className="absolute top-0.5 left-0.5 h-6 w-6 rounded-full bg-[var(--panel)] shadow transition-transform"
style={{ transform: checked ? "translateX(1.25rem)" : "translateX(0)" }}
/>
</button>
);
}
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 [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 [togglingFold, setTogglingFold] = useState(false);
const [savingFoldLines, setSavingFoldLines] = 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 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);
}
};
return (
<div className="max-w-3xl">
<div className="mb-5">
<h1
className="text-[22px] sm:text-[26px] font-extrabold tracking-tight flex items-center gap-2.5"
style={{ color: "var(--ink)" }}
>
<span
className="w-9 h-9 rounded-xl flex items-center justify-center"
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
>
<Globe size={18} />
</span>
站点设置
</h1>
<p className="meta mt-1.5 text-[12.5px]">
配置站点名称、简介与注册开关;主题色请到「外观」页调整
</p>
</div>
<section className="j13-module p-5 sm:p-6 mb-5">
<h2 className="text-[15px] font-semibold mb-1" style={{ color: "var(--ink)" }}>
基本信息
</h2>
<p className="meta text-[12.5px] mb-4">用于页眉品牌、页脚版权与 SEO 元信息</p>
<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>
</section>
<section className="j13-module p-5 sm:p-6 mb-5 flex items-start justify-between gap-4 flex-wrap">
<div className="min-w-0 flex-1">
<h2 className="text-[15px] font-semibold flex items-center gap-1.5" style={{ color: "var(--ink)" }}>
<UserPlus size={15} /> 开放注册
</h2>
<p className="meta text-[12.5px] mt-1 leading-relaxed">
关闭后,游客将无法提交注册;已有账号仍可登录。适用于内测或维护期。
</p>
</div>
<Switch
checked={allowRegister}
disabled={togglingReg}
onChange={() => void toggleRegister()}
label="开放注册"
/>
</section>
<section className="j13-module p-5 sm:p-6 mb-5">
<div className="flex items-start justify-between gap-4 flex-wrap">
<div className="min-w-0 flex-1">
<h2 className="text-[15px] font-semibold flex items-center gap-1.5" style={{ color: "var(--ink)" }}>
<Code2 size={15} /> 代码块自动折叠
</h2>
<p className="meta text-[12.5px] mt-1 leading-relaxed">
开启后,帖子与评论中超过阈值行数的 Markdown 代码块默认折叠,读者可点「展开全部」。关闭则始终完整展示。
</p>
</div>
<Switch
checked={codeAutoFold}
disabled={togglingFold}
onChange={() => void toggleCodeFold()}
label="代码块自动折叠"
/>
</div>
{codeAutoFold ? (
<div className="mt-4 pt-4 border-t border-[var(--line)]">
<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>
</div>
) : null}
</section>
</div>
);
}