This commit is contained in:
2026-05-13 20:40:14 +08:00
parent bd13c842a8
commit c8a75ef2d5
17 changed files with 920 additions and 93 deletions

View File

@@ -6,6 +6,7 @@
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"test:ui-language": "node scripts/check-chinese-ui.mjs",
"preview": "vite preview"
},
"dependencies": {

View File

@@ -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 = [
/<h1>Skill Manager<\/h1>/,
/>\s*Config\s*</,
/>\s*Remote\s*</,
/>\s*Local\s*</,
/>\s*Check Updates\s*</,
/<span>Gitea Base URL<\/span>/,
/<span>Organization<\/span>/,
/<span>Auth Type<\/span>/,
/>\s*Password\s*</,
/<span>Username<\/span>/,
/<span>Credential Key<\/span>/,
/<span>Auto update<\/span>/,
/<span>Check on startup<\/span>/,
/<span>Interval Minutes<\/span>/,
/>\s*Save\s*</,
/>\s*Test\s*</,
/placeholder="Search repositories"/,
/>\s*Refresh\s*</,
/<th>Description<\/th>/,
/<th>Branch<\/th>/,
/<th>Status<\/th>/,
/<th>Action<\/th>/,
/<th>Actions<\/th>/,
/>\s*Update\s*</,
/>\s*Download\s*</,
/title="Open folder"/,
/>No remote skills</,
/>No local skills</,
/return 'Remote Market';/,
/return 'Local Skills';/,
/return 'Configuration';/,
/Delete local skill/,
/title="Delete local"/,
/Remote list refreshed/,
/Config saved/,
/Connected as/,
/installed to/,
/uninstalled from/,
/>not installed</,
];
const requiredChineseText = [
'技能管理器',
'配置',
'远程市场',
'本地技能',
'检查更新',
'保存',
'测试连接',
'搜索仓库',
'刷新',
'下载',
'更新',
'操作',
'暂无远程技能',
'暂无本地技能',
'已下载',
'已安装',
'未安装',
];
const failures = [];
for (const pattern of forbiddenUiText) {
if (pattern.test(appSource)) {
failures.push(`App.tsx still matches English UI pattern: ${pattern}`);
}
}
for (const text of requiredChineseText) {
if (!appSource.includes(text)) {
failures.push(`App.tsx is missing Chinese UI text: ${text}`);
}
}
if (!backendEntrySource.includes('SGG AI 技能管理器')) {
failures.push('window title is not Chinese');
}
if (failures.length > 0) {
console.error(failures.join('\n'));
process.exit(1);
}

View File

@@ -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;

View File

@@ -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<string, string> = {
remote: '远程',
downloaded: '已下载',
check_failed: '检查失败',
installed: '已安装',
not_installed: '未安装',
conflict: '冲突',
unknown: '未知',
local: '就绪',
};
const localizedMessages: Record<string, string> = {
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<Tab>('config');
const [config, setConfig] = useState<Config>(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() {
<div className="brand">
<div className="brand-mark">S</div>
<div>
<h1>Skill Manager</h1>
<h1></h1>
<span>Gitea / Codex / Claude</span>
</div>
</div>
<nav className="tabs" aria-label="Primary">
<nav className="tabs" aria-label="主导航">
<button className={activeTab === 'config' ? 'active' : ''} onClick={() => setActiveTab('config')}>
<Settings size={17} /> Config
<Settings size={17} />
</button>
<button className={activeTab === 'remote' ? 'active' : ''} onClick={() => setActiveTab('remote')}>
<Download size={17} /> Remote
<Download size={17} />
</button>
<button className={activeTab === 'local' ? 'active' : ''} onClick={() => setActiveTab('local')}>
<FolderOpen size={17} /> Local
<FolderOpen size={17} />
</button>
</nav>
</aside>
@@ -196,9 +220,14 @@ function App() {
<h2>{pageTitle(activeTab)}</h2>
<span>{statusSummary(remoteSkills, localSkills)}</span>
</div>
<button className="ghost" onClick={() => run('auto:update', async () => { await api.runAutoUpdate(); await refreshLocal(); })} disabled={busy !== ''}>
<RefreshCw size={16} /> Check Updates
</button>
<div className="topbar-actions">
<button className="ghost" onClick={() => run('auto:update', async () => { await api.runAutoUpdate(); await refreshLocal(); })} disabled={busy !== ''}>
<RefreshCw size={16} />
</button>
<button className="ghost icon-only" onClick={() => BrowserOpenURL(projectRepositoryURL)} title="打开项目仓库" aria-label="打开项目仓库">
<GithubIcon size={17} />
</button>
</div>
</header>
{(message || error) && (
@@ -211,7 +240,7 @@ function App() {
<section className="panel">
<div className="form-grid">
<label>
<span>Gitea Base URL</span>
<span>Gitea </span>
<input
value={config.gitea.baseURL || ''}
placeholder="https://gitea.example.com"
@@ -219,20 +248,20 @@ function App() {
/>
</label>
<label>
<span>Organization</span>
<span></span>
<input
value={config.gitea.org || ''}
onChange={(event) => updateConfig((next) => { next.gitea.org = event.target.value; })}
/>
</label>
<div className="field">
<span>Auth Type</span>
<span></span>
<div className="segmented">
<button
className={config.gitea.authType !== 'token' ? 'active' : ''}
onClick={() => updateConfig((next) => { next.gitea.authType = 'password'; })}
>
Password
</button>
<button
className={config.gitea.authType === 'token' ? 'active' : ''}
@@ -243,7 +272,7 @@ function App() {
</div>
</div>
<label>
<span>Username</span>
<span></span>
<input
value={config.gitea.username || ''}
onChange={(event) => updateConfig((next) => { next.gitea.username = event.target.value; })}
@@ -256,24 +285,17 @@ function App() {
</label>
) : (
<label>
<span>Password</span>
<span></span>
<input type="password" value={password} onChange={(event) => setPassword(event.target.value)} />
</label>
)}
<label>
<span>Credential Key</span>
<input
value={config.gitea.credentialKey || ''}
onChange={(event) => updateConfig((next) => { next.gitea.credentialKey = event.target.value; })}
/>
</label>
<label className="checkbox">
<input
type="checkbox"
checked={Boolean(config.update.autoUpdate)}
onChange={(event) => updateConfig((next) => { next.update.autoUpdate = event.target.checked; })}
/>
<span>Auto update</span>
<span></span>
</label>
<label className="checkbox">
<input
@@ -281,10 +303,10 @@ function App() {
checked={Boolean(config.update.checkOnStartup)}
onChange={(event) => updateConfig((next) => { next.update.checkOnStartup = event.target.checked; })}
/>
<span>Check on startup</span>
<span></span>
</label>
<label>
<span>Interval Minutes</span>
<span></span>
<input
type="number"
min="1"
@@ -295,10 +317,10 @@ function App() {
</div>
<div className="toolbar">
<button onClick={saveConfig} disabled={busy !== ''}>
<Save size={16} /> Save
<Save size={16} />
</button>
<button className="secondary" onClick={testConnection} disabled={busy !== ''}>
<PlugZap size={16} /> Test
<PlugZap size={16} />
</button>
</div>
</section>
@@ -310,22 +332,22 @@ function App() {
<input
className="search"
value={search}
placeholder="Search repositories"
placeholder="搜索仓库"
onChange={(event) => setSearch(event.target.value)}
/>
<button onClick={refreshRemote} disabled={busy !== ''}>
<RefreshCw size={16} /> Refresh
<RefreshCw size={16} />
</button>
</div>
<div className="table-wrap">
<table>
<thead>
<tr>
<th>Name</th>
<th>Description</th>
<th>Branch</th>
<th>Status</th>
<th>Action</th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
@@ -337,12 +359,12 @@ function App() {
<td>{badge(skill.status || (skill.isDownloaded ? 'downloaded' : 'remote'))}</td>
<td>
<div className="row-actions">
<button onClick={() => downloadOrUpdate(skill)} disabled={busy !== ''} title={skill.isDownloaded ? 'Update' : 'Download'}>
<button onClick={() => downloadOrUpdate(skill)} disabled={busy !== ''} title={skill.isDownloaded ? '更新' : '下载'}>
{skill.isDownloaded ? <RefreshCw size={15} /> : <Download size={15} />}
{skill.isDownloaded ? 'Update' : 'Download'}
{skill.isDownloaded ? '更新' : '下载'}
</button>
{skill.isDownloaded && (
<button className="icon-only" onClick={() => openRemoteFolder(skill)} title="Open folder">
<button className="icon-only" onClick={() => openRemoteFolder(skill)} title="打开目录">
<FolderOpen size={15} />
</button>
)}
@@ -352,7 +374,7 @@ function App() {
))}
{filteredRemote.length === 0 && (
<tr>
<td colSpan={5} className="empty">No remote skills</td>
<td colSpan={5} className="empty"></td>
</tr>
)}
</tbody>
@@ -365,20 +387,20 @@ function App() {
<section className="panel">
<div className="toolbar">
<button onClick={refreshLocal} disabled={busy !== ''}>
<RefreshCw size={16} /> Refresh
<RefreshCw size={16} />
</button>
</div>
<div className="table-wrap">
<table>
<thead>
<tr>
<th>Name</th>
<th>Path</th>
<th>Commit</th>
<th>Status</th>
<th></th>
<th></th>
<th></th>
<th></th>
<th>Codex</th>
<th>Claude</th>
<th>Actions</th>
<th></th>
</tr>
</thead>
<tbody>
@@ -387,39 +409,39 @@ function App() {
<td className="name-cell">{skill.repo}</td>
<td className="path-cell" title={skill.localPath}>{skill.localPath}</td>
<td>{shortCommit(skill.currentCommit)}</td>
<td>{skill.lastError ? <span className="badge error">{skill.lastError}</span> : <span className="badge local">ready</span>}</td>
<td>{skill.lastError ? <span className="badge error">{localizeMessage(skill.lastError)}</span> : <span className="badge local"></span>}</td>
<td>{targetBadge(skill, 'codex')}</td>
<td>{targetBadge(skill, 'claude')}</td>
<td>
<div className="row-actions">
<button onClick={() => updateLocal(skill)} disabled={busy !== ''} title="Update">
<RefreshCw size={15} /> Update
<button onClick={() => updateLocal(skill)} disabled={busy !== ''} title="更新">
<RefreshCw size={15} />
</button>
{targetInstalled(skill, 'codex') ? (
<button className="secondary" onClick={() => uninstall(skill, 'codex')} disabled={busy !== ''} title="Uninstall Codex">
<button className="secondary" onClick={() => uninstall(skill, 'codex')} disabled={busy !== ''} title="卸载 Codex">
<Unlink size={15} /> Codex
</button>
) : (
<button className="secondary" onClick={() => install(skill, 'codex')} disabled={busy !== ''} title="Install Codex">
<button className="secondary" onClick={() => install(skill, 'codex')} disabled={busy !== ''} title="安装 Codex">
<Link2 size={15} /> Codex
</button>
)}
{targetInstalled(skill, 'claude') ? (
<button className="secondary" onClick={() => uninstall(skill, 'claude')} disabled={busy !== ''} title="Uninstall Claude">
<button className="secondary" onClick={() => uninstall(skill, 'claude')} disabled={busy !== ''} title="卸载 Claude">
<Unlink size={15} /> Claude
</button>
) : (
<button className="secondary" onClick={() => install(skill, 'claude')} disabled={busy !== ''} title="Install Claude">
<button className="secondary" onClick={() => install(skill, 'claude')} disabled={busy !== ''} title="安装 Claude">
<Link2 size={15} /> Claude
</button>
)}
<button className="icon-only" onClick={() => openCode(skill)} disabled={busy !== ''} title="Open VS Code">
<button className="icon-only" onClick={() => openCode(skill)} disabled={busy !== ''} title="打开 VS Code">
<Code2 size={15} />
</button>
<button className="icon-only" onClick={() => openFolder(skill)} disabled={busy !== ''} title="Open folder">
<button className="icon-only" onClick={() => openFolder(skill)} disabled={busy !== ''} title="打开目录">
<FolderOpen size={15} />
</button>
<button className="danger icon-only" onClick={() => deleteSkill(skill)} disabled={busy !== ''} title="Delete local">
<button className="danger icon-only" onClick={() => deleteSkill(skill)} disabled={busy !== ''} title="删除本地技能">
<Trash2 size={15} />
</button>
</div>
@@ -428,7 +450,7 @@ function App() {
))}
{localSkills.length === 0 && (
<tr>
<td colSpan={7} className="empty">No local skills</td>
<td colSpan={7} className="empty"></td>
</tr>
)}
</tbody>
@@ -441,27 +463,35 @@ function App() {
);
}
function GithubIcon({size}: {size: number}) {
return (
<svg aria-hidden="true" focusable="false" width={size} height={size} viewBox="0 0 24 24" fill="currentColor">
<path d="M12 .5C5.65.5.5 5.65.5 12c0 5.09 3.29 9.4 7.86 10.93.58.1.79-.25.79-.56v-2.15c-3.2.7-3.87-1.37-3.87-1.37-.52-1.33-1.28-1.68-1.28-1.68-1.05-.72.08-.7.08-.7 1.15.08 1.76 1.18 1.76 1.18 1.03 1.75 2.69 1.24 3.35.95.1-.74.4-1.24.73-1.53-2.55-.29-5.23-1.28-5.23-5.68 0-1.25.45-2.28 1.18-3.08-.12-.29-.51-1.46.11-3.04 0 0 .96-.31 3.16 1.17.92-.25 1.9-.38 2.88-.38.98 0 1.96.13 2.88.38 2.2-1.48 3.16-1.17 3.16-1.17.62 1.58.23 2.75.11 3.04.73.8 1.18 1.83 1.18 3.08 0 4.41-2.69 5.38-5.25 5.67.41.36.78 1.06.78 2.13v3.18c0 .31.21.67.8.56A11.51 11.51 0 0 0 23.5 12C23.5 5.65 18.35.5 12 .5Z" />
</svg>
);
}
function pageTitle(tab: Tab) {
if (tab === 'remote') {
return 'Remote Market';
return '远程市场';
}
if (tab === 'local') {
return 'Local Skills';
return '本地技能';
}
return 'Configuration';
return '配置';
}
function statusSummary(remote: RemoteSkill[], local: SkillState[]) {
return `${remote.length} remote / ${local.length} local`;
return `${remote.length} 个远程 / ${local.length} 个本地`;
}
function badge(status: string) {
const label = status.replace(/_/g, ' ') || 'remote';
const label = statusLabels[status] || status.replace(/_/g, ' ') || statusLabels.remote;
return <span className={`badge ${status}`}>{label}</span>;
}
function targetBadge(skill: SkillState, targetID: 'codex' | 'claude') {
return targetInstalled(skill, targetID) ? <span className="badge installed">installed</span> : <span className="badge">not installed</span>;
return targetInstalled(skill, targetID) ? <span className="badge installed"></span> : <span className="badge"></span>;
}
function targetInstalled(skill: SkillState, targetID: 'codex' | 'claude') {
@@ -476,4 +506,9 @@ function shortCommit(commit: string) {
return commit ? commit.slice(0, 8) : '-';
}
function localizeMessage(message: string) {
const normalized = message.trim();
return localizedMessages[normalized] || normalized;
}
export default App;