feat: 多平台 Coding Plan 统一管理系统初始实现

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

Made-with: Cursor
This commit is contained in:
锦麟 王
2026-03-31 15:50:42 +08:00
commit 61ce809634
28 changed files with 2804 additions and 0 deletions

507
app/static/index.html Normal file
View File

@@ -0,0 +1,507 @@
<!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>