Files
yunke/yunkes/src/views/CallList.vue
T

759 lines
27 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 { getCallStats, getCalls } from '@/api'
import type { CallChannel, CallStats, 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')
/** 通话时间范围筛选([startMs, endMs] | null,对齐云客高级筛选)。 */
const callRange = ref<[string, string] | null>(null)
/** 今日微信语音统计卡片(对齐云客语音列表顶部)。 */
const stats = ref<CallStats | null>(null)
async function loadStats(): Promise<void> {
try {
stats.value = await getCallStats()
} catch {
stats.value = null // 卡片失败不阻塞列表
}
}
function fmtDur(sec: number | undefined): string {
if (!sec) return '0秒'
const m = Math.floor(sec / 60)
const s = sec % 60
return m > 0 ? `${m}分${s}秒` : `${s}秒`
}
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,
start: callRange.value?.[0] ? Number(callRange.value[0]) : undefined,
end: callRange.value?.[1] ? Number(callRange.value[1]) : 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
}
}
/** 通话时间范围变更 → 回第 1 页重查(服务端过滤)。 */
function handleRangeChange(): void {
page.value = 1
load()
}
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()
loadStats()
}
},
)
// ---- 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
const t = String(talker)
// 群语音的 talker 是 @chatroom ID → 走群聊路由(1:1 路由按 talker_id 查会
// 查空,同 chat_group owner 错位类问题,2026-08-22)
if (t.endsWith('@chatroom')) {
router.push({
name: 'group-chat',
params: {
channel: chatChannel(row.channel) ?? 'wechat',
ownerId: String(owner),
roomId: t,
},
query: row.start_call_time ? { start: String(row.start_call_time) } : undefined,
})
return
}
router.push({
name: 'chat',
params: {
channel: chatChannel(row.channel) ?? 'wechat',
ownerId: String(owner),
talkerId: t,
},
// 对齐云客:带通话时间定位(?start=),聊天窗滚动到该通通话附近
query: row.start_call_time ? { start: String(row.start_call_time) } : undefined,
})
}
/** 员工微信昵称 → 账号级聊天窗(复用 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()
loadStats()
})
</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"
/>
<!-- 今日统计卡片(对齐云客微信语音列表顶部;仅微信语音 tab) -->
<div
v-if="callType === 'voice_call' && stats"
style="display: flex; gap: 12px; margin-bottom: 12px; flex-wrap: wrap"
>
<el-card shadow="never" style="flex: 1; min-width: 140px">
<div style="font-size: 12px; color: var(--el-text-color-secondary)">今日好友语音呼入量</div>
<div style="font-size: 20px; font-weight: 600">{{ stats.friend_incoming }}</div>
</el-card>
<el-card shadow="never" style="flex: 1; min-width: 140px">
<div style="font-size: 12px; color: var(--el-text-color-secondary)">今日好友语音外呼量</div>
<div style="font-size: 20px; font-weight: 600">{{ stats.friend_outgoing }}</div>
</el-card>
<el-card shadow="never" style="flex: 1; min-width: 140px">
<div style="font-size: 12px; color: var(--el-text-color-secondary)">今日微信群语音量</div>
<div style="font-size: 20px; font-weight: 600">{{ stats.group_count }}</div>
</el-card>
<el-card shadow="never" style="flex: 1; min-width: 140px">
<div style="font-size: 12px; color: var(--el-text-color-secondary)">今日好友呼入/呼出时长</div>
<div style="font-size: 20px; font-weight: 600">{{ fmtDur(stats.friend_incoming_duration) }} / {{ fmtDur(stats.friend_outgoing_duration) }}</div>
</el-card>
<el-card shadow="never" style="flex: 1; min-width: 140px">
<div style="font-size: 12px; color: var(--el-text-color-secondary)">今日群通话录音时长</div>
<div style="font-size: 20px; font-weight: 600">{{ fmtDur(stats.group_duration) }}</div>
</el-card>
</div>
<div style="display: flex; gap: 12px; align-items: center; margin-bottom: 8px; flex-wrap: wrap">
<el-radio-group
v-if="callType === 'voice_call'"
v-model="voiceScope"
size="small"
>
<el-radio-button value="friend">微信好友</el-radio-button>
<el-radio-button value="group">微信群</el-radio-button>
</el-radio-group>
<!-- 高级筛选:通话时间范围(对齐云客,传 /api/calls 已有 start/end) -->
<el-date-picker
v-model="callRange"
type="datetimerange"
size="small"
range-separator="至"
start-placeholder="通话开始时间"
end-placeholder="通话结束时间"
value-format="x"
clearable
style="width: 340px"
@change="handleRangeChange"
/>
</div>
<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>
<!-- 员工信息抽屉(2026-08-24:替代独立页跳转,与全站统一) -->
<el-drawer
v-model="staffDrawerOpen"
:title="staffDrawerCall ? `员工详情 · ${staffDrawerCall.user_id_name ?? staffDrawerCall.user_id ?? ''}` : '员工详情'"
direction="rtl"
size="480px"
>
<template v-if="staffDrawerCall">
<div class="yk-drawer__avatar">
<div class="yk-drawer__avatar-meta">
<div class="yk-drawer__avatar-name">
{{ staffDrawerCall.user_id_name || cell(staffDrawerCall, 'userName') || staffDrawerCall.user_id }}
</div>
<div class="yk-drawer__avatar-sub">
{{ callTypeLabel(staffDrawerCall.channel) }}
<template v-if="cell(staffDrawerCall, 'weChatNickName')">
· {{ cell(staffDrawerCall, 'weChatNickName') }}
</template>
</div>
</div>
</div>
<div v-for="section in staffDrawerSections" :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.label" class="yk-drawer__kv-item">
<dt>{{ f.label }}</dt>
<dd>{{ 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>