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

feat(admin): add member list page size selector

彭峰 4 часов назад
Родитель
Сommit
d487c5646a

+ 3 - 1
projects/shared/ui/dropdown.directive.ts

@@ -150,7 +150,9 @@ export class DropdownDirective implements AfterViewInit, AfterViewChecked, OnDes
     const rect = this.trigger.getBoundingClientRect();
     if (rect.bottom < 0 || rect.top > window.innerHeight) { this.close(false); return; }
     const width = Math.min(rect.width, window.innerWidth - 24);
-    Object.assign(this.menu.style, {left:`${Math.max(12, Math.min(rect.left, window.innerWidth-width-12))}px`, top:`${rect.bottom+6}px`, width:`${width}px`, maxHeight:`${Math.max(44, Math.min(320,window.innerHeight-rect.bottom-18))}px`});
+    const above = this.select.getAttribute('data-dropdown-placement') === 'top';
+    const availableHeight = above ? rect.top - 18 : window.innerHeight - rect.bottom - 18;
+    Object.assign(this.menu.style, {left:`${Math.max(12, Math.min(rect.left, window.innerWidth-width-12))}px`, top:above ? 'auto' : `${rect.bottom+6}px`, bottom:above ? `${window.innerHeight-rect.top+6}px` : 'auto', width:`${width}px`, maxHeight:`${Math.max(44, Math.min(320,availableHeight))}px`});
   }
   private enabledOptions(): HTMLButtonElement[] { return Array.from(this.menu?.querySelectorAll<HTMLButtonElement>('button:not(:disabled)') || []); }
   private menuKeydown(event: KeyboardEvent): void {

+ 8 - 1
projects/xiaoshu-admin/src/app/pages/operations-users.component.html

@@ -34,7 +34,14 @@
       @empty { <tr><td colspan="11"><div class="state">没有符合条件的会员</div></td></tr> }
     </tbody></table></div>
     <div class="mobile-cards">@for (row of page()?.items || []; track row.objectId) { <article><header><div><strong>{{ row.displayName }}</strong><span>ID {{ row.userId || '—' }} · {{ row.mobile || row.mobileMasked || '—' }}</span></div><span class="status" [class.disabled]="row.status === 'disabled'">{{ row.statusLabel }}</span></header><div class="card-status"><span class="learning-status" [attr.data-status]="row.learningStatus">{{ row.learningStatusLabel }}</span>@for (item of row.riskFlags || []; track item) { <em>{{ item }}</em> }</div><dl><div><dt>注册时间</dt><dd>{{ row.registeredAt | date:'yyyy-MM-dd HH:mm' }}</dd></div><div><dt>所属代理</dt><dd>{{ row.parentDisplayName || '未绑定' }}</dd></div><div><dt>剩余课时</dt><dd>{{ row.periods30 }}×30分 · {{ row.periods60 }}×60分</dd></div><div><dt>下次预约</dt><dd>{{ row.nextAppointmentAt ? (row.nextAppointmentAt | date:'MM-dd HH:mm') : '待排课' }}</dd></div></dl><div class="member-actions"><a class="credit-action" [routerLink]="['/admin/members', row.objectId]" [queryParams]="{tab:'balances'}">充值/扣回</a><a class="detail-action" [routerLink]="['/admin/members', row.objectId]">查看详情<lucide-icon [img]="icons.ChevronRight" [size]="17" /></a></div></article> } @empty { <div class="state">没有符合条件的会员</div> }</div>
-    <footer class="pagination"><span>第 {{ page()?.page || 1 }} / {{ pageCount() }} 页 · 共 {{ page()?.total || 0 }} 人</span><div><button type="button" aria-label="上一页" [disabled]="(page()?.page || 1) <= 1" (click)="load((page()?.page || 1) - 1)"><lucide-icon [img]="icons.ChevronLeft" [size]="18" /></button><button type="button" aria-label="下一页" [disabled]="(page()?.page || 1) >= pageCount()" (click)="load((page()?.page || 1) + 1)"><lucide-icon [img]="icons.ChevronRight" [size]="18" /></button></div></footer>
+    <footer class="pagination">
+      <div class="pagination-summary">
+        <span>第 {{ page()?.page || 1 }} / {{ pageCount() }} 页 · 共 {{ page()?.total || 0 }} 人</span>
+        <label class="page-size"><span>每页显示</span><span class="page-size-control"><select data-dropdown-placement="top" aria-label="每页显示条数" [ngModel]="pageSize()" (ngModelChange)="changePageSize($event)" [disabled]="loading()"><option [ngValue]="20">20 条</option><option [ngValue]="30">30 条</option><option [ngValue]="50">50 条</option><option [ngValue]="100">100 条</option><option ngValue="all">全部</option></select></span></label>
+        @if (loading()) { <span role="status">正在加载会员…</span> }
+      </div>
+      <div><button type="button" aria-label="上一页" [disabled]="loading() || (page()?.page || 1) <= 1" (click)="load((page()?.page || 1) - 1)"><lucide-icon [img]="icons.ChevronLeft" [size]="18" /></button><button type="button" aria-label="下一页" [disabled]="loading() || (page()?.page || 1) >= pageCount()" (click)="load((page()?.page || 1) + 1)"><lucide-icon [img]="icons.ChevronRight" [size]="18" /></button></div>
+    </footer>
   }
 </section>
 

