实现第二阶段分工表中吉海燕负责的 P0/P1 前端能力。
- Agent Trace 可视化:新增 traceService 将扁平事件流折叠为树
(ModelCallStarted 区间内的工具/文本事件挂为子节点,运行级事件保持顶层),
TraceTimeline 支持时间线/树两种视图、耗时统计与引用跳转。
- 主题包:新增 themePackageService(Web Mock Adapter),
校验 manifest 必填字段与 theme_id 格式,拒绝远程 css_entry;
CSS 侧拒绝 @import / expression() / javascript:,
未通过校验的 CSS 不会注入页面。内置主题走 data-theme=light|dark|sepia,
自定义主题走 data-theme={theme_id} + 独立 style 节点。
ThemesView 增加“已安装/社区主题”两个标签页与导入、预览、卸载流程。
- Mermaid:新增 mermaidService(securityLevel: strict)与 MermaidBlock,
markdown 渲染管线识别 mermaid 代码块;MarkdownContent 随亮/暗主题重渲染
(SVG 配色在渲染时烘焙,无法靠 CSS 变量事后调整)。
- 插件贡献 UI:PluginsView 增加“概览/命令/设置”标签页,
PluginSettingsPanel 按 Schema 动态生成表单;
secret 字段只写不读,仅展示 configured 状态,不进 store 也不回显。
与 main 上队友成果的整合(rebase 时处理):
- 命令面板保留队友基于真实后端的实现(when 条件求值、效果白名单、
参数命令跳详情页),仅叠加我新增的主题/任务两条内置命令。
- 删除我先前的 pluginContributionService(mock 版),
统一改用队友已落地的 pluginService 真实接口;
相应修正表单以匹配真实契约(options 为 string[]、min/max 可空、无 placeholder)。
- 移除 contracts 中与队友重复的 PluginHostStatus / PluginCommand /
PluginSettingField / PluginSettingsSchema 声明,以队友版本为准。
- PluginsView 概览页保留队友的 PluginMcpPanel,并补回被我改写时丢掉的空状态。
顺带修复:
- 开启 skipLibCheck —— mermaid 11.17 把 type-fest 泄漏进了发布产物的
.d.ts,但只声明为自身 devDependency,vue-tsc -b 会因此报错。
验证:pnpm test 26 文件 / 113 测试通过(新增 traceService、
themePackageService 两个测试文件共 22 项);pnpm build 通过。
405 lines
11 KiB
Vue
405 lines
11 KiB
Vue
<script setup lang="ts">
|
||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||
import type { PluginSettingsSchema, PluginSettingField } from '@/contracts'
|
||
import {
|
||
getPluginSettings,
|
||
updatePluginSettings,
|
||
putPluginSecret,
|
||
deletePluginSecret,
|
||
} from '@/services/pluginService'
|
||
|
||
const props = defineProps<{
|
||
pluginId: string
|
||
}>()
|
||
|
||
const emit = defineEmits<{
|
||
(e: 'saved'): void
|
||
(e: 'error', message: string): void
|
||
}>()
|
||
|
||
const schema = ref<PluginSettingsSchema | null>(null)
|
||
const values = reactive<Record<string, unknown>>({})
|
||
const secrets = reactive<Record<string, string>>({})
|
||
const isLoading = ref(false)
|
||
const isSaving = ref(false)
|
||
const saveError = ref('')
|
||
const hasChanges = ref(false)
|
||
|
||
const nonSecretFields = computed(() =>
|
||
schema.value?.fields.filter((f) => f.type !== 'secret') ?? []
|
||
)
|
||
|
||
const secretFields = computed(() =>
|
||
schema.value?.fields.filter((f) => f.type === 'secret') ?? []
|
||
)
|
||
|
||
async function load() {
|
||
isLoading.value = true
|
||
saveError.value = ''
|
||
try {
|
||
schema.value = await getPluginSettings(props.pluginId)
|
||
Object.keys(values).forEach((k) => delete values[k])
|
||
Object.assign(values, schema.value.values)
|
||
hasChanges.value = false
|
||
} catch (error) {
|
||
emit('error', error instanceof Error ? error.message : '设置加载失败')
|
||
} finally {
|
||
isLoading.value = false
|
||
}
|
||
}
|
||
|
||
async function save() {
|
||
if (!schema.value) return
|
||
isSaving.value = true
|
||
saveError.value = ''
|
||
try {
|
||
schema.value = await updatePluginSettings(
|
||
props.pluginId,
|
||
schema.value.schema_version,
|
||
{ ...values }
|
||
)
|
||
hasChanges.value = false
|
||
emit('saved')
|
||
} catch (error) {
|
||
saveError.value = error instanceof Error ? error.message : '保存失败'
|
||
} finally {
|
||
isSaving.value = false
|
||
}
|
||
}
|
||
|
||
async function saveSecret(key: string) {
|
||
if (!secrets[key]) return
|
||
isSaving.value = true
|
||
saveError.value = ''
|
||
try {
|
||
const result = await putPluginSecret(props.pluginId, key, secrets[key])
|
||
if (schema.value) {
|
||
schema.value.secrets[key] = { configured: result.configured }
|
||
}
|
||
secrets[key] = ''
|
||
emit('saved')
|
||
} catch (error) {
|
||
saveError.value = error instanceof Error ? error.message : '密钥保存失败'
|
||
} finally {
|
||
isSaving.value = false
|
||
}
|
||
}
|
||
|
||
async function clearSecret(key: string) {
|
||
if (!confirm(`确认删除 " ${key} " 的配置?`)) return
|
||
try {
|
||
await deletePluginSecret(props.pluginId, key)
|
||
if (schema.value) {
|
||
schema.value.secrets[key] = { configured: false }
|
||
}
|
||
emit('saved')
|
||
} catch (error) {
|
||
saveError.value = error instanceof Error ? error.message : '删除失败'
|
||
}
|
||
}
|
||
|
||
function setFieldValue(key: string, value: unknown, field: PluginSettingField) {
|
||
if (field.type === 'number') {
|
||
const num = Number(value)
|
||
if (field.minimum != null && num < field.minimum) return
|
||
if (field.maximum != null && num > field.maximum) return
|
||
values[key] = num
|
||
} else {
|
||
values[key] = value
|
||
}
|
||
hasChanges.value = true
|
||
}
|
||
|
||
onMounted(load)
|
||
watch(() => props.pluginId, load)
|
||
</script>
|
||
|
||
<template>
|
||
<div class="plugin-settings-panel">
|
||
<div v-if="isLoading" class="loading">加载设置中…</div>
|
||
|
||
<template v-else-if="schema && schema.fields.length > 0">
|
||
<div v-if="saveError" class="error-banner small">{{ saveError }}</div>
|
||
|
||
<div v-if="nonSecretFields.length" class="settings-section">
|
||
<h4>通用设置</h4>
|
||
<div class="form-grid">
|
||
<div v-for="field in nonSecretFields" :key="field.key" class="field">
|
||
<label>
|
||
{{ field.label }}
|
||
<span v-if="field.required" class="required">*</span>
|
||
</label>
|
||
<small v-if="field.description">{{ field.description }}</small>
|
||
|
||
<input
|
||
v-if="field.type === 'string'"
|
||
:value="values[field.key] ?? ''"
|
||
class="input"
|
||
@input="setFieldValue(field.key, ($event.target as HTMLInputElement).value, field)"
|
||
/>
|
||
|
||
<input
|
||
v-else-if="field.type === 'number'"
|
||
type="number"
|
||
:value="values[field.key] ?? field.default ?? 0"
|
||
:min="field.minimum ?? undefined"
|
||
:max="field.maximum ?? undefined"
|
||
class="input"
|
||
@input="setFieldValue(field.key, ($event.target as HTMLInputElement).value, field)"
|
||
/>
|
||
|
||
<label v-else-if="field.type === 'boolean'" class="switch-label">
|
||
<input
|
||
type="checkbox"
|
||
:checked="Boolean(values[field.key] ?? field.default)"
|
||
@change="setFieldValue(field.key, ($event.target as HTMLInputElement).checked, field)"
|
||
/>
|
||
<span class="switch-track"><span class="switch-thumb"></span></span>
|
||
<span class="switch-text">{{ values[field.key] ? '已启用' : '已禁用' }}</span>
|
||
</label>
|
||
|
||
<select
|
||
v-else-if="field.type === 'select'"
|
||
:value="String(values[field.key] ?? field.default ?? '')"
|
||
class="select"
|
||
@change="setFieldValue(field.key, ($event.target as HTMLSelectElement).value, field)"
|
||
>
|
||
<option v-for="opt in field.options" :key="opt" :value="opt">
|
||
{{ opt }}
|
||
</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-actions">
|
||
<button
|
||
class="button-primary"
|
||
:disabled="!hasChanges || isSaving"
|
||
@click="save"
|
||
>
|
||
{{ isSaving ? '保存中…' : '保存设置' }}
|
||
</button>
|
||
<span v-if="hasChanges" class="unsaved-hint">有未保存的更改</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="secretFields.length" class="settings-section">
|
||
<h4>密钥与凭据</h4>
|
||
<p class="section-hint">密钥加密存储,前端不会回显明文。</p>
|
||
<div class="form-grid">
|
||
<div v-for="field in secretFields" :key="field.key" class="field secret-field">
|
||
<label>{{ field.label }}</label>
|
||
<small v-if="field.description">{{ field.description }}</small>
|
||
<div class="secret-row">
|
||
<span
|
||
class="secret-status"
|
||
:class="schema.secrets[field.key]?.configured ? 'configured' : 'not-configured'"
|
||
>
|
||
{{ schema.secrets[field.key]?.configured ? '● 已配置' : '○ 未配置' }}
|
||
</span>
|
||
<template v-if="schema.secrets[field.key]?.configured">
|
||
<input
|
||
v-model="secrets[field.key]"
|
||
type="password"
|
||
placeholder="重新输入以更新"
|
||
class="input"
|
||
/>
|
||
<button class="button-secondary" :disabled="!secrets[field.key]" @click="saveSecret(field.key)">
|
||
更新
|
||
</button>
|
||
<button class="link-btn danger" @click="clearSecret(field.key)">清除</button>
|
||
</template>
|
||
<template v-else>
|
||
<input
|
||
v-model="secrets[field.key]"
|
||
type="password"
|
||
placeholder="请输入密钥"
|
||
class="input"
|
||
/>
|
||
<button
|
||
class="button-primary"
|
||
:disabled="!secrets[field.key]"
|
||
@click="saveSecret(field.key)"
|
||
>保存</button>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<div v-else class="empty-hint">
|
||
<p>此插件没有可配置项。</p>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.plugin-settings-panel {
|
||
display: grid;
|
||
gap: var(--space-lg);
|
||
}
|
||
|
||
.settings-section h4 {
|
||
margin-bottom: var(--space-sm);
|
||
font-size: var(--font-size-md);
|
||
}
|
||
|
||
.section-hint {
|
||
font-size: var(--font-size-sm);
|
||
color: var(--color-text-tertiary);
|
||
margin-bottom: var(--space-md);
|
||
}
|
||
|
||
.form-grid {
|
||
display: grid;
|
||
gap: var(--space-md);
|
||
}
|
||
|
||
.field {
|
||
display: grid;
|
||
gap: 4px;
|
||
}
|
||
|
||
.field label {
|
||
font-size: var(--font-size-sm);
|
||
color: var(--color-text-primary);
|
||
font-weight: 500;
|
||
}
|
||
|
||
.field small {
|
||
color: var(--color-text-tertiary);
|
||
font-size: var(--font-size-xs);
|
||
}
|
||
|
||
.required {
|
||
color: var(--color-error);
|
||
margin-left: 2px;
|
||
}
|
||
|
||
.input, .select {
|
||
padding: 6px 10px;
|
||
border: 1px solid var(--color-border-default);
|
||
border-radius: var(--radius-sm);
|
||
background: var(--color-surface-primary);
|
||
color: var(--color-text-primary);
|
||
font-size: var(--font-size-sm);
|
||
width: 100%;
|
||
transition: border-color var(--motion-fast);
|
||
}
|
||
|
||
.input:focus, .select:focus {
|
||
outline: none;
|
||
border-color: var(--color-border-focus);
|
||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-accent-primary) 15%, transparent);
|
||
}
|
||
|
||
.switch-label {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--space-sm);
|
||
cursor: pointer;
|
||
font-weight: 400 !important;
|
||
}
|
||
|
||
.switch-label input { display: none; }
|
||
|
||
.switch-track {
|
||
position: relative;
|
||
width: 40px;
|
||
height: 22px;
|
||
border-radius: 11px;
|
||
background: var(--color-background-tertiary);
|
||
transition: background-color var(--motion-fast);
|
||
}
|
||
|
||
.switch-thumb {
|
||
position: absolute;
|
||
top: 2px;
|
||
left: 2px;
|
||
width: 18px;
|
||
height: 18px;
|
||
border-radius: 50%;
|
||
background: var(--color-text-inverse);
|
||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
||
transition: transform var(--motion-fast);
|
||
}
|
||
|
||
.switch-label input:checked + .switch-track {
|
||
background: var(--color-accent-primary);
|
||
}
|
||
|
||
.switch-label input:checked + .switch-track .switch-thumb {
|
||
transform: translateX(18px);
|
||
}
|
||
|
||
.switch-text {
|
||
font-size: var(--font-size-sm);
|
||
color: var(--color-text-secondary);
|
||
}
|
||
|
||
.form-actions {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--space-md);
|
||
margin-top: var(--space-md);
|
||
}
|
||
|
||
.unsaved-hint {
|
||
font-size: var(--font-size-xs);
|
||
color: var(--color-warning);
|
||
}
|
||
|
||
.secret-field .secret-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--space-sm);
|
||
margin-top: 4px;
|
||
}
|
||
|
||
.secret-status {
|
||
font-size: var(--font-size-xs);
|
||
padding: 2px 8px;
|
||
border-radius: var(--radius-full);
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.secret-status.configured {
|
||
background: var(--color-success-soft);
|
||
color: var(--color-success);
|
||
}
|
||
|
||
.secret-status.not-configured {
|
||
background: var(--color-background-tertiary);
|
||
color: var(--color-text-tertiary);
|
||
}
|
||
|
||
.secret-row .input {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
|
||
.error-banner.small {
|
||
padding: var(--space-sm) var(--space-md);
|
||
font-size: var(--font-size-sm);
|
||
}
|
||
|
||
.loading, .empty-hint {
|
||
padding: var(--space-xl);
|
||
text-align: center;
|
||
color: var(--color-text-tertiary);
|
||
font-size: var(--font-size-sm);
|
||
}
|
||
|
||
.link-btn {
|
||
background: none;
|
||
border: none;
|
||
color: var(--color-text-secondary);
|
||
cursor: pointer;
|
||
font-size: var(--font-size-sm);
|
||
padding: 0;
|
||
}
|
||
.link-btn.danger { color: var(--color-error); }
|
||
.link-btn:hover { text-decoration: underline; }
|
||
</style>
|