新增模块: - 友链(friendlink)后端处理 + 前端管理页与友链板 - 书库(library):导入、章节、封面、阅读页、横竖版自适应 AdaptiveCoverSlot - 顶栏导航(header_nav)配置与 MobileTabBar/MobileRailDrawers 移动端抽屉 - 排行榜 service 测试、站点页面测试、时间线发布(timeline_release) 其它改动: - 后端 handlers/services 全量小幅调整 - 前端组件、库函数、URL/品牌/站点 URL 工具更新 - Lightbox 图片、MdEntries 条目卡、coverColor 派生色相等前端能力
323 lines
12 KiB
TypeScript
323 lines
12 KiB
TypeScript
"use client";
|
||
|
||
import { ArrowDown, ArrowUp, BookOpen, ExternalLink, Home, Plus, Trash2, Trophy } from "lucide-react";
|
||
import { useEffect, useState } from "react";
|
||
import { AdminSettingsActions, AdminSettingsGroup, AdminSwitch } from "@/components/admin";
|
||
import { apiUpdateSiteSettings, type PublicSettings } from "@/lib/api";
|
||
import {
|
||
HEADER_NAV_BUILTIN_KEYS,
|
||
HEADER_NAV_BUILTIN_LABELS,
|
||
HEADER_NAV_LIMITS,
|
||
isHeaderNavExternal,
|
||
type HeaderNavBuiltinKey,
|
||
type HeaderNavItem,
|
||
} from "@/lib/headerNav";
|
||
import { footerURLAllowed } from "@/lib/brand";
|
||
import { toast } from "@/lib/toast";
|
||
|
||
const BUILTIN_ICONS: Record<HeaderNavBuiltinKey, React.ReactNode> = {
|
||
home: <Home size={14} />,
|
||
leaderboard: <Trophy size={14} />,
|
||
library: <BookOpen size={14} />,
|
||
};
|
||
|
||
/** 内置项目标页说明(实际地址随伪静态风格变化,不允许改地址) */
|
||
const BUILTIN_HINTS: Record<HeaderNavBuiltinKey, string> = {
|
||
home: "站点首页 /",
|
||
leaderboard: "排行榜页(地址随伪静态风格变化)",
|
||
library: "书库页(地址随伪静态风格变化)",
|
||
};
|
||
|
||
type BuiltinSwitches = Record<HeaderNavBuiltinKey, boolean>;
|
||
|
||
function clone(items: HeaderNavItem[]): HeaderNavItem[] {
|
||
return items.map((item) => ({ ...item }));
|
||
}
|
||
|
||
function switchesOf(items: HeaderNavItem[]): BuiltinSwitches {
|
||
const present = new Set(
|
||
items.filter((it): it is Extract<HeaderNavItem, { type: "builtin" }> => it.type === "builtin").map((it) => it.key),
|
||
);
|
||
return {
|
||
home: present.has("home"),
|
||
leaderboard: present.has("leaderboard"),
|
||
library: present.has("library"),
|
||
};
|
||
}
|
||
|
||
/** 完整行列表:已启用项按配置顺序在前,未启用的内置项按默认顺序追加在后 */
|
||
function rowsOf(items: HeaderNavItem[]): HeaderNavItem[] {
|
||
const present = new Set(
|
||
items.filter((it): it is Extract<HeaderNavItem, { type: "builtin" }> => it.type === "builtin").map((it) => it.key),
|
||
);
|
||
const hidden = HEADER_NAV_BUILTIN_KEYS.filter((key) => !present.has(key)).map((key) => ({
|
||
type: "builtin" as const,
|
||
key,
|
||
}));
|
||
return [...clone(items), ...hidden];
|
||
}
|
||
|
||
/**
|
||
* 桌面端 Header 品牌名右侧的主导航:排序、开关显隐内置项、增删自定义链接,整体替换保存。
|
||
* 与移动端底栏面板同款交互:操作按钮居右,内置项开关隐藏(隐藏项保留排序位置)。
|
||
*/
|
||
export default function HeaderNavPanel({
|
||
initial,
|
||
onSaved,
|
||
onDirtyChange,
|
||
}: {
|
||
initial: PublicSettings;
|
||
onSaved?: (next: PublicSettings) => void;
|
||
onDirtyChange?: (dirty: boolean) => void;
|
||
}) {
|
||
const [rows, setRows] = useState<HeaderNavItem[]>(() => rowsOf(initial.header_nav));
|
||
const [on, setOn] = useState<BuiltinSwitches>(() => switchesOf(initial.header_nav));
|
||
const [savedPayload, setSavedPayload] = useState<HeaderNavItem[]>(() => clone(initial.header_nav));
|
||
const [saving, setSaving] = useState(false);
|
||
const [error, setError] = useState("");
|
||
// 新增自定义行挂载后自动聚焦名称输入框
|
||
const [focusIdx, setFocusIdx] = useState<number | null>(null);
|
||
|
||
// 提交负载 = 自定义链接 + 开关打开的内置项,按行顺序
|
||
const payload: HeaderNavItem[] = rows.filter((item) => item.type === "custom" || on[item.key]);
|
||
const dirty = JSON.stringify(payload) !== JSON.stringify(savedPayload);
|
||
const atCap = payload.length >= HEADER_NAV_LIMITS.items;
|
||
|
||
useEffect(() => {
|
||
onDirtyChange?.(dirty);
|
||
}, [dirty, onDirtyChange]);
|
||
|
||
useEffect(() => {
|
||
if (focusIdx !== null) setFocusIdx(null);
|
||
}, [focusIdx]);
|
||
|
||
const move = (i: number, dir: -1 | 1) => {
|
||
setError("");
|
||
setRows((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;
|
||
});
|
||
};
|
||
|
||
const removeAt = (i: number) => {
|
||
setError("");
|
||
setRows((prev) => prev.filter((_, idx) => idx !== i));
|
||
};
|
||
|
||
const addCustom = () => {
|
||
if (atCap) return;
|
||
setError("");
|
||
setFocusIdx(rows.length);
|
||
setRows((prev) => [...prev, { type: "custom", label: "", url: "", new_tab: false }]);
|
||
};
|
||
|
||
const updateCustom = (i: number, patch: Partial<Extract<HeaderNavItem, { type: "custom" }>>) => {
|
||
setError("");
|
||
setRows((prev) => prev.map((item, idx) => (idx === i && item.type === "custom" ? { ...item, ...patch } : item)));
|
||
};
|
||
|
||
const discard = () => {
|
||
setRows(rowsOf(savedPayload));
|
||
setOn(switchesOf(savedPayload));
|
||
setError("");
|
||
};
|
||
|
||
const save = async () => {
|
||
if (saving || !dirty) return;
|
||
for (const item of payload) {
|
||
if (item.type !== "custom") continue;
|
||
if (!item.label.trim()) {
|
||
setError("自定义菜单名称不能为空");
|
||
toast("自定义菜单名称不能为空");
|
||
return;
|
||
}
|
||
if (!footerURLAllowed(item.url)) {
|
||
setError(`地址无效:${item.url || "(空)"}`);
|
||
toast(`地址无效:${item.url || "(空)"}`);
|
||
return;
|
||
}
|
||
}
|
||
const body: HeaderNavItem[] = payload.map((item) =>
|
||
item.type === "custom"
|
||
? { type: "custom", label: item.label.trim(), url: item.url.trim(), new_tab: item.new_tab }
|
||
: { type: "builtin", key: item.key },
|
||
);
|
||
setSaving(true);
|
||
try {
|
||
const res = await apiUpdateSiteSettings({ header_nav: body });
|
||
setSavedPayload(clone(res.header_nav));
|
||
setRows(rowsOf(res.header_nav));
|
||
setOn(switchesOf(res.header_nav));
|
||
onSaved?.(res);
|
||
toast("已保存,顶部导航已更新", "ok");
|
||
} catch (e) {
|
||
const msg = e instanceof Error ? e.message : "保存失败";
|
||
setError(msg);
|
||
toast(msg);
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<AdminSettingsGroup
|
||
id="settings-header-nav"
|
||
title="顶部导航菜单"
|
||
description="桌面端页眉品牌名右侧的菜单,顺序即前台展示顺序;移动端导航由底部菜单承担。全部隐藏后前台不显示导航。"
|
||
footer={
|
||
<AdminSettingsActions
|
||
dirty={dirty}
|
||
saving={saving}
|
||
saveLabel="保存"
|
||
error={error}
|
||
onSave={() => void save()}
|
||
onDiscard={discard}
|
||
/>
|
||
}
|
||
>
|
||
<div className="flex flex-col gap-2">
|
||
{rows.map((item, i) => {
|
||
const enabled = item.type === "custom" || on[item.key];
|
||
return (
|
||
<div
|
||
key={item.type === "builtin" ? `builtin-${item.key}` : `custom-${i}`}
|
||
className="flex flex-wrap items-center gap-2 rounded-xl px-2 py-2"
|
||
style={{
|
||
border: "1px solid var(--line)",
|
||
background: "var(--panel)",
|
||
opacity: enabled ? 1 : 0.55,
|
||
}}
|
||
>
|
||
{item.type === "builtin" ? (
|
||
<>
|
||
<span
|
||
className="w-7 h-7 flex items-center justify-center rounded-full shrink-0"
|
||
style={{ color: "var(--ink-2)", background: "var(--panel-2)" }}
|
||
aria-hidden
|
||
>
|
||
{BUILTIN_ICONS[item.key]}
|
||
</span>
|
||
<span className="text-[13.5px] font-semibold shrink-0" style={{ color: "var(--ink)" }}>
|
||
{HEADER_NAV_BUILTIN_LABELS[item.key]}
|
||
</span>
|
||
<span className="meta text-[12px] truncate">{BUILTIN_HINTS[item.key]}</span>
|
||
</>
|
||
) : (
|
||
<>
|
||
<div className="admin-field-shell w-32 shrink-0">
|
||
<input
|
||
className="admin-field-input"
|
||
style={{ padding: "7px 10px" }}
|
||
maxLength={HEADER_NAV_LIMITS.label}
|
||
value={item.label}
|
||
onChange={(e) => updateCustom(i, { label: e.target.value })}
|
||
placeholder="菜单名称"
|
||
aria-label={`第 ${i + 1} 个自定义菜单名称`}
|
||
autoFocus={focusIdx === i}
|
||
/>
|
||
</div>
|
||
<div className="admin-field-shell min-w-[10rem] flex-1">
|
||
<input
|
||
className="admin-field-input"
|
||
style={{ padding: "7px 10px" }}
|
||
maxLength={HEADER_NAV_LIMITS.url}
|
||
value={item.url}
|
||
onChange={(e) => {
|
||
const v = e.target.value;
|
||
const wasExt = isHeaderNavExternal(item.url);
|
||
const nowExt = isHeaderNavExternal(v);
|
||
updateCustom(i, { url: v, new_tab: !wasExt && nowExt ? true : item.new_tab });
|
||
}}
|
||
placeholder="https://… 或 /p/about"
|
||
aria-label={`第 ${i + 1} 个自定义菜单地址`}
|
||
/>
|
||
</div>
|
||
<div className="shrink-0 w-[8.75rem]">
|
||
<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={() => updateCustom(i, { new_tab: false })}
|
||
>
|
||
本页
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={`admin-footer-open-opt${item.new_tab ? " is-on" : ""}`}
|
||
aria-pressed={item.new_tab}
|
||
onClick={() => updateCustom(i, { new_tab: true })}
|
||
>
|
||
<ExternalLink size={12} aria-hidden />
|
||
新标签
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</>
|
||
)}
|
||
<div className="flex items-center gap-1 shrink-0 ml-auto">
|
||
<button
|
||
type="button"
|
||
className="admin-icon-btn"
|
||
aria-label="上移"
|
||
disabled={i === 0 || saving}
|
||
onClick={() => move(i, -1)}
|
||
>
|
||
<ArrowUp size={14} />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="admin-icon-btn"
|
||
aria-label="下移"
|
||
disabled={i === rows.length - 1 || saving}
|
||
onClick={() => move(i, 1)}
|
||
>
|
||
<ArrowDown size={14} />
|
||
</button>
|
||
{item.type === "builtin" ? (
|
||
<AdminSwitch
|
||
checked={on[item.key]}
|
||
disabled={saving || (!on[item.key] && atCap)}
|
||
onChange={() => {
|
||
setError("");
|
||
setOn((prev) => ({ ...prev, [item.key]: !prev[item.key] }));
|
||
}}
|
||
label={`顶部导航显示${HEADER_NAV_BUILTIN_LABELS[item.key]}`}
|
||
/>
|
||
) : (
|
||
<button
|
||
type="button"
|
||
className="admin-icon-btn is-danger"
|
||
aria-label="移除"
|
||
onClick={() => removeAt(i)}
|
||
>
|
||
<Trash2 size={14} />
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
|
||
<div className="flex flex-wrap items-center gap-2 pt-1">
|
||
<button type="button" className="btn btn-line btn-sm" disabled={atCap} onClick={addCustom}>
|
||
<Plus size={14} /> 新增自定义链接
|
||
<span className="meta text-[12px] ml-1">
|
||
{payload.length}/{HEADER_NAV_LIMITS.items}
|
||
</span>
|
||
</button>
|
||
</div>
|
||
|
||
<ul className="admin-url-style-notes meta mt-1 text-[12px]">
|
||
<li>内置菜单(首页/排行榜/书库)用开关控制显隐,地址由系统按伪静态风格自动生成;隐藏项保留排序位置。</li>
|
||
<li>自定义链接支持站内路径(如 /p/about、/announcements)或 http(s) 外链;名称最多 16 字。</li>
|
||
</ul>
|
||
</div>
|
||
</AdminSettingsGroup>
|
||
);
|
||
}
|