diff --git a/admin/src/api/__tests__/conversations.test.ts b/admin/src/api/__tests__/conversations.test.ts new file mode 100644 index 0000000..d96eb9d --- /dev/null +++ b/admin/src/api/__tests__/conversations.test.ts @@ -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') + }) +}) diff --git a/admin/src/api/conversations.ts b/admin/src/api/conversations.ts index 5c96762..0e386ec 100644 --- a/admin/src/api/conversations.ts +++ b/admin/src/api/conversations.ts @@ -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 - 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 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 = { + lead: '新线索', + returning: '回访', + correction: '更正' + } + if (!type) return '—' + return labels[type] ?? type +} + +/** 会话状态展示文案(ConversationStatus) */ +export function conversationStatusLabel(status: string | null | undefined): string { + const labels: Record = { + active: '进行中', + completed: '已完成', + abandoned: '已放弃' + } + if (!status) return '—' + return labels[status] ?? status +} + +/** 会话来源展示文案(ConversationSource) */ +export function conversationSourceLabel(source: string | null | undefined): string { + const labels: Record = { + standalone: '独立站', + embedded: '内嵌页', + widget: '挂件', + app: '应用' + } + if (!source) return '—' + return labels[source] ?? source +} + +// ---- 调用函数 ------------------------------------------------------------------- + +/** 会话列表(分页 + intent/site/日期区间筛选) */ +export function fetchConversations( + slug: string, + params?: ConversationListParams +): Promise { + return http.get(CONVERSATION_ENDPOINTS.list(slug), { params }) +} + +/** 会话详情(元数据 + 画像 chips + 评估 + 消息全量) */ +export function fetchConversationDetail( + slug: string, + convId: string +): Promise { + return http.get(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 { + return http.get(CONVERSATION_ENDPOINTS.pushLogs(slug), { params }) +} diff --git a/admin/src/types/import/components.d.ts b/admin/src/types/import/components.d.ts index 400a080..2ef5b87 100644 --- a/admin/src/types/import/components.d.ts +++ b/admin/src/types/import/components.d.ts @@ -71,6 +71,9 @@ declare module 'vue' { ElCard: typeof import('element-plus/es')['ElCard'] ElCheckbox: typeof import('element-plus/es')['ElCheckbox'] ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider'] + ElDatePicker: typeof import('element-plus/es')['ElDatePicker'] + ElDescriptions: typeof import('element-plus/es')['ElDescriptions'] + ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem'] ElDialog: typeof import('element-plus/es')['ElDialog'] ElDrawer: typeof import('element-plus/es')['ElDrawer'] ElDropdown: typeof import('element-plus/es')['ElDropdown'] @@ -85,6 +88,7 @@ declare module 'vue' { ElMenu: typeof import('element-plus/es')['ElMenu'] ElMenuItem: typeof import('element-plus/es')['ElMenuItem'] ElOption: typeof import('element-plus/es')['ElOption'] + ElPagination: typeof import('element-plus/es')['ElPagination'] ElPopover: typeof import('element-plus/es')['ElPopover'] ElRadioButton: typeof import('element-plus/es')['ElRadioButton'] ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup'] @@ -93,6 +97,10 @@ declare module 'vue' { ElSkeleton: typeof import('element-plus/es')['ElSkeleton'] ElSubMenu: typeof import('element-plus/es')['ElSubMenu'] ElSwitch: typeof import('element-plus/es')['ElSwitch'] + ElTable: typeof import('element-plus/es')['ElTable'] + ElTableColumn: typeof import('element-plus/es')['ElTableColumn'] + ElTabPane: typeof import('element-plus/es')['ElTabPane'] + ElTabs: typeof import('element-plus/es')['ElTabs'] ElTag: typeof import('element-plus/es')['ElTag'] ElTooltip: typeof import('element-plus/es')['ElTooltip'] ElWatermark: typeof import('element-plus/es')['ElWatermark'] diff --git a/admin/src/views/conversations/list/index.vue b/admin/src/views/conversations/list/index.vue index 6795d15..fcbeec4 100644 --- a/admin/src/views/conversations/list/index.vue +++ b/admin/src/views/conversations/list/index.vue @@ -1,25 +1,874 @@ - +