功能: - 激活码管理 (Pro/Auto 两种类型) - 账号池管理 - 设备绑定记录 - 使用日志 - 搜索/筛选功能 - 禁用/启用功能 (支持退款参考) - 全局设置 (换号间隔、额度消耗等) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1244 lines
72 KiB
HTML
1244 lines
72 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>CursorPro 管理后台</title>
|
||
<script src="https://cdn.tailwindcss.com"></script>
|
||
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
|
||
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
|
||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||
<style>
|
||
body { font-family: 'Inter', sans-serif; }
|
||
.fade-enter-active, .fade-leave-active { transition: opacity 0.3s ease; }
|
||
.fade-enter-from, .fade-leave-to { opacity: 0; }
|
||
</style>
|
||
</head>
|
||
<body class="bg-gray-100">
|
||
<div id="app">
|
||
<!-- 登录页面 -->
|
||
<div v-if="!isLoggedIn" class="min-h-screen flex items-center justify-center">
|
||
<div class="bg-white p-8 rounded-lg shadow-lg w-96">
|
||
<h1 class="text-2xl font-bold text-center mb-6 text-gray-800">CursorPro 管理后台</h1>
|
||
<form @submit.prevent="login">
|
||
<div class="mb-4">
|
||
<label class="block text-sm font-medium text-gray-700 mb-2">用户名</label>
|
||
<input v-model="loginForm.username" type="text"
|
||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||
placeholder="请输入用户名">
|
||
</div>
|
||
<div class="mb-6">
|
||
<label class="block text-sm font-medium text-gray-700 mb-2">密码</label>
|
||
<input v-model="loginForm.password" type="password"
|
||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||
placeholder="请输入密码">
|
||
</div>
|
||
<button type="submit"
|
||
class="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 transition">
|
||
登录
|
||
</button>
|
||
</form>
|
||
<p v-if="loginError" class="mt-4 text-red-500 text-sm text-center">{{ loginError }}</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 主界面 -->
|
||
<div v-else class="min-h-screen">
|
||
<!-- 顶部导航 -->
|
||
<nav class="bg-white shadow-sm">
|
||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||
<div class="flex justify-between h-16">
|
||
<div class="flex items-center">
|
||
<h1 class="text-xl font-bold text-gray-800">CursorPro 管理后台</h1>
|
||
</div>
|
||
<div class="flex items-center space-x-4">
|
||
<span class="text-gray-600">{{ currentUser }}</span>
|
||
<button @click="logout" class="text-red-600 hover:text-red-800">退出</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</nav>
|
||
|
||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||
<!-- 标签页 -->
|
||
<div class="mb-6">
|
||
<nav class="flex space-x-4">
|
||
<button @click="currentTab = 'dashboard'"
|
||
:class="currentTab === 'dashboard' ? 'bg-blue-100 text-blue-700' : 'text-gray-500 hover:text-gray-700'"
|
||
class="px-3 py-2 font-medium text-sm rounded-md">
|
||
仪表盘
|
||
</button>
|
||
<button @click="currentTab = 'accounts'"
|
||
:class="currentTab === 'accounts' ? 'bg-blue-100 text-blue-700' : 'text-gray-500 hover:text-gray-700'"
|
||
class="px-3 py-2 font-medium text-sm rounded-md">
|
||
账号管理
|
||
</button>
|
||
<button @click="currentTab = 'keys'"
|
||
:class="currentTab === 'keys' ? 'bg-blue-100 text-blue-700' : 'text-gray-500 hover:text-gray-700'"
|
||
class="px-3 py-2 font-medium text-sm rounded-md">
|
||
激活码管理
|
||
</button>
|
||
<button @click="currentTab = 'settings'; loadSettings()"
|
||
:class="currentTab === 'settings' ? 'bg-blue-100 text-blue-700' : 'text-gray-500 hover:text-gray-700'"
|
||
class="px-3 py-2 font-medium text-sm rounded-md">
|
||
全局设置
|
||
</button>
|
||
<button @click="currentTab = 'compensate'"
|
||
:class="currentTab === 'compensate' ? 'bg-blue-100 text-blue-700' : 'text-gray-500 hover:text-gray-700'"
|
||
class="px-3 py-2 font-medium text-sm rounded-md">
|
||
批量补偿
|
||
</button>
|
||
<button @click="currentTab = 'logs'; loadLogs()"
|
||
:class="currentTab === 'logs' ? 'bg-blue-100 text-blue-700' : 'text-gray-500 hover:text-gray-700'"
|
||
class="px-3 py-2 font-medium text-sm rounded-md">
|
||
使用日志
|
||
</button>
|
||
</nav>
|
||
</div>
|
||
|
||
<!-- 仪表盘 -->
|
||
<div v-if="currentTab === 'dashboard'">
|
||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||
<div class="bg-white rounded-lg shadow p-6">
|
||
<div class="text-sm font-medium text-gray-500">总账号数</div>
|
||
<div class="mt-2 text-3xl font-semibold text-gray-900">{{ stats.total_accounts }}</div>
|
||
<div class="mt-1 text-sm text-green-600">{{ stats.active_accounts }} 可用</div>
|
||
</div>
|
||
<div class="bg-white rounded-lg shadow p-6">
|
||
<div class="text-sm font-medium text-gray-500">总激活码</div>
|
||
<div class="mt-2 text-3xl font-semibold text-gray-900">{{ stats.total_keys }}</div>
|
||
<div class="mt-1 text-sm text-green-600">{{ stats.active_keys }} 有效</div>
|
||
</div>
|
||
<div class="bg-white rounded-lg shadow p-6">
|
||
<div class="text-sm font-medium text-gray-500">今日使用</div>
|
||
<div class="mt-2 text-3xl font-semibold text-gray-900">{{ stats.today_usage }}</div>
|
||
<div class="mt-1 text-sm text-blue-600">Pro账号: {{ stats.pro_accounts }}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 账号管理 -->
|
||
<div v-if="currentTab === 'accounts'">
|
||
<div class="bg-white rounded-lg shadow">
|
||
<div class="px-6 py-4 border-b border-gray-200 flex justify-between items-center">
|
||
<h2 class="text-lg font-medium text-gray-900">账号列表</h2>
|
||
<div class="space-x-2">
|
||
<button @click="showImportModal = true"
|
||
class="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 text-sm">
|
||
批量导入
|
||
</button>
|
||
<button @click="showAccountModal = true; editingAccount = null"
|
||
class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 text-sm">
|
||
添加账号
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div class="overflow-x-auto">
|
||
<table class="min-w-full divide-y divide-gray-200">
|
||
<thead class="bg-gray-50">
|
||
<tr>
|
||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">邮箱</th>
|
||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">类型</th>
|
||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">状态</th>
|
||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">使用次数</th>
|
||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody class="bg-white divide-y divide-gray-200">
|
||
<tr v-for="account in accounts" :key="account.id">
|
||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{{ account.email }}</td>
|
||
<td class="px-6 py-4 whitespace-nowrap">
|
||
<span :class="account.membership_type === 'pro' ? 'bg-purple-100 text-purple-800' : 'bg-gray-100 text-gray-800'"
|
||
class="px-2 py-1 text-xs font-medium rounded-full">
|
||
{{ account.membership_type.toUpperCase() }}
|
||
</span>
|
||
</td>
|
||
<td class="px-6 py-4 whitespace-nowrap">
|
||
<span :class="getStatusClass(account.status)"
|
||
class="px-2 py-1 text-xs font-medium rounded-full">
|
||
{{ getStatusText(account.status) }}
|
||
</span>
|
||
</td>
|
||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ account.usage_count }}</td>
|
||
<td class="px-6 py-4 whitespace-nowrap text-sm space-x-2">
|
||
<button @click="editAccount(account)" class="text-blue-600 hover:text-blue-900">编辑</button>
|
||
<button @click="deleteAccount(account.id)" class="text-red-600 hover:text-red-900">删除</button>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 激活码管理 -->
|
||
<div v-if="currentTab === 'keys'">
|
||
<div class="bg-white rounded-lg shadow">
|
||
<div class="px-6 py-4 border-b border-gray-200">
|
||
<div class="flex justify-between items-center mb-4">
|
||
<h2 class="text-lg font-medium text-gray-900">激活码列表</h2>
|
||
<button @click="showKeyModal = true; editingKey = null; resetKeyForm()"
|
||
class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 text-sm">
|
||
生成激活码
|
||
</button>
|
||
</div>
|
||
<!-- 搜索和筛选 -->
|
||
<div class="flex flex-wrap gap-3">
|
||
<input v-model="keySearch.search" @input="searchKeys" type="text" placeholder="搜索激活码..."
|
||
class="px-3 py-2 border border-gray-300 rounded-md text-sm w-64 focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||
<select v-model="keySearch.status" @change="searchKeys"
|
||
class="px-3 py-2 border border-gray-300 rounded-md text-sm">
|
||
<option value="">全部状态</option>
|
||
<option value="active">有效</option>
|
||
<option value="disabled">禁用</option>
|
||
</select>
|
||
<select v-model="keySearch.activated" @change="searchKeys"
|
||
class="px-3 py-2 border border-gray-300 rounded-md text-sm">
|
||
<option value="">全部</option>
|
||
<option value="true">已激活</option>
|
||
<option value="false">未激活</option>
|
||
</select>
|
||
<select v-model="keySearch.membership_type" @change="searchKeys"
|
||
class="px-3 py-2 border border-gray-300 rounded-md text-sm">
|
||
<option value="">全部类型</option>
|
||
<option value="pro">Pro (高级模型)</option>
|
||
<option value="free">Auto (无限换号)</option>
|
||
</select>
|
||
<button @click="resetKeySearch" class="px-3 py-2 text-gray-600 hover:text-gray-800 text-sm">
|
||
重置
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div class="overflow-x-auto">
|
||
<table class="min-w-full divide-y divide-gray-200">
|
||
<thead class="bg-gray-50">
|
||
<tr>
|
||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">激活码</th>
|
||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">类型</th>
|
||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">额度/天数</th>
|
||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">有效期</th>
|
||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">状态</th>
|
||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody class="bg-white divide-y divide-gray-200">
|
||
<tr v-for="key in keys" :key="key.id">
|
||
<td class="px-6 py-4 whitespace-nowrap">
|
||
<code class="text-sm bg-gray-100 px-2 py-1 rounded">{{ key.key }}</code>
|
||
<button @click="copyKey(key.key)" class="ml-2 text-gray-400 hover:text-gray-600">
|
||
<svg class="w-4 h-4 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path>
|
||
</svg>
|
||
</button>
|
||
</td>
|
||
<td class="px-6 py-4 whitespace-nowrap">
|
||
<span :class="key.membership_type === 'pro' ? 'bg-purple-100 text-purple-800' : 'bg-blue-100 text-blue-800'"
|
||
class="px-2 py-1 text-xs font-medium rounded-full">
|
||
{{ key.membership_type === 'pro' ? '高级模型' : '无限Auto' }}
|
||
</span>
|
||
</td>
|
||
<td class="px-6 py-4 whitespace-nowrap text-sm">
|
||
<template v-if="key.membership_type === 'pro'">
|
||
<span class="text-green-600 font-medium">{{ key.quota - key.quota_used }}</span>
|
||
<span class="text-gray-400"> / {{ key.quota }}</span>
|
||
</template>
|
||
<template v-else>
|
||
<span class="text-blue-600">{{ key.valid_days }}天</span>
|
||
</template>
|
||
</td>
|
||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||
<template v-if="key.first_activated_at">
|
||
{{ key.expire_at ? formatDate(key.expire_at) : '永久' }}
|
||
</template>
|
||
<template v-else>
|
||
<span class="text-yellow-600">{{ key.valid_days }}天 (未激活)</span>
|
||
</template>
|
||
</td>
|
||
<td class="px-6 py-4 whitespace-nowrap">
|
||
<span :class="key.status === 'active' ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'"
|
||
class="px-2 py-1 text-xs font-medium rounded-full">
|
||
{{ key.status === 'active' ? '有效' : '禁用' }}
|
||
</span>
|
||
</td>
|
||
<td class="px-6 py-4 whitespace-nowrap text-sm space-x-2">
|
||
<button @click="viewKeyDetail(key)" class="text-purple-600 hover:text-purple-900">查看</button>
|
||
<button v-if="key.membership_type === 'pro'" @click="addQuota(key)" class="text-green-600 hover:text-green-900">充值</button>
|
||
<button @click="extendKey(key)" class="text-yellow-600 hover:text-yellow-900">延期</button>
|
||
<button v-if="key.status === 'active'" @click="disableKey(key)" class="text-orange-600 hover:text-orange-900">禁用</button>
|
||
<button v-else @click="enableKey(key)" class="text-green-600 hover:text-green-900">启用</button>
|
||
<button @click="editKey(key)" class="text-blue-600 hover:text-blue-900">编辑</button>
|
||
<button @click="deleteKey(key.id)" class="text-red-600 hover:text-red-900">删除</button>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 全局设置 -->
|
||
<div v-if="currentTab === 'settings'">
|
||
<div class="bg-white rounded-lg shadow p-6">
|
||
<h2 class="text-lg font-medium text-gray-900 mb-6">全局设置</h2>
|
||
|
||
<!-- 说明 -->
|
||
<div class="bg-gray-50 border border-gray-200 rounded-md p-4 mb-6">
|
||
<p class="text-sm text-gray-700">
|
||
<strong>Auto密钥</strong>:按时间限制,全局控制换号频率<br>
|
||
<strong>Pro密钥</strong>:按额度限制,全局控制每次扣费
|
||
</p>
|
||
</div>
|
||
|
||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||
<div class="space-y-4">
|
||
<h3 class="font-medium text-gray-700 border-b pb-2">Auto密钥 (无限换号)</h3>
|
||
<div>
|
||
<label class="block text-sm font-medium text-gray-700 mb-1">换号最小间隔 (分钟)</label>
|
||
<input v-model.number="globalSettings.auto_switch_interval_minutes" type="number" min="1"
|
||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||
<p class="text-xs text-gray-500 mt-1">两次换号之间至少等待的时间</p>
|
||
</div>
|
||
<div>
|
||
<label class="block text-sm font-medium text-gray-700 mb-1">每天最大换号次数</label>
|
||
<input v-model.number="globalSettings.auto_max_switches_per_day" type="number" min="1"
|
||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||
<p class="text-xs text-gray-500 mt-1">每天0点重置计数</p>
|
||
</div>
|
||
</div>
|
||
<div class="space-y-4">
|
||
<h3 class="font-medium text-gray-700 border-b pb-2">Pro密钥 (高级模型)</h3>
|
||
<div>
|
||
<label class="block text-sm font-medium text-gray-700 mb-1">每次换号扣除额度</label>
|
||
<input v-model.number="globalSettings.pro_quota_cost" type="number" min="1"
|
||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||
<p class="text-xs text-gray-500 mt-1">例如:50点/次,500点总额度可换10次</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="mt-6 flex justify-end">
|
||
<button @click="saveSettings" class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">
|
||
保存设置
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 批量补偿 -->
|
||
<div v-if="currentTab === 'compensate'">
|
||
<div class="bg-white rounded-lg shadow p-6">
|
||
<h2 class="text-lg font-medium text-gray-900 mb-4">批量补偿</h2>
|
||
|
||
<!-- 说明 -->
|
||
<div class="bg-blue-50 border border-blue-200 rounded-md p-4 mb-6">
|
||
<p class="text-sm text-blue-800 font-medium mb-2">补偿逻辑说明:</p>
|
||
<ul class="text-sm text-blue-700 list-disc list-inside space-y-1">
|
||
<li>筛选:在指定日期之前激活 且 在指定日期还未过期的密钥</li>
|
||
<li>未过期的卡:直接在过期时间上增加天数</li>
|
||
<li>已过期的卡(符合条件):恢复使用,过期时间 = 今天 + 补偿天数</li>
|
||
</ul>
|
||
</div>
|
||
|
||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
||
<div>
|
||
<label class="block text-sm font-medium text-gray-700 mb-1">套餐类型</label>
|
||
<select v-model="compensateForm.membership_type"
|
||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||
<option value="">全部</option>
|
||
<option value="pro">Pro (高级模型)</option>
|
||
<option value="free">Free (无限Auto)</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label class="block text-sm font-medium text-gray-700 mb-1">在此日期之前激活</label>
|
||
<input v-model="compensateForm.activated_before" type="date"
|
||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||
<p class="text-xs text-gray-500 mt-1">例如:12月4号之前激活 填 2024-12-05</p>
|
||
</div>
|
||
<div>
|
||
<label class="block text-sm font-medium text-gray-700 mb-1">在此日期还未过期</label>
|
||
<input v-model="compensateForm.not_expired_on" type="date"
|
||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||
<p class="text-xs text-gray-500 mt-1">例如:12月4号还没过期 填 2024-12-04</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
||
<div>
|
||
<label class="block text-sm font-medium text-gray-700 mb-1">延长天数</label>
|
||
<input v-model.number="compensateForm.extend_days" type="number" min="0"
|
||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||
<p class="text-xs text-gray-500 mt-1">Auto和Pro都可延长</p>
|
||
</div>
|
||
<div>
|
||
<label class="block text-sm font-medium text-gray-700 mb-1">增加额度 (仅Pro)</label>
|
||
<input v-model.number="compensateForm.add_quota" type="number" min="0"
|
||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||
<p class="text-xs text-gray-500 mt-1">仅对Pro密钥有效</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="flex space-x-4 mb-6">
|
||
<button @click="previewCompensate" class="px-4 py-2 bg-gray-600 text-white rounded-md hover:bg-gray-700">
|
||
预览
|
||
</button>
|
||
<button @click="executeCompensate" class="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700">
|
||
执行补偿
|
||
</button>
|
||
</div>
|
||
|
||
<!-- 预览结果 -->
|
||
<div v-if="compensatePreview" class="mt-4 p-4 bg-gray-50 rounded-md">
|
||
<p class="font-medium text-gray-700 mb-2">{{ compensatePreview.message }}</p>
|
||
<div v-if="compensatePreview.keys && compensatePreview.keys.length > 0" class="overflow-x-auto">
|
||
<table class="min-w-full text-sm">
|
||
<thead>
|
||
<tr class="text-left text-gray-500">
|
||
<th class="py-1 pr-4">ID</th>
|
||
<th class="py-1 pr-4">激活码</th>
|
||
<th class="py-1 pr-4">类型</th>
|
||
<th class="py-1 pr-4">激活时间</th>
|
||
<th class="py-1 pr-4">到期时间</th>
|
||
<th class="py-1">状态</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="k in compensatePreview.keys" :key="k.id">
|
||
<td class="py-1 pr-4">{{ k.id }}</td>
|
||
<td class="py-1 pr-4"><code class="bg-gray-200 px-1 rounded">{{ k.key }}</code></td>
|
||
<td class="py-1 pr-4">{{ k.membership_type === 'pro' ? 'Pro' : 'Auto' }}</td>
|
||
<td class="py-1 pr-4">{{ k.activated_at }}</td>
|
||
<td class="py-1 pr-4">{{ k.expire_at }}</td>
|
||
<td class="py-1">
|
||
<span :class="k.is_expired ? 'text-red-600' : 'text-green-600'">
|
||
{{ k.is_expired ? '已过期' : '有效' }}
|
||
</span>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 执行结果 -->
|
||
<div v-if="compensateResult" class="mt-4 p-4 rounded-md" :class="compensateResult.failed > 0 ? 'bg-yellow-50' : 'bg-green-50'">
|
||
<p class="font-medium">补偿完成</p>
|
||
<p class="text-sm">
|
||
匹配: {{ compensateResult.total_matched }} 个,
|
||
成功: {{ compensateResult.success }} 个,
|
||
失败: {{ compensateResult.failed }} 个
|
||
<span v-if="compensateResult.recovered > 0" class="text-blue-600">
|
||
(其中 {{ compensateResult.recovered }} 个已过期卡被恢复使用)
|
||
</span>
|
||
</p>
|
||
<ul v-if="compensateResult.errors && compensateResult.errors.length > 0" class="text-sm text-red-600 mt-2">
|
||
<li v-for="err in compensateResult.errors" :key="err">{{ err }}</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 使用日志 -->
|
||
<div v-if="currentTab === 'logs'">
|
||
<div class="bg-white rounded-lg shadow">
|
||
<div class="px-6 py-4 border-b border-gray-200 flex justify-between items-center">
|
||
<h2 class="text-lg font-medium text-gray-900">使用日志</h2>
|
||
<div class="flex space-x-2">
|
||
<select v-model="logFilter.action" @change="loadLogs"
|
||
class="px-3 py-2 border border-gray-300 rounded-md text-sm">
|
||
<option value="">全部操作</option>
|
||
<option value="verify">验证</option>
|
||
<option value="switch">换号</option>
|
||
</select>
|
||
<button @click="loadLogs" class="px-4 py-2 bg-gray-600 text-white rounded-md hover:bg-gray-700 text-sm">
|
||
刷新
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div class="overflow-x-auto">
|
||
<table class="min-w-full divide-y divide-gray-200">
|
||
<thead class="bg-gray-50">
|
||
<tr>
|
||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">时间</th>
|
||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">激活码</th>
|
||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">操作</th>
|
||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">IP</th>
|
||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">状态</th>
|
||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">消息</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody class="bg-white divide-y divide-gray-200">
|
||
<tr v-for="log in logs" :key="log.id">
|
||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ log.created_at }}</td>
|
||
<td class="px-6 py-4 whitespace-nowrap">
|
||
<code class="text-sm bg-gray-100 px-2 py-1 rounded">{{ log.key_preview }}</code>
|
||
</td>
|
||
<td class="px-6 py-4 whitespace-nowrap">
|
||
<span :class="log.action === 'switch' ? 'bg-blue-100 text-blue-800' : 'bg-gray-100 text-gray-800'"
|
||
class="px-2 py-1 text-xs font-medium rounded-full">
|
||
{{ log.action === 'switch' ? '换号' : '验证' }}
|
||
</span>
|
||
</td>
|
||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ log.ip_address || '-' }}</td>
|
||
<td class="px-6 py-4 whitespace-nowrap">
|
||
<span :class="log.success ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'"
|
||
class="px-2 py-1 text-xs font-medium rounded-full">
|
||
{{ log.success ? '成功' : '失败' }}
|
||
</span>
|
||
</td>
|
||
<td class="px-6 py-4 text-sm text-gray-500">{{ log.message || '-' }}</td>
|
||
</tr>
|
||
<tr v-if="logs.length === 0">
|
||
<td colspan="6" class="px-6 py-4 text-center text-gray-500">暂无日志</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 账号编辑弹窗 -->
|
||
<div v-if="showAccountModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||
<div class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4">
|
||
<div class="px-6 py-4 border-b border-gray-200">
|
||
<h3 class="text-lg font-medium text-gray-900">{{ editingAccount ? '编辑账号' : '添加账号' }}</h3>
|
||
</div>
|
||
<form @submit.prevent="saveAccount" class="p-6 space-y-4">
|
||
<div>
|
||
<label class="block text-sm font-medium text-gray-700 mb-1">邮箱</label>
|
||
<input v-model="accountForm.email" type="email" required
|
||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||
</div>
|
||
<div>
|
||
<label class="block text-sm font-medium text-gray-700 mb-1">Access Token</label>
|
||
<textarea v-model="accountForm.access_token" required rows="3"
|
||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"></textarea>
|
||
</div>
|
||
<div>
|
||
<label class="block text-sm font-medium text-gray-700 mb-1">Refresh Token</label>
|
||
<textarea v-model="accountForm.refresh_token" rows="2"
|
||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"></textarea>
|
||
</div>
|
||
<div>
|
||
<label class="block text-sm font-medium text-gray-700 mb-1">WorkosCursorSessionToken</label>
|
||
<textarea v-model="accountForm.workos_session_token" rows="2" placeholder="可选,用于刷新 Token"
|
||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"></textarea>
|
||
</div>
|
||
<div>
|
||
<label class="block text-sm font-medium text-gray-700 mb-1">会员类型</label>
|
||
<select v-model="accountForm.membership_type"
|
||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||
<option value="pro">Pro</option>
|
||
<option value="free">Free</option>
|
||
</select>
|
||
</div>
|
||
<div class="flex justify-end space-x-3 pt-4">
|
||
<button type="button" @click="showAccountModal = false"
|
||
class="px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50">取消</button>
|
||
<button type="submit"
|
||
class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">保存</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 激活码编辑弹窗 -->
|
||
<div v-if="showKeyModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||
<div class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4 max-h-[90vh] overflow-y-auto">
|
||
<div class="px-6 py-4 border-b border-gray-200">
|
||
<h3 class="text-lg font-medium text-gray-900">{{ editingKey ? '编辑激活码' : '生成激活码' }}</h3>
|
||
</div>
|
||
<form @submit.prevent="saveKey" class="p-6 space-y-4">
|
||
<div v-if="!editingKey">
|
||
<label class="block text-sm font-medium text-gray-700 mb-1">生成数量</label>
|
||
<input v-model.number="keyForm.count" type="number" min="1" max="100"
|
||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||
</div>
|
||
<div>
|
||
<label class="block text-sm font-medium text-gray-700 mb-1">套餐类型</label>
|
||
<select v-model="keyForm.membership_type"
|
||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||
<option value="pro">Pro (高级模型) - 按额度计费</option>
|
||
<option value="free">Auto (无限换号) - 按时间计费</option>
|
||
</select>
|
||
</div>
|
||
|
||
<!-- Pro密钥: 只设置总额度 -->
|
||
<div v-if="keyForm.membership_type === 'pro'">
|
||
<label class="block text-sm font-medium text-gray-700 mb-1">总额度</label>
|
||
<input v-model.number="keyForm.quota" type="number" min="1"
|
||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||
<p class="text-xs text-gray-500 mt-1">每次换号扣除额度由全局设置控制</p>
|
||
</div>
|
||
|
||
<!-- Auto密钥: 只设置有效天数 -->
|
||
<div v-if="keyForm.membership_type === 'free'">
|
||
<label class="block text-sm font-medium text-gray-700 mb-1">有效天数</label>
|
||
<select v-model.number="keyForm.valid_days"
|
||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||
<option :value="0">永久</option>
|
||
<option :value="1">1天</option>
|
||
<option :value="7">7天</option>
|
||
<option :value="30">30天</option>
|
||
<option :value="90">90天</option>
|
||
<option :value="365">365天</option>
|
||
</select>
|
||
<p class="text-xs text-gray-500 mt-1">首次使用激活码时开始计时,换号频率由全局设置控制</p>
|
||
</div>
|
||
|
||
<div>
|
||
<label class="block text-sm font-medium text-gray-700 mb-1">最大设备数</label>
|
||
<input v-model.number="keyForm.max_devices" type="number" min="1" max="10"
|
||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||
<p class="text-xs text-gray-500 mt-1">同一激活码最多绑定的设备数量</p>
|
||
</div>
|
||
<div>
|
||
<label class="block text-sm font-medium text-gray-700 mb-1">备注</label>
|
||
<input v-model="keyForm.remark" type="text"
|
||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||
</div>
|
||
<div class="flex justify-end space-x-3 pt-4">
|
||
<button type="button" @click="showKeyModal = false"
|
||
class="px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50">取消</button>
|
||
<button type="submit"
|
||
class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">保存</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 充值额度弹窗 -->
|
||
<div v-if="showQuotaModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||
<div class="bg-white rounded-lg shadow-xl w-full max-w-sm mx-4">
|
||
<div class="px-6 py-4 border-b border-gray-200">
|
||
<h3 class="text-lg font-medium text-gray-900">充值额度</h3>
|
||
</div>
|
||
<div class="p-6 space-y-4">
|
||
<p class="text-sm text-gray-600">当前额度: {{ quotaTarget?.quota - quotaTarget?.quota_used }} / {{ quotaTarget?.quota }}</p>
|
||
<div>
|
||
<label class="block text-sm font-medium text-gray-700 mb-1">增加额度</label>
|
||
<input v-model.number="addQuotaAmount" type="number" min="1"
|
||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||
</div>
|
||
<div class="flex justify-end space-x-3 pt-4">
|
||
<button type="button" @click="showQuotaModal = false"
|
||
class="px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50">取消</button>
|
||
<button @click="submitAddQuota"
|
||
class="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700">充值</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 批量导入弹窗 -->
|
||
<div v-if="showImportModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||
<div class="bg-white rounded-lg shadow-xl w-full max-w-2xl mx-4">
|
||
<div class="px-6 py-4 border-b border-gray-200">
|
||
<h3 class="text-lg font-medium text-gray-900">批量导入账号</h3>
|
||
</div>
|
||
<div class="p-6">
|
||
<p class="text-sm text-gray-600 mb-4">请输入 JSON 格式的账号数据,每行一个账号对象:</p>
|
||
<textarea v-model="importData" rows="10" placeholder='[
|
||
{"email": "xxx@example.com", "access_token": "...", "refresh_token": "...", "membership_type": "pro"},
|
||
...
|
||
]'
|
||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 font-mono text-sm"></textarea>
|
||
<div v-if="importResult" class="mt-4 p-3 rounded-md" :class="importResult.failed > 0 ? 'bg-yellow-50' : 'bg-green-50'">
|
||
<p class="text-sm">导入完成:成功 {{ importResult.success }} 个,失败 {{ importResult.failed }} 个</p>
|
||
</div>
|
||
<div class="flex justify-end space-x-3 mt-6">
|
||
<button @click="showImportModal = false; importResult = null"
|
||
class="px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50">关闭</button>
|
||
<button @click="importAccounts"
|
||
class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">导入</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 延期弹窗 -->
|
||
<div v-if="showExtendModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||
<div class="bg-white rounded-lg shadow-xl w-full max-w-sm mx-4">
|
||
<div class="px-6 py-4 border-b border-gray-200">
|
||
<h3 class="text-lg font-medium text-gray-900">延长有效期</h3>
|
||
</div>
|
||
<div class="p-6 space-y-4">
|
||
<p class="text-sm text-gray-600">
|
||
当前状态:
|
||
<span v-if="extendTarget?.expire_at">{{ formatDate(extendTarget.expire_at) }}</span>
|
||
<span v-else-if="extendTarget?.first_activated_at">{{ extendTarget.valid_days }}天 (已激活)</span>
|
||
<span v-else>{{ extendTarget?.valid_days }}天 (未激活)</span>
|
||
</p>
|
||
<div>
|
||
<label class="block text-sm font-medium text-gray-700 mb-1">延长天数</label>
|
||
<input v-model.number="extendDays" type="number" min="1"
|
||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||
</div>
|
||
<div class="flex justify-end space-x-3 pt-4">
|
||
<button type="button" @click="showExtendModal = false"
|
||
class="px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50">取消</button>
|
||
<button @click="submitExtend"
|
||
class="px-4 py-2 bg-yellow-600 text-white rounded-md hover:bg-yellow-700">延期</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 激活码详情弹窗 -->
|
||
<div v-if="showKeyDetailModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||
<div class="bg-white rounded-lg shadow-xl w-full max-w-3xl mx-4 max-h-[90vh] overflow-y-auto">
|
||
<div class="px-6 py-4 border-b border-gray-200 flex justify-between items-center">
|
||
<h3 class="text-lg font-medium text-gray-900">激活码详情</h3>
|
||
<button @click="showKeyDetailModal = false" class="text-gray-400 hover:text-gray-600">
|
||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
<div class="p-6" v-if="keyDetail">
|
||
<!-- 基本信息 -->
|
||
<div class="mb-6">
|
||
<h4 class="font-medium text-gray-700 mb-2">基本信息</h4>
|
||
<div class="bg-gray-50 rounded-md p-4 grid grid-cols-2 gap-4 text-sm">
|
||
<div><span class="text-gray-500">激活码:</span><code class="bg-gray-200 px-2 py-1 rounded">{{ keyDetail.key }}</code></div>
|
||
<div><span class="text-gray-500">类型:</span>{{ keyDetail.membership_type === 'pro' ? 'Pro (高级模型)' : 'Auto (无限换号)' }}</div>
|
||
<div><span class="text-gray-500">状态:</span>{{ keyDetail.status === 'active' ? '有效' : '禁用' }}</div>
|
||
<div><span class="text-gray-500">激活时间:</span>{{ keyDetail.first_activated_at ? formatDate(keyDetail.first_activated_at) : '未激活' }}</div>
|
||
<div><span class="text-gray-500">过期时间:</span>{{ keyDetail.expire_at ? formatDate(keyDetail.expire_at) : '永久' }}</div>
|
||
<div><span class="text-gray-500">换号次数:</span>{{ keyDetail.switch_count }}</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 绑定设备 -->
|
||
<div class="mb-6">
|
||
<h4 class="font-medium text-gray-700 mb-2">绑定设备 ({{ keyDevices.length }}/{{ keyDetail.max_devices }})</h4>
|
||
<div class="bg-gray-50 rounded-md overflow-hidden">
|
||
<table class="min-w-full text-sm" v-if="keyDevices.length > 0">
|
||
<thead class="bg-gray-100">
|
||
<tr>
|
||
<th class="px-4 py-2 text-left text-gray-600">设备ID</th>
|
||
<th class="px-4 py-2 text-left text-gray-600">绑定时间</th>
|
||
<th class="px-4 py-2 text-left text-gray-600">最后活跃</th>
|
||
<th class="px-4 py-2 text-left text-gray-600">操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="device in keyDevices" :key="device.id" class="border-t border-gray-200">
|
||
<td class="px-4 py-2"><code class="text-xs bg-gray-200 px-1 rounded">{{ device.device_id.slice(0, 16) }}...</code></td>
|
||
<td class="px-4 py-2 text-gray-500">{{ device.created_at }}</td>
|
||
<td class="px-4 py-2 text-gray-500">{{ device.last_active_at || '-' }}</td>
|
||
<td class="px-4 py-2">
|
||
<button @click="deleteDevice(device.id)" class="text-red-600 hover:text-red-900 text-xs">解绑</button>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
<p v-else class="px-4 py-3 text-gray-500 text-center">暂无绑定设备</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 使用记录 -->
|
||
<div>
|
||
<h4 class="font-medium text-gray-700 mb-2">使用记录</h4>
|
||
<div class="bg-gray-50 rounded-md overflow-hidden">
|
||
<table class="min-w-full text-sm" v-if="keyLogs.length > 0">
|
||
<thead class="bg-gray-100">
|
||
<tr>
|
||
<th class="px-4 py-2 text-left text-gray-600">时间</th>
|
||
<th class="px-4 py-2 text-left text-gray-600">操作</th>
|
||
<th class="px-4 py-2 text-left text-gray-600">IP</th>
|
||
<th class="px-4 py-2 text-left text-gray-600">状态</th>
|
||
<th class="px-4 py-2 text-left text-gray-600">消息</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="log in keyLogs" :key="log.id" class="border-t border-gray-200">
|
||
<td class="px-4 py-2 text-gray-500">{{ log.created_at }}</td>
|
||
<td class="px-4 py-2">
|
||
<span :class="log.action === 'switch' ? 'bg-blue-100 text-blue-800' : 'bg-gray-100 text-gray-800'" class="px-2 py-0.5 text-xs rounded-full">
|
||
{{ log.action === 'switch' ? '换号' : '验证' }}
|
||
</span>
|
||
</td>
|
||
<td class="px-4 py-2 text-gray-500">{{ log.ip_address || '-' }}</td>
|
||
<td class="px-4 py-2">
|
||
<span :class="log.success ? 'text-green-600' : 'text-red-600'">{{ log.success ? '成功' : '失败' }}</span>
|
||
</td>
|
||
<td class="px-4 py-2 text-gray-500">{{ log.message || '-' }}</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
<p v-else class="px-4 py-3 text-gray-500 text-center">暂无使用记录</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
const { createApp, ref, reactive, onMounted, watch } = Vue
|
||
|
||
createApp({
|
||
setup() {
|
||
// 状态
|
||
const isLoggedIn = ref(false)
|
||
const currentUser = ref('')
|
||
const token = ref('')
|
||
const currentTab = ref('dashboard')
|
||
const loginError = ref('')
|
||
|
||
// 数据
|
||
const stats = ref({ total_accounts: 0, active_accounts: 0, pro_accounts: 0, total_keys: 0, active_keys: 0, today_usage: 0 })
|
||
const accounts = ref([])
|
||
const keys = ref([])
|
||
const logs = ref([])
|
||
const logFilter = reactive({ action: '' })
|
||
|
||
// 搜索
|
||
const keySearch = reactive({ search: '', status: '', activated: '', membership_type: '' })
|
||
|
||
// 表单
|
||
const loginForm = reactive({ username: '', password: '' })
|
||
const accountForm = reactive({ email: '', access_token: '', refresh_token: '', workos_session_token: '', membership_type: 'pro' })
|
||
const keyForm = reactive({
|
||
count: 1,
|
||
membership_type: 'pro',
|
||
quota: 500, // Pro用
|
||
valid_days: 30, // Auto用
|
||
max_devices: 2,
|
||
remark: ''
|
||
})
|
||
|
||
// 弹窗
|
||
const showAccountModal = ref(false)
|
||
const showKeyModal = ref(false)
|
||
const showImportModal = ref(false)
|
||
const showQuotaModal = ref(false)
|
||
const showExtendModal = ref(false)
|
||
const editingAccount = ref(null)
|
||
const editingKey = ref(null)
|
||
const importData = ref('')
|
||
const importResult = ref(null)
|
||
const quotaTarget = ref(null)
|
||
const addQuotaAmount = ref(100)
|
||
const extendTarget = ref(null)
|
||
const extendDays = ref(7)
|
||
|
||
// 激活码详情
|
||
const showKeyDetailModal = ref(false)
|
||
const keyDetail = ref(null)
|
||
const keyDevices = ref([])
|
||
const keyLogs = ref([])
|
||
|
||
// 全局设置
|
||
const globalSettings = reactive({
|
||
auto_switch_interval_minutes: 20,
|
||
auto_max_switches_per_day: 50,
|
||
pro_quota_cost: 50
|
||
})
|
||
|
||
// 批量补偿
|
||
const compensateForm = reactive({
|
||
membership_type: '',
|
||
activated_before: '',
|
||
not_expired_on: '',
|
||
extend_days: 0,
|
||
add_quota: 0
|
||
})
|
||
const compensatePreview = ref(null)
|
||
const compensateResult = ref(null)
|
||
|
||
// API 请求封装
|
||
const api = axios.create({ baseURL: '/admin' })
|
||
api.interceptors.request.use(config => {
|
||
if (token.value) {
|
||
config.headers.Authorization = `Bearer ${token.value}`
|
||
}
|
||
return config
|
||
})
|
||
|
||
// 登录
|
||
const login = async () => {
|
||
try {
|
||
const res = await axios.post('/admin/login', loginForm)
|
||
token.value = res.data.access_token
|
||
localStorage.setItem('token', token.value)
|
||
isLoggedIn.value = true
|
||
currentUser.value = loginForm.username
|
||
loginError.value = ''
|
||
loadData()
|
||
} catch (e) {
|
||
loginError.value = e.response?.data?.detail || '登录失败'
|
||
}
|
||
}
|
||
|
||
// 登出
|
||
const logout = () => {
|
||
token.value = ''
|
||
localStorage.removeItem('token')
|
||
isLoggedIn.value = false
|
||
}
|
||
|
||
// 加载数据
|
||
const loadData = async () => {
|
||
try {
|
||
const [statsRes, accountsRes, keysRes] = await Promise.all([
|
||
api.get('/dashboard'),
|
||
api.get('/accounts'),
|
||
api.get('/keys')
|
||
])
|
||
stats.value = statsRes.data
|
||
accounts.value = accountsRes.data
|
||
keys.value = keysRes.data
|
||
} catch (e) {
|
||
console.error('加载数据失败', e)
|
||
}
|
||
}
|
||
|
||
// 账号操作
|
||
const editAccount = (account) => {
|
||
editingAccount.value = account
|
||
Object.assign(accountForm, account)
|
||
showAccountModal.value = true
|
||
}
|
||
|
||
const saveAccount = async () => {
|
||
try {
|
||
if (editingAccount.value) {
|
||
await api.put(`/accounts/${editingAccount.value.id}`, accountForm)
|
||
} else {
|
||
await api.post('/accounts', accountForm)
|
||
}
|
||
showAccountModal.value = false
|
||
loadData()
|
||
} catch (e) {
|
||
alert(e.response?.data?.detail || '保存失败')
|
||
}
|
||
}
|
||
|
||
const deleteAccount = async (id) => {
|
||
if (!confirm('确定删除此账号?')) return
|
||
try {
|
||
await api.delete(`/accounts/${id}`)
|
||
loadData()
|
||
} catch (e) {
|
||
alert('删除失败')
|
||
}
|
||
}
|
||
|
||
const importAccounts = async () => {
|
||
try {
|
||
const data = JSON.parse(importData.value)
|
||
const res = await api.post('/accounts/import', { accounts: data })
|
||
importResult.value = res.data
|
||
loadData()
|
||
} catch (e) {
|
||
alert('导入失败: ' + (e.response?.data?.detail || e.message))
|
||
}
|
||
}
|
||
|
||
// 激活码操作
|
||
const resetKeyForm = () => {
|
||
Object.assign(keyForm, {
|
||
count: 1,
|
||
membership_type: 'pro',
|
||
quota: 500,
|
||
valid_days: 30,
|
||
max_devices: 2,
|
||
remark: ''
|
||
})
|
||
}
|
||
|
||
const editKey = (key) => {
|
||
editingKey.value = key
|
||
Object.assign(keyForm, key)
|
||
showKeyModal.value = true
|
||
}
|
||
|
||
const saveKey = async () => {
|
||
try {
|
||
if (editingKey.value) {
|
||
await api.put(`/keys/${editingKey.value.id}`, keyForm)
|
||
} else {
|
||
await api.post('/keys', keyForm)
|
||
}
|
||
showKeyModal.value = false
|
||
loadData()
|
||
} catch (e) {
|
||
alert(e.response?.data?.detail || '保存失败')
|
||
}
|
||
}
|
||
|
||
const deleteKey = async (id) => {
|
||
if (!confirm('确定删除此激活码?')) return
|
||
try {
|
||
await api.delete(`/keys/${id}`)
|
||
loadData()
|
||
} catch (e) {
|
||
alert('删除失败')
|
||
}
|
||
}
|
||
|
||
const addQuota = (key) => {
|
||
quotaTarget.value = key
|
||
addQuotaAmount.value = 100
|
||
showQuotaModal.value = true
|
||
}
|
||
|
||
const submitAddQuota = async () => {
|
||
try {
|
||
await api.post(`/keys/${quotaTarget.value.id}/add-quota?add_quota=${addQuotaAmount.value}`)
|
||
showQuotaModal.value = false
|
||
loadData()
|
||
alert('充值成功')
|
||
} catch (e) {
|
||
alert(e.response?.data?.detail || '充值失败')
|
||
}
|
||
}
|
||
|
||
const copyKey = (key) => {
|
||
navigator.clipboard.writeText(key)
|
||
alert('已复制')
|
||
}
|
||
|
||
// 延期操作
|
||
const extendKey = (key) => {
|
||
extendTarget.value = key
|
||
extendDays.value = 7
|
||
showExtendModal.value = true
|
||
}
|
||
|
||
const submitExtend = async () => {
|
||
try {
|
||
await api.post('/keys/batch-extend', {
|
||
key_ids: [extendTarget.value.id],
|
||
extend_days: extendDays.value,
|
||
add_quota: 0
|
||
})
|
||
showExtendModal.value = false
|
||
loadData()
|
||
alert('延期成功')
|
||
} catch (e) {
|
||
alert(e.response?.data?.detail || '延期失败')
|
||
}
|
||
}
|
||
|
||
// 全局设置
|
||
const loadSettings = async () => {
|
||
try {
|
||
const res = await api.get('/settings')
|
||
Object.assign(globalSettings, res.data)
|
||
} catch (e) {
|
||
console.error('加载设置失败', e)
|
||
}
|
||
}
|
||
|
||
const saveSettings = async () => {
|
||
try {
|
||
await api.put('/settings', globalSettings)
|
||
alert('设置已保存')
|
||
} catch (e) {
|
||
alert(e.response?.data?.detail || '保存失败')
|
||
}
|
||
}
|
||
|
||
// 批量补偿
|
||
const previewCompensate = async () => {
|
||
try {
|
||
const params = new URLSearchParams()
|
||
if (compensateForm.membership_type) params.append('membership_type', compensateForm.membership_type)
|
||
if (compensateForm.activated_before) params.append('activated_before', compensateForm.activated_before)
|
||
if (compensateForm.not_expired_on) params.append('not_expired_on', compensateForm.not_expired_on)
|
||
|
||
const res = await api.get('/keys/preview-compensate?' + params.toString())
|
||
compensatePreview.value = res.data
|
||
compensateResult.value = null
|
||
} catch (e) {
|
||
alert(e.response?.data?.detail || '预览失败')
|
||
}
|
||
}
|
||
|
||
const executeCompensate = async () => {
|
||
if (!confirm('确定执行批量补偿?此操作不可撤销!')) return
|
||
try {
|
||
const params = new URLSearchParams()
|
||
if (compensateForm.membership_type) params.append('membership_type', compensateForm.membership_type)
|
||
if (compensateForm.activated_before) params.append('activated_before', compensateForm.activated_before)
|
||
if (compensateForm.not_expired_on) params.append('not_expired_on', compensateForm.not_expired_on)
|
||
params.append('extend_days', compensateForm.extend_days)
|
||
params.append('add_quota', compensateForm.add_quota)
|
||
|
||
const res = await api.post('/keys/batch-compensate?' + params.toString())
|
||
compensateResult.value = res.data
|
||
compensatePreview.value = null
|
||
loadData()
|
||
} catch (e) {
|
||
alert(e.response?.data?.detail || '补偿失败')
|
||
}
|
||
}
|
||
|
||
// 日志
|
||
const loadLogs = async () => {
|
||
try {
|
||
const params = new URLSearchParams()
|
||
if (logFilter.action) params.append('action', logFilter.action)
|
||
const res = await api.get('/logs?' + params.toString())
|
||
logs.value = res.data
|
||
} catch (e) {
|
||
console.error('加载日志失败', e)
|
||
}
|
||
}
|
||
|
||
// 激活码详情
|
||
const viewKeyDetail = async (key) => {
|
||
keyDetail.value = key
|
||
showKeyDetailModal.value = true
|
||
try {
|
||
const [devicesRes, logsRes] = await Promise.all([
|
||
api.get(`/keys/${key.id}/devices`),
|
||
api.get(`/keys/${key.id}/logs`)
|
||
])
|
||
keyDevices.value = devicesRes.data
|
||
keyLogs.value = logsRes.data
|
||
} catch (e) {
|
||
console.error('加载详情失败', e)
|
||
}
|
||
}
|
||
|
||
const deleteDevice = async (deviceId) => {
|
||
if (!confirm('确定解绑此设备?')) return
|
||
try {
|
||
await api.delete(`/devices/${deviceId}`)
|
||
keyDevices.value = keyDevices.value.filter(d => d.id !== deviceId)
|
||
} catch (e) {
|
||
alert(e.response?.data?.detail || '解绑失败')
|
||
}
|
||
}
|
||
|
||
// 搜索激活码
|
||
let searchTimeout = null
|
||
const searchKeys = () => {
|
||
clearTimeout(searchTimeout)
|
||
searchTimeout = setTimeout(async () => {
|
||
try {
|
||
const params = new URLSearchParams()
|
||
if (keySearch.search) params.append('search', keySearch.search)
|
||
if (keySearch.status) params.append('status', keySearch.status)
|
||
if (keySearch.activated) params.append('activated', keySearch.activated)
|
||
if (keySearch.membership_type) params.append('membership_type', keySearch.membership_type)
|
||
const res = await api.get('/keys?' + params.toString())
|
||
keys.value = res.data
|
||
} catch (e) {
|
||
console.error('搜索失败', e)
|
||
}
|
||
}, 300)
|
||
}
|
||
|
||
const resetKeySearch = () => {
|
||
keySearch.search = ''
|
||
keySearch.status = ''
|
||
keySearch.activated = ''
|
||
keySearch.membership_type = ''
|
||
loadData()
|
||
}
|
||
|
||
// 禁用/启用激活码
|
||
const disableKey = async (key) => {
|
||
try {
|
||
const res = await api.get(`/keys/${key.id}/usage-info`)
|
||
const info = res.data
|
||
const msg = `确定禁用此激活码?\n\n` +
|
||
`激活码: ${info.key}\n` +
|
||
`类型: ${info.membership_type === 'pro' ? 'Pro' : 'Auto'}\n` +
|
||
`激活时间: ${info.first_activated_at || '未激活'}\n` +
|
||
`已使用: ${info.used_days} 天\n` +
|
||
`换号次数: ${info.switch_count} 次\n` +
|
||
(info.membership_type === 'pro' ? `已用额度: ${info.quota_used}/${info.quota}\n` : '') +
|
||
`绑定设备: ${info.device_count}/${info.max_devices}`
|
||
|
||
if (confirm(msg)) {
|
||
const disableRes = await api.post(`/keys/${key.id}/disable`)
|
||
alert(`禁用成功!\n已使用 ${disableRes.data.used_days} 天,换号 ${disableRes.data.switch_count} 次`)
|
||
searchKeys()
|
||
}
|
||
} catch (e) {
|
||
alert(e.response?.data?.detail || '操作失败')
|
||
}
|
||
}
|
||
|
||
const enableKey = async (key) => {
|
||
if (!confirm('确定启用此激活码?')) return
|
||
try {
|
||
await api.post(`/keys/${key.id}/enable`)
|
||
searchKeys()
|
||
} catch (e) {
|
||
alert(e.response?.data?.detail || '操作失败')
|
||
}
|
||
}
|
||
|
||
// 辅助函数
|
||
const getStatusClass = (status) => {
|
||
const map = {
|
||
'active': 'bg-green-100 text-green-800',
|
||
'in_use': 'bg-blue-100 text-blue-800',
|
||
'disabled': 'bg-red-100 text-red-800',
|
||
'expired': 'bg-gray-100 text-gray-800'
|
||
}
|
||
return map[status] || 'bg-gray-100 text-gray-800'
|
||
}
|
||
|
||
const getStatusText = (status) => {
|
||
const map = { 'active': '可用', 'in_use': '使用中', 'disabled': '禁用', 'expired': '过期' }
|
||
return map[status] || status
|
||
}
|
||
|
||
const formatDate = (date) => {
|
||
return new Date(date).toLocaleString('zh-CN')
|
||
}
|
||
|
||
// 初始化
|
||
onMounted(() => {
|
||
const savedToken = localStorage.getItem('token')
|
||
if (savedToken) {
|
||
token.value = savedToken
|
||
isLoggedIn.value = true
|
||
currentUser.value = 'admin'
|
||
loadData()
|
||
}
|
||
})
|
||
|
||
// 监听标签页切换
|
||
watch(currentTab, (newTab) => {
|
||
if (newTab !== 'settings' && newTab !== 'compensate') {
|
||
loadData()
|
||
}
|
||
})
|
||
|
||
return {
|
||
isLoggedIn, currentUser, currentTab, loginError, loginForm,
|
||
stats, accounts, keys, logs, logFilter, keySearch,
|
||
accountForm, keyForm,
|
||
showAccountModal, showKeyModal, showImportModal, showQuotaModal, showExtendModal,
|
||
showKeyDetailModal, keyDetail, keyDevices, keyLogs,
|
||
editingAccount, editingKey,
|
||
importData, importResult,
|
||
quotaTarget, addQuotaAmount,
|
||
extendTarget, extendDays,
|
||
globalSettings,
|
||
compensateForm, compensatePreview, compensateResult,
|
||
login, logout, loadData, loadLogs, searchKeys, resetKeySearch,
|
||
editAccount, saveAccount, deleteAccount, importAccounts,
|
||
resetKeyForm, editKey, saveKey, deleteKey, addQuota, submitAddQuota, copyKey,
|
||
viewKeyDetail, deleteDevice, disableKey, enableKey,
|
||
extendKey, submitExtend,
|
||
loadSettings, saveSettings,
|
||
previewCompensate, executeCompensate,
|
||
getStatusClass, getStatusText, formatDate
|
||
}
|
||
}
|
||
}).mount('#app')
|
||
</script>
|
||
</body>
|
||
</html>
|