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
+31 -3
View File
@@ -152,7 +152,15 @@ class MessageOut(BaseModel):
class GroupOut(BaseModel):
"""Chat group summary row."""
"""Chat group summary row.
The structured columns power the list table; the verbatim upstream record
is exposed as ``raw_json`` so the frontend can render the all-fields drawer
(chatRoomId/chatRoomName, memberIds, headImageUrl, wechatId/wechatNickName,
userName/userPhone, messageCount/ownMessageCount, lossMembers/addMembers,
lastChatTime/updateTime, ...) without a second endpoint. Picked up
automatically via ``from_attributes``.
"""
model_config = ConfigDict(from_attributes=True)
@@ -164,6 +172,7 @@ class GroupOut(BaseModel):
member_count: int | None = None
message_count: int | None = None
last_chat_time: int | None = None
raw_json: dict[str, Any] | None = None
class GroupMemberOut(BaseModel):
@@ -180,7 +189,17 @@ class GroupMemberOut(BaseModel):
class PhoneCallOut(BaseModel):
"""Phone / voice call record. ``channel`` is the call-type."""
"""Phone / voice call record. ``channel`` is the call-type.
The structured columns power the list table; the verbatim upstream record
is exposed as ``raw_json`` so the frontend can render the 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) without a second endpoint. Picked up
automatically via ``from_attributes``.
"""
model_config = ConfigDict(from_attributes=True)
@@ -197,10 +216,18 @@ class PhoneCallOut(BaseModel):
# OSS URL of the call recording — surfaced so the Calls view can offer a
# "播放录音" action when present.
record_file: str | None = None
raw_json: dict[str, Any] | None = None
class SmsOut(BaseModel):
"""SMS log row."""
"""SMS log row.
The structured columns power the list table; the verbatim upstream record
is exposed as ``raw_json`` so the frontend can render the all-fields drawer
(id/customerPhone, sms/smsSub, sender, direction, userId,
senderDepartment, receiveName, createTime, simPhone/simNumber, source)
without a second endpoint. Picked up automatically via ``from_attributes``.
"""
model_config = ConfigDict(from_attributes=True)
@@ -216,6 +243,7 @@ class SmsOut(BaseModel):
receive_name: str | None = None
create_time: int | None = None
sim_phone: str | None = None
raw_json: dict[str, Any] | None = None
# ---------------------------------------------------------------------------
+109
View File
@@ -73,10 +73,119 @@ async def test_default_list_returns_all_ordered_desc(make_client):
"call_number",
"sim_phone",
"record_file",
"raw_json",
):
assert key in body["items"][0]
async def test_raw_json_serializes_verbatim(make_client):
"""``raw_json`` round-trips the verbatim upstream call record.
Phone-call and voice-call payloads carry different camelCase shapes; both
must come back unmolested so the all-fields drawer can render every column
the 云客 通话记录 page shows. Rows without a blob serialize as JSON null.
"""
async def seed(session: AsyncSession) -> None:
await _seed(session)
session.add_all(
[
PhoneCall(
call_id="c_phone_raw",
channel="phone_call",
user_id="u1",
user_id_name="Alice",
start_call_time=9000,
call_duration=12,
call_status=2,
incoming_call=0,
raw_json={
"id": "c_phone_raw",
"startCallTime": 9000,
"callDuration": 12,
"callSeconds": 12,
"ringTime": 3,
"callStatus": 2,
"incomingCall": 0,
"userId": "u1",
"userIdName": "Alice",
"departmentId": "d1",
"callNumber": "13800000000",
"simPhone": "13900000000",
"simNumber": "1",
"imei": "356938035643809",
"recordFile": "https://oss/c_phone_raw.mp3",
"isUpload": 1,
"downloadCount": 2,
"aiAsr": "translated text",
"qcStatus": 1,
"planCustomerName": "Carol",
"version": "v1",
},
),
PhoneCall(
call_id="c_voice_raw",
channel="voice_call",
user_id="u2",
user_id_name="Bob",
start_call_time=9500,
call_duration=20,
raw_json={
"ossFileName": "voice/c_voice_raw.mp3",
"duration": 20,
"startTime": 9500,
"endTime": 9520,
"remark": "voice memo",
"userId": "u2",
},
),
]
)
await session.commit()
client = await make_client(seed)
r = await client.get("/api/calls", params={"start": 9000})
assert r.status_code == 200
by_id = {it["call_id"]: it for it in r.json()["items"]}
assert by_id["c_phone_raw"]["raw_json"] == {
"id": "c_phone_raw",
"startCallTime": 9000,
"callDuration": 12,
"callSeconds": 12,
"ringTime": 3,
"callStatus": 2,
"incomingCall": 0,
"userId": "u1",
"userIdName": "Alice",
"departmentId": "d1",
"callNumber": "13800000000",
"simPhone": "13900000000",
"simNumber": "1",
"imei": "356938035643809",
"recordFile": "https://oss/c_phone_raw.mp3",
"isUpload": 1,
"downloadCount": 2,
"aiAsr": "translated text",
"qcStatus": 1,
"planCustomerName": "Carol",
"version": "v1",
}
# Voice-call payload shape round-trips too.
assert by_id["c_voice_raw"]["raw_json"] == {
"ossFileName": "voice/c_voice_raw.mp3",
"duration": 20,
"startTime": 9500,
"endTime": 9520,
"remark": "voice memo",
"userId": "u2",
}
# Seeded without an explicit raw_json -> JSON null.
assert by_id.get("c3", {}).get("raw_json") is None
async def test_filter_by_user_id(make_client):
"""``?user_id=u1`` -> 2 calls for Alice."""
client = await make_client(_seed)
+70
View File
@@ -82,10 +82,80 @@ async def test_default_list_returns_all_ordered_desc(make_client):
"member_count",
"message_count",
"last_chat_time",
"raw_json",
):
assert key in body["items"][0]
async def test_raw_json_serializes_verbatim(make_client):
"""``raw_json`` round-trips the verbatim upstream chatroom record.
Seeded blobs must come back unmolested (every camelCase key preserved) so
the all-fields drawer can render every column the 云客 群管理 page shows.
Rows without a blob serialize as JSON null.
"""
async def seed(session: AsyncSession) -> None:
await _seed(session)
session.add(
ChatGroup(
room_id="room_raw@chatroom",
channel="wechat",
room_name="Raw Team",
owner_account_id="wx_owner_1",
member_count=4,
last_chat_time=9000,
raw_json={
"chatRoomId": "room_raw@chatroom",
"chatRoomName": "Raw Team",
"memberCount": 4,
"memberIds": "wx_a;wx_b;wx_c;wx_d",
"headImageUrl": "https://oss/raw.jpg",
"wechatId": "wx_owner_1",
"wechatNickName": "WxOne",
"userName": "owner_login",
"userPhone": "13800000000",
"userId": "u_owner",
"messageCount": 42,
"ownMessageCount": 7,
"lossMembers": 1,
"addMembers": 2,
"lastChatTime": 9000,
"updateTime": 9999,
},
)
)
await session.commit()
client = await make_client(seed)
r = await client.get("/api/groups", params={"owner_account_id": "wx_owner_1"})
assert r.status_code == 200
by_id = {it["room_id"]: it for it in r.json()["items"]}
raw = by_id["room_raw@chatroom"]["raw_json"]
assert raw == {
"chatRoomId": "room_raw@chatroom",
"chatRoomName": "Raw Team",
"memberCount": 4,
"memberIds": "wx_a;wx_b;wx_c;wx_d",
"headImageUrl": "https://oss/raw.jpg",
"wechatId": "wx_owner_1",
"wechatNickName": "WxOne",
"userName": "owner_login",
"userPhone": "13800000000",
"userId": "u_owner",
"messageCount": 42,
"ownMessageCount": 7,
"lossMembers": 1,
"addMembers": 2,
"lastChatTime": 9000,
"updateTime": 9999,
}
# Seeded without an explicit raw_json -> JSON null (not omitted, not error).
assert by_id["room_a@chatroom"]["raw_json"] is None
async def test_filter_by_channel_whatsapp(make_client):
"""``?channel=whatsapp`` -> the single WA group."""
client = await make_client(_seed)
+66
View File
@@ -69,10 +69,76 @@ async def test_default_list_returns_all_ordered_desc(make_client):
"receive_name",
"create_time",
"sim_phone",
"raw_json",
):
assert key in body["items"][0]
async def test_raw_json_serializes_verbatim(make_client):
"""``raw_json`` round-trips the verbatim upstream SMS record.
Every camelCase key must come back unmolested so the all-fields drawer can
render every column the 云客 短信记录 page shows. Rows without a blob
serialize as JSON null.
"""
async def seed(session: AsyncSession) -> None:
await _seed(session)
session.add(
Sms(
sms_id="s_raw",
channel="sms",
user_id="u1",
receive_name="Alice",
content_preview="raw body",
content="raw body full text",
create_time=9000,
direction=1,
customer_phone="999",
raw_json={
"id": "s_raw",
"customerPhone": "999",
"sms": "raw body full text",
"smsSub": "raw body",
"sender": "111",
"direction": 1,
"userId": "u1",
"senderDepartment": "Sales",
"receiveName": "Alice",
"createTime": 9000,
"simPhone": "13900000000",
"simNumber": "1",
"source": "yunke",
},
)
)
await session.commit()
client = await make_client(seed)
r = await client.get("/api/sms", params={"user_id": "u1"})
assert r.status_code == 200
by_id = {it["sms_id"]: it for it in r.json()["items"]}
assert by_id["s_raw"]["raw_json"] == {
"id": "s_raw",
"customerPhone": "999",
"sms": "raw body full text",
"smsSub": "raw body",
"sender": "111",
"direction": 1,
"userId": "u1",
"senderDepartment": "Sales",
"receiveName": "Alice",
"createTime": 9000,
"simPhone": "13900000000",
"simNumber": "1",
"source": "yunke",
}
# Seeded without an explicit raw_json -> JSON null.
assert by_id["s1"]["raw_json"] is None
async def test_filter_by_user_id(make_client):
"""``?user_id=u1`` -> 2 SMS for Alice."""
client = await make_client(_seed)
+23
View File
@@ -92,6 +92,14 @@ export interface Group {
message_count?: number | null
/** ms-epoch of most recent message */
last_chat_time?: number | null
/**
* Verbatim upstream chatroom record (camelCase keys: chatRoomId/chatRoomName,
* memberCount/memberIds, headImageUrl, wechatId/wechatNickName, userName/
* userPhone, userId, messageCount/ownMessageCount, lossMembers/addMembers,
* lastChatTime/updateTime, ...). Surfaced so the all-fields drawer can
* render every column the 云客 群管理 page shows.
*/
raw_json?: Record<string, unknown> | null
}
/** Member of a chat group (mirrors backend GroupMemberOut). */
@@ -126,6 +134,14 @@ export interface PhoneCall {
sim_phone?: string | null
/** OSS URL of the recording — present only when a recording exists. */
record_file?: string | null
/**
* Verbatim upstream call record (camelCase keys: id/startCallTime,
* callDuration/callSeconds/ringTime, callStatus/incomingCall, userId/
* userIdName/departmentId, callNumber, simPhone/simNumber/imei, recordFile,
* isUpload/downloadCount/aiAsr/qcStatus/planCustomerName/version; voice-call
* records also carry ossFileName/duration/startTime/endTime/remark).
*/
raw_json?: Record<string, unknown> | null
}
/** SMS log row (mirrors backend SmsOut). */
@@ -143,6 +159,13 @@ export interface Sms {
/** ms-epoch */
create_time?: number | null
sim_phone?: string | null
/**
* Verbatim upstream SMS record (camelCase keys: id/customerPhone, sms/smsSub,
* sender, direction, userId, senderDepartment, receiveName, createTime,
* simPhone/simNumber, source, ...). Surfaced so the all-fields drawer can
* render every column the 云客 短信记录 page shows.
*/
raw_json?: Record<string, unknown> | null
}
/** Resumable sync cursor (mirrors backend SyncStateOut). */
+259
View File
@@ -0,0 +1,259 @@
/**
* Chinese-label map + grouping for the PhoneCall all-fields drawer.
*
* Mirrors the Customer approach (see customerFields.ts): the 云客 upstream
* call record (`PhoneCall.raw_json`) carries the camelCase fields the source
* 通话记录 page shows. Two payload shapes flow through this table:
*
* • phone_call records: id/startCallTime/callDuration/callSeconds/ringTime/
* callStatus/incomingCall/userId/userIdName/departmentId/callNumber/
* simPhone/simNumber/imei/recordFile/isUpload/downloadCount/aiAsr/
* qcStatus/planCustomerName/version.
* • voice_call records: ossFileName/duration/startTime/endTime/remark/
* userId/...
*
* Structured columns are preferred over the verbatim raw_json value when both
* exist (the structured column is type-normalized on ingest). Unknown keys
* (anything not in the label map) fall back to the raw key under 其他, so a
* new field added upstream still shows up instead of disappearing.
*/
import type { PhoneCall } from '@/api/types'
import { formatTime } from './format'
/** camelCase raw_json key → 中文 label. Missing keys fall back to the key. */
export const CALL_FIELD_LABELS: Record<string, string> = {
// 标识与时间
id: '通话ID',
startCallTime: '开始时间',
startTime: '开始时间',
endTime: '结束时间',
ringTime: '振铃时间',
// 时长
callDuration: '时长(秒)',
callSeconds: '时长(秒)',
duration: '时长(秒)',
// 状态与方向
callStatus: '状态',
incomingCall: '方向',
isUpload: '已上传',
downloadCount: '下载次数',
qcStatus: '质检状态',
// 员工与部门
userId: '员工ID',
userIdName: '员工',
departmentId: '部门ID',
// 号码与设备
callNumber: '号码',
simPhone: 'SIM手机',
simNumber: 'SIM号',
imei: 'IMEI',
// 录音与转写
recordFile: '录音URL',
ossFileName: 'OSS文件',
aiAsr: 'AI转写',
remark: '备注',
// 关联与版本
planCustomerName: '关联客户',
version: '版本',
}
/**
* raw_json camelCase key → the structured snake_case column that mirrors it.
* When a structured column is populated we prefer it (type-normalized); we
* fall back to the verbatim raw_json value otherwise.
*/
const STRUCTURED_ALIAS: Record<string, keyof PhoneCall> = {
id: 'call_id',
startCallTime: 'start_call_time',
startTime: 'start_call_time',
callDuration: 'call_duration',
duration: 'call_duration',
callStatus: 'call_status',
incomingCall: 'incoming_call',
userId: 'user_id',
userIdName: 'user_id_name',
callNumber: 'call_number',
simPhone: 'sim_phone',
recordFile: 'record_file',
}
/** Fields whose value is an audio URL → render as a play link. */
const AUDIO_KEYS = new Set(['recordFile'])
/** Fields whose value is a millisecond epoch → format to local datetime. */
const TIMESTAMP_KEYS = new Set(['startCallTime', 'startTime', 'endTime'])
/** Fields whose value is a boolean / 0|1 flag → render 是/否. */
const BOOLEAN_KEYS = new Set(['isUpload'])
/**
* callStatus mapping used inside the drawer (per upstream call-log payload):
* 0 = 未接, 2 = 接通, other codes pass through so info isn't hidden. (The list
* column uses the format.ts helper which follows the 1=已接通/0=未接通 phone
* schema; the drawer surfaces the raw upstream value verbatim with this
* friendlier label.)
*/
function callStatusLabel(v: unknown): string {
if (v === 0 || v === '0') return '未接'
if (v === 2 || v === '2') return '接通'
return String(v)
}
/** incomingCall: 1 = 呼入, 0 = 呼出, other values pass through. */
function callDirectionLabel(v: unknown): string {
if (v === 1 || v === '1') return '呼入'
if (v === 0 || v === '0') return '呼出'
return String(v)
}
/** 0|1|true|false → 是/否; other ints pass through so info isn't hidden. */
function yesNoLabel(v: unknown): string {
if (v === true || v === 1 || v === '1') return '是'
if (v === false || v === 0 || v === '0') return '否'
return String(v)
}
/** Drawer sections, rendered in this order. */
export interface CallFieldGroup {
title: string
/** camelCase raw_json keys to surface in this section. */
keys: string[]
}
export const CALL_FIELD_GROUPS: CallFieldGroup[] = [
{
title: '标识与时间',
keys: ['id', 'startCallTime', 'startTime', 'endTime', 'ringTime'],
},
{
title: '时长',
keys: ['callDuration', 'callSeconds', 'duration'],
},
{
title: '状态与方向',
keys: [
'callStatus',
'incomingCall',
'isUpload',
'downloadCount',
'qcStatus',
],
},
{
title: '员工与部门',
keys: ['userId', 'userIdName', 'departmentId'],
},
{
title: '号码与设备',
keys: ['callNumber', 'simPhone', 'simNumber', 'imei'],
},
{
title: '录音与转写',
keys: ['recordFile', 'ossFileName', 'aiAsr', 'remark'],
},
{
title: '关联与版本',
keys: ['planCustomerName', 'version'],
},
]
/** Every key already surfaced via a group (so 其他 can skip duplicates). */
const KNOWN_KEYS = new Set(CALL_FIELD_GROUPS.flatMap((g) => g.keys))
/** Resolve the 中文 label for a raw_json key (falls back to the key). */
export function fieldLabel(key: string): string {
return CALL_FIELD_LABELS[key] ?? key
}
/**
* Read a field value with structured-column preference.
* Returns the verbatim raw_json value when no structured column maps.
*/
export function readField(c: PhoneCall, camelKey: string): unknown {
const alias = STRUCTURED_ALIAS[camelKey]
if (alias) {
const v = c[alias]
if (v !== null && v !== undefined) return v
}
return c.raw_json?.[camelKey]
}
/** A single field prepared for drawer rendering. */
export interface DisplayField {
key: string
label: string
/** Raw value (for text/audio fallback). */
value: unknown
/** How the template should render it. */
render: 'audio' | 'time' | 'bool' | 'status' | 'direction' | 'text' | 'empty'
/** Pre-formatted text (for non-audio kinds). Empty for audio/empty. */
text: string
}
/** Classify + format a single field for the drawer. */
export function toDisplayField(c: PhoneCall, camelKey: string): DisplayField {
const value = readField(c, camelKey)
const label = fieldLabel(camelKey)
if (value === null || value === undefined || value === '') {
return { key: camelKey, label, value, render: 'empty', text: '' }
}
if (AUDIO_KEYS.has(camelKey) && typeof value === 'string') {
return { key: camelKey, label, value, render: 'audio', text: '' }
}
if (TIMESTAMP_KEYS.has(camelKey) && typeof value === 'number') {
return { key: camelKey, label, value, render: 'time', text: formatTime(value) }
}
if (camelKey === 'callStatus') {
return { key: camelKey, label, value, render: 'status', text: callStatusLabel(value) }
}
if (camelKey === 'incomingCall') {
return {
key: camelKey,
label,
value,
render: 'direction',
text: callDirectionLabel(value),
}
}
if (BOOLEAN_KEYS.has(camelKey)) {
return { key: camelKey, label, value, render: 'bool', text: yesNoLabel(value) }
}
return { key: camelKey, label, value, render: 'text', text: String(value) }
}
/**
* Build the grouped display model for a call.
*
* Each known group only includes fields that have a value (empty ones are
* dropped to keep the drawer compact). A trailing 其他 section collects every
* raw_json key not already surfaced, so new upstream fields show up
* automatically instead of being silently hidden.
*/
export interface CallFieldSection {
title: string
fields: DisplayField[]
}
export function buildCallSections(c: PhoneCall): CallFieldSection[] {
const sections: CallFieldSection[] = CALL_FIELD_GROUPS.map((g) => ({
title: g.title,
fields: g.keys
.map((k) => toDisplayField(c, k))
.filter((f) => f.render !== 'empty'),
}))
// 其他: every raw_json key not already shown, in original insertion order.
const raw = c.raw_json ?? {}
const others: DisplayField[] = []
for (const key of Object.keys(raw)) {
if (KNOWN_KEYS.has(key)) continue
const f = toDisplayField(c, key)
if (f.render === 'empty') continue
others.push(f)
}
if (others.length > 0) {
sections.push({ title: '其他字段', fields: others })
}
return sections.filter((s) => s.fields.length > 0)
}
+191
View File
@@ -0,0 +1,191 @@
/**
* Chinese-label map + grouping for the Group all-fields drawer.
*
* Mirrors the Customer approach (see customerFields.ts): the 云客 upstream
* chatroom record (`Group.raw_json`) carries the camelCase fields the source
* 群管理 page shows (chatRoomId/chatRoomName, memberCount/memberIds,
* headImageUrl, wechatId/wechatNickName, userName/userPhone, userId,
* messageCount/ownMessageCount, lossMembers/addMembers, lastChatTime/
* updateTime). This module turns that blob into a labeled, grouped,
* render-ready structure for the drawer opened by clicking a group row.
*
* Structured columns are preferred over the verbatim raw_json value when both
* exist (the structured column is type-normalized on ingest). Unknown keys
* (anything not in the label map) fall back to the raw key under 其他, so a
* new field added upstream still shows up instead of disappearing.
*/
import type { Group } from '@/api/types'
import { formatTime } from './format'
/** camelCase raw_json key → 中文 label. Missing keys fall back to the key. */
export const GROUP_FIELD_LABELS: Record<string, string> = {
// 群标识
chatRoomId: '群ID',
chatRoomName: '群名',
headImageUrl: '群头像',
// 成员
memberCount: '成员数',
memberIds: '成员列表',
// 归属微信与群主
wechatId: '归属微信ID',
wechatNickName: '归属微信',
userName: '群主登录名',
userPhone: '群主手机',
userId: '群主ID',
// 消息统计
messageCount: '消息数',
ownMessageCount: '发出数',
lossMembers: '流失成员',
addMembers: '新增成员',
// 时间
lastChatTime: '最近聊天',
updateTime: '更新时间',
}
/**
* raw_json camelCase key → the structured snake_case column that mirrors it.
* When a structured column is populated we prefer it (type-normalized); we
* fall back to the verbatim raw_json value otherwise.
*/
const STRUCTURED_ALIAS: Record<string, keyof Group> = {
chatRoomId: 'room_id',
chatRoomName: 'room_name',
memberCount: 'member_count',
messageCount: 'message_count',
lastChatTime: 'last_chat_time',
wechatId: 'owner_account_id',
wechatNickName: 'owner_account_nickname',
}
/** Fields whose value is an image URL → render as a thumbnail. */
const IMAGE_KEYS = new Set(['headImageUrl'])
/** Fields whose value is a millisecond epoch → format to local datetime. */
const TIMESTAMP_KEYS = new Set(['lastChatTime', 'updateTime'])
/** Drawer sections, rendered in this order. */
export interface GroupFieldGroup {
title: string
/** camelCase raw_json keys to surface in this section. */
keys: string[]
}
export const GROUP_FIELD_GROUPS: GroupFieldGroup[] = [
{
title: '群标识',
keys: ['chatRoomId', 'chatRoomName', 'headImageUrl'],
},
{
title: '成员',
keys: ['memberCount', 'memberIds'],
},
{
title: '归属微信与群主',
keys: [
'wechatId',
'wechatNickName',
'userName',
'userPhone',
'userId',
],
},
{
title: '消息统计',
keys: [
'messageCount',
'ownMessageCount',
'lossMembers',
'addMembers',
],
},
{
title: '时间',
keys: ['lastChatTime', 'updateTime'],
},
]
/** Every key already surfaced via a group (so 其他 can skip duplicates). */
const KNOWN_KEYS = new Set(GROUP_FIELD_GROUPS.flatMap((g) => g.keys))
/** Resolve the 中文 label for a raw_json key (falls back to the key). */
export function fieldLabel(key: string): string {
return GROUP_FIELD_LABELS[key] ?? key
}
/**
* Read a field value with structured-column preference.
* Returns the verbatim raw_json value when no structured column maps.
*/
export function readField(g: Group, camelKey: string): unknown {
const alias = STRUCTURED_ALIAS[camelKey]
if (alias) {
const v = g[alias]
if (v !== null && v !== undefined) return v
}
return g.raw_json?.[camelKey]
}
/** A single field prepared for drawer rendering. */
export interface DisplayField {
key: string
label: string
/** Raw value (for text fallback). */
value: unknown
/** How the template should render it. */
render: 'image' | 'time' | 'text' | 'empty'
/** Pre-formatted text (for time/text kinds). Empty for image/empty. */
text: string
}
/** Classify + format a single field for the drawer. */
export function toDisplayField(g: Group, camelKey: string): DisplayField {
const value = readField(g, camelKey)
const label = fieldLabel(camelKey)
if (value === null || value === undefined || value === '') {
return { key: camelKey, label, value, render: 'empty', text: '' }
}
if (IMAGE_KEYS.has(camelKey) && typeof value === 'string') {
return { key: camelKey, label, value, render: 'image', text: '' }
}
if (TIMESTAMP_KEYS.has(camelKey) && typeof value === 'number') {
return { key: camelKey, label, value, render: 'time', text: formatTime(value) }
}
return { key: camelKey, label, value, render: 'text', text: String(value) }
}
/**
* Build the grouped display model for a group.
*
* Each known group only includes fields that have a value (empty ones are
* dropped to keep the drawer compact). A trailing 其他 section collects every
* raw_json key not already surfaced, so new upstream fields show up
* automatically instead of being silently hidden.
*/
export interface GroupFieldSection {
title: string
fields: DisplayField[]
}
export function buildGroupSections(g: Group): GroupFieldSection[] {
const sections: GroupFieldSection[] = GROUP_FIELD_GROUPS.map((grp) => ({
title: grp.title,
fields: grp.keys
.map((k) => toDisplayField(g, k))
.filter((f) => f.render !== 'empty'),
}))
// 其他: every raw_json key not already shown, in original insertion order.
const raw = g.raw_json ?? {}
const others: DisplayField[] = []
for (const key of Object.keys(raw)) {
if (KNOWN_KEYS.has(key)) continue
const f = toDisplayField(g, key)
if (f.render === 'empty') continue
others.push(f)
}
if (others.length > 0) {
sections.push({ title: '其他字段', fields: others })
}
return sections.filter((s) => s.fields.length > 0)
}
+192
View File
@@ -0,0 +1,192 @@
/**
* Chinese-label map + grouping for the SMS all-fields drawer.
*
* Mirrors the Customer approach (see customerFields.ts): the 云客 upstream
* SMS record (`Sms.raw_json`) carries the camelCase fields the source 短信记录
* page shows (id/customerPhone, sms/smsSub, sender, direction, userId,
* senderDepartment, receiveName, createTime, simPhone/simNumber, source).
* This module turns that blob into a labeled, grouped, render-ready structure
* for the drawer opened by clicking an SMS row.
*
* Structured columns are preferred over the verbatim raw_json value when both
* exist (the structured column is type-normalized on ingest). Unknown keys
* (anything not in the label map) fall back to the raw key under 其他, so a
* new field added upstream still shows up instead of disappearing.
*/
import type { Sms } from '@/api/types'
import { formatTime } from './format'
/** camelCase raw_json key → 中文 label. Missing keys fall back to the key. */
export const SMS_FIELD_LABELS: Record<string, string> = {
// 标识与时间
id: '短信ID',
createTime: '时间',
// 客户与号码
customerPhone: '客户号码',
sender: '发送者',
receiveName: '接收者',
// 内容
sms: '全文',
smsSub: '预览',
// 方向与归属
direction: '方向',
userId: '员工ID',
senderDepartment: '部门',
// 设备与来源
simPhone: 'SIM手机',
simNumber: 'SIM号',
source: '来源',
}
/**
* raw_json camelCase key → the structured snake_case column that mirrors it.
* When a structured column is populated we prefer it (type-normalized); we
* fall back to the verbatim raw_json value otherwise.
*/
const STRUCTURED_ALIAS: Record<string, keyof Sms> = {
id: 'sms_id',
customerPhone: 'customer_phone',
sms: 'content',
smsSub: 'content_preview',
sender: 'sender',
direction: 'direction',
userId: 'user_id',
receiveName: 'receive_name',
createTime: 'create_time',
simPhone: 'sim_phone',
}
/** Fields whose value is a millisecond epoch → format to local datetime. */
const TIMESTAMP_KEYS = new Set(['createTime'])
/** Fields whose value is a direction code (0=接收 / 1=发送). */
const DIRECTION_KEYS = new Set(['direction'])
/** Drawer sections, rendered in this order. */
export interface SmsFieldGroup {
title: string
/** camelCase raw_json keys to surface in this section. */
keys: string[]
}
export const SMS_FIELD_GROUPS: SmsFieldGroup[] = [
{
title: '标识与时间',
keys: ['id', 'createTime'],
},
{
title: '客户与号码',
keys: ['customerPhone', 'sender', 'receiveName'],
},
{
title: '内容',
keys: ['sms', 'smsSub'],
},
{
title: '方向与归属',
keys: ['direction', 'userId', 'senderDepartment'],
},
{
title: '设备与来源',
keys: ['simPhone', 'simNumber', 'source'],
},
]
/** Every key already surfaced via a group (so 其他 can skip duplicates). */
const KNOWN_KEYS = new Set(SMS_FIELD_GROUPS.flatMap((g) => g.keys))
/** Resolve the 中文 label for a raw_json key (falls back to the key). */
export function fieldLabel(key: string): string {
return SMS_FIELD_LABELS[key] ?? key
}
/**
* Read a field value with structured-column preference.
* Returns the verbatim raw_json value when no structured column maps.
*/
export function readField(s: Sms, camelKey: string): unknown {
const alias = STRUCTURED_ALIAS[camelKey]
if (alias) {
const v = s[alias]
if (v !== null && v !== undefined) return v
}
return s.raw_json?.[camelKey]
}
/** direction: 1 = 发送 (outbound), 0 = 接收 (inbound), other values pass through. */
function smsDirectionLabel(v: unknown): string {
if (v === 1 || v === '1') return '发送'
if (v === 0 || v === '0') return '接收'
return String(v)
}
/** A single field prepared for drawer rendering. */
export interface DisplayField {
key: string
label: string
/** Raw value (for text fallback). */
value: unknown
/** How the template should render it. */
render: 'time' | 'direction' | 'text' | 'empty'
/** Pre-formatted text (for non-empty kinds). Empty for empty. */
text: string
}
/** Classify + format a single field for the drawer. */
export function toDisplayField(s: Sms, camelKey: string): DisplayField {
const value = readField(s, camelKey)
const label = fieldLabel(camelKey)
if (value === null || value === undefined || value === '') {
return { key: camelKey, label, value, render: 'empty', text: '' }
}
if (TIMESTAMP_KEYS.has(camelKey) && typeof value === 'number') {
return { key: camelKey, label, value, render: 'time', text: formatTime(value) }
}
if (DIRECTION_KEYS.has(camelKey)) {
return {
key: camelKey,
label,
value,
render: 'direction',
text: smsDirectionLabel(value),
}
}
return { key: camelKey, label, value, render: 'text', text: String(value) }
}
/**
* Build the grouped display model for an SMS row.
*
* Each known group only includes fields that have a value (empty ones are
* dropped to keep the drawer compact). A trailing 其他 section collects every
* raw_json key not already surfaced, so new upstream fields show up
* automatically instead of being silently hidden.
*/
export interface SmsFieldSection {
title: string
fields: DisplayField[]
}
export function buildSmsSections(s: Sms): SmsFieldSection[] {
const sections: SmsFieldSection[] = SMS_FIELD_GROUPS.map((g) => ({
title: g.title,
fields: g.keys
.map((k) => toDisplayField(s, k))
.filter((f) => f.render !== 'empty'),
}))
// 其他: every raw_json key not already shown, in original insertion order.
const raw = s.raw_json ?? {}
const others: DisplayField[] = []
for (const key of Object.keys(raw)) {
if (KNOWN_KEYS.has(key)) continue
const f = toDisplayField(s, key)
if (f.render === 'empty') continue
others.push(f)
}
if (others.length > 0) {
sections.push({ title: '其他字段', fields: others })
}
return sections.filter((sec) => sec.fields.length > 0)
}
+162 -4
View File
@@ -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>
+226 -22
View File
@@ -6,6 +6,7 @@ import { getGroups } from '@/api'
import type { Channel, Group } from '@/api/types'
import { useChannelStore } from '@/stores/channel'
import { channelLabel, formatTime } from '@/utils/format'
import { buildGroupSections, readField } from '@/utils/groupFields'
const router = useRouter()
const channelStore = useChannelStore()
@@ -17,6 +18,10 @@ const pageSize = ref(20)
const loading = ref(false)
const errorMsg = ref<string | null>(null)
// All-fields drawer state.
const drawerOpen = ref(false)
const drawerGroup = ref<Group | null>(null)
const channelOptions = [
{ value: 'all' as const, label: '全部渠道' },
{ value: 'wechat' as const, label: '微信' },
@@ -74,9 +79,52 @@ watch(
},
)
function openTimeline(row: Group): void {
// Group messages are queried by room_id; the timeline view branches on the
// presence of a `roomId` route param to issue a room_id query.
/**
* Read a raw_json camelCase key off a group, 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: Group, camelKey: string): unknown {
return readField(row, camelKey)
}
function cellText(row: Group, camelKey: string): string {
const v = cell(row, camelKey)
return v === null || v === undefined || v === '' ? '--' : String(v)
}
function imageCell(row: Group, camelKey: string): string | undefined {
const v = cell(row, camelKey)
return typeof v === 'string' && v !== '' ? v : undefined
}
function timeCell(row: Group, camelKey: string): number | undefined {
const v = cell(row, camelKey)
return typeof v === 'number' ? v : undefined
}
function openDrawer(row: Group): void {
// Row click opens the all-fields drawer; the timeline is reached from the
// drawer's 查看聊天记录 action (preserves the owner/room/channel keying).
drawerGroup.value = row
drawerOpen.value = true
}
const drawerSections = computed(() =>
drawerGroup.value ? buildGroupSections(drawerGroup.value) : [],
)
const drawerAvatarSrc = computed<string | undefined>(() => {
const g = drawerGroup.value
if (!g) return undefined
return imageCell(g, 'headImageUrl') ?? undefined
})
const drawerCanChat = computed(() => !!drawerGroup.value?.owner_account_id)
function goChatFromDrawer(): void {
const row = drawerGroup.value
if (!row) return
if (!row.owner_account_id) {
ElMessage.warning('该群缺少归属账号,无法查看聊天记录')
return
@@ -138,11 +186,50 @@ onMounted(load)
style="width: 100%"
:row-class-name="() => 'yk-clickable-row'"
empty-text="暂无群数据"
@row-click="openTimeline"
@row-click="openDrawer"
>
<el-table-column label="群名" min-width="180">
<template #default="{ row }">
{{ row.room_name || row.room_id }}
{{ row.room_name || cellText(row, 'chatRoomName') || row.room_id }}
</template>
</el-table-column>
<el-table-column label="群ID" min-width="200" show-overflow-tooltip>
<template #default="{ row }">
<span style="font-family: monospace; font-size: 12px">{{ row.room_id }}</span>
</template>
</el-table-column>
<el-table-column label="成员数" prop="member_count" width="90" align="right">
<template #default="{ row }">{{ row.member_count ?? '--' }}</template>
</el-table-column>
<el-table-column label="群主" min-width="140">
<template #default="{ row }">
<span v-if="cell(row, 'userName') || cell(row, 'userPhone')">
<template v-if="cell(row, 'userName')">{{ cellText(row, 'userName') }}</template>
<span
v-if="cell(row, 'userName') && cell(row, 'userPhone')"
style="color: #c0c4cc"
>
/
</span>
<template v-if="cell(row, 'userPhone')">{{ cellText(row, 'userPhone') }}</template>
</span>
<span v-else style="color: #c0c4cc">--</span>
</template>
</el-table-column>
<el-table-column label="归属微信" min-width="130">
<template #default="{ row }">
<span v-if="row.owner_account_nickname">{{ row.owner_account_nickname }}</span>
<span v-else-if="cell(row, 'wechatNickName')">{{ cellText(row, 'wechatNickName') }}</span>
<span v-else-if="row.owner_account_id">{{ row.owner_account_id }}</span>
<span v-else style="color: #c0c4cc">--</span>
</template>
</el-table-column>
<el-table-column label="消息数" prop="message_count" width="90" align="right">
<template #default="{ row }">{{ row.message_count ?? '--' }}</template>
</el-table-column>
<el-table-column label="最近聊天" width="160">
<template #default="{ row }">
{{ formatTime(row.last_chat_time ?? timeCell(row, 'lastChatTime')) }}
</template>
</el-table-column>
<el-table-column label="渠道" width="100">
@@ -152,22 +239,6 @@ onMounted(load)
</el-tag>
</template>
</el-table-column>
<el-table-column label="成员数" prop="member_count" width="90" align="right">
<template #default="{ row }">{{ row.member_count ?? '--' }}</template>
</el-table-column>
<el-table-column label="消息数" prop="message_count" width="90" align="right">
<template #default="{ row }">{{ row.message_count ?? '--' }}</template>
</el-table-column>
<el-table-column label="群主" min-width="140">
<template #default="{ row }">
<span v-if="row.owner_account_nickname">{{ row.owner_account_nickname }}</span>
<span v-else-if="row.owner_account_id">{{ row.owner_account_id }}</span>
<span v-else style="color: #c0c4cc">--</span>
</template>
</el-table-column>
<el-table-column label="最近聊天" width="160">
<template #default="{ row }">{{ formatTime(row.last_chat_time) }}</template>
</el-table-column>
</el-table>
<div class="yk-groups__pager">
@@ -182,7 +253,67 @@ onMounted(load)
@size-change="handlePageSizeChange"
/>
</div>
<p class="yk-groups__hint">点击任意一行查看该群的聊天记录。</p>
<p class="yk-groups__hint">点击任意一行查看群全部字段。</p>
<!-- All-fields drawer -->
<el-drawer
v-model="drawerOpen"
:title="drawerGroup ? `群详情 · ${drawerGroup.room_name ?? drawerGroup.room_id}` : '群详情'"
direction="rtl"
size="560px"
>
<template v-if="drawerGroup">
<div class="yk-drawer__topbar">
<el-button
type="primary"
size="small"
:disabled="!drawerCanChat"
@click="goChatFromDrawer"
>
查看聊天记录
</el-button>
<span v-if="!drawerCanChat" class="yk-drawer__topbar-hint">
该群缺少归属账号,无法查看聊天
</span>
</div>
<div class="yk-drawer__avatar">
<el-avatar :size="56" :src="drawerAvatarSrc" shape="square">
{{ (drawerGroup.room_name || drawerGroup.room_id || '?').slice(0, 1) }}
</el-avatar>
<div class="yk-drawer__avatar-meta">
<div class="yk-drawer__avatar-name">
{{ drawerGroup.room_name || drawerGroup.room_id }}
</div>
<div class="yk-drawer__avatar-sub">
{{ channelLabel(drawerGroup.channel) }}
<template v-if="cell(drawerGroup, 'wechatNickName')">
· {{ cell(drawerGroup, 'wechatNickName') }}
</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 === 'image'" class="yk-drawer__img">
<el-image
:src="String(f.value)"
:preview-src-list="[String(f.value)]"
fit="cover"
hide-on-click-modal
/>
</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>
@@ -219,4 +350,77 @@ onMounted(load)
font-size: 12px;
color: #c0c4cc;
}
.yk-drawer__topbar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.yk-drawer__topbar-hint {
color: #c0c4cc;
font-size: 12px;
}
.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__img :deep(.el-image) {
width: 64px;
height: 64px;
border-radius: 4px;
border: 1px solid var(--el-border-color-light, #e4e7ed);
}
.yk-drawer__empty {
color: #c0c4cc;
}
</style>
+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>