feat: 接入聊天历史界面与外链会话恢复
- 新增管理端与用户端聊天历史接口和页面 - 外链聊天支持访问令牌登录、身份保活与当前会话恢复 - 聊天执行链路切到统一 runtime 与 chatlog 查询接口
This commit is contained in:
@@ -1,177 +1,367 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
import { Delete, MoreFilled, Search } from '@element-plus/icons-vue';
|
||||
import { Delete, Edit, Search } from '@element-plus/icons-vue';
|
||||
import {
|
||||
ElButton,
|
||||
ElContainer,
|
||||
ElDropdown,
|
||||
ElDropdownItem,
|
||||
ElDropdownMenu,
|
||||
ElDrawer,
|
||||
ElEmpty,
|
||||
ElHeader,
|
||||
ElInput,
|
||||
ElMain,
|
||||
ElMessage,
|
||||
ElMessageBox,
|
||||
ElPagination,
|
||||
ElSelect,
|
||||
ElSpace,
|
||||
ElText,
|
||||
ElTag,
|
||||
} from 'element-plus';
|
||||
import { tryit } from 'radash';
|
||||
|
||||
import { api } from '#/api/request';
|
||||
import PageData from '#/components/page/PageData.vue';
|
||||
|
||||
const listTitles = ['聊天助理名称', '话题', '创建时间', '操作'];
|
||||
import { ChatBubbleList } from '#/components/chat';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const assistantList = ref<any[]>([]);
|
||||
const queryParams = ref<any>({});
|
||||
const pageRef = ref();
|
||||
|
||||
onMounted(() => {
|
||||
getAssistantList();
|
||||
const assistantList = ref<any[]>([]);
|
||||
const sessions = ref<any[]>([]);
|
||||
const loading = ref(false);
|
||||
const queryParams = ref({
|
||||
assistantId: undefined as number | undefined,
|
||||
keyword: '',
|
||||
pageNumber: 1,
|
||||
pageSize: 20,
|
||||
});
|
||||
|
||||
async function getAssistantList() {
|
||||
api
|
||||
.get('/userCenter/bot/list', {
|
||||
params: { ...queryParams.value, status: 1 },
|
||||
})
|
||||
.then((res) => {
|
||||
if (res.errorCode === 0) {
|
||||
assistantList.value = res.data.map((item: any) => ({
|
||||
label: item.title,
|
||||
value: item.id,
|
||||
}));
|
||||
}
|
||||
});
|
||||
}
|
||||
function search() {
|
||||
pageRef.value.setQuery({ ...queryParams.value, status: 1 });
|
||||
}
|
||||
function toDetail(record: any) {
|
||||
router.push({ path: `/chatHistory/${record.id}` });
|
||||
}
|
||||
async function handleDelete(id: string) {
|
||||
const [, res] = await tryit(api.post)('/userCenter/botConversation/remove', {
|
||||
id,
|
||||
});
|
||||
const pageState = ref({
|
||||
total: 0,
|
||||
});
|
||||
|
||||
if (res && res.errorCode === 0) {
|
||||
search();
|
||||
ElMessage.success('删除成功');
|
||||
const drawerVisible = ref(false);
|
||||
const drawerLoading = ref(false);
|
||||
const currentSession = ref<any>();
|
||||
const messageList = ref<any[]>([]);
|
||||
const messagePage = ref({
|
||||
total: 0,
|
||||
pageNumber: 1,
|
||||
pageSize: 20,
|
||||
});
|
||||
|
||||
const filteredSessions = computed(() => {
|
||||
const keyword = queryParams.value.keyword.trim().toLowerCase();
|
||||
if (!keyword) {
|
||||
return sessions.value;
|
||||
}
|
||||
return sessions.value.filter((item) => {
|
||||
const title = String(item.title || '').toLowerCase();
|
||||
const preview = String(item.lastMessagePreview || '').toLowerCase();
|
||||
const assistantName = String(item.assistantName || '').toLowerCase();
|
||||
return title.includes(keyword) || preview.includes(keyword) || assistantName.includes(keyword);
|
||||
});
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([fetchAssistants(), fetchSessions()]);
|
||||
const sessionId = route.query.sessionId ? String(route.query.sessionId) : '';
|
||||
if (sessionId) {
|
||||
await openSession(sessionId);
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
() => route.query.sessionId,
|
||||
async (sessionId) => {
|
||||
if (!sessionId) {
|
||||
drawerVisible.value = false;
|
||||
currentSession.value = undefined;
|
||||
messageList.value = [];
|
||||
return;
|
||||
}
|
||||
if (!currentSession.value || String(currentSession.value.id) !== String(sessionId)) {
|
||||
await openSession(String(sessionId));
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
async function fetchAssistants() {
|
||||
const [, res] = await tryit(api.get)('/userCenter/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)('/userCenter/chatHistory/sessions', {
|
||||
params: {
|
||||
assistantId: queryParams.value.assistantId,
|
||||
pageNumber: queryParams.value.pageNumber,
|
||||
pageSize: queryParams.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: string | number) {
|
||||
drawerLoading.value = true;
|
||||
const [, summaryRes] = await tryit(api.get)(`/userCenter/chatHistory/sessions/${sessionId}`);
|
||||
if (summaryRes?.errorCode !== 0) {
|
||||
drawerLoading.value = false;
|
||||
return;
|
||||
}
|
||||
currentSession.value = summaryRes.data;
|
||||
messageList.value = [];
|
||||
messagePage.value = {
|
||||
total: 0,
|
||||
pageNumber: 1,
|
||||
pageSize: 20,
|
||||
};
|
||||
drawerVisible.value = true;
|
||||
await loadMessages(true);
|
||||
drawerLoading.value = false;
|
||||
if (String(route.query.sessionId || '') !== String(sessionId)) {
|
||||
router.replace({
|
||||
path: '/chatHistory',
|
||||
query: { ...route.query, sessionId: String(sessionId) },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMessages(reset = false) {
|
||||
if (!currentSession.value?.id) {
|
||||
return;
|
||||
}
|
||||
const nextPageNumber = reset ? 1 : messagePage.value.pageNumber + 1;
|
||||
const [, res] = await tryit(api.get)(
|
||||
`/userCenter/chatHistory/sessions/${currentSession.value.id}/messages`,
|
||||
{
|
||||
params: {
|
||||
pageNumber: nextPageNumber,
|
||||
pageSize: messagePage.value.pageSize,
|
||||
},
|
||||
},
|
||||
);
|
||||
if (res?.errorCode !== 0) {
|
||||
return;
|
||||
}
|
||||
const normalized = normalizeMessages(res.data?.records || []);
|
||||
if (reset) {
|
||||
messageList.value = normalized;
|
||||
} else {
|
||||
messageList.value = [...normalized, ...messageList.value];
|
||||
}
|
||||
messagePage.value.total = res.data?.total || 0;
|
||||
messagePage.value.pageNumber = nextPageNumber;
|
||||
}
|
||||
|
||||
function normalizeMessages(records: any[]) {
|
||||
return [...records]
|
||||
.reverse()
|
||||
.map((item: any) => ({
|
||||
key: String(item.id),
|
||||
role: item.senderRole === 'assistant' ? 'assistant' : 'user',
|
||||
content:
|
||||
item.senderRole === 'assistant'
|
||||
? String(item.contentText || '').replace(/^Final Answer:\s*/i, '')
|
||||
: item.contentText,
|
||||
placement: item.senderRole === 'assistant' ? 'start' : 'end',
|
||||
created: item.created,
|
||||
chains: Array.isArray(item.contentPayload?.chains)
|
||||
? item.contentPayload.chains
|
||||
: undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
function closeDrawer() {
|
||||
drawerVisible.value = false;
|
||||
currentSession.value = undefined;
|
||||
messageList.value = [];
|
||||
router.replace({ path: '/chatHistory', query: {} });
|
||||
}
|
||||
|
||||
function changePage(pageNumber: number) {
|
||||
queryParams.value.pageNumber = pageNumber;
|
||||
fetchSessions();
|
||||
}
|
||||
|
||||
async function renameSession(session: any) {
|
||||
const [, promptRes] = await tryit(ElMessageBox.prompt)(
|
||||
'请输入新的会话名称',
|
||||
'重命名会话',
|
||||
{
|
||||
inputValue: session.title || '',
|
||||
inputPlaceholder: '请输入会话名称',
|
||||
confirmButtonText: '确认',
|
||||
cancelButtonText: '取消',
|
||||
},
|
||||
);
|
||||
const value = promptRes?.value?.trim();
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
const [, res] = await tryit(api.post)(`/userCenter/chatHistory/sessions/${session.id}/rename`, {
|
||||
title: value,
|
||||
});
|
||||
if (res?.errorCode === 0) {
|
||||
ElMessage.success('重命名成功');
|
||||
if (currentSession.value?.id === session.id) {
|
||||
currentSession.value.title = value;
|
||||
}
|
||||
await fetchSessions();
|
||||
}
|
||||
}
|
||||
|
||||
function deleteSession(session: any) {
|
||||
ElMessageBox.confirm('删除后将不再出现在聊天历史中,是否继续?', '删除会话', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '删除',
|
||||
cancelButtonText: '取消',
|
||||
}).then(async () => {
|
||||
const [, res] = await tryit(api.post)(`/userCenter/chatHistory/sessions/${session.id}/delete`, {});
|
||||
if (res?.errorCode === 0) {
|
||||
ElMessage.success('删除成功');
|
||||
if (currentSession.value?.id === session.id) {
|
||||
closeDrawer();
|
||||
}
|
||||
await fetchSessions();
|
||||
}
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
function formatTime(value?: string) {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
const time = new Date(value);
|
||||
if (Number.isNaN(time.getTime())) {
|
||||
return value;
|
||||
}
|
||||
return `${time.getMonth() + 1}-${time.getDate()} ${String(time.getHours()).padStart(2, '0')}:${String(time.getMinutes()).padStart(2, '0')}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElContainer class="bg-background-deep h-full">
|
||||
<ElHeader class="!h-auto !p-8 !pb-0">
|
||||
<ElSpace direction="vertical" :size="24" alignment="flex-start">
|
||||
<h1 class="text-2xl font-medium">聊天记录</h1>
|
||||
<div class="flex items-center gap-5">
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="text-nowrap text-sm">聊天助理</span>
|
||||
<ElSelect
|
||||
clearable
|
||||
v-model="queryParams.botId"
|
||||
:options="assistantList"
|
||||
placeholder="请选择聊天助理"
|
||||
@change="search"
|
||||
/>
|
||||
</div>
|
||||
<ElHeader class="!h-auto !px-8 !pb-0 !pt-8">
|
||||
<ElSpace direction="vertical" :size="20" alignment="flex-start" class="w-full">
|
||||
<div>
|
||||
<h1 class="text-2xl font-medium">聊天历史</h1>
|
||||
<p class="text-foreground/60 mt-2 text-sm">
|
||||
查看最近会话,并在右侧抽屉中回溯完整聊天内容。
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex w-full flex-wrap items-center gap-4">
|
||||
<ElSelect
|
||||
v-model="queryParams.assistantId"
|
||||
clearable
|
||||
placeholder="筛选聊天助理"
|
||||
:options="assistantList"
|
||||
class="!w-[220px]"
|
||||
@change="fetchSessions"
|
||||
/>
|
||||
<ElInput
|
||||
placeholder="搜索关键词"
|
||||
v-model="queryParams.title"
|
||||
@keyup.enter="search"
|
||||
@change="search"
|
||||
v-model="queryParams.keyword"
|
||||
class="max-w-[320px]"
|
||||
placeholder="搜索标题或最近消息"
|
||||
:prefix-icon="Search"
|
||||
/>
|
||||
</div>
|
||||
</ElSpace>
|
||||
</ElHeader>
|
||||
<ElMain class="!px-8">
|
||||
<ElContainer class="bg-background rounded-lg p-5">
|
||||
<ElHeader
|
||||
class="dark:bg-accent grid grid-cols-[repeat(3,minmax(0,1fr))_120px] place-items-center rounded-lg bg-[#f7f9fd] !p-0"
|
||||
height="54px"
|
||||
>
|
||||
<span
|
||||
class="text-accent-foreground text-sm"
|
||||
v-for="title in listTitles"
|
||||
:key="title"
|
||||
|
||||
<ElMain class="!px-8 !pb-8">
|
||||
<div class="bg-background border-border min-h-full rounded-2xl border p-5">
|
||||
<div v-if="filteredSessions.length > 0" class="flex flex-col gap-3">
|
||||
<button
|
||||
v-for="item in filteredSessions"
|
||||
:key="item.id"
|
||||
class="border-border hover:border-primary/30 hover:bg-accent/40 flex w-full items-start justify-between gap-4 rounded-2xl border px-5 py-4 text-left transition-colors"
|
||||
@click="openSession(item.id)"
|
||||
>
|
||||
{{ title }}
|
||||
</span>
|
||||
</ElHeader>
|
||||
<ElMain class="!p-0">
|
||||
<div class="flex flex-col items-center gap-5">
|
||||
<div class="w-full">
|
||||
<PageData
|
||||
page-url="/userCenter/botConversation/pageList"
|
||||
ref="pageRef"
|
||||
>
|
||||
<template #default="{ pageList }">
|
||||
<div
|
||||
class="text-foreground/90 grid h-[60px] grid-cols-[repeat(3,minmax(0,1fr))_120px] place-items-center text-sm hover:bg-[var(--el-fill-color-light)]"
|
||||
v-for="record in pageList"
|
||||
:key="record.id"
|
||||
>
|
||||
<ElText truncated>{{ record.bot.title }}</ElText>
|
||||
<ElText line-clamp="2">{{ record.title }}</ElText>
|
||||
<span>{{ record.created }}</span>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<ElButton
|
||||
class="[--el-font-weight-primary:400]"
|
||||
link
|
||||
type="primary"
|
||||
@click="toDetail(record)"
|
||||
>
|
||||
查看详情
|
||||
</ElButton>
|
||||
|
||||
<ElDropdown>
|
||||
<ElButton :icon="MoreFilled" link />
|
||||
|
||||
<template #dropdown>
|
||||
<ElDropdownMenu>
|
||||
<ElDropdownItem>
|
||||
<ElButton
|
||||
link
|
||||
type="danger"
|
||||
:icon="Delete"
|
||||
@click="handleDelete(record.id)"
|
||||
>
|
||||
删除
|
||||
</ElButton>
|
||||
</ElDropdownItem>
|
||||
</ElDropdownMenu>
|
||||
</template>
|
||||
</ElDropdown>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</PageData>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="truncate text-base font-medium">{{ item.title || '未命名会话' }}</span>
|
||||
<ElTag size="small" effect="plain">{{ item.assistantName || '聊天助理' }}</ElTag>
|
||||
</div>
|
||||
<div class="text-foreground/65 mt-2 line-clamp-2 text-sm">
|
||||
{{ item.lastMessagePreview || '暂无消息内容' }}
|
||||
</div>
|
||||
<div class="text-foreground/50 mt-3 flex flex-wrap items-center gap-4 text-xs">
|
||||
<span>最近发送人:{{ item.lastSenderName || '未知' }}</span>
|
||||
<span>消息数:{{ item.messageCount || 0 }}</span>
|
||||
<span>活跃时间:{{ formatTime(item.lastMessageAt || item.accessAt) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ElMain>
|
||||
</ElContainer>
|
||||
<div class="flex items-center gap-2">
|
||||
<ElButton link :icon="Edit" @click.stop="renameSession(item)">重命名</ElButton>
|
||||
<ElButton link type="danger" :icon="Delete" @click.stop="deleteSession(item)">删除</ElButton>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<ElEmpty v-else description="暂无聊天历史" />
|
||||
|
||||
<div class="mt-6 flex justify-end" v-if="pageState.total > queryParams.pageSize">
|
||||
<ElPagination
|
||||
background
|
||||
layout="prev, pager, next"
|
||||
:current-page="queryParams.pageNumber"
|
||||
:page-size="queryParams.pageSize"
|
||||
:total="pageState.total"
|
||||
@current-change="changePage"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ElMain>
|
||||
</ElContainer>
|
||||
|
||||
<ElDrawer
|
||||
v-model="drawerVisible"
|
||||
:title="currentSession?.title || '聊天详情'"
|
||||
size="760px"
|
||||
destroy-on-close
|
||||
@close="closeDrawer"
|
||||
>
|
||||
<div v-loading="drawerLoading" class="flex h-full flex-col">
|
||||
<div class="border-border mb-4 flex items-center justify-between rounded-2xl border px-4 py-3">
|
||||
<div class="min-w-0">
|
||||
<div class="truncate text-base font-medium">{{ currentSession?.title || '聊天详情' }}</div>
|
||||
<div class="text-foreground/55 mt-1 text-sm">
|
||||
{{ currentSession?.assistantName || '聊天助理' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-foreground/50 text-xs">
|
||||
{{ formatTime(currentSession?.lastMessageAt || currentSession?.accessAt) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-hidden">
|
||||
<div class="mb-4 flex justify-center">
|
||||
<ElButton
|
||||
v-if="messageList.length < messagePage.total"
|
||||
text
|
||||
type="primary"
|
||||
@click="loadMessages(false)"
|
||||
>
|
||||
加载更早消息
|
||||
</ElButton>
|
||||
</div>
|
||||
<ChatBubbleList
|
||||
:bot="{ icon: '', title: currentSession?.assistantName || '' }"
|
||||
:messages="messageList"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ElDrawer>
|
||||
</template>
|
||||
|
||||
<style lang="css" scoped>
|
||||
.el-select {
|
||||
--el-select-width: 165px;
|
||||
}
|
||||
|
||||
.el-select.bot-select {
|
||||
--el-select-width: 343px;
|
||||
}
|
||||
|
||||
.el-select :deep(.el-select__wrapper) {
|
||||
--el-border-radius-base: 8px;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user