feat(contacts): add source chat record actions

This commit is contained in:
digua committed 2026-06-27 22:13:25 +08:00
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
}
+2
View File
@@ -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": {
+2
View File
@@ -47,6 +47,8 @@
"unmarkFriend": "マークを解除",
"clearSearch": "検索をクリア",
"clearSelection": "選択を解除",
"openSourceSession": "会話へ移動",
"viewSourceRecords": "チャット履歴を見る",
"viewDetail": "{name} の詳細を表示"
},
"detail": {
+2
View File
@@ -47,6 +47,8 @@
"unmarkFriend": "取消标记",
"clearSearch": "清空搜索",
"clearSelection": "取消选中",
"openSourceSession": "前往对话",
"viewSourceRecords": "查看聊天记录",
"viewDetail": "查看 {name} 的详情"
},
"detail": {
+2
View File
@@ -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>
+7
View File
@@ -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"
/>
+3
View File
@@ -150,6 +150,9 @@ export interface MergeResult {
* 支持组合查询:多个条件可同时生效
*/
export interface ChatRecordQuery {
/** 指定要查看的会话;未提供时使用当前页面会话 */
sessionId?: string
/** 定位到指定消息(初始加载时以此消息为中心) */
scrollToMessageId?: number