Files
zn-ai/docs/ChatHistorySessionListMigrationPlan.md
2026-04-14 17:02:20 +08:00

224 lines
7.8 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# ChatHistory 会话列表重构迁移计划
## 参考来源
- **源文件**: `ClawX/src/components/layout/Sidebar.tsx:280-335`
- **目标文件**: `zn-ai/src/pages/home/ChatHistory.vue`
## 源实现思路分析
`Sidebar.tsx:280-335` 的历史会话列表核心设计:
1. **时间分桶Time Buckets**
- 将会话按最后活动时间划分为:`today``yesterday``withinWeek``withinTwoWeeks``withinMonth``older`
- 通过 `getSessionBucket(activityMs, nowMs)` 函数计算每个会话所属的分组。
- 会话先按时间降序排序,再归入对应 bucket。
2. **分组标签渲染**
- 每个 bucket 顶部显示一个 11px 的灰色小标签(如 "Today" / "Yesterday" / "Older")。
- 空 bucket 直接跳过不渲染。
3. **会话项结构**
- 外层 `group relative flex items-center` 包裹。
- 左侧显示 **agent 名称标签**(圆角小 pill右侧显示 **会话标题**`truncate` 截断)。
- 当前选中的会话高亮:背景色 + 加粗文字。
- hover 时背景变深。
4. **删除交互(悬停显隐)**
- 删除按钮默认 `opacity-0`hover 整个会话项时通过 `group-hover:opacity-100` 显现。
- 按钮绝对定位在右侧,避免挤压标题空间。
5. **点击行为**
- 点击整个会话行:切换会话 + 导航到聊天页。
- 删除按钮单独拦截 `stopPropagation`
---
## 迁移目标
将上述时间分桶、分组标签、悬停删除、紧凑列表布局迁移到 `ChatHistory.vue`,替换当前简单平铺的 `groups` 列表,同时保留 zn-ai 项目现有风格与功能。
---
## 实现步骤
### 1. 引入时间分桶工具函数
`<script setup>` 中新增 `SessionBucketKey` 类型与 `getSessionBucket` 函数:
```ts
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` 接口增加可选时间字段,用于分桶:
```ts
interface HistoryMessage {
conversationId: string;
conversationTitle: string;
updatedAt?: number; // 毫秒时间戳,用于分桶
}
```
映射接口数据时注入时间戳(若后端返回字符串时间,先 `new Date().getTime()` 转换):
```ts
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 数组:
```ts
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"` 控制折叠显隐:
```html
<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>` 中引入删除图标:
```ts
import { RiDeleteBinLine } from '@remixicon/vue';
```
### 6. 样式与行为保持一致
- **选中态**:继续使用 `selectedConversationId` 判断,样式调整为带阴影和边框的白色背景卡片(与源实现高亮思路一致)。
- **空列表**:若 `groups` 为空,整个列表区域自然无内容,可后续补充 "暂无历史会话" 占位。
- **定时刷新时间基准**:如需让 "今天/昨天" 标签随时间自动更新,可补充 `setInterval` 每分钟刷新 `nowMs`
```ts
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` 事件。
- [ ] 删除、重命名(如保留)等原有功能不受影响。
- [ ] 侧边栏折叠时,历史会话列表跟随隐藏。