Files
yunke/yunkes/src/utils/accountFields.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

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)
}