From 0d466c4e4a8ad97c4e7cf4055b616d6738d66a21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Wed, 5 Aug 2026 10:12:49 +0800 Subject: [PATCH] feat: full-field display for group/call/sms (raw_json + drawers) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the Customer/Message/Staff/Accounts pattern: backend exposes the verbatim upstream record on the response schema; the frontend list shows source-matching columns and a row-click opens an all-fields el-drawer with Chinese-labeled camelCase keys (unknown keys roll up under 其他). Backend (yunke-server/app/api/schemas.py) - GroupOut / PhoneCallOut / SmsOut: add raw_json: dict[str, Any] | None (Pydantic v2 from_attributes picks up the existing ORM column). - Tests: extend tests/test_api_{groups,calls,sms}.py with a per-schema raw_json round-trip assertion (phone_call + voice_call payload shapes both covered for calls). Frontend (yunkes) - src/utils/{groupFields,callFields,smsFields}.ts: 中文 label map + grouped sections + readField (structured-column preference) + toDisplayField (image / time / audio / bool / status / direction). - src/api/types.ts: add raw_json to Group / PhoneCall / Sms. - src/views/GroupList.vue: richer columns (群名/群ID/成员数/群主/归属微信/ 消息数/最近聊天/渠道) + all-fields drawer (chatRoomId/chatRoomName, memberCount/memberIds, headImageUrl, wechatId/wechatNickName, userName/userPhone/userId, messageCount/ownMessageCount, lossMembers/addMembers, lastChatTime/updateTime). - src/views/CallList.vue: keeps existing columns + adds all-fields drawer (id/startCallTime, callDuration/callSeconds/ringTime, callStatus/ incomingCall, userId/userIdName/departmentId, callNumber/simPhone/ simNumber/imei, recordFile/isUpload/downloadCount/aiAsr/qcStatus/ planCustomerName/version + voice-call extras ossFileName/duration/ startTime/endTime/remark). Drawer plays recording inline via audio render kind; existing play-record dialog retained. - src/views/SmsList.vue: keeps existing columns + adds all-fields drawer (id/customerPhone, sms/smsSub, sender, direction, userId/ senderDepartment, receiveName, createTime, simPhone/simNumber, source) with a full-body preview block at the top. Co-Authored-By: Claude --- yunke-server/app/api/schemas.py | 34 +++- yunke-server/tests/test_api_calls.py | 109 +++++++++++ yunke-server/tests/test_api_groups.py | 70 +++++++ yunke-server/tests/test_api_sms.py | 66 +++++++ yunkes/src/api/types.ts | 23 +++ yunkes/src/utils/callFields.ts | 259 ++++++++++++++++++++++++++ yunkes/src/utils/groupFields.ts | 191 +++++++++++++++++++ yunkes/src/utils/smsFields.ts | 192 +++++++++++++++++++ yunkes/src/views/CallList.vue | 166 ++++++++++++++++- yunkes/src/views/GroupList.vue | 248 +++++++++++++++++++++--- yunkes/src/views/SmsList.vue | 176 ++++++++++------- 11 files changed, 1442 insertions(+), 92 deletions(-) create mode 100644 yunkes/src/utils/callFields.ts create mode 100644 yunkes/src/utils/groupFields.ts create mode 100644 yunkes/src/utils/smsFields.ts diff --git a/yunke-server/app/api/schemas.py b/yunke-server/app/api/schemas.py index f2db291..3526c69 100644 --- a/yunke-server/app/api/schemas.py +++ b/yunke-server/app/api/schemas.py @@ -152,7 +152,15 @@ class MessageOut(BaseModel): class GroupOut(BaseModel): - """Chat group summary row.""" + """Chat group summary 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 + (chatRoomId/chatRoomName, memberIds, headImageUrl, wechatId/wechatNickName, + userName/userPhone, messageCount/ownMessageCount, lossMembers/addMembers, + lastChatTime/updateTime, ...) without a second endpoint. Picked up + automatically via ``from_attributes``. + """ model_config = ConfigDict(from_attributes=True) @@ -164,6 +172,7 @@ class GroupOut(BaseModel): member_count: int | None = None message_count: int | None = None last_chat_time: int | None = None + raw_json: dict[str, Any] | None = None class GroupMemberOut(BaseModel): @@ -180,7 +189,17 @@ class GroupMemberOut(BaseModel): class PhoneCallOut(BaseModel): - """Phone / voice call record. ``channel`` is the call-type.""" + """Phone / voice call record. ``channel`` is the call-type. + + 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 + (id/startCallTime, callDuration/callSeconds, ringTime, callStatus, + incomingCall, userId/userIdName, departmentId, callNumber, + simPhone/simNumber/imei, recordFile, isUpload, downloadCount, aiAsr, + qcStatus, planCustomerName, version + voice-call extras ossFileName/ + duration/startTime/endTime/remark) without a second endpoint. Picked up + automatically via ``from_attributes``. + """ model_config = ConfigDict(from_attributes=True) @@ -197,10 +216,18 @@ class PhoneCallOut(BaseModel): # OSS URL of the call recording — surfaced so the Calls view can offer a # "播放录音" action when present. record_file: str | None = None + raw_json: dict[str, Any] | None = None class SmsOut(BaseModel): - """SMS log row.""" + """SMS log 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 + (id/customerPhone, sms/smsSub, sender, direction, userId, + senderDepartment, receiveName, createTime, simPhone/simNumber, source) + without a second endpoint. Picked up automatically via ``from_attributes``. + """ model_config = ConfigDict(from_attributes=True) @@ -216,6 +243,7 @@ class SmsOut(BaseModel): receive_name: str | None = None create_time: int | None = None sim_phone: str | None = None + raw_json: dict[str, Any] | None = None # --------------------------------------------------------------------------- diff --git a/yunke-server/tests/test_api_calls.py b/yunke-server/tests/test_api_calls.py index 2b6fced..eaf93e2 100644 --- a/yunke-server/tests/test_api_calls.py +++ b/yunke-server/tests/test_api_calls.py @@ -73,10 +73,119 @@ async def test_default_list_returns_all_ordered_desc(make_client): "call_number", "sim_phone", "record_file", + "raw_json", ): assert key in body["items"][0] +async def test_raw_json_serializes_verbatim(make_client): + """``raw_json`` round-trips the verbatim upstream call record. + + Phone-call and voice-call payloads carry different camelCase shapes; both + must come back unmolested so the all-fields drawer can render every column + the 云客 通话记录 page shows. Rows without a blob serialize as JSON null. + """ + + async def seed(session: AsyncSession) -> None: + await _seed(session) + session.add_all( + [ + PhoneCall( + call_id="c_phone_raw", + channel="phone_call", + user_id="u1", + user_id_name="Alice", + start_call_time=9000, + call_duration=12, + call_status=2, + incoming_call=0, + raw_json={ + "id": "c_phone_raw", + "startCallTime": 9000, + "callDuration": 12, + "callSeconds": 12, + "ringTime": 3, + "callStatus": 2, + "incomingCall": 0, + "userId": "u1", + "userIdName": "Alice", + "departmentId": "d1", + "callNumber": "13800000000", + "simPhone": "13900000000", + "simNumber": "1", + "imei": "356938035643809", + "recordFile": "https://oss/c_phone_raw.mp3", + "isUpload": 1, + "downloadCount": 2, + "aiAsr": "translated text", + "qcStatus": 1, + "planCustomerName": "Carol", + "version": "v1", + }, + ), + PhoneCall( + call_id="c_voice_raw", + channel="voice_call", + user_id="u2", + user_id_name="Bob", + start_call_time=9500, + call_duration=20, + raw_json={ + "ossFileName": "voice/c_voice_raw.mp3", + "duration": 20, + "startTime": 9500, + "endTime": 9520, + "remark": "voice memo", + "userId": "u2", + }, + ), + ] + ) + await session.commit() + + client = await make_client(seed) + r = await client.get("/api/calls", params={"start": 9000}) + assert r.status_code == 200 + by_id = {it["call_id"]: it for it in r.json()["items"]} + + assert by_id["c_phone_raw"]["raw_json"] == { + "id": "c_phone_raw", + "startCallTime": 9000, + "callDuration": 12, + "callSeconds": 12, + "ringTime": 3, + "callStatus": 2, + "incomingCall": 0, + "userId": "u1", + "userIdName": "Alice", + "departmentId": "d1", + "callNumber": "13800000000", + "simPhone": "13900000000", + "simNumber": "1", + "imei": "356938035643809", + "recordFile": "https://oss/c_phone_raw.mp3", + "isUpload": 1, + "downloadCount": 2, + "aiAsr": "translated text", + "qcStatus": 1, + "planCustomerName": "Carol", + "version": "v1", + } + + # Voice-call payload shape round-trips too. + assert by_id["c_voice_raw"]["raw_json"] == { + "ossFileName": "voice/c_voice_raw.mp3", + "duration": 20, + "startTime": 9500, + "endTime": 9520, + "remark": "voice memo", + "userId": "u2", + } + + # Seeded without an explicit raw_json -> JSON null. + assert by_id.get("c3", {}).get("raw_json") is None + + async def test_filter_by_user_id(make_client): """``?user_id=u1`` -> 2 calls for Alice.""" client = await make_client(_seed) diff --git a/yunke-server/tests/test_api_groups.py b/yunke-server/tests/test_api_groups.py index fe308e7..2c1df6d 100644 --- a/yunke-server/tests/test_api_groups.py +++ b/yunke-server/tests/test_api_groups.py @@ -82,10 +82,80 @@ async def test_default_list_returns_all_ordered_desc(make_client): "member_count", "message_count", "last_chat_time", + "raw_json", ): assert key in body["items"][0] +async def test_raw_json_serializes_verbatim(make_client): + """``raw_json`` round-trips the verbatim upstream chatroom record. + + Seeded blobs must come back unmolested (every camelCase key preserved) so + the all-fields drawer can render every column the 云客 群管理 page shows. + Rows without a blob serialize as JSON null. + """ + + async def seed(session: AsyncSession) -> None: + await _seed(session) + session.add( + ChatGroup( + room_id="room_raw@chatroom", + channel="wechat", + room_name="Raw Team", + owner_account_id="wx_owner_1", + member_count=4, + last_chat_time=9000, + raw_json={ + "chatRoomId": "room_raw@chatroom", + "chatRoomName": "Raw Team", + "memberCount": 4, + "memberIds": "wx_a;wx_b;wx_c;wx_d", + "headImageUrl": "https://oss/raw.jpg", + "wechatId": "wx_owner_1", + "wechatNickName": "WxOne", + "userName": "owner_login", + "userPhone": "13800000000", + "userId": "u_owner", + "messageCount": 42, + "ownMessageCount": 7, + "lossMembers": 1, + "addMembers": 2, + "lastChatTime": 9000, + "updateTime": 9999, + }, + ) + ) + await session.commit() + + client = await make_client(seed) + r = await client.get("/api/groups", params={"owner_account_id": "wx_owner_1"}) + assert r.status_code == 200 + by_id = {it["room_id"]: it for it in r.json()["items"]} + + raw = by_id["room_raw@chatroom"]["raw_json"] + assert raw == { + "chatRoomId": "room_raw@chatroom", + "chatRoomName": "Raw Team", + "memberCount": 4, + "memberIds": "wx_a;wx_b;wx_c;wx_d", + "headImageUrl": "https://oss/raw.jpg", + "wechatId": "wx_owner_1", + "wechatNickName": "WxOne", + "userName": "owner_login", + "userPhone": "13800000000", + "userId": "u_owner", + "messageCount": 42, + "ownMessageCount": 7, + "lossMembers": 1, + "addMembers": 2, + "lastChatTime": 9000, + "updateTime": 9999, + } + + # Seeded without an explicit raw_json -> JSON null (not omitted, not error). + assert by_id["room_a@chatroom"]["raw_json"] is None + + async def test_filter_by_channel_whatsapp(make_client): """``?channel=whatsapp`` -> the single WA group.""" client = await make_client(_seed) diff --git a/yunke-server/tests/test_api_sms.py b/yunke-server/tests/test_api_sms.py index ef4d338..6235316 100644 --- a/yunke-server/tests/test_api_sms.py +++ b/yunke-server/tests/test_api_sms.py @@ -69,10 +69,76 @@ async def test_default_list_returns_all_ordered_desc(make_client): "receive_name", "create_time", "sim_phone", + "raw_json", ): assert key in body["items"][0] +async def test_raw_json_serializes_verbatim(make_client): + """``raw_json`` round-trips the verbatim upstream SMS record. + + Every camelCase key must come back unmolested so the all-fields drawer can + render every column the 云客 短信记录 page shows. Rows without a blob + serialize as JSON null. + """ + + async def seed(session: AsyncSession) -> None: + await _seed(session) + session.add( + Sms( + sms_id="s_raw", + channel="sms", + user_id="u1", + receive_name="Alice", + content_preview="raw body", + content="raw body full text", + create_time=9000, + direction=1, + customer_phone="999", + raw_json={ + "id": "s_raw", + "customerPhone": "999", + "sms": "raw body full text", + "smsSub": "raw body", + "sender": "111", + "direction": 1, + "userId": "u1", + "senderDepartment": "Sales", + "receiveName": "Alice", + "createTime": 9000, + "simPhone": "13900000000", + "simNumber": "1", + "source": "yunke", + }, + ) + ) + await session.commit() + + client = await make_client(seed) + r = await client.get("/api/sms", params={"user_id": "u1"}) + assert r.status_code == 200 + by_id = {it["sms_id"]: it for it in r.json()["items"]} + + assert by_id["s_raw"]["raw_json"] == { + "id": "s_raw", + "customerPhone": "999", + "sms": "raw body full text", + "smsSub": "raw body", + "sender": "111", + "direction": 1, + "userId": "u1", + "senderDepartment": "Sales", + "receiveName": "Alice", + "createTime": 9000, + "simPhone": "13900000000", + "simNumber": "1", + "source": "yunke", + } + + # Seeded without an explicit raw_json -> JSON null. + assert by_id["s1"]["raw_json"] is None + + async def test_filter_by_user_id(make_client): """``?user_id=u1`` -> 2 SMS for Alice.""" client = await make_client(_seed) diff --git a/yunkes/src/api/types.ts b/yunkes/src/api/types.ts index b709ab1..476dd3e 100644 --- a/yunkes/src/api/types.ts +++ b/yunkes/src/api/types.ts @@ -92,6 +92,14 @@ export interface Group { message_count?: number | null /** ms-epoch of most recent message */ last_chat_time?: number | null + /** + * Verbatim upstream chatroom record (camelCase keys: chatRoomId/chatRoomName, + * memberCount/memberIds, headImageUrl, wechatId/wechatNickName, userName/ + * userPhone, userId, messageCount/ownMessageCount, lossMembers/addMembers, + * lastChatTime/updateTime, ...). Surfaced so the all-fields drawer can + * render every column the 云客 群管理 page shows. + */ + raw_json?: Record | null } /** Member of a chat group (mirrors backend GroupMemberOut). */ @@ -126,6 +134,14 @@ export interface PhoneCall { sim_phone?: string | null /** OSS URL of the recording — present only when a recording exists. */ record_file?: string | null + /** + * Verbatim upstream call record (camelCase keys: id/startCallTime, + * callDuration/callSeconds/ringTime, callStatus/incomingCall, userId/ + * userIdName/departmentId, callNumber, simPhone/simNumber/imei, recordFile, + * isUpload/downloadCount/aiAsr/qcStatus/planCustomerName/version; voice-call + * records also carry ossFileName/duration/startTime/endTime/remark). + */ + raw_json?: Record | null } /** SMS log row (mirrors backend SmsOut). */ @@ -143,6 +159,13 @@ export interface Sms { /** ms-epoch */ create_time?: number | null sim_phone?: string | null + /** + * Verbatim upstream SMS record (camelCase keys: id/customerPhone, sms/smsSub, + * sender, direction, userId, senderDepartment, receiveName, createTime, + * simPhone/simNumber, source, ...). Surfaced so the all-fields drawer can + * render every column the 云客 短信记录 page shows. + */ + raw_json?: Record | null } /** Resumable sync cursor (mirrors backend SyncStateOut). */ diff --git a/yunkes/src/utils/callFields.ts b/yunkes/src/utils/callFields.ts new file mode 100644 index 0000000..9a0c111 --- /dev/null +++ b/yunkes/src/utils/callFields.ts @@ -0,0 +1,259 @@ +/** + * Chinese-label map + grouping for the PhoneCall all-fields drawer. + * + * Mirrors the Customer approach (see customerFields.ts): the 云客 upstream + * call record (`PhoneCall.raw_json`) carries the camelCase fields the source + * 通话记录 page shows. Two payload shapes flow through this table: + * + * • phone_call records: id/startCallTime/callDuration/callSeconds/ringTime/ + * callStatus/incomingCall/userId/userIdName/departmentId/callNumber/ + * simPhone/simNumber/imei/recordFile/isUpload/downloadCount/aiAsr/ + * qcStatus/planCustomerName/version. + * • voice_call records: ossFileName/duration/startTime/endTime/remark/ + * userId/... + * + * 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 { PhoneCall } from '@/api/types' +import { formatTime } from './format' + +/** camelCase raw_json key → 中文 label. Missing keys fall back to the key. */ +export const CALL_FIELD_LABELS: Record = { + // 标识与时间 + id: '通话ID', + startCallTime: '开始时间', + startTime: '开始时间', + endTime: '结束时间', + ringTime: '振铃时间', + // 时长 + callDuration: '时长(秒)', + callSeconds: '时长(秒)', + duration: '时长(秒)', + // 状态与方向 + callStatus: '状态', + incomingCall: '方向', + isUpload: '已上传', + downloadCount: '下载次数', + qcStatus: '质检状态', + // 员工与部门 + userId: '员工ID', + userIdName: '员工', + departmentId: '部门ID', + // 号码与设备 + callNumber: '号码', + simPhone: 'SIM手机', + simNumber: 'SIM号', + imei: 'IMEI', + // 录音与转写 + recordFile: '录音URL', + ossFileName: 'OSS文件', + aiAsr: 'AI转写', + remark: '备注', + // 关联与版本 + planCustomerName: '关联客户', + 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 = { + id: 'call_id', + startCallTime: 'start_call_time', + startTime: 'start_call_time', + callDuration: 'call_duration', + duration: 'call_duration', + callStatus: 'call_status', + incomingCall: 'incoming_call', + userId: 'user_id', + userIdName: 'user_id_name', + callNumber: 'call_number', + simPhone: 'sim_phone', + recordFile: 'record_file', +} + +/** Fields whose value is an audio URL → render as a play link. */ +const AUDIO_KEYS = new Set(['recordFile']) + +/** Fields whose value is a millisecond epoch → format to local datetime. */ +const TIMESTAMP_KEYS = new Set(['startCallTime', 'startTime', 'endTime']) + +/** Fields whose value is a boolean / 0|1 flag → render 是/否. */ +const BOOLEAN_KEYS = new Set(['isUpload']) + +/** + * callStatus mapping used inside the drawer (per upstream call-log payload): + * 0 = 未接, 2 = 接通, other codes pass through so info isn't hidden. (The list + * column uses the format.ts helper which follows the 1=已接通/0=未接通 phone + * schema; the drawer surfaces the raw upstream value verbatim with this + * friendlier label.) + */ +function callStatusLabel(v: unknown): string { + if (v === 0 || v === '0') return '未接' + if (v === 2 || v === '2') return '接通' + return String(v) +} + +/** incomingCall: 1 = 呼入, 0 = 呼出, other values pass through. */ +function callDirectionLabel(v: unknown): string { + if (v === 1 || v === '1') return '呼入' + if (v === 0 || v === '0') return '呼出' + return String(v) +} + +/** 0|1|true|false → 是/否; other ints pass through so info isn't hidden. */ +function yesNoLabel(v: unknown): string { + if (v === true || v === 1 || v === '1') return '是' + if (v === false || v === 0 || v === '0') return '否' + return String(v) +} + +/** Drawer sections, rendered in this order. */ +export interface CallFieldGroup { + title: string + /** camelCase raw_json keys to surface in this section. */ + keys: string[] +} + +export const CALL_FIELD_GROUPS: CallFieldGroup[] = [ + { + title: '标识与时间', + keys: ['id', 'startCallTime', 'startTime', 'endTime', 'ringTime'], + }, + { + title: '时长', + keys: ['callDuration', 'callSeconds', 'duration'], + }, + { + title: '状态与方向', + keys: [ + 'callStatus', + 'incomingCall', + 'isUpload', + 'downloadCount', + 'qcStatus', + ], + }, + { + title: '员工与部门', + keys: ['userId', 'userIdName', 'departmentId'], + }, + { + title: '号码与设备', + keys: ['callNumber', 'simPhone', 'simNumber', 'imei'], + }, + { + title: '录音与转写', + keys: ['recordFile', 'ossFileName', 'aiAsr', 'remark'], + }, + { + title: '关联与版本', + keys: ['planCustomerName', 'version'], + }, +] + +/** Every key already surfaced via a group (so 其他 can skip duplicates). */ +const KNOWN_KEYS = new Set(CALL_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 CALL_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(c: PhoneCall, camelKey: string): unknown { + const alias = STRUCTURED_ALIAS[camelKey] + if (alias) { + const v = c[alias] + if (v !== null && v !== undefined) return v + } + return c.raw_json?.[camelKey] +} + +/** A single field prepared for drawer rendering. */ +export interface DisplayField { + key: string + label: string + /** Raw value (for text/audio fallback). */ + value: unknown + /** How the template should render it. */ + render: 'audio' | 'time' | 'bool' | 'status' | 'direction' | 'text' | 'empty' + /** Pre-formatted text (for non-audio kinds). Empty for audio/empty. */ + text: string +} + +/** Classify + format a single field for the drawer. */ +export function toDisplayField(c: PhoneCall, camelKey: string): DisplayField { + const value = readField(c, camelKey) + const label = fieldLabel(camelKey) + if (value === null || value === undefined || value === '') { + return { key: camelKey, label, value, render: 'empty', text: '' } + } + if (AUDIO_KEYS.has(camelKey) && typeof value === 'string') { + return { key: camelKey, label, value, render: 'audio', text: '' } + } + if (TIMESTAMP_KEYS.has(camelKey) && typeof value === 'number') { + return { key: camelKey, label, value, render: 'time', text: formatTime(value) } + } + if (camelKey === 'callStatus') { + return { key: camelKey, label, value, render: 'status', text: callStatusLabel(value) } + } + if (camelKey === 'incomingCall') { + return { + key: camelKey, + label, + value, + render: 'direction', + text: callDirectionLabel(value), + } + } + 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 call. + * + * 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 CallFieldSection { + title: string + fields: DisplayField[] +} + +export function buildCallSections(c: PhoneCall): CallFieldSection[] { + const sections: CallFieldSection[] = CALL_FIELD_GROUPS.map((g) => ({ + title: g.title, + fields: g.keys + .map((k) => toDisplayField(c, k)) + .filter((f) => f.render !== 'empty'), + })) + + // 其他: every raw_json key not already shown, in original insertion order. + const raw = c.raw_json ?? {} + const others: DisplayField[] = [] + for (const key of Object.keys(raw)) { + if (KNOWN_KEYS.has(key)) continue + const f = toDisplayField(c, 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/groupFields.ts b/yunkes/src/utils/groupFields.ts new file mode 100644 index 0000000..fa6923d --- /dev/null +++ b/yunkes/src/utils/groupFields.ts @@ -0,0 +1,191 @@ +/** + * Chinese-label map + grouping for the Group all-fields drawer. + * + * Mirrors the Customer approach (see customerFields.ts): the 云客 upstream + * chatroom record (`Group.raw_json`) carries the camelCase fields the source + * 群管理 page shows (chatRoomId/chatRoomName, memberCount/memberIds, + * headImageUrl, wechatId/wechatNickName, userName/userPhone, userId, + * messageCount/ownMessageCount, lossMembers/addMembers, lastChatTime/ + * updateTime). This module turns that blob into a labeled, grouped, + * render-ready structure for the drawer opened by clicking a group row. + * + * 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 { Group } from '@/api/types' +import { formatTime } from './format' + +/** camelCase raw_json key → 中文 label. Missing keys fall back to the key. */ +export const GROUP_FIELD_LABELS: Record = { + // 群标识 + chatRoomId: '群ID', + chatRoomName: '群名', + headImageUrl: '群头像', + // 成员 + memberCount: '成员数', + memberIds: '成员列表', + // 归属微信与群主 + wechatId: '归属微信ID', + wechatNickName: '归属微信', + userName: '群主登录名', + userPhone: '群主手机', + userId: '群主ID', + // 消息统计 + messageCount: '消息数', + ownMessageCount: '发出数', + lossMembers: '流失成员', + addMembers: '新增成员', + // 时间 + lastChatTime: '最近聊天', + updateTime: '更新时间', +} + +/** + * 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 = { + chatRoomId: 'room_id', + chatRoomName: 'room_name', + memberCount: 'member_count', + messageCount: 'message_count', + lastChatTime: 'last_chat_time', + wechatId: 'owner_account_id', + wechatNickName: 'owner_account_nickname', +} + +/** Fields whose value is an image URL → render as a thumbnail. */ +const IMAGE_KEYS = new Set(['headImageUrl']) + +/** Fields whose value is a millisecond epoch → format to local datetime. */ +const TIMESTAMP_KEYS = new Set(['lastChatTime', 'updateTime']) + +/** Drawer sections, rendered in this order. */ +export interface GroupFieldGroup { + title: string + /** camelCase raw_json keys to surface in this section. */ + keys: string[] +} + +export const GROUP_FIELD_GROUPS: GroupFieldGroup[] = [ + { + title: '群标识', + keys: ['chatRoomId', 'chatRoomName', 'headImageUrl'], + }, + { + title: '成员', + keys: ['memberCount', 'memberIds'], + }, + { + title: '归属微信与群主', + keys: [ + 'wechatId', + 'wechatNickName', + 'userName', + 'userPhone', + 'userId', + ], + }, + { + title: '消息统计', + keys: [ + 'messageCount', + 'ownMessageCount', + 'lossMembers', + 'addMembers', + ], + }, + { + title: '时间', + keys: ['lastChatTime', 'updateTime'], + }, +] + +/** Every key already surfaced via a group (so 其他 can skip duplicates). */ +const KNOWN_KEYS = new Set(GROUP_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 GROUP_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(g: Group, camelKey: string): unknown { + const alias = STRUCTURED_ALIAS[camelKey] + if (alias) { + const v = g[alias] + if (v !== null && v !== undefined) return v + } + return g.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' | 'text' | 'empty' + /** Pre-formatted text (for time/text kinds). Empty for image/empty. */ + text: string +} + +/** Classify + format a single field for the drawer. */ +export function toDisplayField(g: Group, camelKey: string): DisplayField { + const value = readField(g, 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) } + } + return { key: camelKey, label, value, render: 'text', text: String(value) } +} + +/** + * Build the grouped display model for a group. + * + * 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 GroupFieldSection { + title: string + fields: DisplayField[] +} + +export function buildGroupSections(g: Group): GroupFieldSection[] { + const sections: GroupFieldSection[] = GROUP_FIELD_GROUPS.map((grp) => ({ + title: grp.title, + fields: grp.keys + .map((k) => toDisplayField(g, k)) + .filter((f) => f.render !== 'empty'), + })) + + // 其他: every raw_json key not already shown, in original insertion order. + const raw = g.raw_json ?? {} + const others: DisplayField[] = [] + for (const key of Object.keys(raw)) { + if (KNOWN_KEYS.has(key)) continue + const f = toDisplayField(g, 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/smsFields.ts b/yunkes/src/utils/smsFields.ts new file mode 100644 index 0000000..67a2c7b --- /dev/null +++ b/yunkes/src/utils/smsFields.ts @@ -0,0 +1,192 @@ +/** + * Chinese-label map + grouping for the SMS all-fields drawer. + * + * Mirrors the Customer approach (see customerFields.ts): the 云客 upstream + * SMS record (`Sms.raw_json`) carries the camelCase fields the source 短信记录 + * page shows (id/customerPhone, sms/smsSub, sender, direction, userId, + * senderDepartment, receiveName, createTime, simPhone/simNumber, source). + * This module turns that blob into a labeled, grouped, render-ready structure + * for the drawer opened by clicking an SMS row. + * + * 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 { Sms } from '@/api/types' +import { formatTime } from './format' + +/** camelCase raw_json key → 中文 label. Missing keys fall back to the key. */ +export const SMS_FIELD_LABELS: Record = { + // 标识与时间 + id: '短信ID', + createTime: '时间', + // 客户与号码 + customerPhone: '客户号码', + sender: '发送者', + receiveName: '接收者', + // 内容 + sms: '全文', + smsSub: '预览', + // 方向与归属 + direction: '方向', + userId: '员工ID', + senderDepartment: '部门', + // 设备与来源 + simPhone: 'SIM手机', + simNumber: 'SIM号', + source: '来源', +} + +/** + * 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 = { + id: 'sms_id', + customerPhone: 'customer_phone', + sms: 'content', + smsSub: 'content_preview', + sender: 'sender', + direction: 'direction', + userId: 'user_id', + receiveName: 'receive_name', + createTime: 'create_time', + simPhone: 'sim_phone', +} + +/** Fields whose value is a millisecond epoch → format to local datetime. */ +const TIMESTAMP_KEYS = new Set(['createTime']) + +/** Fields whose value is a direction code (0=接收 / 1=发送). */ +const DIRECTION_KEYS = new Set(['direction']) + +/** Drawer sections, rendered in this order. */ +export interface SmsFieldGroup { + title: string + /** camelCase raw_json keys to surface in this section. */ + keys: string[] +} + +export const SMS_FIELD_GROUPS: SmsFieldGroup[] = [ + { + title: '标识与时间', + keys: ['id', 'createTime'], + }, + { + title: '客户与号码', + keys: ['customerPhone', 'sender', 'receiveName'], + }, + { + title: '内容', + keys: ['sms', 'smsSub'], + }, + { + title: '方向与归属', + keys: ['direction', 'userId', 'senderDepartment'], + }, + { + title: '设备与来源', + keys: ['simPhone', 'simNumber', 'source'], + }, +] + +/** Every key already surfaced via a group (so 其他 can skip duplicates). */ +const KNOWN_KEYS = new Set(SMS_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 SMS_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: Sms, 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] +} + +/** direction: 1 = 发送 (outbound), 0 = 接收 (inbound), other values pass through. */ +function smsDirectionLabel(v: unknown): string { + if (v === 1 || v === '1') return '发送' + if (v === 0 || v === '0') return '接收' + return String(v) +} + +/** 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: 'time' | 'direction' | 'text' | 'empty' + /** Pre-formatted text (for non-empty kinds). Empty for empty. */ + text: string +} + +/** Classify + format a single field for the drawer. */ +export function toDisplayField(s: Sms, 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 (TIMESTAMP_KEYS.has(camelKey) && typeof value === 'number') { + return { key: camelKey, label, value, render: 'time', text: formatTime(value) } + } + if (DIRECTION_KEYS.has(camelKey)) { + return { + key: camelKey, + label, + value, + render: 'direction', + text: smsDirectionLabel(value), + } + } + return { key: camelKey, label, value, render: 'text', text: String(value) } +} + +/** + * Build the grouped display model for an SMS row. + * + * 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 SmsFieldSection { + title: string + fields: DisplayField[] +} + +export function buildSmsSections(s: Sms): SmsFieldSection[] { + const sections: SmsFieldSection[] = SMS_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/CallList.vue b/yunkes/src/views/CallList.vue index 0366aba..1f659b7 100644 --- a/yunkes/src/views/CallList.vue +++ b/yunkes/src/views/CallList.vue @@ -10,6 +10,7 @@ import { formatCallDuration, formatTime, } from '@/utils/format' +import { buildCallSections, readField } from '@/utils/callFields' // Calls aren't tied to a messaging channel — `channel` on a call is the call // type (phone_call / voice_call). We keep a LOCAL filter so it doesn't fight @@ -28,6 +29,10 @@ const recordDialogVisible = ref(false) const recordSrc = ref(null) const recordTitle = ref('') +// All-fields drawer state. +const drawerOpen = ref(false) +const drawerCall = ref(null) + const typeOptions: { value: CallChannel | 'all'; label: string }[] = [ { value: 'all', label: '全部类型' }, { value: 'phone_call', label: '电话' }, @@ -75,18 +80,45 @@ function onTypeChange(value: string | number | boolean | undefined): void { load() } +/** + * Read a raw_json camelCase key off a call, preferring the structured + * snake_case column when it is populated. The first argument is the row so the + * table can use the same helper as the drawer. + */ +function cell(row: PhoneCall, camelKey: string): unknown { + return readField(row, camelKey) +} + +function timeCell(row: PhoneCall, camelKey: string): number | undefined { + const v = cell(row, camelKey) + return typeof v === 'number' ? v : undefined +} + function playRecord(row: PhoneCall): void { - if (!row.record_file) { + const file = + (typeof cell(row, 'recordFile') === 'string' + ? (cell(row, 'recordFile') as string) + : null) ?? row.record_file + if (!file) { ElMessage.info('该通话无录音文件') return } - recordSrc.value = row.record_file + recordSrc.value = file const who = row.user_id_name || row.user_id || row.call_id const when = formatTime(row.start_call_time) recordTitle.value = `录音 · ${who}${when !== '--' ? ` · ${when}` : ''}` recordDialogVisible.value = true } +function openDrawer(row: PhoneCall): void { + drawerCall.value = row + drawerOpen.value = true +} + +const drawerSections = computed(() => + drawerCall.value ? buildCallSections(drawerCall.value) : [], +) + onMounted(load) @@ -128,10 +160,14 @@ onMounted(load) border row-key="call_id" style="width: 100%" + :row-class-name="() => 'yk-clickable-row'" empty-text="暂无通话记录" + @row-click="openDrawer" > - + - - - - - - - - - - - -
@@ -182,7 +253,67 @@ onMounted(load) @size-change="handlePageSizeChange" />
-

