feat: 对话列表支持排序和筛选

This commit is contained in:
digua
2026-04-17 23:06:31 +08:00
committed by digua
parent 0aa99af048
commit 0fb514fe90
10 changed files with 218 additions and 59 deletions
+1
View File
@@ -174,6 +174,7 @@ export function getAllSessions(): any[] {
groupAvatar: meta.group_avatar || null,
ownerId: meta.owner_id || null,
memberAvatar, // 私聊对方头像
lastMessageTs: overview?.lastMessageTs ?? null,
summaryCount,
aiConversationCount: 0, // 将在 IPC 层由主进程填充
})
+18 -5
View File
@@ -21,6 +21,10 @@ interface Props {
persistKey?: string
/** 方向:horizontal(水平)或 vertical(垂直) */
orientation?: 'horizontal' | 'vertical'
/** 尺寸:sm 适用于侧边栏等紧凑场景 */
size?: 'sm' | 'md'
/** 是否显示底部边框线 */
bordered?: boolean
}
interface Emits {
@@ -30,6 +34,7 @@ interface Emits {
const props = withDefaults(defineProps<Props>(), {
orientation: 'horizontal',
size: 'md',
})
const emit = defineEmits<Emits>()
@@ -143,8 +148,12 @@ watch(
<div
:class="[
isVertical
? 'h-full border-r border-gray-200/50 dark:border-gray-700/50'
: 'flex items-center justify-between border-b border-gray-200/50 px-6 dark:border-gray-800/50',
? ['h-full', bordered !== false ? 'border-r border-gray-200/50 dark:border-gray-700/50' : '']
: [
'flex items-center justify-between',
bordered !== false ? 'border-b border-gray-200/50 dark:border-gray-800/50' : '',
size === 'sm' ? 'px-3' : 'px-6',
],
]"
>
<div ref="containerRef" class="relative" :class="[isVertical ? 'flex flex-col gap-1' : 'flex gap-1']">
@@ -152,16 +161,20 @@ watch(
v-for="tab in items"
:key="tab.id"
:ref="(el) => setTabRef(tab.id, el as HTMLElement)"
class="flex items-center gap-2 text-sm font-medium transition-colors"
class="flex items-center font-medium transition-colors"
:class="[
isVertical ? 'justify-start px-3 py-2' : 'px-4 py-3',
isVertical
? 'justify-start gap-2 px-3 py-2 text-sm'
: size === 'sm'
? 'gap-1.5 px-2 py-1.5 text-xs'
: 'gap-2 px-4 py-3 text-sm',
activeTab === tab.id
? 'text-primary-600 dark:text-primary-400'
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300',
]"
@click="handleTabClick(tab.id)"
>
<UIcon v-if="tab.icon" :name="tab.icon" class="h-4 w-4" />
<UIcon v-if="tab.icon" :name="tab.icon" :class="size === 'sm' ? 'h-3.5 w-3.5' : 'h-4 w-4'" />
{{ tab.label }}
</button>
<!-- 滑动指示器 -->
+38 -38
View File
@@ -4,21 +4,18 @@ import { ref, computed, onMounted, nextTick } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import type { AnalysisSession } from '@/types/base'
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
import 'dayjs/locale/zh-cn'
import 'dayjs/locale/en'
import SidebarButton from './sidebar/SidebarButton.vue'
import SidebarFooter from './sidebar/SidebarFooter.vue'
import SidebarSortPopover from './sidebar/SidebarSortPopover.vue'
import SubTabs from '@/components/UI/SubTabs.vue'
import { useSessionStore } from '@/stores/session'
import { useLayoutStore } from '@/stores/layout'
dayjs.extend(relativeTime)
const { t } = useI18n()
const sessionStore = useSessionStore()
const layoutStore = useLayoutStore()
const { sessions, sortedSessions } = storeToRefs(sessionStore)
const { sessions, sortedSessions, filterType } = storeToRefs(sessionStore)
const { isSidebarCollapsed: isCollapsed } = storeToRefs(layoutStore)
const { toggleSidebar } = layoutStore
const router = useRouter()
@@ -44,6 +41,13 @@ const version = ref('')
const showSearch = ref(false)
const searchQuery = ref('')
// 筛选 Tab 配置
const filterTabItems = computed(() => [
{ id: 'all', label: t('layout.filter.all') },
{ id: 'private', label: t('layout.filter.private') },
{ id: 'group', label: t('layout.filter.group') },
])
// 过滤后的会话列表
const filteredSortedSessions = computed(() => {
if (!searchQuery.value.trim()) {
@@ -76,10 +80,6 @@ function handleImport() {
router.push('/')
}
function formatTime(timestamp: number): string {
return dayjs.unix(timestamp).fromNow()
}
// 打开重命名弹窗
function openRenameModal(session: AnalysisSession) {
renameTarget.value = session
@@ -229,35 +229,35 @@ function getSessionAvatar(session: AnalysisSession): string | null {
<!-- Session List -->
<div class="flex-1 relative min-h-0 flex flex-col">
<!-- 聊天记录标题 - 固定在顶部不随列表滚动 -->
<div v-if="!isCollapsed && sessions.length > 0" class="px-4 mb-2">
<div class="flex items-center justify-between">
<UTooltip :text="t('layout.tooltip.hint')" :popper="{ placement: 'right' }">
<div class="flex items-center gap-1 pl-3">
<div class="text-sm font-medium text-gray-500">{{ t('layout.chatHistory') }}</div>
<UIcon name="i-heroicons-question-mark-circle" class="size-3.5 text-gray-400" />
<!-- 筛选与排序 - 固定在顶部不随列表滚动 -->
<div v-if="!isCollapsed && sessions.length > 0" class="mb-2">
<SubTabs v-model="filterType" :items="filterTabItems" size="sm" :bordered="false">
<template #right>
<div class="flex items-center gap-0.5">
<UTooltip :text="t('layout.tooltip.search')" :popper="{ placement: 'right' }">
<UButton
:icon="showSearch ? 'i-heroicons-x-mark' : 'i-heroicons-magnifying-glass'"
color="neutral"
variant="ghost"
size="xs"
@click="toggleSearch"
/>
</UTooltip>
<SidebarSortPopover />
<UTooltip :text="t('layout.manage')" :popper="{ placement: 'right' }">
<UButton
icon="i-heroicons-rectangle-stack"
color="neutral"
variant="ghost"
size="xs"
@click="router.push({ name: 'settings', query: { tab: 'data' } })"
/>
</UTooltip>
</div>
</UTooltip>
<div class="flex items-center gap-2">
<button
class="text-xs font-medium text-gray-400 hover:text-gray-900 transition-colors dark:hover:text-white"
@click="router.push({ name: 'settings', query: { tab: 'data' } })"
>
{{ t('layout.manage') }}
</button>
<UTooltip :text="t('layout.tooltip.search')" :popper="{ placement: 'right' }">
<UButton
:icon="showSearch ? 'i-heroicons-x-mark' : 'i-heroicons-magnifying-glass'"
color="neutral"
variant="ghost"
size="xs"
@click="toggleSearch"
/>
</UTooltip>
</div>
</div>
</template>
</SubTabs>
<!-- 搜索框 -->
<div v-if="showSearch" class="mt-2">
<div v-if="showSearch" class="mt-2 px-4">
<UInput
v-model="searchQuery"
:placeholder="t('layout.searchPlaceholder')"
@@ -351,7 +351,7 @@ function getSessionAvatar(session: AnalysisSession): string | null {
/>
</div>
<p class="truncate text-xs text-gray-500 dark:text-gray-400">
{{ t('layout.sessionInfo', { count: session.messageCount, time: formatTime(session.importedAt) }) }}
{{ t('layout.sessionInfo', { count: session.messageCount }) }}
</p>
</div>
</div>
@@ -0,0 +1,72 @@
<script setup lang="ts">
import { ref } from 'vue'
import { storeToRefs } from 'pinia'
import { useI18n } from 'vue-i18n'
import { useSessionStore } from '@/stores/session'
const { t } = useI18n()
const sessionStore = useSessionStore()
const { sortField, sortOrder } = storeToRefs(sessionStore)
// 排序弹出框
const isSortPopoverOpen = ref(false)
// 排序选项配置
type SortOption = { value: 'importedAt' | 'lastMessageTs' | 'messageCount'; labelKey: string }
const sortOptions: SortOption[] = [
{ value: 'importedAt', labelKey: 'layout.sort.importedAt' },
{ value: 'lastMessageTs', labelKey: 'layout.sort.lastMessageTs' },
{ value: 'messageCount', labelKey: 'layout.sort.messageCount' },
]
function handleSortSelect(field: SortOption['value']) {
if (sortField.value === field) {
sortOrder.value = sortOrder.value === 'desc' ? 'asc' : 'desc'
} else {
sortField.value = field
sortOrder.value = 'desc'
}
isSortPopoverOpen.value = false
}
</script>
<template>
<UPopover v-model:open="isSortPopoverOpen" :ui="{ content: 'z-50 p-0' }">
<UTooltip :text="t('layout.tooltip.sort')" :popper="{ placement: 'right' }">
<UButton
:icon="sortOrder === 'desc' ? 'i-heroicons-bars-arrow-down' : 'i-heroicons-bars-arrow-up'"
color="neutral"
variant="ghost"
size="xs"
/>
</UTooltip>
<template #content>
<div class="w-48 p-1.5 flex flex-col space-y-0.5">
<!-- 头部标题 -->
<div class="px-2 pb-2 pt-1 text-xs font-semibold text-gray-500 dark:text-gray-400">
{{ t('layout.tooltip.sort') }}
</div>
<!-- 排序项 -->
<button
v-for="opt in sortOptions"
:key="opt.value"
class="flex w-full items-center justify-between rounded-md px-2.5 py-2 text-sm transition-colors"
:class="
sortField === opt.value
? 'bg-gray-100 text-gray-900 dark:bg-white/10 dark:text-white font-medium'
: 'text-gray-600 hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-white/5'
"
@click="handleSortSelect(opt.value)"
>
<span>{{ t(opt.labelKey) }}</span>
<UIcon
v-if="sortField === opt.value"
:name="sortOrder === 'desc' ? 'i-heroicons-bars-arrow-down' : 'i-heroicons-bars-arrow-up'"
class="h-4 w-4 opacity-70"
/>
</button>
</div>
</template>
</UPopover>
</template>
+13 -2
View File
@@ -21,13 +21,24 @@
"title": "Confirm Delete",
"message": "Are you sure you want to delete \"{name}\"? This action cannot be undone."
},
"filter": {
"all": "All",
"private": "Private",
"group": "Group"
},
"sort": {
"importedAt": "Import Time",
"lastMessageTs": "Last Message",
"messageCount": "Message Count"
},
"tooltip": {
"expand": "Expand sidebar",
"collapse": "Collapse sidebar",
"hint": "Right-click to rename or delete",
"search": "Search chat records"
"search": "Search chat records",
"sort": "Sort"
},
"sessionInfo": "{count} messages · {time}",
"sessionInfo": "{count} messages",
"footer": {
"helpAndFeedback": "Help & Feedback",
"settings": "Settings"
+13 -2
View File
@@ -21,13 +21,24 @@
"title": "削除の確認",
"message": "チャット記録「{name}」を削除しますか?この操作は取り消せません。"
},
"filter": {
"all": "すべて",
"private": "個人",
"group": "グループ"
},
"sort": {
"importedAt": "インポート日時",
"lastMessageTs": "最新メッセージ",
"messageCount": "メッセージ数"
},
"tooltip": {
"expand": "サイドバーを展開",
"collapse": "サイドバーを折りたたむ",
"hint": "右クリックでチャット記録の削除や名前変更ができます",
"search": "チャット記録を検索"
"search": "チャット記録を検索",
"sort": "並べ替え"
},
"sessionInfo": "{count} 件のメッセージ · {time}",
"sessionInfo": "{count} 件のメッセージ",
"footer": {
"helpAndFeedback": "フィードバックとヘルプ",
"settings": "設定"
+13 -2
View File
@@ -21,13 +21,24 @@
"title": "确认删除",
"message": "确定要删除聊天记录 \"{name}\" 吗?此操作无法撤销。"
},
"filter": {
"all": "全部",
"private": "私聊",
"group": "群聊"
},
"sort": {
"importedAt": "导入时间",
"lastMessageTs": "最近对话",
"messageCount": "消息数量"
},
"tooltip": {
"expand": "展开侧边栏",
"collapse": "收起侧边栏",
"hint": "右键可删除或重命名聊天记录",
"search": "搜索聊天记录"
"search": "搜索聊天记录",
"sort": "排序"
},
"sessionInfo": "{count} 条消息 · {time}",
"sessionInfo": "{count} 条消息",
"footer": {
"helpAndFeedback": "反馈和帮助",
"settings": "设置"
+13 -2
View File
@@ -21,13 +21,24 @@
"title": "確認刪除",
"message": "確定要刪除聊天紀錄「{name}」嗎?此操作無法復原。"
},
"filter": {
"all": "全部",
"private": "私聊",
"group": "群聊"
},
"sort": {
"importedAt": "匯入時間",
"lastMessageTs": "最近對話",
"messageCount": "訊息數量"
},
"tooltip": {
"expand": "展開側邊欄",
"collapse": "收起側邊欄",
"hint": "右鍵可刪除或重新命名聊天紀錄",
"search": "搜尋聊天紀錄"
"search": "搜尋聊天紀錄",
"sort": "排序"
},
"sessionInfo": "{count} 條訊息 · {time}",
"sessionInfo": "{count} 條訊息",
"footer": {
"helpAndFeedback": "回饋與協助",
"settings": "設定"
+36 -8
View File
@@ -1,6 +1,15 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { AnalysisSession, ImportProgress } from '@/types/base'
import type { AnalysisSession, ImportProgress, ChatType } from '@/types/base'
/** 侧边栏筛选类型 */
export type SessionFilterType = 'all' | ChatType
/** 侧边栏排序字段 */
export type SessionSortField = 'importedAt' | 'lastMessageTs' | 'messageCount'
/** 排序方向 */
export type SessionSortOrder = 'asc' | 'desc'
/** 迁移信息 */
export interface MigrationInfo {
@@ -696,12 +705,24 @@ export const useSessionStore = defineStore(
// 置顶会话 ID 列表
const pinnedSessionIds = ref<string[]>([])
// 排序后的会话列表
const sortedSessions = computed(() => {
// 建立索引映射,index 越大表示越晚置顶
const pinIndexMap = new Map(pinnedSessionIds.value.map((id, index) => [id, index]))
// 侧边栏筛选/排序状态
const filterType = ref<SessionFilterType>('all')
const sortField = ref<SessionSortField>('importedAt')
const sortOrder = ref<SessionSortOrder>('desc')
return [...sessions.value].sort((a, b) => {
// 排序后的会话列表(含筛选 + 排序 + 置顶)
const sortedSessions = computed(() => {
// 1. 筛选
let filtered = sessions.value
if (filterType.value !== 'all') {
filtered = filtered.filter((s) => s.type === filterType.value)
}
// 2. 建立置顶索引映射
const pinIndexMap = new Map(pinnedSessionIds.value.map((id, index) => [id, index]))
const dir = sortOrder.value === 'desc' ? -1 : 1
return [...filtered].sort((a, b) => {
const aPinIndex = pinIndexMap.get(a.id)
const bPinIndex = pinIndexMap.get(b.id)
const aPinned = aPinIndex !== undefined
@@ -715,7 +736,11 @@ export const useSessionStore = defineStore(
if (aPinned && !bPinned) return -1
if (!aPinned && bPinned) return 1
// 都不置顶:保持原顺序(通常是按时间倒序)
// 都不置顶:按用户选择的字段排序
const field = sortField.value
const aVal = a[field] ?? 0
const bVal = b[field] ?? 0
if (aVal !== bVal) return (aVal - bVal) * dir
return 0
})
})
@@ -743,6 +768,9 @@ export const useSessionStore = defineStore(
sessions,
sortedSessions,
pinnedSessionIds,
filterType,
sortField,
sortOrder,
currentSessionId,
isImporting,
importProgress,
@@ -791,7 +819,7 @@ export const useSessionStore = defineStore(
storage: sessionStorage,
},
{
pick: ['pinnedSessionIds'],
pick: ['pinnedSessionIds', 'filterType', 'sortField', 'sortOrder'],
storage: localStorage,
},
],
+1
View File
@@ -239,6 +239,7 @@ export interface AnalysisSession {
groupAvatar: string | null // 群头像(base64 Data URL
ownerId: string | null // 所有者/导出者的 platformId
memberAvatar: string | null // 私聊对方头像(base64 Data URL
lastMessageTs: number | null // 最后一条消息时间戳(秒)
summaryCount: number // 已生成摘要的会话片段数
aiConversationCount: number // AI 对话数
}