feat: remaining frontend views (groups/calls/sms/sync) + messageout media fields

Backend (Part A):
- MessageOut: add file_th_url, file_hd_url, duration, transcription_text, md5
  (room_id + sender_head_url already existed) so the chat timeline can render
  image thumbnails, voice duration/transcription, and avatars from a single
  response.
- PhoneCallOut: add record_file so the Calls view can offer recording playback.
- SmsOut: add content (full body) so the SMS detail dialog shows the whole text.
- Tests: red->green for each new field (media fields default None + populated,
  record_file round-trip, content round-trip). Suite 267 -> 271.

Frontend (Part B):
- api/index.ts: wrappers getGroups/getGroupMembers/getCalls/getSms/getSyncStatus
  + Group/GroupMember/PhoneCall/Sms/SyncState/SyncRun/SyncStatus types in
  types.ts (mirror the *Out pydantic schemas).
- views/GroupList.vue (/groups): table + channel filter + pagination; row click
  opens the group timeline.
- views/CallList.vue (/calls): table + call-type filter (phone_call/voice_call/
  all); "播放录音" action opens an el-dialog with <audio controls>.
- views/SmsList.vue (/sms): table; row click opens a detail dialog with the full
  content.
- views/SyncStatus.vue (/sync): two tables (states + recent_runs) with a manual
  refresh; run status color-coded.
- ChatTimeline.vue: reused for both 1:1 DM (talkerId) and group (roomId) by
  branching the query; group title + per-member sender already handled by
  MessageBubble.
- router: add /groups /calls /sms /sync + /group/:channel/:ownerId/:roomId.
- layouts/MainLayout.vue: replace the single 客户列表 button with an el-menu
  (客户 / 群 / 通话 / 短信 / 同步); org tree kept on the left.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
