feat: 友链/书库/移动端导航/排行榜等多模块功能
新增模块: - 友链(friendlink)后端处理 + 前端管理页与友链板 - 书库(library):导入、章节、封面、阅读页、横竖版自适应 AdaptiveCoverSlot - 顶栏导航(header_nav)配置与 MobileTabBar/MobileRailDrawers 移动端抽屉 - 排行榜 service 测试、站点页面测试、时间线发布(timeline_release) 其它改动: - 后端 handlers/services 全量小幅调整 - 前端组件、库函数、URL/品牌/站点 URL 工具更新 - Lightbox 图片、MdEntries 条目卡、coverColor 派生色相等前端能力
This commit is contained in:
@@ -6,6 +6,8 @@ import useUnsavedGuard from "@/hooks/useUnsavedGuard";
|
||||
import BasicIdentityPanel from "./BasicIdentityPanel";
|
||||
import BrandSeoPanel from "./BrandSeoPanel";
|
||||
import FooterLinksPanel from "./FooterLinksPanel";
|
||||
import HeaderNavPanel from "./HeaderNavPanel";
|
||||
import MobileTabBarPanel from "./MobileTabBarPanel";
|
||||
import UrlStylePanel from "./UrlStylePanel";
|
||||
import type { PublicSettings } from "@/lib/api";
|
||||
|
||||
@@ -15,19 +17,25 @@ export default function BasicSettings({ initial }: { initial: PublicSettings })
|
||||
const [brandDirty, setBrandDirty] = useState(false);
|
||||
const [seoDirty, setSeoDirty] = useState(false);
|
||||
const [urlStyleDirty, setUrlStyleDirty] = useState(false);
|
||||
const dirty = basicDirty || brandDirty || seoDirty || urlStyleDirty;
|
||||
const [mobileTabsDirty, setMobileTabsDirty] = useState(false);
|
||||
const [headerNavDirty, setHeaderNavDirty] = useState(false);
|
||||
const dirty = basicDirty || brandDirty || seoDirty || urlStyleDirty || mobileTabsDirty || headerNavDirty;
|
||||
const leave = useUnsavedGuard(dirty);
|
||||
|
||||
const onBasicDirty = useCallback((d: boolean) => setBasicDirty(d), []);
|
||||
const onBrandDirty = useCallback((d: boolean) => setBrandDirty(d), []);
|
||||
const onSeoDirty = useCallback((d: boolean) => setSeoDirty(d), []);
|
||||
const onUrlStyleDirty = useCallback((d: boolean) => setUrlStyleDirty(d), []);
|
||||
const onMobileTabsDirty = useCallback((d: boolean) => setMobileTabsDirty(d), []);
|
||||
const onHeaderNavDirty = useCallback((d: boolean) => setHeaderNavDirty(d), []);
|
||||
|
||||
return (
|
||||
<div className="admin-settings-stack">
|
||||
<BasicIdentityPanel initial={live} onSaved={setLive} onDirtyChange={onBasicDirty} />
|
||||
<BrandSeoPanel initial={live} onSaved={setLive} onDirtyChange={onBrandDirty} />
|
||||
<UrlStylePanel initial={live} onSaved={setLive} onDirtyChange={onUrlStyleDirty} />
|
||||
<HeaderNavPanel initial={live} onSaved={setLive} onDirtyChange={onHeaderNavDirty} />
|
||||
<MobileTabBarPanel initial={live} onSaved={setLive} onDirtyChange={onMobileTabsDirty} />
|
||||
<FooterLinksPanel initial={live} onSaved={setLive} onDirtyChange={onSeoDirty} />
|
||||
<ConfirmDialog
|
||||
open={leave.leaveOpen}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { ImagePlus, Images, X } from "lucide-react";
|
||||
import { Check, ImagePlus, Images, Link2, X } from "lucide-react";
|
||||
import { useEffect, useRef, useState, type Dispatch, type SetStateAction } from "react";
|
||||
import {
|
||||
AdminSegmented,
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
BRAND_LOGO_FITS,
|
||||
BRAND_LOGO_SIZES,
|
||||
brandAccept,
|
||||
brandAliasPath,
|
||||
brandFileOk,
|
||||
type BrandLogoFit,
|
||||
type BrandLogoSize,
|
||||
@@ -75,6 +76,7 @@ export default function BrandSeoPanel({
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState("");
|
||||
const [picker, setPicker] = useState<BrandSlot | null>(null);
|
||||
const [copiedSlot, setCopiedSlot] = useState<BrandSlot | null>(null);
|
||||
const blobs = useRef<string[]>([]);
|
||||
const saveGen = useRef(0);
|
||||
|
||||
@@ -203,6 +205,21 @@ export default function BrandSeoPanel({
|
||||
const slotDraft = (id: BrandSlot) =>
|
||||
id === "logo_dark" ? dark : id === "favicon" ? favicon : light;
|
||||
|
||||
// 已保存的槽位 URL:存在才展示固定地址(未保存过无图可引用)
|
||||
const savedOf = (slot: BrandSlot) =>
|
||||
slot === "logo_dark" ? saved.dark : slot === "favicon" ? saved.favicon : saved.light;
|
||||
|
||||
const copyAlias = async (slot: BrandSlot) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(`${window.location.origin}${brandAliasPath(slot)}`);
|
||||
setCopiedSlot(slot);
|
||||
toast("已复制固定地址", "ok");
|
||||
window.setTimeout(() => setCopiedSlot((cur) => (cur === slot ? null : cur)), 1500);
|
||||
} catch {
|
||||
toast("复制失败,请手动复制");
|
||||
}
|
||||
};
|
||||
|
||||
const renderSlot = (slot: BrandSlot, label: string, swatch: "light" | "dark" | "neutral", compact?: boolean) => {
|
||||
const draft = slotDraft(slot);
|
||||
const preview = previewOf(draft);
|
||||
@@ -224,6 +241,18 @@ export default function BrandSeoPanel({
|
||||
</div>
|
||||
<div className="admin-brand-asset-meta">
|
||||
<span className="admin-brand-asset-label">{label}</span>
|
||||
{savedOf(slot) ? (
|
||||
<button
|
||||
type="button"
|
||||
className="admin-brand-alias"
|
||||
title="点击复制固定地址(换图不变链,友链引用请用此地址)"
|
||||
onClick={() => void copyAlias(slot)}
|
||||
>
|
||||
<Link2 size={12} aria-hidden />
|
||||
<span className="admin-brand-alias-url">{brandAliasPath(slot)}</span>
|
||||
{copiedSlot === slot ? <Check size={12} aria-hidden /> : null}
|
||||
</button>
|
||||
) : null}
|
||||
<div className="admin-brand-asset-actions">
|
||||
<label className="btn btn-line btn-sm cursor-pointer">
|
||||
<ImagePlus size={14} /> 上传
|
||||
|
||||
322
frontend/app/admin/settings/HeaderNavPanel.tsx
Normal file
322
frontend/app/admin/settings/HeaderNavPanel.tsx
Normal file
@@ -0,0 +1,322 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
174
frontend/app/admin/settings/MobileTabBarPanel.tsx
Normal file
174
frontend/app/admin/settings/MobileTabBarPanel.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowDown, ArrowUp } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { AdminSettingsActions, AdminSettingsGroup, AdminSwitch } from "@/components/admin";
|
||||
import { apiUpdateSiteSettings, type PublicSettings } from "@/lib/api";
|
||||
import { MOBILE_TABS, normalizeMobileTabs, type MobileTabKey } from "@/lib/mobileTabs";
|
||||
import { toast } from "@/lib/toast";
|
||||
|
||||
/** 底栏入口说明 */
|
||||
const TAB_META: Record<MobileTabKey, { label: string; hint: string }> = {
|
||||
home: { label: "首页", hint: "站点首页" },
|
||||
library: { label: "书库", hint: "书库列表页入口" },
|
||||
compose: { label: "发帖", hint: "发帖工作台入口(中央主按钮,无对应首页)" },
|
||||
messages: { label: "消息", hint: "私聊/群聊列表;游客不显示" },
|
||||
me: { label: "我的", hint: "个人主页;未登录指向登录页" },
|
||||
};
|
||||
|
||||
type TabSwitches = Record<MobileTabKey, boolean>;
|
||||
|
||||
function switchesOf(tabs: MobileTabKey[]): TabSwitches {
|
||||
return {
|
||||
home: tabs.includes("home"),
|
||||
library: tabs.includes("library"),
|
||||
compose: tabs.includes("compose"),
|
||||
messages: tabs.includes("messages"),
|
||||
me: tabs.includes("me"),
|
||||
};
|
||||
}
|
||||
|
||||
/** 完整顺序:已启用项按配置顺序在前,未启用项按默认顺序追加在后 */
|
||||
function orderOf(tabs: MobileTabKey[]): MobileTabKey[] {
|
||||
const enabled = normalizeMobileTabs(tabs);
|
||||
const rest = MOBILE_TABS.filter((key) => !enabled.includes(key));
|
||||
return [...enabled, ...rest];
|
||||
}
|
||||
|
||||
/** 移动端底栏:开关 + 顺序调整,保存启用的 tab 子集(数组顺序即前台展示顺序) */
|
||||
export default function MobileTabBarPanel({
|
||||
initial,
|
||||
onSaved,
|
||||
onDirtyChange,
|
||||
}: {
|
||||
initial: PublicSettings;
|
||||
onSaved?: (next: PublicSettings) => void;
|
||||
onDirtyChange?: (dirty: boolean) => void;
|
||||
}) {
|
||||
const [order, setOrder] = useState<MobileTabKey[]>(() => orderOf(initial.mobile_tabs));
|
||||
const [on, setOn] = useState<TabSwitches>(() => switchesOf(normalizeMobileTabs(initial.mobile_tabs)));
|
||||
const [savedPayload, setSavedPayload] = useState<MobileTabKey[]>(() => normalizeMobileTabs(initial.mobile_tabs));
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const payload = order.filter((key) => on[key]);
|
||||
const dirty = JSON.stringify(payload) !== JSON.stringify(savedPayload);
|
||||
|
||||
useEffect(() => {
|
||||
onDirtyChange?.(dirty);
|
||||
}, [dirty, onDirtyChange]);
|
||||
|
||||
const move = (i: number, dir: -1 | 1) => {
|
||||
setError("");
|
||||
setOrder((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 discard = () => {
|
||||
setOrder(orderOf(savedPayload));
|
||||
setOn(switchesOf(savedPayload));
|
||||
setError("");
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (saving || !dirty) return;
|
||||
if (payload.length === 0) {
|
||||
setError("至少保留一个入口");
|
||||
toast("至少保留一个入口");
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setError("");
|
||||
try {
|
||||
const res = await apiUpdateSiteSettings({ mobile_tabs: [...payload] });
|
||||
const nextSaved = normalizeMobileTabs(res.mobile_tabs);
|
||||
setSavedPayload(nextSaved);
|
||||
setOrder(orderOf(nextSaved));
|
||||
setOn(switchesOf(nextSaved));
|
||||
onSaved?.(res);
|
||||
toast("已保存,移动端底栏已更新", "ok");
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : "保存失败";
|
||||
setError(msg);
|
||||
toast(msg);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AdminSettingsGroup
|
||||
id="settings-mobile-tabbar"
|
||||
title="移动端底栏"
|
||||
description="控制移动端底部导航显示哪些入口及顺序;底栏只在各入口自己的首页显示。"
|
||||
footer={
|
||||
<AdminSettingsActions
|
||||
dirty={dirty}
|
||||
saving={saving}
|
||||
saveLabel="保存"
|
||||
error={error}
|
||||
saveDisabled={payload.length === 0}
|
||||
onSave={() => void save()}
|
||||
onDiscard={discard}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
{order.map((key, i) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex items-center gap-2 rounded-xl px-2 py-2"
|
||||
style={{ border: "1px solid var(--line)", background: "var(--panel)" }}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[13.5px] font-semibold" style={{ color: "var(--ink)" }}>
|
||||
{TAB_META[key].label}
|
||||
</div>
|
||||
<div className="meta text-[12px]">{TAB_META[key].hint}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
className="admin-icon-btn"
|
||||
aria-label={`上移${TAB_META[key].label}`}
|
||||
disabled={i === 0 || saving}
|
||||
onClick={() => move(i, -1)}
|
||||
>
|
||||
<ArrowUp size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-icon-btn"
|
||||
aria-label={`下移${TAB_META[key].label}`}
|
||||
disabled={i === order.length - 1 || saving}
|
||||
onClick={() => move(i, 1)}
|
||||
>
|
||||
<ArrowDown size={14} />
|
||||
</button>
|
||||
<AdminSwitch
|
||||
checked={on[key]}
|
||||
disabled={saving}
|
||||
onChange={() => {
|
||||
setError("");
|
||||
setOn((prev) => ({ ...prev, [key]: !prev[key] }));
|
||||
}}
|
||||
label={`底栏显示${TAB_META[key].label}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<ul className="admin-url-style-notes meta mt-1 text-[12px]">
|
||||
<li>顺序即前台底栏展示顺序;未启用的入口保留排序位置,重新启用后按当前顺序插入。</li>
|
||||
<li>至少保留一个入口;全部关闭将无法保存。</li>
|
||||
<li>排行榜不在底栏展示,移动端入口由首页抽屉承担。</li>
|
||||
</ul>
|
||||
</div>
|
||||
</AdminSettingsGroup>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,16 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { AdminSettingsActions, AdminSettingsGroup } from "@/components/admin";
|
||||
import { apiUpdateSiteSettings, type PublicSettings } from "@/lib/api";
|
||||
import { announcementPath, leaderboardPath, pagePath, postPath, userPath, type UrlStyle } from "@/lib/urlStyle";
|
||||
import {
|
||||
announcementPath,
|
||||
leaderboardPath,
|
||||
libraryDocPath,
|
||||
libraryPath,
|
||||
pagePath,
|
||||
postPath,
|
||||
userPath,
|
||||
type UrlStyle,
|
||||
} from "@/lib/urlStyle";
|
||||
import { toast } from "@/lib/toast";
|
||||
|
||||
/** 三档风格展示信息:示例 id/slug 固定,方便站长对比形态 */
|
||||
@@ -20,6 +29,8 @@ function samplePaths(style: UrlStyle) {
|
||||
{ kind: "公告", path: announcementPath(style, 1) },
|
||||
{ kind: "自建页", path: pagePath(style, "about") },
|
||||
{ kind: "排行榜", path: leaderboardPath(style) },
|
||||
{ kind: "书库", path: libraryPath(style) },
|
||||
{ kind: "书籍", path: libraryDocPath(style, "slug") },
|
||||
];
|
||||
}
|
||||
|
||||
@@ -117,6 +128,7 @@ export default function UrlStylePanel({
|
||||
<li>保存后全站前台链接立即按新风格生成;旧风格链接自动 301 到新风格,收录与外链不断链。</li>
|
||||
<li>301 会被浏览器长期缓存,切换前请想清楚;建议长期固定一种风格,不要频繁来回切换。</li>
|
||||
<li>自建页与排行榜同样参与伪静态;自建页 slug 含中文时自动 URL 编码,地址栏多数浏览器仍显示中文。</li>
|
||||
<li>书库列表与书籍详情参与伪静态(两种伪静态风格同模板);阅读页保持标准路径不参与。</li>
|
||||
</ul>
|
||||
</AdminSettingsGroup>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user