Files
zsglpt/app-frontend/src/pages/AccountsPage.vue

823 lines
23 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
addAccount,
batchStartAccounts,
batchStopAccounts,
clearAccounts,
deleteAccount as apiDeleteAccount,
fetchAccounts,
startAccount,
stopAccount,
takeScreenshot,
updateAccount,
updateAccountRemark,
} from '../api/accounts'
import { fetchRunStats } from '../api/stats'
import { useSocket } from '../composables/useSocket'
import { useUserStore } from '../stores/user'
const userStore = useUserStore()
const socket = useSocket()
const loading = ref(false)
const statsLoading = ref(false)
const stats = reactive({
today_completed: 0,
today_failed: 0,
current_running: 0,
today_items: 0,
today_attachments: 0,
})
const accountsById = reactive({})
const selectedIds = ref([])
const browseTypeById = reactive({})
const batchBrowseType = ref('应读')
const batchEnableScreenshot = ref(true)
const addOpen = ref(false)
const editOpen = ref(false)
const upgradeOpen = ref(false)
const addForm = reactive({
username: '',
password: '',
remark: '',
})
const editForm = reactive({
id: '',
username: '',
password: '',
remark: '',
originalRemark: '',
})
const browseTypeOptions = [
{ label: '应读', value: '应读' },
{ label: '未读', value: '未读' },
{ label: '注册前未读', value: '注册前未读' },
]
const accounts = computed(() =>
Object.values(accountsById).sort((a, b) => String(a.username || '').localeCompare(String(b.username || ''), 'zh-CN')),
)
const accountCount = computed(() => accounts.value.length)
const accountLimit = computed(() => (userStore.isVip ? 999 : 3))
const selectedCount = computed(() => selectedIds.value.length)
const allSelected = computed(() => accountCount.value > 0 && selectedCount.value === accountCount.value)
const showUpgradeBanner = computed(() => !userStore.isVip)
function normalizeAccountPayload(acc) {
const base = accountsById[acc.id] || {}
accountsById[acc.id] = { ...base, ...acc }
}
function replaceAccounts(list) {
for (const key of Object.keys(accountsById)) delete accountsById[key]
for (const acc of list || []) normalizeAccountPayload(acc)
}
function ensureBrowseTypeDefaults() {
for (const acc of accounts.value) {
if (!browseTypeById[acc.id]) browseTypeById[acc.id] = '应读'
}
}
watch(accounts, ensureBrowseTypeDefaults, { immediate: true })
function toggleSelectAll(value) {
if (value) selectedIds.value = accounts.value.map((a) => a.id)
else selectedIds.value = []
}
function requireVip(featureName) {
if (userStore.isVip) return true
ElMessage.warning(`${featureName}是VIP专属功能`)
upgradeOpen.value = true
return false
}
function toPercent(acc) {
const total = Number(acc.total_items || 0)
const done = Number(acc.progress_items || 0)
if (!total) return 0
return Math.max(0, Math.min(100, Math.round((done / total) * 100)))
}
function statusTagType(status = '') {
const text = String(status)
if (text.includes('已完成') || text.includes('完成')) return 'success'
if (text.includes('失败') || text.includes('错误') || text.includes('异常') || text.includes('登录失败')) return 'danger'
if (text.includes('排队') || text.includes('运行') || text.includes('截图')) return 'warning'
return 'info'
}
async function refreshStats() {
statsLoading.value = true
try {
const data = await fetchRunStats()
stats.today_completed = Number(data?.today_completed || 0)
stats.today_failed = Number(data?.today_failed || 0)
stats.current_running = Number(data?.current_running || 0)
stats.today_items = Number(data?.today_items || 0)
stats.today_attachments = Number(data?.today_attachments || 0)
} catch (e) {
if (e?.response?.status === 401) window.location.href = '/login'
} finally {
statsLoading.value = false
}
}
async function refreshAccounts() {
loading.value = true
try {
const list = await fetchAccounts({ refresh: true })
replaceAccounts(list)
} catch (e) {
if (e?.response?.status === 401) window.location.href = '/login'
} finally {
loading.value = false
}
}
async function onStart(acc) {
try {
await startAccount(acc.id, { browse_type: browseTypeById[acc.id] || '应读', enable_screenshot: true })
} catch (e) {
const data = e?.response?.data
ElMessage.error(data?.error || '启动失败')
}
}
async function onStop(acc) {
try {
await stopAccount(acc.id)
} catch (e) {
const data = e?.response?.data
ElMessage.error(data?.error || '停止失败')
}
}
async function onScreenshot(acc) {
try {
await takeScreenshot(acc.id, { browse_type: browseTypeById[acc.id] || '应读' })
ElMessage.success('已提交截图')
} catch (e) {
const data = e?.response?.data
ElMessage.error(data?.error || '截图失败')
}
}
async function onDelete(acc) {
try {
await ElMessageBox.confirm(`确定要删除账号「${acc.username}」吗?`, '删除账号', {
confirmButtonText: '删除',
cancelButtonText: '取消',
type: 'warning',
})
} catch {
return
}
try {
const res = await apiDeleteAccount(acc.id)
if (res?.success) {
delete accountsById[acc.id]
selectedIds.value = selectedIds.value.filter((id) => id !== acc.id)
ElMessage.success('已删除')
await refreshStats()
} else {
ElMessage.error(res?.error || '删除失败')
}
} catch (e) {
const data = e?.response?.data
ElMessage.error(data?.error || '删除失败')
}
}
function openAdd() {
addForm.username = ''
addForm.password = ''
addForm.remark = ''
addOpen.value = true
}
async function submitAdd() {
const username = addForm.username.trim()
if (!username || !addForm.password.trim()) {
ElMessage.error('用户名和密码不能为空')
return
}
try {
await addAccount({
username,
password: addForm.password,
remember: true,
remark: addForm.remark.trim(),
})
ElMessage.success('添加成功')
addOpen.value = false
await refreshStats()
} catch (e) {
const data = e?.response?.data
ElMessage.error(data?.error || '添加失败')
}
}
function openEdit(acc) {
editForm.id = acc.id
editForm.username = acc.username
editForm.password = ''
editForm.remark = String(acc.remark || '')
editForm.originalRemark = String(acc.remark || '')
editOpen.value = true
}
async function submitEdit() {
if (!editForm.id) return
if (!editForm.password.trim()) {
ElMessage.error('请输入新密码')
return
}
try {
const res = await updateAccount(editForm.id, { password: editForm.password, remember: true })
if (res?.account) normalizeAccountPayload(res.account)
const remarkText = editForm.remark.trim()
if (remarkText !== editForm.originalRemark) {
await updateAccountRemark(editForm.id, { remark: remarkText })
normalizeAccountPayload({ id: editForm.id, remark: remarkText })
}
ElMessage.success('已更新')
editOpen.value = false
} catch (e) {
const data = e?.response?.data
ElMessage.error(data?.error || '更新失败')
}
}
async function batchStart() {
if (!requireVip('批量操作')) return
if (selectedIds.value.length === 0) {
ElMessage.warning('请先选择账号')
return
}
try {
const res = await batchStartAccounts({
account_ids: selectedIds.value,
browse_type: batchBrowseType.value,
enable_screenshot: batchEnableScreenshot.value,
})
ElMessage.success(`已启动 ${res?.started_count || 0} 个账号`)
} catch (e) {
const data = e?.response?.data
ElMessage.error(data?.error || '操作失败')
}
}
async function batchStop() {
if (!requireVip('批量操作')) return
if (selectedIds.value.length === 0) {
ElMessage.warning('请先选择账号')
return
}
try {
const res = await batchStopAccounts({ account_ids: selectedIds.value })
ElMessage.success(`已停止 ${res?.stopped_count || 0} 个账号`)
} catch (e) {
const data = e?.response?.data
ElMessage.error(data?.error || '操作失败')
}
}
async function startAll() {
if (!requireVip('全部启动')) return
if (accounts.value.length === 0) {
ElMessage.warning('没有账号')
return
}
try {
await ElMessageBox.confirm('确定要启动全部账号吗?', '全部启动', {
confirmButtonText: '启动',
cancelButtonText: '取消',
type: 'warning',
})
} catch {
return
}
try {
const res = await batchStartAccounts({
account_ids: accounts.value.map((a) => a.id),
browse_type: batchBrowseType.value,
enable_screenshot: batchEnableScreenshot.value,
})
ElMessage.success(`已启动 ${res?.started_count || 0} 个账号`)
} catch (e) {
const data = e?.response?.data
ElMessage.error(data?.error || '操作失败')
}
}
async function stopAll() {
if (!requireVip('全部停止')) return
if (accounts.value.length === 0) {
ElMessage.warning('没有账号')
return
}
try {
await ElMessageBox.confirm('确定要停止全部账号吗?', '全部停止', {
confirmButtonText: '停止',
cancelButtonText: '取消',
type: 'warning',
})
} catch {
return
}
try {
const res = await batchStopAccounts({ account_ids: accounts.value.map((a) => a.id) })
ElMessage.success(`已停止 ${res?.stopped_count || 0} 个账号`)
} catch (e) {
const data = e?.response?.data
ElMessage.error(data?.error || '操作失败')
}
}
async function clearAll() {
if (accounts.value.length === 0) {
ElMessage.warning('没有账号')
return
}
try {
await ElMessageBox.confirm('确定要清空所有账号吗?此操作不可恢复!', '清空账号', {
confirmButtonText: '继续',
cancelButtonText: '取消',
type: 'warning',
})
await ElMessageBox.confirm('再次确认:真的要删除所有账号吗?', '二次确认', {
confirmButtonText: '删除',
cancelButtonText: '取消',
type: 'warning',
})
} catch {
return
}
try {
const res = await clearAccounts()
if (res?.success) {
replaceAccounts([])
selectedIds.value = []
ElMessage.success('已清空所有账号')
await refreshStats()
return
}
ElMessage.error(res?.error || '操作失败')
} catch (e) {
const data = e?.response?.data
ElMessage.error(data?.error || '操作失败')
}
}
function bindSocket() {
const onAccountsList = (list) => {
replaceAccounts(list)
}
const onAccountUpdate = (acc) => {
normalizeAccountPayload(acc)
}
const onTaskProgress = (payload) => {
if (!payload?.account_id) return
normalizeAccountPayload({
id: payload.account_id,
detail_status: payload.stage || '',
total_items: payload.total_items,
progress_items: payload.browsed_items,
total_attachments: payload.total_attachments,
progress_attachments: payload.viewed_attachments,
elapsed_seconds: payload.elapsed_seconds,
elapsed_display: payload.elapsed_display,
})
}
socket.on('accounts_list', onAccountsList)
socket.on('account_update', onAccountUpdate)
socket.on('task_progress', onTaskProgress)
if (!socket.connected) socket.connect()
return () => {
socket.off('accounts_list', onAccountsList)
socket.off('account_update', onAccountUpdate)
socket.off('task_progress', onTaskProgress)
}
}
let unbindSocket = null
let statsTimer = null
onMounted(async () => {
if (!userStore.vipInfo) {
userStore.refreshVipInfo().catch(() => {
window.location.href = '/login'
})
}
unbindSocket = bindSocket()
await refreshAccounts()
await refreshStats()
statsTimer = window.setInterval(refreshStats, 10_000)
})
onBeforeUnmount(() => {
if (unbindSocket) unbindSocket()
if (statsTimer) window.clearInterval(statsTimer)
})
</script>
<template>
<div class="page">
<el-row :gutter="12" class="stats-row">
<el-col :xs="12" :sm="8" :md="4">
<el-card shadow="never" class="stat-card" :body-style="{ padding: '14px' }">
<div class="stat-label app-muted">今日完成</div>
<div class="stat-value">{{ stats.today_completed }}</div>
</el-card>
</el-col>
<el-col :xs="12" :sm="8" :md="4">
<el-card shadow="never" class="stat-card" :body-style="{ padding: '14px' }">
<div class="stat-label app-muted">今日失败</div>
<div class="stat-value">{{ stats.today_failed }}</div>
</el-card>
</el-col>
<el-col :xs="12" :sm="8" :md="4">
<el-card shadow="never" class="stat-card" :body-style="{ padding: '14px' }">
<div class="stat-label app-muted">运行中</div>
<div class="stat-value">{{ stats.current_running }}</div>
</el-card>
</el-col>
<el-col :xs="12" :sm="8" :md="4">
<el-card shadow="never" class="stat-card" :body-style="{ padding: '14px' }">
<div class="stat-label app-muted">浏览内容</div>
<div class="stat-value">{{ stats.today_items }}</div>
</el-card>
</el-col>
<el-col :xs="12" :sm="8" :md="4">
<el-card shadow="never" class="stat-card" :body-style="{ padding: '14px' }">
<div class="stat-label app-muted">查看附件</div>
<div class="stat-value">{{ stats.today_attachments }}</div>
</el-card>
</el-col>
<el-col :xs="12" :sm="8" :md="4">
<el-card shadow="never" class="stat-card" :body-style="{ padding: '14px' }">
<div class="stat-label app-muted">账号数</div>
<div class="stat-value">
{{ accountCount }}<span class="stat-suffix app-muted">/ {{ userStore.isVip ? '∞' : accountLimit }}</span>
</div>
</el-card>
</el-col>
</el-row>
<el-alert
v-if="showUpgradeBanner"
type="info"
show-icon
:closable="false"
class="upgrade-banner"
title="升级 VIP解锁更多功能无限账号 · 优先排队 · 定时任务 · 批量操作"
>
<template #default>
<div class="upgrade-actions">
<el-button type="primary" plain @click="upgradeOpen = true">了解VIP特权</el-button>
</div>
</template>
</el-alert>
<el-card shadow="never" class="panel" :body-style="{ padding: '14px' }">
<div class="panel-head">
<div class="panel-title">账号管理</div>
<div class="panel-actions">
<el-button :loading="loading" @click="refreshAccounts">刷新</el-button>
<el-button type="primary" @click="openAdd">添加账号</el-button>
</div>
</div>
<div class="toolbar">
<div class="toolbar-left">
<el-checkbox :model-value="allSelected" @change="toggleSelectAll">全选</el-checkbox>
<span class="app-muted">已选 {{ selectedCount }} </span>
</div>
<div class="toolbar-middle">
<el-select v-model="batchBrowseType" size="small" style="width: 120px">
<el-option v-for="opt in browseTypeOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
<el-switch v-model="batchEnableScreenshot" inline-prompt active-text="截图" inactive-text="不截图" />
</div>
<div class="toolbar-right">
<el-button type="primary" @click="batchStart">批量启动</el-button>
<el-button @click="batchStop">批量停止</el-button>
<el-button type="success" plain @click="startAll">全部启动</el-button>
<el-button type="danger" plain @click="stopAll">全部停止</el-button>
<el-button type="danger" text @click="clearAll">清空</el-button>
</div>
</div>
<el-skeleton v-if="loading || statsLoading" :rows="5" animated />
<template v-else>
<el-empty v-if="accounts.length === 0" description="暂无账号,点击右上角添加" />
<div v-else class="grid">
<el-card v-for="acc in accounts" :key="acc.id" shadow="never" class="account-card" :body-style="{ padding: '14px' }">
<div class="card-top">
<el-checkbox-group v-model="selectedIds" class="card-check">
<el-checkbox :value="acc.id" />
</el-checkbox-group>
<div class="card-main">
<div class="card-title">
<span class="card-name">{{ acc.username }}</span>
<el-tag size="small" :type="statusTagType(acc.status)" effect="light">{{ acc.status }}</el-tag>
</div>
<div class="card-sub app-muted">
{{ acc.remark || '—' }}
<span v-if="acc.detail_status"> · {{ acc.detail_status }}</span>
<span v-if="acc.elapsed_display"> · {{ acc.elapsed_display }}</span>
</div>
</div>
</div>
<div class="progress">
<el-progress :percentage="toPercent(acc)" :stroke-width="10" :show-text="false" />
<div class="progress-meta app-muted">
<span>内容 {{ acc.progress_items || 0 }}/{{ acc.total_items || 0 }}</span>
<span>附件 {{ acc.progress_attachments || 0 }}/{{ acc.total_attachments || 0 }}</span>
</div>
</div>
<div class="card-controls">
<el-select v-model="browseTypeById[acc.id]" size="small" style="width: 130px">
<el-option v-for="opt in browseTypeOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
<div class="card-buttons">
<el-button size="small" type="primary" :disabled="acc.is_running" @click="onStart(acc)">启动</el-button>
<el-button size="small" :disabled="!acc.is_running" @click="onStop(acc)">停止</el-button>
<el-button size="small" :disabled="acc.is_running" @click="onScreenshot(acc)">截图</el-button>
<el-button size="small" :disabled="acc.is_running" @click="openEdit(acc)">编辑</el-button>
<el-button size="small" type="danger" text @click="onDelete(acc)">删除</el-button>
</div>
</div>
</el-card>
</div>
</template>
</el-card>
<el-dialog v-model="addOpen" title="添加账号" width="min(560px, 92vw)">
<el-form label-position="top">
<el-form-item label="账号">
<el-input v-model="addForm.username" placeholder="请输入账号" autocomplete="off" />
</el-form-item>
<el-form-item label="密码">
<el-input v-model="addForm.password" type="password" show-password placeholder="请输入密码" autocomplete="off" />
</el-form-item>
<el-form-item label="备注可选最多200字">
<el-input v-model="addForm.remark" type="textarea" :rows="3" maxlength="200" show-word-limit placeholder="例如:部门/用途" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="addOpen = false">取消</el-button>
<el-button type="primary" @click="submitAdd">添加</el-button>
</template>
</el-dialog>
<el-dialog v-model="editOpen" title="编辑账号" width="min(560px, 92vw)">
<el-form label-position="top">
<el-form-item label="账号">
<el-input v-model="editForm.username" disabled />
</el-form-item>
<el-form-item label="新密码(必填)">
<el-input v-model="editForm.password" type="password" show-password placeholder="请输入新密码" autocomplete="off" />
</el-form-item>
<el-form-item label="备注可选最多200字">
<el-input v-model="editForm.remark" type="textarea" :rows="3" maxlength="200" show-word-limit placeholder="例如:部门/用途" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="editOpen = false">取消</el-button>
<el-button type="primary" @click="submitEdit">保存</el-button>
</template>
</el-dialog>
<el-dialog v-model="upgradeOpen" title="VIP 特权" width="min(560px, 92vw)">
<el-alert
type="info"
:closable="false"
title="升级 VIP 后可解锁:无限账号、优先排队、定时任务、批量操作。"
show-icon
/>
<div class="vip-body">
<div class="vip-tip app-muted">升级方式请通过反馈联系管理员开通与后台一致</div>
</div>
<template #footer>
<el-button type="primary" @click="upgradeOpen = false">我知道了</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.page {
display: flex;
flex-direction: column;
gap: 12px;
}
.stat-card,
.panel {
border-radius: var(--app-radius);
border: 1px solid var(--app-border);
}
.stat-label {
font-size: 12px;
}
.stat-value {
margin-top: 6px;
font-size: 22px;
font-weight: 900;
letter-spacing: 0.2px;
}
.stat-suffix {
margin-left: 6px;
font-size: 12px;
font-weight: 600;
}
.upgrade-banner {
border-radius: var(--app-radius);
border: 1px solid var(--app-border);
}
.upgrade-actions {
margin-top: 10px;
}
.panel-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
}
.panel-title {
font-size: 16px;
font-weight: 900;
}
.panel-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
justify-content: flex-end;
}
.toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
padding: 10px;
border: 1px dashed rgba(17, 24, 39, 0.14);
border-radius: 12px;
background: rgba(246, 247, 251, 0.6);
}
.toolbar-left,
.toolbar-middle,
.toolbar-right {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.toolbar-right {
margin-left: auto;
justify-content: flex-end;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 12px;
}
.account-card {
border-radius: 14px;
border: 1px solid var(--app-border);
}
.card-top {
display: flex;
gap: 10px;
}
.card-check {
padding-top: 2px;
}
.card-main {
min-width: 0;
flex: 1;
}
.card-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.card-name {
font-size: 14px;
font-weight: 900;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.card-sub {
margin-top: 6px;
font-size: 12px;
line-height: 1.4;
word-break: break-word;
}
.progress {
margin-top: 12px;
}
.progress-meta {
margin-top: 6px;
display: flex;
justify-content: space-between;
gap: 10px;
font-size: 12px;
}
.card-controls {
margin-top: 12px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.card-buttons {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
justify-content: flex-end;
}
.vip-body {
padding: 12px 0 0;
}
.vip-tip {
margin-top: 10px;
font-size: 13px;
line-height: 1.6;
}
@media (max-width: 480px) {
.grid {
grid-template-columns: 1fr;
}
}
</style>