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:
浅念
2026-08-11 10:43:34 +08:00
parent f1b5861b35
commit 9da272b992
9 changed files with 533 additions and 21 deletions
+223 -8
View File
@@ -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>