From 55ded2695cc0130f89b2b91ac8c359f566025e33 Mon Sep 17 00:00:00 2001 From: freefire Date: Fri, 16 Oct 2026 16:26:08 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=A7=BB=E5=8A=A8=E7=AB=AF=E5=BA=95?= =?UTF-8?q?=E9=83=A8=E5=AF=BC=E8=88=AA=E5=8F=AF=E9=85=8D=E7=BD=AE=E4=B8=8E?= =?UTF-8?q?=E5=B7=A5=E4=BD=9C=E5=8C=BA=E5=AF=BC=E8=88=AA=E3=80=81=E7=BD=AE?= =?UTF-8?q?=E9=A1=B6=E5=90=8E=E5=8F=B0=E7=AD=89=E5=89=8D=E7=AB=AF=E8=B0=83?= =?UTF-8?q?=E6=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/service/setting.go | 15 +- frontend/app/admin/pinned/PinnedAdmin.tsx | 571 ++++++++++++++---- .../app/admin/settings/MobileTabBarPanel.tsx | 4 +- frontend/app/globals.css | 30 + frontend/components/MobileTabBar.tsx | 14 +- frontend/components/SiteChrome.tsx | 3 +- frontend/components/SnippetExplorer.tsx | 20 +- frontend/components/home/WorkspaceNav.tsx | 44 +- frontend/lib/mobileTabs.test.ts | 13 +- frontend/lib/mobileTabs.ts | 13 +- 10 files changed, 573 insertions(+), 154 deletions(-) diff --git a/backend/service/setting.go b/backend/service/setting.go index 8ed070d..b0a3904 100644 --- a/backend/service/setting.go +++ b/backend/service/setting.go @@ -124,15 +124,18 @@ const ( DefaultUrlStyle = "default" // MaxMobileTabs 底栏配置元素上限(与白名单等长) - MaxMobileTabs = 5 + MaxMobileTabs = 6 ) -// MobileTabKeys 移动端底栏允许的 tab key 白名单(同时是默认顺序,与前端 lib/mobileTabs.ts 保持一致) -var MobileTabKeys = []string{"home", "library", "compose", "messages", "me"} +// MobileTabKeys 移动端底栏允许的 tab key 白名单(同时是管理面板排序基准,与前端 lib/mobileTabs.ts 保持一致) +var MobileTabKeys = []string{"home", "library", "snippets", "compose", "messages", "me"} -// DefaultMobileTabs 底栏默认全量配置 +// DefaultMobileTabKeys 底栏缺省启用集合(白名单子集:snippets 默认关闭,需管理员手动开启) +var DefaultMobileTabKeys = []string{"home", "library", "compose", "messages", "me"} + +// DefaultMobileTabs 底栏默认启用配置 func DefaultMobileTabs() []string { - return append([]string(nil), MobileTabKeys...) + return append([]string(nil), DefaultMobileTabKeys...) } // DefaultAttachmentExts 论坛向默认允许扩展名(含安装包/脚本/网页) @@ -645,7 +648,7 @@ func (s *SettingService) SetMobileTabs(tabs []string) error { if !ok || len(out) == 0 { return ErrInvalidSiteSetting } - if mobileTabsEqual(out, MobileTabKeys) { + if mobileTabsEqual(out, DefaultMobileTabKeys) { return s.deleteKey(SettingKeyMobileTabs) } b, err := json.Marshal(out) diff --git a/frontend/app/admin/pinned/PinnedAdmin.tsx b/frontend/app/admin/pinned/PinnedAdmin.tsx index 1bbe17a..26f09ff 100644 --- a/frontend/app/admin/pinned/PinnedAdmin.tsx +++ b/frontend/app/admin/pinned/PinnedAdmin.tsx @@ -1,15 +1,36 @@ "use client"; -import { useCallback, useEffect, useState } from "react"; -import { ChevronDown, ChevronUp, Pin } from "lucide-react"; +import { + 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 { apiAdminPinnedPosts, apiAdminSavePinOrder, type AdminPinnedPost, } from "@/lib/api"; -import AdminPageHeader from "@/components/admin/AdminPageHeader"; -import AdminEmpty from "@/components/admin/AdminEmpty"; +import { formatRelative } from "@/lib/format"; import { toast } from "@/lib/toast"; +import { + AdminEmpty, + AdminIconButton, + AdminPageHeader, + AdminSegmented, +} from "@/components/admin"; interface BoardOpt { id: number; @@ -29,8 +50,11 @@ export default function PinnedAdmin({ boards, canGlobal }: PinnedAdminProps) { const [boardId, setBoardId] = useState(boards[0]?.id ?? 0); const [posts, setPosts] = useState([]); const [loading, setLoading] = useState(true); - const [busy, setBusy] = useState(false); + const [saving, setSaving] = useState(false); const [error, setError] = useState(""); + /** 拖拽排序:被拖行下标 + 插入位置(0..N,N 表示末尾) */ + const [dragIndex, setDragIndex] = useState(null); + const [dropIndex, setDropIndex] = useState(null); const load = useCallback(async (lv: Level, bid: number) => { setLoading(true); @@ -54,41 +78,94 @@ export default function PinnedAdmin({ boards, canGlobal }: PinnedAdminProps) { if (lv === level) return; setLevel(lv); 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; - if (target < 0 || target >= posts.length || busy) return; + if (target < 0 || target >= posts.length || saving) return; const prev = posts; const next = [...posts]; const tmp = next[idx]; next[idx] = next[target]; next[target] = tmp; - setPosts(next); - 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); - } + void saveOrder(next, prev); }; - const tabCls = (active: boolean) => - `px-3.5 py-2 text-[13px] font-semibold rounded-[var(--r-sm)] transition-colors ${ - active ? "" : "hover:bg-[var(--panel-2)]" - }`; + const resetDrag = () => { + setDragIndex(null); + setDropIndex(null); + }; + + const onRowDragOver = (e: DragEvent, 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) => { + 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: }] + : []), + { key: "1", label: "板块置顶", icon: }, + ]; return (
{error && ( @@ -102,64 +179,25 @@ export default function PinnedAdmin({ boards, canGlobal }: PinnedAdminProps) { {/* 层级切换 + 板块选择 */}
-
- {canGlobal && ( - - )} - -
- + switchLevel(Number(k) as Level)} + ariaLabel="置顶层级" + /> {level === 1 && ( - + onChange={setBoardId} + disabled={loading || saving} + /> )}
{loading ? ( -

加载中…

+ ) : posts.length === 0 ? ( } @@ -167,58 +205,349 @@ export default function PinnedAdmin({ boards, canGlobal }: PinnedAdminProps) { description="在帖子页的「管理」菜单中可将帖子设为置顶。" /> ) : ( -
    - {posts.map((p, i) => ( -
  • + {/* 列表状态条:数量 + 保存状态/操作提示 */} +
    + - + {posts.length} + + 篇置顶 + + + {saving ? ( + <> + + 保存中… + + ) : ( + "拖拽手柄或上下按钮调整,自动保存" + )} + +
    + +
      { + // 仅当指针真正离开列表时清除指示线,避免行间移动时闪烁 + if (!e.currentTarget.contains(e.relatedTarget as Node | null)) { + setDropIndex(null); + } + }} + > + {posts.map((p, i) => ( +
    • onRowDragOver(e, i)} + onDrop={onRowDrop} > - {i + 1} - -
      - - -
      -
      - - {p.title} - -

      - {p.board_name} · {new Date(p.created_at).toLocaleDateString("zh-CN")} -

      -
      -
    • - ))} -
    + {i + 1} + + +
    + + {p.title} + +

    + {level === 2 && ( + + + {p.board_name} + + )} + {formatRelative(p.created_at)} +

    +
    + +
    + move(i, -1)} + > + + + move(i, 1)} + > + + +
    +
  • + ))} +
