Files
jiang13-bbs/frontend/app/admin/settings/MobileTabBarPanel.tsx

177 lines
6.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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: "书库列表页入口" },
snippets: { 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"),
snippets: tabs.includes("snippets"),
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>
);
}