后台站点设计UIUX优化

This commit is contained in:
2026-09-19 04:14:13 +08:00
parent a8efb9ecef
commit 2b3d122e56
24 changed files with 2952 additions and 306 deletions

View File

@@ -0,0 +1,173 @@
"use client";
import { ArrowDown, ArrowUp, Loader2, Plus, Save, Trash2 } from "lucide-react";
import { useState } from "react";
import { AdminField, AdminSection } 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,
}: {
initial: PublicSettings;
onSaved?: (next: PublicSettings) => void;
}) {
const [links, setLinks] = useState<FooterLink[]>(() => clone(initial.footer_links));
const [saved, setSaved] = useState<FooterLink[]>(() => clone(initial.footer_links));
const [saving, setSaving] = useState(false);
const dirty = JSON.stringify(links) !== JSON.stringify(saved);
const update = (i: number, patch: Partial<FooterLink>) => {
setLinks((prev) => prev.map((item, idx) => (idx === i ? { ...item, ...patch } : item)));
};
const save = async () => {
if (saving || !dirty) return;
for (const item of links) {
if (!item.label.trim()) {
toast("链接文案不能为空");
return;
}
if (!footerURLAllowed(item.url)) {
toast(`地址无效:${item.url || "(空)"}`);
return;
}
}
setSaving(true);
try {
const payload = links.map((item) => ({
label: item.label.trim(),
url: item.url.trim(),
new_tab: item.new_tab,
}));
const res = await apiUpdateSiteSettings({ footer_links: payload });
setLinks(clone(res.footer_links));
setSaved(clone(res.footer_links));
onSaved?.(res);
toast("页脚链接已保存", "ok");
} catch (e) {
toast(e instanceof Error ? e.message : "保存失败");
} finally {
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 (
<div className="admin-settings-grid">
<AdminSection
title="页脚与合规"
description="用于 ICP 备案、用户协议等。最多 8 条。外链默认新标签打开。"
extra={
<button type="button" className="btn btn-primary btn-sm" disabled={!dirty || saving} onClick={() => void save()}>
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
保存
</button>
}
>
<div className="flex flex-col gap-4">
{links.map((item, i) => (
<div key={i} className="rounded-xl p-3 flex flex-col gap-3" style={{ boxShadow: "inset 0 0 0 1px var(--line)" }}>
<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>
</AdminSection>
<AdminSection title="页脚预览" description="保存前仅在此处预览,前台页脚不会跟着变">
<div className="rounded-xl px-4 py-3 flex flex-wrap items-center gap-x-4 gap-y-2" style={{ background: "var(--panel-2)" }}>
<span className="meta text-[12px] whitespace-nowrap">© {new Date().getFullYear()} {initial.site_name}</span>
{links.filter((l) => l.label.trim() && l.url.trim()).map((item) => (
<span key={`${item.label}-${item.url}`} className="text-[12px]" style={{ color: "var(--ink-2)" }}>
{item.label.trim()}
{item.new_tab ? " ↗" : ""}
</span>
))}
<span className="meta text-[12px] ml-auto">性能指标</span>
</div>
<p className="meta text-[12px] mt-3">外链保存后带 target=_blank 与 rel=noopener。站内路径留在本页。</p>
</AdminSection>
</div>
);
}