fix(people): reuse header across subpage tabs

This commit is contained in:
digua committed 2026-06-30 14:41:46 +08:00
1 parent eaf46c01f9
commit 4ce3dfd160
7 files changed
+954 -773

No files matched your search

+3 -1
View File
@@ -22,6 +22,7 @@ import { useWindowsTitleBarOverlay } from '@/composables/useWindowsTitleBarOverl
import { configureHttpClient } from '@/services/utils/http'
import { IS_ELECTRON } from '@/utils/platform'
import { usePlatformService } from '@/services'
import { resolvePageTransitionKey } from '@/routes/page-transition-key'
const { t } = useI18n()
@@ -36,6 +37,7 @@ const route = useRoute()
const router = useRouter()
const isLoginPage = computed(() => !IS_ELECTRON && route.name === 'login')
const pageTransitionKey = computed(() => resolvePageTransitionKey(route))
const initError = ref<string | null>(null)
const colorMode = useColorMode({
emitAuto: true,
@@ -186,7 +188,7 @@ onUnmounted(() => {
<main class="relative flex-1 overflow-hidden">
<router-view v-slot="{ Component }">
<Transition name="page-fade" mode="out-in">
<component :is="Component" :key="route.path" />
<component :is="Component" :key="pageTransitionKey" />
</Transition>
</router-view>
</main>
+298 -313
View File
@@ -14,9 +14,8 @@ import type {
import { useDataService } from '@/services'
import { useToast } from '@/composables/useToast'
import { useLayoutStore } from '@/stores/layout'
import PageHeader from '@/components/layout/PageHeader.vue'
import { LoadingState, SubTabs } from '@/components/UI'
import PeopleSubnav from '../components/PeopleSubnav.vue'
import { usePeoplePageHeader } from '../people-page-header'
import ContactDetailPanel from './components/ContactDetailPanel.vue'
import ContactsStatusBlocks from './components/ContactsStatusBlocks.vue'
import {
@@ -381,6 +380,41 @@ async function recomputeContacts() {
}
}
const contactsHeader = computed(() => ({
title: t('layout.relationships'),
description: t('contacts.subtitle', { count: diagnostics.value?.privateSessionCount ?? 0 }),
icon: 'i-lucide-users',
iconClass: 'bg-primary-600 text-white dark:bg-primary-500 dark:text-white shadow-sm',
action: {
label: t('contacts.actions.recompute'),
icon: 'i-lucide-refresh-cw',
loading: isRecomputing.value,
disabled: isTaskRunning.value,
class: 'border border-pink-100 hover:border-pink-200 dark:border-pink-950/30 dark:hover:border-pink-900/50',
onClick: recomputeContacts,
},
stats: [
{
id: 'total',
label: t('contacts.stats.total'),
value: stats.value.total.toLocaleString(),
},
{
id: 'friends',
label: t('contacts.stats.friends'),
value: stats.value.friends.toLocaleString(),
},
{
id: 'nonFriends',
label: t('contacts.stats.nonFriends'),
value: stats.value.nonFriends.toLocaleString(),
dividerBefore: true,
},
],
}))
usePeoplePageHeader(contactsHeader)
function applyContactsPage(
state: ContactsTabState,
next: ContactsResponse,
@@ -821,348 +855,299 @@ function getGroupSectionScrollTop(): number | null {
</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('layout.relationships')"
:description="t('contacts.subtitle', { count: diagnostics?.privateSessionCount ?? 0 })"
size="compact"
icon="i-lucide-users"
icon-class="bg-primary-600 text-white dark:bg-primary-500 dark:text-white shadow-sm"
<div class="flex min-h-0 flex-1 flex-col bg-white text-gray-900 dark:bg-gray-900 dark:text-gray-100">
<SubTabs
v-model="activeContactSection"
:items="contactTabs"
persist-key="contactsTab"
@change="handleContactTabChange"
>
<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"
:disabled="isTaskRunning"
@click="recomputeContacts"
>
{{ t('contacts.actions.recompute') }}
</UButton>
</template>
<div class="mt-3 flex items-center justify-between gap-3 pb-1.5">
<PeopleSubnav active="contacts" />
<!-- 统计指标面板 -->
<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="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>
<template #right>
<div class="flex min-w-0 flex-1 flex-wrap items-center justify-end gap-2 px-3 py-2 lg:px-0 lg:py-0">
<UTabs
v-model="timeRangePreset"
:items="timeRangeTabs"
:content="false"
size="xs"
class="min-w-max gap-0"
:disabled="isTaskRunning"
/>
<UInput
v-model="searchQuery"
icon="i-lucide-search"
:placeholder="t('contacts.search')"
size="sm"
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>
</div>
</PageHeader>
</template>
</SubTabs>
<div class="flex min-h-0 flex-1 flex-col">
<SubTabs
v-model="activeContactSection"
:items="contactTabs"
persist-key="contactsTab"
@change="handleContactTabChange"
>
<template #right>
<div class="flex min-w-0 flex-1 flex-wrap items-center justify-end gap-2 px-3 py-2 lg:px-0 lg:py-0">
<UTabs
v-model="timeRangePreset"
:items="timeRangeTabs"
:content="false"
size="xs"
class="min-w-max gap-0"
:disabled="isTaskRunning"
/>
<UInput
v-model="searchQuery"
icon="i-lucide-search"
:placeholder="t('contacts.search')"
size="sm"
class="w-full sm:w-32"
<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">
<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">
<LoadingState v-if="showLoadingState" :text="loadingStateText" height="py-16" />
<div
v-else-if="pageError"
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"
>
<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>
<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">
<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">
<LoadingState v-if="showLoadingState" :text="loadingStateText" height="py-16" />
{{ pageError }}
</div>
<div v-else class="min-h-0">
<div
v-else-if="pageError"
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"
v-if="showEmptyState"
class="flex flex-col items-center justify-center rounded-3xl border border-dashed border-gray-250 p-16 text-center dark:border-white/5"
>
{{ pageError }}
<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>
<div v-else class="min-h-0">
<div v-else class="min-w-0">
<div
v-if="showEmptyState"
class="flex flex-col items-center justify-center rounded-3xl border border-dashed border-gray-250 p-16 text-center dark:border-white/5"
class="sticky top-0 z-20 overflow-hidden border-b border-gray-100 bg-white dark:border-gray-800/40 dark:bg-gray-900"
>
<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"
class="contact-table-grid min-w-[720px] px-4 py-3 text-[11px] font-bold uppercase tracking-wider text-gray-400 dark:text-gray-500"
:class="contactGridClass(activeContactSection)"
:style="{ transform: `translateX(-${tableScrollLeft}px)` }"
>
<UIcon name="i-lucide-users-2" class="h-6 w-6" />
<span>{{ t('contacts.table.contact') }}</span>
<span>{{ t('contacts.table.status') }}</span>
<template v-if="activeContactSection === 'friend'">
<span>{{ t('contacts.metrics.privateMessagesLabel') }}</span>
<span>{{ t('contacts.metrics.activeMonths') }}</span>
<span>{{ t('contacts.metrics.commonGroups') }}</span>
</template>
<template v-else>
<span>{{ t('contacts.metrics.commonGroups') }}</span>
<span>{{ t('contacts.metrics.coOccurrence') }}</span>
<span>{{ t('contacts.metrics.replies') }}</span>
</template>
<span>{{ t('contacts.metrics.lastInteractionShort') }}</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>
<p class="mt-4 text-sm font-medium text-gray-400 dark:text-gray-500">{{ t('contacts.empty') }}</p>
</div>
<div v-else class="min-w-0">
<div
class="sticky top-0 z-20 overflow-hidden border-b border-gray-100 bg-white dark:border-gray-800/40 dark:bg-gray-900"
>
<div
class="contact-table-grid min-w-[720px] px-4 py-3 text-[11px] font-bold uppercase tracking-wider text-gray-400 dark:text-gray-500"
:class="contactGridClass(activeContactSection)"
:style="{ transform: `translateX(-${tableScrollLeft}px)` }"
>
<span>{{ t('contacts.table.contact') }}</span>
<span>{{ t('contacts.table.status') }}</span>
<template v-if="activeContactSection === 'friend'">
<span>{{ t('contacts.metrics.privateMessagesLabel') }}</span>
<span>{{ t('contacts.metrics.activeMonths') }}</span>
<span>{{ t('contacts.metrics.commonGroups') }}</span>
</template>
<template v-else>
<div ref="tableBodyRef" class="overflow-x-auto scrollbar-hide" @scroll="handleTableHorizontalScroll">
<div class="relative min-w-[720px]" :style="{ height: `${totalSize}px` }">
<template v-for="virtualRow in virtualItems" :key="String(virtualRow.key)">
<div
v-if="rowAt(virtualRow.index).type === 'section' && rowAt(virtualRow.index).pool === 'friend'"
class="absolute left-0 top-0 h-px w-full"
:style="{ transform: `translateY(${virtualRow.start}px)` }"
aria-hidden="true"
></div>
<div
v-else-if="rowAt(virtualRow.index).type === 'section'"
class="contact-table-grid absolute left-0 top-0 w-full border-b border-gray-100 bg-white px-4 py-3 text-[11px] font-bold uppercase tracking-wider text-gray-400 dark:border-gray-800/40 dark:bg-gray-900 dark:text-gray-500"
:class="contactGridClass(rowAt(virtualRow.index).pool)"
:style="{ transform: `translateY(${virtualRow.start}px)` }"
>
<span>{{ t('contacts.table.contact') }}</span>
<span>{{ t('contacts.table.status') }}</span>
<span>{{ t('contacts.metrics.commonGroups') }}</span>
<span>{{ t('contacts.metrics.coOccurrence') }}</span>
<span>{{ t('contacts.metrics.replies') }}</span>
</template>
<span>{{ t('contacts.metrics.lastInteractionShort') }}</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>
<span>{{ t('contacts.metrics.lastInteractionShort') }}</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 ref="tableBodyRef" class="overflow-x-auto scrollbar-hide" @scroll="handleTableHorizontalScroll">
<div class="relative min-w-[720px]" :style="{ height: `${totalSize}px` }">
<template v-for="virtualRow in virtualItems" :key="String(virtualRow.key)">
<button
v-else-if="rowAt(virtualRow.index).type === 'contact'"
type="button"
class="contact-table-grid absolute left-0 top-0 w-full px-4 py-3.5 text-left outline-none transition-colors duration-200 focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-gray-900"
:class="[
contactGridClass(contactRowAt(virtualRow.index).pool),
selectedKey === contactRowAt(virtualRow.index).contact.key
? 'border-b border-primary-500/20 bg-primary-500/[0.03] dark:bg-primary-500/[0.04]'
: 'border-b border-gray-100/50 bg-transparent hover:bg-gray-50/50 dark:border-white/5 dark:hover:bg-gray-800/20',
]"
:style="{ transform: `translateY(${virtualRow.start}px)` }"
:aria-label="
t('contacts.actions.viewDetail', {
name: contactRowAt(virtualRow.index).contact.displayName,
})
"
@click="selectContact(contactRowAt(virtualRow.index).contact)"
>
<div
v-if="rowAt(virtualRow.index).type === 'section' && rowAt(virtualRow.index).pool === 'friend'"
class="absolute left-0 top-0 h-px w-full"
:style="{ transform: `translateY(${virtualRow.start}px)` }"
aria-hidden="true"
class="absolute left-0 top-1/4 h-1/2 w-1 rounded-r-full bg-primary-500 transition-all duration-300"
:class="
selectedKey === contactRowAt(virtualRow.index).contact.key
? 'scale-100 opacity-100'
: 'scale-75 opacity-0'
"
></div>
<div
v-else-if="rowAt(virtualRow.index).type === 'section'"
class="contact-table-grid absolute left-0 top-0 w-full border-b border-gray-100 bg-white px-4 py-3 text-[11px] font-bold uppercase tracking-wider text-gray-400 dark:border-gray-800/40 dark:bg-gray-900 dark:text-gray-500"
:class="contactGridClass(rowAt(virtualRow.index).pool)"
:style="{ transform: `translateY(${virtualRow.start}px)` }"
>
<span>{{ t('contacts.table.contact') }}</span>
<span>{{ t('contacts.table.status') }}</span>
<span>{{ t('contacts.metrics.commonGroups') }}</span>
<span>{{ t('contacts.metrics.coOccurrence') }}</span>
<span>{{ t('contacts.metrics.replies') }}</span>
<span>{{ t('contacts.metrics.lastInteractionShort') }}</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
v-else-if="rowAt(virtualRow.index).type === 'contact'"
type="button"
class="contact-table-grid absolute left-0 top-0 w-full px-4 py-3.5 text-left outline-none transition-colors duration-200 focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-gray-900"
:class="[
contactGridClass(contactRowAt(virtualRow.index).pool),
selectedKey === contactRowAt(virtualRow.index).contact.key
? 'border-b border-primary-500/20 bg-primary-500/[0.03] dark:bg-primary-500/[0.04]'
: 'border-b border-gray-100/50 bg-transparent hover:bg-gray-50/50 dark:border-white/5 dark:hover:bg-gray-800/20',
]"
:style="{ transform: `translateY(${virtualRow.start}px)` }"
:aria-label="
t('contacts.actions.viewDetail', {
name: contactRowAt(virtualRow.index).contact.displayName,
})
"
@click="selectContact(contactRowAt(virtualRow.index).contact)"
>
<div
class="absolute left-0 top-1/4 h-1/2 w-1 rounded-r-full bg-primary-500 transition-all duration-300"
:class="
selectedKey === contactRowAt(virtualRow.index).contact.key
? 'scale-100 opacity-100'
: 'scale-75 opacity-0'
"
></div>
<div class="group flex min-w-0 w-full items-center gap-3">
<div class="relative shrink-0 overflow-hidden rounded-lg">
<img
v-if="contactRowAt(virtualRow.index).contact.avatar"
:src="contactRowAt(virtualRow.index).contact.avatar ?? ''"
:alt="contactRowAt(virtualRow.index).contact.displayName"
loading="lazy"
decoding="async"
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-gradient-to-br from-gray-50 to-gray-100 text-sm font-bold text-gray-500 transition-transform duration-300 group-hover:scale-105 dark:from-gray-800 dark:to-gray-800/60 dark:text-gray-400"
>
{{ avatarText(contactRowAt(virtualRow.index).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 leading-tight tracking-tight text-gray-800 transition-colors group-hover:text-primary-600 group-focus-visible:text-primary-600 dark:text-gray-200 dark:group-hover:text-primary-400 dark:group-focus-visible:text-primary-400"
:class="
selectedKey === contactRowAt(virtualRow.index).contact.key
? 'text-primary-600 dark:text-primary-400'
: ''
"
>
{{ contactRowAt(virtualRow.index).contact.displayName }}
</span>
</div>
<span
class="mt-1 inline-flex items-center rounded bg-gray-50 px-1.5 py-0.5 font-mono text-[9px] font-semibold uppercase tracking-wider text-gray-400 dark:bg-white/5 dark:text-gray-500"
>
{{ contactRowAt(virtualRow.index).contact.platform }}
</span>
<div class="group flex min-w-0 w-full items-center gap-3">
<div class="relative shrink-0 overflow-hidden rounded-lg">
<img
v-if="contactRowAt(virtualRow.index).contact.avatar"
:src="contactRowAt(virtualRow.index).contact.avatar ?? ''"
:alt="contactRowAt(virtualRow.index).contact.displayName"
loading="lazy"
decoding="async"
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-gradient-to-br from-gray-50 to-gray-100 text-sm font-bold text-gray-500 transition-transform duration-300 group-hover:scale-105 dark:from-gray-800 dark:to-gray-800/60 dark:text-gray-400"
>
{{ avatarText(contactRowAt(virtualRow.index).contact) }}
</div>
</div>
<span
class="inline-flex w-fit justify-self-start rounded-lg px-2.5 py-1 text-xs font-semibold"
:class="contactBadgeClasses(contactRowAt(virtualRow.index).contact)"
>
{{ contactBadgeLabel(contactRowAt(virtualRow.index).contact) }}
</span>
<span class="contact-table-value">
{{
contactFirstMetric(
contactRowAt(virtualRow.index).pool,
contactRowAt(virtualRow.index).contact
)
}}
</span>
<span class="contact-table-value">
{{
contactSecondMetric(
contactRowAt(virtualRow.index).pool,
contactRowAt(virtualRow.index).contact
)
}}
</span>
<span class="contact-table-value">
{{
contactThirdMetric(
contactRowAt(virtualRow.index).pool,
contactRowAt(virtualRow.index).contact
)
}}
</span>
<span class="contact-table-value">
{{ formatTime(contactRowAt(virtualRow.index).contact.lastInteractionTs) }}
</span>
<span
class="inline-flex h-7 w-12 items-center justify-center justify-self-end rounded-lg font-mono text-xs font-bold tabular-nums transition-colors duration-200"
:class="
selectedKey === contactRowAt(virtualRow.index).contact.key
? 'bg-primary-500 text-white'
: 'bg-primary-50 text-primary-600 dark:bg-primary-500/10 dark:text-primary-400'
"
>
{{ formatContactScore(contactRowAt(virtualRow.index).contact) }}
</span>
</button>
<div
v-else
class="absolute left-0 top-0 flex h-11 w-full items-center justify-center gap-2 border-b border-gray-100/50 text-xs font-semibold text-gray-500 dark:border-white/5 dark:text-gray-400"
:style="{ transform: `translateY(${virtualRow.start}px)` }"
>
<UIcon name="i-lucide-loader-2" class="h-4 w-4 animate-spin" />
<span>{{ t('common.loading') }}</span>
<div class="min-w-0 flex-1">
<div class="flex min-w-0 items-center gap-1">
<span
class="truncate text-sm font-semibold leading-tight tracking-tight text-gray-800 transition-colors group-hover:text-primary-600 group-focus-visible:text-primary-600 dark:text-gray-200 dark:group-hover:text-primary-400 dark:group-focus-visible:text-primary-400"
:class="
selectedKey === contactRowAt(virtualRow.index).contact.key
? 'text-primary-600 dark:text-primary-400'
: ''
"
>
{{ contactRowAt(virtualRow.index).contact.displayName }}
</span>
</div>
<span
class="mt-1 inline-flex items-center rounded bg-gray-50 px-1.5 py-0.5 font-mono text-[9px] font-semibold uppercase tracking-wider text-gray-400 dark:bg-white/5 dark:text-gray-500"
>
{{ contactRowAt(virtualRow.index).contact.platform }}
</span>
</div>
</div>
</template>
</div>
<span
class="inline-flex w-fit justify-self-start rounded-lg px-2.5 py-1 text-xs font-semibold"
:class="contactBadgeClasses(contactRowAt(virtualRow.index).contact)"
>
{{ contactBadgeLabel(contactRowAt(virtualRow.index).contact) }}
</span>
<span class="contact-table-value">
{{
contactFirstMetric(
contactRowAt(virtualRow.index).pool,
contactRowAt(virtualRow.index).contact
)
}}
</span>
<span class="contact-table-value">
{{
contactSecondMetric(
contactRowAt(virtualRow.index).pool,
contactRowAt(virtualRow.index).contact
)
}}
</span>
<span class="contact-table-value">
{{
contactThirdMetric(
contactRowAt(virtualRow.index).pool,
contactRowAt(virtualRow.index).contact
)
}}
</span>
<span class="contact-table-value">
{{ formatTime(contactRowAt(virtualRow.index).contact.lastInteractionTs) }}
</span>
<span
class="inline-flex h-7 w-12 items-center justify-center justify-self-end rounded-lg font-mono text-xs font-bold tabular-nums transition-colors duration-200"
:class="
selectedKey === contactRowAt(virtualRow.index).contact.key
? 'bg-primary-500 text-white'
: 'bg-primary-50 text-primary-600 dark:bg-primary-500/10 dark:text-primary-400'
"
>
{{ formatContactScore(contactRowAt(virtualRow.index).contact) }}
</span>
</button>
<div
v-else
class="absolute left-0 top-0 flex h-11 w-full items-center justify-center gap-2 border-b border-gray-100/50 text-xs font-semibold text-gray-500 dark:border-white/5 dark:text-gray-400"
:style="{ transform: `translateY(${virtualRow.start}px)` }"
>
<UIcon name="i-lucide-loader-2" class="h-4 w-4 animate-spin" />
<span>{{ t('common.loading') }}</span>
</div>
</template>
</div>
</div>
</div>
</section>
</div>
</main>
</div>
</section>
</div>
</main>
<ContactDetailPanel
:selected-key="selectedKey"
:contact="selectedContact"
:is-loading="isDetailLoading"
:is-friend-action-loading="isFriendActionSaving"
@clear="clearSelectedContact"
@open-source="openSourceSession"
@view-source-records="viewSourceSessionRecords"
@mark-friend="markSelectedContactAsFriend"
@unmark-friend="unmarkSelectedContactAsFriend"
/>
</div>
<ContactDetailPanel
:selected-key="selectedKey"
:contact="selectedContact"
:is-loading="isDetailLoading"
:is-friend-action-loading="isFriendActionSaving"
@clear="clearSelectedContact"
@open-source="openSourceSession"
@view-source-records="viewSourceSessionRecords"
@mark-friend="markSelectedContactAsFriend"
@unmark-friend="unmarkSelectedContactAsFriend"
/>
</div>
</div>
</template>
+102 -1
View File
@@ -1,3 +1,104 @@
<script setup lang="ts">
import { computed } from 'vue'
import { RouterView, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import PageHeader from '@/components/layout/PageHeader.vue'
import PeopleSubnav from './components/PeopleSubnav.vue'
import { providePeoplePageHeader, type PeoplePageHeaderConfig, type PeopleSubpage } from './people-page-header'
const { t } = useI18n()
const route = useRoute()
const activeSubpage = computed<PeopleSubpage>(() =>
route.name === 'people-relationships' ? 'relationships' : 'contacts'
)
const defaultHeader = computed<PeoplePageHeaderConfig>(() =>
activeSubpage.value === 'relationships'
? {
title: t('layout.relationships'),
description: t('relationships.subtitle'),
icon: 'i-lucide-git-fork',
iconClass: 'bg-sky-600 text-white dark:bg-sky-500 dark:text-white shadow-sm',
}
: {
title: t('layout.relationships'),
description: t('contacts.subtitle', { count: 0 }),
icon: 'i-lucide-users',
iconClass: 'bg-primary-600 text-white dark:bg-primary-500 dark:text-white shadow-sm',
}
)
const { header } = providePeoplePageHeader(defaultHeader)
</script>
<template>
<RouterView />
<div
class="flex h-full flex-col bg-white text-gray-900 dark:bg-gray-950 dark:text-gray-100"
style="padding-top: var(--titlebar-area-height)"
>
<PageHeader
:title="header.title"
:description="header.description"
size="compact"
:icon="header.icon"
:icon-class="header.iconClass"
>
<template #actions>
<UButton
v-if="header.action"
:icon="header.action.icon"
color="primary"
variant="soft"
size="sm"
class="rounded-xl"
:class="header.action.class"
:loading="header.action.loading"
:disabled="header.action.disabled"
@click="header.action.onClick"
>
{{ header.action.label }}
</UButton>
</template>
<div class="mt-3 flex items-center justify-between gap-3 pb-1.5">
<PeopleSubnav :active="activeSubpage" />
<div v-if="header.stats?.length" class="hidden items-center gap-5 text-[11px] sm:flex">
<template v-for="stat in header.stats" :key="stat.id">
<div v-if="stat.dividerBefore" 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">{{ stat.label }}</span>
<span class="font-mono font-bold text-gray-900 dark:text-white">{{ stat.value }}</span>
</div>
</template>
</div>
</div>
</PageHeader>
<RouterView v-slot="{ Component }">
<Transition name="people-tab-slide" mode="out-in">
<component :is="Component" :key="activeSubpage" />
</Transition>
</RouterView>
</div>
</template>
<style scoped>
.people-tab-slide-enter-active,
.people-tab-slide-leave-active {
transition:
opacity 0.2s ease,
transform 0.2s ease;
}
.people-tab-slide-enter-from {
opacity: 0;
transform: translateY(10px);
}
.people-tab-slide-leave-to {
opacity: 0;
transform: translateY(-10px);
}
</style>
+54
View File
@@ -0,0 +1,54 @@
import { computed, inject, provide, shallowRef, type ComputedRef, type InjectionKey } from 'vue'
export type PeopleSubpage = 'contacts' | 'relationships'
export interface PeoplePageHeaderAction {
label: string
icon: string
loading: boolean
disabled: boolean
class?: string
onClick: () => void | Promise<void>
}
export interface PeoplePageHeaderStat {
id: string
label: string
value: string | number
dividerBefore?: boolean
}
export interface PeoplePageHeaderConfig {
title: string
description?: string
icon: string
iconClass: string
action?: PeoplePageHeaderAction | null
stats?: PeoplePageHeaderStat[]
}
interface PeoplePageHeaderContext {
setHeader: (config: ComputedRef<PeoplePageHeaderConfig>) => void
}
const PEOPLE_PAGE_HEADER_KEY: InjectionKey<PeoplePageHeaderContext> = Symbol('PeoplePageHeader')
export function providePeoplePageHeader(defaultHeader: ComputedRef<PeoplePageHeaderConfig>) {
const registeredHeader = shallowRef<ComputedRef<PeoplePageHeaderConfig> | null>(null)
const header = computed(() => registeredHeader.value?.value ?? defaultHeader.value)
function setHeader(config: ComputedRef<PeoplePageHeaderConfig>) {
registeredHeader.value = config
}
provide(PEOPLE_PAGE_HEADER_KEY, { setHeader })
return {
header,
}
}
export function usePeoplePageHeader(config: ComputedRef<PeoplePageHeaderConfig>) {
// 父路由持有真实 Header 节点,子页只注册当前页面需要展示的标题、操作和统计信息。
inject(PEOPLE_PAGE_HEADER_KEY)?.setHeader(config)
}
+444 -458
View File
@@ -14,10 +14,9 @@ import type {
} from '@openchatlab/shared-types'
import { useDataService } from '@/services'
import { useToast } from '@/composables/useToast'
import PageHeader from '@/components/layout/PageHeader.vue'
import { LoadingState } from '@/components/UI'
import LazyAvatar from '@/components/common/avatar/LazyAvatar.vue'
import PeopleSubnav from '../components/PeopleSubnav.vue'
import { usePeoplePageHeader } from '../people-page-header'
import { buildRelationshipConnectionRanking } from './relationship-galaxy-connections'
import { shouldShowFocusConnectionsAction } from './relationship-galaxy-state'
import RelationshipGalaxyCanvas from './components/RelationshipGalaxyCanvas.vue'
@@ -260,6 +259,41 @@ async function recomputeRelationships() {
}
}
const relationshipsHeader = computed(() => ({
title: t('layout.relationships'),
description: t('relationships.subtitle'),
icon: 'i-lucide-git-fork',
iconClass: 'bg-sky-600 text-white dark:bg-sky-500 dark:text-white shadow-sm',
action: {
label: t('relationships.actions.recompute'),
icon: 'i-lucide-refresh-cw',
loading: isRecomputing.value,
disabled: isTaskRunning.value,
class: 'border border-sky-100 hover:border-sky-200 dark:border-sky-950/30 dark:hover:border-sky-900/50',
onClick: recomputeRelationships,
},
stats: [
{
id: 'nodes',
label: t('relationships.stats.nodes'),
value: formatNumber(stats.value.nodes),
},
{
id: 'edges',
label: t('relationships.stats.edges'),
value: formatNumber(stats.value.edges),
},
{
id: 'communities',
label: t('relationships.stats.communities'),
value: formatNumber(stats.value.communities),
dividerBefore: true,
},
],
}))
usePeoplePageHeader(relationshipsHeader)
async function loadNeighborhood(key: string) {
const requestId = neighborhoodRequestId.value + 1
neighborhoodRequestId.value = requestId
@@ -387,500 +421,452 @@ onBeforeUnmount(() => {
</script>
<template>
<div
class="flex h-full flex-col bg-white text-gray-900 dark:bg-gray-950 dark:text-gray-100"
style="padding-top: var(--titlebar-area-height)"
>
<PageHeader
:title="t('layout.relationships')"
:description="t('relationships.subtitle')"
size="compact"
icon="i-lucide-git-fork"
icon-class="bg-sky-600 text-white dark:bg-sky-500 dark:text-white shadow-sm"
>
<template #actions>
<div class="flex min-h-0 flex-1 overflow-hidden bg-white text-gray-900 dark:bg-gray-950 dark:text-gray-100">
<main class="relative min-h-0 min-w-0 flex-1 overflow-hidden bg-[#05070d]">
<RelationshipGalaxyThreeCanvas
v-if="viewMode === '3d'"
ref="canvasRef"
:graph="activeGraph"
:selected-key="selectedKey"
:privacy-mode="privacyMode"
:label="t('relationships.canvas.label3d')"
:owner-label="t('relationships.owner.me')"
@fallback="handleThreeCanvasFallback"
@select-node="selectNode"
/>
<RelationshipGalaxyCanvas
v-else
ref="canvasRef"
:graph="activeGraph"
:selected-key="selectedKey"
:privacy-mode="privacyMode"
:label="t('relationships.canvas.label')"
:owner-label="t('relationships.owner.me')"
@select-node="selectNode"
/>
<div class="absolute left-4 top-4 z-20 flex max-w-[calc(100%-2rem)] flex-wrap items-center gap-2">
<UTabs v-model="timeRangePreset" :items="timeRangeTabs" :content="false" size="xs" class="min-w-max gap-0" />
<UTabs v-model="graphScope" :items="graphScopeTabs" :content="false" size="xs" class="min-w-max gap-0" />
<UTabs v-model="viewMode" :items="viewModeTabs" :content="false" size="xs" class="min-w-max gap-0" />
<UButton
icon="i-lucide-refresh-cw"
color="primary"
icon="i-lucide-scan-line"
color="neutral"
variant="soft"
size="sm"
class="rounded-xl border border-sky-100 hover:border-sky-200 dark:border-sky-950/30 dark:hover:border-sky-900/50"
:loading="isRecomputing"
:disabled="isTaskRunning"
@click="recomputeRelationships"
size="xs"
:aria-label="t('relationships.actions.fitView')"
@click="fitCanvas"
/>
<UButton
:icon="privacyMode ? 'i-lucide-eye-off' : 'i-lucide-eye'"
color="neutral"
variant="soft"
size="xs"
@click="privacyMode = !privacyMode"
>
{{ t('relationships.actions.recompute') }}
{{ t('relationships.privacy') }}
</UButton>
<UButton
v-if="isNeighborhoodMode"
icon="i-lucide-undo-2"
color="neutral"
variant="soft"
size="xs"
@click="backToPanorama"
>
{{ t('relationships.actions.backToPanorama') }}
</UButton>
</template>
<div class="mt-3 flex items-center justify-between gap-3 pb-1.5">
<PeopleSubnav active="relationships" />
<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('relationships.stats.nodes') }}</span>
<span class="font-mono font-bold text-gray-900 dark:text-white">{{ formatNumber(stats.nodes) }}</span>
</div>
<div class="flex items-center gap-1.5">
<span class="text-gray-400 dark:text-gray-500">{{ t('relationships.stats.edges') }}</span>
<span class="font-mono font-bold text-gray-900 dark:text-white">{{ formatNumber(stats.edges) }}</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('relationships.stats.communities') }}</span>
<span class="font-mono font-bold text-gray-900 dark:text-white">{{ formatNumber(stats.communities) }}</span>
</div>
</div>
</div>
</PageHeader>
<div class="flex min-h-0 flex-1 overflow-hidden">
<main class="relative min-h-0 min-w-0 flex-1 overflow-hidden bg-[#05070d]">
<RelationshipGalaxyThreeCanvas
v-if="viewMode === '3d'"
ref="canvasRef"
:graph="activeGraph"
:selected-key="selectedKey"
:privacy-mode="privacyMode"
:label="t('relationships.canvas.label3d')"
:owner-label="t('relationships.owner.me')"
@fallback="handleThreeCanvasFallback"
@select-node="selectNode"
/>
<RelationshipGalaxyCanvas
v-else
ref="canvasRef"
:graph="activeGraph"
:selected-key="selectedKey"
:privacy-mode="privacyMode"
:label="t('relationships.canvas.label')"
:owner-label="t('relationships.owner.me')"
@select-node="selectNode"
/>
<div class="absolute left-4 top-4 z-20 flex max-w-[calc(100%-2rem)] flex-wrap items-center gap-2">
<UTabs v-model="timeRangePreset" :items="timeRangeTabs" :content="false" size="xs" class="min-w-max gap-0" />
<UTabs v-model="graphScope" :items="graphScopeTabs" :content="false" size="xs" class="min-w-max gap-0" />
<UTabs v-model="viewMode" :items="viewModeTabs" :content="false" size="xs" class="min-w-max gap-0" />
<UButton
icon="i-lucide-scan-line"
color="neutral"
variant="soft"
size="xs"
:aria-label="t('relationships.actions.fitView')"
@click="fitCanvas"
/>
<UButton
:icon="privacyMode ? 'i-lucide-eye-off' : 'i-lucide-eye'"
color="neutral"
variant="soft"
size="xs"
@click="privacyMode = !privacyMode"
>
{{ t('relationships.privacy') }}
</UButton>
<UButton
v-if="isNeighborhoodMode"
icon="i-lucide-undo-2"
color="neutral"
variant="soft"
size="xs"
@click="backToPanorama"
>
{{ t('relationships.actions.backToPanorama') }}
</UButton>
</div>
<div
v-if="showUpdatingBanner"
class="absolute bottom-6 left-1/2 z-20 flex max-w-[min(560px,calc(100%-2rem))] -translate-x-1/2 items-center gap-2.5 rounded-2xl border border-sky-500/20 bg-[#090d16]/85 px-4 py-2.5 text-center text-xs font-semibold text-sky-200 shadow-2xl shadow-sky-950/30 backdrop-blur-md animate-fade-in"
>
<span class="i-lucide-refresh-cw h-3.5 w-3.5 animate-spin text-sky-400"></span>
<span>{{ statusText }}</span>
</div>
<LoadingState
v-if="showInitialLoading"
variant="overlay"
:text="statusText || t('relationships.task.updating')"
/>
<div
v-else-if="!hasGraph"
class="absolute inset-0 flex items-center justify-center text-sm font-medium text-gray-400"
>
{{ loadError || t('relationships.empty') }}
</div>
</main>
<aside
class="flex w-[340px] shrink-0 flex-col border-l border-gray-250/70 bg-white/80 backdrop-blur-md dark:border-white/10 dark:bg-gray-950/80"
<div
v-if="showUpdatingBanner"
class="absolute bottom-6 left-1/2 z-20 flex max-w-[min(560px,calc(100%-2rem))] -translate-x-1/2 items-center gap-2.5 rounded-2xl border border-sky-500/20 bg-[#090d16]/85 px-4 py-2.5 text-center text-xs font-semibold text-sky-200 shadow-2xl shadow-sky-950/30 backdrop-blur-md animate-fade-in"
>
<div class="border-b border-gray-200/80 p-4 dark:border-white/10">
<UInput
v-model="searchQuery"
icon="i-lucide-search"
:placeholder="t('relationships.search')"
size="sm"
class="w-full"
>
<template v-if="searchQuery" #trailing>
<UButton
icon="i-heroicons-x-mark"
variant="link"
color="neutral"
size="xs"
:aria-label="t('relationships.actions.clearSearch')"
@click="clearSearch"
<span class="i-lucide-refresh-cw h-3.5 w-3.5 animate-spin text-sky-400"></span>
<span>{{ statusText }}</span>
</div>
<LoadingState
v-if="showInitialLoading"
variant="overlay"
:text="statusText || t('relationships.task.updating')"
/>
<div
v-else-if="!hasGraph"
class="absolute inset-0 flex items-center justify-center text-sm font-medium text-gray-400"
>
{{ loadError || t('relationships.empty') }}
</div>
</main>
<aside
class="flex w-[340px] shrink-0 flex-col border-l border-gray-250/70 bg-white/80 backdrop-blur-md dark:border-white/10 dark:bg-gray-950/80"
>
<div class="border-b border-gray-200/80 p-4 dark:border-white/10">
<UInput
v-model="searchQuery"
icon="i-lucide-search"
:placeholder="t('relationships.search')"
size="sm"
class="w-full"
>
<template v-if="searchQuery" #trailing>
<UButton
icon="i-heroicons-x-mark"
variant="link"
color="neutral"
size="xs"
:aria-label="t('relationships.actions.clearSearch')"
@click="clearSearch"
/>
</template>
</UInput>
</div>
<div class="min-h-0 flex-1 overflow-y-auto px-4 py-4 scrollbar-thin">
<section v-if="searchResults.length > 0" class="mb-6">
<h2 class="mb-2 text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
{{ t('relationships.searchResults.title') }}
</h2>
<div class="space-y-1">
<button
v-for="result in searchResults"
:key="result.key"
type="button"
class="group flex w-full items-center gap-2.5 rounded-xl px-2.5 py-2 text-left transition hover:bg-gray-100/50 dark:hover:bg-white/5"
:class="selectedKey === result.key ? 'bg-sky-50 dark:bg-sky-500/10' : ''"
:disabled="isLoadingNeighborhood"
@click="selectSearchResult(result)"
>
<LazyAvatar
:src="result.avatar"
:alt="displayName(result)"
:text="avatarText(result)"
root-class="h-8 w-8 shrink-0 shadow-sm border border-gray-250/20 dark:border-white/10"
image-class="h-8 w-8 rounded-full object-cover"
fallback-class="flex h-8 w-8 items-center justify-center rounded-full bg-sky-100 text-[10px] font-bold text-sky-700 dark:bg-sky-500/15 dark:text-sky-200"
/>
</template>
</UInput>
</div>
<div class="min-h-0 flex-1 overflow-y-auto px-4 py-4 scrollbar-thin">
<section v-if="searchResults.length > 0" class="mb-6">
<h2 class="mb-2 text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
{{ t('relationships.searchResults.title') }}
</h2>
<div class="space-y-1">
<button
v-for="result in searchResults"
:key="result.key"
type="button"
class="group flex w-full items-center gap-2.5 rounded-xl px-2.5 py-2 text-left transition hover:bg-gray-100/50 dark:hover:bg-white/5"
:class="selectedKey === result.key ? 'bg-sky-50 dark:bg-sky-500/10' : ''"
:disabled="isLoadingNeighborhood"
@click="selectSearchResult(result)"
>
<LazyAvatar
:src="result.avatar"
:alt="displayName(result)"
:text="avatarText(result)"
root-class="h-8 w-8 shrink-0 shadow-sm border border-gray-250/20 dark:border-white/10"
image-class="h-8 w-8 rounded-full object-cover"
fallback-class="flex h-8 w-8 items-center justify-center rounded-full bg-sky-100 text-[10px] font-bold text-sky-700 dark:bg-sky-500/15 dark:text-sky-200"
/>
<span class="min-w-0 flex-1">
<span
class="block truncate text-sm font-semibold text-gray-900 dark:text-white group-hover:text-sky-600 dark:group-hover:text-sky-400 transition-colors"
>
{{ displayName(result) }}
</span>
<span class="block truncate text-[11px] text-gray-500 dark:text-gray-400">
{{ poolLabel(result) }} · #{{ result.rank }}
</span>
</span>
<span class="min-w-0 flex-1">
<span
v-if="!result.inCoreGraph"
class="text-[10px] font-semibold text-sky-600 dark:text-sky-400 bg-sky-100/60 dark:bg-sky-500/15 px-1.5 py-0.5 rounded-md shrink-0"
class="block truncate text-sm font-semibold text-gray-900 dark:text-white group-hover:text-sky-600 dark:group-hover:text-sky-400 transition-colors"
>
{{ t('relationships.searchResults.offCore') }}
{{ displayName(result) }}
</span>
</button>
<span class="block truncate text-[11px] text-gray-500 dark:text-gray-400">
{{ poolLabel(result) }} · #{{ result.rank }}
</span>
</span>
<span
v-if="!result.inCoreGraph"
class="text-[10px] font-semibold text-sky-600 dark:text-sky-400 bg-sky-100/60 dark:bg-sky-500/15 px-1.5 py-0.5 rounded-md shrink-0"
>
{{ t('relationships.searchResults.offCore') }}
</span>
</button>
</div>
</section>
<section class="mb-6">
<h2 class="mb-3 text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
{{ t('relationships.detail.title') }}
</h2>
<div v-if="selectedNode" class="space-y-4">
<div class="flex items-center gap-3">
<LazyAvatar
:src="selectedNode.avatar"
:alt="displayName(selectedNode)"
:text="avatarText(selectedNode)"
root-class="h-11 w-11 shrink-0 shadow-sm border border-gray-250/20 dark:border-white/10"
image-class="h-11 w-11 rounded-full object-cover"
fallback-class="flex h-11 w-11 items-center justify-center rounded-full bg-sky-100 text-sm font-bold text-sky-700 dark:bg-sky-500/15 dark:text-sky-200"
/>
<div class="min-w-0 flex-1">
<p class="truncate text-base font-bold text-gray-900 dark:text-white">
{{ displayName(selectedNode) }}
</p>
<p class="truncate text-xs text-gray-500 dark:text-gray-400">
{{ selectedNode.platform }} · {{ selectedNode.platformId }}
</p>
</div>
</div>
</section>
<section class="mb-6">
<h2 class="mb-3 text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
{{ t('relationships.detail.title') }}
</h2>
<UButton
v-if="showFocusConnectionsAction"
icon="i-lucide-network"
color="primary"
variant="soft"
size="sm"
block
class="rounded-xl font-semibold shadow-sm border border-sky-100 hover:border-sky-200 dark:border-sky-950/30 dark:hover:border-sky-900/50"
:loading="isLoadingNeighborhood"
@click="focusSelectedConnections"
>
{{ t('relationships.actions.focusConnections') }}
</UButton>
<div v-if="selectedNode" class="space-y-4">
<div class="flex items-center gap-3">
<LazyAvatar
:src="selectedNode.avatar"
:alt="displayName(selectedNode)"
:text="avatarText(selectedNode)"
root-class="h-11 w-11 shrink-0 shadow-sm border border-gray-250/20 dark:border-white/10"
image-class="h-11 w-11 rounded-full object-cover"
fallback-class="flex h-11 w-11 items-center justify-center rounded-full bg-sky-100 text-sm font-bold text-sky-700 dark:bg-sky-500/15 dark:text-sky-200"
/>
<div class="min-w-0 flex-1">
<p class="truncate text-base font-bold text-gray-900 dark:text-white">
{{ displayName(selectedNode) }}
</p>
<p class="truncate text-xs text-gray-500 dark:text-gray-400">
{{ selectedNode.platform }} · {{ selectedNode.platformId }}
</p>
<div class="grid grid-cols-2 gap-2 text-sm">
<div
class="rounded-xl border border-gray-100/80 bg-gray-50/50 p-3 dark:border-white/5 dark:bg-white/3 flex flex-col justify-between"
>
<p class="text-[11px] font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-1">
{{ t('relationships.detail.rank') }}
</p>
<div class="flex items-center gap-1.5">
<p class="font-mono font-bold text-lg text-gray-900 dark:text-white">#{{ selectedNode.rank }}</p>
<span
v-if="selectedNode.rank <= 3"
class="flex h-2 w-2 rounded-full bg-amber-500 animate-pulse shrink-0"
></span>
</div>
</div>
<UButton
v-if="showFocusConnectionsAction"
icon="i-lucide-network"
color="primary"
variant="soft"
size="sm"
block
class="rounded-xl font-semibold shadow-sm border border-sky-100 hover:border-sky-200 dark:border-sky-950/30 dark:hover:border-sky-900/50"
:loading="isLoadingNeighborhood"
@click="focusSelectedConnections"
<div
class="rounded-xl border border-gray-100/80 bg-gray-50/50 p-3 dark:border-white/5 dark:bg-white/3 flex flex-col justify-between"
>
{{ t('relationships.actions.focusConnections') }}
</UButton>
<div class="grid grid-cols-2 gap-2 text-sm">
<div
class="rounded-xl border border-gray-100/80 bg-gray-50/50 p-3 dark:border-white/5 dark:bg-white/3 flex flex-col justify-between"
>
<p class="text-[11px] font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-1">
{{ t('relationships.detail.rank') }}
<p class="text-[11px] font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-1">
{{ t('relationships.detail.score') }}
</p>
<div class="flex items-baseline gap-1">
<p class="font-mono font-bold text-lg text-sky-600 dark:text-sky-400">
{{ formatScore(selectedNode.score) }}
</p>
<div class="flex items-center gap-1.5">
<p class="font-mono font-bold text-lg text-gray-900 dark:text-white">#{{ selectedNode.rank }}</p>
<span
v-if="selectedNode.rank <= 3"
class="flex h-2 w-2 rounded-full bg-amber-500 animate-pulse shrink-0"
></span>
</div>
<span class="text-[9px] text-gray-400">/100</span>
</div>
<div
class="rounded-xl border border-gray-100/80 bg-gray-50/50 p-3 dark:border-white/5 dark:bg-white/3 flex flex-col justify-between"
>
<p class="text-[11px] font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-1">
{{ t('relationships.detail.score') }}
</p>
<div class="flex items-baseline gap-1">
<p class="font-mono font-bold text-lg text-sky-600 dark:text-sky-400">
{{ formatScore(selectedNode.score) }}
</p>
<span class="text-[9px] text-gray-400">/100</span>
</div>
<div class="mt-1.5 h-1 w-full rounded-full bg-gray-200 dark:bg-white/10 overflow-hidden">
<div
class="h-full rounded-full bg-gradient-to-r from-sky-400 to-sky-600 dark:from-sky-500 dark:to-sky-300"
:style="{ width: `${formatScore(selectedNode.score)}%` }"
></div>
</div>
<div class="mt-1.5 h-1 w-full rounded-full bg-gray-200 dark:bg-white/10 overflow-hidden">
<div
class="h-full rounded-full bg-gradient-to-r from-sky-400 to-sky-600 dark:from-sky-500 dark:to-sky-300"
:style="{ width: `${formatScore(selectedNode.score)}%` }"
></div>
</div>
</div>
<div class="rounded-xl border border-gray-100/80 bg-gray-50/50 p-3 dark:border-white/5 dark:bg-white/3">
<p class="text-[11px] font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-1.5">
{{ t('relationships.detail.type') }}
</p>
<div class="rounded-xl border border-gray-100/80 bg-gray-50/50 p-3 dark:border-white/5 dark:bg-white/3">
<p class="text-[11px] font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-1.5">
{{ t('relationships.detail.type') }}
</p>
<span
class="inline-flex items-center rounded-md px-1.5 py-0.5 text-xs font-semibold"
:class="
selectedNode.pool === 'friend'
? 'bg-sky-50 text-sky-700 dark:bg-sky-500/10 dark:text-sky-300'
: 'bg-amber-50 text-amber-700 dark:bg-amber-500/10 dark:text-amber-300'
"
>
{{ poolLabel(selectedNode) }}
</span>
</div>
<div
class="rounded-xl border border-gray-100/80 bg-gray-50/50 p-3 dark:border-white/5 dark:bg-white/3 flex flex-col justify-between"
>
<p class="text-[11px] font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-1">
{{ t('relationships.detail.community') }}
</p>
<div class="flex items-center gap-1.5 min-w-0">
<span
class="inline-flex items-center rounded-md px-1.5 py-0.5 text-xs font-semibold"
class="h-2.5 w-2.5 rounded-full shrink-0 shadow-sm"
:style="{ backgroundColor: getCommunityColor(selectedNode.communityId) }"
></span>
<span class="font-mono text-sm font-semibold text-gray-900 dark:text-white truncate">
{{ selectedNode.communityId }}
</span>
</div>
</div>
</div>
<div
class="space-y-2.5 rounded-xl border border-gray-100/80 bg-gray-50/30 p-3.5 dark:border-white/5 dark:bg-white/2"
>
<div
class="flex items-center justify-between text-xs py-0.5 border-b border-gray-150/40 dark:border-white/5 pb-2"
>
<span class="flex items-center gap-1.5 text-gray-500 dark:text-gray-400">
<span class="i-lucide-message-square h-3.5 w-3.5 text-gray-400"></span>
{{ t('relationships.detail.privateMessages') }}
</span>
<span class="font-mono font-bold text-gray-900 dark:text-white">
{{ formatNumber(selectedNode.privateMessageCount) }}
</span>
</div>
<div
class="flex items-center justify-between text-xs py-0.5 border-b border-gray-150/40 dark:border-white/5 pb-2"
>
<span class="flex items-center gap-1.5 text-gray-500 dark:text-gray-400">
<span class="i-lucide-messages-square h-3.5 w-3.5 text-gray-400"></span>
{{ t('relationships.detail.groupMessages') }}
</span>
<span class="font-mono font-bold text-gray-900 dark:text-white">
{{ formatNumber(selectedNode.groupMessageCount) }}
</span>
</div>
<div
class="flex items-center justify-between text-xs py-0.5 border-b border-gray-150/40 dark:border-white/5 pb-2"
>
<span class="flex items-center gap-1.5 text-gray-500 dark:text-gray-400">
<span class="i-lucide-users-round h-3.5 w-3.5 text-gray-400"></span>
{{ t('relationships.detail.commonGroups') }}
</span>
<span class="font-mono font-bold text-gray-900 dark:text-white">
{{ formatNumber(selectedNode.commonGroupCount) }}
</span>
</div>
<div class="flex items-center justify-between text-xs py-0.5 pb-0">
<span class="flex items-center gap-1.5 text-gray-500 dark:text-gray-400">
<span class="i-lucide-clock h-3.5 w-3.5 text-gray-400"></span>
{{ t('relationships.detail.lastInteraction') }}
</span>
<span class="font-semibold text-gray-900 dark:text-white">
{{ formatTime(selectedNode.lastInteractionTs) }}
</span>
</div>
</div>
<section
class="rounded-xl border border-gray-200/80 bg-gray-50/50 p-3 dark:border-white/5 dark:bg-white/3 shadow-sm"
>
<div class="mb-3 flex items-center justify-between gap-3 px-1">
<h3 class="text-xs font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">
{{ t('relationships.connections.title') }}
</h3>
<span
class="font-mono text-xs font-bold text-gray-400 dark:text-gray-500 bg-gray-200/60 dark:bg-white/5 px-1.5 py-0.5 rounded"
>
{{ formatNumber(connectionRanking.total) }}
</span>
</div>
<div v-if="connectionRanking.items.length > 0" class="space-y-1">
<button
v-for="(item, index) in connectionRanking.items"
:key="item.node.key"
type="button"
class="group flex w-full items-center gap-2.5 rounded-xl px-2.5 py-2 text-left transition-all duration-250 hover:bg-white dark:hover:bg-white/5"
@click="selectNode(item.node)"
>
<!-- Top 1-3 序号圆圈高亮,其他序号保持低调 -->
<span
class="flex h-5 w-5 shrink-0 items-center justify-center rounded-full font-mono text-[10px] font-bold"
:class="
selectedNode.pool === 'friend'
? 'bg-sky-50 text-sky-700 dark:bg-sky-500/10 dark:text-sky-300'
: 'bg-amber-50 text-amber-700 dark:bg-amber-500/10 dark:text-amber-300'
index === 0
? 'bg-amber-100 text-amber-700 dark:bg-amber-500/20 dark:text-amber-300'
: index === 1
? 'bg-slate-200 text-slate-700 dark:bg-slate-300/20 dark:text-slate-300'
: index === 2
? 'bg-amber-700/10 text-amber-800 dark:bg-amber-700/20 dark:text-amber-400'
: 'text-gray-400 dark:text-gray-500'
"
>
{{ poolLabel(selectedNode) }}
{{ index + 1 }}
</span>
</div>
<div
class="rounded-xl border border-gray-100/80 bg-gray-50/50 p-3 dark:border-white/5 dark:bg-white/3 flex flex-col justify-between"
>
<p class="text-[11px] font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-1">
{{ t('relationships.detail.community') }}
</p>
<div class="flex items-center gap-1.5 min-w-0">
<LazyAvatar
:src="item.node.avatar"
:alt="displayName(item.node)"
:text="avatarText(item.node)"
root-class="h-7 w-7 shrink-0 shadow-sm border border-gray-250/20 dark:border-white/10"
image-class="h-7 w-7 rounded-full object-cover"
fallback-class="flex h-7 w-7 items-center justify-center rounded-full bg-sky-100 text-[10px] font-bold text-sky-700 dark:bg-sky-500/15 dark:text-sky-200"
/>
<div class="min-w-0 flex-1 space-y-0.5">
<span
class="h-2.5 w-2.5 rounded-full shrink-0 shadow-sm"
:style="{ backgroundColor: getCommunityColor(selectedNode.communityId) }"
></span>
<span class="font-mono text-sm font-semibold text-gray-900 dark:text-white truncate">
{{ selectedNode.communityId }}
</span>
</div>
</div>
</div>
<div
class="space-y-2.5 rounded-xl border border-gray-100/80 bg-gray-50/30 p-3.5 dark:border-white/5 dark:bg-white/2"
>
<div
class="flex items-center justify-between text-xs py-0.5 border-b border-gray-150/40 dark:border-white/5 pb-2"
>
<span class="flex items-center gap-1.5 text-gray-500 dark:text-gray-400">
<span class="i-lucide-message-square h-3.5 w-3.5 text-gray-400"></span>
{{ t('relationships.detail.privateMessages') }}
</span>
<span class="font-mono font-bold text-gray-900 dark:text-white">
{{ formatNumber(selectedNode.privateMessageCount) }}
</span>
</div>
<div
class="flex items-center justify-between text-xs py-0.5 border-b border-gray-150/40 dark:border-white/5 pb-2"
>
<span class="flex items-center gap-1.5 text-gray-500 dark:text-gray-400">
<span class="i-lucide-messages-square h-3.5 w-3.5 text-gray-400"></span>
{{ t('relationships.detail.groupMessages') }}
</span>
<span class="font-mono font-bold text-gray-900 dark:text-white">
{{ formatNumber(selectedNode.groupMessageCount) }}
</span>
</div>
<div
class="flex items-center justify-between text-xs py-0.5 border-b border-gray-150/40 dark:border-white/5 pb-2"
>
<span class="flex items-center gap-1.5 text-gray-500 dark:text-gray-400">
<span class="i-lucide-users-round h-3.5 w-3.5 text-gray-400"></span>
{{ t('relationships.detail.commonGroups') }}
</span>
<span class="font-mono font-bold text-gray-900 dark:text-white">
{{ formatNumber(selectedNode.commonGroupCount) }}
</span>
</div>
<div class="flex items-center justify-between text-xs py-0.5 pb-0">
<span class="flex items-center gap-1.5 text-gray-500 dark:text-gray-400">
<span class="i-lucide-clock h-3.5 w-3.5 text-gray-400"></span>
{{ t('relationships.detail.lastInteraction') }}
</span>
<span class="font-semibold text-gray-900 dark:text-white">
{{ formatTime(selectedNode.lastInteractionTs) }}
</span>
</div>
</div>
<section
class="rounded-xl border border-gray-200/80 bg-gray-50/50 p-3 dark:border-white/5 dark:bg-white/3 shadow-sm"
>
<div class="mb-3 flex items-center justify-between gap-3 px-1">
<h3 class="text-xs font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">
{{ t('relationships.connections.title') }}
</h3>
<span
class="font-mono text-xs font-bold text-gray-400 dark:text-gray-500 bg-gray-200/60 dark:bg-white/5 px-1.5 py-0.5 rounded"
>
{{ formatNumber(connectionRanking.total) }}
</span>
</div>
<div v-if="connectionRanking.items.length > 0" class="space-y-1">
<button
v-for="(item, index) in connectionRanking.items"
:key="item.node.key"
type="button"
class="group flex w-full items-center gap-2.5 rounded-xl px-2.5 py-2 text-left transition-all duration-250 hover:bg-white dark:hover:bg-white/5"
@click="selectNode(item.node)"
>
<!-- Top 1-3 序号圆圈高亮,其他序号保持低调 -->
<span
class="flex h-5 w-5 shrink-0 items-center justify-center rounded-full font-mono text-[10px] font-bold"
:class="
index === 0
? 'bg-amber-100 text-amber-700 dark:bg-amber-500/20 dark:text-amber-300'
: index === 1
? 'bg-slate-200 text-slate-700 dark:bg-slate-300/20 dark:text-slate-300'
: index === 2
? 'bg-amber-700/10 text-amber-800 dark:bg-amber-700/20 dark:text-amber-400'
: 'text-gray-400 dark:text-gray-500'
"
class="block truncate text-sm font-semibold text-gray-900 dark:text-white group-hover:text-sky-600 dark:group-hover:text-sky-400 transition-colors"
>
{{ index + 1 }}
{{ displayName(item.node) }}
</span>
<LazyAvatar
:src="item.node.avatar"
:alt="displayName(item.node)"
:text="avatarText(item.node)"
root-class="h-7 w-7 shrink-0 shadow-sm border border-gray-250/20 dark:border-white/10"
image-class="h-7 w-7 rounded-full object-cover"
fallback-class="flex h-7 w-7 items-center justify-center rounded-full bg-sky-100 text-[10px] font-bold text-sky-700 dark:bg-sky-500/15 dark:text-sky-200"
/>
<div class="min-w-0 flex-1 space-y-0.5">
<span
class="block truncate text-sm font-semibold text-gray-900 dark:text-white group-hover:text-sky-600 dark:group-hover:text-sky-400 transition-colors"
>
{{ displayName(item.node) }}
</span>
<!-- 迷你引力强度进度条 -->
<div class="flex items-center gap-1.5">
<div class="h-0.5 w-10 rounded-full bg-gray-200 dark:bg-white/10 overflow-hidden shrink-0">
<div
class="h-full rounded-full bg-sky-500/80 dark:bg-sky-400/80"
:style="{ width: `${formatScore(item.edge.weight)}%` }"
></div>
</div>
<span class="text-[9px] font-medium text-gray-400 dark:text-gray-500 shrink-0">
{{ t('relationships.connections.weight', { value: formatScore(item.edge.weight) }) }}
</span>
<!-- 迷你引力强度进度条 -->
<div class="flex items-center gap-1.5">
<div class="h-0.5 w-10 rounded-full bg-gray-200 dark:bg-white/10 overflow-hidden shrink-0">
<div
class="h-full rounded-full bg-sky-500/80 dark:bg-sky-400/80"
:style="{ width: `${formatScore(item.edge.weight)}%` }"
></div>
</div>
</div>
<div class="flex shrink-0 items-center gap-1">
<span class="text-[11px] font-semibold text-gray-800 dark:text-gray-300">
{{
t('relationships.connections.replies', {
count: formatNumber(item.edge.replyInteractionCount),
})
}}
<span class="text-[9px] font-medium text-gray-400 dark:text-gray-500 shrink-0">
{{ t('relationships.connections.weight', { value: formatScore(item.edge.weight) }) }}
</span>
<span
class="i-lucide-chevron-right h-3.5 w-3.5 opacity-0 group-hover:opacity-100 transition-all duration-200 text-gray-400 -translate-x-1 group-hover:translate-x-0"
></span>
</div>
</button>
</div>
<UButton
v-if="connectionRanking.hasMore || isConnectionRankingExpanded"
color="neutral"
variant="ghost"
size="xs"
block
class="mt-2 rounded-lg font-medium hover:bg-gray-150/40 dark:hover:bg-white/5"
@click="isConnectionRankingExpanded = !isConnectionRankingExpanded"
>
{{
isConnectionRankingExpanded
? t('relationships.connections.showLess')
: t('relationships.connections.showMore', {
count: formatNumber(connectionRanking.total - connectionRanking.items.length),
})
}}
</UButton>
</div>
<div class="flex shrink-0 items-center gap-1">
<span class="text-[11px] font-semibold text-gray-800 dark:text-gray-300">
{{
t('relationships.connections.replies', {
count: formatNumber(item.edge.replyInteractionCount),
})
}}
</span>
<span
class="i-lucide-chevron-right h-3.5 w-3.5 opacity-0 group-hover:opacity-100 transition-all duration-200 text-gray-400 -translate-x-1 group-hover:translate-x-0"
></span>
</div>
</button>
<p v-else class="py-4 text-center text-xs text-gray-400 dark:text-gray-500">
{{ t('relationships.connections.empty') }}
</p>
</section>
</div>
<div
v-else
class="rounded-xl border border-dashed border-gray-200 px-3 py-10 text-center dark:border-white/10 bg-gray-50/20 dark:bg-white/2"
>
<p class="text-sm font-semibold text-gray-400 dark:text-gray-500">
{{ t('relationships.detail.emptyTitle') }}
</p>
</div>
</section>
<section v-if="topCommunities.length > 0">
<h2 class="mb-3 text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
{{ t('relationships.stats.communities') }}
</h2>
<div
class="space-y-2 rounded-xl border border-gray-100/80 bg-gray-50/20 p-3 dark:border-white/5 dark:bg-white/2"
>
<div
v-for="community in topCommunities"
:key="community.id"
class="flex items-center justify-between gap-3 text-sm py-1 border-b border-gray-150/20 dark:border-white/5 last:border-b-0 last:pb-0"
>
<div class="flex min-w-0 items-center gap-2">
<span
class="h-2.5 w-2.5 shrink-0 rounded-full shadow-sm"
:style="{ backgroundColor: community.color }"
></span>
<span class="truncate font-semibold text-gray-700 dark:text-gray-200">{{ community.label }}</span>
</div>
<span
class="font-mono text-[10px] font-bold text-gray-500 dark:text-gray-400 bg-gray-100 dark:bg-white/5 px-1.5 py-0.5 rounded shadow-sm shrink-0"
<UButton
v-if="connectionRanking.hasMore || isConnectionRankingExpanded"
color="neutral"
variant="ghost"
size="xs"
block
class="mt-2 rounded-lg font-medium hover:bg-gray-150/40 dark:hover:bg-white/5"
@click="isConnectionRankingExpanded = !isConnectionRankingExpanded"
>
{{ formatNumber(community.size) }}
</span>
{{
isConnectionRankingExpanded
? t('relationships.connections.showLess')
: t('relationships.connections.showMore', {
count: formatNumber(connectionRanking.total - connectionRanking.items.length),
})
}}
</UButton>
</div>
<p v-else class="py-4 text-center text-xs text-gray-400 dark:text-gray-500">
{{ t('relationships.connections.empty') }}
</p>
</section>
</div>
<div
v-else
class="rounded-xl border border-dashed border-gray-200 px-3 py-10 text-center dark:border-white/10 bg-gray-50/20 dark:bg-white/2"
>
<p class="text-sm font-semibold text-gray-400 dark:text-gray-500">
{{ t('relationships.detail.emptyTitle') }}
</p>
</div>
</section>
<section v-if="topCommunities.length > 0">
<h2 class="mb-3 text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
{{ t('relationships.stats.communities') }}
</h2>
<div
class="space-y-2 rounded-xl border border-gray-100/80 bg-gray-50/20 p-3 dark:border-white/5 dark:bg-white/2"
>
<div
v-for="community in topCommunities"
:key="community.id"
class="flex items-center justify-between gap-3 text-sm py-1 border-b border-gray-150/20 dark:border-white/5 last:border-b-0 last:pb-0"
>
<div class="flex min-w-0 items-center gap-2">
<span
class="h-2.5 w-2.5 shrink-0 rounded-full shadow-sm"
:style="{ backgroundColor: community.color }"
></span>
<span class="truncate font-semibold text-gray-700 dark:text-gray-200">{{ community.label }}</span>
</div>
<span
class="font-mono text-[10px] font-bold text-gray-500 dark:text-gray-400 bg-gray-100 dark:bg-white/5 px-1.5 py-0.5 rounded shadow-sm shrink-0"
>
{{ formatNumber(community.size) }}
</span>
</div>
</section>
</div>
</aside>
</div>
</div>
</section>
</div>
</aside>
</div>
</template>
+31
View File
@@ -0,0 +1,31 @@
/**
* Run: pnpm test -- src/routes/page-transition-key.test.ts
*/
import assert from 'node:assert/strict'
import test from 'node:test'
import { resolvePageTransitionKey } from './page-transition-key'
test('keeps people child routes under one page transition key', () => {
const contactsKey = resolvePageTransitionKey({
matched: [{ path: '/people' }, { path: 'contacts' }],
params: {},
})
const relationshipsKey = resolvePageTransitionKey({
matched: [{ path: '/people' }, { path: 'relationships' }],
params: {},
})
assert.equal(contactsKey, '/people')
assert.equal(relationshipsKey, '/people')
})
test('keeps dynamic session id in page transition key', () => {
assert.equal(
resolvePageTransitionKey({
matched: [{ path: '/private-chat/:id' }],
params: { id: 'session-a' },
}),
'/private-chat/:id:session-a'
)
})
+22
View File
@@ -0,0 +1,22 @@
type RouteParamValue = string | string[]
interface PageTransitionRoute {
matched: Array<{ path: string }>
params: Record<string, RouteParamValue>
}
function formatRouteParam(value: RouteParamValue): string {
return Array.isArray(value) ? value.join(',') : value
}
export function resolvePageTransitionKey(route: PageTransitionRoute): string {
const topLevelPath = route.matched[0]?.path ?? ''
if (!topLevelPath) return ''
const dynamicParams = Object.entries(route.params)
.filter(([key]) => topLevelPath.includes(`:${key}`))
.sort(([left], [right]) => left.localeCompare(right))
.map(([, value]) => formatRouteParam(value))
return dynamicParams.length > 0 ? `${topLevelPath}:${dynamicParams.join('&')}` : topLevelPath
}