fix(frontend): 同步 main 并修复 phase2 关闭审阅意见

This commit is contained in:
2026-09-05 17:32:34 +08:00
109 changed files with 3926 additions and 1019 deletions
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { Refresh, VideoPlay } from '@element-plus/icons-vue'
import { ref, watch } from 'vue'
import { useRouter } from 'vue-router'
@@ -51,7 +52,7 @@ async function load() {
for (const command of mine) next[command.command_id] = initialArguments(command)
argumentsByCommand.value = next
} catch (reason) {
if (version === loadVersion) error.value = reason instanceof Error ? reason.message : '命令加载失败'
if (version === loadVersion) error.value = reason instanceof Error ? reason.message : t('命令加载失败', 'Failed to load commands')
} finally {
if (version === loadVersion) loading.value = false
}
@@ -125,7 +126,7 @@ async function execute(command: PluginCommand) {
notify: (text) => { notice.value = text },
})
} catch (reason) {
error.value = reason instanceof Error ? reason.message : '命令执行失败'
error.value = reason instanceof Error ? reason.message : t('命令执行失败', 'Command failed')
} finally {
busy.value = ''
}
@@ -136,11 +137,11 @@ async function execute(command: PluginCommand) {
<div class="command-panel">
<div class="section-head">
<div>
<h3>Plugin 命令</h3>
<p>执行该 Plugin 注册的受控 Command Contribution参数表单由后端声明的 JSON Schema 生成</p>
<h3>{{ t('Plugin 命令', 'Plugin commands') }}</h3>
<p>{{ t('执行该 Plugin 注册的受控 Command Contribution;参数表单由后端声明的 JSON Schema 生成。', 'Run controlled plugin commands using the parameter form defined by the plugin.') }}</p>
</div>
<button class="button-secondary" :disabled="loading" @click="load">
<AppIcon :icon="Refresh" :size="15" />刷新
<AppIcon :icon="Refresh" :size="15" />{{ t('刷新', 'Refresh') }}
</button>
</div>
@@ -160,14 +161,14 @@ async function execute(command: PluginCommand) {
success: commandAvailable(command),
warning: command.enabled && !commandAvailable(command),
}"
>{{ commandAvailable(command) ? '可执行' : command.enabled ? '缺少上下文' : '不可用' }}</span>
>{{ commandAvailable(command) ? t('可执行', 'Available') : command.enabled ? t('缺少上下文', 'Missing context') : t('不可用', 'Unavailable') }}</span>
</div>
<div v-if="commandFields(command).length" class="command-fields">
<label v-for="field in commandFields(command)" :key="field.key" class="field">
<span>
{{ field.title }}
<em v-if="field.required">必填</em>
<em v-if="field.required">{{ t('必填', 'Required') }}</em>
</span>
<select
v-if="field.enum"
@@ -175,7 +176,7 @@ async function execute(command: PluginCommand) {
:value="fieldValue(command.command_id, field)"
@change="updateArgument(command.command_id, field, ($event.target as HTMLSelectElement).value)"
>
<option value="">请选择</option>
<option value="">{{ t('请选择', 'Select') }}</option>
<option v-for="option in field.enum" :key="option" :value="option">{{ option }}</option>
</select>
<select
@@ -184,8 +185,8 @@ async function execute(command: PluginCommand) {
:value="fieldValue(command.command_id, field)"
@change="updateArgument(command.command_id, field, ($event.target as HTMLSelectElement).value)"
>
<option value="false"></option>
<option value="true"></option>
<option value="false">{{ t('否', 'No') }}</option>
<option value="true">{{ t('是', 'Yes') }}</option>
</select>
<input
v-else
@@ -209,12 +210,12 @@ async function execute(command: PluginCommand) {
@click="execute(command)"
>
<AppIcon :icon="VideoPlay" :size="15" />
{{ busy === command.command_id ? '执行中…' : '执行命令' }}
{{ busy === command.command_id ? t('执行中…', 'Running…') : t('执行命令', 'Run command') }}
</button>
</article>
</div>
<div v-else-if="!loading" class="empty-state">
<div><strong>没有可用命令</strong><p>启用 Plugin 已注册的命令会出现在这里</p></div>
<div><strong>{{ t('没有可用命令', 'No available commands') }}</strong><p>{{ t('启用 Plugin 后,已注册的命令会出现在这里。', 'Registered commands appear here after the Plugin is enabled.') }}</p></div>
</div>
</div>
</template>
@@ -6,6 +6,8 @@ import PluginCommandPanel from './PluginCommandPanel.vue'
import type { Plugin, PluginHostStatus, PluginSettingField, PluginSettingsSchema } from '@/contracts'
import * as pluginService from '@/services/pluginService'
import { usePluginStore } from '@/stores/plugin'
import { useWorkspaceStore } from '@/stores/workspace'
import { t, localeTag } from '@/i18n'
const props = defineProps<{ plugin: Plugin }>()
const pluginStore = usePluginStore()
@@ -24,8 +26,8 @@ let loadVersion = 0
const hasSettings = computed(() => props.plugin.contributions.some((item) => item.type === 'settings_section'))
const tabs = computed(() => [
...(props.plugin.backend_type === 'mcp' ? [{ id: 'host' as const, label: 'MCP Host' }] : []),
...(hasSettings.value ? [{ id: 'settings' as const, label: '设置与密钥' }] : []),
{ id: 'commands' as const, label: '插件命令' },
...(hasSettings.value ? [{ id: 'settings' as const, label: t('设置与密钥', 'Settings and secrets') }] : []),
{ id: 'commands' as const, label: t('插件命令', 'Plugin commands') },
])
watch(() => props.plugin.plugin_id, () => {
@@ -40,7 +42,7 @@ watch(() => props.plugin.plugin_id, () => {
function feedback(message = '') { error.value = message; notice.value = '' }
function message(reason: unknown, fallback: string) { return reason instanceof Error ? reason.message : fallback }
function formatTime(value?: string | null) { return value ? new Date(value).toLocaleString() : '—' }
function formatTime(value?: string | null) { return value ? new Date(value).toLocaleString(localeTag()) : '—' }
async function selectTab(tab: typeof activeTab.value) {
activeTab.value = tab
@@ -66,7 +68,7 @@ async function loadActive() {
}
// commands 由 PluginCommandPanel 自己加载。
} catch (reason) {
if (version === loadVersion) feedback(message(reason, 'MCP 数据加载失败'))
if (version === loadVersion) feedback(message(reason, t('MCP 数据加载失败', 'Failed to load MCP data')))
} finally {
if (version === loadVersion) loading.value = false
}
@@ -85,8 +87,8 @@ async function restartHost() {
await pluginService.restartPluginHost(props.plugin.plugin_id)
host.value = await pluginService.getPluginHostStatus(props.plugin.plugin_id)
await pluginStore.loadPlugins()
notice.value = 'MCP Host 已重启。'
} catch (reason) { feedback(message(reason, 'MCP Host 重启失败')) } finally { busy.value = '' }
notice.value = t('MCP Host 已重启。', 'MCP Host restarted.')
} catch (reason) { feedback(message(reason, t('MCP Host 重启失败', 'Failed to restart MCP Host'))) } finally { busy.value = '' }
}
function updateValue(field: PluginSettingField, raw: string | boolean) {
values.value[field.key] = field.type === 'number' && typeof raw === 'string' ? (raw === '' ? null : Number(raw)) : raw
@@ -98,72 +100,72 @@ async function saveSettings() {
try {
schema.value = await pluginService.updatePluginSettings(props.plugin.plugin_id, schema.value.schema_version, values.value)
values.value = { ...schema.value.values }
notice.value = '普通设置已保存。'
} catch (reason) { feedback(message(reason, '设置保存失败')) } finally { busy.value = '' }
notice.value = t('普通设置已保存。', 'Settings saved.')
} catch (reason) { feedback(message(reason, t('设置保存失败', 'Failed to save settings'))) } finally { busy.value = '' }
}
async function saveSecret(field: PluginSettingField) {
const secret = secrets.value[field.key]?.trim()
if (!secret) { feedback('请输入' + field.label); return }
if (!secret) { feedback(t('请输入', 'Enter ') + field.label); return }
busy.value = 'secret:' + field.key
feedback()
try {
const state = await pluginService.putPluginSecret(props.plugin.plugin_id, field.key, secret)
if (schema.value) schema.value.secrets[field.key] = { configured: state.configured }
secrets.value[field.key] = ''
notice.value = field.label + '已加密保存。'
} catch (reason) { feedback(message(reason, '密钥保存失败')) } finally { busy.value = '' }
notice.value = field.label + t('已加密保存。', ' encrypted and saved.')
} catch (reason) { feedback(message(reason, t('密钥保存失败', 'Failed to save secret'))) } finally { busy.value = '' }
}
async function deleteSecret(field: PluginSettingField) {
if (!confirm('删除已保存的' + field.label + '')) return
if (!confirm(t('删除已保存的', 'Delete saved ') + field.label + '')) return
busy.value = 'secret:' + field.key
feedback()
try {
const state = await pluginService.deletePluginSecret(props.plugin.plugin_id, field.key)
if (schema.value) schema.value.secrets[field.key] = { configured: state.configured }
secrets.value[field.key] = ''
notice.value = field.label + '已删除。'
} catch (reason) { feedback(message(reason, '密钥删除失败')) } finally { busy.value = '' }
notice.value = field.label + t('已删除。', ' deleted.')
} catch (reason) { feedback(message(reason, t('密钥删除失败', 'Failed to delete secret'))) } finally { busy.value = '' }
}
</script>
<template>
<section class="mcp-panel">
<nav class="mcp-tabs" aria-label="MCP Plugin 配置">
<nav class="mcp-tabs" :aria-label="t('MCP Plugin 配置', 'MCP and Plugin settings')">
<button v-for="tab in tabs" :key="tab.id" :class="{ active: activeTab === tab.id }" @click="selectTab(tab.id)">{{ tab.label }}</button>
</nav>
<div v-if="error" class="error-banner">{{ error }}</div>
<div v-if="notice" class="notice-banner">{{ notice }}</div>
<div v-if="activeTab === 'host'" class="mcp-section">
<div class="section-head"><div><h3>MCP Host 状态</h3><p>查看协议协商运行状态与 Host 错误</p></div><div class="inline-actions"><button class="button-secondary" :disabled="loading" @click="loadActive"><AppIcon :icon="Refresh" :size="15" />刷新</button><button class="button-primary" :disabled="busy === 'host' || !plugin.enabled" @click="restartHost">{{ busy === 'host' ? '重启中' : '重启 Host' }}</button></div></div>
<div class="section-head"><div><h3>{{ t('MCP Host 状态', 'MCP Host status') }}</h3><p>{{ t('查看协议协商、运行状态与 Host 错误。', 'Inspect protocol negotiation, runtime status, and Host errors.') }}</p></div><div class="inline-actions"><button class="button-secondary" :disabled="loading" @click="loadActive"><AppIcon :icon="Refresh" :size="15" />{{ t('刷新', 'Refresh') }}</button><button class="button-primary" :disabled="busy === 'host' || !plugin.enabled" @click="restartHost">{{ busy === 'host' ? t('重启中', 'Restarting') : t('重启 Host', 'Restart Host') }}</button></div></div>
<div v-if="host" class="status-grid">
<div><span>状态</span><strong><i class="status-dot" :class="host.status"></i>{{ host.status }}</strong></div>
<div><span>服务</span><strong>{{ host.server_name || '—' }} {{ host.server_version || '' }}</strong></div>
<div><span>协议版本</span><strong>{{ host.protocol_version || '—' }}</strong></div>
<div><span>工具数量</span><strong>{{ host.tools_count }}</strong></div>
<div><span>启动时间</span><strong>{{ formatTime(host.started_at) }}</strong></div>
<div><span>最后心跳</span><strong>{{ formatTime(host.last_seen_at) }}</strong></div>
<div><span>{{ t('状态', 'Status') }}</span><strong><i class="status-dot" :class="host.status"></i>{{ host.status }}</strong></div>
<div><span>{{ t('服务', 'Server') }}</span><strong>{{ host.server_name || '—' }} {{ host.server_version || '' }}</strong></div>
<div><span>{{ t('协议版本', 'Protocol version') }}</span><strong>{{ host.protocol_version || '—' }}</strong></div>
<div><span>{{ t('工具数量', 'Tools') }}</span><strong>{{ host.tools_count }}</strong></div>
<div><span>{{ t('启动时间', 'Started') }}</span><strong>{{ formatTime(host.started_at) }}</strong></div>
<div><span>{{ t('最后心跳', 'Last heartbeat') }}</span><strong>{{ formatTime(host.last_seen_at) }}</strong></div>
</div>
<div v-else-if="loading" class="empty-state">正在读取 Host 状态</div>
<div v-else-if="loading" class="empty-state">{{ t('正在读取 Host 状态', 'Loading Host status') }}</div>
<div v-if="host?.error" class="error-banner host-error">{{ host.error }}</div>
<p class="security-hint">当前仅运行插件清单声明的 stdio MCP Server不开放任意 Shell 命令和环境变量编辑</p>
<p class="security-hint">{{ t('当前仅运行插件清单声明的 stdio MCP Server,不开放任意 Shell 命令和环境变量编辑。', 'Only stdio MCP servers declared by the plugin manifest can run. Arbitrary shell commands and environment variable editing are unavailable.') }}</p>
</div>
<div v-else-if="activeTab === 'settings'" class="mcp-section">
<div class="section-head"><div><h3>设置与密钥</h3><p>表单由后端 Schema 生成密钥不会被读取或回显</p></div><button class="button-primary" :disabled="!schema || busy === 'settings'" @click="saveSettings">{{ busy === 'settings' ? '保存中' : '保存普通设置' }}</button></div>
<div class="section-head"><div><h3>{{ t('设置与密钥', 'Settings and secrets') }}</h3><p>{{ t('表单由后端 Schema 生成;密钥不会被读取或回显。', 'The backend schema generates this form. Secrets are never read back or displayed.') }}</p></div><button class="button-primary" :disabled="!schema || busy === 'settings'" @click="saveSettings">{{ busy === 'settings' ? t('保存中', 'Saving') : t('保存普通设置', 'Save settings') }}</button></div>
<div v-if="schema" class="settings-list">
<div v-for="field in schema.fields" :key="field.key" class="setting-row">
<div class="field-copy"><label :for="'plugin-setting-' + field.key"><AppIcon v-if="field.type === 'secret'" :icon="Key" :size="15" />{{ field.label }}<em v-if="field.required">必填</em></label><p>{{ field.description || (field.type === 'secret' ? '加密保存,不在页面回显。' : '') }}</p></div>
<div class="field-copy"><label :for="'plugin-setting-' + field.key"><AppIcon v-if="field.type === 'secret'" :icon="Key" :size="15" />{{ field.label }}<em v-if="field.required">{{ t('必填', 'Required') }}</em></label><p>{{ field.description || (field.type === 'secret' ? t('加密保存,不在页面回显。', 'Encrypted and never displayed.') : '') }}</p></div>
<template v-if="field.type === 'secret'">
<div class="secret-control"><input :id="'plugin-setting-' + field.key" :value="secrets[field.key] || ''" class="input" type="password" autocomplete="new-password" :placeholder="schema.secrets[field.key]?.configured ? '已配置;输入新值可替换' : '输入密钥'" @input="secrets[field.key] = ($event.target as HTMLInputElement).value"><button class="button-secondary" :disabled="!secrets[field.key]?.trim() || busy === 'secret:' + field.key" @click="saveSecret(field)">安全保存</button><button v-if="schema.secrets[field.key]?.configured" class="button-danger" @click="deleteSecret(field)">删除</button></div>
<span class="secret-state" :class="{ configured: schema.secrets[field.key]?.configured }">{{ schema.secrets[field.key]?.configured ? '已配置' : '未配置' }}</span>
<div class="secret-control"><input :id="'plugin-setting-' + field.key" :value="secrets[field.key] || ''" class="input" type="password" autocomplete="new-password" :placeholder="schema.secrets[field.key]?.configured ? t('已配置;输入新值可替换', 'Configured; enter a new value to replace') : t('输入密钥', 'Enter secret')" @input="secrets[field.key] = ($event.target as HTMLInputElement).value"><button class="button-secondary" :disabled="!secrets[field.key]?.trim() || busy === 'secret:' + field.key" @click="saveSecret(field)">{{ t('安全保存', 'Save securely') }}</button><button v-if="schema.secrets[field.key]?.configured" class="button-danger" @click="deleteSecret(field)">{{ t('删除', 'Delete') }}</button></div>
<span class="secret-state" :class="{ configured: schema.secrets[field.key]?.configured }">{{ schema.secrets[field.key]?.configured ? t('已配置', 'Configured') : t('未配置', 'Not configured') }}</span>
</template>
<template v-else-if="field.type === 'boolean'"><label class="check-control"><input :id="'plugin-setting-' + field.key" type="checkbox" :checked="Boolean(values[field.key])" @change="updateValue(field, ($event.target as HTMLInputElement).checked)">{{ values[field.key] ? '开启' : '关闭' }}</label></template>
<template v-else-if="field.type === 'boolean'"><label class="check-control"><input :id="'plugin-setting-' + field.key" type="checkbox" :checked="Boolean(values[field.key])" @change="updateValue(field, ($event.target as HTMLInputElement).checked)">{{ values[field.key] ? t('开启', 'On') : t('关闭', 'Off') }}</label></template>
<template v-else-if="field.type === 'select'"><select :id="'plugin-setting-' + field.key" class="select" :value="values[field.key]" @change="updateValue(field, ($event.target as HTMLSelectElement).value)"><option v-for="option in field.options" :key="option" :value="option">{{ option }}</option></select></template>
<template v-else><input :id="'plugin-setting-' + field.key" class="input" :type="field.type === 'number' ? 'number' : 'text'" :min="field.minimum ?? undefined" :max="field.maximum ?? undefined" :required="field.required" :value="values[field.key] ?? ''" @input="updateValue(field, ($event.target as HTMLInputElement).value)"></template>
</div>
</div>
<div v-else-if="loading" class="empty-state">正在读取 Plugin 设置</div>
<div v-else-if="loading" class="empty-state">{{ t('正在读取 Plugin 设置', 'Loading Plugin settings') }}</div>
</div>
<div v-else class="mcp-section">
@@ -0,0 +1,66 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import type { PluginSettingsSchema } from '@/contracts'
import * as service from '@/services/pluginService'
import PluginSettingsPanel from './PluginSettingsPanel.vue'
vi.mock('@/services/pluginService', () => ({ getPluginSettings: vi.fn(), updatePluginSettings: vi.fn(), putPluginSecret: vi.fn(), deletePluginSecret: vi.fn() }))
const schema = (value = ''): PluginSettingsSchema => ({ plugin_id: 'demo', schema_version: 1, fields: [{ key: 'name', label: 'Name', type: 'string', description: '', required: false, options: [] }], values: { name: value }, secrets: {} })
let wrapper: VueWrapper
beforeEach(() => { vi.resetAllMocks(); vi.mocked(service.getPluginSettings).mockResolvedValue(schema()) })
afterEach(() => wrapper?.unmount())
it('retains edits made during a save and submits them on the next save', async () => {
let resolveSave!: (value: PluginSettingsSchema) => void
vi.mocked(service.updatePluginSettings).mockReturnValueOnce(new Promise(resolve => { resolveSave = resolve }))
wrapper = mount(PluginSettingsPanel, { props: { pluginId: 'demo' } })
await flushPromises()
await wrapper.get('input').setValue('first edit')
await wrapper.get('.form-actions button').trigger('click')
expect(service.updatePluginSettings).toHaveBeenLastCalledWith('demo', 1, { name: 'first edit' })
await wrapper.get('input').setValue('second edit')
resolveSave(schema('first edit'))
await flushPromises()
expect(wrapper.find('.unsaved-hint').exists()).toBe(true)
expect(wrapper.get('.form-actions button').attributes('disabled')).toBeUndefined()
expect((wrapper.get('input').element as HTMLInputElement).value).toBe('second edit')
vi.mocked(service.updatePluginSettings).mockResolvedValueOnce(schema('second edit'))
await wrapper.get('.form-actions button').trigger('click')
await flushPromises()
expect(service.updatePluginSettings).toHaveBeenLastCalledWith('demo', 1, { name: 'second edit' })
expect(wrapper.find('.unsaved-hint').exists()).toBe(false)
})
it('keeps input and permits retry after a failed save', async () => {
vi.mocked(service.updatePluginSettings).mockRejectedValueOnce(new Error('Save failed'))
wrapper = mount(PluginSettingsPanel, { props: { pluginId: 'demo' } })
await flushPromises()
await wrapper.get('input').setValue('retry me')
await wrapper.get('.form-actions button').trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('Save failed')
expect(wrapper.find('.unsaved-hint').exists()).toBe(true)
expect((wrapper.get('input').element as HTMLInputElement).value).toBe('retry me')
vi.mocked(service.updatePluginSettings).mockResolvedValueOnce(schema('retry me'))
await wrapper.get('.form-actions button').trigger('click')
await flushPromises()
expect(service.updatePluginSettings).toHaveBeenCalledTimes(2)
expect(wrapper.find('.unsaved-hint').exists()).toBe(false)
})
it('ignores a save response after switching to another plugin', async () => {
let resolveSave!: (value: PluginSettingsSchema) => void
vi.mocked(service.updatePluginSettings).mockReturnValueOnce(new Promise(resolve => { resolveSave = resolve }))
wrapper = mount(PluginSettingsPanel, { props: { pluginId: 'demo' } })
await flushPromises()
await wrapper.get('input').setValue('old plugin')
await wrapper.get('.form-actions button').trigger('click')
vi.mocked(service.getPluginSettings).mockResolvedValueOnce(schema('new plugin'))
await wrapper.setProps({ pluginId: 'other' })
await flushPromises()
resolveSave(schema('old plugin'))
await flushPromises()
expect((wrapper.get('input').element as HTMLInputElement).value).toBe('new plugin')
expect(wrapper.emitted('saved')).toBeUndefined()
})
@@ -24,6 +24,8 @@ const isLoading = ref(false)
const isSaving = ref(false)
const saveError = ref('')
const hasChanges = ref(false)
let editVersion = 0
let loadVersion = 0
const nonSecretFields = computed(() =>
schema.value?.fields.filter((f) => f.type !== 'secret') ?? []
@@ -34,36 +36,49 @@ const secretFields = computed(() =>
)
async function load() {
const version = ++loadVersion
const pluginId = props.pluginId
isLoading.value = true
isSaving.value = false
saveError.value = ''
schema.value = null
Object.keys(secrets).forEach(key => delete secrets[key])
try {
schema.value = await getPluginSettings(props.pluginId)
const loaded = await getPluginSettings(pluginId)
if (version !== loadVersion) return
schema.value = loaded
Object.keys(values).forEach((k) => delete values[k])
Object.assign(values, schema.value.values)
hasChanges.value = false
editVersion = 0
} catch (error) {
emit('error', error instanceof Error ? error.message : '设置加载失败')
if (version === loadVersion) emit('error', error instanceof Error ? error.message : '设置加载失败')
} finally {
isLoading.value = false
if (version === loadVersion) isLoading.value = false
}
}
async function save() {
if (!schema.value) return
if (!schema.value || isSaving.value) return
const version = loadVersion
const submittedEditVersion = editVersion
const pluginId = props.pluginId
isSaving.value = true
saveError.value = ''
try {
schema.value = await updatePluginSettings(
props.pluginId,
const saved = await updatePluginSettings(
pluginId,
schema.value.schema_version,
{ ...values }
)
hasChanges.value = false
if (version !== loadVersion) return
schema.value = saved
hasChanges.value = editVersion !== submittedEditVersion
emit('saved')
} catch (error) {
saveError.value = error instanceof Error ? error.message : '保存失败'
if (version === loadVersion) saveError.value = error instanceof Error ? error.message : '保存失败'
} finally {
isSaving.value = false
if (version === loadVersion) isSaving.value = false
}
}
@@ -108,6 +123,7 @@ function setFieldValue(key: string, value: unknown, field: PluginSettingField) {
values[key] = value
}
hasChanges.value = true
editVersion++
}
onMounted(load)
+21 -20
View File
@@ -8,6 +8,7 @@ import { computed, onMounted, ref, watch } from 'vue'
import { usePluginStore } from '@/stores/plugin'
import * as pluginService from '@/services/pluginService'
import type { PluginCommand } from '@/contracts'
import { t } from '@/i18n'
const pluginStore = usePluginStore()
const actionError = ref('')
@@ -29,28 +30,28 @@ watch(() => pluginStore.selectedPluginId, async (pluginId) => {
})
async function install() {
const path = prompt('请输入 Plugin Package 路径')?.trim()
const path = prompt(t('请输入 Plugin Package 路径', 'Enter the Plugin Package path'))?.trim()
if (!path) return
try { await pluginStore.installPlugin(path) }
catch (error) { actionError.value = error instanceof Error ? error.message : '安装失败' }
catch (error) { actionError.value = error instanceof Error ? error.message : t('安装失败', 'Installation failed') }
}
async function toggle(id: string, enabled: boolean) {
try {
enabled ? await pluginStore.disablePlugin(id) : await pluginStore.enablePlugin(id)
} catch (error) { actionError.value = error instanceof Error ? error.message : '状态更新失败' }
} catch (error) { actionError.value = error instanceof Error ? error.message : t('状态更新失败', 'Status update failed') }
}
async function grant(id: string, permissions: string[]) {
if (!confirm(`将授权:${permissions.join('')}是否继续?`)) return
if (!confirm(`${t('将授权:', 'Grant permissions: ')}${permissions.join(', ')}${t('是否继续?', 'Continue?')}`)) return
try { await pluginStore.grantPermissions(id, permissions) }
catch (error) { actionError.value = error instanceof Error ? error.message : '授权失败' }
catch (error) { actionError.value = error instanceof Error ? error.message : t('授权失败', 'Authorization failed') }
}
async function uninstall(id: string, name: string) {
if (!confirm(`卸载"${name}"将移除其全部 Contribution,是否继续?`)) return
if (!confirm(t(`卸载${name}将移除其全部 Contribution,是否继续?`, `Uninstalling “${name}” removes all its contributions. Continue?`))) return
try { await pluginStore.uninstallPlugin(id) }
catch (error) { actionError.value = error instanceof Error ? error.message : '卸载失败' }
catch (error) { actionError.value = error instanceof Error ? error.message : t('卸载失败', 'Uninstall failed') }
}
const hasSettingsContribution = computed(() =>
@@ -65,8 +66,8 @@ const hasCommandContribution = computed(() =>
<template>
<section class="feature-page">
<header class="feature-header">
<div><h1>Plugin MCP</h1><p>管理插件生命周期MCP Host权限和受控 Contribution</p></div>
<button class="button-primary" @click="install">安装 Plugin</button>
<div><h1>{{ t('Plugin 与 MCP', 'Plugins and MCP') }}</h1><p>{{ t('管理插件生命周期、MCP Host、权限和受控 Contribution。', 'Manage plugin lifecycles, MCP hosts, permissions, and controlled contributions.') }}</p></div>
<button class="button-primary" @click="install">{{ t('安装 Plugin', 'Install Plugin') }}</button>
</header>
<div v-if="pluginStore.error || actionError" class="error-banner">
@@ -95,15 +96,15 @@ const hasCommandContribution = computed(() =>
v-if="pluginStore.selectedPlugin.status === 'permission_required'"
class="button-primary"
@click="grant(pluginStore.selectedPlugin.plugin_id, pluginStore.selectedPlugin.permissions)"
>授权权限</button>
>{{ t('授权权限', 'Grant permissions') }}</button>
<button
class="button-secondary"
@click="toggle(pluginStore.selectedPlugin.plugin_id, pluginStore.selectedPlugin.enabled)"
>{{ pluginStore.selectedPlugin.enabled ? '停用' : '启用' }}</button>
>{{ pluginStore.selectedPlugin.enabled ? t('停用', 'Disable') : t('启用', 'Enable') }}</button>
<button
class="button-danger"
@click="uninstall(pluginStore.selectedPlugin.plugin_id, pluginStore.selectedPlugin.name)"
>卸载</button>
>{{ t('卸载', 'Uninstall') }}</button>
</div>
</div>
@@ -114,25 +115,25 @@ const hasCommandContribution = computed(() =>
class="tab-btn"
:class="{ active: activeTab === 'info' }"
@click="activeTab = 'info'"
>概览</button>
>{{ t('概览', 'Overview') }}</button>
<button
v-if="hasCommandContribution"
class="tab-btn"
:class="{ active: activeTab === 'commands' }"
@click="activeTab = 'commands'"
>命令 ({{ pluginCommands.length }})</button>
>{{ t('命令', 'Commands') }} ({{ pluginCommands.length }})</button>
<button
v-if="hasSettingsContribution || pluginCommands.some(c => c.enabled)"
class="tab-btn"
:class="{ active: activeTab === 'settings' }"
@click="activeTab = 'settings'"
>设置</button>
>{{ t('设置', 'Settings') }}</button>
</div>
<div v-if="activeTab === 'info'" class="tab-content">
<div class="detail-grid">
<div>
<h3>权限</h3>
<h3>{{ t('权限', 'Permissions') }}</h3>
<div class="tag-list">
<span v-for="permission in pluginStore.selectedPlugin.permissions" :key="permission" class="badge warning">
{{ permission }}
@@ -158,7 +159,7 @@ const hasCommandContribution = computed(() =>
{{ pluginStore.selectedPlugin.last_error }}
</div>
<div v-if="pluginStore.selectedPlugin.dependent_skills?.length" class="notice-banner">
依赖此插件的 Skill{{ pluginStore.selectedPlugin.dependent_skills.join('') }}
{{ t('依赖此插件的 Skill', 'Skills that depend on this plugin: ') }}{{ pluginStore.selectedPlugin.dependent_skills.join(', ') }}
</div>
<PluginMcpPanel :plugin="pluginStore.selectedPlugin" />
</div>
@@ -175,8 +176,8 @@ const hasCommandContribution = computed(() =>
<div v-else-if="!pluginStore.plugins.length" class="empty-state">
<div>
<strong>{{ pluginStore.isLoading ? '正在加载…' : pluginStore.error ? '加载失败' : '尚未安装' }}</strong>
<button class="button-secondary" @click="pluginStore.loadPlugins">重新加载</button>
<strong>{{ pluginStore.isLoading ? t('正在加载…', 'Loading…') : pluginStore.error ? t('加载失败', 'Load failed') : t('尚未安装', 'No plugins installed') }}</strong>
<button class="button-secondary" @click="pluginStore.loadPlugins">{{ t('重新加载', 'Reload') }}</button>
</div>
</div>
@@ -204,7 +205,7 @@ const hasCommandContribution = computed(() =>
</div>
<p class="muted">{{ plugin.description }}</p>
<p class="subtle">
{{ plugin.permissions.length }} 项权限 · {{ plugin.contributions.length }} Contribution
{{ plugin.permissions.length }} {{ t('项权限', 'permissions') }} · {{ plugin.contributions.length }} {{ t('项 Contribution', 'contributions') }}
</p>
</article>
</div>