mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-26 02:04:37 +08:00
refactor(contacts): split contact page panels
This commit is contained in:
1 parent
e9e19f1b05
commit
928ac6064b
7 files changed
+330
-227
No files matched your search
@@ -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,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,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,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
@@ -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>
|
||||
Reference in new issue
Block a user