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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(.*)*',
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user