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