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.
1324 lines
54 KiB
1324 lines
54 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="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="flex items-center gap-2 mb-3">
|
|
<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 class="svc-grid">
|
|
<div v-for="s in g.items" :key="s.id" class="svc-card" @click="selectSvc(s)">
|
|
<div class="flex items-start gap-3">
|
|
<div class="svc-logo" :style="{ background: s.color || '#6366f1' }">{{ s.icon || (s.name || '').charAt(0) }}</div>
|
|
<div style="min-width:0;flex:1">
|
|
<div class="flex items-center gap-2">
|
|
<span class="font-semibold text-slate-800 text-sm truncate">{{ s.name }}</span>
|
|
<span class="badge badge-xs" :class="s.enable ? 'badge-success' : 'badge-ghost'">{{ s.enable ? '启用' : '停用' }}</span>
|
|
</div>
|
|
<p class="text-[11px] text-slate-400 font-mono truncate mt-0.5" :title="s.id">{{ s.id }}</p>
|
|
</div>
|
|
</div>
|
|
<p class="text-xs text-slate-500 mt-2 line-clamp-2" style="min-height:32px">{{ s.description || '—' }}</p>
|
|
<div class="flex flex-wrap items-center gap-1 mt-2">
|
|
<span v-if="s.provider" class="provider-chip">{{ s.provider }}</span>
|
|
<span class="field-count">{{ (s.fields || []).length }} 字段</span>
|
|
<span v-if="encCount(s)" class="lock-badge">🔒{{ encCount(s) }}</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" @click.self="closeSvcModal">
|
|
<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 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>
|
|
<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>
|
|
</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 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>
|
|
<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>
|
|
</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>
|
|
<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>
|
|
|
|
<!-- 区域专属字段 -->
|
|
<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 } 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' },
|
|
]
|
|
|
|
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 } = 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: '' }
|
|
}
|
|
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
|
|
}
|
|
|
|
// ===== 状态 =====
|
|
const backendReady = ref(true)
|
|
const allSvcs = ref<ThirdSvcConfig[]>([])
|
|
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
|
|
})
|
|
|
|
// 模板按类别分组(供下拉与模板管理列表)
|
|
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[] }>('getsvcconfigs', { app_name: scopeApp.value })
|
|
allSvcs.value = data?.items ?? []
|
|
backendReady.value = true
|
|
} catch {
|
|
backendReady.value = false
|
|
allSvcs.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
|
|
}
|
|
|
|
// ===== 默认字段表格 =====
|
|
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 }
|
|
}
|
|
|
|
// ===== 保存 =====
|
|
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 }
|
|
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 })
|
|
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
|
|
}
|
|
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()
|
|
}
|
|
|
|
onMounted(() => {
|
|
loadScopeApps()
|
|
loadSvcs()
|
|
loadTemplates()
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
/* 服务卡片网格 */
|
|
.svc-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fill, minmax(248px, 1fr));
|
|
gap: 12px;
|
|
}
|
|
.svc-card {
|
|
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);
|
|
}
|
|
.add-card {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
border-style: dashed;
|
|
border-color: #e2e8f0;
|
|
background: #fafbff;
|
|
min-height: 120px;
|
|
}
|
|
.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;
|
|
}
|
|
.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;
|
|
font-size: 10px;
|
|
font-weight: 600;
|
|
padding: 1px 7px;
|
|
border-radius: 6px;
|
|
background: #eef2ff;
|
|
color: #4338ca;
|
|
font-family: 'SF Mono', Menlo, monospace;
|
|
}
|
|
/* 新增向导 */
|
|
.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;
|
|
}
|
|
/* 弹窗 */
|
|
.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>
|
|
|