Procházet zdrojové kódy

ui: display member credit accounts and ledger as full-width tables

彭峰 před 3 hodinami
rodič
revize
67bb4b4475

+ 36 - 2
projects/xiaoshu-admin/src/app/components/member-credit-account.component.html

@@ -6,7 +6,17 @@
 <p>{{store?'总部入账后,可向所属学员划拨课时。':'充值来源:'+(value.storeName||'尚未关联门店,请先在会员资料中关联有效门店')}}</p>
 @if(!store){<p>体验资格:{{value.trialStatus==='issued'?'已发放过,不可再次领取':value.trialStatus==='eligible'?'可发放 1 次':'待核验旧系统体验记录'}} · 退回体验额度不恢复领取资格</p>@if(!isStore()&&value.canPost&&value.trialStatus==='pending'){<button type="button" (click)="importHistory()" [disabled]="saving()">核验旧体验并补齐历史流水</button>}}
 </div>
-<div class="accounts" [class.member-credit-summary]="!store">@for(a of value.accounts;track a.account){<article><strong>{{a.label}}</strong><b>{{a.available | number:'1.0-2'}}</b><span>可用 · 余额 {{a.balance}} · 已预留 {{a.reserved}}</span>@if(a.balance<0){<small class="error">历史负余额,需核对</small>}@if(!store){<div class="account-actions">@if(value.canPost){<button type="button" (click)="chooseAccount(a.account)">充值</button>}<button type="button" (click)="chooseAccount(a.account,true)">明细</button></div>}</article>}</div>
+@if(store){<div class="accounts">@for(a of value.accounts;track a.account){<article><strong>{{a.label}}</strong><b>{{a.available | number:'1.0-2'}}</b><span>可用 · 余额 {{a.balance}} · 已预留 {{a.reserved}}</span>@if(a.balance<0){<small class="error">历史负余额,需核对</small>}</article>}</div>}
+@else{
+<div class="scroll credit-table-wrap account-table-wrap" tabindex="0" role="region" aria-label="会员课时账户">
+  <table class="account-table">
+    <thead><tr><th scope="col">课时账户</th><th scope="col" class="numeric">可用课时</th><th scope="col" class="numeric">账户余额</th><th scope="col" class="numeric">已预留</th><th scope="col">账户状态</th><th scope="col" class="actions-cell">操作</th></tr></thead>
+    <tbody>@for(a of value.accounts;track a.account){
+      <tr><th scope="row">{{a.label}}</th><td class="numeric available-credit">{{a.available | number:'1.0-2'}}</td><td class="numeric">{{a.balance | number:'1.0-2'}}</td><td class="numeric">{{a.reserved | number:'1.0-2'}}</td><td>@if(a.balance<0){<span class="error">历史负余额,需核对</span>}@else{<span class="muted">正常</span>}</td><td><div class="account-actions">@if(value.canPost){<button type="button" (click)="chooseAccount(a.account)" [attr.aria-label]="'充值'+a.label">充值</button>}<button type="button" (click)="chooseAccount(a.account,true)" [attr.aria-label]="'查看'+a.label+'明细'">明细</button></div></td></tr>
+    }</tbody>
+  </table>
+</div>
+}
 @if(!value.canPost){<p>当前账号没有充值或扣回权限,可查看账户和流水。</p>}
 @if(value.canPost){<form (ngSubmit)="store?submit():check()" (input)="resetPreview()" (change)="resetPreview()"><fieldset [class.credit-entry-fields]="!store" [disabled]="saving()||loading()">
 @if(!store){<label class="credit-direction">业务类型<select name="direction" [(ngModel)]="direction" (ngModelChange)="sourceId='';resetPreview()"><option value="add">门店划拨充值</option><option value="deduct">原单扣回并退回门店</option>@if(!isStore()){<option value="adjust">历史差错调整</option>}</select></label>}
@@ -30,7 +40,31 @@
 @if(!store){<section class="ledger-panel"><header class="section-header"><h3>全部课时流水</h3><button type="button" (click)="export()" [disabled]="exporting()">{{exporting()?'正在导出…':'导出 Excel'}}</button></header><p>时间均为北京时间;成套充值按一笔业务展示,可展开查看双方账户明细。</p>
 <div class="form-grid ledger-filters"><label>账户<select [(ngModel)]="filterAccount"><option value="">全部账户</option>@for(a of data()?.accounts||[];track a.account){<option [value]="a.account">{{a.label}}</option>}</select></label><label class="credit-direction">业务类型<select [(ngModel)]="filterKind"><option value="">全部业务</option>@for(k of ['transfer','refund','adjust','lesson','recharge','deduction'];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="account-actions"><button type="button" (click)="query()">查询</button><button type="button" (click)="query(true)">重置</button></div></div>
 @if(ledgerError()){<p role="alert" class="error">{{ledgerError()}}</p>}@if(ledgerBusy()){<p role="status">正在加载流水…</p>}
-@for(row of ledger()?.items||[];track row.groupId){<details class="ledger-entry"><summary><time>{{row.at|date:'yyyy-MM-dd HH:mm':'+0800'}}</time><strong>{{kindLabel(row.lines[0].kind)}}</strong><span>@for(l of row.lines;track l.account){ {{label(l.account)}} {{l.delta>0?'+':''}}{{l.delta}} }</span><small>{{row.lines[0].reason||'未记录'}}</small></summary><div class="scroll"><table><thead><tr><th>账户</th><th>增减</th><th>调整前</th><th>调整后</th><th>操作人</th><th>来源与单据</th><th>门店</th></tr></thead><tbody>@for(l of row.lines;track l.account){<tr><td>{{label(l.account)}}</td><td>{{l.delta}}</td><td>{{l.before??'未记录'}}</td><td>{{l.after??'未记录'}}</td><td>{{l.operator||'未记录'}}</td><td>{{l.source==='legacy'?'旧系统':'新系统'}}<small>{{l.reference}}</small>@if(l.lessonId&&!isStore()){<button type="button" (click)="lessonId=l.lessonId">查看训练记录</button>}</td><td>{{l.storeId||'未记录'}}@if(l.balances){<small>余额 {{l.balances.storeBefore??'—'}} → {{l.balances.storeAfter??'—'}}</small>}</td></tr>}</tbody></table></div><p>原因:{{row.lines[0].reason||'未记录'}}</p></details>}@empty{ @if(!ledgerBusy()&&!ledgerError()){<p>暂无符合条件的流水</p>} }
+<div class="scroll credit-table-wrap ledger-table-wrap" tabindex="0" role="region" aria-label="全部课时流水表格" [attr.aria-busy]="ledgerBusy()">
+  <table class="ledger-table">
+    <thead><tr><th scope="col">时间(北京时间)</th><th scope="col">业务类型</th><th scope="col">账户与课时变动</th><th scope="col" class="reason-cell">操作原因</th><th scope="col">操作人</th><th scope="col">来源</th><th scope="col" class="actions-cell">操作</th></tr></thead>
+    <tbody>
+      @for(row of ledger()?.items||[];track row.groupId){
+        <tr class="ledger-entry" [class.expanded]="expandedLedger().has(row.groupId)">
+          <td><time>{{row.at|date:'yyyy-MM-dd HH:mm':'+0800'}}</time></td>
+          <td><strong>{{kindLabel(row.lines[0].kind)}}</strong></td>
+          <td><div class="ledger-changes">@for(l of row.lines;track l.account){<div><span>{{label(l.account)}}</span><b class="numeric" [class.credit-increase]="l.delta>0" [class.credit-decrease]="l.delta<0">{{l.delta>0?'+':''}}{{l.delta | number:'1.0-2'}}</b></div>}</div></td>
+          <td class="reason-cell">{{row.lines[0].reason||'未记录'}}</td>
+          <td>{{row.lines[0].operator||'未记录'}}</td>
+          <td>{{row.lines[0].source==='legacy'?'旧系统':'新系统'}}</td>
+          <td><button type="button" class="ledger-toggle" [attr.aria-expanded]="expandedLedger().has(row.groupId)" [attr.aria-controls]="'credit-detail-'+row.groupId" (click)="toggleLedger(row.groupId)">{{expandedLedger().has(row.groupId)?'收起明细':'展开明细'}}</button></td>
+        </tr>
+        @if(expandedLedger().has(row.groupId)){
+          <tr class="ledger-detail" [id]="'credit-detail-'+row.groupId"><td colspan="7">
+            <div class="scroll ledger-detail-scroll"><table aria-label="账户变动明细"><thead><tr><th scope="col">账户</th><th scope="col" class="numeric">增减</th><th scope="col" class="numeric">调整前</th><th scope="col" class="numeric">调整后</th><th scope="col">操作人</th><th scope="col">来源与单据</th><th scope="col">门店</th></tr></thead><tbody>
+              @for(l of row.lines;track l.account){<tr><th scope="row">{{label(l.account)}}</th><td class="numeric" [class.credit-increase]="l.delta>0" [class.credit-decrease]="l.delta<0">{{l.delta>0?'+':''}}{{l.delta}}</td><td class="numeric">{{l.before??'未记录'}}</td><td class="numeric">{{l.after??'未记录'}}</td><td>{{l.operator||'未记录'}}</td><td>{{l.source==='legacy'?'旧系统':'新系统'}}<small>{{l.reference}}</small>@if(l.lessonId&&!isStore()){<button type="button" (click)="lessonId=l.lessonId">查看训练记录</button>}</td><td>{{l.storeId||'未记录'}}@if(l.balances){<small>余额 {{l.balances.storeBefore??'—'}} → {{l.balances.storeAfter??'—'}}</small>}</td></tr>}
+            </tbody></table></div><p>原因:{{row.lines[0].reason||'未记录'}}</p>
+          </td></tr>
+        }
+      }@empty{@if(!ledgerBusy()&&!ledgerError()){<tr><td colspan="7">暂无符合条件的流水</td></tr>}}
+    </tbody>
+  </table>
+</div>
 <div class="section-header"><span>共 {{ledger()?.total||0}} 笔业务</span><label>每页<select [(ngModel)]="pageSize" (ngModelChange)="query()"><option [ngValue]="20">20 条</option><option [ngValue]="50">50 条</option><option [ngValue]="100">100 条</option></select></label><div class="account-actions"><button type="button" (click)="turn(-1)" [disabled]="page<=1||ledgerBusy()">上一页</button><span>{{page}}</span><button type="button" (click)="turn(1)" [disabled]="page*pageSize>=(ledger()?.total||0)||ledgerBusy()">下一页</button></div></div></section>}
 </section>
 @if(lessonId){<app-lesson-record-detail [lessonId]="lessonId" (closed)="lessonId=''"/>}

+ 4 - 2
projects/xiaoshu-admin/src/app/components/member-credit-account.component.ts

@@ -19,8 +19,10 @@ export class MemberCreditAccountComponent implements OnChanges {
  private host=inject(ElementRef<HTMLElement>);private operations=inject(OperationsService);loading=signal(false);private serial=0;data=signal<CreditAccounts|null>(null);error=signal('');message=signal('');saving=signal(false);preview=signal<CreditPreview|null>(null);
  ledger=signal<LedgerPage|null>(null);ledgerBusy=signal(false);exporting=signal(false);ledgerError=signal('');private ledgerSerial=0;lessonId='';
  account:TeachingCreditAccount='Purse';amount=0;direction='add';sourceId='';method='';reason='';adjustSign='deduct';adjustMode='paired';evidence='';private fingerprint='';private key='';private previewFingerprint='';
+ readonly expandedLedger=signal(new Set<string>());
+ toggleLedger(groupId:string){this.expandedLedger.update(current=>{const next=new Set(current);if(next.has(groupId))next.delete(groupId);else next.add(groupId);return next;});}
  filterAccount='';filterKind='';dateFrom='';dateTo='';page=1;pageSize=20;
- ngOnChanges(){this.preview.set(null);void this.load();if(!this.store)void this.loadLedger();}
+ ngOnChanges(){this.expandedLedger.set(new Set());this.preview.set(null);void this.load();if(!this.store)void this.loadLedger();}
  async load(){if(!this.objectId)return;const serial=++this.serial;this.loading.set(true);this.error.set('');try{const result=await this.operations.request<CreditAccounts>(this.store?'ops/stores/credit-accounts':'ops/users/credit-accounts',{objectId:this.objectId});if(serial===this.serial)this.data.set(result);}catch(e){if(serial===this.serial){this.data.set(null);this.error.set(e instanceof Error?e.message:'账户加载失败');}}finally{if(serial===this.serial)this.loading.set(false);}}
  label(account:string){return this.data()?.accounts.find(a=>a.account===account)?.label||account;}
  kindLabel(kind:string){return ({fund:'总部入账',transfer:'门店充值',refund:'原单扣回',adjust:'差错调整',lesson:'完课扣减',recharge:'历史充值',deduction:'历史扣减'} as Record<string,string>)[kind]||kind;}
@@ -34,7 +36,7 @@ export class MemberCreditAccountComponent implements OnChanges {
  async submit(){if(this.saving()||this.loading()||!this.valid())return;this.error.set('');this.message.set('');const payload=this.payload(),fingerprint=JSON.stringify([this.objectId,payload,this.reason]);if(!this.store&&(!this.preview()||fingerprint!==this.previewFingerprint)){this.preview.set(null);this.error.set('请先预览本次账户变动');return;}if(fingerprint!==this.fingerprint){this.fingerprint=fingerprint;this.key=crypto.randomUUID();}
  this.saving.set(true);try{await this.operations.mutate(this.store?'ops/stores/credit-post':'ops/users/adjust-balance',this.objectId,this.reason,payload,this.preview()?.version||'',this.key);this.amount=0;this.reason='';this.key='';this.fingerprint='';this.preview.set(null);this.message.set('课时业务已完成,余额和流水已更新');await this.load();if(!this.store)await this.loadLedger();this.changed.emit();}catch(e){this.error.set(e instanceof Error?e.message:'课时业务失败');}finally{this.saving.set(false);}}
  params(){return{objectId:this.objectId,account:this.filterAccount,kind:this.filterKind,dateFrom:this.dateFrom,dateTo:this.dateTo,page:this.page,pageSize:this.pageSize};}
- async loadLedger(){const serial=++this.ledgerSerial;this.ledgerBusy.set(true);this.ledgerError.set('');try{const result=await this.operations.request<LedgerPage>('ops/users/credit-ledger',this.params());if(serial===this.ledgerSerial)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 loadLedger(){const serial=++this.ledgerSerial;this.ledgerBusy.set(true);this.ledgerError.set('');try{const result=await this.operations.request<LedgerPage>('ops/users/credit-ledger',this.params());if(serial===this.ledgerSerial){this.ledger.set(result);this.expandedLedger.set(new Set());}}catch(e){if(serial===this.ledgerSerial)this.ledgerError.set(e instanceof Error?e.message:'流水加载失败');}finally{if(serial===this.ledgerSerial)this.ledgerBusy.set(false);}}
  query(clear=false){if(clear){this.filterAccount='';this.filterKind='';this.dateFrom='';this.dateTo='';}this.page=1;void this.loadLedger();}
  turn(delta:number){this.page+=delta;void this.loadLedger();}
  async importHistory(){if(this.saving())return;this.saving.set(true);this.error.set('');try{const r=await this.operations.request<{created:number}>('ops/users/import-ledger',{objectId:this.objectId,runId:crypto.randomUUID()});this.message.set('旧系统核验完成,补齐 '+r.created+' 条流水;现有余额未改变');await this.load();await this.loadLedger();}catch(e){this.error.set(e instanceof Error?e.message:'历史核验失败,请稍后重试');}finally{this.saving.set(false);}}

+ 34 - 16
projects/xiaoshu-admin/src/app/components/member-enrollment.scss

@@ -32,7 +32,7 @@ input[type=checkbox]:disabled { cursor:not-allowed; }
 .rules span { padding:8px 10px; background:#edf5f0; border:1px solid #dce9e1; border-radius:8px; }
 @media(max-width:900px) { .accounts{grid-template-columns:repeat(2,minmax(0,1fr))}fieldset{grid-template-columns:repeat(2,minmax(0,1fr))} }
 @media(max-width:650px) { .panel{padding:16px}.form-grid,fieldset{grid-template-columns:1fr}.wide{grid-column:auto}.accounts{gap:8px}article{padding:12px}.form-actions button{width:100%} }
-.account-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.account-actions button{padding:7px 12px}.preview-panel{background:#edf7f2;border:1px solid #bddbcf;border-radius:12px;padding:18px;margin-bottom:24px}.ledger-panel{border-top:1px solid #dfe7e4;margin-top:24px;padding-top:24px}.ledger-entry{border:1px solid #dfe7e4;border-radius:10px;margin:12px 0;padding:14px}.ledger-entry summary{cursor:pointer;display:list-item;color:#294c3f}.ledger-entry summary>*{margin-right:16px}.ledger-entry small{overflow-wrap:anywhere}.ledger-entry .scroll{max-height:none}.ledger-entry time{font-variant-numeric:tabular-nums}.ledger-entry p{margin-bottom:0}
+.account-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.account-actions button{padding:7px 12px}.preview-panel{background:#edf7f2;border:1px solid #bddbcf;border-radius:12px;padding:18px;margin-bottom:24px}.ledger-panel{border-top:1px solid #dfe7e4;margin-top:24px;padding-top:24px}
 
 .credit-panel a{color:#1b735d;font-weight:600;text-underline-offset:3px}.credit-panel form,.ledger-panel{scroll-margin-top:90px}
 
@@ -40,22 +40,40 @@ input[type=checkbox]:disabled { cursor:not-allowed; }
   display:flex; flex-wrap:wrap; align-items:center; gap:6px 24px; margin:10px 0 12px;
   > p { margin:0; font-size:13px; }
 }
-.member-credit-summary {
-  margin:12px 0 16px;
-  > article {
-    display:grid; grid-template-columns:minmax(0,1fr) auto; align-items:center; gap:6px 12px; padding:14px;
-    > strong { overflow-wrap:anywhere; }
-    > b { text-align:right; }
-    > span, > small, > .account-actions { grid-column:1 / -1; }
-    > span { overflow-wrap:anywhere; }
-    > .account-actions {
-      align-self:end; flex-wrap:nowrap; margin-top:6px;
-      > button { flex:1 1 0; min-width:0; min-height:44px; }
-    }
-  }
+// Member balances and ledger share the full available content width.
+.credit-panel { min-width:0; }
+.credit-table-wrap {
+  width:100%; max-width:100%; box-sizing:border-box; max-height:none; margin:16px 0;
+  &:focus-visible { outline:2px solid #23876a; outline-offset:3px; }
+  table { width:100%; }
+  thead th { position:static; }
+  tbody th { position:static; background:transparent; color:#294c3f; white-space:nowrap; }
+  td, th { vertical-align:middle; }
+  .numeric { text-align:right; font-variant-numeric:tabular-nums; white-space:nowrap; }
+  .available-credit { color:#17674f; font-size:20px; font-weight:700; }
+  .muted { color:#657c6e; }
+  .actions-cell { width:1%; white-space:nowrap; }
+  .account-actions { flex-wrap:nowrap; }
+  button { white-space:nowrap; min-height:40px; }
 }
-@media(max-width:480px) {
-  .member-credit-summary { grid-template-columns:1fr; }
+.account-table { min-width:720px; }
+.ledger-table {
+  min-width:1050px;
+  > thead > tr > th, > tbody > .ledger-entry > td { padding:12px 16px; }
+  .reason-cell { width:28%; min-width:180px; white-space:normal; overflow-wrap:anywhere; }
+  .ledger-entry { color:#294c3f; }
+  .ledger-entry:hover > td, .ledger-entry.expanded > td { background:#f4f9f6; }
+  time { white-space:nowrap; font-variant-numeric:tabular-nums; }
+  .ledger-changes { display:grid; gap:4px; min-width:170px; }
+  .ledger-changes > div { display:flex; align-items:baseline; justify-content:space-between; gap:20px; white-space:nowrap; }
+  .credit-increase { color:#17674f; }
+  .credit-decrease { color:#a33f48; }
+  .ledger-toggle { color:#17674f; }
+  .ledger-detail > td { padding:16px; text-align:left; white-space:normal; background:#f4f9f6; }
+  .ledger-detail-scroll { max-height:none; margin:0; background:#fff; }
+  .ledger-detail-scroll table { min-width:900px; }
+  .ledger-detail small { overflow-wrap:anywhere; }
+  .ledger-detail p { margin:10px 0 0; font-size:13px; overflow-wrap:anywhere; }
 }
 
 // Flexible fields fill each row; numeric inputs stay narrow and actions form a shared footer.

+ 2 - 2
scripts/check-member-credit-browser.mjs

@@ -7,8 +7,8 @@ export async function checkMemberCreditBrowser({auth,output,memberObjectId}){
  try{
  const context=await browser.newContext({viewport:{width:1440,height:1050}});await context.addInitScript(auth=>{localStorage.setItem('xiaoshu_admin_session_v1',JSON.stringify(auth));sessionStorage.setItem('xiaoshu_admin_session_v1',JSON.stringify(auth));},auth);
  const page=await context.newPage();page.on('pageerror',e=>errors.push(e.message));await page.goto('http://127.0.0.1:4300/admin/members/'+memberObjectId+'?tab=balances');
- const panel=page.locator('app-member-credit-account');await panel.locator('.ledger-entry').first().waitFor({timeout:60000});assert.equal(await panel.locator('.accounts article').count(),4);assert.equal(await panel.locator('.ledger-entry').count(),10);checks.push('真实会员四类账户与 10 条历史流水');
- await panel.locator('.ledger-entry').first().locator('summary').click();await panel.getByRole('columnheader',{name:'调整前',exact:true}).waitFor();checks.push('展开原始流水,前后余额、操作人、来源单据可见');
+ const panel=page.locator('app-member-credit-account');await panel.locator('.ledger-entry').first().waitFor({timeout:60000});assert.equal(await panel.locator('.account-table tbody tr').count(),4);assert.equal(await panel.locator('.ledger-entry').count(),10);checks.push('真实会员四类账户与 10 条历史流水');
+ await panel.locator('.ledger-entry').first().locator('.ledger-toggle').click();await panel.getByRole('columnheader',{name:'调整前',exact:true}).waitFor();checks.push('展开原始流水,前后余额、操作人、来源单据可见');
  const dates=await panel.locator('.ledger-entry time').allTextContents();assert.deepEqual(dates,[...dates].sort().reverse());
  const ledger=panel.locator('.ledger-panel'),account=ledger.getByLabel('账户',{exact:true});await account.click();await page.getByRole('option',{name:'60分钟课时',exact:true}).click();await ledger.getByRole('button',{name:'查询',exact:true}).click();await page.waitForFunction(()=>document.querySelectorAll('.ledger-entry').length===5);checks.push('60 分钟账户筛选 5 条,北京时间倒序');
  const downloadPromise=page.waitForEvent('download');await ledger.getByRole('button',{name:'导出 Excel',exact:true}).click();const download=await downloadPromise;await download.saveAs(output+'/credits.xlsx');const book=XLSX.read(readFileSync(output+'/credits.xlsx'));assert.equal(XLSX.utils.sheet_to_json(book.Sheets[book.SheetNames[0]]).length,5);checks.push('真实筛选导出 5 行,与明细一致');