更新
This commit is contained in:
+299
-21
@@ -94,15 +94,32 @@
|
||||
@keyup.enter="resetPage"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="跟进人">
|
||||
<el-input
|
||||
v-model="queryParams.follow_user"
|
||||
<el-form-item label="跟进人">
|
||||
<el-input
|
||||
v-model="queryParams.follow_user"
|
||||
placeholder="跟进人姓名(后台姓名或企微账号)"
|
||||
clearable
|
||||
@keyup.enter="resetPage"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="添加时间">
|
||||
@keyup.enter="resetPage"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="渠道">
|
||||
<el-select
|
||||
v-model="queryParams.add_way"
|
||||
clearable
|
||||
filterable
|
||||
placeholder="选择或搜索添加渠道"
|
||||
style="width: 240px"
|
||||
@change="resetPage"
|
||||
>
|
||||
<el-option
|
||||
v-for="option in ADD_WAY_OPTIONS"
|
||||
:key="option.value"
|
||||
:label="option.label"
|
||||
:value="option.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="添加时间">
|
||||
<el-date-picker
|
||||
v-model="addTimeRange"
|
||||
type="daterange"
|
||||
@@ -235,6 +252,32 @@
|
||||
<span v-else class="text-gray-400">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="添加渠道" min-width="160">
|
||||
<template #default="{ row }">
|
||||
<div v-if="customerAddSources(row).length" class="flex items-center gap-1">
|
||||
<el-tooltip
|
||||
v-for="source in customerAddSources(row).slice(0, 1)"
|
||||
:key="source.key"
|
||||
:content="addSourceTooltip(source)"
|
||||
placement="top"
|
||||
>
|
||||
<span class="inline-block max-w-[150px] truncate align-middle">
|
||||
{{ source.label }}
|
||||
</span>
|
||||
</el-tooltip>
|
||||
<el-tooltip
|
||||
v-if="customerAddSources(row).length > 1"
|
||||
:content="remainingAddSourcesTooltip(row)"
|
||||
placement="top"
|
||||
>
|
||||
<span class="text-primary whitespace-nowrap cursor-help">
|
||||
另 {{ customerAddSources(row).length - 1 }} 条
|
||||
</span>
|
||||
</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)) }}
|
||||
@@ -245,9 +288,19 @@
|
||||
{{ formatTime(row.update_time) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120" fixed="right">
|
||||
<el-table-column label="操作" width="160" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link @click="viewDetail(row)">查看详情</el-button>
|
||||
<el-button
|
||||
v-perms="['qywx.customer/delete']"
|
||||
type="danger"
|
||||
link
|
||||
:loading="deletingCustomerId === Number(row.id)"
|
||||
:disabled="deletingCustomerId !== null"
|
||||
@click="handleDelete(row)"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -500,6 +553,21 @@
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="添加时间" :span="2">
|
||||
{{ formatTime(firstExternalAddTime(currentCustomer)) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="添加渠道" :span="2">
|
||||
<div v-if="customerAddSources(currentCustomer).length" class="flex flex-wrap gap-1">
|
||||
<el-tooltip
|
||||
v-for="source in customerAddSources(currentCustomer)"
|
||||
:key="source.key"
|
||||
:content="addSourceTooltip(source)"
|
||||
placement="top"
|
||||
>
|
||||
<el-tag size="small" type="info" effect="plain">
|
||||
{{ source.label }}
|
||||
</el-tag>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
<span v-else class="text-gray-400">未记录</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="更新时间" :span="2">
|
||||
{{ formatTime(currentCustomer.update_time) }}
|
||||
@@ -549,8 +617,9 @@ import { Refresh, Setting, DataLine, CollectionTag } from '@element-plus/icons-v
|
||||
import { usePaging } from '@/hooks/usePaging'
|
||||
import feedback from '@/utils/feedback'
|
||||
import {
|
||||
qywxCustomerLists,
|
||||
qywxCustomerSync,
|
||||
qywxCustomerLists,
|
||||
qywxCustomerDelete,
|
||||
qywxCustomerSync,
|
||||
qywxCustomerStats,
|
||||
qywxSyncSettingsGet,
|
||||
qywxSyncSettingsSave,
|
||||
@@ -563,6 +632,7 @@ const syncing = ref(false)
|
||||
const showSyncSettings = ref(false)
|
||||
const showDetail = ref(false)
|
||||
const currentCustomer = ref<any>(null)
|
||||
const deletingCustomerId = ref<number | null>(null)
|
||||
|
||||
const stats = reactive({
|
||||
total: 0,
|
||||
@@ -591,17 +661,19 @@ const syncSettings = reactive({
|
||||
interval: 3600
|
||||
})
|
||||
|
||||
const queryParams = reactive<{
|
||||
name: string
|
||||
follow_user: string
|
||||
tag_ids: string[]
|
||||
const queryParams = reactive<{
|
||||
name: string
|
||||
follow_user: string
|
||||
add_way: number | ''
|
||||
tag_ids: string[]
|
||||
add_time_start: string
|
||||
add_time_end: string
|
||||
dedupe_mode: 'first' | 'any'
|
||||
}>({
|
||||
name: '',
|
||||
follow_user: '',
|
||||
tag_ids: [],
|
||||
name: '',
|
||||
follow_user: '',
|
||||
add_way: '',
|
||||
tag_ids: [],
|
||||
add_time_start: '',
|
||||
add_time_end: '',
|
||||
dedupe_mode: 'first'
|
||||
@@ -639,6 +711,22 @@ interface TagStatsPayload {
|
||||
groups: TagGroup[]
|
||||
}
|
||||
|
||||
interface AddChannel {
|
||||
state: string
|
||||
label: string
|
||||
source_type: 'promotion_pool' | 'state'
|
||||
pool_id: number
|
||||
user_id: string
|
||||
event_time: number
|
||||
}
|
||||
|
||||
interface AddSource extends AddChannel {
|
||||
key: string
|
||||
add_way: number | null
|
||||
channel_label: string
|
||||
staff_name: string
|
||||
}
|
||||
|
||||
const tagStats = reactive<TagStatsPayload>({
|
||||
total_tags: 0,
|
||||
total_relations: 0,
|
||||
@@ -847,10 +935,11 @@ const { pager, getLists, resetPage, resetParams } = usePaging({
|
||||
params: queryParams
|
||||
})
|
||||
|
||||
function handleReset() {
|
||||
queryParams.name = ''
|
||||
queryParams.follow_user = ''
|
||||
queryParams.tag_ids = []
|
||||
function handleReset() {
|
||||
queryParams.name = ''
|
||||
queryParams.follow_user = ''
|
||||
queryParams.add_way = ''
|
||||
queryParams.tag_ids = []
|
||||
queryParams.add_time_start = ''
|
||||
queryParams.add_time_end = ''
|
||||
queryParams.dedupe_mode = 'first'
|
||||
@@ -964,6 +1053,34 @@ function viewDetail(row: any) {
|
||||
showDetail.value = true
|
||||
}
|
||||
|
||||
async function handleDelete(row: Record<string, any>) {
|
||||
const id = Number(row.id)
|
||||
if (!Number.isInteger(id) || id <= 0 || deletingCustomerId.value !== null) return
|
||||
|
||||
const customerName = String(row.name || row.external_userid || '该客户')
|
||||
try {
|
||||
await feedback.confirm(
|
||||
`确定删除企业微信客户“${customerName}”吗?此操作仅删除系统内的同步记录,不会删除企业微信中的客户关系;后续重新同步时可能再次出现。`
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
deletingCustomerId.value = id
|
||||
try {
|
||||
await qywxCustomerDelete({ id })
|
||||
if (pager.page > 1 && pager.lists.length === 1) {
|
||||
pager.page -= 1
|
||||
}
|
||||
await Promise.all([getLists(), loadStats(), loadTagStats()])
|
||||
feedback.msgSuccess('删除成功')
|
||||
} catch (e: any) {
|
||||
feedback.msgError(e?.message || e?.msg || '删除失败')
|
||||
} finally {
|
||||
deletingCustomerId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
/** 列表接口会写入 admin_name(admin.work_wechat_userid = userid) */
|
||||
function formatFollowUser(user: Record<string, any>) {
|
||||
const adminName = String(user?.admin_name ?? '').trim()
|
||||
@@ -985,6 +1102,167 @@ function followStaffTooltip(user: Record<string, any>) {
|
||||
return parts.join('|')
|
||||
}
|
||||
|
||||
function customerAddChannels(row: Record<string, any> | null | undefined): AddChannel[] {
|
||||
if (!row) return []
|
||||
if (Array.isArray(row.add_channels)) {
|
||||
return row.add_channels
|
||||
.map((channel: Record<string, any>): AddChannel => ({
|
||||
state: String(channel?.state ?? '').trim(),
|
||||
label: String(channel?.label ?? channel?.state ?? '').trim(),
|
||||
source_type: channel?.source_type === 'promotion_pool' ? 'promotion_pool' : 'state',
|
||||
pool_id: Number(channel?.pool_id ?? 0),
|
||||
user_id: String(channel?.user_id ?? '').trim(),
|
||||
event_time: Number(channel?.event_time ?? 0)
|
||||
}))
|
||||
.filter((channel: AddChannel) => channel.state !== '')
|
||||
}
|
||||
|
||||
// 兼容仅返回原始渠道数组的旧接口/灰度节点。
|
||||
if (!Array.isArray(row.add_channel_states)) return []
|
||||
return row.add_channel_states
|
||||
.map((state: unknown) => String(state ?? '').trim())
|
||||
.filter((state: string) => state !== '')
|
||||
.map((state: string) => ({
|
||||
state,
|
||||
label: state,
|
||||
source_type: 'state' as const,
|
||||
pool_id: 0,
|
||||
user_id: '',
|
||||
event_time: 0
|
||||
}))
|
||||
}
|
||||
|
||||
const ADD_WAY_LABELS: Record<number, string> = {
|
||||
0: '未知添加方式',
|
||||
1: '通过扫描二维码添加',
|
||||
2: '通过搜索手机号添加',
|
||||
3: '通过名片分享添加',
|
||||
4: '通过群聊添加',
|
||||
5: '通过手机通讯录添加',
|
||||
6: '通过微信联系人添加',
|
||||
8: '安装第三方应用时自动添加',
|
||||
9: '通过搜索邮箱添加',
|
||||
10: '通过视频号添加',
|
||||
11: '通过日程参与人添加',
|
||||
12: '通过会议参与人添加',
|
||||
13: '通过微信好友添加',
|
||||
14: '通过智慧硬件专属客服添加',
|
||||
15: '通过上门服务客服添加',
|
||||
16: '通过获客链接添加',
|
||||
17: '通过定制开发添加',
|
||||
18: '通过需求回复添加',
|
||||
21: '通过第三方售前客服添加',
|
||||
22: '通过可能的商务伙伴添加',
|
||||
24: '通过接受微信好友申请添加',
|
||||
201: '通过内部成员共享添加',
|
||||
202: '通过管理员或负责人分配添加'
|
||||
}
|
||||
|
||||
const ADD_WAY_OPTIONS = Object.entries(ADD_WAY_LABELS).map(([value, label]) => ({
|
||||
value: Number(value),
|
||||
label
|
||||
}))
|
||||
|
||||
function normalizeAddWay(value: unknown): number | null {
|
||||
if (typeof value === 'number' && Number.isInteger(value) && value >= 0) return value
|
||||
if (typeof value !== 'string' || !/^\d+$/.test(value.trim())) return null
|
||||
return Number(value.trim())
|
||||
}
|
||||
|
||||
function addWayLabel(addWay: number) {
|
||||
return ADD_WAY_LABELS[addWay] || `其他添加方式(${addWay})`
|
||||
}
|
||||
|
||||
function customerAddSources(row: Record<string, any> | null | undefined): AddSource[] {
|
||||
if (!row) return []
|
||||
|
||||
const channels = customerAddChannels(row)
|
||||
const usedChannelIndexes = new Set<number>()
|
||||
const sources: AddSource[] = []
|
||||
const followUsers = Array.isArray(row.follow_users) ? row.follow_users : []
|
||||
|
||||
followUsers.forEach((user: Record<string, any>, index: number) => {
|
||||
const userId = String(user?.userid ?? user?.UserId ?? '').trim()
|
||||
const state = String(user?.state ?? user?.State ?? '').trim()
|
||||
const addWay = normalizeAddWay(user?.add_way ?? user?.AddWay)
|
||||
|
||||
let channelIndex = channels.findIndex(
|
||||
(channel, i) =>
|
||||
!usedChannelIndexes.has(i) &&
|
||||
userId !== '' &&
|
||||
state !== '' &&
|
||||
channel.user_id === userId &&
|
||||
channel.state === state
|
||||
)
|
||||
if (channelIndex < 0 && state !== '') {
|
||||
channelIndex = channels.findIndex(
|
||||
(channel, i) => !usedChannelIndexes.has(i) && channel.state === state
|
||||
)
|
||||
}
|
||||
if (channelIndex < 0 && userId !== '') {
|
||||
channelIndex = channels.findIndex(
|
||||
(channel, i) => !usedChannelIndexes.has(i) && channel.user_id === userId
|
||||
)
|
||||
}
|
||||
|
||||
const channel = channelIndex >= 0 ? channels[channelIndex] : undefined
|
||||
if (channelIndex >= 0) usedChannelIndexes.add(channelIndex)
|
||||
if (addWay === null && state === '' && !channel) return
|
||||
|
||||
const labelFromApi = String(user?.add_way_label ?? '').trim()
|
||||
const sourceType = channel?.source_type ?? (/^zyt_pool:[1-9]\d*$/.test(state) ? 'promotion_pool' : 'state')
|
||||
const label = labelFromApi || (addWay !== null
|
||||
? addWayLabel(addWay)
|
||||
: sourceType === 'promotion_pool'
|
||||
? '通过获客链接添加'
|
||||
: '通过其他渠道添加')
|
||||
|
||||
sources.push({
|
||||
key: `follow:${index}:${userId}:${addWay ?? 'unknown'}:${state}`,
|
||||
add_way: addWay,
|
||||
label,
|
||||
state: state || channel?.state || '',
|
||||
channel_label: channel?.label || '',
|
||||
source_type: sourceType,
|
||||
pool_id: channel?.pool_id || 0,
|
||||
user_id: userId || channel?.user_id || '',
|
||||
staff_name: formatFollowUser(user),
|
||||
event_time: channel?.event_time || Number(user?.createtime ?? 0)
|
||||
})
|
||||
})
|
||||
|
||||
// 兼容事件日志中仍有记录、但当前 follow_users 已不存在或旧接口未返回 add_way 的客户。
|
||||
channels.forEach((channel, index) => {
|
||||
if (usedChannelIndexes.has(index)) return
|
||||
sources.push({
|
||||
...channel,
|
||||
key: `channel:${index}:${channel.user_id}:${channel.state}`,
|
||||
add_way: channel.source_type === 'promotion_pool' ? 16 : null,
|
||||
label: channel.source_type === 'promotion_pool' ? '通过获客链接添加' : '通过其他渠道添加',
|
||||
channel_label: channel.label,
|
||||
staff_name: channel.user_id || '—'
|
||||
})
|
||||
})
|
||||
|
||||
return sources.sort((a, b) => b.event_time - a.event_time)
|
||||
}
|
||||
|
||||
function addSourceTooltip(source: AddSource) {
|
||||
const parts: string[] = []
|
||||
parts.push(`添加方式:${source.label}`)
|
||||
if (source.source_type === 'promotion_pool' && source.channel_label) {
|
||||
parts.push(`获客助手方案:${source.channel_label}`)
|
||||
}
|
||||
if (source.staff_name && source.staff_name !== '—') parts.push(`跟进人:${source.staff_name}`)
|
||||
if (source.event_time > 0) parts.push(`添加时间:${formatTime(source.event_time)}`)
|
||||
if (source.state) parts.push(`渠道参数:${source.state}`)
|
||||
return parts.join('|')
|
||||
}
|
||||
|
||||
function remainingAddSourcesTooltip(row: Record<string, any>) {
|
||||
return customerAddSources(row).slice(1).map(addSourceTooltip).join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加时间:优先接口字段 external_first_add_time(同步写入 + 列表对未回填行按 JSON 兜底);
|
||||
* 再解析 follow_users;最后退回 create_time
|
||||
|
||||
Reference in New Issue
Block a user