6ac47a1203
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>
263 lines
7.6 KiB
TypeScript
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)
|
|
}
|