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:
@@ -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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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