公开页补齐自引用 Canonical 与按类型的结构化数据,图片补上说明文字。
后台设置和内容编辑改成可导航布局;部署用访客 origin 生成 Canonical,避免落到容器地址。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowDown, ArrowUp, Loader2, Plus, Save, Trash2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { AdminField, AdminSection } from "@/components/admin";
|
||||
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,
|
||||
@@ -19,48 +19,72 @@ function clone(links: FooterLink[]): FooterLink[] {
|
||||
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 [saved, setSaved] = 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(saved);
|
||||
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 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));
|
||||
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");
|
||||
toast("页脚已保存", "ok");
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : "保存失败");
|
||||
if (ticket !== saveGen.current) return;
|
||||
const msg = e instanceof Error ? e.message : "保存失败";
|
||||
setError(msg);
|
||||
toast(msg);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
if (ticket === saveGen.current) setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -76,20 +100,28 @@ export default function FooterLinksPanel({
|
||||
};
|
||||
|
||||
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>
|
||||
}
|
||||
<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="flex flex-col gap-4">
|
||||
<div className="admin-settings-control-long flex flex-col gap-3">
|
||||
{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 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">
|
||||
@@ -154,20 +186,30 @@ export default function FooterLinksPanel({
|
||||
<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 ? " ↗" : ""}
|
||||
</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] ml-auto">性能指标</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>
|
||||
<p className="meta text-[12px] mt-3">外链保存后带 target=_blank 与 rel=noopener。站内路径留在本页。</p>
|
||||
</AdminSection>
|
||||
</div>
|
||||
</AdminSettingsRow>
|
||||
</AdminSettingsGroup>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user