Initial sanitized code sync

This commit is contained in:
zqq61
2026-03-15 20:48:19 +08:00
commit 17ee51ba04
126 changed files with 22546 additions and 0 deletions

13
web/embed.go Normal file
View File

@@ -0,0 +1,13 @@
package web
import (
"embed"
"io/fs"
)
//go:embed all:frontend/dist
var frontendFS embed.FS
func FrontendFS() (fs.FS, error) {
return fs.Sub(frontendFS, "frontend/dist")
}

7
web/frontend/env.d.ts vendored Normal file
View File

@@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}

13
web/frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>GPT-Plus 管理面板</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

2123
web/frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

25
web/frontend/package.json Normal file
View File

@@ -0,0 +1,25 @@
{
"name": "gptplus-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.5.0",
"vue-router": "^4.5.0",
"pinia": "^3.0.0",
"axios": "^1.7.0",
"element-plus": "^2.9.0",
"@element-plus/icons-vue": "^2.3.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.0",
"typescript": "~5.7.0",
"vite": "^6.2.0",
"vue-tsc": "^2.2.0"
}
}

3
web/frontend/src/App.vue Normal file
View File

@@ -0,0 +1,3 @@
<template>
<router-view />
</template>

View File

@@ -0,0 +1,20 @@
import axios from 'axios'
import router from '@/router'
const api = axios.create({
baseURL: '/api',
timeout: 30000,
withCredentials: true,
})
api.interceptors.response.use(
(res) => res,
(err) => {
if (err.response?.status === 401) {
router.push('/login')
}
return Promise.reject(err)
}
)
export default api

View File

@@ -0,0 +1,62 @@
<template>
<div v-if="taskStore.hasActiveTask" class="task-widget">
<el-popover placement="bottom-end" :width="280" trigger="click">
<template #reference>
<el-badge :value="taskStore.activeTask?.done_count + '/' + taskStore.activeTask?.total_count" type="primary">
<el-button :type="taskStore.activeTask?.status === 'stopping' ? 'warning' : 'primary'" size="small" circle>
<el-icon class="is-loading"><Loading /></el-icon>
</el-button>
</el-badge>
</template>
<div class="widget-detail">
<div class="widget-title">
{{ taskStore.activeTask?.status === 'stopping' ? '正在停止...' : '任务进行中' }}
</div>
<el-progress :percentage="taskStore.progress" :stroke-width="8" style="margin: 8px 0" />
<div class="widget-stats">
<span>类型: {{ taskStore.activeTask?.type }}</span>
<span>成功: {{ taskStore.activeTask?.success_count }}</span>
<span>失败: {{ taskStore.activeTask?.fail_count }}</span>
</div>
<div class="widget-actions">
<el-button size="small" type="primary" @click="goToDetail">查看详情</el-button>
</div>
</div>
</el-popover>
</div>
</template>
<script setup lang="ts">
import { useTaskStore } from '@/stores/taskStore'
import { useRouter } from 'vue-router'
import { Loading } from '@element-plus/icons-vue'
const taskStore = useTaskStore()
const router = useRouter()
function goToDetail() {
if (taskStore.activeTask) {
router.push(`/tasks/${taskStore.activeTask.id}`)
}
}
</script>
<style scoped>
.widget-detail {
font-size: 13px;
}
.widget-title {
font-weight: 600;
margin-bottom: 4px;
}
.widget-stats {
display: flex;
gap: 12px;
color: #606266;
margin: 8px 0;
}
.widget-actions {
margin-top: 8px;
text-align: right;
}
</style>

View File

@@ -0,0 +1,107 @@
<template>
<el-container class="app-layout">
<el-header class="app-header">
<div class="header-left">
<span class="logo" @click="$router.push('/')">GPT-Plus</span>
<el-menu
mode="horizontal"
:default-active="activeMenu"
:ellipsis="false"
router
class="header-menu"
>
<el-menu-item index="/">概览</el-menu-item>
<el-menu-item index="/config">配置</el-menu-item>
<el-menu-item index="/tasks">任务</el-menu-item>
<el-menu-item index="/cards">卡片</el-menu-item>
<el-menu-item index="/accounts">账号</el-menu-item>
<el-menu-item index="/email-records">邮箱</el-menu-item>
</el-menu>
</div>
<div class="header-right">
<TaskProgressWidget />
<el-button text @click="handleLogout">退出</el-button>
</div>
</el-header>
<el-main class="app-main">
<router-view />
</el-main>
</el-container>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/authStore'
import { useTaskStore } from '@/stores/taskStore'
import TaskProgressWidget from '@/components/TaskProgressWidget.vue'
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
const taskStore = useTaskStore()
const activeMenu = computed(() => {
const path = route.path
if (path.startsWith('/tasks')) return '/tasks'
if (path.startsWith('/accounts')) return '/accounts'
if (path.startsWith('/cards')) return '/cards'
return path
})
onMounted(() => {
taskStore.startPolling()
})
onUnmounted(() => {
taskStore.stopPolling()
})
async function handleLogout() {
await authStore.logout()
taskStore.stopPolling()
router.push('/login')
}
</script>
<style scoped>
.app-layout {
min-height: 100vh;
background: #f5f7fa;
}
.app-header {
background: #fff;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid #e4e7ed;
padding: 0 20px;
height: 56px;
}
.header-left {
display: flex;
align-items: center;
gap: 24px;
}
.logo {
font-size: 18px;
font-weight: 700;
color: #409eff;
cursor: pointer;
white-space: nowrap;
}
.header-menu {
border-bottom: none !important;
}
.header-right {
display: flex;
align-items: center;
gap: 12px;
}
.app-main {
padding: 20px;
max-width: 1400px;
margin: 0 auto;
width: 100%;
}
</style>

View File

@@ -0,0 +1,15 @@
<template>
<div class="blank-layout">
<router-view />
</div>
</template>
<style scoped>
.blank-layout {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
</style>

13
web/frontend/src/main.ts Normal file
View File

@@ -0,0 +1,13 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import './styles/global.css'
import App from './App.vue'
import router from './router'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.use(ElementPlus, { locale: undefined })
app.mount('#app')

View File

@@ -0,0 +1,42 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/authStore'
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/login',
component: () => import('@/layouts/BlankLayout.vue'),
children: [
{ path: '', component: () => import('@/views/Login.vue') },
],
},
{
path: '/',
component: () => import('@/layouts/AppLayout.vue'),
meta: { requiresAuth: true },
children: [
{ path: '', component: () => import('@/views/Dashboard.vue') },
{ path: 'config', component: () => import('@/views/Config.vue') },
{ path: 'tasks', component: () => import('@/views/Tasks.vue') },
{ path: 'tasks/:id', component: () => import('@/views/TaskDetail.vue') },
{ path: 'email-records', component: () => import('@/views/EmailRecords.vue') },
{ path: 'cards', component: () => import('@/views/Cards.vue') },
{ path: 'accounts', component: () => import('@/views/Accounts.vue') },
{ path: 'accounts/:id', component: () => import('@/views/AccountDetail.vue') },
],
},
],
})
router.beforeEach(async (to) => {
if (to.matched.some((r) => r.meta.requiresAuth)) {
const auth = useAuthStore()
if (!auth.authenticated) {
const ok = await auth.checkAuth()
if (!ok) return '/login'
}
}
})
export default router

View File

