diff --git a/yunke-server/app/api/schemas.py b/yunke-server/app/api/schemas.py index ef9f4fb..9b6ba05 100644 --- a/yunke-server/app/api/schemas.py +++ b/yunke-server/app/api/schemas.py @@ -114,6 +114,14 @@ class MessageOut(BaseModel): type_raw: int | None = None text: str | None = None file_url: str | None = None + # Image thumbnails / originals + voice duration / transcription so the + # timeline can render media without a second round-trip. All optional — the + # underlying columns (see Message model) are nullable. + file_th_url: str | None = None + file_hd_url: str | None = None + duration: int | None = None + transcription_text: str | None = None + md5: str | None = None sender_name: str | None = None sender_head_url: str | None = None timestamp: int | None = None @@ -162,6 +170,9 @@ class PhoneCallOut(BaseModel): user_id_name: str | None = None call_number: str | None = None sim_phone: str | None = None + # OSS URL of the call recording — surfaced so the Calls view can offer a + # "播放录音" action when present. + record_file: str | None = None class SmsOut(BaseModel): @@ -172,6 +183,9 @@ class SmsOut(BaseModel): sms_id: str customer_phone: str | None = None content_preview: str | None = None + # Full SMS body (the table stores it in ``content``); included so the SMS + # view can show the whole message in a detail dialog without a second call. + content: str | None = None sender: str | None = None direction: int | None = None user_id: str | None = None diff --git a/yunke-server/tests/test_api_calls.py b/yunke-server/tests/test_api_calls.py index c0df743..2b6fced 100644 --- a/yunke-server/tests/test_api_calls.py +++ b/yunke-server/tests/test_api_calls.py @@ -46,6 +46,7 @@ async def _seed(session: AsyncSession) -> None: call_status=0, incoming_call=0, call_number="111", + record_file="https://oss/c3.mp3", ), ] ) @@ -71,6 +72,7 @@ async def test_default_list_returns_all_ordered_desc(make_client): "user_id_name", "call_number", "sim_phone", + "record_file", ): assert key in body["items"][0] @@ -126,3 +128,18 @@ async def test_pagination(make_client): assert body["total"] == 3 assert len(body["items"]) == 1 assert body["items"][0]["call_id"] == "c3" + + +async def test_record_file_serializes(make_client): + """``record_file`` (OSS recording URL) round-trips so the Calls view can + render a 播放录音 action — present when set, None when unset. + """ + client = await make_client(_seed) + r = await client.get("/api/calls") + assert r.status_code == 200 + by_id = {it["call_id"]: it for it in r.json()["items"]} + # c3 was seeded with a recording URL. + assert by_id["c3"]["record_file"] == "https://oss/c3.mp3" + # c1/c2 have no record_file set -> None. + assert by_id["c1"]["record_file"] is None + assert by_id["c2"]["record_file"] is None diff --git a/yunke-server/tests/test_api_messages.py b/yunke-server/tests/test_api_messages.py index 58ec388..905772c 100644 --- a/yunke-server/tests/test_api_messages.py +++ b/yunke-server/tests/test_api_messages.py @@ -185,3 +185,102 @@ async def test_group_chat_filter_by_room_id(make_client): body = r.json() assert body["total"] == 1 assert body["items"][0]["msg_id"] == "m4" + + +async def test_media_fields_serialize(make_client): + """Image thumbnail/HD URLs, voice duration/transcription, md5, room_id and + sender avatar round-trip through MessageOut so the timeline can render + media + group messages without a second call. + """ + + async def seed(session: AsyncSession) -> None: + session.add_all( + [ + Message( + msg_id="img1", + channel="wechat", + owner_account_id="wx_owner_1", + talker_id="t_a", + room_id=None, + type="image", + type_raw=3, + timestamp=1000, + file_url="https://oss/full.jpg", + file_th_url="https://oss/th.jpg", + file_hd_url="https://oss/hd.jpg", + md5="abc123", + sender_name="Alice", + sender_head_url="https://oss/alice.jpg", + ), + Message( + msg_id="voice1", + channel="wechat", + owner_account_id="wx_owner_1", + talker_id=None, + room_id="room_x", + type="voice", + type_raw=34, + timestamp=2000, + duration=12, + transcription_text="hello there", + sender_name="Bob", + sender_head_url="https://oss/bob.jpg", + ), + ] + ) + await session.commit() + + client = await make_client(seed) + r = await client.get("/api/messages", params={"owner_account_id": "wx_owner_1"}) + assert r.status_code == 200 + body = r.json() + assert body["total"] == 2 + by_id = {it["msg_id"]: it for it in body["items"]} + + img = by_id["img1"] + assert img["file_th_url"] == "https://oss/th.jpg" + assert img["file_hd_url"] == "https://oss/hd.jpg" + assert img["md5"] == "abc123" + assert img["room_id"] is None + assert img["sender_head_url"] == "https://oss/alice.jpg" + + voice = by_id["voice1"] + assert voice["duration"] == 12 + assert voice["transcription_text"] == "hello there" + assert voice["room_id"] == "room_x" + assert voice["sender_head_url"] == "https://oss/bob.jpg" + + +async def test_media_fields_default_none_when_unset(make_client): + """A text message with no media columns set serializes the new keys as None.""" + + async def seed(session: AsyncSession) -> None: + session.add( + Message( + msg_id="t1", + channel="wechat", + owner_account_id="wx_owner_1", + talker_id="t_a", + type="text", + type_raw=1, + timestamp=1000, + text="plain", + ) + ) + await session.commit() + + client = await make_client(seed) + r = await client.get("/api/messages") + assert r.status_code == 200 + it = r.json()["items"][0] + for key in ( + "file_th_url", + "file_hd_url", + "duration", + "transcription_text", + "md5", + "room_id", + "sender_head_url", + ): + assert key in it + assert it[key] is None diff --git a/yunke-server/tests/test_api_sms.py b/yunke-server/tests/test_api_sms.py index 6badddc..ef4d338 100644 --- a/yunke-server/tests/test_api_sms.py +++ b/yunke-server/tests/test_api_sms.py @@ -20,6 +20,7 @@ async def _seed(session: AsyncSession) -> None: user_id="u1", receive_name="Alice", content_preview="hello", + content="hello, this is the full body", create_time=1000, direction=1, sender="111", @@ -40,6 +41,7 @@ async def _seed(session: AsyncSession) -> None: user_id="u1", receive_name="Alice", content_preview="bye", + content="bye for now", create_time=3000, direction=1, ), @@ -60,6 +62,7 @@ async def test_default_list_returns_all_ordered_desc(make_client): "sms_id", "customer_phone", "content_preview", + "content", "sender", "direction", "user_id", @@ -109,3 +112,17 @@ async def test_pagination(make_client): assert body["total"] == 3 assert len(body["items"]) == 1 assert body["items"][0]["sms_id"] == "s3" + + +async def test_content_field_serializes(make_client): + """``content`` (full SMS body) round-trips so the SMS view can show the + whole message in a detail dialog — present when set, None when unset. + """ + client = await make_client(_seed) + r = await client.get("/api/sms") + assert r.status_code == 200 + by_id = {it["sms_id"]: it for it in r.json()["items"]} + assert by_id["s1"]["content"] == "hello, this is the full body" + assert by_id["s3"]["content"] == "bye for now" + # s2 was seeded without content -> None. + assert by_id["s2"]["content"] is None diff --git a/yunkes/src/api/index.ts b/yunkes/src/api/index.ts index 46bfcfe..15300de 100644 --- a/yunkes/src/api/index.ts +++ b/yunkes/src/api/index.ts @@ -5,12 +5,20 @@ // (the backend treats missing as "no filter"). import { getJson } from './client' import type { + CallChannel, Channel, Customer, Department, + Group, + GroupMember, Message, Page, + PhoneCall, + Sms, Staff, + SyncRun, + SyncState, + SyncStatus, } from './types' /** Drop entries whose value is `undefined`, `null`, or `''`. */ @@ -94,4 +102,113 @@ export function getStaff(query: StaffQuery = {}): Promise> { ) } -export type { Channel, Customer, Department, Message, Page, Staff } +// --------------------------------------------------------------------------- +// Chat groups +// --------------------------------------------------------------------------- + +export interface GroupQuery { + channel?: Channel | 'all' + owner_account_id?: string + page?: number + page_size?: number +} + +export function getGroups(query: GroupQuery = {}): Promise> { + return getJson>( + '/groups', + clean({ + channel: query.channel === 'all' ? undefined : query.channel, + owner_account_id: query.owner_account_id, + page: query.page ?? 1, + page_size: query.page_size ?? 20, + }), + ) +} + +export function getGroupMembers(roomId: string): Promise { + // room_id can contain '@' — axios encodes params, but path segments need + // explicit encoding so the FastAPI `{room_id}` path param matches. + return getJson( + `/groups/${encodeURIComponent(roomId)}/members`, + ) +} + +// --------------------------------------------------------------------------- +// Phone / voice calls +// --------------------------------------------------------------------------- + +export interface CallQuery { + /** call-type filter; `'all'` (default) drops the param. */ + channel?: CallChannel | 'all' + user_id?: string + /** ms-epoch inclusive window compared against start_call_time. */ + start?: number + end?: number + page?: number + page_size?: number +} + +export function getCalls(query: CallQuery = {}): Promise> { + return getJson>( + '/calls', + clean({ + channel: query.channel === 'all' ? undefined : query.channel, + user_id: query.user_id, + start: query.start, + end: query.end, + page: query.page ?? 1, + page_size: query.page_size ?? 20, + }), + ) +} + +// --------------------------------------------------------------------------- +// SMS +// --------------------------------------------------------------------------- + +export interface SmsQuery { + user_id?: string + /** ms-epoch inclusive window compared against create_time. */ + start?: number + end?: number + page?: number + page_size?: number +} + +export function getSms(query: SmsQuery = {}): Promise> { + return getJson>( + '/sms', + clean({ + user_id: query.user_id, + start: query.start, + end: query.end, + page: query.page ?? 1, + page_size: query.page_size ?? 20, + }), + ) +} + +// --------------------------------------------------------------------------- +// Sync monitor +// --------------------------------------------------------------------------- + +export function getSyncStatus(): Promise { + return getJson('/sync/status') +} + +export type { + CallChannel, + Channel, + Customer, + Department, + Group, + GroupMember, + Message, + Page, + PhoneCall, + Sms, + Staff, + SyncRun, + SyncState, + SyncStatus, +} diff --git a/yunkes/src/api/types.ts b/yunkes/src/api/types.ts index 4422e66..a036f61 100644 --- a/yunkes/src/api/types.ts +++ b/yunkes/src/api/types.ts @@ -50,6 +50,101 @@ export interface Staff { is_on_job?: boolean | null } +/** Chat group summary row (mirrors backend GroupOut). */ +export interface Group { + room_id: string + channel: Channel + room_name?: string | null + owner_account_id?: string | null + owner_account_nickname?: string | null + member_count?: number | null + message_count?: number | null + /** ms-epoch of most recent message */ + last_chat_time?: number | null +} + +/** Member of a chat group (mirrors backend GroupMemberOut). */ +export interface GroupMember { + channel: Channel + room_id: string + member_id: string + nickname?: string | null + display_name?: string | null + avatar?: string | null +} + +/** + * Phone / voice call record (mirrors backend PhoneCallOut). `channel` here is + * the call-type in {phone_call, voice_call}, not a messaging channel. + */ +export type CallChannel = 'phone_call' | 'voice_call' + +export interface PhoneCall { + call_id: string + channel: CallChannel + /** ms-epoch */ + start_call_time?: number | null + /** seconds */ + call_duration?: number | null + call_status?: number | null + /** 1 = incoming, 0 = outgoing (per upstream schema). */ + incoming_call?: number | null + user_id?: string | null + user_id_name?: string | null + call_number?: string | null + sim_phone?: string | null + /** OSS URL of the recording — present only when a recording exists. */ + record_file?: string | null +} + +/** SMS log row (mirrors backend SmsOut). */ +export interface Sms { + sms_id: string + customer_phone?: string | null + content_preview?: string | null + /** Full SMS body (shown in the detail dialog). */ + content?: string | null + sender?: string | null + /** 1 = sent (outbound), 0 = received (inbound). */ + direction?: number | null + user_id?: string | null + receive_name?: string | null + /** ms-epoch */ + create_time?: number | null + sim_phone?: string | null +} + +/** Resumable sync cursor (mirrors backend SyncStateOut). */ +export interface SyncState { + state_id: number + entity_type: string + channel?: string | null + scope_id?: string | null + last_version?: string | null + last_timestamp?: number | null + last_synced_at?: string | null +} + +/** Audit row for a single sync execution (mirrors backend SyncRunOut). */ +export interface SyncRun { + run_id: number + entity_type: string + channel?: string | null + scope_id?: string | null + status: string + started_at: string + finished_at?: string | null + records_fetched: number + records_upserted: number + error?: string | null +} + +/** Aggregate monitor payload returned by /api/sync/status. */ +export interface SyncStatus { + states: SyncState[] + recent_runs: SyncRun[] +} + /** Normalized message type label (text/image/video/voice/divider/system/unknown). */ export type MessageType = | 'text' diff --git a/yunkes/src/layouts/MainLayout.vue b/yunkes/src/layouts/MainLayout.vue index c1025fa..b4abeb5 100644 --- a/yunkes/src/layouts/MainLayout.vue +++ b/yunkes/src/layouts/MainLayout.vue @@ -21,6 +21,18 @@ const selectedDeptId = computed( () => (route.query.department_id as string | undefined) ?? null, ) +// `el-menu` with `router` mode emits when an item is clicked; we drive +// `default-active` from the current path so the active item highlights on load +// and on browser back/forward. Group/DM chat routes don't appear in the menu — +// the active item falls back to none there, which is fine. +const activeMenu = computed(() => { + // Map chat timeline routes back to their entry list so the nav reflects the + // "owning" section while a conversation is open. + if (route.name === 'chat') return '/customers' + if (route.name === 'group-chat') return '/groups' + return route.path +}) + const treeProps = { label: 'label', children: 'children' } // el-tree's `filter-node-method` is called for every node on each keystroke. @@ -53,10 +65,6 @@ function onChannelChange(value: string | number | boolean | undefined): void { channelStore.setChannel(value as Channel | 'all') } -function goCustomers(): void { - router.push({ name: 'customers' }) -} - async function reloadTree(): Promise { try { await org.ensureLoaded(true) @@ -100,10 +108,34 @@ onMounted(async () => { - - - 客户列表 - + + + + 客户 + + + + 群 + + + + 通话 + + + + 短信 + + + + 同步 + + @@ -190,4 +222,17 @@ onMounted(async () => { align-items: center; font-size: 13px; } + +/* The horizontal nav lives inside the app header, so strip the default menu + * border/background that would otherwise clash with the header's own styling. */ +.yk-header__nav.el-menu--horizontal { + border-bottom: none; + background: transparent; + flex: 0 0 auto; +} + +.yk-header__nav.el-menu--horizontal :deep(.el-menu-item) { + height: var(--yk-header-height); + line-height: var(--yk-header-height); +} diff --git a/yunkes/src/router/index.ts b/yunkes/src/router/index.ts index 895aa88..1d643ac 100644 --- a/yunkes/src/router/index.ts +++ b/yunkes/src/router/index.ts @@ -2,7 +2,8 @@ import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router' // `/` redirects to `/customers`. Chat timeline params are path segments so the // URL is shareable + survives refresh: -// /chat/:channel/:ownerId/:talkerId +// /chat/:channel/:ownerId/:talkerId (1:1 DM) +// /group/:channel/:ownerId/:roomId (group chat — same component, room_id) const routes: RouteRecordRaw[] = [ { path: '/', @@ -14,6 +15,30 @@ const routes: RouteRecordRaw[] = [ component: () => import('@/views/CustomerList.vue'), meta: { title: '客户列表' }, }, + { + path: '/groups', + name: 'groups', + component: () => import('@/views/GroupList.vue'), + meta: { title: '群列表' }, + }, + { + path: '/calls', + name: 'calls', + component: () => import('@/views/CallList.vue'), + meta: { title: '通话记录' }, + }, + { + path: '/sms', + name: 'sms', + component: () => import('@/views/SmsList.vue'), + meta: { title: '短信记录' }, + }, + { + path: '/sync', + name: 'sync', + component: () => import('@/views/SyncStatus.vue'), + meta: { title: '同步状态' }, + }, { path: '/chat/:channel/:ownerId/:talkerId', name: 'chat', @@ -21,6 +46,15 @@ const routes: RouteRecordRaw[] = [ props: true, meta: { title: '聊天记录' }, }, + { + // Group chat timeline — same component, but with a `roomId` prop so it + // issues a room_id query instead of a talker_id query. + path: '/group/:channel/:ownerId/:roomId', + name: 'group-chat', + component: () => import('@/views/ChatTimeline.vue'), + props: true, + meta: { title: '群聊记录' }, + }, { // Anything else → back to the list. path: '/:pathMatch(.*)*', diff --git a/yunkes/src/utils/format.ts b/yunkes/src/utils/format.ts index d340a54..0ee1609 100644 --- a/yunkes/src/utils/format.ts +++ b/yunkes/src/utils/format.ts @@ -45,3 +45,68 @@ export function channelLabel(channel?: string | null): string { return channel ?? '未知' } } + +// --------------------------------------------------------------------------- +// Phone / voice call rendering +// --------------------------------------------------------------------------- + +/** Call-type (PhoneCall.channel) label — 电话 vs 语音通话. */ +export function callTypeLabel(channel?: string | null): string { + switch (channel) { + case 'phone_call': + return '电话' + case 'voice_call': + return '语音通话' + default: + return channel ?? '未知' + } +} + +/** Call direction. ``incoming_call``: 1 = 呼入 (incoming), 0 = 呼出 (outgoing). */ +export function callDirectionLabel(incoming?: number | null): string { + if (incoming == null) return '--' + return incoming === 1 ? '呼入' : '呼出' +} + +/** + * Call status. The upstream schema uses 1 = 已接通 (answered/connected) and + * 0 = 未接通 (missed/failed); other codes fall back to the raw value so the UI + * never hides information. + */ +export function callStatusLabel(status?: number | null): string { + if (status == null) return '--' + switch (status) { + case 1: + return '已接通' + case 0: + return '未接通' + default: + return `状态 ${status}` + } +} + +/** Render call_duration (seconds) as `N"s` / `M'SS"`; ``--`` when missing. */ +export function formatCallDuration(seconds?: number | null): string { + if (!seconds || !Number.isFinite(seconds) || seconds <= 0) return '--' + return formatDuration(seconds) +} + +// --------------------------------------------------------------------------- +// SMS rendering +// --------------------------------------------------------------------------- + +/** + * SMS direction. Conventions: ``direction`` 1 = 发送 (outbound, staff → + * customer), 0 = 接收 (inbound). Other values fall back to the raw code. + */ +export function smsDirectionLabel(direction?: number | null): string { + if (direction == null) return '--' + switch (direction) { + case 1: + return '发送' + case 0: + return '接收' + default: + return `方向 ${direction}` + } +} diff --git a/yunkes/src/views/CallList.vue b/yunkes/src/views/CallList.vue new file mode 100644 index 0000000..0366aba --- /dev/null +++ b/yunkes/src/views/CallList.vue @@ -0,0 +1,243 @@ + + + + + diff --git a/yunkes/src/views/ChatTimeline.vue b/yunkes/src/views/ChatTimeline.vue index d7167f5..9c60fac 100644 --- a/yunkes/src/views/ChatTimeline.vue +++ b/yunkes/src/views/ChatTimeline.vue @@ -8,10 +8,19 @@ import type { Channel, Message } from '@/api/types' import MessageBubble from '@/components/MessageBubble.vue' import { channelLabel } from '@/utils/format' +/** + * Chat timeline. Reused for two conversation shapes: + * * 1:1 DM — `talkerId` set; messages filtered by `talker_id`. + * * Group — `roomId` set; messages filtered by `room_id`. Group messages + * carry per-member `sender_name` / `sender_head_url`, so each + * bubble identifies its sender (handled by MessageBubble). + * Exactly one of `talkerId` / `roomId` is normally present. + */ const props = defineProps<{ channel: string ownerId: string - talkerId: string + talkerId?: string + roomId?: string }>() const router = useRouter() @@ -31,11 +40,17 @@ const total = ref(0) const channelTyped = computed(() => props.channel as Channel) +/** True when this timeline is a group chat (drives the query + title). */ +const isGroup = computed(() => !!props.roomId) + const hasMoreOlder = computed(() => currentPage.value > 1) -const conversationTitle = computed( - () => `与 ${props.talkerId} 的聊天记录`, -) +const conversationTitle = computed(() => { + if (isGroup.value) { + return `群聊记录 · ${props.roomId}` + } + return `与 ${props.talkerId} 的聊天记录` +}) const subtitle = computed( () => @@ -52,6 +67,18 @@ function scrollToBottom(): void { }) } +/** Shared query params for both DM and group timelines. */ +function queryFor(pageNum: number) { + return { + channel: channelTyped.value, + owner_account_id: props.ownerId, + talker_id: isGroup.value ? undefined : props.talkerId, + room_id: isGroup.value ? props.roomId : undefined, + page: pageNum, + page_size: PAGE_SIZE, + } +} + /** * Fetch the last page (most recent messages) on entry. Page 1 is fetched first * purely to learn `total` so we can compute `lastPage` — the messages API @@ -61,26 +88,14 @@ async function loadInitial(): Promise { loading.value = true errorMsg.value = null try { - const first = await getMessages({ - channel: channelTyped.value, - owner_account_id: props.ownerId, - talker_id: props.talkerId, - page: 1, - page_size: PAGE_SIZE, - }) + const first = await getMessages(queryFor(1)) total.value = first.total totalPages.value = Math.max(1, Math.ceil(first.total / PAGE_SIZE)) if (totalPages.value <= 1) { messages.value = first.items } else { - const last = await getMessages({ - channel: channelTyped.value, - owner_account_id: props.ownerId, - talker_id: props.talkerId, - page: totalPages.value, - page_size: PAGE_SIZE, - }) + const last = await getMessages(queryFor(totalPages.value)) messages.value = last.items } currentPage.value = totalPages.value @@ -100,13 +115,7 @@ async function loadOlder(): Promise { const prevHeight = streamRef.value?.scrollHeight ?? 0 try { const prevPage = currentPage.value - 1 - const res = await getMessages({ - channel: channelTyped.value, - owner_account_id: props.ownerId, - talker_id: props.talkerId, - page: prevPage, - page_size: PAGE_SIZE, - }) + const res = await getMessages(queryFor(prevPage)) // Backend returns asc, so res.items are older than everything currently // shown — prepend them. messages.value = [...res.items, ...messages.value] @@ -134,7 +143,7 @@ function goBack(): void { // Reload when the route params change (navigating between conversations). watch( - () => [props.channel, props.ownerId, props.talkerId] as const, + () => [props.channel, props.ownerId, props.talkerId, props.roomId] as const, () => { loadInitial() }, diff --git a/yunkes/src/views/GroupList.vue b/yunkes/src/views/GroupList.vue new file mode 100644 index 0000000..18818d4 --- /dev/null +++ b/yunkes/src/views/GroupList.vue @@ -0,0 +1,222 @@ + + + + + diff --git a/yunkes/src/views/SmsList.vue b/yunkes/src/views/SmsList.vue new file mode 100644 index 0000000..cad33a0 --- /dev/null +++ b/yunkes/src/views/SmsList.vue @@ -0,0 +1,217 @@ + + + + + diff --git a/yunkes/src/views/SyncStatus.vue b/yunkes/src/views/SyncStatus.vue new file mode 100644 index 0000000..390223f --- /dev/null +++ b/yunkes/src/views/SyncStatus.vue @@ -0,0 +1,171 @@ + + + + +