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>
249 lines
7.4 KiB
TypeScript
249 lines
7.4 KiB
TypeScript
/**
|
|
* Chinese-label map + grouping for the Account all-fields drawer.
|
|
*
|
|
* Mirrors the Customer approach (see customerFields.ts). The 云客 upstream
|
|
* account records (`WechatAccount.raw_json` / `WhatsappAccount.raw_json`)
|
|
* carry the verbatim wechatweb/qqweb account-list payload (wechatId / uid /
|
|
* nickName / alias / userName / sendNum / receiveNum / dbSize /
|
|
* onlineStatus / lastUpdate / talkerCount / friendAddNum / ...). This module
|
|
* turns that blob into a labeled, grouped, render-ready structure for the
|
|
* drawer opened by clicking an account row.
|
|
*
|
|
* Structured columns are preferred over the verbatim raw_json value when both
|
|
* exist. Unknown keys fall back to the raw key under 其他, so a new field
|
|
* added upstream still shows up.
|
|
*/
|
|
import type { Account } from '@/api/types'
|
|
import { formatTime } from './format'
|
|
|
|
/** camelCase raw_json key → 中文 label. Missing keys fall back to the key. */
|
|
export const ACCOUNT_FIELD_LABELS: Record<string, string> = {
|
|
// 标识
|
|
wechatId: '账号ID',
|
|
uid: '账号ID',
|
|
channel: '渠道',
|
|
nickName: '昵称',
|
|
alias: '微信号',
|
|
headImgUrl: '头像',
|
|
// 归属员工
|
|
userName: '员工姓名',
|
|
userId: '员工ID',
|
|
user_id: '员工ID',
|
|
user_name: '员工姓名',
|
|
phone: '手机',
|
|
salePhone: '销售手机',
|
|
sale_phone: '销售手机',
|
|
salesBindPhone: '绑定手机',
|
|
deptId: '部门ID',
|
|
deptName: '部门',
|
|
// 业务统计
|
|
contactsNum: '好友数',
|
|
groupNum: '群数',
|
|
sendNum: '发送消息数',
|
|
receiveNum: '接收消息数',
|
|
talkerCount: '会话数',
|
|
friendAddNum: '新增好友数',
|
|
dbSize: '本地库大小',
|
|
// 状态与时间
|
|
isOnJob: '在职',
|
|
is_on_job: '在职',
|
|
onlineStatus: '在线状态',
|
|
lastUpdate: '最后更新',
|
|
// 其他 / 内部
|
|
version: '版本号',
|
|
}
|
|
|
|
/**
|
|
* raw_json camelCase key → the structured snake_case column that mirrors it.
|
|
* When a structured column is populated we prefer it; we fall back to the
|
|
* verbatim raw_json value otherwise.
|
|
*/
|
|
const STRUCTURED_ALIAS: Record<string, keyof Account> = {
|
|
wechatId: 'account_id',
|
|
uid: 'account_id',
|
|
channel: 'channel',
|
|
nickName: 'nickname',
|
|
userName: 'user_name',
|
|
user_name: 'user_name',
|
|
userId: 'user_id',
|
|
user_id: 'user_id',
|
|
phone: 'phone',
|
|
contactsNum: 'contacts_num',
|
|
groupNum: 'group_num',
|
|
isOnJob: 'is_on_job',
|
|
is_on_job: 'is_on_job',
|
|
}
|
|
|
|
/** Fields whose value is an image URL → render as a thumbnail. */
|
|
const IMAGE_KEYS = new Set(['headImgUrl'])
|
|
|
|
/** Fields whose value is a millisecond epoch → format to local datetime. */
|
|
const TIMESTAMP_KEYS = new Set(['lastUpdate'])
|
|
|
|
/** Fields whose value is a boolean / 0|1 flag → render 是/否. */
|
|
const BOOLEAN_KEYS = new Set(['isOnJob', 'is_on_job'])
|
|
|
|
/** Drawer sections, rendered in this order. */
|
|
export interface AccountFieldGroup {
|
|
title: string
|
|
/** raw_json / snake_case keys to surface in this section. */
|
|
keys: string[]
|
|
}
|
|
|
|
export const ACCOUNT_FIELD_GROUPS: AccountFieldGroup[] = [
|
|
{
|
|
title: '基本信息',
|
|
keys: ['wechatId', 'uid', 'channel', 'nickName', 'alias', 'headImgUrl'],
|
|
},
|
|
{
|
|
title: '归属员工',
|
|
keys: [
|
|
'userName',
|
|
'user_name',
|
|
'userId',
|
|
'user_id',
|
|
'phone',
|
|
'salePhone',
|
|
'sale_phone',
|
|
'salesBindPhone',
|
|
'deptId',
|
|
'deptName',
|
|
],
|
|
},
|
|
{
|
|
title: '业务统计',
|
|
keys: [
|
|
'contactsNum',
|
|
'groupNum',
|
|
'sendNum',
|
|
'receiveNum',
|
|
'talkerCount',
|
|
'friendAddNum',
|
|
'dbSize',
|
|
],
|
|
},
|
|
{
|
|
title: '状态与时间',
|
|
keys: ['isOnJob', 'is_on_job', 'onlineStatus', 'lastUpdate', 'version'],
|
|
},
|
|
]
|
|
|
|
/** Every key already surfaced via a group (so 其他 can skip duplicates). */
|
|
const KNOWN_KEYS = new Set(ACCOUNT_FIELD_GROUPS.flatMap((g) => g.keys))
|
|
|
|
/** Resolve the 中文 label for a key (falls back to the key). */
|
|
export function fieldLabel(key: string): string {
|
|
return ACCOUNT_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(a: Account, camelKey: string): unknown {
|
|
const alias = STRUCTURED_ALIAS[camelKey]
|
|
if (alias) {
|
|
const v = a[alias]
|
|
if (v !== null && v !== undefined) return v
|
|
}
|
|
return a.raw_json?.[camelKey]
|
|
}
|
|
|
|
/** 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: 'image' | 'time' | 'bool' | 'text' | 'empty'
|
|
/** Pre-formatted text. Empty for image/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 a byte count as a human-readable string (KB/MB/GB). */
|
|
export function formatBytes(v: unknown): string {
|
|
if (typeof v !== 'number' || !Number.isFinite(v) || v < 0) return String(v)
|
|
if (v < 1024) return `${v} B`
|
|
const units = ['KB', 'MB', 'GB', 'TB']
|
|
let size = v / 1024
|
|
let i = 0
|
|
while (size >= 1024 && i < units.length - 1) {
|
|
size /= 1024
|
|
i++
|
|
}
|
|
return `${size.toFixed(size >= 10 ? 0 : 1)} ${units[i]}`
|
|
}
|
|
|
|
/** Classify + format a single field for the drawer. */
|
|
export function toDisplayField(a: Account, camelKey: string): DisplayField {
|
|
const value = readField(a, 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) }
|
|
}
|
|
if (camelKey === 'dbSize' && typeof value === 'number') {
|
|
return {
|
|
key: camelKey,
|
|
label,
|
|
value,
|
|
render: 'text',
|
|
text: formatBytes(value),
|
|
}
|
|
}
|
|
return { key: camelKey, label, value, render: 'text', text: String(value) }
|
|
}
|
|
|
|
/**
|
|
* Build the grouped display model for an account.
|
|
*
|
|
* 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 AccountFieldSection {
|
|
title: string
|
|
fields: DisplayField[]
|
|
}
|
|
|
|
export function buildAccountSections(a: Account): AccountFieldSection[] {
|
|
const sections: AccountFieldSection[] = ACCOUNT_FIELD_GROUPS.map((g) => ({
|
|
title: g.title,
|
|
fields: g.keys
|
|
.map((k) => toDisplayField(a, k))
|
|
.filter((f) => f.render !== 'empty'),
|
|
}))
|
|
|
|
// 其他: every raw_json key not already shown, in original insertion order.
|
|
const raw = a.raw_json ?? {}
|
|
const others: DisplayField[] = []
|
|
for (const key of Object.keys(raw)) {
|
|
if (KNOWN_KEYS.has(key)) continue
|
|
const f = toDisplayField(a, 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)
|
|
}
|