Jelajahi Sumber

feat: add SMS login modal for user authentication before payment

gangvy 5 bulan lalu
induk
melakukan
5965a43e7f
4 mengubah file dengan 457 tambahan dan 4 penghapusan
  1. 7 1
      src/app/app.html
  2. 15 0
      src/app/app.scss
  3. 49 3
      src/app/app.ts
  4. 386 0
      src/app/login-modal.component.ts

+ 7 - 1
src/app/app.html

@@ -1,14 +1,20 @@
+<!-- Login Modal -->
+<app-login-modal #loginModal (loginSuccess)="onLoginSuccess($event)"></app-login-modal>
+
 <!-- Header -->
 <div class="page-header">
   <div class="logo">BRAIN<span>HACK</span></div>
   <div class="subtitle">API 服务充值</div>
+  <div class="user-bar" *ngIf="loggedInUser">
+    <span class="user-badge">👤 已登录</span>
+  </div>
 </div>
 
 <!-- Main -->
 <div class="main-container">
 
   <!-- API Info Card -->
-  <div class="api-info-card neu-raised" *ngIf="!showSuccess">
+  <div class="api-info-card neu-raised" *ngIf="!showSuccess && !needLogin">
     <div class="api-title" *ngIf="!apig">
       <span class="skeleton" style="display:inline-block;width:200px;height:24px;">&nbsp;</span>
     </div>

+ 15 - 0
src/app/app.scss

@@ -99,6 +99,21 @@
   color: var(--text-dim);
   margin-left: 8px;
 }
