Преглед на файлове

fix: add trial code expiry and resend flow

gangvy преди 1 месец
родител
ревизия
2995e3ba19
променени са 1 файла, в които са добавени 56 реда и са изтрити 2 реда
  1. 56 2
      src/app/qiwei-skill-install.component.ts

+ 56 - 2
src/app/qiwei-skill-install.component.ts

@@ -58,6 +58,7 @@ interface TrialStatus {
   credentialReady: boolean;
   credentialReady: boolean;
   phoneRequired: boolean;
   phoneRequired: boolean;
   phone?: string | null;
   phone?: string | null;
+  codeExpiresAt?: string | null;
   unavailableReason?: string | null;
   unavailableReason?: string | null;
 }
 }
 
 
@@ -113,7 +114,12 @@ interface PendingOrder {
                 </button>
                 </button>
               } @else if (trialStatus()!.state === 'code_sent') {
               } @else if (trialStatus()!.state === 'code_sent') {
                 <div class="trial-form">
                 <div class="trial-form">
-                  <span class="trial-phone">验证码已发送至 {{ trialStatus()!.phone || '绑定手机号' }}</span>
+                  <div class="trial-phone-row">
+                    <span class="trial-phone">验证码已发送至 {{ trialStatus()!.phone || '绑定手机号' }}</span>
+                    <span class="trial-countdown" [class.expired]="trialSecondsRemaining() === 0">
+                      {{ trialSecondsRemaining() > 0 ? '剩余 ' + formatCountdown(trialSecondsRemaining()) : '验证码已过期' }}
+                    </span>
+                  </div>
                   <div class="trial-input-row">
                   <div class="trial-input-row">
                     <input inputmode="numeric" maxlength="6" autocomplete="one-time-code" placeholder="输入 6 位验证码" [value]="trialCode()" (input)="trialCode.set($any($event.target).value)" aria-label="试用验证码" />
                     <input inputmode="numeric" maxlength="6" autocomplete="one-time-code" placeholder="输入 6 位验证码" [value]="trialCode()" (input)="trialCode.set($any($event.target).value)" aria-label="试用验证码" />
                     <button type="button" class="primary-button compact" [disabled]="trialBusy() || trialCode().length !== 6" (click)="claimTrial()">
                     <button type="button" class="primary-button compact" [disabled]="trialBusy() || trialCode().length !== 6" (click)="claimTrial()">
@@ -121,6 +127,9 @@ interface PendingOrder {
                       领取试用
                       领取试用
                     </button>
                     </button>
                   </div>
                   </div>
+                  <button type="button" class="resend-button" [disabled]="trialBusy() || trialSecondsRemaining() > 0" (click)="requestTrialCode()">
+                    {{ trialSecondsRemaining() > 0 ? '重新获取(' + formatCountdown(trialSecondsRemaining()) + ')' : '重新获取验证码' }}
+                  </button>
                 </div>
                 </div>
               }
               }
               @if (trialMessage()) { <div class="trial-message">{{ trialMessage() }}</div> }
               @if (trialMessage()) { <div class="trial-message">{{ trialMessage() }}</div> }
