1070 lines
41 KiB
Vue
1070 lines
41 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="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-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.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="添加时间" 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-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">
|
||
{{ 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,
|
||
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 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
|
||
tag_ids: string[]
|
||
add_time_start: string
|
||
add_time_end: string
|
||
}>({
|
||
name: '',
|
||
follow_user: '',
|
||
tag_ids: [],
|
||
add_time_start: '',
|
||
add_time_end: ''
|
||
})
|
||
|
||
/** 与列表「添加时间」列口径一致(库内 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[]
|
||
}
|
||
|
||
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.tag_ids = []
|
||
queryParams.add_time_start = ''
|
||
queryParams.add_time_end = ''
|
||
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
|
||
}
|
||
|
||
/** 列表接口会写入 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)
|
||
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>
|