CC Switch 五个功能面板共用一副骨架

2026-08-31

src/components/ 的时候很容易先入为主:skills、prompts、mcp、proxy、universal 这几个目录管的事情差得很远——一个管技能包的发现与安装,一个管提示词,一个管 MCP 服务器条目,一个管本地代理与故障转移,一个管统一供应商配置。功能不搭界,按常理代码也该各写各的。但真去数共享组件的引用,会看到另一幅图:它们既不是五份互不相干的实现,也不是”抽象出一个通用面板套五次”,而是一条从”全用”到”一件不用”的复用梯度。这篇文章要拆的就是这条梯度——每一层复用的到底是什么,以及为什么有两个面板停在了半路。

先说清楚这篇的依据

本文对应 CC Switch 的 v3.20.1 快照,仓库 commit 前缀 3217f725,核对日 2026-08-31。所有结论来自静态阅读 src/ 下的源码、docs/ 下的用户手册,以及与上一个版本 v3.19.2 的目录 diff。我们没有安装、编译或运行过这个桌面应用,因此下文不会出现任何关于界面外观、按钮位置、切换快慢的描述;能写的只有”这段代码写了什么、它约束了什么”。凡是引用行号的地方,读者都可以自己去仓库对应文件里翻到那一行。

第零层:五个面板都不直连后端

先立一个前提,因为后面几层复用都建立在它上面。在这五个目录里执行 grep -rn "invoke(" src/components/{skills,prompts,mcp,proxy,universal} | wc -l,结果是 0。一次都没有。

