/** * Chinese-label map + grouping for the Message all-fields drawer. * * Mirrors the Customer approach (see customerFields.ts): the 云客 upstream * message record (`Message.raw_json`) carries ~30 camelCase fields (msgSvrId/ * keyId, type, file/fileTh/fileHd, md5/md5Th/md5Hd, paymsgid, referMsg, * talker/oriTalker, name/headUrl, wechatId, roomid, isSend, ...). This module * turns that blob into a labeled, grouped, render-ready structure for the * drawer opened by clicking a chat bubble. * * 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 { Message } from '@/api/types' import { formatTime } from './format' /** camelCase raw_json key → 中文 label. Missing keys fall back to the key. */ export const MESSAGE_FIELD_LABELS: Record = { // 标识 msgSvrId: '消息ID', keyId: '消息ID', type: '类型', isSend: '是否发出', mine: '是否发出', isDel: '已删除', // 内容 text: '文字内容', content: '文字内容', transcriptionText: '语音转文字', paymsgid: '转账ID', referMsg: '引用消息', // 媒体 file: '媒体地址', fileTh: '缩略图', fileHd: '高清图', imgPath: '图片路径', originalImage: '原图路径', picType: '图片类型', md5: 'MD5', md5Th: 'MD5(缩略图)', md5Hd: 'MD5(高清)', duration: '时长(秒)', // 发送者与归属 talker: '对方', oriTalker: '对方(原始)', name: '发送者', headUrl: '发送者头像', wechatId: '归属微信', roomid: '群ID', // 时间与元数据 timestamp: '时间', createTime: '时间', 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 = { msgSvrId: 'msg_id', keyId: 'msg_id', type: 'type', text: 'text', content: 'text', transcriptionText: 'transcription_text', file: 'file_url', fileTh: 'file_th_url', fileHd: 'file_hd_url', md5: 'md5', duration: 'duration', talker: 'talker_id', oriTalker: 'talker_id', name: 'sender_name', headUrl: 'sender_head_url', roomid: 'room_id', timestamp: 'timestamp', createTime: 'timestamp', isDel: 'is_del', } /** Fields whose value is an image URL → render as a thumbnail. */ const IMAGE_KEYS = new Set([ 'file', 'fileTh', 'fileHd', 'imgPath', 'originalImage', 'headUrl', ]) /** Fields whose value is a millisecond epoch → format to local datetime. */ const TIMESTAMP_KEYS = new Set(['timestamp', 'createTime']) /** Fields whose value is a boolean / 0|1 flag → render 是/否. */ const BOOLEAN_KEYS = new Set(['isSend', 'mine', 'isDel']) /** Drawer sections, rendered in this order. */ export interface MessageFieldGroup { title: string /** camelCase raw_json keys to surface in this section. */ keys: string[] } export const MESSAGE_FIELD_GROUPS: MessageFieldGroup[] = [ { title: '标识', keys: ['msgSvrId', 'keyId', 'type', 'isSend', 'mine', 'isDel'], }, { title: '内容', keys: ['text', 'content', 'transcriptionText', 'paymsgid', 'referMsg'], }, { title: '媒体', keys: [ 'file', 'fileTh', 'fileHd', 'imgPath', 'originalImage', 'picType', 'md5', 'md5Th', 'md5Hd', 'duration', ], }, { title: '发送者与归属', keys: [ 'talker', 'oriTalker', 'name', 'headUrl', 'wechatId', 'roomid', ], }, { title: '时间与元数据', keys: ['timestamp', 'createTime', 'version'], }, ] /** Every key already surfaced via a group (so 其他 can skip duplicates). */ const KNOWN_KEYS = new Set(MESSAGE_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 MESSAGE_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(m: Message, camelKey: string): unknown { const alias = STRUCTURED_ALIAS[camelKey] if (alias) { const v = m[alias] if (v !== null && v !== undefined) return v } return m.raw_json?.[camelKey] } /** A single field prepared for drawer rendering. */ export interface DisplayField { key: string label: string /** Raw value (for text fallback / object rendering). */ value: unknown /** How the template should render it. */ render: 'image' | 'time' | 'bool' | 'object' | 'text' | 'empty' /** Pre-formatted text (for time/bool/text kinds). Empty for image/object/empty. */ text: string } /** 0|1|true|false → 是/否; other ints pass through so info isn't hidden. */ export function yesNoLabel(v: unknown): string { if (v === true || v === 1 || v === '1') return '是' if (v === false || v === 0 || v === '0') return '否' return String(v) } /** Render an object/array value as indented JSON for the drawer. */ export function formatJson(value: unknown): string { try { return JSON.stringify(value, null, 2) } catch { return String(value) } } /** Classify + format a single field for the drawer. */ export function toDisplayField(m: Message, camelKey: string): DisplayField { const value = readField(m, 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) } } if (BOOLEAN_KEYS.has(camelKey)) { return { key: camelKey, label, value, render: 'bool', text: yesNoLabel(value) } } // referMsg + any object/array → pretty-printed JSON block. if (typeof value === 'object') { return { key: camelKey, label, value, render: 'object', text: formatJson(value), } } return { key: camelKey, label, value, render: 'text', text: String(value) } } /** * Build the grouped display model for a message. * * 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 MessageFieldSection { title: string fields: DisplayField[] } export function buildMessageSections(m: Message): MessageFieldSection[] { const sections: MessageFieldSection[] = MESSAGE_FIELD_GROUPS.map((g) => ({ title: g.title, fields: g.keys .map((k) => toDisplayField(m, k)) .filter((f) => f.render !== 'empty'), })) // 其他: every raw_json key not already shown, in original insertion order. const raw = m.raw_json ?? {} const others: DisplayField[] = [] for (const key of Object.keys(raw)) { if (KNOWN_KEYS.has(key)) continue const f = toDisplayField(m, 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) }