Files
zyt/admin/src/views/fans/qywx.vue
T
2026-05-12 17:26:11 +08:00

1070 lines
41 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="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_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)
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>