refactor(contacts): split contact page panels

This commit is contained in:
digua committed 2026-06-27 20:02:23 +08:00
1 parent e9e19f1b05
commit 928ac6064b
7 files changed
+330 -227

No files matched your search

+4 -2
View File
@@ -4,7 +4,7 @@
"disabled": "Current active private chat count in this range is {count}. The sidebar entry appears after more than 10 active private chats.",
"empty": "No matching contacts",
"emptyValue": "None",
"search": "Search contacts, IDs, or aliases",
"search": "Search contacts",
"score": "Score {value}",
"timeRange": {
"1y": "1Y",
@@ -42,12 +42,14 @@
},
"actions": {
"recompute": "Recompute",
"clearSearch": "Clear search",
"clearSelection": "Clear selection",
"viewDetail": "View details for {name}"
},
"detail": {
"title": "Contact details",
"score": "Score",
"scoreHelp": "The score is used to sort contacts. A higher score means stronger interaction signals.",
"lastInteraction": "Last interaction",
"sources": "Source chats",
"sourceType": {
@@ -78,7 +80,7 @@
},
"task": {
"running": "Computing contacts: {current} / {total} sessions",
"updating": "Contacts may be stale. Updating in the background.",
"updating": "Contacts may have changed. Recomputing.",
"failed": "Contact computation failed",
"retry": "Retry"
},
+4 -2
View File
@@ -4,7 +4,7 @@
"disabled": "現在の期間内の有効な個人チャット数は {count} 件です。サイドバー項目は有効な個人チャットが 10 件を超えると表示されます。",
"empty": "一致する連絡先はありません",
"emptyValue": "なし",
"search": "連絡先、ID、別名を検索",
"search": "連絡先を検索",
"score": "スコア {value}",
"timeRange": {
"1y": "1年",
@@ -42,12 +42,14 @@
},
"actions": {
"recompute": "再計算",
"clearSearch": "検索をクリア",
"clearSelection": "選択を解除",
"viewDetail": "{name} の詳細を表示"
},
"detail": {
"title": "連絡先の詳細",
"score": "総合スコア",
"scoreHelp": "総合スコアは連絡先の並び順に使われます。スコアが高いほど、やり取りのシグナルが強いことを示します。",
"lastInteraction": "最終インタラクション",
"sources": "元チャット",
"sourceType": {
@@ -78,7 +80,7 @@
},
"task": {
"running": "連絡先を計算中: {current} / {total} セッション",
"updating": "連絡先データが古い可能性があります。バックグラウンドで更新しています。",
"updating": "連絡先データが更新された可能性があります。再計算しています。",
"failed": "連絡先の計算に失敗しました",
"retry": "再試行"
},
+4 -2
View File
@@ -4,7 +4,7 @@
"disabled": "当前时间范围内有效私聊会话数为 {count},联系人入口默认在有效私聊会话数超过 10 后显示。",
"empty": "没有匹配的联系人",
"emptyValue": "暂无",
"search": "搜索联系人、ID 或别名",
"search": "搜索联系人",
"score": "分数 {value}",
"timeRange": {
"1y": "近1年",
@@ -42,12 +42,14 @@
},
"actions": {
"recompute": "重新计算",
"clearSearch": "清空搜索",
"clearSelection": "取消选中",
"viewDetail": "查看 {name} 的详情"
},
"detail": {
"title": "联系人详情",
"score": "综合分",
"scoreHelp": "综合分用于联系人排序。分数越高,代表互动信号越强。",
"lastInteraction": "最近互动",
"sources": "来源会话",
"sourceType": {
@@ -78,7 +80,7 @@
},
"task": {
"running": "正在计算联系人:{current} / {total} 个会话",
"updating": "联系人数据可能已过期,正在后台更新。",
"updating": "联系人数据可能已更新,正在重新计算。",
"failed": "联系人计算失败",
"retry": "重试"
},
+4 -2
View File
@@ -4,7 +4,7 @@
"disabled": "目前時間範圍內有效私聊會話數為 {count},聯絡人入口預設在有效私聊會話數超過 10 後顯示。",
"empty": "沒有符合的聯絡人",
"emptyValue": "暫無",
"search": "搜尋聯絡人、ID 或別名",
"search": "搜尋聯絡人",
"score": "分數 {value}",
"timeRange": {
"1y": "近1年",
@@ -42,12 +42,14 @@
},
"actions": {
"recompute": "重新計算",
"clearSearch": "清空搜尋",
"clearSelection": "取消選中",
"viewDetail": "查看 {name} 的詳情"
},
"detail": {
"title": "聯絡人詳情",
"score": "綜合分",
"scoreHelp": "綜合分用於聯絡人排序。分數越高,代表互動訊號越強。",
"lastInteraction": "最近互動",
"sources": "來源會話",
"sourceType": {
@@ -78,7 +80,7 @@
},
"task": {
"running": "正在計算聯絡人:{current} / {total} 個會話",
"updating": "聯絡人資料可能已過期,正在背景更新。",
"updating": "聯絡人資料可能已更新,正在重新計算。",
"failed": "聯絡人計算失敗",
"retry": "重試"
},
@@ -0,0 +1,163 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { ContactItem } from '@openchatlab/shared-types'
const props = defineProps<{
selectedKey: string | null
contact: ContactItem | null
isLoading: boolean
}>()
const emit = defineEmits<{
clear: []
openSource: [source: ContactItem['sourceSessions'][number]]
}>()
const { t } = useI18n()
const visibleAliases = computed(() => {
const contact = props.contact
if (!contact) return []
const hidden = new Set([contact.displayName.trim().toLowerCase(), contact.platformId.trim().toLowerCase()])
return contact.aliases.filter((alias) => !hidden.has(alias.trim().toLowerCase()))
})
function avatarText(contact: ContactItem): string {
return contact.displayName.trim().slice(0, 1).toUpperCase() || '?'
}
</script>
<template>
<Transition name="contact-detail-panel">
<aside
v-if="selectedKey"
class="flex h-full w-[420px] max-w-[80vw] shrink-0 flex-col border-l border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900"
style="-webkit-app-region: no-drag"
>
<div class="flex items-center justify-between border-b border-gray-200 px-4 py-3 dark:border-gray-800">
<h3 class="text-base font-semibold text-gray-900 dark:text-white">{{ t('contacts.detail.title') }}</h3>
<UButton
:aria-label="t('contacts.actions.clearSelection')"
icon="i-heroicons-x-mark"
color="neutral"
variant="ghost"
size="sm"
@click="emit('clear')"
/>
</div>
<div
v-if="isLoading"
class="flex min-h-0 flex-1 items-center justify-center gap-2 text-sm text-gray-400 dark:text-gray-500"
>
<UIcon name="i-lucide-loader-2" class="h-4 w-4 animate-spin" />
<span>{{ t('common.loading') }}</span>
</div>
<div v-else-if="contact" class="min-h-0 flex-1 overflow-y-auto">
<section class="px-5 py-5">
<div class="flex items-start gap-4">
<div class="relative shrink-0">
<img
v-if="contact.avatar"
:src="contact.avatar"
:alt="contact.displayName"
class="h-16 w-16 rounded-2xl object-cover shadow-sm ring-2 ring-white dark:ring-gray-900"
/>
<div
v-else
class="flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-gray-155 to-gray-200 text-lg font-bold text-gray-700 dark:from-gray-800 dark:to-gray-900 dark:text-gray-200"
>
{{ avatarText(contact) }}
</div>
</div>
<div class="min-w-0 flex-1 pt-1">
<h2 class="truncate text-lg font-bold tracking-tight text-gray-900 dark:text-white">
{{ contact.displayName }}
</h2>
<p class="mt-1 truncate font-mono text-xs text-gray-400 dark:text-gray-500">
{{ contact.platform }} · {{ contact.platformId }}
</p>
<div v-if="visibleAliases.length > 0" class="mt-2 flex flex-wrap gap-1.5">
<span
v-for="alias in visibleAliases.slice(0, 4)"
:key="alias"
class="max-w-full truncate rounded-md bg-gray-100 px-2 py-0.5 text-[11px] font-medium text-gray-500 dark:bg-white/5 dark:text-gray-400"
>
{{ alias }}
</span>
</div>
</div>
</div>
</section>
<section class="border-t border-gray-100 px-5 py-4 dark:border-white/5">
<h3 class="mb-3 text-[10px] font-bold uppercase tracking-wider text-gray-400 dark:text-gray-500">
{{ t('contacts.detail.sources') }}
</h3>
<div class="space-y-2.5">
<button
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)"
>
<div class="flex items-center justify-between gap-2">
<span class="truncate text-xs font-semibold text-gray-700 dark:text-gray-300">
{{ source.name }}
</span>
<span
class="flex items-center gap-1 text-[10px] font-bold text-gray-400 transition group-hover/item:text-pink-500 dark:text-gray-500"
>
{{
source.type === 'private'
? 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">
{{
source.privateMessageCount != null
? t('contacts.metrics.privateMessages', { count: source.privateMessageCount })
: t('contacts.metrics.groupSignals', { count: source.coOccurrenceCount ?? 0 })
}}
</div>
</button>
</div>
</section>
</div>
</aside>
</Transition>
</template>
<style scoped>
.contact-detail-panel-enter-active,
.contact-detail-panel-leave-active {
overflow: hidden;
transition:
width 0.22s ease,
opacity 0.18s ease,
transform 0.22s ease;
}
.contact-detail-panel-enter-from,
.contact-detail-panel-leave-to {
width: 0 !important;
opacity: 0;
transform: translateX(16px);
}
.contact-detail-panel-enter-to,
.contact-detail-panel-leave-from {
width: 420px;
opacity: 1;
transform: translateX(0);
}
</style>
@@ -0,0 +1,78 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import type { ContactsCacheStatus } from '@openchatlab/shared-types'
defineProps<{
showDisabledNotice: boolean
activePrivateSessionCount: number
cacheStatus?: ContactsCacheStatus
taskFailed: boolean
taskLastError?: string
isTaskRunning: boolean
isRecomputing: boolean
}>()
const emit = defineEmits<{
recompute: []
}>()
const { t } = useI18n()
</script>
<template>
<div
v-if="showDisabledNotice"
class="flex items-center gap-3.5 rounded-2xl border border-amber-200/60 bg-amber-50/40 p-4 text-sm text-amber-800 dark:border-amber-900/40 dark:bg-amber-950/10 dark:text-amber-200 backdrop-blur-sm shadow-sm"
>
<div class="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-amber-100/80 dark:bg-amber-950/50">
<UIcon name="i-lucide-alert-triangle" class="h-5 w-5 text-amber-500" />
</div>
<span class="font-medium">
{{ t('contacts.disabled', { count: activePrivateSessionCount }) }}
</span>
</div>
<div
v-if="cacheStatus === 'stale' && !isTaskRunning"
class="flex flex-col gap-4 rounded-2xl border border-zinc-200/80 bg-zinc-50/60 p-4 text-sm text-zinc-700 dark:border-zinc-800/80 dark:bg-zinc-900/40 dark:text-zinc-300 sm:flex-row sm:items-center sm:justify-between backdrop-blur-sm shadow-sm"
>
<div class="flex items-center gap-3.5">
<div class="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-zinc-100 dark:bg-zinc-800/80">
<UIcon name="i-lucide-history" class="h-5 w-5 text-zinc-500 dark:text-zinc-400" />
</div>
<span class="font-medium">
{{ t('contacts.stale.inline') }}
</span>
</div>
<div class="flex items-center shrink-0 self-end sm:self-center">
<UButton
size="sm"
color="primary"
variant="soft"
class="rounded-xl px-4 font-medium"
:loading="isRecomputing || isTaskRunning"
:disabled="isTaskRunning"
@click="emit('recompute')"
>
{{ t('contacts.actions.recompute') }}
</UButton>
</div>
</div>
<div
v-if="taskFailed"
class="flex flex-col gap-4 rounded-2xl border border-red-200/60 bg-red-50/40 p-4 text-sm text-red-700 dark:border-red-900/40 dark:bg-red-950/10 dark:text-red-300 sm:flex-row sm:items-center sm:justify-between backdrop-blur-sm shadow-sm"
>
<div class="flex min-w-0 items-center gap-3.5">
<div class="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-red-100/80 dark:bg-red-950/50">
<UIcon name="i-lucide-alert-circle" class="h-5 w-5 text-red-500" />
</div>
<span class="truncate font-medium">{{ taskLastError || t('contacts.task.failed') }}</span>
</div>
<div class="flex items-center shrink-0 self-end sm:self-center">
<UButton size="sm" color="error" variant="soft" class="rounded-xl px-4 font-medium" @click="emit('recompute')">
{{ t('contacts.task.retry') }}
</UButton>
</div>
</div>
</template>
+73 -219
View File
@@ -8,7 +8,9 @@ import type { ContactItem, ContactListItem, ContactsResponse, ContactsTimeRangeP
import { useDataService } from '@/services'
import { useToast } from '@/composables/useToast'
import PageHeader from '@/components/layout/PageHeader.vue'
import { SubTabs } from '@/components/UI'
import { LoadingState, SubTabs } from '@/components/UI'
import ContactDetailPanel from './components/ContactDetailPanel.vue'
import ContactsStatusBlocks from './components/ContactsStatusBlocks.vue'
import { shouldShowContactsDisabledNotice } from './contacts-view-state'
import { buildContactVirtualRows, type ContactPoolTab, type ContactVirtualRow } from './contacts-virtual-list'
@@ -113,10 +115,18 @@ const task = computed(() => response.value?.task)
const isTaskRunning = computed(() => task.value?.status === 'running')
const taskFailed = computed(() => task.value?.status === 'failed')
const showLoadingState = computed(
() =>
(friendState.value.isLoadingInitial && virtualRows.value.length <= 1) ||
(response.value?.cache.status === 'missing' && isTaskRunning.value && virtualRows.value.length <= 1)
() => isTaskRunning.value || (friendState.value.isLoadingInitial && virtualRows.value.length <= 1)
)
const loadingStateText = computed(() => {
if (response.value?.cache.status === 'stale' && isTaskRunning.value) return t('contacts.task.updating')
if (response.value?.cache.status === 'missing' && isTaskRunning.value) {
return t('contacts.task.running', {
current: task.value?.processedSessions ?? 0,
total: task.value?.totalSessions ?? 0,
})
}
return t('common.loading')
})
const showDisabledNotice = computed(() =>
shouldShowContactsDisabledNotice({
diagnostics: diagnostics.value,
@@ -168,13 +178,6 @@ const virtualizer = useVirtualizer(
const virtualItems = computed(() => virtualizer.value.getVirtualItems())
const totalSize = computed(() => virtualizer.value.getTotalSize())
const visibleSelectedContactAliases = computed(() => {
const contact = selectedContact.value
if (!contact) return []
const hidden = new Set([contact.displayName.trim().toLowerCase(), contact.platformId.trim().toLowerCase()])
return contact.aliases.filter((alias) => !hidden.has(alias.trim().toLowerCase()))
})
watch(searchQuery, (value) => {
if (searchTimer.value) clearTimeout(searchTimer.value)
searchTimer.value = setTimeout(() => {
@@ -646,8 +649,19 @@ function getGroupSectionStart(): number {
icon="i-lucide-search"
:placeholder="t('contacts.search')"
size="sm"
class="w-full sm:w-64"
/>
class="w-full sm:w-32"
>
<template v-if="searchQuery" #trailing>
<UButton
icon="i-heroicons-x-mark"
variant="link"
color="neutral"
size="xs"
:aria-label="t('contacts.actions.clearSearch')"
@click="searchQuery = ''"
/>
</template>
</UInput>
</div>
</template>
</SubTabs>
@@ -655,83 +669,19 @@ function getGroupSectionStart(): number {
<div class="flex min-h-0 flex-1 overflow-hidden">
<main ref="scrollContainerRef" class="min-h-0 min-w-0 flex-1 overflow-y-auto" @scroll="handleListScroll">
<div class="mx-auto flex w-full max-w-[1800px] flex-col gap-6 px-6 pb-6 pt-0 lg:px-8">
<div
v-if="showDisabledNotice"
class="flex items-center gap-3 rounded-2xl border border-amber-200 bg-amber-50/50 px-4 py-3.5 text-sm text-amber-800 dark:border-amber-900/50 dark:bg-amber-950/20 dark:text-amber-200"
>
<UIcon name="i-lucide-alert-triangle" class="h-5 w-5 shrink-0 text-amber-500" />
<span>{{ t('contacts.disabled', { count: diagnostics?.activePrivateSessionCount ?? 0 }) }}</span>
</div>
<div
v-if="response?.cache.status === 'stale'"
class="flex flex-col gap-3 rounded-2xl border border-sky-100 bg-sky-50/50 px-4 py-3.5 text-sm text-sky-800 dark:border-sky-900/30 dark:bg-sky-950/20 dark:text-sky-200 sm:flex-row sm:items-center sm:justify-between"
>
<div class="flex items-center gap-3">
<UIcon name="i-lucide-info" class="h-5 w-5 shrink-0 text-sky-500" />
<span>{{ isTaskRunning ? t('contacts.task.updating') : t('contacts.stale.inline') }}</span>
</div>
<UButton
size="xs"
color="primary"
variant="solid"
class="rounded-xl"
:loading="isRecomputing || isTaskRunning"
:disabled="isTaskRunning"
@click="recomputeContacts"
>
{{ t('contacts.actions.recompute') }}
</UButton>
</div>
<div
v-if="response?.cache.status === 'missing' && isTaskRunning"
class="flex items-center gap-3 rounded-2xl border border-sky-100 bg-sky-50/50 px-4 py-3.5 text-sm text-sky-800 dark:border-sky-900/30 dark:bg-sky-950/20 dark:text-sky-200"
>
<UIcon name="i-lucide-loader-2" class="h-5 w-5 shrink-0 animate-spin text-sky-500" />
<span>
{{
t('contacts.task.running', {
current: task?.processedSessions ?? 0,
total: task?.totalSessions ?? 0,
})
}}
</span>
</div>
<div
v-if="taskFailed"
class="flex flex-col gap-3 rounded-2xl border border-red-200 bg-red-50/50 px-4 py-3.5 text-sm text-red-700 dark:border-red-900/30 dark:bg-red-950/20 dark:text-red-300 sm:flex-row sm:items-center sm:justify-between"
>
<div class="flex min-w-0 items-center gap-3">
<UIcon name="i-lucide-alert-circle" class="h-5 w-5 shrink-0 text-red-500" />
<span class="truncate">{{ task?.lastError || t('contacts.task.failed') }}</span>
</div>
<UButton size="xs" color="error" variant="soft" class="rounded-xl" @click="recomputeContacts">
{{ t('contacts.task.retry') }}
</UButton>
</div>
<ContactsStatusBlocks
:show-disabled-notice="showDisabledNotice"
:active-private-session-count="diagnostics?.activePrivateSessionCount ?? 0"
:cache-status="response?.cache.status"
:task-failed="taskFailed"
:task-last-error="task?.lastError"
:is-task-running="isTaskRunning"
:is-recomputing="isRecomputing"
@recompute="recomputeContacts"
/>
<section class="flex flex-col gap-4">
<!-- 加载骨架 -->
<div v-if="showLoadingState" class="space-y-8">
<div v-for="g in 3" :key="g" class="space-y-4">
<!-- 分组骨架标题 -->
<div class="flex items-center gap-3">
<div class="h-5 w-20 animate-pulse rounded-lg bg-gray-100 dark:bg-white/5" />
<div class="h-4 w-10 animate-pulse rounded-lg bg-gray-50 dark:bg-white/5" />
<div class="h-px flex-1 bg-gray-100 dark:bg-white/5" />
</div>
<!-- 列表骨架 -->
<div class="space-y-2">
<div
v-for="i in 4"
:key="i"
class="h-[76px] animate-pulse rounded-2xl bg-gray-100 dark:bg-white/5"
/>
</div>
</div>
</div>
<LoadingState v-if="showLoadingState" :text="loadingStateText" height="py-16" />
<div
v-else-if="pageError"
@@ -775,7 +725,20 @@ function getGroupSectionStart(): number {
<span>{{ t('contacts.metrics.replies') }}</span>
</template>
<span>{{ t('contacts.metrics.lastInteractionShort') }}</span>
<span class="text-right">{{ t('contacts.detail.score') }}</span>
<span class="flex min-w-0 items-center justify-end gap-1 text-right">
<span class="truncate">{{ t('contacts.detail.score') }}</span>
<UTooltip :content="{ side: 'top' }" :ui="{ content: 'h-auto' }">
<UIcon
name="i-lucide-circle-help"
class="h-3.5 w-3.5 shrink-0 text-gray-350 transition-colors hover:text-primary-500 dark:text-gray-600 dark:hover:text-primary-400"
/>
<template #content>
<div class="max-w-[280px] whitespace-normal break-words text-left text-xs leading-5">
{{ t('contacts.detail.scoreHelp') }}
</div>
</template>
</UTooltip>
</span>
</div>
</div>
@@ -801,7 +764,20 @@ function getGroupSectionStart(): number {
<span>{{ t('contacts.metrics.coOccurrence') }}</span>
<span>{{ t('contacts.metrics.replies') }}</span>
<span>{{ t('contacts.metrics.lastInteractionShort') }}</span>
<span class="text-right">{{ t('contacts.detail.score') }}</span>
<span class="flex min-w-0 items-center justify-end gap-1 text-right">
<span class="truncate">{{ t('contacts.detail.score') }}</span>
<UTooltip :content="{ side: 'top' }" :ui="{ content: 'h-auto' }">
<UIcon
name="i-lucide-circle-help"
class="h-3.5 w-3.5 shrink-0 text-gray-350 transition-colors hover:text-primary-500 dark:text-gray-600 dark:hover:text-primary-400"
/>
<template #content>
<div class="max-w-[280px] whitespace-normal break-words text-left text-xs leading-5">
{{ t('contacts.detail.scoreHelp') }}
</div>
</template>
</UTooltip>
</span>
</div>
<button
@@ -931,112 +907,13 @@ function getGroupSectionStart(): number {
</div>
</main>
<Transition name="contact-detail-panel">
<aside
v-if="selectedKey"
class="flex h-full w-[420px] max-w-[80vw] shrink-0 flex-col border-l border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900"
style="-webkit-app-region: no-drag"
>
<div class="flex items-center justify-between border-b border-gray-200 px-4 py-3 dark:border-gray-800">
<h3 class="text-base font-semibold text-gray-900 dark:text-white">{{ t('contacts.detail.title') }}</h3>
<UButton
:aria-label="t('contacts.actions.clearSelection')"
icon="i-heroicons-x-mark"
color="neutral"
variant="ghost"
size="sm"
@click="clearSelectedContact"
/>
</div>
<div
v-if="isDetailLoading"
class="flex min-h-0 flex-1 items-center justify-center gap-2 text-sm text-gray-400 dark:text-gray-500"
>
<UIcon name="i-lucide-loader-2" class="h-4 w-4 animate-spin" />
<span>{{ t('common.loading') }}</span>
</div>
<div v-else-if="selectedContact" class="min-h-0 flex-1 overflow-y-auto">
<section class="px-5 py-5">
<div class="flex items-start gap-4">
<div class="relative shrink-0">
<img
v-if="selectedContact.avatar"
:src="selectedContact.avatar"
:alt="selectedContact.displayName"
class="h-16 w-16 rounded-2xl object-cover shadow-sm ring-2 ring-white dark:ring-gray-900"
/>
<div
v-else
class="flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-gray-155 to-gray-200 text-lg font-bold text-gray-700 dark:from-gray-800 dark:to-gray-900 dark:text-gray-200"
>
{{ avatarText(selectedContact) }}
</div>
</div>
<div class="min-w-0 flex-1 pt-1">
<h2 class="truncate text-lg font-bold tracking-tight text-gray-900 dark:text-white">
{{ selectedContact.displayName }}
</h2>
<p class="mt-1 truncate font-mono text-xs text-gray-400 dark:text-gray-500">
{{ selectedContact.platform }} · {{ selectedContact.platformId }}
</p>
<div v-if="visibleSelectedContactAliases.length > 0" class="mt-2 flex flex-wrap gap-1.5">
<span
v-for="alias in visibleSelectedContactAliases.slice(0, 4)"
:key="alias"
class="max-w-full truncate rounded-md bg-gray-100 px-2 py-0.5 text-[11px] font-medium text-gray-500 dark:bg-white/5 dark:text-gray-400"
>
{{ alias }}
</span>
</div>
</div>
</div>
</section>
<section class="border-t border-gray-100 px-5 py-4 dark:border-white/5">
<h3 class="mb-3 text-[10px] font-bold uppercase tracking-wider text-gray-400 dark:text-gray-500">
{{ t('contacts.detail.sources') }}
</h3>
<div class="space-y-2.5">
<button
v-for="source in selectedContact.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="openSourceSession(source)"
>
<div class="flex items-center justify-between gap-2">
<span class="truncate text-xs font-semibold text-gray-700 dark:text-gray-300">
{{ source.name }}
</span>
<span
class="flex items-center gap-1 text-[10px] font-bold text-gray-400 transition group-hover/item:text-pink-500 dark:text-gray-500"
>
{{
source.type === 'private'
? 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">
{{
source.privateMessageCount != null
? t('contacts.metrics.privateMessages', { count: source.privateMessageCount })
: t('contacts.metrics.groupSignals', { count: source.coOccurrenceCount ?? 0 })
}}
</div>
</button>
</div>
</section>
</div>
</aside>
</Transition>
<ContactDetailPanel
:selected-key="selectedKey"
:contact="selectedContact"
:is-loading="isDetailLoading"
@clear="clearSelectedContact"
@open-source="openSourceSession"
/>
</div>
</div>
</div>
@@ -1073,27 +950,4 @@ function getGroupSectionStart(): number {
.dark .contact-table-value {
color: rgb(209, 213, 219);
}
.contact-detail-panel-enter-active,
.contact-detail-panel-leave-active {
overflow: hidden;
transition:
width 0.22s ease,
opacity 0.18s ease,
transform 0.22s ease;
}
.contact-detail-panel-enter-from,
.contact-detail-panel-leave-to {
width: 0 !important;
opacity: 0;
transform: translateX(16px);
}
.contact-detail-panel-enter-to,
.contact-detail-panel-leave-from {
width: 420px;
opacity: 1;
transform: translateX(0);
}
</style>