From 4aeed003a4c962fe3cc3c3293d49fba9dfbe4b4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Thu, 20 Aug 2026 11:08:13 +0800 Subject: [PATCH] =?UTF-8?q?feat(calls):=20=E5=BE=AE=E4=BF=A1=E8=AF=AD?= =?UTF-8?q?=E9=9F=B3=E5=88=97=E8=A1=A8=E5=8A=A0=E4=BB=8A=E6=97=A5=E7=BB=9F?= =?UTF-8?q?=E8=AE=A1=E5=8D=A1=E7=89=87+=E9=80=9A=E8=AF=9D=E6=97=B6?= =?UTF-8?q?=E9=97=B4=E8=8C=83=E5=9B=B4=E7=AD=9B=E9=80=89+=E5=AE=A2?= =?UTF-8?q?=E6=88=B7=E8=B7=B3=E8=BD=AC=E5=B8=A6=E9=80=9A=E8=AF=9D=E6=97=B6?= =?UTF-8?q?=E9=97=B4=E5=AE=9A=E4=BD=8D(=E5=AF=B9=E9=BD=90=E4=BA=91?= =?UTF-8?q?=E5=AE=A2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /api/calls/stats:今日(Asia/Shanghai)好友呼入/外呼量+群量+时长聚合(@chatroom判群) - CallList:5张统计卡片(仅微信语音tab)+datetimerange筛选(服务端start/end)+viewCallCustomer带?start定位 - TDD新增stats聚合测试,全量绿 Co-Authored-By: Claude --- yunke-server/app/api/routers/calls.py | 39 ++++++++++- yunke-server/app/api/schemas.py | 14 ++++ yunke-server/tests/test_api_calls.py | 34 ++++++++++ yunkes/src/api/index.ts | 5 ++ yunkes/src/api/types.ts | 10 +++ yunkes/src/views/CallList.vue | 98 ++++++++++++++++++++++++--- 6 files changed, 188 insertions(+), 12 deletions(-) diff --git a/yunke-server/app/api/routers/calls.py b/yunke-server/app/api/routers/calls.py index f7996c5..01cdd25 100644 --- a/yunke-server/app/api/routers/calls.py +++ b/yunke-server/app/api/routers/calls.py @@ -9,6 +9,7 @@ Ordered by ``start_call_time desc`` (nulls last) — most recent calls first. from __future__ import annotations +import datetime as dt from typing import Annotated, Literal import sqlalchemy as sa @@ -16,7 +17,7 @@ from fastapi import APIRouter, Depends, Query from sqlalchemy.ext.asyncio import AsyncSession from app.api.deps import get_session -from app.api.schemas import Page, PhoneCallOut +from app.api.schemas import CallStatsOut, Page, PhoneCallOut from app.db.models import PhoneCall router = APIRouter(prefix="/api", tags=["calls"]) @@ -26,6 +27,42 @@ PageParam = Annotated[int, Query(ge=1)] PageSizeParam = Annotated[int, Query(ge=1, le=100)] +@router.get("/calls/stats") +async def call_stats(session: SessionDep) -> CallStatsOut: + """今日(Asia/Shanghai)微信语音统计卡片 — 对齐云客语音列表顶部。 + + 好友/群按 ``call_number`` 是否以 ``@chatroom`` 结尾(与 ``scope`` 同判据, + 与云客 isRoom 实测等价);呼入/出按 ``incoming_call``(1=呼入/0=呼出, + None 不计)。 + """ + # Asia/Shanghai (UTC+8) 的今日 00:00 → ms epoch,与 /api/sessions 的 + # today_start 同一约定(部署时区无关) + shanghai_now = dt.datetime.now(dt.UTC) + dt.timedelta(hours=8) + shanghai_midnight = shanghai_now.replace(hour=0, minute=0, second=0, microsecond=0) + today_start_ms = int((shanghai_midnight - dt.timedelta(hours=8)).timestamp() * 1000) + + is_room = PhoneCall.call_number.endswith("@chatroom") + base = [PhoneCall.channel == "voice_call", PhoneCall.start_call_time >= today_start_ms] + + async def _count(*extra) -> int: + stmt = sa.select(sa.func.count()).select_from(PhoneCall).where(*base, *extra) + return (await session.execute(stmt)).scalar_one() + + async def _duration(*extra) -> int: + stmt = sa.select(sa.func.coalesce(sa.func.sum(PhoneCall.call_duration), 0)).where(*base, *extra) + return (await session.execute(stmt)).scalar_one() + + friend = ~is_room + return CallStatsOut( + friend_incoming=await _count(friend, PhoneCall.incoming_call == 1), + friend_outgoing=await _count(friend, PhoneCall.incoming_call == 0), + group_count=await _count(is_room), + friend_incoming_duration=await _duration(friend, PhoneCall.incoming_call == 1), + friend_outgoing_duration=await _duration(friend, PhoneCall.incoming_call == 0), + group_duration=await _duration(is_room), + ) + + @router.get("/calls") async def list_calls( session: SessionDep, diff --git a/yunke-server/app/api/schemas.py b/yunke-server/app/api/schemas.py index bb1b50f..739884a 100644 --- a/yunke-server/app/api/schemas.py +++ b/yunke-server/app/api/schemas.py @@ -194,6 +194,20 @@ class GroupMemberOut(BaseModel): avatar: str | None = None +class CallStatsOut(BaseModel): + """今日微信语音统计卡片(GET /api/calls/stats,对齐云客语音列表顶部)。 + + 好友呼入/外呼量与时长、群语音量与群录音时长;时长单位秒(call_duration 累加)。 + """ + + friend_incoming: int + friend_outgoing: int + group_count: int + friend_incoming_duration: int + friend_outgoing_duration: int + group_duration: int + + class PhoneCallOut(BaseModel): """Phone / voice call record. ``channel`` is the call-type. diff --git a/yunke-server/tests/test_api_calls.py b/yunke-server/tests/test_api_calls.py index d01f400..5912eaa 100644 --- a/yunke-server/tests/test_api_calls.py +++ b/yunke-server/tests/test_api_calls.py @@ -347,3 +347,37 @@ async def test_scope_invalid_value_rejected(make_client): client = await make_client(_seed_voice_scopes) r = await client.get("/api/calls", params={"scope": "bogus"}) assert r.status_code == 422 + + +# --------------------------------------------------------------------------- +# GET /api/calls/stats — 微信语音列表顶部统计卡片(今日,对齐云客) +# --------------------------------------------------------------------------- + +async def _seed_voice_stats(session: AsyncSession) -> None: + """今日(按 Asia/Shanghai)语音:好友呼入2/呼出1/群1,呼出接通时长60s。""" + import datetime as dt + + # today_start 计算依赖时区,直接用一个肯定在"今天"的远未来时间戳构造会跨天; + # 改用相对 now 的 ms —— 种子用比 now 晚 1ms 保证落进今天窗口。 + soon = int(dt.datetime.now(dt.UTC).timestamp() * 1000) + 1000 + session.add_all([ + PhoneCall(call_id="s1", channel="voice_call", start_call_time=soon, incoming_call=1, call_duration=30, call_number="q1"), + PhoneCall(call_id="s2", channel="voice_call", start_call_time=soon, incoming_call=1, call_duration=10, call_number="q2"), + PhoneCall(call_id="s3", channel="voice_call", start_call_time=soon, incoming_call=0, call_duration=60, call_number="q3"), + PhoneCall(call_id="s4", channel="voice_call", start_call_time=soon, incoming_call=1, call_duration=5, call_number="r@chatroom"), + PhoneCall(call_id="s5_old", channel="voice_call", start_call_time=1000, incoming_call=1, call_duration=99, call_number="q_old"), + ]) + await session.commit() + + +async def test_calls_stats_today_aggregation(make_client): + client = await make_client(_seed_voice_stats) + r = await client.get("/api/calls/stats") + assert r.status_code == 200 + s = r.json() + assert s["friend_incoming"] == 2 + assert s["friend_outgoing"] == 1 + assert s["group_count"] == 1 + assert s["friend_outgoing_duration"] == 60 + assert s["friend_incoming_duration"] == 40 + assert s["group_duration"] == 5 diff --git a/yunkes/src/api/index.ts b/yunkes/src/api/index.ts index 1ceb316..c73f4af 100644 --- a/yunkes/src/api/index.ts +++ b/yunkes/src/api/index.ts @@ -7,6 +7,7 @@ import { getJson, requestJson } from './client' import type { Account, CallChannel, + CallStats, Channel, Customer, Department, @@ -185,6 +186,10 @@ export interface CallQuery { page_size?: number } +export function getCallStats(): Promise { + return getJson('/calls/stats') +} + export function getCalls(query: CallQuery = {}): Promise> { return getJson>( '/calls', diff --git a/yunkes/src/api/types.ts b/yunkes/src/api/types.ts index b853820..6cb5168 100644 --- a/yunkes/src/api/types.ts +++ b/yunkes/src/api/types.ts @@ -120,6 +120,16 @@ export interface GroupMember { */ export type CallChannel = 'phone_call' | 'voice_call' | 'wa_voice_call' +export interface CallStats { + /** 今日微信语音统计卡片(GET /api/calls/stats,时长单位秒)。 */ + friend_incoming: number + friend_outgoing: number + group_count: number + friend_incoming_duration: number + friend_outgoing_duration: number + group_duration: number +} + export interface PhoneCall { call_id: string channel: CallChannel diff --git a/yunkes/src/views/CallList.vue b/yunkes/src/views/CallList.vue index 9666de0..5a2e084 100644 --- a/yunkes/src/views/CallList.vue +++ b/yunkes/src/views/CallList.vue @@ -3,8 +3,8 @@ import { computed, onMounted, ref, watch } from 'vue' import { useRoute, useRouter } from 'vue-router' import { ElMessage } from 'element-plus' import { Download } from '@element-plus/icons-vue' -import { getCalls } from '@/api' -import type { CallChannel, PhoneCall } from '@/api/types' +import { getCallStats, getCalls } from '@/api' +import type { CallChannel, CallStats, PhoneCall } from '@/api/types' import { callDirectionLabel, callStatusLabel, @@ -30,6 +30,27 @@ const callType = ref( // 仅在 voice_call tab 生效;client-side 过滤当前页。 const voiceScope = ref<'friend' | 'group'>('friend') +/** 通话时间范围筛选([startMs, endMs] | null,对齐云客高级筛选)。 */ +const callRange = ref<[string, string] | null>(null) + +/** 今日微信语音统计卡片(对齐云客语音列表顶部)。 */ +const stats = ref(null) + +async function loadStats(): Promise { + try { + stats.value = await getCallStats() + } catch { + stats.value = null // 卡片失败不阻塞列表 + } +} + +function fmtDur(sec: number | undefined): string { + if (!sec) return '0秒' + const m = Math.floor(sec / 60) + const s = sec % 60 + return m > 0 ? `${m}分${s}秒` : `${s}秒` +} + const calls = ref([]) const total = ref(0) const page = ref(1) @@ -66,6 +87,8 @@ async function load(): Promise { channel: callType.value, // 微信语音 好友/群 scope 为服务端过滤(@chatroom 后缀判群,与云客 isRoom 等价) scope: callType.value === 'voice_call' ? voiceScope.value : undefined, + start: callRange.value?.[0] ? Number(callRange.value[0]) : undefined, + end: callRange.value?.[1] ? Number(callRange.value[1]) : undefined, page: page.value, page_size: pageSize.value, }) @@ -79,6 +102,12 @@ async function load(): Promise { } } +/** 通话时间范围变更 → 回第 1 页重查(服务端过滤)。 */ +function handleRangeChange(): void { + page.value = 1 + load() +} + function handlePageChange(p: number): void { page.value = p load() @@ -101,6 +130,7 @@ watch( // 复位 scope,避免把 微信群/好友 筛选带到 WA语音。 voiceScope.value = 'friend' load() + loadStats() } }, ) @@ -226,6 +256,8 @@ function viewCallCustomer(row: PhoneCall): void { ownerId: String(owner), talkerId: String(talker), }, + // 对齐云客:带通话时间定位(?start=),聊天窗滚动到该通通话附近 + query: row.start_call_time ? { start: String(row.start_call_time) } : undefined, }) } @@ -243,7 +275,10 @@ const drawerSections = computed(() => drawerCall.value ? buildCallSections(drawerCall.value) : [], ) -onMounted(load) +onMounted(() => { + load() + loadStats() +})