Files
NotesAgentic/frontend/src/features/plugins/PluginSettingsPanel.vue
T

434 lines
12 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, onBeforeUnmount, 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)
let editVersion = 0
let loadVersion = 0
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() {
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 {
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) {
if (version === loadVersion) emit('error', error instanceof Error ? error.message : '设置加载失败')
} finally {
if (version === loadVersion) isLoading.value = false
}
}
async function save() {
if (!schema.value || isSaving.value) return
const version = loadVersion
const submittedEditVersion = editVersion
const pluginId = props.pluginId
isSaving.value = true
saveError.value = ''
try {
const saved = await updatePluginSettings(
pluginId,
schema.value.schema_version,
{ ...values }
)
if (version !== loadVersion) return
schema.value = saved
hasChanges.value = editVersion !== submittedEditVersion
emit('saved')
} catch (error) {
if (version === loadVersion) saveError.value = error instanceof Error ? error.message : '保存失败'
} finally {
if (version === loadVersion) isSaving.value = false
}
}
async function saveSecret(key: string) {
if (!schema.value || !secrets[key] || isSaving.value) return
const version = loadVersion
const pluginId = props.pluginId
const submittedSecret = secrets[key]
isSaving.value = true
saveError.value = ''
try {
const result = await putPluginSecret(pluginId, key, submittedSecret)
if (version !== loadVersion || pluginId !== props.pluginId) return
if (schema.value) {
schema.value.secrets[key] = { configured: result.configured }
}
if (secrets[key] === submittedSecret) secrets[key] = ''
emit('saved')
} catch (error) {
if (version === loadVersion && pluginId === props.pluginId) saveError.value = error instanceof Error ? error.message : '密钥保存失败'
} finally {
if (version === loadVersion && pluginId === props.pluginId) isSaving.value = false
}
}
async function clearSecret(key: string) {
if (!schema.value || isSaving.value) return
if (!confirm(`确认删除 " ${key} " 的配置?`)) return
const version = loadVersion
const pluginId = props.pluginId
isSaving.value = true
saveError.value = ''
try {
await deletePluginSecret(pluginId, key)
if (version !== loadVersion || pluginId !== props.pluginId) return
if (schema.value) {
schema.value.secrets[key] = { configured: false }
}
emit('saved')
} catch (error) {
if (version === loadVersion && pluginId === props.pluginId) saveError.value = error instanceof Error ? error.message : '删除失败'
} finally {
if (version === loadVersion && pluginId === props.pluginId) isSaving.value = false
}
}
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
editVersion++
}
onMounted(load)
onBeforeUnmount(() => { loadVersion++ })
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] || isSaving" @click="saveSecret(field.key)">
更新
</button>
<button class="link-btn danger" :disabled="isSaving" @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] || isSaving"
@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>