# DSH 侧边栏现状调研 > 目标:为「写一个重新定义 DSH 侧边栏 UI 与功能的插件」建立事实基线。 > 调研对象:本机安装的 DeepSeek Harness 桌面版 **0.2.0-rc.2**(`@deepseek-ai/dsh-desktop-runtime`)。 > 所有结论都标注了来源;`[实证]` 表示我用脚本在本机跑出来的结果,`[文档]` 表示包内 README,`[代码]` 表示读打包后的实现。 --- ## 0. 一句话结论 **DSH 的「侧边栏」不是一个组件,而是三个层次拼出来的插件组合:** 1. `dsh-client-ui-layout` 声明一个三栏骨架,并开出三个「座位」:`sidebar`(左栏)、`main`(中栏,keyed)、`rightbar`(右栏)。 2. `dsh-client-ui-sidebar` 占住 `sidebar` 座位,渲染导航壳(品牌行 / 新建会话 / 全局面板入口 / 工作区区域 / 底部设置),**并在自己的注册里声明 7 个子座位**。 3. `dsh-client-ui-workspace`、`dsh-client-ui-settings`、`dsh-client-ui-plugin-manager` 等插件再填进那 7 个子座位。 所以「重新定义侧边栏 UI」在 DSH 里是一件**一等公民能力**:只要写一个新插件接管 `sidebar` 座位,就能整体替换外观而保留全部既有功能。代价和边界见 §7 —— 其中最关键的一条是:**接管者无法「继承」官方外壳声明的子座位**,所以完整替换必须把官方外壳从组合里摘掉。 另外两点先说清楚: - **右侧栏是另一个独立体系**(`dsh-client-ui-sidebar-right` + `dsh-client-ui-dockkit`),和左侧栏只共享布局列,不共享代码。 - **你机器上装了第三方插件 `dsh-better-sidebar@0.21.1`,但它当前是「装了但没生效」的状态**:不在 `dsh.profile.bundles` 里,而且被 peer 版本闸门在启动时拒绝加载(详见 §9.1)。所以**现在你看到的右侧栏是官方原版**,没有和任何第三方重叠——两条侧边栏都还没被人改造过。 --- ## 1. 相关包与角色 版本统一为 `0.2.0-rc.2`(来自 app.asar 内 `/dsh/package.json`)。 | 包 | 角色 | 关键导出 | |---|---|---| | `dsh-client-ui-layout` | 三栏骨架所有者、`ctx.layout` 服务、主题呈现 | `sidebar` / `main` / `rightbar` / `shell.overlay` / `shell.leading` 五个座位 | | `dsh-client-ui-sidebar` | **左侧栏外壳** | 占 `sidebar`,声明 7 个子座位 | | `dsh-client-ui-workspace` | **侧边栏内容**:工作区/会话浏览器 | 占 `sidebar.workspaces`;`ctx.uiWorkspace` 服务 | | `dsh-client-ui-settings` | 设置入口行 | 占 `sidebar.settings` | | `dsh-client-ui-plugin-manager` | 侧边栏「插件」全页面板(范例) | 占 `sidebar.panellist` + `main` 的 keyed 项 | | `dsh-client-ui-cordis` | 侧边栏底部一个动作 | 占 `sidebar.footer.action` | | `dsh-client-ui-brand-official` | 品牌 slot 的官方占用者 | 占 `sidebar.brand.mark` / `sidebar.brand.name` | | `dsh-client-ui-sidebar-right` | **右侧栏**(每会话一个停靠面板) | `ctx.sidebarRight`、`ctx.sidebarRightTabs` | | `dsh-client-ui-dockkit` | 右侧栏的分屏/拖拽内核(与产品无关) | — | | `dsh-client-ui-slots` | 槽位**纯内核**(无 React,23KB) | `SlotCore`、`SlotOwnershipError`、`StaleAuthorizationError` | | `dsh-client-ui-renderer` | 槽位的 React 渲染器 + `ctx.slots` 服务 | `ctx.slots.register/inject/renderSlot` | `[代码]` 活跃组合清单见 `@deepseek-ai/dsh-web-app/cordis.patch.yml:261-344`。 --- ## 2. 三栏布局与几何 来源:`[文档]` `dsh-client-ui-layout/README.md` + `[代码]` `lib/client.js:38`、`:319-336`。 ``` ┌──────────────┬────────────────────────────┬──────────────────────┐ │ sidebar │ main │ rightbar │ │ 左栏 │ 中栏(keyed 槽位) │ 右栏(可停靠面板) │ │ 264–420px │ min 400px(有右栏时) │ 45%→70% cap, min300 │ │ 默认 280px │ │ 可 fullscreen │ │ 收起 56px │ │ │ └──────────────┴────────────────────────────┴──────────────────────┘ ``` - 左栏宽度 `sidebar === 0` 表示收起;收起时轨道宽度 `collapsedWidth` 默认 **56px**。 - 收起/展开由 `ctx.layout.toggleSidebar()` 驱动;快捷命令 id `sidebar.left.toggle`(桌面 `Mod+B`)。 - 宽度 < 1024px 自动收起;打开右栏会收起「手动展开」的左栏(除非处于 narrowExpanded)。 - 布局状态**不持久化**,刷新即回到默认(280px,右栏关闭)。 CSS 关键变量(`[代码]` `dsh-client-ui-sidebar/lib/client.js` CSS 块): | 变量 | 含义 | |---|---| | `--dsh-sidebar-inline-padding: 12px` | 左栏左右内边距 | | `--dsw-specific-sidebar-fill` | 左栏底色 | | `--dsh-scrollbar-thumb` / `-hover` | 滚动条缩略图(离开指针时被改成 `transparent`) | | `--dsh-frame-top-clearance` | 顶部预留条(macOS 48px / Windows 标题栏高) | | `--dsh-frame-leading-clearance` | macOS 收起时左上角给窗口控件留的横向净空(160px / 全屏 84px) | | `--dsh-windows-titlebar-height` / `--dsh-windows-sidebar-width` / `--dsh-windows-content-radius` | Windows 标题栏集成 | --- ## 3. 左侧栏外壳(`dsh-client-ui-sidebar`)详解 ### 3.1 渲染出来的 DOM 树 `[代码]` `lib/client.js:262-410`(JSX 逐行核对)。展开态: ``` div.root ← 宽度 style,指针进出控制滚动条 │ class: root [collapsed] [railIn] [fading] [quietBars] ├── div.topStrip [data-window-drag] ← 仅 macOS:置顶条 + 收起按钮 ├── div.logoRow [data-window-drag] │ ├── span.brand.wide | button.brand.wide ← 非 macOS 时整行可点 = 新建会话 │ │ └── span.brandIdentity[aria-hidden] │ │ ├── span.brandMark → renderSlot('sidebar.brand.mark', {size:24}) │ │ └── span.brandName → renderSlot('sidebar.brand.name', {}) │ └── Tooltip > button.iconButton.toggle ← 收起按钮 aria-label + aria-keyshortcuts │ ├── span.railMark → renderSlot('sidebar.brand.mark')(收起、非 Windows) │ ├── IconPanelLeftOutlineRegular.panelIcon (16px wide / 18px rail) │ └── renderSlot('sidebar.toggle.badge') (仅收起时,非交互状态角标) ├── Tooltip > button.newSession ← 新建会话 │ ├── span.newSessionLabelMask > span.newSessionContent │ │ ├── IconNewChatOutlineMedium / Regular │ │ └── span.newSessionLabel(宽态才显示文字) │ └── span.newSessionShortcut(宽态,显示快捷键键帽) ├── nav.panelList [aria-label] ← 仅当有全局面板注册时才渲染 │ └── PanelRow × N ← 来自 sidebar.panellist ├── div.regionArea │ └── renderSlot('sidebar.workspaces', { wide, expandSidebar }) ← 会话/工作区浏览器 └── div.footArea ├── div.footerActions → renderSlot('sidebar.footer.action', { wide }) └── div.settingsArea → renderSlot('sidebar.settings', { wide }) ``` macOS 收起时,`HeaderLeadingControls` 会额外注册进 `shell.leading`(布局层的窗口 chrome 座位),渲染在红绿灯旁边。 ### 3.2 左侧栏声明的 7 个子座位(这是改造的核心接口) `[代码]` `lib/client.js:487-520`: ```js ctx.slots.inject('sidebar', () => ctx.slots.register({ name: 'sidebar', locale: 'sidebar', children: { 'sidebar.brand.mark': { kind: 'single', scope: 'root' }, 'sidebar.brand.name': { kind: 'single', scope: 'root' }, 'sidebar.toggle.badge': { kind: 'single', scope: 'root' }, 'sidebar.panellist': { kind: 'list', scope: 'root' }, 'sidebar.workspaces': { kind: 'single', scope: 'root' }, 'sidebar.settings': { kind: 'single', scope: 'root' }, 'sidebar.footer.action':{ kind: 'list', scope: 'root' }, }, inject: injectProps, }, SidebarRoot)); ``` | 座位 | kind | 占用者 | occupant 收到的 props | |---|---|---|---| | `sidebar.brand.mark` | single | `ui-brand-official`(可无) | `{ size: 24 }` | | `sidebar.brand.name` | single | `ui-brand-official`(可无) | `{}` | | `sidebar.toggle.badge` | single | 无 | `{}` | | `sidebar.panellist` | list | `ui-plugin-manager` 等 | `{ size, active }` | | `sidebar.workspaces` | single | **`ui-workspace`** | `{ wide, expandSidebar }` | | `sidebar.settings` | single | `ui-settings` | `{ wide }` | | `sidebar.footer.action` | list | `ui-cordis` 等 | `{ wide }` | `[文档]` 类型契约(npm 包里的 `lib/types/client/contract/slots.d.ts`): ```ts export type SidebarRootComponentProps = & PropsRuntime<'sidebar'> // 来自 layout:collapsed, width & PropsRenderSlots<'sidebar.brand.mark' | 'sidebar.brand.name' | 'sidebar.toggle.badge' | 'sidebar.panellist' | 'sidebar.workspaces' | 'sidebar.settings' | 'sidebar.footer.action'> & InjectFace // startSession / toggleSidebar / selectPanel / hooks & PropsLocale<'sidebar'>; ``` ### 3.3 外壳插件本体 `[代码]` `lib/client.js:436-445`: ```js const inject = ['slots', 'layout', 'uiWorkspace', 'locale', 'shortcuts']; function apply(ctx) { … } ``` - 监听 `sidebar.panellist` 的注册变化,投影成有序的面板元数据(`ctx.slots.entriesOfSlot` + `ctx.slots.subscribe`)。 - `injectProps()` 提供 `startSession(workspaceId?)`、`toggleSidebar()`、`selectPanel(id)`,以及 `hooks.panels` / `hooks.shortcuts` 两个可观察源。 - `lib/index.js` 是宿主侧入口,空实现(纯浏览器插件)。 ### 3.4 交互与平台分支(重写时必须复刻) `[文档]` `README.md:34-56`,`[代码]` CSS 块: - **品牌行点击 = 新建会话**(非 macOS);macOS 下品牌行不可点,只能用专用 New Session 按钮。 - **收起动画**:内容在当前位置淡出 → 上部控件整体左移进 56px 轨道(`rail-in .15s var(--ds-ease-in-out)`,起始 `translate(49px)`)→ 布局列滑动收尾。`prefers-reduced-motion` 关闭全部过渡。 - **滚动条**:指针不在栏内时把 `--dsh-scrollbar-thumb` 置为 `transparent`,离开后再保留 2s;预留宽度由 `ui-workspace` 负责,所以显隐不会让行位移。 - **Windows**:`html[data-windows-titlebar]` 下收起栏整列消失,收起按钮固定在标题栏左上角,新建会话按钮移到 `left:48px`,两者为 28px 圆形;侧栏通过 `--dsh-windows-menu-start` 给桌面菜单让位。 - **macOS**:`html[data-platform='darwin']` 下顶部 52px 条避开 hiddenInset 红绿灯;整列在收起时完全隐藏(不留 56px 轨道),改由 `shell.leading` 座位提供重新打开按钮。 ### 3.5 几何常量 | 项 | 展开 | 收起(56px 轨道) | |---|---|---| | root padding | `6px 12px` | `18px 10px 6px` | | logoRow 高 | 60px(下边距 4px) | 36px(下边距 12px) | | 图标按钮 | 28×28 | 36×36 | | 新建会话按钮 | 常规 | 36×36 透明底 | --- ## 4. 左侧栏的内容:会话/工作区浏览器(`dsh-client-ui-workspace`) `[文档]` `README.md` `[代码]` `lib/client.js`。这是用户实际使用频率最高的一块。 **能做什么**:分组的会话列表 / 扁平列表、选工作区新建会话、工作区的新增/重命名/排序/删除、会话的重命名/复制(fork)/归档/取消归档/置顶/拖拽排序、搜索、展示待处理交互。 **几个值得注意的行为**: - 展开的工作区默认显示 **5 条**空闲非空会话;运行中的会话不受配额限制。每次「显示更多」+5。 - 排序模式:**最后更新**(按最后一条用户输入时间,置顶的排最前)与**手动**(保存的完整顺序)。切换回最后更新会丢弃手动布局。 - 分组模式:**按工作区分组**(默认)或**工作区树**(按最近的已注册祖先嵌套);分组与折叠状态存在浏览器本地,工作区组的顺序是 Host 持久化的。 - 搜索:折叠态是一个 header 动作,展开后横跨 header;标题/工作区子串立即匹配,250ms 防抖后再请求 Host 做正文检索;结果上限 20。 - 行状态:`pendingInteraction` 分类(等待审批 / 计划待审 / 等待回答)→ 警告点,并把更新时间替换成文字;空闲无点,未读完成用 done 点。 - **可供插件扩展的行内座位**(重要): - `sidebar.session.row.leading`(single,仅行主状态为 idle 时渲染;日程标记就在这里) - `sidebar.session.row.hover`(list) - `sidebar.workspaces.session.row.action`(list) - `sidebar.workspaces.session.menu.item`(list)← 置顶/重命名/复制/归档本身就是这个 list 的条目 - `sidebar.workspaces.directoryFlow`(single) - 导航服务:`ctx.uiWorkspace.openSession(target)`、`openWorkspace(id, beforeOpen?)`、`forkSession(id)`、`startSession(workspaceId?)`。 - 主视图服务:`ctx.layout.selectPanel(id)`,`null` = 回到会话;未注册的 id 会抛错且不改变当前选择。 **实现细节(重写时要知道的)**: - **无虚拟化**:列表是全量渲染的,性能靠 `AnimatedRows`(class 组件,100ms 淡入 / 200ms 滑动,用 FLIP + `element.animate`)。 - 树形缩进用行内变量 `--dsh-workspace-indent: Npx`,**每层 12px**。 - 视图状态持久化在浏览器本地,key 是 **`dsh.workspace.view.v5`**(`{ groupBy, orderBy, groupExpansion, sessionOrderByAccount, archivedFilter }`)。 - 状态点优先级:`approval | plan-review | question`(warning 点)> `running` > 有子代理活动 > `done`(未读完成)> `idle`(无点)。 - 会话行的 `data-row-key` 形如 `workspace:` / `session:` / `overflow:` / `empty`——皮肤和自动化都该用这个选择器。 - 工作区浏览器自己又声明了 5 个座位:`sidebar.workspaces.directoryFlow`、`sidebar.workspaces.session.menu.item`(内置 pin/rename/fork/archive)、`sidebar.workspaces.session.row.action`、`sidebar.session.row.leading`、`sidebar.session.row.hover`。 - 外层 `sidebar` 座位**由 ui-layout 声明**(owner props `{collapsed, width}`);注册到它 = 替换整列,并让上面这 8 个子座位的声明全部失效。 --- ## 5. 右侧栏(`dsh-client-ui-sidebar-right`)——与左侧栏是两套东西 `[文档]` `README.md`。 - 结构:每会话一个**停靠表面**(docking surface),由 `dsh-client-ui-dockkit` 提供分屏树/拖拽/浮动面板。 - 两种呈现:`push`(默认,占右列,会话让宽)与 `fullscreen`(覆盖视口,保留底下的宽屏列)。视口 < 768px 时自动全屏。 - 面板**没有 header 行**:呈现切换和收起按钮挂在右上分屏 tab 条的最右端。 - 右栏隐藏时,会话 header 角落座位 `conversation.session.header.corner` 放一个展开按钮。 - 扩展点(服务 + 座位): - **`ctx.sidebarRightTabs` —— tab 类型注册表(推荐入口)** ```js ctx.sidebarRightTabs.register({ id, kind, // 一个实现 id = 注册表身份 = 座位 key priority: 'extension', // 档位:extension > builtin > fallback patterns?, canOpen?, // canOpen 控制能否接管某个资源 title, guide?, keepMounted?, }) // 返回 disposer ``` 每种 `kind` 同时最多一个 builtin + 一个 extension;**extension 生效时 builtin 让位,extension 卸载后 builtin 自动恢复**——这是 DSH 里最干净的「替换某类页面」机制,不用遮蔽任何座位。 - `ctx.sidebarRight` —— 导航控制器(`openTab` / `openResource` / `close` / `active` / `isExpanded` / `toggleExpanded` / `mounted`) - 座位:`sidebar.right.pane.tab`(+ `.title`,按实现 id 作 key)、`sidebar.right.tab.guide` / `.guide.entry`、`sidebar.right.tab.menu.item`、`sidebar.right.tab.files.actions`、`sidebar.right.tab.document.*`、`sidebar.right.toggle` - 呈现控制:`ctx.layout.openRightbar(track, fullscreen)` / `closeRightbar()`。 - ⚠️ **要等「服务」而不是「座位声明」**:座位声明早于服务 provide,如果你用 `slots.inject('sidebar.right.pane.tab', …)` 去触发注册,可能永远不执行。正确姿势是 `ctx.inject(['sidebarRightTabs'], …)`。 - ⚠️ 不要在会话列表里读「当前会话」(列表没有这个字段),用 `ctx.sidebarRight.mounted` 作为唯一可信来源,并按会话排队。`[我推断]` --- ## 6. 槽位系统:整个 UI 的扩展机制 `[文档]` `dsh-client-ui-slots/README.md`,`[代码]` `lib/index.js`、`dsh-client-ui-renderer/lib/client.js`。 **四种 kind**:`single`(一个占用者)、`list`(有序多条目)、`keyed`(按键分发)、`chain`(条目自选举,带 `priority` + `select`)。 **核心规则(都已在代码中核对)**: 1. **声明即认领**:只有声明了某个子槽的注册者才能 `renderSlot` 它。 `[代码]` renderer `lib/client.js:330-333`: ```js const declared = entry.children?.[key]; if (declared === void 0) throw new SlotOwnershipError(`slot '${key}' is not declared by this entry's children`); ``` 即 **A 组件无法渲染 B 声明的槽**,即使那个槽已经被声明、也已经有人注册进去了。 2. **`ctx.slots.inject(key, cb)` = 等声明**:key 一旦被声明就执行 `cb`;声明消失后再出现会重新执行。这就是「替换者无论先加载还是后加载都能生效」的机制。`[代码]` renderer `:1343-1385`。 3. **`single` 槽位可以按 priority 遮蔽**:同优先级重复注册会抛错,**不同优先级则允许共存,数值最小者渲染**。 `[代码]` slots `lib/index.js:167-172`、`:221`、`:263-293`。 4. **遮蔽者不能重复声明别人的子槽**:`[代码]` `lib/index.js:191-194`,子槽已被声明就抛 `slot "sidebar.brand.mark" is already declared`。 5. **降级容错**:渲染崩溃时该条目会「abdicate」(退位),同格的下一个条目接管 —— 所以遮蔽者崩了,被遮蔽的原件会自动回来。`[代码]` `lib/index.js:436-460`。 --- ## 7. 实证:到底能不能「换掉」侧边栏? 我用真实的 `SlotCore` 跑了一次实验(脚本:`tools/slot-shadow-probe.mjs`),复刻「ui-layout 声明 sidebar → ui-sidebar 占位并声明 7 个子槽 → ui-workspace 填 sidebar.workspaces → 新插件尝试接管」: | 尝试 | 结果 | |---|---| | 同优先级再注册 `sidebar` | ❌ 抛错:`single slot "sidebar" already has a registration at priority 0 — register at a different priority to shadow it (lowest renders)` | | `priority: -1` **且声明同样的子槽** | ❌ 抛错:`slot "sidebar.brand.mark" is already declared` | | `priority: -1` **且不声明子槽** | ✅ 注册成功,**且成为唯一渲染者**(最低优先级获胜) | | `priority: -1` 遮蔽**叶子座位**(如 `sidebar.workspaces`) | ✅ 成功,且**无冲突**——叶子座位没有子槽声明,是最安全的替换方式(路线 A+) | | 被遮蔽的原件仍在账本上时 | 它声明的子槽**依然有效**,`ui-workspace` 仍能正常注册进 `sidebar.workspaces` | | 被遮蔽的原件被卸载后 | 它声明的子槽随之塌陷,填入的占用者一起死亡 | **补充机制(来自 `dsh-cordis-client-runner`,已核对源码)**:DSH 里没有 `replace()` API,替换 = 优先级遮蔽。对**动态包**(运行时由 Agent 写入并加载的包),runner 会给每次非 chain 注册**自动分配递减优先级**: ```js // dsh-cordis-client-runner/lib/client.js:275-280, 615 const spec = slots.spec(slot); if (spec === void 0 || spec.kind !== "chain") { options.priority = env.allocatePriority(); // allocatePriority: () => --this.nextPriority } ``` `nextPriority` 从 0 开始,所以动态包第一次注册拿到 `-1`,**自动压过所有官方条目(官方为 0)**。 ⚠️ 但这是**动态包专用**的保护代理(`guardedSlots`);**以 profile bundle 形式安装的普通插件走的是直连 `ctx.slots`,不会自动降优先级**,必须自己显式传 `priority: -1`,否则同优先级重复注册会直接抛错。 **结论:四条可行路线** ### 路线 A —— 增量扩展(风险最低,先做这个) 保留官方外壳,只用现成座位 + 设计令牌。 - 加功能:`sidebar.footer.action`、`sidebar.panellist`(+ 一个 `main` keyed 面板)、`sidebar.session.row.leading/hover`、`sidebar.workspaces.session.*`、`sidebar.right.*`。 - 改观感:覆盖 `--dsw-*` / `--dsh-*` 变量,用 `[data-slot="…"]` / `data-row-key` 选择器写样式。 - 优点:不动结构,官方升级不易崩。缺点:**改不了外壳结构**(品牌行、新建会话按钮、区域的顺序与几何都是写死的),56px 轨道也是布局层的决定。 ### 路线 A+ —— 整体换掉「内容区」而不是外壳 `sidebar.workspaces` 是 `single` 叶子座位,用 `priority: -1` 遮蔽它,就能用自己的组件完全替换会话/工作区浏览器,同时保留官方外壳的品牌行/新建会话/设置行。 - 座位是叶子、没有子槽声明冲突,是**唯一能安全遮蔽的官方座位类型**。 - 代价:要自己实现工作区树、分组、搜索、排序、拖拽、状态点等(数据可以从 `useSessions` / `useWorkspaces` / `ctx.uiWorkspace` 拿到)。 - 同理可换:`sidebar.settings`、`sidebar.brand.mark`、`sidebar.brand.name`。 ### 路线 B —— 整体替换外壳(真正的「重新定义 UI」) 在自己的 bundle patch 里把 `@deepseek-ai/dsh-client-ui-sidebar` 设为 `disabled: true`,然后自己 `ctx.slots.inject('sidebar', …)` 注册一个**声明完全相同 7 个子槽**的新外壳。 - 优点:**100% 外观自由**,同时 `ui-workspace`、`ui-settings`、`plugin-manager`、`brand-official` 全部照常工作,功能零损失。 - 代价:要自己复刻收起动画、`reduced-motion`、macOS hiddenInset 顶条、Windows 固定标题栏几何、滚动条策略、无障碍属性;并满足 §3.2 的 props 契约。 - **必须禁用原件**:不禁用就会子槽声明冲突(见上表)。若只遮蔽不禁用,官方子槽的 `slots.inject` 注册会永远 pending,会话树和设置行都不会出现。 - 顺带说明:`root` 座位**绝对不要**注册——官方 slot 目录有明确警告:`root` 是 `single`,二次注册会遮蔽 AppFrame,整个页面只剩你的组件。 ### 路线 C —— 纯优先级遮蔽(不推荐) `priority: -1` 占住 `sidebar` 但**不声明**任何子槽。 - 结果:你能画自己的侧边栏,但**无法渲染官方的会话树/设置行**(`renderSlot` 只允许渲染自己声明的子槽,否则抛 `SlotOwnershipError`),它们变成孤儿。 - 只适合完全自给自足、不需要任何既有座位的极端设计,而且原件一旦卸载/崩溃你的 UI 会连带失效。 --- ## 8. 样式接口:能依赖什么、不能依赖什么 `[实证]` 我拆了外壳包的 CSS。 **❌ 不能依赖**:类名是 **CSS Modules 哈希**,每次构建都会变,例如 左栏 `._3WPZCG_root`、工作区 `._7514NG_list` / `.jJkEga_sessionRow`、品牌 `.DL-xwG_iconButton`、插件管理 `.ZVcBiW_card`。写 `.root { … }` 这类皮肤必然跨版本失效。 (找到官方样式表的办法:`