feat: 添加多个新项目及更新文档

- 新增 GPT_register+duckmail+CPA+autouploadsub2api (DuckMail + OAuth + Sub2Api 注册工具)
- 新增 team_all-in-one (ChatGPT Team 一键注册工具)
- 新增 Code-Patch 项目
- 新增 ABCard 子模块 (ChatGPT Business/Plus 自动开通)
- 新增 cloudflare_temp_email 子模块 (Cloudflare 临时邮箱服务)
- 添加 .gitignore 文件
- 更新 README.md (新增项目导航、子模块说明)
- 添加 CHANGELOG.md
This commit is contained in:
adminlove520
2026-03-19 23:25:34 +08:00
parent 69ba5ab3f5
commit cc691b9fca
43 changed files with 19047 additions and 10 deletions

View File

@@ -0,0 +1,95 @@
<template>
<el-container class="app-layout">
<el-aside class="sidebar" width="220px">
<div class="sidebar-logo">
<span class="logo-icon"></span>
<span class="logo-text">Code Patch</span>
</div>
<el-menu
:router="true"
:default-active="route.path"
class="sidebar-menu"
>
<el-menu-item index="/register">
<el-icon><UserFilled /></el-icon>
<span>批量注册</span>
</el-menu-item>
<el-menu-item index="/sessions">
<el-icon><List /></el-icon>
<span>注册记录</span>
</el-menu-item>
<el-menu-item index="/accounts">
<el-icon><DataAnalysis /></el-icon>
<span>账号查询</span>
</el-menu-item>
<el-menu-item index="/schedules">
<el-icon><AlarmClock /></el-icon>
<span>任务中心</span>
</el-menu-item>
</el-menu>
</el-aside>
<el-main class="page-main">
<router-view />
</el-main>
</el-container>
</template>
<script setup>
import { useRoute } from 'vue-router'
const route = useRoute()
</script>
<style>
* { box-sizing: border-box; }
body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', sans-serif; }
</style>
<style scoped>
.app-layout {
height: 100vh;
overflow: hidden;
}
.sidebar {
background: var(--color-sidebar-bg);
height: 100vh;
display: flex;
flex-direction: column;
}
.sidebar-logo {
padding: 20px 16px 16px;
display: flex;
align-items: center;
gap: 8px;
border-bottom: 1px solid rgba(255, 255, 255, .06);
margin-bottom: 4px;
}
.logo-icon {
font-size: 20px;
}
.logo-text {
color: #fff;
font-size: 15px;
font-weight: 600;
letter-spacing: .5px;
}
.sidebar-menu {
background-color: var(--color-sidebar-bg) !important;
border-right: none !important;
--el-menu-text-color: var(--color-sidebar-text);
--el-menu-active-color: var(--color-sidebar-active);
--el-menu-hover-bg-color: var(--color-sidebar-hover);
--el-menu-bg-color: var(--color-sidebar-bg);
}
.page-main {
background: var(--color-page-bg);
padding: var(--space-xl);
overflow-y: auto;
}
</style>

View File

@@ -0,0 +1,104 @@
import axios from 'axios'
const http = axios.create({ baseURL: '/api', timeout: 30000 })
// Normalise error messages so callers can use err.message uniformly
http.interceptors.response.use(
r => r,
err => {
const msg = err.response?.data?.detail || err.message || '请求失败'
return Promise.reject(new Error(msg))
}
)
export const getSystemProxy = () => http.get('/system-proxy')
export const startSession = (data) => http.post('/sessions', data)
export const getSessions = () => http.get('/sessions')
export const getActiveSession = () => http.get('/sessions/active')
export const getAccounts = (params) => http.get('/accounts', { params })
export const getAccount = (id) => http.get(`/accounts/${id}`)
export function exportSessionUrl(sessionId) {
return `/api/sessions/${sessionId}/export`
}
export function exportAccountsUrl(params = {}) {
const qs = new URLSearchParams()
if (params.search) qs.set('search', params.search)
if (params.status) qs.set('status', params.status)
if (params.session_id) qs.set('session_id', params.session_id)
if (params.alive) qs.set('alive', params.alive)
const q = qs.toString()
return `/api/accounts/export${q ? '?' + q : ''}`
}
export const pauseSession = (id) => http.post(`/sessions/${id}/pause`)
export const resumeSession = (id) => http.post(`/sessions/${id}/resume`)
export const getSchedules = () => http.get('/schedules')
export const createSchedule = (data) => http.post('/schedules', data)
export const updateSchedule = (id, data) => http.put(`/schedules/${id}`, data)
export const toggleSchedule = (id) => http.put(`/schedules/${id}/toggle`)
export const deleteSchedule = (id) => http.delete(`/schedules/${id}`)
export const getScheduleRuns = (id) => http.get(`/schedules/${id}/runs`)
export const getAllRuns = (limit = 50) => http.get('/schedule-runs', { params: { limit } })
export const startCheckSession = (data) => http.post('/check-sessions', data)
export const importAccounts = (data) => http.post('/accounts/import', data)
export const deleteDeadAccounts = () => http.delete('/accounts/dead')
export const setAutoRefresh = (id, enabled) => http.put(`/accounts/${id}/auto-refresh`, null, { params: { enabled } })
/**
* Open a WebSocket for a registration session.
*/
export function openSessionWS(sessionId, { onSuccess, onFailed, onDone, onError } = {}) {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'
const ws = new WebSocket(`${proto}//${location.host}/ws/sessions/${sessionId}`)
ws.onmessage = (e) => {
const msg = JSON.parse(e.data)
if (msg.type === 'success') onSuccess?.(msg)
else if (msg.type === 'failed') onFailed?.(msg)
else if (msg.type === 'done') onDone?.(msg)
// ignore 'ping'
}
ws.onerror = (e) => onError?.(e)
return ws
}
/**
* Open a WebSocket for a liveness check session.
*/
export function openCheckWS(checkId, { onResult, onDone, onError } = {}) {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'
const ws = new WebSocket(`${proto}//${location.host}/ws/check/${checkId}`)
ws.onmessage = (e) => {
const msg = JSON.parse(e.data)
if (msg.type === 'result') onResult?.(msg)
else if (msg.type === 'done') onDone?.(msg)
// ignore 'ping'
}
ws.onerror = (e) => onError?.(e)
return ws
}
/**
* Open a WebSocket for an import session.
*/
export function openImportWS(importId, { onResult, onDone, onError } = {}) {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'
const ws = new WebSocket(`${proto}//${location.host}/ws/sessions/${importId}`)
ws.onmessage = (e) => {
const msg = JSON.parse(e.data)
if (msg.type === 'success') onResult?.({ ...msg, alive: 'alive' })
else if (msg.type === 'failed') onResult?.({ ...msg, alive: 'dead' })
else if (msg.type === 'done') onDone?.(msg)
// ignore 'ping'
}
ws.onerror = (e) => onError?.(e)
return ws
}

View File

@@ -0,0 +1,57 @@
import { ref, reactive, computed } from 'vue'
import { openCheckWS } from '../api/index.js'
// 全局状态 — 模块级别,切换页面不会丢失
const checking = ref(false)
const checkProgress = reactive({ total: 0, done: 0, alive: 0, dead: 0, error: 0 })
let checkWs = null
let onDoneCallback = null
const checkPct = computed(() => {
if (!checkProgress.total) return 0
return Math.round((checkProgress.done / checkProgress.total) * 100)
})
function startCheck(checkId, total, { onResult, onDone } = {}) {
// 关闭之前的连接
checkWs?.close()
checking.value = true
checkProgress.total = total
checkProgress.done = 0
checkProgress.alive = 0
checkProgress.dead = 0
checkProgress.error = 0
onDoneCallback = onDone || null
checkWs = openCheckWS(checkId, {
onResult(msg) {
checkProgress.done++
if (msg.alive === 'alive') checkProgress.alive++
else if (msg.alive === 'dead') checkProgress.dead++
else checkProgress.error++
onResult?.(msg)
},
onDone(msg) {
checking.value = false
checkWs?.close()
checkWs = null
onDoneCallback?.(msg)
},
onError() {
checking.value = false
checkWs?.close()
checkWs = null
},
})
}
function stopCheck() {
checkWs?.close()
checkWs = null
checking.value = false
}
export function useCheckState() {
return { checking, checkProgress, checkPct, startCheck, stopCheck }
}

