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:
浅念
2026-10-13 17:31:12 +08:00
parent f640889f22
commit ce668eaf2f
4 changed files with 1189 additions and 29 deletions
+859 -10
View File
@@ -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>
<div class="page-placeholder">
<ElCard shadow="never">
<template #header>
<span class="card-title">会话与线索</span>
</template>
<ElEmpty description="会话列表 / 详情 / 询盘推送日志(后续任务实现)" />
<div class="conversations-page">
<!-- 头部:标题 + 租户切换器(超管)/ 租户标识(租户管理员) -->
<div class="page-header">
<div>
<h2 class="page-title">会话与线索</h2>
<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>
<!-- ==================== 详情抽屉 ==================== -->
<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>
</template>
<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' })
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>
<style scoped lang="scss">
.page-placeholder {
.conversations-page {
display: flex;
flex-direction: column;
gap: 16px;
padding: 16px;
}
.card-title {
font-weight: 600;
.page-header {
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>