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:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user