This commit is contained in:
Your Name
2026-08-26 17:18:09 +08:00
parent 327a0bc42f
commit 4ac6990efe
20 changed files with 1336 additions and 130 deletions
+263 -20
View File
@@ -68,9 +68,13 @@ const accountPageSize = ref(9)
const accountTotal = ref(0)
const rules = ref([])
const deviceProfiles = ref([])
const egressChannels = ref([])
const egressChannelsLoading = ref(false)
const egressChannelsError = ref('')
const CUSTOM_UA_PROFILE = '__custom__'
const loading = ref(false)
const batchStarting = ref(false)
const batchDeleting = ref(false)
const activeStartBatchId = ref(null)
let batchStatusTimer = null
let batchStatusRequestActive = false
@@ -362,6 +366,8 @@ const editForm = ref({
follow_welcome_content: '',
user_agent_profile: 'chrome_win120',
user_agent_custom: '',
egress_public_ip: '',
egress_auto_attempts: 1,
})
const profileSelectOptions = computed(() => {
@@ -373,6 +379,25 @@ const profileSelectOptions = computed(() => {
return opts
})
const egressChannelOptions = computed(() => {
const options = [
{ value: '', label: '自动选择(服务器默认公网出口)' }
]
for (const channel of egressChannels.value || []) {
const source = channel.source_ip ? `本地 ${channel.source_ip}` : '默认路由'
const suffix = channel.is_default ? ' · 当前默认' : ''
options.push({
value: channel.public_ip,
label: `${channel.public_ip}${source}${suffix}`
})
}
const selected = (editForm.value.egress_public_ip || '').trim()
if (selected && !options.some((item) => item.value === selected)) {
options.push({ value: selected, label: `${selected}(当前未检测到)`, disabled: true })
}
return options
})
const accountQuota = computed(() => {
const user = auth.user
const count = accountTotal.value
@@ -446,6 +471,18 @@ const selectedStartableCount = computed(() =>
startableAccounts.value.filter((a) => selectedIds.value.includes(a.id)).length
)
const selectableAccounts = computed(() => {
if (auth.canDeleteAccounts) return accounts.value
if (auth.canStartAccounts) return startableAccounts.value
return []
})
const selectedAccounts = computed(() =>
selectableAccounts.value.filter((a) => selectedIds.value.includes(a.id))
)
const selectedAccountCount = computed(() => selectedAccounts.value.length)
const isAccountSelected = (id) => selectedIds.value.includes(id)
const toggleAccountSelect = (id) => {
@@ -456,8 +493,8 @@ const toggleAccountSelect = (id) => {
}
}
const selectAllStartable = () => {
selectedIds.value = startableAccounts.value.map((a) => a.id)
const selectAllAccounts = () => {
selectedIds.value = selectableAccounts.value.map((a) => a.id)
}
const clearSelection = () => {
@@ -465,7 +502,7 @@ const clearSelection = () => {
}
const onSelectAllChange = (e) => {
if (e.target.checked) selectAllStartable()
if (e.target.checked) selectAllAccounts()
else clearSelection()
}
@@ -547,6 +584,26 @@ const fetchDeviceProfiles = async () => {
}
}
const fetchEgressChannels = async (refresh = false) => {
if (!auth.canUpdateAccounts || egressChannelsLoading.value) return
egressChannelsLoading.value = true
egressChannelsError.value = ''
try {
const res = await api.get('/network/egress-channels', {
params: { refresh },
timeout: 20000
})
egressChannels.value = res.data?.channels || []
if (!egressChannels.value.length) {
egressChannelsError.value = '未探测到可用公网出口,将继续使用服务器默认路由'
}
} catch (error) {
egressChannelsError.value = error.response?.data?.detail || '公网通道检测失败'
} finally {
egressChannelsLoading.value = false
}
}
const fetchAccounts = async () => {
try {
loading.value = true
@@ -560,6 +617,8 @@ const fetchAccounts = async () => {
})
accounts.value = res.data.items || []
accountTotal.value = res.data.total || 0
const visibleIds = new Set(selectableAccounts.value.map((a) => a.id))
selectedIds.value = selectedIds.value.filter((id) => visibleIds.has(id))
// 删除/筛选后当前页可能超界,自动回退到最后一页
const maxPage = Math.max(1, Math.ceil(accountTotal.value / accountPageSize.value) || 1)
if (accountPage.value > maxPage) {
@@ -1063,10 +1122,74 @@ const handleAddAccount = async () => {
const handleDeleteAccount = async (id) => {
try {
await api.delete(`/accounts/${id}`)
selectedIds.value = selectedIds.value.filter((item) => item !== id)
message.success('删除成功')
fetchAccounts()
await Promise.all([fetchAccounts(), auth.fetchMe()])
} catch (error) {
message.error('删除账号失败')
message.error(error.response?.data?.detail || '删除账号失败')
}
}
const handleBatchDeleteAccounts = async () => {
if (batchDeleting.value || batchStarting.value) return
const targets = selectedAccounts.value.map((account) => account.id)
if (!targets.length) {
message.warning('请先勾选要删除的账号')
return
}
batchDeleting.value = true
const deletedIds = []
const failures = []
message.loading({
content: `正在删除 0/${targets.length} 个账号...`,
key: 'batch_delete',
duration: 0
})
try {
// SQLite 下并发执行多个删除事务容易互相抢锁,逐个删除更稳定。
for (let index = 0; index < targets.length; index += 1) {
const id = targets[index]
try {
await api.delete(`/accounts/${id}`)
deletedIds.push(id)
} catch (error) {
failures.push({
id,
reason: error.response?.data?.detail || error.message || '删除失败'
})
}
message.loading({
content: `正在删除 ${index + 1}/${targets.length} 个账号...`,
key: 'batch_delete',
duration: 0
})
}
const deletedSet = new Set(deletedIds)
selectedIds.value = selectedIds.value.filter((id) => !deletedSet.has(id))
await Promise.all([fetchAccounts(), auth.fetchMe()])
if (!failures.length) {
message.success({
content: `已删除 ${deletedIds.length} 个账号`,
key: 'batch_delete'
})
} else if (deletedIds.length) {
message.warning({
content: `已删除 ${deletedIds.length} 个账号,${failures.length} 个失败,可重新勾选后重试`,
key: 'batch_delete',
duration: 6
})
} else {
message.error({
content: failures[0]?.reason || '批量删除失败',
key: 'batch_delete',
duration: 6
})
}
} finally {
batchDeleting.value = false
}
}
@@ -1300,7 +1423,7 @@ const pollBatchStartStatus = (batchId, initialSnapshot = null) => {
// 批量启动只提交一个请求;凭证校验和启动由后端小并发队列处理。
const runBatchStart = async ({ accountIds = [], allAccounts = false }) => {
if (batchStarting.value) return
if (batchStarting.value || batchDeleting.value) return
batchStarting.value = true
stopReplyQueueSummaryPolling()
stopBatchStatusPolling()
@@ -1336,7 +1459,7 @@ const runBatchStart = async ({ accountIds = [], allAccounts = false }) => {
const accountLabel = (acc) => acc?.username || `账号 #${acc?.id}`
const batchStartRpa = async () => {
if (batchStarting.value || startingAll.value) return
if (batchStarting.value || batchDeleting.value || startingAll.value) return
const targets = startableAccounts.value
.filter((a) => selectedIds.value.includes(a.id))
.map((a) => ({ id: a.id, label: accountLabel(a) }))
@@ -1351,7 +1474,7 @@ const batchStartRpa = async () => {
const startingAll = ref(false)
const startAllRpa = async () => {
if (batchStarting.value || startingAll.value) return
if (batchStarting.value || batchDeleting.value || startingAll.value) return
startingAll.value = true
try {
await runBatchStart({ allAccounts: true })
@@ -1601,8 +1724,13 @@ const openEditModal = async (acc) => {
follow_welcome_content: acc.follow_welcome_content || '',
user_agent_profile: 'chrome_win120',
user_agent_custom: '',
egress_public_ip: acc.egress_public_ip || '',
egress_auto_attempts: Math.max(1, Number(acc.egress_auto_attempts) || 1),
}
initUserAgentFields(acc)
if (auth.canUpdateAccounts) {
fetchEgressChannels(false)
}
try {
if (auth.canManageCookies) {
const res = await api.get(`/accounts/${acc.id}/cookie?purpose=management`)
@@ -1690,8 +1818,10 @@ const saveAccountInfo = async () => {
follow_welcome_enabled: !!editForm.value.follow_welcome_enabled,
follow_welcome_content: (editForm.value.follow_welcome_content || '').trim() || null,
user_agent: resolveUserAgentToSave() || null,
egress_public_ip: (editForm.value.egress_public_ip || '').trim() || null,
egress_auto_attempts: Math.max(1, Math.min(8, Number(editForm.value.egress_auto_attempts) || 1)),
})
message.success('账号信息已保存(设备头将在下次启动托管时生效)')
message.success('账号信息已保存(公网发送通道立即生效,设备头下次启动生效)')
fetchAccounts()
} catch (error) {
message.error('保存账号信息失败')
@@ -1805,26 +1935,54 @@ onUnmounted(() => {
</p>
</div>
<div class="header-actions">
<div v-if="auth.canStartAccounts && startableAccounts.length > 0" class="batch-toolbar">
<div
v-if="selectableAccounts.length > 0 && (auth.canStartAccounts || auth.canDeleteAccounts)"
class="batch-toolbar"
>
<a-checkbox
:indeterminate="selectedStartableCount > 0 && selectedStartableCount < startableAccounts.length"
:checked="startableAccounts.length > 0 && selectedStartableCount === startableAccounts.length"
:indeterminate="selectedAccountCount > 0 && selectedAccountCount < selectableAccounts.length"
:checked="selectableAccounts.length > 0 && selectedAccountCount === selectableAccounts.length"
:disabled="batchStarting || batchDeleting"
@change="onSelectAllChange"
>
全选可启动 ({{ startableAccounts.length }})
{{ auth.canDeleteAccounts ? '全选当前页' : '全选可启动' }} ({{ selectableAccounts.length }})
</a-checkbox>
<a-button
v-if="auth.canStartAccounts && startableAccounts.length > 0"
type="primary"
ghost
class="batch-start-btn"
:disabled="selectedStartableCount === 0"
:disabled="selectedStartableCount === 0 || batchDeleting"
:loading="batchStarting"
@click="batchStartRpa"
>
<template #icon><PlayCircleOutlined /></template>
批量启动{{ selectedStartableCount ? ` (${selectedStartableCount})` : '' }}
</a-button>
<a-button v-if="selectedStartableCount > 0" class="batch-clear-btn" @click="clearSelection">
<a-popconfirm
v-if="auth.canDeleteAccounts"
:title="`确认删除选中的 ${selectedAccountCount} 个账号?运行中的托管会先停止,关联的自动回复规则和消息日志也会被清除。`"
ok-text="确认删除"
cancel-text="取消"
placement="bottomRight"
@confirm="handleBatchDeleteAccounts"
>
<a-button
danger
class="batch-delete-btn"
:disabled="selectedAccountCount === 0 || batchStarting"
:loading="batchDeleting"
>
<template #icon><DeleteOutlined /></template>
批量删除{{ selectedAccountCount ? ` (${selectedAccountCount})` : '' }}
</a-button>
</a-popconfirm>
<a-button
v-if="selectedAccountCount > 0"
class="batch-clear-btn"
:disabled="batchStarting || batchDeleting"
@click="clearSelection"
>
取消选择
</a-button>
</div>
@@ -1837,9 +1995,10 @@ onUnmounted(() => {
@confirm="startAllRpa"
>
<a-button
type="primary"
ghost
:loading="startingAll || batchStarting"
type="primary"
ghost
:loading="startingAll || batchStarting"
:disabled="batchDeleting"
>
<template #icon><ThunderboltOutlined /></template>
一键启动全部
@@ -1933,9 +2092,10 @@ onUnmounted(() => {
:class="{ 'account-card-selected': isAccountSelected(acc.id) }"
>
<a-checkbox
v-if="!acc.quota_disabled && (acc.status === 'offline' || acc.status === 'error')"
v-if="auth.canDeleteAccounts || (auth.canStartAccounts && !acc.quota_disabled && (acc.status === 'offline' || acc.status === 'error'))"
class="account-select-checkbox"
:checked="isAccountSelected(acc.id)"
:disabled="batchStarting || batchDeleting"
@change="toggleAccountSelect(acc.id)"
/>
<!-- 账号顶部信息 -->
@@ -2113,7 +2273,13 @@ onUnmounted(() => {
cancel-text="取消"
@confirm="handleDeleteAccount(acc.id)"
>
<a-button type="text" danger size="small" class="action-delete-btn">
<a-button
type="text"
danger
size="small"
class="action-delete-btn"
:disabled="batchDeleting"
>
<template #icon><DeleteOutlined /></template>
</a-button>
</a-popconfirm>
@@ -2372,6 +2538,53 @@ onUnmounted(() => {
</div>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="公网发送通道">
<div class="egress-channel-row">
<a-select
v-model:value="editForm.egress_public_ip"
:options="egressChannelOptions"
:loading="egressChannelsLoading"
placeholder="自动选择服务器默认公网出口"
style="flex: 1; min-width: 0;"
/>
<a-button
:loading="egressChannelsLoading"
@click="fetchEgressChannels(true)"
>
重新检测
</a-button>
</div>
<div class="field-hint">
<template v-if="egressChannels.length > 1">
已检测到 {{ egressChannels.length }} 个不同公网 IP。固定选择后,该账号的 IM 请求将绑定到对应本地网卡地址。
</template>
<template v-else-if="egressChannels.length === 1">
当前仅检测到一个公网出口 {{ egressChannels[0].public_ip }};仍可提前保存自动切换次数,增加出口后重新检测即可。
</template>
<template v-else>
系统会自动检测服务器网卡与公网 IP 的映射;未检测到时保持默认路由。
</template>
</div>
<div v-if="egressChannelsError" class="egress-channel-error">
{{ egressChannelsError }}
</div>
</a-form-item>
</a-col>
<a-col :xs="24" :sm="12">
<a-form-item label="发送最多尝试通道数 N">
<a-input-number
v-model:value="editForm.egress_auto_attempts"
:min="1"
:max="8"
:precision="0"
style="width: 100%;"
/>
<div class="field-hint">
包含首选通道。只有明确收到通道/安全校验失败时才按顺序切换;超时等结果不确定的请求不会重发,避免重复消息。
</div>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="伪装设备头User-Agent">
<a-select
@@ -3122,6 +3335,24 @@ onUnmounted(() => {
background: rgba(255, 255, 255, 0.02) !important;
}
.batch-toolbar :deep(.batch-delete-btn.ant-btn-dangerous) {
color: #fca5a5 !important;
border-color: rgba(248, 113, 113, 0.45) !important;
background: rgba(239, 68, 68, 0.08) !important;
}
.batch-toolbar :deep(.batch-delete-btn.ant-btn-dangerous:not(:disabled):hover) {
color: #fecaca !important;
border-color: rgba(252, 165, 165, 0.75) !important;
background: rgba(239, 68, 68, 0.16) !important;
}
.batch-toolbar :deep(.batch-delete-btn.ant-btn-dangerous:disabled) {
color: rgba(203, 213, 225, 0.45) !important;
border-color: rgba(255, 255, 255, 0.08) !important;
background: rgba(255, 255, 255, 0.02) !important;
}
.batch-toolbar :deep(.batch-clear-btn.ant-btn-default) {
color: #cbd5e1 !important;
border-color: rgba(255, 255, 255, 0.16) !important;
@@ -4364,6 +4595,18 @@ onUnmounted(() => {
line-height: 1.55;
}
.egress-channel-row {
display: flex;
align-items: center;
gap: 10px;
}
.egress-channel-error {
margin-top: 6px;
color: #fbbf24;
font-size: 0.78rem;
}
.im-credential-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));