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
+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'