This commit is contained in:
Your Name
2026-04-21 18:02:27 +08:00
parent a1d2ab4649
commit d899e0fe0a
301 changed files with 1937 additions and 344 deletions
+304 -5
View File
@@ -5,6 +5,10 @@
<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>
立即同步
@@ -34,9 +38,34 @@
<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">
<div class="text-gray-500 text-sm mb-1">今日新增</div>
<div class="text-2xl font-bold text-success">{{ stats.today }}</div>
<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>
@@ -167,6 +196,103 @@
</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"
@@ -217,7 +343,7 @@
<script setup lang="ts">
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
import { Refresh, Setting } from '@element-plus/icons-vue'
import { Refresh, Setting, DataLine } from '@element-plus/icons-vue'
import { usePaging } from '@/hooks/usePaging'
import feedback from '@/utils/feedback'
import {
@@ -225,7 +351,9 @@ import {
qywxCustomerSync,
qywxCustomerStats,
qywxSyncSettingsGet,
qywxSyncSettingsSave
qywxSyncSettingsSave,
qywxCustomerTodayArrival,
qywxCustomerTodayArrivalList
} from '@/api/qywx'
const syncing = ref(false)
@@ -236,6 +364,7 @@ const currentCustomer = ref<any>(null)
const stats = reactive({
total: 0,
today: 0,
today_follow_staff: 0,
lastSync: '',
syncStatus: 'idle',
syncStatusText: '未同步'
@@ -264,6 +393,124 @@ const queryParams = reactive({
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
@@ -453,15 +700,34 @@ function formatTime(timestamp: number | string | null | undefined) {
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>
@@ -470,4 +736,37 @@ onUnmounted(() => {
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>