feat(frontend): 实现中英文切换与拼写检查

This commit is contained in:
2026-09-05 09:38:23 +08:00
parent a35b577d66
commit ef961d322b
44 changed files with 722 additions and 519 deletions
+34 -33
View File
@@ -5,6 +5,7 @@ import AppIcon from '@/components/common/AppIcon.vue'
import type { McpServer, McpServerInput, McpServerTransport } from '@/contracts'
import * as service from '@/services/mcpServerService'
import { emptyMcpConfig, mergeImportedSecrets, normalizeMcpConfig, parseMcpJson, type ImportedSecret, type SecretKind } from './configuration'
import { t } from '@/i18n'
const servers = ref<McpServer[]>([])
const busy = ref('')
@@ -24,12 +25,12 @@ const secretDrafts = reactive<Record<string, string>>({})
const form = reactive<McpServerInput>(emptyMcpConfig())
const importedSecrets = ref<ImportedSecret[]>([])
const dialogTitle = computed(() => editingId.value ? '编辑 MCP 服务器' : '新增 MCP 服务器')
const dialogTitle = computed(() => editingId.value ? t('编辑 MCP 服务器', 'Edit MCP Server') : t('新增 MCP 服务器', 'Add MCP Server'))
async function load() {
error.value = ''
try { servers.value = await service.listMcpServers() }
catch (cause) { error.value = message(cause, '读取 MCP 服务器失败') }
catch (cause) { error.value = message(cause, t('读取 MCP 服务器失败', 'Failed to load MCP servers')) }
}
function resetEditor(input: McpServerInput) {
@@ -84,8 +85,8 @@ function applyTemplate(transport: McpServerTransport) {
function parseObject(value: string, label: string): Record<string, string> {
let parsed: unknown
try { parsed = JSON.parse(value || '{}') } catch { throw new Error(`${label}必须是 JSON 对象`) }
if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object' || Object.values(parsed).some(item => typeof item !== 'string')) throw new Error(`${label}必须是字符串键值 JSON 对象`)
try { parsed = JSON.parse(value || '{}') } catch { throw new Error(`${label}${t('必须是 JSON 对象', ' must be a JSON object')}`) }
if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object' || Object.values(parsed).some(item => typeof item !== 'string')) throw new Error(`${label}${t('必须是字符串键值 JSON 对象', ' must be a JSON object with string keys and values')}`)
return parsed as Record<string, string>
}
@@ -97,8 +98,8 @@ function formPayload(): McpServerInput {
command: stdio ? form.command?.trim() : null,
args: stdio ? argsText.value.split('\n').map(value => value.trim()).filter(Boolean) : [],
url: stdio ? null : form.url?.trim(),
headers: stdio ? {} : parseObject(headersText.value, '普通 Header'),
environment: stdio ? parseObject(environmentText.value, '普通环境变量') : {},
headers: stdio ? {} : parseObject(headersText.value, t('普通 Header', 'Headers')),
environment: stdio ? parseObject(environmentText.value, t('普通环境变量', 'Environment variables')) : {},
secret_environment_keys: stdio ? splitKeys(secretKeysText.value) : [],
secret_header_keys: stdio ? [] : splitKeys(secretHeaderKeysText.value),
permissions: permissionsText.value.split(',').map(value => value.trim()).filter(Boolean),
@@ -131,7 +132,7 @@ function switchMode(mode: 'form' | 'json') {
if (mode === 'json') rawConfig.value = JSON.stringify(payload(false), null, 2)
else resetEditor(payload(false))
editorMode.value = mode
} catch (cause) { error.value = message(cause, '配置转换失败') }
} catch (cause) { error.value = message(cause, t('配置转换失败', 'Configuration conversion failed')) }
}
async function save() {
@@ -140,8 +141,8 @@ async function save() {
try {
error.value = ''
const input = payload()
if (!input.name || (input.transport === 'stdio' ? !input.command : !input.url)) throw new Error('请填写服务器名称和连接地址')
if (editingOriginal.value && executionChanged(editingOriginal.value, input) && !confirm('连接命令、地址或认证配置已变化,保存后旧测试与授权会失效。是否保存?')) return
if (!input.name || (input.transport === 'stdio' ? !input.command : !input.url)) throw new Error(t('请填写服务器名称和连接地址', 'Enter a server name and connection address'))
if (editingOriginal.value && executionChanged(editingOriginal.value, input) && !confirm(t('连接命令、地址或认证配置已变化,保存后旧测试与授权会失效。是否保存?', 'The command, address, or authentication settings changed. Previous tests and authorization will be invalidated. Save?'))) return
busy.value = 'save'
saved = editingId.value ? await service.updateMcpServer(editingId.value, input) : await service.createMcpServer(input)
// Commit the returned ID/version before saving secrets so a partial failure can
@@ -157,7 +158,7 @@ async function save() {
await load()
} catch (cause) {
if (saved) await load()
error.value = `${saved ? '服务器配置已保存,但密钥保存失败;可点击保存重试。' : ''}${message(cause, '保存失败')}`
error.value = `${saved ? t('服务器配置已保存,但密钥保存失败;可点击保存重试。', 'Server settings were saved, but saving secrets failed. Save again to retry.') : ''}${message(cause, t('保存失败', 'Save failed'))}`
}
finally { busy.value = '' }
}
@@ -189,8 +190,8 @@ function executionChanged(server: McpServer, input: McpServerInput) {
async function approve(server: McpServer): Promise<McpServer | null> {
if (server.trusted) return server
const localWarning = server.transport === 'stdio' ? '\n\n本机进程尚无系统级沙箱,仅应运行可信服务器。' : '\n\n连接可能向该地址发送配置的 Header。'
if (!confirm(`请确认 MCP 连接:\n\n${server.command_summary}${localWarning}\n\n是否继续?`)) return null
const localWarning = server.transport === 'stdio' ? t('\n\n本机进程尚无系统级沙箱,仅应运行可信服务器。', '\n\nLocal processes have no system-level sandbox. Run trusted servers only.') : t('\n\n连接可能向该地址发送配置的 Header。', '\n\nThe connection may send configured headers to this address.')
if (!confirm(`${t('请确认 MCP 连接:', 'Confirm MCP connection:')}\n\n${server.command_summary}${localWarning}\n\n${t('是否继续?', 'Continue?')}`)) return null
return service.trustMcpServer(server)
}
@@ -199,14 +200,14 @@ async function toggle(server: McpServer) { await act(server, 'toggle', current =
async function act(server: McpServer, action: string, operation: (server: McpServer) => Promise<McpServer>) {
busy.value = `${action}:${server.server_id}`; error.value = ''
try { const current = action === 'toggle' && server.enabled ? server : await approve(server); if (!current) return; await operation(current); await load() }
catch (cause) { error.value = message(cause, '操作失败') }
catch (cause) { error.value = message(cause, t('操作失败', 'Operation failed')) }
finally { busy.value = '' }
}
async function remove(server: McpServer) {
if (!confirm(`删除“${server.name}”及其加密凭据?`)) return
if (!confirm(t(`删除“${server.name}”及其加密凭据?`, `Delete “${server.name}” and its encrypted credentials?`))) return
try { busy.value = `delete:${server.server_id}`; await service.deleteMcpServer(server.server_id); await load() }
catch (cause) { error.value = message(cause, '删除失败') } finally { busy.value = '' }
catch (cause) { error.value = message(cause, t('删除失败', 'Delete failed')) } finally { busy.value = '' }
}
async function saveSecret(server: McpServer, key: string, kind: SecretKind) {
@@ -214,7 +215,7 @@ async function saveSecret(server: McpServer, key: string, kind: SecretKind) {
const value = secretDrafts[draftKey]?.trim()
if (!value) return
try { busy.value = `secret:${draftKey}`; await service.putMcpServerSecret(server.server_id, key, value, kind); secretDrafts[draftKey] = ''; await load() }
catch (cause) { error.value = message(cause, '保存密钥失败') } finally { busy.value = '' }
catch (cause) { error.value = message(cause, t('保存密钥失败', 'Failed to save secret')) } finally { busy.value = '' }
}
function splitKeys(value: string) { return value.split(/[\n,]/).map(item => item.trim()).filter(Boolean) }
@@ -224,20 +225,20 @@ onMounted(load)
<template>
<section class="feature-page mcp-page">
<header class="feature-header"><div><h1>MCP 服务器</h1><p>管理独立 MCP Server 的连接凭据与工具生命周期</p></div><div class="inline-actions"><button class="button-secondary" :disabled="!!busy" @click="load"><AppIcon :icon="Refresh" /> 刷新</button><button class="button-primary" @click="openCreate"><AppIcon :icon="Plus" /> 新增服务器</button></div></header>
<div class="notice-banner">stdio 本机进程仅在开发环境开放Streamable HTTP 为首选远程传输SSE 仅用于兼容旧服务器uvx 隔离依赖但不是安全沙箱</div>
<header class="feature-header"><div><h1>{{ t('MCP 服务器', 'MCP Servers') }}</h1><p>{{ t('管理独立 MCP Server 的连接、凭据与工具生命周期。', 'Manage standalone MCP server connections, credentials, and tool lifecycles.') }}</p></div><div class="inline-actions"><button class="button-secondary" :disabled="!!busy" @click="load"><AppIcon :icon="Refresh" /> {{ t('刷新', 'Refresh') }}</button><button class="button-primary" @click="openCreate"><AppIcon :icon="Plus" /> {{ t('新增服务器', 'Add server') }}</button></div></header>
<div class="notice-banner">{{ t('stdio 本机进程仅在开发环境开放;Streamable HTTP 为首选远程传输,SSE 仅用于兼容旧服务器。uvx 隔离依赖但不是安全沙箱。', 'Local stdio processes are available only in development. Streamable HTTP is the preferred remote transport; SSE supports legacy servers. uvx isolates dependencies but is not a security sandbox.') }}</div>
<div v-if="error" class="error-banner">{{ error }}</div>
<div v-if="!servers.length" class="panel empty"><AppIcon :icon="Connection" :size="34" /><h2>尚未配置 MCP 服务器</h2><p>添加 Server,测试连接成功后才能启用工具。</p><button class="button-primary" @click="openCreate">新增服务器</button></div>
<div v-if="!servers.length" class="panel empty"><AppIcon :icon="Connection" :size="34" /><h2>{{ t('尚未配置 MCP 服务器', 'No MCP servers configured') }}</h2><p>{{ t('添加 Server,测试连接成功后才能启用工具。', 'Add a server and test its connection before enabling its tools.') }}</p><button class="button-primary" @click="openCreate">{{ t('新增服务器', 'Add server') }}</button></div>
<div v-else class="server-list">
<article v-for="server in servers" :key="server.server_id" class="panel server-card">
<div class="server-main"><div class="server-title"><AppIcon :icon="Connection" :size="24" /><div><h2>{{ server.name }}</h2><code>{{ server.command_summary }}</code></div></div><span class="badge" :class="{ success: server.status === 'ready', error: ['error','unhealthy'].includes(server.status) }">{{ server.status }}</span></div>
<div class="metadata"><span>{{ server.transport }}</span><span>v{{ server.version }}</span><span>{{ server.tools_count }} 个工具</span><span>{{ server.trusted ? '连接已确认' : '等待确认连接' }}</span><span v-if="server.last_test_succeeded">当前配置测试成功</span><span v-if="server.remote_server_name">{{ server.remote_server_name }} {{ server.remote_server_version }}</span></div>
<div class="metadata"><span>{{ server.transport }}</span><span>v{{ server.version }}</span><span>{{ server.tools_count }} {{ t('个工具', 'tools') }}</span><span>{{ server.trusted ? t('连接已确认', 'Connection confirmed') : t('等待确认连接', 'Awaiting confirmation') }}</span><span v-if="server.last_test_succeeded">{{ t('当前配置测试成功', 'Current configuration passed') }}</span><span v-if="server.remote_server_name">{{ server.remote_server_name }} {{ server.remote_server_version }}</span></div>
<div v-if="server.error" class="error-banner compact">{{ server.error }}</div>
<div v-if="Object.keys(server.secret_environment).length || Object.keys(server.secret_headers).length" class="secrets">
<label v-for="(configured, key) in server.secret_environment" :key="`env:${key}`"><span>环境变量 · {{ key }} <small>{{ configured ? '已加密保存' : '未配置' }}</small></span><span class="secret-input"><input v-model="secretDrafts[`${server.server_id}:environment:${key}`]" type="password" autocomplete="new-password" placeholder="输入后保存(不会回显)"><button class="button-secondary" @click="saveSecret(server, key, 'environment')">保存</button></span></label>
<label v-for="(configured, key) in server.secret_headers" :key="`header:${key}`"><span>HTTP Header · {{ key }} <small>{{ configured ? '已加密保存' : '未配置' }}</small></span><span class="secret-input"><input v-model="secretDrafts[`${server.server_id}:header:${key}`]" type="password" autocomplete="new-password" placeholder="输入后保存(不会回显)"><button class="button-secondary" @click="saveSecret(server, key, 'header')">保存</button></span></label>
<label v-for="(configured, key) in server.secret_environment" :key="`env:${key}`"><span>{{ t('环境变量', 'Environment variable') }} · {{ key }} <small>{{ configured ? t('已加密保存', 'Encrypted and saved') : t('未配置', 'Not configured') }}</small></span><span class="secret-input"><input v-model="secretDrafts[`${server.server_id}:environment:${key}`]" type="password" autocomplete="new-password" :placeholder="t('输入后保存(不会回显)', 'Enter and save (never displayed)')"><button class="button-secondary" @click="saveSecret(server, key, 'environment')">{{ t('保存', 'Save') }}</button></span></label>
<label v-for="(configured, key) in server.secret_headers" :key="`header:${key}`"><span>HTTP Header · {{ key }} <small>{{ configured ? t('已加密保存', 'Encrypted and saved') : t('未配置', 'Not configured') }}</small></span><span class="secret-input"><input v-model="secretDrafts[`${server.server_id}:header:${key}`]" type="password" autocomplete="new-password" :placeholder="t('输入后保存(不会回显)', 'Enter and save (never displayed)')"><button class="button-secondary" @click="saveSecret(server, key, 'header')">{{ t('保存', 'Save') }}</button></span></label>
</div>
<footer class="card-actions"><button class="button-secondary" :disabled="!!busy || server.enabled" @click="test(server)"><AppIcon :icon="VideoPlay" /> 测试连接</button><button class="button-secondary" :disabled="!!busy" @click="openEdit(server)"><AppIcon :icon="EditPen" /> 编辑</button><button class="button-danger" :disabled="!!busy" @click="remove(server)"><AppIcon :icon="Delete" /> 删除</button><button class="button-primary" :disabled="!!busy || (!server.enabled && !server.last_test_succeeded)" :title="!server.enabled && !server.last_test_succeeded ? '请先测试当前配置' : ''" @click="toggle(server)">{{ server.enabled ? '停用' : '启用' }}</button></footer>
<footer class="card-actions"><button class="button-secondary" :disabled="!!busy || server.enabled" @click="test(server)"><AppIcon :icon="VideoPlay" /> {{ t('测试连接', 'Test connection') }}</button><button class="button-secondary" :disabled="!!busy" @click="openEdit(server)"><AppIcon :icon="EditPen" /> {{ t('编辑', 'Edit') }}</button><button class="button-danger" :disabled="!!busy" @click="remove(server)"><AppIcon :icon="Delete" /> {{ t('删除', 'Delete') }}</button><button class="button-primary" :disabled="!!busy || (!server.enabled && !server.last_test_succeeded)" :title="!server.enabled && !server.last_test_succeeded ? t('请先测试当前配置', 'Test the current configuration first') : ''" @click="toggle(server)">{{ server.enabled ? t('停用', 'Disable') : t('启用', 'Enable') }}</button></footer>
</article>
</div>
@@ -246,18 +247,18 @@ onMounted(load)
<fieldset :disabled="!!busy" class="editor-fields">
<header><h2><AppIcon :icon="Plus" /> {{ dialogTitle }}</h2><button type="button" class="close" @click="closeEditor">×</button></header>
<div v-if="error" class="error-banner" role="alert">{{ error }}</div>
<div v-if="importedSecrets.length" class="notice-banner">已识别 {{ importedSecrets.length }} 项密钥保存时将单独加密不会写入普通服务器配置取消将清除未保存密钥</div>
<div class="mode-tabs"><button type="button" :class="{ active: editorMode === 'form' }" @click="switchMode('form')">表单配置</button><button type="button" :class="{ active: editorMode === 'json' }" @click="switchMode('json')">JSON 配置</button></div>
<div v-if="importedSecrets.length" class="notice-banner">{{ t('已识别', 'Detected') }} {{ importedSecrets.length }} {{ t('项密钥保存时将单独加密不会写入普通服务器配置取消将清除未保存密钥', 'secrets. They will be encrypted separately and excluded from regular server settings. Canceling clears unsaved secrets.') }}</div>
<div class="mode-tabs"><button type="button" :class="{ active: editorMode === 'form' }" @click="switchMode('form')">{{ t('表单配置', 'Form') }}</button><button type="button" :class="{ active: editorMode === 'json' }" @click="switchMode('json')">{{ t('JSON 配置', 'JSON') }}</button></div>
<template v-if="editorMode === 'form'">
<label>服务器名称<input v-model="form.name" maxlength="80" placeholder="例如:文件系统工具"></label>
<div class="template-row"><span>服务器配置</span><button type="button" class="template" :class="{ active: form.transport === 'stdio' }" @click="applyTemplate('stdio')">stdio 模板</button><button type="button" class="template" :class="{ active: form.transport === 'streamable_http' }" @click="applyTemplate('streamable_http')">Streamable HTTP</button><button type="button" class="template" :class="{ active: form.transport === 'sse' }" @click="applyTemplate('sse')">SSE兼容</button></div>
<template v-if="form.transport === 'stdio'"><label>可执行命令<input v-model="form.command" placeholder="uvx、npx 或可信可执行文件路径"></label><label>参数(每行一项)<textarea v-model="argsText" rows="5"></textarea></label><div class="two-columns"><label>普通环境变量(JSON<textarea v-model="environmentText" rows="5"></textarea></label><label>敏感环境变量名(每行一项)<textarea v-model="secretKeysText" rows="5" placeholder="API_KEY"></textarea></label></div></template>
<template v-else><label>MCP URL<input v-model="form.url" placeholder="https://example.com/mcp"></label><div class="two-columns"><label>普通 HeaderJSON<textarea v-model="headersText" rows="5" placeholder='{"X-Client":"NotesAgent"}'></textarea></label><label>敏感 Header 名(每行一项)<textarea v-model="secretHeaderKeysText" rows="5" placeholder="Authorization"></textarea></label></div></template>
<label>声明权限逗号分隔可选<input v-model="permissionsText" placeholder="network.request, notes.read"></label>
<div class="two-columns"><label>启动超时<input v-model.number="form.startup_timeout_seconds" type="number" min="1" max="120"></label><label>工具超时<input v-model.number="form.tool_timeout_seconds" type="number" min="1" max="300"></label></div>
<label>{{ t('服务器名称', 'Server name') }}<input v-model="form.name" maxlength="80" :placeholder="t('例如:文件系统工具', 'For example: Filesystem tools')"></label>
<div class="template-row"><span>{{ t('服务器配置', 'Server configuration') }}</span><button type="button" class="template" :class="{ active: form.transport === 'stdio' }" @click="applyTemplate('stdio')">stdio {{ t('模板', 'template') }}</button><button type="button" class="template" :class="{ active: form.transport === 'streamable_http' }" @click="applyTemplate('streamable_http')">Streamable HTTP</button><button type="button" class="template" :class="{ active: form.transport === 'sse' }" @click="applyTemplate('sse')">SSE {{ t('兼容', '(legacy)') }}</button></div>
<template v-if="form.transport === 'stdio'"><label>{{ t('可执行命令', 'Executable command') }}<input v-model="form.command" :placeholder="t('uvx、npx 或可信可执行文件路径', 'uvx, npx, or a trusted executable path')"></label><label>{{ t('参数(每行一项)', 'Arguments (one per line)') }}<textarea v-model="argsText" rows="5"></textarea></label><div class="two-columns"><label>{{ t('普通环境变量(JSON', 'Environment variables (JSON)') }}<textarea v-model="environmentText" rows="5"></textarea></label><label>{{ t('敏感环境变量名(每行一项)', 'Secret environment names (one per line)') }}<textarea v-model="secretKeysText" rows="5" placeholder="API_KEY"></textarea></label></div></template>
<template v-else><label>MCP URL<input v-model="form.url" placeholder="https://example.com/mcp"></label><div class="two-columns"><label>{{ t('普通 HeaderJSON', 'Headers (JSON)') }}<textarea v-model="headersText" rows="5" placeholder='{"X-Client":"NotesAgent"}'></textarea></label><label>{{ t('敏感 Header 名(每行一项)', 'Secret header names (one per line)') }}<textarea v-model="secretHeaderKeysText" rows="5" placeholder="Authorization"></textarea></label></div></template>
<label>{{ t('声明权限(逗号分隔,可选)', 'Declared permissions (comma-separated, optional)') }}<input v-model="permissionsText" placeholder="network.request, notes.read"></label>
<div class="two-columns"><label>{{ t('启动超时(秒)', 'Startup timeout (seconds)') }}<input v-model.number="form.startup_timeout_seconds" type="number" min="1" max="120"></label><label>{{ t('工具超时(秒)', 'Tool timeout (seconds)') }}<input v-model.number="form.tool_timeout_seconds" type="number" min="1" max="300"></label></div>
</template>
<label v-else>服务器 JSON 配置<textarea v-model="rawConfig" class="json-editor" rows="22" spellcheck="false"></textarea><small>支持 NotesAgent 配置command/args/env 和单服务器 mcpServers 配置已声明的 Secret 及常见 API KeyTokenAuthorization 会拆分后加密保存其他敏感值请显式声明不要把密钥放入命令或参数</small><small>兼容导入 timeout 为启动超时sse_read_timeout 为工具等待上限不保留 SSE 读取超时语义</small></label>
<footer><button type="button" class="button-secondary" @click="closeEditor">取消</button><button class="button-primary" :disabled="busy === 'save'">保存</button></footer>
<label v-else>{{ t('服务器 JSON 配置', 'Server JSON configuration') }}<textarea v-model="rawConfig" class="json-editor" rows="22" spellcheck="false"></textarea><small>{{ t('支持 NotesAgent 配置、command/args/env 和单服务器 mcpServers 配置。已声明的 Secret 及常见 API Key、Token、Authorization 会拆分后加密保存其他敏感值请显式声明不要把密钥放入命令或参数。', 'Supports NotesAgent, command/args/env, and single-server mcpServers configurations. Declared secrets and common API key, token, and authorization values are separated and encrypted. Declare other sensitive values explicitly; never place secrets in commands or arguments.') }}</small><small>{{ t('兼容导入 timeout 为启动超时,sse_read_timeout 为工具等待上限(不保留 SSE 读取超时语义)。', 'For compatible imports, timeout maps to startup timeout and sse_read_timeout maps to the tool wait limit.') }}</small></label>
<footer><button type="button" class="button-secondary" @click="closeEditor">{{ t('取消', 'Cancel') }}</button><button class="button-primary" :disabled="busy === 'save'">{{ t('保存', 'Save') }}</button></footer>
</fieldset>
</form>
</div>