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

Refresh mobile credit account layout

彭峰 6 дней назад
Родитель
Сommit
4b47eeace2

+ 63 - 6
projects/xiaoshu-mobile/src/app/features/finance/finance-page.component.html

@@ -1,8 +1,65 @@
 <section class="finance-page">
 <section class="finance-page">
-  <header><h2>课时账户</h2><p>课时用于学习,预留课时在预约结束或取消后更新。</p></header>
-  <div class="credit-grid">@for(account of accounts();track account.account){<button type="button" class="credit-card" [class.selected]="selected()===account.account" (click)="choose(account.account)"><strong>{{account.label}}</strong><b>{{account.available | number:'1.0-2'}} <small>{{account.unit}}可用</small></b><span>余额 {{account.balance | number:'1.0-2'}} · 已预留 {{account.reserved | number:'1.0-2'}}</span></button>}</div>
-  @if(error()){<div class="credit-error" role="alert">{{error()}} <button type="button" (click)="load()">重新加载</button></div>}
-  <div class="log-heading"><strong>课时明细</strong><button type="button" (click)="load()" [disabled]="loading()">刷新</button></div>
-  <div class="finance-logs">@for(item of logs();track item['ExpHisID'] ?? $index){<article><div><strong>{{item['Detail'] || item['Remark'] || '课时变动'}}</strong><span>{{date(item['HisTime'])}}</span></div><em>{{amount(item['Score']) | number:'1.0-2'}}</em></article>}@empty{<p>{{loading()?'正在加载…':error()?'':'暂无课时明细'}}</p>}</div>
-  <nav class="mobile-pagination"><button (click)="turn(-1)" [disabled]="loading()||page()<=1">上一页</button><span>{{page()}} / {{pages()}}</span><button (click)="turn(1)" [disabled]="loading()||page()>=pages()">下一页</button></nav>
+  <header class="finance-hero">
+    <div class="hero-copy">
+      <span>我的课时</span>
+      <h2>课时账户</h2>
+      <p>查看可用课时与使用明细,预留课时会在预约结束或取消后更新。</p>
+    </div>
+    @if (selectedAccount(); as account) {
+      <div class="hero-balance">
+        <span>{{ account.label }} · 当前可用</span>
+        <strong>{{ account.available | number:'1.0-2' }} <small>{{ account.unit }}</small></strong>
+      </div>
+    }
+  </header>
+
+  <div class="finance-content">
+    <section class="account-section" aria-labelledby="account-section-title">
+      <div class="section-heading">
+        <div><span>账户概览</span><h3 id="account-section-title">我的课时</h3></div>
+        <small>选择账户查看对应明细</small>
+      </div>
+      @if (accounts().length) {
+        <div class="credit-grid">
+          @for (account of accounts(); track account.account) {
+            <button type="button" class="credit-card" [class.selected]="selected() === account.account" [attr.aria-pressed]="selected() === account.account" (click)="choose(account.account)">
+              <span class="card-title"><strong>{{ account.label }}</strong><i aria-hidden="true"></i></span>
+              <span class="card-value"><b>{{ account.available | number:'1.0-2' }}</b><small>{{ account.unit }}可用</small></span>
+              <span class="card-meta"><span>总余额 {{ account.balance | number:'1.0-2' }}</span><span>已预留 {{ account.reserved | number:'1.0-2' }}</span></span>
+            </button>
+          }
+        </div>
+      } @else if (loading()) {
+        <p class="account-state">正在加载课时账户…</p>
+      }
+    </section>
+
+    @if (error()) {
+      <div class="credit-error" role="alert"><span>{{ error() }}</span><button type="button" (click)="load()">重新加载</button></div>
+    }
+
+    <section class="log-section" aria-labelledby="log-section-title">
+      <header class="log-heading">
+        <div><span>账户记录</span><h3 id="log-section-title">课时明细</h3></div>
+        <button type="button" (click)="load()" [disabled]="loading()">{{ loading() ? '刷新中…' : '刷新' }}</button>
+      </header>
+      <div class="finance-logs">
+        @for (item of logs(); track item['ExpHisID'] ?? $index) {
+          <article>
+            <div><strong>{{ item['Detail'] || item['Remark'] || '课时变动' }}</strong><time>{{ date(item['HisTime']) }}</time></div>
+            <em [class.debit]="amount(item['Score']) < 0">{{ amount(item['Score']) > 0 ? '+' : '' }}{{ amount(item['Score']) | number:'1.0-2' }}</em>
+          </article>
+        } @empty {
+          <div class="log-empty"><strong>{{ loading() ? '正在加载明细…' : error() ? '明细加载失败' : '暂无课时明细' }}</strong><span>{{ loading() ? '请稍候' : error() ? '请点击上方重新加载' : '此账户的课时变动会显示在这里' }}</span></div>
+        }
+      </div>
+      @if (pages() > 1) {
+        <nav class="finance-pagination" aria-label="课时明细分页">
+          <button type="button" (click)="turn(-1)" [disabled]="loading() || page() <= 1">上一页</button>
+          <span>第 {{ page() }} / {{ pages() }} 页</span>
+          <button type="button" (click)="turn(1)" [disabled]="loading() || page() >= pages()">下一页</button>
+        </nav>
+      }
+    </section>
+  </div>
 </section>
 </section>