@@ -0,0 +1,30 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import api from '@/api'
export const useAuthStore = defineStore('auth', () => {
const authenticated = ref(false)
async function login(password: string) {
await api.post('/login', { password })
authenticated.value = true
}
async function logout() {
await api.post('/logout')
authenticated.value = false
}
async function checkAuth(): Promise<boolean> {
try {
await api.get('/auth/check')
authenticated.value = true
return true
} catch {
authenticated.value = false
return false
}
}
return { authenticated, login, logout, checkAuth }
})

View File

@@ -0,0 +1,63 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import api from '@/api'
export interface TaskInfo {
id: string
type: string
total_count: number
done_count: number
success_count: number
fail_count: number
status: string
created_at: string
started_at: string | null
stopped_at: string | null
}
export const useTaskStore = defineStore('task', () => {
const activeTask = ref<TaskInfo | null>(null)
const polling = ref(false)
let timer: ReturnType<typeof setInterval> | null = null
const hasActiveTask = computed(() => {
return activeTask.value !== null &&
['running', 'stopping'].includes(activeTask.value.status)
})
const progress = computed(() => {
if (!activeTask.value || activeTask.value.total_count === 0) return 0
return Math.round((activeTask.value.done_count / activeTask.value.total_count) * 100)
})
async function fetchActive() {
try {
const { data } = await api.get('/tasks', { params: { status: 'running,stopping' } })
const tasks = data.items || data
if (Array.isArray(tasks) && tasks.length > 0) {
activeTask.value = tasks[0]
} else {
activeTask.value = null
}
} catch {
// ignore
}
}
function startPolling() {
if (polling.value) return
polling.value = true
fetchActive()
timer = setInterval(fetchActive, 3000)
}
function stopPolling() {
polling.value = false
if (timer) {
clearInterval(timer)
timer = null
}
}
return { activeTask, hasActiveTask, progress, fetchActive, startPolling, stopPolling }
})

View File

@@ -0,0 +1,5 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}

View File

