feat(desktop): refresh localized control panel

Rebuild the Wails desktop shell into a localized tabbed control panel so provider, alias, sync, and settings workflows are easier to manage in one place. Extend desktop preferences with theme and language, document the desktop build flow, and keep the browser fallback shell aligned with the same frontend.
This commit is contained in:
apale7 2026-04-19 10:07:46 +08:00
parent 31f25b54ab
commit f3b211f745
21 changed files with 2351 additions and 648 deletions

View File

@ -0,0 +1,72 @@
{
"id": "desktop-gui-i18n-ux-refresh-impl",
"name": "desktop-gui-i18n-ux-refresh-impl",
"title": "Implement desktop GUI i18n and UX refresh",
"description": "",
"status": "completed",
"dev_type": "feature",
"scope": "desktop",
"package": null,
"priority": "P2",
"creator": "OpenCode",
"assignee": "OpenCode",
"createdAt": "2026-04-19",
"completedAt": "2026-04-19",
"branch": "master",
"base_branch": "master",
"worktree_path": null,
"current_phase": 6,
"next_action": [
{
"phase": 1,
"action": "brainstorm"
},
{
"phase": 2,
"action": "research"
},
{
"phase": 3,
"action": "implement"
},
{
"phase": 4,
"action": "check"
},
{
"phase": 5,
"action": "update-spec"
},
{
"phase": 6,
"action": "record-session"
}
],
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "Completed the desktop GUI i18n and UX refresh. Extended desktop preferences with theme and language across Go and frontend, added i18next-based localization with en-US/zh-CN/system support, rebuilt the desktop shell into a tabbed Wails/browser-shared control panel, refreshed layout and theme tokens, updated the generated Wails desktop prefs models, documented desktop build and usage in the README files, and verified with `npm run build`, `go test ./internal/app ./internal/desktop`, and `wails build -tags desktop_wails`.",
"meta": {
"desktopTabs": [
"overview",
"providers",
"aliases",
"sync",
"settings"
],
"languages": [
"en-US",
"zh-CN",
"system"
],
"themes": [
"light",
"dark",
"system"
],
"desktopExe": "build/bin/ocswitch-desktop.exe"
}
}

View File

@ -8,8 +8,8 @@
<!-- @@@auto:current-status --> <!-- @@@auto:current-status -->
- **Active File**: `journal-1.md` - **Active File**: `journal-1.md`
- **Total Sessions**: 0 - **Total Sessions**: 1
- **Last Active**: - - **Last Active**: 2026-04-19
<!-- @@@/auto:current-status --> <!-- @@@/auto:current-status -->
--- ---
@ -19,7 +19,7 @@
<!-- @@@auto:active-documents --> <!-- @@@auto:active-documents -->
| File | Lines | Status | | File | Lines | Status |
|------|-------|--------| |------|-------|--------|
| `journal-1.md` | ~0 | Active | | `journal-1.md` | ~101 | Active |
<!-- @@@/auto:active-documents --> <!-- @@@/auto:active-documents -->
--- ---
@ -29,6 +29,7 @@
<!-- @@@auto:session-history --> <!-- @@@auto:session-history -->
| # | Date | Title | Commits | Branch | | # | Date | Title | Commits | Branch |
|---|------|-------|---------|--------| |---|------|-------|---------|--------|
| 1 | 2026-04-19 | Desktop GUI i18n and UX refresh | - | `master` |
<!-- @@@/auto:session-history --> <!-- @@@/auto:session-history -->
--- ---
@ -37,4 +38,4 @@
- Sessions are appended to journal files - Sessions are appended to journal files
- New journal file created when current exceeds 2000 lines - New journal file created when current exceeds 2000 lines
- Use `add_session.py` to record sessions - Use `add_session.py` to record sessions

View File

@ -68,3 +68,34 @@ Completed the Windows-focused desktop integration pass for `ocswitch desktop` by
- If needed, run a full desktop binary build path (`wails build` or Windows packaging) on a machine with final packaging prerequisites. - If needed, run a full desktop binary build path (`wails build` or Windows packaging) on a machine with final packaging prerequisites.
## Session 1: Desktop GUI i18n and UX refresh
**Date**: 2026-04-19
**Task**: Desktop GUI i18n and UX refresh
**Branch**: `master`
### Summary
Added desktop theme/language preferences across Go and frontend, rebuilt the app into a tabbed localized shell, refreshed CSS theming/layout, updated Wails desktop prefs models, and verified with npm run build plus go test ./internal/app ./internal/desktop.
### Main Changes
(Add details)
### Git Commits
(No commits - planning session)
### Testing
- [OK] (Add test results)
### Status
[OK] **Completed**
### Next Steps
- None - task complete

View File

@ -54,6 +54,63 @@ go build -o ocswitch ./cmd/ocswitch
go run ./cmd/ocswitch --help go run ./cmd/ocswitch --help
``` ```
## 桌面 GUI
仓库同时包含一个基于 Wails 的桌面控制台,适合直接在 Windows 上管理 provider、alias、同步和桌面偏好。
当前桌面界面提供:
- 左侧导航页签:`Overview` / `Providers` / `Aliases` / `Sync` / `Settings`
- 中英文界面切换:`en-US` / `zh-CN` / `system`
- 主题偏好:`light` / `dark` / `system`
- 与浏览器 fallback shell 共用同一套前端
### 构建桌面可执行文件
先安装前端依赖并确认前端能正常构建:
```bash
cd frontend
npm install
npm run build
```
然后在仓库根目录构建桌面应用:
```bash
wails build -tags desktop_wails
```
Windows 下默认产物路径:
```text
build/bin/ocswitch-desktop.exe
```
### 开发模式
如果你想本地调试桌面 GUI
```bash
wails dev -tags desktop_wails
```
### 使用方式
启动桌面应用后,可以直接在界面里完成这些操作:
- 查看当前代理状态、配置路径和 Doctor 摘要
- 管理 provider包括搜索、筛选、编辑和从 OpenCode 导入
- 管理 alias 和 target 绑定关系
- 预览并应用 `ocswitch opencode sync`
- 保存桌面偏好,包括开机启动、托盘行为、通知、主题和语言
如果你已经构建出可执行文件,也可以直接运行:
```bash
./build/bin/ocswitch-desktop.exe
```
## 5 分钟快速上手 ## 5 分钟快速上手
### 1. 添加上游 provider ### 1. 添加上游 provider

View File

