UI/细节调整
This commit is contained in:
@@ -794,6 +794,394 @@ ZMODEM 剩余价值主要在串口/老旧嵌入式设备。成本收益不成立
|
|||||||
3. 连接复用:同主机双标签秒连、关一个另一个不受影响、全关后连接断开。
|
3. 连接复用:同主机双标签秒连、关一个另一个不受影响、全关后连接断开。
|
||||||
4. ProxyJump 跳板链、端口转发 -L/-R、`.ppk` 导入、会话模板拉起、AI 助手(需翻译引擎配置)。
|
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.00s;TerminalModule 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 只认 8–40 → 静默改回 14 | UI 收敛到 8–40 |
|
||||||
|
| 快捷键表两侧不一致 | `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 新增命令清单(供前端对接与后续维护)
|
## 附:P1 新增命令清单(供前端对接与后续维护)
|
||||||
|
|
||||||
| 命令 | 参数 | 返回 |
|
| 命令 | 参数 | 返回 |
|
||||||
|
|||||||
@@ -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.42–0.58`(浅)/ `0.68–0.80`(深),C 限制在 `0.06–0.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) | 左栏 280–320px·右栏 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 ≠ selected:hover 表达"可以点",selected 表达"已经选中",后者才用强调色。
|
||||||
|
|
||||||
|
**状态点**
|
||||||
|
|
||||||
|
```
|
||||||
|
8px 圆 · border-radius: full · 底色 = 对应 --<state>
|
||||||
|
与文字间距 6px · 文字用 body 或 label
|
||||||
|
```
|
||||||
|
可选呼吸动效(运行中):`opacity` 1↔0.55,1.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 章。
|
||||||
@@ -13,6 +13,13 @@
|
|||||||
"@tauri-apps/plugin-global-shortcut": "^2",
|
"@tauri-apps/plugin-global-shortcut": "^2",
|
||||||
"@tauri-apps/plugin-opener": "^2",
|
"@tauri-apps/plugin-opener": "^2",
|
||||||
"@vueuse/core": "^14.4.0",
|
"@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",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
@@ -28,6 +35,9 @@
|
|||||||
"@tauri-apps/cli": "^2",
|
"@tauri-apps/cli": "^2",
|
||||||
"@types/node": "^26.1.1",
|
"@types/node": "^26.1.1",
|
||||||
"@vitejs/plugin-vue": "^5.2.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",
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "~5.6.2",
|
"typescript": "~5.6.2",
|
||||||
"vite": "^6.0.3",
|
"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=="],
|
"@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/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=="],
|
"@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=="],
|
"@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/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=="],
|
"@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=="],
|
"@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/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=="],
|
"@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=="],
|
"@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=="],
|
"@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-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=="],
|
"@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=="],
|
"@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/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/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/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=="],
|
"@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=="],
|
"@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=="],
|
"@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/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/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=="],
|
"@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=="],
|
"@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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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": ["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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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-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=="],
|
"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=="],
|
"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": ["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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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": ["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=="],
|
"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=="],
|
"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=="],
|
"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-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-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=="],
|
"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/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=="],
|
"@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=="],
|
"@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/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=="],
|
"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=="],
|
"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=="],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 原生色阶:请使用语义 token(bg-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
@@ -8,8 +8,9 @@
|
|||||||
"build": "vue-tsc --noEmit && node --max-old-space-size=8192 node_modules/vite/bin/vite.js build",
|
"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",
|
"build:vite": "node --max-old-space-size=8192 node_modules/vite/bin/vite.js build",
|
||||||
"typecheck": "vue-tsc --noEmit",
|
"typecheck": "vue-tsc --noEmit",
|
||||||
|
"lint": "eslint src",
|
||||||
"preview": "vite preview",
|
"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"
|
"tauri": "tauri"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -43,6 +44,9 @@
|
|||||||
"@tauri-apps/cli": "^2",
|
"@tauri-apps/cli": "^2",
|
||||||
"@types/node": "^26.1.1",
|
"@types/node": "^26.1.1",
|
||||||
"@vitejs/plugin-vue": "^5.2.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",
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "~5.6.2",
|
"typescript": "~5.6.2",
|
||||||
"vite": "^6.0.3",
|
"vite": "^6.0.3",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
"dialog:default",
|
"dialog:default",
|
||||||
"core:window:allow-minimize",
|
"core:window:allow-minimize",
|
||||||
"core:window:allow-maximize",
|
"core:window:allow-maximize",
|
||||||
|
"core:window:allow-unminimize",
|
||||||
"core:window:allow-close",
|
"core:window:allow-close",
|
||||||
"core:window:allow-toggle-maximize",
|
"core:window:allow-toggle-maximize",
|
||||||
"core:window:allow-hide",
|
"core:window:allow-hide",
|
||||||
@@ -26,6 +27,9 @@
|
|||||||
"core:window:allow-set-always-on-top",
|
"core:window:allow-set-always-on-top",
|
||||||
"core:window:allow-set-skip-taskbar",
|
"core:window:allow-set-skip-taskbar",
|
||||||
"core:window:allow-set-decorations",
|
"core:window:allow-set-decorations",
|
||||||
|
"global-shortcut:allow-register",
|
||||||
|
"global-shortcut:allow-unregister",
|
||||||
|
"global-shortcut:allow-is-registered",
|
||||||
"snap-layout:default"
|
"snap-layout:default"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -5,11 +5,13 @@
|
|||||||
"windows": ["terminal-window-*"],
|
"windows": ["terminal-window-*"],
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"core:default",
|
"core:default",
|
||||||
|
"core:window:allow-show",
|
||||||
"core:window:allow-close",
|
"core:window:allow-close",
|
||||||
"core:window:allow-minimize",
|
"core:window:allow-minimize",
|
||||||
"core:window:allow-maximize",
|
"core:window:allow-maximize",
|
||||||
"core:window:allow-toggle-maximize",
|
"core:window:allow-toggle-maximize",
|
||||||
"core:window:allow-set-focus",
|
"core:window:allow-set-focus",
|
||||||
|
"core:window:allow-set-title",
|
||||||
"core:window:allow-start-dragging",
|
"core:window:allow-start-dragging",
|
||||||
"core:window:allow-set-theme",
|
"core:window:allow-set-theme",
|
||||||
"core:window:allow-set-background-color",
|
"core:window:allow-set-background-color",
|
||||||
|
|||||||
@@ -625,8 +625,9 @@ pub fn hide_preview(app: &AppHandle) {
|
|||||||
/// - 交互锁定模式(点击过预览窗的放大/缩小/复制等):弹窗已失焦不会再收到
|
/// - 交互锁定模式(点击过预览窗的放大/缩小/复制等):弹窗已失焦不会再收到
|
||||||
/// Focused(false),检测"鼠标按下沿且按下位置在弹窗+预览之外"→ 点击外部,关闭弹窗。
|
/// Focused(false),检测"鼠标按下沿且按下位置在弹窗+预览之外"→ 点击外部,关闭弹窗。
|
||||||
///
|
///
|
||||||
/// 公共退出条件:弹窗已隐藏/销毁(其他路径关闭);弹窗重新获得焦点(用户点击
|
/// 退出条件:弹窗已隐藏/销毁(其他路径关闭);弹窗重新获得焦点(用户点击
|
||||||
/// 弹窗,恢复正常失焦关闭路径,并复位交互锁定);超时兜底防线程泄漏
|
/// 弹窗,恢复正常失焦关闭路径,并复位交互锁定);**光标回到弹窗内并停留片刻
|
||||||
|
/// (交互锁定解除,但看护继续以常规模式运行)**;超时兜底防线程泄漏
|
||||||
/// (常规 30s;交互锁定为事件驱动,放宽至 10min)。
|
/// (常规 30s;交互锁定为事件驱动,放宽至 10min)。
|
||||||
fn spawn_popup_leave_watch(app: AppHandle) {
|
fn spawn_popup_leave_watch(app: AppHandle) {
|
||||||
// 防重入:已有看护在运行则跳过
|
// 防重入:已有看护在运行则跳过
|
||||||
@@ -634,9 +635,12 @@ fn spawn_popup_leave_watch(app: AppHandle) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
let start = Instant::now();
|
// 超时兜底的起点:交互锁定解除时重置(那之后按常规模式的 30s 计)
|
||||||
|
let mut start = Instant::now();
|
||||||
let mut outside = 0u32;
|
let mut outside = 0u32;
|
||||||
let mut was_down = false;
|
let mut was_down = false;
|
||||||
|
// 交互锁定期间光标停在弹窗内的连续轮数(30ms/轮)
|
||||||
|
let mut back_in_popup = 0u32;
|
||||||
loop {
|
loop {
|
||||||
std::thread::sleep(Duration::from_millis(30));
|
std::thread::sleep(Duration::from_millis(30));
|
||||||
// 弹窗已隐藏或被销毁:看护结束
|
// 弹窗已隐藏或被销毁:看护结束
|
||||||
@@ -654,6 +658,33 @@ fn spawn_popup_leave_watch(app: AppHandle) {
|
|||||||
POPUP_INTERACTED.store(false, Ordering::SeqCst);
|
POPUP_INTERACTED.store(false, Ordering::SeqCst);
|
||||||
break;
|
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 interacted = POPUP_INTERACTED.load(Ordering::SeqCst);
|
||||||
// 超长兜底:避免异常路径下看护线程无限轮询
|
// 超长兜底:避免异常路径下看护线程无限轮询
|
||||||
let timeout = if interacted {
|
let timeout = if interacted {
|
||||||
|
|||||||
@@ -94,6 +94,12 @@ pub mod events {
|
|||||||
// 终端模块:SFTP 传输进度(负载见 terminal::ssh::sftp::TransferProgress)
|
// 终端模块:SFTP 传输进度(负载见 terminal::ssh::sftp::TransferProgress)
|
||||||
// 节流后发送(约 200ms 一次),前端据此画进度条
|
// 节流后发送(约 200ms 一次),前端据此画进度条
|
||||||
pub const TERMINAL_TRANSFER_PROGRESS: &str = "terminal-transfer-progress";
|
pub const TERMINAL_TRANSFER_PROGRESS: &str = "terminal-transfer-progress";
|
||||||
|
// 终端模块:设置已保存(无负载)。
|
||||||
|
//
|
||||||
|
// 终端设置存在 Rust 侧,而每个窗口(主窗口 / 终端独立窗口 / 选择窗口)各有
|
||||||
|
// 一份前端副本 —— 没有这个事件,在主窗口改完外观后,已打开的独立窗口会一直
|
||||||
|
// 用旧配色,直到重开窗口。
|
||||||
|
pub const TERMINAL_SETTINGS: &str = "terminal-settings";
|
||||||
// 音乐模块:下载任务事件(桥接事件行 → 前端,负载见 bridge.py _emit_event)
|
// 音乐模块:下载任务事件(桥接事件行 → 前端,负载见 bridge.py _emit_event)
|
||||||
pub const MUSIC_DOWNLOAD_EVENT: &str = "music-download-event";
|
pub const MUSIC_DOWNLOAD_EVENT: &str = "music-download-event";
|
||||||
// 后端自动切换节点完成(前端据以刷新节点列表并提示)
|
// 后端自动切换节点完成(前端据以刷新节点列表并提示)
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ use std::collections::HashMap;
|
|||||||
|
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use specta::Type;
|
use specta::Type;
|
||||||
use tauri::{AppHandle, Manager};
|
use tauri::{AppHandle, Emitter, Manager};
|
||||||
|
|
||||||
use super::assistant;
|
use super::assistant;
|
||||||
use super::audit;
|
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]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
@@ -86,6 +95,7 @@ pub async fn terminal_save_settings(
|
|||||||
) -> Result<TerminalSettings, String> {
|
) -> Result<TerminalSettings, String> {
|
||||||
let m = manager(&app)?;
|
let m = manager(&app)?;
|
||||||
m.save_settings(settings.clone())?;
|
m.save_settings(settings.clone())?;
|
||||||
|
notify_settings_changed(&app);
|
||||||
Ok(m.settings())
|
Ok(m.settings())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,7 +107,9 @@ pub async fn terminal_save_appearance(
|
|||||||
appearance: AppearanceSettings,
|
appearance: AppearanceSettings,
|
||||||
) -> Result<TerminalSettings, String> {
|
) -> Result<TerminalSettings, String> {
|
||||||
let m = manager(&app)?;
|
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,
|
layout: LayoutSettings,
|
||||||
) -> Result<TerminalSettings, String> {
|
) -> Result<TerminalSettings, String> {
|
||||||
let m = manager(&app)?;
|
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,
|
selection: TerminalSelectionSettings,
|
||||||
) -> Result<TerminalSettings, String> {
|
) -> Result<TerminalSettings, String> {
|
||||||
let m = manager(&app)?;
|
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,
|
security: SecuritySettings,
|
||||||
) -> Result<TerminalSettings, String> {
|
) -> Result<TerminalSettings, String> {
|
||||||
let m = manager(&app)?;
|
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()));
|
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 =====
|
// ===== 本地 Shell =====
|
||||||
@@ -283,7 +303,9 @@ pub async fn terminal_open_local(
|
|||||||
app.clone(),
|
app.clone(),
|
||||||
id.clone(),
|
id.clone(),
|
||||||
profile.id.clone(),
|
profile.id.clone(),
|
||||||
profile.name.clone(),
|
// 标签上的短名,与设置页的描述名(`profile.name`)刻意分开:
|
||||||
|
// 描述名可编辑、可很长,而标签要能一行放下多个会话(见 `session_label`)
|
||||||
|
shell::session_label(&profile),
|
||||||
command_line,
|
command_line,
|
||||||
work_dir,
|
work_dir,
|
||||||
shell::env_pairs(&profile.env),
|
shell::env_pairs(&profile.env),
|
||||||
@@ -467,6 +489,7 @@ pub async fn terminal_open_ssh(
|
|||||||
},
|
},
|
||||||
jump_chain: jump_hops,
|
jump_chain: jump_hops,
|
||||||
encoding: host.encoding.clone(),
|
encoding: host.encoding.clone(),
|
||||||
|
inject_shell_integration: host.inject_shell_integration,
|
||||||
};
|
};
|
||||||
|
|
||||||
let session = ssh::SshSession::spawn(app.clone(), id, params, m.ssh_pool.clone());
|
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]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub async fn terminal_rename_session(
|
pub async fn terminal_rename_session(
|
||||||
@@ -580,7 +609,7 @@ pub async fn terminal_rename_session(
|
|||||||
if t.is_empty() {
|
if t.is_empty() {
|
||||||
return Err("标题不能为空".to_string());
|
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)) {
|
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,
|
use_proxy: false,
|
||||||
jump_ids: Vec::new(),
|
jump_ids: Vec::new(),
|
||||||
encoding: "utf-8".to_string(),
|
encoding: "utf-8".to_string(),
|
||||||
|
inject_shell_integration: true,
|
||||||
});
|
});
|
||||||
imported += 1;
|
imported += 1;
|
||||||
}
|
}
|
||||||
@@ -1417,9 +1447,14 @@ pub async fn terminal_import_known_hosts(text: String) -> Result<ActionOutcome,
|
|||||||
///
|
///
|
||||||
/// 由前端在用户点击「接受」/「拒绝」后调用。会话侧的握手正在阻塞等待这个回传,
|
/// 由前端在用户点击「接受」/「拒绝」后调用。会话侧的握手正在阻塞等待这个回传,
|
||||||
/// 因此超时(120 秒)后会视为拒绝。
|
/// 因此超时(120 秒)后会视为拒绝。
|
||||||
|
///
|
||||||
|
/// `app` 只用于安全事件日志(`security.auditLog`)——用户的每个决定都要留痕:
|
||||||
|
/// 「谁在什么时候接受了新指纹」是事后审计里最关键的一条。specta 会把
|
||||||
|
/// `AppHandle` 参数从生成的 JS 签名里剔除,因此前端调用方式不变。
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub async fn terminal_confirm_host_key(
|
pub async fn terminal_confirm_host_key(
|
||||||
|
app: AppHandle,
|
||||||
session_id: String,
|
session_id: String,
|
||||||
accept: bool,
|
accept: bool,
|
||||||
host: String,
|
host: String,
|
||||||
@@ -1427,6 +1462,15 @@ pub async fn terminal_confirm_host_key(
|
|||||||
key_type: String,
|
key_type: String,
|
||||||
fingerprint: String,
|
fingerprint: String,
|
||||||
) -> Result<(), 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 {
|
if !accept {
|
||||||
// 记一次「拒绝」以便事后审计
|
// 记一次「拒绝」以便事后审计
|
||||||
hostkey::record_rejection(&host, port, &fingerprint, &key_type);
|
hostkey::record_rejection(&host, port, &fingerprint, &key_type);
|
||||||
|
|||||||
@@ -695,8 +695,8 @@ impl Session for ConPtySession {
|
|||||||
.emit(crate::terminal::events::TERMINAL_STATE, self.info());
|
.emit(crate::terminal::events::TERMINAL_STATE, self.info());
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_title(&self, title: &str) {
|
fn set_title_by_user(&self, title: &str) {
|
||||||
*self.state.title.lock().unwrap_or_else(|e| e.into_inner()) = title.to_string();
|
self.state.set_title_by_user(title);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_detached(&self, detached: bool) {
|
fn set_detached(&self, detached: bool) {
|
||||||
|
|||||||
@@ -131,8 +131,12 @@ pub trait Session: Send + Sync {
|
|||||||
/// 更新状态(供内部线程在握手/退出时调用)。
|
/// 更新状态(供内部线程在握手/退出时调用)。
|
||||||
fn set_state(&self, state: SessionState, error: Option<String>);
|
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);
|
fn set_detached(&self, detached: bool);
|
||||||
@@ -276,6 +280,17 @@ pub struct LocalSessionState {
|
|||||||
/// 没有这个字段就只能靠 `target_id` 的形式去猜,那是不可靠的。
|
/// 没有这个字段就只能靠 `target_id` 的形式去猜,那是不可靠的。
|
||||||
pub kind: SessionKind,
|
pub kind: SessionKind,
|
||||||
pub title: std::sync::Mutex<String>,
|
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 state: std::sync::Mutex<SessionState>,
|
||||||
pub error: std::sync::Mutex<Option<String>>,
|
pub error: std::sync::Mutex<Option<String>>,
|
||||||
pub cwd: std::sync::Mutex<String>,
|
pub cwd: std::sync::Mutex<String>,
|
||||||
@@ -313,6 +328,7 @@ impl LocalSessionState {
|
|||||||
target_id,
|
target_id,
|
||||||
kind,
|
kind,
|
||||||
title: std::sync::Mutex::new(title),
|
title: std::sync::Mutex::new(title),
|
||||||
|
title_locked: std::sync::atomic::AtomicBool::new(false),
|
||||||
state: std::sync::Mutex::new(SessionState::Idle),
|
state: std::sync::Mutex::new(SessionState::Idle),
|
||||||
error: std::sync::Mutex::new(None),
|
error: std::sync::Mutex::new(None),
|
||||||
cwd: std::sync::Mutex::new(String::new()),
|
cwd: std::sync::Mutex::new(String::new()),
|
||||||
@@ -329,6 +345,19 @@ impl LocalSessionState {
|
|||||||
matches!(self.kind, SessionKind::Ssh)
|
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 {
|
pub fn encoding(&self) -> String {
|
||||||
self.encoding
|
self.encoding
|
||||||
@@ -444,20 +473,34 @@ pub fn scan_control_sequences(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let Some(title) = titles.last() {
|
if let Some(title) = titles.last() {
|
||||||
let changed = {
|
// 两类标题一律丢弃,理由不同但都落在同一个判定点上:
|
||||||
let mut cur = state.title.lock().unwrap_or_else(|e| e.into_inner());
|
//
|
||||||
if *cur == *title {
|
// 1. 用户已重命名过(`title_locked`):标题所有权已交给用户,
|
||||||
false
|
// 否则下一条提示符就会把用户刚起的名字覆盖回去。
|
||||||
} else {
|
// 2. 标题是可执行文件全路径(`osc_title_is_executable_path`):
|
||||||
*cur = title.clone();
|
// cmd.exe / powershell.exe 启动时就是这么设控制台标题的,
|
||||||
true
|
// 把会话名顶成 `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),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,11 @@ use specta::Type;
|
|||||||
pub struct ShellProfile {
|
pub struct ShellProfile {
|
||||||
/// 唯一标识(同时是新建会话时的 shellKey)
|
/// 唯一标识(同时是新建会话时的 shellKey)
|
||||||
pub id: String,
|
pub id: String,
|
||||||
/// 展示名称,如 "PowerShell 7"
|
/// 展示名称,如 "PowerShell 7" / "Windows PowerShell"。
|
||||||
|
///
|
||||||
|
/// **设置页里的描述名,不是标签上显示的名字** —— 后者取
|
||||||
|
/// [`super::shell::session_label`](按可执行文件名归一的短名)。
|
||||||
|
/// 两者刻意分开:描述名可编辑、可很长,而标签要能一行放下多个会话。
|
||||||
pub name: String,
|
pub name: String,
|
||||||
/// 可执行文件绝对路径
|
/// 可执行文件绝对路径
|
||||||
pub path: String,
|
pub path: String,
|
||||||
@@ -147,6 +151,20 @@ pub struct SshHost {
|
|||||||
pub jump_ids: Vec<String>,
|
pub jump_ids: Vec<String>,
|
||||||
/// 终端的字符编码("utf-8" 默认 | "gbk" 等)。老服务器常见 GBK,中文环境刚需。
|
/// 终端的字符编码("utf-8" 默认 | "gbk" 等)。老服务器常见 GBK,中文环境刚需。
|
||||||
pub encoding: String,
|
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 {
|
impl Default for SshHost {
|
||||||
@@ -170,6 +188,8 @@ impl Default for SshHost {
|
|||||||
use_proxy: false,
|
use_proxy: false,
|
||||||
jump_ids: Vec::new(),
|
jump_ids: Vec::new(),
|
||||||
encoding: "utf-8".to_string(),
|
encoding: "utf-8".to_string(),
|
||||||
|
// 必须 true,理由见字段文档(容器级 serde(default) 会用这里的值兜老配置)
|
||||||
|
inject_shell_integration: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -206,7 +226,8 @@ pub struct AppearanceSettings {
|
|||||||
impl Default for AppearanceSettings {
|
impl Default for AppearanceSettings {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
theme: "thing-dark".to_string(),
|
// 「跟随主应用」:配合默认开启的 follow_app_theme,开箱即随应用深浅切换
|
||||||
|
theme: "system".to_string(),
|
||||||
follow_app_theme: true,
|
follow_app_theme: true,
|
||||||
font_family: "Cascadia Mono, Consolas, Microsoft YaHei Mono, monospace".to_string(),
|
font_family: "Cascadia Mono, Consolas, Microsoft YaHei Mono, monospace".to_string(),
|
||||||
font_size: 14,
|
font_size: 14,
|
||||||
@@ -296,10 +317,6 @@ pub struct LayoutSettings {
|
|||||||
pub confirm_close_running: bool,
|
pub confirm_close_running: bool,
|
||||||
/// 新建标签页时是否继承当前会话的工作目录
|
/// 新建标签页时是否继承当前会话的工作目录
|
||||||
pub inherit_cwd: bool,
|
pub inherit_cwd: bool,
|
||||||
/// 侧栏默认是否展开
|
|
||||||
pub sidebar_open: bool,
|
|
||||||
/// 侧栏宽度(像素)
|
|
||||||
pub sidebar_width: u32,
|
|
||||||
/// 是否显示底部状态栏
|
/// 是否显示底部状态栏
|
||||||
pub show_status_bar: bool,
|
pub show_status_bar: bool,
|
||||||
/// 分屏上限(1 = 不分屏,2 = 2×1,4 = 2×2)。
|
/// 分屏上限(1 = 不分屏,2 = 2×1,4 = 2×2)。
|
||||||
@@ -312,8 +329,6 @@ impl Default for LayoutSettings {
|
|||||||
Self {
|
Self {
|
||||||
confirm_close_running: true,
|
confirm_close_running: true,
|
||||||
inherit_cwd: true,
|
inherit_cwd: true,
|
||||||
sidebar_open: true,
|
|
||||||
sidebar_width: 220,
|
|
||||||
show_status_bar: true,
|
show_status_bar: true,
|
||||||
max_panes: 4,
|
max_panes: 4,
|
||||||
}
|
}
|
||||||
@@ -510,11 +525,25 @@ pub struct SecuritySettings {
|
|||||||
///
|
///
|
||||||
/// **不提供 "auto-accept" 选项**:TOFU 静默接受是 MITM 的入口,
|
/// **不提供 "auto-accept" 选项**:TOFU 静默接受是 MITM 的入口,
|
||||||
/// 属于代码层不该给用户的开关。
|
/// 属于代码层不该给用户的开关。
|
||||||
|
///
|
||||||
|
/// 读取方(`ssh::SshHandler::check_server_key`)**显式读取本字段而不是写死**:
|
||||||
|
/// 唯一支持的取值是 `"ask"`,其它取值(配置文件被手工改过)一律按 `"ask"`
|
||||||
|
/// 处理 —— 安全开关的失败方向必须朝更严的一侧,绝不能因为出现了一个
|
||||||
|
/// 没见过的字符串就放宽。
|
||||||
pub host_key_policy: String,
|
pub host_key_policy: String,
|
||||||
/// 指纹变更时是否阻断(**默认 true**)。关掉会让中间人攻击无声通过,
|
/// 指纹变更时是否阻断(**默认 true**)。关掉会让中间人攻击无声通过,
|
||||||
/// 因此前端需以红色风险提示呈现该开关。
|
/// 因此前端需以红色风险提示呈现该开关。
|
||||||
|
///
|
||||||
|
/// 关闭时的行为不是「照旧弹窗」而是「自动接受并记入变更历史」:
|
||||||
|
/// 开关的语义是「不再拦截」,若关掉后仍弹阻断弹窗,用户会以为没生效。
|
||||||
pub block_on_fingerprint_change: bool,
|
pub block_on_fingerprint_change: bool,
|
||||||
/// 是否记录连接审计日志(P2,默认关;开启后输入输出落盘,含脱敏)
|
/// 是否把**安全事件**写入日志(默认开)。
|
||||||
|
///
|
||||||
|
/// 记录对象是主机密钥相关的事件:指纹已信任 / 首次见到被接受 / 指纹变更
|
||||||
|
/// 被接受或拒绝 / 自动接受(阻断关闭时)。与「会话日志」(输出字节流落盘,
|
||||||
|
/// 由工具栏的 `terminal_toggle_logging` 按会话显式开启,见 `audit` 模块的
|
||||||
|
/// 取舍说明)**是两件事**:这里只写事件行,不含屏幕内容,因此没有隐私代价,
|
||||||
|
/// 默认开启才能保证「发生过什么」事后可查。
|
||||||
pub audit_log: bool,
|
pub audit_log: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -523,7 +552,7 @@ impl Default for SecuritySettings {
|
|||||||
Self {
|
Self {
|
||||||
host_key_policy: "ask".to_string(),
|
host_key_policy: "ask".to_string(),
|
||||||
block_on_fingerprint_change: true,
|
block_on_fingerprint_change: true,
|
||||||
audit_log: false,
|
audit_log: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -633,6 +662,9 @@ impl Default for TemplateEntry {
|
|||||||
/// - 新建/关闭标签用 `Ctrl+Shift+T/W`,与浏览器习惯一致。
|
/// - 新建/关闭标签用 `Ctrl+Shift+T/W`,与浏览器习惯一致。
|
||||||
/// - 跳转标签用 `Alt+1..9`:`Ctrl+数字` 已被翻译模块的 `Ctrl+2`(翻译面板)占用,
|
/// - 跳转标签用 `Alt+1..9`:`Ctrl+数字` 已被翻译模块的 `Ctrl+2`(翻译面板)占用,
|
||||||
/// 而 `Alt+数字` 在终端里通常不产生控制字符,冲突面最小。
|
/// 而 `Alt+数字` 在终端里通常不产生控制字符,冲突面最小。
|
||||||
|
/// - `newDetachedWindow`(`Ctrl+3`)是这张表里唯一**全局**生效的一条:它由前端
|
||||||
|
/// `TerminalQuickLaunch` 注册成系统级热键(任意应用前台可触发),放在这里只为
|
||||||
|
/// 跟着终端设置一起存与改。`Ctrl+2` 已被翻译面板占用,`Ctrl+3` 是空闲的相邻键位。
|
||||||
pub fn default_shortcuts() -> Vec<ShortcutBinding> {
|
pub fn default_shortcuts() -> Vec<ShortcutBinding> {
|
||||||
let pairs: &[(&str, &str)] = &[
|
let pairs: &[(&str, &str)] = &[
|
||||||
("copy", "Ctrl+Shift+C"),
|
("copy", "Ctrl+Shift+C"),
|
||||||
@@ -656,6 +688,11 @@ pub fn default_shortcuts() -> Vec<ShortcutBinding> {
|
|||||||
("history", "Ctrl+Shift+H"),
|
("history", "Ctrl+Shift+H"),
|
||||||
("renameTab", "F2"),
|
("renameTab", "F2"),
|
||||||
("sessionSwitcher", "Ctrl+Shift+O"),
|
("sessionSwitcher", "Ctrl+Shift+O"),
|
||||||
|
// 把当前会话放到独立窗口。**必须列在这里**:`heal()` 只保留默认表里出现过的
|
||||||
|
// action(未知 action 会被剔除),前端设置表里有而这里没有 = 用户改完下次启动就被清掉。
|
||||||
|
("detachWindow", "Ctrl+Shift+N"),
|
||||||
|
// 全局生效的一条(见上方说明):新建独立窗口 + 选择会话
|
||||||
|
("newDetachedWindow", "Ctrl+3"),
|
||||||
];
|
];
|
||||||
pairs
|
pairs
|
||||||
.iter()
|
.iter()
|
||||||
@@ -685,6 +722,24 @@ impl TerminalSettings {
|
|||||||
pub fn heal(&mut self) -> bool {
|
pub fn heal(&mut self) -> bool {
|
||||||
let mut changed = false;
|
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();
|
let defaults = default_shortcuts();
|
||||||
for d in &defaults {
|
for d in &defaults {
|
||||||
@@ -747,6 +802,14 @@ impl TerminalSettings {
|
|||||||
changed = true;
|
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 → 清空,由前端选第一个可用
|
// last_shell_id 指向已删除的 Shell → 清空,由前端选第一个可用
|
||||||
if !self.last_shell_id.is_empty() && self.shell(&self.last_shell_id).is_none() {
|
if !self.last_shell_id.is_empty() && self.shell(&self.last_shell_id).is_none() {
|
||||||
self.last_shell_id.clear();
|
self.last_shell_id.clear();
|
||||||
|
|||||||
+556
-14
@@ -14,6 +14,25 @@ use std::path::{Path, PathBuf};
|
|||||||
|
|
||||||
use super::settings::{EnvVar, ShellProfile};
|
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」与「用户已有的自定义配置」。
|
/// 探测结果:合并「自动发现的 Shell」与「用户已有的自定义配置」。
|
||||||
///
|
///
|
||||||
/// 合并策略:**以自动探测为权威源修正路径**(用户机器上升级了 PowerShell 7,
|
/// 合并策略:**以自动探测为权威源修正路径**(用户机器上升级了 PowerShell 7,
|
||||||
@@ -25,8 +44,13 @@ pub fn detect_and_merge(existing: &[ShellProfile]) -> Vec<ShellProfile> {
|
|||||||
|
|
||||||
for mut d in detected {
|
for mut d in detected {
|
||||||
if let Some(old) = existing.iter().find(|s| s.id == d.id) {
|
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()
|
d.name.clone()
|
||||||
} else {
|
} else {
|
||||||
old.name.clone()
|
old.name.clone()
|
||||||
@@ -62,6 +86,12 @@ pub fn detect_and_merge(existing: &[ShellProfile]) -> Vec<ShellProfile> {
|
|||||||
/// 自动探测本机可用的 Shell。
|
/// 自动探测本机可用的 Shell。
|
||||||
///
|
///
|
||||||
/// 顺序即菜单顺序,也是新建会话时的默认选中顺序(按现代性与功能排序)。
|
/// 顺序即菜单顺序,也是新建会话时的默认选中顺序(按现代性与功能排序)。
|
||||||
|
///
|
||||||
|
/// # 关于 `name`
|
||||||
|
///
|
||||||
|
/// `name` 是**设置页里的描述名**(可编辑,用于描述「这条配置是什么」),
|
||||||
|
/// 不是会话标签上显示的名字 —— 后者取 [`session_label`]。两者刻意分开:
|
||||||
|
/// 描述名可以长、可以含版本来源,而标签需要稳定、短、一眼可辨。
|
||||||
pub fn detect_shells() -> Vec<ShellProfile> {
|
pub fn detect_shells() -> Vec<ShellProfile> {
|
||||||
let mut list = Vec::new();
|
let mut list = Vec::new();
|
||||||
|
|
||||||
@@ -335,6 +365,28 @@ fn encode_powershell_command(script: &str) -> String {
|
|||||||
/// PowerShell 的提示符函数在「上一条命令执行完、即将显示新提示符」这个时刻被调用,
|
/// PowerShell 的提示符函数在「上一条命令执行完、即将显示新提示符」这个时刻被调用,
|
||||||
/// 因此这里输出的是 **D(上一条结束)** 而不是 C(即将开始)。
|
/// 因此这里输出的是 **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),两者语义不同:
|
/// 退出码取自 `$LASTEXITCODE`(原生命令)或 `$?`(cmdlet),两者语义不同:
|
||||||
/// cmdlet 成功时 `$LASTEXITCODE` 可能保留着**更早那条原生命令**的值。
|
/// cmdlet 成功时 `$LASTEXITCODE` 可能保留着**更早那条原生命令**的值。
|
||||||
/// 因此优先 `$LASTEXITCODE`(仅当其在本轮被设置过),否则用 `$?` 折算 0/1。
|
/// 因此优先 `$LASTEXITCODE`(仅当其在本轮被设置过),否则用 `$?` 折算 0/1。
|
||||||
@@ -343,11 +395,26 @@ fn encode_powershell_command(script: &str) -> String {
|
|||||||
fn powershell_prompt_hook() -> String {
|
fn powershell_prompt_hook() -> String {
|
||||||
[
|
[
|
||||||
"$__thingOrigPrompt = $function:prompt;",
|
"$__thingOrigPrompt = $function:prompt;",
|
||||||
|
// 去重用的「最近一次已上报的历史条目 id」,见函数文档
|
||||||
|
"$global:__thingLastHistId = -1;",
|
||||||
|
// 「下一个提示符对应的是注入脚本自己」,见函数文档
|
||||||
|
"$global:__thingSkipNextPrompt = $true;",
|
||||||
"function global:prompt {",
|
"function global:prompt {",
|
||||||
// 必须最先取:后面任何一条语句都会刷新 $?
|
// 必须最先取:后面任何一条语句都会刷新 $?
|
||||||
" $__ok = $?;",
|
" $__ok = $?;",
|
||||||
" $__code = $LASTEXITCODE;",
|
" $__code = $LASTEXITCODE;",
|
||||||
" if ($null -eq $__code) { $__code = if ($__ok) { 0 } else { 1 } }",
|
" 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)\";",
|
" Write-Host -NoNewline \"$([char]27)]133;D;$__code$([char]7)\";",
|
||||||
" $__p = $ExecutionContext.SessionState.Path.CurrentLocation;",
|
" $__p = $ExecutionContext.SessionState.Path.CurrentLocation;",
|
||||||
" $__loc = $__p.ProviderPath;",
|
" $__loc = $__p.ProviderPath;",
|
||||||
@@ -388,6 +455,12 @@ fn powershell_prompt_hook() -> String {
|
|||||||
/// 结论:**保留 shell 的原样输出交给 Rust 侧判断** —— hook 只管如实上报,
|
/// 结论:**保留 shell 的原样输出交给 Rust 侧判断** —— hook 只管如实上报,
|
||||||
/// 「前导空格要不要记」是策略,由 `History::record` 统一决定(那里也是
|
/// 「前导空格要不要记」是策略,由 `History::record` 统一决定(那里也是
|
||||||
/// `HISTCONTROL=ignorespace` 的落点)。hook 里做策略判断会散落成两处规则。
|
/// `HISTCONTROL=ignorespace` 的落点)。hook 里做策略判断会散落成两处规则。
|
||||||
|
///
|
||||||
|
/// 例外一处:字段拆分(`read`)会把命令**自己的前导空格**一并吃掉 —— 序号后的
|
||||||
|
/// 分隔空格与命令的前导空格在文本上无法区分。于是「以空格开头」的命令会以普通
|
||||||
|
/// 命令的形态上报,由 `History::record` 记录。常见配置
|
||||||
|
/// `HISTCONTROL=ignorespace` 下这类命令压根不在 `history 1` 里,这条例外也就
|
||||||
|
/// 无从触发;不对抗它,如实记在这里。
|
||||||
fn bash_prompt_hook() -> String {
|
fn bash_prompt_hook() -> String {
|
||||||
[
|
[
|
||||||
"# Thing 终端:shell integration hook(由终端模块注入,可安全删除)",
|
"# Thing 终端:shell integration hook(由终端模块注入,可安全删除)",
|
||||||
@@ -395,30 +468,170 @@ fn bash_prompt_hook() -> String {
|
|||||||
"# 供文件管理器、cwd 继承与命令历史使用",
|
"# 供文件管理器、cwd 继承与命令历史使用",
|
||||||
"__thing_osc7() {",
|
"__thing_osc7() {",
|
||||||
" local __code=$?",
|
" local __code=$?",
|
||||||
" printf '\\033]133;D;%s\\007' \"$__code\"",
|
|
||||||
// 取最后一条历史:`history 1` 返回 ` 123 <命令>`
|
// 取最后一条历史:`history 1` 返回 ` 123 <命令>`
|
||||||
// (前导空格 + 序号 + 两个空格 + 命令本体)。
|
// (前导空格 + 序号 + 两个空格 + 命令本体)。
|
||||||
//
|
//
|
||||||
// 用 `read -r` 拆而不是参数展开剥前缀:`${x#"$y"}` 这种嵌套引号在
|
// **必须只跳过「序号」这一个字段**:`read -r _ __cmd` 的语义是「第一个
|
||||||
// Rust 字符串字面量里要转义到难以阅读,而且 `history` 的输出里
|
// 字段给 `_`,其余字段连同它们之间的分隔符一起给 `__cmd`」。
|
||||||
// 命令本体可能**含空格**,参数展开必须保留剩余全部内容。
|
//
|
||||||
// `read -r _ _ __cmd` 的语义恰好是「跳过前两段空白分隔的字段,
|
// 曾经的写法 `read -r _ _ __cmd` 多跳了一个字段,后果按命令词数分三种:
|
||||||
// 其余原样(含内部空格)收进 __cmd」—— 正是所需,且引号最少。
|
// - 单字命令(`ls`):只剩序号 + 命令两个字段 → `__cmd` 为空 → 不发 Cmd
|
||||||
|
// → **一条都不落库**(实测就是「SSH 上跑了 ls,历史里什么都没有」);
|
||||||
|
// - 多字命令(`git status`):第一个词被 `_` 吃掉 → 记成 `status`;
|
||||||
|
// - 恰好两字命令才是碰巧正确的。
|
||||||
|
//
|
||||||
|
// 用 `read` 而不是参数展开剥前缀:`history` 的输出里命令本体可能含空格,
|
||||||
|
// 参数展开(`${x##* }` 之类)会在命令内部出现连续空格时切错位置。
|
||||||
" local __cmd=''",
|
" 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]1337;Cmd=%s\\007' \"$__cmd\"",
|
||||||
|
" printf '\\033]133;D;%s\\007' \"$__code\"",
|
||||||
" printf '\\033]7;file://%s%s\\007' \"${HOSTNAME:-localhost}\" \"$PWD\"",
|
" printf '\\033]7;file://%s%s\\007' \"${HOSTNAME:-localhost}\" \"$PWD\"",
|
||||||
"}",
|
"}",
|
||||||
"if [[ -n \"$PROMPT_COMMAND\" ]]; then",
|
// bash 5.1+ 允许 `PROMPT_COMMAND` 是**数组**(每个元素都是一条要执行的命令)。
|
||||||
" PROMPT_COMMAND=\"__thing_osc7; $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",
|
"else",
|
||||||
" PROMPT_COMMAND=\"__thing_osc7\"",
|
" PROMPT_COMMAND=\"__thing_osc7${PROMPT_COMMAND:+; $PROMPT_COMMAND}\"",
|
||||||
"fi",
|
"fi",
|
||||||
"",
|
"",
|
||||||
]
|
]
|
||||||
.join("\n")
|
.join("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 远端(SSH)shell 集成脚本:**一行** 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 只发 D,preexec 发 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/zsh(dash、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 类型区分)。
|
/// 清屏命令(按 Shell 类型区分)。
|
||||||
pub fn clear_command(kind: &str) -> &'static str {
|
pub fn clear_command(kind: &str) -> &'static str {
|
||||||
match kind {
|
match kind {
|
||||||
@@ -430,7 +643,6 @@ pub fn clear_command(kind: &str) -> &'static str {
|
|||||||
}
|
}
|
||||||
|
|
||||||
use std::os::windows::process::CommandExt;
|
use std::os::windows::process::CommandExt;
|
||||||
|
|
||||||
/// `Command::creation_flags` 的糖:隐藏控制台窗口。
|
/// `Command::creation_flags` 的糖:隐藏控制台窗口。
|
||||||
trait NoWindow {
|
trait NoWindow {
|
||||||
fn creation_flags_no_window(&mut self) -> &mut Self;
|
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() {
|
if !rest.trim().is_empty() {
|
||||||
out.titles.push(rest.to_string());
|
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) {
|
if let Some(mark) = parse_osc133(&payload) {
|
||||||
out.marks.push(mark);
|
out.marks.push(mark);
|
||||||
}
|
}
|
||||||
@@ -614,6 +829,83 @@ pub struct ParsedSequences {
|
|||||||
pub marks: Vec<CommandMark>,
|
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 的载荷解析出本地路径。
|
/// 从 OSC 7 的载荷解析出本地路径。
|
||||||
///
|
///
|
||||||
/// 载荷形如 `file://HOST/C:/Users/foo` 或 `file:///home/user`。
|
/// 载荷形如 `file://HOST/C:/Users/foo` 或 `file:///home/user`。
|
||||||
@@ -685,3 +977,253 @@ pub fn resolve_cwd(requested: &str) -> Option<String> {
|
|||||||
Some(default_cwd())
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -30,6 +30,11 @@
|
|||||||
//!
|
//!
|
||||||
//! 防御 MITM 的基线:若 `check_server_key` 返回 `false` 是**默认路径**,
|
//! 防御 MITM 的基线:若 `check_server_key` 返回 `false` 是**默认路径**,
|
||||||
//! 超时(120 秒)视为拒绝。用户没有任何回应就不继续握手。
|
//! 超时(120 秒)视为拒绝。用户没有任何回应就不继续握手。
|
||||||
|
//!
|
||||||
|
//! 上述行为受 `terminal::settings::SecuritySettings` 的三个开关约束(见该结构说明):
|
||||||
|
//! `host_key_policy`(唯一取值 `"ask"`,未知取值按 ask 处理)、
|
||||||
|
//! `block_on_fingerprint_change`(关闭后「与记录不符」不再弹确认,改为自动接受
|
||||||
|
//! 并写入变更历史)、`audit_log`(上述事件是否写入日志)。
|
||||||
|
|
||||||
pub mod forward;
|
pub mod forward;
|
||||||
pub mod hostkey;
|
pub mod hostkey;
|
||||||
@@ -50,6 +55,7 @@ use tokio::sync::oneshot;
|
|||||||
use super::session::{
|
use super::session::{
|
||||||
local_info, LocalSessionState, Session, SessionId, SessionInfo, SessionKind, SessionState,
|
local_info, LocalSessionState, Session, SessionId, SessionInfo, SessionKind, SessionState,
|
||||||
};
|
};
|
||||||
|
use super::settings::SecuritySettings;
|
||||||
use crate::secrets;
|
use crate::secrets;
|
||||||
use crate::terminal::events as EV;
|
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 fingerprint = key_data.fingerprint(russh::keys::HashAlg::Sha256).to_string();
|
||||||
let key_type = key_data.algorithm().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 比对
|
// 1. 与 known_hosts 比对
|
||||||
let verdict = hostkey::verify(&self.host, self.port, &fingerprint, &key_type);
|
let verdict = hostkey::verify(&self.host, self.port, &fingerprint, &key_type);
|
||||||
|
|
||||||
match verdict {
|
match verdict {
|
||||||
hostkey::Verdict::Trusted => {
|
hostkey::Verdict::Trusted => {
|
||||||
crate::logger::log_info(
|
self.audit(
|
||||||
"terminal",
|
&sec,
|
||||||
|
false,
|
||||||
&format!(
|
&format!(
|
||||||
"主机密钥已信任:{}:{} {}",
|
"主机密钥已信任:{}:{} {}",
|
||||||
self.host, self.port, fingerprint
|
self.host, self.port, fingerprint
|
||||||
@@ -218,13 +246,36 @@ impl client::Handler for SshHandler {
|
|||||||
}
|
}
|
||||||
hostkey::Verdict::Changed { previous } => {
|
hostkey::Verdict::Changed { previous } => {
|
||||||
// 高危信号:可能是服务器重装,也可能是 MITM
|
// 高危信号:可能是服务器重装,也可能是 MITM
|
||||||
crate::logger::log_warn(
|
self.audit(
|
||||||
"terminal",
|
&sec,
|
||||||
|
true,
|
||||||
&format!(
|
&format!(
|
||||||
"主机密钥发生变化:{}:{} 新指纹 {}(旧 {})",
|
"主机密钥发生变化:{}:{} 新指纹 {}(旧 {})",
|
||||||
self.host, self.port, fingerprint, previous
|
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
|
let accepted = self
|
||||||
.ask_user(&key_type, &fingerprint, "changed", Some(previous))
|
.ask_user(&key_type, &fingerprint, "changed", Some(previous))
|
||||||
.await;
|
.await;
|
||||||
@@ -239,6 +290,14 @@ impl client::Handler for SshHandler {
|
|||||||
hostkey::Verdict::Unknown => {
|
hostkey::Verdict::Unknown => {
|
||||||
let accepted = self.ask_user(&key_type, &fingerprint, "unknown", None).await;
|
let accepted = self.ask_user(&key_type, &fingerprint, "unknown", None).await;
|
||||||
if accepted {
|
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)
|
if let Err(e) = hostkey::accept(&self.host, self.port, &fingerprint, &key_type)
|
||||||
{
|
{
|
||||||
crate::logger::log_error("terminal", &format!("新增 known_hosts 失败: {e}"));
|
crate::logger::log_error("terminal", &format!("新增 known_hosts 失败: {e}"));
|
||||||
@@ -293,6 +352,27 @@ impl client::Handler for SshHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl 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`(拒绝)。这是刻意的安全姿态:
|
/// 超时(或前端未响应)→ 返回 `false`(拒绝)。这是刻意的安全姿态:
|
||||||
@@ -392,6 +472,11 @@ pub struct SshConnectParams {
|
|||||||
pub keepalive_secs: u64,
|
pub keepalive_secs: u64,
|
||||||
pub connect_timeout_ms: u64,
|
pub connect_timeout_ms: u64,
|
||||||
pub startup_command: String,
|
pub startup_command: String,
|
||||||
|
/// 是否在远端注入 shell 集成 hook(命令历史 + cwd 跟踪)。
|
||||||
|
///
|
||||||
|
/// 见 [`crate::terminal::shell::remote_shell_integration_script`]:不落盘,
|
||||||
|
/// 只在本次会话里改远端 shell 的 `PROMPT_COMMAND` / `precmd`。
|
||||||
|
pub inject_shell_integration: bool,
|
||||||
/// 代理:`None` 表示直连。
|
/// 代理:`None` 表示直连。
|
||||||
///
|
///
|
||||||
/// P0 不实现 SOCKS5 桥接,命令层在主机开了「走代理」时会填 `Some`,
|
/// P0 不实现 SOCKS5 桥接,命令层在主机开了「走代理」时会填 `Some`,
|
||||||
@@ -655,6 +740,25 @@ impl SshSession {
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| format!("启动远程 shell 失败: {e}"))?;
|
.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() {
|
if !params.startup_command.trim().is_empty() {
|
||||||
let cmd = format!("{}\n", params.startup_command.trim());
|
let cmd = format!("{}\n", params.startup_command.trim());
|
||||||
@@ -1339,8 +1443,8 @@ impl Session for SshSession {
|
|||||||
self.emit_state();
|
self.emit_state();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_title(&self, title: &str) {
|
fn set_title_by_user(&self, title: &str) {
|
||||||
*self.state.title.lock().unwrap_or_else(|e| e.into_inner()) = title.to_string();
|
self.state.set_title_by_user(title);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_detached(&self, detached: bool) {
|
fn set_detached(&self, detached: bool) {
|
||||||
|
|||||||
@@ -90,6 +90,12 @@ pub fn open_for_session(
|
|||||||
// 无边框窗口默认没有投影,加上以保持与系统窗口一致的层次感
|
// 无边框窗口默认没有投影,加上以保持与系统窗口一致的层次感
|
||||||
.shadow(true)
|
.shadow(true)
|
||||||
.center()
|
.center()
|
||||||
|
// **隐藏创建**:窗口的初始底色是白的,而深浅色主题由前端读 localStorage 后
|
||||||
|
// 才应用 —— 直接显示会先闪一下纯白窗口再变深色(深色模式下尤其刺眼)。
|
||||||
|
// 因此这里先隐藏,由 TerminalWindow.vue 应用完主题(含 setBackgroundColor)
|
||||||
|
// 再 show + setFocus。同 translate/clipboard 弹窗的预创建做法。
|
||||||
|
.visible(false)
|
||||||
|
.focused(false)
|
||||||
.build()
|
.build()
|
||||||
.map_err(|e| format!("创建终端窗口失败: {e}"))?;
|
.map_err(|e| format!("创建终端窗口失败: {e}"))?;
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import TitleBar from '@/components/layout/TitleBar.vue'
|
|||||||
import Sidebar from '@/components/layout/Sidebar.vue'
|
import Sidebar from '@/components/layout/Sidebar.vue'
|
||||||
import ModuleContainer from '@/components/layout/ModuleContainer.vue'
|
import ModuleContainer from '@/components/layout/ModuleContainer.vue'
|
||||||
import NowPlayingDialog from '@/components/layout/NowPlayingDialog.vue'
|
import NowPlayingDialog from '@/components/layout/NowPlayingDialog.vue'
|
||||||
|
import TerminalQuickLaunch from '@/modules/terminal/TerminalQuickLaunch.vue'
|
||||||
import { Toaster } from '@/components/ui/sonner'
|
import { Toaster } from '@/components/ui/sonner'
|
||||||
import { useAppStore } from '@/stores/appStore'
|
import { useAppStore } from '@/stores/appStore'
|
||||||
import { useFeiniuStore } from '@/stores/feiniuStore'
|
import { useFeiniuStore } from '@/stores/feiniuStore'
|
||||||
@@ -299,6 +300,7 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<NowPlayingDialog />
|
<NowPlayingDialog />
|
||||||
|
<TerminalQuickLaunch />
|
||||||
<Toaster position="bottom-right" rich-colors close-button :style="{ zIndex: 99999 }" />
|
<Toaster position="bottom-right" rich-colors close-button :style="{ zIndex: 99999 }" />
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* 列表行 —— 主从分栏(模式 B)左栏的条目。
|
||||||
|
* 选中态用 primary-soft,hover 态用中性 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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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" />
|
||||||
|
|
||||||
|
<!-- 固定层 #2:Tab 行。自定义内容走 #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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -33,11 +33,11 @@ const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
|
|||||||
type="button"
|
type="button"
|
||||||
role="tab"
|
role="tab"
|
||||||
:aria-selected="modelValue === item.value"
|
: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="
|
:class="
|
||||||
modelValue === item.value
|
modelValue === item.value
|
||||||
? 'bg-background font-medium text-foreground shadow-sm'
|
? 'bg-primary-soft font-medium text-primary-soft-text'
|
||||||
: 'text-muted-foreground hover:text-foreground'
|
: 'text-fg-secondary hover:bg-hover hover:text-foreground'
|
||||||
"
|
"
|
||||||
@click="emit('update:modelValue', item.value)"
|
@click="emit('update:modelValue', item.value)"
|
||||||
>
|
>
|
||||||
@@ -45,8 +45,12 @@ const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
|
|||||||
<span class="truncate">{{ item.label }}</span>
|
<span class="truncate">{{ item.label }}</span>
|
||||||
<span
|
<span
|
||||||
v-if="item.count"
|
v-if="item.count"
|
||||||
class="shrink-0 rounded-full bg-muted px-1.5 text-[10px] tabular-nums text-muted-foreground"
|
class="shrink-0 rounded-full px-1.5 text-[10px] tabular-nums"
|
||||||
:class="modelValue === item.value ? 'bg-muted' : 'bg-background/70'"
|
:class="
|
||||||
|
modelValue === item.value
|
||||||
|
? 'bg-primary-soft-text/15 text-primary-soft-text'
|
||||||
|
: 'bg-card/70 text-fg-secondary'
|
||||||
|
"
|
||||||
>
|
>
|
||||||
{{ item.count }}
|
{{ item.count }}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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'
|
||||||
@@ -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>
|
||||||
@@ -328,7 +328,7 @@ const handleBlur = () => {
|
|||||||
:class="
|
:class="
|
||||||
tabsStore.activeTab === tab.value
|
tabsStore.activeTab === tab.value
|
||||||
? 'bg-primary text-primary-foreground'
|
? '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)"
|
@click="tabsStore.setActiveTab(tab.value)"
|
||||||
@mousedown.stop
|
@mousedown.stop
|
||||||
|
|||||||
@@ -3,31 +3,41 @@ import { cva } from "class-variance-authority"
|
|||||||
|
|
||||||
export { default as Button } from "./Button.vue"
|
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(
|
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: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default:
|
/** 主操作:同屏仅允许一个 */
|
||||||
"bg-primary text-primary-foreground hover:bg-primary/90",
|
default: "bg-primary text-primary-foreground hover:bg-primary-hover active:bg-primary-active",
|
||||||
destructive:
|
/** 破坏性操作:必须配合 ConfirmDialog */
|
||||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
destructive: "bg-danger text-white hover:bg-danger/90 focus-visible:ring-danger/30",
|
||||||
outline:
|
/** 次操作:描边卡片底,中性 hover */
|
||||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
outline: "border border-border-strong bg-card text-foreground hover:bg-hover active:bg-active",
|
||||||
secondary:
|
secondary: "bg-secondary text-secondary-foreground hover:bg-hover active:bg-active",
|
||||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
/** 幽灵操作:工具栏、图标按钮。hover 带强调色 */
|
||||||
ghost:
|
ghost: "text-fg-secondary hover:bg-primary-soft hover:text-primary-soft-text",
|
||||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
|
||||||
link: "text-primary underline-offset-4 hover:underline",
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
"default": "h-9 px-4 py-2 has-[>svg]:px-3",
|
"default": "h-[34px] px-3.5 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",
|
"xs": "h-6 gap-1 rounded-sm px-2 text-[11px] 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",
|
"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",
|
"lg": "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||||
"icon": "size-9",
|
"icon": "size-[34px]",
|
||||||
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
|
"icon-xs": "size-6 rounded-sm [&_svg:not([class*='size-'])]:size-3",
|
||||||
"icon-sm": "size-8",
|
"icon-sm": "size-7",
|
||||||
"icon-lg": "size-10",
|
"icon-lg": "size-10",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { cn } from "@/lib/utils"
|
|||||||
const props = defineProps<CalendarHeadingProps & { class?: HTMLAttributes["class"] }>()
|
const props = defineProps<CalendarHeadingProps & { class?: HTMLAttributes["class"] }>()
|
||||||
|
|
||||||
defineSlots<{
|
defineSlots<{
|
||||||
default: (props: { headingValue: string }) => any
|
default: (props: { headingValue: string }) => unknown
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const delegatedProps = reactiveOmit(props, "class")
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
|||||||
data-slot="switch"
|
data-slot="switch"
|
||||||
v-bind="forwarded"
|
v-bind="forwarded"
|
||||||
:class="cn(
|
: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,
|
props.class,
|
||||||
)"
|
)"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ const forwardedProps = useForwardProps(delegatedProps)
|
|||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
data-slot="tabs-trigger"
|
data-slot="tabs-trigger"
|
||||||
:class="cn(
|
: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,
|
props.class,
|
||||||
)"
|
)"
|
||||||
v-bind="forwardedProps"
|
v-bind="forwardedProps"
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useTerminalStore } from '@/stores/terminalStore'
|
|||||||
import { createLogger } from '@/lib/logger'
|
import { createLogger } from '@/lib/logger'
|
||||||
import type { ShortcutBinding, TerminalSettings } from '@/types/terminal'
|
import type { ShortcutBinding, TerminalSettings } from '@/types/terminal'
|
||||||
import { TERMINAL_ACTIONS } from '@/lib/terminalActions'
|
import { TERMINAL_ACTIONS } from '@/lib/terminalActions'
|
||||||
|
import { appIsDark, effectiveThemeName } from '@/lib/terminalThemes'
|
||||||
|
|
||||||
const logger = createLogger('terminal')
|
const logger = createLogger('terminal')
|
||||||
|
|
||||||
@@ -32,19 +33,10 @@ export function useTerminalStream(options?: { fixedSessionId?: Ref<string | null
|
|||||||
/** 分屏:当前标签内并列展示的会话(长度为 1 时即普通单面板) */
|
/** 分屏:当前标签内并列展示的会话(长度为 1 时即普通单面板) */
|
||||||
const panes = ref<Array<{ id: string; sessionId: string }>>([])
|
const panes = ref<Array<{ id: string; sessionId: string }>>([])
|
||||||
|
|
||||||
/**
|
// 这里曾有一份 `overlay`(当前打开的浮层)与 `searchTerm` 状态:模块 Tabs 与
|
||||||
* 当前打开的浮层。
|
// 控制栏浮层接管导航之后,它们既没有被写入也没有被读取,是「内嵌独立应用」
|
||||||
*
|
// 时期的残留,2026-09-22 清理。浮层开合现在分别归属模块根(tab)与
|
||||||
* `snippets` 与 `search` 与其余项的区别:它们需要知道「当前作用于哪个会话」,
|
// `modules/terminal/useRecordPopovers`(控制栏上的历史/片段)。
|
||||||
* 而不只是一个全局面板。因此 TerminalModule 渲染它们时会把 activeSession
|
|
||||||
* 一并传下去 —— 片段面板据此过滤出对当前 shell 可用的片段。
|
|
||||||
*/
|
|
||||||
const overlay = ref<
|
|
||||||
'none' | 'settings' | 'hosts' | 'keys' | 'knownHosts' | 'search' | 'snippets' | 'history'
|
|
||||||
>('none')
|
|
||||||
|
|
||||||
/** 搜索结果统计(供搜索栏显示「第 n 项」) */
|
|
||||||
const searchTerm = ref('')
|
|
||||||
|
|
||||||
// ===== 标签 =====
|
// ===== 标签 =====
|
||||||
|
|
||||||
@@ -115,32 +107,36 @@ export function useTerminalStream(options?: { fixedSessionId?: Ref<string | null
|
|||||||
: [sessionId]
|
: [sessionId]
|
||||||
const groupSet = new Set(group)
|
const groupSet = new Set(group)
|
||||||
|
|
||||||
try {
|
// 「关闭运行中会话前确认」关闭时直接关:连 alive 查询都不发
|
||||||
// 只要组里有任意一个仍活跃就要确认(而不是只看被点的那一个)
|
// (那是一次 IPC + 后端进程存活探测,用户已经明确表示不要这道确认)
|
||||||
let anyAlive = false
|
if (store.settings?.layout.confirmCloseRunning ?? true) {
|
||||||
for (const id of group) {
|
try {
|
||||||
try {
|
// 只要组里有任意一个仍活跃就要确认(而不是只看被点的那一个)
|
||||||
if (await store.sessionAlive(id)) {
|
let anyAlive = false
|
||||||
anyAlive = true
|
for (const id of group) {
|
||||||
break
|
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) {
|
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) {
|
if (!shellId) {
|
||||||
logger.error('没有可用的 Shell,无法新建会话')
|
logger.error('没有可用的 Shell,无法新建会话')
|
||||||
return null
|
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 {
|
try {
|
||||||
// 尺寸给一个合理初值:真实值由 xterm 的 fit 在挂载后立即上报,
|
// 尺寸给一个合理初值:真实值由 xterm 的 fit 在挂载后立即上报,
|
||||||
// 这里的作用只是让 PTY 在首帧有正确的行列数,避免 shell 先按 80x24 画一遍提示符再重排
|
// 这里的作用只是让 PTY 在首帧有正确的行列数,避免 shell 先按 80x24 画一遍提示符再重排
|
||||||
@@ -385,12 +401,19 @@ export function useTerminalStream(options?: { fixedSessionId?: Ref<string | null
|
|||||||
fontOverride.value = null
|
fontOverride.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 生效的外观(把字号覆盖合并进去,供 TerminalPane 使用) */
|
/** 生效的外观(把字号覆盖 + 「跟随应用主题」解析后的配色一起并进去,供 TerminalPane 使用) */
|
||||||
const effectiveAppearance = computed(() => {
|
const effectiveAppearance = computed(() => {
|
||||||
const a = store.settings?.appearance
|
const a = store.settings?.appearance
|
||||||
if (!a) return null
|
if (!a) return null
|
||||||
if (fontOverride.value === null) return a
|
// 「跟随应用主题」要跟着应用深浅实时变:开了就用响应式的 appIsDark,
|
||||||
return { ...a, fontSize: fontOverride.value }
|
// 关了就只按此刻的深浅解析一次(此后应用改深浅不再带着终端变,见字段说明)。
|
||||||
|
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,
|
activeSession,
|
||||||
orderedSessions,
|
orderedSessions,
|
||||||
panes,
|
panes,
|
||||||
overlay,
|
|
||||||
searchTerm,
|
|
||||||
fontOverride,
|
fontOverride,
|
||||||
effectiveAppearance,
|
effectiveAppearance,
|
||||||
confirmDialog,
|
confirmDialog,
|
||||||
|
|||||||
+60
-33
@@ -6,7 +6,7 @@ import { WebLinksAddon } from '@xterm/addon-web-links'
|
|||||||
import { Unicode11Addon } from '@xterm/addon-unicode11'
|
import { Unicode11Addon } from '@xterm/addon-unicode11'
|
||||||
import { WebglAddon } from '@xterm/addon-webgl'
|
import { WebglAddon } from '@xterm/addon-webgl'
|
||||||
import { useTerminalStore } from '@/stores/terminalStore'
|
import { useTerminalStore } from '@/stores/terminalStore'
|
||||||
import { resolveTheme } from '@/lib/terminalThemes'
|
import { resolveTheme, themeWithOpacity } from '@/lib/terminalThemes'
|
||||||
import { createLogger } from '@/lib/logger'
|
import { createLogger } from '@/lib/logger'
|
||||||
import type { AppearanceSettings } from '@/types/terminal'
|
import type { AppearanceSettings } from '@/types/terminal'
|
||||||
|
|
||||||
@@ -139,6 +139,10 @@ export function useXterm(options: UseXtermOptions) {
|
|||||||
// ——这是 xterm 的标准做法,Ctrl+拖选仍能强制进入选择模式
|
// ——这是 xterm 的标准做法,Ctrl+拖选仍能强制进入选择模式
|
||||||
macOptionIsMeta: false,
|
macOptionIsMeta: false,
|
||||||
allowProposedApi: true,
|
allowProposedApi: true,
|
||||||
|
// 允许透明背景(「背景不透明度」设置项依赖它)。只能在构造时设定:
|
||||||
|
// 它决定渲染器是否按 alpha 混合,运行中改会被 xterm 忽略。
|
||||||
|
// 代价是渲染稍慢,因此不透明度 < 100 时还会关掉 WebGL(见 ensureRenderer)。
|
||||||
|
allowTransparency: true,
|
||||||
// 转换宽字符(中日韩)的正确显示;配合 unicode11 插件使用 Unicode 11 宽度表
|
// 转换宽字符(中日韩)的正确显示;配合 unicode11 插件使用 Unicode 11 宽度表
|
||||||
convertEol: false
|
convertEol: false
|
||||||
})
|
})
|
||||||
@@ -168,24 +172,8 @@ export function useXterm(options: UseXtermOptions) {
|
|||||||
void document.fonts.ready.then(() => safeFit())
|
void document.fonts.ready.then(() => safeFit())
|
||||||
}
|
}
|
||||||
|
|
||||||
// WebGL 渲染:大吞吐场景(`cat` 大文件、编译日志)下 CPU 占用远低于 canvas。
|
// WebGL 渲染:见 ensureRenderer(按 GPU 开关 + 不透明度决定是否启用)
|
||||||
// 但它在部分虚拟机 / 远程桌面 / 老显卡上会黑屏,所以**必须可回退**:
|
ensureRenderer(app)
|
||||||
// 加载失败就留在默认的 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)}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 用户输入 → Rust(base64 保字节完整性)
|
// 用户输入 → Rust(base64 保字节完整性)
|
||||||
t.onData(data => {
|
t.onData(data => {
|
||||||
@@ -318,22 +306,61 @@ export function useXterm(options: UseXtermOptions) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 外观热更新 =====
|
// ===== 渲染器与外观热更新 =====
|
||||||
|
|
||||||
function applyAppearance(app: AppearanceSettings | null) {
|
/**
|
||||||
const t = term.value
|
* 按需启用 / 关闭 WebGL 渲染。
|
||||||
if (!t || !app) return
|
*
|
||||||
t.options.theme = resolveTheme(app.theme)
|
* 两个条件缺一不可:
|
||||||
t.options.fontFamily = app.fontFamily || 'Cascadia Mono, Consolas, monospace'
|
* - `gpuRendering` 开关(默认开):WebGL 在大吞吐场景(`cat` 大文件、编译日志)
|
||||||
t.options.fontSize = app.fontSize || 14
|
* 下 CPU 占用远低于 canvas,但它在少数虚拟机 / 远程桌面 / 老显卡上会黑屏,
|
||||||
t.options.lineHeight = app.lineHeight || 1.2
|
* 所以**必须可回退**:加载失败就留在默认渲染器,功能不受影响。
|
||||||
t.options.letterSpacing = app.letterSpacing || 0
|
* - **不透明度 100%**:WebGL 渲染器不支持透明背景(`allowTransparency` 只被
|
||||||
t.options.cursorBlink = app.cursorBlink ?? true
|
* DOM/canvas 渲染器认),半透明时继续用它会让「背景不透明度」看起来完全没反应。
|
||||||
t.options.cursorStyle = normalizeCursorStyle(app.cursorStyle)
|
* 于是拉低不透明度时主动丢弃 WebGL,回到支持 alpha 的渲染器。
|
||||||
t.options.scrollback = app.scrollback || 5000
|
*/
|
||||||
// 字体变化会改变字符宽高 → 行列数变了 → 必须重新 fit 并上报
|
function ensureRenderer(app: AppearanceSettings | null) {
|
||||||
requestAnimationFrame(() => safeFit())
|
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
@@ -557,7 +557,15 @@ export const commands = {
|
|||||||
terminalWrite: (sessionId: string, data: string) => __TAURI_INVOKE<null>("terminal_write", { sessionId, data }),
|
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 }),
|
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 }),
|
terminalRenameSession: (sessionId: string, title: string) => __TAURI_INVOKE<null>("terminal_rename_session", { sessionId, title }),
|
||||||
/** 在独立窗口中打开会话。 */
|
/** 在独立窗口中打开会话。 */
|
||||||
terminalDetachSession: (sessionId: string) => __TAURI_INVOKE<null>("terminal_detach_session", { sessionId }),
|
terminalDetachSession: (sessionId: string) => __TAURI_INVOKE<null>("terminal_detach_session", { sessionId }),
|
||||||
@@ -659,6 +667,10 @@ export const commands = {
|
|||||||
*
|
*
|
||||||
* 由前端在用户点击「接受」/「拒绝」后调用。会话侧的握手正在阻塞等待这个回传,
|
* 由前端在用户点击「接受」/「拒绝」后调用。会话侧的握手正在阻塞等待这个回传,
|
||||||
* 因此超时(120 秒)后会视为拒绝。
|
* 因此超时(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 }),
|
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,
|
confirmCloseRunning?: boolean,
|
||||||
/** 新建标签页时是否继承当前会话的工作目录 */
|
/** 新建标签页时是否继承当前会话的工作目录 */
|
||||||
inheritCwd?: boolean,
|
inheritCwd?: boolean,
|
||||||
/** 侧栏默认是否展开 */
|
|
||||||
sidebarOpen?: boolean,
|
|
||||||
/** 侧栏宽度(像素) */
|
|
||||||
sidebarWidth?: number,
|
|
||||||
/** 是否显示底部状态栏 */
|
/** 是否显示底部状态栏 */
|
||||||
showStatusBar?: boolean,
|
showStatusBar?: boolean,
|
||||||
/**
|
/**
|
||||||
@@ -1564,14 +1572,30 @@ export type SecuritySettings = {
|
|||||||
*
|
*
|
||||||
* **不提供 "auto-accept" 选项**:TOFU 静默接受是 MITM 的入口,
|
* **不提供 "auto-accept" 选项**:TOFU 静默接受是 MITM 的入口,
|
||||||
* 属于代码层不该给用户的开关。
|
* 属于代码层不该给用户的开关。
|
||||||
|
*
|
||||||
|
* 读取方(`ssh::SshHandler::check_server_key`)**显式读取本字段而不是写死**:
|
||||||
|
* 唯一支持的取值是 `"ask"`,其它取值(配置文件被手工改过)一律按 `"ask"`
|
||||||
|
* 处理 —— 安全开关的失败方向必须朝更严的一侧,绝不能因为出现了一个
|
||||||
|
* 没见过的字符串就放宽。
|
||||||
*/
|
*/
|
||||||
hostKeyPolicy?: string,
|
hostKeyPolicy?: string,
|
||||||
/**
|
/**
|
||||||
* 指纹变更时是否阻断(**默认 true**)。关掉会让中间人攻击无声通过,
|
* 指纹变更时是否阻断(**默认 true**)。关掉会让中间人攻击无声通过,
|
||||||
* 因此前端需以红色风险提示呈现该开关。
|
* 因此前端需以红色风险提示呈现该开关。
|
||||||
|
*
|
||||||
|
* 关闭时的行为不是「照旧弹窗」而是「自动接受并记入变更历史」:
|
||||||
|
* 开关的语义是「不再拦截」,若关掉后仍弹阻断弹窗,用户会以为没生效。
|
||||||
*/
|
*/
|
||||||
blockOnFingerprintChange?: boolean,
|
blockOnFingerprintChange?: boolean,
|
||||||
/** 是否记录连接审计日志(P2,默认关;开启后输入输出落盘,含脱敏) */
|
/**
|
||||||
|
* 是否把**安全事件**写入日志(默认开)。
|
||||||
|
*
|
||||||
|
* 记录对象是主机密钥相关的事件:指纹已信任 / 首次见到被接受 / 指纹变更
|
||||||
|
* 被接受或拒绝 / 自动接受(阻断关闭时)。与「会话日志」(输出字节流落盘,
|
||||||
|
* 由工具栏的 `terminal_toggle_logging` 按会话显式开启,见 `audit` 模块的
|
||||||
|
* 取舍说明)**是两件事**:这里只写事件行,不含屏幕内容,因此没有隐私代价,
|
||||||
|
* 默认开启才能保证「发生过什么」事后可查。
|
||||||
|
*/
|
||||||
auditLog?: boolean,
|
auditLog?: boolean,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1704,7 +1728,13 @@ export type SessionTemplate = {
|
|||||||
export type ShellProfile = {
|
export type ShellProfile = {
|
||||||
/** 唯一标识(同时是新建会话时的 shellKey) */
|
/** 唯一标识(同时是新建会话时的 shellKey) */
|
||||||
id?: string,
|
id?: string,
|
||||||
/** 展示名称,如 "PowerShell 7" */
|
/**
|
||||||
|
* 展示名称,如 "PowerShell 7" / "Windows PowerShell"。
|
||||||
|
*
|
||||||
|
* **设置页里的描述名,不是标签上显示的名字** —— 后者取
|
||||||
|
* [`super::shell::session_label`](按可执行文件名归一的短名)。
|
||||||
|
* 两者刻意分开:描述名可编辑、可很长,而标签要能一行放下多个会话。
|
||||||
|
*/
|
||||||
name?: string,
|
name?: string,
|
||||||
/** 可执行文件绝对路径 */
|
/** 可执行文件绝对路径 */
|
||||||
path?: string,
|
path?: string,
|
||||||
@@ -1803,6 +1833,22 @@ export type SshHost = {
|
|||||||
jumpIds?: string[],
|
jumpIds?: string[],
|
||||||
/** 终端的字符编码("utf-8" 默认 | "gbk" 等)。老服务器常见 GBK,中文环境刚需。 */
|
/** 终端的字符编码("utf-8" 默认 | "gbk" 等)。老服务器常见 GBK,中文环境刚需。 */
|
||||||
encoding?: string,
|
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,
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 任务下载协议类型 */
|
/** 任务下载协议类型 */
|
||||||
|
|||||||
@@ -93,6 +93,8 @@ export const EVENTS = {
|
|||||||
terminalTransferProgress: 'terminal-transfer-progress',
|
terminalTransferProgress: 'terminal-transfer-progress',
|
||||||
/** 终端:请求对「关闭仍在运行的会话」二次确认 */
|
/** 终端:请求对「关闭仍在运行的会话」二次确认 */
|
||||||
terminalConfirmClose: 'terminal-confirm-close',
|
terminalConfirmClose: 'terminal-confirm-close',
|
||||||
|
/** 终端:设置已保存(无负载)。各窗口据此重读设置,实现跨窗口同步 */
|
||||||
|
terminalSettings: 'terminal-settings',
|
||||||
// 监控 OSD
|
// 监控 OSD
|
||||||
osdStateUpdate: 'osd-state-update',
|
osdStateUpdate: 'osd-state-update',
|
||||||
/** OSD 数据通道:仅推送显示项 key→value 映射 + 网速(高频,每秒) */
|
/** OSD 数据通道:仅推送显示项 key→value 映射 + 网速(高频,每秒) */
|
||||||
|
|||||||
@@ -20,6 +20,14 @@
|
|||||||
* 2. **避开项目已有的全局快捷键**。`Ctrl+2` 已被翻译模块占用(取词翻译面板),
|
* 2. **避开项目已有的全局快捷键**。`Ctrl+2` 已被翻译模块占用(取词翻译面板),
|
||||||
* 所以标签跳转用 `Alt+1..9` —— 在终端里 `Alt+数字` 通常不产生控制字符,
|
* 所以标签跳转用 `Alt+1..9` —— 在终端里 `Alt+数字` 通常不产生控制字符,
|
||||||
* 冲突面最小,且符合「切标签」的直觉。
|
* 冲突面最小,且符合「切标签」的直觉。
|
||||||
|
*
|
||||||
|
* # 唯一一条「全局」动作:`newDetachedWindow`
|
||||||
|
*
|
||||||
|
* 这一条不在第二层生效,而是由 `TerminalQuickLaunch` 用 `plugin-global-shortcut`
|
||||||
|
* 注册成**系统级**热键(任意应用前台时都能按)。它放在这张表里,是为了让键位
|
||||||
|
* 跟着终端设置一起存、一起改(设置 → 快捷键 → 会话 → 新建独立窗口)。
|
||||||
|
* 因此终端内的按键分发(`useTerminalKeys`)刻意**不处理**它 —— 免得同一次按键
|
||||||
|
* 既被全局注册触发、又被终端内分发触发,开出两个窗口。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** 快捷键动作定义 */
|
/** 快捷键动作定义 */
|
||||||
@@ -162,6 +170,13 @@ export const TERMINAL_ACTIONS: TerminalAction[] = [
|
|||||||
description: '打开常用命令片段列表',
|
description: '打开常用命令片段列表',
|
||||||
defaultKeys: 'Ctrl+Shift+S'
|
defaultKeys: 'Ctrl+Shift+S'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'history',
|
||||||
|
label: '命令历史',
|
||||||
|
group: '会话',
|
||||||
|
description: '打开命令历史面板(从已记录的历史里检索、复用)',
|
||||||
|
defaultKeys: 'Ctrl+Shift+H'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'sessionSwitcher',
|
id: 'sessionSwitcher',
|
||||||
label: '会话切换器',
|
label: '会话切换器',
|
||||||
@@ -175,6 +190,13 @@ export const TERMINAL_ACTIONS: TerminalAction[] = [
|
|||||||
group: '会话',
|
group: '会话',
|
||||||
description: '把当前会话放到独立窗口中(需终端独立窗口支持)',
|
description: '把当前会话放到独立窗口中(需终端独立窗口支持)',
|
||||||
defaultKeys: 'Ctrl+Shift+N'
|
defaultKeys: 'Ctrl+Shift+N'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'newDetachedWindow',
|
||||||
|
label: '新建独立窗口',
|
||||||
|
group: '会话',
|
||||||
|
description: '全局生效(任意界面按下):打开独立终端窗口并选择要启动的会话',
|
||||||
|
defaultKeys: 'Ctrl+3'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
import type { ITheme } from '@xterm/xterm'
|
import type { ITheme } from '@xterm/xterm'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -183,17 +184,83 @@ export const TERMINAL_THEME_OPTIONS: Array<{ value: string; label: string }> = [
|
|||||||
{ value: 'system', label: '跟随主应用' }
|
{ 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 主题对象。
|
* 解析主题名到具体的 xterm 主题对象。
|
||||||
*
|
*
|
||||||
* `system` 会读取主应用的亮暗模式(`document.documentElement` 上的 `dark` class,
|
* `system` 读取 `appIsDark`(由各窗口的主题应用逻辑维护),映射到
|
||||||
* Tailwind 的暗色模式约定),映射到 vscode-dark / vscode-light。
|
* vscode-dark / vscode-light,避免终端在主应用切到浅色时突兀地保持全黑。
|
||||||
* 这样终端不会在主应用切到浅色时突兀地保持全黑。
|
|
||||||
*/
|
*/
|
||||||
export function resolveTheme(name: string): ITheme {
|
export function resolveTheme(name: string): ITheme {
|
||||||
if (name === 'system') {
|
if (name === 'system') {
|
||||||
const isDark = document.documentElement.classList.contains('dark')
|
return TERMINAL_THEMES[appIsDark.value ? 'vscode-dark' : 'vscode-light']
|
||||||
return TERMINAL_THEMES[isDark ? 'vscode-dark' : 'vscode-light']
|
|
||||||
}
|
}
|
||||||
return TERMINAL_THEMES[name] ?? TERMINAL_THEMES['vscode-dark']
|
return TERMINAL_THEMES[name] ?? TERMINAL_THEMES['vscode-dark']
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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')
|
||||||
|
})
|
||||||
@@ -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 {
|
||||||
|
/** 感知明度 0–1 */
|
||||||
|
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` → 0–1 的 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)
|
||||||
|
}
|
||||||
@@ -29,12 +29,13 @@ import { useModuleTabsStore, type ModuleTab } from '@/stores/moduleTabsStore'
|
|||||||
* ```
|
* ```
|
||||||
*
|
*
|
||||||
* ## 工作原理
|
* ## 工作原理
|
||||||
* 1. onMounted 时注册标签到 moduleTabsStore(**并复用该模块上次停留的 tab**),
|
* 1. **setup 阶段(首帧之前)** 定下要显示的 tab:搜索跳转 > 上次停留 > 声明时的初始值
|
||||||
* TitleBar 据此渲染浮动切换器
|
* —— 模块切回来是重新挂载,等到 onMounted 再改就会先渲染一帧初始页(闪一下)
|
||||||
* 2. 用 IntersectionObserver 监听 TabsList 可见性(rootMargin 裁剪 TitleBar 高度)
|
* 2. onMounted 时注册标签到 moduleTabsStore,TitleBar 据此渲染浮动切换器
|
||||||
* 3. 双向 watch 同步本地 activeTab 与 store.activeTab
|
* 3. 用 IntersectionObserver 监听 TabsList 可见性(rootMargin 裁剪 TitleBar 高度)
|
||||||
* 4. 消费搜索导航的待跳转 tab(模块尚未挂载的场景)
|
* 4. 双向 watch 同步本地 activeTab 与 store.activeTab
|
||||||
* 5. onUnmounted 时清理 observer 并把当前 tab 记到 store(按模块)后注销标签
|
* 5. 消费搜索导航的待跳转 tab(模块尚未挂载的场景)
|
||||||
|
* 6. onUnmounted 时清理 observer 并把当前 tab 记到 store(按模块)后注销标签
|
||||||
*
|
*
|
||||||
* ## 约束
|
* ## 约束
|
||||||
* - TitleBar 高度固定为 40px (h-10),composable 内部已用 44px 裁剪(含缓冲)
|
* - TitleBar 高度固定为 40px (h-10),composable 内部已用 44px 裁剪(含缓冲)
|
||||||
@@ -45,11 +46,32 @@ import { useModuleTabsStore, type ModuleTab } from '@/stores/moduleTabsStore'
|
|||||||
export function useModuleTabs(
|
export function useModuleTabs(
|
||||||
moduleId: string,
|
moduleId: string,
|
||||||
activeTab: Ref<string>,
|
activeTab: Ref<string>,
|
||||||
tabs: ModuleTab[]
|
tabs: ModuleTab[],
|
||||||
|
/** alwaysVisible: 模块自身不渲染 TabsList,标题栏切换器常驻(内容最大化模式) */
|
||||||
|
options: { alwaysVisible?: boolean } = {}
|
||||||
): Ref<HTMLElement | null> {
|
): Ref<HTMLElement | null> {
|
||||||
const tabsStore = useModuleTabsStore()
|
const tabsStore = useModuleTabsStore()
|
||||||
const tabsListRef = ref<HTMLElement | null>(null)
|
const tabsListRef = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 首次渲染前就定下要显示的 tab:搜索跳转 > 上次停留 > 声明时的初始值。
|
||||||
|
*
|
||||||
|
* 必须在 setup 里做。模块切走时组件被卸载、`activeTab` 随之丢失,切回来重新挂载
|
||||||
|
* 时它是声明时的初始值(如 proxy 的 `'overview'`);若等到 onMounted 才恢复,
|
||||||
|
* 首帧已经按初始值渲染过一次 —— 表现为「先闪一下第一页,再跳到上次停留页」,
|
||||||
|
* 而不是一次干净的进入动画。
|
||||||
|
*
|
||||||
|
* 这里只**读** store(rememberedTab / 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
|
let observer: IntersectionObserver | null = null
|
||||||
|
|
||||||
/** 应用待跳转 tab(若属于当前模块的 tab 列表) */
|
/** 应用待跳转 tab(若属于当前模块的 tab 列表) */
|
||||||
@@ -103,7 +125,12 @@ export function useModuleTabs(
|
|||||||
const restored = tabsStore.registerTabs(moduleId, tabs, activeTab.value)
|
const restored = tabsStore.registerTabs(moduleId, tabs, activeTab.value)
|
||||||
if (restored !== activeTab.value) activeTab.value = restored
|
if (restored !== activeTab.value) activeTab.value = restored
|
||||||
await nextTick()
|
await nextTick()
|
||||||
setupObserver()
|
// 常驻模式:模块不渲染 TabsList,直接让标题栏切换器显示
|
||||||
|
if (options.alwaysVisible) {
|
||||||
|
tabsStore.setFloatingVisible(true)
|
||||||
|
} else {
|
||||||
|
setupObserver()
|
||||||
|
}
|
||||||
// 搜索导航跳转:模块刚挂载,消费待跳转 tab(优先级高于上次停留)
|
// 搜索导航跳转:模块刚挂载,消费待跳转 tab(优先级高于上次停留)
|
||||||
applyPendingTab()
|
applyPendingTab()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ const standaloneWindowApps: Array<[hash: string, label: string, loader: () => Pr
|
|||||||
['#download-window', '下载窗口', () => import('./modules/downloader/DownloadWindow.vue')],
|
['#download-window', '下载窗口', () => import('./modules/downloader/DownloadWindow.vue')],
|
||||||
['#translate-popup', '翻译悬浮窗', () => import('./modules/translate/TranslatePopup.vue')],
|
['#translate-popup', '翻译悬浮窗', () => import('./modules/translate/TranslatePopup.vue')],
|
||||||
['#terminal-window', '终端独立窗口', () => import('./modules/terminal/TerminalWindow.vue')],
|
['#terminal-window', '终端独立窗口', () => import('./modules/terminal/TerminalWindow.vue')],
|
||||||
|
['#new-terminal-window', '新建终端窗口', () => import('./modules/terminal/NewTerminalWindow.vue')],
|
||||||
]
|
]
|
||||||
|
|
||||||
const winHash = window.location.hash
|
const winHash = window.location.hash
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||||
import {
|
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,
|
FileText, Files, Settings as SettingsIcon, Loader2, Eraser, Keyboard,
|
||||||
} from '@lucide/vue'
|
} from '@lucide/vue'
|
||||||
import { toast } from 'vue-sonner'
|
import { toast } from 'vue-sonner'
|
||||||
import { useClipboardStore, type ClipboardItem, type ClipboardKind, type ClipboardItemDetail } from '@/stores/clipboardStore'
|
import { useClipboardStore, type ClipboardItem, type ClipboardKind, type ClipboardItemDetail } from '@/stores/clipboardStore'
|
||||||
import { useModuleTabs } from '@/lib/use-module-tabs'
|
import { useModuleTabs } from '@/lib/use-module-tabs'
|
||||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
|
import { Tabs, TabsContent } from '@/components/ui/tabs'
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Card, CardContent } from '@/components/ui/card'
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Switch } from '@/components/ui/switch'
|
import { Switch } from '@/components/ui/switch'
|
||||||
@@ -33,11 +32,11 @@ import {
|
|||||||
const store = useClipboardStore()
|
const store = useClipboardStore()
|
||||||
|
|
||||||
const activeTab = ref('history')
|
const activeTab = ref('history')
|
||||||
const tabsListRef = useModuleTabs('clipboard', activeTab, [
|
useModuleTabs('clipboard', activeTab, [
|
||||||
{ value: 'history', label: '历史' },
|
{ value: 'history', label: '历史' },
|
||||||
{ value: 'pinned', label: '固定' },
|
{ value: 'pinned', label: '固定' },
|
||||||
{ value: 'settings', label: '设置' },
|
{ value: 'settings', label: '设置' },
|
||||||
])
|
], { alwaysVisible: true })
|
||||||
|
|
||||||
// 历史搜索/过滤
|
// 历史搜索/过滤
|
||||||
const searchQuery = ref('')
|
const searchQuery = ref('')
|
||||||
@@ -276,10 +275,10 @@ const kindIcon = (k: string) => {
|
|||||||
const kindLabel = (k: string) => (k === 'text' ? '文本' : k === 'image' ? '图片' : '文件')
|
const kindLabel = (k: string) => (k === 'text' ? '文本' : k === 'image' ? '图片' : '文件')
|
||||||
const kindBadgeClass = (k: string) =>
|
const kindBadgeClass = (k: string) =>
|
||||||
k === 'text'
|
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'
|
: k === 'image'
|
||||||
? 'border-emerald-500/40 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'
|
? 'border-success/40 bg-success-soft text-success-text'
|
||||||
: 'border-amber-500/40 bg-amber-500/10 text-amber-600 dark:text-amber-400'
|
: 'border-warning/40 bg-warning-soft text-warning-text'
|
||||||
const formatSize = (bytes: number) => {
|
const formatSize = (bytes: number) => {
|
||||||
if (!bytes) return ''
|
if (!bytes) return ''
|
||||||
if (bytes < 1024) return `${bytes} B`
|
if (bytes < 1024) return `${bytes} B`
|
||||||
@@ -320,18 +319,10 @@ onUnmounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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">
|
<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="flex items-center gap-2 mb-3 shrink-0">
|
||||||
<div class="relative flex-1 max-w-sm">
|
<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" />
|
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
|
||||||
@@ -392,7 +383,7 @@ onUnmounted(() => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ScrollArea class="flex-1 min-h-0">
|
<div class="flex-1 min-h-0">
|
||||||
<div class="space-y-1.5 pr-2">
|
<div class="space-y-1.5 pr-2">
|
||||||
<!-- 加载骨架:数据异步返回前撑起画面,避免误显示"暂无历史" -->
|
<!-- 加载骨架:数据异步返回前撑起画面,避免误显示"暂无历史" -->
|
||||||
<div v-if="store.loading" class="space-y-1.5">
|
<div v-if="store.loading" class="space-y-1.5">
|
||||||
@@ -450,12 +441,17 @@ onUnmounted(() => {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<!-- 固定 -->
|
<!-- 固定 -->
|
||||||
<TabsContent value="pinned" class="flex-1 min-h-0 flex flex-col mt-4 tab-animate">
|
<TabsContent value="pinned" class="flex-1 min-h-0 flex flex-col tab-animate">
|
||||||
<div class="flex-1 min-h-0 overflow-y-auto space-y-1.5 pr-1">
|
<!--
|
||||||
|
这份列表是模块内的独立滚动区:视口会裁掉超出自身的阴影,而这里的卡片
|
||||||
|
悬停时是 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">
|
<Empty v-if="!pinnedList.length" class="h-full py-8">
|
||||||
<EmptyHeader>
|
<EmptyHeader>
|
||||||
<EmptyMedia variant="icon">
|
<EmptyMedia variant="icon">
|
||||||
@@ -467,6 +463,8 @@ onUnmounted(() => {
|
|||||||
<EmptyDescription>鼠标悬停历史条目,点击图钉按钮即可固定</EmptyDescription>
|
<EmptyDescription>鼠标悬停历史条目,点击图钉按钮即可固定</EmptyDescription>
|
||||||
</EmptyContent>
|
</EmptyContent>
|
||||||
</Empty>
|
</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">
|
<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">
|
<CardContent class="flex items-center gap-3 px-3 py-2">
|
||||||
<component :is="kindIcon(item.kind)" class="size-4 text-primary shrink-0" />
|
<component :is="kindIcon(item.kind)" class="size-4 text-primary shrink-0" />
|
||||||
@@ -491,20 +489,29 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<!-- 设置 -->
|
<!--
|
||||||
<TabsContent value="settings" class="flex-1 min-h-0 overflow-y-auto mt-4 tab-animate">
|
设置页同样自带滚动(只有内容区滚,Tab 行固定)。内层 `px-3 pb-3` 是
|
||||||
<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>
|
|
||||||
|
|
||||||
|
卡片形态对齐下载器/监控等模块的设置页:标题进卡片(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>
|
<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 class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<Label>启用剪贴板监听</Label>
|
<Label>启用剪贴板监听</Label>
|
||||||
@@ -546,9 +553,16 @@ onUnmounted(() => {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<!-- 容量与上限 -->
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent class="space-y-4 p-4">
|
<CardHeader>
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<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">
|
<div class="space-y-2">
|
||||||
<Label>最大历史条数</Label>
|
<Label>最大历史条数</Label>
|
||||||
<Input v-model.number="form.maxItems" type="number" min="50" max="10000" />
|
<Input v-model.number="form.maxItems" type="number" min="50" max="10000" />
|
||||||
@@ -563,13 +577,17 @@ onUnmounted(() => {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<!-- 快捷弹窗 -->
|
||||||
<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 class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<Label class="flex items-center gap-2">
|
<Label>快捷弹窗快捷键</Label>
|
||||||
<Keyboard class="size-4" />快捷弹窗快捷键
|
|
||||||
</Label>
|
|
||||||
<p class="text-xs text-muted-foreground mt-1">全局快捷键触发鼠标位置历史弹窗,留空禁用</p>
|
<p class="text-xs text-muted-foreground mt-1">全局快捷键触发鼠标位置历史弹窗,留空禁用</p>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" size="sm" @click="handleTestPopup">测试弹窗</Button>
|
<Button variant="outline" size="sm" @click="handleTestPopup">测试弹窗</Button>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
|
|||||||
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
|
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
|
||||||
import { getCurrentWindow } from '@tauri-apps/api/window'
|
import { getCurrentWindow } from '@tauri-apps/api/window'
|
||||||
import { EVENTS, STORAGE_KEYS } from '@/lib/constants'
|
import { EVENTS, STORAGE_KEYS } from '@/lib/constants'
|
||||||
|
import { applyStoredAccent } from '@/lib/theme'
|
||||||
import { Effect, EffectState } from '@tauri-apps/api/window'
|
import { Effect, EffectState } from '@tauri-apps/api/window'
|
||||||
// Rust 端通过 tauri-specta 生成的命令绑定(bindings.ts)
|
// Rust 端通过 tauri-specta 生成的命令绑定(bindings.ts)
|
||||||
import { commands } from '@/lib/bindings'
|
import { commands } from '@/lib/bindings'
|
||||||
@@ -36,7 +37,7 @@ const kindFilter = ref<'all' | 'text' | 'image' | 'files'>('all')
|
|||||||
const selectedIndex = ref(0)
|
const selectedIndex = ref(0)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const searchInputRef = ref<HTMLInputElement | null>(null)
|
const searchInputRef = ref<HTMLInputElement | null>(null)
|
||||||
let unlistenFns: UnlistenFn[] = []
|
const unlistenFns: UnlistenFn[] = []
|
||||||
let searchTimer: ReturnType<typeof setTimeout> | null = null
|
let searchTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / PAGE_SIZE)))
|
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.remove('dark', 'effect-mica', 'effect-acrylic', 'effect-normal')
|
||||||
root.classList.add(`effect-${effect}`)
|
root.classList.add(`effect-${effect}`)
|
||||||
if (isDark) root.classList.add('dark')
|
if (isDark) root.classList.add('dark')
|
||||||
|
// 主题色:与主窗口共用同一份设置
|
||||||
|
applyStoredAccent(root, isDark ? 'dark' : 'light', STORAGE_KEYS.appSettings)
|
||||||
|
|
||||||
// 4. 设置窗口效果与背景色
|
// 4. 设置窗口效果与背景色
|
||||||
// 弹窗窗口创建时 transparent=true,透明窗口下原生背景色不显示,
|
// 弹窗窗口创建时 transparent=true,透明窗口下原生背景色不显示,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { ref, onMounted, onUnmounted } from 'vue'
|
|||||||
import { listen, emit, type UnlistenFn } from '@tauri-apps/api/event'
|
import { listen, emit, type UnlistenFn } from '@tauri-apps/api/event'
|
||||||
import { getCurrentWindow, Effect, EffectState } from '@tauri-apps/api/window'
|
import { getCurrentWindow, Effect, EffectState } from '@tauri-apps/api/window'
|
||||||
import { EVENTS, STORAGE_KEYS } from '@/lib/constants'
|
import { EVENTS, STORAGE_KEYS } from '@/lib/constants'
|
||||||
|
import { applyStoredAccent } from '@/lib/theme'
|
||||||
import { commands } from '@/lib/bindings'
|
import { commands } from '@/lib/bindings'
|
||||||
import { ZoomIn, ZoomOut, FileText, Image as ImageIcon } from '@lucide/vue'
|
import { ZoomIn, ZoomOut, FileText, Image as ImageIcon } from '@lucide/vue'
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||||
@@ -14,7 +15,7 @@ const thumbSrc = ref('')
|
|||||||
const fullSrc = ref('')
|
const fullSrc = ref('')
|
||||||
const enlarged = ref(false)
|
const enlarged = ref(false)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
let unlistenFns: UnlistenFn[] = []
|
const unlistenFns: UnlistenFn[] = []
|
||||||
// 加载序号:连续悬停快速切换时丢弃过期请求结果
|
// 加载序号:连续悬停快速切换时丢弃过期请求结果
|
||||||
let loadSeq = 0
|
let loadSeq = 0
|
||||||
|
|
||||||
@@ -208,6 +209,8 @@ async function applyTheme() {
|
|||||||
root.classList.remove('dark', 'effect-mica', 'effect-acrylic', 'effect-normal')
|
root.classList.remove('dark', 'effect-mica', 'effect-acrylic', 'effect-normal')
|
||||||
root.classList.add(`effect-${effect}`)
|
root.classList.add(`effect-${effect}`)
|
||||||
if (isDark) root.classList.add('dark')
|
if (isDark) root.classList.add('dark')
|
||||||
|
// 主题色:与主窗口共用同一份设置
|
||||||
|
applyStoredAccent(root, isDark ? 'dark' : 'light', STORAGE_KEYS.appSettings)
|
||||||
try {
|
try {
|
||||||
const tauriWin = getCurrentWindow()
|
const tauriWin = getCurrentWindow()
|
||||||
await tauriWin.clearEffects()
|
await tauriWin.clearEffects()
|
||||||
|
|||||||
@@ -55,5 +55,5 @@ export const moduleConfig: ModuleConfig = {
|
|||||||
defaultEnabled: true,
|
defaultEnabled: true,
|
||||||
loader: () => import('./ClipboardModule.vue'),
|
loader: () => import('./ClipboardModule.vue'),
|
||||||
searchItems,
|
searchItems,
|
||||||
order: 20
|
order: 80
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { ArrowLeft, Search } from '@lucide/vue'
|
|||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Card, CardContent } from '@/components/ui/card'
|
import { Card, CardContent } from '@/components/ui/card'
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
|
||||||
import { Separator } from '@/components/ui/separator'
|
import { Separator } from '@/components/ui/separator'
|
||||||
import { useSearchStore } from '@/stores/searchStore'
|
import { useSearchStore } from '@/stores/searchStore'
|
||||||
import { registerAllTools, TOOLS_META } from './tools'
|
import { registerAllTools, TOOLS_META } from './tools'
|
||||||
@@ -77,21 +76,46 @@ watch(selectedId, () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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">
|
<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">
|
<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" />
|
<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" />
|
<Input v-model="query" placeholder="搜索工具..." class="h-8 pl-8 text-sm" />
|
||||||
</div>
|
</div>
|
||||||
|
<!--
|
||||||
|
分类筛选:选中态用主题色软底(与 SegmentedNav 及其余模块的筛选 chip 同一
|
||||||
|
口径)。此前写的是 `bg-secondary text-foreground` —— 灰底黑字读起来像
|
||||||
|
「禁用」,也是全项目唯一用实心灰表达选中的地方。
|
||||||
|
-->
|
||||||
<Button
|
<Button
|
||||||
v-for="cat in (['all', ...CATEGORY_ORDER] as const)"
|
v-for="cat in (['all', ...CATEGORY_ORDER] as const)"
|
||||||
:key="cat"
|
:key="cat"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
class="h-7 px-2.5 text-xs"
|
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"
|
@click="category = cat as typeof category"
|
||||||
>
|
>
|
||||||
{{ cat === 'all' ? '全部' : CATEGORY_LABEL[cat as DevTool['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>
|
<span class="text-xs text-muted-foreground ml-auto">{{ (category === 'all' ? allTools : filteredTools).length }} 个工具</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ScrollArea class="flex-1 min-h-0 -mr-3 pr-3">
|
<!-- 空态:flex-1 撑住剩余高度,短页面时也能垂直居中 -->
|
||||||
<div v-if="filteredTools.length === 0" class="h-full flex items-center justify-center text-muted-foreground text-sm">
|
<div
|
||||||
没有找到匹配的工具
|
v-if="filteredTools.length === 0"
|
||||||
</div>
|
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 v-for="group in groupedTools" :key="group.category" class="mb-5">
|
||||||
<div class="flex items-center gap-2 mb-2">
|
<div class="flex items-center gap-2 mb-2">
|
||||||
<span class="text-xs font-medium text-muted-foreground">{{ group.label }}</span>
|
<span class="text-xs font-medium text-muted-foreground">{{ group.label }}</span>
|
||||||
@@ -129,12 +157,12 @@ watch(selectedId, () => {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- 工具详情视图 -->
|
<!-- 工具详情视图 -->
|
||||||
<template v-else>
|
<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">
|
<Button size="sm" variant="ghost" class="gap-1 h-8" @click="selectedId = null">
|
||||||
<ArrowLeft class="size-4" />
|
<ArrowLeft class="size-4" />
|
||||||
返回
|
返回
|
||||||
@@ -147,11 +175,7 @@ watch(selectedId, () => {
|
|||||||
<span class="text-xs text-muted-foreground leading-snug">{{ selectedTool.description }}</span>
|
<span class="text-xs text-muted-foreground leading-snug">{{ selectedTool.description }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ScrollArea class="flex-1 min-h-0 -mr-3 pr-3">
|
<component :is="selectedTool.component" />
|
||||||
<div class="max-w-3xl px-1 pb-6">
|
|
||||||
<component :is="selectedTool.component" />
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -58,7 +58,7 @@ const onInput = (v: string | number) => {
|
|||||||
:title="copied ? '已复制' : '复制'"
|
:title="copied ? '已复制' : '复制'"
|
||||||
@click="copy"
|
@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" />
|
<Copy v-else class="size-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -23,5 +23,5 @@ export const moduleConfig: ModuleConfig = {
|
|||||||
onEnable: async () => {},
|
onEnable: async () => {},
|
||||||
onDisable: async () => {}
|
onDisable: async () => {}
|
||||||
},
|
},
|
||||||
order: 60
|
order: 100
|
||||||
}
|
}
|
||||||
@@ -12,7 +12,6 @@ type Tab = 'convert' | 'file'
|
|||||||
const tab = ref<Tab>('convert')
|
const tab = ref<Tab>('convert')
|
||||||
const mode = ref<'encode' | 'decode'>('encode')
|
const mode = ref<'encode' | 'decode'>('encode')
|
||||||
const input = ref('')
|
const input = ref('')
|
||||||
const error = ref('')
|
|
||||||
|
|
||||||
function encode(s: string): string {
|
function encode(s: string): string {
|
||||||
const bytes = new TextEncoder().encode(s)
|
const bytes = new TextEncoder().encode(s)
|
||||||
@@ -28,17 +27,18 @@ function decode(s: string): string {
|
|||||||
return new TextDecoder().decode(bytes)
|
return new TextDecoder().decode(bytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
const output = computed(() => {
|
// 一次试算同时产出结果与错误,computed 内保持无副作用
|
||||||
error.value = ''
|
const result = computed(() => {
|
||||||
const v = input.value
|
const v = input.value
|
||||||
if (!v) return ''
|
if (!v) return { out: '', err: '' }
|
||||||
try {
|
try {
|
||||||
return mode.value === 'encode' ? encode(v) : decode(v)
|
return { out: mode.value === 'encode' ? encode(v) : decode(v), err: '' }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = '解码失败:' + String(e)
|
return { out: '', err: '解码失败:' + String(e) }
|
||||||
return ''
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
const output = computed(() => result.value.out)
|
||||||
|
const error = computed(() => result.value.err)
|
||||||
|
|
||||||
const swap = () => {
|
const swap = () => {
|
||||||
if (output.value) {
|
if (output.value) {
|
||||||
|
|||||||
@@ -111,11 +111,11 @@ const unifiedText = computed(() => {
|
|||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-4">
|
||||||
<div class="grid grid-cols-2 gap-3">
|
<div class="grid grid-cols-2 gap-3">
|
||||||
<div class="flex flex-col gap-1.5">
|
<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" />
|
<Textarea v-model="oldText" placeholder="旧文本..." class="min-h-[140px] font-mono text-xs resize-y" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col gap-1.5">
|
<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" />
|
<Textarea v-model="newText" placeholder="新文本..." class="min-h-[140px] font-mono text-xs resize-y" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -130,8 +130,8 @@ const unifiedText = computed(() => {
|
|||||||
:key="i"
|
:key="i"
|
||||||
class="flex whitespace-pre px-2 py-0.5"
|
class="flex whitespace-pre px-2 py-0.5"
|
||||||
:class="{
|
:class="{
|
||||||
'bg-red-500/10 text-red-600 dark:text-red-400': l.op === 'del',
|
'bg-danger-soft text-danger-text': l.op === 'del',
|
||||||
'bg-green-500/10 text-green-600 dark:text-green-400': l.op === 'add',
|
'bg-success-soft text-success-text': l.op === 'add',
|
||||||
'text-muted-foreground': l.op === 'equal'
|
'text-muted-foreground': l.op === 'equal'
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -118,11 +118,11 @@ const MIMES: MimeDef[] = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
const STATUS_CLASS: Record<string, string> = {
|
const STATUS_CLASS: Record<string, string> = {
|
||||||
'1xx': 'bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/30',
|
'1xx': 'bg-info-soft text-info-text border-info/30',
|
||||||
'2xx': 'bg-green-500/10 text-green-600 dark:text-green-400 border-green-500/30',
|
'2xx': 'bg-success-soft text-success-text border-success/30',
|
||||||
'3xx': 'bg-blue-500/10 text-blue-600 dark:text-blue-400 border-blue-500/30',
|
'3xx': 'bg-info-soft text-info-text border-info/30',
|
||||||
'4xx': 'bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/30',
|
'4xx': 'bg-warning-soft text-warning-text border-warning/30',
|
||||||
'5xx': 'bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/30'
|
'5xx': 'bg-danger-soft text-danger-text border-danger/30'
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusGroups = computed(() => {
|
const statusGroups = computed(() => {
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ const converted = computed(() => {
|
|||||||
<template v-if="cidr">
|
<template v-if="cidr">
|
||||||
<p v-if="cidr.error" class="text-xs text-destructive">{{ cidr.error }}</p>
|
<p v-if="cidr.error" class="text-xs text-destructive">{{ cidr.error }}</p>
|
||||||
<template v-else>
|
<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 }} 计算。
|
注意:输入地址不是该子网的网络地址,已按网络 {{ cidr.network }}/{{ cidr.prefix }} 计算。
|
||||||
</p>
|
</p>
|
||||||
<div class="rounded-md border border-border divide-y divide-border text-xs">
|
<div class="rounded-md border border-border divide-y divide-border text-xs">
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ function normalizeReplacer(sort: boolean): (this: unknown, key: string, value: u
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="isValid !== false" class="flex items-center gap-2 text-xs">
|
<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>
|
</div>
|
||||||
<ResultArea :text="output" placeholder="结果" />
|
<ResultArea :text="output" placeholder="结果" />
|
||||||
<p v-if="error" class="text-xs text-destructive break-all">{{ error }}</p>
|
<p v-if="error" class="text-xs text-destructive break-all">{{ error }}</p>
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { Badge } from '@/components/ui/badge'
|
|||||||
import ResultArea from '../components/ResultArea.vue'
|
import ResultArea from '../components/ResultArea.vue'
|
||||||
|
|
||||||
const token = ref('')
|
const token = ref('')
|
||||||
const error = ref('')
|
|
||||||
|
|
||||||
function base64UrlDecode(s: string): string {
|
function base64UrlDecode(s: string): string {
|
||||||
const cleaned = s.replace(/-/g, '+').replace(/_/g, '/')
|
const cleaned = s.replace(/-/g, '+').replace(/_/g, '/')
|
||||||
@@ -24,30 +23,33 @@ interface TimeClaim {
|
|||||||
status: 'ok' | 'expired' | 'not-yet' | 'unknown'
|
status: 'ok' | 'expired' | 'not-yet' | 'unknown'
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsed = computed<{
|
// 一次解析同时产出数据与错误,computed 内保持无副作用
|
||||||
|
const parsedResult = computed<{ data: {
|
||||||
header: string
|
header: string
|
||||||
payload: string
|
payload: string
|
||||||
signature: string
|
signature: string
|
||||||
timeClaims: TimeClaim[]
|
timeClaims: TimeClaim[]
|
||||||
} | null>(() => {
|
} | null; err: string }>(() => {
|
||||||
error.value = ''
|
|
||||||
const t = token.value.trim()
|
const t = token.value.trim()
|
||||||
if (!t) return null
|
if (!t) return { data: null, err: '' }
|
||||||
const parts = t.split('.')
|
const parts = t.split('.')
|
||||||
if (parts.length < 2) {
|
if (parts.length < 2) {
|
||||||
error.value = 'JWT 格式不正确(应为 header.payload.signature)'
|
return { data: null, err: 'JWT 格式不正确(应为 header.payload.signature)' }
|
||||||
return null
|
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const header = formatJson(base64UrlDecode(parts[0]))
|
const header = formatJson(base64UrlDecode(parts[0]))
|
||||||
const payload = formatJson(base64UrlDecode(parts[1]))
|
const payload = formatJson(base64UrlDecode(parts[1]))
|
||||||
const signature = parts[2] ?? ''
|
const signature = parts[2] ?? ''
|
||||||
return { header, payload, signature, timeClaims: parseTimeClaims(payload) }
|
return {
|
||||||
|
data: { header, payload, signature, timeClaims: parseTimeClaims(payload) },
|
||||||
|
err: ''
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = '解码失败:' + String(e)
|
return { data: null, err: '解码失败:' + String(e) }
|
||||||
return null
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
const parsed = computed(() => parsedResult.value.data)
|
||||||
|
const error = computed(() => parsedResult.value.err)
|
||||||
|
|
||||||
function formatJson(s: string): string {
|
function formatJson(s: string): string {
|
||||||
let pretty = s
|
let pretty = s
|
||||||
@@ -90,9 +92,9 @@ function parseTimeClaims(payload: string): TimeClaim[] {
|
|||||||
|
|
||||||
const STATUS_BADGE: Record<TimeClaim['status'], { label: string; class: string } | null> = {
|
const STATUS_BADGE: Record<TimeClaim['status'], { label: string; class: string } | null> = {
|
||||||
ok: null,
|
ok: null,
|
||||||
expired: { label: '已过期', class: 'bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/30' },
|
expired: { label: '已过期', class: 'bg-danger-soft text-danger-text border-danger/30' },
|
||||||
'not-yet': { label: '尚未生效', class: 'bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/30' },
|
'not-yet': { label: '尚未生效', class: 'bg-warning-soft text-warning-text border-warning/30' },
|
||||||
unknown: { label: '签发时间在未来(时钟偏差?)', class: 'bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/30' }
|
unknown: { label: '签发时间在未来(时钟偏差?)', class: 'bg-warning-soft text-warning-text border-warning/30' }
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -73,10 +73,10 @@ const entropy = computed(() => {
|
|||||||
|
|
||||||
const strength = computed(() => {
|
const strength = computed(() => {
|
||||||
const e = entropy.value
|
const e = entropy.value
|
||||||
if (e >= 128) return { label: '极强(128+ bit)', class: 'text-green-600 dark:text-green-400' }
|
if (e >= 128) return { label: '极强(128+ bit)', class: 'text-success-text' }
|
||||||
if (e >= 80) return { label: '强(80-127 bit)', class: 'text-green-600 dark:text-green-400' }
|
if (e >= 80) return { label: '强(80-127 bit)', class: 'text-success-text' }
|
||||||
if (e >= 60) return { label: '中等(60-79 bit)', class: 'text-amber-600 dark:text-amber-400' }
|
if (e >= 60) return { label: '中等(60-79 bit)', class: 'text-warning-text' }
|
||||||
return { label: '弱(<60 bit)', class: 'text-red-600 dark:text-red-400' }
|
return { label: '弱(<60 bit)', class: 'text-danger-text' }
|
||||||
})
|
})
|
||||||
|
|
||||||
function generate() {
|
function generate() {
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ const bases: { value: Radix; label: string }[] = [
|
|||||||
const from = ref<Radix>('10')
|
const from = ref<Radix>('10')
|
||||||
const to = ref<Radix>('16')
|
const to = ref<Radix>('16')
|
||||||
const input = ref('')
|
const input = ref('')
|
||||||
const error = ref('')
|
|
||||||
|
|
||||||
const RADIX_MAP: Record<Radix, number> = { '2': 2, '8': 8, '10': 10, '16': 16 }
|
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
|
return negative ? -n : n
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 一次试算同时产出结果与错误,computed 内保持无副作用
|
||||||
const result = computed(() => {
|
const result = computed(() => {
|
||||||
error.value = ''
|
|
||||||
const v = input.value.trim()
|
const v = input.value.trim()
|
||||||
if (!v) return { dec: null, output: '', outputUppercase: '' }
|
if (!v) return { dec: null, output: '', outputUppercase: '', err: '' }
|
||||||
try {
|
try {
|
||||||
const parsed = parseBigInt(v, RADIX_MAP[from.value])
|
const parsed = parseBigInt(v, RADIX_MAP[from.value])
|
||||||
const out = parsed.toString(RADIX_MAP[to.value])
|
const out = parsed.toString(RADIX_MAP[to.value])
|
||||||
return {
|
return {
|
||||||
dec: parsed,
|
dec: parsed,
|
||||||
output: out,
|
output: out,
|
||||||
outputUppercase: to.value === '16' ? out.toUpperCase() : out
|
outputUppercase: to.value === '16' ? out.toUpperCase() : out,
|
||||||
|
err: ''
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
error.value = '输入不合法,请确认数字与当前进制匹配(如十六进制仅含 0-9a-f)'
|
return {
|
||||||
return { dec: null, output: '', outputUppercase: '' }
|
dec: null,
|
||||||
|
output: '',
|
||||||
|
outputUppercase: '',
|
||||||
|
err: '输入不合法,请确认数字与当前进制匹配(如十六进制仅含 0-9a-f)'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
const error = computed(() => result.value.err)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -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 { Pause, Play, X, Minus, FolderOpen, AlertCircle, Loader2, XCircle } from '@lucide/vue'
|
||||||
import { commands, type DownloadTask, type Segment, type TaskStatus } from '@/lib/bindings'
|
import { commands, type DownloadTask, type Segment, type TaskStatus } from '@/lib/bindings'
|
||||||
import { STORAGE_KEYS } from '@/lib/constants'
|
import { STORAGE_KEYS } from '@/lib/constants'
|
||||||
|
import { applyStoredAccent } from '@/lib/theme'
|
||||||
import { createLogger } from '@/lib/logger'
|
import { createLogger } from '@/lib/logger'
|
||||||
|
|
||||||
const logger = createLogger('download-window')
|
const logger = createLogger('download-window')
|
||||||
@@ -68,6 +69,7 @@ const statusText = computed(() => {
|
|||||||
case 'complete': return '已完成'
|
case 'complete': return '已完成'
|
||||||
case 'error': return '出错'
|
case 'error': return '出错'
|
||||||
case 'cancelled': return '已取消'
|
case 'cancelled': return '已取消'
|
||||||
|
default: return ''
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
const statusTone = computed(() => {
|
const statusTone = computed(() => {
|
||||||
@@ -175,6 +177,8 @@ async function applyTheme() {
|
|||||||
root.classList.remove('dark', 'effect-mica', 'effect-acrylic', 'effect-normal')
|
root.classList.remove('dark', 'effect-mica', 'effect-acrylic', 'effect-normal')
|
||||||
root.classList.add(`effect-${effect}`)
|
root.classList.add(`effect-${effect}`)
|
||||||
if (isDark) root.classList.add('dark')
|
if (isDark) root.classList.add('dark')
|
||||||
|
// 主题色:与主窗口共用同一份设置
|
||||||
|
applyStoredAccent(root, isDark ? 'dark' : 'light', STORAGE_KEYS.appSettings)
|
||||||
|
|
||||||
// 4. 设置窗口效果:mica/acrylic 下卡片透明,普通模式用不透明背景
|
// 4. 设置窗口效果:mica/acrylic 下卡片透明,普通模式用不透明背景
|
||||||
try {
|
try {
|
||||||
@@ -297,7 +301,7 @@ onUnmounted(() => {
|
|||||||
<div class="dlw-titlebar" data-tauri-drag-region>
|
<div class="dlw-titlebar" data-tauri-drag-region>
|
||||||
<div class="dlw-title-left" 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" />
|
<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" />
|
<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-if="loading" class="text-xs font-medium text-muted-foreground">下载</span>
|
||||||
<span v-else-if="task" class="dlw-title-name" :title="task.filename"
|
<span v-else-if="task" class="dlw-title-name" :title="task.filename"
|
||||||
@@ -363,7 +367,7 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</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 }}
|
{{ actionError }}
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -460,8 +464,8 @@ onUnmounted(() => {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.dlw-status-badge.tone-active { background: var(--primary); color: var(--primary-foreground); }
|
.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-success { background: var(--success); color: #fff; }
|
||||||
.dlw-status-badge.tone-destructive { background: #ef4444; 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-status-badge.tone-muted { background: var(--muted); color: var(--muted-foreground); }
|
||||||
|
|
||||||
.dlw-bar { height: 7px; border-radius: 999px; background: var(--muted); overflow: hidden; }
|
.dlw-bar { height: 7px; border-radius: 999px; background: var(--muted); overflow: hidden; }
|
||||||
@@ -487,8 +491,8 @@ onUnmounted(() => {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.dlw-btn:hover { background: var(--accent); color: var(--accent-foreground); }
|
.dlw-btn:hover { background: var(--accent); color: var(--accent-foreground); }
|
||||||
.dlw-btn-danger { color: #ef4444; }
|
.dlw-btn-danger { color: var(--danger); }
|
||||||
.dlw-btn-danger:hover { background: rgb(239 68 68 / 0.12); color: #dc2626; }
|
.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; }
|
.dlw-btn-primary { background: var(--primary); color: var(--primary-foreground); border-color: transparent; }
|
||||||
/* hover 时显式保持前景色,避免被上面的 .dlw-btn:hover 覆盖成深色导致黑字黑底不可见 */
|
/* hover 时显式保持前景色,避免被上面的 .dlw-btn:hover 覆盖成深色导致黑字黑底不可见 */
|
||||||
.dlw-btn-primary:hover { background: var(--primary); color: var(--primary-foreground); filter: brightness(1.05); }
|
.dlw-btn-primary:hover { background: var(--primary); color: var(--primary-foreground); filter: brightness(1.05); }
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import { Input } from '@/components/ui/input'
|
|||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
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 { ScrollArea } from '@/components/ui/scroll-area'
|
||||||
import { Separator } from '@/components/ui/separator'
|
import { Separator } from '@/components/ui/separator'
|
||||||
import { Progress } from '@/components/ui/progress'
|
import { Progress } from '@/components/ui/progress'
|
||||||
@@ -44,11 +44,11 @@ const logger = createLogger('downloader')
|
|||||||
// ===== 主 Tab 状态 =====
|
// ===== 主 Tab 状态 =====
|
||||||
const activeTab = ref('tasks')
|
const activeTab = ref('tasks')
|
||||||
|
|
||||||
const tabsListRef = useModuleTabs('downloader', activeTab, [
|
useModuleTabs('downloader', activeTab, [
|
||||||
{ value: 'tasks', label: '下载任务' },
|
{ value: 'tasks', label: '下载任务' },
|
||||||
{ value: 'settings', label: '设置' },
|
{ value: 'settings', label: '设置' },
|
||||||
{ value: 'extension', label: '浏览器扩展' }
|
{ value: 'extension', label: '浏览器扩展' }
|
||||||
])
|
], { alwaysVisible: true })
|
||||||
|
|
||||||
// 添加下载表单
|
// 添加下载表单
|
||||||
const addUriText = ref('')
|
const addUriText = ref('')
|
||||||
@@ -237,8 +237,6 @@ const SORT_OPTIONS: { label: string; value: SortField }[] = [
|
|||||||
{ label: '下载大小', value: 'size' }
|
{ label: '下载大小', value: 'size' }
|
||||||
]
|
]
|
||||||
|
|
||||||
const running = computed(() => store.status.running)
|
|
||||||
|
|
||||||
// ===== 工具函数 =====
|
// ===== 工具函数 =====
|
||||||
/** BT 第 i 个文件已下载字节(progress 事件把每文件进度写入 segments[i].completed) */
|
/** BT 第 i 个文件已下载字节(progress 事件把每文件进度写入 segments[i].completed) */
|
||||||
const btFileDownloaded = (task: DownloadTask, i: number): number =>
|
const btFileDownloaded = (task: DownloadTask, i: number): number =>
|
||||||
@@ -347,7 +345,7 @@ const handleResume = async (id: string) => {
|
|||||||
|
|
||||||
// ===== 取消下载(参考下载窗口:二次点击确认,3 秒内未二次点击则取消)=====
|
// ===== 取消下载(参考下载窗口:二次点击确认,3 秒内未二次点击则取消)=====
|
||||||
const cancelArmed = ref<Record<string, boolean>>({})
|
const cancelArmed = ref<Record<string, boolean>>({})
|
||||||
let cancelArmTimers: Record<string, number> = {}
|
const cancelArmTimers: Record<string, number> = {}
|
||||||
const handleCancel = (id: string) => {
|
const handleCancel = (id: string) => {
|
||||||
if (cancelArmed.value[id]) {
|
if (cancelArmed.value[id]) {
|
||||||
// 二次点击:执行取消
|
// 二次点击:执行取消
|
||||||
@@ -813,13 +811,6 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
const allTasks = computed<DownloadTask[]>(() => store.tasks)
|
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[]>(() => {
|
const filteredByStatus = computed<DownloadTask[]>(() => {
|
||||||
if (statusFilter.value === 'all') return allTasks.value
|
if (statusFilter.value === 'all') return allTasks.value
|
||||||
@@ -878,79 +869,11 @@ const toggleSortOrder = () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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="h-full flex flex-col">
|
<Tabs v-model="activeTab" class="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>
|
|
||||||
|
|
||||||
<!-- ===== 下载任务 ===== -->
|
<!-- ===== 下载任务 ===== -->
|
||||||
<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">
|
<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">
|
<div class="shrink-0 flex items-center gap-2 flex-wrap">
|
||||||
<!-- 新建下载按钮 -->
|
<!-- 新建下载按钮 -->
|
||||||
@@ -1052,13 +975,28 @@ const toggleSortOrder = () => {
|
|||||||
下载设置
|
下载设置
|
||||||
</Button>
|
</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">
|
<span class="text-xs text-muted-foreground ml-auto">
|
||||||
共 {{ sortedTasks.length }} 个任务
|
共 {{ sortedTasks.length }} 个任务
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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">
|
<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" />
|
<CheckCircle2 class="size-16 opacity-30" />
|
||||||
<p>暂无符合条件的任务</p>
|
<p>暂无符合条件的任务</p>
|
||||||
@@ -1093,7 +1031,7 @@ const toggleSortOrder = () => {
|
|||||||
<span>
|
<span>
|
||||||
{{ formatSize(task.completedSize) }} / {{ formatTotalSize(task.totalSize) }}
|
{{ formatSize(task.completedSize) }} / {{ formatTotalSize(task.totalSize) }}
|
||||||
</span>
|
</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" />
|
<Download class="size-3" />
|
||||||
{{ formatSpeed(task.speed) }}
|
{{ formatSpeed(task.speed) }}
|
||||||
</span>
|
</span>
|
||||||
@@ -1287,14 +1225,14 @@ const toggleSortOrder = () => {
|
|||||||
<TooltipContent>最后一页</TooltipContent>
|
<TooltipContent>最后一页</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<!-- ===== 设置 ===== -->
|
<!-- ===== 设置 ===== -->
|
||||||
<TabsContent value="settings" class="flex-1 mt-4 min-h-0 tab-animate">
|
<TabsContent value="settings" class="flex-1 min-h-0 tab-animate">
|
||||||
<ScrollArea class="h-full pr-3">
|
<div>
|
||||||
<div class="flex flex-col gap-4 pb-4 max-w-2xl">
|
<div class="flex flex-col gap-4 pb-4">
|
||||||
<!-- 下载设置 -->
|
<!-- 下载设置 -->
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -1482,13 +1420,13 @@ const toggleSortOrder = () => {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<!-- ===== 浏览器扩展 ===== -->
|
<!-- ===== 浏览器扩展 ===== -->
|
||||||
<TabsContent value="extension" class="flex-1 mt-4 min-h-0 tab-animate">
|
<TabsContent value="extension" class="flex-1 min-h-0 tab-animate">
|
||||||
<ScrollArea class="h-full pr-3">
|
<div>
|
||||||
<div class="flex flex-col gap-4 pb-4 max-w-2xl">
|
<div class="flex flex-col gap-4 pb-4">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle class="flex items-center gap-2 text-base">
|
<CardTitle class="flex items-center gap-2 text-base">
|
||||||
@@ -1623,7 +1561,7 @@ const toggleSortOrder = () => {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
@@ -1879,7 +1817,7 @@ const toggleSortOrder = () => {
|
|||||||
<AlertDialogContent class="max-w-md">
|
<AlertDialogContent class="max-w-md">
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle class="flex items-center gap-2">
|
<AlertDialogTitle class="flex items-center gap-2">
|
||||||
<AlertCircle class="size-4 text-amber-500" />
|
<AlertCircle class="size-4 text-warning" />
|
||||||
下载重复提醒
|
下载重复提醒
|
||||||
</AlertDialogTitle>
|
</AlertDialogTitle>
|
||||||
<AlertDialogDescription class="text-xs">
|
<AlertDialogDescription class="text-xs">
|
||||||
|
|||||||
@@ -64,5 +64,5 @@ export const moduleConfig: ModuleConfig = {
|
|||||||
// 模块禁用时不停止引擎(引擎在应用退出时统一清理)
|
// 模块禁用时不停止引擎(引擎在应用退出时统一清理)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
order: 50
|
order: 30
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
ShieldCheck, ShieldOff, Zap, Thermometer, Clock, ChevronDown,
|
ShieldCheck, ShieldOff, Zap, Thermometer, Clock, ChevronDown,
|
||||||
ArrowDown, ArrowUp, Wifi, FolderOpen, Copy, ListChecks,
|
ArrowDown, ArrowUp, Wifi, FolderOpen, Copy, ListChecks,
|
||||||
Monitor as MonitorIcon, GripVertical, SlidersHorizontal,
|
Monitor as MonitorIcon, GripVertical, SlidersHorizontal,
|
||||||
Eye, EyeOff, MousePointerClick, Plus, PencilLine,
|
Eye, EyeOff, MousePointerClick, Check, PencilLine,
|
||||||
CircuitBoard, BatteryFull, Gamepad2,
|
CircuitBoard, BatteryFull, Gamepad2,
|
||||||
} from '@lucide/vue'
|
} from '@lucide/vue'
|
||||||
import type { LucideIcon } 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 { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Badge } from '@/components/ui/badge'
|
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 { ScrollArea } from '@/components/ui/scroll-area'
|
||||||
import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from '@/components/ui/accordion'
|
import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from '@/components/ui/accordion'
|
||||||
import { Progress } from '@/components/ui/progress'
|
import { Progress } from '@/components/ui/progress'
|
||||||
@@ -50,12 +50,12 @@ const store = useMonitorStore()
|
|||||||
|
|
||||||
// ===== Tab 配置(注册到 TitleBar 浮动切换器) =====
|
// ===== Tab 配置(注册到 TitleBar 浮动切换器) =====
|
||||||
const activeTab = ref('overview')
|
const activeTab = ref('overview')
|
||||||
const tabsListRef = useModuleTabs('monitor', activeTab, [
|
useModuleTabs('monitor', activeTab, [
|
||||||
{ value: 'overview', label: '概览' },
|
{ value: 'overview', label: '概览' },
|
||||||
{ value: 'details', label: '详细' },
|
{ value: 'details', label: '详细' },
|
||||||
{ value: 'osd', label: 'OSD 显示' },
|
{ value: 'osd', label: 'OSD 显示' },
|
||||||
{ value: 'settings', label: '设置' },
|
{ value: 'settings', label: '设置' },
|
||||||
])
|
], { alwaysVisible: true })
|
||||||
|
|
||||||
// ===== 关键指标 computed =====
|
// ===== 关键指标 computed =====
|
||||||
|
|
||||||
@@ -248,8 +248,14 @@ function saveOverviewCards() {
|
|||||||
|
|
||||||
/** 编辑模式:显示拖拽把手/移除按钮,启用拖拽排序 */
|
/** 编辑模式:显示拖拽把手/移除按钮,启用拖拽排序 */
|
||||||
const overviewEditing = ref(false)
|
const overviewEditing = ref(false)
|
||||||
/** 添加卡片 Popover 开关 */
|
/**
|
||||||
const overviewAddOpen = ref(false)
|
* 「布局」Popover 开关(编辑模式 + 添加卡片)。
|
||||||
|
*
|
||||||
|
* 两件事合并进同一个 popover:它们都是**对概览卡片集合的操作**,此前各占一个
|
||||||
|
* 文字按钮(「编辑布局」「添加卡片」),把横幅挤成了两行 —— 而横幅的规范是
|
||||||
|
* 「状态区 → 指标区 → 操作区」一行常驻(UI_DESIGN_SYSTEM §3.4)。
|
||||||
|
*/
|
||||||
|
const overviewEditOpen = ref(false)
|
||||||
|
|
||||||
/** 尚未添加的卡片(添加菜单内容) */
|
/** 尚未添加的卡片(添加菜单内容) */
|
||||||
const addableOverviewCards = computed(() =>
|
const addableOverviewCards = computed(() =>
|
||||||
@@ -260,7 +266,7 @@ function addOverviewCard(id: OverviewCardId) {
|
|||||||
if (overviewCards.value.includes(id)) return
|
if (overviewCards.value.includes(id)) return
|
||||||
overviewCards.value.push(id)
|
overviewCards.value.push(id)
|
||||||
saveOverviewCards()
|
saveOverviewCards()
|
||||||
overviewAddOpen.value = false
|
overviewEditOpen.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeOverviewCard(id: string) {
|
function removeOverviewCard(id: string) {
|
||||||
@@ -330,8 +336,8 @@ function buildOverviewCardView(id: OverviewCardId): OverviewCardView {
|
|||||||
case 'network':
|
case 'network':
|
||||||
return {
|
return {
|
||||||
id, title: '网络', icon: Wifi,
|
id, title: '网络', icon: Wifi,
|
||||||
main: { label: '下载', labelIcon: ArrowDown, text: downSpeed.value.value, unit: downSpeed.value.unit, colorClass: 'text-sky-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-violet-500' }],
|
subs: [{ label: '上传', labelIcon: ArrowUp, text: upSpeed.value.value, unit: upSpeed.value.unit, colorClass: 'text-data-up' }],
|
||||||
load: null,
|
load: null,
|
||||||
}
|
}
|
||||||
case 'storage':
|
case 'storage':
|
||||||
@@ -376,12 +382,13 @@ const overviewCardViews = computed<OverviewCardView[]>(() =>
|
|||||||
)
|
)
|
||||||
|
|
||||||
// ===== 连接状态徽章 =====
|
// ===== 连接状态徽章 =====
|
||||||
const stateMeta: Record<ConnectionState, { text: string; class: string }> = {
|
/** 横幅状态区用:状态点颜色 + 状态文字颜色(规范 §3.4 = 状态点 8px + 状态文字 13px/500) */
|
||||||
idle: { text: '未启动', class: 'bg-muted text-muted-foreground' },
|
const stateMeta: Record<ConnectionState, { text: string; dot: string; textClass: string }> = {
|
||||||
loading: { text: '启动中', class: 'bg-yellow-500/15 text-yellow-600 dark:text-yellow-400' },
|
idle: { text: '未启动', dot: 'bg-muted-foreground', textClass: 'text-muted-foreground' },
|
||||||
connected: { text: '已连接', class: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400' },
|
loading: { text: '启动中', dot: 'bg-warning', textClass: 'text-warning' },
|
||||||
disconnected: { text: '已断开', class: 'bg-orange-500/15 text-orange-600 dark:text-orange-400' },
|
connected: { text: '已连接', dot: 'bg-success', textClass: 'text-success' },
|
||||||
error: { text: '错误', class: 'bg-red-500/15 text-red-600 dark:text-red-400' },
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="h-full p-6">
|
<div class="min-h-full p-5">
|
||||||
<Tabs v-model="activeTab" class="h-full flex flex-col">
|
<Tabs v-model="activeTab" class="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>
|
|
||||||
|
|
||||||
<!-- ===== 概览 ===== -->
|
<!-- ===== 概览 ===== -->
|
||||||
<TabsContent value="overview" class="flex-1 mt-4 tab-animate">
|
<TabsContent value="overview" class="flex-1 tab-animate">
|
||||||
<ScrollArea class="h-full pr-3">
|
<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">
|
<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" />
|
<Loader2 class="size-8 animate-spin" />
|
||||||
@@ -1363,40 +1361,59 @@ watch(() => store.status?.ready, (ready, prev) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<!-- ===== Kernel 状态栏(置顶紧凑横条) ===== -->
|
<!-- ===== Kernel 状态横幅:状态区 → 指标区 → 操作区(UI_DESIGN_SYSTEM §3.4) ===== -->
|
||||||
<Card class="py-0 gap-0 mb-2.5">
|
<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">
|
<CardContent class="flex flex-wrap items-center gap-x-6 gap-y-3 px-4 py-4">
|
||||||
<span class="flex items-center gap-1.5 font-medium text-sm">
|
<!-- 状态区:状态点 8px + 状态文字 13px/500 + 进程名 13px 次要色 -->
|
||||||
<Activity class="size-4 text-primary" />Kernel
|
<div class="flex flex-wrap items-center gap-2.5">
|
||||||
</span>
|
<span class="flex items-center gap-1.5 text-[13px] font-medium" :class="stateMeta[store.connState].textClass">
|
||||||
<span :class="['px-2 py-0.5 rounded-full', stateMeta[store.connState].class]">
|
<span
|
||||||
{{ stateMeta[store.connState].text }}
|
class="size-2 rounded-full"
|
||||||
</span>
|
:class="[stateMeta[store.connState].dot, store.connState === 'connected' && 'status-dot-pulse']"
|
||||||
<Badge :variant="store.snapshot?.isAdmin ? 'default' : 'outline'" :class="store.snapshot?.isAdmin ? 'bg-emerald-500 hover:bg-emerald-500' : ''">
|
/>{{ stateMeta[store.connState].text }}
|
||||||
{{ store.snapshot?.isAdmin ? '管理员' : '普通' }}
|
</span>
|
||||||
</Badge>
|
<span class="text-[13px] text-fg-secondary">ThingHK</span>
|
||||||
<Badge v-if="store.status?.thingElevated" variant="outline" class="border-emerald-500/50 text-emerald-600 dark:text-emerald-400">
|
<Badge :variant="store.snapshot?.isAdmin ? 'default' : 'outline'" :class="store.snapshot?.isAdmin ? 'bg-success hover:bg-success' : ''">
|
||||||
<ShieldCheck class="size-2.5 mr-0.5" />提权
|
{{ store.snapshot?.isAdmin ? '管理员' : '普通' }}
|
||||||
</Badge>
|
</Badge>
|
||||||
<span class="text-muted-foreground">PID: <span class="font-mono text-foreground">{{ store.status?.pid ?? '--' }}</span></span>
|
<Badge v-if="store.status?.thingElevated" variant="outline" class="border-success/50 text-success-text">
|
||||||
<span class="text-muted-foreground">传感器: <span class="font-mono text-foreground">{{ store.status?.sensorCount ?? '--' }}</span></span>
|
<ShieldCheck class="size-2.5 mr-0.5" />提权
|
||||||
<span class="text-muted-foreground">重启: <span class="font-mono text-foreground">{{ store.status?.restartCount ?? 0 }}</span></span>
|
</Badge>
|
||||||
<span class="text-muted-foreground">事件: <span class="font-mono text-foreground">{{ store.eventCount }}</span></span>
|
</div>
|
||||||
<div class="flex items-center gap-1.5 ml-auto">
|
<!-- 指标区:核心属性同样占一个指标位(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">
|
||||||
<!-- 启动中 loading(starting=true 但状态还没变为 loading 时显示) -->
|
<!-- 启动中 loading(starting=true 但状态还没变为 loading 时显示) -->
|
||||||
<Button v-if="store.starting && store.connState === 'idle'" size="xs" variant="outline" disabled>
|
<Button v-if="store.starting && store.connState === 'idle'" size="sm" variant="outline" disabled>
|
||||||
<Loader2 class="size-3 animate-spin" />启动中
|
<Loader2 class="size-3.5 animate-spin" />启动中
|
||||||
</Button>
|
</Button>
|
||||||
<!-- 启动按钮(未运行且非启动中时显示) -->
|
<!-- 启动按钮(未运行且非启动中时显示) -->
|
||||||
<Button v-if="store.connState === 'idle' && !store.starting" size="xs" :disabled="store.starting" @click="handleStart">
|
<Button v-if="store.connState === 'idle' && !store.starting" size="sm" :disabled="store.starting" @click="handleStart">
|
||||||
<Play class="size-3" />启动
|
<Play class="size-3.5" />启动
|
||||||
</Button>
|
</Button>
|
||||||
<!-- 提权按钮(标志未启用时显示:设置标志 + 以管理员权限重启 Thing) -->
|
<!-- 提权按钮(标志未启用时显示:设置标志 + 以管理员权限重启 Thing) -->
|
||||||
<Tooltip v-if="!store.elevateOnLaunch">
|
<Tooltip v-if="!store.elevateOnLaunch">
|
||||||
<TooltipTrigger as-child>
|
<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">
|
<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 animate-spin" />
|
<Loader2 v-if="store.starting" class="size-3.5 animate-spin" />
|
||||||
<ShieldCheck v-else class="size-3" />提权
|
<ShieldCheck v-else class="size-3.5" />提权
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent class="max-w-[480px] break-words">以管理员权限重启 Thing(弹 UAC,ThingHK 子进程继承权限,后续启动自动提权,崩溃自动重启)</TooltipContent>
|
<TooltipContent class="max-w-[480px] break-words">以管理员权限重启 Thing(弹 UAC,ThingHK 子进程继承权限,后续启动自动提权,崩溃自动重启)</TooltipContent>
|
||||||
@@ -1404,36 +1421,70 @@ watch(() => store.status?.ready, (ready, prev) => {
|
|||||||
<!-- 取消提权按钮(标志已启用时显示:清除标志,下次启动不触发 UAC) -->
|
<!-- 取消提权按钮(标志已启用时显示:清除标志,下次启动不触发 UAC) -->
|
||||||
<Tooltip v-else>
|
<Tooltip v-else>
|
||||||
<TooltipTrigger as-child>
|
<TooltipTrigger as-child>
|
||||||
<Button size="xs" variant="outline" class="gap-1 text-muted-foreground hover:text-foreground" @click="handleCancelElevation">
|
<Button size="sm" variant="outline" class="gap-1 text-muted-foreground hover:text-foreground" @click="handleCancelElevation">
|
||||||
<ShieldOff class="size-3" />取消提权
|
<ShieldOff class="size-3.5" />取消提权
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent class="max-w-[480px] break-words">取消提权,下次启动将以普通权限运行(不影响当前会话)</TooltipContent>
|
<TooltipContent class="max-w-[480px] break-words">取消提权,下次启动将以普通权限运行(不影响当前会话)</TooltipContent>
|
||||||
</Tooltip>
|
</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>
|
||||||
<Button size="xs" variant="outline" :disabled="store.starting || store.stopping || store.connState === 'idle'" @click="handleStop">
|
<Separator orientation="vertical" class="h-5 mx-0.5" />
|
||||||
<Square class="size-3" />停止
|
<!--
|
||||||
</Button>
|
布局:编辑模式(拖拽排序 / 移除)+ 添加卡片 收进一个图标按钮的
|
||||||
<Separator orientation="vertical" class="h-4 mx-0.5" />
|
popover。两件事都属于「卡片集合的操作」,低频且互斥于运行控制,
|
||||||
<!-- 编辑布局开关:拖拽排序 / 删除卡片 -->
|
各占一个文字按钮只会把横幅挤成两行。
|
||||||
<Button
|
按钮在编辑态用主题色软底提示「正在编辑」。
|
||||||
size="xs"
|
-->
|
||||||
:variant="overviewEditing ? 'default' : 'outline'"
|
<Popover v-model:open="overviewEditOpen">
|
||||||
:title="overviewEditing ? '完成编辑' : '编辑卡片布局(拖拽排序 / 删除)'"
|
|
||||||
@click="overviewEditing = !overviewEditing"
|
|
||||||
>
|
|
||||||
<PencilLine class="size-3" />{{ overviewEditing ? '完成' : '编辑布局' }}
|
|
||||||
</Button>
|
|
||||||
<!-- 添加卡片 -->
|
|
||||||
<Popover v-model:open="overviewAddOpen">
|
|
||||||
<PopoverTrigger as-child>
|
<PopoverTrigger as-child>
|
||||||
<Button size="xs" variant="outline" :disabled="!addableOverviewCards.length" title="添加硬件卡片">
|
<Button
|
||||||
<Plus class="size-3" />添加卡片
|
variant="outline"
|
||||||
|
size="icon-sm"
|
||||||
|
title="编辑卡片布局 / 添加卡片"
|
||||||
|
:class="
|
||||||
|
overviewEditing
|
||||||
|
? 'border-primary/40 bg-primary-soft text-primary-soft-text'
|
||||||
|
: ''
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<PencilLine class="size-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent class="w-64 p-1.5" align="end">
|
<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
|
<button
|
||||||
v-for="c in addableOverviewCards"
|
v-for="c in addableOverviewCards"
|
||||||
:key="c.id"
|
:key="c.id"
|
||||||
@@ -1454,11 +1505,11 @@ watch(() => store.status?.ready, (ready, prev) => {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<!-- PawnIO 驱动缺失提示(提权但驱动未装 → CPU 温度/功耗无法读取) -->
|
<!-- PawnIO 驱动缺失提示(提权但驱动未装 → CPU 温度/功耗无法读取) -->
|
||||||
<Card v-if="store.pawnIoMissing" class="py-0 gap-0 mb-2.5 !border-amber-500/40 bg-amber-500/10">
|
<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-amber-600 dark:text-amber-400">
|
<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" />
|
<AlertTriangle class="size-4 shrink-0" />
|
||||||
<span class="flex-1 min-w-[200px]">检测到 PawnIO 驱动未安装,CPU 温度/功耗可能无法读取(误卸载过 PawnIO 等情况会触发)。点击下方按钮自动安装并重启监控内核。</span>
|
<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" />
|
<Loader2 v-if="repairingPawnio" class="size-3 mr-1 animate-spin" />
|
||||||
<RefreshCw v-else class="size-3 mr-1" />
|
<RefreshCw v-else class="size-3 mr-1" />
|
||||||
{{ repairingPawnio ? '修复中...' : '检测并修复' }}
|
{{ repairingPawnio ? '修复中...' : '检测并修复' }}
|
||||||
@@ -1523,32 +1574,32 @@ watch(() => store.status?.ready, (ready, prev) => {
|
|||||||
</VueDraggable>
|
</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">
|
<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>
|
||||||
<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 class="text-xs text-muted-foreground mt-0.5">Kernel 可能正在重启,等待自动重连...</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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">
|
<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>
|
||||||
<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 class="text-xs font-mono mt-0.5 break-all">{{ store.errorMsg }}</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</template>
|
</template>
|
||||||
</ScrollArea>
|
</div>
|
||||||
</TabsContent>
|
</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">
|
<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" />
|
<Gauge class="size-12 opacity-30" />
|
||||||
<p class="text-sm">无快照数据</p>
|
<p class="text-sm">无快照数据</p>
|
||||||
@@ -1558,7 +1609,7 @@ watch(() => store.status?.ready, (ready, prev) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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">
|
<div class="flex items-center justify-between mb-3">
|
||||||
<p class="text-sm text-muted-foreground">{{ groups.length }} 个分组 / {{ store.status?.sensorCount ?? 0 }} 个传感器</p>
|
<p class="text-sm text-muted-foreground">{{ groups.length }} 个分组 / {{ store.status?.sensorCount ?? 0 }} 个传感器</p>
|
||||||
<Button size="xs" variant="outline" @click="store.fetchSnapshot()">
|
<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">
|
<AccordionContent class="px-4 pb-3 pt-0">
|
||||||
<!-- 无传感器数据占位(硬件已启用但 LHB 未检测到传感器,通常因权限不足或硬件不支持) -->
|
<!-- 无传感器数据占位(硬件已启用但 LHB 未检测到传感器,通常因权限不足或硬件不支持) -->
|
||||||
<div v-if="!g.sensors.length" class="py-3 flex items-center gap-2 text-xs text-muted-foreground">
|
<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>
|
<span>未检测到传感器数据(可能需要管理员权限或硬件不支持)</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-for="hwGroup in groupSensors(g.sensors)" :key="hwGroup.hardware" class="py-2 border-t first:border-t-0">
|
<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>
|
</AccordionItem>
|
||||||
</Card>
|
</Card>
|
||||||
</Accordion>
|
</Accordion>
|
||||||
</ScrollArea>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<!-- ===== OSD 显示 ===== -->
|
<!-- ===== OSD 显示 ===== -->
|
||||||
<TabsContent value="osd" class="flex-1 mt-4 tab-animate">
|
<TabsContent value="osd" class="flex-1 tab-animate">
|
||||||
<ScrollArea class="h-full pr-3">
|
<div>
|
||||||
<div class="max-w-2xl space-y-4 pb-4">
|
<div class="space-y-4 pb-4">
|
||||||
<!-- 悬浮窗开关 -->
|
<!-- 悬浮窗开关 -->
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -1665,7 +1716,7 @@ watch(() => store.status?.ready, (ready, prev) => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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" />
|
<AlertTriangle class="size-3.5 mt-0.5 shrink-0" />
|
||||||
<span>Kernel 未就绪,无法获取可用传感器列表。请先在概览页启动 Kernel。</span>
|
<span>Kernel 未就绪,无法获取可用传感器列表。请先在概览页启动 Kernel。</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -2234,13 +2285,13 @@ watch(() => store.status?.ready, (ready, prev) => {
|
|||||||
|
|
||||||
<!-- 预览卡片已移除(用户可在桌面悬浮窗直接看实际效果) -->
|
<!-- 预览卡片已移除(用户可在桌面悬浮窗直接看实际效果) -->
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<!-- ===== 设置 ===== -->
|
<!-- ===== 设置 ===== -->
|
||||||
<TabsContent value="settings" class="flex-1 mt-4 tab-animate">
|
<TabsContent value="settings" class="flex-1 tab-animate">
|
||||||
<ScrollArea class="h-full pr-3">
|
<div>
|
||||||
<div class="max-w-2xl space-y-4 pb-4">
|
<div class="space-y-4 pb-4">
|
||||||
<!-- 控制 -->
|
<!-- 控制 -->
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -2296,7 +2347,7 @@ watch(() => store.status?.ready, (ready, prev) => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
<span class="text-muted-foreground">已安装</span>
|
<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 ? '是' : '否' }}
|
{{ store.kernelInfo.exists ? '是' : '否' }}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
@@ -2347,10 +2398,10 @@ watch(() => store.status?.ready, (ready, prev) => {
|
|||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<span>当前权限</span>
|
<span>当前权限</span>
|
||||||
<div class="flex items-center gap-1.5">
|
<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 ? '管理员' : '普通' }}
|
{{ store.snapshot?.isAdmin ? '管理员' : '普通' }}
|
||||||
</Badge>
|
</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" />提权
|
<ShieldCheck class="size-2.5 mr-0.5" />提权
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
@@ -2363,13 +2414,13 @@ watch(() => store.status?.ready, (ready, prev) => {
|
|||||||
<!-- 提权说明 -->
|
<!-- 提权说明 -->
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<div class="flex items-start gap-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">
|
<div class="text-xs">
|
||||||
<span class="font-medium text-foreground">提权</span>:以管理员权限重启 Thing 自身,ThingHK 子进程继承权限。标志持久化,后续每次启动自动触发 UAC;可在概览页点击"取消提权"关闭。ThingHK 崩溃由 ProcessManager 自动重启,避免数据停止后 Thing 不感知。
|
<span class="font-medium text-foreground">提权</span>:以管理员权限重启 Thing 自身,ThingHK 子进程继承权限。标志持久化,后续每次启动自动触发 UAC;可在概览页点击"取消提权"关闭。ThingHK 崩溃由 ProcessManager 自动重启,避免数据停止后 Thing 不感知。
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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" />
|
<ShieldCheck class="size-3.5 mt-0.5 shrink-0" />
|
||||||
<span>提权模式:Thing 与 ThingHK 均以管理员权限运行,ProcessManager 可直接 kill,崩溃自动重启可用。</span>
|
<span>提权模式:Thing 与 ThingHK 均以管理员权限运行,ProcessManager 可直接 kill,崩溃自动重启可用。</span>
|
||||||
</p>
|
</p>
|
||||||
@@ -2396,7 +2447,7 @@ watch(() => store.status?.ready, (ready, prev) => {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</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>
|
<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>
|
<span v-if="hw.key === 'motherboard'" class="text-[10px] text-muted-foreground/60">含 SuperIO/EC(自动联动)</span>
|
||||||
</div>
|
</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>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
@@ -2478,7 +2529,7 @@ watch(() => store.status?.ready, (ready, prev) => {
|
|||||||
<DialogContent class="max-w-md">
|
<DialogContent class="max-w-md">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle class="flex items-center gap-2">
|
<DialogTitle class="flex items-center gap-2">
|
||||||
<TriangleAlert class="size-4 text-amber-500" />
|
<TriangleAlert class="size-4 text-warning" />
|
||||||
需先启动监控内核
|
需先启动监控内核
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
|
|||||||
@@ -325,7 +325,7 @@ const config = ref<OsdConfig | null>(null)
|
|||||||
/** 数据通道:显示项 key→value 映射(由主窗口每秒推送,替代全量快照) */
|
/** 数据通道:显示项 key→value 映射(由主窗口每秒推送,替代全量快照) */
|
||||||
const dataMap = ref<Record<string, number | null>>({})
|
const dataMap = ref<Record<string, number | null>>({})
|
||||||
const networkSpeed = ref<NetworkSpeed | null>(null)
|
const networkSpeed = ref<NetworkSpeed | null>(null)
|
||||||
let unlistenFns: UnlistenFn[] = []
|
const unlistenFns: UnlistenFn[] = []
|
||||||
|
|
||||||
// ===== 数据查询 =====
|
// ===== 数据查询 =====
|
||||||
function getOsdItemValue(item: OsdItem): number | null {
|
function getOsdItemValue(item: OsdItem): number | null {
|
||||||
|
|||||||
@@ -13,18 +13,18 @@ export function fmt(v: number | null, digits = 1): string {
|
|||||||
/** 温度颜色:绿(<50) → 黄(<70) → 橙(<85) → 红(>=85) */
|
/** 温度颜色:绿(<50) → 黄(<70) → 橙(<85) → 红(>=85) */
|
||||||
export function tempColor(t: number | null): string {
|
export function tempColor(t: number | null): string {
|
||||||
if (t == null) return 'text-muted-foreground'
|
if (t == null) return 'text-muted-foreground'
|
||||||
if (t < 50) return 'text-emerald-500'
|
if (t < 50) return 'text-success'
|
||||||
if (t < 70) return 'text-yellow-500'
|
if (t < 70) return 'text-warning'
|
||||||
if (t < 85) return 'text-orange-500'
|
if (t < 85) return 'text-warning'
|
||||||
return 'text-red-500'
|
return 'text-danger'
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 负载颜色:蓝(<50) → 紫(<80) → 红(>=80) */
|
/** 负载颜色:蓝(<50) → 紫(<80) → 红(>=80) */
|
||||||
export function loadColor(v: number | null): string {
|
export function loadColor(v: number | null): string {
|
||||||
if (v == null) return 'text-muted-foreground'
|
if (v == null) return 'text-muted-foreground'
|
||||||
if (v < 50) return 'text-sky-500'
|
if (v < 50) return 'text-info'
|
||||||
if (v < 80) return 'text-violet-500'
|
if (v < 80) return 'text-warning'
|
||||||
return 'text-red-500'
|
return 'text-danger'
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 格式化网速(bytes/s → 自适应 KB/s 或 MB/s) */
|
/** 格式化网速(bytes/s → 自适应 KB/s 或 MB/s) */
|
||||||
|
|||||||
@@ -107,5 +107,5 @@ export const moduleConfig: ModuleConfig = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
order: 40
|
order: 60
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import {
|
|||||||
HardDriveDownload,
|
HardDriveDownload,
|
||||||
History,
|
History,
|
||||||
Info,
|
Info,
|
||||||
Library,
|
|
||||||
Link2,
|
Link2,
|
||||||
ListMusic,
|
ListMusic,
|
||||||
Loader2,
|
Loader2,
|
||||||
@@ -20,7 +19,6 @@ import {
|
|||||||
Plus,
|
Plus,
|
||||||
RotateCw,
|
RotateCw,
|
||||||
Search,
|
Search,
|
||||||
Settings as SettingsIcon,
|
|
||||||
Square,
|
Square,
|
||||||
Trash2,
|
Trash2,
|
||||||
Upload,
|
Upload,
|
||||||
@@ -61,7 +59,7 @@ import { Separator } from '@/components/ui/separator'
|
|||||||
import { Progress } from '@/components/ui/progress'
|
import { Progress } from '@/components/ui/progress'
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||||
import { Checkbox } from '@/components/ui/checkbox'
|
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 { Empty, EmptyDescription, EmptyMedia, EmptyTitle } from '@/components/ui/empty'
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
@@ -75,11 +73,11 @@ const logger = createLogger('music')
|
|||||||
|
|
||||||
// ===== 模块导航 =====
|
// ===== 模块导航 =====
|
||||||
const activeTab = ref('mymusic')
|
const activeTab = ref('mymusic')
|
||||||
const tabsListRef = useModuleTabs('music', activeTab, [
|
useModuleTabs('music', activeTab, [
|
||||||
{ value: 'mymusic', label: '曲库' },
|
{ value: 'mymusic', label: '曲库' },
|
||||||
{ value: 'discover', label: '发现音乐' },
|
{ value: 'discover', label: '发现音乐' },
|
||||||
{ value: 'settings', label: '设置' }
|
{ value: 'settings', label: '设置' }
|
||||||
])
|
], { alwaysVisible: true })
|
||||||
|
|
||||||
// ===== 发现音乐:下载目标 =====
|
// ===== 发现音乐:下载目标 =====
|
||||||
const downloadTarget = ref<'local' | 'feiniu'>('local')
|
const downloadTarget = ref<'local' | 'feiniu'>('local')
|
||||||
@@ -540,7 +538,7 @@ const taskStatusInfo = (
|
|||||||
case 'cancelling':
|
case 'cancelling':
|
||||||
return { label: '取消中', variant: 'outline' }
|
return { label: '取消中', variant: 'outline' }
|
||||||
case 'done':
|
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':
|
case 'error':
|
||||||
return { label: '出错', variant: 'destructive' }
|
return { label: '出错', variant: 'destructive' }
|
||||||
case 'cancelled':
|
case 'cancelled':
|
||||||
@@ -788,7 +786,7 @@ const envBadgeText = computed(() => {
|
|||||||
})
|
})
|
||||||
const envBadgeClass = computed(() =>
|
const envBadgeClass = computed(() =>
|
||||||
!store.envLoading && store.env?.musicdlOutdated
|
!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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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 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 />
|
<MyMusicLibrary />
|
||||||
</TabsContent>
|
</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">
|
<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">
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- 结果列表 -->
|
<!-- 结果列表 -->
|
||||||
<ScrollArea class="min-h-0 flex-1">
|
<div class="min-h-0 flex-1">
|
||||||
<div class="pb-4">
|
<div class="pb-4">
|
||||||
<div v-if="store.searching" class="space-y-1.5 pt-3">
|
<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" />
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<!-- ===== 设置(与其他模块一致的 Card 卡片式布局) ===== -->
|
<!-- ===== 设置(与其他模块一致的 Card 卡片式布局) ===== -->
|
||||||
<TabsContent value="settings" class="mt-4 min-h-0 flex-1 tab-animate">
|
<TabsContent value="settings" class="min-h-0 flex-1 tab-animate">
|
||||||
<ScrollArea class="h-full pr-3">
|
<div>
|
||||||
<div class="flex max-w-2xl flex-col gap-4 pb-4">
|
<div class="flex flex-col gap-4 pb-4">
|
||||||
<!-- 连接 -->
|
<!-- 连接 -->
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -1679,7 +1668,7 @@ const RESULT_GRID = { gridTemplateColumns: '24px 36px minmax(0,1fr) 108px 52px 9
|
|||||||
"
|
"
|
||||||
:class="
|
:class="
|
||||||
!store.envLoading && store.env?.musicdlOutdated
|
!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>
|
</Badge>
|
||||||
<span
|
<span
|
||||||
class="text-[12.5px] tabular-nums"
|
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-if="store.envLoading">探测中…</template>
|
||||||
<template v-else-if="!store.env?.musicdlInstalled">—</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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
|
|||||||
@@ -177,7 +177,7 @@ onMounted(() => store.refreshConnections())
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex shrink-0 items-center gap-1">
|
<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" /> 已登录
|
<Check class="size-3.5" /> 已登录
|
||||||
</span>
|
</span>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -43,6 +43,6 @@ export const moduleConfig: ModuleConfig = {
|
|||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
// 默认排在「代理」之后、整体第二位(proxy=10、clipboard=20)
|
// 默认第一位(音乐是常驻后台的播放器,入口最靠前)
|
||||||
order: 15
|
order: 10
|
||||||
}
|
}
|
||||||
|
|||||||
+316
-435
@@ -1,9 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {
|
import {
|
||||||
Globe, Play, Square, RotateCw, Power, Zap, Plus, RefreshCw, Trash2,
|
Play, Square, RotateCw, Zap, Plus, RefreshCw, Trash2,
|
||||||
Check, AlertCircle, Server, Settings as SettingsIcon, ListChecks,
|
Check, AlertCircle, Server, Settings as SettingsIcon,
|
||||||
Upload, Link2, Loader2, Download, Timer, Target, FolderOpen, Copy, DownloadCloud,
|
Upload, Link2, Loader2, Download, Target, FolderOpen, Copy, DownloadCloud,
|
||||||
Waypoints, ArrowDown, ArrowUp, Activity, X
|
Waypoints, X
|
||||||
} from '@lucide/vue'
|
} from '@lucide/vue'
|
||||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
import { toast } from 'vue-sonner'
|
import { toast } from 'vue-sonner'
|
||||||
@@ -21,8 +21,7 @@ import { Badge } from '@/components/ui/badge'
|
|||||||
import { Switch } from '@/components/ui/switch'
|
import { Switch } from '@/components/ui/switch'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
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 { Separator } from '@/components/ui/separator'
|
||||||
import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from '@/components/ui/accordion'
|
import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from '@/components/ui/accordion'
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||||
@@ -33,6 +32,7 @@ import {
|
|||||||
AlertDialogContent, AlertDialogDescription, AlertDialogFooter,
|
AlertDialogContent, AlertDialogDescription, AlertDialogFooter,
|
||||||
AlertDialogHeader, AlertDialogTitle
|
AlertDialogHeader, AlertDialogTitle
|
||||||
} from '@/components/ui/alert-dialog'
|
} from '@/components/ui/alert-dialog'
|
||||||
|
import { InfoGroup, KeyValueRow, StatusBadge } from '@/components/common'
|
||||||
|
|
||||||
const store = useProxyStore()
|
const store = useProxyStore()
|
||||||
const logger = createLogger('proxy')
|
const logger = createLogger('proxy')
|
||||||
@@ -80,14 +80,19 @@ const onConfirmOpenChange = (open: boolean) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const activeTab = ref('overview')
|
const activeTab = ref('overview')
|
||||||
// 浮动标签切换器:注册到 TitleBar,滚动遮挡时自动显示
|
// 标签切换器注册到 TitleBar 且常驻 —— 模块内不再渲染 Tab 行,把纵向空间还给内容
|
||||||
const tabsListRef = useModuleTabs('proxy', activeTab, [
|
useModuleTabs(
|
||||||
{ value: 'overview', label: '概览' },
|
'proxy',
|
||||||
{ value: 'connections', label: '连接' },
|
activeTab,
|
||||||
{ value: 'proxies', label: '节点' },
|
[
|
||||||
{ value: 'profiles', label: '订阅' },
|
{ value: 'overview', label: '概览' },
|
||||||
{ value: 'settings', label: '设置' }
|
{ value: 'connections', label: '连接' },
|
||||||
])
|
{ value: 'proxies', label: '节点' },
|
||||||
|
{ value: 'profiles', label: '订阅' },
|
||||||
|
{ value: 'settings', label: '设置' }
|
||||||
|
],
|
||||||
|
{ alwaysVisible: true }
|
||||||
|
)
|
||||||
const starting = ref(false)
|
const starting = ref(false)
|
||||||
const stopping = ref(false)
|
const stopping = ref(false)
|
||||||
const restarting = ref(false)
|
const restarting = ref(false)
|
||||||
@@ -177,11 +182,6 @@ const scopeFilterOptions: { value: 'all' | ConnScope; label: string }[] = [
|
|||||||
{ value: 'direct', label: '国内' },
|
{ value: 'direct', label: '国内' },
|
||||||
{ value: 'proxy', 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 ruleHits = computed(() => {
|
||||||
const map = new Map<string, number>()
|
const map = new Map<string, number>()
|
||||||
@@ -455,13 +455,14 @@ const openFolder = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 延迟 Badge 样式(超时用醒目红色)
|
// 延迟分级样式。四级全部落在规范语义色上,不再直接引 Tailwind 色阶:
|
||||||
|
// 快=成功 / 中=警告 / 慢=危险浅底 / 超时=危险实心(最醒目)
|
||||||
const delayBadgeClass = (delay: number | undefined) => {
|
const delayBadgeClass = (delay: number | undefined) => {
|
||||||
if (delay === undefined) return 'border-transparent bg-muted text-muted-foreground'
|
if (delay === undefined) return 'border-transparent bg-neutral-soft text-neutral-text'
|
||||||
if (delay === 0) return 'border-transparent bg-red-500 text-white'
|
if (delay === 0) return 'border-transparent bg-danger text-white'
|
||||||
if (delay < 150) return 'border-transparent bg-emerald-100 text-emerald-700'
|
if (delay < 150) return 'border-transparent bg-success-soft text-success-text'
|
||||||
if (delay < 400) return 'border-transparent bg-amber-100 text-amber-700'
|
if (delay < 400) return 'border-transparent bg-warning-soft text-warning-text'
|
||||||
return 'border-transparent bg-orange-100 text-orange-700'
|
return 'border-transparent bg-danger-soft text-danger-text'
|
||||||
}
|
}
|
||||||
|
|
||||||
const delayText = (delay: number | undefined) => {
|
const delayText = (delay: number | undefined) => {
|
||||||
@@ -562,12 +563,24 @@ watch(running, async (val, old) => {
|
|||||||
await store.waitForApi()
|
await store.waitForApi()
|
||||||
await store.refreshVersion()
|
await store.refreshVersion()
|
||||||
await loadProxiesWithError()
|
await loadProxiesWithError()
|
||||||
|
// 进程刚起来时如果用户就停在连接页,立刻补一次:否则要等下一轮 3s 轮询,
|
||||||
|
// 而 mihomo 起来后的头几秒 API 往往还没热身,轮询会连续失败(见 connTimer)
|
||||||
|
if (activeTab.value === 'connections') await store.refreshConnections()
|
||||||
|
}
|
||||||
|
if (!val && old) {
|
||||||
|
// 进程已停:清掉上一轮快照,否则连接页会一直列着那个已经结束的进程的连接
|
||||||
|
store.connections = null
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// 切换到节点 Tab 时,加载节点列表并自动测速(10s 节流:快速切换 Tab 时避免重复 IPC 洪峰)
|
// 切换到节点 Tab 时,加载节点列表并自动测速(10s 节流:快速切换 Tab 时避免重复 IPC 洪峰)
|
||||||
let lastAutoTestAt = 0
|
let lastAutoTestAt = 0
|
||||||
watch(activeTab, async (tab) => {
|
watch(activeTab, async (tab) => {
|
||||||
|
// 连接页:进入即拉,不等 3s 轮询 —— 轮询只在停留期间跑,首次进入的空窗期
|
||||||
|
// 会让用户以为「没有连接」(实际是还没查过)
|
||||||
|
if (tab === 'connections' && running.value) {
|
||||||
|
await store.refreshConnections()
|
||||||
|
}
|
||||||
if (tab === 'proxies' && running.value) {
|
if (tab === 'proxies' && running.value) {
|
||||||
if (!Object.keys(store.proxies).length) {
|
if (!Object.keys(store.proxies).length) {
|
||||||
await loadProxiesWithError()
|
await loadProxiesWithError()
|
||||||
@@ -640,6 +653,9 @@ const onToggleSystemProxy = async (on: boolean) => {
|
|||||||
sysProxyLoading.value = true
|
sysProxyLoading.value = true
|
||||||
try {
|
try {
|
||||||
await store.toggleSystemProxy(on)
|
await store.toggleSystemProxy(on)
|
||||||
|
// 开启系统代理是「流量开始经过 mihomo」的时刻,立即拉一次连接快照:
|
||||||
|
// 用户多半接着就去连接页看效果,不该在那里等轮询(也不该看到上一轮的空结果)
|
||||||
|
if (on) await store.refreshConnections()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error('操作失败', { description: String(e) })
|
toast.error('操作失败', { description: String(e) })
|
||||||
} finally {
|
} finally {
|
||||||
@@ -777,9 +793,9 @@ const installStageText = computed(() => {
|
|||||||
|
|
||||||
const installStageColor = computed(() => {
|
const installStageColor = computed(() => {
|
||||||
const stage = store.installProgress?.stage
|
const stage = store.installProgress?.stage
|
||||||
if (stage === 'done') return 'text-emerald-500'
|
if (stage === 'done') return 'text-success'
|
||||||
if (stage === 'error') return 'text-destructive'
|
if (stage === 'error') return 'text-danger'
|
||||||
if (stage === 'need_stop') return 'text-amber-500'
|
if (stage === 'need_stop') return 'text-warning'
|
||||||
return 'text-primary'
|
return 'text-primary'
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1148,104 +1164,87 @@ watch(
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="h-full p-6">
|
<div class="min-h-full p-5">
|
||||||
<Tabs v-model="activeTab" class="h-full flex flex-col">
|
<Tabs v-model="activeTab" class="flex min-h-full w-full 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>
|
|
||||||
|
|
||||||
<!-- 概览 -->
|
<!-- 概览 -->
|
||||||
<TabsContent value="overview" class="flex-1 mt-4 tab-animate">
|
<TabsContent value="overview" class="tab-animate">
|
||||||
<ScrollArea class="h-full pr-3">
|
<div class="flex flex-col gap-4">
|
||||||
<div class="columns-1 md:columns-2 gap-4 [&>*]:mb-4 [&>*]:break-inside-avoid">
|
<!-- 状态横幅:状态区 → 指标区 → 操作区,一行常驻(UI_DESIGN_SYSTEM §3.4) -->
|
||||||
<!-- 实时流量 -->
|
<Card class="!py-0 !gap-0">
|
||||||
<Card>
|
<CardContent class="flex flex-wrap items-center gap-x-6 gap-y-3 px-4 py-4">
|
||||||
<CardHeader>
|
<div class="flex items-center gap-2.5">
|
||||||
<CardTitle class="flex items-center justify-between text-base">
|
<span v-if="!store.initialized" class="flex items-center gap-1.5 text-[13px] text-muted-foreground">
|
||||||
<span class="flex items-center gap-2"><Activity class="size-4 text-primary" />实时流量</span>
|
<Loader2 class="size-3.5 animate-spin" />加载中
|
||||||
<Badge v-if="running" variant="outline" class="gap-1 text-xs">
|
</span>
|
||||||
<span class="size-1.5 rounded-full bg-emerald-500" />实时更新
|
<template v-else>
|
||||||
</Badge>
|
<span v-if="running" class="flex items-center gap-1.5 text-[13px] text-success">
|
||||||
</CardTitle>
|
<span class="size-2 rounded-full bg-success status-dot-pulse" />运行中
|
||||||
</CardHeader>
|
</span>
|
||||||
<CardContent class="space-y-4 text-sm">
|
<span v-else class="flex items-center gap-1.5 text-[13px] text-muted-foreground">
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<span class="size-2 rounded-full bg-muted-foreground" />已停止
|
||||||
<div>
|
</span>
|
||||||
<div class="flex items-center gap-1 text-muted-foreground text-xs mb-1">
|
</template>
|
||||||
<ArrowDown class="size-3.5 text-emerald-500" />下载
|
<span class="text-[13px] text-fg-secondary">mihomo</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-lg font-semibold tabular-nums">{{ fmtSpeed(store.traffic?.downloadSpeed ?? 0) }}</p>
|
<div class="flex min-w-20 flex-col gap-0.5">
|
||||||
<p class="text-xs text-muted-foreground tabular-nums">累计 {{ store.traffic ? fmtBytes(store.traffic.downloadTotal) : '--' }}</p>
|
<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>
|
<div class="flex min-w-24 flex-col gap-0.5">
|
||||||
<div class="flex items-center gap-1 text-muted-foreground text-xs mb-1">
|
<span class="text-[11px] text-muted-foreground">上传</span>
|
||||||
<ArrowUp class="size-3.5 text-rose-500" />上传
|
<span class="text-lg font-semibold tabular-nums text-data-up">{{ fmtSpeed(store.traffic?.uploadSpeed ?? 0) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-lg font-semibold tabular-nums">{{ fmtSpeed(store.traffic?.uploadSpeed ?? 0) }}</p>
|
<div class="flex min-w-20 flex-col gap-0.5">
|
||||||
<p class="text-xs text-muted-foreground tabular-nums">累计 {{ store.traffic ? fmtBytes(store.traffic.uploadTotal) : '--' }}</p>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<Separator />
|
<div class="ml-auto flex gap-2">
|
||||||
<div class="flex items-center justify-between">
|
<Button v-if="!running" size="sm" :disabled="starting" @click="handleStart">
|
||||||
<span class="flex items-center gap-1.5 text-muted-foreground">
|
<Loader2 v-if="starting" class="size-3.5 animate-spin" />
|
||||||
<Waypoints class="size-3.5" />活跃连接
|
<Play v-else class="size-3.5" />启动
|
||||||
</span>
|
</Button>
|
||||||
<span class="font-semibold tabular-nums">{{ store.traffic?.activeConnections ?? '--' }}</span>
|
<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>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<div class="columns-1 gap-4 md:columns-2 [&>*]:mb-4 [&>*:last-child]:mb-0 [&>*]:break-inside-avoid">
|
||||||
<!-- 内核状态 -->
|
<!-- 内核状态 -->
|
||||||
<Card>
|
<InfoGroup variant="card" title="内核">
|
||||||
<CardHeader>
|
<template #action>
|
||||||
<CardTitle class="flex items-center justify-between text-base">
|
<Button
|
||||||
<span class="flex items-center gap-2"><Server class="size-4 text-primary" />内核</span>
|
v-if="store.kernel?.exists"
|
||||||
<Button
|
size="xs" variant="outline"
|
||||||
v-if="store.kernel?.exists"
|
:disabled="checkingUpdate"
|
||||||
size="xs" variant="outline"
|
@click="handleCheckUpdate"
|
||||||
:disabled="checkingUpdate"
|
>
|
||||||
@click="handleCheckUpdate"
|
<Loader2 v-if="checkingUpdate" class="size-3 animate-spin" />
|
||||||
>
|
<Download v-else key="icon-download" class="size-3" />检查更新
|
||||||
<Loader2 v-if="checkingUpdate" class="size-3 animate-spin" />
|
</Button>
|
||||||
<Download v-else key="icon-download" class="size-3" />检查更新
|
</template>
|
||||||
</Button>
|
<KeyValueRow v-if="kernelUpdateInfo" label="最新版本">
|
||||||
</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>
|
|
||||||
<span class="font-mono text-xs flex items-center gap-2">
|
<span class="font-mono text-xs flex items-center gap-2">
|
||||||
{{ kernelUpdateInfo.latestVersion }}
|
{{ kernelUpdateInfo.latestVersion }}
|
||||||
<Button
|
<Button
|
||||||
@@ -1257,12 +1256,11 @@ watch(
|
|||||||
>
|
>
|
||||||
<Download key="icon-download" class="size-3" />更新
|
<Download key="icon-download" class="size-3" />更新
|
||||||
</Button>
|
</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>
|
</span>
|
||||||
</div>
|
</KeyValueRow>
|
||||||
<div class="flex items-center justify-between gap-3">
|
<KeyValueRow label="路径">
|
||||||
<span class="text-muted-foreground shrink-0">路径</span>
|
<span class="flex min-w-0 items-center gap-1.5">
|
||||||
<div class="flex items-center gap-1.5 min-w-0">
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger as-child>
|
<TooltipTrigger as-child>
|
||||||
<span class="font-mono text-xs text-right truncate cursor-default">{{ pathDisplay || pathShort }}</span>
|
<span class="font-mono text-xs text-right truncate cursor-default">{{ pathDisplay || pathShort }}</span>
|
||||||
@@ -1287,8 +1285,11 @@ watch(
|
|||||||
<TooltipContent>在文件夹中显示</TooltipContent>
|
<TooltipContent>在文件夹中显示</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</span>
|
||||||
</div>
|
</KeyValueRow>
|
||||||
|
<KeyValueRow label="PID" :value="store.status.pid ?? '—'" mono />
|
||||||
|
<KeyValueRow label="API 版本" :value="store.version || '—'" mono />
|
||||||
|
<KeyValueRow label="重启次数" :value="store.status.restartCount" mono />
|
||||||
|
|
||||||
<!-- 更新内核区块:检查到更新且用户点击"更新"后展开(复用首次安装的下载源选择 UI) -->
|
<!-- 更新内核区块:检查到更新且用户点击"更新"后展开(复用首次安装的下载源选择 UI) -->
|
||||||
<div
|
<div
|
||||||
@@ -1448,274 +1449,150 @@ watch(
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</InfoGroup>
|
||||||
</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>
|
|
||||||
|
|
||||||
<!-- 当前节点 -->
|
<!-- 当前节点 -->
|
||||||
<Card>
|
<InfoGroup variant="card" title="当前节点">
|
||||||
<CardHeader>
|
<KeyValueRow label="代理组" :value="mainGroupName || '—'" />
|
||||||
<CardTitle class="flex items-center gap-2 text-base">
|
<KeyValueRow label="当前节点" :value="mainGroupNow || '—'" />
|
||||||
<Target class="size-4 text-primary" />当前节点
|
<KeyValueRow label="延迟">
|
||||||
</CardTitle>
|
<Badge v-if="mainGroupName" variant="outline" :class="delayBadgeClass(currentNodeDelay)">{{ delayText(currentNodeDelay) }}</Badge>
|
||||||
</CardHeader>
|
<span v-else class="text-[13px] text-muted-foreground">—</span>
|
||||||
<CardContent class="space-y-3 text-sm">
|
</KeyValueRow>
|
||||||
<template v-if="mainGroupName" key="has-main">
|
<template #footer>
|
||||||
<div class="flex items-center justify-between">
|
<div class="space-y-2">
|
||||||
<span class="text-muted-foreground">代理组</span>
|
<Label class="text-xs text-muted-foreground">快捷切换</Label>
|
||||||
<span class="font-medium">{{ mainGroupName }}</span>
|
<Select :disabled="!running || !mainGroupName" :model-value="mainGroupNow" @update:model-value="(v) => quickSwitchNode(String(v))">
|
||||||
</div>
|
<SelectTrigger class="w-full">
|
||||||
<div class="flex items-center justify-between gap-3">
|
<SelectValue :placeholder="mainGroupNow || '选择节点'" />
|
||||||
<span class="text-muted-foreground shrink-0">当前节点</span>
|
</SelectTrigger>
|
||||||
<span class="text-right truncate">{{ mainGroupNow || '—' }}</span>
|
<SelectContent>
|
||||||
</div>
|
<SelectItem v-for="node in mainGroupNodes" :key="node" :value="node">
|
||||||
<div class="flex items-center justify-between">
|
{{ node }}
|
||||||
<span class="text-muted-foreground">延迟</span>
|
</SelectItem>
|
||||||
<Badge variant="outline" :class="delayBadgeClass(currentNodeDelay)">{{ delayText(currentNodeDelay) }}</Badge>
|
</SelectContent>
|
||||||
</div>
|
</Select>
|
||||||
<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 未运行' }}
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</template>
|
||||||
</Card>
|
</InfoGroup>
|
||||||
|
|
||||||
<!-- 自动切换 -->
|
<!-- 订阅与代理 -->
|
||||||
<Card :class="{ 'opacity-60': !running && autoSwitchEnabled }">
|
<InfoGroup variant="card" title="订阅与代理">
|
||||||
<CardHeader>
|
<KeyValueRow label="当前订阅" :value="currentProfile?.name ?? '—'" />
|
||||||
<CardTitle class="flex items-center justify-between text-base">
|
<KeyValueRow label="运行模式">
|
||||||
<span class="flex items-center gap-2">
|
<div class="flex gap-1">
|
||||||
<Timer class="size-4 text-primary" />自动切换节点
|
<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>
|
</span>
|
||||||
<Switch
|
<Switch
|
||||||
:model-value="autoSwitchEnabled"
|
:model-value="store.systemProxy"
|
||||||
:disabled="!running"
|
:disabled="sysProxyLoading || !running"
|
||||||
@update:model-value="onToggleAutoSwitch"
|
@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>
|
</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}」地区` : '全部' }}有效节点的延迟,
|
定时测试「{{ autoSwitchTargetGroup || mainGroupName || '主代理组' }}」{{ autoSwitchRegion ? `中「${autoSwitchRegion}」地区` : '全部' }}有效节点的延迟,
|
||||||
自动切换至最优节点。当前 {{ filteredNodes.length }} 个节点在候选范围内。
|
自动切换至最优节点。当前 {{ filteredNodes.length }} 个节点在候选范围内。
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</template>
|
||||||
</Card>
|
</InfoGroup>
|
||||||
|
|
||||||
<!-- 系统代理 -->
|
</div>
|
||||||
<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>
|
||||||
|
|
||||||
<!-- 连接 -->
|
<!-- 连接 -->
|
||||||
<TabsContent value="connections" class="flex-1 mt-4 tab-animate">
|
<TabsContent value="connections" class="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">
|
<div class="flex min-h-full flex-col gap-4">
|
||||||
<Waypoints class="size-12 opacity-30" />
|
<!-- 指标行:与概览横幅同构;活跃连接后紧跟规则命中 chips,随内容向右延伸 -->
|
||||||
<p class="text-sm">mihomo 未运行,请先在概览页启动</p>
|
<Card class="!py-0 !gap-0">
|
||||||
</div>
|
<CardContent class="flex flex-wrap items-center gap-x-6 gap-y-3 px-4 py-4">
|
||||||
<div v-else class="h-full flex flex-col gap-4 pr-3">
|
<div class="flex min-w-12 flex-col gap-0.5">
|
||||||
<!-- 顶部统计 -->
|
<span class="text-[11px] text-muted-foreground">活跃连接</span>
|
||||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-2">
|
<span class="text-lg font-semibold tabular-nums" :class="running ? 'text-foreground' : 'text-muted-foreground'">{{ connList.length }}</span>
|
||||||
<Card>
|
</div>
|
||||||
<CardContent class="py-3">
|
<!-- 规则命中:内联摘要(chips),紧挨活跃连接,命中项变化时向右延伸(超出后块内换行) -->
|
||||||
<div class="flex items-center gap-1.5 text-muted-foreground text-xs mb-1"><Waypoints class="size-3.5" />活跃连接</div>
|
<div class="flex min-w-0 flex-1 flex-wrap items-center gap-x-3 gap-y-1.5 text-xs">
|
||||||
<p class="text-xl font-semibold tabular-nums">{{ connList.length }}</p>
|
<span class="flex items-center gap-1.5 font-medium text-fg-secondary">
|
||||||
</CardContent>
|
<Target class="size-3.5" />规则命中
|
||||||
</Card>
|
</span>
|
||||||
<Card>
|
<template v-if="ruleHits.length">
|
||||||
<CardContent class="py-3">
|
<span
|
||||||
<div class="flex items-center gap-1.5 text-muted-foreground text-xs mb-1"><ArrowDown class="size-3.5 text-emerald-500" />下载速率</div>
|
v-for="r in ruleHits.slice(0, 6)"
|
||||||
<p class="text-xl font-semibold tabular-nums">{{ fmtSpeed(connTotalDownloadSec) }}</p>
|
:key="r.rule"
|
||||||
</CardContent>
|
class="flex items-center gap-1.5 rounded-sm bg-neutral-soft px-2 py-0.5 text-neutral-text"
|
||||||
</Card>
|
:title="ruleDesc(r.rule)"
|
||||||
<Card>
|
>
|
||||||
<CardContent class="py-3">
|
{{ translateRule(r.rule) }}
|
||||||
<div class="flex items-center gap-1.5 text-muted-foreground text-xs mb-1"><ArrowUp class="size-3.5 text-rose-500" />上传速率</div>
|
<span class="tabular-nums opacity-70">×{{ r.count }}</span>
|
||||||
<p class="text-xl font-semibold tabular-nums">{{ fmtSpeed(connTotalUploadSec) }}</p>
|
</span>
|
||||||
</CardContent>
|
</template>
|
||||||
</Card>
|
<span v-else class="text-muted-foreground">暂无连接</span>
|
||||||
<Card>
|
</div>
|
||||||
<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>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -1725,7 +1602,7 @@ watch(
|
|||||||
<div class="flex items-center justify-between gap-3">
|
<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>
|
<CardTitle class="text-sm flex items-center gap-2"><Waypoints class="size-3.5 text-primary" />当前连接</CardTitle>
|
||||||
<div class="flex items-center gap-2">
|
<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">
|
<Button size="xs" variant="outline" :disabled="!connList.length" @click="closeAllConnections">
|
||||||
<Square class="size-3" />断开全部
|
<Square class="size-3" />断开全部
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1743,7 +1620,7 @@ watch(
|
|||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent class="flex-1 min-h-0 overflow-hidden pt-0">
|
<CardContent class="flex-1 min-h-0 overflow-hidden pt-0">
|
||||||
<ScrollArea class="h-full">
|
<div>
|
||||||
<table class="w-full text-xs">
|
<table class="w-full text-xs">
|
||||||
<thead class="sticky top-0 z-10 bg-card text-muted-foreground">
|
<thead class="sticky top-0 z-10 bg-card text-muted-foreground">
|
||||||
<tr class="border-b">
|
<tr class="border-b">
|
||||||
@@ -1756,21 +1633,18 @@ watch(
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<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">
|
<td class="py-2 px-2 align-baseline">
|
||||||
<span class="font-medium truncate block max-w-[140px]">{{ connProcess(c) }}</span>
|
<span class="font-medium truncate block max-w-[140px]">{{ connProcess(c) }}</span>
|
||||||
<span class="text-muted-foreground">{{ connSource(c) }}</span>
|
<span class="text-muted-foreground">{{ connSource(c) }}</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="py-2 px-2 align-baseline">
|
<td class="py-2 px-2 align-baseline">
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
<Badge
|
<StatusBadge
|
||||||
v-if="connScopeOf(c) === 'direct'"
|
v-if="connScopeOf(c) === 'direct'"
|
||||||
variant="outline" class="h-4 px-1.5 text-[10px] shrink-0 border-emerald-500 text-emerald-500"
|
variant="success" size="sm"
|
||||||
>国内</Badge>
|
>国内</StatusBadge>
|
||||||
<Badge
|
<StatusBadge v-else variant="info" size="sm">国外</StatusBadge>
|
||||||
v-else
|
|
||||||
variant="outline" class="h-4 px-1.5 text-[10px] shrink-0 border-sky-500 text-sky-500"
|
|
||||||
>国外</Badge>
|
|
||||||
<span class="truncate block max-w-[140px]">{{ connHost(c) }}</span>
|
<span class="truncate block max-w-[140px]">{{ connHost(c) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-muted-foreground">{{ c.metadata?.network }} / {{ c.metadata?.type }}</span>
|
<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.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-right tabular-nums align-baseline">{{ fmtBytes(c.upload) }}</td>
|
||||||
<td class="py-2 px-2 text-center align-baseline">
|
<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" />
|
<X class="size-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<!--
|
||||||
|
空态分三种,别让用户把「还没查过」当成「没有连接」:
|
||||||
|
未运行 / 查询中(store.connections 仍为 null)/ 确实为空
|
||||||
|
-->
|
||||||
<tr v-if="!connList.length">
|
<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>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</ScrollArea>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<!-- 节点 -->
|
<!-- 节点 -->
|
||||||
<TabsContent value="proxies" class="flex-1 mt-4 tab-animate">
|
<TabsContent value="proxies" class="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">
|
<div>
|
||||||
<Server class="size-12 opacity-30" />
|
|
||||||
<p class="text-sm">mihomo 未运行,请先在概览页启动</p>
|
|
||||||
</div>
|
|
||||||
<ScrollArea v-else key="proxy-list" class="h-full pr-3">
|
|
||||||
<div class="flex items-center justify-between mb-3">
|
<div class="flex items-center justify-between mb-3">
|
||||||
<p v-if="!groups.length" class="text-sm text-muted-foreground">暂无代理组</p>
|
<p v-if="!groups.length" class="text-sm text-muted-foreground">暂无代理组</p>
|
||||||
<p v-else class="text-sm text-muted-foreground">{{ groups.length }} 个代理组</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">
|
<AccordionTrigger class="px-4 py-2.5 hover:no-underline">
|
||||||
<div class="flex items-center justify-between w-full pr-2">
|
<div class="flex items-center justify-between w-full pr-2">
|
||||||
<span class="flex items-center gap-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="font-medium">{{ gname }}</span>
|
||||||
<span class="text-xs font-normal text-muted-foreground">{{ group.type }}</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>
|
<span v-if="group.now" class="text-xs text-primary truncate max-w-32">{{ group.now }}</span>
|
||||||
@@ -1851,8 +1733,8 @@ watch(
|
|||||||
<button
|
<button
|
||||||
v-for="node in group.all" :key="node"
|
v-for="node in group.all" :key="node"
|
||||||
type="button"
|
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="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/10' : 'border-border'"
|
:class="group.now === node ? 'border-primary bg-primary-soft' : 'border-border'"
|
||||||
:disabled="group.type !== 'Selector'"
|
:disabled="group.type !== 'Selector'"
|
||||||
@click="selectNode(gname, node)"
|
@click="selectNode(gname, node)"
|
||||||
>
|
>
|
||||||
@@ -1866,13 +1748,13 @@ watch(
|
|||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
</Card>
|
</Card>
|
||||||
</Accordion>
|
</Accordion>
|
||||||
</ScrollArea>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<!-- 订阅 -->
|
<!-- 订阅 -->
|
||||||
<TabsContent value="profiles" class="flex-1 mt-4 tab-animate">
|
<TabsContent value="profiles" class="flex-1 min-h-0 tab-animate">
|
||||||
<ScrollArea class="h-full pr-3">
|
<div>
|
||||||
<div class="space-y-4 max-w-3xl">
|
<div class="space-y-4">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader class="pb-3">
|
<CardHeader class="pb-3">
|
||||||
<CardTitle class="flex items-center gap-2 text-base">
|
<CardTitle class="flex items-center gap-2 text-base">
|
||||||
@@ -1907,7 +1789,7 @@ watch(
|
|||||||
<div
|
<div
|
||||||
v-for="p in store.settings.profiles" :key="p.id"
|
v-for="p in store.settings.profiles" :key="p.id"
|
||||||
class="flex items-center gap-3 rounded-md border p-3"
|
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-1 min-w-0 space-y-1">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
@@ -1953,13 +1835,13 @@ watch(
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<!-- 设置 -->
|
<!-- 设置 -->
|
||||||
<TabsContent value="settings" class="flex-1 mt-4 tab-animate">
|
<TabsContent value="settings" class="tab-animate">
|
||||||
<ScrollArea class="h-full pr-3">
|
<div>
|
||||||
<Card class="max-w-2xl">
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle class="flex items-center gap-2 text-base">
|
<CardTitle class="flex items-center gap-2 text-base">
|
||||||
<SettingsIcon class="size-4 text-primary" />基础设置
|
<SettingsIcon class="size-4 text-primary" />基础设置
|
||||||
@@ -1967,11 +1849,10 @@ watch(
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent class="space-y-4">
|
<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、规则等高级配置请直接编辑订阅文件。
|
修改端口/接口/密钥/模式后需重启 mihomo 生效。DNS、规则等高级配置请直接编辑订阅文件。
|
||||||
</p>
|
</p>
|
||||||
<Separator />
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
<div class="grid grid-cols-2 gap-4">
|
|
||||||
<div class="grid gap-2">
|
<div class="grid gap-2">
|
||||||
<Label for="mixed-port">混合代理端口</Label>
|
<Label for="mixed-port">混合代理端口</Label>
|
||||||
<Input id="mixed-port" v-model.number="localSettings.mixedPort" type="number" />
|
<Input id="mixed-port" v-model.number="localSettings.mixedPort" type="number" />
|
||||||
@@ -1985,29 +1866,7 @@ watch(
|
|||||||
<Label for="secret">API 密钥(留空则不鉴权)</Label>
|
<Label for="secret">API 密钥(留空则不鉴权)</Label>
|
||||||
<Input id="secret" v-model="localSettings.secret" placeholder="可选" />
|
<Input id="secret" v-model="localSettings.secret" placeholder="可选" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-between rounded-md border p-3">
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
<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 gap-2">
|
<div class="grid gap-2">
|
||||||
<Label for="mode-select">运行模式</Label>
|
<Label for="mode-select">运行模式</Label>
|
||||||
<Select v-model="localSettings.mode">
|
<Select v-model="localSettings.mode">
|
||||||
@@ -2037,9 +1896,31 @@ watch(
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</ScrollArea>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
|
|||||||
@@ -157,5 +157,5 @@ export const moduleConfig: ModuleConfig = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
order: 10
|
order: 20
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from '@/comp
|
|||||||
import { Switch } from '@/components/ui/switch'
|
import { Switch } from '@/components/ui/switch'
|
||||||
import { aggregateSearch, loadAppIconsForResults, recordHistoryItem, getAllHistoryItems, setFileIndexReady, type QPItem, type QPSubAction } from './providers'
|
import { aggregateSearch, loadAppIconsForResults, recordHistoryItem, getAllHistoryItems, setFileIndexReady, type QPItem, type QPSubAction } from './providers'
|
||||||
import { EVENTS, STORAGE_KEYS } from '@/lib/constants'
|
import { EVENTS, STORAGE_KEYS } from '@/lib/constants'
|
||||||
|
import { applyStoredAccent } from '@/lib/theme'
|
||||||
import HistoryPicker from './HistoryPicker.vue'
|
import HistoryPicker from './HistoryPicker.vue'
|
||||||
|
|
||||||
// ===== 状态 =====
|
// ===== 状态 =====
|
||||||
@@ -23,7 +24,7 @@ const subActionExpanded = ref<number | null>(null)
|
|||||||
const subActionIndex = ref(0)
|
const subActionIndex = ref(0)
|
||||||
// 待删除确认项(文件/文件夹删除前弹窗确认)
|
// 待删除确认项(文件/文件夹删除前弹窗确认)
|
||||||
const pendingDelete = ref<{ path: string; isDir: boolean; name: string } | null>(null)
|
const pendingDelete = ref<{ path: string; isDir: boolean; name: string } | null>(null)
|
||||||
let unlistenFns: UnlistenFn[] = []
|
const unlistenFns: UnlistenFn[] = []
|
||||||
let searchTimer: ReturnType<typeof setTimeout> | null = null
|
let searchTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
// ===== 当前目录文件操作(快捷键在资源管理器中按下时检测) =====
|
// ===== 当前目录文件操作(快捷键在资源管理器中按下时检测) =====
|
||||||
@@ -851,18 +852,18 @@ const groupIcon = (group: string) => {
|
|||||||
|
|
||||||
/** 类型 badge 颜色映射(柔和色块风格,跟随亮/暗主题) */
|
/** 类型 badge 颜色映射(柔和色块风格,跟随亮/暗主题) */
|
||||||
const GROUP_COLORS: Record<string, string> = {
|
const GROUP_COLORS: Record<string, string> = {
|
||||||
设置: 'bg-indigo-500/15 text-indigo-600 dark:text-indigo-400',
|
设置: 'bg-info-soft text-info-text',
|
||||||
计算: 'bg-amber-500/15 text-amber-600 dark:text-amber-400',
|
计算: 'bg-warning-soft text-warning-text',
|
||||||
网页: 'bg-rose-500/15 text-rose-600 dark:text-rose-400',
|
网页: 'bg-danger-soft text-danger-text',
|
||||||
系统: 'bg-slate-500/15 text-slate-600 dark:text-slate-400',
|
系统: 'bg-neutral-soft text-neutral-text',
|
||||||
自定义: 'bg-orange-500/15 text-orange-600 dark:text-orange-400',
|
自定义: 'bg-warning-soft text-warning-text',
|
||||||
历史: 'bg-violet-500/15 text-violet-600 dark:text-violet-400',
|
历史: 'bg-info-soft text-info-text',
|
||||||
快捷: 'bg-sky-500/15 text-sky-600 dark:text-sky-400',
|
快捷: 'bg-info-soft text-info-text',
|
||||||
换算: 'bg-teal-500/15 text-teal-600 dark:text-teal-400',
|
换算: 'bg-success-soft text-success-text',
|
||||||
目录操作: 'bg-cyan-500/15 text-cyan-600 dark:text-cyan-400',
|
目录操作: 'bg-info-soft text-info-text',
|
||||||
应用: 'bg-blue-500/15 text-blue-600 dark:text-blue-400',
|
应用: 'bg-info-soft text-info-text',
|
||||||
文件: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400',
|
文件: 'bg-success-soft text-success-text',
|
||||||
剪贴板: 'bg-pink-500/15 text-pink-600 dark:text-pink-400',
|
剪贴板: 'bg-success-soft text-success-text',
|
||||||
}
|
}
|
||||||
|
|
||||||
function groupBadgeClass(group: string): string {
|
function groupBadgeClass(group: string): string {
|
||||||
@@ -915,6 +916,9 @@ async function applyTheme() {
|
|||||||
root.classList.add(`effect-${effect}`)
|
root.classList.add(`effect-${effect}`)
|
||||||
if (isDark) root.classList.add('dark')
|
if (isDark) root.classList.add('dark')
|
||||||
|
|
||||||
|
// 主题色:与主窗口共用同一份设置。行内变量会压住 .dark 规则,故按当前模式整组重写
|
||||||
|
applyStoredAccent(root, isDark ? 'dark' : 'light', STORAGE_KEYS.appSettings)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const tauriWin = getCurrentWindow()
|
const tauriWin = getCurrentWindow()
|
||||||
await tauriWin.clearEffects()
|
await tauriWin.clearEffects()
|
||||||
@@ -1356,7 +1360,7 @@ onUnmounted(() => {
|
|||||||
:checked="deleteSelected.has(f.path)"
|
:checked="deleteSelected.has(f.path)"
|
||||||
@change="toggleDeleteSelect(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" />
|
<FileText v-else class="size-4 text-muted-foreground shrink-0" />
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<p class="text-xs truncate">{{ f.name }}</p>
|
<p class="text-xs truncate">{{ f.name }}</p>
|
||||||
|
|||||||
@@ -293,8 +293,8 @@ async function changeEngine(v: string) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="h-full p-6 overflow-y-auto">
|
<div class="min-h-full p-5">
|
||||||
<div class="max-w-2xl space-y-4">
|
<div class="space-y-4">
|
||||||
<!-- 标题与测试 -->
|
<!-- 标题与测试 -->
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<h2 class="text-lg font-semibold flex items-center gap-2">
|
<h2 class="text-lg font-semibold flex items-center gap-2">
|
||||||
@@ -311,7 +311,7 @@ async function changeEngine(v: string) {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle class="text-sm flex items-center gap-2">
|
<CardTitle class="text-sm flex items-center gap-2">
|
||||||
<Keyboard class="size-4" />
|
<Keyboard class="size-4 text-primary" />
|
||||||
唤起快捷键
|
唤起快捷键
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -372,7 +372,7 @@ async function changeEngine(v: string) {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle class="text-sm flex items-center gap-2">
|
<CardTitle class="text-sm flex items-center gap-2">
|
||||||
<Globe class="size-4" />
|
<Globe class="size-4 text-primary" />
|
||||||
默认搜索引擎
|
默认搜索引擎
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -398,7 +398,7 @@ async function changeEngine(v: string) {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle class="text-sm flex items-center gap-2">
|
<CardTitle class="text-sm flex items-center gap-2">
|
||||||
<FolderTree class="size-4" />
|
<FolderTree class="size-4 text-primary" />
|
||||||
文件索引
|
文件索引
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -457,7 +457,7 @@ async function changeEngine(v: string) {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle class="text-sm flex items-center gap-2">
|
<CardTitle class="text-sm flex items-center gap-2">
|
||||||
<Terminal class="size-4" />
|
<Terminal class="size-4 text-primary" />
|
||||||
自定义命令
|
自定义命令
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|||||||
@@ -66,5 +66,5 @@ export const moduleConfig: ModuleConfig = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
order: 60
|
order: 90
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { LogicalSize } from '@tauri-apps/api/dpi'
|
|||||||
import { emit, listen, type UnlistenFn } from '@tauri-apps/api/event'
|
import { emit, listen, type UnlistenFn } from '@tauri-apps/api/event'
|
||||||
import { invoke } from '@tauri-apps/api/core'
|
import { invoke } from '@tauri-apps/api/core'
|
||||||
import { EVENTS, STORAGE_KEYS } from '@/lib/constants'
|
import { EVENTS, STORAGE_KEYS } from '@/lib/constants'
|
||||||
|
import { applyStoredAccent } from '@/lib/theme'
|
||||||
// Rust 端通过 tauri-specta 生成的命令绑定(bindings.ts)
|
// Rust 端通过 tauri-specta 生成的命令绑定(bindings.ts)
|
||||||
import { commands } from '@/lib/bindings'
|
import { commands } from '@/lib/bindings'
|
||||||
import { save } from '@tauri-apps/plugin-dialog'
|
import { save } from '@tauri-apps/plugin-dialog'
|
||||||
@@ -931,6 +932,8 @@ function applyTheme() {
|
|||||||
else if (theme === 'light') dark = false
|
else if (theme === 'light') dark = false
|
||||||
else dark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
else dark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||||
root.classList.toggle('dark', dark)
|
root.classList.toggle('dark', dark)
|
||||||
|
// 主题色:与主窗口共用同一份设置(行内变量压住 .dark 规则,按当前模式整组重写)
|
||||||
|
applyStoredAccent(root, dark ? 'dark' : 'light', STORAGE_KEYS.appSettings)
|
||||||
}
|
}
|
||||||
|
|
||||||
function onStorageChange(e: StorageEvent) {
|
function onStorageChange(e: StorageEvent) {
|
||||||
@@ -1349,10 +1352,16 @@ onUnmounted(() => {
|
|||||||
<p>加载中...</p>
|
<p>加载中...</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- 画布区(ScrollArea:主题化滚动条,纵向 + 横向;w-fit mx-auto 窄图水平居中) -->
|
<!--
|
||||||
<ScrollAreaRoot v-else class="flex-1 min-h-0 bg-background">
|
画布区(ScrollArea:主题化滚动条,纵向 + 横向;w-fit mx-auto 窄图水平居中)。
|
||||||
<ScrollAreaViewport class="size-full outline-none">
|
|
||||||
<div class="p-4 w-fit mx-auto">
|
内边距必须是内层的(这里是包住画布的 div,属于滚动内容),且底部要留够:
|
||||||
|
画布的 shadow-2xl 向下投影约 38px,原来 p-4(16px)不够 —— 滚到底时
|
||||||
|
画布下方那条阴影会被视口裁掉。左右方向 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">
|
<div class="canvas-wrap relative inline-block shadow-2xl">
|
||||||
<canvas
|
<canvas
|
||||||
ref="canvasRef"
|
ref="canvasRef"
|
||||||
|
|||||||
@@ -8,25 +8,58 @@ import { toast } from 'vue-sonner'
|
|||||||
import { open } from '@tauri-apps/plugin-dialog'
|
import { open } from '@tauri-apps/plugin-dialog'
|
||||||
import { useScreenshotStore, type RecentCapture } from '@/stores/screenshotStore'
|
import { useScreenshotStore, type RecentCapture } from '@/stores/screenshotStore'
|
||||||
import { useModuleTabs } from '@/lib/use-module-tabs'
|
import { useModuleTabs } from '@/lib/use-module-tabs'
|
||||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
|
import { Tabs, TabsContent } from '@/components/ui/tabs'
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Switch } from '@/components/ui/switch'
|
import { Switch } from '@/components/ui/switch'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
|
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
|
||||||
|
import {
|
||||||
|
Pagination, PaginationContent, PaginationItem, PaginationEllipsis,
|
||||||
|
} from '@/components/ui/pagination'
|
||||||
|
|
||||||
const store = useScreenshotStore()
|
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: 'history', label: '历史' },
|
||||||
])
|
{ value: 'settings', label: '设置' },
|
||||||
|
], { alwaysVisible: true })
|
||||||
|
|
||||||
const HISTORY_LIMITS = [24, 48, 96]
|
const HISTORY_LIMITS = [24, 48, 96]
|
||||||
const DELAY_OPTIONS = [0, 1, 2, 3, 5]
|
const DELAY_OPTIONS = [0, 1, 2, 3, 5]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 历史分页:一页 9 张(3×3)。
|
||||||
|
*
|
||||||
|
* 历史条数上限是 96(HISTORY_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 {
|
function formatTime(t: number): string {
|
||||||
const d = new Date(t)
|
const d = new Date(t)
|
||||||
const pad = (n: number) => String(n).padStart(2, '0')
|
const pad = (n: number) => String(n).padStart(2, '0')
|
||||||
@@ -208,22 +241,12 @@ onUnmounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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">
|
<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">
|
<TabsContent value="settings" class="flex-1 min-h-0">
|
||||||
<ScrollArea class="h-full">
|
<div class="h-full">
|
||||||
<div class="space-y-4 max-w-3xl">
|
<div class="space-y-4">
|
||||||
<!-- 快捷键 -->
|
<!-- 快捷键 -->
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -423,12 +446,12 @@ onUnmounted(() => {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<!-- 历史记录 -->
|
<!-- 历史记录 -->
|
||||||
<TabsContent value="history" class="flex-1 min-h-0 mt-4">
|
<TabsContent value="history" class="flex-1 min-h-0">
|
||||||
<ScrollArea class="h-full">
|
<div class="h-full">
|
||||||
<div>
|
<div>
|
||||||
<!-- 空状态 -->
|
<!-- 空状态 -->
|
||||||
<div
|
<div
|
||||||
@@ -440,15 +463,54 @@ onUnmounted(() => {
|
|||||||
<p class="text-xs">按 Ctrl + 1 截图并导出后会显示在这里</p>
|
<p class="text-xs">按 Ctrl + 1 截图并导出后会显示在这里</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 历史网格 -->
|
<template v-else>
|
||||||
<div v-else class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
|
<!--
|
||||||
|
分页条:只在多于一页时出现,放在网格上方(翻页后不用滚回去)
|
||||||
|
-->
|
||||||
|
<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
|
<div
|
||||||
v-for="item in store.recent"
|
v-for="item in pagedRecent"
|
||||||
:key="item.id"
|
:key="item.id"
|
||||||
class="group relative rounded-lg border bg-muted/30 overflow-hidden"
|
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
|
<img
|
||||||
:src="thumbSrc(item)"
|
:src="thumbSrc(item)"
|
||||||
class="max-w-full max-h-full object-contain"
|
class="max-w-full max-h-full object-contain"
|
||||||
@@ -518,8 +580,9 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { invoke } from '@tauri-apps/api/core'
|
|||||||
import { getCurrentWindow } from '@tauri-apps/api/window'
|
import { getCurrentWindow } from '@tauri-apps/api/window'
|
||||||
import { emit, listen, type UnlistenFn } from '@tauri-apps/api/event'
|
import { emit, listen, type UnlistenFn } from '@tauri-apps/api/event'
|
||||||
import { EVENTS, STORAGE_KEYS, WINDOWS } from '@/lib/constants'
|
import { EVENTS, STORAGE_KEYS, WINDOWS } from '@/lib/constants'
|
||||||
|
import { applyStoredAccent } from '@/lib/theme'
|
||||||
// Rust 端通过 tauri-specta 生成的命令绑定(bindings.ts)
|
// Rust 端通过 tauri-specta 生成的命令绑定(bindings.ts)
|
||||||
import { commands } from '@/lib/bindings'
|
import { commands } from '@/lib/bindings'
|
||||||
import {
|
import {
|
||||||
@@ -309,6 +310,7 @@ const hint = computed(() => {
|
|||||||
case 'drawing': return '松开鼠标完成选区'
|
case 'drawing': return '松开鼠标完成选区'
|
||||||
case 'selected': return '拖动移动选区 · 拖动手柄调整大小 · 选择工具开始标注 · Enter 完成 · Esc 取消'
|
case 'selected': return '拖动移动选区 · 拖动手柄调整大小 · 选择工具开始标注 · Enter 完成 · Esc 取消'
|
||||||
case 'editing': return '在选区内绘制标注 · 文字工具点击已有文字可编辑 · Ctrl+Enter 完成 · Esc 退出标注'
|
case 'editing': return '在选区内绘制标注 · 文字工具点击已有文字可编辑 · Ctrl+Enter 完成 · Esc 退出标注'
|
||||||
|
default: return ''
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -600,8 +602,8 @@ function normRect(a: Point, b: Point): Sel {
|
|||||||
function clampSel() {
|
function clampSel() {
|
||||||
const s = sel.value
|
const s = sel.value
|
||||||
const min = 8
|
const min = 8
|
||||||
let w = Math.min(Math.max(min, s.w), winW.value)
|
const w = Math.min(Math.max(min, s.w), winW.value)
|
||||||
let h = Math.min(Math.max(min, s.h), winH.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 x = Math.min(Math.max(0, s.x), winW.value - w)
|
||||||
const y = Math.min(Math.max(0, s.y), winH.value - h)
|
const y = Math.min(Math.max(0, s.y), winH.value - h)
|
||||||
sel.value = { x, y, w, h }
|
sel.value = { x, y, w, h }
|
||||||
@@ -1858,6 +1860,8 @@ function applyTheme() {
|
|||||||
else if (theme === 'light') isDark = false
|
else if (theme === 'light') isDark = false
|
||||||
else isDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
else isDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||||
root.classList.toggle('dark', isDark)
|
root.classList.toggle('dark', isDark)
|
||||||
|
// 主题色:与主窗口共用同一份设置(行内变量压住 .dark 规则,按当前模式整组重写)
|
||||||
|
applyStoredAccent(root, isDark ? 'dark' : 'light', STORAGE_KEYS.appSettings)
|
||||||
// 跟随系统时监听变化
|
// 跟随系统时监听变化
|
||||||
return window.matchMedia('(prefers-color-scheme: dark)')
|
return window.matchMedia('(prefers-color-scheme: dark)')
|
||||||
}
|
}
|
||||||
@@ -2465,7 +2469,7 @@ onUnmounted(() => {
|
|||||||
padding: 8px 14px;
|
padding: 8px 14px;
|
||||||
background: rgba(15, 23, 42, 0.92);
|
background: rgba(15, 23, 42, 0.92);
|
||||||
border: 1px solid rgba(239, 68, 68, 0.6);
|
border: 1px solid rgba(239, 68, 68, 0.6);
|
||||||
color: #fecaca;
|
color: var(--danger);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
@@ -2487,7 +2491,7 @@ onUnmounted(() => {
|
|||||||
/* 窗口识别:框内全透明透出原画面,box-shadow 暗化框外(形成"框内透明、框外变暗") */
|
/* 窗口识别:框内全透明透出原画面,box-shadow 暗化框外(形成"框内透明、框外变暗") */
|
||||||
.win-highlight {
|
.win-highlight {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
border: 2px solid #3b82f6;
|
border: 2px solid var(--primary);
|
||||||
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.45);
|
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.45);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 5;
|
z-index: 5;
|
||||||
@@ -2498,7 +2502,7 @@ onUnmounted(() => {
|
|||||||
left: -2px;
|
left: -2px;
|
||||||
max-width: 240px;
|
max-width: 240px;
|
||||||
padding: 1px 6px;
|
padding: 1px 6px;
|
||||||
background: #3b82f6;
|
background: var(--primary);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
@@ -2510,7 +2514,7 @@ onUnmounted(() => {
|
|||||||
/* 拖拽中的选区:透明内部 + 巨大 box-shadow 形成外部遮罩 */
|
/* 拖拽中的选区:透明内部 + 巨大 box-shadow 形成外部遮罩 */
|
||||||
.selection {
|
.selection {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
border: 2px solid #3b82f6;
|
border: 2px solid var(--primary);
|
||||||
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.45);
|
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.45);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 5;
|
z-index: 5;
|
||||||
@@ -2520,7 +2524,7 @@ onUnmounted(() => {
|
|||||||
top: -24px;
|
top: -24px;
|
||||||
left: 0;
|
left: 0;
|
||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
background: #3b82f6;
|
background: var(--primary);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
@@ -2548,7 +2552,7 @@ onUnmounted(() => {
|
|||||||
.region-border {
|
.region-border {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
border: 2px solid #3b82f6;
|
border: 2px solid var(--primary);
|
||||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.6);
|
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.6);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
@@ -2559,7 +2563,7 @@ onUnmounted(() => {
|
|||||||
width: 9px;
|
width: 9px;
|
||||||
height: 9px;
|
height: 9px;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border: 2px solid #3b82f6;
|
border: 2px solid var(--primary);
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
transform: translate(-50%, -50%);
|
transform: translate(-50%, -50%);
|
||||||
z-index: 8;
|
z-index: 8;
|
||||||
@@ -2742,14 +2746,14 @@ onUnmounted(() => {
|
|||||||
color: #fff;
|
color: #fff;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.sm-mode.auto { background: #2563eb; }
|
.sm-mode.auto { background: var(--primary); }
|
||||||
.sm-mode.manual { background: #059669; }
|
.sm-mode.manual { background: var(--success); }
|
||||||
/* 拼接进行中的呼吸点 */
|
/* 拼接进行中的呼吸点 */
|
||||||
.sm-dot {
|
.sm-dot {
|
||||||
width: 7px;
|
width: 7px;
|
||||||
height: 7px;
|
height: 7px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: #22c55e;
|
background: var(--success);
|
||||||
animation: sm-pulse 1.1s ease-in-out infinite;
|
animation: sm-pulse 1.1s ease-in-out infinite;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { LogicalPosition, LogicalSize } from '@tauri-apps/api/dpi'
|
|||||||
import { emit, listen, type UnlistenFn } from '@tauri-apps/api/event'
|
import { emit, listen, type UnlistenFn } from '@tauri-apps/api/event'
|
||||||
import { ChevronLeft, ChevronRight, X, Loader2 } from '@lucide/vue'
|
import { ChevronLeft, ChevronRight, X, Loader2 } from '@lucide/vue'
|
||||||
import { EVENTS, STORAGE_KEYS } from '@/lib/constants'
|
import { EVENTS, STORAGE_KEYS } from '@/lib/constants'
|
||||||
|
import { applyStoredAccent } from '@/lib/theme'
|
||||||
// Rust 端通过 tauri-specta 生成的命令绑定(bindings.ts)
|
// Rust 端通过 tauri-specta 生成的命令绑定(bindings.ts)
|
||||||
import { commands } from '@/lib/bindings'
|
import { commands } from '@/lib/bindings'
|
||||||
|
|
||||||
@@ -220,6 +221,8 @@ function applyTheme() {
|
|||||||
isDark = theme === 'dark'
|
isDark = theme === 'dark'
|
||||||
}
|
}
|
||||||
root.classList.toggle('dark', isDark)
|
root.classList.toggle('dark', isDark)
|
||||||
|
// 主题色:与主窗口共用同一份设置(行内变量压住 .dark 规则,按当前模式整组重写)
|
||||||
|
applyStoredAccent(root, isDark ? 'dark' : 'light', STORAGE_KEYS.appSettings)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 从 localStorage 读取主窗口持久化的截图历史(图片文件在应用数据目录) */
|
/** 从 localStorage 读取主窗口持久化的截图历史(图片文件在应用数据目录) */
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ const height = ref(0)
|
|||||||
const status = ref('')
|
const status = ref('')
|
||||||
/** 是否已收到最终事件(完成/取消),此后关闭窗口不再触发取消 */
|
/** 是否已收到最终事件(完成/取消),此后关闭窗口不再触发取消 */
|
||||||
let ended = false
|
let ended = false
|
||||||
let unlisten: UnlistenFn[] = []
|
const unlisten: UnlistenFn[] = []
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
status.value = auto.value ? '自动向下拼接中…' : '请滚动目标窗口,完成后点「完成」'
|
status.value = auto.value ? '自动向下拼接中…' : '请滚动目标窗口,完成后点「完成」'
|
||||||
|
|||||||
@@ -55,5 +55,5 @@ export const moduleConfig: ModuleConfig = {
|
|||||||
defaultEnabled: true,
|
defaultEnabled: true,
|
||||||
loader: () => import('./ScreenshotModule.vue'),
|
loader: () => import('./ScreenshotModule.vue'),
|
||||||
searchItems,
|
searchItems,
|
||||||
order: 30
|
order: 70
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<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 { Switch } from '@/components/ui/switch'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
import { Progress } from '@/components/ui/progress'
|
import { Progress } from '@/components/ui/progress'
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||||
import { useAppStore, type Theme, type EffectType, type ModuleInfo } from '@/stores/appStore'
|
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 { toast } from 'vue-sonner'
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
import { useUpdaterStore } from '@/stores/updaterStore'
|
import { useUpdaterStore } from '@/stores/updaterStore'
|
||||||
|
import {
|
||||||
|
ACCENT_PRESETS,
|
||||||
|
CUSTOM_ACCENT_ID,
|
||||||
|
DEFAULT_ACCENT_ID,
|
||||||
|
contrastRatio,
|
||||||
|
deriveAccent,
|
||||||
|
parseHex
|
||||||
|
} from '@/lib/theme'
|
||||||
|
|
||||||
const appStore = useAppStore()
|
const appStore = useAppStore()
|
||||||
const searchStore = useSearchStore()
|
const searchStore = useSearchStore()
|
||||||
@@ -383,6 +392,8 @@ onMounted(() => {
|
|||||||
searchStore.registerAction('settings', 9, () => scrollToCard('settings-card-about'))
|
searchStore.registerAction('settings', 9, () => scrollToCard('settings-card-about'))
|
||||||
searchStore.registerAction('settings', 10, () => scrollToCard('settings-card-about'))
|
searchStore.registerAction('settings', 10, () => scrollToCard('settings-card-about'))
|
||||||
searchStore.registerAction('settings', 11, () => scrollToCard('settings-card-quit'))
|
searchStore.registerAction('settings', 11, () => scrollToCard('settings-card-quit'))
|
||||||
|
// 主题色:追加在 searchItems 末尾(index 12),避免给前面 0–11 重新编号
|
||||||
|
searchStore.registerAction('settings', 12, () => scrollToCard('settings-card-accent'))
|
||||||
// 主动刷新所有进程状态,确保内核 badge 显示当前真实状态(而非过期缓存)
|
// 主动刷新所有进程状态,确保内核 badge 显示当前真实状态(而非过期缓存)
|
||||||
processStore.refreshAll().catch(() => { /* 忽略:后端可能未就绪 */ })
|
processStore.refreshAll().catch(() => { /* 忽略:后端可能未就绪 */ })
|
||||||
})
|
})
|
||||||
@@ -402,6 +413,65 @@ const effects: Array<{ id: EffectType; name: string; color: string; darkColor: s
|
|||||||
// 亚克力仅在"跟随系统"主题下能正确同步深浅色(无深浅枚举,依赖系统级主题广播刷新)
|
// 亚克力仅在"跟随系统"主题下能正确同步深浅色(无深浅枚举,依赖系统级主题广播刷新)
|
||||||
const isAcrylicDisabled = () => appStore.theme !== 'system'
|
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(() => {
|
const isAppDark = computed(() => {
|
||||||
if (appStore.theme === 'dark') return true
|
if (appStore.theme === 'dark') return true
|
||||||
@@ -502,8 +572,8 @@ const onDragEnd = () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="h-full p-6 overflow-y-auto">
|
<div class="min-h-full p-6">
|
||||||
<div class="max-w-3xl mx-auto space-y-6">
|
<div class="space-y-6">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle class="flex items-center gap-2">
|
<CardTitle class="flex items-center gap-2">
|
||||||
@@ -561,7 +631,7 @@ const onDragEnd = () => {
|
|||||||
<component
|
<component
|
||||||
:is="theme.icon"
|
:is="theme.icon"
|
||||||
class="size-8 transition-colors duration-300"
|
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>
|
||||||
<div class="h-10 flex items-center justify-center">
|
<div class="h-10 flex items-center justify-center">
|
||||||
@@ -580,6 +650,88 @@ const onDragEnd = () => {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle class="flex items-center gap-2">
|
<CardTitle class="flex items-center gap-2">
|
||||||
@@ -673,7 +825,7 @@ const onDragEnd = () => {
|
|||||||
<span
|
<span
|
||||||
v-if="getProcessStatusText(module.id)"
|
v-if="getProcessStatusText(module.id)"
|
||||||
class="text-xs px-1.5 py-0.5 rounded-full"
|
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) }}
|
{{ getProcessStatusText(module.id) }}
|
||||||
</span>
|
</span>
|
||||||
@@ -753,7 +905,7 @@ const onDragEnd = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="flex items-center gap-1.5 text-sm text-muted-foreground">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -62,6 +62,12 @@ const searchItems: SearchIndexItem[] = [
|
|||||||
title: '退出程序',
|
title: '退出程序',
|
||||||
description: '彻底退出 Thing 应用',
|
description: '彻底退出 Thing 应用',
|
||||||
keywords: ['退出', '关闭', 'quit', 'exit', '结束']
|
keywords: ['退出', '关闭', 'quit', 'exit', '结束']
|
||||||
|
},
|
||||||
|
// 追加在末尾:searchItems 的下标与 GeneralSettings 里 registerAction 的 index 一一对应
|
||||||
|
{
|
||||||
|
title: '主题色',
|
||||||
|
description: '自定义应用强调色(预设色板 / 自定义颜色)',
|
||||||
|
keywords: ['主题色', '强调色', '配色', '颜色', 'accent', 'color', 'theme']
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
// 底色单独 try:setTheme 失败不该连带跳过底色,否则仍会白底闪一下
|
||||||
|
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>
|
||||||
@@ -23,17 +23,65 @@
|
|||||||
*
|
*
|
||||||
* 输出事件是**窗口广播**(Rust 侧 `app.emit` 发到所有窗口),主窗口的监听器
|
* 输出事件是**窗口广播**(Rust 侧 `app.emit` 发到所有窗口),主窗口的监听器
|
||||||
* 管不到本窗口。这正是「独立窗口能收到输出」的实现基础。
|
* 管不到本窗口。这正是「独立窗口能收到输出」的实现基础。
|
||||||
|
*
|
||||||
|
* # 界面:与终端页同构的「终端卡片」(2026-09-21)
|
||||||
|
*
|
||||||
|
* 此前本窗口是一条 h-8 的标题栏 + 画布 + 状态栏,与主窗口里的终端页看起来
|
||||||
|
* 是两套东西。现在改成与终端页**同一套结构与组件**:
|
||||||
|
*
|
||||||
|
* ```
|
||||||
|
* ┌───────────────────────────────────────────────────────────┐
|
||||||
|
* │ [图标 会话名 …可拖拽…] │ 搜索 字体 清屏 ⋯ │ ─ □ ✕ │ ← 控制栏 h-9
|
||||||
|
* ├───────────────────────────────────────────────────────────┤
|
||||||
|
* │ 画布(xterm) │
|
||||||
|
* ├───────────────────────────────────────────────────────────┤
|
||||||
|
* │ 状态栏(与主窗口同一个 TerminalStatusBar) │
|
||||||
|
* └───────────────────────────────────────────────────────────┘
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* 控制栏左端承担原标题栏的两件事:显示「这是哪个会话」与**窗口拖拽区**
|
||||||
|
* (无边框窗口必须有拖拽区,否则窗口挪不动)。右侧是复用的 `TerminalToolbar`
|
||||||
|
* (`standalone` 模式:隐藏只对主窗口页内面板有意义的动作)+ 最小化/最大化/关闭。
|
||||||
|
*
|
||||||
|
* 窗口绑定单个会话,因此**不渲染标签栏** —— 这保持了「一个会话 ↔ 一个窗口」的
|
||||||
|
* 语义:主窗口那侧的「已在独立窗口中打开」提示条、标签上的「独立」徽标都以此为准。
|
||||||
*/
|
*/
|
||||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||||
import { getCurrentWindow } from '@tauri-apps/api/window'
|
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 { useTerminalStore } from '@/stores/terminalStore'
|
||||||
import { useTerminalStream } from '@/composables/useSessionStream'
|
import { useTerminalStream } from '@/composables/useSessionStream'
|
||||||
import { useTerminalKeys } from '@/composables/useTerminalKeys'
|
import { useTerminalKeys } from '@/composables/useTerminalKeys'
|
||||||
|
import { toggleHistoryPopover, toggleSnippetsPopover } from './useRecordPopovers'
|
||||||
import { createLogger } from '@/lib/logger'
|
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 TerminalPane from './components/TerminalPane.vue'
|
||||||
|
import TerminalToolbar from './components/TerminalToolbar.vue'
|
||||||
import TerminalStatusBar from './components/TerminalStatusBar.vue'
|
import TerminalStatusBar from './components/TerminalStatusBar.vue'
|
||||||
|
import TerminalContextMenu from './components/TerminalContextMenu.vue'
|
||||||
import HostKeyPromptDialog from './components/HostKeyPromptDialog.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')
|
const logger = createLogger('terminal')
|
||||||
|
|
||||||
@@ -55,6 +103,12 @@ const { effectiveAppearance, bumpFont, resetFont, bindings, init } = stream
|
|||||||
|
|
||||||
const session = computed(() => store.sessionById(sessionId))
|
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)
|
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 ready = ref(false)
|
||||||
const fatal = ref('')
|
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 {
|
||||||
|
// 底色单独 try:setTheme 失败不该连带跳过底色,否则仍会白底闪一下
|
||||||
|
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 () => {
|
onMounted(async () => {
|
||||||
|
darkMedia.addEventListener('change', onThemeChange)
|
||||||
|
window.addEventListener('storage', onStorageChange)
|
||||||
|
|
||||||
|
// 主题 → 显示:窗口是隐藏创建的,这一步之后用户才第一次看到它。
|
||||||
|
// 即使后面初始化失败(会话已结束等),窗口也已经可见 —— 错误提示才看得见。
|
||||||
|
await applyTheme()
|
||||||
|
await revealWindow()
|
||||||
|
|
||||||
if (!sessionId) {
|
if (!sessionId) {
|
||||||
fatal.value = '缺少会话 ID,无法确定要显示哪个会话'
|
fatal.value = '缺少会话 ID,无法确定要显示哪个会话'
|
||||||
ready.value = true
|
ready.value = true
|
||||||
@@ -84,7 +236,7 @@ onMounted(async () => {
|
|||||||
// 把标题设成会话名,方便任务栏与窗口列表识别
|
// 把标题设成会话名,方便任务栏与窗口列表识别
|
||||||
const info = store.sessionById(sessionId)
|
const info = store.sessionById(sessionId)
|
||||||
if (info?.title) {
|
if (info?.title) {
|
||||||
void win.setTitle(`${info.title} · 终端`)
|
void win.setTitle(`${info.title} · 终端`).catch(() => {})
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(`独立窗口初始化失败:${String(e)}`)
|
logger.error(`独立窗口初始化失败:${String(e)}`)
|
||||||
@@ -105,12 +257,104 @@ onMounted(async () => {
|
|||||||
* `invoke` 仍可发出(IPC 通道在窗口销毁前还有效),这是最后一个可靠时机。
|
* `invoke` 仍可发出(IPC 通道在窗口销毁前还有效),这是最后一个可靠时机。
|
||||||
*/
|
*/
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
|
darkMedia.removeEventListener('change', onThemeChange)
|
||||||
|
window.removeEventListener('storage', onStorageChange)
|
||||||
if (!sessionId) return
|
if (!sessionId) return
|
||||||
void store.attachSession(sessionId).catch(e => {
|
void store.attachSession(sessionId).catch(e => {
|
||||||
logger.warn(`重置 detached 标记失败:${String(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({
|
useTerminalKeys({
|
||||||
@@ -118,8 +362,8 @@ useTerminalKeys({
|
|||||||
onAction: actionId => {
|
onAction: actionId => {
|
||||||
switch (actionId) {
|
switch (actionId) {
|
||||||
case 'copy': {
|
case 'copy': {
|
||||||
const text = paneRef.value?.getSelection?.()
|
// 与右键菜单走同一个函数:都遵守「复制时去掉末尾换行」设置
|
||||||
if (text) void navigator.clipboard.writeText(text.replace(/\n+$/, ''))
|
void doCopy()
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case 'paste':
|
case 'paste':
|
||||||
@@ -140,6 +384,17 @@ useTerminalKeys({
|
|||||||
case 'fontReset':
|
case 'fontReset':
|
||||||
resetFont()
|
resetFont()
|
||||||
break
|
break
|
||||||
|
case 'search':
|
||||||
|
toggleSearch()
|
||||||
|
break
|
||||||
|
// 命令历史 / 片段:控制栏上的两个浮层,独立窗口里同样适用
|
||||||
|
// (它们只需要当前会话,不依赖终端页的分屏与面板容器)
|
||||||
|
case 'history':
|
||||||
|
toggleHistoryPopover()
|
||||||
|
break
|
||||||
|
case 'snippets':
|
||||||
|
toggleSnippetsPopover()
|
||||||
|
break
|
||||||
// 标签类/分屏类动作在独立窗口里没有意义,静默忽略
|
// 标签类/分屏类动作在独立窗口里没有意义,静默忽略
|
||||||
default:
|
default:
|
||||||
break
|
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
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 无边框窗口需要自己实现拖拽与最小化/最大化/关闭。
|
* 面板右键的统一入口,与终端页同一套三种行为(`selection.rightClick`):
|
||||||
*
|
* - `menu`:弹菜单
|
||||||
* 但本窗口**不实现拖拽区域**:终端面板占满整个窗口,若在顶部加拖拽条,
|
* - `paste`:直接粘贴
|
||||||
* 用户会失去一块垂直空间;而拖拽整个窗口的需求可以通过系统的方式完成
|
* - `select-word`:先选中右键处的词,再弹菜单(只选中不弹菜单会让用户无法继续操作)
|
||||||
* (Alt+Space、或拖窗口边缘)。这里只保留最小化/最大化/关闭三个按钮。
|
|
||||||
*/
|
*/
|
||||||
|
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() {
|
async function minimize() {
|
||||||
await win.minimize()
|
await win.minimize()
|
||||||
}
|
}
|
||||||
@@ -168,42 +497,90 @@ async function closeWindow() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col h-screen w-screen overflow-hidden bg-background">
|
<!--
|
||||||
<!-- 标题栏 -->
|
独立窗口是**另一个 WebView 入口**(main.ts 按 hash 路由),不经过 App.vue,
|
||||||
<div
|
因此这里必须自带 TooltipProvider —— 否则控制栏里的 Tooltip 取不到上下文。
|
||||||
class="shrink-0 h-8 flex items-center gap-2 px-2 border-b border-border select-none"
|
-->
|
||||||
data-tauri-drag-region
|
<TooltipProvider>
|
||||||
>
|
<div class="flex flex-col h-screen w-screen overflow-hidden bg-background">
|
||||||
<Menu class="size-3.5 text-muted-foreground shrink-0 pointer-events-none" />
|
<!-- 控制栏:会话身份(拖拽区)+ 控制栏动作 + 窗口按钮 -->
|
||||||
<span class="text-xs truncate flex-1 pointer-events-none">
|
<div class="shrink-0 flex items-center gap-1 h-9 px-1.5 border-b border-border">
|
||||||
{{ session?.title || '终端' }}
|
<!--
|
||||||
</span>
|
拖拽区:无边框窗口靠它移动。子元素 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
|
<TerminalToolbar
|
||||||
class="size-6 flex items-center justify-center rounded hover:bg-accent transition-colors"
|
v-if="ready && !fatal"
|
||||||
title="最小化"
|
standalone
|
||||||
@click="minimize"
|
:session-id="sessionId"
|
||||||
>
|
:session="session"
|
||||||
<Minus class="size-3" />
|
:searching="searching"
|
||||||
</button>
|
:search-hint="searchHint"
|
||||||
<button
|
:is-ssh="isSsh"
|
||||||
class="size-6 flex items-center justify-center rounded hover:bg-accent transition-colors"
|
:sftp-visible="false"
|
||||||
title="最大化"
|
:logging="session?.logging ?? false"
|
||||||
@click="toggleMaximize"
|
:font-size="effectiveAppearance?.fontSize ?? 14"
|
||||||
>
|
@toggle-search="toggleSearch"
|
||||||
<Square class="size-2.5" />
|
@search="doSearch($event)"
|
||||||
</button>
|
@search-next="doSearch($event)"
|
||||||
<button
|
@search-prev="doSearch($event, true)"
|
||||||
class="size-6 flex items-center justify-center rounded hover:bg-destructive
|
@font="bumpFont($event)"
|
||||||
hover:text-destructive-foreground transition-colors"
|
@font-reset="resetFont"
|
||||||
title="关闭窗口(会话保留)"
|
@clear="handleClear"
|
||||||
@click="closeWindow"
|
@rename="openRename"
|
||||||
>
|
@open-ai="aiOpen = true"
|
||||||
<X class="size-3.5" />
|
@toggle-logging="toggleLogging"
|
||||||
</button>
|
@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>
|
||||||
|
|
||||||
<!-- 主体 -->
|
<!-- 画布 -->
|
||||||
<div class="flex-1 min-h-0 relative">
|
<div class="flex-1 min-h-0 relative">
|
||||||
<div v-if="!ready" class="absolute inset-0 flex items-center justify-center">
|
<div v-if="!ready" class="absolute inset-0 flex items-center justify-center">
|
||||||
<div class="flex flex-col items-center gap-3">
|
<div class="flex flex-col items-center gap-3">
|
||||||
@@ -225,12 +602,29 @@ async function closeWindow() {
|
|||||||
:session-id="sessionId"
|
:session-id="sessionId"
|
||||||
:appearance="effectiveAppearance"
|
:appearance="effectiveAppearance"
|
||||||
:visible="true"
|
:visible="true"
|
||||||
|
@contextmenu="onPaneContextMenu"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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
|
<TerminalStatusBar
|
||||||
v-if="ready && !fatal"
|
v-if="ready && !fatal && store.settings?.layout?.showStatusBar !== false"
|
||||||
:session="session"
|
:session="session"
|
||||||
:font-size="effectiveAppearance?.fontSize ?? 14"
|
:font-size="effectiveAppearance?.fontSize ?? 14"
|
||||||
:cols="session?.cols"
|
:cols="session?.cols"
|
||||||
@@ -239,7 +633,36 @@ async function closeWindow() {
|
|||||||
|
|
||||||
<!-- 主机密钥确认:独立窗口也可能触发(用户直接从这个窗口发起连接时) -->
|
<!-- 主机密钥确认:独立窗口也可能触发(用户直接从这个窗口发起连接时) -->
|
||||||
<HostKeyPromptDialog />
|
<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>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
* # 交互与片段库同一套安全语义
|
* # 交互与片段库同一套安全语义
|
||||||
*
|
*
|
||||||
* 建议默认「填入命令行」(用户自己按回车),「直接执行」是显式第二动作。
|
* 建议默认「填入命令行」(用户自己按回车),「直接执行」是显式第二动作。
|
||||||
* 理由与 SnippetPanel 一致:命令被自动执行与等待用户确认,在心理上完全不同;
|
* 理由与命令片段(SnippetPopover)一致:命令被自动执行与等待用户确认,在心理上完全不同;
|
||||||
* 而且模型的建议未经本地验证,用户应当有机会先看一眼再回车。
|
* 而且模型的建议未经本地验证,用户应当有机会先看一眼再回车。
|
||||||
*
|
*
|
||||||
* # 上下文
|
* # 上下文
|
||||||
@@ -26,6 +26,7 @@ import { useTerminalStore } from '@/stores/terminalStore'
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -135,7 +136,7 @@ function engineLabel(e: AiEngineOption): string {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Dialog :open="open" @update:open="v => emit('update:open', v)">
|
<Dialog :open="open" @update:open="v => emit('update:open', v)">
|
||||||
<DialogContent class="max-w-lg">
|
<DialogContent class="max-w-lg max-h-[85vh]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle class="flex items-center gap-2 text-base">
|
<DialogTitle class="flex items-center gap-2 text-base">
|
||||||
<Sparkles class="size-4" />AI 命令助手
|
<Sparkles class="size-4" />AI 命令助手
|
||||||
@@ -187,8 +188,11 @@ function engineLabel(e: AiEngineOption): string {
|
|||||||
<Sparkles class="size-3.5" />{{ generating ? '生成中…' : '生成建议' }}
|
<Sparkles class="size-3.5" />{{ generating ? '生成中…' : '生成建议' }}
|
||||||
</Button>
|
</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
|
<div
|
||||||
v-for="(s, i) in suggestions"
|
v-for="(s, i) in suggestions"
|
||||||
:key="i"
|
:key="i"
|
||||||
@@ -206,6 +210,7 @@ function engineLabel(e: AiEngineOption): string {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { useTerminalStore } from '@/stores/terminalStore'
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -157,7 +158,7 @@ function directionText(f: ForwardView): string {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Dialog :open="open" @update:open="v => emit('update:open', v)">
|
<Dialog :open="open" @update:open="v => emit('update:open', v)">
|
||||||
<DialogContent class="max-w-xl">
|
<DialogContent class="max-w-xl max-h-[85vh]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle class="flex items-center gap-2 text-base">
|
<DialogTitle class="flex items-center gap-2 text-base">
|
||||||
<Network class="size-4" />端口转发
|
<Network class="size-4" />端口转发
|
||||||
@@ -167,7 +168,8 @@ function directionText(f: ForwardView): string {
|
|||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<!-- ===== 已有规则 ===== -->
|
<!-- ===== 已有规则(条数不限,限高滚动) ===== -->
|
||||||
|
<ScrollArea class="max-h-[40vh] pr-3">
|
||||||
<div class="space-y-1.5 min-h-[60px]">
|
<div class="space-y-1.5 min-h-[60px]">
|
||||||
<p v-if="loading" class="text-xs text-muted-foreground">加载中…</p>
|
<p v-if="loading" class="text-xs text-muted-foreground">加载中…</p>
|
||||||
<p v-else-if="forwards.length === 0" class="text-xs text-muted-foreground">
|
<p v-else-if="forwards.length === 0" class="text-xs text-muted-foreground">
|
||||||
@@ -181,7 +183,7 @@ function directionText(f: ForwardView): string {
|
|||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class="shrink-0 rounded px-1.5 py-0.5 text-[10px]"
|
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' ? '本地' : '远程' }}
|
{{ f.kind === 'local' ? '本地' : '远程' }}
|
||||||
</span>
|
</span>
|
||||||
@@ -205,6 +207,7 @@ function directionText(f: ForwardView): string {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
|
||||||
<!-- ===== 新增 ===== -->
|
<!-- ===== 新增 ===== -->
|
||||||
<div class="space-y-2 rounded-md border border-border p-3">
|
<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="size-9 rounded-full flex items-center justify-center shrink-0"
|
||||||
:class="
|
:class="
|
||||||
isChanged
|
isChanged
|
||||||
? 'bg-red-500/15 text-red-600 dark:text-red-400'
|
? 'bg-danger-soft text-danger-text'
|
||||||
: 'bg-primary/15 text-primary'
|
: 'bg-primary/15 text-primary'
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
@@ -115,7 +115,7 @@ const canAccept = computed(() => !isChanged.value || acknowledged.value)
|
|||||||
<!-- 高危警告:指纹变更几乎只有两种可能 —— 服务器重装,或有人在中间 -->
|
<!-- 高危警告:指纹变更几乎只有两种可能 —— 服务器重装,或有人在中间 -->
|
||||||
<div
|
<div
|
||||||
v-if="isChanged"
|
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 class="font-medium mb-1">这可能意味着有人在窃听你的连接。</p>
|
||||||
<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="flex-1 px-2.5 py-2 rounded font-mono text-[12px] break-all select-all leading-relaxed"
|
||||||
:class="
|
:class="
|
||||||
isChanged
|
isChanged
|
||||||
? 'bg-red-500/10 border border-red-500/30'
|
? 'bg-danger-soft border-danger/30'
|
||||||
: 'bg-muted border border-border'
|
: 'bg-muted border border-border'
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
@@ -163,7 +163,7 @@ const canAccept = computed(() => !isChanged.value || acknowledged.value)
|
|||||||
:title="copied ? '已复制' : '复制指纹'"
|
:title="copied ? '已复制' : '复制指纹'"
|
||||||
@click="copyFingerprint"
|
@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" />
|
<Copy v-else class="size-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,10 +12,25 @@
|
|||||||
* 2. **新建时先向要一个 id**。密码按 hostId 存取,若等 save_host 之后再生成 id,
|
* 2. **新建时先向要一个 id**。密码按 hostId 存取,若等 save_host 之后再生成 id,
|
||||||
* 用户在新建对话框里填的密码就没有归属。因此用 `terminal_new_host_id`
|
* 用户在新建对话框里填的密码就没有归属。因此用 `terminal_new_host_id`
|
||||||
* 预取 id,使「新建」与「编辑」走完全相同的流程。
|
* 预取 id,使「新建」与「编辑」走完全相同的流程。
|
||||||
|
*
|
||||||
|
* # 已知主机(known_hosts)在这里而不是独立页(2026-09-22)
|
||||||
|
*
|
||||||
|
* 「这台主机我信任过吗、指纹变过没有」是**浏览主机列表时顺手核对**的信息,
|
||||||
|
* 此前它占着「凭据」页的一个分区,于是核对信任要跨 tab,而每个 tab 里都只有
|
||||||
|
* 半张信息。现在三层都落在本页:
|
||||||
|
*
|
||||||
|
* - 主机行的**信任徽标**(未连接过 / 已信任 / 变更 N 次)——一眼可见;
|
||||||
|
* - 徽标点开的 **HostTrustPopover**——各算法指纹、变更历史、「忘记」;
|
||||||
|
* - 列表末尾的 **OrphanKnownHosts**——没有对应主机配置的记录(配置已删或
|
||||||
|
* 从未保存过配置),它们照样会拦截连接,不能失去入口。
|
||||||
|
*
|
||||||
|
* 整份 known_hosts 的维护动作(导入 / 导出 / 清空)是低频操作,收进工具条的
|
||||||
|
* 「已知主机」下拉(`KnownHostsMenu`),不占页面主线。
|
||||||
*/
|
*/
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { open as openDialog, save as saveDialog } from '@tauri-apps/plugin-dialog'
|
import { open as openDialog, save as saveDialog } from '@tauri-apps/plugin-dialog'
|
||||||
import {
|
import {
|
||||||
|
AlertTriangle,
|
||||||
ArrowDown,
|
ArrowDown,
|
||||||
ArrowUp,
|
ArrowUp,
|
||||||
Download,
|
Download,
|
||||||
@@ -51,14 +66,24 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue
|
SelectValue
|
||||||
} from '@/components/ui/select'
|
} 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 { createLogger } from '@/lib/logger'
|
||||||
import { blankHost, type SshHost } from './hostForm'
|
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 logger = createLogger('terminal')
|
||||||
const store = useTerminalStore()
|
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))
|
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)=====
|
// ===== 跳板机链(ProxyJump)=====
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -280,129 +344,175 @@ function moveHop(i: number, dir: -1 | 1) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col h-full">
|
<div class="flex flex-col h-full overflow-hidden">
|
||||||
<!-- 头部 -->
|
<PageToolbar :icon="Globe" title="SSH 主机" :meta="`共 ${store.hosts.length} 台`">
|
||||||
<div class="shrink-0 flex items-center gap-2 px-4 h-12 border-b border-border">
|
<KnownHostsMenu />
|
||||||
<Globe class="size-4 text-muted-foreground" />
|
<Button variant="outline" size="sm" @click="importHosts">
|
||||||
<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">
|
|
||||||
<Upload class="size-3.5" />导入备份
|
<Upload class="size-3.5" />导入备份
|
||||||
</Button>
|
</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" />导出
|
<Download class="size-3.5" />导出
|
||||||
</Button>
|
</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
|
<Download class="size-3.5" />导入 ~/.ssh/config
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" class="h-7 gap-1.5 text-xs" @click="openNew">
|
<Button size="sm" @click="openNew">
|
||||||
<Plus class="size-3.5" />新建主机
|
<Plus class="size-3.5" />新建主机
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</PageToolbar>
|
||||||
|
|
||||||
<!-- 列表 -->
|
<!-- 风险提示:有主机指纹变更时始终可见(信任详情 popover 是同一条信息的展开) -->
|
||||||
<div class="flex-1 min-h-0 overflow-y-auto p-3">
|
<div
|
||||||
<div v-if="store.hosts.length === 0" class="py-16 text-center">
|
v-if="changedHostCount > 0"
|
||||||
<Globe class="size-8 mx-auto text-muted-foreground/30 mb-3" />
|
class="shrink-0 mb-3 flex items-start gap-2 p-2.5 rounded-md
|
||||||
<p class="text-sm text-muted-foreground mb-1">还没有配置 SSH 主机</p>
|
border border-warning/40 bg-warning-soft"
|
||||||
<p class="text-xs text-muted-foreground/70 mb-4">
|
>
|
||||||
可以手工新建,也可以直接从 <code class="px-1 rounded bg-muted">~/.ssh/config</code> 导入
|
<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>
|
</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>
|
||||||
</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">
|
<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>
|
<DialogHeader>
|
||||||
<DialogTitle>{{ isNew ? '新建 SSH 主机' : '编辑 SSH 主机' }}</DialogTitle>
|
<DialogTitle>{{ isNew ? '新建 SSH 主机' : '编辑 SSH 主机' }}</DialogTitle>
|
||||||
<DialogDescription class="text-xs">
|
<DialogDescription class="text-xs">
|
||||||
@@ -410,7 +520,14 @@ function moveHop(i: number, dir: -1 | 1) {
|
|||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</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="grid grid-cols-2 gap-3">
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
@@ -476,7 +593,7 @@ function moveHop(i: number, dir: -1 | 1) {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<p v-if="errors.keyId" class="text-[11px] text-destructive">{{ errors.keyId }}</p>
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -637,9 +754,21 @@ function moveHop(i: number, dir: -1 | 1) {
|
|||||||
</div>
|
</div>
|
||||||
<Switch v-model="editing.favorited" />
|
<Switch v-model="editing.favorited" />
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" size="sm" @click="dialogOpen = false">取消</Button>
|
<Button variant="outline" size="sm" @click="dialogOpen = false">取消</Button>
|
||||||
@@ -667,5 +796,38 @@ function moveHop(i: number, dir: -1 | 1) {
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</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>
|
</div>
|
||||||
</template>
|
</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
Reference in New Issue
Block a user