mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-25 17:54:34 +08:00
feat(contacts): add source chat record actions
This commit is contained in:
1 parent
e1543c5f3b
commit
2b6d483049
11 files changed
+101
-22
No files matched your search
@@ -3,7 +3,7 @@
|
||||
* 聊天记录查看器 Drawer
|
||||
* 主组件,组合筛选面板、消息列表、会话时间线等子组件
|
||||
*/
|
||||
import { ref, watch, toRaw, nextTick, onMounted } from 'vue'
|
||||
import { computed, ref, watch, toRaw, nextTick, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import FilterPanel from './FilterPanel.vue'
|
||||
import MessageList from './MessageList.vue'
|
||||
@@ -13,6 +13,7 @@ import { useLayoutStore } from '@/stores/layout'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { useSessionIndexService } from '@/services'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { preserveChatRecordSessionId, resolveChatRecordSessionId } from './query-session'
|
||||
|
||||
const { t } = useI18n()
|
||||
const layoutStore = useLayoutStore()
|
||||
@@ -31,6 +32,7 @@ const messageListRef = ref<InstanceType<typeof MessageList> | null>(null)
|
||||
|
||||
// 本地查询条件(可编辑的副本)
|
||||
const localQuery = ref<ChatRecordQuery>({})
|
||||
const effectiveSessionId = computed(() => resolveChatRecordSessionId(localQuery.value, currentSessionId.value))
|
||||
|
||||
// 消息数量
|
||||
const messageCount = ref(0)
|
||||
@@ -49,12 +51,12 @@ const matchedSessionIds = ref<Set<number> | undefined>(undefined)
|
||||
|
||||
// 应用筛选
|
||||
function handleApplyFilter(query: ChatRecordQuery) {
|
||||
localQuery.value = query
|
||||
localQuery.value = preserveChatRecordSessionId(query, localQuery.value)
|
||||
}
|
||||
|
||||
// 重置筛选
|
||||
function handleResetFilter() {
|
||||
localQuery.value = {}
|
||||
localQuery.value = preserveChatRecordSessionId({}, localQuery.value)
|
||||
matchedSessionIds.value = undefined
|
||||
}
|
||||
|
||||
@@ -130,17 +132,16 @@ function handleSessionSelect(_sessionId: number, firstMessageId: number) {
|
||||
// 处理跳转到消息(查看上下文)
|
||||
function handleJumpToMessage(messageId: number) {
|
||||
// 清空筛选条件,只保留 scrollToMessageId
|
||||
localQuery.value = {
|
||||
scrollToMessageId: messageId,
|
||||
}
|
||||
localQuery.value = preserveChatRecordSessionId({ scrollToMessageId: messageId }, localQuery.value)
|
||||
}
|
||||
|
||||
// 加载会话列表缓存
|
||||
async function loadSessionsCache() {
|
||||
if (!currentSessionId.value) return
|
||||
const sessionId = effectiveSessionId.value
|
||||
if (!sessionId) return
|
||||
|
||||
try {
|
||||
const sessions = await useSessionIndexService().getSessions(currentSessionId.value)
|
||||
const sessions = await useSessionIndexService().getSessions(sessionId)
|
||||
sessionsCache.value = sessions.map((s) => ({
|
||||
id: s.id,
|
||||
startTs: s.startTs,
|
||||
@@ -206,9 +207,9 @@ watch(
|
||||
<div class="flex min-h-0 flex-1">
|
||||
<!-- 会话时间线 -->
|
||||
<SessionTimeline
|
||||
v-if="currentSessionId"
|
||||
v-if="effectiveSessionId"
|
||||
v-model:collapsed="timelineCollapsed"
|
||||
:session-id="currentSessionId"
|
||||
:session-id="effectiveSessionId"
|
||||
:active-session-id="activeSessionId"
|
||||
:filter-start-ts="localQuery.startTs"
|
||||
:filter-end-ts="localQuery.endTs"
|
||||
|
||||
@@ -11,6 +11,7 @@ import MessageItem from './MessageItem.vue'
|
||||
import type { ChatRecordMessage, ChatRecordQuery } from './types'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { useMessageService } from '@/services'
|
||||
import { resolveChatRecordSessionId } from './query-session'
|
||||
|
||||
// 时间分隔阈值(秒):消息间隔超过此值则显示时间分隔线
|
||||
const TIME_SEPARATOR_THRESHOLD = 5 * 60 // 5 分钟
|
||||
@@ -51,6 +52,7 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const sessionStore = useSessionStore()
|
||||
const effectiveSessionId = computed(() => resolveChatRecordSessionId(props.query, sessionStore.currentSessionId))
|
||||
|
||||
// 判断是否使用外部传入的消息
|
||||
const isExternalMode = computed(() => !!props.externalMessages?.length)
|
||||
@@ -150,7 +152,7 @@ async function loadInitialMessages() {
|
||||
return
|
||||
}
|
||||
|
||||
const sessionId = sessionStore.currentSessionId
|
||||
const sessionId = effectiveSessionId.value
|
||||
if (!sessionId) {
|
||||
messages.value = []
|
||||
emit('count-change', 0)
|
||||
@@ -266,7 +268,7 @@ function scrollToBottom() {
|
||||
async function loadMoreBefore() {
|
||||
if (isLoadingMore.value || !hasMoreBefore.value || messages.value.length === 0) return
|
||||
|
||||
const sessionId = sessionStore.currentSessionId
|
||||
const sessionId = effectiveSessionId.value
|
||||
if (!sessionId) return
|
||||
|
||||
const firstMessage = messages.value[0]
|
||||
@@ -320,7 +322,7 @@ async function loadMoreBefore() {
|
||||
async function loadMoreAfter() {
|
||||
if (isLoadingMore.value || !hasMoreAfter.value || messages.value.length === 0) return
|
||||
|
||||
const sessionId = sessionStore.currentSessionId
|
||||
const sessionId = effectiveSessionId.value
|
||||
if (!sessionId) return
|
||||
|
||||
isLoadingMore.value = true
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Run: pnpm test -- src/components/common/ChatRecord/query-session.test.ts
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { preserveChatRecordSessionId, resolveChatRecordSessionId } from './query-session'
|
||||
|
||||
test('uses the chat record query session id before the current route session id', () => {
|
||||
assert.equal(resolveChatRecordSessionId({ sessionId: 'source-session' }, 'current-session'), 'source-session')
|
||||
})
|
||||
|
||||
test('falls back to the current route session id when query has no session id', () => {
|
||||
assert.equal(resolveChatRecordSessionId({}, 'current-session'), 'current-session')
|
||||
})
|
||||
|
||||
test('returns null when neither query nor route has a session id', () => {
|
||||
assert.equal(resolveChatRecordSessionId({}, null), null)
|
||||
})
|
||||
|
||||
test('preserves the current chat record session id when applying drawer filters', () => {
|
||||
assert.deepEqual(
|
||||
preserveChatRecordSessionId({ keywords: ['refund'] }, { sessionId: 'source-session', memberName: 'Alice' }),
|
||||
{ keywords: ['refund'], sessionId: 'source-session' }
|
||||
)
|
||||
})
|
||||
|
||||
test('does not add an empty session id when the current query is not session scoped', () => {
|
||||
assert.deepEqual(preserveChatRecordSessionId({ keywords: ['refund'] }, {}), { keywords: ['refund'] })
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { ChatRecordQuery } from './types'
|
||||
|
||||
export function resolveChatRecordSessionId(query: ChatRecordQuery, fallbackSessionId?: string | null): string | null {
|
||||
return query.sessionId?.trim() || fallbackSessionId || null
|
||||
}
|
||||
|
||||
export function preserveChatRecordSessionId(
|
||||
nextQuery: ChatRecordQuery,
|
||||
currentQuery: ChatRecordQuery
|
||||
): ChatRecordQuery {
|
||||
const sessionId = currentQuery.sessionId?.trim()
|
||||
return sessionId ? { ...nextQuery, sessionId } : nextQuery
|
||||
}
|
||||
@@ -47,6 +47,8 @@
|
||||
"unmarkFriend": "Unmark friend",
|
||||
"clearSearch": "Clear search",
|
||||
"clearSelection": "Clear selection",
|
||||
"openSourceSession": "Go to chat",
|
||||
"viewSourceRecords": "View chat records",
|
||||
"viewDetail": "View details for {name}"
|
||||
},
|
||||
"detail": {
|
||||
|
||||
@@ -47,6 +47,8 @@
|
||||
"unmarkFriend": "マークを解除",
|
||||
"clearSearch": "検索をクリア",
|
||||
"clearSelection": "選択を解除",
|
||||
"openSourceSession": "会話へ移動",
|
||||
"viewSourceRecords": "チャット履歴を見る",
|
||||
"viewDetail": "{name} の詳細を表示"
|
||||
},
|
||||
"detail": {
|
||||
|
||||
@@ -47,6 +47,8 @@
|
||||
"unmarkFriend": "取消标记",
|
||||
"clearSearch": "清空搜索",
|
||||
"clearSelection": "取消选中",
|
||||
"openSourceSession": "前往对话",
|
||||
"viewSourceRecords": "查看聊天记录",
|
||||
"viewDetail": "查看 {name} 的详情"
|
||||
},
|
||||
"detail": {
|
||||
|
||||
@@ -47,6 +47,8 @@
|
||||
"unmarkFriend": "取消標記",
|
||||
"clearSearch": "清空搜尋",
|
||||
"clearSelection": "取消選中",
|
||||
"openSourceSession": "前往對話",
|
||||
"viewSourceRecords": "查看聊天記錄",
|
||||
"viewDetail": "查看 {name} 的詳情"
|
||||
},
|
||||
"detail": {
|
||||
|
||||
@@ -13,6 +13,7 @@ const props = defineProps<{
|
||||
const emit = defineEmits<{
|
||||
clear: []
|
||||
openSource: [source: ContactItem['sourceSessions'][number]]
|
||||
viewSourceRecords: [source: ContactItem['sourceSessions'][number]]
|
||||
markFriend: []
|
||||
unmarkFriend: []
|
||||
}>()
|
||||
@@ -130,12 +131,10 @@ function avatarText(contact: ContactItem): string {
|
||||
{{ t('contacts.detail.sources') }}
|
||||
</h3>
|
||||
<div class="space-y-2.5">
|
||||
<button
|
||||
<div
|
||||
v-for="source in contact.sourceSessions"
|
||||
:key="source.id"
|
||||
type="button"
|
||||
class="group/item w-full rounded-2xl border border-gray-100 bg-white/40 px-3.5 py-3 text-left transition duration-300 hover:-translate-y-0.5 hover:border-gray-200 hover:shadow-sm dark:border-white/5 dark:bg-gray-900/10 dark:hover:border-white/10"
|
||||
@click="emit('openSource', source)"
|
||||
class="w-full rounded-2xl border border-gray-100 bg-white/40 px-3.5 py-3 text-left transition duration-300 hover:border-gray-200 hover:shadow-sm dark:border-white/5 dark:bg-gray-900/10 dark:hover:border-white/10"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="truncate text-xs font-semibold text-gray-700 dark:text-gray-300">
|
||||
@@ -149,10 +148,6 @@ function avatarText(contact: ContactItem): string {
|
||||
? t('contacts.detail.sourceType.private')
|
||||
: t('contacts.detail.sourceType.group')
|
||||
}}
|
||||
<UIcon
|
||||
name="i-lucide-arrow-up-right"
|
||||
class="h-3 w-3 transition-transform group-hover/item:translate-x-0.5 group-hover/item:-translate-y-0.5"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-1.5 truncate text-[11px] font-medium text-gray-400 dark:text-gray-500">
|
||||
@@ -162,7 +157,27 @@ function avatarText(contact: ContactItem): string {
|
||||
: t('contacts.metrics.groupSignals', { count: source.coOccurrenceCount ?? 0 })
|
||||
}}
|
||||
</div>
|
||||
</button>
|
||||
<div class="mt-2 flex flex-wrap gap-1.5">
|
||||
<UButton
|
||||
icon="i-lucide-arrow-up-right"
|
||||
color="primary"
|
||||
variant="soft"
|
||||
size="xs"
|
||||
@click="emit('openSource', source)"
|
||||
>
|
||||
{{ t('contacts.actions.openSourceSession') }}
|
||||
</UButton>
|
||||
<UButton
|
||||
icon="i-lucide-messages-square"
|
||||
color="primary"
|
||||
variant="soft"
|
||||
size="xs"
|
||||
@click="emit('viewSourceRecords', source)"
|
||||
>
|
||||
{{ t('contacts.actions.viewSourceRecords') }}
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
} from '@openchatlab/shared-types'
|
||||
import { useDataService } from '@/services'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useLayoutStore } from '@/stores/layout'
|
||||
import PageHeader from '@/components/layout/PageHeader.vue'
|
||||
import { LoadingState, SubTabs } from '@/components/UI'
|
||||
import ContactDetailPanel from './components/ContactDetailPanel.vue'
|
||||
@@ -47,6 +48,7 @@ const CONTACTS_LOAD_MORE_REMAINING = 20
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
const dataService = useDataService()
|
||||
const layoutStore = useLayoutStore()
|
||||
const router = useRouter()
|
||||
|
||||
const isRecomputing = ref(false)
|
||||
@@ -732,6 +734,10 @@ function openSourceSession(source: ContactItem['sourceSessions'][number]) {
|
||||
})
|
||||
}
|
||||
|
||||
function viewSourceSessionRecords(source: ContactItem['sourceSessions'][number]) {
|
||||
layoutStore.openChatRecordDrawer({ sessionId: source.id })
|
||||
}
|
||||
|
||||
function handleListScroll(event: Event) {
|
||||
const target = event.target as HTMLElement
|
||||
activeState.value.scrollOffset = target.scrollTop
|
||||
@@ -1128,6 +1134,7 @@ function getGroupSectionScrollTop(): number | null {
|
||||
:is-friend-action-loading="isFriendActionSaving"
|
||||
@clear="clearSelectedContact"
|
||||
@open-source="openSourceSession"
|
||||
@view-source-records="viewSourceSessionRecords"
|
||||
@mark-friend="markSelectedContactAsFriend"
|
||||
@unmark-friend="unmarkSelectedContactAsFriend"
|
||||
/>
|
||||
|
||||
@@ -150,6 +150,9 @@ export interface MergeResult {
|
||||
* 支持组合查询:多个条件可同时生效
|
||||
*/
|
||||
export interface ChatRecordQuery {
|
||||
/** 指定要查看的会话;未提供时使用当前页面会话 */
|
||||
sessionId?: string
|
||||
|
||||
/** 定位到指定消息(初始加载时以此消息为中心) */
|
||||
scrollToMessageId?: number
|
||||
|
||||
|
||||
Reference in new issue
Block a user