新增模块: - 友链(friendlink)后端处理 + 前端管理页与友链板 - 书库(library):导入、章节、封面、阅读页、横竖版自适应 AdaptiveCoverSlot - 顶栏导航(header_nav)配置与 MobileTabBar/MobileRailDrawers 移动端抽屉 - 排行榜 service 测试、站点页面测试、时间线发布(timeline_release) 其它改动: - 后端 handlers/services 全量小幅调整 - 前端组件、库函数、URL/品牌/站点 URL 工具更新 - Lightbox 图片、MdEntries 条目卡、coverColor 派生色相等前端能力
175 lines
6.0 KiB
TypeScript
175 lines
6.0 KiB
TypeScript
"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>
|
||
);
|
||
}
|