@@ -312,7 +321,7 @@ interface PendingOrder {
     .user-area{display:flex;align-items:center;gap:12px;color:#9ba7aa;font-size:13px}.icon-button{width:36px;height:36px;padding:0;display:grid;place-items:center;border:1px solid #354047;border-radius:6px;background:#14191c;color:#aeb8bc}.icon-button:hover{color:#fff;border-color:#536068}.icon-button svg{width:18px;height:18px}
     .user-area{display:flex;align-items:center;gap:12px;color:#9ba7aa;font-size:13px}.icon-button{width:36px;height:36px;padding:0;display:grid;place-items:center;border:1px solid #354047;border-radius:6px;background:#14191c;color:#aeb8bc}.icon-button:hover{color:#fff;border-color:#536068}.icon-button svg{width:18px;height:18px}
     .purchase-layout{width:min(1180px,calc(100% - 40px));min-height:calc(100vh - 70px);margin:0 auto;padding:58px 0 72px;display:grid;grid-template-columns:minmax(0,1fr) 390px;gap:clamp(52px,8vw,110px);align-items:center}
     .purchase-layout{width:min(1180px,calc(100% - 40px));min-height:calc(100vh - 70px);margin:0 auto;padding:58px 0 72px;display:grid;grid-template-columns:minmax(0,1fr) 390px;gap:clamp(52px,8vw,110px);align-items:center}
     .configuration{max-width:680px}.product-name{margin-bottom:15px;color:#55d6a8;font-size:14px;font-weight:750}.configuration h1{margin:0;max-width:690px;color:#f8fbfa;font-size:clamp(38px,5vw,60px);line-height:1.08;font-weight:760}.lead{margin:20px 0 34px;max-width:660px;color:#9ca8ac;font-size:16px;line-height:1.75}
     .configuration{max-width:680px}.product-name{margin-bottom:15px;color:#55d6a8;font-size:14px;font-weight:750}.configuration h1{margin:0;max-width:690px;color:#f8fbfa;font-size:clamp(38px,5vw,60px);line-height:1.08;font-weight:760}.lead{margin:20px 0 34px;max-width:660px;color:#9ca8ac;font-size:16px;line-height:1.75}
-    .trial-panel{margin:0 0 30px;padding:18px;border:1px solid #2f5d4d;border-radius:8px;background:#10211b}.trial-panel-unavailable{border-color:#66522c;background:#211c12}.trial-copy h2{margin:5px 0 6px;color:#eafff5;font-size:17px}.trial-copy p{margin:0;color:#9fc8b9;font-size:12px;line-height:1.6}.trial-panel-unavailable .trial-kicker{color:#e1b867}.trial-panel-unavailable .trial-copy p{color:#d0bc8d}.trial-kicker{color:#69dda9;font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.08em}.trial-panel>.secondary-button{margin-top:16px}.trial-form{margin-top:16px}.trial-phone{display:block;color:#a9cdbf;font-size:11px}.trial-input-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:8px;margin-top:9px}.trial-input-row input{min-width:0;height:40px;padding:0 11px;border:1px solid #3a554a;border-radius:6px;background:#0d1713;color:#ecfff6;font-size:13px;letter-spacing:.12em}.trial-input-row .compact{width:auto;min-width:120px;margin-top:0;padding:0 14px}.trial-message{margin-top:10px;color:#9fc8b9;font-size:11px;line-height:1.5}
+    .trial-panel{margin:0 0 30px;padding:18px;border:1px solid #2f5d4d;border-radius:8px;background:#10211b}.trial-panel-unavailable{border-color:#66522c;background:#211c12}.trial-copy h2{margin:5px 0 6px;color:#eafff5;font-size:17px}.trial-copy p{margin:0;color:#9fc8b9;font-size:12px;line-height:1.6}.trial-panel-unavailable .trial-kicker{color:#e1b867}.trial-panel-unavailable .trial-copy p{color:#d0bc8d}.trial-kicker{color:#69dda9;font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.08em}.trial-panel>.secondary-button{margin-top:16px}.trial-form{margin-top:16px}.trial-phone-row{display:flex;align-items:center;justify-content:space-between;gap:12px}.trial-phone{display:block;color:#a9cdbf;font-size:11px}.trial-countdown{color:#e1b867;font-size:11px;white-space:nowrap}.trial-countdown.expired{color:#ef9078}.trial-input-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:8px;margin-top:9px}.trial-input-row input{min-width:0;height:40px;padding:0 11px;border:1px solid #3a554a;border-radius:6px;background:#0d1713;color:#ecfff6;font-size:13px;letter-spacing:.12em}.trial-input-row .compact{width:auto;min-width:120px;margin-top:0;padding:0 14px}.resend-button{margin-top:9px;padding:0;border:0;background:transparent;color:#69dda9;font-size:11px;text-decoration:underline;text-underline-offset:3px}.resend-button:disabled{color:#667a72;text-decoration:none;cursor:not-allowed}.trial-message{margin-top:10px;color:#9fc8b9;font-size:11px;line-height:1.5}
     .active-subscription{margin:0 0 30px;padding:16px 18px;border-left:3px solid #25b983;background:#111a17}.active-subscription>div{display:flex;align-items:center;gap:9px}.status-dot{width:8px;height:8px;border-radius:50%;background:#35d79b;box-shadow:0 0 0 5px rgba(53,215,155,.1)}.active-subscription dl{margin:14px 0 10px;display:flex;gap:32px}.active-subscription dl div{display:flex;gap:8px}.active-subscription dt{color:#788589}.active-subscription dd{margin:0;color:#dfe7e5}.text-button{padding:0;border:0;background:transparent;color:#55d6a8;font-size:12px}
     .active-subscription{margin:0 0 30px;padding:16px 18px;border-left:3px solid #25b983;background:#111a17}.active-subscription>div{display:flex;align-items:center;gap:9px}.status-dot{width:8px;height:8px;border-radius:50%;background:#35d79b;box-shadow:0 0 0 5px rgba(53,215,155,.1)}.active-subscription dl{margin:14px 0 10px;display:flex;gap:32px}.active-subscription dl div{display:flex;gap:8px}.active-subscription dt{color:#788589}.active-subscription dd{margin:0;color:#dfe7e5}.text-button{padding:0;border:0;background:transparent;color:#55d6a8;font-size:12px}
     .control-section{padding:22px 0;border-top:1px solid #2a3338}.control-heading{display:flex;align-items:center;justify-content:space-between;gap:24px}.control-heading>div:first-child{display:flex;flex-direction:column;gap:5px}.control-heading strong{font-size:14px}.control-heading span{color:#778488;font-size:12px}
     .control-section{padding:22px 0;border-top:1px solid #2a3338}.control-heading{display:flex;align-items:center;justify-content:space-between;gap:24px}.control-heading>div:first-child{display:flex;flex-direction:column;gap:5px}.control-heading strong{font-size:14px}.control-heading span{color:#778488;font-size:12px}
     .stepper{height:40px;display:grid;grid-template-columns:40px 58px 40px;border:1px solid #374148;border-radius:6px;overflow:hidden}.stepper button{border:0;background:#171d20;color:#dce3e1;font-size:19px}.stepper button:disabled{opacity:.35;cursor:not-allowed}.stepper input{width:100%;border:0;border-left:1px solid #374148;border-right:1px solid #374148;background:#0f1315;color:#fff;text-align:center;-moz-appearance:textfield}.stepper input::-webkit-inner-spin-button{display:none}
     .stepper{height:40px;display:grid;grid-template-columns:40px 58px 40px;border:1px solid #374148;border-radius:6px;overflow:hidden}.stepper button{border:0;background:#171d20;color:#dce3e1;font-size:19px}.stepper button:disabled{opacity:.35;cursor:not-allowed}.stepper input{width:100%;border:0;border-left:1px solid #374148;border-right:1px solid #374148;background:#0f1315;color:#fff;text-align:center;-moz-appearance:textfield}.stepper input::-webkit-inner-spin-button{display:none}
@@ -347,6 +356,7 @@ export class QiweiSkillInstallComponent implements OnInit, OnDestroy {
   readonly trialCode = signal('');
   readonly trialCode = signal('');
   readonly trialMessage = signal('');
   readonly trialMessage = signal('');
   readonly trialBusy = signal(false);
   readonly trialBusy = signal(false);
+  readonly trialSecondsRemaining = signal(0);
   readonly stage = signal<FlowStage>('checking');
   readonly stage = signal<FlowStage>('checking');
   readonly busy = signal(false);
   readonly busy = signal(false);
   readonly errorMessage = signal('');
   readonly errorMessage = signal('');
@@ -364,6 +374,7 @@ export class QiweiSkillInstallComponent implements OnInit, OnDestroy {
   private pollTimer: ReturnType<typeof setInterval> | null = null;
   private pollTimer: ReturnType<typeof setInterval> | null = null;
   private quoteSequence = 0;
   private quoteSequence = 0;
   private hasExplicitSeats = false;
   private hasExplicitSeats = false;
+  private trialExpiryTimer: ReturnType<typeof setInterval> | null = null;
 
 
   ngOnInit(): void {
   ngOnInit(): void {
     const hashQuery = window.location.hash.includes('?') ? window.location.hash.split('?')[1] : '';
     const hashQuery = window.location.hash.includes('?') ? window.location.hash.split('?')[1] : '';
@@ -378,6 +389,7 @@ export class QiweiSkillInstallComponent implements OnInit, OnDestroy {
 
 
   ngOnDestroy(): void {
   ngOnDestroy(): void {
     this.clearPolling();
     this.clearPolling();
+    this.clearTrialCountdown();
   }
   }
 
 
   maxSeats(): number { return this.plans()?.maxSeats || 100; }
   maxSeats(): number { return this.plans()?.maxSeats || 100; }
@@ -394,6 +406,11 @@ export class QiweiSkillInstallComponent implements OnInit, OnDestroy {
     return Number.isNaN(date.getTime()) ? '待确认' : date.toLocaleDateString('zh-CN');
     return Number.isNaN(date.getTime()) ? '待确认' : date.toLocaleDateString('zh-CN');
   }
   }
 
 
+  formatCountdown(totalSeconds: number): string {
+    const seconds = Math.max(0, Math.floor(totalSeconds));
+    return `${String(Math.floor(seconds / 60)).padStart(2, '0')}:${String(seconds % 60).padStart(2, '0')}`;
+  }
+
   actionLabel(): string {
   actionLabel(): string {
     if (this.pendingOrder()) return `继续原订单 ¥${this.formatMoney(this.pendingOrder()!.amount)}`;
     if (this.pendingOrder()) return `继续原订单 ¥${this.formatMoney(this.pendingOrder()!.amount)}`;
     const amount = this.quote()?.amount || 0;
     const amount = this.quote()?.amount || 0;
@@ -505,7 +522,9 @@ export class QiweiSkillInstallComponent implements OnInit, OnDestroy {
         ...(this.trialStatus() || {} as TrialStatus),
         ...(this.trialStatus() || {} as TrialStatus),
         state: 'code_sent',
         state: 'code_sent',
         phone: result.data.maskedPhone || null,
         phone: result.data.maskedPhone || null,
+        codeExpiresAt: result.data.expiresAt || null,
       });
       });
+      this.startTrialCountdown(result.data.expiresAt);
       this.trialMessage.set('验证码有效期 10 分钟,请在当前页面完成领取。');
       this.trialMessage.set('验证码有效期 10 分钟,请在当前页面完成领取。');
     } catch (error: any) {
     } catch (error: any) {
       this.trialMessage.set(error?.message || '验证码发送失败,请稍后重试。');
       this.trialMessage.set(error?.message || '验证码发送失败,请稍后重试。');
@@ -528,6 +547,10 @@ export class QiweiSkillInstallComponent implements OnInit, OnDestroy {
       this.stage.set('active');
       this.stage.set('active');
       await this.requestInstallPrompt();
       await this.requestInstallPrompt();
     } catch (error: any) {
     } catch (error: any) {
+      if (Number(error?.status) === 400 || /验证码已过期/.test(error?.message || '')) {
+        this.trialCode.set('');
+        await this.loadTrialStatus();
+      }
       this.trialMessage.set(error?.message || '试用领取失败,请稍后重试。');
       this.trialMessage.set(error?.message || '试用领取失败,请稍后重试。');
     } finally {
     } finally {
       this.trialBusy.set(false);
       this.trialBusy.set(false);
@@ -612,12 +635,43 @@ export class QiweiSkillInstallComponent implements OnInit, OnDestroy {
       const result = await this.authJson('/api/qiwei/trial/status', 'GET');
       const result = await this.authJson('/api/qiwei/trial/status', 'GET');
       if (result.code !== 200 || !result.data) throw new Error(result.mess || '试用状态查询失败');
       if (result.code !== 200 || !result.data) throw new Error(result.mess || '试用状态查询失败');
       this.trialStatus.set(result.data as TrialStatus);
       this.trialStatus.set(result.data as TrialStatus);
+      if (result.data.state === 'code_sent') this.startTrialCountdown(result.data.codeExpiresAt);
+      else this.clearTrialCountdown();
     } catch (error: any) {
     } catch (error: any) {
       this.trialStatus.set(null);
       this.trialStatus.set(null);
       if (!this.errorMessage()) this.errorMessage.set(error?.message || '试用状态查询失败,请稍后重试。');
       if (!this.errorMessage()) this.errorMessage.set(error?.message || '试用状态查询失败,请稍后重试。');
     }
     }
   }
   }
 
 
+  private startTrialCountdown(expiresAt?: string | null): void {
+    this.clearTrialCountdown();
+    const expiry = Date.parse(expiresAt || '');
+    if (!Number.isFinite(expiry)) return;
+    const update = () => {
+      const remaining = Math.max(0, Math.ceil((expiry - Date.now()) / 1000));
+      this.trialSecondsRemaining.set(remaining);
+      if (remaining === 0) {
+        this.clearTrialCountdown();
+        const current = this.trialStatus();
+        if (current?.state === 'code_sent') {
+          this.trialCode.set('');
+          this.trialStatus.set({ ...current, state: 'available', codeExpiresAt: null });
+          this.trialMessage.set('验证码已过期,请重新获取。');
+        }
+      }
+    };
+    update();
+    if (this.trialSecondsRemaining() > 0) this.trialExpiryTimer = setInterval(update, 1000);
+  }
+
+  private clearTrialCountdown(): void {
+    if (this.trialExpiryTimer) {
+      clearInterval(this.trialExpiryTimer);
+      this.trialExpiryTimer = null;
+    }
+    this.trialSecondsRemaining.set(0);
+  }
+
   private pendingMatchesQuote(pending: PendingOrder): boolean {
   private pendingMatchesQuote(pending: PendingOrder): boolean {
     const quote = this.quote();
     const quote = this.quote();
     return Boolean(
     return Boolean(