mirror of
https://github.com/Tencent/WeKnora.git
synced 2026-09-19 10:28:49 +08:00
refactor(frontend): unify Model/WebSearch/MCP settings pages with shared card + drawer
Aligns the three list-style settings pages (ModelSettings, WebSearchSettings,
McpSettings) under a consistent visual language, and replaces the modal editors
with right-side drawers for a calmer editing flow.
- Extract shared building blocks under frontend/src/components/settings/:
SettingCard (list-item card), SettingDrawer (500px right drawer with a
pinned footer), and useConfirmDelete (a single DialogPlugin.confirm path
that replaces the mix of window.confirm / t-popconfirm / ad-hoc dialogs).
- Swap the old bespoke overlay / t-dialog shells in ModelEditorDialog and
McpServiceDialog for SettingDrawer. Form logic is untouched.
- Model settings now use a single t-tabs filter (All/Chat/Embedding/ReRank/
Vision/Speech with per-type counts) and decouple "Add model" from the tab
state via a dropdown in the header. Type tags get a 5-color palette so
categories read at a glance. The list is a two-column grid.
- Web search and MCP lists are likewise rendered as two-column grids, with
richer cards: transport type / provider, on-off and default state, plus a
compact meta row showing base URL, proxy or service URL.
- Introduce modelSettings.typeShort.{chat,embedding,rerank,vllm,asr} in all
four locales for the shortened category labels.
This commit is contained in:
@@ -1,24 +1,15 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="modal">
|
||||
<div v-if="dialogVisible" class="model-editor-overlay" @mousedown.self="handleOverlayMouseDown" @mouseup.self="handleOverlayMouseUp">
|
||||
<div class="model-editor-modal">
|
||||
<!-- 关闭按钮 -->
|
||||
<button class="close-btn" @click="handleCancel" :aria-label="$t('common.close')">
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path d="M15 5L5 15M5 5L15 15" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- 标题区域 -->
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-title">{{ isEdit ? $t('model.editor.editTitle') : $t('model.editor.addTitle') }}</h2>
|
||||
<p class="modal-desc">{{ getModalDescription() }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 表单内容区域 -->
|
||||
<div class="modal-body">
|
||||
<t-form ref="formRef" :data="formData" :rules="rules" layout="vertical">
|
||||
<SettingDrawer
|
||||
:visible="dialogVisible"
|
||||
:title="isEdit ? $t('model.editor.editTitle') : $t('model.editor.addTitle')"
|
||||
:description="getModalDescription()"
|
||||
:confirm-loading="saving"
|
||||
:confirm-disabled="formData.provider === 'weknoracloud' && wkcCredentialState !== 'configured'"
|
||||
@update:visible="(v: boolean) => dialogVisible = v"
|
||||
@confirm="handleConfirm"
|
||||
@cancel="handleCancel"
|
||||
>
|
||||
<t-form ref="formRef" :data="formData" :rules="rules" layout="vertical">
|
||||
<!-- 模型来源 -->
|
||||
<div class="form-item">
|
||||
<label class="form-label required">{{ $t('model.editor.sourceLabel') }}</label>
|
||||
@@ -329,21 +320,7 @@
|
||||
</div>
|
||||
|
||||
</t-form>
|
||||
</div>
|
||||
|
||||
<!-- 底部按钮区域 -->
|
||||
<div class="modal-footer">
|
||||
<t-button theme="default" variant="outline" @click="handleCancel">
|
||||
{{ $t('common.cancel') }}
|
||||
</t-button>
|
||||
<t-button theme="primary" @click="handleConfirm" :loading="saving" :disabled="formData.provider === 'weknoracloud' && wkcCredentialState !== 'configured'">
|
||||
{{ $t('common.save') }}
|
||||
</t-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</SettingDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -353,6 +330,7 @@ import { checkOllamaModels, checkRemoteModel, testEmbeddingModel, checkRerankMod
|
||||
import { getWeKnoraCloudStatus } from '@/api/model'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useUIStore } from '@/stores/ui'
|
||||
import SettingDrawer from '@/components/settings/SettingDrawer.vue'
|
||||
|
||||
interface CustomHeaderItem {
|
||||
key: string
|
||||
@@ -707,6 +685,11 @@ const checkOllamaServiceStatus = async () => {
|
||||
} finally {
|
||||
checkingOllamaStatus.value = false
|
||||
}
|
||||
|
||||
// Ollama 不可用时,新增场景下默认切换到 remote
|
||||
if (ollamaServiceStatus.value === false && !isEdit.value && formData.value.source === 'local') {
|
||||
formData.value.source = 'remote'
|
||||
}
|
||||
}
|
||||
|
||||
// 打开Ollama设置窗口
|
||||
@@ -728,28 +711,46 @@ const goToOllamaSettings = async () => {
|
||||
console.log('uiStore.openSettings调用完成')
|
||||
}
|
||||
|
||||
// 上一次打开时的 modelData id:用来判断切换模型/新增 vs. 同一次新增的连续打开
|
||||
const lastOpenedModelId = ref<string | null>(null)
|
||||
|
||||
// 监听 visible 变化,初始化表单
|
||||
watch(() => props.visible, (val) => {
|
||||
if (val) {
|
||||
// 锁定背景滚动
|
||||
document.body.style.overflow = 'hidden'
|
||||
|
||||
// 检查Ollama服务状态
|
||||
checkOllamaServiceStatus()
|
||||
|
||||
// 从 API 加载 Model Provider 列表
|
||||
loadProviders()
|
||||
|
||||
// 每次打开都清理上一次遗留的校验/检测结果,避免编辑别的模型时
|
||||
// 直接显示上一次的“连接成功”
|
||||
modelChecked.value = false
|
||||
modelAvailable.value = false
|
||||
remoteChecked.value = false
|
||||
remoteAvailable.value = false
|
||||
remoteMessage.value = ''
|
||||
dimensionChecked.value = false
|
||||
dimensionSuccess.value = false
|
||||
dimensionMessage.value = ''
|
||||
|
||||
const currentId = props.modelData?.id ?? null
|
||||
|
||||
if (props.modelData) {
|
||||
// 编辑:始终用最新的 modelData 覆盖
|
||||
formData.value = {
|
||||
...props.modelData,
|
||||
customHeaders: Array.isArray(props.modelData.customHeaders)
|
||||
? props.modelData.customHeaders.map(h => ({ key: h.key, value: h.value }))
|
||||
: []
|
||||
}
|
||||
} else {
|
||||
} else if (lastOpenedModelId.value !== null || !formData.value.id) {
|
||||
// 上次是编辑某个模型,或第一次新增 → 重置成空白
|
||||
resetForm()
|
||||
}
|
||||
// 否则:连续两次"新增"打开(中间是点遮罩/ESC 关闭的)→ 保留上次填写
|
||||
|
||||
lastOpenedModelId.value = currentId
|
||||
|
||||
// ReRank 模型强制使用 remote 来源(Ollama 不支持 ReRank)
|
||||
if (props.modelType === 'rerank') {
|
||||
@@ -760,9 +761,6 @@ watch(() => props.visible, (val) => {
|
||||
if (formData.value.provider === 'weknoracloud') {
|
||||
checkWkcCredentialStatus()
|
||||
}
|
||||
} else {
|
||||
// 恢复背景滚动
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1121,6 +1119,9 @@ const handleConfirm = async () => {
|
||||
|
||||
emit('confirm', { ...formData.value })
|
||||
dialogVisible.value = false
|
||||
// 保存成功后重置草稿,下次打开新增模型时是空白
|
||||
resetForm()
|
||||
lastOpenedModelId.value = null
|
||||
// 移除此处的成功提示,由父组件统一处理
|
||||
} catch (error) {
|
||||
console.error('表单验证失败:', error)
|
||||
@@ -1253,133 +1254,19 @@ watch(() => formData.value.modelName, () => {
|
||||
dimensionMessage.value = ''
|
||||
})
|
||||
|
||||
// 取消
|
||||
// 取消(点击底部"取消"按钮触发;点遮罩/ESC 不触发,从而保留草稿)
|
||||
const handleCancel = () => {
|
||||
resetForm()
|
||||
lastOpenedModelId.value = null
|
||||
dialogVisible.value = false
|
||||
}
|
||||
|
||||
// 遮罩层点击关闭:只有 mousedown 和 mouseup 都发生在遮罩层上才关闭,
|
||||
// 防止在输入框中拖选文字时鼠标滑出弹窗导致误关闭
|
||||
let overlayMouseDownFired = false
|
||||
const handleOverlayMouseDown = () => {
|
||||
overlayMouseDownFired = true
|
||||
}
|
||||
const handleOverlayMouseUp = () => {
|
||||
if (overlayMouseDownFired) {
|
||||
handleCancel()
|
||||
}
|
||||
overlayMouseDownFired = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
// 遮罩层
|
||||
.model-editor-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1200;
|
||||
backdrop-filter: blur(4px);
|
||||
overflow: hidden;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
// 弹窗主体
|
||||
.model-editor-modal {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 560px;
|
||||
max-height: 90vh;
|
||||
background: var(--td-bg-color-container);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 6px 28px rgba(15, 23, 42, 0.08);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
// 关闭按钮
|
||||
.close-btn {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--td-text-color-secondary);
|
||||
transition: all 0.15s ease;
|
||||
z-index: 10;
|
||||
|
||||
&:hover {
|
||||
background: var(--td-bg-color-secondarycontainer);
|
||||
color: var(--td-text-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
// 标题区域
|
||||
.modal-header {
|
||||
padding: 24px 24px 16px;
|
||||
border-bottom: 1px solid var(--td-component-stroke);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
margin: 0 0 6px 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--td-text-color-primary);
|
||||
}
|
||||
|
||||
.modal-desc {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--td-text-color-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
// 内容区域
|
||||
.modal-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 24px;
|
||||
background: var(--td-bg-color-container);
|
||||
|
||||
// 自定义滚动条
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: var(--td-bg-color-secondarycontainer);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--td-bg-color-component-disabled);
|
||||
border-radius: 3px;
|
||||
transition: background 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: var(--td-bg-color-component-disabled);
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.t-form) {
|
||||
.t-form-item {
|
||||
display: none;
|
||||
}
|
||||
// 原生 t-form-item 容器置空(本组件使用自定义 .form-item + 手写 label)
|
||||
:deep(.t-form) {
|
||||
.t-form-item {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1407,38 +1294,14 @@ const handleOverlayMouseUp = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 输入框样式
|
||||
// 输入框样式:只在最外层 .t-input 上调字号,避免在内部 wrap/inner 上重复加边
|
||||
// 与 border-radius,造成视觉上"嵌套圆角容器"的错觉
|
||||
:deep(.t-input),
|
||||
:deep(.t-select),
|
||||
:deep(.t-textarea),
|
||||
:deep(.t-input-number) {
|
||||
width: 100%;
|
||||
font-size: 13px;
|
||||
|
||||
.t-input__inner,
|
||||
.t-input__wrap,
|
||||
input,
|
||||
textarea {
|
||||
font-size: 13px;
|
||||
border-radius: 6px;
|
||||
border-color: var(--td-component-stroke);
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
&:hover .t-input__inner,
|
||||
&:hover .t-input__wrap,
|
||||
&:hover input,
|
||||
&:hover textarea {
|
||||
border-color: var(--td-component-stroke);
|
||||
}
|
||||
|
||||
&.t-is-focused .t-input__inner,
|
||||
&.t-is-focused .t-input__wrap,
|
||||
&.t-is-focused input,
|
||||
&.t-is-focused textarea {
|
||||
border-color: var(--td-brand-color);
|
||||
box-shadow: 0 0 0 2px rgba(7, 192, 95, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
// 厂商选择器样式 — 移至非 scoped 块,因为 t-select popup 渲染到 body 下
|
||||
@@ -1482,72 +1345,6 @@ const handleOverlayMouseUp = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 底部按钮区域
|
||||
.modal-footer {
|
||||
padding: 16px 24px;
|
||||
border-top: 1px solid var(--td-component-stroke);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
background: var(--td-bg-color-secondarycontainer);
|
||||
|
||||
:deep(.t-button) {
|
||||
min-width: 80px;
|
||||
height: 36px;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
border-radius: 6px;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&.t-button--theme-primary {
|
||||
background: var(--td-brand-color);
|
||||
border-color: var(--td-brand-color);
|
||||
|
||||
&:hover {
|
||||
background: var(--td-brand-color);
|
||||
border-color: var(--td-brand-color);
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: var(--td-brand-color-active);
|
||||
border-color: var(--td-brand-color-active);
|
||||
}
|
||||
}
|
||||
|
||||
&.t-button--variant-outline {
|
||||
color: var(--td-text-color-secondary);
|
||||
border-color: var(--td-component-stroke);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--td-brand-color);
|
||||
color: var(--td-brand-color);
|
||||
background: rgba(7, 192, 95, 0.04);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 过渡动画
|
||||
.modal-enter-active,
|
||||
.modal-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
|
||||
.model-editor-modal {
|
||||
transition: transform 0.2s ease, opacity 0.2s ease;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-enter-from,
|
||||
.modal-leave-to {
|
||||
opacity: 0;
|
||||
|
||||
.model-editor-modal {
|
||||
transform: scale(0.95);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// API 测试区域
|
||||
.api-test-section {
|
||||
display: flex;
|
||||
@@ -1903,20 +1700,36 @@ const handleOverlayMouseUp = () => {
|
||||
// 覆盖 TDesign option 默认固定高度,让两行内容正常展示
|
||||
.t-select-option {
|
||||
height: auto !important;
|
||||
padding: 0 8px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
margin: 0 4px;
|
||||
outline: none;
|
||||
|
||||
&:focus,
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
// 命中态:浅一点的底色,去掉默认的描边/反色
|
||||
.t-select-option.t-is-selected {
|
||||
background-color: var(--td-brand-color-light);
|
||||
color: var(--td-text-color-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.provider-option {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 6px 0;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
|
||||
.provider-name {
|
||||
font-size: 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--td-text-color-primary);
|
||||
line-height: 22px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.provider-desc {
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
<template>
|
||||
<div class="setting-card" :class="{ 'setting-card--disabled': disabled }">
|
||||
<div class="setting-card__header">
|
||||
<h3 class="setting-card__title" :title="title">{{ title }}</h3>
|
||||
<div class="setting-card__header-right">
|
||||
<slot name="controls" />
|
||||
<t-dropdown
|
||||
v-if="actions && actions.length > 0"
|
||||
:options="actions"
|
||||
placement="bottom-right"
|
||||
attach="body"
|
||||
@click="(data: any) => emit('action', data.value)"
|
||||
>
|
||||
<t-button variant="text" shape="square" size="small" class="setting-card__more">
|
||||
<t-icon name="more" />
|
||||
</t-button>
|
||||
</t-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="$slots.tags" class="setting-card__tags">
|
||||
<slot name="tags" />
|
||||
</div>
|
||||
<p v-if="description" class="setting-card__desc">{{ description }}</p>
|
||||
<div v-if="$slots.meta" class="setting-card__meta">
|
||||
<slot name="meta" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface DropdownOption {
|
||||
content: string
|
||||
value: string
|
||||
theme?: 'default' | 'success' | 'warning' | 'error' | 'primary'
|
||||
}
|
||||
|
||||
interface Props {
|
||||
title: string
|
||||
description?: string
|
||||
disabled?: boolean
|
||||
actions?: DropdownOption[]
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
description: '',
|
||||
disabled: false,
|
||||
actions: () => []
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'action', value: string): void
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.setting-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 16px;
|
||||
border: 1px solid var(--td-component-stroke);
|
||||
border-radius: 8px;
|
||||
background: var(--td-bg-color-container);
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease;
|
||||
min-width: 0;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--td-brand-color);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
&--disabled {
|
||||
background: var(--td-bg-color-secondarycontainer);
|
||||
|
||||
.setting-card__title {
|
||||
color: var(--td-text-color-secondary);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: var(--td-brand-color-light);
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.setting-card__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.setting-card__title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
color: var(--td-text-color-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.setting-card__header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.setting-card__more {
|
||||
color: var(--td-text-color-placeholder);
|
||||
padding: 4px;
|
||||
|
||||
&:hover {
|
||||
background: var(--td-bg-color-secondarycontainer);
|
||||
color: var(--td-text-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.setting-card__tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 20px;
|
||||
}
|
||||
|
||||
.setting-card__desc {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: var(--td-text-color-secondary);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.setting-card__meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-size: 12px;
|
||||
color: var(--td-text-color-placeholder);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,118 @@
|
||||
<template>
|
||||
<t-drawer
|
||||
v-model:visible="drawerVisible"
|
||||
:header="title"
|
||||
:size="width"
|
||||
placement="right"
|
||||
destroy-on-close
|
||||
>
|
||||
<div class="setting-drawer__body">
|
||||
<p v-if="description" class="setting-drawer__desc">{{ description }}</p>
|
||||
<slot />
|
||||
</div>
|
||||
<template v-if="!hideFooter" #footer>
|
||||
<div class="setting-drawer__footer">
|
||||
<div class="setting-drawer__footer-left">
|
||||
<slot name="footer-left" />
|
||||
</div>
|
||||
<div class="setting-drawer__footer-right">
|
||||
<t-button theme="default" variant="outline" @click="handleCancel">
|
||||
{{ cancelText || t('common.cancel') }}
|
||||
</t-button>
|
||||
<t-button
|
||||
theme="primary"
|
||||
:loading="confirmLoading"
|
||||
:disabled="confirmDisabled"
|
||||
@click="handleConfirm"
|
||||
>
|
||||
{{ confirmText || t('common.save') }}
|
||||
</t-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</t-drawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
interface Props {
|
||||
visible: boolean
|
||||
title: string
|
||||
description?: string
|
||||
width?: string
|
||||
confirmLoading?: boolean
|
||||
confirmDisabled?: boolean
|
||||
confirmText?: string
|
||||
cancelText?: string
|
||||
hideFooter?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
description: '',
|
||||
width: '500px',
|
||||
confirmLoading: false,
|
||||
confirmDisabled: false,
|
||||
confirmText: '',
|
||||
cancelText: '',
|
||||
hideFooter: false
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:visible', value: boolean): void
|
||||
(e: 'confirm'): void
|
||||
(e: 'cancel'): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const drawerVisible = computed({
|
||||
get: () => props.visible,
|
||||
set: (val) => emit('update:visible', val)
|
||||
})
|
||||
|
||||
const handleConfirm = () => emit('confirm')
|
||||
const handleCancel = () => {
|
||||
emit('cancel')
|
||||
emit('update:visible', false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.setting-drawer__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.setting-drawer__desc {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--td-text-color-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.setting-drawer__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.setting-drawer__footer-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.setting-drawer__footer-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,36 @@
|
||||
import { DialogPlugin } from 'tdesign-vue-next'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
interface ConfirmDeleteOptions {
|
||||
title?: string
|
||||
body: string
|
||||
confirmText?: string
|
||||
cancelText?: string
|
||||
onConfirm: () => Promise<void> | void
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一的删除确认交互,基于 TDesign DialogPlugin.confirm。
|
||||
* 取代散落在各处的 window.confirm / t-popconfirm / 自定义 Dialog 写法。
|
||||
*/
|
||||
export function useConfirmDelete() {
|
||||
const { t } = useI18n()
|
||||
|
||||
return (opts: ConfirmDeleteOptions) => {
|
||||
const dialog = DialogPlugin.confirm({
|
||||
header: opts.title || (t('common.confirmDelete') as string),
|
||||
body: opts.body,
|
||||
confirmBtn: opts.confirmText || (t('common.delete') as string),
|
||||
cancelBtn: opts.cancelText || (t('common.cancel') as string),
|
||||
theme: 'warning',
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await opts.onConfirm()
|
||||
} finally {
|
||||
dialog.hide()
|
||||
}
|
||||
}
|
||||
})
|
||||
return dialog
|
||||
}
|
||||
}
|
||||
@@ -2855,6 +2855,13 @@ export default {
|
||||
modelSettings: {
|
||||
title: 'Model Settings',
|
||||
description: 'Manage different types of AI models, including local Ollama and remote APIs',
|
||||
typeShort: {
|
||||
chat: 'Chat',
|
||||
embedding: 'Embedding',
|
||||
rerank: 'ReRank',
|
||||
vllm: 'Vision',
|
||||
asr: 'Speech',
|
||||
},
|
||||
actions: {
|
||||
addModel: 'Add Model',
|
||||
setDefault: 'Set as Default'
|
||||
|
||||
@@ -2902,6 +2902,13 @@ export default {
|
||||
modelSettings: {
|
||||
title: "모델 설정",
|
||||
description: "다양한 유형의 AI 모델을 관리합니다. Ollama 로컬 모델과 원격 API를 지원합니다",
|
||||
typeShort: {
|
||||
chat: "대화",
|
||||
embedding: "Embedding",
|
||||
rerank: "ReRank",
|
||||
vllm: "비전",
|
||||
asr: "음성",
|
||||
},
|
||||
actions: {
|
||||
addModel: "모델 추가",
|
||||
setDefault: "기본값으로 설정",
|
||||
|
||||
@@ -2660,6 +2660,13 @@ export default {
|
||||
modelSettings: {
|
||||
title: 'Настройки моделей',
|
||||
description: 'Управление типами AI‑моделей: локальные (Ollama) и удалённые API',
|
||||
typeShort: {
|
||||
chat: 'Чат',
|
||||
embedding: 'Embedding',
|
||||
rerank: 'ReRank',
|
||||
vllm: 'Зрение',
|
||||
asr: 'Речь',
|
||||
},
|
||||
actions: {
|
||||
addModel: 'Добавить модель',
|
||||
setDefault: 'Сделать по умолчанию'
|
||||
|
||||
@@ -2852,6 +2852,13 @@ export default {
|
||||
modelSettings: {
|
||||
title: "模型配置",
|
||||
description: "管理不同类型的 AI 模型,支持 Ollama 本地模型和远程 API",
|
||||
typeShort: {
|
||||
chat: "对话",
|
||||
embedding: "Embedding",
|
||||
rerank: "ReRank",
|
||||
vllm: "视觉",
|
||||
asr: "语音",
|
||||
},
|
||||
actions: {
|
||||
addModel: "添加模型",
|
||||
setDefault: "设为默认",
|
||||
|
||||
@@ -11,9 +11,9 @@
|
||||
<t-loading :text="$t('common.loading')" />
|
||||
</div>
|
||||
|
||||
<div v-else class="services-container">
|
||||
<div class="services-header">
|
||||
<div class="header-info">
|
||||
<template v-else>
|
||||
<div class="settings-toolbar">
|
||||
<div class="toolbar-info">
|
||||
<h3>{{ $t('mcpSettings.configuredServices') }}</h3>
|
||||
<p>{{ $t('mcpSettings.manageAndTest') }}</p>
|
||||
</div>
|
||||
@@ -24,73 +24,67 @@
|
||||
</div>
|
||||
|
||||
<div v-if="services.length === 0" class="empty-state">
|
||||
<t-empty :description="$t('mcpSettings.empty')" >
|
||||
<t-button theme="primary" @click="handleAdd">{{ $t('mcpSettings.addFirst') }}</t-button>
|
||||
<t-empty :description="$t('mcpSettings.empty')">
|
||||
<t-button theme="primary" size="small" @click="handleAdd">
|
||||
<template #icon><t-icon name="add" /></template>
|
||||
{{ $t('mcpSettings.addFirst') }}
|
||||
</t-button>
|
||||
</t-empty>
|
||||
</div>
|
||||
|
||||
<div v-else class="services-list">
|
||||
<div v-for="service in services" :key="service.id" class="service-card">
|
||||
<div class="service-info">
|
||||
<div class="service-header">
|
||||
<div class="service-name">
|
||||
{{ service.name }}
|
||||
<t-tag
|
||||
v-if="service.is_builtin"
|
||||
theme="warning"
|
||||
size="small"
|
||||
variant="light"
|
||||
>
|
||||
{{ $t('mcpSettings.builtin') }}
|
||||
</t-tag>
|
||||
<t-tag
|
||||
:theme="getTransportTypeTheme(service.transport_type)"
|
||||
size="small"
|
||||
variant="light"
|
||||
>
|
||||
{{ getTransportTypeLabel(service.transport_type) }}
|
||||
</t-tag>
|
||||
</div>
|
||||
<div class="service-controls">
|
||||
<t-switch
|
||||
v-model="service.enabled"
|
||||
@change="() => handleToggleEnabled(service)"
|
||||
size="medium"
|
||||
:disabled="service.is_builtin"
|
||||
/>
|
||||
<t-dropdown
|
||||
v-if="!service.is_builtin"
|
||||
:options="getServiceOptions(service)"
|
||||
@click="(data: any) => handleMenuAction(data, service)"
|
||||
placement="bottom-right"
|
||||
:disabled="testing"
|
||||
>
|
||||
<t-button variant="text" shape="square" size="small" class="more-btn" :disabled="testing">
|
||||
<t-icon name="more" />
|
||||
</t-button>
|
||||
</t-dropdown>
|
||||
<t-dropdown
|
||||
v-else
|
||||
:options="getBuiltinServiceOptions(service)"
|
||||
@click="(data: any) => handleMenuAction(data, service)"
|
||||
placement="bottom-right"
|
||||
:disabled="testing"
|
||||
>
|
||||
<t-button variant="text" shape="square" size="small" class="more-btn" :disabled="testing">
|
||||
<t-icon name="more" />
|
||||
</t-button>
|
||||
</t-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="service.description" class="service-description">
|
||||
{{ service.description }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="services-grid">
|
||||
<SettingCard
|
||||
v-for="service in services"
|
||||
:key="service.id"
|
||||
:title="service.name"
|
||||
:description="service.description || ''"
|
||||
:disabled="service.is_builtin"
|
||||
:actions="service.is_builtin ? getBuiltinServiceOptions() : getServiceOptions()"
|
||||
@action="(value: string) => handleMenuAction({ value }, service)"
|
||||
>
|
||||
<template #tags>
|
||||
<t-tag
|
||||
:theme="getTransportTypeTheme(service.transport_type)"
|
||||
size="small"
|
||||
variant="light"
|
||||
>
|
||||
{{ getTransportTypeLabel(service.transport_type) }}
|
||||
</t-tag>
|
||||
<t-tag
|
||||
v-if="service.is_builtin"
|
||||
theme="warning"
|
||||
size="small"
|
||||
variant="light"
|
||||
>
|
||||
{{ $t('mcpSettings.builtin') }}
|
||||
</t-tag>
|
||||
<t-tag
|
||||
:theme="service.enabled ? 'success' : 'default'"
|
||||
size="small"
|
||||
variant="light"
|
||||
>
|
||||
{{ service.enabled ? $t('common.on') : $t('common.off') }}
|
||||
</t-tag>
|
||||
</template>
|
||||
<template #controls>
|
||||
<t-switch
|
||||
v-model="service.enabled"
|
||||
size="medium"
|
||||
:disabled="service.is_builtin"
|
||||
@change="() => handleToggleEnabled(service)"
|
||||
/>
|
||||
</template>
|
||||
<template #meta>
|
||||
<span v-if="service.url" class="service-meta-item" :title="service.url">
|
||||
<t-icon name="link" size="12px" />
|
||||
<span class="service-meta-text">{{ service.url }}</span>
|
||||
</span>
|
||||
</template>
|
||||
</SettingCard>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Add/Edit Dialog -->
|
||||
<!-- Add/Edit Drawer -->
|
||||
<McpServiceDialog
|
||||
v-model:visible="dialogVisible"
|
||||
:service="currentService"
|
||||
@@ -109,7 +103,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { MessagePlugin, DialogPlugin } from 'tdesign-vue-next'
|
||||
import { MessagePlugin } from 'tdesign-vue-next'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import {
|
||||
listMCPServices,
|
||||
@@ -121,8 +115,11 @@ import {
|
||||
} from '@/api/mcp-service'
|
||||
import McpServiceDialog from './components/McpServiceDialog.vue'
|
||||
import McpTestResult from './components/McpTestResult.vue'
|
||||
import SettingCard from '@/components/settings/SettingCard.vue'
|
||||
import { useConfirmDelete } from '@/components/settings/useConfirmDelete'
|
||||
|
||||
const { t } = useI18n()
|
||||
const confirmDelete = useConfirmDelete()
|
||||
|
||||
const services = ref<MCPService[]>([])
|
||||
const loading = ref(false)
|
||||
@@ -170,13 +167,12 @@ const handleDialogSuccess = () => {
|
||||
// Handle toggle enabled/disabled
|
||||
const handleToggleEnabled = async (service: MCPService) => {
|
||||
if (!service || !service.id) return
|
||||
|
||||
|
||||
const originalState = service.enabled
|
||||
try {
|
||||
await updateMCPService(service.id, { enabled: service.enabled })
|
||||
MessagePlugin.success(service.enabled ? t('mcpSettings.toasts.enabled') : t('mcpSettings.toasts.disabled'))
|
||||
} catch (error) {
|
||||
// Revert on error
|
||||
service.enabled = originalState
|
||||
MessagePlugin.error(t('mcpSettings.toasts.updateStateFailed'))
|
||||
console.error('Failed to update MCP service:', error)
|
||||
@@ -186,28 +182,22 @@ const handleToggleEnabled = async (service: MCPService) => {
|
||||
// Handle test button click
|
||||
const handleTest = async (service: MCPService) => {
|
||||
if (!service || !service.id) return
|
||||
|
||||
|
||||
testingServiceName.value = service.name
|
||||
testing.value = true
|
||||
|
||||
// 显示测试开始提示
|
||||
|
||||
MessagePlugin.info({
|
||||
content: t('mcpSettings.toasts.testing', { name: service.name }),
|
||||
duration: 0, // 不自动关闭
|
||||
duration: 0,
|
||||
closeBtn: false
|
||||
})
|
||||
|
||||
|
||||
try {
|
||||
const result = await testMCPService(service.id)
|
||||
|
||||
console.log('Test result received:', result)
|
||||
|
||||
// 关闭所有消息提示
|
||||
|
||||
MessagePlugin.closeAll()
|
||||
|
||||
// 检查结果是否存在
|
||||
|
||||
if (!result) {
|
||||
// 即使没有结果,也显示错误对话框
|
||||
testResult.value = {
|
||||
success: false,
|
||||
message: t('mcpSettings.toasts.noResponse')
|
||||
@@ -215,48 +205,35 @@ const handleTest = async (service: MCPService) => {
|
||||
testDialogVisible.value = true
|
||||
return
|
||||
}
|
||||
|
||||
// 设置测试结果
|
||||
|
||||
testResult.value = result
|
||||
|
||||
// 显示详细结果对话框
|
||||
console.log('Opening test dialog, result:', testResult.value)
|
||||
testDialogVisible.value = true
|
||||
} catch (error: any) {
|
||||
// 关闭所有消息提示
|
||||
MessagePlugin.closeAll()
|
||||
|
||||
// 显示错误信息
|
||||
|
||||
const errorMessage = error?.response?.data?.error?.message || error?.message || t('mcpSettings.toasts.testFailed')
|
||||
console.error('Failed to test MCP service:', error)
|
||||
|
||||
// 即使出错也显示结果对话框,显示错误信息
|
||||
|
||||
testResult.value = {
|
||||
success: false,
|
||||
message: errorMessage
|
||||
}
|
||||
testDialogVisible.value = true
|
||||
} finally {
|
||||
// 确保关闭 loading
|
||||
testing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Handle delete button click
|
||||
const handleDelete = async (service: MCPService) => {
|
||||
const handleDelete = (service: MCPService) => {
|
||||
if (!service || !service.id) return
|
||||
|
||||
const confirmDialog = DialogPlugin.confirm({
|
||||
header: t('common.confirmDelete'),
|
||||
|
||||
confirmDelete({
|
||||
body: t('mcpSettings.deleteConfirmBody', { name: service.name || t('mcpSettings.unnamed') }),
|
||||
confirmBtn: t('common.delete'),
|
||||
cancelBtn: t('common.cancel'),
|
||||
theme: 'warning',
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await deleteMCPService(service.id)
|
||||
MessagePlugin.success(t('mcpSettings.toasts.deleted'))
|
||||
confirmDialog.hide()
|
||||
loadServices()
|
||||
} catch (error) {
|
||||
MessagePlugin.error(t('mcpSettings.toasts.deleteFailed'))
|
||||
@@ -267,44 +244,34 @@ const handleDelete = async (service: MCPService) => {
|
||||
}
|
||||
|
||||
// Get service options for dropdown menu
|
||||
const getServiceOptions = (service: MCPService) => {
|
||||
const getServiceOptions = () => {
|
||||
return [
|
||||
{
|
||||
content: t('mcpSettings.actions.test'),
|
||||
value: `test-${service.id}`
|
||||
},
|
||||
{
|
||||
content: t('common.edit'),
|
||||
value: `edit-${service.id}`
|
||||
},
|
||||
{
|
||||
content: t('common.delete'),
|
||||
value: `delete-${service.id}`,
|
||||
theme: 'error'
|
||||
}
|
||||
{ content: t('mcpSettings.actions.test'), value: 'test' },
|
||||
{ content: t('common.edit'), value: 'edit' },
|
||||
{ content: t('common.delete'), value: 'delete', theme: 'error' as const }
|
||||
]
|
||||
}
|
||||
|
||||
// Get service options for builtin services (test only)
|
||||
const getBuiltinServiceOptions = (service: MCPService) => {
|
||||
// Builtin: 仅测试
|
||||
const getBuiltinServiceOptions = () => {
|
||||
return [
|
||||
{
|
||||
content: t('mcpSettings.actions.test'),
|
||||
value: `test-${service.id}`
|
||||
}
|
||||
{ content: t('mcpSettings.actions.test'), value: 'test' }
|
||||
]
|
||||
}
|
||||
|
||||
// Handle menu action
|
||||
const handleMenuAction = (data: { value: string }, service: MCPService) => {
|
||||
const value = data.value
|
||||
|
||||
if (value.startsWith('test-')) {
|
||||
handleTest(service)
|
||||
} else if (value.startsWith('edit-')) {
|
||||
handleEdit(service)
|
||||
} else if (value.startsWith('delete-')) {
|
||||
handleDelete(service)
|
||||
if (testing.value) return
|
||||
switch (data.value) {
|
||||
case 'test':
|
||||
handleTest(service)
|
||||
break
|
||||
case 'edit':
|
||||
handleEdit(service)
|
||||
break
|
||||
case 'delete':
|
||||
handleDelete(service)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@@ -347,7 +314,7 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.section-header {
|
||||
margin-bottom: 32px;
|
||||
margin-bottom: 28px;
|
||||
|
||||
h2 {
|
||||
font-size: 20px;
|
||||
@@ -360,7 +327,7 @@ onMounted(() => {
|
||||
font-size: 14px;
|
||||
color: var(--td-text-color-secondary);
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
line-height: 1.6;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -369,20 +336,16 @@ onMounted(() => {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.services-container {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.services-header {
|
||||
.settings-toolbar {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid var(--td-component-stroke);
|
||||
|
||||
.header-info {
|
||||
.toolbar-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
h3 {
|
||||
font-size: 15px;
|
||||
@@ -411,91 +374,23 @@ onMounted(() => {
|
||||
}
|
||||
}
|
||||
|
||||
.services-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
border: 1px solid var(--td-component-stroke);
|
||||
border-radius: 6px;
|
||||
padding: 16px;
|
||||
background: var(--td-bg-color-secondarycontainer);
|
||||
.services-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.service-card {
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid var(--td-component-stroke);
|
||||
transition: all 0.2s;
|
||||
.service-meta-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
&:first-child {
|
||||
padding-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.service-info {
|
||||
.service-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
|
||||
.service-name {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: var(--td-text-color-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.service-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
|
||||
.more-btn {
|
||||
color: var(--td-text-color-placeholder);
|
||||
padding: 4px;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: var(--td-bg-color-secondarycontainer);
|
||||
color: var(--td-text-color-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.service-description {
|
||||
font-size: 13px;
|
||||
color: var(--td-text-color-secondary);
|
||||
margin-bottom: 8px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.service-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-size: 12px;
|
||||
color: var(--td-text-color-placeholder);
|
||||
|
||||
.meta-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
.meta-icon {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
.service-meta-text {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,141 +5,138 @@
|
||||
<p class="section-description">{{ t('webSearchSettings.description') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="settings-group">
|
||||
<div class="section-subheader">
|
||||
<h3>{{ t('webSearchSettings.providersTitle') }}</h3>
|
||||
<div class="settings-toolbar">
|
||||
<h3>{{ t('webSearchSettings.providersTitle') }}</h3>
|
||||
<t-button theme="primary" size="small" @click="openAddDialog">
|
||||
<template #icon><add-icon /></template>
|
||||
{{ t('webSearchSettings.addProvider') }}
|
||||
</t-button>
|
||||
</div>
|
||||
|
||||
<!-- Provider List -->
|
||||
<div v-if="providerEntities.length > 0" class="provider-grid">
|
||||
<SettingCard
|
||||
v-for="entity in providerEntities"
|
||||
:key="entity.id"
|
||||
:title="entity.name"
|
||||
:description="entity.description || ''"
|
||||
:actions="getProviderOptions(entity)"
|
||||
@action="(value: string) => handleMenuAction({ value }, entity)"
|
||||
>
|
||||
<template #tags>
|
||||
<t-tag theme="primary" size="small" variant="light">
|
||||
{{ entity.provider }}
|
||||
</t-tag>
|
||||
<t-tag v-if="entity.is_default" theme="success" size="small" variant="light">
|
||||
{{ t('webSearchSettings.default') }}
|
||||
</t-tag>
|
||||
<t-tag v-if="isEntityFree(entity)" theme="warning" size="small" variant="light">
|
||||
{{ t('webSearchSettings.free') }}
|
||||
</t-tag>
|
||||
</template>
|
||||
<template #meta>
|
||||
<span v-if="entity.parameters?.proxy_url" class="provider-meta-item" :title="entity.parameters.proxy_url">
|
||||
<t-icon name="internet" size="12px" />
|
||||
<span class="provider-meta-text">{{ entity.parameters.proxy_url }}</span>
|
||||
</span>
|
||||
</template>
|
||||
</SettingCard>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else class="empty-state">
|
||||
<t-empty :description="t('webSearchSettings.noProvidersDesc')">
|
||||
<t-button theme="primary" size="small" @click="openAddDialog">
|
||||
<template #icon><add-icon /></template>
|
||||
{{ t('webSearchSettings.addProvider') }}
|
||||
</t-button>
|
||||
</div>
|
||||
|
||||
<!-- Provider List -->
|
||||
<div v-if="providerEntities.length > 0" class="provider-list">
|
||||
<div v-for="entity in providerEntities" :key="entity.id" class="provider-item">
|
||||
<div class="item-info">
|
||||
<div class="item-header">
|
||||
<span class="item-name">{{ entity.name }}</span>
|
||||
<t-tag v-if="entity.is_default" theme="primary" size="small" variant="light">
|
||||
{{ t('webSearchSettings.default') }}
|
||||
</t-tag>
|
||||
<t-tag size="small" variant="outline">{{ entity.provider }}</t-tag>
|
||||
</div>
|
||||
<div class="item-desc">{{ entity.description || t('webSearchSettings.noDescription') }}</div>
|
||||
</div>
|
||||
<div class="item-actions">
|
||||
<t-button theme="default" variant="text" size="small" @click="testExistingConnection(entity)" :loading="testingId === entity.id">
|
||||
{{ t('webSearchSettings.testConnection') }}
|
||||
</t-button>
|
||||
<t-button theme="primary" variant="text" size="small" @click="editProvider(entity)">
|
||||
{{ t('common.edit') }}
|
||||
</t-button>
|
||||
<t-popconfirm :content="t('webSearchSettings.deleteConfirm')" @confirm="deleteProvider(entity.id!)">
|
||||
<t-button theme="danger" variant="text" size="small">
|
||||
{{ t('common.delete') }}
|
||||
</t-button>
|
||||
</t-popconfirm>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else class="empty-providers">
|
||||
<p>{{ t('webSearchSettings.noProvidersDesc') }}</p>
|
||||
</div>
|
||||
</t-empty>
|
||||
</div>
|
||||
|
||||
<!-- Add/Edit Dialog -->
|
||||
<t-dialog
|
||||
<!-- Add/Edit Drawer -->
|
||||
<SettingDrawer
|
||||
v-model:visible="showAddProviderDialog"
|
||||
:header="editingProvider ? t('webSearchSettings.editProvider') : t('webSearchSettings.addProvider')"
|
||||
width="520px"
|
||||
:footer="false"
|
||||
destroy-on-close
|
||||
:title="editingProvider ? t('webSearchSettings.editProvider') : t('webSearchSettings.addProvider')"
|
||||
:confirm-loading="saving"
|
||||
@confirm="saveProvider"
|
||||
>
|
||||
<div class="dialog-form-container">
|
||||
<t-form :data="providerForm" label-align="top" @submit="saveProvider" class="provider-form">
|
||||
<t-form-item :label="t('webSearchSettings.providerTypeLabel')" name="provider">
|
||||
<t-select v-model="providerForm.provider" :disabled="!!editingProvider" @change="onProviderTypeChange">
|
||||
<t-option v-for="pt in providerTypes" :key="pt.id" :value="pt.id" :label="pt.name">
|
||||
<div class="provider-option">
|
||||
<span>{{ pt.name }}</span>
|
||||
<t-tag v-if="isProviderFree(pt)" theme="success" size="small" variant="light">{{ t('webSearchSettings.free') }}</t-tag>
|
||||
</div>
|
||||
</t-option>
|
||||
</t-select>
|
||||
</t-form-item>
|
||||
<t-form ref="formRef" :data="providerForm" label-align="top" class="provider-form">
|
||||
<t-form-item :label="t('webSearchSettings.providerTypeLabel')" name="provider">
|
||||
<t-select v-model="providerForm.provider" :disabled="!!editingProvider" @change="onProviderTypeChange">
|
||||
<t-option v-for="pt in providerTypes" :key="pt.id" :value="pt.id" :label="pt.name">
|
||||
<div class="provider-option">
|
||||
<span>{{ pt.name }}</span>
|
||||
<t-tag v-if="isProviderFree(pt)" theme="success" size="small" variant="light">
|
||||
{{ t('webSearchSettings.free') }}
|
||||
</t-tag>
|
||||
</div>
|
||||
</t-option>
|
||||
</t-select>
|
||||
</t-form-item>
|
||||
|
||||
<t-form-item :label="t('webSearchSettings.providerNameLabel')" name="name">
|
||||
<t-input v-model="providerForm.name" :placeholder="selectedProviderType?.name || t('webSearchSettings.providerNamePlaceholder')" />
|
||||
</t-form-item>
|
||||
<t-form-item :label="t('webSearchSettings.providerNameLabel')" name="name">
|
||||
<t-input v-model="providerForm.name" :placeholder="selectedProviderType?.name || t('webSearchSettings.providerNamePlaceholder')" />
|
||||
</t-form-item>
|
||||
|
||||
<t-form-item :label="t('webSearchSettings.providerDescLabel')" name="description">
|
||||
<t-input v-model="providerForm.description" :placeholder="t('webSearchSettings.providerDescPlaceholder')" />
|
||||
</t-form-item>
|
||||
|
||||
<template v-if="selectedProviderType?.requires_api_key || selectedProviderType?.requires_engine_id">
|
||||
<div class="form-divider"></div>
|
||||
|
||||
<div class="credentials-hint" v-if="selectedProviderType?.docs_url">
|
||||
<a :href="selectedProviderType.docs_url" target="_blank" rel="noopener noreferrer">
|
||||
{{ t('webSearchSettings.viewDocs') }} ↗
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<t-form-item v-if="selectedProviderType?.requires_api_key" :label="t('webSearchSettings.apiKeyLabel')" name="parameters.api_key">
|
||||
<t-input
|
||||
v-model="providerForm.parameters.api_key"
|
||||
type="password"
|
||||
:placeholder="editingProvider ? t('webSearchSettings.apiKeyUnchanged') : t('webSearchSettings.apiKeyPlaceholder')"
|
||||
/>
|
||||
</t-form-item>
|
||||
<t-form-item v-if="selectedProviderType?.requires_engine_id" :label="t('webSearchSettings.engineIdLabel')" name="parameters.engine_id">
|
||||
<t-input v-model="providerForm.parameters.engine_id" :placeholder="t('webSearchSettings.engineIdLabel')" />
|
||||
</t-form-item>
|
||||
</template>
|
||||
|
||||
<t-form-item v-if="selectedProviderType?.supports_proxy" :label="t('webSearchSettings.proxyUrlLabel')" name="parameters.proxy_url">
|
||||
<t-input
|
||||
v-model="providerForm.parameters.proxy_url"
|
||||
:placeholder="t('webSearchSettings.proxyUrlPlaceholder')"
|
||||
/>
|
||||
<template #help>
|
||||
<span class="switch-help">{{ t('webSearchSettings.proxyUrlHelp') }}</span>
|
||||
</template>
|
||||
</t-form-item>
|
||||
<t-form-item :label="t('webSearchSettings.providerDescLabel')" name="description">
|
||||
<t-input v-model="providerForm.description" :placeholder="t('webSearchSettings.providerDescPlaceholder')" />
|
||||
</t-form-item>
|
||||
|
||||
<template v-if="selectedProviderType?.requires_api_key || selectedProviderType?.requires_engine_id">
|
||||
<div class="form-divider"></div>
|
||||
|
||||
<t-form-item :label="t('webSearchSettings.setAsDefault')" name="is_default">
|
||||
<template #help>
|
||||
<div class="switch-help">
|
||||
{{ t('webSearchSettings.setAsDefaultDesc') }}
|
||||
</div>
|
||||
</template>
|
||||
<t-switch v-model="providerForm.is_default" />
|
||||
</t-form-item>
|
||||
|
||||
<div class="dialog-footer">
|
||||
<div class="footer-left">
|
||||
<t-button
|
||||
v-if="selectedProviderType && !isProviderFree(selectedProviderType)"
|
||||
theme="default"
|
||||
variant="outline"
|
||||
:loading="testing"
|
||||
@click="testConnection"
|
||||
>
|
||||
{{ testing ? t('webSearchSettings.testing') : t('webSearchSettings.testConnection') }}
|
||||
</t-button>
|
||||
</div>
|
||||
<div class="footer-right">
|
||||
<t-button theme="default" variant="base" @click="showAddProviderDialog = false">{{ t('common.cancel') }}</t-button>
|
||||
<t-button theme="primary" type="submit" :loading="saving">{{ t('common.save') }}</t-button>
|
||||
</div>
|
||||
<div class="credentials-hint" v-if="selectedProviderType?.docs_url">
|
||||
<a :href="selectedProviderType.docs_url" target="_blank" rel="noopener noreferrer">
|
||||
{{ t('webSearchSettings.viewDocs') }} ↗
|
||||
</a>
|
||||
</div>
|
||||
</t-form>
|
||||
</div>
|
||||
</t-dialog>
|
||||
|
||||
<t-form-item v-if="selectedProviderType?.requires_api_key" :label="t('webSearchSettings.apiKeyLabel')" name="parameters.api_key">
|
||||
<t-input
|
||||
v-model="providerForm.parameters.api_key"
|
||||
type="password"
|
||||
:placeholder="editingProvider ? t('webSearchSettings.apiKeyUnchanged') : t('webSearchSettings.apiKeyPlaceholder')"
|
||||
/>
|
||||
</t-form-item>
|
||||
<t-form-item v-if="selectedProviderType?.requires_engine_id" :label="t('webSearchSettings.engineIdLabel')" name="parameters.engine_id">
|
||||
<t-input v-model="providerForm.parameters.engine_id" :placeholder="t('webSearchSettings.engineIdLabel')" />
|
||||
</t-form-item>
|
||||
</template>
|
||||
|
||||
<t-form-item v-if="selectedProviderType?.supports_proxy" :label="t('webSearchSettings.proxyUrlLabel')" name="parameters.proxy_url">
|
||||
<t-input
|
||||
v-model="providerForm.parameters.proxy_url"
|
||||
:placeholder="t('webSearchSettings.proxyUrlPlaceholder')"
|
||||
/>
|
||||
<template #help>
|
||||
<span class="switch-help">{{ t('webSearchSettings.proxyUrlHelp') }}</span>
|
||||
</template>
|
||||
</t-form-item>
|
||||
|
||||
<div class="form-divider"></div>
|
||||
|
||||
<t-form-item :label="t('webSearchSettings.setAsDefault')" name="is_default">
|
||||
<template #help>
|
||||
<div class="switch-help">
|
||||
{{ t('webSearchSettings.setAsDefaultDesc') }}
|
||||
</div>
|
||||
</template>
|
||||
<t-switch v-model="providerForm.is_default" />
|
||||
</t-form-item>
|
||||
</t-form>
|
||||
|
||||
<template #footer-left>
|
||||
<t-button
|
||||
v-if="selectedProviderType && !isProviderFree(selectedProviderType)"
|
||||
theme="default"
|
||||
variant="outline"
|
||||
:loading="testing"
|
||||
@click="testConnection"
|
||||
>
|
||||
{{ testing ? t('webSearchSettings.testing') : t('webSearchSettings.testConnection') }}
|
||||
</t-button>
|
||||
</template>
|
||||
</SettingDrawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -158,8 +155,12 @@ import {
|
||||
type WebSearchProviderEntity,
|
||||
type WebSearchProviderTypeInfo,
|
||||
} from '@/api/web-search-provider'
|
||||
import SettingCard from '@/components/settings/SettingCard.vue'
|
||||
import SettingDrawer from '@/components/settings/SettingDrawer.vue'
|
||||
import { useConfirmDelete } from '@/components/settings/useConfirmDelete'
|
||||
|
||||
const { t } = useI18n()
|
||||
const confirmDelete = useConfirmDelete()
|
||||
|
||||
// ===== State =====
|
||||
const providerEntities = ref<WebSearchProviderEntity[]>([])
|
||||
@@ -169,6 +170,7 @@ const editingProvider = ref<WebSearchProviderEntity | null>(null)
|
||||
const testing = ref(false)
|
||||
const testingId = ref<string | null>(null)
|
||||
const saving = ref(false)
|
||||
const formRef = ref<any>()
|
||||
|
||||
const providerForm = ref<{
|
||||
name: string
|
||||
@@ -193,6 +195,11 @@ const isProviderFree = (providerType: WebSearchProviderTypeInfo) => {
|
||||
return !providerType.requires_api_key && !providerType.requires_engine_id
|
||||
}
|
||||
|
||||
const isEntityFree = (entity: WebSearchProviderEntity) => {
|
||||
const pt = providerTypes.value.find(p => p.id === entity.provider)
|
||||
return pt ? isProviderFree(pt) : false
|
||||
}
|
||||
|
||||
// ===== Methods =====
|
||||
const onProviderTypeChange = () => {
|
||||
providerForm.value.parameters = {}
|
||||
@@ -219,12 +226,12 @@ const loadProviderTypes = async () => {
|
||||
|
||||
const openAddDialog = () => {
|
||||
editingProvider.value = null
|
||||
providerForm.value = {
|
||||
name: '',
|
||||
provider: providerTypes.value[0]?.id || 'duckduckgo',
|
||||
description: '',
|
||||
parameters: {},
|
||||
is_default: providerEntities.value.length === 0
|
||||
providerForm.value = {
|
||||
name: '',
|
||||
provider: providerTypes.value[0]?.id || 'duckduckgo',
|
||||
description: '',
|
||||
parameters: {},
|
||||
is_default: providerEntities.value.length === 0
|
||||
}
|
||||
showAddProviderDialog.value = true
|
||||
}
|
||||
@@ -245,12 +252,14 @@ const editProvider = (entity: WebSearchProviderEntity) => {
|
||||
showAddProviderDialog.value = true
|
||||
}
|
||||
|
||||
const saveProvider = async ({ validateResult, firstError }: any) => {
|
||||
const saveProvider = async () => {
|
||||
const validateResult = await formRef.value?.validate()
|
||||
if (validateResult !== true && validateResult !== undefined) {
|
||||
MessagePlugin.warning(firstError || 'Please check the form fields')
|
||||
const firstError = typeof validateResult === 'object' ? Object.values(validateResult)[0] : ''
|
||||
MessagePlugin.warning(typeof firstError === 'string' ? firstError : 'Please check the form fields')
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
const data: Partial<WebSearchProviderEntity> = {
|
||||
@@ -260,7 +269,7 @@ const saveProvider = async ({ validateResult, firstError }: any) => {
|
||||
parameters: { ...providerForm.value.parameters },
|
||||
is_default: providerForm.value.is_default,
|
||||
}
|
||||
|
||||
|
||||
if (editingProvider.value && !data.parameters!.api_key) {
|
||||
delete data.parameters!.api_key
|
||||
}
|
||||
@@ -281,14 +290,19 @@ const saveProvider = async ({ validateResult, firstError }: any) => {
|
||||
}
|
||||
}
|
||||
|
||||
const deleteProvider = async (id: string) => {
|
||||
try {
|
||||
await deleteWebSearchProviderAPI(id)
|
||||
MessagePlugin.success(t('webSearchSettings.toasts.providerDeleted'))
|
||||
await loadProviderEntities()
|
||||
} catch (error: any) {
|
||||
MessagePlugin.error(error?.message || 'Failed to delete provider')
|
||||
}
|
||||
const deleteProvider = (entity: WebSearchProviderEntity) => {
|
||||
confirmDelete({
|
||||
body: t('webSearchSettings.deleteConfirm'),
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await deleteWebSearchProviderAPI(entity.id!)
|
||||
MessagePlugin.success(t('webSearchSettings.toasts.providerDeleted'))
|
||||
await loadProviderEntities()
|
||||
} catch (error: any) {
|
||||
MessagePlugin.error(error?.message || 'Failed to delete provider')
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const testConnection = async () => {
|
||||
@@ -298,7 +312,7 @@ const testConnection = async () => {
|
||||
provider: providerForm.value.provider,
|
||||
parameters: { ...providerForm.value.parameters },
|
||||
}
|
||||
|
||||
|
||||
if (editingProvider.value && !data.parameters.api_key) {
|
||||
const res = await testWebSearchProvider(editingProvider.value.id!)
|
||||
if (res.success) {
|
||||
@@ -337,6 +351,28 @@ const testExistingConnection = async (entity: WebSearchProviderEntity) => {
|
||||
}
|
||||
}
|
||||
|
||||
const getProviderOptions = (_entity: WebSearchProviderEntity) => {
|
||||
return [
|
||||
{ content: t('webSearchSettings.testConnection'), value: 'test' },
|
||||
{ content: t('common.edit'), value: 'edit' },
|
||||
{ content: t('common.delete'), value: 'delete', theme: 'error' as const }
|
||||
]
|
||||
}
|
||||
|
||||
const handleMenuAction = (data: { value: string }, entity: WebSearchProviderEntity) => {
|
||||
switch (data.value) {
|
||||
case 'test':
|
||||
testExistingConnection(entity)
|
||||
break
|
||||
case 'edit':
|
||||
editProvider(entity)
|
||||
break
|
||||
case 'delete':
|
||||
deleteProvider(entity)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Init =====
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadProviderTypes(), loadProviderEntities()])
|
||||
@@ -349,7 +385,7 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
.section-header {
|
||||
margin-bottom: 32px;
|
||||
margin-bottom: 28px;
|
||||
|
||||
h2 {
|
||||
font-size: 20px;
|
||||
@@ -362,16 +398,11 @@ onMounted(async () => {
|
||||
font-size: 14px;
|
||||
color: var(--td-text-color-secondary);
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
line-height: 1.6;
|
||||
}
|
||||
}
|
||||
|
||||
.settings-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.section-subheader {
|
||||
.settings-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
@@ -385,67 +416,35 @@ onMounted(async () => {
|
||||
}
|
||||
}
|
||||
|
||||
.provider-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
.provider-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.provider-item {
|
||||
display: flex;
|
||||
.provider-meta-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 16px;
|
||||
background: var(--td-bg-color-container);
|
||||
border: 1px solid var(--td-component-stroke);
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s ease;
|
||||
gap: 4px;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--td-brand-color);
|
||||
.provider-meta-text {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.item-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.item-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.item-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--td-text-color-primary);
|
||||
}
|
||||
|
||||
.item-desc {
|
||||
font-size: 13px;
|
||||
color: var(--td-text-color-secondary);
|
||||
}
|
||||
|
||||
.item-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.empty-providers {
|
||||
padding: 32px;
|
||||
.empty-state {
|
||||
padding: 64px 0;
|
||||
text-align: center;
|
||||
color: var(--td-text-color-placeholder);
|
||||
border: 1px dashed var(--td-component-stroke);
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.dialog-form-container {
|
||||
margin-top: 12px;
|
||||
:deep(.t-empty__description) {
|
||||
font-size: 14px;
|
||||
color: var(--td-text-color-placeholder);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.provider-option {
|
||||
@@ -464,11 +463,11 @@ onMounted(async () => {
|
||||
.credentials-hint {
|
||||
margin-bottom: 12px;
|
||||
font-size: 13px;
|
||||
|
||||
|
||||
a {
|
||||
color: var(--td-brand-color);
|
||||
text-decoration: none;
|
||||
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
@@ -481,18 +480,4 @@ onMounted(async () => {
|
||||
margin-top: 4px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 32px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid var(--td-component-border);
|
||||
|
||||
.footer-right {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
<template>
|
||||
<t-dialog
|
||||
v-model:visible="dialogVisible"
|
||||
:header="mode === 'add' ? t('mcpServiceDialog.addTitle') : t('mcpServiceDialog.editTitle')"
|
||||
width="700px"
|
||||
:on-confirm="handleSubmit"
|
||||
:on-cancel="handleClose"
|
||||
:confirm-btn="{ content: t('common.save'), loading: submitting }"
|
||||
<SettingDrawer
|
||||
:visible="dialogVisible"
|
||||
:title="mode === 'add' ? t('mcpServiceDialog.addTitle') : t('mcpServiceDialog.editTitle')"
|
||||
:confirm-loading="submitting"
|
||||
@update:visible="(v: boolean) => dialogVisible = v"
|
||||
@confirm="handleSubmit"
|
||||
@cancel="handleClose"
|
||||
>
|
||||
<t-form
|
||||
ref="formRef"
|
||||
:data="formData"
|
||||
:rules="rules"
|
||||
label-width="120px"
|
||||
label-align="top"
|
||||
>
|
||||
<t-form-item :label="t('mcpServiceDialog.name')" name="name">
|
||||
<t-input v-model="formData.name" :placeholder="t('mcpServiceDialog.namePlaceholder')" />
|
||||
@@ -95,7 +95,7 @@
|
||||
</t-collapse-panel>
|
||||
</t-collapse>
|
||||
</t-form>
|
||||
</t-dialog>
|
||||
</SettingDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -108,6 +108,7 @@ import {
|
||||
updateMCPService,
|
||||
type MCPService
|
||||
} from '@/api/mcp-service'
|
||||
import SettingDrawer from '@/components/settings/SettingDrawer.vue'
|
||||
|
||||
interface Props {
|
||||
visible: boolean
|
||||
|
||||
Reference in New Issue
Block a user