fix(frontend): 同步 main 并修复 phase2 关闭审阅意见
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user