+ 5 - 0
projects/xiaoshu-admin/src/app/pages/operations-users.component.scss

@@ -4,6 +4,11 @@
 @media(max-width:800px){.page-header{align-items:start;flex-direction:column}.header-actions{width:100%;justify-content:stretch}.header-actions button{flex:1}.summary-grid{grid-template-columns:1fr 1fr}.summary-grid article{min-height:100px}.filters{grid-template-columns:1fr 1fr}.search-field{grid-column:1/-1}.filter-actions{grid-column:1/-1}.desktop-table{display:none}.mobile-cards{display:grid;gap:12px;padding:12px;background:#f4f7f6}.mobile-cards article{padding:16px;border:1px solid #dfe7e4;border-radius:13px;background:#fff}.mobile-cards article>header{display:flex;align-items:start;justify-content:space-between;gap:12px}.mobile-cards header div{display:grid;gap:4px}.mobile-cards header strong{font-size:15px}.mobile-cards header span{color:#81908b;font-size:11px}.card-status{display:flex;flex-wrap:wrap;gap:5px;margin:12px 0}.mobile-cards dl{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin:0}.mobile-cards dl div{display:grid;gap:3px}.mobile-cards dt{color:#87948f;font-size:10px}.mobile-cards dd{margin:0;font-size:12px}.mobile-cards article>a{display:flex;align-items:center;justify-content:center;gap:6px;margin-top:14px;min-height:38px;border-radius:9px;color:#176c50;background:#eaf6f1;font-size:12px;font-weight:700}.table-card>header small{display:none}}
 @media(max-width:540px){.summary-grid{grid-template-columns:1fr 1fr}.summary-grid article:last-child{grid-column:1/-1}.filters{grid-template-columns:1fr}.search-field,.filter-actions{grid-column:auto}.form-grid{grid-template-columns:1fr}.drawer-body{padding:16px}.mobile-cards dl{grid-template-columns:1fr}.pagination{align-items:flex-start;gap:12px}.page-header>div>span{display:none}.import-guide{align-items:stretch;flex-direction:column}.import-summary{grid-template-columns:1fr 1fr}.import-summary article:nth-child(2){border-right:0}.import-summary article:nth-child(-n+2){border-bottom:1px solid #e8eeec}.import-drawer>footer{display:grid;grid-template-columns:1fr 1fr}.import-drawer>footer .primary{grid-column:1/-1}}
 
+.pagination { gap:16px; flex-wrap:wrap; }
+.pagination .pagination-summary { align-items:center; flex-wrap:wrap; gap:12px 20px; }
+.page-size { display:flex; align-items:center; gap:8px; white-space:nowrap; }
+.page-size-control { display:block; width:110px; }
+
 // Allocate space by content and keep both row actions usable while horizontally scrolling.
 .desktop-table {
   table { min-width:1500px; table-layout:fixed; }

+ 79 - 0
projects/xiaoshu-admin/src/app/pages/operations-users.component.spec.ts

@@ -49,6 +49,85 @@ describe('OperationsUsersComponent', () => {
     expect((fixture.nativeElement as HTMLElement).textContent).toContain('注册时间至');
   });
 
+  for (const size of [20, 30, 50, 100]) {
+    it(`loads the first page with ${size} members after changing the page size`, async () => {
+      const component = fixture.componentInstance;
+      component.page.set({ ...memberPage, page: 3, total: 172 });
+      operations.users.and.callFake(async (page, pageSize) => ({ ...memberPage, page, pageSize, total: 172 }));
+      fixture.detectChanges();
+      const select = (fixture.nativeElement as HTMLElement).querySelector<HTMLSelectElement>('.page-size select')!;
+      expect(select).not.toBeNull();
+      if (!select) return;
+      expect(Array.from(select.options, option => option.textContent?.trim())).toEqual(['20 条', '30 条', '50 条', '100 条', '全部']);
+      select.selectedIndex = [20, 30, 50, 100].indexOf(size);
+      select.dispatchEvent(new Event('change', { bubbles: true }));
+      await fixture.whenStable();
+      fixture.detectChanges();
+      expect(operations.users).toHaveBeenCalledWith(1, size, '', jasmine.any(Object), { field: 'registeredAt', direction: 'desc' }, false);
+      expect(component.page()?.page).toBe(1);
+      expect(component.pageCount()).toBe(Math.ceil(172 / size));
+    });
+  }
+
+  it('loads every filtered member beyond the server page limit and disables pagination', async () => {
+    const component = fixture.componentInstance;
+    component.query.set('测试');
+    component.parentUserId.set(7);
+    // Only objectId is needed by this pagination test; no real member data is used.
+    const rows = Array.from({ length: 237 }, (_, index) => ({ objectId: `member-${index}` })) as MemberPage['items'];
+    operations.users.calls.reset();
+    operations.users.and.callFake(async (page, pageSize) => ({
+      ...memberPage, page, pageSize, total: rows.length,
+      items: rows.slice((page - 1) * pageSize, page * pageSize),
+    }));
+    const select = (fixture.nativeElement as HTMLElement).querySelector<HTMLSelectElement>('.page-size select')!;
+    expect(select).not.toBeNull();
+    if (!select) return;
+    select.selectedIndex = 4;
+    select.dispatchEvent(new Event('change', { bubbles: true }));
+    await fixture.whenStable();
+    fixture.detectChanges();
+
+    expect(component.page()?.items).toEqual(rows);
+    expect(component.pageCount()).toBe(1);
+    expect(operations.users.calls.allArgs().map(args => args.slice(0, 3))).toEqual([[1, 100, '测试'], [2, 100, '测试'], [3, 100, '测试']]);
+    for (const args of operations.users.calls.allArgs()) expect(args[3]?.['parentUserId']).toBe(7);
+    expect((fixture.nativeElement as HTMLElement).querySelector<HTMLButtonElement>('[aria-label="上一页"]')?.disabled).toBeTrue();
+    expect((fixture.nativeElement as HTMLElement).querySelector<HTMLButtonElement>('[aria-label="下一页"]')?.disabled).toBeTrue();
+
+    operations.users.calls.reset();
+    await component.refresh();
+    expect(component.page()?.items.length).toBe(237);
+    expect(operations.users.calls.allArgs().every(args => args[5] === true)).toBeTrue();
+  });
+
+  it('opens the page-size menu above the footer so all five choices fit on screen', () => {
+    const trigger = (fixture.nativeElement as HTMLElement).querySelector<HTMLButtonElement>('.page-size .xs-dropdown-trigger')!;
+    trigger.style.position = 'fixed';
+    trigger.style.bottom = '20px';
+    trigger.click();
+    const menu = document.querySelector<HTMLElement>('.xs-dropdown-menu')!;
+    expect(menu.getBoundingClientRect().bottom).toBeLessThanOrEqual(trigger.getBoundingClientRect().top - 5);
+    expect(menu.querySelectorAll('button').length).toBe(5);
+    expect(menu.scrollHeight).toBeLessThanOrEqual(menu.clientHeight + 1);
+  });
+
+  for (const scenario of ['changed total', 'duplicate member']) {
+    it(`keeps the previous page and reports an error for all-mode batches with a ${scenario}`, async () => {
+      const component = fixture.componentInstance, previous = component.page();
+      const rows = Array.from({ length: 120 }, (_, index) => ({ objectId: `member-${index}` })) as MemberPage['items'];
+      operations.users.and.callFake(async (page, pageSize) => ({
+        ...memberPage, page, pageSize,
+        total: page === 2 && scenario === 'changed total' ? 121 : 120,
+        items: page === 1 ? rows.slice(0, 100) : scenario === 'duplicate member' ? [rows[0], ...rows.slice(101)] : rows.slice(100),
+      }));
+      await component.changePageSize('all');
+      expect(component.page()).toBe(previous);
+      expect(component.error()).toContain('会员数据已变化');
+      expect(component.loading()).toBeFalse();
+    });
+  }
+
   it('shows an explicit searching state until the member query finishes', async () => {
     const component = fixture.componentInstance;
     let resolveRequest!: (value: MemberPage) => void;

+ 23 - 1
projects/xiaoshu-admin/src/app/pages/operations-users.component.ts

@@ -19,6 +19,7 @@ import { OperationsLiveRefreshService } from '../operations-live-refresh.service
 })
 export class OperationsUsersComponent implements OnInit, OnDestroy {
   readonly page = signal<MemberPage | null>(null);
+  readonly pageSize = signal<number | 'all'>(20);
   readonly query = signal(''); readonly status = signal('active'); readonly learningStatus = signal('studying'); readonly risk = signal('all');
   readonly parentUserId = signal(0); readonly registeredFrom = signal(''); readonly registeredTo = signal(''); readonly sortValue = signal('registeredAt:desc');
   readonly loading = signal(true); readonly searching = signal(false); readonly refreshing = signal(false); readonly saving = signal(false); readonly exporting = signal(false); readonly error = signal(''); readonly message = signal(''); readonly lastRefreshedAt = signal('');
@@ -41,10 +42,31 @@ export class OperationsUsersComponent implements OnInit, OnDestroy {
   async load(page = this.page()?.page ?? 1, refresh = false, background = false): Promise<void> {
     const request = ++this.loadRequest;
     if (!background) this.loading.set(true); this.error.set('');
-    try { const result = await this.operations.users(page, 20, this.query(), this.filters(), this.sort(), refresh); if (request !== this.loadRequest) return; this.page.set(result); this.lastRefreshedAt.set(result.refreshedAt || new Date().toISOString()); }
+    try {
+      const size = this.pageSize(), query = this.query(), filters = this.filters(), sort = this.sort();
+      let result = await this.operations.users(size === 'all' ? 1 : page, size === 'all' ? 100 : size, query, filters, sort, refresh);
+      if (request !== this.loadRequest) return;
+      if (size === 'all') {
+        // The existing API caps each response at 100 members.
+        const items = [...result.items];
+        for (let next = 2; (next - 1) * result.pageSize < result.total; next++) {
+          const batch = await this.operations.users(next, result.pageSize, query, filters, sort, refresh);
+          if (request !== this.loadRequest) return;
+          if (!batch.items.length || batch.total !== result.total) throw new Error('会员数据已变化,请刷新后重试');
+          items.push(...batch.items);
+        }
+        if (items.length !== result.total || new Set(items.map(item => item.objectId)).size !== items.length) throw new Error('会员数据已变化,请刷新后重试');
+        result = { ...result, items, page: 1, pageSize: Math.max(1, result.total) };
+      }
+      this.page.set(result); this.lastRefreshedAt.set(result.refreshedAt || new Date().toISOString());
+    }
     catch (error) { if (request === this.loadRequest) this.error.set(error instanceof Error ? error.message : '会员列表加载失败'); }
     finally { if (!background && request === this.loadRequest) this.loading.set(false); }
   }
+  async changePageSize(size: number | 'all'): Promise<void> {
+    this.pageSize.set(size);
+    await this.load(1);
+  }
   async searchMembers(): Promise<void> {
     if (this.searching()) return;
     this.searching.set(true);