View File

@@ -0,0 +1,40 @@
import { onUnmounted } from 'vue'
/**
* Composable for managing a WebSocket connection.
* Automatically closes the socket when the component is unmounted.
*
* @param {string} path - Path template, e.g. '/ws/sessions/{id}'
* @param {object} handlers - { onMessage(msg), onError(e) }
* @returns {{ open(id: string|number): void, close(): void }}
*/
export function useWebSocket(path, handlers = {}) {
let ws = null
function open(id) {
close()
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'
const url = `${proto}//${location.host}${path.replace('{id}', id)}`
ws = new WebSocket(url)
ws.onmessage = (e) => {
const msg = JSON.parse(e.data)
if (msg.type !== 'ping') {
handlers.onMessage?.(msg)
}
}
ws.onerror = (e) => handlers.onError?.(e)
}
function close() {
if (ws) {
ws.close()
ws = null
}
}
onUnmounted(close)
return { open, close }
}

View File

@@ -0,0 +1,18 @@
import './styles/variables.css'
import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import App from './App.vue'
import router from './router/index.js'
const app = createApp(App)
app.use(ElementPlus)
app.use(router)
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.mount('#app')

View File

@@ -0,0 +1,18 @@
import { createRouter, createWebHistory } from 'vue-router'
import RegisterView from '../views/RegisterView.vue'
import SessionsView from '../views/SessionsView.vue'
import AccountsView from '../views/AccountsView.vue'
import SchedulesView from '../views/SchedulesView.vue'
const routes = [
{ path: '/', redirect: '/register' },
{ path: '/register', component: RegisterView, name: 'Register' },
{ path: '/sessions', component: SessionsView, name: 'Sessions' },
{ path: '/accounts', component: AccountsView, name: 'Accounts' },
{ path: '/schedules', component: SchedulesView, name: 'Schedules' },
]
export default createRouter({
history: createWebHistory(),
routes,
})

View File

@@ -0,0 +1,35 @@
:root {
/* Sidebar */
--color-sidebar-bg: #1a2332;
--color-sidebar-text: #8b9ab4;
--color-sidebar-active: #ffffff;
--color-sidebar-hover: #2d3f52;
/* Page */
--color-page-bg: #f0f2f5;
/* Log / Terminal */
--color-log-bg: #0d1117;
--color-log-success: #3fb950;
--color-log-failed: #f85149;
--color-log-meta: #8b949e;
/* Typography */
--font-mono: 'JetBrains Mono', 'Fira Code', Consolas, monospace;
/* Spacing */
--space-xs: 4px;
--space-sm: 8px;
--space-md: 12px;
--space-lg: 16px;
--space-xl: 24px;
--space-2xl: 32px;
/* Border radius */
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
/* Shadows */
--shadow-card: 0 1px 3px rgba(0, 0, 0, .08), 0 1px 2px rgba(0, 0, 0, .06);
}

View File

@@ -0,0 +1,28 @@
/**
* Format an ISO timestamp to a human-readable local string.
* Returns '—' for null/undefined values.
*/
export function formatTime(iso) {
if (!iso) return '—'
return new Date(iso).toLocaleString('zh-CN', { hour12: false })
}
/**
* Map alive status value to an Element Plus tag type.
*/
export function aliveTagType(v) {
if (v === 'alive') return 'success'
if (v === 'dead') return 'danger'
if (v === 'error') return 'warning'
return 'info'
}
/**
* Map alive status value to a display label.
*/
export function aliveLabel(v) {
if (v === 'alive') return '存活'
if (v === 'dead') return '已失效'
if (v === 'error') return '检测异常'
return '未检测'
}

View File

