mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-26 02:04:37 +08:00
feat(records): filter chat records by member
This commit is contained in:
1 parent
3a4386b5f5
commit
733cd0db95
18 files changed
+448
-22
No files matched your search
@@ -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
|
||||
/** 关键词(逗号分隔) */
|
||||
|
||||
@@ -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()]
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
},
|
||||
"filter": {
|
||||
"messageId": "メッセージ ID",
|
||||
"memberNotSupported": "メンバー(未対応)",
|
||||
"memberPlaceholder": "メンバー",
|
||||
"keywordsPlaceholder": "キーワード(複数はカンマ区切り、Enter で検索)",
|
||||
"startDate": "開始日",
|
||||
"endDate": "終了日",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
},
|
||||
"filter": {
|
||||
"messageId": "消息 ID",
|
||||
"memberNotSupported": "成员(暂不支持)",
|
||||
"memberPlaceholder": "成员",
|
||||
"keywordsPlaceholder": "关键词,多个用逗号分隔,回车搜索",
|
||||
"startDate": "起始日期",
|
||||
"endDate": "结束日期",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in new issue
Block a user