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
|
* 端点与响应事实源:app/platform/routes/data_routes.py
|
||||||
* (会话列表/详情/删除、推送日志)。本文件只建类型与端点常量——
|
* - 列表:{total, page, page_size, items},行含 preview/intent_level/contact_info/
|
||||||
* 调用函数在 Task 5(会话与线索)实装。
|
* 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) */
|
/** 意向层级(Evaluation.intent_level 枚举;列表筛选白名单同值) */
|
||||||
export type IntentLevel = 'high' | 'medium' | 'low' | 'none' | null
|
export type IntentLevel = 'interested' | 'not_interested' | 'pending'
|
||||||
|
|
||||||
/** 联系方式(评估产出) */
|
/** 推送状态(LeadPushLog.status;筛选白名单同值) */
|
||||||
|
export type PushLogStatus = 'pending' | 'success' | 'failed' | 'skipped'
|
||||||
|
|
||||||
|
/** 联系方式(评估产出,留资三选一) */
|
||||||
export interface ContactInfo {
|
export interface ContactInfo {
|
||||||
email?: string | null
|
email?: string | null
|
||||||
whatsapp?: string | null
|
whatsapp?: string | null
|
||||||
@@ -26,7 +37,7 @@ export interface ConversationListItem {
|
|||||||
ip: string
|
ip: string
|
||||||
device: string
|
device: string
|
||||||
country: string | null
|
country: string | null
|
||||||
intent_level: IntentLevel
|
intent_level: IntentLevel | null
|
||||||
reasoning: string | null
|
reasoning: string | null
|
||||||
contact_info: ContactInfo | null
|
contact_info: ContactInfo | null
|
||||||
email_sent: boolean
|
email_sent: boolean
|
||||||
@@ -35,9 +46,9 @@ export interface ConversationListItem {
|
|||||||
created_at: string | null
|
created_at: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 会话列表查询参数(intent 白名单校验在后端;from/to 为闭区间日期) */
|
/** 会话列表查询参数(intent 白名单校验在后端;from/to 为闭区间日期 YYYY-MM-DD) */
|
||||||
export interface ConversationListParams {
|
export interface ConversationListParams {
|
||||||
intent?: string
|
intent?: IntentLevel
|
||||||
site?: string
|
site?: string
|
||||||
from?: string
|
from?: string
|
||||||
to?: string
|
to?: string
|
||||||
@@ -53,30 +64,66 @@ export interface ConversationListResponse {
|
|||||||
items: ConversationListItem[]
|
items: ConversationListItem[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 会话详情消息行(时间升序) */
|
/** 会话详情消息行(时间升序;字段为 time) */
|
||||||
export interface ConversationMessage {
|
export interface ConversationMessage {
|
||||||
role: 'user' | 'assistant' | 'system' | string
|
role: 'user' | 'assistant' | 'system' | string
|
||||||
content: string
|
content: string
|
||||||
created_at: string | null
|
time: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 会话详情响应(GET .../conversations/{convId}) */
|
/** 评估块(详情内嵌,未评估为 null) */
|
||||||
export interface ConversationDetailResponse extends ConversationListItem {
|
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>
|
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: {
|
profile: {
|
||||||
chips: Record<string, unknown>
|
chips: Record<string, unknown>
|
||||||
summary: string | null
|
summary: string | null
|
||||||
} | null
|
} | null
|
||||||
|
messages: ConversationMessage[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 推送日志行(GET .../push-logs) */
|
/** 推送日志行(GET .../push-logs——无 request_payload,仅 payload_size 长度标记) */
|
||||||
export interface PushLogItem {
|
export interface PushLogItem {
|
||||||
id: number
|
id: string
|
||||||
|
evaluation_id: string
|
||||||
conversation_id: string
|
conversation_id: string
|
||||||
status: string
|
push_type: string
|
||||||
error: string | null
|
status: PushLogStatus | string
|
||||||
|
attempts: number
|
||||||
|
outcome: string | null
|
||||||
|
response_status: number | null
|
||||||
|
error_message: string | null
|
||||||
|
payload_size: number | null
|
||||||
created_at: string | 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}`,
|
`/api/platform/tenants/${slug}/conversations/${convId}`,
|
||||||
pushLogs: (slug: string) => `/api/platform/tenants/${slug}/push-logs`
|
pushLogs: (slug: string) => `/api/platform/tenants/${slug}/push-logs`
|
||||||
} as const
|
} 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 })
|
||||||
|
}
|
||||||
|
|||||||
+8
@@ -71,6 +71,9 @@ declare module 'vue' {
|
|||||||
ElCard: typeof import('element-plus/es')['ElCard']
|
ElCard: typeof import('element-plus/es')['ElCard']
|
||||||
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
|
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
|
||||||
ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
|
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']
|
ElDialog: typeof import('element-plus/es')['ElDialog']
|
||||||
ElDrawer: typeof import('element-plus/es')['ElDrawer']
|
ElDrawer: typeof import('element-plus/es')['ElDrawer']
|
||||||
ElDropdown: typeof import('element-plus/es')['ElDropdown']
|
ElDropdown: typeof import('element-plus/es')['ElDropdown']
|
||||||
@@ -85,6 +88,7 @@ declare module 'vue' {
|
|||||||
ElMenu: typeof import('element-plus/es')['ElMenu']
|
ElMenu: typeof import('element-plus/es')['ElMenu']
|
||||||
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
||||||
ElOption: typeof import('element-plus/es')['ElOption']
|
ElOption: typeof import('element-plus/es')['ElOption']
|
||||||
|
ElPagination: typeof import('element-plus/es')['ElPagination']
|
||||||
ElPopover: typeof import('element-plus/es')['ElPopover']
|
ElPopover: typeof import('element-plus/es')['ElPopover']
|
||||||
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
|
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
|
||||||
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
|
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
|
||||||
@@ -93,6 +97,10 @@ declare module 'vue' {
|
|||||||
ElSkeleton: typeof import('element-plus/es')['ElSkeleton']
|
ElSkeleton: typeof import('element-plus/es')['ElSkeleton']
|
||||||
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
|
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
|
||||||
ElSwitch: typeof import('element-plus/es')['ElSwitch']
|
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']
|
ElTag: typeof import('element-plus/es')['ElTag']
|
||||||
ElTooltip: typeof import('element-plus/es')['ElTooltip']
|
ElTooltip: typeof import('element-plus/es')['ElTooltip']
|
||||||
ElWatermark: typeof import('element-plus/es')['ElWatermark']
|
ElWatermark: typeof import('element-plus/es')['ElWatermark']
|
||||||
|
|||||||
@@ -1,25 +1,874 @@
|
|||||||
<!-- 会话与线索(骨架占位页) -->
|
<!--
|
||||||
|
会话与线索(Phase 3 Task 5)
|
||||||
|
- 列表页签:分页表格 + 筛选(意向三值 / 站点精确 / 日期区间);
|
||||||
|
行操作:查看详情(抽屉)、删除(确认框,级联消息/画像/评估/推送日志)
|
||||||
|
- 租户切换器:超管专用(下拉选租户,默认第一个);租户管理员固定
|
||||||
|
自己(登录态 tenant_slug)无切换器
|
||||||
|
- 推送日志页签(列表平行):状态筛选(pending/success/failed/skipped)
|
||||||
|
+ 分页表格(类型/状态/尝试次数/outcome/payload 大小/时间)——
|
||||||
|
列表接口不回大 payload(load_only),仅服务端算的 payload_size
|
||||||
|
- 详情抽屉:会话元数据(来源/IP 归国/状态/站点)+ 画像 chips(ElTag
|
||||||
|
键值标签)+ 评估块(意向/理由)+ 消息流(user 右 / assistant 左气泡)
|
||||||
|
- 时间直显库内 naive UTC(与 from/to 筛选同一对比空间),不做时区换算
|
||||||
|
-->
|
||||||
<template>
|
<template>
|
||||||
<div class="page-placeholder">
|
<div class="conversations-page">
|
||||||
<ElCard shadow="never">
|
<!-- 头部:标题 + 租户切换器(超管)/ 租户标识(租户管理员) -->
|
||||||
<template #header>
|
<div class="page-header">
|
||||||
<span class="card-title">会话与线索</span>
|
<div>
|
||||||
</template>
|
<h2 class="page-title">会话与线索</h2>
|
||||||
<ElEmpty description="会话列表 / 详情 / 询盘推送日志(后续任务实现)" />
|
<p class="page-sub">访客会话、意向评估与询盘推送记录</p>
|
||||||
|
</div>
|
||||||
|
<ElSelect
|
||||||
|
v-if="isSuper"
|
||||||
|
v-model="activeSlug"
|
||||||
|
:loading="tenantsLoading"
|
||||||
|
placeholder="选择租户"
|
||||||
|
class="tenant-switcher"
|
||||||
|
>
|
||||||
|
<ElOption
|
||||||
|
v-for="tenant in tenants"
|
||||||
|
:key="tenant.slug"
|
||||||
|
:value="tenant.slug"
|
||||||
|
:label="`${tenant.name}(@${tenant.slug})`"
|
||||||
|
/>
|
||||||
|
</ElSelect>
|
||||||
|
<ElTag v-else-if="authStore.tenantSlug" type="info" size="large">
|
||||||
|
租户:{{ authStore.tenantSlug }}
|
||||||
|
</ElTag>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 登录态异常 / 租户枚举失败(超管) -->
|
||||||
|
<ElCard shadow="never" class="section-card">
|
||||||
|
<ElAlert
|
||||||
|
v-if="!isSuper && !authStore.tenantSlug"
|
||||||
|
type="warning"
|
||||||
|
:closable="false"
|
||||||
|
title="登录态缺少租户标识,请重新登录"
|
||||||
|
/>
|
||||||
|
<ElAlert
|
||||||
|
v-else-if="tenantsError"
|
||||||
|
type="error"
|
||||||
|
:closable="false"
|
||||||
|
:title="tenantsError"
|
||||||
|
>
|
||||||
|
<ElButton size="small" :loading="tenantsLoading" @click="loadTenants">
|
||||||
|
重试
|
||||||
|
</ElButton>
|
||||||
|
</ElAlert>
|
||||||
|
<ElEmpty
|
||||||
|
v-else-if="isSuper && !tenantsLoading && tenants.length === 0"
|
||||||
|
description="暂无租户——到「租户管理」创建第一个租户后,这里将展示其会话与线索"
|
||||||
|
/>
|
||||||
|
<ElTabs v-else v-model="activeTab">
|
||||||
|
<!-- ==================== 会话列表 ==================== -->
|
||||||
|
<ElTabPane label="会话列表" name="list">
|
||||||
|
<div class="filter-bar">
|
||||||
|
<ElSelect
|
||||||
|
v-model="intentFilter"
|
||||||
|
clearable
|
||||||
|
placeholder="意向(全部)"
|
||||||
|
class="filter-intent"
|
||||||
|
@change="onFilterChange"
|
||||||
|
>
|
||||||
|
<ElOption
|
||||||
|
v-for="item in INTENT_OPTIONS"
|
||||||
|
:key="item.value"
|
||||||
|
:value="item.value"
|
||||||
|
:label="item.label"
|
||||||
|
/>
|
||||||
|
</ElSelect>
|
||||||
|
<ElInput
|
||||||
|
v-model="siteFilter"
|
||||||
|
clearable
|
||||||
|
placeholder="站点(精确匹配)"
|
||||||
|
class="filter-site"
|
||||||
|
@change="onFilterChange"
|
||||||
|
@clear="onFilterChange"
|
||||||
|
/>
|
||||||
|
<ElDatePicker
|
||||||
|
v-model="dateRange"
|
||||||
|
type="daterange"
|
||||||
|
value-format="YYYY-MM-DD"
|
||||||
|
range-separator="至"
|
||||||
|
start-placeholder="开始日期"
|
||||||
|
end-placeholder="结束日期"
|
||||||
|
class="filter-date"
|
||||||
|
@change="onFilterChange"
|
||||||
|
/>
|
||||||
|
<ElButton :loading="listLoading" @click="onFilterChange">查询</ElButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ElAlert v-if="listError" type="error" :closable="false" :title="listError" class="load-error">
|
||||||
|
<ElButton size="small" :loading="listLoading" @click="reloadList">重试</ElButton>
|
||||||
|
</ElAlert>
|
||||||
|
<ElTable v-else v-loading="listLoading" :data="listRows" row-key="id">
|
||||||
|
<ElTableColumn label="时间" width="150">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span>{{ formatDateTime(row.created_at) }}</span>
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
<ElTableColumn label="首条用户消息" min-width="220" show-overflow-tooltip>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span>{{ row.preview || '—' }}</span>
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
<ElTableColumn label="站点" width="90">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span>{{ row.site || '—' }}</span>
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
<ElTableColumn label="IP 归国" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span>{{ row.country || '—' }}</span>
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
<ElTableColumn label="意向" width="92">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<ElTag
|
||||||
|
v-if="row.intent_level"
|
||||||
|
:type="intentTagType(row.intent_level)"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
{{ intentLabel(row.intent_level) }}
|
||||||
|
</ElTag>
|
||||||
|
<span v-else class="muted">未评估</span>
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
<ElTableColumn label="联系方式" min-width="170" show-overflow-tooltip>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span>{{ contactText(row.contact_info) || '—' }}</span>
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
<ElTableColumn label="邮件" width="70" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span>{{ row.email_sent ? '已发' : '—' }}</span>
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
<ElTableColumn label="消息数" width="76" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span>{{ row.message_count }}</span>
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
<ElTableColumn label="操作" width="128" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<ElButton type="primary" link size="small" @click="openDetail(row)">
|
||||||
|
详情
|
||||||
|
</ElButton>
|
||||||
|
<ElButton
|
||||||
|
type="danger"
|
||||||
|
link
|
||||||
|
size="small"
|
||||||
|
:loading="deletingId === row.id"
|
||||||
|
@click="onDelete(row)"
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</ElButton>
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
</ElTable>
|
||||||
|
<div class="pagination-bar">
|
||||||
|
<ElPagination
|
||||||
|
v-model:current-page="page"
|
||||||
|
v-model:page-size="pageSize"
|
||||||
|
:total="listTotal"
|
||||||
|
:page-sizes="[10, 20, 50, 100]"
|
||||||
|
layout="total, sizes, prev, pager, next, jumper"
|
||||||
|
@current-change="reloadList"
|
||||||
|
@size-change="onPageSizeChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ElTabPane>
|
||||||
|
|
||||||
|
<!-- ==================== 推送日志 ==================== -->
|
||||||
|
<ElTabPane label="推送日志" name="pushLogs" lazy>
|
||||||
|
<div class="filter-bar">
|
||||||
|
<ElSelect
|
||||||
|
v-model="pushStatusFilter"
|
||||||
|
clearable
|
||||||
|
placeholder="状态(全部)"
|
||||||
|
class="filter-intent"
|
||||||
|
@change="onPushFilterChange"
|
||||||
|
>
|
||||||
|
<ElOption
|
||||||
|
v-for="item in PUSH_STATUS_OPTIONS"
|
||||||
|
:key="item.value"
|
||||||
|
:value="item.value"
|
||||||
|
:label="item.label"
|
||||||
|
/>
|
||||||
|
</ElSelect>
|
||||||
|
<ElButton :loading="logsLoading" @click="onPushFilterChange">查询</ElButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ElAlert v-if="logsError" type="error" :closable="false" :title="logsError" class="load-error">
|
||||||
|
<ElButton size="small" :loading="logsLoading" @click="reloadLogs">重试</ElButton>
|
||||||
|
</ElAlert>
|
||||||
|
<ElTable v-else v-loading="logsLoading" :data="logRows" row-key="id">
|
||||||
|
<ElTableColumn label="时间" width="150">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span>{{ formatDateTime(row.created_at) }}</span>
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
<ElTableColumn label="类型" width="84">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span>{{ pushTypeLabel(row.push_type) }}</span>
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
<ElTableColumn label="状态" width="84">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<ElTag :type="pushStatusTagType(row.status)" size="small">
|
||||||
|
{{ pushStatusLabel(row.status) }}
|
||||||
|
</ElTag>
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
<ElTableColumn label="尝试" width="64" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span>{{ row.attempts }}</span>
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
<ElTableColumn label="outcome" width="96">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span>{{ outcomeLabel(row.outcome) }}</span>
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
<ElTableColumn label="HTTP" width="70" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span>{{ row.response_status ?? '—' }}</span>
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
<ElTableColumn label="payload" width="92" align="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span>{{ formatPayloadSize(row.payload_size) }}</span>
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
<ElTableColumn label="会话" min-width="150" show-overflow-tooltip>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="mono">{{ row.conversation_id }}</span>
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
<ElTableColumn label="错误" min-width="140" show-overflow-tooltip>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span>{{ row.error_message || '—' }}</span>
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
</ElTable>
|
||||||
|
<div class="pagination-bar">
|
||||||
|
<ElPagination
|
||||||
|
v-model:current-page="logPage"
|
||||||
|
v-model:page-size="logPageSize"
|
||||||
|
:total="logTotal"
|
||||||
|
:page-sizes="[10, 20, 50, 100]"
|
||||||
|
layout="total, sizes, prev, pager, next, jumper"
|
||||||
|
@current-change="reloadLogs"
|
||||||
|
@size-change="onLogPageSizeChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ElTabPane>
|
||||||
|
</ElTabs>
|
||||||
</ElCard>
|
</ElCard>
|
||||||
|
|
||||||
|
<!-- ==================== 详情抽屉 ==================== -->
|
||||||
|
<ElDrawer v-model="drawerVisible" size="720px" title="会话详情" @closed="onDrawerClosed">
|
||||||
|
<div v-loading="detailLoading" class="detail-body">
|
||||||
|
<ElAlert v-if="detailError" type="error" :closable="false" :title="detailError">
|
||||||
|
<ElButton size="small" :loading="detailLoading" @click="loadDetail">重试</ElButton>
|
||||||
|
</ElAlert>
|
||||||
|
<template v-else-if="detail">
|
||||||
|
<!-- 元数据 -->
|
||||||
|
<ElDescriptions :column="2" border size="small" class="detail-section">
|
||||||
|
<ElDescriptionsItem label="状态">
|
||||||
|
{{ conversationStatusLabel(detail.status) }}
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem label="来源">
|
||||||
|
{{ conversationSourceLabel(detail.source) }}
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem label="站点">{{ detail.site || '—' }}</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem label="IP 归国">
|
||||||
|
{{ detail.ip || '—' }}{{ detail.country ? `(${detail.country})` : '' }}
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem label="创建时间">
|
||||||
|
{{ formatDateTime(detail.created_at) }}
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem label="更新时间">
|
||||||
|
{{ formatDateTime(detail.updated_at) }}
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem label="session" :span="2">
|
||||||
|
<span class="mono">{{ detail.session_id }}</span>
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem label="设备" :span="2">
|
||||||
|
<span class="device-ua">{{ detail.device || '—' }}</span>
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
</ElDescriptions>
|
||||||
|
|
||||||
|
<!-- 画像 chips -->
|
||||||
|
<section class="detail-section">
|
||||||
|
<h4 class="section-title">客户画像</h4>
|
||||||
|
<template v-if="chipEntries.length > 0">
|
||||||
|
<div class="chip-wrap">
|
||||||
|
<ElTag v-for="[key, value] in chipEntries" :key="key" size="large" class="chip">
|
||||||
|
{{ key }}: {{ chipText(value) }}
|
||||||
|
</ElTag>
|
||||||
|
</div>
|
||||||
|
<p v-if="detail.profile?.summary" class="profile-summary">
|
||||||
|
{{ detail.profile.summary }}
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
<p v-else class="muted">暂无画像</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 评估块 -->
|
||||||
|
<section class="detail-section">
|
||||||
|
<h4 class="section-title">意向评估</h4>
|
||||||
|
<template v-if="detail.evaluation">
|
||||||
|
<div class="evaluation-block">
|
||||||
|
<ElTag :type="intentTagType(detail.evaluation.intent_level)" size="large">
|
||||||
|
{{ intentLabel(detail.evaluation.intent_level) }}
|
||||||
|
</ElTag>
|
||||||
|
<span class="evaluation-meta">
|
||||||
|
邮件:{{ detail.evaluation.email_sent ? '已发' : '未发' }} ·
|
||||||
|
更正 {{ detail.evaluation.correction_count ?? 0 }} 次 ·
|
||||||
|
评估于 {{ formatDateTime(detail.evaluation.evaluated_at) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p v-if="detail.evaluation.reasoning" class="reasoning">
|
||||||
|
{{ detail.evaluation.reasoning }}
|
||||||
|
</p>
|
||||||
|
<p v-if="contactText(detail.evaluation.contact_info)" class="contact-line">
|
||||||
|
联系方式:{{ contactText(detail.evaluation.contact_info) }}
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
<p v-else class="muted">未评估</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 消息流 -->
|
||||||
|
<section class="detail-section">
|
||||||
|
<h4 class="section-title">消息流({{ detail.messages.length }} 条)</h4>
|
||||||
|
<div v-if="detail.messages.length > 0" class="message-flow">
|
||||||
|
<div
|
||||||
|
v-for="(msg, index) in detail.messages"
|
||||||
|
:key="index"
|
||||||
|
class="bubble-row"
|
||||||
|
:class="msg.role === 'user' ? 'is-user' : 'is-assistant'"
|
||||||
|
>
|
||||||
|
<div class="bubble">
|
||||||
|
<p class="bubble-content">{{ msg.content }}</p>
|
||||||
|
<span class="bubble-meta">
|
||||||
|
{{ msg.role === 'user' ? '客户' : msg.role === 'assistant' ? '助手' : msg.role }}
|
||||||
|
· {{ formatDateTime(msg.time) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p v-else class="muted">暂无消息</p>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</ElDrawer>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { ElMessageBox } from 'element-plus'
|
||||||
|
import { useAuthStore } from '@/store/modules/auth'
|
||||||
|
import { fetchTenantList, type TenantListItem } from '@/api/tenants'
|
||||||
|
import {
|
||||||
|
INTENT_OPTIONS,
|
||||||
|
PUSH_STATUS_OPTIONS,
|
||||||
|
conversationSourceLabel,
|
||||||
|
conversationStatusLabel,
|
||||||
|
deleteConversation,
|
||||||
|
fetchConversationDetail,
|
||||||
|
fetchConversations,
|
||||||
|
fetchPushLogs,
|
||||||
|
intentLabel,
|
||||||
|
pushStatusLabel,
|
||||||
|
pushTypeLabel,
|
||||||
|
type ContactInfo,
|
||||||
|
type ConversationDetail,
|
||||||
|
type ConversationListItem,
|
||||||
|
type IntentLevel,
|
||||||
|
type PushLogItem,
|
||||||
|
type PushLogStatus
|
||||||
|
} from '@/api/conversations'
|
||||||
|
|
||||||
defineOptions({ name: 'ConversationList' })
|
defineOptions({ name: 'ConversationList' })
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const isSuper = computed(() => authStore.role === 'super')
|
||||||
|
|
||||||
|
// ---- 租户上下文(超管可切换;租户管理员固定自己) --------------------------------
|
||||||
|
const tenants = ref<TenantListItem[]>([])
|
||||||
|
const tenantsLoading = ref(false)
|
||||||
|
const tenantsError = ref('')
|
||||||
|
const activeSlug = ref('')
|
||||||
|
|
||||||
|
async function loadTenants(): Promise<void> {
|
||||||
|
tenantsLoading.value = true
|
||||||
|
tenantsError.value = ''
|
||||||
|
try {
|
||||||
|
const res = await fetchTenantList()
|
||||||
|
tenants.value = res.items
|
||||||
|
// 默认选中第一个租户(选中后由 watch(activeSlug) 触发首载)
|
||||||
|
if (res.items.length > 0) {
|
||||||
|
activeSlug.value = res.items[0].slug
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
tenantsError.value = error instanceof Error ? error.message : '租户列表加载失败'
|
||||||
|
} finally {
|
||||||
|
tenantsLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 页签 -----------------------------------------------------------------------
|
||||||
|
const activeTab = ref<'list' | 'pushLogs'>('list')
|
||||||
|
/** 推送日志页签懒加载标记(首次切入才拉取,此后租户切换/删除联动刷新) */
|
||||||
|
const pushLogsActivated = ref(false)
|
||||||
|
|
||||||
|
watch(activeTab, (tab) => {
|
||||||
|
if (tab === 'pushLogs' && !pushLogsActivated.value) {
|
||||||
|
pushLogsActivated.value = true
|
||||||
|
reloadLogs()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---- 会话列表:筛选 + 分页 --------------------------------------------------------
|
||||||
|
const intentFilter = ref<IntentLevel | ''>('')
|
||||||
|
const siteFilter = ref('')
|
||||||
|
const dateRange = ref<[string, string] | null>(null)
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = ref(20)
|
||||||
|
|
||||||
|
const listRows = ref<ConversationListItem[]>([])
|
||||||
|
const listTotal = ref(0)
|
||||||
|
const listLoading = ref(false)
|
||||||
|
const listError = ref('')
|
||||||
|
/** 列表加载世代(筛选/切租户快速变化时丢弃过期响应) */
|
||||||
|
const listEpoch = ref(0)
|
||||||
|
|
||||||
|
function listParams() {
|
||||||
|
return {
|
||||||
|
intent: intentFilter.value || undefined,
|
||||||
|
site: siteFilter.value.trim() || undefined,
|
||||||
|
from: dateRange.value?.[0],
|
||||||
|
to: dateRange.value?.[1],
|
||||||
|
page: page.value,
|
||||||
|
page_size: pageSize.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadList(epoch: number): Promise<void> {
|
||||||
|
if (!activeSlug.value) return
|
||||||
|
listLoading.value = true
|
||||||
|
listError.value = ''
|
||||||
|
try {
|
||||||
|
const res = await fetchConversations(activeSlug.value, listParams())
|
||||||
|
if (epoch !== listEpoch.value) return
|
||||||
|
listRows.value = res.items
|
||||||
|
listTotal.value = res.total
|
||||||
|
} catch (error) {
|
||||||
|
if (epoch !== listEpoch.value) return
|
||||||
|
listError.value = error instanceof Error ? error.message : '会话列表加载失败'
|
||||||
|
} finally {
|
||||||
|
if (epoch === listEpoch.value) listLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function reloadList(): void {
|
||||||
|
loadList(++listEpoch.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 筛选变化:重置到第一页再查 */
|
||||||
|
function onFilterChange(): void {
|
||||||
|
page.value = 1
|
||||||
|
reloadList()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPageSizeChange(): void {
|
||||||
|
page.value = 1
|
||||||
|
reloadList()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 推送日志:状态筛选 + 分页 ----------------------------------------------------
|
||||||
|
const pushStatusFilter = ref<PushLogStatus | ''>('')
|
||||||
|
const logPage = ref(1)
|
||||||
|
const logPageSize = ref(20)
|
||||||
|
|
||||||
|
const logRows = ref<PushLogItem[]>([])
|
||||||
|
const logTotal = ref(0)
|
||||||
|
const logsLoading = ref(false)
|
||||||
|
const logsError = ref('')
|
||||||
|
const logsEpoch = ref(0)
|
||||||
|
|
||||||
|
async function loadLogs(epoch: number): Promise<void> {
|
||||||
|
if (!activeSlug.value) return
|
||||||
|
logsLoading.value = true
|
||||||
|
logsError.value = ''
|
||||||
|
try {
|
||||||
|
const res = await fetchPushLogs(activeSlug.value, {
|
||||||
|
status: pushStatusFilter.value || undefined,
|
||||||
|
page: logPage.value,
|
||||||
|
page_size: logPageSize.value
|
||||||
|
})
|
||||||
|
if (epoch !== logsEpoch.value) return
|
||||||
|
logRows.value = res.items
|
||||||
|
logTotal.value = res.total
|
||||||
|
} catch (error) {
|
||||||
|
if (epoch !== logsEpoch.value) return
|
||||||
|
logsError.value = error instanceof Error ? error.message : '推送日志加载失败'
|
||||||
|
} finally {
|
||||||
|
if (epoch === logsEpoch.value) logsLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function reloadLogs(): void {
|
||||||
|
loadLogs(++logsEpoch.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPushFilterChange(): void {
|
||||||
|
logPage.value = 1
|
||||||
|
reloadLogs()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onLogPageSizeChange(): void {
|
||||||
|
logPage.value = 1
|
||||||
|
reloadLogs()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 删除(确认框 → DELETE → 列表刷新;末页删空自动回退一页) ----------------------
|
||||||
|
const deletingId = ref('')
|
||||||
|
|
||||||
|
async function onDelete(row: ConversationListItem): Promise<void> {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
`确定删除该会话吗?(${row.preview || row.id})消息、画像、评估与推送日志将一并删除,不可恢复。`,
|
||||||
|
'删除会话',
|
||||||
|
{ type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' }
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
return // 用户取消
|
||||||
|
}
|
||||||
|
deletingId.value = row.id
|
||||||
|
try {
|
||||||
|
await deleteConversation(activeSlug.value, row.id)
|
||||||
|
ElMessage.success('已删除')
|
||||||
|
// 当前页删空且非首页 → 回退一页,避免停在空页
|
||||||
|
if (listRows.value.length === 1 && page.value > 1) page.value -= 1
|
||||||
|
reloadList()
|
||||||
|
if (pushLogsActivated.value) reloadLogs()
|
||||||
|
} catch {
|
||||||
|
// 失败提示由 client 全局拦截器统一弹出
|
||||||
|
} finally {
|
||||||
|
deletingId.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 详情抽屉 --------------------------------------------------------------------
|
||||||
|
const drawerVisible = ref(false)
|
||||||
|
const detailId = ref('')
|
||||||
|
const detail = ref<ConversationDetail | null>(null)
|
||||||
|
const detailLoading = ref(false)
|
||||||
|
const detailError = ref('')
|
||||||
|
const detailSeq = ref(0)
|
||||||
|
|
||||||
|
function openDetail(row: ConversationListItem): void {
|
||||||
|
detailId.value = row.id
|
||||||
|
detail.value = null
|
||||||
|
drawerVisible.value = true
|
||||||
|
loadDetail()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDetail(): Promise<void> {
|
||||||
|
const seq = ++detailSeq.value
|
||||||
|
detailLoading.value = true
|
||||||
|
detailError.value = ''
|
||||||
|
try {
|
||||||
|
const res = await fetchConversationDetail(activeSlug.value, detailId.value)
|
||||||
|
if (seq !== detailSeq.value || !drawerVisible.value) return
|
||||||
|
detail.value = res
|
||||||
|
} catch (error) {
|
||||||
|
if (seq !== detailSeq.value) return
|
||||||
|
detailError.value = error instanceof Error ? error.message : '详情加载失败'
|
||||||
|
} finally {
|
||||||
|
if (seq === detailSeq.value) detailLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDrawerClosed(): void {
|
||||||
|
detailSeq.value++ // 使在途请求作废
|
||||||
|
detail.value = null
|
||||||
|
detailError.value = ''
|
||||||
|
detailId.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const chipEntries = computed(() => Object.entries(detail.value?.profile?.chips ?? {}))
|
||||||
|
|
||||||
|
// ---- 展示辅助 --------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** 联系方式三选一合并展示(email / whatsapp / phone) */
|
||||||
|
function contactText(info: ContactInfo | null | undefined): string {
|
||||||
|
if (!info) return ''
|
||||||
|
return [info.email, info.whatsapp, info.phone].filter(Boolean).join(' · ')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 时间直显库内 naive UTC(ISO → 'YYYY-MM-DD HH:mm',与筛选口径一致不换算时区) */
|
||||||
|
function formatDateTime(iso: string | null | undefined): string {
|
||||||
|
if (!iso) return '—'
|
||||||
|
return iso.replace('T', ' ').slice(0, 16)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** payload 大小(服务端 func.length 字节数,内容不出库) */
|
||||||
|
function formatPayloadSize(size: number | null | undefined): string {
|
||||||
|
if (size === null || size === undefined) return '—'
|
||||||
|
if (size < 1024) return `${size} B`
|
||||||
|
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`
|
||||||
|
return `${(size / 1024 / 1024).toFixed(1)} MB`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 画像 chip 值:对象 JSON 化(如 _cc 内部态已被后端滤除,此处兜底) */
|
||||||
|
function chipText(value: unknown): string {
|
||||||
|
if (value === null || value === undefined) return ''
|
||||||
|
return typeof value === 'object' ? JSON.stringify(value) : String(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function intentTagType(level: string | null | undefined): 'success' | 'info' | 'warning' {
|
||||||
|
if (level === 'interested') return 'success'
|
||||||
|
if (level === 'pending') return 'warning'
|
||||||
|
return 'info'
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushStatusTagType(status: string | null | undefined): 'success' | 'danger' | 'warning' | 'info' {
|
||||||
|
if (status === 'success') return 'success'
|
||||||
|
if (status === 'failed') return 'danger'
|
||||||
|
if (status === 'pending') return 'warning'
|
||||||
|
return 'info'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 推送结论(200 时对方逐条结论;duplicated 非错误) */
|
||||||
|
function outcomeLabel(outcome: string | null | undefined): string {
|
||||||
|
if (!outcome) return '—'
|
||||||
|
const labels: Record<string, string> = { accepted: '已接受', duplicated: '重复' }
|
||||||
|
return labels[outcome] ?? outcome
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 装配 -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
// 租户切换:重置分页/关抽屉,双页签联动刷新(推送日志未激活过则不拉)
|
||||||
|
watch(activeSlug, () => {
|
||||||
|
if (!activeSlug.value) return
|
||||||
|
page.value = 1
|
||||||
|
logPage.value = 1
|
||||||
|
drawerVisible.value = false
|
||||||
|
reloadList()
|
||||||
|
if (pushLogsActivated.value) reloadLogs()
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (isSuper.value) {
|
||||||
|
loadTenants() // 成功后置 activeSlug → watch 触发首载
|
||||||
|
} else if (authStore.tenantSlug) {
|
||||||
|
activeSlug.value = authStore.tenantSlug // watch 触发首载
|
||||||
|
}
|
||||||
|
// 租户管理员无 tenantSlug:模板已出防御告警,不加载
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.page-placeholder {
|
.conversations-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-title {
|
.page-header {
|
||||||
font-weight: 600;
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-sub {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--art-gray-500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tenant-switcher {
|
||||||
|
width: 240px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-card {
|
||||||
|
.filter-bar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-intent {
|
||||||
|
width: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-site {
|
||||||
|
width: 190px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-date {
|
||||||
|
width: 260px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.load-error {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--art-gray-400);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mono {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-bar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 详情抽屉 -----------------------------------------------------------------
|
||||||
|
|
||||||
|
.detail-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
min-height: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-section {
|
||||||
|
.section-title {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-wrap {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
.chip {
|
||||||
|
max-width: 100%;
|
||||||
|
|
||||||
|
:deep(.el-tag__content) {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-summary {
|
||||||
|
margin: 10px 0 0;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--art-gray-500);
|
||||||
|
}
|
||||||
|
|
||||||
|
.evaluation-block {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.evaluation-meta {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--art-gray-500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.reasoning {
|
||||||
|
margin: 10px 0 0;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-line {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--art-gray-500);
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-ua {
|
||||||
|
font-size: 12px;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 消息流(assistant 左 / user 右) -------------------------------------------
|
||||||
|
|
||||||
|
.message-flow {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
|
.bubble-row {
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
.bubble {
|
||||||
|
max-width: 78%;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
|
||||||
|
.bubble-content {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-meta {
|
||||||
|
display: block;
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--art-gray-400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-user {
|
||||||
|
justify-content: flex-end;
|
||||||
|
|
||||||
|
.bubble {
|
||||||
|
color: #fff;
|
||||||
|
background: var(--el-color-primary);
|
||||||
|
|
||||||
|
.bubble-meta {
|
||||||
|
color: rgb(255 255 255 / 70%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-assistant {
|
||||||
|
justify-content: flex-start;
|
||||||
|
|
||||||
|
.bubble {
|
||||||
|
background: var(--art-main-bg-color, var(--el-fill-color-light));
|
||||||
|
|
||||||
|
.bubble-meta {
|
||||||
|
color: var(--art-gray-400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user