feat: 会话与线索(列表/筛选/租户切换/详情消息流/推送日志/删除)
- 会话列表页签:分页表格 + 筛选(意向三值/站点精确/日期区间 daterange, 筛选变化重置第一页)+ 行操作(详情抽屉/删除确认框,级联消息画像评估 推送日志,末页删空自动回退一页) - 租户切换器:超管专用(fetchTenantList 下拉,默认第一个,切换联动双页签 并关抽屉);租户管理员固定登录态 tenant_slug,无切换器只有标签 - 详情抽屉:元数据(状态/来源/站点/IP 归国/session/设备)+ 画像 chips (ElTag 键值)+ 评估块(意向/理由/联系方式/更正次数)+ 消息流气泡 (user 右 assistant 左,时间直显库内 naive UTC 不换算,与筛选同口径) - 推送日志页签(列表平行):状态筛选(pending/success/failed/skipped) + 分页表格(类型/状态/尝试/outcome/HTTP/payload 大小/会话/错误)—— 列表接口不回大 payload,仅服务端 payload_size - api/conversations.ts 实装调用函数并按 data_routes.py 实际响应修正类型 (IntentLevel 三值/messages.time/详情扁平 evaluation+profile 块/ PushLogItem 全字段),新增枚举中文映射(intentLabel 等,未知值回退原样) - vitest +9(conversations API 契约:URL/参数/白名单/映射),总 45; 后端不动(pytest 577 基线复核) Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* 会话与线索 API 单元测试(Phase 3 Task 5)
|
||||
*
|
||||
* 钉住端点 URL 与查询参数组装(事实源 app/platform/routes/data_routes.py:
|
||||
* intent 白名单 interested/not_interested/pending、push status 白名单
|
||||
* pending/success/failed/skipped、from/to 闭区间、分页 page/page_size ≤100);
|
||||
* HTTP 层行为已在 client.test.ts 覆盖,此处 mock http 只断言 API 层契约。
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
http: { get: vi.fn(), del: vi.fn() }
|
||||
}))
|
||||
|
||||
import { http } from '@/api/client'
|
||||
import {
|
||||
CONVERSATION_ENDPOINTS,
|
||||
INTENT_OPTIONS,
|
||||
PUSH_STATUS_OPTIONS,
|
||||
conversationSourceLabel,
|
||||
conversationStatusLabel,
|
||||
deleteConversation,
|
||||
fetchConversationDetail,
|
||||
fetchConversations,
|
||||
fetchPushLogs,
|
||||
intentLabel,
|
||||
pushStatusLabel,
|
||||
pushTypeLabel
|
||||
} from '@/api/conversations'
|
||||
|
||||
describe('会话与线索 API(src/api/conversations.ts)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(http.get).mockResolvedValue({ total: 0, page: 1, page_size: 20, items: [] })
|
||||
vi.mocked(http.del).mockResolvedValue({ ok: true })
|
||||
})
|
||||
|
||||
it('fetchConversations:GET /api/platform/tenants/{slug}/conversations 带筛选+分页参数', async () => {
|
||||
await fetchConversations('jiancai', {
|
||||
intent: 'interested',
|
||||
site: 'es',
|
||||
from: '2026-10-01',
|
||||
to: '2026-10-13',
|
||||
page: 2,
|
||||
page_size: 50
|
||||
})
|
||||
|
||||
expect(http.get).toHaveBeenCalledWith(
|
||||
'/api/platform/tenants/jiancai/conversations',
|
||||
{
|
||||
params: {
|
||||
intent: 'interested',
|
||||
site: 'es',
|
||||
from: '2026-10-01',
|
||||
to: '2026-10-13',
|
||||
page: 2,
|
||||
page_size: 50
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('fetchConversations 无参数:params 为 undefined(后端缺省 page=1/page_size=20)', async () => {
|
||||
await fetchConversations('jiancai')
|
||||
|
||||
expect(http.get).toHaveBeenCalledWith(
|
||||
'/api/platform/tenants/jiancai/conversations',
|
||||
{ params: undefined }
|
||||
)
|
||||
})
|
||||
|
||||
it('fetchConversationDetail:GET .../conversations/{convId}', async () => {
|
||||
const detail = {
|
||||
id: 'c1',
|
||||
session_id: 'sess-c1',
|
||||
status: 'completed',
|
||||
source: 'widget',
|
||||
client_info: { ip: '8.8.8.8', site: 'es' },
|
||||
site: 'es',
|
||||
ip: '8.8.8.8',
|
||||
device: '',
|
||||
country: '美国',
|
||||
created_at: '2026-10-01T12:00:00',
|
||||
updated_at: '2026-10-01T12:00:00',
|
||||
evaluation: null,
|
||||
profile: { chips: { 姓名: 'John' }, summary: null },
|
||||
messages: [{ role: 'user', content: '您好', time: '2026-10-01T12:00:00' }]
|
||||
}
|
||||
vi.mocked(http.get).mockResolvedValue(detail)
|
||||
|
||||
const res = await fetchConversationDetail('jiancai', 'c1')
|
||||
|
||||
expect(res).toEqual(detail)
|
||||
expect(http.get).toHaveBeenCalledWith('/api/platform/tenants/jiancai/conversations/c1')
|
||||
})
|
||||
|
||||
it('deleteConversation:DELETE .../conversations/{convId},响应 {ok}', async () => {
|
||||
const res = await deleteConversation('jiancai', 'c1')
|
||||
|
||||
expect(res).toEqual({ ok: true })
|
||||
expect(http.del).toHaveBeenCalledWith('/api/platform/tenants/jiancai/conversations/c1')
|
||||
})
|
||||
|
||||
it('fetchPushLogs:GET .../push-logs 带 status/conversation_id/分页参数', async () => {
|
||||
await fetchPushLogs('jiancai', {
|
||||
status: 'failed',
|
||||
conversation_id: 'c1',
|
||||
page: 2,
|
||||
page_size: 10
|
||||
})
|
||||
|
||||
expect(http.get).toHaveBeenCalledWith('/api/platform/tenants/jiancai/push-logs', {
|
||||
params: { status: 'failed', conversation_id: 'c1', page: 2, page_size: 10 }
|
||||
})
|
||||
})
|
||||
|
||||
it('fetchPushLogs 无参数:params 为 undefined', async () => {
|
||||
await fetchPushLogs('jiancai')
|
||||
|
||||
expect(http.get).toHaveBeenCalledWith('/api/platform/tenants/jiancai/push-logs', {
|
||||
params: undefined
|
||||
})
|
||||
})
|
||||
|
||||
it('CONVERSATION_ENDPOINTS:端点形态与 data_routes.py 路由一致', () => {
|
||||
expect(CONVERSATION_ENDPOINTS.list('a')).toBe('/api/platform/tenants/a/conversations')
|
||||
expect(CONVERSATION_ENDPOINTS.detail('a', 'c1')).toBe(
|
||||
'/api/platform/tenants/a/conversations/c1'
|
||||
)
|
||||
expect(CONVERSATION_ENDPOINTS.remove('a', 'c1')).toBe(
|
||||
'/api/platform/tenants/a/conversations/c1'
|
||||
)
|
||||
expect(CONVERSATION_ENDPOINTS.pushLogs('a')).toBe('/api/platform/tenants/a/push-logs')
|
||||
})
|
||||
|
||||
it('筛选白名单选项:intent 三值 / push status 四值(与后端 INTENTS/PUSH_STATUSES 对齐)', () => {
|
||||
expect(INTENT_OPTIONS.map((item) => item.value)).toEqual([
|
||||
'interested',
|
||||
'not_interested',
|
||||
'pending'
|
||||
])
|
||||
expect(PUSH_STATUS_OPTIONS.map((item) => item.value)).toEqual([
|
||||
'pending',
|
||||
'success',
|
||||
'failed',
|
||||
'skipped'
|
||||
])
|
||||
})
|
||||
|
||||
it('展示映射:未知枚举值回退原样,空值有占底文案', () => {
|
||||
expect(intentLabel('interested')).toBe('有意向')
|
||||
expect(intentLabel(null)).toBe('未评估')
|
||||
expect(intentLabel('bogus')).toBe('bogus')
|
||||
expect(pushStatusLabel('skipped')).toBe('跳过')
|
||||
expect(pushStatusLabel('bogus')).toBe('bogus')
|
||||
expect(pushTypeLabel('correction')).toBe('更正')
|
||||
expect(pushTypeLabel('unknown')).toBe('unknown')
|
||||
expect(conversationStatusLabel('completed')).toBe('已完成')
|
||||
expect(conversationSourceLabel('widget')).toBe('挂件')
|
||||
expect(conversationSourceLabel('weird')).toBe('weird')
|
||||
})
|
||||
})
|
||||
+160
-19
@@ -1,15 +1,26 @@
|
||||
/**
|
||||
* 会话与线索 API 骨架(Phase 3 Task 3)
|
||||
* 会话与线索 API(Phase 3 Task 3 骨架;Task 5 实装调用函数并按实际响应修正类型)
|
||||
*
|
||||
* 端点事实源:app/platform/routes/data_routes.py
|
||||
* (会话列表/详情/删除、推送日志)。本文件只建类型与端点常量——
|
||||
* 调用函数在 Task 5(会话与线索)实装。
|
||||
* 端点与响应事实源:app/platform/routes/data_routes.py
|
||||
* - 列表:{total, page, page_size, items},行含 preview/intent_level/contact_info/
|
||||
* email_sent/message_count/country/site 等;筛选 intent 白名单(interested/
|
||||
* not_interested/pending,非法 400)/site 精确/client_info["site"]/from-to 闭区间
|
||||
* (日期-only 的 to 含当日全天)
|
||||
* - 详情:扁平结构(元数据 + evaluation/profile/messages 三块),消息行字段为
|
||||
* time(非 created_at);profile.chips 为 raw_info 去 _ 前缀键
|
||||
* - 删除:{ok: true};不存在 404(级联消息/画像/评估/推送日志)
|
||||
* - push-logs:列表不回大 payload(load_only 排除 request_payload),带
|
||||
* payload_size 服务端长度标记;status 白名单 pending/success/failed/skipped
|
||||
*/
|
||||
import { http } from './client'
|
||||
|
||||
/** 意向层级(Evaluation.intent_level) */
|
||||
export type IntentLevel = 'high' | 'medium' | 'low' | 'none' | null
|
||||
/** 意向层级(Evaluation.intent_level 枚举;列表筛选白名单同值) */
|
||||
export type IntentLevel = 'interested' | 'not_interested' | 'pending'
|
||||
|
||||
/** 联系方式(评估产出) */
|
||||
/** 推送状态(LeadPushLog.status;筛选白名单同值) */
|
||||
export type PushLogStatus = 'pending' | 'success' | 'failed' | 'skipped'
|
||||
|
||||
/** 联系方式(评估产出,留资三选一) */
|
||||
export interface ContactInfo {
|
||||
email?: string | null
|
||||
whatsapp?: string | null
|
||||
@@ -26,7 +37,7 @@ export interface ConversationListItem {
|
||||
ip: string
|
||||
device: string
|
||||
country: string | null
|
||||
intent_level: IntentLevel
|
||||
intent_level: IntentLevel | null
|
||||
reasoning: string | null
|
||||
contact_info: ContactInfo | null
|
||||
email_sent: boolean
|
||||
@@ -35,9 +46,9 @@ export interface ConversationListItem {
|
||||
created_at: string | null
|
||||
}
|
||||
|
||||
/** 会话列表查询参数(intent 白名单校验在后端;from/to 为闭区间日期) */
|
||||
/** 会话列表查询参数(intent 白名单校验在后端;from/to 为闭区间日期 YYYY-MM-DD) */
|
||||
export interface ConversationListParams {
|
||||
intent?: string
|
||||
intent?: IntentLevel
|
||||
site?: string
|
||||
from?: string
|
||||
to?: string
|
||||
@@ -53,30 +64,66 @@ export interface ConversationListResponse {
|
||||
items: ConversationListItem[]
|
||||
}
|
||||
|
||||
/** 会话详情消息行(时间升序) */
|
||||
/** 会话详情消息行(时间升序;字段为 time) */
|
||||
export interface ConversationMessage {
|
||||
role: 'user' | 'assistant' | 'system' | string
|
||||
content: string
|
||||
created_at: string | null
|
||||
time: string | null
|
||||
}
|
||||
|
||||
/** 会话详情响应(GET .../conversations/{convId}) */
|
||||
export interface ConversationDetailResponse extends ConversationListItem {
|
||||
/** 评估块(详情内嵌,未评估为 null) */
|
||||
export interface ConversationEvaluation {
|
||||
intent_level: IntentLevel | null
|
||||
reasoning: string | null
|
||||
contact_info: ContactInfo | null
|
||||
email_sent: boolean
|
||||
correction_count: number | null
|
||||
evaluated_at: string | null
|
||||
}
|
||||
|
||||
/** 会话详情响应(GET .../conversations/{convId}——扁平,与列表行不同构) */
|
||||
export interface ConversationDetail {
|
||||
id: string
|
||||
session_id: string
|
||||
status: string
|
||||
source: string
|
||||
client_info: Record<string, unknown>
|
||||
messages: ConversationMessage[]
|
||||
site: string | null
|
||||
ip: string
|
||||
device: string
|
||||
country: string | null
|
||||
created_at: string | null
|
||||
updated_at: string | null
|
||||
evaluation: ConversationEvaluation | null
|
||||
profile: {
|
||||
chips: Record<string, unknown>
|
||||
summary: string | null
|
||||
} | null
|
||||
messages: ConversationMessage[]
|
||||
}
|
||||
|
||||
/** 推送日志行(GET .../push-logs) */
|
||||
/** 推送日志行(GET .../push-logs——无 request_payload,仅 payload_size 长度标记) */
|
||||
export interface PushLogItem {
|
||||
id: number
|
||||
id: string
|
||||
evaluation_id: string
|
||||
conversation_id: string
|
||||
status: string
|
||||
error: string | null
|
||||
push_type: string
|
||||
status: PushLogStatus | string
|
||||
attempts: number
|
||||
outcome: string | null
|
||||
response_status: number | null
|
||||
error_message: string | null
|
||||
payload_size: number | null
|
||||
created_at: string | null
|
||||
updated_at: string | null
|
||||
}
|
||||
|
||||
/** 推送日志查询参数(status 白名单校验在后端) */
|
||||
export interface PushLogParams {
|
||||
status?: PushLogStatus
|
||||
conversation_id?: string
|
||||
page?: number
|
||||
page_size?: number
|
||||
}
|
||||
|
||||
/** 推送日志响应 */
|
||||
@@ -96,3 +143,97 @@ export const CONVERSATION_ENDPOINTS = {
|
||||
`/api/platform/tenants/${slug}/conversations/${convId}`,
|
||||
pushLogs: (slug: string) => `/api/platform/tenants/${slug}/push-logs`
|
||||
} as const
|
||||
|
||||
// ---- 展示映射(枚举值事实源 data_routes.py INTENTS/PUSH_STATUSES 与 models.py) ----
|
||||
|
||||
/** 意向筛选/展示选项 */
|
||||
export const INTENT_OPTIONS: Array<{ value: IntentLevel; label: string }> = [
|
||||
{ value: 'interested', label: '有意向' },
|
||||
{ value: 'not_interested', label: '无意向' },
|
||||
{ value: 'pending', label: '待定' }
|
||||
]
|
||||
|
||||
/** 意向展示文案(未知值回退原样) */
|
||||
export function intentLabel(level: string | null | undefined): string {
|
||||
if (!level) return '未评估'
|
||||
return INTENT_OPTIONS.find((item) => item.value === level)?.label ?? level
|
||||
}
|
||||
|
||||
/** 推送状态筛选/展示选项 */
|
||||
export const PUSH_STATUS_OPTIONS: Array<{ value: PushLogStatus; label: string }> = [
|
||||
{ value: 'pending', label: '待推送' },
|
||||
{ value: 'success', label: '成功' },
|
||||
{ value: 'failed', label: '失败' },
|
||||
{ value: 'skipped', label: '跳过' }
|
||||
]
|
||||
|
||||
/** 推送状态展示文案 */
|
||||
export function pushStatusLabel(status: string | null | undefined): string {
|
||||
if (!status) return '—'
|
||||
return PUSH_STATUS_OPTIONS.find((item) => item.value === status)?.label ?? status
|
||||
}
|
||||
|
||||
/** 推送类型展示文案(LeadPushLog.push_type:lead/returning/correction) */
|
||||
export function pushTypeLabel(type: string | null | undefined): string {
|
||||
const labels: Record<string, string> = {
|
||||
lead: '新线索',
|
||||
returning: '回访',
|
||||
correction: '更正'
|
||||
}
|
||||
if (!type) return '—'
|
||||
return labels[type] ?? type
|
||||
}
|
||||
|
||||
/** 会话状态展示文案(ConversationStatus) */
|
||||
export function conversationStatusLabel(status: string | null | undefined): string {
|
||||
const labels: Record<string, string> = {
|
||||
active: '进行中',
|
||||
completed: '已完成',
|
||||
abandoned: '已放弃'
|
||||
}
|
||||
if (!status) return '—'
|
||||
return labels[status] ?? status
|
||||
}
|
||||
|
||||
/** 会话来源展示文案(ConversationSource) */
|
||||
export function conversationSourceLabel(source: string | null | undefined): string {
|
||||
const labels: Record<string, string> = {
|
||||
standalone: '独立站',
|
||||
embedded: '内嵌页',
|
||||
widget: '挂件',
|
||||
app: '应用'
|
||||
}
|
||||
if (!source) return '—'
|
||||
return labels[source] ?? source
|
||||
}
|
||||
|
||||
// ---- 调用函数 -------------------------------------------------------------------
|
||||
|
||||
/** 会话列表(分页 + intent/site/日期区间筛选) */
|
||||
export function fetchConversations(
|
||||
slug: string,
|
||||
params?: ConversationListParams
|
||||
): Promise<ConversationListResponse> {
|
||||
return http.get<ConversationListResponse>(CONVERSATION_ENDPOINTS.list(slug), { params })
|
||||
}
|
||||
|
||||
/** 会话详情(元数据 + 画像 chips + 评估 + 消息全量) */
|
||||
export function fetchConversationDetail(
|
||||
slug: string,
|
||||
convId: string
|
||||
): Promise<ConversationDetail> {
|
||||
return http.get<ConversationDetail>(CONVERSATION_ENDPOINTS.detail(slug, convId))
|
||||
}
|
||||
|
||||
/** 删除会话(级联消息/画像/评估/推送日志;不存在 404) */
|
||||
export function deleteConversation(
|
||||
slug: string,
|
||||
convId: string
|
||||
): Promise<{ ok: boolean }> {
|
||||
return http.del<{ ok: boolean }>(CONVERSATION_ENDPOINTS.remove(slug, convId))
|
||||
}
|
||||
|
||||
/** 推送日志列表(status/conversation_id 筛选 + 分页;不回大 payload) */
|
||||
export function fetchPushLogs(slug: string, params?: PushLogParams): Promise<PushLogResponse> {
|
||||
return http.get<PushLogResponse>(CONVERSATION_ENDPOINTS.pushLogs(slug), { params })
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user