feat(records): filter chat records by member

This commit is contained in:
digua committed 2026-07-02 10:35:02 +08:00
1 parent 3a4386b5f5
commit 733cd0db95
18 files changed
+448 -22

No files matched your search

+1
View File
@@ -49,6 +49,7 @@
## 代码规范
- 多语言:代码中的日志、注释、AI 工具描述、错误消息等非 UI 文本默认使用英文。当有运行时 locale 可用时(如工具返回结果、AI 看到的文本),应通过 `isChineseLocale(locale)` 等机制支持中英双语。数据清洗中与聊天平台格式匹配的标签(如 `[分享]`、`[图片]`)保持原始语言不变。UI 文案的国际化遵循 `.docs/rules/i18n.md`
- i18n 复用性:新增 UI 文案 key 前,先判断是否是通用动作、状态、提示或组件文案;能复用的优先放在 `common.*` 等共享命名空间,避免在具体业务模块(如 `members.*`、`records.*`)重复定义同义 key。只有明确绑定业务语境、无法自然复用的文案才放到模块命名空间。
## 日志
@@ -211,7 +211,12 @@ watch(
</div>
<!-- 筛选面板 -->
<FilterPanel :query="localQuery" @apply="handleApplyFilter" @reset="handleResetFilter" />
<FilterPanel
:query="localQuery"
:session-id="effectiveSessionId || undefined"
@apply="handleApplyFilter"
@reset="handleResetFilter"
/>
<!-- 主内容区:时间线 + 消息列表 -->
<div class="flex min-h-0 flex-1">
@@ -7,6 +7,7 @@ import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import dayjs from 'dayjs'
import { DatePicker } from '@/components/UI'
import MemberSearchSelect from '@/components/common/member/MemberSearchSelect.vue'
import type { ChatRecordQuery, FilterFormData } from './types'
const { t } = useI18n()
@@ -14,6 +15,8 @@ const { t } = useI18n()
const props = defineProps<{
/** 当前查询条件 */
query: ChatRecordQuery
/** 当前会话 ID,用于分页搜索成员 */
sessionId?: string
}>()
const emit = defineEmits<{
@@ -26,6 +29,7 @@ const emit = defineEmits<{
// 本地表单数据
const formData = ref<FilterFormData>({
messageId: '',
memberId: null,
memberName: '',
keywords: '',
startDate: '',
@@ -39,6 +43,7 @@ watch(
if (query) {
formData.value = {
messageId: query.scrollToMessageId?.toString() || '',
memberId: query.memberId ?? null,
memberName: query.memberName || '',
keywords: query.keywords?.join(', ') || '',
startDate: query.startTs ? dayjs.unix(query.startTs).format('YYYY-MM-DD') : '',
@@ -65,8 +70,9 @@ function applyFilter() {
}
}
// 成员名称(需要后续通过 API 获取成员 ID)
if (f.memberName) {
// 成员筛选可与关键词、时间范围组合使用。
if (f.memberId !== null) {
query.memberId = f.memberId
query.memberName = f.memberName
}
@@ -107,6 +113,7 @@ function handleKeywordsKeydown(event: KeyboardEvent) {
function resetFilter() {
formData.value = {
messageId: '',
memberId: null,
memberName: '',
keywords: '',
startDate: '',
@@ -127,12 +134,14 @@ function resetFilter() {
size="sm"
class="w-24"
/>
<UInput
v-model="formData.memberName"
:placeholder="t('records.filter.memberNotSupported')"
size="sm"
class="w-28"
disabled
<MemberSearchSelect
v-model="formData.memberId"
v-model:member-name="formData.memberName"
:session-id="sessionId"
:placeholder="t('records.filter.memberPlaceholder')"
width-class="w-36"
@select="applyFilter"
@clear="applyFilter"
/>
<div class="flex items-center gap-2">
<DatePicker v-model="formData.startDate" :placeholder="t('records.filter.startDate')" />
@@ -187,15 +187,15 @@ async function loadInitialMessages() {
// 设置待滚动的目标
pendingScrollToId.value = targetId
} else if (keywords && keywords.length > 0) {
// 有关键词,使用搜索功能
} else if ((keywords && keywords.length > 0) || senderId != null) {
// 有关键词或成员筛选时,统一走搜索分页;底层支持空关键词 + senderId。
isSearchMode.value = true
searchOffset.value = 0
const result = await useMessageService().searchMessages(sessionId, keywords, filter, 100, 0, senderId)
const result = await useMessageService().searchMessages(sessionId, keywords ?? [], filter, 100, 0, senderId)
messages.value = mapMessages(result.messages)
hasMoreBefore.value = false // 搜索结果从最新开始,没有更早的
hasMoreAfter.value = result.messages.length >= 100
searchOffset.value = result.messages.length
hasMoreAfter.value = searchOffset.value < result.total
// 滚动到顶部
await nextTick()
@@ -331,11 +331,11 @@ async function loadMoreAfter() {
const query = toRaw(props.query)
const { filter, senderId, keywords } = buildFilterParams(query)
if (isSearchMode.value && keywords && keywords.length > 0) {
if (isSearchMode.value) {
// 搜索模式:使用分页加载
const result = await useMessageService().searchMessages(
sessionId,
keywords,
keywords ?? [],
filter,
50,
searchOffset.value,
@@ -352,7 +352,7 @@ async function loadMoreAfter() {
)
}
hasMoreAfter.value = result.messages.length >= 50
hasMoreAfter.value = searchOffset.value < result.total
} else {
// 普通模式:使用消息 ID 加载
const lastMessage = messages.value[messages.value.length - 1]
@@ -13,6 +13,8 @@ export type { ChatRecordQuery, ChatRecordMessage }
export interface FilterFormData {
/** 消息 ID */
messageId: string
/** 成员 ID */
memberId: number | null
/** 成员名称 */
memberName: string
/** 关键词(逗号分隔) */
+21 -1
View File
@@ -4,8 +4,10 @@ import { useI18n } from 'vue-i18n'
import type { MemberWithStats } from '@/types/analysis'
import OwnerEntryCard from '@/components/analysis/member/OwnerEntryCard.vue'
import { useDataService } from '@/services'
import { useLayoutStore } from '@/stores/layout'
const { t } = useI18n()
const layoutStore = useLayoutStore()
// Props
const props = withDefaults(
@@ -64,6 +66,14 @@ function getFirstChar(member: MemberWithStats): string {
return name.slice(0, 1)
}
function viewMemberChatRecords(member: MemberWithStats) {
layoutStore.openChatRecordDrawer({
sessionId: props.sessionId,
memberId: member.id,
memberName: getDisplayName(member),
})
}
const selectedMergeMembers = computed(() => allMembers.value.filter((member) => selectedMergeIds.value.has(member.id)))
const canMergeSelected = computed(() => selectedMergeMembers.value.length === 2)
@@ -423,7 +433,17 @@ onMounted(() => {
<!-- 操作 -->
<td class="px-4 py-4 text-right">
<UButton :label="t('members.list.delete')" size="xs" @click="showDeleteConfirm(member)" />
<div class="flex justify-end gap-2">
<UButton
icon="i-heroicons-chat-bubble-left-ellipsis"
size="xs"
color="neutral"
variant="ghost"
:title="t('common.viewChatRecords')"
@click="viewMemberChatRecords(member)"
/>
<UButton :label="t('members.list.delete')" size="xs" @click="showDeleteConfirm(member)" />
</div>
</td>
</tr>
</tbody>
@@ -0,0 +1,257 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { onClickOutside, useDebounceFn } from '@vueuse/core'
import { useI18n } from 'vue-i18n'
import { useDataService } from '@/services'
import type { MemberWithStats } from '@/types/analysis'
import { formatMemberOption, getMemberDisplayName, mergeMemberPages } from './member-select-utils'
const { t } = useI18n()
const props = withDefaults(
defineProps<{
sessionId?: string
modelValue: number | null
memberName?: string
placeholder?: string
widthClass?: string
}>(),
{
sessionId: '',
memberName: '',
placeholder: '',
widthClass: 'w-40',
}
)
const emit = defineEmits<{
(e: 'update:modelValue', value: number | null): void
(e: 'update:memberName', value: string): void
(e: 'select', member: MemberWithStats): void
(e: 'clear'): void
}>()
const PAGE_SIZE = 30
const LOAD_MORE_THRESHOLD_PX = 32
const open = ref(false)
const rootRef = ref<HTMLElement | null>(null)
const listRef = ref<HTMLElement | null>(null)
const searchText = ref(props.memberName)
const members = ref<MemberWithStats[]>([])
const isLoading = ref(false)
const currentPage = ref(0)
const totalPages = ref(0)
const total = ref(0)
let requestSeq = 0
const options = computed(() => members.value.map(formatMemberOption))
const hasMore = computed(() => currentPage.value > 0 && currentPage.value < totalPages.value)
const displayPlaceholder = computed(() => props.placeholder || t('common.memberSearchSelect.placeholder'))
function clearSelection() {
emit('update:modelValue', null)
emit('update:memberName', '')
}
// 分页搜索请求可能被快速输入打乱顺序;用 requestSeq 只接收最后一次返回,避免旧结果覆盖新搜索。
async function loadMembers(reset: boolean) {
if (!reset && (isLoading.value || !hasMore.value)) return
if (!props.sessionId) {
members.value = []
currentPage.value = 0
totalPages.value = 0
total.value = 0
return
}
const nextPage = reset ? 1 : currentPage.value + 1
const seq = ++requestSeq
isLoading.value = true
try {
const result = await useDataService().getMembersPaginated(props.sessionId, {
page: nextPage,
pageSize: PAGE_SIZE,
search: searchText.value.trim(),
sortOrder: 'desc',
})
if (seq !== requestSeq) return
members.value = reset ? result.items : mergeMemberPages(members.value, result.items)
currentPage.value = result.page
totalPages.value = result.totalPages
total.value = result.total
} catch (error) {
console.error('Failed to load members for search select:', error)
if (seq === requestSeq && reset) {
members.value = []
currentPage.value = 0
totalPages.value = 0
total.value = 0
}
} finally {
if (seq === requestSeq) {
isLoading.value = false
}
}
}
function handleListScroll() {
const list = listRef.value
if (!list || !hasMore.value || isLoading.value) return
const distanceToBottom = list.scrollHeight - list.scrollTop - list.clientHeight
if (distanceToBottom <= LOAD_MORE_THRESHOLD_PX) {
loadMembers(false)
}
}
const debouncedReload = useDebounceFn(() => {
if (open.value) {
loadMembers(true)
}
}, 250)
function handleInput() {
if (props.modelValue !== null && searchText.value !== props.memberName) {
clearSelection()
}
if (searchText.value.trim() === '') {
clearSelection()
}
debouncedReload()
}
function handleOpen() {
open.value = true
}
onClickOutside(rootRef, () => {
open.value = false
})
function selectMember(member: MemberWithStats) {
const name = getMemberDisplayName(member)
searchText.value = name
emit('update:modelValue', member.id)
emit('update:memberName', name)
emit('select', member)
open.value = false
}
function handleClear() {
searchText.value = ''
members.value = []
clearSelection()
emit('clear')
if (open.value) {
loadMembers(true)
}
}
watch(
() => open.value,
(isOpen) => {
if (isOpen) {
loadMembers(true)
}
}
)
watch(
() => props.memberName,
(name) => {
if (props.modelValue !== null && name !== searchText.value) {
searchText.value = name || ''
}
}
)
watch(
() => props.sessionId,
() => {
searchText.value = ''
members.value = []
currentPage.value = 0
totalPages.value = 0
total.value = 0
clearSelection()
}
)
</script>
<template>
<div ref="rootRef" class="relative" :class="widthClass">
<div class="relative">
<UInput
v-model="searchText"
icon="i-heroicons-user"
:placeholder="displayPlaceholder"
size="sm"
class="w-full"
:disabled="!sessionId"
@focus="handleOpen"
@click="handleOpen"
@update:model-value="handleInput"
@keydown.escape="open = false"
/>
<button
v-if="modelValue !== null || searchText"
type="button"
class="absolute top-1/2 right-2 flex h-5 w-5 -translate-y-1/2 items-center justify-center rounded text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-800 dark:hover:text-gray-200"
@click.stop="handleClear"
>
<UIcon name="i-heroicons-x-mark" class="h-3.5 w-3.5" />
</button>
</div>
<div
v-if="open"
class="absolute top-full left-0 z-[100] mt-1 w-80 rounded-md border border-gray-200 bg-white shadow-lg dark:border-gray-800 dark:bg-gray-950"
>
<div ref="listRef" class="max-h-80 overflow-y-auto py-1" @scroll="handleListScroll">
<div v-if="isLoading && options.length === 0" class="px-3 py-3 text-sm text-gray-500">
{{ t('common.loading') }}
</div>
<div v-else-if="options.length === 0" class="px-3 py-3 text-sm text-gray-500">
{{ t('common.noResults') }}
</div>
<button
v-for="option in options"
:key="option.id"
type="button"
class="flex w-full min-w-0 items-center gap-2 px-3 py-2 text-left hover:bg-gray-50 dark:hover:bg-gray-800"
@click="selectMember(option.member)"
>
<img v-if="option.avatar" :src="option.avatar" :alt="option.label" class="h-7 w-7 rounded object-cover" />
<div
v-else
class="flex h-7 w-7 shrink-0 items-center justify-center rounded bg-gray-200 text-xs font-medium text-gray-600 dark:bg-gray-700 dark:text-gray-200"
>
{{ option.label.slice(0, 1) }}
</div>
<div class="min-w-0 flex-1">
<div class="truncate text-sm text-gray-800 dark:text-gray-100">{{ option.label }}</div>
<div class="truncate text-xs text-gray-500 dark:text-gray-400">
{{ option.secondary || t('common.memberSearchSelect.noSecondary') }}
</div>
</div>
<span class="shrink-0 text-xs text-gray-400">{{ option.messageCount }}</span>
</button>
<div
v-if="isLoading && options.length > 0"
class="flex items-center justify-center gap-1 px-3 py-2 text-xs text-gray-400"
>
<UIcon name="i-heroicons-arrow-path" class="h-3.5 w-3.5 animate-spin" />
<span>{{ t('common.loading') }}</span>
</div>
</div>
</div>
</div>
</template>
@@ -0,0 +1,52 @@
import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import { formatMemberOption, mergeMemberPages } from './member-select-utils'
import type { MemberWithStats } from '@/types/analysis'
function member(overrides: Partial<MemberWithStats>): MemberWithStats {
return {
id: 1,
platformId: 'member_1',
accountName: null,
groupNickname: null,
aliases: [],
messageCount: 0,
avatar: null,
...overrides,
}
}
describe('member select utils', () => {
it('formats the best display name and searchable secondary text', () => {
const option = formatMemberOption(
member({
groupNickname: '群昵称',
accountName: '账号名',
platformId: 'platform_1',
aliases: ['别名A', '别名B'],
messageCount: 42,
})
)
assert.equal(option.label, '群昵称')
assert.equal(option.secondary, '别名A、别名B · 账号名 · platform_1')
assert.equal(option.messageCount, 42)
})
it('deduplicates members when appending paginated results', () => {
const firstPage = [member({ id: 1, groupNickname: 'A' }), member({ id: 2, groupNickname: 'B' })]
const nextPage = [member({ id: 2, groupNickname: 'B updated' }), member({ id: 3, groupNickname: 'C' })]
const merged = mergeMemberPages(firstPage, nextPage)
assert.deepEqual(
merged.map((item) => [item.id, item.groupNickname]),
[
[1, 'A'],
[2, 'B updated'],
[3, 'C'],
]
)
})
})
@@ -0,0 +1,42 @@
import type { MemberWithStats } from '@/types/analysis'
export interface MemberSelectOption {
id: number
label: string
secondary: string
messageCount: number
avatar: string | null
member: MemberWithStats
}
export function getMemberDisplayName(member: MemberWithStats): string {
return member.groupNickname || member.accountName || member.platformId
}
export function formatMemberOption(member: MemberWithStats): MemberSelectOption {
const label = getMemberDisplayName(member)
const aliasText = member.aliases.filter((alias) => alias && alias !== label).join('、')
const secondaryParts = [aliasText, member.accountName, member.platformId].filter((part): part is string =>
Boolean(part && part !== label)
)
return {
id: member.id,
label,
secondary: secondaryParts.join(' · '),
messageCount: member.messageCount,
avatar: member.avatar,
member,
}
}
export function mergeMemberPages(current: MemberWithStats[], incoming: MemberWithStats[]): MemberWithStats[] {
const merged = new Map<number, MemberWithStats>()
for (const member of current) {
merged.set(member.id, member)
}
for (const member of incoming) {
merged.set(member.id, member)
}
return [...merged.values()]
}
+5
View File
@@ -20,6 +20,7 @@
"generating": "Generating...",
"regenerate": "Regenerate",
"openFolder": "Open Folder",
"viewChatRecords": "View chat records",
"errorNoAIConfig": "Please configure AI service in Settings first",
"error": {
"unknown": "Unknown error"
@@ -113,6 +114,10 @@
"userSelect": {
"allMembers": "All Members"
},
"memberSearchSelect": {
"placeholder": "Search members",
"noSecondary": "No other names"
},
"login": {
"description": "This service requires authentication. Enter your API Token to continue.",
"tokenLabel": "API Token",
+1 -1
View File
@@ -13,7 +13,7 @@
},
"filter": {
"messageId": "Message ID",
"memberNotSupported": "Member (not supported)",
"memberPlaceholder": "Member",
"keywordsPlaceholder": "Keywords, comma separated, Enter to search",
"startDate": "Start Date",
"endDate": "End Date",
+5
View File
@@ -20,6 +20,7 @@
"generating": "生成中...",
"regenerate": "再生成",
"openFolder": "フォルダーを開く",
"viewChatRecords": "チャット履歴を表示",
"errorNoAIConfig": "先に設定でAIサービスを構成してください",
"error": {
"unknown": "不明なエラー"
@@ -113,6 +114,10 @@
"userSelect": {
"allMembers": "すべてのメンバー"
},
"memberSearchSelect": {
"placeholder": "メンバーを検索",
"noSecondary": "他の名前はありません"
},
"login": {
"description": "このサービスは認証が必要です。API Tokenを入力してください。",
"tokenLabel": "API Token",
+1 -1
View File
@@ -13,7 +13,7 @@
},
"filter": {
"messageId": "メッセージ ID",
"memberNotSupported": "メンバー(未対応)",
"memberPlaceholder": "メンバー",
"keywordsPlaceholder": "キーワード(複数はカンマ区切り、Enter で検索)",
"startDate": "開始日",
"endDate": "終了日",
+5
View File
@@ -20,6 +20,7 @@
"generating": "生成中...",
"regenerate": "重新生成",
"openFolder": "打开目录",
"viewChatRecords": "查看聊天记录",
"errorNoAIConfig": "请先在设置中配置 AI 服务",
"error": {
"unknown": "未知错误"
@@ -113,6 +114,10 @@
"userSelect": {
"allMembers": "全部成员"
},
"memberSearchSelect": {
"placeholder": "搜索成员",
"noSecondary": "无其他名称"
},
"login": {
"description": "此服务已开启认证保护,请输入 API Token 登录",
"tokenLabel": "API Token",
+1 -1
View File
@@ -13,7 +13,7 @@
},
"filter": {
"messageId": "消息 ID",
"memberNotSupported": "成员(暂不支持)",
"memberPlaceholder": "成员",
"keywordsPlaceholder": "关键词,多个用逗号分隔,回车搜索",
"startDate": "起始日期",
"endDate": "结束日期",
+5
View File
@@ -20,6 +20,7 @@
"generating": "產生中...",
"regenerate": "重新產生",
"openFolder": "開啟資料夾",
"viewChatRecords": "檢視聊天紀錄",
"errorNoAIConfig": "請先在設定中設定 AI 服務",
"error": {
"unknown": "未知錯誤"
@@ -113,6 +114,10 @@
"userSelect": {
"allMembers": "全部成員"
},
"memberSearchSelect": {
"placeholder": "搜尋成員",
"noSecondary": "無其他名稱"
},
"login": {
"description": "此服務已開啟認證保護,請輸入 API Token 登入",
"tokenLabel": "API Token",
+1 -1
View File
@@ -13,7 +13,7 @@
},
"filter": {
"messageId": "訊息 ID",
"memberNotSupported": "成員(暫不支援)",
"memberPlaceholder": "成員",
"keywordsPlaceholder": "關鍵字(多個請用逗號分隔,按 Enter 搜尋)",
"startDate": "起始日期",
"endDate": "結束日期",
@@ -4,8 +4,10 @@ import { useI18n } from 'vue-i18n'
import type { MemberWithStats } from '@/types/analysis'
import OwnerEntryCard from '@/components/analysis/member/OwnerEntryCard.vue'
import { useDataService } from '@/services'
import { useLayoutStore } from '@/stores/layout'
const { t } = useI18n()
const layoutStore = useLayoutStore()
// Props
const props = withDefaults(
@@ -41,6 +43,14 @@ function getFirstChar(member: MemberWithStats): string {
return name.slice(0, 1)
}
function viewMemberChatRecords(member: MemberWithStats) {
layoutStore.openChatRecordDrawer({
sessionId: props.sessionId,
memberId: member.id,
memberName: getDisplayName(member),
})
}
const selectedMergeMembers = computed(() => members.value.filter((member) => selectedMergeIds.value.has(member.id)))
const canMergeSelected = computed(() => selectedMergeMembers.value.length === 2)
@@ -200,7 +210,15 @@ onMounted(() => {
:key="member.id"
class="relative rounded-xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-700 dark:bg-page-dark"
>
<div class="absolute right-4 top-4">
<div class="absolute right-4 top-4 flex items-center gap-2">
<UButton
icon="i-heroicons-chat-bubble-left-ellipsis"
size="xs"
color="neutral"
variant="ghost"
:title="t('common.viewChatRecords')"
@click="viewMemberChatRecords(member)"
/>
<UCheckbox :model-value="selectedMergeIds.has(member.id)" @click.stop="toggleMergeSelection(member.id)" />
</div>