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

7.8 KiB
Raw Blame History

ChatHistory 会话列表重构迁移计划

参考来源

  • 源文件: ClawX/src/components/layout/Sidebar.tsx:280-335
  • 目标文件: zn-ai/src/pages/home/ChatHistory.vue

源实现思路分析

Sidebar.tsx:280-335 的历史会话列表核心设计:

  1. 时间分桶Time Buckets

    • 将会话按最后活动时间划分为:todayyesterdaywithinWeekwithinTwoWeekswithinMontholder
    • 通过 getSessionBucket(activityMs, nowMs) 函数计算每个会话所属的分组。
    • 会话先按时间降序排序,再归入对应 bucket。
  2. 分组标签渲染

    • 每个 bucket 顶部显示一个 11px 的灰色小标签(如 "Today" / "Yesterday" / "Older")。
    • 空 bucket 直接跳过不渲染。
  3. 会话项结构

    • 外层 group relative flex items-center 包裹。
    • 左侧显示 agent 名称标签(圆角小 pill右侧显示 会话标题truncate 截断)。
    • 当前选中的会话高亮:背景色 + 加粗文字。
    • hover 时背景变深。
  4. 删除交互(悬停显隐)

    • 删除按钮默认 opacity-0hover 整个会话项时通过 group-hover:opacity-100 显现。
    • 按钮绝对定位在右侧,避免挤压标题空间。
  5. 点击行为

    • 点击整个会话行:切换会话 + 导航到聊天页。
    • 删除按钮单独拦截 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 事件。
  • 删除、重命名(如保留)等原有功能不受影响。
  • 侧边栏折叠时,历史会话列表跟随隐藏。