mirror of
https://github.com/adminlove520/AI-Account-Toolkit.git
synced 2026-05-16 09:26:46 +08:00
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:
95
Code-Patch/frontend/src/App.vue
Normal file
95
Code-Patch/frontend/src/App.vue
Normal 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>
|
||||
104
Code-Patch/frontend/src/api/index.js
Normal file
104
Code-Patch/frontend/src/api/index.js
Normal 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
|
||||
}
|
||||
57
Code-Patch/frontend/src/composables/useCheckState.js
Normal file
57
Code-Patch/frontend/src/composables/useCheckState.js
Normal 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 }
|
||||
}
|
||||
40
Code-Patch/frontend/src/composables/useWebSocket.js
Normal file
40
Code-Patch/frontend/src/composables/useWebSocket.js
Normal 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 }
|
||||
}
|
||||
18
Code-Patch/frontend/src/main.js
Normal file
18
Code-Patch/frontend/src/main.js
Normal 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')
|
||||
18
Code-Patch/frontend/src/router/index.js
Normal file
18
Code-Patch/frontend/src/router/index.js
Normal 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,
|
||||
})
|
||||
35
Code-Patch/frontend/src/styles/variables.css
Normal file
35
Code-Patch/frontend/src/styles/variables.css
Normal 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);
|
||||
}
|
||||
28
Code-Patch/frontend/src/utils/format.js
Normal file
28
Code-Patch/frontend/src/utils/format.js
Normal 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 '未检测'
|
||||
}
|
||||
702
Code-Patch/frontend/src/views/AccountsView.vue
Normal file
702
Code-Patch/frontend/src/views/AccountsView.vue
Normal 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>
|
||||
418
Code-Patch/frontend/src/views/RegisterView.vue
Normal file
418
Code-Patch/frontend/src/views/RegisterView.vue
Normal 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="每行一个代理地址,例如: http://user:pass@host:port 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>
|
||||
{{ 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>
|
||||
483
Code-Patch/frontend/src/views/SchedulesView.vue
Normal file
483
Code-Patch/frontend/src/views/SchedulesView.vue
Normal 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>
|
||||
324
Code-Patch/frontend/src/views/SessionsView.vue
Normal file
324
Code-Patch/frontend/src/views/SessionsView.vue
Normal 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>
|
||||
Reference in New Issue
Block a user