- 新增 Announcement 数据模型,支持公告的增删改查 - 后台管理新增"公告管理"Tab(创建/编辑/删除/启用禁用) - 客户端 /api/announcement 改为从数据库读取 - 账号服务重构,新增无感换号、自动分析等功能 - 新增后台任务调度器、数据库迁移脚本 - Schema/Service/Config 全面升级至 v2.1 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1985 lines
116 KiB
HTML
1985 lines
116 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 = '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>
|