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
+13
View File
@@ -42,6 +42,19 @@ export function getJson<T>(url: string, params?: Record<string, unknown>): Promi
return http.get(url, { params }) as unknown as Promise<T>
}
/**
* Typed JSON request helper for non-GET methods (PATCH/POST/PUT/DELETE). Same
* response-unwrapping contract as `getJson`. Used by the LOCAL write endpoints
* (e.g. voice_clip 手动匹配).
*/
export function requestJson<T>(
method: 'patch' | 'post' | 'put' | 'delete',
url: string,
data?: unknown,
): Promise<T> {
return http.request({ method, url, data }) as unknown as Promise<T>
}
/** 以 blob 下载(HTML 导出等附件)。从 Content-Disposition 取文件名(绕开 http 的 (r)=>r.data 解包拦截器)。 */
export async function downloadBlob(
url: string,
+22 -1
View File
@@ -3,7 +3,7 @@
// Mirrors the FastAPI routes in yunke-server/app/api/routers/. Each wrapper
// drops `undefined`/empty params so the query string only carries set filters
// (the backend treats missing as "no filter").
import { getJson } from './client'
import { getJson, requestJson } from './client'
import type {
Account,
CallChannel,
@@ -228,6 +228,27 @@ export function getVoiceClips(query: VoiceClipQuery = {}): Promise<Page<VoiceCli
)
}
/** PATCH /voice-clips/{id}/match 响应(镜像 backend payload)。 */
export interface VoiceClipMatchResult {
voice_clip_id: number
matched_friend_id: string | null
}
/**
* 手动匹配 voice_clip ↔ customer (LOCAL 写)。`friendId=null` 取消匹配。
* 该写操作只落本地镜像库,不回写云客。
*/
export function matchVoiceClip(
voiceClipId: number,
friendId: string | null,
): Promise<VoiceClipMatchResult> {
return requestJson<VoiceClipMatchResult>(
'patch',
`/voice-clips/${voiceClipId}/match`,
{ friend_id: friendId },
)
}
// ---------------------------------------------------------------------------
// SMS
// ---------------------------------------------------------------------------
+12
View File
@@ -175,6 +175,18 @@ export interface VoiceClip {
duration_format?: string | null
/** `wechat` / `whatsapp` — local tag set at scrape time (not sent upstream). */
channel: string
/**
* LOCAL-only 手动匹配: the customer.friend_id this clip has been associated
* with, or null when unmatched. Set via PATCH /voice-clips/{id}/match; never
* pushed back to 云客. (spec §7)
*/
matched_friend_id?: string | null
/**
* Display name for `matched_friend_id`, resolved by the backend via Customer
* (remark > nickname > real_name > friend_id). null when unmatched. Not a
* stored column — computed by the GET handler.
*/
matched_friend_name?: string | null
/** Verbatim upstream call/list record (camelCase keys). */
raw_json?: Record<string, unknown> | null
}