mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-05-20 21:30:28 +08:00
feat: 对话列表支持排序和筛选
This commit is contained in:
@@ -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 层由主进程填充
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
<!-- 滑动指示器 -->
|
||||
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
|
||||
@@ -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": "設定"
|
||||
|
||||
@@ -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": "设置"
|
||||
|
||||
@@ -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
@@ -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,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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 对话数
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user