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.
 
 
 
 
 
 

1902 lines
79 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>
<span v-if="isServerOnlyCat(sc.v)" class="text-[11px] text-rose-600 mt-1">仅服务端调用</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 v-if="isServerOnlyCat(editType)" class="alert alert-warning py-2 text-xs mb-3">
该类服务的凭据仅在服务端使用,<b>不会下发给客户端</b>。请填写云账号的 AccessKey / SecretKey。
</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' },
{ v: 11, label: '身份证校验', short: '实名', color: '#9f1239', bg: '#ffe4e6' },
]
// MCP 服务类别(与后端 svcCatMCP 一致):字段固定为 url/type/tools,type 用 HTTP/SSE 下拉。
const SCAT_MCP = 10
// 服务端专用类别(与后端 comm.serverOnlySvcCats 一致)。
// 这类服务的凭据是云账号主 AK/SK,只由服务端调用;后端 resolveThirdSvcs 会整条拦掉,
// 不随 user_getthirdsvcs / user_getappconfig 下发客户端。这里只负责在界面上说清楚,
// 真正的拦截在后端 —— 改这里不会改变下发行为。
const SERVER_ONLY_SCATS = [11]
const isServerOnlyCat = (v: number) => SERVER_ONLY_SCATS.includes(v)
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>