feat(calls): voice_clip 手动匹配(voice_clip↔customer 本地关联写操作)
- VoiceClip: 新增 matched_friend_id 列 (String(128), nullable) + alembic 迁移 b3c4d5e6f7a8
- PATCH /api/voice-clips/{id}/match: friend_id 关联 / null 取消 (本地写, 不回写云客)
- GET /api/voice-clips: 批量解析 matched_friend_name (remark>nickname>real>id)
- VoiceClipModal: 已匹配客户列 + 匹配/取消匹配按钮 + 嵌套客户选择 picker (默认按员工列客户, 名搜索走 /search)
- 测试: 17 passed (新增 7 个 match 用例覆盖 set/clear/name 解析/404)
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,16 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getVoiceClips } from '@/api'
|
||||
import type { VoiceClip } from '@/api/types'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { getCustomers, getVoiceClips, matchVoiceClip, search } from '@/api'
|
||||
import type { Customer, VoiceClip } from '@/api/types'
|
||||
import { formatCallDuration, formatTime } from '@/utils/format'
|
||||
|
||||
/**
|
||||
* 长语音记录 modal(只读)—— 复刻云客「微信长语音列表」modal 的展示部分。
|
||||
* 长语音记录 modal —— 复刻云客「微信长语音列表」modal 的展示部分 +
|
||||
* 本地手动匹配(voice_clip ↔ customer)。
|
||||
*
|
||||
* 调用方传 `userId`(从 CallList 行传入,精确限定员工)。
|
||||
*
|
||||
* 本期仅只读:列出片段 + 内联播放录音;不做匹配 / 转写 / 回写(spec §7)。
|
||||
* 播放直接用 `oss_file_name` 的 OSS 直链(已知全本地妥协,随 ASR 一起修)。
|
||||
* 匹配是本地写(PATCH /voice-clips/{id}/match):把 voice_clip.matched_friend_id
|
||||
* 关联到 customer.friend_id,不回写云客。matched_friend_name 由后端在 GET 时
|
||||
* 经 Customer join 解析(remark > nickname > real_name > friend_id)。
|
||||
*
|
||||
* 录音播放仍用 `oss_file_name` 的 OSS 直链(已知全本地妥协,随 ASR 一起修)。
|
||||
*/
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -44,12 +49,29 @@ const dateRange = ref<[Date, Date] | null>(null)
|
||||
const playingUrl = ref<string | null>(null)
|
||||
const playingLabel = ref('')
|
||||
|
||||
// ----- 手动匹配 picker 状态 -----
|
||||
// picker 对话框是否打开。
|
||||
const pickerOpen = ref(false)
|
||||
// 正在为其挑选客户的 voice_clip 行(picker 打开期间非空)。
|
||||
const pickerRow = ref<VoiceClip | null>(null)
|
||||
const pickerLoading = ref(false)
|
||||
const pickerQuery = ref('')
|
||||
// picker 当前展示的客户候选(默认 = 该员工最近活跃客户;输入查询后 = 全通道名搜索)。
|
||||
const pickerResults = ref<Customer[]>([])
|
||||
// PATCH 提交中(避免重复点击)。
|
||||
const matching = ref(false)
|
||||
|
||||
const dialogTitle = computed(() => {
|
||||
if (props.title) return props.title
|
||||
const who = props.staffName || props.userId
|
||||
return `长语音记录 · ${who}`
|
||||
})
|
||||
|
||||
const pickerTitle = computed(() => {
|
||||
const who = pickerRow.value?.user_real_name || pickerRow.value?.user_id || ''
|
||||
return who ? `选择匹配客户 · ${who}` : '选择匹配客户'
|
||||
})
|
||||
|
||||
async function load(): Promise<void> {
|
||||
if (!props.userId) {
|
||||
items.value = []
|
||||
@@ -126,6 +148,117 @@ function handlePageSizeChange(s: number): void {
|
||||
load()
|
||||
}
|
||||
|
||||
// ----- 手动匹配 picker 逻辑 -----
|
||||
|
||||
/** 客户显示名(与后端 matched_friend_name 解析口径一致:remark > nickname > real_name > friend_id)。 */
|
||||
function customerName(c: Customer): string {
|
||||
return c.friend_remark || c.friend_nickname || c.real_name || c.friend_id
|
||||
}
|
||||
|
||||
/** 打开 picker:先按员工+通道加载默认候选(最近活跃客户),清空查询框。 */
|
||||
async function openPicker(row: VoiceClip): Promise<void> {
|
||||
pickerRow.value = row
|
||||
pickerQuery.value = ''
|
||||
pickerOpen.value = true
|
||||
pickerResults.value = []
|
||||
await loadPickerDefaults()
|
||||
}
|
||||
|
||||
/** 默认候选:该员工、该通道下的客户(最近活跃在前)。失败不阻塞(用户可走名搜索)。 */
|
||||
async function loadPickerDefaults(): Promise<void> {
|
||||
if (!pickerRow.value) return
|
||||
pickerLoading.value = true
|
||||
try {
|
||||
const res = await getCustomers({
|
||||
user_id: pickerRow.value.user_id,
|
||||
channel: props.channel as 'wechat' | 'whatsapp',
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
})
|
||||
pickerResults.value = res.items
|
||||
} catch {
|
||||
// 默认列表加载失败时静默——用户仍可在搜索框输入查询走 /search 路径。
|
||||
pickerResults.value = []
|
||||
} finally {
|
||||
pickerLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 名字查询:走 /search(跨该通道全员工),覆盖默认按员工范围。 */
|
||||
async function runPickerSearch(): Promise<void> {
|
||||
const q = pickerQuery.value.trim()
|
||||
if (!q) {
|
||||
await loadPickerDefaults()
|
||||
return
|
||||
}
|
||||
pickerLoading.value = true
|
||||
try {
|
||||
const res = await search({
|
||||
q,
|
||||
type: 'customers',
|
||||
channel: props.channel as 'wechat' | 'whatsapp',
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
})
|
||||
pickerResults.value = res.customers?.items ?? []
|
||||
} catch (e) {
|
||||
ElMessage.error(`客户搜索失败:${(e as Error).message}`)
|
||||
pickerResults.value = []
|
||||
} finally {
|
||||
pickerLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onPickerQueryEnter(): void {
|
||||
runPickerSearch()
|
||||
}
|
||||
|
||||
function clearPickerQuery(): void {
|
||||
pickerQuery.value = ''
|
||||
loadPickerDefaults()
|
||||
}
|
||||
|
||||
/** 选定一个客户:PATCH 本地匹配 → 关闭 picker → 刷新主表(拿回 matched_friend_name)。 */
|
||||
async function confirmMatch(c: Customer): Promise<void> {
|
||||
if (!pickerRow.value) return
|
||||
const row = pickerRow.value
|
||||
matching.value = true
|
||||
try {
|
||||
await matchVoiceClip(row.voice_clip_id, c.friend_id)
|
||||
ElMessage.success(`已匹配:${customerName(c)}`)
|
||||
pickerOpen.value = false
|
||||
pickerRow.value = null
|
||||
await load()
|
||||
} catch (e) {
|
||||
ElMessage.error(`匹配失败:${(e as Error).message}`)
|
||||
} finally {
|
||||
matching.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 取消匹配:二次确认 → PATCH friend_id=null → 刷新主表。 */
|
||||
async function unmatch(row: VoiceClip): Promise<void> {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定取消该片段与「${row.matched_friend_name ?? row.matched_friend_id}」的匹配?`,
|
||||
'取消匹配',
|
||||
{ type: 'warning', confirmButtonText: '取消匹配', cancelButtonText: '保留' },
|
||||
)
|
||||
} catch {
|
||||
return // 用户放弃
|
||||
}
|
||||
matching.value = true
|
||||
try {
|
||||
await matchVoiceClip(row.voice_clip_id, null)
|
||||
ElMessage.success('已取消匹配')
|
||||
await load()
|
||||
} catch (e) {
|
||||
ElMessage.error(`取消匹配失败:${(e as Error).message}`)
|
||||
} finally {
|
||||
matching.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(open) => {
|
||||
@@ -208,6 +341,14 @@ watch(
|
||||
<el-table-column label="录音时长" width="100" align="right">
|
||||
<template #default="{ row }">{{ durationDisplay(row) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="已匹配客户" min-width="120">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.matched_friend_id" :title="row.matched_friend_id">
|
||||
{{ row.matched_friend_name ?? row.matched_friend_id }}
|
||||
</span>
|
||||
<span v-else style="color: #c0c4cc">--</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="文件地址" min-width="160" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.oss_file_name" class="yk-voice-clip__url" :title="row.oss_file_name">
|
||||
@@ -216,8 +357,19 @@ watch(
|
||||
<span v-else style="color: #c0c4cc">--</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="80" align="center" fixed="right">
|
||||
<el-table-column label="操作" width="170" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small" @click="openPicker(row)">匹配</el-button>
|
||||
<el-button
|
||||
v-if="row.matched_friend_id"
|
||||
link
|
||||
type="warning"
|
||||
size="small"
|
||||
:disabled="matching"
|
||||
@click="unmatch(row)"
|
||||
>
|
||||
取消匹配
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="row.oss_file_name"
|
||||
link
|
||||
@@ -227,7 +379,6 @@ watch(
|
||||
>
|
||||
播放
|
||||
</el-button>
|
||||
<span v-else style="color: #c0c4cc">--</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -246,6 +397,64 @@ watch(
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 客户选择 picker(嵌套 dialog) -->
|
||||
<el-dialog
|
||||
v-model="pickerOpen"
|
||||
:title="pickerTitle"
|
||||
width="640px"
|
||||
append-to-body
|
||||
align-center
|
||||
@closed="pickerRow = null"
|
||||
>
|
||||
<div class="yk-voice-clip__picker-search">
|
||||
<el-input
|
||||
v-model="pickerQuery"
|
||||
placeholder="按姓名/备注搜索客户;留空显示该员工最近客户"
|
||||
size="small"
|
||||
clearable
|
||||
@keyup.enter="onPickerQueryEnter"
|
||||
@clear="clearPickerQuery"
|
||||
/>
|
||||
<el-button type="primary" size="small" @click="runPickerSearch">搜索</el-button>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
v-loading="pickerLoading"
|
||||
:data="pickerResults"
|
||||
border
|
||||
size="small"
|
||||
style="width: 100%"
|
||||
max-height="360"
|
||||
empty-text="无候选客户,请尝试搜索"
|
||||
>
|
||||
<el-table-column label="名称" min-width="160">
|
||||
<template #default="{ row: c }">{{ customerName(c) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="备注" min-width="120">
|
||||
<template #default="{ row: c }">{{ c.friend_remark || '--' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="昵称" min-width="120">
|
||||
<template #default="{ row: c }">{{ c.friend_nickname || '--' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="所属员工" min-width="110">
|
||||
<template #default="{ row: c }">{{ c.sale_name || c.owner_account_id || '--' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="90" align="center" fixed="right">
|
||||
<template #default="{ row: c }">
|
||||
<el-button
|
||||
type="primary"
|
||||
link
|
||||
size="small"
|
||||
:disabled="matching"
|
||||
@click="confirmMatch(c)"
|
||||
>
|
||||
选择
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-dialog>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="emit('update:modelValue', false)">关闭</el-button>
|
||||
</template>
|
||||
@@ -299,4 +508,10 @@ watch(
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.yk-voice-clip__picker-search {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user