This commit is contained in:
Your Name
2026-09-09 14:04:09 +08:00
parent 9be2e18ea6
commit 083fd857e8
7 changed files with 462 additions and 46 deletions
@@ -58,22 +58,29 @@
</div>
<div class="section-heading-actions">
<span v-if="selectedPoolIds.length" class="selection-count">已选 {{ selectedPoolIds.length }} 个方案</span>
<el-button
:icon="Refresh"
:loading="syncingBatchMembers"
:disabled="!selectedPoolIds.length || memberSyncBusy || togglingMemberId > 0 || deletingPoolId > 0"
@click="syncSelectedMemberRanges"
>批量同步成员范围</el-button>
<el-button
:icon="Edit"
:disabled="!selectedPoolIds.length"
:disabled="!selectedManageablePoolCount || memberSyncBusy"
@click="openBatchConfigDialog()"
>批量修改方案</el-button>
<el-button
:icon="User"
:disabled="!selectedPoolIds.length"
:disabled="!selectedManageablePoolCount || memberSyncBusy"
@click="openAccessDialog()"
>批量设置访问操作</el-button>
<el-button type="primary" :icon="Plus" @click="openPoolDialog()">新建分流方案</el-button>
<el-button type="primary" :icon="Plus" :disabled="memberSyncBusy" @click="openPoolDialog()">新建分流方案</el-button>
</div>
</div>
<section v-if="operationResults.length" class="member-sync-results" aria-live="polite">
<strong>本次保存与企微同步结果</strong>
<p v-if="syncingBatchMembers" role="status">正在同步企微 {{ batchSyncProgress.completed }} / {{ batchSyncProgress.total }} 个方案请等待完成</p>
<ul>
<li v-for="result in operationResults" :key="result.id">
<span>{{ result.name || `方案 ${result.id}` }}{{ operationResultText(result) }}</span>
@@ -82,7 +89,7 @@
type="primary"
link
:loading="syncingPoolId === result.id"
:disabled="syncingPoolId > 0 || savingBatchConfig"
:disabled="memberSyncBusy"
@click="syncMemberRange(result.id)"
>重试同步</el-button>
</li>
@@ -93,13 +100,13 @@
<aside class="pool-sidebar">
<div class="pool-select-all">
<el-checkbox
:model-value="allManageablePoolsSelected"
:indeterminate="someManageablePoolsSelected"
:disabled="!manageablePoolIds.length"
aria-label="全选可管理的分流方案"
:model-value="allSelectablePoolsSelected"
:indeterminate="someSelectablePoolsSelected"
:disabled="!selectablePoolIds.length || memberSyncBusy"
aria-label="全选可操作的分流方案"
@change="toggleAllPoolSelection"
>全选</el-checkbox>
<span>可选 {{ manageablePoolIds.length }} </span>
<span>可选 {{ selectablePoolIds.length }} </span>
</div>
<div
v-for="pool in overview.pools"
@@ -109,7 +116,7 @@
>
<el-checkbox
:model-value="selectedPoolIds.includes(Number(pool.id))"
:disabled="!pool.can_manage_access"
:disabled="(!pool.can_manage_access && !pool.can_operate) || memberSyncBusy"
:aria-label="`选择方案 ${pool.name}`"
@change="(checked) => togglePoolSelection(pool, checked)"
@click.stop
@@ -143,12 +150,12 @@
</div>
<div class="toolbar-actions">
<el-button :icon="CircleCheck" :loading="checkingApi" @click="checkApiPermission">验证 API</el-button>
<el-button v-if="selectedPool.can_operate" :icon="Refresh" :loading="syncingPoolId === Number(selectedPool.id)" :disabled="!selectedPool.official_link || syncingPoolId > 0 || togglingMemberId > 0" @click="syncMemberRange(Number(selectedPool.id))">同步成员范围</el-button>
<el-button v-if="selectedPool.can_operate" :icon="Refresh" :loading="syncingPoolId === Number(selectedPool.id)" :disabled="!selectedPool.official_link || memberSyncBusy || togglingMemberId > 0" @click="syncMemberRange(Number(selectedPool.id))">同步成员范围</el-button>
<el-button :icon="DocumentCopy" :disabled="!selectedPool.main_url" @click="copyText(selectedPool.main_url, '官方获客链接')">复制链接</el-button>
<el-button :icon="DocumentCopy" @click="copyText(selectedPool.install_code, 'JS 安装代码')">复制 JS</el-button>
<el-button v-if="selectedPool.can_manage_access" :icon="User" @click="openAccessDialog([Number(selectedPool.id)])">设置访问操作</el-button>
<el-button v-if="selectedPool.can_operate" :icon="Edit" @click="openPoolDialog(selectedPool)">编辑方案</el-button>
<el-button v-if="selectedPool.can_delete" type="danger" plain :icon="Delete" :loading="deletingPoolId === Number(selectedPool.id)" :disabled="deletingPoolId > 0" @click="removePool(selectedPool)">删除</el-button>
<el-button v-if="selectedPool.can_manage_access" :icon="User" :disabled="memberSyncBusy" @click="openAccessDialog([Number(selectedPool.id)])">设置访问操作</el-button>
<el-button v-if="selectedPool.can_operate" :icon="Edit" :disabled="memberSyncBusy" @click="openPoolDialog(selectedPool)">编辑方案</el-button>
<el-button v-if="selectedPool.can_delete" type="danger" plain :icon="Delete" :loading="deletingPoolId === Number(selectedPool.id)" :disabled="deletingPoolId > 0 || memberSyncBusy" @click="removePool(selectedPool)">删除</el-button>
</div>
</div>
@@ -207,12 +214,12 @@
</el-table-column>
<el-table-column label="上线" width="90" align="center">
<template #default="{ row }">
<el-switch :model-value="Number(row.enabled) === 1" :disabled="!selectedPool.can_operate || togglingMemberId > 0 || syncingPoolId > 0" :loading="togglingMemberId === Number(row.id)" @change="(value) => handleMemberToggle(row, value)" />
<el-switch :model-value="Number(row.enabled) === 1" :disabled="!selectedPool.can_operate || togglingMemberId > 0 || memberSyncBusy" :loading="togglingMemberId === Number(row.id)" @change="(value) => handleMemberToggle(row, value)" />
</template>
</el-table-column>
<el-table-column label="操作" min-width="100" fixed="right">
<template #default="{ row }">
<el-button v-if="selectedPool.can_operate" type="primary" link @click="openMemberDialog(row)">编辑规则</el-button>
<el-button v-if="selectedPool.can_operate" type="primary" link :disabled="memberSyncBusy" @click="openMemberDialog(row)">编辑规则</el-button>
</template>
</el-table-column>
<template #empty><el-empty :image-size="72" description="编辑方案并选择获客医助" /></template>
@@ -808,8 +815,11 @@ const savingAccess = ref(false)
const batchConfigDialogVisible = ref(false)
const savingBatchConfig = ref(false)
const syncingPoolId = ref(0)
const syncingBatchMembers = ref(false)
const memberSyncBusy = computed(() => syncingBatchMembers.value || syncingPoolId.value > 0 || savingBatchConfig.value)
const batchSyncProgress = reactive({ completed: 0, total: 0 })
const operationResults = ref<WecomPromotionBatchUpdatePoolResult[]>([])
type MemberOperationResult = WecomPromotionBatchUpdatePoolResult & { sync_only?: boolean }
const operationResults = ref<MemberOperationResult[]>([])
const batchConfigBusy = ref(false)
const batchConfigScroll = ref<HTMLElement>()
const batchConfigError = ref('')
@@ -864,14 +874,18 @@ const accessDialogPools = computed(() => overview.pools.filter((item: any) => ac
const manageablePoolIds = computed(() => overview.pools
.filter((pool: any) => pool.can_manage_access)
.map((pool: any) => Number(pool.id)))
const selectablePoolIds = computed(() => overview.pools
.filter((pool: any) => pool.can_manage_access || pool.can_operate)
.map((pool: any) => Number(pool.id)))
const selectedManageablePoolCount = computed(() => {
const selected = new Set(selectedPoolIds.value)
return manageablePoolIds.value.filter((id) => selected.has(id)).length
})
const allManageablePoolsSelected = computed(() => manageablePoolIds.value.length > 0
&& selectedManageablePoolCount.value === manageablePoolIds.value.length)
const someManageablePoolsSelected = computed(() => selectedManageablePoolCount.value > 0
&& selectedManageablePoolCount.value < manageablePoolIds.value.length)
const selectedSelectablePoolCount = computed(() => selectablePoolIds.value.filter((id) => selectedPoolIds.value.includes(id)).length)
const allSelectablePoolsSelected = computed(() => selectablePoolIds.value.length > 0
&& selectedSelectablePoolCount.value === selectablePoolIds.value.length)
const someSelectablePoolsSelected = computed(() => selectedSelectablePoolCount.value > 0
&& selectedSelectablePoolCount.value < selectablePoolIds.value.length)
const batchConfigPools = computed(() => overview.pools.filter((item: any) => batchConfigForm.pool_ids.includes(Number(item.id))))
const batchMemberStatusOptions = computed<BatchMemberStatusOption[]>(() => {
const memberOptionById = new Map(overview.member_options.map((member) => [Number(member.id), member]))
@@ -969,14 +983,12 @@ const customerMemberOptions = computed(() => customerStats.member_options.length
department_name: Array.isArray(item.dept_names) ? item.dept_names.join(' / ') : (item.department_name || item.dept_name || '')
})))
watch(() => overview.pools.map((item: any) => `${Number(item.id)}:${item.can_manage_access ? 1 : 0}`).join(','), () => {
watch(() => overview.pools.map((item: any) => `${Number(item.id)}:${item.can_manage_access ? 1 : 0}:${item.can_operate ? 1 : 0}`).join(','), () => {
const ids = overview.pools.map((item: any) => Number(item.id))
const manageableIds = new Set(overview.pools
.filter((item: any) => item.can_manage_access)
.map((item: any) => Number(item.id)))
const selectableIds = new Set(selectablePoolIds.value)
if (!selectedPoolId.value || !ids.includes(selectedPoolId.value)) selectedPoolId.value = ids[0]
if (!selectedInstallPoolId.value || !ids.includes(selectedInstallPoolId.value)) selectedInstallPoolId.value = ids[0]
selectedPoolIds.value = selectedPoolIds.value.filter((id) => manageableIds.has(id))
selectedPoolIds.value = selectedPoolIds.value.filter((id) => selectableIds.has(id))
}, { immediate: true })
watch(activeTab, (tab) => {
@@ -1004,7 +1016,7 @@ async function loadOverview() {
}
function togglePoolSelection(pool: any, checked: unknown) {
if (!pool?.can_manage_access) return
if (memberSyncBusy.value || (!pool?.can_manage_access && !pool?.can_operate)) return
const id = Number(pool.id)
const next = new Set(selectedPoolIds.value)
checked ? next.add(id) : next.delete(id)
@@ -1012,10 +1024,12 @@ function togglePoolSelection(pool: any, checked: unknown) {
}
function toggleAllPoolSelection(checked: unknown) {
selectedPoolIds.value = checked ? [...manageablePoolIds.value] : []
if (memberSyncBusy.value) return
selectedPoolIds.value = checked ? [...selectablePoolIds.value] : []
}
function openBatchConfigDialog(poolIds: number[] = selectedPoolIds.value) {
if (memberSyncBusy.value) return
const manageableIds = new Set(overview.pools
.filter((pool: any) => pool.can_manage_access)
.map((pool: any) => Number(pool.id)))
@@ -1068,16 +1082,17 @@ function syncErrorMessage(error: unknown, fallback: string) {
return fallback
}
function operationResultText(result: WecomPromotionBatchUpdatePoolResult) {
function operationResultText(result: MemberOperationResult) {
if (!result.success) return `本地保存失败:${result.error || '请检查配置后重试'}`
if (result.sync_status === 'synced') return '本地已保存,企微成员范围已确认同步'
const prefix = result.sync_only ? '' : '本地已保存,'
if (result.sync_status === 'synced') return `${prefix}企微成员范围已确认同步`
if (result.sync_status === 'blocked' || result.sync_status === 'failed' || result.sync_error) {
return `本地已保存,企微同步${result.sync_status === 'blocked' ? '受阻' : '失败'}${result.sync_error || '请重试同步'}`
return `${prefix}企微同步${result.sync_status === 'blocked' ? '受阻' : '失败'}${result.sync_error || '请重试同步'}`
}
return '本地已保存,企微成员范围尚未确认同步,请重试同步'
return `${prefix}企微成员范围尚未确认同步,请重试同步`
}
function recordOperationResult(result: WecomPromotionBatchUpdatePoolResult) {
function recordOperationResult(result: MemberOperationResult) {
const index = operationResults.value.findIndex((item) => item.id === result.id)
if (index < 0) operationResults.value.push(result)
else operationResults.value[index] = result
@@ -1110,20 +1125,20 @@ async function requestMemberSync(poolId: number): Promise<Partial<WecomPromotion
}
async function syncMemberRange(poolId: number) {
if (syncingPoolId.value || savingBatchConfig.value || !canSyncPool(poolId)) return
if (memberSyncBusy.value || togglingMemberId.value || !canSyncPool(poolId)) return
const pool = overview.pools.find((item: any) => Number(item.id) === poolId)
syncingPoolId.value = poolId
try {
const result = await requestMemberSync(poolId)
recordOperationResult({ id: poolId, name: pool.name, success: true, ...result })
recordOperationResult({ id: poolId, name: pool.name, success: true, sync_only: true, ...result })
await loadOverview()
notifySavedResult('本地规则已保存', result)
notifySavedResult('成员范围同步结果', result)
} finally {
syncingPoolId.value = 0
}
}
async function syncBatchResults(results: WecomPromotionBatchUpdatePoolResult[]) {
async function syncBatchResults(results: MemberOperationResult[]) {
operationResults.value = results.map((item) => ({ ...item }))
const queued = results.filter((item) => item.success && item.sync_queued)
Object.assign(batchSyncProgress, { total: queued.length, completed: 0 })
@@ -1139,8 +1154,41 @@ async function syncBatchResults(results: WecomPromotionBatchUpdatePoolResult[])
}))
}
async function syncSelectedMemberRanges() {
if (memberSyncBusy.value || togglingMemberId.value || deletingPoolId.value
|| savingPool.value || savingMember.value || savingAccess.value) return
const ids = [...new Set(selectedPoolIds.value.map(Number))]
if (!ids.length) return ElMessage.warning('请先勾选需要同步的分流方案')
if (ids.length > 100) return ElMessage.warning('单次最多同步 100 个分流方案,请分批选择')
const results: MemberOperationResult[] = ids.map((id) => {
const pool = overview.pools.find((item: any) => Number(item.id) === id)
const eligible = canSyncPool(id)
return {
id, name: pool?.name || `方案 ${id}`, success: true, sync_only: true,
sync_status: eligible ? 'pending' : 'blocked', sync_queued: eligible,
sync_error: eligible ? '' : !pool?.can_operate ? '没有该方案的操作权限' : '方案尚未生成官方链接,请先保存方案'
}
})
syncingBatchMembers.value = true
try {
await syncBatchResults(results)
selectedPoolIds.value = operationResults.value.filter((item) => item.sync_status !== 'synced').map((item) => item.id)
await loadOverview()
const synced = operationResults.value.filter((item) => item.sync_status === 'synced').length
const unconfirmed = ids.length - synced
ElMessage({
type: unconfirmed ? 'warning' : 'success',
message: `企微已确认同步 ${synced} 个方案${unconfirmed ? `${unconfirmed} 个尚未确认同步,已保留勾选` : ''}。详情见方案列表上方。`,
duration: 8000
})
} finally {
syncingBatchMembers.value = false
Object.assign(batchSyncProgress, { completed: 0, total: 0 })
}
}
async function saveBatchConfig() {
if (savingBatchConfig.value || batchConfigBusy.value) return
if (memberSyncBusy.value || batchConfigBusy.value) return
batchConfigError.value = ''
const hasAutomationChange = overview.automation_installed
&& (batchConfigApply.reception || batchConfigApply.customer || batchConfigApply.welcome)
@@ -1248,6 +1296,7 @@ async function saveBatchConfig() {
}
function openAccessDialog(poolIds: number[] = selectedPoolIds.value) {
if (memberSyncBusy.value) return
const manageableIds = new Set(overview.pools
.filter((pool: any) => pool.can_manage_access)
.map((pool: any) => Number(pool.id)))
@@ -1309,6 +1358,7 @@ function accessSummary(pool: any) {
}
function openPoolDialog(pool?: any) {
if (memberSyncBusy.value) return
const selectableMemberIds = new Set(overview.member_options.map((member) => Number(member.id)))
poolFormError.value = ''
automationBusy.value = false
@@ -1337,7 +1387,7 @@ function openPoolDialog(pool?: any) {
}
async function savePool() {
if (savingPool.value || automationBusy.value) return
if (savingPool.value || automationBusy.value || memberSyncBusy.value) return
poolFormError.value = ''
const automation = cloneAutomationConfig(poolForm.automation_config)
// Disabled sections do not submit unfinished draft rows or draft attachments.
@@ -1380,6 +1430,7 @@ async function savePool() {
}
async function removePool(pool: any) {
if (memberSyncBusy.value) return
try {
await ElMessageBox.confirm(
`删除“${pool.name}”将同时永久删除企业微信后台中的官方获客链接,已投放的链接会失效且无法恢复。确认继续?`,
@@ -1402,6 +1453,7 @@ async function removePool(pool: any) {
}
function openMemberDialog(row: any) {
if (memberSyncBusy.value) return
const range = Number(row.active_start) > 0 && Number(row.active_end) > 0
? [formatPickerTime(row.active_start), formatPickerTime(row.active_end)]
: []
@@ -1418,7 +1470,7 @@ function openMemberDialog(row: any) {
}
async function saveMemberRule() {
if (savingMember.value) return
if (savingMember.value || memberSyncBusy.value) return
savingMember.value = true
const poolId = Number(selectedPool.value?.id)
const poolName = String(selectedPool.value?.name || '')
@@ -1440,7 +1492,7 @@ async function saveMemberRule() {
}
async function handleMemberToggle(row: any, value: unknown) {
if (togglingMemberId.value || syncingPoolId.value) return
if (togglingMemberId.value || memberSyncBusy.value) return
togglingMemberId.value = Number(row.id)
const poolId = Number(selectedPool.value?.id)
const poolName = String(selectedPool.value?.name || '')