Files
yunke/yunkes/src/utils/messageFields.ts
T
浅念 6ac47a1203 feat: full-field display for message/staff/accounts (raw_json + drawers)
Replicates the Customer all-fields pattern across the remaining three
entities. Backend now exposes `raw_json` on MessageOut / StaffOut /
AccountOut so the frontend can render every 云客 source field without a
second endpoint. Each entity gains a Chinese-label field utility + an
el-drawer surfacing all fields (structured-first, raw_json fallback,
unknown keys under 其他).

Backend:
- schemas.py: add `raw_json: dict | None` to MessageOut / StaffOut /
  AccountOut. MessageOut/StaffOut pick the column up via from_attributes;
  AccountOut is built explicitly in the router, which now threads
  `raw_json` through from both WechatAccount and WhatsappAccount rows.
- tests: new raw_json serialization tests for messages, staff, accounts
  (assert populated dict round-trips verbatim + NULL serializes as null).

Frontend (yunkes/):
- New utils: messageFields.ts, staffFields.ts, accountFields.ts — each
  with a 中文 label map, ordered groups, structured-column preference,
  and an 其他 fallback for unknown raw_json keys.
- Message: clicking a chat bubble opens an all-fields drawer (referMsg
  renders as pretty-printed JSON). Image preview + file download links
  stopPropagation so their own interactions still work.
- Staff: row click → drawer; columns relabeled (员工ID, 销售手机).
- Accounts: new AccountList.vue at /accounts with a 账号 nav item,
  unified WeChat/WhatsApp table (渠道/账号ID/昵称/微信号/归属员工/好友数/
  群数/收发/在职/最后更新), channel filter, pagination, row-click drawer.
- api: add Account type + getAccounts(); add raw_json to Message/Staff.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-01 17:47:50 +08:00

263 lines
7.6 KiB
TypeScript

/**
* 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<string, string> = {
// 标识
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<string, keyof Message> = {
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)
}