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
+91 -8
View File
@@ -1,9 +1,15 @@
"""GET /api/voice-clips — 长语音 (voice_clip) list.
"""GET /api/voice-clips + PATCH /api/voice-clips/{id}/match — 长语音 (voice_clip).
Read-only query over the mirrored ``voice_clip`` table. Powers the
``VoiceClipModal`` (微信长语音 / WA wavoicecalllist 共用的只读展示 modal).
``GET /voice-clips`` is a read-only query over the mirrored ``voice_clip``
table. Powers the ``VoiceClipModal`` (微信长语音 / WA wavoicecalllist 共用的
只读展示 modal).
Filters (all optional):
``PATCH /voice-clips/{id}/match`` is the 手动匹配 local write — associates a
clip with a customer (好友) by storing ``matched_friend_id``. Passing
``{"friend_id": null}`` clears the association. The match lives only in our
mirror; nothing is pushed back to 云客.
GET filters (all optional):
* ``user_id`` — exact employee-id match (modal opens from a CallList
row keyed on the employee). Takes PRECEDENCE over ``user_real_name`` when
@@ -21,15 +27,16 @@ the 云客 modal's default ordering.
from __future__ import annotations
from typing import Annotated
from typing import Annotated, Any
import sqlalchemy as sa
from fastapi import APIRouter, Depends, Query
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_session
from app.api.schemas import Page, VoiceClipOut
from app.db.models import VoiceClip
from app.db.models import Customer, VoiceClip
router = APIRouter(prefix="/api", tags=["voice_clips"])
@@ -38,6 +45,42 @@ PageParam = Annotated[int, Query(ge=1)]
PageSizeParam = Annotated[int, Query(ge=1, le=100)]
class MatchBody(BaseModel):
"""Body for PATCH /voice-clips/{id}/match.
``friend_id`` is the customer.friend_id to associate, or ``None`` to clear
the existing match. A non-null id that doesn't reference an existing
customer row is accepted (no FK is enforced across the mirror tables —
``matched_friend_name`` will simply resolve to the id itself, same fallback
as a customer whose name columns are all NULL).
"""
friend_id: str | None = None
async def _resolve_friend_names(
session: AsyncSession, friend_ids: list[str]
) -> dict[str, str]:
"""Batch-resolve display names for a set of customer.friend_ids.
Returns ``{friend_id: name}`` keyed by id. Name precedence matches the rest
of the app (see staff_chat_stats): ``remark > nickname > real_name > friend_id``.
Empty input -> empty dict (callers default to ``None``).
"""
if not friend_ids:
return {}
stmt = sa.select(
Customer.friend_id,
Customer.friend_remark,
Customer.friend_nickname,
Customer.real_name,
).where(Customer.friend_id.in_(friend_ids))
names: dict[str, str] = {}
for fid, remark, nick, real in (await session.execute(stmt)).all():
names[fid] = remark or nick or real or fid
return names
@router.get("/voice-clips")
async def list_voice_clips(
session: SessionDep,
@@ -85,5 +128,45 @@ async def list_voice_clips(
.offset((page - 1) * page_size)
)
rows = (await session.execute(list_stmt)).scalars().all()
items = [VoiceClipOut.model_validate(row) for row in rows]
# Resolve the matched-customer display names for this page in a single
# batched query (one extra round-trip per page, not per row).
matched_ids = [r.matched_friend_id for r in rows if r.matched_friend_id]
name_map = await _resolve_friend_names(session, matched_ids)
items = []
for row in rows:
out = VoiceClipOut.model_validate(row)
if row.matched_friend_id:
# name_map always has an entry when matched_friend_id was non-null,
# but guard anyway: fall back to the id itself when the customer
# row was deleted after the match was recorded.
out.matched_friend_name = name_map.get(
row.matched_friend_id, row.matched_friend_id
)
items.append(out)
return Page(items=items, total=total, page=page, page_size=page_size)
@router.patch("/voice-clips/{voice_clip_id}/match")
async def match_voice_clip(
session: SessionDep,
voice_clip_id: int,
body: MatchBody,
) -> dict[str, Any]:
"""Associate (or clear) the customer match on a voice_clip.
LOCAL write only — ``matched_friend_id`` is stored in the mirror's
``voice_clip`` row and never pushed to 云客. ``friend_id=None`` clears an
existing match.
"""
clip = await session.get(VoiceClip, voice_clip_id)
if not clip:
raise HTTPException(status_code=404, detail="voice_clip not found")
clip.matched_friend_id = body.friend_id # None clears the match
await session.commit()
return {
"voice_clip_id": voice_clip_id,
"matched_friend_id": body.friend_id,
}
+7
View File
@@ -277,6 +277,13 @@ class VoiceClipOut(BaseModel):
oss_file_name: str | None = None
mp3_duration: int | None = None
duration_format: str | None = None
# LOCAL-only 手动匹配 (spec §7): the customer.friend_id this clip has been
# associated with, or None when unmatched. ``matched_friend_name`` is NOT a
# stored column — it's resolved by the GET handler via a Customer lookup
# (remark > nickname > real_name > friend_id) so the modal can render the
# 「已匹配客户」 column without a second round-trip per row.
matched_friend_id: str | None = None
matched_friend_name: str | None = None
raw_json: dict[str, Any] | None = None
+6
View File
@@ -516,6 +516,12 @@ class VoiceClip(Base):
duration_format: Mapped[str | None] = mapped_column(String(32), nullable=True)
channel: Mapped[str] = mapped_column(String(16), nullable=False)
# LOCAL-only manual match: references customer.friend_id when an operator
# has associated this clip with a specific customer (好友). NULL = unmatched.
# The mirror never pushes this back to 云客 — it's our own bookkeeping for
# the 手动匹配 UI (spec §7). Read paths resolve the customer name via join.
matched_friend_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
raw_json: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
fetched_at: Mapped[dt.datetime] = _fetched_at()
updated_at: Mapped[dt.datetime] = _updated_at()
@@ -0,0 +1,35 @@
"""voice_clip matched_friend_id
Revision ID: b3c4d5e6f7a8
Revises: a1b2c3d4e5f6
Create Date: 2026-08-10 00:00:00.000000
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = 'b3c4d5e6f7a8'
down_revision: str | Sequence[str] | None = 'a1b2c3d4e5f6'
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""Upgrade schema."""
# LOCAL-only 手动匹配 column: references customer.friend_id. NULL = unmatched.
# batch_alter_table keeps this portable across SQLite (dev/test, table-recreate)
# and MySQL (prod, direct ALTER). See env.py render_as_batch wiring.
with op.batch_alter_table('voice_clip', schema=None) as batch_op:
batch_op.add_column(
sa.Column('matched_friend_id', sa.String(length=128), nullable=True)
)
def downgrade() -> None:
"""Downgrade schema."""
with op.batch_alter_table('voice_clip', schema=None) as batch_op:
batch_op.drop_column('matched_friend_id')
+124 -4
View File
@@ -1,15 +1,16 @@
"""Tests for GET /api/voice-clips — 长语音 (voice_clip) list.
"""Tests for GET /api/voice-clips + PATCH /voice-clips/{id}/match.
Seeds 3 clips (2 wechat for user u1 at end_time 1000/3000, 1 whatsapp for u2
at end_time 2000) and asserts filters + ordering (``end_time desc``) +
pagination + the ``user_id``-beats-``user_real_name`` precedence rule.
at end_time 2000) and asserts GET filters + ordering (``end_time desc``) +
pagination + the ``user_id``-beats-``user_real_name`` precedence rule, plus
the 手动匹配 PATCH (set + clear + name resolution + 404 path).
"""
from __future__ import annotations
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import VoiceClip
from app.db.models import Customer, VoiceClip
async def _seed(session: AsyncSession) -> None:
@@ -237,3 +238,122 @@ async def test_raw_json_serializes_verbatim(make_client):
r_all = await client.get("/api/voice-clips")
by_id = {it["voice_clip_id"]: it for it in r_all.json()["items"]}
assert by_id[1]["raw_json"] is None
# ---------------------------------------------------------------------------
# 手动匹配 — PATCH /api/voice-clips/{id}/match
# ---------------------------------------------------------------------------
async def _seed_with_customer(session: AsyncSession) -> None:
"""Seed 1 clip + 1 matched customer (remark='张三')."""
await _seed(session)
session.add(
Customer(
friend_id="fid_remark",
channel="wechat",
owner_account_id="wx1",
friend_remark="张三",
friend_nickname="三三",
real_name="张三真名",
)
)
await session.commit()
async def test_match_returns_payload_and_persists(make_client):
"""PATCH /match sets matched_friend_id; payload echoes it; GET sees it."""
client = await make_client(_seed_with_customer)
r = await client.patch("/api/voice-clips/1/match", json={"friend_id": "fid_remark"})
assert r.status_code == 200
body = r.json()
assert body == {"voice_clip_id": 1, "matched_friend_id": "fid_remark"}
# The GET list now reflects the match (cross-request persistence —
# requires the PATCH to commit, not just flush).
r2 = await client.get("/api/voice-clips", params={"user_id": "u1"})
by_id = {it["voice_clip_id"]: it for it in r2.json()["items"]}
assert by_id[1]["matched_friend_id"] == "fid_remark"
# Resolved via Customer lookup: remark > nickname > real_name > friend_id.
assert by_id[1]["matched_friend_name"] == "张三"
# The other wechat clip for u1 is still unmatched.
assert by_id[3]["matched_friend_id"] is None
assert by_id[3]["matched_friend_name"] is None
async def test_match_name_falls_back_through_nickname_real_name(make_client):
"""Name resolution: no remark -> nickname; no nickname -> real_name."""
async def seed(session: AsyncSession) -> None:
await _seed(session)
session.add_all(
[
Customer(
friend_id="fid_nick",
channel="wechat",
friend_nickname="尼克",
),
Customer(
friend_id="fid_real",
channel="wechat",
real_name="王五",
),
]
)
await session.commit()
client = await make_client(seed)
await client.patch("/api/voice-clips/1/match", json={"friend_id": "fid_nick"})
await client.patch("/api/voice-clips/3/match", json={"friend_id": "fid_real"})
r = await client.get("/api/voice-clips", params={"user_id": "u1"})
by_id = {it["voice_clip_id"]: it for it in r.json()["items"]}
assert by_id[1]["matched_friend_name"] == "尼克"
assert by_id[3]["matched_friend_name"] == "王五"
async def test_match_with_unknown_friend_id_falls_back_to_id(make_client):
"""A match pointing at a deleted/unknown customer shows the id as name."""
client = await make_client(_seed)
await client.patch(
"/api/voice-clips/1/match", json={"friend_id": "ghost_customer"}
)
r = await client.get("/api/voice-clips", params={"user_id": "u1"})
by_id = {it["voice_clip_id"]: it for it in r.json()["items"]}
assert by_id[1]["matched_friend_id"] == "ghost_customer"
assert by_id[1]["matched_friend_name"] == "ghost_customer"
async def test_unmatch_clears_friend_id(make_client):
"""PATCH with friend_id=null clears an existing match."""
client = await make_client(_seed_with_customer)
await client.patch("/api/voice-clips/1/match", json={"friend_id": "fid_remark"})
# Now clear it.
r = await client.patch("/api/voice-clips/1/match", json={"friend_id": None})
assert r.status_code == 200
assert r.json() == {"voice_clip_id": 1, "matched_friend_id": None}
r2 = await client.get("/api/voice-clips", params={"user_id": "u1"})
by_id = {it["voice_clip_id"]: it for it in r2.json()["items"]}
assert by_id[1]["matched_friend_id"] is None
assert by_id[1]["matched_friend_name"] is None
async def test_match_missing_clip_returns_404(make_client):
"""PATCH on a non-existent voice_clip_id -> 404."""
client = await make_client(_seed)
r = await client.patch(
"/api/voice-clips/999999/match", json={"friend_id": "fid_remark"}
)
assert r.status_code == 404
async def test_match_default_null_body_is_treated_as_unmatch(make_client):
"""Omitting friend_id from the body clears the match (default None)."""
client = await make_client(_seed_with_customer)
await client.patch("/api/voice-clips/1/match", json={"friend_id": "fid_remark"})
# Empty body -> body.friend_id defaults to None -> unmatch.
r = await client.patch("/api/voice-clips/1/match", json={})
assert r.status_code == 200
assert r.json()["matched_friend_id"] is None
+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
}
+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>