Files
dreamweaver/admin-frontend/src/views/AdminProviders.vue

873 lines
37 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<main class="max-w-7xl mx-auto px-4 py-8">
<BaseCard v-if="!isLoggedIn" class="max-w-md mx-auto mt-20" padding="lg">
<h1 class="text-3xl font-bold gradient-text mb-4 text-center">DreamWeaver 控制台</h1>
<p class="text-sm text-gray-500 mb-8 text-center">请登录以管理 AI 计算引擎与策略</p>
<form @submit.prevent="login" class="space-y-6">
<BaseInput v-model="loginForm.username" label="管理员账号" required placeholder="admin" />
<BaseInput v-model="loginForm.password" label="密钥密码" type="password" required />
<div class="flex items-center justify-between mt-6">
<span v-if="loginError" class="text-sm text-red-500 font-medium">{{ loginError }}</span>
<BaseButton type="submit" class="w-full">进入控制台</BaseButton>
</div>
</form>
</BaseCard>
<div v-else class="space-y-8">
<!-- Header -->
<header class="flex flex-col md:flex-row md:items-center justify-between gap-4 bg-white p-6 rounded-2xl shadow-sm border border-gray-100">
<div>
<h1 class="text-3xl font-bold gradient-text">引擎调度中心</h1>
<p class="text-sm text-gray-500 mt-1">Provider Orchestration & Strategy</p>
</div>
<div class="flex items-center gap-3">
<div class="bg-blue-50 text-blue-700 px-3 py-1 rounded-full text-xs font-medium flex items-center gap-1">
<span class="w-2 h-2 rounded-full bg-blue-500 animate-pulse"></span>
系统运行中
</div>
<BaseButton variant="ghost" @click="logout" size="sm">退出</BaseButton>
</div>
</header>
<BaseCard padding="lg">
<div class="flex flex-col gap-5 xl:flex-row xl:items-start xl:justify-between">
<div class="max-w-2xl">
<div class="flex flex-wrap items-center gap-3">
<h2 class="text-xl font-bold text-gray-900">当前环境 Provider 运营摘要</h2>
<span class="rounded-full bg-emerald-50 px-3 py-1 text-xs font-medium text-emerald-700">
跨用户 / 当前环境
</span>
</div>
<p class="mt-2 text-sm leading-6 text-gray-500">
这里展示的是当前部署环境内所有生成任务留下的 Provider 调用轨迹便于运营和排障
跨环境对比仍需要后续独立汇聚层
</p>
<div class="mt-4 flex flex-wrap gap-2">
<button
type="button"
class="rounded-lg border px-3 py-1.5 text-sm transition-colors"
:class="analyticsWindow === '7' ? 'border-gray-900 bg-gray-900 text-white' : 'border-gray-200 bg-white text-gray-600 hover:border-gray-400'"
@click="analyticsWindow = '7'"
>
最近 7
</button>
<button
type="button"
class="rounded-lg border px-3 py-1.5 text-sm transition-colors"
:class="analyticsWindow === '30' ? 'border-gray-900 bg-gray-900 text-white' : 'border-gray-200 bg-white text-gray-600 hover:border-gray-400'"
@click="analyticsWindow = '30'"
>
最近 30
</button>
<button
type="button"
class="rounded-lg border px-3 py-1.5 text-sm transition-colors"
:class="analyticsWindow === 'all' ? 'border-gray-900 bg-gray-900 text-white' : 'border-gray-200 bg-white text-gray-600 hover:border-gray-400'"
@click="analyticsWindow = 'all'"
>
全部
</button>
</div>
<div class="mt-3 flex flex-wrap gap-2">
<button
type="button"
class="rounded-lg border px-3 py-1.5 text-sm transition-colors"
:class="analyticsCapability === 'all' ? 'border-indigo-600 bg-indigo-600 text-white' : 'border-gray-200 bg-white text-gray-600 hover:border-gray-400'"
@click="analyticsCapability = 'all'"
>
全部能力
</button>
<button
type="button"
class="rounded-lg border px-3 py-1.5 text-sm transition-colors"
:class="analyticsCapability === 'text' ? 'border-indigo-600 bg-indigo-600 text-white' : 'border-gray-200 bg-white text-gray-600 hover:border-gray-400'"
@click="analyticsCapability = 'text'"
>
文本
</button>
<button
type="button"
class="rounded-lg border px-3 py-1.5 text-sm transition-colors"
:class="analyticsCapability === 'image' ? 'border-indigo-600 bg-indigo-600 text-white' : 'border-gray-200 bg-white text-gray-600 hover:border-gray-400'"
@click="analyticsCapability = 'image'"
>
图片
</button>
<button
type="button"
class="rounded-lg border px-3 py-1.5 text-sm transition-colors"
:class="analyticsCapability === 'tts' ? 'border-indigo-600 bg-indigo-600 text-white' : 'border-gray-200 bg-white text-gray-600 hover:border-gray-400'"
@click="analyticsCapability = 'tts'"
>
语音
</button>
<button
type="button"
class="rounded-lg border px-3 py-1.5 text-sm transition-colors"
:class="analyticsCapability === 'storybook' ? 'border-indigo-600 bg-indigo-600 text-white' : 'border-gray-200 bg-white text-gray-600 hover:border-gray-400'"
@click="analyticsCapability = 'storybook'"
>
绘本
</button>
</div>
</div>
<div class="grid grid-cols-2 gap-3 sm:grid-cols-4 xl:min-w-[420px]">
<div class="rounded-xl border border-gray-100 bg-gray-50 px-4 py-3">
<div class="text-xs text-gray-500">活跃用户</div>
<div class="mt-1 text-2xl font-semibold text-gray-900">
{{ analytics?.user_count ?? 0 }}
</div>
</div>
<div class="rounded-xl border border-gray-100 bg-gray-50 px-4 py-3">
<div class="text-xs text-gray-500">总调用</div>
<div class="mt-1 text-2xl font-semibold text-gray-900">
{{ analytics?.total_calls ?? 0 }}
</div>
</div>
<div class="rounded-xl border border-gray-100 bg-gray-50 px-4 py-3">
<div class="text-xs text-gray-500">成功率</div>
<div class="mt-1 text-2xl font-semibold text-gray-900">
{{ providerSuccessRate ?? '--' }}<span v-if="providerSuccessRate !== null">%</span>
</div>
</div>
<div class="rounded-xl border border-gray-100 bg-gray-50 px-4 py-3">
<div class="text-xs text-gray-500">预估成本</div>
<div class="mt-1 text-2xl font-semibold text-gray-900">
{{ formatCost(analytics?.estimated_cost_usd) }}
</div>
</div>
</div>
</div>
<div v-if="analyticsLoading" class="mt-5 rounded-xl border border-dashed border-gray-200 px-4 py-6 text-sm text-gray-500">
正在更新运营摘要...
</div>
<div v-else-if="analyticsError" class="mt-5 rounded-xl border border-rose-100 bg-rose-50 px-4 py-4 text-sm text-rose-600">
{{ analyticsError }}
</div>
<template v-else-if="analytics">
<div class="mt-6 grid grid-cols-2 gap-3 lg:grid-cols-4">
<div class="rounded-xl border border-gray-100 bg-white px-4 py-3">
<div class="text-xs text-gray-500">覆盖故事</div>
<div class="mt-1 text-lg font-semibold text-gray-900">{{ analytics.story_count }}</div>
</div>
<div class="rounded-xl border border-gray-100 bg-white px-4 py-3">
<div class="text-xs text-gray-500">覆盖任务</div>
<div class="mt-1 text-lg font-semibold text-gray-900">{{ analytics.job_count }}</div>
</div>
<div class="rounded-xl border border-gray-100 bg-white px-4 py-3">
<div class="text-xs text-gray-500">平均耗时</div>
<div class="mt-1 text-lg font-semibold text-gray-900">{{ formatLatency(analytics.avg_latency_ms) }}</div>
</div>
<div class="rounded-xl border border-gray-100 bg-white px-4 py-3">
<div class="text-xs text-gray-500">配置中 Provider</div>
<div class="mt-1 text-lg font-semibold text-gray-900">{{ enabledProviderCount }}/{{ providers.length }}</div>
</div>
</div>
<div class="mt-6 grid grid-cols-1 gap-6 xl:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]">
<div class="rounded-2xl border border-gray-100 bg-white">
<div class="flex items-center justify-between border-b border-gray-100 px-5 py-4">
<div>
<h3 class="font-semibold text-gray-900">Provider 调用明细</h3>
<p class="mt-1 text-xs text-gray-500">按能力和 adapter 聚合的当前环境视图</p>
</div>
<span class="text-xs text-gray-400">{{ analyticsProviderRows.length }} 个组合</span>
</div>
<div class="divide-y divide-gray-100">
<div
v-for="row in analyticsProviderRows"
:key="`${row.capability}:${row.adapter}`"
class="grid grid-cols-1 gap-3 px-5 py-4 md:grid-cols-[minmax(0,1.2fr)_repeat(4,minmax(0,0.8fr))]"
>
<div>
<div class="flex items-center gap-2">
<span class="rounded-full bg-gray-100 px-2 py-0.5 text-[11px] font-medium text-gray-600">
{{ formatCapability(row.capability) }}
</span>
<span class="font-medium text-gray-900">{{ row.adapter }}</span>
</div>
</div>
<div>
<div class="text-[11px] text-gray-400">调用</div>
<div class="mt-1 text-sm font-medium text-gray-900">{{ row.call_count }}</div>
</div>
<div>
<div class="text-[11px] text-gray-400">成功率</div>
<div class="mt-1 text-sm font-medium text-gray-900">
{{ getSuccessRate(row.success_count, row.call_count) ?? '--' }}<span v-if="getSuccessRate(row.success_count, row.call_count) !== null">%</span>
</div>
</div>
<div>
<div class="text-[11px] text-gray-400">耗时</div>
<div class="mt-1 text-sm font-medium text-gray-900">{{ formatLatency(row.avg_latency_ms) }}</div>
</div>
<div>
<div class="text-[11px] text-gray-400">成本</div>
<div class="mt-1 text-sm font-medium text-gray-900">{{ formatCost(row.estimated_cost_usd) }}</div>
</div>
</div>
<div v-if="analyticsProviderRows.length === 0" class="px-5 py-8 text-sm text-gray-500">
当前筛选条件下还没有 Provider 调用样本
</div>
</div>
</div>
<div class="space-y-6">
<div class="rounded-2xl border border-gray-100 bg-white">
<div class="border-b border-gray-100 px-5 py-4">
<h3 class="font-semibold text-gray-900">跨用户分布</h3>
<p class="mt-1 text-xs text-gray-500">按调用量排序帮助快速识别主要使用者</p>
</div>
<div class="divide-y divide-gray-100">
<div
v-for="row in analyticsUserRows.slice(0, 5)"
:key="row.user_id"
class="px-5 py-4"
>
<div class="flex items-start justify-between gap-3">
<div>
<div class="text-sm font-medium text-gray-900">{{ row.user_id }}</div>
<div class="mt-1 text-xs text-gray-500">
{{ row.story_count }} 个故事 · {{ row.job_count }} 个任务
</div>
</div>
<div class="text-right">
<div class="text-sm font-semibold text-gray-900">{{ row.call_count }} </div>
<div class="mt-1 text-xs text-gray-500">{{ formatCost(row.estimated_cost_usd) }}</div>
</div>
</div>
</div>
<div v-if="analyticsUserRows.length === 0" class="px-5 py-8 text-sm text-gray-500">
当前还没有跨用户样本
</div>
</div>
</div>
<div class="rounded-2xl border border-gray-100 bg-white">
<div class="border-b border-gray-100 px-5 py-4">
<h3 class="font-semibold text-gray-900">最近失败原因</h3>
<p class="mt-1 text-xs text-gray-500">先看最常见原因再决定是否调整路由或优先级</p>
</div>
<div class="divide-y divide-gray-100">
<div
v-for="reason in analytics.failure_reasons.slice(0, 5)"
:key="reason.reason"
class="flex items-center justify-between gap-3 px-5 py-4"
>
<span class="text-sm text-gray-700">{{ reason.reason }}</span>
<span class="rounded-full bg-rose-50 px-2.5 py-1 text-xs font-medium text-rose-600">
{{ reason.count }}
</span>
</div>
<div v-if="analytics.failure_reasons.length === 0" class="px-5 py-8 text-sm text-gray-500">
当前窗口内还没有失败调用
</div>
</div>
</div>
</div>
</div>
</template>
</BaseCard>
<!-- Main Content Grid -->
<div class="grid grid-cols-1 lg:grid-cols-4 gap-8">
<!-- Left: Status & Defaults -->
<div class="lg:col-span-1 space-y-6">
<BaseCard padding="md" title="出厂默认策略 (.env)">
<div class="space-y-4">
<div v-for="(providers, type) in defaults" :key="type" class="p-3 bg-gray-50 rounded-lg border border-gray-100">
<div class="flex items-center gap-2 mb-2">
<span class="text-xs font-bold uppercase tracking-wider text-gray-400">{{ type }}</span>
</div>
<div class="flex flex-wrap gap-2">
<button
v-for="p in providers"
:key="p"
@click="cloneDefault(type, p)"
class="px-2 py-1 text-xs bg-white border border-gray-200 rounded text-gray-600 font-mono hover:border-indigo-300 hover:text-indigo-600 hover:shadow-sm transition-all cursor-pointer"
title="点击基于此默认配置创建"
>
{{ p }}
</button>
</div>
</div>
<p class="text-xs text-gray-400 mt-2 px-1">
* 当数据库中未配置或全部禁用时系统将回退到上述出厂设置
</p>
</div>
</BaseCard>
<BaseCard padding="md" title="可用驱动 (Adapters)">
<div class="flex flex-wrap gap-2">
<span v-for="adapter in availableAdapters" :key="adapter"
class="px-2 py-1 text-xs bg-indigo-50 text-indigo-700 rounded-full border border-indigo-100">
{{ adapter.split(':')[1] }} <span class="opacity-50 text-[10px]">({{ adapter.split(':')[0] }})</span>
</span>
</div>
</BaseCard>
</div>
<!-- Right: Active Manager -->
<div class="lg:col-span-3 space-y-6">
<!-- Tabs -->
<div class="flex space-x-1 bg-gray-100 p-1 rounded-xl w-fit">
<button
v-for="tab in ['text', 'image', 'tts', 'storybook']"
:key="tab"
@click="activeTab = tab"
class="px-6 py-2 rounded-lg text-sm font-medium transition-all duration-200"
:class="activeTab === tab ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500 hover:text-gray-700'"
>
{{ tab.toUpperCase() }}
</button>
</div>
<!-- Provider Cards -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- Add New Card -->
<button @click="openCreateModal" class="flex flex-col items-center justify-center p-8 border-2 border-dashed border-gray-200 rounded-2xl hover:border-indigo-300 hover:bg-indigo-50 transition-all group min-h-[200px]">
<div class="w-12 h-12 rounded-full bg-white shadow-sm flex items-center justify-center mb-3 group-hover:scale-110 transition-transform">
<svg class="w-6 h-6 text-indigo-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path></svg>
</div>
<span class="text-sm font-medium text-gray-600 group-hover:text-indigo-600">添加新的 {{ activeTab }} 引擎</span>
</button>
<!-- Existing Cards -->
<div v-for="p in filteredProviders" :key="p.id"
class="relative p-6 bg-white rounded-2xl border transition-all duration-200 group hover:shadow-lg"
:class="p.enabled ? 'border-gray-200' : 'border-gray-100 opacity-75 bg-gray-50'"
>
<!-- Enabled Toggle -->
<div class="absolute top-4 right-4 z-10">
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" :checked="p.enabled" @change="toggleEnabled(p)" class="sr-only peer">
<div class="w-9 h-5 bg-gray-200 peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-indigo-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-indigo-600"></div>
</label>
</div>
<div class="flex items-start justify-between mb-4">
<div>
<h3 class="font-bold text-lg text-gray-900">{{ p.name }}</h3>
<div class="flex items-center gap-2 mt-1">
<span class="px-2 py-0.5 rounded text-xs font-mono bg-gray-100 text-gray-600 border border-gray-200">
{{ p.adapter }}
</span>
<span v-if="p.model" class="px-2 py-0.5 rounded text-xs bg-blue-50 text-blue-600 border border-blue-100 truncate max-w-[120px]">
{{ p.model }}
</span>
</div>
</div>
</div>
<div class="space-y-2 text-sm text-gray-500 mb-6">
<div class="flex justify-between">
<span>Priority:</span>
<span class="font-medium text-gray-700">{{ p.priority }}</span>
</div>
<div class="flex justify-between">
<span>API Key:</span>
<span :class="p.has_api_key ? 'text-green-600' : 'text-yellow-600'">
{{ p.has_api_key ? '● Configured' : '○ Not Set (Env?)' }}
</span>
</div>
</div>
<div class="flex items-center gap-2 pt-4 border-t border-gray-100 opacity-0 group-hover:opacity-100 transition-opacity">
<BaseButton size="sm" variant="secondary" class="flex-1" @click="edit(p)">配置</BaseButton>
<BaseButton size="sm" variant="ghost" class="text-red-500 hover:bg-red-50" @click="remove(p)">删除</BaseButton>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Edit/Create Modal -->
<div v-if="editing" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm" @click.self="reset">
<BaseCard class="w-full max-w-2xl max-h-[90vh] overflow-y-auto" padding="lg" @click.stop>
<div class="flex justify-between items-center mb-6">
<h2 class="text-xl font-bold gradient-text">{{ form.id ? '编辑引擎配置' : '添加新引擎' }}</h2>
<button @click="reset" class="text-gray-400 hover:text-gray-600">
<svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /></svg>
</button>
</div>
<form @submit.prevent="submit" class="grid grid-cols-1 md:grid-cols-2 gap-6">
<BaseInput v-model="form.name" label="名称 (显示名)" placeholder="如: Pro GPT-4" required />
<BaseSelect
v-model="form.adapter"
label="驱动程序 (Adapter)"
:options="adapterOptions"
required
description="选择底层的 API 驱动协议"
/>
<BaseInput v-model="form.model" label="模型名称 (Model)" placeholder="如: gpt-4o, minimax-v2" description="具体调用的模型ID" />
<BaseInput v-model.number="form.priority" label="优先级 (0-100)" type="number" description="数字越大越优先" />
<div class="md:col-span-2 p-4 bg-gray-50 rounded-xl border border-gray-100 space-y-4">
<h3 class="text-sm font-bold text-gray-700">密钥与连接</h3>
<BaseInput v-model="form.api_key" label="API Key" type="password" placeholder="留空则使用 .env 配置" :required="!form.id && !form.config_ref" />
<BaseInput v-model="form.api_base" label="API Endpoint / Group ID" placeholder="https://... 或 Group ID" />
<BaseInput v-model="form.config_ref" label="Fallback Env Var" placeholder="如: OPENAI_API_KEY (高级)" />
</div>
<!-- MiniMax Specific Config -->
<div v-if="form.adapter === 'minimax'" class="md:col-span-2 p-4 bg-indigo-50 rounded-xl border border-indigo-100 space-y-4">
<h3 class="text-sm font-bold text-indigo-700">MiniMax 语音参数</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<BaseInput v-model="form.config_json.voice_id" label="音色 ID (Voice ID)" placeholder="male-qn-qingse" />
<BaseInput v-model="form.config_json.emotion" label="情感 (Emotion)" placeholder="happy / sad / angry" />
<div class="space-y-2">
<div class="flex justify-between">
<label class="text-xs font-semibold text-gray-600">语速 (Speed)</label>
<span class="text-xs text-indigo-600 font-mono">{{ form.config_json.speed || 1.0 }}x</span>
</div>
<input type="range" v-model.number="form.config_json.speed" min="0.5" max="2.0" step="0.1" class="w-full accent-indigo-600" />
</div>
<div class="space-y-2">
<div class="flex justify-between">
<label class="text-xs font-semibold text-gray-600">音量 (Volume)</label>
<span class="text-xs text-indigo-600 font-mono">{{ form.config_json.vol || 1.0 }}</span>
</div>
<input type="range" v-model.number="form.config_json.vol" min="0.1" max="5.0" step="0.1" class="w-full accent-indigo-600" />
</div>
<div class="space-y-2">
<div class="flex justify-between">
<label class="text-xs font-semibold text-gray-600">音高 (Pitch)</label>
<span class="text-xs text-indigo-600 font-mono">{{ form.config_json.pitch || 0 }}</span>
</div>
<input type="range" v-model.number="form.config_json.pitch" min="-12" max="12" step="1" class="w-full accent-indigo-600" />
</div>
</div>
</div>
<!-- CQTAI Specific Config -->
<div v-if="form.adapter === 'cqtai'" class="md:col-span-2 p-4 bg-purple-50 rounded-xl border border-purple-100 space-y-4">
<h3 class="text-sm font-bold text-purple-700">CQTAI 画图参数</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<BaseSelect
v-model="form.config_json.aspect_ratio"
label="画面比例 (Aspect Ratio)"
:options="[
{ value: '1:1', label: '1:1 (正方形)' },
{ value: '16:9', label: '16:9 (横屏)' },
{ value: '9:16', label: '9:16 (竖屏)' },
{ value: '4:3', label: '4:3 (传统)' },
{ value: '3:4', label: '3:4 (海报)' }
]"
placeholder="默认 1:1"
/>
<BaseSelect
v-model="form.config_json.resolution"
label="分辨率 (Resolution)"
:options="[
{ value: '1K', label: '1K (标准)' },
{ value: '2K', label: '2K (高清)' },
{ value: '4K', label: '4K (超清)' }
]"
placeholder="默认 1K"
/>
</div>
</div>
<!-- Antigravity Specific Config -->
<div v-if="form.adapter === 'antigravity'" class="md:col-span-2 p-4 bg-emerald-50 rounded-xl border border-emerald-100 space-y-4">
<h3 class="text-sm font-bold text-emerald-700">Antigravity 画图参数 (OpenAI Compatible)</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<BaseSelect
v-model="form.config_json.size"
label="图像尺寸 (Size)"
:options="[
{ value: '1024x1024', label: '1024x1024 (1:1 正方形)' },
{ value: '1280x720', label: '1280x720 (16:9 横屏)' },
{ value: '720x1280', label: '720x1280 (9:16 竖屏)' },
{ value: '1216x896', label: '1216x896 (4:3 传统)' }
]"
placeholder="默认 1024x1024"
/>
<div class="flex items-center">
<p class="text-xs text-emerald-600">
💡 使用 Gemini 3 Pro Image 模型通过 OpenAI 兼容接口生成图像
</p>
</div>
</div>
</div>
<div class="md:col-span-2 flex justify-end gap-3 pt-4 border-t border-gray-100">
<BaseButton variant="secondary" type="button" @click="reset">取消</BaseButton>
<BaseButton type="submit">{{ form.id ? '保存变更' : '立即创建' }}</BaseButton>
</div>
</form>
</BaseCard>
</div>
</main>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import BaseButton from '../components/ui/BaseButton.vue'
import BaseCard from '../components/ui/BaseCard.vue'
import BaseInput from '../components/ui/BaseInput.vue'
import BaseSelect from '../components/ui/BaseSelect.vue'
// Types
type Provider = {
id: string
name: string
type: string
adapter: string
model?: string
api_base?: string
has_api_key?: boolean
priority: number
enabled: boolean
config_ref?: string
weight?: number
timeout_ms?: number
max_retries?: number
config_json: Record<string, any>
}
type ProviderAnalyticsBucket = {
capability: string
adapter: string
call_count: number
success_count: number
failure_count: number
avg_latency_ms: number | null
estimated_cost_usd: number
}
type ProviderAnalyticsUserBucket = {
user_id: string
call_count: number
success_count: number
failure_count: number
job_count: number
story_count: number
estimated_cost_usd: number
}
type ProviderAnalyticsResponse = {
scope: string
window_days: number | null
capability: string | null
total_calls: number
successful_calls: number
failed_calls: number
avg_latency_ms: number | null
estimated_cost_usd: number
user_count: number
job_count: number
story_count: number
by_provider: ProviderAnalyticsBucket[]
by_user: ProviderAnalyticsUserBucket[]
failure_reasons: Array<{
reason: string
count: number
}>
}
// State
const loginForm = ref({ username: '', password: '' })
const loginError = ref('')
const isLoggedIn = ref(!!sessionStorage.getItem('admin_auth'))
const activeTab = ref('text')
const providers = ref<Provider[]>([])
const defaults = ref<Record<string, string[]>>({})
const availableAdapters = ref<string[]>([])
const analytics = ref<ProviderAnalyticsResponse | null>(null)
const analyticsLoading = ref(false)
const analyticsError = ref('')
const analyticsWindow = ref<'7' | '30' | 'all'>('30')
const analyticsCapability = ref<'all' | 'text' | 'image' | 'tts' | 'storybook'>('all')
const editing = ref(false)
const form = ref<Partial<Provider> & { api_key?: string; config_json: Record<string, any> }>({
type: 'text',
priority: 10,
enabled: true,
config_json: {}
})
const apiBase = import.meta.env.VITE_API_BASE || ''
function getAuthHeader(): string {
return sessionStorage.getItem('admin_auth') || ''
}
function buildAnalyticsPath() {
const params = new URLSearchParams()
if (analyticsWindow.value !== 'all') {
params.set('days', analyticsWindow.value)
}
if (analyticsCapability.value !== 'all') {
params.set('capability', analyticsCapability.value)
}
const query = params.toString()
return `${apiBase}/admin/providers/analytics${query ? `?${query}` : ''}`
}
function formatLatency(value?: number | null) {
return typeof value === 'number' ? `${Math.round(value)}ms` : '暂无'
}
function formatCost(value?: number | null) {
return typeof value === 'number' ? `$${value.toFixed(4)}` : '$0.0000'
}
function formatCapability(value: string) {
switch (value) {
case 'text':
return '文本'
case 'image':
return '图片'
case 'tts':
return '语音'
case 'storybook':
return '绘本'
default:
return value
}
}
function getSuccessRate(successCount: number, totalCount: number) {
if (!totalCount) return null
return Math.round((successCount / totalCount) * 100)
}
// Actions
async function login() {
loginError.value = ''
const auth = 'Basic ' + btoa(loginForm.value.username + ':' + loginForm.value.password)
try {
const res = await fetch(`${apiBase}/admin/providers`, {
headers: { Authorization: auth },
})
if (res.ok) {
sessionStorage.setItem('admin_auth', auth)
isLoggedIn.value = true
// 触发 NavBar 更新(如果在同一页面)- 实际上我们需要强制刷新或使用事件总线,但最简单的是 reload
// 不过 AdminProviders 是视图组件NavBar 是布局组件,它们状态不共享是个问题
// 鉴于 NavBar 也依赖 sessionStorage 但不响应,这里的页面刷新是必要的
window.location.reload()
} else {
loginError.value = '鉴权失败'
}
} catch {
loginError.value = '网络不可达'
}
}
function logout() {
sessionStorage.removeItem('admin_auth')
isLoggedIn.value = false
providers.value = []
analytics.value = null
window.location.reload()
}
async function loadAnalytics() {
if (!isLoggedIn.value) return
analyticsLoading.value = true
analyticsError.value = ''
try {
const response = await fetch(buildAnalyticsPath(), {
headers: { Authorization: getAuthHeader() },
})
if (!response.ok) {
throw new Error('运营数据加载失败')
}
analytics.value = await response.json()
} catch (error) {
analyticsError.value = error instanceof Error ? error.message : '运营数据加载失败'
analytics.value = null
} finally {
analyticsLoading.value = false
}
}
async function loadData() {
if (!isLoggedIn.value) return
const headers = { Authorization: getAuthHeader() }
analyticsLoading.value = true
try {
const [pRes, dRes, aRes, analyticsRes] = await Promise.all([
fetch(`${apiBase}/admin/providers`, { headers }),
fetch(`${apiBase}/admin/providers/defaults`, { headers }),
fetch(`${apiBase}/admin/providers/adapters`, { headers }),
fetch(buildAnalyticsPath(), { headers }),
])
if (pRes.ok) providers.value = await pRes.json()
if (dRes.ok) defaults.value = await dRes.json()
if (aRes.ok) availableAdapters.value = await aRes.json()
if (analyticsRes.ok) {
analytics.value = await analyticsRes.json()
analyticsError.value = ''
} else {
analytics.value = null
analyticsError.value = '运营数据加载失败'
}
} finally {
analyticsLoading.value = false
}
}
// Computed
const filteredProviders = computed(() => {
return providers.value
.filter(p => p.type === activeTab.value)
.sort((a, b) => b.priority - a.priority)
})
const adapterOptions = computed(() => {
return availableAdapters.value
.filter(a => a.startsWith(activeTab.value + ':'))
.map(a => {
const name = a.split(':')[1]
return { value: name, label: name } // e.g. 'gemini', 'openai'
})
})
const enabledProviderCount = computed(() => providers.value.filter((provider) => provider.enabled).length)
const providerSuccessRate = computed(() => {
if (!analytics.value?.total_calls) return null
return getSuccessRate(analytics.value.successful_calls, analytics.value.total_calls)
})
const analyticsProviderRows = computed(() => {
return [...(analytics.value?.by_provider || [])].sort((left, right) => {
if (right.call_count !== left.call_count) {
return right.call_count - left.call_count
}
if (right.estimated_cost_usd !== left.estimated_cost_usd) {
return right.estimated_cost_usd - left.estimated_cost_usd
}
return left.adapter.localeCompare(right.adapter)
})
})
const analyticsUserRows = computed(() => {
return [...(analytics.value?.by_user || [])].sort((left, right) => {
if (right.call_count !== left.call_count) {
return right.call_count - left.call_count
}
if (right.estimated_cost_usd !== left.estimated_cost_usd) {
return right.estimated_cost_usd - left.estimated_cost_usd
}
return left.user_id.localeCompare(right.user_id)
})
})
// UI Actions
function cloneDefault(type: string, name: string) {
// 根据默认名称推断配置
// 大多数默认 provider 的 name 就是 adapter name (如 gemini, openai, cqtai)
// 如果未来有别名,这里可以做映射
activeTab.value = type // 切换到对应 tab
form.value = {
type: type,
name: name,
adapter: name, // Default assumption
priority: 10,
enabled: true,
weight: 1,
timeout_ms: 60000,
max_retries: 1,
config_json: {}
}
editing.value = true
}
function openCreateModal() {
form.value = {
type: activeTab.value,
priority: 10,
enabled: true,
weight: 1,
timeout_ms: 60000,
max_retries: 1,
config_json: {}
}
editing.value = true
}
function edit(p: Provider) {
const { has_api_key, ...rest } = p
form.value = { ...rest, api_key: '', config_json: rest.config_json || {} } // Clear key for security, user re-enters if needed
editing.value = true
}
function reset() {
editing.value = false
form.value = { config_json: {} }
}
async function submit() {
const method = form.value.id ? 'PUT' : 'POST'
const url = form.value.id
? `${apiBase}/admin/providers/${form.value.id}`
: `${apiBase}/admin/providers`
await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
Authorization: getAuthHeader(),
},
body: JSON.stringify(form.value),
})
await loadData()
reset()
}
async function remove(p: Provider) {
if (!confirm(`确认删除 ${p.name}?`)) return
await fetch(`${apiBase}/admin/providers/${p.id}`, {
method: 'DELETE',
headers: { Authorization: getAuthHeader() },
})
await loadData()
}
async function toggleEnabled(p: Provider) {
await fetch(`${apiBase}/admin/providers/${p.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: getAuthHeader(),
},
body: JSON.stringify({ enabled: !p.enabled }),
})
await loadData()
}
onMounted(() => {
if (isLoggedIn.value) loadData()
})
watch([analyticsWindow, analyticsCapability], () => {
if (isLoggedIn.value) {
void loadAnalytics()
}
})
</script>