更新
This commit is contained in:
+263
-20
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user