浅念
2026-08-01 16:38:47 +08:00
parent 6c4cd92ae6
commit b822e87d4d
14 changed files with 1401 additions and 36 deletions
+14
View File
@@ -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
+17
View File
@@ -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
+99
View File
@@ -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
+17
View File
@@ -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
+118 -1
View File
@@ -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<Page<Staff>> {
)
}
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<Page<Group>> {
return getJson<Page<Group>>(
'/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<GroupMember[]> {
// room_id can contain '@' — axios encodes params, but path segments need
// explicit encoding so the FastAPI `{room_id}` path param matches.
return getJson<GroupMember[]>(
`/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<Page<PhoneCall>> {
return getJson<Page<PhoneCall>>(
'/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<Page<Sms>> {
return getJson<Page<Sms>>(
'/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<SyncStatus> {
return getJson<SyncStatus>('/sync/status')
}
export type {
CallChannel,
Channel,
Customer,
Department,
Group,
GroupMember,
Message,
Page,
PhoneCall,
Sms,
Staff,
SyncRun,
SyncState,
SyncStatus,
}
+95
View File
@@ -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'
+53 -8
View File
@@ -21,6 +21,18 @@ const selectedDeptId = computed<string | null>(
() => (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<void> {
try {
await org.ensureLoaded(true)
@@ -100,10 +108,34 @@ onMounted(async () => {
</el-radio-button>
</el-radio-group>
<span class="yk-header__spacer" />
<el-button text @click="goCustomers">
<el-icon><ChatLineSquare /></el-icon>
<span style="margin-left: 4px">客户列表</span>
</el-button>
<el-menu
:default-active="activeMenu"
mode="horizontal"
:ellipsis="false"
class="yk-header__nav"
router
>
<el-menu-item index="/customers">
<el-icon><ChatLineSquare /></el-icon>
<span>客户</span>
</el-menu-item>
<el-menu-item index="/groups">
<el-icon><User /></el-icon>
<span>群</span>
</el-menu-item>
<el-menu-item index="/calls">
<el-icon><Phone /></el-icon>
<span>通话</span>
</el-menu-item>
<el-menu-item index="/sms">
<el-icon><Message /></el-icon>
<span>短信</span>
</el-menu-item>
<el-menu-item index="/sync">
<el-icon><Refresh /></el-icon>
<span>同步</span>
</el-menu-item>
</el-menu>
</el-header>
<el-container>
@@ -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);
}
</style>
+35 -1
View File
@@ -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(.*)*',
+65
View File
@@ -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}`
}
}
+243
View File
@@ -0,0 +1,243 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { getCalls } from '@/api'
import type { CallChannel, PhoneCall } from '@/api/types'
import {
callDirectionLabel,
callStatusLabel,
callTypeLabel,
formatCallDuration,
formatTime,
} from '@/utils/format'
// 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
// the shared top-bar wechat/whatsapp store used by customers/groups.
const callType = ref<CallChannel | 'all'>('all')
const calls = ref<PhoneCall[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(20)
const loading = ref(false)
const errorMsg = ref<string | null>(null)
// Recording playback dialog state.
const recordDialogVisible = ref(false)
const recordSrc = ref<string | null>(null)
const recordTitle = ref('')
const typeOptions: { value: CallChannel | 'all'; label: string }[] = [
{ value: 'all', label: '全部类型' },
{ value: 'phone_call', label: '电话' },
{ value: 'voice_call', label: '语音通话' },
]
const typeLabelCurrent = computed(() => {
const opt = typeOptions.find((o) => o.value === callType.value)
return opt ? opt.label : '全部类型'
})
async function load(): Promise<void> {
loading.value = true
errorMsg.value = null
try {
const res = await getCalls({
channel: callType.value,
page: page.value,
page_size: pageSize.value,
})
calls.value = res.items
total.value = res.total
} catch (e) {
errorMsg.value = (e as Error).message
ElMessage.error(`通话记录加载失败:${(e as Error).message}`)
} finally {
loading.value = false
}
}
function handlePageChange(p: number): void {
page.value = p
load()
}
function handlePageSizeChange(s: number): void {
pageSize.value = s
page.value = 1
load()
}
function onTypeChange(value: string | number | boolean | undefined): void {
callType.value = value as CallChannel | 'all'
page.value = 1
load()
}
function playRecord(row: PhoneCall): void {
if (!row.record_file) {
ElMessage.info('该通话无录音文件')
return
}
recordSrc.value = row.record_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
}
onMounted(load)
</script>
<template>
<div class="yk-calls">
<div class="yk-calls__header">
<div>
<h2 style="margin: 0 0 4px; font-size: 16px">通话记录</h2>
<span class="yk-calls__sub">
共 {{ total }} 条 · 类型:{{ typeLabelCurrent }}
</span>
</div>
<div class="yk-calls__filters">
<el-radio-group :model-value="callType" size="small" @change="onTypeChange">
<el-radio-button
v-for="opt in typeOptions"
:key="opt.value"
:value="opt.value"
>
{{ opt.label }}
</el-radio-button>
</el-radio-group>
</div>
</div>
<el-alert
v-if="errorMsg"
:title="errorMsg"
type="error"
show-icon
:closable="false"
style="margin-bottom: 12px"
/>
<el-table
v-loading="loading"
:data="calls"
stripe
border
row-key="call_id"
style="width: 100%"
empty-text="暂无通话记录"
>
<el-table-column label="时间" width="160">
<template #default="{ row }">{{ formatTime(row.start_call_time) }}</template>
</el-table-column>
<el-table-column label="员工" min-width="130">
<template #default="{ row }">
{{ row.user_id_name || row.user_id || '--' }}
</template>
</el-table-column>
<el-table-column label="号码" min-width="130">
<template #default="{ row }">
{{ row.call_number || row.sim_phone || '--' }}
</template>
</el-table-column>
<el-table-column label="方向" width="80">
<template #default="{ row }">{{ callDirectionLabel(row.incoming_call) }}</template>
</el-table-column>
<el-table-column label="类型" width="100">
<template #default="{ row }">{{ callTypeLabel(row.channel) }}</template>
</el-table-column>
<el-table-column label="时长" width="90" align="right">
<template #default="{ row }">{{ formatCallDuration(row.call_duration) }}</template>
</el-table-column>
<el-table-column label="状态" width="90">
<template #default="{ row }">{{ callStatusLabel(row.call_status) }}</template>
</el-table-column>
<el-table-column label="录音" width="100" align="center">
<template #default="{ row }">
<el-button
v-if="row.record_file"
size="small"
type="primary"
link
@click.stop="playRecord(row)"
>
播放录音
</el-button>
<span v-else style="color: #c0c4cc">--</span>
</template>
</el-table-column>
</el-table>
<div class="yk-calls__pager">
<el-pagination
:current-page="page"
:page-size="pageSize"
:page-sizes="[20, 50, 100]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
background
@current-change="handlePageChange"
@size-change="handlePageSizeChange"
/>
</div>
<el-dialog
v-model="recordDialogVisible"
:title="recordTitle"
width="480px"
align-center
@closed="recordSrc = null"
>
<audio
v-if="recordSrc"
controls
:src="recordSrc"
style="width: 100%"
preload="metadata"
>
您的浏览器不支持音频播放。
</audio>
<div v-if="recordSrc" class="yk-calls__record-link">
<a :href="recordSrc" target="_blank" rel="noopener">在新窗口打开录音文件</a>
</div>
</el-dialog>
</div>
</template>
<style scoped>
.yk-calls__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.yk-calls__sub {
color: #909399;
font-size: 13px;
}
.yk-calls__filters {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.yk-calls__pager {
margin-top: 12px;
display: flex;
justify-content: flex-end;
}
.yk-calls__record-link {
margin-top: 8px;
font-size: 12px;
text-align: center;
}
</style>
+35 -26
View File
@@ -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<void> {
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<void> {
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()
},
+222
View File
@@ -0,0 +1,222 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { getGroups } from '@/api'
import type { Channel, Group } from '@/api/types'
import { useChannelStore } from '@/stores/channel'
import { channelLabel, formatTime } from '@/utils/format'
const router = useRouter()
const channelStore = useChannelStore()
const groups = ref<Group[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(20)
const loading = ref(false)
const errorMsg = ref<string | null>(null)
const channelOptions = [
{ value: 'all' as const, label: '全部渠道' },
{ value: 'wechat' as const, label: '微信' },
{ value: 'whatsapp' as const, label: 'WhatsApp' },
]
const channelLabelCurrent = computed(() =>
channelLabel(channelStore.channel === 'all' ? null : channelStore.channel),
)
function channelTagType(c: Group['channel']) {
return c === 'wechat' ? 'success' : 'warning'
}
function onChannelChange(value: string | number | boolean | undefined): void {
channelStore.setChannel(value as Channel | 'all')
}
async function load(): Promise<void> {
loading.value = true
errorMsg.value = null
try {
const res = await getGroups({
channel: channelStore.channel,
page: page.value,
page_size: pageSize.value,
})
groups.value = res.items
total.value = res.total
} catch (e) {
errorMsg.value = (e as Error).message
ElMessage.error(`群列表加载失败:${(e as Error).message}`)
} finally {
loading.value = false
}
}
function handlePageChange(p: number): void {
page.value = p
load()
}
function handlePageSizeChange(s: number): void {
pageSize.value = s
page.value = 1
load()
}
// React to the top-bar channel filter.
watch(
() => channelStore.channel,
() => {
page.value = 1
load()
},
)
function openTimeline(row: Group): void {
// Group messages are queried by room_id; the timeline view branches on the
// presence of a `roomId` route param to issue a room_id query.
if (!row.owner_account_id) {
ElMessage.warning('该群缺少归属账号,无法查看聊天记录')
return
}
router.push({
name: 'group-chat',
params: {
channel: row.channel,
ownerId: row.owner_account_id,
roomId: row.room_id,
},
})
}
onMounted(load)
</script>
<template>
<div class="yk-groups">
<div class="yk-groups__header">
<div>
<h2 style="margin: 0 0 4px; font-size: 16px">群列表</h2>
<span class="yk-groups__sub">
共 {{ total }} 条 · 渠道:{{ channelLabelCurrent }}
</span>
</div>
<div class="yk-groups__filters">
<el-radio-group
:model-value="channelStore.channel"
size="small"
@change="onChannelChange"
>
<el-radio-button
v-for="opt in channelOptions"
:key="opt.value"
:value="opt.value"
>
{{ opt.label }}
</el-radio-button>
</el-radio-group>
</div>
</div>
<el-alert
v-if="errorMsg"
:title="errorMsg"
type="error"
show-icon
:closable="false"
style="margin-bottom: 12px"
/>
<el-table
v-loading="loading"
:data="groups"
stripe
border
row-key="room_id"
style="width: 100%"
:row-class-name="() => 'yk-clickable-row'"
empty-text="暂无群数据"
@row-click="openTimeline"
>
<el-table-column label="群名" min-width="180">
<template #default="{ row }">
{{ row.room_name || row.room_id }}
</template>
</el-table-column>
<el-table-column label="渠道" width="100">
<template #default="{ row }">
<el-tag :type="channelTagType(row.channel)" size="small" effect="plain">
{{ channelLabel(row.channel) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="成员数" prop="member_count" width="90" align="right">
<template #default="{ row }">{{ row.member_count ?? '--' }}</template>
</el-table-column>
<el-table-column label="消息数" prop="message_count" width="90" align="right">
<template #default="{ row }">{{ row.message_count ?? '--' }}</template>
</el-table-column>
<el-table-column label="群主" min-width="140">
<template #default="{ row }">
<span v-if="row.owner_account_nickname">{{ row.owner_account_nickname }}</span>
<span v-else-if="row.owner_account_id">{{ row.owner_account_id }}</span>
<span v-else style="color: #c0c4cc">--</span>
</template>
</el-table-column>
<el-table-column label="最近聊天" width="160">
<template #default="{ row }">{{ formatTime(row.last_chat_time) }}</template>
</el-table-column>
</el-table>
<div class="yk-groups__pager">
<el-pagination
:current-page="page"
:page-size="pageSize"
:page-sizes="[20, 50, 100]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
background
@current-change="handlePageChange"
@size-change="handlePageSizeChange"
/>
</div>
<p class="yk-groups__hint">点击任意一行查看该群的聊天记录。</p>
</div>
</template>
<style scoped>
.yk-groups__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.yk-groups__sub {
color: #909399;
font-size: 13px;
}
.yk-groups__filters {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.yk-groups__pager {
margin-top: 12px;
display: flex;
justify-content: flex-end;
}
.yk-groups__hint {
margin: 8px 2px 0;
font-size: 12px;
color: #c0c4cc;
}
</style>
+217
View File
@@ -0,0 +1,217 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { getSms } from '@/api'
import type { Sms } from '@/api/types'
import { formatTime, smsDirectionLabel } from '@/utils/format'
const list = ref<Sms[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(20)
const loading = ref(false)
const errorMsg = ref<string | null>(null)
// Detail dialog state — shows the full SMS body (vs the truncated preview in
// the table).
const detailVisible = ref(false)
const detailRow = ref<Sms | null>(null)
async function load(): Promise<void> {
loading.value = true
errorMsg.value = null
try {
const res = await getSms({
page: page.value,
page_size: pageSize.value,
})
list.value = res.items
total.value = res.total
} catch (e) {
errorMsg.value = (e as Error).message
ElMessage.error(`短信记录加载失败:${(e as Error).message}`)
} finally {
loading.value = false
}
}
function handlePageChange(p: number): void {
page.value = p
load()
}
function handlePageSizeChange(s: number): void {
pageSize.value = s
page.value = 1
load()
}
function openDetail(row: Sms): void {
detailRow.value = row
detailVisible.value = true
}
onMounted(load)
</script>
<template>
<div class="yk-sms">
<div class="yk-sms__header">
<div>
<h2 style="margin: 0 0 4px; font-size: 16px">短信记录</h2>
<span class="yk-sms__sub">共 {{ total }} 条</span>
</div>
</div>
<el-alert
v-if="errorMsg"
:title="errorMsg"
type="error"
show-icon
:closable="false"
style="margin-bottom: 12px"
/>
<el-table
v-loading="loading"
:data="list"
stripe
border
row-key="sms_id"
style="width: 100%"
:row-class-name="() => 'yk-clickable-row'"
empty-text="暂无短信记录"
@row-click="openDetail"
>
<el-table-column label="时间" width="160">
<template #default="{ row }">{{ formatTime(row.create_time) }}</template>
</el-table-column>
<el-table-column label="员工" min-width="130">
<template #default="{ row }">
{{ row.receive_name || row.user_id || '--' }}
</template>
</el-table-column>
<el-table-column label="客户号码" min-width="130">
<template #default="{ row }">
{{ row.customer_phone || row.sender || '--' }}
</template>
</el-table-column>
<el-table-column label="方向" width="80">
<template #default="{ row }">{{ smsDirectionLabel(row.direction) }}</template>
</el-table-column>
<el-table-column label="内容预览" min-width="260">
<template #default="{ row }">
<span v-if="row.content_preview">{{ row.content_preview }}</span>
<span v-else-if="row.content">{{ row.content }}</span>
<span v-else style="color: #c0c4cc">--</span>
</template>
</el-table-column>
</el-table>
<div class="yk-sms__pager">
<el-pagination
:current-page="page"
:page-size="pageSize"
:page-sizes="[20, 50, 100]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
background
@current-change="handlePageChange"
@size-change="handlePageSizeChange"
/>
</div>
<p class="yk-sms__hint">点击任意一行查看完整短信内容。</p>
<el-dialog
v-model="detailVisible"
title="短信详情"
width="520px"
align-center
>
<div v-if="detailRow" class="yk-sms__detail">
<div class="yk-sms__detail-row">
<span class="yk-sms__detail-label">时间</span>
<span>{{ formatTime(detailRow.create_time) }}</span>
</div>
<div class="yk-sms__detail-row">
<span class="yk-sms__detail-label">员工</span>
<span>{{ detailRow.receive_name || detailRow.user_id || '--' }}</span>
</div>
<div class="yk-sms__detail-row">
<span class="yk-sms__detail-label">客户号码</span>
<span>{{ detailRow.customer_phone || '--' }}</span>
</div>
<div class="yk-sms__detail-row">
<span class="yk-sms__detail-label">发送方</span>
<span>{{ detailRow.sender || '--' }}</span>
</div>
<div class="yk-sms__detail-row">
<span class="yk-sms__detail-label">方向</span>
<span>{{ smsDirectionLabel(detailRow.direction) }}</span>
</div>
<div class="yk-sms__detail-row yk-sms__detail-row--top">
<span class="yk-sms__detail-label">内容</span>
<span class="yk-sms__detail-content">{{
detailRow.content || detailRow.content_preview || '(空)'
}}</span>
</div>
</div>
</el-dialog>
</div>
</template>
<style scoped>
.yk-sms__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.yk-sms__sub {
color: #909399;
font-size: 13px;
}
.yk-sms__pager {
margin-top: 12px;
display: flex;
justify-content: flex-end;
}
.yk-sms__hint {
margin: 8px 2px 0;
font-size: 12px;
color: #c0c4cc;
}
.yk-sms__detail {
display: flex;
flex-direction: column;
gap: 10px;
font-size: 13px;
}
.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;
color: #909399;
}
.yk-sms__detail-content {
white-space: pre-wrap;
word-break: break-word;
line-height: 1.6;
}
</style>
+171
View File
@@ -0,0 +1,171 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { Refresh } from '@element-plus/icons-vue'
import { getSyncStatus } from '@/api'
import type { SyncRun, SyncState } from '@/api/types'
import { formatTime } from '@/utils/format'
const states = ref<SyncState[]>([])
const recentRuns = ref<SyncRun[]>([])
const loading = ref(false)
const errorMsg = ref<string | null>(null)
async function load(): Promise<void> {
loading.value = true
errorMsg.value = null
try {
const res = await getSyncStatus()
states.value = res.states
recentRuns.value = res.recent_runs
} catch (e) {
errorMsg.value = (e as Error).message
ElMessage.error(`同步状态加载失败:${(e as Error).message}`)
} finally {
loading.value = false
}
}
/** Map a run status to a tag type for color coding. */
function runStatusTagType(status: string): 'success' | 'warning' | 'danger' | 'info' {
switch (status) {
case 'success':
return 'success'
case 'running':
return 'warning'
case 'failed':
return 'danger'
default:
return 'info'
}
}
/** last_timestamp is a ms-epoch; last_synced_at is an ISO datetime string. */
function isoToTime(iso?: string | null): string {
if (!iso) return '--'
const t = Date.parse(iso)
return Number.isNaN(t) ? '--' : formatTime(t)
}
onMounted(load)
</script>
<template>
<div class="yk-sync" v-loading="loading">
<div class="yk-sync__header">
<div>
<h2 style="margin: 0 0 4px; font-size: 16px">同步状态</h2>
<span class="yk-sync__sub">
游标 {{ states.length }} 条 · 最近执行 {{ recentRuns.length }} 条
</span>
</div>
<el-button :icon="Refresh" size="small" @click="load">刷新</el-button>
</div>
<el-alert
v-if="errorMsg"
:title="errorMsg"
type="error"
show-icon
:closable="false"
style="margin-bottom: 12px"
/>
<h3 class="yk-sync__section">同步游标</h3>
<el-table
:data="states"
stripe
border
row-key="state_id"
style="width: 100%"
empty-text="暂无游标"
>
<el-table-column label="实体" prop="entity_type" min-width="110" />
<el-table-column label="渠道" min-width="90">
<template #default="{ row }">{{ row.channel ?? '--' }}</template>
</el-table-column>
<el-table-column label="作用域" min-width="140">
<template #default="{ row }">{{ row.scope_id ?? '--' }}</template>
</el-table-column>
<el-table-column label="最近版本" min-width="110">
<template #default="{ row }">{{ row.last_version ?? '--' }}</template>
</el-table-column>
<el-table-column label="最近时间戳" width="170">
<template #default="{ row }">{{ formatTime(row.last_timestamp) }}</template>
</el-table-column>
<el-table-column label="最近同步时间" width="180">
<template #default="{ row }">{{ isoToTime(row.last_synced_at) }}</template>
</el-table-column>
</el-table>
<h3 class="yk-sync__section">最近执行</h3>
<el-table
:data="recentRuns"
stripe
border
row-key="run_id"
style="width: 100%"
empty-text="暂无执行记录"
>
<el-table-column label="实体" prop="entity_type" min-width="110" />
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="runStatusTagType(row.status)" size="small" effect="plain">
{{ row.status }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="渠道" min-width="90">
<template #default="{ row }">{{ row.channel ?? '--' }}</template>
</el-table-column>
<el-table-column label="作用域" min-width="130">
<template #default="{ row }">{{ row.scope_id ?? '--' }}</template>
</el-table-column>
<el-table-column label="开始时间" width="180">
<template #default="{ row }">{{ isoToTime(row.started_at) }}</template>
</el-table-column>
<el-table-column label="结束时间" width="180">
<template #default="{ row }">{{ isoToTime(row.finished_at) }}</template>
</el-table-column>
<el-table-column label="拉取/写入" width="120" align="right">
<template #default="{ row }">
{{ row.records_fetched }} / {{ row.records_upserted }}
</template>
</el-table-column>
<el-table-column label="错误" min-width="180">
<template #default="{ row }">
<span v-if="row.error" class="yk-sync__error">{{ row.error }}</span>
<span v-else style="color: #c0c4cc">--</span>
</template>
</el-table-column>
</el-table>
</div>
</template>
<style scoped>
.yk-sync__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.yk-sync__sub {
color: #909399;
font-size: 13px;
}
.yk-sync__section {
font-size: 14px;
font-weight: 600;
margin: 20px 0 8px;
color: #303133;
}
.yk-sync__error {
color: #f56c6c;
word-break: break-word;
}
</style>