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:
浅念
2026-08-05 10:12:49 +08:00
parent fd86adfb63
commit 0d466c4e4a
11 changed files with 1442 additions and 92 deletions
+113 -63
View File
@@ -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>