Discourse Sidebar Feed Panel

将侧边栏改造为信息流面板,支持板块分类筛选、已读/未读过滤、拖拽调整宽度

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         Discourse Sidebar Feed Panel
// @namespace    https://linux.do/
// @version      0.6.70
// @description  将侧边栏改造为信息流面板,支持板块分类筛选、已读/未读过滤、拖拽调整宽度
// @author       YsLtr
// @match        https://linux.do/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=linux.do
// @grant        GM_addStyle
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        unsafeWindow
// @run-at       document-idle
// @license      MIT
// ==/UserScript==

(function () {
  "use strict";

  if (window.top !== window.self) return;

  // ========== 持久化键 ==========
  // 所有 GM_* 键都带 sfp_ 前缀,避免和其他 userscript 或站点本身的
  // localStorage/GM 存储冲突。这里的键名一旦发布就尽量不要重命名:
  // 老版本用户升级时会直接读取这些值来恢复宽度、标签、筛选和刷新偏好。
  const STATE_KEY = "sfp_feed_mode_enabled";
  const ORDER_KEY = "sfp_current_order";
  const PERIOD_KEY = "sfp_current_period";
  const WIDTH_KEY = "sfp_sidebar_width";
  const TAB_KEY = "sfp_current_tab";
  const TAB_ORDER_KEY = "sfp_tab_order";
  const FILTER_KEY = "sfp_current_filter";
  const HIDE_PINNED_KEY = "sfp_hide_pinned";
  const SHOW_INCOMING_HINT_KEY = "sfp_show_incoming_hint";
  const AUTO_SILENT_REFRESH_KEY = "sfp_auto_silent_refresh";
  const AUTO_SILENT_REFRESH_INTERVAL_KEY = "sfp_auto_silent_refresh_interval";
  const AUTO_REFRESH_ENABLED_KEY = "sfp_auto_refresh_enabled";
  const AUTO_REFRESH_INTERVAL_KEY = "sfp_auto_refresh_interval";
  const TAG_STYLE_CACHE_KEY = "sfp_tag_style_cache_v1";

  // ========== 常量 ==========
  // DEFAULT_WIDTH 同时也是当前最小宽度。之前的需求要求“允许压缩的最小宽度
  // 改为 header-sidebar-toggle 的宽度”,但侧边栏内容在 272px 以下会破坏
  // 话题卡片和设置浮层,因此这里保留信息流面板自己的最低可用宽度。
  const DEFAULT_WIDTH = 272;
  const MIN_WIDTH = DEFAULT_WIDTH;
  const MAX_WIDTH = 500;

  // 两套刷新机制的默认值刻意不同:
  // - 最新活动依赖 Discourse message-bus 的增量候选,默认 0 表示用户手动点提醒;
  // - 其他排序没有可靠增量事件,默认 10 秒重新拉取当前列表。
  const DEFAULT_AUTO_SILENT_REFRESH_INTERVAL = 0;
  const DEFAULT_AUTO_REFRESH_INTERVAL = 10;

  // 自动补页只在“筛选后当前页不够显示”时触发。窗口限速和空结果计数一起
  // 防止未读/已读筛选在站点数据不足时连续请求后续页。
  const AUTO_LOAD_RATE_WINDOW_MS = 5000;
  const AUTO_LOAD_MAX_REQUESTS_PER_WINDOW = 3;
  const AUTO_LOAD_MAX_EMPTY_FILTER_RESULTS = 3;
  const SETTINGS_BUTTON_SIZE = 28;
  const TAG_STYLE_CACHE_VERSION = 1;

  // ========== 全局状态 ==========
  // currentOrder 历史上曾使用 default,后续需求把“默认”和“最新活动”合并。
  // 这里在启动时迁移旧值,避免旧用户升级后落到不存在的排序分支。
  let feedModeEnabled = GM_getValue(STATE_KEY, false);
  let currentOrder = GM_getValue(ORDER_KEY, "activity");
  if (currentOrder === "default") {
    currentOrder = "activity";
    GM_setValue(ORDER_KEY, currentOrder);
  }
  let currentPeriod = GM_getValue(PERIOD_KEY, "all");
  let sfpSidebarWidth = GM_getValue(WIDTH_KEY, DEFAULT_WIDTH);
  let currentTab = GM_getValue(TAB_KEY, "all");
  let currentFilter = GM_getValue(FILTER_KEY, "all");
  let hidePinned = GM_getValue(HIDE_PINNED_KEY, false);
  let showIncomingHint = GM_getValue(SHOW_INCOMING_HINT_KEY, true);
  let autoSilentRefreshEnabled = GM_getValue(AUTO_SILENT_REFRESH_KEY, false);
  let autoSilentRefreshInterval = Math.max(0, Number(GM_getValue(AUTO_SILENT_REFRESH_INTERVAL_KEY, DEFAULT_AUTO_SILENT_REFRESH_INTERVAL)) || DEFAULT_AUTO_SILENT_REFRESH_INTERVAL);
  let autoRefreshEnabled = GM_getValue(AUTO_REFRESH_ENABLED_KEY, false);
  let autoRefreshInterval = Math.max(1, Number(GM_getValue(AUTO_REFRESH_INTERVAL_KEY, DEFAULT_AUTO_REFRESH_INTERVAL)) || DEFAULT_AUTO_REFRESH_INTERVAL);
  let currentCategoryId = null;

  let allTopics = [];
  let usersMap = {};
  let loadedTopicIds = new Set();
  let currentPage = 0;
  let hasMorePages = true;
  let isLoading = false;
  let isLoadingMore = false;
  let isRefreshing = false;
  let _pendingReload = false;

  // 自动刷新相关计时器都只存运行态,不持久化剩余秒数。页面切换或脚本重载后
  // 重新按用户配置开始倒计时,比恢复旧倒计时更容易避免重复刷新。
  let autoSilentRefreshTimer = null;
  let autoSilentRefreshSeconds = 0;
  let autoRefreshTimer = null;
  let autoRefreshSeconds = 0;

  // 自动补页按“当前查询快照”隔离。切换板块、排序、已读筛选后必须清零,
  // 否则上一个视图的限速或空结果会错误影响新视图。
  let autoLoadTimestamps = [];
  let autoLoadEmptyFilterCount = 0;
  let autoLoadStoppedForSession = false;
  let autoLoadSessionKey = "";

  // message-bus 只告诉我们“可能有变化的话题 id”。完整话题数据仍要从
  // /latest.json?topic_ids=... 拉取;本地 cache 只用于在显示提醒前做板块范围
  // 粗筛,避免每条推送都立即请求详情。
  const sidebarIncomingState = {
    topicIds: [],
    topicIdSet: new Set(),
    topicCache: new Map(),
    filteredTopicIds: [],
    filterRefreshTimer: null,
    filterRefreshToken: 0,
    viewSettling: false,
    filterStable: false,
    applyQueued: false,
  };
  let sidebarMessageBus = null;
  let sidebarLatestMessageBusCallback = null;
  let sidebarNewMessageBusCallback = null;
  let activeLoadToken = 0;
  let activeLoadMoreToken = 0;
  let activeRefreshToken = 0;
  let categoryMetaPromise = null;
  let categoryMetaLoaded = false;
  let tagStylePromise = null;
  let tagStyleLoaded = false;
  let siteDataPromise = null;
  let siteDataLoaded = false;
  let siteDataCache = null;
  let pendingTabBarScrollTab = null;
  let toggleBtn = null;
  let feedContainer = null;
  let feedScrollEl = null;
  let feedListEl = null;
  let feedHeaderEl = null;
  let feedRefreshBtn = null;
  let refreshBusyCount = 0;
  let feedBackTopBtn = null;
  let feedScrollAbortController = null;
  let resizerEl = null;
  let isResizing = false;
  let originalSidebarWidthBeforeFeed = null;
  let widthAnimationTimer = null;
  const topicHighlightTimers = new WeakMap();

  // ========== 工具函数 ==========
  let cachedCsrfToken = null;

  function getCsrfToken() {
    if (cachedCsrfToken === null) {
      cachedCsrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute("content") || "";
    }
    return cachedCsrfToken;
  }

  function toAbsoluteSiteUrl(path) {
    if (!path) return "";
    return new URL(path, location.origin).href;
  }

  function navigateTo(path) {
    const script = document.createElement("script");
    script.textContent = `window.require("discourse/lib/url").default.routeTo("${path}");`;
    document.documentElement.appendChild(script);
    script.remove();
  }

  function getDiscourse() {
    try {
      return (typeof unsafeWindow !== "undefined" && unsafeWindow.Discourse) ||
        (typeof window !== "undefined" && window.Discourse) ||
        (typeof Discourse !== "undefined" && Discourse) ||
        null;
    } catch (e) {
      return null;
    }
  }

  function getMessageBus() {
    try {
      return getDiscourse()?.__container__?.lookup("service:message-bus") || null;
    } catch (e) {
      return null;
    }
  }

  function debounce(fn, delay) {
    let timer;
    return function (...args) {
      clearTimeout(timer);
      timer = setTimeout(() => fn.apply(this, args), delay);
    };
  }

  function escapeHtml(text) {
    if (!text) return "";
    const div = document.createElement("div");
    div.textContent = text;
    return div.innerHTML;
  }

  function escapeAttr(text) {
    return escapeHtml(text).replace(/"/g, """).replace(/'/g, "'");
  }

  function formatRelativeTime(dateStr) {
    const date = new Date(dateStr);
    if (Number.isNaN(date.getTime())) return "";
    const now = new Date();
    const diff = Math.max(0, now - date);
    const seconds = Math.floor(diff / 1000);
    const minutes = Math.floor(seconds / 60);
    const hours = Math.floor(minutes / 60);
    const days = Math.floor(hours / 24);
    if (seconds < 60) return `${Math.max(1, seconds)}秒前`;
    if (minutes < 60) return `${minutes}分钟前`;
    if (hours < 24) return `${hours}小时前`;
    if (days < 30) return `${days}天前`;
    const months = Math.floor(days / 30);
    if (months < 12) return `${months}个月前`;
    return `${Math.floor(months / 12)}年前`;
  }

  function getAvatarUrl(template, size) {
    if (!template) return "";
    let url = template.replace("{size}", String(size));
    if (!url.startsWith("http")) url = toAbsoluteSiteUrl(url);
    return url;
  }

  function waitForEmber(callback, maxWait = 15000) {
    const start = Date.now();
    function check() {
      try {
        if (
          getDiscourse()?.__container__
        ) {
          callback();
          return;
        }
      } catch (e) { /* not ready */ }
      if (Date.now() - start < maxWait) {
        setTimeout(check, 500);
      } else {
        console.warn("[SFP] Timed out waiting for Ember");
      }
    }
    check();
  }

  // ========== 分类配置 ==========
  const CATEGORY_CONFIG = {
    4: { name: "开发调优", icon: "code", color: "#32c3c3", tabId: "develop" },
    20: { name: "开发调优, Lv1", icon: "code", color: "#32c3c3" },
    31: { name: "开发调优, Lv2", icon: "code", color: "#32c3c3" },
    88: { name: "开发调优, Lv3", icon: "code", color: "#32c3c3" },
    98: { name: "国产替代", icon: "seedling", color: "#D12C25", tabId: "domestic" },
    99: { name: "国产替代, Lv1", icon: "seedling", color: "#D12C25" },
    100: { name: "国产替代, Lv2", icon: "seedling", color: "#D12C25" },
    101: { name: "国产替代, Lv3", icon: "seedling", color: "#D12C25" },
    14: { name: "资源荟萃", icon: "square-share-nodes", color: "#12A89D", tabId: "resource" },
    83: { name: "资源荟萃, Lv1", icon: "square-share-nodes", color: "#12A89D" },
    84: { name: "资源荟萃, Lv2", icon: "square-share-nodes", color: "#12A89D" },
    85: { name: "资源荟萃, Lv3", icon: "square-share-nodes", color: "#12A89D" },
    94: { name: "网盘资源", icon: "hard-drive", color: "#16b176" },
    95: { name: "网盘资源, Lv1", icon: "hard-drive", color: "#16b176" },
    96: { name: "网盘资源, Lv2", icon: "hard-drive", color: "#16b176" },
    97: { name: "网盘资源, Lv3", icon: "hard-drive", color: "#16b176" },
    42: { name: "文档共建", icon: "book", color: "#9cb6c4", tabId: "wiki" },
    75: { name: "文档共建, Lv1", icon: "book", color: "#9cb6c4" },
    76: { name: "文档共建, Lv2", icon: "book", color: "#9cb6c4" },
    77: { name: "文档共建, Lv3", icon: "book", color: "#9cb6c4" },
    10: { name: "跳蚤市场", icon: "coins", color: "#ED207B", tabId: "trade" },
    106: { name: "积分乐园", icon: "credit-card", color: "#fcca44", tabId: "credit" },
    107: { name: "积分乐园, Lv1", icon: "credit-card", color: "#fcca44" },
    108: { name: "积分乐园, Lv2", icon: "credit-card", color: "#fcca44" },
    109: { name: "积分乐园, Lv3", icon: "credit-card", color: "#fcca44" },
    27: { name: "非我莫属", icon: "briefcase", color: "#a8c6fe", tabId: "job" },
    72: { name: "非我莫属, Lv1", icon: "briefcase", color: "#a8c6fe" },
    73: { name: "非我莫属, Lv2", icon: "briefcase", color: "#a8c6fe" },
    74: { name: "非我莫属, Lv3", icon: "briefcase", color: "#a8c6fe" },
    32: { name: "读书成诗", icon: "book-open-reader", color: "#e0d900", tabId: "reading" },
    69: { name: "读书成诗, Lv1", icon: "book-open-reader", color: "#e0d900" },
    70: { name: "读书成诗, Lv2", icon: "book-open-reader", color: "#e0d900" },
    71: { name: "读书成诗, Lv3", icon: "book-open-reader", color: "#e0d900" },
    46: { name: "扬帆起航", icon: "rocket", color: "#ff9838", tabId: "startup" },
    66: { name: "扬帆起航, Lv1", icon: "rocket", color: "#ff9838" },
    67: { name: "扬帆起航, Lv2", icon: "rocket", color: "#ff9838" },
    68: { name: "扬帆起航, Lv3", icon: "rocket", color: "#ff9838" },
    34: { name: "前沿快讯", icon: "newspaper", color: "#BB8FCE", tabId: "news" },
    78: { name: "前沿快讯, Lv1", icon: "newspaper", color: "#BB8FCE" },
    79: { name: "前沿快讯, Lv2", icon: "newspaper", color: "#BB8FCE" },
    80: { name: "前沿快讯, Lv3", icon: "newspaper", color: "#BB8FCE" },
    36: { name: "福利羊毛", icon: "piggy-bank", color: "#E45735", tabId: "welfare" },
    60: { name: "福利羊毛, Lv1", icon: "piggy-bank", color: "#E45735" },
    61: { name: "福利羊毛, Lv2", icon: "piggy-bank", color: "#E45735" },
    62: { name: "福利羊毛, Lv3", icon: "piggy-bank", color: "#E45735" },
    11: { name: "搞七捻三", icon: "droplet", color: "#3AB54A", tabId: "gossip" },
    35: { name: "搞七捻三, Lv1", icon: "droplet", color: "#3AB54A" },
    89: { name: "搞七捻三, Lv2", icon: "droplet", color: "#3AB54A" },
    21: { name: "搞七捻三, Lv3", icon: "droplet", color: "#3AB54A" },
    102: { name: "社区孵化", icon: "lightbulb", color: "#ffbb00", tabId: "incubation" },
    103: { name: "社区孵化, Lv1", icon: "lightbulb", color: "#ffbb00" },
    104: { name: "社区孵化, Lv2", icon: "lightbulb", color: "#ffbb00" },
    105: { name: "社区孵化, Lv3", icon: "lightbulb", color: "#ffbb00" },
    110: { name: "虫洞广场", icon: "hurricane", color: "#ff00f7", tabId: "square" },
    2: { name: "运营反馈", icon: "comments", color: "#808281", tabId: "feedback" },
    30: { name: "运营反馈, 活动", icon: "comments", color: "#808281" },
    63: { name: "运营反馈, Lv1", icon: "comments", color: "#808281" },
    64: { name: "运营反馈, Lv2", icon: "comments", color: "#808281" },
    65: { name: "运营反馈, Lv3", icon: "comments", color: "#808281" },
    45: { name: "深海幽域", icon: "water", color: "#45B7D1", tabId: "muted" },
    57: { name: "深海幽域, Lv1", icon: "water", color: "#45B7D1" },
    58: { name: "深海幽域, Lv2", icon: "water", color: "#45B7D1" },
    59: { name: "深海幽域, Lv3", icon: "water", color: "#45B7D1" },
  };

  // 有 tabId 的主分类(用于标签页渲染)
  const TAB_CATEGORIES = Object.entries(CATEGORY_CONFIG)
    .filter(([, v]) => v.tabId)
    .map(([id, v]) => ({ id: Number(id), ...v }));

  const categoryMetaById = new Map();
  const tagStyleByKey = new Map();
  const SAFE_ICON_RE = /^[A-Za-z0-9_-]+$/;
  const SAFE_COLOR_RE = /^#?[A-Fa-f0-9]{3,8}$/;

  function _normalizeHexColor(color, fallback = "888") {
    if (!color) return fallback;
    const raw = String(color).trim();
    if (!SAFE_COLOR_RE.test(raw)) return fallback;
    return raw.startsWith("#") ? raw.slice(1) : raw;
  }

  function _isSafeIconName(icon) {
    return typeof icon === "string" && SAFE_ICON_RE.test(icon);
  }

  function _safeIconName(icon) {
    return _isSafeIconName(icon) ? icon : "";
  }

  function _safeCategoryStyleType(styleType, hasIcon) {
    return ["icon", "emoji", "square"].includes(styleType)
      ? styleType
      : (hasIcon ? "icon" : "square");
  }

  function _svgIcon(icon, extraClass = "") {
    const safeIcon = _safeIconName(icon);
    if (!safeIcon) return "";
    const className = `fa d-icon d-icon-${safeIcon} svg-icon fa-width-auto svg-string${extraClass ? ` ${extraClass}` : ""}`;
    return `<svg class="${className}" width="1em" height="1em" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"><use href="#${safeIcon}"></use></svg>`;
  }

  function _categoryFallbackMeta(id) {
    const config = CATEGORY_CONFIG[id];
    if (!config) return null;
    return _normalizeCategoryMeta({ id }, config, config.parent_category_id ? _getCategoryMeta(config.parent_category_id) : null);
  }

  function _normalizeCategoryMeta(raw = {}, fallback = {}, parent = null) {
    const id = Number(raw.id ?? fallback.id);
    const icon = _safeIconName(raw.icon || fallback.icon || parent?.icon || "folder");
    return {
      id,
      name: raw.name || fallback.name || "",
      color: _normalizeHexColor(raw.color || fallback.color, "888"),
      text_color: _normalizeHexColor(raw.text_color || fallback.text_color, "FFFFFF"),
      icon,
      style_type: _safeCategoryStyleType(raw.style_type || fallback.style_type, !!icon),
      slug: raw.slug || fallback.tabId || "",
      parent_category_id: raw.parent_category_id || fallback.parent_category_id || null,
      parent_color: parent ? _normalizeHexColor(parent.color, "888") : null,
      parent_text_color: parent ? _normalizeHexColor(parent.text_color, "FFFFFF") : null,
      read_restricted: !!(raw.read_restricted || fallback.read_restricted),
      description_text: raw.description_text || raw.description_excerpt || raw.description || "",
      description_excerpt: raw.description_excerpt || raw.description_text || raw.description || "",
    };
  }

  function _getCategoryMeta(id) {
    const numericId = Number(id);
    if (!Number.isFinite(numericId)) return null;
    return categoryMetaById.get(numericId) || _categoryFallbackMeta(numericId);
  }

  function getCategoryTabMeta(cat) {
    const meta = _getCategoryMeta(cat.id);
    return {
      ...cat,
      name: meta?.name || cat.name,
      icon: meta?.icon || cat.icon,
      color: meta?.color ? `#${meta.color}` : cat.color,
    };
  }

  function _getSavedTabOrder() {
    const savedOrder = GM_getValue(TAB_ORDER_KEY, []);
    if (!Array.isArray(savedOrder)) return [];
    return savedOrder.map((id) => Number(id)).filter((id) => Number.isFinite(id));
  }

  function _getOrderedTabCategories() {
    const savedOrder = _getSavedTabOrder();
    if (!savedOrder.length) return [...TAB_CATEGORIES];

    return [...TAB_CATEGORIES].sort((a, b) => {
      const idxA = savedOrder.indexOf(a.id);
      const idxB = savedOrder.indexOf(b.id);
      if (idxA === -1 && idxB === -1) return 0;
      if (idxA === -1) return 1;
      if (idxB === -1) return -1;
      return idxA - idxB;
    });
  }

  function _saveTabOrderFromGrid(grid) {
    const order = Array.from(grid.querySelectorAll(".sfp-tab-grid-item[data-category-id]"))
      .map((item) => Number(item.dataset.categoryId))
      .filter((id) => Number.isFinite(id));
    GM_setValue(TAB_ORDER_KEY, order);
  }

  function _buildCategoryTabContent(cat) {
    const tabMeta = getCategoryTabMeta(cat);
    return `${_svgIcon(tabMeta.icon)}<span>${escapeHtml(tabMeta.name)}</span>`;
  }

  function _cssEscape(value) {
    return window.CSS?.escape ? CSS.escape(String(value)) : String(value).replace(/["\\]/g, "\\$&");
  }

  function _closeFloatingPanels(exceptEl = null) {
    document.querySelectorAll(".sfp-custom-select.open, .sfp-settings-wrap.open, .sfp-tab-shell.open").forEach((el) => {
      if (el !== exceptEl) el.classList.remove("open");
    });
  }

  function _scrollTabIntoView(shell, tabId = currentTab, behavior = "auto") {
    const bar = shell?.querySelector(".sfp-tab-bar");
    const activeTab = shell?.querySelector(`.sfp-tab-bar .sfp-tab-item[data-tab="${_cssEscape(tabId)}"]`);
    if (!bar || !activeTab) return;

    const maxScrollLeft = Math.max(0, bar.scrollWidth - bar.clientWidth);
    const targetLeft = activeTab.offsetLeft - ((bar.clientWidth - activeTab.offsetWidth) / 2);
    const left = Math.min(maxScrollLeft, Math.max(0, targetLeft));

    bar.scrollTo({ left, behavior });
  }

  function _parsePreloadedPayload(raw) {
    if (!raw) return null;
    try {
      const decoded = raw.startsWith("%") ? decodeURIComponent(raw) : raw;
      return JSON.parse(decoded);
    } catch (e) {
      return null;
    }
  }

  function _extractPreloadedSiteData() {
    const candidates = [
      ...document.querySelectorAll("[data-preloaded]"),
      ...document.querySelectorAll("script[type='application/json']"),
    ];
    for (const el of candidates) {
      const payload = _parsePreloadedPayload(el.getAttribute("data-preloaded") || el.textContent || "");
      const site = payload?._site || payload?.site || payload;
      if (site?.categories || site?.top_tags) return site;
    }
    return null;
  }

  async function loadSiteData() {
    if (siteDataLoaded) return siteDataCache;
    if (siteDataPromise) return siteDataPromise;

    siteDataPromise = (async () => {
      const preloaded = _extractPreloadedSiteData();
      if (preloaded) {
        siteDataCache = preloaded;
        siteDataLoaded = true;
        return siteDataCache;
      }

      const resp = await fetch("/site.json", { headers: { "X-CSRF-Token": getCsrfToken() } });
      if (!resp.ok) throw new Error(`site.json ${resp.status}`);
      siteDataCache = await resp.json();
      siteDataLoaded = true;
      return siteDataCache;
    })().finally(() => {
      siteDataPromise = null;
    });

    return siteDataPromise;
  }

  async function loadCategoryMetadata() {
    if (categoryMetaLoaded) return;
    if (categoryMetaPromise) return categoryMetaPromise;

    categoryMetaPromise = (async () => {
      try {
        const site = await loadSiteData();
        const categories = Array.isArray(site?.categories) ? site.categories : [];
        const rawById = new Map(categories.map((cat) => [Number(cat.id), cat]));

        categories.forEach((cat) => {
          const id = Number(cat.id);
          if (!Number.isFinite(id)) return;
          const parent = cat.parent_category_id ? rawById.get(Number(cat.parent_category_id)) : null;
          const fallback = CATEGORY_CONFIG[id] || {};
          categoryMetaById.set(id, _normalizeCategoryMeta(cat, fallback, parent));
        });

        categoryMetaLoaded = true;
      } catch (e) {
        console.warn("[SFP] load category metadata failed:", e);
      } finally {
        categoryMetaPromise = null;
      }
    })();

    return categoryMetaPromise;
  }

  function _tagIndexKeys(tag) {
    const keys = [];
    const add = (value) => {
      if (value === null || value === undefined) return;
      const key = String(value).trim().toLowerCase();
      if (key && !keys.includes(key)) keys.push(key);
    };
    if (typeof tag === "string") {
      add(tag);
      return keys;
    }
    add(tag?.name);
    add(tag?.slug);
    add(tag?.text);
    add(tag?.id);
    return keys;
  }

  function _tagDisplayName(tag) {
    return typeof tag === "string" ? tag : (tag?.name || tag?.text || tag?.slug || "");
  }

  function _normalizeTagRecord(tag) {
    if (typeof tag === "string") {
      const name = tag.trim();
      return name ? { name, slug: name } : null;
    }
    if (tag && typeof tag === "object") {
      const name = String(tag.name || tag.text || tag.slug || tag.id || "").trim();
      if (!name) return null;
      return {
        id: tag.id,
        name,
        slug: tag.slug || tag.name || name,
      };
    }
    const name = String(tag || "").trim();
    return name ? { name, slug: name } : null;
  }

  function _getTopTagsFromSiteData(site) {
    if (site?.can_tag_topics === false) return [];
    const topTags = Array.isArray(site?.top_tags) ? site.top_tags : [];
    return topTags.map(_normalizeTagRecord).filter(Boolean);
  }

  function _cacheTagStyleAliases(tags) {
    tags.forEach((tag) => {
      const style = _getTagStyle(tag);
      if (style) _cacheTagStyle(_tagIndexKeys(tag), style);
    });
  }

  function _getTagStyle(tag) {
    for (const key of _tagIndexKeys(tag)) {
      const style = tagStyleByKey.get(key);
      if (style) return style;
    }
    return null;
  }

  function _sanitizeTagStyle(styleText) {
    const pairs = [];
    String(styleText || "").split(";").forEach((part) => {
      const [rawName, rawValue] = part.split(":");
      const name = rawName?.trim();
      const value = rawValue?.trim();
      if ((name === "--color1" || name === "--color2") && /^#[A-Fa-f0-9]{3,8}$/.test(value)) {
        pairs.push(`${name}: ${value}`);
      }
    });
    return pairs.join("; ");
  }

  function _cacheTagStyle(keys, style) {
    keys.forEach((key) => {
      const normalized = String(key || "").trim().toLowerCase();
      if (normalized) tagStyleByKey.set(normalized, style);
    });
  }

  function _loadTagStyleCache() {
    if (tagStyleByKey.size > 0) return true;

    try {
      const cache = GM_getValue(TAG_STYLE_CACHE_KEY, null);
      if (!cache || cache.version !== TAG_STYLE_CACHE_VERSION || !Array.isArray(cache.entries)) {
        return false;
      }

      cache.entries.forEach(([key, style]) => {
        if (!key || !style || typeof style !== "object") return;
        const icon = _safeIconName(style.icon || "");
        const cssText = _sanitizeTagStyle(style.cssText || "");
        if (!icon && !cssText) return;
        tagStyleByKey.set(String(key), {
          icon,
          cssText,
          hasIcon: !!icon,
        });
      });

      return tagStyleByKey.size > 0;
    } catch (e) {
      console.warn("[SFP] load tag style cache failed:", e);
      return false;
    }
  }

  function _saveTagStyleCache() {
    if (tagStyleByKey.size === 0) return;

    try {
      GM_setValue(TAG_STYLE_CACHE_KEY, {
        version: TAG_STYLE_CACHE_VERSION,
        savedAt: Date.now(),
        entries: Array.from(tagStyleByKey.entries()),
      });
    } catch (e) {
      console.warn("[SFP] save tag style cache failed:", e);
    }
  }

  function _extractTagStylesFromDocument(doc) {
    const anchors = Array.from(doc.querySelectorAll("a.discourse-tag[data-tag-name]"));
    anchors.forEach((anchor) => {
      const use = anchor.querySelector("svg use");
      const icon = _safeIconName((use?.getAttribute("href") || use?.getAttribute("xlink:href") || "").replace(/^#/, ""));
      const style = {
        icon,
        cssText: _sanitizeTagStyle(anchor.getAttribute("style") || ""),
        hasIcon: !!icon,
      };
      if (!style.hasIcon && !style.cssText) return;

      const hrefParts = (anchor.getAttribute("href") || "").split("/").filter(Boolean);
      _cacheTagStyle([
        anchor.dataset.tagName,
        anchor.textContent,
        hrefParts[1],
        hrefParts[2],
      ], style);
    });
  }

  function _waitForIframeTags(iframe, timeoutMs = 12000) {
    return new Promise((resolve) => {
      const start = Date.now();
      const tick = () => {
        let doc = null;
        try {
          doc = iframe.contentDocument;
          if (doc && doc.querySelector("a.discourse-tag[data-tag-name]")) {
            resolve(doc);
            return;
          }
        } catch (e) {
          resolve(null);
          return;
        }
        if (Date.now() - start >= timeoutMs) {
          resolve(doc);
          return;
        }
        setTimeout(tick, 250);
      };
      tick();
    });
  }

  async function loadTagStyleIndex() {
    if (tagStyleLoaded) return;
    if (tagStylePromise) return tagStylePromise;

    tagStylePromise = (async () => {
      let iframe = null;
      try {
        let siteTags = [];
        try {
          siteTags = _getTopTagsFromSiteData(await loadSiteData());
        } catch (e) {
          siteTags = _getTopTagsFromSiteData(_extractPreloadedSiteData());
        }

        if (_loadTagStyleCache()) {
          _cacheTagStyleAliases(siteTags);
          tagStyleLoaded = true;
          return;
        }

        _extractTagStylesFromDocument(document);
        iframe = document.createElement("iframe");
        iframe.src = "/tags";
        iframe.setAttribute("aria-hidden", "true");
        iframe.style.cssText = "position:absolute;width:1px;height:1px;left:-10000px;top:-10000px;border:0;visibility:hidden;pointer-events:none;";
        document.body.appendChild(iframe);
        const doc = await _waitForIframeTags(iframe);
        if (doc) _extractTagStylesFromDocument(doc);

        _cacheTagStyleAliases(siteTags);
        _saveTagStyleCache();
        tagStyleLoaded = true;
      } catch (e) {
        console.warn("[SFP] load tag style index failed:", e);
      } finally {
        if (iframe) iframe.remove();
        tagStylePromise = null;
      }
    })();

    return tagStylePromise;
  }

  // ========== CSS 注入 ==========
  function injectStyles() {
    GM_addStyle(`
      /* ===== 切换按钮 ===== */
      .sfp-toggle-btn {
        display: inline-flex;
        align-items: center;
        justify-content: center;
        width: 28px;
        height: 28px;
        border: none;
        background: var(--primary-very-low);
        color: var(--primary-medium);
        cursor: pointer;
        border-radius: 6px;
        padding: 0;
        margin-left: 6px;
        vertical-align: middle;
        transition: color 0.2s, background 0.2s;
        flex-shrink: 0;
      }
      .sfp-toggle-btn:hover {
        color: var(--primary);
        background: var(--primary-low);
      }
      .sfp-toggle-btn.active {
        color: var(--secondary);
        background: var(--tertiary);
      }
      .sfp-toggle-btn svg {
        width: 18px;
        height: 18px;
        fill: currentColor;
      }
      .home-logo-wrapper-outlet .title {
        display: flex;
        align-items: center;
        gap: 2px;
      }

      /* ===== 侧边栏 Feed 模式 ===== */
      .sidebar-wrapper:has(> .sidebar-container.sfp-feed-mode) {
        overflow-x: hidden !important;
      }
      .sidebar-container.sfp-feed-mode {
        overflow-x: hidden !important;
      }
      .sidebar-container.sfp-feed-mode .sfp-feed-container,
      .sidebar-container.sfp-feed-mode .sfp-feed-container * {
        box-sizing: border-box;
      }
      /* 隐藏所有非 feed 的直接子元素 */
      .sidebar-container.sfp-feed-mode > :not(.sfp-feed-container):not(.sfp-resizer) {
        display: none !important;
      }
      /* 显式隐藏常见 sidebar 组件(嵌套情况兜底) */
      .sidebar-container.sfp-feed-mode .sidebar-sections,
      .sidebar-container.sfp-feed-mode .sidebar-footer-container,
      .sidebar-container.sfp-feed-mode .sidebar-footer-wrapper,
      .sidebar-container.sfp-feed-mode .sidebar-footer,
      .sidebar-container.sfp-feed-mode .sidebar-custom-sections,
      .sidebar-container.sfp-feed-mode .sidebar-section-wrapper,
      .sidebar-container.sfp-feed-mode .sidebar-section-header,
      .sidebar-container.sfp-feed-mode .sidebar-section-link-wrapper {
        display: none !important;
      }
      .sidebar-container.sfp-feed-mode .sfp-feed-container {
        display: flex;
        flex-direction: column;
        position: relative;
        width: 100%;
        min-width: 0;
        height: 100%;
        overflow: hidden;
        max-width: 100%;
      }
      .sidebar-wrapper.sfp-width-animating,
      .sidebar-container.sfp-width-animating,
      #d-sidebar.sfp-width-animating {
        transition: width 220ms ease, max-width 220ms ease;
      }

      /* ===== 拖拽调整宽度 ===== */
      .sfp-resizer {
        position: absolute;
        top: 0;
        right: -2px;
        width: 5px;
        height: 100%;
        cursor: ew-resize;
        z-index: 10001;
        transition: background 0.2s;
      }
      .sfp-resizer:hover,
      .sfp-resizer.sfp-resizing {
        background: var(--tertiary);
      }

      /* ===== Feed Header ===== */
      .sfp-feed-header {
        position: relative;
        flex-shrink: 0;
        padding: 8px 12px;
        border-bottom: 1px solid var(--primary-low);
        display: flex;
        flex-wrap: wrap;
        gap: 6px;
        align-items: center;
        overflow: visible;
      }
      .sfp-feed-header .sfp-header-spacer {
        flex: 1 1 auto;
        min-width: 8px;
      }
      .sfp-feed-header .sfp-refresh-btn,
      .sfp-feed-header .sfp-settings-btn {
        display: inline-flex;
        align-items: center;
        justify-content: center;
        width: 28px;
        height: 28px;
        border: none;
        background: var(--primary-very-low);
        color: var(--primary-medium);
        cursor: pointer;
        border-radius: 6px;
        padding: 0;
        flex-shrink: 0;
        transition: color 0.2s, background 0.2s;
      }
      .sfp-feed-header .sfp-refresh-btn:hover,
      .sfp-feed-header .sfp-settings-btn:hover,
      .sfp-settings-wrap.open .sfp-settings-btn {
        color: var(--tertiary);
        background: var(--primary-low);
      }
      .sfp-feed-header .sfp-refresh-btn.spinning svg {
        animation: sfp-spin 0.6s linear infinite;
      }
      .sfp-feed-header .sfp-refresh-btn.spinning {
        color: var(--tertiary);
        background: var(--primary-low);
      }
      @keyframes sfp-spin {
        from { transform: rotate(0deg); }
        to { transform: rotate(360deg); }
      }
      .sfp-feed-header .sfp-refresh-btn svg,
      .sfp-feed-header .sfp-settings-btn svg {
        width: 16px;
        height: 16px;
        fill: currentColor;
      }
      .sfp-settings-btn {
        position: absolute;
        top: 0;
        right: 0;
        z-index: 2;
        gap: 3px;
        flex-direction: column;
      }
      .sfp-settings-line {
        width: 14px;
        height: 2px;
        border-radius: 2px;
        background: currentColor;
        transition: transform 0.28s ease, opacity 0.2s ease;
        transform-origin: center;
      }
      .sfp-settings-wrap.open .sfp-settings-line-1 {
        transform: translateY(5px) rotate(45deg);
      }
      .sfp-settings-wrap.open .sfp-settings-line-2 {
        opacity: 0;
        transform: scaleX(0);
      }
      .sfp-settings-wrap.open .sfp-settings-line-3 {
        transform: translateY(-5px) rotate(-45deg);
      }
      .sfp-show-more-overlay {
        position: relative;
        z-index: 1;
        display: flex;
        justify-content: center;
        width: 100%;
        max-width: 100%;
        margin: 0;
        padding: 0;
        font-size: 12px;
        pointer-events: none;
        overflow: hidden;
        animation: sfp-show-more-enter 260ms cubic-bezier(0.2, 0.8, 0.2, 1);
      }
      .sfp-show-more-overlay .sfp-hint-text {
        display: inline-flex;
        align-items: center;
        justify-content: center;
        gap: 0.5em;
        max-width: calc(100% - 24px);
        margin: 8px 12px 6px;
        padding: var(--space-2, 0.5em) var(--space-4, 1em);
        border: none;
        border-radius: var(--d-border-radius-large, 20px);
        cursor: pointer;
        font-size: inherit;
        line-height: 1.35;
        text-decoration: none;
        white-space: nowrap;
        pointer-events: auto;
        transition: background-color 0.2s, color 0.2s;
      }
      .sfp-show-more-overlay .sfp-hint-label {
        min-width: 0;
        overflow: hidden;
        text-overflow: ellipsis;
      }
      .sfp-show-more-overlay .sfp-hint-spinner-custom {
        box-sizing: border-box;
        display: inline-block;
        width: 0.85em;
        height: 0.85em;
        border: 0.15em solid currentColor;
        border-right-color: transparent;
        border-radius: 50%;
        flex: 0 0 auto;
        align-self: center;
        animation: sfp-spin 0.75s linear infinite;
      }
      .sfp-show-more-overlay .sfp-hint-text.loading {
        color: var(--primary-medium);
        cursor: default;
      }
      .sfp-show-more-overlay .sfp-hint-text:hover {
        color: var(--tertiary-hover, var(--tertiary));
      }
      .sfp-show-more-overlay .sfp-hint-text.loading:hover {
        color: var(--primary-medium);
      }
      @keyframes sfp-show-more-enter {
        from {
          max-height: 0;
          opacity: 0;
          transform: translateY(-100%);
        }
        to {
          max-height: 48px;
          opacity: 1;
          transform: translateY(0);
        }
      }
      .sfp-settings-wrap {
        position: relative;
        width: 28px;
        height: 28px;
        flex-shrink: 0;
        overflow: visible;
      }
      .sfp-settings-shell {
        position: absolute;
        top: 0;
        right: 0;
        width: 28px;
        height: 28px;
        background: transparent;
        border: none;
        border-radius: 6px;
        box-shadow: none;
        z-index: 10003;
        overflow: hidden;
        transition: width 0.36s cubic-bezier(0.25, 1, 0.5, 1),
                    height 0.36s cubic-bezier(0.25, 1, 0.5, 1),
                    border-radius 0.24s ease,
                    background 0.2s ease;
      }
      .sfp-settings-wrap.open .sfp-settings-shell {
        width: 204px;
        height: var(--sfp-settings-shell-height, 128px);
        overflow: visible;
        background: var(--primary-very-low);
        border: 1px solid var(--primary-low);
        border-radius: 8px;
        box-shadow: 0 8px 24px color-mix(in srgb, var(--primary) 14%, transparent);
      }
      .sfp-settings-panel {
        box-sizing: border-box;
        width: 204px;
        padding: 36px 10px 8px 10px;
        opacity: 0;
        visibility: hidden;
        transform: translateY(-8px);
        pointer-events: none;
        transition: opacity 0.18s ease, transform 0.18s ease, visibility 0.18s;
      }
      .sfp-settings-wrap.open .sfp-settings-panel {
        opacity: 1;
        visibility: visible;
        transform: translateY(0);
        pointer-events: auto;
        transition: opacity 0.26s ease 0.12s, transform 0.26s ease 0.12s, visibility 0.26s 0.12s;
      }
      .sfp-setting-row {
        display: grid;
        grid-template-columns: minmax(0, 1fr) auto;
        align-items: center;
        column-gap: 8px;
        font-size: 12px;
        color: var(--primary);
        line-height: 1.3;
        padding: 4px 0;
      }
      .sfp-setting-label {
        display: inline-flex;
        align-items: center;
        gap: 4px;
        min-width: 0;
        white-space: nowrap;
      }
      .sfp-setting-help-wrap {
        display: inline-flex;
        align-items: center;
        justify-content: center;
        width: 14px;
        height: 14px;
        flex: 0 0 14px;
        pointer-events: none;
      }
      .sfp-setting-help {
        display: inline-flex;
        align-items: center;
        justify-content: center;
        width: 14px;
        height: 14px;
        box-sizing: border-box;
        appearance: none;
        border: none;
        background: transparent;
        color: var(--primary-medium);
        cursor: help;
        padding: 0;
        pointer-events: auto;
      }
      .sfp-setting-help svg {
        width: 13px;
        height: 13px;
        display: block;
        fill: currentColor;
        pointer-events: none;
      }
      .sfp-setting-help:hover {
        color: var(--tertiary);
        outline: none;
      }
      .sfp-help-tooltip {
        position: fixed;
        z-index: 10004;
        width: 178px;
        max-width: calc(100vw - 32px);
        padding: 8px 9px;
        border: 1px solid var(--primary-low);
        border-radius: 6px;
        background: var(--secondary);
        box-shadow: 0 8px 22px color-mix(in srgb, var(--primary) 16%, transparent);
        color: var(--primary);
        font-size: 12px;
        font-weight: 400;
        line-height: 1.45;
        text-align: left;
        white-space: normal;
        opacity: 0;
        pointer-events: none;
        transform: translateY(-4px);
        transition: opacity 0.16s ease, transform 0.16s ease;
      }
      .sfp-help-tooltip.visible {
        opacity: 1;
        transform: translateY(0);
      }
      .sfp-setting-row input[type="checkbox"] {
        flex-shrink: 0;
        margin: 0;
      }
      .sfp-setting-interval {
        display: none;
        grid-template-columns: minmax(0, 1fr) 58px auto;
        align-items: center;
        column-gap: 6px;
        margin-top: 6px;
        font-size: 12px;
        color: var(--primary-medium);
      }
      .sfp-setting-interval.visible {
        display: grid;
      }
      .sfp-setting-row.hidden,
      .sfp-setting-interval.hidden {
        display: none;
      }
      .sfp-setting-interval input {
        width: 58px;
        height: 26px;
        padding: 2px 6px;
        border: 1px solid var(--primary-low);
        border-radius: 4px;
        background: var(--secondary);
        color: var(--primary);
        font-size: 12px;
      }

      /* ===== 自定义下拉 ===== */
      .sfp-custom-select {
        position: relative;
        flex-shrink: 0;
      }
      .sfp-custom-select-btn {
        display: inline-flex;
        align-items: center;
        gap: 4px;
        padding: 4px 10px;
        font-size: 12px;
        height: 28px;
        border: none;
        background: var(--primary-very-low);
        color: var(--primary);
        border-radius: 6px;
        cursor: pointer;
        white-space: nowrap;
        user-select: none;
        transition: background 0.2s, color 0.2s;
      }
      .sfp-custom-select-btn:hover {
        background: var(--primary-low);
      }
      .sfp-custom-select-btn::after {
        content: "";
        width: 0;
        height: 0;
        border-left: 4px solid transparent;
        border-right: 4px solid transparent;
        border-top: 5px solid currentColor;
      }
      .sfp-custom-select-dropdown {
        position: fixed;
        min-width: 100%;
        background: var(--secondary);
        border: 1px solid var(--primary-low);
        border-radius: 6px;
        box-shadow: 0 4px 12px color-mix(in srgb, var(--primary) 10%, transparent);
        z-index: 10002;
        display: none;
        overflow: hidden;
      }
      .sfp-custom-select.open .sfp-custom-select-dropdown {
        display: block;
      }
      .sfp-custom-select-option {
        display: block;
        width: 100%;
        padding: 6px 14px;
        font-size: 12px;
        border: none;
        background: none;
        color: var(--primary);
        cursor: pointer;
        text-align: left;
        white-space: nowrap;
        transition: background 0.15s;
      }
      .sfp-custom-select-option:hover {
        background: var(--primary-very-low);
      }
      .sfp-custom-select-option.selected {
        color: var(--tertiary);
        font-weight: 600;
      }

      /* ===== 分类标签栏 ===== */
      .sfp-tab-shell {
        position: relative;
        display: grid;
        grid-template-columns: minmax(0, 1fr) 36px;
        align-items: stretch;
        width: 100%;
        min-width: 0;
        max-width: 100%;
        border-bottom: 1px solid var(--primary-low);
        flex-shrink: 0;
        background: var(--d-content-background, var(--secondary));
      }
      .sfp-tab-bar {
        display: flex;
        gap: 8px;
        overflow-x: auto;
        overflow-y: hidden;
        -webkit-overflow-scrolling: touch;
        scrollbar-width: none;
        width: 100%;
        min-width: 0;
        max-width: 100%;
        padding: 8px 12px;
        margin: 0;
        flex-shrink: 0;
        background: transparent;
      }
      .sfp-tab-bar::-webkit-scrollbar { display: none; }
      .sfp-tab-item {
        display: inline-flex;
        align-items: center;
        gap: 3px;
        padding: 4px 12px;
        font-size: 13px;
        color: var(--primary-medium);
        cursor: pointer;
        white-space: nowrap;
        border-radius: 16px;
        background: var(--primary-very-low);
        transition: all 0.2s;
        border: 1px solid transparent;
        flex-shrink: 0;
        user-select: none;
      }
      .sfp-tab-item:hover {
        color: var(--primary);
        background: var(--primary-low);
      }
      .sfp-tab-item.active {
        color: var(--secondary);
        background: var(--tertiary);
        border-color: var(--tertiary);
      }
      .sfp-tab-item svg {
        width: 12px;
        height: 12px;
        fill: currentColor;
        flex-shrink: 0;
      }
      .sfp-tab-more-btn {
        display: inline-flex;
        align-items: center;
        justify-content: center;
        width: 36px;
        min-width: 36px;
        padding: 0;
        border: none;
        border-left: 1px solid var(--primary-low);
        background: var(--d-content-background, var(--secondary));
        color: var(--primary-medium);
        cursor: pointer;
        transition: background 0.2s, color 0.2s;
      }
      .sfp-tab-more-btn:hover,
      .sfp-tab-shell.open .sfp-tab-more-btn {
        background: var(--primary-very-low);
        color: var(--primary);
      }
      .sfp-tab-more-btn svg {
        width: 16px;
        height: 16px;
        fill: currentColor;
      }
      .sfp-tab-panel {
        position: absolute;
        top: 100%;
        right: 8px;
        left: 8px;
        display: none;
        padding: 10px;
        max-height: min(58vh, 420px);
        overflow-y: auto;
        background: var(--secondary);
        border: 1px solid var(--primary-low);
        border-radius: 8px;
        box-shadow: 0 10px 28px color-mix(in srgb, var(--primary) 16%, transparent);
        z-index: 10002;
      }
      .sfp-tab-shell.open .sfp-tab-panel {
        display: block;
      }
      .sfp-tab-panel-header {
        display: flex;
        align-items: center;
        justify-content: space-between;
        gap: 8px;
        margin-bottom: 8px;
        font-size: 12px;
        color: var(--primary-medium);
        line-height: 1.3;
      }
      .sfp-tab-panel-title {
        display: inline-flex;
        align-items: center;
        gap: 6px;
        min-width: 0;
      }
      .sfp-tab-panel-title svg {
        width: 13px;
        height: 13px;
        fill: currentColor;
        flex-shrink: 0;
      }
      .sfp-tab-panel-close {
        display: inline-flex;
        align-items: center;
        justify-content: center;
        width: 24px;
        height: 24px;
        padding: 0;
        border: none;
        border-radius: 4px;
        background: transparent;
        color: var(--primary-medium);
        cursor: pointer;
      }
      .sfp-tab-panel-close:hover {
        background: var(--primary-very-low);
        color: var(--primary);
      }
      .sfp-tab-grid {
        display: grid;
        grid-template-columns: repeat(auto-fill, minmax(92px, 1fr));
        gap: 6px;
      }
      .sfp-tab-grid-item {
        display: inline-flex;
        align-items: center;
        justify-content: center;
        gap: 5px;
        min-width: 0;
        min-height: 32px;
        padding: 6px 8px;
        border: 1px solid transparent;
        border-radius: 6px;
        background: var(--primary-very-low);
        color: var(--primary-medium);
        cursor: pointer;
        font-size: 12px;
        line-height: 1.2;
        text-align: center;
        user-select: none;
        transition: background 0.15s, border-color 0.15s, color 0.15s, opacity 0.15s;
      }
      .sfp-tab-grid-item svg {
        width: 12px;
        height: 12px;
        fill: currentColor;
        flex: 0 0 auto;
      }
      .sfp-tab-grid-item span {
        min-width: 0;
        overflow: hidden;
        text-overflow: ellipsis;
        white-space: nowrap;
      }
      .sfp-tab-grid-item:hover {
        background: var(--primary-low);
        color: var(--primary);
      }
      .sfp-tab-grid-item.active {
        background: var(--tertiary);
        border-color: var(--tertiary);
        color: var(--secondary);
      }
      .sfp-tab-grid-item.dragging {
        opacity: 0.45;
      }
      .sfp-tab-grid-item.drop-target {
        border-color: var(--tertiary);
        box-shadow: inset 0 0 0 1px var(--tertiary);
      }

      /* ===== 筛选栏 ===== */
      .sfp-filter-bar {
        position: relative;
        z-index: 3;
        display: flex;
        align-items: center;
        gap: 12px;
        width: 100%;
        min-width: 0;
        max-width: 100%;
        padding: 8px 16px;
        margin: 0;
        background: var(--primary-very-low);
        border-bottom: 1px solid var(--primary-low);
        font-size: 12px;
        color: var(--primary-medium);
        flex-shrink: 0;
      }
      .sfp-filter-item {
        cursor: pointer;
        padding: 2px 6px;
        border-radius: 4px;
        transition: all 0.2s;
        user-select: none;
      }
      .sfp-filter-item:hover {
        color: var(--tertiary);
        background: var(--primary-low);
      }
      .sfp-filter-item.active {
        color: var(--secondary);
        background: var(--tertiary);
      }

      /* ===== Feed 滚动区 ===== */
      .sfp-feed-scroll {
        position: relative;
        flex: 1;
        min-width: 0;
        max-width: 100%;
        overflow-y: auto;
        overflow-x: hidden;
        -webkit-overflow-scrolling: touch;
        --scrollbarBg: transparent;
        --scrollbarThumbBg: var(--d-selected, var(--token-color-surface-hovered));
        --scrollbarWidth: var(--space-2, 0.5em);
        scrollbar-color: transparent var(--scrollbarBg);
        transition: scrollbar-color 0.25s ease-in-out;
        transition-delay: 0.5s;
      }
      .sfp-feed-scroll::-webkit-scrollbar {
        width: var(--scrollbarWidth);
      }
      .sfp-feed-scroll::-webkit-scrollbar-thumb {
        background-color: transparent;
        border-radius: calc(var(--scrollbarWidth) / 2);
      }
      .sfp-feed-scroll::-webkit-scrollbar-track {
        background-color: transparent;
      }
      .sfp-feed-scroll:hover {
        scrollbar-color: var(--scrollbarThumbBg) var(--scrollbarBg);
        transition-delay: 0s;
      }
      .sfp-feed-scroll:hover::-webkit-scrollbar-thumb {
        background-color: var(--scrollbarThumbBg);
      }
      .sfp-content-wrapper {
        position: relative;
        min-width: 0;
        max-width: 100%;
        min-height: 100%;
      }
      .sfp-back-top-btn {
        position: absolute;
        right: 14px;
        bottom: 14px;
        z-index: 4;
        display: inline-flex;
        align-items: center;
        justify-content: center;
        width: 36px;
        height: 36px;
        padding: 0;
        border: 1px solid var(--primary-low);
        border-radius: 50%;
        background: var(--secondary);
        color: var(--primary-medium);
        box-shadow: 0 4px 14px color-mix(in srgb, var(--primary) 14%, transparent);
        cursor: pointer;
        opacity: 0;
        visibility: hidden;
        transform: translateY(8px);
        pointer-events: none;
        transition: opacity 0.18s ease, transform 0.18s ease, visibility 0.18s, color 0.18s, background 0.18s;
      }
      .sfp-back-top-btn.visible {
        opacity: 1;
        visibility: visible;
        transform: translateY(0);
        pointer-events: auto;
      }
      .sfp-back-top-btn:hover {
        background: var(--primary-very-low);
        color: var(--tertiary);
      }
      .sfp-back-top-btn svg {
        width: 18px;
        height: 18px;
        fill: currentColor;
      }

      /* ===== 帖子列表项 ===== */
      .sfp-topic-item {
        padding: 12px 20px;
        border-bottom: 1px solid var(--primary-very-low);
        cursor: pointer;
        transition: background 0.2s;
        position: relative;
        min-width: 0;
        max-width: 100%;
        overflow-wrap: break-word;
        word-break: break-word;
      }
      .sfp-topic-item:hover {
        background: var(--primary-very-low);
      }
      .sfp-topic-item.sfp-filter-mismatch {
        opacity: 0.48;
        filter: grayscale(0.85);
      }
      .sfp-topic-item.sfp-topic-unavailable .sfp-topic-title-line {
        text-decoration: line-through;
      }
      .sfp-topic-item.sfp-new-highlight {
        --sfp-new-highlight-color: var(
          --tertiary-med-or-tertiary,
          var(--tertiary)
        );
        animation: sfp-new-pulse 10s ease-out forwards;
        position: relative;
      }
      @keyframes sfp-new-pulse {
        0% {
          box-shadow: inset 0 0 0 2px var(--sfp-new-highlight-color);
          background: color-mix(
            in srgb,
            var(--sfp-new-highlight-color) 15%,
            transparent
          );
        }
        100% {
          box-shadow: inset 0 0 0 0px transparent;
          background: transparent;
        }
      }

      /* 未读圆点 — 紧跟在时间后 */
      .sfp-topic-item .sfp-topic-time {
        font-size: 12px;
        color: var(--primary-medium);
        white-space: nowrap;
        margin-left: auto;
        flex-shrink: 0;
        display: inline-flex;
        align-items: center;
        gap: 4px;
        line-height: 1;
      }
      .sfp-topic-item .sfp-unread-dot {
        width: 8px;
        height: 8px;
        flex: 0 0 8px;
        display: inline-block;
        border-radius: 50%;
        color: var(--tertiary-med-or-tertiary, var(--tertiary));
        background: currentColor;
        opacity: 0.75;
        vertical-align: middle;
      }

      /* 头像 + 用户信息行 */
      .sfp-topic-item .sfp-topic-header {
        display: flex;
        align-items: center;
        gap: 8px;
        margin-bottom: 5px;
      }
      .sfp-topic-item .sfp-topic-avatar {
        width: 28px;
        height: 28px;
        border-radius: 50%;
        flex-shrink: 0;
        object-fit: cover;
      }
      .sfp-topic-item .sfp-topic-meta-col {
        display: flex;
        flex-direction: column;
        min-width: 0;
        flex: 1;
      }
      .sfp-topic-item .sfp-topic-user-info {
        display: flex;
        align-items: center;
        gap: 5px;
        flex-wrap: wrap;
        overflow: hidden;
      }
      .sfp-topic-item .sfp-topic-username {
        font-size: 13px;
        color: var(--primary);
        font-weight: 500;
        cursor: pointer;
        transition: color 0.2s;
        white-space: nowrap;
        overflow: hidden;
        text-overflow: ellipsis;
      }
      .sfp-topic-item .sfp-topic-username:hover {
        color: var(--tertiary);
      }
      .sfp-topic-item .sfp-topic-name {
        font-size: 12px;
        color: var(--primary-medium);
        white-space: nowrap;
        overflow: hidden;
        text-overflow: ellipsis;
      }
      /* 标题 */
      .sfp-topic-item .sfp-topic-title {
        font-size: 14px;
        font-weight: bold;
        color: var(--primary);
        line-height: 1.4;
        margin: 0;
        word-break: break-word;
        overflow: hidden;
        text-overflow: ellipsis;
        display: -webkit-box;
        -webkit-line-clamp: 2;
        -webkit-box-orient: vertical;
        transition: color 0.2s;
      }
      .sfp-topic-item .sfp-topic-title:hover {
        color: var(--tertiary);
      }
      .sfp-topic-item.sfp-read .sfp-topic-title {
        color: var(--title-color--read, var(--primary-medium));
      }
      .sfp-topic-item .sfp-topic-title-line {
        display: inline;
      }
      .sfp-topic-item .sfp-topic-status-badges {
        display: inline-flex;
        align-items: center;
        gap: 4px;
        margin-left: 2px;
        flex-shrink: 0;
      }
      .sfp-topic-item .topic-status-card {
        --badge-accent: var(--primary-medium);
        --badge-bg: var(--primary-very-low);
        --badge-border: var(--primary-low);
        display: inline-flex;
        align-items: center;
        gap: 3px;
        padding: 1px 6px;
        border: 1px solid var(--badge-border);
        border-radius: var(--d-border-radius, 8px);
        background: var(--badge-bg);
        color: var(--badge-accent);
        font-size: 11px;
        font-weight: 700;
        line-height: 1.45;
        margin-right: 5px;
        vertical-align: middle;
        white-space: nowrap;
        box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--badge-accent) 8%, transparent);
      }
      .sfp-topic-item .topic-status-card.--hot {
        --badge-accent: var(--danger);
        --badge-bg: var(--danger-low, var(--d-hover, var(--tertiary-low)));
        --badge-border: color-mix(in srgb, var(--danger) 28%, transparent);
      }
      .sfp-topic-item .topic-status-card.--pinned {
        --badge-accent: var(--primary-medium);
        --badge-bg: var(--primary-very-low);
        --badge-border: var(--primary-low);
      }
      .sfp-topic-item .topic-status-card__name {
        color: var(--badge-accent);
        font-size: inherit;
        font-weight: inherit;
        line-height: inherit;
        margin: 0;
      }
      .sfp-topic-item .topic-status-card .d-icon {
        color: var(--badge-accent);
        width: 0.92em;
        height: 0.92em;
        flex-shrink: 0;
      }
      .sfp-topic-item .topic-statuses {
        float: left;
      }
      .sfp-topic-item .topic-statuses .topic-status {
        display: inline-flex;
        align-items: center;
        color: var(--primary-medium);
        margin: 0 0.18em 0 0;
        --icon-size: 0.86em;
      }
      .sfp-topic-item .topic-statuses .topic-status .d-icon {
        width: var(--icon-size);
        height: var(--icon-size);
        color: currentColor;
      }

      /* 分类 + 标签行 */
      .sfp-topic-item .sfp-topic-category-tags {
        display: flex;
        flex-wrap: wrap;
        align-items: center;
        gap: 4px;
        margin-top: 5px;
      }
      .sfp-topic-item .sfp-category-badge {
        --badge-category-bg: light-dark(
          oklch(from var(--category-badge-color) 97% calc(c * 0.3) h),
          oklch(from var(--category-badge-color) 45% calc(c * 0.5) h)
        );
        --badge-category-text: light-dark(
          oklch(from var(--category-badge-color) 35% calc(c * 0.6) h),
          oklch(from var(--category-badge-color) 95% calc(c * 0.2) h)
        );
        display: inline-flex;
        align-items: center;
        gap: 0.33em;
        font-size: 11px;
        padding: 2px 6px;
        border-radius: var(--d-border-radius, 4px);
        background-color: var(--badge-category-bg, var(--primary-very-low));
        color: var(--badge-category-text, var(--primary-medium));
        flex-shrink: 0;
        max-width: 120px;
        overflow: hidden;
        text-overflow: ellipsis;
        white-space: nowrap;
      }
      .sfp-topic-item .sfp-category-badge .badge-category {
        min-width: 0;
        align-items: center;
      }
      .sfp-topic-item .sfp-category-badge .badge-category__name {
        min-width: 0;
        overflow: hidden;
        text-overflow: ellipsis;
        color: var(--badge-category-text, var(--primary-medium));
      }
      .sfp-topic-item .sfp-category-badge .d-icon {
        width: 0.9em;
        height: 0.9em;
        flex-shrink: 0;
      }
      @supports not (color: light-dark(tan, tan)) {
        .sfp-topic-item .sfp-category-badge {
          --badge-category-bg: color-mix(in srgb, var(--category-badge-color) 16%, transparent);
          --badge-category-text: var(--primary-high);
        }
      }

      /* 标签 */
      .sfp-topic-item .sfp-topic-tags {
        display: flex;
        gap: 3px;
        flex-wrap: wrap;
      }
      .sfp-topic-item .sfp-tag {
        font-size: 11px;
        padding: 2px 6px;
        border-radius: var(--d-border-radius, 4px);
        line-height: 1.4;
        max-width: 96px;
        overflow: hidden;
        text-overflow: ellipsis;
        white-space: nowrap;
        flex-shrink: 1;
      }
      .sfp-topic-item .sfp-tag .tag-icon .d-icon {
        width: 0.9em;
        height: 0.9em;
      }

      /* 统计行 */
      .sfp-topic-item .sfp-topic-stats {
        display: flex;
        gap: 12px;
        margin-top: 8px;
        font-size: 12px;
        color: var(--primary-medium);
      }
      .sfp-topic-item .sfp-topic-stat {
        display: flex;
        align-items: center;
        gap: 4px;
      }
      .sfp-topic-item .sfp-topic-stat .d-icon {
        width: 1em;
        height: 1em;
      }

      /* ===== 加载状态 ===== */
      .sfp-loading {
        display: flex;
        flex-direction: column;
        align-items: center;
        justify-content: center;
        padding: 40px 20px;
        color: var(--primary-medium);
        font-size: 13px;
        gap: 12px;
      }
      .sfp-spinner {
        width: 28px;
        height: 28px;
        border: 3px solid var(--primary-low);
        border-top-color: var(--tertiary);
        border-radius: 50%;
        animation: sfp-spin 0.8s linear infinite;
      }
      .sfp-empty {
        text-align: center;
        padding: 40px 10px;
        color: var(--primary-medium);
        font-size: 13px;
      }
      .sfp-load-more {
        padding: 14px 10px;
        text-align: center;
        font-size: 12px;
        color: var(--primary-medium);
        cursor: pointer;
        transition: color 0.2s;
      }
      .sfp-load-more-error {
        display: flex;
        justify-content: center;
        align-items: center;
        gap: 8px;
        cursor: default;
      }
      .sfp-load-more-error .sfp-load-more-retry {
        padding: 4px 10px;
        border: none;
        border-radius: 4px;
        background: var(--tertiary);
        color: var(--secondary);
        cursor: pointer;
        font-size: 12px;
      }
      .sfp-load-more:hover {
        color: var(--tertiary);
      }
      .sfp-load-more .sfp-load-more-spinner {
        display: inline-block;
        width: 14px;
        height: 14px;
        border: 2px solid var(--primary-low);
        border-top-color: var(--tertiary);
        border-radius: 50%;
        animation: sfp-spin 0.8s linear infinite;
        vertical-align: middle;
        margin-right: 6px;
      }
      .sfp-no-more {
        padding: 14px 10px;
        text-align: center;
        font-size: 11px;
        color: var(--primary-low-mid);
      }
      .sfp-load-more-note {
        padding: 10px 10px 0;
        text-align: center;
        font-size: 12px;
        color: var(--primary-medium);
      }
      .sfp-error {
        padding: 40px 20px;
        text-align: center;
        color: var(--danger);
        display: flex;
        flex-direction: column;
        align-items: center;
        gap: 10px;
      }
      .sfp-error-icon {
        font-size: 32px;
      }
      .sfp-error-msg {
        font-size: 14px;
        font-weight: 600;
      }
      .sfp-error-detail {
        font-size: 12px;
        color: var(--primary-medium);
        word-break: break-word;
      }
      .sfp-error .sfp-retry-btn {
        margin-top: 6px;
        padding: 6px 16px;
        background: var(--tertiary);
        color: var(--secondary);
        border: none;
        border-radius: 4px;
        cursor: pointer;
        font-size: 12px;
        transition: opacity 0.2s;
      }
      .sfp-error .sfp-retry-btn:hover {
        opacity: 0.85;
      }
    `);
  }

  // ========== 切换开关 ==========
  function createToggle() {
    if (toggleBtn) return toggleBtn;

    const homeLogo = document.querySelector(".home-logo-wrapper-outlet");
    if (!homeLogo) return null;

    toggleBtn = document.createElement("button");
    toggleBtn.className = "sfp-toggle-btn" + (feedModeEnabled ? " active" : "");
    toggleBtn.title = "切换侧边栏信息流";
    toggleBtn.innerHTML = `<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><rect x="3" y="3" width="7" height="18" rx="1" fill="currentColor" opacity="0.6"/><rect x="13" y="3" width="8" height="18" rx="1" fill="currentColor"/></svg>`;

    toggleBtn.addEventListener("click", (e) => {
      e.stopPropagation();
      e.preventDefault();
      feedModeEnabled = !feedModeEnabled;
      GM_setValue(STATE_KEY, feedModeEnabled);
      toggleBtn.classList.toggle("active", feedModeEnabled);
      if (feedModeEnabled) {
        activateFeed();
      } else {
        deactivateFeed();
      }
    });

    // 放入 .title 内部,logo 右边
    const titleEl = homeLogo.querySelector(".title");
    if (titleEl) {
      titleEl.appendChild(toggleBtn);
    } else {
      homeLogo.appendChild(toggleBtn);
    }
    return toggleBtn;
  }

  // ========== 侧边栏宽度控制 ==========
  function getMinSidebarWidth() {
    return MIN_WIDTH;
  }

  function getSidebarElement() {
    return document.querySelector("#d-sidebar") || document.querySelector(".sidebar-container");
  }

  function applySidebarWidth(width) {
    const clampedWidth = Math.min(MAX_WIDTH, Math.max(getMinSidebarWidth(), width));
    sfpSidebarWidth = clampedWidth;
    const sidebar = getSidebarElement();
    if (sidebar) {
      sidebar.style.setProperty("width", clampedWidth + "px", "important");
    }
    document.documentElement.style.setProperty("--d-sidebar-width", clampedWidth + "px");
  }

  function getSidebarWidthTransitionElements(sidebar) {
    const wrapper = sidebar?.classList?.contains("sidebar-wrapper")
      ? sidebar
      : sidebar?.closest?.(".sidebar-wrapper");
    return [sidebar, wrapper].filter(Boolean);
  }

  function setSidebarWidthForAnimation(sidebar, width, { enforceMin = true } = {}) {
    const minWidth = enforceMin ? getMinSidebarWidth() : 0;
    const clampedWidth = Math.min(MAX_WIDTH, Math.max(minWidth, width));
    sidebar.style.setProperty("width", clampedWidth + "px", "important");
    document.documentElement.style.setProperty("--d-sidebar-width", clampedWidth + "px");
  }

  function animateSidebarWidth(targetWidth, { cleanupAfter = false, enforceMin = true } = {}) {
    const sidebar = getSidebarElement();
    if (!sidebar) return;

    if (widthAnimationTimer) {
      window.clearTimeout(widthAnimationTimer);
      widthAnimationTimer = null;
    }

    const startWidth = sidebar.getBoundingClientRect().width || DEFAULT_WIDTH;
    const minWidth = enforceMin ? getMinSidebarWidth() : 0;
    const clampedTarget = Math.min(MAX_WIDTH, Math.max(minWidth, targetWidth));
    const transitionEls = getSidebarWidthTransitionElements(sidebar);

    setSidebarWidthForAnimation(sidebar, startWidth, { enforceMin: false });
    transitionEls.forEach((el) => el.classList.add("sfp-width-animating"));

    window.requestAnimationFrame(() => {
      setSidebarWidthForAnimation(sidebar, clampedTarget, { enforceMin });

      widthAnimationTimer = window.setTimeout(() => {
        widthAnimationTimer = null;
        transitionEls.forEach((el) => el.classList.remove("sfp-width-animating"));
        if (cleanupAfter) {
          restoreSidebarWidth();
        }
      }, 260);
    });
  }

  function restoreSidebarWidth() {
    const sidebar = getSidebarElement();
    if (sidebar) {
      sidebar.style.removeProperty("width");
    }
    document.documentElement.style.removeProperty("--d-sidebar-width");
  }

  function setupResizer() {
    const sidebar = getSidebarElement();
    if (!sidebar) return;

    if (resizerEl && !sidebar.contains(resizerEl)) {
      resizerEl.remove();
      resizerEl = null;
    }

    if (resizerEl) return;

    resizerEl = sidebar.querySelector(":scope > .sfp-resizer") || document.createElement("div");
    resizerEl.className = "sfp-resizer";
    if (!resizerEl.parentElement) {
      sidebar.appendChild(resizerEl);
    }

    resizerEl.addEventListener("mousedown", (e) => {
      e.preventDefault();
      e.stopPropagation();
      isResizing = true;
      const startX = e.clientX;
      const startWidth = sidebar.offsetWidth;
      resizerEl.classList.add("sfp-resizing");
      getSidebarWidthTransitionElements(sidebar).forEach((el) => el.classList.remove("sfp-width-animating"));
      document.body.style.cursor = "ew-resize";
      document.body.style.userSelect = "none";

      const onMouseMove = (e) => {
        if (!isResizing) return;
        const delta = e.clientX - startX;
        const newWidth = Math.min(MAX_WIDTH, Math.max(getMinSidebarWidth(), startWidth + delta));
        applySidebarWidth(newWidth);
      };

      const onMouseUp = () => {
        isResizing = false;
        resizerEl.classList.remove("sfp-resizing");
        document.body.style.cursor = "";
        document.body.style.userSelect = "";
        GM_setValue(WIDTH_KEY, sfpSidebarWidth);
        document.removeEventListener("mousemove", onMouseMove);
        document.removeEventListener("mouseup", onMouseUp);
      };

      document.addEventListener("mousemove", onMouseMove);
      document.addEventListener("mouseup", onMouseUp);
    });
  }

  function removeResizer() {
    if (resizerEl) {
      resizerEl.remove();
      resizerEl = null;
    }

    const sidebar = getSidebarElement();
    sidebar?.querySelectorAll(":scope > .sfp-resizer").forEach((el) => el.remove());
  }

  // ========== 激活 / 停用 ==========
  function activateFeed() {
    const sidebar = getSidebarElement();
    if (!sidebar) return;

    if (!sidebar.classList.contains("sfp-feed-mode") || originalSidebarWidthBeforeFeed === null) {
      originalSidebarWidthBeforeFeed = sidebar.getBoundingClientRect().width || DEFAULT_WIDTH;
    }

    if (feedContainer && sidebar.contains(feedContainer)) {
      sidebar.classList.add("sfp-feed-mode");
      animateSidebarWidth(sfpSidebarWidth);
      setupResizer();
      _syncDefaultViewControls();
      _updateShowMoreHint();
      _updateBackTopButton();
      return;
    }

    if (feedContainer) {
      _resetRefreshButtonBusy();
      if (feedScrollAbortController) {
        feedScrollAbortController.abort();
        feedScrollAbortController = null;
      }
      feedContainer.remove();
      feedContainer = null;
      feedHeaderEl = null;
      feedRefreshBtn = null;
      feedScrollEl = null;
      feedListEl = null;
      feedBackTopBtn = null;
    }

    // 创建 feed 容器
    feedContainer = document.createElement("div");
    feedContainer.className = "sfp-feed-container";

    feedHeaderEl = document.createElement("div");
    feedHeaderEl.className = "sfp-feed-header";
    _buildHeaderControls(feedHeaderEl);

    // 分类标签栏
    const tabBar = _buildTabBar();
    feedContainer.appendChild(feedHeaderEl);
    feedContainer.appendChild(tabBar);

    // 筛选栏
    const filterBar = _buildFilterBar();
    feedContainer.appendChild(filterBar);

    feedScrollEl = document.createElement("div");
    feedScrollEl.className = "sfp-feed-scroll";

    // 创建内容包装器,用于相对定位
    const contentWrapper = document.createElement("div");
    contentWrapper.className = "sfp-content-wrapper";

    feedListEl = document.createElement("div");
    feedListEl.className = "sfp-topic-list";

    contentWrapper.appendChild(feedListEl);
    feedScrollEl.appendChild(contentWrapper);
    feedContainer.appendChild(feedScrollEl);
    feedBackTopBtn = _buildBackTopButton();
    feedContainer.appendChild(feedBackTopBtn);
    sidebar.appendChild(feedContainer);

    sidebar.classList.add("sfp-feed-mode");
    animateSidebarWidth(sfpSidebarWidth);
    setupResizer();

    // 恢复当前 tab 筛选的分类
    _restoreTabState();
    _startSidebarIncomingTracking();
    _syncDefaultViewControls();

    // 始终全量加载,数据已在 deactivateFeed 中清除
    loadTopics();

    // 无限滚动
    _setupScrollLoadMore();
  }

  function deactivateFeed() {
    const sidebar = getSidebarElement();
    if (!sidebar) return;

    activeLoadToken++;
    activeLoadMoreToken++;
    activeRefreshToken++;
    isLoading = false;
    isLoadingMore = false;
    isRefreshing = false;
    _pendingReload = false;
    sidebarIncomingState.applyQueued = false;
    if (feedScrollAbortController) {
      feedScrollAbortController.abort();
      feedScrollAbortController = null;
    }

    _stopAutoRefresh();
    _stopAutoSilentRefresh();
    _stopSidebarIncomingTracking();
    _resetRefreshButtonBusy();

    if (feedContainer) {
      feedContainer.remove();
      feedContainer = null;
      feedHeaderEl = null;
      feedRefreshBtn = null;
      feedScrollEl = null;
      feedListEl = null;
      feedBackTopBtn = null;
    }

    // 清除数据缓存,避免下次激活时显示旧数据
    allTopics = [];
    usersMap = {};
    loadedTopicIds.clear();
    currentPage = 0;
    hasMorePages = true;
    _resetAutoLoadState();

    sidebar.classList.remove("sfp-feed-mode");
    removeResizer();
    animateSidebarWidth(originalSidebarWidthBeforeFeed || DEFAULT_WIDTH, { cleanupAfter: true, enforceMin: false });
    originalSidebarWidthBeforeFeed = null;
  }

  // ========== Header 控件 ==========
  function _buildHeaderControls(header) {
    // Order 自定义下拉
    const orderOptions = [
      { label: "最新活动", value: "activity" },
      { label: "最新发布", value: "created" },
      { label: "最多浏览", value: "views" },
      { label: "最多回复", value: "posts" },
      { label: "最多点赞", value: "likes" },
      { label: "楼主点赞", value: "op_likes" },
    ];

    const periodOptions = [
      { label: "全部", value: "all" },
      { label: "每日", value: "daily" },
      { label: "每周", value: "weekly" },
      { label: "每月", value: "monthly" },
      { label: "每季", value: "quarterly" },
      { label: "每年", value: "yearly" },
    ];

    // Period 下拉(先创建,因为 order 切换时需要引用)
    const periodSelect = _buildCustomSelect(periodOptions, currentPeriod, (value) => {
      currentPeriod = value;
      GM_setValue(PERIOD_KEY, currentPeriod);
      _resetAutoLoadState();
      loadTopics();
    });
    periodSelect.classList.add("sfp-period-select");
    _updatePeriodVisibility(periodSelect);

    // Order 下拉
    const orderSelect = _buildCustomSelect(orderOptions, currentOrder, (value) => {
      currentOrder = value;
      GM_setValue(ORDER_KEY, currentOrder);
      _updatePeriodVisibility(periodSelect);
      _beginSidebarIncomingViewSettling();
      _syncDefaultViewControls();
      _resetAutoLoadState();
      loadTopics();
    });
    orderSelect.classList.add("sfp-order-select");

    function _updatePeriodVisibility(ps) {
      ps.style.display = _needsPeriodForUrl(currentOrder) ? "" : "none";
    }

    header.appendChild(orderSelect);
    header.appendChild(periodSelect);

    const spacer = document.createElement("span");
    spacer.className = "sfp-header-spacer";
    header.appendChild(spacer);

    header.appendChild(_buildSettingsControl());

    // 刷新按钮
    const refreshBtn = document.createElement("button");
    refreshBtn.className = "sfp-refresh-btn";
    refreshBtn.title = "刷新";
    refreshBtn.setAttribute("aria-label", "刷新");
    refreshBtn.innerHTML = `<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M17.65 6.35A7.958 7.958 0 0012 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08A5.99 5.99 0 0112 18c-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/></svg>`;
    refreshBtn.addEventListener("click", () => {
      refreshCurrentView();
    });
    feedRefreshBtn = refreshBtn;
    _syncRefreshButtonBusy();
    header.appendChild(refreshBtn);
  }

  function _buildSettingsControl() {
    const wrapper = document.createElement("span");
    wrapper.className = "sfp-settings-wrap";
    const isLatestActivityView = _isLatestActivityView();

    const shell = document.createElement("span");
    shell.className = "sfp-settings-shell";

    const btn = document.createElement("button");
    btn.className = "sfp-settings-btn";
    btn.type = "button";
    btn.title = "设置";
    btn.innerHTML = `
      <span class="sfp-settings-line sfp-settings-line-1"></span>
      <span class="sfp-settings-line sfp-settings-line-2"></span>
      <span class="sfp-settings-line sfp-settings-line-3"></span>
    `;

    const panel = document.createElement("div");
    panel.className = "sfp-settings-panel";

    // 设置项按当前排序视图分组,而不是一次性展示全部选项:
    // - 最新活动可以消费 message-bus 增量,因此提供“新活动提醒”和“静默刷新”;
    // - 浏览量/回复/点赞等排序没有同等可靠的增量通道,只提供普通自动刷新。
    // 这样可以减少用户误以为所有排序都能无请求地接收新话题。
    if (isLatestActivityView) {
      panel.innerHTML = `
        <div class="sfp-setting-row sfp-incoming-hint-row">
          ${_buildSettingLabelHtml("新活动提醒", "在最新活动的全部列表中,按站点推送的新话题显示顶部提醒;点击提醒才把新内容加入列表。开启后会关闭自动静默刷新。")}
          <input type="checkbox" class="sfp-incoming-hint-input"${showIncomingHint ? " checked" : ""}>
        </div>
        <div class="sfp-setting-row sfp-auto-silent-row">
          ${_buildSettingLabelHtml("自动静默刷新", "在最新活动视图中按间隔自动应用新话题,尽量保留当前阅读位置。仅在关闭新活动提醒后可用;它只处理已收到的新活动候选,无速率限制,可按需要设置。")}
          <input type="checkbox" class="sfp-auto-silent-input"${autoSilentRefreshEnabled ? " checked" : ""}>
        </div>
        <div class="sfp-setting-interval sfp-auto-silent-interval${_isAutoSilentRefreshActive() ? " visible" : ""}">
          ${_buildSettingLabelHtml("静默刷新间隔", "单位为秒,最小为 0。设为 0 时,有新活动会立即静默应用;大于 0 时按倒计时批量应用。")}
          <input type="number" class="sfp-auto-silent-refresh-interval-input" min="0" step="1" value="${autoSilentRefreshInterval}">
          <span>s</span>
        </div>
      `;
    } else {
      panel.innerHTML = `
        <div class="sfp-setting-row sfp-auto-refresh-row">
          ${_buildSettingLabelHtml("自动刷新", "用于非最新活动的排序视图,按间隔重新拉取当前列表,并尽量保留当前阅读位置。不要设置太快,频繁请求可能触发站点速率限制。")}
          <input type="checkbox" class="sfp-auto-refresh-input"${autoRefreshEnabled ? " checked" : ""}>
        </div>
        <div class="sfp-setting-interval sfp-auto-refresh-interval${autoRefreshEnabled ? " visible" : ""}">
          ${_buildSettingLabelHtml("自动刷新间隔", "单位为秒,最小为 1。到达间隔后刷新当前筛选和排序下的列表。")}
          <input type="number" class="sfp-auto-refresh-interval-input" min="1" step="1" value="${autoRefreshInterval}">
          <span>s</span>
        </div>
      `;
    }

    btn.addEventListener("click", (e) => {
      e.preventDefault();
      e.stopPropagation();
      const shouldOpen = !wrapper.classList.contains("open");
      _closeFloatingPanels(wrapper);
      wrapper.classList.toggle("open", shouldOpen);
      _syncSettingsPanelState(wrapper);
    });

    panel.addEventListener("click", (e) => e.stopPropagation());
    panel.querySelectorAll(".sfp-setting-help").forEach((helpBtn) => {
      const showTooltip = () => {
        if (!globalHelpTooltip) return;
        const text = helpBtn.getAttribute("data-tooltip");
        if (!text) return;
        globalHelpTooltip.textContent = text;
        globalHelpTooltip.style.display = "";
        globalHelpTooltip.style.visibility = "hidden";
        globalHelpTooltip.classList.add("visible");
        const btnRect = helpBtn.getBoundingClientRect();
        const gap = 6;
        const tooltipHeight = globalHelpTooltip.offsetHeight;
        const tooltipWidth = globalHelpTooltip.offsetWidth;
        let left = btnRect.left;
        let top = btnRect.bottom + gap;
        if (left + tooltipWidth > window.innerWidth - 8) {
          left = Math.max(8, btnRect.right - tooltipWidth);
        }
        if (top + tooltipHeight > window.innerHeight - 8) {
          top = btnRect.top - tooltipHeight - gap;
        }
        globalHelpTooltip.style.left = left + "px";
        globalHelpTooltip.style.top = top + "px";
        globalHelpTooltip.style.visibility = "";
        globalHelpTooltip.classList.remove("visible");
        requestAnimationFrame(() => {
          requestAnimationFrame(() => {
            globalHelpTooltip.classList.add("visible");
          });
        });
      };
      const hideTooltip = () => {
        if (globalHelpTooltip) {
          globalHelpTooltip.classList.remove("visible");
          globalHelpTooltip.style.display = "none";
        }
      };
      helpBtn.addEventListener("mouseenter", showTooltip);
      helpBtn.addEventListener("mouseleave", hideTooltip);
      helpBtn.addEventListener("click", (e) => {
        e.preventDefault();
        e.stopPropagation();
      });
    });

    const incomingHintInput = panel.querySelector(".sfp-incoming-hint-input");
    if (incomingHintInput) {
      _bindCheckboxSetting(incomingHintInput, (checked) => {
        showIncomingHint = checked;
        GM_setValue(SHOW_INCOMING_HINT_KEY, showIncomingHint);
        // 手动提醒和自动静默刷新是互斥体验:前者让用户决定何时插入新话题,
        // 后者由脚本自动合并。互斥可以避免同一批 incoming 同时出现在提醒和
        // 静默队列里,导致重复刷新或顶部提示残留。
        if (showIncomingHint) {
          _stopAutoSilentRefresh();
          sidebarIncomingState.applyQueued = false;
        } else {
          _startAutoSilentRefresh();
          if (_isAutoSilentRefreshActive() && autoSilentRefreshInterval === 0) {
            _queueSidebarIncomingApply();
          }
        }
        _syncSettingsPanelState(wrapper);
        _updateShowMoreHint();
      });
    }

    const autoSilentInput = panel.querySelector(".sfp-auto-silent-input");
    const silentIntervalInput = panel.querySelector(".sfp-auto-silent-refresh-interval-input");
    if (autoSilentInput) {
      _bindCheckboxSetting(autoSilentInput, (checked) => {
        autoSilentRefreshEnabled = checked;
        GM_setValue(AUTO_SILENT_REFRESH_KEY, autoSilentRefreshEnabled);
        // 开启静默刷新时主动关闭新活动提醒,保持上面的互斥关系。设置面板随后
        // 会重新计算可见行高度,避免隐藏间隔输入后留下空白。
        if (autoSilentRefreshEnabled && showIncomingHint) {
          showIncomingHint = false;
          GM_setValue(SHOW_INCOMING_HINT_KEY, false);
        }
        _syncSettingsPanelState(wrapper);
        _startAutoSilentRefresh();
        _updateShowMoreHint();
        if (_isAutoSilentRefreshActive() && autoSilentRefreshInterval === 0) {
          _queueSidebarIncomingApply();
        }
      });
    }

    _bindNumberSetting(silentIntervalInput, 0, DEFAULT_AUTO_SILENT_REFRESH_INTERVAL, (seconds) => {
      autoSilentRefreshInterval = seconds;
      GM_setValue(AUTO_SILENT_REFRESH_INTERVAL_KEY, autoSilentRefreshInterval);
      _startAutoSilentRefresh();
      if (_isAutoSilentRefreshActive() && autoSilentRefreshInterval === 0) {
        _queueSidebarIncomingApply();
      }
    });

    const autoRefreshInput = panel.querySelector(".sfp-auto-refresh-input");
    const intervalRow = panel.querySelector(".sfp-auto-refresh-interval");
    const intervalInput = panel.querySelector(".sfp-auto-refresh-interval-input");
    if (autoRefreshInput) {
      _bindCheckboxSetting(autoRefreshInput, (checked) => {
        autoRefreshEnabled = checked;
        GM_setValue(AUTO_REFRESH_ENABLED_KEY, autoRefreshEnabled);
        intervalRow.classList.toggle("visible", autoRefreshEnabled);
        _syncSettingsPanelHeight(wrapper);
        _startAutoRefresh();
      });
    }

    _bindNumberSetting(intervalInput, 1, DEFAULT_AUTO_REFRESH_INTERVAL, (seconds) => {
      autoRefreshInterval = seconds;
      GM_setValue(AUTO_REFRESH_INTERVAL_KEY, autoRefreshInterval);
      _startAutoRefresh();
    });

    shell.appendChild(btn);
    shell.appendChild(panel);
    wrapper.appendChild(shell);
    _syncSettingsPanelState(wrapper);
    return wrapper;
  }

  function _buildSettingLabelHtml(label, tooltip) {
    return `
      <span class="sfp-setting-label">
        <span>${escapeHtml(label)}</span>
        <span class="sfp-setting-help-wrap">
          <button type="button" class="sfp-setting-help" aria-label="${escapeHtml(label)}说明" data-tooltip="${escapeHtml(tooltip)}">
            <svg viewBox="0 0 1024 1024" aria-hidden="true" focusable="false" xmlns="http://www.w3.org/2000/svg"><path d="M514.048 54.272q95.232 0 178.688 36.352t145.92 98.304 98.304 145.408 35.84 178.688-35.84 178.176-98.304 145.408-145.92 98.304-178.688 35.84-178.176-35.84-145.408-98.304-98.304-145.408-35.84-178.176 35.84-178.688 98.304-145.408 145.408-98.304 178.176-36.352zM515.072 826.368q26.624 0 44.544-17.92t17.92-43.52q0-26.624-17.92-44.544t-44.544-17.92-44.544 17.92-17.92 44.544q0 25.6 17.92 43.52t44.544 17.92zM567.296 574.464q-1.024-16.384 20.48-34.816t48.128-40.96 49.152-50.688 24.576-65.024q2.048-39.936-8.192-74.752t-33.792-59.904-60.928-39.936-87.552-14.848q-62.464 0-103.936 22.016t-67.072 53.248-35.84 64.512-9.216 55.808q1.024 26.624 16.896 38.912t34.304 12.8 33.792-10.24 15.36-31.232q0-12.288 7.68-30.208t20.992-34.304 32.256-27.648 42.496-11.264q46.08 0 73.728 23.04t25.6 57.856q0 17.408-10.24 32.256t-26.112 28.672-33.792 27.648-33.792 28.672-26.624 32.256-11.776 37.888l1.024 38.912q0 15.36 14.336 29.184t37.888 14.848q23.552-1.024 37.376-15.36t12.8-32.768l0-24.576z"></path></svg>
          </button>
        </span>
      </span>
    `;
  }

  function _syncSettingsPanelState(wrapper) {
    const panel = wrapper?.querySelector(".sfp-settings-panel");
    if (!panel) return;

    const incomingHintInput = panel.querySelector(".sfp-incoming-hint-input");
    const autoSilentInput = panel.querySelector(".sfp-auto-silent-input");
    const autoSilentRow = panel.querySelector(".sfp-auto-silent-row");
    const autoSilentIntervalRow = panel.querySelector(".sfp-auto-silent-interval");
    const autoRefreshInput = panel.querySelector(".sfp-auto-refresh-input");
    const autoRefreshIntervalRow = panel.querySelector(".sfp-auto-refresh-interval");

    if (incomingHintInput) incomingHintInput.checked = showIncomingHint;
    if (autoSilentInput) autoSilentInput.checked = autoSilentRefreshEnabled;
    if (autoRefreshInput) autoRefreshInput.checked = autoRefreshEnabled;

    // “自动静默刷新”只有在关闭“新活动提醒”后才显示;这不是权限限制,
    // 而是为了让用户明确选择“手动应用”或“自动应用”其中一种 incoming 处理方式。
    if (autoSilentRow) {
      autoSilentRow.classList.toggle("hidden", showIncomingHint);
    }
    if (autoSilentIntervalRow) {
      autoSilentIntervalRow.classList.toggle("hidden", showIncomingHint);
      autoSilentIntervalRow.classList.toggle("visible", !showIncomingHint && autoSilentRefreshEnabled);
    }
    if (autoRefreshIntervalRow) {
      autoRefreshIntervalRow.classList.toggle("visible", autoRefreshEnabled);
    }

    _syncSettingsPanelHeight(wrapper);
  }

  function _syncSettingsPanelHeight(wrapper) {
    const shell = wrapper?.querySelector(".sfp-settings-shell");
    const panel = wrapper?.querySelector(".sfp-settings-panel");
    if (!shell || !panel) return;

    // 设置面板是绝对定位浮层,但外层 shell 需要参与 header 布局。
    // 每次显示/隐藏行后重新测量实际可见内容高度,避免动画期间按钮区域被截断。
    requestAnimationFrame(() => {
      const visibleRows = Array.from(panel.children).filter((child) => {
        return child instanceof HTMLElement && getComputedStyle(child).display !== "none";
      });
      const contentBottom = visibleRows.reduce((bottom, row) => {
        return Math.max(bottom, row.offsetTop + row.offsetHeight);
      }, SETTINGS_BUTTON_SIZE);
      const panelStyle = getComputedStyle(panel);
      const paddingBottom = Number.parseFloat(panelStyle.paddingBottom) || 0;
      const height = Math.max(SETTINGS_BUTTON_SIZE, Math.ceil(contentBottom + paddingBottom));
      shell.style.setProperty("--sfp-settings-shell-height", `${height}px`);
    });
  }

  function _bindCheckboxSetting(input, onChange) {
    if (!input) return;
    input.addEventListener("change", () => onChange(input.checked));
  }

  function _bindNumberSetting(input, min, fallback, onChange) {
    if (!input) return;
    input.addEventListener("change", () => {
      const nextValue = Math.max(min, Number(input.value) || fallback);
      input.value = nextValue;
      onChange(nextValue);
    });
  }

  function _beginRefreshButtonBusy() {
    refreshBusyCount++;
    _syncRefreshButtonBusy();

    let ended = false;
    // Call the returned function exactly once when the async refresh path settles.
    return () => {
      if (ended) return;
      ended = true;
      refreshBusyCount = Math.max(0, refreshBusyCount - 1);
      _syncRefreshButtonBusy();
    };
  }

  function _syncRefreshButtonBusy() {
    if (!feedRefreshBtn) return;
    const isBusy = refreshBusyCount > 0;
    feedRefreshBtn.classList.toggle("spinning", isBusy);
    feedRefreshBtn.setAttribute("aria-busy", isBusy ? "true" : "false");
  }

  function _resetRefreshButtonBusy() {
    refreshBusyCount = 0;
    _syncRefreshButtonBusy();
  }

  // ========== 自定义下拉组件 ==========
  function _buildCustomSelect(options, selectedValue, onChange) {
    const wrapper = document.createElement("span");
    wrapper.className = "sfp-custom-select";

    const btn = document.createElement("button");
    btn.className = "sfp-custom-select-btn";
    btn.type = "button";
    const selected = options.find((o) => o.value === selectedValue) || options[0];
    btn.textContent = selected.label;

    const dropdown = document.createElement("div");
    dropdown.className = "sfp-custom-select-dropdown";

    let _currentSelected = selectedValue;

    options.forEach((opt) => {
      const item = document.createElement("button");
      item.className = "sfp-custom-select-option" + (opt.value === selectedValue ? " selected" : "");
      item.type = "button";
      item.textContent = opt.label;
      item.addEventListener("click", (e) => {
        e.stopPropagation();
        if (opt.value === _currentSelected) {
          wrapper.classList.remove("open");
          return;
        }
        btn.textContent = opt.label;
        dropdown.querySelectorAll(".sfp-custom-select-option").forEach((el) => el.classList.remove("selected"));
        item.classList.add("selected");
        wrapper.classList.remove("open");
        _currentSelected = opt.value;
        onChange(opt.value);
      });
      dropdown.appendChild(item);
    });

    btn.addEventListener("click", (e) => {
      e.stopPropagation();
      const shouldOpen = !wrapper.classList.contains("open");
      _closeFloatingPanels(wrapper);
      wrapper.classList.toggle("open", shouldOpen);
      const isOpen = shouldOpen;
      if (isOpen) {
        const btnRect = btn.getBoundingClientRect();
        dropdown.style.top = (btnRect.bottom + 4) + "px";
        dropdown.style.left = btnRect.left + "px";
        dropdown.style.minWidth = btnRect.width + "px";
      }
    });

    wrapper.appendChild(btn);
    wrapper.appendChild(dropdown);
    return wrapper;
  }

  // 点击页面其他地方关闭下拉
  document.addEventListener("click", () => {
    _closeFloatingPanels();
  });

  // ========== 分类标签栏 ==========
  function _buildTabBar() {
    const shell = document.createElement("div");
    shell.className = "sfp-tab-shell";

    const bar = document.createElement("div");
    bar.className = "sfp-tab-bar";

    const moreBtn = document.createElement("button");
    moreBtn.type = "button";
    moreBtn.className = "sfp-tab-more-btn";
    moreBtn.title = "展开板块 / 排序";
    moreBtn.setAttribute("aria-label", "展开板块 / 排序");
    moreBtn.innerHTML = `<svg viewBox="0 0 24 24" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"><path d="M7 10a2 2 0 1 1 .01 0H7zm5 0a2 2 0 1 1 .01 0H12zm5 0a2 2 0 1 1 .01 0H17zM7 16a2 2 0 1 1 .01 0H7zm5 0a2 2 0 1 1 .01 0H12zm5 0a2 2 0 1 1 .01 0H17z"/></svg>`;

    const panel = document.createElement("div");
    panel.className = "sfp-tab-panel";
    panel.innerHTML = `
      <div class="sfp-tab-panel-header">
        <span class="sfp-tab-panel-title">
          <svg viewBox="0 0 24 24" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"><path d="M10 4h10v2H10V4zM4 3.5h4v3H4v-3zM10 11h10v2H10v-2zM4 10.5h4v3H4v-3zM10 18h10v2H10v-2zM4 17.5h4v3H4v-3z"/></svg>
          <span>拖动板块调整顺序</span>
        </span>
        <button type="button" class="sfp-tab-panel-close" title="关闭" aria-label="关闭">
          <svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"><path fill="currentColor" d="m6.4 5 5.6 5.6L17.6 5 19 6.4 13.4 12l5.6 5.6-1.4 1.4-5.6-5.6L6.4 19 5 17.6l5.6-5.6L5 6.4 6.4 5z"/></svg>
        </button>
      </div>
      <div class="sfp-tab-grid"></div>
    `;
    const grid = panel.querySelector(".sfp-tab-grid");

    // "全部" 标签
    const allTab = document.createElement("span");
    allTab.className = "sfp-tab-item" + (currentTab === "all" ? " active" : "");
    allTab.dataset.tab = "all";
    allTab.innerHTML = `<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M4 8h4V4H4v4zm6 12h4v-4h-4v4zm-6 0h4v-4H4v4zm0-6h4v-4H4v4zm6 0h4v-4h-4v4zm6-10v4h4V4h-4zm-6 4h4V4h-4v4zm6 6h4v-4h-4v4zm0 6h4v-4h-4v4z"/></svg><span>全部</span>`;
    bar.appendChild(allTab);

    const allGridItem = document.createElement("span");
    allGridItem.className = "sfp-tab-grid-item" + (currentTab === "all" ? " active" : "");
    allGridItem.dataset.tab = "all";
    allGridItem.innerHTML = allTab.innerHTML;
    grid.appendChild(allGridItem);

    // 各板块标签
    _getOrderedTabCategories().forEach((cat) => {
      const tab = document.createElement("span");
      tab.className = "sfp-tab-item" + (currentTab === cat.tabId ? " active" : "");
      tab.dataset.tab = cat.tabId;
      tab.dataset.categoryId = cat.id;
      tab.innerHTML = _buildCategoryTabContent(cat);
      bar.appendChild(tab);

      const gridItem = document.createElement("span");
      gridItem.className = "sfp-tab-grid-item" + (currentTab === cat.tabId ? " active" : "");
      gridItem.draggable = true;
      gridItem.dataset.tab = cat.tabId;
      gridItem.dataset.categoryId = cat.id;
      gridItem.title = getCategoryTabMeta(cat).name;
      gridItem.innerHTML = _buildCategoryTabContent(cat);
      grid.appendChild(gridItem);
    });

    // 事件代理 — 点击切换
    shell.addEventListener("click", (e) => {
      e.stopPropagation();
      _closeFloatingPanels(shell);
      const closeBtn = e.target.closest(".sfp-tab-panel-close");
      if (closeBtn) {
        e.stopPropagation();
        shell.classList.remove("open");
        return;
      }

      if (e.target.closest(".sfp-tab-more-btn")) {
        e.stopPropagation();
        shell.classList.toggle("open", !shell.classList.contains("open"));
        return;
      }

      const tab = e.target.closest(".sfp-tab-item, .sfp-tab-grid-item");
      if (!tab) return;

      const tabId = tab.dataset.tab;
      const catId = tab.dataset.categoryId ? Number(tab.dataset.categoryId) : null;
      const fromGrid = tab.classList.contains("sfp-tab-grid-item");
      if (tabId === currentTab && catId === currentCategoryId) {
        if (fromGrid) {
          shell.classList.remove("open");
          _scrollTabIntoView(shell, tabId, "smooth");
        }
        return;
      }

      currentTab = tabId;
      currentCategoryId = catId;
      GM_setValue(TAB_KEY, currentTab);
      _beginSidebarIncomingViewSettling();
      _syncDefaultViewControls();
      _resetAutoLoadState();

      shell.querySelectorAll(".sfp-tab-item, .sfp-tab-grid-item").forEach((t) => {
        t.classList.remove("active");
      });
      shell.querySelectorAll(`[data-tab="${_cssEscape(tabId)}"]`).forEach((t) => t.classList.add("active"));
      if (fromGrid) {
        pendingTabBarScrollTab = tabId;
        shell.classList.remove("open");
        _scrollTabIntoView(shell, tabId, "smooth");
      }

      loadTopics();
    });

    moreBtn.addEventListener("mousedown", (e) => e.preventDefault());

    let dragItem = null;
    let tabOrderChanged = false;
    grid.addEventListener("dragstart", (e) => {
      const item = e.target.closest(".sfp-tab-grid-item[data-category-id]");
      if (!item) return;
      dragItem = item;
      tabOrderChanged = false;
      item.classList.add("dragging");
      e.dataTransfer.effectAllowed = "move";
      e.dataTransfer.setData("text/plain", item.dataset.categoryId);
    });

    grid.addEventListener("dragover", (e) => {
      if (!dragItem) return;
      const target = e.target.closest(".sfp-tab-grid-item[data-category-id]");
      if (!target || target === dragItem) return;
      e.preventDefault();
      grid.querySelectorAll(".drop-target").forEach((el) => el.classList.remove("drop-target"));
      target.classList.add("drop-target");

      const rect = target.getBoundingClientRect();
      const before = e.clientY < rect.top + rect.height / 2;
      const nextNode = before ? target : target.nextSibling;
      if (dragItem !== nextNode) {
        grid.insertBefore(dragItem, nextNode);
        tabOrderChanged = true;
      }
    });

    grid.addEventListener("drop", (e) => {
      if (!dragItem) return;
      e.preventDefault();
    });

    grid.addEventListener("dragend", () => {
      if (dragItem) dragItem.classList.remove("dragging");
      grid.querySelectorAll(".drop-target").forEach((el) => el.classList.remove("drop-target"));
      if (tabOrderChanged) {
        _saveTabOrderFromGrid(grid);
        _rerenderTabBar(shell, { keepOpen: true });
      }
      dragItem = null;
      tabOrderChanged = false;
    });

    // 滚轮横向滚动
    bar.addEventListener("wheel", (e) => {
      if (Math.abs(e.deltaY) > Math.abs(e.deltaX)) {
        e.preventDefault();
        bar.scrollLeft += e.deltaY;
      }
    });

    shell.appendChild(bar);
    shell.appendChild(moreBtn);
    shell.appendChild(panel);
    return shell;
  }

  function _rerenderTabBar(oldShell, options = {}) {
    if (!oldShell?.parentNode) return null;
    const oldBar = oldShell.querySelector(".sfp-tab-bar");
    const scrollLeft = oldBar?.scrollLeft || 0;
    const keepOpen = options.keepOpen ?? oldShell.classList.contains("open");
    const newShell = _buildTabBar();
    if (keepOpen) newShell.classList.add("open");
    oldShell.replaceWith(newShell);
    const newBar = newShell.querySelector(".sfp-tab-bar");
    if (options.scrollTabId) {
      requestAnimationFrame(() => {
        _scrollTabIntoView(newShell, options.scrollTabId, options.scrollBehavior || "auto");
      });
    } else if (newBar) {
      newBar.scrollLeft = scrollLeft;
    }
    return newShell;
  }

  // ========== 筛选栏 ==========
  function _buildFilterBar() {
    const bar = document.createElement("div");
    bar.className = "sfp-filter-bar";

    const filters = [
      { label: "全部", value: "all" },
      { label: "未读", value: "unseen" },
      { label: "已读", value: "read" },
    ];

    filters.forEach((f) => {
      const item = document.createElement("span");
      item.className = "sfp-filter-item" + (currentFilter === f.value ? " active" : "");
      item.dataset.filter = f.value;
      item.textContent = f.label;
      bar.appendChild(item);
    });

    // 分隔
    const sep = document.createElement("span");
    sep.style.cssText = "color:var(--primary-low,#ddd);margin:0 2px;";
    sep.textContent = "|";
    bar.appendChild(sep);

    // 隐藏置顶开关
    const pinnedToggle = document.createElement("span");
    pinnedToggle.className = "sfp-filter-item" + (hidePinned ? " active" : "");
    pinnedToggle.dataset.filter = "hide-pinned";
    pinnedToggle.textContent = "隐藏置顶";
    bar.appendChild(pinnedToggle);

    bar.addEventListener("click", (e) => {
      const item = e.target.closest(".sfp-filter-item");
      if (!item) return;

      const filterVal = item.dataset.filter;
      if (filterVal === "hide-pinned") {
        hidePinned = !hidePinned;
        GM_setValue(HIDE_PINNED_KEY, hidePinned);
        item.classList.toggle("active", hidePinned);
        _resetAutoLoadState();
        renderTopics();
        return;
      } else {
        if (filterVal === currentFilter) return;
        currentFilter = filterVal;
        GM_setValue(FILTER_KEY, currentFilter);
        _beginSidebarIncomingViewSettling();
        _syncDefaultViewControls();
        _resetAutoLoadState();
        bar.querySelectorAll(".sfp-filter-item[data-filter]:not([data-filter=\"hide-pinned\"])").forEach((i) => i.classList.remove("active"));
        item.classList.add("active");
        renderTopics();
        _finishSidebarIncomingViewSettling();
      }
    });

    return bar;
  }

  // ========== 恢复标签栏状态 ==========
  function _restoreTabState() {
    if (currentTab === "all") {
      currentCategoryId = null;
      return;
    }
    const cat = TAB_CATEGORIES.find((c) => c.tabId === currentTab);
    if (cat) {
      currentCategoryId = cat.id;
    } else {
      currentTab = "all";
      currentCategoryId = null;
    }
  }

  function _refreshCategoryTabs() {
    const shell = feedContainer?.querySelector(".sfp-tab-shell");
    if (!shell) return;

    const scrollTabId = pendingTabBarScrollTab;
    pendingTabBarScrollTab = null;
    _rerenderTabBar(shell, { scrollTabId, scrollBehavior: scrollTabId ? "smooth" : "auto" });
  }

  function _removeShowMoreHint() {
    const contentWrapper = feedScrollEl?.querySelector(".sfp-content-wrapper");
    if (!contentWrapper) return;
    contentWrapper.querySelector(".sfp-show-more-overlay")?.remove();
    contentWrapper.classList.remove("sfp-has-show-more");
  }

  function _beginSidebarIncomingViewSettling() {
    sidebarIncomingState.viewSettling = true;
    sidebarIncomingState.filterStable = false;
    sidebarIncomingState.filteredTopicIds = [];
    sidebarIncomingState.filterRefreshToken++;
    if (sidebarIncomingState.filterRefreshTimer) {
      clearTimeout(sidebarIncomingState.filterRefreshTimer);
      sidebarIncomingState.filterRefreshTimer = null;
    }
    _removeShowMoreHint();
  }

  function _finishSidebarIncomingViewSettling() {
    sidebarIncomingState.viewSettling = false;
    sidebarIncomingState.filterStable = false;
    _recomputeSidebarIncomingFilteredTopicIds();
    _scheduleSidebarIncomingFilterRefresh();
    _updateShowMoreHint();
    if (_isAutoSilentRefreshActive() && autoSilentRefreshInterval === 0) {
      _queueSidebarIncomingApply();
    }
  }

  function _updateShowMoreHint({ skipIncomingFilterRefresh = false } = {}) {
    if (!feedScrollEl) return;

    const contentWrapper = feedScrollEl.querySelector(".sfp-content-wrapper");
    if (!contentWrapper) return;

    // Discourse 原生 show-more 支持 latest 及分类 latest 列表;这里仅用
    // message-bus payload 做板块范围计数,避免提醒前拉取话题详情。
    // 0 秒静默刷新会立即应用新话题,不需要显示手动提醒;有效间隔会批量应用,
    // 间隔期间保留数量提示。
    if (
      sidebarIncomingState.viewSettling ||
      isLoading ||
      !_canShowSidebarIncomingHint() ||
      (_isAutoSilentRefreshActive() && autoSilentRefreshInterval === 0)
    ) {
      _removeShowMoreHint();
      return;
    }

    if (!sidebarIncomingState.filterStable) {
      return;
    }

    if (!skipIncomingFilterRefresh) {
      _scheduleSidebarIncomingFilterRefresh();
    }

    // 提醒条只显示已经通过当前板块范围过滤的候选数量。未读/已读这种依赖完整
    // 话题字段的筛选会在点击应用时再次确认,避免仅凭 message-bus payload 误判。
    const existing = contentWrapper.querySelector(".sfp-show-more-overlay");
    const newCount = sidebarIncomingState.filteredTopicIds.length;
    if (newCount <= 0) {
      if (existing) existing.remove();
      contentWrapper.classList.remove("sfp-has-show-more");
      return;
    }

    const overlay = existing || document.createElement("div");
    overlay.className = "show-more has-topics sfp-show-more-overlay";

    let hint = overlay.querySelector(".sfp-hint-text");
    if (!hint) {
      hint = document.createElement("a");
      hint.className = "sfp-hint-text alert alert-info clickable";
      hint.href = "#";
      hint.addEventListener("click", async (e) => {
        e.preventDefault();
        if (hint.classList.contains("loading")) return;

        _setShowMoreHintLoading(hint, true);
        try {
          await _applySidebarIncomingTopics({ requireDefaultView: true, logPrefix: "show more" });
        } finally {
          _setShowMoreHintLoading(hint, false);
        }
      });
      overlay.appendChild(hint);
    }

    let label = hint.querySelector(".sfp-hint-label");
    if (!label) {
      label = document.createElement("span");
      label.className = "sfp-hint-label";
      hint.appendChild(label);
    }

    label.textContent = `查看 ${newCount} 个新的或更新的话题`;
    contentWrapper.classList.add("sfp-has-show-more");
    if (!existing) {
      contentWrapper.insertBefore(overlay, contentWrapper.firstChild);
    }
  }

  function _setShowMoreHintLoading(hint, loading) {
    hint.classList.toggle("loading", loading);
    hint.setAttribute("aria-busy", loading ? "true" : "false");
    hint.setAttribute("aria-disabled", loading ? "true" : "false");

    const existingSpinner = hint.querySelector(".sfp-hint-spinner-custom");
    if (loading) {
      if (!existingSpinner) {
        const spinner = document.createElement("div");
        spinner.className = "sfp-hint-spinner-custom";
        hint.appendChild(spinner);
      }
    } else if (existingSpinner) {
      existingSpinner.remove();
    }
  }

  function _isLatestActivityView(query = FeedQuery.snapshot()) {
    return query.order === "activity";
  }

  function _canUseSidebarIncomingRefresh(query = FeedQuery.snapshot()) {
    return _isLatestActivityView(query);
  }

  function _canShowSidebarIncomingHint(query = FeedQuery.snapshot()) {
    return showIncomingHint && _canUseSidebarIncomingRefresh(query) && query.filter === "all";
  }

  function _isAutoSilentRefreshActive(query = FeedQuery.snapshot()) {
    return autoSilentRefreshEnabled && !showIncomingHint && _canUseSidebarIncomingRefresh(query);
  }

  function _topicMatchesCategoryScope(topic, query = FeedQuery.snapshot()) {
    if (query.tab === "all" || !query.categoryId) return true;

    let categoryId = Number(topic?.category_id);
    const targetCategoryId = Number(query.categoryId);
    if (!Number.isFinite(categoryId) || !Number.isFinite(targetCategoryId)) return false;

    while (Number.isFinite(categoryId)) {
      if (categoryId === targetCategoryId) return true;
      const parentId = Number(_getCategoryMeta(categoryId)?.parent_category_id);
      if (!Number.isFinite(parentId) || parentId === categoryId) break;
      categoryId = parentId;
    }
    return false;
  }

  function _topicMatchesLocalFilter(topic, query = FeedQuery.snapshot()) {
    if (query.filter === "unseen") return _hasUnreadMarker(topic);
    if (query.filter === "read") return !_hasUnreadMarker(topic);
    return true;
  }

  function _topicMatchesIncomingView(topic, query = FeedQuery.snapshot()) {
    return _topicMatchesIncomingCandidate(topic, query) &&
      _topicMatchesLocalFilter(topic, query);
  }

  function _topicMatchesIncomingCandidate(topic, query = FeedQuery.snapshot()) {
    return _canUseSidebarIncomingRefresh(query) &&
      _topicMatchesCategoryScope(topic, query);
  }

  function _recomputeSidebarIncomingFilteredTopicIds(query = FeedQuery.snapshot()) {
    if (!_canUseSidebarIncomingRefresh(query)) {
      sidebarIncomingState.filteredTopicIds = [];
      sidebarIncomingState.filterStable = false;
      return sidebarIncomingState.filteredTopicIds;
    }

    // 这里故意只用 incoming candidate 条件,不套完整本地筛选。
    // cache 里的 payload 可能缺少 last_read_post_number/new_posts 等字段;
    // 完整筛选留给 _applySidebarIncomingTopics 拉取详情后处理。
    sidebarIncomingState.filteredTopicIds = sidebarIncomingState.topicIds.filter((id) => {
      const topic = sidebarIncomingState.topicCache.get(Number(id));
      return topic && _topicMatchesIncomingCandidate(topic, query);
    });
    return sidebarIncomingState.filteredTopicIds;
  }

  function _scheduleSidebarIncomingFilterRefresh() {
    if (sidebarIncomingState.viewSettling || isLoading || isRefreshing) return;
    if (!_canUseSidebarIncomingRefresh() || sidebarIncomingState.topicIds.length === 0) return;
    if (sidebarIncomingState.filterRefreshTimer) return;

    sidebarIncomingState.filterRefreshTimer = setTimeout(() => {
      sidebarIncomingState.filterRefreshTimer = null;
      _refreshSidebarIncomingFilter().catch((e) => {
        console.warn("[SFP] incoming filter refresh error:", e);
      });
    }, 150);
  }

  async function _refreshSidebarIncomingFilter() {
    const requestQuery = FeedQuery.snapshot();
    if (sidebarIncomingState.viewSettling || isLoading || isRefreshing) {
      return sidebarIncomingState.filteredTopicIds;
    }
    if (!_canUseSidebarIncomingRefresh(requestQuery)) {
      _recomputeSidebarIncomingFilteredTopicIds(requestQuery);
      _updateShowMoreHint({ skipIncomingFilterRefresh: true });
      return [];
    }

    const incomingTopicIds = sidebarIncomingState.topicIds.slice();
    const token = ++sidebarIncomingState.filterRefreshToken;
    if (incomingTopicIds.length === 0) {
      sidebarIncomingState.filteredTopicIds = [];
      sidebarIncomingState.filterStable = true;
      _updateShowMoreHint({ skipIncomingFilterRefresh: true });
      return [];
    }

    await loadCategoryMetadata();
    if (token !== sidebarIncomingState.filterRefreshToken || !FeedQuery.isCurrent(requestQuery)) return sidebarIncomingState.filteredTopicIds;

    _recomputeSidebarIncomingFilteredTopicIds(requestQuery);
    sidebarIncomingState.filterStable = true;
    _updateShowMoreHint({ skipIncomingFilterRefresh: true });
    return sidebarIncomingState.filteredTopicIds;
  }

  function _syncDefaultViewControls() {
    _recomputeSidebarIncomingFilteredTopicIds();
    _updateSettingsControl();
    _updateShowMoreHint();
    _startAutoSilentRefresh();
    _startAutoRefresh();
    _scheduleSidebarIncomingFilterRefresh();
    if (_isAutoSilentRefreshActive() && autoSilentRefreshInterval === 0 && sidebarIncomingState.topicIds.length > 0) {
      _queueSidebarIncomingApply();
    }
  }

  function _updateSettingsControl() {
    if (!feedHeaderEl) return;

    const oldSettings = feedHeaderEl.querySelector(".sfp-settings-wrap");
    if (!oldSettings) return;

    const hasLatestActivityPanel = !!oldSettings.querySelector(".sfp-incoming-hint-row");
    if (hasLatestActivityPanel === _isLatestActivityView()) {
      _syncSettingsPanelState(oldSettings);
      return;
    }

    const isOpen = oldSettings.classList.contains("open");
    const nextSettings = _buildSettingsControl();
    if (isOpen) nextSettings.classList.add("open");
    oldSettings.replaceWith(nextSettings);
  }

  function _startSidebarIncomingTracking() {
    if (sidebarLatestMessageBusCallback || sidebarNewMessageBusCallback) return;

    const messageBus = getMessageBus();
    if (!messageBus?.subscribe) {
      console.warn("[SFP] message-bus service unavailable; incoming topics will not auto-update");
      return;
    }

    sidebarMessageBus = messageBus;
    sidebarLatestMessageBusCallback = (data) => _handleSidebarIncomingMessage(data);
    sidebarNewMessageBusCallback = (data) => _handleSidebarIncomingMessage(data);
    // 同时订阅 /latest 和 /new:Discourse 在不同列表和站点配置下可能通过
    // 其中任一频道广播新话题或最新活动。lastId 使用当前 bus 实例读取,
    // 避免重新订阅时误用已经清掉的全局 sidebarMessageBus。
    messageBus.subscribe("/latest", sidebarLatestMessageBusCallback, _getMessageBusLastId(messageBus, "/latest"));
    messageBus.subscribe("/new", sidebarNewMessageBusCallback, _getMessageBusLastId(messageBus, "/new"));
  }

  function _getMessageBusLastId(messageBus, channel) {
    const candidates = [
      messageBus?.lastId?.(channel),
      messageBus?.lastIdForChannel?.(channel),
      messageBus?.lastIds?.[channel],
      messageBus?.last_ids?.[channel],
      messageBus?.channels?.[channel]?.lastId,
    ];

    for (const candidate of candidates) {
      const lastId = Number(candidate);
      if (Number.isFinite(lastId)) return lastId;
    }

    return -1;
  }

  function _stopSidebarIncomingTracking() {
    if (sidebarMessageBus?.unsubscribe) {
      if (sidebarLatestMessageBusCallback) {
        sidebarMessageBus.unsubscribe("/latest", sidebarLatestMessageBusCallback);
      }
      if (sidebarNewMessageBusCallback) {
        sidebarMessageBus.unsubscribe("/new", sidebarNewMessageBusCallback);
      }
    }

    sidebarMessageBus = null;
    sidebarLatestMessageBusCallback = null;
    sidebarNewMessageBusCallback = null;
    sidebarIncomingState.applyQueued = false;
    if (sidebarIncomingState.filterRefreshTimer) {
      clearTimeout(sidebarIncomingState.filterRefreshTimer);
      sidebarIncomingState.filterRefreshTimer = null;
    }
  }

  function _handleSidebarIncomingMessage(data) {
    if (!data || !["latest", "new_topic"].includes(data.message_type)) return;
    if (!data.topic_id) return;
    if (data.payload?.archetype && data.payload.archetype !== "regular") return;

    // 推送 payload 不一定包含完整话题字段,但通常足够判断 id、分类和 archetype。
    // 先记录候选,真正插入列表前再拉完整数据,避免把不完整 payload 直接渲染。
    _addSidebarIncomingTopicId(data.topic_id);
    if (data.payload) {
      sidebarIncomingState.topicCache.set(Number(data.topic_id), {
        ...(sidebarIncomingState.topicCache.get(Number(data.topic_id)) || {}),
        ...data.payload,
        id: Number(data.topic_id),
      });
    }
    sidebarIncomingState.filterStable = false;

    if (_isAutoSilentRefreshActive() && autoSilentRefreshInterval === 0) {
      _queueSidebarIncomingApply();
    } else {
      if (_canUseSidebarIncomingRefresh()) {
        _recomputeSidebarIncomingFilteredTopicIds();
        sidebarIncomingState.filterStable = true;
        _updateShowMoreHint({ skipIncomingFilterRefresh: true });
      }
    }
  }

  function _addSidebarIncomingTopicId(topicId) {
    const numericId = Number(topicId);
    if (!Number.isFinite(numericId) || sidebarIncomingState.topicIdSet.has(numericId)) return;

    sidebarIncomingState.topicIdSet.add(numericId);
    sidebarIncomingState.topicIds.push(numericId);
    sidebarIncomingState.filterStable = false;
  }

  function _removeSidebarIncomingTopicIds(topicIds) {
    if (!topicIds?.length) return;

    const toRemove = new Set(topicIds.map((id) => Number(id)).filter(Number.isFinite));
    if (toRemove.size === 0) return;

    sidebarIncomingState.topicIds = sidebarIncomingState.topicIds.filter((id) => !toRemove.has(Number(id)));
    sidebarIncomingState.topicIdSet = new Set(sidebarIncomingState.topicIds);
    toRemove.forEach((id) => sidebarIncomingState.topicCache.delete(Number(id)));
    _recomputeSidebarIncomingFilteredTopicIds();
  }

  function _queueSidebarIncomingApply() {
    if (sidebarIncomingState.viewSettling) return;
    if (!_canUseSidebarIncomingRefresh()) return;
    if (!_isAutoSilentRefreshActive() || autoSilentRefreshInterval > 0) return;
    if (sidebarIncomingState.applyQueued) return;

    // 0 秒静默刷新表示“有 incoming 就尽快应用”。这里排入 microtask,
    // 让同一轮 message-bus 回调中的多个 topic id 先合并,再发起一次批量请求。
    sidebarIncomingState.applyQueued = true;
    Promise.resolve().then(async () => {
      sidebarIncomingState.applyQueued = false;
      if (!_isAutoSilentRefreshActive() || autoSilentRefreshInterval > 0) return;
      await _applySidebarIncomingTopics({ requireDefaultView: true, logPrefix: "auto silent refresh", preserveViewport: true });
    });
  }

  function _flushQueuedSidebarIncomingApply() {
    if (!sidebarIncomingState.applyQueued) return;

    if (!_isAutoSilentRefreshActive() || autoSilentRefreshInterval > 0) {
      sidebarIncomingState.applyQueued = false;
      return;
    }

    sidebarIncomingState.applyQueued = false;
    _queueSidebarIncomingApply();
  }

  function _getAutoLoadSessionKey() {
    return FeedQuery.key(FeedQuery.snapshot());
  }

  function _resetAutoLoadState() {
    autoLoadTimestamps = [];
    autoLoadEmptyFilterCount = 0;
    autoLoadStoppedForSession = false;
    autoLoadSessionKey = _getAutoLoadSessionKey();
  }

  function _ensureAutoLoadSession() {
    const nextKey = _getAutoLoadSessionKey();
    if (nextKey !== autoLoadSessionKey) {
      _resetAutoLoadState();
    }
  }

  function _canRunAutoLoad() {
    _ensureAutoLoadSession();
    if (autoLoadStoppedForSession) return false;

    const now = Date.now();
    autoLoadTimestamps = autoLoadTimestamps.filter((ts) => now - ts < AUTO_LOAD_RATE_WINDOW_MS);
    return autoLoadTimestamps.length < AUTO_LOAD_MAX_REQUESTS_PER_WINDOW;
  }

  function _recordAutoLoadRequest() {
    _ensureAutoLoadSession();
    autoLoadTimestamps.push(Date.now());
  }

  function _recordAutoLoadFilterResult(filteredNewCount) {
    _ensureAutoLoadSession();
    if (filteredNewCount > 0) {
      autoLoadEmptyFilterCount = 0;
      return;
    }

    autoLoadEmptyFilterCount++;
    if (autoLoadEmptyFilterCount >= AUTO_LOAD_MAX_EMPTY_FILTER_RESULTS) {
      autoLoadStoppedForSession = true;
    }
  }

  const FeedQuery = {
    snapshot() {
      return {
        tab: currentTab,
        categoryId: currentCategoryId,
        order: currentOrder,
        period: currentPeriod,
        filter: currentFilter,
        hidePinned,
      };
    },

    key(query) {
      // 查询 key 覆盖所有会影响 API URL 或本地筛选的状态。异步请求返回时用
      // isCurrent 比较 key,丢弃用户切换视图前发出的旧响应。
      return [
        query.tab,
        query.categoryId || "",
        query.order,
        query.period,
        query.filter,
        query.hidePinned ? "hide-pinned" : "show-pinned",
      ].join("|");
    },

    isCurrent(query) {
      return this.key(query) === this.key(this.snapshot());
    },

    buildUrl(query, page) {
      const useTopList = _usesPeriodScopedTopList(query.order, query.period);

      // Discourse 的 top 周期只在 /top.json 或分类 /l/top.json 上有完整语义。
      // period=all 的“最多浏览/回复/点赞”等排序继续走 latest.json?order=...,
      // 保留此前版本的 ranked order 行为。
      if (query.tab !== "all" && query.categoryId) {
        const cat = CATEGORY_CONFIG[query.categoryId];
        const tabId = cat?.tabId || query.tab;
        const params = [`page=${page}`];
        if (useTopList) {
          params.push(`period=${encodeURIComponent(query.period)}`);
          params.push(`order=${encodeURIComponent(query.order)}`);
          return `/c/${tabId}/${query.categoryId}/l/top.json?${params.join("&")}`;
        }

        params.push(`order=${encodeURIComponent(query.order)}`);
        return `/c/${tabId}/${query.categoryId}/l/latest.json?${params.join("&")}`;
      }

      if (useTopList) {
        const params = [
          `period=${encodeURIComponent(query.period)}`,
          `order=${encodeURIComponent(query.order)}`,
          `page=${page}`,
        ];
        return `/top.json?${params.join("&")}`;
      }

      const params = [
        `order=${encodeURIComponent(query.order)}`,
        `page=${page}`,
      ];
      if (query.period !== "all" && _needsPeriodForUrl(query.order)) {
        params.push(`period=${encodeURIComponent(query.period)}`);
      }
      return `/latest.json?${params.join("&")}`;
    },
  };

  // ========== 数据加载 ==========
  async function fetchFeedTopics(query, page) {
    const url = FeedQuery.buildUrl(query, page);
    const csrfToken = getCsrfToken();
    const headers = { "X-CSRF-Token": csrfToken };
    const resp = await fetch(url, { headers });
    if (!resp.ok) throw new Error(`API error: ${resp.status}`);
    return resp.json();
  }

  async function fetchFeedTopicsByIds(topicIds) {
    const ids = Array.from(new Set(topicIds.map((id) => Number(id)).filter(Number.isFinite)));
    if (ids.length === 0) return null;

    const csrfToken = getCsrfToken();
    const headers = { "X-CSRF-Token": csrfToken };
    const resp = await fetch(`/latest.json?topic_ids=${ids.join(",")}`, { headers });
    if (!resp.ok) throw new Error(`API error: ${resp.status}`);
    return resp.json();
  }

  function _needsPeriodForUrl(order) {
    return ["views", "posts", "likes", "op_likes"].includes(order);
  }

  function _usesPeriodScopedTopList(order, period) {
    return period !== "all" && _needsPeriodForUrl(order);
  }

  function _startTagStyleIndexLoad() {
    if (tagStyleLoaded || tagStylePromise) return;
    loadTagStyleIndex().then(() => {
      if (feedModeEnabled && feedListEl && allTopics.length > 0) {
        renderTopics();
      }
    });
  }

  async function loadTopics() {
    if (isLoading) {
      _pendingReload = true;
      return;
    }

    const requestQuery = FeedQuery.snapshot();
    const requestToken = ++activeLoadToken;
    activeLoadMoreToken++;
    activeRefreshToken++;
    _beginSidebarIncomingViewSettling();
    isLoading = true;
    isLoadingMore = false;
    isRefreshing = false;
    _pendingReload = false;

    currentPage = 0;
    hasMorePages = true;
    allTopics = [];
    loadedTopicIds.clear();
    _updateShowMoreHint();
    _resetAutoLoadState();

    if (feedListEl) {
      feedListEl.innerHTML = `<div class="sfp-loading"><div class="sfp-spinner"></div>加载中...</div>`;
    }
    _updateBackTopButton();

    try {
      _startTagStyleIndexLoad();
      await loadCategoryMetadata();
      if (requestToken !== activeLoadToken || !FeedQuery.isCurrent(requestQuery)) return;
      _refreshCategoryTabs();

      const data = await fetchFeedTopics(requestQuery, 0);
      if (requestToken !== activeLoadToken || !FeedQuery.isCurrent(requestQuery)) return;
      _processUsers(data);

      if (data?.topic_list?.topics) {
        const topics = data.topic_list.topics;
        topics.forEach((t) => loadedTopicIds.add(t.id));
        allTopics = topics;
        hasMorePages = !!data.topic_list.more_topics_url;
        renderTopics();
        _removeSidebarIncomingTopicIds(topics.map((topic) => topic.id));
        _updateShowMoreHint();
      } else {
        if (feedListEl) feedListEl.innerHTML = `<div class="sfp-empty">暂无话题</div>`;
        hasMorePages = false;
      }

      _startAutoRefresh();
    } catch (e) {
      if (requestToken !== activeLoadToken || !FeedQuery.isCurrent(requestQuery)) return;
      console.error("[SFP] loadTopics error:", e);
      if (feedListEl) {
        feedListEl.innerHTML = `
          <div class="sfp-error">
            <div class="sfp-error-icon">⚠️</div>
            <div class="sfp-error-msg">加载失败</div>
            <div class="sfp-error-detail">${escapeHtml(e.message)}</div>
            <button class="sfp-retry-btn">重试</button>
          </div>`;
        feedListEl.querySelector(".sfp-retry-btn")?.addEventListener("click", () => loadTopics());
      }
    } finally {
      if (requestToken === activeLoadToken) {
        isLoading = false;
        _flushQueuedSidebarIncomingApply();
        if (_pendingReload) {
          _pendingReload = false;
          loadTopics();
        } else {
          _finishSidebarIncomingViewSettling();
        }
      }
    }
  }

  async function loadMoreTopics({ source = "manual" } = {}) {
    if (isLoading || isLoadingMore || !hasMorePages) return;
    const isAutoLoad = source === "auto";
    if (isAutoLoad && !_canRunAutoLoad()) return;

    const requestQuery = FeedQuery.snapshot();
    const requestToken = ++activeLoadMoreToken;
    const nextPage = currentPage + 1;
    isLoadingMore = true;
    if (isAutoLoad) _recordAutoLoadRequest();

    _showLoadMoreSpinner();

    try {
      await loadCategoryMetadata();
      if (requestToken !== activeLoadMoreToken || !FeedQuery.isCurrent(requestQuery)) return;
      const data = await fetchFeedTopics(requestQuery, nextPage);
      if (requestToken !== activeLoadMoreToken || !FeedQuery.isCurrent(requestQuery)) return;
      _processUsers(data);

      if (data?.topic_list?.topics) {
        const topics = data.topic_list.topics;
        currentPage = nextPage;
        const newTopics = topics.filter((t) => {
          if (loadedTopicIds.has(t.id)) return false;
          loadedTopicIds.add(t.id);
          return true;
        });

        hasMorePages = !!data.topic_list.more_topics_url;
        if (newTopics.length === 0) {
          if (hasMorePages) {
            _renderPaginationFooter({
              note: !isAutoLoad ? "下一页无符合条件的话题" : "",
            });
          } else {
            _showNoMore();
          }
        } else {
          allTopics = allTopics.concat(newTopics);
          // 增量追加,应用当前筛选,保留滚动位置
          const filteredNew = _applyFilter(newTopics);
          filteredNew.forEach((topic) => {
            const item = createTopicItem(topic);
            feedListEl.appendChild(item);
          });

          if (isAutoLoad) _recordAutoLoadFilterResult(filteredNew.length);
          _renderPaginationFooter({
            note: !isAutoLoad && filteredNew.length === 0 ? "下一页无符合条件的话题" : "",
          });
        }
      } else {
        hasMorePages = false;
        _showNoMore();
      }
    } catch (e) {
      if (requestToken !== activeLoadMoreToken || !FeedQuery.isCurrent(requestQuery)) return;
      console.error("[SFP] loadMoreTopics error:", e);
      _showLoadMoreError(e);
    } finally {
      if (requestToken === activeLoadMoreToken) {
        isLoadingMore = false;
        _flushQueuedSidebarIncomingApply();
      }
    }
  }

  function _processUsers(data) {
    if (data?.users) {
      data.users.forEach((u) => { usersMap[u.id] = u; });
    }
  }

  async function refreshCurrentView() {
    return _refreshCurrentView({
      logPrefix: "manual refresh",
    });
  }

  function _captureFeedScrollAnchor() {
    if (!feedScrollEl || !feedListEl) return null;
    if (feedScrollEl.scrollTop <= 1) return null;

    const scrollRect = feedScrollEl.getBoundingClientRect();
    const items = feedListEl.querySelectorAll(".sfp-topic-item[data-topic-id]");
    for (const item of items) {
      const itemRect = item.getBoundingClientRect();
      if (itemRect.bottom > scrollRect.top + 1) {
        return {
          topicId: item.dataset.topicId,
          offsetTop: itemRect.top - scrollRect.top,
          scrollTop: feedScrollEl.scrollTop,
          scrollHeight: feedScrollEl.scrollHeight,
        };
      }
    }

    return {
      topicId: null,
      offsetTop: 0,
      scrollTop: feedScrollEl.scrollTop,
      scrollHeight: feedScrollEl.scrollHeight,
    };
  }

  function _restoreFeedScrollAnchor(anchor) {
    if (!anchor || !feedScrollEl || !feedListEl) return;

    const restore = () => {
      if (!feedScrollEl || !feedListEl) return;

      if (anchor.topicId) {
        const item = feedListEl.querySelector(`.sfp-topic-item[data-topic-id="${anchor.topicId}"]`);
        if (item) {
          const scrollRect = feedScrollEl.getBoundingClientRect();
          const itemRect = item.getBoundingClientRect();
          feedScrollEl.scrollTop += itemRect.top - scrollRect.top - anchor.offsetTop;
          _updateBackTopButton();
          return;
        }
      }

      feedScrollEl.scrollTop = anchor.scrollTop + (feedScrollEl.scrollHeight - anchor.scrollHeight);
      _updateBackTopButton();
    };

    restore();
    requestAnimationFrame(restore);
  }

  function _mergeAndRenderTopics(fetchedTopics, {
    mode = "prepend",
    moreTopicsUrl = "",
    incomingCandidateIds = [],
    filterTopic = null,
    preserveViewport = false,
  } = {}) {
    const shouldFilterTopics = mode !== "replace-head" && typeof filterTopic === "function";
    const topics = shouldFilterTopics
      ? fetchedTopics.filter(filterTopic)
      : fetchedTopics;

    if (topics.length === 0) {
      if (incomingCandidateIds.length) _removeSidebarIncomingTopicIds(incomingCandidateIds);
      _updateShowMoreHint();
      return false;
    }

    const topicMap = new Map(topics.map((topic) => [topic.id, topic]));
    let highlightTopicIds = [];

    if (mode === "replace-head") {
      // 手动刷新或普通自动刷新拿到的是列表头部,应该同步 more_topics_url;
      // incoming prepend 只是在现有列表前插入候选话题,不能据此重置分页状态。
      highlightTopicIds = topics
        .filter((topic) => !loadedTopicIds.has(topic.id) || sidebarIncomingState.topicIdSet.has(Number(topic.id)))
        .map((topic) => topic.id);
      allTopics = topics.concat(allTopics.filter((topic) => !topicMap.has(topic.id)));
      hasMorePages = !!moreTopicsUrl;
    } else {
      highlightTopicIds = topics.map((topic) => topic.id);
      allTopics = topics.concat(allTopics.filter((topic) => !topicMap.has(topic.id)));
    }

    topics.forEach((topic) => loadedTopicIds.add(topic.id));

    const scrollAnchor = _captureFeedScrollAnchor();
    renderTopics(highlightTopicIds, { preserveProtected: preserveViewport });
    if (incomingCandidateIds.length) _removeSidebarIncomingTopicIds(incomingCandidateIds);
    _updateShowMoreHint();
    _restoreFeedScrollAnchor(scrollAnchor);
    return true;
  }

  async function _refreshCurrentView({ requireDefaultView = false, logPrefix = "refresh", preserveViewport = false } = {}) {
    if (isLoading || isLoadingMore || isRefreshing) return false;
    if (requireDefaultView && !_canUseSidebarIncomingRefresh()) return false;
    if (!feedListEl) return false;

    const requestQuery = FeedQuery.snapshot();
    const requestToken = ++activeRefreshToken;
    const endRefreshBusy = _beginRefreshButtonBusy();
    isRefreshing = true;
    try {
      await loadCategoryMetadata();
      if (requestToken !== activeRefreshToken || !FeedQuery.isCurrent(requestQuery)) return false;
      const data = await fetchFeedTopics(requestQuery, 0);
      if (requestToken !== activeRefreshToken || !FeedQuery.isCurrent(requestQuery)) return false;
      _processUsers(data);

      if (!data?.topic_list?.topics) return false;

      return _mergeAndRenderTopics(data.topic_list.topics, {
        mode: "replace-head",
        moreTopicsUrl: data.topic_list.more_topics_url,
        incomingCandidateIds: data.topic_list.topics.map((topic) => topic.id),
        preserveViewport,
      });
    } catch (e) {
      console.warn(`[SFP] ${logPrefix} error:`, e);
      return false;
    } finally {
      if (requestToken === activeRefreshToken) {
        isRefreshing = false;
        _resetAutoRefreshCountdown();
        _flushQueuedSidebarIncomingApply();
      }
      endRefreshBusy();
    }
  }

  // ========== 静默刷新 ==========
  // 仅在 latest/latest category 语义可匹配的最新活动视图中按 incoming 事件启用。
  // 这条路径不重新拉整页,而是按 message-bus 收集到的 topic id 批量取详情,
  // 然后插入到当前列表顶部;这样能减少刷新时对阅读位置的扰动。
  async function _applySidebarIncomingTopics({ requireDefaultView = false, logPrefix = "incoming", queueIfBusy = true, preserveViewport = false } = {}) {
    if (sidebarIncomingState.viewSettling || isLoading || isLoadingMore || isRefreshing) {
      if (queueIfBusy) sidebarIncomingState.applyQueued = true;
      return;
    }
    if (requireDefaultView && !_canUseSidebarIncomingRefresh()) return;
    if (!feedListEl) return;

    const endRefreshBusy = _beginRefreshButtonBusy();
    let requestToken = null;
    try {
      const incomingTopicIds = (await _refreshSidebarIncomingFilter()).slice();
      if (incomingTopicIds.length === 0) {
        _updateShowMoreHint();
        return;
      }
      if (isLoading || isLoadingMore || isRefreshing) {
        if (queueIfBusy) sidebarIncomingState.applyQueued = true;
        return;
      }

      const requestQuery = FeedQuery.snapshot();
      requestToken = ++activeRefreshToken;
      isRefreshing = true;
      await loadCategoryMetadata();
      if (requestToken !== activeRefreshToken || !FeedQuery.isCurrent(requestQuery)) return;
      const data = await fetchFeedTopicsByIds(incomingTopicIds);
      if (requestToken !== activeRefreshToken || !FeedQuery.isCurrent(requestQuery)) return;
      if (!data?.topic_list?.topics) return;
      _processUsers(data);

      _mergeAndRenderTopics(data.topic_list.topics, {
        mode: "prepend",
        incomingCandidateIds: incomingTopicIds,
        filterTopic: (topic) => _topicMatchesIncomingView(topic, requestQuery),
        preserveViewport,
      });
    } catch (e) {
      console.warn(`[SFP] ${logPrefix} error:`, e);
    } finally {
      if (requestToken === activeRefreshToken) {
        isRefreshing = false;
        _flushQueuedSidebarIncomingApply();
      }
      endRefreshBusy();
    }
  }

  function _startAutoSilentRefresh() {
    _stopAutoSilentRefresh();
    if (!_isAutoSilentRefreshActive()) return;
    if (autoSilentRefreshInterval <= 0) return;

    // 大于 0 的静默刷新按倒计时批量应用 incoming。0 秒场景由
    // _queueSidebarIncomingApply 处理,避免同时存在 interval 和 microtask 两条触发链。
    _resetAutoSilentRefreshCountdown();
    autoSilentRefreshTimer = setInterval(() => {
      autoSilentRefreshSeconds--;
      if (autoSilentRefreshSeconds <= 0) {
        _resetAutoSilentRefreshCountdown();
        if (feedModeEnabled && !isLoading && !isLoadingMore && !isRefreshing) {
          if (_canUseSidebarIncomingRefresh()) {
            _applySidebarIncomingTopics({
              requireDefaultView: true,
              logPrefix: "auto silent refresh interval",
              queueIfBusy: false,
              preserveViewport: true,
            });
          } else {
            _refreshCurrentView({
              logPrefix: "auto silent refresh interval",
              preserveViewport: true,
            });
          }
        }
      }
    }, 1000);
  }

  function _resetAutoSilentRefreshCountdown() {
    if (_isAutoSilentRefreshActive() && autoSilentRefreshInterval > 0) {
      autoSilentRefreshSeconds = autoSilentRefreshInterval;
    }
  }

  function _stopAutoSilentRefresh() {
    if (autoSilentRefreshTimer) {
      clearInterval(autoSilentRefreshTimer);
      autoSilentRefreshTimer = null;
    }
  }

  // ========== 自动刷新 ==========
  function _startAutoRefresh() {
    _stopAutoRefresh();
    if (_isLatestActivityView()) return;
    if (!autoRefreshEnabled) return;

    // 非最新活动排序没有可靠 incoming 增量,只能按当前查询重新拉取列表头部。
    // refreshCurrentView 会保存滚动锚点,尽量避免自动刷新把正在看的内容挤走。
    _resetAutoRefreshCountdown();
    autoRefreshTimer = setInterval(() => {
      autoRefreshSeconds--;
      if (autoRefreshSeconds <= 0) {
        _resetAutoRefreshCountdown();
        if (feedModeEnabled && !isLoading && !isLoadingMore) {
          _refreshCurrentView({ logPrefix: "auto refresh", preserveViewport: true });
        }
      }
    }, 1000);
  }

  function _resetAutoRefreshCountdown() {
    if (autoRefreshEnabled) {
      autoRefreshSeconds = autoRefreshInterval;
    }
  }

  function _stopAutoRefresh() {
    if (autoRefreshTimer) {
      clearInterval(autoRefreshTimer);
      autoRefreshTimer = null;
    }
  }

  function _getVisibleOrHoveredTopicItems() {
    if (!feedScrollEl || !feedListEl) return [];
    if (feedScrollEl.scrollTop <= 1) return [];

    const scrollRect = feedScrollEl.getBoundingClientRect();
    const protectedItems = [];
    const protectedIds = new Set();
    feedListEl.querySelectorAll(".sfp-topic-item[data-topic-id]").forEach((item) => {
      const itemRect = item.getBoundingClientRect();
      const isVisible = itemRect.bottom > scrollRect.top && itemRect.top < scrollRect.bottom;
      const isHovered = item.matches(":hover");
      if (!isVisible && !isHovered) return;

      const topicId = Number(item.dataset.topicId);
      if (!Number.isFinite(topicId) || protectedIds.has(topicId)) return;
      protectedIds.add(topicId);
      protectedItems.push({ topicId, element: item });
    });

    return protectedItems;
  }

  function _triggerTopicHighlight(item) {
    if (!item) return;

    const oldTimer = topicHighlightTimers.get(item);
    if (oldTimer) clearTimeout(oldTimer);

    item.classList.remove("sfp-new-highlight");
    void item.offsetWidth;
    item.classList.add("sfp-new-highlight");

    const timer = setTimeout(() => {
      item.classList.remove("sfp-new-highlight");
      topicHighlightTimers.delete(item);
    }, 10000);
    topicHighlightTimers.set(item, timer);
  }

  function _topicStatsHtml(topic) {
    const replies = Math.max(0, (topic.posts_count || 1) - 1);
    const views = topic.views >= 1000 ? (topic.views / 1000).toFixed(1) + "k" : (topic.views || 0);
    const likes = topic.like_count || 0;
    return `
        <span class="sfp-topic-stat">${_svgIcon("comment")} ${replies}</span>
        <span class="sfp-topic-stat">${_svgIcon("far-eye")} ${views}</span>
        <span class="sfp-topic-stat">${_svgIcon("heart")} ${likes}</span>
      `;
  }

  function _topicStatusBadgesHtml(topic) {
    const statusBadges = [];
    if (topic.is_hot) {
      statusBadges.push('<span class="topic-status-card --hot"><svg class="fa d-icon d-icon-fire svg-icon fa-width-auto svg-string" width="1em" height="1em" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"><use href="#fire"></use></svg><p class="topic-status-card__name">热门</p></span>');
    }
    if (topic.pinned || topic.pinned_globally) {
      statusBadges.push('<span class="topic-status-card --pinned"><svg class="fa d-icon d-icon-thumbtack svg-icon fa-width-auto svg-string" width="1em" height="1em" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"><use href="#thumbtack"></use></svg><p class="topic-status-card__name">已置顶</p></span>');
    }
    return statusBadges.length
      ? `<span class="sfp-topic-status-badges">${statusBadges.join("")}</span>`
      : "";
  }

  function _topicTimeHtml(topic) {
    const timeStr = formatRelativeTime(topic.bumped_at || topic.last_posted_at || topic.created_at);
    const unreadDotHtml = _hasUnreadMarker(topic)
      ? '<span class="sfp-unread-dot" aria-hidden="true"></span>'
      : "";
    return `${timeStr}${unreadDotHtml}`;
  }

  function _isTopicExplicitlyUnavailable(topic) {
    return !!topic && (
      topic.deleted_at ||
      topic.deleted ||
      topic.hidden ||
      topic.visible === false
    );
  }

  function _patchProtectedTopicItem(item, topic, { isNew = false, filterMatched = true } = {}) {
    if (!item || !topic) return;

    item.classList.toggle("sfp-pinned", !!(topic.pinned || topic.pinned_globally));
    item.classList.toggle("sfp-read", _isTopicRead(topic));
    item.classList.toggle("sfp-filter-mismatch", !filterMatched || _isTopicExplicitlyUnavailable(topic));
    item.classList.toggle("sfp-topic-unavailable", _isTopicExplicitlyUnavailable(topic));

    const header = item.querySelector(".sfp-topic-header");
    const time = item.querySelector(".sfp-topic-time");
    if (time) time.innerHTML = _topicTimeHtml(topic);

    const badgesHtml = _topicStatusBadgesHtml(topic);
    const existingBadges = item.querySelector(".sfp-topic-status-badges");
    if (badgesHtml) {
      if (existingBadges) {
        existingBadges.outerHTML = badgesHtml;
      } else if (header && time) {
        time.insertAdjacentHTML("beforebegin", badgesHtml);
      }
    } else if (existingBadges) {
      existingBadges.remove();
    }

    const stats = item.querySelector(".sfp-topic-stats");
    if (stats) stats.innerHTML = _topicStatsHtml(topic);

    if (isNew) _triggerTopicHighlight(item);
  }

  function _renderTopicsPreservingProtected(newTopicIds = []) {
    if (!feedListEl) return false;

    const protectedItems = _getVisibleOrHoveredTopicItems();
    if (protectedItems.length === 0) return false;

    const newTopicIdSet = new Set(newTopicIds.map((id) => Number(id)).filter(Number.isFinite));
    const protectedIds = new Set(protectedItems.map(({ topicId }) => topicId));
    const filtered = _applyFilter(allTopics);
    const filteredIds = new Set(filtered.map((topic) => Number(topic.id)));
    const topicById = new Map(allTopics.map((topic) => [Number(topic.id), topic]));
    const nonProtectedTopics = filtered.filter((topic) => !protectedIds.has(Number(topic.id)));
    let nextNonProtectedIndex = 0;
    const currentItems = Array.from(feedListEl.querySelectorAll(".sfp-topic-item[data-topic-id]"));

    _removePaginationFooter();

    currentItems.forEach((oldItem) => {
      const oldTopicId = Number(oldItem.dataset.topicId);
      if (protectedIds.has(oldTopicId)) {
        const topic = topicById.get(oldTopicId);
        if (topic) {
          _patchProtectedTopicItem(oldItem, topic, {
            isNew: newTopicIdSet.has(oldTopicId),
            filterMatched: filteredIds.has(oldTopicId),
          });
        }
        return;
      }

      if (nextNonProtectedIndex >= nonProtectedTopics.length) {
        oldItem.remove();
        return;
      }

      const topic = nonProtectedTopics[nextNonProtectedIndex++];
      const topicId = Number(topic.id);
      oldItem.replaceWith(createTopicItem(topic, newTopicIdSet.has(topicId)));
    });

    while (nextNonProtectedIndex < nonProtectedTopics.length) {
      const topic = nonProtectedTopics[nextNonProtectedIndex++];
      const topicId = Number(topic.id);
      feedListEl.appendChild(createTopicItem(topic, newTopicIdSet.has(topicId)));
    }

    _renderPaginationFooter();
    _updateBackTopButton();
    return true;
  }

  // ========== 渲染 ==========
  function renderTopics(newTopicIds = [], { preserveProtected = false } = {}) {
    if (!feedListEl) return;

    if (preserveProtected && _renderTopicsPreservingProtected(newTopicIds)) return;

    feedListEl.innerHTML = "";

    if (allTopics.length === 0) {
      feedListEl.innerHTML = `<div class="sfp-empty">暂无话题</div>`;
      _updateBackTopButton();
      return;
    }

    // 客户端筛选
    let filtered = _applyFilter(allTopics);

    if (filtered.length === 0) {
      // 构建更精确的空状态消息
      let emptyMsg = "无匹配话题";
      if (currentFilter === "unseen") {
        emptyMsg = currentTab !== "all" ? "该板块暂无未读话题" : "暂无未读话题";
      } else if (currentFilter === "read") {
        emptyMsg = "暂无已读话题";
      }

      // 有数据但筛选后为空 → 显示当前页提示,分页控件由底部统一渲染
      if (hasMorePages && !isLoadingMore) {
        feedListEl.innerHTML = `<div class="sfp-empty">当前页${emptyMsg}</div>`;
      } else {
        feedListEl.innerHTML = `<div class="sfp-empty">${emptyMsg}</div>`;
      }
    } else {
      filtered.forEach((topic) => {
        const item = createTopicItem(topic, newTopicIds.includes(topic.id));
        feedListEl.appendChild(item);
      });
    }

    _renderPaginationFooter();
    _updateBackTopButton();
  }

  // ========== 话题 URL / 已读状态 / 客户端筛选 ==========
  function _topicBaseUrl(topic) {
    const slug = topic.slug || "topic";
    return `/t/${slug}/${topic.id}`;
  }

  function _hasLastReadPostNumber(topic) {
    return topic.last_read_post_number !== null &&
      topic.last_read_post_number !== undefined &&
      topic.last_read_post_number !== "";
  }

  function _topicListUrl(topic) {
    const baseUrl = _topicBaseUrl(topic);
    if (!_hasLastReadPostNumber(topic)) return baseUrl;

    const lastRead = Number(topic.last_read_post_number);
    const highest = Number(topic.highest_post_number);
    if (!Number.isFinite(lastRead)) return baseUrl;

    let postNumber = lastRead + 1;
    if (Number.isFinite(highest) && postNumber > highest) {
      postNumber = highest;
    }
    if (postNumber < 1) postNumber = 1;
    return `${baseUrl}/${postNumber}`;
  }

  // Discourse topic 列表的已读信号不稳定:优先保留楼层号语义,再用 API 字段兜底。
  function _isTopicRead(topic) {
    if (!topic || !topic.id) return false;
    if (_hasLastReadPostNumber(topic)) {
      const baseUrl = _topicBaseUrl(topic);
      const url = _topicListUrl(topic);
      return url !== baseUrl && url.startsWith(`${baseUrl}/`);
    }
    if (topic.unseen === true) return false;
    if (Number(topic.new_posts) > 0 || Number(topic.unread_posts) > 0) return false;
    if (topic.is_seen === true || topic.unseen === false) return true;
    if (Number(topic.new_posts) === 0 && Number(topic.unread_posts) === 0) return true;
    return false;
  }

  function _hasUnreadMarker(topic) {
    return !_isTopicRead(topic);
  }

  function _applyReadMarker(topic) {
    topic.unread_posts = 0;
    topic.new_posts = 0;
    topic.unseen = false;
    topic.is_seen = true;
    if (topic.highest_post_number) {
      topic.last_read_post_number = topic.highest_post_number;
    }
  }

  function markTopicAsRead(topic, itemElement) {
    if (!_hasUnreadMarker(topic)) return;
    _applyReadMarker(topic);
    const existing = allTopics.find((t) => t.id === topic.id);
    if (existing && existing !== topic) _applyReadMarker(existing);
    itemElement.classList.add("sfp-read");
    const dot = itemElement.querySelector(".sfp-unread-dot");
    if (dot) dot.remove();
  }

  // 未读/已读筛选复用 _isTopicRead,避免和渲染、点击后本地 patch 的语义分叉。
  function _applyFilter(topics) {
    if (!hidePinned && currentFilter === "all") return topics;

    let result = topics;
    if (hidePinned) {
      result = result.filter((t) => !t.pinned && !t.pinned_globally);
    }
    if (currentFilter === "unseen") {
      result = result.filter((t) => _hasUnreadMarker(t));
    }
    if (currentFilter === "read") {
      result = result.filter((t) => !_hasUnreadMarker(t));
    }
    return result;
  }

  function _buildCategoryBadge(categoryId) {
    const meta = _getCategoryMeta(categoryId);
    if (!meta?.name) return "";

    const styleParts = [
      `--category-badge-color: #${_normalizeHexColor(meta.color, "888")}`,
      `--category-badge-text-color: #${_normalizeHexColor(meta.text_color, "FFFFFF")}`,
    ];
    if (meta.parent_category_id && meta.parent_color) {
      styleParts.push(`--parent-category-badge-color: #${_normalizeHexColor(meta.parent_color, "888")}`);
      styleParts.push(`--parent-category-badge-text-color: #${_normalizeHexColor(meta.parent_text_color, "FFFFFF")}`);
    }

    const styleType = _safeCategoryStyleType(meta.style_type, !!meta.icon);
    const categoryClasses = ["badge-category"];
    if (meta.read_restricted) categoryClasses.push("restricted");
    if (meta.parent_category_id) categoryClasses.push("--has-parent");
    categoryClasses.push(`--style-${styleType}`);

    const dataParent = meta.parent_category_id
      ? ` data-parent-category-id="${Number(meta.parent_category_id)}"`
      : "";
    const title = meta.description_text || meta.description_excerpt || "";
    const titleAttr = title ? ` title="${escapeAttr(title)}"` : "";
    const iconHtml = styleType === "icon" && meta.icon ? _svgIcon(meta.icon) : "";
    const lockHtml = meta.read_restricted ? _svgIcon("lock") : "";

    return `<span class="badge-category__wrapper sfp-category-badge" style="${styleParts.join("; ")}"><span data-category-id="${Number(meta.id)}"${dataParent} data-drop-close="true" class="${categoryClasses.join(" ")}"${titleAttr}>${iconHtml}${lockHtml}<span class="badge-category__name" dir="auto">${escapeHtml(meta.name)}</span></span></span>`;
  }

  function _buildTagBadge(tag) {
    const tagName = _tagDisplayName(tag);
    if (!tagName) return "";

    const tagStyle = _getTagStyle(tag);
    const classes = ["discourse-tag", "box", "sfp-tag"];
    if (tagStyle?.hasIcon) classes.push("discourse-tag--tag-icons-style");
    const styleAttr = tagStyle?.cssText ? ` style="${tagStyle.cssText}"` : "";
    const iconHtml = tagStyle?.hasIcon
      ? `<span class="tag-icon">${_svgIcon(tagStyle.icon)}</span>`
      : "";

    return `<span class="${classes.join(" ")}"${styleAttr}>${iconHtml}${escapeHtml(tagName)}</span>`;
  }

  // ========== 创建帖子项 ==========
  function createTopicItem(topic, isNew = false) {
    const item = document.createElement("div");
    item.className = "sfp-topic-item";
    item.dataset.topicId = topic.id;
    if (topic.pinned || topic.pinned_globally) {
      item.classList.add("sfp-pinned");
    }
    if (_isTopicRead(topic)) {
      item.classList.add("sfp-read");
    }
    if (isNew) {
      _triggerTopicHighlight(item);
    }

    // 获取用户信息
    let avatarUrl = "";
    let name = "";
    let username = "";
    if (topic.posters && topic.posters.length > 0) {
      const userId = topic.posters[0].user_id;
      const user = usersMap[userId];
      if (user) {
        name = user.name || "";
        username = user.username || "";
        if (user.avatar_template) {
          avatarUrl = getAvatarUrl(user.avatar_template, 45);
        }
      }
    }

    // 头像 HTML
    const avatarHtml = avatarUrl
      ? `<img class="sfp-topic-avatar" src="${avatarUrl}" alt="${escapeHtml(username)}" loading="lazy">`
      : "";

    // 显示名称
    const displayName = name && name !== username
      ? `<span class="sfp-topic-name">${escapeHtml(name)}</span>`
      : "";

    const statusBadgesHtml = _topicStatusBadgesHtml(topic);

    // 标题
    const closedHtml = topic.closed
      ? '<span class="topic-statuses"><span title="此话题已被关闭;不再接受新回复" class="topic-status --closed"><svg class="fa d-icon d-icon-lock svg-icon fa-width-auto svg-string" width="1em" height="1em" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"><use href="#lock"></use></svg></span></span>'
      : "";

    // 分类
    const categoryHtml = _buildCategoryBadge(topic.category_id);

    // 标签
    let tagsHtml = "";
    if (topic.tags && topic.tags.length > 0) {
      const tagItems = topic.tags.slice(0, 3).map((tag) => {
        return _buildTagBadge(tag);
      }).join("");
      tagsHtml = `<span class="sfp-topic-tags">${tagItems}</span>`;
    }

    item.innerHTML = `
      <div class="sfp-topic-header">
        ${avatarHtml}
        <div class="sfp-topic-meta-col">
          <div class="sfp-topic-user-info">
            ${displayName}
            <span class="sfp-topic-username">${escapeHtml(username)}</span>
          </div>
        </div>
        ${statusBadgesHtml}
        <span class="sfp-topic-time">${_topicTimeHtml(topic)}</span>
      </div>
      <div class="sfp-topic-title"><span class="sfp-topic-title-line">${closedHtml}${escapeHtml(topic.unicode_title?.trim() || topic.title)}</span></div>
      <div class="sfp-topic-category-tags">
        ${categoryHtml}
        ${tagsHtml}
      </div>
      <div class="sfp-topic-stats">
        ${_topicStatsHtml(topic)}
      </div>
    `;

    // 点击跳转
    item.addEventListener("click", (e) => {
      if (e.button !== 0) return;
      const targetUrl = _topicListUrl(topic);
      markTopicAsRead(topic, item);
      navigateTo(targetUrl);
    });

    // 中键新标签页
    item.addEventListener("auxclick", (e) => {
      if (e.button === 1) {
        e.preventDefault();
        const targetUrl = _topicListUrl(topic);
        markTopicAsRead(topic, item);
        window.open(toAbsoluteSiteUrl(targetUrl), "_blank");
      }
    });

    return item;
  }

  // ========== 回到顶部 ==========
  function _buildBackTopButton() {
    const btn = document.createElement("button");
    btn.className = "sfp-back-top-btn";
    btn.type = "button";
    btn.title = "回到顶部";
    btn.setAttribute("aria-label", "回到顶部");
    btn.innerHTML = `<svg viewBox="0 0 24 24" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"><path d="M12 5.5 5.5 12l1.4 1.4 4.1-4.1V20h2V9.3l4.1 4.1 1.4-1.4L12 5.5z"/></svg>`;
    btn.addEventListener("click", () => {
      if (!feedScrollEl) return;
      feedScrollEl.scrollTo({ top: 0, behavior: "smooth" });
      _updateBackTopButton();
    });
    return btn;
  }

  function _updateBackTopButton() {
    if (!feedBackTopBtn || !feedScrollEl) return;
    const show = feedScrollEl.scrollTop > feedScrollEl.clientHeight;
    feedBackTopBtn.classList.toggle("visible", show);
  }

  // ========== 无限滚动加载 ==========
  function _setupScrollLoadMore() {
    if (!feedScrollEl) return;
    if (feedScrollAbortController) feedScrollAbortController.abort();
    feedScrollAbortController = typeof AbortController === "function" ? new AbortController() : null;
    const listenerOptions = feedScrollAbortController
      ? { passive: false, signal: feedScrollAbortController.signal }
      : { passive: false };
    const passiveListenerOptions = feedScrollAbortController
      ? { passive: true, signal: feedScrollAbortController.signal }
      : { passive: true };

    feedScrollEl.addEventListener("wheel", (e) => {
      if (!feedScrollEl || e.deltaY === 0) return;

      const { scrollTop, scrollHeight, clientHeight } = feedScrollEl;
      const canScroll = scrollHeight > clientHeight;
      const atTop = scrollTop <= 0;
      const atBottom = Math.ceil(scrollTop + clientHeight) >= scrollHeight;
      const scrollingUp = e.deltaY < 0;
      const scrollingDown = e.deltaY > 0;

      if (!canScroll || (scrollingUp && atTop) || (scrollingDown && atBottom)) {
        e.preventDefault();
        e.stopPropagation();
      }
    }, listenerOptions);

    feedScrollEl.addEventListener("scroll", _updateBackTopButton, passiveListenerOptions);

    feedScrollEl.addEventListener("scroll", debounce(() => {
      if (!feedScrollEl || !hasMorePages || isLoadingMore) return;
      const { scrollTop, scrollHeight, clientHeight } = feedScrollEl;
      if (scrollHeight - scrollTop - clientHeight < 200) {
        loadMoreTopics({ source: "auto" });
      }
    }, 300), passiveListenerOptions);
  }

  // ========== 加载更多辅助 ==========
  function _renderPaginationFooter({ note = "" } = {}) {
    if (!feedListEl) return;
    _removePaginationFooter();

    if (note) {
      const noteEl = document.createElement("div");
      noteEl.className = "sfp-load-more-note";
      noteEl.textContent = note;
      feedListEl.appendChild(noteEl);
    }

    if (hasMorePages) {
      const loadMoreEl = document.createElement("div");
      loadMoreEl.className = "sfp-load-more";
      loadMoreEl.textContent = "加载更多";
      loadMoreEl.addEventListener("click", () => {
        loadMoreEl.remove();
        loadMoreTopics({ source: "manual" });
      });
      feedListEl.appendChild(loadMoreEl);
      return;
    }

    _appendNoMore();
  }

  function _showLoadMoreSpinner() {
    _removePaginationFooter();
    const el = document.createElement("div");
    el.className = "sfp-load-more";
    el.innerHTML = `<span class="sfp-load-more-spinner"></span>加载中...`;
    if (feedListEl) feedListEl.appendChild(el);
  }

  function _removePaginationFooter() {
    feedListEl?.querySelectorAll(".sfp-load-more, .sfp-no-more, .sfp-load-more-note").forEach((el) => el.remove());
  }

  function _showNoMore() {
    _removePaginationFooter();
    _appendNoMore();
  }

  function _appendNoMore() {
    const el = document.createElement("div");
    el.className = "sfp-no-more";
    el.textContent = "— 已经到底了 —";
    if (feedListEl) feedListEl.appendChild(el);
  }

  function _showLoadMoreError(error) {
    _removePaginationFooter();
    const el = document.createElement("div");
    el.className = "sfp-load-more sfp-load-more-error";
    el.innerHTML = `
      <span>请求失败</span>
      <button type="button" class="sfp-load-more-retry">重试</button>
    `;
    el.querySelector(".sfp-load-more-retry")?.addEventListener("click", () => {
      loadMoreTopics({ source: "manual" });
    });
    if (feedListEl) feedListEl.appendChild(el);
    console.warn("[SFP] load more failed:", error);
  }

  // ========== RouteWatcher(轻量,仅感知路由,不重建 feed) ==========
  const RouteWatcher = (() => {
    let lastUrl = location.href;
    let observer = null;

    function start() {
      const origPush = history.pushState;
      history.pushState = function () {
        origPush.apply(this, arguments);
        _checkUrlChange();
      };

      const origReplace = history.replaceState;
      history.replaceState = function () {
        origReplace.apply(this, arguments);
        _checkUrlChange();
      };

      window.addEventListener("popstate", () => _checkUrlChange());

      observer = new MutationObserver(() => {
        if (location.href !== lastUrl) {
          _checkUrlChange();
        }

        if (feedModeEnabled) {
          const sidebar = getSidebarElement();
          const resizerMissing = !resizerEl || !sidebar?.contains(resizerEl);
          if (sidebar && (!feedContainer || !sidebar.contains(feedContainer) || !sidebar.classList.contains("sfp-feed-mode") || resizerMissing)) {
            activateFeed();
          }
        }
      });
      const target = document.querySelector("#main-outlet") || document.querySelector(".d-header") || document.body;
      observer.observe(target, { childList: true, subtree: true });
    }

    function _checkUrlChange() {
      const newUrl = location.href;
      if (newUrl !== lastUrl) {
        lastUrl = newUrl;
        if (feedModeEnabled) {
          _updateShowMoreHint();
        }
      }
    }

    function stop() {
      if (observer) observer.disconnect();
    }

    return { start, stop };
  })();

  // ========== 初始化 ==========
  let globalHelpTooltip = null;

  function init() {
    injectStyles();

    globalHelpTooltip = document.createElement("div");
    globalHelpTooltip.className = "sfp-help-tooltip";
    globalHelpTooltip.setAttribute("role", "tooltip");
    globalHelpTooltip.style.display = "none";
    document.body.appendChild(globalHelpTooltip);

    waitForEmber(() => {
      createToggle();

      RouteWatcher.start();

      if (feedModeEnabled) {
        setTimeout(() => activateFeed(), 300);
      } else {
        removeResizer();
        restoreSidebarWidth();
      }
    });
  }

  init();
})();