diff --git a/yunke-server/app/api/routers/organization.py b/yunke-server/app/api/routers/organization.py index 20dd321..e283144 100644 --- a/yunke-server/app/api/routers/organization.py +++ b/yunke-server/app/api/routers/organization.py @@ -111,6 +111,7 @@ async def list_accounts( contacts_num=r.contacts_num, group_num=r.group_num, is_on_job=r.is_on_job, + raw_json=r.raw_json, ) ) if "whatsapp" in selected_channels: @@ -127,6 +128,7 @@ async def list_accounts( contacts_num=r.contacts_num, group_num=r.group_num, is_on_job=r.is_on_job, + raw_json=r.raw_json, ) ) diff --git a/yunke-server/app/api/schemas.py b/yunke-server/app/api/schemas.py index d4c2ba1..f2db291 100644 --- a/yunke-server/app/api/schemas.py +++ b/yunke-server/app/api/schemas.py @@ -68,7 +68,12 @@ class DepartmentOut(BaseModel): class StaffOut(BaseModel): - """Staff member (employee) row.""" + """Staff member (employee) row. + + The structured columns power the list table; the verbatim upstream record + is exposed as ``raw_json`` so the frontend can render the all-fields drawer + without a second endpoint. Picked up automatically via ``from_attributes``. + """ model_config = ConfigDict(from_attributes=True) @@ -78,6 +83,7 @@ class StaffOut(BaseModel): sale_phone: str | None = None dept_id: str | None = None is_on_job: bool | None = None + raw_json: dict[str, Any] | None = None class AccountOut(BaseModel): @@ -87,7 +93,9 @@ class AccountOut(BaseModel): have different PK / column names; this view projects both onto a single shape keyed by ``channel`` + ``account_id`` so the frontend can render one list. Built explicitly in the route handler (not via ``from_attributes``) - because no single ORM row carries this schema. + because no single ORM row carries this schema. ``raw_json`` carries the + verbatim upstream record (wechatId/alias/sendNum/receiveNum/dbSize/...) so + the all-fields drawer can render every column the 云客 微信号管理 page shows. """ channel: str @@ -99,6 +107,7 @@ class AccountOut(BaseModel): contacts_num: int | None = None group_num: int | None = None is_on_job: bool | None = None + raw_json: dict[str, Any] | None = None # --------------------------------------------------------------------------- @@ -107,7 +116,14 @@ class AccountOut(BaseModel): class MessageOut(BaseModel): - """Chat timeline row — a single message (text/media/voice/...).""" + """Chat timeline row — a single message (text/media/voice/...). + + The structured columns power the bubble rendering; the verbatim upstream + record is exposed as ``raw_json`` so the frontend can render the all-fields + drawer (msgSvrId/keyId, file/fileTh/fileHd, md5/md5Th/md5Hd, paymsgid, + referMsg, imgPath, ...) without a second endpoint. Picked up automatically + via ``from_attributes``. + """ model_config = ConfigDict(from_attributes=True) @@ -132,6 +148,7 @@ class MessageOut(BaseModel): sender_name: str | None = None sender_head_url: str | None = None timestamp: int | None = None + raw_json: dict[str, Any] | None = None class GroupOut(BaseModel): diff --git a/yunke-server/tests/test_api_messages.py b/yunke-server/tests/test_api_messages.py index 905772c..852487e 100644 --- a/yunke-server/tests/test_api_messages.py +++ b/yunke-server/tests/test_api_messages.py @@ -79,10 +79,86 @@ async def test_default_page_returns_all_ordered_asc(make_client): "sender_name", "sender_head_url", "timestamp", + "raw_json", ): assert key in body["items"][0] +async def test_raw_json_serializes_verbatim(make_client): + """``raw_json`` round-trips the verbatim upstream message record. + + The seeded messages omit ``raw_json`` (NULL); this test seeds one with a + populated blob and asserts every camelCase key is preserved verbatim while + the existing rows still serialize as ``null``. + """ + + async def seed(session: AsyncSession) -> None: + await _seed(session) + session.add( + Message( + msg_id="m_raw", + channel="wechat", + owner_account_id="wx_owner_1", + talker_id="t_a", + room_id=None, + type="image", + type_raw=3, + timestamp=5000, + raw_json={ + "msgSvrId": "m_raw", + "type": 3, + "file": "https://oss/full.jpg", + "fileTh": "https://oss/th.jpg", + "fileHd": "https://oss/hd.jpg", + "md5": "abc123", + "md5Th": "th123", + "md5Hd": "hd123", + "duration": 0, + "talker": "t_a", + "oriTalker": "t_a", + "name": "Alice", + "headUrl": "https://oss/alice.jpg", + "wechatId": "wx_owner_1", + "roomid": "", + "isSend": 1, + "paymsgid": "", + "referMsg": {"text": "原文"}, + }, + ) + ) + await session.commit() + + client = await make_client(seed) + r = await client.get("/api/messages", params={"talker_id": "t_a"}) + assert r.status_code == 200 + by_id = {it["msg_id"]: it for it in r.json()["items"]} + + raw = by_id["m_raw"]["raw_json"] + assert raw == { + "msgSvrId": "m_raw", + "type": 3, + "file": "https://oss/full.jpg", + "fileTh": "https://oss/th.jpg", + "fileHd": "https://oss/hd.jpg", + "md5": "abc123", + "md5Th": "th123", + "md5Hd": "hd123", + "duration": 0, + "talker": "t_a", + "oriTalker": "t_a", + "name": "Alice", + "headUrl": "https://oss/alice.jpg", + "wechatId": "wx_owner_1", + "roomid": "", + "isSend": 1, + "paymsgid": "", + "referMsg": {"text": "原文"}, + } + + # Seeded messages without raw_json serialize as JSON null. + assert by_id["m1"]["raw_json"] is None + + async def test_filter_by_talker_id(make_client): """``?talker_id=t_a`` -> 2 messages for that 1:1 conversation.""" client = await make_client(_seed) diff --git a/yunke-server/tests/test_api_organization.py b/yunke-server/tests/test_api_organization.py index 843cde4..56c7c56 100644 --- a/yunke-server/tests/test_api_organization.py +++ b/yunke-server/tests/test_api_organization.py @@ -27,12 +27,21 @@ async def _seed(session: AsyncSession) -> None: dept_id="d1", is_on_job=True, phone="111", + # Verbatim upstream staff record — the all-fields drawer reads + # these camelCase keys off raw_json. + raw_json={ + "userId": "u1", + "userName": "Alice", + "sale_phone": "111", + "deptId": "d1", + }, ), Staff( user_id="u2", name="Bob", dept_id="d2", is_on_job=False, + raw_json=None, ), Staff(user_id="u3", name="Carol", dept_id="d1", is_on_job=True), WechatAccount( @@ -44,6 +53,15 @@ async def _seed(session: AsyncSession) -> None: contacts_num=10, group_num=2, is_on_job=True, + raw_json={ + "wechatId": "wx_a", + "nickName": "WxOne", + "alias": "alice_wx", + "sendNum": 120, + "receiveNum": 80, + "dbSize": 2048, + "onlineStatus": "online", + }, ), WechatAccount( wx_id="wx_b", @@ -51,6 +69,7 @@ async def _seed(session: AsyncSession) -> None: user_id="u2", contacts_num=5, is_on_job=False, + raw_json=None, ), WhatsappAccount( uid="wa_c@s.whatsapp.net", @@ -61,6 +80,12 @@ async def _seed(session: AsyncSession) -> None: contacts_num=7, group_num=1, is_on_job=True, + raw_json={ + "uid": "wa_c@s.whatsapp.net", + "nickName": "WaOne", + "talkerCount": 7, + "friendAddNum": 3, + }, ), ] ) @@ -92,10 +117,39 @@ async def test_staff_default_page_returns_all(make_client): assert body["page_size"] == 20 assert body["total"] == 3 assert len(body["items"]) == 3 - for key in ("user_id", "name", "phone", "sale_phone", "dept_id", "is_on_job"): + for key in ( + "user_id", + "name", + "phone", + "sale_phone", + "dept_id", + "is_on_job", + "raw_json", + ): assert key in body["items"][0] +async def test_staff_raw_json_serializes_verbatim(make_client): + """``raw_json`` round-trips the verbatim staff record unmolested. + + Seeded ``u1`` carries a populated blob; ``u2`` has NULL raw_json; ``u3`` + omits it entirely. All three must serialize (populated dict vs null). + """ + client = await make_client(_seed) + r = await client.get("/api/staff") + assert r.status_code == 200 + by_id = {it["user_id"]: it for it in r.json()["items"]} + + assert by_id["u1"]["raw_json"] == { + "userId": "u1", + "userName": "Alice", + "sale_phone": "111", + "deptId": "d1", + } + assert by_id["u2"]["raw_json"] is None + assert by_id["u3"]["raw_json"] is None + + async def test_staff_filter_by_department_id(make_client): """``?department_id=d1`` -> 2 staff (Alice + Carol).""" client = await make_client(_seed) @@ -162,6 +216,7 @@ async def test_accounts_default_returns_both_channels(make_client): "contacts_num", "group_num", "is_on_job", + "raw_json", ): assert key in item assert item["channel"] == "wechat" @@ -169,6 +224,40 @@ async def test_accounts_default_returns_both_channels(make_client): assert item["user_name"] == "Alice" +async def test_accounts_raw_json_serializes_verbatim(make_client): + """``raw_json`` round-trips the verbatim account record for both channels. + + Seeded ``wx_a`` and the whatsapp account carry populated blobs; ``wx_b`` + has NULL raw_json. All three must serialize (populated dict vs null). + """ + client = await make_client(_seed) + r = await client.get("/api/accounts") + assert r.status_code == 200 + by_id = {(it["channel"], it["account_id"]): it for it in r.json()["items"]} + + wx_a = by_id[("wechat", "wx_a")] + assert wx_a["raw_json"] == { + "wechatId": "wx_a", + "nickName": "WxOne", + "alias": "alice_wx", + "sendNum": 120, + "receiveNum": 80, + "dbSize": 2048, + "onlineStatus": "online", + } + + wa = by_id[("whatsapp", "wa_c@s.whatsapp.net")] + assert wa["raw_json"] == { + "uid": "wa_c@s.whatsapp.net", + "nickName": "WaOne", + "talkerCount": 7, + "friendAddNum": 3, + } + + # NULL raw_json serializes as JSON null. + assert by_id[("wechat", "wx_b")]["raw_json"] is None + + async def test_accounts_filter_channel_wechat(make_client): """``?channel=wechat`` -> only the 2 wechat accounts.""" client = await make_client(_seed) diff --git a/yunkes/src/api/index.ts b/yunkes/src/api/index.ts index 15300de..876dd41 100644 --- a/yunkes/src/api/index.ts +++ b/yunkes/src/api/index.ts @@ -5,6 +5,7 @@ // (the backend treats missing as "no filter"). import { getJson } from './client' import type { + Account, CallChannel, Channel, Customer, @@ -102,6 +103,24 @@ export function getStaff(query: StaffQuery = {}): Promise> { ) } +export interface AccountQuery { + /** channel filter; `'all'` (default) drops the param so both channels union. */ + channel?: Channel | 'all' + page?: number + page_size?: number +} + +export function getAccounts(query: AccountQuery = {}): Promise> { + return getJson>( + '/accounts', + clean({ + channel: query.channel === 'all' ? undefined : query.channel, + page: query.page ?? 1, + page_size: query.page_size ?? 20, + }), + ) +} + // --------------------------------------------------------------------------- // Chat groups // --------------------------------------------------------------------------- @@ -197,6 +216,7 @@ export function getSyncStatus(): Promise { } export type { + Account, CallChannel, Channel, Customer, diff --git a/yunkes/src/api/types.ts b/yunkes/src/api/types.ts index b40fae3..b709ab1 100644 --- a/yunkes/src/api/types.ts +++ b/yunkes/src/api/types.ts @@ -55,6 +55,30 @@ export interface Staff { sale_phone?: string | null dept_id?: string | null is_on_job?: boolean | null + /** + * Verbatim upstream staff record (camelCase keys). Surfaced so the + * all-fields drawer can render every column without enumerating each. + */ + raw_json?: Record | null +} + +/** + * Unified staff messaging account (mirrors backend AccountOut). The two source + * tables (wechat_account / whatsapp_account) are projected onto this single + * shape keyed by `channel` + `account_id`. `raw_json` carries the verbatim + * upstream record (wechatId/alias/sendNum/receiveNum/dbSize/...). + */ +export interface Account { + channel: Channel + account_id: string + nickname?: string | null + user_id?: string | null + user_name?: string | null + phone?: string | null + contacts_num?: number | null + group_num?: number | null + is_on_job?: boolean | null + raw_json?: Record | null } /** Chat group summary row (mirrors backend GroupOut). */ @@ -187,4 +211,11 @@ export interface Message { /** ms-epoch */ timestamp?: number | null is_del?: boolean | null + /** + * Verbatim upstream message record (camelCase keys: msgSvrId/keyId, type, + * file/fileTh/fileHd, md5/md5Th/md5Hd, paymsgid, referMsg, talker/oriTalker, + * name/headUrl, wechatId, roomid, ...). Surfaced so the all-fields drawer + * can render every column the 云客 chat detail page shows. + */ + raw_json?: Record | null } diff --git a/yunkes/src/components/MessageBubble.vue b/yunkes/src/components/MessageBubble.vue index 81d0fe8..c63d552 100644 --- a/yunkes/src/components/MessageBubble.vue +++ b/yunkes/src/components/MessageBubble.vue @@ -12,7 +12,14 @@ const props = defineProps<{ message: Message }>() * `/api/media/{hash}` streaming endpoint exists for cases where the OSS URL is * missing, but the current MessageOut schema exposes `file_url` only, so we * degrade gracefully via `file_th_url || file_url` (forward-compatible). + * + * Clicking anywhere on the bubble (other than interactive children such as the + * image preview or the file download link) emits `open-detail` so the parent + * timeline can open the all-fields drawer for this message. The image preview + * and download link stop propagation so their own interactions still work. */ +const emit = defineEmits<{ (e: 'open-detail', message: Message): void }>() + const isSend = computed(() => !!props.message.is_send) // Centered (system/divider) rows ignore direction. @@ -41,13 +48,15 @@ const imageThumbUrl = computed( 'yk-msg--center': isCentered, }" > - +
{{ message.text || (message.type === 'divider' ? '—— 分割线 ——' : '系统消息') }}
- -
+ +
{{ senderLabel }} {{ timeLabel }} @@ -59,7 +68,7 @@ const imageThumbUrl = computed(
-
+
-
+
{ 员工 + + + 账号 + 群 diff --git a/yunkes/src/router/index.ts b/yunkes/src/router/index.ts index d9faac0..9697daa 100644 --- a/yunkes/src/router/index.ts +++ b/yunkes/src/router/index.ts @@ -21,6 +21,12 @@ const routes: RouteRecordRaw[] = [ component: () => import('@/views/StaffList.vue'), meta: { title: '员工列表' }, }, + { + path: '/accounts', + name: 'accounts', + component: () => import('@/views/AccountList.vue'), + meta: { title: '账号列表' }, + }, { path: '/groups', name: 'groups', diff --git a/yunkes/src/style.css b/yunkes/src/style.css index 9026acb..581ba26 100644 --- a/yunkes/src/style.css +++ b/yunkes/src/style.css @@ -159,6 +159,18 @@ a { align-items: flex-end; } +/* Clickable bubble row — opens the all-fields drawer. Subtle hover so users + * discover the interaction without changing the bubble's own look. */ +.yk-msg__row--clickable { + cursor: pointer; + border-radius: 6px; + transition: background-color 0.15s ease; +} + +.yk-msg__row--clickable:hover { + background-color: var(--el-fill-color-light, #f5f7fa); +} + .yk-msg__meta { display: flex; gap: 8px; diff --git a/yunkes/src/utils/accountFields.ts b/yunkes/src/utils/accountFields.ts new file mode 100644 index 0000000..adcd01f --- /dev/null +++ b/yunkes/src/utils/accountFields.ts @@ -0,0 +1,248 @@ +/** + * 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) +} diff --git a/yunkes/src/utils/messageFields.ts b/yunkes/src/utils/messageFields.ts new file mode 100644 index 0000000..00bcaa3 --- /dev/null +++ b/yunkes/src/utils/messageFields.ts @@ -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 = { + // 标识 + 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 = { + 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) +} diff --git a/yunkes/src/utils/staffFields.ts b/yunkes/src/utils/staffFields.ts new file mode 100644 index 0000000..579abb5 --- /dev/null +++ b/yunkes/src/utils/staffFields.ts @@ -0,0 +1,171 @@ +/** + * Chinese-label map + grouping for the Staff all-fields drawer. + * + * Mirrors the Customer approach (see customerFields.ts). Staff rows are + * relatively sparse — the structured columns (user_id / name / phone / + * sale_phone / dept_id / is_on_job) are the main payload; raw_json carries + * whatever extra keys the upstream account-list payload embedded the staff + * record in (userId / userName / salePhone / deptId / ...). All such keys are + * surfaced under 其他 so a new upstream field still shows up. + */ +import type { Staff } from '@/api/types' + +/** camelCase / raw_json key → 中文 label. Missing keys fall back to the key. */ +export const STAFF_FIELD_LABELS: Record = { + // 基本信息 + user_id: '员工ID', + userId: '员工ID', + name: '姓名', + userName: '姓名', + // 联系方式 + phone: '手机', + sale_phone: '销售手机', + salePhone: '销售手机', + mobile: '手机', + // 部门与状态 + dept_id: '部门ID', + deptId: '部门ID', + deptName: '部门', + department: '部门', + is_on_job: '在职', + isOnJob: '在职', + onJob: '在职', + // 其他 / 内部 + version: '版本号', +} + +/** + * raw_json / snake_case 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 = { + user_id: 'user_id', + userId: 'user_id', + name: 'name', + userName: 'name', + phone: 'phone', + mobile: 'phone', + sale_phone: 'sale_phone', + salePhone: 'sale_phone', + dept_id: 'dept_id', + deptId: 'dept_id', + is_on_job: 'is_on_job', + isOnJob: 'is_on_job', + onJob: 'is_on_job', +} + +/** Fields whose value is a boolean / 0|1 flag → render 是/否. */ +const BOOLEAN_KEYS = new Set(['is_on_job', 'isOnJob', 'onJob']) + +/** Drawer sections, rendered in this order. */ +export interface StaffFieldGroup { + title: string + /** raw_json / snake_case keys to surface in this section. */ + keys: string[] +} + +export const STAFF_FIELD_GROUPS: StaffFieldGroup[] = [ + { + title: '基本信息', + keys: ['user_id', 'userId', 'name', 'userName'], + }, + { + title: '联系方式', + keys: ['phone', 'mobile', 'sale_phone', 'salePhone'], + }, + { + title: '部门与状态', + keys: ['dept_id', 'deptId', 'deptName', 'department', 'is_on_job', 'isOnJob', 'onJob'], + }, +] + +/** Every key already surfaced via a group (so 其他 can skip duplicates). */ +const KNOWN_KEYS = new Set(STAFF_FIELD_GROUPS.flatMap((g) => g.keys)) + +/** Resolve the 中文 label for a key (falls back to the key). */ +export function fieldLabel(key: string): string { + return STAFF_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(s: Staff, camelKey: string): unknown { + const alias = STRUCTURED_ALIAS[camelKey] + if (alias) { + const v = s[alias] + if (v !== null && v !== undefined) return v + } + return s.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: 'bool' | 'text' | 'empty' + /** Pre-formatted text. Empty for 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) +} + +/** Classify + format a single field for the drawer. */ +export function toDisplayField(s: Staff, camelKey: string): DisplayField { + const value = readField(s, camelKey) + const label = fieldLabel(camelKey) + if (value === null || value === undefined || value === '') { + return { key: camelKey, label, value, render: 'empty', text: '' } + } + if (BOOLEAN_KEYS.has(camelKey)) { + return { key: camelKey, label, value, render: 'bool', text: yesNoLabel(value) } + } + return { key: camelKey, label, value, render: 'text', text: String(value) } +} + +/** + * Build the grouped display model for a staff member. + * + * 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 StaffFieldSection { + title: string + fields: DisplayField[] +} + +export function buildStaffSections(s: Staff): StaffFieldSection[] { + const sections: StaffFieldSection[] = STAFF_FIELD_GROUPS.map((g) => ({ + title: g.title, + fields: g.keys + .map((k) => toDisplayField(s, k)) + .filter((f) => f.render !== 'empty'), + })) + + // 其他: every raw_json key not already shown, in original insertion order. + const raw = s.raw_json ?? {} + const others: DisplayField[] = [] + for (const key of Object.keys(raw)) { + if (KNOWN_KEYS.has(key)) continue + const f = toDisplayField(s, 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) +} diff --git a/yunkes/src/views/AccountList.vue b/yunkes/src/views/AccountList.vue new file mode 100644 index 0000000..5fc1f9f --- /dev/null +++ b/yunkes/src/views/AccountList.vue @@ -0,0 +1,367 @@ + + + + + diff --git a/yunkes/src/views/ChatTimeline.vue b/yunkes/src/views/ChatTimeline.vue index 9c60fac..a98818c 100644 --- a/yunkes/src/views/ChatTimeline.vue +++ b/yunkes/src/views/ChatTimeline.vue @@ -6,7 +6,8 @@ import { ArrowLeft } from '@element-plus/icons-vue' import { getMessages } from '@/api' import type { Channel, Message } from '@/api/types' import MessageBubble from '@/components/MessageBubble.vue' -import { channelLabel } from '@/utils/format' +import { channelLabel, formatTime } from '@/utils/format' +import { buildMessageSections } from '@/utils/messageFields' /** * Chat timeline. Reused for two conversation shapes: @@ -141,6 +142,26 @@ function goBack(): void { } } +// All-fields drawer state for the message detail view. +const drawerOpen = ref(false) +const drawerMessage = ref(null) + +function openDetail(m: Message): void { + drawerMessage.value = m + drawerOpen.value = true +} + +const drawerSections = computed(() => + drawerMessage.value ? buildMessageSections(drawerMessage.value) : [], +) + +const drawerTitle = computed(() => { + const m = drawerMessage.value + if (!m) return '消息详情' + const when = m.timestamp ? formatTime(m.timestamp) : '' + return `消息详情${when ? ` · ${when}` : ''}` +}) + // Reload when the route params change (navigating between conversations). watch( () => [props.channel, props.ownerId, props.talkerId, props.roomId] as const, @@ -200,8 +221,74 @@ onMounted(loadInitial) v-for="m in messages" :key="m.msg_id" :message="m" + @open-detail="openDetail" />
+ + + + +
@@ -213,4 +300,65 @@ onMounted(loadInitial) max-height: calc(100vh - var(--yk-header-height) - 140px); overflow-y: auto; } + +.yk-drawer__topmeta { + display: flex; + gap: 6px; + flex-wrap: wrap; + margin-bottom: 12px; +} + +.yk-drawer__kv { + margin: 0 0 8px; +} + +.yk-drawer__kv-item { + display: grid; + grid-template-columns: 110px 1fr; + gap: 8px 12px; + padding: 3px 0; +} + +.yk-drawer__kv dt { + color: #909399; + font-size: 13px; + word-break: break-all; +} + +.yk-drawer__kv dd { + margin: 0; + font-size: 13px; + color: #303133; + word-break: break-all; + min-width: 0; +} + +.yk-drawer__img :deep(.el-image) { + width: 64px; + height: 64px; + border-radius: 4px; + border: 1px solid var(--el-border-color-light, #e4e7ed); +} + +.yk-drawer__empty { + color: #c0c4cc; +} + +.yk-drawer__pre { + margin: 0; + min-width: 0; +} + +.yk-drawer__pre pre { + margin: 0; + padding: 8px; + background: var(--el-fill-color-light, #f5f7fa); + border-radius: 4px; + font-size: 12px; + color: #303133; + white-space: pre-wrap; + word-break: break-all; + max-height: 240px; + overflow: auto; +} diff --git a/yunkes/src/views/StaffList.vue b/yunkes/src/views/StaffList.vue index 4a726b1..b4fff4f 100644 --- a/yunkes/src/views/StaffList.vue +++ b/yunkes/src/views/StaffList.vue @@ -5,10 +5,15 @@ import { ElMessage } from 'element-plus' import { getStaff } from '@/api' import type { Staff } from '@/api/types' import { useOrgStore } from '@/stores/org' +import { buildStaffSections, readField } from '@/utils/staffFields' const route = useRoute() const org = useOrgStore() +// All-fields drawer state. +const drawerOpen = ref(false) +const drawerStaff = ref(null) + const staff = ref([]) const total = ref(0) const page = ref(1) @@ -98,6 +103,26 @@ onMounted(async () => { } await load() }) + +/** Read a raw_json camelCase key off a staff row, preferring the structured + * snake_case column when it is populated. */ +function cell(row: Staff, camelKey: string): unknown { + return readField(row, camelKey) +} + +function cellText(row: Staff, camelKey: string): string { + const v = cell(row, camelKey) + return v === null || v === undefined || v === '' ? '--' : String(v) +} + +function openDrawer(row: Staff): void { + drawerStaff.value = row + drawerOpen.value = true +} + +const drawerSections = computed(() => + drawerStaff.value ? buildStaffSections(drawerStaff.value) : [], +)