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.
501 lines
21 KiB
501 lines
21 KiB
<template>
|
|
<div>
|
|
<!-- 顶部 -->
|
|
<div class="flex items-center justify-between mb-5 flex-wrap gap-3">
|
|
<div>
|
|
<h2 class="text-lg font-semibold text-slate-800">Agent 配置</h2>
|
|
<p class="text-xs text-slate-400 mt-1">
|
|
把已配置的第三方服务编排成可下发客户端的智能体。四种类型按需引用不同服务;通话翻译规则只能引用「翻译」与「端到端翻译」两类。
|
|
</p>
|
|
</div>
|
|
<div class="flex items-center gap-3">
|
|
<span class="text-sm text-slate-500">作用域</span>
|
|
<select v-model="scopeApp" class="select select-bordered select-sm" @change="loadAll">
|
|
<option value="">全局默认</option>
|
|
<option v-for="a in scopeApps" :key="a" :value="a">{{ a }}</option>
|
|
</select>
|
|
<button class="btn btn-primary btn-sm text-white" @click="startAdd">+ 新建 Agent</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 按类型分组 -->
|
|
<div v-if="!agents.length" class="text-center py-20 text-slate-300">
|
|
<div class="text-5xl mb-3">🤖</div>
|
|
<p class="text-sm">当前作用域下还没有 Agent</p>
|
|
<p class="text-xs mt-1">先在「第三方服务配置」里配好服务,再来把它们组装成 Agent</p>
|
|
</div>
|
|
<div v-for="g in grouped" :key="g.type" class="mb-6">
|
|
<div class="flex items-center gap-2 mb-3">
|
|
<span class="type-tag" :style="{ background: g.bg, color: g.color }">{{ g.icon }} {{ g.label }}</span>
|
|
<span class="text-xs text-slate-400">{{ g.hint }}</span>
|
|
<span class="text-xs text-slate-300">·</span>
|
|
<span class="text-xs text-slate-400">{{ g.items.length }} 个</span>
|
|
</div>
|
|
<div class="agent-grid">
|
|
<div v-for="a in g.items" :key="a.id" class="agent-card" @click="startEdit(a)">
|
|
<div class="flex items-start justify-between gap-2">
|
|
<div class="min-w-0">
|
|
<div class="font-semibold text-sm text-slate-800 truncate">{{ a.name || a.id }}</div>
|
|
<div class="text-[11px] text-slate-400 mt-0.5 truncate">{{ a.id }}</div>
|
|
</div>
|
|
<div class="flex items-center gap-1 flex-shrink-0">
|
|
<span v-if="a.enable" class="badge badge-success badge-xs">启用</span>
|
|
<span v-else class="badge badge-ghost badge-xs">停用</span>
|
|
<button class="btn btn-ghost btn-xs text-error" @click.stop="delAgent(a)">删除</button>
|
|
</div>
|
|
</div>
|
|
<p v-if="a.description" class="text-xs text-slate-500 mt-2 line-clamp-2">{{ a.description }}</p>
|
|
<div class="flex flex-wrap items-center gap-1.5 mt-3">
|
|
<span v-for="s in pipelineOf(a)" :key="s.slot" class="svc-pill" :style="{ background: s.color }">
|
|
{{ s.slot }}: {{ svcName(s.id) }}
|
|
</span>
|
|
</div>
|
|
<div v-if="a.support_langs" class="text-[11px] text-slate-400 mt-2 truncate">
|
|
支持语言:{{ a.support_langs }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 编辑弹窗 -->
|
|
<div v-show="modalOpen" class="ag-overlay" @click.self="closeModal">
|
|
<div class="ag-box">
|
|
<div class="flex items-center justify-between mb-4">
|
|
<div class="flex items-center gap-3">
|
|
<h3 class="text-base font-semibold text-slate-800">{{ isEditing ? '编辑 Agent' : '新建 Agent' }}</h3>
|
|
<span class="scope-chip">{{ scopeApp || '全局默认' }}</span>
|
|
</div>
|
|
<button class="btn btn-ghost btn-xs" @click="closeModal">✕ 关闭</button>
|
|
</div>
|
|
|
|
<!-- 类型:新建时可选,编辑时锁定(换类型等于换一套服务槽位) -->
|
|
<section class="ed-section">
|
|
<div class="ed-section-title">类型</div>
|
|
<div class="type-row">
|
|
<button
|
|
v-for="t in AGENT_TYPES"
|
|
:key="t.value"
|
|
type="button"
|
|
class="ttab"
|
|
:class="{ on: form.type === t.value, locked: isEditing && form.type !== t.value }"
|
|
:disabled="isEditing"
|
|
@click="form.type = t.value"
|
|
>
|
|
<span class="ttab-title">{{ t.icon }} {{ t.label }}</span>
|
|
<span class="ttab-desc">{{ t.hint }}</span>
|
|
</button>
|
|
</div>
|
|
<p v-if="isEditing" class="ed-tip">类型创建后不可更改;需要换类型请新建一个 Agent。</p>
|
|
</section>
|
|
|
|
<!-- 基本信息 -->
|
|
<section class="ed-section">
|
|
<div class="ed-section-title">基本信息</div>
|
|
<div class="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<span class="ed-label">Agent ID <i class="req">*</i></span>
|
|
<input
|
|
v-model="form.id"
|
|
:disabled="isEditing"
|
|
class="input input-bordered input-sm w-full"
|
|
placeholder="如 trans-cn-en(作用域内唯一,创建后不可改)"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<span class="ed-label">名称 <i class="req">*</i></span>
|
|
<input v-model="form.name" class="input input-bordered input-sm w-full" placeholder="展示名,如 中英实时翻译" />
|
|
</div>
|
|
</div>
|
|
<div class="mt-3">
|
|
<span class="ed-label">描述</span>
|
|
<input v-model="form.description" class="input input-bordered input-sm w-full" placeholder="这个 Agent 用在什么场景" />
|
|
</div>
|
|
<div class="grid grid-cols-3 gap-3 mt-3">
|
|
<div>
|
|
<span class="ed-label">头像 URL</span>
|
|
<input v-model="form.avatar_url" class="input input-bordered input-sm w-full" placeholder="可留空" />
|
|
</div>
|
|
<div>
|
|
<span class="ed-label">排序(越大越靠前)</span>
|
|
<input v-model.number="form.sort" type="number" class="input input-bordered input-sm w-full" />
|
|
</div>
|
|
<div>
|
|
<span class="ed-label">音色性别偏好</span>
|
|
<select v-model.number="form.gender" class="select select-bordered select-sm w-full">
|
|
<option :value="0">不限</option>
|
|
<option :value="1">男</option>
|
|
<option :value="2">女</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- 服务编排:槽位随类型变化 -->
|
|
<section class="ed-section">
|
|
<div class="ed-section-title">服务编排</div>
|
|
<div class="slot-row">
|
|
<template v-for="(s, i) in slotsOfType(form.type)" :key="s.key">
|
|
<div class="slot-item">
|
|
<span class="stage-tag" :style="{ background: s.color }">{{ s.slot }}</span>
|
|
<span class="ed-label inline">{{ s.label }}</span>
|
|
<select v-model="(form as any)[s.key]" class="select select-bordered select-sm w-full">
|
|
<option value="" disabled>{{ svcsByCat(s.cat).length ? '请选择服务' : '(无可用服务,请先去第三方服务配置里添加)' }}</option>
|
|
<option v-for="v in svcsByCat(s.cat)" :key="v.id" :value="v.id">{{ v.name }}</option>
|
|
</select>
|
|
</div>
|
|
<span v-if="i < slotsOfType(form.type).length - 1" class="slot-arrow">→</span>
|
|
</template>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- 语言与音色 -->
|
|
<section class="ed-section">
|
|
<div class="ed-section-title">语言与音色</div>
|
|
<div class="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<span class="ed-label">支持语言(多选)</span>
|
|
<LangSelect v-model="form.support_langs" multiple />
|
|
</div>
|
|
<div>
|
|
<span class="ed-label">默认语言</span>
|
|
<LangSelect v-model="form.default_lang" :options="supportedLangOptions" />
|
|
</div>
|
|
</div>
|
|
<div class="mt-3">
|
|
<div class="flex items-center justify-between mb-1">
|
|
<span class="ed-label mb-0">音色覆盖(不填则用 TTS 服务自带默认音色)</span>
|
|
<button class="btn btn-ghost btn-xs" @click="addVoice">+ 添加一行</button>
|
|
</div>
|
|
<div v-if="!form.voice_map.length" class="text-xs text-slate-300 py-2">未设置音色覆盖</div>
|
|
<div v-for="(v, i) in form.voice_map" :key="i" class="voice-row">
|
|
<select v-model="v.language" class="select select-bordered select-xs">
|
|
<option value="">(语言)</option>
|
|
<option v-for="o in supportedLangOptions" :key="o.code" :value="o.code">{{ o.code }} · {{ o.name }}</option>
|
|
</select>
|
|
<select v-model.number="v.gender" class="select select-bordered select-xs">
|
|
<option :value="0">不限</option>
|
|
<option :value="1">男</option>
|
|
<option :value="2">女</option>
|
|
</select>
|
|
<input v-model="v.voice" class="input input-bordered input-xs flex-1" placeholder="音色名,如 zh-CN-XiaoxiaoNeural" />
|
|
<button class="btn btn-ghost btn-xs text-error" @click="form.voice_map.splice(i, 1)">删除</button>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- 对话设定:只有对话类 agent 需要 -->
|
|
<section v-if="isChatType" class="ed-section">
|
|
<div class="ed-section-title">对话设定</div>
|
|
<div>
|
|
<span class="ed-label">系统提示词</span>
|
|
<textarea v-model="form.system_prompt" class="textarea textarea-bordered w-full text-xs" rows="3" placeholder="定义这个智能体的人设与行为" />
|
|
</div>
|
|
<div class="mt-3">
|
|
<label class="flex items-center gap-2 text-sm cursor-pointer">
|
|
<input v-model="form.greeting_enabled" type="checkbox" class="checkbox checkbox-sm checkbox-primary" />
|
|
<span>开场白</span>
|
|
</label>
|
|
<input
|
|
v-if="form.greeting_enabled"
|
|
v-model="form.greeting_text"
|
|
class="input input-bordered input-sm w-full mt-2"
|
|
placeholder="接通后先说的一句话"
|
|
/>
|
|
</div>
|
|
</section>
|
|
|
|
<div class="ed-footer">
|
|
<label class="flex items-center gap-2 text-sm cursor-pointer">
|
|
<input v-model="form.enable" type="checkbox" class="checkbox checkbox-sm checkbox-primary" />
|
|
<span>启用</span>
|
|
</label>
|
|
<div class="flex gap-2">
|
|
<button class="btn btn-ghost btn-sm" @click="closeModal">取消</button>
|
|
<button class="btn btn-primary btn-sm text-white" :disabled="submitting" @click="save">保存</button>
|
|
</div>
|
|
</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'
|
|
import { LANG_OPTIONS } from '~/composables/useLangOptions'
|
|
import LangSelect from '~/components/LangSelect.vue'
|
|
|
|
definePageMeta({ title: 'Agent 配置' })
|
|
|
|
// ===== 类型 =====
|
|
interface SvcConfig {
|
|
id: string
|
|
name: string
|
|
enable: boolean
|
|
categories: string
|
|
}
|
|
interface AgentVoice {
|
|
language: string
|
|
gender: number
|
|
voice: string
|
|
}
|
|
interface Agent {
|
|
app_name: string
|
|
id: string
|
|
name: string
|
|
type: string
|
|
description: string
|
|
avatar_url: string
|
|
enable: boolean
|
|
stt_svc_id: string
|
|
llm_svc_id: string
|
|
tts_svc_id: string
|
|
mt_svc_id: string
|
|
sts_svc_id: string
|
|
ast_svc_id: string
|
|
system_prompt: string
|
|
gender: number
|
|
greeting_enabled: boolean
|
|
greeting_text: string
|
|
support_langs: string
|
|
default_lang: string
|
|
voice_map: AgentVoice[]
|
|
sort: number
|
|
}
|
|
|
|
// ===== 常量 =====
|
|
// 服务类别(与后端 svcCatXxx / serviceconfig.vue 的 SCAT 一致)
|
|
const CAT_STT = 1
|
|
const CAT_TTS = 2
|
|
const CAT_MT = 3
|
|
const CAT_AST = 4
|
|
const CAT_LLM = 5
|
|
const CAT_STS = 7
|
|
|
|
// Agent 类型(值与后端 comm.AgentType* 常量严格一致)
|
|
const AGENT_TYPES = [
|
|
{ value: 'chat', label: '对话', icon: '💬', hint: 'STT → LLM → TTS 三段拼接', color: '#b45309', bg: '#fef9c3' },
|
|
{ value: 'translate', label: '翻译', icon: '🧩', hint: 'STT → MT → TTS 三段拼接', color: '#059669', bg: '#dcfce7' },
|
|
{ value: 'sts-chat', label: '端到端对话', icon: '⚡', hint: '单个 STS 服务直接完成语音对话', color: '#be185d', bg: '#fce7f3' },
|
|
{ value: 'ast-translate', label: '端到端翻译', icon: '🚀', hint: '单个 AST 服务直接完成语音翻译', color: '#c2410c', bg: '#fff7ed' },
|
|
]
|
|
|
|
// 每种类型的服务槽位:key 对应 AgentConfig 字段名,cat 用于筛选可选服务
|
|
interface Slot { key: string; slot: string; label: string; cat: number; color: string }
|
|
const SLOTS: Record<string, Slot[]> = {
|
|
chat: [
|
|
{ key: 'stt_svc_id', slot: 'STT', label: '语音识别', cat: CAT_STT, color: '#1664ff' },
|
|
{ key: 'llm_svc_id', slot: 'LLM', label: '大语言模型', cat: CAT_LLM, color: '#b45309' },
|
|
{ key: 'tts_svc_id', slot: 'TTS', label: '语音合成', cat: CAT_TTS, color: '#7c3aed' },
|
|
],
|
|
translate: [
|
|
{ key: 'stt_svc_id', slot: 'STT', label: '语音识别', cat: CAT_STT, color: '#1664ff' },
|
|
{ key: 'mt_svc_id', slot: 'MT', label: '机器翻译', cat: CAT_MT, color: '#059669' },
|
|
{ key: 'tts_svc_id', slot: 'TTS', label: '语音合成', cat: CAT_TTS, color: '#7c3aed' },
|
|
],
|
|
'sts-chat': [{ key: 'sts_svc_id', slot: 'STS', label: '端到端语音对话', cat: CAT_STS, color: '#be185d' }],
|
|
'ast-translate': [{ key: 'ast_svc_id', slot: 'AST', label: '端到端语音翻译', cat: CAT_AST, color: '#f97316' }],
|
|
}
|
|
function slotsOfType(t: string): Slot[] {
|
|
return SLOTS[t] ?? []
|
|
}
|
|
|
|
const { webApi, consoleApi } = useApi()
|
|
const { success, error } = useToast()
|
|
|
|
// ===== 状态 =====
|
|
const scopeApp = ref('')
|
|
const scopeApps = ref<string[]>([])
|
|
const agents = ref<Agent[]>([])
|
|
const allSvcs = ref<SvcConfig[]>([])
|
|
const modalOpen = ref(false)
|
|
const isEditing = ref(false)
|
|
const submitting = ref(false)
|
|
|
|
function emptyForm(): Agent {
|
|
return {
|
|
app_name: '', id: '', name: '', type: 'translate', description: '', avatar_url: '', enable: true,
|
|
stt_svc_id: '', llm_svc_id: '', tts_svc_id: '', mt_svc_id: '', sts_svc_id: '', ast_svc_id: '',
|
|
system_prompt: '', gender: 0, greeting_enabled: false, greeting_text: '',
|
|
support_langs: '', default_lang: '', voice_map: [], sort: 0,
|
|
}
|
|
}
|
|
const form = reactive<Agent>(emptyForm())
|
|
|
|
const isChatType = computed(() => form.type === 'chat' || form.type === 'sts-chat')
|
|
|
|
// 默认语言只在已选的支持语言里挑;没选支持语言时放开全集,避免无从选择
|
|
const supportedLangOptions = computed(() => {
|
|
const codes = (form.support_langs || '').split(',').map((x) => x.trim()).filter(Boolean)
|
|
if (!codes.length) return LANG_OPTIONS
|
|
return LANG_OPTIONS.filter((o) => codes.includes(o.code))
|
|
})
|
|
|
|
const grouped = computed(() =>
|
|
AGENT_TYPES.map((t) => ({
|
|
...t,
|
|
type: t.value,
|
|
items: agents.value
|
|
.filter((a) => a.type === t.value)
|
|
.sort((a, b) => (b.sort || 0) - (a.sort || 0) || (a.name || '').localeCompare(b.name || '')),
|
|
})).filter((g) => g.items.length),
|
|
)
|
|
|
|
// ===== 加载 =====
|
|
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 = []
|
|
}
|
|
}
|
|
async function loadSvcs() {
|
|
try {
|
|
const d = await webApi<{ items: SvcConfig[] }>('getsvcconfigs', { app_name: scopeApp.value })
|
|
allSvcs.value = d?.items ?? []
|
|
} catch {
|
|
allSvcs.value = []
|
|
}
|
|
}
|
|
async function loadAgents() {
|
|
try {
|
|
const d = await webApi<{ items: Agent[] }>('getagents', { app_name: scopeApp.value })
|
|
agents.value = (d?.items ?? []).map((a) => ({ ...a, voice_map: a.voice_map ?? [] }))
|
|
} catch (e: any) {
|
|
error(e?.message ?? '加载失败')
|
|
agents.value = []
|
|
}
|
|
}
|
|
async function loadAll() {
|
|
await Promise.all([loadSvcs(), loadAgents()])
|
|
}
|
|
|
|
// ===== 服务 =====
|
|
function svcsByCat(cat: number): SvcConfig[] {
|
|
return allSvcs.value.filter(
|
|
(s) => s.enable && (s.categories || '').split(',').map(Number).includes(cat),
|
|
)
|
|
}
|
|
function svcName(id?: string): string {
|
|
if (!id) return '未配置'
|
|
const s = allSvcs.value.find((x) => x.id === id)
|
|
return s ? s.name : id
|
|
}
|
|
// 卡片上展示这个 agent 的服务管线
|
|
function pipelineOf(a: Agent) {
|
|
return slotsOfType(a.type).map((s) => ({ slot: s.slot, color: s.color, id: (a as any)[s.key] as string }))
|
|
}
|
|
|
|
// ===== 编辑 =====
|
|
function startAdd() {
|
|
Object.assign(form, emptyForm())
|
|
isEditing.value = false
|
|
modalOpen.value = true
|
|
}
|
|
function startEdit(a: Agent) {
|
|
Object.assign(form, emptyForm(), a, { voice_map: (a.voice_map ?? []).map((v) => ({ ...v })) })
|
|
isEditing.value = true
|
|
modalOpen.value = true
|
|
}
|
|
function closeModal() {
|
|
modalOpen.value = false
|
|
}
|
|
function addVoice() {
|
|
form.voice_map.push({ language: form.default_lang || '', gender: 0, voice: '' })
|
|
}
|
|
|
|
async function save() {
|
|
if (!form.id.trim() || !form.name.trim()) {
|
|
error('Agent ID 和 名称 必填')
|
|
return
|
|
}
|
|
// 服务槽位缺一不可——后端也会校验,这里先挡一道,省一次往返
|
|
for (const s of slotsOfType(form.type)) {
|
|
if (!((form as any)[s.key] || '').trim()) {
|
|
error(`${s.slot} ${s.label} 必选`)
|
|
return
|
|
}
|
|
}
|
|
submitting.value = true
|
|
try {
|
|
const payload: Agent = {
|
|
...JSON.parse(JSON.stringify(form)),
|
|
app_name: scopeApp.value,
|
|
id: form.id.trim(),
|
|
name: form.name.trim(),
|
|
// 只提交当前类型用得到的服务引用,避免换过类型后残留脏字段
|
|
stt_svc_id: '', llm_svc_id: '', tts_svc_id: '', mt_svc_id: '', sts_svc_id: '', ast_svc_id: '',
|
|
}
|
|
for (const s of slotsOfType(form.type)) {
|
|
;(payload as any)[s.key] = (form as any)[s.key]
|
|
}
|
|
payload.voice_map = form.voice_map.filter((v) => v.language && v.voice)
|
|
await webApi(isEditing.value ? 'updateagent' : 'addagent', payload)
|
|
success('保存成功')
|
|
closeModal()
|
|
await loadAgents()
|
|
} catch (e: any) {
|
|
error(e?.message ?? '保存失败')
|
|
} finally {
|
|
submitting.value = false
|
|
}
|
|
}
|
|
|
|
function delAgent(a: Agent) {
|
|
if (!window.confirm(`确定删除 Agent「${a.name || a.id}」吗?引用它的通话翻译规则会失效。`)) return
|
|
webApi('delagent', { app_name: scopeApp.value, id: a.id })
|
|
.then(() => { success('已删除'); loadAgents() })
|
|
.catch((e: any) => error(e?.message ?? '删除失败'))
|
|
}
|
|
|
|
onMounted(async () => {
|
|
await loadScopeApps()
|
|
await loadAll()
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
.type-tag { font-size: 12px; font-weight: 700; padding: 3px 10px; border-radius: 8px; }
|
|
.agent-grid { display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); }
|
|
.agent-card { background: #fff; border: 1px solid #eef2f7; border-radius: 14px; padding: 14px 16px; cursor: pointer; transition: all .15s; }
|
|
.agent-card:hover { border-color: #c7d2fe; box-shadow: 0 4px 14px rgba(99, 102, 241, .08); }
|
|
.svc-pill { display: inline-flex; align-items: center; font-size: 10px; font-weight: 600; padding: 2px 8px; border-radius: 7px; color: #fff; }
|
|
|
|
/* 弹窗 */
|
|
.ag-overlay { position: fixed; inset: 0; background: rgba(15, 23, 42, .45); z-index: 100; display: flex; align-items: center; justify-content: center; backdrop-filter: blur(2px); padding: 24px; }
|
|
.ag-box { background: #fff; border-radius: 18px; padding: 24px; width: 780px; max-width: 96vw; max-height: 90vh; overflow-y: auto; box-shadow: 0 24px 80px rgba(0, 0, 0, .18); }
|
|
.scope-chip { font-size: 11px; font-weight: 600; padding: 2px 9px; border-radius: 6px; background: #eef2ff; color: #4338ca; }
|
|
|
|
.ed-section { padding: 14px 0; border-top: 1px dashed #eef2f7; }
|
|
.ed-section:first-of-type { padding-top: 0; border-top: none; }
|
|
.ed-section-title { font-size: 11px; font-weight: 700; letter-spacing: .04em; color: #94a3b8; margin-bottom: 10px; }
|
|
.ed-label { display: block; font-size: 11px; color: #64748b; margin-bottom: 4px; }
|
|
.ed-label.inline { display: inline-block; margin-left: 6px; }
|
|
.ed-tip { font-size: 11px; color: #94a3b8; margin-top: 8px; }
|
|
.req { color: #dc2626; font-style: normal; }
|
|
|
|
.type-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; }
|
|
.ttab { display: flex; flex-direction: column; gap: 3px; padding: 11px 12px; border-radius: 10px; border: 1.5px solid #e2e8f0; background: #fff; cursor: pointer; transition: all .15s; text-align: left; }
|
|
.ttab:hover:not(:disabled) { border-color: #c7d2fe; background: #f8fafc; }
|
|
.ttab.on { border-color: #6366f1; background: linear-gradient(135deg, #eef2ff, #f5f3ff); }
|
|
.ttab.locked { opacity: .45; cursor: not-allowed; }
|
|
.ttab-title { font-weight: 700; font-size: 12px; color: #1e293b; }
|
|
.ttab-desc { font-size: 10px; color: #94a3b8; }
|
|
|
|
.slot-row { display: flex; align-items: flex-end; gap: 10px; }
|
|
.slot-item { flex: 1; min-width: 0; }
|
|
.slot-arrow { color: #cbd5e1; padding-bottom: 8px; flex-shrink: 0; }
|
|
.stage-tag { display: inline-block; font-size: 10px; font-weight: 700; padding: 1px 7px; border-radius: 5px; color: #fff; vertical-align: middle; }
|
|
|
|
.voice-row { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
|
|
|
|
.ed-footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding-top: 14px; border-top: 1px solid #eef2f7; }
|
|
|
|
@media (max-width: 860px) {
|
|
.type-row { grid-template-columns: repeat(2, 1fr); }
|
|
.slot-row { flex-direction: column; align-items: stretch; }
|
|
.slot-arrow { display: none; }
|
|
}
|
|
</style>
|
|
|