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>
This commit is contained in:
@@ -0,0 +1,262 @@
|
||||
/**
|
||||
* 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)
|
||||
}
|
||||
Reference in New Issue
Block a user