后台设置和内容编辑改成可导航布局;部署用访客 origin 生成 Canonical,避免落到容器地址。 Co-authored-by: Cursor <cursoragent@cursor.com>
216 lines
7.5 KiB
TypeScript
216 lines
7.5 KiB
TypeScript
"use client";
|
|
|
|
import { ArrowDown, ArrowUp, Plus, Trash2 } 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 {
|
|
BRAND_LIMITS,
|
|
footerURLAllowed,
|
|
isExternalFooterURL,
|
|
type FooterLink,
|
|
} from "@/lib/brand";
|
|
import { toast } from "@/lib/toast";
|
|
|
|
function clone(links: FooterLink[]): FooterLink[] {
|
|
return links.map((l) => ({ ...l }));
|
|
}
|
|
|
|
export default function FooterLinksPanel({
|
|
initial,
|
|
onSaved,
|
|
onDirtyChange,
|
|
}: {
|
|
initial: PublicSettings;
|
|
onSaved?: (next: PublicSettings) => void;
|
|
onDirtyChange?: (dirty: boolean) => void;
|
|
}) {
|
|
const [links, setLinks] = useState<FooterLink[]>(() => clone(initial.footer_links));
|
|
const [savedLinks, setSavedLinks] = useState<FooterLink[]>(() => clone(initial.footer_links));
|
|
const [saving, setSaving] = useState(false);
|
|
const [error, setError] = useState("");
|
|
const saveGen = useRef(0);
|
|
|
|
const dirty = JSON.stringify(links) !== JSON.stringify(savedLinks);
|
|
|
|
useEffect(() => {
|
|
onDirtyChange?.(dirty);
|
|
}, [dirty, onDirtyChange]);
|
|
|
|
const update = (i: number, patch: Partial<FooterLink>) => {
|
|
setError("");
|
|
setLinks((prev) => prev.map((item, idx) => (idx === i ? { ...item, ...patch } : item)));
|
|
};
|
|
|
|
const discard = () => {
|
|
setLinks(clone(savedLinks));
|
|
setError("");
|
|
};
|
|
|
|
const save = async () => {
|
|
if (saving || !dirty) return;
|
|
for (const item of links) {
|
|
if (!item.label.trim()) {
|
|
setError("链接文案不能为空");
|
|
toast("链接文案不能为空");
|
|
return;
|
|
}
|
|
if (!footerURLAllowed(item.url)) {
|
|
setError(`地址无效:${item.url || "(空)"}`);
|
|
toast(`地址无效:${item.url || "(空)"}`);
|
|
return;
|
|
}
|
|
}
|
|
setError("");
|
|
const snapLinks = clone(links).map((item) => ({
|
|
label: item.label.trim(),
|
|
url: item.url.trim(),
|
|
new_tab: item.new_tab,
|
|
}));
|
|
const ticket = ++saveGen.current;
|
|
setSaving(true);
|
|
try {
|
|
const res = await apiUpdateSiteSettings({
|
|
footer_links: snapLinks,
|
|
});
|
|
if (ticket !== saveGen.current) return;
|
|
setSavedLinks(clone(res.footer_links));
|
|
setLinks((prev) => (JSON.stringify(prev) === JSON.stringify(links) ? clone(res.footer_links) : prev));
|
|
onSaved?.(res);
|
|
toast("页脚已保存", "ok");
|
|
} catch (e) {
|
|
if (ticket !== saveGen.current) return;
|
|
const msg = e instanceof Error ? e.message : "保存失败";
|
|
setError(msg);
|
|
toast(msg);
|
|
} finally {
|
|
if (ticket === saveGen.current) setSaving(false);
|
|
}
|
|
};
|
|
|
|
const move = (i: number, dir: -1 | 1) => {
|
|
setLinks((prev) => {
|
|
const j = i + dir;
|
|
if (j < 0 || j >= prev.length) return prev;
|
|
const next = [...prev];
|
|
const [row] = next.splice(i, 1);
|
|
next.splice(j, 0, row);
|
|
return next;
|
|
});
|
|
};
|
|
|
|
return (
|
|
<AdminSettingsGroup
|
|
title="页脚"
|
|
description="备案、协议等链接。保存后出现在站点页脚右侧。"
|
|
footer={
|
|
<AdminSettingsActions
|
|
dirty={dirty}
|
|
saving={saving}
|
|
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)}>
|
|
<ArrowUp size={14} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="admin-icon-btn"
|
|
aria-label="下移"
|
|
disabled={i === links.length - 1}
|
|
onClick={() => move(i, 1)}
|
|
>
|
|
<ArrowDown size={14} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="admin-icon-btn"
|
|
aria-label="删除"
|
|
onClick={() => setLinks((prev) => prev.filter((_, idx) => idx !== i))}
|
|
>
|
|
<Trash2 size={14} />
|
|
</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>
|
|
</AdminSettingsGroup>
|
|
);
|
|
}
|