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