完善站点运营设置:分路由管理、登录可见评论、邮件模板内联与关闭注册 SSR。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-23 00:17:00 +08:00
parent acde7ee288
commit 6f054a903c
98 changed files with 8239 additions and 2073 deletions

View File

@@ -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>
);
}