873 lines
37 KiB
Vue
873 lines
37 KiB
Vue
<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>
|