Files
NotesAgentic/frontend/src/features/plugins/PluginSettingsPanel.vue
T
saint 639f38c1fc feat(frontend): 第二阶段前端 Agent Trace / 主题包 / Mermaid 能力
实现第二阶段分工表中吉海燕负责的 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 通过。
2026-09-04 21:36:47 +08:00

405 lines
11 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>