+ 68 - 1
projects/xiaoshu-mobile/src/app/features/finance/finance-page.component.scss

@@ -1 +1,68 @@
-.finance-page{max-width:1100px;margin:24px auto;padding:24px;border:1px solid var(--border-color,#dce6e2);border-radius:20px;background:white}header p,.credit-card span,.finance-logs span{color:#71827c}.credit-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:16px;margin:24px 0}.credit-card{display:flex;flex-direction:column;gap:12px;text-align:left;border:1px solid #dce6e2;border-radius:16px;padding:20px;background:#fff;color:#203d34;cursor:pointer}.credit-card.selected{border-color:#18745d;background:#edf8f3}.credit-card b{font-size:24px}.credit-card small{font-size:12px}.credit-error{padding:16px;background:#fff0ed;color:#a73c30;border-radius:12px}.log-heading,.finance-logs article{display:flex;align-items:center;justify-content:space-between;gap:20px;padding:18px 0}.finance-logs article{border-bottom:1px solid #e7eeeb}.finance-logs article div{display:flex;flex-direction:column;gap:8px}.finance-logs em{font-style:normal;color:#18745d}button{font:inherit}.log-heading button,.credit-error button{padding:8px 16px;border:1px solid #dce6e2;border-radius:10px;background:white}@media(max-width:700px){.finance-page{margin:16px;padding:16px}.credit-grid{grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}.credit-card{padding:14px}}
+:host { display: block; min-height: 100%; background: var(--surface-muted); }
+
+.finance-page { max-width: 960px; min-height: 100%; margin: 0 auto; padding-bottom: 28px; }
+.finance-hero { display: flex; min-height: 158px; align-items: end; justify-content: space-between; padding: 28px 24px; color: white; background: var(--brand-strong); gap: 24px; }
+.hero-copy { min-width: 0; }
+.hero-copy > span, .hero-balance > span { font-size: 12px; font-weight: 650; letter-spacing: .03em; opacity: .82; }
+.hero-copy h2 { margin: 5px 0 8px; font-size: 26px; line-height: 1.25; }
+.hero-copy p { max-width: 490px; margin: 0; font-size: 13px; line-height: 1.6; opacity: .86; }
+.hero-balance { display: flex; min-width: 190px; align-items: flex-end; flex-direction: column; gap: 5px; }
+.hero-balance strong { font-size: 34px; line-height: 1.1; white-space: nowrap; }
+.hero-balance small { font-size: 13px; font-weight: 600; }
+.finance-content { display: grid; padding: 16px 14px 0; gap: 14px; }
+.account-section, .log-section { overflow: hidden; background: white; border: 1px solid var(--line); border-radius: var(--radius-lg); box-shadow: var(--shadow-sm); }
+.section-heading, .log-heading { display: flex; min-height: 68px; align-items: center; justify-content: space-between; padding: 13px 18px; border-bottom: 1px solid var(--line); gap: 12px; }
+.section-heading > div, .log-heading > div { display: grid; gap: 3px; }
+.section-heading span, .log-heading span { color: var(--ink-soft); font-size: 11px; }
+.section-heading h3, .log-heading h3 { margin: 0; color: var(--ink); font-size: 17px; }
+.section-heading > small { color: var(--ink-soft); font-size: 11px; text-align: right; }
+.credit-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); padding: 16px; gap: 10px; }
+.credit-card { display: flex; min-width: 0; min-height: 154px; padding: 15px; color: var(--ink); text-align: left; background: white; border: 1px solid var(--line); border-radius: var(--radius-md); cursor: pointer; flex-direction: column; gap: 9px; font: inherit; transition: background .15s, border-color .15s, box-shadow .15s; }
+.credit-card:hover { border-color: var(--brand); box-shadow: var(--shadow-xs); }
+.credit-card:focus-visible, .log-heading button:focus-visible, .finance-pagination button:focus-visible, .credit-error button:focus-visible { outline: 2px solid var(--brand-strong); outline-offset: 2px; }
+.credit-card.selected { background: var(--brand-wash); border-color: var(--brand-strong); box-shadow: inset 0 0 0 1px var(--brand-strong); }
+.card-title { display: flex; align-items: center; justify-content: space-between; gap: 6px; }
+.card-title strong { font-size: 13px; line-height: 1.3; }
+.card-title i { width: 15px; height: 15px; flex: none; border: 1px solid var(--line); border-radius: 50%; }
+.selected .card-title i { background: var(--brand-strong); border: 4px solid var(--brand-pale); box-shadow: 0 0 0 1px var(--brand-strong); }
+.card-value { display: flex; align-items: baseline; color: var(--brand-deep); gap: 5px; white-space: nowrap; }
+.card-value b { font-size: 26px; line-height: 1.1; }
+.card-value small { font-size: 11px; }
+.card-meta { display: flex; margin-top: auto; color: var(--ink-soft); flex-direction: column; gap: 2px; font-size: 11px; line-height: 1.4; }
+.account-state { margin: 0; padding: 24px 18px; color: var(--ink-soft); font-size: 13px; }
+.credit-error { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; color: var(--danger); background: var(--danger-pale); border: 1px solid color-mix(in srgb, var(--danger) 25%, var(--line)); border-radius: var(--radius-md); gap: 12px; font-size: 12px; }
+.credit-error button { min-height: 34px; flex: none; padding: 0 10px; color: var(--danger); background: white; border: 1px solid currentColor; border-radius: var(--radius-sm); font: inherit; font-weight: 700; cursor: pointer; }
+.log-heading button { min-height: 35px; padding: 0 13px; color: var(--brand-strong); background: var(--brand-pale); border: 1px solid color-mix(in srgb, var(--brand) 30%, var(--line)); border-radius: var(--radius-sm); font: inherit; font-size: 12px; font-weight: 700; cursor: pointer; }
+.log-heading button:disabled { opacity: .55; cursor: wait; }
+.finance-logs article { display: flex; min-height: 68px; align-items: center; justify-content: space-between; padding: 12px 18px; border-bottom: 1px solid var(--line-soft); gap: 16px; }
+.finance-logs article:last-child { border-bottom: 0; }
+.finance-logs article > div { display: grid; min-width: 0; gap: 5px; }
+.finance-logs strong { color: var(--ink); font-size: 13px; font-weight: 650; overflow-wrap: anywhere; }
+.finance-logs time { color: var(--ink-soft); font-size: 11px; }
+.finance-logs em { flex: none; color: var(--brand-strong); font-size: 15px; font-style: normal; font-weight: 750; white-space: nowrap; }
+.finance-logs em.debit { color: var(--ink); }
+.log-empty { display: flex; min-height: 138px; align-items: center; justify-content: center; padding: 24px; text-align: center; flex-direction: column; gap: 6px; }
+.log-empty span { color: var(--ink-soft); font-size: 12px; }
+.finance-pagination { display: grid; grid-template-columns: 94px auto 94px; align-items: center; justify-content: center; padding: 13px 16px; background: var(--surface-muted); border-top: 1px solid var(--line); gap: 12px; }
+.finance-pagination span { color: var(--ink-soft); font-size: 12px; font-weight: 700; white-space: nowrap; }
+.finance-pagination button { min-height: 38px; padding: 0 10px; color: var(--brand-strong); background: white; border: 1px solid var(--line); border-radius: var(--radius-sm); font: inherit; font-size: 12px; font-weight: 700; cursor: pointer; }
+.finance-pagination button:disabled { color: var(--ink-faint); background: var(--surface-muted); cursor: not-allowed; }
+
+@media (max-width: 700px) {
+  .finance-hero { min-height: 155px; align-items: flex-start; padding: 23px 18px; flex-direction: column; gap: 14px; }
+  .hero-copy h2 { font-size: 23px; }
+  .hero-balance { min-width: 0; align-items: baseline; flex-direction: row; gap: 12px; }
+  .hero-balance strong { font-size: 27px; }
+  .credit-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); padding: 12px; gap: 8px; }
+  .credit-card { min-height: 143px; padding: 12px; }
+  .card-value b { font-size: 22px; }
+}
+
+@media (max-width: 380px) {
+  .finance-content { padding-inline: 10px; }
+  .section-heading, .log-heading { padding-inline: 13px; }
+  .credit-grid { padding: 10px; }
+  .credit-card { padding: 10px; }
+  .card-title strong { font-size: 12px; }
+  .card-value { flex-wrap: wrap; }
+}

