Files
yunke/yunkes/src/utils/smsFields.ts
T
浅念 0d466c4e4a 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>
2026-08-05 10:12:49 +08:00

193 lines
5.9 KiB
TypeScript

/**
* 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)
}