m19921414377's picture
Upload folder using huggingface_hub
6db48b4 verified
<template>
<!-- 服务商编辑/添加弹窗 -->
<div v-if="visible" class="modal-overlay" @click="$emit('close')">
<div class="modal-content" @click.stop>
<div class="modal-header">
<h3>{{ isEditing ? '编辑服务商' : '添加服务商' }}</h3>
<button class="close-btn" @click="$emit('close')">×</button>
</div>
<div class="modal-body">
<!-- 服务商名称(仅添加时显示) -->
<div class="form-group" v-if="!isEditing">
<label>服务商名称</label>
<input
type="text"
class="form-input"
:value="formData.name"
@input="updateField('name', ($event.target as HTMLInputElement).value)"
placeholder="例如: openai"
/>
<span class="form-hint">唯一标识,用于区分不同服务商</span>
</div>
<!-- 类型选择 -->
<div class="form-group">
<label>类型</label>
<select
class="form-select"
:value="formData.type"
@change="updateField('type', ($event.target as HTMLSelectElement).value)"
>
<option v-for="opt in typeOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
</div>
<!-- API Key -->
<div class="form-group">
<label>API Key</label>
<input
type="text"
class="form-input"
:value="formData.api_key"
@input="updateField('api_key', ($event.target as HTMLInputElement).value)"
:placeholder="isEditing && formData._has_api_key ? formData.api_key_masked : '输入 API Key'"
/>
<span class="form-hint" v-if="isEditing && formData._has_api_key">
已配置 API Key,留空表示不修改
</span>
</div>
<!-- Base URL -->
<div class="form-group" v-if="showBaseUrl">
<label>Base URL</label>
<input
type="text"
class="form-input"
:value="formData.base_url"
@input="updateField('base_url', ($event.target as HTMLInputElement).value)"
:placeholder="baseUrlPlaceholder"
/>
<span class="form-hint" v-if="previewUrl">
预览: {{ previewUrl }}
</span>
</div>
<!-- 模型 -->
<div class="form-group">
<label>模型</label>
<input
type="text"
class="form-input"
:value="formData.model"
@input="updateField('model', ($event.target as HTMLInputElement).value)"
:placeholder="modelPlaceholder"
/>
</div>
<!-- 端点路径(仅 OpenAI 兼容接口) -->
<div class="form-group" v-if="showEndpointType">
<label>API 端点路径</label>
<input
type="text"
class="form-input"
:value="formData.endpoint_type"
@input="updateField('endpoint_type', ($event.target as HTMLInputElement).value)"
placeholder="例如: /v1/chat/completions"
/>
<span class="form-hint">
默认端点:/v1/chat/completions(大多数 OpenAI 兼容 API 使用此端点)
</span>
</div>
</div>
<div class="modal-footer">
<button class="btn" @click="$emit('close')">取消</button>
<button
class="btn btn-secondary"
@click="$emit('test')"
:disabled="testing || (!formData.api_key && !isEditing)"
>
<span v-if="testing" class="spinner-small"></span>
{{ testing ? '测试中...' : '测试连接' }}
</button>
<button class="btn btn-primary" @click="$emit('save')">
{{ isEditing ? '保存' : '添加' }}
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
/**
* 服务商编辑/添加弹窗组件
*
* 功能:
* - 添加新服务商
* - 编辑现有服务商
* - 测试连接
*/
// 定义表单数据类型
interface FormData {
name: string
type: string
api_key: string
api_key_masked?: string
_has_api_key?: boolean
base_url: string
model: string
endpoint_type?: string
}
// 定义类型选项
interface TypeOption {
value: string
label: string
}
// 定义 Props
const props = defineProps<{
visible: boolean
isEditing: boolean
formData: FormData
testing: boolean
typeOptions: TypeOption[]
providerCategory: 'text' | 'image'
}>()
// 定义 Emits
const emit = defineEmits<{
(e: 'close'): void
(e: 'save'): void
(e: 'test'): void
(e: 'update:formData', data: FormData): void
}>()
// 更新表单字段
function updateField(field: keyof FormData, value: string) {
emit('update:formData', {
...props.formData,
[field]: value
})
}
// 是否显示 Base URL
const showBaseUrl = computed(() => {
return ['openai_compatible', 'google_gemini', 'google_genai', 'image_api'].includes(props.formData.type)
})
// 是否显示端点类型
const showEndpointType = computed(() => {
return props.formData.type === 'openai_compatible'
})
// Base URL 占位符
const baseUrlPlaceholder = computed(() => {
switch (props.formData.type) {
case 'google_gemini':
case 'google_genai':
return '例如: https://generativelanguage.googleapis.com'
default:
return '例如: https://api.openai.com'
}
})
// 模型占位符
const modelPlaceholder = computed(() => {
switch (props.formData.type) {
case 'google_gemini':
return '例如: gemini-2.0-flash-exp'
case 'google_genai':
return '例如: imagen-3.0-generate-002'
case 'image_api':
return '例如: flux-pro'
default:
return '例如: gpt-4o'
}
})
// 预览 URL
const previewUrl = computed(() => {
if (!props.formData.base_url) return ''
const baseUrl = props.formData.base_url.replace(/\/$/, '').replace(/\/v1$/, '')
switch (props.formData.type) {
case 'openai_compatible':
return `${baseUrl}/v1/chat/completions`
case 'google_gemini':
case 'google_genai':
return `${baseUrl}/v1beta/models/${props.formData.model || '{model}'}:generateContent`
case 'image_api':
return `${baseUrl}/v1/images/generations`
default:
return ''
}
})
</script>
<style scoped>
/* 模态框遮罩 */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 20px;
}
/* 模态框内容 */
.modal-content {
background: white;
border-radius: 12px;
width: 100%;
max-width: 500px;
max-height: 90vh;
overflow: hidden;
display: flex;
flex-direction: column;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2);
}
/* 头部 */
.modal-header {
padding: 20px 24px;
border-bottom: 1px solid var(--border-color, #eee);
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-header h3 {
margin: 0;
font-size: 18px;
font-weight: 600;
}
.close-btn {
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: #999;
padding: 0;
line-height: 1;
}
.close-btn:hover {
color: #333;
}
/* 主体 */
.modal-body {
padding: 24px;
overflow-y: auto;
flex: 1;
}
/* 表单组 */
.form-group {
margin-bottom: 20px;
}
.form-group:last-child {
margin-bottom: 0;
}
.form-group label {
display: block;
font-size: 14px;
font-weight: 500;
color: var(--text-main, #1a1a1a);
margin-bottom: 8px;
}
.form-input {
width: 100%;
padding: 10px 12px;
border: 1px solid var(--border-color, #eee);
border-radius: 8px;
font-size: 14px;
transition: border-color 0.2s, box-shadow 0.2s;
}
.form-input:focus {
outline: none;
border-color: var(--primary, #ff2442);
box-shadow: 0 0 0 3px rgba(255, 36, 66, 0.1);
}
.form-select {
width: 100%;
padding: 10px 12px;
border: 1px solid var(--border-color, #eee);
border-radius: 8px;
font-size: 14px;
background: white;
cursor: pointer;
}
.form-hint {
display: block;
font-size: 12px;
color: var(--text-sub, #666);
margin-top: 6px;
}
/* 底部 */
.modal-footer {
padding: 16px 24px;
border-top: 1px solid var(--border-color, #eee);
display: flex;
justify-content: flex-end;
gap: 12px;
}
/* 按钮样式 */
.btn {
padding: 8px 16px;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
border: 1px solid var(--border-color, #eee);
background: white;
color: var(--text-main, #1a1a1a);
transition: all 0.2s;
}
.btn:hover {
background: #f5f5f5;
}
.btn-primary {
background: var(--primary, #ff2442);
border-color: var(--primary, #ff2442);
color: white;
}
.btn-primary:hover {
background: var(--primary-hover, #e61e3a);
}
.btn-secondary {
background: #f0f0f0;
border-color: #ddd;
color: #333;
}
.btn-secondary:hover {
background: #e5e5e5;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* 加载动画 */
.spinner-small {
display: inline-block;
width: 14px;
height: 14px;
border: 2px solid currentColor;
border-top-color: transparent;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-right: 6px;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>