完善站点运营设置:分路由管理、登录可见评论、邮件模板内联与关闭注册 SSR。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -34,12 +34,10 @@ import {
|
||||
apiAdminRejectPost,
|
||||
apiAdminApproveComment,
|
||||
apiAdminRejectComment,
|
||||
apiUpdateSiteSettings,
|
||||
type AdminContentComment,
|
||||
type AdminContentCounts,
|
||||
type AdminContentPost,
|
||||
type AdminContentStatus,
|
||||
type PublicSettings,
|
||||
} from "@/lib/api";
|
||||
import { canModerateAuthor } from "@/lib/roles";
|
||||
import { formatRelative } from "@/lib/format";
|
||||
@@ -58,8 +56,6 @@ import {
|
||||
AdminSearchField,
|
||||
AdminSegmented,
|
||||
AdminStatusChip,
|
||||
AdminStepper,
|
||||
AdminSwitch,
|
||||
contentStatusChip,
|
||||
} from "@/components/admin";
|
||||
|
||||
@@ -184,29 +180,6 @@ function PendingActions({
|
||||
);
|
||||
}
|
||||
|
||||
/** 策略项:标题、控件与保存按钮成组换行,不拆散单项 */
|
||||
function PolicyChip({
|
||||
title,
|
||||
hint,
|
||||
control,
|
||||
instant,
|
||||
}: {
|
||||
title: string;
|
||||
hint?: string;
|
||||
control: ReactNode;
|
||||
instant?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="admin-content-policy-chip" data-tip={hint || undefined}>
|
||||
<span className="admin-content-policy-chip-copy">
|
||||
<span className="admin-content-policy-chip-label">{title}</span>
|
||||
{instant ? <span className="admin-content-policy-chip-how">立即生效</span> : null}
|
||||
</span>
|
||||
<div className="admin-content-policy-chip-ctrl shrink-0 flex items-center gap-1.5">{control}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ColumnShell({
|
||||
icon,
|
||||
title,
|
||||
@@ -290,10 +263,8 @@ export default function ContentAdmin({
|
||||
initialCommentsTotal,
|
||||
initialCounts,
|
||||
initError,
|
||||
canConfigureSettings,
|
||||
canPurge,
|
||||
viewerRole,
|
||||
initialSettings,
|
||||
}: {
|
||||
initialKind: ContentKind;
|
||||
initialStatus: AdminContentStatus;
|
||||
@@ -303,10 +274,8 @@ export default function ContentAdmin({
|
||||
initialCommentsTotal: number;
|
||||
initialCounts: AdminContentCounts;
|
||||
initError: string;
|
||||
canConfigureSettings: boolean;
|
||||
canPurge: boolean;
|
||||
viewerRole: string;
|
||||
initialSettings: PublicSettings;
|
||||
}) {
|
||||
// 窄屏分段:lg 以下只显示一栏;PC 双栏同时可见
|
||||
const [mobileKind, setMobileKind] = useState<ContentKind>(initialKind);
|
||||
@@ -338,11 +307,6 @@ export default function ContentAdmin({
|
||||
|
||||
const [actingKey, setActingKey] = useState<string | null>(null);
|
||||
|
||||
const [trustOn, setTrustOn] = useState(initialSettings.trust_reviewed_publish);
|
||||
const [cooldown, setCooldown] = useState(initialSettings.post_cooldown_hours);
|
||||
const [postLinkNewTab, setPostLinkNewTab] = useState(initialSettings.post_link_new_tab !== false);
|
||||
const [savingPolicy, setSavingPolicy] = useState(false);
|
||||
|
||||
const [confirm, setConfirm] = useState<{
|
||||
title: string;
|
||||
message: string;
|
||||
@@ -463,65 +427,6 @@ export default function ContentAdmin({
|
||||
);
|
||||
};
|
||||
|
||||
const toggleTrust = async () => {
|
||||
if (!canConfigureSettings || savingPolicy) return;
|
||||
setSavingPolicy(true);
|
||||
try {
|
||||
const res = await apiUpdateSiteSettings({ trust_reviewed_publish: !trustOn });
|
||||
setTrustOn(res.trust_reviewed_publish);
|
||||
toast(
|
||||
res.trust_reviewed_publish
|
||||
? "已开启过审免审(帖子与评论)"
|
||||
: "已关闭过审免审(帖子与评论将按规则待审)",
|
||||
"ok"
|
||||
);
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : "保存失败");
|
||||
} finally {
|
||||
setSavingPolicy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveCooldown = async () => {
|
||||
if (!canConfigureSettings || savingPolicy) return;
|
||||
const n = Math.max(0, Math.min(168, Math.round(Number(cooldown)) || 0));
|
||||
setSavingPolicy(true);
|
||||
try {
|
||||
const res = await apiUpdateSiteSettings({ post_cooldown_hours: n });
|
||||
setCooldown(res.post_cooldown_hours);
|
||||
toast(
|
||||
res.post_cooldown_hours === 0
|
||||
? "已关闭新用户发帖冷静期"
|
||||
: `新用户发帖冷静期已设为 ${res.post_cooldown_hours} 小时(评论与聊天不受限)`,
|
||||
"ok"
|
||||
);
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : "保存失败");
|
||||
} finally {
|
||||
setSavingPolicy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const togglePostLinkNewTab = async () => {
|
||||
if (!canConfigureSettings || savingPolicy) return;
|
||||
const next = !postLinkNewTab;
|
||||
setSavingPolicy(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 {
|
||||
setSavingPolicy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const postsToolbar = (
|
||||
<div className="admin-content-col-toolbar">
|
||||
<div className="admin-content-status-scroll">
|
||||
@@ -922,73 +827,9 @@ export default function ContentAdmin({
|
||||
<AdminPageHeader
|
||||
icon={FolderOpen}
|
||||
title="内容管理"
|
||||
description="审核与管理帖子、评论。共用策略作用于全站发帖与评论,与下方列表筛选无关。"
|
||||
description="审核与管理帖子、评论。"
|
||||
/>
|
||||
|
||||
{canConfigureSettings && (
|
||||
<div className="admin-content-policy" role="group" aria-label="全站内容策略">
|
||||
<div className="admin-content-policy-lead">
|
||||
<span className="admin-content-policy-title">共用策略</span>
|
||||
<span className="admin-content-policy-scope">
|
||||
过审免审与外链开关改完立即写入;冷静期需点保存。失败时恢复为已确认值。
|
||||
</span>
|
||||
</div>
|
||||
<div className="admin-content-policy-row">
|
||||
<PolicyChip
|
||||
title="过审免审"
|
||||
instant
|
||||
hint="帖子 / 评论:用户曾有过审内容后,后续发帖与评论直接公开,不再进待审。修改后立即生效。"
|
||||
control={
|
||||
<AdminSwitch
|
||||
checked={trustOn}
|
||||
disabled={savingPolicy}
|
||||
onChange={() => void toggleTrust()}
|
||||
label="过审免审(帖子与评论)"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<PolicyChip
|
||||
title="新用户冷静期"
|
||||
hint="仅限制新注册用户发帖(0=关闭,上限 168 小时)。评论与聊天不受此限制。需点保存后生效。"
|
||||
control={
|
||||
<div className="flex items-center gap-1.5">
|
||||
<AdminStepper
|
||||
value={cooldown}
|
||||
onChange={setCooldown}
|
||||
min={0}
|
||||
max={168}
|
||||
unit="小时"
|
||||
ariaLabel="新用户发帖冷静期小时数"
|
||||
disabled={savingPolicy}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm"
|
||||
disabled={savingPolicy}
|
||||
onClick={() => void saveCooldown()}
|
||||
>
|
||||
{savingPolicy ? "保存中…" : "保存"}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<PolicyChip
|
||||
title="外链新开"
|
||||
instant
|
||||
hint="帖子与评论中的外链在新标签打开。修改后立即生效。"
|
||||
control={
|
||||
<AdminSwitch
|
||||
checked={postLinkNewTab}
|
||||
disabled={savingPolicy}
|
||||
onChange={() => void togglePostLinkNewTab()}
|
||||
label="外链新开(帖子与评论)"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 窄屏:顶部分段切换双栏 */}
|
||||
<div className="admin-content-kind lg:hidden shrink-0" role="tablist" aria-label="内容类型">
|
||||
<button
|
||||
|
||||
@@ -9,8 +9,8 @@ import {
|
||||
type AdminContentPost,
|
||||
type AdminContentStatus,
|
||||
} from "@/lib/api";
|
||||
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
|
||||
import { canModerateAny, isSuperOrOwner, isOwner } from "@/lib/roles";
|
||||
import { getMeCached } from "@/lib/serverData";
|
||||
import { canModerateAny, isOwner } from "@/lib/roles";
|
||||
import Forbidden from "../Forbidden";
|
||||
import ContentAdmin, { type ContentKind } from "./ContentAdmin";
|
||||
|
||||
@@ -51,7 +51,7 @@ export default async function AdminContentPage({ searchParams }: ContentPageProp
|
||||
let initError = "";
|
||||
|
||||
// PC 双栏同时展示:SSR 并行预取帖子与评论
|
||||
const [postsSettled, commentsSettled, counts, settings] = await Promise.all([
|
||||
const [postsSettled, commentsSettled, counts] = await Promise.all([
|
||||
fetchAdminContentPosts(1, initialStatus, cookie || undefined)
|
||||
.then((res) => ({ ok: true as const, res }))
|
||||
.catch((e) => ({
|
||||
@@ -65,7 +65,6 @@ export default async function AdminContentPage({ searchParams }: ContentPageProp
|
||||
error: e instanceof Error ? e.message : "获取评论失败",
|
||||
})),
|
||||
fetchAdminContentCounts(cookie || undefined),
|
||||
getPublicSettingsCached(),
|
||||
]);
|
||||
|
||||
if (postsSettled.ok) {
|
||||
@@ -92,10 +91,8 @@ export default async function AdminContentPage({ searchParams }: ContentPageProp
|
||||
initialCommentsTotal={initialCommentsTotal}
|
||||
initialCounts={counts}
|
||||
initError={initError}
|
||||
canConfigureSettings={isSuperOrOwner(me.user.role)}
|
||||
canPurge={isOwner(me.user.role)}
|
||||
viewerRole={me.user.role}
|
||||
initialSettings={settings}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
280
frontend/app/admin/settings/AccessSettings.tsx
Normal file
280
frontend/app/admin/settings/AccessSettings.tsx
Normal file
@@ -0,0 +1,280 @@
|
||||
"use client";
|
||||
|
||||
import { ExternalLink, LogIn, MessageSquare, MessagesSquare, ShieldCheck, Timer } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
AdminSettingsGroup,
|
||||
AdminSettingsRow,
|
||||
AdminStepper,
|
||||
AdminSwitch,
|
||||
} from "@/components/admin";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog";
|
||||
import useUnsavedGuard from "@/hooks/useUnsavedGuard";
|
||||
import { apiUpdateSiteSettings, type PublicSettings } from "@/lib/api";
|
||||
import { toast } from "@/lib/toast";
|
||||
import OperationsPanel from "./OperationsPanel";
|
||||
|
||||
function InstantNote({ saving }: { saving?: boolean }) {
|
||||
return <span className="admin-settings-instant">{saving ? "正在保存…" : "修改后立即生效"}</span>;
|
||||
}
|
||||
|
||||
/** 内容互动开关 + 访问安全模块 */
|
||||
export default function AccessSettings({ initial }: { initial: PublicSettings }) {
|
||||
const [allowComments, setAllowComments] = useState(initial.allow_comments !== false);
|
||||
const [commentsRequireLogin, setCommentsRequireLogin] = useState(
|
||||
initial.comments_require_login === true,
|
||||
);
|
||||
const [allowMessages, setAllowMessages] = useState(initial.allow_messages !== false);
|
||||
const [postLinkNewTab, setPostLinkNewTab] = useState(initial.post_link_new_tab !== false);
|
||||
const [trustOn, setTrustOn] = useState(initial.trust_reviewed_publish !== false);
|
||||
const [cooldown, setCooldown] = useState(initial.post_cooldown_hours);
|
||||
const [savedCooldown, setSavedCooldown] = useState(initial.post_cooldown_hours);
|
||||
const [togglingComments, setTogglingComments] = useState(false);
|
||||
const [togglingCommentsLogin, setTogglingCommentsLogin] = useState(false);
|
||||
const [togglingMessages, setTogglingMessages] = useState(false);
|
||||
const [togglingLink, setTogglingLink] = useState(false);
|
||||
const [togglingTrust, setTogglingTrust] = useState(false);
|
||||
const [savingCooldown, setSavingCooldown] = useState(false);
|
||||
const [moduleDirty, setModuleDirty] = useState(false);
|
||||
const leave = useUnsavedGuard(moduleDirty);
|
||||
|
||||
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 toggleCommentsRequireLogin = async () => {
|
||||
if (togglingCommentsLogin || !allowComments) return;
|
||||
const next = !commentsRequireLogin;
|
||||
setTogglingCommentsLogin(true);
|
||||
try {
|
||||
const res = await apiUpdateSiteSettings({ comments_require_login: next });
|
||||
setCommentsRequireLogin(res.comments_require_login === true);
|
||||
toast(res.comments_require_login ? "评论仅登录用户可见" : "游客也可查看评论", "ok");
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : "保存失败");
|
||||
} finally {
|
||||
setTogglingCommentsLogin(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 toggleTrust = async () => {
|
||||
if (togglingTrust) return;
|
||||
setTogglingTrust(true);
|
||||
try {
|
||||
const res = await apiUpdateSiteSettings({ trust_reviewed_publish: !trustOn });
|
||||
setTrustOn(res.trust_reviewed_publish);
|
||||
toast(
|
||||
res.trust_reviewed_publish
|
||||
? "已开启过审免审(帖子与评论)"
|
||||
: "已关闭过审免审(帖子与评论将按规则待审)",
|
||||
"ok",
|
||||
);
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : "保存失败");
|
||||
} finally {
|
||||
setTogglingTrust(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveCooldown = async () => {
|
||||
if (savingCooldown) return;
|
||||
const n = Math.max(0, Math.min(168, Math.round(Number(cooldown)) || 0));
|
||||
setSavingCooldown(true);
|
||||
try {
|
||||
const res = await apiUpdateSiteSettings({ post_cooldown_hours: n });
|
||||
setCooldown(res.post_cooldown_hours);
|
||||
setSavedCooldown(res.post_cooldown_hours);
|
||||
toast(
|
||||
res.post_cooldown_hours === 0
|
||||
? "已关闭新用户发帖冷静期"
|
||||
: `新用户发帖冷静期已设为 ${res.post_cooldown_hours} 小时(评论与聊天不受限)`,
|
||||
"ok",
|
||||
);
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : "保存失败");
|
||||
} finally {
|
||||
setSavingCooldown(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="admin-settings-stack">
|
||||
<AdminSettingsGroup title="内容与互动">
|
||||
<AdminSettingsRow
|
||||
label={
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<MessageSquare size={15} /> 开放评论
|
||||
</span>
|
||||
}
|
||||
hintBelow="关闭后全站不提供评论功能。"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<AdminSwitch
|
||||
checked={allowComments}
|
||||
disabled={togglingComments}
|
||||
onChange={() => void toggleComments()}
|
||||
label="开放评论"
|
||||
/>
|
||||
<InstantNote saving={togglingComments} />
|
||||
</div>
|
||||
</AdminSettingsRow>
|
||||
<AdminSettingsRow
|
||||
label={
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<LogIn size={15} /> 登录可见评论
|
||||
</span>
|
||||
}
|
||||
hintBelow="开启后游客无法查看评论内容,需登录后阅读与参与。"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<AdminSwitch
|
||||
checked={commentsRequireLogin}
|
||||
disabled={!allowComments || togglingCommentsLogin}
|
||||
onChange={() => void toggleCommentsRequireLogin()}
|
||||
label="登录可见评论"
|
||||
/>
|
||||
<InstantNote saving={togglingCommentsLogin} />
|
||||
</div>
|
||||
</AdminSettingsRow>
|
||||
<AdminSettingsRow
|
||||
label={
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<MessagesSquare size={15} /> 开放消息
|
||||
</span>
|
||||
}
|
||||
hintBelow="关闭后前台隐藏私聊与群聊入口。"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<AdminSwitch
|
||||
checked={allowMessages}
|
||||
disabled={togglingMessages}
|
||||
onChange={() => void toggleMessages()}
|
||||
label="开放消息"
|
||||
/>
|
||||
<InstantNote saving={togglingMessages} />
|
||||
</div>
|
||||
</AdminSettingsRow>
|
||||
<AdminSettingsRow
|
||||
label={
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<ExternalLink size={15} /> 外链新标签打开
|
||||
</span>
|
||||
}
|
||||
hintBelow="帖子与评论中的外链是否在新标签打开。"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<AdminSwitch
|
||||
checked={postLinkNewTab}
|
||||
disabled={togglingLink}
|
||||
onChange={() => void togglePostLinkNewTab()}
|
||||
label="外链新标签打开"
|
||||
/>
|
||||
<InstantNote saving={togglingLink} />
|
||||
</div>
|
||||
</AdminSettingsRow>
|
||||
<AdminSettingsRow
|
||||
label={
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<ShieldCheck size={15} /> 过审免审
|
||||
</span>
|
||||
}
|
||||
hintBelow="用户曾有过审内容后,后续发帖与评论直接公开。"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<AdminSwitch
|
||||
checked={trustOn}
|
||||
disabled={togglingTrust}
|
||||
onChange={() => void toggleTrust()}
|
||||
label="过审免审"
|
||||
/>
|
||||
<InstantNote saving={togglingTrust} />
|
||||
</div>
|
||||
</AdminSettingsRow>
|
||||
<AdminSettingsRow
|
||||
label={
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Timer size={15} /> 新用户发帖冷静期
|
||||
</span>
|
||||
}
|
||||
hintBelow="仅限制新注册用户发帖(0=关闭,上限 168 小时)。评论与聊天不受限。"
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<AdminStepper
|
||||
value={cooldown}
|
||||
onChange={setCooldown}
|
||||
min={0}
|
||||
max={168}
|
||||
unit="小时"
|
||||
ariaLabel="新用户发帖冷静期小时数"
|
||||
disabled={savingCooldown}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm"
|
||||
disabled={savingCooldown || cooldown === savedCooldown}
|
||||
onClick={() => void saveCooldown()}
|
||||
>
|
||||
{savingCooldown ? "保存中…" : "保存"}
|
||||
</button>
|
||||
</div>
|
||||
</AdminSettingsRow>
|
||||
</AdminSettingsGroup>
|
||||
<OperationsPanel name="security" onDirtyChange={setModuleDirty} />
|
||||
<ConfirmDialog
|
||||
open={leave.leaveOpen}
|
||||
title="有未保存的修改"
|
||||
message="离开后,当前保存单元里尚未写入的修改会丢失。确定离开?"
|
||||
confirmLabel="离开"
|
||||
danger
|
||||
onCancel={leave.cancelLeave}
|
||||
onConfirm={leave.confirmLeave}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
import { X } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { AdminField, AdminSettingsActions, AdminSettingsGroup, AdminSettingsRow } from "@/components/admin";
|
||||
import { apiUpdateSiteSettings, type PublicSettings } from "@/lib/api";
|
||||
import { AdminField, AdminSettingsActions, AdminSettingsGroup } from "@/components/admin";
|
||||
import { apiUpdateSiteSettings, apiOperations, type PublicSettings } from "@/lib/api";
|
||||
import { BRAND_LIMITS, runeCount, siteDocumentTitle } from "@/lib/brand";
|
||||
import { toast } from "@/lib/toast";
|
||||
|
||||
@@ -17,6 +17,12 @@ export default function BasicIdentityPanel({
|
||||
onDirtyChange?: (dirty: boolean) => void;
|
||||
}) {
|
||||
const [name, setName] = useState(initial.site_name);
|
||||
const [siteURL, setSiteURL] = useState("");
|
||||
useEffect(() => {
|
||||
apiOperations<{ site_url: string }>("/api/site-state")
|
||||
.then((s) => setSiteURL(s.site_url))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
const [wordmark, setWordmark] = useState(initial.site_wordmark);
|
||||
const [slogan, setSlogan] = useState(initial.site_slogan);
|
||||
const [desc, setDesc] = useState(initial.site_description);
|
||||
@@ -48,7 +54,10 @@ export default function BasicIdentityPanel({
|
||||
const preview = siteDocumentTitle(name, slogan);
|
||||
|
||||
const addKeyword = (raw: string) => {
|
||||
const parts = raw.split(/[,,;;]/).map((s) => s.trim()).filter(Boolean);
|
||||
const parts = raw
|
||||
.split(/[,,;;]/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
if (parts.length === 0) return;
|
||||
setSaveError("");
|
||||
setKeywords((prev) => {
|
||||
@@ -121,9 +130,11 @@ export default function BasicIdentityPanel({
|
||||
setWordmark((v) => (v.trim() === snap.wordmark ? res.site_wordmark : v));
|
||||
setSlogan((v) => (v.trim() === snap.slogan ? res.site_slogan : v));
|
||||
setDesc((v) => (v.trim() === snap.desc ? res.site_description : v));
|
||||
setKeywords((prev) => (JSON.stringify(prev) === JSON.stringify(snap.keywords) ? res.site_keywords : prev));
|
||||
setKeywords((prev) =>
|
||||
JSON.stringify(prev) === JSON.stringify(snap.keywords) ? res.site_keywords : prev,
|
||||
);
|
||||
onSaved?.(res);
|
||||
toast("基础资料已保存", "ok");
|
||||
toast("已保存", "ok");
|
||||
} catch (e) {
|
||||
if (ticket !== saveGen.current) return;
|
||||
const msg = e instanceof Error ? e.message : "保存失败";
|
||||
@@ -134,145 +145,147 @@ export default function BasicIdentityPanel({
|
||||
}
|
||||
};
|
||||
|
||||
const clearSaveError = () => {
|
||||
if (saveError) setSaveError("");
|
||||
};
|
||||
|
||||
return (
|
||||
<AdminSettingsGroup
|
||||
title="基础资料"
|
||||
description="用于页眉、浏览器标题、公开页摘要与搜索关键词。保存后对访客生效。"
|
||||
id="settings-identity"
|
||||
title="站点身份"
|
||||
footer={
|
||||
<AdminSettingsActions
|
||||
dirty={dirty}
|
||||
saving={saving}
|
||||
saveLabel="保存基础资料"
|
||||
saveLabel="保存"
|
||||
error={saveError}
|
||||
onSave={() => void save()}
|
||||
onDiscard={discard}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<AdminSettingsRow label="显示名称" htmlFor="site-name" hintBelow="用于版权行与浏览器标题">
|
||||
<AdminField
|
||||
id="site-name"
|
||||
label="显示名称"
|
||||
embed
|
||||
className="admin-settings-control-text"
|
||||
maxLength={32}
|
||||
value={name}
|
||||
onChange={(v) => {
|
||||
setName(v);
|
||||
if (nameError) setNameError("");
|
||||
if (saveError) setSaveError("");
|
||||
}}
|
||||
placeholder="姜十三论坛"
|
||||
error={nameError}
|
||||
/>
|
||||
</AdminSettingsRow>
|
||||
<AdminSettingsRow
|
||||
label="字标"
|
||||
htmlFor="site-wordmark"
|
||||
hintBelow="无 Logo 或纯文字页眉时使用;留空则使用显示名称"
|
||||
>
|
||||
<AdminField
|
||||
id="site-wordmark"
|
||||
label="字标"
|
||||
embed
|
||||
className="admin-settings-control-text"
|
||||
maxLength={BRAND_LIMITS.wordmark}
|
||||
value={wordmark}
|
||||
onChange={(v) => {
|
||||
setWordmark(v);
|
||||
if (saveError) setSaveError("");
|
||||
}}
|
||||
placeholder="姜十三"
|
||||
/>
|
||||
</AdminSettingsRow>
|
||||
<AdminSettingsRow
|
||||
label="标语"
|
||||
htmlFor="site-slogan"
|
||||
hintBelow={
|
||||
<>
|
||||
出现在浏览器标题和页眉副标题。预览:
|
||||
<span style={{ color: "var(--ink)" }}>{preview}</span>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="admin-identity-studio">
|
||||
<div className="admin-identity-url" role="group" aria-label="正式站点地址">
|
||||
<span className="admin-identity-url-label">正式地址</span>
|
||||
<code className="admin-identity-url-value">{siteURL || "尚未部署 SITE_URL"}</code>
|
||||
</div>
|
||||
|
||||
<div className="admin-identity-grid">
|
||||
<AdminField
|
||||
id="site-name"
|
||||
label="显示名称"
|
||||
className="admin-identity-field"
|
||||
maxLength={32}
|
||||
value={name}
|
||||
onChange={(v) => {
|
||||
setName(v);
|
||||
if (nameError) setNameError("");
|
||||
clearSaveError();
|
||||
}}
|
||||
placeholder="姜十三论坛"
|
||||
error={nameError}
|
||||
/>
|
||||
<AdminField
|
||||
id="site-wordmark"
|
||||
label="字标"
|
||||
className="admin-identity-field"
|
||||
maxLength={BRAND_LIMITS.wordmark}
|
||||
value={wordmark}
|
||||
onChange={(v) => {
|
||||
setWordmark(v);
|
||||
clearSaveError();
|
||||
}}
|
||||
placeholder="姜十三"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AdminField
|
||||
id="site-slogan"
|
||||
label="标语"
|
||||
embed
|
||||
className="admin-settings-control-text"
|
||||
className="admin-identity-field"
|
||||
maxLength={BRAND_LIMITS.slogan}
|
||||
value={slogan}
|
||||
onChange={(v) => {
|
||||
setSlogan(v);
|
||||
if (saveError) setSaveError("");
|
||||
clearSaveError();
|
||||
}}
|
||||
placeholder="技术分享与讨论"
|
||||
/>
|
||||
</AdminSettingsRow>
|
||||
<AdminSettingsRow label="简介" htmlFor="site-desc" hintBelow="用于页面摘要与搜索描述" wide>
|
||||
<p className="admin-identity-title-preview" aria-live="polite">
|
||||
浏览器标题预览 · <strong>{preview}</strong>
|
||||
</p>
|
||||
|
||||
<AdminField
|
||||
id="site-desc"
|
||||
label="简介"
|
||||
embed
|
||||
as="textarea"
|
||||
className="admin-settings-control-long"
|
||||
minHeight="96px"
|
||||
className="admin-identity-field"
|
||||
minHeight="72px"
|
||||
maxLength={160}
|
||||
value={desc}
|
||||
onChange={(v) => {
|
||||
setDesc(v);
|
||||
if (saveError) setSaveError("");
|
||||
clearSaveError();
|
||||
}}
|
||||
placeholder="一句话介绍社区定位…"
|
||||
placeholder="一句话介绍社区…"
|
||||
/>
|
||||
</AdminSettingsRow>
|
||||
<AdminSettingsRow
|
||||
label="SEO 关键词"
|
||||
htmlFor="seo-keyword-input"
|
||||
hintBelow={`用于搜索结果。最多 ${BRAND_LIMITS.keywordCount} 个,每个 ${BRAND_LIMITS.keywordRunes} 字。点击已添加的词可删除。`}
|
||||
wide
|
||||
>
|
||||
<div className="admin-settings-control-long">
|
||||
<div className="admin-settings-keywords">
|
||||
{keywords.map((k) => (
|
||||
<button
|
||||
key={k}
|
||||
type="button"
|
||||
className="admin-settings-kw"
|
||||
onClick={() => {
|
||||
setKeywords((prev) => prev.filter((x) => x !== k));
|
||||
if (saveError) setSaveError("");
|
||||
}}
|
||||
>
|
||||
{k}
|
||||
<X size={12} />
|
||||
</button>
|
||||
))}
|
||||
{keywords.length === 0 ? <span className="meta text-[12.5px]">尚未添加</span> : null}
|
||||
|
||||
<div className="admin-identity-keywords">
|
||||
<div className="admin-identity-keywords-head">
|
||||
<label htmlFor="seo-keyword-input" className="field-label">
|
||||
关键词
|
||||
</label>
|
||||
<span className="meta text-[12px]">
|
||||
{keywords.length}/{BRAND_LIMITS.keywordCount}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="admin-field-shell flex-1 min-w-0">
|
||||
<input
|
||||
id="seo-keyword-input"
|
||||
value={kwInput}
|
||||
onChange={(e) => setKwInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === "," || e.key === ",") {
|
||||
e.preventDefault();
|
||||
addKeyword(kwInput);
|
||||
}
|
||||
}}
|
||||
placeholder="输入后回车添加"
|
||||
className="admin-field-input"
|
||||
aria-label="添加关键词"
|
||||
/>
|
||||
<div className="admin-identity-keywords-box">
|
||||
{keywords.length > 0 ? (
|
||||
<div className="admin-settings-keywords">
|
||||
{keywords.map((k) => (
|
||||
<button
|
||||
key={k}
|
||||
type="button"
|
||||
className="admin-settings-kw"
|
||||
onClick={() => {
|
||||
setKeywords((prev) => prev.filter((x) => x !== k));
|
||||
clearSaveError();
|
||||
}}
|
||||
>
|
||||
{k}
|
||||
<X size={12} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="admin-identity-keywords-add">
|
||||
<div className="admin-field-shell flex-1 min-w-0">
|
||||
<input
|
||||
id="seo-keyword-input"
|
||||
value={kwInput}
|
||||
onChange={(e) => setKwInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === "," || e.key === ",") {
|
||||
e.preventDefault();
|
||||
addKeyword(kwInput);
|
||||
}
|
||||
}}
|
||||
placeholder="回车添加"
|
||||
className="admin-field-input"
|
||||
aria-label="添加关键词"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm shrink-0"
|
||||
onClick={() => addKeyword(kwInput)}
|
||||
>
|
||||
添加
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" className="btn btn-line btn-sm shrink-0" onClick={() => addKeyword(kwInput)}>
|
||||
添加
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</AdminSettingsRow>
|
||||
</div>
|
||||
</AdminSettingsGroup>
|
||||
);
|
||||
}
|
||||
|
||||
39
frontend/app/admin/settings/BasicSettings.tsx
Normal file
39
frontend/app/admin/settings/BasicSettings.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog";
|
||||
import useUnsavedGuard from "@/hooks/useUnsavedGuard";
|
||||
import BasicIdentityPanel from "./BasicIdentityPanel";
|
||||
import BrandSeoPanel from "./BrandSeoPanel";
|
||||
import FooterLinksPanel from "./FooterLinksPanel";
|
||||
import type { PublicSettings } from "@/lib/api";
|
||||
|
||||
export default function BasicSettings({ initial }: { initial: PublicSettings }) {
|
||||
const [live, setLive] = useState(initial);
|
||||
const [basicDirty, setBasicDirty] = useState(false);
|
||||
const [brandDirty, setBrandDirty] = useState(false);
|
||||
const [seoDirty, setSeoDirty] = useState(false);
|
||||
const dirty = basicDirty || brandDirty || seoDirty;
|
||||
const leave = useUnsavedGuard(dirty);
|
||||
|
||||
const onBasicDirty = useCallback((d: boolean) => setBasicDirty(d), []);
|
||||
const onBrandDirty = useCallback((d: boolean) => setBrandDirty(d), []);
|
||||
const onSeoDirty = useCallback((d: boolean) => setSeoDirty(d), []);
|
||||
|
||||
return (
|
||||
<div className="admin-settings-stack">
|
||||
<BasicIdentityPanel initial={live} onSaved={setLive} onDirtyChange={onBasicDirty} />
|
||||
<BrandSeoPanel initial={live} onSaved={setLive} onDirtyChange={onBrandDirty} />
|
||||
<FooterLinksPanel initial={live} onSaved={setLive} onDirtyChange={onSeoDirty} />
|
||||
<ConfirmDialog
|
||||
open={leave.leaveOpen}
|
||||
title="有未保存的修改"
|
||||
message="离开后,当前保存单元里尚未写入的修改会丢失。确定离开?"
|
||||
confirmLabel="离开"
|
||||
danger
|
||||
onCancel={leave.cancelLeave}
|
||||
onConfirm={leave.confirmLeave}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
AdminSegmented,
|
||||
AdminSettingsActions,
|
||||
AdminSettingsGroup,
|
||||
AdminSettingsRow,
|
||||
} from "@/components/admin";
|
||||
import BrandLockup from "@/components/BrandLockup";
|
||||
import { apiBrandFromMedia, apiUpdateSiteSettings, apiUploadBrand, type PublicSettings } from "@/lib/api";
|
||||
@@ -44,10 +43,10 @@ function slotDirty(d: SlotDraft, saved: string): boolean {
|
||||
return d.committed !== saved;
|
||||
}
|
||||
|
||||
const SLOTS: { id: BrandSlot; label: string; hint: string; swatch: "light" | "dark" | "neutral" }[] = [
|
||||
{ id: "logo_light", label: "浅色主题 Logo", hint: "浅色主题页眉。只传一套时,暗色共用这张。", swatch: "light" },
|
||||
{ id: "logo_dark", label: "深色主题 Logo", hint: "深色主题页眉。PNG / WebP / SVG,不超过 2MB。", swatch: "dark" },
|
||||
{ id: "favicon", label: "Favicon", hint: "浏览器标签图标。另支持 ICO,不超过 512KB。", swatch: "neutral" },
|
||||
const SLOTS: { id: BrandSlot; label: string; swatch: "light" | "dark" | "neutral"; compact?: boolean }[] = [
|
||||
{ id: "logo_light", label: "浅色 Logo", swatch: "light" },
|
||||
{ id: "logo_dark", label: "深色 Logo", swatch: "dark" },
|
||||
{ id: "favicon", label: "Favicon", swatch: "neutral", compact: true },
|
||||
];
|
||||
|
||||
export default function BrandSeoPanel({
|
||||
@@ -116,13 +115,8 @@ export default function BrandSeoPanel({
|
||||
}, [dirty, onDirtyChange]);
|
||||
|
||||
const resolveSlot = async (slot: BrandSlot, draft: SlotDraft): Promise<string> => {
|
||||
if (draft.file) {
|
||||
const url = await apiUploadBrand(slot, draft.file);
|
||||
return url;
|
||||
}
|
||||
if (draft.mediaId != null) {
|
||||
return apiBrandFromMedia(slot, draft.mediaId);
|
||||
}
|
||||
if (draft.file) return apiUploadBrand(slot, draft.file);
|
||||
if (draft.mediaId != null) return apiBrandFromMedia(slot, draft.mediaId);
|
||||
return draft.committed;
|
||||
};
|
||||
|
||||
@@ -142,11 +136,7 @@ export default function BrandSeoPanel({
|
||||
const save = async () => {
|
||||
if (saving || !dirty) return;
|
||||
const ticket = ++saveGen.current;
|
||||
const snap = {
|
||||
mark,
|
||||
size: logoSize,
|
||||
fit: logoFit,
|
||||
};
|
||||
const snap = { mark, size: logoSize, fit: logoFit };
|
||||
setSaving(true);
|
||||
setSaveError("");
|
||||
try {
|
||||
@@ -188,14 +178,18 @@ export default function BrandSeoPanel({
|
||||
return cur;
|
||||
});
|
||||
setFavicon((cur) => {
|
||||
if (cur.file === favicon.file && cur.mediaId === favicon.mediaId && cur.committed === favicon.committed) {
|
||||
if (
|
||||
cur.file === favicon.file &&
|
||||
cur.mediaId === favicon.mediaId &&
|
||||
cur.committed === favicon.committed
|
||||
) {
|
||||
if (cur.blob) URL.revokeObjectURL(cur.blob);
|
||||
return emptyDraft(res.favicon_url);
|
||||
}
|
||||
return cur;
|
||||
});
|
||||
onSaved?.(res);
|
||||
toast("品牌设置已保存", "ok");
|
||||
toast("已保存", "ok");
|
||||
} catch (e) {
|
||||
if (ticket !== saveGen.current) return;
|
||||
const msg = e instanceof Error ? e.message : "保存失败";
|
||||
@@ -206,7 +200,11 @@ export default function BrandSeoPanel({
|
||||
}
|
||||
};
|
||||
|
||||
const renderSlot = (slot: BrandSlot, label: string, hint: string, swatch: "light" | "dark" | "neutral", draft: SlotDraft) => {
|
||||
const slotDraft = (id: BrandSlot) =>
|
||||
id === "logo_dark" ? dark : id === "favicon" ? favicon : light;
|
||||
|
||||
const renderSlot = (slot: BrandSlot, label: string, swatch: "light" | "dark" | "neutral", compact?: boolean) => {
|
||||
const draft = slotDraft(slot);
|
||||
const preview = previewOf(draft);
|
||||
const swatchClass =
|
||||
swatch === "light"
|
||||
@@ -215,55 +213,60 @@ export default function BrandSeoPanel({
|
||||
? "admin-brand-swatch-dark"
|
||||
: "admin-brand-swatch-neutral";
|
||||
return (
|
||||
<div key={slot} className="admin-brand-slot-card">
|
||||
<p className="text-[13px] font-medium" style={{ color: "var(--ink)" }}>
|
||||
{label}
|
||||
</p>
|
||||
<p className="meta text-[12px] mt-0.5 mb-2">{hint}</p>
|
||||
<div key={slot} className={`admin-brand-asset${compact ? " is-compact" : ""}`}>
|
||||
<div className={`admin-brand-swatch ${swatchClass}`}>
|
||||
{preview ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={preview} alt={`${label}预览`} />
|
||||
<img src={preview} alt="" />
|
||||
) : (
|
||||
<span className="text-[12.5px]">未设置</span>
|
||||
<span>未设置</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5 mt-2">
|
||||
<label className="btn btn-line btn-sm cursor-pointer">
|
||||
<ImagePlus size={14} /> 上传
|
||||
<input
|
||||
type="file"
|
||||
accept={brandAccept(slot)}
|
||||
className="hidden"
|
||||
aria-label={`上传${label}`}
|
||||
<div className="admin-brand-asset-meta">
|
||||
<span className="admin-brand-asset-label">{label}</span>
|
||||
<div className="admin-brand-asset-actions">
|
||||
<label className="btn btn-line btn-sm cursor-pointer">
|
||||
<ImagePlus size={14} /> 上传
|
||||
<input
|
||||
type="file"
|
||||
accept={brandAccept(slot)}
|
||||
className="hidden"
|
||||
aria-label={`上传${label}`}
|
||||
disabled={saving}
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = "";
|
||||
if (!file) return;
|
||||
const err = brandFileOk(slot, file);
|
||||
if (err) {
|
||||
toast(err);
|
||||
return;
|
||||
}
|
||||
replaceFile(slot, file);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm"
|
||||
disabled={saving}
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = "";
|
||||
if (!file) return;
|
||||
const err = brandFileOk(slot, file);
|
||||
if (err) {
|
||||
toast(err);
|
||||
return;
|
||||
}
|
||||
replaceFile(slot, file);
|
||||
onClick={() => setPicker(slot)}
|
||||
>
|
||||
<Images size={14} /> 库
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-icon-btn"
|
||||
aria-label={`清除${label}`}
|
||||
disabled={saving || !preview}
|
||||
onClick={() => {
|
||||
if (draft.blob) URL.revokeObjectURL(draft.blob);
|
||||
drafts[slot][1](emptyDraft(""));
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<button type="button" className="btn btn-line btn-sm" disabled={saving} onClick={() => setPicker(slot)}>
|
||||
<Images size={14} /> 媒体库
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm"
|
||||
disabled={saving || !preview}
|
||||
onClick={() => {
|
||||
if (draft.blob) URL.revokeObjectURL(draft.blob);
|
||||
drafts[slot][1](emptyDraft(""));
|
||||
}}
|
||||
>
|
||||
<X size={14} /> 清除
|
||||
</button>
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -271,41 +274,26 @@ export default function BrandSeoPanel({
|
||||
|
||||
return (
|
||||
<AdminSettingsGroup
|
||||
title="品牌与图标"
|
||||
description="预览随编辑更新,保存后对访客生效。"
|
||||
id="settings-brand"
|
||||
title="品牌"
|
||||
footer={
|
||||
<AdminSettingsActions
|
||||
dirty={dirty}
|
||||
saving={saving}
|
||||
saveLabel="保存品牌设置"
|
||||
saveLabel="保存"
|
||||
error={saveError}
|
||||
onSave={() => void save()}
|
||||
onDiscard={discard}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<AdminSettingsRow label="页眉形式" hintBelow="图加文字和纯图共用画幅与铺法">
|
||||
<AdminSegmented
|
||||
role="group"
|
||||
size="sm"
|
||||
ariaLabel="页眉形式"
|
||||
value={mark}
|
||||
onChange={(v) => {
|
||||
setMark(v);
|
||||
if (saveError) setSaveError("");
|
||||
}}
|
||||
options={[
|
||||
{ key: "image_text", label: "图加文字" },
|
||||
{ key: "image", label: "纯图" },
|
||||
{ key: "text", label: "纯文字" },
|
||||
]}
|
||||
/>
|
||||
</AdminSettingsRow>
|
||||
<AdminSettingsRow
|
||||
label="品牌预览"
|
||||
hintBelow={dirty ? "页眉效果预览;站点仍显示已保存版本,保存后对访客生效" : "页眉效果预览,保存后对访客生效"}
|
||||
>
|
||||
<div className="admin-brand-lockup-preview">
|
||||
<div className="admin-brand-studio">
|
||||
<div className="admin-brand-studio-preview" aria-label="页眉预览">
|
||||
<div className="admin-brand-studio-chrome">
|
||||
<span aria-hidden />
|
||||
<span aria-hidden />
|
||||
<span aria-hidden />
|
||||
</div>
|
||||
<div className="admin-brand-lockup-preview-bar">
|
||||
<BrandLockup
|
||||
mode={mark}
|
||||
@@ -320,82 +308,75 @@ export default function BrandSeoPanel({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</AdminSettingsRow>
|
||||
{mark !== "text" ? (
|
||||
<>
|
||||
<AdminSettingsRow label="Logo 比例">
|
||||
<div className="flex flex-wrap gap-2" role="group" aria-label="Logo 比例">
|
||||
{BRAND_LOGO_SIZES.map((item) => {
|
||||
const on = logoSize === item.id;
|
||||
const thumbH = 16;
|
||||
const thumbW = Math.round((item.w / item.h) * thumbH);
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className="rounded-xl px-3 py-2 text-left inline-flex items-center gap-2"
|
||||
style={{
|
||||
background: on ? "var(--accent-soft)" : "var(--panel)",
|
||||
color: on ? "var(--accent)" : "var(--ink)",
|
||||
boxShadow: on ? "inset 0 0 0 1px var(--accent)" : "inset 0 0 0 1px var(--line)",
|
||||
}}
|
||||
onClick={() => {
|
||||
setLogoSize(item.id);
|
||||
if (saveError) setSaveError("");
|
||||
}}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="shrink-0 rounded-sm"
|
||||
style={{
|
||||
width: thumbW,
|
||||
height: thumbH,
|
||||
boxShadow: "inset 0 0 0 1.5px currentColor",
|
||||
}}
|
||||
/>
|
||||
<span>
|
||||
<span className="block text-[13px] font-medium">{item.label}</span>
|
||||
<span className="meta text-[12px]">
|
||||
{item.w}×{item.h}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</AdminSettingsRow>
|
||||
<AdminSettingsRow label="适配方式" hintBelow="图片如何铺进所选画幅">
|
||||
|
||||
<div className="admin-brand-studio-toolbar">
|
||||
<div className="admin-brand-studio-field">
|
||||
<span className="admin-brand-studio-kicker">形式</span>
|
||||
<AdminSegmented
|
||||
role="group"
|
||||
size="sm"
|
||||
ariaLabel="适配方式"
|
||||
value={logoFit}
|
||||
ariaLabel="页眉形式"
|
||||
value={mark}
|
||||
onChange={(v) => {
|
||||
setLogoFit(v);
|
||||
setMark(v);
|
||||
if (saveError) setSaveError("");
|
||||
}}
|
||||
options={BRAND_LOGO_FITS.map((item) => ({ key: item.id, label: item.label }))}
|
||||
options={[
|
||||
{ key: "image_text", label: "图加文字" },
|
||||
{ key: "image", label: "纯图" },
|
||||
{ key: "text", label: "纯文字" },
|
||||
]}
|
||||
/>
|
||||
</AdminSettingsRow>
|
||||
</>
|
||||
) : (
|
||||
<AdminSettingsRow label="画幅与铺法">
|
||||
<p className="meta text-[12.5px]">纯文字页眉不显示 Logo。画幅和铺法只作用于图加文字和纯图。</p>
|
||||
</AdminSettingsRow>
|
||||
)}
|
||||
<AdminSettingsRow label="站点图标" hintBelow="设置不同主题的站点标识及浏览器图标" wide>
|
||||
<div className="admin-brand-slots">
|
||||
{SLOTS.map((s) =>
|
||||
renderSlot(
|
||||
s.id,
|
||||
s.label,
|
||||
s.hint,
|
||||
s.swatch,
|
||||
s.id === "logo_dark" ? dark : s.id === "favicon" ? favicon : light
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
{mark !== "text" ? (
|
||||
<>
|
||||
<div className="admin-brand-studio-field">
|
||||
<span className="admin-brand-studio-kicker">比例</span>
|
||||
<div className="admin-brand-size-row" role="group" aria-label="Logo 比例">
|
||||
{BRAND_LOGO_SIZES.map((item) => {
|
||||
const on = logoSize === item.id;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className={`admin-brand-size-chip${on ? " is-on" : ""}`}
|
||||
onClick={() => {
|
||||
setLogoSize(item.id);
|
||||
if (saveError) setSaveError("");
|
||||
}}
|
||||
>
|
||||
<span className="admin-brand-size-chip-label">{item.label}</span>
|
||||
<span className="admin-brand-size-chip-px">
|
||||
{item.w}×{item.h}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-brand-studio-field">
|
||||
<span className="admin-brand-studio-kicker">铺法</span>
|
||||
<AdminSegmented
|
||||
role="group"
|
||||
size="sm"
|
||||
ariaLabel="适配方式"
|
||||
value={logoFit}
|
||||
onChange={(v) => {
|
||||
setLogoFit(v);
|
||||
if (saveError) setSaveError("");
|
||||
}}
|
||||
options={BRAND_LOGO_FITS.map((item) => ({ key: item.id, label: item.label }))}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</AdminSettingsRow>
|
||||
|
||||
<div className="admin-brand-studio-assets" aria-label="品牌素材">
|
||||
{SLOTS.map((s) => renderSlot(s.id, s.label, s.swatch, s.compact))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BrandMediaPicker
|
||||
open={picker != null}
|
||||
slot={picker || "logo_light"}
|
||||
|
||||
382
frontend/app/admin/settings/FilterRules.tsx
Normal file
382
frontend/app/admin/settings/FilterRules.tsx
Normal file
@@ -0,0 +1,382 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { Download, Plus, Upload } from "lucide-react";
|
||||
import Modal from "@/components/Modal";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog";
|
||||
|
||||
export type Rule = {
|
||||
id: string;
|
||||
word: string;
|
||||
scopes: string[];
|
||||
action: string;
|
||||
enabled: boolean;
|
||||
exceptions: string[];
|
||||
note: string;
|
||||
};
|
||||
|
||||
const scopes = [
|
||||
["username", "用户名"],
|
||||
["title", "帖子标题"],
|
||||
["body", "正文"],
|
||||
["comment", "评论"],
|
||||
] as const;
|
||||
|
||||
const empty = (): Rule => ({
|
||||
id: crypto.randomUUID(),
|
||||
word: "",
|
||||
scopes: ["title", "body", "comment"],
|
||||
action: "block",
|
||||
enabled: true,
|
||||
exceptions: [],
|
||||
note: "",
|
||||
});
|
||||
|
||||
export default function FilterRules({
|
||||
rules,
|
||||
onChange,
|
||||
}: {
|
||||
rules: Rule[];
|
||||
onChange: (rules: Rule[]) => void;
|
||||
}) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [editing, setEditing] = useState<Rule | null>(null);
|
||||
const [selected, setSelected] = useState<string[]>([]);
|
||||
const [importText, setImportText] = useState("");
|
||||
const [replace, setReplace] = useState(false);
|
||||
const [confirm, setConfirm] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const filtered = rules.filter((r) => r.word.includes(query) || r.note.includes(query));
|
||||
const pageCount = Math.max(1, Math.ceil(filtered.length / 12));
|
||||
const pageItems = filtered.slice((page - 1) * 12, page * 12);
|
||||
|
||||
const preview = useMemo(() => {
|
||||
const known = new Set(rules.map((r) => r.word.normalize("NFKC").toLowerCase()));
|
||||
const seen = new Set<string>();
|
||||
const added: string[] = [];
|
||||
let duplicate = 0;
|
||||
let invalid = 0;
|
||||
for (const line of importText.split(/\r?\n/)) {
|
||||
const word = line.trim();
|
||||
if (!word) continue;
|
||||
if ([...word].length > 80) {
|
||||
invalid++;
|
||||
continue;
|
||||
}
|
||||
const n = word.normalize("NFKC").toLowerCase();
|
||||
if (seen.has(n) || (!replace && known.has(n))) {
|
||||
duplicate++;
|
||||
continue;
|
||||
}
|
||||
seen.add(n);
|
||||
added.push(word);
|
||||
}
|
||||
return { added, duplicate, invalid };
|
||||
}, [importText, rules, replace]);
|
||||
|
||||
const doImport = () => {
|
||||
const next = preview.added.map((word) => ({ ...empty(), word }));
|
||||
if ((replace ? 0 : rules.length) + next.length > 2000) {
|
||||
setError("最多 2000 条规则");
|
||||
return;
|
||||
}
|
||||
onChange(replace ? next : [...rules, ...next]);
|
||||
setImportText("");
|
||||
setConfirm(false);
|
||||
};
|
||||
|
||||
const exportRules = () => {
|
||||
const blob = new Blob([rules.map((r) => r.word).join("\n")], { type: "text/plain;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "content-filter.txt";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const toggleSelectAll = (on: boolean) => {
|
||||
setSelected(on ? pageItems.map((r) => r.id) : []);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ops-filter">
|
||||
<div className="ops-filter-toolbar">
|
||||
<input
|
||||
className="field ops-input"
|
||||
aria-label="搜索规则"
|
||||
placeholder="搜索词语或备注"
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
<span className="meta text-[12.5px] whitespace-nowrap">
|
||||
{rules.length}/2000
|
||||
</span>
|
||||
<button type="button" className="btn btn-line btn-sm" onClick={() => setEditing(empty())}>
|
||||
<Plus size={14} /> 添加
|
||||
</button>
|
||||
<button type="button" className="btn btn-line btn-sm" onClick={exportRules}>
|
||||
<Download size={14} /> 导出
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{selected.length > 0 && (
|
||||
<div className="ops-filter-bulk">
|
||||
<span className="meta text-[12.5px]">已选 {selected.length}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm"
|
||||
onClick={() => onChange(rules.map((r) => (selected.includes(r.id) ? { ...r, enabled: true } : r)))}
|
||||
>
|
||||
启用
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm"
|
||||
onClick={() => onChange(rules.map((r) => (selected.includes(r.id) ? { ...r, enabled: false } : r)))}
|
||||
>
|
||||
停用
|
||||
</button>
|
||||
<button type="button" className="ops-text-btn" onClick={() => setSelected([])}>
|
||||
取消选择
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="ops-filter-table" role="table" aria-label="过滤规则">
|
||||
<div className="ops-filter-head" role="row">
|
||||
<label className="ops-filter-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={pageItems.length > 0 && pageItems.every((r) => selected.includes(r.id))}
|
||||
onChange={(e) => toggleSelectAll(e.target.checked)}
|
||||
aria-label="全选本页"
|
||||
/>
|
||||
</label>
|
||||
<span>词语</span>
|
||||
<span>范围</span>
|
||||
<span>处理</span>
|
||||
<span>状态</span>
|
||||
<span />
|
||||
</div>
|
||||
{pageItems.map((r) => (
|
||||
<div className="ops-filter-row" role="row" key={r.id}>
|
||||
<label className="ops-filter-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.includes(r.id)}
|
||||
onChange={(e) =>
|
||||
setSelected((a) => (e.target.checked ? [...a, r.id] : a.filter((id) => id !== r.id)))
|
||||
}
|
||||
aria-label={`选择 ${r.word}`}
|
||||
/>
|
||||
</label>
|
||||
<strong className="ops-filter-word">{r.word}</strong>
|
||||
<span className="meta text-[12.5px]">
|
||||
{r.scopes.map((s) => scopes.find(([k]) => k === s)?.[1]).filter(Boolean).join("、")}
|
||||
</span>
|
||||
<span className="meta text-[12.5px]">{r.action === "block" ? "拦截" : "仅记录"}</span>
|
||||
<span className={`ops-filter-state ${r.enabled ? "is-on" : ""}`}>{r.enabled ? "启用" : "停用"}</span>
|
||||
<div className="ops-filter-row-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm"
|
||||
onClick={() => setEditing({ ...r, scopes: [...r.scopes], exceptions: [...r.exceptions] })}
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ops-text-btn"
|
||||
onClick={() => onChange(rules.map((x) => (x.id === r.id ? { ...x, enabled: !x.enabled } : x)))}
|
||||
>
|
||||
{r.enabled ? "停用" : "启用"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{!filtered.length && <p className="ops-hint ops-filter-empty">尚无规则</p>}
|
||||
</div>
|
||||
|
||||
{pageCount > 1 && (
|
||||
<div className="ops-filter-pager">
|
||||
<button type="button" className="btn btn-line btn-sm" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
|
||||
上一页
|
||||
</button>
|
||||
<span className="meta text-[12.5px]">
|
||||
{page} / {pageCount}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm"
|
||||
disabled={page >= pageCount}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<details className="ops-advanced ops-filter-import">
|
||||
<summary>
|
||||
<Upload size={14} aria-hidden /> 从 TXT 导入
|
||||
</summary>
|
||||
<input
|
||||
type="file"
|
||||
accept=".txt,text/plain"
|
||||
aria-label="导入 TXT"
|
||||
className="mt-3"
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
if (file.size > 300000) {
|
||||
setError("TXT 文件上限 300 KB");
|
||||
return;
|
||||
}
|
||||
setImportText(await file.text());
|
||||
}}
|
||||
/>
|
||||
<textarea
|
||||
rows={4}
|
||||
className="field mt-3 ops-input"
|
||||
aria-label="导入词语"
|
||||
value={importText}
|
||||
onChange={(e) => setImportText(e.target.value)}
|
||||
placeholder="每行一个词语"
|
||||
/>
|
||||
<label className="ops-filter-import-opt">
|
||||
<input type="checkbox" checked={replace} onChange={(e) => setReplace(e.target.checked)} />
|
||||
覆盖全部规则(默认合并)
|
||||
</label>
|
||||
<p className="meta text-[12.5px]">
|
||||
新增 {preview.added.length} · 重复 {preview.duplicate} · 无效 {preview.invalid}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm"
|
||||
disabled={!preview.added.length}
|
||||
onClick={() => (replace ? setConfirm(true) : doImport())}
|
||||
>
|
||||
应用到表单
|
||||
</button>
|
||||
</details>
|
||||
|
||||
{error && (
|
||||
<p role="alert" className="alert-error">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{editing && (
|
||||
<Modal open title="编辑过滤规则" onClose={() => setEditing(null)} maxWidthClass="max-w-xl">
|
||||
<div className="ops-filter-editor">
|
||||
<label>
|
||||
词语
|
||||
<input
|
||||
autoFocus
|
||||
className="field"
|
||||
maxLength={80}
|
||||
value={editing.word}
|
||||
onChange={(e) => setEditing({ ...editing, word: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<fieldset>
|
||||
<legend>适用范围</legend>
|
||||
{scopes.map(([k, l]) => (
|
||||
<label key={k} className="mr-3 inline-flex items-center gap-1.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editing.scopes.includes(k)}
|
||||
onChange={(e) =>
|
||||
setEditing({
|
||||
...editing,
|
||||
scopes: e.target.checked
|
||||
? [...editing.scopes, k]
|
||||
: editing.scopes.filter((s) => s !== k),
|
||||
})
|
||||
}
|
||||
/>
|
||||
{l}
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
<label>
|
||||
命中处理
|
||||
<select
|
||||
className="field ops-select"
|
||||
value={editing.action}
|
||||
onChange={(e) => setEditing({ ...editing, action: e.target.value })}
|
||||
>
|
||||
<option value="block">拦截提交</option>
|
||||
<option value="log">仅记录</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
例外短语(每行一个)
|
||||
<textarea
|
||||
className="field"
|
||||
rows={3}
|
||||
value={editing.exceptions.join("\n")}
|
||||
onChange={(e) => setEditing({ ...editing, exceptions: e.target.value.split("\n") })}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
备注
|
||||
<input
|
||||
className="field"
|
||||
value={editing.note}
|
||||
onChange={(e) => setEditing({ ...editing, note: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label className="inline-flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editing.enabled}
|
||||
onChange={(e) => setEditing({ ...editing, enabled: e.target.checked })}
|
||||
/>
|
||||
启用
|
||||
</label>
|
||||
<div className="ops-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
disabled={!editing.word.trim() || !editing.scopes.length}
|
||||
onClick={() => {
|
||||
const value = {
|
||||
...editing,
|
||||
word: editing.word.trim(),
|
||||
exceptions: editing.exceptions.map((s) => s.trim()).filter(Boolean),
|
||||
};
|
||||
onChange(
|
||||
rules.some((r) => r.id === value.id)
|
||||
? rules.map((r) => (r.id === value.id ? value : r))
|
||||
: [...rules, value]
|
||||
);
|
||||
setEditing(null);
|
||||
}}
|
||||
>
|
||||
应用到表单
|
||||
</button>
|
||||
<button type="button" className="btn btn-line btn-sm" onClick={() => setEditing(null)}>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirm}
|
||||
title="覆盖当前词库"
|
||||
message={`将用 ${preview.added.length} 条导入规则替换现有 ${rules.length} 条;保存本页后生效。`}
|
||||
onConfirm={doImport}
|
||||
onCancel={() => setConfirm(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowDown, ArrowUp, Plus, Trash2 } from "lucide-react";
|
||||
import { ArrowDown, ArrowUp, ExternalLink, Plus, Trash2 } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { AdminField, AdminSettingsActions, AdminSettingsGroup, AdminSettingsRow } from "@/components/admin";
|
||||
import { AdminSettingsActions, AdminSettingsGroup } from "@/components/admin";
|
||||
import { apiUpdateSiteSettings, type PublicSettings } from "@/lib/api";
|
||||
import {
|
||||
BRAND_LIMITS,
|
||||
FOOTER_LINKS_ALIGNS,
|
||||
footerURLAllowed,
|
||||
isExternalFooterURL,
|
||||
type FooterLink,
|
||||
type FooterLinksAlign,
|
||||
} from "@/lib/brand";
|
||||
import { toast } from "@/lib/toast";
|
||||
|
||||
@@ -16,6 +18,10 @@ function clone(links: FooterLink[]): FooterLink[] {
|
||||
return links.map((l) => ({ ...l }));
|
||||
}
|
||||
|
||||
function zoneOf(links: FooterLink[], align: FooterLinksAlign): FooterLink[] {
|
||||
return links.filter((l) => (l.align || "right") === align && l.label.trim() && l.url.trim());
|
||||
}
|
||||
|
||||
export default function FooterLinksPanel({
|
||||
initial,
|
||||
onSaved,
|
||||
@@ -32,6 +38,10 @@ export default function FooterLinksPanel({
|
||||
const saveGen = useRef(0);
|
||||
|
||||
const dirty = JSON.stringify(links) !== JSON.stringify(savedLinks);
|
||||
const left = zoneOf(links, "left");
|
||||
const center = zoneOf(links, "center");
|
||||
const right = zoneOf(links, "right");
|
||||
const hasPreview = left.length + center.length + right.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
onDirtyChange?.(dirty);
|
||||
@@ -66,6 +76,7 @@ export default function FooterLinksPanel({
|
||||
label: item.label.trim(),
|
||||
url: item.url.trim(),
|
||||
new_tab: item.new_tab,
|
||||
align: item.align || "right",
|
||||
}));
|
||||
const ticket = ++saveGen.current;
|
||||
setSaving(true);
|
||||
@@ -77,7 +88,7 @@ export default function FooterLinksPanel({
|
||||
setSavedLinks(clone(res.footer_links));
|
||||
setLinks((prev) => (JSON.stringify(prev) === JSON.stringify(links) ? clone(res.footer_links) : prev));
|
||||
onSaved?.(res);
|
||||
toast("页脚已保存", "ok");
|
||||
toast("已保存", "ok");
|
||||
} catch (e) {
|
||||
if (ticket !== saveGen.current) return;
|
||||
const msg = e instanceof Error ? e.message : "保存失败";
|
||||
@@ -99,33 +110,150 @@ export default function FooterLinksPanel({
|
||||
});
|
||||
};
|
||||
|
||||
const renderPreviewLinks = (items: FooterLink[]) =>
|
||||
items.map((item) => (
|
||||
<span key={`${item.align}-${item.label}-${item.url}`} className="admin-footer-preview-link">
|
||||
{item.label.trim()}
|
||||
{item.new_tab ? <ExternalLink size={11} aria-hidden /> : null}
|
||||
</span>
|
||||
));
|
||||
|
||||
return (
|
||||
<AdminSettingsGroup
|
||||
title="页脚"
|
||||
description="备案、协议等链接。保存后出现在站点页脚右侧。"
|
||||
id="settings-footer"
|
||||
title="页脚链接"
|
||||
footer={
|
||||
<AdminSettingsActions
|
||||
dirty={dirty}
|
||||
saving={saving}
|
||||
saveLabel="保存页脚"
|
||||
saveLabel="保存"
|
||||
error={error}
|
||||
onSave={() => void save()}
|
||||
onDiscard={discard}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<AdminSettingsRow
|
||||
label="页脚链接"
|
||||
hintBelow="用于 ICP 备案、用户协议等,最多 8 条。外链默认新标签打开。"
|
||||
wide
|
||||
>
|
||||
<div className="admin-settings-control-long flex flex-col gap-3">
|
||||
{links.map((item, i) => (
|
||||
<div key={i} className="admin-footer-item">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="meta text-[12px]">第 {i + 1} 条</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<button type="button" className="admin-icon-btn" aria-label="上移" disabled={i === 0} onClick={() => move(i, -1)}>
|
||||
<div className="admin-footer-studio">
|
||||
<div className="admin-footer-preview" aria-label="页脚预览">
|
||||
<div className="admin-footer-preview-zone admin-footer-preview-zone-left">
|
||||
<span className="admin-footer-preview-copy">
|
||||
© {new Date().getFullYear()} {initial.site_name}
|
||||
</span>
|
||||
{left.length > 0 ? <nav className="admin-footer-preview-links">{renderPreviewLinks(left)}</nav> : null}
|
||||
</div>
|
||||
<div className="admin-footer-preview-zone admin-footer-preview-zone-center">
|
||||
{center.length > 0 ? (
|
||||
<nav className="admin-footer-preview-links">{renderPreviewLinks(center)}</nav>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="admin-footer-preview-zone admin-footer-preview-zone-right">
|
||||
{right.length > 0 ? (
|
||||
<nav className="admin-footer-preview-links">{renderPreviewLinks(right)}</nav>
|
||||
) : !hasPreview ? (
|
||||
<span className="admin-footer-preview-empty meta text-[12px]">暂无链接</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-footer-table" role="table" aria-label="页脚链接列表">
|
||||
<div className="admin-footer-table-head" role="row">
|
||||
<span className="admin-footer-col-idx" aria-hidden>
|
||||
#
|
||||
</span>
|
||||
<span>文案</span>
|
||||
<span>地址</span>
|
||||
<span className="admin-footer-col-align">位置</span>
|
||||
<span className="admin-footer-col-open">打开方式</span>
|
||||
<span className="admin-footer-col-ops">
|
||||
<span className="sr-only">操作</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{links.length === 0 ? (
|
||||
<p className="admin-footer-empty">暂无链接,可添加备案号、用户协议等</p>
|
||||
) : (
|
||||
links.map((item, i) => (
|
||||
<div key={i} className="admin-footer-table-row" role="row">
|
||||
<span className="admin-footer-col-idx meta">{i + 1}</span>
|
||||
<div className="admin-field-shell admin-footer-cell-label">
|
||||
<input
|
||||
id={`footer-label-${i}`}
|
||||
className="admin-field-input"
|
||||
maxLength={BRAND_LIMITS.footerLabel}
|
||||
value={item.label}
|
||||
onChange={(e) => update(i, { label: e.target.value })}
|
||||
placeholder="如:京 ICP 备…"
|
||||
aria-label={`第 ${i + 1} 条文案`}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-field-shell admin-footer-cell-url">
|
||||
<input
|
||||
id={`footer-url-${i}`}
|
||||
className="admin-field-input"
|
||||
maxLength={BRAND_LIMITS.footerURL}
|
||||
value={item.url}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
const wasExt = isExternalFooterURL(item.url);
|
||||
const nowExt = isExternalFooterURL(v);
|
||||
update(i, { url: v, new_tab: !wasExt && nowExt ? true : item.new_tab });
|
||||
}}
|
||||
placeholder="https://… 或 /about"
|
||||
aria-label={`第 ${i + 1} 条地址`}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-footer-col-align">
|
||||
<div
|
||||
className="admin-footer-align-toggle"
|
||||
role="group"
|
||||
aria-label={`第 ${i + 1} 条位置`}
|
||||
>
|
||||
{FOOTER_LINKS_ALIGNS.map((opt) => (
|
||||
<button
|
||||
key={opt.id}
|
||||
type="button"
|
||||
className={`admin-footer-align-opt${(item.align || "right") === opt.id ? " is-on" : ""}`}
|
||||
aria-pressed={(item.align || "right") === opt.id}
|
||||
onClick={() => update(i, { align: opt.id })}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-footer-col-open">
|
||||
<div
|
||||
className="admin-footer-open-toggle"
|
||||
role="group"
|
||||
aria-label={`第 ${i + 1} 条打开方式`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={`admin-footer-open-opt${!item.new_tab ? " is-on" : ""}`}
|
||||
aria-pressed={!item.new_tab}
|
||||
onClick={() => update(i, { new_tab: false })}
|
||||
>
|
||||
本页
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`admin-footer-open-opt${item.new_tab ? " is-on" : ""}`}
|
||||
aria-pressed={item.new_tab}
|
||||
onClick={() => update(i, { new_tab: true })}
|
||||
>
|
||||
<ExternalLink size={12} aria-hidden />
|
||||
新标签
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-footer-col-ops">
|
||||
<button
|
||||
type="button"
|
||||
className="admin-icon-btn"
|
||||
aria-label="上移"
|
||||
disabled={i === 0}
|
||||
onClick={() => move(i, -1)}
|
||||
>
|
||||
<ArrowUp size={14} />
|
||||
</button>
|
||||
<button
|
||||
@@ -147,69 +275,24 @@ export default function FooterLinksPanel({
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<AdminField
|
||||
id={`footer-label-${i}`}
|
||||
label="文案"
|
||||
maxLength={BRAND_LIMITS.footerLabel}
|
||||
value={item.label}
|
||||
onChange={(v) => update(i, { label: v })}
|
||||
placeholder="京 ICP 备xxxxxxxx号"
|
||||
/>
|
||||
<AdminField
|
||||
id={`footer-url-${i}`}
|
||||
label="地址"
|
||||
maxLength={BRAND_LIMITS.footerURL}
|
||||
value={item.url}
|
||||
onChange={(v) => {
|
||||
const wasExt = isExternalFooterURL(item.url);
|
||||
const nowExt = isExternalFooterURL(v);
|
||||
update(i, { url: v, new_tab: !wasExt && nowExt ? true : item.new_tab });
|
||||
}}
|
||||
placeholder="https://beian.miit.gov.cn/ 或 /about"
|
||||
/>
|
||||
<label className="flex items-center gap-2 text-[13px]" style={{ color: "var(--ink-2)" }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={item.new_tab}
|
||||
onChange={(e) => update(i, { new_tab: e.target.checked })}
|
||||
/>
|
||||
新标签打开
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm self-start"
|
||||
disabled={links.length >= BRAND_LIMITS.footerLinks}
|
||||
onClick={() => setLinks((prev) => [...prev, { label: "", url: "", new_tab: false }])}
|
||||
>
|
||||
<Plus size={14} /> 新增链接
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</AdminSettingsRow>
|
||||
|
||||
<AdminSettingsRow label="页脚预览" hintBelow="当前编辑预览,保存后页脚才会更新">
|
||||
<div className="admin-footer-preview">
|
||||
<div className="flex items-center gap-x-3 gap-y-1 flex-wrap min-w-0">
|
||||
<span className="meta text-[12px] whitespace-nowrap">
|
||||
© {new Date().getFullYear()} {initial.site_name}
|
||||
</span>
|
||||
<span className="meta text-[12px] whitespace-nowrap">版本:— · 页面:— · 模板:—</span>
|
||||
</div>
|
||||
{links.filter((l) => l.label.trim() && l.url.trim()).length > 0 ? (
|
||||
<div className="flex flex-wrap items-center justify-end gap-x-3 gap-y-1 min-w-0 ml-auto">
|
||||
{links
|
||||
.filter((l) => l.label.trim() && l.url.trim())
|
||||
.map((item) => (
|
||||
<span key={`${item.label}-${item.url}`} className="text-[12px] break-all" style={{ color: "var(--ink-2)" }}>
|
||||
{item.label.trim()}
|
||||
{item.new_tab ? " ↗" : ""}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</AdminSettingsRow>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm self-start"
|
||||
disabled={links.length >= BRAND_LIMITS.footerLinks}
|
||||
onClick={() =>
|
||||
setLinks((prev) => [...prev, { label: "", url: "", new_tab: false, align: "right" }])
|
||||
}
|
||||
>
|
||||
<Plus size={14} /> 新增链接
|
||||
<span className="meta text-[12px] ml-1">
|
||||
{links.length}/{BRAND_LIMITS.footerLinks}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</AdminSettingsGroup>
|
||||
);
|
||||
}
|
||||
|
||||
27
frontend/app/admin/settings/ModuleSettings.tsx
Normal file
27
frontend/app/admin/settings/ModuleSettings.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog";
|
||||
import useUnsavedGuard from "@/hooks/useUnsavedGuard";
|
||||
import OperationsPanel, { type ModuleName } from "./OperationsPanel";
|
||||
|
||||
/** 运营模块页:保存单元离开保护 */
|
||||
export default function ModuleSettings({ name }: { name: Exclude<ModuleName, "security"> }) {
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const leave = useUnsavedGuard(dirty);
|
||||
|
||||
return (
|
||||
<div className="admin-settings-stack">
|
||||
<OperationsPanel name={name} onDirtyChange={setDirty} />
|
||||
<ConfirmDialog
|
||||
open={leave.leaveOpen}
|
||||
title="有未保存的修改"
|
||||
message="离开后,当前保存单元里尚未写入的修改会丢失。确定离开?"
|
||||
confirmLabel="离开"
|
||||
danger
|
||||
onCancel={leave.cancelLeave}
|
||||
onConfirm={leave.confirmLeave}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1075
frontend/app/admin/settings/OperationsPanel.tsx
Normal file
1075
frontend/app/admin/settings/OperationsPanel.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,864 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
CheckCircle2,
|
||||
ExternalLink,
|
||||
GitBranch,
|
||||
Globe,
|
||||
Image as ImageIcon,
|
||||
Loader2,
|
||||
MessageSquare,
|
||||
MessagesSquare,
|
||||
Paperclip,
|
||||
RotateCcw,
|
||||
UserPlus,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react";
|
||||
import {
|
||||
AdminExtChipEditor,
|
||||
AdminPageHeader,
|
||||
AdminSettingsActions,
|
||||
AdminSettingsGroup,
|
||||
AdminSettingsPage,
|
||||
AdminSettingsRow,
|
||||
AdminSettingsWork,
|
||||
AdminStepper,
|
||||
AdminSwitch,
|
||||
} from "@/components/admin";
|
||||
import { apiGetAdminSettings, apiUpdateSiteSettings, type PublicSettings } from "@/lib/api";
|
||||
import { toast } from "@/lib/toast";
|
||||
import useUnsavedGuard from "@/hooks/useUnsavedGuard";
|
||||
import BasicIdentityPanel from "./BasicIdentityPanel";
|
||||
import BrandSeoPanel from "./BrandSeoPanel";
|
||||
import FooterLinksPanel from "./FooterLinksPanel";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog";
|
||||
|
||||
function extsEqual(a: string[], b: string[]) {
|
||||
if (a.length !== b.length) return false;
|
||||
return a.every((v, i) => v === b[i]);
|
||||
}
|
||||
|
||||
/** 与后端 DefaultTimelineGitAdapterJSON 对齐的内置默认适配器 */
|
||||
const DEFAULT_TIMELINE_GIT_IMPORT = `{
|
||||
"max_pages": 10,
|
||||
"max_commits": 100,
|
||||
"sources": [
|
||||
{
|
||||
"id": "github",
|
||||
"host": "github.com",
|
||||
"list_path": "^/(?P<owner>[^/]+)/(?P<repo>[^/]+)/commits/(?P<ref>[^/]+)/?$",
|
||||
"commit_path": "^/(?P<owner>[^/]+)/(?P<repo>[^/]+)/commit/(?P<sha>[0-9a-fA-F]{7,64})/?$",
|
||||
"api_url": "https://api.github.com/repos/{owner}/{repo}/commits",
|
||||
"commit_api": "https://api.github.com/repos/{owner}/{repo}/commits/{sha}",
|
||||
"query": { "sha": "{ref}", "per_page": "100", "page": "{page}" },
|
||||
"headers": { "User-Agent": "jiang13-bbs", "Accept": "application/vnd.github+json" },
|
||||
"pagination": "link_header",
|
||||
"item": {
|
||||
"sha": "sha",
|
||||
"date": "commit.committer.date",
|
||||
"message": "commit.message",
|
||||
"source_url": "html_url"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "gitea",
|
||||
"host": "*",
|
||||
"list_path": "^/(?P<owner>[^/]+)/(?P<repo>[^/]+)/commits/branch/(?P<ref>.+)/?$",
|
||||
"commit_path": "^/(?P<owner>[^/]+)/(?P<repo>[^/]+)/commit/(?P<sha>[0-9a-fA-F]{7,64})/?$",
|
||||
"api_url": "https://{host}/api/v1/repos/{owner}/{repo}/commits",
|
||||
"commit_api": "https://{host}/api/v1/repos/{owner}/{repo}/git/commits/{sha}",
|
||||
"query": { "sha": "{ref}", "limit": "50", "page": "{page}" },
|
||||
"headers": { "User-Agent": "jiang13-bbs", "Accept": "application/json" },
|
||||
"pagination": "query_page",
|
||||
"item": {
|
||||
"sha": "sha",
|
||||
"date": "created",
|
||||
"message": "commit.message",
|
||||
"source_url": "html_url"
|
||||
}
|
||||
}
|
||||
]
|
||||
}`;
|
||||
|
||||
type SectionId = "basic" | "content" | "gitimport";
|
||||
|
||||
const NAV: { id: SectionId; label: string }[] = [
|
||||
{ id: "basic", label: "基本信息" },
|
||||
{ id: "content", label: "内容与互动" },
|
||||
{ id: "gitimport", label: "提交导入" },
|
||||
];
|
||||
|
||||
const COMING_SOON = ["安全与限流", "邮件", "对象存储", "敏感词", "系统维护"];
|
||||
|
||||
function parseSection(hash: string): SectionId {
|
||||
const id = hash.replace(/^#/, "");
|
||||
if (id === "content" || id === "gitimport" || id === "basic") return id;
|
||||
return "basic";
|
||||
}
|
||||
|
||||
function InstantNote({ saving }: { saving?: boolean }) {
|
||||
return <span className="admin-settings-instant">{saving ? "正在保存…" : "修改后立即生效"}</span>;
|
||||
}
|
||||
|
||||
export default function SettingsAdmin({ initial }: { initial: PublicSettings }) {
|
||||
const [live, setLive] = useState(initial);
|
||||
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 [togglingReg, setTogglingReg] = useState(false);
|
||||
const [togglingComments, setTogglingComments] = useState(false);
|
||||
const [togglingMessages, setTogglingMessages] = useState(false);
|
||||
const [togglingLink, setTogglingLink] = useState(false);
|
||||
const [active, setActive] = useState<SectionId>("basic");
|
||||
const navRefs = useRef<(HTMLAnchorElement | null)[]>([]);
|
||||
|
||||
const [extLimit, setExtLimit] = useState(initial.attachment_ext_limit !== false);
|
||||
const [exts, setExts] = useState<string[]>(() => [...(initial.attachment_exts || [])]);
|
||||
const [savedExts, setSavedExts] = useState<string[]>(() => [...(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 confirmedLimits = useRef({
|
||||
attachment_max_mb: initial.attachment_max_mb || 20,
|
||||
attachment_max_count: initial.attachment_max_count || 10,
|
||||
image_max_mb: initial.image_max_mb || 5,
|
||||
});
|
||||
const limitsReq = useRef(0);
|
||||
|
||||
const [gitImportJSON, setGitImportJSON] = useState("");
|
||||
const [savedGitImport, setSavedGitImport] = useState("");
|
||||
const [gitImportLoading, setGitImportLoading] = useState(false);
|
||||
const [gitImportLoaded, setGitImportLoaded] = useState(false);
|
||||
const [savingGitImport, setSavingGitImport] = useState(false);
|
||||
const [restoreGitOpen, setRestoreGitOpen] = useState(false);
|
||||
const [gitValidateMsg, setGitValidateMsg] = useState<{ ok: boolean; text: string } | null>(null);
|
||||
|
||||
const [basicDirty, setBasicDirty] = useState(false);
|
||||
const [brandDirty, setBrandDirty] = useState(false);
|
||||
const [seoDirty, setSeoDirty] = useState(false);
|
||||
const [pendingSection, setPendingSection] = useState<SectionId | null>(null);
|
||||
|
||||
const extsDirty = useMemo(() => !extsEqual(exts, savedExts), [exts, savedExts]);
|
||||
const gitImportDirty = gitImportJSON !== savedGitImport;
|
||||
const gitPreview = useMemo(() => {
|
||||
try {
|
||||
const parsed = JSON.parse(gitImportJSON) as {
|
||||
max_pages?: unknown;
|
||||
max_commits?: unknown;
|
||||
sources?: unknown;
|
||||
};
|
||||
if (!Array.isArray(parsed.sources)) return null;
|
||||
const sources = parsed.sources
|
||||
.filter((s): s is Record<string, unknown> => !!s && typeof s === "object")
|
||||
.map((s) => ({
|
||||
id: typeof s.id === "string" ? s.id : "—",
|
||||
host: typeof s.host === "string" ? s.host : "*",
|
||||
}));
|
||||
return {
|
||||
maxPages: typeof parsed.max_pages === "number" ? parsed.max_pages : null,
|
||||
maxCommits: typeof parsed.max_commits === "number" ? parsed.max_commits : null,
|
||||
sources,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, [gitImportJSON]);
|
||||
|
||||
const tabDirty =
|
||||
active === "basic" ? basicDirty || brandDirty || seoDirty : active === "content" ? extsDirty : gitImportDirty;
|
||||
|
||||
const leave = useUnsavedGuard(tabDirty);
|
||||
|
||||
const onBasicDirty = useCallback((d: boolean) => setBasicDirty(d), []);
|
||||
const onBrandDirty = useCallback((d: boolean) => setBrandDirty(d), []);
|
||||
const onSeoDirty = useCallback((d: boolean) => setSeoDirty(d), []);
|
||||
|
||||
const loadGitImport = async () => {
|
||||
if (gitImportLoading) return;
|
||||
setGitImportLoading(true);
|
||||
try {
|
||||
const res = await apiGetAdminSettings();
|
||||
const raw = res.timeline_git_import?.trim() || DEFAULT_TIMELINE_GIT_IMPORT;
|
||||
let pretty = raw;
|
||||
try {
|
||||
pretty = JSON.stringify(JSON.parse(raw), null, 2);
|
||||
} catch {
|
||||
/* 保持原文 */
|
||||
}
|
||||
setGitImportJSON(pretty);
|
||||
setSavedGitImport(pretty);
|
||||
setGitImportLoaded(true);
|
||||
setGitValidateMsg(null);
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : "加载适配器失败");
|
||||
} finally {
|
||||
setGitImportLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void loadGitImport();
|
||||
// 仅挂载时拉取一次
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const apply = () => setActive(parseSection(window.location.hash));
|
||||
apply();
|
||||
if (!window.location.hash) {
|
||||
history.replaceState(null, "", `${window.location.pathname}${window.location.search}#basic`);
|
||||
}
|
||||
window.addEventListener("hashchange", apply);
|
||||
return () => window.removeEventListener("hashchange", apply);
|
||||
}, []);
|
||||
|
||||
const goSection = (id: SectionId) => {
|
||||
if (id === active) return;
|
||||
setActive(id);
|
||||
const next = `${window.location.pathname}${window.location.search}#${id}`;
|
||||
history.replaceState(null, "", next);
|
||||
document.getElementById("main")?.scrollTo({ top: 0 });
|
||||
if (id === "gitimport" && !gitImportLoaded && !gitImportLoading) {
|
||||
void loadGitImport();
|
||||
}
|
||||
};
|
||||
|
||||
const selectTab = (id: SectionId) => {
|
||||
if (id === active) return;
|
||||
if (tabDirty) {
|
||||
setPendingSection(id);
|
||||
return;
|
||||
}
|
||||
goSection(id);
|
||||
};
|
||||
|
||||
const onNavKeyDown = (e: KeyboardEvent<HTMLAnchorElement>, i: number) => {
|
||||
if (e.key !== "ArrowDown" && e.key !== "ArrowUp" && e.key !== "Home" && e.key !== "End" && e.key !== "ArrowRight" && e.key !== "ArrowLeft") {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
let next = i;
|
||||
if (e.key === "ArrowDown" || e.key === "ArrowRight") next = (i + 1) % NAV.length;
|
||||
else if (e.key === "ArrowUp" || e.key === "ArrowLeft") next = (i + NAV.length - 1) % NAV.length;
|
||||
else if (e.key === "Home") next = 0;
|
||||
else next = NAV.length - 1;
|
||||
selectTab(NAV[next].id);
|
||||
navRefs.current[next]?.focus();
|
||||
};
|
||||
|
||||
const validateGitImportClient = (): boolean => {
|
||||
try {
|
||||
JSON.parse(gitImportJSON);
|
||||
setGitValidateMsg({ ok: true, text: "JSON 语法有效(服务端保存时还会做结构校验)" });
|
||||
return true;
|
||||
} catch (e) {
|
||||
setGitValidateMsg({
|
||||
ok: false,
|
||||
text: e instanceof Error ? `JSON 无效:${e.message}` : "JSON 无效",
|
||||
});
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const saveGitImport = async () => {
|
||||
if (savingGitImport) return;
|
||||
if (!validateGitImportClient()) {
|
||||
toast("请先修正 JSON 语法");
|
||||
return;
|
||||
}
|
||||
const snap = gitImportJSON;
|
||||
setSavingGitImport(true);
|
||||
try {
|
||||
await apiUpdateSiteSettings({ timeline_git_import: snap });
|
||||
const res = await apiGetAdminSettings();
|
||||
const raw = res.timeline_git_import?.trim() || snap;
|
||||
let pretty = raw;
|
||||
try {
|
||||
pretty = JSON.stringify(JSON.parse(raw), null, 2);
|
||||
} catch {
|
||||
/* 保持 */
|
||||
}
|
||||
setSavedGitImport(pretty);
|
||||
setGitImportJSON((cur) => (cur === snap ? pretty : cur));
|
||||
toast("提交导入适配器已保存", "ok");
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : "保存失败");
|
||||
} finally {
|
||||
setSavingGitImport(false);
|
||||
}
|
||||
};
|
||||
|
||||
const restoreGitDefault = async () => {
|
||||
setSavingGitImport(true);
|
||||
try {
|
||||
let pretty = DEFAULT_TIMELINE_GIT_IMPORT;
|
||||
try {
|
||||
pretty = JSON.stringify(JSON.parse(DEFAULT_TIMELINE_GIT_IMPORT), null, 2);
|
||||
} catch {
|
||||
/* 保持 */
|
||||
}
|
||||
await apiUpdateSiteSettings({ timeline_git_import: pretty });
|
||||
setGitImportJSON(pretty);
|
||||
setSavedGitImport(pretty);
|
||||
setGitValidateMsg(null);
|
||||
setRestoreGitOpen(false);
|
||||
toast("已恢复默认适配器", "ok");
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : "恢复失败");
|
||||
} finally {
|
||||
setSavingGitImport(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;
|
||||
}) => {
|
||||
const ticket = ++limitsReq.current;
|
||||
setSavingLimits(true);
|
||||
try {
|
||||
const res = await apiUpdateSiteSettings(patch);
|
||||
if (ticket !== limitsReq.current) return;
|
||||
confirmedLimits.current = {
|
||||
attachment_max_mb: res.attachment_max_mb,
|
||||
attachment_max_count: res.attachment_max_count,
|
||||
image_max_mb: res.image_max_mb,
|
||||
};
|
||||
setAttMaxMB(res.attachment_max_mb);
|
||||
setAttMaxCount(res.attachment_max_count);
|
||||
setImageMaxMB(res.image_max_mb);
|
||||
toast("上传限额已保存", "ok");
|
||||
} catch (e) {
|
||||
if (ticket !== limitsReq.current) return;
|
||||
setAttMaxMB(confirmedLimits.current.attachment_max_mb);
|
||||
setAttMaxCount(confirmedLimits.current.attachment_max_count);
|
||||
setImageMaxMB(confirmedLimits.current.image_max_mb);
|
||||
toast(e instanceof Error ? e.message : "保存失败");
|
||||
} finally {
|
||||
if (ticket === limitsReq.current) setSavingLimits(false);
|
||||
}
|
||||
};
|
||||
|
||||
const limitsTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const pendingLimits = useRef<typeof confirmedLimits.current>({ ...confirmedLimits.current });
|
||||
const scheduleLimitSave = (patch: {
|
||||
attachment_max_mb?: number;
|
||||
attachment_max_count?: number;
|
||||
image_max_mb?: number;
|
||||
}) => {
|
||||
pendingLimits.current = { ...pendingLimits.current, ...patch };
|
||||
if (limitsTimer.current) clearTimeout(limitsTimer.current);
|
||||
limitsTimer.current = setTimeout(() => {
|
||||
const next = pendingLimits.current;
|
||||
void saveUploadLimits({
|
||||
attachment_max_mb: next.attachment_max_mb,
|
||||
attachment_max_count: next.attachment_max_count,
|
||||
image_max_mb: next.image_max_mb,
|
||||
});
|
||||
}, 450);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (limitsTimer.current) clearTimeout(limitsTimer.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const saveExts = async () => {
|
||||
if (savingExts || !extsDirty) return;
|
||||
const snap = [...exts];
|
||||
setSavingExts(true);
|
||||
try {
|
||||
const res = await apiUpdateSiteSettings({ attachment_exts: snap });
|
||||
setSavedExts([...res.attachment_exts]);
|
||||
setExts((cur) => (extsEqual(cur, snap) ? [...res.attachment_exts] : cur));
|
||||
toast("附件扩展名已保存", "ok");
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : "保存失败");
|
||||
} finally {
|
||||
setSavingExts(false);
|
||||
}
|
||||
};
|
||||
|
||||
const leaveOpen = leave.leaveOpen || pendingSection != null;
|
||||
const confirmLeave = () => {
|
||||
if (pendingSection) {
|
||||
const next = pendingSection;
|
||||
setPendingSection(null);
|
||||
goSection(next);
|
||||
return;
|
||||
}
|
||||
leave.confirmLeave();
|
||||
};
|
||||
const cancelLeave = () => {
|
||||
setPendingSection(null);
|
||||
leave.cancelLeave();
|
||||
};
|
||||
|
||||
const nav = (
|
||||
<nav className="admin-settings-nav" aria-label="设置分类">
|
||||
{NAV.map((item, i) => {
|
||||
const isActive = active === item.id;
|
||||
return (
|
||||
<a
|
||||
key={item.id}
|
||||
ref={(el) => {
|
||||
navRefs.current[i] = el;
|
||||
}}
|
||||
href={`#${item.id}`}
|
||||
className="admin-settings-nav-link"
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
selectTab(item.id);
|
||||
}}
|
||||
onKeyDown={(e) => onNavKeyDown(e, i)}
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
<details className="admin-settings-nav-soon">
|
||||
<summary className="admin-settings-nav-soon-summary">未开放模块</summary>
|
||||
<ul className="admin-settings-nav-soon-list">
|
||||
{COMING_SOON.map((name) => (
|
||||
<li key={name}>{name}</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
</nav>
|
||||
);
|
||||
|
||||
return (
|
||||
<AdminSettingsPage className={active === "gitimport" ? "is-fill" : undefined}>
|
||||
<AdminPageHeader
|
||||
icon={Globe}
|
||||
title="站点设置"
|
||||
description="名称、关键词、Logo 与页脚在「基本信息」;主题色请到「外观」调整"
|
||||
/>
|
||||
|
||||
<AdminSettingsWork nav={nav}>
|
||||
<div id={`settings-panel-${active}`} className="admin-settings-stack">
|
||||
{active === "basic" && (
|
||||
<>
|
||||
<BasicIdentityPanel initial={live} onSaved={setLive} onDirtyChange={onBasicDirty} />
|
||||
<BrandSeoPanel initial={live} onSaved={setLive} onDirtyChange={onBrandDirty} />
|
||||
<FooterLinksPanel initial={live} onSaved={setLive} onDirtyChange={onSeoDirty} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{active === "content" && (
|
||||
<>
|
||||
<AdminSettingsGroup
|
||||
title="内容与互动"
|
||||
description="开关改完立即写入,无需再点保存。"
|
||||
>
|
||||
<AdminSettingsRow
|
||||
label={
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<UserPlus size={15} /> 开放注册
|
||||
</span>
|
||||
}
|
||||
hintBelow="关闭后游客无法提交注册,已有账号仍可登录。"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<AdminSwitch
|
||||
checked={allowRegister}
|
||||
disabled={togglingReg}
|
||||
onChange={() => void toggleRegister()}
|
||||
label="开放注册"
|
||||
/>
|
||||
<InstantNote saving={togglingReg} />
|
||||
</div>
|
||||
</AdminSettingsRow>
|
||||
<AdminSettingsRow
|
||||
label={
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<MessageSquare size={15} /> 开放评论
|
||||
</span>
|
||||
}
|
||||
hintBelow="关闭后前台不展示评论入口、评论数量与已有评论。"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<AdminSwitch
|
||||
checked={allowComments}
|
||||
disabled={togglingComments}
|
||||
onChange={() => void toggleComments()}
|
||||
label="开放评论"
|
||||
/>
|
||||
<InstantNote saving={togglingComments} />
|
||||
</div>
|
||||
</AdminSettingsRow>
|
||||
<AdminSettingsRow
|
||||
label={
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<MessagesSquare size={15} /> 开放消息
|
||||
</span>
|
||||
}
|
||||
hintBelow="关闭后前台不展示私聊与群聊入口;已有会话仍可由后台监管。"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<AdminSwitch
|
||||
checked={allowMessages}
|
||||
disabled={togglingMessages}
|
||||
onChange={() => void toggleMessages()}
|
||||
label="开放消息"
|
||||
/>
|
||||
<InstantNote saving={togglingMessages} />
|
||||
</div>
|
||||
</AdminSettingsRow>
|
||||
<AdminSettingsRow
|
||||
label={
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<ExternalLink size={15} /> 外链新标签打开
|
||||
</span>
|
||||
}
|
||||
hintBelow="开启后,帖子与评论中的外链在新标签打开;关闭则当前页跳转。"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<AdminSwitch
|
||||
checked={postLinkNewTab}
|
||||
disabled={togglingLink}
|
||||
onChange={() => void togglePostLinkNewTab()}
|
||||
label="外链新标签打开"
|
||||
/>
|
||||
<InstantNote saving={togglingLink} />
|
||||
</div>
|
||||
</AdminSettingsRow>
|
||||
</AdminSettingsGroup>
|
||||
|
||||
<AdminSettingsGroup
|
||||
title="附件与上传"
|
||||
description="限额停止输入约 0.5 秒后写入。扩展名白名单需点保存。"
|
||||
footer={
|
||||
<AdminSettingsActions
|
||||
dirty={extsDirty}
|
||||
saving={savingExts}
|
||||
saveLabel="保存扩展名"
|
||||
saveDisabled={!extsDirty || savingExts || !extLimit}
|
||||
onSave={() => void saveExts()}
|
||||
onDiscard={() => setExts([...savedExts])}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<AdminSettingsRow
|
||||
label={
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Paperclip size={15} /> 附件单文件上限
|
||||
</span>
|
||||
}
|
||||
hintBelow={
|
||||
attMaxMB >= 50
|
||||
? "发帖附件体积上限。当前 ≥50MB:请确认服务器内存与反代 body 上限已同步调大。"
|
||||
: "发帖附件体积上限。过大可能拖垮进程,反代与 Next 请求体上限须不少于该值。"
|
||||
}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<AdminStepper
|
||||
value={attMaxMB}
|
||||
min={1}
|
||||
max={999999}
|
||||
unit="MB"
|
||||
ariaLabel="附件单文件上限"
|
||||
disabled={savingLimits}
|
||||
onChange={(n) => {
|
||||
setAttMaxMB(n);
|
||||
scheduleLimitSave({ attachment_max_mb: n });
|
||||
}}
|
||||
/>
|
||||
<InstantNote saving={savingLimits} />
|
||||
</div>
|
||||
</AdminSettingsRow>
|
||||
|
||||
<AdminSettingsRow
|
||||
label={
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Paperclip size={15} /> 每帖附件个数
|
||||
</span>
|
||||
}
|
||||
hintBelow="单帖(含草稿)最多可绑定的附件数量。"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<AdminStepper
|
||||
value={attMaxCount}
|
||||
min={1}
|
||||
max={20}
|
||||
unit="个"
|
||||
ariaLabel="每帖附件个数"
|
||||
disabled={savingLimits}
|
||||
onChange={(n) => {
|
||||
setAttMaxCount(n);
|
||||
scheduleLimitSave({ attachment_max_count: n });
|
||||
}}
|
||||
/>
|
||||
<InstantNote saving={savingLimits} />
|
||||
</div>
|
||||
</AdminSettingsRow>
|
||||
|
||||
<AdminSettingsRow
|
||||
label={
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<ImageIcon size={15} /> 正文插图上限
|
||||
</span>
|
||||
}
|
||||
hintBelow={
|
||||
imageMaxMB >= 10
|
||||
? "Markdown 拖拽/粘贴图片(仅 JPEG / PNG / WebP)。插图 ≥10MB 时解码更耗内存。"
|
||||
: "Markdown 拖拽/粘贴图片(仅 JPEG / PNG / WebP)。"
|
||||
}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<AdminStepper
|
||||
value={imageMaxMB}
|
||||
min={1}
|
||||
max={999999}
|
||||
unit="MB"
|
||||
ariaLabel="正文插图上限"
|
||||
disabled={savingLimits}
|
||||
onChange={(n) => {
|
||||
setImageMaxMB(n);
|
||||
scheduleLimitSave({ image_max_mb: n });
|
||||
}}
|
||||
/>
|
||||
<InstantNote saving={savingLimits} />
|
||||
</div>
|
||||
</AdminSettingsRow>
|
||||
|
||||
<AdminSettingsRow
|
||||
label={
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Paperclip size={15} /> 限制附件扩展名
|
||||
</span>
|
||||
}
|
||||
hintBelow={
|
||||
extLimit
|
||||
? "仅允许下方列表中的扩展名。"
|
||||
: "任意格式可传,仍受体积上限与下载沙箱约束。"
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<AdminSwitch
|
||||
checked={extLimit}
|
||||
disabled={togglingExtLimit}
|
||||
onChange={() => void toggleExtLimit()}
|
||||
label="限制附件扩展名"
|
||||
/>
|
||||
<InstantNote saving={togglingExtLimit} />
|
||||
</div>
|
||||
</AdminSettingsRow>
|
||||
|
||||
<AdminSettingsRow label="允许的扩展名" hintBelow="列表变更需点保存,不会随开关立即写入。" wide>
|
||||
<div className="admin-settings-control-long">
|
||||
<AdminExtChipEditor value={exts} onChange={setExts} disabled={!extLimit || savingExts} />
|
||||
</div>
|
||||
</AdminSettingsRow>
|
||||
</AdminSettingsGroup>
|
||||
</>
|
||||
)}
|
||||
|
||||
{active === "gitimport" && (
|
||||
<AdminSettingsGroup
|
||||
className="is-fill"
|
||||
title="提交导入适配器"
|
||||
description="用 JSON 描述 GitHub / Gitea 等匹配规则。保存时服务端会做结构校验,不会执行任意代码。"
|
||||
footer={
|
||||
<>
|
||||
<AdminSettingsActions
|
||||
dirty={gitImportDirty}
|
||||
saving={savingGitImport}
|
||||
saveLabel="保存导入适配器"
|
||||
onSave={() => void saveGitImport()}
|
||||
onDiscard={() => {
|
||||
setGitImportJSON(savedGitImport);
|
||||
setGitValidateMsg(null);
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm"
|
||||
disabled={gitImportLoading || savingGitImport}
|
||||
onClick={() => {
|
||||
if (validateGitImportClient()) toast("语法校验通过", "ok");
|
||||
else toast("JSON 语法无效");
|
||||
}}
|
||||
>
|
||||
<CheckCircle2 size={14} /> 校验
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm"
|
||||
disabled={savingGitImport}
|
||||
onClick={() => setRestoreGitOpen(true)}
|
||||
>
|
||||
<RotateCcw size={14} /> 恢复默认
|
||||
</button>
|
||||
</AdminSettingsActions>
|
||||
{(gitPreview || gitImportLoaded) ? (
|
||||
<div className="admin-settings-git-summary" aria-label="规则摘要">
|
||||
<span className="admin-settings-git-summary-kicker">规则摘要</span>
|
||||
{gitPreview ? (
|
||||
<>
|
||||
<span>
|
||||
最多 {gitPreview.maxPages ?? "—"} 页 · {gitPreview.maxCommits ?? "—"} 条提交
|
||||
</span>
|
||||
{gitPreview.sources.map((s) => (
|
||||
<span key={`${s.id}-${s.host}`} className="admin-settings-git-summary-src">
|
||||
<GitBranch size={12} aria-hidden />
|
||||
<strong>{s.id}</strong>
|
||||
<span>{s.host}</span>
|
||||
</span>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<span>JSON 无法解析时不显示摘要,保存前请先校验语法。</span>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
>
|
||||
{gitImportLoading && !gitImportLoaded ? (
|
||||
<div className="flex items-center gap-2 meta py-8 justify-center">
|
||||
<Loader2 size={16} className="animate-spin" /> 加载适配器…
|
||||
</div>
|
||||
) : (
|
||||
<AdminSettingsRow
|
||||
className="admin-settings-git-editor"
|
||||
label="适配器 JSON"
|
||||
htmlFor="git-import-json"
|
||||
hint="声明式匹配规则。服务端只按规则拉取。"
|
||||
wide
|
||||
>
|
||||
<div className="admin-settings-git-json-wrap">
|
||||
<textarea
|
||||
id="git-import-json"
|
||||
className="j13-compose-field admin-settings-git-json font-mono text-[12.5px] leading-relaxed"
|
||||
spellCheck={false}
|
||||
value={gitImportJSON}
|
||||
onChange={(e) => {
|
||||
setGitImportJSON(e.target.value);
|
||||
setGitValidateMsg(null);
|
||||
}}
|
||||
placeholder="粘贴或编辑适配器 JSON…"
|
||||
/>
|
||||
{gitValidateMsg ? (
|
||||
<p
|
||||
className={`mt-2 text-[12.5px] flex items-start gap-1.5 ${
|
||||
gitValidateMsg.ok ? "" : "alert-error !mt-2"
|
||||
}`}
|
||||
style={gitValidateMsg.ok ? { color: "var(--ok, #137333)" } : undefined}
|
||||
role="status"
|
||||
>
|
||||
{gitValidateMsg.ok ? <CheckCircle2 size={14} className="mt-0.5 shrink-0" /> : null}
|
||||
{gitValidateMsg.text}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</AdminSettingsRow>
|
||||
)}
|
||||
</AdminSettingsGroup>
|
||||
)}
|
||||
</div>
|
||||
</AdminSettingsWork>
|
||||
|
||||
<ConfirmDialog
|
||||
open={restoreGitOpen}
|
||||
title="恢复默认适配器"
|
||||
message="将覆盖当前「提交导入」JSON 为内置 GitHub + Gitea 模板,并立即保存。确定继续?"
|
||||
busy={savingGitImport}
|
||||
confirmLabel="恢复并保存"
|
||||
onCancel={() => !savingGitImport && setRestoreGitOpen(false)}
|
||||
onConfirm={() => void restoreGitDefault()}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
open={leaveOpen}
|
||||
title="有未保存的修改"
|
||||
message="离开后,当前保存单元里尚未写入的修改会丢失。确定离开?"
|
||||
confirmLabel="离开"
|
||||
danger
|
||||
onCancel={cancelLeave}
|
||||
onConfirm={confirmLeave}
|
||||
/>
|
||||
</AdminSettingsPage>
|
||||
);
|
||||
}
|
||||
97
frontend/app/admin/settings/SettingsShell.tsx
Normal file
97
frontend/app/admin/settings/SettingsShell.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useLayoutEffect, useRef, type KeyboardEvent, type ReactNode } from "react";
|
||||
import { Globe } from "lucide-react";
|
||||
import {
|
||||
AdminPageHeader,
|
||||
AdminSettingsPage,
|
||||
AdminSettingsWork,
|
||||
} from "@/components/admin";
|
||||
|
||||
const NAV: { href: string; label: string }[] = [
|
||||
{ href: "/admin/settings/basic", label: "基本信息" },
|
||||
{ href: "/admin/settings/access", label: "互动与安全" },
|
||||
{ href: "/admin/settings/mail", label: "邮件服务" },
|
||||
{ href: "/admin/settings/storage", label: "文件与存储" },
|
||||
{ href: "/admin/settings/filter", label: "内容过滤" },
|
||||
{ href: "/admin/settings/maintenance", label: "维护与诊断" },
|
||||
];
|
||||
|
||||
/** 旧 #hash / ?section= → 独立路由 */
|
||||
const LEGACY: Record<string, string> = {
|
||||
basic: "/admin/settings/basic",
|
||||
content: "/admin/settings/access",
|
||||
security: "/admin/settings/access",
|
||||
access: "/admin/settings/access",
|
||||
mail: "/admin/settings/mail",
|
||||
storage: "/admin/settings/storage",
|
||||
filter: "/admin/settings/filter",
|
||||
maintenance: "/admin/settings/maintenance",
|
||||
};
|
||||
|
||||
export default function SettingsShell({ children }: { children: ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const navRefs = useRef<(HTMLAnchorElement | null)[]>([]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const hash = window.location.hash.replace(/^#/, "").trim();
|
||||
const section = new URLSearchParams(window.location.search).get("section") || hash;
|
||||
if (!section) return;
|
||||
const dest = LEGACY[section];
|
||||
if (!dest || dest === pathname) return;
|
||||
router.replace(dest);
|
||||
}, [pathname, router]);
|
||||
|
||||
const onNavKeyDown = (e: KeyboardEvent<HTMLAnchorElement>, i: number) => {
|
||||
if (
|
||||
e.key !== "ArrowDown" &&
|
||||
e.key !== "ArrowUp" &&
|
||||
e.key !== "Home" &&
|
||||
e.key !== "End" &&
|
||||
e.key !== "ArrowRight" &&
|
||||
e.key !== "ArrowLeft"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
let next = i;
|
||||
if (e.key === "ArrowDown" || e.key === "ArrowRight") next = (i + 1) % NAV.length;
|
||||
else if (e.key === "ArrowUp" || e.key === "ArrowLeft") next = (i + NAV.length - 1) % NAV.length;
|
||||
else if (e.key === "Home") next = 0;
|
||||
else next = NAV.length - 1;
|
||||
navRefs.current[next]?.focus();
|
||||
router.push(NAV[next].href);
|
||||
};
|
||||
|
||||
const nav = (
|
||||
<nav className="admin-settings-nav" aria-label="设置分类">
|
||||
{NAV.map((item, i) => {
|
||||
const isActive = pathname === item.href;
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
ref={(el) => {
|
||||
navRefs.current[i] = el;
|
||||
}}
|
||||
href={item.href}
|
||||
className="admin-settings-nav-link"
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
onKeyDown={(e) => onNavKeyDown(e, i)}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
|
||||
return (
|
||||
<AdminSettingsPage>
|
||||
<AdminPageHeader icon={Globe} title="站点设置" description="基本信息、互动安全与运营模块" />
|
||||
<AdminSettingsWork nav={nav}>{children}</AdminSettingsWork>
|
||||
</AdminSettingsPage>
|
||||
);
|
||||
}
|
||||
12
frontend/app/admin/settings/access/page.tsx
Normal file
12
frontend/app/admin/settings/access/page.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { Metadata } from "next";
|
||||
import { getPublicSettingsCached } from "@/lib/serverData";
|
||||
import AccessSettings from "../AccessSettings";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "互动与安全",
|
||||
};
|
||||
|
||||
export default async function AccessSettingsPage() {
|
||||
const settings = await getPublicSettingsCached();
|
||||
return <AccessSettings initial={settings} />;
|
||||
}
|
||||
12
frontend/app/admin/settings/basic/page.tsx
Normal file
12
frontend/app/admin/settings/basic/page.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { Metadata } from "next";
|
||||
import { getPublicSettingsCached } from "@/lib/serverData";
|
||||
import BasicSettings from "../BasicSettings";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "基本信息",
|
||||
};
|
||||
|
||||
export default async function BasicSettingsPage() {
|
||||
const settings = await getPublicSettingsCached();
|
||||
return <BasicSettings initial={settings} />;
|
||||
}
|
||||
10
frontend/app/admin/settings/filter/page.tsx
Normal file
10
frontend/app/admin/settings/filter/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { Metadata } from "next";
|
||||
import ModuleSettings from "../ModuleSettings";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "内容过滤",
|
||||
};
|
||||
|
||||
export default function FilterSettingsPage() {
|
||||
return <ModuleSettings name="filter" />;
|
||||
}
|
||||
15
frontend/app/admin/settings/layout.tsx
Normal file
15
frontend/app/admin/settings/layout.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { cookies } from "next/headers";
|
||||
import { authCookieHeader } from "@/lib/cookies";
|
||||
import { getMeCached } from "@/lib/serverData";
|
||||
import { isSuperOrOwner } from "@/lib/roles";
|
||||
import Forbidden from "../Forbidden";
|
||||
import SettingsShell from "./SettingsShell";
|
||||
|
||||
export default async function SettingsLayout({ children }: { children: React.ReactNode }) {
|
||||
const cookie = authCookieHeader(await cookies());
|
||||
const me = await getMeCached(cookie || undefined);
|
||||
if (!me.user || !isSuperOrOwner(me.user.role)) {
|
||||
return <Forbidden text="站点设置仅超级管理员和站长可操作。" />;
|
||||
}
|
||||
return <SettingsShell>{children}</SettingsShell>;
|
||||
}
|
||||
10
frontend/app/admin/settings/mail/page.tsx
Normal file
10
frontend/app/admin/settings/mail/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { Metadata } from "next";
|
||||
import ModuleSettings from "../ModuleSettings";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "邮件服务",
|
||||
};
|
||||
|
||||
export default function MailSettingsPage() {
|
||||
return <ModuleSettings name="mail" />;
|
||||
}
|
||||
10
frontend/app/admin/settings/maintenance/page.tsx
Normal file
10
frontend/app/admin/settings/maintenance/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { Metadata } from "next";
|
||||
import ModuleSettings from "../ModuleSettings";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "维护与诊断",
|
||||
};
|
||||
|
||||
export default function MaintenanceSettingsPage() {
|
||||
return <ModuleSettings name="maintenance" />;
|
||||
}
|
||||
@@ -1,21 +1,28 @@
|
||||
import type { Metadata } from "next";
|
||||
import { cookies } from "next/headers";
|
||||
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
|
||||
import { authCookieHeader } from "@/lib/cookies";
|
||||
import { isSuperOrOwner } from "@/lib/roles";
|
||||
import Forbidden from "../Forbidden";
|
||||
import SettingsAdmin from "./SettingsAdmin";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "站点设置",
|
||||
};
|
||||
|
||||
export default async function AdminSettingsPage() {
|
||||
const cookie = authCookieHeader(await cookies());
|
||||
const me = await getMeCached(cookie || undefined);
|
||||
if (!me.user || !isSuperOrOwner(me.user.role)) {
|
||||
return <Forbidden text="站点设置仅超级管理员和站长可操作。" />;
|
||||
}
|
||||
const settings = await getPublicSettingsCached();
|
||||
return <SettingsAdmin initial={settings} />;
|
||||
interface PageProps {
|
||||
searchParams: Promise<{ section?: string }>;
|
||||
}
|
||||
|
||||
const LEGACY: Record<string, string> = {
|
||||
basic: "/admin/settings/basic",
|
||||
content: "/admin/settings/access",
|
||||
security: "/admin/settings/access",
|
||||
access: "/admin/settings/access",
|
||||
mail: "/admin/settings/mail",
|
||||
storage: "/admin/settings/storage",
|
||||
filter: "/admin/settings/filter",
|
||||
maintenance: "/admin/settings/maintenance",
|
||||
};
|
||||
|
||||
/** /admin/settings 与旧 ?section= 统一跳到子路由 */
|
||||
export default async function SettingsIndexPage({ searchParams }: PageProps) {
|
||||
const sp = await searchParams;
|
||||
const raw = typeof sp.section === "string" ? sp.section.trim() : "";
|
||||
redirect(LEGACY[raw] || "/admin/settings/basic");
|
||||
}
|
||||
|
||||
10
frontend/app/admin/settings/storage/page.tsx
Normal file
10
frontend/app/admin/settings/storage/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { Metadata } from "next";
|
||||
import ModuleSettings from "../ModuleSettings";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "文件与存储",
|
||||
};
|
||||
|
||||
export default function StorageSettingsPage() {
|
||||
return <ModuleSettings name="storage" />;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,4 @@
|
||||
import OperationalBanner from "@/components/OperationalBanner";
|
||||
import type { Metadata } from "next";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { Plus_Jakarta_Sans, Noto_Sans_SC, JetBrains_Mono } from "next/font/google";
|
||||
@@ -22,7 +23,8 @@ import { accentStyleCSS } from "@/lib/theme";
|
||||
import { bgConfigFromSettings, bgStyleCSS } from "@/lib/bg";
|
||||
import { siteDocumentTitle } from "@/lib/brand";
|
||||
import { motionConfigFromSettings } from "@/lib/motion";
|
||||
import { requestMetadataBase } from "@/lib/canonicalMeta";
|
||||
import { peekPublicMetaSnapshot, publishPublicMetaSnapshot } from "@/lib/publicMetaSnapshot";
|
||||
import { metadataBaseURL } from "@/lib/siteUrl";
|
||||
|
||||
// 构建期下载并自托管字体:同源加载 + 自动 preload + fallback 度量校正,
|
||||
// 消除 F5 时 Google CDN 字体到达后整页"由粗变细"的 FOUT 闪动
|
||||
@@ -47,15 +49,16 @@ const jetbrainsMono = JetBrains_Mono({
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const settings = await getPublicSettingsCached();
|
||||
// 必须同步:根 layout 若在此 await(拉设置 / headers),Next 流式 metadata
|
||||
// 会在等待期把标签临时写成 localhost:3000,开发态 RSC/HMR 一密就来回闪。
|
||||
export function generateMetadata(): Metadata {
|
||||
const settings = peekPublicMetaSnapshot();
|
||||
const name = settings.site_name || "姜十三论坛";
|
||||
const title = siteDocumentTitle(name, settings.site_slogan || "");
|
||||
const desc = settings.site_description || "姜十三论坛 - 技术分享与讨论社区";
|
||||
const keywords = settings.site_keywords?.length ? settings.site_keywords : undefined;
|
||||
const metadataBase = await requestMetadataBase();
|
||||
const metadataBase = metadataBaseURL();
|
||||
return {
|
||||
// 只提供解析相对 URL 的基址;Canonical 由各公开页自行声明,避免子路由继承首页
|
||||
...(metadataBase ? { metadataBase } : {}),
|
||||
title: {
|
||||
default: title,
|
||||
@@ -63,9 +66,7 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
},
|
||||
description: desc,
|
||||
keywords,
|
||||
...(settings.favicon_url
|
||||
? { icons: { icon: settings.favicon_url } }
|
||||
: {}),
|
||||
...(settings.favicon_url ? { icons: { icon: settings.favicon_url } } : {}),
|
||||
openGraph: {
|
||||
title,
|
||||
description: desc,
|
||||
@@ -94,6 +95,7 @@ export default async function RootLayout({
|
||||
// 站点主题色(公开设置):派生浅/暗两套 accent CSS 变量后随首屏 HTML 内联,
|
||||
// 覆盖 globals.css 默认令牌——无主题闪动(FOUC),后端不可用时降级为默认靛蓝
|
||||
const settings = await getPublicSettingsCached();
|
||||
publishPublicMetaSnapshot(settings);
|
||||
const accent = settings.accent;
|
||||
const accentCSS = accentStyleCSS(accent || "");
|
||||
const bgCfg = bgConfigFromSettings(settings);
|
||||
@@ -147,7 +149,10 @@ export default async function RootLayout({
|
||||
</a>
|
||||
<SiteBrandProvider initial={settings}>
|
||||
<MotionProvider initial={motionCfg}>
|
||||
<CommentsPolicyProvider allowComments={settings.allow_comments !== false}>
|
||||
<CommentsPolicyProvider
|
||||
allowComments={settings.allow_comments !== false}
|
||||
requireLogin={settings.comments_require_login === true}
|
||||
>
|
||||
<MessagesPolicyProvider allowMessages={settings.allow_messages !== false}>
|
||||
<PostLinkProvider openInNewTab={settings.post_link_new_tab !== false}>
|
||||
<CodeFoldProvider
|
||||
@@ -160,8 +165,8 @@ export default async function RootLayout({
|
||||
unread={unread}
|
||||
chatUnread={settings.allow_messages !== false ? chatUnread : 0}
|
||||
theme={theme}
|
||||
allowRegister={settings.allow_register}
|
||||
>
|
||||
<OperationalBanner />
|
||||
{children}
|
||||
</SiteChrome>
|
||||
</CodeFoldProvider>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useState, useEffect } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { MessagesSquare } from "lucide-react";
|
||||
import { apiLogin, apiMe, apiGetSettings } from "@/lib/api";
|
||||
import { apiLogin, apiMe } from "@/lib/api";
|
||||
import { hasAuthCookieHint } from "@/lib/cookies";
|
||||
import { useAllowComments } from "@/components/CommentsPolicyProvider";
|
||||
import { useAllowMessages } from "@/components/MessagesPolicyProvider";
|
||||
@@ -18,17 +18,6 @@ function safeRedirect(to: string | null): string {
|
||||
}
|
||||
|
||||
function RegisterHint() {
|
||||
const [allow, setAllow] = useState(true);
|
||||
useEffect(() => {
|
||||
apiGetSettings()
|
||||
.then((s) => setAllow(s.allow_register))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
if (!allow) {
|
||||
return (
|
||||
<p className="meta text-center mt-5">当前站点暂不开放注册</p>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<p className="meta text-center mt-5">
|
||||
还没有账号?
|
||||
@@ -133,6 +122,7 @@ export default function LoginPage() {
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="text-center mt-4"><Link href="/reset-password">找回密码</Link></p>
|
||||
<RegisterHint />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -452,8 +452,10 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
|
||||
);
|
||||
}
|
||||
|
||||
// 站点设置已在 layout 缓存;关评时不拉评论树,避免首屏 HTML 泄露历史评论
|
||||
const allowComments = (await getPublicSettingsCached()).allow_comments !== false;
|
||||
// 站点设置已在 layout 缓存;关评或「登录可见」且游客时不拉评论树,避免首屏泄露
|
||||
const publicSettings = await getPublicSettingsCached();
|
||||
const allowComments = publicSettings.allow_comments !== false;
|
||||
const commentsRequireLogin = publicSettings.comments_require_login === true;
|
||||
const emptyComments: CommentsResponse = {
|
||||
comments: [],
|
||||
total: 0,
|
||||
@@ -461,12 +463,14 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
|
||||
page: 1,
|
||||
size: 20,
|
||||
};
|
||||
const [commentsData, me, profile, relatedData] = await Promise.all([
|
||||
allowComments ? fetchComments(id, commentPage, cookie) : Promise.resolve(emptyComments),
|
||||
getMeCached(cookie || undefined),
|
||||
const meFirst = await getMeCached(cookie || undefined);
|
||||
const canFetchComments = allowComments && (!commentsRequireLogin || !!meFirst.user);
|
||||
const [commentsData, profile, relatedData] = await Promise.all([
|
||||
canFetchComments ? fetchComments(id, commentPage, cookie) : Promise.resolve(emptyComments),
|
||||
fetchUserProfile(String(post.user.id), 1, cookie).catch(() => null),
|
||||
fetchPosts(1, 8, post.board_id, "recommended", "", false, cookie).catch(() => null),
|
||||
]);
|
||||
const me = meFirst;
|
||||
const comments = commentsData.comments;
|
||||
const related = (relatedData?.posts ?? []).filter((p) => p.id !== post.id).slice(0, 5);
|
||||
const authorPosts = (profile?.posts ?? []).filter((p) => p.id !== post.id).slice(0, 5);
|
||||
@@ -517,7 +521,7 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
|
||||
absoluteCanonical(`/post/${post.id}`, commentPage > 1 ? { page: commentPage } : undefined) ||
|
||||
entityUrl;
|
||||
const authorName = post.user.nickname || post.user.username;
|
||||
const visibleFloors: JsonLdVisibleComment[] = allowComments
|
||||
const visibleFloors: JsonLdVisibleComment[] = canFetchComments
|
||||
? comments
|
||||
.filter((c) => !c.deleted && (c.content || "").trim())
|
||||
.map((c) => ({
|
||||
@@ -530,7 +534,7 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
|
||||
}))
|
||||
: [];
|
||||
const acceptedForLd: JsonLdVisibleComment | null =
|
||||
allowComments && acceptedAnswer && !acceptedAnswer.deleted && acceptedAnswer.content?.trim()
|
||||
canFetchComments && acceptedAnswer && !acceptedAnswer.deleted && acceptedAnswer.content?.trim()
|
||||
? {
|
||||
id: acceptedAnswer.id,
|
||||
content: acceptedAnswer.content,
|
||||
@@ -565,7 +569,7 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
|
||||
author: { id: post.user.id, name: authorName },
|
||||
postId: post.id,
|
||||
pageUrl,
|
||||
commentCount: allowComments ? post.comment_count : undefined,
|
||||
commentCount: canFetchComments ? post.comment_count : undefined,
|
||||
viewCount: post.view_count,
|
||||
comments: visibleFloors,
|
||||
});
|
||||
|
||||
225
frontend/app/register/RegisterClient.tsx
Normal file
225
frontend/app/register/RegisterClient.tsx
Normal file
@@ -0,0 +1,225 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { UserPlus } from "lucide-react";
|
||||
import { apiRegister, apiMe, apiOperations } from "@/lib/api";
|
||||
import { hasAuthCookieHint } from "@/lib/cookies";
|
||||
|
||||
/** 关闭注册时的说明页(提交失败切到关闭态时复用) */
|
||||
export function RegisterClosedPanel({ notice }: { notice: string }) {
|
||||
return (
|
||||
<div className="max-w-md mx-auto pt-8 sm:pt-14">
|
||||
<div className="panel p-8 text-center">
|
||||
<h1 className="text-lg font-extrabold" style={{ color: "var(--ink)" }}>
|
||||
暂不开放注册
|
||||
</h1>
|
||||
<p className="meta mt-2 text-[13px]">
|
||||
{notice || "站点当前已关闭新用户注册,已有账号可直接登录。"}
|
||||
</p>
|
||||
<Link href="/login" className="btn btn-primary mt-6">
|
||||
去登录
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RegisterClient({
|
||||
verifyEmail: initialVerifyEmail,
|
||||
registerNotice,
|
||||
}: {
|
||||
verifyEmail: boolean;
|
||||
registerNotice: string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [username, setUsername] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [registerClosed, setRegisterClosed] = useState(false);
|
||||
const [code, setCode] = useState("");
|
||||
const [notice, setNotice] = useState(registerNotice);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [sentMessage, setSentMessage] = useState("");
|
||||
const verifyEmail = initialVerifyEmail;
|
||||
|
||||
// 已登录用户不必再注册,直接回首页
|
||||
useEffect(() => {
|
||||
if (!hasAuthCookieHint()) return;
|
||||
apiMe()
|
||||
.then((res) => {
|
||||
if (res.user) router.replace("/");
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [router]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await apiRegister(username, email, password, code);
|
||||
if (res.user) {
|
||||
router.push("/");
|
||||
router.refresh();
|
||||
} else if (res.id) {
|
||||
router.push("/login");
|
||||
} else {
|
||||
const msg = res.error || "注册失败";
|
||||
if (msg.includes("关闭注册")) {
|
||||
// 热关闭:补拉提示文案后切关闭态
|
||||
try {
|
||||
const s = await apiOperations<{ register_notice: string }>("/api/site-state");
|
||||
setNotice(s.register_notice || notice);
|
||||
} catch {
|
||||
/* 沿用 SSR 提示 */
|
||||
}
|
||||
setRegisterClosed(true);
|
||||
}
|
||||
setError(msg);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (registerClosed) {
|
||||
return <RegisterClosedPanel notice={notice} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-md mx-auto pt-8 sm:pt-14">
|
||||
<div className="mb-7 text-center">
|
||||
<span
|
||||
className="inline-flex w-14 h-14 rounded-2xl items-center justify-center mb-4"
|
||||
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
|
||||
aria-hidden
|
||||
>
|
||||
<UserPlus size={26} />
|
||||
</span>
|
||||
<h1 className="text-[28px] font-extrabold tracking-tight" style={{ color: "var(--ink)" }}>
|
||||
创建账号
|
||||
</h1>
|
||||
<p className="text-sm mt-2" style={{ color: "var(--ink-2)" }}>
|
||||
加入社区,开始你的第一帖
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="panel p-6 sm:p-7 space-y-4">
|
||||
{error && (
|
||||
<div className="alert-error" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="field-label" htmlFor="reg-username">
|
||||
用户名
|
||||
</label>
|
||||
<input
|
||||
id="reg-username"
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
minLength={3}
|
||||
maxLength={32}
|
||||
autoFocus
|
||||
autoComplete="username"
|
||||
className="field"
|
||||
placeholder="3–32 位"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label" htmlFor="reg-email">
|
||||
{verifyEmail ? "邮箱(必填)" : "邮箱(可选)"}
|
||||
</label>
|
||||
<input
|
||||
id="reg-email"
|
||||
required={verifyEmail}
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
autoComplete="email"
|
||||
className="field"
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
</div>
|
||||
{verifyEmail && (
|
||||
<div>
|
||||
<label className="field-label" htmlFor="reg-code">
|
||||
邮箱验证码
|
||||
</label>
|
||||
<div className="flex gap-2 items-stretch">
|
||||
<input
|
||||
id="reg-code"
|
||||
className="field min-w-0 flex-1"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
required
|
||||
autoComplete="one-time-code"
|
||||
inputMode="numeric"
|
||||
placeholder="6 位验证码"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line shrink-0"
|
||||
disabled={sending || !email}
|
||||
onClick={async () => {
|
||||
setSending(true);
|
||||
setError("");
|
||||
try {
|
||||
const r = await apiOperations<{ message: string }>("/api/auth/code", "POST", {
|
||||
email,
|
||||
purpose: "register",
|
||||
});
|
||||
setSentMessage(r.message);
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{sending ? "正在排队…" : "发送验证码"}
|
||||
</button>
|
||||
</div>
|
||||
{sentMessage && (
|
||||
<p className="meta mt-2" role="status">
|
||||
{sentMessage}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="field-label" htmlFor="reg-password">
|
||||
密码
|
||||
</label>
|
||||
<input
|
||||
id="reg-password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={6}
|
||||
autoComplete="new-password"
|
||||
className="field"
|
||||
placeholder="至少 6 位"
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" disabled={loading} className="btn btn-primary w-full justify-center mt-2">
|
||||
{loading ? "注册中..." : "注册"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="meta text-center mt-5">
|
||||
已有账号?
|
||||
<Link href="/login" className="ml-1 font-semibold" style={{ color: "var(--accent)" }}>
|
||||
去登录
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,150 +1,18 @@
|
||||
"use client";
|
||||
import type { Metadata } from "next";
|
||||
import { getSiteStateCached } from "@/lib/serverData";
|
||||
import RegisterClient, { RegisterClosedPanel } from "./RegisterClient";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { UserPlus } from "lucide-react";
|
||||
import { apiRegister, apiMe, apiGetSettings } from "@/lib/api";
|
||||
import { hasAuthCookieHint } from "@/lib/cookies";
|
||||
export const metadata: Metadata = {
|
||||
title: "注册",
|
||||
};
|
||||
|
||||
export default function RegisterPage() {
|
||||
const router = useRouter();
|
||||
const [username, setUsername] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [registerClosed, setRegisterClosed] = useState(false);
|
||||
|
||||
// 已登录用户不必再注册,直接回首页
|
||||
useEffect(() => {
|
||||
if (!hasAuthCookieHint()) return;
|
||||
apiMe()
|
||||
.then((res) => {
|
||||
if (res.user) router.replace("/");
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [router]);
|
||||
|
||||
useEffect(() => {
|
||||
apiGetSettings()
|
||||
.then((s) => {
|
||||
if (!s.allow_register) setRegisterClosed(true);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await apiRegister(username, email, password);
|
||||
if (res.user) {
|
||||
// 后端注册即签发登录态 cookie:直接进入社区,无需再手动登录
|
||||
router.push("/");
|
||||
router.refresh();
|
||||
} else if (res.id) {
|
||||
// 极端情况:注册成功但自动签发失败,引导去登录
|
||||
router.push("/login");
|
||||
} else {
|
||||
const msg = res.error || "注册失败";
|
||||
if (msg.includes("关闭注册")) setRegisterClosed(true);
|
||||
setError(msg);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (registerClosed) {
|
||||
return (
|
||||
<div className="max-w-md mx-auto pt-8 sm:pt-14">
|
||||
<div className="panel p-8 text-center">
|
||||
<h1 className="text-lg font-extrabold" style={{ color: "var(--ink)" }}>
|
||||
暂不开放注册
|
||||
</h1>
|
||||
<p className="meta mt-2 text-[13px]">站点当前已关闭新用户注册,已有账号可直接登录。</p>
|
||||
<Link href="/login" className="btn btn-primary mt-6">
|
||||
去登录
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
/** SSR 直出开放/关闭态,避免先闪注册表单再切「暂不开放」 */
|
||||
export default async function RegisterPage() {
|
||||
const state = await getSiteStateCached();
|
||||
if (!state.allow_register) {
|
||||
return <RegisterClosedPanel notice={state.register_notice} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-md mx-auto pt-8 sm:pt-14">
|
||||
<div className="mb-7 text-center">
|
||||
<span
|
||||
className="inline-flex w-14 h-14 rounded-2xl items-center justify-center mb-4"
|
||||
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
|
||||
>
|
||||
<UserPlus size={26} />
|
||||
</span>
|
||||
<h1 className="text-[28px] font-extrabold tracking-tight" style={{ color: "var(--ink)" }}>
|
||||
创建账号
|
||||
</h1>
|
||||
<p className="text-sm mt-2" style={{ color: "var(--ink-2)" }}>
|
||||
加入社区,开始你的第一帖
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="panel p-6 sm:p-7 space-y-4">
|
||||
{error && <div className="alert-error" role="alert">{error}</div>}
|
||||
<div>
|
||||
<label className="field-label" htmlFor="reg-username">用户名</label>
|
||||
<input
|
||||
id="reg-username"
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
minLength={3}
|
||||
maxLength={32}
|
||||
autoFocus
|
||||
autoComplete="username"
|
||||
className="field"
|
||||
placeholder="3–32 位"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label" htmlFor="reg-email">邮箱(可选)</label>
|
||||
<input
|
||||
id="reg-email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
autoComplete="email"
|
||||
className="field"
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label" htmlFor="reg-password">密码</label>
|
||||
<input
|
||||
id="reg-password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={6}
|
||||
autoComplete="new-password"
|
||||
className="field"
|
||||
placeholder="至少 6 位"
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" disabled={loading} className="btn btn-primary w-full justify-center mt-2">
|
||||
{loading ? "注册中..." : "注册"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="meta text-center mt-5">
|
||||
已有账号?
|
||||
<Link href="/login" className="ml-1 font-semibold" style={{ color: "var(--accent)" }}>
|
||||
去登录
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
<RegisterClient verifyEmail={state.verify_email} registerNotice={state.register_notice} />
|
||||
);
|
||||
}
|
||||
|
||||
169
frontend/app/reset-password/page.tsx
Normal file
169
frontend/app/reset-password/page.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { KeyRound } from "lucide-react";
|
||||
import { apiOperations } from "@/lib/api";
|
||||
|
||||
export default function ResetPasswordPage() {
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [email, setEmail] = useState("");
|
||||
const [code, setCode] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [message, setMessage] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
apiOperations<{ password_reset: boolean }>("/api/site-state")
|
||||
.then((s) => setEnabled(s.password_reset))
|
||||
.catch(() => setError("暂时无法读取站点状态"));
|
||||
}, []);
|
||||
|
||||
async function sendCode() {
|
||||
if (sending || !email) return;
|
||||
setSending(true);
|
||||
setError("");
|
||||
setMessage("");
|
||||
try {
|
||||
const r = await apiOperations<{ message: string }>("/api/auth/code", "POST", {
|
||||
email,
|
||||
purpose: "reset",
|
||||
});
|
||||
setMessage(r.message);
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function resetPassword(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
setMessage("");
|
||||
try {
|
||||
const r = await apiOperations<{ message: string }>("/api/auth/reset-password", "POST", {
|
||||
email,
|
||||
code,
|
||||
password,
|
||||
});
|
||||
setMessage(r.message);
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-md mx-auto pt-8 sm:pt-14">
|
||||
<div className="mb-7 text-center">
|
||||
<span
|
||||
className="inline-flex w-14 h-14 rounded-2xl items-center justify-center mb-4"
|
||||
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
|
||||
>
|
||||
<KeyRound size={26} />
|
||||
</span>
|
||||
<h1 className="text-[28px] font-extrabold tracking-tight" style={{ color: "var(--ink)" }}>
|
||||
找回密码
|
||||
</h1>
|
||||
<p className="text-sm mt-2" style={{ color: "var(--ink-2)" }}>
|
||||
通过邮箱验证码重置登录密码
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={resetPassword} className="panel p-6 sm:p-7 space-y-4">
|
||||
{error && (
|
||||
<div className="alert-error" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{!enabled ? (
|
||||
<p className="meta text-[13px]">站点暂未开放邮件找回密码。</p>
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<label className="field-label" htmlFor="reset-email">
|
||||
邮箱
|
||||
</label>
|
||||
<input
|
||||
id="reset-email"
|
||||
type="email"
|
||||
required
|
||||
className="field"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
autoComplete="email"
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label" htmlFor="reset-code">
|
||||
邮箱验证码
|
||||
</label>
|
||||
<div className="flex gap-2 items-stretch">
|
||||
<input
|
||||
id="reset-code"
|
||||
className="field min-w-0 flex-1"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
required
|
||||
autoComplete="one-time-code"
|
||||
inputMode="numeric"
|
||||
placeholder="6 位验证码"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line shrink-0"
|
||||
disabled={sending || busy || !email}
|
||||
onClick={() => void sendCode()}
|
||||
>
|
||||
{sending ? "正在排队…" : "发送验证码"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label" htmlFor="reset-password">
|
||||
新密码
|
||||
</label>
|
||||
<input
|
||||
id="reset-password"
|
||||
type="password"
|
||||
required
|
||||
minLength={6}
|
||||
maxLength={64}
|
||||
autoComplete="new-password"
|
||||
className="field"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="至少 6 位"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary w-full justify-center mt-2"
|
||||
disabled={busy || !code || password.length < 6}
|
||||
>
|
||||
{busy ? "处理中…" : "更新密码"}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{message && (
|
||||
<p className="meta" role="status">
|
||||
{message}
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
|
||||
<p className="meta text-center mt-5">
|
||||
<Link href="/login" className="font-semibold" style={{ color: "var(--accent)" }}>
|
||||
返回登录
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -83,7 +83,9 @@ export default async function UserPage({ params, searchParams }: UserPageProps)
|
||||
const me = await getMeCached(cookie || undefined);
|
||||
const settings = await getPublicSettingsCached();
|
||||
const allowComments = settings.allow_comments !== false;
|
||||
const commentsRequireLogin = settings.comments_require_login === true;
|
||||
const allowMessages = settings.allow_messages !== false;
|
||||
const canReadComments = allowComments && (!commentsRequireLogin || !!me.user);
|
||||
|
||||
let data: UserProfile;
|
||||
try {
|
||||
@@ -108,12 +110,12 @@ export default async function UserPage({ params, searchParams }: UserPageProps)
|
||||
const currentUser = me.user;
|
||||
let effectiveTab: UserTab =
|
||||
(tab === "settings" || tab === "security") && !isOwner ? "posts" : tab;
|
||||
if (!allowComments && effectiveTab === "comments") effectiveTab = "posts";
|
||||
if (!canReadComments && effectiveTab === "comments") effectiveTab = "posts";
|
||||
|
||||
// 评论 tab:SSR 直出对应分页
|
||||
let commentData: UserCommentsResponse | null = null;
|
||||
let commentError = false;
|
||||
if (allowComments && effectiveTab === "comments") {
|
||||
if (canReadComments && effectiveTab === "comments") {
|
||||
try {
|
||||
commentData = await fetchUserComments(id, page);
|
||||
} catch {
|
||||
@@ -147,7 +149,7 @@ export default async function UserPage({ params, searchParams }: UserPageProps)
|
||||
// 导航项(桌面左栏 / 移动 chips 共用定义)
|
||||
const navItems: { key: UserTab; label: string; count?: number; icon: React.ReactNode; ownerOnly?: boolean }[] = [
|
||||
{ key: "posts", label: "发帖", count: data.stats.post_count, icon: <FileText size={15} /> },
|
||||
...(allowComments
|
||||
...(canReadComments
|
||||
? [{ key: "comments" as const, label: "评论", count: data.stats.comment_count, icon: <MessageCircle size={15} /> }]
|
||||
: []),
|
||||
{ key: "settings", label: "资料设置", icon: <Settings size={15} />, ownerOnly: true },
|
||||
@@ -157,7 +159,7 @@ export default async function UserPage({ params, searchParams }: UserPageProps)
|
||||
const tabHref = (t: UserTab) => (t === "posts" ? `/u/${id}` : `/u/${id}?tab=${t}`);
|
||||
|
||||
const profileQuery: Record<string, string | number> = {};
|
||||
if (allowComments && effectiveTab === "comments") profileQuery.tab = "comments";
|
||||
if (canReadComments && effectiveTab === "comments") profileQuery.tab = "comments";
|
||||
if (page > 1) profileQuery.page = page;
|
||||
const profileLd =
|
||||
effectiveTab === "settings" || effectiveTab === "security"
|
||||
@@ -274,7 +276,7 @@ export default async function UserPage({ params, searchParams }: UserPageProps)
|
||||
<span className="tabular-nums font-semibold" style={{ color: "var(--ink)" }}>{data.stats.post_count}</span>
|
||||
{" "}帖子
|
||||
</span>
|
||||
{allowComments && (
|
||||
{canReadComments && (
|
||||
<>
|
||||
<span aria-hidden className="mx-1.5">·</span>
|
||||
<span>
|
||||
|
||||
Reference in New Issue
Block a user