task-15(壳层/路由): 实现页面标题与面包屑规则

title-breadcrumb 提供 resolveDocumentTitle('页面 - 数富AI')、updateDocumentTitle
(非浏览器静默)、crumbsForActiveRoute(分组->页面);AdminLayout 用 watch 同步
document.title 并在层级>1 时渲染 el-breadcrumb。
This commit is contained in:
2026-09-05 12:27:25 +08:00
parent 13e7a4c602
commit 83b935c11b
4 changed files with 131 additions and 1 deletions
+12 -1
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { useAdminSessionStore } from '@/stores/admin-session'
@@ -13,6 +13,7 @@ import {
} from '@/layout/empty-state'
import GlobalErrorContainer from '@/layout/GlobalErrorContainer.vue'
import { initialCollapsed, toggleCollapsed, writeCollapsePreference } from '@/layout/sidebar-collapse'
import { crumbsForActiveRoute, resolveDocumentTitle, updateDocumentTitle } from '@/layout/title-breadcrumb'
const route = useRoute()
const router = useRouter()
@@ -40,8 +41,15 @@ const openedKeys = computed(() => groupKeysForActive(menuEntries.value, route.pa
const emptyMenu = computed(() => shouldShowEmptyMenu(menuEntries.value.length > 0, session.loading, session.initialized))
const pageTitle = computed(() => pageTitleOf(route.meta.title))
const userVm = computed(() => topbarUserOf(session.user))
const breadcrumbs = computed(() => crumbsForActiveRoute(session.menuTree, route.path))
const logoUrl = joinAdminPath('assets', 'logo.jpg')
watch(
pageTitle,
(title) => updateDocumentTitle(resolveDocumentTitle(title), typeof document !== 'undefined' ? document : null),
{ immediate: true },
)
async function signOut() {
try {
await session.signOut()
@@ -101,6 +109,9 @@ async function signOut() {
</div>
</header>
<main class="admin-content">
<el-breadcrumb v-if="breadcrumbs.length > 1" class="admin-breadcrumb" separator="/">
<el-breadcrumb-item v-for="crumb in breadcrumbs" :key="crumb.key">{{ crumb.name }}</el-breadcrumb-item>
</el-breadcrumb>
<GlobalErrorContainer />
<el-alert v-if="session.error" :title="session.error" type="error" show-icon :closable="false" />
<el-empty
@@ -0,0 +1,42 @@
import type { AdminMenuNode } from '../types/admin'
export const ADMIN_BRAND = '数富AI'
/** 文档标题规则:`页面标题 - 数富AI`;无页面标题时仅品牌。 */
export function resolveDocumentTitle(pageTitle: string, brand: string = ADMIN_BRAND): string {
const trimmed = (pageTitle || '').trim()
return trimmed ? `${trimmed} - ${brand}` : brand
}
/** 同步 HTML 文档标题;doc 为空(如非浏览器环境)时静默跳过。 */
export function updateDocumentTitle(title: string, doc: { title: string } | null | undefined): void {
if (doc) doc.title = title
}
export interface BreadcrumbItem {
key: string
name: string
route?: string
}
/**
* 面包屑规则:在菜单树中定位激活路由,返回“分组 -> 页面”层级(顶层叶子只返回自身)。
* 无命中返回空数组,不伪造路径。
*/
export function crumbsForActiveRoute(nodes: AdminMenuNode[] | null | undefined, activeRoute: string): BreadcrumbItem[] {
for (const node of nodes || []) {
if (node.route === activeRoute) return [{ key: node.key, name: node.name, route: node.route }]
const children = node.children || []
for (const child of children) {
if (child.route === activeRoute) {
return [
{ key: node.key, name: node.name },
{ key: child.key, name: child.name, route: child.route },
]
}
}
const nested = crumbsForActiveRoute(children, activeRoute)
if (nested.length) return [{ key: node.key, name: node.name }, ...nested]
}
return []
}
+1
View File
@@ -52,6 +52,7 @@ button, input, textarea, select { font: inherit; }
.page-error { color: #b91c1c; }
.not-found { padding: 56px 20px; }
.global-error-stack { display: flex; flex-direction: column; gap: 10px; margin-bottom: 14px; }
.admin-breadcrumb { margin-bottom: 14px; }
@media (max-width: 820px) {
.admin-sidebar { flex-basis: 64px; }