mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-25 17:54:34 +08:00
feat(members): unify private member management
This commit is contained in:
1 parent
15d0158a40
commit
f5f7596822
10 files changed
+88
-391
No files matched your search
@@ -5,7 +5,7 @@ import { useI18n } from 'vue-i18n'
|
||||
import type { MemberWithStats } from '@/types/analysis'
|
||||
import OwnerEntryCard from '@/components/analysis/member/OwnerEntryCard.vue'
|
||||
import LazyAvatar from '@/components/common/avatar/LazyAvatar.vue'
|
||||
import { filterAndSortMembers, type MemberSortField } from './member-select-utils'
|
||||
import { filterAndSortMembers, shouldShowMemberMergeControls, type MemberSortField } from './member-select-utils'
|
||||
import { useDataService } from '@/services'
|
||||
import { useTableRowSelection, useTableSort, type TableSortDirection } from '@/composables/useTable'
|
||||
import { reportError } from '@/services/log-report'
|
||||
@@ -22,9 +22,11 @@ const props = withDefaults(
|
||||
defineProps<{
|
||||
sessionId: string
|
||||
showHeader?: boolean
|
||||
chatType?: 'group' | 'private'
|
||||
}>(),
|
||||
{
|
||||
showHeader: true,
|
||||
chatType: 'group',
|
||||
}
|
||||
)
|
||||
|
||||
@@ -38,6 +40,11 @@ const members = ref<MemberWithStats[]>([])
|
||||
const isLoading = ref(false)
|
||||
const loadFailed = ref(false)
|
||||
const searchQuery = ref('')
|
||||
const showMergeControls = computed(() => shouldShowMemberMergeControls(props.chatType, members.value.length))
|
||||
const memberTableGridClass = computed(() => {
|
||||
if (props.chatType === 'group') return ''
|
||||
return showMergeControls.value ? 'member-table-grid--private' : 'member-table-grid--private-without-selection'
|
||||
})
|
||||
|
||||
// 删除确认状态
|
||||
const deletingMember = ref<MemberWithStats | null>(null)
|
||||
@@ -87,8 +94,8 @@ const {
|
||||
selectedIds: selectedMergeIds,
|
||||
setSelection: setSelectedMergeIds,
|
||||
clearSelection: clearMergeSelection,
|
||||
handleRowClick: handleMemberRowClick,
|
||||
handleRowMouseDown: handleMemberRowMouseDown,
|
||||
handleRowClick: handleSelectionRowClick,
|
||||
handleRowMouseDown: handleSelectionRowMouseDown,
|
||||
} = useTableRowSelection({
|
||||
rows: filteredSortedMembers,
|
||||
getRowId: (member) => member.id,
|
||||
@@ -96,6 +103,16 @@ const {
|
||||
const selectedMergeMembers = computed(() => members.value.filter((member) => selectedMergeIds.value.has(member.id)))
|
||||
const canMergeSelected = computed(() => selectedMergeMembers.value.length === 2)
|
||||
|
||||
function handleMemberRowClick(index: number, memberId: number, event: MouseEvent) {
|
||||
if (!showMergeControls.value) return
|
||||
handleSelectionRowClick(index, memberId, event)
|
||||
}
|
||||
|
||||
function handleMemberRowMouseDown(event: MouseEvent) {
|
||||
if (!showMergeControls.value) return
|
||||
handleSelectionRowMouseDown(event)
|
||||
}
|
||||
|
||||
const mergePlan = computed(() => {
|
||||
if (selectedMergeMembers.value.length !== 2) return null
|
||||
const [memberA, memberB] = selectedMergeMembers.value
|
||||
@@ -268,6 +285,9 @@ async function confirmMerge() {
|
||||
}
|
||||
|
||||
watch([searchQuery, sortState], resetVirtualScroll)
|
||||
watch(showMergeControls, (show) => {
|
||||
if (!show) clearMergeSelection()
|
||||
})
|
||||
|
||||
// 监听 sessionId 变化
|
||||
watch(
|
||||
@@ -300,16 +320,22 @@ onUnmounted(() => {
|
||||
<div v-if="props.showHeader" class="mb-6">
|
||||
<div class="flex items-center gap-3">
|
||||
<div>
|
||||
<h2 class="text-xl font-bold text-gray-900 dark:text-white">{{ t('members.list.title') }}</h2>
|
||||
<h2 class="text-xl font-bold text-gray-900 dark:text-white">
|
||||
{{ t(props.chatType === 'private' ? 'members.private.title' : 'members.list.title') }}
|
||||
</h2>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('members.list.description', { count: members.length }) }}
|
||||
{{
|
||||
t(props.chatType === 'private' ? 'members.private.description' : 'members.list.description', {
|
||||
count: members.length,
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Owner配置 -->
|
||||
<OwnerEntryCard class="mb-6" :session-id="sessionId" :members="members" chat-type="group" />
|
||||
<OwnerEntryCard class="mb-6" :session-id="sessionId" :members="members" :chat-type="props.chatType" />
|
||||
|
||||
<!-- 搜索框 -->
|
||||
<div class="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
@@ -323,7 +349,7 @@ onUnmounted(() => {
|
||||
<UButton icon="i-heroicons-x-mark" variant="link" color="neutral" size="xs" @click="searchQuery = ''" />
|
||||
</template>
|
||||
</UInput>
|
||||
<div class="flex items-center gap-2">
|
||||
<div v-if="showMergeControls" class="flex items-center gap-2">
|
||||
<UIcon
|
||||
v-if="isLoading && members.length > 0"
|
||||
name="i-heroicons-arrow-path"
|
||||
@@ -376,14 +402,16 @@ onUnmounted(() => {
|
||||
|
||||
<!-- 成员表格 -->
|
||||
<div v-else class="min-h-0 flex-1 overflow-x-auto overflow-y-hidden">
|
||||
<div class="flex h-full min-w-[1120px] flex-col">
|
||||
<div class="flex h-full flex-col" :class="props.chatType === 'private' ? 'min-w-[920px]' : 'min-w-[1120px]'">
|
||||
<!-- 固定表头:只让数据区纵向滚动 -->
|
||||
<div
|
||||
class="member-table-grid shrink-0 border-b border-gray-200 bg-gray-50 px-3 py-2 text-xs font-medium text-gray-500 dark:border-white/5 dark:bg-page-dark/80 dark:text-gray-400"
|
||||
:class="memberTableGridClass"
|
||||
>
|
||||
<span aria-hidden="true" />
|
||||
<span v-if="showMergeControls" aria-hidden="true" />
|
||||
<span>{{ t('members.list.table.accountName') }}</span>
|
||||
<button
|
||||
v-if="props.chatType === 'group'"
|
||||
type="button"
|
||||
class="flex items-center justify-start gap-1.5 text-left transition-colors hover:text-gray-700 dark:hover:text-gray-200"
|
||||
@click="toggleSort('groupNickname')"
|
||||
@@ -457,13 +485,17 @@ onUnmounted(() => {
|
||||
:key="String(virtualItem.key)"
|
||||
:ref="measureVirtualRow"
|
||||
:data-index="virtualItem.index"
|
||||
class="member-table-grid absolute left-0 top-0 w-full min-h-16 cursor-pointer border-b border-gray-100/80 px-3 py-2 transition-colors hover:bg-gray-50 dark:border-white/5 dark:hover:bg-gray-800/30"
|
||||
:class="selectedMergeIds.has(member.id) ? 'bg-primary-50/70 dark:bg-primary-950/20' : ''"
|
||||
class="member-table-grid absolute left-0 top-0 w-full min-h-16 border-b border-gray-100/80 px-3 py-2 transition-colors hover:bg-gray-50 dark:border-white/5 dark:hover:bg-gray-800/30"
|
||||
:class="[
|
||||
memberTableGridClass,
|
||||
showMergeControls ? 'cursor-pointer' : '',
|
||||
showMergeControls && selectedMergeIds.has(member.id) ? 'bg-primary-50/70 dark:bg-primary-950/20' : '',
|
||||
]"
|
||||
:style="{ transform: `translateY(${virtualItem.start}px)` }"
|
||||
@mousedown="handleMemberRowMouseDown"
|
||||
@click="handleMemberRowClick(virtualItem.index, member.id, $event)"
|
||||
>
|
||||
<div class="flex justify-center">
|
||||
<div v-if="showMergeControls" class="flex justify-center">
|
||||
<UCheckbox
|
||||
:model-value="selectedMergeIds.has(member.id)"
|
||||
@click.stop="handleMemberRowClick(virtualItem.index, member.id, $event)"
|
||||
@@ -494,6 +526,7 @@ onUnmounted(() => {
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="props.chatType === 'group'"
|
||||
class="truncate text-sm text-gray-700 dark:text-gray-300"
|
||||
:class="member.groupNickname ? 'font-medium' : 'text-gray-400 dark:text-gray-500'"
|
||||
:title="member.groupNickname || '-'"
|
||||
@@ -621,4 +654,12 @@ onUnmounted(() => {
|
||||
column-gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.member-table-grid--private {
|
||||
grid-template-columns: 40px minmax(220px, 1.45fr) 96px 148px minmax(280px, 1.35fr) 80px;
|
||||
}
|
||||
|
||||
.member-table-grid--private-without-selection {
|
||||
grid-template-columns: minmax(220px, 1.45fr) 96px 148px minmax(280px, 1.35fr) 80px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,12 @@
|
||||
import { describe, it } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { filterAndSortMembers, formatMemberOption, mergeMemberPages } from './member-select-utils'
|
||||
import {
|
||||
filterAndSortMembers,
|
||||
formatMemberOption,
|
||||
mergeMemberPages,
|
||||
shouldShowMemberMergeControls,
|
||||
} from './member-select-utils'
|
||||
import type { MemberWithStats } from '@/types/analysis'
|
||||
|
||||
function member(overrides: Partial<MemberWithStats>): MemberWithStats {
|
||||
@@ -51,6 +56,13 @@ describe('member select utils', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('only shows private-chat merge controls when duplicate member identities may exist', () => {
|
||||
assert.equal(shouldShowMemberMergeControls('group', 2), true)
|
||||
assert.equal(shouldShowMemberMergeControls('private', 1), false)
|
||||
assert.equal(shouldShowMemberMergeControls('private', 2), false)
|
||||
assert.equal(shouldShowMemberMergeControls('private', 3), true)
|
||||
})
|
||||
|
||||
it('filters every searchable member field and keeps tied counts in a deterministic order', () => {
|
||||
const members = [
|
||||
member({
|
||||
|
||||
@@ -44,6 +44,10 @@ export function mergeMemberPages(current: MemberWithStats[], incoming: MemberWit
|
||||
return [...merged.values()]
|
||||
}
|
||||
|
||||
export function shouldShowMemberMergeControls(chatType: 'group' | 'private', memberCount: number): boolean {
|
||||
return chatType === 'group' || memberCount > 2
|
||||
}
|
||||
|
||||
function normalizeMemberSortValue(value: number | string | null): number | string | null {
|
||||
if (typeof value !== 'string') return value
|
||||
return value.trim() || null
|
||||
|
||||
@@ -95,13 +95,6 @@
|
||||
},
|
||||
"private": {
|
||||
"title": "Chat Members",
|
||||
"description": "{count} members. Add aliases for search and AI analysis",
|
||||
"messageCount": "Messages",
|
||||
"percentage": "{value}% of total",
|
||||
"customAlias": "Custom Aliases",
|
||||
"aliasPlaceholder": "Press Enter to add alias",
|
||||
"empty": "No member data available",
|
||||
"tipTitle": "Tip",
|
||||
"tipContent": "Adding aliases helps identify chat participants and improves search and AI analysis accuracy."
|
||||
"description": "{count} members. Add aliases for search and AI analysis"
|
||||
}
|
||||
}
|
||||
@@ -95,13 +95,6 @@
|
||||
},
|
||||
"private": {
|
||||
"title": "会話メンバー",
|
||||
"description": "全 {count} 名のメンバー。検索や AI 分析に使用する別名を追加できます",
|
||||
"messageCount": "メッセージ数",
|
||||
"percentage": "割合 {value}%",
|
||||
"customAlias": "カスタム別名",
|
||||
"aliasPlaceholder": "入力して Enter で別名を追加",
|
||||
"empty": "メンバーデータがありません",
|
||||
"tipTitle": "ヒント",
|
||||
"tipContent": "別名を追加すると、チャット履歴の相手をより正確に識別できます。別名は検索や AI 分析で使用されます。"
|
||||
"description": "全 {count} 名のメンバー。検索や AI 分析に使用する別名を追加できます"
|
||||
}
|
||||
}
|
||||
@@ -95,13 +95,6 @@
|
||||
},
|
||||
"private": {
|
||||
"title": "对话成员",
|
||||
"description": "共 {count} 位成员,可为成员添加别名备注用于搜索和 AI 分析",
|
||||
"messageCount": "消息数",
|
||||
"percentage": "占比 {value}%",
|
||||
"customAlias": "自定义别名",
|
||||
"aliasPlaceholder": "输入后回车添加别名",
|
||||
"empty": "暂无成员数据",
|
||||
"tipTitle": "提示",
|
||||
"tipContent": "添加别名可以更好地识别聊天记录中的对话对象,别名将用于搜索和 AI 分析中。"
|
||||
"description": "共 {count} 位成员,可为成员添加别名备注用于搜索和 AI 分析"
|
||||
}
|
||||
}
|
||||
@@ -95,13 +95,6 @@
|
||||
},
|
||||
"private": {
|
||||
"title": "對話成員",
|
||||
"description": "共 {count} 位成員,可為成員新增別名備註用於搜尋和 AI 分析",
|
||||
"messageCount": "訊息數",
|
||||
"percentage": "佔比 {value}%",
|
||||
"customAlias": "自訂別名",
|
||||
"aliasPlaceholder": "輸入後按 Enter 新增別名",
|
||||
"empty": "暫無成員資料",
|
||||
"tipTitle": "提示",
|
||||
"tipContent": "新增別名可以更好地識別聊天紀錄中的對話對象,別名將用於搜尋和 AI 分析中。"
|
||||
"description": "共 {count} 位成員,可為成員新增別名備註用於搜尋和 AI 分析"
|
||||
}
|
||||
}
|
||||
@@ -265,7 +265,12 @@ const filteredMemberCount = computed(() => {
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 overflow-hidden">
|
||||
<MemberList :session-id="currentSessionId" :show-header="false" @data-changed="loadData" />
|
||||
<MemberList
|
||||
:session-id="currentSessionId"
|
||||
:show-header="false"
|
||||
chat-type="group"
|
||||
@data-changed="loadData"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,342 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
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'
|
||||
import { ThemeCard } from '@/components/UI'
|
||||
|
||||
const { t } = useI18n()
|
||||
const layoutStore = useLayoutStore()
|
||||
|
||||
// Props
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
sessionId: string
|
||||
showHeader?: boolean
|
||||
}>(),
|
||||
{
|
||||
showHeader: true,
|
||||
}
|
||||
)
|
||||
|
||||
// 成员列表
|
||||
const members = ref<MemberWithStats[]>([])
|
||||
const isLoading = ref(false)
|
||||
|
||||
// 正在保存别名的成员ID
|
||||
const savingAliasesId = ref<number | null>(null)
|
||||
|
||||
// 合并成员状态
|
||||
const selectedMergeIds = ref<Set<number>>(new Set())
|
||||
const showMergeModal = ref(false)
|
||||
const isMerging = ref(false)
|
||||
|
||||
// 获取成员显示名称
|
||||
function getDisplayName(member: MemberWithStats): string {
|
||||
return member.groupNickname || member.accountName || member.platformId
|
||||
}
|
||||
|
||||
// 获取成员首字符(用于头像)
|
||||
function getFirstChar(member: MemberWithStats): string {
|
||||
const name = getDisplayName(member)
|
||||
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)
|
||||
|
||||
const mergePlan = computed(() => {
|
||||
if (selectedMergeMembers.value.length !== 2) return null
|
||||
const [memberA, memberB] = selectedMergeMembers.value
|
||||
if (
|
||||
memberB.messageCount > memberA.messageCount ||
|
||||
(memberB.messageCount === memberA.messageCount && memberB.id < memberA.id)
|
||||
) {
|
||||
return { primary: memberB, secondary: memberA }
|
||||
}
|
||||
return { primary: memberA, secondary: memberB }
|
||||
})
|
||||
|
||||
// 计算消息总数
|
||||
const totalMessageCount = computed(() => {
|
||||
return members.value.reduce((sum, m) => sum + m.messageCount, 0)
|
||||
})
|
||||
|
||||
// 计算每个成员的消息占比
|
||||
function getPercentage(count: number): number {
|
||||
if (totalMessageCount.value === 0) return 0
|
||||
return Math.round((count / totalMessageCount.value) * 100)
|
||||
}
|
||||
|
||||
// 加载成员列表
|
||||
async function loadMembers() {
|
||||
if (!props.sessionId) return
|
||||
isLoading.value = true
|
||||
try {
|
||||
members.value = await useDataService().getMembers(props.sessionId)
|
||||
} catch (error) {
|
||||
console.error('加载成员列表失败:', error)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 更新别名
|
||||
async function updateAliases(member: MemberWithStats, newAliases: string[]) {
|
||||
const aliasesToSave = JSON.parse(JSON.stringify(newAliases)) as string[]
|
||||
|
||||
const currentAliases = JSON.stringify(member.aliases)
|
||||
const newAliasesStr = JSON.stringify(aliasesToSave)
|
||||
if (currentAliases === newAliasesStr) return
|
||||
|
||||
savingAliasesId.value = member.id
|
||||
try {
|
||||
const success = await useDataService().updateMemberAliases(props.sessionId, member.id, aliasesToSave)
|
||||
if (success) {
|
||||
const idx = members.value.findIndex((m) => m.id === member.id)
|
||||
if (idx !== -1) {
|
||||
members.value[idx] = {
|
||||
...members.value[idx],
|
||||
aliases: aliasesToSave,
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('保存别名失败:', error)
|
||||
} finally {
|
||||
savingAliasesId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function toggleMergeSelection(memberId: number) {
|
||||
const next = new Set(selectedMergeIds.value)
|
||||
if (next.has(memberId)) {
|
||||
next.delete(memberId)
|
||||
} else {
|
||||
next.add(memberId)
|
||||
}
|
||||
selectedMergeIds.value = next
|
||||
}
|
||||
|
||||
function openMergeModal() {
|
||||
if (!canMergeSelected.value) return
|
||||
showMergeModal.value = true
|
||||
}
|
||||
|
||||
function clearMergeSelection() {
|
||||
selectedMergeIds.value = new Set()
|
||||
}
|
||||
|
||||
async function confirmMerge() {
|
||||
if (!mergePlan.value) return
|
||||
isMerging.value = true
|
||||
try {
|
||||
const success = await useDataService().mergeMembers(
|
||||
props.sessionId,
|
||||
mergePlan.value.primary.id,
|
||||
mergePlan.value.secondary.id
|
||||
)
|
||||
if (success) {
|
||||
showMergeModal.value = false
|
||||
clearMergeSelection()
|
||||
await loadMembers()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('合并成员失败:', error)
|
||||
} finally {
|
||||
isMerging.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 监听 sessionId 变化
|
||||
watch(
|
||||
() => props.sessionId,
|
||||
() => {
|
||||
clearMergeSelection()
|
||||
loadMembers()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
loadMembers()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="main-content max-w-4xl p-6">
|
||||
<!-- 页面标题 -->
|
||||
<div v-if="props.showHeader" class="mb-6">
|
||||
<div class="flex items-center gap-3">
|
||||
<div>
|
||||
<h2 class="text-xl font-bold text-gray-900 dark:text-white">{{ t('members.private.title') }}</h2>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('members.private.description', { count: members.length }) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Owner配置 -->
|
||||
<OwnerEntryCard class="mb-6" :session-id="sessionId" :members="members" chat-type="private" />
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="isLoading" class="flex h-60 items-center justify-center">
|
||||
<UIcon name="i-heroicons-arrow-path" class="h-8 w-8 animate-spin text-pink-500" />
|
||||
</div>
|
||||
|
||||
<div v-if="!isLoading && members.length > 1" class="mb-4 flex items-center justify-end gap-2">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('members.list.mergeSelectedCount', { count: selectedMergeIds.size }) }}
|
||||
</span>
|
||||
<UButton icon="i-heroicons-link" size="sm" color="primary" :disabled="!canMergeSelected" @click="openMergeModal">
|
||||
{{ t('members.list.mergeSelected') }}
|
||||
</UButton>
|
||||
</div>
|
||||
|
||||
<!-- 成员卡片列表 -->
|
||||
<div v-if="!isLoading" class="grid gap-4 md:grid-cols-2">
|
||||
<ThemeCard v-for="member in members" :key="member.id" class="relative p-5">
|
||||
<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>
|
||||
|
||||
<!-- 成员头部信息 -->
|
||||
<div class="flex items-start gap-4">
|
||||
<!-- 头像:优先显示真实头像,否则显示首字母 -->
|
||||
<img
|
||||
v-if="member.avatar"
|
||||
:src="member.avatar"
|
||||
:alt="getDisplayName(member)"
|
||||
class="h-14 w-14 shrink-0 rounded-full object-cover"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="flex h-14 w-14 shrink-0 items-center justify-center rounded-full bg-linear-to-br from-pink-400 to-pink-600 text-lg font-medium text-white"
|
||||
>
|
||||
{{ getFirstChar(member) }}
|
||||
</div>
|
||||
|
||||
<!-- 名称和ID -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white truncate">
|
||||
{{ getDisplayName(member) }}
|
||||
</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">ID: {{ member.platformId }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 消息统计 -->
|
||||
<div class="mt-4 flex items-center gap-4">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-baseline justify-between">
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">{{ t('members.private.messageCount') }}</span>
|
||||
<span class="text-lg font-bold text-gray-900 dark:text-white">
|
||||
{{ member.messageCount.toLocaleString() }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- 进度条 -->
|
||||
<div class="mt-2 h-2 w-full overflow-hidden rounded-full bg-gray-100 dark:bg-gray-800">
|
||||
<div
|
||||
class="h-full rounded-full bg-linear-to-r from-pink-400 to-pink-600 transition-all duration-500"
|
||||
:style="{ width: `${getPercentage(member.messageCount)}%` }"
|
||||
/>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-gray-400 dark:text-gray-500">
|
||||
{{ t('members.private.percentage', { value: getPercentage(member.messageCount) }) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 别名编辑 -->
|
||||
<div class="mt-4 border-t border-gray-100 pt-4 dark:border-gray-800">
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('members.private.customAlias') }}
|
||||
</label>
|
||||
<div class="relative">
|
||||
<UInputTags
|
||||
:model-value="member.aliases"
|
||||
:placeholder="t('members.private.aliasPlaceholder')"
|
||||
class="w-full"
|
||||
@update:model-value="(val) => updateAliases(member, val)"
|
||||
/>
|
||||
<!-- 保存中指示器 -->
|
||||
<div v-if="savingAliasesId === member.id" class="absolute right-3 top-1/2 -translate-y-1/2">
|
||||
<UIcon name="i-heroicons-arrow-path" class="h-4 w-4 animate-spin text-pink-500" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ThemeCard>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-if="!isLoading && members.length === 0" class="flex h-60 flex-col items-center justify-center">
|
||||
<UIcon name="i-heroicons-user-group" class="mb-3 h-12 w-12 text-gray-300 dark:text-gray-600" />
|
||||
<p class="text-gray-500 dark:text-gray-400">{{ t('members.private.empty') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 提示信息 -->
|
||||
<div v-if="members.length > 0" class="mt-6 flex items-start gap-3 rounded-xl bg-blue-50 p-4 dark:bg-blue-900/20">
|
||||
<UIcon name="i-heroicons-information-circle" class="mt-0.5 h-5 w-5 shrink-0 text-blue-500" />
|
||||
<div>
|
||||
<p class="text-sm font-medium text-blue-800 dark:text-blue-200">{{ t('members.private.tipTitle') }}</p>
|
||||
<p class="mt-1 text-sm text-blue-700 dark:text-blue-300">
|
||||
{{ t('members.private.tipContent') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 合并确认弹窗 -->
|
||||
<UModal :open="showMergeModal" :ui="{ content: 'max-w-md' }" @update:open="showMergeModal = $event">
|
||||
<template #content>
|
||||
<div class="p-6">
|
||||
<div class="mb-4 flex items-center gap-3">
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-amber-100 dark:bg-amber-900/30">
|
||||
<UIcon name="i-heroicons-link" class="h-5 w-5 text-amber-600 dark:text-amber-400" />
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{{ t('members.list.mergeModal.title') }}
|
||||
</h3>
|
||||
</div>
|
||||
<p class="mb-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
{{
|
||||
t('members.list.mergeModal.content', {
|
||||
source: mergePlan ? getDisplayName(mergePlan.secondary) : '',
|
||||
sourceCount: mergePlan ? mergePlan.secondary.messageCount.toLocaleString() : '0',
|
||||
target: mergePlan ? getDisplayName(mergePlan.primary) : '',
|
||||
targetCount: mergePlan ? mergePlan.primary.messageCount.toLocaleString() : '0',
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
<p class="mb-6 text-xs text-amber-700 dark:text-amber-300">{{ t('members.list.mergeModal.hint') }}</p>
|
||||
<div class="flex justify-end gap-2">
|
||||
<UButton variant="outline" :disabled="isMerging" @click="showMergeModal = false">
|
||||
{{ t('members.list.mergeModal.cancel') }}
|
||||
</UButton>
|
||||
<UButton color="primary" :loading="isMerging" @click="confirmMerge">
|
||||
{{ t('members.list.mergeModal.confirm') }}
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</UModal>
|
||||
</div>
|
||||
</template>
|
||||
@@ -9,7 +9,7 @@ import { DebugTab } from '@/components/DebugTab'
|
||||
import { ChatExplorer } from '@/components/AIChat'
|
||||
import OverviewTab from './components/OverviewTab.vue'
|
||||
import ViewTab from './components/ViewTab.vue'
|
||||
import MemberManagementPanel from './components/MemberTab.vue'
|
||||
import MemberList from '@/components/common/member/MemberList.vue'
|
||||
import SessionAnalysisHeader from '@/components/layout/session/SessionAnalysisHeader.vue'
|
||||
import SemanticIndexSessionModal from '@/components/analysis/SemanticIndexSessionModal.vue'
|
||||
import OwnerPromptModal from '@/components/analysis/member/OwnerPromptModal.vue'
|
||||
@@ -271,8 +271,13 @@ const otherMemberAvatar = computed(() => {
|
||||
</div>
|
||||
<UButton variant="ghost" icon="i-heroicons-x-mark" size="sm" @click="showMemberManagementModal = false" />
|
||||
</div>
|
||||
<div class="flex-1 overflow-auto">
|
||||
<MemberManagementPanel :session-id="currentSessionId" :show-header="false" />
|
||||
<div class="flex-1 overflow-hidden">
|
||||
<MemberList
|
||||
:session-id="currentSessionId"
|
||||
:show-header="false"
|
||||
chat-type="private"
|
||||
@data-changed="loadAnalysisData"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
Reference in new issue
Block a user