Files
zyt/admin/src/views/fans/qywx.vue
T
2026-09-03 16:03:41 +08:00

1373 lines
54 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.
<template>
<div class="qywx-customer-page p-4">
<el-card shadow="never" class="mb-4">
<template #header>
<div class="flex items-center justify-between">
<span class="text-lg font-semibold">企业微信客户管理</span>
<div class="flex gap-2">
<el-button type="success" plain @click="openArrivalDrawer">
<template #icon><DataLine /></template>
今日进入明细
</el-button>
<el-button type="warning" plain @click="openTagDrawer">
<template #icon><CollectionTag /></template>
标签维度
</el-button>
<el-button type="primary" :loading="syncing" @click="syncCustomers()">
<template #icon><Refresh /></template>
立即同步
</el-button>
<el-button @click="showSyncSettings = true">
<template #icon><Setting /></template>
同步设置
</el-button>
</div>
</div>
</template>
<el-alert
title="功能说明"
type="info"
:closable="false"
class="mb-4"
>
<div class="text-sm">
<p>自动从企业微信同步外部联系人客户信息包括客户基本信息跟进人等</p>
<p class="mt-1">同步后的数据可用于患者信息匹配和自动关联</p>
</div>
</el-alert>
<div class="grid grid-cols-4 gap-4 mb-4">
<el-card shadow="hover">
<div class="text-gray-500 text-sm mb-1">总客户数</div>
<div class="text-2xl font-bold text-primary">{{ stats.total }}</div>
</el-card>
<el-card shadow="hover" class="today-arrival-card cursor-pointer" @click="openArrivalDrawer">
<div class="flex items-center justify-between mb-1">
<span class="text-gray-500 text-sm">今日新增</span>
<span v-if="arrival.recent_time" class="text-xs text-gray-400">
最近 {{ formatHm(arrival.recent_time) }}
</span>
</div>
<div class="flex items-end gap-2">
<div class="text-2xl font-bold text-success leading-none">{{ stats.today }}</div>
<div class="text-xs text-gray-400 pb-1"></div>
</div>
<!-- 24 小时分布迷你柱高度相对今日峰值等比 -->
<div class="hourly-bars mt-2" :title="hourlyTooltip">
<div
v-for="(c, h) in arrival.hourly"
:key="h"
class="hourly-bar"
:class="{
'is-empty': c === 0,
'is-current': h === currentHour
}"
:style="{ height: barHeight(c) }"
/>
</div>
<div class="mt-2 pt-2 border-t border-gray-100">
<div class="text-gray-500 text-xs mb-0.5">今日跟进人合计</div>
<div class="text-lg font-semibold text-gray-800">{{ stats.today_follow_staff }}</div>
</div>
</el-card>
<el-card shadow="hover">
<div class="text-gray-500 text-sm mb-1">最后同步</div>
<div class="text-sm font-medium">{{ stats.lastSync || '未同步' }}</div>
</el-card>
<el-card shadow="hover">
<div class="text-gray-500 text-sm mb-1">同步状态</div>
<el-tag :type="syncStatusTagType" size="small">
{{ stats.syncStatusText }}
</el-tag>
</el-card>
</div>
</el-card>
<el-card shadow="never">
<el-form :model="queryParams" inline class="mb-4">
<el-form-item label="客户名称">
<el-input
v-model="queryParams.name"
placeholder="搜索客户名称"
clearable
@keyup.enter="resetPage"
/>
</el-form-item>
<el-form-item label="跟进人">
<el-input
v-model="queryParams.follow_user"
placeholder="跟进人姓名(后台姓名或企微账号)"
clearable
@keyup.enter="resetPage"
/>
</el-form-item>
<el-form-item label="渠道">
<el-select
v-model="queryParams.add_way"
clearable
filterable
placeholder="选择或搜索添加渠道"
style="width: 240px"
@change="resetPage"
>
<el-option
v-for="option in ADD_WAY_OPTIONS"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</el-select>
</el-form-item>
<el-form-item label="添加时间">
<el-date-picker
v-model="addTimeRange"
type="daterange"
value-format="YYYY-MM-DD"
start-placeholder="开始日期"
end-placeholder="结束日期"
clearable
class="!w-[280px]"
@change="onAddTimeRangeChange"
/>
</el-form-item>
<el-form-item label="去重筛选">
<el-select
v-model="queryParams.dedupe_mode"
style="width: 220px"
@change="resetPage"
>
<el-option
label="客户去重(首次添加)"
value="first"
/>
<el-option
label="含重加(对齐企微)"
value="any"
/>
</el-select>
<el-tooltip
content="客户去重:按客户首次添加时间。含重加:当天任意员工添加过即计入(含老客被其他员工重加),与企微时间筛选更接近;可与跟进人、添加时间联用。"
placement="top"
>
<span class="ml-1 text-gray-400 text-xs cursor-help">说明</span>
</el-tooltip>
</el-form-item>
<el-form-item label="标签">
<el-select
v-model="queryParams.tag_ids"
multiple
collapse-tags
collapse-tags-tooltip
clearable
filterable
placeholder="按标签筛选(可多选,命中任一即返回)"
:loading="tagStatsLoading"
style="min-width: 320px"
@change="resetPage"
>
<el-option-group
v-for="g in tagStats.groups"
:key="g.group_name || '_'"
:label="(g.group_name || '(未分组)') + ' · ' + g.customer_count"
>
<el-option
v-for="t in g.tags"
:key="t.tag_id"
:value="t.tag_id"
:label="t.tag_name"
>
<span class="flex items-center justify-between gap-3">
<span>{{ t.tag_name }}</span>
<span class="text-xs text-gray-400">{{ t.customer_count }} </span>
</span>
</el-option>
</el-option-group>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="resetPage">查询</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
<el-table v-loading="pager.loading" :data="pager.lists" stripe>
<el-table-column label="客户信息" min-width="200">
<template #default="{ row }">
<div class="flex items-center gap-2">
<el-avatar :size="40" :src="row.avatar" />
<div>
<div class="font-medium">{{ row.name }}</div>
<div class="text-xs text-gray-400">{{ row.external_userid }}</div>
</div>
</div>
</template>
</el-table-column>
<el-table-column label="类型" width="80">
<template #default="{ row }">
<el-tag size="small">{{ row.type === 1 ? '微信' : '企微' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="性别" width="60">
<template #default="{ row }">
{{ row.gender === 1 ? '男' : row.gender === 2 ? '女' : '未知' }}
</template>
</el-table-column>
<el-table-column label="跟进人" min-width="150">
<template #default="{ row }">
<div v-if="row.follow_users && row.follow_users.length">
<el-tooltip
v-for="(user, idx) in row.follow_users"
:key="(user.userid || '') + '-' + idx"
:disabled="!followStaffTooltip(user)"
:content="followStaffTooltip(user)"
placement="top"
>
<el-tag size="small" class="mr-1 mb-1">
{{ formatFollowUser(user) }}
</el-tag>
</el-tooltip>
</div>
<span v-else class="text-gray-400"></span>
</template>
</el-table-column>
<el-table-column label="标签" min-width="180">
<template #default="{ row }">
<div v-if="row.tags && row.tags.length">
<el-tag
v-for="t in row.tags"
:key="t.tag_id"
size="small"
effect="light"
type="warning"
class="mr-1 mb-1 cursor-pointer"
@click="addTagToFilter(t.tag_id)"
>
{{ t.tag_name }}
<span v-if="t.group_name" class="ml-1 text-gray-400 text-xs">
{{ t.group_name }}
</span>
</el-tag>
</div>
<span v-else class="text-gray-400"></span>
</template>
</el-table-column>
<el-table-column label="添加渠道" min-width="160">
<template #default="{ row }">
<div v-if="customerAddSources(row).length" class="flex items-center gap-1">
<el-tooltip
v-for="source in customerAddSources(row).slice(0, 1)"
:key="source.key"
:content="addSourceTooltip(source)"
placement="top"
>
<span class="inline-block max-w-[150px] truncate align-middle">
{{ source.label }}
</span>
</el-tooltip>
<el-tooltip
v-if="customerAddSources(row).length > 1"
:content="remainingAddSourcesTooltip(row)"
placement="top"
>
<span class="text-primary whitespace-nowrap cursor-help">
{{ customerAddSources(row).length - 1 }}
</span>
</el-tooltip>
</div>
<span v-else class="text-gray-400">未记录</span>
</template>
</el-table-column>
<el-table-column label="添加时间" width="160">
<template #default="{ row }">
{{ formatTime(firstExternalAddTime(row)) }}
</template>
</el-table-column>
<el-table-column label="更新时间" width="160">
<template #default="{ row }">
{{ formatTime(row.update_time) }}
</template>
</el-table-column>
<el-table-column label="操作" width="160" fixed="right">
<template #default="{ row }">
<el-button type="primary" link @click="viewDetail(row)">查看详情</el-button>
<el-button
v-perms="['qywx.customer/delete']"
type="danger"
link
:loading="deletingCustomerId === Number(row.id)"
:disabled="deletingCustomerId !== null"
@click="handleDelete(row)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<div class="flex justify-end mt-4">
<pagination v-model="pager" @change="getLists" />
</div>
</el-card>
<!-- 同步设置对话框 -->
<el-dialog
v-model="showSyncSettings"
title="同步设置"
width="500px"
>
<el-form :model="syncSettings" label-width="120px">
<el-form-item label="自动同步">
<el-switch v-model="syncSettings.auto_sync" />
<div class="text-xs text-gray-400 mt-1">
开启后将定时自动同步企业微信客户数据
</div>
</el-form-item>
<el-form-item label="同步间隔">
<el-select v-model="syncSettings.interval" :disabled="!syncSettings.auto_sync">
<el-option label="每小时" :value="3600" />
<el-option label="每2小时" :value="7200" />
<el-option label="每4小时" :value="14400" />
<el-option label="每6小时" :value="21600" />
<el-option label="每12小时" :value="43200" />
<el-option label="每天" :value="86400" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showSyncSettings = false">取消</el-button>
<el-button type="primary" @click="saveSyncSettings">保存</el-button>
</template>
</el-dialog>
<!-- 今日进入明细事件流水零误差口径 -->
<el-drawer
v-model="showArrival"
title="今日进入明细"
direction="rtl"
size="520px"
:destroy-on-close="false"
>
<template #header>
<div class="flex items-center justify-between w-full pr-4">
<div class="flex items-center gap-3">
<span class="text-base font-semibold">今日进入明细</span>
<el-tag type="success" effect="plain"> {{ arrivalList.total }} </el-tag>
</div>
<el-button link type="primary" :loading="arrivalListLoading" @click="loadArrival(true)">
<template #icon><Refresh /></template>
刷新
</el-button>
</div>
</template>
<div class="px-1">
<el-alert
type="info"
:closable="false"
class="mb-3"
show-icon
>
<template #title>
<span class="text-xs">
一次推送 = 一个人进来数据来源为企微回调流水表INSERT IGNORE 幂等),与客户同步解耦数字零误差
</span>
</template>
</el-alert>
<div v-if="arrival.by_state?.length" class="mb-3">
<div class="text-xs text-gray-500 mb-1">今日推广渠道 Top</div>
<div class="flex flex-wrap gap-1">
<el-tag
v-for="it in arrival.by_state"
:key="it.state || '_'"
size="small"
effect="light"
type="success"
>
{{ it.state || '(无 state' }} · {{ it.count }}
</el-tag>
</div>
</div>
<el-timeline v-if="arrivalList.lists.length" class="mt-2">
<el-timeline-item
v-for="row in arrivalList.lists"
:key="row.id"
:timestamp="formatHms(row.event_time)"
placement="top"
type="success"
:hollow="false"
>
<div class="flex items-start gap-2">
<el-avatar
:size="32"
:src="row.customer_avatar || undefined"
>
{{ (row.customer_name || '?').slice(0, 1) }}
</el-avatar>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium truncate">
{{ row.customer_name || row.external_userid || '(未知客户)' }}
</div>
<div class="text-xs text-gray-500 mt-0.5">
<span>接待{{ row.admin_name || row.user_id || '—' }}</span>
<span v-if="row.state" class="ml-2">
渠道<el-tag size="small" type="info" effect="plain">{{ row.state }}</el-tag>
</span>
<span v-if="row.welcome_code" class="ml-2 text-emerald-500">欢迎语</span>
</div>
</div>
</div>
</el-timeline-item>
</el-timeline>
<el-empty v-else description="今天还没有人进入" :image-size="80" />
<div
v-if="arrivalList.total > arrivalList.lists.length"
class="mt-3 text-center"
>
<el-button
:loading="arrivalListLoading"
size="small"
@click="loadMoreArrival"
>
加载更多还剩 {{ arrivalList.total - arrivalList.lists.length }}
</el-button>
</div>
</div>
</el-drawer>
<!-- 标签维度面板 -->
<el-drawer
v-model="showTagDrawer"
title="标签维度统计"
direction="rtl"
size="560px"
:destroy-on-close="false"
>
<template #header>
<div class="flex items-center justify-between w-full pr-4">
<div class="flex items-center gap-3">
<span class="text-base font-semibold">标签维度统计</span>
<el-tag type="warning" effect="plain">
{{ tagStats.groups.length }} · {{ tagStats.total_tags }} 标签
</el-tag>
</div>
<el-button link type="primary" :loading="tagStatsLoading" @click="loadTagStats(true)">
<template #icon><Refresh /></template>
刷新
</el-button>
</div>
</template>
<div class="px-1">
<div class="grid grid-cols-3 gap-2 mb-3">
<el-card shadow="never" body-class="!p-3">
<div class="text-xs text-gray-500">已打标签客户</div>
<div class="text-xl font-bold text-warning">{{ tagStats.total_tagged_customers }}</div>
</el-card>
<el-card shadow="never" body-class="!p-3">
<div class="text-xs text-gray-500">标签数</div>
<div class="text-xl font-bold">{{ tagStats.total_tags }}</div>
</el-card>
<el-card shadow="never" body-class="!p-3">
<div class="text-xs text-gray-500">关系总数</div>
<div class="text-xl font-bold">{{ tagStats.total_relations }}</div>
</el-card>
</div>
<el-input
v-model="tagSearchKw"
placeholder="筛选标签名 / 分组名"
clearable
size="small"
class="mb-3"
/>
<el-empty
v-if="filteredTagGroups.length === 0"
:description="tagStatsLoading ? '加载中…' : '暂无标签数据'"
:image-size="64"
/>
<el-collapse v-else v-model="tagDrawerActiveGroups">
<el-collapse-item
v-for="g in filteredTagGroups"
:key="g.group_name || '_'"
:name="g.group_name || '_'"
>
<template #title>
<div class="flex items-center gap-2 w-full">
<span class="font-medium">{{ g.group_name || '(未分组)' }}</span>
<el-tag size="small" effect="plain">{{ g.tags.length }}</el-tag>
<span class="ml-auto text-xs text-gray-500">
最热 {{ g.customer_count }}
</span>
</div>
</template>
<el-table :data="g.tags" size="small" stripe>
<el-table-column label="标签" min-width="160">
<template #default="{ row }">
<span class="font-medium">{{ row.tag_name }}</span>
<div class="text-xs text-gray-400 truncate">{{ row.tag_id }}</div>
</template>
</el-table-column>
<el-table-column label="客户数" width="100" align="right">
<template #default="{ row }">
<span class="text-warning font-bold">{{ row.customer_count }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="80" align="center">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="filterByTag(row.tag_id)">
筛选
</el-button>
</template>
</el-table-column>
</el-table>
</el-collapse-item>
</el-collapse>
</div>
</el-drawer>
<!-- 客户详情对话框 -->
<el-dialog
v-model="showDetail"
title="客户详情"
width="600px"
>
<el-descriptions v-if="currentCustomer" :column="2" border>
<el-descriptions-item label="客户名称">{{ currentCustomer.name }}</el-descriptions-item>
<el-descriptions-item label="External ID">{{ currentCustomer.external_userid }}</el-descriptions-item>
<el-descriptions-item label="性别">
{{ currentCustomer.gender === 1 ? '男' : currentCustomer.gender === 2 ? '女' : '未知' }}
</el-descriptions-item>
<el-descriptions-item label="类型">
{{ currentCustomer.type === 1 ? '微信用户' : '企业微信用户' }}
</el-descriptions-item>
<el-descriptions-item label="企业名称" :span="2">
{{ currentCustomer.corp_name || '—' }}
</el-descriptions-item>
<el-descriptions-item label="职位" :span="2">
{{ currentCustomer.position || '—' }}
</el-descriptions-item>
<el-descriptions-item label="添加时间" :span="2">
{{ formatTime(firstExternalAddTime(currentCustomer)) }}
</el-descriptions-item>
<el-descriptions-item label="添加渠道" :span="2">
<div v-if="customerAddSources(currentCustomer).length" class="flex flex-wrap gap-1">
<el-tooltip
v-for="source in customerAddSources(currentCustomer)"
:key="source.key"
:content="addSourceTooltip(source)"
placement="top"
>
<el-tag size="small" type="info" effect="plain">
{{ source.label }}
</el-tag>
</el-tooltip>
</div>
<span v-else class="text-gray-400">未记录</span>
</el-descriptions-item>
<el-descriptions-item label="更新时间" :span="2">
{{ formatTime(currentCustomer.update_time) }}
</el-descriptions-item>
<el-descriptions-item label="跟进人" :span="2">
<div v-if="currentCustomer.follow_users?.length">
<el-tooltip
v-for="(user, idx) in currentCustomer.follow_users"
:key="(user.userid || '') + '-' + idx"
:disabled="!followStaffTooltip(user)"
:content="followStaffTooltip(user)"
placement="top"
>
<el-tag size="small" class="mr-1 mb-1">
{{ formatFollowUser(user) }}
</el-tag>
</el-tooltip>
</div>
<span v-else class="text-gray-400"></span>
</el-descriptions-item>
<el-descriptions-item label="标签" :span="2">
<div v-if="currentCustomer.tags?.length">
<el-tag
v-for="t in currentCustomer.tags"
:key="t.tag_id"
size="small"
effect="light"
type="warning"
class="mr-1 mb-1"
>
{{ t.tag_name }}
<span v-if="t.group_name" class="ml-1 text-gray-400 text-xs">
{{ t.group_name }}
</span>
</el-tag>
</div>
<span v-else class="text-gray-400"></span>
</el-descriptions-item>
</el-descriptions>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
import { Refresh, Setting, DataLine, CollectionTag } from '@element-plus/icons-vue'
import { usePaging } from '@/hooks/usePaging'
import feedback from '@/utils/feedback'
import {
qywxCustomerLists,
qywxCustomerDelete,
qywxCustomerSync,
qywxCustomerStats,
qywxSyncSettingsGet,
qywxSyncSettingsSave,
qywxCustomerTodayArrival,
qywxCustomerTodayArrivalList,
qywxCustomerTagStats
} from '@/api/qywx'
const syncing = ref(false)
const showSyncSettings = ref(false)
const showDetail = ref(false)
const currentCustomer = ref<any>(null)
const deletingCustomerId = ref<number | null>(null)
const stats = reactive({
total: 0,
today: 0,
today_follow_staff: 0,
lastSync: '',
syncStatus: 'idle',
syncStatusText: '未同步'
})
const syncStatusTagType = computed(() => {
switch (stats.syncStatus) {
case 'success':
return 'success'
case 'syncing':
return 'warning'
case 'failed':
return 'danger'
default:
return 'info'
}
})
const syncSettings = reactive({
auto_sync: false,
interval: 3600
})
const queryParams = reactive<{
name: string
follow_user: string
add_way: number | ''
tag_ids: string[]
add_time_start: string
add_time_end: string
dedupe_mode: 'first' | 'any'
}>({
name: '',
follow_user: '',
add_way: '',
tag_ids: [],
add_time_start: '',
add_time_end: '',
dedupe_mode: 'first'
})
/** 与列表「添加时间」列口径一致(库内 external_first_add_time / create_time */
const addTimeRange = ref<[string, string] | null>(null)
function onAddTimeRangeChange(val: [string, string] | null) {
if (val && val.length === 2 && val[0] && val[1]) {
queryParams.add_time_start = val[0]
queryParams.add_time_end = val[1]
} else {
queryParams.add_time_start = ''
queryParams.add_time_end = ''
}
resetPage()
}
// ── 标签维度(筛选下拉 + 抽屉面板共用同一份数据) ──────────────────────────
interface TagItem {
tag_id: string
tag_name: string
customer_count: number
}
interface TagGroup {
group_name: string
customer_count: number
tags: TagItem[]
}
interface TagStatsPayload {
total_tags: number
total_relations: number
total_tagged_customers: number
groups: TagGroup[]
}
interface AddChannel {
state: string
label: string
source_type: 'promotion_pool' | 'state'
pool_id: number
user_id: string
event_time: number
}
interface AddSource extends AddChannel {
key: string
add_way: number | null
channel_label: string
staff_name: string
}
const tagStats = reactive<TagStatsPayload>({
total_tags: 0,
total_relations: 0,
total_tagged_customers: 0,
groups: []
})
const tagStatsLoading = ref(false)
const showTagDrawer = ref(false)
const tagSearchKw = ref('')
const tagDrawerActiveGroups = ref<string[]>([])
const filteredTagGroups = computed<TagGroup[]>(() => {
const kw = tagSearchKw.value.trim().toLowerCase()
if (!kw) return tagStats.groups
const result: TagGroup[] = []
for (const g of tagStats.groups) {
const groupHit = (g.group_name || '').toLowerCase().includes(kw)
const tags = g.tags.filter((t) => groupHit || (t.tag_name || '').toLowerCase().includes(kw))
if (tags.length) {
result.push({ ...g, tags })
}
}
return result
})
async function loadTagStats(showFeedback = false) {
tagStatsLoading.value = true
try {
const res: any = await qywxCustomerTagStats()
if (res && typeof res === 'object') {
tagStats.total_tags = Number(res.total_tags ?? 0)
tagStats.total_relations = Number(res.total_relations ?? 0)
tagStats.total_tagged_customers = Number(res.total_tagged_customers ?? 0)
tagStats.groups = Array.isArray(res.groups) ? res.groups : []
}
if (showFeedback) feedback.msgSuccess('已刷新')
} catch (e: any) {
if (showFeedback) feedback.msgError(e?.message || '加载标签统计失败')
console.error('加载标签统计失败', e)
} finally {
tagStatsLoading.value = false
}
}
function openTagDrawer() {
showTagDrawer.value = true
// 默认展开第 1 组(最热门);其余折叠避免首屏太长
tagDrawerActiveGroups.value = tagStats.groups[0]?.group_name ? [tagStats.groups[0].group_name] : []
if (tagStats.groups.length === 0) {
loadTagStats()
}
}
/** 列表上点击 tag 徽章 → 把这个 tag 加入筛选并重查 */
function addTagToFilter(tagId: string) {
if (!tagId) return
if (!queryParams.tag_ids.includes(tagId)) {
queryParams.tag_ids.push(tagId)
resetPage()
}
}
/** 抽屉里点「筛选」→ 用这个 tag 替换当前筛选 + 关闭抽屉 */
function filterByTag(tagId: string) {
if (!tagId) return
queryParams.tag_ids = [tagId]
showTagDrawer.value = false
resetPage()
}
// ── 今日进入分布 / 明细(事件流水零误差口径) ──────────────────────────────
interface ArrivalStats {
total: number
recent_time: number
hourly: number[]
by_state: { state: string; count: number }[]
}
interface ArrivalItem {
id: number
event_time: number
user_id: string
admin_name: string
external_userid: string
customer_name: string
customer_avatar: string
state: string
welcome_code: number
}
const arrival = reactive<ArrivalStats>({
total: 0,
recent_time: 0,
hourly: new Array(24).fill(0),
by_state: []
})
const showArrival = ref(false)
const arrivalListLoading = ref(false)
const arrivalList = reactive<{ total: number; page_no: number; page_size: number; lists: ArrivalItem[] }>({
total: 0,
page_no: 1,
page_size: 20,
lists: []
})
const currentHour = new Date().getHours()
const hourlyMax = computed(() => Math.max(1, ...arrival.hourly))
function barHeight(c: number) {
// 柱图容器高度 32px;0 值保留一条 2px 的底线让用户知道"这个小时有格子"
if (c <= 0) return '2px'
const ratio = c / hourlyMax.value
return Math.max(4, Math.round(ratio * 32)) + 'px'
}
const hourlyTooltip = computed(() => {
const parts: string[] = []
arrival.hourly.forEach((c, h) => {
if (c > 0) parts.push(`${String(h).padStart(2, '0')}:00 · ${c}`)
})
return parts.length ? parts.join(' ') : '今天还没有人进入'
})
async function loadArrival(refreshList = false) {
try {
const res: any = await qywxCustomerTodayArrival()
if (res) {
arrival.total = Number(res.total ?? 0)
arrival.recent_time = Number(res.recent_time ?? 0)
arrival.hourly = Array.isArray(res.hourly) && res.hourly.length === 24
? res.hourly.map((n: any) => Number(n) || 0)
: new Array(24).fill(0)
arrival.by_state = Array.isArray(res.by_state) ? res.by_state : []
}
} catch (e) {
console.error('加载今日进入分布失败', e)
}
if (refreshList && showArrival.value) {
arrivalList.page_no = 1
arrivalList.lists = []
await fetchArrivalPage()
}
}
async function fetchArrivalPage() {
arrivalListLoading.value = true
try {
const res: any = await qywxCustomerTodayArrivalList({
page_no: arrivalList.page_no,
page_size: arrivalList.page_size
})
if (res) {
arrivalList.total = Number(res.total ?? 0)
const rows: ArrivalItem[] = Array.isArray(res.lists) ? res.lists : []
if (arrivalList.page_no === 1) {
arrivalList.lists = rows
} else {
arrivalList.lists.push(...rows)
}
}
} catch (e) {
console.error('加载今日进入明细失败', e)
} finally {
arrivalListLoading.value = false
}
}
async function openArrivalDrawer() {
showArrival.value = true
arrivalList.page_no = 1
arrivalList.lists = []
// 分布和明细一起刷新,确保抽屉里看到的数字和外面卡片一致
await Promise.all([loadArrival(false), fetchArrivalPage()])
}
async function loadMoreArrival() {
if (arrivalList.lists.length >= arrivalList.total) return
arrivalList.page_no += 1
await fetchArrivalPage()
}
function formatHm(ts: number) {
if (!ts || ts <= 0) return ''
const d = new Date(ts < 1e12 ? ts * 1000 : ts)
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
}
function formatHms(ts: number) {
if (!ts || ts <= 0) return '—'
const d = new Date(ts < 1e12 ? ts * 1000 : ts)
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}`
}
let syncTimer: number | null = null
/** 后台同步完成后轮询统计,直到非「同步中」 */
let pollSyncTimer: number | null = null
function stopPollSync() {
if (pollSyncTimer !== null) {
clearInterval(pollSyncTimer)
pollSyncTimer = null
}
}
async function fetchLists(params: Record<string, unknown>) {
return qywxCustomerLists(params)
}
const { pager, getLists, resetPage, resetParams } = usePaging({
fetchFun: fetchLists,
params: queryParams
})
function handleReset() {
queryParams.name = ''
queryParams.follow_user = ''
queryParams.add_way = ''
queryParams.tag_ids = []
queryParams.add_time_start = ''
queryParams.add_time_end = ''
queryParams.dedupe_mode = 'first'
addTimeRange.value = null
resetParams()
}
function startPollSyncUntilIdle() {
stopPollSync()
let ticks = 0
pollSyncTimer = window.setInterval(async () => {
ticks += 1
await loadStats()
await getLists()
if (stats.syncStatus !== 'syncing' || ticks >= 120) {
stopPollSync()
syncing.value = false
if (ticks >= 120 && stats.syncStatus === 'syncing') {
feedback.msgWarning('长时间仍为「同步中」,请刷新页面或查看服务器 runtime/log/qywx_sync.log(可能未安装 CLI php 或禁用了 exec')
} else if (stats.syncStatus === 'failed') {
feedback.msgError('同步失败,请查看服务器 runtime/log/qywx_sync.log 或后台同步状态')
}
}
}, 3000)
}
async function syncCustomers(options?: { silent?: boolean }) {
const silent = options?.silent === true
syncing.value = true
try {
const res: any = await qywxCustomerSync()
if (!silent) {
feedback.msgSuccess(res?.message || '操作成功')
}
await loadStats()
await getLists()
if (res?.background) {
startPollSyncUntilIdle()
} else {
syncing.value = false
}
} catch (e: any) {
const msg = e?.message || e?.msg || '同步失败'
if (!silent || !String(msg).includes('正在执行')) {
feedback.msgError(msg)
}
syncing.value = false
}
}
async function loadStats() {
try {
const res: any = await qywxCustomerStats()
if (res && typeof res === 'object') {
Object.assign(stats, res)
}
} catch (e) {
console.error('加载统计失败', e)
}
}
async function loadSyncSettings() {
try {
const res: any = await qywxSyncSettingsGet()
if (res && typeof res === 'object') {
Object.assign(syncSettings, res)
}
// 如果开启了自动同步,启动定时器
if (syncSettings.auto_sync) {
startSyncTimer()
}
} catch (e) {
console.error('加载同步设置失败', e)
}
}
async function saveSyncSettings() {
try {
await qywxSyncSettingsSave(syncSettings)
feedback.msgSuccess('保存成功')
showSyncSettings.value = false
// 重新启动定时器
stopSyncTimer()
if (syncSettings.auto_sync) {
startSyncTimer()
}
} catch (e: any) {
feedback.msgError(e?.message || '保存失败')
}
}
function startSyncTimer() {
if (syncTimer) return
syncTimer = window.setInterval(() => {
syncCustomers({ silent: true })
}, syncSettings.interval * 1000)
}
function stopSyncTimer() {
if (syncTimer) {
clearInterval(syncTimer)
syncTimer = null
}
}
function viewDetail(row: any) {
currentCustomer.value = row
showDetail.value = true
}
async function handleDelete(row: Record<string, any>) {
const id = Number(row.id)
if (!Number.isInteger(id) || id <= 0 || deletingCustomerId.value !== null) return
const customerName = String(row.name || row.external_userid || '该客户')
try {
await feedback.confirm(
`确定删除企业微信客户“${customerName}”吗?此操作仅删除系统内的同步记录,不会删除企业微信中的客户关系;后续重新同步时可能再次出现。`
)
} catch {
return
}
deletingCustomerId.value = id
try {
await qywxCustomerDelete({ id })
if (pager.page > 1 && pager.lists.length === 1) {
pager.page -= 1
}
await Promise.all([getLists(), loadStats(), loadTagStats()])
feedback.msgSuccess('删除成功')
} catch (e: any) {
feedback.msgError(e?.message || e?.msg || '删除失败')
} finally {
deletingCustomerId.value = null
}
}
/** 列表接口会写入 admin_nameadmin.work_wechat_userid = userid */
function formatFollowUser(user: Record<string, any>) {
const adminName = String(user?.admin_name ?? '').trim()
const id = String(user?.userid ?? '').trim()
const name = String(user?.name ?? '').trim()
if (adminName) return adminName
if (name && id) return `${name}${id}`
if (name) return name
if (id) return id
return '—'
}
function followStaffTooltip(user: Record<string, any>) {
const adminName = String(user?.admin_name ?? '').trim()
const id = String(user?.userid ?? '').trim()
const parts: string[] = []
if (adminName) parts.push('后台:' + adminName)
if (id) parts.push('企微:' + id)
return parts.join('')
}
function customerAddChannels(row: Record<string, any> | null | undefined): AddChannel[] {
if (!row) return []
if (Array.isArray(row.add_channels)) {
return row.add_channels
.map((channel: Record<string, any>): AddChannel => ({
state: String(channel?.state ?? '').trim(),
label: String(channel?.label ?? channel?.state ?? '').trim(),
source_type: channel?.source_type === 'promotion_pool' ? 'promotion_pool' : 'state',
pool_id: Number(channel?.pool_id ?? 0),
user_id: String(channel?.user_id ?? '').trim(),
event_time: Number(channel?.event_time ?? 0)
}))
.filter((channel: AddChannel) => channel.state !== '')
}
// 兼容仅返回原始渠道数组的旧接口/灰度节点。
if (!Array.isArray(row.add_channel_states)) return []
return row.add_channel_states
.map((state: unknown) => String(state ?? '').trim())
.filter((state: string) => state !== '')
.map((state: string) => ({
state,
label: state,
source_type: 'state' as const,
pool_id: 0,
user_id: '',
event_time: 0
}))
}
const ADD_WAY_LABELS: Record<number, string> = {
0: '未知添加方式',
1: '通过扫描二维码添加',
2: '通过搜索手机号添加',
3: '通过名片分享添加',
4: '通过群聊添加',
5: '通过手机通讯录添加',
6: '通过微信联系人添加',
8: '安装第三方应用时自动添加',
9: '通过搜索邮箱添加',
10: '通过视频号添加',
11: '通过日程参与人添加',
12: '通过会议参与人添加',
13: '通过微信好友添加',
14: '通过智慧硬件专属客服添加',
15: '通过上门服务客服添加',
16: '通过获客链接添加',
17: '通过定制开发添加',
18: '通过需求回复添加',
21: '通过第三方售前客服添加',
22: '通过可能的商务伙伴添加',
24: '通过接受微信好友申请添加',
201: '通过内部成员共享添加',
202: '通过管理员或负责人分配添加'
}
const ADD_WAY_OPTIONS = Object.entries(ADD_WAY_LABELS).map(([value, label]) => ({
value: Number(value),
label
}))
function normalizeAddWay(value: unknown): number | null {
if (typeof value === 'number' && Number.isInteger(value) && value >= 0) return value
if (typeof value !== 'string' || !/^\d+$/.test(value.trim())) return null
return Number(value.trim())
}
function addWayLabel(addWay: number) {
return ADD_WAY_LABELS[addWay] || `其他添加方式(${addWay}`
}
function customerAddSources(row: Record<string, any> | null | undefined): AddSource[] {
if (!row) return []
const channels = customerAddChannels(row)
const usedChannelIndexes = new Set<number>()
const sources: AddSource[] = []
const followUsers = Array.isArray(row.follow_users) ? row.follow_users : []
followUsers.forEach((user: Record<string, any>, index: number) => {
const userId = String(user?.userid ?? user?.UserId ?? '').trim()
const state = String(user?.state ?? user?.State ?? '').trim()
const addWay = normalizeAddWay(user?.add_way ?? user?.AddWay)
let channelIndex = channels.findIndex(
(channel, i) =>
!usedChannelIndexes.has(i) &&
userId !== '' &&
state !== '' &&
channel.user_id === userId &&
channel.state === state
)
if (channelIndex < 0 && state !== '') {
channelIndex = channels.findIndex(
(channel, i) => !usedChannelIndexes.has(i) && channel.state === state
)
}
if (channelIndex < 0 && userId !== '') {
channelIndex = channels.findIndex(
(channel, i) => !usedChannelIndexes.has(i) && channel.user_id === userId
)
}
const channel = channelIndex >= 0 ? channels[channelIndex] : undefined
if (channelIndex >= 0) usedChannelIndexes.add(channelIndex)
if (addWay === null && state === '' && !channel) return
const labelFromApi = String(user?.add_way_label ?? '').trim()
const sourceType = channel?.source_type ?? (/^zyt_pool:[1-9]\d*$/.test(state) ? 'promotion_pool' : 'state')
const label = labelFromApi || (addWay !== null
? addWayLabel(addWay)
: sourceType === 'promotion_pool'
? '通过获客链接添加'
: '通过其他渠道添加')
sources.push({
key: `follow:${index}:${userId}:${addWay ?? 'unknown'}:${state}`,
add_way: addWay,
label,
state: state || channel?.state || '',
channel_label: channel?.label || '',
source_type: sourceType,
pool_id: channel?.pool_id || 0,
user_id: userId || channel?.user_id || '',
staff_name: formatFollowUser(user),
event_time: channel?.event_time || Number(user?.createtime ?? 0)
})
})
// 兼容事件日志中仍有记录、但当前 follow_users 已不存在或旧接口未返回 add_way 的客户。
channels.forEach((channel, index) => {
if (usedChannelIndexes.has(index)) return
sources.push({
...channel,
key: `channel:${index}:${channel.user_id}:${channel.state}`,
add_way: channel.source_type === 'promotion_pool' ? 16 : null,
label: channel.source_type === 'promotion_pool' ? '通过获客链接添加' : '通过其他渠道添加',
channel_label: channel.label,
staff_name: channel.user_id || '—'
})
})
return sources.sort((a, b) => b.event_time - a.event_time)
}
function addSourceTooltip(source: AddSource) {
const parts: string[] = []
parts.push(`添加方式:${source.label}`)
if (source.source_type === 'promotion_pool' && source.channel_label) {
parts.push(`获客助手方案:${source.channel_label}`)
}
if (source.staff_name && source.staff_name !== '—') parts.push(`跟进人:${source.staff_name}`)
if (source.event_time > 0) parts.push(`添加时间:${formatTime(source.event_time)}`)
if (source.state) parts.push(`渠道参数:${source.state}`)
return parts.join('')
}
function remainingAddSourcesTooltip(row: Record<string, any>) {
return customerAddSources(row).slice(1).map(addSourceTooltip).join('\n')
}
/**
* 添加时间:优先接口字段 external_first_add_time(同步写入 + 列表对未回填行按 JSON 兜底);
* 再解析 follow_users;最后退回 create_time
*/
function firstExternalAddTime(row: Record<string, any> | null | undefined): number | string | null {
if (!row) return null
const fromApi = Number(row.external_first_add_time ?? 0)
if (fromApi > 0) return fromApi
const users = row.follow_users
if (Array.isArray(users) && users.length > 0) {
let min = 0
for (const u of users) {
const t = Number(u?.createtime ?? 0)
if (t > 0 && (min === 0 || t < min)) min = t
}
if (min > 0) return min
}
return row.create_time ?? null
}
function formatTime(timestamp: number | string | null | undefined) {
if (timestamp === '' || timestamp === null || timestamp === undefined) return '—'
const n = Number(timestamp)
const isNumericString = typeof timestamp === 'string' && /^\d+$/.test(timestamp.trim())
let ms: number
if (typeof timestamp === 'number') {
ms = timestamp < 1e12 ? timestamp * 1000 : timestamp
} else if (!Number.isNaN(n) && isNumericString) {
ms = n < 1e12 ? n * 1000 : n
} else {
ms = new Date(timestamp).getTime()
}
if (Number.isNaN(ms)) return '—'
return new Date(ms).toLocaleString('zh-CN')
}
/** 今日进入分布 30s 轮询:回调一到,事件流水立刻能反映在卡片上 */
let arrivalPollTimer: number | null = null
function startArrivalPolling() {
if (arrivalPollTimer !== null) return
arrivalPollTimer = window.setInterval(() => {
loadStats()
loadArrival(showArrival.value)
}, 30000)
}
function stopArrivalPolling() {
if (arrivalPollTimer !== null) {
clearInterval(arrivalPollTimer)
arrivalPollTimer = null
}
}
onMounted(() => {
getLists()
loadStats()
loadSyncSettings()
loadArrival(false)
loadTagStats()
startArrivalPolling()
})
onUnmounted(() => {
stopSyncTimer()
stopPollSync()
stopArrivalPolling()
})
</script>
<style scoped lang="scss">
.qywx-customer-page {
max-width: 1400px;
margin: 0 auto;
}
.today-arrival-card {
transition: transform 0.15s ease, box-shadow 0.15s ease;
&:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
}
.hourly-bars {
display: grid;
grid-template-columns: repeat(24, 1fr);
align-items: end;
gap: 2px;
height: 32px;
.hourly-bar {
background: var(--el-color-success);
border-radius: 2px 2px 0 0;
min-height: 2px;
transition: height 0.3s ease;
&.is-empty {
background: var(--el-border-color-lighter);
}
&.is-current {
outline: 1px solid var(--el-color-success-dark-2);
outline-offset: -1px;
}
}
}
</style>