feat(web): add contacts page

This commit is contained in:
digua committed 2026-06-27 15:54:08 +08:00
1 parent c500d735a7
commit 11152a7c4b
17 files changed
+1078 -2

No files matched your search

+15
View File
@@ -32,9 +32,13 @@ const { isSidebarCollapsed: isCollapsed } = storeToRefs(layoutStore)
const { toggleSidebar } = layoutStore
const router = useRouter()
const route = useRoute()
const CONTACTS_PRIVATE_SESSION_THRESHOLD = 10
// 是否在首页
const isHomePage = computed(() => route.path === '/')
const isContactsPage = computed(() => route.name === 'contacts')
const privateSessionCount = computed(() => sessions.value.filter((session) => session.type === 'private').length)
const showContactsEntry = computed(() => privateSessionCount.value > CONTACTS_PRIVATE_SESSION_THRESHOLD)
// 重命名相关状态
const showRenameModal = ref(false)
@@ -108,6 +112,10 @@ function handleImport() {
router.push('/')
}
function openContacts() {
router.push({ name: 'contacts' })
}
function readUpdateCheckCache(): UpdateNoticeCache | null {
try {
const raw = window.localStorage.getItem(UPDATE_CHECK_CACHE_KEY)
@@ -438,6 +446,13 @@ function getAvatarColorClass(session: AnalysisSession, isActive: boolean) {
:active="isHomePage"
@click="handleImport"
/>
<SidebarButton
v-if="showContactsEntry"
icon="i-lucide-users"
:title="t('layout.contacts')"
:active="isContactsPage"
@click="openContacts"
/>
</div>
<!-- Session List -->
+65
View File
@@ -0,0 +1,65 @@
{
"title": "Contacts",
"subtitle": "{count} private chats imported",
"disabled": "Current private chat count is {count}. The sidebar entry appears after more than 10 private chats.",
"empty": "No matching contacts",
"emptyValue": "None",
"search": "Search contacts, IDs, or aliases",
"score": "Score {value}",
"stats": {
"total": "Contacts",
"core": "Core",
"friends": "Friends",
"nonFriends": "Group contacts"
},
"filters": {
"allPools": "All sources",
"friends": "Friends",
"nonFriends": "Group contacts",
"allTiers": "All tiers",
"showLow": "Show low interaction"
},
"pool": {
"friend": "Friend",
"nonFriend": "Non-friend"
},
"tier": {
"core": "Core",
"friend": "Friend",
"acquaintance": "Acquaintance",
"high_interaction": "High interaction",
"medium_interaction": "Medium interaction",
"low_interaction": "Low interaction"
},
"actions": {
"recompute": "Recompute",
"lockCore": "Lock core",
"unlock": "Unlock"
},
"detail": {
"score": "Score",
"lastInteraction": "Last interaction",
"sources": "Source chats"
},
"metrics": {
"privateMessages": "{count} private messages",
"privateMessagesLabel": "Private messages",
"activeMonths": "Active months",
"commonGroups": "Common groups",
"groups": "{count} common groups",
"coOccurrence": "Co-occurrences",
"replies": "Replies",
"groupSignals": "{count} co-occurrences"
},
"stale": {
"title": "Contacts may be stale",
"message": "Chat data or manual locks changed. Recompute contacts now?",
"inline": "Contacts may be stale.",
"later": "Later"
},
"toast": {
"recomputed": "Contacts recomputed",
"recomputeFailed": "Recompute failed",
"overrideFailed": "Failed to save contact settings"
}
}
+2
View File
@@ -11,6 +11,7 @@ import members from './members.json'
import records from './records.json'
import views from './views.json'
import wordFilter from './wordFilter.json'
import contacts from './contacts.json'
export default {
common,
@@ -26,4 +27,5 @@ export default {
records,
views,
wordFilter,
contacts,
}
+1
View File
@@ -1,6 +1,7 @@
{
"brand": "ChatLab",
"newAnalysis": "New Analysis",
"contacts": "Contacts",
"manage": "Manage",
"noRecords": "No records",
"searchPlaceholder": "Search conversation name",
+65
View File
@@ -0,0 +1,65 @@
{
"title": "連絡先",
"subtitle": "{count} 件の個人チャットをインポート済み",
"disabled": "現在の個人チャット数は {count} 件です。サイドバー項目は個人チャットが 10 件を超えると表示されます。",
"empty": "一致する連絡先はありません",
"emptyValue": "なし",
"search": "連絡先、ID、別名を検索",
"score": "スコア {value}",
"stats": {
"total": "連絡先",
"core": "コア",
"friends": "友人",
"nonFriends": "グループ連絡先"
},
"filters": {
"allPools": "すべて",
"friends": "友人",
"nonFriends": "グループ連絡先",
"allTiers": "すべての階層",
"showLow": "低インタラクションを表示"
},
"pool": {
"friend": "友人",
"nonFriend": "非友人"
},
"tier": {
"core": "コア関係",
"friend": "友人",
"acquaintance": "知人",
"high_interaction": "高インタラクション",
"medium_interaction": "中インタラクション",
"low_interaction": "低インタラクション"
},
"actions": {
"recompute": "再計算",
"lockCore": "コアに固定",
"unlock": "固定を解除"
},
"detail": {
"score": "総合スコア",
"lastInteraction": "最終インタラクション",
"sources": "元チャット"
},
"metrics": {
"privateMessages": "個人チャット {count} 件",
"privateMessagesLabel": "個人メッセージ",
"activeMonths": "アクティブ月",
"commonGroups": "共通グループ",
"groups": "共通グループ {count} 件",
"coOccurrence": "同時出現",
"replies": "返信",
"groupSignals": "同時出現 {count} 回"
},
"stale": {
"title": "連絡先データが古い可能性があります",
"message": "チャットデータまたは手動固定が変更されました。今すぐ再計算しますか?",
"inline": "連絡先データが古い可能性があります。",
"later": "後で"
},
"toast": {
"recomputed": "連絡先を再計算しました",
"recomputeFailed": "再計算に失敗しました",
"overrideFailed": "連絡先設定の保存に失敗しました"
}
}
+2
View File
@@ -11,6 +11,7 @@ import members from './members.json'
import records from './records.json'
import views from './views.json'
import wordFilter from './wordFilter.json'
import contacts from './contacts.json'
export default {
common,
@@ -26,4 +27,5 @@ export default {
records,
views,
wordFilter,
contacts,
}
+1
View File
@@ -1,6 +1,7 @@
{
"brand": "ChatLab",
"newAnalysis": "新しいチャットを分析",
"contacts": "連絡先",
"manage": "管理",
"noRecords": "記録がありません",
"searchPlaceholder": "チャット名を検索",
+65
View File
@@ -0,0 +1,65 @@
{
"title": "联系人",
"subtitle": "已导入 {count} 个私聊会话",
"disabled": "当前私聊会话数为 {count},联系人入口默认在私聊会话数超过 10 后显示。",
"empty": "没有匹配的联系人",
"emptyValue": "暂无",
"search": "搜索联系人、ID 或别名",
"score": "分数 {value}",
"stats": {
"total": "联系人",
"core": "核心",
"friends": "好友",
"nonFriends": "群聊联系人"
},
"filters": {
"allPools": "全部来源",
"friends": "好友",
"nonFriends": "群聊联系人",
"allTiers": "全部分层",
"showLow": "显示低互动"
},
"pool": {
"friend": "好友",
"nonFriend": "非好友"
},
"tier": {
"core": "核心关系",
"friend": "朋友",
"acquaintance": "普通熟人",
"high_interaction": "高互动",
"medium_interaction": "中互动",
"low_interaction": "低互动"
},
"actions": {
"recompute": "重新计算",
"lockCore": "锁定核心",
"unlock": "取消锁定"
},
"detail": {
"score": "综合分",
"lastInteraction": "最近互动",
"sources": "来源会话"
},
"metrics": {
"privateMessages": "{count} 条私聊",
"privateMessagesLabel": "私聊消息",
"activeMonths": "活跃月份",
"commonGroups": "共同群",
"groups": "{count} 个共同群",
"coOccurrence": "同框次数",
"replies": "回复互动",
"groupSignals": "{count} 次同框"
},
"stale": {
"title": "联系人数据可能已过期",
"message": "检测到聊天库或手动锁定发生变化,是否立即重新计算联系人?",
"inline": "联系人数据可能已过期。",
"later": "稍后"
},
"toast": {
"recomputed": "联系人已重新计算",
"recomputeFailed": "重新计算失败",
"overrideFailed": "保存联系人设置失败"
}
}
+2
View File
@@ -11,6 +11,7 @@ import members from './members.json'
import records from './records.json'
import views from './views.json'
import wordFilter from './wordFilter.json'
import contacts from './contacts.json'
export default {
common,
@@ -26,4 +27,5 @@ export default {
records,
views,
wordFilter,
contacts,
}
+1
View File
@@ -1,6 +1,7 @@
{
"brand": "ChatLab",
"newAnalysis": "分析新对话",
"contacts": "联系人",
"manage": "管理",
"noRecords": "暂无记录",
"searchPlaceholder": "搜索对话名称",
+65
View File
@@ -0,0 +1,65 @@
{
"title": "聯絡人",
"subtitle": "已匯入 {count} 個私聊會話",
"disabled": "目前私聊會話數為 {count},聯絡人入口預設在私聊會話數超過 10 後顯示。",
"empty": "沒有符合的聯絡人",
"emptyValue": "暫無",
"search": "搜尋聯絡人、ID 或別名",
"score": "分數 {value}",
"stats": {
"total": "聯絡人",
"core": "核心",
"friends": "好友",
"nonFriends": "群聊聯絡人"
},
"filters": {
"allPools": "全部來源",
"friends": "好友",
"nonFriends": "群聊聯絡人",
"allTiers": "全部分層",
"showLow": "顯示低互動"
},
"pool": {
"friend": "好友",
"nonFriend": "非好友"
},
"tier": {
"core": "核心關係",
"friend": "朋友",
"acquaintance": "普通熟人",
"high_interaction": "高互動",
"medium_interaction": "中互動",
"low_interaction": "低互動"
},
"actions": {
"recompute": "重新計算",
"lockCore": "鎖定核心",
"unlock": "取消鎖定"
},
"detail": {
"score": "綜合分",
"lastInteraction": "最近互動",
"sources": "來源會話"
},
"metrics": {
"privateMessages": "{count} 則私聊",
"privateMessagesLabel": "私聊訊息",
"activeMonths": "活躍月份",
"commonGroups": "共同群",
"groups": "{count} 個共同群",
"coOccurrence": "同框次數",
"replies": "回覆互動",
"groupSignals": "{count} 次同框"
},
"stale": {
"title": "聯絡人資料可能已過期",
"message": "偵測到聊天庫或手動鎖定發生變化,是否立即重新計算聯絡人?",
"inline": "聯絡人資料可能已過期。",
"later": "稍後"
},
"toast": {
"recomputed": "聯絡人已重新計算",
"recomputeFailed": "重新計算失敗",
"overrideFailed": "儲存聯絡人設定失敗"
}
}
+2
View File
@@ -11,6 +11,7 @@ import members from './members.json'
import records from './records.json'
import views from './views.json'
import wordFilter from './wordFilter.json'
import contacts from './contacts.json'
export default {
common,
@@ -26,4 +27,5 @@ export default {
records,
views,
wordFilter,
contacts,
}
+1
View File
@@ -1,6 +1,7 @@
{
"brand": "ChatLab",
"newAnalysis": "分析新對話",
"contacts": "聯絡人",
"manage": "管理",
"noRecords": "暫無紀錄",
"searchPlaceholder": "搜尋聊天名稱",
+736
View File
@@ -0,0 +1,736 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import type { ContactItem, ContactTier, ContactsResponse } from '@openchatlab/shared-types'
import { useDataService } from '@/services'
import { useToast } from '@/composables/useToast'
import PageHeader from '@/components/layout/PageHeader.vue'
type PoolFilter = 'all' | 'friend' | 'non_friend'
type TierFilter = 'all' | ContactTier
const { t } = useI18n()
const toast = useToast()
const dataService = useDataService()
const router = useRouter()
const response = ref<ContactsResponse | null>(null)
const isLoading = ref(true)
const isRecomputing = ref(false)
const error = ref('')
const searchQuery = ref('')
const poolFilter = ref<PoolFilter>('all')
const tierFilter = ref<TierFilter>('all')
const showLowInteraction = ref(false)
const selectedKey = ref<string | null>(null)
const pendingOverrideKey = ref<string | null>(null)
const showRecomputePrompt = ref(false)
const tierOrder: ContactTier[] = [
'core',
'friend',
'acquaintance',
'high_interaction',
'medium_interaction',
'low_interaction',
]
const contacts = computed(() => response.value?.contacts ?? [])
const diagnostics = computed(() => response.value?.diagnostics)
const stats = computed(() => {
const all = contacts.value
return {
total: all.length,
friends: all.filter((contact) => contact.pool === 'friend').length,
core: all.filter((contact) => contact.tier === 'core').length,
nonFriends: all.filter((contact) => contact.pool === 'non_friend').length,
hidden: diagnostics.value?.hiddenLowInteractionNonFriends ?? 0,
}
})
const filteredContacts = computed(() => {
const query = searchQuery.value.trim().toLowerCase()
return contacts.value.filter((contact) => {
if (poolFilter.value !== 'all' && contact.pool !== poolFilter.value) return false
if (tierFilter.value !== 'all' && contact.tier !== tierFilter.value) return false
if (!showLowInteraction.value && !query && contact.pool === 'non_friend' && contact.tier === 'low_interaction') {
return false
}
if (!query) return true
return contact.searchText.includes(query)
})
})
const groupedContacts = computed(() => {
const groups = new Map<ContactTier, ContactItem[]>()
for (const contact of filteredContacts.value) {
const group = groups.get(contact.tier) ?? []
group.push(contact)
groups.set(contact.tier, group)
}
return tierOrder
.map((tier) => ({ tier, contacts: groups.get(tier) ?? [] }))
.filter((group) => group.contacts.length > 0)
})
const selectedContact = computed(() => {
if (selectedKey.value) {
const selected = filteredContacts.value.find((contact) => contact.key === selectedKey.value)
if (selected) return selected
}
return filteredContacts.value[0] ?? null
})
watch(filteredContacts, () => {
if (selectedKey.value && filteredContacts.value.some((contact) => contact.key === selectedKey.value)) return
selectedKey.value = filteredContacts.value[0]?.key ?? null
})
onMounted(() => {
void loadContacts({ acceptStale: true })
})
async function loadContacts(options?: { acceptStale?: boolean; force?: boolean }) {
isLoading.value = !response.value
error.value = ''
try {
const next = options?.force
? await dataService.recomputeContacts()
: await dataService.getContacts({ acceptStale: options?.acceptStale })
response.value = next
selectedKey.value = selectedKey.value ?? next.contacts[0]?.key ?? null
showRecomputePrompt.value = next.cache.status === 'stale'
} catch (err) {
error.value = String(err)
} finally {
isLoading.value = false
}
}
async function recomputeContacts() {
isRecomputing.value = true
try {
const next = await dataService.recomputeContacts()
response.value = next
showRecomputePrompt.value = false
toast.success(t('contacts.toast.recomputed'))
} catch (err) {
toast.fail(t('contacts.toast.recomputeFailed'), { description: String(err) })
} finally {
isRecomputing.value = false
}
}
async function lockAsCore(contact: ContactItem) {
pendingOverrideKey.value = contact.key
try {
response.value = await dataService.setContactOverride(contact.key, { lockedTier: 'core' })
selectedKey.value = contact.key
} catch (err) {
toast.fail(t('contacts.toast.overrideFailed'), { description: String(err) })
} finally {
pendingOverrideKey.value = null
}
}
async function unlockTier(contact: ContactItem) {
pendingOverrideKey.value = contact.key
try {
response.value = await dataService.deleteContactOverride(contact.key)
selectedKey.value = contact.key
} catch (err) {
toast.fail(t('contacts.toast.overrideFailed'), { description: String(err) })
} finally {
pendingOverrideKey.value = null
}
}
function selectContact(contact: ContactItem) {
selectedKey.value = contact.key
}
function tierLabel(tier: ContactTier): string {
return t(`contacts.tier.${tier}`)
}
function tierClasses(tier: ContactTier): string {
const classes: Record<ContactTier, string> = {
core: 'bg-pink-50 text-pink-600 dark:bg-pink-500/10 dark:text-pink-400 border border-pink-100/50 dark:border-pink-500/20',
friend:
'bg-sky-50 text-sky-600 dark:bg-sky-500/10 dark:text-sky-400 border border-sky-100/50 dark:border-sky-500/20',
acquaintance:
'bg-slate-50 text-slate-600 dark:bg-slate-500/10 dark:text-slate-400 border border-slate-100/50 dark:border-slate-500/20',
high_interaction:
'bg-emerald-50 text-emerald-600 dark:bg-emerald-500/10 dark:text-emerald-400 border border-emerald-100/50 dark:border-emerald-500/20',
medium_interaction:
'bg-amber-50 text-amber-600 dark:bg-amber-500/10 dark:text-amber-400 border border-amber-100/50 dark:border-amber-500/20',
low_interaction:
'bg-gray-50 text-gray-500 dark:bg-gray-500/10 dark:text-gray-400 border border-gray-100/50 dark:border-gray-500/20',
}
return classes[tier]
}
function avatarText(contact: ContactItem): string {
return (contact.displayName || contact.platformId || '?').slice(0, 1)
}
function formatScore(score: number): string {
return Math.round(score * 100).toString()
}
function formatCount(value: number | undefined): string {
return String(value ?? 0)
}
function formatTime(ts: number | null | undefined): string {
if (!ts) return t('contacts.emptyValue')
return new Date(ts * 1000).toLocaleDateString()
}
function openSourceSession(source: ContactItem['sourceSessions'][number]) {
router.push({
name: source.type === 'private' ? 'private-chat' : 'group-chat',
params: { id: source.id },
})
}
const activeTab = ref('overview')
const tabs = [
{ id: 'overview', labelKey: 'analysis.tabs.overview', icon: 'i-heroicons-chart-pie' },
{ id: 'view', labelKey: 'analysis.tabs.view', icon: 'i-heroicons-presentation-chart-bar' },
{ id: 'ai-chat', labelKey: 'analysis.tabs.aiChat', icon: 'i-heroicons-chat-bubble-left-ellipsis' },
{ id: 'lab', labelKey: 'analysis.tabs.lab', icon: 'i-heroicons-beaker' },
]
</script>
<template>
<div
class="flex h-full flex-col bg-white text-gray-900 dark:bg-gray-900 dark:text-gray-100"
style="padding-top: var(--titlebar-area-height)"
>
<PageHeader
:title="t('contacts.title')"
:description="t('contacts.subtitle', { count: diagnostics?.privateSessionCount ?? 0 })"
size="compact"
icon="i-lucide-users"
icon-class="bg-gray-900 text-white dark:bg-gray-800 shadow-sm"
>
<template #actions>
<UButton
icon="i-lucide-refresh-cw"
color="primary"
variant="soft"
size="sm"
class="rounded-xl border border-pink-100 hover:border-pink-200 dark:border-pink-950/30 dark:hover:border-pink-900/50"
:loading="isRecomputing"
@click="recomputeContacts"
>
{{ t('contacts.actions.recompute') }}
</UButton>
</template>
<!-- 仿聊天分析页的 Tab 菜单栏与统计信息 -->
<div class="mt-3 flex items-center justify-between gap-3 pb-1.5">
<div class="flex shrink-0 items-center gap-0.5 overflow-x-auto scrollbar-hide">
<button
v-for="tab in tabs"
:key="tab.id"
type="button"
class="flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs font-semibold transition-all"
:class="[
activeTab === tab.id
? 'bg-pink-500 text-white dark:bg-pink-900/30 dark:text-pink-300'
: 'text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800',
]"
@click="activeTab = tab.id"
>
<UIcon :name="tab.icon" class="h-3.5 w-3.5" />
<span class="whitespace-nowrap">{{ t(tab.labelKey) }}</span>
</button>
</div>
<!-- 统计指标面板 -->
<div class="hidden items-center gap-5 text-[11px] sm:flex">
<div class="flex items-center gap-1.5">
<span class="text-gray-400 dark:text-gray-500">{{ t('contacts.stats.total') }}</span>
<span class="font-mono font-bold text-gray-900 dark:text-white">{{ stats.total }}</span>
</div>
<div class="h-3 w-px bg-gray-250 dark:bg-white/10"></div>
<div class="flex items-center gap-1.5">
<span class="text-gray-400 dark:text-gray-500">{{ t('contacts.stats.core') }}</span>
<span class="font-mono font-bold text-pink-600 dark:text-pink-400">{{ stats.core }}</span>
</div>
<div class="h-3 w-px bg-gray-250 dark:bg-white/10"></div>
<div class="flex items-center gap-1.5">
<span class="text-gray-400 dark:text-gray-500">{{ t('contacts.stats.friends') }}</span>
<span class="font-mono font-bold text-gray-900 dark:text-white">{{ stats.friends }}</span>
</div>
<div class="h-3 w-px bg-gray-250 dark:bg-white/10"></div>
<div class="flex items-center gap-1.5">
<span class="text-gray-400 dark:text-gray-500">{{ t('contacts.stats.nonFriends') }}</span>
<span class="font-mono font-bold text-gray-900 dark:text-white">{{ stats.nonFriends }}</span>
</div>
</div>
</div>
</PageHeader>
<div class="flex-1 min-w-0 overflow-y-auto">
<main class="mx-auto flex w-full max-w-[1800px] flex-col gap-6 px-6 py-6 lg:px-8">
<div
v-if="diagnostics && !diagnostics.contactsEnabled"
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.privateSessionCount }) }}</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>{{ t('contacts.stale.inline') }}</span>
</div>
<UButton
size="xs"
color="primary"
variant="solid"
class="rounded-xl"
:loading="isRecomputing"
@click="recomputeContacts"
>
{{ t('contacts.actions.recompute') }}
</UButton>
</div>
<section v-if="activeTab === 'overview'" class="flex flex-col gap-4">
<!-- 筛选与搜索控制栏 -->
<div
class="flex flex-col gap-3 rounded-2xl border border-gray-100/80 bg-white/60 p-3 shadow-[0_1px_2px_rgba(0,0,0,0.01)] dark:border-white/5 dark:bg-gray-900/20 lg:flex-row lg:items-center lg:justify-between lg:px-4"
>
<div class="min-w-0 flex-1 lg:max-w-sm">
<UInput
v-model="searchQuery"
icon="i-lucide-search"
:placeholder="t('contacts.search')"
size="md"
class="w-full"
/>
</div>
<div class="flex flex-wrap items-center gap-2">
<select
v-model="poolFilter"
class="h-10 cursor-pointer rounded-xl border border-gray-200 bg-white px-3 text-xs font-medium text-gray-700 shadow-sm transition hover:border-gray-300 focus:border-pink-500 focus:outline-none focus:ring-1 focus:ring-pink-500 dark:border-white/5 dark:bg-gray-900/60 dark:text-gray-300 dark:hover:border-white/10"
>
<option value="all">{{ t('contacts.filters.allPools') }}</option>
<option value="friend">{{ t('contacts.filters.friends') }}</option>
<option value="non_friend">{{ t('contacts.filters.nonFriends') }}</option>
</select>
<select
v-model="tierFilter"
class="h-10 cursor-pointer rounded-xl border border-gray-200 bg-white px-3 text-xs font-medium text-gray-700 shadow-sm transition hover:border-gray-300 focus:border-pink-500 focus:outline-none focus:ring-1 focus:ring-pink-500 dark:border-white/5 dark:bg-gray-900/60 dark:text-gray-300 dark:hover:border-white/10"
>
<option value="all">{{ t('contacts.filters.allTiers') }}</option>
<option v-for="tier in tierOrder" :key="tier" :value="tier">{{ tierLabel(tier) }}</option>
</select>
<label
class="flex h-10 cursor-pointer select-none items-center gap-2 rounded-xl border border-gray-200 bg-white px-3 text-xs font-medium text-gray-600 shadow-sm transition hover:bg-gray-50/50 dark:border-white/5 dark:bg-gray-900/60 dark:text-gray-300 dark:hover:border-gray-800/40"
>
<input
v-model="showLowInteraction"
type="checkbox"
class="h-3.5 w-3.5 rounded border-gray-300 text-pink-600 focus:ring-pink-500 dark:border-gray-700 dark:bg-gray-800"
/>
<span>{{ t('contacts.filters.showLow') }}</span>
</label>
</div>
</div>
<!-- 加载骨架 -->
<div
v-if="isLoading"
class="grid gap-6 lg:grid-cols-[minmax(0,1fr)_380px] xl:grid-cols-[minmax(0,1fr)_420px]"
>
<div 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="contact-card-grid">
<div
v-for="i in 4"
:key="i"
class="h-[104px] animate-pulse rounded-2xl bg-gray-100 dark:bg-white/5"
/>
</div>
</div>
</div>
<div class="hidden h-[560px] animate-pulse rounded-3xl bg-gray-50/50 dark:bg-white/5 lg:block" />
</div>
<div
v-else-if="error"
class="rounded-2xl border border-red-200 bg-red-50/50 p-4 text-sm text-red-700 dark:border-red-900/30 dark:bg-red-950/20 dark:text-red-300"
>
{{ error }}
</div>
<!-- 主体网格与详情 -->
<div v-else class="grid min-h-0 gap-6 lg:grid-cols-[minmax(0,1fr)_380px] xl:grid-cols-[minmax(0,1fr)_420px]">
<section class="min-w-0 space-y-8">
<!-- 空状态 -->
<div
v-if="filteredContacts.length === 0"
class="flex flex-col items-center justify-center rounded-3xl border border-dashed border-gray-250 p-16 text-center dark:border-white/5"
>
<div
class="flex h-12 w-12 items-center justify-center rounded-2xl bg-gray-50 text-gray-400 dark:bg-white/5 dark:text-gray-500"
>
<UIcon name="i-lucide-users-2" class="h-6 w-6" />
</div>
<p class="mt-4 text-sm font-medium text-gray-400 dark:text-gray-500">{{ t('contacts.empty') }}</p>
</div>
<!-- 分组卡片列表 -->
<section v-for="group in groupedContacts" :key="group.tier" class="space-y-4">
<div class="flex items-center gap-3">
<span
class="rounded-lg px-2.5 py-1 text-xs font-bold uppercase tracking-wider"
:class="tierClasses(group.tier)"
>
{{ tierLabel(group.tier) }}
</span>
<span class="text-xs font-semibold text-gray-400 dark:text-gray-500">
{{ group.contacts.length }} 人
</span>
<div class="h-px flex-1 bg-gray-200/60 dark:bg-white/5"></div>
</div>
<div class="contact-card-grid">
<button
v-for="contact in group.contacts"
:key="contact.key"
type="button"
class="group relative flex h-[104px] w-full flex-col justify-between rounded-2xl border p-4 text-left transition-all duration-300 hover:-translate-y-0.5 hover:shadow-[0_8px_30px_rgb(0,0,0,0.02)] dark:hover:shadow-[0_8px_30px_rgb(0,0,0,0.2)]"
:class="
selectedContact?.key === contact.key
? 'border-pink-500 bg-pink-500/[0.02] dark:border-pink-500/80 dark:bg-pink-500/[0.01]'
: 'border-gray-200/80 bg-white hover:border-gray-300 dark:border-white/5 dark:bg-gray-900/20 dark:hover:border-white/10'
"
@click="selectContact(contact)"
>
<!-- 选中激活细杠 -->
<div
class="absolute left-0 top-1/4 h-1/2 w-1 rounded-r-full bg-pink-500 transition-all duration-300"
:class="selectedContact?.key === contact.key ? 'scale-100 opacity-100' : 'scale-75 opacity-0'"
></div>
<div class="flex min-w-0 w-full items-start gap-3">
<div class="relative shrink-0 overflow-hidden rounded-xl">
<img
v-if="contact.avatar"
:src="contact.avatar"
:alt="contact.displayName"
class="h-10 w-10 object-cover transition-transform duration-300 group-hover:scale-105"
/>
<div
v-else
class="flex h-10 w-10 items-center justify-center bg-gray-50 text-sm font-bold text-gray-500 transition-transform duration-300 group-hover:scale-105 dark:bg-gray-800/80 dark:text-gray-400"
>
{{ avatarText(contact) }}
</div>
</div>
<div class="min-w-0 flex-1">
<div class="flex min-w-0 items-center gap-1">
<span
class="truncate text-sm font-semibold tracking-tight text-gray-900 dark:text-white leading-tight"
>
{{ contact.displayName }}
</span>
<UIcon
v-if="contact.lockedTier"
name="i-lucide-lock"
class="h-3 w-3 shrink-0 text-pink-500"
/>
</div>
<span class="mt-0.5 block truncate text-[11px] font-medium text-gray-400 dark:text-gray-500">
{{ contact.platform }}
</span>
</div>
<span
class="shrink-0 rounded-lg bg-gray-50 px-2 py-0.5 font-mono text-[11px] font-bold tabular-nums text-gray-500 dark:bg-white/5 dark:text-gray-400"
:class="selectedContact?.key === contact.key ? 'text-pink-600 dark:text-pink-400' : ''"
>
{{ formatScore(contact.score) }}
</span>
</div>
<div
class="flex min-w-0 items-center gap-1.5 text-[11px] font-medium text-gray-400 dark:text-gray-500"
>
<UIcon
:name="contact.pool === 'friend' ? 'i-lucide-message-square' : 'i-lucide-users'"
class="h-3.5 w-3.5 shrink-0 text-gray-300 dark:text-gray-600"
/>
<span v-if="contact.pool === 'friend'" class="min-w-0 truncate">
{{
t('contacts.metrics.privateMessages', {
count: formatCount(contact.scoreBreakdown.privateMessageCount),
})
}}
</span>
<span v-else class="min-w-0 truncate">
{{
t('contacts.metrics.groups', { count: formatCount(contact.scoreBreakdown.commonGroupCount) })
}}
</span>
</div>
</button>
</div>
</section>
</section>
<!-- 右侧详情区 -->
<aside
class="min-h-0 rounded-3xl border border-gray-100/80 bg-white/60 p-6 shadow-sm dark:border-white/5 dark:bg-gray-900/30 backdrop-blur-md lg:sticky lg:top-6 lg:self-start"
>
<template v-if="selectedContact">
<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.platformId }}
</p>
</div>
</div>
<div class="mt-4 flex flex-wrap gap-2">
<span class="rounded-lg px-2.5 py-1 text-xs font-semibold" :class="tierClasses(selectedContact.tier)">
{{ tierLabel(selectedContact.tier) }}
</span>
<span
class="rounded-lg bg-gray-100/80 px-2.5 py-1 text-xs font-semibold text-gray-600 dark:bg-white/5 dark:text-gray-300"
>
{{ selectedContact.pool === 'friend' ? t('contacts.pool.friend') : t('contacts.pool.nonFriend') }}
</span>
</div>
<!-- 双栏卡片(得分、最近互动) -->
<div class="mt-6 grid grid-cols-2 gap-4">
<div
class="rounded-2xl border border-gray-100 bg-white/80 p-4 dark:border-white/5 dark:bg-gray-900/40"
>
<span class="text-[10px] font-bold uppercase tracking-wider text-gray-400 dark:text-gray-500 block">
{{ t('contacts.detail.score') }}
</span>
<span class="mt-2 text-2xl font-bold tracking-tight text-pink-600 dark:text-pink-400 font-mono">
{{ formatScore(selectedContact.score) }}
</span>
</div>
<div
class="rounded-2xl border border-gray-100 bg-white/80 p-4 dark:border-white/5 dark:bg-gray-900/40"
>
<span class="text-[10px] font-bold uppercase tracking-wider text-gray-400 dark:text-gray-500 block">
{{ t('contacts.detail.lastInteraction') }}
</span>
<span class="mt-2.5 block truncate text-xs font-bold text-gray-800 dark:text-gray-200">
{{ formatTime(selectedContact.lastInteractionTs) }}
</span>
</div>
</div>
<!-- 统计数值列表 -->
<div
class="mt-6 rounded-2xl border border-gray-100 bg-white/80 p-4 dark:border-white/5 dark:bg-gray-900/40 space-y-4"
>
<div class="flex items-center justify-between text-xs font-medium">
<div class="flex items-center gap-2 text-gray-500 dark:text-gray-400">
<UIcon name="i-lucide-message-circle" class="h-4 w-4 text-gray-400" />
<span>{{ t('contacts.metrics.privateMessagesLabel') }}</span>
</div>
<span class="font-mono font-bold text-gray-900 dark:text-white">
{{ formatCount(selectedContact.scoreBreakdown.privateMessageCount) }}
</span>
</div>
<div class="h-px bg-gray-50 dark:bg-white/5" />
<div class="flex items-center justify-between text-xs font-medium">
<div class="flex items-center gap-2 text-gray-500 dark:text-gray-400">
<UIcon name="i-lucide-calendar" class="h-4 w-4 text-gray-400" />
<span>{{ t('contacts.metrics.activeMonths') }}</span>
</div>
<span class="font-mono font-bold text-gray-900 dark:text-white">
{{ formatCount(selectedContact.scoreBreakdown.activePrivateMonths) }}
</span>
</div>
<div class="h-px bg-gray-50 dark:bg-white/5" />
<div class="flex items-center justify-between text-xs font-medium">
<div class="flex items-center gap-2 text-gray-500 dark:text-gray-400">
<UIcon name="i-lucide-users-2" class="h-4 w-4 text-gray-400" />
<span>{{ t('contacts.metrics.commonGroups') }}</span>
</div>
<span class="font-mono font-bold text-gray-900 dark:text-white">
{{ formatCount(selectedContact.scoreBreakdown.commonGroupCount) }}
</span>
</div>
<div class="h-px bg-gray-50 dark:bg-white/5" />
<div class="flex items-center justify-between text-xs font-medium">
<div class="flex items-center gap-2 text-gray-500 dark:text-gray-400">
<UIcon name="i-lucide-eye" class="h-4 w-4 text-gray-400" />
<span>{{ t('contacts.metrics.coOccurrence') }}</span>
</div>
<span class="font-mono font-bold text-gray-900 dark:text-white">
{{ formatCount(selectedContact.scoreBreakdown.coOccurrenceCount) }}
</span>
</div>
<div class="h-px bg-gray-50 dark:bg-white/5" />
<div class="flex items-center justify-between text-xs font-medium">
<div class="flex items-center gap-2 text-gray-500 dark:text-gray-400">
<UIcon name="i-lucide-reply" class="h-4 w-4 text-gray-400" />
<span>{{ t('contacts.metrics.replies') }}</span>
</div>
<span class="font-mono font-bold text-gray-900 dark:text-white">
{{ formatCount(selectedContact.scoreBreakdown.replyInteractionCount) }}
</span>
</div>
</div>
<!-- 操作区 -->
<div class="mt-6 flex gap-2">
<UButton
v-if="selectedContact.lockedTier !== 'core'"
icon="i-lucide-lock"
size="md"
color="primary"
variant="soft"
class="flex-1 rounded-xl justify-center h-10"
:loading="pendingOverrideKey === selectedContact.key"
@click="lockAsCore(selectedContact)"
>
{{ t('contacts.actions.lockCore') }}
</UButton>
<UButton
v-else
icon="i-lucide-lock-open"
size="md"
color="neutral"
variant="soft"
class="flex-1 rounded-xl justify-center h-10 border border-gray-200 dark:border-white/5"
:loading="pendingOverrideKey === selectedContact.key"
@click="unlockTier(selectedContact)"
>
{{ t('contacts.actions.unlock') }}
</UButton>
</div>
<!-- 来源会话列表 -->
<div class="mt-8 border-t border-gray-100 pt-6 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 dark:text-gray-500 transition group-hover/item:text-pink-500"
>
{{ source.type === 'private' ? '私聊' : '群聊' }}
<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>
</div>
</template>
</aside>
</div>
</section>
<!-- 功能建设中 Placeholder -->
<section
v-else
class="flex flex-1 flex-col items-center justify-center rounded-3xl border border-dashed border-gray-200 bg-white/40 p-16 text-center dark:border-white/5 dark:bg-gray-900/10 min-h-[400px]"
>
<div
class="flex h-12 w-12 items-center justify-center rounded-2xl bg-gray-50 text-gray-400 dark:bg-white/5 dark:text-gray-500"
>
<UIcon :name="tabs.find((t) => t.id === activeTab)?.icon || 'i-heroicons-beaker'" class="h-6 w-6" />
</div>
<h3 class="mt-4 text-sm font-semibold text-gray-900 dark:text-white">
{{ tabs.find((t) => t.id === activeTab) ? t(tabs.find((t) => t.id === activeTab)!.labelKey) : '' }}
功能建设中
</h3>
<p class="mt-1.5 text-xs text-gray-450 dark:text-gray-500">
该维度的联系人分析与互动透视功能正在积极建设中,敬请期待。
</p>
</section>
</main>
</div>
<UModal v-model:open="showRecomputePrompt" :ui="{ content: 'max-w-md' }">
<template #content>
<div class="p-4">
<h3 class="font-semibold text-gray-900 dark:text-white">{{ t('contacts.stale.title') }}</h3>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">{{ t('contacts.stale.message') }}</p>
<div class="mt-4 flex justify-end gap-2">
<UButton variant="soft" @click="showRecomputePrompt = false">{{ t('contacts.stale.later') }}</UButton>
<UButton color="primary" :loading="isRecomputing" @click="recomputeContacts">
{{ t('contacts.actions.recompute') }}
</UButton>
</div>
</div>
</template>
</UModal>
</div>
</template>
<style scoped>
.contact-card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 228px), 1fr));
gap: 12px;
}
</style>
+6
View File
@@ -25,6 +25,11 @@ export const router = createRouter({
name: 'private-chat',
component: () => import('@/pages/private-chat/index.vue'),
},
{
path: '/contacts',
name: 'contacts',
component: () => import('@/pages/contacts/index.vue'),
},
],
history: createWebHashHistory(),
})
@@ -56,6 +61,7 @@ function preloadCriticalRoutes() {
requestIdleCallback(() => {
import('@/pages/group-chat/index.vue')
import('@/pages/private-chat/index.vue')
import('@/pages/contacts/index.vue')
})
}
+29 -1
View File
@@ -5,7 +5,13 @@
*/
import type { AnalysisSession, MessageType } from '@/types/base'
import type { TimeFilter, ApplyOwnerProfileResult, SetOwnerAndApplyProfileResult } from '@openchatlab/shared-types'
import type {
TimeFilter,
ApplyOwnerProfileResult,
SetOwnerAndApplyProfileResult,
ContactOverridePatch,
ContactsResponse,
} from '@openchatlab/shared-types'
import type {
MemberActivity,
MemberWithStats,
@@ -35,6 +41,7 @@ import type {
} from '@openchatlab/core'
import type {
DataAdapter,
ContactsFetchOptions,
PaginationParams,
PaginatedResult,
SQLResult,
@@ -97,6 +104,27 @@ export class FetchDataAdapter implements DataAdapter {
return result.success
}
// ==================== 联系人 ====================
getContacts(options?: ContactsFetchOptions): Promise<ContactsResponse> {
const params = new URLSearchParams()
if (options?.acceptStale) params.set('acceptStale', '1')
const qs = params.toString()
return get(`/contacts${qs ? `?${qs}` : ''}`)
}
recomputeContacts(): Promise<ContactsResponse> {
return post('/contacts/recompute', {})
}
setContactOverride(key: string, patchBody: ContactOverridePatch): Promise<ContactsResponse> {
return patch(`/contacts/${encodeURIComponent(key)}/override`, patchBody)
}
deleteContactOverride(key: string): Promise<ContactsResponse> {
return del(`/contacts/${encodeURIComponent(key)}/override`)
}
// ==================== 时间范围 ====================
getAvailableYears(sessionId: string): Promise<number[]> {
+20 -1
View File
@@ -6,7 +6,13 @@
*/
import type { AnalysisSession, MessageType } from '@/types/base'
import type { TimeFilter, ApplyOwnerProfileResult, SetOwnerAndApplyProfileResult } from '@openchatlab/shared-types'
import type {
TimeFilter,
ApplyOwnerProfileResult,
SetOwnerAndApplyProfileResult,
ContactOverridePatch,
ContactsResponse,
} from '@openchatlab/shared-types'
import type {
MemberActivity,
MemberWithStats,
@@ -72,6 +78,12 @@ export interface TableSchema {
}>
}
// ==================== Contacts ====================
export interface ContactsFetchOptions {
acceptStale?: boolean
}
// ==================== Mention Graph ====================
export interface MentionGraphData {
@@ -101,6 +113,13 @@ export interface DataAdapter {
setOwnerAndApplyProfile(sessionId: string, ownerPlatformId: string): Promise<SetOwnerAndApplyProfileResult>
dismissOwnerPrompt(sessionId: string): Promise<boolean>
// ==================== 联系人 ====================
getContacts(options?: ContactsFetchOptions): Promise<ContactsResponse>
recomputeContacts(): Promise<ContactsResponse>
setContactOverride(key: string, patch: ContactOverridePatch): Promise<ContactsResponse>
deleteContactOverride(key: string): Promise<ContactsResponse>
// ==================== 时间范围 ====================
getAvailableYears(sessionId: string): Promise<number[]>