Răsfoiți Sursa

feat: add dashboard access gate

gangvy 1 lună în urmă
părinte
comite
39f43869b4

+ 44 - 24
client/src/app/api.service.ts

@@ -1,6 +1,6 @@
-import { Injectable } from '@angular/core';
-import { HttpClient } from '@angular/common/http';
-import { Observable, catchError, shareReplay, throwError } from 'rxjs';
+import { Injectable, signal } from '@angular/core';
+import { HttpClient, HttpErrorResponse, HttpHeaders } from '@angular/common/http';
+import { Observable, catchError, shareReplay, tap, throwError } from 'rxjs';
 
 export interface UpstreamStatus {
   provider: string;
@@ -201,21 +201,39 @@ export interface FmodeApiTopups {
 
 @Injectable({ providedIn: 'root' })
 export class ApiService {
-  /** 后端为 future-server 的 /api/profit 子路由,纯数据接口 */
-  private readonly base = '/api/profit/api';
-  private readonly key: string;
+  private readonly base = 'https://server.fmode.cn/api/profit/api';
+  private readonly sessionKey = 'voc-profit-key';
+  private key = '';
   private readonly cache = new Map<string, Observable<unknown>>();
+  readonly authenticated = signal(false);
 
   constructor(private http: HttpClient) {
-    this.key =
-      new URLSearchParams(location.search).get('key') ||
-      localStorage.getItem('voc-profit-key') ||
-      'fmode-voc-profit';
-    localStorage.setItem('voc-profit-key', this.key);
+    localStorage.removeItem(this.sessionKey);
   }
 
-  private q(params: string): string {
-    return `${params}&key=${encodeURIComponent(this.key)}`;
+  getSessionKey(): string {
+    return sessionStorage.getItem(this.sessionKey) || '';
+  }
+
+  authenticate(key: string): Observable<{ ok: boolean }> {
+    const nextKey = key.trim();
+    const headers = new HttpHeaders({ Authorization: `Bearer ${nextKey}` });
+
+    return this.http.get<{ ok: boolean }>(`${this.base}/auth`, { headers }).pipe(
+      tap(() => {
+        this.key = nextKey;
+        sessionStorage.setItem(this.sessionKey, nextKey);
+        this.cache.clear();
+        this.authenticated.set(true);
+      }),
+    );
+  }
+
+  clearAccess() {
+    this.key = '';
+    sessionStorage.removeItem(this.sessionKey);
+    this.cache.clear();
+    this.authenticated.set(false);
   }
 
   clearCache() {
@@ -226,10 +244,12 @@ export class ApiService {
     const cached = this.cache.get(url);
     if (cached) return cached as Observable<T>;
 
-    const request = this.http.get<T>(url).pipe(
+    const headers = new HttpHeaders({ Authorization: `Bearer ${this.key}` });
+    const request = this.http.get<T>(url, { headers }).pipe(
       shareReplay({ bufferSize: 1, refCount: false }),
       catchError((error: unknown) => {
         this.cache.delete(url);
+        if (error instanceof HttpErrorResponse && error.status === 401) this.clearAccess();
         return throwError(() => error);
       }),
     );
@@ -243,47 +263,47 @@ export class ApiService {
   }
 
   getOverview(days: number, month?: string): Observable<Overview> {
-    return this.cachedGet<Overview>(`${this.base}/overview?${this.q(this.range(days, month))}`);
+    return this.cachedGet<Overview>(`${this.base}/overview?${this.range(days, month)}`);
   }
 
   /** 上一周期 overview(环比对比用):month=上月,或 start/end 日期范围;lite=1 不落快照 */
   getOverviewPrev(prev: { month?: string; start?: string; end?: string }): Observable<Overview> {
     const p = prev.month ? `month=${prev.month}` : `start=${prev.start}&end=${prev.end}`;
-    return this.cachedGet<Overview>(`${this.base}/overview?${this.q(`${p}&lite=1`)}`);
+    return this.cachedGet<Overview>(`${this.base}/overview?${p}&lite=1`);
   }
 
   getFmodeApiRecharge(days: number, month?: string): Observable<FmodeApiRecharge> {
-    return this.cachedGet<FmodeApiRecharge>(`${this.base}/fmodeapi/recharge?${this.q(this.range(days, month))}`);
+    return this.cachedGet<FmodeApiRecharge>(`${this.base}/fmodeapi/recharge?${this.range(days, month)}`);
   }
 
   getFmodeApiUsers(limit = 20, offset = 0, search = ''): Observable<FmodeApiUsers> {
     const s = search ? `&search=${encodeURIComponent(search)}` : '';
-    return this.cachedGet<FmodeApiUsers>(`${this.base}/fmodeapi/users?${this.q(`limit=${limit}&offset=${offset}${s}`)}`);
+    return this.cachedGet<FmodeApiUsers>(`${this.base}/fmodeapi/users?limit=${limit}&offset=${offset}${s}`);
   }
 
   /** NewAPI 钱包充值逐笔明细 */
   getFmodeApiTopups(days: number, month?: string): Observable<FmodeApiTopups> {
-    return this.cachedGet<FmodeApiTopups>(`${this.base}/fmodeapi/topups?${this.q(this.range(days, month))}`);
+    return this.cachedGet<FmodeApiTopups>(`${this.base}/fmodeapi/topups?${this.range(days, month)}`);
   }
 
   /** 用户消耗统计:排行 + 趋势;timeQ 为 days=N 或 month=YYYY-MM */
   getFmodeApiUserStats(timeQ: string, top: number, gran: 'hour' | 'day'): Observable<FmodeApiUserStats> {
-    return this.cachedGet<FmodeApiUserStats>(`${this.base}/fmodeapi/user-stats?${this.q(`${timeQ}&top=${top}&gran=${gran}`)}`);
+    return this.cachedGet<FmodeApiUserStats>(`${this.base}/fmodeapi/user-stats?${timeQ}&top=${top}&gran=${gran}`);
   }
 
   getApigOrders(days: number, month?: string): Observable<ApigOrderGroups> {
-    return this.cachedGet<ApigOrderGroups>(`${this.base}/orders?${this.q(this.range(days, month))}`);
+    return this.cachedGet<ApigOrderGroups>(`${this.base}/orders?${this.range(days, month)}`);
   }
 
   getUpstreamCost(days: number, month?: string): Observable<UpstreamCost> {
-    return this.cachedGet<UpstreamCost>(`${this.base}/upstream-cost?${this.q(this.range(days, month))}`);
+    return this.cachedGet<UpstreamCost>(`${this.base}/upstream-cost?${this.range(days, month)}`);
   }
 
   getModuleForwardingDetails(days: number, month?: string): Observable<ModuleForwardingDetails> {
-    return this.cachedGet<ModuleForwardingDetails>(`${this.base}/module-forwarding?${this.q(this.range(days, month))}`);
+    return this.cachedGet<ModuleForwardingDetails>(`${this.base}/module-forwarding?${this.range(days, month)}`);
   }
 
   getFmodeApiNewUsers(days: number, month?: string): Observable<FmodeApiNewUsers> {
-    return this.cachedGet<FmodeApiNewUsers>(`${this.base}/fmodeapi/new-users?${this.q(this.range(days, month))}`);
+    return this.cachedGet<FmodeApiNewUsers>(`${this.base}/fmodeapi/new-users?${this.range(days, month)}`);
   }
 }

+ 40 - 0
client/src/app/app.component.html

@@ -0,0 +1,40 @@
+@if (api.authenticated()) {
+  <router-outlet />
+} @else {
+  <main class="access-shell">
+    <section class="access-panel" aria-labelledby="access-title">
+      <div class="brand-row">
+        <div class="brand-mark" aria-hidden="true">V</div>
+        <div>
+          <div class="brand-name">VOC 数据中台</div>
+          <div class="brand-sub">利润与成本看板</div>
+        </div>
+      </div>
+
+      <form (ngSubmit)="unlock()">
+        <h1 id="access-title">看板验证</h1>
+
+        <label for="dashboard-key">访问密钥</label>
+        <input
+          id="dashboard-key"
+          name="dashboardKey"
+          type="password"
+          autocomplete="current-password"
+          [(ngModel)]="accessKey"
+          [disabled]="checking"
+          [attr.aria-invalid]="!!error"
+          [attr.aria-describedby]="error ? 'access-error' : null"
+          autofocus
+        />
+
+        @if (error) {
+          <p id="access-error" class="access-error" role="alert">{{ error }}</p>
+        }
+
+        <button type="submit" [disabled]="checking || !accessKey.trim()">
+          {{ checking ? '验证中...' : '进入看板' }}
+        </button>
+      </form>
+    </section>
+  </main>
+}

+ 121 - 0
client/src/app/app.component.scss

@@ -0,0 +1,121 @@
+:host {
+  display: block;
+  min-height: 100%;
+}
+
+.access-shell {
+  min-height: 100vh;
+  display: grid;
+  place-items: center;
+  padding: 24px;
+  background: #0b0f19;
+}
+
+.access-panel {
+  width: min(100%, 400px);
+  background: #151d31;
+  border: 1px solid #2a3554;
+  border-radius: 8px;
+  box-shadow: 0 24px 70px rgba(0, 0, 0, 0.36);
+  overflow: hidden;
+}
+
+.brand-row {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  padding: 22px 24px;
+  border-bottom: 1px solid #232e4d;
+  background: #101627;
+}
+
+.brand-mark {
+  width: 40px;
+  height: 40px;
+  display: grid;
+  place-items: center;
+  flex: 0 0 40px;
+  border-radius: 6px;
+  background: #35d399;
+  color: #07130f;
+  font-size: 20px;
+  font-weight: 800;
+}
+
+.brand-name {
+  color: #e7ecf5;
+  font-size: 15px;
+  font-weight: 700;
+}
+
+.brand-sub {
+  margin-top: 3px;
+  color: #8b96b3;
+  font-size: 12px;
+}
+
+form {
+  display: flex;
+  flex-direction: column;
+  padding: 28px 24px 24px;
+}
+
+h1 {
+  margin: 0 0 24px;
+  color: #e7ecf5;
+  font-size: 22px;
+  line-height: 1.25;
+}
+
+label {
+  margin-bottom: 8px;
+  color: #aeb8d0;
+  font-size: 13px;
+  font-weight: 600;
+}
+
+input {
+  width: 100%;
+  height: 44px;
+  padding: 0 12px;
+  border: 1px solid #364261;
+  border-radius: 6px;
+  outline: none;
+  background: #0f1524;
+  color: #e7ecf5;
+  font: inherit;
+
+  &:focus {
+    border-color: #38d0f2;
+    box-shadow: 0 0 0 3px rgba(56, 208, 242, 0.14);
+  }
+
+  &[aria-invalid='true'] { border-color: #f87171; }
+}
+
+.access-error {
+  margin: 10px 0 0;
+  color: #f87171;
+  font-size: 13px;
+}
+
+button {
+  height: 44px;
+  margin-top: 20px;
+  border: 0;
+  border-radius: 6px;
+  background: #35d399;
+  color: #07130f;
+  font: inherit;
+  font-weight: 700;
+  cursor: pointer;
+
+  &:hover:not(:disabled) { background: #5de1b0; }
+  &:focus-visible { outline: 3px solid rgba(56, 208, 242, 0.34); outline-offset: 2px; }
+  &:disabled { cursor: not-allowed; opacity: 0.55; }
+}
+
+@media (max-width: 480px) {
+  .access-shell { padding: 16px; }
+  .brand-row, form { padding-left: 20px; padding-right: 20px; }
+}

+ 44 - 4
client/src/app/app.component.ts

@@ -1,10 +1,50 @@
-import { Component } from '@angular/core';
+import { Component, OnInit, effect } from '@angular/core';
+import { HttpErrorResponse } from '@angular/common/http';
+import { FormsModule } from '@angular/forms';
 import { RouterOutlet } from '@angular/router';
+import { finalize } from 'rxjs';
+import { ApiService } from './api.service';
 
 @Component({
   selector: 'app-root',
   standalone: true,
-  imports: [RouterOutlet],
-  template: '<router-outlet />',
+  imports: [FormsModule, RouterOutlet],
+  templateUrl: './app.component.html',
+  styleUrl: './app.component.scss',
 })
-export class AppComponent {}
+export class AppComponent implements OnInit {
+  accessKey = '';
+  checking = false;
+  error = '';
+
+  constructor(readonly api: ApiService) {
+    effect(() => {
+      if (!this.api.authenticated()) this.accessKey = '';
+    });
+  }
+
+  ngOnInit() {
+    const savedKey = this.api.getSessionKey();
+    if (!savedKey) return;
+
+    this.accessKey = savedKey;
+    this.unlock();
+  }
+
+  unlock() {
+    const key = this.accessKey.trim();
+    if (!key || this.checking) return;
+
+    this.checking = true;
+    this.error = '';
+    this.api.authenticate(key).pipe(
+      finalize(() => { this.checking = false; }),
+    ).subscribe({
+      error: (error: unknown) => {
+        this.error = error instanceof HttpErrorResponse && error.status === 401
+          ? '访问密钥不正确'
+          : '暂时无法连接数据服务';
+      },
+    });
+  }
+}

+ 1 - 0
client/src/app/dashboard.component.html

@@ -30,6 +30,7 @@
         <option [ngValue]="90">近 90 天</option>
       </select>
       <button class="refresh" (click)="refresh()">刷新数据</button>
+      <button class="refresh" (click)="api.clearAccess()">退出看板</button>
       <div class="updated" *ngIf="updatedAt">更新于 {{ updatedAt }}</div>
     </div>
   </aside>

+ 1 - 1
client/src/app/dashboard.component.ts

@@ -175,7 +175,7 @@ export class DashboardComponent implements OnInit, AfterViewInit, OnDestroy {
   rechargeChart: EChartsOption | null = null;
 
   constructor(
-    private api: ApiService,
+    readonly api: ApiService,
     private route: ActivatedRoute,
     private router: Router,
   ) {}