@ -21,6 +21,65 @@ Protocol: OpenAI Responses (`POST /v1/responses`) only. Streaming supported.
go build -o ocswitch ./cmd/ocswitch go build -o ocswitch ./cmd/ocswitch
``` ```
## Desktop GUI
The repository also ships a Wails-based desktop control panel for managing
providers, aliases, sync flows, and desktop preferences on Windows.
Current desktop capabilities:
- Sidebar tabs: `Overview` / `Providers` / `Aliases` / `Sync` / `Settings`
- UI language preference: `en-US` / `zh-CN` / `system`
- Theme preference: `light` / `dark` / `system`
- Shared frontend between the desktop shell and the browser fallback shell
### Build the desktop executable
Install frontend dependencies and verify the frontend build first:
```bash
cd frontend
npm install
npm run build
```
Then build the desktop app from the repository root:
```bash
wails build -tags desktop_wails
```
On Windows, the default output path is:
```text
build/bin/ocswitch-desktop.exe
```
### Development mode
To run the desktop GUI in local development mode:
```bash
wails dev -tags desktop_wails
```
### Usage
After launching the desktop app, you can:
- inspect proxy state, config path, and doctor summary
- manage providers with search, filtering, editing, and OpenCode import
- manage aliases and target bindings
- preview and apply `ocswitch opencode sync`
- save desktop preferences including launch-at-login, tray behavior,
notifications, theme, and language
If you already built the executable, you can run it directly:
```bash
./build/bin/ocswitch-desktop.exe
```
## Quick start ## Quick start
```bash ```bash

View File

