Files
cursornew2026/backend/templates/index.html
huangzhenpc ac19d029da backend v2.1: 公告管理功能 + 系统重构
- 新增 Announcement 数据模型,支持公告的增删改查
- 后台管理新增"公告管理"Tab(创建/编辑/删除/启用禁用)
- 客户端 /api/announcement 改为从数据库读取
- 账号服务重构,新增无感换号、自动分析等功能
- 新增后台任务调度器、数据库迁移脚本
- Schema/Service/Config 全面升级至 v2.1

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 19:58:05 +08:00

1985 lines
116 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>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 = 'announcements'; loadAnnouncements()"
:class="currentTab === 'announcements' ? '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'">
<!-- 外部API说明 -->
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-4">
<div class="flex items-start">
<div class="flex-shrink-0">
<svg class="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</div>
<div class="ml-3 flex-1">
<h3 class="text-sm font-medium text-blue-800">外部系统批量上传接口</h3>
<div class="mt-2 text-sm text-blue-700">
<p class="font-mono bg-blue-100 px-2 py-1 rounded mb-2">POST /admin/external/accounts/batch</p>
<p class="mb-1"><strong>Header:</strong> X-API-Token: hb-api-token-change-in-production</p>
<details class="cursor-pointer">
<summary class="text-blue-800 hover:text-blue-900">查看请求格式 ▼</summary>
<pre class="mt-2 bg-gray-800 text-green-400 p-3 rounded text-xs overflow-x-auto">{
"accounts": [
{
"email": "user@example.com",
"access_token": "eyJhbG...",
"refresh_token": "xxx", // 可选
"workos_session_token": "xxx", // 可选
"membership_type": "free", // free=Free账号, pro=Pro账号
"remark": "备注" // 可选
}
],
"update_existing": true // 是否更新已存在的账号
}</pre>
</details>
</div>
</div>
</div>
</div>
<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">账号列表 (共 {{ accountsTotal }} 条)</h2>
<div class="space-x-2">
<button v-if="selectedAccounts.length > 0" @click="batchEnableAccounts"
class="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 text-sm">
批量启用 ({{ selectedAccounts.length }})
</button>
<button v-if="selectedAccounts.length > 0" @click="batchDisableAccounts"
class="px-4 py-2 bg-orange-600 text-white rounded-md hover:bg-orange-700 text-sm">
批量禁用 ({{ selectedAccounts.length }})
</button>
<button v-if="selectedAccounts.length > 0" @click="batchDeleteAccounts"
class="px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 text-sm">
批量删除 ({{ selectedAccounts.length }})
</button>
<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="openCreateAccountModal"
class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 text-sm">
添加账号
</button>
</div>
</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">
<input type="checkbox" @change="toggleSelectAll" :checked="isAllSelected"
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500">
</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">Token</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">
<input type="checkbox" :value="account.id" v-model="selectedAccounts"
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500">
</td>
<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">
{{ formatMembershipLabel(account.membership_type) }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm space-x-1">
<button @click="copyToken(account.access_token, 'AccessToken')"
class="px-2 py-1 bg-blue-100 text-blue-700 rounded text-xs hover:bg-blue-200" title="复制 Access Token">
AT
</button>
<button v-if="account.refresh_token" @click="copyToken(account.refresh_token, 'RefreshToken')"
class="px-2 py-1 bg-green-100 text-green-700 rounded text-xs hover:bg-green-200" title="复制 Refresh Token">
RT
</button>
<button v-if="account.workos_session_token" @click="copyToken(account.workos_session_token, 'WorkosSessionToken')"
class="px-2 py-1 bg-purple-100 text-purple-700 rounded text-xs hover:bg-purple-200" title="复制 Workos Session Token">
WT
</button>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span @click="toggleAccountStatus(account)" :class="getStatusClass(account.status)"
class="px-2 py-1 text-xs font-medium rounded-full cursor-pointer hover:opacity-80 transition"
:title="'点击切换状态'">
{{ 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="openAnalyzeModal(account)"
class="text-green-600 hover:text-green-900">
手动检测
</button>
<span class="text-gray-300">|</span>
<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 class="px-6 py-4 border-t border-gray-200 flex items-center justify-between">
<div class="flex items-center space-x-2">
<span class="text-sm text-gray-700">每页显示</span>
<select v-model="accountsPagination.pageSize" @change="loadAccounts"
class="px-3 py-1 border border-gray-300 rounded-md text-sm">
<option :value="10">10</option>
<option :value="20">20</option>
<option :value="50">50</option>
<option :value="100">100</option>
</select>
<span class="text-sm text-gray-700"></span>
</div>
<div class="flex items-center space-x-2">
<button @click="accountsPagination.currentPage = 1; loadAccounts()"
:disabled="accountsPagination.currentPage === 1"
:class="accountsPagination.currentPage === 1 ? 'opacity-50 cursor-not-allowed' : 'hover:bg-gray-100'"
class="px-3 py-1 border border-gray-300 rounded-md text-sm">
首页
</button>
<button @click="accountsPagination.currentPage--; loadAccounts()"
:disabled="accountsPagination.currentPage === 1"
:class="accountsPagination.currentPage === 1 ? 'opacity-50 cursor-not-allowed' : 'hover:bg-gray-100'"
class="px-3 py-1 border border-gray-300 rounded-md text-sm">
上一页
</button>
<span class="text-sm text-gray-700">
第 {{ accountsPagination.currentPage }} / {{ accountsPagination.totalPages }} 页
</span>
<button @click="accountsPagination.currentPage++; loadAccounts()"
:disabled="accountsPagination.currentPage >= accountsPagination.totalPages"
:class="accountsPagination.currentPage >= accountsPagination.totalPages ? 'opacity-50 cursor-not-allowed' : 'hover:bg-gray-100'"
class="px-3 py-1 border border-gray-300 rounded-md text-sm">
下一页
</button>
<button @click="accountsPagination.currentPage = accountsPagination.totalPages; loadAccounts()"
:disabled="accountsPagination.currentPage >= accountsPagination.totalPages"
:class="accountsPagination.currentPage >= accountsPagination.totalPages ? 'opacity-50 cursor-not-allowed' : 'hover:bg-gray-100'"
class="px-3 py-1 border border-gray-300 rounded-md text-sm">
末页
</button>
</div>
</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">激活码列表 (共 {{ keysTotal }} 条)</h2>
<div class="space-x-2">
<button v-if="selectedKeys.length > 0" @click="batchCopyKeys"
class="px-4 py-2 bg-purple-600 text-white rounded-md hover:bg-purple-700 text-sm">
批量复制 ({{ selectedKeys.length }})
</button>
<button v-if="selectedKeys.length > 0" @click="batchEnableKeys"
class="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 text-sm">
批量启用 ({{ selectedKeys.length }})
</button>
<button v-if="selectedKeys.length > 0" @click="batchDisableKeys"
class="px-4 py-2 bg-orange-600 text-white rounded-md hover:bg-orange-700 text-sm">
批量禁用 ({{ selectedKeys.length }})
</button>
<button v-if="selectedKeys.length > 0" @click="batchDeleteKeys"
class="px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 text-sm">
批量删除 ({{ selectedKeys.length }})
</button>
<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>
<!-- 搜索和筛选 -->
<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="auto">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">
<input type="checkbox" @change="toggleSelectAllKeys" :checked="isAllKeysSelected"
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500">
</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>
<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">
<input type="checkbox" :value="key.id" v-model="selectedKeys"
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500">
</td>
<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 class="px-6 py-4 border-t border-gray-200 flex items-center justify-between">
<div class="flex items-center space-x-2">
<span class="text-sm text-gray-700">每页显示</span>
<select v-model="keysPagination.pageSize" @change="searchKeys"
class="px-3 py-1 border border-gray-300 rounded-md text-sm">
<option :value="10">10</option>
<option :value="20">20</option>
<option :value="50">50</option>
<option :value="100">100</option>
</select>
<span class="text-sm text-gray-700"></span>
</div>
<div class="flex items-center space-x-2">
<button @click="keysPagination.currentPage = 1; searchKeys()"
:disabled="keysPagination.currentPage === 1"
:class="keysPagination.currentPage === 1 ? 'opacity-50 cursor-not-allowed' : 'hover:bg-gray-100'"
class="px-3 py-1 border border-gray-300 rounded-md text-sm">
首页
</button>
<button @click="keysPagination.currentPage--; searchKeys()"
:disabled="keysPagination.currentPage === 1"
:class="keysPagination.currentPage === 1 ? 'opacity-50 cursor-not-allowed' : 'hover:bg-gray-100'"
class="px-3 py-1 border border-gray-300 rounded-md text-sm">
上一页
</button>
<span class="text-sm text-gray-700">
第 {{ keysPagination.currentPage }} / {{ keysPagination.totalPages }} 页
</span>
<button @click="keysPagination.currentPage++; searchKeys()"
:disabled="keysPagination.currentPage >= keysPagination.totalPages"
:class="keysPagination.currentPage >= keysPagination.totalPages ? 'opacity-50 cursor-not-allowed' : 'hover:bg-gray-100'"
class="px-3 py-1 border border-gray-300 rounded-md text-sm">
下一页
</button>
<button @click="keysPagination.currentPage = keysPagination.totalPages; searchKeys()"
:disabled="keysPagination.currentPage >= keysPagination.totalPages"
:class="keysPagination.currentPage >= keysPagination.totalPages ? 'opacity-50 cursor-not-allowed' : 'hover:bg-gray-100'"
class="px-3 py-1 border border-gray-300 rounded-md text-sm">
末页
</button>
</div>
</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" 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">两次换号之间至少等待的时间0表示无限制</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">每天最大换号次数</label>
<input v-model.number="globalSettings.auto_daily_switches" 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_per_switch" 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="auto">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 === 'announcements'">
<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>
<button @click="openCreateAnnouncementModal"
class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 text-sm">
发布公告
</button>
</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="ann in announcements" :key="ann.id">
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{{ ann.title }}</td>
<td class="px-6 py-4 whitespace-nowrap">
<span :class="announcementTypeClass(ann.type)"
class="px-2 py-1 text-xs font-medium rounded-full">
{{ announcementTypeLabel(ann.type) }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<button @click="toggleAnnouncement(ann)"
:class="ann.is_active ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'"
class="px-2 py-1 text-xs font-medium rounded-full cursor-pointer hover:opacity-80 transition">
{{ ann.is_active ? '已启用' : '已禁用' }}
</button>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ formatDate(ann.created_at) }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm space-x-2">
<button @click="editAnnouncement(ann)" class="text-blue-600 hover:text-blue-900">编辑</button>
<button @click="deleteAnnouncement(ann.id)" class="text-red-600 hover:text-red-900">删除</button>
</td>
</tr>
<tr v-if="announcements.length === 0">
<td colspan="5" class="px-6 py-4 text-center text-gray-500">暂无公告</td>
</tr>
</tbody>
</table>
</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" 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>
<input v-model="accountForm.remark" type="text" placeholder="来源、部门等"
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="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="showAnalyzeModal" 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 flex items-center justify-between">
<div>
<h3 class="text-lg font-medium text-gray-900">手动检测账号</h3>
<p class="text-sm text-gray-500 mt-1">可粘贴最新的 WorkosCursorSessionToken 或 user_xxx::jwt 用于调试</p>
<p class="text-sm text-gray-600 mt-2">
当前账号: <span class="font-mono text-blue-600">{{ analyzeTarget ? analyzeTarget.email : '' }}</span>
<span class="ml-4">状态: {{ getStatusText(analyzeTarget ? analyzeTarget.status : null) }}</span>
</p>
</div>
<button @click="closeAnalyzeModal" class="text-gray-400 hover:text-gray-600">
</button>
</div>
<div class="p-6 space-y-4 max-h-[70vh] overflow-y-auto">
<div class="grid gap-3 md:grid-cols-3">
<div class="bg-gray-50 border border-gray-200 rounded-md px-3 py-2 text-sm">
<p class="text-gray-600">Workos Session</p>
<p class="mt-1 font-mono break-all text-gray-800">
{{ analyzeTarget && analyzeTarget.workos_session_token ? analyzeTarget.workos_session_token : '未保存' }}
</p>
</div>
<div class="bg-gray-50 border border-gray-200 rounded-md px-3 py-2 text-sm">
<p class="text-gray-600">Access Token</p>
<p class="mt-1 font-mono break-all text-gray-800">
{{ analyzeTarget && analyzeTarget.access_token ? analyzeTarget.access_token : '未保存' }}
</p>
</div>
<div class="bg-gray-50 border border-gray-200 rounded-md px-3 py-2 text-sm">
<p class="text-gray-600">Refresh Token</p>
<p class="mt-1 font-mono break-all text-gray-800">
{{ analyzeTarget && analyzeTarget.refresh_token ? analyzeTarget.refresh_token : '未保存' }}
</p>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">检测用 Token</label>
<textarea v-model="analyzeForm.token" rows="4"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="WorkosCursorSessionToken=... 或 user_xxx::jwt"></textarea>
<p class="text-xs text-gray-500 mt-1">若留空,将使用系统当前保存的 Token</p>
</div>
<label class="flex items-center space-x-2 text-sm text-gray-700">
<input type="checkbox" v-model="analyzeForm.saveToken"
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500">
<span>检测成功后,将本次 Token 同步到账号</span>
</label>
<div v-if="analyzeResult" class="rounded-md border px-4 py-3"
:class="analyzeResult.success ? 'border-green-200 bg-green-50' : 'border-red-200 bg-red-50'">
<p class="font-medium" :class="analyzeResult.success ? 'text-green-700' : 'text-red-700'">
{{ analyzeResult.success ? '检测成功' : '检测失败' }}
</p>
<p v-if="analyzeResult.message" class="text-sm mt-1">{{ analyzeResult.message }}</p>
<pre v-if="analyzeResult.data" class="mt-3 text-xs bg-white border rounded p-2 overflow-auto max-h-48">{{ JSON.stringify(analyzeResult.data, null, 2) }}</pre>
</div>
</div>
<div class="px-6 py-4 border-t border-gray-200 flex justify-end space-x-3">
<button @click="closeAnalyzeModal"
class="px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50">取消</button>
<button @click="submitAnalyze" :disabled="analyzeLoading"
:class="analyzeLoading ? 'bg-blue-300 cursor-not-allowed' : 'bg-blue-600 hover:bg-blue-700'"
class="px-4 py-2 text-white rounded-md">
{{ analyzeLoading ? '检测中...' : '开始检测' }}
</button>
</div>
</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="auto">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 === 'auto'">
<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 v-if="showAnnouncementModal" 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-lg mx-4">
<div class="px-6 py-4 border-b border-gray-200">
<h3 class="text-lg font-medium text-gray-900">{{ editingAnnouncement ? '编辑公告' : '发布公告' }}</h3>
</div>
<form @submit.prevent="saveAnnouncement" class="p-6 space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">标题</label>
<input v-model="announcementForm.title" type="text" required
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>
<label class="block text-sm font-medium text-gray-700 mb-1">内容</label>
<textarea v-model="announcementForm.content" rows="5" required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="公告内容,支持换行"></textarea>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">类型</label>
<select v-model="announcementForm.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="info">信息 (info)</option>
<option value="warning">警告 (warning)</option>
<option value="error">错误 (error)</option>
<option value="success">成功 (success)</option>
</select>
</div>
<div class="flex items-center space-x-2">
<input type="checkbox" v-model="announcementForm.is_active" id="ann-active"
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500">
<label for="ann-active" class="text-sm text-gray-700">启用</label>
</div>
<div class="flex justify-end space-x-3 pt-4">
<button type="button" @click="showAnnouncementModal = 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>
<script>
const { createApp, ref, reactive, computed, 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 accountsTotal = ref(0)
const selectedAccounts = ref([])
const accountsPagination = reactive({
currentPage: 1,
pageSize: 20,
totalPages: 1
})
const keys = ref([])
const keysTotal = ref(0)
const selectedKeys = ref([])
const keysPagination = reactive({
currentPage: 1,
pageSize: 20,
totalPages: 1
})
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: '',
remark: ''
})
const keyForm = reactive({
count: 1,
membership_type: 'pro',
quota: 500, // Pro用
valid_days: 30, // Auto用
max_devices: 2,
remark: ''
})
// 弹窗
const showAccountModal = ref(false)
const showAnalyzeModal = ref(false)
const showKeyModal = ref(false)
const showImportModal = ref(false)
const showQuotaModal = ref(false)
const showExtendModal = ref(false)
const editingAccount = ref(null)
const analyzeTarget = 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 announcements = ref([])
const showAnnouncementModal = ref(false)
const editingAnnouncement = ref(null)
const announcementForm = reactive({
title: '',
content: '',
type: 'info',
is_active: true
})
// 全局设置
const globalSettings = reactive({
auto_switch_interval: 0,
auto_daily_switches: 999,
pro_quota_per_switch: 1
})
// 批量补偿
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 = await api.get('/dashboard')
stats.value = statsRes.data
await Promise.all([loadAccounts(), searchKeys()])
} catch (e) {
console.error('加载数据失败', e)
}
}
// 加载账号列表(分页)
const loadAccounts = async () => {
try {
const skip = (accountsPagination.currentPage - 1) * accountsPagination.pageSize
const [accountsRes, countRes] = await Promise.all([
api.get(`/accounts?skip=${skip}&limit=${accountsPagination.pageSize}`),
api.get('/accounts/count')
])
accounts.value = accountsRes.data
accountsTotal.value = countRes.data.total
accountsPagination.totalPages = Math.ceil(accountsTotal.value / accountsPagination.pageSize)
selectedAccounts.value = []
} catch (e) {
console.error('加载账号失败', e)
}
}
// 全选/取消全选
const isAllSelected = computed(() => {
return accounts.value.length > 0 && selectedAccounts.value.length === accounts.value.length
})
const toggleSelectAll = (event) => {
if (event.target.checked) {
selectedAccounts.value = accounts.value.map(a => a.id)
} else {
selectedAccounts.value = []
}
}
// 账号操作
const openCreateAccountModal = () => {
editingAccount.value = null
resetAccountForm()
showAccountModal.value = true
}
const editAccount = (account) => {
editingAccount.value = account
prepareAccountForm(account)
showAccountModal.value = true
}
const buildAccountPayload = () => {
const workosToken = accountForm.workos_session_token?.trim() || ''
const accessToken = accountForm.access_token?.trim() || ''
if (!workosToken && !accessToken) {
throw new Error('请至少填写 Access Token 或 Workos Token')
}
return {
email: accountForm.email,
token: workosToken || accessToken,
workos_session_token: workosToken || null,
access_token: accessToken || null,
refresh_token: accountForm.refresh_token?.trim() || null,
remark: accountForm.remark?.trim() || ''
}
}
const saveAccount = async () => {
try {
const payload = buildAccountPayload()
if (editingAccount.value) {
await api.put(`/accounts/${editingAccount.value.id}`, payload)
} else {
await api.post('/accounts', payload)
}
showAccountModal.value = false
loadAccounts()
} catch (e) {
const message = e.message || e.response?.data?.detail
alert(message || '保存失败')
}
}
const deleteAccount = async (id) => {
if (!confirm('确定删除此账号?')) return
try {
await api.delete(`/accounts/${id}`)
loadAccounts()
} catch (e) {
alert('删除失败')
}
}
// 手动分析单个账号
const analyzeForm = reactive({
token: '',
saveToken: false
})
const analyzeResult = ref(null)
const analyzeLoading = ref(false)
const resetAccountForm = () => {
accountForm.email = ''
accountForm.access_token = ''
accountForm.refresh_token = ''
accountForm.workos_session_token = ''
accountForm.remark = ''
}
const prepareAccountForm = (account) => {
accountForm.email = account.email || ''
accountForm.access_token = account.access_token || ''
accountForm.refresh_token = account.refresh_token || ''
accountForm.workos_session_token = account.workos_session_token || account.token || ''
accountForm.remark = account.remark || ''
}
const openAnalyzeModal = (account) => {
analyzeTarget.value = account
analyzeForm.token = account?.workos_session_token || account?.access_token || account?.token || ''
analyzeForm.saveToken = false
analyzeResult.value = null
showAnalyzeModal.value = true
}
const closeAnalyzeModal = () => {
showAnalyzeModal.value = false
analyzeTarget.value = null
analyzeForm.token = ''
analyzeForm.saveToken = false
analyzeResult.value = null
}
const submitAnalyze = async () => {
if (!analyzeTarget.value) return
analyzeLoading.value = true
analyzeResult.value = null
try {
const payload = {
save_token: analyzeForm.saveToken
}
if (analyzeForm.token?.trim()) {
payload.token = analyzeForm.token.trim()
}
const res = await api.post(`/accounts/${analyzeTarget.value.id}/analyze`, payload)
analyzeResult.value = res.data
if (res.data?.success) {
await loadAccounts()
}
} catch (e) {
analyzeResult.value = {
success: false,
message: e.response?.data?.detail || '分析失败'
}
} finally {
analyzeLoading.value = false
}
}
// 快捷切换账号状态
const toggleAccountStatus = async (account) => {
const statusMap = { 'active': '可用', 'in_use': '使用中', 'disabled': '禁用', 'expired': '过期' }
const nextMap = { 'in_use': 'active', 'active': 'disabled', 'disabled': 'active', 'expired': 'active' }
const nextStatus = nextMap[account.status] || 'active'
const msg = `确定将账号状态从「${statusMap[account.status]}」切换为「${statusMap[nextStatus]}」?`
if (!confirm(msg)) return
try {
const res = await api.post(`/accounts/${account.id}/toggle-status`)
loadAccounts()
} catch (e) {
alert(e.response?.data?.detail || '切换失败')
}
}
// 批量启用账号
const batchEnableAccounts = async () => {
if (selectedAccounts.value.length === 0) return
if (!confirm(`确定启用选中的 ${selectedAccounts.value.length} 个账号?`)) return
try {
const res = await api.post('/accounts/batch-enable', selectedAccounts.value)
alert(res.data.message)
loadAccounts()
} catch (e) {
alert(e.response?.data?.detail || '批量启用失败')
}
}
// 批量禁用账号
const batchDisableAccounts = async () => {
if (selectedAccounts.value.length === 0) return
if (!confirm(`确定禁用选中的 ${selectedAccounts.value.length} 个账号?`)) return
try {
const res = await api.post('/accounts/batch-disable', selectedAccounts.value)
alert(res.data.message)
loadAccounts()
} catch (e) {
alert(e.response?.data?.detail || '批量禁用失败')
}
}
// 批量删除账号
const batchDeleteAccounts = async () => {
if (selectedAccounts.value.length === 0) return
if (!confirm(`确定删除选中的 ${selectedAccounts.value.length} 个账号?此操作不可撤销!`)) return
try {
const res = await api.post('/accounts/batch-delete', selectedAccounts.value)
alert(res.data.message)
loadAccounts()
} catch (e) {
alert(e.response?.data?.detail || '批量删除失败')
}
}
const importAccounts = async () => {
try {
const data = JSON.parse(importData.value)
const res = await api.post('/accounts/import', { accounts: data })
importResult.value = res.data
loadAccounts()
} 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
searchKeys()
} catch (e) {
alert(e.response?.data?.detail || '保存失败')
}
}
const deleteKey = async (id) => {
if (!confirm('确定删除此激活码?')) return
try {
await api.delete(`/keys/${id}`)
searchKeys()
} 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
searchKeys()
alert('充值成功')
} catch (e) {
alert(e.response?.data?.detail || '充值失败')
}
}
const copyKey = (key) => {
navigator.clipboard.writeText(key)
alert('已复制')
}
const copyToken = (token, name) => {
navigator.clipboard.writeText(token)
alert(`${name} 已复制`)
}
// 延期操作
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
searchKeys()
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
searchKeys()
} 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 skip = (keysPagination.currentPage - 1) * keysPagination.pageSize
// 构建激活码列表查询参数
const listParams = new URLSearchParams()
listParams.append('skip', skip)
listParams.append('limit', keysPagination.pageSize)
if (keySearch.search) listParams.append('search', keySearch.search)
if (keySearch.status) listParams.append('status', keySearch.status)
if (keySearch.activated) listParams.append('activated', keySearch.activated)
if (keySearch.membership_type) listParams.append('membership_type', keySearch.membership_type)
// 构建计数查询参数不包含skip和limit
const countParams = new URLSearchParams()
if (keySearch.search) countParams.append('search', keySearch.search)
if (keySearch.status) countParams.append('status', keySearch.status)
if (keySearch.activated) countParams.append('activated', keySearch.activated)
if (keySearch.membership_type) countParams.append('membership_type', keySearch.membership_type)
const [keysRes, countRes] = await Promise.all([
api.get('/keys?' + listParams.toString()),
api.get('/keys/count' + (countParams.toString() ? '?' + countParams.toString() : ''))
])
keys.value = keysRes.data
keysTotal.value = countRes.data.total
keysPagination.totalPages = Math.ceil(keysTotal.value / keysPagination.pageSize)
selectedKeys.value = []
} catch (e) {
console.error('搜索失败', e)
}
}, 300)
}
const resetKeySearch = () => {
keySearch.search = ''
keySearch.status = ''
keySearch.activated = ''
keySearch.membership_type = ''
keysPagination.currentPage = 1
searchKeys()
}
// 全选/取消全选激活码
const isAllKeysSelected = computed(() => {
return keys.value.length > 0 && selectedKeys.value.length === keys.value.length
})
const toggleSelectAllKeys = (event) => {
if (event.target.checked) {
selectedKeys.value = keys.value.map(k => k.id)
} else {
selectedKeys.value = []
}
}
// 批量复制激活码
const batchCopyKeys = async () => {
if (selectedKeys.value.length === 0) return
try {
// 获取选中的激活码
const selectedKeyObjects = keys.value.filter(k => selectedKeys.value.includes(k.id))
const keyStrings = selectedKeyObjects.map(k => k.key).join('\n')
await navigator.clipboard.writeText(keyStrings)
alert(`已复制 ${selectedKeys.value.length} 个激活码到剪贴板`)
} catch (e) {
alert('复制失败: ' + e.message)
}
}
// 批量启用激活码
const batchEnableKeys = async () => {
if (selectedKeys.value.length === 0) return
if (!confirm(`确定启用选中的 ${selectedKeys.value.length} 个激活码?`)) return
try {
const res = await api.post('/keys/batch-enable', selectedKeys.value)
alert(res.data.message)
searchKeys()
} catch (e) {
alert(e.response?.data?.detail || '批量启用失败')
}
}
// 批量禁用激活码
const batchDisableKeys = async () => {
if (selectedKeys.value.length === 0) return
if (!confirm(`确定禁用选中的 ${selectedKeys.value.length} 个激活码?`)) return
try {
const res = await api.post('/keys/batch-disable', selectedKeys.value)
alert(res.data.message)
searchKeys()
} catch (e) {
alert(e.response?.data?.detail || '批量禁用失败')
}
}
// 批量删除激活码
const batchDeleteKeys = async () => {
if (selectedKeys.value.length === 0) return
if (!confirm(`确定删除选中的 ${selectedKeys.value.length} 个激活码?此操作不可撤销!`)) return
try {
const res = await api.post('/keys/batch-delete', selectedKeys.value)
alert(res.data.message)
searchKeys()
} catch (e) {
alert(e.response?.data?.detail || '批量删除失败')
}
}
// 禁用/启用激活码
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 loadAnnouncements = async () => {
try {
const res = await api.get('/announcements')
announcements.value = res.data
} catch (e) {
console.error('加载公告失败', e)
}
}
const openCreateAnnouncementModal = () => {
editingAnnouncement.value = null
announcementForm.title = ''
announcementForm.content = ''
announcementForm.type = 'info'
announcementForm.is_active = true
showAnnouncementModal.value = true
}
const editAnnouncement = (ann) => {
editingAnnouncement.value = ann
announcementForm.title = ann.title
announcementForm.content = ann.content
announcementForm.type = ann.type
announcementForm.is_active = ann.is_active
showAnnouncementModal.value = true
}
const saveAnnouncement = async () => {
try {
if (editingAnnouncement.value) {
await api.put(`/announcements/${editingAnnouncement.value.id}`, announcementForm)
} else {
await api.post('/announcements', announcementForm)
}
showAnnouncementModal.value = false
loadAnnouncements()
} catch (e) {
alert(e.response?.data?.detail || '保存失败')
}
}
const deleteAnnouncement = async (id) => {
if (!confirm('确定删除此公告?')) return
try {
await api.delete(`/announcements/${id}`)
loadAnnouncements()
} catch (e) {
alert(e.response?.data?.detail || '删除失败')
}
}
const toggleAnnouncement = async (ann) => {
try {
await api.post(`/announcements/${ann.id}/toggle`)
loadAnnouncements()
} catch (e) {
alert(e.response?.data?.detail || '操作失败')
}
}
const announcementTypeClass = (type) => {
const map = {
'info': 'bg-blue-100 text-blue-800',
'warning': 'bg-yellow-100 text-yellow-800',
'error': 'bg-red-100 text-red-800',
'success': 'bg-green-100 text-green-800'
}
return map[type] || 'bg-gray-100 text-gray-800'
}
const announcementTypeLabel = (type) => {
const map = { 'info': '信息', 'warning': '警告', 'error': '错误', 'success': '成功' }
return map[type] || type
}
// 辅助函数
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 formatMembershipLabel = (type) => {
if (!type) return '未知'
return String(type).toUpperCase()
}
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 === 'announcements') {
loadAnnouncements()
} else if (newTab !== 'settings' && newTab !== 'compensate') {
loadData()
}
})
return {
isLoggedIn, currentUser, currentTab, loginError, loginForm,
stats, accounts, accountsTotal, selectedAccounts, accountsPagination, isAllSelected,
keys, keysTotal, selectedKeys, keysPagination, isAllKeysSelected,
logs, logFilter, keySearch,
accountForm, keyForm,
showAccountModal, showAnalyzeModal, showKeyModal, showImportModal, showQuotaModal, showExtendModal,
showKeyDetailModal, keyDetail, keyDevices, keyLogs,
editingAccount, editingKey, analyzeTarget,
importData, importResult,
quotaTarget, addQuotaAmount,
extendTarget, extendDays,
globalSettings,
compensateForm, compensatePreview, compensateResult,
announcements, showAnnouncementModal, editingAnnouncement, announcementForm,
loadAnnouncements, openCreateAnnouncementModal, editAnnouncement, saveAnnouncement,
deleteAnnouncement, toggleAnnouncement, announcementTypeClass, announcementTypeLabel,
login, logout, loadData, loadAccounts, loadLogs, searchKeys, resetKeySearch,
toggleSelectAll, batchEnableAccounts, batchDisableAccounts, batchDeleteAccounts,
toggleSelectAllKeys, batchCopyKeys, batchEnableKeys, batchDisableKeys, batchDeleteKeys,
openCreateAccountModal, editAccount, saveAccount, deleteAccount, importAccounts, toggleAccountStatus,
openAnalyzeModal, closeAnalyzeModal, submitAnalyze,
resetKeyForm, editKey, saveKey, deleteKey, addQuota, submitAddQuota, copyKey, copyToken,
viewKeyDetail, deleteDevice, disableKey, enableKey,
extendKey, submitExtend,
analyzeForm, analyzeResult, analyzeLoading,
loadSettings, saveSettings,
previewCompensate, executeCompensate,
getStatusClass, getStatusText, formatDate, formatMembershipLabel
}
}
}).mount('#app')
</script>
</body>
</html>