@@ -0,0 +1,702 @@
<template>
<div>
<div class="page-header">
<span class="page-title">账号查询</span>
<div class="header-actions">
<el-button :icon="Download" :disabled="total === 0" @click="exportResults">导出账号</el-button>
<el-button type="success" @click="importVisible = true">
<el-icon><Upload /></el-icon>
导入账号
</el-button>
</div>
</div>
<!-- Search + Check tabs -->
<el-card class="panel-card">
<el-tabs v-model="activeTab">
<el-tab-pane label="搜索筛选" name="search">
<div class="filter-row">
<div class="filter-item flex-1">
<div class="field-label">关键词Email / Account ID</div>
<el-input
v-model="searchForm.keyword"
placeholder="输入邮箱或 Account ID..."
clearable
@keyup.enter="doSearch"
@clear="doSearch"
>
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
</div>
<div class="filter-item">
<div class="field-label">状态</div>
<el-select v-model="searchForm.status" placeholder="全部" clearable style="width:110px;">
<el-option label="成功" value="success" />
<el-option label="失败" value="failed" />
</el-select>
</div>
<div class="filter-item">
<div class="field-label">存活</div>
<el-select v-model="searchForm.alive" placeholder="全部" clearable style="width:110px;">
<el-option label="存活" value="alive" />
<el-option label="已死" value="dead" />
<el-option label="未检测" value="unchecked" />
<el-option label="检测异常" value="error" />
</el-select>
</div>
<div class="filter-item" style="align-self:flex-end;">
<el-button type="primary" :icon="Search" :loading="loading" @click="doSearch">查询</el-button>
</div>
</div>
<el-alert
v-if="searchForm.status === 'success'"
title="当前仅显示注册成功的账号"
type="info"
:closable="false"
show-icon
style="margin-top: 10px;"
/>
</el-tab-pane>
<el-tab-pane name="check">
<template #label>
检测存活
<el-badge
v-if="selectedIds.length > 0"
:value="selectedIds.length"
style="margin-left: 4px;"
/>
</template>
<div class="filter-row">
<div class="filter-item flex-1">
<div class="field-label">检测代理池每行一个</div>
<el-input
v-model="checkForm.proxies"
type="textarea"
:rows="2"
placeholder="http://127.0.0.1:10809"
class="mono-input"
/>
</div>
<div class="filter-item">
<div class="field-label">检测数量</div>
<el-input-number v-model="checkForm.limit" :min="0" :max="1000000" style="width:130px;" />
<div class="field-hint">0 = 全部</div>
</div>
<div class="filter-item">
<div class="field-label">检测范围</div>
<el-select v-model="checkForm.filter" style="width:150px;">
<el-option value="all" label="全部成功账号" />
<el-option value="alive" label="仅存活账号" />
<el-option value="unchecked" label="仅未检测账号" />
</el-select>
</div>
<div class="filter-item">
<div class="field-label">并发数</div>
<el-input-number v-model="checkForm.concurrency" :min="1" :max="1000" style="width:110px;" />
</div>
<div class="filter-item" style="align-self:flex-end; display:flex; flex-direction:column; gap:6px;">
<el-checkbox v-model="checkForm.autoClean">检测后清理失效账号</el-checkbox>
<el-button
type="warning"
:disabled="selectedIds.length === 0 || checking"
:loading="checking"
@click="startCheck(selectedIds)"
>
检测选中 {{ selectedIds.length > 0 ? `(${selectedIds.length})` : '' }}
</el-button>
<el-button
type="danger"
:disabled="checkTotal === 0 || checking"
:loading="checking"
@click="startCheckAll"
>
检测 {{ checkForm.limit > 0 ? checkForm.limit : '全部' }} ({{ checkTotal }})
</el-button>
</div>
</div>
<div v-if="checkProgress.total > 0" style="margin-top: 12px;">
<div class="progress-stats">
<span style="color:#67c23a;">存活 {{ checkProgress.alive }}</span>
<span style="color:#f56c6c;">已死 {{ checkProgress.dead }}</span>
<span style="color:#e6a23c;">异常 {{ checkProgress.error }}</span>
<span> {{ checkProgress.total }}</span>
</div>
<el-progress :percentage="checkPct" :status="checking ? '' : 'success'" :stroke-width="8" />
</div>
</el-tab-pane>
</el-tabs>
</el-card>
<!-- Results table -->
<el-card>
<div class="table-meta">
<b>{{ total }}</b> 条记录
<el-text v-if="selectedIds.length > 0" type="primary" style="margin-left:12px;">
已选 {{ selectedIds.length }}
</el-text>
</div>
<el-table
ref="tableRef"
:data="rows"
v-loading="loading"
border
size="small"
style="width:100%;"
@row-click="openDetail"
@selection-change="onSelectionChange"
:row-style="{ cursor: 'pointer' }"
>
<el-table-column type="selection" width="42" @click.stop />
<el-table-column prop="id" label="ID" width="65" />
<el-table-column prop="session_id" label="批次" width="60" align="center" />
<el-table-column prop="email" label="Email" min-width="200" show-overflow-tooltip />
<el-table-column prop="account_id" label="Account ID" min-width="150" show-overflow-tooltip />
<el-table-column label="存活" width="85" align="center">
<template #default="{ row }">
<el-tag :type="aliveTagType(row.alive)" size="small">{{ aliveLabel(row.alive) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="套餐" width="80" align="center">
<template #default="{ row }">
<el-tag v-if="row.plan_type" :type="planTagType(row.plan_type)" size="small">{{ row.plan_type }}</el-tag>
<span v-else style="color:#c0c4cc;"></span>
</template>
</el-table-column>
<el-table-column label="配额" width="90" align="center">
<template #default="{ row }">
<span v-if="row.usage_json" style="font-size:12px;">{{ usageSummary(row.usage_json) }}</span>
<span v-else style="color:#c0c4cc;"></span>
</template>
</el-table-column>
<el-table-column label="检测时间" width="160" show-overflow-tooltip>
<template #default="{ row }">{{ formatTime(row.checked_at) }}</template>
</el-table-column>
<el-table-column prop="expired" label="过期时间" width="170" show-overflow-tooltip />
<el-table-column label="注册状态" width="75" align="center">
<template #default="{ row }">
<el-tag :type="row.error ? 'danger' : 'success'" size="small">
{{ row.error ? '失败' : '成功' }}
</el-tag>
</template>
</el-table-column>
<template #empty>
<el-empty description="暂无账号数据">
<el-button @click="doSearch">刷新</el-button>
</el-empty>
</template>
</el-table>
<div style="display:flex; justify-content:flex-end; margin-top:12px;">
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:page-sizes="[20, 50, 100, 200]"
:total="total"
layout="total, sizes, prev, pager, next"
@change="doSearch"
/>
</div>
</el-card>
<!-- Detail drawer -->
<el-drawer
v-model="drawerVisible"
title="账号详情"
direction="rtl"
size="520px"
:destroy-on-close="true"
>
<el-skeleton :loading="!detail" :rows="8" animated>
<template #default>
<div v-if="detail" style="font-size:13px;">
<el-descriptions :column="1" border size="small">
<el-descriptions-item label="ID">{{ detail.id }}</el-descriptions-item>
<el-descriptions-item label="批次">{{ detail.session_id }}</el-descriptions-item>
<el-descriptions-item label="注册状态">
<el-tag :type="detail.error ? 'danger' : 'success'" size="small">
{{ detail.error ? '失败' : '成功' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="存活状态">
<el-tag :type="aliveTagType(detail.alive)" size="small">{{ aliveLabel(detail.alive) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="上次自动刷新">{{ formatTime(detail.last_auto_refresh) }}</el-descriptions-item>
<el-descriptions-item label="检测时间">{{ formatTime(detail.checked_at) }}</el-descriptions-item>
<el-descriptions-item label="Email">{{ detail.email || '—' }}</el-descriptions-item>
<el-descriptions-item label="Account ID">{{ detail.account_id || '—' }}</el-descriptions-item>
<el-descriptions-item label="过期时间">{{ detail.expired || '—' }}</el-descriptions-item>
<el-descriptions-item label="套餐类型">
<el-tag v-if="detail.plan_type" :type="planTagType(detail.plan_type)" size="small">{{ detail.plan_type }}</el-tag>
<span v-else></span>
</el-descriptions-item>
<el-descriptions-item v-if="parsedUsage" label="Rate Limit">
<span :style="{ color: parsedUsage.rate_limit?.limit_reached ? '#f56c6c' : '#67c23a' }">
{{ parsedUsage.rate_limit?.allowed ? '可用' : '不可用' }}
</span>
<span v-if="parsedUsage.rate_limit?.primary_window" style="margin-left:8px; color:#909399;">
已用 {{ parsedUsage.rate_limit.primary_window.used_percent }}%
</span>
</el-descriptions-item>
<el-descriptions-item v-if="parsedUsage?.promo" label="推广">
{{ parsedUsage.promo.message || parsedUsage.promo.campaign_id }}
</el-descriptions-item>
<el-descriptions-item label="代理">{{ detail.proxy_used || '—' }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatTime(detail.created_at) }}</el-descriptions-item>
<el-descriptions-item v-if="detail.error" label="错误信息">
<span style="color:#f56c6c; word-break:break-all;">{{ detail.error }}</span>
</el-descriptions-item>
</el-descriptions>
<template v-if="!detail.error">
<div class="token-section-title">Token 信息</div>
<div v-for="field in tokenFields" :key="field.key" class="token-block-wrap">
<div class="token-block-header">
<span class="token-label">{{ field.label }}</span>
<el-button size="small" text @click="copyText(detail[field.key])">
<el-icon><CopyDocument /></el-icon>
复制
</el-button>
</div>
<div class="token-block">{{ detail[field.key] || '—' }}</div>
</div>
</template>
</div>
</template>
</el-skeleton>
</el-drawer>
<!-- Import dialog -->
<el-dialog v-model="importVisible" title="导入账号" width="520px" :close-on-click-modal="!importing">
<el-alert
title="每行一个 refresh_token或完整 JSON 对象(需含 refresh_token 字段)。导入时自动验证存活并开启自动刷新保活。"
type="info"
:closable="false"
show-icon
style="margin-bottom: 10px;"
/>
<el-upload
:before-upload="handleCsvUpload"
:show-file-list="false"
accept=".csv,.txt"
:disabled="importing"
style="margin-bottom: 10px;"
>
<el-button :icon="Upload" :disabled="importing">选择 CSV / TXT 文件</el-button>
</el-upload>
<el-input
v-model="importForm.tokens"
type="textarea"
:rows="8"
placeholder="粘贴 refresh_token / JSON / CSV 内容,每行一个;或点击上方按钮上传文件"
class="mono-input"
:disabled="importing"
/>
<div class="filter-row" style="margin-top:12px;">
<div class="filter-item flex-1">
<div class="field-label">代理留空使用系统代理</div>
<el-input v-model="importForm.proxy" placeholder="http://127.0.0.1:10809" :disabled="importing" />
</div>
<div class="filter-item">
<div class="field-label">并发</div>
<el-input-number v-model="importForm.concurrency" :min="1" :max="5" style="width:100px;" :disabled="importing" />
</div>
</div>
<div v-if="importProgress.total > 0" style="margin-top:16px;">
<div class="progress-stats">
<span style="color:#67c23a;">有效 {{ importProgress.alive }}</span>
<span style="color:#f56c6c;">失效 {{ importProgress.dead + importProgress.error }}</span>
<span> {{ importProgress.total }}</span>
</div>
<el-progress :percentage="importPct" :status="importing ? '' : 'success'" :stroke-width="8" />
</div>
<template #footer>
<el-button @click="importVisible = false" :disabled="importing">取消</el-button>
<el-button type="primary" :loading="importing" @click="startImport">开始导入</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted, onUnmounted, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { Search, Download, Upload, CopyDocument } from '@element-plus/icons-vue'
import {
getAccounts, getAccount, exportAccountsUrl, getSystemProxy,
startCheckSession, importAccounts, deleteDeadAccounts,
openImportWS,
} from '../api/index.js'
import { formatTime, aliveTagType, aliveLabel } from '../utils/format.js'
import { useCheckState } from '../composables/useCheckState.js'
const { checking, checkProgress, checkPct, startCheck: globalStartCheck, stopCheck } = useCheckState()
const searchForm = reactive({ keyword: '', status: 'success', alive: '' })
const rows = ref([])
const total = ref(0)
const checkTotal = ref(0)
const page = ref(1)
const pageSize = ref(50)
const loading = ref(false)
const activeTab = ref('search')
const drawerVisible = ref(false)
const detail = ref(null)
const tableRef = ref(null)
const selectedIds = ref([])
const checkForm = reactive({ proxies: '', concurrency: 5, limit: 0, filter: 'all', autoClean: true })
const importVisible = ref(false)
const importing = ref(false)
const importForm = reactive({ tokens: '', proxy: '', concurrency: 3 })
const importProgress = reactive({ total: 0, done: 0, alive: 0, dead: 0, error: 0 })
let importWs = null
const importPct = computed(() => {
if (!importProgress.total) return 0
return Math.round((importProgress.done / importProgress.total) * 100)
})
const tokenFields = [
{ key: 'refresh_token', label: 'Refresh Token' },
{ key: 'access_token', label: 'Access Token' },
{ key: 'id_token', label: 'ID Token' },
]
function planTagType(plan) {
const map = { team: 'success', plus: 'warning', pro: '', enterprise: 'danger', free: 'info' }
return map[plan] || 'info'
}
function usageSummary(usageJsonStr) {
if (!usageJsonStr) return ''
try {
const data = JSON.parse(usageJsonStr)
const rl = data.rate_limit
if (!rl) return ''
const pct = rl.primary_window?.used_percent ?? '?'
return rl.limit_reached ? `${pct}% 限制` : `${pct}%`
} catch { return '' }
}
const parsedUsage = computed(() => {
if (!detail.value?.usage_json) return null
try { return JSON.parse(detail.value.usage_json) } catch { return null }
})
async function doSearch() {
loading.value = true
try {
const resp = await getAccounts({
search: searchForm.keyword || undefined,
status: searchForm.status || undefined,
alive: searchForm.alive || undefined,
page: page.value,
page_size: pageSize.value,
})
rows.value = resp.data.items
total.value = resp.data.total
} catch (err) {
ElMessage.error(err.message)
} finally {
loading.value = false
}
}
function onSelectionChange(selection) {
selectedIds.value = selection.map((r) => r.id)
}
async function loadCheckTotal() {
try {
let aliveFilter = undefined
if (checkForm.filter === 'alive') aliveFilter = 'alive'
else if (checkForm.filter === 'unchecked') aliveFilter = 'unchecked'
const resp = await getAccounts({ status: 'success', alive: aliveFilter, page: 1, page_size: 1 })
checkTotal.value = resp.data.total
} catch { checkTotal.value = 0 }
}
watch(() => checkForm.filter, () => loadCheckTotal(), { immediate: true })
async function startCheckAll() {
loading.value = true
try {
const ids = []
const limit = checkForm.limit > 0 ? checkForm.limit : Infinity
// 根据检测范围设置 alive 过滤
let aliveFilter = undefined
if (checkForm.filter === 'alive') aliveFilter = 'alive'
else if (checkForm.filter === 'unchecked') aliveFilter = 'unchecked'
let p = 1
while (ids.length < limit) {
const resp = await getAccounts({
status: 'success',
alive: aliveFilter,
page: p,
page_size: 200,
})
for (const r of resp.data.items) {
ids.push(r.id)
if (ids.length >= limit) break
}
if (ids.length >= resp.data.total) break
p++
}
await startCheck(ids)
} catch (err) {
ElMessage.error(err.message)
} finally {
loading.value = false
}
}
async function startCheck(ids) {
if (!checkForm.proxies.trim()) {
ElMessage.warning('请填写检测代理')
return
}
const rowMap = {}
rows.value.forEach((r) => { rowMap[r.id] = r })
try {
const resp = await startCheckSession({
account_ids: ids,
proxies: checkForm.proxies,
concurrency: checkForm.concurrency,
})
globalStartCheck(resp.data.check_id, ids.length, {
onResult(msg) {
if (rowMap[msg.account_id]) rowMap[msg.account_id].alive = msg.alive
},
onDone(msg) {
ElMessage.success(`检测完成:存活 ${msg.alive},已死 ${msg.dead},异常 ${msg.error}`)
if (checkForm.autoClean && msg.dead > 0) {
deleteDeadAccounts().then((resp) => {
ElMessage.success(`已清理 ${resp.data.deleted} 个失效账号`)
doSearch()
loadCheckTotal()
}).catch(() => { doSearch(); loadCheckTotal() })
} else {
doSearch()
loadCheckTotal()
}
},
})
} catch (err) {
ElMessage.error(err.message)
}
}
function handleCsvUpload(file) {
const reader = new FileReader()
reader.onload = (e) => {
importForm.tokens = e.target.result
ElMessage.success(`已加载文件:${file.name}`)
}
reader.readAsText(file)
return false // 阻止 el-upload 自动上传
}
async function startImport() {
const lines = importForm.tokens.split('\n').filter(l => l.trim())
if (!lines.length) { ElMessage.warning('请输入至少一个 token'); return }
importing.value = true
importProgress.total = lines.length
importProgress.done = 0
importProgress.alive = 0
importProgress.dead = 0
importProgress.error = 0
try {
const resp = await importAccounts({
tokens: importForm.tokens,
proxy: importForm.proxy,
concurrency: importForm.concurrency,
})
importWs = openImportWS(resp.data.import_id, {
onResult(msg) {
importProgress.done++
if (msg.alive === 'alive') importProgress.alive++
else if (msg.alive === 'dead') importProgress.dead++
else importProgress.error++
},
onDone(msg) {
importing.value = false
importWs?.close()
ElMessage.success(`导入完成:有效 ${msg.alive},失效 ${msg.dead + msg.error}`)
doSearch()
},
onError() {
ElMessage.error('连接异常')
importing.value = false
},
})
} catch (err) {
ElMessage.error(err.message)
importing.value = false
}
}
async function openDetail(row) {
drawerVisible.value = true
detail.value = null
try {
const resp = await getAccount(row.id)
detail.value = resp.data
} catch (err) {
ElMessage.error(err.message)
drawerVisible.value = false
}
}
function exportResults() {
window.open(exportAccountsUrl({
search: searchForm.keyword || undefined,
status: searchForm.status || undefined,
alive: searchForm.alive || undefined,
}))
}
async function copyText(text) {
if (!text) return
try {
await navigator.clipboard.writeText(text)
ElMessage.success('已复制')
} catch {
ElMessage.error('复制失败')
}
}
onMounted(async () => {
try {
const resp = await getSystemProxy()
if (resp.data.proxy) {
checkForm.proxies = resp.data.proxy
importForm.proxy = resp.data.proxy
}
} catch {}
doSearch()
})
onUnmounted(() => {
importWs?.close()
})
</script>
<style scoped>
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.page-title {
font-size: 18px;
font-weight: 600;
}
.header-actions {
display: flex;
gap: 8px;
}
.panel-card {
margin-bottom: 12px;
}
.filter-row {
display: flex;
gap: 12px;
flex-wrap: wrap;
align-items: flex-start;
}
.filter-item {
display: flex;
flex-direction: column;
}
.flex-1 { flex: 1; min-width: 200px; }
.field-label {
font-size: 12px;
color: #909399;
margin-bottom: 4px;
}
.field-hint {
font-size: 11px;
color: #c0c4cc;
margin-top: 2px;
}
.mono-input :deep(textarea),
.mono-input :deep(input) {
font-family: var(--font-mono);
font-size: 12px;
}
.progress-stats {
font-size: 12px;
color: #606266;
margin-bottom: 6px;
display: flex;
gap: 12px;
}
.table-meta {
margin-bottom: 8px;
color: #606266;
font-size: 13px;
}
.token-section-title {
margin-top: 20px;
margin-bottom: 10px;
font-weight: 600;
color: #303133;
}
.token-block-wrap {
margin-bottom: 14px;
}
.token-block-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 4px;
}
.token-label {
color: #606266;
font-size: 12px;
font-weight: 500;
}
.token-block {
background: var(--color-log-bg);
color: #c9d1d9;
font-family: var(--font-mono);
font-size: 11px;
line-height: 1.6;
padding: 10px 12px;
border-radius: var(--radius-sm);
word-break: break-all;
white-space: pre-wrap;
max-height: 120px;
overflow-y: auto;
}
</style>

View File

@@ -0,0 +1,418 @@
<template>
<div>
<el-card class="form-card">
<template #header>
<span class="card-title">批量注册</span>
</template>
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px" :disabled="status === 'running' || status === 'paused'">
<el-form-item label="代理池" prop="proxies">
<el-input
v-model="form.proxies"
type="textarea"
:rows="5"
placeholder="每行一个代理地址,例如:&#10;http://user:pass@host:port&#10;http://host2:port"
class="proxy-textarea"
/>
<div class="field-hint">
注册时随机选取代理多代理可提高成功率
<el-text v-if="proxyCount > 0" type="primary" size="small" style="margin-left:8px;">已输入 {{ proxyCount }} 个代理</el-text>
</div>
</el-form-item>
<el-form-item label="目标数量" prop="count">
<el-input-number v-model="form.count" :min="1" :max="1000000" />
</el-form-item>
<el-form-item label="并发数">
<el-input-number v-model="form.concurrency" :min="1" :max="1000" />
<el-tooltip content="并发越高速度越快,但失败率可能上升" placement="right">
<el-icon class="tip-icon"><QuestionFilled /></el-icon>
</el-tooltip>
</el-form-item>
<el-form-item>
<el-button
type="primary"
:loading="status === 'running'"
@click="startRegistration"
>
<el-icon v-if="status !== 'running'"><UserFilled /></el-icon>
{{ status === 'running' ? '注册中...' : '开始注册' }}
</el-button>
</el-form-item>
</el-form>
<div v-if="status === 'running' || status === 'paused' || (status === 'done' && sessionId)" class="action-bar">
<el-button
v-if="status === 'running'"
type="warning"
@click="togglePause"
>
<el-icon><VideoPause /></el-icon>
暂停
</el-button>
<el-button
v-if="status === 'paused'"
type="success"
@click="togglePause"
>
<el-icon><VideoPlay /></el-icon>
继续
</el-button>
<el-button
v-if="status === 'done' && sessionId"
type="success"
@click="exportCsv"
>
<el-icon><Download /></el-icon>
导出本次 CSV
</el-button>
</div>
</el-card>
<!-- Progress -->
<el-card v-if="status !== 'idle'" style="margin-top: 16px;">
<template #header>
<div class="progress-header">
<div class="progress-title">
<span class="card-title">进度</span>
<el-tag v-if="status === 'running'" type="warning" size="small">运行中</el-tag>
<el-tag v-else-if="status === 'paused'" type="info" size="small">已暂停</el-tag>
<el-tag v-else type="success" size="small">已完成</el-tag>
</div>
</div>
</template>
<div class="progress-info">
<span class="progress-text">
<span class="progress-current">{{ progress.success }}</span>
<span class="progress-sep"> / </span>
<span class="progress-target">{{ targetCount }}</span>
</span>
<span class="progress-detail">
失败 {{ progress.failed }}
<template v-if="progress.success + progress.failed > 0">
· 成功率 {{ successRate }}%
</template>
</span>
</div>
<el-progress
:percentage="progressPct"
:status="status === 'done' ? 'success' : ''"
:stroke-width="12"
:show-text="false"
style="margin-bottom: 16px;"
/>
<!-- Log terminal -->
<div class="log-header">
<span class="log-title">实时日志</span>
<div class="log-controls">
<el-switch
v-model="autoScroll"
size="small"
active-text="自动滚动"
inactive-text=""
style="margin-right: 12px;"
/>
<el-button size="small" text @click="logs = []">
<el-icon><Delete /></el-icon>
清空
</el-button>
</div>
</div>
<div ref="logEl" class="log-terminal">
<div
v-for="(line, i) in logs"
:key="i"
:class="['log-line', line.type === 'success' ? 'log-success' : 'log-failed']"
>
<span class="log-time">{{ line.time }} </span>
<span class="log-level">{{ line.type === 'success' ? 'SUCCESS' : 'FAILED ' }}</span>
&nbsp;{{ line.text }}
</div>
<div v-if="logs.length === 0" class="log-empty">等待任务开始...</div>
</div>
</el-card>
</div>
</template>
<script setup>
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
import { ElMessage } from 'element-plus'
import { startSession, pauseSession, resumeSession, openSessionWS, exportSessionUrl, getSystemProxy, getActiveSession } from '../api/index.js'
const formRef = ref(null)
const savedProxies = localStorage.getItem('register_proxies') || ''
const form = ref({ proxies: savedProxies, count: 5, concurrency: 3 })
const autoScroll = ref(true)
const rules = {
proxies: [{ required: true, message: '请填写至少一个代理地址', trigger: 'blur' }],
count: [{ type: 'number', min: 1, message: '注册数量需 ≥ 1', trigger: 'change' }],
}
const proxyCount = computed(() =>
form.value.proxies.split('\n').filter(l => l.trim()).length
)
watch(() => form.value.proxies, (v) => {
localStorage.setItem('register_proxies', v)
})
onMounted(async () => {
try {
const resp = await getSystemProxy()
if (resp.data.proxy && !form.value.proxies) form.value.proxies = resp.data.proxy
} catch {}
// 恢复运行中/暂停的任务
try {
const resp = await getActiveSession()
const s = resp.data.session
if (s) {
sessionId.value = s.id
targetCount.value = s.requested
progress.value = { success: s.success, failed: s.failed }
status.value = s.status === 'paused' ? 'paused' : 'running'
connectWS(s.id)
}
} catch {}
})
const status = ref('idle') // idle | running | paused | done
const sessionId = ref(null)
const targetCount = ref(0)
const progress = ref({ success: 0, failed: 0 })
const logs = ref([])
const logEl = ref(null)
let ws = null
const progressPct = computed(() => {
if (!targetCount.value) return 0
return Math.min(100, Math.round((progress.value.success / targetCount.value) * 100))
})
const successRate = computed(() => {
const total = progress.value.success + progress.value.failed
if (total === 0) return 0
return Math.round((progress.value.success / total) * 100)
})
function timestamp() {
return new Date().toLocaleTimeString('zh-CN', { hour12: false })
}
function scrollLog() {
if (!autoScroll.value) return
nextTick(() => {
if (logEl.value) logEl.value.scrollTop = logEl.value.scrollHeight
})
}
function connectWS(sid) {
ws?.close()
ws = openSessionWS(sid, {
onSuccess(msg) {
progress.value.success++
logs.value.push({
type: 'success',
time: timestamp(),
text: `${msg.email} [${msg.proxy}] (${msg.elapsed}s)`,
})
scrollLog()
},
onFailed(msg) {
progress.value.failed++
logs.value.push({
type: 'failed',
time: timestamp(),
text: `${msg.error} [${msg.proxy}] (${msg.elapsed}s)`,
})
scrollLog()
},
onDone(msg) {
status.value = 'done'
progress.value.success = msg.success
progress.value.failed = msg.failed
ElMessage.success(`注册完成:成功 ${msg.success},失败 ${msg.failed}`)
ws?.close()
},
onError() {
ElMessage.error('WebSocket 连接异常')
status.value = 'done'
},
})
}
async function startRegistration() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
status.value = 'running'
logs.value = []
progress.value = { success: 0, failed: 0 }
sessionId.value = null
targetCount.value = form.value.count
try {
const resp = await startSession({
proxies: form.value.proxies,
count: form.value.count,
concurrency: form.value.concurrency,
})
sessionId.value = resp.data.session_id
connectWS(sessionId.value)
} catch (err) {
status.value = 'idle'
ElMessage.error(err.message)
}
}
async function togglePause() {
if (!sessionId.value) return
try {
if (status.value === 'running') {
await pauseSession(sessionId.value)
status.value = 'paused'
ElMessage.info('已暂停')
} else if (status.value === 'paused') {
await resumeSession(sessionId.value)
status.value = 'running'
ElMessage.success('已恢复')
}
} catch (err) {
ElMessage.error(err.message)
}
}
function exportCsv() {
window.open(exportSessionUrl(sessionId.value))
}
onUnmounted(() => {
// 只关闭 WS 连接,不影响后端任务继续运行
ws?.close()
})
</script>
<style scoped>
.form-card { max-width: 800px; }
.action-bar {
display: flex;
gap: 8px;
padding: 0 0 0 100px;
}
.card-title { font-weight: 600; }
.proxy-textarea :deep(textarea) {
font-family: var(--font-mono);
font-size: 13px;
}
.field-hint {
color: #909399;
font-size: 12px;
margin-top: 4px;
line-height: 1.5;
}
.tip-icon {
margin-left: 8px;
color: #909399;
cursor: help;
font-size: 16px;
}
.progress-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.progress-title {
display: flex;
align-items: center;
gap: 8px;
}
.log-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.log-title {
font-size: 13px;
font-weight: 600;
color: #606266;
}
.log-controls {
display: flex;
align-items: center;
}
.log-terminal {
background: var(--color-log-bg);
border-radius: var(--radius-md);
padding: var(--space-md);
height: 320px;
overflow-y: auto;
font-family: var(--font-mono);
font-size: 12px;
line-height: 1.7;
}
.log-line { margin: 0; }
.log-success { color: var(--color-log-success); }
.log-failed { color: var(--color-log-failed); }
.log-time {
color: var(--color-log-meta);
user-select: none;
}
.log-level {
font-weight: 600;
}
.log-empty {
color: #484f58;
}
.progress-info {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-bottom: 8px;
}
.progress-text {
font-size: 24px;
font-weight: 700;
}
.progress-current {
color: #67c23a;
}
.progress-sep {
color: #909399;
margin: 0 2px;
}
.progress-target {
color: #303133;
}
.progress-detail {
font-size: 13px;
color: #909399;
}
</style>

View File

@@ -0,0 +1,483 @@
<template>
<div>
<div class="page-header">
<span class="page-title">任务中心</span>
<el-button type="primary" @click="openDialog(null)">
<el-icon><Plus /></el-icon>
新建任务
</el-button>
</div>
<el-card>
<el-table :data="schedules" v-loading="loading" stripe>
<el-table-column prop="id" label="ID" width="55" />
<el-table-column prop="name" label="任务名称" min-width="120" show-overflow-tooltip>
<template #default="{ row }">{{ row.name || '未命名' }}</template>
</el-table-column>
<el-table-column label="类型" width="80" align="center">
<template #default="{ row }">
<el-tag :type="row.schedule_type === 'daily' ? 'primary' : 'info'" size="small">
{{ row.schedule_type === 'daily' ? '每天' : '单次' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="任务类型" width="100" align="center">
<template #default="{ row }">
<el-tag :type="taskTypeTag(row.task_type || 'register')" size="small">
{{ taskTypeLabel(row.task_type || 'register') }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="执行时间" width="170">
<template #default="{ row }">
<span v-if="row.schedule_type === 'daily'">每天 {{ row.run_time }}</span>
<span v-else>{{ formatTime(row.run_time) }}</span>
</template>
</el-table-column>
<el-table-column label="目标/范围" width="130" align="center">
<template #default="{ row }">
<span v-if="(row.task_type || 'register') === 'register'">{{ row.target }}</span>
<span v-else-if="row.task_type === 'clean'" style="color:#909399;">自动</span>
<template v-else>
<el-tag size="small" type="info">
{{ {all:'全部', alive:'存活', unchecked:'未检测'}[row.check_filter] || '全部' }}
</el-tag>
<span v-if="row.check_limit > 0" style="margin-left:4px; font-size:12px; color:#909399;">
×{{ row.check_limit }}
</span>
</template>
</template>
</el-table-column>
<el-table-column prop="concurrency" label="并发" width="60" align="center" />
<el-table-column label="下次执行" width="170" show-overflow-tooltip>
<template #default="{ row }">
<span v-if="row.enabled && row.next_run">{{ formatTime(row.next_run) }}</span>
<span v-else style="color:#c0c4cc;">--</span>
</template>
</el-table-column>
<el-table-column label="上次执行" width="170" show-overflow-tooltip>
<template #default="{ row }">
<span v-if="row.last_run_at">{{ formatTime(row.last_run_at) }}</span>
<span v-else style="color:#c0c4cc;">--</span>
</template>
</el-table-column>
<el-table-column label="上次批次" width="80" align="center">
<template #default="{ row }">
<router-link
v-if="row.last_session_id"
:to="'/sessions'"
class="session-link"
>#{{ row.last_session_id }}</router-link>
<span v-else style="color:#c0c4cc;">--</span>
</template>
</el-table-column>
<el-table-column label="状态" width="80" align="center">
<template #default="{ row }">
<el-tag :type="row.enabled ? 'success' : 'info'" size="small">
{{ row.enabled ? '启用' : '停用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="200" align="center">
<template #default="{ row }">
<el-button
:type="row.enabled ? 'warning' : 'success'"
size="small"
@click="toggle(row)"
>
{{ row.enabled ? '停用' : '启用' }}
</el-button>
<el-button size="small" @click="openDialog(row)">编辑</el-button>
<el-button type="danger" size="small" @click="remove(row)">删除</el-button>
</template>
</el-table-column>
<template #empty>
<el-empty description="暂无定时任务">
<el-button type="primary" @click="openDialog(null)">创建第一个</el-button>
</el-empty>
</template>
</el-table>
</el-card>
<!-- 执行记录 -->
<el-card style="margin-top:16px;">
<template #header>
<div style="display:flex; align-items:center; justify-content:space-between;">
<span style="font-weight:600;">执行记录</span>
<el-button size="small" @click="loadRuns">刷新</el-button>
</div>
</template>
<el-table :data="runs" v-loading="runsLoading" stripe size="small">
<el-table-column prop="id" label="ID" width="55" />
<el-table-column label="任务" min-width="120" show-overflow-tooltip>
<template #default="{ row }">
{{ row.schedule_name || `#${row.schedule_id}` }}
</template>
</el-table-column>
<el-table-column label="类型" width="100" align="center">
<template #default="{ row }">
<el-tag :type="taskTypeTag(row.task_type)" size="small">{{ taskTypeLabel(row.task_type) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="开始时间" width="170">
<template #default="{ row }">{{ formatTime(row.started_at) }}</template>
</el-table-column>
<el-table-column label="结束时间" width="170">
<template #default="{ row }">
<span v-if="row.finished_at">{{ formatTime(row.finished_at) }}</span>
<span v-else style="color:#e6a23c;">运行中...</span>
</template>
</el-table-column>
<el-table-column label="状态" width="90" align="center">
<template #default="{ row }">
<el-tag :type="runStatusTag(row.status)" size="small">{{ runStatusLabel(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="详情" min-width="250">
<template #default="{ row }">
<div v-if="row.status === 'running' && parseProgress(row.detail)">
<div style="display:flex; align-items:center; gap:8px;">
<el-progress
:percentage="parseProgress(row.detail).pct"
:stroke-width="14"
:text-inside="true"
style="flex:1;"
/>
</div>
<div style="font-size:12px; color:#909399; margin-top:2px;">{{ row.detail }}</div>
</div>
<span v-else>{{ row.detail || '' }}</span>
</template>
</el-table-column>
<template #empty>
<el-empty description="暂无执行记录" :image-size="60" />
</template>
</el-table>
</el-card>
<!-- 新建/编辑对话框 -->
<el-dialog
v-model="dialogVisible"
:title="editingId ? '编辑定时任务' : '新建定时任务'"
width="560px"
:close-on-click-modal="false"
>
<el-form :model="formData" label-width="90px">
<el-form-item label="任务名称">
<el-input v-model="formData.name" placeholder="可选,便于识别" />
</el-form-item>
<el-form-item label="任务类型">
<el-radio-group v-model="formData.task_type">
<el-radio value="register">注册账号</el-radio>
<el-radio value="check">检测存活</el-radio>
<el-radio value="refresh">刷新Token</el-radio>
<el-radio value="clean">清理失效</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="执行方式">
<el-radio-group v-model="formData.schedule_type">
<el-radio value="once">单次</el-radio>
<el-radio value="daily">每天</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="执行时间">
<el-date-picker
v-if="formData.schedule_type === 'once'"
v-model="formData.run_time_once"
type="datetime"
placeholder="选择日期时间"
format="YYYY-MM-DD HH:mm"
value-format="YYYY-MM-DDTHH:mm:ss"
style="width:100%;"
/>
<el-time-picker
v-else
v-model="formData.run_time_daily"
placeholder="选择时间"
format="HH:mm"
value-format="HH:mm"
style="width:220px;"
/>
</el-form-item>
<el-form-item v-if="formData.task_type !== 'clean'" label="代理池">
<el-input
v-model="formData.proxies"
type="textarea"
:rows="3"
placeholder="每行一个代理地址"
class="mono-input"
/>
</el-form-item>
<el-form-item v-if="formData.task_type === 'register'" label="目标数量">
<el-input-number v-model="formData.target" :min="1" :max="1000000" />
</el-form-item>
<el-form-item v-if="formData.task_type === 'check'" label="检测范围">
<el-select v-model="formData.check_filter" style="width:220px;">
<el-option value="all" label="全部成功账号" />
<el-option value="alive" label="仅存活账号" />
<el-option value="unchecked" label="仅未检测账号" />
</el-select>
</el-form-item>
<el-form-item v-if="formData.task_type === 'check' || formData.task_type === 'refresh'" label="数量限制">
<el-input-number v-model="formData.check_limit" :min="0" :max="1000000" />
<span style="margin-left:8px; font-size:12px; color:#909399;">0 = 全部</span>
</el-form-item>
<el-form-item v-if="formData.task_type === 'check'" label="自动清理">
<el-checkbox v-model="formData.auto_clean">检测后删除失效账号</el-checkbox>
</el-form-item>
<el-form-item v-if="formData.task_type !== 'clean'" label="并发数">
<el-input-number v-model="formData.concurrency" :min="1" :max="1000" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="save">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, onUnmounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
getSchedules, createSchedule, updateSchedule, toggleSchedule, deleteSchedule,
getSystemProxy, getScheduleRuns, getAllRuns,
} from '../api/index.js'
import { formatTime } from '../utils/format.js'
const schedules = ref([])
const loading = ref(false)
const dialogVisible = ref(false)
const saving = ref(false)
const editingId = ref(null)
const formData = reactive({
name: '',
task_type: 'register',
schedule_type: 'daily',
run_time_once: '',
run_time_daily: '08:00',
proxies: '',
target: 10,
concurrency: 3,
check_filter: 'all',
check_limit: 0,
auto_clean: false,
})
let defaultProxies = ''
async function loadSchedules() {
loading.value = true
try {
const resp = await getSchedules()
schedules.value = resp.data
} finally {
loading.value = false
}
}
function openDialog(row) {
if (row) {
editingId.value = row.id
formData.name = row.name || ''
formData.task_type = row.task_type || 'register'
formData.schedule_type = row.schedule_type
formData.proxies = row.proxies || ''
formData.target = row.target
formData.concurrency = row.concurrency
formData.check_filter = row.check_filter || 'all'
formData.check_limit = row.check_limit || 0
formData.auto_clean = !!row.auto_clean
if (row.schedule_type === 'daily') {
formData.run_time_daily = row.run_time
formData.run_time_once = ''
} else {
formData.run_time_once = row.run_time
formData.run_time_daily = '08:00'
}
} else {
editingId.value = null
formData.name = ''
formData.task_type = 'register'
formData.schedule_type = 'daily'
formData.run_time_once = ''
formData.run_time_daily = '08:00'
formData.proxies = defaultProxies
formData.target = 10
formData.concurrency = 3
formData.check_filter = 'all'
formData.check_limit = 0
formData.auto_clean = false
}
dialogVisible.value = true
}
async function save() {
const run_time = formData.schedule_type === 'daily'
? formData.run_time_daily
: formData.run_time_once
if (!run_time) {
ElMessage.warning('请选择执行时间')
return
}
if (formData.task_type !== 'clean' && !formData.proxies.trim()) {
ElMessage.warning('请填写代理池')
return
}
saving.value = true
const payload = {
name: formData.name,
task_type: formData.task_type,
proxies: formData.proxies,
target: formData.target,
concurrency: formData.concurrency,
check_filter: formData.check_filter,
check_limit: formData.check_limit,
auto_clean: formData.auto_clean,
schedule_type: formData.schedule_type,
run_time,
}
try {
if (editingId.value) {
await updateSchedule(editingId.value, payload)
ElMessage.success('已更新')
} else {
await createSchedule(payload)
ElMessage.success('已创建')
}
dialogVisible.value = false
await loadSchedules()
} catch (err) {
ElMessage.error(err.message)
} finally {
saving.value = false
}
}
async function toggle(row) {
try {
const resp = await toggleSchedule(row.id)
ElMessage.success(resp.data.enabled ? '已启用' : '已停用')
await loadSchedules()
} catch (err) {
ElMessage.error(err.message)
}
}
async function remove(row) {
try {
await ElMessageBox.confirm(
`确定删除任务「${row.name || '#' + row.id}」?`,
'删除确认',
{ type: 'warning' },
)
await deleteSchedule(row.id)
ElMessage.success('已删除')
await loadSchedules()
} catch {}
}
const TASK_TYPE_MAP = {
register: { label: '注册', tag: 'success' },
check: { label: '检测存活', tag: 'warning' },
refresh: { label: '刷新Token', tag: '' },
clean: { label: '清理失效', tag: 'danger' },
}
function taskTypeLabel(t) { return TASK_TYPE_MAP[t]?.label || t }
function taskTypeTag(t) { return TASK_TYPE_MAP[t]?.tag || 'info' }
function runStatusLabel(s) {
return { running: '运行中', done: '完成', failed: '失败' }[s] || s
}
function runStatusTag(s) {
return { running: 'warning', done: 'success', failed: 'danger' }[s] || 'info'
}
const runs = ref([])
const runsLoading = ref(false)
async function loadRuns() {
runsLoading.value = true
try {
const resp = await getAllRuns(50)
runs.value = resp.data
} finally {
runsLoading.value = false
}
}
function parseProgress(detail) {
if (!detail) return null
// 匹配 "成功 3 / 目标 10" 或 "已检测 5 / 20" 或 "已刷新 5 / 20"
const m = detail.match(/(\d+)\s*\/\s*(?:目标\s*)?(\d+)/)
if (!m) return null
const current = parseInt(m[1])
const total = parseInt(m[2])
if (total <= 0) return null
return { current, total, pct: Math.min(Math.round(current / total * 100), 100) }
}
let runsTimer = null
function startRunsPolling() {
stopRunsPolling()
runsTimer = setInterval(() => {
if (runs.value.some(r => r.status === 'running')) {
loadRuns()
}
}, 5000)
}
function stopRunsPolling() {
if (runsTimer) {
clearInterval(runsTimer)
runsTimer = null
}
}
onMounted(async () => {
try {
const resp = await getSystemProxy()
if (resp.data.proxy) defaultProxies = resp.data.proxy
} catch {}
loadSchedules()
loadRuns().then(() => startRunsPolling())
})
onUnmounted(() => {
stopRunsPolling()
})
</script>
<style scoped>
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.page-title {
font-size: 18px;
font-weight: 600;
}
.mono-input :deep(textarea) {
font-family: var(--font-mono);
font-size: 12px;
}
.session-link {
color: #409eff;
text-decoration: none;
}
.session-link:hover {
text-decoration: underline;
}
</style>

View File

@@ -0,0 +1,324 @@
<template>
<div>
<!-- Page header -->
<div class="page-header">
<div>
<span class="page-title">注册记录</span>
<el-text type="info" size="small" style="margin-left: 8px;"> {{ sessions.length }} 条记录</el-text>
</div>
<el-button :icon="Refresh" circle @click="loadSessions" :loading="loadingSessions" />
</div>
<!-- Filter bar -->
<el-card class="filter-card">
<el-row :gutter="12" align="middle">
<el-col :span="10">
<el-input
v-model="filterKeyword"
placeholder="搜索 ID 或日期..."
:prefix-icon="Search"
clearable
size="small"
/>
</el-col>
<el-col :span="8">
<el-select v-model="filterStatus" placeholder="全部状态" clearable size="small" style="width: 100%;">
<el-option label="全部状态" value="" />
<el-option label="运行中" value="running" />
<el-option label="已暂停" value="paused" />
<el-option label="导入中" value="importing" />
<el-option label="已完成" value="done" />
</el-select>
</el-col>
</el-row>
</el-card>
<!-- Sessions table -->
<el-card>
<el-table
:data="filteredSessions"
v-loading="loadingSessions"
row-key="id"
stripe
@expand-change="onExpand"
>
<el-table-column type="expand">
<template #default="{ row }">
<div style="padding: 12px 24px;">
<el-table
:data="accountsMap[row.id] || []"
v-loading="loadingAccounts[row.id]"
size="small"
:max-height="300"
>
<el-table-column prop="id" label="ID" width="60" />
<el-table-column prop="email" label="Email" min-width="180" />
<el-table-column prop="account_id" label="Account ID" min-width="160" show-overflow-tooltip />
<el-table-column label="过期时间" width="180">
<template #default="{ row: acct }">{{ formatTime(acct.expired) }}</template>
</el-table-column>
<el-table-column prop="exit_ip" label="出口 IP" min-width="140" show-overflow-tooltip />
<el-table-column prop="proxy_used" label="代理" min-width="140" show-overflow-tooltip />
<el-table-column label="存活" width="90">
<template #default="{ row: acct }">
<el-tag :type="aliveTagType(acct.alive)" size="small">{{ aliveLabel(acct.alive) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="注册状态" width="80">
<template #default="{ row: acct }">
<el-tag :type="acct.error ? 'danger' : 'success'" size="small">
{{ acct.error ? '失败' : '成功' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="error" label="错误" min-width="160" show-overflow-tooltip />
</el-table>
</div>
</template>
</el-table-column>
<el-table-column prop="id" label="ID" width="60" />
<el-table-column label="创建时间" width="180">
<template #default="{ row }">{{ formatTime(row.created_at) }}</template>
</el-table-column>
<el-table-column prop="proxy_count" label="代理数" width="70" align="center" />
<el-table-column prop="requested" label="目标" width="60" align="center" />
<el-table-column prop="concurrency" label="并发" width="60" align="center" />
<el-table-column label="IP 统计" width="140" align="center">
<template #default="{ row }">
<span v-if="row.unique_ips > 0">
<span class="ip-unique">{{ row.unique_ips }}</span>
<span v-if="row.reused_ips > 0" class="ip-reused">重复 {{ row.reused_ips }}</span>
</span>
<span v-else style="color: #c0c4cc;"></span>
</template>
</el-table-column>
<el-table-column label="成功" width="60" align="center">
<template #default="{ row }">
<span class="count-success">{{ row.success }}</span>
</template>
</el-table-column>
<el-table-column label="失败" width="60" align="center">
<template #default="{ row }">
<span class="count-failed">{{ row.failed }}</span>
</template>
</el-table-column>
<el-table-column label="进度" width="100" align="center">
<template #default="{ row }">
<span v-if="row.requested > 0">{{ row.success }} / {{ row.requested }}</span>
<span v-else style="color: #c0c4cc;"></span>
</template>
</el-table-column>
<el-table-column label="成功率" width="80" align="center">
<template #default="{ row }">
<span v-if="row.success + row.failed > 0" :style="{ color: successRateColor(row) }">
{{ Math.round((row.success / (row.success + row.failed)) * 100) }}%
</span>
<span v-else style="color: #c0c4cc;"></span>
</template>
</el-table-column>
<el-table-column label="状态" width="90" align="center">
<template #default="{ row }">
<el-tag
:type="statusTagType(row.status)"
size="small"
>
{{ statusLabel(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="160" align="center">
<template #default="{ row }">
<div style="display:flex; gap:4px; justify-content:center;">
<el-button
v-if="row.status === 'running'"
type="warning"
size="small"
@click.stop="doPause(row.id)"
>
暂停
</el-button>
<el-button
v-if="row.status === 'paused'"
type="success"
size="small"
@click.stop="doResume(row.id)"
>
继续
</el-button>
<el-button
type="primary"
size="small"
:icon="Download"
@click.stop="exportSession(row.id)"
:disabled="row.success === 0"
>
导出
</el-button>
</div>
</template>
</el-table-column>
<template #empty>
<el-empty description="暂无注册记录">
<el-button @click="loadSessions">刷新</el-button>
</el-empty>
</template>
</el-table>
</el-card>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
import { Refresh, Search, Download } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { getSessions, getAccounts, exportSessionUrl, pauseSession, resumeSession } from '../api/index.js'
import { formatTime, aliveTagType, aliveLabel } from '../utils/format.js'
const sessions = ref([])
const loadingSessions = ref(false)
const accountsMap = reactive({})
const loadingAccounts = reactive({})
const filterKeyword = ref('')
const filterStatus = ref('')
const filteredSessions = computed(() => {
return sessions.value.filter(s => {
const matchesStatus = !filterStatus.value || s.status === filterStatus.value
const matchesKeyword = !filterKeyword.value ||
String(s.id).includes(filterKeyword.value) ||
(s.created_at && s.created_at.includes(filterKeyword.value))
return matchesStatus && matchesKeyword
})
})
function statusTagType(s) {
if (s === 'done') return 'success'
if (s === 'paused') return 'info'
if (s === 'importing') return 'primary'
return 'warning'
}
function statusLabel(s) {
const map = { done: '已完成', paused: '已暂停', importing: '导入中', running: '运行中' }
return map[s] || s
}
function successRateColor(row) {
const total = row.success + row.failed
if (total === 0) return '#c0c4cc'
const rate = row.success / total
if (rate >= 0.8) return '#67c23a'
if (rate >= 0.5) return '#e6a23c'
return '#f56c6c'
}
let pollTimer = null
async function loadSessions() {
loadingSessions.value = true
try {
const resp = await getSessions()
sessions.value = resp.data
} finally {
loadingSessions.value = false
}
}
async function onExpand(row, expandedRows) {
const isExpanded = expandedRows.some((r) => r.id === row.id)
if (!isExpanded || accountsMap[row.id]) return
loadingAccounts[row.id] = true
try {
const resp = await getAccounts({ session_id: row.id, page_size: 200 })
accountsMap[row.id] = resp.data.items
} finally {
loadingAccounts[row.id] = false
}
}
function exportSession(id) {
window.open(exportSessionUrl(id))
}
async function doPause(id) {
try {
await pauseSession(id)
ElMessage.info('已暂停')
} catch (err) {
ElMessage.warning(err.message)
}
await loadSessions()
}
async function doResume(id) {
try {
await resumeSession(id)
ElMessage.success('已恢复')
} catch (err) {
ElMessage.warning(err.message)
}
await loadSessions()
}
function startPolling() {
pollTimer = setInterval(async () => {
if (document.hidden) return
const hasRunning = sessions.value.some((s) => s.status === 'running' || s.status === 'importing' || s.status === 'paused')
if (hasRunning) await loadSessions()
}, 3000)
}
onMounted(() => {
loadSessions()
startPolling()
})
onUnmounted(() => {
clearInterval(pollTimer)
})
</script>
<style scoped>
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.page-title {
font-size: 18px;
font-weight: 600;
}
.filter-card {
margin-bottom: 12px;
}
.filter-card :deep(.el-card__body) {
padding: 12px 16px;
}
.count-success {
color: #67c23a;
font-weight: 600;
}
.count-failed {
color: #f56c6c;
font-weight: 600;
}
.ip-unique {
color: #409eff;
font-weight: 600;
}
.ip-reused {
color: #e6a23c;
font-size: 12px;
}
</style>