@@ -0,0 +1,203 @@
<template>
<div class="account-detail">
<el-page-header @back="$router.push('/accounts')">
<template #content>
<span>账号详情</span>
</template>
</el-page-header>
<el-card style="margin-top: 16px" v-loading="loading">
<el-descriptions :column="2" border>
<el-descriptions-item label="邮箱">{{ account?.email }}</el-descriptions-item>
<el-descriptions-item label="账号类型">
<el-tag :type="planTagType(account?.plan)" size="small">{{ planLabel(account?.plan) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="会员状态">
<el-tag :type="statusTagType(account?.status)" size="small">{{ statusLabel(account?.status) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="Account ID">{{ account?.account_id || '-' }}</el-descriptions-item>
<el-descriptions-item label="创建时间">
{{ account?.created_at ? new Date(account.created_at).toLocaleString('zh-CN') : '-' }}
</el-descriptions-item>
<el-descriptions-item label="上次检查">
{{ account?.status_checked_at ? new Date(account.status_checked_at).toLocaleString('zh-CN') : '未检查' }}
</el-descriptions-item>
<el-descriptions-item label="备注" :span="2">{{ account?.note || '-' }}</el-descriptions-item>
</el-descriptions>
<div class="actions">
<el-button type="primary" :loading="checking" @click="checkStatus">检查账号状态</el-button>
<el-button type="success" :loading="testing" @click="showTestDialog = true">测试模型可用性</el-button>
</div>
<el-alert
v-if="checkResult"
:type="checkResultAlertType(checkResult.status)"
style="margin-top: 12px"
show-icon
:closable="false"
>
<template #title>{{ checkResult.message }}</template>
<span v-if="checkResult.plan">会员等级: {{ checkResult.plan }}</span>
</el-alert>
</el-card>
<el-card v-if="modelResult" style="margin-top: 16px">
<template #header>模型测试结果</template>
<el-result :icon="modelResult.success ? 'success' : 'error'" :title="modelResult.message" :sub-title="'模型: ' + modelResult.model">
<template #extra>
<div v-if="modelResult.output" class="model-output">
<strong>模型回复:</strong> {{ modelResult.output }}
</div>
</template>
</el-result>
</el-card>
<el-card v-if="account?.sub_accounts?.length" style="margin-top: 16px">
<template #header>Team 子号 ({{ account.sub_accounts.length }})</template>
<el-table :data="account.sub_accounts" size="small" stripe>
<el-table-column prop="email" label="邮箱" min-width="220" />
<el-table-column prop="status" label="会员状态" width="100">
<template #default="{ row }">
<el-tag :type="statusTagType(row.status)" size="small">{{ statusLabel(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" width="180">
<template #default="{ row }">{{ new Date(row.created_at).toLocaleString('zh-CN') }}</template>
</el-table-column>
</el-table>
</el-card>
<el-dialog v-model="showTestDialog" title="测试模型可用性" width="420">
<el-form label-width="80px">
<el-form-item label="模型">
<el-select v-model="testModelId" filterable allow-create placeholder="选择或输入模型名称" style="width: 100%">
<el-option-group label="GPT-5">
<el-option label="gpt-5.4" value="gpt-5.4" />
<el-option label="gpt-5.3-codex" value="gpt-5.3-codex" />
</el-option-group>
<el-option-group label="常用模型">
<el-option label="gpt-4o" value="gpt-4o" />
<el-option label="gpt-4o-mini" value="gpt-4o-mini" />
<el-option label="o3-pro" value="o3-pro" />
<el-option label="o3" value="o3" />
<el-option label="o4-mini" value="o4-mini" />
<el-option label="gpt-4.5" value="gpt-4.5" />
</el-option-group>
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showTestDialog = false">取消</el-button>
<el-button type="primary" :loading="testing" @click="testModel">开始测试</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import api from '@/api'
const route = useRoute()
const accountId = route.params.id
const account = ref<any>(null)
const loading = ref(false)
const checking = ref(false)
const testing = ref(false)
const checkResult = ref<any>(null)
const modelResult = ref<any>(null)
const showTestDialog = ref(false)
const testModelId = ref('gpt-4o')
function planTagType(plan: string) {
return ({ plus: 'success', team_owner: 'primary', team_member: 'warning' } as any)[plan] || 'info'
}
function planLabel(plan: string) {
return ({ plus: 'Plus', team_owner: 'Team 母号', team_member: 'Team 子号' } as any)[plan] || plan
}
function statusTagType(status: string) {
return ({ free: 'info', plus: 'success', team: 'primary', banned: 'danger', unknown: 'warning', error: 'danger', active: 'info' } as any)[status] || 'info'
}
function statusLabel(status: string) {
return ({ free: 'Free', plus: 'Plus', team: 'Team', banned: 'Banned', unknown: 'Unknown', error: 'Error', active: 'Unchecked' } as any)[status] || status
}
function checkResultAlertType(status: string) {
if (status === 'banned' || status === 'error') return 'error'
if (status === 'unknown') return 'warning'
return 'success'
}
async function fetchAccount() {
loading.value = true
try {
const { data } = await api.get(`/accounts/${accountId}`)
account.value = data
} finally {
loading.value = false
}
}
async function checkStatus() {
checking.value = true
checkResult.value = null
try {
const { data } = await api.post('/accounts/check', { ids: [Number(accountId)] })
if (data.results?.length > 0) {
checkResult.value = data.results[0]
}
await fetchAccount()
ElMessage.success('检查完成')
} catch (error: any) {
ElMessage.error(error.response?.data?.error || '检查失败')
} finally {
checking.value = false
}
}
async function testModel() {
testing.value = true
modelResult.value = null
showTestDialog.value = false
try {
const { data } = await api.post('/accounts/test-model', {
id: Number(accountId),
model: testModelId.value,
})
modelResult.value = data
if (data.success) {
ElMessage.success(`模型 ${data.model} 可用`)
} else {
ElMessage.warning(data.message)
}
} catch (error: any) {
ElMessage.error(error.response?.data?.error || '测试失败')
} finally {
testing.value = false
}
}
onMounted(fetchAccount)
</script>
<style scoped>
.actions {
margin-top: 16px;
display: flex;
gap: 8px;
}
.model-output {
background: #f5f7fa;
padding: 12px;
border-radius: 6px;
max-width: 600px;
word-break: break-all;
}
</style>

View File

@@ -0,0 +1,408 @@
<template>
<div class="accounts-page">
<div class="page-header">
<h2>账号管理</h2>
<div class="header-actions">
<el-button :disabled="!selectedIds.length" type="primary" plain @click="batchExport">
批量导出 ({{ selectedIds.length }})
</el-button>
<el-button :disabled="!selectedIds.length || transferring" type="success" plain @click="batchTransferToCPA">
转移到 CPA ({{ selectedIds.length }})
</el-button>
<el-button :disabled="!selectedIds.length || checking" :loading="checking" @click="batchCheck">
{{ checkProgress || '批量检查账号状态' }}
</el-button>
</div>
</div>
<div class="filter-bar">
<el-select v-model="filter.plan" placeholder="账号类型" clearable style="width: 140px" @change="fetchAccounts">
<el-option label="全部" value="all" />
<el-option label="Plus" value="plus" />
<el-option label="Team 母号" value="team_owner" />
<el-option label="Team 子号" value="team_member" />
</el-select>
<el-select v-model="filter.status" placeholder="会员状态" clearable style="width: 140px" @change="fetchAccounts">
<el-option label="全部" value="" />
<el-option label="Free" value="free" />
<el-option label="Plus" value="plus" />
<el-option label="Team" value="team" />
<el-option label="封禁" value="banned" />
<el-option label="未知" value="unknown" />
<el-option label="未检查" value="active" />
</el-select>
<el-input
v-model="filter.search"
placeholder="搜索邮箱"
clearable
style="width: 240px"
@clear="fetchAccounts"
@keyup.enter="fetchAccounts"
/>
<el-button @click="fetchAccounts">搜索</el-button>
</div>
<el-table :data="accounts" v-loading="loading" stripe @selection-change="onSelectChange">
<el-table-column type="selection" width="40" />
<el-table-column prop="email" label="邮箱" min-width="220" />
<el-table-column prop="plan" label="账号类型" width="110">
<template #default="{ row }">
<el-tag :type="planTagType(row.plan)" size="small">{{ planLabel(row.plan) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="归属母号" min-width="160">
<template #default="{ row }">
<span v-if="row.plan === 'team_member' && row.parent">
<router-link :to="`/accounts/${row.parent.id}`">{{ row.parent.email }}</router-link>
</span>
<span v-else-if="row.plan === 'team_owner'" class="muted">
{{ row.sub_accounts?.length || 0 }} 个子号
</span>
<span v-else class="muted">-</span>
</template>
</el-table-column>
<el-table-column prop="status" label="会员状态" width="100">
<template #default="{ row }">
<el-tag :type="statusTagType(row.status)" size="small">{{ statusLabel(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="note" label="备注" min-width="100" show-overflow-tooltip />
<el-table-column prop="created_at" label="创建时间" width="170">
<template #default="{ row }">{{ new Date(row.created_at).toLocaleString('zh-CN') }}</template>
</el-table-column>
<el-table-column label="操作" width="200">
<template #default="{ row }">
<el-button size="small" @click="$router.push(`/accounts/${row.id}`)">详情</el-button>
<el-button size="small" @click="editNote(row)">备注</el-button>
<el-button size="small" type="primary" plain @click="exportOne(row.id)">导出</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
v-if="total > pageSize"
v-model:current-page="page"
style="margin-top: 16px; justify-content: center"
layout="prev, pager, next, total"
:total="total"
:page-size="pageSize"
@current-change="fetchAccounts"
/>
<el-dialog
v-model="transferDialogVisible"
title="转移到 CPA"
width="500"
:close-on-click-modal="false"
:close-on-press-escape="!transferring"
>
<div v-if="!transferring && transferResults.length === 0">
<p> <strong>{{ transferIds.length }}</strong> 个账号的 auth 文件上传到 CPA (CLI Proxy API)</p>
<p class="dialog-tip">Team 母号会自动包含其子号</p>
</div>
<div v-else>
<el-progress
:percentage="transferPercent"
:status="transferDone ? (transferErrors > 0 ? 'warning' : 'success') : ''"
:stroke-width="20"
striped
striped-flow
style="margin-bottom: 16px"
/>
<p class="dialog-progress">
进度: {{ transferDoneCount }}/{{ transferIds.length }}
<span v-if="transferErrors > 0" class="dialog-error">失败: {{ transferErrors }}</span>
</p>
<div v-if="transferResults.length" class="transfer-log">
<div v-for="result in transferResults" :key="result.id" class="transfer-log-item">
<el-icon v-if="result.ok" class="ok-icon">
<svg viewBox="0 0 1024 1024" width="14">
<path
d="M512 64a448 448 0 1 1 0 896 448 448 0 0 1 0-896zm-55.808 536.384-99.52-99.584a38.4 38.4 0 1 0-54.336 54.336l126.72 126.72a38.272 38.272 0 0 0 54.336 0l262.4-262.464a38.4 38.4 0 1 0-54.272-54.336L456.192 600.384z"
fill="currentColor"
/>
</svg>
</el-icon>
<el-icon v-else class="error-icon">
<svg viewBox="0 0 1024 1024" width="14">
<path
d="M512 64a448 448 0 1 1 0 896 448 448 0 0 1 0-896zm0 393.664L407.936 353.6a38.4 38.4 0 1 0-54.336 54.336L457.664 512 353.6 616.064a38.4 38.4 0 1 0 54.336 54.336L512 566.336 616.064 670.4a38.4 38.4 0 1 0 54.336-54.336L566.336 512 670.4 407.936a38.4 38.4 0 1 0-54.336-54.336L512 457.664z"
fill="currentColor"
/>
</svg>
</el-icon>
<span class="transfer-email">{{ result.email }}</span>
<span v-if="result.error" class="dialog-error">{{ result.error }}</span>
</div>
</div>
</div>
<template #footer>
<el-button v-if="!transferring && transferResults.length === 0" @click="transferDialogVisible = false">取消</el-button>
<el-button v-if="!transferring && transferResults.length === 0" type="primary" @click="doTransfer">开始转移</el-button>
<el-button v-if="transferDone" @click="transferDialogVisible = false">关闭</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import api from '@/api'
const accounts = ref<any[]>([])
const loading = ref(false)
const page = ref(1)
const pageSize = 20
const total = ref(0)
const selectedIds = ref<number[]>([])
const filter = reactive({ plan: 'all', status: '', search: '' })
function planTagType(plan: string) {
return ({ plus: 'success', team_owner: 'primary', team_member: 'warning' } as any)[plan] || 'info'
}
function planLabel(plan: string) {
return ({ plus: 'Plus', team_owner: 'Team 母号', team_member: 'Team 子号' } as any)[plan] || plan
}
function statusTagType(status: string) {
return ({ free: 'info', plus: 'success', team: 'primary', banned: 'danger', unknown: 'warning', error: 'danger', active: 'info' } as any)[status] || 'info'
}
function statusLabel(status: string) {
return ({ free: 'Free', plus: 'Plus', team: 'Team', banned: 'Banned', unknown: 'Unknown', error: 'Error', active: 'Unchecked' } as any)[status] || status
}
function onSelectChange(rows: any[]) {
selectedIds.value = rows.map((row) => row.id)
}
async function fetchAccounts() {
loading.value = true
try {
const { data } = await api.get('/accounts', {
params: { page: page.value, size: pageSize, ...filter },
})
accounts.value = data.items || data || []
total.value = data.total || 0
} finally {
loading.value = false
}
}
async function editNote(row: any) {
const { value } = await ElMessageBox.prompt('输入备注', '编辑备注', { inputValue: row.note || '' })
try {
await api.put(`/accounts/${row.id}/note`, { note: value })
ElMessage.success('备注已更新')
fetchAccounts()
} catch (error: any) {
ElMessage.error(error.response?.data?.error || '更新失败')
}
}
async function exportOne(id: number) {
try {
const resp = await api.post('/accounts/export', { ids: [id] }, { responseType: 'blob' })
downloadBlob(resp)
} catch {
ElMessage.error('导出失败')
}
}
async function batchExport() {
const { value: note } = await ElMessageBox.prompt('输入导出备注(可选)', '批量导出')
try {
const resp = await api.post('/accounts/export', { ids: selectedIds.value, note }, { responseType: 'blob' })
downloadBlob(resp)
} catch {
ElMessage.error('导出失败')
}
}
const transferring = ref(false)
const transferDialogVisible = ref(false)
const transferIds = ref<number[]>([])
const transferResults = ref<any[]>([])
const transferDoneCount = ref(0)
const transferErrors = ref(0)
const transferDone = ref(false)
const transferPercent = computed(() =>
transferIds.value.length ? Math.round((transferDoneCount.value / transferIds.value.length) * 100) : 0,
)
function batchTransferToCPA() {
transferIds.value = [...selectedIds.value]
transferResults.value = []
transferDoneCount.value = 0
transferErrors.value = 0
transferDone.value = false
transferDialogVisible.value = true
}
async function doTransfer() {
transferring.value = true
const ids = [...transferIds.value]
const concurrency = 2
let index = 0
const next = async (): Promise<void> => {
while (index < ids.length) {
const id = ids[index++]
try {
const { data } = await api.post(`/accounts/${id}/transfer-to-cpa`)
transferResults.value.push(data)
if (!data.ok) transferErrors.value++
} catch (error: any) {
const email = accounts.value.find((account) => account.id === id)?.email || `ID:${id}`
transferResults.value.push({ id, email, ok: false, error: error.response?.data?.error || '请求失败' })
transferErrors.value++
}
transferDoneCount.value++
}
}
await Promise.all(Array.from({ length: concurrency }, () => next()))
transferring.value = false
transferDone.value = true
if (transferErrors.value === 0) {
ElMessage.success(`全部 ${ids.length} 个账号已转移到 CPA`)
} else {
ElMessage.warning(`完成: ${ids.length - transferErrors.value} 成功, ${transferErrors.value} 失败`)
}
}
const checking = ref(false)
const checkProgress = ref('')
async function batchCheck() {
if (checking.value) return
checking.value = true
const ids = [...selectedIds.value]
let done = 0
const counts: Record<string, number> = {
free: 0,
plus: 0,
team: 0,
banned: 0,
unknown: 0,
error: 0,
active: 0,
}
checkProgress.value = `检查中 0/${ids.length}...`
const concurrency = 3
let index = 0
const next = async (): Promise<void> => {
while (index < ids.length) {
const id = ids[index++]
try {
const { data } = await api.post(`/accounts/${id}/check`)
if (data.status && data.status in counts) counts[data.status]++
else counts.error++
} catch {
counts.error++
}
done++
checkProgress.value = `检查中 ${done}/${ids.length} (free:${counts.free} plus:${counts.plus} team:${counts.team})`
}
}
await Promise.all(Array.from({ length: concurrency }, () => next()))
checkProgress.value = ''
checking.value = false
ElMessage.success(
`检查完成 free:${counts.free} plus:${counts.plus} team:${counts.team} banned:${counts.banned} unknown:${counts.unknown} error:${counts.error}`,
)
fetchAccounts()
}
function downloadBlob(resp: any) {
const cd = resp.headers['content-disposition'] || ''
const match = cd.match(/filename="?(.+?)"?$/)
const filename = match ? match[1] : 'export.zip'
const url = window.URL.createObjectURL(new Blob([resp.data]))
const a = document.createElement('a')
a.href = url
a.download = filename
a.click()
window.URL.revokeObjectURL(url)
}
onMounted(fetchAccounts)
</script>
<style scoped>
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.page-header h2 {
margin: 0;
}
.header-actions {
display: flex;
gap: 8px;
}
.filter-bar {
display: flex;
gap: 8px;
margin-bottom: 12px;
}
.muted {
color: #909399;
}
.dialog-tip {
color: #909399;
font-size: 13px;
}
.dialog-progress {
margin-bottom: 8px;
color: #606266;
}
.dialog-error {
color: #f56c6c;
margin-left: 8px;
}
.transfer-log {
max-height: 200px;
overflow-y: auto;
font-size: 13px;
background: #f5f7fa;
padding: 8px;
border-radius: 4px;
}
.transfer-log-item {
padding: 2px 0;
}
.transfer-email {
margin-left: 4px;
}
.ok-icon {
color: #67c23a;
}
.error-icon {
color: #f56c6c;
}
</style>

View File

@@ -0,0 +1,223 @@
<template>
<div class="cards-page">
<h2>卡片管理</h2>
<el-tabs v-model="activeTab" type="border-card">
<!-- Tab 1: Cards -->
<el-tab-pane label="卡片" name="cards">
<div class="stats-bar">
<el-tag v-for="s in cardStatItems" :key="s.label" :type="s.type" effect="plain" size="large">
{{ s.label }}: {{ s.value }}
</el-tag>
</div>
<div style="margin-bottom: 12px; text-align: right;">
<el-button type="primary" @click="showAddCard = true">手动添加卡片</el-button>
</div>
<el-table :data="cards" v-loading="loadingCards" stripe size="small">
<el-table-column prop="number_last4" label="卡号" min-width="160" />
<el-table-column prop="cvc_plain" label="CVC" width="60" />
<el-table-column label="有效期" width="90">
<template #default="{ row }">{{ row.exp_month }}/{{ row.exp_year }}</template>
</el-table-column>
<el-table-column prop="name" label="持卡人" width="120" />
<el-table-column prop="country" label="国家" width="60" />
<el-table-column prop="status" label="状态" width="90">
<template #default="{ row }">
<el-tag :type="cardStatusType(row.status)" size="small">{{ row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column label="已用/上限" width="90">
<template #default="{ row }">{{ row.bind_count }}/{{ row.max_binds || '∞' }}</template>
</el-table-column>
<el-table-column label="操作" width="200">
<template #default="{ row }">
<el-button v-if="row.status === 'available'" size="small" type="success" @click="activateCard(row.id)">激活</el-button>
<el-button v-if="['available','active'].includes(row.status)" size="small" type="warning" @click="disableCard(row.id)">禁用</el-button>
<el-button size="small" type="danger" plain @click="deleteCard(row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
<!-- Tab 2: Card Codes -->
<el-tab-pane label="卡密" name="codes">
<div class="stats-bar">
<el-tag v-for="s in codeStatItems" :key="s.label" :type="s.type" effect="plain" size="large">
{{ s.label }}: {{ s.value }}
</el-tag>
</div>
<div style="margin-bottom: 12px; display: flex; gap: 8px; justify-content: flex-end;">
<el-button type="primary" @click="showImportCodes = true">批量导入卡密</el-button>
</div>
<el-table :data="codes" v-loading="loadingCodes" stripe size="small">
<el-table-column prop="code" label="卡密" min-width="200" />
<el-table-column prop="status" label="状态" width="90">
<template #default="{ row }">
<el-tag :type="codeStatusType(row.status)" size="small">{{ row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="redeemed_at" label="兑换时间" width="180">
<template #default="{ row }">{{ row.redeemed_at ? new Date(row.redeemed_at).toLocaleString('zh-CN') : '-' }}</template>
</el-table-column>
<el-table-column label="操作" width="160">
<template #default="{ row }">
<el-button v-if="row.status === 'unused'" size="small" type="success" @click="redeemCode(row.id)">兑换</el-button>
<el-button size="small" type="danger" plain @click="deleteCode(row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
</el-tabs>
<!-- Add card dialog -->
<el-dialog v-model="showAddCard" title="添加卡片" width="500">
<el-form :model="cardForm" label-width="100px">
<el-form-item label="卡号"><el-input v-model="cardForm.number" /></el-form-item>
<el-form-item label="CVC"><el-input v-model="cardForm.cvc" style="width: 120px" /></el-form-item>
<el-form-item label="有效月"><el-input v-model="cardForm.exp_month" style="width: 80px" /></el-form-item>
<el-form-item label="有效年"><el-input v-model="cardForm.exp_year" style="width: 100px" /></el-form-item>
<el-form-item label="持卡人"><el-input v-model="cardForm.name" /></el-form-item>
<el-form-item label="国家"><el-input v-model="cardForm.country" style="width: 80px" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="showAddCard = false">取消</el-button>
<el-button type="primary" @click="addCard">确认</el-button>
</template>
</el-dialog>
<!-- Import codes dialog -->
<el-dialog v-model="showImportCodes" title="批量导入卡密" width="500">
<el-input v-model="importText" type="textarea" :rows="10" placeholder="每行一个卡密" />
<template #footer>
<el-button @click="showImportCodes = false">取消</el-button>
<el-button type="primary" @click="importCodes">导入</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed, reactive, onMounted } from 'vue'
import api from '@/api'
import { ElMessage, ElMessageBox } from 'element-plus'
const activeTab = ref('cards')
const cards = ref<any[]>([])
const codes = ref<any[]>([])
const cardStats = ref<any>({})
const codeStats = ref<any>({})
const loadingCards = ref(false)
const loadingCodes = ref(false)
const showAddCard = ref(false)
const showImportCodes = ref(false)
const importText = ref('')
const cardForm = reactive({ number: '', cvc: '', exp_month: '', exp_year: '', name: '', country: 'US' })
const cardStatItems = computed(() => [
{ label: '总数', value: cardStats.value.total ?? 0, type: '' as const },
{ label: '可用', value: cardStats.value.available ?? 0, type: 'success' as const },
{ label: '激活中', value: cardStats.value.active ?? 0, type: 'primary' as const },
{ label: '已耗尽', value: cardStats.value.exhausted ?? 0, type: 'warning' as const },
{ label: '已过期', value: cardStats.value.expired ?? 0, type: 'warning' as const },
{ label: '被拒', value: cardStats.value.rejected ?? 0, type: 'danger' as const },
])
const codeStatItems = computed(() => [
{ label: '总数', value: codeStats.value.total ?? 0, type: '' as const },
{ label: '未使用', value: codeStats.value.unused ?? 0, type: 'success' as const },
{ label: '已兑换', value: codeStats.value.redeemed ?? 0, type: 'primary' as const },
{ label: '失败', value: codeStats.value.failed ?? 0, type: 'danger' as const },
])
function cardStatusType(s: string) {
return ({ active: 'success', available: 'primary', exhausted: 'warning', expired: 'warning', rejected: 'danger', disabled: 'info' } as any)[s] || 'info'
}
function codeStatusType(s: string) {
return ({ unused: 'success', redeemed: 'primary', redeeming: 'warning', failed: 'danger' } as any)[s] || 'info'
}
function maskCode(code: string) {
if (code.length <= 8) return code
return code.slice(0, 4) + '****' + code.slice(-4)
}
async function fetchCards() {
loadingCards.value = true
try {
const [{ data: list }, { data: stats }] = await Promise.all([
api.get('/cards'), api.get('/cards/stats'),
])
cards.value = list.items || list || []
cardStats.value = stats
} catch { /* */ } finally { loadingCards.value = false }
}
async function fetchCodes() {
loadingCodes.value = true
try {
const [{ data: list }, { data: stats }] = await Promise.all([
api.get('/card-codes'), api.get('/card-codes/stats'),
])
codes.value = list.items || list || []
codeStats.value = stats
} catch { /* */ } finally { loadingCodes.value = false }
}
async function addCard() {
try {
await api.post('/cards', cardForm)
ElMessage.success('卡片已添加')
showAddCard.value = false
fetchCards()
} catch (e: any) { ElMessage.error(e.response?.data?.error || '添加失败') }
}
async function activateCard(id: number) {
try { await api.put(`/cards/${id}/activate`); ElMessage.success('已激活'); fetchCards() }
catch (e: any) { ElMessage.error(e.response?.data?.error || '操作失败') }
}
async function disableCard(id: number) {
try { await api.put(`/cards/${id}/status`, { status: 'disabled' }); ElMessage.success('已禁用'); fetchCards() }
catch (e: any) { ElMessage.error(e.response?.data?.error || '操作失败') }
}
async function deleteCard(id: number) {
await ElMessageBox.confirm('确认删除?', '警告', { type: 'warning' })
try { await api.delete(`/cards/${id}`); ElMessage.success('已删除'); fetchCards() }
catch (e: any) { ElMessage.error(e.response?.data?.error || '删除失败') }
}
async function importCodes() {
const lines = importText.value.split('\n').map(l => l.trim()).filter(Boolean)
if (lines.length === 0) return
try {
await api.post('/card-codes/import', { codes: lines })
ElMessage.success(`已导入 ${lines.length} 个卡密`)
showImportCodes.value = false
importText.value = ''
fetchCodes()
} catch (e: any) { ElMessage.error(e.response?.data?.error || '导入失败') }
}
async function redeemCode(id: number) {
try { await api.post('/card-codes/redeem', { id }); ElMessage.success('兑换中...'); fetchCodes() }
catch (e: any) { ElMessage.error(e.response?.data?.error || '兑换失败') }
}
async function deleteCode(id: number) {
await ElMessageBox.confirm('确认删除?', '警告', { type: 'warning' })
try { await api.delete(`/card-codes/${id}`); ElMessage.success('已删除'); fetchCodes() }
catch (e: any) { ElMessage.error(e.response?.data?.error || '删除失败') }
}
onMounted(() => { fetchCards(); fetchCodes() })
</script>
<style scoped>
.stats-bar { display: flex; gap: 8px; margin-bottom: 12px; }
</style>

View File

@@ -0,0 +1,143 @@
<template>
<div class="config-page">
<el-page-header title="返回" @back="$router.push('/')">
<template #content>
<span class="page-title">系统配置</span>
</template>
</el-page-header>
<el-card style="margin-top: 16px" v-loading="loading">
<el-tabs v-model="activeTab" type="border-card">
<el-tab-pane
v-for="group in groupOrder"
:key="group.key"
:label="group.label"
:name="group.key"
>
<el-form label-width="180px" label-position="right">
<el-form-item
v-for="item in (groups[group.key] || [])"
:key="item.key"
:label="item.label"
>
<el-switch
v-if="item.type === 'bool'"
v-model="formData[item.key]"
active-value="true"
inactive-value="false"
/>
<el-input-number
v-else-if="item.type === 'int'"
v-model.number="formData[item.key]"
:min="0"
controls-position="right"
/>
<el-input
v-else-if="item.type === 'password'"
v-model="formData[item.key]"
type="password"
show-password
placeholder="输入后保存"
/>
<el-input
v-else-if="item.type === 'textarea'"
v-model="formData[item.key]"
type="textarea"
:rows="3"
/>
<el-input
v-else
v-model="formData[item.key]"
clearable
/>
</el-form-item>
</el-form>
</el-tab-pane>
</el-tabs>
<div style="margin-top: 16px; text-align: right;">
<el-button @click="fetchConfig">重置</el-button>
<el-button type="primary" :loading="saving" @click="saveConfig">保存配置</el-button>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import api from '@/api'
import { ElMessage } from 'element-plus'
interface ConfigItem {
id: number
key: string
value: string
group: string
label: string
type: string
}
const groupOrder = [
{ key: 'proxy', label: '代理' },
{ key: 'email', label: '邮箱网关' },
{ key: 'card', label: '卡片' },
{ key: 'stripe', label: 'Stripe' },
{ key: 'captcha', label: '验证码' },
{ key: 'account', label: '账号' },
{ key: 'team', label: 'Team' },
{ key: 'cpa', label: 'CPA' },
]
const activeTab = ref('proxy')
const loading = ref(false)
const saving = ref(false)
const groups = ref<Record<string, ConfigItem[]>>({})
const formData = reactive<Record<string, any>>({})
async function fetchConfig() {
loading.value = true
try {
const { data } = await api.get('/config')
groups.value = data.groups || {}
for (const items of Object.values(groups.value) as ConfigItem[][]) {
for (const item of items) {
if (item.type === 'int') {
formData[item.key] = Number(item.value) || 0
} else {
formData[item.key] = item.value
}
}
}
} catch (e: any) {
ElMessage.error('加载配置失败')
} finally {
loading.value = false
}
}
async function saveConfig() {
saving.value = true
try {
const items: { key: string; value: string }[] = []
for (const [key, value] of Object.entries(formData)) {
items.push({ key, value: String(value) })
}
await api.put('/config', { items })
ElMessage.success('配置已保存')
await fetchConfig()
} catch (e: any) {
ElMessage.error(e.response?.data?.error || '保存失败')
} finally {
saving.value = false
}
}
onMounted(fetchConfig)
</script>
<style scoped>
.page-title {
font-size: 16px;
font-weight: 600;
}
</style>

View File

@@ -0,0 +1,81 @@
<template>
<div class="dashboard">
<h2>概览</h2>
<el-row :gutter="16">
<el-col :span="6" v-for="card in statCards" :key="card.label">
<el-card shadow="hover" class="stat-card">
<div class="stat-value">{{ card.value }}</div>
<div class="stat-label">{{ card.label }}</div>
</el-card>
</el-col>
</el-row>
<el-row :gutter="16" style="margin-top: 16px">
<el-col :span="12">
<el-card>
<template #header>最近任务</template>
<el-table :data="stats?.recent_tasks || []" size="small" stripe>
<el-table-column prop="id" label="ID" width="100">
<template #default="{ row }">
<router-link :to="`/tasks/${row.id}`">{{ row.id.slice(0, 8) }}</router-link>
</template>
</el-table-column>
<el-table-column prop="type" label="类型" width="80" />
<el-table-column label="进度" width="120">
<template #default="{ row }">{{ row.done_count }}/{{ row.total_count }}</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="statusType(row.status)" size="small">{{ row.status }}</el-tag>
</template>
</el-table-column>
</el-table>
</el-card>
</el-col>
<el-col :span="12">
<el-card>
<template #header>系统信息</template>
<el-descriptions :column="1" border size="small">
<el-descriptions-item label="成功率">{{ stats?.success_rate?.toFixed(1) || 0 }}%</el-descriptions-item>
<el-descriptions-item label="活跃任务">{{ stats?.active_task ? '是' : '无' }}</el-descriptions-item>
</el-descriptions>
</el-card>
</el-col>
</el-row>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import api from '@/api'
const stats = ref<any>(null)
const statCards = computed(() => [
{ label: '总账号', value: stats.value?.total_accounts ?? '-' },
{ label: 'Plus', value: stats.value?.plus_count ?? '-' },
{ label: 'Team', value: stats.value?.team_count ?? '-' },
{ label: '今日注册', value: stats.value?.today_registrations ?? '-' },
])
function statusType(s: string) {
const map: Record<string, string> = {
completed: 'success', running: 'primary', stopping: 'warning',
stopped: 'info', interrupted: 'danger', pending: 'info',
}
return map[s] || 'info'
}
onMounted(async () => {
try {
const { data } = await api.get('/dashboard')
stats.value = data
} catch { /* */ }
})
</script>
<style scoped>
.stat-card { text-align: center; }
.stat-value { font-size: 28px; font-weight: 700; color: #409eff; }
.stat-label { font-size: 13px; color: #909399; margin-top: 4px; }
</style>

View File

@@ -0,0 +1,79 @@
<template>
<div class="email-records-page">
<h2>邮箱记录</h2>
<div class="stats-bar">
<el-tag v-for="s in statItems" :key="s.label" :type="s.type" effect="plain" size="large">
{{ s.label }}: {{ s.value }}
</el-tag>
</div>
<el-table :data="records" v-loading="loading" stripe size="small">
<el-table-column prop="email" label="邮箱" min-width="200" />
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="statusType(row.status)" size="small">{{ statusLabel(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="used_for_role" label="用途" width="80" />
<el-table-column prop="created_at" label="创建时间" width="180">
<template #default="{ row }">{{ new Date(row.created_at).toLocaleString('zh-CN') }}</template>
</el-table-column>
</el-table>
<el-pagination
v-if="total > pageSize"
style="margin-top: 16px; justify-content: center"
layout="prev, pager, next"
:total="total"
:page-size="pageSize"
v-model:current-page="page"
@current-change="fetchRecords"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import api from '@/api'
const records = ref<any[]>([])
const stats = ref<any>({})
const loading = ref(false)
const page = ref(1)
const pageSize = 20
const total = ref(0)
const statItems = computed(() => [
{ label: '总数', value: stats.value.total ?? 0, type: '' as const },
{ label: '已用(主号)', value: stats.value.used ?? 0, type: 'success' as const },
{ label: '已用(小号)', value: stats.value.used_member ?? 0, type: 'primary' as const },
{ label: '注册失败', value: stats.value.used_failed ?? 0, type: 'danger' as const },
])
function statusType(s: string) {
return ({ used: 'success', used_member: 'primary', used_failed: 'danger', in_use: 'warning' } as any)[s] || 'info'
}
function statusLabel(s: string) {
return ({ used: '已用', used_member: '小号', used_failed: '失败', in_use: '使用中' } as any)[s] || s
}
async function fetchRecords() {
loading.value = true
try {
const [{ data: list }, { data: s }] = await Promise.all([
api.get('/email-records', { params: { page: page.value, size: pageSize } }),
api.get('/email-records/stats'),
])
records.value = list.items || list || []
total.value = list.total || 0
stats.value = s
} catch { /* */ } finally { loading.value = false }
}
onMounted(fetchRecords)
</script>
<style scoped>
.stats-bar { display: flex; gap: 8px; margin-bottom: 12px; }
</style>

View File

@@ -0,0 +1,82 @@
<template>
<el-card class="login-card" shadow="always">
<template #header>
<div class="login-header">
<h2>GPT-Plus</h2>
<p>管理面板</p>
</div>
</template>
<el-form @submit.prevent="handleLogin" :model="form">
<el-form-item>
<el-input
v-model="form.password"
type="password"
placeholder="输入管理密码"
size="large"
show-password
@keyup.enter="handleLogin"
/>
</el-form-item>
<el-form-item>
<el-button
type="primary"
size="large"
style="width: 100%"
:loading="loading"
@click="handleLogin"
>
登录
</el-button>
</el-form-item>
</el-form>
</el-card>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/authStore'
import { ElMessage } from 'element-plus'
const router = useRouter()
const authStore = useAuthStore()
const loading = ref(false)
const form = reactive({ password: '' })
async function handleLogin() {
if (!form.password) {
ElMessage.warning('请输入密码')
return
}
loading.value = true
try {
await authStore.login(form.password)
ElMessage.success('登录成功')
router.push('/')
} catch (e: any) {
ElMessage.error(e.response?.data?.error || '登录失败')
} finally {
loading.value = false
}
}
</script>
<style scoped>
.login-card {
width: 380px;
border-radius: 12px;
}
.login-header {
text-align: center;
}
.login-header h2 {
margin: 0;
color: #409eff;
font-size: 24px;
}
.login-header p {
margin: 4px 0 0;
color: #909399;
font-size: 14px;
}
</style>

View File

@@ -0,0 +1,235 @@
<template>
<div class="task-detail">
<el-page-header @back="$router.push('/tasks')">
<template #content>
<span>任务详情 {{ task?.id?.slice(0, 8) }}</span>
</template>
<template #extra>
<el-space>
<el-button v-if="task?.status === 'running'" type="warning" @click="stopTask">优雅停止</el-button>
<el-button v-if="task?.status === 'running'" type="danger" @click="forceStopTask">强制取消</el-button>
</el-space>
</template>
</el-page-header>
<el-row :gutter="16" style="margin-top: 16px">
<el-col :span="8">
<el-card>
<el-descriptions :column="1" border size="small">
<el-descriptions-item label="类型">{{ task?.type }}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="statusType(task?.status)" size="small">{{ statusLabel(task?.status) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="总数">{{ task?.total_count }}</el-descriptions-item>
<el-descriptions-item label="成功">{{ task?.success_count }}</el-descriptions-item>
<el-descriptions-item label="失败">{{ task?.fail_count }}</el-descriptions-item>
</el-descriptions>
</el-card>
</el-col>
<el-col :span="16">
<el-card>
<el-progress
:percentage="task?.total_count ? Math.round((task?.done_count || 0) / task.total_count * 100) : 0"
:stroke-width="20"
:text-inside="true"
style="margin-bottom: 16px"
/>
<div v-if="currentStep" class="current-step">
<el-icon class="is-loading"><Loading /></el-icon>
<span>{{ currentStep }}</span>
</div>
</el-card>
</el-col>
</el-row>
<el-card style="margin-top: 16px">
<template #header>实时日志</template>
<div class="log-timeline" ref="logContainer">
<div v-for="log in logs" :key="log.id" class="log-entry" :class="'log-' + log.status">
<div class="log-marker">
<span v-if="log.status === 'step'" class="dot dot-step"></span>
<span v-else-if="log.status === 'success'" class="dot dot-success"></span>
<span v-else class="dot dot-fail"></span>
</div>
<div class="log-content">
<div class="log-header">
<span class="log-index">#{{ log.index }}</span>
<el-tag v-if="log.status !== 'step'" :type="log.status === 'success' ? 'success' : 'danger'" size="small">
{{ log.status === 'success' ? '成功' : '失败' }}
</el-tag>
<span v-if="log.email" class="log-email">{{ log.email }}</span>
<span v-if="log.plan && log.status !== 'step'" class="log-plan">[{{ log.plan }}]</span>
<span v-if="log.duration && log.status !== 'step'" class="log-duration">{{ log.duration }}s</span>
</div>
<div class="log-message">{{ log.message || log.error || '' }}</div>
</div>
<div class="log-time">{{ formatTime(log.created_at) }}</div>
</div>
<div v-if="logs.length === 0" class="log-empty">
<span v-if="task?.status === 'running'">等待任务输出...</span>
<span v-else>暂无日志</span>
</div>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
import { useRoute } from 'vue-router'
import api from '@/api'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Loading } from '@element-plus/icons-vue'
const route = useRoute()
const taskId = route.params.id as string
const task = ref<any>(null)
const logs = ref<any[]>([])
const logContainer = ref<HTMLElement>()
let sinceId = 0
let timer: ReturnType<typeof setInterval> | null = null
const currentStep = computed(() => {
if (!task.value || task.value.status !== 'running') return ''
const stepLogs = logs.value.filter(l => l.status === 'step')
return stepLogs.length > 0 ? stepLogs[stepLogs.length - 1].message : '准备中...'
})
function statusType(s: string | undefined) {
const m: Record<string, string> = {
pending: 'info', running: 'primary', stopping: 'warning',
stopped: 'warning', interrupted: 'danger', completed: 'success',
}
return m[s || ''] || 'info'
}
function statusLabel(s: string | undefined) {
const m: Record<string, string> = {
pending: '待启动', running: '运行中', stopping: '停止中',
stopped: '已停止', interrupted: '已中断', completed: '已完成',
}
return m[s || ''] || s || ''
}
function formatTime(t: string) {
if (!t) return ''
const d = new Date(t)
return d.toLocaleTimeString('zh-CN', { hour12: false })
}
async function fetchTask() {
try {
const { data } = await api.get(`/tasks/${taskId}`)
task.value = data
} catch { /* */ }
}
async function fetchLogs() {
try {
const { data } = await api.get(`/tasks/${taskId}/logs`, { params: { since_id: sinceId, limit: 100 } })
const items = data.items || data || []
if (items.length > 0) {
logs.value.push(...items)
sinceId = items[items.length - 1].id
await nextTick()
if (logContainer.value) {
logContainer.value.scrollTop = logContainer.value.scrollHeight
}
}
} catch { /* */ }
}
async function stopTask() {
try {
await api.post(`/tasks/${taskId}/stop`)
ElMessage.success('正在停止...')
} catch (e: any) {
ElMessage.error(e.response?.data?.error || '操作失败')
}
}
async function forceStopTask() {
await ElMessageBox.confirm('确认强制取消?', '警告', { type: 'warning' })
try {
await api.post(`/tasks/${taskId}/force-stop`)
ElMessage.success('已强制取消')
} catch (e: any) {
ElMessage.error(e.response?.data?.error || '操作失败')
}
}
onMounted(() => {
fetchTask()
fetchLogs()
timer = setInterval(() => {
fetchTask()
fetchLogs()
}, 2000)
})
onUnmounted(() => {
if (timer) clearInterval(timer)
})
</script>
<style scoped>
.current-step {
display: flex;
align-items: center;
gap: 8px;
color: #409eff;
font-size: 13px;
}
.log-timeline {
max-height: 520px;
overflow-y: auto;
padding: 4px 0;
}
.log-entry {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 6px 0;
border-bottom: 1px solid #f0f0f0;
font-size: 13px;
}
.log-entry:last-child { border-bottom: none; }
.log-marker { padding-top: 3px; flex-shrink: 0; }
.dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
}
.dot-step { background: #409eff; }
.dot-success { background: #67c23a; }
.dot-fail { background: #f56c6c; }
.log-content { flex: 1; min-width: 0; }
.log-header {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.log-index { color: #909399; font-weight: 600; }
.log-email { color: #606266; }
.log-plan { color: #409eff; font-size: 12px; }
.log-duration { color: #909399; font-size: 12px; }
.log-message {
color: #303133;
margin-top: 2px;
word-break: break-all;
}
.log-step .log-message { color: #909399; }
.log-time {
color: #c0c4cc;
font-size: 12px;
white-space: nowrap;
flex-shrink: 0;
}
.log-empty {
text-align: center;
padding: 40px 0;
color: #c0c4cc;
}
</style>

View File

@@ -0,0 +1,173 @@
<template>
<div class="tasks-page">
<div class="page-header">
<h2>任务管理</h2>
<el-button type="primary" @click="showCreateDialog = true">新建任务</el-button>
</div>
<el-table :data="tasks" v-loading="loading" stripe>
<el-table-column prop="id" label="ID" width="120">
<template #default="{ row }">
<router-link :to="`/tasks/${row.id}`">{{ row.id.slice(0, 8) }}...</router-link>
</template>
</el-table-column>
<el-table-column prop="type" label="类型" width="80" />
<el-table-column label="进度" width="160">
<template #default="{ row }">
<el-progress
:percentage="row.total_count ? Math.round(row.done_count / row.total_count * 100) : 0"
:stroke-width="12"
:format="() => `${row.done_count}/${row.total_count}`"
/>
</template>
</el-table-column>
<el-table-column label="成功/失败" width="100">
<template #default="{ row }">
<span style="color:#67c23a">{{ row.success_count }}</span> /
<span style="color:#f56c6c">{{ row.fail_count }}</span>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="110">
<template #default="{ row }">
<el-tag :type="statusType(row.status)" size="small">{{ statusLabel(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" width="180">
<template #default="{ row }">{{ formatTime(row.created_at) }}</template>
</el-table-column>
<el-table-column label="操作" width="220">
<template #default="{ row }">
<el-button v-if="row.status === 'pending'" size="small" type="success" @click="startTask(row.id)">启动</el-button>
<el-button v-if="row.status === 'running'" size="small" type="warning" @click="stopTask(row.id)">停止</el-button>
<el-button v-if="row.status === 'running'" size="small" type="danger" @click="forceStopTask(row.id)">强制取消</el-button>
<el-button v-if="['pending','completed','stopped','interrupted'].includes(row.status)" size="small" type="danger" plain @click="deleteTask(row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-dialog v-model="showCreateDialog" title="新建任务" width="400">
<el-form :model="createForm" label-width="80px">
<el-form-item label="类型">
<el-select v-model="createForm.type">
<el-option label="Plus" value="plus" />
<el-option label="Team" value="team" />
<el-option label="Plus + Team" value="both" />
</el-select>
</el-form-item>
<el-form-item label="数量">
<el-input-number v-model="createForm.count" :min="1" :max="100" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showCreateDialog = false">取消</el-button>
<el-button type="primary" :loading="creating" @click="createTask">创建</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import api from '@/api'
import { ElMessage, ElMessageBox } from 'element-plus'
const tasks = ref<any[]>([])
const loading = ref(false)
const showCreateDialog = ref(false)
const creating = ref(false)
const createForm = reactive({ type: 'plus', count: 10 })
function statusType(s: string) {
const m: Record<string, string> = {
pending: 'info', running: 'primary', stopping: 'warning',
stopped: 'warning', interrupted: 'danger', completed: 'success',
}
return m[s] || 'info'
}
function statusLabel(s: string) {
const m: Record<string, string> = {
pending: '待启动', running: '运行中', stopping: '停止中',
stopped: '已停止', interrupted: '已中断', completed: '已完成',
}
return m[s] || s
}
function formatTime(t: string) {
if (!t) return '-'
return new Date(t).toLocaleString('zh-CN')
}
async function fetchTasks() {
loading.value = true
try {
const { data } = await api.get('/tasks')
tasks.value = data.items || data || []
} catch { /* */ } finally {
loading.value = false
}
}
async function createTask() {
creating.value = true
try {
await api.post('/tasks', createForm)
ElMessage.success('任务已创建')
showCreateDialog.value = false
await fetchTasks()
} catch (e: any) {
ElMessage.error(e.response?.data?.error || '创建失败')
} finally {
creating.value = false
}
}
async function startTask(id: string) {
try {
await api.post(`/tasks/${id}/start`)
ElMessage.success('任务已启动')
await fetchTasks()
} catch (e: any) {
ElMessage.error(e.response?.data?.error || '启动失败')
}
}
async function stopTask(id: string) {
try {
await api.post(`/tasks/${id}/stop`)
ElMessage.success('正在停止...')
await fetchTasks()
} catch (e: any) {
ElMessage.error(e.response?.data?.error || '停止失败')
}
}
async function forceStopTask(id: string) {
await ElMessageBox.confirm('强制取消将立即中断当前操作,确定?', '警告', { type: 'warning' })
try {
await api.post(`/tasks/${id}/force-stop`)
ElMessage.success('已强制取消')
await fetchTasks()
} catch (e: any) {
ElMessage.error(e.response?.data?.error || '操作失败')
}
}
async function deleteTask(id: string) {
await ElMessageBox.confirm('确认删除该任务及其日志?', '删除确认', { type: 'warning' })
try {
await api.delete(`/tasks/${id}`)
ElMessage.success('已删除')
await fetchTasks()
} catch (e: any) {
ElMessage.error(e.response?.data?.error || '删除失败')
}
}
onMounted(fetchTasks)
</script>
<style scoped>
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
.page-header h2 { margin: 0; }
</style>

View File

@@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "preserve",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"paths": {
"@/*": ["./src/*"]
},
"baseUrl": "."
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "env.d.ts"]
}

View File

@@ -0,0 +1 @@
{"root":["./src/main.ts","./src/api/index.ts","./src/router/index.ts","./src/stores/authstore.ts","./src/stores/taskstore.ts","./src/app.vue","./src/components/taskprogresswidget.vue","./src/layouts/applayout.vue","./src/layouts/blanklayout.vue","./src/views/accountdetail.vue","./src/views/accounts.vue","./src/views/cards.vue","./src/views/config.vue","./src/views/dashboard.vue","./src/views/emailrecords.vue","./src/views/login.vue","./src/views/taskdetail.vue","./src/views/tasks.vue","./env.d.ts"],"version":"5.7.3"}

View File

@@ -0,0 +1,21 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
},
},
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
},
},
},
})