+.page-header .user-bar {
+  margin-left: auto;
+}
+.page-header .user-badge {
+  display: inline-flex;
+  align-items: center;
+  gap: 4px;
+  padding: 4px 12px;
+  font-size: 12px;
+  font-weight: 600;
+  color: var(--success);
+  background: rgba(0,255,136,0.08);
+  border: 1px solid rgba(0,255,136,0.2);
+  border-radius: 20px;
+}
 
 /* ─── Main Container ─── */
 .main-container {

+ 49 - 3
src/app/app.ts

@@ -1,5 +1,6 @@
 import { Component, OnInit, ViewChild, ElementRef } from '@angular/core';
 import { CommonModule } from '@angular/common';
+import { LoginModalComponent } from './login-modal.component';
 
 declare var QRCode: any;
 
@@ -40,12 +41,13 @@ interface OrderData {
 
 @Component({
   selector: 'app-root',
-  imports: [CommonModule],
+  imports: [CommonModule, LoginModalComponent],
   templateUrl: './app.html',
   styleUrl: './app.scss'
 })
 export class App implements OnInit {
   @ViewChild('qrCanvas', { static: false }) qrCanvasRef!: ElementRef<HTMLCanvasElement>;
+  @ViewChild('loginModal') loginModalRef!: LoginModalComponent;
 
   // URL params
   authId = '';
@@ -53,6 +55,10 @@ export class App implements OnInit {
   apigId = '';
   funId = DEFAULT_FUN_ID;
 
+  // Login state
+  needLogin = false;
+  loggedInUser: string = '';
+
   // State
   apig: ApigData | null = null;
   selectedIndex = 0;
@@ -93,8 +99,48 @@ export class App implements OnInit {
     this.apigId = params.get('apigid') || '';
     this.funId = params.get('fun_id') || DEFAULT_FUN_ID;
 
-    if (!this.authId && !(this.userId && this.apigId)) {
-      this.errorMsg = '缺少参数。请传入 authid,或同时传入 user 和 apigid。';
+    if (this.authId || (this.userId && this.apigId)) {
+      // URL 已提供足够参数,直接加载
+      this.loadApig();
+    } else if (this.apigId && !this.userId) {
+      // 有 apigId 但没有 user → 尝试自动登录或显示登录弹窗
+      this.tryAutoLoginOrShowModal();
+    } else {
+      // 什么参数都没有 → 显示登录弹窗
+      this.needLogin = true;
+      setTimeout(() => this.loginModalRef?.show(), 100);
+    }
+  }
+
+  async tryAutoLoginOrShowModal(): Promise<void> {
+    // 检查是否已有 Parse 登录态
+    try {
+      const existing = await this.loginModalRef?.checkExistingLogin();
+      if (existing?.userId) {
+        console.log('[AUTH] 已有登录用户:', existing.userId);
+        this.userId = existing.userId;
+        this.loggedInUser = existing.userId;
+        this.loadApig();
+        return;
+      }
+    } catch (e) {
+      console.warn('[AUTH] 检查登录态失败:', e);
+    }
+    // 没有登录态 → 显示登录弹窗
+    this.needLogin = true;
+    setTimeout(() => this.loginModalRef?.show(), 100);
+  }
+
+  onLoginSuccess(event: { userId: string; sessionToken: string }): void {
+    console.log('[AUTH] 登录成功, userId:', event.userId);
+    this.userId = event.userId;
+    this.loggedInUser = event.userId;
+    this.needLogin = false;
+    this.errorMsg = '';
+
+    if (!this.apigId) {
+      // 如果 URL 也没有 apigId,提示需要 apigId
+      this.errorMsg = '登录成功,但缺少 apigid 参数。请在 URL 中提供 apigid。';
       return;
     }
     this.loadApig();

+ 386 - 0
src/app/login-modal.component.ts

@@ -0,0 +1,386 @@
+import { Component, Output, EventEmitter, signal } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { FormsModule } from '@angular/forms';
+
+const API_HOST = 'https://server.fmode.cn';
+const APP_ID = 'ncloudmaster';
+
+@Component({
+  selector: 'app-login-modal',
+  standalone: true,
+  imports: [CommonModule, FormsModule],
+  template: `
+    <div class="login-overlay" *ngIf="visible">
+      <div class="login-modal">
+        <!-- Logo -->
+        <div class="logo-section">
+          <div class="logo-icon">
+            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+              <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
+            </svg>
+          </div>
+          <h1 class="logo-title">BRAIN<span>HACK</span></h1>
+          <p class="logo-subtitle">登录以使用 API 充值服务</p>
+        </div>
+
+        <!-- 登录表单 -->
+        <form class="login-form" (ngSubmit)="handleSubmit()">
+          <!-- 手机号 -->
+          <div class="form-group">
+            <label for="phone">手机号</label>
+            <div class="input-wrapper">
+              <svg class="input-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+                <path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/>
+              </svg>
+              <input
+                id="phone"
+                name="phone"
+                type="tel"
+                class="form-input"
+                placeholder="请输入手机号"
+                [(ngModel)]="phone"
+                (ngModelChange)="validatePhone()"
+                maxlength="11"
+                [disabled]="isLoading()"
+              />
+            </div>
+          </div>
+
+          <!-- 验证码 -->
+          <div class="form-group">
+            <label for="code">验证码</label>
+            <div class="code-input-wrapper">
+              <div class="input-wrapper">
+                <svg class="input-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+                  <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
+                </svg>
+                <input
+                  id="code"
+                  name="code"
+                  type="text"
+                  class="form-input code-input"
+                  placeholder="请输入验证码"
+                  [(ngModel)]="code"
+                  maxlength="6"
+                  [disabled]="isLoading()"
+                />
+              </div>
+              <button
+                type="button"
+                class="btn-send-code"
+                (click)="sendCode()"
+                [disabled]="!canSendCode() || isLoading() || countdown() > 0"
+              >
+                {{ countdown() > 0 ? countdown() + 's' : '发送验证码' }}
+              </button>
+            </div>
+          </div>
+
+          <!-- 错误提示 -->
+          <div class="error-msg" *ngIf="errorMsg">{{errorMsg}}</div>
+
+          <!-- 提交 -->
+          <button type="submit" class="btn-submit" [disabled]="!canSubmit() || isLoading()">
+            <span *ngIf="isLoading()" class="loading-spinner"></span>
+            <span *ngIf="isLoading()">登录中...</span>
+            <span *ngIf="!isLoading()">🔐 立即登录</span>
+          </button>
+        </form>
+
+        <p class="footer-hint">登录即表示同意《用户协议》和《隐私政策》</p>
+      </div>
+    </div>
+  `,
+  styles: [`
+    .login-overlay {
+      position: fixed;
+      top: 0; left: 0; right: 0; bottom: 0;
+      background: rgba(0,0,0,0.85);
+      backdrop-filter: blur(12px);
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      z-index: 200;
+      animation: fadeIn 0.3s ease-out;
+    }
+    @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
+
+    .login-modal {
+      position: relative;
+      background: #0A0A0A;
+      border: 1px solid rgba(0,212,255,0.15);
+      border-radius: 20px;
+      padding: 40px 36px;
+      width: 90%;
+      max-width: 420px;
+      box-shadow: 0 25px 50px rgba(0,0,0,0.5), 0 0 30px rgba(0,212,255,0.08);
+      animation: slideUp 0.4s ease-out;
+    }
+    @keyframes slideUp {
+      from { opacity: 0; transform: translateY(30px); }
+      to { opacity: 1; transform: translateY(0); }
+    }
+
+    .logo-section { text-align: center; margin-bottom: 32px; }
+    .logo-icon {
+      width: 64px; height: 64px;
+      margin: 0 auto 16px;
+      background: rgba(0,212,255,0.1);
+      border: 1px solid rgba(0,212,255,0.3);
+      border-radius: 16px;
+      display: flex; align-items: center; justify-content: center;
+      color: #00D4FF;
+      animation: pulse 2s ease-in-out infinite;
+    }
+    @keyframes pulse {
+      0%,100% { box-shadow: 0 0 0 0 rgba(0,212,255,0.4); }
+      50% { box-shadow: 0 0 0 12px rgba(0,212,255,0); }
+    }
+    .logo-icon svg { width: 32px; height: 32px; }
+    .logo-title {
+      margin: 0 0 8px 0; font-size: 24px; font-weight: 900;
+      color: #fff; letter-spacing: 2px;
+    }
+    .logo-title span { color: #00D4FF; }
+    .logo-subtitle { margin: 0; font-size: 14px; color: #888; }
+
+    .login-form { margin-top: 28px; }
+    .form-group { margin-bottom: 20px; }
+    .form-group label {
+      display: block; margin-bottom: 8px;
+      font-weight: 700; font-size: 12px;
+      color: rgba(224,224,224,0.9);
+      text-transform: uppercase; letter-spacing: 0.5px;
+    }
+
+    .input-wrapper { position: relative; display: flex; align-items: center; }
+    .input-icon {
+      position: absolute; left: 14px; width: 18px; height: 18px;
+      color: rgba(224,224,224,0.4); pointer-events: none;
+    }
+    .form-input {
+      width: 100%; padding: 12px 14px 12px 44px;
+      background: rgba(20,20,20,0.8);
+      border: 1px solid rgba(255,255,255,0.1);
+      border-radius: 10px; font-size: 14px; color: #E0E0E0;
+      transition: all 0.2s; box-sizing: border-box;
+    }
+    .form-input:focus {
+      outline: none;
+      border-color: rgba(0,212,255,0.5);
+      box-shadow: 0 0 0 3px rgba(0,212,255,0.1);
+    }
+    .form-input::placeholder { color: rgba(224,224,224,0.4); }
+    .form-input:disabled { opacity: 0.6; cursor: not-allowed; }
+
+    .code-input-wrapper { display: flex; gap: 10px; }
+    .code-input-wrapper .input-wrapper { flex: 1; }
+    .code-input { letter-spacing: 4px; font-weight: 700; text-align: center; }
+
+    .btn-send-code {
+      padding: 12px 16px;
+      background: rgba(0,212,255,0.1);
+      border: 1px solid rgba(0,212,255,0.3);
+      border-radius: 10px; color: #00D4FF;
+      font-size: 13px; font-weight: 700;
+      cursor: pointer; transition: all 0.2s;
+      white-space: nowrap; min-width: 100px;
+    }
+    .btn-send-code:hover:not(:disabled) {
+      background: rgba(0,212,255,0.2);
+      border-color: rgba(0,212,255,0.5);
+    }
+    .btn-send-code:disabled { opacity: 0.5; cursor: not-allowed; }
+
+    .btn-submit {
+      width: 100%; display: flex; align-items: center; justify-content: center;
+      gap: 8px; padding: 14px 20px; margin-top: 8px;
+      background: linear-gradient(145deg, rgba(0,212,255,0.15), rgba(0,0,0,0.3));
+      border: 1px solid rgba(0,212,255,0.4);
+      border-radius: 12px; color: #00D4FF;
+      font-size: 15px; font-weight: 800;
+      cursor: pointer; transition: all 0.2s;
+    }
+    .btn-submit:hover:not(:disabled) {
+      background: linear-gradient(145deg, rgba(0,212,255,0.25), rgba(0,0,0,0.2));
+      border-color: rgba(0,212,255,0.6);
+      transform: translateY(-2px);
+      box-shadow: 0 8px 20px rgba(0,212,255,0.2);
+    }
+    .btn-submit:disabled { opacity: 0.6; cursor: not-allowed; transform: none; }
+
+    .loading-spinner {
+      width: 16px; height: 16px;
+      border: 2px solid rgba(0,212,255,0.3);
+      border-top-color: #00D4FF;
+      border-radius: 50%;
+      animation: spin 0.8s linear infinite;
+    }
+    @keyframes spin { to { transform: rotate(360deg); } }
+
+    .error-msg {
+      padding: 10px 14px; margin-bottom: 12px;
+      background: rgba(255,77,106,0.1);
+      border: 1px solid rgba(255,77,106,0.3);
+      border-radius: 8px; color: #FF4D6A; font-size: 13px;
+    }
+
+    .footer-hint {
+      margin: 20px 0 0 0; text-align: center;
+      font-size: 12px; color: rgba(224,224,224,0.4); line-height: 1.5;
+    }
+
+    @media (max-width: 480px) {
+      .login-modal { padding: 28px 20px; }
+    }
+  `]
+})
+export class LoginModalComponent {
+  @Output() loginSuccess = new EventEmitter<{ userId: string; sessionToken: string }>();
+
+  visible = false;
+  phone = '';
+  code = '';
+  errorMsg = '';
+  isLoading = signal(false);
+  countdown = signal(0);
+  countdownTimer: any = null;
+
+  private company = 'E4KpGvTEto';
+
+  show(): void { this.visible = true; }
+  hide(): void { this.visible = false; }
+
+  validatePhone(): void {
+    this.phone = this.phone.replace(/\D/g, '').substring(0, 11);
+  }
+
+  canSendCode(): boolean {
+    return /^1[3-9]\d{9}$/.test(this.phone);
+  }
+
+  canSubmit(): boolean {
+    return this.canSendCode() && this.code.length >= 4;
+  }
+
+  async sendCode(): Promise<void> {
+    if (!this.canSendCode() || this.countdown() > 0) return;
+    this.isLoading.set(true);
+    this.errorMsg = '';
+
+    try {
+      const resp = await fetch(`${API_HOST}/api/apig/message`, {
+        method: 'POST',
+        headers: {
+          'Content-Type': 'application/json',
+          'X-Parse-Application-Id': APP_ID
+        },
+        body: JSON.stringify({ company: this.company, mobile: this.phone })
+      });
+      const result = await resp.json();
+      if (result.error) {
+        throw new Error(result.error);
+      }
+      // 开始倒计时
+      this.countdown.set(60);
+      this.countdownTimer = setInterval(() => {
+        const cur = this.countdown();
+        if (cur <= 0) {
+          clearInterval(this.countdownTimer);
+        } else {
+          this.countdown.set(cur - 1);
+        }
+      }, 1000);
+      console.log('[Login] 验证码已发送');
+    } catch (e: any) {
+      console.error('[Login] 发送验证码失败:', e);
+      this.errorMsg = '发送失败: ' + (e.message || '未知错误');
+    } finally {
+      this.isLoading.set(false);
+    }
+  }
+
+  async handleSubmit(): Promise<void> {
+    if (!this.canSubmit() || this.isLoading()) return;
+    this.isLoading.set(true);
+    this.errorMsg = '';
+
+    try {
+      // 1. 调用登录接口获取 session token
+      const loginUrl = `${API_HOST}/api/auth/mobile?company=${this.company}&mobile=${this.phone}&code=${this.code}`;
+      const resp = await fetch(loginUrl, {
+        method: 'GET',
+        headers: { 'X-Parse-Application-Id': APP_ID }
+      });
+      const result = await resp.json();
+      console.log('[Login] loginMobile result:', result);
+
+      if (result.code !== 200 || !result.data?.token) {
+        throw new Error(result.message || result.error || '验证码错误或已过期');
+      }
+
+      const sessionToken = result.data.token;
+
+      // 2. 用 session token 获取用户信息
+      const meResp = await fetch(`${API_HOST}/parse/users/me`, {
+        method: 'GET',
+        headers: {
+          'X-Parse-Application-Id': APP_ID,
+          'X-Parse-Session-Token': sessionToken
+        }
+      });
+      const userData = await meResp.json();
+      console.log('[Login] 用户信息:', userData);
+
+      if (!userData.objectId) {
+        throw new Error('登录成功但无法获取用户信息');
+      }
+
+      // 3. 缓存登录态
+      localStorage.setItem('apig_session_token', sessionToken);
+      localStorage.setItem('apig_user_id', userData.objectId);
+
+      console.log('[Login] userId:', userData.objectId);
+      this.loginSuccess.emit({ userId: userData.objectId, sessionToken });
+      this.hide();
+    } catch (e: any) {
+      console.error('[Login] 登录失败:', e);
+      this.errorMsg = '登录失败: ' + (e.message || '未知错误');
+    } finally {
+      this.isLoading.set(false);
+    }
+  }
+
+  // 检查是否已登录(从 localStorage 恢复)
+  async checkExistingLogin(): Promise<{ userId: string; sessionToken: string } | null> {
+    try {
+      const token = localStorage.getItem('apig_session_token');
+      const cachedUserId = localStorage.getItem('apig_user_id');
+      if (!token) return null;
+
+      // 验证 token 是否仍然有效
+      const resp = await fetch(`${API_HOST}/parse/users/me`, {
+        method: 'GET',
+        headers: {
+          'X-Parse-Application-Id': APP_ID,
+          'X-Parse-Session-Token': token
+        }
+      });
+      const userData = await resp.json();
+      if (userData.objectId) {
+        console.log('[Login] 已有登录用户:', userData.objectId);
+        return { userId: userData.objectId, sessionToken: token };
+      }
+      // token 失效,清除缓存
+      localStorage.removeItem('apig_session_token');
+      localStorage.removeItem('apig_user_id');
+    } catch (e) {
+      console.warn('[Login] 检查登录态失败:', e);
+    }
+    return null;
+  }
+
+  ngOnDestroy(): void {
+    if (this.countdownTimer) clearInterval(this.countdownTimer);
+  }
+}