点击任意一行查看该群的聊天记录。

+

点击任意一行查看群全部字段。

+ + + + + @@ -219,4 +350,77 @@ onMounted(load) font-size: 12px; color: #c0c4cc; } + +.yk-drawer__topbar { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 12px; +} + +.yk-drawer__topbar-hint { + color: #c0c4cc; + font-size: 12px; +} + +.yk-drawer__avatar { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 4px; +} + +.yk-drawer__avatar-meta { + min-width: 0; +} + +.yk-drawer__avatar-name { + font-size: 16px; + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.yk-drawer__avatar-sub { + font-size: 12px; + color: #909399; + margin-top: 2px; +} + +.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; +} diff --git a/yunkes/src/views/SmsList.vue b/yunkes/src/views/SmsList.vue index cad33a0..8de8c1d 100644 --- a/yunkes/src/views/SmsList.vue +++ b/yunkes/src/views/SmsList.vue @@ -1,9 +1,10 @@ @@ -81,14 +116,16 @@ onMounted(load) style="width: 100%" :row-class-name="() => 'yk-clickable-row'" empty-text="暂无短信记录" - @row-click="openDetail" + @row-click="openDrawer" > - + @@ -120,43 +157,33 @@ onMounted(load) @size-change="handlePageSizeChange" /> -

点击任意一行查看完整短信内容。

+

点击任意一行查看短信全部字段。

- + -
-
- 时间 - {{ formatTime(detailRow.create_time) }} + +
@@ -187,31 +214,54 @@ onMounted(load) color: #c0c4cc; } -.yk-sms__detail { - display: flex; - flex-direction: column; - gap: 10px; - font-size: 13px; +.yk-sms__full { + background: var(--el-fill-color-light, #f5f7fa); + border: 1px solid var(--el-border-color-light, #e4e7ed); + border-radius: 4px; + padding: 10px 12px; + margin-bottom: 12px; } -.yk-sms__detail-row { - display: flex; - gap: 12px; - align-items: center; -} - -.yk-sms__detail-row--top { - align-items: flex-start; -} - -.yk-sms__detail-label { - flex: 0 0 64px; +.yk-sms__full-label { color: #909399; + font-size: 12px; + margin-bottom: 6px; } -.yk-sms__detail-content { +.yk-sms__full-body { + font-size: 13px; + color: #303133; + line-height: 1.6; white-space: pre-wrap; word-break: break-word; - line-height: 1.6; +} + +.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__empty { + color: #c0c4cc; }