feat: full-field display for group/call/sms (raw_json + drawers)
Mirrors the Customer/Message/Staff/Accounts pattern: backend exposes the
verbatim upstream record on the response schema; the frontend list shows
source-matching columns and a row-click opens an all-fields el-drawer with
Chinese-labeled camelCase keys (unknown keys roll up under 其他).
Backend (yunke-server/app/api/schemas.py)
- GroupOut / PhoneCallOut / SmsOut: add raw_json: dict[str, Any] | None
(Pydantic v2 from_attributes picks up the existing ORM column).
- Tests: extend tests/test_api_{groups,calls,sms}.py with a per-schema
raw_json round-trip assertion (phone_call + voice_call payload shapes
both covered for calls).
Frontend (yunkes)
- src/utils/{groupFields,callFields,smsFields}.ts: 中文 label map +
grouped sections + readField (structured-column preference) +
toDisplayField (image / time / audio / bool / status / direction).
- src/api/types.ts: add raw_json to Group / PhoneCall / Sms.
- src/views/GroupList.vue: richer columns (群名/群ID/成员数/群主/归属微信/
消息数/最近聊天/渠道) + all-fields drawer (chatRoomId/chatRoomName,
memberCount/memberIds, headImageUrl, wechatId/wechatNickName,
userName/userPhone/userId, messageCount/ownMessageCount,
lossMembers/addMembers, lastChatTime/updateTime).
- src/views/CallList.vue: keeps existing columns + adds all-fields drawer
(id/startCallTime, callDuration/callSeconds/ringTime, callStatus/
incomingCall, userId/userIdName/departmentId, callNumber/simPhone/
simNumber/imei, recordFile/isUpload/downloadCount/aiAsr/qcStatus/
planCustomerName/version + voice-call extras ossFileName/duration/
startTime/endTime/remark). Drawer plays recording inline via audio
render kind; existing play-record dialog retained.
- src/views/SmsList.vue: keeps existing columns + adds all-fields drawer
(id/customerPhone, sms/smsSub, sender, direction, userId/
senderDepartment, receiveName, createTime, simPhone/simNumber, source)
with a full-body preview block at the top.
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,7 @@ import {
|
||||
formatCallDuration,
|
||||
formatTime,
|
||||
} from '@/utils/format'
|
||||
import { buildCallSections, readField } from '@/utils/callFields'
|
||||
|
||||
// Calls aren't tied to a messaging channel — `channel` on a call is the call
|
||||
// type (phone_call / voice_call). We keep a LOCAL filter so it doesn't fight
|
||||
@@ -28,6 +29,10 @@ const recordDialogVisible = ref(false)
|
||||
const recordSrc = ref<string | null>(null)
|
||||
const recordTitle = ref('')
|
||||
|
||||
// All-fields drawer state.
|
||||
const drawerOpen = ref(false)
|
||||
const drawerCall = ref<PhoneCall | null>(null)
|
||||
|
||||
const typeOptions: { value: CallChannel | 'all'; label: string }[] = [
|
||||
{ value: 'all', label: '全部类型' },
|
||||
{ value: 'phone_call', label: '电话' },
|
||||
@@ -75,18 +80,45 @@ function onTypeChange(value: string | number | boolean | undefined): void {
|
||||
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 {
|
||||
if (!row.record_file) {
|
||||
const file =
|
||||
(typeof cell(row, 'recordFile') === 'string'
|
||||
? (cell(row, 'recordFile') as string)
|
||||
: null) ?? row.record_file
|
||||
if (!file) {
|
||||
ElMessage.info('该通话无录音文件')
|
||||
return
|
||||
}
|
||||
recordSrc.value = row.record_file
|
||||
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
|
||||
}
|
||||
|
||||
function openDrawer(row: PhoneCall): void {
|
||||
drawerCall.value = row
|
||||
drawerOpen.value = true
|
||||
}
|
||||
|
||||
const drawerSections = computed(() =>
|
||||
drawerCall.value ? buildCallSections(drawerCall.value) : [],
|
||||
)
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
@@ -128,10 +160,14 @@ onMounted(load)
|
||||
border
|
||||
row-key="call_id"
|
||||
style="width: 100%"
|
||||
:row-class-name="() => 'yk-clickable-row'"
|
||||
empty-text="暂无通话记录"
|
||||
@row-click="openDrawer"
|
||||
>
|
||||
<el-table-column label="时间" width="160">
|
||||
<template #default="{ row }">{{ formatTime(row.start_call_time) }}</template>
|
||||
<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 }">
|
||||
@@ -158,7 +194,7 @@ onMounted(load)
|
||||
<el-table-column label="录音" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
v-if="row.record_file"
|
||||
v-if="row.record_file || cell(row, 'recordFile')"
|
||||
size="small"
|
||||
type="primary"
|
||||
link
|
||||
@@ -183,7 +219,9 @@ onMounted(load)
|
||||
@size-change="handlePageSizeChange"
|
||||
/>
|
||||
</div>
|
||||
<p class="yk-calls__hint">点击任意一行查看通话全部字段。</p>
|
||||
|
||||
<!-- Recording playback dialog -->
|
||||
<el-dialog
|
||||
v-model="recordDialogVisible"
|
||||
:title="recordTitle"
|
||||
@@ -204,6 +242,54 @@ onMounted(load)
|
||||
<a :href="recordSrc" target="_blank" rel="noopener">在新窗口打开录音文件</a>
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -240,4 +326,76 @@ onMounted(load)
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user