+ )}
); } + +/** 板块自定义下拉:浮层 + 键盘导航 + 滚动列表,替代原生 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(null); + const triggerRef = useRef(null); + const listRef = useRef(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 无可用板块; + } + + return ( +
+ + + {open && ( +
+
    + {boards.map((b, i) => { + const active = b.id === value; + return ( +
  • + +
  • + ); + })} +
+
+ )} +
+ ); +} + +/** 加载骨架:模拟 手柄/徽章/标题/摘要 行结构 */ +function PinListSkeleton() { + return ( +
    + {[0, 1, 2, 3].map((i) => ( +
  • + + +
    + + +
    +
  • + ))} +
+ ); +} diff --git a/frontend/app/admin/settings/MobileTabBarPanel.tsx b/frontend/app/admin/settings/MobileTabBarPanel.tsx index bab4e91..8400b00 100644 --- a/frontend/app/admin/settings/MobileTabBarPanel.tsx +++ b/frontend/app/admin/settings/MobileTabBarPanel.tsx @@ -11,6 +11,7 @@ import { toast } from "@/lib/toast"; const TAB_META: Record = { home: { label: "首页", hint: "站点首页" }, library: { label: "书库", hint: "书库列表页入口" }, + snippets: { label: "片段", hint: "代码片段列表页入口(默认关闭,开启后显示在底栏)" }, compose: { label: "发帖", hint: "发帖工作台入口(中央主按钮,无对应首页)" }, messages: { label: "消息", hint: "私聊/群聊列表;游客不显示" }, me: { label: "我的", hint: "个人主页;未登录指向登录页" }, @@ -22,6 +23,7 @@ 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"), @@ -166,7 +168,7 @@ export default function MobileTabBarPanel({
  • 顺序即前台底栏展示顺序;未启用的入口保留排序位置,重新启用后按当前顺序插入。
  • 至少保留一个入口;全部关闭将无法保存。
  • -
  • 排行榜不在底栏展示,移动端入口由首页抽屉承担。
  • +
  • 片段、排行榜未放进底栏时,移动端仍可从首页 ☰ 导航抽屉进入(片段默认关闭)。
diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 936203e..f2c2eb0 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -5280,6 +5280,36 @@ a.j13-mention-token:hover { 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;画布保持镂空,与其他管理页一致 */ .admin-doc-page { width: 100%; diff --git a/frontend/components/MobileTabBar.tsx b/frontend/components/MobileTabBar.tsx index 27e7dfd..fcb96e4 100644 --- a/frontend/components/MobileTabBar.tsx +++ b/frontend/components/MobileTabBar.tsx @@ -6,6 +6,7 @@ import { Fragment, useEffect, useState } from "react"; import { Home, BookOpen, + CodeXml, PenSquare, MessageSquare, User as UserIcon, @@ -14,7 +15,7 @@ import { apiChatUnreadSummary, type User } from "@/lib/api"; import { useAllowMessages } from "@/components/MessagesPolicyProvider"; import { useSiteBrand } from "@/components/SiteBrandProvider"; 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 type { RtChatUnreadData } from "@/lib/realtime"; import type { MobileTabKey } from "@/lib/mobileTabs"; @@ -36,6 +37,7 @@ export default function MobileTabBar({ const allowMessages = useAllowMessages(); const { url_style: urlStyle, mobile_tabs: mobileTabs } = useSiteBrand(); const libHref = libraryPath(urlStyle); + const snipHref = snippetsPath(urlStyle); const [chatUnread, setChatUnread] = useState(initialChatUnread); // 登录态经 router.refresh() 下发新 props 时校正徽标 @@ -100,6 +102,16 @@ export default function MobileTabBar({ active={isActive((p) => p === libHref)} /> ), + snippets: ( + } + label="片段" + active={isActive( + (p) => p === snipHref || p.startsWith("/snippets") || p.startsWith("/snippet-"), + )} + /> + ), compose: ( - {/* 结果统计 */} + {/* 结果统计:纯文本行,切换范围时数据原地更新,无任何加载指示 */}
- {loading ? ( - - ) : ( - - ${" "} - {scope === "mine" ? "ls ~/snippets --count" : "ls --count"} - - )} + + ${" "} + {scope === "mine" ? "ls ~/snippets --count" : "ls --count"} + 共 {total} 个片段 - {loading ? ",加载中…" : ""}
- {/* 列表 / 空态 */} - {items.length === 0 && loading ? null : items.length === 0 && !loading ? ( + {/* 列表 / 空态:请求期间保留现有内容,新数据返回后原地替换,不闪空态 */} + {items.length === 0 ? ( filtering ? (
@@ -49,6 +56,37 @@ export function WorkspaceNavPanel({ 全部讨论 + {/* 不进底栏的全局入口:仅移动端展示(首页 ☰ 导航抽屉是其在移动端的唯一路径), + 桌面端由顶部导航承担,左栏不重复 */} + + + 代码片段 + + + + 排行榜 + + {user ? ( { assert.deepEqual(normalizeMobileTabs(["home", "me"]), ["home", "me"]); assert.deepEqual(normalizeMobileTabs(["messages"]), ["messages"]); }); + it("snippets 为白名单合法项,显式启用时保留", () => { + assert.deepEqual(normalizeMobileTabs(["snippets"]), ["snippets"]); + assert.deepEqual(normalizeMobileTabs(["home", "snippets", "me"]), ["home", "snippets", "me"]); + }); it("去重与去空白", () => { assert.deepEqual(normalizeMobileTabs(["home", "home", "", " ", "me"]), ["home", "me"]); }); @@ -16,15 +20,18 @@ describe("normalizeMobileTabs", () => { it("非法元素被剔除", () => { assert.deepEqual(normalizeMobileTabs(["home", "trophy", 42, null]), ["home"]); }); - it("空数组/非法输入回落默认全量", () => { + it("空数组/非法输入回落默认集合", () => { assert.deepEqual(normalizeMobileTabs([]), DEFAULT_MOBILE_TABS); assert.deepEqual(normalizeMobileTabs(["nope"]), DEFAULT_MOBILE_TABS); assert.deepEqual(normalizeMobileTabs(undefined), DEFAULT_MOBILE_TABS); assert.deepEqual(normalizeMobileTabs("home"), 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.deepEqual(DEFAULT_MOBILE_TABS, [...MOBILE_TABS]); + assert.ok(!DEFAULT_MOBILE_TABS.includes("snippets")); + assert.deepEqual(DEFAULT_MOBILE_TABS, MOBILE_TABS.filter((k) => k !== "snippets")); }); }); diff --git a/frontend/lib/mobileTabs.ts b/frontend/lib/mobileTabs.ts index e04bddc..c96cd29 100644 --- a/frontend/lib/mobileTabs.ts +++ b/frontend/lib/mobileTabs.ts @@ -1,17 +1,20 @@ /** * 移动端底栏配置的单一事实源: * 与后端 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 const DEFAULT_MOBILE_TABS: MobileTabKey[] = [...MOBILE_TABS]; +/** 底栏默认启用配置(白名单子集:snippets 默认不启用,移动端入口由首页导航抽屉承担) */ +export const DEFAULT_MOBILE_TABS: MobileTabKey[] = ["home", "library", "compose", "messages", "me"]; -/** 配置子集解析:去空白、去重、只留白名单内元素;结果为空视为非法,回落默认全量 */ +/** 配置子集解析:去空白、去重、只留白名单内元素;结果为空视为非法,回落默认集合 */ export function normalizeMobileTabs(raw: unknown): MobileTabKey[] { if (!Array.isArray(raw)) return [...DEFAULT_MOBILE_TABS]; const seen = new Set();