916 lines
26 KiB
Vue
916 lines
26 KiB
Vue
<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 SCREENSHOT_TOGGLE_STORAGE_KEY = 'zsglpt:accounts:enable_screenshot'
|
||
function loadEnableScreenshot() {
|
||
try {
|
||
const raw = window.localStorage.getItem(SCREENSHOT_TOGGLE_STORAGE_KEY)
|
||
if (raw === '0' || raw === 'false') return false
|
||
if (raw === '1' || raw === 'true') return true
|
||
} catch {
|
||
// ignore
|
||
}
|
||
return true
|
||
}
|
||
const batchEnableScreenshot = ref(loadEnableScreenshot())
|
||
watch(batchEnableScreenshot, (value) => {
|
||
try {
|
||
window.localStorage.setItem(SCREENSHOT_TOGGLE_STORAGE_KEY, value ? '1' : '0')
|
||
} catch {
|
||
// ignore
|
||
}
|
||
})
|
||
|
||
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: '注册前未读' },
|
||
]
|
||
|
||
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) {
|
||
const nextList = Array.isArray(list) ? list : []
|
||
const nextIds = new Set(nextList.map((acc) => String(acc?.id || '')))
|
||
|
||
for (const existingId of Object.keys(accountsById)) {
|
||
if (!nextIds.has(existingId)) delete accountsById[existingId]
|
||
}
|
||
for (const acc of nextList) 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'
|
||
}
|
||
|
||
function showRuntimeProgress(acc) {
|
||
if (!acc?.is_running) return false
|
||
const statusText = String(acc.status || '')
|
||
const detailText = String(acc.detail_status || '')
|
||
|
||
if (!statusText || statusText === '未开始') return false
|
||
|
||
// 仅在“运行中”展示进度;排队/等待等非执行阶段不展示
|
||
if (!statusText.includes('运行')) return false
|
||
|
||
// 浏览完成后(包含等待截图/截图中等阶段)不再展示进度条与内容/附件
|
||
if (statusText.includes('截图') || statusText.includes('等待截图')) return false
|
||
if (detailText.includes('截图') || detailText.includes('等待截图')) return false
|
||
if (detailText.includes('浏览完成') || detailText.includes('任务完成')) return false
|
||
if (statusText.includes('已完成')) return false
|
||
|
||
return true
|
||
}
|
||
|
||
async function refreshStats(options = {}) {
|
||
const silent = Boolean(options?.silent)
|
||
if (!silent) 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 {
|
||
if (!silent) 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: batchEnableScreenshot.value })
|
||
} 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
|
||
const newPassword = editForm.password.trim()
|
||
const remarkText = editForm.remark.trim()
|
||
|
||
if (!newPassword && remarkText === editForm.originalRemark) {
|
||
ElMessage.info('没有修改')
|
||
editOpen.value = false
|
||
return
|
||
}
|
||
|
||
try {
|
||
if (newPassword) {
|
||
const res = await updateAccount(editForm.id, { password: newPassword, remember: true })
|
||
if (res?.account) normalizeAccountPayload(res.account)
|
||
}
|
||
|
||
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
|
||
|
||
const shouldPollStats = computed(() => {
|
||
// 仅在“真正执行中”才轮询(排队中不轮询,避免空转导致页面闪烁)
|
||
return accounts.value.some((acc) => {
|
||
if (!acc?.is_running) return false
|
||
const statusText = String(acc.status || '')
|
||
if (statusText.includes('排队')) return false
|
||
return true
|
||
})
|
||
})
|
||
|
||
function stopStatsPolling() {
|
||
if (!statsTimer) return
|
||
window.clearInterval(statsTimer)
|
||
statsTimer = null
|
||
}
|
||
|
||
function startStatsPolling() {
|
||
if (statsTimer) return
|
||
statsTimer = window.setInterval(() => refreshStats({ silent: true }), 10_000)
|
||
}
|
||
|
||
function syncStatsPolling(prevRunning = null) {
|
||
const running = shouldPollStats.value
|
||
|
||
// 任务从“运行中 -> 空闲”时,补拉一次统计以确保最终数据正确,再停止轮询
|
||
if (prevRunning === true && running === false) refreshStats({ silent: true }).catch(() => {})
|
||
|
||
if (running) startStatsPolling()
|
||
else stopStatsPolling()
|
||
}
|
||
|
||
watch(shouldPollStats, (running, prevRunning) => {
|
||
syncStatsPolling(prevRunning)
|
||
})
|
||
|
||
onMounted(async () => {
|
||
if (!userStore.vipInfo) {
|
||
userStore.refreshVipInfo().catch(() => {
|
||
window.location.href = '/login'
|
||
})
|
||
}
|
||
|
||
unbindSocket = bindSocket()
|
||
|
||
await refreshAccounts()
|
||
await refreshStats()
|
||
syncStatsPolling()
|
||
})
|
||
|
||
onBeforeUnmount(() => {
|
||
if (unbindSocket) unbindSocket()
|
||
stopStatsPolling()
|
||
})
|
||
</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" :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="showRuntimeProgress(acc) && acc.detail_status"> · {{ acc.detail_status }}</span>
|
||
<span v-if="showRuntimeProgress(acc) && acc.elapsed_display"> · {{ acc.elapsed_display }}</span>
|
||
<span v-if="String(acc.status || '').includes('排队') && acc.queue_ahead != null">
|
||
· 前面 {{ acc.queue_ahead }} 个 · 运行中 {{ acc.queue_running_total ?? 0 }} 个
|
||
</span>
|
||
<span v-else-if="showRuntimeProgress(acc) && (acc.queue_pending_total != null || acc.queue_running_total != null)">
|
||
· 排队 {{ acc.queue_pending_total ?? 0 }} 个 · 运行中 {{ acc.queue_running_total ?? 0 }} 个
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="showRuntimeProgress(acc)" class="progress">
|
||
<el-progress :percentage="toPercent(acc)" :stroke-width="10" :show-text="false" />
|
||
<div class="progress-meta app-muted">
|
||
<span>内容 {{ acc.progress_items || 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>
|