task-15(壳层/路由): 实现页面标题与面包屑规则
title-breadcrumb 提供 resolveDocumentTitle('页面 - 数富AI')、updateDocumentTitle
(非浏览器静默)、crumbsForActiveRoute(分组->页面);AdminLayout 用 watch 同步
document.title 并在层级>1 时渲染 el-breadcrumb。
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { useAdminSessionStore } from '@/stores/admin-session'
|
import { useAdminSessionStore } from '@/stores/admin-session'
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
} from '@/layout/empty-state'
|
} from '@/layout/empty-state'
|
||||||
import GlobalErrorContainer from '@/layout/GlobalErrorContainer.vue'
|
import GlobalErrorContainer from '@/layout/GlobalErrorContainer.vue'
|
||||||
import { initialCollapsed, toggleCollapsed, writeCollapsePreference } from '@/layout/sidebar-collapse'
|
import { initialCollapsed, toggleCollapsed, writeCollapsePreference } from '@/layout/sidebar-collapse'
|
||||||
|
import { crumbsForActiveRoute, resolveDocumentTitle, updateDocumentTitle } from '@/layout/title-breadcrumb'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
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 emptyMenu = computed(() => shouldShowEmptyMenu(menuEntries.value.length > 0, session.loading, session.initialized))
|
||||||
const pageTitle = computed(() => pageTitleOf(route.meta.title))
|
const pageTitle = computed(() => pageTitleOf(route.meta.title))
|
||||||
const userVm = computed(() => topbarUserOf(session.user))
|
const userVm = computed(() => topbarUserOf(session.user))
|
||||||
|
const breadcrumbs = computed(() => crumbsForActiveRoute(session.menuTree, route.path))
|
||||||
const logoUrl = joinAdminPath('assets', 'logo.jpg')
|
const logoUrl = joinAdminPath('assets', 'logo.jpg')
|
||||||
|
|
||||||
|
watch(
|
||||||
|
pageTitle,
|
||||||
|
(title) => updateDocumentTitle(resolveDocumentTitle(title), typeof document !== 'undefined' ? document : null),
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
async function signOut() {
|
async function signOut() {
|
||||||
try {
|
try {
|
||||||
await session.signOut()
|
await session.signOut()
|
||||||
@@ -101,6 +109,9 @@ async function signOut() {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main class="admin-content">
|
<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 />
|
<GlobalErrorContainer />
|
||||||
<el-alert v-if="session.error" :title="session.error" type="error" show-icon :closable="false" />
|
<el-alert v-if="session.error" :title="session.error" type="error" show-icon :closable="false" />
|
||||||
<el-empty
|
<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 []
|
||||||
|
}
|
||||||
@@ -52,6 +52,7 @@ button, input, textarea, select { font: inherit; }
|
|||||||
.page-error { color: #b91c1c; }
|
.page-error { color: #b91c1c; }
|
||||||
.not-found { padding: 56px 20px; }
|
.not-found { padding: 56px 20px; }
|
||||||
.global-error-stack { display: flex; flex-direction: column; gap: 10px; margin-bottom: 14px; }
|
.global-error-stack { display: flex; flex-direction: column; gap: 10px; margin-bottom: 14px; }
|
||||||
|
.admin-breadcrumb { margin-bottom: 14px; }
|
||||||
|
|
||||||
@media (max-width: 820px) {
|
@media (max-width: 820px) {
|
||||||
.admin-sidebar { flex-basis: 64px; }
|
.admin-sidebar { flex-basis: 64px; }
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { readSource } from './helpers.ts'
|
||||||
|
import {
|
||||||
|
ADMIN_BRAND,
|
||||||
|
crumbsForActiveRoute,
|
||||||
|
resolveDocumentTitle,
|
||||||
|
updateDocumentTitle,
|
||||||
|
} from '../src/layout/title-breadcrumb.ts'
|
||||||
|
import type { AdminMenuNode } from '../src/types/admin.ts'
|
||||||
|
|
||||||
|
function node(partial: Partial<AdminMenuNode> & { key: string; name: string }): AdminMenuNode {
|
||||||
|
return { key: partial.key, name: partial.name, route: partial.route, children: partial.children }
|
||||||
|
}
|
||||||
|
|
||||||
|
const TREE: AdminMenuNode[] = [
|
||||||
|
node({ key: 'account', name: '账号权限', children: [
|
||||||
|
node({ key: 'admin_users', name: '用户管理', route: '/account/users' }),
|
||||||
|
] }),
|
||||||
|
node({ key: 'top', name: '顶级页', route: '/top' }),
|
||||||
|
]
|
||||||
|
|
||||||
|
test('test_task_015_page_title_breadcrumb_normal_primary_path', () => {
|
||||||
|
// 正常主路径:文档标题 = 页面标题 - 品牌。
|
||||||
|
assert.equal(resolveDocumentTitle('用户管理'), '用户管理 - 数富AI')
|
||||||
|
assert.equal(ADMIN_BRAND, '数富AI')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_task_015_page_title_breadcrumb_normal_variant_input', () => {
|
||||||
|
// 正常变体:分组内命中时返回 分组->页面 两级面包屑。
|
||||||
|
const crumbs = crumbsForActiveRoute(TREE, '/account/users')
|
||||||
|
assert.deepEqual(crumbs.map((c) => c.name), ['账号权限', '用户管理'])
|
||||||
|
assert.equal(crumbs[1].route, '/account/users')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_task_015_page_title_breadcrumb_normal_repeated_operation_is_idempotent', () => {
|
||||||
|
// 正常重复:标题与面包屑解析稳定。
|
||||||
|
assert.equal(resolveDocumentTitle('菜单管理'), resolveDocumentTitle('菜单管理'))
|
||||||
|
assert.deepEqual(crumbsForActiveRoute(TREE, '/account/users'), crumbsForActiveRoute(TREE, '/account/users'))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_task_015_page_title_breadcrumb_boundary_empty_input', () => {
|
||||||
|
// 边界空值:无页面标题时文档标题仅品牌;空树/空激活路径返回空面包屑。
|
||||||
|
assert.equal(resolveDocumentTitle(''), ADMIN_BRAND)
|
||||||
|
assert.equal(resolveDocumentTitle(' '), ADMIN_BRAND)
|
||||||
|
assert.deepEqual(crumbsForActiveRoute([], '/x'), [])
|
||||||
|
assert.deepEqual(crumbsForActiveRoute(undefined, '/x'), [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_task_015_page_title_breadcrumb_boundary_single_item', () => {
|
||||||
|
// 边界单元素:顶级叶子页命中返回自身单级面包屑。
|
||||||
|
const crumbs = crumbsForActiveRoute(TREE, '/top')
|
||||||
|
assert.equal(crumbs.length, 1)
|
||||||
|
assert.equal(crumbs[0].name, '顶级页')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_task_015_page_title_breadcrumb_boundary_limit_or_missing_field', () => {
|
||||||
|
// 边界上限/缺字段:激活路径不在菜单树中时返回空,不伪造父级。
|
||||||
|
assert.deepEqual(crumbsForActiveRoute(TREE, '/no/such/page'), [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_task_015_page_title_breadcrumb_invalid_input_rejected', () => {
|
||||||
|
// 异常输入:非法页面标题按空处理;doc 缺省时更新静默失败不抛异常。
|
||||||
|
assert.equal(resolveDocumentTitle(null as unknown as string), ADMIN_BRAND)
|
||||||
|
assert.doesNotThrow(() => updateDocumentTitle('标题', null))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_task_015_page_title_breadcrumb_dependency_failure_returns_actionable_message', () => {
|
||||||
|
// 依赖失败:壳层接入文档标题同步与面包屑渲染。
|
||||||
|
const layout = readSource('src/layout/AdminLayout.vue')
|
||||||
|
assert.match(layout, /updateDocumentTitle/)
|
||||||
|
assert.match(layout, /resolveDocumentTitle/)
|
||||||
|
assert.match(layout, /crumbsForActiveRoute/)
|
||||||
|
assert.match(layout, /el-breadcrumb/)
|
||||||
|
assert.match(layout, /breadcrumbs\.length > 1/)
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user