Files
planManage/app/static/index.html
锦麟 王 61ce809634 feat: 多平台 Coding Plan 统一管理系统初始实现
- 支持 MiniMax/OpenAI/Google Gemini/智谱/Kimi 五个平台
- 插件化 Provider 架构,自动发现注册
- 多维度 QuotaRule 额度追踪(固定间隔/自然周期/API同步/手动)
- OpenAI + Anthropic 兼容 API 代理,SSE 流式转发
- Model 路由表 + 额度耗尽自动 fallback
- 多媒体任务队列(图片/语音/视频)
- Vue3 + Tailwind 单文件 Web 仪表盘
- Docker 一键部署

Made-with: Cursor
2026-03-31 15:50:42 +08:00

508 lines
21 KiB
HTML
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.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Plan Manager</title>
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
<style>
[v-cloak] { display: none; }
.fade-enter-active, .fade-leave-active { transition: opacity .2s; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; }
.progress-bar { transition: width 0.5s ease; }
</style>
</head>
<body class="bg-gray-50 min-h-screen">
<div id="app" v-cloak>
<!-- 顶栏 -->
<header class="bg-white shadow-sm border-b sticky top-0 z-10">
<div class="max-w-7xl mx-auto px-4 py-3 flex items-center justify-between">
<h1 class="text-xl font-bold text-gray-800">Plan Manager</h1>
<nav class="flex gap-2">
<button v-for="t in tabs" :key="t.id"
@click="activeTab = t.id"
:class="activeTab === t.id ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'"
class="px-4 py-1.5 rounded-full text-sm font-medium transition">
{{ t.label }}
</button>
</nav>
</div>
</header>
<main class="max-w-7xl mx-auto px-4 py-6">
<!-- ═══ 仪表盘 ═══ -->
<section v-if="activeTab === 'dashboard'">
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-5">
<div v-for="p in plans" :key="p.id"
class="bg-white rounded-xl shadow-sm border p-5 hover:shadow-md transition">
<!-- 头部 -->
<div class="flex items-center justify-between mb-4">
<div>
<h3 class="font-semibold text-gray-800">{{ p.name }}</h3>
<span class="text-xs px-2 py-0.5 rounded-full"
:class="providerColor(p.provider_name)">
{{ p.provider_name }}
</span>
<span class="text-xs ml-1 text-gray-400">{{ p.plan_type }}</span>
</div>
<span class="w-3 h-3 rounded-full"
:class="p.all_available ? 'bg-green-400' : 'bg-red-400'"
:title="p.all_available ? '可用' : '额度不足'"></span>
</div>
<!-- QuotaRules -->
<div v-for="r in p.quota_rules" :key="r.id" class="mb-3 last:mb-0">
<div class="flex justify-between text-xs text-gray-500 mb-1">
<span>{{ r.rule_name }}
<span class="ml-1 px-1.5 py-0.5 bg-gray-100 rounded text-gray-400">{{ refreshLabel(r) }}</span>
</span>
<span>{{ r.quota_used }} / {{ r.quota_total }} {{ r.quota_unit }}</span>
</div>
<div class="w-full bg-gray-100 rounded-full h-2.5">
<div class="h-2.5 rounded-full progress-bar"
:class="barColor(r)"
:style="{width: pct(r) + '%'}"></div>
</div>
<div v-if="r.next_refresh_at" class="text-xs text-gray-400 mt-0.5">
刷新倒计时: {{ countdown(r.next_refresh_at) }}
</div>
</div>
<!-- 操作 -->
<div class="flex gap-2 mt-4 pt-3 border-t">
<button @click="refreshQuota(p.id)" class="text-xs text-blue-500 hover:underline">手动刷新</button>
<button @click="editPlan(p)" class="text-xs text-gray-400 hover:underline">编辑</button>
</div>
</div>
</div>
<div v-if="plans.length === 0" class="text-center text-gray-400 py-20">
暂无 Plan请在配置页添加
</div>
</section>
<!-- ═══ 任务队列 ═══ -->
<section v-if="activeTab === 'queue'">
<div class="flex justify-between items-center mb-4">
<div class="flex gap-2">
<button v-for="s in ['all','pending','running','completed','failed']" :key="s"
@click="queueFilter = s === 'all' ? '' : s"
:class="(queueFilter === '' && s === 'all') || queueFilter === s ? 'bg-blue-600 text-white' : 'bg-gray-100'"
class="px-3 py-1 rounded text-xs font-medium transition">
{{ s === 'all' ? '全部' : s }}
</button>
</div>
<button @click="showNewTask = true"
class="bg-blue-600 text-white px-4 py-1.5 rounded-lg text-sm hover:bg-blue-700 transition">
新建任务
</button>
</div>
<div class="bg-white rounded-xl shadow-sm border">
<table class="w-full text-sm">
<thead class="bg-gray-50 text-gray-500 text-xs">
<tr>
<th class="px-4 py-3 text-left">ID</th>
<th class="px-4 py-3 text-left">类型</th>
<th class="px-4 py-3 text-left">状态</th>
<th class="px-4 py-3 text-left">优先级</th>
<th class="px-4 py-3 text-left">创建时间</th>
<th class="px-4 py-3 text-left">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="t in tasks" :key="t.id" class="border-t hover:bg-gray-50">
<td class="px-4 py-3 font-mono text-xs">{{ t.id.slice(0,8) }}</td>
<td class="px-4 py-3">{{ t.task_type }}</td>
<td class="px-4 py-3">
<span class="px-2 py-0.5 rounded-full text-xs"
:class="statusColor(t.status)">{{ t.status }}</span>
</td>
<td class="px-4 py-3">{{ t.priority }}</td>
<td class="px-4 py-3 text-xs text-gray-400">{{ fmtTime(t.created_at) }}</td>
<td class="px-4 py-3">
<button v-if="t.status === 'pending'" @click="cancelTask(t.id)"
class="text-xs text-red-500 hover:underline">取消</button>
</td>
</tr>
<tr v-if="tasks.length === 0">
<td colspan="6" class="px-4 py-8 text-center text-gray-400">暂无任务</td>
</tr>
</tbody>
</table>
</div>
</section>
<!-- ═══ 配置页 ═══ -->
<section v-if="activeTab === 'config'">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Plan 列表 -->
<div>
<div class="flex justify-between items-center mb-3">
<h2 class="font-semibold text-gray-700">Plan 列表</h2>
<button @click="showAddPlan = true"
class="bg-blue-600 text-white px-3 py-1 rounded text-sm hover:bg-blue-700">添加 Plan</button>
</div>
<div v-for="p in allPlans" :key="p.id"
class="bg-white rounded-lg border p-4 mb-3 hover:shadow-sm transition">
<div class="flex justify-between items-start">
<div>
<span class="font-medium">{{ p.name }}</span>
<span class="text-xs ml-2 px-1.5 py-0.5 rounded"
:class="providerColor(p.provider_name)">{{ p.provider_name }}</span>
</div>
<div class="flex gap-2">
<button @click="togglePlan(p)" class="text-xs"
:class="p.enabled ? 'text-green-600' : 'text-gray-400'">
{{ p.enabled ? '已启用' : '已禁用' }}
</button>
<button @click="deletePlan(p.id)" class="text-xs text-red-400 hover:text-red-600">删除</button>
</div>
</div>
<div class="text-xs text-gray-400 mt-1">{{ p.api_base }}</div>
<div class="text-xs text-gray-400 mt-1">
模型: {{ (p.supported_models || []).join(', ') || '无' }}
</div>
</div>
</div>
<!-- Model 路由表 -->
<div>
<div class="flex justify-between items-center mb-3">
<h2 class="font-semibold text-gray-700">Model 路由</h2>
<button @click="showAddRoute = true"
class="bg-green-600 text-white px-3 py-1 rounded text-sm hover:bg-green-700">添加路由</button>
</div>
<div class="bg-white rounded-lg border">
<table class="w-full text-sm">
<thead class="bg-gray-50 text-gray-500 text-xs">
<tr>
<th class="px-4 py-2 text-left">Model</th>
<th class="px-4 py-2 text-left">Plan</th>
<th class="px-4 py-2 text-left">优先级</th>
<th class="px-4 py-2"></th>
</tr>
</thead>
<tbody>
<tr v-for="r in routes" :key="r.id" class="border-t">
<td class="px-4 py-2 font-mono text-xs">{{ r.model_name }}</td>
<td class="px-4 py-2 text-xs">{{ planName(r.plan_id) }}</td>
<td class="px-4 py-2 text-xs">{{ r.priority }}</td>
<td class="px-4 py-2">
<button @click="deleteRoute(r.id)" class="text-xs text-red-400 hover:text-red-600">删除</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</section>
</main>
<!-- ═══ 弹窗: 新建任务 ═══ -->
<div v-if="showNewTask" class="fixed inset-0 bg-black bg-opacity-30 flex items-center justify-center z-20"
@click.self="showNewTask = false">
<div class="bg-white rounded-xl shadow-xl p-6 w-full max-w-md">
<h3 class="font-semibold mb-4">新建任务</h3>
<label class="block text-sm text-gray-600 mb-1">任务类型</label>
<select v-model="newTask.task_type" class="w-full border rounded px-3 py-2 mb-3 text-sm">
<option v-for="t in ['image','voice','video','chat']" :value="t">{{ t }}</option>
</select>
<label class="block text-sm text-gray-600 mb-1">目标 Plan</label>
<select v-model="newTask.plan_id" class="w-full border rounded px-3 py-2 mb-3 text-sm">
<option value="">自动选择</option>
<option v-for="p in allPlans" :key="p.id" :value="p.id">{{ p.name }}</option>
</select>
<label class="block text-sm text-gray-600 mb-1">Prompt / Text</label>
<textarea v-model="newTask.prompt" class="w-full border rounded px-3 py-2 mb-3 text-sm h-24"></textarea>
<label class="block text-sm text-gray-600 mb-1">优先级</label>
<input v-model.number="newTask.priority" type="number" class="w-full border rounded px-3 py-2 mb-4 text-sm">
<div class="flex justify-end gap-2">
<button @click="showNewTask = false" class="px-4 py-2 text-sm text-gray-500">取消</button>
<button @click="submitTask" class="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700">提交</button>
</div>
</div>
</div>
<!-- ═══ 弹窗: 添加 Plan ═══ -->
<div v-if="showAddPlan" class="fixed inset-0 bg-black bg-opacity-30 flex items-center justify-center z-20"
@click.self="showAddPlan = false">
<div class="bg-white rounded-xl shadow-xl p-6 w-full max-w-lg max-h-screen overflow-y-auto">
<h3 class="font-semibold mb-4">{{ editingPlan ? '编辑' : '添加' }} Plan</h3>
<div class="grid grid-cols-2 gap-3 mb-3">
<div>
<label class="block text-xs text-gray-500 mb-1">名称</label>
<input v-model="planForm.name" class="w-full border rounded px-3 py-2 text-sm">
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">Provider</label>
<input v-model="planForm.provider_name" class="w-full border rounded px-3 py-2 text-sm"
placeholder="openai / kimi / minimax / google / zhipu">
</div>
</div>
<div class="mb-3">
<label class="block text-xs text-gray-500 mb-1">API Key</label>
<input v-model="planForm.api_key" type="password" class="w-full border rounded px-3 py-2 text-sm">
</div>
<div class="mb-3">
<label class="block text-xs text-gray-500 mb-1">API Base URL</label>
<input v-model="planForm.api_base" class="w-full border rounded px-3 py-2 text-sm">
</div>
<div class="mb-3">
<label class="block text-xs text-gray-500 mb-1">支持模型 (逗号分隔)</label>
<input v-model="planForm.models_str" class="w-full border rounded px-3 py-2 text-sm">
</div>
<div class="flex justify-end gap-2">
<button @click="showAddPlan = false; editingPlan = null" class="px-4 py-2 text-sm text-gray-500">取消</button>
<button @click="savePlan" class="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700">保存</button>
</div>
</div>
</div>
<!-- ═══ 弹窗: 添加路由 ═══ -->
<div v-if="showAddRoute" class="fixed inset-0 bg-black bg-opacity-30 flex items-center justify-center z-20"
@click.self="showAddRoute = false">
<div class="bg-white rounded-xl shadow-xl p-6 w-full max-w-sm">
<h3 class="font-semibold mb-4">添加 Model 路由</h3>
<label class="block text-xs text-gray-500 mb-1">Model 名称</label>
<input v-model="routeForm.model_name" class="w-full border rounded px-3 py-2 mb-3 text-sm">
<label class="block text-xs text-gray-500 mb-1">目标 Plan</label>
<select v-model="routeForm.plan_id" class="w-full border rounded px-3 py-2 mb-3 text-sm">
<option v-for="p in allPlans" :key="p.id" :value="p.id">{{ p.name }}</option>
</select>
<label class="block text-xs text-gray-500 mb-1">优先级 (越大越优先)</label>
<input v-model.number="routeForm.priority" type="number" class="w-full border rounded px-3 py-2 mb-4 text-sm">
<div class="flex justify-end gap-2">
<button @click="showAddRoute = false" class="px-4 py-2 text-sm text-gray-500">取消</button>
<button @click="saveRoute" class="px-4 py-2 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700">保存</button>
</div>
</div>
</div>
</div>
<script>
const { createApp, ref, reactive, computed, onMounted, onUnmounted } = Vue;
createApp({
setup() {
const tabs = [
{ id: 'dashboard', label: '仪表盘' },
{ id: 'queue', label: '任务队列' },
{ id: 'config', label: '配置' },
];
const activeTab = ref('dashboard');
// ── 数据 ──
const plans = ref([]);
const allPlans = ref([]);
const tasks = ref([]);
const routes = ref([]);
const queueFilter = ref('');
// ── 弹窗 ──
const showNewTask = ref(false);
const showAddPlan = ref(false);
const showAddRoute = ref(false);
const editingPlan = ref(null);
const newTask = reactive({ task_type: 'image', plan_id: '', prompt: '', priority: 0 });
const planForm = reactive({ name: '', provider_name: '', api_key: '', api_base: '', models_str: '' });
const routeForm = reactive({ model_name: '', plan_id: '', priority: 0 });
// ── API ──
const api = (path, opts = {}) => fetch('/api' + path, {
headers: { 'Content-Type': 'application/json', ...opts.headers },
...opts,
}).then(r => r.json());
async function loadDashboard() {
plans.value = await api('/quota/dashboard');
}
async function loadPlans() {
allPlans.value = await api('/plans');
}
async function loadTasks() {
const q = queueFilter.value ? `?status=${queueFilter.value}` : '';
tasks.value = await api(`/queue${q}`);
}
async function loadRoutes() {
routes.value = await api('/plans/routes/models');
}
async function refreshAll() {
await Promise.all([loadDashboard(), loadPlans(), loadTasks(), loadRoutes()]);
}
// 定时刷新
let timer;
onMounted(() => {
refreshAll();
timer = setInterval(refreshAll, 15000);
});
onUnmounted(() => clearInterval(timer));
// ── 操作 ──
async function refreshQuota(planId) {
await api(`/quota/plan/${planId}/refresh`, { method: 'POST' });
await loadDashboard();
}
function editPlan(p) {
editingPlan.value = p;
planForm.name = p.name;
planForm.provider_name = p.provider_name;
planForm.api_key = '';
planForm.api_base = p.api_base;
planForm.models_str = (p.supported_models || []).join(', ');
showAddPlan.value = true;
}
async function savePlan() {
const models = planForm.models_str.split(',').map(s => s.trim()).filter(Boolean);
if (editingPlan.value) {
const body = { name: planForm.name, api_base: planForm.api_base, supported_models: models };
if (planForm.api_key) body.api_key = planForm.api_key;
await api(`/plans/${editingPlan.value.id}`, { method: 'PATCH', body: JSON.stringify(body) });
} else {
await api('/plans', {
method: 'POST',
body: JSON.stringify({
name: planForm.name,
provider_name: planForm.provider_name,
api_key: planForm.api_key,
api_base: planForm.api_base,
supported_models: models,
}),
});
}
showAddPlan.value = false;
editingPlan.value = null;
await refreshAll();
}
async function deletePlan(id) {
if (!confirm('确认删除?')) return;
await api(`/plans/${id}`, { method: 'DELETE' });
await refreshAll();
}
async function togglePlan(p) {
await api(`/plans/${p.id}`, { method: 'PATCH', body: JSON.stringify({ enabled: !p.enabled }) });
await refreshAll();
}
async function submitTask() {
const payload = { prompt: newTask.prompt };
if (newTask.task_type === 'voice') payload.text = newTask.prompt;
await api('/queue', {
method: 'POST',
body: JSON.stringify({
task_type: newTask.task_type,
plan_id: newTask.plan_id || null,
request_payload: payload,
priority: newTask.priority,
}),
});
showNewTask.value = false;
newTask.prompt = '';
await loadTasks();
}
async function cancelTask(id) {
await api(`/queue/${id}/cancel`, { method: 'POST' });
await loadTasks();
}
async function saveRoute() {
await api('/plans/routes/models', {
method: 'POST',
body: JSON.stringify(routeForm),
});
showAddRoute.value = false;
await loadRoutes();
}
async function deleteRoute(id) {
await api(`/plans/routes/models/${id}`, { method: 'DELETE' });
await loadRoutes();
}
// ── 工具函数 ──
function pct(r) {
if (!r.quota_total) return 0;
return Math.min(100, (r.quota_used / r.quota_total) * 100);
}
function barColor(r) {
const p = pct(r);
if (p >= 90) return 'bg-red-500';
if (p >= 70) return 'bg-yellow-400';
return 'bg-blue-500';
}
function refreshLabel(r) {
if (r.refresh_type === 'fixed_interval') return `${r.interval_hours}h 滚动`;
if (r.refresh_type === 'calendar_cycle') return r.calendar_unit || '周期';
if (r.refresh_type === 'api_sync') return 'API 同步';
return '手动';
}
function countdown(isoStr) {
if (!isoStr) return '--';
const diff = new Date(isoStr) - Date.now();
if (diff <= 0) return '即将刷新';
const h = Math.floor(diff / 3600000);
const m = Math.floor((diff % 3600000) / 60000);
const s = Math.floor((diff % 60000) / 1000);
if (h > 24) return `${Math.floor(h / 24)}${h % 24}`;
if (h > 0) return `${h}${m}`;
return `${m}${s}`;
}
function providerColor(name) {
const m = {
openai: 'bg-green-100 text-green-700',
kimi: 'bg-purple-100 text-purple-700',
minimax: 'bg-orange-100 text-orange-700',
google: 'bg-blue-100 text-blue-700',
zhipu: 'bg-indigo-100 text-indigo-700',
};
return m[name] || 'bg-gray-100 text-gray-600';
}
function statusColor(s) {
const m = {
pending: 'bg-yellow-100 text-yellow-700',
running: 'bg-blue-100 text-blue-700',
completed: 'bg-green-100 text-green-700',
failed: 'bg-red-100 text-red-700',
cancelled: 'bg-gray-100 text-gray-500',
};
return m[s] || '';
}
function fmtTime(iso) {
if (!iso) return '--';
return new Date(iso).toLocaleString('zh-CN');
}
function planName(id) {
const p = allPlans.value.find(x => x.id === id);
return p ? p.name : id;
}
// 倒计时实时更新
let cdTimer;
const tick = ref(0);
onMounted(() => { cdTimer = setInterval(() => tick.value++, 1000); });
onUnmounted(() => clearInterval(cdTimer));
return {
tabs, activeTab, plans, allPlans, tasks, routes, queueFilter,
showNewTask, showAddPlan, showAddRoute, editingPlan,
newTask, planForm, routeForm,
loadDashboard, loadPlans, loadTasks, loadRoutes,
refreshQuota, editPlan, savePlan, deletePlan, togglePlan,
submitTask, cancelTask, saveRoute, deleteRoute,
pct, barColor, refreshLabel, countdown: (s) => { tick.value; return countdown(s); },
providerColor, statusColor, fmtTime, planName,
};
},
}).mount('#app');
</script>
</body>
</html>