773 lines
28 KiB
Vue
773 lines
28 KiB
Vue
<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_name(admin.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>
|