Files
EasyFlow/easyflow-ui-admin/app/src/views/ai/chatHistory/index.vue
陈子默 4a15124183 feat: 重构聊天时间线与附件上传交互
- 管理端和用户中心统一切换到 chatTime 时间线模型,按真实 assistant/tool 时序渲染

- 收紧工具气泡与 JSON 展示样式,保留同气泡内的工具状态更新

- 回形针直接触发文件选择,附件列表上移到输入框上方并补充共享 helper 测试
2026-05-11 21:25:21 +08:00

636 lines
17 KiB
Vue

<script setup lang="ts">
import type { ChatTimeTimelineItem } from '@easyflow/types';
import { computed, onMounted, ref } from 'vue';
import { useEasyFlowDrawer } from '@easyflow/common-ui';
import { ChatTimeHistoryMapper } from '@easyflow/utils';
import { Search } from '@element-plus/icons-vue';
import {
ElButton,
ElDatePicker,
ElEmpty,
ElInput,
ElPagination,
ElSelect,
ElTable,
ElTableColumn,
} from 'element-plus';
import { tryit } from 'radash';
import { api } from '#/api/request';
import ChatHistoryDetailDrawer from '#/components/chat-history/ChatHistoryDetailDrawer.vue';
import ListPageShell from '#/components/page/ListPageShell.vue';
const assistantList = ref<any[]>([]);
const sessions = ref<any[]>([]);
const loading = ref(false);
const query = ref({
assistantId: undefined as number | undefined,
userAccount: '',
timeRange: [] as string[],
pageNumber: 1,
pageSize: 20,
});
const quickRange = ref<'' | 'last7' | 'last30' | 'today'>('');
const pageState = ref({
total: 0,
});
const pageSizeOptions = [20, 50, 100];
const quickRangeOptions = [
{ label: '今天', value: 'today' as const },
{ label: '最近 7 天', value: 'last7' as const },
{ label: '最近 30 天', value: 'last30' as const },
];
const drawerLoading = ref(false);
const currentSession = ref<any>();
const messageList = ref<ChatTimeTimelineItem[]>([]);
const loadedMessageRecordCount = ref(0);
const messagePage = ref({
total: 0,
pageNumber: 1,
pageSize: 20,
});
const [Drawer, drawerApi] = useEasyFlowDrawer({
appendToMain: false,
class:
'!w-[820px] max-w-[calc(100vw-24px)] !border-0 !bg-[hsl(var(--glass-tint))/0.28] shadow-[0_24px_48px_-36px_hsl(var(--foreground)/0.12)] supports-[backdrop-filter]:!bg-[hsl(var(--glass-tint))/0.2]',
closable: false,
contentClass: 'p-0',
footer: false,
header: false,
modal: false,
placement: 'right',
});
const hasMoreMessages = computed(
() => loadedMessageRecordCount.value < messagePage.value.total,
);
const selectedSessionId = computed(() =>
String(currentSession.value?.id || ''),
);
onMounted(async () => {
await Promise.all([fetchAssistants(), fetchSessions()]);
});
async function fetchAssistants() {
const [, res] = await tryit(api.get)('/api/v1/bot/list', {
params: { status: 1 },
});
if (res?.errorCode === 0) {
assistantList.value = (res.data || []).map((item: any) => ({
label: item.title,
value: item.id,
}));
}
}
async function fetchSessions() {
loading.value = true;
const [, res] = await tryit(api.get)('/api/v1/chatHistory/sessions', {
params: {
assistantId: query.value.assistantId,
userAccount: query.value.userAccount || undefined,
startTime: query.value.timeRange?.[0],
endTime: query.value.timeRange?.[1],
pageNumber: query.value.pageNumber,
pageSize: query.value.pageSize,
},
});
loading.value = false;
if (res?.errorCode === 0) {
sessions.value = res.data?.records || [];
pageState.value.total = res.data?.total || 0;
}
}
async function openSession(sessionId: number | string) {
drawerLoading.value = true;
currentSession.value =
sessions.value.find((item: any) => String(item.id) === String(sessionId)) ||
undefined;
messageList.value = [];
loadedMessageRecordCount.value = 0;
messagePage.value = {
total: 0,
pageNumber: 1,
pageSize: 20,
};
drawerApi.open();
const [, summaryRes] = await tryit(api.get)(
`/api/v1/chatHistory/sessions/${sessionId}`,
);
if (summaryRes?.errorCode !== 0) {
drawerLoading.value = false;
drawerApi.close();
return;
}
currentSession.value = summaryRes.data;
await loadMessages(true);
drawerLoading.value = false;
}
async function loadMessages(reset = false) {
if (!currentSession.value?.id) {
return;
}
const nextPageNumber = reset ? 1 : messagePage.value.pageNumber + 1;
const [, res] = await tryit(api.get)(
`/api/v1/chatHistory/sessions/${currentSession.value.id}/messages`,
{
params: {
pageNumber: nextPageNumber,
pageSize: messagePage.value.pageSize,
},
},
);
if (res?.errorCode !== 0) {
return;
}
const normalized = normalizeMessages(res.data?.records || []);
messageList.value = reset
? normalized
: [...normalized, ...messageList.value];
loadedMessageRecordCount.value = reset
? (res.data?.records || []).length
: loadedMessageRecordCount.value + (res.data?.records || []).length;
messagePage.value.total = res.data?.total || 0;
messagePage.value.pageNumber = nextPageNumber;
}
function normalizeMessages(records: any[]) {
return ChatTimeHistoryMapper.fromHistoryRecords([...records].reverse());
}
function changePage(pageNumber: number) {
query.value.pageNumber = pageNumber;
fetchSessions();
}
function changePageSize(pageSize: number) {
query.value.pageNumber = 1;
query.value.pageSize = pageSize;
fetchSessions();
}
function formatTime(value?: string) {
if (!value) {
return '-';
}
const time = new Date(value);
if (Number.isNaN(time.getTime())) {
return value;
}
const year = time.getFullYear();
const month = String(time.getMonth() + 1).padStart(2, '0');
const day = String(time.getDate()).padStart(2, '0');
const hour = String(time.getHours()).padStart(2, '0');
const minute = String(time.getMinutes()).padStart(2, '0');
return `${year}-${month}-${day} ${hour}:${minute}`;
}
function handleSearch() {
query.value.pageNumber = 1;
fetchSessions();
}
function applyQuickRange(range: 'last7' | 'last30' | 'today') {
quickRange.value = range;
query.value.timeRange = resolveQuickRange(range);
handleSearch();
}
function handleTimeRangeChange(value?: string[]) {
quickRange.value = '';
query.value.timeRange = normalizeTimeRange(value);
handleSearch();
}
function resolveQuickRange(range: 'last7' | 'last30' | 'today') {
const now = new Date();
const end = endOfDay(now);
let start = startOfDay(now);
if (range === 'last7') {
start = startOfDay(addDays(now, -6));
} else if (range === 'last30') {
start = startOfDay(addDays(now, -29));
}
return [formatDateTime(start), formatDateTime(end)];
}
function normalizeTimeRange(value?: string[]) {
if (!value?.length) {
return [];
}
const [startValue, endValue] = value;
if (!startValue || !endValue) {
return [];
}
return [
formatDateTime(startOfDay(new Date(startValue))),
formatDateTime(endOfDay(new Date(endValue))),
];
}
function startOfDay(date: Date) {
const value = new Date(date);
value.setHours(0, 0, 0, 0);
return value;
}
function endOfDay(date: Date) {
const value = new Date(date);
value.setHours(23, 59, 59, 0);
return value;
}
function addDays(date: Date, days: number) {
const value = new Date(date);
value.setDate(value.getDate() + days);
return value;
}
function formatDateTime(value: Date) {
const year = value.getFullYear();
const month = String(value.getMonth() + 1).padStart(2, '0');
const day = String(value.getDate()).padStart(2, '0');
const hour = String(value.getHours()).padStart(2, '0');
const minute = String(value.getMinutes()).padStart(2, '0');
const second = String(value.getSeconds()).padStart(2, '0');
return `${year}-${month}-${day} ${hour}:${minute}:${second}`;
}
function closeDetail() {
drawerApi.close();
}
</script>
<template>
<div class="chat-history-page flex h-full flex-col gap-6 p-6">
<ListPageShell class="flex-1" :content-padding="20">
<template #filters>
<div class="chat-history-page__filters">
<ElSelect
v-model="query.assistantId"
clearable
placeholder="筛选聊天助手"
:options="assistantList"
class="chat-history-page__filter-control is-select"
@change="handleSearch"
/>
<ElInput
v-model="query.userAccount"
class="chat-history-page__filter-control is-input"
placeholder="搜索聊天用户"
:prefix-icon="Search"
@keyup.enter="handleSearch"
/>
<div class="chat-history-page__quick-ranges">
<ElButton
v-for="item in quickRangeOptions"
:key="item.value"
:type="quickRange === item.value ? 'primary' : 'default'"
class="chat-history-page__quick-range-button"
@click="applyQuickRange(item.value)"
>
{{ item.label }}
</ElButton>
</div>
<ElDatePicker
v-model="query.timeRange"
class="chat-history-page__filter-control is-range"
type="daterange"
value-format="YYYY-MM-DD HH:mm:ss"
start-placeholder="开始时间"
end-placeholder="结束时间"
@change="handleTimeRangeChange"
/>
<ElButton type="primary" @click="handleSearch">查询</ElButton>
</div>
</template>
<div v-loading="loading" class="chat-history-page__content">
<div v-if="sessions.length > 0" class="chat-history-page__table-shell">
<ElTable
:data="sessions"
:current-row-key="selectedSessionId || undefined"
row-key="id"
class="chat-history-page__table"
height="100%"
highlight-current-row
empty-text=""
@row-click="(row) => openSession(row.id)"
>
<ElTableColumn label="会话信息" min-width="280">
<template #default="{ row }">
<div class="chat-history-page__session-cell">
<div class="chat-history-page__session-title">
{{ row.title || '未命名会话' }}
</div>
<div
v-if="row.lastMessagePreview"
class="chat-history-page__session-preview"
>
{{ row.lastMessagePreview }}
</div>
</div>
</template>
</ElTableColumn>
<ElTableColumn label="聊天助手" min-width="160">
<template #default="{ row }">
<span class="chat-history-page__assistant-chip">
{{ row.assistantName || '聊天助手' }}
</span>
</template>
</ElTableColumn>
<ElTableColumn prop="userAccount" label="聊天用户" min-width="160">
<template #default="{ row }">
<span class="chat-history-page__user-cell">
{{ row.userAccount || '-' }}
</span>
</template>
</ElTableColumn>
<ElTableColumn label="消息数" width="110" align="center">
<template #default="{ row }">
<span class="chat-history-page__count-pill">
{{ row.messageCount || 0 }}
</span>
</template>
</ElTableColumn>
<ElTableColumn label="最近活跃" width="180">
<template #default="{ row }">
<span class="chat-history-page__time-cell">
{{ formatTime(row.lastMessageAt || row.accessAt) }}
</span>
</template>
</ElTableColumn>
<ElTableColumn width="108" align="right">
<template #default="{ row }">
<ElButton
link
type="primary"
class="chat-history-page__detail-action"
@click.stop="openSession(row.id)"
>
查看详情
</ElButton>
</template>
</ElTableColumn>
</ElTable>
</div>
<div v-else class="chat-history-page__empty">
<ElEmpty description="暂无聊天历史" />
</div>
<div v-if="pageState.total > 0" class="chat-history-page__pagination">
<ElPagination
background
layout="total, sizes, prev, pager, next, jumper"
:current-page="query.pageNumber"
:page-size="query.pageSize"
:page-sizes="pageSizeOptions"
:total="pageState.total"
@current-change="changePage"
@size-change="changePageSize"
/>
</div>
</div>
</ListPageShell>
<Drawer>
<ChatHistoryDetailDrawer
:visible="true"
:loading="drawerLoading"
:session="currentSession"
:messages="messageList"
:has-more="hasMoreMessages"
:on-load-more="() => loadMessages(false)"
@close="closeDetail"
/>
</Drawer>
</div>
</template>
<style scoped>
.chat-history-page__filters {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
}
.chat-history-page__filter-control {
min-width: 0;
}
.chat-history-page__filter-control.is-select,
.chat-history-page__filter-control.is-input {
width: 220px;
}
.chat-history-page__filter-control.is-range {
width: 360px;
}
.chat-history-page__quick-ranges {
display: inline-flex;
flex-wrap: wrap;
gap: 8px;
}
.chat-history-page__quick-range-button {
min-width: 88px;
}
.chat-history-page__content {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
}
.chat-history-page__table-shell {
flex: 1;
min-height: 0;
overflow: hidden;
background: linear-gradient(
180deg,
hsl(var(--glass-border) / 28%) 0%,
hsl(var(--glass-tint) / 40%) 14%,
hsl(var(--surface-panel) / 94%) 100%
);
border: 1px solid hsl(var(--glass-border) / 42%);
border-radius: 24px;
box-shadow:
inset 0 1px 0 hsl(var(--glass-border) / 54%),
0 24px 42px -36px hsl(var(--foreground) / 16%);
}
.chat-history-page__table {
height: 100%;
background: transparent;
}
.chat-history-page__session-cell {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
padding: 2px 0;
}
.chat-history-page__session-title {
overflow: hidden;
text-overflow: ellipsis;
font-size: 14px;
font-weight: 600;
color: hsl(var(--text-strong));
white-space: nowrap;
}
.chat-history-page__session-preview {
display: -webkit-box;
overflow: hidden;
-webkit-line-clamp: 1;
font-size: 12px;
line-height: 1.5;
color: hsl(var(--text-muted));
overflow-wrap: anywhere;
-webkit-box-orient: vertical;
}
.chat-history-page__assistant-chip {
display: inline-flex;
align-items: center;
max-width: 100%;
min-height: 28px;
padding: 0 12px;
overflow: hidden;
text-overflow: ellipsis;
font-size: 12px;
font-weight: 600;
color: hsl(var(--nav-item-active-foreground));
white-space: nowrap;
background: hsl(var(--glass-tint) / 76%);
border: 1px solid hsl(var(--glass-border) / 48%);
border-radius: 999px;
}
.chat-history-page__user-cell,
.chat-history-page__time-cell {
font-size: 13px;
color: hsl(var(--text-secondary));
}
.chat-history-page__count-pill {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 42px;
padding: 4px 10px;
font-size: 12px;
font-weight: 600;
color: hsl(var(--text-strong));
background: hsl(var(--surface-contrast-soft) / 88%);
border-radius: 999px;
}
.chat-history-page__detail-action {
padding-right: 2px;
}
.chat-history-page__empty {
display: flex;
flex: 1;
align-items: center;
justify-content: center;
min-height: 360px;
}
.chat-history-page__pagination {
display: flex;
justify-content: flex-end;
padding-top: 20px;
}
.chat-history-page__content :deep(.el-table),
.chat-history-page__content :deep(.el-table__inner-wrapper),
.chat-history-page__content :deep(.el-table__body-wrapper),
.chat-history-page__content :deep(.el-scrollbar),
.chat-history-page__content :deep(.el-scrollbar__wrap),
.chat-history-page__content :deep(.el-scrollbar__view) {
height: 100%;
}
.chat-history-page__content :deep(.el-table::before) {
display: none;
}
.chat-history-page__content :deep(.el-table tr) {
cursor: pointer;
}
.chat-history-page__content :deep(.el-table th.el-table__cell) {
height: 48px;
font-size: 12px;
font-weight: 600;
color: hsl(var(--text-muted));
background: hsl(var(--surface-contrast-soft) / 54%);
border-bottom-color: hsl(var(--divider-faint) / 28%);
}
.chat-history-page__content :deep(.el-table td.el-table__cell) {
padding: 14px 0;
background: transparent;
border-bottom-color: hsl(var(--divider-faint) / 22%);
}
.chat-history-page__content
:deep(.el-table__body tr:hover > td.el-table__cell) {
background: hsl(var(--primary) / 4%);
}
.chat-history-page__content
:deep(.el-table__body tr.current-row > td.el-table__cell) {
background: hsl(var(--primary) / 8%);
}
@media (max-width: 1024px) {
.chat-history-page__filter-control.is-range {
width: min(100%, 360px);
}
}
@media (max-width: 768px) {
.chat-history-page {
padding: 16px;
}
.chat-history-page__filters {
gap: 10px;
}
.chat-history-page__filter-control.is-select,
.chat-history-page__filter-control.is-input,
.chat-history-page__filter-control.is-range {
width: 100%;
}
}
</style>