+ 2 - 1
projects/xiaoshu-mobile/src/app/features/finance/finance-page.component.ts

@@ -1,5 +1,5 @@
 import { CommonModule } from '@angular/common';
 import { CommonModule } from '@angular/common';
-import { ChangeDetectionStrategy, Component, inject, OnDestroy, OnInit, signal } from '@angular/core';
+import { ChangeDetectionStrategy, Component, computed, inject, OnDestroy, OnInit, signal } from '@angular/core';
 import { ActivatedRoute } from '@angular/router';
 import { ActivatedRoute } from '@angular/router';
 import { finalize, Subscription } from 'rxjs';
 import { finalize, Subscription } from 'rxjs';
 import { ApiError, ApiService } from '../../core/api.service';
 import { ApiError, ApiService } from '../../core/api.service';
@@ -10,6 +10,7 @@ interface CreditAccount { account: string; label: string; historyType: number; b
 export class FinancePageComponent implements OnInit, OnDestroy {
 export class FinancePageComponent implements OnInit, OnDestroy {
   readonly accounts=signal<CreditAccount[]>([]); readonly logs=signal<Record<string,unknown>[]>([]);
   readonly accounts=signal<CreditAccount[]>([]); readonly logs=signal<Record<string,unknown>[]>([]);
   readonly selected=signal('Purse'); readonly page=signal(1); readonly pages=signal(1); readonly loading=signal(false); readonly error=signal('');
   readonly selected=signal('Purse'); readonly page=signal(1); readonly pages=signal(1); readonly loading=signal(false); readonly error=signal('');
+  readonly selectedAccount=computed(()=>this.accounts().find(account=>account.account===this.selected())??null);
   private readonly api=inject(ApiService); private readonly session=inject(SessionService); private readonly route=inject(ActivatedRoute);
   private readonly api=inject(ApiService); private readonly session=inject(SessionService); private readonly route=inject(ActivatedRoute);
   private readonly liveData=inject(LiveDataRefreshService);
   private readonly liveData=inject(LiveDataRefreshService);
   private request?:Subscription; private requestId=0; private stopLiveRefresh?:()=>void;
   private request?:Subscription; private requestId=0; private stopLiveRefresh?:()=>void;