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:
+113
-63
@@ -1,9 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getSms } from '@/api'
|
||||
import type { Sms } from '@/api/types'
|
||||
import { formatTime, smsDirectionLabel } from '@/utils/format'
|
||||
import { buildSmsSections, readField } from '@/utils/smsFields'
|
||||
|
||||
const list = ref<Sms[]>([])
|
||||
const total = ref(0)
|
||||
@@ -12,10 +13,9 @@ const pageSize = ref(20)
|
||||
const loading = ref(false)
|
||||
const errorMsg = ref<string | null>(null)
|
||||
|
||||
// Detail dialog state — shows the full SMS body (vs the truncated preview in
|
||||
// the table).
|
||||
const detailVisible = ref(false)
|
||||
const detailRow = ref<Sms | null>(null)
|
||||
// All-fields drawer state.
|
||||
const drawerOpen = ref(false)
|
||||
const drawerSms = ref<Sms | null>(null)
|
||||
|
||||
async function load(): Promise<void> {
|
||||
loading.value = true
|
||||
@@ -46,11 +46,46 @@ function handlePageSizeChange(s: number): void {
|
||||
load()
|
||||
}
|
||||
|
||||
function openDetail(row: Sms): void {
|
||||
detailRow.value = row
|
||||
detailVisible.value = true
|
||||
/**
|
||||
* Read a raw_json camelCase key off an SMS, 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: Sms, camelKey: string): unknown {
|
||||
return readField(row, camelKey)
|
||||
}
|
||||
|
||||
function cellText(row: Sms, camelKey: string): string {
|
||||
const v = cell(row, camelKey)
|
||||
return v === null || v === undefined || v === '' ? '--' : String(v)
|
||||
}
|
||||
|
||||
function timeCell(row: Sms, camelKey: string): number | undefined {
|
||||
const v = cell(row, camelKey)
|
||||
return typeof v === 'number' ? v : undefined
|
||||
}
|
||||
|
||||
function openDrawer(row: Sms): void {
|
||||
// Row click opens the all-fields drawer (which surfaces the full body too).
|
||||
drawerSms.value = row
|
||||
drawerOpen.value = true
|
||||
}
|
||||
|
||||
const drawerSections = computed(() =>
|
||||
drawerSms.value ? buildSmsSections(drawerSms.value) : [],
|
||||
)
|
||||
|
||||
const drawerFullBody = computed(() => {
|
||||
const s = drawerSms.value
|
||||
if (!s) return ''
|
||||
return (
|
||||
(typeof cell(s, 'sms') === 'string' ? (cell(s, 'sms') as string) : '') ||
|
||||
s.content ||
|
||||
s.content_preview ||
|
||||
''
|
||||
)
|
||||
})
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
@@ -81,14 +116,16 @@ onMounted(load)
|
||||
style="width: 100%"
|
||||
:row-class-name="() => 'yk-clickable-row'"
|
||||
empty-text="暂无短信记录"
|
||||
@row-click="openDetail"
|
||||
@row-click="openDrawer"
|
||||
>
|
||||
<el-table-column label="时间" width="160">
|
||||
<template #default="{ row }">{{ formatTime(row.create_time) }}</template>
|
||||
<template #default="{ row }">
|
||||
{{ formatTime(row.create_time ?? timeCell(row, 'createTime')) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="员工" min-width="130">
|
||||
<template #default="{ row }">
|
||||
{{ row.receive_name || row.user_id || '--' }}
|
||||
{{ row.receive_name || row.user_id || cellText(row, 'userId') }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="客户号码" min-width="130">
|
||||
@@ -120,43 +157,33 @@ onMounted(load)
|
||||
@size-change="handlePageSizeChange"
|
||||
/>
|
||||
</div>
|
||||
<p class="yk-sms__hint">点击任意一行查看完整短信内容。</p>
|
||||
<p class="yk-sms__hint">点击任意一行查看短信全部字段。</p>
|
||||
|
||||
<el-dialog
|
||||
v-model="detailVisible"
|
||||
title="短信详情"
|
||||
width="520px"
|
||||
align-center
|
||||
<!-- All-fields drawer -->
|
||||
<el-drawer
|
||||
v-model="drawerOpen"
|
||||
:title="drawerSms ? `短信详情 · ${drawerSms.customer_phone ?? drawerSms.sms_id}` : '短信详情'"
|
||||
direction="rtl"
|
||||
size="560px"
|
||||
>
|
||||
<div v-if="detailRow" class="yk-sms__detail">
|
||||
<div class="yk-sms__detail-row">
|
||||
<span class="yk-sms__detail-label">时间</span>
|
||||
<span>{{ formatTime(detailRow.create_time) }}</span>
|
||||
<template v-if="drawerSms">
|
||||
<div v-if="drawerFullBody" class="yk-sms__full">
|
||||
<div class="yk-sms__full-label">短信全文</div>
|
||||
<div class="yk-sms__full-body">{{ drawerFullBody }}</div>
|
||||
</div>
|
||||
<div class="yk-sms__detail-row">
|
||||
<span class="yk-sms__detail-label">员工</span>
|
||||
<span>{{ detailRow.receive_name || detailRow.user_id || '--' }}</span>
|
||||
|
||||
<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 === 'empty'" class="yk-drawer__empty">—</dd>
|
||||
<dd v-else>{{ f.text }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
<div class="yk-sms__detail-row">
|
||||
<span class="yk-sms__detail-label">客户号码</span>
|
||||
<span>{{ detailRow.customer_phone || '--' }}</span>
|
||||
</div>
|
||||
<div class="yk-sms__detail-row">
|
||||
<span class="yk-sms__detail-label">发送方</span>
|
||||
<span>{{ detailRow.sender || '--' }}</span>
|
||||
</div>
|
||||
<div class="yk-sms__detail-row">
|
||||
<span class="yk-sms__detail-label">方向</span>
|
||||
<span>{{ smsDirectionLabel(detailRow.direction) }}</span>
|
||||
</div>
|
||||
<div class="yk-sms__detail-row yk-sms__detail-row--top">
|
||||
<span class="yk-sms__detail-label">内容</span>
|
||||
<span class="yk-sms__detail-content">{{
|
||||
detailRow.content || detailRow.content_preview || '(空)'
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</template>
|
||||
</el-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -187,31 +214,54 @@ onMounted(load)
|
||||
color: #c0c4cc;
|
||||
}
|
||||
|
||||
.yk-sms__detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
font-size: 13px;
|
||||
.yk-sms__full {
|
||||
background: var(--el-fill-color-light, #f5f7fa);
|
||||
border: 1px solid var(--el-border-color-light, #e4e7ed);
|
||||
border-radius: 4px;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.yk-sms__detail-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.yk-sms__detail-row--top {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.yk-sms__detail-label {
|
||||
flex: 0 0 64px;
|
||||
.yk-sms__full-label {
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.yk-sms__detail-content {
|
||||
.yk-sms__full-body {
|
||||
font-size: 13px;
|
||||
color: #303133;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.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__empty {
|
||||
color: #c0c4cc;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user