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:
wizardchen
2026-04-29 22:34:39 +08:00
committed by lyingbug
parent b48adde3e6
commit 679e40de82
12 changed files with 994 additions and 1206 deletions
+72 -259
View File
@@ -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
}
}
+7
View File
@@ -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'
+7
View File
@@ -2902,6 +2902,13 @@ export default {
modelSettings: {
title: "모델 설정",
description: "다양한 유형의 AI 모델을 관리합니다. Ollama 로컬 모델과 원격 API를 지원합니다",
typeShort: {
chat: "대화",
embedding: "Embedding",
rerank: "ReRank",
vllm: "비전",
asr: "음성",
},
actions: {
addModel: "모델 추가",
setDefault: "기본값으로 설정",
+7
View File
@@ -2660,6 +2660,13 @@ export default {
modelSettings: {
title: 'Настройки моделей',
description: 'Управление типами AI‑моделей: локальные (Ollama) и удалённые API',
typeShort: {
chat: 'Чат',
embedding: 'Embedding',
rerank: 'ReRank',
vllm: 'Зрение',
asr: 'Речь',
},
actions: {
addModel: 'Добавить модель',
setDefault: 'Сделать по умолчанию'
+7
View File
@@ -2852,6 +2852,13 @@ export default {
modelSettings: {
title: "模型配置",
description: "管理不同类型的 AI 模型,支持 Ollama 本地模型和远程 API",
typeShort: {
chat: "对话",
embedding: "Embedding",
rerank: "ReRank",
vllm: "视觉",
asr: "语音",
},
actions: {
addModel: "添加模型",
setDefault: "设为默认",
+114 -219
View File
@@ -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
+201 -216
View File
@@ -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