Просмотр исходного кода

feat(admin): show global data loading activity

彭峰 1 месяц назад
Родитель
Сommit
01de260909

+ 9 - 0
projects/xiaoshu-admin/src/app/admin-request-activity.interceptor.ts

@@ -0,0 +1,9 @@
+import { HttpInterceptorFn } from '@angular/common/http';
+import { inject } from '@angular/core';
+import { finalize } from 'rxjs';
+import { AdminRequestActivityService } from './admin-request-activity.service';
+
+export const adminRequestActivityInterceptor: HttpInterceptorFn = (request, next) => {
+  const finish = inject(AdminRequestActivityService).begin();
+  return next(request).pipe(finalize(finish));
+};

+ 62 - 0
projects/xiaoshu-admin/src/app/admin-request-activity.service.spec.ts

@@ -0,0 +1,62 @@
+import { HttpClient, provideHttpClient, withInterceptors } from '@angular/common/http';
+import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
+import { fakeAsync, TestBed, tick } from '@angular/core/testing';
+import { adminRequestActivityInterceptor } from './admin-request-activity.interceptor';
+import { AdminRequestActivityService } from './admin-request-activity.service';
+
+describe('AdminRequestActivityService', () => {
+  let activity: AdminRequestActivityService;
+
+  beforeEach(() => {
+    TestBed.configureTestingModule({ providers: [
+      provideHttpClient(withInterceptors([adminRequestActivityInterceptor])),
+      provideHttpClientTesting(),
+    ] });
+    activity = TestBed.inject(AdminRequestActivityService);
+  });
+
+  it('shows an indicator for a slow request and hides it after completion', fakeAsync(() => {
+    const finish = activity.begin();
+    tick(179);
+    expect(activity.visible()).toBeFalse();
+    tick(1);
+    expect(activity.visible()).toBeTrue();
+    finish();
+    expect(activity.visible()).toBeTrue();
+    tick(360);
+    expect(activity.visible()).toBeFalse();
+  }));
+
+  it('does not flash for a fast request', fakeAsync(() => {
+    const finish = activity.begin();
+    tick(80);
+    finish();
+    tick(500);
+    expect(activity.visible()).toBeFalse();
+    expect(activity.pending()).toBe(0);
+  }));
+
+  it('stays visible until every concurrent request has completed', fakeAsync(() => {
+    const finishFirst = activity.begin();
+    const finishSecond = activity.begin();
+    tick(180);
+    finishFirst();
+    tick(500);
+    expect(activity.visible()).toBeTrue();
+    expect(activity.pending()).toBe(1);
+    finishSecond();
+    tick(360);
+    expect(activity.visible()).toBeFalse();
+  }));
+
+  it('finishes intercepted activity when an HTTP request is cancelled', fakeAsync(() => {
+    const subscription = TestBed.inject(HttpClient).get('/slow-data').subscribe();
+    TestBed.inject(HttpTestingController).expectOne('/slow-data');
+    tick(180);
+    expect(activity.visible()).toBeTrue();
+    subscription.unsubscribe();
+    tick(360);
+    expect(activity.pending()).toBe(0);
+    expect(activity.visible()).toBeFalse();
+  }));
+});

+ 54 - 0
projects/xiaoshu-admin/src/app/admin-request-activity.service.ts

@@ -0,0 +1,54 @@
+import { Injectable, signal } from '@angular/core';
+
+const SHOW_DELAY_MS = 180;
+const MIN_VISIBLE_MS = 360;
+
+/** Coordinates one unobtrusive loading indicator for all admin HttpClient requests. */
+@Injectable({ providedIn: 'root' })
+export class AdminRequestActivityService {
+  private readonly pendingState = signal(0);
+  private readonly visibleState = signal(false);
+  private showTimer: ReturnType<typeof setTimeout> | undefined;
+  private hideTimer: ReturnType<typeof setTimeout> | undefined;
+  private shownAt = 0;
+
+  readonly pending = this.pendingState.asReadonly();
+  readonly visible = this.visibleState.asReadonly();
+
+  begin(): () => void {
+    if (this.hideTimer) {
+      clearTimeout(this.hideTimer);
+      this.hideTimer = undefined;
+    }
+    this.pendingState.update(count => count + 1);
+    if (!this.visibleState() && !this.showTimer) {
+      this.showTimer = setTimeout(() => {
+        this.showTimer = undefined;
+        if (this.pendingState() > 0) {
+          this.shownAt = Date.now();
+          this.visibleState.set(true);
+        }
+      }, SHOW_DELAY_MS);
+    }
+
+    let completed = false;
+    return () => {
+      if (completed) return;
+      completed = true;
+      this.pendingState.update(count => Math.max(0, count - 1));
+      if (this.pendingState() > 0) return;
+
+      if (this.showTimer) {
+        clearTimeout(this.showTimer);
+        this.showTimer = undefined;
+      }
+      if (!this.visibleState()) return;
+
+      const delay = Math.max(0, MIN_VISIBLE_MS - (Date.now() - this.shownAt));
+      this.hideTimer = setTimeout(() => {
+        this.hideTimer = undefined;
+        if (this.pendingState() === 0) this.visibleState.set(false);
+      }, delay);
+    };
+  }
+}