这意味着组件层完全不碰 Tauri 的 invoke,一律经 src/lib/api/*src/hooks/* 中转。各面板的落点不太一样:skills 走 src/hooks/useSkills.tssrc/lib/api/skills.ts;mcp 走 src/hooks/useMcp.tssrc/lib/api/mcp.ts;proxy 走 src/lib/query/proxy.tssrc/lib/query/failover.ts 这两层 query 封装;universal 则直接用 src/lib/api/providers.ts:213-248 里的 universalProvidersApi。路径各异,但”组件不认识 IPC”这条边界是齐的。

这个 0 值得单独拎出来,是因为它让后面的复用有了共同前提:既然所有面板拿数据的姿势都被收进同一层,那么面板之间真正的差异就只剩下”这一堆数据长什么样、要摆成什么结构”。

第一层:共享组件的使用矩阵

src/components/common/ 下放的是跨面板共享件,v3.20.1 的构成是 FullScreenPanel.tsxAppCountBar.tsxAppToggleGroup.tsxManagementListSearch.tsxListItemRow.tsx(这份清单本身会随版本增删)。谁用了谁,可以用一条循环 grep 复现:

for c in AppCountBar AppToggleGroup ListItemRow ManagementListSearch FullScreenPanel; do
  grep -rl "$c" src/components/{skills,prompts,mcp,proxy,universal}
done

结果排成矩阵是这样:

共享组件skillspromptsmcpproxyuniversal
FullScreenPanel
AppCountBar
AppToggleGroup
ListItemRow
ManagementListSearch

这张表要横着读,读出来的是梯度而不是有无:skills 和 mcp 五件全用,几乎是一个模具压出来的;prompts 用三件;universal 只用一件;proxy 一件都不用。

缺的那两件是同一类东西。AppCountBarAppToggleGroup 都以”应用”为轴——前者按应用列出计数徽章,后者是一排按应用的小图标开关。提示词在这个软件里是按当前应用单独管理的,压根不存在”同一条提示词在几个 CLI 上分别开关”的跨应用矩阵,所以 prompts 用不上这两件,不是漏用。

第二层:为什么 proxy 一件都不用

proxy 的位置本身就和其它四个不一样。skills、prompts、mcp、universal 都是 src/App.tsxtype View(116 行起)的一个分支,由 1009 行起的那个 switch (currentView) 分发;proxy 没有自己的顶层视图,它的三个主要组件寄居在设置页的”代理”Tab 下——ProxyPanelFailoverQueueManagerAutoFailoverConfigPanel 分别挂在 src/components/settings/ProxyTabContent.tsx 的 129、206、212 行。

位置不同带来的是概念不同:其余四个面板的核心动作都是”新建/编辑一条实体”——一条 Skill、一条提示词、一台 MCP 服务器、一个供应商条目;proxy 编辑的是全局配置和按应用的一组参数,没有”实体”这个东西。没有实体,就不需要列表行(ListItemRow)、不需要在列表里搜(ManagementListSearch)、不需要按应用统计条目数(AppCountBar),也不需要一个承载新建表单的全屏面板(FullScreenPanel)。参数表单直接内联在 Tab 里就够了。

同一个原因还解释了另一处:grep -rln "ConfirmDialog" src/components/{skills,prompts,mcp,proxy,universal} 里,skills、prompts、mcp、universal 都有命中,proxy 没有。删一条实体需要二次确认,改一个数字不需要。

顺带一提这个仓库的桶文件(index.ts)并不统一:src/components/proxy/index.ts:5 只 re-export 了 ProxyPanel 一个,其余六个组件由消费方按文件路径直接引入(src/App.tsx:74-77 引了四个顶栏组件,ProxyTabContent.tsx:13-15 引了三个);universal/index.ts 是三个全导出;skills 与 prompts 根本没有 index。这三种做法并存,我们只陈述现状,不推断谁该向谁看齐。

第三层:FullScreenPanel 封装的是”窗口级”问题

复用梯度里最先被用上的那一件是 FullScreenPanel,四个面板都有它,只有 proxy 没有。它值得单独说,因为它封装的东西恰好是”每个面板自己写都会写漏”的那一类。

src/components/common/FullScreenPanel.tsx 全文,它至少解决了四件事:

  • portal 出去:内容 createPortaldocument.body(110 行发起,199 行是目标节点),不受调用处 DOM 层级影响。
  • 带引用计数的滚动锁(33-50 行):模块级 bodyScrollLockCount 计数,首次加锁前记录原始 overflow,计数归零才还原。这一条是为嵌套场景准备的——面板套面板时,里层关闭不会把外层的滚动锁一起解掉。
  • ESC 的三重让路(84-108 行):event.defaultPrevented 为真说明 Radix 的 Select/Dialog 已经消费过这次 ESC,不管;焦点在可编辑元素里(isTextEditableTarget)也不管,让输入框自己处理;真正处理完还要 stopPropagation,避免冒泡到 App 的全局监听再关一层。
  • 平台差异DRAG_BAR_HEIGHT = isWindows() || isLinux() ? 0 : 28(30 行),即 Windows 与 Linux 下不留系统拖拽占位条,同文件 HEADER_HEIGHT = 64,两行注释都写着”match App.tsx”。另外 motionPreset?: "fade" | "slide-from-right"(22 行)两种入场方式,在 useReducedMotion() 为真时一律降级。这些都是 v3.20.1 的默认取值,可改且会随版本变。

这四件事和面板管什么业务毫无关系,全是”一个覆盖全屏的容器该怎么表现”。抽这一层的收益不在少写几行 JSX,而在于四个面板不会各自漏掉其中一条。

第四层:句柄、忙碌态、写锁

再往上还有三层共性,一层比一层用的人少。

命令式句柄。 skills、mcp、prompts 都用 forwardRef + useImperativeHandle 把动作交给外部顶栏:UnifiedSkillsPanel.tsx:67-73 暴露 openDiscovery / openImport / openInstallFromZip / openRestoreFromBackup / checkUpdates,通过同文件 606-620 行挂出去;mcp 的 UnifiedMcpPanelHandleUnifiedMcpPanel.tsx:58-61)暴露 openAdd / openImport;prompts 的两套面板各暴露一个 openAdd。universal 与 proxy 没有句柄。这是”面板内部逻辑 + 外部触发按钮”的解耦方式:按钮渲染在顶栏,逻辑留在面板里。

忙碌态冒泡。 mcp、prompts、skills 各有一个 onInteractionBlockedChange 之类的回调 prop(分别在 UnifiedMcpPanel.tsx:55PromptPanel.tsx:15-17UnifiedSkillsPanel.tsx:57-59),把”我正忙”往上报给 App。三处都配了一个卸载时清零的 effect,防止面板被卸载后 App 侧永远停在 blocked。skills 侧还把粒度拆成两级(147-148 行):navigationBlocked 由写操作、mutation、弹窗决定,interactionBlocked 在它之上再叠一个”正在查更新”——也就是查更新只锁交互、不锁导航。

写操作串行锁。 grep -rln "beginWrite" src/components 恰好命中三个文件:mcp/UnifiedMcpPanel.tsxprompts/PromptPanel.tsxskills/UnifiedSkillsPanel.tsx。三处都是 ref 加 state 双写——ref 同步生效,用来挡住 React 批处理里连点两下都放行的情况;state 供渲染。mcp 与 skills 还各带一个例外参数——mcp 侧叫 allowOpenConfirmationUnifiedMcpPanel.tsx:103),skills 侧叫 allowOpenDialogUnifiedSkillsPanel.tsx:181),默认都是 false,需要放行时由调用处显式传 true,用于”确认弹窗已经开着、此刻执行的正是它的 onConfirm”这种场景。skills 版还多带一个 checkUpdatesLockRef(107 行),把”正在查更新”也算进不许开写的条件里。

universal 与 proxy 这层锁也没有,它们只靠 mutation 的 isPending 去 disable 按钮。到这里,梯度的形状已经很清楚:越靠上层的复用,参与的面板越少。

universal 的五个”唯一”

沿着这条梯度往下看,universal 是个显眼的异类。它有五处”全仓只有它这么干”:

  1. 唯一不用 react-query 的grep -rn "useQuery\|useMutation\|@tanstack" src/components/universal | wc -l = 0。数据加载是手写的 useState + useCallback + useEffectloadProvidersUniversalProviderPanel.tsx:33-48)调 universalProvidersApi.getAll(),每次增删改之后再手动调一次 loadProviders()(74、99、186 行)。其余四个面板的列表刷新都靠 query 失效。
  2. 唯一用网格而不是行列表的:列表容器的类名是 grid gap-4 sm:grid-cols-2 lg:grid-cols-3(261 行),mcp、skills、prompts 用的都是 ListItemRow 或等价的行结构。
  3. 唯一没有搜索的:五个面板里只有它没引用 ManagementListSearch
  4. 唯一”新建即同步”的handleSave(55-86 行)在 if (!editingProvider) 分支里自动追加一句 await universalProvidersApi.sync(provider.id)(60-63 行),成功文案也从”已更新”换成”已添加并同步”。复制条目的 handleDuplicate 同理,upsert 完立刻 sync。
  5. 它还有一个从没被调用的 API 方法universalProvidersApiproviders.ts:213-248 里定义的那几个方法当中,get(id) 全仓零调用(grep -rn "universalProvidersApi.get(" src 无命中)。

第 4 条是这五条里唯一有行为后果的:新建和复制会无条件触发一次向各应用的同步,而手册 docs/user-manual/zh/2-providers/2.1-add.md:299-315 的创建步骤只写到”保存”,“同步机制”那一节讲的是”修改后自动同步”,没提新建这一次是无条件的。两处口径不一致,以我们实读的源码为准。说到这儿就停——不去猜哪边”才是对的”,也不拿这个差异去评价什么。

至于 universal 为什么不合群,源码里没有任何注释解释,我们也不打算替它解释。能确定的只有一件事:它在 v3.19.2 到 v3.20.1 之间零变化diff -rq 两个快照的 src/components/universal 目录无输出),而同期其余四个目录都动过。

应用矩阵的口径由一个配置文件统一

最后一根梁在 src/config/appConfig.tsx。这里定义了全集 APP_IDS,各面板再从中取一份自己的白名单常量——skills 一份、mcp 一份、proxy 一份,另有一份”累加式”应用清单和一份全局默认可见清单。这些数组的成员会随版本增删,所以这里只说机制:新增一个被托管的 AI CLI,主要工作是往对应数组里加一项,而不是改面板代码

比数组本身更有意思的是类型层的写法:appConfig.tsx:88 用的是 Exclude<AppId, ...> 这种排除型,把没有原生 MCP 注册表的应用从 mcp 的 AppId 类型里直接减掉,上一行注释写着 “Pi has no native MCP registry; do not manufacture a disabled mirror.”——不给没有原生能力的应用造一个永远灰着的假开关。ProxyAppId 那条注释同理,写的是 “Apps with a complete local gateway + failover data plane.”

这一层在两个版本之间变动不小。v3.19.2 时 mcp 的应用列表是直接复制 skills 的那一份(源码原文是 export const MCP_APP_IDS: AppId[] = [...SKILLS_APP_IDS];);v3.20.1 已改为两者分家——skills 那份加进了 pi,mcp 那份改成显式数组配排除型,同时新增了 isMcpAppId 之类的运行时守卫,并在 UnifiedMcpPanel.tsx 的两个 toggle 处理函数里各加了一句 if (!isMcpAppId(app)) return;。类型收紧之外再补一道运行时兜底,是这次改动的完整形状。

同一轮统一常量的改动在 proxy 侧没做完:v3.19.2 的 ProxyPanel 接管开关区写的是四个应用的字面量数组,v3.20.1 已改为 PROXY_APP_IDS.map(...)getAppLabel(appType),队列组件的 appType 类型也从 string 收紧成 ProxyAppId;但 ClaudeDesktopRouteToggle.tsx:27-32 里同样的那组四个字面量原封不动,没跟上。这是一处代码内部的不一致,位置具体、可自行核对,我们只记录到这里。

这张表怎么自己重新数一遍

本文所有矩阵都能用前面给出的 grep 复现,命令口径固定,换个版本重跑一次就知道梯度有没有变。需要留意的是:grep -rl 数的是”文件里出现过这个标识符”,不是”运行时真的用上了”——清单里有一条,不等于这条能力对每个应用都可用。判断某个共享件到底怎么被用的,还是得回到调用处那一行去看。

以上命令为按仓库中的目录结构组合的示例,未经实测,以官方文档与实际仓库内容为准。


本文依据 CC Switch 官方仓库(github.com/farion1231/cc-switch)的 README、docs/ 下的用户手册、路由指南与发布说明,以及 src/src-tauri/tests/ 的源码整理,核对日 2026-08-31,对应仓库快照 3217f725(仓库内版本号 3.20.1)。本文内容为仓库源码与文档口径,我们没有安装或运行过这个桌面应用,因此不涉及界面外观、操作手感与切换速度的任何描述。文中出现的阈值与默认值均为源码中的默认配置,不构成对实际运行结果的保证。该项目仍在快速迭代,版本与默认值随时可能变动,请以仓库最新内容为准。

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。

留言讨论

评论发布后会被人工复核,违规内容将被删除。

    还没有人评论,来说说你的看法

    如果发表没有反应,可以前往联系我们告诉我们。

    在 CC Switch 里加一个国内直连的供应商

    力达云网关,注册送 ¥5 额度,一期提供 DeepSeek。

    去添加

    这个页面有问题?

    提交时会附带当前页面地址和浏览器信息,帮助我们定位问题。不填联系方式即为匿名。