You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1890 lines
78 KiB
1890 lines
78 KiB
<template>
|
|
<div>
|
|
<div v-if="!backendReady" class="mb-4 rounded-lg px-4 py-3 text-sm" style="background:#fffbeb;border:1px solid #fde68a;color:#92400e">
|
|
⚠ 「第三方服务配置」后端接口暂不可用(连接失败),请检查 console 服务是否运行。
|
|
</div>
|
|
<!-- 顶部 -->
|
|
<div class="flex items-center justify-between mb-5">
|
|
<div>
|
|
<h2 class="text-lg font-semibold text-slate-800">第三方服务配置</h2>
|
|
<p class="text-xs text-slate-400 mt-1">按服务类型分组管理已接入的第三方服务(火山引擎、阿里云、Azure 等)。默认展示<b>全局默认</b>配置,可切换到具体应用;区域默认<b>中国</b>,切换区域后点击卡片可为该区域覆盖字段值。</p>
|
|
</div>
|
|
<div class="flex items-center gap-3">
|
|
<select v-model="scopeApp" class="select select-bordered select-sm" @change="onScopeChange">
|
|
<option value="">全局默认</option>
|
|
<option v-for="a in scopeApps" :key="a" :value="a">应用:{{ a }}</option>
|
|
</select>
|
|
<select v-model.number="curRegion" class="select select-bordered select-sm" @change="onRegionChange">
|
|
<option :value="0">默认(全区域)</option>
|
|
<option v-for="r in REGIONS" :key="r.value" :value="r.value">{{ r.label }}</option>
|
|
</select>
|
|
<button class="btn btn-ghost btn-sm" @click="toggleAllGroups">{{ allFolded ? '展开全部' : '折叠全部' }}</button>
|
|
<button class="btn btn-ghost btn-sm" @click="openTplModal">服务商模板</button>
|
|
<button class="btn btn-primary btn-sm text-white" @click="startAdd()">+ 添加服务</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 区域分叉提示条 -->
|
|
<div v-if="curRegion !== 0" class="rounded-lg px-3 py-2 mb-4 text-xs" style="background:#fefce8;border:1px solid #fde047;color:#854d0e">
|
|
✦ 当前处于「{{ curRegionLabel }}」区域视图:点击服务卡片编辑该区域的字段分叉(覆盖值 / 停用字段 / 区域专属字段)。要改服务本身的默认配置,请切回「默认(全区域)」。
|
|
</div>
|
|
|
|
<!-- 服务卡片:按服务类型分组 -->
|
|
<div v-if="groupedSvcs.length" class="flex flex-col gap-6">
|
|
<section v-for="g in groupedSvcs" :key="g.v">
|
|
<div class="group-head" @click="toggleGroup(g.v)">
|
|
<span class="fold-arrow" :class="{ folded: folded[g.v] }">▾</span>
|
|
<span class="cat-chip" :style="{ color: g.color, background: g.bg }">{{ g.short }}</span>
|
|
<h3 class="text-sm font-semibold text-slate-700">{{ g.label }}</h3>
|
|
<span class="text-xs text-slate-400">{{ g.items.length }} 个服务</span>
|
|
<div class="flex-1 h-px bg-slate-100 ml-2"></div>
|
|
</div>
|
|
<div v-show="!folded[g.v]" class="svc-grid">
|
|
<div v-for="s in g.items" :key="s.id" class="svc-card" :class="{ off: !s.enable }" @click="selectSvc(s)">
|
|
<div class="card-head">
|
|
<div class="svc-logo" :style="{ background: s.color || '#6366f1' }">{{ s.icon || (s.name || '').charAt(0) }}</div>
|
|
<div class="head-text">
|
|
<div class="name-line">
|
|
<span class="svc-name" :title="s.name">{{ s.name }}</span>
|
|
<span class="state-chip" :class="s.enable ? 'on' : 'off'">{{ s.enable ? '启用' : '停用' }}</span>
|
|
</div>
|
|
<p class="svc-id" :title="s.id">{{ s.id }}</p>
|
|
</div>
|
|
</div>
|
|
<p class="svc-desc" :title="s.description || ''">{{ s.description || '—' }}</p>
|
|
<!-- 标签分两行固定轨道:上行服务商/字段数/密钥数(服务商可收缩省略),下行区域标识独占,
|
|
这样窄卡片下既不换行抖动、也不会把区域标识挤没。 -->
|
|
<div class="meta-row">
|
|
<span v-if="s.provider" class="provider-chip" :title="s.provider">{{ s.provider }}</span>
|
|
<span class="field-count">{{ (s.fields || []).length }} 字段</span>
|
|
<span v-if="encCount(s)" class="lock-badge">🔒{{ encCount(s) }}</span>
|
|
<span
|
|
v-if="credMissing[s.id]"
|
|
class="cred-badge"
|
|
:title="`凭据未配置:${credMissing[s.id].join('、')}(基础行与各区域分叉都没有值)。客户端会拿到空字符串,运行时必然鉴权失败。`"
|
|
>⚠ 缺凭据</span>
|
|
</div>
|
|
<div class="region-row">
|
|
<span class="region-chip" :class="regionChip(s).cls" :title="regionChip(s).title">{{ regionChip(s).text }}</span>
|
|
</div>
|
|
</div>
|
|
<!-- 该类型下的新增入口 -->
|
|
<div class="svc-card add-card" @click="startAdd(g.v)">
|
|
<span class="text-2xl text-slate-300">+</span>
|
|
<span class="text-xs text-slate-400 mt-1">添加{{ g.short }}服务</span>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
<div v-else class="bg-white rounded-2xl border border-slate-100 shadow-sm text-center text-slate-300 py-20">
|
|
<div class="text-5xl mb-3">⚙️</div>
|
|
<p class="text-sm mb-4">尚未配置任何第三方服务</p>
|
|
<button class="btn btn-primary btn-sm text-white" @click="startAdd()">+ 添加服务</button>
|
|
</div>
|
|
|
|
<!-- ===== 服务编辑弹窗(默认编辑 / 新增向导 / 区域分叉) ===== -->
|
|
<div v-if="svcModalVisible" class="modal-overlay">
|
|
<div class="svc-box">
|
|
<!-- 默认模式:服务全量编辑 -->
|
|
<template v-if="detailMode === 'default' && curSvc">
|
|
<div class="flex items-center justify-between mb-5">
|
|
<div class="flex items-center gap-3">
|
|
<div class="svc-logo" :style="{ background: curSvc.color || '#6366f1', width: '44px', height: '44px', fontSize: '20px' }">{{ curSvc.icon || (curSvc.name || '').charAt(0) }}</div>
|
|
<div>
|
|
<h3 class="text-base font-semibold text-slate-800">{{ curSvc.name }}</h3>
|
|
<p class="text-xs text-slate-400 mt-0.5">{{ curSvc.id }}<span v-if="editForm.provider"> · 服务商 {{ editForm.provider }}</span></p>
|
|
</div>
|
|
</div>
|
|
<div class="flex gap-2">
|
|
<button class="btn btn-error btn-sm btn-outline" @click="deleteSvc">删除服务</button>
|
|
<button class="btn btn-primary btn-sm text-white" @click="openSyncModal(false)">保存</button>
|
|
<button class="btn btn-ghost btn-sm" @click="closeSvcModal">关闭</button>
|
|
</div>
|
|
</div>
|
|
<div class="divider text-xs text-slate-400 my-3">基本信息</div>
|
|
<div class="grid grid-cols-2 gap-4 mb-4">
|
|
<div class="form-control">
|
|
<label class="label"><span class="label-text">服务名称 *</span></label>
|
|
<input v-model="editForm.name" class="input input-bordered" />
|
|
</div>
|
|
<div class="form-control">
|
|
<label class="label"><span class="label-text">启用</span></label>
|
|
<input v-model="editForm.enable" type="checkbox" class="toggle toggle-primary mt-3" />
|
|
</div>
|
|
</div>
|
|
<div class="form-control mb-4">
|
|
<label class="label"><span class="label-text">服务描述</span></label>
|
|
<input v-model="editForm.description" class="input input-bordered" />
|
|
</div>
|
|
<div class="mb-4">
|
|
<p class="text-sm font-medium text-slate-700 mb-2">服务类型</p>
|
|
<span v-if="editType" class="cat-chip" :style="{ color: scatInfo(editType).color, background: scatInfo(editType).bg }">{{ scatInfo(editType).short }}</span>
|
|
<span class="text-xs text-slate-500 ml-2">{{ editType ? scatInfo(editType).label : '未设置' }}</span>
|
|
<span class="text-[11px] text-slate-400 ml-2">(类型在创建时确定,不可更改)</span>
|
|
</div>
|
|
<!-- 语音输出类服务:展示内置音色表覆盖情况(只读,接入时确认用) -->
|
|
<div v-if="isVoiceCat" class="voice-card">
|
|
<div class="flex items-center justify-between">
|
|
<span class="text-sm font-medium text-slate-700">内置音色表</span>
|
|
<span class="flex items-center gap-2">
|
|
<span class="text-[11px] text-slate-400">
|
|
<template v-if="voicesLoading">加载中…</template>
|
|
<template v-else-if="voiceRows.length">{{ editForm.provider }} · {{ voiceRows.length }} 个音色 / {{ voiceLangGroups.length }} 种语言</template>
|
|
<template v-else>未覆盖 {{ editForm.provider || '该服务商' }}</template>
|
|
</span>
|
|
<button v-if="voiceRows.length" class="btn btn-ghost btn-xs" @click="onFillLanguages">填入 languages</button>
|
|
</span>
|
|
</div>
|
|
<p v-if="!voicesLoading && !voiceRows.length" class="text-[11px] text-slate-400 mt-1">
|
|
内置音色表里没有这家服务商,不影响接入——Agent 配音色时手填服务商的音色 id 即可。
|
|
</p>
|
|
<div v-else-if="voiceRows.length" class="voice-lang-list">
|
|
<div v-for="g in voiceLangGroups" :key="g.lang" class="voice-lang-row">
|
|
<span class="voice-lang">{{ g.lang }}</span>
|
|
<span class="voice-ids">
|
|
<span v-for="(v, i) in g.items" :key="v.voice_id" class="voice-pill" :title="v.name">
|
|
{{ v.gender === 1 ? '♂' : v.gender === 2 ? '♀' : '·' }} {{ v.voice_id }}<em v-if="i === 0">默认</em>
|
|
</span>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<p v-if="voiceRows.length" class="text-[11px] text-slate-400 mt-1">
|
|
这 {{ voiceLangGroups.length }} 种语言就是该服务能说的语言,已作为下方 languages 的预填值——通话翻译、AI 助手排查「这条链路有没有服务支持」看的就是它。
|
|
音色本身是代码里的常量表(后端 comm/ttsvoice.go),实际用哪个在 Agent 里按语言+性别选,同组第一个是默认。
|
|
</p>
|
|
</div>
|
|
<div class="flex items-center justify-between mt-4 mb-2">
|
|
<span class="text-sm font-medium text-slate-700">配置字段(全区域共用)</span>
|
|
<button class="btn btn-ghost btn-xs" :title="fillFromTemplateTip" @click="fillFieldsFromTemplate">按模板补齐字段</button>
|
|
</div>
|
|
<div v-if="isMcpType()" class="rounded-lg px-3 py-2 mb-3 text-xs" style="background:#f0fdfa;border:1px solid #99f6e4;color:#0f766e">
|
|
✦ MCP 服务与其他第三方服务同表存储(svc_config):这里填的 url/type/tools 为<b>全区域默认</b>;
|
|
只在某些区域生效的地址,切到对应区域做分叉即可,卡片上会显示已分叉的区域数。
|
|
</div>
|
|
<div>
|
|
<p v-if="!editFields.length" class="text-xs text-slate-400 py-4 text-center">尚无字段,点击下方添加</p>
|
|
<div v-else class="overflow-x-auto">
|
|
<table class="table text-xs">
|
|
<thead>
|
|
<tr>
|
|
<th class="w-44">字段名</th><th>字段描述</th><th class="w-16 text-center">加密</th><th>默认值</th><th class="w-12"></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="(f, i) in editFields" :key="i">
|
|
<td><input class="field-input" v-model="f.key" placeholder="key" :title="f.key" /></td>
|
|
<td><input class="field-input" v-model="f.description" placeholder="用途描述" /></td>
|
|
<td class="text-center"><input type="checkbox" class="checkbox checkbox-xs checkbox-primary" v-model="f.encrypted" /></td>
|
|
<td>
|
|
<LangSelect v-if="f.type === 'langs'" v-model="f.def_value" :multiple="true" />
|
|
<LangSelect v-else-if="f.type === 'lang'" v-model="f.def_value" />
|
|
<template v-else-if="f.encrypted">
|
|
<div class="relative">
|
|
<input :type="f._show ? 'text' : 'password'" class="field-input pr-14" v-model="f.def_value" placeholder="留空保留原值" />
|
|
<button type="button" class="absolute right-2 top-1/2 -translate-y-1/2 text-xs text-slate-400 hover:text-slate-700" @click="f._show = !f._show">{{ f._show ? '隐藏' : '显示' }}</button>
|
|
</div>
|
|
<span class="lock-badge ml-1">🔒 加密</span>
|
|
</template>
|
|
<select v-else-if="isMcpType() && f.key === 'type'" class="field-input" v-model="f.def_value">
|
|
<option value="0">HTTP</option>
|
|
<option value="1">SSE</option>
|
|
</select>
|
|
<input v-else class="field-input" v-model="f.def_value" />
|
|
</td>
|
|
<td><button class="btn btn-ghost btn-xs text-error" @click="removeField(i)">×</button></td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<button class="btn btn-ghost btn-sm mt-2 text-primary" @click="addField">+ 添加字段</button>
|
|
</div>
|
|
|
|
<!-- MCP 工具白名单:连服务端把工具列出来勾,别再手抄工具名 -->
|
|
<div v-if="isMcpType()" class="mt-4">
|
|
<div class="flex items-center justify-between mb-2">
|
|
<span class="text-sm font-medium text-slate-700">工具白名单</span>
|
|
<span class="text-[11px] text-slate-400">勾选结果写入上面的 tools 字段(逗号分隔,空=全部)</span>
|
|
</div>
|
|
<McpToolPicker
|
|
v-model="mcpEditTools"
|
|
:fetch-params="{ url: mcpEditUrl, type: mcpEditType }"
|
|
:can-fetch="!!mcpEditUrl"
|
|
/>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- 新增服务表单(类型优先向导:① 服务类型 → ② 服务商 → ③ 配置) -->
|
|
<template v-else-if="detailMode === 'new'">
|
|
<div class="flex items-center justify-between mb-4">
|
|
<h3 class="text-base font-semibold text-slate-800">新增第三方服务</h3>
|
|
<button class="btn btn-ghost btn-sm" @click="closeSvcModal">取消</button>
|
|
</div>
|
|
<!-- 步骤指示 -->
|
|
<div class="wizard-steps mb-5">
|
|
<div class="wstep" :class="{ on: wizardStep >= 1, cur: wizardStep === 1 }" @click="wizardStep = 1">① 服务类型<span v-if="editType"> · {{ scatInfo(editType).short }}</span></div>
|
|
<div class="wline"></div>
|
|
<div class="wstep" :class="{ on: wizardStep >= 2, cur: wizardStep === 2, disabled: !editType }" @click="editType && (wizardStep = 2)">② 服务商<span v-if="editForm.provider"> · {{ editForm.provider }}</span></div>
|
|
<div class="wline"></div>
|
|
<div class="wstep" :class="{ on: wizardStep >= 3, cur: wizardStep === 3, disabled: wizardStep < 3 }">③ 配置</div>
|
|
</div>
|
|
|
|
<!-- 步骤1:选择服务类型(单选,每个服务归属单一类型) -->
|
|
<template v-if="wizardStep === 1">
|
|
<p class="text-sm text-slate-500 mb-3">请选择要接入的服务类型(每个服务归属单一类型,独立配置):</p>
|
|
<div class="type-grid">
|
|
<div v-for="sc in SCAT" :key="sc.v" class="type-card" :class="{ on: editType === sc.v }" @click="chooseType(sc.v)">
|
|
<span class="cat-chip" :style="{ color: sc.color, background: sc.bg }">{{ sc.short }}</span>
|
|
<span class="text-sm text-slate-700 mt-2">{{ sc.label }}</span>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- 步骤2:选择服务商 -->
|
|
<template v-else-if="wizardStep === 2">
|
|
<div class="flex items-center justify-between mb-3">
|
|
<p class="text-sm text-slate-500">为「{{ scatInfo(editType).label }}」选择服务商:</p>
|
|
<button class="btn btn-ghost btn-xs" @click="wizardStep = 1">← 上一步</button>
|
|
</div>
|
|
<div class="type-grid">
|
|
<div v-for="t in templatesForType" :key="t.id" class="type-card" :class="{ on: selectedTemplateId === t.id }" @click="chooseProvider(t)">
|
|
<span class="provider-chip">{{ t.provider }}</span>
|
|
<span class="text-sm text-slate-700 mt-2">{{ t.name }}</span>
|
|
<span class="text-[11px] text-slate-400 mt-0.5">{{ (t.fields || []).length }} 字段</span>
|
|
</div>
|
|
<div class="type-card" @click="chooseProvider(null)">
|
|
<span class="provider-chip" style="background:#f1f5f9;color:#475569">自定义</span>
|
|
<span class="text-sm text-slate-700 mt-2">空白自定义</span>
|
|
<span class="text-[11px] text-slate-400 mt-0.5">手动添加字段</span>
|
|
</div>
|
|
</div>
|
|
<p v-if="!templatesForType.length" class="text-xs text-slate-400 mt-2">该类型暂无内置模板,可选「空白自定义」,或到「服务商模板」新增。</p>
|
|
</template>
|
|
|
|
<!-- 步骤3:填配置 -->
|
|
<template v-else>
|
|
<div class="flex items-center justify-between mb-3">
|
|
<div class="flex items-center gap-2">
|
|
<span class="cat-chip" :style="{ color: scatInfo(editType).color, background: scatInfo(editType).bg }">{{ scatInfo(editType).short }}</span>
|
|
<span v-if="editForm.provider" class="provider-chip">{{ editForm.provider }}</span>
|
|
</div>
|
|
<div class="flex gap-2">
|
|
<button class="btn btn-ghost btn-xs" @click="wizardStep = 2">← 上一步</button>
|
|
<button class="btn btn-primary btn-sm text-white" @click="openSyncModal(true)">创建</button>
|
|
</div>
|
|
</div>
|
|
<div class="grid grid-cols-2 gap-4 mb-4">
|
|
<div class="form-control">
|
|
<label class="label"><span class="label-text">服务ID * <span class="text-slate-400 text-xs">(唯一,如 tts_azure)</span></span></label>
|
|
<input v-model="editForm.id" class="input input-bordered font-mono" placeholder="tts_azure" />
|
|
</div>
|
|
<div class="form-control">
|
|
<label class="label"><span class="label-text">服务名称 *</span></label>
|
|
<input v-model="editForm.name" class="input input-bordered" placeholder="微软 Azure TTS" />
|
|
</div>
|
|
</div>
|
|
<div class="grid grid-cols-2 gap-4 mb-4">
|
|
<div class="form-control">
|
|
<label class="label"><span class="label-text">服务商标识 <span class="text-slate-400 text-xs">(如 azure/alibaba)</span></span></label>
|
|
<input v-model="editForm.provider" class="input input-bordered font-mono" placeholder="azure" />
|
|
</div>
|
|
<div class="form-control">
|
|
<label class="label"><span class="label-text">启用</span></label>
|
|
<input v-model="editForm.enable" type="checkbox" class="toggle toggle-primary mt-3" />
|
|
</div>
|
|
</div>
|
|
<div class="form-control mb-4">
|
|
<label class="label"><span class="label-text">服务描述</span></label>
|
|
<input v-model="editForm.description" class="input input-bordered" placeholder="简述该服务的用途" />
|
|
</div>
|
|
<!-- 语音输出类服务:展示内置音色表覆盖情况(只读,接入时确认用) -->
|
|
<div v-if="isVoiceCat" class="voice-card">
|
|
<div class="flex items-center justify-between">
|
|
<span class="text-sm font-medium text-slate-700">内置音色表</span>
|
|
<span class="text-[11px] text-slate-400">
|
|
<template v-if="voicesLoading">加载中…</template>
|
|
<template v-else-if="voiceRows.length">{{ editForm.provider }} · {{ voiceRows.length }} 个音色 / {{ voiceLangGroups.length }} 种语言</template>
|
|
<template v-else>未覆盖 {{ editForm.provider || '该服务商' }}</template>
|
|
</span>
|
|
</div>
|
|
<p v-if="!voicesLoading && !voiceRows.length" class="text-[11px] text-slate-400 mt-1">
|
|
内置音色表里没有这家服务商,不影响接入——Agent 配音色时手填服务商的音色 id 即可。
|
|
</p>
|
|
<div v-else-if="voiceRows.length" class="voice-lang-list">
|
|
<div v-for="g in voiceLangGroups" :key="g.lang" class="voice-lang-row">
|
|
<span class="voice-lang">{{ g.lang }}</span>
|
|
<span class="voice-ids">
|
|
<span v-for="(v, i) in g.items" :key="v.voice_id" class="voice-pill" :title="v.name">
|
|
{{ v.gender === 1 ? '♂' : v.gender === 2 ? '♀' : '·' }} {{ v.voice_id }}<em v-if="i === 0">默认</em>
|
|
</span>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<p v-if="voiceRows.length" class="text-[11px] text-slate-400 mt-1">
|
|
这 {{ voiceLangGroups.length }} 种语言就是该服务能说的语言,已作为下方 languages 的预填值——通话翻译、AI 助手排查「这条链路有没有服务支持」看的就是它。
|
|
音色本身是代码里的常量表(后端 comm/ttsvoice.go),实际用哪个在 Agent 里按语言+性别选,同组第一个是默认。
|
|
</p>
|
|
</div>
|
|
<div class="divider text-xs text-slate-400 my-3">配置字段</div>
|
|
<div>
|
|
<p v-if="!editFields.length" class="text-xs text-slate-400 py-4 text-center">尚无字段,选模板自动带出或点击下方添加</p>
|
|
<div v-else class="overflow-x-auto">
|
|
<table class="table text-xs">
|
|
<thead>
|
|
<tr>
|
|
<th class="w-44">字段名</th><th>字段描述</th><th class="w-16 text-center">加密</th><th>默认值</th><th class="w-12"></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="(f, i) in editFields" :key="i">
|
|
<td><input class="field-input" v-model="f.key" placeholder="key" :title="f.key" /></td>
|
|
<td><input class="field-input" v-model="f.description" placeholder="用途描述" /></td>
|
|
<td class="text-center"><input type="checkbox" class="checkbox checkbox-xs checkbox-primary" v-model="f.encrypted" /></td>
|
|
<td>
|
|
<LangSelect v-if="f.type === 'langs'" v-model="f.def_value" :multiple="true" />
|
|
<LangSelect v-else-if="f.type === 'lang'" v-model="f.def_value" />
|
|
<template v-else-if="f.encrypted">
|
|
<div class="relative">
|
|
<input :type="f._show ? 'text' : 'password'" class="field-input pr-14" v-model="f.def_value" placeholder="留空保留原值" />
|
|
<button type="button" class="absolute right-2 top-1/2 -translate-y-1/2 text-xs text-slate-400 hover:text-slate-700" @click="f._show = !f._show">{{ f._show ? '隐藏' : '显示' }}</button>
|
|
</div>
|
|
<span class="lock-badge ml-1">🔒 加密</span>
|
|
</template>
|
|
<select v-else-if="isMcpType() && f.key === 'type'" class="field-input" v-model="f.def_value">
|
|
<option value="0">HTTP</option>
|
|
<option value="1">SSE</option>
|
|
</select>
|
|
<input v-else class="field-input" v-model="f.def_value" />
|
|
</td>
|
|
<td><button class="btn btn-ghost btn-xs text-error" @click="removeField(i)">×</button></td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<button class="btn btn-ghost btn-sm mt-2 text-primary" @click="addField">+ 添加字段</button>
|
|
</div>
|
|
|
|
<!-- MCP 工具白名单:连服务端把工具列出来勾,别再手抄工具名 -->
|
|
<div v-if="isMcpType()" class="mt-4">
|
|
<div class="flex items-center justify-between mb-2">
|
|
<span class="text-sm font-medium text-slate-700">工具白名单</span>
|
|
<span class="text-[11px] text-slate-400">勾选结果写入上面的 tools 字段(逗号分隔,空=全部)</span>
|
|
</div>
|
|
<McpToolPicker
|
|
v-model="mcpEditTools"
|
|
:fetch-params="{ url: mcpEditUrl, type: mcpEditType }"
|
|
:can-fetch="!!mcpEditUrl"
|
|
/>
|
|
</div>
|
|
</template>
|
|
</template>
|
|
|
|
<!-- 区域覆盖模式 -->
|
|
<template v-else-if="detailMode === 'region' && curSvc">
|
|
<div v-if="regionLoading" class="text-center py-10 text-slate-400">加载中...</div>
|
|
<template v-else>
|
|
<div class="flex items-center justify-between mb-5">
|
|
<div class="flex items-center gap-3">
|
|
<div class="svc-logo" :style="{ background: curSvc.color || '#6366f1', width: '44px', height: '44px', fontSize: '20px' }">{{ curSvc.icon || (curSvc.name || '').charAt(0) }}</div>
|
|
<div>
|
|
<h3 class="text-base font-semibold text-slate-800">{{ curSvc.name }}</h3>
|
|
<span class="badge badge-warning badge-sm mt-0.5">{{ curRegionLabel }} 分叉模式</span>
|
|
</div>
|
|
</div>
|
|
<div class="flex gap-2">
|
|
<button class="btn btn-primary btn-sm text-white" @click="openSyncModal(false)">保存分叉</button>
|
|
<button class="btn btn-ghost btn-sm" @click="closeSvcModal">关闭</button>
|
|
</div>
|
|
</div>
|
|
<div class="rounded-lg px-3 py-2 mb-4 text-xs" style="background:#fefce8;border:1px solid #fde047;color:#854d0e">
|
|
✦ 当前编辑「{{ curRegionLabel }}」的字段分叉:默认字段可覆盖值或勾选「不适用」停用;也可新增本区域专属字段。留空的覆盖字段继承默认值,加密字段留空保留已存储密文。
|
|
</div>
|
|
|
|
<!-- 默认字段(含停用开关 + 值覆盖) -->
|
|
<p class="text-sm font-medium text-slate-700 mb-2">默认字段</p>
|
|
<div class="overflow-x-auto">
|
|
<table class="table text-xs">
|
|
<thead>
|
|
<tr>
|
|
<th class="w-44">字段名</th><th>字段描述</th><th class="w-24 text-center">状态</th><th class="w-20 text-center">本区域不适用</th><th>有效值</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="(f, i) in overrideFields" :key="i" :class="{ 'opacity-50': f.disabled }">
|
|
<td class="font-mono text-slate-700 break-all" :title="f.key">{{ f.key }}</td>
|
|
<td class="text-slate-500">{{ f.description }}</td>
|
|
<td class="text-center">
|
|
<span v-if="f.disabled" class="disabled-badge">停用</span>
|
|
<span v-else-if="f.is_overridden" class="override-badge">覆盖</span>
|
|
<span v-else class="inherit-badge">继承</span>
|
|
</td>
|
|
<td class="text-center"><input type="checkbox" class="checkbox checkbox-xs" v-model="f.disabled" /></td>
|
|
<td>
|
|
<LangSelect v-if="f.type === 'langs'" v-model="f._input" :multiple="true" :disabled="f.disabled" />
|
|
<LangSelect v-else-if="f.type === 'lang'" v-model="f._input" :disabled="f.disabled" />
|
|
<template v-else-if="f.encrypted">
|
|
<div class="relative">
|
|
<input :type="f._show ? 'text' : 'password'" class="field-input pr-14" v-model="f._input" :disabled="f.disabled" placeholder="留空继承默认" />
|
|
<button type="button" class="absolute right-2 top-1/2 -translate-y-1/2 text-xs text-slate-400 hover:text-slate-700" @click="f._show = !f._show">{{ f._show ? '隐藏' : '显示' }}</button>
|
|
</div>
|
|
<span class="lock-badge ml-1">🔒</span>
|
|
</template>
|
|
<select v-else-if="isMcpType() && f.key === 'type'" class="field-input" v-model="f._input" :disabled="f.disabled">
|
|
<option value="">继承默认</option>
|
|
<option value="0">HTTP</option>
|
|
<option value="1">SSE</option>
|
|
</select>
|
|
<input v-else class="field-input" v-model="f._input" :disabled="f.disabled" :placeholder="'留空继承:' + (f.value || '')" />
|
|
</td>
|
|
</tr>
|
|
<tr v-if="!overrideFields.length"><td colspan="5" class="text-center text-slate-400 py-3">该服务尚无默认字段</td></tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<!-- MCP 工具白名单(本区域):探的是本区域生效的 url -->
|
|
<div v-if="isMcpType()" class="mt-4">
|
|
<div class="flex items-center justify-between mb-2">
|
|
<span class="text-sm font-medium text-slate-700">工具白名单({{ curRegionLabel }})</span>
|
|
<span class="text-[11px] text-slate-400">写入本区域 tools 覆盖值;全清空=留空=继承默认</span>
|
|
</div>
|
|
<McpToolPicker
|
|
v-model="mcpRgnTools"
|
|
:fetch-params="{ url: mcpRgnUrl, type: mcpRgnType }"
|
|
:can-fetch="!!mcpRgnUrl"
|
|
hint="本区域没有生效的 url(覆盖值与默认值都为空)"
|
|
/>
|
|
</div>
|
|
|
|
<!-- 区域专属字段 -->
|
|
<div class="divider text-xs text-slate-400 my-3">{{ curRegionLabel }} 专属字段</div>
|
|
<div>
|
|
<p v-if="!extraRegionFields.length" class="text-xs text-slate-400 py-2 text-center">尚无区域专属字段,点击下方添加</p>
|
|
<div v-else class="overflow-x-auto">
|
|
<table class="table text-xs">
|
|
<thead>
|
|
<tr><th class="w-44">字段名</th><th>字段描述</th><th class="w-16 text-center">加密</th><th>值</th><th class="w-12"></th></tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="(f, i) in extraRegionFields" :key="i">
|
|
<td><input class="field-input" v-model="f.key" placeholder="key" :title="f.key" /></td>
|
|
<td><input class="field-input" v-model="f.description" placeholder="用途描述" /></td>
|
|
<td class="text-center"><input type="checkbox" class="checkbox checkbox-xs checkbox-primary" v-model="f.encrypted" /></td>
|
|
<td>
|
|
<template v-if="f.encrypted">
|
|
<div class="relative">
|
|
<input :type="f._show ? 'text' : 'password'" class="field-input pr-14" v-model="f._input" placeholder="留空保留原值" />
|
|
<button type="button" class="absolute right-2 top-1/2 -translate-y-1/2 text-xs text-slate-400 hover:text-slate-700" @click="f._show = !f._show">{{ f._show ? '隐藏' : '显示' }}</button>
|
|
</div>
|
|
<span class="lock-badge ml-1">🔒</span>
|
|
</template>
|
|
<input v-else class="field-input" v-model="f._input" />
|
|
</td>
|
|
<td><button class="btn btn-ghost btn-xs text-error" @click="removeExtraField(i)">×</button></td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<button class="btn btn-ghost btn-sm mt-2 text-primary" @click="addExtraField">+ 添加区域专属字段</button>
|
|
</div>
|
|
</template>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 同步区域弹窗 -->
|
|
<div v-show="syncVisible" class="modal-overlay" style="z-index:120">
|
|
<div class="sync-box">
|
|
<div class="flex items-start gap-3 mb-4">
|
|
<div style="width:42px;height:42px;background:linear-gradient(135deg,#4f46e5,#7c3aed);border-radius:13px;display:flex;align-items:center;justify-content:center;flex-shrink:0">
|
|
<svg style="width:20px;height:20px;color:#fff" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" /></svg>
|
|
</div>
|
|
<div>
|
|
<h3 class="text-base font-semibold text-slate-800">同步到其他区域</h3>
|
|
<p class="text-xs text-slate-400 mt-1">{{ syncDesc }}</p>
|
|
</div>
|
|
</div>
|
|
<div class="text-xs px-3 py-2 rounded-lg mb-3" style="background:#f0fdf4;border:1px solid #bbf7d0;color:#15803d" v-html="syncScopeNote"></div>
|
|
<div class="flex items-center justify-between mb-1">
|
|
<span class="text-xs font-semibold text-slate-500">选择目标区域</span>
|
|
<div class="flex gap-2">
|
|
<button class="btn btn-ghost btn-xs" @click="syncSelAll(true)">全选</button>
|
|
<button class="btn btn-ghost btn-xs" @click="syncSelAll(false)">清空</button>
|
|
</div>
|
|
</div>
|
|
<div class="sync-region-grid">
|
|
<label
|
|
v-for="r in syncRegionList"
|
|
:key="r.value"
|
|
class="sync-region-item"
|
|
:class="{ checked: syncTargets.includes(r.value) }"
|
|
@click="toggleSyncR(r.value)"
|
|
>
|
|
<input type="checkbox" class="checkbox checkbox-xs checkbox-primary" :checked="syncTargets.includes(r.value)" @click.stop="toggleSyncR(r.value)" />
|
|
<span>{{ r.label }}</span>
|
|
</label>
|
|
</div>
|
|
<p class="text-[11px] text-slate-400 mb-5">⚠ 已勾选区域将被当前配置(值覆盖 / 区域专属字段 / 停用字段)整体替换;加密字段密文一并同步。</p>
|
|
<div class="flex gap-3 justify-end">
|
|
<button class="btn btn-ghost" @click="doSaveOnly">仅保存当前区域</button>
|
|
<button class="btn btn-primary text-white" @click="doSaveAndSync">保存并同步</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 服务商模板管理弹窗 -->
|
|
<div v-show="tplModalVisible" class="modal-overlay" style="z-index:130">
|
|
<div class="tpl-box">
|
|
<!-- 模板列表 -->
|
|
<template v-if="tplView === 'list'">
|
|
<div class="flex items-center justify-between mb-4">
|
|
<div>
|
|
<h3 class="text-base font-semibold text-slate-800">服务商模板</h3>
|
|
<p class="text-xs text-slate-400 mt-1">模板是字段预设,新增服务时可一键带出。内置模板可编辑;删除后重启会被重新补回。</p>
|
|
</div>
|
|
<div class="flex gap-2">
|
|
<button class="btn btn-primary btn-sm text-white" @click="tplShowForm()">+ 新增模板</button>
|
|
<button class="btn btn-ghost btn-sm" @click="closeTplModal">关闭</button>
|
|
</div>
|
|
</div>
|
|
<div class="tpl-list">
|
|
<div v-for="g in templatesByCat" :key="g.cat" class="mb-3">
|
|
<p class="text-xs font-semibold text-slate-500 mb-1">{{ g.label }}</p>
|
|
<div v-for="t in g.items" :key="t.id" class="tpl-row">
|
|
<div style="min-width:0;flex:1">
|
|
<div class="flex items-center gap-2">
|
|
<span class="font-semibold text-sm text-slate-800 truncate">{{ t.name }}</span>
|
|
<span class="provider-chip">{{ t.provider }}</span>
|
|
<span v-if="t.builtin" class="badge badge-xs badge-ghost">内置</span>
|
|
</div>
|
|
<p class="text-xs text-slate-400 mt-0.5">{{ t.id }} · {{ (t.fields || []).length }} 字段</p>
|
|
</div>
|
|
<div class="flex gap-1">
|
|
<button class="btn btn-ghost btn-xs" @click="tplShowForm(t)">编辑</button>
|
|
<button class="btn btn-ghost btn-xs text-error" @click="delTpl(t)">删除</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<p v-if="!templates.length" class="text-xs text-slate-400 text-center py-6">暂无模板</p>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- 模板编辑表单 -->
|
|
<template v-else>
|
|
<div class="flex items-center justify-between mb-4">
|
|
<h3 class="text-base font-semibold text-slate-800">{{ tplEditing ? '编辑模板' : '新增模板' }}</h3>
|
|
<div class="flex gap-2">
|
|
<button class="btn btn-ghost btn-sm" @click="tplView = 'list'">返回</button>
|
|
<button class="btn btn-primary btn-sm text-white" @click="saveTpl">保存</button>
|
|
</div>
|
|
</div>
|
|
<div class="grid grid-cols-2 gap-3 mb-3">
|
|
<div class="form-control">
|
|
<label class="label"><span class="label-text text-xs">模板ID *</span></label>
|
|
<input v-model="tplForm.id" :disabled="tplEditing" class="input input-bordered input-sm font-mono" placeholder="tts_azure" />
|
|
</div>
|
|
<div class="form-control">
|
|
<label class="label"><span class="label-text text-xs">类别 *</span></label>
|
|
<select v-model.number="tplForm.category" class="select select-bordered select-sm">
|
|
<option v-for="sc in SCAT" :key="sc.v" :value="sc.v">{{ sc.label }}</option>
|
|
</select>
|
|
</div>
|
|
<div class="form-control">
|
|
<label class="label"><span class="label-text text-xs">服务商标识 *</span></label>
|
|
<input v-model="tplForm.provider" class="input input-bordered input-sm font-mono" placeholder="azure" />
|
|
</div>
|
|
<div class="form-control">
|
|
<label class="label"><span class="label-text text-xs">展示名 *</span></label>
|
|
<input v-model="tplForm.name" class="input input-bordered input-sm" placeholder="微软 Azure TTS" />
|
|
</div>
|
|
</div>
|
|
<div class="form-control mb-3">
|
|
<label class="label"><span class="label-text text-xs">描述</span></label>
|
|
<input v-model="tplForm.description" class="input input-bordered input-sm" />
|
|
</div>
|
|
<p class="text-sm font-medium text-slate-700 mb-1">字段</p>
|
|
<div class="overflow-x-auto" style="max-height:34vh">
|
|
<table class="table text-xs">
|
|
<thead>
|
|
<tr><th class="w-44">字段名</th><th>字段描述</th><th class="w-16 text-center">加密</th><th>默认/提示值</th><th class="w-12"></th></tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="(f, i) in tplFields" :key="i">
|
|
<td><input class="field-input" v-model="f.key" placeholder="key" :title="f.key" /></td>
|
|
<td><input class="field-input" v-model="f.description" placeholder="用途描述" /></td>
|
|
<td class="text-center"><input type="checkbox" class="checkbox checkbox-xs checkbox-primary" v-model="f.encrypted" /></td>
|
|
<td>
|
|
<LangSelect v-if="f.type === 'langs'" v-model="f.def_value" :multiple="true" />
|
|
<LangSelect v-else-if="f.type === 'lang'" v-model="f.def_value" />
|
|
<input v-else class="field-input" v-model="f.def_value" placeholder="可留空" />
|
|
</td>
|
|
<td><button class="btn btn-ghost btn-xs text-error" @click="tplRemoveField(i)">×</button></td>
|
|
</tr>
|
|
<tr v-if="!tplFields.length"><td colspan="5" class="text-center text-slate-400 py-2">尚无字段</td></tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<button class="btn btn-ghost btn-sm mt-2 text-primary" @click="tplAddField">+ 添加字段</button>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, reactive, computed, onMounted, watch } from 'vue'
|
|
import { useApi } from '~/composables/useApi'
|
|
import { useToast } from '~/composables/useToast'
|
|
|
|
definePageMeta({ title: '第三方服务配置' })
|
|
|
|
// 区域枚举(与 pb.Region 一一对应)
|
|
const REGIONS = [
|
|
{ value: 1, label: '中国' }, { value: 2, label: '美国' }, { value: 3, label: '东亚' },
|
|
{ value: 4, label: '东南亚' }, { value: 5, label: '南亚' }, { value: 6, label: '中东' },
|
|
{ value: 7, label: '欧洲' }, { value: 8, label: '俄罗斯' }, { value: 9, label: '非洲' },
|
|
{ value: 10, label: '南美' }, { value: 11, label: '北美' }, { value: 12, label: '大洋洲' },
|
|
{ value: 13, label: '巴西' }, { value: 14, label: '印度' },
|
|
]
|
|
|
|
// 服务类别常量(与后端 svcCatXxx 一一对应)。categories 支持逗号分隔多值,一个服务可同属多类。
|
|
const SCAT = [
|
|
{ v: 1, label: 'STT 语音识别', short: 'STT', color: '#0369a1', bg: '#dbeafe' },
|
|
{ v: 8, label: '录音文件识别', short: '录音识别', color: '#0e7490', bg: '#cffafe' },
|
|
{ v: 2, label: 'TTS 语音合成', short: 'TTS', color: '#7c3aed', bg: '#ede9fe' },
|
|
{ v: 3, label: 'MT 机器翻译', short: 'MT', color: '#059669', bg: '#dcfce7' },
|
|
{ v: 4, label: 'AST 端到端翻译', short: 'AST', color: '#c2410c', bg: '#fff7ed' },
|
|
{ v: 5, label: 'LLM 文本大模型', short: '文本LLM', color: '#b45309', bg: '#fef9c3' },
|
|
{ v: 9, label: '多媒体大模型', short: '多媒体LLM', color: '#9333ea', bg: '#f3e8ff' },
|
|
{ v: 7, label: 'STS 端到端对话', short: 'STS', color: '#be185d', bg: '#fce7f3' },
|
|
{ v: 6, label: '存储 OSS/COS', short: '存储', color: '#475569', bg: '#f1f5f9' },
|
|
{ v: 10, label: 'MCP 服务', short: 'MCP', color: '#0d9488', bg: '#ccfbf1' },
|
|
]
|
|
|
|
// MCP 服务类别(与后端 svcCatMCP 一致):字段固定为 url/type/tools,type 用 HTTP/SSE 下拉。
|
|
const SCAT_MCP = 10
|
|
|
|
interface SvcField {
|
|
key: string
|
|
description: string
|
|
type?: string // ''/text 普通;langs 支持语言多选;lang 单选默认语言
|
|
encrypted: boolean
|
|
def_value: string
|
|
sort?: number
|
|
_show?: boolean
|
|
}
|
|
|
|
interface ThirdSvcConfig {
|
|
id: string
|
|
name: string
|
|
provider?: string
|
|
description: string
|
|
categories: string
|
|
enable: boolean
|
|
color?: string
|
|
icon?: string
|
|
fields: SvcField[]
|
|
}
|
|
|
|
interface SvcTemplate {
|
|
id: string
|
|
category: number
|
|
provider: string
|
|
name: string
|
|
description: string
|
|
fields: SvcField[]
|
|
builtin: boolean
|
|
sort: number
|
|
}
|
|
|
|
// 区域默认字段视图(含停用/覆盖状态 + 编辑值)
|
|
interface RegionDefaultField {
|
|
key: string
|
|
description: string
|
|
type?: string
|
|
encrypted: boolean
|
|
is_overridden: boolean
|
|
disabled: boolean
|
|
value: string
|
|
_input: string
|
|
_show: boolean
|
|
}
|
|
|
|
// 区域专属字段(可编辑的完整字段定义)
|
|
interface RegionExtraField {
|
|
key: string
|
|
description: string
|
|
encrypted: boolean
|
|
_input: string
|
|
_show: boolean
|
|
}
|
|
|
|
const { webApi, consoleApi } = useApi()
|
|
const { success, error, warn } = useToast()
|
|
|
|
// ===== 作用域:全局默认('') 或 某应用(app_name) =====
|
|
// 第三方服务/区域覆盖均按应用分离:应用有自己的就用应用的、没有回退全局。此选择器切换正在管理的作用域。
|
|
const scopeApp = ref('')
|
|
const scopeApps = ref<string[]>([])
|
|
async function loadScopeApps() {
|
|
try {
|
|
const list = await consoleApi<Array<{ name: string }>>('products/list', {})
|
|
scopeApps.value = (list ?? []).map((p: { name: string }) => p.name).filter(Boolean)
|
|
} catch {
|
|
scopeApps.value = []
|
|
}
|
|
}
|
|
function onScopeChange() {
|
|
closeSvcModal()
|
|
loadSvcs()
|
|
}
|
|
|
|
function scatInfo(v: number) {
|
|
return SCAT.find((s) => s.v === v) || { short: '?', color: '#999', bg: '#eee', v: 0, label: '' }
|
|
}
|
|
// 当前编辑的是否 MCP 服务:新增向导看 editType,默认/区域编辑看当前服务 categories。
|
|
// ── 按模板补齐字段 ──
|
|
// 内置模板后来新增的字段(如 llm_qwen 的 base_url)只会补进模板表,已经建好的服务实例不会自动长出来,
|
|
// 于是客户端拿到的这条服务就缺字段。这里按模板把缺的字段补进当前编辑中的字段表:
|
|
// 只加不改——已有字段的值/描述/加密标记一律保留,补进来的带模板默认值,确认后照常点保存生效。
|
|
const fillFromTemplateTip = '内置模板新增的字段不会自动补到已建好的服务上,点这里按模板补齐(只加不改,需保存)'
|
|
|
|
// 服务实例与模板的对应:优先按同 id(向导建的服务 id 就取自模板 id),否则按「服务商 + 类别」找。
|
|
function matchTemplate(): SvcTemplate | undefined {
|
|
const byId = templates.value.find((t) => t.id === editForm.id)
|
|
if (byId) return byId
|
|
const cat = editType.value || parseCats(curSvc.value?.categories || '')[0]
|
|
return templates.value.find((t) => t.provider === editForm.provider && t.category === cat)
|
|
}
|
|
|
|
function fillFieldsFromTemplate() {
|
|
const tpl = matchTemplate()
|
|
if (!tpl) {
|
|
warn('没找到对应的服务商模板(可到「服务商模板」里补一个,或手动 + 添加字段)')
|
|
return
|
|
}
|
|
const have = new Set(editFields.value.map((f) => f.key))
|
|
const added: string[] = []
|
|
for (const f of tpl.fields || []) {
|
|
if (have.has(f.key)) continue
|
|
editFields.value.push({ ...f, sort: editFields.value.length, _show: false })
|
|
added.push(f.key)
|
|
}
|
|
if (!added.length) {
|
|
success(`字段已与模板「${tpl.name}」一致,无需补齐`)
|
|
return
|
|
}
|
|
success(`已按模板「${tpl.name}」补上 ${added.join('、')},确认无误后点保存`)
|
|
}
|
|
|
|
// ── MCP 工具白名单 ──
|
|
// tools 字段存的是逗号分隔字符串,McpToolPicker 按数组来,这里做两个方向的转换。
|
|
// url/type 取当前正在编辑的那份值:默认视图看 def_value,区域分叉看「覆盖值 || 继承的默认值」。
|
|
function pickField<T extends { key: string }>(list: T[], key: string): T | undefined {
|
|
return list.find((f) => f.key === key)
|
|
}
|
|
function csvToArr(v?: string): string[] {
|
|
return (v || '').split(',').map((x) => x.trim()).filter(Boolean)
|
|
}
|
|
|
|
const mcpEditUrl = computed(() => (pickField(editFields.value, 'url')?.def_value || '').trim())
|
|
const mcpEditType = computed(() => (pickField(editFields.value, 'type')?.def_value || '0').trim())
|
|
const mcpEditTools = computed<string[]>({
|
|
get: () => csvToArr(pickField(editFields.value, 'tools')?.def_value),
|
|
set: (v) => {
|
|
const f = pickField(editFields.value, 'tools')
|
|
if (f) f.def_value = v.join(',')
|
|
// 模板里本来就带 tools 字段;万一被删了就补一条回去,否则勾选结果无处可存
|
|
else editFields.value.push({ key: 'tools', description: '启用工具(逗号分隔,空=全部)', encrypted: false, def_value: v.join(','), sort: editFields.value.length } as SvcField)
|
|
},
|
|
})
|
|
|
|
const mcpRgnUrl = computed(() => {
|
|
const f = pickField(overrideFields.value, 'url')
|
|
return ((f?._input || f?.value) || '').trim()
|
|
})
|
|
const mcpRgnType = computed(() => {
|
|
const f = pickField(overrideFields.value, 'type')
|
|
return ((f?._input || f?.value) || '0').trim()
|
|
})
|
|
const mcpRgnTools = computed<string[]>({
|
|
get: () => {
|
|
const f = pickField(overrideFields.value, 'tools')
|
|
return csvToArr(f?._input || f?.value)
|
|
},
|
|
set: (v) => {
|
|
const f = pickField(overrideFields.value, 'tools')
|
|
if (f) f._input = v.join(',')
|
|
},
|
|
})
|
|
|
|
function isMcpType(): boolean {
|
|
if (editType.value === SCAT_MCP) return true
|
|
return !!curSvc.value && parseCats(curSvc.value.categories).includes(SCAT_MCP)
|
|
}
|
|
function parseCats(s?: string): number[] {
|
|
return (s || '').split(',').map((x) => parseInt(x) || 0).filter((x) => x > 0)
|
|
}
|
|
function encCount(s: ThirdSvcConfig): number {
|
|
return (s.fields || []).filter((f) => f.encrypted).length
|
|
}
|
|
|
|
// 卡片右下角的区域标识:默认视图报「分叉了几个区域」,区域视图报「本区域是分叉还是继承默认」。
|
|
function regionChip(s: ThirdSvcConfig): { text: string; cls: string; title: string } {
|
|
const rs = svcRegions.value[s.id] || []
|
|
const names = rs.map((v) => REGIONS.find((x) => x.value === v)?.label || '区域' + v)
|
|
if (curRegion.value === 0) {
|
|
if (!rs.length) return { text: '全区域通用', cls: 'plain', title: '未做任何区域分叉,各区域都用默认配置' }
|
|
return { text: rs.length + ' 区域分叉', cls: 'has', title: '已分叉:' + names.join('、') }
|
|
}
|
|
if (rs.includes(curRegion.value)) {
|
|
return { text: '本区域已分叉', cls: 'has', title: curRegionLabel.value + '有独立的字段配置' }
|
|
}
|
|
return { text: '继承默认', cls: 'plain', title: curRegionLabel.value + '未分叉,使用默认配置' }
|
|
}
|
|
|
|
// ===== 内置音色表(接入语音输出类服务时确认覆盖情况;后端 comm/ttsvoice.go 常量) =====
|
|
interface TTSVoice {
|
|
provider: string
|
|
language: string
|
|
gender: number
|
|
voice_id: string
|
|
name: string
|
|
}
|
|
// 会出声的类别:TTS / AST 端到端翻译 / STS 端到端对话——它们的音色都按服务商区分。
|
|
const VOICE_CATS = [2, 4, 7]
|
|
const voiceRows = ref<TTSVoice[]>([])
|
|
const voicesLoading = ref(false)
|
|
const isVoiceCat = computed(() => VOICE_CATS.includes(editType.value))
|
|
// 按语言分组,组内保持后端顺序(第一个即默认音色,与运行时 comm.PickVoice 口径一致)
|
|
const voiceLangGroups = computed(() => {
|
|
const map = new Map<string, TTSVoice[]>()
|
|
for (const v of voiceRows.value) {
|
|
if (!map.has(v.language)) map.set(v.language, [])
|
|
map.get(v.language)!.push(v)
|
|
}
|
|
return Array.from(map, ([lang, items]) => ({ lang, items }))
|
|
})
|
|
// provider 变了就重拉;没覆盖到的服务商拿到空列表,界面提示一句了事,不挡接入流程
|
|
// 把音色表的语言写进表单的 languages 字段。onlyIfEmpty=true 时只在空值下填,
|
|
// 不覆盖运营已经调过的语言范围(那是他们按实际账号权限收窄过的,覆盖了会悄悄放开)。
|
|
function fillLanguagesFromVoices(onlyIfEmpty: boolean): boolean {
|
|
const f = editFields.value.find((x) => x.key === 'languages')
|
|
if (!f) return false
|
|
if (onlyIfEmpty && (f.def_value || '').trim()) return false
|
|
const langs: string[] = []
|
|
const seen = new Set<string>()
|
|
for (const v of voiceRows.value) {
|
|
if (!seen.has(v.language)) {
|
|
seen.add(v.language)
|
|
langs.push(v.language)
|
|
}
|
|
}
|
|
if (!langs.length) return false
|
|
f.def_value = langs.join(',')
|
|
return true
|
|
}
|
|
|
|
async function loadVoicesForProvider(provider: string) {
|
|
const p = (provider || '').trim()
|
|
voiceRows.value = []
|
|
if (!p) return
|
|
voicesLoading.value = true
|
|
try {
|
|
const d = await webApi<{ voices: TTSVoice[] }>('getttsvoices', { provider: p })
|
|
voiceRows.value = d?.voices ?? []
|
|
} catch {
|
|
voiceRows.value = []
|
|
} finally {
|
|
voicesLoading.value = false
|
|
}
|
|
}
|
|
|
|
// 手动把音色表语言写进 languages(会覆盖当前值,所以做成按钮由人决定,不自动来)
|
|
function onFillLanguages() {
|
|
if (fillLanguagesFromVoices(false)) {
|
|
success('已按音色表填入 ' + voiceLangGroups.value.length + ' 种语言,保存后生效')
|
|
} else {
|
|
error('该服务商没有内置音色,或本服务没有 languages 字段')
|
|
}
|
|
}
|
|
|
|
// ===== 状态 =====
|
|
const backendReady = ref(true)
|
|
const allSvcs = ref<ThirdSvcConfig[]>([])
|
|
// svc_id → 已建区域分叉的区域枚举(后端 getsvcconfigs 一并返回)。卡片据此显示区域标识:
|
|
// MCP 这类值全部落在区域分叉上的服务,不显示的话在默认视图里看着像「什么都没配」。
|
|
const svcRegions = ref<Record<string, number[]>>({})
|
|
// svc_id → 一个值都没有的凭据字段(后端 getsvcconfigs 一并算好:基础行与所有区域分叉都空才算缺)。
|
|
// 前端自己判不了——加密字段返回的是脱敏值,且凭据可能只配在某个区域分叉上。
|
|
const credMissing = ref<Record<string, string[]>>({})
|
|
const curSvc = ref<ThirdSvcConfig | null>(null)
|
|
const curRegion = ref(0)
|
|
const svcModalVisible = ref(false)
|
|
const detailMode = ref<'default' | 'new' | 'region'>('default')
|
|
|
|
// 编辑中数据
|
|
const editForm = reactive({ id: '', name: '', provider: '', description: '', enable: false })
|
|
const editType = ref(0) // 单一服务类型(SCAT.v)
|
|
const editFields = ref<SvcField[]>([])
|
|
const selectedTemplateId = ref('')
|
|
const wizardStep = ref(1) // 新增向导步骤:1 类型 → 2 服务商 → 3 配置
|
|
|
|
// 区域分叉
|
|
const overrideFields = ref<RegionDefaultField[]>([])
|
|
const extraRegionFields = ref<RegionExtraField[]>([])
|
|
const regionLoading = ref(false)
|
|
|
|
// 服务商模板
|
|
const templates = ref<SvcTemplate[]>([])
|
|
|
|
const curRegionLabel = computed(() => {
|
|
const r = REGIONS.find((x) => x.value === curRegion.value)
|
|
return r ? r.label : '区域' + curRegion.value
|
|
})
|
|
|
|
// 服务卡片按服务类型分组;未归类的服务归入末尾的「未分类」组
|
|
const groupedSvcs = computed(() => {
|
|
const groups = SCAT.map((sc) => ({
|
|
...sc,
|
|
items: allSvcs.value.filter((s) => parseCats(s.categories).includes(sc.v)),
|
|
})).filter((g) => g.items.length)
|
|
const unclassified = allSvcs.value.filter((s) => !parseCats(s.categories).length)
|
|
if (unclassified.length) {
|
|
groups.push({ v: 0, label: '未分类', short: '未分类', color: '#64748b', bg: '#f1f5f9', items: unclassified })
|
|
}
|
|
return groups
|
|
})
|
|
|
|
// ===== 分组折叠 =====
|
|
// 服务多起来(尤其 MCP 这种一类里几十个)时整页铺满没法看,按服务类型折叠。
|
|
// key = SCAT.v(未分类组为 0),值 true 表示折叠。默认全展开。
|
|
const folded = reactive<Record<number, boolean>>({})
|
|
function toggleGroup(v: number) {
|
|
folded[v] = !folded[v]
|
|
}
|
|
const allFolded = computed(() => groupedSvcs.value.length > 0 && groupedSvcs.value.every((g) => folded[g.v]))
|
|
function toggleAllGroups() {
|
|
const next = !allFolded.value
|
|
groupedSvcs.value.forEach((g) => { folded[g.v] = next })
|
|
}
|
|
|
|
// 模板按类别分组(供下拉与模板管理列表)
|
|
const templatesByCat = computed(() => {
|
|
return SCAT.map((sc) => ({
|
|
cat: sc.v,
|
|
label: sc.label,
|
|
items: templates.value.filter((t) => t.category === sc.v),
|
|
})).filter((g) => g.items.length)
|
|
})
|
|
|
|
// ===== 加载服务列表 =====
|
|
async function loadSvcs() {
|
|
try {
|
|
const data = await webApi<{ items: ThirdSvcConfig[]; regions?: Record<string, number[]>; cred_missing?: Record<string, string[]> }>('getsvcconfigs', { app_name: scopeApp.value })
|
|
allSvcs.value = data?.items ?? []
|
|
svcRegions.value = data?.regions ?? {}
|
|
credMissing.value = data?.cred_missing ?? {}
|
|
backendReady.value = true
|
|
} catch {
|
|
backendReady.value = false
|
|
allSvcs.value = []
|
|
svcRegions.value = {}
|
|
credMissing.value = {}
|
|
return
|
|
}
|
|
if (curSvc.value) {
|
|
const found = allSvcs.value.find((s) => s.id === curSvc.value!.id)
|
|
if (found) curSvc.value = found
|
|
}
|
|
}
|
|
|
|
// ===== 加载服务商模板 =====
|
|
async function loadTemplates() {
|
|
try {
|
|
const data = await webApi<{ items: SvcTemplate[] }>('getsvctemplates', {})
|
|
templates.value = data?.items ?? []
|
|
} catch {
|
|
templates.value = []
|
|
}
|
|
}
|
|
|
|
// ===== 区域变更:关闭弹窗,卡片列表本身与区域无关 =====
|
|
function onRegionChange() {
|
|
closeSvcModal()
|
|
}
|
|
|
|
// ===== 点击卡片:默认区域进全量编辑,其他区域进分叉编辑 =====
|
|
function selectSvc(svc: ThirdSvcConfig) {
|
|
if (!svc) return
|
|
curSvc.value = svc
|
|
svcModalVisible.value = true
|
|
if (curRegion.value === 0) renderDefaultDetail()
|
|
else renderRegionDetail()
|
|
}
|
|
|
|
function closeSvcModal() {
|
|
svcModalVisible.value = false
|
|
curSvc.value = null
|
|
}
|
|
|
|
// ===== 默认模式:服务全量编辑 =====
|
|
function renderDefaultDetail() {
|
|
const s = curSvc.value!
|
|
editForm.id = s.id
|
|
editForm.name = s.name || ''
|
|
editForm.provider = s.provider || ''
|
|
editForm.description = s.description || ''
|
|
editForm.enable = !!s.enable
|
|
editType.value = parseCats(s.categories)[0] || 0
|
|
editFields.value = JSON.parse(JSON.stringify(s.fields || [])).map((f: SvcField) => ({ ...f, _show: false }))
|
|
detailMode.value = 'default'
|
|
}
|
|
|
|
// ===== 新增服务(类型优先向导);presetType 由分组卡片的「+」带入 =====
|
|
function startAdd(presetType = 0) {
|
|
curSvc.value = null
|
|
editForm.id = ''
|
|
editForm.name = ''
|
|
editForm.provider = ''
|
|
editForm.description = ''
|
|
editForm.enable = false
|
|
editType.value = presetType
|
|
editFields.value = []
|
|
selectedTemplateId.value = ''
|
|
wizardStep.value = presetType ? 2 : 1
|
|
detailMode.value = 'new'
|
|
svcModalVisible.value = true
|
|
}
|
|
|
|
// 当前所选类型下的服务商模板
|
|
const templatesForType = computed(() => templates.value.filter((t) => t.category === editType.value))
|
|
|
|
// 步骤1:选服务类型(切换类型会重置下游的服务商与字段)
|
|
function chooseType(v: number) {
|
|
if (editType.value !== v) {
|
|
editType.value = v
|
|
selectedTemplateId.value = ''
|
|
editForm.provider = ''
|
|
editFields.value = []
|
|
}
|
|
wizardStep.value = 2
|
|
}
|
|
|
|
// 步骤2:选服务商模板(t 为 null 表示空白自定义)
|
|
function chooseProvider(t: SvcTemplate | null) {
|
|
if (t) {
|
|
selectedTemplateId.value = t.id
|
|
editForm.provider = t.provider || ''
|
|
if (!editForm.name) editForm.name = t.name || ''
|
|
if (!editForm.id) editForm.id = t.id || ''
|
|
if (!editForm.description) editForm.description = t.description || ''
|
|
editFields.value = (t.fields || []).map((f) => ({
|
|
key: f.key, description: f.description, type: f.type || '', encrypted: !!f.encrypted,
|
|
def_value: f.def_value || '', sort: f.sort, _show: false,
|
|
}))
|
|
} else {
|
|
selectedTemplateId.value = ''
|
|
editForm.provider = ''
|
|
editFields.value = []
|
|
}
|
|
wizardStep.value = 3
|
|
// 新建语音输出类服务时,languages 用音色表自动填好——有音色才说得出那门语言,
|
|
// 让运营对着文档手抄一遍既费事又容易漏。填完仍可增删。
|
|
if (editForm.provider && VOICE_CATS.includes(editType.value)) {
|
|
loadVoicesForProvider(editForm.provider).then(() => fillLanguagesFromVoices(true))
|
|
}
|
|
}
|
|
|
|
// ===== 默认字段表格 =====
|
|
function addField() {
|
|
editFields.value.push({ key: '', description: '', encrypted: false, def_value: '', sort: editFields.value.length, _show: false })
|
|
}
|
|
function removeField(i: number) {
|
|
editFields.value.splice(i, 1)
|
|
editFields.value.forEach((f, j) => { f.sort = j })
|
|
}
|
|
|
|
// ===== 区域分叉模式 =====
|
|
async function renderRegionDetail() {
|
|
const s = curSvc.value!
|
|
regionLoading.value = true
|
|
detailMode.value = 'region'
|
|
try {
|
|
const data = await webApi<{ fields: any[] }>('getsvcregionoverride', { app_name: scopeApp.value, svc_id: s.id, region: curRegion.value })
|
|
const fields = data?.fields ?? []
|
|
overrideFields.value = fields.filter((f) => !f.region_only).map((f) => ({
|
|
key: f.key, description: f.description, type: f.type || '', encrypted: !!f.encrypted,
|
|
is_overridden: !!f.is_overridden, disabled: !!f.disabled, value: f.value || '',
|
|
// 加密字段输入框始终留空;非加密字段:已覆盖回填覆盖值,否则留空
|
|
_input: f.encrypted ? '' : (f.is_overridden ? (f.value || '') : ''),
|
|
_show: false,
|
|
}))
|
|
extraRegionFields.value = fields.filter((f) => f.region_only).map((f) => ({
|
|
key: f.key, description: f.description, encrypted: !!f.encrypted,
|
|
_input: f.encrypted ? '' : (f.value || ''),
|
|
_show: false,
|
|
}))
|
|
} catch (e: any) {
|
|
error(e?.message ?? '加载区域分叉失败')
|
|
overrideFields.value = []
|
|
extraRegionFields.value = []
|
|
} finally {
|
|
regionLoading.value = false
|
|
}
|
|
}
|
|
|
|
function addExtraField() {
|
|
extraRegionFields.value.push({ key: '', description: '', encrypted: false, _input: '', _show: false })
|
|
}
|
|
function removeExtraField(i: number) {
|
|
extraRegionFields.value.splice(i, 1)
|
|
}
|
|
|
|
// ===== 收集表单数据 =====
|
|
function collectDefaultData(isNew: boolean) {
|
|
const data: Record<string, unknown> = {
|
|
name: editForm.name || '',
|
|
provider: editForm.provider || '',
|
|
description: editForm.description || '',
|
|
categories: editType.value ? String(editType.value) : '',
|
|
enable: editForm.enable || false,
|
|
fields: editFields.value.map((f) => ({
|
|
key: f.key,
|
|
description: f.description,
|
|
type: f.type || '',
|
|
encrypted: f.encrypted,
|
|
def_value: f.def_value,
|
|
sort: f.sort,
|
|
})),
|
|
}
|
|
if (isNew) data.id = editForm.id || ''
|
|
else data.id = curSvc.value!.id
|
|
data.app_name = scopeApp.value // 作用域随保存写入(''=全局默认)
|
|
return data
|
|
}
|
|
|
|
function collectOverrideData() {
|
|
const overrides: Record<string, string> = {}
|
|
const disabled_keys: string[] = []
|
|
overrideFields.value.forEach((f) => {
|
|
overrides[f.key] = f._input || ''
|
|
if (f.disabled) disabled_keys.push(f.key)
|
|
})
|
|
const extra_fields = extraRegionFields.value
|
|
.filter((f) => (f.key || '').trim())
|
|
.map((f, i) => ({ key: f.key.trim(), description: f.description, encrypted: f.encrypted, def_value: f._input || '', sort: i }))
|
|
return { overrides, disabled_keys, extra_fields }
|
|
}
|
|
|
|
// 保存前的空字段检查:凭据为空由后端硬拦(启用中的服务存不下去),这里管的是其余字段——
|
|
// base_url / languages / region 这类留空往往是有意的(走默认/不限),拦死会误伤,所以只列出来让人确认一次。
|
|
// 之前 stt_azure 就是三个字段全空却一路存了下去,客户端拿到空串才发现。
|
|
function confirmEmptyFields(): boolean {
|
|
const empty = editFields.value
|
|
.filter((f) => (f.key || '').trim() && !(f.def_value || '').trim())
|
|
.map((f) => `${f.key}${f.description ? `(${f.description})` : ''}`)
|
|
if (!empty.length) return true
|
|
return window.confirm(
|
|
`以下字段没有填值:\n\n${empty.join('\n')}\n\n` +
|
|
'这些值会原样下发给客户端(空字符串)。确认它们本来就该留空吗?',
|
|
)
|
|
}
|
|
|
|
// ===== 保存 =====
|
|
async function performSave(isNew: boolean): Promise<boolean> {
|
|
const isNew2 = isNew || !curSvc.value
|
|
if (curRegion.value === 0) {
|
|
const data = collectDefaultData(isNew2)
|
|
if (!data.id || !data.name) { error('服务ID 和名称 必填'); return false }
|
|
if (!confirmEmptyFields()) return false
|
|
const method = isNew2 ? 'addsvcconfig' : 'updatesvcconfig'
|
|
try {
|
|
const res = await webApi<ThirdSvcConfig>(method, data)
|
|
curSvc.value = res
|
|
await loadSvcs()
|
|
success('保存成功')
|
|
return true
|
|
} catch (e: any) {
|
|
error(e?.message ?? '保存失败')
|
|
return false
|
|
}
|
|
} else {
|
|
const { overrides, disabled_keys, extra_fields } = collectOverrideData()
|
|
try {
|
|
await webApi('savesvcregionoverride', { app_name: scopeApp.value, svc_id: curSvc.value!.id, region: curRegion.value, overrides, disabled_keys, extra_fields })
|
|
await loadSvcs() // 刷新卡片上的区域分叉标识
|
|
success('区域分叉已保存')
|
|
return true
|
|
} catch (e: any) {
|
|
error(e?.message ?? '保存失败')
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
|
|
async function deleteSvc() {
|
|
if (!curSvc.value) return
|
|
if (!window.confirm('确定删除服务「' + curSvc.value.name + '」及其所有区域分叉?')) return
|
|
try {
|
|
await webApi('delsvcconfig', { app_name: scopeApp.value, id: curSvc.value.id })
|
|
} catch (e: any) {
|
|
error(e?.message ?? '删除失败')
|
|
return
|
|
}
|
|
closeSvcModal()
|
|
success('已删除')
|
|
await loadSvcs()
|
|
}
|
|
|
|
// ===== 同步弹窗 =====
|
|
const syncVisible = ref(false)
|
|
const isNewSave = ref(false)
|
|
const syncDesc = ref('')
|
|
const syncScopeNote = ref('')
|
|
const syncTargets = ref<number[]>([])
|
|
|
|
const syncRegionList = computed(() => REGIONS.filter((r) => r.value !== curRegion.value))
|
|
|
|
function openSyncModal(isNew: boolean) {
|
|
isNewSave.value = isNew
|
|
const svcName = curSvc.value ? curSvc.value.name : (editForm.name || '新服务')
|
|
const isDefault = curRegion.value === 0
|
|
syncDesc.value = isDefault
|
|
? '「' + svcName + '」默认配置将保存,可同步推送到其他区域。'
|
|
: '「' + svcName + '」在当前区域的字段分叉将保存,可同步到其他区域。'
|
|
syncScopeNote.value = isDefault
|
|
? '✦ 同步内容:各字段<b>默认值</b>推送为目标区域「覆盖值」,目标区域原有分叉将被替换。'
|
|
: '✦ 同步内容:当前区域的值覆盖 / 区域专属字段 / 停用字段整体复制到目标区域。'
|
|
syncTargets.value = []
|
|
syncVisible.value = true
|
|
}
|
|
|
|
function toggleSyncR(v: number) {
|
|
const idx = syncTargets.value.indexOf(v)
|
|
if (idx >= 0) syncTargets.value.splice(idx, 1)
|
|
else syncTargets.value.push(v)
|
|
}
|
|
function syncSelAll(val: boolean) {
|
|
syncTargets.value = val ? syncRegionList.value.map((r) => r.value) : []
|
|
}
|
|
function closeSyncModal() {
|
|
syncVisible.value = false
|
|
}
|
|
|
|
async function doSaveOnly() {
|
|
closeSyncModal()
|
|
const ok = await performSave(isNewSave.value)
|
|
if (ok) closeSvcModal()
|
|
}
|
|
|
|
async function doSaveAndSync() {
|
|
const targets = syncTargets.value.slice()
|
|
closeSyncModal()
|
|
const ok = await performSave(isNewSave.value)
|
|
if (!ok) return
|
|
const svcId = curSvc.value ? curSvc.value.id : null
|
|
if (!targets.length || !svcId) { closeSvcModal(); return }
|
|
let synced = 0
|
|
try {
|
|
const res = await webApi<{ synced: number }>('syncsvctoregins', { app_name: scopeApp.value, svc_id: svcId, src_region: curRegion.value, target_regions: targets })
|
|
synced = res?.synced ?? 0
|
|
} catch (e: any) {
|
|
error(e?.message ?? '同步失败')
|
|
return
|
|
}
|
|
await loadSvcs() // 同步后各服务的区域分叉集合变了,刷新卡片标识
|
|
const names = targets.map((v) => {
|
|
const r = REGIONS.find((x) => x.value === v)
|
|
return r ? r.label : '区域' + v
|
|
})
|
|
success('已保存并同步到:' + names.join('、') + '(' + synced + ' 个)')
|
|
closeSvcModal()
|
|
}
|
|
|
|
// ===== 服务商模板管理弹窗 =====
|
|
const tplModalVisible = ref(false)
|
|
const tplView = ref<'list' | 'form'>('list')
|
|
const tplEditing = ref(false)
|
|
const tplForm = reactive({ id: '', category: 2, provider: '', name: '', description: '' })
|
|
const tplFields = ref<SvcField[]>([])
|
|
|
|
function openTplModal() {
|
|
tplView.value = 'list'
|
|
tplModalVisible.value = true
|
|
loadTemplates()
|
|
}
|
|
function closeTplModal() {
|
|
tplModalVisible.value = false
|
|
}
|
|
|
|
function tplShowForm(t?: SvcTemplate) {
|
|
if (t) {
|
|
tplEditing.value = true
|
|
tplForm.id = t.id
|
|
tplForm.category = t.category || 2
|
|
tplForm.provider = t.provider || ''
|
|
tplForm.name = t.name || ''
|
|
tplForm.description = t.description || ''
|
|
tplFields.value = (t.fields || []).map((f) => ({ key: f.key, description: f.description, type: f.type || '', encrypted: !!f.encrypted, def_value: f.def_value || '', sort: f.sort }))
|
|
} else {
|
|
tplEditing.value = false
|
|
tplForm.id = ''
|
|
tplForm.category = 2
|
|
tplForm.provider = ''
|
|
tplForm.name = ''
|
|
tplForm.description = ''
|
|
tplFields.value = []
|
|
}
|
|
tplView.value = 'form'
|
|
}
|
|
|
|
function tplAddField() {
|
|
tplFields.value.push({ key: '', description: '', encrypted: false, def_value: '', sort: tplFields.value.length })
|
|
}
|
|
function tplRemoveField(i: number) {
|
|
tplFields.value.splice(i, 1)
|
|
tplFields.value.forEach((f, j) => { f.sort = j })
|
|
}
|
|
|
|
async function saveTpl() {
|
|
if (!tplForm.id.trim() || !tplForm.name.trim() || !tplForm.provider.trim()) {
|
|
error('模板ID / 展示名 / 服务商标识 必填')
|
|
return
|
|
}
|
|
const data: Record<string, unknown> = {
|
|
id: tplForm.id.trim(),
|
|
category: Number(tplForm.category) || 0,
|
|
provider: tplForm.provider.trim(),
|
|
name: tplForm.name.trim(),
|
|
description: tplForm.description.trim(),
|
|
fields: tplFields.value.map((f, i) => ({ key: f.key, description: f.description, type: f.type || '', encrypted: f.encrypted, def_value: f.def_value, sort: i })),
|
|
}
|
|
try {
|
|
await webApi(tplEditing.value ? 'updatesvctemplate' : 'addsvctemplate', data)
|
|
} catch (e: any) {
|
|
error(e?.message ?? '保存失败')
|
|
return
|
|
}
|
|
success('模板已保存')
|
|
await loadTemplates()
|
|
tplView.value = 'list'
|
|
}
|
|
|
|
async function delTpl(t: SvcTemplate) {
|
|
if (!window.confirm('确定删除模板「' + t.name + '」?' + (t.builtin ? '(内置模板下次启动会被重新补回)' : ''))) return
|
|
try {
|
|
await webApi('delsvctemplate', { id: t.id })
|
|
} catch (e: any) {
|
|
error(e?.message ?? '删除失败')
|
|
return
|
|
}
|
|
success('已删除')
|
|
await loadTemplates()
|
|
}
|
|
|
|
// 只在语音输出类服务上拉音色表,别的类别不做无谓请求
|
|
watch([() => editForm.provider, isVoiceCat, svcModalVisible], ([provider, isVoice, visible]) => {
|
|
if (!visible || !isVoice) {
|
|
voiceRows.value = []
|
|
return
|
|
}
|
|
loadVoicesForProvider(provider as string)
|
|
}, { immediate: false })
|
|
|
|
onMounted(() => {
|
|
loadScopeApps()
|
|
loadSvcs()
|
|
loadTemplates()
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
/* 服务卡片网格。卡片一律等高(min-height + flex 列),标签行贴底对齐,
|
|
所有可变长文本(名称/ID/描述/服务商)都在自己的轨道里截断,不许把标签挤出卡片。 */
|
|
/* 分组标题整行可点折叠 */
|
|
.group-head {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
margin-bottom: 12px;
|
|
cursor: pointer;
|
|
user-select: none;
|
|
}
|
|
.group-head:hover h3 {
|
|
color: #4338ca;
|
|
}
|
|
.fold-arrow {
|
|
flex-shrink: 0;
|
|
width: 14px;
|
|
font-size: 11px;
|
|
color: #94a3b8;
|
|
transition: transform 0.15s;
|
|
}
|
|
.fold-arrow.folded {
|
|
transform: rotate(-90deg);
|
|
}
|
|
.svc-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fill, minmax(232px, 1fr));
|
|
gap: 12px;
|
|
align-items: stretch;
|
|
}
|
|
.svc-card {
|
|
display: flex;
|
|
flex-direction: column;
|
|
min-width: 0;
|
|
min-height: 166px;
|
|
overflow: hidden; /* 兜底:任何算错宽度的子元素都不许画到卡片外 */
|
|
background: #fff;
|
|
border: 1px solid #eef2f7;
|
|
border-radius: 14px;
|
|
padding: 14px;
|
|
cursor: pointer;
|
|
transition: all 0.15s;
|
|
}
|
|
.svc-card:hover {
|
|
border-color: #c7d2fe;
|
|
box-shadow: 0 6px 18px rgba(99, 102, 241, 0.12);
|
|
transform: translateY(-1px);
|
|
}
|
|
.svc-card.off {
|
|
background: #fcfcfd;
|
|
}
|
|
.svc-card.off .svc-logo {
|
|
filter: grayscale(0.55);
|
|
opacity: 0.75;
|
|
}
|
|
.card-head {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
gap: 10px;
|
|
min-width: 0;
|
|
}
|
|
.head-text {
|
|
flex: 1;
|
|
min-width: 0; /* 关键:不给 0 最小宽度,truncate 不生效,状态标签会被顶出卡片 */
|
|
}
|
|
.name-line {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
min-width: 0;
|
|
}
|
|
.svc-name {
|
|
flex: 1;
|
|
min-width: 0;
|
|
font-size: 13.5px;
|
|
font-weight: 600;
|
|
color: #1e293b;
|
|
line-height: 1.35;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
.svc-id {
|
|
margin-top: 2px;
|
|
font-size: 11px;
|
|
color: #94a3b8;
|
|
font-family: 'SF Mono', Menlo, monospace;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
.svc-desc {
|
|
margin-top: 8px;
|
|
font-size: 12px;
|
|
line-height: 1.5;
|
|
color: #64748b;
|
|
display: -webkit-box;
|
|
-webkit-line-clamp: 2;
|
|
-webkit-box-orient: vertical;
|
|
overflow: hidden;
|
|
overflow-wrap: anywhere; /* 长 URL/无空格串不撑破卡片 */
|
|
min-height: 36px;
|
|
}
|
|
/* 标签上行:不换行,宽度不够时只压缩服务商标签(它自带省略号),
|
|
字段数/密钥数是短固定文本,永远完整显示。 */
|
|
.meta-row {
|
|
display: flex;
|
|
align-items: center;
|
|
flex-wrap: nowrap;
|
|
gap: 4px;
|
|
margin-top: auto; /* 标签区贴卡片底部,卡片高度不齐时也对得上 */
|
|
padding-top: 10px;
|
|
min-width: 0;
|
|
overflow: hidden;
|
|
}
|
|
.meta-row > .field-count,
|
|
.meta-row > .lock-badge {
|
|
flex-shrink: 0;
|
|
}
|
|
/* 凭据一个都没配的服务:卡片上直接标红,别等客户端拿到空字符串才发现 */
|
|
.meta-row > .cred-badge {
|
|
flex-shrink: 0;
|
|
font-size: 10px;
|
|
font-weight: 700;
|
|
padding: 1px 6px;
|
|
border-radius: 5px;
|
|
background: #fef2f2;
|
|
color: #b91c1c;
|
|
border: 1px solid #fecaca;
|
|
}
|
|
/* 标签下行:区域标识独占一行,长文案(如「本区域已分叉」)不会被上行挤掉 */
|
|
.region-row {
|
|
display: flex;
|
|
margin-top: 5px;
|
|
min-width: 0;
|
|
}
|
|
/* 启用/停用:不用 daisy 的 badge,它在窄容器里会被压变形 */
|
|
.state-chip {
|
|
flex-shrink: 0;
|
|
font-size: 10px;
|
|
font-weight: 700;
|
|
line-height: 1.6;
|
|
padding: 1px 7px;
|
|
border-radius: 6px;
|
|
white-space: nowrap;
|
|
}
|
|
.state-chip.on {
|
|
background: #dcfce7;
|
|
color: #15803d;
|
|
}
|
|
.state-chip.off {
|
|
background: #f1f5f9;
|
|
color: #94a3b8;
|
|
}
|
|
.add-card {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
border-style: dashed;
|
|
border-color: #e2e8f0;
|
|
background: #fafbff;
|
|
}
|
|
.add-card:hover {
|
|
border-color: #a5b4fc;
|
|
background: #f5f3ff;
|
|
}
|
|
.field-count {
|
|
display: inline-flex;
|
|
font-size: 10px;
|
|
font-weight: 600;
|
|
padding: 1px 7px;
|
|
border-radius: 6px;
|
|
background: #f1f5f9;
|
|
color: #64748b;
|
|
white-space: nowrap;
|
|
}
|
|
/* 区域分叉标识:有分叉=琥珀色(与区域视图的「覆盖」同色系),无分叉=灰 */
|
|
.region-chip {
|
|
display: inline-flex;
|
|
max-width: 100%;
|
|
min-width: 0;
|
|
font-size: 10px;
|
|
font-weight: 600;
|
|
padding: 1px 7px;
|
|
border-radius: 6px;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
.region-chip.has {
|
|
background: #fef3c7;
|
|
color: #b45309;
|
|
}
|
|
.region-chip.plain {
|
|
background: #f8fafc;
|
|
color: #94a3b8;
|
|
}
|
|
.line-clamp-2 {
|
|
display: -webkit-box;
|
|
-webkit-line-clamp: 2;
|
|
-webkit-box-orient: vertical;
|
|
overflow: hidden;
|
|
}
|
|
.svc-logo {
|
|
width: 36px;
|
|
height: 36px;
|
|
border-radius: 9px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-size: 16px;
|
|
flex-shrink: 0;
|
|
color: #fff;
|
|
font-weight: 700;
|
|
}
|
|
.cat-chip {
|
|
display: inline-flex;
|
|
font-size: 10px;
|
|
font-weight: 700;
|
|
padding: 2px 7px;
|
|
border-radius: 6px;
|
|
}
|
|
.provider-chip {
|
|
display: inline-flex;
|
|
max-width: 100%;
|
|
min-width: 0; /* 关键:flex 子项默认 min-width:auto,不清零就不会收缩,直接顶宽整行 */
|
|
flex-shrink: 1;
|
|
font-size: 10px;
|
|
font-weight: 600;
|
|
padding: 1px 7px;
|
|
border-radius: 6px;
|
|
background: #eef2ff;
|
|
color: #4338ca;
|
|
font-family: 'SF Mono', Menlo, monospace;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
/* 新增向导 */
|
|
.wizard-steps {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
.wstep {
|
|
font-size: 12px;
|
|
font-weight: 600;
|
|
color: #94a3b8;
|
|
padding: 5px 12px;
|
|
border-radius: 8px;
|
|
background: #f1f5f9;
|
|
cursor: pointer;
|
|
white-space: nowrap;
|
|
}
|
|
.wstep.on {
|
|
color: #4338ca;
|
|
background: #eef2ff;
|
|
}
|
|
.wstep.cur {
|
|
color: #fff;
|
|
background: #6366f1;
|
|
}
|
|
.wstep.disabled {
|
|
cursor: not-allowed;
|
|
opacity: 0.6;
|
|
}
|
|
.wline {
|
|
flex: 1;
|
|
height: 1px;
|
|
background: #e2e8f0;
|
|
}
|
|
.type-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(3, 1fr);
|
|
gap: 12px;
|
|
}
|
|
.type-card {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: flex-start;
|
|
padding: 14px;
|
|
border-radius: 12px;
|
|
border: 1.5px solid #e2e8f0;
|
|
cursor: pointer;
|
|
transition: all 0.15s;
|
|
background: #fff;
|
|
}
|
|
.type-card:hover {
|
|
border-color: #c7d2fe;
|
|
background: #f5f3ff;
|
|
}
|
|
.type-card.on {
|
|
border-color: #6366f1;
|
|
background: #eef2ff;
|
|
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.12);
|
|
}
|
|
.lock-badge {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 3px;
|
|
font-size: 10px;
|
|
font-weight: 600;
|
|
padding: 1px 7px;
|
|
border-radius: 6px;
|
|
background: #fef2f2;
|
|
color: #dc2626;
|
|
}
|
|
.inherit-badge {
|
|
font-size: 10px;
|
|
font-weight: 600;
|
|
padding: 1px 7px;
|
|
border-radius: 6px;
|
|
background: #f1f5f9;
|
|
color: #94a3b8;
|
|
}
|
|
.override-badge {
|
|
font-size: 10px;
|
|
font-weight: 600;
|
|
padding: 1px 7px;
|
|
border-radius: 6px;
|
|
background: #fef3c7;
|
|
color: #b45309;
|
|
}
|
|
.disabled-badge {
|
|
font-size: 10px;
|
|
font-weight: 600;
|
|
padding: 1px 7px;
|
|
border-radius: 6px;
|
|
background: #fee2e2;
|
|
color: #b91c1c;
|
|
}
|
|
.field-input {
|
|
border: 1px solid #e2e8f0;
|
|
border-radius: 7px;
|
|
padding: 5px 9px;
|
|
font-size: 12.5px;
|
|
font-family: 'SF Mono', Menlo, monospace;
|
|
width: 100%;
|
|
}
|
|
.field-input:focus {
|
|
border-color: #6366f1;
|
|
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
|
|
outline: none;
|
|
}
|
|
.field-input:disabled {
|
|
background: #f8fafc;
|
|
color: #cbd5e1;
|
|
}
|
|
/* 内置音色表(只读展示) */
|
|
.voice-card {
|
|
border: 1px solid #e9d5ff;
|
|
background: #faf5ff;
|
|
border-radius: 12px;
|
|
padding: 10px 12px;
|
|
margin-top: 4px;
|
|
}
|
|
.voice-lang-list {
|
|
margin-top: 6px;
|
|
max-height: 168px;
|
|
overflow: auto;
|
|
}
|
|
.voice-lang-row {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
gap: 8px;
|
|
padding: 3px 0;
|
|
min-width: 0;
|
|
}
|
|
.voice-lang {
|
|
flex-shrink: 0;
|
|
width: 64px;
|
|
font-size: 11px;
|
|
font-weight: 600;
|
|
color: #7c3aed;
|
|
font-family: 'SF Mono', Menlo, monospace;
|
|
}
|
|
.voice-ids {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 4px;
|
|
min-width: 0;
|
|
}
|
|
.voice-pill {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 3px;
|
|
font-size: 10px;
|
|
padding: 1px 6px;
|
|
border-radius: 5px;
|
|
background: #fff;
|
|
border: 1px solid #ede9fe;
|
|
color: #6b21a8;
|
|
font-family: 'SF Mono', Menlo, monospace;
|
|
}
|
|
.voice-pill em {
|
|
font-style: normal;
|
|
font-size: 9px;
|
|
color: #a16207;
|
|
background: #fef3c7;
|
|
padding: 0 3px;
|
|
border-radius: 3px;
|
|
}
|
|
|
|
/* 弹窗 */
|
|
.modal-overlay {
|
|
position: fixed;
|
|
inset: 0;
|
|
background: rgba(15, 23, 42, 0.45);
|
|
z-index: 100;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
backdrop-filter: blur(2px);
|
|
}
|
|
.svc-box {
|
|
background: #fff;
|
|
border-radius: 20px;
|
|
padding: 26px;
|
|
width: 860px;
|
|
max-width: 96vw;
|
|
max-height: 90vh;
|
|
overflow: auto;
|
|
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.18);
|
|
}
|
|
.sync-box {
|
|
background: #fff;
|
|
border-radius: 20px;
|
|
padding: 28px;
|
|
width: 520px;
|
|
max-width: 95vw;
|
|
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.18);
|
|
}
|
|
.tpl-box {
|
|
background: #fff;
|
|
border-radius: 20px;
|
|
padding: 24px;
|
|
width: 620px;
|
|
max-width: 96vw;
|
|
max-height: 88vh;
|
|
overflow: auto;
|
|
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.18);
|
|
}
|
|
.tpl-list {
|
|
max-height: 60vh;
|
|
overflow: auto;
|
|
}
|
|
.tpl-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
padding: 8px 10px;
|
|
border-radius: 9px;
|
|
border: 1px solid #eef2f7;
|
|
margin-bottom: 6px;
|
|
}
|
|
.tpl-row:hover {
|
|
border-color: #c7d2fe;
|
|
background: #f8fafc;
|
|
}
|
|
.sync-region-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(4, 1fr);
|
|
gap: 8px;
|
|
margin: 14px 0;
|
|
}
|
|
.sync-region-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 7px;
|
|
padding: 8px 10px;
|
|
border-radius: 9px;
|
|
border: 1.5px solid #e2e8f0;
|
|
cursor: pointer;
|
|
transition: all 0.15s;
|
|
font-size: 13px;
|
|
font-weight: 500;
|
|
color: #475569;
|
|
}
|
|
.sync-region-item:hover {
|
|
border-color: #c7d2fe;
|
|
background: #f5f3ff;
|
|
}
|
|
.sync-region-item.checked {
|
|
border-color: #6366f1;
|
|
background: #eef2ff;
|
|
color: #4338ca;
|
|
}
|
|
</style>
|
|
|