|
@@ -0,0 +1,973 @@
|
|
|
|
|
+import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnDestroy, OnInit, Output, signal } from '@angular/core';
|
|
|
|
|
+import { CommonModule } from '@angular/common';
|
|
|
|
|
+import QRCode from 'qrcode';
|
|
|
|
|
+
|
|
|
|
|
+const API_BASE = 'https://server.fmode.cn';
|
|
|
|
|
+const APP_ID = 'ncloudmaster';
|
|
|
|
|
+const PAY_COMPANY = '1AiWpTEDH9';
|
|
|
|
|
+const PENDING_ORDER_KEY = 'qiwei_skill_subscription_pending_order';
|
|
|
|
|
+
|
|
|
|
|
+type FlowStage = 'checking' | 'ready' | 'paying' | 'crediting' | 'activating' | 'active';
|
|
|
|
|
+
|
|
|
|
|
+interface Quote {
|
|
|
|
|
+ currency: 'CNY';
|
|
|
|
|
+ seats: number;
|
|
|
|
|
+ months: number;
|
|
|
|
|
+ listUnitPrice: number;
|
|
|
|
|
+ unitPrice: number;
|
|
|
|
|
+ monthlyAmount: number;
|
|
|
|
|
+ listAmount: number;
|
|
|
|
|
+ amount: number;
|
|
|
|
|
+ discountAmount: number;
|
|
|
|
|
+ discountRate: number;
|
|
|
|
|
+ discountLabel: string;
|
|
|
|
|
+ allowed: boolean;
|
|
|
|
|
+ reason: string | null;
|
|
|
|
|
+ changeType: 'new' | 'renewal' | 'upgrade' | 'downgrade';
|
|
|
|
|
+ currentSeats: number;
|
|
|
|
|
+ remainingDays: number;
|
|
|
|
|
+ renewalAmount: number;
|
|
|
|
|
+ proratedAmount: number;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+interface PlanData {
|
|
|
|
|
+ baseMonthlyPrice: number;
|
|
|
|
|
+ maxSeats: number;
|
|
|
|
|
+ seatPresets: number[];
|
|
|
|
|
+ monthPresets: number[];
|
|
|
|
|
+ quote: Quote;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+interface SubscriptionStatus {
|
|
|
|
|
+ subscribed: boolean;
|
|
|
|
|
+ state: 'active' | 'expired' | 'inactive';
|
|
|
|
|
+ expireAt: string | null;
|
|
|
|
|
+ seats: number | null;
|
|
|
|
|
+ usedSeats: number;
|
|
|
|
|
+ balance?: number;
|
|
|
|
|
+ lastIdempotencyKey?: string | null;
|
|
|
|
|
+ quote: Quote;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+interface TrialStatus {
|
|
|
|
|
+ claimed: boolean;
|
|
|
|
|
+ active: boolean;
|
|
|
|
|
+ state: 'available' | 'code_sent' | 'active' | 'expired' | 'unavailable';
|
|
|
|
|
+ expireAt: string | null;
|
|
|
|
|
+ seats: number | null;
|
|
|
|
|
+ credentialReady: boolean;
|
|
|
|
|
+ phoneRequired: boolean;
|
|
|
|
|
+ phone?: string | null;
|
|
|
|
|
+ unavailableReason?: string | null;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+interface PendingOrder {
|
|
|
|
|
+ userId: string;
|
|
|
|
|
+ tradeNo: string;
|
|
|
|
|
+ nonceStr: string;
|
|
|
|
|
+ codeUrl?: string;
|
|
|
|
|
+ paymentMethod: 'balance' | 'wechat';
|
|
|
|
|
+ seats: number;
|
|
|
|
|
+ months: number;
|
|
|
|
|
+ amount: number;
|
|
|
|
|
+ source: string;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+@Component({
|
|
|
|
|
+ selector: 'app-qiwei-skill-install',
|
|
|
|
|
+ standalone: true,
|
|
|
|
|
+ imports: [CommonModule],
|
|
|
|
|
+ changeDetection: ChangeDetectionStrategy.OnPush,
|
|
|
|
|
+ template: `
|
|
|
|
|
+ <div class="qiwei-page">
|
|
|
|
|
+ <header class="page-header">
|
|
|
|
|
+ <a class="brand" href="https://fmode.cn/qiwei-skill/" aria-label="Fmode 企业微信智能助手">
|
|
|
|
|
+ <span class="brand-mark">F</span>
|
|
|
|
|
+ <span>Fmode<span> · 企微助手</span></span>
|
|
|
|
|
+ </a>
|
|
|
|
|
+ <div class="user-area">
|
|
|
|
|
+ <span>{{ displayName || userId }}</span>
|
|
|
|
|
+ <button type="button" class="icon-button" title="退出登录" aria-label="退出登录" (click)="logout.emit()">
|
|
|
|
|
+ <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M10 17l5-5-5-5M15 12H3M21 3v18h-7"/></svg>
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </header>
|
|
|
|
|
+
|
|
|
|
|
+ <main class="purchase-layout">
|
|
|
|
|
+ <section class="configuration" aria-labelledby="purchase-title">
|
|
|
|
|
+ <div class="product-name">企业微信智能助手</div>
|
|
|
|
|
+ <h1 id="purchase-title">按团队并发规模,开通企微 AI 工作台</h1>
|
|
|
|
|
+ <p class="lead">每个席位可绑定一个企微账号。席位越多,单席月价越低;支付完成后直接获得交给 AI 的安装提示词。</p>
|
|
|
|
|
+
|
|
|
|
|
+ @if (trialStatus() && (trialStatus()!.state === 'available' || trialStatus()!.state === 'code_sent') && !subscription()?.subscribed) {
|
|
|
|
|
+ <section class="trial-panel" aria-labelledby="trial-title">
|
|
|
|
|
+ <div class="trial-copy">
|
|
|
|
|
+ <span class="trial-kicker">新人专享</span>
|
|
|
|
|
+ <h2 id="trial-title">免费体验 7 天企微数字员工</h2>
|
|
|
|
|
+ <p>登录即可领取,7 天内可体验 1 个企微账号接入。每位用户限领一次。</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ @if (trialStatus()!.state === 'available') {
|
|
|
|
|
+ <button type="button" class="secondary-button" [disabled]="trialBusy()" (click)="requestTrialCode()">
|
|
|
|
|
+ @if (trialBusy()) { <span class="spinner"></span> }
|
|
|
|
|
+ 获取验证码
|
|
|
|
|
+ </button>
|
|
|
|
|
+ } @else if (trialStatus()!.state === 'code_sent') {
|
|
|
|
|
+ <div class="trial-form">
|
|
|
|
|
+ <span class="trial-phone">验证码已发送至 {{ trialStatus()!.phone || '绑定手机号' }}</span>
|
|
|
|
|
+ <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()">
|
|
|
|
|
+ @if (trialBusy()) { <span class="spinner light"></span> }
|
|
|
|
|
+ 领取试用
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ }
|
|
|
|
|
+ @if (trialMessage()) { <div class="trial-message">{{ trialMessage() }}</div> }
|
|
|
|
|
+ </section>
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @if (trialStatus()?.state === 'unavailable' && trialStatus()?.claimed) {
|
|
|
|
|
+ <section class="trial-panel trial-panel-unavailable" aria-live="polite">
|
|
|
|
|
+ <div class="trial-copy">
|
|
|
|
|
+ <span class="trial-kicker">暂时不可用</span>
|
|
|
|
|
+ <h2>试用资源正在调整</h2>
|
|
|
|
|
+ <p>{{ trialStatus()?.unavailableReason || '当前试用资源暂时不可用,请稍后重试。' }}</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </section>
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @if (subscription()?.subscribed && !purchaseMode()) {
|
|
|
|
|
+ <div class="active-subscription">
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <span class="status-dot"></span>
|
|
|
|
|
+ <strong>当前服务已开通</strong>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <dl>
|
|
|
|
|
+ <div><dt>并发席位</dt><dd>{{ subscription()?.usedSeats || 0 }} / {{ subscription()?.seats || 1 }}</dd></div>
|
|
|
|
|
+ <div><dt>有效期至</dt><dd>{{ formatDate(subscription()?.expireAt) }}</dd></div>
|
|
|
|
|
+ </dl>
|
|
|
|
|
+ <button type="button" class="text-button" (click)="purchaseMode.set(true)">续费或调整席位</button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ <div class="control-section">
|
|
|
|
|
+ <div class="control-heading">
|
|
|
|
|
+ <div><strong>并发席位</strong><span>一个席位对应一个企微账号</span></div>
|
|
|
|
|
+ <div class="stepper" aria-label="调整并发席位">
|
|
|
|
|
+ <button type="button" aria-label="减少席位" [disabled]="seats() <= 1 || !!pendingOrder()" (click)="changeSeats(-1)">−</button>
|
|
|
|
|
+ <input type="number" min="1" [max]="maxSeats()" [disabled]="!!pendingOrder()" [value]="seats()" (change)="setSeats($any($event.target).value)" aria-label="席位数量" />
|
|
|
|
|
+ <button type="button" aria-label="增加席位" [disabled]="seats() >= maxSeats() || !!pendingOrder()" (click)="changeSeats(1)">+</button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="preset-grid" role="radiogroup" aria-label="常用席位档位">
|
|
|
|
|
+ @for (value of seatPresets(); track value) {
|
|
|
|
|
+ <button type="button" role="radio" [disabled]="!!pendingOrder()" [class.selected]="seats() === value" [attr.aria-checked]="seats() === value" (click)="setSeats(value)">
|
|
|
|
|
+ <strong>{{ value }}</strong><span>席位</span>
|
|
|
|
|
+ </button>
|
|
|
|
|
+ }
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div class="control-section duration-section">
|
|
|
|
|
+ <div class="control-heading">
|
|
|
|
|
+ <div><strong>购买时长</strong><span>一次支付,按所选月数顺延有效期</span></div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="duration-control" role="radiogroup" aria-label="购买时长">
|
|
|
|
|
+ @for (value of monthPresets(); track value) {
|
|
|
|
|
+ <button type="button" role="radio" [disabled]="!!pendingOrder()" [class.selected]="months() === value" [attr.aria-checked]="months() === value" (click)="setMonths(value)">{{ value }} 个月</button>
|
|
|
|
|
+ }
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div class="service-points" aria-label="服务包含">
|
|
|
|
|
+ <span><svg viewBox="0 0 24 24"><path d="m5 12 4 4L19 6"/></svg>多账号与独立会话</span>
|
|
|
|
|
+ <span><svg viewBox="0 0 24 24"><path d="m5 12 4 4L19 6"/></svg>客户与群运营工作台</span>
|
|
|
|
|
+ <span><svg viewBox="0 0 24 24"><path d="m5 12 4 4L19 6"/></svg>待审核与白名单控制</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ @if (pendingOrder()) {
|
|
|
|
|
+ <div class="pending-notice">
|
|
|
|
|
+ <span>未完成订单已锁定为 {{ pendingOrder()!.seats }} 席位 · {{ pendingOrder()!.months }} 个月。继续支付或等待激活期间不会创建新订单。</span>
|
|
|
|
|
+ @if (canCancelPaymentForPlanChange()) {
|
|
|
|
|
+ <button type="button" (click)="cancelPaymentForPlanChange()">取消订单并重选</button>
|
|
|
|
|
+ }
|
|
|
|
|
+ </div>
|
|
|
|
|
+ }
|
|
|
|
|
+ </section>
|
|
|
|
|
+
|
|
|
|
|
+ <aside class="order-summary" aria-live="polite">
|
|
|
|
|
+ <div class="summary-heading">
|
|
|
|
|
+ <span>订单摘要</span>
|
|
|
|
|
+ @if (quote() && quote()!.discountRate < 1) { <b>{{ quote()!.discountLabel }}</b> }
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ @if (stage() === 'checking' || !quote()) {
|
|
|
|
|
+ <div class="loading-state"><span class="spinner"></span>正在读取服务端报价...</div>
|
|
|
|
|
+ } @else {
|
|
|
|
|
+ <div class="price-main"><small>¥</small>{{ formatMoney(quote()!.amount) }}</div>
|
|
|
|
|
+ <div class="price-caption">{{ seats() }} 席位 · {{ months() }} 个月</div>
|
|
|
|
|
+
|
|
|
|
|
+ <dl class="summary-lines">
|
|
|
|
|
+ <div><dt>标准单席月价</dt><dd>¥{{ formatMoney(quote()!.listUnitPrice) }}</dd></div>
|
|
|
|
|
+ <div><dt>平均单席月价</dt><dd>¥{{ formatMoney(quote()!.unitPrice) }}</dd></div>
|
|
|
|
|
+ <div><dt>每月合计</dt><dd>¥{{ formatMoney(quote()!.monthlyAmount) }}</dd></div>
|
|
|
|
|
+ @if (quote()!.changeType === 'upgrade') {
|
|
|
|
|
+ <div><dt>剩余 {{ quote()!.remainingDays }} 天增席补差</dt><dd>¥{{ formatMoney(quote()!.proratedAmount) }}</dd></div>
|
|
|
|
|
+ <div><dt>续费 {{ months() }} 个月</dt><dd>¥{{ formatMoney(quote()!.renewalAmount) }}</dd></div>
|
|
|
|
|
+ }
|
|
|
|
|
+ @if (quote()!.discountAmount > 0) {
|
|
|
|
|
+ <div class="saving"><dt>并发优惠</dt><dd>−¥{{ formatMoney(quote()!.discountAmount) }}</dd></div>
|
|
|
|
|
+ }
|
|
|
|
|
+ </dl>
|
|
|
|
|
+
|
|
|
|
|
+ @if (subscription()?.balance !== undefined) {
|
|
|
|
|
+ <div class="balance-line"><span>飞马可用余额</span><strong>¥{{ formatMoney(subscription()!.balance || 0) }}</strong></div>
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @if (errorMessage()) { <div class="flow-error">{{ errorMessage() }}</div> }
|
|
|
|
|
+ @if (quote()!.allowed === false) { <div class="flow-error">{{ quote()!.reason || '当前订阅暂不支持该席位变更' }}</div> }
|
|
|
|
|
+
|
|
|
|
|
+ @if (subscription()?.subscribed && !purchaseMode()) {
|
|
|
|
|
+ <button type="button" class="primary-button" [disabled]="busy()" (click)="requestInstallPrompt()">
|
|
|
|
|
+ @if (busy()) { <span class="spinner light"></span> }
|
|
|
|
|
+ 获取专属安装提示词
|
|
|
|
|
+ </button>
|
|
|
|
|
+ } @else {
|
|
|
|
|
+ <button type="button" class="primary-button" [disabled]="busy() || quote()!.allowed === false" (click)="continuePurchase()">
|
|
|
|
|
+ @if (busy()) { <span class="spinner light"></span> }
|
|
|
|
|
+ {{ actionLabel() }}
|
|
|
|
|
+ </button>
|
|
|
|
|
+ @if (subscription()?.subscribed) {
|
|
|
|
|
+ <button type="button" class="secondary-button" (click)="purchaseMode.set(false)">返回当前服务</button>
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ <p class="billing-note">报价与扣费均由服务端核定。支付后将依次确认到账和订阅激活,刷新页面可继续原订单。</p>
|
|
|
|
|
+ }
|
|
|
|
|
+ </aside>
|
|
|
|
|
+ </main>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ @if (showPayment()) {
|
|
|
|
|
+ <div class="modal-backdrop" role="presentation">
|
|
|
|
|
+ <section class="payment-dialog" role="dialog" aria-modal="true" aria-labelledby="payment-title">
|
|
|
|
|
+ <button type="button" class="dialog-close icon-button" title="关闭" aria-label="关闭支付窗口" (click)="closePayment()">
|
|
|
|
|
+ <svg viewBox="0 0 24 24"><path d="M6 6l12 12M18 6 6 18"/></svg>
|
|
|
|
|
+ </button>
|
|
|
|
|
+ <h2 id="payment-title">开通企业微信智能助手</h2>
|
|
|
|
|
+ <div class="dialog-amount">¥{{ formatMoney(pendingOrder()?.amount || quote()?.amount || 0) }}</div>
|
|
|
|
|
+
|
|
|
|
|
+ @if (stage() === 'paying') {
|
|
|
|
|
+ <div class="qr-frame">
|
|
|
|
|
+ @if (qrDataUrl()) { <img [src]="qrDataUrl()" width="228" height="228" alt="微信支付二维码" /> }
|
|
|
|
|
+ @else { <span class="spinner dark"></span> }
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <p class="dialog-hint">请使用微信扫码完成支付</p>
|
|
|
|
|
+ } @else {
|
|
|
|
|
+ <div class="processing-symbol"><span class="spinner"></span></div>
|
|
|
|
|
+ <p class="dialog-hint">{{ stage() === 'crediting' ? '支付已确认,正在等待余额到账' : '余额已到账,正在激活订阅' }}</p>
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ <ol class="payment-progress">
|
|
|
|
|
+ <li class="done"><span>1</span><div><strong>创建订单</strong><small>金额与套餐已锁定</small></div></li>
|
|
|
|
|
+ <li [class.done]="stage() !== 'paying'" [class.current]="stage() === 'crediting'"><span>2</span><div><strong>支付到账</strong><small>{{ stage() === 'paying' ? '等待微信支付' : stage() === 'crediting' ? '正在核对账户流水' : '账户流水已确认' }}</small></div></li>
|
|
|
|
|
+ <li [class.done]="stage() === 'active'" [class.current]="stage() === 'activating'"><span>3</span><div><strong>激活订阅</strong><small>{{ stage() === 'active' ? '服务已开通' : stage() === 'activating' ? '正在写入席位和有效期' : '到账后自动执行' }}</small></div></li>
|
|
|
|
|
+ </ol>
|
|
|
|
|
+
|
|
|
|
|
+ @if (paymentMessage()) { <div class="payment-message">{{ paymentMessage() }}</div> }
|
|
|
|
|
+ @if (stage() === 'crediting' || stage() === 'activating') {
|
|
|
|
|
+ <button type="button" class="secondary-button full" [disabled]="busy()" (click)="resumeActivation()">刷新到账与激活状态</button>
|
|
|
|
|
+ }
|
|
|
|
|
+ @if (canCancelPaymentForPlanChange()) {
|
|
|
|
|
+ <button type="button" class="secondary-button full" (click)="cancelPaymentForPlanChange()">取消支付并重选套餐</button>
|
|
|
|
|
+ <p class="cancel-payment-note">若已完成扫码支付,到账金额仍会保留在飞马余额中。</p>
|
|
|
|
|
+ }
|
|
|
|
|
+ </section>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @if (showPrompt()) {
|
|
|
|
|
+ <div class="modal-backdrop prompt-backdrop" role="presentation">
|
|
|
|
|
+ <section class="prompt-dialog" role="dialog" aria-modal="true" aria-labelledby="prompt-title">
|
|
|
|
|
+ <div class="prompt-heading">
|
|
|
|
|
+ <div class="success-icon"><svg viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5"/></svg></div>
|
|
|
|
|
+ <div><h2 id="prompt-title">企微助手已开通</h2><p>{{ subscription()?.seats || seats() }} 个并发席位 · 有效期至 {{ formatDate(subscription()?.expireAt) }}</p></div>
|
|
|
|
|
+ <button type="button" class="icon-button" title="关闭" aria-label="关闭安装提示词" (click)="showPrompt.set(false)"><svg viewBox="0 0 24 24"><path d="M6 6l12 12M18 6 6 18"/></svg></button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="prompt-preview" tabindex="0"><pre>{{ maskedPrompt() }}</pre></div>
|
|
|
|
|
+ <div class="security-note"><svg viewBox="0 0 24 24"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10Z"/><path d="m9 12 2 2 4-4"/></svg>预览已隐藏专属授权。完整内容仅在点击复制时写入剪贴板,请直接交给自己的 AI。</div>
|
|
|
|
|
+ <button type="button" class="copy-button" (click)="copyPrompt()">
|
|
|
|
|
+ @if (copied()) { 已复制,可发送给 AI } @else { 复制完整提示词给 AI }
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </section>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ }
|
|
|
|
|
+ `,
|
|
|
|
|
+ styles: [`
|
|
|
|
|
+ :host{display:block;min-height:100vh;color:#edf2f3;background:#0b0e10;font-family:Inter,"PingFang SC","Microsoft YaHei",sans-serif}
|
|
|
|
|
+ *{box-sizing:border-box;letter-spacing:0}button,input{font:inherit}button{cursor:pointer}svg{fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
|
|
|
|
|
+ button:focus-visible,input:focus-visible,.prompt-preview:focus-visible{outline:2px solid #25b983;outline-offset:3px}
|
|
|
|
|
+ .qiwei-page{min-height:100vh;background:linear-gradient(180deg,#0b0e10 0,#101619 68%,#0b0e10 100%)}
|
|
|
|
|
+ .page-header{height:70px;padding:0 clamp(18px,5vw,72px);display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid #283035;background:rgba(11,14,16,.94)}
|
|
|
|
|
+ .brand{display:flex;align-items:center;gap:10px;color:#f7fbfa;text-decoration:none;font-size:17px;font-weight:800}.brand>span:last-child>span{color:#55d6a8;font-weight:650}.brand-mark{width:31px;height:31px;display:grid;place-items:center;border-radius:6px;background:#25b983;color:#07130f;font-size:15px}
|
|
|
|
|
+ .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}
|
|
|
|
|
+ .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}
|
|
|
|
|
+ .preset-grid{margin-top:14px;display:grid;grid-template-columns:repeat(5,1fr);gap:8px}.preset-grid button{min-height:58px;display:flex;align-items:center;justify-content:center;gap:4px;border:1px solid #313a40;border-radius:6px;background:#13181b;color:#a8b3b6}.preset-grid button:disabled{opacity:.45;cursor:not-allowed}.preset-grid button strong{font-size:18px;line-height:1}.preset-grid button span{font-size:10px;line-height:1}.preset-grid button.selected{border-color:#25b983;background:#10231c;color:#eafff7;box-shadow:inset 0 0 0 1px rgba(37,185,131,.18)}
|
|
|
|
|
+ .duration-control{margin-top:14px;padding:3px;display:grid;grid-template-columns:repeat(4,1fr);gap:3px;border:1px solid #313a40;border-radius:6px;background:#101416}.duration-control button{height:39px;border:0;border-radius:4px;background:transparent;color:#879398;font-size:12px}.duration-control button:disabled{opacity:.45;cursor:not-allowed}.duration-control button.selected{background:#25302c;color:#f1f7f4}
|
|
|
|
|
+ .service-points{padding-top:20px;display:flex;flex-wrap:wrap;gap:13px 22px;border-top:1px solid #2a3338;color:#9ca8ac;font-size:12px}.service-points span{display:flex;align-items:center;gap:7px}.service-points svg{width:15px;color:#55d6a8}.pending-notice{margin-top:16px;padding:10px 12px;display:flex;align-items:center;justify-content:space-between;gap:14px;border-left:3px solid #55d6a8;background:#10211b;color:#9fc8b9;font-size:11px;line-height:1.55}.pending-notice span{min-width:0}.pending-notice button{flex:0 0 auto;padding:0;border:0;background:transparent;color:#6de0b1;font:inherit;font-weight:750;text-decoration:underline;text-underline-offset:3px;cursor:pointer}
|
|
|
|
|
+ .order-summary{min-height:500px;padding:28px;border:1px solid #303a40;border-radius:8px;background:#12171a;box-shadow:0 28px 80px rgba(0,0,0,.32)}.summary-heading{display:flex;align-items:center;justify-content:space-between;color:#a4afb2;font-size:12px;font-weight:700}.summary-heading b{padding:3px 7px;border-radius:4px;background:#173426;color:#69dda9;font-size:11px}.loading-state{min-height:360px;display:flex;align-items:center;justify-content:center;gap:9px;color:#829094;font-size:13px}
|
|
|
|
|
+ .price-main{margin-top:24px;color:#fff;font-size:44px;line-height:1;font-weight:780}.price-main small{margin-right:4px;color:#809095;font-size:16px}.price-caption{margin-top:8px;color:#7f8c90;font-size:12px}.summary-lines{margin:26px 0 0;border-top:1px solid #2a3338}.summary-lines>div{padding:12px 0;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid #252e33;font-size:12px}.summary-lines dt{color:#849196}.summary-lines dd{margin:0;color:#dbe3e1;font-weight:650}.summary-lines .saving dd{color:#55d6a8}.balance-line{margin-top:17px;padding:11px 12px;display:flex;align-items:center;justify-content:space-between;border:1px solid #2c493f;border-radius:6px;background:#10211b;color:#8fb3a6;font-size:12px}.balance-line strong{color:#dff8ee}
|
|
|
|
|
+ .primary-button,.copy-button{width:100%;min-height:46px;margin-top:18px;border:1px solid #25b983;border-radius:6px;background:#25b983;color:#06140f;font-size:13px;font-weight:800;display:flex;align-items:center;justify-content:center;gap:8px}.primary-button:hover,.copy-button:hover{background:#35ca94}.primary-button:disabled{opacity:.55;cursor:not-allowed}.secondary-button{min-height:40px;margin-top:10px;padding:0 14px;border:1px solid #39444a;border-radius:6px;background:#171d20;color:#c4cdcf;font-size:12px}.secondary-button.full{width:100%}.billing-note{margin:14px 0 0;color:#6f7b7f;font-size:10px;line-height:1.6;text-align:center}.flow-error,.payment-message{margin-top:14px;padding:10px 11px;border-left:3px solid #ef7359;background:#291713;color:#ffbaa9;font-size:12px;line-height:1.5}
|
|
|
|
|
+ .spinner{width:17px;height:17px;display:inline-block;border:2px solid #3c484d;border-top-color:#55d6a8;border-radius:50%;animation:spin .75s linear infinite}.spinner.light{border-color:rgba(0,0,0,.25);border-top-color:#06140f}.spinner.dark{border-color:#dce3e1;border-top-color:#25b983}@keyframes spin{to{transform:rotate(360deg)}}
|
|
|
|
|
+ .modal-backdrop{position:fixed;inset:0;z-index:1000;padding:20px;display:grid;place-items:center;background:rgba(3,5,6,.82);backdrop-filter:blur(8px)}.payment-dialog,.prompt-dialog{position:relative;width:min(460px,100%);padding:28px;border:1px solid #344047;border-radius:8px;background:#12171a;box-shadow:0 35px 110px rgba(0,0,0,.58)}.dialog-close{position:absolute;right:16px;top:16px}.payment-dialog h2{margin:0;text-align:center;font-size:19px}.dialog-amount{margin:8px 0 18px;text-align:center;color:#fff;font-size:32px;font-weight:760}.qr-frame{width:248px;height:248px;margin:0 auto;display:grid;place-items:center;border:1px solid #414d52;border-radius:7px;background:#fff}.qr-frame img{display:block}.dialog-hint{margin:15px 0;color:#9aa6aa;text-align:center;font-size:12px}.cancel-payment-note{margin:9px 0 0;color:#778488;text-align:center;font-size:10px;line-height:1.55}.processing-symbol{width:70px;height:70px;margin:30px auto 14px;display:grid;place-items:center;border-radius:50%;background:#10251d}.processing-symbol .spinner{width:28px;height:28px}
|
|
|
|
|
+ .payment-progress{margin:24px 0 0;padding:0;list-style:none}.payment-progress li{position:relative;min-height:56px;display:grid;grid-template-columns:28px 1fr;gap:11px;color:#6f7b80}.payment-progress li:not(:last-child)::after{content:"";position:absolute;left:13px;top:30px;width:1px;height:25px;background:#354047}.payment-progress li>span{width:28px;height:28px;display:grid;place-items:center;border:1px solid #3a454b;border-radius:50%;font-size:11px}.payment-progress strong,.payment-progress small{display:block}.payment-progress strong{font-size:12px}.payment-progress small{margin-top:4px;font-size:10px}.payment-progress li.done,.payment-progress li.current{color:#dce6e2}.payment-progress li.done>span{border-color:#25b983;background:#25b983;color:#07140f}.payment-progress li.current>span{border-color:#55d6a8;color:#55d6a8}.payment-progress li.done:not(:last-child)::after{background:#25b983}
|
|
|
|
|
+ .prompt-backdrop{align-items:center}.prompt-dialog{width:min(780px,100%)}.prompt-heading{display:grid;grid-template-columns:42px 1fr 36px;gap:12px;align-items:start}.success-icon{width:42px;height:42px;display:grid;place-items:center;border-radius:7px;background:#123024;color:#60dda5}.success-icon svg{width:20px}.prompt-heading h2{margin:0;font-size:21px}.prompt-heading p{margin:6px 0 0;color:#879397;font-size:12px}.prompt-preview{height:320px;margin-top:22px;padding:17px;overflow:auto;border:1px solid #2e383d;border-radius:6px;background:#090c0e}.prompt-preview pre{margin:0;color:#bec8cb;font:12px/1.7 Consolas,monospace;white-space:pre-wrap;overflow-wrap:anywhere}.security-note{margin-top:13px;display:flex;align-items:flex-start;gap:8px;color:#d5b463;font-size:11px;line-height:1.55}.security-note svg{width:17px;flex:0 0 auto}
|
|
|
|
|
+ @media(max-width:880px){.purchase-layout{grid-template-columns:1fr;gap:38px;align-items:start}.configuration{max-width:none}.order-summary{min-height:0}}
|
|
|
|
|
+ @media(max-width:560px){.page-header{height:62px;padding:0 15px}.user-area>span{display:none}.purchase-layout{width:calc(100% - 28px);padding:32px 0 48px}.configuration h1{font-size:36px}.lead{font-size:14px}.control-heading{align-items:flex-start}.preset-grid{grid-template-columns:repeat(3,1fr)}.duration-control{grid-template-columns:repeat(2,1fr)}.service-points{display:grid}.order-summary{padding:22px}.modal-backdrop{padding:0;align-items:end}.payment-dialog,.prompt-dialog{width:100%;max-height:100vh;border-radius:8px 8px 0 0;padding:23px 18px}.prompt-dialog{height:100vh;border-radius:0;display:flex;flex-direction:column}.prompt-preview{height:auto;min-height:0;flex:1}.prompt-heading{grid-template-columns:38px 1fr 36px}.qr-frame{width:232px;height:232px}.qr-frame img{width:216px;height:216px}}
|
|
|
|
|
+ @media(max-width:560px){.trial-input-row{grid-template-columns:1fr}.trial-input-row .compact{width:100%}}
|
|
|
|
|
+ @media(prefers-reduced-motion:reduce){*{animation-duration:.01ms!important;transition-duration:.01ms!important}}
|
|
|
|
|
+ `]
|
|
|
|
|
+})
|
|
|
|
|
+export class QiweiSkillInstallComponent implements OnInit, OnDestroy {
|
|
|
|
|
+ @Input({ required: true }) sessionToken = '';
|
|
|
|
|
+ @Input({ required: true }) userId = '';
|
|
|
|
|
+ @Input() displayName = '';
|
|
|
|
|
+ @Output() logout = new EventEmitter<void>();
|
|
|
|
|
+
|
|
|
|
|
+ readonly seats = signal(1);
|
|
|
|
|
+ readonly months = signal(1);
|
|
|
|
|
+ readonly plans = signal<PlanData | null>(null);
|
|
|
|
|
+ readonly quote = signal<Quote | null>(null);
|
|
|
|
|
+ readonly subscription = signal<SubscriptionStatus | null>(null);
|
|
|
|
|
+ readonly trialStatus = signal<TrialStatus | null>(null);
|
|
|
|
|
+ readonly trialCode = signal('');
|
|
|
|
|
+ readonly trialMessage = signal('');
|
|
|
|
|
+ readonly trialBusy = signal(false);
|
|
|
|
|
+ readonly stage = signal<FlowStage>('checking');
|
|
|
|
|
+ readonly busy = signal(false);
|
|
|
|
|
+ readonly errorMessage = signal('');
|
|
|
|
|
+ readonly paymentMessage = signal('');
|
|
|
|
|
+ readonly qrDataUrl = signal('');
|
|
|
|
|
+ readonly showPayment = signal(false);
|
|
|
|
|
+ readonly showPrompt = signal(false);
|
|
|
|
|
+ readonly prompt = signal('');
|
|
|
|
|
+ readonly maskedPrompt = signal('');
|
|
|
|
|
+ readonly copied = signal(false);
|
|
|
|
|
+ readonly purchaseMode = signal(false);
|
|
|
|
|
+ readonly pendingOrder = signal<PendingOrder | null>(null);
|
|
|
|
|
+
|
|
|
|
|
+ private source = 'apig-pay';
|
|
|
|
|
+ private pollTimer: ReturnType<typeof setInterval> | null = null;
|
|
|
|
|
+ private quoteSequence = 0;
|
|
|
|
|
+ private hasExplicitSeats = false;
|
|
|
|
|
+
|
|
|
|
|
+ ngOnInit(): void {
|
|
|
|
|
+ const hashQuery = window.location.hash.includes('?') ? window.location.hash.split('?')[1] : '';
|
|
|
|
|
+ const query = new URLSearchParams(hashQuery || window.location.search);
|
|
|
|
|
+ this.hasExplicitSeats = query.has('seats');
|
|
|
|
|
+ this.seats.set(this.clampSeats(Number(query.get('seats')) || 1));
|
|
|
|
|
+ this.months.set(this.clampMonths(Number(query.get('months')) || 1));
|
|
|
|
|
+ this.purchaseMode.set(query.has('seats') || query.has('months'));
|
|
|
|
|
+ this.source = String(query.get('source') || 'apig-pay').slice(0, 64);
|
|
|
|
|
+ void this.initialize();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ ngOnDestroy(): void {
|
|
|
|
|
+ this.clearPolling();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ maxSeats(): number { return this.plans()?.maxSeats || 100; }
|
|
|
|
|
+ seatPresets(): number[] { return this.plans()?.seatPresets || [1, 3, 10, 20, 50]; }
|
|
|
|
|
+ monthPresets(): number[] { return this.plans()?.monthPresets || [1, 3, 6, 12]; }
|
|
|
|
|
+
|
|
|
|
|
+ formatMoney(value: number): string {
|
|
|
|
|
+ return Number(value || 0).toLocaleString('zh-CN', { minimumFractionDigits: Number.isInteger(value) ? 0 : 2, maximumFractionDigits: 2 });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ formatDate(value?: string | null): string {
|
|
|
|
|
+ if (!value) return '待开通';
|
|
|
|
|
+ const date = new Date(value);
|
|
|
|
|
+ return Number.isNaN(date.getTime()) ? '待确认' : date.toLocaleDateString('zh-CN');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ actionLabel(): string {
|
|
|
|
|
+ if (this.pendingOrder()) return `继续原订单 ¥${this.formatMoney(this.pendingOrder()!.amount)}`;
|
|
|
|
|
+ const amount = this.quote()?.amount || 0;
|
|
|
|
|
+ const balance = this.subscription()?.balance;
|
|
|
|
|
+ if (balance !== undefined && balance >= amount) return `使用余额开通 ¥${this.formatMoney(amount)}`;
|
|
|
|
|
+ return `微信支付 ¥${this.formatMoney(amount)}`;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ changeSeats(delta: number): void { this.setSeats(this.seats() + delta); }
|
|
|
|
|
+
|
|
|
|
|
+ setSeats(value: unknown): void {
|
|
|
|
|
+ this.seats.set(this.clampSeats(Number(value)));
|
|
|
|
|
+ this.purchaseMode.set(true);
|
|
|
|
|
+ void this.refreshQuoteAndStatus();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ setMonths(value: unknown): void {
|
|
|
|
|
+ this.months.set(this.clampMonths(Number(value)));
|
|
|
|
|
+ this.purchaseMode.set(true);
|
|
|
|
|
+ void this.refreshQuoteAndStatus();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async continuePurchase(): Promise<void> {
|
|
|
|
|
+ if (this.busy() || !this.quote() || this.quote()!.allowed === false) return;
|
|
|
|
|
+ this.errorMessage.set('');
|
|
|
|
|
+ const existing = this.pendingOrder() || this.readPendingOrder();
|
|
|
|
|
+ if (existing) {
|
|
|
|
|
+ await this.resumePendingOrder(existing);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ const balance = this.subscription()?.balance;
|
|
|
|
|
+ if (balance !== undefined && balance >= this.quote()!.amount) {
|
|
|
|
|
+ const key = this.createTradeNo('QWB');
|
|
|
|
|
+ const pending: PendingOrder = {
|
|
|
|
|
+ userId: this.userId,
|
|
|
|
|
+ tradeNo: key,
|
|
|
|
|
+ nonceStr: '',
|
|
|
|
|
+ paymentMethod: 'balance',
|
|
|
|
|
+ seats: this.seats(),
|
|
|
|
|
+ months: this.months(),
|
|
|
|
|
+ amount: this.quote()!.amount,
|
|
|
|
|
+ source: this.source
|
|
|
|
|
+ };
|
|
|
|
|
+ this.writePendingOrder(pending);
|
|
|
|
|
+ await this.activateSubscription(pending, false);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ await this.startPayment();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async resumeActivation(): Promise<void> {
|
|
|
|
|
+ const pending = this.pendingOrder() || this.readPendingOrder();
|
|
|
|
|
+ if (!pending) return;
|
|
|
|
|
+ await this.resumePendingOrder(pending);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ closePayment(): void {
|
|
|
|
|
+ this.clearPolling();
|
|
|
|
|
+ this.showPayment.set(false);
|
|
|
|
|
+ this.qrDataUrl.set('');
|
|
|
|
|
+ if (this.stage() === 'paying') this.stage.set('ready');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ canCancelPaymentForPlanChange(): boolean {
|
|
|
|
|
+ const pending = this.pendingOrder();
|
|
|
|
|
+ return Boolean(
|
|
|
|
|
+ pending?.paymentMethod === 'wechat' &&
|
|
|
|
|
+ (this.stage() === 'paying' || this.stage() === 'ready')
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ cancelPaymentForPlanChange(): void {
|
|
|
|
|
+ if (!this.canCancelPaymentForPlanChange()) return;
|
|
|
|
|
+ this.clearPolling();
|
|
|
|
|
+ this.showPayment.set(false);
|
|
|
|
|
+ this.qrDataUrl.set('');
|
|
|
|
|
+ this.paymentMessage.set('');
|
|
|
|
|
+ this.errorMessage.set('');
|
|
|
|
|
+ this.clearPendingOrder();
|
|
|
|
|
+ this.stage.set('ready');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async requestInstallPrompt(): Promise<void> {
|
|
|
|
|
+ if (this.busy()) return;
|
|
|
|
|
+ this.busy.set(true);
|
|
|
|
|
+ this.errorMessage.set('');
|
|
|
|
|
+ try {
|
|
|
|
|
+ const result = await this.authJson('/api/qiwei/install-prompt', 'POST', {});
|
|
|
|
|
+ if (result.code !== 200 || !result.data?.prompt) throw new Error(result.mess || '安装提示词生成失败');
|
|
|
|
|
+ this.prompt.set(result.data.prompt);
|
|
|
|
|
+ this.maskedPrompt.set(result.data.maskedPrompt || '专属授权已生成,完整内容仅在复制时出现。');
|
|
|
|
|
+ this.showPrompt.set(true);
|
|
|
|
|
+ this.stage.set('active');
|
|
|
|
|
+ } catch (error: any) {
|
|
|
|
|
+ this.errorMessage.set(error?.message || '安装提示词生成失败,请稍后重试。');
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ this.busy.set(false);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async requestTrialCode(): Promise<void> {
|
|
|
|
|
+ if (this.trialBusy()) return;
|
|
|
|
|
+ this.trialBusy.set(true);
|
|
|
|
|
+ this.trialMessage.set('');
|
|
|
|
|
+ try {
|
|
|
|
|
+ const result = await this.authJson('/api/qiwei/trial/request-code', 'POST', {});
|
|
|
|
|
+ if (result.code !== 200 || !result.data?.sent) throw new Error(result.mess || '验证码发送失败');
|
|
|
|
|
+ this.trialStatus.set({
|
|
|
|
|
+ ...(this.trialStatus() || {} as TrialStatus),
|
|
|
|
|
+ state: 'code_sent',
|
|
|
|
|
+ phone: result.data.maskedPhone || null,
|
|
|
|
|
+ });
|
|
|
|
|
+ this.trialMessage.set('验证码有效期 10 分钟,请在当前页面完成领取。');
|
|
|
|
|
+ } catch (error: any) {
|
|
|
|
|
+ this.trialMessage.set(error?.message || '验证码发送失败,请稍后重试。');
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ this.trialBusy.set(false);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async claimTrial(): Promise<void> {
|
|
|
|
|
+ if (this.trialBusy() || this.trialCode().length !== 6) return;
|
|
|
|
|
+ this.trialBusy.set(true);
|
|
|
|
|
+ this.trialMessage.set('');
|
|
|
|
|
+ try {
|
|
|
|
|
+ const result = await this.authJson('/api/qiwei/trial/claim', 'POST', { code: this.trialCode() });
|
|
|
|
|
+ if (result.code !== 200 || result.data?.state !== 'active') throw new Error(result.mess || '试用领取失败');
|
|
|
|
|
+ this.trialMessage.set(`试用已开通,有效期至 ${this.formatDate(result.data.expireAt)}。`);
|
|
|
|
|
+ this.trialCode.set('');
|
|
|
|
|
+ await this.loadTrialStatus();
|
|
|
|
|
+ await this.loadStatus();
|
|
|
|
|
+ this.stage.set('active');
|
|
|
|
|
+ await this.requestInstallPrompt();
|
|
|
|
|
+ } catch (error: any) {
|
|
|
|
|
+ this.trialMessage.set(error?.message || '试用领取失败,请稍后重试。');
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ this.trialBusy.set(false);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async copyPrompt(): Promise<void> {
|
|
|
|
|
+ if (!this.prompt()) return;
|
|
|
|
|
+ try {
|
|
|
|
|
+ await navigator.clipboard.writeText(this.prompt());
|
|
|
|
|
+ this.copied.set(true);
|
|
|
|
|
+ window.setTimeout(() => this.copied.set(false), 2200);
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ this.errorMessage.set('浏览器未允许复制,请在 HTTPS 页面中重试。');
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private async initialize(): Promise<void> {
|
|
|
|
|
+ this.stage.set('checking');
|
|
|
|
|
+ await this.loadPlans();
|
|
|
|
|
+ await this.loadStatus();
|
|
|
|
|
+ await this.loadTrialStatus();
|
|
|
|
|
+ const currentSeats = Number(this.subscription()?.seats || 0);
|
|
|
|
|
+ if (!this.hasExplicitSeats && this.subscription()?.subscribed && currentSeats > 0 && currentSeats !== this.seats()) {
|
|
|
|
|
+ this.seats.set(this.clampSeats(currentSeats));
|
|
|
|
|
+ await this.loadPlans();
|
|
|
|
|
+ await this.loadStatus();
|
|
|
|
|
+ }
|
|
|
|
|
+ const pending = this.readPendingOrder();
|
|
|
|
|
+ if (pending) {
|
|
|
|
|
+ this.pendingOrder.set(pending);
|
|
|
|
|
+ this.seats.set(pending.seats);
|
|
|
|
|
+ this.months.set(pending.months);
|
|
|
|
|
+ await this.loadPlans();
|
|
|
|
|
+ await this.loadStatus();
|
|
|
|
|
+ if (this.subscription()?.subscribed && this.subscription()?.lastIdempotencyKey === pending.tradeNo) {
|
|
|
|
|
+ this.clearPendingOrder();
|
|
|
|
|
+ await this.requestInstallPrompt();
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ await this.resumePendingOrder(pending, false);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ this.stage.set(this.subscription()?.subscribed ? 'active' : 'ready');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private async refreshQuoteAndStatus(): Promise<void> {
|
|
|
|
|
+ await this.loadPlans();
|
|
|
|
|
+ await this.loadStatus();
|
|
|
|
|
+ if (!this.subscription()?.subscribed) this.stage.set('ready');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private async loadPlans(): Promise<void> {
|
|
|
|
|
+ const sequence = ++this.quoteSequence;
|
|
|
|
|
+ try {
|
|
|
|
|
+ const url = `${API_BASE}/api/qiwei/product/plans?seats=${this.seats()}&months=${this.months()}`;
|
|
|
|
|
+ const response = await fetch(url, { cache: 'no-store' });
|
|
|
|
|
+ const result = await response.json().catch(() => ({}));
|
|
|
|
|
+ if (!response.ok || result.code !== 200 || !result.data?.quote) throw new Error(result.mess || '套餐报价加载失败');
|
|
|
|
|
+ if (sequence !== this.quoteSequence) return;
|
|
|
|
|
+ this.plans.set(result.data);
|
|
|
|
|
+ this.quote.set(result.data.quote);
|
|
|
|
|
+ } catch (error: any) {
|
|
|
|
|
+ this.errorMessage.set(error?.message || '套餐报价加载失败,请稍后重试。');
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private async loadStatus(): Promise<void> {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const result = await this.authJson(`/api/qiwei/subscribe/status?seats=${this.seats()}&months=${this.months()}`, 'GET');
|
|
|
|
|
+ if (result.code !== 200) throw new Error(result.mess || '订阅状态查询失败');
|
|
|
|
|
+ this.subscription.set(result.data);
|
|
|
|
|
+ if (result.data?.quote) this.quote.set(result.data.quote);
|
|
|
|
|
+ if (result.data?.subscribed && !this.purchaseMode()) this.stage.set('active');
|
|
|
|
|
+ } catch (error: any) {
|
|
|
|
|
+ this.errorMessage.set(error?.message || '订阅状态查询失败,请稍后重试。');
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private async loadTrialStatus(): Promise<void> {
|
|
|
|
|
+ try {
|
|
|
|
|
+ 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);
|
|
|
|
|
+ } catch (error: any) {
|
|
|
|
|
+ this.trialStatus.set(null);
|
|
|
|
|
+ if (!this.errorMessage()) this.errorMessage.set(error?.message || '试用状态查询失败,请稍后重试。');
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private pendingMatchesQuote(pending: PendingOrder): boolean {
|
|
|
|
|
+ const quote = this.quote();
|
|
|
|
|
+ return Boolean(
|
|
|
|
|
+ quote?.allowed &&
|
|
|
|
|
+ quote.seats === pending.seats &&
|
|
|
|
|
+ quote.months === pending.months &&
|
|
|
|
|
+ Math.abs(quote.amount - pending.amount) < 0.001
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private async resumePendingOrder(pending: PendingOrder, reloadQuote = true): Promise<void> {
|
|
|
|
|
+ this.pendingOrder.set(pending);
|
|
|
|
|
+ this.seats.set(pending.seats);
|
|
|
|
|
+ this.months.set(pending.months);
|
|
|
|
|
+ this.purchaseMode.set(true);
|
|
|
|
|
+ this.errorMessage.set('');
|
|
|
|
|
+ if (reloadQuote) {
|
|
|
|
|
+ await this.loadPlans();
|
|
|
|
|
+ await this.loadStatus();
|
|
|
|
|
+ }
|
|
|
|
|
+ if (!this.pendingMatchesQuote(pending)) {
|
|
|
|
|
+ this.clearPolling();
|
|
|
|
|
+ this.showPayment.set(false);
|
|
|
|
|
+ this.stage.set('ready');
|
|
|
|
|
+ this.errorMessage.set(
|
|
|
|
|
+ `原订单金额 ¥${this.formatMoney(pending.amount)} 与当前服务端报价 ¥${this.formatMoney(this.quote()?.amount || 0)} 不一致,已停止自动激活。请联系客服处理原订单后再重建。`
|
|
|
|
|
+ );
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (pending.paymentMethod === 'balance') {
|
|
|
|
|
+ await this.waitForBalanceAndActivate(pending);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ this.busy.set(true);
|
|
|
|
|
+ this.showPayment.set(true);
|
|
|
|
|
+ this.stage.set('paying');
|
|
|
|
|
+ this.paymentMessage.set('已恢复未完成订单,将继续核对同一笔支付。');
|
|
|
|
|
+ try {
|
|
|
|
|
+ if (!pending.codeUrl) {
|
|
|
|
|
+ const payResult = await this.runCloudFunction('pay_code2', {
|
|
|
|
|
+ company: PAY_COMPANY,
|
|
|
|
|
+ out_trade_no: pending.tradeNo,
|
|
|
|
|
+ total_fee: pending.amount,
|
|
|
|
|
+ body: `企微助手 ${pending.seats}席位 ${pending.months}个月`
|
|
|
|
|
+ });
|
|
|
|
|
+ pending.nonceStr = String(payResult?.nonce_str || pending.nonceStr || '');
|
|
|
|
|
+ pending.codeUrl = String(Array.isArray(payResult?.code_url) ? payResult.code_url[0] : payResult?.code_url || '');
|
|
|
|
|
+ if (!pending.codeUrl) throw new Error('原订单支付码恢复失败,请稍后重试。');
|
|
|
|
|
+ this.writePendingOrder(pending);
|
|
|
|
|
+ }
|
|
|
|
|
+ this.qrDataUrl.set(await QRCode.toDataURL(pending.codeUrl, { width: 228, margin: 1, errorCorrectionLevel: 'M' }));
|
|
|
|
|
+ this.startPolling(pending);
|
|
|
|
|
+ } catch (error: any) {
|
|
|
|
|
+ this.showPayment.set(false);
|
|
|
|
|
+ this.stage.set('ready');
|
|
|
|
|
+ this.errorMessage.set(error?.message || '原订单恢复失败,请稍后重试。');
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ this.busy.set(false);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private async startPayment(): Promise<void> {
|
|
|
|
|
+ if (!this.quote() || this.busy()) return;
|
|
|
|
|
+ this.busy.set(true);
|
|
|
|
|
+ this.errorMessage.set('');
|
|
|
|
|
+ this.paymentMessage.set('');
|
|
|
|
|
+ this.qrDataUrl.set('');
|
|
|
|
|
+ try {
|
|
|
|
|
+ const pending: PendingOrder = {
|
|
|
|
|
+ userId: this.userId,
|
|
|
|
|
+ tradeNo: this.createTradeNo('QW'),
|
|
|
|
|
+ nonceStr: '',
|
|
|
|
|
+ paymentMethod: 'wechat',
|
|
|
|
|
+ seats: this.seats(),
|
|
|
|
|
+ months: this.months(),
|
|
|
|
|
+ amount: this.quote()!.amount,
|
|
|
|
|
+ source: this.source
|
|
|
|
|
+ };
|
|
|
|
|
+ const accountId = await this.ensureAccount();
|
|
|
|
|
+ await this.createAccountLog(accountId, pending);
|
|
|
|
|
+ const payResult = await this.runCloudFunction('pay_code2', {
|
|
|
|
|
+ company: PAY_COMPANY,
|
|
|
|
|
+ out_trade_no: pending.tradeNo,
|
|
|
|
|
+ total_fee: pending.amount,
|
|
|
|
|
+ body: `企微助手 ${pending.seats}席位 ${pending.months}个月`
|
|
|
|
|
+ });
|
|
|
|
|
+ const codeUrl = Array.isArray(payResult?.code_url) ? payResult.code_url[0] : payResult?.code_url;
|
|
|
|
|
+ if (!codeUrl) throw new Error('支付码生成失败,请稍后重试。');
|
|
|
|
|
+ pending.nonceStr = String(payResult.nonce_str || '');
|
|
|
|
|
+ pending.codeUrl = String(codeUrl);
|
|
|
|
|
+ this.writePendingOrder(pending);
|
|
|
|
|
+ this.qrDataUrl.set(await QRCode.toDataURL(codeUrl, { width: 228, margin: 1, errorCorrectionLevel: 'M' }));
|
|
|
|
|
+ this.stage.set('paying');
|
|
|
|
|
+ this.showPayment.set(true);
|
|
|
|
|
+ this.startPolling(pending);
|
|
|
|
|
+ } catch (error: any) {
|
|
|
|
|
+ this.stage.set('ready');
|
|
|
|
|
+ this.errorMessage.set(error?.message || '支付发起失败,请稍后重试。');
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ this.busy.set(false);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private startPolling(pending: PendingOrder): void {
|
|
|
|
|
+ this.clearPolling();
|
|
|
|
|
+ const check = async () => {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const result = await this.runCloudFunction('order_status2', {
|
|
|
|
|
+ out_trade_no: pending.tradeNo,
|
|
|
|
|
+ nonce_str: pending.nonceStr,
|
|
|
|
|
+ company: PAY_COMPANY
|
|
|
|
|
+ });
|
|
|
|
|
+ if (result?.status?.[0] === 'SUCCESS') {
|
|
|
|
|
+ this.clearPolling();
|
|
|
|
|
+ this.stage.set('crediting');
|
|
|
|
|
+ await this.waitForBalanceAndActivate(pending);
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ // Transient payment provider errors are retried with the same order.
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+ void check();
|
|
|
|
|
+ this.pollTimer = setInterval(() => void check(), 3000);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private async waitForBalanceAndActivate(pending: PendingOrder): Promise<void> {
|
|
|
|
|
+ if (this.busy()) return;
|
|
|
|
|
+ this.busy.set(true);
|
|
|
|
|
+ this.showPayment.set(true);
|
|
|
|
|
+ this.paymentMessage.set('');
|
|
|
|
|
+ try {
|
|
|
|
|
+ for (let attempt = 0; attempt < 20; attempt++) {
|
|
|
|
|
+ if (attempt > 0) await new Promise(resolve => window.setTimeout(resolve, 1500));
|
|
|
|
|
+ await this.loadStatus();
|
|
|
|
|
+ if (!this.pendingMatchesQuote(pending)) {
|
|
|
|
|
+ this.stage.set('ready');
|
|
|
|
|
+ this.paymentMessage.set('原订单金额与当前服务端报价不一致,已停止自动激活,请联系客服处理。');
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (this.subscription()?.subscribed && this.subscription()?.lastIdempotencyKey === pending.tradeNo) {
|
|
|
|
|
+ this.clearPendingOrder();
|
|
|
|
|
+ this.showPayment.set(false);
|
|
|
|
|
+ await this.requestInstallPromptAfterBusy();
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ this.stage.set('activating');
|
|
|
|
|
+ const activated = await this.activateSubscription(pending, true);
|
|
|
|
|
+ if (activated) return;
|
|
|
|
|
+ this.stage.set('crediting');
|
|
|
|
|
+ }
|
|
|
|
|
+ this.stage.set('crediting');
|
|
|
|
|
+ this.paymentMessage.set('支付已确认,到账或激活仍在处理中。可稍后刷新页面继续原订单。');
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ this.busy.set(false);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private async activateSubscription(pending: PendingOrder, preserveBusy: boolean): Promise<boolean> {
|
|
|
|
|
+ if (!preserveBusy) this.busy.set(true);
|
|
|
|
|
+ this.errorMessage.set('');
|
|
|
|
|
+ try {
|
|
|
|
|
+ this.stage.set('activating');
|
|
|
|
|
+ const result = await this.authJson('/api/qiwei/subscribe', 'POST', {
|
|
|
|
|
+ seats: pending.seats,
|
|
|
|
|
+ months: pending.months,
|
|
|
|
|
+ expectedAmount: pending.amount,
|
|
|
|
|
+ idempotencyKey: pending.tradeNo
|
|
|
|
|
+ }, pending.tradeNo);
|
|
|
|
|
+ if (result.code !== 200 || result.data?.state !== 'active') throw new Error(result.mess || '订阅激活失败');
|
|
|
|
|
+ await this.loadStatus();
|
|
|
|
|
+ this.clearPendingOrder();
|
|
|
|
|
+ this.showPayment.set(false);
|
|
|
|
|
+ this.stage.set('active');
|
|
|
|
|
+ await this.requestInstallPromptAfterBusy();
|
|
|
|
|
+ return true;
|
|
|
|
|
+ } catch (error: any) {
|
|
|
|
|
+ if (Number(error?.status) !== 402) this.paymentMessage.set(error?.message || '订阅激活失败,请刷新重试。');
|
|
|
|
|
+ return false;
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ if (!preserveBusy) this.busy.set(false);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private async requestInstallPromptAfterBusy(): Promise<void> {
|
|
|
|
|
+ const wasBusy = this.busy();
|
|
|
|
|
+ this.busy.set(false);
|
|
|
|
|
+ await this.requestInstallPrompt();
|
|
|
|
|
+ if (wasBusy && !this.showPrompt()) this.busy.set(true);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private async ensureAccount(): Promise<string> {
|
|
|
|
|
+ const pointer = { __type: 'Pointer', className: '_User', objectId: this.userId };
|
|
|
|
|
+ const where = encodeURIComponent(JSON.stringify({ user: pointer }));
|
|
|
|
|
+ const response = await fetch(`${API_BASE}/parse/classes/Account?where=${where}&limit=1&keys=objectId`, { headers: this.parseHeaders() });
|
|
|
|
|
+ const result = await response.json();
|
|
|
|
|
+ const existingId = result.results?.[0]?.objectId;
|
|
|
|
|
+ if (existingId) return existingId;
|
|
|
|
|
+ const created = await this.parseRequest('/parse/classes/Account', 'POST', { user: pointer, balance: 0 });
|
|
|
|
|
+ if (!created.objectId) throw new Error('账户初始化失败,请稍后重试。');
|
|
|
|
|
+ return created.objectId;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private async createAccountLog(accountId: string, pending: PendingOrder): Promise<void> {
|
|
|
|
|
+ const pointer = (className: string, objectId: string) => ({ __type: 'Pointer', className, objectId });
|
|
|
|
|
+ const where = encodeURIComponent(JSON.stringify({
|
|
|
|
|
+ company: pointer('Company', PAY_COMPANY),
|
|
|
|
|
+ user: pointer('_User', this.userId),
|
|
|
|
|
+ orderNumber: pending.tradeNo
|
|
|
|
|
+ }));
|
|
|
|
|
+ const existing = await fetch(`${API_BASE}/parse/classes/AccountLog?where=${where}&limit=1&keys=objectId`, { headers: this.parseHeaders() }).then(response => response.json());
|
|
|
|
|
+ if (existing.results?.[0]?.objectId) return;
|
|
|
|
|
+ await this.parseRequest('/parse/classes/AccountLog', 'POST', {
|
|
|
|
|
+ targetAccount: pointer('Account', accountId),
|
|
|
|
|
+ user: pointer('_User', this.userId),
|
|
|
|
|
+ company: pointer('Company', PAY_COMPANY),
|
|
|
|
|
+ orderType: 'qiwei-subscription-wxpay',
|
|
|
|
|
+ assetType: 'balance',
|
|
|
|
|
+ isVerified: false,
|
|
|
|
|
+ orderId: pending.tradeNo,
|
|
|
|
|
+ orderNumber: pending.tradeNo,
|
|
|
|
|
+ targetName: 'system',
|
|
|
|
|
+ payType: 'wxpay',
|
|
|
|
|
+ assetCount: pending.amount,
|
|
|
|
|
+ detail: {
|
|
|
|
|
+ product: 'fmode-qiwei-subscription',
|
|
|
|
|
+ seats: pending.seats,
|
|
|
|
|
+ months: pending.months,
|
|
|
|
|
+ amount: pending.amount,
|
|
|
|
|
+ discountRate: this.quote()?.discountRate,
|
|
|
|
|
+ source: pending.source
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private async runCloudFunction(name: string, body: Record<string, unknown>): Promise<any> {
|
|
|
|
|
+ const result = await this.postJson(`${API_BASE}/parse/functions/${name}`, { _ApplicationId: APP_ID, ...body }, true);
|
|
|
|
|
+ if (result.error) throw new Error(result.error);
|
|
|
|
|
+ return result.result || result;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private async authJson(path: string, method: 'GET' | 'POST', body?: unknown, idempotencyKey?: string): Promise<any> {
|
|
|
|
|
+ const headers: Record<string, string> = {
|
|
|
|
|
+ 'Content-Type': 'application/json',
|
|
|
|
|
+ 'Authorization': `Bearer ${this.sessionToken}`
|
|
|
|
|
+ };
|
|
|
|
|
+ if (idempotencyKey) headers['Idempotency-Key'] = idempotencyKey;
|
|
|
|
|
+ const response = await fetch(`${API_BASE}${path}`, {
|
|
|
|
|
+ method,
|
|
|
|
|
+ headers,
|
|
|
|
|
+ cache: 'no-store',
|
|
|
|
|
+ body: method === 'GET' ? undefined : JSON.stringify(body || {})
|
|
|
|
|
+ });
|
|
|
|
|
+ const result = await response.json().catch(() => ({}));
|
|
|
|
|
+ if (!response.ok || (result.code && result.code !== 200)) {
|
|
|
|
|
+ const error = new Error(result.mess || result.message || '请求失败') as Error & { status?: number };
|
|
|
|
|
+ error.status = response.status || result.code;
|
|
|
|
|
+ throw error;
|
|
|
|
|
+ }
|
|
|
|
|
+ return result;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private async parseRequest(path: string, method: string, body?: unknown): Promise<any> {
|
|
|
|
|
+ const response = await fetch(`${API_BASE}${path}`, {
|
|
|
|
|
+ method,
|
|
|
|
|
+ headers: this.parseHeaders(),
|
|
|
|
|
+ body: body === undefined ? undefined : JSON.stringify(body)
|
|
|
|
|
+ });
|
|
|
|
|
+ const result = await response.json();
|
|
|
|
|
+ if (!response.ok || result.error) throw new Error(result.error || '请求失败');
|
|
|
|
|
+ return result;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private parseHeaders(): Record<string, string> {
|
|
|
|
|
+ return {
|
|
|
|
|
+ 'Content-Type': 'application/json',
|
|
|
|
|
+ 'X-Parse-Application-Id': APP_ID,
|
|
|
|
|
+ 'X-Parse-Session-Token': this.sessionToken
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private async postJson(url: string, body: unknown, parseAuth = false): Promise<any> {
|
|
|
|
|
+ const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
|
|
|
|
+ if (parseAuth) Object.assign(headers, this.parseHeaders());
|
|
|
|
|
+ const response = await fetch(url, { method: 'POST', headers, cache: 'no-store', body: JSON.stringify(body) });
|
|
|
|
|
+ const result = await response.json().catch(() => ({}));
|
|
|
|
|
+ if (!response.ok && !result.code) throw new Error(result.error || result.message || '请求失败');
|
|
|
|
|
+ return result;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private createTradeNo(prefix: string): string {
|
|
|
|
|
+ const now = new Date();
|
|
|
|
|
+ const stamp = [now.getFullYear(), String(now.getMonth() + 1).padStart(2, '0'), String(now.getDate()).padStart(2, '0'), String(now.getHours()).padStart(2, '0'), String(now.getMinutes()).padStart(2, '0'), String(now.getSeconds()).padStart(2, '0'), String(now.getMilliseconds()).padStart(3, '0')].join('');
|
|
|
|
|
+ return `${prefix}${this.userId}${stamp}`;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private clampSeats(value: number): number {
|
|
|
|
|
+ return Math.min(this.maxSeats(), Math.max(1, Number.isSafeInteger(value) ? value : 1));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private clampMonths(value: number): number {
|
|
|
|
|
+ return Math.min(12, Math.max(1, Number.isSafeInteger(value) ? value : 1));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private pendingOrderKey(): string {
|
|
|
|
|
+ return `${PENDING_ORDER_KEY}:${this.userId}`;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private writePendingOrder(order: PendingOrder): void {
|
|
|
|
|
+ const scopedOrder = { ...order, userId: this.userId };
|
|
|
|
|
+ this.pendingOrder.set(scopedOrder);
|
|
|
|
|
+ sessionStorage.setItem(this.pendingOrderKey(), JSON.stringify(scopedOrder));
|
|
|
|
|
+ sessionStorage.removeItem(PENDING_ORDER_KEY);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private readPendingOrder(): PendingOrder | null {
|
|
|
|
|
+ for (const key of [this.pendingOrderKey(), PENDING_ORDER_KEY]) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const raw = sessionStorage.getItem(key);
|
|
|
|
|
+ if (!raw) continue;
|
|
|
|
|
+ const value = JSON.parse(raw) as Partial<PendingOrder>;
|
|
|
|
|
+ const valid =
|
|
|
|
|
+ value.userId === this.userId &&
|
|
|
|
|
+ typeof value.tradeNo === 'string' && value.tradeNo.length > 0 && value.tradeNo.length <= 160 &&
|
|
|
|
|
+ typeof value.nonceStr === 'string' &&
|
|
|
|
|
+ (value.codeUrl === undefined || typeof value.codeUrl === 'string') &&
|
|
|
|
|
+ (value.paymentMethod === 'balance' || value.paymentMethod === 'wechat') &&
|
|
|
|
|
+ Number.isSafeInteger(value.seats) && Number(value.seats) >= 1 && Number(value.seats) <= this.maxSeats() &&
|
|
|
|
|
+ Number.isSafeInteger(value.months) && Number(value.months) >= 1 && Number(value.months) <= 12 &&
|
|
|
|
|
+ Number.isFinite(value.amount) && Number(value.amount) > 0 &&
|
|
|
|
|
+ typeof value.source === 'string';
|
|
|
|
|
+ if (valid) return value as PendingOrder;
|
|
|
|
|
+ sessionStorage.removeItem(key);
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ sessionStorage.removeItem(key);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private clearPendingOrder(): void {
|
|
|
|
|
+ this.pendingOrder.set(null);
|
|
|
|
|
+ sessionStorage.removeItem(this.pendingOrderKey());
|
|
|
|
|
+ sessionStorage.removeItem(PENDING_ORDER_KEY);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private clearPolling(): void {
|
|
|
|
|
+ if (this.pollTimer) {
|
|
|
|
|
+ clearInterval(this.pollTimer);
|
|
|
|
|
+ this.pollTimer = null;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|