Przeglądaj źródła

feat(admin): unify header navigation and sidebar controls

彭峰 1 miesiąc temu
rodzic
commit
b9071cd72b

+ 23 - 23
projects/xiaoshu-admin/src/app/admin.routes.ts

@@ -3,36 +3,36 @@ import { adminGuard, operatorManagerGuard, superAdminGuard } from './admin.guard
 
 const operationsChildren: Routes = [
   { path: '', pathMatch: 'full', redirectTo: 'dashboard' },
-  { path: 'dashboard', loadComponent: () => import('./pages/admin-dashboard.component').then((module) => module.AdminDashboardComponent) },
+  { path: 'dashboard', data: { title: '工作台', parentUrl: null }, loadComponent: () => import('./pages/admin-dashboard.component').then((module) => module.AdminDashboardComponent) },
   { path: 'users', pathMatch: 'full', redirectTo: 'members' },
   { path: 'users/:objectId', redirectTo: 'members/:objectId' },
-  { path: 'members', loadComponent: () => import('./pages/operations-users.component').then((module) => module.OperationsUsersComponent) },
-  { path: 'members/:objectId', loadComponent: () => import('./pages/operations-user-detail.component').then((module) => module.OperationsUserDetailComponent) },
-  { path: 'stores', loadComponent: () => import('./pages/operations-stores.component').then((module) => module.OperationsStoresComponent) },
-  { path: 'stores/:objectId', loadComponent: () => import('./pages/operations-store-detail.component').then((module) => module.OperationsStoreDetailComponent) },
-  { path: 'schedule', loadComponent: () => import('./pages/operations-schedule.component').then((module) => module.OperationsScheduleComponent) },
-  { path: 'learning-reports', loadComponent: () => import('./pages/operations-learning-reports.component').then((module) => module.OperationsLearningReportsComponent) },
+  { path: 'members', data: { title: '会员管理', parentUrl: '/admin/dashboard' }, loadComponent: () => import('./pages/operations-users.component').then((module) => module.OperationsUsersComponent) },
+  { path: 'members/:objectId', data: { title: '会员详情', parentUrl: '/admin/members', parentTitle: '会员管理' }, loadComponent: () => import('./pages/operations-user-detail.component').then((module) => module.OperationsUserDetailComponent) },
+  { path: 'stores', data: { title: '门店管理', parentUrl: '/admin/dashboard' }, loadComponent: () => import('./pages/operations-stores.component').then((module) => module.OperationsStoresComponent) },
+  { path: 'stores/:objectId', data: { title: '门店详情', parentUrl: '/admin/stores', parentTitle: '门店管理' }, loadComponent: () => import('./pages/operations-store-detail.component').then((module) => module.OperationsStoreDetailComponent) },
+  { path: 'schedule', data: { title: '排课中心', parentUrl: '/admin/dashboard' }, loadComponent: () => import('./pages/operations-schedule.component').then((module) => module.OperationsScheduleComponent) },
+  { path: 'learning-reports', data: { title: '学习报表', parentUrl: '/admin/dashboard' }, loadComponent: () => import('./pages/operations-learning-reports.component').then((module) => module.OperationsLearningReportsComponent) },
   { path: 'appointments', pathMatch: 'full', redirectTo: 'schedule' },
-  { path: 'coaches', loadComponent: () => import('./pages/operations-coaches.component').then((module) => module.OperationsCoachesComponent) },
-  { path: 'coaches/:objectId', loadComponent: () => import('./pages/operations-coach-detail.component').then((module) => module.OperationsCoachDetailComponent) },
-  { path: 'payroll', loadComponent: () => import('./pages/operations-payroll.component').then((module) => module.OperationsPayrollComponent) },
-  { path: 'vocabulary', loadComponent: () => import('./pages/operations-vocabulary.component').then((module) => module.OperationsVocabularyComponent) },
-  { path: 'special-training', loadComponent: () => import('./pages/operations-special-training.component').then((module) => module.OperationsSpecialTrainingComponent) },
-  { path: 'operator-accounts', canActivate: [operatorManagerGuard], loadComponent: () => import('./pages/operator-accounts.component').then(module => module.OperatorAccountsComponent) },
-  { path: 'change-password', loadComponent: () => import('./pages/admin-change-password.component').then((module) => module.AdminChangePasswordComponent) },
-  { path: 'forbidden', loadComponent: () => import('./pages/admin-forbidden.component').then((module) => module.AdminForbiddenComponent) },
+  { path: 'coaches', data: { title: '陪练老师', parentUrl: '/admin/dashboard' }, loadComponent: () => import('./pages/operations-coaches.component').then((module) => module.OperationsCoachesComponent) },
+  { path: 'coaches/:objectId', data: { title: '老师详情', parentUrl: '/admin/coaches', parentTitle: '陪练老师' }, loadComponent: () => import('./pages/operations-coach-detail.component').then((module) => module.OperationsCoachDetailComponent) },
+  { path: 'payroll', data: { title: '工资结算', parentUrl: '/admin/dashboard' }, loadComponent: () => import('./pages/operations-payroll.component').then((module) => module.OperationsPayrollComponent) },
+  { path: 'vocabulary', data: { title: '词库管理', parentUrl: '/admin/dashboard' }, loadComponent: () => import('./pages/operations-vocabulary.component').then((module) => module.OperationsVocabularyComponent) },
+  { path: 'special-training', data: { title: '专项训练', parentUrl: '/admin/dashboard' }, loadComponent: () => import('./pages/operations-special-training.component').then((module) => module.OperationsSpecialTrainingComponent) },
+  { path: 'operator-accounts', data: { title: '运营账号管理', parentUrl: '/admin/dashboard' }, canActivate: [operatorManagerGuard], loadComponent: () => import('./pages/operator-accounts.component').then(module => module.OperatorAccountsComponent) },
+  { path: 'change-password', data: { title: '修改密码', parentUrl: '/admin/dashboard' }, loadComponent: () => import('./pages/admin-change-password.component').then((module) => module.AdminChangePasswordComponent) },
+  { path: 'forbidden', data: { title: '无访问权限', parentUrl: '/admin/dashboard' }, loadComponent: () => import('./pages/admin-forbidden.component').then((module) => module.AdminForbiddenComponent) },
 ];
 
 const technicalChildren: Routes = [
   { path: '', pathMatch: 'full', redirectTo: 'dashboard' },
-  { path: 'dashboard', loadComponent: () => import('./pages/tech-dashboard.component').then((module) => module.TechDashboardComponent) },
-  { path: 'api-catalog', loadComponent: () => import('./pages/admin-api-catalog.component').then((module) => module.AdminApiCatalogComponent) },
-  { path: 'icon-catalog', loadComponent: () => import('./pages/admin-icon-catalog.component').then((module) => module.AdminIconCatalogComponent) },
-  { path: 'boot-layout', loadComponent: () => import('./pages/admin-boot-layout.component').then((module) => module.AdminBootLayoutComponent) },
-  { path: 'resources', loadComponent: () => import('./pages/admin-resource-catalog.component').then((module) => module.AdminResourceCatalogComponent) },
-  { path: 'resources/:className', loadComponent: () => import('./pages/admin-resource.component').then((module) => module.AdminResourceComponent) },
-  { path: 'sync', loadComponent: () => import('./pages/admin-sync-center.component').then((module) => module.AdminSyncCenterComponent) },
-  { path: 'migration', loadComponent: () => import('./pages/admin-migration.component').then((module) => module.AdminMigrationComponent) },
+  { path: 'dashboard', data: { title: '技术工作台', parentUrl: null }, loadComponent: () => import('./pages/tech-dashboard.component').then((module) => module.TechDashboardComponent) },
+  { path: 'api-catalog', data: { title: '云函数接口', parentUrl: '/tech-admin/dashboard' }, loadComponent: () => import('./pages/admin-api-catalog.component').then((module) => module.AdminApiCatalogComponent) },
+  { path: 'icon-catalog', data: { title: '图标选择器', parentUrl: '/tech-admin/dashboard' }, loadComponent: () => import('./pages/admin-icon-catalog.component').then((module) => module.AdminIconCatalogComponent) },
+  { path: 'boot-layout', data: { title: 'Bootstrap 布局', parentUrl: '/tech-admin/dashboard' }, loadComponent: () => import('./pages/admin-boot-layout.component').then((module) => module.AdminBootLayoutComponent) },
+  { path: 'resources', data: { title: '全部数据类', parentUrl: '/tech-admin/dashboard' }, loadComponent: () => import('./pages/admin-resource-catalog.component').then((module) => module.AdminResourceCatalogComponent) },
+  { path: 'resources/:className', data: { title: '数据类详情', parentUrl: '/tech-admin/resources', parentTitle: '全部数据类' }, loadComponent: () => import('./pages/admin-resource.component').then((module) => module.AdminResourceComponent) },
+  { path: 'sync', data: { title: '数据同步中心', parentUrl: '/tech-admin/dashboard' }, loadComponent: () => import('./pages/admin-sync-center.component').then((module) => module.AdminSyncCenterComponent) },
+  { path: 'migration', data: { title: '迁移状态', parentUrl: '/tech-admin/dashboard' }, loadComponent: () => import('./pages/admin-migration.component').then((module) => module.AdminMigrationComponent) },
 ];
 
 export const adminRoutes: Routes = [

+ 20 - 13
projects/xiaoshu-admin/src/app/layout/admin-shell.component.html

@@ -1,15 +1,21 @@
 <div class="admin-frame" [class.sidebar-collapsed]="collapsed()">
-  <aside class="admin-sidebar" [class.mobile-open]="mobileOpen()">
+  <aside id="admin-sidebar" class="admin-sidebar" [class.mobile-open]="mobileOpen()" [attr.inert]="isNarrow() && !mobileOpen() ? '' : null">
+    <div class="brand-area">
     <a class="brand" [routerLink]="mode === 'technical' ? '/tech-admin/dashboard' : '/admin/dashboard'" aria-label="小树陪练管理后台">
       <img src="/static/images/logo.png" alt="小树陪练" />
       <span>小树陪练<small>{{ mode === 'technical' ? '技术后台' : '运营管理后台' }}</small></span>
     </a>
+      <button class="sidebar-toggle" type="button" [attr.aria-label]="collapsed() ? '展开侧栏' : '收起侧栏'" [title]="collapsed() ? '展开侧栏' : '收起侧栏'" [attr.aria-expanded]="!collapsed()" aria-controls="admin-sidebar" (click)="collapsed.set(!collapsed())">
+        <lucide-icon [img]="collapsed() ? icons.PanelLeftOpen : icons.PanelLeftClose" [size]="19" />
+      </button>
+      <button class="sidebar-close" type="button" aria-label="关闭菜单" (click)="closeMenu()"><lucide-icon [img]="icons.X" [size]="20" /></button>
+    </div>
 
     <nav aria-label="后台主导航">
       @for (group of navigation(); track group.label) {
         <p class="nav-group-label">{{ group.label }}</p>
         @for (item of group.items; track item.route) {
-          <a [routerLink]="item.route" routerLinkActive="active" (click)="mobileOpen.set(false)">
+          <a [routerLink]="item.route" [title]="item.label" [attr.aria-label]="item.label" routerLinkActive="active" (click)="mobileOpen.set(false)">
             <lucide-icon [img]="iconFor(item.icon)" [size]="19" />
             <span>{{ item.label }}</span>
           </a>
@@ -18,28 +24,29 @@
     </nav>
 
     <div class="sidebar-foot">
-      <button type="button" (click)="sessions.logout()">
+      <button type="button" aria-label="退出登录" title="退出登录" (click)="sessions.logout()">
         <lucide-icon [img]="icons.LogOut" [size]="18" /><span>退出登录</span>
       </button>
     </div>
   </aside>
 
-  <button class="sidebar-mask" type="button" aria-label="关闭菜单" (click)="mobileOpen.set(false)"></button>
+  <button class="sidebar-mask" type="button" aria-label="关闭菜单" (click)="closeMenu()"></button>
 
   <section class="admin-workspace">
     <header class="admin-topbar">
       <div class="topbar-leading">
-        <button class="mobile-menu" type="button" aria-label="打开菜单" (click)="mobileOpen.set(true)">
+        <button id="admin-menu-toggle" class="mobile-menu" type="button" aria-controls="admin-sidebar" [attr.aria-expanded]="mobileOpen()" aria-label="打开菜单" (click)="openMenu()">
           <lucide-icon [img]="icons.Menu" [size]="22" />
         </button>
-        <button class="collapse-button" type="button" aria-label="切换侧栏" (click)="collapsed.set(!collapsed())">
-          <lucide-icon [img]="collapsed() ? icons.ChevronRight : icons.ChevronLeft" [size]="18" />
-        </button>
-        <a class="global-search" [routerLink]="mode === 'technical' ? '/tech-admin/resources' : '/admin/users'" aria-label="进入后台搜索">
-          <lucide-icon [img]="icons.Search" [size]="18" />
-          <span>{{ mode === 'technical' ? '搜索数据类或接口' : '搜索用户、手机号或代理' }}</span>
-          <kbd>⌘ K</kbd>
-        </a>
+        @if (page().parentUrl; as parentUrl) {
+          <a class="back-button" [routerLink]="parentUrl" [attr.aria-label]="'返回' + (page().parentTitle || (mode === 'technical' ? '技术工作台' : '工作台'))">
+            <lucide-icon [img]="icons.ArrowLeft" [size]="18" /><span>返回</span>
+          </a>
+        }
+        <nav class="page-location" aria-label="当前位置">
+          @if (page().parentTitle) { <span class="parent-title">{{ page().parentTitle }}</span><span class="path-divider" aria-hidden="true">/</span> }
+          <span aria-current="page" [title]="page().title">{{ page().title }}</span>
+        </nav>
       </div>
       <div class="topbar-account">
         <button class="notification" type="button" aria-label="通知"><lucide-icon [img]="icons.Bell" [size]="19" /></button>

+ 35 - 13
projects/xiaoshu-admin/src/app/layout/admin-shell.component.scss

@@ -2,27 +2,42 @@
 .admin-frame { --sidebar: 256px; display: grid; grid-template-columns: var(--sidebar) minmax(0, 1fr); min-height: 100vh; transition: grid-template-columns .22s ease; }
 .admin-frame.sidebar-collapsed { --sidebar: 78px; }
 .admin-sidebar { position: sticky; top: 0; z-index: 30; display: flex; flex-direction: column; height: 100vh; overflow: hidden; color: #d7e2f4; background: linear-gradient(180deg, #10233f 0%, #0b1930 100%); box-shadow: 10px 0 32px rgba(12, 30, 55, .08); }
-.brand { display: flex; align-items: center; gap: 12px; min-height: 76px; padding: 14px 20px; color: white; text-decoration: none; border-bottom: 1px solid rgba(255,255,255,.08); }
+.brand-area { display: flex; align-items: center; gap: 8px; min-height: 76px; padding: 12px; border-bottom: 1px solid rgba(255,255,255,.08); }
+.brand { display: flex; align-items: center; gap: 8px; min-width: 0; flex: 1; padding: 0; color: white; text-decoration: none; }
 .brand img { width: 38px; height: 38px; object-fit: contain; border-radius: 11px; background: white; }
-.brand span { display: grid; min-width: 135px; font-size: 16px; font-weight: 700; line-height: 1.15; }
+.brand span { display: grid; min-width: 0; white-space: nowrap; font-size: 16px; font-weight: 700; line-height: 1.15; }
 .brand small { margin-top: 5px; color: #8ca4c8; font-size: 11px; font-weight: 500; letter-spacing: .18em; }
-nav { display: grid; gap: 5px; padding: 18px 12px; overflow: auto; }
+.admin-sidebar nav { display: grid; gap: 5px; padding: 18px 12px; overflow: auto; }
 .nav-group-label { margin: 15px 14px 5px; color: #7188aa; font-size: 10px; font-weight: 800; letter-spacing: .13em; white-space: nowrap; }
 .nav-group-label:first-child { margin-top: 0; }
-nav a, .sidebar-foot button { display: flex; align-items: center; gap: 13px; min-height: 44px; padding: 0 14px; overflow: hidden; border: 0; border-radius: 10px; color: #aebdd3; background: transparent; font: inherit; white-space: nowrap; text-decoration: none; cursor: pointer; transition: .18s ease; }
-nav a:hover, .sidebar-foot button:hover { color: white; background: rgba(255,255,255,.07); }
-nav a.active { color: white; background: linear-gradient(135deg, #2f80ed, #236bd1); box-shadow: 0 8px 20px rgba(47,128,237,.28); }
+.admin-sidebar nav a, .sidebar-foot button { display: flex; align-items: center; gap: 13px; min-height: 44px; padding: 0 14px; overflow: hidden; border: 0; border-radius: 10px; color: #aebdd3; background: transparent; font: inherit; white-space: nowrap; text-decoration: none; cursor: pointer; transition: .18s ease; }
+.admin-sidebar nav a:hover, .sidebar-foot button:hover { color: white; background: rgba(255,255,255,.07); }
+.admin-sidebar nav a.active { color: white; background: linear-gradient(135deg, #2f80ed, #236bd1); box-shadow: 0 8px 20px rgba(47,128,237,.28); }
 .sidebar-foot { margin-top: auto; padding: 14px 12px 20px; border-top: 1px solid rgba(255,255,255,.08); }
 .sidebar-foot button { width: 100%; }
-.sidebar-collapsed .brand span, .sidebar-collapsed nav span, .sidebar-collapsed .sidebar-foot span, .sidebar-collapsed .nav-group-label { opacity: 0; pointer-events: none; }
+@media (min-width: 901px) {
+  .sidebar-collapsed .brand-area { flex-direction: column; gap: 8px; }
+  .sidebar-collapsed .brand span, .sidebar-collapsed nav span, .sidebar-collapsed .sidebar-foot span, .sidebar-collapsed .nav-group-label { display: none; }
+  .sidebar-collapsed nav a, .sidebar-collapsed .sidebar-foot button { justify-content: center; padding: 0; }
+}
+.sidebar-toggle, .sidebar-close { display: grid; flex: 0 0 32px; place-items: center; width: 32px; height: 32px; padding: 0; border: 1px solid rgba(255,255,255,.2); border-radius: 8px; color: #d7e2f4; background: transparent; cursor: pointer; }
+.sidebar-close { display: none; }
+.sidebar-toggle:hover, .sidebar-close:hover { background: rgba(255,255,255,.1); color: white; }
+button:focus-visible, a:focus-visible { outline: 2px solid #51a8ff; outline-offset: 3px; }
 .admin-workspace { min-width: 0; }
 .admin-topbar { position: sticky; top: 0; z-index: 20; display: flex; align-items: center; justify-content: space-between; min-height: 70px; padding: 0 28px; border-bottom: 1px solid #e5eaf1; background: rgba(255,255,255,.94); backdrop-filter: blur(12px); }
 .topbar-leading, .topbar-account { display: flex; align-items: center; gap: 12px; }
-.collapse-button, .mobile-menu, .notification { display: grid; place-items: center; width: 36px; height: 36px; border: 1px solid #e1e7ef; border-radius: 9px; color: #55647a; background: white; cursor: pointer; }
+.back-button, .mobile-menu, .notification { display: grid; place-items: center; width: 36px; height: 36px; border: 1px solid #e1e7ef; border-radius: 9px; color: #55647a; background: white; cursor: pointer; }
 .mobile-menu { display: none; }
-.global-search { display: flex; align-items: center; gap: 9px; width: min(360px, 34vw); height: 39px; padding: 0 12px; border: 1px solid #e1e7ef; border-radius: 10px; color: #8a96a8; background: #f8fafc; font-size: 13px; }
-.global-search { text-decoration: none; }
-.global-search kbd { margin-left: auto; padding: 2px 6px; border: 1px solid #dfe5ed; border-radius: 5px; background: white; font: inherit; }
+.back-button { display: inline-flex; flex-shrink: 0; gap: 6px; width: auto; padding: 0 10px; font-size: 13px; text-decoration: none; }
+.topbar-leading { flex: 1; min-width: 0; }
+.mobile-menu { flex-shrink: 0; }
+.page-location { display: flex; align-items: center; gap: 8px; min-width: 0; color: #4e6076; font-size: 13px; }
+.page-location span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.page-location .parent-title, .path-divider { color: #8693a6; }
+.topbar-account { min-width: 0; max-width: 40%; flex-shrink: 0; margin-left: 16px; }
+.topbar-account > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.topbar-account .avatar, .topbar-account .notification { flex-shrink: 0; }
 .topbar-account { font-size: 13px; font-weight: 600; }
 .avatar { display: grid; place-items: center; width: 34px; height: 34px; border-radius: 50%; color: white; background: linear-gradient(135deg, #43b37d, #208d5c); }
 .admin-content { padding: 28px; }
@@ -33,9 +48,16 @@ nav a.active { color: white; background: linear-gradient(135deg, #2f80ed, #236bd
   .admin-sidebar.mobile-open { transform: translateX(0); }
   .sidebar-mask { position: fixed; inset: 0; z-index: 25; border: 0; background: rgba(7,18,35,.5); }
   .admin-sidebar.mobile-open + .sidebar-mask { display: block; }
-  .collapse-button { display: none; }
+  .sidebar-toggle { display: none; }
+  .sidebar-close { display: grid; }
   .mobile-menu { display: grid; }
-  .global-search { display: none; }
   .admin-topbar { padding: 0 16px; }
   .admin-content { padding: 18px 14px; }
 }
+
+@media (max-width: 600px) {
+  .topbar-account > span, .topbar-account .notification { display: none; }
+  .topbar-account { margin-left: 10px; }
+  .topbar-leading { gap: 8px; }
+  .page-location .parent-title, .page-location .path-divider { display: none; }
+}

+ 78 - 0
projects/xiaoshu-admin/src/app/layout/admin-shell.component.spec.ts

@@ -0,0 +1,78 @@
+import { Component, signal } from '@angular/core';
+import { TestBed } from '@angular/core/testing';
+import { provideRouter, Route, Router } from '@angular/router';
+import { RouterTestingHarness } from '@angular/router/testing';
+import { AdminSessionService } from '../admin-session.service';
+import { adminRoutes } from '../admin.routes';
+import { AdminShellComponent } from './admin-shell.component';
+
+@Component({ standalone: true, template: '' })
+class EmptyPage {}
+
+// Exercise real route metadata and redirects without loading business data.
+const routes: Route[] = adminRoutes.filter(route => route.children).map(route => ({
+  path: route.path, component: AdminShellComponent, data: route.data,
+  children: route.children!.map(child => child.redirectTo !== undefined ? child : {
+    path: child.path, data: child.data, component: EmptyPage,
+  }),
+}));
+
+describe('Admin shell navigation', () => {
+  let harness: RouterTestingHarness;
+  beforeEach(async () => {
+    await TestBed.configureTestingModule({ providers: [
+      provideRouter(routes),
+      { provide: AdminSessionService, useValue: { session: signal({ username: 'admin', isSuperAdmin: true }), logout: jasmine.createSpy() } },
+    ] }).compileComponents();
+    harness = await RouterTestingHarness.create();
+  });
+
+  it('provides a fixed upper-level target and title on every operations and technical route', async () => {
+    for (const parent of routes) {
+      for (const child of parent.children!.filter(child => child.component)) {
+        const url = `/${parent.path}/${child.path!.replace(':objectId', 'sample').replace(':className', '_User')}`;
+        await harness.navigateByUrl(url);
+        harness.detectChanges();
+        const element = harness.routeNativeElement!;
+        const back = element.querySelector<HTMLAnchorElement>('.back-button');
+        expect(element.querySelector('.page-location')?.textContent).withContext(url).toContain(child.data!['title']);
+        if (child.path === 'dashboard') {
+          expect(back).withContext(url).toBeNull();
+        } else {
+          const target = child.path!.startsWith('members/') ? '/admin/members'
+            : child.path!.startsWith('stores/') ? '/admin/stores'
+            : child.path!.startsWith('coaches/') ? '/admin/coaches'
+            : child.path!.startsWith('resources/') ? '/tech-admin/resources'
+            : `/${parent.path}/dashboard`;
+          expect(back?.getAttribute('href')).withContext(url).toBe(target);
+        }
+        expect(element.querySelector('.global-search')).toBeNull();
+      }
+    }
+  });
+
+  it('resolves old member links and returns to the list regardless of the previous page', async () => {
+    await harness.navigateByUrl('/admin/payroll');
+    await harness.navigateByUrl('/admin/users/sample');
+    harness.detectChanges();
+    const back = harness.routeNativeElement!.querySelector<HTMLAnchorElement>('.back-button')!;
+    expect(back.getAttribute('href')).toBe('/admin/members');
+    back.click();
+    await harness.fixture.whenStable();
+    expect(TestBed.inject(Router).url).toBe('/admin/members');
+  });
+
+  it('toggles the sidebar without navigating and keeps narrow-screen state independent', async () => {
+    const shell = await harness.navigateByUrl('/admin/members', AdminShellComponent);
+    const toggle = harness.routeNativeElement!.querySelector<HTMLButtonElement>('.sidebar-toggle')!;
+    toggle.click(); harness.detectChanges();
+    expect(shell.collapsed()).toBeTrue();
+    expect(toggle.getAttribute('aria-label')).toBe('展开侧栏');
+    expect(TestBed.inject(Router).url).toBe('/admin/members');
+    shell.isNarrow.set(true); shell.mobileOpen.set(true); harness.detectChanges();
+    expect(harness.routeNativeElement!.querySelector('aside')!.hasAttribute('inert')).toBeFalse();
+    shell.closeMenu(); harness.detectChanges();
+    expect(shell.collapsed()).toBeTrue();
+    expect(harness.routeNativeElement!.querySelector('aside')!.hasAttribute('inert')).toBeTrue();
+  });
+});

+ 52 - 8
projects/xiaoshu-admin/src/app/layout/admin-shell.component.ts

@@ -1,6 +1,8 @@
+import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
+import { filter } from 'rxjs';
 import { CommonModule } from '@angular/common';
-import { ChangeDetectionStrategy, Component, computed, signal } from '@angular/core';
-import { ActivatedRoute, RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
+import { ChangeDetectionStrategy, Component, computed, signal, HostListener } from '@angular/core';
+import { ActivatedRoute, NavigationEnd, Router, RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
 import {
   BadgeCent,
   Bell,
@@ -11,8 +13,10 @@ import {
   CalendarDays,
   ChartNoAxesCombined,
   ChartBarBig,
-  ChevronLeft,
-  ChevronRight,
+  ArrowLeft,
+  PanelLeftClose,
+  PanelLeftOpen,
+  X,
   ClipboardCheck,
   ClipboardList,
   Files,
@@ -36,7 +40,6 @@ import {
   PanelsTopLeft,
   Route,
   ReceiptText,
-  Search,
   ScrollText,
   ShieldCheck,
   Shapes,
@@ -63,13 +66,54 @@ export class AdminShellComponent {
   readonly navigation;
   readonly collapsed = signal(false);
   readonly mobileOpen = signal(false);
+  readonly isNarrow = signal(window.innerWidth <= 900);
+  readonly page = signal<{ title: string; parentUrl: string | null; parentTitle?: string }>({ title: '', parentUrl: null });
+
+  openMenu() {
+    this.mobileOpen.set(true);
+    requestAnimationFrame(() => {
+      if (this.mobileOpen()) document.querySelector<HTMLButtonElement>('.sidebar-close')?.focus();
+    });
+  }
+
+  @HostListener('document:keydown', ['$event'])
+  trapMenuFocus(event: KeyboardEvent) {
+    if (event.key !== 'Tab' || !this.isNarrow() || !this.mobileOpen()) return;
+    const controls = Array.from(document.querySelectorAll<HTMLElement>('#admin-sidebar a, #admin-sidebar button'))
+      .filter(element => element.getClientRects().length > 0);
+    const first = controls[0], last = controls[controls.length - 1];
+    if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last?.focus(); }
+    else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first?.focus(); }
+  }
+
+  @HostListener('window:resize')
+  onResize() {
+    this.isNarrow.set(window.innerWidth <= 900);
+    if (!this.isNarrow()) this.mobileOpen.set(false);
+  }
+
+  @HostListener('document:keydown.escape')
+  closeMenu() {
+    if (!this.mobileOpen()) return;
+    this.mobileOpen.set(false);
+    document.getElementById('admin-menu-toggle')?.focus();
+  }
+
   readonly icons = {
-    BadgeCent, Bell, BookMarked, BookOpen, Braces, BriefcaseBusiness, CalendarDays, ChartBarBig, ChartNoAxesCombined, ChevronLeft, ChevronRight,
+    BadgeCent, Bell, BookMarked, BookOpen, Braces, BriefcaseBusiness, CalendarDays, ChartBarBig, ChartNoAxesCombined, ArrowLeft, PanelLeftClose, PanelLeftOpen, X,
     ClipboardCheck, ClipboardList, Files, GalleryHorizontalEnd, GraduationCap, Headphones, Image, KeyRound, Languages, LayoutDashboard, ListChecks, ListTodo, ScrollText,
-    LayoutTemplate, LogOut, Megaphone, Menu, MessageSquareText, Network, Palette, PanelsTopLeft, ReceiptText, Route, Search, ShieldCheck, Shapes, Store, TableProperties, Tags, Users, WalletCards, Workflow,
+    LayoutTemplate, LogOut, Megaphone, Menu, MessageSquareText, Network, Palette, PanelsTopLeft, ReceiptText, Route, ShieldCheck, Shapes, Store, TableProperties, Tags, Users, WalletCards, Workflow,
   };
 
-  constructor(readonly sessions: AdminSessionService, route: ActivatedRoute) {
+  constructor(readonly sessions: AdminSessionService, route: ActivatedRoute, router: Router) {
+    const updatePage = () => {
+      let current = route.snapshot;
+      while (current.firstChild) current = current.firstChild;
+      this.page.set({ title: current.data['title'] || '', parentUrl: current.data['parentUrl'] ?? null, parentTitle: current.data['parentTitle'] });
+      this.mobileOpen.set(false);
+    };
+    updatePage();
+    router.events.pipe(filter(event => event instanceof NavigationEnd), takeUntilDestroyed()).subscribe(updatePage);
     this.mode = route.snapshot.data['mode'] === 'technical' ? 'technical' : 'operations';
     this.navigation = computed(() => (this.mode === 'technical' ? TECH_NAVIGATION : OPERATIONS_NAVIGATION).filter(group => group.label !== '账号与安全' || this.sessions.session()?.isSuperAdmin || this.sessions.session()?.operationsRole === 'ops-manager'));
   }

+ 1 - 1
projects/xiaoshu-admin/src/app/pages/operations-coach-detail.component.html

@@ -1,4 +1,4 @@
-<a class="coach-back-link" routerLink="/admin/coaches"><lucide-icon [img]="icons.ArrowLeft" [size]="17"/>返回陪练老师</a>
+
 <header class="page-header coach-detail-header">
   <div class="coach-heading">
     <div class="coach-avatar">{{(data()?.coach?.displayName||'陪').slice(0,1)}}</div>

+ 0 - 1
projects/xiaoshu-admin/src/app/pages/operations-store-detail.component.html

@@ -1,6 +1,5 @@
 <header class="page-header detail-header">
   <div>
-    <a class="back-link" routerLink="/admin/stores"><lucide-icon [img]="icons.ArrowLeft" [size]="16"/>返回门店管理</a>
     <p>STORE OPERATING PROFILE</p>
     <h1>{{data()?.store?.storeName||'门店经营详情'}}</h1>
     <span>集中查看门店归属会员、陪练、排课、课程履约和陪练工资成本</span>

+ 1 - 1
projects/xiaoshu-admin/src/app/pages/operations-user-detail.component.html

@@ -1,4 +1,4 @@
-<header class="page-header"><a routerLink="/admin/members"><lucide-icon [img]="icons.ArrowLeft" [size]="17" />返回会员管理</a><button type="button" (click)="refreshBusiness()" [disabled]="loading()"><lucide-icon [img]="icons.RefreshCw" [size]="17" />刷新</button></header>
+<header class="page-header detail-actions"><button type="button" (click)="refreshBusiness()" [disabled]="loading()"><lucide-icon [img]="icons.RefreshCw" [size]="17" />刷新</button></header>
 
 @if (loading() && !user()) { <div class="state"><lucide-icon class="spin" [img]="icons.LoaderCircle" [size]="28" />正在加载会员资料…</div> }
 @else if (error() && !user()) { <div class="state error" role="alert">{{ error() }}</div> }

+ 2 - 0
projects/xiaoshu-admin/src/app/pages/operations-user-detail.component.scss

@@ -85,3 +85,5 @@
 @keyframes spin { to { transform:rotate(360deg); } }
 @media(max-width:1100px) { .detail-grid,.audit-grid{grid-template-columns:1fr}.profile-editor{grid-row:auto}.risk-panel{grid-column:auto} }
 @media(max-width:650px) { .profile-summary{padding:18px;gap:14px}.avatar{display:none}.profile-identity{flex-basis:100%}.profile-title h1{font-size:24px}.quick-actions{width:100%;margin-left:0}.tabs button{flex:0 0 auto}.form-grid,.account-facts{grid-template-columns:1fr}.metric-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.panel{padding:16px}.panel>header{flex-wrap:wrap}.date-filters{grid-template-columns:repeat(2,minmax(0,1fr))}.quick-actions>*{flex:1 1 auto}.pagination{gap:8px} }
+
+.page-header.detail-actions { justify-content: flex-end; }