7.8 KiB
7.8 KiB
ChatHistory 会话列表重构迁移计划
参考来源
- 源文件:
ClawX/src/components/layout/Sidebar.tsx:280-335 - 目标文件:
zn-ai/src/pages/home/ChatHistory.vue
源实现思路分析
Sidebar.tsx:280-335 的历史会话列表核心设计:
-
时间分桶(Time Buckets)
- 将会话按最后活动时间划分为:
today、yesterday、withinWeek、withinTwoWeeks、withinMonth、older。 - 通过
getSessionBucket(activityMs, nowMs)函数计算每个会话所属的分组。 - 会话先按时间降序排序,再归入对应 bucket。
- 将会话按最后活动时间划分为:
-
分组标签渲染
- 每个 bucket 顶部显示一个 11px 的灰色小标签(如 "Today" / "Yesterday" / "Older")。
- 空 bucket 直接跳过不渲染。
-
会话项结构
- 外层
group relative flex items-center包裹。 - 左侧显示 agent 名称标签(圆角小 pill),右侧显示 会话标题(
truncate截断)。 - 当前选中的会话高亮:背景色 + 加粗文字。
- hover 时背景变深。
- 外层
-
删除交互(悬停显隐)
- 删除按钮默认
opacity-0,hover 整个会话项时通过group-hover:opacity-100显现。 - 按钮绝对定位在右侧,避免挤压标题空间。
- 删除按钮默认
-
点击行为
- 点击整个会话行:切换会话 + 导航到聊天页。
- 删除按钮单独拦截
stopPropagation。
迁移目标
将上述时间分桶、分组标签、悬停删除、紧凑列表布局迁移到 ChatHistory.vue,替换当前简单平铺的 groups 列表,同时保留 zn-ai 项目现有风格与功能。
实现步骤
1. 引入时间分桶工具函数
在 <script setup> 中新增 SessionBucketKey 类型与 getSessionBucket 函数:
type SessionBucketKey =
| 'today'
| 'yesterday'
| 'withinWeek'
| 'withinTwoWeeks'
| 'withinMonth'
| 'older';
function getSessionBucket(activityMs: number, nowMs: number): SessionBucketKey {
if (!activityMs || activityMs <= 0) return 'older';
const now = new Date(nowMs);
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
const startOfYesterday = startOfToday - 24 * 60 * 60 * 1000;
if (activityMs >= startOfToday) return 'today';
if (activityMs >= startOfYesterday) return 'yesterday';
const daysAgo = (startOfToday - activityMs) / (24 * 60 * 60 * 1000);
if (daysAgo <= 7) return 'withinWeek';
if (daysAgo <= 14) return 'withinTwoWeeks';
if (daysAgo <= 30) return 'withinMonth';
return 'older';
}
说明:ChatHistory.vue 当前接口可能不返回
lastActivity时间。若接口无该字段,可先以会话创建时间/更新时间或本地Date.now()作为兜底,后续再补充真实数据。
2. 扩展数据模型
HistoryMessage 接口增加可选时间字段,用于分桶:
interface HistoryMessage {
conversationId: string;
conversationTitle: string;
updatedAt?: number; // 毫秒时间戳,用于分桶
}
映射接口数据时注入时间戳(若后端返回字符串时间,先 new Date().getTime() 转换):
groups.value = list.sessions.map((item: any) => ({
conversationId: item.session_id,
conversationTitle: item.title,
updatedAt: item.updated_at ? new Date(item.updated_at).getTime() : Date.now(),
}));
3. 计算时间分桶(响应式)
使用 computed 将会话列表转换为 bucket 数组:
import { computed } from 'vue';
const nowMs = ref(Date.now());
const sessionBuckets = computed(() => {
const buckets: Array<{ key: SessionBucketKey; label: string; sessions: HistoryMessage[] }> = [
{ key: 'today', label: '今天', sessions: [] },
{ key: 'yesterday', label: '昨天', sessions: [] },
{ key: 'withinWeek', label: '近7天', sessions: [] },
{ key: 'withinTwoWeeks', label: '近14天', sessions: [] },
{ key: 'withinMonth', label: '近30天', sessions: [] },
{ key: 'older', label: '更早', sessions: [] },
];
const map = Object.fromEntries(buckets.map((b) => [b.key, b])) as Record<SessionBucketKey, typeof buckets[number]>;
for (const session of [...groups.value].sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0))) {
const bucketKey = getSessionBucket(session.updatedAt || 0, nowMs.value);
map[bucketKey].sessions.push(session);
}
return buckets;
});
4. 模板结构改造
替换当前平铺的 <ul> 为按 bucket 分组的结构。外层保持 v-if="!sidebarCollapsed" 控制折叠显隐:
<div v-if="!sidebarCollapsed" class="overflow-y-auto p-2 flex-1">
<div v-for="bucket in sessionBuckets" :key="bucket.key" class="mb-2">
<div
v-if="bucket.sessions.length > 0"
class="px-2.5 pb-1 text-[11px] font-medium text-gray-400 tracking-tight"
>
{{ bucket.label }}
</div>
<div
v-for="item in bucket.sessions"
:key="item.conversationId"
class="group relative flex items-center"
>
<div
@click="selectedHistoryMessage(item.conversationId)"
:class="[
'w-full text-left rounded-lg px-2.5 py-1.5 text-[13px] transition-colors cursor-pointer pr-7',
item.conversationId === selectedConversationId
? 'bg-white shadow-sm border border-[#E5E8EE] text-gray-800 font-medium'
: 'text-gray-600 hover:bg-gray-200'
]"
>
<div class="flex min-w-0 items-center gap-2">
<!-- 如需展示 agent/来源,可在此加 pill 标签 -->
<span class="truncate">{{ item.conversationTitle }}</span>
</div>
</div>
<!-- 悬停删除按钮 -->
<button
aria-label="删除会话"
@click.stop="deleteHistoryMessage(item.conversationId)"
class="absolute right-1 flex items-center justify-center rounded p-0.5 transition-opacity opacity-0 group-hover:opacity-100 text-gray-400 hover:text-red-500 hover:bg-red-50"
>
<RiDeleteBinLine class="h-3.5 w-3.5" />
</button>
</div>
</div>
</div>
说明:
- 移除了旧版左侧的蓝色圆点,改为更紧凑的行式布局。
- 删除按钮改为 hover 显隐,并使用
@click.stop避免触发选择。- 保留原
selectedHistoryMessage选择逻辑。- 重命名功能可继续通过双击或扩展菜单实现;若需保留,可额外加一个 hover 出现的 "更多" 下拉按钮(本计划暂不展开)。
5. 图标引入
在 <script setup> 中引入删除图标:
import { RiDeleteBinLine } from '@remixicon/vue';
6. 样式与行为保持一致
- 选中态:继续使用
selectedConversationId判断,样式调整为带阴影和边框的白色背景卡片(与源实现高亮思路一致)。 - 空列表:若
groups为空,整个列表区域自然无内容,可后续补充 "暂无历史会话" 占位。 - 定时刷新时间基准:如需让 "今天/昨天" 标签随时间自动更新,可补充
setInterval每分钟刷新nowMs:
let timer: number | undefined;
onMounted(() => {
getHistoryConversationList();
timer = window.setInterval(() => {
nowMs.value = Date.now();
}, 60 * 1000);
});
onBeforeUnmount(() => {
if (timer) clearInterval(timer);
});
涉及文件
| 文件 | 修改内容 |
|---|---|
zn-ai/src/pages/home/ChatHistory.vue |
新增时间分桶逻辑、改造历史会话列表模板、引入悬停删除按钮、扩展 HistoryMessage 类型 |
验收标准
- 历史会话按时间自动分组成:今天、昨天、近7天、近14天、近30天、更早。
- 每个分组顶部显示对应中文标签。
- 会话项采用紧凑行式布局,标题超长截断。
- 鼠标悬停在某会话项上时,右侧出现删除按钮;移开后隐藏。
- 点击会话项仍正常触发
select-chat事件。 - 删除、重命名(如保留)等原有功能不受影响。
- 侧边栏折叠时,历史会话列表跟随隐藏。