@ -8,8 +8,10 @@
"name": "ocswitch-desktop-frontend", "name": "ocswitch-desktop-frontend",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"i18next": "^25.0.2",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1" "react-dom": "^18.3.1",
"react-i18next": "^15.5.3"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^18.3.18", "@types/react": "^18.3.18",
@ -254,6 +256,15 @@
"@babel/core": "^7.0.0-0" "@babel/core": "^7.0.0-0"
} }
}, },
"node_modules/@babel/runtime": {
"version": "7.29.2",
"resolved": "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.29.2.tgz",
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/template": { "node_modules/@babel/template": {
"version": "7.28.6", "version": "7.28.6",
"resolved": "https://registry.npmmirror.com/@babel/template/-/template-7.28.6.tgz", "resolved": "https://registry.npmmirror.com/@babel/template/-/template-7.28.6.tgz",
@ -1456,6 +1467,47 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/html-parse-stringify": {
"version": "3.0.1",
"resolved": "https://registry.npmmirror.com/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
"license": "MIT",
"dependencies": {
"void-elements": "3.1.0"
}
},
"node_modules/i18next": {
"version": "25.10.10",
"resolved": "https://registry.npmmirror.com/i18next/-/i18next-25.10.10.tgz",
"integrity": "sha512-cqUW2Z3EkRx7NqSyywjkgCLK7KLCL6IFVFcONG7nVYIJ3ekZ1/N5jUsihHV6Bq37NfhgtczxJcxduELtjTwkuQ==",
"funding": [
{
"type": "individual",
"url": "https://www.locize.com/i18next"
},
{
"type": "individual",
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
},
{
"type": "individual",
"url": "https://www.locize.com"
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.29.2"
},
"peerDependencies": {
"typescript": "^5 || ^6"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/js-tokens": { "node_modules/js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz",
@ -1619,6 +1671,32 @@
"react": "^18.3.1" "react": "^18.3.1"
} }
}, },
"node_modules/react-i18next": {
"version": "15.7.4",
"resolved": "https://registry.npmmirror.com/react-i18next/-/react-i18next-15.7.4.tgz",
"integrity": "sha512-nyU8iKNrI5uDJch0z9+Y5XEr34b0wkyYj3Rp+tfbahxtlswxSCjcUL9H0nqXo9IR3/t5Y5PKIA3fx3MfUyR9Xw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.27.6",
"html-parse-stringify": "^3.0.1"
},
"peerDependencies": {
"i18next": ">= 23.4.0",
"react": ">= 16.8.0",
"typescript": "^5"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
},
"react-native": {
"optional": true
},
"typescript": {
"optional": true
}
}
},
"node_modules/react-refresh": { "node_modules/react-refresh": {
"version": "0.17.0", "version": "0.17.0",
"resolved": "https://registry.npmmirror.com/react-refresh/-/react-refresh-0.17.0.tgz", "resolved": "https://registry.npmmirror.com/react-refresh/-/react-refresh-0.17.0.tgz",
@ -1724,8 +1802,9 @@
"version": "5.9.3", "version": "5.9.3",
"resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@ -1841,6 +1920,15 @@
} }
} }
}, },
"node_modules/void-elements": {
"version": "3.1.0",
"resolved": "https://registry.npmmirror.com/void-elements/-/void-elements-3.1.0.tgz",
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/yallist": { "node_modules/yallist": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmmirror.com/yallist/-/yallist-3.1.1.tgz", "resolved": "https://registry.npmmirror.com/yallist/-/yallist-3.1.1.tgz",

View File

@ -9,7 +9,9 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"i18next": "^25.0.2",
"react": "^18.3.1", "react": "^18.3.1",
"react-i18next": "^15.5.3",
"react-dom": "^18.3.1" "react-dom": "^18.3.1"
}, },
"devDependencies": { "devDependencies": {

View File

@ -1 +1 @@
2f0b51396a8b6c44ea214228c533bdbe 30ca7bcbca69c2b717e8a2e8cbadd5c5

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,34 @@
import i18n from 'i18next'
import { initReactI18next } from 'react-i18next'
import type { LanguagePreference } from '../types'
import en from './locales/en.json'
import zhCN from './locales/zh-CN.json'
export function normalizeSupportedLanguage(language?: string): 'en-US' | 'zh-CN' {
const value = language?.trim().toLowerCase()
if (value?.startsWith('zh')) {
return 'zh-CN'
}
return 'en-US'
}
export function resolveLanguagePreference(language: LanguagePreference, systemLanguage?: string): 'en-US' | 'zh-CN' {
if (language === 'en-US' || language === 'zh-CN') {
return language
}
return normalizeSupportedLanguage(systemLanguage)
}
void i18n.use(initReactI18next).init({
resources: {
'en-US': { translation: en },
'zh-CN': { translation: zhCN },
},
lng: 'en-US',
fallbackLng: 'en-US',
interpolation: {
escapeValue: false,
},
})
export default i18n

View File

@ -0,0 +1,216 @@
{
"app": {
"eyebrow": "ocswitch desktop",
"title": "Native control panel",
"subtitle": "{{version}} · {{shell}} shell · {{path}}",
"loadingConfig": "loading config",
"dev": "dev"
},
"nav": {
"overview": { "title": "Overview", "description": "Status, actions, and environment" },
"providers": { "title": "Providers", "description": "Manage upstream endpoints" },
"aliases": { "title": "Aliases", "description": "Route models across targets" },
"sync": { "title": "Sync", "description": "OpenCode sync and doctor" },
"settings": { "title": "Settings", "description": "Theme, language, desktop behavior" }
},
"actions": {
"refresh": "Refresh",
"runDoctor": "Run doctor",
"startProxy": "Start proxy",
"stopProxy": "Stop proxy",
"save": "Save",
"reset": "Reset",
"preview": "Preview",
"apply": "Apply sync",
"edit": "Edit",
"delete": "Delete",
"enable": "Enable",
"disable": "Disable",
"import": "Import providers",
"bind": "Bind target",
"unbind": "Unbind",
"newProvider": "New provider",
"newAlias": "New alias",
"useInBindForm": "Use in bind form"
},
"status": {
"proxyRunning": "Proxy running",
"proxyIdle": "Proxy idle",
"enabled": "Enabled",
"disabled": "Disabled",
"yes": "Yes",
"no": "No"
},
"messages": {
"refreshing": "Refreshing...",
"fresh": "Fresh",
"saving": "Saving...",
"saved": "Saved",
"running": "Running...",
"applying": "Applying...",
"previewing": "Previewing...",
"importing": "Importing providers...",
"loading": "Loading...",
"noData": "No data yet.",
"warningsSuffix": "Warnings: {{warnings}}",
"proxyStarted": "Proxy started",
"proxyStopped": "Proxy stopped",
"doctorOk": "Doctor OK",
"previewChanges": "Preview shows changes",
"previewNoChanges": "Preview shows no changes",
"syncApplied": "Sync applied",
"syncUpToDate": "Already up to date"
},
"overview": {
"title": "Overview",
"subtitle": "See the current desktop state and run common actions without leaving this screen.",
"actionsTitle": "Actions",
"statsTitle": "System snapshot",
"environmentTitle": "Environment",
"doctorTitle": "Doctor summary",
"debugTitle": "Debug details",
"debugSummary": "Show raw overview JSON",
"providers": "Providers",
"aliases": "Aliases",
"routableAliases": "Routable aliases",
"proxy": "Proxy",
"version": "Version",
"shell": "Shell",
"configPath": "Config path",
"startedAt": "Started at",
"lastError": "Last error",
"doctorReady": "Doctor has not run yet. Use it to inspect config and OpenCode wiring.",
"doctorIssues": "{{count}} issue(s) found",
"doctorHealthy": "No known issues"
},
"providers": {
"title": "Providers",
"subtitle": "Edit upstream providers with a fixed editor and a scrollable list.",
"listTitle": "Provider list",
"formCreateTitle": "Create provider",
"formEditTitle": "Edit provider {{id}}",
"importTitle": "Import from OpenCode",
"search": "Search providers",
"searchPlaceholder": "Filter by id, name, or URL",
"filter": "Status filter",
"filterAll": "All",
"filterEnabled": "Enabled",
"filterDisabled": "Disabled",
"listCount": "{{shown}} shown / {{total}} total",
"empty": "No providers configured yet.",
"noMatches": "No providers match the current filters.",
"id": "Provider id",
"name": "Display name",
"baseUrl": "Base URL",
"apiKey": "API key",
"headers": "Headers",
"models": "Models",
"sourcePath": "Import path",
"saveDisabled": "Save as disabled",
"skipModels": "Skip /v1/models discovery",
"clearHeaders": "Clear saved headers before update",
"overwrite": "Overwrite existing providers",
"apiKeyMasked": "API key",
"apiKeyNotSet": "not set",
"headersNone": "none",
"modelsNone": "none",
"sourceLabel": "Source",
"placeholderId": "su8",
"placeholderName": "SU8",
"placeholderBaseUrl": "https://example.com/v1",
"placeholderApiKey": "Leave blank to keep existing key when editing",
"placeholderHeaders": "X-Token=abc\nX-Workspace=my-team",
"placeholderSourcePath": "Leave blank to use global OpenCode config",
"statusSaved": "Saved provider {{id}}",
"statusEditing": "Editing provider {{id}}. Leave API key blank to keep the current value.",
"statusEnabling": "Enabling provider {{id}}...",
"statusDisabling": "Disabling provider {{id}}...",
"statusEnabled": "Enabled provider {{id}}",
"statusDisabled": "Disabled provider {{id}}",
"statusDeleting": "Deleting provider {{id}}...",
"statusDeleted": "Deleted provider {{id}}",
"statusImportDone": "Import done: imported={{imported}}, skipped={{skipped}}"
},
"aliases": {
"title": "Aliases",
"subtitle": "Separate alias editing from target binding so routing stays readable.",
"listTitle": "Alias list",
"formCreateTitle": "Create alias",
"formEditTitle": "Edit alias {{alias}}",
"bindTitle": "Bind target",
"search": "Search aliases",
"searchPlaceholder": "Filter by alias, display name, provider, or model",
"listCount": "{{shown}} shown / {{total}} total",
"empty": "No aliases configured yet.",
"noMatches": "No aliases match the current filters.",
"alias": "Alias",
"displayName": "Display name",
"aliasForBinding": "Alias for binding",
"providerId": "Provider id",
"model": "Model",
"createDisabled": "Create or keep disabled",
"targetDisabled": "Add target disabled",
"placeholderAlias": "gpt-5.4",
"placeholderDisplayName": "GPT 5.4",
"placeholderAliasBinding": "Existing alias or new alias",
"placeholderProviderId": "su8",
"placeholderModel": "gpt-5.4",
"targets": "Targets",
"routable": "{{available}}/{{total}} routable",
"statusSaved": "Saved alias {{alias}}",
"statusEditing": "Editing alias {{alias}}",
"statusDeleting": "Deleting alias {{alias}}...",
"statusDeleted": "Deleted alias {{alias}}",
"statusBinding": "Binding target...",
"statusBound": "Bound {{provider}}/{{model}} to {{alias}}",
"statusRemoving": "Removing {{provider}}/{{model}} from {{alias}}...",
"statusRemoved": "Removed {{provider}}/{{model}} from {{alias}}",
"statusEnabling": "Enabling {{provider}}/{{model}} on {{alias}}...",
"statusDisabling": "Disabling {{provider}}/{{model}} on {{alias}}...",
"statusEnabled": "Enabled {{provider}}/{{model}} on {{alias}}",
"statusDisabled": "Disabled {{provider}}/{{model}} on {{alias}}",
"noTargets": "No targets bound."
},
"sync": {
"title": "Sync",
"subtitle": "Preview and apply OpenCode sync without leaving the desktop shell.",
"syncTitle": "OpenCode sync",
"doctorTitle": "Doctor report",
"targetPath": "Target path",
"model": "model",
"smallModel": "small_model",
"placeholderTargetPath": "Use default OpenCode config",
"placeholderModel": "ocswitch/<alias>",
"outputTitle": "Last output",
"doctorHint": "Run doctor to inspect config and OpenCode wiring.",
"issuesTitle": "Issues",
"noIssues": "No issues reported.",
"debugTitle": "Raw doctor JSON",
"debugSummary": "Show doctor output"
},
"settings": {
"title": "Settings",
"subtitle": "Persist desktop behavior, theme preference, and language preference.",
"appearanceTitle": "Appearance",
"languageTitle": "Language",
"behaviorTitle": "Desktop behavior",
"aboutTitle": "About",
"theme": "Theme",
"language": "Language",
"themeSystem": "System",
"themeLight": "Light",
"themeDark": "Dark",
"languageSystem": "System",
"languageEnglish": "English",
"languageChinese": "Simplified Chinese",
"resolvedTheme": "Resolved theme",
"resolvedLanguage": "Resolved language",
"launchAtLogin": "Launch at login",
"minimizeToTray": "Minimize to tray / close to background",
"notifications": "Native notifications"
},
"errors": {
"invalidHeaderFormat": "Invalid header \"{{line}}\". Use KEY=VALUE.",
"invalidHeaderName": "Invalid header \"{{line}}\". Header name must not be empty."
}
}

View File

@ -0,0 +1,216 @@
{
"app": {
"eyebrow": "ocswitch desktop",
"title": "桌面控制台",
"subtitle": "{{version}} · {{shell}} shell · {{path}}",
"loadingConfig": "正在读取配置",
"dev": "dev"
},
"nav": {
"overview": { "title": "概览", "description": "状态、操作与环境信息" },
"providers": { "title": "提供商", "description": "管理上游端点" },
"aliases": { "title": "别名路由", "description": "管理别名与目标绑定" },
"sync": { "title": "同步与体检", "description": "OpenCode 同步与 Doctor" },
"settings": { "title": "设置", "description": "主题、语言与桌面偏好" }
},
"actions": {
"refresh": "刷新",
"runDoctor": "运行 Doctor",
"startProxy": "启动代理",
"stopProxy": "停止代理",
"save": "保存",
"reset": "重置",
"preview": "预览",
"apply": "应用同步",
"edit": "编辑",
"delete": "删除",
"enable": "启用",
"disable": "禁用",
"import": "导入提供商",
"bind": "绑定目标",
"unbind": "解绑",
"newProvider": "新建提供商",
"newAlias": "新建别名",
"useInBindForm": "带入绑定表单"
},
"status": {
"proxyRunning": "代理运行中",
"proxyIdle": "代理未运行",
"enabled": "已启用",
"disabled": "已禁用",
"yes": "是",
"no": "否"
},
"messages": {
"refreshing": "正在刷新...",
"fresh": "已刷新",
"saving": "正在保存...",
"saved": "已保存",
"running": "正在执行...",
"applying": "正在应用...",
"previewing": "正在预览...",
"importing": "正在导入提供商...",
"loading": "正在加载...",
"noData": "暂无数据。",
"warningsSuffix": "警告:{{warnings}}",
"proxyStarted": "代理已启动",
"proxyStopped": "代理已停止",
"doctorOk": "Doctor 通过",
"previewChanges": "预览显示有变更",
"previewNoChanges": "预览显示无变更",
"syncApplied": "同步已应用",
"syncUpToDate": "已经是最新状态"
},
"overview": {
"title": "概览",
"subtitle": "在一个页面里快速查看当前桌面状态,并完成常用操作。",
"actionsTitle": "快捷操作",
"statsTitle": "系统快照",
"environmentTitle": "环境信息",
"doctorTitle": "Doctor 摘要",
"debugTitle": "调试详情",
"debugSummary": "查看原始 overview JSON",
"providers": "提供商",
"aliases": "别名",
"routableAliases": "可路由别名",
"proxy": "代理",
"version": "版本",
"shell": "Shell",
"configPath": "配置路径",
"startedAt": "启动时间",
"lastError": "最近错误",
"doctorReady": "尚未运行 Doctor可用来检查配置与 OpenCode 接线情况。",
"doctorIssues": "发现 {{count}} 个问题",
"doctorHealthy": "当前未发现问题"
},
"providers": {
"title": "提供商",
"subtitle": "左侧滚动列表,右侧固定编辑区,减少整页来回滚动。",
"listTitle": "提供商列表",
"formCreateTitle": "新建提供商",
"formEditTitle": "编辑提供商 {{id}}",
"importTitle": "从 OpenCode 导入",
"search": "搜索提供商",
"searchPlaceholder": "按 id、名称或 URL 过滤",
"filter": "状态筛选",
"filterAll": "全部",
"filterEnabled": "已启用",
"filterDisabled": "已禁用",
"listCount": "显示 {{shown}} / 共 {{total}}",
"empty": "还没有配置任何提供商。",
"noMatches": "当前筛选条件下没有匹配的提供商。",
"id": "提供商 id",
"name": "显示名称",
"baseUrl": "Base URL",
"apiKey": "API key",
"headers": "Headers",
"models": "Models",
"sourcePath": "导入路径",
"saveDisabled": "保存为禁用状态",
"skipModels": "跳过 /v1/models 发现",
"clearHeaders": "更新前清空已保存 headers",
"overwrite": "覆盖现有提供商",
"apiKeyMasked": "API key",
"apiKeyNotSet": "未设置",
"headersNone": "无",
"modelsNone": "无",
"sourceLabel": "来源",
"placeholderId": "su8",
"placeholderName": "SU8",
"placeholderBaseUrl": "https://example.com/v1",
"placeholderApiKey": "编辑时留空表示保留现有 key",
"placeholderHeaders": "X-Token=abc\nX-Workspace=my-team",
"placeholderSourcePath": "留空则使用全局 OpenCode 配置",
"statusSaved": "已保存提供商 {{id}}",
"statusEditing": "正在编辑提供商 {{id}}API key 留空会保留当前值。",
"statusEnabling": "正在启用提供商 {{id}}...",
"statusDisabling": "正在禁用提供商 {{id}}...",
"statusEnabled": "已启用提供商 {{id}}",
"statusDisabled": "已禁用提供商 {{id}}",
"statusDeleting": "正在删除提供商 {{id}}...",
"statusDeleted": "已删除提供商 {{id}}",
"statusImportDone": "导入完成imported={{imported}}, skipped={{skipped}}"
},
"aliases": {
"title": "别名路由",
"subtitle": "把别名编辑和目标绑定拆开,路由关系更清晰。",
"listTitle": "别名列表",
"formCreateTitle": "新建别名",
"formEditTitle": "编辑别名 {{alias}}",
"bindTitle": "绑定目标",
"search": "搜索别名",
"searchPlaceholder": "按别名、显示名、provider 或 model 过滤",
"listCount": "显示 {{shown}} / 共 {{total}}",
"empty": "还没有配置任何别名。",
"noMatches": "当前筛选条件下没有匹配的别名。",
"alias": "别名",
"displayName": "显示名称",
"aliasForBinding": "用于绑定的别名",
"providerId": "提供商 id",
"model": "模型",
"createDisabled": "创建或保持为禁用",
"targetDisabled": "新增目标时设为禁用",
"placeholderAlias": "gpt-5.4",
"placeholderDisplayName": "GPT 5.4",
"placeholderAliasBinding": "已有别名或新别名",
"placeholderProviderId": "su8",
"placeholderModel": "gpt-5.4",
"targets": "目标",
"routable": "可路由 {{available}}/{{total}}",
"statusSaved": "已保存别名 {{alias}}",
"statusEditing": "正在编辑别名 {{alias}}",
"statusDeleting": "正在删除别名 {{alias}}...",
"statusDeleted": "已删除别名 {{alias}}",
"statusBinding": "正在绑定目标...",
"statusBound": "已将 {{provider}}/{{model}} 绑定到 {{alias}}",
"statusRemoving": "正在从 {{alias}} 移除 {{provider}}/{{model}}...",
"statusRemoved": "已从 {{alias}} 移除 {{provider}}/{{model}}",
"statusEnabling": "正在启用 {{alias}} 上的 {{provider}}/{{model}}...",
"statusDisabling": "正在禁用 {{alias}} 上的 {{provider}}/{{model}}...",
"statusEnabled": "已启用 {{alias}} 上的 {{provider}}/{{model}}",
"statusDisabled": "已禁用 {{alias}} 上的 {{provider}}/{{model}}",
"noTargets": "还没有绑定目标。"
},
"sync": {
"title": "同步与体检",
"subtitle": "不离开桌面应用即可预览、应用 OpenCode 同步并运行 Doctor。",
"syncTitle": "OpenCode 同步",
"doctorTitle": "Doctor 报告",
"targetPath": "目标路径",
"model": "model",
"smallModel": "small_model",
"placeholderTargetPath": "使用默认 OpenCode 配置",
"placeholderModel": "ocswitch/<alias>",
"outputTitle": "最近输出",
"doctorHint": "运行 Doctor 以检查配置与 OpenCode 接线。",
"issuesTitle": "问题列表",
"noIssues": "未报告问题。",
"debugTitle": "原始 Doctor JSON",
"debugSummary": "查看 Doctor 输出"
},
"settings": {
"title": "设置",
"subtitle": "持久化桌面行为、主题偏好和语言偏好。",
"appearanceTitle": "外观",
"languageTitle": "语言",
"behaviorTitle": "桌面行为",
"aboutTitle": "关于",
"theme": "主题",
"language": "语言",
"themeSystem": "跟随系统",
"themeLight": "浅色",
"themeDark": "深色",
"languageSystem": "跟随系统",
"languageEnglish": "English",
"languageChinese": "简体中文",
"resolvedTheme": "实际主题",
"resolvedLanguage": "实际语言",
"launchAtLogin": "开机启动",
"minimizeToTray": "最小化到托盘 / 关闭时转入后台",
"notifications": "系统通知"
},
"errors": {
"invalidHeaderFormat": "Header \"{{line}}\" 格式无效,请使用 KEY=VALUE。",
"invalidHeaderName": "Header \"{{line}}\" 无效,名称不能为空。"
}
}

View File

@ -1,6 +1,7 @@
import React from 'react' import React from 'react'
import ReactDOM from 'react-dom/client' import ReactDOM from 'react-dom/client'
import App from './App' import App from './App'
import './i18n'
import './styles.css' import './styles.css'
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById('root')!).render(

View File

@ -1,259 +1,550 @@
:root { :root {
color-scheme: dark;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #0b1020; --bg: #f3f7fb;
color: #eef2ff; --bg-accent: rgba(76, 141, 255, 0.08);
--panel: rgba(255, 255, 255, 0.9);
--panel-strong: #ffffff;
--panel-muted: #eef4fb;
--text: #142033;
--text-muted: #5e6f86;
--border: rgba(20, 32, 51, 0.08);
--border-strong: rgba(20, 32, 51, 0.14);
--accent: #2f6df6;
--accent-strong: #1749ba;
--accent-soft: rgba(47, 109, 246, 0.12);
--success: #178154;
--success-soft: rgba(23, 129, 84, 0.14);
--danger: #cf3d4f;
--danger-soft: rgba(207, 61, 79, 0.14);
--shadow: 0 24px 70px rgba(34, 54, 84, 0.12);
}
html[data-theme='dark'] {
--bg: #07111f;
--bg-accent: rgba(77, 156, 255, 0.12);
--panel: rgba(8, 18, 34, 0.88);
--panel-strong: #0c1728;
--panel-muted: #0f2038;
--text: #edf3ff;
--text-muted: #8ca0bd;
--border: rgba(171, 192, 220, 0.12);
--border-strong: rgba(171, 192, 220, 0.22);
--accent: #69a4ff;
--accent-strong: #9bc0ff;
--accent-soft: rgba(105, 164, 255, 0.18);
--success: #65d49a;
--success-soft: rgba(101, 212, 154, 0.16);
--danger: #ff8a96;
--danger-soft: rgba(255, 138, 150, 0.18);
--shadow: 0 24px 72px rgba(0, 0, 0, 0.34);
} }
* { * {
box-sizing: border-box; box-sizing: border-box;
} }
html,
body,
#root {
min-height: 100vh;
}
body { body {
margin: 0; margin: 0;
min-height: 100vh; color: var(--text);
background: background:
radial-gradient(circle at top, rgba(96, 165, 250, 0.18), transparent 28%), radial-gradient(circle at top left, var(--bg-accent), transparent 24%),
linear-gradient(180deg, #0b1020 0%, #0f172a 100%); radial-gradient(circle at top right, rgba(87, 199, 133, 0.07), transparent 18%),
var(--bg);
} }
button, button,
input, input,
textarea { textarea,
select {
font: inherit; font: inherit;
} }
button,
input,
textarea,
select {
transition:
border-color 160ms ease,
background 160ms ease,
color 160ms ease,
box-shadow 160ms ease,
transform 160ms ease;
}
button { button {
cursor: pointer; cursor: pointer;
border: 1px solid rgba(148, 163, 184, 0.28); border: 1px solid var(--border-strong);
border-radius: 12px; border-radius: 14px;
background: rgba(15, 23, 42, 0.85); background: var(--panel-muted);
color: #eef2ff; color: var(--text);
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
} }
button:hover {
border-color: var(--accent);
}
button.primary { button.primary {
background: linear-gradient(135deg, #2563eb, #22c55e); border-color: transparent;
border: none; background: linear-gradient(135deg, var(--accent), var(--accent-strong));
color: #fff;
box-shadow: 0 16px 30px rgba(47, 109, 246, 0.24);
} }
button:disabled { button:disabled {
opacity: 0.65; opacity: 0.65;
cursor: default; cursor: default;
transform: none;
} }
input[type='text'], input[type='text'],
textarea { textarea,
select {
width: 100%; width: 100%;
border: 1px solid rgba(148, 163, 184, 0.28); border: 1px solid var(--border);
border-radius: 10px; border-radius: 14px;
background: rgba(15, 23, 42, 0.75); background: var(--panel-strong);
color: #eef2ff; color: var(--text);
padding: 0.75rem 0.9rem; padding: 0.8rem 0.95rem;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
}
input[type='text']:focus,
textarea:focus,
select:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
} }
textarea { textarea {
resize: vertical; resize: vertical;
min-height: 5.5rem; min-height: 6rem;
} }
.shell { code,
max-width: 1280px; pre {
margin: 0 auto; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', monospace;
padding: 1.25rem;
} }
.hero { .app-shell {
display: grid;
grid-template-columns: 320px minmax(0, 1fr);
min-height: 100vh;
}
.sidebar {
display: flex; display: flex;
justify-content: space-between; flex-direction: column;
align-items: flex-start; gap: 1.25rem;
gap: 1rem; padding: 1.5rem;
margin-bottom: 1rem; border-right: 1px solid var(--border);
background: linear-gradient(180deg, var(--panel), rgba(255, 255, 255, 0.02));
backdrop-filter: blur(20px);
} }
.hero h1, .sidebar-brand,
.panel h2 { .sidebar-panel,
.panel,
.subpanel {
border: 1px solid var(--border);
border-radius: 22px;
background: var(--panel);
box-shadow: var(--shadow);
}
.sidebar-brand {
padding: 1.2rem 1.25rem;
}
.sidebar-panel {
padding: 1rem;
}
.eyebrow,
.section-kicker,
.meta-label,
dt {
margin: 0;
font-size: 0.78rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-muted);
}
.sidebar-brand h1,
.workspace-header h2,
.panel h3,
.subpanel h4 {
margin: 0; margin: 0;
} }
.eyebrow {
margin: 0 0 0.35rem;
font-size: 0.75rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: #93c5fd;
}
.subtle { .subtle {
color: #94a3b8; color: var(--text-muted);
} }
.grid { .small-text {
font-size: 0.9rem;
}
.nav-list {
display: grid; display: grid;
gap: 1rem; gap: 0.7rem;
grid-template-columns: repeat(2, minmax(0, 1fr));
} }
.panel { .nav-item {
background: rgba(15, 23, 42, 0.82); display: grid;
border: 1px solid rgba(148, 163, 184, 0.16); gap: 0.35rem;
text-align: left;
padding: 1rem 1.05rem;
border-radius: 18px; border-radius: 18px;
padding: 1rem; background: transparent;
box-shadow: 0 18px 60px rgba(15, 23, 42, 0.35);
} }
.wide, .nav-item.active {
.overview-panel { border-color: transparent;
grid-column: 1 / -1; background: linear-gradient(135deg, var(--accent-soft), rgba(255, 255, 255, 0.04));
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.04);
} }
.nav-title {
font-weight: 700;
}
.nav-description {
font-size: 0.92rem;
color: var(--text-muted);
}
.sidebar-panel-header,
.panel-header, .panel-header,
.toolbar, .toolbar,
.hero-actions { .hero-actions,
.item-heading,
.subpanel-header,
.list-toolbar {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
gap: 0.75rem; gap: 0.75rem;
} }
.hero-actions, .sidebar-stats,
.toolbar { .stack,
flex-wrap: wrap; .stack-blocks,
} .issue-stack,
.target-list {
.stats {
display: grid;
gap: 0.75rem;
grid-template-columns: repeat(4, minmax(0, 1fr));
margin: 1rem 0;
}
.stat {
padding: 0.9rem;
border-radius: 14px;
background: rgba(30, 41, 59, 0.88);
}
.stat-label {
display: block;
font-size: 0.8rem;
color: #94a3b8;
}
.stat-value {
display: block;
margin-top: 0.25rem;
font-size: 1.45rem;
font-weight: 600;
}
.badge {
padding: 0.35rem 0.7rem;
border-radius: 999px;
font-size: 0.82rem;
}
.badge.live {
background: rgba(34, 197, 94, 0.18);
color: #86efac;
}
.badge.idle {
background: rgba(148, 163, 184, 0.14);
color: #cbd5e1;
}
.stack {
display: grid; display: grid;
gap: 0.85rem; gap: 0.85rem;
} }
.sidebar-stat,
.issue-card,
.target-card,
.item-card,
.stat-card {
border: 1px solid var(--border);
border-radius: 18px;
background: var(--panel-muted);
}
.sidebar-stat {
padding: 0.9rem;
}
.workspace {
display: flex;
flex-direction: column;
min-width: 0;
padding: 1.5rem;
gap: 1rem;
}
.workspace-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
}
.tab-layout {
display: grid;
gap: 1rem;
align-content: start;
}
.overview-layout {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.sync-layout,
.settings-layout {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.panel {
padding: 1.25rem;
min-width: 0;
}
.panel-fill {
min-height: calc(100vh - 11.5rem);
}
.panel-full {
grid-column: 1 / -1;
}
.stats-grid,
.action-grid,
.info-grid,
.item-grid {
display: grid;
gap: 0.85rem;
}
.stats-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
margin-top: 1rem;
}
.action-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.info-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.item-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.stat-card {
padding: 1rem;
}
.stat-value {
display: block;
margin-top: 0.35rem;
font-size: 1.5rem;
font-weight: 700;
}
.badge {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.38rem 0.72rem;
border-radius: 999px;
font-size: 0.82rem;
font-weight: 600;
}
.badge.live {
background: var(--success-soft);
color: var(--success);
}
.badge.idle {
background: rgba(148, 163, 184, 0.16);
color: var(--text-muted);
}
.subpanel {
padding: 1rem;
}
.split-view {
display: grid;
grid-template-columns: minmax(320px, 420px) minmax(0, 1fr);
gap: 1rem;
align-items: start;
margin-top: 1rem;
}
.editor-column,
.list-column {
min-width: 0;
}
.list-column {
display: grid;
gap: 0.85rem;
}
.list-toolbar {
padding: 0.2rem 0;
}
.list-toolbar > label,
.stack label { .stack label {
display: grid; display: grid;
gap: 0.4rem; gap: 0.45rem;
}
.list-toolbar > label {
flex: 1;
}
.list-toolbar-single {
justify-content: stretch;
}
.scroll-list {
display: grid;
gap: 0.85rem;
max-height: calc(100vh - 18rem);
padding-right: 0.25rem;
overflow: auto;
}
.item-card {
padding: 1rem;
}
.item-heading {
align-items: flex-start;
}
.item-heading code {
display: block;
margin-top: 0.2rem;
color: var(--text-muted);
}
.target-card {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.75rem;
padding: 0.8rem 0.9rem;
} }
.checkbox-row { .checkbox-row {
display: flex !important; display: flex !important;
align-items: center; align-items: center;
gap: 0.65rem; gap: 0.7rem;
}
.checkbox-row input {
width: auto;
}
.inline-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
border: 1px dashed var(--border-strong);
border-radius: 16px;
padding: 0.85rem 0.95rem;
}
.details-toggle summary {
cursor: pointer;
color: var(--text-muted);
} }
.details { .details {
margin: 0; margin: 0.8rem 0 0;
padding: 0.9rem; padding: 1rem;
border-radius: 14px; border-radius: 18px;
background: rgba(2, 6, 23, 0.72); background: #06101d;
color: #cbd5e1; color: #d7e5fb;
overflow: auto; overflow: auto;
white-space: pre-wrap; white-space: pre-wrap;
word-break: break-word; word-break: break-word;
} }
.list {
display: grid;
gap: 0.75rem;
margin-top: 0.9rem;
}
.compact-list {
margin-top: 0;
}
.split-layout {
display: grid;
grid-template-columns: minmax(300px, 420px) minmax(0, 1fr);
gap: 1rem;
align-items: start;
}
.item-card {
display: grid;
gap: 0.45rem;
padding: 0.9rem;
border-radius: 14px;
background: rgba(30, 41, 59, 0.82);
}
.item-card strong {
font-size: 1rem;
}
.item-card code {
font-family: ui-monospace, Menlo, Monaco, Consolas, monospace;
}
.item-meta {
color: #cbd5e1;
}
.nested-card {
background: rgba(15, 23, 42, 0.74);
padding: 0.7rem 0.85rem;
}
.tone-error { .tone-error {
color: #fca5a5; color: var(--danger);
} }
.tone-ok { .tone-ok {
color: #86efac; color: var(--success);
}
dt {
margin-bottom: 0.35rem;
}
dd {
margin: 0;
font-weight: 600;
word-break: break-word;
}
@media (max-width: 1180px) {
.app-shell {
grid-template-columns: 1fr;
}
.sidebar {
border-right: 0;
border-bottom: 1px solid var(--border);
}
.nav-list {
grid-template-columns: repeat(5, minmax(0, 1fr));
}
.nav-description {
display: none;
}
.overview-layout,
.sync-layout,
.settings-layout,
.stats-grid,
.info-grid,
.item-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
} }
@media (max-width: 860px) { @media (max-width: 860px) {
.hero, .workspace,
.sidebar {
padding: 1rem;
}
.workspace-header,
.panel-header, .panel-header,
.toolbar { .hero-actions,
.toolbar,
.subpanel-header,
.list-toolbar,
.target-card,
.item-heading {
flex-direction: column; flex-direction: column;
align-items: stretch; align-items: stretch;
} }
.grid, .nav-list,
.stats { .overview-layout,
.sync-layout,
.settings-layout,
.stats-grid,
.info-grid,
.item-grid,
.action-grid,
.split-view {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.split-layout { .scroll-list {
grid-template-columns: 1fr; max-height: none;
overflow: visible;
}
.panel-fill {
min-height: auto;
} }
} }

View File

@ -1,7 +1,13 @@
export type ThemePreference = 'system' | 'light' | 'dark'
export type LanguagePreference = 'system' | 'en-US' | 'zh-CN'
export type DesktopPrefsView = { export type DesktopPrefsView = {
launchAtLogin: boolean launchAtLogin: boolean
minimizeToTray: boolean minimizeToTray: boolean
notifications: boolean notifications: boolean
theme: ThemePreference
language: LanguagePreference
} }
export type DesktopPrefsSaveResult = { export type DesktopPrefsSaveResult = {

View File

@ -94,6 +94,8 @@ export namespace app {
launchAtLogin: boolean; launchAtLogin: boolean;
minimizeToTray: boolean; minimizeToTray: boolean;
notifications: boolean; notifications: boolean;
theme: string;
language: string;
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
return new DesktopPrefsInput(source); return new DesktopPrefsInput(source);
@ -104,12 +106,16 @@ export namespace app {
this.launchAtLogin = source["launchAtLogin"]; this.launchAtLogin = source["launchAtLogin"];
this.minimizeToTray = source["minimizeToTray"]; this.minimizeToTray = source["minimizeToTray"];
this.notifications = source["notifications"]; this.notifications = source["notifications"];
this.theme = source["theme"];
this.language = source["language"];
} }
} }
export class DesktopPrefsView { export class DesktopPrefsView {
launchAtLogin: boolean; launchAtLogin: boolean;
minimizeToTray: boolean; minimizeToTray: boolean;
notifications: boolean; notifications: boolean;
theme: string;
language: string;
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
return new DesktopPrefsView(source); return new DesktopPrefsView(source);
@ -120,6 +126,8 @@ export namespace app {
this.launchAtLogin = source["launchAtLogin"]; this.launchAtLogin = source["launchAtLogin"];
this.minimizeToTray = source["minimizeToTray"]; this.minimizeToTray = source["minimizeToTray"];
this.notifications = source["notifications"]; this.notifications = source["notifications"];
this.theme = source["theme"];
this.language = source["language"];
} }
} }
export class DesktopPrefsSaveResult { export class DesktopPrefsSaveResult {

View File

@ -253,6 +253,8 @@ func (s *Service) SaveDesktopPrefs(ctx context.Context, in DesktopPrefsInput) (D
LaunchAtLogin: in.LaunchAtLogin, LaunchAtLogin: in.LaunchAtLogin,
MinimizeToTray: in.MinimizeToTray, MinimizeToTray: in.MinimizeToTray,
Notifications: in.Notifications, Notifications: in.Notifications,
Theme: normalizeThemePreference(in.Theme),
Language: normalizeLanguagePreference(in.Language),
} }
if err := cfg.Save(); err != nil { if err := cfg.Save(); err != nil {
return DesktopPrefsView{}, err return DesktopPrefsView{}, err
@ -387,6 +389,28 @@ func desktopPrefsView(prefs config.Desktop) DesktopPrefsView {
LaunchAtLogin: prefs.LaunchAtLogin, LaunchAtLogin: prefs.LaunchAtLogin,
MinimizeToTray: prefs.MinimizeToTray, MinimizeToTray: prefs.MinimizeToTray,
Notifications: prefs.Notifications, Notifications: prefs.Notifications,
Theme: normalizeThemePreference(prefs.Theme),
Language: normalizeLanguagePreference(prefs.Language),
}
}
func normalizeThemePreference(value string) string {
trimmed := strings.TrimSpace(value)
switch trimmed {
case "light", "dark":
return trimmed
default:
return "system"
}
}
func normalizeLanguagePreference(value string) string {
trimmed := strings.TrimSpace(value)
switch trimmed {
case "en-US", "zh-CN":
return trimmed
default:
return "system"
} }
} }

View File

@ -25,11 +25,13 @@ func TestSaveDesktopPrefsPersistsToConfig(t *testing.T) {
LaunchAtLogin: true, LaunchAtLogin: true,
MinimizeToTray: true, MinimizeToTray: true,
Notifications: true, Notifications: true,
Theme: "dark",
Language: "zh-CN",
}) })
if err != nil { if err != nil {
t.Fatalf("SaveDesktopPrefs() error = %v", err) t.Fatalf("SaveDesktopPrefs() error = %v", err)
} }
if !prefs.LaunchAtLogin || !prefs.MinimizeToTray || !prefs.Notifications { if !prefs.LaunchAtLogin || !prefs.MinimizeToTray || !prefs.Notifications || prefs.Theme != "dark" || prefs.Language != "zh-CN" {
t.Fatalf("SaveDesktopPrefs() = %#v", prefs) t.Fatalf("SaveDesktopPrefs() = %#v", prefs)
} }
@ -37,11 +39,29 @@ func TestSaveDesktopPrefsPersistsToConfig(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("config.Load() error = %v", err) t.Fatalf("config.Load() error = %v", err)
} }
if !cfg.Desktop.LaunchAtLogin || !cfg.Desktop.MinimizeToTray || !cfg.Desktop.Notifications { if !cfg.Desktop.LaunchAtLogin || !cfg.Desktop.MinimizeToTray || !cfg.Desktop.Notifications || cfg.Desktop.Theme != "dark" || cfg.Desktop.Language != "zh-CN" {
t.Fatalf("persisted desktop prefs = %#v", cfg.Desktop) t.Fatalf("persisted desktop prefs = %#v", cfg.Desktop)
} }
} }
func TestSaveDesktopPrefsNormalizesUnknownThemeAndLanguage(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "ocswitch.json")
svc := NewService(path)
prefs, err := svc.SaveDesktopPrefs(context.Background(), DesktopPrefsInput{
Theme: "night-mode",
Language: "fr-FR",
})
if err != nil {
t.Fatalf("SaveDesktopPrefs() error = %v", err)
}
if prefs.Theme != "system" || prefs.Language != "system" {
t.Fatalf("normalized prefs = %#v", prefs)
}
}
func TestStartStopProxyUpdatesStatus(t *testing.T) { func TestStartStopProxyUpdatesStatus(t *testing.T) {
t.Parallel() t.Parallel()

View File

@ -95,9 +95,11 @@ type SyncResult struct {
} }
type DesktopPrefsView struct { type DesktopPrefsView struct {
LaunchAtLogin bool `json:"launchAtLogin"` LaunchAtLogin bool `json:"launchAtLogin"`
MinimizeToTray bool `json:"minimizeToTray"` MinimizeToTray bool `json:"minimizeToTray"`
Notifications bool `json:"notifications"` Notifications bool `json:"notifications"`
Theme string `json:"theme"`
Language string `json:"language"`
} }
type DesktopPrefsSaveResult struct { type DesktopPrefsSaveResult struct {
@ -106,9 +108,11 @@ type DesktopPrefsSaveResult struct {
} }
type DesktopPrefsInput struct { type DesktopPrefsInput struct {
LaunchAtLogin bool `json:"launchAtLogin"` LaunchAtLogin bool `json:"launchAtLogin"`
MinimizeToTray bool `json:"minimizeToTray"` MinimizeToTray bool `json:"minimizeToTray"`
Notifications bool `json:"notifications"` Notifications bool `json:"notifications"`
Theme string `json:"theme"`
Language string `json:"language"`
} }
type ProviderUpsertInput struct { type ProviderUpsertInput struct {

View File

@ -58,9 +58,11 @@ type Server struct {
// Desktop holds desktop-shell user preferences. // Desktop holds desktop-shell user preferences.
type Desktop struct { type Desktop struct {
LaunchAtLogin bool `json:"launch_at_login,omitempty"` LaunchAtLogin bool `json:"launch_at_login,omitempty"`
MinimizeToTray bool `json:"minimize_to_tray,omitempty"` MinimizeToTray bool `json:"minimize_to_tray,omitempty"`
Notifications bool `json:"notifications,omitempty"` Notifications bool `json:"notifications,omitempty"`
Theme string `json:"theme,omitempty"`
Language string `json:"language,omitempty"`
} }
// Config is the on-disk ocswitch config. // Config is the on-disk ocswitch config.

View File

@ -77,7 +77,7 @@ func TestDesktopHTTPHandlerServesOverviewAndStaticApp(t *testing.T) {
}) })
t.Run("save desktop prefs", func(t *testing.T) { t.Run("save desktop prefs", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/desktop-prefs", strings.NewReader(`{"launchAtLogin":true,"minimizeToTray":true,"notifications":true}`)) req := httptest.NewRequest(http.MethodPost, "/api/desktop-prefs", strings.NewReader(`{"launchAtLogin":true,"minimizeToTray":true,"notifications":true,"theme":"dark","language":"zh-CN"}`))
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder() resp := httptest.NewRecorder()
h.ServeHTTP(resp, req) h.ServeHTTP(resp, req)
@ -89,7 +89,7 @@ func TestDesktopHTTPHandlerServesOverviewAndStaticApp(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("config.Load() error = %v", err) t.Fatalf("config.Load() error = %v", err)
} }
if !loaded.Desktop.LaunchAtLogin || !loaded.Desktop.MinimizeToTray || !loaded.Desktop.Notifications { if !loaded.Desktop.LaunchAtLogin || !loaded.Desktop.MinimizeToTray || !loaded.Desktop.Notifications || loaded.Desktop.Theme != "dark" || loaded.Desktop.Language != "zh-CN" {
t.Fatalf("persisted desktop prefs = %#v", loaded.Desktop) t.Fatalf("persisted desktop prefs = %#v", loaded.Desktop)
} }
}) })