|
|
@@ -58,6 +58,7 @@ interface TrialStatus {
|
|
|
credentialReady: boolean;
|
|
|
phoneRequired: boolean;
|
|
|
phone?: string | null;
|
|
|
+ codeExpiresAt?: string | null;
|
|
|
unavailableReason?: string | null;
|
|
|
}
|
|
|
|
|
|
@@ -113,7 +114,12 @@ interface PendingOrder {
|
|
|
</button>
|
|
|
} @else if (trialStatus()!.state === 'code_sent') {
|
|
|
<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">
|
|
|
<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()">
|
|
|
@@ -121,6 +127,9 @@ interface PendingOrder {
|
|
|
领取试用
|
|
|
</button>
|
|
|
</div>
|
|
|
+ <button type="button" class="resend-button" [disabled]="trialBusy() || trialSecondsRemaining() > 0" (click)="requestTrialCode()">
|
|
|
+ {{ trialSecondsRemaining() > 0 ? '重新获取(' + formatCountdown(trialSecondsRemaining()) + ')' : '重新获取验证码' }}
|
|
|
+ </button>
|
|
|
</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}
|
|
|
.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}
|
|
|
- .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}
|
|
|
.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}
|
|
|
@@ -347,6 +356,7 @@ export class QiweiSkillInstallComponent implements OnInit, OnDestroy {
|
|
|
readonly trialCode = signal('');
|
|
|
readonly trialMessage = signal('');
|
|
|
readonly trialBusy = signal(false);
|
|
|
+ readonly trialSecondsRemaining = signal(0);
|
|
|
readonly stage = signal<FlowStage>('checking');
|
|
|
readonly busy = signal(false);
|
|
|
readonly errorMessage = signal('');
|
|
|
@@ -364,6 +374,7 @@ export class QiweiSkillInstallComponent implements OnInit, OnDestroy {
|
|
|
private pollTimer: ReturnType<typeof setInterval> | null = null;
|
|
|
private quoteSequence = 0;
|
|
|
private hasExplicitSeats = false;
|
|
|
+ private trialExpiryTimer: ReturnType<typeof setInterval> | null = null;
|
|
|
|
|
|
ngOnInit(): void {
|
|
|
const hashQuery = window.location.hash.includes('?') ? window.location.hash.split('?')[1] : '';
|
|
|
@@ -378,6 +389,7 @@ export class QiweiSkillInstallComponent implements OnInit, OnDestroy {
|
|
|
|
|
|
ngOnDestroy(): void {
|
|
|
this.clearPolling();
|
|
|
+ this.clearTrialCountdown();
|
|
|
}
|
|
|
|
|
|
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');
|
|
|
}
|
|
|
|
|
|
+ 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 {
|
|
|
if (this.pendingOrder()) return `继续原订单 ¥${this.formatMoney(this.pendingOrder()!.amount)}`;
|
|
|
const amount = this.quote()?.amount || 0;
|
|
|
@@ -505,7 +522,9 @@ export class QiweiSkillInstallComponent implements OnInit, OnDestroy {
|
|
|
...(this.trialStatus() || {} as TrialStatus),
|
|
|
state: 'code_sent',
|
|
|
phone: result.data.maskedPhone || null,
|
|
|
+ codeExpiresAt: result.data.expiresAt || null,
|
|
|
});
|
|
|
+ this.startTrialCountdown(result.data.expiresAt);
|
|
|
this.trialMessage.set('验证码有效期 10 分钟,请在当前页面完成领取。');
|
|
|
} catch (error: any) {
|
|
|
this.trialMessage.set(error?.message || '验证码发送失败,请稍后重试。');
|
|
|
@@ -528,6 +547,10 @@ export class QiweiSkillInstallComponent implements OnInit, OnDestroy {
|
|
|
this.stage.set('active');
|
|
|
await this.requestInstallPrompt();
|
|
|
} catch (error: any) {
|
|
|
+ if (Number(error?.status) === 400 || /验证码已过期/.test(error?.message || '')) {
|
|
|
+ this.trialCode.set('');
|
|
|
+ await this.loadTrialStatus();
|
|
|
+ }
|
|
|
this.trialMessage.set(error?.message || '试用领取失败,请稍后重试。');
|
|
|
} finally {
|
|
|
this.trialBusy.set(false);
|
|
|
@@ -612,12 +635,43 @@ export class QiweiSkillInstallComponent implements OnInit, OnDestroy {
|
|
|
const result = await this.authJson('/api/qiwei/trial/status', 'GET');
|
|
|
if (result.code !== 200 || !result.data) throw new Error(result.mess || '试用状态查询失败');
|
|
|
this.trialStatus.set(result.data as TrialStatus);
|
|
|
+ if (result.data.state === 'code_sent') this.startTrialCountdown(result.data.codeExpiresAt);
|
|
|
+ else this.clearTrialCountdown();
|
|
|
} catch (error: any) {
|
|
|
this.trialStatus.set(null);
|
|
|
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 {
|
|
|
const quote = this.quote();
|
|
|
return Boolean(
|