feat: 移动端底部导航可配置与工作区导航、置顶后台等前端调整
This commit is contained in:
@@ -124,15 +124,18 @@ const (
|
|||||||
DefaultUrlStyle = "default"
|
DefaultUrlStyle = "default"
|
||||||
|
|
||||||
// MaxMobileTabs 底栏配置元素上限(与白名单等长)
|
// MaxMobileTabs 底栏配置元素上限(与白名单等长)
|
||||||
MaxMobileTabs = 5
|
MaxMobileTabs = 6
|
||||||
)
|
)
|
||||||
|
|
||||||
// MobileTabKeys 移动端底栏允许的 tab key 白名单(同时是默认顺序,与前端 lib/mobileTabs.ts 保持一致)
|
// MobileTabKeys 移动端底栏允许的 tab key 白名单(同时是管理面板排序基准,与前端 lib/mobileTabs.ts 保持一致)
|
||||||
var MobileTabKeys = []string{"home", "library", "compose", "messages", "me"}
|
var MobileTabKeys = []string{"home", "library", "snippets", "compose", "messages", "me"}
|
||||||
|
|
||||||
// DefaultMobileTabs 底栏默认全量配置
|
// DefaultMobileTabKeys 底栏缺省启用集合(白名单子集:snippets 默认关闭,需管理员手动开启)
|
||||||
|
var DefaultMobileTabKeys = []string{"home", "library", "compose", "messages", "me"}
|
||||||
|
|
||||||
|
// DefaultMobileTabs 底栏默认启用配置
|
||||||
func DefaultMobileTabs() []string {
|
func DefaultMobileTabs() []string {
|
||||||
return append([]string(nil), MobileTabKeys...)
|
return append([]string(nil), DefaultMobileTabKeys...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultAttachmentExts 论坛向默认允许扩展名(含安装包/脚本/网页)
|
// DefaultAttachmentExts 论坛向默认允许扩展名(含安装包/脚本/网页)
|
||||||
@@ -645,7 +648,7 @@ func (s *SettingService) SetMobileTabs(tabs []string) error {
|
|||||||
if !ok || len(out) == 0 {
|
if !ok || len(out) == 0 {
|
||||||
return ErrInvalidSiteSetting
|
return ErrInvalidSiteSetting
|
||||||
}
|
}
|
||||||
if mobileTabsEqual(out, MobileTabKeys) {
|
if mobileTabsEqual(out, DefaultMobileTabKeys) {
|
||||||
return s.deleteKey(SettingKeyMobileTabs)
|
return s.deleteKey(SettingKeyMobileTabs)
|
||||||
}
|
}
|
||||||
b, err := json.Marshal(out)
|
b, err := json.Marshal(out)
|
||||||
|
|||||||
@@ -1,15 +1,36 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import {
|
||||||
import { ChevronDown, ChevronUp, Pin } from "lucide-react";
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type DragEvent,
|
||||||
|
type KeyboardEvent as ReactKeyboardEvent,
|
||||||
|
} from "react";
|
||||||
|
import {
|
||||||
|
Check,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
|
FolderOpen,
|
||||||
|
Globe,
|
||||||
|
GripVertical,
|
||||||
|
Loader2,
|
||||||
|
Pin,
|
||||||
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
apiAdminPinnedPosts,
|
apiAdminPinnedPosts,
|
||||||
apiAdminSavePinOrder,
|
apiAdminSavePinOrder,
|
||||||
type AdminPinnedPost,
|
type AdminPinnedPost,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import AdminPageHeader from "@/components/admin/AdminPageHeader";
|
import { formatRelative } from "@/lib/format";
|
||||||
import AdminEmpty from "@/components/admin/AdminEmpty";
|
|
||||||
import { toast } from "@/lib/toast";
|
import { toast } from "@/lib/toast";
|
||||||
|
import {
|
||||||
|
AdminEmpty,
|
||||||
|
AdminIconButton,
|
||||||
|
AdminPageHeader,
|
||||||
|
AdminSegmented,
|
||||||
|
} from "@/components/admin";
|
||||||
|
|
||||||
interface BoardOpt {
|
interface BoardOpt {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -29,8 +50,11 @@ export default function PinnedAdmin({ boards, canGlobal }: PinnedAdminProps) {
|
|||||||
const [boardId, setBoardId] = useState<number>(boards[0]?.id ?? 0);
|
const [boardId, setBoardId] = useState<number>(boards[0]?.id ?? 0);
|
||||||
const [posts, setPosts] = useState<AdminPinnedPost[]>([]);
|
const [posts, setPosts] = useState<AdminPinnedPost[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [busy, setBusy] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
/** 拖拽排序:被拖行下标 + 插入位置(0..N,N 表示末尾) */
|
||||||
|
const [dragIndex, setDragIndex] = useState<number | null>(null);
|
||||||
|
const [dropIndex, setDropIndex] = useState<number | null>(null);
|
||||||
|
|
||||||
const load = useCallback(async (lv: Level, bid: number) => {
|
const load = useCallback(async (lv: Level, bid: number) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -54,41 +78,94 @@ export default function PinnedAdmin({ boards, canGlobal }: PinnedAdminProps) {
|
|||||||
if (lv === level) return;
|
if (lv === level) return;
|
||||||
setLevel(lv);
|
setLevel(lv);
|
||||||
setPosts([]);
|
setPosts([]);
|
||||||
|
setError("");
|
||||||
};
|
};
|
||||||
|
|
||||||
// 上移/下移:乐观交换后整体保存顺序,失败回滚
|
/** 统一保存:乐观更新新顺序,失败回滚并提示 */
|
||||||
const move = async (idx: number, dir: -1 | 1) => {
|
const saveOrder = useCallback(
|
||||||
|
async (next: AdminPinnedPost[], prev: AdminPinnedPost[]) => {
|
||||||
|
setPosts(next);
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await apiAdminSavePinOrder(
|
||||||
|
level,
|
||||||
|
level === 1 ? boardId : 0,
|
||||||
|
next.map((p) => p.id)
|
||||||
|
);
|
||||||
|
toast("置顶顺序已更新", "ok");
|
||||||
|
} catch (e) {
|
||||||
|
setPosts(prev);
|
||||||
|
toast(e instanceof Error ? e.message : "保存顺序失败");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[level, boardId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 上移/下移按钮与键盘共用
|
||||||
|
const move = (idx: number, dir: -1 | 1) => {
|
||||||
const target = idx + dir;
|
const target = idx + dir;
|
||||||
if (target < 0 || target >= posts.length || busy) return;
|
if (target < 0 || target >= posts.length || saving) return;
|
||||||
const prev = posts;
|
const prev = posts;
|
||||||
const next = [...posts];
|
const next = [...posts];
|
||||||
const tmp = next[idx];
|
const tmp = next[idx];
|
||||||
next[idx] = next[target];
|
next[idx] = next[target];
|
||||||
next[target] = tmp;
|
next[target] = tmp;
|
||||||
setPosts(next);
|
void saveOrder(next, prev);
|
||||||
setBusy(true);
|
|
||||||
setError("");
|
|
||||||
try {
|
|
||||||
await apiAdminSavePinOrder(level, level === 1 ? boardId : 0, next.map((p) => p.id));
|
|
||||||
} catch (e) {
|
|
||||||
setPosts(prev);
|
|
||||||
setError(e instanceof Error ? e.message : "保存顺序失败");
|
|
||||||
} finally {
|
|
||||||
setBusy(false);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const tabCls = (active: boolean) =>
|
const resetDrag = () => {
|
||||||
`px-3.5 py-2 text-[13px] font-semibold rounded-[var(--r-sm)] transition-colors ${
|
setDragIndex(null);
|
||||||
active ? "" : "hover:bg-[var(--panel-2)]"
|
setDropIndex(null);
|
||||||
}`;
|
};
|
||||||
|
|
||||||
|
const onRowDragOver = (e: DragEvent<HTMLLIElement>, i: number) => {
|
||||||
|
if (dragIndex === null) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = "move";
|
||||||
|
// 指针过行中线则插到行后,否则插到行前
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
|
const after = e.clientY > rect.top + rect.height / 2;
|
||||||
|
setDropIndex(i + (after ? 1 : 0));
|
||||||
|
};
|
||||||
|
|
||||||
|
const onRowDrop = (e: DragEvent<HTMLLIElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (dragIndex === null || dropIndex === null || saving) {
|
||||||
|
resetDrag();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 落在原位置前后:无变化
|
||||||
|
if (dropIndex === dragIndex || dropIndex === dragIndex + 1) {
|
||||||
|
resetDrag();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const prev = posts;
|
||||||
|
const next = [...posts];
|
||||||
|
const [moved] = next.splice(dragIndex, 1);
|
||||||
|
next.splice(dropIndex > dragIndex ? dropIndex - 1 : dropIndex, 0, moved);
|
||||||
|
resetDrag();
|
||||||
|
void saveOrder(next, prev);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 插入指示线:仅当落点确实改变位置时显示 */
|
||||||
|
const showLineAt = (pos: number) =>
|
||||||
|
dragIndex !== null && dropIndex === pos && pos !== dragIndex && pos !== dragIndex + 1;
|
||||||
|
|
||||||
|
const segOptions = [
|
||||||
|
...(canGlobal
|
||||||
|
? [{ key: "2", label: "全站置顶", icon: <Globe size={13} aria-hidden /> }]
|
||||||
|
: []),
|
||||||
|
{ key: "1", label: "板块置顶", icon: <FolderOpen size={13} aria-hidden /> },
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<AdminPageHeader
|
<AdminPageHeader
|
||||||
icon={Pin}
|
icon={Pin}
|
||||||
title="置顶管理"
|
title="置顶管理"
|
||||||
description="调整置顶帖在列表中的显示顺序,越靠前越先展示。板块管理员仅可排序授权板块的板块置顶。"
|
description="拖拽手柄或上下箭头调整置顶帖顺序,改动自动保存,越靠前越先展示。板块管理员仅可排序授权板块的板块置顶。"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
@@ -102,64 +179,25 @@ export default function PinnedAdmin({ boards, canGlobal }: PinnedAdminProps) {
|
|||||||
|
|
||||||
{/* 层级切换 + 板块选择 */}
|
{/* 层级切换 + 板块选择 */}
|
||||||
<div className="mb-4 flex items-center gap-3 flex-wrap">
|
<div className="mb-4 flex items-center gap-3 flex-wrap">
|
||||||
<div
|
<AdminSegmented
|
||||||
className="inline-flex items-center gap-1 p-1 rounded-[var(--r-sm)]"
|
options={segOptions}
|
||||||
style={{ background: "var(--panel-2)" }}
|
value={String(level)}
|
||||||
role="tablist"
|
onChange={(k) => switchLevel(Number(k) as Level)}
|
||||||
>
|
ariaLabel="置顶层级"
|
||||||
{canGlobal && (
|
/>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
role="tab"
|
|
||||||
aria-selected={level === 2}
|
|
||||||
className={tabCls(level === 2)}
|
|
||||||
style={
|
|
||||||
level === 2
|
|
||||||
? { background: "var(--panel)", color: "var(--ink)", boxShadow: "var(--shadow-sm)" }
|
|
||||||
: { color: "var(--ink-3)" }
|
|
||||||
}
|
|
||||||
onClick={() => switchLevel(2)}
|
|
||||||
>
|
|
||||||
全站置顶
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
role="tab"
|
|
||||||
aria-selected={level === 1}
|
|
||||||
className={tabCls(level === 1)}
|
|
||||||
style={
|
|
||||||
level === 1
|
|
||||||
? { background: "var(--panel)", color: "var(--ink)", boxShadow: "var(--shadow-sm)" }
|
|
||||||
: { color: "var(--ink-3)" }
|
|
||||||
}
|
|
||||||
onClick={() => switchLevel(1)}
|
|
||||||
>
|
|
||||||
板块置顶
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{level === 1 && (
|
{level === 1 && (
|
||||||
<select
|
<BoardSelect
|
||||||
className="input text-[13px] py-2"
|
boards={boards}
|
||||||
style={{ minWidth: 160 }}
|
|
||||||
value={boardId}
|
value={boardId}
|
||||||
onChange={(e) => setBoardId(Number(e.target.value))}
|
onChange={setBoardId}
|
||||||
aria-label="选择板块"
|
disabled={loading || saving}
|
||||||
>
|
/>
|
||||||
{boards.length === 0 && <option value={0}>无可用板块</option>}
|
|
||||||
{boards.map((b) => (
|
|
||||||
<option key={b.id} value={b.id}>
|
|
||||||
{b.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="panel overflow-hidden">
|
<div className="panel overflow-hidden">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<p className="meta text-center py-14 text-[13px]">加载中…</p>
|
<PinListSkeleton />
|
||||||
) : posts.length === 0 ? (
|
) : posts.length === 0 ? (
|
||||||
<AdminEmpty
|
<AdminEmpty
|
||||||
icon={<Pin size={20} />}
|
icon={<Pin size={20} />}
|
||||||
@@ -167,39 +205,106 @@ export default function PinnedAdmin({ boards, canGlobal }: PinnedAdminProps) {
|
|||||||
description="在帖子页的「管理」菜单中可将帖子设为置顶。"
|
description="在帖子页的「管理」菜单中可将帖子设为置顶。"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ul>
|
<>
|
||||||
|
{/* 列表状态条:数量 + 保存状态/操作提示 */}
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-between gap-3 px-4 py-2.5"
|
||||||
|
style={{ borderBottom: "1px solid var(--line)" }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="text-[12.5px] font-semibold inline-flex items-center gap-1"
|
||||||
|
style={{ color: "var(--ink-2)" }}
|
||||||
|
>
|
||||||
|
共
|
||||||
|
<b className="tabular-nums" style={{ color: "var(--accent)" }}>
|
||||||
|
{posts.length}
|
||||||
|
</b>
|
||||||
|
篇置顶
|
||||||
|
</span>
|
||||||
|
<span className="meta text-[12px] inline-flex items-center gap-1.5">
|
||||||
|
{saving ? (
|
||||||
|
<>
|
||||||
|
<Loader2 size={12} className="animate-spin" aria-hidden />
|
||||||
|
保存中…
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"拖拽手柄或上下按钮调整,自动保存"
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul
|
||||||
|
onDragLeave={(e) => {
|
||||||
|
// 仅当指针真正离开列表时清除指示线,避免行间移动时闪烁
|
||||||
|
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) {
|
||||||
|
setDropIndex(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
{posts.map((p, i) => (
|
{posts.map((p, i) => (
|
||||||
<li
|
<li
|
||||||
key={p.id}
|
key={p.id}
|
||||||
className="flex items-center gap-3 px-4 py-3"
|
className={`pin-row relative flex items-center gap-3 px-4 py-3${
|
||||||
|
dragIndex === i ? " is-dragging" : ""
|
||||||
|
}`}
|
||||||
style={{ borderTop: i === 0 ? "none" : "1px solid var(--line)" }}
|
style={{ borderTop: i === 0 ? "none" : "1px solid var(--line)" }}
|
||||||
|
onDragOver={(e) => onRowDragOver(e, i)}
|
||||||
|
onDrop={onRowDrop}
|
||||||
>
|
>
|
||||||
|
{/* 拖拽插入位置指示线 */}
|
||||||
|
{showLineAt(i) && (
|
||||||
<span
|
<span
|
||||||
className="w-6 text-center text-[12.5px] font-bold tabular-nums shrink-0"
|
className="absolute left-3 right-3 -top-px h-[2px] rounded-full pointer-events-none"
|
||||||
style={{ color: "var(--ink-3)" }}
|
style={{ background: "var(--accent)" }}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{i === posts.length - 1 && showLineAt(posts.length) && (
|
||||||
|
<span
|
||||||
|
className="absolute left-3 right-3 -bottom-px h-[2px] rounded-full pointer-events-none"
|
||||||
|
style={{ background: "var(--accent)" }}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 拖拽手柄:键盘上下方向键同样可排序 */}
|
||||||
|
<span
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label={`拖拽调整「${p.title}」的顺序,也可使用上下方向键`}
|
||||||
|
className="pin-drag-handle w-5 h-8 -ml-1 inline-flex items-center justify-center rounded-md shrink-0"
|
||||||
|
draggable={!saving}
|
||||||
|
onDragStart={(e) => {
|
||||||
|
e.dataTransfer.effectAllowed = "move";
|
||||||
|
e.dataTransfer.setData("text/plain", String(i));
|
||||||
|
setDragIndex(i);
|
||||||
|
}}
|
||||||
|
onDragEnd={resetDrag}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "ArrowUp") {
|
||||||
|
e.preventDefault();
|
||||||
|
move(i, -1);
|
||||||
|
} else if (e.key === "ArrowDown") {
|
||||||
|
e.preventDefault();
|
||||||
|
move(i, 1);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<GripVertical size={14} aria-hidden />
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* 名次徽章:前三名高亮 */}
|
||||||
|
<span
|
||||||
|
className="w-7 h-7 rounded-lg inline-flex items-center justify-center text-[12.5px] font-bold tabular-nums shrink-0"
|
||||||
|
style={
|
||||||
|
i < 3
|
||||||
|
? { background: "var(--accent-soft)", color: "var(--accent)" }
|
||||||
|
: { background: "var(--panel-2)", color: "var(--ink-3)" }
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{i + 1}
|
{i + 1}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex flex-col gap-0.5 shrink-0">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="admin-icon-btn"
|
|
||||||
onClick={() => void move(i, -1)}
|
|
||||||
disabled={i === 0 || busy}
|
|
||||||
aria-label="上移"
|
|
||||||
>
|
|
||||||
<ChevronUp size={14} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="admin-icon-btn"
|
|
||||||
onClick={() => void move(i, 1)}
|
|
||||||
disabled={i === posts.length - 1 || busy}
|
|
||||||
aria-label="下移"
|
|
||||||
>
|
|
||||||
<ChevronDown size={14} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<a
|
<a
|
||||||
href={`/post/${p.id}`}
|
href={`/post/${p.id}`}
|
||||||
@@ -210,15 +315,239 @@ export default function PinnedAdmin({ boards, canGlobal }: PinnedAdminProps) {
|
|||||||
>
|
>
|
||||||
{p.title}
|
{p.title}
|
||||||
</a>
|
</a>
|
||||||
<p className="meta text-[12px] truncate">
|
<p className="meta text-[12px] mt-0.5 flex items-center gap-1.5 truncate">
|
||||||
{p.board_name} · {new Date(p.created_at).toLocaleDateString("zh-CN")}
|
{level === 2 && (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1 px-1.5 py-px rounded-full shrink-0"
|
||||||
|
style={{ background: "var(--panel-2)", color: "var(--ink-3)" }}
|
||||||
|
>
|
||||||
|
<FolderOpen size={10} aria-hidden />
|
||||||
|
{p.board_name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="shrink-0">{formatRelative(p.created_at)}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
|
<AdminIconButton
|
||||||
|
ariaLabel={`上移「${p.title}」`}
|
||||||
|
tip="上移"
|
||||||
|
disabled={i === 0 || saving}
|
||||||
|
onClick={() => move(i, -1)}
|
||||||
|
>
|
||||||
|
<ChevronUp size={15} />
|
||||||
|
</AdminIconButton>
|
||||||
|
<AdminIconButton
|
||||||
|
ariaLabel={`下移「${p.title}」`}
|
||||||
|
tip="下移"
|
||||||
|
disabled={i === posts.length - 1 || saving}
|
||||||
|
onClick={() => move(i, 1)}
|
||||||
|
>
|
||||||
|
<ChevronDown size={15} />
|
||||||
|
</AdminIconButton>
|
||||||
|
</div>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 板块自定义下拉:浮层 + 键盘导航 + 滚动列表,替代原生 select */
|
||||||
|
function BoardSelect({
|
||||||
|
boards,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
boards: BoardOpt[];
|
||||||
|
value: number;
|
||||||
|
onChange: (id: number) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
/** 键盘/悬停高亮下标 */
|
||||||
|
const [hi, setHi] = useState(0);
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const listRef = useRef<HTMLUListElement>(null);
|
||||||
|
const current = boards.find((b) => b.id === value);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
setHi(Math.max(0, boards.findIndex((b) => b.id === value)));
|
||||||
|
const onDown = (e: MouseEvent) => {
|
||||||
|
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", onDown);
|
||||||
|
return () => document.removeEventListener("mousedown", onDown);
|
||||||
|
}, [open, boards, value]);
|
||||||
|
|
||||||
|
// 高亮项保持在可视区内
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const el = listRef.current?.children[hi];
|
||||||
|
el?.scrollIntoView({ block: "nearest" });
|
||||||
|
}, [hi, open]);
|
||||||
|
|
||||||
|
const close = (refocus = false) => {
|
||||||
|
setOpen(false);
|
||||||
|
if (refocus) triggerRef.current?.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
const select = (id: number) => {
|
||||||
|
onChange(id);
|
||||||
|
close(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onKeyDown = (e: ReactKeyboardEvent) => {
|
||||||
|
if (!open) {
|
||||||
|
if (["ArrowDown", "ArrowUp", "Enter", " "].includes(e.key)) {
|
||||||
|
e.preventDefault();
|
||||||
|
setOpen(true);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
e.preventDefault();
|
||||||
|
close(true);
|
||||||
|
} else if (e.key === "ArrowDown") {
|
||||||
|
e.preventDefault();
|
||||||
|
setHi((v) => Math.min(boards.length - 1, v + 1));
|
||||||
|
} else if (e.key === "ArrowUp") {
|
||||||
|
e.preventDefault();
|
||||||
|
setHi((v) => Math.max(0, v - 1));
|
||||||
|
} else if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
if (boards[hi]) select(boards[hi].id);
|
||||||
|
} else if (e.key === "Tab") {
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (boards.length === 0) {
|
||||||
|
return <span className="meta text-[13px] py-2">无可用板块</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className="relative" onKeyDown={onKeyDown}>
|
||||||
|
<button
|
||||||
|
ref={triggerRef}
|
||||||
|
type="button"
|
||||||
|
aria-haspopup="listbox"
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-label="选择板块"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
className="pin-board-trigger h-9 pl-3 pr-2.5 rounded-lg inline-flex items-center gap-2 text-[13px] font-medium transition-[border-color,box-shadow] disabled:opacity-60 disabled:cursor-not-allowed"
|
||||||
|
style={{
|
||||||
|
minWidth: 176,
|
||||||
|
background: "var(--panel)",
|
||||||
|
border: `1px solid ${open ? "var(--accent)" : "var(--line-2)"}`,
|
||||||
|
color: "var(--ink)",
|
||||||
|
boxShadow: open ? "0 0 0 3px var(--accent-soft)" : "var(--shadow-sm)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FolderOpen
|
||||||
|
size={14}
|
||||||
|
className="shrink-0 transition-colors"
|
||||||
|
style={{ color: open ? "var(--accent)" : "var(--ink-3)" }}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<span className="truncate max-w-44">{current?.name ?? "选择板块"}</span>
|
||||||
|
<ChevronDown
|
||||||
|
size={13}
|
||||||
|
className={`ml-auto shrink-0 transition-transform duration-150${
|
||||||
|
open ? " rotate-180" : ""
|
||||||
|
}`}
|
||||||
|
style={{ color: "var(--ink-3)" }}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div
|
||||||
|
className="absolute z-30 mt-1.5 left-0 w-full min-w-52 rounded-xl overflow-hidden"
|
||||||
|
style={{
|
||||||
|
background: "var(--panel)",
|
||||||
|
border: "1px solid var(--line-2)",
|
||||||
|
boxShadow: "var(--shadow-lg)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ul
|
||||||
|
ref={listRef}
|
||||||
|
role="listbox"
|
||||||
|
aria-label="板块列表"
|
||||||
|
className="max-h-60 overflow-y-auto p-1.5"
|
||||||
|
>
|
||||||
|
{boards.map((b, i) => {
|
||||||
|
const active = b.id === value;
|
||||||
|
return (
|
||||||
|
<li key={b.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="option"
|
||||||
|
aria-selected={active}
|
||||||
|
tabIndex={-1}
|
||||||
|
onClick={() => select(b.id)}
|
||||||
|
onMouseEnter={() => setHi(i)}
|
||||||
|
className="w-full flex items-center gap-2 px-2.5 py-2 rounded-lg text-left text-[13px] transition-colors"
|
||||||
|
style={{
|
||||||
|
background: active
|
||||||
|
? "var(--nav-active-bg)"
|
||||||
|
: i === hi
|
||||||
|
? "var(--panel-2)"
|
||||||
|
: undefined,
|
||||||
|
color: active ? "var(--nav-active-ink)" : "var(--ink-2)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="flex-1 truncate font-medium">{b.name}</span>
|
||||||
|
{active && <Check size={13} className="shrink-0" aria-hidden />}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 加载骨架:模拟 手柄/徽章/标题/摘要 行结构 */
|
||||||
|
function PinListSkeleton() {
|
||||||
|
return (
|
||||||
|
<ul aria-hidden>
|
||||||
|
{[0, 1, 2, 3].map((i) => (
|
||||||
|
<li
|
||||||
|
key={i}
|
||||||
|
className="flex items-center gap-3 px-4 py-3"
|
||||||
|
style={{ borderTop: i === 0 ? "none" : "1px solid var(--line)" }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="w-5 h-7 -ml-1 rounded-md animate-pulse shrink-0"
|
||||||
|
style={{ background: "var(--panel-2)" }}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="w-7 h-7 rounded-lg animate-pulse shrink-0"
|
||||||
|
style={{ background: "var(--panel-2)" }}
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0 flex flex-col gap-1.5">
|
||||||
|
<span
|
||||||
|
className="h-3.5 rounded animate-pulse"
|
||||||
|
style={{ background: "var(--panel-2)", width: `${52 - i * 9}%` }}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="h-3 w-28 rounded animate-pulse"
|
||||||
|
style={{ background: "var(--panel-2)" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { toast } from "@/lib/toast";
|
|||||||
const TAB_META: Record<MobileTabKey, { label: string; hint: string }> = {
|
const TAB_META: Record<MobileTabKey, { label: string; hint: string }> = {
|
||||||
home: { label: "首页", hint: "站点首页" },
|
home: { label: "首页", hint: "站点首页" },
|
||||||
library: { label: "书库", hint: "书库列表页入口" },
|
library: { label: "书库", hint: "书库列表页入口" },
|
||||||
|
snippets: { label: "片段", hint: "代码片段列表页入口(默认关闭,开启后显示在底栏)" },
|
||||||
compose: { label: "发帖", hint: "发帖工作台入口(中央主按钮,无对应首页)" },
|
compose: { label: "发帖", hint: "发帖工作台入口(中央主按钮,无对应首页)" },
|
||||||
messages: { label: "消息", hint: "私聊/群聊列表;游客不显示" },
|
messages: { label: "消息", hint: "私聊/群聊列表;游客不显示" },
|
||||||
me: { label: "我的", hint: "个人主页;未登录指向登录页" },
|
me: { label: "我的", hint: "个人主页;未登录指向登录页" },
|
||||||
@@ -22,6 +23,7 @@ function switchesOf(tabs: MobileTabKey[]): TabSwitches {
|
|||||||
return {
|
return {
|
||||||
home: tabs.includes("home"),
|
home: tabs.includes("home"),
|
||||||
library: tabs.includes("library"),
|
library: tabs.includes("library"),
|
||||||
|
snippets: tabs.includes("snippets"),
|
||||||
compose: tabs.includes("compose"),
|
compose: tabs.includes("compose"),
|
||||||
messages: tabs.includes("messages"),
|
messages: tabs.includes("messages"),
|
||||||
me: tabs.includes("me"),
|
me: tabs.includes("me"),
|
||||||
@@ -166,7 +168,7 @@ export default function MobileTabBarPanel({
|
|||||||
<ul className="admin-url-style-notes meta mt-1 text-[12px]">
|
<ul className="admin-url-style-notes meta mt-1 text-[12px]">
|
||||||
<li>顺序即前台底栏展示顺序;未启用的入口保留排序位置,重新启用后按当前顺序插入。</li>
|
<li>顺序即前台底栏展示顺序;未启用的入口保留排序位置,重新启用后按当前顺序插入。</li>
|
||||||
<li>至少保留一个入口;全部关闭将无法保存。</li>
|
<li>至少保留一个入口;全部关闭将无法保存。</li>
|
||||||
<li>排行榜不在底栏展示,移动端入口由首页抽屉承担。</li>
|
<li>片段、排行榜未放进底栏时,移动端仍可从首页 ☰ 导航抽屉进入(片段默认关闭)。</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</AdminSettingsGroup>
|
</AdminSettingsGroup>
|
||||||
|
|||||||
@@ -5280,6 +5280,36 @@ a.j13-mention-token:hover {
|
|||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 置顶管理:行悬停、拖拽手柄、板块下拉触发器焦点 */
|
||||||
|
.pin-row {
|
||||||
|
transition: background 0.15s ease, opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
.pin-row:hover {
|
||||||
|
background: var(--item-hover-bg);
|
||||||
|
}
|
||||||
|
.pin-row.is-dragging {
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
.pin-drag-handle {
|
||||||
|
cursor: grab;
|
||||||
|
color: var(--ink-3);
|
||||||
|
transition: color 0.15s ease;
|
||||||
|
}
|
||||||
|
.pin-row:hover .pin-drag-handle {
|
||||||
|
color: var(--ink-2);
|
||||||
|
}
|
||||||
|
.pin-drag-handle:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
.pin-drag-handle:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
.pin-board-trigger:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
/* 单页/公告管理:限宽左对齐,不套用设置页 1120px;画布保持镂空,与其他管理页一致 */
|
/* 单页/公告管理:限宽左对齐,不套用设置页 1120px;画布保持镂空,与其他管理页一致 */
|
||||||
.admin-doc-page {
|
.admin-doc-page {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Fragment, useEffect, useState } from "react";
|
|||||||
import {
|
import {
|
||||||
Home,
|
Home,
|
||||||
BookOpen,
|
BookOpen,
|
||||||
|
CodeXml,
|
||||||
PenSquare,
|
PenSquare,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
User as UserIcon,
|
User as UserIcon,
|
||||||
@@ -14,7 +15,7 @@ import { apiChatUnreadSummary, type User } from "@/lib/api";
|
|||||||
import { useAllowMessages } from "@/components/MessagesPolicyProvider";
|
import { useAllowMessages } from "@/components/MessagesPolicyProvider";
|
||||||
import { useSiteBrand } from "@/components/SiteBrandProvider";
|
import { useSiteBrand } from "@/components/SiteBrandProvider";
|
||||||
import { onDocumentVisible } from "@/lib/visibilityReconcile";
|
import { onDocumentVisible } from "@/lib/visibilityReconcile";
|
||||||
import { userPath, libraryPath } from "@/lib/urlStyle";
|
import { userPath, libraryPath, snippetsPath } from "@/lib/urlStyle";
|
||||||
import { realtime, RT_CHAT_UNREAD } from "@/lib/realtime";
|
import { realtime, RT_CHAT_UNREAD } from "@/lib/realtime";
|
||||||
import type { RtChatUnreadData } from "@/lib/realtime";
|
import type { RtChatUnreadData } from "@/lib/realtime";
|
||||||
import type { MobileTabKey } from "@/lib/mobileTabs";
|
import type { MobileTabKey } from "@/lib/mobileTabs";
|
||||||
@@ -36,6 +37,7 @@ export default function MobileTabBar({
|
|||||||
const allowMessages = useAllowMessages();
|
const allowMessages = useAllowMessages();
|
||||||
const { url_style: urlStyle, mobile_tabs: mobileTabs } = useSiteBrand();
|
const { url_style: urlStyle, mobile_tabs: mobileTabs } = useSiteBrand();
|
||||||
const libHref = libraryPath(urlStyle);
|
const libHref = libraryPath(urlStyle);
|
||||||
|
const snipHref = snippetsPath(urlStyle);
|
||||||
const [chatUnread, setChatUnread] = useState(initialChatUnread);
|
const [chatUnread, setChatUnread] = useState(initialChatUnread);
|
||||||
|
|
||||||
// 登录态经 router.refresh() 下发新 props 时校正徽标
|
// 登录态经 router.refresh() 下发新 props 时校正徽标
|
||||||
@@ -100,6 +102,16 @@ export default function MobileTabBar({
|
|||||||
active={isActive((p) => p === libHref)}
|
active={isActive((p) => p === libHref)}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
|
snippets: (
|
||||||
|
<TabLink
|
||||||
|
href={snipHref}
|
||||||
|
icon={<CodeXml size={20} />}
|
||||||
|
label="片段"
|
||||||
|
active={isActive(
|
||||||
|
(p) => p === snipHref || p.startsWith("/snippets") || p.startsWith("/snippet-"),
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
compose: (
|
compose: (
|
||||||
<TabLink
|
<TabLink
|
||||||
href={user ? "/compose" : "/login"}
|
href={user ? "/compose" : "/login"}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import Footer from "@/components/Footer";
|
|||||||
import BackToTop from "@/components/BackToTop";
|
import BackToTop from "@/components/BackToTop";
|
||||||
import MobileTabBar from "@/components/MobileTabBar";
|
import MobileTabBar from "@/components/MobileTabBar";
|
||||||
import type { User } from "@/lib/api";
|
import type { User } from "@/lib/api";
|
||||||
import { libraryPath, userPath } from "@/lib/urlStyle";
|
import { libraryPath, snippetsPath, userPath } from "@/lib/urlStyle";
|
||||||
import { useSiteBrand } from "@/components/SiteBrandProvider";
|
import { useSiteBrand } from "@/components/SiteBrandProvider";
|
||||||
|
|
||||||
function pathIsAdmin(pathname: string | null) {
|
function pathIsAdmin(pathname: string | null) {
|
||||||
@@ -88,6 +88,7 @@ export default function SiteChrome({
|
|||||||
const tabBarRoots: string[] = [];
|
const tabBarRoots: string[] = [];
|
||||||
if (enabledTabs.has("home")) tabBarRoots.push("/");
|
if (enabledTabs.has("home")) tabBarRoots.push("/");
|
||||||
if (enabledTabs.has("library")) tabBarRoots.push(libraryPath(brand.url_style));
|
if (enabledTabs.has("library")) tabBarRoots.push(libraryPath(brand.url_style));
|
||||||
|
if (enabledTabs.has("snippets")) tabBarRoots.push(snippetsPath(brand.url_style));
|
||||||
if (enabledTabs.has("messages")) tabBarRoots.push("/chat");
|
if (enabledTabs.has("messages")) tabBarRoots.push("/chat");
|
||||||
if (enabledTabs.has("me") && meHref) tabBarRoots.push(meHref);
|
if (enabledTabs.has("me") && meHref) tabBarRoots.push(meHref);
|
||||||
const showTabBar =
|
const showTabBar =
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import {
|
|||||||
GitFork,
|
GitFork,
|
||||||
Plus,
|
Plus,
|
||||||
Lock,
|
Lock,
|
||||||
Loader2,
|
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
@@ -454,24 +453,19 @@ export default function SnippetExplorer({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 结果统计 */}
|
{/* 结果统计:纯文本行,切换范围时数据原地更新,无任何加载指示 */}
|
||||||
<div className="mt-3 flex items-center gap-2 text-[12px]" style={{ color: "var(--ink-3)" }}>
|
<div className="mt-3 flex items-center gap-2 text-[12px]" style={{ color: "var(--ink-3)" }}>
|
||||||
{loading ? (
|
|
||||||
<Loader2 size={12} className="animate-spin" />
|
|
||||||
) : (
|
|
||||||
<span className="tabular-nums" style={{ fontFamily: MONO }}>
|
<span className="tabular-nums" style={{ fontFamily: MONO }}>
|
||||||
<span style={{ color: "var(--accent)" }}>$</span>{" "}
|
<span style={{ color: "var(--accent)" }}>$</span>{" "}
|
||||||
{scope === "mine" ? "ls ~/snippets --count" : "ls --count"}
|
{scope === "mine" ? "ls ~/snippets --count" : "ls --count"}
|
||||||
</span>
|
</span>
|
||||||
)}
|
|
||||||
<span>
|
<span>
|
||||||
共 <b className="tabular-nums" style={{ color: "var(--ink-2)" }}>{total}</b> 个片段
|
共 <b className="tabular-nums" style={{ color: "var(--ink-2)" }}>{total}</b> 个片段
|
||||||
{loading ? ",加载中…" : ""}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 列表 / 空态 */}
|
{/* 列表 / 空态:请求期间保留现有内容,新数据返回后原地替换,不闪空态 */}
|
||||||
{items.length === 0 && loading ? null : items.length === 0 && !loading ? (
|
{items.length === 0 ? (
|
||||||
filtering ? (
|
filtering ? (
|
||||||
<div
|
<div
|
||||||
className="mt-4 rounded-xl px-6 py-12 text-center"
|
className="mt-4 rounded-xl px-6 py-12 text-center"
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useSearchParams } from "next/navigation";
|
import { usePathname, useSearchParams } from "next/navigation";
|
||||||
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
|
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
|
||||||
import { Bookmark, Home, LayoutGrid, Menu, ShieldCheck } from "lucide-react";
|
import { Bookmark, CodeXml, Home, LayoutGrid, Menu, ShieldCheck, Trophy } from "lucide-react";
|
||||||
import type { BoardWithCount, User } from "@/lib/api";
|
import type { BoardWithCount, User } from "@/lib/api";
|
||||||
import { isStaff } from "@/lib/roles";
|
import { isStaff } from "@/lib/roles";
|
||||||
import BoardIcon, { boardColor } from "@/components/BoardIcon";
|
import BoardIcon, { boardColor } from "@/components/BoardIcon";
|
||||||
import { useUrlStyle } from "@/components/SiteBrandProvider";
|
import { useUrlStyle } from "@/components/SiteBrandProvider";
|
||||||
import { userPath } from "@/lib/urlStyle";
|
import { leaderboardPath, snippetsPath, userPath } from "@/lib/urlStyle";
|
||||||
import { RailDrawerPanel, useDrawerChrome } from "@/components/MobileRailDrawers";
|
import { RailDrawerPanel, useDrawerChrome } from "@/components/MobileRailDrawers";
|
||||||
|
|
||||||
// 首页壳内板块切换:仅替换中栏帖子流,不离开工作台
|
// 首页壳内板块切换:仅替换中栏帖子流,不离开工作台
|
||||||
@@ -26,9 +26,16 @@ export function WorkspaceNavPanel({
|
|||||||
user?: User | null;
|
user?: User | null;
|
||||||
}) {
|
}) {
|
||||||
const params = useSearchParams();
|
const params = useSearchParams();
|
||||||
|
const pathname = usePathname();
|
||||||
const urlStyle = useUrlStyle();
|
const urlStyle = useUrlStyle();
|
||||||
const activeBoard = Number(params.get("board")) || 0;
|
const activeBoard = Number(params.get("board")) || 0;
|
||||||
const showAdmin = !!user && isStaff(user.role);
|
const showAdmin = !!user && isStaff(user.role);
|
||||||
|
const snipHref = snippetsPath(urlStyle);
|
||||||
|
const lbHref = leaderboardPath(urlStyle);
|
||||||
|
// 与 Header 高亮规则一致:覆盖标准路径与伪静态(/snippet-*.html、/rank.html 等)
|
||||||
|
const snippetsActive =
|
||||||
|
!!pathname && (pathname.startsWith("/snippets") || pathname.startsWith("/snippet-"));
|
||||||
|
const leaderboardActive = !!pathname && (pathname === lbHref || pathname.startsWith(`${lbHref}/`));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="j13-module flex max-h-[var(--rail-max-h)] flex-col overflow-hidden" aria-label="板块目录">
|
<section className="j13-module flex max-h-[var(--rail-max-h)] flex-col overflow-hidden" aria-label="板块目录">
|
||||||
@@ -49,6 +56,37 @@ export function WorkspaceNavPanel({
|
|||||||
<span className="j13-bcard-name flex-1 truncate">全部讨论</span>
|
<span className="j13-bcard-name flex-1 truncate">全部讨论</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
{/* 不进底栏的全局入口:仅移动端展示(首页 ☰ 导航抽屉是其在移动端的唯一路径),
|
||||||
|
桌面端由顶部导航承担,左栏不重复 */}
|
||||||
|
<Link
|
||||||
|
href={snipHref}
|
||||||
|
className="j13-bcard lg:hidden"
|
||||||
|
aria-current={snippetsActive ? "page" : undefined}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="j13-bcard-ico"
|
||||||
|
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<CodeXml size={15} />
|
||||||
|
</span>
|
||||||
|
<span className="j13-bcard-name flex-1 truncate">代码片段</span>
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href={lbHref}
|
||||||
|
className="j13-bcard lg:hidden"
|
||||||
|
aria-current={leaderboardActive ? "page" : undefined}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="j13-bcard-ico"
|
||||||
|
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<Trophy size={15} />
|
||||||
|
</span>
|
||||||
|
<span className="j13-bcard-name flex-1 truncate">排行榜</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
{user ? (
|
{user ? (
|
||||||
<Link href={`${userPath(urlStyle, user.id)}?tab=favorites`} className="j13-bcard">
|
<Link href={`${userPath(urlStyle, user.id)}?tab=favorites`} className="j13-bcard">
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ describe("normalizeMobileTabs", () => {
|
|||||||
assert.deepEqual(normalizeMobileTabs(["home", "me"]), ["home", "me"]);
|
assert.deepEqual(normalizeMobileTabs(["home", "me"]), ["home", "me"]);
|
||||||
assert.deepEqual(normalizeMobileTabs(["messages"]), ["messages"]);
|
assert.deepEqual(normalizeMobileTabs(["messages"]), ["messages"]);
|
||||||
});
|
});
|
||||||
|
it("snippets 为白名单合法项,显式启用时保留", () => {
|
||||||
|
assert.deepEqual(normalizeMobileTabs(["snippets"]), ["snippets"]);
|
||||||
|
assert.deepEqual(normalizeMobileTabs(["home", "snippets", "me"]), ["home", "snippets", "me"]);
|
||||||
|
});
|
||||||
it("去重与去空白", () => {
|
it("去重与去空白", () => {
|
||||||
assert.deepEqual(normalizeMobileTabs(["home", "home", "", " ", "me"]), ["home", "me"]);
|
assert.deepEqual(normalizeMobileTabs(["home", "home", "", " ", "me"]), ["home", "me"]);
|
||||||
});
|
});
|
||||||
@@ -16,15 +20,18 @@ describe("normalizeMobileTabs", () => {
|
|||||||
it("非法元素被剔除", () => {
|
it("非法元素被剔除", () => {
|
||||||
assert.deepEqual(normalizeMobileTabs(["home", "trophy", 42, null]), ["home"]);
|
assert.deepEqual(normalizeMobileTabs(["home", "trophy", 42, null]), ["home"]);
|
||||||
});
|
});
|
||||||
it("空数组/非法输入回落默认全量", () => {
|
it("空数组/非法输入回落默认集合", () => {
|
||||||
assert.deepEqual(normalizeMobileTabs([]), DEFAULT_MOBILE_TABS);
|
assert.deepEqual(normalizeMobileTabs([]), DEFAULT_MOBILE_TABS);
|
||||||
assert.deepEqual(normalizeMobileTabs(["nope"]), DEFAULT_MOBILE_TABS);
|
assert.deepEqual(normalizeMobileTabs(["nope"]), DEFAULT_MOBILE_TABS);
|
||||||
assert.deepEqual(normalizeMobileTabs(undefined), DEFAULT_MOBILE_TABS);
|
assert.deepEqual(normalizeMobileTabs(undefined), DEFAULT_MOBILE_TABS);
|
||||||
assert.deepEqual(normalizeMobileTabs("home"), DEFAULT_MOBILE_TABS);
|
assert.deepEqual(normalizeMobileTabs("home"), DEFAULT_MOBILE_TABS);
|
||||||
assert.deepEqual(normalizeMobileTabs(null), DEFAULT_MOBILE_TABS);
|
assert.deepEqual(normalizeMobileTabs(null), DEFAULT_MOBILE_TABS);
|
||||||
});
|
});
|
||||||
it("默认全量与固定顺序一致", () => {
|
it("白名单与默认集合:snippets 在白名单内但默认不启用", () => {
|
||||||
|
assert.equal(MOBILE_TABS.length, 6);
|
||||||
|
assert.ok(MOBILE_TABS.includes("snippets"));
|
||||||
assert.equal(DEFAULT_MOBILE_TABS.length, 5);
|
assert.equal(DEFAULT_MOBILE_TABS.length, 5);
|
||||||
assert.deepEqual(DEFAULT_MOBILE_TABS, [...MOBILE_TABS]);
|
assert.ok(!DEFAULT_MOBILE_TABS.includes("snippets"));
|
||||||
|
assert.deepEqual(DEFAULT_MOBILE_TABS, MOBILE_TABS.filter((k) => k !== "snippets"));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,17 +1,20 @@
|
|||||||
/**
|
/**
|
||||||
* 移动端底栏配置的单一事实源:
|
* 移动端底栏配置的单一事实源:
|
||||||
* 与后端 service.MobileTabKeys 保持一致;管理面板、MobileTabBar、公共设置 normalize 共用。
|
* 与后端 service.MobileTabKeys 保持一致;管理面板、MobileTabBar、公共设置 normalize 共用。
|
||||||
* 配置为有序数组,数组顺序即前台底栏展示顺序;配置为空/非法回落默认全量。
|
* 配置为有序数组,数组顺序即前台底栏展示顺序;配置为空/非法回落默认集合。
|
||||||
|
*
|
||||||
|
* MOBILE_TABS = 白名单(同时是管理面板排序基准),含全部可选 key;
|
||||||
|
* DEFAULT_MOBILE_TABS = 缺省启用集合,是白名单的子集(snippets 默认关闭,需管理员手动开启)。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const MOBILE_TABS = ["home", "library", "compose", "messages", "me"] as const;
|
export const MOBILE_TABS = ["home", "library", "snippets", "compose", "messages", "me"] as const;
|
||||||
|
|
||||||
export type MobileTabKey = (typeof MOBILE_TABS)[number];
|
export type MobileTabKey = (typeof MOBILE_TABS)[number];
|
||||||
|
|
||||||
/** 底栏默认全量配置 */
|
/** 底栏默认启用配置(白名单子集:snippets 默认不启用,移动端入口由首页导航抽屉承担) */
|
||||||
export const DEFAULT_MOBILE_TABS: MobileTabKey[] = [...MOBILE_TABS];
|
export const DEFAULT_MOBILE_TABS: MobileTabKey[] = ["home", "library", "compose", "messages", "me"];
|
||||||
|
|
||||||
/** 配置子集解析:去空白、去重、只留白名单内元素;结果为空视为非法,回落默认全量 */
|
/** 配置子集解析:去空白、去重、只留白名单内元素;结果为空视为非法,回落默认集合 */
|
||||||
export function normalizeMobileTabs(raw: unknown): MobileTabKey[] {
|
export function normalizeMobileTabs(raw: unknown): MobileTabKey[] {
|
||||||
if (!Array.isArray(raw)) return [...DEFAULT_MOBILE_TABS];
|
if (!Array.isArray(raw)) return [...DEFAULT_MOBILE_TABS];
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
|
|||||||
Reference in New Issue
Block a user