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