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.
 
 
 
 
 
 

744 lines
29 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">配置火山引擎、阿里云等第三方服务;字段支持加密存储。默认配置全区域共用,可按区域覆盖字段值。</p>
</div>
<div class="flex items-center gap-3">
<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 v-show="curRegion === 0" class="btn btn-primary btn-sm text-white" @click="startAdd">+ 新增服务</button>
</div>
</div>
<!-- 主区域:左侧列表 + 右侧详情 -->
<div class="flex gap-4" style="align-items:flex-start">
<!-- 左侧服务列表 -->
<div style="width:248px;flex-shrink:0">
<div class="flex flex-col gap-2">
<div
v-for="s in allSvcs"
:key="s.id"
class="svc-item"
:class="{ active: curSvc && curSvc.id === s.id }"
@click="selectSvc(s)"
>
<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>
<div class="flex flex-wrap gap-1 mt-1">
<span v-for="v in parseCats(s.categories)" :key="v" class="cat-chip" :style="{ color: scatInfo(v).color, background: scatInfo(v).bg }">{{ scatInfo(v).short }}</span>
<span v-if="encCount(s)" class="lock-badge">🔒{{ encCount(s) }}</span>
</div>
</div>
</div>
<p v-if="!allSvcs.length" class="text-xs text-slate-400 text-center py-8">暂无服务</p>
</div>
</div>
<!-- 右侧详情/编辑 -->
<div class="flex-1 min-w-0">
<div class="bg-white rounded-2xl border border-slate-100 shadow-sm p-6">
<!-- 空状态 -->
<div v-if="detailMode === 'empty'" class="text-center text-slate-300 py-20">
<div class="text-5xl mb-3">⚙️</div>
<p class="text-sm">从左侧选择一个服务开始配置</p>
</div>
<!-- 默认模式:服务全量编辑 -->
<template v-else-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 }}</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>
</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">服务类别 <span class="text-slate-400 text-xs">(可多选)</span></p>
<div class="flex flex-wrap gap-3">
<label v-for="sc in SCAT" :key="sc.v" class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" class="checkbox checkbox-sm checkbox-primary" :value="sc.v" v-model="editCats" />
<span class="cat-chip" :style="{ color: sc.color, background: sc.bg }">{{ sc.short }}</span>
<span class="text-xs text-slate-600">{{ sc.label }}</span>
</label>
</div>
</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-32">字段名</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" /></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.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-5">
<h3 class="text-base font-semibold text-slate-800">新增依赖服务</h3>
<div class="flex gap-2">
<button class="btn btn-ghost btn-sm" @click="cancelAdd">取消</button>
<button class="btn btn-primary btn-sm text-white" @click="openSyncModal(true)">创建</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">服务ID * <span class="text-slate-400 text-xs">(唯一,如 volcengine)</span></span></label>
<input v-model="editForm.id" class="input input-bordered font-mono" placeholder="volcengine" />
</div>
<div class="form-control">
<label class="label"><span class="label-text">服务名称 *</span></label>
<input v-model="editForm.name" class="input input-bordered" placeholder="火山引擎" />
</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="mb-4">
<p class="text-sm font-medium text-slate-700 mb-2">服务类别</p>
<div class="flex flex-wrap gap-3">
<label v-for="sc in SCAT" :key="sc.v" class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" class="checkbox checkbox-sm checkbox-primary" :value="sc.v" v-model="editCats" />
<span class="cat-chip" :style="{ color: sc.color, background: sc.bg }">{{ sc.short }}</span>
<span class="text-xs text-slate-600">{{ sc.label }}</span>
</label>
</div>
</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-32">字段名</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" /></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.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 === '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>
<button class="btn btn-primary btn-sm text-white" @click="openSyncModal(false)">保存覆盖</button>
</div>
<div class="rounded-lg px-3 py-2 mb-4 text-xs" style="background:#fefce8;border:1px solid #fde047;color:#854d0e">
✦ 当前编辑「{{ curRegionLabel }}」的字段覆盖值。留空的字段将继承默认值。加密字段留空保留已存储密文。
</div>
<div class="overflow-x-auto">
<table class="table text-xs">
<thead>
<tr>
<th class="w-32">字段名</th><th>字段描述</th><th class="w-24 text-center">状态</th><th>有效值</th>
</tr>
</thead>
<tbody>
<tr v-for="(f, i) in overrideFields" :key="i">
<td class="font-mono text-slate-700">{{ f.key }}</td>
<td class="text-slate-500">{{ f.description }}</td>
<td class="text-center">
<span v-if="f.is_overridden" class="override-badge">覆盖</span>
<span v-else class="inherit-badge">继承</span>
</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" :placeholder="'留空继承:' + (f.value || '')" />
</td>
</tr>
</tbody>
</table>
</div>
</template>
</template>
</div>
</div>
</div>
<!-- 同步区域弹窗 -->
<div v-show="syncVisible" class="sync-overlay">
<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>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { useApi } from '~/composables/useApi'
import { useToast } from '~/composables/useToast'
definePageMeta({ title: '依赖服务配置' })
// 区域枚举(与 common.js REGIONS 一一对应)
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: '印度' },
]
// 服务类别常量
const SCAT = [
{ v: 1, label: 'STT 语音识别', short: 'STT', color: '#0369a1', bg: '#dbeafe' },
{ 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: 6, label: '存储 OSS/COS', short: '存储', color: '#475569', bg: '#f1f5f9' },
]
interface SvcField {
key: string
description: string
encrypted: boolean
def_value: string
sort?: number
_show?: boolean
}
interface ThirdSvcConfig {
id: string
name: string
description: string
categories: string
enable: boolean
color?: string
icon?: string
fields: SvcField[]
}
interface OverrideField {
key: string
description: string
encrypted: boolean
is_overridden: boolean
value: string
_input?: string
_show?: boolean
}
const { webApi } = useApi()
const { success, error } = useToast()
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 detailMode = ref<'empty' | 'default' | 'new' | 'region'>('empty')
// 编辑中数据
const editForm = reactive({ id: '', name: '', description: '', enable: false })
const editCats = ref<number[]>([])
const editFields = ref<SvcField[]>([])
// 区域覆盖
const overrideFields = ref<OverrideField[]>([])
const regionLoading = ref(false)
const curRegionLabel = computed(() => {
const r = REGIONS.find((x) => x.value === curRegion.value)
return r ? r.label : '区域' + curRegion.value
})
// ===== 加载服务列表 =====
async function loadSvcs() {
try {
const data = await webApi<{ items: ThirdSvcConfig[] }>('getsvcconfigs', {})
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) selectSvc(found)
}
}
// ===== 区域变更 =====
function onRegionChange() {
if (curSvc.value) selectSvc(curSvc.value)
else if (detailMode.value !== 'new') detailMode.value = 'empty'
}
// ===== 选中服务 =====
function selectSvc(svc: ThirdSvcConfig) {
if (!svc) return
curSvc.value = svc
if (curRegion.value === 0) {
renderDefaultDetail()
} else {
renderRegionDetail()
}
}
// ===== 默认模式:服务全量编辑 =====
function renderDefaultDetail() {
const s = curSvc.value!
editForm.id = s.id
editForm.name = s.name || ''
editForm.description = s.description || ''
editForm.enable = !!s.enable
editCats.value = parseCats(s.categories)
editFields.value = JSON.parse(JSON.stringify(s.fields || [])).map((f: SvcField) => ({ ...f, _show: false }))
detailMode.value = 'default'
}
// ===== 新增服务 =====
function startAdd() {
curSvc.value = null
editForm.id = ''
editForm.name = ''
editForm.description = ''
editForm.enable = false
editCats.value = []
editFields.value = []
detailMode.value = 'new'
}
function cancelAdd() {
if (curSvc.value) selectSvc(curSvc.value)
else detailMode.value = 'empty'
}
// ===== 字段表格 =====
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: OverrideField[] }>('getsvcregionoverride', { svc_id: s.id, region: curRegion.value })
const fields = data?.fields ?? []
overrideFields.value = fields.map((f) => ({
...f,
// 加密字段输入框始终留空;非加密字段:已覆盖回填覆盖值,否则留空
_input: f.encrypted ? '' : (f.is_overridden ? (f.value || '') : ''),
_show: false,
}))
} catch (e: any) {
error(e?.message ?? '加载区域覆盖失败')
overrideFields.value = []
} finally {
regionLoading.value = false
}
}
// ===== 收集表单数据 =====
function collectDefaultData(isNew: boolean) {
const data: Record<string, unknown> = {
name: editForm.name || '',
description: editForm.description || '',
categories: editCats.value.slice().sort((a, b) => a - b).join(','),
enable: editForm.enable || false,
fields: editFields.value.map((f) => ({
key: f.key,
description: f.description,
encrypted: f.encrypted,
def_value: f.def_value,
sort: f.sort,
})),
}
if (isNew) data.id = editForm.id || ''
else data.id = curSvc.value!.id
return data
}
function collectOverrideData(): Record<string, string> {
const overrides: Record<string, string> = {}
overrideFields.value.forEach((f) => {
overrides[f.key] = f._input || ''
})
return overrides
}
// ===== 保存 =====
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 ovr = collectOverrideData()
try {
await webApi('savesvcregionoverride', { svc_id: curSvc.value!.id, region: curRegion.value, overrides: ovr })
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', { id: curSvc.value.id })
} catch (e: any) {
error(e?.message ?? '删除失败')
return
}
curSvc.value = null
success('已删除')
await loadSvcs()
detailMode.value = 'empty'
}
// ===== 同步弹窗 =====
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()
await performSave(isNewSave.value)
}
async function doSaveAndSync() {
const targets = syncTargets.value.slice()
closeSyncModal()
const ok = await performSave(isNewSave.value)
if (!ok || !targets.length) return
const svcId = curSvc.value ? curSvc.value.id : null
if (!svcId) return
let synced = 0
try {
const res = await webApi<{ synced: number }>('syncsvctoregins', { 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 + ' 个)')
}
onMounted(loadSvcs)
</script>
<style scoped>
.svc-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid #eef2f7;
background: #fff;
cursor: pointer;
transition: all 0.15s;
}
.svc-item:hover {
border-color: #c7d2fe;
background: #f5f3ff;
}
.svc-item.active {
border-color: #6366f1;
background: linear-gradient(135deg, #eef2ff, #f5f3ff);
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.12);
}
.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;
}
.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;
}
.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;
}
/* 同步弹窗 */
.sync-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);
}
.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);
}
.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>