Răsfoiți Sursa

fix(admin): unify store detail filters and responsive layout

彭峰 7 ore în urmă
părinte
comite
3bf81740cc

+ 15 - 5
projects/xiaoshu-admin/src/app/components/store-credit-account.component.html

@@ -1,16 +1,26 @@
 @if(!compact){
 <section class="credit-card" aria-label="门店额度账户">
- <header><div><h2>门店额度账户</h2><span>{{data()?.storeName}}</span></div><div class="actions"><button type="button" (click)="load()" [disabled]="loading()">刷新额度</button>@if(data()?.canPost){<button class="primary" type="button" (click)="start()">充值额度</button>}</div></header>
+ <header><div><h2>门店额度账户</h2><span>{{data()?.storeName}}</span></div><div class="actions"><button type="button" (click)="load()" [disabled]="loading()"><lucide-icon [img]="icons.RefreshCw" [size]="16" [class.spin]="loading()"/>{{loading()?'读取中…':'刷新额度'}}</button>@if(data()?.canPost){<button class="primary" type="button" (click)="start()">充值额度</button>}</div></header>
  @if(error()){<p class="error" role="alert">{{error()}}</p>}@if(message()){<p class="success" role="status">{{message()}}</p>}
  @if(loading()&&!data()){<p>正在读取门店额度…</p>}
  @if(data()){<div class="balances">@for(a of accounts;track a.key){<article><span>{{a.label}}</span><strong>{{balance(a.key)?.balance | number:'1.0-2'}} <small>{{a.unit}}</small></strong><small>可用 {{balance(a.key)?.available | number:'1.0-2'}} · 预留 {{balance(a.key)?.reserved | number:'1.0-2'}}</small></article>}</div>}
- <nav><button type="button" [class.active]="tab()==='accounts'" (click)="tab.set('accounts')">额度概览</button><button type="button" [class.active]="tab()==='ledger'" (click)="showLedger()">额度流水</button></nav>
+ <nav aria-label="门店额度视图"><button type="button" [class.active]="tab()==='accounts'" [attr.aria-pressed]="tab()==='accounts'" (click)="tab.set('accounts')">额度概览</button><button type="button" [class.active]="tab()==='ledger'" [attr.aria-pressed]="tab()==='ledger'" (click)="showLedger()">额度流水</button></nav>
  @if(tab()==='accounts'){<p>总部为门店入账,门店再向所属学生划拨。四类额度分别记账。</p>@if(data()&&!data()?.canPost){<p>{{isStore()?'本店额度由总部充值;为学生充值请进入学生的课时账户。':'当前账号可查看额度;充值需由超级管理员单独授权。'}}</p>}}
  @if(tab()==='ledger'){
- <div class="filters"><label>账户<select [(ngModel)]="accountFilter"><option value="">全部账户</option>@for(a of accounts;track a.key){<option [value]="a.key">{{a.label}}</option>}</select></label><label>业务<select [(ngModel)]="kindFilter"><option value="">全部业务</option>@for(k of ['fund','transfer','refund','adjust','recharge','deduction','lesson'];track k){<option [value]="k">{{kindLabel(k)}}</option>}</select></label><label>开始日期<input type="date" [(ngModel)]="dateFrom"/></label><label>结束日期<input type="date" [(ngModel)]="dateTo"/></label><button type="button" (click)="loadLedger(true)" [disabled]="ledgerBusy()">查询</button><button type="button" (click)="export()" [disabled]="exporting()">{{exporting()?'导出中…':'导出 Excel'}}</button></div>
+ <div class="filters" role="group" aria-label="额度流水筛选">
+  <label>账户<select aria-label="账户" [(ngModel)]="accountFilter"><option value="">全部账户</option>@for(a of accounts;track a.key){<option [value]="a.key">{{a.label}}</option>}</select></label>
+  <label>业务类型<select aria-label="业务类型" [(ngModel)]="kindFilter"><option value="">全部业务</option>@for(k of ['fund','transfer','refund','adjust','recharge','deduction','lesson'];track k){<option [value]="k">{{kindLabel(k)}}</option>}</select></label>
+  <app-operations-date-filter label="开始日期" [value]="dateFrom" (valueChange)="dateFrom=$event"/>
+  <app-operations-date-filter label="结束日期" [value]="dateTo" (valueChange)="dateTo=$event"/>
+  <div class="filter-actions">
+   <button class="primary" type="button" (click)="loadLedger(true)" [disabled]="ledgerBusy()"><lucide-icon [img]="icons.Search" [size]="16"/>{{ledgerBusy()?'查询中…':'查询'}}</button>
+   <button type="button" (click)="resetFilters()" [disabled]="ledgerBusy()"><lucide-icon [img]="icons.RotateCcw" [size]="16"/>重置</button>
+   <button class="export-button" type="button" (click)="export()" [disabled]="exporting()||ledgerBusy()"><lucide-icon [img]="icons.Download" [size]="16"/>{{exporting()?'导出中…':'导出 Excel'}}</button>
+  </div>
+ </div>
  @if(ledgerError()){<p class="error" role="alert">{{ledgerError()}}</p>}@if(ledgerBusy()){<p>正在读取流水…</p>}
- <div class="scroll"><table><thead><tr><th>时间 / 单号</th><th>账户 / 业务</th><th>变动</th><th>前后余额</th><th>操作人 / 原因</th><th>原单 / 操作</th></tr></thead><tbody>@for(row of ledger()?.items||[];track row.groupId){@for(l of row.lines;track $index){<tr><td>{{row.at|date:'yyyy-MM-dd HH:mm':'+0800'}}<small>{{row.groupId}}</small></td><td>{{label(l.account)}}<small>{{kindLabel(l.kind)}}</small></td><td [class.negative]="l.delta<0">{{l.delta>0?'+':''}}{{l.delta|number:'1.0-2'}}</td><td>{{l.before??'未记录'}} → {{l.after??'未记录'}}</td><td>{{l.operator||'未记录'}}<small>{{l.reason}}</small></td><td><small>{{l.sourceTransactionId||'—'}}</small>@if(data()?.canPost&&l.kind==='fund'&&l.reference){<button type="button" (click)="reverse(l)">纠正入账</button>}</td></tr>}}@empty{<tr><td colspan="6">{{ledgerError()?'流水暂不可用':ledgerBusy()?'读取中…':'没有符合条件的流水'}}</td></tr>}</tbody></table></div>
- <footer><span>共 {{ledger()?.total??'—'}} 笔 · 第 {{page}} 页</span><button type="button" (click)="page=page-1;loadLedger()" [disabled]="page<=1||ledgerBusy()">上一页</button><button type="button" (click)="page=page+1;loadLedger()" [disabled]="page*20>=(ledger()?.total||0)||ledgerBusy()">下一页</button></footer>
+ <div class="scroll ledger-table" role="region" aria-label="额度流水明细" tabindex="0" [attr.aria-busy]="ledgerBusy()" [class.has-entries]="ledger()?.items?.length"><table><thead><tr><th>时间 / 单号</th><th>账户 / 业务</th><th>变动</th><th>前后余额</th><th>操作人 / 原因</th><th>原单 / 操作</th></tr></thead><tbody>@for(row of ledger()?.items||[];track row.groupId){@for(l of row.lines;track $index){<tr><td>{{row.at|date:'yyyy-MM-dd HH:mm':'+0800'}}<small>{{row.groupId}}</small></td><td>{{label(l.account)}}<small>{{kindLabel(l.kind)}}</small></td><td [class.negative]="l.delta<0">{{l.delta>0?'+':''}}{{l.delta|number:'1.0-2'}}</td><td>{{l.before??'未记录'}} → {{l.after??'未记录'}}</td><td>{{l.operator||'未记录'}}<small>{{l.reason}}</small></td><td><small>{{l.sourceTransactionId||'—'}}</small>@if(data()?.canPost&&l.kind==='fund'&&l.reference){<button type="button" (click)="reverse(l)">纠正入账</button>}</td></tr>}}@empty{<tr><td colspan="6" class="empty-ledger">{{ledgerError()?'流水暂不可用':ledgerBusy()?'读取中…':'没有符合条件的流水'}}</td></tr>}</tbody></table></div>
+ <footer class="ledger-footer"><span>共 {{ledger()?.total??'—'}} 笔 · 第 {{page}} 页</span><div class="pagination-actions"><button type="button" (click)="page=page-1;loadLedger()" [disabled]="page<=1||ledgerBusy()">上一页</button><button type="button" (click)="page=page+1;loadLedger()" [disabled]="page*20>=(ledger()?.total||0)||ledgerBusy()">下一页</button></div></footer>
  }
 </section>}
 @if(open()){

+ 47 - 0
projects/xiaoshu-admin/src/app/components/store-credit-account.component.scss

@@ -23,3 +23,50 @@
 @media (prefers-reduced-motion: reduce) {
   .credit-dialog.credit-drawer { animation: none; }
 }
+
+/* Store account controls follow the same rhythm as the operations filters. */
+.credit-card { min-width: 0; }
+.credit-card header { align-items: flex-start; flex-wrap: wrap; }
+.credit-card button { display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 44px; white-space: nowrap; }
+.credit-card button:not(:disabled):hover { border-color: #7fae9f; background: #f0f7f4; }
+.credit-card button.primary:not(:disabled):hover { color: white; background: #15634f; }
+.credit-card .balances article { min-width: 0; }
+.credit-card .balances strong { font-variant-numeric: tabular-nums; }
+.credit-card nav { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 4px; padding: 4px; border: 1px solid #e1eae6; border-radius: 12px; background: #f1f7f4; }
+.credit-card nav button { border: 0; background: transparent; font-weight: 600; }
+.credit-card nav button.active { color: white; background: #19755e; box-shadow: 0 3px 8px #19755e1a; }
+.credit-card .filters { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)) auto; align-items: end; gap: 14px; margin: 24px 0; }
+.credit-card .filters > label { min-width: 0; margin: 0; gap: 7px; color: #60726d; font-size: 13px; font-weight: 600; }
+.credit-card .filters > app-operations-date-filter { --date-filter-height: 48px; min-width: 0; }
+.credit-card .filter-actions { display: flex; gap: 8px; }
+.credit-card .filter-actions button { min-height: 48px; font-size: 14px; }
+.credit-card .export-button { color: #19755e; background: #edf7f3; border-color: #c0ddd2; }
+.credit-card .ledger-table { border: 1px solid #e1eae6; border-radius: 12px; }
+.credit-card .ledger-table.has-entries table { min-width: 760px; }
+.credit-card .ledger-table th { white-space: nowrap; }
+.credit-card .ledger-table td { overflow-wrap: anywhere; }
+.credit-card .empty-ledger { height: 130px; text-align: center; vertical-align: middle; color: #6a7e78; border: 0; }
+.credit-card .ledger-footer { flex-wrap: wrap; padding-top: 4px; gap: 12px; color: #6a7e78; font-size: 14px; }
+.credit-card .pagination-actions { display: flex; gap: 8px; }
+.spin { animation: credit-spin .8s linear infinite; }
+@keyframes credit-spin { to { transform: rotate(360deg); } }
+@media (max-width: 1400px) {
+ .credit-card .filters { grid-template-columns: repeat(2,minmax(0,1fr)); }
+ .credit-card .filter-actions { grid-column: 1 / -1; }
+ .credit-card .filter-actions button { flex: 1; }
+}
+@media (max-width: 650px) {
+ .credit-card .actions { width: 100%; }
+ .credit-card .actions button { flex: 1; }
+ .credit-card .balances { gap: 10px; margin: 20px 0; }
+ .credit-card .balances article { padding: 14px; }
+ .credit-card .balances strong { font-size: 25px; }
+ .credit-card .filters { grid-template-columns: minmax(0,1fr); gap: 12px; }
+ .credit-card .filter-actions { flex-wrap: wrap; }
+ .credit-card .filter-actions button { padding-inline: 10px; }
+ .credit-card .ledger-table { overflow-x: auto; }
+ .credit-card .ledger-table table { min-width: 600px; }
+ .credit-card .ledger-table:not(.has-entries) table { min-width: 0; }
+ .credit-card .ledger-table:not(.has-entries) thead { display: none; }
+}
+@media (prefers-reduced-motion: reduce) { .spin { animation: none; } }

+ 2 - 0
projects/xiaoshu-admin/src/app/components/store-credit-account.component.spec.ts

@@ -11,6 +11,8 @@ describe('门店额度业务',()=>{
  beforeEach(async()=>{request=jasmine.createSpy('request').and.callFake(async(op:string)=>op.endsWith('credit-accounts')?data:op.endsWith('credit-preview')?plan:{items:[],total:0,version:'ledger-v1'});mutate=jasmine.createSpy('mutate').and.resolveTo({});await TestBed.configureTestingModule({imports:[StoreCreditAccountComponent],providers: [{provide:AdminSessionService,useValue:{session:()=>({operationsRole:'ops-manager'})}},{provide:OperationsService,useValue:{request,mutate}}]}).compileComponents();fixture=TestBed.createComponent(StoreCreditAccountComponent);fixture.componentRef.setInput('objectId','store');fixture.detectChanges();await fixture.whenStable();c=fixture.componentInstance;});
  afterEach(()=>fixture.destroy());
  function fill(){c.start();c.amounts={Purse:2,SilverCoin:3,UserExp:4,UserPoint:2.5};c.method='运营记账';c.reason='总部额度入账';}
+ it('日期倒置时不查询也不导出,避免把无效范围当成空流水',async()=>{c.dateFrom='2026-10-20';c.dateTo='2026-10-01';const count=request.calls.count();await c.loadLedger(true);await c.export();expect(request.calls.count()).toBe(count);expect(c.ledgerError()).toContain('开始日期不能晚于结束日期');});
+ it('重置清除账户、业务和日期并回到第一页查询',async()=>{c.accountFilter='Purse';c.kindFilter='fund';c.dateFrom='2026-10-01';c.dateTo='2026-10-15';c.page=3;c.resetFilters();await fixture.whenStable();expect(request.calls.mostRecent().args).toEqual(['ops/stores/credit-ledger',jasmine.objectContaining({account:'',kind:'',dateFrom:'',dateTo:'',page:1})]);});
  it('一次预览四类增量,只在确认后发送同一门店和版本',async()=>{fill();await c.check();expect(request.calls.mostRecent().args[1].payload.lines.length).toBe(4);expect(mutate).not.toHaveBeenCalled();await c.submit();expect(mutate.calls.mostRecent().args.slice(0,5)).toEqual(['ops/stores/credit-post','store','总部额度入账',{direction:'add',payMethod:'运营记账',lines:[{account:'Purse',amount:2},{account:'SilverCoin',amount:3},{account:'UserExp',amount:4},{account:'UserPoint',amount:2.5}]},'v1']);});
  it('预览返回后修改金额必须重新预览',async()=>{fill();await c.check();c.amounts['Purse']=9;await c.submit();expect(mutate).not.toHaveBeenCalled();});
  it('超时重试保留原请求号,防止重复入账',async()=>{fill();await c.check();mutate.and.rejectWith(new Error('网络超时'));await c.submit();const key=mutate.calls.mostRecent().args[5];expect(c.open()).toBeTrue();expect(c.preview()).not.toBeNull();mutate.and.resolveTo({});await c.submit();expect(mutate.calls.mostRecent().args[5]).toBe(key);});

+ 9 - 3
projects/xiaoshu-admin/src/app/components/store-credit-account.component.ts

@@ -2,17 +2,21 @@ import {AdminSessionService} from '../admin-session.service';
 import { CommonModule } from '@angular/common';
 import { Component, EventEmitter, Input, OnChanges, OnDestroy, Output, computed, inject, signal } from '@angular/core';
 import { FormsModule } from '@angular/forms';
+import { DropdownDirective } from '../shared/dropdown.directive';
+import { OperationsDateFilterComponent } from './operations-date-filter.component';
+import { Download, RefreshCw, Search, RotateCcw, LucideAngularModule } from 'lucide-angular';
 import { OperationsService } from '../operations.service';
 import { CreditAccounts } from '../member-enrollment.models';
 
 interface CreditLine {account:string;delta:number;before:number|null;after:number|null;availableBefore?:number;availableAfter?:number;kind?:string;operator?:string;reason?:string;reference?:string;sourceTransactionId?:string;source?:string;}
 interface CreditPlan {version:string;storeId:number;lines:CreditLine[];}
 interface Ledger {total:number;version:string;items:Array<{groupId:string;at:string;lines:CreditLine[]}>;}
-@Component({selector:'app-store-credit-account',standalone:true,imports:[CommonModule,FormsModule],templateUrl:'./store-credit-account.component.html',styleUrls:['./store-credit-account.component.scss']})
+@Component({selector:'app-store-credit-account',standalone:true,imports:[CommonModule,FormsModule,DropdownDirective,OperationsDateFilterComponent,LucideAngularModule],templateUrl:'./store-credit-account.component.html',styleUrls:['./store-credit-account.component.scss']})
 export class StoreCreditAccountComponent implements OnChanges,OnDestroy {
  readonly isStore=(()=>{const sessions=inject(AdminSessionService);return computed(()=>sessions.session()?.operationsRole==='store-admin');})();
  @Input({required:true}) objectId='';@Input() compact=false;@Input() initialOpen=false;@Output() closed=new EventEmitter<void>();@Output() posted=new EventEmitter<string>();
  private readonly operations=inject(OperationsService);private serial=0;private ledgerSerial=0;private destroyed=false;private key='';private fingerprint='';private previewFingerprint='';
+ readonly icons={Download,RefreshCw,Search,RotateCcw};
  readonly accounts=[{key:'Purse',label:'30分钟课时',unit:'节'},{key:'SilverCoin',label:'60分钟课时',unit:'节'},{key:'UserExp',label:'体验次数',unit:'次'},{key:'UserPoint',label:'陪练时长',unit:'小时'}];
  data=signal<CreditAccounts|null>(null);loading=signal(false);error=signal('');message=signal('');open=signal(false);busy=signal(false);preview=signal<CreditPlan|null>(null);ledger=signal<Ledger|null>(null);ledgerBusy=signal(false);ledgerError=signal('');exporting=signal(false);tab=signal<'accounts'|'ledger'>('accounts');
  amounts:Record<string,number>={Purse:0,SilverCoin:0,UserExp:0,UserPoint:0};method='';reason='';direction='add';sourceId='';sourceAccount='';accountFilter='';kindFilter='';dateFrom='';dateTo='';page=1;
@@ -32,6 +36,8 @@ export class StoreCreditAccountComponent implements OnChanges,OnDestroy {
  async submit(){const plan=this.preview();if(this.busy()||!plan||this.previewFingerprint!==this.currentFingerprint())return;const fingerprint=this.currentFingerprint();if(this.fingerprint!==fingerprint){this.key=crypto.randomUUID();this.fingerprint=fingerprint;}this.busy.set(true);this.error.set('');try{await this.operations.mutate('ops/stores/credit-post',this.objectId,this.reason.trim(),this.payload(),plan.version,this.key);this.preview.set(null);this.open.set(false);this.message.set(this.direction==='reverse'?'已纠正原入账,余额与流水已更新':'门店充值成功,四类额度已统一入账');this.key='';this.fingerprint='';await this.load();if(this.tab()==='ledger')await this.loadLedger();this.posted.emit(this.message());if(this.compact)this.closed.emit();}catch(e){this.error.set(e instanceof Error?e.message:'提交未确认,请保留当前内容重试');}finally{this.busy.set(false);}}
  params(page=this.page){return {objectId:this.objectId,page,pageSize:20,account:this.accountFilter,kind:this.kindFilter,dateFrom:this.dateFrom,dateTo:this.dateTo};}
  async showLedger(){this.tab.set('ledger');if(!this.ledger())await this.loadLedger();}
- async loadLedger(reset=false){if(reset)this.page=1;const serial=++this.ledgerSerial;this.ledgerBusy.set(true);this.ledgerError.set('');try{const result=await this.operations.request<Ledger>('ops/stores/credit-ledger',this.params());if(serial===this.ledgerSerial&&!this.destroyed)this.ledger.set(result);}catch(e){if(serial===this.ledgerSerial)this.ledgerError.set(e instanceof Error?e.message:'流水加载失败');}finally{if(serial===this.ledgerSerial)this.ledgerBusy.set(false);}}
- async export(){if(this.exporting())return;this.exporting.set(true);this.ledgerError.set('');const params={...this.params(1),pageSize:100};try{const first=await this.operations.request<Ledger>('ops/stores/credit-ledger',params),rows=[...first.items];for(let page=2;rows.length<first.total;page++){const next=await this.operations.request<Ledger>('ops/stores/credit-ledger',{...params,page});if(!next.items.length||next.version!==first.version||next.total!==first.total)throw Error('导出期间流水变化,请重试');rows.push(...next.items);}const latest=await this.operations.request<Ledger>('ops/stores/credit-ledger',params);if(latest.version!==first.version||new Set(rows.map(r=>r.groupId)).size!==rows.length)throw Error('导出期间流水变化,请重试');const xlsx=await import('xlsx'),book=xlsx.utils.book_new();xlsx.utils.book_append_sheet(book,xlsx.utils.json_to_sheet(rows.flatMap(r=>r.lines.map(l=>({'业务单号':r.groupId,'时间(北京时间)':new Date(r.at).toLocaleString('sv-SE',{timeZone:'Asia/Shanghai'}),'账户':this.label(l.account),'业务':this.kindLabel(l.kind),'变动':l.delta,'变动前':l.before??'未记录','变动后':l.after??'未记录','操作人':l.operator||'未记录','原因':l.reason||'','凭据':l.reference||'','关联原单':l.sourceTransactionId||'','来源':l.source==='legacy'?'历史流水':'新系统'})))),'门店额度流水');xlsx.writeFile(book,`门店${this.data()?.storeId||''}额度流水.xlsx`);}catch(e){this.ledgerError.set(e instanceof Error?e.message:'导出失败');}finally{this.exporting.set(false);}}
+ resetFilters(){this.accountFilter='';this.kindFilter='';this.dateFrom='';this.dateTo='';void this.loadLedger(true);}
+ private validDateRange(){if(this.dateFrom&&this.dateTo&&this.dateFrom>this.dateTo){this.ledgerError.set('开始日期不能晚于结束日期,请调整后查询');return false;}return true;}
+ async loadLedger(reset=false){if(!this.validDateRange())return;if(reset)this.page=1;const serial=++this.ledgerSerial;this.ledgerBusy.set(true);this.ledgerError.set('');try{const result=await this.operations.request<Ledger>('ops/stores/credit-ledger',this.params());if(serial===this.ledgerSerial&&!this.destroyed)this.ledger.set(result);}catch(e){if(serial===this.ledgerSerial)this.ledgerError.set(e instanceof Error?e.message:'流水加载失败');}finally{if(serial===this.ledgerSerial)this.ledgerBusy.set(false);}}
+ async export(){if(this.exporting()||!this.validDateRange())return;this.exporting.set(true);this.ledgerError.set('');const params={...this.params(1),pageSize:100};try{const first=await this.operations.request<Ledger>('ops/stores/credit-ledger',params),rows=[...first.items];for(let page=2;rows.length<first.total;page++){const next=await this.operations.request<Ledger>('ops/stores/credit-ledger',{...params,page});if(!next.items.length||next.version!==first.version||next.total!==first.total)throw Error('导出期间流水变化,请重试');rows.push(...next.items);}const latest=await this.operations.request<Ledger>('ops/stores/credit-ledger',params);if(latest.version!==first.version||new Set(rows.map(r=>r.groupId)).size!==rows.length)throw Error('导出期间流水变化,请重试');const xlsx=await import('xlsx'),book=xlsx.utils.book_new();xlsx.utils.book_append_sheet(book,xlsx.utils.json_to_sheet(rows.flatMap(r=>r.lines.map(l=>({'业务单号':r.groupId,'时间(北京时间)':new Date(r.at).toLocaleString('sv-SE',{timeZone:'Asia/Shanghai'}),'账户':this.label(l.account),'业务':this.kindLabel(l.kind),'变动':l.delta,'变动前':l.before??'未记录','变动后':l.after??'未记录','操作人':l.operator||'未记录','原因':l.reason||'','凭据':l.reference||'','关联原单':l.sourceTransactionId||'','来源':l.source==='legacy'?'历史流水':'新系统'})))),'门店额度流水');xlsx.writeFile(book,`门店${this.data()?.storeId||''}额度流水.xlsx`);}catch(e){this.ledgerError.set(e instanceof Error?e.message:'导出失败');}finally{this.exporting.set(false);}}
 }

+ 18 - 0
projects/xiaoshu-admin/src/app/pages/operations-store-detail.component.scss

@@ -103,3 +103,21 @@
   .summary-grid { grid-template-columns: 1fr 1fr; }
   .summary-grid strong { font-size: 23px; }
 }
+
+/* Keep the detail header and searches within the available content width. */
+:host { min-width: 0; width: 100%; }
+.detail-header { margin-top: 0; }
+.detail-header .header-actions { padding-top: 0; }
+.month-control { font-size: 13px; font-weight: 600; }
+.month-picker-trigger, .detail-header .header-actions > .ui-button { min-height: 48px; height: 48px; }
+.month-picker-trigger strong { font-size: 14px; }
+.inline-search { box-sizing: border-box; min-height: 48px; }
+.inline-search:focus-within { border-color: #2b8b6d; box-shadow: 0 0 0 3px rgba(43,139,109,.12); }
+.section-heading > div { min-width: 0; }
+@media (max-width: 1120px) {
+ .detail-header { grid-template-columns: minmax(0,1fr); gap: 16px; }
+ .detail-header .header-actions { display: grid; grid-template-columns: minmax(0,1fr) minmax(0,1fr); width: 100%; }
+}
+@media (max-width: 440px) {
+ .detail-header .header-actions { grid-template-columns: minmax(0,1fr); }
+}

+ 4 - 1
projects/xiaoshu-admin/src/app/pages/operations-store-detail.component.spec.ts

@@ -1,3 +1,4 @@
+import { AdminSessionService } from '../admin-session.service';
 import { ComponentFixture, TestBed } from '@angular/core/testing';
 import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router';
 import { StoreDetailData } from '../admin.models';
@@ -28,12 +29,14 @@ describe('OperationsStoreDetailComponent', () => {
   let operations: jasmine.SpyObj<OperationsService>;
 
   beforeEach(async () => {
-    operations = jasmine.createSpyObj<OperationsService>('OperationsService', ['storeDetail']);
+    operations = jasmine.createSpyObj<OperationsService>('OperationsService', ['storeDetail', 'request']);
+    operations.request.and.resolveTo({storeId: 2, storeName: '南昌总店', canPost: false, accounts: []});
     operations.storeDetail.and.callFake((_objectId, month) => Promise.resolve(storeDetail(month)));
     await TestBed.configureTestingModule({
       imports: [OperationsStoreDetailComponent],
       providers: [
         provideRouter([]),
+        { provide: AdminSessionService, useValue: { session: () => ({operationsRole: 'ops-manager'}) } },
         { provide: OperationsService, useValue: operations },
         { provide: ActivatedRoute, useValue: { snapshot: { paramMap: convertToParamMap({ objectId: 'store-1' }) } } },
       ],