Files
JARVIS/frontend/src/components/settings/LLMTableRow.vue

284 lines
7.1 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 { ref, computed, watch } from 'vue'
import { Eye, EyeOff, Play, ChevronDown, ChevronRight, Trash2 } from 'lucide-vue-next'
import type { LLMModelConfig } from '@/api/settings'
const props = defineProps<{
model: LLMModelConfig
isExpanded: boolean
isNew?: boolean
}>()
const emit = defineEmits<{
(e: 'toggle'): void
(e: 'update', data: LLMModelConfig): void
(e: 'delete'): void
(e: 'test', data: LLMModelConfig): void
(e: 'save', data: LLMModelConfig): void
}>()
const showApiKey = ref(false)
const editingModel = ref<LLMModelConfig>({ ...props.model })
// Reinitialize editing model when expanding (handles editing different rows)
watch(() => props.isExpanded, (expanded, wasExpanded) => {
if (expanded && !wasExpanded) {
editingModel.value = { ...props.model }
}
})
const status = computed(() => {
if (!props.model.api_key || !props.model.model) return 'empty'
if (props.model.enabled) return 'available'
return 'unavailable'
})
const statusConfig = computed(() => ({
available: { icon: '●', color: '#10b981', label: '可用' },
unavailable: { icon: '○', color: '#6b7280', label: '不可用' },
empty: { icon: '⚠', color: '#ef4444', label: '未配置' }
}[status.value]))
function onProviderChange() {
const defaults: Record<string, string> = {
ollama: 'http://localhost:11434',
openai: 'https://api.openai.com/v1',
claude: 'https://api.anthropic.com',
deepseek: 'https://api.deepseek.com/v1'
}
if (!editingModel.value.base_url || Object.values(defaults).includes(editingModel.value.base_url)) {
editingModel.value.base_url = defaults[editingModel.value.provider] || ''
}
}
</script>
<template>
<div class="table-row" :class="{ expanded: isExpanded, 'is-new': isNew }">
<!-- 表格行可点击展开 -->
<div class="row-summary" @click="emit('toggle')">
<div class="cell cell-toggle">
<ChevronDown v-if="isExpanded" :size="14" />
<ChevronRight v-else :size="14" />
</div>
<div class="cell cell-name">{{ model.name || '未命名' }}</div>
<div class="cell cell-provider">{{ model.provider }}</div>
<div class="cell cell-model">{{ model.model || '-' }}</div>
<div class="cell cell-status" :style="{ color: statusConfig.color }">
{{ statusConfig.icon }} {{ statusConfig.label }}
</div>
<div class="cell cell-actions" @click.stop>
<button class="icon-btn danger" @click="emit('delete')" title="删除">
<Trash2 :size="12" />
</button>
</div>
</div>
<!-- 展开的详情面板 -->
<div v-if="isExpanded" class="expand-panel">
<div class="form-row">
<div class="form-group">
<label>// NAME</label>
<input v-model="editingModel.name" type="text" placeholder="模型名称" />
</div>
<div class="form-group">
<label>// PROVIDER</label>
<select v-model="editingModel.provider" @change="onProviderChange">
<option value="openai">OpenAI</option>
<option value="claude">Claude</option>
<option value="ollama">Ollama</option>
<option value="deepseek">DeepSeek</option>
<option value="custom">Custom</option>
</select>
</div>
<div class="form-group">
<label>// MODEL</label>
<input v-model="editingModel.model" type="text" placeholder="gpt-4o" />
</div>
</div>
<div class="form-group">
<label>// BASE URL</label>
<input v-model="editingModel.base_url" type="text" />
</div>
<div class="form-group">
<label>// API KEY</label>
<div class="input-with-toggle">
<input
v-model="editingModel.api_key"
:type="showApiKey ? 'text' : 'password'"
placeholder="sk-..."
/>
<button @click="showApiKey = !showApiKey">
<Eye v-if="!showApiKey" :size="14" />
<EyeOff v-else :size="14" />
</button>
</div>
</div>
<div class="panel-actions">
<button class="test-btn" @click="emit('test', editingModel)">
<Play :size="12" /> 测试连接
</button>
<button
class="save-btn"
:disabled="status !== 'available'"
@click="emit('save', editingModel)"
>
保存
</button>
<button class="cancel-btn" @click="emit('toggle')">取消</button>
</div>
</div>
</div>
</template>
<style scoped>
.table-row {
border: 1px solid var(--border-mid);
border-radius: var(--radius-md);
margin-bottom: 8px;
background: var(--bg-card);
}
.row-summary {
display: flex;
align-items: center;
padding: 10px 14px;
cursor: pointer;
}
.row-summary:hover {
background: rgba(0,245,212,0.05);
}
.cell {
font-family: var(--font-mono);
font-size: 11px;
}
.cell-toggle { width: 30px; }
.cell-name { flex: 1; min-width: 120px; }
.cell-provider { width: 80px; }
.cell-model { width: 120px; }
.cell-status { width: 80px; }
.cell-actions { width: 40px; text-align: right; }
.expand-panel {
padding: 14px;
border-top: 1px solid var(--border-dim);
background: var(--bg-void);
}
.form-row {
display: flex;
gap: 14px;
}
.form-row .form-group {
flex: 1;
}
.form-group {
margin-bottom: 14px;
}
.form-group label {
display: block;
font-family: var(--font-mono);
font-size: 9px;
letter-spacing: 0.15em;
color: var(--text-dim);
margin-bottom: 6px;
}
.form-group input,
.form-group select {
width: 100%;
padding: 8px 10px;
background: var(--bg-card);
border: 1px solid var(--border-mid);
border-radius: var(--radius-sm);
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 12px;
}
.input-with-toggle {
display: flex;
gap: 8px;
}
.input-with-toggle input {
flex: 1;
}
.input-with-toggle button {
width: 36px;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-card);
border: 1px solid var(--border-mid);
border-radius: var(--radius-sm);
color: var(--text-dim);
cursor: pointer;
}
.panel-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
margin-top: 14px;
}
.test-btn, .save-btn, .cancel-btn {
padding: 6px 14px;
border-radius: var(--radius-sm);
font-family: var(--font-mono);
font-size: 11px;
cursor: pointer;
display: flex;
align-items: center;
gap: 4px;
}
.test-btn {
background: transparent;
border: 1px solid rgba(0,245,212,0.3);
color: var(--accent-cyan);
}
.save-btn {
background: rgba(0,245,212,0.1);
border: 1px solid rgba(0,245,212,0.3);
color: var(--accent-cyan);
}
.save-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.cancel-btn {
background: transparent;
border: 1px solid var(--border-mid);
color: var(--text-dim);
}
.icon-btn {
width: 26px;
height: 26px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: 1px solid var(--border-mid);
border-radius: var(--radius-sm);
color: var(--text-dim);
cursor: pointer;
}
.icon-btn.danger:hover {
border-color: var(--accent-red);
color: var(--accent-red);
}
</style>