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