Compare commits

..
5 Commits
Author SHA1 Message Date
Bill 5602949a4f website: dark-first redesign + SFTP 面板截图
CI / typecheck + test + build (windows) (push) Waiting to run
Deploy website to GitHub Pages / deploy (push) Waiting to run
- 全站深色(#0b0e12 + 终端绿),右上角可切浅色;两套主题共用 CSS 令牌
- 自托管字体:Space Grotesk(展示)+ JetBrains Mono(等宽),不再依赖系统栈
- 「核心能力」改为横向滚动卡片轨道(箭头翻页 / 拖拽 / 键盘方向键)
- 新增 SFTP 文件面板截图(含空白处右键菜单),IP 已打码
- 补 og:image / theme-color / skip-link / 焦点环 / 导航当前位置高亮
2026-10-09 00:46:35 +08:00
Bill 9e5e959f80 docs(readme): add screenshots & advantages section; fix stale facts (11 themes, 19 tests, MSI dropped)
CI / typecheck + test + build (windows) (push) Canceled after 0s
2026-10-08 14:19:22 +08:00
Bill 309de439e8 website: v1.0.22 — drop MSI, refresh screenshots (agents/themes/lock)
CI / typecheck + test + build (windows) (push) Canceled after 0s
Deploy website to GitHub Pages / deploy (push) Canceled after 0s
- download: NSIS exe only, v1.0.22 links, update-source order GitHub-first
- features: 22 highlight rules, 11 builtin themes, bg dim layer
- new rows: AI Agent workspace, lock screen & security
- changelog: 1.0.22 / 1.0.21 / 1.0.20
2026-10-08 14:10:15 +08:00
Bill a381d7be03 docs: warn about auto-created wrong-target tags in release flow
CI / typecheck + test + build (windows) (push) Canceled after 0s
2026-10-08 13:31:54 +08:00
Bill 14f5963706 build: drop MSI installer, NSIS exe only
CI / typecheck + test + build (windows) (push) Canceled after 0s
electron-updater never supported MSI auto-updates; the exe is the only
installer from v1.0.22 on. release.cjs no longer expects/uploads the msi.
2026-10-08 13:25:01 +08:00
27 changed files with 937 additions and 226 deletions

No files matched your search

+4 -4
View File
@@ -10,7 +10,7 @@ Electron + electron-vite + React 终端工具(本地终端 / SSH / SFTP)。
- 测试:`npm test`(**npm 生命周期先自动跑 `pretest` 做类型检查**,再 `node tests/build-bundles.cjs` 重建 esbuild bundle,然后依次跑可离线运行的 18 个测试:ssh-loopback、commands-store、connections-store、settings-store、local-path-grants、lock-store、lock-controller、lock-shortcuts、hl-split-smoke、hl-rules、reserved-accelerators、ipc-guard、updater-fallback、log-sanitizer、sftp-timeout、terminal-title、zmodem-e2e、ssh-session-e2e、sysinfo-e2e;真实服务器测试需 JD_* 凭据,不在此列)
- 依赖分类规则:**只有 `src/main/`/`src/preload/` 实际 import 的包才能进 `dependencies`**(node-pty/ssh2/zmodem.js/font-list/electron-updater);纯渲染层依赖一律 devDependencies(Vite 全量打包进 out/renderer,`externalizeDepsPlugin` 不作用渲染层)——这条让 asar 从 98MB 瘦到 8.1MB,加新依赖时别放错边
- 下载量统计:`node scripts/download-stats.cjs`(Gitea + GitHub release 资产的 download_count 汇总;GitHub 优先直连、失败自动回退 `HTTPS_PROXY`/本地 7897;更新通道无计数接口不计入)
- 打包:`npm run dist`(**生命周期先自动跑 `predist` → `npm test`,即类型检查 + 18 个离线测试全部通过后才 build/package**,typecheck 全程只跑一次;predist 末尾的 `npm install --package-lock-only` 会把 `package-lock.json` 根版本号对齐 `package.json`,**发布提交必须带上 package-lock.json**),产物在 `release/`(msi + exe + latest.yml + blockmap)
- 打包:`npm run dist`(**生命周期先自动跑 `predist` → `npm test`,即类型检查 + 18 个离线测试全部通过后才 build/package**,typecheck 全程只跑一次;predist 末尾的 `npm install --package-lock-only` 会把 `package-lock.json` 根版本号对齐 `package.json`,**发布提交必须带上 package-lock.json**),产物在 `release/`(exe + latest.yml + blockmap)
- GitHub Actions:`.github/workflows/ci.yml` 在 windows-latest + Node 22 上跑 `npm ci` / `npm test`(含 pretest typecheck)/ `npm run build`,只做验证,不打包安装器、不发布
- 国内网络需镜像:`ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/ npm run dist`
- 依赖一致性闸门:`predist` 开头跑 `node scripts/verify-deps.cjs`(逐条比对 `node_modules` 与 `package-lock.json` 的版本,跨平台 optional 依赖缺失跳过;不一致就 exit 1)——`npm install --package-lock-only` **只重算 lockfile、不碰 node_modules**(本版 npm 还会把 `node_modules/.package-lock.json` 一并重写成理想树,制造「已经装好了」的假象),所以**改动依赖版本后必须真实 `npm install` 或 `npm ci` 再构建**,别指望 lockfile 对齐就等于树里换了包;v1.0.21 的中文输入法回归正是这个坑(`@xterm/xterm` 锁 6.1.0-beta.304,树里还是 6.0.0,打进去的是旧代码)
@@ -34,20 +34,20 @@ Electron + electron-vite + React 终端工具(本地终端 / SSH / SFTP)。
2. `node scripts/sync-changelog.cjs` 把发布说明并入 `CHANGELOG*.md` —— **必须在 `npm run dist` 之前**:更新日志以 `?raw` 打进安装包(「关于」页离线读),`release.cjs` 也校验 `CHANGELOG.md` 已含该版本号
3. `npm run dist` 构建
4. `node scripts/release.cjs <版本号>`,例如 `node scripts/release.cjs 1.0.14`(**不带 skip 参数**,Gitea 与 GitHub 一起发)
- Gitea:release(msi + exe 资产)→ Gitea 更新通道(`api/packages/admin/generic/openterminal-update/stable`:exe.blockmap → exe → release-notes.md → **latest.yml 最后**)
- Gitea:release(exe 资产)→ Gitea 更新通道(`api/packages/admin/generic/openterminal-update/stable`:exe.blockmap → exe → release-notes.md → **latest.yml 最后**)
- GitHub:release 资产再次随版本同步发布(exe + exe.blockmap + latest.yml),electron-updater 标准 GitHub provider 直接吃 release 资产
- 通道不再先删旧版:新版本文件全部传完、latest.yml 生效后才清掉上一版 exe/blockmap,中途失败不会把通道打空;同一版本可重复运行(release 复用、已传资产跳过)
- 上传前先比通道版本:`assertNoDowngrade()` 读通道 latest.yml,若线上版本**高于**待发布版本就直接拒绝——三条本地护栏只比本地产物,旧分支发旧版本号会一路通过,覆盖 latest.yml 之后 `pruneChannel` 会把线上新版本的 exe/blockmap 删掉
- 只补通道:`node scripts/release.cjs <版本号> --channel-only`(不建 release、不发 GitHub)
- 国内通道全程直连,**不需要设代理**;GitHub 请求走 `HTTPS_PROXY=http://127.0.0.1:7897`(脚本只把它用于 GitHub 请求)
5. 验证更新通道:`curl https://git.codingplan.site/api/packages/admin/generic/openterminal-update/stable/latest.yml` 应返回新版本号
6. `git tag v<版本号>` 并推送两个远程(代码/tag 与 release 资产的镜像保持同步)
6. `git tag v<版本号>` 并推送两个远程(代码/tag 与 release 资产的镜像保持同步)。注意顺序坑:release.cjs 创建 release 时若远端尚无该 tag,Gitea/GitHub 会在**默认分支 HEAD** 自动建一个指向错误 commit 的 tag,第 6 步推送会被拒——要么先建 tag 推上去再跑 release.cjs,要么事后 `git push -f <远端> v<版本号>` 强制修正到 release commit(v1.0.22 即踩过)
## 更新机制
- 检查更新:**GitHub 优先**(走系统代理;前置 20 秒连通性探测 `probeGithub`——探测失败/超时直接兜底 Gitea,不给 electron-updater 挂起的机会),失败回退国内 Gitea 通用包通道(强制直连,不走系统代理)。 electron-updater 用独立 session(partition `electron-updater`),代理模式在 `useFeed` 里按源切换
- 更新日志:Gitea 仓库是私有的(匿名 API 404),改为从更新通道的 `release-notes.md` 读取(直连 session `openterminal-update-direct`),再回退 Gitea/GitHub releases API
- electron-updater 不支持 MSI 自动更新,自动更新只走 NSIS exe
- 自动更新只走 NSIS exe;自 v1.0.22 起不再构建 MSI 安装包(electron-updater 本就不支持 MSI 自动更新)
- 每次 checkForUpdates 都被 **30s 整体超时**包住(`withTimeout`):electron-updater 自带的 60s 只是 socket 空闲超时,慢滴流响应能一直占住它。超时按普通失败走 GitHub→Gitea 回退,但**被放弃的检查取消不掉**,而 electron-updater 对并发检查去重(返回同一个 in-flight promise),所以兜底那次共用被放弃的 promise——它同样被超时兜住,最终落到错误态,不会永远停在「检查中」
- 更新日志 / releases API 的 fetch 都带 `AbortSignal.timeout(15s)`(`directFetch` 与 `fetchChangelog` 里的 `net.fetch`):卡住的通道必须让位给下一个来源,不能把「关于」页吊住
+3
View File
@@ -5,6 +5,9 @@
### IME
- **Fixed: Chinese IME candidate window not following the caret** (regression in v1.0.21): the v1.0.21 build environment had a terminal component dependency that did not match the locked version, so the installer shipped an older build without the upstream IME fix; this release restores the terminal component version used by v1.0.20, and the candidate window follows the caret again.
### Installer
- The MSI installer is discontinued (electron-updater never supported MSI auto-updates); the NSIS exe is now the only installer format.
### Internal
- The packaging pipeline now verifies dependency consistency before building: installed dependencies are compared against the lockfile and the build aborts on any mismatch, preventing this class of issue from recurring.
+3
View File
@@ -5,6 +5,9 @@
### IME
- **中国語 IME の候補ウィンドウがキャレットに追従しない問題を修正**(v1.0.21 でのリグレッション):v1.0.21 のビルド環境では端末コンポーネントの依存がロックされたバージョンと一致しておらず、インストーラーには上流の IME 修正を含まない古いビルドが同梱されていました。本バージョンでは v1.0.20 と同じ端末コンポーネントに戻し、候補ウィンドウが再びキャレットに追従します。
### インストーラー
- MSI インストーラーの提供を終了しました(electron-updater は MSI の自動更新に非対応のため)。今後は NSIS exe インストーラーに統一されます。
### 内部
- パッケージング前に依存関係の整合性チェックを追加:インストール済み依存とロックファイルを照合し、不一致があればビルドを中止します。同種の問題の再発を防ぎます。
+3
View File
@@ -5,6 +5,9 @@
### 输入法
- **修复中文输入法候选窗不跟随光标**(v1.0.21 回归):v1.0.21 的构建环境中终端组件依赖未按锁定版本安装,安装包实际打进的是不含上游输入法修复的旧版本;本版本恢复 v1.0.20 所用的终端组件版本,候选窗重新紧贴光标。
### 安装包
- 不再提供 MSI 安装包(electron-updater 本就不支持 MSI 自动更新),统一使用 NSIS exe 安装包。
### 内部
- 打包流程新增依赖一致性校验:构建前自动比对实际安装的依赖与锁定清单,不一致即中止,防止同类问题再次发生。
+3
View File
@@ -5,6 +5,9 @@
### 輸入法
- **修復中文輸入法候選視窗不跟隨游標**(v1.0.21 回歸):v1.0.21 的建置環境中終端機元件相依未按鎖定版本安裝,安裝包實際打包的是不含上游輸入法修復的舊版本;本版本恢復 v1.0.20 所用的終端機元件版本,候選視窗重新緊貼游標。
### 安裝包
- 不再提供 MSI 安裝包(electron-updater 本就不支援 MSI 自動更新),統一使用 NSIS exe 安裝包。
### 內部
- 打包流程新增相依一致性校驗:建置前自動比對實際安裝的相依與鎖定清單,不一致即中止,防止同類問題再次發生。
+30 -4
View File
@@ -4,6 +4,25 @@
> 产品官网(GitHub Pages):<https://billowliu2.github.io/OpenTerminal/>,源码在 [`website/`](website/)。
## 截图
| 多路分屏并行 AI 编程 Agent | 无背景图的干净分屏 |
| --- | --- |
| ![主界面:三路分屏并行运行 AI 编程 Agent,自定义背景图](website/assets/shot-terminal.jpg) | ![多路分屏运行 Kimi Code / Claude Code / Codex](website/assets/shot-agents.png) |
| 主题与个性化(背景图 / 不透明度 / 压暗层) | 锁屏(Ctrl+L,遮罩下会话不断线) |
| --- | --- |
| ![主题设置:背景图片、不透明度、压暗层与主题画廊](website/assets/shot-themes.jpg) | ![锁屏界面:不透明遮罩与密码解锁](website/assets/shot-lock.png) |
## 优势
- **轻量**:渲染层依赖全量打包进 bundle、不重复进安装包,应用本体 asar 仅 8.1MB,NSIS 安装包约 109MB;单一 exe 安装包,应用内自动更新
- **隐私与安全**:SSH 凭据经 DPAPI 加密落盘、渲染层接触不到明文;锁屏密码只存 scrypt 派生值;SFTP/ZMODEM 的本地路径必须经系统对话框授权,阻断构造路径读写
- **性能**:xterm.js WebGL 渲染;关键词高亮引擎实测 0.02–0.05ms/KB(200KB 混合输出 4–9ms);SFTP 大目录虚拟滚动;分屏尺寸去抖同步,TUI 不反复重绘
- **AI 编程友好**:为 Kimi Code / Claude Code / Codex 等高频刷新 TUI 优化;中文输入法候选窗紧贴光标
- **国内可用**:更新检查 GitHub 优先(跟随系统代理),失败自动回退国内直连通道,两边都拿不到时 30s 整体超时兜底
- **开源可审计**:MIT 协议;主/预载/渲染三端契约集中在 `src/shared/`,模块间零横向依赖;19 个离线测试覆盖存储、锁屏、高亮、传输与更新回退
## 快速开始
```bash
@@ -11,7 +30,7 @@ npm install # 已安装可跳过
npm run dev # 开发模式(热更新)
npm run build # 生产构建到 out/
npm run typecheck # 全量类型检查
npm test # 先自动跑 typecheck,再重建测试 bundle 并跑 12 个离线测试(详见「测试」)
npm test # 先自动跑 typecheck,再重建测试 bundle 并跑 19 个离线测试(详见「测试」)
```
> Electron 二进制下载失败时:`ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ node node_modules/electron/install.js`
@@ -60,12 +79,12 @@ npm test # 先自动跑 typecheck,再重建测试 bundle 并跑 12
- 锁定期间吞掉 F5 / Ctrl+R / Ctrl+±0 / Ctrl+Shift+I/J/C,并整块摘除应用菜单,防止菜单键绕过遮罩
**外观与窗口**
- 12 款内置主题(Dracula / One Half / Solarized / Gruvbox / Nord / Monokai / GitHub 等)
- 11 款内置主题(Dracula / One Half / Solarized / Gruvbox / Nord / Monokai / GitHub 等)
- 自定义主题编辑器:16 色 ANSI 调色板 + 前景/背景/光标/选区色,实时预览
- 窗口标题栏与背景跟随终端主题
- 系统托盘:关闭按钮可设「每次询问 / 最小化到托盘 / 直接退出」,托盘菜单可切换
- 单实例运行:重复启动唤出已有窗口
- 自动更新:GitHub 优先(走系统代理),失败回退国内 Gitea 更新通道(强制直连);单次检查 30s 整体超时,MSI 仅分发、自动更新走 NSIS 安装包
- 自动更新:GitHub 优先(走系统代理),失败回退国内 Gitea 更新通道(强制直连);单次检查 30s 整体超时;统一使用 NSIS exe 安装包(自 v1.0.22 起不再提供 MSI)
- 设置持久化(userData/settings.json,原子写入)+ 多窗口实时同步
**界面与语言**
@@ -74,15 +93,22 @@ npm test # 先自动跑 typecheck,再重建测试 bundle 并跑 12
**测试**
`npm test` 会先跑 `pretest`(= `npm run typecheck`),再重建 esbuild bundle,最后依次跑下列 12 个测试(全部离线,无需服务器与凭据):
`npm test` 会先跑 `pretest`(= `npm run typecheck`),再重建 esbuild bundle,最后依次跑下列 19 个测试(全部离线,无需服务器与凭据):
- `node tests/ssh-loopback.mjs` — ssh2 客户端/服务端回环(认证、shell、数据、resize、指纹)
- `node tests/commands-store.mjs` — 命令库 / 历史 / 会话日志存储
- `node tests/connections-store.mjs` — SSH 书签 CRUD、公开/密文字段切分、损坏文件备份
- `node tests/settings-store.mjs` — 设置清洗器(closeAction/高亮规则修复/旧预设升级/告警日志)
- `node tests/local-path-grants.mjs` — 本地路径准入(对话框授权、realpath+stat 双重校验、大小写折叠)
- `node tests/lock-store.mjs` — 锁屏密码校验器(scrypt 往返、文件损坏处理)
- `node tests/lock-controller.mjs` — 锁屏控制器(冷却阶梯、并发串行化、落盘恢复、闲置触发、清除联动)
- `node tests/lock-shortcuts.mjs` — 锁屏快捷键分类器(表驱动:Ctrl+L 恐慌锁 / 锁定时禁用的组合键)
- `node tests/reserved-accelerators.mjs` — 保留快捷键表(设置录制器与全局注册共用同一张表)
- `node tests/.terminal-title.cjs` — 终端标签自动标题(四语言反解与重渲染)
- `node tests/ipc-guard.mjs` — IPC 发送方守卫(仅信任本应用渲染帧)
- `node tests/updater-fallback.mjs` — 更新通道回退(GitHub 优先 / Gitea 兜底、超时、防降级)
- `node tests/log-sanitizer.mjs` — 会话日志脱敏(原始 PTY 字节流转可读文本)
- `node tests/sftp-timeout.mjs` — SFTP 操作超时(半死通道驱逐与重试)
- `node tests/.hl-split-smoke.cjs` — 关键词高亮流分块回归(bundle 由 `node tests/build-bundles.cjs` 生成)
- `node tests/.hl-rules.cjs` — 内置高亮预设(词边界、大小写、负向词、危险命令)
- `node tests/zmodem-e2e.mjs` — ZMODEM 双向传输(与第二个 zmodem.js Sentry 对接,内容一致性)
+2 -13
View File
@@ -16,23 +16,12 @@ asarUnpack:
# @lydell/node-pty ships prebuilt binaries; do not trigger a rebuild.
npmRebuild: false
# Electron-updater only supports NSIS auto-updates (MSI has no update channel).
# The NSIS .exe + latest.yml get uploaded together; MSI is manual/enterprise only.
# NSIS exe is the only installer: electron-updater only supports NSIS
# auto-updates, so the MSI (no update channel) was dropped in v1.0.22.
win:
target:
- target: msi
- target: nsis
msi:
oneClick: false
# Fixed upgrade code keeps the upgrade chain consistent across releases.
upgradeCode: "{5ab9f79e-e4eb-4052-9df6-3af3a301ab0a}"
artifactName: ${productName}-${version}-setup.${ext}
# Shortcuts need an <Icon>; build/icon.png (auto-converted to .ico) provides it.
# Without an icon the WiX shortcut Icon reference fails to link (LGHT0094).
createDesktopShortcut: true
createStartMenuShortcut: true
nsis:
oneClick: false
allowToChangeInstallationDirectory: true
+1 -1
View File
@@ -15,7 +15,7 @@
"pretest": "npm run typecheck",
"test": "node tests/build-bundles.cjs && node tests/ssh-loopback.mjs && node tests/commands-store.mjs && node tests/connections-store.mjs && node tests/settings-store.mjs && node tests/local-path-grants.mjs && node tests/lock-store.mjs && node tests/lock-controller.mjs && node tests/lock-shortcuts.mjs && node tests/reserved-accelerators.mjs && node tests/.terminal-title.cjs && node tests/ipc-guard.mjs && node tests/updater-fallback.mjs && node tests/log-sanitizer.mjs && node tests/sftp-timeout.mjs && node tests/.hl-split-smoke.cjs && node tests/.hl-rules.cjs && node tests/zmodem-e2e.mjs && node tests/ssh-session-e2e.mjs && node tests/sysinfo-e2e.mjs",
"predist": "node scripts/verify-deps.cjs && npm test && npm install --package-lock-only",
"dist": "electron-vite build && electron-builder --win msi nsis",
"dist": "electron-vite build && electron-builder --win nsis",
"dist:dir": "electron-vite build && electron-builder --win --dir"
},
"dependencies": {
+5 -7
View File
@@ -42,14 +42,12 @@ const env = Object.fromEntries(
)
const notes = fs.readFileSync(path.join(ROOT, 'RELEASE_NOTES.md'), 'utf8')
const R = path.join(ROOT, 'release')
const msi = path.join(R, `OpenTerminal-${V}-setup.msi`)
const exe = path.join(R, `OpenTerminal-${V}-setup.exe`)
const files = [msi, exe]
// --channel-only republishes the update channel, which carries the NSIS exe and
// its blockmap (electron-updater cannot consume an MSI) — the msi is a release
// asset only, so demanding it here would block the repair path this mode exists
// for. The channel's own file list is validated in giteaChannel().
for (const f of CHANNEL_ONLY ? [exe] : files) {
const files = [exe]
// Only the NSIS exe is built (the MSI was dropped in v1.0.22 — electron-updater
// never supported it). The channel's own file list is validated in
// giteaChannel().
for (const f of files) {
if (!fs.existsSync(f)) { console.error('missing build artifact:', f); process.exit(1) }
}
// Guard rails: the artifacts must belong to the version being published —
+17 -10
View File
@@ -14,20 +14,24 @@ website/
├── index.html # 单页站点:功能介绍 / 下载 / 更新日志
├── .nojekyll # 让 GitHub Pages 原样托管(跳过 Jekyll 处理)
└── assets/
├── style.css # 全部样式(企业风浅色主题,单一强调色)
├── shot-terminal.png # 主界面截图(四路分屏)
├── shot-themes.png # 主题设置页截图
├── style.css # 全部样式(深色默认 / 浅色可切换,主题令牌在 :root 与 html[data-theme='light'])
├── fonts/ # 自托管 woff2:Space Grotesk 400/500/600/700、JetBrains Mono 400/500/600
├── shot-terminal.jpg # 主界面截图(三路分屏并行 AI 编程 Agent,含背景图个性化)
├── shot-agents.png # 多路 AI Agent 分屏截图(无背景图版)
├── shot-sftp.png # SFTP 文件面板:列头、排序、用户/组、字体缩放
├── shot-sftp-menu.png # 文件列表空白处右键菜单
├── shot-themes.jpg # 主题设置页截图(背景图/不透明度/压暗层)
├── shot-lock.png # 锁屏界面截图
└── icon.png # 应用图标(来自仓库 build/icon.png)
```
## 发新版本时需要更新的地方
`index.html` 中搜索 `1.0.15`,共 3 处下载链接(hero 主按钮、下载表格 exe、msi)。
下载链接使用 GitHub Releases 原生地址:
`index.html` 中搜索当前版本号,共 2 处下载链接(hero 主按钮、下载表格 exe)。
自 v1.0.22 起不再提供 MSI,只有 NSIS exe 一条直链:
```
https://github.com/billowliu2/OpenTerminal/releases/download/v<版本>/OpenTerminal-<版本>-setup.exe
https://github.com/billowliu2/OpenTerminal/releases/download/v<版本>/OpenTerminal-<版本>-setup.msi
```
注意:发布新版本时需同时把安装包资产上传到 GitHub release
@@ -61,7 +65,10 @@ npx serve website
## 设计约定
- 浅色企业风,避免营销站常见的霓虹/渐变装饰
- 强调色仅一个:`#1a7f37`(应用品牌绿 #3fb950 的深色化,保证浅底对比度 ≥ 4.5:1)
- 截图来自真实运行界面,与应用内设置一致
- 中文为主要语言;字体使用系统栈(Segoe UI / 微软雅黑),不加载外部字体
- **深色为主**:默认深色(`#0b0e12` 底 + 终端绿强调色),右上角按钮可切浅色;两套主题共用同一份令牌,新增样式一律走 CSS 变量,不要写死颜色
- 主题选择存 `localStorage['openterminal.theme']`,首帧前由 `index.html` 头部的内联脚本定好,避免闪白/闪黑
- 全站只用**一个强调色**:深底 `#3fb950`、浅底 `#1a7f37`(对比度 ≥ 4.5:1)
- 字体自托管(`assets/fonts/`),不依赖 Google Fonts:展示字体 Space Grotesk、等宽 JetBrains Mono;中文回落到系统栈
- 「核心能力」是横向滚动卡片轨道(箭头翻页 + 拖拽 + 键盘方向键),不再用三栏等高卡片
- 截图来自真实运行界面,与应用内设置一致;涉及 IP / 主机名等敏感信息的截图必须打码后再放入 `assets/`
- 中文为主要语言;文案保持具体、平实,避免营销套话
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 662 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 629 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 315 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

+536 -128
View File
@@ -1,17 +1,140 @@
/* OpenTerminal 官网 - 企业风浅色主题
单一强调色: 品牌绿 #1a7f37 (深化的 #3fb950, 保证浅底对比度)
无渐变 / 无发光 / 无装饰性动画, 仅保留 hover 与 focus 反馈 */
/* OpenTerminal 官网 —— 双主题 (深色默认 / 浅色)
排版: Space Grotesk (展示) + JetBrains Mono (等宽), 自托管 woff2
单一强调色: 终端绿 (深底 #3fb950, 浅底 #1a7f37) */
@font-face {
font-family: 'Space Grotesk';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('assets/fonts/space-grotesk-400.woff2') format('woff2');
}
@font-face {
font-family: 'Space Grotesk';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url('assets/fonts/space-grotesk-500.woff2') format('woff2');
}
@font-face {
font-family: 'Space Grotesk';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url('assets/fonts/space-grotesk-600.woff2') format('woff2');
}
@font-face {
font-family: 'Space Grotesk';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('assets/fonts/space-grotesk-700.woff2') format('woff2');
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('assets/fonts/jetbrains-mono-400.woff2') format('woff2');
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url('assets/fonts/jetbrains-mono-500.woff2') format('woff2');
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url('assets/fonts/jetbrains-mono-600.woff2') format('woff2');
}
/* ---- 主题令牌: 深色为默认 ---- */
:root {
--bg: #ffffff;
--bg-soft: #f7f8f8;
--text: #1c2128;
--text-2: #57606a;
--border: #d9dee3;
color-scheme: dark;
--bg: #0b0e12;
--bg-alt: #0d1219;
--surface: #11161e;
--surface-2: #161c26;
--text: #e6ebe6;
--text-2: #99a49c;
--text-3: #6f7a72;
--border: rgba(255, 255, 255, 0.09);
--border-strong: rgba(255, 255, 255, 0.16);
--accent: #3fb950;
--accent-hover: #34a24a;
--accent-text: #4fd067;
--accent-soft: rgba(63, 185, 80, 0.12);
--btn-fg: #06210d;
--overlay-1: rgba(255, 255, 255, 0.03);
--overlay-2: rgba(255, 255, 255, 0.06);
--overlay-3: rgba(255, 255, 255, 0.08);
--overlay-row: rgba(255, 255, 255, 0.02);
--grid-line: rgba(255, 255, 255, 0.03);
--grid-line-soft: rgba(255, 255, 255, 0.022);
--nav-bg: rgba(11, 14, 18, 0.82);
--glow-1: rgba(63, 185, 80, 0.15);
--glow-2: rgba(63, 185, 80, 0.07);
--grain: 0.045;
--shadow-card: 0 1px 0 rgba(255, 255, 255, 0.04) inset, 0 18px 48px -24px rgba(0, 0, 0, 0.9);
--shadow-lift: 0 1px 0 rgba(255, 255, 255, 0.06) inset, 0 28px 64px -28px rgba(0, 0, 0, 1);
--shadow-shot: 0 2px 10px rgba(0, 0, 0, 0.5), 0 36px 88px -32px rgba(0, 0, 0, 0.95);
--display: 'Space Grotesk', 'Segoe UI', 'Microsoft YaHei', sans-serif;
--mono: 'JetBrains Mono', ui-monospace, 'Cascadia Mono', Consolas, monospace;
--radius: 10px;
--radius-lg: 14px;
}
html[data-theme='light'] {
color-scheme: light;
--bg: #fafbf9;
--bg-alt: #f1f4f0;
--surface: #ffffff;
--surface-2: #fbfdfa;
--text: #151a16;
--text-2: #59615a;
--text-3: #8b948c;
--border: #e1e6df;
--border-strong: #cbd3ca;
--accent: #1a7f37;
--accent-hover: #116329;
--radius: 8px;
--mono: ui-monospace, "Cascadia Mono", "SF Mono", Consolas, monospace;
--accent-hover: #146227;
--accent-text: #1a7f37;
--accent-soft: rgba(26, 127, 55, 0.09);
--btn-fg: #ffffff;
--overlay-1: rgba(21, 26, 22, 0.03);
--overlay-2: rgba(21, 26, 22, 0.05);
--overlay-3: rgba(21, 26, 22, 0.06);
--overlay-row: rgba(21, 26, 22, 0.02);
--grid-line: rgba(21, 26, 22, 0.05);
--grid-line-soft: rgba(21, 26, 22, 0.035);
--nav-bg: rgba(250, 251, 249, 0.86);
--glow-1: rgba(26, 127, 55, 0.1);
--glow-2: rgba(26, 127, 55, 0.05);
--grain: 0.02;
--shadow-card: 0 1px 2px rgba(23, 32, 24, 0.06), 0 18px 40px -24px rgba(23, 32, 24, 0.22);
--shadow-lift: 0 2px 4px rgba(23, 32, 24, 0.08), 0 28px 56px -26px rgba(23, 32, 24, 0.28);
--shadow-shot: 0 2px 8px rgba(23, 32, 24, 0.12), 0 32px 72px -32px rgba(23, 32, 24, 0.35);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
@@ -19,28 +142,63 @@
html { scroll-behavior: smooth; }
body {
font-family: "Segoe UI", "Microsoft YaHei", -apple-system, "PingFang SC", "Noto Sans SC", sans-serif;
font-family: 'Segoe UI', 'Microsoft YaHei', -apple-system, 'PingFang SC', 'Noto Sans SC', sans-serif;
font-size: 16px;
line-height: 1.6;
line-height: 1.65;
color: var(--text);
background: var(--bg);
transition: background-color 0.3s ease, color 0.3s ease;
-webkit-font-smoothing: antialiased;
}
.container {
max-width: 1120px;
max-width: 1180px;
margin: 0 auto;
padding: 0 24px;
padding: 0 28px;
}
.mono { font-family: var(--mono); font-size: 0.92em; }
.mono { font-family: var(--mono); font-variant-numeric: tabular-nums; }
h1, h2, h3 {
font-family: var(--display);
font-weight: 700;
text-wrap: balance;
color: var(--text);
}
p { text-wrap: pretty; }
img { max-width: 100%; }
a:focus-visible,
button:focus-visible {
outline: 2px solid var(--accent-text);
outline-offset: 3px;
border-radius: 6px;
}
.skip-link {
position: absolute;
left: -9999px;
top: 0;
z-index: 100;
background: var(--accent);
color: var(--btn-fg);
padding: 10px 18px;
font-size: 14px;
font-weight: 600;
}
.skip-link:focus { left: 12px; top: 12px; }
section[id], .site-nav { scroll-margin-top: 76px; }
/* ---- nav ---- */
.site-nav {
position: sticky;
top: 0;
z-index: 10;
background: rgba(255, 255, 255, 0.94);
z-index: 30;
background: var(--nav-bg);
backdrop-filter: blur(14px);
border-bottom: 1px solid var(--border);
}
@@ -48,58 +206,128 @@ body {
display: flex;
align-items: center;
justify-content: space-between;
height: 64px;
gap: 16px;
height: 62px;
}
.brand {
display: flex;
align-items: center;
gap: 10px;
font-family: var(--display);
font-weight: 600;
font-size: 17px;
letter-spacing: -0.01em;
color: var(--text);
text-decoration: none;
flex-shrink: 0;
}
.nav-links { display: flex; gap: 28px; }
.brand img { border-radius: 6px; }
.nav-right { display: flex; align-items: center; gap: 10px; }
.nav-links { display: flex; gap: 2px; }
.nav-links a {
color: var(--text-2);
text-decoration: none;
font-size: 15px;
font-size: 14.5px;
padding: 7px 13px;
border-radius: 7px;
transition: color 0.2s ease, background 0.2s ease;
}
.nav-links a:hover { color: var(--text); }
.nav-links a:hover { color: var(--text); background: var(--overlay-2); }
.nav-links a.is-current { color: var(--text); background: var(--accent-soft); }
.theme-toggle {
position: relative;
width: 38px;
height: 38px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 50%;
border: 1px solid var(--border-strong);
background: var(--surface);
color: var(--text-2);
cursor: pointer;
flex-shrink: 0;
transition: color 0.2s ease, border-color 0.2s ease, background 0.2s ease, transform 0.15s ease;
}
.theme-toggle:hover { color: var(--accent-text); border-color: var(--accent); transform: translateY(-1px); }
.theme-toggle:active { transform: translateY(0) scale(0.97); }
.theme-toggle svg { position: absolute; transition: opacity 0.25s ease, transform 0.35s ease; }
.theme-toggle .icon-sun { opacity: 0; transform: rotate(-60deg) scale(0.7); }
.theme-toggle .icon-moon { opacity: 1; transform: none; }
html[data-theme='light'] .theme-toggle .icon-sun { opacity: 1; transform: none; }
html[data-theme='light'] .theme-toggle .icon-moon { opacity: 0; transform: rotate(60deg) scale(0.7); }
/* ---- hero ---- */
.hero {
padding: 72px 0 0;
background: var(--bg-soft);
position: relative;
overflow: hidden;
padding: 92px 0 0;
border-bottom: 1px solid var(--border);
background:
radial-gradient(1100px 500px at 80% -15%, var(--glow-1), transparent 62%),
radial-gradient(760px 420px at -10% 115%, var(--glow-2), transparent 58%),
var(--bg);
}
.hero::before {
content: "";
position: absolute;
inset: 0;
background-image:
linear-gradient(var(--grid-line) 1px, transparent 1px),
linear-gradient(90deg, var(--grid-line) 1px, transparent 1px);
background-size: 72px 72px;
mask-image: radial-gradient(900px 620px at 50% 0%, #000 32%, transparent 100%);
pointer-events: none;
}
.hero::after {
content: "";
position: absolute;
inset: 0;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.5'/%3E%3C/svg%3E");
opacity: var(--grain);
pointer-events: none;
}
.hero-grid {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: 1fr 1.2fr;
gap: 48px;
grid-template-columns: 1fr 1.12fr;
gap: 56px;
align-items: center;
}
.hero-kicker {
font-size: 14px;
color: var(--text-2);
margin-bottom: 16px;
font-family: var(--mono);
font-size: 13px;
font-weight: 500;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--accent-text);
margin-bottom: 20px;
}
.hero h1 {
font-size: 36px;
line-height: 1.25;
letter-spacing: -0.02em;
font-weight: 650;
font-size: clamp(34px, 4.6vw, 54px);
line-height: 1.08;
letter-spacing: -0.03em;
}
.hero h1 .accent { color: var(--accent-text); }
.hero-sub {
margin-top: 20px;
margin-top: 22px;
max-width: 46ch;
color: var(--text-2);
font-size: 17px;
@@ -108,118 +336,251 @@ body {
.hero-actions {
display: flex;
gap: 12px;
margin-top: 28px;
margin-top: 32px;
flex-wrap: wrap;
}
.btn {
display: inline-block;
padding: 10px 22px;
padding: 11px 24px;
border-radius: var(--radius);
font-size: 15px;
font-weight: 600;
text-decoration: none;
border: 1px solid transparent;
transition: background 0.2s ease, border-color 0.2s ease, transform 0.15s ease, box-shadow 0.2s ease;
}
.btn:active { transform: translateY(1px); }
.btn:active { transform: translateY(1px) scale(0.99); }
.btn-primary {
background: var(--accent);
color: #ffffff;
color: var(--btn-fg);
box-shadow: 0 10px 28px -10px rgba(63, 185, 80, 0.5);
}
.btn-primary:hover { background: var(--accent-hover); }
.btn-ghost {
color: var(--text);
border-color: var(--border);
background: var(--bg);
border-color: var(--border-strong);
background: var(--overlay-1);
}
.btn-ghost:hover { border-color: var(--text-2); }
.btn-ghost:hover { border-color: var(--accent); background: var(--overlay-2); }
.hero-note {
margin-top: 14px;
margin-top: 16px;
font-size: 13px;
color: var(--text-2);
}
.hero-note .mono { color: var(--text); }
.hero-shot {
min-width: 0;
}
.hero-shot { min-width: 0; }
.hero-shot img {
display: block;
width: 100%;
height: auto;
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: 0 8px 24px rgba(28, 33, 40, 0.10);
border-radius: 12px;
box-shadow: var(--shadow-shot);
}
.hero-shot figcaption {
margin-top: 10px;
margin-top: 12px;
font-size: 13px;
color: var(--text-2);
text-align: center;
}
.spec-strip {
position: relative;
z-index: 1;
display: flex;
gap: 32px;
gap: 40px;
flex-wrap: wrap;
padding-top: 40px;
padding-bottom: 40px;
padding: 40px 0 48px;
margin-top: 48px;
font-size: 14px;
color: var(--text-2);
border-top: 1px solid var(--border);
}
.spec-strip strong { color: var(--text); font-weight: 600; }
.spec-strip .mono { font-size: 13px; }
/* ---- features ---- */
.features { padding: 72px 0; }
/* ---- shared section chrome ---- */
section { position: relative; }
.section-head {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 32px;
}
.section-eyebrow {
font-family: var(--mono);
font-size: 12.5px;
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--accent-text);
margin-bottom: 12px;
}
.features h2, .download h2, .changelog h2 {
font-size: 28px;
font-weight: 650;
letter-spacing: -0.01em;
font-size: clamp(26px, 3vw, 34px);
font-weight: 700;
letter-spacing: -0.025em;
}
.section-sub {
margin-top: 10px;
margin-top: 14px;
color: var(--text-2);
max-width: 60ch;
max-width: 62ch;
font-size: 16.5px;
}
.grid-bg::before {
content: "";
position: absolute;
inset: 0;
background-image:
linear-gradient(var(--grid-line-soft) 1px, transparent 1px),
linear-gradient(90deg, var(--grid-line-soft) 1px, transparent 1px);
background-size: 64px 64px;
pointer-events: none;
}
.features > .container { position: relative; z-index: 1; }
/* ---- capability track (horizontal scroll) ---- */
.features { padding: 92px 0 100px; background: var(--bg); }
.cap-controls { display: flex; gap: 8px; flex-shrink: 0; }
.track-btn {
width: 38px;
height: 38px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 50%;
border: 1px solid var(--border-strong);
background: var(--surface);
color: var(--text);
cursor: pointer;
transition: background 0.2s ease, border-color 0.2s ease, color 0.2s ease, transform 0.15s ease;
}
.track-btn:hover:not(:disabled) { border-color: var(--accent); color: var(--accent-text); transform: translateY(-1px); }
.track-btn:active:not(:disabled) { transform: translateY(0) scale(0.97); }
.track-btn:disabled { opacity: 0.35; cursor: default; }
.cap-track {
display: flex;
gap: 20px;
margin-top: 44px;
padding: 4px 28px 22px;
margin-left: -28px;
margin-right: -28px;
overflow-x: auto;
overscroll-behavior-x: contain;
scroll-snap-type: x proximity;
scrollbar-width: none;
cursor: grab;
mask-image: linear-gradient(90deg, transparent 0, #000 28px, #000 calc(100% - 28px), transparent 100%);
}
.cap-track::-webkit-scrollbar { display: none; }
.cap-track.is-dragging { cursor: grabbing; scroll-snap-type: none; }
.cap-card {
flex: 0 0 clamp(292px, 30vw, 366px);
scroll-snap-align: start;
display: flex;
flex-direction: column;
gap: 14px;
padding: 26px 24px 28px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
transition: border-color 0.25s ease, transform 0.25s ease, box-shadow 0.25s ease, background 0.25s ease;
}
.cap-card:hover {
border-color: var(--accent);
background: var(--surface-2);
transform: translateY(-4px);
box-shadow: var(--shadow-lift);
}
.cap-num {
font-family: var(--mono);
font-size: 12.5px;
font-weight: 500;
color: var(--accent-text);
letter-spacing: 0.1em;
}
.cap-card h3 { font-size: 19px; letter-spacing: -0.01em; }
.cap-card ul { list-style: none; display: grid; gap: 9px; margin-top: 2px; }
.cap-card li {
position: relative;
padding-left: 16px;
font-size: 14.5px;
line-height: 1.55;
color: var(--text-2);
}
.cap-card li::before {
content: "";
position: absolute;
left: 0;
top: 0.66em;
width: 5px;
height: 5px;
border-radius: 1px;
background: var(--accent);
}
.cap-hint {
margin-top: 18px;
font-family: var(--mono);
font-size: 12.5px;
color: var(--text-3);
}
/* ---- media rows (screenshot + copy) ---- */
.feature-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 64px;
margin-top: 48px;
}
.media-row {
grid-template-columns: 7fr 5fr;
gap: 60px;
align-items: center;
gap: 56px;
margin-top: 84px;
}
.feature-row:first-of-type { margin-top: 76px; }
.feature-copy { min-width: 0; }
.feature-copy h3 { font-size: 21px; letter-spacing: -0.015em; margin-bottom: 14px; }
.eyebrow {
font-family: var(--mono);
font-size: 12.5px;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--accent-text);
margin-bottom: 12px;
}
.feature-copy h3 {
font-size: 20px;
font-weight: 650;
margin-bottom: 16px;
}
.feature-copy ul {
list-style: none;
display: grid;
gap: 10px;
}
.feature-copy ul { list-style: none; display: grid; gap: 10px; }
.feature-copy li {
padding-left: 18px;
position: relative;
padding-left: 18px;
color: var(--text-2);
font-size: 15px;
}
@@ -229,24 +590,38 @@ body {
position: absolute;
left: 0;
top: 0.62em;
width: 6px;
height: 2px;
width: 5px;
height: 5px;
border-radius: 1px;
background: var(--accent);
}
.feature-shot { min-width: 0; }
.feature-shot img {
display: block;
width: 100%;
height: auto;
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: 0 6px 18px rgba(28, 33, 40, 0.08);
border-radius: 12px;
box-shadow: var(--shadow-shot);
background: var(--surface);
}
.feature-shot img + img { margin-top: 16px; }
.feature-shot img.narrow { width: 78%; }
.feature-shot figcaption {
margin-top: 12px;
font-size: 13px;
color: var(--text-2);
text-align: center;
}
/* ---- download ---- */
.download {
padding: 72px 0;
background: var(--bg-soft);
padding: 92px 0 100px;
background: var(--bg-alt);
border-top: 1px solid var(--border);
border-bottom: 1px solid var(--border);
}
@@ -254,84 +629,94 @@ body {
.dl-table {
width: 100%;
border-collapse: collapse;
margin-top: 40px;
background: var(--bg);
margin-top: 44px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
border-radius: var(--radius-lg);
overflow: hidden;
font-size: 14.5px;
}
.dl-table th, .dl-table td {
text-align: left;
padding: 14px 18px;
padding: 15px 20px;
vertical-align: top;
}
.dl-table thead th {
background: var(--bg-soft);
background: var(--accent-soft);
color: var(--text-2);
font-weight: 600;
font-size: 13px;
font-family: var(--mono);
font-weight: 500;
font-size: 12.5px;
letter-spacing: 0.06em;
}
.dl-table tbody td { color: var(--text-2); }
.dl-table tbody tr + tr td { border-top: 1px solid var(--border); }
.dl-table tbody tr:hover td { background: var(--overlay-row); }
.dl-table a { color: var(--accent); text-decoration: none; font-weight: 600; }
.dl-table a:hover { text-decoration: underline; }
.dl-table a {
color: var(--accent-text);
text-decoration: none;
font-weight: 500;
border-bottom: 1px solid transparent;
}
.dl-table a:hover { border-bottom-color: currentColor; }
.dl-table .sep { color: var(--border); margin: 0 8px; }
.dl-table .sep { color: var(--border-strong); margin: 0 8px; }
.dl-table .mono { color: var(--text); }
.update-note { margin-top: 40px; max-width: 72ch; }
.update-note { margin-top: 44px; max-width: 74ch; }
.update-note h3 { font-size: 16px; margin-bottom: 8px; }
.update-note p { color: var(--text-2); font-size: 14.5px; }
/* ---- changelog ---- */
.changelog { padding: 72px 0; }
.changelog { padding: 92px 0 100px; background: var(--bg); }
.changelog-list {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 48px;
margin-top: 40px;
gap: 40px;
margin-top: 44px;
}
.changelog-list article {
border-top: 2px solid var(--border);
padding-top: 18px;
transition: border-color 0.25s ease;
}
.changelog-list article:hover { border-top-color: var(--accent); }
.changelog-list article h3 {
font-size: 15px;
font-family: var(--mono);
font-size: 14.5px;
font-weight: 600;
margin-bottom: 12px;
margin-bottom: 14px;
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
}
.changelog-list article h3 .mono {
color: var(--text);
margin-right: 8px;
}
.changelog-list .rel-date { color: var(--text-3); font-weight: 400; font-size: 12.5px; }
.changelog-list ul {
list-style: none;
display: grid;
gap: 8px;
}
.changelog-list ul { list-style: none; display: grid; gap: 8px; }
.changelog-list li {
font-size: 14px;
color: var(--text-2);
}
.changelog-list li { font-size: 14px; color: var(--text-2); }
.changelog-more {
margin-top: 40px;
font-size: 14px;
color: var(--text-2);
}
.changelog-more { margin-top: 44px; font-size: 14px; color: var(--text-2); }
.changelog-more a { color: var(--accent); }
.changelog-more a { color: var(--accent-text); font-weight: 500; text-decoration: none; }
.changelog-more a:hover { border-bottom: 1px solid currentColor; }
/* ---- footer ---- */
.site-footer {
background: var(--bg-alt);
border-top: 1px solid var(--border);
padding: 28px 0;
padding: 34px 0;
font-size: 13.5px;
color: var(--text-2);
}
@@ -341,24 +726,47 @@ body {
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
align-items: center;
}
.footer-inner .mono { font-size: 12.5px; color: var(--text-3); }
/* ---- scroll reveal ---- */
@media (prefers-reduced-motion: no-preference) {
.reveal {
opacity: 0;
transform: translateY(14px);
transition: opacity 0.6s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
}
.reveal.is-in { opacity: 1; transform: none; }
}
/* ---- responsive ---- */
@media (max-width: 960px) {
@media (max-width: 1024px) {
.feature-row { grid-template-columns: 1fr; gap: 36px; }
.feature-row .feature-shot { order: -1; }
.feature-shot img.narrow { width: 100%; }
}
@media (max-width: 900px) {
.hero-grid { grid-template-columns: 1fr; gap: 40px; }
.hero { padding-top: 48px; }
.feature-row, .media-row { grid-template-columns: 1fr; gap: 32px; }
.media-row .feature-shot { order: -1; }
.changelog-list { grid-template-columns: 1fr; gap: 32px; }
.hero { padding-top: 60px; }
.section-head { flex-direction: column; align-items: flex-start; gap: 20px; }
.changelog-list { grid-template-columns: 1fr; gap: 28px; }
}
@media (max-width: 640px) {
.nav-links { gap: 16px; }
.nav-links a { font-size: 14px; }
.container { padding: 0 20px; }
.nav-inner { height: 56px; }
.nav-links { gap: 0; }
.nav-links a { font-size: 13.5px; padding: 6px 8px; }
.hero h1 { font-size: 30px; }
.spec-strip { gap: 16px 24px; }
.cap-track { padding: 4px 20px 20px; margin-left: -20px; margin-right: -20px; }
.dl-table { display: block; overflow-x: auto; white-space: nowrap; }
}
@media (prefers-reduced-motion: reduce) {
html { scroll-behavior: auto; }
*, *::before, *::after { transition: none !important; }
}
+330 -59
View File
@@ -6,111 +6,277 @@
<title>OpenTerminal - 本地终端 / SSH / SFTP 桌面终端,开源 Windows 终端工具</title>
<meta name="description" content="OpenTerminal 是开源的 Windows 桌面终端:本地终端、SSH 远程会话、SFTP 文件管理、分屏与广播输入、主题定制。MIT 协议,支持自动更新。" />
<link rel="icon" type="image/png" href="assets/icon.png" />
<link rel="preload" href="assets/fonts/space-grotesk-600.woff2" as="font" type="font/woff2" crossorigin />
<link rel="preload" href="assets/fonts/jetbrains-mono-500.woff2" as="font" type="font/woff2" crossorigin />
<meta property="og:title" content="OpenTerminal - 本地终端 / SSH / SFTP 桌面终端" />
<meta property="og:description" content="开源的 Windows 桌面终端:本地终端、SSH 远程会话、SFTP 文件管理、分屏与广播输入。MIT 协议,支持自动更新。" />
<meta property="og:image" content="assets/shot-terminal.jpg" />
<meta name="theme-color" content="#0b0e12" />
<link rel="stylesheet" href="assets/style.css" />
<script>
// 主题必须在首帧前定好,避免闪白/闪黑;默认深色,只有用户显式切过才用保存值
(function () {
var theme = 'dark';
try {
if (localStorage.getItem('openterminal.theme') === 'light') theme = 'light';
} catch (e) {}
document.documentElement.dataset.theme = theme;
})();
</script>
</head>
<body>
<a class="skip-link" href="#main">跳到主要内容</a>
<header class="site-nav">
<div class="container nav-inner">
<a class="brand" href="#top">
<img src="assets/icon.png" alt="OpenTerminal 图标" width="24" height="24" />
<span>OpenTerminal</span>
</a>
<nav class="nav-links">
<a href="#features">功能</a>
<a href="#download">下载</a>
<a href="#changelog">更新日志</a>
<a href="https://github.com/billowliu2/OpenTerminal" target="_blank" rel="noopener">GitHub</a>
</nav>
<div class="nav-right">
<nav class="nav-links" aria-label="站内导航">
<a href="#features">功能</a>
<a href="#download">下载</a>
<a href="#changelog">更新日志</a>
<a href="https://github.com/billowliu2/OpenTerminal" target="_blank" rel="noopener">GitHub</a>
</nav>
<button class="theme-toggle" id="theme-toggle" type="button" aria-label="切换明暗主题" title="切换明暗主题">
<svg class="icon-moon" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z" />
</svg>
<svg class="icon-sun" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" />
</svg>
</button>
</div>
</div>
</header>
<main id="top">
<main id="main">
<section class="hero">
<section class="hero" id="top">
<div class="container hero-grid">
<div class="hero-copy">
<p class="hero-kicker">开源桌面终端 · MIT 协议</p>
<h1>本地终端、SSH 与 SFTP<br />装进同一个桌面应用</h1>
<h1>本地终端、SSH 与 SFTP<br />装进<span class="accent">同一个</span>桌面应用</h1>
<p class="hero-sub">
OpenTerminal 面向开发和运维场景:WebGL 加速的终端渲染、可分屏的 SSH 会话、
内置 SFTP 文件管理与广播输入。安装即用,支持自动更新。
</p>
<div class="hero-actions">
<a class="btn btn-primary" id="dl-exe" href="https://github.com/billowliu2/OpenTerminal/releases/download/v1.0.15/OpenTerminal-1.0.15-setup.exe">
<a class="btn btn-primary" id="dl-exe" href="https://github.com/billowliu2/OpenTerminal/releases/download/v1.0.22/OpenTerminal-1.0.22-setup.exe">
下载 Windows 安装包
</a>
<a class="btn btn-ghost" href="https://github.com/billowliu2/OpenTerminal" target="_blank" rel="noopener">
查看源码
</a>
</div>
<p class="hero-note">当前版本 v1.0.15 · 提供 exe 与 msi 安装包。</p>
<p class="hero-note">当前版本 <span class="mono">v1.0.22</span> · NSIS exe 安装包,支持应用内自动更新。</p>
</div>
<figure class="hero-shot">
<img src="assets/shot-terminal.png" alt="OpenTerminal 主界面:四个分屏终端会话" width="1282" height="801" />
<figcaption>四路分屏会话,每路独立连接本地或远程主机</figcaption>
<img src="assets/shot-terminal.jpg" alt="OpenTerminal 主界面:三路分屏并行运行 Kimi Code、Claude Code 与 Codex" width="1739" height="870" />
<figcaption>三路分屏并行运行 AI 编程 Agent,背景图与压暗层可自定义</figcaption>
</figure>
</div>
<div class="container spec-strip">
<span>当前版本 <strong class="mono">1.0.15</strong></span>
<span>当前版本 <strong class="mono">v1.0.22</strong></span>
<span>Windows 10 / 11 · x64</span>
<span>MIT 开源协议</span>
<span>exe 支持应用内自动更新</span>
</div>
</section>
<section class="features" id="features">
<section class="features grid-bg" id="features">
<div class="container">
<h2>核心能力</h2>
<p class="section-sub">按使用场景划分的三组功能,全部能力见仓库 README。</p>
<div class="section-head">
<div>
<p class="section-eyebrow">Capabilities</p>
<h2>核心能力</h2>
<p class="section-sub">按使用场景划分的能力组,左右拖动或用箭头浏览;完整清单见仓库 README。</p>
</div>
<div class="cap-controls">
<button class="track-btn" type="button" data-dir="-1" aria-label="向左浏览能力卡片">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 18l-6-6 6-6" /></svg>
</button>
<button class="track-btn" type="button" data-dir="1" aria-label="向右浏览能力卡片">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 6l6 6-6 6" /></svg>
</button>
</div>
</div>
<div class="feature-row">
<div class="feature-copy">
<div class="cap-track" id="cap-track" tabindex="0" aria-label="核心能力,可横向滚动">
<article class="cap-card">
<p class="cap-num">01</p>
<h3>终端核心</h3>
<ul>
<li>node-pty 本地会话,自动探测 PowerShell / zsh / bash</li>
<li>xterm.js WebGL 渲染,声明 256 色与真彩能力,异常时自动降级</li>
<li>xterm.js WebGL 渲染,声明 256 色与真彩,异常自动降级</li>
<li>内嵌搜索(Ctrl+F)、命令历史、粘贴风险确认</li>
<li>关键词高亮:内置 11 条规则(错误状态、路径、IP、URL 等),支持自定义正则</li>
<li>关键词高亮 22 条内置规则,支持自定义正则与三档强度</li>
<li>字体、字重、字距、行高、光标样式实时生效</li>
</ul>
</div>
<div class="feature-copy">
<h3>SSH 与 SFTP</h3>
</article>
<article class="cap-card">
<p class="cap-num">02</p>
<h3>SSH 会话</h3>
<ul>
<li>ssh2 实现:密码、私钥(文件或粘贴内容)、SSH Agent 三种认证</li>
<li>ssh2 实现:密码、私钥(文件或粘贴内容)、SSH Agent</li>
<li>多路分屏与广播输入,一次操作下发到多台主机</li>
<li>SFTP 文件面板:上传下载、同名覆盖确认、chmod / chown 权限编辑</li>
<li>ZMODEM 传输支持;主机指纹校验与首次连接确认</li>
<li>目录记忆:OSC 7 上报工作目录,新会话按上次位置恢复</li>
<li>主机指纹校验、首次连接确认、变更告警</li>
<li>ZMODEM 传输支持(sz / rz)</li>
<li>OSC 7 上报工作目录,新会话按上次位置恢复</li>
</ul>
</article>
<article class="cap-card">
<p class="cap-num">03</p>
<h3>SFTP 文件面板</h3>
<ul>
<li>列表头列分明:名称、大小、修改时间、权限、用户/组</li>
<li>点击列头排序;文件名解析为真实用户与组名</li>
<li>字体缩放 80%–180%,Ctrl+滚轮或工具栏按钮</li>
<li>上传下载、同名覆盖确认、chmod / chown 编辑</li>
<li>大目录虚拟滚动,上万条目不卡窗口</li>
</ul>
</article>
<article class="cap-card">
<p class="cap-num">04</p>
<h3>工作区与布局</h3>
<ul>
<li>dockview 分屏:任意拆分、拖拽、合并与浮动</li>
<li>布局模板保存与一键套用</li>
<li>启动恢复上次会话布局</li>
<li>界面缩放 50% 至 150%</li>
</ul>
</article>
<article class="cap-card">
<p class="cap-num">05</p>
<h3>主题与外观</h3>
<ul>
<li>主题编辑器:22 个颜色位逐项调整,实时预览</li>
<li>内置 11 套主题,跟随系统明暗自动切换</li>
<li>自定义背景图片、不透明度与压暗层</li>
<li>标签强调色、高亮配色跟随主题</li>
</ul>
</article>
<article class="cap-card">
<p class="cap-num">06</p>
<h3>效率工具</h3>
<ul>
<li>命令库:保存常用命令片段,跨会话发送到指定终端</li>
<li>高亮规则集按主机绑定,不同服务器不同配色</li>
<li>规则导入导出、实时预览与命中统计</li>
<li>会话快照与恢复、终端标签自动命名</li>
</ul>
</article>
<article class="cap-card">
<p class="cap-num">07</p>
<h3>系统监控</h3>
<ul>
<li>CPU、负载、内存、磁盘、网络实时面板</li>
<li>每核占用与磁盘分区明细</li>
<li>运行时长与网络速率曲线</li>
<li>随会话打开,不额外占用通道</li>
</ul>
</article>
<article class="cap-card">
<p class="cap-num">08</p>
<h3>锁屏与安全</h3>
<ul>
<li>Ctrl+L 一键锁屏,支持闲置自动锁定与启动即锁</li>
<li>密码经 scrypt 加盐哈希存储,不落盘明文</li>
<li>输错触发递增冷却;锁定期间禁用菜单与刷新快捷键</li>
<li>锁屏只是遮罩:背后会话与传输不中断</li>
</ul>
</article>
</div>
<p class="cap-hint">← 拖动浏览 · 共 8 组能力</p>
<div class="feature-row">
<figure class="feature-shot reveal">
<img src="assets/shot-agents.png" alt="OpenTerminal 多路分屏运行 Kimi Code、Claude Code、Codex 等 AI 编程 Agent" width="1296" height="870" />
</figure>
<div class="feature-copy reveal">
<p class="eyebrow">Workbench</p>
<h3>AI Agent 工作台</h3>
<ul>
<li>多路分屏并行运行 Kimi Code、Claude Code、Codex 等 AI 编程工具</li>
<li>为高频刷新的 TUI 界面优化:终端尺寸去抖同步,避免反复重绘</li>
<li>中文输入法候选窗紧贴光标,TUI 内输入中文不再漂移</li>
<li>关键词高亮自动标出错误、警告、HTTP 状态码与百分比</li>
</ul>
</div>
</div>
<div class="feature-row media-row">
<figure class="feature-shot">
<img src="assets/shot-themes.png" alt="OpenTerminal 主题设置:主题画廊与背景图片配置" width="1282" height="801" />
<div class="feature-row">
<figure class="feature-shot reveal">
<img src="assets/shot-sftp.png" alt="OpenTerminal SFTP 文件面板:列表头、可排序列、完整修改时间与用户/组" width="1282" height="801" />
<img class="narrow" src="assets/shot-sftp-menu.png" alt="OpenTerminal 在文件列表空白处右键弹出同款文件菜单" width="1282" height="801" />
<figcaption>列表空白处右键同样是完整文件菜单,行内右键则作用于所选条目</figcaption>
</figure>
<div class="feature-copy">
<div class="feature-copy reveal">
<p class="eyebrow">SFTP</p>
<h3>远程文件面板</h3>
<ul>
<li>列表头列分明:名称、大小、修改时间、权限、用户/组,点击列头排序</li>
<li>文件名解析为真实用户与组名(root/root、eveuser/eveuser),不再只有数字</li>
<li>字体缩放 80%–180%:Ctrl+滚轮或工具栏按钮,行高与字号联动并记忆</li>
<li>上传 / 下载、同名覆盖确认、chmod 权限与 chown 属主编辑</li>
<li>大目录虚拟滚动,上万条目不卡窗口</li>
</ul>
</div>
</div>
<div class="feature-row">
<figure class="feature-shot reveal">
<img src="assets/shot-themes.jpg" alt="OpenTerminal 主题设置:背景图片、不透明度、压暗层与主题画廊" width="1739" height="870" />
</figure>
<div class="feature-copy reveal">
<p class="eyebrow">Customize</p>
<h3>定制与效率</h3>
<ul>
<li>主题编辑器:22 个颜色位逐项调整,实时预览;支持自定义背景图片与不透明度</li>
<li>内置 13 套主题,跟随系统明暗自动切换</li>
<li>主题编辑器:22 个颜色位逐项调整,实时预览;支持自定义背景图片、不透明度与压暗层</li>
<li>内置 11 套主题,跟随系统明暗自动切换</li>
<li>命令库:保存常用命令片段,跨会话发送到指定终端</li>
<li>启动恢复上次会话布局;界面缩放 50% 至 150%</li>
<li>四语言界面:简体中文、繁体中文、English、日本語</li>
</ul>
</div>
</div>
<div class="feature-row">
<figure class="feature-shot reveal">
<img src="assets/shot-lock.png" alt="OpenTerminal 锁屏界面:不透明遮罩与密码解锁" width="1739" height="870" />
</figure>
<div class="feature-copy reveal">
<p class="eyebrow">Security</p>
<h3>锁屏与安全</h3>
<ul>
<li>Ctrl+L 一键锁屏,支持闲置自动锁定与启动即锁</li>
<li>锁定期间禁用应用菜单、页面刷新与开发者工具快捷键</li>
<li>密码经 scrypt 加盐哈希存储,不落盘明文;输错触发递增冷却</li>
<li>锁屏只是遮罩:背后的终端会话与文件传输保持运行不中断</li>
</ul>
</div>
</div>
</div>
</section>
<section class="download" id="download">
<div class="container">
<p class="section-eyebrow">Download</p>
<h2>下载</h2>
<p class="section-sub">
从 GitHub Releases 下载;全部历史版本在同一页面。
<!-- 发新版本时更新下方两条直链与 hero 的 id=dl-exe 链接 -->
<!-- 发新版本时更新下方 exe 直链与 hero 的 id=dl-exe 链接 -->
</p>
<table class="dl-table">
<thead>
@@ -118,19 +284,11 @@
</thead>
<tbody>
<tr>
<td class="mono">OpenTerminal-1.0.15-setup.exe</td>
<td>常规安装,支持应用内自动更新(推荐)</td>
<td class="mono">122 MB</td>
<td class="mono">OpenTerminal-1.0.22-setup.exe</td>
<td>NSIS 安装包,支持应用内自动更新(推荐)</td>
<td class="mono">109 MB</td>
<td>
<a href="https://github.com/billowliu2/OpenTerminal/releases/download/v1.0.15/OpenTerminal-1.0.15-setup.exe">下载</a>
</td>
</tr>
<tr>
<td class="mono">OpenTerminal-1.0.15-setup.msi</td>
<td>企业批量部署(MSI 无自动更新,需手动升级)</td>
<td class="mono">135 MB</td>
<td>
<a href="https://github.com/billowliu2/OpenTerminal/releases/download/v1.0.15/OpenTerminal-1.0.15-setup.msi">下载</a>
<a href="https://github.com/billowliu2/OpenTerminal/releases/download/v1.0.22/OpenTerminal-1.0.22-setup.exe">下载</a>
</td>
</tr>
<tr>
@@ -147,8 +305,9 @@
<div class="update-note">
<h3>自动更新说明</h3>
<p>
应用内检查更新优先访问 git.codingplan.site 更新通道,失败时回退 GitHub Releases。
应用内检查更新优先访问 GitHub Releases(跟随系统代理),失败时自动回退国内 git.codingplan.site 更新通道(直连)。
更新包经 SHA-512 校验后安装。更新说明文字从发布通道的 release-notes.md 读取,与每次发布同步更新。
自 v1.0.22 起不再提供 MSI 安装包,统一使用 NSIS exe。
</p>
</div>
</div>
@@ -156,29 +315,30 @@
<section class="changelog" id="changelog">
<div class="container">
<p class="section-eyebrow">Changelog</p>
<h2>近期更新</h2>
<div class="changelog-list">
<article>
<h3><span class="mono">1.0.15</span> 2026-09-20</h3>
<h3><span class="mono">1.0.22</span><span class="rel-date">2026-10-08</span></h3>
<ul>
<li>新增自定义终端背景图片与图片不透明度设置</li>
<li>主题编辑器重名提醒与颜色值校验</li>
<li>设置导航选中项文字对比度修复</li>
<li>修复中文输入法候选窗不跟随光标(v1.0.21 回归)</li>
<li>不再提供 MSI 安装包,统一使用 NSIS exe</li>
<li>打包流程新增依赖一致性校验,防止旧依赖混入安装包</li>
</ul>
</article>
<article>
<h3><span class="mono">1.0.14</span> 2026-09-20</h3>
<h3><span class="mono">1.0.21</span><span class="rel-date">2026-10-07</span></h3>
<ul>
<li>修复大文件(约 250KB 以上)上传损坏与 SFTP 句柄泄漏</li>
<li>SSH 中文乱码、命令历史错乱等终端修复</li>
<li>权限编辑、连接错误提示等 20 余项修复</li>
<li>背景图体验:任意主题下调透明度均为压暗、文字自动提亮到 WCAG AA、新增背景压暗滑杆</li>
<li>性能:设置热更不再触发全窗格重排,SFTP 大目录虚拟滚动,面板独立错误边界</li>
<li>安装包精简约 90MB;SFTP/ZMODEM 本地路径准入与多处安全加固</li>
</ul>
</article>
<article>
<h3><span class="mono">1.0.13</span> 2026-09-20</h3>
<h3><span class="mono">1.0.20</span><span class="rel-date">2026-10-01</span></h3>
<ul>
<li>命令片段库与广播输入改进</li>
<li>监控面板历史曲线实时重绘</li>
<li>修复中文输入法候选窗不跟随光标</li>
<li>锁屏加固:锁定期间移除应用菜单,Ctrl+L 按键判定重构并补测试</li>
</ul>
</article>
</div>
@@ -197,5 +357,116 @@
</div>
</footer>
<script>
(function () {
var reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
// 滚动进场
var revealables = document.querySelectorAll('.reveal');
if (reduce || !('IntersectionObserver' in window)) {
revealables.forEach(function (el) { el.classList.add('is-in'); });
} else {
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (e) {
if (e.isIntersecting) { e.target.classList.add('is-in'); io.unobserve(e.target); }
});
}, { rootMargin: '0px 0px -8% 0px', threshold: 0.05 });
revealables.forEach(function (el) { io.observe(el); });
}
// 导航当前位置
var links = Array.prototype.slice.call(document.querySelectorAll('.nav-links a[href^="#"]'));
var sections = links
.map(function (a) { return document.querySelector(a.getAttribute('href')); })
.filter(Boolean);
function syncNav() {
var pos = scrollY + 130;
var current = null;
sections.forEach(function (s) { if (s.offsetTop <= pos) current = s; });
links.forEach(function (a) {
a.classList.toggle('is-current', !!current && a.getAttribute('href') === '#' + current.id);
});
}
addEventListener('scroll', syncNav, { passive: true });
syncNav();
// 能力卡片横向轨道:箭头翻页 + 按住拖动
var track = document.getElementById('cap-track');
if (!track) return;
var buttons = document.querySelectorAll('.track-btn');
function step() {
var card = track.querySelector('.cap-card');
return card ? card.getBoundingClientRect().width + 20 : track.clientWidth * 0.8;
}
function syncButtons() {
var max = track.scrollWidth - track.clientWidth - 1;
buttons.forEach(function (b) {
var dir = Number(b.getAttribute('data-dir'));
b.disabled = dir < 0 ? track.scrollLeft <= 0 : track.scrollLeft >= max;
});
}
buttons.forEach(function (b) {
b.addEventListener('click', function () {
track.scrollBy({ left: Number(b.getAttribute('data-dir')) * step(), behavior: reduce ? 'auto' : 'smooth' });
});
});
track.addEventListener('scroll', syncButtons, { passive: true });
addEventListener('resize', syncButtons);
syncButtons();
var dragging = false;
var startX = 0;
var startScroll = 0;
track.addEventListener('pointerdown', function (e) {
if (e.pointerType === 'mouse' && e.button !== 0) return;
dragging = true;
startX = e.clientX;
startScroll = track.scrollLeft;
track.classList.add('is-dragging');
track.setPointerCapture(e.pointerId);
});
track.addEventListener('pointermove', function (e) {
if (!dragging) return;
var delta = e.clientX - startX;
if (Math.abs(delta) > 3) e.preventDefault();
track.scrollLeft = startScroll - delta;
});
function endDrag(e) {
if (!dragging) return;
dragging = false;
track.classList.remove('is-dragging');
if (e && e.pointerId !== undefined && track.hasPointerCapture(e.pointerId)) {
track.releasePointerCapture(e.pointerId);
}
syncButtons();
}
track.addEventListener('pointerup', endDrag);
track.addEventListener('pointercancel', endDrag);
track.addEventListener('dragstart', function (e) { e.preventDefault(); });
track.addEventListener('keydown', function (e) {
if (e.key === 'ArrowRight') { track.scrollBy({ left: step(), behavior: 'smooth' }); e.preventDefault(); }
if (e.key === 'ArrowLeft') { track.scrollBy({ left: -step(), behavior: 'smooth' }); e.preventDefault(); }
});
// 明暗主题切换
var toggle = document.getElementById('theme-toggle');
var meta = document.querySelector('meta[name="theme-color"]');
function applyTheme(theme) {
document.documentElement.dataset.theme = theme;
if (meta) meta.setAttribute('content', theme === 'light' ? '#fafbf9' : '#0b0e12');
if (toggle) toggle.setAttribute('aria-label', theme === 'light' ? '切换到深色主题' : '切换到浅色主题');
}
if (toggle) {
toggle.addEventListener('click', function () {
var next = document.documentElement.dataset.theme === 'light' ? 'dark' : 'light';
applyTheme(next);
try { localStorage.setItem('openterminal.theme', next); } catch (e) {}
});
}
applyTheme(document.documentElement.dataset.theme === 'light' ? 'light' : 'dark');
})();
</script>
</body>
</html>