Files
yunke/yunkes/src/views/CallList.vue
T
浅念 ab5a918a3b fix(calls+scraper): 语音群scope服务端过滤+对话发现缺口补全+抓取韧性三连修
- /api/calls 加 scope=friend|group(@chatroom后缀判群,与isRoom实测等价120=120),CallList去掉「全部」tab改服务端查
- 对话发现:全部customer pair ∪ 语音pair(weChatId,talker),未扫过的排最前——修复纯语音关系客户聊天记录永远空(35%语音客户pair从未扫过)
- voice-calls改浏览器直发(isRoom=0/1双趟+WA callSource=4,拦截方式已两次失效)
- 断连恢复重试30×2min(云客约3h一次断连,已实战扛过多次)
- WA messages upsert改整列表直调(_upsert_all逐条传dict被迭代成key字符串→TypeError)
- 405测试绿含scope新增3个

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-19 16:48:22 +08:00

635 lines
22 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.
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { Download } from '@element-plus/icons-vue'
import { getCalls } from '@/api'
import type { CallChannel, PhoneCall } from '@/api/types'
import {
callDirectionLabel,
callStatusLabel,
callTypeLabel,
formatCallDuration,
formatTime,
} from '@/utils/format'
import { buildCallSections, readField } from '@/utils/callFields'
import { exportListToCsv } from '@/utils/csvExport'
import VoiceClipModal from '@/components/VoiceClipModal.vue'
const route = useRoute()
const router = useRouter()
// Calls aren't tied to a messaging channel — `channel` on a call is the call
// type (phone_call / voice_call / wa_voice_call). We keep a LOCAL filter so it
// doesn't fight the shared top-bar wechat/whatsapp store used by customers/groups.
const callType = ref<CallChannel>(
route.query.channel === 'whatsapp' ? 'wa_voice_call' : 'voice_call',
)
// 微信语音 tab scope filter: 对齐云客 微信好友/微信群 二分(isRoom 0=好友, 1=群)。
// 仅在 voice_call tab 生效;client-side 过滤当前页。
const voiceScope = ref<'friend' | 'group'>('friend')
const calls = ref<PhoneCall[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(10)
const loading = ref(false)
const errorMsg = ref<string | null>(null)
// Recording playback dialog state.
const recordDialogVisible = ref(false)
const recordSrc = ref<string | null>(null)
const recordTitle = ref('')
// VoiceClipModal state — opened from 微信/WA 语音 tabs by row.user_id.
const voiceClipModalOpen = ref(false)
const voiceClipUserId = ref('')
const voiceClipChannel = ref<'wechat' | 'whatsapp'>('wechat')
const voiceClipStaffName = ref<string | null>(null)
// All-fields drawer state.
const drawerOpen = ref(false)
const drawerCall = ref<PhoneCall | null>(null)
const tabs: { value: CallChannel; label: string }[] = [
{ value: 'voice_call', label: '微信语音' },
{ value: 'wa_voice_call', label: 'WA 语音' },
{ value: 'phone_call', label: '电话' },
]
async function load(): Promise<void> {
loading.value = true
errorMsg.value = null
try {
const res = await getCalls({
channel: callType.value,
// 微信语音 好友/群 scope 为服务端过滤(@chatroom 后缀判群,与云客 isRoom 等价)
scope: callType.value === 'voice_call' ? voiceScope.value : undefined,
page: page.value,
page_size: pageSize.value,
})
calls.value = res.items
total.value = res.total
} catch (e) {
errorMsg.value = (e as Error).message
ElMessage.error(`通话记录加载失败:${(e as Error).message}`)
} finally {
loading.value = false
}
}
function handlePageChange(p: number): void {
page.value = p
load()
}
function handlePageSizeChange(s: number): void {
pageSize.value = s
page.value = 1
load()
}
// 侧栏菜单在同一页面内切换 ?channel= 时组件复用(不重挂载)→ watch 同步通话类型并重载。
watch(
() => route.query.channel,
(q) => {
const next: CallChannel = q === 'whatsapp' ? 'wa_voice_call' : 'voice_call'
if (next !== callType.value) {
callType.value = next
page.value = 1
// 复位 scope,避免把 微信群/好友 筛选带到 WA语音。
voiceScope.value = 'friend'
load()
}
},
)
// ---- header 导出:当前 tab 通话类型全量记录 CSV ----
const exporting = ref(false)
async function exportCalls(): Promise<void> {
if (total.value === 0) {
ElMessage.warning('当前列表为空,无数据可导出')
return
}
exporting.value = true
try {
const channel = callType.value
const result = await exportListToCsv({
fetchPage: (page, pageSize) => getCalls({ channel, page, page_size: pageSize }),
columns: [
{ key: 'user_id_name', label: '员工', format: (r) => r.user_id_name ?? r.user_id ?? '' },
{ key: 'call_number', label: '客户', format: (r) => r.call_number ?? '' },
{ key: 'call_status', label: '状态', format: (r) => callStatusLabel(r.call_status, r.channel) },
{ key: 'incoming_call', label: '方向', format: (r) => callDirectionLabel(r.incoming_call) },
{ key: 'call_duration', label: '通话时长', format: (r) => formatCallDuration(r.call_duration) },
{ key: 'start_call_time', label: '通话时间', format: (r) => (r.start_call_time ? formatTime(r.start_call_time) : '') },
{ key: 'record', label: '有无录音', format: (r) => (r.record_media_hash || r.record_file ? '有' : '无') },
],
filename: `${tabs.find((t) => t.value === callType.value)?.label ?? '通话'}记录`,
})
ElMessage.success(`已导出 ${result.rows} 条`)
} catch (e) {
ElMessage.error(`导出失败:${(e as Error).message}`)
} finally {
exporting.value = false
}
}
/**
* 好友/群 scope 切换 → 服务端重查(与云客一致按 isRoom 分舱,分页/总数都对)。
*/
watch(voiceScope, () => {
page.value = 1
load()
})
/**
* Read a raw_json camelCase key off a call, preferring the structured
* snake_case column when it is populated. The first argument is the row so the
* table can use the same helper as the drawer.
*/
function cell(row: PhoneCall, camelKey: string): unknown {
return readField(row, camelKey)
}
function timeCell(row: PhoneCall, camelKey: string): number | undefined {
const v = cell(row, camelKey)
return typeof v === 'number' ? v : undefined
}
function playRecord(row: PhoneCall): void {
const local = row.record_media_hash ? `/api/media/${row.record_media_hash}` : null
const file =
local ??
(typeof cell(row, 'recordFile') === 'string' ? (cell(row, 'recordFile') as string) : null) ??
row.record_file
if (!file) {
ElMessage.info('该通话无录音文件')
return
}
recordSrc.value = file
const who = row.user_id_name || row.user_id || row.call_id
const when = formatTime(row.start_call_time)
recordTitle.value = `录音 · ${who}${when !== '--' ? ` · ${when}` : ''}`
recordDialogVisible.value = true
}
/** 打开长语音 modal:按当前通话员工 user_id 列出其长语音片段(只读)。 */
function openVoiceClip(row: PhoneCall): void {
const userId = row.user_id || (cell(row, 'userId') as string | undefined)
if (!userId) {
ElMessage.info('该通话无员工 userId,无法查看长语音')
return
}
voiceClipUserId.value = userId
voiceClipStaffName.value = row.user_id_name ?? null
voiceClipChannel.value = callType.value === 'wa_voice_call' ? 'whatsapp' : 'wechat'
voiceClipModalOpen.value = true
}
function openDrawer(row: PhoneCall): void {
drawerCall.value = row
drawerOpen.value = true
}
/**
* 员工姓名 click → standalone 员工详情 page (mirrors 云客 userDetailNew).
* Distinct from row-click which still opens the all-fields call drawer.
* Prefer the structured `user_id`; fall back to the raw_json `userId` cell so
* calls whose structured user_id is null still resolve.
*/
function goToStaffDetail(row: PhoneCall): void {
const id = row.user_id ?? (cell(row, 'userId') as string | undefined)
if (!id) {
ElMessage.info('该通话无员工 userId,无法查看员工详情')
return
}
router.push({ name: 'staff-detail', params: { userId: String(id) } })
}
/** 把通话类型(voice_call/wa_voice_call)映射到聊天渠道(wechat/whatsapp)。电话返回 null。 */
function chatChannel(ch: CallChannel): 'wechat' | 'whatsapp' | null {
return ch === 'voice_call' ? 'wechat' : ch === 'wa_voice_call' ? 'whatsapp' : null
}
/** 客户昵称 → 该员工×客户 1:1 聊天(复用 Phase B 导航)。 */
function viewCallCustomer(row: PhoneCall): void {
const owner = cell(row, 'weChatId')
const talker = cell(row, 'talker')
if (!owner || !talker) return
router.push({
name: 'chat',
params: {
channel: chatChannel(row.channel) ?? 'wechat',
ownerId: String(owner),
talkerId: String(talker),
},
})
}
/** 员工微信昵称 → 账号级聊天窗(复用 step 5 导航)。 */
function viewCallStaff(row: PhoneCall): void {
const owner = cell(row, 'weChatId')
if (!owner) return
router.push({
name: 'chat',
params: { channel: chatChannel(row.channel) ?? 'wechat', ownerId: String(owner) },
})
}
const drawerSections = computed(() =>
drawerCall.value ? buildCallSections(drawerCall.value) : [],
)
onMounted(load)
</script>
<template>
<div class="yk-calls">
<div class="yk-calls__header">
<div>
<h2 style="margin: 0 0 4px; font-size: 16px">{{ tabs.find(t => t.value === callType)?.label }}通话记录</h2>
<span class="yk-calls__sub">
共 {{ total }} 条 · 类型:{{ tabs.find(t => t.value === callType)?.label }}
</span>
</div>
<div class="yk-calls__actions">
<el-button size="small" :loading="exporting" @click="exportCalls">
<el-icon><Download /></el-icon> 导出
</el-button>
</div>
</div>
<el-alert
v-if="errorMsg"
:title="errorMsg"
type="error"
show-icon
:closable="false"
style="margin-bottom: 12px"
/>
<el-radio-group
v-if="callType === 'voice_call'"
v-model="voiceScope"
size="small"
style="margin-bottom: 8px"
>
<el-radio-button value="friend">微信好友</el-radio-button>
<el-radio-button value="group">微信群</el-radio-button>
</el-radio-group>
<el-table
v-loading="loading"
:data="calls"
stripe
border
row-key="call_id"
style="width: 100%"
:row-class-name="() => 'yk-clickable-row'"
empty-text="暂无通话记录"
@row-click="openDrawer"
>
<template v-if="callType === 'voice_call'">
<el-table-column label="通话信息" min-width="150">
<template #default="{ row }">
<div>通话状态:{{ callStatusLabel(row.call_status, row.channel) }}</div>
<div class="yk-cell__sub">类型:{{ callTypeLabel(row.channel) }}</div>
<div class="yk-cell__sub">来源:{{ cell(row, 'from') || '--' }}</div>
</template>
</el-table-column>
<!-- 客户信息(好友 scope) / 群信息(群 scope) — 云客微信好友/微信群两 tab 各自的列 -->
<el-table-column :label="voiceScope === 'group' ? '群信息' : '客户信息'" min-width="150">
<template #default="{ row }">
<template v-if="voiceScope === 'group'">
<div>{{ cell(row, 'roomName') || cell(row, 'talker') || '--' }}</div>
<div class="yk-cell__sub">{{ cell(row, 'roomId') || '--' }}</div>
</template>
<template v-else>
<el-button v-if="cell(row, 'talkerNickName')" link type="primary" size="small" @click.stop="viewCallCustomer(row)">{{ cell(row, 'talkerNickName') }}</el-button>
<span v-else>--</span>
<div class="yk-cell__sub">{{ row.call_number || cell(row, 'talker') || '--' }}</div>
</template>
</template>
</el-table-column>
<el-table-column label="员工微信" min-width="150">
<template #default="{ row }">
<el-button v-if="cell(row, 'userWeChatNickName')" link type="primary" size="small" @click.stop="viewCallStaff(row)">{{ cell(row, 'userWeChatNickName') }}</el-button>
<span v-else>--</span>
<div class="yk-cell__sub">{{ cell(row, 'userWeChatAlias') || cell(row, 'weChatId') || '--' }}</div>
</template>
</el-table-column>
<el-table-column label="员工信息" min-width="130">
<template #default="{ row }">
<el-button v-if="row.user_id_name" link type="primary" size="small" @click.stop="goToStaffDetail(row)">{{ row.user_id_name }}</el-button>
<span v-else>--</span>
<div class="yk-cell__sub">{{ cell(row, 'userPhone') || '--' }}</div>
</template>
</el-table-column>
<el-table-column label="通话录音" width="140" align="center">
<template #default="{ row }">
<el-button v-if="row.record_media_hash || row.record_file || cell(row, 'recordFile')" size="small" type="primary" link @click.stop="playRecord(row)">播放</el-button>
<span v-else style="color:#c0c4cc">--</span>
<div style="margin-top:2px">
<el-button size="small" type="primary" link @click.stop="openVoiceClip(row)">查看长语音</el-button>
</div>
</template>
</el-table-column>
<el-table-column label="通话时长" width="100" align="right">
<template #default="{ row }">{{ formatCallDuration(row.call_duration) }}</template>
</el-table-column>
</template>
<template v-if="callType === 'wa_voice_call'">
<el-table-column label="状态" width="160">
<template #default="{ row }">
<div>{{ callStatusLabel(row.call_status, row.channel) }}</div>
<div class="yk-cell__sub">时长 {{ formatCallDuration(row.call_duration) }}</div>
</template>
</el-table-column>
<el-table-column label="客户信息" min-width="160">
<template #default="{ row }">
<el-button v-if="cell(row, 'talkerNickName')" link type="primary" size="small" @click.stop="viewCallCustomer(row)">{{ cell(row, 'talkerNickName') }}</el-button>
<span v-else>--</span>
<div class="yk-cell__sub">{{ cell(row, 'talkerAlias') || row.call_number || '--' }}</div>
</template>
</el-table-column>
<el-table-column label="员工信息" min-width="160">
<template #default="{ row }">
<div>
<el-button v-if="row.user_id_name" link type="primary" size="small" @click.stop="goToStaffDetail(row)">{{ row.user_id_name }}</el-button>
<span v-else>--</span>
<span> · {{ cell(row, 'userPhone') || '--' }}</span>
</div>
<div class="yk-cell__sub">{{ cell(row, 'userWeChatNickName') || '--' }} · {{ cell(row, 'userWeChatAlias') || '--' }}</div>
</template>
</el-table-column>
<el-table-column label="通话时间和录音" min-width="180">
<template #default="{ row }">
<div>{{ formatTime(row.start_call_time) }}</div>
<el-button v-if="row.record_media_hash || row.record_file || cell(row, 'ossFileName')" size="small" type="primary" link @click.stop="playRecord(row)" style="margin-top:2px">播放录音</el-button>
<span v-else class="yk-cell__sub">无录音</span>
<div style="margin-top:2px">
<el-button size="small" type="primary" link @click.stop="openVoiceClip(row)">查看长语音</el-button>
</div>
</template>
</el-table-column>
<el-table-column label="操作" width="150" fixed="right" align="center">
<template #default="{ row }">
<el-button v-if="row.record_media_hash || row.record_file || cell(row, 'ossFileName')" link type="primary" size="small" @click.stop="playRecord(row)">播放录音</el-button>
<el-button link type="primary" size="small" @click.stop="openVoiceClip(row)">查看长语音</el-button>
</template>
</el-table-column>
</template>
<template v-if="callType === 'phone_call'">
<el-table-column label="时间" width="160">
<template #default="{ row }">
{{ formatTime(row.start_call_time ?? timeCell(row, 'startCallTime')) }}
</template>
</el-table-column>
<el-table-column label="员工" min-width="130">
<template #default="{ row }">
{{ row.user_id_name || row.user_id || '--' }}
</template>
</el-table-column>
<el-table-column label="号码" min-width="130">
<template #default="{ row }">
{{ row.call_number || row.sim_phone || '--' }}
</template>
</el-table-column>
<el-table-column label="方向" width="80">
<template #default="{ row }">{{ callDirectionLabel(row.incoming_call) }}</template>
</el-table-column>
<el-table-column label="类型" width="100">
<template #default="{ row }">{{ callTypeLabel(row.channel) }}</template>
</el-table-column>
<el-table-column label="时长" width="90" align="right">
<template #default="{ row }">{{ formatCallDuration(row.call_duration) }}</template>
</el-table-column>
<el-table-column label="状态" width="90">
<template #default="{ row }">{{ callStatusLabel(row.call_status, row.channel) }}</template>
</el-table-column>
<el-table-column label="录音" width="100" align="center">
<template #default="{ row }">
<el-button
v-if="row.record_media_hash || row.record_file || cell(row, 'recordFile')"
size="small"
type="primary"
link
@click.stop="playRecord(row)"
>
播放录音
</el-button>
<span v-else style="color: #c0c4cc">--</span>
</template>
</el-table-column>
</template>
</el-table>
<div class="yk-calls__pager">
<el-pagination
:current-page="page"
:page-size="pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
background
@current-change="handlePageChange"
@size-change="handlePageSizeChange"
/>
</div>
<p class="yk-calls__hint">点击任意一行查看通话全部字段。</p>
<!-- Recording playback dialog -->
<el-dialog
v-model="recordDialogVisible"
:title="recordTitle"
width="480px"
align-center
@closed="recordSrc = null"
>
<audio
v-if="recordSrc"
controls
:src="recordSrc"
style="width: 100%"
preload="metadata"
>
您的浏览器不支持音频播放。
</audio>
<div v-if="recordSrc" class="yk-calls__record-link">
<a :href="recordSrc" target="_blank" rel="noopener">在新窗口打开录音文件</a>
</div>
</el-dialog>
<!-- Voice clip list modal (read-only, opened by 查看长语音) -->
<VoiceClipModal
v-model="voiceClipModalOpen"
:user-id="voiceClipUserId"
:channel="voiceClipChannel"
:staff-name="voiceClipStaffName"
/>
<!-- All-fields drawer -->
<el-drawer
v-model="drawerOpen"
:title="drawerCall ? `通话详情 · ${drawerCall.user_id_name ?? drawerCall.user_id ?? drawerCall.call_id}` : '通话详情'"
direction="rtl"
size="560px"
>
<template v-if="drawerCall">
<div class="yk-drawer__avatar">
<div class="yk-drawer__avatar-meta">
<div class="yk-drawer__avatar-name">
{{ drawerCall.user_id_name || drawerCall.user_id || drawerCall.call_id }}
</div>
<div class="yk-drawer__avatar-sub">
{{ callTypeLabel(drawerCall.channel) }}
<template v-if="drawerCall.call_number">
· {{ drawerCall.call_number }}
</template>
</div>
</div>
</div>
<div v-for="section in drawerSections" :key="section.title" class="yk-drawer__section">
<el-divider content-position="left">{{ section.title }}</el-divider>
<dl class="yk-drawer__kv">
<div v-for="f in section.fields" :key="f.key" class="yk-drawer__kv-item">
<dt>{{ f.label }}</dt>
<dd v-if="f.render === 'audio'" class="yk-drawer__audio">
<audio
:src="String(f.value)"
controls
preload="metadata"
style="width: 100%; max-width: 320px"
>
您的浏览器不支持音频播放。
</audio>
<a :href="String(f.value)" target="_blank" rel="noopener" class="yk-drawer__audio-link">
打开录音
</a>
</dd>
<dd v-else-if="f.render === 'empty'" class="yk-drawer__empty">—</dd>
<dd v-else>{{ f.text }}</dd>
</div>
</dl>
</div>
</template>
</el-drawer>
</div>
</template>
<style scoped>
.yk-calls__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.yk-calls__sub {
color: #909399;
font-size: 13px;
}
.yk-calls__actions {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.yk-cell__sub {
color: #909399;
font-size: 12px;
}
.yk-calls__pager {
margin-top: 12px;
display: flex;
justify-content: flex-end;
}
.yk-calls__record-link {
margin-top: 8px;
font-size: 12px;
text-align: center;
}
.yk-calls__hint {
margin: 8px 2px 0;
font-size: 12px;
color: #c0c4cc;
}
.yk-drawer__avatar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 4px;
}
.yk-drawer__avatar-meta {
min-width: 0;
}
.yk-drawer__avatar-name {
font-size: 16px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.yk-drawer__avatar-sub {
font-size: 12px;
color: #909399;
margin-top: 2px;
}
.yk-drawer__kv {
margin: 0 0 8px;
}
.yk-drawer__kv-item {
display: grid;
grid-template-columns: 110px 1fr;
gap: 8px 12px;
padding: 3px 0;
}
.yk-drawer__kv dt {
color: #909399;
font-size: 13px;
word-break: break-all;
}
.yk-drawer__kv dd {
margin: 0;
font-size: 13px;
color: #303133;
word-break: break-all;
min-width: 0;
}
.yk-drawer__audio {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
.yk-drawer__audio-link {
font-size: 12px;
color: var(--el-color-primary, #409eff);
}
.yk-drawer__empty {
color: #c0c4cc;
}
</style>