Files
zyt/admin/src/views/fans/qywx.vue
T
2026-04-21 18:02:27 +08:00

773 lines
28 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="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>
<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="添加时间" 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="120" fixed="right">
<template #default="{ row }">
<el-button type="primary" link @click="viewDetail(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-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">
{{ 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>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
import { Refresh, Setting, DataLine } from '@element-plus/icons-vue'
import { usePaging } from '@/hooks/usePaging'
import feedback from '@/utils/feedback'
import {
qywxCustomerLists,
qywxCustomerSync,
qywxCustomerStats,
qywxSyncSettingsGet,
qywxSyncSettingsSave,
qywxCustomerTodayArrival,
qywxCustomerTodayArrivalList
} from '@/api/qywx'
const syncing = ref(false)
const showSyncSettings = ref(false)
const showDetail = ref(false)
const currentCustomer = ref<any>(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: '',
follow_user: ''
})
// ── 今日进入分布 / 明细(事件流水零误差口径) ──────────────────────────────
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 = ''
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
}
/** 列表接口会写入 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('')
}
/**
* 添加时间:优先接口字段 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)
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>