diff --git a/app.go b/app.go index c266398..5483c38 100644 --- a/app.go +++ b/app.go @@ -5,12 +5,16 @@ import ( "sgg-ai-skill-manager/internal/domain" "sgg-ai-skill-manager/internal/service" + "sgg-ai-skill-manager/internal/tray" + + wailsruntime "github.com/wailsapp/wails/v2/pkg/runtime" ) type App struct { - ctx context.Context - service *service.Service - initErr error + ctx context.Context + service *service.Service + initErr error + trayStop func() } func NewApp() *App { @@ -23,6 +27,30 @@ func (a *App) startup(ctx context.Context) { if a.service != nil { a.service.StartAutoUpdate(ctx) } + a.startTray(ctx) +} + +func (a *App) shutdown(context.Context) { + if a.trayStop != nil { + a.trayStop() + a.trayStop = nil + } +} + +func (a *App) startTray(ctx context.Context) { + stop, err := tray.Start(ctx, tray.Config{ + Tooltip: appTitle, + IconPath: defaultTrayIconPath, + OnOpen: func() { + wailsruntime.WindowShow(ctx) + wailsruntime.WindowUnminimise(ctx) + }, + }) + if err != nil { + wailsruntime.LogErrorf(ctx, "failed to start tray icon: %v", err) + return + } + a.trayStop = stop } func (a *App) LoadConfig() (domain.Config, error) { diff --git a/app_options.go b/app_options.go new file mode 100644 index 0000000..c0f4c4d --- /dev/null +++ b/app_options.go @@ -0,0 +1,31 @@ +package main + +import ( + "io/fs" + + "github.com/wailsapp/wails/v2/pkg/options" + "github.com/wailsapp/wails/v2/pkg/options/assetserver" +) + +const ( + appTitle = "SGG AI 技能管理器" + defaultTrayIconPath = "build/windows/icon.ico" +) + +func newAppOptions(app *App, assets fs.FS) *options.App { + return &options.App{ + Title: appTitle, + Width: 1024, + Height: 768, + HideWindowOnClose: true, + AssetServer: &assetserver.Options{ + Assets: assets, + }, + BackgroundColour: &options.RGBA{R: 27, G: 38, B: 54, A: 1}, + OnStartup: app.startup, + OnShutdown: app.shutdown, + Bind: []interface{}{ + app, + }, + } +} diff --git a/app_options_test.go b/app_options_test.go new file mode 100644 index 0000000..88037c8 --- /dev/null +++ b/app_options_test.go @@ -0,0 +1,23 @@ +package main + +import "testing" + +func TestNewAppOptionsHidesWindowOnClose(t *testing.T) { + app := &App{} + + options := newAppOptions(app, assets) + + if !options.HideWindowOnClose { + t.Fatal("expected closing the window to hide it instead of quitting") + } +} + +func TestNewAppOptionsStopsTrayOnShutdown(t *testing.T) { + app := &App{} + + options := newAppOptions(app, assets) + + if options.OnShutdown == nil { + t.Fatal("expected shutdown hook to clean up the tray icon") + } +} diff --git a/frontend/package.json b/frontend/package.json index 46da039..014f58f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,6 +6,7 @@ "scripts": { "dev": "vite", "build": "tsc && vite build", + "test:ui-language": "node scripts/check-chinese-ui.mjs", "preview": "vite preview" }, "dependencies": { diff --git a/frontend/scripts/check-chinese-ui.mjs b/frontend/scripts/check-chinese-ui.mjs new file mode 100644 index 0000000..8acc235 --- /dev/null +++ b/frontend/scripts/check-chinese-ui.mjs @@ -0,0 +1,94 @@ +import {readFileSync} from 'node:fs'; +import {fileURLToPath} from 'node:url'; +import {resolve} from 'node:path'; + +const root = resolve(fileURLToPath(new URL('../..', import.meta.url))); +const appSource = readFileSync(resolve(root, 'frontend/src/App.tsx'), 'utf8'); +const mainSource = readFileSync(resolve(root, 'main.go'), 'utf8'); +const appOptionsSource = readFileSync(resolve(root, 'app_options.go'), 'utf8'); +const backendEntrySource = `${mainSource}\n${appOptionsSource}`; + +const forbiddenUiText = [ + /

Skill Manager<\/h1>/, + />\s*Config\s*\s*Remote\s*\s*Local\s*\s*Check Updates\s*Gitea Base URL<\/span>/, + /Organization<\/span>/, + /Auth Type<\/span>/, + />\s*Password\s*Username<\/span>/, + /Credential Key<\/span>/, + /Auto update<\/span>/, + /Check on startup<\/span>/, + /Interval Minutes<\/span>/, + />\s*Save\s*\s*Test\s*\s*Refresh\s*Description<\/th>/, + /Branch<\/th>/, + /Status<\/th>/, + /Action<\/th>/, + /Actions<\/th>/, + />\s*Update\s*\s*Download\s*No remote skillsNo local skillsnot installed 0) { + console.error(failures.join('\n')); + process.exit(1); +} diff --git a/frontend/src/App.css b/frontend/src/App.css index 9ba5835..8d59755 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -131,6 +131,13 @@ button.danger:hover:not(:disabled) { line-height: 28px; } +.topbar-actions { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} + .notice { margin-bottom: 12px; padding: 10px 12px; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 222a7fe..eef5ded 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -11,6 +11,7 @@ import { Trash2, Unlink, } from 'lucide-react'; +import {BrowserOpenURL} from '../wailsjs/runtime/runtime'; import './App.css'; import {api} from './api'; import {Config, RemoteSkill, SkillState, defaultConfig, saveConfigRequest} from './types'; @@ -18,6 +19,29 @@ import {domain} from '../wailsjs/go/models'; type Tab = 'config' | 'remote' | 'local'; +const statusLabels: Record = { + remote: '远程', + downloaded: '已下载', + check_failed: '检查失败', + installed: '已安装', + not_installed: '未安装', + conflict: '冲突', + unknown: '未知', + local: '就绪', +}; + +const localizedMessages: Record = { + connected: '已连接', + 'gitea baseURL is required': '请填写 Gitea 服务地址', + 'gitea org is required': '请填写组织', + 'gitea username is required': '请填写用户名', + 'authType must be password or token': '认证方式必须是密码或 Token', + 'update interval must be positive': '检查间隔必须大于 0', + 'local changes present': '存在本地改动', +}; + +const projectRepositoryURL = 'http://10.1.0.1:3000/sgg-sgg-tools/sgg-sgg-ai-skill-manage-windows'; + function App() { const [activeTab, setActiveTab] = useState('config'); const [config, setConfig] = useState(defaultConfig()); @@ -57,7 +81,7 @@ function App() { async function refreshRemote() { await run('remote:refresh', async () => { setRemoteSkills(await api.listRemoteSkills()); - setMessage('Remote list refreshed'); + setMessage('远程列表已刷新'); }); } @@ -70,14 +94,14 @@ function App() { await api.saveConfig(saveConfigRequest(config, password, token)); setPassword(''); setToken(''); - setMessage('Config saved'); + setMessage('配置已保存'); }); } async function testConnection() { await run('config:test', async () => { const result = await api.testConnection(saveConfigRequest(config, password, token)); - setMessage(result.ok ? `Connected as ${result.username}` : result.message); + setMessage(result.ok ? (result.username ? `已连接:${result.username}` : '已连接') : localizeMessage(result.message)); }); } @@ -85,10 +109,10 @@ function App() { await run(`remote:${skill.name}`, async () => { if (skill.isDownloaded) { await api.updateSkill(config.gitea.org, skill.name); - setMessage(`${skill.name} updated`); + setMessage(`${skill.name} 已更新`); } else { await api.downloadSkill(skill); - setMessage(`${skill.name} downloaded`); + setMessage(`${skill.name} 已下载`); } await refreshLocal(); await refreshRemote(); @@ -99,7 +123,7 @@ function App() { await run(`local:update:${skill.repo}`, async () => { await api.updateSkill(skill.org, skill.repo); await refreshLocal(); - setMessage(`${skill.repo} updated`); + setMessage(`${skill.repo} 已更新`); }); } @@ -107,7 +131,7 @@ function App() { await run(`install:${targetID}:${skill.repo}`, async () => { await api.installSkill(skill.org, skill.repo, targetID); await refreshLocal(); - setMessage(`${skill.repo} installed to ${targetLabel(targetID)}`); + setMessage(`${skill.repo} 已安装到 ${targetLabel(targetID)}`); }); } @@ -115,18 +139,18 @@ function App() { await run(`uninstall:${targetID}:${skill.repo}`, async () => { await api.uninstallSkill(skill.org, skill.repo, targetID); await refreshLocal(); - setMessage(`${skill.repo} uninstalled from ${targetLabel(targetID)}`); + setMessage(`${skill.repo} 已从 ${targetLabel(targetID)} 卸载`); }); } async function deleteSkill(skill: SkillState) { - if (!window.confirm(`Delete local skill ${skill.repo}?`)) { + if (!window.confirm(`确认删除本地技能 ${skill.repo}?`)) { return; } await run(`delete:${skill.repo}`, async () => { await api.deleteSkill(skill.org, skill.repo); await refreshLocal(); - setMessage(`${skill.repo} deleted`); + setMessage(`${skill.repo} 已删除`); }); } @@ -155,7 +179,7 @@ function App() { try { await action(); } catch (err) { - setError(err instanceof Error ? err.message : String(err)); + setError(localizeMessage(err instanceof Error ? err.message : String(err))); } finally { setBusy(''); } @@ -173,19 +197,19 @@ function App() {
S
-

Skill Manager

+

技能管理器

Gitea / Codex / Claude
-