+ 3 - 2
projects/xiaoshu-admin/src/app/app.config.ts

@@ -1,12 +1,13 @@
-import { provideHttpClient } from '@angular/common/http';
+import { provideHttpClient, withInterceptors } from '@angular/common/http';
 import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
 import { provideRouter } from '@angular/router';
+import { adminRequestActivityInterceptor } from './admin-request-activity.interceptor';
 import { routes } from './app.routes';
 
 export const appConfig: ApplicationConfig = {
   providers: [
     provideZoneChangeDetection({ eventCoalescing: true }),
-    provideHttpClient(),
+    provideHttpClient(withInterceptors([adminRequestActivityInterceptor])),
     provideRouter(routes),
   ],
 };

+ 6 - 0
projects/xiaoshu-admin/src/app/layout/admin-shell.component.html

@@ -48,6 +48,12 @@
           <span aria-current="page" [title]="page().title">{{ page().title }}</span>
         </nav>
       </div>
+      @if (requestActivity.visible()) {
+        <div class="request-activity" role="status" aria-live="polite" aria-label="正在加载数据">
+          <lucide-icon class="request-spinner" [img]="icons.LoaderCircle" [size]="18" aria-hidden="true" />
+          <span>正在加载数据…</span>
+        </div>
+      }
       <div class="topbar-account">
         <button class="notification" type="button" aria-label="通知"><lucide-icon [img]="icons.Bell" [size]="19" /></button>
         <div class="avatar">{{ (sessions.session()?.username || '管').slice(0, 1).toUpperCase() }}</div>

+ 10 - 0
projects/xiaoshu-admin/src/app/layout/admin-shell.component.scss

@@ -29,6 +29,9 @@ button:focus-visible, a:focus-visible { outline: 2px solid #51a8ff; outline-offs
 .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; }
+.request-activity { position: absolute; left: 50%; display: inline-flex; align-items: center; gap: 8px; min-height: 34px; padding: 0 13px; border: 1px solid #bfe5d6; border-radius: 999px; color: #176c53; background: #effaf5; box-shadow: 0 7px 20px rgba(28, 121, 91, .12); font-size: 13px; font-weight: 700; white-space: nowrap; transform: translateX(-50%); }
+.request-spinner { animation: request-spin .8s linear infinite; }
+@keyframes request-spin { to { transform: rotate(360deg); } }
 .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; }
 .back-button { display: inline-flex; flex-shrink: 0; gap: 6px; width: auto; padding: 0 10px; font-size: 13px; text-decoration: none; }
@@ -55,6 +58,7 @@ button:focus-visible, a:focus-visible { outline: 2px solid #51a8ff; outline-offs
   .mobile-menu { display: grid; }
   .admin-topbar { padding: 0 16px; }
   .admin-content { padding: 18px 14px; }
+  .request-activity { left: auto; right: 76px; transform: none; }
 }
 
 @media (max-width: 600px) {
@@ -62,4 +66,10 @@ button:focus-visible, a:focus-visible { outline: 2px solid #51a8ff; outline-offs
   .topbar-account { margin-left: 10px; }
   .topbar-leading { gap: 8px; }
   .page-location .parent-title, .page-location .path-divider { display: none; }
+  .request-activity { right: 62px; width: 34px; min-height: 34px; padding: 0; justify-content: center; }
+  .request-activity span { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
+}
+
+@media (prefers-reduced-motion: reduce) {
+  .request-spinner { animation-duration: 1.6s; }
 }

+ 4 - 2
projects/xiaoshu-admin/src/app/layout/admin-shell.component.ts

@@ -30,6 +30,7 @@ import {
   LayoutTemplate,
   ListChecks,
   ListTodo,
+  LoaderCircle,
   LogOut,
   LucideAngularModule,
   Menu,
@@ -51,6 +52,7 @@ import {
   Workflow,
 } from 'lucide-angular';
 import { OPERATIONS_NAVIGATION, TECH_NAVIGATION } from '../admin.constants';
+import { AdminRequestActivityService } from '../admin-request-activity.service';
 import { AdminSessionService } from '../admin-session.service';
 
 @Component({
@@ -102,10 +104,10 @@ export class AdminShellComponent {
   readonly icons = {
     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, ShieldCheck, Shapes, Store, TableProperties, Tags, Users, WalletCards, Workflow,
+    LayoutTemplate, LoaderCircle, LogOut, Megaphone, Menu, MessageSquareText, Network, Palette, PanelsTopLeft, ReceiptText, Route, ShieldCheck, Shapes, Store, TableProperties, Tags, Users, WalletCards, Workflow,
   };
 
-  constructor(readonly sessions: AdminSessionService, route: ActivatedRoute, router: Router) {
+  constructor(readonly sessions: AdminSessionService, readonly requestActivity: AdminRequestActivityService, route: ActivatedRoute, router: Router) {
     const updatePage = () => {
       let current = route.snapshot;
       while (current.firstChild) current = current.firstChild;