UI/细节调整

This commit is contained in:
zhongluofeng
2026-09-22 19:10:16 +08:00
parent b018abd922
commit bccd21fde5
135 changed files with 11393 additions and 5104 deletions
+388
View File
@@ -794,6 +794,394 @@ ZMODEM 剩余价值主要在串口/老旧嵌入式设备。成本收益不成立
3. 连接复用:同主机双标签秒连、关一个另一个不受影响、全关后连接断开。
4. ProxyJump 跳板链、端口转发 -L/-R、`.ppk` 导入、会话模板拉起、AI 助手(需翻译引擎配置)。
### 界面重构与标题口径(2026-09-20)
用户反馈两条:「本地 Shell 的标题显示成了全路径」「终端页像内嵌了一个独立终端页面」。
两条都成立,根因不同,分别处理。
#### 一、会话标题为什么曾经是全路径
标题有两个来源,后者会覆盖前者:
1. 创建时写入的标题(`terminal_open_local``ConPTYSession::spawn`);
2. shell 通过 OSC 0/2 上报的「自定义标题」。
`cmd.exe``powershell.exe` **默认就把自己的可执行文件全路径设成控制台标题**
cmd 运行命令时还会追加 ` - <命令>`),经 PTY 原样到达 → 标签栏与状态栏被顶成
`C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe`。Windows Terminal /
VS Code 同样要过滤这类标题,属于同类问题。
**提权会话还多一层坑**:以管理员身份运行时标题前面会带一个本地化前缀
(简体中文下是 `管理员: `,英文是 `Administrator: `)。首版过滤只判「以盘符开头」,
日常(非提权)会话表现完全正确,只有提权运行时才漏 —— 因此
`osc_title_is_executable_path` 现在对「原串」与「剥掉前缀后的串」各判一次,
并有单测覆盖 `管理员: ` / `Administrator: ` / 全角冒号三种形态。
修复分四处:
| 改动 | 位置 | 理由 |
|---|---|---|
| 过滤「绝对路径 + `.exe`/`.com`」形态的 OSC 标题(含提权前缀) | `shell.rs::osc_title_is_executable_path` + `session.rs` | 判据收紧到绝对路径,WSL/Git Bash 的 `user@host: ~/dir``/usr/bin/htop` 必须保留 |
| **标题改用 `shell::session_label()`**(按可执行文件名取短名:`pwsh.exe``PowerShell 7``powershell.exe``PowerShell``cmd.exe``cmd``bash.exe``Git Bash`,认不出则退回 `name` | `shell.rs::session_label` | 标签要短、稳定、一行放下多个会话;设置页的 `name` 可编辑、可很长,两者诉求不同 |
| 设置页的探测名**保持不变**`PowerShell 7` / `Windows PowerShell` / `命令提示符` / `Git Bash` | `shell.rs::detect_shells` | 用户明确要求:命名问题在标签位置,不动设置里的命名 |
| 历史默认名迁移同时覆盖两代取值 | `shell.rs::legacy_default_names` | 合并规则是「保留用户设置的名字」,不迁移则默认名对老用户**永远不生效**;列表里含上一版误当作展示名的短名,用于把它们收回描述性默认名 |
状态栏随之调整:本地会话不再显示从 `ShellProfile::name` 拼出的副标题 ——
标题已经是短名(`PowerShell`),再并排一个 `Windows PowerShell` 会让人以为是两个东西。
SSH 不受影响(标题是主机名、副标题是 `user@host:port`,后者标题给不出)。
顺带修掉一个相邻缺陷:`terminal_rename_session` 设的标题会被下一条提示符的 OSC 标题
覆盖(表现为「重命名无效」)。现在 `set_title_by_user` 会置 `title_locked`
一旦用户显式命名,OSC 标题一律忽略;trait 方法名从 `set_title` 改为带 `_by_user`
后缀,从命名上堵住「程序自动改标题」这条误用路径。
#### 二、终端页为什么像内嵌独立应用
| 症状 | 处理 |
|---|---|
| 工具栏一行 + 会话标签一行,两套横向导航 | 合并为单行头部:左侧会话标签,右侧操作组(省 36px 垂直空间) |
| 工具栏写原始 `sessionId` | 从界面移除,改放标签的悬停提示(排障时仍可见) |
| 十来个别扭的图标按钮铺满一行 | 高频动作(搜索/字号/清屏/独立窗口)外露,其余入「更多」下拉 |
| 「+」只能新建上次用过的 shell,连主机要去主机页 | 改为「+▾」下拉:本地 Shell 列表 + 已保存主机 + 管理主机 |
| 七个页面头部高度/内边距/计数写法各不相同 | 新增 `TerminalPageHeader`h-11、icon size-4、`共 N …`),六页统一 |
| 父层与页面各套一层圆角边框 | 容器下沉到各页自身,历史页的双边框随之消失 |
| 「返回终端」「关闭」按钮与模块 Tabs 重复 | 移除;`close` 意图事件保留,由父层映射为切 tab |
| 唯一未接入 `useModuleTabs` 的模块 | 接入(标题栏浮动切换器 + tab 记忆 + **全局搜索跳转到指定页** |
#### 三、Tabs 由 7 项收敛为 5 项
同类模块(proxy / monitor / music / downloader / clipboard)的 TabsList 都不超过
5 项、统一 `max-w-md`;终端原有 7 项只能靠 `max-w-2xl` 撑开,每项被压得很窄,
横向比别处宽出半屏 —— 是「不像是同一套导航」的直接来源之一。
| tab | 组成 | 说明 |
|---|---|---|
| 终端 | 会话(xterm + SFTP + 状态栏) | forceMount,切走不卸载 |
| 主机 | SSH 主机管理 | 不变 |
| 凭据 | 密钥 + 已知主机 | 都是「SSH 信任材料」,同一类列表 |
| 记录 | 命令历史 + 命令片段 | 都是「命令资产」,同一类用途 |
| 设置 | 外观 / 布局 / 选择 / 安全 / 快捷键 / Shell | 不变 |
合并的前提是**子分区仍能被直接到达**,否则用户要在一页里找两块内容,比多一个 tab 更糟。
因此新增 `TerminalSectionSwitch`(按钮组实现,视觉规格对齐 `TabsList`/`TabsTrigger`
不是嵌套 reka Tabs —— 那会带来两层键盘导航语义)。分区状态由 `TerminalModule`
持有(`credSection` / `recordSection`),面板卸载重挂不丢「上次看的是哪一半」。
**已知取舍**`SearchIndexItem.tab` 只能表达「哪个 tab」,无法表达「tab 里的哪个分区」,
所以全局搜索「已知主机」会落在「凭据」页的密钥分区,需再点一次页内开关。
要精确落位需给 `moduleTabsStore` / `useModuleTabs` / `TitleBar` 三处共享基建加字段,
收益不抵成本,故不做(已在 `index.ts` 的注释里写明)。
未改动的部分及原因:底部状态栏保留(终端习惯,且它是唯一承载编码/尺寸/cwd 的位置);
会话标签仍可拖动重排;会话面板仍常驻挂载用 `v-show` 切换(见模块文件头)。
`useModuleTabs` 的浮动切换器在终端模块**实际不会出现** —— 模块根是
`overflow-hidden`,页面不滚动,TabsList 不会滚出可视区。接入的实际收益是
tab 记忆与全局搜索跳转(此前搜索「SSH 密钥」只切模块、停在终端页)。
#### 验证
| 检查 | 结果 |
|---|---|
| `cargo check` | exit 0 |
| `cargo test --lib terminal::` | shell.rs 新增 5 个单测(提权前缀 / 有效标题保留 / 短名取值 / 兜底 / 历史默认名) |
| `vue-tsc --noEmit` | exit 0 |
| `vite build` | 成功 |
| 待真机确认 | 提权 PowerShell 标签显示为 `PowerShell`;重命名后不再被覆盖;窄窗口下头部不打架;5 个 tab 与页内分区开关的可达性 |
### 容器口径统一与终端页拆分(2026-09-20 第二轮)
峰提出:「终端模块的结构完全是单独的内部窗口,各 tabs 的内部窗口用同一个框架,
与其他模块 UI 模式差别很大」。勘察确认成立,并定位到三处偏离。
#### 一、偏离的取证(改动前)
| # | 偏离 | 事实 |
|---|---|---|
| 1 | 每页多一层**内嵌窗口框** | `h-full flex flex-col rounded-lg border border-border bg-background overflow-hidden`,全项目**仅终端使用**,共 7 处(终端页 + 6 个子面板) |
| 2 | 每页多一条**页中页标题栏** | `TerminalPageHeader`h-11 + border-b + px-4);6 个子面板各自手写同一套外框再内嵌该头部 |
| 3 | 滚动实现不统一 | 子面板用原生 `overflow-y-auto`,其它模块统一用 `ScrollArea`,滚动条外观不同 |
对照口径(proxy / monitor / music / clipboard / downloader 一致遵守):
模块根 `h-full p-6` → TabsList 包 `div[ref=tabsListRef]``max-w-md !bg-transparent`
`TabsContent flex-1 min-h-0 mt-4 tab-animate` → 内容**直接铺开**
`ScrollArea` + Card 流 / 列表项),**没有任何容器边框**。
#### 二、改动
| 项 | 内容 |
|---|---|
| 新增 `components/layout/PageToolbar.vue` | 漂浮工具条(无边框、无底边、`mb-3`),API 与原 `TerminalPageHeader` 一致(icon / title / meta / 默认槽=动作 / info 槽);提升到 layout 层,其它模块可复用 |
| 6 个子面板 | 去外框(保留 `flex flex-col h-full overflow-hidden`);`TerminalPageHeader``PageToolbar``overflow-y-auto``ScrollArea`padding 移到 ScrollArea 上(沿用 proxy/monitor 的 `pr-3` 写法) |
| 删除 `components/TerminalPageHeader.vue` | 已被 `PageToolbar` 取代,避免留两份近似组件 |
| 新增 `components/TerminalWorkspace.vue` | 终端页整体:工具条 + 终端屏幕(画布 / SFTP / 状态栏)+ 右键菜单 + 4 个对话框 |
| `TerminalModule.vue` 1197 → 383 行 | 只剩模块级编排:5 个 tab、tab 记忆、快捷键分发、初始化、主机密钥弹窗 |
#### 三、两个关键取舍
**1. 终端屏幕保留边框,但边界从「整页」收缩到「画布 + 状态栏」。**
xterm 是画布而不是内容流,需要明确的视觉边界;但此前边框包住的是
「标签行 + 主体 + 状态栏」整体,框内还有一条 h-11 标题栏 —— 那正是
「内嵌窗口」的观感来源。现在会话标签与操作组移到框外成为页面级工具条,
边框只包画布与状态栏。
**2. `useTerminalStream` 全模块只创建一次。**
该 composable 的 `tabOrder` / `panes` / `activeSessionId` 是**每次调用各创建一份**的
局部状态。拆分后若模块根与终端页各调一次,会出现两套互不相干的标签顺序与分屏布局
(表现为「点击标签没反应」)。因此实例在模块根创建,经 prop 传给 `TerminalWorkspace`
终端页里由模块级快捷键触发的动作(复制/粘贴/搜索/清屏/分屏/独立窗口/SFTP/重命名)
通过 `defineExpose` 的显式清单暴露,由模块根做一次转发。
#### 四、约束(后续改动须遵守)
1. **`h-9` 是卡片内的横向分隔线高度**:控制栏 / 状态栏 / `SftpPanel` 头部同为 h-9
改其一必须同步(原契约是「SftpPanel 头部对齐卡片外的工具条行 h-11」,2026-09-21
控制栏收进卡片并降为 h-9 后该层已不存在)。
2. **xterm 尺寸链不能断**`TabsContent → 工具条 + 屏幕容器 → 画布` 全靠 `flex-1 min-h-0`
3. `ModuleContainer.vue``[data-main-scroll] … > div { height: 100% }` 是给全高模块(终端/翻译)补高度链的补丁,勿删。
4. `TerminalWindow.vue`(独立窗口)与主窗口共用 `TerminalPane` / `TerminalStatusBar`,共享逻辑留在 `useSessionStream` 与 store,不下移到页面组件。
#### 五、非终端页与其它模块的口径对齐(2026-09-21)
第二轮对齐只动**观感与组件选型**,不改终端页(画布 + 状态栏)的结构:
1. **外层 padding 归模块根**:根容器 `p-6``p-5`,各页不再自带 `p-3/p-5`
—— 此前「根 24px + 页内 12/20px」双重内缩,列表左边缘比其它模块多缩 16px。
分栏页(片段、设置)的沟槽也由模块根提供:`UI_DESIGN_SYSTEM.md` §3.4 的
「split 无 padding」指**分栏容器自身**不加 padding,不是让页面顶到窗口边缘。
2. **列表一律用 `Card` 承载**(与 proxy「订阅列表」同口径):`Card` + `CardContent`
内部行保持 `rounded-md border`;页名与计数仍由 `PageToolbar` 承担,不再加 `CardTitle`
3. **表单控件走 `@/components/ui`**`Input` / `Select` / `Textarea` / `Checkbox`),
不再出现裸 `<input>` / `<select>` / `<textarea>``SftpPanel` 的远程文件编辑器是
文档显式豁免,保持原生 `<textarea>`
4. **四态与徽章用 `@/components/common`**:空/加载用 `StateBlock`(注意它在 ScrollArea 内
不会垂直居中,需显式 `min-height`),状态类徽章用 `StatusBadge`
纯计数的内联标记(历史 `×N`、Shell kind)保持 `text-[11px]` 文本,
避免 StatusBadge 的 20px 最小高度把行高抬起来。
5. **设置页用 `SettingGroup` + `SettingRow`**(本仓库首批真实消费者):分组自带卡片与
`divide-y`,行内不要再加边框/分隔;下拉/滑杆/输入这类表单项按 §3.4 的
「label 在上 + 控件全宽」纵排,不塞进 `SettingRow` 的值区。
控制列保留 `max-w-3xl`(§3.3 的例外:`SettingRow``justify-between`
不限宽会把标签与控件拉到 900px 之外)。
6. **分栏页保留页内滚动**:设置页/片段页的左右栏各自滚动(§3.4 split 模式),
不改外部滚动 —— 模块根有 `overflow-hidden`,改外部滚动会让左栏导航跟着滚走
`sticky` 会被祖先的 `overflow-hidden` 杀死)。
7. **历史页的键盘守卫**`onKeydown` 挂在外层,对 Enter/↑↓ 无条件 `preventDefault`
焦点落在按钮/下拉触发器/文本域上时先让路(`closest('button,[data-slot="select-trigger"],select,textarea')`),
否则换成 `Select` 后一次 Enter 会「先展开下拉、再把选中命令填进终端」。
搜索框不在让路之列:在那里 ↑↓ 选条目、Enter 填入是刻意保留的操作方式。
#### 六、验证
| 检查 | 结果 |
|---|---|
| `vue-tsc --noEmit` | exit 0 |
| `vite build` | 成功(28.00sTerminalModule chunk 160.34 kB |
| 待真机确认 | 6 个子页去框后与其它模块观感是否一致;终端屏幕的边界感;SFTP 面板与工具条行的对齐;分屏 / 右键菜单 / 搜索 / 会话模板 / AI 助手行为未变 |
| 第二轮待真机确认 | 列表卡片化与 20px 沟槽;历史页在搜索框/来源下拉里按 Enter 的行为;设置页 8 个分区的行对齐;片段页右栏滚动条为自绘 |
### 控制栏收进终端卡片与控件规范化(2026-09-21 第三轮)
峰提出:「控制栏放在卡片外面不对」、「按钮 hover 应是统一的主题色字 + 半透明主题色底,
看来有遗漏」、「字号三连图标改成『字体』按钮 + popover,图标按钮用 tooltip 而不是 title」、
「状态栏调到与控制栏同高、字号调大、编码换成 popover」。
#### 改动
| 项 | 前 | 后 |
|---|---|---|
| 控制栏位置 | 卡片**外**的页面级工具条(h-11,`mt-1` 与卡片分隔) | 卡片**内**顶部(h-9 + `border-b`),与画布、状态栏同属一张「终端窗口」 |
| 图标按钮 | 自定义 `.icon-btn`hover = `--accent` + `--foreground` | `Button variant="ghost"` —— 全项目唯一口径 `--primary-soft` + `--primary-soft-text` |
| 按钮提示 | 原生 `title` | `Tooltip` + `TooltipContent`(菜单触发器用 `:disabled="open"`,避免提示压在菜单上) |
| 字号 | 三个并排图标(缩小 / 放大 / 重置) | 「字体」按钮 + popover:滑块(6–48+ 当前值 + 重置 |
| 状态栏 | h-6 / 10px / 图标 `size-3` | h-9 / 12px / 图标 `size-3.5` |
| 编码 | 原生 `<select>` | `Popover` 选项列表(当前项打勾 + 非默认编码提示) |
| `SftpPanel` 头部 | h-11(对齐卡片外的工具条行) | h-9(对齐卡片内的两条栏) |
#### 取舍
1. **字号滑块复用 `bumpFont(delta)`**,不新增 `setFont` 接口:`bumpFont` 内部以
「临时覆盖值 ?? 设置值」为基准并收敛到 6–48,与状态栏展示的 `fontSize` 同源,
因此「基准 + 增量 = 目标值」恒成立。
2. **编码的浮层反而是更稳的选择**2026-09 早期用原生 select 的理由是
「shadcn 的浮层会被终端容器裁剪」,但 `PopoverContent``PopoverPortal`(挂 body),
不受卡片 `overflow-hidden` 影响 —— 该顾虑不成立,原生 select 的样式代价则一直存在。
3. **高亮态加在 svg 上**`Button.ghost` 已给按钮设 `text-fg-secondary`
同元素再写 `text-primary` 的胜负取决于 Tailwind 输出顺序;`[&_svg]:text-primary`
落在子元素上确定覆盖继承色(「日志记录中」的 ⋯ 用它)。
#### 验证
| 检查 | 结果 |
|---|---|
| `eslint src/modules/terminal` | exit 0 |
| `vue-tsc --noEmit` | exit 0 |
| 待真机确认 | tooltip 与下拉菜单不叠加;滑块拖动跟手且「重置」回落;编码浮层在卡片/独立窗口内均不被裁剪;SFTP 头部与上下栏等高 |
### 独立窗口与 Ctrl+3 快捷新建(2026-09-21 第四轮)
峰提出:「控制栏的 tooltip 似乎和点击事件冲突,新建会话/更多操作点不动」、
「独立窗口复用终端页内部窗口,再加最小化/最大化/关闭」、「加一个 Ctrl+3 直接开独立窗口,
打开时列出 SSH + 本地终端 + 新建的选择」。
#### 一、修 tooltip 与菜单触发器冲突
现象:控制栏的「新建会话」「更多操作」点击后毫无反应(菜单不出现)。
三轮定位过程:① `TooltipTrigger as-child` 直接包 `DropdownMenuTrigger as-child`(同时给
`DropdownMenu` 绑了 `v-model:open` 以便展开时收起提示);② 中间夹一层真实 `<span>` 作锚点
(仍带着 `v-model:open`);③ 去掉 Tooltip 与 `v-model:open` —— 菜单恢复可用。
结论:**受控 `open` 是元凶**。给 `DropdownMenu`(本项目对 `DropdownMenuRoot` 的封装)
`v-model:open` 后菜单点不开;Tooltip 只要锚在**包裹元素**上(而不是把菜单触发器当作
它的 as-child 子节点)就没有副作用。最终形态:
```
Tooltip > TooltipTrigger as-child > div(包裹元素)
└─ DropdownMenu @update:open(仅监听,不绑 open
└─ DropdownMenuTrigger as-child > Button
```
两个菜单触发器(本组件的「更多操作」、标签栏的「新建会话」)现在都有 Tooltip,
文案统一 `side="bottom"`(控制栏在卡片/窗口最顶部,向上弹会被裁掉)。
**约束**:① 本项目不给 `DropdownMenu``v-model:open`(需要开合状态时只监听
`@update:open`);② Tooltip 的锚点用普通元素,不要直接套在菜单/弹层触发器上。
#### 二、独立窗口 = 单会话的终端卡片
| 项 | 前 | 后 |
|---|---|---|
| 结构 | h-8 标题栏(菜单图标 + 会话名 + 窗口按钮)+ 画布 + 状态栏 | 与终端页同构:控制栏(h-9)+ 画布 + 状态栏 |
| 控制栏左端 | — | 会话身份(图标 + 名称 + 目标),**兼窗口拖拽区**(`data-tauri-drag-region` + 子元素 `pointer-events-none` |
| 控制栏右侧 | — | 复用的 `TerminalToolbar``standalone` 模式)+ 最小化/最大化/关闭 |
| Tooltip | 无(窗口是独立入口,没有 TooltipProvider | 窗口根加 `TooltipProvider` |
| 主题 | **不跟随主窗口**(窗口不加载 appStore,也没有各自的 applyTheme 副本) | 补上独立窗口的 `applyTheme`:读同一份 `localStorage` 设置 → `win.setTheme` + `dark` class + `applyStoredAccent`,并监听 `storage` / `prefers-color-scheme` 跟随主窗口改动(与快速面板、剪贴板弹窗、下载窗口同一套做法);**不应用 mica/acrylic**(本窗口是不透明窗口,见 `terminal/window.rs` |
| 提示条 | 无 Toaster`toast.*` 静默失效) | 自带 `Toaster`Toaster 是窗口级的) |
`standalone` 模式隐藏三类只对主窗口终端页成立的动作:独立窗口按钮(自己就是那个窗口)、
文件面板 / 端口转发 / 会话模板(它们开的是终端页的面板与分屏);保留搜索、字体、清屏、
重命名(文案改「重命名会话」)、AI 助手、会话日志。
**取舍**:窗口仍是「一个会话 ↔ 一个窗口」,不渲染标签栏 —— 主窗口那侧的「已在独立窗口打开」
提示条与标签上的「独立」徽标以此为准。控制栏动作与 TerminalWorkspace 有**少量有意重复**
(搜索/清屏/重命名/日志):那边还要管分屏与 SFTP,抽共享 composable 会把状态搅在一起。
#### 三、Ctrl+3:全局快捷键 + 选择框
**最终形态(2026-09-21 第二轮调整后)**
- **先开窗、再问**:快捷键触发 → 立刻创建「选择窗口」(`#new-terminal-window`
`NewTerminalWindow.vue`label 前缀 `terminal-window-new-` 以命中同名 capability
→ 用户在**这个窗口里**选本地 Shell / SSH 主机 → 建会话 →
`terminal_detach_session` 交给 Rust 用规范 label`terminal-window-<id>`)开真正的会话窗口
→ 选择窗口关闭(两者同尺寸同居中,视觉上是「原地换成了会话窗口」)。
**不再经过主窗口弹窗**,也不再把主窗口拉到前台 —— 会话窗口由 Rust 创建且此时还没有
sessionId,窗口 label 在 Tauri 里不可改,所以「选择窗口」与「会话窗口」只能是两个窗口。
想要零切换需要新增 Rust 命令(把已有窗口认领为某会话的窗口),当前未做。
- **键位可自定义**`newDetachedWindow` 作为一条动作放进 `TERMINAL_ACTIONS`
于是它跟着终端设置一起存、一起改(设置 → 快捷键 → 会话 → 新建独立窗口,默认 `Ctrl+3`
与其它键位一样支持录制与开关);Rust `default_shortcuts()` 同步加一条,`heal()` 会为老配置补齐。
`TerminalQuickLaunch`(常驻 App 层)读该键位并用 `plugin-global-shortcut` 注册,
键位变化即重新注册;关掉该行开关 = 不注册。
- **终端内分发不处理它**:全局注册在应用前台时同样生效,两处都处理会开出两个窗口,
因此 `TerminalModule``onAction` 里是一个带注释的空分支。
#### 四、窗口首帧不再闪白(2026-09-21 第二轮追加)
现象:深色模式下打开独立窗口 / 选择窗口,会先出现一个纯白窗口,随后才变深色。
原因:两个窗口都是**可见创建**的 —— WebView 初始底色是白的,而深浅色主题要等前端
读到 `localStorage` 设置后才应用。窗口在主题生效之前就已经显示出来了。
做法(沿用本项目的既有范式,见 `translate/popup.rs` 的预创建):
| 窗口 | 创建方式 | 显示时机 |
|---|---|---|
| 会话窗口(Rust `terminal/window.rs` | `visible(false)` + `focused(false)`(新增) | `TerminalWindow.vue``onMounted``applyTheme()``show()` + `setFocus()` |
| 选择窗口(前端 `openWindow.ts` | `visible: false` + `focus: false` | `NewTerminalWindow.vue` 同上 |
同时两个窗口的 `applyTheme()` 里补了 `win.setBackgroundColor(--background 的值)`
`show()` 那一刻 DOM 可能还没绘制完,窗口底色是主题色才不会露出白底。
capability 因此多了 `core:window:allow-show``terminal-window.json`)。
`show()` 放在主题之后、会话校验之前 —— 即使后面初始化失败(会话已结束等),
窗口也已可见,错误提示才看得见。
#### 五、设置跨窗口同步与「跟随应用主题」修复(2026-09-21 第三轮追加)
峰提出三件事:独立窗口不跟随主窗口改的终端配色;其它设置要不要/能不能同步;
「跟随应用主题」开关像没生效(改应用深浅时终端不变)。
**1. 跨窗口同步(原来完全没有)**
终端设置存在 Rust 侧,而每个窗口各有一份前端副本 —— 主窗口改完,已打开的独立窗口
永远用旧值。现在:Rust 在 6 个保存命令(settings / appearance / layout / selection /
security / shortcuts)后广播 `terminal-settings`(无负载;`save_settings` 上没有 AppHandle
故在命令层发);前端 `terminalStore` 监听该事件 → `loadSettingsInner()` 重读 →
`effectiveAppearance` / 状态栏显隐等随之更新。**能同步的就是这些**:外观、布局、选择、
安全、快捷键;主机 / 密钥 / 片段等列表数据在各自面板打开时重读,不需要推送。
**2. 「跟随应用主题」此前是死开关**
`followAppTheme` 从来没被任何代码读过(主题只按 `resolveTheme(appearance.theme)` 解析),
`resolveTheme('system')` 读的是**非响应式**的 DOM class —— 应用切深浅时没人重算。
现在按 Rust 字段说明的原始意图实现:**开关打开时,所选主题只作亮/暗取色基准**
`vscode-dark↔vscode-light``solarized-dark↔solarized-light`Dracula / One Dark 只有
深色,浅色下回落 VS Code 浅色);关闭时用所选主题本身(`system` 按当时深浅解析一次)。
实现要点:新增响应式 `appIsDark``terminalThemes.ts`),由 appStore 与各独立窗口的
`applyTheme` 写入;`useSessionStream.effectiveAppearance``effectiveThemeName()` 解析出
**具体主题名**后再交给面板,于是应用改深浅 → computed 重算 → xterm 换皮肤。
**3. 顺带修掉陈旧默认值**Rust 默认 `theme``thing-dark`(一套从未实现的主题,
解析时静默回落到 vscode-dark,用户会以为「选了主题不生效」)。默认改为 `system`
`heal()` 里把不认识的 key 一律收敛到 `system`
#### 六、设置项逐个核对与修复(2026-09-21 第四轮追加)
峰报了两个具体问题(背景不透明度无效、独立窗口右键无反应),并要求核对其余设置。
逐字段核对后确认了**一批「设置了但没有任何代码读它」的死设置**,以及若干只在终端页
生效、独立窗口漏掉的行为。
**已修**
| 问题 | 原因 | 修法 |
|---|---|---|
| 背景不透明度无效 | `appearance.opacity` 从没被读过 | `allowTransparency: true`(构造时固定)+ 主题背景写成带 alpha 的 8 位 hex`themeWithOpacity`);不透明度 < 100 时**主动丢弃 WebGL**WebGL 渲染器不认 `allowTransparency`,否则看起来仍是没反应);面板容器底色同带 alpha,避免四周取整边缘颜色不一致 |
| 独立窗口右键无反应 | 右键菜单只是终端页里的一段内联标记;`main.ts` 又全局 `preventDefault` 了原生菜单 | 菜单抽成共用组件 `TerminalContextMenu.vue`,两个宿主都接 `@contextmenu`menu / paste / select-word 三种行为一致) |
| 独立窗口不尊重「显示状态栏」 | 窗口无条件渲染状态栏 | 加 `showStatusBar !== false` 守卫 |
| 独立窗口复制忽略「去掉末尾换行」 | 窗口内联硬编码 `replace(/\n+$/,'')` | 键盘与菜单统一走 `doCopy()` |
| 「面板数量上限」下拉改不动 | `<Select :model-value>` 没有 `@update:model-value` | 补上处理 |
| 字号滑杆可拖到后端不认的值 | UI 6–48,后端 heal 只认 840 → 静默改回 14 | UI 收敛到 840 |
| 快捷键表两侧不一致 | `history` 只在 Rust 有(设置页看不到,保存时会被抹掉);`detachWindow` 只在设置页有(Rust `heal()` 会剔除) | 两侧都补齐;`heal()` 只保留默认表内 action,这条约束写进了注释 |
**死设置:全部实现(同日追加)**
上一版列出的 8 项「设置了但没有代码读它」的字段,按「让设置真的驱动行为」逐项落地。
原则:**不删除用户能看见的开关**(除确实无宿主的两项),把开关接到已有链路上。
| 设置 | 修法 |
|---|---|
| `layout.confirmCloseRunning` | `closeTab()` 加守卫:关闭时**连 `sessionAlive` 查询都不发**(那是一次 IPC + 后端进程探测),直接关 |
| `layout.inheritCwd` | `newLocalTab()` 在调用方未显式传 cwd 时取**当前激活会话**的 cwd;只对**本地**会话生效 —— SSH 的 cwd 是远端路径,本地 shell 起不来(Windows 上 `/home/ops` 会被判非法目录) |
| `layout.sidebarOpen` / `sidebarWidth` | 无宿主(终端模块的侧栏是 host 面板的 tab,不是可折叠轨道)→ 从 Rust `LayoutSettings`、前端类型、设置页草稿三处**删除**;容器级 `#[serde(default)]` 让旧配置里的多余字段被忽略,无需迁移 |
| `selection.copyOnSelect` | 在 `TerminalPane`**mouseup**(仅左键)写剪贴板,复用 `trimTrailingNewline`。刻意不放 `onSelectionChange`:拖动过程中它每次变化都触发,会把一次拖选切成几十次剪贴板写入 |
| `selection.middleClickPaste` | `TerminalPane``@mousedown.capture.middle``preventDefault` 挡掉 Chromium/Windows 的「中键自动滚动」,捕获阶段 `stopPropagation` 让 xterm 的鼠标状态机完全看不到这次中键;因事件被吞,焦点由处理函数自己 emit |
| `security.hostKeyPolicy` | `check_server_key` **显式读取**该字段(此前写死走 ask)。唯一合法取值仍是 `"ask"`,其它取值按 `"ask"` 处理并记日志——安全开关的失败方向必须朝更严一侧;`heal()` 同时把非法值收敛回 `"ask"`(修数据 + 防空两道保险) |
| `security.blockOnFingerprintChange` | 关闭时「与记录不符」**不再弹确认**,改为自动 `hostkey::accept`(旧指纹进变更历史,事后仍可查出「密钥曾被悄悄换过」)+ 记一条 warn。开着时行为不变(红框阻断式确认) |
| `security.auditLog` | 原来描述与实现是两件事:字段注释写的是「输入输出落盘」,而 UI 写的是「主机密钥确认 / 指纹变更等事件写日志」。按 **UI 的口径**实现(会话输出落盘由工具栏的 `terminal_toggle_logging` 按会话显式开启,`audit` 模块的取舍说明里已论证过「不自动记录屏幕内容」)。`SshHandler::audit()` 单入口收敛五类事件(已信任 / 变更 / 变更被接受 / 首次接受 / 自动接受),命中前先判开关;`terminal_confirm_host_key` 也记一条用户的接受/拒绝(该命令新增 `app` 参数,specta 会把 `AppHandle` 从 JS 签名剔除,前端调用不变)。**默认值 `false` 改 `true`**:只写事件行、不含屏幕内容,没有隐私代价,默认开才能保证事后可查 |
顺带修掉一处陈旧文案:布局页「面板数量上限」的说明还写着「分屏功能在后续版本提供」,
而分屏早已实现,改为说明真实含义与封顶 4 的原因。
#### 验证
| 检查 | 结果 |
|---|---|
| `eslint src` | exit 0 |
| `vue-tsc --noEmit` | exit 0 |
| `cargo check`src-tauri | exit 0 |
| 待真机确认(第四轮 · 死设置) | 关闭「关闭运行中的会话前确认」后关标签不再弹窗;开启「新会话继承当前目录」后新建本地标签落在当前 cwd(SSH 会话下不继承);「选中即复制」选中后剪贴板有内容、「中键粘贴」中键一次只粘一次;关掉「指纹变更时阻断连接」后换过密钥的主机直接连上且日志有 warn;关掉「记录安全事件」后日志不再出现主机密钥事件行;布局页不再有陈旧文案 |
| 待真机确认(第三轮) | 新建/更多菜单可点开、tooltip 正常;独立窗口拖拽/最小化/最大化/关闭;Ctrl+3 从其它应用按下能唤到前台并建出窗口;终端模块禁用后 Ctrl+3 不再占用;深色模式下开窗不再闪白;主窗口改终端配色/字号后独立窗口即时同步;应用切深浅时终端画布一起变 |
## 附:P1 新增命令清单(供前端对接与后续维护)
| 命令 | 参数 | 返回 |
+756
View File
@@ -0,0 +1,756 @@
# Thing UI 设计系统与开发约束
> **适用范围**:`src/` 下全部前端代码(12 个模块、220+ 组件)
> **约束力**:第 7 章为硬性约束,Code Review 据此判定;第 3–5 章为实现规范
> **配套**`UI Design System` 画布(色彩 / 排版 / 组件 / 骨架四屏可视化规范)
> **唯一事实来源**token 值以 `src/style.css` 为准,本文档与之同步
---
## 1. 设计原则
以下 8 条为不可协商项,贯穿全部模块。
**P1 · 单一强调色**
全应用只有 `--primary` 一种交互色,用于按钮、链接、选中态、焦点环。不存在"第二个主题色"。状态色(success/warning/danger)仅表达**状态**,不得用于可点击元素。
**P2 · 颜色只能来自 token**
禁止在组件内写 hex、rgb、oklch,禁止引用 Tailwind 原生色阶(`bg-blue-500``text-emerald-700`)。组件里出现的每个颜色都必须能追溯到 `style.css` 的一个语义变量。
**P3 · 状态必须四态齐全**
任何数据驱动的界面必须实现 **加载 / 空 / 错误 / 成功** 四态。禁止空白,禁止静默失败。这是最容易漏、也最影响质感的一条。
**P4 · 一个主区域,一个主操作**
每屏有且仅有一个视觉重心、一个主按钮。破坏性操作(停止内核、删除记录)必须视觉降级或二次确认,且不得与主操作同权重。
**P5 · 骨架统一,内容区自治**
所有模块共享同一套外壳(侧边栏 / 标题栏 / Tab 行)。**模块之间允许不同的只有内容区布局**——这是"统一"与"灵活"的分界线。
**P6 · 密度一致**
全应用统一使用**紧凑偏中**密度:13px 正文、34px 控件、16px 卡片内边距。禁止在同一屏内混用疏密两种节奏。
**P7 · 卡片统一描边 + 轻阴影,浮层用重阴影**
层级靠**表面色差 + 1px 描边 + 轻阴影(`shadow-sm` / `--shadow-card`)**表达。浮层(下拉、气泡、对话框)使用更重的 `--shadow-popover` / `--shadow-dialog`
**P8 · 中文界面不做负字距**
字体模板中的负字距(`-0.28px` 等)是为拉丁字母设计的,会让中文发挤。**拉丁文与数字可保留微调,中文一律 `letter-spacing: 0`。**
---
## 2. Design Token
### 2.1 命名与结构
Token 分四组,**在现有 shadcn 变量之上扩展,不另起一套**:
```
shadcn 既有(值调整) → --background / --foreground / --primary / --card / --border / ...
本项目扩展 → --canvas / --fg-* / --success / --warning / --data-* / --shadow-*
```
**关键调整**:把 shadcn 的 `--primary` 从当前近黑色(`oklch(0.21 0.006 285.885)`)改为强调蓝 `#0066CC`。这样全部 shadcn 组件(Button / Switch / Checkbox / focus ring)自动获得统一的交互色,无需逐组件改样式。
### 2.2 色彩
#### 浅色模式(`:root`
```css
:root {
/* ── 表面 ───────────────────────────── */
--canvas: #F5F5F7; /* 应用底 / 模块内容区背景 */
--background: #FFFFFF; /* shadcn 基准面 */
--card: #FFFFFF; /* 卡片 / 面板 */
--popover: #FFFFFF; /* 浮层 */
--subtle: #F0F0F2; /* 次级填充:搜索框底、分段控件槽、代码块 */
--hover: #EBEBED; /* 幽灵控件 hover */
--active: #E4E4E7; /* 幽灵控件 active */
/* ── 描边 ───────────────────────────── */
--border: #E4E4E7; /* 卡片外框、输入框 */
--border-strong: #D1D1D6; /* 需要更强分隔时 */
--divider: #F0F0F2; /* 卡片内部分隔线(1px */
--input: #E4E4E7;
/* ── 文字 ───────────────────────────── */
--foreground: #1D1D1F; /* 主文字:标题、正文、数值 */
--fg-secondary: #55555C; /* 次文字:键名、标签说明、幽灵控件文字 */
--muted-foreground: #8A8A90; /* 辅助:占位符、规格说明、次级 meta */
--fg-disabled: #B4B4BA; /* 禁用 */
/* ── 强调 ───────────────────────────── */
--primary: #0066CC;
--primary-hover: #0055B3;
--primary-active: #004A9E;
--primary-foreground: #FFFFFF;
--primary-soft: #E8F1FC; /* 选中 Tab 底、选中行底 */
--primary-soft-text: #004F9E; /* 上述底上的文字 */
--ring: #0071E3;
/* ── 语义状态 ───────────────────────── */
--success: #0F7B4F; --success-soft: #E7F5EE; --success-text: #0B5C3B;
--warning: #A96B00; --warning-soft: #FDF4E4; --warning-text: #7D4F00;
--danger: #C7362F; --danger-soft: #FDECEA; --danger-text: #9A2620;
--info: #0066CC; --info-soft: #E8F1FC; --info-text: #004F9E;
--neutral: #8A8A90; --neutral-soft: #F0F0F2; --neutral-text: #55555C;
--destructive: #C7362F; /* shadcn 兼容别名,指向 danger */
--destructive-foreground: #FFFFFF;
/* ── 数据方向(与状态语义解耦)───────── */
--data-down: #0F7B4F; /* 下载 / 入站 */
--data-up: #C7362F; /* 上传 / 出站 */
/* ── 尺寸 ───────────────────────────── */
--radius: 12px;
--radius-xs: 4px; --radius-sm: 6px; --radius-md: 8px;
--radius-lg: 12px; --radius-xl: 16px;
/* ── 阴影 ───────────────────────────── */
--shadow-card: 0 1px 2px rgba(0,0,0,0.06);
--shadow-popover: 0 1px 3px rgba(0,0,0,0.06), 0 4px 16px rgba(0,0,0,0.10);
--shadow-dialog: 0 4px 12px rgba(0,0,0,0.08), 0 20px 48px rgba(0,0,0,0.16);
}
```
#### 深色模式(`.dark`
深色下**必须成对重定义**,且强调色改为亮蓝(`#0066CC` 在深底上对比度不足):
```css
.dark {
--canvas: #16161A;
--background: #16161A;
--card: #1E1E22;
--popover: #1E1E22;
--subtle: #26262B;
--hover: #2C2C32;
--active: #33333A;
--border: #2E2E34;
--border-strong: #3A3A42;
--divider: #26262B;
--input: #2E2E34;
--foreground: #F5F5F7;
--fg-secondary: #A8A8B0;
--muted-foreground: #7A7A82;
--fg-disabled: #55555C;
--primary: #2997FF;
--primary-hover: #4AA8FF;
--primary-active: #1A85E8;
--primary-foreground: #FFFFFF;
--primary-soft: #14304D;
--primary-soft-text: #7FC0FF;
--ring: #2997FF;
--success: #3DC77F; --success-soft: #12301F; --success-text: #7FE0AB;
--warning: #E0A83C; --warning-soft: #33260E; --warning-text: #F0C976;
--danger: #FF6B60; --danger-soft: #3A1A18; --danger-text: #FF9E96;
--info: #2997FF; --info-soft: #14304D; --info-text: #7FC0FF;
--neutral: #7A7A82; --neutral-soft: #26262B; --neutral-text: #A8A8B0;
--destructive: #FF6B60; --destructive-foreground: #FFFFFF;
--data-down: #3DC77F;
--data-up: #FF6B60;
}
```
#### 状态色三件套的用法
每个语义状态有 **3 个** token,用途严格区分:
| Token | 用途 | 示例 |
|---|---|---|
| `--success` | 实心:状态点、图标、粗体数值 | ● 运行中 的圆点 |
| `--success-soft` | 浅底:徽章背景、行高亮 | `<span class="bg-success-soft">` |
| `--success-text` | 浅底上的文字 | 徽章内的"已安装" |
**禁止**把 `--success` 用作文字色 + `--success-soft` 以外的底色组合,也**禁止**用状态色做可点击元素的颜色(见 P1)。
#### 强调色的使用边界
`--primary` 只表达两件事:**这里可以点**、**这里是当前项**。
| 用 | 不用 |
|---|---|
| 主按钮底色、开关开启态、焦点环 | 卡片标题前的**装饰性图标** |
| 当前 Tab / 选中列表行 / 选中项 | 全部 Tab 的图标 |
| 可点击的文字链接 | 状态徽章(那是状态语义色的职责) |
| 输入框聚焦态 | 大面积背景、分隔线、装饰元素 |
**反例(已修正)**:proxy 概览页的 6 个卡片标题图标全部用了 `text-primary`,加上主按钮、开关、选中态,整屏发蓝 —— 结果"蓝色"不再意味着"可点击",层次感也丢了。已统一改为 `--fg-secondary`
#### 数据方向色(`--data-*`
下载/上传速率沿用现有视觉(绿/红),但独立成 token,**与 `success/danger` 解耦**。
> **待决策**:当前 `data-up` 与 `danger` 同色,会让人把"上传速率"误读为"异常"。若希望彻底解耦,建议改为
> `--data-down: #0066CC`(蓝)/ `--data-up: #A96B00`(琥珀)。这是**唯一改动成本为零**的时机——只需改两个 token。
### 2.3 排版
字体族保持不变(现有 `--font-sans` 已正确针对中文优化):
```css
--font-sans: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI',
'Microsoft YaHei', 'PingFang SC', 'Noto Sans SC', sans-serif;
--font-mono: 'JetBrains Mono', 'Cascadia Code', Consolas, monospace;
```
生产代码不引用外部字体 CDN(设计稿 / 原型页可保留 Inter);数值对齐通过全局 `font-feature-settings: 'tnum','lnum'` 实现,不依赖具体字体。
字号标尺(9 级,全部为 `px`):
| Token | 字号 / 字重 | 行高 | 用途 |
|---|---|---|---|
| `display` | 28 / 700 | 36 | 页面级大标题(设置页、关于页),**全应用不超过 1 处/屏** |
| `metric` | 22 / 600 | 28 | 关键数值(速率、容量)· **必须 `font-variant-numeric: tabular-nums`** |
| `h1` | 16 / 600 | 24 | 模块标题、卡片标题 |
| `h2` | 14 / 600 | 20 | 区块标题、卡片标题(小卡) |
| `h3` | 13 / 600 | 20 | 小节标题、列表分组标题 |
| `body` | 13 / 400 | 20 | **界面默认正文** |
| `body-strong` | 13 / 600 | 20 | 行内强调、键值行的键 |
| `caption` | 12 / 400 | 18 | 辅助说明、参数解释 |
| `label` | 11 / 500 | 16 | 徽章、标签、表头、单位 |
**规则**
1. 字号只从以上 9 级中取,**禁止 `text-[13.5px]` 这类任意值**。
2. 数值(速率、版本号、计数)统一用 `tabular-nums`,避免刷新时数字跳动。
3. 中文 `letter-spacing: 0`;拉丁小标题可 `-0.1px`,正文一律 `0`
4. 行高按表中固定值写,不用 Tailwind 默认相对行高。
### 2.4 间距
4px 基数,**8 个值,用尽即止**:
| Token | 值 | 典型用途 |
|---|---|---|
| `space-1` | 4 | 图标与文字间隙、徽章内边距(纵向) |
| `space-2` | 8 | 控件内元素间隙、徽章内边距(横向) |
| `space-3` | 12 | 列表行内间隙、卡片标题与内容间距 |
| `space-4` | 16 | **卡片内边距、卡片间距** |
| `space-5` | 20 | **模块内容区默认内边距** |
| `space-6` | 24 | 区块之间、对话框内边距 |
| `space-8` | 32 | 大区块分隔 |
| `space-12` | 48 | 页面级分段 |
**禁止** `p-[18px]``gap-[14px]``mt-[6px]`。需要更细的调整时,说明是布局问题而非间距问题。
### 2.5 圆角
| Token | 值 | 适用 |
|---|---|---|
| `radius-xs` | 4 | 内联小元素:标签内块、进度条端点 |
| `radius-sm` | 6 | 徽章、小按钮、下拉项 |
| `radius-md` | 8 | **按钮、输入框、下拉框、分段控件、Tab 项** |
| `radius-lg` | 12 | **卡片、面板、样张容器** |
| `radius-xl` | 16 | 对话框、大浮层 |
| `radius-full` | 9999 | 状态点、开关、头像、搜索框(胶囊) |
**规则**:同层级控件必须同圆角。按钮 8px 就全应用 8px,不允许某模块用 6px。
### 2.6 阴影与层级
**层级主要靠表面色差 + 描边 + 轻阴影**P7):
| 层级 | 表现 | 用途 |
|---|---|---|
| L0 应用底 | `--canvas`,无描边无阴影 | 窗口、模块内容区 |
| L1 卡片 | `--card` + 1px `--border` + 轻阴影 `shadow-sm` | 所有卡片、面板 |
| L1.5 次级容器 | `--subtle`,无描边 | 搜索框底、分段控件槽、代码块 |
| L2 浮层 | `--card` + `--shadow-popover` | 下拉菜单、气泡、tooltip、右键菜单 |
| L3 模态 | `--card` + `--shadow-dialog` + 遮罩 | 对话框、AlertDialog |
卡片默认使用轻阴影 `--shadow-card``shadow-sm`);`--shadow-popover` / `--shadow-dialog` 仅用于浮层与模态。
### 2.7 动效
```css
--ease-standard: cubic-bezier(0.22, 0.61, 0.36, 1); /* 沿用现有 tab-animate 曲线 */
--duration-fast: 120ms; /* hover、颜色变化 */
--duration-base: 200ms; /* 展开、浮层出现 */
--duration-slow: 320ms; /* 页面/Tab 切换 */
```
**规则**
1. 只对 `opacity``transform` 做动画(性能)。
2. 按钮按下统一 `transform: scale(0.97)`,不用颜色变化模拟按压。
3. 所有动画必须包在 `@media (prefers-reduced-motion: reduce)` 里降级为 `none`(现有 `.music-eq` 已正确实现,作为模板)。
4. Tab 切换复用现有全局 `.tab-animate` 类,**不要各模块自建过渡**。
### 2.8 图标
- 库:`@lucide/vue`,**禁止混用其他图标库或内联 Unicode 符号**(`↓` `✓` `●` 等)。
- 尺寸只有 3 档:`14`(行内 / 徽章内)、`16`(按钮、Tab、默认)、`20`(侧边栏、卡片标题)。
- `stroke-width` 统一 `1.75`lucide 默认 2,偏粗,与 13px 正文不协调)—— 在 `style.css` 里对 `.lucide` 全局设置,不要逐个组件写。
- 图标**不单独承载语义**:状态点旁必须有文字,工具栏图标必须有 tooltip 或文字标签(P4 可理解性)。
### 2.9 主题色自定义(已落地)
**需求**:设置页提供主题色选择,统一改变全应用的强调色。
**可行性**:可行且成本低 —— 因为全应用的"可交互色"已经收敛到单一来源 `--primary`。落地后组件层改动为 0。
**实现**`src/lib/theme.ts`(派生 + 应用)+ `appStore.setAccent()` + 设置页「主题色」卡片。
用户**只选一个基色**,深浅两套由代码派生 —— 不需要为浅色/深色各选一次(同色相两个亮度是既有做法:
`#0066cc` / 深 `#2997ff`),也不会出现"浅色选青、深色选紫"的语义割裂。
#### 需要派生的变量
只改基色是不够的,必须同时派生 7 个变量,否则 hover / 选中态仍是旧色:
| 变量 | 派生规则(OKLCH,色相 H 原样保留) | 用途 |
|---|---|---|
| `--primary` | 基色,L 归一到 `0.420.58`(浅)/ `0.680.80`(深),C 限制在 `0.060.20` | 主按钮、开关、焦点环 |
| `--primary-hover` | 浅:L 0.08;深:L + 0.06 | 主按钮 hover |
| `--primary-active` | 浅:L 0.16;深:L 0.10 | 主按钮按下 |
| `--primary-soft` | 浅:`oklch(0.95, C×0.30, H)`;深:`oklch(0.30, C×0.45, H)` | 选中底、幽灵按钮 hover |
| `--primary-soft-text` | 基色加深/提亮起步,再按对比度推进到 ≥4.5:1 | 上述底上的文字 |
| `--ring` | 同 `--primary` | 焦点环 |
| `--primary-foreground` | 白字低于 2.5:1 时才换 `#1D1D1F` | 填充上的文字 |
深色模式**单独派生一套**(深底上要提亮),不能复用浅色值。
#### 关键技术点
1. **用 OKLCH 做派生,不要用 HSL。**
只换色相 H、锁定 L 与 C,才能保证换任意色相后对比度一致。HSL 的"明度"感知不均匀,换色相会时亮时暗。
2. **对比度靠"推进亮度"而不是固定阈值判定。**
同一 OKLCH L 下不同色相的 WCAG 亮度差很多(L=0.58 的青柠对白底只有 ~2.8:1,蓝色却有 ~5:1),
所以归一到区间后还要继续推进 L 直到达标:
- 浅色:`--primary``#FFFFFF` ≥ 4.5:1(它会被当文字用在白卡上)→ 选亮色自动压暗
- 深色:`--primary``#1E1E22` ≥ 4.5:1 → 自动提亮
- `--primary-soft-text``--primary-soft` ≥ 4.5:1
以上断言在 `src/lib/theme.test.ts` 里对 10 个预设 × 2 模式全量校验。
3. **`--primary-foreground` 的取舍是有意的。**
深色模式下强调色必须够亮才能在深底上当文字用(≥4.5:1),同一颜色做填充时白字只能到 2.7–3:1 ——
现有主题就是这个取舍(`#2997ff` + 白字 ≈ 2.98:1)。因此门槛按 UI 组件的 2.5:1 兜底,
只在自定义极亮色时才改深墨。
4. **只开放"强调色",不开放状态色。**
`success` / `warning` / `danger` 是语义色,改了会破坏"绿色=成功"的心智模型。`--data-*` 同理。
`--info` 一族当前取值与强调色相同(蓝),但**不跟随**主题色 —— 它表达"信息"而非"可交互"
换紫/绿主色后少数徽标(代理"国外"、HTTP 1xx/3xx、快速面板分类)仍是蓝色,属有意的语义区分。
5. **预设 + 自定义。**
10 个预设(默认蓝 / 靛蓝 / 紫罗兰 / 玫红 / 橙 / 琥珀 / 青柠 / 翠绿 / 青绿 / 石墨)+ 原生取色器。
纯自由取色很容易选出对比度不达标的颜色,所以色板优先。
色板显示色用**派生后的浅色模式主色**(所见即所得,如青柠显示为压暗后的绿)。
6. **实时预览 + 可回滚。**
点击色块 / 拖动取色器立即生效(写 7 个变量,无防抖);「恢复默认」清除行内变量回到内置蓝。
7. **持久化**:随其它设置写入 `thing_app_settings``accentId` + `accentHex`)。
属**纯增量字段**`SETTINGS_VERSION` 不递增 —— 递增会命中"版本不匹配清空"分支,
把用户的模块排序/启停/开机自启一并抹掉,为零收益付真实数据损失。
8. **行内变量的优先级陷阱。**
变量写在 `documentElement` 的行内样式上,优先级高于 `:root``.dark` 两条规则,因此:
切模式必须**整组 7 个重写**(否则浅色的行内值会残留到深色模式);
恢复默认必须 `removeProperty`(写空串仍算"已设置")。
#### 落地范围
- `src/lib/theme.ts` —— 派生计算 + 应用/清除(新增,含单测)
- `src/stores/appStore.ts` —— `accentId`/`accentHex` + `setAccent()`,在 `applyTheme()`
`handleSystemThemeChange()` 里按当前模式应用
- `src/modules/settings/GeneralSettings.vue` —— 「主题色」卡片
- 9 个独立窗口(快速面板 / 托盘菜单 / 下载窗 / 剪贴板弹窗与预览 / 翻译弹窗 / 截图覆盖层·编辑器·贴图)
各加一行 `applyStoredAccent()`;托盘菜单额外补 `storage` 监听
- `src/style.css` —— **无需改动**7 个变量已是 CSS 变量)
- **首屏闪烁(FOUC)不需要额外处理**:主窗口 `visible: false``appStore.init()``show()` 前完成应用;
文档原建议的 Rust `initialization_script` 方案与本仓库 localStorage 方案冲突,不实施
**影响面:0 处组件改动。** 这正是把交互色收敛到 `--primary` 单点带来的收益 —— 现在不做,将来也不用挨个改组件。
---
## 3. 页面框架
框架只定义三件事:**谁多高、谁滚动、padding 加在哪**。这三件事定死之前,任何模块级的美化都会被布局 bug 吃掉。
### 3.1 分区与高度约束链
```
AppShell h-screen flex-col
├ TitleBar h-14 shrink-0 固定 · 侧栏图标 + 模块 Tab + 搜索
└ 主体 flex-1 min-h-0 flex-row
├ Sidebar w-15 shrink-0 60px 固定
└ 内容滚动区 flex-1 min-h-0 overflow-y-auto ← 全应用唯一滚动容器
└ 内容 wrapper p-5(占满内容宽,无 max-width
```
Tab 已并入标题栏(见 3.5),内容区不含独立 Tab 行,纵向空间最大化。
**三个必须遵守的点**(都是实际踩过的坑,不是理论):
1. **`min-h-0` 不能漏。**
flex 子项默认 `min-height: auto` —— 内容变高时它**不收缩**,直接把父容器顶破,底部 padding 被推出视口。这就是"卡片贴着应用底部"的根因。
2. **padding 加在滚动容器**内部**的 wrapper 上。**
加在滚动容器自身,底部 padding 会被内容高度覆盖,滚动到底依然贴边。
3. **断点按内容宽度选,不按视口宽度。**
侧边栏固定占 60px —— 视口 1000px 时内容只剩 940px。两列网格必须用 `md`(768) 而不是 `lg`(1024),否则窗口一窄就退化成单列全宽、卡片大片留白。
### 3.2 滚动归属
**全应用只有一处滚动容器。**
- 模块内容区随外层滚动,**不是**每个模块各建一个滚动容器
- 模块根 / 页面级容器**禁止** `overflow-y-auto` —— 那会绕过外层 `ScrollArea`,出现原生宽滚动条(快速面板踩过);根容器一律 `min-h-full`
- 局部列表(剪贴板历史、音乐队列等)**可以用 `ScrollArea` 组件**做独立滚动 —— 这是「局部区域」而非「页面级」
- 需要固定表头的长表格:用 `position: sticky` 固定表头,而不是给表格单独开滚动容器
- 例外:terminal / translate 的工作台面板内部滚动(workbench 模式特征)
- 代价:切换 Tab 时共享同一个滚动位置(可以接受,反而更符合"翻页"直觉)
**统一组件使用规则**(违规即 UI 不一致,等同违反第 5 章 D 系):
| 需求 | 用 | 禁止 |
|---|---|---|
| 页面滚动 | 外层 `ScrollArea`(自动,模块无需处理) | 模块根 `overflow-y-auto` |
| 局部列表滚动 | `ScrollArea` 组件 | 原生 `overflow` |
| 键值行 / 状态点 / 徽章 / 大数值 | `@/components/common` 对应组件 | 手写 flex 结构 |
| 卡片 | `Card` / `InfoGroup(card)` / `StatCard`(统一 `shadow-sm` | 手写描边块 |
| 多分组设置表单 | `SettingGroup` + `SettingRow` | 手写行 |
### 3.3 内容区宽度
内容区**不限宽,占满窗口宽度**(已移除 `max-w-6xl` 限制)。窗口拉宽时卡片随内容区延展,不再居中约束。
### 3.4 内容区布局模式
内容区**只能**从以下五种里选,不得自创:
| 模式 | 适用 | 规格 |
|---|---|---|
| `grid` | 概览 / 状态总览 | `grid-cols-1 md:grid-cols-2`·gap 16·`items-start` 让卡片各自高度 |
| `split` | 列表类(clipboard / music / terminal | 左栏 280320px·右栏 fill·中间 1px `--border`·无 padding |
| `workbench` | 输入-输出类(devtools / translate | 纵向堆叠·gap 12 |
| `settings` | 设置页 | 纵向堆叠·gap 24·复用 `SettingGroup` |
| `plain` | 其他 | 纵向堆叠·gap 12 |
**`grid` 的用法边界**:只适合「各块内容量接近」的概览页。若各块信息量差异大,两列会让矮的那列留空。
**概览页标准结构 · 「状态横幅 + 卡片网格」**(适用于 proxy / 下载 / 硬件监控,四处横幅已统一,见下方「落地状态」):
```
第一行:状态横幅(全宽,独立于网格,高约 76px)
├ 容器 `Card``!py-0 !gap-0`+ `CardContent``flex flex-wrap items-center gap-x-6 gap-y-3 px-4 py-4`
│ 圆角/描边/阴影一律由 Card 提供(`rounded-xl + border + shadow-sm`),禁止手写 `rounded-lg border bg-card`
├ 左组成组(状态 → 核心属性 → 实时指标,依次靠左,不把指标悬在中间)
│ 状态区 状态点 8px + 状态文字 13px/500 + 进程名 13px 次要色(状态点/文字用语义色;运行态加 status-dot-pulse
│ 指标区 label 11px 灰 + 数值 18px/600 tabular-nums;核心属性(如内核版本、PID)也占一个指标位
└ 操作区 运行中 = 重启(outline)+停止(destructive);停止 = 启动(primary)sm = 32px`ml-auto` 推到行尾
下方:**两列瀑布流** `columns-1 md:columns-2`(卡片 `break-inside-avoid`,列间距/卡间距 16px),`InfoGroup variant="card"`
├ 卡内键值**一律单列**`InfoGroup columns=1`,一行一条)—— 双列键值会挤压值区,路径类长值被截断
├ 瀑布流各卡自然高度、列内堆叠,**没有「同行等高」的配对压力** —— 双列网格的配对调整是反复返工的根源,已废弃
├ 表单类内容(下拉/输入)用「label 在上 + 控件全宽」纵排,不塞进键值行的值区
└ 阅读顺序为纵向(先左列后右列),分组按重要性排序;列底部不齐是模型特征,不是缺陷
```
**适用判断**:模块概览有「一个核心状态 + 2-4 个实时指标 + 一个主操作」就用横幅;三者不齐的模块直接用卡片网格,不要硬凑横幅。
**落地状态**2026-09-21 统一):proxy 概览 / proxy 连接 / 下载任务 / 硬件监控概览四处横幅均按本节实现 ——
- 硬件监控:`PID / 传感器 / 重启 / 事件``text-xs` 内联「标签: 值」提升为指标位,进程名用 `ThingHK`,操作区 `xs``sm`,停止按钮改 `destructive`
- 下载:`下载中 / 等待 / 已暂停 / 已完成 / 已取消``Badge` 改为指标位,且恒显(0 比隐藏更真实,也避免位置漂移)
- 状态文字一律「状态 + 进程名」顺序(`运行中 mihomo` / `已连接 ThingHK` / `运行中 下载引擎`
**已否决的两个方案**(避免回头再试):
- **平铺分组**(InfoGroup 无容器版):无容器所以视觉"散";键值两端对齐导致一行一条时"空"。
- **等宽两列卡片**:内容量不齐导致列内留白。D2 之所以成立,是横幅吸收了最"高"的内容(状态+指标),剩下的卡片内容量天然接近。
**设置行**结构(`SettingGroup` + `SettingRow`):
```
分组标题(h2 14/600)+ 可选说明
└ 设置行:左 label · 右控件,行高 44,行间 1px --divider
```
### 3.5 导航与 Tab
**Tab 只有一处:标题栏。** 模块内容区不再渲染 Tab 行。
| 层级 | 位置 | 内容 |
|---|---|---|
| 一级导航 | 侧边栏 60px | 模块图标(代理 / 音乐 / 剪贴板…) |
| 二级导航 | **标题栏内** | 当前模块的 Tab(概览 / 连接 / 节点 / 订阅 / 设置) |
**为什么**:Tab 行独占 48px 纵向空间,却只承载一个切换器。并入标题栏后,内容区最大化。
**实现**
```ts
useModuleTabs('proxy', activeTab, tabs, { alwaysVisible: true })
```
- 模块**不渲染** `TabsList`,标题栏切换器常驻
-`alwaysVisible` 后不再注册 `IntersectionObserver`
- 未传该选项的模块保持原行为(Tab 行滚出视口时才在标题栏补一个)—— 属过渡状态,应逐步迁移
**选中态**`--primary` 实心胶囊 + 白字。标题栏空间小、Tab 数量少,实心比下划线更易扫读。
> 过渡期存在两套 Tab 外观:模块内的 `TabsList`(下划线)与标题栏切换器(实心胶囊)。**全部模块迁移完成后,只保留标题栏这一套**,那时 `TabsTrigger` 的下划线样式可以简化掉。
### 3.6 状态覆盖(P3 落地)
每种模式都必须实现四态,**抽成通用组件,不在模块内手写**:
| 状态 | 组件 | 表现 |
|---|---|---|
| 加载 | `<StateBlock variant="loading">` | Spinner 16px + "加载中…",高度撑满容器最小 120px |
| 空 | `<StateBlock variant="empty">` | 图标 32px(`--muted-foreground`) + 说明 caption + 可选主按钮 |
| 错误 | `<StateBlock variant="error">` | 图标 32px(`--danger`) + 错误信息 + "重试"次按钮 |
| 无权限 | `<StateBlock variant="denied">` | 图标 + 说明 + 引导操作 |
**验收**:随机打开任一模块的任一 Tab,断网/清空数据/制造错误,都必须看到对应的块,而不是空白。
### 3.7 页面构造示例 · proxy(样板,含其余四页规划)
概览页已定稿(横幅 + 瀑布流)。其余四页用**同一套模式**组装,空态一律按 M8(结构恒定 + `—` 占位):
**统一原则(四页共用)**
1. 每页从 3.4 的模式里选一种承载,不自创
2. 统计类信息统一用「指标行」形态(与横幅指标区同构:label 11 + 数值 18/600),容器口径同横幅 —— 用 `Card` 承载,不做裸指标行(M8
3. 表格/列表统一用卡片承载,长表头 `sticky`
4. 行内操作集中在行尾,用 `icon-sm`28px),禁微缩按钮
**连接** —— 指标行 + 表格卡(workbench 变体)
```
指标行:活跃连接 + 规则命中 chips(与概览横幅同容器 Card + `px-4 py-4`chips 紧挨活跃连接,随命中项变化向右延伸)
下载/上传速率已删(与概览横幅重复);命中规则计数已删(恒等于活跃连接数)
表格卡(全宽):进程/域名 + 国内国外徽章 + 规则 + 上下行流量 + 断开
表头 sticky · 行 hover(bg-hover) · 空态「暂无活跃连接」
```
**节点** —— 组导航 + 节点网格
```
顶部:代理组切换(SegmentedNav,组多时降级为 Select)+ 测速按钮(行右)
节点网格:节点小卡(名称 + 延迟徽章 + 选中态 primary-soft),grid-cols-2 lg:grid-cols-3
未运行时网格空态「暂无节点」,结构不变
```
**订阅** —— 列表卡 + 导入区(plain 模式)
```
列表卡:行式(名称 / 更新时间 / 节点数 + 更新、删除),行高 52,行间 1px divider
导入区:URL 输入 + 导入按钮,置于列表卡上方
空态「暂无订阅」+ 主按钮引导导入
```
**设置** —— 单卡 + 卡内标题(峰定稿偏好:设置页标题留在卡片内)
```
单张设置卡承载全部设置项,卡片占满内容宽(放大居中与概览/连接一致)
输入类双列(md:grid-cols-2)、开关行「左说明右开关」、提示条 primary-soft 底;保存按钮在标题栏
SettingGroup / SettingRow 保留,用于全局设置页等多分组场景)
```
---
## 4. 组件规范
### 4.1 业务组件清单(统一从 `@/components/common` 导入)
以下 14 个组件已建立完成。**模块内不得再手写同类结构**——这是"UI 不统一"反复复发的根本原因:只要没有现成的替代品,开发者就只能手写。
| # | 组件 | 解决的问题 | 关键 props |
|---|---|---|---|
| 1 | `ModuleShell` | **页面框架落地件**:高度约束链 + 滚动归属 + 内容区 padding/限宽 | `layout`, `padded`, `maxWidth`, `tabs` |
| 2 | `ModuleToolbar` | 模块内容区顶部操作条 | `slot: left/right` |
| 3 | `SectionHeader` | 区块标题 + 说明 + 右侧操作 | `title`, `description`, `slot: action` |
| 4 | `StatCard` | 键值卡片(需要视觉分组的独立块) | `title`, `description`, `slot: action` |
| 4a | `InfoGroup` | **平铺分组**(概览页键值快照,密度优先) | `title`, `columns`, `slot: action` / `slot: footer` |
| 5 | `MetricValue` | 大数值 + 标签 + 单位 + 方向色 | `value`, `unit`, `label`, `trend` |
| 6 | `KeyValueRow` | 键值行(状态/版本/路径) | `label`, `value`, `slot: value` |
| 7 | `StatusDot` | 状态点 + 文字 | `state: success/warning/danger/neutral`, `label` |
| 8 | `StatusBadge` | 徽章 | `variant`, `size: sm/md` |
| 9 | `SettingRow` | 设置行 | `label`, `description`, `slot: control` |
| 10 | `SettingGroup` | 设置分组 | `title`, `description` |
| 11 | `DataListRow` | 列表行(主从分栏左栏) | `title`, `subtitle`, `active`, `slot: trailing` |
| 12 | `StateBlock` | 加载/空/错误/无权限 | `variant`, `title`, `description`, `slot: action` |
| 13 | `CopyField` | 可复制文本(路径、版本号) | `value`, `mono` |
| 14 | `ConfirmDialog` | 破坏性操作确认 | `title`, `description`, `variant: danger` |
### 4.2 关键组件规格
**按钮**(基于 shadcn `Button`,只调 token 与尺寸)
| 变体 | 底色 | 文字 | 描边 | 用途 |
|---|---|---|---|---|
| `default`(主) | `--primary` | `--primary-foreground` | 无 | 每屏仅 1 个 |
| `outline`(次) | `--card` | `--foreground` | `--border-strong` | 常规操作 |
| `ghost`(幽灵) | 无底 / hover `--primary-soft` | `--fg-secondary` / hover `--primary-soft-text` | 无 | 工具栏、行内 |
| `destructive`(危险) | `--danger` | 白 | 无 | 停止/删除,需确认 |
尺寸:`sm` 高 28 / `default` 高 34 / `lg` 高 40。内边距 `px-3.5`default),圆角 `radius-md`。按下统一 `scale(0.97)`
**禁止** `h-4 px-1.5 text-[10px]` 这类微缩按钮(`ProxyModule.vue:1768` 有此写法)——需要更小的按钮时用 `StatusBadge`
**交互反馈(hover / active / focus**
| 场景 | hover | active |
|---|---|---|
| 主按钮 | `--primary-hover`(变深) | `--primary-active` |
| 次按钮 / 卡片 | `--hover`(中性加深) | `--active` |
| 幽灵按钮 / 图标按钮 | `--primary-soft` + `--primary-soft-text` | `--primary-soft` |
| 列表行 / 菜单项 | `--hover` | — |
| 选中行 / 选中 Tab | — | `--primary-soft` |
**焦点环**全应用统一:`ring-2 ring-ring/40`。hover ≠ selectedhover 表达"可以点"selected 表达"已经选中",后者才用强调色。
**状态点**
```
8px 圆 · border-radius: full · 底色 = 对应 --<state>
与文字间距 6px · 文字用 body 或 label
```
可选呼吸动效(运行中):`opacity` 1↔0.551.8s 循环,`prefers-reduced-motion` 下禁用。
**徽章**
```
高 20 · 内边距 3px 8px · 圆角 radius-sm
底 --<state>-soft · 文字 --<state>-text · 字号 label(11/500)
```
**卡片**
```
底 --card · 1px --border · 圆角 radius-lg(12) · 内边距 space-4(16)
标题 h2(14/600) · 标题与内容间距 space-3(12)
统一轻阴影 shadow-sm--shadow-card)—— shadcn Card / InfoGroup card / StatCard 一致
```
**输入 / 下拉 / 分段控件**
```
高度 34 · 圆角 radius-md(8) · 1px --border
输入与下拉态:底 --card
分段控件:外槽底 --subtle,内边距 3,选中项底 --card + 文字 --foreground,未选中项文字 --fg-secondary
聚焦:border 不变,box-shadow: 0 0 0 2px --ring(40% alpha)
```
### 4.3 组件用法要点
- 业务组件统一从 `@/components/common` 导入(见 4.1 清单),模块内禁止手写同类结构。
- 页面用 `ModuleShell` 包裹,通过 `layout`grid / split / workbench / settings / plain)选内容区模式。
- 内容区先按四态组织:先 `loading`,再 `empty`/`error`,最后才是数据内容(见 3.6 / M8)。
- 颜色只用 token 类名(`bg-card border border-border`),禁止 hex 与原生色阶(见第 5 章 D1–D2)。
- 数值统一 `tabular-nums`;状态点 / 徽章用 `StatusDot` / `StatusBadge`,键值用 `KeyValueRow` / `InfoGroup`
---
## 5. 开发约束(硬性)
### 5.1 禁止清单
| # | 禁止 | 替代方案 |
|---|---|---|
| D1 | 组件内出现 hex / rgb / oklch 字面量 | 用语义 token 类名 |
| D2 | Tailwind 原生色阶 `bg-blue-500` `text-emerald-700` `border-slate-200` | 用 `bg-primary` `text-success-text` `border-border` |
| D3 | 间距/圆角/尺寸的任意值 `p-[18px]` `rounded-[10px]` | 归位 4px 栅格:`p-4` `rounded-md`(非 4 倍数就近归档)。**字号例外**:正文标准档 `text-[11px]`~`text-[14px]` 是项目字号体系(Tailwind 无 13px 预设),允许保留;非标准档(15/13.5/16px 等)归位最近标准档 |
| D4 | 用状态色表达可点击性(绿色按钮 = 主操作) | 主操作一律 `--primary` |
| D5 | 卡片使用重阴影(shadow-popover / shadow-dialog 或 hover 抬起阴影) | 卡片统一轻阴影 `shadow-sm` |
| D6 | 模块内手写状态点 / 徽章 / 卡片标题 / 键值行 | 用第 4.1 节组件 |
| D7 | 混用图标库,或用 Unicode 符号(`↓ ✓ ● ▸`)当图标 | lucide,尺寸 14/16/20 |
| D8 | 中文加负字距 | `letter-spacing: 0` |
| D9 | 生产代码 `@import` 外部字体 CDN | 系统字体栈 |
| D10 | 数据驱动界面缺四态之一 | `StateBlock` |
| D11 | 一屏多个主按钮 / 破坏性操作与主操作同权重 | 视觉降级 + `ConfirmDialog` |
| D12 | 在 `.dark` 下靠改组件类名做适配 | 只改 token,组件代码不感知主题 |
### 5.2 必须清单
- **M1** 新增模块必须使用 `ModuleShell`,内容区三选一(第 3.2 节)
- **M2** 所有颜色/间距/圆角/字号可追溯到 `style.css` 的 token
- **M3** 深色模式与浅色模式同时验证,不得只测一套
- **M4** 数值使用 `tabular-nums`
- **M5** 动效尊重 `prefers-reduced-motion`
- **M6** 控件最小点击区 28×28(桌面标准,非触摸的 44)
- **M7** 文字与背景对比度 ≥ 4.5:1;`--muted-foreground` 不得用于正文,只用于 ≥12px 的辅助信息
- **M8** 数据驱动的界面**结构恒定**:无数据时保持完整结构、值用 `—` 占位(横幅、键值行、表格单元同规则);**不得**用「xx 未运行」之类文案整块替换结构。运行状态由横幅状态区或状态点表达,不在卡片内复述。控件在不可用时禁用,而不是隐藏整块。
`—``0` 的区分:`—` = 数据缺失/不适用(如未运行时的 PID);`0` = 语义上就是零(如未运行时的连接数、速率)—— 可计数指标显示 0 比 `—` 更真实。指标行/横幅必须有容器(卡片),裸指标行浮在画布上与页面不协调。
- **M9** 动态数值的指标位**按数据类型固定最小宽度**:速率/流量/计数高频刷新,宽度变化会左右推移兄弟元素。做法同 OSD(`tabular-nums` + `min-width`)。**宽度按内容定,不要一刀切**——速率 `min-w-24`(容纳 `999 KB/s`)、版本/累计 `min-w-20`、纯计数 `min-w-12`;统一给大值会留白并把同卡操作按钮挤换行
- **M8** 键盘可聚焦元素必须有可见焦点环(`ring-2 ring-ring/40`
### 5.3 Code Review 检查点
提交前自查(建议做成 MR 模板 checklist):
1. `grep -rE "(#[0-9a-fA-F]{3,8}|(bg|text|border)-(slate|gray|zinc|blue|emerald|amber|red|rose|orange|sky|purple|violet)-[0-9]{2,3})" src/` **无新增命中**
2. `grep -rE "\w-\[[^\]]+\]" src/` 新增行数可控且能说明理由
3. 深浅两套主题截图留存
4. 加载/空/错误三态至少手动触发过一次
5. 破坏性操作有确认
**建议自动化**:加入 ESLint 规则,直接拦截 D1–D3。
```js
// eslint 思路示例:禁止特定 class 模式
{
files: ['**/*.vue'],
rules: {
'no-restricted-syntax': [
'error',
{
selector: 'Literal[value=/(bg|text|border)-(slate|gray|zinc|blue|emerald|amber|red|rose|orange|sky|purple|violet)-\\d{3}/]',
message: '禁止使用 Tailwind 原生色阶,请使用语义 token(见 UI_DESIGN_SYSTEM.md 第 5.1 节 D2'
}
]
}
}
```
---
## 6. 工具与状态
### 6.1 组件预览页(改 token 后的回归工具)
路径:`preview/` —— **不在生产构建入口内**,只在 `bun dev` 时可访问。
```bash
bun dev
# 浏览器打开 http://localhost:14210/preview/index.html
```
用途:改完 `src/style.css` 的 token 后,把全部基础组件与业务组件过一遍,确认浅色 / 深色两套都正确 —— 替代"改完 token 靠记忆推断影响面"。本页不依赖任何 Tauri API,纯浏览器即可运行。
查询参数:
| 参数 | 作用 | 示例 |
|---|---|---|
| `theme=dark` | 以深色模式启动 | `?theme=dark` |
| `only=<id>` | 只渲染指定分区,便于放大核对 | `?only=data` |
已内置 `id` 的分区:`forms``data``shell`。需要放大看其他分区时,按同样方式给 `<section>``id` 即可。
> **实现注意**`only` 的隔离 CSS 只能用**显式标记**`[data-block]` / `[data-page-header]`),**不能用 `section` / `header` 这类标签选择器**——`StatCard` 内部也用 `<section>` 和 `<header>`,会被一起隐藏,产生"组件没渲染"的假象。
### 6.2 当前状态
- 业务组件层(第 4.1 节 14 个组件)与 Token 层已落地,`preview/` 预览页已完成浅色/深色视觉回归。
- 色阶治理:Tailwind 原生色阶从约 155 处降至 3 处(proxy / terminal / monitor / translate / music 的色阶与 Tabs 已完成)。
- proxy 概览页定稿为「状态横幅 + 两列瀑布流」,InfoGroup 新增 `variant="card"`
- 状态横幅统一(2026-09-21):proxy 概览 / proxy 连接 / 下载任务 / 硬件监控概览四处横幅统一为 §3.4 的三段式(状态区 → 指标区 → 操作区)+ `Card` 容器;监控与下载的旧样式(`text-xs` 内联键值、Badge 计数、`xs` 按钮)已废弃。
- 主题色自定义落地(2026-09-21,见 §2.9):`src/lib/theme.ts` + `appStore.setAccent()` + 设置页「主题色」卡片(10 预设 + 自定义),深浅两套自动派生、对比度自动兜底;组件层 0 改动。
- 终端模块非终端页对齐(2026-09-21,见 TERMINAL_MODULE_PLAN.md §5):根 padding 归类、列表 Card 化、表单走 `@/components/ui` 原语、四态/徽章走 `@/components/common`、设置页用 `SettingGroup`/`SettingRow`(此后二者与 `StateBlock`/`StatusBadge` 有真实消费者);终端画布、状态栏、`h-11` 契约、xterm 链保持不动。
- 剩余项:
- `OsdWindow` 免改(功能性配置,非 UI token 问题)
- `DownloadWindow` / `ClipboardPopup` 仍有少量 hex,待统一
- ESLint 约束规则(第 5.3 节)尚未搭建
### 6.3 待决策
| # | 问题 | 状态 | 结论 / 影响 |
|---|---|---|---|
| 1 | 数据方向色是否与状态色解耦 | 已定(暂保留现有取值) | 保留"下载=绿、上传=红",已独立成 `--data-*` token,要换色只改这 2 个值 |
| 2 | 强调色用 `#0066CC` | ✅ 已采用 | 落地;同时解决 shadcn primary 近黑导致按钮发灰 |
| 3 | 浮窗(OSD/截图/下载)是否纳入统一体系 | 部分已定 | `OsdWindow` 免改(功能性配置);`DownloadWindow`/`ClipboardPopup` 少量 hex 待统一;不强行并入主界面 token |
| 4 | `--font-mono` 是否用网络字体 | ✅ 已定 | 系统 `Cascadia Code` / `Consolas`,不引外部字体 |
| 5 | 侧边栏宽度 60px | ✅ 已定 | 沿用现有实现 |
| 6 | 内容区限宽 | ✅ 已定 | 改为**不限宽占满**窗口宽度(原 `max-w-6xl` 限制已移除) |
| 7 | 卡片阴影 | ✅ 已定 | 卡片统一轻阴影 `shadow-sm`shadcn Card / InfoGroup card / StatCard 一致),浮层仍用 `shadow-popover`/`shadow-dialog` |
---
**维护约定**Token 值以 `src/style.css` 为唯一事实来源,本文档与之同步;两者冲突时以本文档为准并修正代码。新增 token 或组件须同步更新第 2、4 章。
+257 -3
View File
@@ -13,6 +13,13 @@
"@tauri-apps/plugin-global-shortcut": "^2",
"@tauri-apps/plugin-opener": "^2",
"@vueuse/core": "^14.4.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-search": "^0.16.0",
"@xterm/addon-serialize": "^0.14.0",
"@xterm/addon-unicode11": "^0.9.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/addon-webgl": "^0.19.0",
"@xterm/xterm": "^6.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"pinia": "^3.0.4",
@@ -28,6 +35,9 @@
"@tauri-apps/cli": "^2",
"@types/node": "^26.1.1",
"@vitejs/plugin-vue": "^5.2.1",
"@vue/eslint-config-typescript": "^14.9.0",
"eslint": "^10.11.0",
"eslint-plugin-vue": "^10.11.0",
"tw-animate-css": "^1.4.0",
"typescript": "~5.6.2",
"vite": "^6.0.3",
@@ -44,6 +54,10 @@
"@babel/types": ["@babel/types@7.29.7", "https://registry.npmmirror.com/@babel/types/-/types-7.29.7.tgz", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="],
"@cacheable/memory": ["@cacheable/memory@2.2.0", "https://registry.npmmirror.com/@cacheable/memory/-/memory-2.2.0.tgz", { "dependencies": { "@cacheable/utils": "^2.5.0", "@keyv/bigmap": "^1.3.1", "hookified": "^1.15.1", "keyv": "^5.6.0" } }, "sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ=="],
"@cacheable/utils": ["@cacheable/utils@2.5.0", "https://registry.npmmirror.com/@cacheable/utils/-/utils-2.5.0.tgz", { "dependencies": { "hashery": "^1.5.1", "keyv": "^5.6.0" } }, "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.25.12.tgz", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
@@ -96,6 +110,20 @@
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
"@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.10.1", "https://registry.npmmirror.com/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg=="],
"@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "https://registry.npmmirror.com/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="],
"@eslint/config-array": ["@eslint/config-array@0.23.5", "https://registry.npmmirror.com/@eslint/config-array/-/config-array-0.23.5.tgz", { "dependencies": { "@eslint/object-schema": "^3.0.5", "debug": "^4.3.1", "minimatch": "^10.2.4" } }, "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA=="],
"@eslint/config-helpers": ["@eslint/config-helpers@0.7.0", "https://registry.npmmirror.com/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", { "dependencies": { "@eslint/core": "^1.2.1" } }, "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw=="],
"@eslint/core": ["@eslint/core@1.2.1", "https://registry.npmmirror.com/@eslint/core/-/core-1.2.1.tgz", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ=="],
"@eslint/object-schema": ["@eslint/object-schema@3.0.5", "https://registry.npmmirror.com/@eslint/object-schema/-/object-schema-3.0.5.tgz", {}, "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw=="],
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.3", "https://registry.npmmirror.com/@eslint/plugin-kit/-/plugin-kit-0.7.3.tgz", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q=="],
"@floating-ui/core": ["@floating-ui/core@1.8.0", "https://registry.npmmirror.com/@floating-ui/core/-/core-1.8.0.tgz", { "dependencies": { "@floating-ui/utils": "^0.2.12" } }, "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ=="],
"@floating-ui/dom": ["@floating-ui/dom@1.8.0", "https://registry.npmmirror.com/@floating-ui/dom/-/dom-1.8.0.tgz", { "dependencies": { "@floating-ui/core": "^1.8.0", "@floating-ui/utils": "^0.2.12" } }, "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg=="],
@@ -104,6 +132,16 @@
"@floating-ui/vue": ["@floating-ui/vue@1.1.11", "https://registry.npmmirror.com/@floating-ui/vue/-/vue-1.1.11.tgz", { "dependencies": { "@floating-ui/dom": "^1.7.6", "@floating-ui/utils": "^0.2.11", "vue-demi": ">=0.13.0" } }, "sha512-HzHKCNVxnGS35r9fCHBc3+uCnjw9IWIlCPL683cGgM9Kgj2BiAl8x1mS7vtvP6F9S/e/q4O6MApwSHj8hNLGfw=="],
"@humanfs/core": ["@humanfs/core@0.19.2", "https://registry.npmmirror.com/@humanfs/core/-/core-0.19.2.tgz", { "dependencies": { "@humanfs/types": "^0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="],
"@humanfs/node": ["@humanfs/node@0.16.8", "https://registry.npmmirror.com/@humanfs/node/-/node-0.16.8.tgz", { "dependencies": { "@humanfs/core": "^0.19.2", "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ=="],
"@humanfs/types": ["@humanfs/types@0.15.0", "https://registry.npmmirror.com/@humanfs/types/-/types-0.15.0.tgz", {}, "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q=="],
"@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "https://registry.npmmirror.com/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="],
"@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "https://registry.npmmirror.com/@humanwhocodes/retry/-/retry-0.4.3.tgz", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="],
"@internationalized/date": ["@internationalized/date@3.12.2", "https://registry.npmmirror.com/@internationalized/date/-/date-3.12.2.tgz", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-FY1Y+H64NDs+HAF6omlnWxm3mEpfgaCSWtL5l551ZZfImA+kGjPFgrnJrGjH6lfmLL0g8Z/mBu1R3kufeCp6Jw=="],
"@internationalized/number": ["@internationalized/number@3.6.7", "https://registry.npmmirror.com/@internationalized/number/-/number-3.6.7.tgz", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-3ji1fcrT+FPAK86UqEhB/psHixYo6niWPJtt7+qRaYFynt/BaJG8GhAPimtWUpEiVSTq8ZM8L5psMxGquiB/Vg=="],
@@ -118,8 +156,18 @@
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@keyv/bigmap": ["@keyv/bigmap@1.3.1", "https://registry.npmmirror.com/@keyv/bigmap/-/bigmap-1.3.1.tgz", { "dependencies": { "hashery": "^1.4.0", "hookified": "^1.15.0" }, "peerDependencies": { "keyv": "^5.6.0" } }, "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ=="],
"@keyv/serialize": ["@keyv/serialize@1.1.1", "https://registry.npmmirror.com/@keyv/serialize/-/serialize-1.1.1.tgz", {}, "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA=="],
"@lucide/vue": ["@lucide/vue@1.28.0", "https://registry.npmmirror.com/@lucide/vue/-/vue-1.28.0.tgz", { "peerDependencies": { "vue": ">=3.0.1" } }, "sha512-cO89UyNM/0i2Srdi04nFWeEOQkqzDZlM2ehtWyBhcMpRTJDKpMZPCGAVlmVBgBgr5HaeL5L6FsU8RHsOdwXtKw=="],
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "https://registry.npmmirror.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
"@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "https://registry.npmmirror.com/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
"@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "https://registry.npmmirror.com/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.62.2", "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", { "os": "android", "cpu": "arm" }, "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.62.2", "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", { "os": "android", "cpu": "arm64" }, "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw=="],
@@ -240,14 +288,38 @@
"@tauri-apps/plugin-opener": ["@tauri-apps/plugin-opener@2.5.4", "https://registry.npmmirror.com/@tauri-apps/plugin-opener/-/plugin-opener-2.5.4.tgz", { "dependencies": { "@tauri-apps/api": "^2.11.0" } }, "sha512-1HnPkb+AmgO29HBazm4uPLKB+r7zzcTBW1d0fyYp1uP+jwtpoiNDGKMMzz58SFp49nOIrxdE3aUJtT57lfO9CQ=="],
"@types/esrecurse": ["@types/esrecurse@4.3.1", "https://registry.npmmirror.com/@types/esrecurse/-/esrecurse-4.3.1.tgz", {}, "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw=="],
"@types/estree": ["@types/estree@1.0.9", "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
"@types/json-schema": ["@types/json-schema@7.0.15", "https://registry.npmmirror.com/@types/json-schema/-/json-schema-7.0.15.tgz", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
"@types/node": ["@types/node@26.1.1", "https://registry.npmmirror.com/@types/node/-/node-26.1.1.tgz", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw=="],
"@types/sortablejs": ["@types/sortablejs@1.15.9", "https://registry.npmmirror.com/@types/sortablejs/-/sortablejs-1.15.9.tgz", {}, "sha512-7HP+rZGE2p886PKV9c9OJzLBI6BBJu1O7lJGYnPyG3fS4/duUCcngkNCjsLwIMV+WMqANe3tt4irrXHSIe68OQ=="],
"@types/web-bluetooth": ["@types/web-bluetooth@0.0.21", "https://registry.npmmirror.com/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", {}, "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA=="],
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.70.0", "https://registry.npmmirror.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.70.0.tgz", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.70.0", "@typescript-eslint/type-utils": "8.70.0", "@typescript-eslint/utils": "8.70.0", "@typescript-eslint/visitor-keys": "8.70.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.70.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-/v8HZt6RlyIZxB3ntehELOcUcfxKPVGWXnQdJuHRmzrqgF8nQypcC/oxGW+Ot4VGKDq81XugPKxx0n5PBtf9PA=="],
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.70.0", "https://registry.npmmirror.com/@typescript-eslint/parser/-/parser-8.70.0.tgz", { "dependencies": { "@typescript-eslint/scope-manager": "8.70.0", "@typescript-eslint/types": "8.70.0", "@typescript-eslint/typescript-estree": "8.70.0", "@typescript-eslint/visitor-keys": "8.70.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-zYvrmj9Yxd63UGaXw+kdt6A0F0s0qveJyuatIM77bYC2DE4pgmg7a50u8LR7PRtXd0x+h+Tl3eXabGm06SWd3Q=="],
"@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.70.0", "https://registry.npmmirror.com/@typescript-eslint/project-service/-/project-service-8.70.0.tgz", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.70.0", "@typescript-eslint/types": "^8.70.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-hFHbTNqhU9G+2eKFXCBVb1tjFT/LceiJ4+HfLO4pTpDI0KHi6iajpcFFkaSQ9gXmCh7n82A0PthaayEdN6mspQ=="],
"@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.70.0", "https://registry.npmmirror.com/@typescript-eslint/scope-manager/-/scope-manager-8.70.0.tgz", { "dependencies": { "@typescript-eslint/types": "8.70.0", "@typescript-eslint/visitor-keys": "8.70.0" } }, "sha512-8nP3Kwh5hlgZ4FicGvmznAmJe8UL4sdU8tLukrPaMuQmDuk4Y8xYfzu/aYZW4xT2JCgc7H/TpDI5cGlxcWJSqQ=="],
"@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.70.0", "https://registry.npmmirror.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.70.0.tgz", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-adnkeeNq9Sq1sUf4+FRVc0KdgYghzsgFpZSQVZVvY0LCuUuN0FnQgyGzCJeC4fW1cdXseBAjU2EOqUIjbNcZUw=="],
"@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.70.0", "https://registry.npmmirror.com/@typescript-eslint/type-utils/-/type-utils-8.70.0.tgz", { "dependencies": { "@typescript-eslint/types": "8.70.0", "@typescript-eslint/typescript-estree": "8.70.0", "@typescript-eslint/utils": "8.70.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-NUMKIhYVaVIVLnRL9CRt+VVcuLgSHUCpXn4/+K8wql+vdInUzvx8BjUO1oJ7cG9shjFJKtF8F8Hh2kCh3/KBVw=="],
"@typescript-eslint/types": ["@typescript-eslint/types@8.70.0", "https://registry.npmmirror.com/@typescript-eslint/types/-/types-8.70.0.tgz", {}, "sha512-asTOIYhDg4zdzOScCyaytrsV3cR6B4ecPQlXw/dJIm7J/MZTtCtfVII9JD8Geh4jTCrK/Xe6cg5UevoleMcoJQ=="],
"@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.70.0", "https://registry.npmmirror.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.70.0.tgz", { "dependencies": { "@typescript-eslint/project-service": "8.70.0", "@typescript-eslint/tsconfig-utils": "8.70.0", "@typescript-eslint/types": "8.70.0", "@typescript-eslint/visitor-keys": "8.70.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-d9NmHMPEKQ7QCLLm1jI3zmoQBwT5KwFYjXBJ9ymZfKCUU+5rmTRykKAFvH5Qn/ZCds3CEAFS9OC9M/jkl0X2bA=="],
"@typescript-eslint/utils": ["@typescript-eslint/utils@8.70.0", "https://registry.npmmirror.com/@typescript-eslint/utils/-/utils-8.70.0.tgz", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.70.0", "@typescript-eslint/types": "8.70.0", "@typescript-eslint/typescript-estree": "8.70.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-oZmtKJz/4fufZ2p3+Cn3ijEojcdfR+1zYDH2xKYrEly0dR/Q/1xUPRCOlKGxod78nWlU2UnDe09GZ3TaknBFGA=="],
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.70.0", "https://registry.npmmirror.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.70.0.tgz", { "dependencies": { "@typescript-eslint/types": "8.70.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-BoC8PiO4Hkdo0TVJh9Ntxr5MxPDI7/oFsrygN5ADelFSeXG/qgNuucIGA+L5Z6JpPTE/uRfcTWtscjbUaufepQ=="],
"@vitejs/plugin-vue": ["@vitejs/plugin-vue@5.2.4", "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", { "peerDependencies": { "vite": "^5.0.0 || ^6.0.0", "vue": "^3.2.25" } }, "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA=="],
"@volar/language-core": ["@volar/language-core@2.4.15", "https://registry.npmmirror.com/@volar/language-core/-/language-core-2.4.15.tgz", { "dependencies": { "@volar/source-map": "2.4.15" } }, "sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA=="],
@@ -272,6 +344,8 @@
"@vue/devtools-shared": ["@vue/devtools-shared@7.7.10", "https://registry.npmmirror.com/@vue/devtools-shared/-/devtools-shared-7.7.10.tgz", { "dependencies": { "rfdc": "^1.4.1" } }, "sha512-wOPslzB8vTvpxwdaOcR2qAbwmuSP0L+rhpoC6Cf56V3Jip+HWb7PQQXOUPgBNQARpXsbQX/+mvi8kKucmBGRwQ=="],
"@vue/eslint-config-typescript": ["@vue/eslint-config-typescript@14.9.0", "https://registry.npmmirror.com/@vue/eslint-config-typescript/-/eslint-config-typescript-14.9.0.tgz", { "dependencies": { "@typescript-eslint/utils": "^8.60.0", "fast-glob": "^3.3.3", "typescript-eslint": "^8.60.0", "vue-eslint-parser": "^10.4.0" }, "peerDependencies": { "eslint": "^9.10.0 || ^10.0.0", "eslint-plugin-vue": "^9.28.0 || ^10.0.0", "typescript": ">=4.8.4" }, "optionalPeers": ["typescript"], "bin": { "vue-eslint-config-typescript": "./dist/bin.js" } }, "sha512-E3j9hDlfVf10F30MRcLTPY2IIhWIx1nsvkVukk14kTcuA+oBVot9zsP1hzsO+PAMDxV3Fd9FimBJtUBNBL5KFA=="],
"@vue/language-core": ["@vue/language-core@2.2.12", "https://registry.npmmirror.com/@vue/language-core/-/language-core-2.2.12.tgz", { "dependencies": { "@volar/language-core": "2.4.15", "@vue/compiler-dom": "^3.5.0", "@vue/compiler-vue2": "^2.7.16", "@vue/shared": "^3.5.0", "alien-signals": "^1.0.3", "minimatch": "^9.0.3", "muggle-string": "^0.4.1", "path-browserify": "^1.0.1" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA=="],
"@vue/reactivity": ["@vue/reactivity@3.5.39", "https://registry.npmmirror.com/@vue/reactivity/-/reactivity-3.5.39.tgz", { "dependencies": { "@vue/shared": "3.5.39" } }, "sha512-TpsuBJ9gGlZa5d23XcM2y8EXanz9dZeVDQBXRwzy46ItgvM+rWpzs+UVM0wcRLxGvcav0HE5jz2gNL53xlRAog=="],
@@ -290,15 +364,41 @@
"@vueuse/shared": ["@vueuse/shared@14.4.0", "https://registry.npmmirror.com/@vueuse/shared/-/shared-14.4.0.tgz", { "peerDependencies": { "vue": "^3.5.0" } }, "sha512-JRgY90Sz8DDtPMsaDflvPMp9xYk69JZAmbuDvAquUVXKr2gEjqtzGNTTthLfckH0BzBqvnu31gb4a8TGLRe79g=="],
"@xterm/addon-fit": ["@xterm/addon-fit@0.11.0", "https://registry.npmmirror.com/@xterm/addon-fit/-/addon-fit-0.11.0.tgz", {}, "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g=="],
"@xterm/addon-search": ["@xterm/addon-search@0.16.0", "https://registry.npmmirror.com/@xterm/addon-search/-/addon-search-0.16.0.tgz", {}, "sha512-9OeuBFu0/uZJPu+9AHKY6g/w0Czyb/Ut0A5t79I4ULoU4IfU5BEpPFVGQxP4zTTMdfZEYkVIRYbHBX1xWwjeSA=="],
"@xterm/addon-serialize": ["@xterm/addon-serialize@0.14.0", "https://registry.npmmirror.com/@xterm/addon-serialize/-/addon-serialize-0.14.0.tgz", {}, "sha512-uteyTU1EkrQa2Ux6P/uFl2fzmXI46jy5uoQMKEOM0fKTyiW7cSn0WrFenHm5vO5uEXX/GpwW/FgILvv3r0WbkA=="],
"@xterm/addon-unicode11": ["@xterm/addon-unicode11@0.9.0", "https://registry.npmmirror.com/@xterm/addon-unicode11/-/addon-unicode11-0.9.0.tgz", {}, "sha512-FxDnYcyuXhNl+XSqGZL/t0U9eiNb/q3EWT5rYkQT/zuig8Gz/VagnQANKHdDWFM2lTMk9ly0EFQxxxtZUoRetw=="],
"@xterm/addon-web-links": ["@xterm/addon-web-links@0.12.0", "https://registry.npmmirror.com/@xterm/addon-web-links/-/addon-web-links-0.12.0.tgz", {}, "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw=="],
"@xterm/addon-webgl": ["@xterm/addon-webgl@0.19.0", "https://registry.npmmirror.com/@xterm/addon-webgl/-/addon-webgl-0.19.0.tgz", {}, "sha512-b3fMOsyLVuCeNJWxolACEUED0vm7qC0cy4wRvf3oURSzDTYVQiGPhTnhWZwIHdvC48Y+oLhvYXnY4XDXPoJo6A=="],
"@xterm/xterm": ["@xterm/xterm@6.0.0", "https://registry.npmmirror.com/@xterm/xterm/-/xterm-6.0.0.tgz", {}, "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg=="],
"acorn": ["acorn@8.18.0", "https://registry.npmmirror.com/acorn/-/acorn-8.18.0.tgz", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="],
"acorn-jsx": ["acorn-jsx@5.3.2", "https://registry.npmmirror.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
"ajv": ["ajv@6.15.0", "https://registry.npmmirror.com/ajv/-/ajv-6.15.0.tgz", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="],
"alien-signals": ["alien-signals@1.0.13", "https://registry.npmmirror.com/alien-signals/-/alien-signals-1.0.13.tgz", {}, "sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg=="],
"aria-hidden": ["aria-hidden@1.2.6", "https://registry.npmmirror.com/aria-hidden/-/aria-hidden-1.2.6.tgz", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
"balanced-match": ["balanced-match@1.0.2", "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"balanced-match": ["balanced-match@4.0.4", "https://registry.npmmirror.com/balanced-match/-/balanced-match-4.0.4.tgz", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
"birpc": ["birpc@2.9.0", "https://registry.npmmirror.com/birpc/-/birpc-2.9.0.tgz", {}, "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw=="],
"brace-expansion": ["brace-expansion@2.1.2", "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.1.2.tgz", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA=="],
"boolbase": ["boolbase@1.0.0", "https://registry.npmmirror.com/boolbase/-/boolbase-1.0.0.tgz", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="],
"brace-expansion": ["brace-expansion@5.0.12", "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-5.0.12.tgz", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ=="],
"braces": ["braces@3.0.3", "https://registry.npmmirror.com/braces/-/braces-3.0.3.tgz", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
"cacheable": ["cacheable@2.5.0", "https://registry.npmmirror.com/cacheable/-/cacheable-2.5.0.tgz", { "dependencies": { "@cacheable/memory": "^2.2.0", "@cacheable/utils": "^2.5.0", "hookified": "^1.15.0", "keyv": "^5.6.0", "qified": "^0.10.1" } }, "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g=="],
"class-variance-authority": ["class-variance-authority@0.7.1", "https://registry.npmmirror.com/class-variance-authority/-/class-variance-authority-0.7.1.tgz", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
@@ -306,10 +406,18 @@
"copy-anything": ["copy-anything@4.0.5", "https://registry.npmmirror.com/copy-anything/-/copy-anything-4.0.5.tgz", { "dependencies": { "is-what": "^5.2.0" } }, "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA=="],
"cross-spawn": ["cross-spawn@7.0.6", "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"cssesc": ["cssesc@3.0.0", "https://registry.npmmirror.com/cssesc/-/cssesc-3.0.0.tgz", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
"csstype": ["csstype@3.2.3", "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"de-indent": ["de-indent@1.0.2", "https://registry.npmmirror.com/de-indent/-/de-indent-1.0.2.tgz", {}, "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg=="],
"debug": ["debug@4.4.3", "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"deep-is": ["deep-is@0.1.4", "https://registry.npmmirror.com/deep-is/-/deep-is-0.1.4.tgz", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
"defu": ["defu@6.1.7", "https://registry.npmmirror.com/defu/-/defu-6.1.7.tgz", {}, "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ=="],
"detect-libc": ["detect-libc@2.1.2", "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
@@ -320,22 +428,88 @@
"esbuild": ["esbuild@0.25.12", "https://registry.npmmirror.com/esbuild/-/esbuild-0.25.12.tgz", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
"escape-string-regexp": ["escape-string-regexp@4.0.0", "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
"eslint": ["eslint@10.11.0", "https://registry.npmmirror.com/eslint/-/eslint-10.11.0.tgz", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.7.0", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.3", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "11.1.5 || >11.1.6 <12", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-P7a6UEEqb9G95MYAtqkmsTbVXIYyzIfl6NGOIJk162PaahFxFyeGcrlXYFSiagECg4sEm8IseJdZBKR3rx6MsQ=="],
"eslint-plugin-vue": ["eslint-plugin-vue@10.11.0", "https://registry.npmmirror.com/eslint-plugin-vue/-/eslint-plugin-vue-10.11.0.tgz", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "natural-compare": "^1.4.0", "nth-check": "^2.1.1", "postcss-selector-parser": "^7.1.4", "semver": "^7.8.5", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "@stylistic/eslint-plugin": "^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0", "@typescript-eslint/parser": "^7.0.0 || ^8.0.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "vue-eslint-parser": "^10.3.0" }, "optionalPeers": ["@stylistic/eslint-plugin", "@typescript-eslint/parser"] }, "sha512-fGt38SsaPCZ2FmA3bX/3vOW2qdsPNx0/9x+7Aab6NMX++PhynMtK4LWC4vzXgfQBCqpUAler18H38GA+NBAd0Q=="],
"eslint-scope": ["eslint-scope@9.1.2", "https://registry.npmmirror.com/eslint-scope/-/eslint-scope-9.1.2.tgz", { "dependencies": { "@types/esrecurse": "^4.3.1", "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ=="],
"eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="],
"espree": ["espree@11.2.0", "https://registry.npmmirror.com/espree/-/espree-11.2.0.tgz", { "dependencies": { "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^5.0.1" } }, "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw=="],
"esquery": ["esquery@1.7.0", "https://registry.npmmirror.com/esquery/-/esquery-1.7.0.tgz", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="],
"esrecurse": ["esrecurse@4.3.0", "https://registry.npmmirror.com/esrecurse/-/esrecurse-4.3.0.tgz", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="],
"estraverse": ["estraverse@5.3.0", "https://registry.npmmirror.com/estraverse/-/estraverse-5.3.0.tgz", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="],
"estree-walker": ["estree-walker@2.0.2", "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
"esutils": ["esutils@2.0.3", "https://registry.npmmirror.com/esutils/-/esutils-2.0.3.tgz", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-glob": ["fast-glob@3.3.3", "https://registry.npmmirror.com/fast-glob/-/fast-glob-3.3.3.tgz", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "https://registry.npmmirror.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
"fast-levenshtein": ["fast-levenshtein@2.0.6", "https://registry.npmmirror.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
"fastq": ["fastq@1.20.3", "https://registry.npmmirror.com/fastq/-/fastq-1.20.3.tgz", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw=="],
"fdir": ["fdir@6.5.0", "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"file-entry-cache": ["file-entry-cache@11.1.5", "https://registry.npmmirror.com/file-entry-cache/-/file-entry-cache-11.1.5.tgz", { "dependencies": { "flat-cache": "^6.1.23" } }, "sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q=="],
"fill-range": ["fill-range@7.1.1", "https://registry.npmmirror.com/fill-range/-/fill-range-7.1.1.tgz", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
"find-up": ["find-up@5.0.0", "https://registry.npmmirror.com/find-up/-/find-up-5.0.0.tgz", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="],
"flat-cache": ["flat-cache@6.1.23", "https://registry.npmmirror.com/flat-cache/-/flat-cache-6.1.23.tgz", { "dependencies": { "cacheable": "^2.5.0", "flatted": "^3.4.2", "hookified": "^1.15.0" } }, "sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA=="],
"flatted": ["flatted@3.4.4", "https://registry.npmmirror.com/flatted/-/flatted-3.4.4.tgz", {}, "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q=="],
"fsevents": ["fsevents@2.3.3", "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"glob-parent": ["glob-parent@6.0.2", "https://registry.npmmirror.com/glob-parent/-/glob-parent-6.0.2.tgz", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
"graceful-fs": ["graceful-fs@4.2.11", "https://registry.npmmirror.com/graceful-fs/-/graceful-fs-4.2.11.tgz", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"hashery": ["hashery@1.5.1", "https://registry.npmmirror.com/hashery/-/hashery-1.5.1.tgz", { "dependencies": { "hookified": "^1.15.0" } }, "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ=="],
"he": ["he@1.2.0", "https://registry.npmmirror.com/he/-/he-1.2.0.tgz", { "bin": { "he": "bin/he" } }, "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="],
"hookable": ["hookable@5.5.3", "https://registry.npmmirror.com/hookable/-/hookable-5.5.3.tgz", {}, "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="],
"hookified": ["hookified@1.15.1", "https://registry.npmmirror.com/hookified/-/hookified-1.15.1.tgz", {}, "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg=="],
"ignore": ["ignore@5.3.2", "https://registry.npmmirror.com/ignore/-/ignore-5.3.2.tgz", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
"imurmurhash": ["imurmurhash@0.1.4", "https://registry.npmmirror.com/imurmurhash/-/imurmurhash-0.1.4.tgz", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
"is-extglob": ["is-extglob@2.1.1", "https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
"is-glob": ["is-glob@4.0.3", "https://registry.npmmirror.com/is-glob/-/is-glob-4.0.3.tgz", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
"is-number": ["is-number@7.0.0", "https://registry.npmmirror.com/is-number/-/is-number-7.0.0.tgz", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
"is-what": ["is-what@5.5.0", "https://registry.npmmirror.com/is-what/-/is-what-5.5.0.tgz", {}, "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw=="],
"isexe": ["isexe@2.0.0", "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"jiti": ["jiti@2.7.0", "https://registry.npmmirror.com/jiti/-/jiti-2.7.0.tgz", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="],
"json-schema-traverse": ["json-schema-traverse@0.4.1", "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
"json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "https://registry.npmmirror.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="],
"keyv": ["keyv@5.6.0", "https://registry.npmmirror.com/keyv/-/keyv-5.6.0.tgz", { "dependencies": { "@keyv/serialize": "^1.1.1" } }, "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw=="],
"levn": ["levn@0.4.1", "https://registry.npmmirror.com/levn/-/levn-0.4.1.tgz", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
"lightningcss": ["lightningcss@1.32.0", "https://registry.npmmirror.com/lightningcss/-/lightningcss-1.32.0.tgz", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "https://registry.npmmirror.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
@@ -360,20 +534,42 @@
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "https://registry.npmmirror.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
"locate-path": ["locate-path@6.0.0", "https://registry.npmmirror.com/locate-path/-/locate-path-6.0.0.tgz", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
"magic-string": ["magic-string@0.30.21", "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"minimatch": ["minimatch@9.0.9", "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.9.tgz", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="],
"merge2": ["merge2@1.4.1", "https://registry.npmmirror.com/merge2/-/merge2-1.4.1.tgz", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
"micromatch": ["micromatch@4.0.8", "https://registry.npmmirror.com/micromatch/-/micromatch-4.0.8.tgz", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
"minimatch": ["minimatch@10.2.6", "https://registry.npmmirror.com/minimatch/-/minimatch-10.2.6.tgz", { "dependencies": { "brace-expansion": "^5.0.8" } }, "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A=="],
"mitt": ["mitt@3.0.1", "https://registry.npmmirror.com/mitt/-/mitt-3.0.1.tgz", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="],
"ms": ["ms@2.1.3", "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"muggle-string": ["muggle-string@0.4.1", "https://registry.npmmirror.com/muggle-string/-/muggle-string-0.4.1.tgz", {}, "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ=="],
"nanoid": ["nanoid@3.3.16", "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.16.tgz", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q=="],
"natural-compare": ["natural-compare@1.4.0", "https://registry.npmmirror.com/natural-compare/-/natural-compare-1.4.0.tgz", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
"nth-check": ["nth-check@2.1.1", "https://registry.npmmirror.com/nth-check/-/nth-check-2.1.1.tgz", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="],
"ohash": ["ohash@2.0.11", "https://registry.npmmirror.com/ohash/-/ohash-2.0.11.tgz", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="],
"optionator": ["optionator@0.9.4", "https://registry.npmmirror.com/optionator/-/optionator-0.9.4.tgz", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
"p-limit": ["p-limit@3.1.0", "https://registry.npmmirror.com/p-limit/-/p-limit-3.1.0.tgz", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
"p-locate": ["p-locate@5.0.0", "https://registry.npmmirror.com/p-locate/-/p-locate-5.0.0.tgz", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
"path-browserify": ["path-browserify@1.0.1", "https://registry.npmmirror.com/path-browserify/-/path-browserify-1.0.1.tgz", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="],
"path-exists": ["path-exists@4.0.0", "https://registry.npmmirror.com/path-exists/-/path-exists-4.0.0.tgz", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
"path-key": ["path-key@3.1.1", "https://registry.npmmirror.com/path-key/-/path-key-3.1.1.tgz", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"perfect-debounce": ["perfect-debounce@1.0.0", "https://registry.npmmirror.com/perfect-debounce/-/perfect-debounce-1.0.0.tgz", {}, "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA=="],
"picocolors": ["picocolors@1.1.1", "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
@@ -386,12 +582,32 @@
"postcss": ["postcss@8.5.19", "https://registry.npmmirror.com/postcss/-/postcss-8.5.19.tgz", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ=="],
"postcss-selector-parser": ["postcss-selector-parser@7.1.6", "https://registry.npmmirror.com/postcss-selector-parser/-/postcss-selector-parser-7.1.6.tgz", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw=="],
"prelude-ls": ["prelude-ls@1.2.1", "https://registry.npmmirror.com/prelude-ls/-/prelude-ls-1.2.1.tgz", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
"punycode": ["punycode@2.3.1", "https://registry.npmmirror.com/punycode/-/punycode-2.3.1.tgz", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"qified": ["qified@0.10.1", "https://registry.npmmirror.com/qified/-/qified-0.10.1.tgz", { "dependencies": { "hookified": "^2.1.1" } }, "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA=="],
"queue-microtask": ["queue-microtask@1.2.3", "https://registry.npmmirror.com/queue-microtask/-/queue-microtask-1.2.3.tgz", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
"reka-ui": ["reka-ui@2.10.1", "https://registry.npmmirror.com/reka-ui/-/reka-ui-2.10.1.tgz", { "dependencies": { "@floating-ui/dom": "^1.6.13", "@floating-ui/vue": "^1.1.6", "@internationalized/date": "^3.5.0", "@internationalized/number": "^3.5.0", "@tanstack/vue-virtual": "^3.12.0", "@vueuse/core": "^14.1.0", "@vueuse/shared": "^14.1.0", "aria-hidden": "^1.2.4", "defu": "^6.1.5", "ohash": "^2.0.11" }, "peerDependencies": { "vue": ">= 3.4.0" } }, "sha512-drcOQ4rQtDYAcGCsyQBqQg8QQ+H3B+zDaMJU0h8KPEPMa7g9BHu3zcOi4OB39XJSWizceFoNO0Z9tctSGLOXqg=="],
"reusify": ["reusify@1.1.0", "https://registry.npmmirror.com/reusify/-/reusify-1.1.0.tgz", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
"rfdc": ["rfdc@1.4.1", "https://registry.npmmirror.com/rfdc/-/rfdc-1.4.1.tgz", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="],
"rollup": ["rollup@4.62.2", "https://registry.npmmirror.com/rollup/-/rollup-4.62.2.tgz", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.62.2", "@rollup/rollup-android-arm64": "4.62.2", "@rollup/rollup-darwin-arm64": "4.62.2", "@rollup/rollup-darwin-x64": "4.62.2", "@rollup/rollup-freebsd-arm64": "4.62.2", "@rollup/rollup-freebsd-x64": "4.62.2", "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", "@rollup/rollup-linux-arm-musleabihf": "4.62.2", "@rollup/rollup-linux-arm64-gnu": "4.62.2", "@rollup/rollup-linux-arm64-musl": "4.62.2", "@rollup/rollup-linux-loong64-gnu": "4.62.2", "@rollup/rollup-linux-loong64-musl": "4.62.2", "@rollup/rollup-linux-ppc64-gnu": "4.62.2", "@rollup/rollup-linux-ppc64-musl": "4.62.2", "@rollup/rollup-linux-riscv64-gnu": "4.62.2", "@rollup/rollup-linux-riscv64-musl": "4.62.2", "@rollup/rollup-linux-s390x-gnu": "4.62.2", "@rollup/rollup-linux-x64-gnu": "4.62.2", "@rollup/rollup-linux-x64-musl": "4.62.2", "@rollup/rollup-openbsd-x64": "4.62.2", "@rollup/rollup-openharmony-arm64": "4.62.2", "@rollup/rollup-win32-arm64-msvc": "4.62.2", "@rollup/rollup-win32-ia32-msvc": "4.62.2", "@rollup/rollup-win32-x64-gnu": "4.62.2", "@rollup/rollup-win32-x64-msvc": "4.62.2", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA=="],
"run-parallel": ["run-parallel@1.2.0", "https://registry.npmmirror.com/run-parallel/-/run-parallel-1.2.0.tgz", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
"semver": ["semver@7.8.5", "https://registry.npmmirror.com/semver/-/semver-7.8.5.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
"shebang-command": ["shebang-command@2.0.0", "https://registry.npmmirror.com/shebang-command/-/shebang-command-2.0.0.tgz", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
"shebang-regex": ["shebang-regex@3.0.0", "https://registry.npmmirror.com/shebang-regex/-/shebang-regex-3.0.0.tgz", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
"source-map-js": ["source-map-js@1.2.1", "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"speakingurl": ["speakingurl@14.0.1", "https://registry.npmmirror.com/speakingurl/-/speakingurl-14.0.1.tgz", {}, "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ=="],
@@ -406,14 +622,26 @@
"tinyglobby": ["tinyglobby@0.2.17", "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.17.tgz", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
"to-regex-range": ["to-regex-range@5.0.1", "https://registry.npmmirror.com/to-regex-range/-/to-regex-range-5.0.1.tgz", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
"ts-api-utils": ["ts-api-utils@2.5.0", "https://registry.npmmirror.com/ts-api-utils/-/ts-api-utils-2.5.0.tgz", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="],
"tslib": ["tslib@2.8.1", "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"tw-animate-css": ["tw-animate-css@1.4.0", "https://registry.npmmirror.com/tw-animate-css/-/tw-animate-css-1.4.0.tgz", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="],
"type-check": ["type-check@0.4.0", "https://registry.npmmirror.com/type-check/-/type-check-0.4.0.tgz", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
"typescript": ["typescript@5.6.3", "https://registry.npmmirror.com/typescript/-/typescript-5.6.3.tgz", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw=="],
"typescript-eslint": ["typescript-eslint@8.70.0", "https://registry.npmmirror.com/typescript-eslint/-/typescript-eslint-8.70.0.tgz", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.70.0", "@typescript-eslint/parser": "8.70.0", "@typescript-eslint/typescript-estree": "8.70.0", "@typescript-eslint/utils": "8.70.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-P/W5cz70/cQAuKfY3xwQMWWTV7BvJ0mAQmi+9mBcsVPaBUpd6Ohpa+fECv9rBFrQcig86jAiNBFNWUqnTjr4pw=="],
"undici-types": ["undici-types@8.3.0", "https://registry.npmmirror.com/undici-types/-/undici-types-8.3.0.tgz", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
"uri-js": ["uri-js@4.4.1", "https://registry.npmmirror.com/uri-js/-/uri-js-4.4.1.tgz", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
"util-deprecate": ["util-deprecate@1.0.2", "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
"vite": ["vite@6.4.3", "https://registry.npmmirror.com/vite/-/vite-6.4.3.tgz", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A=="],
"vscode-uri": ["vscode-uri@3.1.0", "https://registry.npmmirror.com/vscode-uri/-/vscode-uri-3.1.0.tgz", {}, "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ=="],
@@ -424,10 +652,22 @@
"vue-draggable-plus": ["vue-draggable-plus@0.6.1", "https://registry.npmmirror.com/vue-draggable-plus/-/vue-draggable-plus-0.6.1.tgz", { "dependencies": { "@types/sortablejs": "^1.15.8" }, "peerDependencies": { "@vue/composition-api": "*" }, "optionalPeers": ["@vue/composition-api"] }, "sha512-FbtQ/fuoixiOfTZzG3yoPl4JAo9HJXRHmBQZFB9x2NYCh6pq0TomHf7g5MUmpaDYv+LU2n6BPq2YN9sBO+FbIg=="],
"vue-eslint-parser": ["vue-eslint-parser@10.4.1", "https://registry.npmmirror.com/vue-eslint-parser/-/vue-eslint-parser-10.4.1.tgz", { "dependencies": { "debug": "^4.4.0", "eslint-scope": "^8.2.0 || ^9.0.0", "eslint-visitor-keys": "^4.2.0 || ^5.0.0", "espree": "^10.3.0 || ^11.0.0", "esquery": "^1.6.0", "semver": "^7.6.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0" } }, "sha512-Gk6gRDj0n/fkRa3C3l0bBheoBckUq/Rs0F/TvMWIS6nzzx67amAViMe9CkNgsP2tXyQONvGiHQESHwFtZ3aYDA=="],
"vue-sonner": ["vue-sonner@2.0.9", "https://registry.npmmirror.com/vue-sonner/-/vue-sonner-2.0.9.tgz", { "peerDependencies": { "@nuxt/kit": "^4.0.3", "@nuxt/schema": "^4.0.3", "nuxt": "^4.0.3" }, "optionalPeers": ["@nuxt/kit", "@nuxt/schema", "nuxt"] }, "sha512-i6BokNlNDL93fpzNxN/LZSn6D6MzlO+i3qXt6iVZne3x1k7R46d5HlFB4P8tYydhgqOrRbIZEsnRd3kG7qGXyw=="],
"vue-tsc": ["vue-tsc@2.2.12", "https://registry.npmmirror.com/vue-tsc/-/vue-tsc-2.2.12.tgz", { "dependencies": { "@volar/typescript": "2.4.15", "@vue/language-core": "2.2.12" }, "peerDependencies": { "typescript": ">=5.0.0" }, "bin": { "vue-tsc": "./bin/vue-tsc.js" } }, "sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw=="],
"which": ["which@2.0.2", "https://registry.npmmirror.com/which/-/which-2.0.2.tgz", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"word-wrap": ["word-wrap@1.2.5", "https://registry.npmmirror.com/word-wrap/-/word-wrap-1.2.5.tgz", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
"xml-name-validator": ["xml-name-validator@5.0.0", "https://registry.npmmirror.com/xml-name-validator/-/xml-name-validator-5.0.0.tgz", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="],
"yocto-queue": ["yocto-queue@0.1.0", "https://registry.npmmirror.com/yocto-queue/-/yocto-queue-0.1.0.tgz", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.2", "https://registry.npmmirror.com/@emnapi/core/-/core-1.11.2.tgz", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" }, "bundled": true }, "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.2", "https://registry.npmmirror.com/@emnapi/runtime/-/runtime-1.11.2.tgz", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA=="],
@@ -440,10 +680,24 @@
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.9", "https://registry.npmmirror.com/ignore/-/ignore-7.0.9.tgz", {}, "sha512-brTTsvFRt5C1gGHtPst/281UjPD5t9fBqbgoMPlVWy11ZLTPfu7HxK4ZYqO9H7o/yC9rSTCI85EaQ4OoY12qYw=="],
"@vue/language-core/minimatch": ["minimatch@9.0.9", "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.9.tgz", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="],
"fast-glob/glob-parent": ["glob-parent@5.1.2", "https://registry.npmmirror.com/glob-parent/-/glob-parent-5.1.2.tgz", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"micromatch/picomatch": ["picomatch@2.3.2", "https://registry.npmmirror.com/picomatch/-/picomatch-2.3.2.tgz", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
"qified/hookified": ["hookified@2.2.0", "https://registry.npmmirror.com/hookified/-/hookified-2.2.0.tgz", {}, "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA=="],
"reka-ui/@vueuse/core": ["@vueuse/core@14.3.0", "https://registry.npmmirror.com/@vueuse/core/-/core-14.3.0.tgz", { "dependencies": { "@types/web-bluetooth": "^0.0.21", "@vueuse/metadata": "14.3.0", "@vueuse/shared": "14.3.0" }, "peerDependencies": { "vue": "^3.5.0" } }, "sha512-aHfz47g0ZhMtTVHmIzMVpJy8ePhhOy68GY5bv110+5DVtZ+W7BsOx+m61UNQqfrWyPztIHIanWa3E2tib3NFIw=="],
"reka-ui/@vueuse/shared": ["@vueuse/shared@14.3.0", "https://registry.npmmirror.com/@vueuse/shared/-/shared-14.3.0.tgz", { "peerDependencies": { "vue": "^3.5.0" } }, "sha512-bZpge9eSXwa4ToSiqJ7j6KRwhAsneMFoSz3LMWKQDkqimm3D/tbFlrklrs/IOqC8tEcYmXQZJ6N0UrjhBirVCg=="],
"@vue/language-core/minimatch/brace-expansion": ["brace-expansion@2.1.2", "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.1.2.tgz", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA=="],
"reka-ui/@vueuse/core/@vueuse/metadata": ["@vueuse/metadata@14.3.0", "https://registry.npmmirror.com/@vueuse/metadata/-/metadata-14.3.0.tgz", {}, "sha512-BwxmbAzwAVF50+MW57GXOUEV61nFBGnlBvrTqj49PqWJu3uw7hdu72ztXeZ33RdZtDY6kO+bfCAE1PCn88Tktw=="],
"@vue/language-core/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
}
}
+80
View File
@@ -0,0 +1,80 @@
// ESLint 平坦配置 —— 落地 UI_DESIGN_SYSTEM.md 第 5 章的 D 系约束。
// 依赖:eslint / eslint-plugin-vue / @vue/eslint-config-typescript(见 package.json devDependencies
// 运行:bun run lint
import pluginVue from 'eslint-plugin-vue'
import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript'
export default defineConfigWithVueTs(
{
ignores: [
'dist/**',
'node_modules/**',
'src-tauri/**',
'release_stage/**',
'ThingHK/**',
],
},
// flat/essential:只保留「防错」规则(v-for key、重复 attr 等),
// 不含缩进/引号等格式规则 —— 3183 条里 3038 条是格式噪音,与既有代码风格无关
pluginVue.configs['flat/essential'],
vueTsConfigs.recommended,
{
rules: {
// ---- D2:禁止 Tailwind 原生色阶,必须使用语义 token ----
'no-restricted-syntax': [
'error',
{
selector:
"Literal[value=/(bg|text|border|ring|fill|stroke)-(slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-[0-9]{2,3}(\\/[0-9]{1,3})?/]",
message:
'D2 禁止 Tailwind 原生色阶:请使用语义 tokenbg-card / text-success-text / border-border 等),见 UI_DESIGN_SYSTEM.md',
},
{
// D9:生产代码禁止外部字体 CDN
selector:
"Literal[value=/@import\\s+url\\(['\\\"]?https:\\/\\/(fonts\\.googleapis|fonts\\.gstatic)\\./]",
message: 'D9 禁止外部字体 CDN:使用系统字体栈',
},
],
// ---- 未使用变量:_ 前缀豁免(Pinia action 占位、解构占位等惯例)----
'@typescript-eslint/no-unused-vars': [
'error',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrors: 'none',
},
],
// ---- Vue 风格:与既有代码一致 ----
'vue/multi-word-component-names': 'off',
'vue/max-attributes-per-line': 'off',
'vue/singleline-html-element-content-newline': 'off',
'vue/html-self-closing': 'off',
'vue/attributes-order': 'off',
},
},
// .d.ts 声明文件:Vue 官方模板的 ImportMetaEnv 写法使用 {} 与 any,豁免
{
files: ['**/*.d.ts'],
rules: {
'@typescript-eslint/no-empty-object-type': 'off',
'@typescript-eslint/no-explicit-any': 'off',
},
},
// feiniuStore 是飞牛 NAS 的 API 适配层:后端原始结构未在前端定型(字段类型化需对接 Rust 端),
// 边界层暂豁免 no-explicit-any;定型后应移除此豁免
{
files: ['src/stores/feiniuStore.ts'],
rules: {
'@typescript-eslint/no-explicit-any': 'off',
},
},
// xterm 等第三方深色区域如需豁免,在下方追加 overrides
)
+5 -1
View File
@@ -8,8 +8,9 @@
"build": "vue-tsc --noEmit && node --max-old-space-size=8192 node_modules/vite/bin/vite.js build",
"build:vite": "node --max-old-space-size=8192 node_modules/vite/bin/vite.js build",
"typecheck": "vue-tsc --noEmit",
"lint": "eslint src",
"preview": "vite preview",
"test": "node --test src/modules/quickpanel/engine.test.ts src/lib/calc.test.ts",
"test": "node --test src/modules/quickpanel/engine.test.ts src/lib/calc.test.ts src/lib/theme.test.ts",
"tauri": "tauri"
},
"dependencies": {
@@ -43,6 +44,9 @@
"@tauri-apps/cli": "^2",
"@types/node": "^26.1.1",
"@vitejs/plugin-vue": "^5.2.1",
"@vue/eslint-config-typescript": "^14.9.0",
"eslint": "^10.11.0",
"eslint-plugin-vue": "^10.11.0",
"tw-animate-css": "^1.4.0",
"typescript": "~5.6.2",
"vite": "^6.0.3",
+4
View File
@@ -11,6 +11,7 @@
"dialog:default",
"core:window:allow-minimize",
"core:window:allow-maximize",
"core:window:allow-unminimize",
"core:window:allow-close",
"core:window:allow-toggle-maximize",
"core:window:allow-hide",
@@ -26,6 +27,9 @@
"core:window:allow-set-always-on-top",
"core:window:allow-set-skip-taskbar",
"core:window:allow-set-decorations",
"global-shortcut:allow-register",
"global-shortcut:allow-unregister",
"global-shortcut:allow-is-registered",
"snap-layout:default"
]
}
@@ -5,11 +5,13 @@
"windows": ["terminal-window-*"],
"permissions": [
"core:default",
"core:window:allow-show",
"core:window:allow-close",
"core:window:allow-minimize",
"core:window:allow-maximize",
"core:window:allow-toggle-maximize",
"core:window:allow-set-focus",
"core:window:allow-set-title",
"core:window:allow-start-dragging",
"core:window:allow-set-theme",
"core:window:allow-set-background-color",
+34 -3
View File
@@ -625,8 +625,9 @@ pub fn hide_preview(app: &AppHandle) {
/// - 交互锁定模式(点击过预览窗的放大/缩小/复制等):弹窗已失焦不会再收到
/// Focused(false),检测"鼠标按下沿且按下位置在弹窗+预览之外"→ 点击外部,关闭弹窗。
///
/// 公共退出条件:弹窗已隐藏/销毁(其他路径关闭);弹窗重新获得焦点(用户点击
/// 弹窗,恢复正常失焦关闭路径,并复位交互锁定);超时兜底防线程泄漏
/// 退出条件:弹窗已隐藏/销毁(其他路径关闭);弹窗重新获得焦点(用户点击
/// 弹窗,恢复正常失焦关闭路径,并复位交互锁定);**光标回到弹窗内并停留片刻
/// (交互锁定解除,但看护继续以常规模式运行)**;超时兜底防线程泄漏
/// (常规 30s;交互锁定为事件驱动,放宽至 10min)。
fn spawn_popup_leave_watch(app: AppHandle) {
// 防重入:已有看护在运行则跳过
@@ -634,9 +635,12 @@ fn spawn_popup_leave_watch(app: AppHandle) {
return;
}
std::thread::spawn(move || {
let start = Instant::now();
// 超时兜底的起点:交互锁定解除时重置(那之后按常规模式的 30s 计)
let mut start = Instant::now();
let mut outside = 0u32;
let mut was_down = false;
// 交互锁定期间光标停在弹窗内的连续轮数(30ms/轮)
let mut back_in_popup = 0u32;
loop {
std::thread::sleep(Duration::from_millis(30));
// 弹窗已隐藏或被销毁:看护结束
@@ -654,6 +658,33 @@ fn spawn_popup_leave_watch(app: AppHandle) {
POPUP_INTERACTED.store(false, Ordering::SeqCst);
break;
}
// 交互锁定期间光标已回到弹窗内:用户离开预览窗、回到列表继续浏览 ——
// 解除锁定,让前端的悬停逻辑重新生效(否则 hide_preview 在锁定期间一律
// 被忽略,表现为「鼠标回到弹窗、悬停在不需要预览的条目上,预览窗不关」)。
//
// 这里**只解除锁定、不 break**:弹窗此刻没有焦点,不会再收到 Focused(false)
// 若连看护也退出,它就成了无人负责关闭的残留窗口。继续跑常规模式,
// 光标离开弹窗+预览区域约 300ms 后照常关闭。
// 连续计数(~210ms)用来排除「弹窗-预览之间的间隙」造成的瞬时误判。
if POPUP_INTERACTED.load(Ordering::SeqCst) {
if is_cursor_in_popup(&app) {
back_in_popup += 1;
if back_in_popup >= 7 {
POPUP_INTERACTED.store(false, Ordering::SeqCst);
back_in_popup = 0;
// 兜底超时从「解除锁定」这一刻重新计时
start = Instant::now();
// 顺势收起预览:用户回到弹窗意味着意图已从「看预览」变回
// 「浏览列表」。前端在悬停时发出的那次 hide_preview 落在锁定
// 期间、已被忽略,这里补上(否则预览会一直挂到下次悬停)。
hide_preview(&app);
}
} else {
back_in_popup = 0;
}
} else {
back_in_popup = 0;
}
let interacted = POPUP_INTERACTED.load(Ordering::SeqCst);
// 超长兜底:避免异常路径下看护线程无限轮询
let timeout = if interacted {
+6
View File
@@ -94,6 +94,12 @@ pub mod events {
// 终端模块:SFTP 传输进度(负载见 terminal::ssh::sftp::TransferProgress
// 节流后发送(约 200ms 一次),前端据此画进度条
pub const TERMINAL_TRANSFER_PROGRESS: &str = "terminal-transfer-progress";
// 终端模块:设置已保存(无负载)。
//
// 终端设置存在 Rust 侧,而每个窗口(主窗口 / 终端独立窗口 / 选择窗口)各有
// 一份前端副本 —— 没有这个事件,在主窗口改完外观后,已打开的独立窗口会一直
// 用旧配色,直到重开窗口。
pub const TERMINAL_SETTINGS: &str = "terminal-settings";
// 音乐模块:下载任务事件(桥接事件行 → 前端,负载见 bridge.py _emit_event
pub const MUSIC_DOWNLOAD_EVENT: &str = "music-download-event";
// 后端自动切换节点完成(前端据以刷新节点列表并提示)
+52 -8
View File
@@ -19,7 +19,7 @@ use std::collections::HashMap;
use serde::Serialize;
use specta::Type;
use tauri::{AppHandle, Manager};
use tauri::{AppHandle, Emitter, Manager};
use super::assistant;
use super::audit;
@@ -64,6 +64,15 @@ impl ActionOutcome {
// ===== 设置 =====
/// 广播「终端设置已保存」。
///
/// 终端设置存在 Rust 侧,而每个窗口(主窗口 / 终端独立窗口 / 选择窗口)各有自己的
/// 前端副本。没有这个事件时,在主窗口改完外观后,已经打开的独立窗口会一直用旧配色
/// (画布配色、字号、状态栏显隐都不同步),只能重开窗口。
fn notify_settings_changed(app: &AppHandle) {
let _ = app.emit(crate::constants::events::TERMINAL_SETTINGS, ());
}
/// 读取终端设置。
#[tauri::command]
#[specta::specta]
@@ -86,6 +95,7 @@ pub async fn terminal_save_settings(
) -> Result<TerminalSettings, String> {
let m = manager(&app)?;
m.save_settings(settings.clone())?;
notify_settings_changed(&app);
Ok(m.settings())
}
@@ -97,7 +107,9 @@ pub async fn terminal_save_appearance(
appearance: AppearanceSettings,
) -> Result<TerminalSettings, String> {
let m = manager(&app)?;
m.update_settings(|s| s.appearance = appearance)
let out = m.update_settings(|s| s.appearance = appearance)?;
notify_settings_changed(&app);
Ok(out)
}
/// 保存布局设置。
@@ -108,7 +120,9 @@ pub async fn terminal_save_layout(
layout: LayoutSettings,
) -> Result<TerminalSettings, String> {
let m = manager(&app)?;
m.update_settings(|s| s.layout = layout)
let out = m.update_settings(|s| s.layout = layout)?;
notify_settings_changed(&app);
Ok(out)
}
/// 保存终端内选中行为。
@@ -119,7 +133,9 @@ pub async fn terminal_save_selection(
selection: TerminalSelectionSettings,
) -> Result<TerminalSettings, String> {
let m = manager(&app)?;
m.update_settings(|s| s.selection = selection)
let out = m.update_settings(|s| s.selection = selection)?;
notify_settings_changed(&app);
Ok(out)
}
/// 保存安全设置。
@@ -130,7 +146,9 @@ pub async fn terminal_save_security(
security: SecuritySettings,
) -> Result<TerminalSettings, String> {
let m = manager(&app)?;
m.update_settings(|s| s.security = security)
let out = m.update_settings(|s| s.security = security)?;
notify_settings_changed(&app);
Ok(out)
}
/// 保存快捷键绑定(整组覆盖)。
@@ -154,7 +172,9 @@ pub async fn terminal_save_shortcuts(
}
seen.push((key, s.action.clone()));
}
m.update_settings(|st| st.shortcuts = shortcuts)
let out = m.update_settings(|st| st.shortcuts = shortcuts)?;
notify_settings_changed(&app);
Ok(out)
}
// ===== 本地 Shell =====
@@ -283,7 +303,9 @@ pub async fn terminal_open_local(
app.clone(),
id.clone(),
profile.id.clone(),
profile.name.clone(),
// 标签上的短名,与设置页的描述名(`profile.name`)刻意分开:
// 描述名可编辑、可很长,而标签要能一行放下多个会话(见 `session_label`
shell::session_label(&profile),
command_line,
work_dir,
shell::env_pairs(&profile.env),
@@ -467,6 +489,7 @@ pub async fn terminal_open_ssh(
},
jump_chain: jump_hops,
encoding: host.encoding.clone(),
inject_shell_integration: host.inject_shell_integration,
};
let session = ssh::SshSession::spawn(app.clone(), id, params, m.ssh_pool.clone());
@@ -564,6 +587,12 @@ pub async fn terminal_resize(
}
/// 重命名会话(同时更新标签与独立窗口标题)。
///
/// # 为什么用 `set_title_by_user` 而不是直接写 `title`
///
/// 重命名会把标题**锁定**,此后 shell 通过 OSC 0/2 上报的标题一律被忽略。
/// 不锁的话,用户重命名后下一条提示符就把名字覆盖回去了 ——
/// 表现为「重命名无效」,且只在有输出时才复现,很难归因。
#[tauri::command]
#[specta::specta]
pub async fn terminal_rename_session(
@@ -580,7 +609,7 @@ pub async fn terminal_rename_session(
if t.is_empty() {
return Err("标题不能为空".to_string());
}
s.set_title(t);
s.set_title_by_user(t);
// 独立窗口存在时同步标题,避免窗口标题与内部标签不一致
if let Some(win) = app.get_webview_window(&window::label_for(&session_id)) {
@@ -1001,6 +1030,7 @@ pub async fn terminal_import_ssh_config(app: AppHandle) -> Result<ActionOutcome,
use_proxy: false,
jump_ids: Vec::new(),
encoding: "utf-8".to_string(),
inject_shell_integration: true,
});
imported += 1;
}
@@ -1417,9 +1447,14 @@ pub async fn terminal_import_known_hosts(text: String) -> Result<ActionOutcome,
///
/// 由前端在用户点击「接受」/「拒绝」后调用。会话侧的握手正在阻塞等待这个回传,
/// 因此超时(120 秒)后会视为拒绝。
///
/// `app` 只用于安全事件日志(`security.auditLog`)——用户的每个决定都要留痕:
/// 「谁在什么时候接受了新指纹」是事后审计里最关键的一条。specta 会把
/// `AppHandle` 参数从生成的 JS 签名里剔除,因此前端调用方式不变。
#[tauri::command]
#[specta::specta]
pub async fn terminal_confirm_host_key(
app: AppHandle,
session_id: String,
accept: bool,
host: String,
@@ -1427,6 +1462,15 @@ pub async fn terminal_confirm_host_key(
key_type: String,
fingerprint: String,
) -> Result<(), String> {
if let Ok(m) = manager(&app) {
if m.settings().security.audit_log {
let action = if accept { "接受" } else { "拒绝" };
crate::logger::log_info(
"terminal",
&format!("用户{action}主机密钥:{host}:{port} {key_type} {fingerprint}"),
);
}
}
if !accept {
// 记一次「拒绝」以便事后审计
hostkey::record_rejection(&host, port, &fingerprint, &key_type);
+2 -2
View File
@@ -695,8 +695,8 @@ impl Session for ConPtySession {
.emit(crate::terminal::events::TERMINAL_STATE, self.info());
}
fn set_title(&self, title: &str) {
*self.state.title.lock().unwrap_or_else(|e| e.into_inner()) = title.to_string();
fn set_title_by_user(&self, title: &str) {
self.state.set_title_by_user(title);
}
fn set_detached(&self, detached: bool) {
+58 -15
View File
@@ -131,8 +131,12 @@ pub trait Session: Send + Sync {
/// 更新状态(供内部线程在握手/退出时调用)。
fn set_state(&self, state: SessionState, error: Option<String>);
/// 更新标题
fn set_title(&self, title: &str);
/// 由**用户**指定标题(重命名)
///
/// 名字里带 `_by_user` 是刻意的:它会锁定标题,之后 shell 通过 OSC 0/2
/// 上报的标题一律被忽略。任何「程序自动设置标题」的需求都不该走这里 ——
/// 那会把用户的命名覆盖掉(见 `LocalSessionState::title_locked`)。
fn set_title_by_user(&self, title: &str);
/// 标记是否由独立窗口承载。
fn set_detached(&self, detached: bool);
@@ -276,6 +280,17 @@ pub struct LocalSessionState {
/// 没有这个字段就只能靠 `target_id` 的形式去猜,那是不可靠的。
pub kind: SessionKind,
pub title: std::sync::Mutex<String>,
/// 标题是否已被用户手动指定(重命名)。
///
/// # 为什么需要这个标记
///
/// 会话标题有两个来源:创建时的 shell/主机名,以及 shell 通过 OSC 0/2
/// 上报的自定义标题(每次绘制提示符都可能刷新)。后者是**异步且随时可能到达**的,
/// 因此用户刚重命名完,下一条提示符就会把它覆盖回去 —— 表现为「重命名无效」,
/// 且因为只在有输出时才复现,很难判断是哪里出的问题。
///
/// 一旦用户显式命名,就把标题的所有权交给用户:后续 OSC 标题一律忽略。
pub title_locked: std::sync::atomic::AtomicBool,
pub state: std::sync::Mutex<SessionState>,
pub error: std::sync::Mutex<Option<String>>,
pub cwd: std::sync::Mutex<String>,
@@ -313,6 +328,7 @@ impl LocalSessionState {
target_id,
kind,
title: std::sync::Mutex::new(title),
title_locked: std::sync::atomic::AtomicBool::new(false),
state: std::sync::Mutex::new(SessionState::Idle),
error: std::sync::Mutex::new(None),
cwd: std::sync::Mutex::new(String::new()),
@@ -329,6 +345,19 @@ impl LocalSessionState {
matches!(self.kind, SessionKind::Ssh)
}
/// 用户手动指定标题(重命名)。同时锁定,见 `title_locked` 的说明。
pub fn set_title_by_user(&self, title: &str) {
*self.title.lock().unwrap_or_else(|e| e.into_inner()) = title.to_string();
self.title_locked
.store(true, std::sync::atomic::Ordering::Relaxed);
}
/// 标题当前是否被用户锁定(锁定后忽略 shell 上报的 OSC 标题)。
pub fn is_title_locked(&self) -> bool {
self.title_locked
.load(std::sync::atomic::Ordering::Relaxed)
}
/// 当前编码(供读线程与命令层读取)。
pub fn encoding(&self) -> String {
self.encoding
@@ -444,20 +473,34 @@ pub fn scan_control_sequences(
}
if let Some(title) = titles.last() {
let changed = {
let mut cur = state.title.lock().unwrap_or_else(|e| e.into_inner());
if *cur == *title {
false
} else {
*cur = title.clone();
true
// 两类标题一律丢弃,理由不同但都落在同一个判定点上:
//
// 1. 用户已重命名过(`title_locked`):标题所有权已交给用户,
// 否则下一条提示符就会把用户刚起的名字覆盖回去。
// 2. 标题是可执行文件全路径(`osc_title_is_executable_path`):
// cmd.exe / powershell.exe 启动时就是这么设控制台标题的,
// 把会话名顶成 `C:\Windows\System32\...\cmd.exe` 毫无信息量。
//
// 注意这里**不能提前 return** —— 函数尾部还有 OSC 133 命令边界的落库,
// 早退会把「同批次里带标题的命令」整条丢掉。
let ignored = state.is_title_locked()
|| super::shell::osc_title_is_executable_path(title);
if !ignored {
let changed = {
let mut cur = state.title.lock().unwrap_or_else(|e| e.into_inner());
if *cur == *title {
false
} else {
*cur = title.clone();
true
}
};
if changed {
let _ = app.emit(
crate::terminal::events::TERMINAL_STATE,
local_info(state, state.kind),
);
}
};
if changed {
let _ = app.emit(
crate::terminal::events::TERMINAL_STATE,
local_info(state, state.kind),
);
}
}
+73 -10
View File
@@ -23,7 +23,11 @@ use specta::Type;
pub struct ShellProfile {
/// 唯一标识(同时是新建会话时的 shellKey)
pub id: String,
/// 展示名称,如 "PowerShell 7"
/// 展示名称,如 "PowerShell 7" / "Windows PowerShell"。
///
/// **设置页里的描述名,不是标签上显示的名字** —— 后者取
/// [`super::shell::session_label`](按可执行文件名归一的短名)。
/// 两者刻意分开:描述名可编辑、可很长,而标签要能一行放下多个会话。
pub name: String,
/// 可执行文件绝对路径
pub path: String,
@@ -147,6 +151,20 @@ pub struct SshHost {
pub jump_ids: Vec<String>,
/// 终端的字符编码("utf-8" 默认 | "gbk" 等)。老服务器常见 GBK,中文环境刚需。
pub encoding: String,
/// 是否在远端注入 shell 集成 hook(命令历史 + cwd 跟踪)。
///
/// # 为什么默认必须是 true
///
/// 本结构是容器级 `#[serde(default)]`:老配置里缺这个字段时,serde 不是填
/// 「零值」而是取 [`SshHost::default()`] 的值。因此**默认值决定老主机是否自动
/// 开启注入** —— 填 `false` 会让所有既有主机静默不注入(表现为历史永远为空,
/// 而开关在界面上看着是关的,用户根本不会去动它)。填 `true` 则无需改
/// `heal()`、无需升 `version`。
///
/// 关掉它的场景:远端 shell 集成会与 `startup_command` 里 `exec` 换 shell 的
/// 配置冲突,或服务器对会话环境有洁癖要求(注入会改本次会话的 `PROMPT_COMMAND`
/// `precmd`,但不落盘、不影响其它会话)。
pub inject_shell_integration: bool,
}
impl Default for SshHost {
@@ -170,6 +188,8 @@ impl Default for SshHost {
use_proxy: false,
jump_ids: Vec::new(),
encoding: "utf-8".to_string(),
// 必须 true,理由见字段文档(容器级 serde(default) 会用这里的值兜老配置)
inject_shell_integration: true,
}
}
}
@@ -206,7 +226,8 @@ pub struct AppearanceSettings {
impl Default for AppearanceSettings {
fn default() -> Self {
Self {
theme: "thing-dark".to_string(),
// 「跟随主应用」:配合默认开启的 follow_app_theme,开箱即随应用深浅切换
theme: "system".to_string(),
follow_app_theme: true,
font_family: "Cascadia Mono, Consolas, Microsoft YaHei Mono, monospace".to_string(),
font_size: 14,
@@ -296,10 +317,6 @@ pub struct LayoutSettings {
pub confirm_close_running: bool,
/// 新建标签页时是否继承当前会话的工作目录
pub inherit_cwd: bool,
/// 侧栏默认是否展开
pub sidebar_open: bool,
/// 侧栏宽度(像素)
pub sidebar_width: u32,
/// 是否显示底部状态栏
pub show_status_bar: bool,
/// 分屏上限(1 = 不分屏,2 = 2×1,4 = 2×2)。
@@ -312,8 +329,6 @@ impl Default for LayoutSettings {
Self {
confirm_close_running: true,
inherit_cwd: true,
sidebar_open: true,
sidebar_width: 220,
show_status_bar: true,
max_panes: 4,
}
@@ -510,11 +525,25 @@ pub struct SecuritySettings {
///
/// **不提供 "auto-accept" 选项**TOFU 静默接受是 MITM 的入口,
/// 属于代码层不该给用户的开关。
///
/// 读取方(`ssh::SshHandler::check_server_key`**显式读取本字段而不是写死**:
/// 唯一支持的取值是 `"ask"`,其它取值(配置文件被手工改过)一律按 `"ask"`
/// 处理 —— 安全开关的失败方向必须朝更严的一侧,绝不能因为出现了一个
/// 没见过的字符串就放宽。
pub host_key_policy: String,
/// 指纹变更时是否阻断(**默认 true**)。关掉会让中间人攻击无声通过,
/// 因此前端需以红色风险提示呈现该开关。
///
/// 关闭时的行为不是「照旧弹窗」而是「自动接受并记入变更历史」:
/// 开关的语义是「不再拦截」,若关掉后仍弹阻断弹窗,用户会以为没生效。
pub block_on_fingerprint_change: bool,
/// 是否记录连接审计日志(P2,默认关;开启后输入输出落盘,含脱敏)
/// 是否把**安全事件**写入日志(默认开)。
///
/// 记录对象是主机密钥相关的事件:指纹已信任 / 首次见到被接受 / 指纹变更
/// 被接受或拒绝 / 自动接受(阻断关闭时)。与「会话日志」(输出字节流落盘,
/// 由工具栏的 `terminal_toggle_logging` 按会话显式开启,见 `audit` 模块的
/// 取舍说明)**是两件事**:这里只写事件行,不含屏幕内容,因此没有隐私代价,
/// 默认开启才能保证「发生过什么」事后可查。
pub audit_log: bool,
}
@@ -523,7 +552,7 @@ impl Default for SecuritySettings {
Self {
host_key_policy: "ask".to_string(),
block_on_fingerprint_change: true,
audit_log: false,
audit_log: true,
}
}
}
@@ -633,6 +662,9 @@ impl Default for TemplateEntry {
/// - 新建/关闭标签用 `Ctrl+Shift+T/W`,与浏览器习惯一致。
/// - 跳转标签用 `Alt+1..9``Ctrl+数字` 已被翻译模块的 `Ctrl+2`(翻译面板)占用,
/// 而 `Alt+数字` 在终端里通常不产生控制字符,冲突面最小。
/// - `newDetachedWindow``Ctrl+3`)是这张表里唯一**全局**生效的一条:它由前端
/// `TerminalQuickLaunch` 注册成系统级热键(任意应用前台可触发),放在这里只为
/// 跟着终端设置一起存与改。`Ctrl+2` 已被翻译面板占用,`Ctrl+3` 是空闲的相邻键位。
pub fn default_shortcuts() -> Vec<ShortcutBinding> {
let pairs: &[(&str, &str)] = &[
("copy", "Ctrl+Shift+C"),
@@ -656,6 +688,11 @@ pub fn default_shortcuts() -> Vec<ShortcutBinding> {
("history", "Ctrl+Shift+H"),
("renameTab", "F2"),
("sessionSwitcher", "Ctrl+Shift+O"),
// 把当前会话放到独立窗口。**必须列在这里**:`heal()` 只保留默认表里出现过的
// action(未知 action 会被剔除),前端设置表里有而这里没有 = 用户改完下次启动就被清掉。
("detachWindow", "Ctrl+Shift+N"),
// 全局生效的一条(见上方说明):新建独立窗口 + 选择会话
("newDetachedWindow", "Ctrl+3"),
];
pairs
.iter()
@@ -685,6 +722,24 @@ impl TerminalSettings {
pub fn heal(&mut self) -> bool {
let mut changed = false;
// 配色主题:只认前端 `terminalThemes.ts` 里存在的 key。
// 早期默认值是 `thing-dark`(一套从未实现的主题),解析时会静默回落到
// vscode-dark,用户看到的是「选了主题不生效」。未知 key 一律收敛到
// 「system」(跟随主应用)—— 它至少是用户能预期的行为。
const THEMES: &[&str] = &[
"vscode-dark",
"vscode-light",
"solarized-dark",
"solarized-light",
"dracula",
"one-dark",
"system",
];
if !THEMES.contains(&self.appearance.theme.as_str()) {
self.appearance.theme = "system".to_string();
changed = true;
}
// 快捷键表:补齐新增动作、剔除已废弃动作。用户改过的键位保留。
let defaults = default_shortcuts();
for d in &defaults {
@@ -747,6 +802,14 @@ impl TerminalSettings {
changed = true;
}
// 安全:主机密钥策略只有一个合法取值(见 SecuritySettings 的字段说明)。
// 手工改过的配置在这里收敛回 "ask",与 `check_server_key` 里
// 「未知取值按 ask 处理」构成两道保险:这道修数据,那道防空。
if self.security.host_key_policy != "ask" {
self.security.host_key_policy = "ask".to_string();
changed = true;
}
// last_shell_id 指向已删除的 Shell → 清空,由前端选第一个可用
if !self.last_shell_id.is_empty() && self.shell(&self.last_shell_id).is_none() {
self.last_shell_id.clear();
+556 -14
View File
@@ -14,6 +14,25 @@ use std::path::{Path, PathBuf};
use super::settings::{EnvVar, ShellProfile};
/// 探测项的**历史默认名**。
///
/// 用途只有一个:`detect_and_merge` 的规则是「保留用户设置的名字」,因此
/// 默认名一旦调整,老用户的 `settings.json` 里仍存着旧名 —— 不显式迁移,
/// 新默认名**永远不生效**,表现为「改了代码但界面上丝毫没变」这类最费时间的假故障。
///
/// 因此这里登记「曾经作为默认名出现过」的全部取值:命中的视为「从未被用户改过」,
/// 直接采用当前默认名;用户真的改过名(不等于任何一个历史默认名)则照旧保留。
///
/// 注:`cmd` / `PowerShell` 这两个短名是 2026-09-20 那一版误当作展示名的取值,
/// 保留在列表里是为了把它们**收回**到描述性默认名。
pub fn legacy_default_names(id: &str) -> &'static [&'static str] {
match id {
"powershell" => &["Windows PowerShell", "PowerShell"],
"cmd" => &["命令提示符", "cmd"],
_ => &[],
}
}
/// 探测结果:合并「自动发现的 Shell」与「用户已有的自定义配置」。
///
/// 合并策略:**以自动探测为权威源修正路径**(用户机器上升级了 PowerShell 7
@@ -25,8 +44,13 @@ pub fn detect_and_merge(existing: &[ShellProfile]) -> Vec<ShellProfile> {
for mut d in detected {
if let Some(old) = existing.iter().find(|s| s.id == d.id) {
// 保留用户的个性化字段,路径以探测结果为准
d.name = if old.name.trim().is_empty() {
// 保留用户的个性化字段,路径以探测结果为准
// 名字的取舍见 `legacy_default_names`:空名与历史默认名都归探测结果,
// 其余(用户真的改过)保留用户值。
let old_name = old.name.trim();
let untouched = old_name.is_empty()
|| legacy_default_names(&d.id).contains(&old_name);
d.name = if untouched {
d.name.clone()
} else {
old.name.clone()
@@ -62,6 +86,12 @@ pub fn detect_and_merge(existing: &[ShellProfile]) -> Vec<ShellProfile> {
/// 自动探测本机可用的 Shell。
///
/// 顺序即菜单顺序,也是新建会话时的默认选中顺序(按现代性与功能排序)。
///
/// # 关于 `name`
///
/// `name` 是**设置页里的描述名**(可编辑,用于描述「这条配置是什么」),
/// 不是会话标签上显示的名字 —— 后者取 [`session_label`]。两者刻意分开:
/// 描述名可以长、可以含版本来源,而标签需要稳定、短、一眼可辨。
pub fn detect_shells() -> Vec<ShellProfile> {
let mut list = Vec::new();
@@ -335,6 +365,28 @@ fn encode_powershell_command(script: &str) -> String {
/// PowerShell 的提示符函数在「上一条命令执行完、即将显示新提示符」这个时刻被调用,
/// 因此这里输出的是 **D(上一条结束)** 而不是 C(即将开始)。
///
/// 命令文本单独用 `OSC 1337;Cmd=` 上报(协议里的 `C` 不带文本,见
/// [`CommandMark`]),取自 `Get-History -Count 1`:提示符函数运行时,刚刚执行的
/// 那条命令已经在会话历史里(PowerShell 在读取输入行时就写入历史,早于执行)。
///
/// **必须先发 `Cmd` 再发 `D`**Rust 侧在 `D` 到达时用暂存的文本落库
/// (见 `session::CommandAccumulator`),顺序反了会记成上一条命令。
///
/// `$global:__thingLastHistId` 做去重:提示符函数可能因续行提示符(`>>`)等原因
/// 在同一条命令后被调用多次,不加这个守卫会把同一条命令重复计数。
///
/// # 为什么要跳过第一个提示符(`$global:__thingSkipNextPrompt`
///
/// PowerShell 会把 `-EncodedCommand` 的**脚本文本本身**记进会话历史,于是本脚本
/// 注入后遇到的第一个提示符,`Get-History -Count 1` 拿到的是这段注入脚本 ——
/// 若不跳过,每个新建的 PowerShell 会话都会往历史里塞一条用户从没敲过的
/// 「巨型命令」(实测形态:一条以 `$__thingOrigPrompt = $function:prompt;` 开头的
/// 几百字符记录,且因去重键相同而不断累加 count)。
///
/// 跳过一次即可:第一个提示符之前用户不可能敲过任何命令,所以「跳过」不会误伤
/// 真实记录;即便某个 PowerShell 版本不把注入脚本记进历史,跳过也只是少一次
/// 无用调用。
///
/// 退出码取自 `$LASTEXITCODE`(原生命令)或 `$?`cmdlet),两者语义不同:
/// cmdlet 成功时 `$LASTEXITCODE` 可能保留着**更早那条原生命令**的值。
/// 因此优先 `$LASTEXITCODE`(仅当其在本轮被设置过),否则用 `$?` 折算 0/1。
@@ -343,11 +395,26 @@ fn encode_powershell_command(script: &str) -> String {
fn powershell_prompt_hook() -> String {
[
"$__thingOrigPrompt = $function:prompt;",
// 去重用的「最近一次已上报的历史条目 id」,见函数文档
"$global:__thingLastHistId = -1;",
// 「下一个提示符对应的是注入脚本自己」,见函数文档
"$global:__thingSkipNextPrompt = $true;",
"function global:prompt {",
// 必须最先取:后面任何一条语句都会刷新 $?
" $__ok = $?;",
" $__code = $LASTEXITCODE;",
" if ($null -eq $__code) { $__code = if ($__ok) { 0 } else { 1 } }",
// 命令文本:先于 D 输出(顺序见函数文档)
" if ($global:__thingSkipNextPrompt) {",
" $global:__thingSkipNextPrompt = $false",
" } else {",
" $__h = Get-History -Count 1;",
" if ($__h -and $__h.Id -ne $global:__thingLastHistId) {",
" $global:__thingLastHistId = $__h.Id;",
" $__c = $__h.CommandLine;",
" if ($__c) { Write-Host -NoNewline \"$([char]27)]1337;Cmd=$__c$([char]7)\"; }",
" }",
" }",
" Write-Host -NoNewline \"$([char]27)]133;D;$__code$([char]7)\";",
" $__p = $ExecutionContext.SessionState.Path.CurrentLocation;",
" $__loc = $__p.ProviderPath;",
@@ -388,6 +455,12 @@ fn powershell_prompt_hook() -> String {
/// 结论:**保留 shell 的原样输出交给 Rust 侧判断** —— hook 只管如实上报,
/// 「前导空格要不要记」是策略,由 `History::record` 统一决定(那里也是
/// `HISTCONTROL=ignorespace` 的落点)。hook 里做策略判断会散落成两处规则。
///
/// 例外一处:字段拆分(`read`)会把命令**自己的前导空格**一并吃掉 —— 序号后的
/// 分隔空格与命令的前导空格在文本上无法区分。于是「以空格开头」的命令会以普通
/// 命令的形态上报,由 `History::record` 记录。常见配置
/// `HISTCONTROL=ignorespace` 下这类命令压根不在 `history 1` 里,这条例外也就
/// 无从触发;不对抗它,如实记在这里。
fn bash_prompt_hook() -> String {
[
"# Thing 终端:shell integration hook(由终端模块注入,可安全删除)",
@@ -395,30 +468,170 @@ fn bash_prompt_hook() -> String {
"# 供文件管理器、cwd 继承与命令历史使用",
"__thing_osc7() {",
" local __code=$?",
" printf '\\033]133;D;%s\\007' \"$__code\"",
// 取最后一条历史:`history 1` 返回 ` 123 <命令>`
// (前导空格 + 序号 + 两个空格 + 命令本体)。
//
// 用 `read -r` 拆而不是参数展开剥前缀:`${x#"$y"}` 这种嵌套引号在
// Rust 字符串字面量里要转义到难以阅读,而且 `history` 的输出里
// 命令本体可能**含空格**,参数展开必须保留剩余全部内容。
// `read -r _ _ __cmd` 的语义恰好是「跳过前两段空白分隔的字段,
// 其余原样(含内部空格)收进 __cmd」—— 正是所需,且引号最少。
// **必须只跳过「序号」这一个字段**`read -r _ __cmd` 的语义是「第一个
// 字段给 `_`,其余字段连同它们之间的分隔符一起给 `__cmd`」。
//
// 曾经的写法 `read -r _ _ __cmd` 多跳了一个字段,后果按命令词数分三种:
// - 单字命令(`ls`):只剩序号 + 命令两个字段 → `__cmd` 为空 → 不发 Cmd
// → **一条都不落库**(实测就是「SSH 上跑了 ls,历史里什么都没有」);
// - 多字命令(`git status`):第一个词被 `_` 吃掉 → 记成 `status`
// - 恰好两字命令才是碰巧正确的。
//
// 用 `read` 而不是参数展开剥前缀:`history` 的输出里命令本体可能含空格,
// 参数展开(`${x##* }` 之类)会在命令内部出现连续空格时切错位置。
" local __cmd=''",
" read -r _ _ __cmd <<< \"$(HISTTIMEFORMAT= builtin history 1)\"",
" read -r _ __cmd <<< \"$(HISTTIMEFORMAT= builtin history 1)\"",
// 顺序要紧:Rust 侧在 D 到达时用暂存的文本落库(见 `CommandAccumulator`),
// 先 D 后 Cmd 会让每条命令都推迟到下一次提示符才记录,且最后一条永远丢失
" printf '\\033]1337;Cmd=%s\\007' \"$__cmd\"",
" printf '\\033]133;D;%s\\007' \"$__code\"",
" printf '\\033]7;file://%s%s\\007' \"${HOSTNAME:-localhost}\" \"$PWD\"",
"}",
"if [[ -n \"$PROMPT_COMMAND\" ]]; then",
" PROMPT_COMMAND=\"__thing_osc7; $PROMPT_COMMAND\"",
// bash 5.1+ 允许 `PROMPT_COMMAND` 是**数组**(每个元素都是一条要执行的命令)。
// 原来那句字符串拼接在数组场景下只会展开元素 0 并把整个数组覆盖成字符串 ——
// 用户自己的钩子(bash-preexec 之类)就此丢失。因此按 `declare -p` 的形态分流。
"if [[ \"$(declare -p PROMPT_COMMAND 2>/dev/null)\" == \"declare -a\"* ]]; then",
" PROMPT_COMMAND=(__thing_osc7 \"${PROMPT_COMMAND[@]}\")",
"else",
" PROMPT_COMMAND=\"__thing_osc7\"",
" PROMPT_COMMAND=\"__thing_osc7${PROMPT_COMMAND:+; $PROMPT_COMMAND}\"",
"fi",
"",
]
.join("\n")
}
/// 远端(SSHshell 集成脚本:**一行** POSIX sh,由连接期直接写进远端 shell。
///
/// # 为什么要注入、以及注入什么
///
/// 远端 shell 默认不会输出任何 OSC 标记,于是 SSH 会话既没有命令历史
/// (见 [`CommandMark`])也没有 cwd 跟踪。本脚本让远端 shell 在提示符处自行上报
/// `OSC 1337;Cmd=` / `OSC 133;D` / `OSC 7`,与本地 hook 输出完全同一套协议,
/// Rust 侧无需为 SSH 单独分流。
///
/// # 调用方的两个义务
///
/// 1. **以空格开头**`{script}` 前面加一个空格):`HISTCONTROL=ignorespace` 惯例,
/// 让这行注入命令不进远端 shell 的历史;`History::record` 也丢弃前导空格的命令。
/// 2. **以 `\n` 结尾**PTY 的行规程只在收到 NL 时才把这一行交给 shell。
///
/// # 单行脚本的硬约束(全是踩过的坑)
///
/// - **不能有 `#` 注释**:单行脚本里注释会吃掉行尾其余内容(本地 Git Bash 的 hook
/// 是多行文件,才能写注释)。
/// - **不能含裸 `\r`**:远端 PTY 的 ICRNL 会把 `\r` 当行结束,脚本被切成多条半截命令。
/// - **保持纯 ASCII**GBK 主机的回显才不会是乱码。
/// - `printf` 用 `\033` / `\007` 而不是 `\e`:部分 dash/busybox 不认 `\e`。
/// 命令文本一律作为 `%s` 参数传入(含单引号、`%` 都不会破坏格式串)。
///
/// # 两个 shell 分支的差异
///
/// - **bash**:钩子挂在 `PROMPT_COMMAND`,命令文本取自 `history 1`(别名已展开、
/// Tab 补全已生效)。挂载用**前插**:`$?` 必须在用户已有钩子之前取走,
/// 否则会被别人的语句覆盖。`PROMPT_COMMAND` 可能是数组,按形态分流。
/// - **zsh**:用 `preexec`(命令执行前,`$1` 就是最终命令行)发 `Cmd`、`precmd`
/// 发 `D` —— 两者天然配对,比 bash 更精确。同样前插,理由同上。
///
/// # 跳过第一个提示符(`__thing_skip`
///
/// 注入命令本身会进入远端会话历史,于是第一个提示符取到的「上一条命令」就是这行
/// 注入命令,不跳过就会把注入脚本记成一条用户命令(本地 PowerShell 已实测踩过)。
///
/// # 命令文本为空时不发 `Cmd`
///
/// `CommandAccumulator` 是**覆盖**语义(Cmd 到来即覆盖暂存),发一个空的 Cmd 会
/// 覆盖掉上一条待落库的文本。`HISTFILE=/dev/null` / `set +o history` 的场景就靠
/// 这个守卫降级为「cwd 有、历史空」,而不是让历史错位。
///
/// # 幂等
///
/// `__thing_installed` 守卫:同一会话里重复注入不会注册两次(否则每条命令入库两次)。
///
/// # 末尾附一次清屏:为什么是必须的,以及为什么不用 `clear`
///
/// 注入行是在远端还处于登录阶段时就到达的(`Last login:` 那一段,shell 还没开始读
/// 输入)。于是 tty 层先回显一次、readline 起来后从缓冲里读到再重绘一次 —— 用户会看到
/// **两遍**这行几百字符的命令,登录 banner / motd 也还在屏幕上。注入完清一次屏把这
/// 几行一起抹掉。
///
/// 直接写序列而不是调 `clear`:不依赖 `clear` 二进制(精简容器里可能没有)、字节固定、
/// 不随远端 TERM 变化。三个参数各有用处 —— `2J` 清屏、`3J` 清滚动缓冲(否则往上滚
/// 还能看到那行回显)、`H` 归位光标。代价是 motd 也一并被清掉:这是「干净的首屏」与
/// 「先看一眼 motd」之间的取舍,取前者(VS Code / Windows Terminal 的远端集成同样
/// 会刷新首屏)。
///
/// 清屏放在 `fi` 之后:它不是 shell 集成的一部分,`dash` 这类没有 hook 可挂的 shell
/// 也该清掉这行回显。
pub fn remote_shell_integration_script() -> String {
// bash 分支:`<<<`、`${v//a/b}`、数组都是 bashism,故先按 BASH_VERSION 分流再使用。
let bash = "__thing_osc7(){ local __code=$?; local __cmd=''; \
if [ -z \"${__thing_skip:-}\" ]; then \
read -r _ __cmd <<< \"$(HISTTIMEFORMAT= builtin history 1)\"; \
__cmd=${__cmd//[$'\\033\\007']/}; \
[ -n \"$__cmd\" ] && printf '\\033]1337;Cmd=%s\\007' \"$__cmd\"; \
else unset __thing_skip; fi; \
printf '\\033]133;D;%s\\007' \"$__code\"; \
printf '\\033]7;file://%s%s\\007' \"${HOSTNAME:-localhost}\" \"$PWD\"; }; \
if [ -z \"${__thing_installed:-}\" ]; then __thing_installed=1; __thing_skip=1; \
case \"$(declare -p PROMPT_COMMAND 2>/dev/null)\" in \
\"declare -a\"*) PROMPT_COMMAND=(__thing_osc7 \"${PROMPT_COMMAND[@]}\");; \
*) PROMPT_COMMAND=\"__thing_osc7${PROMPT_COMMAND:+; $PROMPT_COMMAND}\";; esac; fi";
// zsh 分支:precmd 只发 Dpreexec 发 Cmd(顺序见 `CommandAccumulator`)。
let zsh = "__thing_precmd(){ local __code=$?; \
printf '\\033]133;D;%s\\007' \"$__code\"; \
printf '\\033]7;file://%s%s\\007' \"${HOSTNAME:-${HOST:-localhost}}\" \"$PWD\"; }; \
__thing_preexec(){ if [ -n \"${__thing_skip:-}\" ]; then unset __thing_skip; return 0; fi; \
local __cmd=${1:-${2:-}}; \
if [ -n \"$__cmd\" ]; then printf '\\033]1337;Cmd=%s\\007' \"${__cmd//[$'\\033\\007']/}\"; fi; \
return 0; }; \
if [ -z \"${__thing_installed:-}\" ]; then __thing_installed=1; __thing_skip=1; \
typeset -ga precmd_functions preexec_functions; \
precmd_functions=(__thing_precmd ${precmd_functions[@]}); \
preexec_functions=(__thing_preexec ${preexec_functions[@]}); fi";
// 都不是 bash/zshdash、fish、tcsh…)时前半段是空操作;末尾的清屏对所有 shell 都执行。
format!(
"if [ -n \"${{BASH_VERSION:-}}\" ]; then {bash}; \
elif [ -n \"${{ZSH_VERSION:-}}\" ]; then {zsh}; fi; \
printf '\\033[2J\\033[3J\\033[H'"
)
}
/// 会话标签上显示的**短名**。
///
/// # 为什么不直接用 `ShellProfile::name`
///
/// `name` 是设置页里的描述名,用户可编辑(可能是 "Windows PowerShell"、
/// 也可能是自定的 "我的远程 PS")。标签的诉求完全不同:它要在一行里容下
/// 多个会话、要能一眼分辨,因此需要**短且稳定**的名字。两者分开之后,
/// 调整其中一个不会捎带动另一个。
///
/// # 取值来源是可执行文件名而不是 `kind`
///
/// `kind` 只区分注入方式(powershell / cmd / bash / wsl),pwsh 与
/// Windows PowerShell 的 kind 相同,靠它分不出「PowerShell 7」与
/// 「PowerShell」。文件名恰好是各自的身份:`pwsh.exe` / `powershell.exe`。
///
/// 认不出的一律退回 `name`:自定义 Shell(用户手工填路径)本来就该用他起的名字,
/// WSL 条目也是 —— 它的 name 是 "WSL · Ubuntu",比干巴巴的 "WSL" 有用。
pub fn session_label(profile: &ShellProfile) -> String {
let stem = Path::new(&profile.path)
.file_stem()
.map(|s| s.to_string_lossy().to_ascii_lowercase())
.unwrap_or_default();
match stem.as_str() {
"pwsh" => "PowerShell 7".to_string(),
"powershell" => "PowerShell".to_string(),
"cmd" => "cmd".to_string(),
"bash" => "Git Bash".to_string(),
_ => profile.name.clone(),
}
}
/// 清屏命令(按 Shell 类型区分)。
pub fn clear_command(kind: &str) -> &'static str {
match kind {
@@ -430,7 +643,6 @@ pub fn clear_command(kind: &str) -> &'static str {
}
use std::os::windows::process::CommandExt;
/// `Command::creation_flags` 的糖:隐藏控制台窗口。
trait NoWindow {
fn creation_flags_no_window(&mut self) -> &mut Self;
@@ -513,7 +725,10 @@ pub fn parse_control_sequences(data: &[u8]) -> ParsedSequences {
if !rest.trim().is_empty() {
out.titles.push(rest.to_string());
}
} else if payload.starts_with("133;") {
} else if payload.starts_with("133;") || payload.starts_with("1337;") {
// **两个前缀都要放进来**`1337;Cmd=` 与 `133;D` 共享 `133` 前缀,
// 只放行 `133;` 会让命令文本(`1337;`)在这里就被挡掉,永远到不了
// `parse_osc133` —— 表现为「历史一条都不记」,而错误点离成因很远。
if let Some(mark) = parse_osc133(&payload) {
out.marks.push(mark);
}
@@ -614,6 +829,83 @@ pub struct ParsedSequences {
pub marks: Vec<CommandMark>,
}
/// 判断一个 OSC 0/2 标题是否是「Windows 可执行文件全路径」。
///
/// # 为什么需要过滤
///
/// `cmd.exe` 与 `powershell.exe` 启动时会把自己的可执行文件全路径设成控制台标题
/// `cmd.exe` 运行命令时还会追加 ` - <命令>`)。这个标题经 PTY 原样到达我们这里,
/// 若照单全收,会话标签与状态栏就会显示成
/// `C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe`。
///
/// Windows Terminal 与 VS Code 的终端同样要处理这件事(过滤这类标题,
/// 把标签留给 shell 名或用户自定义标题)。
///
/// # 提权前缀
///
/// 以管理员身份运行时,PowerShell / cmd 会在标题前加一个本地化的前缀 ——
/// 简体中文下是 `管理员: `(对应英文的 `Administrator: `)。**这是最容易漏掉的一种**:
/// 不带前缀的判定看起来完全正确,只有提权会话暴露出问题,而日常又很少提权运行。
/// 因此这里对「原串」与「剥掉前缀后的串」各判一次。
///
/// # 只认「绝对路径 + .exe/.com」,不做广义「像路径就丢」
///
/// WSL / Git Bash 上报的 `user@host: ~/dir`、`/home/foo` 这类标题是有用信息,
/// 必须保留。判据因此收紧到三个条件同时成立:必须是绝对路径(盘符或 UNC)、
/// 必须以 `.exe` / `.com` 结尾、可选的 ` - <命令>` 后缀不影响判定。
pub fn osc_title_is_executable_path(title: &str) -> bool {
let t = title.trim();
if t.is_empty() {
return false;
}
if looks_like_exe_path(t) {
return true;
}
match strip_leading_label(t) {
Some(rest) => looks_like_exe_path(rest),
None => false,
}
}
/// 判定单一字符串本身是否为可执行文件全路径(不含提权前缀处理)。
fn looks_like_exe_path(s: &str) -> bool {
// `C:\...\cmd.exe - ping 8.8.8.8` → 只看 ` - ` 之前的部分
let head = s.split(" - ").next().unwrap_or(s).trim();
let lower = head.to_ascii_lowercase();
if !lower.ends_with(".exe") && !lower.ends_with(".com") {
return false;
}
let b = head.as_bytes();
let drive_absolute = b.len() >= 3
&& b[0].is_ascii_alphabetic()
&& b[1] == b':'
&& (b[2] == b'\\' || b[2] == b'/');
let unc = head.starts_with(r"\\") || head.starts_with("//");
drive_absolute || unc
}
/// 剥掉 `管理员:` / `Administrator:` 这类前缀标签,返回剩余部分。
///
/// 三重防护,避免把正常标题误当成长路径:
/// - 分隔符前不能含路径分隔符(否则 `C:\...` 的盘号会被当成标签)
/// - 标签本身要短:上限 16 字符是量出来的 —— `Administrator`13+ 余量,
/// 而正文式的标题(`user@host` 之外还有内容的)通常远超这个量级
/// - 全角冒号 `:` 一并处理 —— 中文输入法下系统用的就是它
fn strip_leading_label(s: &str) -> Option<&str> {
/// 前缀标签的字符数上限。取值依据见函数文档:英文的 `Administrator` 是 13。
const MAX_LABEL_CHARS: usize = 16;
let idx = s.find(|c| c == ':' || c == '')?;
let head = &s[..idx];
if head.is_empty() || head.contains('\\') || head.contains('/') {
return None;
}
if head.chars().count() > MAX_LABEL_CHARS {
return None;
}
Some(s[idx..].trim_start_matches(|c| c == ':' || c == '' || c == ' '))
}
/// 从 OSC 7 的载荷解析出本地路径。
///
/// 载荷形如 `file://HOST/C:/Users/foo` 或 `file:///home/user`。
@@ -685,3 +977,253 @@ pub fn resolve_cwd(requested: &str) -> Option<String> {
Some(default_cwd())
}
}
#[cfg(test)]
mod tests {
use super::*;
/// 解析器必须真的把 `1337;Cmd=` 认成 mark。
///
/// 这条守着的是「标记被上游挡掉」的失效:`1337;Cmd=` 与 `133;D` 共享 `133`
/// 前缀,若只按 `133;` 放行,命令文本在进入 `parse_osc133` 之前就被丢掉了,
/// 结果是**历史一条都不记**,而现象(列表为空)与成因(解析分支没走到)之间
/// 隔着 shell hook、IPC、SQLite 三层,最难查。整条链路在此用真实字节流覆盖:
/// hook 输出的字节 → 解析出的标记序列。
#[test]
fn parse_command_text_and_end_mark_from_stream() {
let stream: Vec<u8> = b"\x1b]1337;Cmd=ls -la\x07\x1b]133;D;0\x07\x1b]7;file://host/C:/x\x07".to_vec();
let p = parse_control_sequences(&stream);
assert_eq!(
p.marks,
vec![
CommandMark::Command("ls -la".to_string()),
CommandMark::End(Some(0))
],
"先 Cmd 后 D 的顺序必须原样解析出来"
);
// 同批里的 OSC 7 也要一起解析(三类序列共用一次扫描)
assert_eq!(p.cwds, vec!["C:\\x".to_string()]);
assert_eq!(p.consumed, stream.len(), "完整序列应全部被消费");
}
/// 序列被切在两批数据之间时必须保留尾部,等下一批拼接后再解析 ——
/// 否则 `Cmd` 与 `D` 各被截断一次,历史同样一条不记。
#[test]
fn parse_marks_across_three_batches() {
let whole: Vec<u8> = b"\x1b]1337;Cmd=echo hi\x07\x1b]133;D;1\x07".to_vec();
let (a, b) = whole.split_at(9);
let (b, c) = b.split_at(6);
let first = parse_control_sequences(a);
assert!(first.marks.is_empty(), "半个序列不该产出标记");
assert!(first.consumed < a.len(), "未闭合序列的尾部必须保留");
let mut carry = a[first.consumed..].to_vec();
carry.extend_from_slice(b);
let second = parse_control_sequences(&carry);
assert!(second.marks.is_empty());
carry.drain(..second.consumed);
carry.extend_from_slice(c);
let third = parse_control_sequences(&carry);
assert_eq!(
third.marks,
vec![
CommandMark::Command("echo hi".to_string()),
CommandMark::End(Some(1))
]
);
}
/// 两个 hook 都必须**上报命令文本**,且 `Cmd` 必须**早于** `D`。
///
/// 这两条是命令历史能否落库的全部条件:Rust 侧在 `D` 到达时用暂存的文本落库
/// (见 `session::CommandAccumulator`),缺了 `Cmd` 会一条都不记,顺序反了
/// 会永远记成上一条 —— 两种失效都只在运行时表现为「历史是空的 / 少一条」。
#[test]
fn prompt_hooks_report_command_before_end_mark() {
for (name, script) in [
("PowerShell", powershell_prompt_hook()),
("Git Bash", bash_prompt_hook()),
] {
let cmd = script
.find("]1337;Cmd=")
.unwrap_or_else(|| panic!("{name} hook 未上报命令文本"));
let end = script
.find("]133;D")
.unwrap_or_else(|| panic!("{name} hook 未上报结束标记"));
assert!(cmd < end, "{name} hook 必须先发 Cmd 再发 D");
}
}
/// 注入脚本自身不得被当成用户命令上报(实测踩到过)。
///
/// PowerShell 会把 `-EncodedCommand` 的脚本文本记进会话历史,于是注入后第一个
/// 提示符的 `Get-History -Count 1` 就是这段脚本 —— 表现为「新建会话就多出一条
/// 几百字符的命令」,且因去重键相同而不断累加 count。
#[test]
fn powershell_hook_skips_its_own_injected_script() {
let script = powershell_prompt_hook();
assert!(
script.contains("$global:__thingSkipNextPrompt = $true"),
"hook 必须标记「第一个提示符对应注入脚本自身」"
);
assert!(
script.contains("$global:__thingSkipNextPrompt = $false"),
"该标记必须在提示符函数里被消费(否则会一直跳过真实命令)"
);
}
/// 远端脚本:一行、纯 ASCII、无 `#` 注释、无裸 `\r`,且 bash/zsh 两个分支都在。
///
/// 这些都是「静默失效」的来源:多行/裸 CR 会被远端 PTY 逐行或拦腰执行,
/// `#` 注释会吃掉它之后的所有内容(单行脚本里没有「下一行」可以续)。
#[test]
fn remote_script_is_single_line_ascii_with_both_branches() {
let s = remote_shell_integration_script();
assert!(!s.contains('\n'), "必须单行:多行下发会被远端 PTY 逐行执行");
assert!(!s.contains('\r'), "不能含裸 CR:ICRNL 会把它当行结束,脚本被切断");
assert!(!s.contains('#'), "单行脚本不能有 # 注释(会吃掉行尾其余内容)");
assert!(s.is_ascii(), "保持纯 ASCII,GBK 主机回显才不乱码");
assert!(s.contains("BASH_VERSION"), "缺少 bash 分支");
assert!(s.contains("ZSH_VERSION"), "缺少 zsh 分支");
assert!(
s.contains("declare -a"),
"bash 5.1+ 的 PROMPT_COMMAND 可能是数组,必须分流否则会覆盖用户钩子"
);
// 末尾清屏:抹掉注入行的双回显与登录 banner(3J 连滚动缓冲一起清)
assert!(
s.contains("[2J") && s.contains("[3J") && s.trim_end().ends_with("[H'"),
"末尾必须清屏,且要连滚动缓冲一起清(否则往上滚还能看到回显)"
);
}
/// 远端脚本必须「先 Cmd 后 D」,且**空命令文本时不发 Cmd**。
///
/// 顺序反了会永远记成上一条;而 `CommandAccumulator` 的 Cmd 是覆盖语义,
/// 空文本会把上一条待落库的文本冲掉(HISTFILE=/dev/null 之类的降级路径)。
#[test]
fn remote_script_reports_command_before_end_mark() {
let s = remote_shell_integration_script();
let cmd = s.find("]1337;Cmd=").expect("必须上报命令文本");
let end = s.find("]133;D").expect("必须上报结束标记");
assert!(cmd < end, "必须先发 Cmd 再发 D");
let guard = s.find("[ -n \"$__cmd\" ]").expect("缺少空文本守卫");
assert!(guard < cmd, "空命令文本时不得发出 Cmd");
}
/// 注入命令自身不得被记成用户命令,且重复注入必须幂等。
#[test]
fn remote_script_skips_injected_line_and_is_idempotent() {
let s = remote_shell_integration_script();
assert!(s.contains("__thing_installed"), "缺少幂等守卫(重复注入会入库两次)");
assert!(
s.matches("__thing_skip=1").count() >= 2,
"两个分支都要标记「跳过首个提示符」(注入命令本身会进远端历史)"
);
assert!(s.contains("unset __thing_skip"), "标记必须被消费,否则会一直跳过真实命令");
}
/// 两个 bash hook 取命令文本时**只能跳过序号一个字段**。
///
/// `read -r _ _ __cmd` 多跳一个字段:单字命令(`ls`)只剩「序号 + 命令」两个
/// 字段,`__cmd` 因此为空 → 不发 `Cmd` → 一条都不落库(实测:SSH 上跑了 `ls`,
/// 历史里什么都没有);多字命令(`git status`)会丢掉第一个词。只有恰好两字的
/// 命令碰巧正确 —— 这是最容易被「看起来能跑」掩盖的一类错误。
#[test]
fn bash_hooks_skip_only_the_history_index() {
for (name, script) in [
("本地 Git Bash hook", bash_prompt_hook()),
("远端 hook", remote_shell_integration_script()),
] {
assert!(
script.contains("read -r _ __cmd <<<"),
"{name}: 必须用 `read -r _ __cmd` 只跳过序号一个字段"
);
assert!(
!script.contains("read -r _ _ __cmd"),
"{name}: 多跳一个字段会让单字命令解析出空文本,历史一条都不记"
);
}
}
/// 提权会话的前缀是本模块最容易漏的一环:不带前缀的实现在日常(非提权)
/// 会话里表现完全正确,只有管理员运行时才暴露。
#[test]
fn osc_title_strips_elevation_prefix() {
assert!(osc_title_is_executable_path(
r"管理员: C:\WINDOWS\System32\WindowsPowerShell\v1.0\powershell.exe"
));
assert!(osc_title_is_executable_path(
r"Administrator: C:\Windows\System32\cmd.exe"
));
// 全角冒号(中文输入法下系统用的就是它)与追加的当前命令
assert!(osc_title_is_executable_path(
r"管理员:C:\Windows\System32\cmd.exe - ping 8.8.8.8"
));
}
#[test]
fn osc_title_filters_plain_exe_paths() {
assert!(osc_title_is_executable_path(r"C:\Windows\System32\cmd.exe"));
assert!(osc_title_is_executable_path(r"C:\Windows\System32\cmd.exe - pause"));
assert!(osc_title_is_executable_path(r"\\server\share\cmd.exe"));
}
/// 过滤必须「宁可少丢」:把远端 shell 上报的有效标题丢掉,比留下一个丑标题更糟。
#[test]
fn osc_title_keeps_meaningful_titles() {
assert!(!osc_title_is_executable_path("user@host: ~/projects"));
assert!(!osc_title_is_executable_path("/home/dev/app"));
assert!(!osc_title_is_executable_path("PowerShell"));
assert!(!osc_title_is_executable_path(""));
// 相对路径不是 shell 自报家门的形式
assert!(!osc_title_is_executable_path(r"bin\cmd.exe"));
// 非 Windows 可执行后缀(SSH 到 Linux 时远端标题常是这种)
assert!(!osc_title_is_executable_path("/usr/bin/htop"));
}
#[test]
fn session_label_uses_exe_stem() {
let powershell = ShellProfile::new(
"powershell",
"Windows PowerShell",
r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe",
"powershell",
);
assert_eq!(session_label(&powershell), "PowerShell");
let pwsh = ShellProfile::new(
"pwsh",
"PowerShell 7",
r"C:\Program Files\PowerShell\7\pwsh.exe",
"powershell",
);
assert_eq!(session_label(&pwsh), "PowerShell 7");
let cmd = ShellProfile::new("cmd", "命令提示符", r"C:\Windows\System32\cmd.exe", "cmd");
assert_eq!(session_label(&cmd), "cmd");
}
/// 认不出的可执行文件必须退回「设置里那个名字」:自定义 Shell 的名字是用户自己起的,
/// WSL 条目的名字里带着发行版(比 `wsl.exe` 有用)。
#[test]
fn session_label_falls_back_to_profile_name() {
let custom = ShellProfile::new("custom", "我的 Shell", r"C:\tools\myshell.exe", "bash");
assert_eq!(session_label(&custom), "我的 Shell");
let wsl = ShellProfile::new("wsl-ubuntu", "WSL · Ubuntu", "wsl.exe", "wsl");
assert_eq!(session_label(&wsl), "WSL · Ubuntu");
}
/// 历史默认名要同时覆盖两代取值,否则老 `settings.json` 里的名字永远不迁移。
#[test]
fn legacy_names_cover_settled_defaults() {
assert!(legacy_default_names("cmd").contains(&"命令提示符"));
assert!(legacy_default_names("cmd").contains(&"cmd"));
assert!(legacy_default_names("powershell").contains(&"Windows PowerShell"));
assert!(legacy_default_names("powershell").contains(&"PowerShell"));
// 未登记过的条目:用户改过的名字必须原样保留
assert!(legacy_default_names("pwsh").is_empty());
}
}
+110 -6
View File
@@ -30,6 +30,11 @@
//!
//! 防御 MITM 的基线:若 `check_server_key` 返回 `false` 是**默认路径**
//! 超时(120 秒)视为拒绝。用户没有任何回应就不继续握手。
//!
//! 上述行为受 `terminal::settings::SecuritySettings` 的三个开关约束(见该结构说明):
//! `host_key_policy`(唯一取值 `"ask"`,未知取值按 ask 处理)、
//! `block_on_fingerprint_change`(关闭后「与记录不符」不再弹确认,改为自动接受
//! 并写入变更历史)、`audit_log`(上述事件是否写入日志)。
pub mod forward;
pub mod hostkey;
@@ -50,6 +55,7 @@ use tokio::sync::oneshot;
use super::session::{
local_info, LocalSessionState, Session, SessionId, SessionInfo, SessionKind, SessionState,
};
use super::settings::SecuritySettings;
use crate::secrets;
use crate::terminal::events as EV;
@@ -202,13 +208,35 @@ impl client::Handler for SshHandler {
let fingerprint = key_data.fingerprint(russh::keys::HashAlg::Sha256).to_string();
let key_type = key_data.algorithm().to_string();
// 安全设置三项(策略 / 指纹变更阻断 / 安全事件日志)全部在这条路径上生效,
// 因此集中读一次。读不到(模块未初始化)时用默认值 ——
// 那是最严的一组:ask + 阻断 + 记录。
let sec = super::manager(&self.app)
.map(|m| m.settings().security)
.unwrap_or_default();
// 策略显式读取而不是写死(取值只有一个 "ask",见 SecuritySettings 说明):
// 让「策略」成为真正的驱动源,将来新增取值时分支已就位。
// 未知取值一律按 "ask" 处理,绝不放宽。
if sec.host_key_policy != "ask" {
self.audit(
&sec,
true,
&format!(
"未知的主机密钥策略「{}」,按 ask(逐个核对指纹)处理",
sec.host_key_policy
),
);
}
// 1. 与 known_hosts 比对
let verdict = hostkey::verify(&self.host, self.port, &fingerprint, &key_type);
match verdict {
hostkey::Verdict::Trusted => {
crate::logger::log_info(
"terminal",
self.audit(
&sec,
false,
&format!(
"主机密钥已信任:{}:{} {}",
self.host, self.port, fingerprint
@@ -218,13 +246,36 @@ impl client::Handler for SshHandler {
}
hostkey::Verdict::Changed { previous } => {
// 高危信号:可能是服务器重装,也可能是 MITM
crate::logger::log_warn(
"terminal",
self.audit(
&sec,
true,
&format!(
"主机密钥发生变化:{}:{} 新指纹 {}(旧 {}",
self.host, self.port, fingerprint, previous
),
);
// 「指纹变更时阻断连接」关闭 → 不再弹确认,直接接受新指纹。
// 旧指纹会由 `hostkey::accept` 记入变更历史,因此事后仍可查出
// 「这台主机的密钥曾在何时被悄悄换过」——这是关掉阻断后
// 唯一的追溯手段,也是这条分支不能只做 `Ok(true)` 的原因。
if !sec.block_on_fingerprint_change {
self.audit(
&sec,
true,
&format!(
"「指纹变更时阻断连接」已关闭,自动接受新指纹:{}:{} {}",
self.host, self.port, fingerprint
),
);
if let Err(e) =
hostkey::accept(&self.host, self.port, &fingerprint, &key_type)
{
crate::logger::log_error("terminal", &format!("更新 known_hosts 失败: {e}"));
}
return Ok(true);
}
let accepted = self
.ask_user(&key_type, &fingerprint, "changed", Some(previous))
.await;
@@ -239,6 +290,14 @@ impl client::Handler for SshHandler {
hostkey::Verdict::Unknown => {
let accepted = self.ask_user(&key_type, &fingerprint, "unknown", None).await;
if accepted {
self.audit(
&sec,
false,
&format!(
"首次连接并接受主机密钥:{}:{} {} {}",
self.host, self.port, key_type, fingerprint
),
);
if let Err(e) = hostkey::accept(&self.host, self.port, &fingerprint, &key_type)
{
crate::logger::log_error("terminal", &format!("新增 known_hosts 失败: {e}"));
@@ -293,6 +352,27 @@ impl client::Handler for SshHandler {
}
impl SshHandler {
/// 写一条主机密钥相关的**安全事件**日志(受 `security.auditLog` 控制)。
///
/// # 为什么收敛成单入口
///
/// 这条路径上共有五类事件(已信任 / 指纹变更 / 变更被接受 / 首次见到被接受 /
/// 阻断关闭时的自动接受),若每个分支各自判断开关,很容易漏掉一处 ——
/// 用户关掉「记录安全事件」却仍看到半数指纹事件,等同于开关坏了。
///
/// 注意:**运维类错误(如写 known_hosts 失败)不走这里**
/// 那类日志与「是否记录安全事件」无关,任何情况下都该留下。
fn audit(&self, sec: &SecuritySettings, warn: bool, msg: &str) {
if !sec.audit_log {
return;
}
if warn {
crate::logger::log_warn("terminal", msg);
} else {
crate::logger::log_info("terminal", msg);
}
}
/// 请求前端确认主机密钥。
///
/// 超时(或前端未响应)→ 返回 `false`(拒绝)。这是刻意的安全姿态:
@@ -392,6 +472,11 @@ pub struct SshConnectParams {
pub keepalive_secs: u64,
pub connect_timeout_ms: u64,
pub startup_command: String,
/// 是否在远端注入 shell 集成 hook(命令历史 + cwd 跟踪)。
///
/// 见 [`crate::terminal::shell::remote_shell_integration_script`]:不落盘,
/// 只在本次会话里改远端 shell 的 `PROMPT_COMMAND` / `precmd`。
pub inject_shell_integration: bool,
/// 代理:`None` 表示直连。
///
/// P0 不实现 SOCKS5 桥接,命令层在主机开了「走代理」时会填 `Some`,
@@ -655,6 +740,25 @@ impl SshSession {
.await
.map_err(|e| format!("启动远程 shell 失败: {e}"))?;
// ===== 注入远端 shell 集成 hook(命令历史 + cwd 跟踪)=====
//
// **必须排在 startup_command 之前**startup_command 可能是 `htop` / `tail -f`
// 这类接管输入的程序,后发的 hook 会被当成那个程序的按键;先发还能让
// startup_command 里 `cd` 之后的第一个提示符就上报正确的 cwd。
//
// 连接期只能直接写 `channel`:会话的写队列(`inner.input_tx`)要到
// `spawn_reader` 里才建立,此刻还没有队列可入。
//
// 发送失败**不影响连接**:hook 只是辅助能力,丢了它最多表现为
// 「历史为空、cwd 不跟随」,不该让用户连不上机器。
if params.inject_shell_integration {
// 前导空格(HISTCONTROL=ignorespace 惯例,也顺带让我们的落库跳过它)
// + 换行(PTY 的行规程只在收到 NL 时才把这一行交给 shell),
// 这两个约定写在 `remote_shell_integration_script` 的文档里。
let hook = format!(" {}\n", super::shell::remote_shell_integration_script());
let _ = channel.data_bytes(hook.into_bytes()).await;
}
// 执行登录后自动执行的命令
if !params.startup_command.trim().is_empty() {
let cmd = format!("{}\n", params.startup_command.trim());
@@ -1339,8 +1443,8 @@ impl Session for SshSession {
self.emit_state();
}
fn set_title(&self, title: &str) {
*self.state.title.lock().unwrap_or_else(|e| e.into_inner()) = title.to_string();
fn set_title_by_user(&self, title: &str) {
self.state.set_title_by_user(title);
}
fn set_detached(&self, detached: bool) {
+6
View File
@@ -90,6 +90,12 @@ pub fn open_for_session(
// 无边框窗口默认没有投影,加上以保持与系统窗口一致的层次感
.shadow(true)
.center()
// **隐藏创建**:窗口的初始底色是白的,而深浅色主题由前端读 localStorage 后
// 才应用 —— 直接显示会先闪一下纯白窗口再变深色(深色模式下尤其刺眼)。
// 因此这里先隐藏,由 TerminalWindow.vue 应用完主题(含 setBackgroundColor
// 再 show + setFocus。同 translate/clipboard 弹窗的预创建做法。
.visible(false)
.focused(false)
.build()
.map_err(|e| format!("创建终端窗口失败: {e}"))?;
+2
View File
@@ -6,6 +6,7 @@ import TitleBar from '@/components/layout/TitleBar.vue'
import Sidebar from '@/components/layout/Sidebar.vue'
import ModuleContainer from '@/components/layout/ModuleContainer.vue'
import NowPlayingDialog from '@/components/layout/NowPlayingDialog.vue'
import TerminalQuickLaunch from '@/modules/terminal/TerminalQuickLaunch.vue'
import { Toaster } from '@/components/ui/sonner'
import { useAppStore } from '@/stores/appStore'
import { useFeiniuStore } from '@/stores/feiniuStore'
@@ -299,6 +300,7 @@ onUnmounted(() => {
</div>
</div>
<NowPlayingDialog />
<TerminalQuickLaunch />
<Toaster position="bottom-right" rich-colors close-button :style="{ zIndex: 99999 }" />
</TooltipProvider>
</template>
+61
View File
@@ -0,0 +1,61 @@
<script setup lang="ts">
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
/**
* 破坏性 / 重要操作确认框 —— 规范 P4 的落地件。
*
* variant="danger" 用于不可逆动作(停止内核、删除记录、清空历史)。
* 不要用 window.confirm:它无法承载「这次操作具体影响什么」的说明。
*/
withDefaults(
defineProps<{
open: boolean
title: string
description?: string
confirmText?: string
cancelText?: string
variant?: 'default' | 'danger'
loading?: boolean
}>(),
{ confirmText: '确认', cancelText: '取消', variant: 'default', loading: false }
)
const emit = defineEmits<{
'update:open': [value: boolean]
confirm: []
}>()
</script>
<template>
<Dialog :open="open" @update:open="emit('update:open', $event)">
<DialogContent class="sm:max-w-md">
<DialogHeader>
<DialogTitle>{{ title }}</DialogTitle>
<DialogDescription v-if="description">{{ description }}</DialogDescription>
</DialogHeader>
<slot />
<DialogFooter>
<Button variant="outline" :disabled="loading" @click="emit('update:open', false)">
{{ cancelText }}
</Button>
<Button
:variant="variant === 'danger' ? 'destructive' : 'default'"
:disabled="loading"
@click="emit('confirm')"
>
{{ confirmText }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>
+57
View File
@@ -0,0 +1,57 @@
<script setup lang="ts">
import { onUnmounted, ref } from 'vue'
import { Check, Copy } from '@lucide/vue'
import { toast } from 'vue-sonner'
/**
* 可复制文本(路径、版本号、ID)。
* 复制后图标短暂切换为对勾 —— 比只弹 toast 的反馈更即时。
*/
const props = withDefaults(
defineProps<{
value: string
mono?: boolean
toastText?: string
}>(),
{ mono: false, toastText: '已复制' }
)
const copied = ref(false)
let timer: ReturnType<typeof setTimeout> | undefined
async function copy() {
try {
await navigator.clipboard.writeText(props.value)
copied.value = true
toast.success(props.toastText)
clearTimeout(timer)
timer = setTimeout(() => (copied.value = false), 1500)
} catch {
toast.error('复制失败')
}
}
onUnmounted(() => clearTimeout(timer))
</script>
<template>
<button
type="button"
class="group inline-flex min-w-0 max-w-full items-center gap-1.5 rounded-sm px-1.5 py-0.5 text-left transition-colors duration-150 outline-none hover:bg-hover focus-visible:ring-2 focus-visible:ring-ring/40"
:title="value"
@click="copy"
>
<span
class="truncate text-[13px] text-foreground"
:class="mono && 'font-mono tabular-nums'"
>
{{ value }}
</span>
<Check v-if="copied" class="size-3 shrink-0 text-success" :stroke-width="1.75" />
<Copy
v-else
class="size-3 shrink-0 text-muted-foreground opacity-0 transition-opacity duration-150 group-hover:opacity-100"
:stroke-width="1.75"
/>
</button>
</template>
+35
View File
@@ -0,0 +1,35 @@
<script setup lang="ts">
/**
* 列表行 —— 主从分栏(模式 B)左栏的条目。
* 选中态用 primary-softhover 态用中性 hover,两者不要混用同一个色。
*/
defineProps<{
title: string
subtitle?: string
active?: boolean
}>()
const emit = defineEmits<{ select: [] }>()
</script>
<template>
<button
type="button"
class="flex w-full items-center gap-3 px-3 py-2 text-left transition-colors duration-150 outline-none focus-visible:ring-2 focus-visible:ring-ring/40 focus-visible:ring-inset"
:class="active ? 'bg-primary-soft' : 'hover:bg-hover'"
@click="emit('select')"
>
<span class="flex min-w-0 flex-1 flex-col gap-0.5">
<span
class="truncate text-[13px]"
:class="active ? 'font-medium text-primary-soft-text' : 'text-foreground'"
>
{{ title }}
</span>
<span v-if="subtitle" class="truncate text-xs text-muted-foreground">{{ subtitle }}</span>
</span>
<span v-if="$slots.trailing" class="shrink-0">
<slot name="trailing" />
</span>
</button>
</template>
+49
View File
@@ -0,0 +1,49 @@
<script setup lang="ts">
/**
* 信息分组 —— 概览类页面的组织单元(规范 4.4「平铺分组」)。
*
* 用「组标题 + 1px 分隔线 + 键值区」替代卡片:
* - 去掉卡片外框与 16px 内边距,纵向密度提升约 40%
* - 彻底避开「两列卡片高度不齐 → 矮的那列下方留空」的问题
*
* 与 StatCard 的分工:StatCard 用于「每块内容量接近、需要视觉分组」的场景;
* InfoGroup 用于「键值快照」类概览页,密度优先。
*/
withDefaults(
defineProps<{
title: string
/** 键值区列数:1 = 单列(默认,一行一条、值有完整宽度);2 = 双列(仅内容极短时用) */
columns?: 1 | 2
/** plain = 平铺无容器;card = 卡片形态(概览网格用,规范 4.4 D2 结构) */
variant?: 'plain' | 'card'
}>(),
{ columns: 1, variant: 'plain' }
)
</script>
<template>
<section
class="flex flex-col"
:class="variant === 'card' && 'rounded-lg border border-border bg-card p-4 shadow-sm'"
>
<header class="flex min-h-8 items-center justify-between gap-3">
<h2 class="text-sm font-semibold text-foreground">{{ title }}</h2>
<div v-if="$slots.action" class="flex shrink-0 items-center gap-1.5">
<slot name="action" />
</div>
</header>
<div class="mt-2 border-t border-divider" />
<div
class="grid gap-x-10 pt-2"
:class="columns === 2 ? 'grid-cols-1 md:grid-cols-2' : 'grid-cols-1'"
>
<slot />
</div>
<div v-if="$slots.footer" class="pt-3">
<slot name="footer" />
</div>
</section>
</template>
+31
View File
@@ -0,0 +1,31 @@
<script setup lang="ts">
/**
* 键值行:左侧键名(次要色),右侧值(主文字色)。
*
* 这是「状态 / 当前版本 / 路径 / 模式」这类信息的统一承载方式,
* 此前各模块各自手写 flex + truncate,间距和对齐都不一致。
*
* 需要放徽章、开关、复制按钮等复杂内容时用默认插槽。
*/
withDefaults(
defineProps<{
label: string
value?: string | number
/** 值用等宽数字(版本号、ID、计数) */
mono?: boolean
}>(),
{ mono: false }
)
</script>
<template>
<div class="flex min-h-8 items-center justify-between gap-3 py-1.5">
<span class="shrink-0 text-[13px] text-fg-secondary">{{ label }}</span>
<span
class="min-w-0 truncate text-right text-[13px] text-foreground"
:class="mono && 'font-mono tabular-nums'"
>
<slot>{{ value }}</slot>
</span>
</div>
</template>
+42
View File
@@ -0,0 +1,42 @@
<script setup lang="ts">
import { computed } from 'vue'
/**
* 关键数值(速率、容量、计数)。
*
* 数字必须 tabular-nums —— 否则高频刷新时宽度会抖动,整行跟着跳。
* trend 只用于「有方向语义」的数值(下载/上传、收入/支出),不要拿它当状态色用。
*/
const props = withDefaults(
defineProps<{
value: string | number
unit?: string
label?: string
trend?: 'down' | 'up'
size?: 'md' | 'lg'
}>(),
{ size: 'md' }
)
const valueClass = computed(() => {
if (props.trend === 'down') return 'text-data-down'
if (props.trend === 'up') return 'text-data-up'
return 'text-foreground'
})
const sizeClass = computed(() =>
props.size === 'lg' ? 'text-[26px] leading-8' : 'text-[22px] leading-7'
)
</script>
<template>
<div class="flex min-w-0 flex-col gap-1">
<span v-if="label" class="text-[11px] text-muted-foreground">{{ label }}</span>
<span class="flex items-baseline gap-1">
<span class="font-semibold tabular-nums" :class="[valueClass, sizeClass]">
{{ value }}
</span>
<span v-if="unit" class="text-xs text-muted-foreground">{{ unit }}</span>
</span>
</div>
</template>
+96
View File
@@ -0,0 +1,96 @@
<script setup lang="ts">
import { computed } from 'vue'
import { cn } from '@/lib/utils'
/**
* 模块外壳 —— 页面框架的唯一落地件,见 UI_DESIGN_SYSTEM.md 第 4 章。
*
* 它固定三件事,缺一就会出现「底部贴边」或「内容被裁」:
* 1. 高度约束链:h-full + min-h-0 一路传下去
* 2. 滚动归属:只有内容区滚动,Tab 行固定
* 3. padding 位置:加在滚动容器内部的 wrapper 上
*
* 模块自身不要再套 overflow 容器,否则会变成嵌套滚动。
*/
const props = withDefaults(
defineProps<{
/** 内置 Tab 行(可选)。需要 TabsContent 这类面板切换时,改用 #header 插槽自带 */
tabs?: { value: string; label: string; count?: number }[]
activeTab?: string
/** 内容区布局,见规范 4.2 */
layout?: 'grid' | 'split' | 'workbench' | 'settings' | 'plain'
/** 内容区是否带内边距;split 与全出血布局传 false */
padded?: boolean
/** 内容区最大宽度,超出居中 —— 避免大屏上卡片被拉得过宽 */
maxWidth?: number
}>(),
{ layout: 'plain', padded: true, maxWidth: 0 }
)
const emit = defineEmits<{ 'update:activeTab': [value: string] }>()
const LAYOUT_CLASS = {
grid: 'grid grid-cols-1 content-start gap-4 md:grid-cols-2',
workbench: 'flex flex-col gap-3',
settings: 'flex flex-col gap-6',
split: 'flex h-full min-h-0',
plain: 'flex flex-col gap-3',
} as const
/** split 需要撑满高度、由内部分栏各自滚动,外壳不能再包一层滚动 */
const isFill = computed(() => props.layout === 'split')
const innerClass = computed(() =>
cn(props.padded && !isFill.value && 'p-5', LAYOUT_CLASS[props.layout])
)
</script>
<template>
<div class="flex h-full min-h-0 flex-col">
<!-- 固定层 #1模块自定义头部通常由 ModuleToolbar 填充 -->
<slot name="toolbar" />
<!-- 固定层 #2Tab 自定义内容走 #header否则用内置 tabs -->
<slot name="header">
<nav
v-if="tabs?.length"
class="flex h-12 shrink-0 items-center gap-1 border-b border-border px-5"
role="tablist"
>
<button
v-for="tab in tabs"
:key="tab.value"
type="button"
role="tab"
:aria-selected="activeTab === tab.value"
class="tab-underline relative inline-flex shrink-0 items-center gap-1.5 rounded-md px-3 py-2 text-[13px] transition-colors duration-150 outline-none hover:bg-hover focus-visible:ring-2 focus-visible:ring-ring/40"
:class="
activeTab === tab.value
? 'font-medium text-primary'
: 'text-fg-secondary hover:text-foreground'
"
@click="emit('update:activeTab', tab.value)"
>
{{ tab.label }}
<span v-if="tab.count" class="tabular-nums opacity-70">{{ tab.count }}</span>
</button>
</nav>
</slot>
<!-- split撑满由内部分栏自己滚 -->
<div v-if="isFill" class="min-h-0 flex-1">
<slot />
</div>
<!-- 其余内容区滚动padding 在内层 wrapper 否则底部 padding 会被内容盖掉 -->
<div v-else class="min-h-0 flex-1 overflow-y-auto">
<div
class="mx-auto w-full"
:class="innerClass"
:style="maxWidth ? { maxWidth: `${maxWidth}px` } : undefined"
>
<slot />
</div>
</div>
</div>
</template>
+17
View File
@@ -0,0 +1,17 @@
<script setup lang="ts">
/**
* 模块内容区顶部的操作条。
* 统一 48px 高、20px 左右内边距、底部 1px 分隔,避免各模块自定高度和间距。
*/
</script>
<template>
<div class="flex h-12 shrink-0 items-center justify-between gap-3 border-b border-border px-5">
<div class="flex min-w-0 items-center gap-2">
<slot name="left" />
</div>
<div class="flex shrink-0 items-center gap-1.5">
<slot name="right" />
</div>
</div>
</template>
+22
View File
@@ -0,0 +1,22 @@
<script setup lang="ts">
/**
* 区块标题(h1 = 16px)。
* 用在内容区里做分组标题,区别于卡片内的 h2(14px)。
*/
defineProps<{
title: string
description?: string
}>()
</script>
<template>
<div class="flex items-start justify-between gap-3">
<div class="flex min-w-0 flex-col gap-1">
<h2 class="truncate text-base font-semibold text-foreground">{{ title }}</h2>
<p v-if="description" class="text-xs text-muted-foreground">{{ description }}</p>
</div>
<div v-if="$slots.action" class="shrink-0">
<slot name="action" />
</div>
</div>
</template>
+9 -5
View File
@@ -33,11 +33,11 @@ const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
type="button"
role="tab"
:aria-selected="modelValue === item.value"
class="flex min-w-0 flex-1 items-center justify-center gap-1.5 rounded-[7px] px-3 py-1.5 text-[13px] transition-colors duration-150 outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
class="flex min-w-0 flex-1 items-center justify-center gap-1.5 rounded-md px-3 py-1.5 text-[13px] transition-colors duration-150 outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
:class="
modelValue === item.value
? 'bg-background font-medium text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
? 'bg-primary-soft font-medium text-primary-soft-text'
: 'text-fg-secondary hover:bg-hover hover:text-foreground'
"
@click="emit('update:modelValue', item.value)"
>
@@ -45,8 +45,12 @@ const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
<span class="truncate">{{ item.label }}</span>
<span
v-if="item.count"
class="shrink-0 rounded-full bg-muted px-1.5 text-[10px] tabular-nums text-muted-foreground"
:class="modelValue === item.value ? 'bg-muted' : 'bg-background/70'"
class="shrink-0 rounded-full px-1.5 text-[10px] tabular-nums"
:class="
modelValue === item.value
? 'bg-primary-soft-text/15 text-primary-soft-text'
: 'bg-card/70 text-fg-secondary'
"
>
{{ item.count }}
</span>
+36
View File
@@ -0,0 +1,36 @@
<script setup lang="ts">
/**
* 设置分组:标题 + 一组 SettingRow。
*
* # 卡片化承载
*
* 组内容用项目的 `Card` + `CardContent` 承载,而不是手写
* `rounded-lg border bg-card`:项目里「卡片」的统一口径是
* `rounded-xl + shadow-sm`(见 UI_DESIGN_SYSTEM 与各模块横幅),手写那份
* 既少了阴影、圆角也小一号 —— 结果是同一个设置页里,用 SettingGroup 的分组
* 没有阴影、直接用 Card 的分组(Shell / 快捷键)有阴影,看着像两套体系。
*
* 行的分隔(1px divider)与内边距留在这里:Card 默认的 `py-6 gap-6`
* 对「一叠设置行」来说过宽,用 `!py-0 !gap-0` 让行自己掌控垂直间距。
*/
import { Card, CardContent } from '@/components/ui/card'
defineProps<{
title: string
description?: string
}>()
</script>
<template>
<section class="flex flex-col gap-3">
<div class="flex min-w-0 flex-col gap-1">
<h2 class="text-sm font-semibold text-foreground">{{ title }}</h2>
<p v-if="description" class="text-xs text-muted-foreground">{{ description }}</p>
</div>
<Card class="!py-0 !gap-0">
<CardContent class="flex flex-col divide-y divide-divider px-4">
<slot />
</CardContent>
</Card>
</section>
</template>
+27
View File
@@ -0,0 +1,27 @@
<script setup lang="ts">
/**
* 设置行:左标签(可带说明),右控件。
* 行高 44px,分组内由 SettingGroup 用 1px divider 分隔。
*/
withDefaults(
defineProps<{
label: string
description?: string
/** wide = 控件固定 200px,用于下拉/输入框对齐;auto = 自适应(开关、按钮) */
controlWidth?: 'auto' | 'wide'
}>(),
{ controlWidth: 'auto' }
)
</script>
<template>
<div class="flex min-h-11 items-center justify-between gap-4 py-2">
<div class="flex min-w-0 flex-col gap-0.5">
<span class="text-[13px] text-foreground">{{ label }}</span>
<span v-if="description" class="text-xs text-muted-foreground">{{ description }}</span>
</div>
<div class="shrink-0" :class="controlWidth === 'wide' && 'w-50'">
<slot />
</div>
</div>
</template>
+29
View File
@@ -0,0 +1,29 @@
<script setup lang="ts">
/**
* 键值/指标卡片 —— 概览类内容区(模式 A 卡片网格)的最小单元。
*
* 默认无阴影,靠 1px 描边与 --canvas 底色区分层级(规范 P7)。
* 卡片内边距 16px、标题与内容间距 12px,不要逐处覆盖。
*/
defineProps<{
title: string
description?: string
}>()
</script>
<template>
<section class="flex flex-col gap-3 rounded-lg border border-border bg-card p-4 shadow-sm">
<header class="flex items-center justify-between gap-2">
<h3 class="truncate text-sm font-semibold text-foreground">{{ title }}</h3>
<div v-if="$slots.action" class="shrink-0">
<slot name="action" />
</div>
</header>
<p v-if="description" class="text-xs text-muted-foreground">{{ description }}</p>
<div class="flex min-w-0 flex-col">
<slot />
</div>
</section>
</template>
+49
View File
@@ -0,0 +1,49 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Inbox, Loader2, ShieldOff, TriangleAlert } from '@lucide/vue'
/**
* 四态占位块 —— 规范 P3 的落地件。
*
* 任何数据驱动的界面都必须能从这四态里选一个渲染,不允许留白。
* 使用方式:v-if 链上按 loading → error → empty → 内容 的顺序判断。
*/
const props = withDefaults(
defineProps<{
variant: 'loading' | 'empty' | 'error' | 'denied'
title?: string
description?: string
/** 占位高度,默认 140;放在卡片里可传更小值 */
minHeight?: number
}>(),
{ minHeight: 140 }
)
const PRESET = {
loading: { icon: Loader2, title: '加载中…', spin: true },
empty: { icon: Inbox, title: '暂无数据', spin: false },
error: { icon: TriangleAlert, title: '加载失败', spin: false },
denied: { icon: ShieldOff, title: '无访问权限', spin: false },
} as const
const preset = computed(() => PRESET[props.variant])
</script>
<template>
<div
class="flex w-full flex-col items-center justify-center gap-2 px-6 text-center"
:style="{ minHeight: `${minHeight}px` }"
>
<component
:is="preset.icon"
class="size-8 shrink-0"
:class="[variant === 'error' ? 'text-danger' : 'text-fg-disabled', preset.spin && 'animate-spin']"
:stroke-width="1.75"
/>
<p class="text-[13px] font-medium text-foreground">{{ title ?? preset.title }}</p>
<p v-if="description" class="max-w-xs text-xs text-muted-foreground">{{ description }}</p>
<div v-if="$slots.action" class="mt-1">
<slot name="action" />
</div>
</div>
</template>
+43
View File
@@ -0,0 +1,43 @@
<script setup lang="ts">
import { computed } from 'vue'
import { cn } from '@/lib/utils'
/**
* 语义徽章。
*
* 三件套用法:底色取 --<state>-soft,文字取 --<state>-text。
* 禁止用实心状态色做徽章底(那是状态点的用法)。
*/
const props = withDefaults(
defineProps<{
variant?: 'success' | 'warning' | 'danger' | 'info' | 'neutral' | 'accent'
size?: 'sm' | 'md'
}>(),
{ variant: 'neutral', size: 'md' }
)
const variantClass = computed(
() =>
({
success: 'bg-success-soft text-success-text',
warning: 'bg-warning-soft text-warning-text',
danger: 'bg-danger-soft text-danger-text',
info: 'bg-info-soft text-info-text',
neutral: 'bg-neutral-soft text-neutral-text',
accent: 'bg-primary-soft text-primary-soft-text',
})[props.variant]
)
const sizeClass = computed(() =>
props.size === 'sm' ? 'h-5 px-2 text-[11px]' : 'h-6 px-2.5 text-xs'
)
</script>
<template>
<span
class="inline-flex shrink-0 items-center gap-1 rounded-sm font-medium whitespace-nowrap"
:class="cn(variantClass, sizeClass)"
>
<slot />
</span>
</template>
+41
View File
@@ -0,0 +1,41 @@
<script setup lang="ts">
import { computed } from 'vue'
/**
* 状态点 + 文字。
*
* 状态色只表达「系统状态」,不得用在可点击元素上(规范 P1)——
* 需要表达「可点击」时用 --primary,需要表达状态时用这里。
*/
const props = withDefaults(
defineProps<{
state?: 'success' | 'warning' | 'danger' | 'info' | 'neutral'
label?: string
/** 持续态(运行中 / 连接中)才开呼吸动效;瞬时状态不要开 */
pulse?: boolean
}>(),
{ state: 'neutral', pulse: false }
)
const dotClass = computed(
() =>
({
success: 'bg-success',
warning: 'bg-warning',
danger: 'bg-danger',
info: 'bg-info',
neutral: 'bg-neutral',
})[props.state]
)
</script>
<template>
<span class="inline-flex min-w-0 items-center gap-1.5">
<span
class="size-2 shrink-0 rounded-full"
:class="[dotClass, pulse && 'status-dot-pulse']"
aria-hidden="true"
/>
<span v-if="label" class="truncate text-[13px] text-foreground">{{ label }}</span>
</span>
</template>
+25
View File
@@ -0,0 +1,25 @@
/**
* 业务组件层 —— 规范见 UI_DESIGN_SYSTEM.md 第 5 章。
*
* 这一层负责「把 token 组装成模块能直接用的结构」。
* 模块内不得再手写状态点、徽章、卡片标题、键值行、四态占位等结构,
* 一律从这里取 —— 否则又会退回各写各的状态。
*/
export { default as ConfirmDialog } from './ConfirmDialog.vue'
export { default as CopyField } from './CopyField.vue'
export { default as DataListRow } from './DataListRow.vue'
export { default as InfoGroup } from './InfoGroup.vue'
export { default as KeyValueRow } from './KeyValueRow.vue'
export { default as MetricValue } from './MetricValue.vue'
export { default as ModuleShell } from './ModuleShell.vue'
export { default as ModuleToolbar } from './ModuleToolbar.vue'
export { default as SectionHeader } from './SectionHeader.vue'
export { default as SettingGroup } from './SettingGroup.vue'
export { default as SettingRow } from './SettingRow.vue'
export { default as StatCard } from './StatCard.vue'
export { default as StateBlock } from './StateBlock.vue'
export { default as StatusBadge } from './StatusBadge.vue'
export { default as StatusDot } from './StatusDot.vue'
export { default as SegmentedNav } from './SegmentedNav.vue'
+56
View File
@@ -0,0 +1,56 @@
<script setup lang="ts">
/**
* 模块内子页的工具条(页壳的头部)。
*
* # 它替代了什么
*
* 此前终端模块的六个子页各自套一层 `rounded-lg border bg-background` 的容器,
* 容器顶端再放一条 `h-11 + border-b` 的标题栏 —— 合起来读作「模块里嵌了一个
* 独立窗口」。而其它模块(proxy / monitor / music / clipboard / downloader
* 的页内一致是「一条漂浮的工具条 + 直接铺开的内容」,没有任何容器边框。
*
* 这个组件就是那条漂浮工具条:**无边框、无底部分隔线、无内边距**,
* 靠 `mb-3` 与下方内容留出间距。各页只需保证自己的内容区是
* `flex-1 min-h-0`,整页的层次就与其它模块一致。
*
* # 为什么标题不删
*
* 模块级 Tabs 已经写了「主机」「凭据」,页内再写一遍标题看似冗余,
* 但合并页(凭据 = 密钥 + 已知主机)里工具条还承载分区开关与计数,
* 且这些页面会被全局搜索直接跳到 —— 标题是「我落在哪一页」的确认。
* 保留标题、去掉边框,才是这次改动要的效果。
*
* # 用法
*
* ```vue
* <div class="flex flex-col h-full overflow-hidden">
* <PageToolbar :icon="Globe" title="SSH 主机" :meta="`共 ${n} 台`">
* <Button size="sm">新建主机</Button> <!-- 默认插槽 = 右侧动作区 -->
* </PageToolbar>
* <ScrollArea class="flex-1 min-h-0">…</ScrollArea>
* </div>
* ```
*
* 需要放在标题与动作之间的信息(如历史页的筛选摘要、分区开关)用 `info` 具名插槽。
*/
import type { Component } from 'vue'
defineProps<{
/** lucide 图标组件 */
icon: Component
title: string
/** 标题右侧的次要信息(数量、状态等)。为空则不渲染 */
meta?: string
}>()
</script>
<template>
<div class="shrink-0 flex items-center gap-2 mb-3 min-h-8">
<component :is="icon" class="size-4 text-muted-foreground shrink-0" />
<h3 class="text-sm font-medium shrink-0">{{ title }}</h3>
<span v-if="meta" class="text-xs text-muted-foreground shrink-0">{{ meta }}</span>
<slot name="info" />
<div class="flex-1 min-w-0" />
<slot />
</div>
</template>
+1 -1
View File
@@ -328,7 +328,7 @@ const handleBlur = () => {
:class="
tabsStore.activeTab === tab.value
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-secondary/60'
: 'text-muted-foreground hover:bg-primary-soft hover:text-primary-soft-text'
"
@click="tabsStore.setActiveTab(tab.value)"
@mousedown.stop
+27 -17
View File
@@ -3,31 +3,41 @@ import { cva } from "class-variance-authority"
export { default as Button } from "./Button.vue"
/**
* 按钮变体 —— 约束见 UI_DESIGN_SYSTEM.md 第 5.2 节
*
* hover 语义(这里曾经是界面最大的观感问题):
* 主按钮 → 底色变深(--primary-hover),不用透明度方案(/90 在深色上方向是反的)
* 次按钮 → 中性加深(--hover)
* 幽灵按钮 → 带强调色的淡底(--primary-soft),让"可点击"一眼可见
*
* 尺寸统一 28 / 34 / 40。基础组件是实现尺寸标尺的地方,允许等值任意值;
* 业务代码不得照抄这种写法。
*/
export const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-[13px] font-medium transition-[background-color,border-color,color,box-shadow,transform] duration-150 outline-none focus-visible:ring-2 focus-visible:ring-ring/40 aria-invalid:ring-destructive/20 aria-invalid:border-destructive active:scale-[0.97] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
/** 主操作:同屏仅允许一个 */
default: "bg-primary text-primary-foreground hover:bg-primary-hover active:bg-primary-active",
/** 破坏性操作:必须配合 ConfirmDialog */
destructive: "bg-danger text-white hover:bg-danger/90 focus-visible:ring-danger/30",
/** 次操作:描边卡片底,中性 hover */
outline: "border border-border-strong bg-card text-foreground hover:bg-hover active:bg-active",
secondary: "bg-secondary text-secondary-foreground hover:bg-hover active:bg-active",
/** 幽灵操作:工具栏、图标按钮。hover 带强调色 */
ghost: "text-fg-secondary hover:bg-primary-soft hover:text-primary-soft-text",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
"default": "h-9 px-4 py-2 has-[>svg]:px-3",
"xs": "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
"sm": "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
"default": "h-[34px] px-3.5 py-2 has-[>svg]:px-3",
"xs": "h-6 gap-1 rounded-sm px-2 text-[11px] has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
"sm": "h-7 gap-1.5 rounded-md px-2.5 text-xs has-[>svg]:px-2",
"lg": "h-10 rounded-md px-6 has-[>svg]:px-4",
"icon": "size-9",
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8",
"icon": "size-[34px]",
"icon-xs": "size-6 rounded-sm [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-7",
"icon-lg": "size-10",
},
},
@@ -8,7 +8,7 @@ import { cn } from "@/lib/utils"
const props = defineProps<CalendarHeadingProps & { class?: HTMLAttributes["class"] }>()
defineSlots<{
default: (props: { headingValue: string }) => any
default: (props: { headingValue: string }) => unknown
}>()
const delegatedProps = reactiveOmit(props, "class")
+1 -1
View File
@@ -24,7 +24,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
data-slot="switch"
v-bind="forwarded"
:class="cn(
'peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-3 disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer hover:data-[state=checked]:bg-primary/90 hover:data-[state=unchecked]:bg-input/70 active:scale-95',
'peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/40 inline-flex h-[18px] w-8 shrink-0 cursor-pointer items-center rounded-full border border-transparent outline-none transition-colors focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-50 hover:data-[state=checked]:bg-primary-hover hover:data-[state=unchecked]:bg-active',
props.class,
)"
>
+1 -1
View File
@@ -16,7 +16,7 @@ const forwardedProps = useForwardProps(delegatedProps)
<TabsTrigger
data-slot="tabs-trigger"
:class="cn(
'data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,background-color,box-shadow] duration-200 ease-out focus-visible:ring-3 focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4',
'tab-underline relative text-fg-secondary hover:bg-hover hover:text-foreground data-[state=active]:font-medium data-[state=active]:text-primary data-[state=active]:hover:bg-transparent focus-visible:ring-ring/40 inline-flex h-full flex-1 items-center justify-center gap-1.5 rounded-md border-0 px-3 py-2 text-[13px] whitespace-nowrap transition-colors duration-150 outline-none focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4',
props.class,
)"
v-bind="forwardedProps"
+65 -44
View File
@@ -3,6 +3,7 @@ import { useTerminalStore } from '@/stores/terminalStore'
import { createLogger } from '@/lib/logger'
import type { ShortcutBinding, TerminalSettings } from '@/types/terminal'
import { TERMINAL_ACTIONS } from '@/lib/terminalActions'
import { appIsDark, effectiveThemeName } from '@/lib/terminalThemes'
const logger = createLogger('terminal')
@@ -32,19 +33,10 @@ export function useTerminalStream(options?: { fixedSessionId?: Ref<string | null
/** 分屏:当前标签内并列展示的会话(长度为 1 时即普通单面板) */
const panes = ref<Array<{ id: string; sessionId: string }>>([])
/**
* 当前打开的浮层。
*
* `snippets` 与 `search` 与其余项的区别:它们需要知道「当前作用于哪个会话」,
* 而不只是一个全局面板。因此 TerminalModule 渲染它们时会把 activeSession
* 一并传下去 —— 片段面板据此过滤出对当前 shell 可用的片段。
*/
const overlay = ref<
'none' | 'settings' | 'hosts' | 'keys' | 'knownHosts' | 'search' | 'snippets' | 'history'
>('none')
/** 搜索结果统计(供搜索栏显示「第 n 项」) */
const searchTerm = ref('')
// 这里曾有一份 `overlay`(当前打开的浮层)与 `searchTerm` 状态:模块 Tabs 与
// 控制栏浮层接管导航之后,它们既没有被写入也没有被读取,是「内嵌独立应用」
// 时期的残留,2026-09-22 清理。浮层开合现在分别归属模块根(tab)与
// `modules/terminal/useRecordPopovers`(控制栏上的历史/片段)。
// ===== 标签 =====
@@ -115,32 +107,36 @@ export function useTerminalStream(options?: { fixedSessionId?: Ref<string | null
: [sessionId]
const groupSet = new Set(group)
try {
// 只要组里有任意一个仍活跃就要确认(而不是只看被点的那一个
let anyAlive = false
for (const id of group) {
try {
if (await store.sessionAlive(id)) {
anyAlive = true
break
// 「关闭运行中会话前确认」关闭时直接关:连 alive 查询都不发
// (那是一次 IPC + 后端进程存活探测,用户已经明确表示不要这道确认
if (store.settings?.layout.confirmCloseRunning ?? true) {
try {
// 只要组里有任意一个仍活跃就要确认(而不是只看被点的那一个)
let anyAlive = false
for (const id of group) {
try {
if (await store.sessionAlive(id)) {
anyAlive = true
break
}
} catch {
/* 单个查询失败按不活跃处理,下面还有整体兜底 */
}
} catch {
/* 单个查询失败按不活跃处理,下面还有整体兜底 */
}
if (anyAlive) {
const info = store.sessionById(sessionId)
const extra = group.length > 1 ? `(该标签含 ${group.length} 个分屏面板)` : ''
const ok = await requestConfirm(
sessionId,
info?.title || '会话',
`该会话中可能有正在运行的任务${extra}。关闭后无法恢复,确定继续?`
)
if (!ok) return
}
} catch (e) {
// 会话可能已经断开(alive 查询本身失败)——此时无需确认,直接关
logger.warn(`查询会话存活状态失败,直接关闭:${String(e)}`)
}
if (anyAlive) {
const info = store.sessionById(sessionId)
const extra = group.length > 1 ? `(该标签含 ${group.length} 个分屏面板)` : ''
const ok = await requestConfirm(
sessionId,
info?.title || '会话',
`该会话中可能有正在运行的任务${extra}。关闭后无法恢复,确定继续?`
)
if (!ok) return
}
} catch (e) {
// 会话可能已经断开(alive 查询本身失败)——此时无需确认,直接关
logger.warn(`查询会话存活状态失败,直接关闭:${String(e)}`)
}
for (const id of group) {
@@ -195,13 +191,33 @@ export function useTerminalStream(options?: { fixedSessionId?: Ref<string | null
// ===== 新建会话 =====
/** 新建本地会话(用设置里的默认 shell) */
async function newLocalTab(cwd?: string) {
const shellId = store.settings?.lastShellId || store.shells.find(s => s.enabled)?.id
/**
* 新建本地会话。
*
* `shellId` 为空时用「上次使用的 shell」,其次第一个启用项 —— 这与
* 「新建」(一个按钮)的预期一致:不动脑子点下去就该起来最常用的那个。
* 传入具体 id 的场合是终端页的「新建」下拉(用户明确选了某个 shell)。
*/
async function newLocalTab(opts: { shellId?: string; cwd?: string } = {}) {
const shellId =
opts.shellId ||
store.settings?.lastShellId ||
store.shells.find(s => s.enabled)?.id
if (!shellId) {
logger.error('没有可用的 Shell,无法新建会话')
return null
}
// 「新会话继承当前目录」:显式传了 cwd 就以调用方为准;
// 否则在开启该项时取**当前激活会话**的 cwd。
// 只对本地会话生效 —— SSH 会话的 cwd 是远端路径,本地 shell 起不来
// Windows 上 `/home/ops` 会被当成非法目录,会话直接失败)。
let cwd = opts.cwd
if (cwd === undefined && (store.settings?.layout.inheritCwd ?? false)) {
const cur = activeSession.value
if (cur?.kind === 'local' && cur.cwd) cwd = cur.cwd
}
try {
// 尺寸给一个合理初值:真实值由 xterm 的 fit 在挂载后立即上报,
// 这里的作用只是让 PTY 在首帧有正确的行列数,避免 shell 先按 80x24 画一遍提示符再重排
@@ -385,12 +401,19 @@ export function useTerminalStream(options?: { fixedSessionId?: Ref<string | null
fontOverride.value = null
}
/** 生效的外观(把字号覆盖并进去,供 TerminalPane 使用) */
/** 生效的外观(把字号覆盖 + 「跟随应用主题」解析后的配色一起并进去,供 TerminalPane 使用) */
const effectiveAppearance = computed(() => {
const a = store.settings?.appearance
if (!a) return null
if (fontOverride.value === null) return a
return { ...a, fontSize: fontOverride.value }
// 「跟随应用主题」要跟着应用深浅实时变:开了就用响应式的 appIsDark,
// 关了就只按此刻的深浅解析一次(此后应用改深浅不再带着终端变,见字段说明)。
const theme = effectiveThemeName(
a.theme,
a.followAppTheme,
a.followAppTheme ? appIsDark.value : document.documentElement.classList.contains('dark')
)
if (fontOverride.value === null) return { ...a, theme }
return { ...a, theme, fontSize: fontOverride.value }
})
// ===== 快捷键 =====
@@ -469,8 +492,6 @@ export function useTerminalStream(options?: { fixedSessionId?: Ref<string | null
activeSession,
orderedSessions,
panes,
overlay,
searchTerm,
fontOverride,
effectiveAppearance,
confirmDialog,
+60 -33
View File
@@ -6,7 +6,7 @@ import { WebLinksAddon } from '@xterm/addon-web-links'
import { Unicode11Addon } from '@xterm/addon-unicode11'
import { WebglAddon } from '@xterm/addon-webgl'
import { useTerminalStore } from '@/stores/terminalStore'
import { resolveTheme } from '@/lib/terminalThemes'
import { resolveTheme, themeWithOpacity } from '@/lib/terminalThemes'
import { createLogger } from '@/lib/logger'
import type { AppearanceSettings } from '@/types/terminal'
@@ -139,6 +139,10 @@ export function useXterm(options: UseXtermOptions) {
// ——这是 xterm 的标准做法,Ctrl+拖选仍能强制进入选择模式
macOptionIsMeta: false,
allowProposedApi: true,
// 允许透明背景(「背景不透明度」设置项依赖它)。只能在构造时设定:
// 它决定渲染器是否按 alpha 混合,运行中改会被 xterm 忽略。
// 代价是渲染稍慢,因此不透明度 < 100 时还会关掉 WebGL(见 ensureRenderer)。
allowTransparency: true,
// 转换宽字符(中日韩)的正确显示;配合 unicode11 插件使用 Unicode 11 宽度表
convertEol: false
})
@@ -168,24 +172,8 @@ export function useXterm(options: UseXtermOptions) {
void document.fonts.ready.then(() => safeFit())
}
// WebGL 渲染:大吞吐场景(`cat` 大文件、编译日志)下 CPU 占用远低于 canvas。
// 但它在部分虚拟机 / 远程桌面 / 老显卡上会黑屏,所以**必须可回退**:
// 加载失败就留在默认的 DOM 渲染器,功能不受影响,只是性能差一些。
if (app?.gpuRendering !== false) {
try {
const webgl = new WebglAddon()
// 上下文丢失(切换显卡、系统休眠唤醒)时必须丢弃,否则画面永久卡死
webgl.onContextLoss(() => {
logger.warn('WebGL 上下文丢失,回退到默认渲染器')
webgl.dispose()
webglAddon.value = null
})
t.loadAddon(webgl)
webglAddon.value = webgl
} catch (e) {
logger.warn(`WebGL 渲染不可用,回退到默认渲染器:${String(e)}`)
}
}
// WebGL 渲染:见 ensureRenderer(按 GPU 开关 + 不透明度决定是否启用)
ensureRenderer(app)
// 用户输入 → Rust(base64 保字节完整性)
t.onData(data => {
@@ -318,22 +306,61 @@ export function useXterm(options: UseXtermOptions) {
})
}
// ===== 外观热更新 =====
// ===== 渲染器与外观热更新 =====
function applyAppearance(app: AppearanceSettings | null) {
const t = term.value
if (!t || !app) return
t.options.theme = resolveTheme(app.theme)
t.options.fontFamily = app.fontFamily || 'Cascadia Mono, Consolas, monospace'
t.options.fontSize = app.fontSize || 14
t.options.lineHeight = app.lineHeight || 1.2
t.options.letterSpacing = app.letterSpacing || 0
t.options.cursorBlink = app.cursorBlink ?? true
t.options.cursorStyle = normalizeCursorStyle(app.cursorStyle)
t.options.scrollback = app.scrollback || 5000
// 字体变化会改变字符宽高 → 行列数变了 → 必须重新 fit 并上报
requestAnimationFrame(() => safeFit())
/**
* 按需启用 / 关闭 WebGL 渲染。
*
* 两个条件缺一不可:
* - `gpuRendering` 开关(默认开):WebGL 在大吞吐场景(`cat` 大文件、编译日志)
* 下 CPU 占用远低于 canvas,但它在少数虚拟机 / 远程桌面 / 老显卡上会黑屏,
* 所以**必须可回退**:加载失败就留在默认渲染器,功能不受影响。
* - **不透明度 100%**WebGL 渲染器不支持透明背景(`allowTransparency` 只被
* DOM/canvas 渲染器认),半透明时继续用它会让「背景不透明度」看起来完全没反应。
* 于是拉低不透明度时主动丢弃 WebGL,回到支持 alpha 的渲染器。
*/
function ensureRenderer(app: AppearanceSettings | null) {
const t = term.value
if (!t) return
const wantWebgl = app?.gpuRendering !== false && (app?.opacity ?? 100) >= 100
if (wantWebgl && !webglAddon.value) {
try {
const webgl = new WebglAddon()
// 上下文丢失(切换显卡、系统休眠唤醒)时必须丢弃,否则画面永久卡死
webgl.onContextLoss(() => {
logger.warn('WebGL 上下文丢失,回退到默认渲染器')
webgl.dispose()
webglAddon.value = null
})
t.loadAddon(webgl)
webglAddon.value = webgl
} catch (e) {
logger.warn(`WebGL 渲染不可用,回退到默认渲染器:${String(e)}`)
}
} else if (!wantWebgl && webglAddon.value) {
webglAddon.value.dispose()
webglAddon.value = null
}
}
function applyAppearance(app: AppearanceSettings | null) {
const t = term.value
if (!t || !app) return
t.options.theme = themeWithOpacity(resolveTheme(app.theme), app.opacity ?? 100)
t.options.fontFamily = app.fontFamily || 'Cascadia Mono, Consolas, monospace'
t.options.fontSize = app.fontSize || 14
t.options.lineHeight = app.lineHeight || 1.2
t.options.letterSpacing = app.letterSpacing || 0
t.options.cursorBlink = app.cursorBlink ?? true
t.options.cursorStyle = normalizeCursorStyle(app.cursorStyle)
t.options.scrollback = app.scrollback || 5000
// 不透明度或 GPU 开关变了都可能要换渲染器(半透明必须离开 WebGL)
ensureRenderer(app)
// 字体变化会改变字符宽高 → 行列数变了 → 必须重新 fit 并上报
requestAnimationFrame(() => safeFit())
}
// ===== 监视 =====
+53 -7
View File
@@ -557,7 +557,15 @@ export const commands = {
terminalWrite: (sessionId: string, data: string) => __TAURI_INVOKE<null>("terminal_write", { sessionId, data }),
/** 通知会话尺寸变化。 */
terminalResize: (sessionId: string, cols: number, rows: number) => __TAURI_INVOKE<null>("terminal_resize", { sessionId, cols, rows }),
/** 重命名会话(同时更新标签与独立窗口标题)。 */
/**
* 重命名会话(同时更新标签与独立窗口标题)。
*
* # 为什么用 `set_title_by_user` 而不是直接写 `title`
*
* 重命名会把标题**锁定**,此后 shell 通过 OSC 0/2 上报的标题一律被忽略。
* 不锁的话,用户重命名后下一条提示符就把名字覆盖回去了 ——
* 表现为「重命名无效」,且只在有输出时才复现,很难归因。
*/
terminalRenameSession: (sessionId: string, title: string) => __TAURI_INVOKE<null>("terminal_rename_session", { sessionId, title }),
/** 在独立窗口中打开会话。 */
terminalDetachSession: (sessionId: string) => __TAURI_INVOKE<null>("terminal_detach_session", { sessionId }),
@@ -659,6 +667,10 @@ export const commands = {
*
* 由前端在用户点击「接受」/「拒绝」后调用。会话侧的握手正在阻塞等待这个回传,
* 因此超时(120 秒)后会视为拒绝。
*
* `app` 只用于安全事件日志(`security.auditLog`)——用户的每个决定都要留痕:
* 「谁在什么时候接受了新指纹」是事后审计里最关键的一条。specta 会把
* `AppHandle` 参数从生成的 JS 签名里剔除,因此前端调用方式不变。
*/
terminalConfirmHostKey: (sessionId: string, accept: boolean, host: string, port: number, keyType: string, fingerprint: string) => __TAURI_INVOKE<null>("terminal_confirm_host_key", { sessionId, accept, host, port, keyType, fingerprint }),
/** 分页 + 搜索命令历史。 */
@@ -1310,10 +1322,6 @@ export type LayoutSettings = {
confirmCloseRunning?: boolean,
/** 新建标签页时是否继承当前会话的工作目录 */
inheritCwd?: boolean,
/** 侧栏默认是否展开 */
sidebarOpen?: boolean,
/** 侧栏宽度(像素) */
sidebarWidth?: number,
/** 是否显示底部状态栏 */
showStatusBar?: boolean,
/**
@@ -1564,14 +1572,30 @@ export type SecuritySettings = {
*
* **不提供 "auto-accept" 选项**TOFU 静默接受是 MITM 的入口,
* 属于代码层不该给用户的开关。
*
* 读取方(`ssh::SshHandler::check_server_key`**显式读取本字段而不是写死**:
* 唯一支持的取值是 `"ask"`,其它取值(配置文件被手工改过)一律按 `"ask"`
* 处理 —— 安全开关的失败方向必须朝更严的一侧,绝不能因为出现了一个
* 没见过的字符串就放宽。
*/
hostKeyPolicy?: string,
/**
* 指纹变更时是否阻断(**默认 true**)。关掉会让中间人攻击无声通过,
* 因此前端需以红色风险提示呈现该开关。
*
* 关闭时的行为不是「照旧弹窗」而是「自动接受并记入变更历史」:
* 开关的语义是「不再拦截」,若关掉后仍弹阻断弹窗,用户会以为没生效。
*/
blockOnFingerprintChange?: boolean,
/** 是否记录连接审计日志(P2,默认关;开启后输入输出落盘,含脱敏) */
/**
* 是否把**安全事件**写入日志(默认开)。
*
* 记录对象是主机密钥相关的事件:指纹已信任 / 首次见到被接受 / 指纹变更
* 被接受或拒绝 / 自动接受(阻断关闭时)。与「会话日志」(输出字节流落盘,
* 由工具栏的 `terminal_toggle_logging` 按会话显式开启,见 `audit` 模块的
* 取舍说明)**是两件事**:这里只写事件行,不含屏幕内容,因此没有隐私代价,
* 默认开启才能保证「发生过什么」事后可查。
*/
auditLog?: boolean,
};
@@ -1704,7 +1728,13 @@ export type SessionTemplate = {
export type ShellProfile = {
/** 唯一标识(同时是新建会话时的 shellKey) */
id?: string,
/** 展示名称,如 "PowerShell 7" */
/**
* 展示名称,如 "PowerShell 7" / "Windows PowerShell"。
*
* **设置页里的描述名,不是标签上显示的名字** —— 后者取
* [`super::shell::session_label`](按可执行文件名归一的短名)。
* 两者刻意分开:描述名可编辑、可很长,而标签要能一行放下多个会话。
*/
name?: string,
/** 可执行文件绝对路径 */
path?: string,
@@ -1803,6 +1833,22 @@ export type SshHost = {
jumpIds?: string[],
/** 终端的字符编码("utf-8" 默认 | "gbk" 等)。老服务器常见 GBK,中文环境刚需。 */
encoding?: string,
/**
* 是否在远端注入 shell 集成 hook(命令历史 + cwd 跟踪)。
*
* # 为什么默认必须是 true
*
* 本结构是容器级 `#[serde(default)]`:老配置里缺这个字段时,serde 不是填
* 「零值」而是取 [`SshHost::default()`] 的值。因此**默认值决定老主机是否自动
* 开启注入** —— 填 `false` 会让所有既有主机静默不注入(表现为历史永远为空,
* 而开关在界面上看着是关的,用户根本不会去动它)。填 `true` 则无需改
* `heal()`、无需升 `version`。
*
* 关掉它的场景:远端 shell 集成会与 `startup_command` 里 `exec` 换 shell 的
* 配置冲突,或服务器对会话环境有洁癖要求(注入会改本次会话的 `PROMPT_COMMAND`
* `precmd`,但不落盘、不影响其它会话)。
*/
injectShellIntegration?: boolean,
};
/** 任务下载协议类型 */
+2
View File
@@ -93,6 +93,8 @@ export const EVENTS = {
terminalTransferProgress: 'terminal-transfer-progress',
/** 终端:请求对「关闭仍在运行的会话」二次确认 */
terminalConfirmClose: 'terminal-confirm-close',
/** 终端:设置已保存(无负载)。各窗口据此重读设置,实现跨窗口同步 */
terminalSettings: 'terminal-settings',
// 监控 OSD
osdStateUpdate: 'osd-state-update',
/** OSD 数据通道:仅推送显示项 key→value 映射 + 网速(高频,每秒) */
+22
View File
@@ -20,6 +20,14 @@
* 2. **避开项目已有的全局快捷键**。`Ctrl+2` 已被翻译模块占用(取词翻译面板),
* 所以标签跳转用 `Alt+1..9` —— 在终端里 `Alt+数字` 通常不产生控制字符,
* 冲突面最小,且符合「切标签」的直觉。
*
* # 唯一一条「全局」动作:`newDetachedWindow`
*
* 这一条不在第二层生效,而是由 `TerminalQuickLaunch` 用 `plugin-global-shortcut`
* 注册成**系统级**热键(任意应用前台时都能按)。它放在这张表里,是为了让键位
* 跟着终端设置一起存、一起改(设置 → 快捷键 → 会话 → 新建独立窗口)。
* 因此终端内的按键分发(`useTerminalKeys`)刻意**不处理**它 —— 免得同一次按键
* 既被全局注册触发、又被终端内分发触发,开出两个窗口。
*/
/** 快捷键动作定义 */
@@ -162,6 +170,13 @@ export const TERMINAL_ACTIONS: TerminalAction[] = [
description: '打开常用命令片段列表',
defaultKeys: 'Ctrl+Shift+S'
},
{
id: 'history',
label: '命令历史',
group: '会话',
description: '打开命令历史面板(从已记录的历史里检索、复用)',
defaultKeys: 'Ctrl+Shift+H'
},
{
id: 'sessionSwitcher',
label: '会话切换器',
@@ -175,6 +190,13 @@ export const TERMINAL_ACTIONS: TerminalAction[] = [
group: '会话',
description: '把当前会话放到独立窗口中(需终端独立窗口支持)',
defaultKeys: 'Ctrl+Shift+N'
},
{
id: 'newDetachedWindow',
label: '新建独立窗口',
group: '会话',
description: '全局生效(任意界面按下):打开独立终端窗口并选择要启动的会话',
defaultKeys: 'Ctrl+3'
}
]
+72 -5
View File
@@ -1,3 +1,4 @@
import { ref } from 'vue'
import type { ITheme } from '@xterm/xterm'
/**
@@ -183,17 +184,83 @@ export const TERMINAL_THEME_OPTIONS: Array<{ value: string; label: string }> = [
{ value: 'system', label: '跟随主应用' }
]
/**
* 应用当前是否深色。
*
* 各窗口的 `applyTheme`(主窗口 appStore、独立窗口 / 选择窗口)在切换
* `documentElement` 的 `dark` class 时写入这里。终端主题要「跟随应用」就必须有
* 一个**响应式**输入 —— 只读 DOM class 的话,应用切浅色时终端不会重算
* (这正是「跟随应用主题」此前形同摆设的原因)。
*/
export const appIsDark = ref(false)
/**
* 亮暗成对的主题。
*
* 「跟随应用主题」打开时,`theme` 只作为**取色基准**(见 Rust `AppearanceSettings`
* 的字段说明):应用是深色取 `dark`,浅色取 `light`。只有深色变体的主题
* Dracula / One Dark)在浅色下回落到 VS Code 浅色 —— 与其在浅色背景上硬套深色
* 配色,不如换一套能看清的。
*/
const THEME_PAIRS: Record<string, { light: string; dark: string }> = {
'vscode-dark': { light: 'vscode-light', dark: 'vscode-dark' },
'vscode-light': { light: 'vscode-light', dark: 'vscode-dark' },
'solarized-dark': { light: 'solarized-light', dark: 'solarized-dark' },
'solarized-light': { light: 'solarized-light', dark: 'solarized-dark' },
dracula: { light: 'vscode-light', dark: 'dracula' },
'one-dark': { light: 'vscode-light', dark: 'one-dark' },
system: { light: 'vscode-light', dark: 'vscode-dark' }
}
/**
* 求「实际生效」的主题名。
*
* - `followAppTheme` 开:按应用深浅取对应侧(见 THEME_PAIRS
* - `followAppTheme` 关:用所选主题本身;选「跟随主应用」时按传入的深浅解析一次
* (调用方传的是当前值快照,因此之后应用改深浅不会带着终端一起变)
*/
export function effectiveThemeName(theme: string, followAppTheme: boolean, isDark: boolean): string {
if (followAppTheme) {
const pair = THEME_PAIRS[theme] ?? THEME_PAIRS.system
return isDark ? pair.dark : pair.light
}
if (theme === 'system') return isDark ? 'vscode-dark' : 'vscode-light'
return theme
}
/**
* 按「背景不透明度」把主题背景色改成半透明。
*
* xterm 的 `theme.background` 支持带 alpha 的 8 位 hex —— 这是实现背景不透明度的
* 正确方式:**文字颜色不受影响**(给容器加 CSS `opacity` 会把字也一起变淡)。
* 前置条件是构造时 `allowTransparency: true`(见 useXterm)。
*/
export function themeWithOpacity(theme: ITheme, opacity: number): ITheme {
const p = Math.min(100, Math.max(0, Math.round(opacity)))
if (p >= 100) return theme
const bg = theme.background ?? '#000000'
// 只处理 #RRGGBB / #RRGGBBAA;其它写法原样返回(宁可不透明,也不要变成无效色值)
let rgb: string
if (bg.length === 7) rgb = bg
else if (bg.length === 9) rgb = bg.slice(0, 7)
else return theme
const alpha = Math.round((p / 100) * 255)
.toString(16)
.padStart(2, '0')
return { ...theme, background: `${rgb}${alpha}` }
}
/**
* 解析主题名到具体的 xterm 主题对象。
*
* `system` 读取主应用的亮暗模式(`document.documentElement` 上的 `dark` class
* Tailwind 的暗色模式约定),映射到 vscode-dark / vscode-light。
* 这样终端不会在主应用切到浅色时突兀地保持全黑。
* `system` 读取 `appIsDark`(由各窗口的主题应用逻辑维护),映射到
* vscode-dark / vscode-light,避免终端在主应用切到浅色时突兀地保持全黑
*/
export function resolveTheme(name: string): ITheme {
if (name === 'system') {
const isDark = document.documentElement.classList.contains('dark')
return TERMINAL_THEMES[isDark ? 'vscode-dark' : 'vscode-light']
return TERMINAL_THEMES[appIsDark.value ? 'vscode-dark' : 'vscode-light']
}
return TERMINAL_THEMES[name] ?? TERMINAL_THEMES['vscode-dark']
}
+114
View File
@@ -0,0 +1,114 @@
/**
* 主题色派生单测(Node 内置 test runner)。
*
* 重点不是「算得对不对」,而是「任何预设色在两种模式下都可读」——
* 对比度是这条链路上唯一会真正伤到用户的指标。
*/
import { test } from 'node:test'
import assert from 'node:assert/strict'
import {
ACCENT_PRESETS,
ACCENT_VAR_KEYS,
contrastRatio,
deriveAccent,
hexToOklch,
oklchToHex,
resolveAccentHex,
type ThemeMode
} from './theme.ts'
const MODES: ThemeMode[] = ['light', 'dark']
/** 深色模式的对比度基准(与实现里的 DARK_REFERENCE 一致) */
const DARK_REFERENCE = '#1e1e22'
test('hex → OKLCH → hex 往返稳定', () => {
for (const preset of ACCENT_PRESETS) {
const back = oklchToHex(hexToOklch(preset.hex))
const [r1, g1, b1] = [1, 3, 5].map(i => parseInt(preset.hex.slice(i, i + 2), 16))
const [r2, g2, b2] = [1, 3, 5].map(i => parseInt(back.slice(i, i + 2), 16))
// 允许 ±1/255 的取整误差
assert.ok(Math.abs(r1 - r2) <= 1, `${preset.id} R: ${preset.hex}${back}`)
assert.ok(Math.abs(g1 - g2) <= 1, `${preset.id} G: ${preset.hex}${back}`)
assert.ok(Math.abs(b1 - b2) <= 1, `${preset.id} B: ${preset.hex}${back}`)
}
})
test('默认蓝派生结果贴近现有手调值', () => {
const light = deriveAccent('#0066cc', 'light')
assert.equal(light['--primary'], '#0066cc')
// 现有 --primary-soft / --primary-soft-text = #e8f1fc / #004f9e,只要求同一色族与量级
assert.equal(hexToOklch(light['--primary-soft']).l > 0.9, true)
assert.equal(contrastRatio(light['--primary-soft-text'], light['--primary-soft']) >= 4.5, true)
})
test('所有预设 × 两种模式:强调色对底色、浅底文字对浅底、按钮文字对按钮底 均 ≥ 4.5:1', () => {
for (const preset of ACCENT_PRESETS) {
for (const mode of MODES) {
const vars = deriveAccent(preset.hex, mode)
const reference = mode === 'light' ? '#ffffff' : DARK_REFERENCE
const label = `${preset.id}/${mode}`
assert.ok(
contrastRatio(vars['--primary'], reference) >= 4.5,
`${label} primary 对底对比度不足: ${contrastRatio(vars['--primary'], reference).toFixed(2)}`
)
assert.ok(
contrastRatio(vars['--primary-soft-text'], vars['--primary-soft']) >= 4.5,
`${label} soft-text 对 soft 对比度不足: ${contrastRatio(
vars['--primary-soft-text'],
vars['--primary-soft']
).toFixed(2)}`
)
// 填充上的文字:浅色模式必然 ≥4.5:1;深色模式强调色偏亮,白字约 2.7–3:1
//(与现有 #2997ff + 白字 的取舍一致),门槛按 UI 组件的 2.5:1 兜底
const fillMin = mode === 'light' ? 4.5 : 2.5
assert.ok(
contrastRatio(vars['--primary-foreground'], vars['--primary']) >= fillMin,
`${label} foreground 对 primary 对比度不足: ${contrastRatio(
vars['--primary-foreground'],
vars['--primary']
).toFixed(2)}`
)
assert.equal(vars['--ring'], vars['--primary'], `${label} ring 应等于 primary`)
}
}
})
test('亮色(青柠)在浅色模式下被自动压暗', () => {
const base = hexToOklch('#65a30d')
const derived = hexToOklch(deriveAccent('#65a30d', 'light')['--primary'])
// 青柠原色在白底上只有约 2.8:1,派生后必须达标且比原色暗
assert.ok(derived.l < base.l, `未压暗: ${base.l}${derived.l}`)
assert.ok(contrastRatio(oklchToHex(derived), '#ffffff') >= 4.5)
})
test('色相保留(除被 sRGB 色域裁剪的极端彩度)', () => {
for (const preset of ACCENT_PRESETS) {
for (const mode of MODES) {
const base = hexToOklch(preset.hex)
const derived = hexToOklch(deriveAccent(preset.hex, mode)['--primary'])
if (derived.c < 0.02) continue // 石墨这类近无彩色,色相无意义
const diff = Math.abs(((derived.h - base.h + 540) % 360) - 180)
assert.ok(diff < 12, `${preset.id}/${mode} 色相偏移过大: ${base.h}${derived.h}`)
}
}
})
test('派生结果都是合法 hex,且每个变量都有值', () => {
for (const preset of ACCENT_PRESETS) {
for (const mode of MODES) {
const vars = deriveAccent(preset.hex, mode)
for (const key of ACCENT_VAR_KEYS) {
assert.match(vars[key], /^#[0-9a-f]{6}$/, `${preset.id}/${mode} ${key} 非法: ${vars[key]}`)
}
}
}
})
test('resolveAccentHex:默认与非法自定义都回落到内置默认', () => {
assert.equal(resolveAccentHex('blue', null), null)
assert.equal(resolveAccentHex('custom', null), null)
assert.equal(resolveAccentHex('custom', 'not-a-color'), null)
assert.equal(resolveAccentHex('custom', '#ffd60a'), '#ffd60a')
assert.equal(resolveAccentHex('violet', null), '#7c3aed')
})
+345
View File
@@ -0,0 +1,345 @@
/**
* 主题色(强调色)派生与应用。
*
* # 为什么只改 7 个变量就够
*
* 全应用的「可交互色」早已收敛到 `--primary` 单点(见 UI_DESIGN_SYSTEM.md §P1):
* 按钮、开关、焦点环、选中态都走它。因此换色不需要动任何组件,只要覆盖这一族变量。
*
* # 为什么用 OKLCH 而不是 HSL
*
* HSL 的「明度」感知不均匀:换色相后同一个 L 有的发亮有的发暗(黄色 L=50% 已经很亮,
* 蓝色 L=50% 却很暗)。OKLCH 的 L 是感知均匀的,锁住 L/C 只换 H,才能保证
* 「换任意色相后对比度一致」。
*
* # 深浅两套为什么必须分别派生
*
* 深色底上直接用浅色值不可读(`#0066cc` 在 `#1e1e22` 上只有约 3.2:1)。
* 所以同一基色按模式重定 L:浅色压暗到能在白底上当文字用,深色提亮到能在深底上读。
*
* # 与样式表的关系
*
* 这些变量写在 `document.documentElement` 的行内样式上 —— 行内优先级高于
* `:root` 与 `.dark` 两条规则,因此:
* - 切模式必须**整组 7 个重写**(否则浅色的行内值会残留到深色模式)
* - 恢复默认必须 `removeProperty`(写空串仍算「已设置」,会继续压住样式表)
*
* # 为什么这个文件零依赖
*
* 它要能被 `node --test` 直接跑(`theme.test.ts` 校验全部预设的对比度),
* 而 Node 不解析 `@/` 路径别名 —— 所以这里不 import 任何项目模块。
* 需要读 localStorage 的独立窗口请用 `@/lib/accentStorage`。
*/
/** 强调色变量名(顺序固定,整组写入/整组清除) */
export const ACCENT_VAR_KEYS = [
'--primary',
'--primary-hover',
'--primary-active',
'--primary-soft',
'--primary-soft-text',
'--ring',
'--primary-foreground'
] as const
export type AccentVarKey = (typeof ACCENT_VAR_KEYS)[number]
export interface AccentPreset {
id: string
name: string
/** 基色(浅色模式取值)。深色模式由派生算法提亮,不需要另给 */
hex: string
}
/** 预设色板:覆盖色相环主要区间,纯自由取色很容易选出对比度不达标的颜色 */
export const ACCENT_PRESETS: AccentPreset[] = [
{ id: 'blue', name: '默认蓝', hex: '#0066cc' },
{ id: 'indigo', name: '靛蓝', hex: '#4f46e5' },
{ id: 'violet', name: '紫罗兰', hex: '#7c3aed' },
{ id: 'rose', name: '玫红', hex: '#e11d48' },
{ id: 'orange', name: '橙', hex: '#ea580c' },
{ id: 'amber', name: '琥珀', hex: '#d97706' },
{ id: 'lime', name: '青柠', hex: '#65a30d' },
{ id: 'green', name: '翠绿', hex: '#059669' },
{ id: 'teal', name: '青绿', hex: '#0d9488' },
{ id: 'graphite', name: '石墨', hex: '#5b6472' }
]
export const DEFAULT_ACCENT_ID = 'blue'
/** 自定义色的 id(hex 单独存放) */
export const CUSTOM_ACCENT_ID = 'custom'
export type ThemeMode = 'light' | 'dark'
/** 浅色模式:基色需在白卡上同时胜任「文字」与「填充」,亮度压在这个区间 */
const LIGHT_L_MIN = 0.42
const LIGHT_L_MAX = 0.58
/** 深色模式:深底上要提亮 */
const DARK_L_MIN = 0.68
const DARK_L_MAX = 0.8
/** 彩度上下限:太低会「灰得没有强调感」,太高在深底上发刺 */
const C_MIN = 0.06
const C_MAX_LIGHT = 0.2
const C_MAX_DARK = 0.18
/** 正文可读性门槛(WCAG AA,用于「强调色当文字用」的场合) */
const CONTRAST_AA = 4.5
/**
* 填充色上文字的最低门槛。
*
* 比 AA 低是有意的:深色模式下强调色必须够亮才能在深底上当文字用(≥4.5:1),
* 而同一颜色做填充时白字必然只能到 2.7–3:1 —— 现有主题就是这个取舍
* `#2997ff` + 白字 ≈ 2.98:1)。这里只兜住「自定义极亮色导致白字几乎不可读」的极端情况。
*/
const CONTRAST_FILL_MIN = 2.5
/** 深色模式的对比度基准:卡片底(比画布 #16161a 略亮,取更严的一侧) */
const DARK_REFERENCE = '#1e1e22'
const WHITE = '#ffffff'
const INK = '#1d1d1f'
export interface Oklch {
/** 感知明度 01 */
l: number
/** 彩度 */
c: number
/** 色相角(度) */
h: number
}
// ===== 色彩空间换算 =====
const clamp = (v: number, min: number, max: number) => Math.min(max, Math.max(min, v))
const toLinear = (channel: number) =>
channel <= 0.04045 ? channel / 12.92 : Math.pow((channel + 0.055) / 1.055, 2.4)
const toGamma = (channel: number) =>
channel <= 0.0031308 ? 12.92 * channel : 1.055 * Math.pow(channel, 1 / 2.4) - 0.055
/** `#rrggbb` → 01 的 sRGB 三元组;非法输入返回 null */
export function parseHex(hex: string): [number, number, number] | null {
const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim())
if (!m) return null
const n = parseInt(m[1], 16)
return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255]
}
const toHex = (rgb: [number, number, number]) =>
'#' +
rgb
.map(v => Math.round(clamp(v, 0, 1) * 255).toString(16).padStart(2, '0'))
.join('')
export function hexToOklch(hex: string): Oklch {
const parsed = parseHex(hex)
if (!parsed) return { l: 0.5, c: 0.1, h: 250 }
const [r, g, b] = parsed.map(toLinear) as [number, number, number]
const l = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b
const m = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b
const s = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b
const l_ = Math.cbrt(l)
const m_ = Math.cbrt(m)
const s_ = Math.cbrt(s)
const L = 0.2104542553 * l_ + 0.793617785 * m_ - 0.0040720468 * s_
const A = 1.9779984951 * l_ - 2.428592205 * m_ + 0.4505937099 * s_
const B = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.808675766 * s_
const c = Math.hypot(A, B)
// 无彩色时色相无意义,给 0 避免 atan2 噪声
const h = c < 1e-4 ? 0 : ((Math.atan2(B, A) * 180) / Math.PI + 360) % 360
return { l: L, c, h }
}
/** OKLCH → `#rrggbb`;越出 sRGB 色域时逐步降彩度,保证颜色有效 */
export function oklchToHex(value: Oklch): string {
const { l, h } = value
let c = Math.max(0, value.c)
for (let i = 0; i < 200; i++) {
const rgb = oklchToRgb(l, c, h)
if (rgb.every(v => v >= -0.0005 && v <= 1.0005)) return toHex(rgb)
c -= 0.005
if (c <= 0) return toHex(oklchToRgb(l, 0, h))
}
return toHex(oklchToRgb(l, 0, h))
}
const oklchToRgb = (l: number, c: number, hDeg: number): [number, number, number] => {
const hRad = (hDeg * Math.PI) / 180
const A = c * Math.cos(hRad)
const B = c * Math.sin(hRad)
const l_ = l + 0.3963377774 * A + 0.2158037573 * B
const m_ = l - 0.1055613458 * A - 0.0638541728 * B
const s_ = l - 0.0894841775 * A - 1.291485548 * B
const lc = l_ ** 3
const mc = m_ ** 3
const sc = s_ ** 3
return [
toGamma(4.0767416621 * lc - 3.3077115913 * mc + 0.2309699292 * sc),
toGamma(-1.2684380046 * lc + 2.6097574011 * mc - 0.3413193965 * sc),
toGamma(-0.0041960863 * lc - 0.7034186147 * mc + 1.707614701 * sc)
]
}
// ===== 对比度(WCAG =====
export function relativeLuminance(hex: string): number {
const parsed = parseHex(hex)
if (!parsed) return 0
const [r, g, b] = parsed.map(toLinear) as [number, number, number]
return 0.2126 * r + 0.7152 * g + 0.0722 * b
}
export function contrastRatio(a: string, b: string): number {
const la = relativeLuminance(a)
const lb = relativeLuminance(b)
const [hi, lo] = la >= lb ? [la, lb] : [lb, la]
return (hi + 0.05) / (lo + 0.05)
}
/** 在给定色相/彩度下,把明度朝目标方向推进到满足对比度为止 */
function fitLuminance(
startL: number,
c: number,
h: number,
reference: string,
direction: 1 | -1
): number {
let l = startL
for (let i = 0; i < 40; i++) {
if (contrastRatio(oklchToHex({ l, c, h }), reference) >= CONTRAST_AA) break
const next = l + direction * 0.02
if (next < 0.2 || next > 0.98) break
l = next
}
return l
}
/**
* 填充色上的文字色。
*
* 浅色模式的强调色已被亮度拟合压到对白底 ≥4.5:1,因此白字必然达标;
* 深色模式的强调色偏亮,白字约 2.7–3:1 —— 与现有主题一致,保持白字。
* 只有白字低到不可读(自定义极亮色)才改深墨。
*/
function foregroundFor(primary: string): string {
const onWhite = contrastRatio(primary, WHITE)
if (onWhite >= CONTRAST_FILL_MIN) return WHITE
return contrastRatio(primary, INK) > onWhite ? INK : WHITE
}
/**
* 由单一基色派生某个模式下的全套强调色变量。
*
* 色相 H 原样保留(用户选的就是色相),只按模式重定 L/C,并在必要时继续推进 L
* 直到对比度达标 —— 这就是「选到黄/浅绿时自动压暗」的实现。
*/
export function deriveAccent(baseHex: string, mode: ThemeMode): Record<AccentVarKey, string> {
const base = hexToOklch(baseHex)
const h = base.h
if (mode === 'light') {
const c = clamp(base.c, C_MIN, C_MAX_LIGHT)
const l = fitLuminance(
clamp(base.l, LIGHT_L_MIN, LIGHT_L_MAX),
c,
h,
WHITE,
-1
)
const primary = oklchToHex({ l, c, h })
const soft = oklchToHex({ l: 0.95, c: c * 0.3, h })
// 浅底上的文字:从基色加深起步,不足 4.5:1 继续压暗
const softTextL = fitLuminance(clamp(l - 0.1, 0.3, 0.48), c, h, soft, -1)
return {
'--primary': primary,
'--primary-hover': oklchToHex({ l: clamp(l - 0.08, 0.2, 1), c, h }),
'--primary-active': oklchToHex({ l: clamp(l - 0.16, 0.15, 1), c, h }),
'--primary-soft': soft,
'--primary-soft-text': oklchToHex({ l: softTextL, c, h }),
'--ring': primary,
'--primary-foreground': foregroundFor(primary)
}
}
const c = clamp(base.c, C_MIN, C_MAX_DARK)
const l = fitLuminance(clamp(base.l, DARK_L_MIN, DARK_L_MAX), c, h, DARK_REFERENCE, 1)
const primary = oklchToHex({ l, c, h })
const soft = oklchToHex({ l: 0.3, c: c * 0.45, h })
const softTextL = fitLuminance(clamp(l + 0.2, 0.72, 0.9), c * 0.7, h, soft, 1)
return {
'--primary': primary,
'--primary-hover': oklchToHex({ l: clamp(l + 0.06, 0, 0.96), c, h }),
'--primary-active': oklchToHex({ l: clamp(l - 0.1, 0, 1), c, h }),
'--primary-soft': soft,
'--primary-soft-text': oklchToHex({ l: softTextL, c: c * 0.7, h }),
'--ring': primary,
'--primary-foreground': foregroundFor(primary)
}
}
// ===== 应用 / 清除 =====
/**
* 把强调色写到 `documentElement` 的行内样式(`baseHex` 为 null 表示恢复内置默认)。
*
* 必须传入当前模式:行内值会压住 `.dark` 规则,所以每次都要按模式整组重写。
*/
export function applyAccent(root: HTMLElement, baseHex: string | null, mode: ThemeMode): void {
if (!baseHex || !parseHex(baseHex)) {
clearAccent(root)
return
}
const vars = deriveAccent(baseHex, mode)
for (const key of ACCENT_VAR_KEYS) {
root.style.setProperty(key, vars[key])
}
}
/** 清除行内强调色,回落到 style.css 里手调的默认值 */
export function clearAccent(root: HTMLElement): void {
for (const key of ACCENT_VAR_KEYS) {
root.style.removeProperty(key)
}
}
/**
* 由设置值解析出实际基色。
*
* 返回 null 表示「用内置默认」—— 此时不写任何行内变量,
* 保留 style.css 里手调的蓝,老用户与未改设置的用户零视觉变化。
*/
export function resolveAccentHex(accentId: string, accentHex: string | null): string | null {
if (accentId === CUSTOM_ACCENT_ID) return accentHex && parseHex(accentHex) ? accentHex : null
if (accentId === DEFAULT_ACCENT_ID) return null
return ACCENT_PRESETS.find(p => p.id === accentId)?.hex ?? null
}
/**
* 独立窗口用:从 localStorage 的设置里读出主题色。
*
* 独立窗口(快速面板 / 托盘菜单 / 截图编辑器…)不加载 appStore,各自有一套
* applyTheme 副本,只共享同一份 localStorage 设置。
*
* `storageKey` 由调用方传入(`STORAGE_KEYS.appSettings`)—— 本文件不 import 项目模块,
* 是为了能被 `node --test` 直接加载。
*/
export function readStoredAccent(storageKey: string): string | null {
try {
const raw = localStorage.getItem(storageKey)
if (!raw) return null
const settings = JSON.parse(raw) as { accentId?: string; accentHex?: string | null }
return resolveAccentHex(settings.accentId ?? DEFAULT_ACCENT_ID, settings.accentHex ?? null)
} catch {
return null
}
}
/** 独立窗口用:读设置并应用主题色(在各自 applyTheme 里 class 切换之后调用) */
export function applyStoredAccent(root: HTMLElement, mode: ThemeMode, storageKey: string): void {
applyAccent(root, readStoredAccent(storageKey), mode)
}
+35 -8
View File
@@ -29,12 +29,13 @@ import { useModuleTabsStore, type ModuleTab } from '@/stores/moduleTabsStore'
* ```
*
* ## 工作原理
* 1. onMounted 时注册标签到 moduleTabsStore**并复用该模块上次停留的 tab**),
* TitleBar 据此渲染浮动切换器
* 2. 用 IntersectionObserver 监听 TabsList 可见性(rootMargin 裁剪 TitleBar 高度)
* 3. 双向 watch 同步本地 activeTab 与 store.activeTab
* 4. 消费搜索导航的待跳转 tab(模块尚未挂载的场景)
* 5. onUnmounted 时清理 observer 并把当前 tab 记到 store(按模块)后注销标签
* 1. **setup 阶段(首帧之前)** 定下要显示的 tab:搜索跳转 > 上次停留 > 声明时的初始值
* —— 模块切回来是重新挂载,等到 onMounted 再改就会先渲染一帧初始页(闪一下)
* 2. onMounted 时注册标签到 moduleTabsStoreTitleBar 据此渲染浮动切换器
* 3. 用 IntersectionObserver 监听 TabsList 可见性(rootMargin 裁剪 TitleBar 高度)
* 4. 双向 watch 同步本地 activeTab 与 store.activeTab
* 5. 消费搜索导航的待跳转 tab(模块尚未挂载的场景)
* 6. onUnmounted 时清理 observer 并把当前 tab 记到 store(按模块)后注销标签
*
* ## 约束
* - TitleBar 高度固定为 40px (h-10)composable 内部已用 44px 裁剪(含缓冲)
@@ -45,11 +46,32 @@ import { useModuleTabsStore, type ModuleTab } from '@/stores/moduleTabsStore'
export function useModuleTabs(
moduleId: string,
activeTab: Ref<string>,
tabs: ModuleTab[]
tabs: ModuleTab[],
/** alwaysVisible: 模块自身不渲染 TabsList,标题栏切换器常驻(内容最大化模式) */
options: { alwaysVisible?: boolean } = {}
): Ref<HTMLElement | null> {
const tabsStore = useModuleTabsStore()
const tabsListRef = ref<HTMLElement | null>(null)
/**
* 首次渲染前就定下要显示的 tab:搜索跳转 > 上次停留 > 声明时的初始值。
*
* 必须在 setup 里做。模块切走时组件被卸载、`activeTab` 随之丢失,切回来重新挂载
* 时它是声明时的初始值(如 proxy 的 `'overview'`);若等到 onMounted 才恢复,
* 首帧已经按初始值渲染过一次 —— 表现为「先闪一下第一页,再跳到上次停留页」,
* 而不是一次干净的进入动画。
*
* 这里只**读** storerememberedTab / peekPendingTab 都不改状态):注册仍留在
* onMounted,避免从未挂载的组件污染 store 的当前模块。
*/
const pendingTab = tabsStore.peekPendingTab(moduleId)
if (pendingTab && tabs.some(t => t.value === pendingTab)) {
activeTab.value = pendingTab
} else {
const restored = tabsStore.rememberedTab(moduleId, tabs, activeTab.value)
if (restored !== activeTab.value) activeTab.value = restored
}
let observer: IntersectionObserver | null = null
/** 应用待跳转 tab(若属于当前模块的 tab 列表) */
@@ -103,7 +125,12 @@ export function useModuleTabs(
const restored = tabsStore.registerTabs(moduleId, tabs, activeTab.value)
if (restored !== activeTab.value) activeTab.value = restored
await nextTick()
setupObserver()
// 常驻模式:模块不渲染 TabsList,直接让标题栏切换器显示
if (options.alwaysVisible) {
tabsStore.setFloatingVisible(true)
} else {
setupObserver()
}
// 搜索导航跳转:模块刚挂载,消费待跳转 tab(优先级高于上次停留)
applyPendingTab()
})
+1
View File
@@ -41,6 +41,7 @@ const standaloneWindowApps: Array<[hash: string, label: string, loader: () => Pr
['#download-window', '下载窗口', () => import('./modules/downloader/DownloadWindow.vue')],
['#translate-popup', '翻译悬浮窗', () => import('./modules/translate/TranslatePopup.vue')],
['#terminal-window', '终端独立窗口', () => import('./modules/terminal/TerminalWindow.vue')],
['#new-terminal-window', '新建终端窗口', () => import('./modules/terminal/NewTerminalWindow.vue')],
]
const winHash = window.location.hash
+56 -38
View File
@@ -1,15 +1,14 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
import {
ClipboardList, Copy, Pin, PinOff, Trash2, Search, Image as ImageIcon,
ClipboardList, Copy, Database, Pin, PinOff, Trash2, Search, Image as ImageIcon,
FileText, Files, Settings as SettingsIcon, Loader2, Eraser, Keyboard,
} from '@lucide/vue'
import { toast } from 'vue-sonner'
import { useClipboardStore, type ClipboardItem, type ClipboardKind, type ClipboardItemDetail } from '@/stores/clipboardStore'
import { useModuleTabs } from '@/lib/use-module-tabs'
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Card, CardContent } from '@/components/ui/card'
import { Tabs, TabsContent } from '@/components/ui/tabs'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Switch } from '@/components/ui/switch'
@@ -33,11 +32,11 @@ import {
const store = useClipboardStore()
const activeTab = ref('history')
const tabsListRef = useModuleTabs('clipboard', activeTab, [
useModuleTabs('clipboard', activeTab, [
{ value: 'history', label: '历史' },
{ value: 'pinned', label: '固定' },
{ value: 'settings', label: '设置' },
])
], { alwaysVisible: true })
// 历史搜索/过滤
const searchQuery = ref('')
@@ -276,10 +275,10 @@ const kindIcon = (k: string) => {
const kindLabel = (k: string) => (k === 'text' ? '文本' : k === 'image' ? '图片' : '文件')
const kindBadgeClass = (k: string) =>
k === 'text'
? 'border-blue-500/40 bg-blue-500/10 text-blue-600 dark:text-blue-400'
? 'border-info/40 bg-info-soft text-info-text'
: k === 'image'
? 'border-emerald-500/40 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'
: 'border-amber-500/40 bg-amber-500/10 text-amber-600 dark:text-amber-400'
? 'border-success/40 bg-success-soft text-success-text'
: 'border-warning/40 bg-warning-soft text-warning-text'
const formatSize = (bytes: number) => {
if (!bytes) return ''
if (bytes < 1024) return `${bytes} B`
@@ -320,18 +319,10 @@ onUnmounted(() => {
</script>
<template>
<div class="h-full p-6 overflow-hidden flex flex-col">
<div class="min-h-full p-5 flex flex-col">
<Tabs v-model="activeTab" class="flex-1 min-h-0 flex flex-col">
<div ref="tabsListRef" class="shrink-0">
<TabsList class="grid w-full max-w-md grid-cols-3 !bg-transparent !p-0 !shadow-none">
<TabsTrigger value="history" class="gap-1.5"><ClipboardList class="size-3.5" />历史</TabsTrigger>
<TabsTrigger value="pinned" class="gap-1.5"><Pin class="size-3.5" />固定</TabsTrigger>
<TabsTrigger value="settings" class="gap-1.5"><SettingsIcon class="size-3.5" />设置</TabsTrigger>
</TabsList>
</div>
<!-- 历史 -->
<TabsContent value="history" class="flex-1 min-h-0 flex flex-col mt-4 tab-animate">
<TabsContent value="history" class="flex-1 min-h-0 flex flex-col tab-animate">
<div class="flex items-center gap-2 mb-3 shrink-0">
<div class="relative flex-1 max-w-sm">
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
@@ -392,7 +383,7 @@ onUnmounted(() => {
</span>
</div>
<ScrollArea class="flex-1 min-h-0">
<div class="flex-1 min-h-0">
<div class="space-y-1.5 pr-2">
<!-- 加载骨架数据异步返回前撑起画面避免误显示"暂无历史" -->
<div v-if="store.loading" class="space-y-1.5">
@@ -450,12 +441,17 @@ onUnmounted(() => {
</CardContent>
</Card>
</div>
</ScrollArea>
</div>
</TabsContent>
<!-- 固定 -->
<TabsContent value="pinned" class="flex-1 min-h-0 flex flex-col mt-4 tab-animate">
<div class="flex-1 min-h-0 overflow-y-auto space-y-1.5 pr-1">
<TabsContent value="pinned" class="flex-1 min-h-0 flex flex-col tab-animate">
<!--
这份列表是模块内的独立滚动区视口会裁掉超出自身的阴影而这里的卡片
悬停时是 shadow-md shadow-sm 更大`-mx-3` + 内层 `px-3 pb-2`
让视口两侧各多出 12px底部多出 8px 的余量卡片位置与宽度不变
-->
<div class="flex-1 min-h-0 overflow-y-auto -mx-3">
<Empty v-if="!pinnedList.length" class="h-full py-8">
<EmptyHeader>
<EmptyMedia variant="icon">
@@ -467,6 +463,8 @@ onUnmounted(() => {
<EmptyDescription>鼠标悬停历史条目点击图钉按钮即可固定</EmptyDescription>
</EmptyContent>
</Empty>
<div v-else class="space-y-1.5 px-3 pb-2">
<Card v-for="item in pinnedList" :key="item.id" class="group hover:shadow-md transition-shadow py-0">
<CardContent class="flex items-center gap-3 px-3 py-2">
<component :is="kindIcon(item.kind)" class="size-4 text-primary shrink-0" />
@@ -491,20 +489,29 @@ onUnmounted(() => {
</div>
</CardContent>
</Card>
</div>
</div>
</TabsContent>
<!-- 设置 -->
<TabsContent value="settings" class="flex-1 min-h-0 overflow-y-auto mt-4 tab-animate">
<div class="max-w-xl space-y-4">
<div class="flex items-center">
<h3 class="text-base font-medium flex items-center gap-2">
<SettingsIcon class="size-4" />基本设置
</h3>
</div>
<!--
设置页同样自带滚动只有内容区滚Tab 行固定内层 `px-3 pb-3`
必须的卡片阴影贴着这个视口的边缘会被裁而根元素上的内边距不属于
滚动内容滚到底时最后一张卡片的底部阴影照样没了
卡片形态对齐下载器/监控等模块的设置页标题进卡片CardHeader +
CardTitle图标用主题色卡片铺满内容宽度此前是 max-w-xl 的窄栏 +
一个页级 h3读起来像页面上浮着一个标题 + 三张无标题的卡片
-->
<TabsContent value="settings" class="flex-1 min-h-0 overflow-y-auto tab-animate -mx-3">
<div class="space-y-4 px-3 pb-3">
<!-- 基本设置 -->
<Card>
<CardContent class="space-y-4 p-4">
<CardHeader>
<CardTitle class="flex items-center gap-2 text-base">
<SettingsIcon class="size-4 text-primary" />基本设置
</CardTitle>
</CardHeader>
<CardContent class="space-y-4">
<div class="flex items-center justify-between">
<div>
<Label>启用剪贴板监听</Label>
@@ -546,9 +553,16 @@ onUnmounted(() => {
</CardContent>
</Card>
<!-- 容量与上限 -->
<Card>
<CardContent class="space-y-4 p-4">
<div class="grid grid-cols-2 gap-4">
<CardHeader>
<CardTitle class="flex items-center gap-2 text-base">
<Database class="size-4 text-primary" />容量与上限
</CardTitle>
</CardHeader>
<CardContent>
<!-- 数值输入限宽卡片铺满后让输入框跟着拉到六百多像素只是难读 -->
<div class="grid grid-cols-2 gap-4 max-w-xl">
<div class="space-y-2">
<Label>最大历史条数</Label>
<Input v-model.number="form.maxItems" type="number" min="50" max="10000" />
@@ -563,13 +577,17 @@ onUnmounted(() => {
</CardContent>
</Card>
<!-- 快捷弹窗 -->
<Card>
<CardContent class="space-y-4 p-4">
<CardHeader>
<CardTitle class="flex items-center gap-2 text-base">
<Keyboard class="size-4 text-primary" />快捷弹窗
</CardTitle>
</CardHeader>
<CardContent class="space-y-4">
<div class="flex items-center justify-between">
<div>
<Label class="flex items-center gap-2">
<Keyboard class="size-4" />快捷弹窗快捷键
</Label>
<Label>快捷弹窗快捷键</Label>
<p class="text-xs text-muted-foreground mt-1">全局快捷键触发鼠标位置历史弹窗留空禁用</p>
</div>
<Button variant="outline" size="sm" @click="handleTestPopup">测试弹窗</Button>
+4 -1
View File
@@ -3,6 +3,7 @@ import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
import { getCurrentWindow } from '@tauri-apps/api/window'
import { EVENTS, STORAGE_KEYS } from '@/lib/constants'
import { applyStoredAccent } from '@/lib/theme'
import { Effect, EffectState } from '@tauri-apps/api/window'
// Rust 端通过 tauri-specta 生成的命令绑定(bindings.ts
import { commands } from '@/lib/bindings'
@@ -36,7 +37,7 @@ const kindFilter = ref<'all' | 'text' | 'image' | 'files'>('all')
const selectedIndex = ref(0)
const loading = ref(false)
const searchInputRef = ref<HTMLInputElement | null>(null)
let unlistenFns: UnlistenFn[] = []
const unlistenFns: UnlistenFn[] = []
let searchTimer: ReturnType<typeof setTimeout> | null = null
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / PAGE_SIZE)))
@@ -356,6 +357,8 @@ async function applyTheme() {
root.classList.remove('dark', 'effect-mica', 'effect-acrylic', 'effect-normal')
root.classList.add(`effect-${effect}`)
if (isDark) root.classList.add('dark')
// 主题色:与主窗口共用同一份设置
applyStoredAccent(root, isDark ? 'dark' : 'light', STORAGE_KEYS.appSettings)
// 4. 设置窗口效果与背景色
// 弹窗窗口创建时 transparent=true,透明窗口下原生背景色不显示,
+4 -1
View File
@@ -3,6 +3,7 @@ import { ref, onMounted, onUnmounted } from 'vue'
import { listen, emit, type UnlistenFn } from '@tauri-apps/api/event'
import { getCurrentWindow, Effect, EffectState } from '@tauri-apps/api/window'
import { EVENTS, STORAGE_KEYS } from '@/lib/constants'
import { applyStoredAccent } from '@/lib/theme'
import { commands } from '@/lib/bindings'
import { ZoomIn, ZoomOut, FileText, Image as ImageIcon } from '@lucide/vue'
import { ScrollArea } from '@/components/ui/scroll-area'
@@ -14,7 +15,7 @@ const thumbSrc = ref('')
const fullSrc = ref('')
const enlarged = ref(false)
const loading = ref(false)
let unlistenFns: UnlistenFn[] = []
const unlistenFns: UnlistenFn[] = []
// 加载序号:连续悬停快速切换时丢弃过期请求结果
let loadSeq = 0
@@ -208,6 +209,8 @@ async function applyTheme() {
root.classList.remove('dark', 'effect-mica', 'effect-acrylic', 'effect-normal')
root.classList.add(`effect-${effect}`)
if (isDark) root.classList.add('dark')
// 主题色:与主窗口共用同一份设置
applyStoredAccent(root, isDark ? 'dark' : 'light', STORAGE_KEYS.appSettings)
try {
const tauriWin = getCurrentWindow()
await tauriWin.clearEffects()
+1 -1
View File
@@ -55,5 +55,5 @@ export const moduleConfig: ModuleConfig = {
defaultEnabled: true,
loader: () => import('./ClipboardModule.vue'),
searchItems,
order: 20
order: 80
}
+40 -16
View File
@@ -4,7 +4,6 @@ import { ArrowLeft, Search } from '@lucide/vue'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Separator } from '@/components/ui/separator'
import { useSearchStore } from '@/stores/searchStore'
import { registerAllTools, TOOLS_META } from './tools'
@@ -77,21 +76,46 @@ watch(selectedId, () => {
</script>
<template>
<div class="h-full p-6 overflow-hidden flex flex-col">
<!--
# 滚动归属只有主滚动区滚2026-09-22 由内滚动改为外滚动
此前本模块的列表页与详情页各自套一层 `ScrollArea``h-full overflow-hidden`
的模块根 + 内部滚动 monitor / music / clipboard / 设置页的形态不一致
那些模块的模块根是 `min-h-full p-*`整页在 ModuleContainer 的主滚动区里滚
两套滚动归属混用会带来一串副作用Tab /工具条要不要固定内边距该加在
滚动容器根还是内层卡片阴影被视口裁`-mx-3 px-3` 就是在补这个
现在统一到外滚动模块根 `min-h-full p-5`内容自然增高滚动交给主滚动区
p-5 属于**滚动内容**模块根在主滚动区内部因此卡片阴影四周都有余量
不再需要任何负外边距补偿
工具自身需要限高的地方正则结果diff 输出等仍保留各自的 `max-h-*` 内滚动
盒子 那是一块有边界的输出区与页面级滚动不是一回事
-->
<div class="min-h-full p-5 flex flex-col">
<!-- 列表视图 -->
<template v-if="!selectedTool">
<div class="flex items-center gap-3 mb-4 shrink-0 flex-wrap">
<div class="flex items-center gap-3 mb-4 flex-wrap">
<div class="relative flex-1 min-w-[180px] max-w-xs">
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input v-model="query" placeholder="搜索工具..." class="h-8 pl-8 text-sm" />
</div>
<!--
分类筛选选中态用主题色软底 SegmentedNav 及其余模块的筛选 chip 同一
口径此前写的是 `bg-secondary text-foreground` 灰底黑字读起来像
禁用也是全项目唯一用实心灰表达选中的地方
-->
<Button
v-for="cat in (['all', ...CATEGORY_ORDER] as const)"
:key="cat"
size="sm"
variant="ghost"
class="h-7 px-2.5 text-xs"
:class="category === cat ? 'bg-secondary text-foreground' : 'text-muted-foreground'"
:class="
category === cat
? 'bg-primary-soft font-medium text-primary-soft-text'
: 'text-fg-secondary hover:text-foreground'
"
@click="category = cat as typeof category"
>
{{ cat === 'all' ? '全部' : CATEGORY_LABEL[cat as DevTool['category']] }}
@@ -99,12 +123,16 @@ watch(selectedId, () => {
<span class="text-xs text-muted-foreground ml-auto">{{ (category === 'all' ? allTools : filteredTools).length }} 个工具</span>
</div>
<ScrollArea class="flex-1 min-h-0 -mr-3 pr-3">
<div v-if="filteredTools.length === 0" class="h-full flex items-center justify-center text-muted-foreground text-sm">
没有找到匹配的工具
</div>
<!-- 空态flex-1 撑住剩余高度短页面时也能垂直居中 -->
<div
v-if="filteredTools.length === 0"
class="flex-1 flex items-center justify-center text-muted-foreground text-sm"
>
没有找到匹配的工具
</div>
<!-- 按分类分组 -->
<!-- 按分类分组 -->
<div v-else>
<div v-for="group in groupedTools" :key="group.category" class="mb-5">
<div class="flex items-center gap-2 mb-2">
<span class="text-xs font-medium text-muted-foreground">{{ group.label }}</span>
@@ -129,12 +157,12 @@ watch(selectedId, () => {
</Card>
</div>
</div>
</ScrollArea>
</div>
</template>
<!-- 工具详情视图 -->
<template v-else>
<div class="flex items-center gap-3 mb-4 shrink-0">
<div class="flex items-center gap-3 mb-4">
<Button size="sm" variant="ghost" class="gap-1 h-8" @click="selectedId = null">
<ArrowLeft class="size-4" />
返回
@@ -147,11 +175,7 @@ watch(selectedId, () => {
<span class="text-xs text-muted-foreground leading-snug">{{ selectedTool.description }}</span>
</div>
</div>
<ScrollArea class="flex-1 min-h-0 -mr-3 pr-3">
<div class="max-w-3xl px-1 pb-6">
<component :is="selectedTool.component" />
</div>
</ScrollArea>
<component :is="selectedTool.component" />
</template>
</div>
</template>
@@ -58,7 +58,7 @@ const onInput = (v: string | number) => {
:title="copied ? '已复制' : '复制'"
@click="copy"
>
<Check v-if="copied" class="size-3.5 text-green-500" />
<Check v-if="copied" class="size-3.5 text-success" />
<Copy v-else class="size-3.5" />
</Button>
</div>
+1 -1
View File
@@ -23,5 +23,5 @@ export const moduleConfig: ModuleConfig = {
onEnable: async () => {},
onDisable: async () => {}
},
order: 60
order: 100
}
+7 -7
View File
@@ -12,7 +12,6 @@ type Tab = 'convert' | 'file'
const tab = ref<Tab>('convert')
const mode = ref<'encode' | 'decode'>('encode')
const input = ref('')
const error = ref('')
function encode(s: string): string {
const bytes = new TextEncoder().encode(s)
@@ -28,17 +27,18 @@ function decode(s: string): string {
return new TextDecoder().decode(bytes)
}
const output = computed(() => {
error.value = ''
// computed
const result = computed(() => {
const v = input.value
if (!v) return ''
if (!v) return { out: '', err: '' }
try {
return mode.value === 'encode' ? encode(v) : decode(v)
return { out: mode.value === 'encode' ? encode(v) : decode(v), err: '' }
} catch (e) {
error.value = '解码失败:' + String(e)
return ''
return { out: '', err: '解码失败:' + String(e) }
}
})
const output = computed(() => result.value.out)
const error = computed(() => result.value.err)
const swap = () => {
if (output.value) {
+4 -4
View File
@@ -111,11 +111,11 @@ const unifiedText = computed(() => {
<div class="flex flex-col gap-4">
<div class="grid grid-cols-2 gap-3">
<div class="flex flex-col gap-1.5">
<Label class="text-xs text-red-500">旧文本删除 {{ stats.dels }} </Label>
<Label class="text-xs text-danger">旧文本删除 {{ stats.dels }} </Label>
<Textarea v-model="oldText" placeholder="旧文本..." class="min-h-[140px] font-mono text-xs resize-y" />
</div>
<div class="flex flex-col gap-1.5">
<Label class="text-xs text-green-500">新文本新增 {{ stats.adds }} </Label>
<Label class="text-xs text-success">新文本新增 {{ stats.adds }} </Label>
<Textarea v-model="newText" placeholder="新文本..." class="min-h-[140px] font-mono text-xs resize-y" />
</div>
</div>
@@ -130,8 +130,8 @@ const unifiedText = computed(() => {
:key="i"
class="flex whitespace-pre px-2 py-0.5"
:class="{
'bg-red-500/10 text-red-600 dark:text-red-400': l.op === 'del',
'bg-green-500/10 text-green-600 dark:text-green-400': l.op === 'add',
'bg-danger-soft text-danger-text': l.op === 'del',
'bg-success-soft text-success-text': l.op === 'add',
'text-muted-foreground': l.op === 'equal'
}"
>
@@ -118,11 +118,11 @@ const MIMES: MimeDef[] = [
]
const STATUS_CLASS: Record<string, string> = {
'1xx': 'bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/30',
'2xx': 'bg-green-500/10 text-green-600 dark:text-green-400 border-green-500/30',
'3xx': 'bg-blue-500/10 text-blue-600 dark:text-blue-400 border-blue-500/30',
'4xx': 'bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/30',
'5xx': 'bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/30'
'1xx': 'bg-info-soft text-info-text border-info/30',
'2xx': 'bg-success-soft text-success-text border-success/30',
'3xx': 'bg-info-soft text-info-text border-info/30',
'4xx': 'bg-warning-soft text-warning-text border-warning/30',
'5xx': 'bg-danger-soft text-danger-text border-danger/30'
}
const statusGroups = computed(() => {
+1 -1
View File
@@ -128,7 +128,7 @@ const converted = computed(() => {
<template v-if="cidr">
<p v-if="cidr.error" class="text-xs text-destructive">{{ cidr.error }}</p>
<template v-else>
<p v-if="!cidr.ipIsNetwork" class="text-xs text-amber-600 dark:text-amber-400">
<p v-if="!cidr.ipIsNetwork" class="text-xs text-warning-text">
注意输入地址不是该子网的网络地址已按网络 {{ cidr.network }}/{{ cidr.prefix }} 计算
</p>
<div class="rounded-md border border-border divide-y divide-border text-xs">
+1 -1
View File
@@ -88,7 +88,7 @@ function normalizeReplacer(sort: boolean): (this: unknown, key: string, value: u
</div>
<div v-if="isValid !== false" class="flex items-center gap-2 text-xs">
<span v-if="isValid === true" class="text-green-600 dark:text-green-400"> 合法 JSON</span>
<span v-if="isValid === true" class="text-success-text"> 合法 JSON</span>
</div>
<ResultArea :text="output" placeholder="结果" />
<p v-if="error" class="text-xs text-destructive break-all">{{ error }}</p>
+15 -13
View File
@@ -6,7 +6,6 @@ import { Badge } from '@/components/ui/badge'
import ResultArea from '../components/ResultArea.vue'
const token = ref('')
const error = ref('')
function base64UrlDecode(s: string): string {
const cleaned = s.replace(/-/g, '+').replace(/_/g, '/')
@@ -24,30 +23,33 @@ interface TimeClaim {
status: 'ok' | 'expired' | 'not-yet' | 'unknown'
}
const parsed = computed<{
// computed
const parsedResult = computed<{ data: {
header: string
payload: string
signature: string
timeClaims: TimeClaim[]
} | null>(() => {
error.value = ''
} | null; err: string }>(() => {
const t = token.value.trim()
if (!t) return null
if (!t) return { data: null, err: '' }
const parts = t.split('.')
if (parts.length < 2) {
error.value = 'JWT 格式不正确(应为 header.payload.signature'
return null
return { data: null, err: 'JWT 格式不正确(应为 header.payload.signature' }
}
try {
const header = formatJson(base64UrlDecode(parts[0]))
const payload = formatJson(base64UrlDecode(parts[1]))
const signature = parts[2] ?? ''
return { header, payload, signature, timeClaims: parseTimeClaims(payload) }
return {
data: { header, payload, signature, timeClaims: parseTimeClaims(payload) },
err: ''
}
} catch (e) {
error.value = '解码失败:' + String(e)
return null
return { data: null, err: '解码失败:' + String(e) }
}
})
const parsed = computed(() => parsedResult.value.data)
const error = computed(() => parsedResult.value.err)
function formatJson(s: string): string {
let pretty = s
@@ -90,9 +92,9 @@ function parseTimeClaims(payload: string): TimeClaim[] {
const STATUS_BADGE: Record<TimeClaim['status'], { label: string; class: string } | null> = {
ok: null,
expired: { label: '已过期', class: 'bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/30' },
'not-yet': { label: '尚未生效', class: 'bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/30' },
unknown: { label: '签发时间在未来(时钟偏差?)', class: 'bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/30' }
expired: { label: '已过期', class: 'bg-danger-soft text-danger-text border-danger/30' },
'not-yet': { label: '尚未生效', class: 'bg-warning-soft text-warning-text border-warning/30' },
unknown: { label: '签发时间在未来(时钟偏差?)', class: 'bg-warning-soft text-warning-text border-warning/30' }
}
</script>
+4 -4
View File
@@ -73,10 +73,10 @@ const entropy = computed(() => {
const strength = computed(() => {
const e = entropy.value
if (e >= 128) return { label: '极强(128+ bit', class: 'text-green-600 dark:text-green-400' }
if (e >= 80) return { label: '强(80-127 bit', class: 'text-green-600 dark:text-green-400' }
if (e >= 60) return { label: '中等(60-79 bit', class: 'text-amber-600 dark:text-amber-400' }
return { label: '弱(<60 bit', class: 'text-red-600 dark:text-red-400' }
if (e >= 128) return { label: '极强(128+ bit', class: 'text-success-text' }
if (e >= 80) return { label: '强(80-127 bit', class: 'text-success-text' }
if (e >= 60) return { label: '中等(60-79 bit', class: 'text-warning-text' }
return { label: '弱(<60 bit', class: 'text-danger-text' }
})
function generate() {
+11 -6
View File
@@ -17,7 +17,6 @@ const bases: { value: Radix; label: string }[] = [
const from = ref<Radix>('10')
const to = ref<Radix>('16')
const input = ref('')
const error = ref('')
const RADIX_MAP: Record<Radix, number> = { '2': 2, '8': 8, '10': 10, '16': 16 }
@@ -37,23 +36,29 @@ function parseBigInt(s: string, radix: number): bigint {
return negative ? -n : n
}
// computed
const result = computed(() => {
error.value = ''
const v = input.value.trim()
if (!v) return { dec: null, output: '', outputUppercase: '' }
if (!v) return { dec: null, output: '', outputUppercase: '', err: '' }
try {
const parsed = parseBigInt(v, RADIX_MAP[from.value])
const out = parsed.toString(RADIX_MAP[to.value])
return {
dec: parsed,
output: out,
outputUppercase: to.value === '16' ? out.toUpperCase() : out
outputUppercase: to.value === '16' ? out.toUpperCase() : out,
err: ''
}
} catch {
error.value = '输入不合法,请确认数字与当前进制匹配(如十六进制仅含 0-9a-f)'
return { dec: null, output: '', outputUppercase: '' }
return {
dec: null,
output: '',
outputUppercase: '',
err: '输入不合法,请确认数字与当前进制匹配(如十六进制仅含 0-9a-f)'
}
}
})
const error = computed(() => result.value.err)
</script>
<template>
+10 -6
View File
@@ -6,6 +6,7 @@ import { listen, type UnlistenFn } from '@tauri-apps/api/event'
import { Pause, Play, X, Minus, FolderOpen, AlertCircle, Loader2, XCircle } from '@lucide/vue'
import { commands, type DownloadTask, type Segment, type TaskStatus } from '@/lib/bindings'
import { STORAGE_KEYS } from '@/lib/constants'
import { applyStoredAccent } from '@/lib/theme'
import { createLogger } from '@/lib/logger'
const logger = createLogger('download-window')
@@ -68,6 +69,7 @@ const statusText = computed(() => {
case 'complete': return '已完成'
case 'error': return '出错'
case 'cancelled': return '已取消'
default: return ''
}
})
const statusTone = computed(() => {
@@ -175,6 +177,8 @@ async function applyTheme() {
root.classList.remove('dark', 'effect-mica', 'effect-acrylic', 'effect-normal')
root.classList.add(`effect-${effect}`)
if (isDark) root.classList.add('dark')
//
applyStoredAccent(root, isDark ? 'dark' : 'light', STORAGE_KEYS.appSettings)
// 4. mica/acrylic
try {
@@ -297,7 +301,7 @@ onUnmounted(() => {
<div class="dlw-titlebar" data-tauri-drag-region>
<div class="dlw-title-left" data-tauri-drag-region>
<Loader2 v-if="status === 'active'" class="size-3 shrink-0 animate-spin text-primary" />
<XCircle v-else-if="status === 'error'" class="size-3 shrink-0 text-red-500" />
<XCircle v-else-if="status === 'error'" class="size-3 shrink-0 text-danger" />
<Pause v-else class="size-3 shrink-0 text-muted-foreground" />
<span v-if="loading" class="text-xs font-medium text-muted-foreground">下载</span>
<span v-else-if="task" class="dlw-title-name" :title="task.filename"
@@ -363,7 +367,7 @@ onUnmounted(() => {
</div>
</div>
<div v-if="actionError" class="mt-1.5 truncate text-right text-[10px] text-red-500" @click="clearErrorSoon">
<div v-if="actionError" class="mt-1.5 truncate text-right text-[10px] text-danger" @click="clearErrorSoon">
{{ actionError }}
</div>
</template>
@@ -460,8 +464,8 @@ onUnmounted(() => {
white-space: nowrap;
}
.dlw-status-badge.tone-active { background: var(--primary); color: var(--primary-foreground); }
.dlw-status-badge.tone-success { background: #10b981; color: #fff; }
.dlw-status-badge.tone-destructive { background: #ef4444; color: #fff; }
.dlw-status-badge.tone-success { background: var(--success); color: #fff; }
.dlw-status-badge.tone-destructive { background: var(--danger); color: #fff; }
.dlw-status-badge.tone-muted { background: var(--muted); color: var(--muted-foreground); }
.dlw-bar { height: 7px; border-radius: 999px; background: var(--muted); overflow: hidden; }
@@ -487,8 +491,8 @@ onUnmounted(() => {
white-space: nowrap;
}
.dlw-btn:hover { background: var(--accent); color: var(--accent-foreground); }
.dlw-btn-danger { color: #ef4444; }
.dlw-btn-danger:hover { background: rgb(239 68 68 / 0.12); color: #dc2626; }
.dlw-btn-danger { color: var(--danger); }
.dlw-btn-danger:hover { background: rgb(239 68 68 / 0.12); color: var(--danger); }
.dlw-btn-primary { background: var(--primary); color: var(--primary-foreground); border-color: transparent; }
/* hover 时显式保持前景色,避免被上面的 .dlw-btn:hover 覆盖成深色导致黑字黑底不可见 */
.dlw-btn-primary:hover { background: var(--primary); color: var(--primary-foreground); filter: brightness(1.05); }
+34 -96
View File
@@ -24,7 +24,7 @@ import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
import { Tabs, TabsContent } from '@/components/ui/tabs'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Separator } from '@/components/ui/separator'
import { Progress } from '@/components/ui/progress'
@@ -44,11 +44,11 @@ const logger = createLogger('downloader')
// ===== Tab =====
const activeTab = ref('tasks')
const tabsListRef = useModuleTabs('downloader', activeTab, [
useModuleTabs('downloader', activeTab, [
{ value: 'tasks', label: '下载任务' },
{ value: 'settings', label: '设置' },
{ value: 'extension', label: '浏览器扩展' }
])
], { alwaysVisible: true })
//
const addUriText = ref('')
@@ -237,8 +237,6 @@ const SORT_OPTIONS: { label: string; value: SortField }[] = [
{ label: '下载大小', value: 'size' }
]
const running = computed(() => store.status.running)
// ===== =====
/** BT 第 i 个文件已下载字节(progress 事件把每文件进度写入 segments[i].completed */
const btFileDownloaded = (task: DownloadTask, i: number): number =>
@@ -347,7 +345,7 @@ const handleResume = async (id: string) => {
// ===== 3 =====
const cancelArmed = ref<Record<string, boolean>>({})
let cancelArmTimers: Record<string, number> = {}
const cancelArmTimers: Record<string, number> = {}
const handleCancel = (id: string) => {
if (cancelArmed.value[id]) {
//
@@ -813,13 +811,6 @@ onUnmounted(() => {
const allTasks = computed<DownloadTask[]>(() => store.tasks)
// 4 filter
const statusCounts = computed(() => {
const counts: Record<TaskStatus, number> = { queued: 0, active: 0, paused: 0, complete: 0, error: 0, cancelled: 0 }
for (const t of allTasks.value) counts[t.status]++
return counts
})
//
const filteredByStatus = computed<DownloadTask[]>(() => {
if (statusFilter.value === 'all') return allTasks.value
@@ -878,79 +869,11 @@ const toggleSortOrder = () => {
</script>
<template>
<div class="h-full p-6 overflow-hidden flex flex-col">
<Tabs v-model="activeTab" class="h-full flex flex-col">
<div ref="tabsListRef">
<TabsList class="grid w-full grid-cols-3 max-w-md !bg-transparent !p-0 !shadow-none">
<TabsTrigger value="tasks" class="gap-1.5">
<ListChecks class="size-4" />
下载任务
</TabsTrigger>
<TabsTrigger value="settings" class="gap-1.5">
<SettingsIcon class="size-4" />
设置
</TabsTrigger>
<TabsTrigger value="extension" class="gap-1.5">
<Puzzle class="size-4" />
浏览器扩展
</TabsTrigger>
</TabsList>
</div>
<div class="min-h-full p-5 flex flex-col">
<Tabs v-model="activeTab" class="flex flex-col">
<!-- ===== 下载任务 ===== -->
<TabsContent value="tasks" class="flex-1 mt-4 min-h-0 min-w-0 tab-animate">
<TabsContent value="tasks" class="flex-1 min-h-0 min-w-0 tab-animate">
<div class="h-full flex flex-col gap-4 min-w-0">
<!-- 状态栏 -->
<Card class="shrink-0 !py-0 !gap-0">
<CardContent class="pl-4 pr-4 py-3">
<div class="flex items-center justify-between flex-wrap gap-3">
<div class="flex items-center gap-3">
<div class="flex items-center gap-2">
<div
class="h-2.5 w-2.5 rounded-full"
:class="running ? 'bg-green-500 animate-pulse' : 'bg-muted-foreground'"
/>
<span class="text-sm font-medium">
下载引擎 {{ running ? '运行中' : '已停止' }}
</span>
</div>
<Separator orientation="vertical" class="h-5" />
<div v-if="running" class="flex items-center gap-2 text-xs">
<Badge variant="secondary" class="gap-1">
<Download class="size-3" />
下载中 {{ statusCounts.active }}
</Badge>
<Badge variant="secondary" class="gap-1">
<Clock class="size-3" />
等待 {{ statusCounts.queued }}
</Badge>
<Badge v-if="statusCounts.paused > 0" variant="secondary" class="gap-1">
<Pause class="size-3" />
已暂停 {{ statusCounts.paused }}
</Badge>
<Badge variant="secondary" class="gap-1">
<Check class="size-3" />
已完成 {{ statusCounts.complete }}
</Badge>
<Badge v-if="statusCounts.cancelled > 0" variant="secondary" class="gap-1">
<XCircle class="size-3" />
已取消 {{ statusCounts.cancelled }}
</Badge>
</div>
</div>
<div class="flex items-center gap-2">
<Button
size="sm"
variant="ghost"
@click="store.refreshTasks()"
>
<RefreshCw class="size-4" />
</Button>
</div>
</div>
</CardContent>
</Card>
<!-- 工具栏筛选 / 排序 / 搜索 -->
<div class="shrink-0 flex items-center gap-2 flex-wrap">
<!-- 新建下载按钮 -->
@@ -1052,13 +975,28 @@ const toggleSortOrder = () => {
下载设置
</Button>
<!-- 手动刷新任务列表横幅移除后保留事件漏收时可兜底 -->
<Tooltip>
<TooltipTrigger as-child>
<Button
size="sm"
variant="outline"
class="size-8 p-0 bg-transparent"
@click="store.refreshTasks()"
>
<RefreshCw class="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>刷新任务列表</TooltipContent>
</Tooltip>
<span class="text-xs text-muted-foreground ml-auto">
{{ sortedTasks.length }} 个任务
</span>
</div>
<!-- 任务列表 -->
<ScrollArea class="flex-1 min-h-0 min-w-0">
<div class="flex-1 min-h-0 min-w-0">
<div v-if="pagedTasks.length === 0" key="empty" class="h-full flex flex-col items-center justify-center text-muted-foreground gap-2 pt-10">
<CheckCircle2 class="size-16 opacity-30" />
<p>暂无符合条件的任务</p>
@@ -1093,7 +1031,7 @@ const toggleSortOrder = () => {
<span>
{{ formatSize(task.completedSize) }} / {{ formatTotalSize(task.totalSize) }}
</span>
<span v-if="task.status === 'active'" class="flex items-center gap-1 text-green-600 dark:text-green-400">
<span v-if="task.status === 'active'" class="flex items-center gap-1 text-success-text">
<Download class="size-3" />
{{ formatSpeed(task.speed) }}
</span>
@@ -1287,14 +1225,14 @@ const toggleSortOrder = () => {
<TooltipContent>最后一页</TooltipContent>
</Tooltip>
</div>
</ScrollArea>
</div>
</div>
</TabsContent>
<!-- ===== 设置 ===== -->
<TabsContent value="settings" class="flex-1 mt-4 min-h-0 tab-animate">
<ScrollArea class="h-full pr-3">
<div class="flex flex-col gap-4 pb-4 max-w-2xl">
<TabsContent value="settings" class="flex-1 min-h-0 tab-animate">
<div>
<div class="flex flex-col gap-4 pb-4">
<!-- 下载设置 -->
<Card>
<CardHeader>
@@ -1482,13 +1420,13 @@ const toggleSortOrder = () => {
</CardContent>
</Card>
</div>
</ScrollArea>
</div>
</TabsContent>
<!-- ===== 浏览器扩展 ===== -->
<TabsContent value="extension" class="flex-1 mt-4 min-h-0 tab-animate">
<ScrollArea class="h-full pr-3">
<div class="flex flex-col gap-4 pb-4 max-w-2xl">
<TabsContent value="extension" class="flex-1 min-h-0 tab-animate">
<div>
<div class="flex flex-col gap-4 pb-4">
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2 text-base">
@@ -1623,7 +1561,7 @@ const toggleSortOrder = () => {
</CardContent>
</Card>
</div>
</ScrollArea>
</div>
</TabsContent>
</Tabs>
@@ -1879,7 +1817,7 @@ const toggleSortOrder = () => {
<AlertDialogContent class="max-w-md">
<AlertDialogHeader>
<AlertDialogTitle class="flex items-center gap-2">
<AlertCircle class="size-4 text-amber-500" />
<AlertCircle class="size-4 text-warning" />
下载重复提醒
</AlertDialogTitle>
<AlertDialogDescription class="text-xs">
+1 -1
View File
@@ -64,5 +64,5 @@ export const moduleConfig: ModuleConfig = {
// 模块禁用时不停止引擎(引擎在应用退出时统一清理)
}
},
order: 50
order: 30
}
+157 -106
View File
@@ -5,7 +5,7 @@ import {
ShieldCheck, ShieldOff, Zap, Thermometer, Clock, ChevronDown,
ArrowDown, ArrowUp, Wifi, FolderOpen, Copy, ListChecks,
Monitor as MonitorIcon, GripVertical, SlidersHorizontal,
Eye, EyeOff, MousePointerClick, Plus, PencilLine,
Eye, EyeOff, MousePointerClick, Check, PencilLine,
CircuitBoard, BatteryFull, Gamepad2,
} from '@lucide/vue'
import type { LucideIcon } from '@lucide/vue'
@@ -33,7 +33,7 @@ import OverviewCard, { type OverviewCardView } from './OverviewCard.vue'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
import { Tabs, TabsContent } from '@/components/ui/tabs'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from '@/components/ui/accordion'
import { Progress } from '@/components/ui/progress'
@@ -50,12 +50,12 @@ const store = useMonitorStore()
// ===== Tab TitleBar =====
const activeTab = ref('overview')
const tabsListRef = useModuleTabs('monitor', activeTab, [
useModuleTabs('monitor', activeTab, [
{ value: 'overview', label: '概览' },
{ value: 'details', label: '详细' },
{ value: 'osd', label: 'OSD 显示' },
{ value: 'settings', label: '设置' },
])
], { alwaysVisible: true })
// ===== computed =====
@@ -248,8 +248,14 @@ function saveOverviewCards() {
/** 编辑模式:显示拖拽把手/移除按钮,启用拖拽排序 */
const overviewEditing = ref(false)
/** 添加卡片 Popover 开关 */
const overviewAddOpen = ref(false)
/**
* 布局Popover 开关编辑模式 + 添加卡片
*
* 两件事合并进同一个 popover它们都是**对概览卡片集合的操作**此前各占一个
* 文字按钮编辑布局添加卡片把横幅挤成了两行 而横幅的规范是
* 状态区 指标区 操作区一行常驻UI_DESIGN_SYSTEM §3.4
*/
const overviewEditOpen = ref(false)
/** 尚未添加的卡片(添加菜单内容) */
const addableOverviewCards = computed(() =>
@@ -260,7 +266,7 @@ function addOverviewCard(id: OverviewCardId) {
if (overviewCards.value.includes(id)) return
overviewCards.value.push(id)
saveOverviewCards()
overviewAddOpen.value = false
overviewEditOpen.value = false
}
function removeOverviewCard(id: string) {
@@ -330,8 +336,8 @@ function buildOverviewCardView(id: OverviewCardId): OverviewCardView {
case 'network':
return {
id, title: '网络', icon: Wifi,
main: { label: '下载', labelIcon: ArrowDown, text: downSpeed.value.value, unit: downSpeed.value.unit, colorClass: 'text-sky-500' },
subs: [{ label: '上传', labelIcon: ArrowUp, text: upSpeed.value.value, unit: upSpeed.value.unit, colorClass: 'text-violet-500' }],
main: { label: '下载', labelIcon: ArrowDown, text: downSpeed.value.value, unit: downSpeed.value.unit, colorClass: 'text-info' },
subs: [{ label: '上传', labelIcon: ArrowUp, text: upSpeed.value.value, unit: upSpeed.value.unit, colorClass: 'text-data-up' }],
load: null,
}
case 'storage':
@@ -376,12 +382,13 @@ const overviewCardViews = computed<OverviewCardView[]>(() =>
)
// ===== =====
const stateMeta: Record<ConnectionState, { text: string; class: string }> = {
idle: { text: '未启动', class: 'bg-muted text-muted-foreground' },
loading: { text: '启动', class: 'bg-yellow-500/15 text-yellow-600 dark:text-yellow-400' },
connected: { text: '已连接', class: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400' },
disconnected: { text: '已断开', class: 'bg-orange-500/15 text-orange-600 dark:text-orange-400' },
error: { text: '错误', class: 'bg-red-500/15 text-red-600 dark:text-red-400' },
/** 横幅状态区用:状态点颜色 + 状态文字颜色(规范 §3.4 = 状态点 8px + 状态文字 13px/500 */
const stateMeta: Record<ConnectionState, { text: string; dot: string; textClass: string }> = {
idle: { text: '启动', dot: 'bg-muted-foreground', textClass: 'text-muted-foreground' },
loading: { text: '启动中', dot: 'bg-warning', textClass: 'text-warning' },
connected: { text: '已连接', dot: 'bg-success', textClass: 'text-success' },
disconnected: { text: '已断开', dot: 'bg-warning', textClass: 'text-warning' },
error: { text: '错误', dot: 'bg-danger', textClass: 'text-danger' },
}
// ===== =====
@@ -1342,20 +1349,11 @@ watch(() => store.status?.ready, (ready, prev) => {
</script>
<template>
<div class="h-full p-6">
<Tabs v-model="activeTab" class="h-full flex flex-col">
<div ref="tabsListRef">
<TabsList class="grid w-full max-w-md grid-cols-4 !bg-transparent !p-0 !shadow-none">
<TabsTrigger value="overview" class="gap-1.5"><Activity class="size-3.5" />概览</TabsTrigger>
<TabsTrigger value="details" class="gap-1.5"><Gauge class="size-3.5" />详细</TabsTrigger>
<TabsTrigger value="osd" class="gap-1.5"><MonitorIcon class="size-3.5" />OSD</TabsTrigger>
<TabsTrigger value="settings" class="gap-1.5"><SettingsIcon class="size-3.5" />设置</TabsTrigger>
</TabsList>
</div>
<div class="min-h-full p-5">
<Tabs v-model="activeTab" class="flex flex-col">
<!-- ===== 概览 ===== -->
<TabsContent value="overview" class="flex-1 mt-4 tab-animate">
<ScrollArea class="h-full pr-3">
<TabsContent value="overview" class="flex-1 tab-animate">
<div>
<!-- 加载中占位仅首次初始化前显示 -->
<div v-if="!store.initialized" key="initializing" class="h-full flex flex-col items-center justify-center text-muted-foreground gap-2 pt-10">
<Loader2 class="size-8 animate-spin" />
@@ -1363,40 +1361,59 @@ watch(() => store.status?.ready, (ready, prev) => {
</div>
<template v-else>
<!-- ===== Kernel 状态置顶紧凑横条 ===== -->
<Card class="py-0 gap-0 mb-2.5">
<CardContent class="px-3.5 py-2 flex flex-wrap items-center gap-x-3 gap-y-1.5 text-xs">
<span class="flex items-center gap-1.5 font-medium text-sm">
<Activity class="size-4 text-primary" />Kernel
</span>
<span :class="['px-2 py-0.5 rounded-full', stateMeta[store.connState].class]">
{{ stateMeta[store.connState].text }}
</span>
<Badge :variant="store.snapshot?.isAdmin ? 'default' : 'outline'" :class="store.snapshot?.isAdmin ? 'bg-emerald-500 hover:bg-emerald-500' : ''">
{{ store.snapshot?.isAdmin ? '管理员' : '普通' }}
</Badge>
<Badge v-if="store.status?.thingElevated" variant="outline" class="border-emerald-500/50 text-emerald-600 dark:text-emerald-400">
<ShieldCheck class="size-2.5 mr-0.5" />提权
</Badge>
<span class="text-muted-foreground">PID: <span class="font-mono text-foreground">{{ store.status?.pid ?? '--' }}</span></span>
<span class="text-muted-foreground">传感器: <span class="font-mono text-foreground">{{ store.status?.sensorCount ?? '--' }}</span></span>
<span class="text-muted-foreground">重启: <span class="font-mono text-foreground">{{ store.status?.restartCount ?? 0 }}</span></span>
<span class="text-muted-foreground">事件: <span class="font-mono text-foreground">{{ store.eventCount }}</span></span>
<div class="flex items-center gap-1.5 ml-auto">
<!-- ===== Kernel 状态横幅状态区 指标区 操作区UI_DESIGN_SYSTEM §3.4 ===== -->
<Card class="!py-0 !gap-0 mb-2.5">
<CardContent class="flex flex-wrap items-center gap-x-6 gap-y-3 px-4 py-4">
<!-- 状态区状态点 8px + 状态文字 13px/500 + 进程名 13px 次要色 -->
<div class="flex flex-wrap items-center gap-2.5">
<span class="flex items-center gap-1.5 text-[13px] font-medium" :class="stateMeta[store.connState].textClass">
<span
class="size-2 rounded-full"
:class="[stateMeta[store.connState].dot, store.connState === 'connected' && 'status-dot-pulse']"
/>{{ stateMeta[store.connState].text }}
</span>
<span class="text-[13px] text-fg-secondary">ThingHK</span>
<Badge :variant="store.snapshot?.isAdmin ? 'default' : 'outline'" :class="store.snapshot?.isAdmin ? 'bg-success hover:bg-success' : ''">
{{ store.snapshot?.isAdmin ? '管理员' : '普通' }}
</Badge>
<Badge v-if="store.status?.thingElevated" variant="outline" class="border-success/50 text-success-text">
<ShieldCheck class="size-2.5 mr-0.5" />提权
</Badge>
</div>
<!-- 指标区核心属性同样占一个指标位label 11px + 数值 18px/600 tabular-nums -->
<div class="flex flex-wrap items-center gap-6">
<div class="flex min-w-20 flex-col gap-0.5">
<span class="text-[11px] text-muted-foreground">PID</span>
<span class="text-lg font-semibold tabular-nums text-foreground">{{ store.status?.pid ?? '—' }}</span>
</div>
<div class="flex min-w-12 flex-col gap-0.5">
<span class="text-[11px] text-muted-foreground">传感器</span>
<span class="text-lg font-semibold tabular-nums text-foreground">{{ store.status?.sensorCount ?? 0 }}</span>
</div>
<div class="flex min-w-12 flex-col gap-0.5">
<span class="text-[11px] text-muted-foreground">重启</span>
<span class="text-lg font-semibold tabular-nums text-foreground">{{ store.status?.restartCount ?? 0 }}</span>
</div>
<div class="flex min-w-12 flex-col gap-0.5">
<span class="text-[11px] text-muted-foreground">事件</span>
<span class="text-lg font-semibold tabular-nums text-foreground">{{ store.eventCount }}</span>
</div>
</div>
<div class="ml-auto flex flex-wrap items-center gap-1.5">
<!-- 启动中 loadingstarting=true 但状态还没变为 loading 时显示 -->
<Button v-if="store.starting && store.connState === 'idle'" size="xs" variant="outline" disabled>
<Loader2 class="size-3 animate-spin" />启动中
<Button v-if="store.starting && store.connState === 'idle'" size="sm" variant="outline" disabled>
<Loader2 class="size-3.5 animate-spin" />启动中
</Button>
<!-- 启动按钮未运行且非启动中时显示 -->
<Button v-if="store.connState === 'idle' && !store.starting" size="xs" :disabled="store.starting" @click="handleStart">
<Play class="size-3" />启动
<Button v-if="store.connState === 'idle' && !store.starting" size="sm" :disabled="store.starting" @click="handleStart">
<Play class="size-3.5" />启动
</Button>
<!-- 提权按钮标志未启用时显示设置标志 + 以管理员权限重启 Thing -->
<Tooltip v-if="!store.elevateOnLaunch">
<TooltipTrigger as-child>
<Button size="xs" variant="outline" class="gap-1 text-emerald-600 dark:text-emerald-400 border-emerald-500/40 hover:bg-emerald-500/10" :disabled="store.starting" @click="handleElevateSelf">
<Loader2 v-if="store.starting" class="size-3 animate-spin" />
<ShieldCheck v-else class="size-3" />提权
<Button size="sm" variant="outline" class="gap-1 text-success-text border-success/40 hover:bg-success/10" :disabled="store.starting" @click="handleElevateSelf">
<Loader2 v-if="store.starting" class="size-3.5 animate-spin" />
<ShieldCheck v-else class="size-3.5" />提权
</Button>
</TooltipTrigger>
<TooltipContent class="max-w-[480px] break-words">以管理员权限重启 Thing UACThingHK 子进程继承权限后续启动自动提权崩溃自动重启</TooltipContent>
@@ -1404,36 +1421,70 @@ watch(() => store.status?.ready, (ready, prev) => {
<!-- 取消提权按钮标志已启用时显示清除标志下次启动不触发 UAC -->
<Tooltip v-else>
<TooltipTrigger as-child>
<Button size="xs" variant="outline" class="gap-1 text-muted-foreground hover:text-foreground" @click="handleCancelElevation">
<ShieldOff class="size-3" />取消提权
<Button size="sm" variant="outline" class="gap-1 text-muted-foreground hover:text-foreground" @click="handleCancelElevation">
<ShieldOff class="size-3.5" />取消提权
</Button>
</TooltipTrigger>
<TooltipContent class="max-w-[480px] break-words">取消提权下次启动将以普通权限运行不影响当前会话</TooltipContent>
</Tooltip>
<Button size="xs" variant="outline" :disabled="store.starting || store.stopping || store.connState === 'idle'" @click="handleRefresh">
<RefreshCw class="size-3" />刷新
<!--
刷新图标按钮原来是与启动/停止同级的文字按钮把操作区撑宽
近一个按钮的宽度语义交给 Tooltip与其它图标按钮一致
-->
<Tooltip>
<TooltipTrigger as-child>
<Button
variant="outline"
size="icon-sm"
:disabled="store.starting || store.stopping || store.connState === 'idle'"
@click="handleRefresh"
>
<RefreshCw class="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>刷新传感器数据</TooltipContent>
</Tooltip>
<Button size="sm" variant="destructive" :disabled="store.starting || store.stopping || store.connState === 'idle'" @click="handleStop">
<Square class="size-3.5" />停止
</Button>
<Button size="xs" variant="outline" :disabled="store.starting || store.stopping || store.connState === 'idle'" @click="handleStop">
<Square class="size-3" />停止
</Button>
<Separator orientation="vertical" class="h-4 mx-0.5" />
<!-- 编辑布局开关拖拽排序 / 删除卡片 -->
<Button
size="xs"
:variant="overviewEditing ? 'default' : 'outline'"
:title="overviewEditing ? '完成编辑' : '编辑卡片布局(拖拽排序 / 删除)'"
@click="overviewEditing = !overviewEditing"
>
<PencilLine class="size-3" />{{ overviewEditing ? '完成' : '编辑布局' }}
</Button>
<!-- 添加卡片 -->
<Popover v-model:open="overviewAddOpen">
<Separator orientation="vertical" class="h-5 mx-0.5" />
<!--
布局编辑模式拖拽排序 / 移除+ 添加卡片 收进一个图标按钮的
popover两件事都属于卡片集合的操作低频且互斥于运行控制
各占一个文字按钮只会把横幅挤成两行
按钮在编辑态用主题色软底提示正在编辑
-->
<Popover v-model:open="overviewEditOpen">
<PopoverTrigger as-child>
<Button size="xs" variant="outline" :disabled="!addableOverviewCards.length" title="添加硬件卡片">
<Plus class="size-3" />添加卡片
<Button
variant="outline"
size="icon-sm"
title="编辑卡片布局 / 添加卡片"
:class="
overviewEditing
? 'border-primary/40 bg-primary-soft text-primary-soft-text'
: ''
"
>
<PencilLine class="size-3.5" />
</Button>
</PopoverTrigger>
<PopoverContent class="w-64 p-1.5" align="end">
<!-- 编辑布局开关点完即收起 popover编辑态的反馈在卡片本身把手与移除按钮出现 -->
<button
class="w-full flex items-center gap-2.5 rounded-md px-2.5 py-2 text-sm hover:bg-accent transition-colors text-left"
@click="overviewEditing = !overviewEditing; overviewEditOpen = false"
>
<PencilLine class="size-4 text-primary shrink-0" />
<span class="min-w-0 flex-1">
<span class="block">{{ overviewEditing ? '完成编辑' : '编辑卡片布局' }}</span>
<span class="block text-xs text-muted-foreground">拖拽排序移除卡片</span>
</span>
<Check v-if="overviewEditing" class="size-4 text-primary shrink-0" />
</button>
<Separator class="my-1" />
<p class="px-2.5 pb-0.5 pt-1 text-[11px] font-medium text-muted-foreground">添加卡片</p>
<button
v-for="c in addableOverviewCards"
:key="c.id"
@@ -1454,11 +1505,11 @@ watch(() => store.status?.ready, (ready, prev) => {
</Card>
<!-- PawnIO 驱动缺失提示提权但驱动未装 CPU 温度/功耗无法读取 -->
<Card v-if="store.pawnIoMissing" class="py-0 gap-0 mb-2.5 !border-amber-500/40 bg-amber-500/10">
<CardContent class="px-3.5 py-2 flex flex-wrap items-center gap-2 text-xs text-amber-600 dark:text-amber-400">
<Card v-if="store.pawnIoMissing" class="py-0 gap-0 mb-2.5 !border-warning/40 bg-warning-soft">
<CardContent class="px-3.5 py-2 flex flex-wrap items-center gap-2 text-xs text-warning-text">
<AlertTriangle class="size-4 shrink-0" />
<span class="flex-1 min-w-[200px]">检测到 PawnIO 驱动未安装CPU 温度/功耗可能无法读取误卸载过 PawnIO 等情况会触发点击下方按钮自动安装并重启监控内核</span>
<Button size="xs" variant="outline" class="shrink-0 text-amber-600 dark:text-amber-400 border-amber-500/40 hover:bg-amber-500/10" :disabled="store.starting || store.stopping || repairingPawnio" @click="handleRepairPawnio">
<Button size="xs" variant="outline" class="shrink-0 text-warning-text border-warning/40 hover:bg-warning/10" :disabled="store.starting || store.stopping || repairingPawnio" @click="handleRepairPawnio">
<Loader2 v-if="repairingPawnio" class="size-3 mr-1 animate-spin" />
<RefreshCw v-else class="size-3 mr-1" />
{{ repairingPawnio ? '修复中...' : '检测并修复' }}
@@ -1523,32 +1574,32 @@ watch(() => store.status?.ready, (ready, prev) => {
</VueDraggable>
<!-- 断线提示 -->
<Card v-if="store.connState === 'disconnected'" class="mt-2.5 border-orange-500/40 py-0 gap-0">
<Card v-if="store.connState === 'disconnected'" class="mt-2.5 border-warning/40 py-0 gap-0">
<CardContent class="pt-3 flex items-start gap-2 text-sm">
<AlertTriangle class="size-4 text-orange-500 mt-0.5 shrink-0" />
<AlertTriangle class="size-4 text-warning mt-0.5 shrink-0" />
<div>
<div class="font-medium text-orange-600 dark:text-orange-400">SSE 连接断开</div>
<div class="font-medium text-warning-text">SSE 连接断开</div>
<div class="text-xs text-muted-foreground mt-0.5">Kernel 可能正在重启等待自动重连...</div>
</div>
</CardContent>
</Card>
<!-- 错误提示 -->
<Card v-if="store.errorMsg" class="mt-2.5 border-red-500/40 py-0 gap-0">
<Card v-if="store.errorMsg" class="mt-2.5 border-danger/40 py-0 gap-0">
<CardContent class="pt-3 flex items-start gap-2 text-sm">
<AlertTriangle class="size-4 text-red-500 mt-0.5 shrink-0" />
<AlertTriangle class="size-4 text-danger mt-0.5 shrink-0" />
<div>
<div class="font-medium text-red-600 dark:text-red-400">错误</div>
<div class="font-medium text-danger-text">错误</div>
<div class="text-xs font-mono mt-0.5 break-all">{{ store.errorMsg }}</div>
</div>
</CardContent>
</Card>
</template>
</ScrollArea>
</div>
</TabsContent>
<!-- ===== 详细 ===== -->
<TabsContent value="details" class="flex-1 mt-4 tab-animate">
<TabsContent value="details" class="flex-1 tab-animate">
<div v-if="!store.snapshot" key="no-snapshot" class="h-full flex flex-col items-center justify-center text-muted-foreground gap-2 pt-10">
<Gauge class="size-12 opacity-30" />
<p class="text-sm">无快照数据</p>
@@ -1558,7 +1609,7 @@ watch(() => store.status?.ready, (ready, prev) => {
</Button>
</div>
<ScrollArea v-else key="details-list" class="h-full pr-3">
<div v-else key="details-list">
<div class="flex items-center justify-between mb-3">
<p class="text-sm text-muted-foreground">{{ groups.length }} 个分组 / {{ store.status?.sensorCount ?? 0 }} 个传感器</p>
<Button size="xs" variant="outline" @click="store.fetchSnapshot()">
@@ -1582,7 +1633,7 @@ watch(() => store.status?.ready, (ready, prev) => {
<AccordionContent class="px-4 pb-3 pt-0">
<!-- 无传感器数据占位硬件已启用但 LHB 未检测到传感器通常因权限不足或硬件不支持 -->
<div v-if="!g.sensors.length" class="py-3 flex items-center gap-2 text-xs text-muted-foreground">
<AlertTriangle class="size-3.5 shrink-0 text-amber-500" />
<AlertTriangle class="size-3.5 shrink-0 text-warning" />
<span>未检测到传感器数据可能需要管理员权限或硬件不支持</span>
</div>
<div v-for="hwGroup in groupSensors(g.sensors)" :key="hwGroup.hardware" class="py-2 border-t first:border-t-0">
@@ -1611,13 +1662,13 @@ watch(() => store.status?.ready, (ready, prev) => {
</AccordionItem>
</Card>
</Accordion>
</ScrollArea>
</div>
</TabsContent>
<!-- ===== OSD 显示 ===== -->
<TabsContent value="osd" class="flex-1 mt-4 tab-animate">
<ScrollArea class="h-full pr-3">
<div class="max-w-2xl space-y-4 pb-4">
<TabsContent value="osd" class="flex-1 tab-animate">
<div>
<div class="space-y-4 pb-4">
<!-- 悬浮窗开关 -->
<Card>
<CardHeader>
@@ -1665,7 +1716,7 @@ watch(() => store.status?.ready, (ready, prev) => {
/>
</div>
<div v-if="!store.snapshot?.ready" class="flex items-start gap-2 text-xs text-amber-600 dark:text-amber-400 rounded-md border border-amber-500/30 bg-amber-500/5 p-2.5">
<div v-if="!store.snapshot?.ready" class="flex items-start gap-2 text-xs text-warning-text rounded-md border border-warning/30 bg-warning-soft p-2.5">
<AlertTriangle class="size-3.5 mt-0.5 shrink-0" />
<span>Kernel 未就绪无法获取可用传感器列表请先在概览页启动 Kernel</span>
</div>
@@ -2234,13 +2285,13 @@ watch(() => store.status?.ready, (ready, prev) => {
<!-- 预览卡片已移除用户可在桌面悬浮窗直接看实际效果 -->
</div>
</ScrollArea>
</div>
</TabsContent>
<!-- ===== 设置 ===== -->
<TabsContent value="settings" class="flex-1 mt-4 tab-animate">
<ScrollArea class="h-full pr-3">
<div class="max-w-2xl space-y-4 pb-4">
<TabsContent value="settings" class="flex-1 tab-animate">
<div>
<div class="space-y-4 pb-4">
<!-- 控制 -->
<Card>
<CardHeader>
@@ -2296,7 +2347,7 @@ watch(() => store.status?.ready, (ready, prev) => {
</div>
<div class="flex justify-between">
<span class="text-muted-foreground">已安装</span>
<Badge :variant="store.kernelInfo.exists ? 'default' : 'destructive'" :class="store.kernelInfo.exists ? 'bg-emerald-500 hover:bg-emerald-500' : ''">
<Badge :variant="store.kernelInfo.exists ? 'default' : 'destructive'" :class="store.kernelInfo.exists ? 'bg-success hover:bg-success' : ''">
{{ store.kernelInfo.exists ? '是' : '否' }}
</Badge>
</div>
@@ -2347,10 +2398,10 @@ watch(() => store.status?.ready, (ready, prev) => {
<div class="flex items-center justify-between">
<span>当前权限</span>
<div class="flex items-center gap-1.5">
<Badge :variant="store.snapshot?.isAdmin ? 'default' : 'outline'" :class="store.snapshot?.isAdmin ? 'bg-emerald-500 hover:bg-emerald-500' : ''">
<Badge :variant="store.snapshot?.isAdmin ? 'default' : 'outline'" :class="store.snapshot?.isAdmin ? 'bg-success hover:bg-success' : ''">
{{ store.snapshot?.isAdmin ? '管理员' : '普通' }}
</Badge>
<Badge v-if="store.status?.thingElevated" variant="outline" class="border-emerald-500/50 text-emerald-600 dark:text-emerald-400">
<Badge v-if="store.status?.thingElevated" variant="outline" class="border-success/50 text-success-text">
<ShieldCheck class="size-2.5 mr-0.5" />提权
</Badge>
</div>
@@ -2363,13 +2414,13 @@ watch(() => store.status?.ready, (ready, prev) => {
<!-- 提权说明 -->
<div class="space-y-2">
<div class="flex items-start gap-2">
<ShieldCheck class="size-3.5 mt-0.5 shrink-0 text-emerald-500" />
<ShieldCheck class="size-3.5 mt-0.5 shrink-0 text-success" />
<div class="text-xs">
<span class="font-medium text-foreground">提权</span>以管理员权限重启 Thing 自身ThingHK 子进程继承权限标志持久化后续每次启动自动触发 UAC可在概览页点击"取消提权"关闭ThingHK 崩溃由 ProcessManager 自动重启避免数据停止后 Thing 不感知
</div>
</div>
</div>
<p v-if="store.status?.thingElevated" class="text-xs leading-relaxed text-emerald-600 dark:text-emerald-400 flex items-start gap-1.5">
<p v-if="store.status?.thingElevated" class="text-xs leading-relaxed text-success-text flex items-start gap-1.5">
<ShieldCheck class="size-3.5 mt-0.5 shrink-0" />
<span>提权模式Thing ThingHK 均以管理员权限运行ProcessManager 可直接 kill崩溃自动重启可用</span>
</p>
@@ -2396,7 +2447,7 @@ watch(() => store.status?.ready, (ready, prev) => {
</CardContent>
</Card>
</div>
</ScrollArea>
</div>
</TabsContent>
</Tabs>
</div>
@@ -2433,7 +2484,7 @@ watch(() => store.status?.ready, (ready, prev) => {
<label :for="'hw-' + hw.key" class="text-sm cursor-pointer truncate">{{ hw.name }}</label>
<span v-if="hw.key === 'motherboard'" class="text-[10px] text-muted-foreground/60"> SuperIO/EC自动联动</span>
</div>
<Badge v-if="hw.requiresAdmin" variant="outline" class="text-[10px] shrink-0 text-amber-600 border-amber-500/40">
<Badge v-if="hw.requiresAdmin" variant="outline" class="text-[10px] shrink-0 text-warning-text border-warning/40">
需管理员
</Badge>
</div>
@@ -2478,7 +2529,7 @@ watch(() => store.status?.ready, (ready, prev) => {
<DialogContent class="max-w-md">
<DialogHeader>
<DialogTitle class="flex items-center gap-2">
<TriangleAlert class="size-4 text-amber-500" />
<TriangleAlert class="size-4 text-warning" />
需先启动监控内核
</DialogTitle>
<DialogDescription>
+1 -1
View File
@@ -325,7 +325,7 @@ const config = ref<OsdConfig | null>(null)
/** 数据通道:显示项 key→value 映射(由主窗口每秒推送,替代全量快照) */
const dataMap = ref<Record<string, number | null>>({})
const networkSpeed = ref<NetworkSpeed | null>(null)
let unlistenFns: UnlistenFn[] = []
const unlistenFns: UnlistenFn[] = []
// ===== =====
function getOsdItemValue(item: OsdItem): number | null {
+7 -7
View File
@@ -13,18 +13,18 @@ export function fmt(v: number | null, digits = 1): string {
/** 温度颜色:绿(<50) → 黄(<70) → 橙(<85) → 红(>=85) */
export function tempColor(t: number | null): string {
if (t == null) return 'text-muted-foreground'
if (t < 50) return 'text-emerald-500'
if (t < 70) return 'text-yellow-500'
if (t < 85) return 'text-orange-500'
return 'text-red-500'
if (t < 50) return 'text-success'
if (t < 70) return 'text-warning'
if (t < 85) return 'text-warning'
return 'text-danger'
}
/** 负载颜色:蓝(<50) → 紫(<80) → 红(>=80) */
export function loadColor(v: number | null): string {
if (v == null) return 'text-muted-foreground'
if (v < 50) return 'text-sky-500'
if (v < 80) return 'text-violet-500'
return 'text-red-500'
if (v < 50) return 'text-info'
if (v < 80) return 'text-warning'
return 'text-danger'
}
/** 格式化网速(bytes/s → 自适应 KB/s 或 MB/s */
+1 -1
View File
@@ -107,5 +107,5 @@ export const moduleConfig: ModuleConfig = {
}
}
},
order: 40
order: 60
}
+16 -27
View File
@@ -11,7 +11,6 @@ import {
HardDriveDownload,
History,
Info,
Library,
Link2,
ListMusic,
Loader2,
@@ -20,7 +19,6 @@ import {
Plus,
RotateCw,
Search,
Settings as SettingsIcon,
Square,
Trash2,
Upload,
@@ -61,7 +59,7 @@ import { Separator } from '@/components/ui/separator'
import { Progress } from '@/components/ui/progress'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Checkbox } from '@/components/ui/checkbox'
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
import { Tabs, TabsContent } from '@/components/ui/tabs'
import { Empty, EmptyDescription, EmptyMedia, EmptyTitle } from '@/components/ui/empty'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
@@ -75,11 +73,11 @@ const logger = createLogger('music')
// ===== =====
const activeTab = ref('mymusic')
const tabsListRef = useModuleTabs('music', activeTab, [
useModuleTabs('music', activeTab, [
{ value: 'mymusic', label: '曲库' },
{ value: 'discover', label: '发现音乐' },
{ value: 'settings', label: '设置' }
])
], { alwaysVisible: true })
// ===== =====
const downloadTarget = ref<'local' | 'feiniu'>('local')
@@ -540,7 +538,7 @@ const taskStatusInfo = (
case 'cancelling':
return { label: '取消中', variant: 'outline' }
case 'done':
return { label: '完成', variant: 'outline', class: 'bg-emerald-500/15 text-emerald-700 border-emerald-500/30 dark:text-emerald-400' }
return { label: '完成', variant: 'outline', class: 'bg-success-soft text-success-text border-success/30' }
case 'error':
return { label: '出错', variant: 'destructive' }
case 'cancelled':
@@ -788,7 +786,7 @@ const envBadgeText = computed(() => {
})
const envBadgeClass = computed(() =>
!store.envLoading && store.env?.musicdlOutdated
? 'border-amber-500/40 text-amber-700 dark:text-amber-400'
? 'border-warning/40 text-warning-text'
: ''
)
@@ -801,24 +799,15 @@ const RESULT_GRID = { gridTemplateColumns: '24px 36px minmax(0,1fr) 108px 52px 9
</script>
<template>
<div class="h-full p-6 overflow-hidden flex flex-col">
<div class="min-h-full p-5 flex flex-col">
<Tabs v-model="activeTab" class="min-h-0 flex-1 flex flex-col">
<!-- ===== 模块导航与其他模块保持一致的 Tabs 写法 ===== -->
<div ref="tabsListRef" class="shrink-0">
<TabsList class="grid w-full max-w-md grid-cols-3 !bg-transparent !p-0 !shadow-none">
<TabsTrigger value="mymusic" class="gap-1.5"><Library class="size-3.5" />曲库</TabsTrigger>
<TabsTrigger value="discover" class="gap-1.5"><Search class="size-3.5" />发现音乐</TabsTrigger>
<TabsTrigger value="settings" class="gap-1.5"><SettingsIcon class="size-3.5" />设置</TabsTrigger>
</TabsList>
</div>
<!-- ===== 曲库 ===== -->
<TabsContent value="mymusic" class="mt-4 min-h-0 flex-1 tab-animate">
<TabsContent value="mymusic" class="min-h-0 flex-1 tab-animate">
<MyMusicLibrary />
</TabsContent>
<!-- ===== 发现音乐 ===== -->
<TabsContent value="discover" class="mt-4 min-h-0 flex-1 flex flex-col tab-animate">
<TabsContent value="discover" class="min-h-0 flex-1 flex flex-col tab-animate">
<!-- 工具栏搜索主操作留在首行 -->
<header class="flex shrink-0 items-center gap-2 border-b pb-3">
<div ref="searchBoxRef" class="relative min-w-0 flex-1 max-w-md" @focusin="historyOpen = true" @focusout="onHistoryFocusOut">
@@ -1089,7 +1078,7 @@ const RESULT_GRID = { gridTemplateColumns: '24px 36px minmax(0,1fr) 108px 52px 9
</div>
<!-- 结果列表 -->
<ScrollArea class="min-h-0 flex-1">
<div class="min-h-0 flex-1">
<div class="pb-4">
<div v-if="store.searching" class="space-y-1.5 pt-3">
<div v-for="i in 8" :key="i" class="h-11 animate-pulse rounded-md bg-muted/40" />
@@ -1271,13 +1260,13 @@ const RESULT_GRID = { gridTemplateColumns: '24px 36px minmax(0,1fr) 108px 52px 9
</div>
</template>
</div>
</ScrollArea>
</div>
</TabsContent>
<!-- ===== 设置与其他模块一致的 Card 卡片式布局 ===== -->
<TabsContent value="settings" class="mt-4 min-h-0 flex-1 tab-animate">
<ScrollArea class="h-full pr-3">
<div class="flex max-w-2xl flex-col gap-4 pb-4">
<TabsContent value="settings" class="min-h-0 flex-1 tab-animate">
<div>
<div class="flex flex-col gap-4 pb-4">
<!-- 连接 -->
<Card>
<CardHeader>
@@ -1679,7 +1668,7 @@ const RESULT_GRID = { gridTemplateColumns: '24px 36px minmax(0,1fr) 108px 52px 9
"
:class="
!store.envLoading && store.env?.musicdlOutdated
? 'border-amber-500/40 text-amber-700 dark:text-amber-400'
? 'border-warning/40 text-warning-text'
: ''
"
>
@@ -1695,7 +1684,7 @@ const RESULT_GRID = { gridTemplateColumns: '24px 36px minmax(0,1fr) 108px 52px 9
</Badge>
<span
class="text-[12.5px] tabular-nums"
:class="!store.envLoading && store.env?.musicdlOutdated ? 'text-amber-700 dark:text-amber-400' : ''"
:class="!store.envLoading && store.env?.musicdlOutdated ? 'text-warning-text' : ''"
>
<template v-if="store.envLoading">探测中</template>
<template v-else-if="!store.env?.musicdlInstalled"></template>
@@ -1798,7 +1787,7 @@ const RESULT_GRID = { gridTemplateColumns: '24px 36px minmax(0,1fr) 108px 52px 9
</CardContent>
</Card>
</div>
</ScrollArea>
</div>
</TabsContent>
</Tabs>
@@ -177,7 +177,7 @@ onMounted(() => store.refreshConnections())
</div>
</div>
<div class="flex shrink-0 items-center gap-1">
<span v-if="c.loggedIn" class="mr-1 flex items-center gap-1 text-xs text-emerald-600">
<span v-if="c.loggedIn" class="mr-1 flex items-center gap-1 text-xs text-success-text">
<Check class="size-3.5" /> 已登录
</span>
<Button
+2 -2
View File
@@ -43,6 +43,6 @@ export const moduleConfig: ModuleConfig = {
.catch(() => {})
}
},
// 默认排在「代理」之后、整体第二位(proxy=10、clipboard=20
order: 15
// 默认第一位(音乐是常驻后台的播放器,入口最靠前
order: 10
}
+316 -435
View File
@@ -1,9 +1,9 @@
<script setup lang="ts">
import {
Globe, Play, Square, RotateCw, Power, Zap, Plus, RefreshCw, Trash2,
Check, AlertCircle, Server, Settings as SettingsIcon, ListChecks,
Upload, Link2, Loader2, Download, Timer, Target, FolderOpen, Copy, DownloadCloud,
Waypoints, ArrowDown, ArrowUp, Activity, X
Play, Square, RotateCw, Zap, Plus, RefreshCw, Trash2,
Check, AlertCircle, Server, Settings as SettingsIcon,
Upload, Link2, Loader2, Download, Target, FolderOpen, Copy, DownloadCloud,
Waypoints, X
} from '@lucide/vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { toast } from 'vue-sonner'
@@ -21,8 +21,7 @@ import { Badge } from '@/components/ui/badge'
import { Switch } from '@/components/ui/switch'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Tabs, TabsContent } from '@/components/ui/tabs'
import { Separator } from '@/components/ui/separator'
import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from '@/components/ui/accordion'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
@@ -33,6 +32,7 @@ import {
AlertDialogContent, AlertDialogDescription, AlertDialogFooter,
AlertDialogHeader, AlertDialogTitle
} from '@/components/ui/alert-dialog'
import { InfoGroup, KeyValueRow, StatusBadge } from '@/components/common'
const store = useProxyStore()
const logger = createLogger('proxy')
@@ -80,14 +80,19 @@ const onConfirmOpenChange = (open: boolean) => {
}
const activeTab = ref('overview')
// TitleBar
const tabsListRef = useModuleTabs('proxy', activeTab, [
{ value: 'overview', label: '概览' },
{ value: 'connections', label: '连接' },
{ value: 'proxies', label: '节点' },
{ value: 'profiles', label: '订阅' },
{ value: 'settings', label: '设置' }
])
// TitleBar Tab
useModuleTabs(
'proxy',
activeTab,
[
{ value: 'overview', label: '概览' },
{ value: 'connections', label: '连接' },
{ value: 'proxies', label: '节点' },
{ value: 'profiles', label: '订阅' },
{ value: 'settings', label: '设置' }
],
{ alwaysVisible: true }
)
const starting = ref(false)
const stopping = ref(false)
const restarting = ref(false)
@@ -177,11 +182,6 @@ const scopeFilterOptions: { value: 'all' | ConnScope; label: string }[] = [
{ value: 'direct', label: '国内' },
{ value: 'proxy', label: '国外' }
]
/** 顶部下载/上传速率(复用实时流量快照的整体速率) */
const connTotalDownloadSec = computed(() => store.traffic?.downloadSpeed ?? 0)
const connTotalUploadSec = computed(() => store.traffic?.uploadSpeed ?? 0)
/** 命中规则总数 = 活跃连接数(每条连接命中一条规则) */
const ruleHitCount = computed(() => connList.value.length)
/** 按规则聚合当前连接,便于观察哪些规则被频繁命中 */
const ruleHits = computed(() => {
const map = new Map<string, number>()
@@ -455,13 +455,14 @@ const openFolder = async () => {
}
}
// Badge
// Tailwind
// = / = / = / =
const delayBadgeClass = (delay: number | undefined) => {
if (delay === undefined) return 'border-transparent bg-muted text-muted-foreground'
if (delay === 0) return 'border-transparent bg-red-500 text-white'
if (delay < 150) return 'border-transparent bg-emerald-100 text-emerald-700'
if (delay < 400) return 'border-transparent bg-amber-100 text-amber-700'
return 'border-transparent bg-orange-100 text-orange-700'
if (delay === undefined) return 'border-transparent bg-neutral-soft text-neutral-text'
if (delay === 0) return 'border-transparent bg-danger text-white'
if (delay < 150) return 'border-transparent bg-success-soft text-success-text'
if (delay < 400) return 'border-transparent bg-warning-soft text-warning-text'
return 'border-transparent bg-danger-soft text-danger-text'
}
const delayText = (delay: number | undefined) => {
@@ -562,12 +563,24 @@ watch(running, async (val, old) => {
await store.waitForApi()
await store.refreshVersion()
await loadProxiesWithError()
// 3s
// mihomo API connTimer
if (activeTab.value === 'connections') await store.refreshConnections()
}
if (!val && old) {
//
store.connections = null
}
})
// Tab 10s Tab IPC
let lastAutoTestAt = 0
watch(activeTab, async (tab) => {
// 3s
//
if (tab === 'connections' && running.value) {
await store.refreshConnections()
}
if (tab === 'proxies' && running.value) {
if (!Object.keys(store.proxies).length) {
await loadProxiesWithError()
@@ -640,6 +653,9 @@ const onToggleSystemProxy = async (on: boolean) => {
sysProxyLoading.value = true
try {
await store.toggleSystemProxy(on)
// mihomo
//
if (on) await store.refreshConnections()
} catch (e) {
toast.error('操作失败', { description: String(e) })
} finally {
@@ -777,9 +793,9 @@ const installStageText = computed(() => {
const installStageColor = computed(() => {
const stage = store.installProgress?.stage
if (stage === 'done') return 'text-emerald-500'
if (stage === 'error') return 'text-destructive'
if (stage === 'need_stop') return 'text-amber-500'
if (stage === 'done') return 'text-success'
if (stage === 'error') return 'text-danger'
if (stage === 'need_stop') return 'text-warning'
return 'text-primary'
})
@@ -1148,104 +1164,87 @@ watch(
</script>
<template>
<div class="h-full p-6">
<Tabs v-model="activeTab" class="h-full flex flex-col">
<div ref="tabsListRef">
<TabsList class="grid w-full grid-cols-5 max-w-md !bg-transparent !p-0 !shadow-none">
<TabsTrigger value="overview" class="gap-1.5"><Globe class="size-3.5" />概览</TabsTrigger>
<TabsTrigger value="connections" class="gap-1.5"><Waypoints class="size-3.5" />连接</TabsTrigger>
<TabsTrigger value="proxies" class="gap-1.5"><Server class="size-3.5" />节点</TabsTrigger>
<TabsTrigger value="profiles" class="gap-1.5"><ListChecks class="size-3.5" />订阅</TabsTrigger>
<TabsTrigger value="settings" class="gap-1.5"><SettingsIcon class="size-3.5" />设置</TabsTrigger>
</TabsList>
</div>
<div class="min-h-full p-5">
<Tabs v-model="activeTab" class="flex min-h-full w-full flex-col">
<!-- 概览 -->
<TabsContent value="overview" class="flex-1 mt-4 tab-animate">
<ScrollArea class="h-full pr-3">
<div class="columns-1 md:columns-2 gap-4 [&>*]:mb-4 [&>*]:break-inside-avoid">
<!-- 实时流量 -->
<Card>
<CardHeader>
<CardTitle class="flex items-center justify-between text-base">
<span class="flex items-center gap-2"><Activity class="size-4 text-primary" />实时流量</span>
<Badge v-if="running" variant="outline" class="gap-1 text-xs">
<span class="size-1.5 rounded-full bg-emerald-500" />实时更新
</Badge>
</CardTitle>
</CardHeader>
<CardContent class="space-y-4 text-sm">
<div class="grid grid-cols-2 gap-4">
<div>
<div class="flex items-center gap-1 text-muted-foreground text-xs mb-1">
<ArrowDown class="size-3.5 text-emerald-500" />下载
</div>
<p class="text-lg font-semibold tabular-nums">{{ fmtSpeed(store.traffic?.downloadSpeed ?? 0) }}</p>
<p class="text-xs text-muted-foreground tabular-nums">累计 {{ store.traffic ? fmtBytes(store.traffic.downloadTotal) : '--' }}</p>
<TabsContent value="overview" class="tab-animate">
<div class="flex flex-col gap-4">
<!-- 状态横幅状态区 指标区 操作区一行常驻UI_DESIGN_SYSTEM §3.4 -->
<Card class="!py-0 !gap-0">
<CardContent class="flex flex-wrap items-center gap-x-6 gap-y-3 px-4 py-4">
<div class="flex items-center gap-2.5">
<span v-if="!store.initialized" class="flex items-center gap-1.5 text-[13px] text-muted-foreground">
<Loader2 class="size-3.5 animate-spin" />加载中
</span>
<template v-else>
<span v-if="running" class="flex items-center gap-1.5 text-[13px] text-success">
<span class="size-2 rounded-full bg-success status-dot-pulse" />运行中
</span>
<span v-else class="flex items-center gap-1.5 text-[13px] text-muted-foreground">
<span class="size-2 rounded-full bg-muted-foreground" />已停止
</span>
</template>
<span class="text-[13px] text-fg-secondary">mihomo</span>
</div>
<div class="flex min-w-20 flex-col gap-0.5">
<span class="text-[11px] text-muted-foreground">内核</span>
<span
class="text-lg font-semibold tabular-nums"
:class="store.kernel?.exists ? 'text-foreground' : 'text-danger'"
>{{ store.kernel?.exists ? versionShort : '未安装' }}</span>
</div>
<div class="flex flex-wrap items-center gap-6">
<div class="flex min-w-24 flex-col gap-0.5">
<span class="text-[11px] text-muted-foreground">下载</span>
<span class="text-lg font-semibold tabular-nums text-data-down">{{ fmtSpeed(store.traffic?.downloadSpeed ?? 0) }}</span>
</div>
<div>
<div class="flex items-center gap-1 text-muted-foreground text-xs mb-1">
<ArrowUp class="size-3.5 text-rose-500" />上传
</div>
<p class="text-lg font-semibold tabular-nums">{{ fmtSpeed(store.traffic?.uploadSpeed ?? 0) }}</p>
<p class="text-xs text-muted-foreground tabular-nums">累计 {{ store.traffic ? fmtBytes(store.traffic.uploadTotal) : '--' }}</p>
<div class="flex min-w-24 flex-col gap-0.5">
<span class="text-[11px] text-muted-foreground">上传</span>
<span class="text-lg font-semibold tabular-nums text-data-up">{{ fmtSpeed(store.traffic?.uploadSpeed ?? 0) }}</span>
</div>
<div class="flex min-w-20 flex-col gap-0.5">
<span class="text-[11px] text-muted-foreground">累计流量</span>
<span class="text-lg font-semibold tabular-nums text-foreground">{{ store.traffic ? fmtBytes((store.traffic.downloadTotal ?? 0) + (store.traffic.uploadTotal ?? 0)) : '—' }}</span>
</div>
<div class="flex min-w-12 flex-col gap-0.5">
<span class="text-[11px] text-muted-foreground">活跃连接</span>
<span class="text-lg font-semibold tabular-nums text-foreground">{{ store.traffic?.activeConnections ?? '—' }}</span>
</div>
</div>
<Separator />
<div class="flex items-center justify-between">
<span class="flex items-center gap-1.5 text-muted-foreground">
<Waypoints class="size-3.5" />活跃连接
</span>
<span class="font-semibold tabular-nums">{{ store.traffic?.activeConnections ?? '--' }}</span>
<div class="ml-auto flex gap-2">
<Button v-if="!running" size="sm" :disabled="starting" @click="handleStart">
<Loader2 v-if="starting" class="size-3.5 animate-spin" />
<Play v-else class="size-3.5" />启动
</Button>
<template v-else>
<Button size="sm" variant="outline" :disabled="restarting" @click="handleRestart">
<Loader2 v-if="restarting" class="size-3.5 animate-spin" />
<RotateCw v-else class="size-3.5" />重启
</Button>
<Button size="sm" variant="destructive" :disabled="stopping" @click="handleStop">
<Loader2 v-if="stopping" class="size-3.5 animate-spin" />
<Square v-else class="size-3.5" />停止
</Button>
</template>
</div>
</CardContent>
</Card>
<div class="columns-1 gap-4 md:columns-2 [&>*]:mb-4 [&>*:last-child]:mb-0 [&>*]:break-inside-avoid">
<!-- 内核状态 -->
<Card>
<CardHeader>
<CardTitle class="flex items-center justify-between text-base">
<span class="flex items-center gap-2"><Server class="size-4 text-primary" />内核</span>
<Button
v-if="store.kernel?.exists"
size="xs" variant="outline"
:disabled="checkingUpdate"
@click="handleCheckUpdate"
>
<Loader2 v-if="checkingUpdate" class="size-3 animate-spin" />
<Download v-else key="icon-download" class="size-3" />检查更新
</Button>
</CardTitle>
</CardHeader>
<CardContent class="space-y-3 text-sm">
<div class="flex items-center justify-between">
<span class="text-muted-foreground">状态</span>
<template v-if="!store.initialized">
<span class="flex items-center gap-1.5 text-xs text-muted-foreground">
<Loader2 class="size-3 animate-spin" />加载中...
</span>
</template>
<template v-else>
<Badge v-if="store.kernel?.exists" variant="default" class="gap-1 bg-emerald-500 hover:bg-emerald-500">
<Check class="size-3" />已安装
</Badge>
<Badge v-else variant="destructive" class="gap-1">
<AlertCircle class="size-3" />未安装
</Badge>
</template>
</div>
<div v-if="store.kernel?.exists" class="flex items-center justify-between">
<span class="text-muted-foreground">当前版本</span>
<Tooltip>
<TooltipTrigger as-child>
<Badge variant="secondary" class="font-mono text-xs cursor-default">
{{ versionShort }}
</Badge>
</TooltipTrigger>
<TooltipContent>{{ store.kernel?.version ?? '' }}</TooltipContent>
</Tooltip>
</div>
<div v-if="kernelUpdateInfo" class="flex items-center justify-between">
<span class="text-muted-foreground">最新版本</span>
<InfoGroup variant="card" title="内核">
<template #action>
<Button
v-if="store.kernel?.exists"
size="xs" variant="outline"
:disabled="checkingUpdate"
@click="handleCheckUpdate"
>
<Loader2 v-if="checkingUpdate" class="size-3 animate-spin" />
<Download v-else key="icon-download" class="size-3" />检查更新
</Button>
</template>
<KeyValueRow v-if="kernelUpdateInfo" label="最新版本">
<span class="font-mono text-xs flex items-center gap-2">
{{ kernelUpdateInfo.latestVersion }}
<Button
@@ -1257,12 +1256,11 @@ watch(
>
<Download key="icon-download" class="size-3" />更新
</Button>
<Check v-else key="icon-updated" class="size-3 text-emerald-500" />
<Check v-else key="icon-updated" class="size-3 text-success" />
</span>
</div>
<div class="flex items-center justify-between gap-3">
<span class="text-muted-foreground shrink-0">路径</span>
<div class="flex items-center gap-1.5 min-w-0">
</KeyValueRow>
<KeyValueRow label="路径">
<span class="flex min-w-0 items-center gap-1.5">
<Tooltip>
<TooltipTrigger as-child>
<span class="font-mono text-xs text-right truncate cursor-default">{{ pathDisplay || pathShort }}</span>
@@ -1287,8 +1285,11 @@ watch(
<TooltipContent>在文件夹中显示</TooltipContent>
</Tooltip>
</template>
</div>
</div>
</span>
</KeyValueRow>
<KeyValueRow label="PID" :value="store.status.pid ?? '—'" mono />
<KeyValueRow label="API 版本" :value="store.version || '—'" mono />
<KeyValueRow label="重启次数" :value="store.status.restartCount" mono />
<!-- 更新内核区块检查到更新且用户点击"更新"后展开复用首次安装的下载源选择 UI -->
<div
@@ -1448,274 +1449,150 @@ watch(
</Button>
</div>
</div>
</CardContent>
</Card>
<!-- 运行状态 -->
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2 text-base">
<Zap class="size-4 text-primary" />运行状态
</CardTitle>
</CardHeader>
<CardContent class="space-y-3 text-sm">
<div class="flex items-center justify-between">
<span class="text-muted-foreground">mihomo</span>
<template v-if="!store.initialized">
<span class="flex items-center gap-1.5 text-xs text-muted-foreground">
<Loader2 class="size-3 animate-spin" />加载中...
</span>
</template>
<template v-else>
<span v-if="running" class="flex items-center gap-1 text-emerald-500">
<span class="size-2 rounded-full bg-emerald-500" />运行中
</span>
<span v-else class="flex items-center gap-1 text-muted-foreground">
<span class="size-2 rounded-full bg-muted-foreground" />已停止
</span>
</template>
</div>
<div class="flex items-center justify-between">
<span class="text-muted-foreground">PID</span>
<span class="font-mono text-xs">{{ store.status.pid ?? '—' }}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-muted-foreground">API 版本</span>
<span class="font-mono text-xs">{{ store.version || '—' }}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-muted-foreground">重启次数</span>
<span class="font-mono text-xs">{{ store.status.restartCount }}</span>
</div>
<Separator />
<div class="flex gap-2">
<Button v-if="!running" key="btn-start" size="sm" :disabled="starting" @click="handleStart">
<Loader2 v-if="starting" key="starting-loading" class="size-3.5 animate-spin" />
<Play v-else key="starting-icon" class="size-3.5" />启动
</Button>
<template v-else key="btn-stop-group">
<Button size="sm" variant="destructive" :disabled="stopping" @click="handleStop">
<Loader2 v-if="stopping" key="stopping-loading" class="size-3.5 animate-spin" />
<Square v-else key="stopping-icon" class="size-3.5" />停止
</Button>
<Button size="sm" variant="outline" :disabled="restarting" @click="handleRestart">
<Loader2 v-if="restarting" key="restarting-loading" class="size-3.5 animate-spin" />
<RotateCw v-else key="restarting-icon" class="size-3.5" />重启
</Button>
</template>
</div>
</CardContent>
</Card>
<!-- 当前订阅 & 模式 -->
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2 text-base">
<ListChecks class="size-4 text-primary" />订阅与模式
</CardTitle>
</CardHeader>
<CardContent class="space-y-3 text-sm">
<div class="flex items-center justify-between gap-3">
<span class="text-muted-foreground shrink-0">当前订阅</span>
<span class="text-right truncate">{{ currentProfile?.name ?? '无' }}</span>
</div>
<Separator />
<div class="flex items-center justify-between">
<span class="text-muted-foreground">运行模式</span>
<div class="flex gap-1">
<Button
v-for="m in modeOptions" :key="m.value"
size="xs"
:variant="store.settings?.mode === m.value ? 'default' : 'outline'"
:disabled="!running && store.settings?.mode !== m.value"
@click="changeMode(m.value)"
>{{ m.label }}</Button>
</div>
</div>
</CardContent>
</Card>
</InfoGroup>
<!-- 当前节点 -->
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2 text-base">
<Target class="size-4 text-primary" />当前节点
</CardTitle>
</CardHeader>
<CardContent class="space-y-3 text-sm">
<template v-if="mainGroupName" key="has-main">
<div class="flex items-center justify-between">
<span class="text-muted-foreground">代理组</span>
<span class="font-medium">{{ mainGroupName }}</span>
</div>
<div class="flex items-center justify-between gap-3">
<span class="text-muted-foreground shrink-0">当前节点</span>
<span class="text-right truncate">{{ mainGroupNow || '—' }}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-muted-foreground">延迟</span>
<Badge variant="outline" :class="delayBadgeClass(currentNodeDelay)">{{ delayText(currentNodeDelay) }}</Badge>
</div>
<Separator />
<div class="space-y-2">
<Label class="text-xs text-muted-foreground">快捷切换</Label>
<Select :model-value="mainGroupNow" @update:model-value="(v) => quickSwitchNode(String(v))">
<SelectTrigger class="w-full">
<SelectValue :placeholder="mainGroupNow || '选择节点'" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="node in mainGroupNodes" :key="node" :value="node">
{{ node }}
</SelectItem>
</SelectContent>
</Select>
</div>
</template>
<div v-else key="no-main" class="text-center text-muted-foreground py-4 text-xs">
{{ running ? '暂无可选节点,请先导入订阅' : 'mihomo 未运行' }}
<InfoGroup variant="card" title="当前节点">
<KeyValueRow label="代理组" :value="mainGroupName || '—'" />
<KeyValueRow label="当前节点" :value="mainGroupNow || '—'" />
<KeyValueRow label="延迟">
<Badge v-if="mainGroupName" variant="outline" :class="delayBadgeClass(currentNodeDelay)">{{ delayText(currentNodeDelay) }}</Badge>
<span v-else class="text-[13px] text-muted-foreground"></span>
</KeyValueRow>
<template #footer>
<div class="space-y-2">
<Label class="text-xs text-muted-foreground">快捷切换</Label>
<Select :disabled="!running || !mainGroupName" :model-value="mainGroupNow" @update:model-value="(v) => quickSwitchNode(String(v))">
<SelectTrigger class="w-full">
<SelectValue :placeholder="mainGroupNow || '选择节点'" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="node in mainGroupNodes" :key="node" :value="node">
{{ node }}
</SelectItem>
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
</template>
</InfoGroup>
<!-- 自动切换 -->
<Card :class="{ 'opacity-60': !running && autoSwitchEnabled }">
<CardHeader>
<CardTitle class="flex items-center justify-between text-base">
<span class="flex items-center gap-2">
<Timer class="size-4 text-primary" />自动切换节点
<!-- 订阅与代理 -->
<InfoGroup variant="card" title="订阅与代理">
<KeyValueRow label="当前订阅" :value="currentProfile?.name ?? '—'" />
<KeyValueRow label="运行模式">
<div class="flex gap-1">
<Button
v-for="m in modeOptions" :key="m.value"
size="xs"
:variant="store.settings?.mode === m.value ? 'default' : 'outline'"
:disabled="!running && store.settings?.mode !== m.value"
@click="changeMode(m.value)"
>{{ m.label }}</Button>
</div>
</KeyValueRow>
<KeyValueRow label="系统代理">
<div class="flex items-center gap-2">
<span class="text-[13px] text-muted-foreground">
{{ store.systemProxy ? `指向 127.0.0.1:${store.settings?.mixedPort ?? 7890}` : '已关闭' }}
</span>
<Switch
:model-value="autoSwitchEnabled"
:disabled="!running"
@update:model-value="onToggleAutoSwitch"
:model-value="store.systemProxy"
:disabled="sysProxyLoading || !running"
@update:model-value="onToggleSystemProxy"
/>
</CardTitle>
</CardHeader>
<CardContent class="space-y-3 text-sm" :class="{ 'pointer-events-none': !autoSwitchEnabled }">
<div class="grid grid-cols-3 gap-3">
<div class="grid gap-1.5">
<Label class="text-xs text-muted-foreground">目标代理组</Label>
<Select :model-value="autoSwitchTargetGroup || '__default__'" @update:model-value="(v) => { autoSwitchTargetGroup = v === '__default__' ? '' : String(v); saveAutoSwitchSettings() }">
<SelectTrigger class="w-full">
<SelectValue placeholder="主代理组" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__default__">主代理组</SelectItem>
<SelectItem v-for="[gname] in selectorGroups" :key="gname" :value="gname">{{ gname }}</SelectItem>
</SelectContent>
</Select>
</div>
<div class="grid gap-1.5">
<Label class="text-xs text-muted-foreground">测试间隔</Label>
<Select :model-value="autoSwitchInterval" @update:model-value="(v) => onAutoSwitchIntervalChange(v)">
<SelectTrigger class="w-full">
<SelectValue placeholder="选择间隔" />
</SelectTrigger>
<SelectContent>
<SelectItem :value="1"> 1 分钟</SelectItem>
<SelectItem :value="5"> 5 分钟</SelectItem>
<SelectItem :value="10"> 10 分钟</SelectItem>
<SelectItem :value="30"> 30 分钟</SelectItem>
</SelectContent>
</Select>
</div>
<div class="grid gap-1.5">
<Label class="text-xs text-muted-foreground">地区筛选</Label>
<Select :model-value="autoSwitchRegion || '__all__'" @update:model-value="(v) => { autoSwitchRegion = v === '__all__' ? '' : String(v); saveAutoSwitchSettings() }">
<SelectTrigger class="w-full">
<SelectValue placeholder="全部地区" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">全部地区</SelectItem>
<SelectItem v-for="r in regionOptions" :key="r" :value="r">{{ r }}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<p class="text-xs text-muted-foreground leading-relaxed">
</KeyValueRow>
</InfoGroup>
<!-- 自动切换 -->
<InfoGroup variant="card" title="自动切换节点" :class="{ 'opacity-60': !running && autoSwitchEnabled }">
<template #action>
<Switch
:model-value="autoSwitchEnabled"
:disabled="!running"
@update:model-value="onToggleAutoSwitch"
/>
</template>
<div class="space-y-3" :class="{ 'pointer-events-none': !autoSwitchEnabled }">
<div class="grid gap-1.5">
<Label class="text-xs text-muted-foreground">目标代理组</Label>
<Select :model-value="autoSwitchTargetGroup || '__default__'" @update:model-value="(v) => { autoSwitchTargetGroup = v === '__default__' ? '' : String(v); saveAutoSwitchSettings() }">
<SelectTrigger size="sm" class="w-full">
<SelectValue placeholder="主代理组" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__default__">主代理组</SelectItem>
<SelectItem v-for="[gname] in selectorGroups" :key="gname" :value="gname">{{ gname }}</SelectItem>
</SelectContent>
</Select>
</div>
<div class="grid gap-1.5">
<Label class="text-xs text-muted-foreground">测试间隔</Label>
<Select :model-value="autoSwitchInterval" @update:model-value="(v) => onAutoSwitchIntervalChange(v)">
<SelectTrigger size="sm" class="w-full">
<SelectValue placeholder="选择间隔" />
</SelectTrigger>
<SelectContent>
<SelectItem :value="1"> 1 分钟</SelectItem>
<SelectItem :value="5"> 5 分钟</SelectItem>
<SelectItem :value="10"> 10 分钟</SelectItem>
<SelectItem :value="30"> 30 分钟</SelectItem>
</SelectContent>
</Select>
</div>
<div class="grid gap-1.5">
<Label class="text-xs text-muted-foreground">地区筛选</Label>
<Select :model-value="autoSwitchRegion || '__all__'" @update:model-value="(v) => { autoSwitchRegion = v === '__all__' ? '' : String(v); saveAutoSwitchSettings() }">
<SelectTrigger size="sm" class="w-full">
<SelectValue placeholder="全部地区" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">全部地区</SelectItem>
<SelectItem v-for="r in regionOptions" :key="r" :value="r">{{ r }}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<template #footer>
<p class="text-xs leading-relaxed text-muted-foreground">
定时测试{{ autoSwitchTargetGroup || mainGroupName || '主代理组' }}{{ autoSwitchRegion ? `中「${autoSwitchRegion}」地区` : '全部' }}有效节点的延迟
自动切换至最优节点当前 {{ filteredNodes.length }} 个节点在候选范围内
</p>
</CardContent>
</Card>
</template>
</InfoGroup>
<!-- 系统代理 -->
<Card :class="{ 'opacity-60': sysProxyLoading }">
<CardHeader>
<CardTitle class="flex items-center gap-2 text-base">
<Power class="size-4 text-primary" />系统代理
</CardTitle>
</CardHeader>
<CardContent class="flex items-center justify-between">
<div class="space-y-1">
<p class="text-sm">Windows 系统代理</p>
<p class="text-xs text-muted-foreground">
{{ store.systemProxy ? `指向 127.0.0.1:${store.settings?.mixedPort ?? 7890}` : '已关闭' }}
</p>
</div>
<Switch
:model-value="store.systemProxy"
:disabled="sysProxyLoading || !running"
@update:model-value="onToggleSystemProxy"
/>
</CardContent>
</Card>
</div>
</div>
</ScrollArea>
</TabsContent>
<!-- 连接 -->
<TabsContent value="connections" class="flex-1 mt-4 tab-animate">
<div v-if="!running" key="conn-not-running" class="h-full flex flex-col items-center justify-center text-muted-foreground gap-2 pt-10 pr-10">
<Waypoints class="size-12 opacity-30" />
<p class="text-sm">mihomo 未运行请先在概览页启动</p>
</div>
<div v-else class="h-full flex flex-col gap-4 pr-3">
<!-- 顶部统计 -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-2">
<Card>
<CardContent class="py-3">
<div class="flex items-center gap-1.5 text-muted-foreground text-xs mb-1"><Waypoints class="size-3.5" />活跃连接</div>
<p class="text-xl font-semibold tabular-nums">{{ connList.length }}</p>
</CardContent>
</Card>
<Card>
<CardContent class="py-3">
<div class="flex items-center gap-1.5 text-muted-foreground text-xs mb-1"><ArrowDown class="size-3.5 text-emerald-500" />下载速率</div>
<p class="text-xl font-semibold tabular-nums">{{ fmtSpeed(connTotalDownloadSec) }}</p>
</CardContent>
</Card>
<Card>
<CardContent class="py-3">
<div class="flex items-center gap-1.5 text-muted-foreground text-xs mb-1"><ArrowUp class="size-3.5 text-rose-500" />上传速率</div>
<p class="text-xl font-semibold tabular-nums">{{ fmtSpeed(connTotalUploadSec) }}</p>
</CardContent>
</Card>
<Card>
<CardContent class="py-3">
<div class="flex items-center gap-1.5 text-muted-foreground text-xs mb-1"><Target class="size-3.5" />命中规则</div>
<p class="text-xl font-semibold tabular-nums">{{ ruleHitCount }}</p>
</CardContent>
</Card>
</div>
<!-- 规则命中分布 -->
<Card>
<CardHeader class="pb-2">
<CardTitle class="text-sm flex items-center gap-2"><Target class="size-3.5 text-primary" />规则命中</CardTitle>
</CardHeader>
<CardContent class="pt-0">
<div v-if="!ruleHits.length" class="text-xs text-muted-foreground py-2">暂无连接</div>
<div v-else class="space-y-1.5">
<div v-for="r in ruleHits.slice(0, 6)" :key="r.rule" class="flex items-baseline gap-2 text-xs">
<span class="w-1.5 h-1.5 rounded-full bg-primary shrink-0 self-center" />
<span class="font-medium shrink-0">{{ translateRule(r.rule) }}</span>
<span class="flex-1 min-w-0 truncate text-muted-foreground">
<template v-if="ruleDesc(r.rule)">{{ ruleDesc(r.rule) }}</template>
</span>
<span class="shrink-0 tabular-nums">×{{ r.count }}</span>
</div>
</div>
<TabsContent value="connections" class="tab-animate">
<div class="flex min-h-full flex-col gap-4">
<!-- 指标行与概览横幅同构活跃连接后紧跟规则命中 chips随内容向右延伸 -->
<Card class="!py-0 !gap-0">
<CardContent class="flex flex-wrap items-center gap-x-6 gap-y-3 px-4 py-4">
<div class="flex min-w-12 flex-col gap-0.5">
<span class="text-[11px] text-muted-foreground">活跃连接</span>
<span class="text-lg font-semibold tabular-nums" :class="running ? 'text-foreground' : 'text-muted-foreground'">{{ connList.length }}</span>
</div>
<!-- 规则命中内联摘要chips紧挨活跃连接命中项变化时向右延伸超出后块内换行 -->
<div class="flex min-w-0 flex-1 flex-wrap items-center gap-x-3 gap-y-1.5 text-xs">
<span class="flex items-center gap-1.5 font-medium text-fg-secondary">
<Target class="size-3.5" />规则命中
</span>
<template v-if="ruleHits.length">
<span
v-for="r in ruleHits.slice(0, 6)"
:key="r.rule"
class="flex items-center gap-1.5 rounded-sm bg-neutral-soft px-2 py-0.5 text-neutral-text"
:title="ruleDesc(r.rule)"
>
{{ translateRule(r.rule) }}
<span class="tabular-nums opacity-70">×{{ r.count }}</span>
</span>
</template>
<span v-else class="text-muted-foreground">暂无连接</span>
</div>
</CardContent>
</Card>
@@ -1725,7 +1602,7 @@ watch(
<div class="flex items-center justify-between gap-3">
<CardTitle class="text-sm flex items-center gap-2"><Waypoints class="size-3.5 text-primary" />当前连接</CardTitle>
<div class="flex items-center gap-2">
<Input v-model="connFilter" placeholder="按进程/域名/规则过滤" class="h-8 w-56" />
<Input v-model="connFilter" placeholder="按进程/域名/规则过滤" class="h-8 w-56" :disabled="!running" />
<Button size="xs" variant="outline" :disabled="!connList.length" @click="closeAllConnections">
<Square class="size-3" />断开全部
</Button>
@@ -1743,7 +1620,7 @@ watch(
</div>
</CardHeader>
<CardContent class="flex-1 min-h-0 overflow-hidden pt-0">
<ScrollArea class="h-full">
<div>
<table class="w-full text-xs">
<thead class="sticky top-0 z-10 bg-card text-muted-foreground">
<tr class="border-b">
@@ -1756,21 +1633,18 @@ watch(
</tr>
</thead>
<tbody>
<tr v-for="c in filteredConnections" :key="c.id" class="border-b last:border-0 hover:bg-muted/40">
<tr v-for="c in filteredConnections" :key="c.id" class="border-b last:border-0 hover:bg-hover">
<td class="py-2 px-2 align-baseline">
<span class="font-medium truncate block max-w-[140px]">{{ connProcess(c) }}</span>
<span class="text-muted-foreground">{{ connSource(c) }}</span>
</td>
<td class="py-2 px-2 align-baseline">
<div class="flex items-center gap-1.5">
<Badge
<StatusBadge
v-if="connScopeOf(c) === 'direct'"
variant="outline" class="h-4 px-1.5 text-[10px] shrink-0 border-emerald-500 text-emerald-500"
>国内</Badge>
<Badge
v-else
variant="outline" class="h-4 px-1.5 text-[10px] shrink-0 border-sky-500 text-sky-500"
>国外</Badge>
variant="success" size="sm"
>国内</StatusBadge>
<StatusBadge v-else variant="info" size="sm">国外</StatusBadge>
<span class="truncate block max-w-[140px]">{{ connHost(c) }}</span>
</div>
<span class="text-muted-foreground">{{ c.metadata?.network }} / {{ c.metadata?.type }}</span>
@@ -1781,29 +1655,37 @@ watch(
<td class="py-2 px-2 text-right tabular-nums align-baseline">{{ fmtBytes(c.download) }}</td>
<td class="py-2 px-2 text-right tabular-nums align-baseline">{{ fmtBytes(c.upload) }}</td>
<td class="py-2 px-2 text-center align-baseline">
<Button size="icon" variant="ghost" class="size-6" title="断开连接" @click="store.closeConnection(c.id)">
<Button size="icon-sm" variant="ghost" title="断开连接" @click="store.closeConnection(c.id)">
<X class="size-3.5" />
</Button>
</td>
</tr>
<!--
空态分三种别让用户把还没查过当成没有连接
未运行 / 查询中store.connections 仍为 null/ 确实为空
-->
<tr v-if="!connList.length">
<td colspan="6" class="text-center text-muted-foreground py-8">暂无活跃连接</td>
<td colspan="6" class="text-center text-muted-foreground py-8">
{{
!running
? 'mihomo 未运行 — 启动后这里会显示经过代理的连接'
: store.connections === null
? '正在读取连接…'
: '暂无活跃连接'
}}
</td>
</tr>
</tbody>
</table>
</ScrollArea>
</div>
</CardContent>
</Card>
</div>
</TabsContent>
<!-- 节点 -->
<TabsContent value="proxies" class="flex-1 mt-4 tab-animate">
<div v-if="!running" key="not-running" class="h-full flex flex-col items-center justify-center text-muted-foreground gap-2 pt-10 pr-10">
<Server class="size-12 opacity-30" />
<p class="text-sm">mihomo 未运行请先在概览页启动</p>
</div>
<ScrollArea v-else key="proxy-list" class="h-full pr-3">
<TabsContent value="proxies" class="tab-animate">
<div>
<div class="flex items-center justify-between mb-3">
<p v-if="!groups.length" class="text-sm text-muted-foreground">暂无代理组</p>
<p v-else class="text-sm text-muted-foreground">{{ groups.length }} 个代理组</p>
@@ -1828,7 +1710,7 @@ watch(
<AccordionTrigger class="px-4 py-2.5 hover:no-underline">
<div class="flex items-center justify-between w-full pr-2">
<span class="flex items-center gap-2">
<Server class="size-4 text-primary" />
<Server class="size-4 text-fg-secondary" />
<span class="font-medium">{{ gname }}</span>
<span class="text-xs font-normal text-muted-foreground">{{ group.type }}</span>
<span v-if="group.now" class="text-xs text-primary truncate max-w-32">{{ group.now }}</span>
@@ -1851,8 +1733,8 @@ watch(
<button
v-for="node in group.all" :key="node"
type="button"
class="flex items-center justify-between gap-2 rounded-md border px-2.5 py-1.5 text-xs transition-colors hover:bg-accent disabled:opacity-50 disabled:cursor-not-allowed"
:class="group.now === node ? 'border-primary bg-primary/10' : 'border-border'"
class="flex items-center justify-between gap-2 rounded-md border px-2.5 py-1.5 text-xs transition-colors hover:bg-hover disabled:opacity-50 disabled:cursor-not-allowed"
:class="group.now === node ? 'border-primary bg-primary-soft' : 'border-border'"
:disabled="group.type !== 'Selector'"
@click="selectNode(gname, node)"
>
@@ -1866,13 +1748,13 @@ watch(
</AccordionItem>
</Card>
</Accordion>
</ScrollArea>
</div>
</TabsContent>
<!-- 订阅 -->
<TabsContent value="profiles" class="flex-1 mt-4 tab-animate">
<ScrollArea class="h-full pr-3">
<div class="space-y-4 max-w-3xl">
<TabsContent value="profiles" class="flex-1 min-h-0 tab-animate">
<div>
<div class="space-y-4">
<Card>
<CardHeader class="pb-3">
<CardTitle class="flex items-center gap-2 text-base">
@@ -1907,7 +1789,7 @@ watch(
<div
v-for="p in store.settings.profiles" :key="p.id"
class="flex items-center gap-3 rounded-md border p-3"
:class="store.settings.currentProfile === p.id ? 'border-primary bg-primary/5' : 'border-border'"
:class="store.settings.currentProfile === p.id ? 'border-primary bg-primary-soft' : 'border-border'"
>
<div class="flex-1 min-w-0 space-y-1">
<div class="flex items-center gap-2">
@@ -1953,13 +1835,13 @@ watch(
</CardContent>
</Card>
</div>
</ScrollArea>
</div>
</TabsContent>
<!-- 设置 -->
<TabsContent value="settings" class="flex-1 mt-4 tab-animate">
<ScrollArea class="h-full pr-3">
<Card class="max-w-2xl">
<TabsContent value="settings" class="tab-animate">
<div>
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2 text-base">
<SettingsIcon class="size-4 text-primary" />基础设置
@@ -1967,11 +1849,10 @@ watch(
</CardHeader>
<CardContent class="space-y-4">
<!-- 说明文字保存按钮已移至顶部标签栏 -->
<p class="text-xs text-muted-foreground rounded-md border border-primary/30 bg-primary/5 p-3">
<p class="rounded-md border border-primary/30 bg-primary-soft p-3 text-xs text-primary-soft-text">
修改端口/接口/密钥/模式后需重启 mihomo 生效DNS规则等高级配置请直接编辑订阅文件
</p>
<Separator />
<div class="grid grid-cols-2 gap-4">
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="grid gap-2">
<Label for="mixed-port">混合代理端口</Label>
<Input id="mixed-port" v-model.number="localSettings.mixedPort" type="number" />
@@ -1985,29 +1866,7 @@ watch(
<Label for="secret">API 密钥留空则不鉴权</Label>
<Input id="secret" v-model="localSettings.secret" placeholder="可选" />
</div>
<div class="flex items-center justify-between rounded-md border p-3">
<div>
<p class="text-sm">允许局域网连接</p>
<p class="text-xs text-muted-foreground">允许其他设备通过本机代理上网</p>
</div>
<Switch v-model="localSettings.allowLan" />
</div>
<div class="flex items-center justify-between rounded-md border p-3">
<div>
<p class="text-sm">应用启动时自动启动 mihomo</p>
<p class="text-xs text-muted-foreground">软件启动时自动运行内核</p>
</div>
<Switch v-model="localSettings.autoStart" />
</div>
<div class="flex items-center justify-between rounded-md border p-3">
<div>
<p class="text-sm">启动时自动开启系统代理</p>
<p class="text-xs text-muted-foreground">mihomo 启动后自动设置 Windows 系统代理退出时自动关闭</p>
</div>
<Switch v-model="localSettings.autoSystemProxy" />
</div>
<Separator />
<div class="grid grid-cols-2 gap-4">
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="grid gap-2">
<Label for="mode-select">运行模式</Label>
<Select v-model="localSettings.mode">
@@ -2037,9 +1896,31 @@ watch(
</Select>
</div>
</div>
<Separator />
<div class="flex items-center justify-between gap-4">
<div>
<p class="text-sm">允许局域网连接</p>
<p class="text-xs text-muted-foreground">允许其他设备通过本机代理上网</p>
</div>
<Switch v-model="localSettings.allowLan" />
</div>
<div class="flex items-center justify-between gap-4">
<div>
<p class="text-sm">应用启动时自动启动 mihomo</p>
<p class="text-xs text-muted-foreground">软件启动时自动运行内核</p>
</div>
<Switch v-model="localSettings.autoStart" />
</div>
<div class="flex items-center justify-between gap-4">
<div>
<p class="text-sm">启动时自动开启系统代理</p>
<p class="text-xs text-muted-foreground">mihomo 启动后自动设置 Windows 系统代理退出时自动关闭</p>
</div>
<Switch v-model="localSettings.autoSystemProxy" />
</div>
</CardContent>
</Card>
</ScrollArea>
</div>
</TabsContent>
</Tabs>
+1 -1
View File
@@ -157,5 +157,5 @@ export const moduleConfig: ModuleConfig = {
}
}
},
order: 10
order: 20
}
+18 -14
View File
@@ -10,6 +10,7 @@ import { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from '@/comp
import { Switch } from '@/components/ui/switch'
import { aggregateSearch, loadAppIconsForResults, recordHistoryItem, getAllHistoryItems, setFileIndexReady, type QPItem, type QPSubAction } from './providers'
import { EVENTS, STORAGE_KEYS } from '@/lib/constants'
import { applyStoredAccent } from '@/lib/theme'
import HistoryPicker from './HistoryPicker.vue'
// ===== =====
@@ -23,7 +24,7 @@ const subActionExpanded = ref<number | null>(null)
const subActionIndex = ref(0)
// /
const pendingDelete = ref<{ path: string; isDir: boolean; name: string } | null>(null)
let unlistenFns: UnlistenFn[] = []
const unlistenFns: UnlistenFn[] = []
let searchTimer: ReturnType<typeof setTimeout> | null = null
// ===== =====
@@ -851,18 +852,18 @@ const groupIcon = (group: string) => {
/** 类型 badge 颜色映射(柔和色块风格,跟随亮/暗主题) */
const GROUP_COLORS: Record<string, string> = {
设置: 'bg-indigo-500/15 text-indigo-600 dark:text-indigo-400',
计算: 'bg-amber-500/15 text-amber-600 dark:text-amber-400',
网页: 'bg-rose-500/15 text-rose-600 dark:text-rose-400',
系统: 'bg-slate-500/15 text-slate-600 dark:text-slate-400',
自定义: 'bg-orange-500/15 text-orange-600 dark:text-orange-400',
历史: 'bg-violet-500/15 text-violet-600 dark:text-violet-400',
快捷: 'bg-sky-500/15 text-sky-600 dark:text-sky-400',
换算: 'bg-teal-500/15 text-teal-600 dark:text-teal-400',
目录操作: 'bg-cyan-500/15 text-cyan-600 dark:text-cyan-400',
应用: 'bg-blue-500/15 text-blue-600 dark:text-blue-400',
文件: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400',
剪贴板: 'bg-pink-500/15 text-pink-600 dark:text-pink-400',
设置: 'bg-info-soft text-info-text',
计算: 'bg-warning-soft text-warning-text',
网页: 'bg-danger-soft text-danger-text',
系统: 'bg-neutral-soft text-neutral-text',
自定义: 'bg-warning-soft text-warning-text',
历史: 'bg-info-soft text-info-text',
快捷: 'bg-info-soft text-info-text',
换算: 'bg-success-soft text-success-text',
目录操作: 'bg-info-soft text-info-text',
应用: 'bg-info-soft text-info-text',
文件: 'bg-success-soft text-success-text',
剪贴板: 'bg-success-soft text-success-text',
}
function groupBadgeClass(group: string): string {
@@ -915,6 +916,9 @@ async function applyTheme() {
root.classList.add(`effect-${effect}`)
if (isDark) root.classList.add('dark')
// .dark
applyStoredAccent(root, isDark ? 'dark' : 'light', STORAGE_KEYS.appSettings)
try {
const tauriWin = getCurrentWindow()
await tauriWin.clearEffects()
@@ -1356,7 +1360,7 @@ onUnmounted(() => {
:checked="deleteSelected.has(f.path)"
@change="toggleDeleteSelect(f.path)"
/>
<FolderOpen v-if="f.isDir" class="size-4 text-amber-500 shrink-0" />
<FolderOpen v-if="f.isDir" class="size-4 text-warning shrink-0" />
<FileText v-else class="size-4 text-muted-foreground shrink-0" />
<div class="flex-1 min-w-0">
<p class="text-xs truncate">{{ f.name }}</p>
+6 -6
View File
@@ -293,8 +293,8 @@ async function changeEngine(v: string) {
</script>
<template>
<div class="h-full p-6 overflow-y-auto">
<div class="max-w-2xl space-y-4">
<div class="min-h-full p-5">
<div class="space-y-4">
<!-- 标题与测试 -->
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold flex items-center gap-2">
@@ -311,7 +311,7 @@ async function changeEngine(v: string) {
<Card>
<CardHeader>
<CardTitle class="text-sm flex items-center gap-2">
<Keyboard class="size-4" />
<Keyboard class="size-4 text-primary" />
唤起快捷键
</CardTitle>
</CardHeader>
@@ -372,7 +372,7 @@ async function changeEngine(v: string) {
<Card>
<CardHeader>
<CardTitle class="text-sm flex items-center gap-2">
<Globe class="size-4" />
<Globe class="size-4 text-primary" />
默认搜索引擎
</CardTitle>
</CardHeader>
@@ -398,7 +398,7 @@ async function changeEngine(v: string) {
<Card>
<CardHeader>
<CardTitle class="text-sm flex items-center gap-2">
<FolderTree class="size-4" />
<FolderTree class="size-4 text-primary" />
文件索引
</CardTitle>
</CardHeader>
@@ -457,7 +457,7 @@ async function changeEngine(v: string) {
<Card>
<CardHeader>
<CardTitle class="text-sm flex items-center gap-2">
<Terminal class="size-4" />
<Terminal class="size-4 text-primary" />
自定义命令
</CardTitle>
</CardHeader>
+1 -1
View File
@@ -66,5 +66,5 @@ export const moduleConfig: ModuleConfig = {
}
}
},
order: 60
order: 90
}
+13 -4
View File
@@ -5,6 +5,7 @@ import { LogicalSize } from '@tauri-apps/api/dpi'
import { emit, listen, type UnlistenFn } from '@tauri-apps/api/event'
import { invoke } from '@tauri-apps/api/core'
import { EVENTS, STORAGE_KEYS } from '@/lib/constants'
import { applyStoredAccent } from '@/lib/theme'
// Rust tauri-specta bindings.ts
import { commands } from '@/lib/bindings'
import { save } from '@tauri-apps/plugin-dialog'
@@ -931,6 +932,8 @@ function applyTheme() {
else if (theme === 'light') dark = false
else dark = window.matchMedia('(prefers-color-scheme: dark)').matches
root.classList.toggle('dark', dark)
// .dark
applyStoredAccent(root, dark ? 'dark' : 'light', STORAGE_KEYS.appSettings)
}
function onStorageChange(e: StorageEvent) {
@@ -1349,10 +1352,16 @@ onUnmounted(() => {
<p>加载中...</p>
</div>
</div>
<!-- 画布区ScrollArea主题化滚动条纵向 + 横向w-fit mx-auto 窄图水平居中 -->
<ScrollAreaRoot v-else class="flex-1 min-h-0 bg-background">
<ScrollAreaViewport class="size-full outline-none">
<div class="p-4 w-fit mx-auto">
<!--
画布区ScrollArea主题化滚动条纵向 + 横向w-fit mx-auto 窄图水平居中
内边距必须是内层的这里是包住画布的 div属于滚动内容且底部要留够
画布的 shadow-2xl 向下投影约 38px原来 p-416px不够 滚到底时
画布下方那条阴影会被视口裁掉左右方向 16px 已够水平投影约 13px
-->
<ScrollAreaRoot v-else class="flex-1 min-h-0 bg-background">
<ScrollAreaViewport class="size-full outline-none">
<div class="px-4 pt-4 pb-12 w-fit mx-auto">
<div class="canvas-wrap relative inline-block shadow-2xl">
<canvas
ref="canvasRef"
+92 -29
View File
@@ -8,25 +8,58 @@ import { toast } from 'vue-sonner'
import { open } from '@tauri-apps/plugin-dialog'
import { useScreenshotStore, type RecentCapture } from '@/stores/screenshotStore'
import { useModuleTabs } from '@/lib/use-module-tabs'
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Tabs, TabsContent } from '@/components/ui/tabs'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Switch } from '@/components/ui/switch'
import { Label } from '@/components/ui/label'
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
import {
Pagination, PaginationContent, PaginationItem, PaginationEllipsis,
} from '@/components/ui/pagination'
const store = useScreenshotStore()
const activeTab = ref('settings')
const tabsListRef = useModuleTabs('screenshot', activeTab, [
{ value: 'settings', label: '设置' },
/**
* 历史排在第一并作为默认页截图模块的日常用法是截完看一眼复制/另存
* 进来先看到最近截图比先看到设置更贴近使用节奏设置改一次就很少再动
* Tab 顺序即使用频率顺序 与其它模块主用途在前设置在后的排法一致
*/
const activeTab = ref('history')
useModuleTabs('screenshot', activeTab, [
{ value: 'history', label: '历史' },
])
{ value: 'settings', label: '设置' },
], { alwaysVisible: true })
const HISTORY_LIMITS = [24, 48, 96]
const DELAY_OPTIONS = [0, 1, 2, 3, 5]
/**
* 历史分页一页 9 3×3
*
* 历史条数上限是 96HISTORY_LIMITS若把 96 张缩略图一次性塞进 DOM
* 解码与内存占用会让打开历史页和滚动都变卡分页把同时存在的 `<img>`
* 限制在 9 翻页只重挂这一页的元素
* 页码是纯前端切片`store.recent` 本就全在内存里所以用不着走后端分页
* 与剪贴板那边后端分页 + `historyTotal`的取数方式不同但分页 UI 同一个组件
*/
const PAGE_SIZE = 9
const currentPage = ref(1)
const totalPages = computed(() => Math.max(1, Math.ceil(store.recent.length / PAGE_SIZE)))
const pagedRecent = computed(() => {
const start = (currentPage.value - 1) * PAGE_SIZE
return store.recent.slice(start, start + PAGE_SIZE)
})
/** 列表变化(新增截图 / 删除 / 清空)后把页码收进有效范围,避免停在空页上 */
watch(totalPages, tp => {
if (currentPage.value > tp) currentPage.value = tp
})
function gotoPage(p: number) {
currentPage.value = Math.min(Math.max(1, p), totalPages.value)
}
function formatTime(t: number): string {
const d = new Date(t)
const pad = (n: number) => String(n).padStart(2, '0')
@@ -208,22 +241,12 @@ onUnmounted(() => {
</script>
<template>
<div class="h-full p-6 overflow-hidden flex flex-col">
<div class="min-h-full p-5 flex flex-col">
<Tabs v-model="activeTab" class="flex-1 min-h-0 flex flex-col">
<div ref="tabsListRef" class="shrink-0">
<TabsList class="grid w-full max-w-md grid-cols-2 !bg-transparent !p-0 !shadow-none">
<TabsTrigger value="settings" class="gap-1.5"><Settings class="size-3.5" />设置</TabsTrigger>
<TabsTrigger value="history" class="gap-1.5">
<ImageIcon class="size-3.5" />历史
<span v-if="store.recent.length" class="text-xs text-muted-foreground">({{ store.recent.length }})</span>
</TabsTrigger>
</TabsList>
</div>
<!-- 截图设置 -->
<TabsContent value="settings" class="flex-1 min-h-0 mt-4">
<ScrollArea class="h-full">
<div class="space-y-4 max-w-3xl">
<TabsContent value="settings" class="flex-1 min-h-0">
<div class="h-full">
<div class="space-y-4">
<!-- 快捷键 -->
<Card>
<CardHeader>
@@ -423,12 +446,12 @@ onUnmounted(() => {
</CardContent>
</Card>
</div>
</ScrollArea>
</div>
</TabsContent>
<!-- 历史记录 -->
<TabsContent value="history" class="flex-1 min-h-0 mt-4">
<ScrollArea class="h-full">
<TabsContent value="history" class="flex-1 min-h-0">
<div class="h-full">
<div>
<!-- 空状态 -->
<div
@@ -440,15 +463,54 @@ onUnmounted(() => {
<p class="text-xs"> Ctrl + 1 截图并导出后会显示在这里</p>
</div>
<!-- 历史网格 -->
<div v-else class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
<template v-else>
<!--
分页条只在多于一页时出现放在网格上方翻页后不用滚回去
-->
<div v-if="totalPages > 1" class="flex items-center gap-3 pb-3">
<Pagination
v-slot="{ page }"
:page="currentPage"
:total="store.recent.length"
:items-per-page="PAGE_SIZE"
:sibling-count="1"
show-edges
@update:page="gotoPage"
class="justify-start"
>
<PaginationContent v-slot="{ items }" class="gap-1">
<template v-for="(item, index) in items" :key="index">
<PaginationItem
v-if="item.type === 'page'"
:value="item.value"
:is-active="item.value === page"
size="icon"
class="size-7 text-xs"
>
{{ item.value }}
</PaginationItem>
<PaginationEllipsis v-else class="size-7" />
</template>
</PaginationContent>
</Pagination>
<span class="ml-auto shrink-0 text-xs text-muted-foreground">
{{ currentPage }} / {{ totalPages }} · {{ store.recent.length }}
</span>
</div>
<!-- 历史网格一页 9 3 列排3×3 -->
<div class="grid grid-cols-2 sm:grid-cols-3 gap-3">
<div
v-for="item in store.recent"
v-for="item in pagedRecent"
:key="item.id"
class="group relative rounded-lg border bg-muted/30 overflow-hidden"
>
<!-- 缩略图 -->
<div class="aspect-video flex items-center justify-center bg-zinc-900">
<!--
缩略图底座用语义色 bg-muted随深浅主题切换而不是写死的
深色 浅色主题下那块黑底非常突兀且带透明通道的截图在黑底上
看不出边界
-->
<div class="aspect-video flex items-center justify-center bg-muted">
<img
:src="thumbSrc(item)"
class="max-w-full max-h-full object-contain"
@@ -518,8 +580,9 @@ onUnmounted(() => {
</div>
</div>
</div>
</template>
</div>
</ScrollArea>
</div>
</TabsContent>
</Tabs>
</div>
+16 -12
View File
@@ -4,6 +4,7 @@ import { invoke } from '@tauri-apps/api/core'
import { getCurrentWindow } from '@tauri-apps/api/window'
import { emit, listen, type UnlistenFn } from '@tauri-apps/api/event'
import { EVENTS, STORAGE_KEYS, WINDOWS } from '@/lib/constants'
import { applyStoredAccent } from '@/lib/theme'
// Rust tauri-specta bindings.ts
import { commands } from '@/lib/bindings'
import {
@@ -309,6 +310,7 @@ const hint = computed(() => {
case 'drawing': return '松开鼠标完成选区'
case 'selected': return '拖动移动选区 · 拖动手柄调整大小 · 选择工具开始标注 · Enter 完成 · Esc 取消'
case 'editing': return '在选区内绘制标注 · 文字工具点击已有文字可编辑 · Ctrl+Enter 完成 · Esc 退出标注'
default: return ''
}
})
@@ -600,8 +602,8 @@ function normRect(a: Point, b: Point): Sel {
function clampSel() {
const s = sel.value
const min = 8
let w = Math.min(Math.max(min, s.w), winW.value)
let h = Math.min(Math.max(min, s.h), winH.value)
const w = Math.min(Math.max(min, s.w), winW.value)
const h = Math.min(Math.max(min, s.h), winH.value)
const x = Math.min(Math.max(0, s.x), winW.value - w)
const y = Math.min(Math.max(0, s.y), winH.value - h)
sel.value = { x, y, w, h }
@@ -1858,6 +1860,8 @@ function applyTheme() {
else if (theme === 'light') isDark = false
else isDark = window.matchMedia('(prefers-color-scheme: dark)').matches
root.classList.toggle('dark', isDark)
// .dark
applyStoredAccent(root, isDark ? 'dark' : 'light', STORAGE_KEYS.appSettings)
//
return window.matchMedia('(prefers-color-scheme: dark)')
}
@@ -2465,7 +2469,7 @@ onUnmounted(() => {
padding: 8px 14px;
background: rgba(15, 23, 42, 0.92);
border: 1px solid rgba(239, 68, 68, 0.6);
color: #fecaca;
color: var(--danger);
font-size: 13px;
line-height: 1.4;
border-radius: 6px;
@@ -2487,7 +2491,7 @@ onUnmounted(() => {
/* 窗口识别:框内全透明透出原画面,box-shadow 暗化框外(形成"框内透明、框外变暗" */
.win-highlight {
position: absolute;
border: 2px solid #3b82f6;
border: 2px solid var(--primary);
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.45);
pointer-events: none;
z-index: 5;
@@ -2498,7 +2502,7 @@ onUnmounted(() => {
left: -2px;
max-width: 240px;
padding: 1px 6px;
background: #3b82f6;
background: var(--primary);
color: #fff;
font-size: 12px;
border-radius: 3px;
@@ -2510,7 +2514,7 @@ onUnmounted(() => {
/* 拖拽中的选区:透明内部 + 巨大 box-shadow 形成外部遮罩 */
.selection {
position: absolute;
border: 2px solid #3b82f6;
border: 2px solid var(--primary);
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.45);
pointer-events: none;
z-index: 5;
@@ -2520,7 +2524,7 @@ onUnmounted(() => {
top: -24px;
left: 0;
padding: 2px 6px;
background: #3b82f6;
background: var(--primary);
color: #fff;
font-size: 12px;
border-radius: 3px;
@@ -2548,7 +2552,7 @@ onUnmounted(() => {
.region-border {
position: absolute;
inset: 0;
border: 2px solid #3b82f6;
border: 2px solid var(--primary);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.6);
pointer-events: none;
}
@@ -2559,7 +2563,7 @@ onUnmounted(() => {
width: 9px;
height: 9px;
background: #fff;
border: 2px solid #3b82f6;
border: 2px solid var(--primary);
border-radius: 2px;
transform: translate(-50%, -50%);
z-index: 8;
@@ -2742,14 +2746,14 @@ onUnmounted(() => {
color: #fff;
white-space: nowrap;
}
.sm-mode.auto { background: #2563eb; }
.sm-mode.manual { background: #059669; }
.sm-mode.auto { background: var(--primary); }
.sm-mode.manual { background: var(--success); }
/* 拼接进行中的呼吸点 */
.sm-dot {
width: 7px;
height: 7px;
border-radius: 999px;
background: #22c55e;
background: var(--success);
animation: sm-pulse 1.1s ease-in-out infinite;
flex-shrink: 0;
}
+3
View File
@@ -9,6 +9,7 @@ import { LogicalPosition, LogicalSize } from '@tauri-apps/api/dpi'
import { emit, listen, type UnlistenFn } from '@tauri-apps/api/event'
import { ChevronLeft, ChevronRight, X, Loader2 } from '@lucide/vue'
import { EVENTS, STORAGE_KEYS } from '@/lib/constants'
import { applyStoredAccent } from '@/lib/theme'
// Rust tauri-specta bindings.ts
import { commands } from '@/lib/bindings'
@@ -220,6 +221,8 @@ function applyTheme() {
isDark = theme === 'dark'
}
root.classList.toggle('dark', isDark)
// .dark
applyStoredAccent(root, isDark ? 'dark' : 'light', STORAGE_KEYS.appSettings)
}
/** 从 localStorage 读取主窗口持久化的截图历史(图片文件在应用数据目录) */
+1 -1
View File
@@ -20,7 +20,7 @@ const height = ref(0)
const status = ref('')
/** 是否已收到最终事件(完成/取消),此后关闭窗口不再触发取消 */
let ended = false
let unlisten: UnlistenFn[] = []
const unlisten: UnlistenFn[] = []
onMounted(async () => {
status.value = auto.value ? '自动向下拼接中…' : '请滚动目标窗口,完成后点「完成」'
+1 -1
View File
@@ -55,5 +55,5 @@ export const moduleConfig: ModuleConfig = {
defaultEnabled: true,
loader: () => import('./ScreenshotModule.vue'),
searchItems,
order: 30
order: 70
}
+158 -6
View File
@@ -1,9 +1,10 @@
<script setup lang="ts">
import { Monitor, Sun, Moon, Sparkles, Layers, LogOut, Package, GripVertical, Info, RefreshCw, Download, Check, Loader2, X } from '@lucide/vue'
import { Monitor, Sun, Moon, Sparkles, Layers, LogOut, Package, GripVertical, Info, RefreshCw, Download, Check, Loader2, X, Palette, RotateCcw } from '@lucide/vue'
import { Switch } from '@/components/ui/switch'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Label } from '@/components/ui/label'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Progress } from '@/components/ui/progress'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useAppStore, type Theme, type EffectType, type ModuleInfo } from '@/stores/appStore'
@@ -20,6 +21,14 @@ import { VueDraggable } from 'vue-draggable-plus'
import { toast } from 'vue-sonner'
import { storeToRefs } from 'pinia'
import { useUpdaterStore } from '@/stores/updaterStore'
import {
ACCENT_PRESETS,
CUSTOM_ACCENT_ID,
DEFAULT_ACCENT_ID,
contrastRatio,
deriveAccent,
parseHex
} from '@/lib/theme'
const appStore = useAppStore()
const searchStore = useSearchStore()
@@ -383,6 +392,8 @@ onMounted(() => {
searchStore.registerAction('settings', 9, () => scrollToCard('settings-card-about'))
searchStore.registerAction('settings', 10, () => scrollToCard('settings-card-about'))
searchStore.registerAction('settings', 11, () => scrollToCard('settings-card-quit'))
// searchItems index 12 011
searchStore.registerAction('settings', 12, () => scrollToCard('settings-card-accent'))
// badge
processStore.refreshAll().catch(() => { /* 忽略:后端可能未就绪 */ })
})
@@ -402,6 +413,65 @@ const effects: Array<{ id: EffectType; name: string; color: string; darkColor: s
// ""广
const isAcrylicDisabled = () => appStore.theme !== 'system'
// ===== =====
/**
* 色板显示色用**派生后的浅色模式主色**而不是原始基色
* 用户看到的就是实际会生效的颜色如青柠会被自动压暗避免"选的色和界面不一致"
*/
const accentSwatches = ACCENT_PRESETS.map(preset => ({
...preset,
swatch: deriveAccent(preset.hex, 'light')['--primary']
}))
/** 自定义色块的显示色(同样走派生) */
const customSwatch = computed(() => {
const hex = appStore.accentHex
return hex && parseHex(hex) ? deriveAccent(hex, 'light')['--primary'] : null
})
/** 色块上的对勾颜色:浅色底上用深墨,否则白 */
const checkColor = (hex: string) => (contrastRatio(hex, '#ffffff') >= 3 ? '#ffffff' : '#1d1d1f')
/**
* 自定义色输入框文本框走草稿 + 回车/失焦提交逐字符落盘会把 `#00` 这类半成品
* 写进设置原生取色器则连续 `input` 事件直接生效改一个颜色写 7 个变量开销极低
*/
const customHexDraft = ref('')
watch(
() => [appStore.accentId, appStore.accentHex] as const,
([id, hex]) => {
customHexDraft.value =
id === CUSTOM_ACCENT_ID && hex
? hex
: (ACCENT_PRESETS.find(p => p.id === id)?.hex ?? '')
},
{ immediate: true }
)
function applyCustomHex(hex: string) {
appStore.setAccent(CUSTOM_ACCENT_ID, hex)
}
function commitCustomHex() {
const hex = customHexDraft.value.trim()
if (!parseHex(hex)) {
// 退
customHexDraft.value = appStore.accentHex ?? ''
toast.error('请输入 #RRGGBB 格式的颜色值')
return
}
applyCustomHex(hex.startsWith('#') ? hex : `#${hex}`)
}
/** 原生取色器:连续拖动时逐次生效,实时预览 */
function onCustomPick(e: Event) {
const value = (e.target as HTMLInputElement).value
customHexDraft.value = value
applyCustomHex(value)
}
//
const isAppDark = computed(() => {
if (appStore.theme === 'dark') return true
@@ -502,8 +572,8 @@ const onDragEnd = () => {
</script>
<template>
<div class="h-full p-6 overflow-y-auto">
<div class="max-w-3xl mx-auto space-y-6">
<div class="min-h-full p-6">
<div class="space-y-6">
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2">
@@ -561,7 +631,7 @@ const onDragEnd = () => {
<component
:is="theme.icon"
class="size-8 transition-colors duration-300"
:class="theme.id === 'dark' || (theme.id === 'system' && systemDark) ? 'text-white' : 'text-gray-800'"
:class="theme.id === 'dark' || (theme.id === 'system' && systemDark) ? 'text-white' : 'text-foreground'"
/>
</div>
<div class="h-10 flex items-center justify-center">
@@ -580,6 +650,88 @@ const onDragEnd = () => {
</CardContent>
</Card>
<Card id="settings-card-accent">
<CardHeader>
<CardTitle class="flex items-center gap-2">
<Palette class="size-5 text-primary" />
主题色
</CardTitle>
</CardHeader>
<CardContent class="space-y-4">
<!-- 预设色板 + 自定义取色 -->
<div class="flex flex-wrap items-center gap-2.5">
<button
v-for="preset in accentSwatches"
:key="preset.id"
class="relative size-8 rounded-full border-2 transition-all duration-200"
:class="
appStore.accentId === preset.id
? 'border-primary scale-110 shadow-md'
: 'border-border hover:border-primary/50'
"
:style="{ backgroundColor: preset.swatch }"
:title="preset.name"
@click="appStore.setAccent(preset.id)"
>
<Check
v-if="appStore.accentId === preset.id"
class="absolute inset-0 m-auto size-4"
:style="{ color: checkColor(preset.swatch) }"
/>
</button>
<!-- 自定义原生取色器覆盖在色块上点击即弹系统取色面板 -->
<label
class="relative size-8 rounded-full border-2 border-dashed transition-all duration-200 cursor-pointer"
:class="
appStore.accentId === CUSTOM_ACCENT_ID
? 'border-primary scale-110 shadow-md'
: 'border-border hover:border-primary/50'
"
:style="customSwatch ? { backgroundColor: customSwatch } : undefined"
title="自定义颜色"
>
<input
type="color"
class="absolute inset-0 size-full cursor-pointer opacity-0"
:value="appStore.accentHex ?? '#0066cc'"
@input="onCustomPick"
/>
<Palette
class="absolute inset-0 m-auto size-4"
:style="{ color: customSwatch ? checkColor(customSwatch) : 'var(--muted-foreground)' }"
/>
</label>
</div>
<div class="flex flex-wrap items-center gap-3">
<Label class="text-xs text-muted-foreground">自定义</Label>
<Input
v-model="customHexDraft"
class="h-8 w-28 font-mono text-xs"
placeholder="#0066cc"
spellcheck="false"
@keydown.enter="commitCustomHex"
@blur="commitCustomHex"
/>
<Button
size="sm"
variant="outline"
:disabled="appStore.accentId === DEFAULT_ACCENT_ID"
@click="appStore.setAccent(DEFAULT_ACCENT_ID)"
>
<RotateCcw class="size-3.5" />
恢复默认
</Button>
</div>
<p class="text-xs text-muted-foreground">
深浅模式会自动调整同一个颜色的亮度浅色下压暗深色下提亮
保证按钮文字与选中态在任何色相下都清晰可读状态色成功 / 警告 / 危险不随主题色变化
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2">
@@ -673,7 +825,7 @@ const onDragEnd = () => {
<span
v-if="getProcessStatusText(module.id)"
class="text-xs px-1.5 py-0.5 rounded-full"
:class="module.enabled ? 'bg-green-500/10 text-green-600 dark:text-green-400' : 'bg-muted text-muted-foreground'"
:class="module.enabled ? 'bg-success-soft text-success-text' : 'bg-muted text-muted-foreground'"
>
{{ getProcessStatusText(module.id) }}
</span>
@@ -753,7 +905,7 @@ const onDragEnd = () => {
</div>
</div>
<div v-else class="flex items-center gap-1.5 text-sm text-muted-foreground">
<Check class="size-4 text-green-500" />
<Check class="size-4 text-success" />
已是最新版本
</div>
</div>
+6
View File
@@ -62,6 +62,12 @@ const searchItems: SearchIndexItem[] = [
title: '退出程序',
description: '彻底退出 Thing 应用',
keywords: ['退出', '关闭', 'quit', 'exit', '结束']
},
// 追加在末尾:searchItems 的下标与 GeneralSettings 里 registerAction 的 index 一一对应
{
title: '主题色',
description: '自定义应用强调色(预设色板 / 自定义颜色)',
keywords: ['主题色', '强调色', '配色', '颜色', 'accent', 'color', 'theme']
}
]
+288
View File
@@ -0,0 +1,288 @@
<script setup lang="ts">
/**
* 终端新建独立窗口的选择窗口
*
* # 为什么是先开窗再问
*
* 旧流程是快捷键 主窗口弹选择框 选完再开独立窗口结果是用户按了快捷键
* 之后焦点先被拉到主窗口选完窗口再跳一次现在按快捷键**立刻开一个窗口**
* 选择就发生在这个窗口里 全程不打扰主窗口它可能在别的显示器上最小化着
*
* # 选完之后为什么本窗口会关闭
*
* 会话的独立窗口 label 必须是 `terminal-window-<sessionId>`Rust `label_for`
* 而这个窗口是先于会话创建的label 只能是临时值 窗口 label Tauri 里不可改
* 因此选完目标后交给 Rust **规范 label** 开真正的会话窗口`terminal_detach_session`
* 会顺带把会话标记为已分离本窗口随即关闭两个窗口都是居中同尺寸
* 视觉上是原地换成会话窗口
*
* # 与主窗口的关系
*
* 本窗口是独立入口main.ts hash 路由自带 Pinia会话与设置的真实状态在
* Rust 因此这里 `useTerminalStore().init()` 拿到的列表与主窗口一致
*/
import { computed, onMounted, onBeforeUnmount, ref } from 'vue'
import { getCurrentWindow } from '@tauri-apps/api/window'
import { Globe, Loader2, Minus, Square, TerminalSquare, X } from '@lucide/vue'
import { toast } from 'vue-sonner'
import { useTerminalStore } from '@/stores/terminalStore'
import { createLogger } from '@/lib/logger'
import { applyStoredAccent } from '@/lib/theme'
import { appIsDark } from '@/lib/terminalThemes'
import { STORAGE_KEYS } from '@/lib/constants'
import { Button } from '@/components/ui/button'
import { Toaster } from '@/components/ui/sonner'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
const logger = createLogger('terminal')
const store = useTerminalStore()
const win = getCurrentWindow()
// ===== localStorage =====
//
// mica/acrylic
// **** openWindow.ts
//
/** 与 style.css 的 `--background` 对齐:浅色 #ffffff / 深色 #16161a */
const WINDOW_BG = { light: '#ffffff', dark: '#16161a' }
function readMainTheme(): string {
try {
const raw = localStorage.getItem(STORAGE_KEYS.appSettings)
if (raw) return (JSON.parse(raw) as { theme?: string }).theme ?? 'system'
} catch {
/* 设置不可读时按跟随系统处理 */
}
return 'system'
}
function resolveIsDark(theme: string): boolean {
if (theme === 'dark') return true
if (theme === 'light') return false
return window.matchMedia('(prefers-color-scheme: dark)').matches
}
async function applyTheme() {
const root = document.documentElement
const theme = readMainTheme()
const isDark = resolveIsDark(theme)
try {
await win.setTheme(theme === 'system' ? null : (theme as 'dark' | 'light'))
} catch {
/* 平台不支持时忽略 */
}
try {
// trysetTheme
await win.setBackgroundColor(isDark ? WINDOW_BG.dark : WINDOW_BG.light)
} catch {
/* 忽略 */
}
root.classList.toggle('dark', isDark)
//
appIsDark.value = isDark
applyStoredAccent(root, isDark ? 'dark' : 'light', STORAGE_KEYS.appSettings)
}
const darkMedia = window.matchMedia('(prefers-color-scheme: dark)')
const onThemeChange = () => void applyTheme()
const onStorageChange = (e: StorageEvent) => {
if (e.key === STORAGE_KEYS.appSettings) void applyTheme()
}
// ===== =====
const ready = ref(false)
const busy = ref(false)
/** 正在创建的目标名(列表上显示 spinner) */
const pending = ref('')
const shells = computed(() => store.shells.filter(s => s.enabled))
const hosts = computed(() =>
[...store.hosts].sort((a, b) => a.config.name.localeCompare(b.config.name, 'zh-CN'))
)
onMounted(async () => {
darkMedia.addEventListener('change', onThemeChange)
window.addEventListener('storage', onStorageChange)
window.addEventListener('keydown', onKeydown, true)
// openWindow.ts
await applyTheme()
try {
await win.show()
await win.setFocus()
} catch (e) {
logger.warn(`显示选择窗口失败:${String(e)}`)
}
try {
await store.init()
} catch (e) {
logger.error(`选择窗口初始化失败:${String(e)}`)
toast.error(`读取会话列表失败:${String(e)}`)
} finally {
ready.value = true
}
})
onBeforeUnmount(() => {
darkMedia.removeEventListener('change', onThemeChange)
window.removeEventListener('storage', onStorageChange)
window.removeEventListener('keydown', onKeydown, true)
})
/** Esc 关闭本窗口(选择窗口是临时性的,错了直接关掉最省事) */
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
e.preventDefault()
void win.close()
}
}
/**
* 建会话 交给 Rust 开规范 label 的会话窗口 关掉本窗口
*
* 失败例如已达独立窗口上限**不关窗**错误提示要留在用户眼前
*/
async function launch(create: () => Promise<{ id: string }>, name: string) {
if (busy.value) return
busy.value = true
pending.value = name
try {
const info = await create()
await store.detachSession(info.id)
await win.close()
} catch (e) {
toast.error(`打开独立窗口失败:${String(e)}`)
} finally {
busy.value = false
pending.value = ''
}
}
async function minimize() {
await win.minimize()
}
async function toggleMaximize() {
await win.toggleMaximize()
}
async function closeWindow() {
await win.close()
}
</script>
<template>
<TooltipProvider>
<div class="flex flex-col h-screen w-screen overflow-hidden bg-background">
<!-- 控制栏标题兼拖拽区+ 窗口按钮与终端独立窗口同构 -->
<div class="shrink-0 flex items-center gap-1 h-9 px-1.5 border-b border-border">
<div class="flex h-full min-w-0 flex-1 items-center gap-1.5 px-2" data-tauri-drag-region>
<TerminalSquare class="size-3.5 shrink-0 text-muted-foreground pointer-events-none" />
<span class="truncate text-xs font-medium pointer-events-none">新建终端窗口</span>
<span class="truncate text-[11px] text-muted-foreground pointer-events-none">
选择一个本地 Shell 或已保存的 SSH 主机会话将在新的独立窗口中打开
</span>
</div>
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="icon-sm" @click="minimize">
<Minus class="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">最小化</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="icon-sm" @click="toggleMaximize">
<Square class="size-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">最大化 / 还原</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<Button
variant="ghost"
size="icon-sm"
class="hover:!bg-destructive hover:!text-destructive-foreground"
@click="closeWindow"
>
<X class="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">关闭Esc</TooltipContent>
</Tooltip>
</div>
<!-- 列表居中收窄避免大屏上一行被拉到很远 -->
<ScrollArea class="flex-1 min-h-0">
<div class="mx-auto w-full max-w-md p-5">
<div v-if="!ready" class="flex items-center justify-center gap-2 py-10 text-xs text-muted-foreground">
<Loader2 class="size-4 animate-spin" /> 正在读取会话列表
</div>
<template v-else>
<p class="pb-1.5 text-[11px] font-medium tracking-wide text-muted-foreground">本地 Shell</p>
<div class="flex flex-col gap-0.5">
<button
v-for="sh in shells"
:key="sh.id"
type="button"
class="flex items-center gap-2 rounded-md px-2 py-2 text-left text-[13px] transition-colors
hover:bg-accent/60 disabled:pointer-events-none disabled:opacity-50"
:disabled="busy"
@click="launch(() => store.openLocal(sh.id, undefined, 120, 30), sh.name)"
>
<TerminalSquare class="size-3.5 shrink-0 text-muted-foreground" />
<span class="min-w-0 flex-1 truncate">{{ sh.name }}</span>
<Loader2 v-if="pending === sh.name" class="size-3.5 shrink-0 animate-spin" />
</button>
<p v-if="shells.length === 0" class="px-2 py-1 text-[11.5px] text-muted-foreground">
没有可用的 Shell请到终端 设置 Shell重新探测
</p>
</div>
<p class="px-0 pb-1.5 pt-4 text-[11px] font-medium tracking-wide text-muted-foreground">
SSH 主机
</p>
<div class="flex flex-col gap-0.5">
<button
v-for="h in hosts"
:key="h.config.id"
type="button"
class="flex items-center gap-2 rounded-md px-2 py-2 text-left text-[13px] transition-colors
hover:bg-accent/60 disabled:pointer-events-none disabled:opacity-50"
:disabled="busy"
@click="launch(() => store.openSsh(h.config.id, 120, 30), h.config.name)"
>
<Globe class="size-3.5 shrink-0 text-muted-foreground" />
<span class="min-w-0 flex-1 truncate">{{ h.config.name }}</span>
<span class="shrink-0 text-[11px] text-muted-foreground">
{{ h.config.username ? `${h.config.username}@` : '' }}{{ h.config.host }}
</span>
<Loader2 v-if="pending === h.config.name" class="size-3.5 shrink-0 animate-spin" />
</button>
<p v-if="hosts.length === 0" class="px-2 py-1 text-[11.5px] text-muted-foreground">
还没有保存的 SSH 主机终端 主机新建一台
</p>
</div>
</template>
</div>
</ScrollArea>
<Toaster position="bottom-right" rich-colors close-button :style="{ zIndex: 99999 }" />
</div>
</TooltipProvider>
</template>
<style scoped>
/* 本窗口无边框,禁止整页滚动与选中,贴近原生窗口行为 */
:global(html),
:global(body) {
overflow: hidden;
margin: 0;
}
</style>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,112 @@
<script setup lang="ts">
/**
* 终端新建独立窗口全局快捷键的宿主挂在 App.vue
*
* # 为什么挂在 App
*
* 快捷键是**全局**任何窗口前台时都能按而终端模块的界面是懒加载的
* 用户此刻可能停在下载器或音乐模块上那时终端模块根本没挂载宿主必须在常驻组件里
*
* # 触发后只做一件事开选择窗口
*
* 选择会话的界面在**独立窗口里** `NewTerminalWindow.vue`不再经过主窗口弹窗
* 用户按了快捷键就该看到一个终端窗口而不是主窗口被拉到前台再弹一个框
*
* # 键位从设置里读
*
* 键位存在终端设置的快捷键表里设置 快捷键 会话 新建独立窗口默认 `Ctrl+3`
* 因此这里是读设置 注册而不是硬编码改键后 watch 到变化即重新注册
* 关掉那一行右侧的开关 = 不注册
*
* # 注册方式与冲突
*
* `@tauri-apps/plugin-global-shortcut` JS 接口而不是像剪贴板/快速面板那样
* Rust `setup.rs` 注册那条路需要为终端新增 Rust 命令并重新生成 `bindings.ts`
* 代价是绕过了 `crate::shortcut` 的应用内占用表 因此这条例外地在**注册失败时
* 只记日志**系统或其它应用占用同名热键时静默降级不弹提示打断启动
*/
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { isRegistered, register, unregister } from '@tauri-apps/plugin-global-shortcut'
import { useAppStore } from '@/stores/appStore'
import { useTerminalStore } from '@/stores/terminalStore'
import { createLogger } from '@/lib/logger'
import { ACTION_MAP } from '@/lib/terminalActions'
import { openTerminalPickerWindow } from './openWindow'
/** 与 `TERMINAL_ACTIONS` 中的动作 id 一致 */
const ACTION_ID = 'newDetachedWindow'
const logger = createLogger('terminal')
const appStore = useAppStore()
const store = useTerminalStore()
/** 终端模块是否启用(禁用时不该继续占用系统热键) */
const moduleEnabled = computed(() => appStore.modules.find(m => m.id === 'terminal')?.enabled !== false)
/**
* 生效的键位
*
* 设置还没加载时先用前端默认值 Rust `default_shortcuts()` 里的值一致
* 避免应用刚启动快捷键暂时不生效的空窗期那一行的开关关掉即为空串不注册
*/
const shortcutKey = computed(() => {
const fallback = ACTION_MAP[ACTION_ID]?.defaultKeys ?? ''
const row = store.settings?.shortcuts?.find(b => b.action === ACTION_ID)
if (!row) return fallback
if (row.enabled === false) return ''
return row.keys || fallback
})
/** 当前已注册的键位(改键时先注销它) */
const registeredKey = ref('')
async function applyShortcut() {
if (registeredKey.value) {
try {
if (await isRegistered(registeredKey.value)) await unregister(registeredKey.value)
} catch (e) {
logger.warn(`注销全局快捷键失败:${String(e)}`)
}
registeredKey.value = ''
}
if (!moduleEnabled.value) return
const key = shortcutKey.value
if (!key) return
try {
await register(key, onShortcut)
registeredKey.value = key
} catch (e) {
//
logger.error(`注册终端全局快捷键 ${key} 失败:${String(e)}`)
}
}
function onShortcut(event: { state: 'Pressed' | 'Released' }) {
if (event.state !== 'Pressed') return
void openTerminalPickerWindow().catch(e => logger.error(`打开终端选择窗口失败:${String(e)}`))
}
onMounted(async () => {
//
//
try {
await store.init()
} catch (e) {
logger.warn(`终端设置读取失败:${String(e)}`)
}
await applyShortcut()
})
onBeforeUnmount(() => {
if (registeredKey.value) void unregister(registeredKey.value).catch(() => {})
})
// /
watch([shortcutKey, moduleEnabled], () => void applyShortcut())
</script>
<template>
<!-- 纯逻辑组件选择界面在自己的窗口里 NewTerminalWindow.vue -->
<span />
</template>
+469 -46
View File
@@ -23,17 +23,65 @@
*
* 输出事件是**窗口广播**Rust `app.emit` 发到所有窗口主窗口的监听器
* 管不到本窗口这正是独立窗口能收到输出的实现基础
*
* # 界面与终端页同构的终端卡片2026-09-21
*
* 此前本窗口是一条 h-8 的标题栏 + 画布 + 状态栏与主窗口里的终端页看起来
* 是两套东西现在改成与终端页**同一套结构与组件**
*
* ```
*
* [图标 会话名 可拖拽] 搜索 字体 清屏 控制栏 h-9
*
* 画布xterm
*
* 状态栏与主窗口同一个 TerminalStatusBar
*
* ```
*
* 控制栏左端承担原标题栏的两件事显示这是哪个会话**窗口拖拽区**
* 无边框窗口必须有拖拽区否则窗口挪不动右侧是复用的 `TerminalToolbar`
* `standalone` 模式隐藏只对主窗口页内面板有意义的动作+ 最小化/最大化/关闭
*
* 窗口绑定单个会话因此**不渲染标签栏** 这保持了一个会话 一个窗口
* 语义主窗口那侧的已在独立窗口中打开提示条标签上的独立徽标都以此为准
*/
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { getCurrentWindow } from '@tauri-apps/api/window'
import { AlertTriangle, Loader2, Menu, Minus, Square, X } from '@lucide/vue'
import {
AlertTriangle,
Globe,
Loader2,
Minus,
Square,
TerminalSquare,
X
} from '@lucide/vue'
import { toast } from 'vue-sonner'
import { useTerminalStore } from '@/stores/terminalStore'
import { useTerminalStream } from '@/composables/useSessionStream'
import { useTerminalKeys } from '@/composables/useTerminalKeys'
import { toggleHistoryPopover, toggleSnippetsPopover } from './useRecordPopovers'
import { createLogger } from '@/lib/logger'
import { applyStoredAccent } from '@/lib/theme'
import { appIsDark } from '@/lib/terminalThemes'
import { STORAGE_KEYS } from '@/lib/constants'
import TerminalPane from './components/TerminalPane.vue'
import TerminalToolbar from './components/TerminalToolbar.vue'
import TerminalStatusBar from './components/TerminalStatusBar.vue'
import TerminalContextMenu from './components/TerminalContextMenu.vue'
import HostKeyPromptDialog from './components/HostKeyPromptDialog.vue'
import AiPanel from './components/AiPanel.vue'
import { Button } from '@/components/ui/button'
import { Toaster } from '@/components/ui/sonner'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
const logger = createLogger('terminal')
@@ -55,6 +103,12 @@ const { effectiveAppearance, bumpFont, resetFont, bindings, init } = stream
const session = computed(() => store.sessionById(sessionId))
// ===== =====
const isSsh = computed(() => session.value?.kind === 'ssh')
const sessionLabel = computed(() => store.sessionLabel(session.value) || '终端')
const sessionTarget = computed(() => (isSsh.value ? store.sessionTarget(session.value) : ''))
// ===== =====
const paneRef = ref<InstanceType<typeof TerminalPane> | null>(null)
@@ -64,7 +118,105 @@ const paneRef = ref<InstanceType<typeof TerminalPane> | null>(null)
const ready = ref(false)
const fatal = ref('')
// ===== =====
//
// JS ** appStore**
// store / /
// `localStorage` `dark` class +
// `storage` 沿****
// mica / acrylic terminal/window.rs
//
// xterm
//
//
// Rust **** terminal/window.rs
// class****
// `--background` style.css
/** 与 style.css 的 `--background` 对齐:浅色 #ffffff / 深色 #16161a */
const WINDOW_BG = { light: '#ffffff', dark: '#16161a' }
/** 主窗口的主题设置(独立窗口只共享 settings,不共享 store */
function readMainTheme(): string {
try {
const raw = localStorage.getItem(STORAGE_KEYS.appSettings)
if (raw) {
const s = JSON.parse(raw) as { theme?: string }
return s.theme ?? 'system'
}
} catch {
/* 设置不可读时按跟随系统处理 */
}
return 'system'
}
/** system 模式下用 matchMedia 判断深浅(独立窗口拿不到主窗口的响应式状态) */
function resolveIsDark(theme: string): boolean {
if (theme === 'dark') return true
if (theme === 'light') return false
return window.matchMedia('(prefers-color-scheme: dark)').matches
}
async function applyTheme() {
const root = document.documentElement
const theme = readMainTheme()
const isDark = resolveIsDark(theme)
// 1. + WebView prefers-color-scheme
// WebView
try {
await win.setTheme(theme === 'system' ? null : (theme as 'dark' | 'light'))
} catch {
/* 平台不支持时忽略 */
}
try {
// trysetTheme
await win.setBackgroundColor(isDark ? WINDOW_BG.dark : WINDOW_BG.light)
} catch {
/* 忽略 */
}
// 2. DOM class bg-background / text-foreground
root.classList.toggle('dark', isDark)
// terminalThemes.ts
appIsDark.value = isDark
// 3. localStorage accentId / accentHex
applyStoredAccent(root, isDark ? 'dark' : 'light', STORAGE_KEYS.appSettings)
}
/**
* 显示本窗口
*
* Rust 侧是隐藏创建的见文件头说明所以第一次显示由这里触发
* 放在 `applyTheme` 之后用户看到的第一帧就是正确配色
*/
async function revealWindow() {
try {
await win.show()
await win.setFocus()
} catch (e) {
logger.warn(`显示独立窗口失败:${String(e)}`)
}
}
const onThemeChange = () => void applyTheme()
/** 主窗口改主题时,同源的其它窗口会收到 storage 事件 —— 这就是「跟随」的实现 */
const onStorageChange = (e: StorageEvent) => {
if (e.key === STORAGE_KEYS.appSettings) void applyTheme()
}
const darkMedia = window.matchMedia('(prefers-color-scheme: dark)')
onMounted(async () => {
darkMedia.addEventListener('change', onThemeChange)
window.addEventListener('storage', onStorageChange)
//
// 使
await applyTheme()
await revealWindow()
if (!sessionId) {
fatal.value = '缺少会话 ID,无法确定要显示哪个会话'
ready.value = true
@@ -84,7 +236,7 @@ onMounted(async () => {
// 便
const info = store.sessionById(sessionId)
if (info?.title) {
void win.setTitle(`${info.title} · 终端`)
void win.setTitle(`${info.title} · 终端`).catch(() => {})
}
} catch (e) {
logger.error(`独立窗口初始化失败:${String(e)}`)
@@ -105,12 +257,104 @@ onMounted(async () => {
* `invoke` 仍可发出IPC 通道在窗口销毁前还有效这是最后一个可靠时机
*/
onBeforeUnmount(() => {
darkMedia.removeEventListener('change', onThemeChange)
window.removeEventListener('storage', onStorageChange)
if (!sessionId) return
void store.attachSession(sessionId).catch(e => {
logger.warn(`重置 detached 标记失败:${String(e)}`)
})
})
// ===== =====
//
// TerminalWorkspace ****
// SFTP composable
// store
/** 搜索 */
const searching = ref(false)
const searchHint = ref('')
function toggleSearch() {
searching.value = !searching.value
if (!searching.value) {
paneRef.value?.clearSearch?.()
searchHint.value = ''
}
}
function doSearch(keyword: string, back = false) {
const found = back ? paneRef.value?.findPrevious?.(keyword) : paneRef.value?.findNext?.(keyword)
searchHint.value = found === false ? '无匹配' : ''
}
function handleClear() {
const shellKind = store.shells.find(s => s.id === session.value?.targetId)?.kind
// shell xterm
// shell
void store.clearSession(sessionId, shellKind)
paneRef.value?.clear?.()
}
/** 重命名会话(控制栏没有标签,改的是窗口标题与会话标签共用的那个名称) */
const renameOpen = ref(false)
const renameValue = ref('')
function openRename() {
renameValue.value = session.value?.title ?? ''
renameOpen.value = true
}
async function submitRename() {
const name = renameValue.value.trim()
if (!name) return
try {
await store.renameSession(sessionId, name)
// /
void win.setTitle(`${name} · 终端`).catch(() => {})
toast.success('已重命名')
renameOpen.value = false
} catch (e) {
toast.error(`重命名失败:${String(e)}`)
}
}
/** AI 命令助手:把当前选区作为上下文(与终端页同一口径) */
const aiOpen = ref(false)
function getTerminalContext(): string {
return paneRef.value?.getSelection?.() ?? ''
}
/** 会话日志 */
async function toggleLogging() {
const sess = session.value
if (!sess) return
try {
await store.toggleLogging(sessionId, !sess.logging)
if (!sess.logging) {
const p = await store.logPath(sessionId)
toast.success(p ? `已开始记录会话日志:${p}` : '已开始记录会话日志')
} else {
toast.success('已停止记录会话日志(文件已保留)')
}
} catch (e) {
toast.error(`切换日志记录失败:${String(e)}`)
}
}
async function openLogDir() {
try {
const p = await store.logPath(sessionId)
if (!p) {
toast.info('当前会话未开启日志')
return
}
await store.revealLocalPath(p)
} catch (e) {
toast.error(`打开日志位置失败:${String(e)}`)
}
}
// ===== =====
useTerminalKeys({
@@ -118,8 +362,8 @@ useTerminalKeys({
onAction: actionId => {
switch (actionId) {
case 'copy': {
const text = paneRef.value?.getSelection?.()
if (text) void navigator.clipboard.writeText(text.replace(/\n+$/, ''))
//
void doCopy()
break
}
case 'paste':
@@ -140,6 +384,17 @@ useTerminalKeys({
case 'fontReset':
resetFont()
break
case 'search':
toggleSearch()
break
// /
//
case 'history':
toggleHistoryPopover()
break
case 'snippets':
toggleSnippetsPopover()
break
// /
default:
break
@@ -147,15 +402,89 @@ useTerminalKeys({
}
})
// ===== =====
// ===== =====
//
// `TerminalContextMenu`
// main.ts `preventDefault`
const contextMenu = ref<{ x: number; y: number } | null>(null)
/** 右键菜单的「复制」是否可用(取决于当时是否有选区) */
const contextHasSelection = computed(() => !!paneRef.value?.getSelection?.())
function closeContextMenu() {
contextMenu.value = null
}
/**
* 无边框窗口需要自己实现拖拽与最小化/最大化/关闭
*
* 但本窗口**不实现拖拽区域**终端面板占满整个窗口若在顶部加拖拽条
* 用户会失去一块垂直空间而拖拽整个窗口的需求可以通过系统的方式完成
* Alt+Space或拖窗口边缘这里只保留最小化/最大化/关闭三个按钮
* 面板右键的统一入口与终端页同一套三种行为`selection.rightClick`
* - `menu`弹菜单
* - `paste`直接粘贴
* - `select-word`先选中右键处的词再弹菜单只选中不弹菜单会让用户无法继续操作
*/
function onPaneContextMenu(payload: { x: number; y: number }) {
const mode = store.settings?.selection?.rightClick ?? 'menu'
if (mode === 'paste') {
void doPaste()
return
}
if (mode === 'select-word') {
paneRef.value?.selectWordAt?.(payload.x, payload.y)
}
contextMenu.value = payload
}
async function doCopy() {
const text = paneRef.value?.getSelection?.()
closeContextMenu()
if (!text) {
toast.info('没有选中内容')
return
}
const trim = store.settings?.selection?.trimTrailingNewline ?? true
const payload = trim && text.endsWith('\n') ? text.replace(/\n+$/, '') : text
try {
await navigator.clipboard.writeText(payload)
} catch (e) {
toast.error(`复制失败:${String(e)}`)
}
}
async function doPaste() {
closeContextMenu()
try {
const text = await navigator.clipboard.readText()
if (!text) return
await store.write(sessionId, text)
} catch (e) {
toast.error(`粘贴失败:${String(e)}`)
}
}
function doSelectAll() {
paneRef.value?.selectAll?.()
closeContextMenu()
}
function doClearFromMenu() {
closeContextMenu()
handleClear()
}
function doRenameFromMenu() {
closeContextMenu()
openRename()
}
async function doCloseFromMenu() {
closeContextMenu()
await store.closeSession(sessionId)
}
// ===== =====
//
// //
async function minimize() {
await win.minimize()
}
@@ -168,42 +497,90 @@ async function closeWindow() {
</script>
<template>
<div class="flex flex-col h-screen w-screen overflow-hidden bg-background">
<!-- 标题栏 -->
<div
class="shrink-0 h-8 flex items-center gap-2 px-2 border-b border-border select-none"
data-tauri-drag-region
>
<Menu class="size-3.5 text-muted-foreground shrink-0 pointer-events-none" />
<span class="text-xs truncate flex-1 pointer-events-none">
{{ session?.title || '终端' }}
</span>
<!--
独立窗口是**另一个 WebView 入口**main.ts hash 路由不经过 App.vue
因此这里必须自带 TooltipProvider 否则控制栏里的 Tooltip 取不到上下文
-->
<TooltipProvider>
<div class="flex flex-col h-screen w-screen overflow-hidden bg-background">
<!-- 控制栏会话身份拖拽区+ 控制栏动作 + 窗口按钮 -->
<div class="shrink-0 flex items-center gap-1 h-9 px-1.5 border-b border-border">
<!--
拖拽区无边框窗口靠它移动子元素 pointer-events-none
mousedown target 始终落在这块带 data-tauri-drag-region 的容器上
-->
<div class="flex h-full min-w-0 flex-1 items-center gap-1.5 px-2" data-tauri-drag-region>
<component
:is="isSsh ? Globe : TerminalSquare"
class="size-3.5 shrink-0 text-muted-foreground pointer-events-none"
/>
<span class="truncate text-xs font-medium pointer-events-none">{{ sessionLabel }}</span>
<span
v-if="sessionTarget"
class="truncate text-[11px] text-muted-foreground pointer-events-none"
>
{{ sessionTarget }}
</span>
</div>
<button
class="size-6 flex items-center justify-center rounded hover:bg-accent transition-colors"
title="最小化"
@click="minimize"
>
<Minus class="size-3" />
</button>
<button
class="size-6 flex items-center justify-center rounded hover:bg-accent transition-colors"
title="最大化"
@click="toggleMaximize"
>
<Square class="size-2.5" />
</button>
<button
class="size-6 flex items-center justify-center rounded hover:bg-destructive
hover:text-destructive-foreground transition-colors"
title="关闭窗口(会话保留)"
@click="closeWindow"
>
<X class="size-3.5" />
</button>
<TerminalToolbar
v-if="ready && !fatal"
standalone
:session-id="sessionId"
:session="session"
:searching="searching"
:search-hint="searchHint"
:is-ssh="isSsh"
:sftp-visible="false"
:logging="session?.logging ?? false"
:font-size="effectiveAppearance?.fontSize ?? 14"
@toggle-search="toggleSearch"
@search="doSearch($event)"
@search-next="doSearch($event)"
@search-prev="doSearch($event, true)"
@font="bumpFont($event)"
@font-reset="resetFont"
@clear="handleClear"
@rename="openRename"
@open-ai="aiOpen = true"
@toggle-logging="toggleLogging"
@open-log-dir="openLogDir"
/>
<div class="w-px h-4 bg-border shrink-0" />
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="icon-sm" @click="minimize">
<Minus class="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">最小化</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="icon-sm" @click="toggleMaximize">
<Square class="size-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">最大化 / 还原</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<Button
variant="ghost"
size="icon-sm"
class="hover:!bg-destructive hover:!text-destructive-foreground"
@click="closeWindow"
>
<X class="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">关闭窗口会话保留回到主窗口</TooltipContent>
</Tooltip>
</div>
<!-- 主体 -->
<!-- 画布 -->
<div class="flex-1 min-h-0 relative">
<div v-if="!ready" class="absolute inset-0 flex items-center justify-center">
<div class="flex flex-col items-center gap-3">
@@ -225,12 +602,29 @@ async function closeWindow() {
:session-id="sessionId"
:appearance="effectiveAppearance"
:visible="true"
@contextmenu="onPaneContextMenu"
/>
</div>
<!-- 状态栏 -->
<!-- 右键菜单与终端页同一组件重命名在单会话窗口里叫重命名会话 -->
<TerminalContextMenu
v-if="contextMenu"
:x="contextMenu.x"
:y="contextMenu.y"
:can-copy="contextHasSelection"
rename-label="重命名会话"
@copy="doCopy"
@paste="doPaste"
@select-all="doSelectAll"
@clear="doClearFromMenu"
@rename="doRenameFromMenu"
@close="doCloseFromMenu"
/>
<!-- 状态栏与主窗口终端页同一个组件高度/字号一致
显隐同样尊重显示状态栏设置此前这里无条件渲染关掉设置后窗口里还在 -->
<TerminalStatusBar
v-if="ready && !fatal"
v-if="ready && !fatal && store.settings?.layout?.showStatusBar !== false"
:session="session"
:font-size="effectiveAppearance?.fontSize ?? 14"
:cols="session?.cols"
@@ -239,7 +633,36 @@ async function closeWindow() {
<!-- 主机密钥确认独立窗口也可能触发用户直接从这个窗口发起连接时 -->
<HostKeyPromptDialog />
</div>
<!-- AI 命令助手 -->
<AiPanel v-model:open="aiOpen" :session-id="sessionId" :get-context="getTerminalContext" />
<!-- 重命名 -->
<Dialog v-model:open="renameOpen">
<DialogContent class="max-w-sm">
<DialogHeader>
<DialogTitle class="text-base">重命名会话</DialogTitle>
</DialogHeader>
<input
v-model="renameValue"
class="w-full h-8 px-2.5 rounded-md border border-border bg-transparent
text-sm outline-none focus:border-primary/60"
@keydown.enter="submitRename"
/>
<DialogFooter>
<Button variant="outline" size="sm" @click="renameOpen = false">取消</Button>
<Button size="sm" @click="submitRename">保存</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<!--
提示条要自带Toaster **窗口级**主窗口那个管不到本窗口
重命名切换日志AI 助手的反馈都在这个窗口里发出
-->
<Toaster position="bottom-right" rich-colors close-button :style="{ zIndex: 99999 }" />
</div>
</TooltipProvider>
</template>
<style scoped>
+9 -4
View File
@@ -10,7 +10,7 @@
* # 交互与片段库同一套安全语义
*
* 建议默认填入命令行用户自己按回车直接执行是显式第二动作
* 理由与 SnippetPanel 一致命令被自动执行与等待用户确认在心理上完全不同
* 理由与命令片段SnippetPopover一致命令被自动执行与等待用户确认在心理上完全不同
* 而且模型的建议未经本地验证用户应当有机会先看一眼再回车
*
* # 上下文
@@ -26,6 +26,7 @@ import { useTerminalStore } from '@/stores/terminalStore'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { ScrollArea } from '@/components/ui/scroll-area'
import {
Dialog,
DialogContent,
@@ -135,7 +136,7 @@ function engineLabel(e: AiEngineOption): string {
<template>
<Dialog :open="open" @update:open="v => emit('update:open', v)">
<DialogContent class="max-w-lg">
<DialogContent class="max-w-lg max-h-[85vh]">
<DialogHeader>
<DialogTitle class="flex items-center gap-2 text-base">
<Sparkles class="size-4" />AI 命令助手
@@ -187,8 +188,11 @@ function engineLabel(e: AiEngineOption): string {
<Sparkles class="size-3.5" />{{ generating ? '生成中…' : '生成建议' }}
</Button>
<!-- 建议 -->
<div v-if="suggestions.length > 0" class="space-y-1.5">
<!--
建议条数由模型决定可能很多条必须限高滚动 ScrollArea 而非原生滚动条
-->
<ScrollArea v-if="suggestions.length > 0" class="max-h-[42vh] pr-3">
<div class="space-y-1.5">
<div
v-for="(s, i) in suggestions"
:key="i"
@@ -206,6 +210,7 @@ function engineLabel(e: AiEngineOption): string {
</div>
</div>
</div>
</ScrollArea>
</DialogContent>
</Dialog>
</template>
@@ -23,6 +23,7 @@ import { useTerminalStore } from '@/stores/terminalStore'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { ScrollArea } from '@/components/ui/scroll-area'
import {
Dialog,
DialogContent,
@@ -157,7 +158,7 @@ function directionText(f: ForwardView): string {
<template>
<Dialog :open="open" @update:open="v => emit('update:open', v)">
<DialogContent class="max-w-xl">
<DialogContent class="max-w-xl max-h-[85vh]">
<DialogHeader>
<DialogTitle class="flex items-center gap-2 text-base">
<Network class="size-4" />端口转发
@@ -167,7 +168,8 @@ function directionText(f: ForwardView): string {
</DialogDescription>
</DialogHeader>
<!-- ===== 已有规则 ===== -->
<!-- ===== 已有规则条数不限限高滚动 ===== -->
<ScrollArea class="max-h-[40vh] pr-3">
<div class="space-y-1.5 min-h-[60px]">
<p v-if="loading" class="text-xs text-muted-foreground">加载中</p>
<p v-else-if="forwards.length === 0" class="text-xs text-muted-foreground">
@@ -181,7 +183,7 @@ function directionText(f: ForwardView): string {
>
<span
class="shrink-0 rounded px-1.5 py-0.5 text-[10px]"
:class="f.kind === 'local' ? 'bg-primary/10 text-primary' : 'bg-blue-500/10 text-blue-500'"
:class="f.kind === 'local' ? 'bg-primary/10 text-primary' : 'bg-info-soft text-info'"
>
{{ f.kind === 'local' ? '本地' : '远程' }}
</span>
@@ -205,6 +207,7 @@ function directionText(f: ForwardView): string {
</Button>
</div>
</div>
</ScrollArea>
<!-- ===== 新增 ===== -->
<div class="space-y-2 rounded-md border border-border p-3">
@@ -1,443 +0,0 @@
<script setup lang="ts">
/**
* 命令历史面板浮层
*
* # 交互模型搜索即过滤单击填入双击执行
*
* 历史与片段不同片段是我准备好要用的历史是我已经用过的
* 后者的使用模式是**快速找回**因此搜索框默认聚焦输入即过滤
* 键盘上下键可直接选中
*
* 单击 = 填入命令行不执行双击或 `Enter` = 填入并执行
* 这个区分的必要性在于历史里躺着用户过去敲过的所有命令包括
* `rm -rf``DROP TABLE`默认执行会让翻历史变成一件危险的事
*
* # 为什么只显示当前会话来源不做默认
*
* 有人按主机筛选那台机器上我跑过什么也有人跨主机找同一条命令
* 上次那条 rsync 参数是怎么写的默认全量 + 可选筛选比反向合理
* 前者只需点一下筛选后者要清空筛选才能看到全部
*/
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import {
ArrowDownToLine,
Check,
Clock,
Copy,
FolderOpen,
History,
Loader2,
Play,
Search,
Star,
Trash2,
X
} from '@lucide/vue'
import { toast } from 'vue-sonner'
import { useTerminalStore } from '@/stores/terminalStore'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import type { CommandHistoryItem, SessionInfo } from '@/types/terminal'
const props = defineProps<{
sessionId: string | null
session: SessionInfo | undefined
}>()
const emit = defineEmits<{
(e: 'close'): void
}>()
const store = useTerminalStore()
// ===== =====
const keyword = ref('')
const hostFilter = ref('')
const favoritedOnly = ref(false)
/** 选中项(用于键盘导航与详情) */
const selectedId = ref<number | null>(null)
const items = computed(() => store.historyPage.items)
const total = computed(() => store.historyPage.total)
/** 当前会话的来源 id(用于「只看本会话」快捷筛选) */
const currentHostId = computed(() => props.session?.targetId ?? '')
/**
* 加载历史
*
* `force` false 时不重复请求浮层首次打开才拉其余靠筛选变化触发
* 浮层每次开关都重拉会让刚删掉的记录因为重拉又出现这种错觉出现
* 而实际是删成功了只是列表被刷新回旧快照请求早于删除返回
*/
async function reload(force = false) {
try {
await store.loadHistory({
keyword: keyword.value,
hostId: hostFilter.value,
favoritedOnly: favoritedOnly.value
})
if (force) await store.loadHistorySources()
} catch (e) {
toast.error(`加载历史失败:${String(e)}`)
}
}
onMounted(async () => {
await reload(true)
})
// 200ms
// IPC store
let searchTimer: ReturnType<typeof setTimeout> | null = null
watch([keyword, favoritedOnly], () => {
if (searchTimer) clearTimeout(searchTimer)
searchTimer = setTimeout(() => void reload(), 200)
})
//
watch(hostFilter, () => void reload())
//
onBeforeUnmount(() => {
if (searchTimer) clearTimeout(searchTimer)
})
// ===== =====
/** 把命令填入会话命令行(不执行) */
async function useCommand(item: CommandHistoryItem, submit = false) {
if (!props.sessionId) {
toast.info('当前没有活跃会话')
return
}
try {
const r = await store.runHistory(props.sessionId, item.command, submit)
if (r.ok) {
//
//
if (!submit) emit('close')
else toast.success(r.message)
} else {
toast.error(r.message)
}
} catch (e) {
toast.error(`操作失败:${String(e)}`)
}
}
async function copyCommand(item: CommandHistoryItem) {
try {
await navigator.clipboard.writeText(item.command)
toast.success('已复制到剪贴板')
} catch (e) {
toast.error(`复制失败:${String(e)}`)
}
}
async function toggleFavorite(item: CommandHistoryItem) {
try {
await store.toggleHistoryFavorite(item.id)
} catch (e) {
toast.error(`操作失败:${String(e)}`)
}
}
async function removeItem(item: CommandHistoryItem) {
try {
const r = await store.deleteHistory(item.id)
if (!r.ok) toast.info(r.message)
if (selectedId.value === item.id) selectedId.value = null
} catch (e) {
toast.error(`删除失败:${String(e)}`)
}
}
async function clearAll() {
//
// window.confirm Dialog
// Radix Dialog z-index
const ok = window.confirm(
'确定清空命令历史吗?\n\n收藏的记录会保留(如需连同收藏一起清空,请先取消收藏)。'
)
if (!ok) return
try {
const r = await store.clearHistory(true)
toast.success(r.message)
} catch (e) {
toast.error(`清空失败:${String(e)}`)
}
}
// ===== =====
/**
* 上下键移动选中项Enter 执行
*
* 只在列表为空时不处理其余一律 `preventDefault` 否则方向键会
* 同时滚动容器选中项移出可视区
*/
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
emit('close')
return
}
if (items.value.length === 0) return
const idx = items.value.findIndex(i => i.id === selectedId.value)
if (e.key === 'ArrowDown') {
e.preventDefault()
const next = idx < 0 ? 0 : Math.min(items.value.length - 1, idx + 1)
selectedId.value = items.value[next].id
} else if (e.key === 'ArrowUp') {
e.preventDefault()
const next = idx <= 0 ? 0 : idx - 1
selectedId.value = items.value[next].id
} else if (e.key === 'Enter') {
e.preventDefault()
const item = items.value.find(i => i.id === selectedId.value)
if (item) void useCommand(item, false)
}
}
// ===== =====
/** 相对时间(历史列表里「3 分钟前」比精确时间戳更有信息量) */
function relTime(ts: number): string {
const diff = Date.now() - ts
if (diff < 60_000) return '刚刚'
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)} 分钟前`
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)} 小时前`
if (diff < 2_592_000_000) return `${Math.floor(diff / 86_400_000)} 天前`
return new Date(ts).toLocaleDateString('zh-CN')
}
/**
* 目录缩略只显示最后一级
*
* 完整路径会占满一行且把命令挤到看不见的位置而用户分辨在哪个项目里跑的
* 只需要最后一级完整路径放在 `title` 里悬停可看
*/
function cwdTail(cwd: string): string {
if (!cwd) return ''
const parts = cwd.split(/[\\/]/).filter(Boolean)
return parts.length > 0 ? parts[parts.length - 1] : cwd
}
/** 退出码非 0 时给视觉提示(用户找的常常正是「刚才那条报错的命令」) */
function isFailed(item: CommandHistoryItem): boolean {
return item.exitCode !== null && item.exitCode !== 0
}
</script>
<template>
<!-- 作为模块 Tab 内容渲染根不再是居中对话框 -->
<div
class="h-full flex flex-col rounded-lg border border-border bg-background overflow-hidden"
@keydown="onKeydown"
>
<!-- ===== 头部搜索 + 筛选 ===== -->
<div class="shrink-0 border-b border-border">
<div class="flex items-center gap-2 px-3 h-11">
<History class="size-4 text-muted-foreground shrink-0" />
<span class="text-sm font-medium shrink-0">命令历史</span>
<span class="text-xs text-muted-foreground shrink-0">
{{ total }} {{ hostFilter || favoritedOnly || keyword ? '(已筛选)' : '' }}
</span>
<div class="flex-1" />
<div class="relative">
<Search class="size-3.5 absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground" />
<Input
v-model="keyword"
placeholder="搜索命令…"
class="h-7 w-[240px] pl-7 text-xs"
autofocus
/>
</div>
<Button
variant="ghost"
size="sm"
class="h-7 text-xs shrink-0"
title="清空历史(保留收藏)"
@click="clearAll"
>
<Trash2 class="size-3.5" />
</Button>
<Button variant="ghost" size="sm" class="h-7 text-xs shrink-0" title="关闭" @click="emit('close')">
<X class="size-3.5" />
</Button>
</div>
<!-- 筛选行 -->
<div class="flex items-center gap-2 px-3 pb-2 text-xs">
<button
class="h-6 px-2 rounded border transition-colors"
:class="hostFilter === '' && !favoritedOnly
? 'border-primary/50 bg-primary/10 text-foreground'
: 'border-border text-muted-foreground hover:bg-accent'"
@click="hostFilter = ''; favoritedOnly = false"
>
全部来源
</button>
<button
v-if="currentHostId"
class="h-6 px-2 rounded border transition-colors"
:class="hostFilter === currentHostId
? 'border-primary/50 bg-primary/10 text-foreground'
: 'border-border text-muted-foreground hover:bg-accent'"
:title="`只看本会话(${store.sessionLabel(session)}`"
@click="hostFilter = currentHostId; favoritedOnly = false"
>
仅本会话
</button>
<button
class="h-6 px-2 rounded border transition-colors inline-flex items-center gap-1"
:class="favoritedOnly
? 'border-primary/50 bg-primary/10 text-foreground'
: 'border-border text-muted-foreground hover:bg-accent'"
@click="favoritedOnly = !favoritedOnly"
>
<Star class="size-3" /> 收藏
</button>
<!-- 其他来源只列有历史的避免显示一堆空来源 -->
<div class="flex-1" />
<select
v-if="store.historySources.length > 1"
:value="hostFilter"
class="h-6 px-1 rounded border border-border bg-transparent text-xs max-w-[200px]"
@change="hostFilter = ($event.target as HTMLSelectElement).value"
>
<option value="">按来源筛选</option>
<option v-for="s in store.historySources" :key="s.hostId" :value="s.hostId">
{{ s.hostName }}{{ s.count }}
</option>
</select>
</div>
</div>
<!-- ===== 列表 ===== -->
<div class="flex-1 min-h-0 overflow-y-auto">
<div v-if="store.historyLoading && items.length === 0" class="flex items-center justify-center h-full">
<Loader2 class="size-5 animate-spin text-muted-foreground" />
</div>
<div v-else-if="items.length === 0" class="flex flex-col items-center justify-center h-full gap-2 text-muted-foreground">
<History class="size-8 opacity-40" />
<span class="text-sm">
{{ keyword || hostFilter || favoritedOnly ? '没有匹配的命令' : '还没有命令历史' }}
</span>
<span v-if="!(keyword || hostFilter || favoritedOnly)" class="text-xs opacity-70 max-w-[320px] text-center">
命令由 shell 集成 hook 上报新开的会话执行命令后即可在此查看
</span>
</div>
<div v-else class="divide-y divide-border/60">
<div
v-for="item in items"
:key="item.id"
class="group flex items-start gap-2 px-3 py-2 cursor-pointer transition-colors"
:class="selectedId === item.id ? 'bg-accent' : 'hover:bg-accent/50'"
@click="selectedId = item.id"
@dblclick="useCommand(item, false)"
>
<!-- 收藏 -->
<button
class="shrink-0 mt-0.5 size-4 flex items-center justify-center transition-colors"
:class="item.favorited ? 'text-amber-500' : 'text-muted-foreground/40 hover:text-amber-500'"
:title="item.favorited ? '取消收藏' : '收藏(不参与容量淘汰)'"
@click.stop="toggleFavorite(item)"
>
<Star class="size-3.5" :fill="item.favorited ? 'currentColor' : 'none'" />
</button>
<!-- 主体 -->
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<code
class="text-xs font-mono truncate"
:class="isFailed(item) ? 'text-red-600 dark:text-red-400' : 'text-foreground'"
:title="item.command"
>
{{ item.command }}
</code>
<!-- 执行次数>1 说明是反复用到的命令值得优先看 -->
<span
v-if="item.count > 1"
class="shrink-0 text-[10px] px-1 rounded bg-muted text-muted-foreground"
:title="`执行过 ${item.count} 次`"
>
×{{ item.count }}
</span>
</div>
<div class="flex items-center gap-3 mt-0.5 text-[10px] text-muted-foreground">
<span class="inline-flex items-center gap-0.5 shrink-0">
<Clock class="size-2.5" />{{ relTime(item.ts) }}
</span>
<span v-if="item.cwd" class="inline-flex items-center gap-0.5 min-w-0" :title="item.cwd">
<FolderOpen class="size-2.5 shrink-0" />
<span class="truncate max-w-[140px]">{{ cwdTail(item.cwd) }}</span>
</span>
<span class="truncate max-w-[180px]">{{ item.hostName }}</span>
<span v-if="isFailed(item)" class="text-red-600 dark:text-red-400 shrink-0">
退出码 {{ item.exitCode }}
</span>
</div>
</div>
<!-- 行操作悬停出现避免列表视觉噪音 -->
<div class="shrink-0 flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<button
class="size-6 rounded flex items-center justify-center text-muted-foreground hover:bg-accent hover:text-foreground"
title="填入命令行(不执行)"
@click.stop="useCommand(item, false)"
>
<ArrowDownToLine class="size-3.5" />
</button>
<button
class="size-6 rounded flex items-center justify-center text-muted-foreground hover:bg-accent hover:text-foreground"
title="填入并执行"
@click.stop="useCommand(item, true)"
>
<Play class="size-3.5" />
</button>
<button
class="size-6 rounded flex items-center justify-center text-muted-foreground hover:bg-accent hover:text-foreground"
title="复制"
@click.stop="copyCommand(item)"
>
<Copy class="size-3.5" />
</button>
<button
class="size-6 rounded flex items-center justify-center text-muted-foreground hover:bg-accent hover:text-red-600"
title="删除这条"
@click.stop="removeItem(item)"
>
<Trash2 class="size-3.5" />
</button>
</div>
</div>
</div>
</div>
<!-- ===== 底部提示 ===== -->
<div class="shrink-0 h-7 flex items-center gap-4 px-3 border-t border-border text-[10px] text-muted-foreground">
<span class="inline-flex items-center gap-1">
<ArrowDownToLine class="size-3" /> 单击 / Enter = 填入
</span>
<span class="inline-flex items-center gap-1">
<Play class="size-3" /> 双击 = 填入并执行
</span>
<span class="inline-flex items-center gap-1">
<Check class="size-3" /> /下键导航Esc 关闭
</span>
<div class="flex-1" />
<span v-if="total > items.length">显示 {{ items.length }} / {{ total }} </span>
</div>
</div>
</template>
@@ -0,0 +1,471 @@
<script setup lang="ts">
/**
* 命令历史 popover终端页控制栏的历史按钮
*
* # 这个组件是按钮 + 浮层的整体
*
* 触发器浮层内容快捷键用的开合状态都封在这里控制栏只摆一个标签即可
* 分开的代价是填入后自动收起Esc 关闭这些动作都要跨组件传意图
* 变成父组件管开子组件管关而它们读写的本来就是同一份状态
*
* # 与旧记录页版本的区别
*
* 逻辑照搬原记录页的命令历史面板搜索即过滤选中双击填入收藏清空
* **形态**从整页变成 380px 宽的浮层去掉了页头工具条分区开关底部三行
* 提示换成一行与整页的筛选行排版之所以能有这个自信是因为历史的使用
* 模式本来就是快速找回一条命令用户想看的是列表不是页面
*
* # 为什么填入不执行为默认且单击不填入
*
* 历史里躺着用户过去敲过的所有命令包括 `rm -rf``DROP TABLE`
* 因此单击只**选中**便于键盘导航与读清楚双击或回车才**填入**命令行
* 执行一律要显式点执行按钮
*
* 单击不填入还有一层实际考虑填入是把文本写进当前 PTY 的命令行
* 用户可能正在敲一条命令命令行上有半截输入单击即填入会把那半截冲掉或拼在
* 一起选中不动手填入是明确的意图表达
*
* # 关闭时机
*
* 填入成功后立即收起浮层用户的下一步是看着终端按回车浮层挡在那里没有价值
* 浮层开合状态由 `useRecordPopovers` 的模块级 ref 持有 快捷键
* Ctrl+Shift+H与这里的触发按钮共用的就是它
*/
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import {
ArrowDownToLine,
Clock,
Copy,
FolderOpen,
History,
Play,
Search,
Star,
Trash2
} from '@lucide/vue'
import { toast } from 'vue-sonner'
import { useTerminalStore } from '@/stores/terminalStore'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { ScrollArea } from '@/components/ui/scroll-area'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import { StateBlock } from '@/components/common'
import { historyPopoverOpen, toggleHistoryPopover } from '../useRecordPopovers'
import type { CommandHistoryItem, SessionInfo } from '@/types/terminal'
const props = defineProps<{
/** 当前会话(把命令填给它;为空时动作不可用) */
sessionId: string | null
session: SessionInfo | undefined
}>()
const store = useTerminalStore()
// ===== =====
const keyword = ref('')
const hostFilter = ref('')
const favoritedOnly = ref(false)
/** 来源下拉的「全部」哨兵值:reka 的 SelectItem 不接受空串 value */
const ALL_SOURCES = '__all_sources__'
/** 选中项(键盘导航用) */
const selectedId = ref<number | null>(null)
const items = computed(() => store.historyPage.items)
const total = computed(() => store.historyPage.total)
/** 当前会话的来源 id(「仅本会话」快捷筛选的判据) */
const currentHostId = computed(() => props.session?.targetId ?? '')
/**
* 加载
*
* `force` 为真时连带刷新来源列表 只在浮层首次打开时做来源集合变化很慢
* 每敲一个字都拉一遍纯属浪费
*/
async function reload(force = false) {
try {
await store.loadHistory({
keyword: keyword.value,
hostId: hostFilter.value,
favoritedOnly: favoritedOnly.value
})
if (force) await store.loadHistorySources()
} catch (e) {
toast.error(`加载历史失败:${String(e)}`)
}
}
onMounted(async () => {
await reload(true)
})
// 200ms IPC
let searchTimer: ReturnType<typeof setTimeout> | null = null
watch([keyword, favoritedOnly], () => {
if (searchTimer) clearTimeout(searchTimer)
searchTimer = setTimeout(() => void reload(), 200)
})
//
watch(hostFilter, () => void reload())
//
onBeforeUnmount(() => {
if (searchTimer) clearTimeout(searchTimer)
})
// ===== =====
/** 把命令填入会话命令行(`submit` 为真则直接执行) */
async function useCommand(item: CommandHistoryItem, submit = false) {
if (!props.sessionId) {
toast.info('当前没有活跃会话')
return
}
try {
const r = await store.runHistory(props.sessionId, item.command, submit)
if (r.ok) {
if (submit) toast.success(r.message)
else toggleHistoryPopover(false)
} else {
toast.error(r.message)
}
} catch (e) {
toast.error(`操作失败:${String(e)}`)
}
}
async function copyCommand(item: CommandHistoryItem) {
try {
await navigator.clipboard.writeText(item.command)
toast.success('已复制到剪贴板')
} catch (e) {
toast.error(`复制失败:${String(e)}`)
}
}
async function toggleFavorite(item: CommandHistoryItem) {
try {
await store.toggleHistoryFavorite(item.id)
} catch (e) {
toast.error(`操作失败:${String(e)}`)
}
}
async function removeItem(item: CommandHistoryItem) {
try {
const r = await store.deleteHistory(item.id)
if (!r.ok) toast.info(r.message)
if (selectedId.value === item.id) selectedId.value = null
} catch (e) {
toast.error(`删除失败:${String(e)}`)
}
}
async function clearAll() {
//
// window.confirm Dialog
// reka Dialog z-index
const ok = window.confirm(
'确定清空命令历史吗?\n\n收藏的记录会保留(如需连同收藏一起清空,请先取消收藏)。'
)
if (!ok) return
try {
const r = await store.clearHistory(true)
toast.success(r.message)
} catch (e) {
toast.error(`清空失败:${String(e)}`)
}
}
// ===== =====
/**
* 移动选中项Enter 填入
*
* 焦点落在行内按钮/下拉上时让路它们自己要用方向键与 Enter
* 搜索框不让路在搜索框里按 选条目Enter 填入是刻意保留的操作方式
*/
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
toggleHistoryPopover(false)
return
}
const target = e.target as HTMLElement | null
if (target?.closest('button,[data-slot="select-trigger"],select,textarea')) return
if (items.value.length === 0) return
const idx = items.value.findIndex(i => i.id === selectedId.value)
if (e.key === 'ArrowDown') {
e.preventDefault()
const next = idx < 0 ? 0 : Math.min(items.value.length - 1, idx + 1)
selectedId.value = items.value[next].id
} else if (e.key === 'ArrowUp') {
e.preventDefault()
const next = idx <= 0 ? 0 : idx - 1
selectedId.value = items.value[next].id
} else if (e.key === 'Enter') {
e.preventDefault()
const item = items.value.find(i => i.id === selectedId.value)
if (item) void useCommand(item, false)
}
}
// ===== =====
/** 相对时间(「3 分钟前」比精确时间戳更有信息量) */
function relTime(ts: number): string {
const diff = Date.now() - ts
if (diff < 60_000) return '刚刚'
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)} 分钟前`
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)} 小时前`
if (diff < 2_592_000_000) return `${Math.floor(diff / 86_400_000)} 天前`
return new Date(ts).toLocaleDateString('zh-CN')
}
/** 目录只显示最后一级(完整路径会挤走命令本身,悬停可看全) */
function cwdTail(cwd: string): string {
if (!cwd) return ''
const parts = cwd.split(/[\\/]/).filter(Boolean)
return parts.length > 0 ? parts[parts.length - 1] : cwd
}
/** 退出码非 0(用户找的常常正是「刚才那条报错的命令」) */
function isFailed(item: CommandHistoryItem): boolean {
return item.exitCode !== null && item.exitCode !== 0
}
/** 是否处于筛选态(决定空态文案:是「没匹配」还是「还没有历史」) */
const isFiltered = computed(() => !!keyword.value || !!hostFilter.value || favoritedOnly.value)
</script>
<template>
<!--
触发器 + 浮层一体Tooltip 锚在包裹 div Popover 在自己的子树里用 as-child
两条 as-child 链互不相干更多菜单同款处理浮层展开期间禁用提示
鼠标此时还停在按钮上提示会压在浮层内容上
-->
<Tooltip :disabled="historyPopoverOpen">
<TooltipTrigger as-child>
<div class="inline-flex shrink-0">
<Popover v-model:open="historyPopoverOpen">
<PopoverTrigger as-child>
<Button variant="ghost" size="icon-sm" class="shrink-0">
<History class="size-3.5" />
</Button>
</PopoverTrigger>
<!-- 内容自带内边距让开 popover 默认的 w-72 / p-4 -->
<PopoverContent align="start" class="w-auto p-0">
<!-- 键盘事件挂在内容根上 / Esc 在浮层内任意位置都应生效 -->
<div class="w-[380px]" @keydown="onKeydown">
<!-- ===== 搜索 + 来源 ===== -->
<div class="flex items-center gap-2 p-2.5 border-b border-border">
<div class="relative flex-1 min-w-0">
<Search
class="size-3.5 absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground"
/>
<Input v-model="keyword" placeholder="搜索命令…" class="h-7 pl-7 text-xs" autofocus />
</div>
<!-- 只有一个来源时下拉没有筛选意义纯属噪音 -->
<Select
v-if="store.historySources.length > 1"
:model-value="hostFilter || ALL_SOURCES"
@update:model-value="v => (hostFilter = String(v) === ALL_SOURCES ? '' : String(v))"
>
<SelectTrigger size="sm" class="w-[124px] shrink-0 text-xs">
<SelectValue placeholder="来源" />
</SelectTrigger>
<SelectContent>
<!-- reka SelectItem 不接受空串值用哨兵值代表不筛选 -->
<SelectItem :value="ALL_SOURCES">全部来源</SelectItem>
<SelectItem v-for="s in store.historySources" :key="s.hostId" :value="s.hostId">
{{ s.hostName }}{{ s.count }}
</SelectItem>
</SelectContent>
</Select>
</div>
<!-- ===== 快捷筛选 ===== -->
<div class="flex items-center gap-1.5 px-2.5 py-2 text-xs">
<Button
size="sm"
class="h-6 px-2 text-[11px]"
:variant="hostFilter === '' && !favoritedOnly ? 'default' : 'outline'"
@click="hostFilter = ''; favoritedOnly = false"
>
全部
</Button>
<Button
v-if="currentHostId"
size="sm"
class="h-6 px-2 text-[11px]"
:variant="hostFilter === currentHostId ? 'default' : 'outline'"
:title="`只看本会话(${store.sessionLabel(session)}`"
@click="hostFilter = currentHostId; favoritedOnly = false"
>
仅本会话
</Button>
<Button
size="sm"
class="h-6 px-2 text-[11px]"
:variant="favoritedOnly ? 'default' : 'outline'"
@click="favoritedOnly = !favoritedOnly"
>
<Star class="size-3" />收藏
</Button>
<div class="flex-1" />
<span class="shrink-0 text-[10px] text-muted-foreground">{{ total }} </span>
</div>
<!-- ===== 列表 ===== -->
<ScrollArea class="h-[300px] border-t border-border">
<StateBlock
v-if="store.historyLoading && items.length === 0"
variant="loading"
:min-height="180"
/>
<StateBlock
v-else-if="items.length === 0"
variant="empty"
:title="isFiltered ? '没有匹配的命令' : '还没有命令历史'"
:description="
isFiltered ? undefined : '命令由 shell 集成 hook 上报,新开会话执行命令后即可在此查看。'
"
:min-height="180"
/>
<div v-else class="divide-y divide-divider">
<div
v-for="item in items"
:key="item.id"
class="group flex items-start gap-1.5 px-2.5 py-1.5 cursor-pointer transition-colors"
:class="selectedId === item.id ? 'bg-primary-soft' : 'hover:bg-hover'"
@click="selectedId = item.id"
@dblclick="useCommand(item, false)"
>
<!-- 收藏不参与容量淘汰因此可以随手留下想留的命令 -->
<button
class="shrink-0 mt-0.5 size-6 flex items-center justify-center transition-colors"
:class="
item.favorited ? 'text-warning' : 'text-muted-foreground/40 hover:text-warning'
"
:title="item.favorited ? '取消收藏' : '收藏(不参与容量淘汰)'"
@click.stop="toggleFavorite(item)"
>
<Star class="size-3.5" :fill="item.favorited ? 'currentColor' : 'none'" />
</button>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-1.5">
<code
class="text-[11px] font-mono truncate"
:class="isFailed(item) ? 'text-danger' : 'text-foreground'"
:title="item.command"
>
{{ item.command }}
</code>
<!-- 执行次数>1 说明是反复用到的命令值得优先看 -->
<span
v-if="item.count > 1"
class="shrink-0 text-[10px] text-muted-foreground"
:title="`执行过 ${item.count} 次`"
>
×{{ item.count }}
</span>
</div>
<div class="flex items-center gap-2 mt-0.5 text-[10px] text-muted-foreground">
<span class="inline-flex items-center gap-0.5 shrink-0">
<Clock class="size-2.5" />{{ relTime(item.ts) }}
</span>
<span
v-if="item.cwd"
class="inline-flex items-center gap-0.5 min-w-0"
:title="item.cwd"
>
<FolderOpen class="size-2.5 shrink-0" />
<span class="truncate max-w-[80px]">{{ cwdTail(item.cwd) }}</span>
</span>
<span class="truncate max-w-[90px]">{{ item.hostName }}</span>
<span v-if="isFailed(item)" class="text-danger shrink-0">
退出码 {{ item.exitCode }}
</span>
</div>
</div>
<!-- 行操作悬停出现避免列表视觉噪音 -->
<div
class="shrink-0 flex items-center opacity-0 group-hover:opacity-100 transition-opacity"
>
<Button
variant="ghost"
size="icon-sm"
class="size-6 text-muted-foreground hover:text-foreground"
title="填入命令行(不执行)"
@click.stop="useCommand(item, false)"
>
<ArrowDownToLine class="size-3" />
</Button>
<Button
variant="ghost"
size="icon-sm"
class="size-6 text-muted-foreground hover:text-foreground"
title="填入并执行"
@click.stop="useCommand(item, true)"
>
<Play class="size-3" />
</Button>
<Button
variant="ghost"
size="icon-sm"
class="size-6 text-muted-foreground hover:text-foreground"
title="复制"
@click.stop="copyCommand(item)"
>
<Copy class="size-3" />
</Button>
<Button
variant="ghost"
size="icon-sm"
class="size-6 text-muted-foreground hover:text-danger"
title="删除这条"
@click.stop="removeItem(item)"
>
<Trash2 class="size-3" />
</Button>
</div>
</div>
</div>
</ScrollArea>
<!-- ===== 底部操作提示 + 清空 ===== -->
<div
class="flex items-center gap-2 px-2.5 py-1.5 border-t border-border text-[10px] text-muted-foreground"
>
<span class="truncate">双击 / Enter 填入不执行</span>
<div class="flex-1" />
<Button
variant="ghost"
size="sm"
class="h-6 px-2 text-[11px] text-muted-foreground hover:text-destructive"
title="清空历史(保留收藏)"
@click="clearAll"
>
<Trash2 class="size-3" />清空
</Button>
</div>
</div>
</PopoverContent>
</Popover>
</div>
</TooltipTrigger>
<TooltipContent side="bottom">命令历史Ctrl+Shift+H</TooltipContent>
</Tooltip>
</template>
@@ -94,7 +94,7 @@ const canAccept = computed(() => !isChanged.value || acknowledged.value)
class="size-9 rounded-full flex items-center justify-center shrink-0"
:class="
isChanged
? 'bg-red-500/15 text-red-600 dark:text-red-400'
? 'bg-danger-soft text-danger-text'
: 'bg-primary/15 text-primary'
"
>
@@ -115,7 +115,7 @@ const canAccept = computed(() => !isChanged.value || acknowledged.value)
<!-- 高危警告指纹变更几乎只有两种可能 服务器重装或有人在中间 -->
<div
v-if="isChanged"
class="rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2.5 text-xs leading-relaxed text-red-700 dark:text-red-300"
class="rounded-md border border-danger/40 bg-danger-soft px-3 py-2.5 text-xs leading-relaxed text-danger-text"
>
<p class="font-medium mb-1">这可能意味着有人在窃听你的连接</p>
<p>
@@ -150,7 +150,7 @@ const canAccept = computed(() => !isChanged.value || acknowledged.value)
class="flex-1 px-2.5 py-2 rounded font-mono text-[12px] break-all select-all leading-relaxed"
:class="
isChanged
? 'bg-red-500/10 border border-red-500/30'
? 'bg-danger-soft border-danger/30'
: 'bg-muted border border-border'
"
>
@@ -163,7 +163,7 @@ const canAccept = computed(() => !isChanged.value || acknowledged.value)
:title="copied ? '已复制' : '复制指纹'"
@click="copyFingerprint"
>
<Check v-if="copied" class="size-3.5 text-emerald-500" />
<Check v-if="copied" class="size-3.5 text-success" />
<Copy v-else class="size-3.5" />
</Button>
</div>
+275 -113
View File
@@ -12,10 +12,25 @@
* 2. **新建时先向要一个 id**密码按 hostId 存取若等 save_host 之后再生成 id
* 用户在新建对话框里填的密码就没有归属因此用 `terminal_new_host_id`
* 预取 id使新建编辑走完全相同的流程
*
* # 已知主机known_hosts在这里而不是独立页2026-09-22
*
* 这台主机我信任过吗指纹变过没有**浏览主机列表时顺手核对**的信息
* 此前它占着凭据页的一个分区于是核对信任要跨 tab而每个 tab 里都只有
* 半张信息现在三层都落在本页
*
* - 主机行的**信任徽标**未连接过 / 已信任 / 变更 N 一眼可见
* - 徽标点开的 **HostTrustPopover**各算法指纹变更历史忘记
* - 列表末尾的 **OrphanKnownHosts**没有对应主机配置的记录配置已删或
* 从未保存过配置它们照样会拦截连接不能失去入口
*
* 整份 known_hosts 的维护动作导入 / 导出 / 清空是低频操作收进工具条的
* 已知主机下拉`KnownHostsMenu`不占页面主线
*/
import { computed, ref, watch } from 'vue'
import { open as openDialog, save as saveDialog } from '@tauri-apps/plugin-dialog'
import {
AlertTriangle,
ArrowDown,
ArrowUp,
Download,
@@ -51,14 +66,24 @@ import {
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Card, CardContent } from '@/components/ui/card'
import { StateBlock, StatusBadge } from '@/components/common'
import { createLogger } from '@/lib/logger'
import { blankHost, type SshHost } from './hostForm'
import type { HostView } from '@/types/terminal'
import HostTrustBadge from './HostTrustBadge.vue'
import KnownHostsMenu from './KnownHostsMenu.vue'
import OrphanKnownHosts from './OrphanKnownHosts.vue'
import { addressLabel, groupByAddress, recordsForHost, trustState } from './hostTrust'
import PageToolbar from '@/components/layout/PageToolbar.vue'
import type { HostView, KnownHost } from '@/types/terminal'
const logger = createLogger('terminal')
const store = useTerminalStore()
const emit = defineEmits<{ (e: 'close'): void; (e: 'connect', hostId: string): void }>()
// connect Tabs
// Tabs close
const emit = defineEmits<{ (e: 'connect', hostId: string): void }>()
// ===== =====
@@ -237,6 +262,45 @@ async function toggleFavorite(v: HostView) {
/** 可用密钥(供认证方式下拉使用) */
const availableKeys = computed(() => store.keys.filter(k => k.fileExists))
// ===== known_hosts =====
/**
* 记录按地址归组一次供本页反复查每行徽标 + 详情 popover + 顶部提示
* 匹配规则与孤儿判定共用 `hostTrust.ts` 里的同一套实现 分头实现迟早
* 会出现徽标说已信任点开却找不到记录这类自相矛盾
*/
const groupedKnownHosts = computed(() => groupByAddress(store.knownHosts))
/** 某台主机对应的指纹记录(可能为空 = 还没成功连过) */
function recordsOf(v: HostView): KnownHost[] {
return recordsForHost(groupedKnownHosts.value, v.config)
}
/** 指纹曾变更过的主机数(顶部风险提示的数据源) */
const changedHostCount = computed(
() => store.hosts.filter(v => trustState(recordsOf(v)) === 'changed').length
)
// ===== popover =====
const pendingForget = ref<KnownHost | null>(null)
const forgetBusy = ref(false)
async function submitForget() {
const r = pendingForget.value
if (!r) return
forgetBusy.value = true
try {
await store.forgetHost(r.host, r.port, r.keyType)
toast.success(`已忘记 ${addressLabel(r.host, r.port)}`)
pendingForget.value = null
} catch (e) {
toast.error(`操作失败:${String(e)}`)
} finally {
forgetBusy.value = false
}
}
// ===== ProxyJump=====
/**
@@ -280,129 +344,175 @@ function moveHop(i: number, dir: -1 | 1) {
</script>
<template>
<div class="flex flex-col h-full">
<!-- 头部 -->
<div class="shrink-0 flex items-center gap-2 px-4 h-12 border-b border-border">
<Globe class="size-4 text-muted-foreground" />
<h3 class="text-sm font-medium">SSH 主机</h3>
<span class="text-xs text-muted-foreground">({{ store.hosts.length }})</span>
<div class="flex-1" />
<Button variant="outline" size="sm" class="h-7 gap-1.5 text-xs" @click="importHosts">
<div class="flex flex-col h-full overflow-hidden">
<PageToolbar :icon="Globe" title="SSH 主机" :meta="`共 ${store.hosts.length} 台`">
<KnownHostsMenu />
<Button variant="outline" size="sm" @click="importHosts">
<Upload class="size-3.5" />导入备份
</Button>
<Button variant="outline" size="sm" class="h-7 gap-1.5 text-xs" @click="exportHosts">
<Button variant="outline" size="sm" @click="exportHosts">
<Download class="size-3.5" />导出
</Button>
<Button variant="outline" size="sm" class="h-7 gap-1.5 text-xs" @click="importConfig">
<Button variant="outline" size="sm" @click="importConfig">
<Download class="size-3.5" />导入 ~/.ssh/config
</Button>
<Button size="sm" class="h-7 gap-1.5 text-xs" @click="openNew">
<Button size="sm" @click="openNew">
<Plus class="size-3.5" />新建主机
</Button>
</div>
</PageToolbar>
<!-- 列表 -->
<div class="flex-1 min-h-0 overflow-y-auto p-3">
<div v-if="store.hosts.length === 0" class="py-16 text-center">
<Globe class="size-8 mx-auto text-muted-foreground/30 mb-3" />
<p class="text-sm text-muted-foreground mb-1">还没有配置 SSH 主机</p>
<p class="text-xs text-muted-foreground/70 mb-4">
可以手工新建也可以直接从 <code class="px-1 rounded bg-muted">~/.ssh/config</code>
<!-- 风险提示有主机指纹变更时始终可见信任详情 popover 是同一条信息的展开 -->
<div
v-if="changedHostCount > 0"
class="shrink-0 mb-3 flex items-start gap-2 p-2.5 rounded-md
border border-warning/40 bg-warning-soft"
>
<AlertTriangle class="size-3.5 shrink-0 mt-0.5 text-warning" />
<div class="text-[11px] text-warning-text">
<p class="font-medium">{{ changedHostCount }} 台主机曾发生指纹变更</p>
<p class="mt-0.5 opacity-90">
若不是您本人重装或更换了服务器密钥请联系管理员核实点主机行的信任徽标可查看详情
</p>
<div class="flex items-center justify-center gap-2">
<Button size="sm" class="gap-1.5" @click="openNew">
<Plus class="size-3.5" />新建主机
</Button>
<Button variant="outline" size="sm" class="gap-1.5" @click="importConfig">
<Download class="size-3.5" />导入配置
</Button>
</div>
</div>
<div v-else class="space-y-1">
<div
v-for="v in store.hosts"
:key="v.config.id"
class="group flex items-center gap-3 px-3 py-2 rounded-md border border-border
hover:border-primary/40 hover:bg-accent/30 transition-colors"
>
<button
class="shrink-0 size-6 rounded flex items-center justify-center transition-colors"
:class="
v.config.favorited
? 'text-amber-500'
: 'text-muted-foreground/30 hover:text-amber-500'
"
:title="v.config.favorited ? '取消收藏' : '收藏'"
@click="toggleFavorite(v)"
>
<Star class="size-3.5" :class="{ 'fill-current': v.config.favorited }" />
</button>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span class="text-sm font-medium truncate">
{{ v.config.name || v.config.host }}
</span>
<span v-if="v.config.group" class="text-[10px] px-1.5 py-0.5 rounded bg-muted shrink-0">
{{ v.config.group }}
</span>
<!-- 不可连接时给出明确原因而不是让用户点下去才发现失败 -->
<span
v-if="!v.ready"
class="text-[10px] px-1.5 py-0.5 rounded bg-amber-500/15 text-amber-600 dark:text-amber-400 shrink-0"
:title="v.issue ?? ''"
>
{{ v.issue || '配置不完整' }}
</span>
</div>
<div class="text-xs text-muted-foreground truncate mt-0.5">
{{ v.config.username }}@{{ v.config.host
}}<span v-if="v.config.port !== 22">:{{ v.config.port }}</span>
<span class="mx-1.5">·</span>
<span v-if="v.config.authMethod === 'key' && v.config.keyId">
<KeyRound class="inline size-2.5 -mt-0.5" />
{{ store.keys.find(k => k.meta.id === v.config.keyId)?.meta.name ?? '密钥' }}
</span>
<span v-else-if="v.config.authMethod === 'password'">
<!-- 不回显掩码密码不是 API key保留前 3 4 也是在泄露真实内容
是否已保存由 hasPassword 表达明文/掩码一律不出现在列表 -->
{{ v.hasPassword ? '密码已保存' : '密码未保存' }}
</span>
<span v-else>{{ v.config.authMethod }}</span>
</div>
</div>
<div class="shrink-0 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
size="sm"
class="h-7 text-xs"
:disabled="!v.ready"
:title="v.ready ? '连接' : (v.issue ?? '')"
@click="emit('connect', v.config.id)"
>
连接
</Button>
<Button variant="ghost" size="icon" class="size-7" title="编辑" @click="openEdit(v.config.id)">
<Pencil class="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="size-7 text-destructive hover:text-destructive"
title="删除"
@click="remove(v)"
>
<Trash2 class="size-3.5" />
</Button>
</div>
</div>
</div>
</div>
<!--
列表卡片承载 proxy 订阅列表同口径行间距 8px
`-mx-3` + 内层 `px-3 pb-3`滚动视口会裁掉超出自身盒子的内容卡片阴影
shadow-sm贴着视口边缘就会被切一半 表现为终端模块的卡片比别的
页面扁负外边距把视口向两侧各外扩 12px内层等量内缩卡片位置与宽度
不变却留出了阴影余量**内边距必须在内层**根元素上的 pb 不属于滚动内容
滚到底时最后一张卡片的底部阴影仍会被裁同一份说明见 TerminalSettingsPanel
-->
<ScrollArea class="flex-1 min-h-0 -mx-3">
<div class="px-3 pb-3">
<Card v-if="store.hosts.length === 0">
<CardContent>
<StateBlock
variant="empty"
title="还没有配置 SSH 主机"
description="可以手工新建,也可以直接从 ~/.ssh/config 导入"
:min-height="220"
>
<template #action>
<div class="flex items-center justify-center gap-2">
<Button size="sm" class="gap-1.5" @click="openNew">
<Plus class="size-3.5" />新建主机
</Button>
<Button variant="outline" size="sm" class="gap-1.5" @click="importConfig">
<Download class="size-3.5" />导入配置
</Button>
</div>
</template>
</StateBlock>
</CardContent>
</Card>
<Card v-else>
<CardContent class="space-y-2">
<div
v-for="v in store.hosts"
:key="v.config.id"
class="group flex items-center gap-3 rounded-md border border-border p-3 transition-colors hover:bg-hover"
>
<button
class="shrink-0 size-7 rounded flex items-center justify-center transition-colors"
:class="
v.config.favorited
? 'text-warning'
: 'text-muted-foreground/30 hover:text-warning'
"
:title="v.config.favorited ? '取消收藏' : '收藏'"
@click="toggleFavorite(v)"
>
<Star class="size-3.5" :class="{ 'fill-current': v.config.favorited }" />
</button>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span class="text-sm font-medium truncate">
{{ v.config.name || v.config.host }}
</span>
<span v-if="v.config.group" class="shrink-0 text-[11px] text-muted-foreground">
{{ v.config.group }}
</span>
<!-- 不可连接时给出明确原因而不是让用户点下去才发现失败 -->
<StatusBadge
v-if="!v.ready"
variant="warning"
size="sm"
:title="v.issue ?? ''"
>
{{ v.issue || '配置不完整' }}
</StatusBadge>
<!--
主机密钥信任徽标即查看详情的入口有记录时才可点
详情与忘记都在 HostTrustBadge 行内只传记录集合
-->
<HostTrustBadge
:label="v.config.name || v.config.host"
:address="addressLabel(v.config.host, v.config.port)"
:records="recordsOf(v)"
@forget="pendingForget = $event"
/>
</div>
<div class="text-xs text-muted-foreground truncate mt-0.5">
{{ v.config.username }}@{{ v.config.host
}}<span v-if="v.config.port !== 22">:{{ v.config.port }}</span>
<span class="mx-1.5">·</span>
<span v-if="v.config.authMethod === 'key' && v.config.keyId">
<KeyRound class="inline size-2.5 -mt-0.5" />
{{ store.keys.find(k => k.meta.id === v.config.keyId)?.meta.name ?? '密钥' }}
</span>
<span v-else-if="v.config.authMethod === 'password'">
<!-- 不回显掩码密码不是 API key保留前 3 4 也是在泄露真实内容
是否已保存由 hasPassword 表达明文/掩码一律不出现在列表 -->
{{ v.hasPassword ? '密码已保存' : '密码未保存' }}
</span>
<span v-else>{{ v.config.authMethod }}</span>
</div>
</div>
<div class="shrink-0 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
size="sm"
:disabled="!v.ready"
:title="v.ready ? '连接' : (v.issue ?? '')"
@click="emit('connect', v.config.id)"
>
连接
</Button>
<Button variant="ghost" size="icon-sm" title="编辑" @click="openEdit(v.config.id)">
<Pencil class="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
class="text-destructive hover:text-destructive"
title="删除"
@click="remove(v)"
>
<Trash2 class="size-3.5" />
</Button>
</div>
</div>
</CardContent>
</Card>
<!--
没有对应主机配置的指纹记录配置已删 / 从未保存过配置
放在主机列表之后的**同一滚动区**它属于同一份 known_hosts 的另一半
独立成一屏会让用户在两个位置之间来回找
-->
<OrphanKnownHosts @forget="pendingForget = $event" />
</div>
</ScrollArea>
<!-- ===== 编辑对话框 ===== -->
<Dialog v-model:open="dialogOpen">
<DialogContent class="max-w-xl max-h-[85vh] overflow-y-auto">
<DialogContent class="max-w-xl max-h-[85vh]">
<DialogHeader>
<DialogTitle>{{ isNew ? '新建 SSH 主机' : '编辑 SSH 主机' }}</DialogTitle>
<DialogDescription class="text-xs">
@@ -410,7 +520,14 @@ function moveHop(i: number, dir: -1 | 1) {
</DialogDescription>
</DialogHeader>
<div v-if="editing" class="space-y-4">
<!--
表单很长基本信息 + 认证 + 可展开的高级选项必须滚动
ScrollArea 而不是给 DialogContent overflow-y-auto后者会带上
浏览器原生滚动条系统样式与主题不一致项目其余长对话框
monitor / downloader都用 ScrollArea + max-h 的写法
-->
<ScrollArea class="max-h-[60vh] pr-4">
<div v-if="editing" class="space-y-4">
<!-- 基本信息 -->
<div class="grid grid-cols-2 gap-3">
<div class="space-y-1.5">
@@ -476,7 +593,7 @@ function moveHop(i: number, dir: -1 | 1) {
</SelectContent>
</Select>
<p v-if="errors.keyId" class="text-[11px] text-destructive">{{ errors.keyId }}</p>
<p v-if="availableKeys.length === 0" class="text-[11px] text-amber-600 dark:text-amber-400">
<p v-if="availableKeys.length === 0" class="text-[11px] text-warning">
还没有可用密钥请先到密钥管理生成或导入
</p>
</div>
@@ -637,9 +754,21 @@ function moveHop(i: number, dir: -1 | 1) {
</div>
<Switch v-model="editing.favorited" />
</div>
<div class="flex items-center justify-between gap-3">
<div>
<Label class="text-xs">注入 shell 集成</Label>
<p class="text-[11px] text-muted-foreground">
让远端上报命令与目录命令历史与 cwd 跟踪才可用不落盘只影响本次会话
登录后执行 exec shell 的配置冲突时可关掉
</p>
</div>
<Switch v-model="editing.injectShellIntegration" />
</div>
</div>
</details>
</div>
</div>
</ScrollArea>
<DialogFooter>
<Button variant="outline" size="sm" @click="dialogOpen = false">取消</Button>
@@ -667,5 +796,38 @@ function moveHop(i: number, dir: -1 | 1) {
</DialogFooter>
</DialogContent>
</Dialog>
<!--
===== 忘记主机密钥指纹记录 =====
对话框放在页级而不是信任 popover 内部popover 会因失焦关闭
而确认框是用户明确同意降低校验强度的关键一步不能被关在中间
-->
<Dialog :open="pendingForget !== null" @update:open="v => !v && (pendingForget = null)">
<DialogContent class="max-w-sm" :show-close-button="false">
<DialogHeader>
<DialogTitle class="text-base">忘记该主机</DialogTitle>
<DialogDescription class="text-xs">
将删除
<span class="font-mono">
{{ pendingForget ? addressLabel(pendingForget.host, pendingForget.port) : '' }}
</span>
<span class="font-mono">{{ pendingForget?.keyType }}</span>
指纹记录下次连接时将重新提示核对
</DialogDescription>
</DialogHeader>
<div class="flex items-start gap-2 p-2 rounded bg-muted text-[11px] text-muted-foreground">
<AlertTriangle class="size-3.5 shrink-0 mt-0.5" />
<span>仅在确认该主机密钥确实已更换时执行若出于连不上就删掉的动机请先核实原因</span>
</div>
<DialogFooter>
<Button variant="outline" size="sm" @click="pendingForget = null">取消</Button>
<Button variant="destructive" size="sm" :disabled="forgetBusy" @click="submitForget">
<Loader2 v-if="forgetBusy" class="size-3.5 mr-1.5 animate-spin" />
忘记
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</template>
@@ -0,0 +1,71 @@
<script setup lang="ts">
/**
* 主机行的主机密钥信任徽标 + 详情 popover
*
* # 为什么单独成一个组件
*
* 主机行本身已经很长收藏名称分组可连接性认证方式四个操作按钮
* 信任这块要读三样东西记录集合状态变更次数并渲染两层结构徽标 +
* popover塞进主机行的模板会让那一行难以阅读也会让同一批判断在模板里
* 重复出现好几次抽出来后主机行只需要传 `records` 一个 prop
*
* # 徽标即入口
*
* 有记录时徽标本身可点开不再额外挂一个盾牌图标按钮徽标已经表达了状态
* 再放一个图标是同一件事占两份宽度没有记录时用中性徽标未连接过
* 且不可点 没有内容可看可点只会让人白点一次
*/
import { computed } from 'vue'
import { History, ShieldCheck } from '@lucide/vue'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { StatusBadge } from '@/components/common'
import HostTrustPopover from './HostTrustPopover.vue'
import { changeCount, trustState } from './hostTrust'
import type { KnownHost } from '@/types/terminal'
const props = defineProps<{
/** 主机显示名(配置里 name 为空时传地址) */
label: string
/** 地址:端口 */
address: string
/** 该主机的全部指纹记录(空 = 从未成功连过) */
records: KnownHost[]
}>()
const emit = defineEmits<{ (e: 'forget', record: KnownHost): void }>()
const state = computed(() => trustState(props.records))
const changes = computed(() => changeCount(props.records))
</script>
<template>
<Popover v-if="records.length > 0">
<PopoverTrigger as-child>
<button class="shrink-0" title="查看主机密钥指纹与变更历史">
<StatusBadge :variant="state === 'changed' ? 'warning' : 'success'" size="sm">
<History v-if="state === 'changed'" class="size-2.5" />
<ShieldCheck v-else class="size-2.5" />
{{ state === 'changed' ? `指纹变更 ${changes}` : '已信任' }}
</StatusBadge>
</button>
</PopoverTrigger>
<!-- 内容自带内边距这里把 popover 默认的 w-72 / p-4 让开避免双重留白 -->
<PopoverContent align="start" class="w-auto p-0">
<HostTrustPopover
:label="label"
:address="address"
:records="records"
@forget="emit('forget', $event)"
/>
</PopoverContent>
</Popover>
<StatusBadge
v-else
variant="neutral"
size="sm"
title="还没有该主机的指纹记录,首次连接时会提示核对"
>
未连接过
</StatusBadge>
</template>
@@ -0,0 +1,148 @@
<script setup lang="ts">
/**
* 单台主机的信任详情popover挂在主机行的盾牌按钮上
*
* # 为什么用 popover 而不是新页面
*
* 信任这件事的信息量是一台主机的几条指纹 + 变更历史属于**行级细节**
* 用户是在浏览主机列表时顺手核对一下这台我信任过吗变过没有
* 以前它占着凭据页的一整个分区于是看主机的信任状态要跨越两个 tab
* 而每个 tab 里都只有半张信息现在它与主机挤在同一行上
*
* # 为什么忘记要抛给父组件
*
* 忘记是**破坏性且带安全含义**的操作删掉指纹后下次连接会在没有校验的情况下
* 重新提示必须有一个明确的确认对话框对话框若挂在 popover 内部
* popover 会先因失焦关闭再把对话框弹在一个已经不存在的锚点上下文里
* 焦点与层级的边界问题不值得为省一次事件传递而承担
* 因此这里只负责选出要忘记的那条记录确认与执行由主机页统一处理
*/
import { computed, ref } from 'vue'
import { AlertTriangle, History, Server, ShieldCheck, Trash2 } from '@lucide/vue'
import { Button } from '@/components/ui/button'
import { StatusBadge } from '@/components/common'
import {
changeCount,
formatDateTime,
keyTypeLabel,
recordKey,
trustState
} from './hostTrust'
import type { KnownHost } from '@/types/terminal'
const props = defineProps<{
/** 主机显示名(配置里的 name,可能为空 → 调用方传地址兜底) */
label: string
/** 地址:端口(非 22 才带端口) */
address: string
/** 该主机的全部 known_hosts 记录(含多种算法) */
records: KnownHost[]
}>()
const emit = defineEmits<{ (e: 'forget', record: KnownHost): void }>()
const state = computed(() => trustState(props.records))
const changes = computed(() => changeCount(props.records))
/** 展开变更历史的记录键(同一时刻只展开一条,popover 空间有限) */
const expanded = ref<string | null>(null)
function toggleHistory(r: KnownHost) {
const k = recordKey(r)
expanded.value = expanded.value === k ? null : k
}
</script>
<template>
<div class="w-[340px] p-3 space-y-3">
<!-- 头部目标 + 总体状态 -->
<div class="flex items-start gap-2">
<Server class="size-3.5 shrink-0 mt-0.5 text-muted-foreground" />
<div class="min-w-0 flex-1">
<p class="text-xs font-medium truncate" :title="label">{{ label }}</p>
<p class="text-[11px] font-mono text-muted-foreground truncate">{{ address }}</p>
</div>
<StatusBadge v-if="state === 'changed'" variant="warning" size="sm">
<History class="size-2.5" />变更 {{ changes }}
</StatusBadge>
<StatusBadge v-else variant="success" size="sm">
<ShieldCheck class="size-2.5" />已信任
</StatusBadge>
</div>
<div v-if="changes > 0" class="flex items-start gap-2 p-2 rounded bg-warning-soft">
<AlertTriangle class="size-3.5 shrink-0 mt-0.5 text-warning" />
<p class="text-[11px] text-warning-text leading-relaxed">
这台主机的主机密钥曾发生变更若不是您本人重装或更换了服务端密钥
请联系管理员核实后再继续使用
</p>
</div>
<!-- 逐算法列出指纹 -->
<div class="space-y-2">
<div
v-for="r in records"
:key="recordKey(r)"
class="rounded-md border border-border p-2"
>
<div class="flex items-center gap-2">
<span class="text-[11px] font-medium">{{ keyTypeLabel(r.keyType) }}</span>
<div class="flex-1" />
<button
v-if="r.history.length > 0"
class="text-[10px] text-warning-text hover:underline shrink-0"
@click="toggleHistory(r)"
>
变更 {{ r.history.length }}
{{ expanded === recordKey(r) ? '▴' : '▾' }}
</button>
<Button
variant="ghost"
size="icon-sm"
class="shrink-0 text-muted-foreground hover:text-destructive"
title="忘记该主机(删除这条指纹记录)"
@click="emit('forget', r)"
>
<Trash2 class="size-3" />
</Button>
</div>
<p class="mt-1 text-[11px] font-mono text-muted-foreground break-all">
{{ r.fingerprint }}
</p>
<div class="mt-1 flex items-center gap-3 text-[10px] text-muted-foreground/80">
<span>首次 {{ formatDateTime(r.firstSeen) }}</span>
<span>最近确认 {{ formatDateTime(r.lastConfirmed) }}</span>
</div>
<!-- 变更历史展开后按时间倒序后端已是倒序这里不再排序 -->
<div
v-if="expanded === recordKey(r)"
class="mt-2 pt-2 border-t border-border space-y-1.5"
>
<div v-for="(c, i) in r.history" :key="i" class="flex items-start gap-1.5">
<span
class="shrink-0 mt-0.5 size-1.5 rounded-full"
:class="c.accepted ? 'bg-warning' : 'bg-muted-foreground/40'"
/>
<div class="min-w-0 text-[10px]">
<span class="text-muted-foreground">{{ formatDateTime(c.changedAt) }}</span>
<span class="mx-1">·</span>
<span :class="c.accepted ? 'text-warning-text' : 'text-muted-foreground'">
{{ c.accepted ? '已接受变更' : '已拒绝变更' }}
</span>
<p class="font-mono text-muted-foreground/70 break-all" :title="c.oldFingerprint">
原指纹 {{ c.oldFingerprint }}
</p>
</div>
</div>
</div>
</div>
</div>
<p class="text-[10px] text-muted-foreground/70 leading-relaxed">
核对指纹应与服务器管理员提供的值一致指纹不同且无法解释时请不要连接
</p>
</div>
</template>

Some files were not shown because too many files have changed in this diff Show More