From 4ce3dfd1603ecbd27ece151ca15662851f540671 Mon Sep 17 00:00:00 2001 From: digua Date: Mon, 29 Jun 2026 16:07:51 +0800 Subject: [PATCH] fix(people): reuse header across subpage tabs --- src/App.vue | 4 +- src/pages/people/contacts/index.vue | 611 ++++++++------- src/pages/people/index.vue | 103 ++- src/pages/people/people-page-header.ts | 54 ++ src/pages/people/relationships/index.vue | 902 +++++++++++------------ src/routes/page-transition-key.test.ts | 31 + src/routes/page-transition-key.ts | 22 + 7 files changed, 954 insertions(+), 773 deletions(-) create mode 100644 src/pages/people/people-page-header.ts create mode 100644 src/routes/page-transition-key.test.ts create mode 100644 src/routes/page-transition-key.ts diff --git a/src/App.vue b/src/App.vue index f7620432..41be7a1b 100644 --- a/src/App.vue +++ b/src/App.vue @@ -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(null) const colorMode = useColorMode({ emitAuto: true, @@ -186,7 +188,7 @@ onUnmounted(() => {
- +
diff --git a/src/pages/people/contacts/index.vue b/src/pages/people/contacts/index.vue index 470f9eb2..95d47c84 100644 --- a/src/pages/people/contacts/index.vue +++ b/src/pages/people/contacts/index.vue @@ -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 { diff --git a/src/pages/people/index.vue b/src/pages/people/index.vue index 7c2aa3f3..1fe4276a 100644 --- a/src/pages/people/index.vue +++ b/src/pages/people/index.vue @@ -1,3 +1,104 @@ + + + + diff --git a/src/pages/people/people-page-header.ts b/src/pages/people/people-page-header.ts new file mode 100644 index 00000000..a20d12b4 --- /dev/null +++ b/src/pages/people/people-page-header.ts @@ -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 +} + +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) => void +} + +const PEOPLE_PAGE_HEADER_KEY: InjectionKey = Symbol('PeoplePageHeader') + +export function providePeoplePageHeader(defaultHeader: ComputedRef) { + const registeredHeader = shallowRef | null>(null) + const header = computed(() => registeredHeader.value?.value ?? defaultHeader.value) + + function setHeader(config: ComputedRef) { + registeredHeader.value = config + } + + provide(PEOPLE_PAGE_HEADER_KEY, { setHeader }) + + return { + header, + } +} + +export function usePeoplePageHeader(config: ComputedRef) { + // 父路由持有真实 Header 节点,子页只注册当前页面需要展示的标题、操作和统计信息。 + inject(PEOPLE_PAGE_HEADER_KEY)?.setHeader(config) +} diff --git a/src/pages/people/relationships/index.vue b/src/pages/people/relationships/index.vue index 89f11b8a..3950cadb 100644 --- a/src/pages/people/relationships/index.vue +++ b/src/pages/people/relationships/index.vue @@ -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(() => { diff --git a/src/routes/page-transition-key.test.ts b/src/routes/page-transition-key.test.ts new file mode 100644 index 00000000..06b340d2 --- /dev/null +++ b/src/routes/page-transition-key.test.ts @@ -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' + ) +}) diff --git a/src/routes/page-transition-key.ts b/src/routes/page-transition-key.ts new file mode 100644 index 00000000..142f202b --- /dev/null +++ b/src/routes/page-transition-key.ts @@ -0,0 +1,22 @@ +type RouteParamValue = string | string[] + +interface PageTransitionRoute { + matched: Array<{ path: string }> + params: Record +} + +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 +}