Przeglądaj źródła

feat: add qiwei trial install flow

gangvy 1 miesiąc temu
rodzic
commit
4ff5a14e17

+ 1 - 2
.gitignore

@@ -40,5 +40,4 @@ testem.log
 # System files
 .DS_Store
 Thumbs.db
-
-deploy.ps1
+deploy.ps1

+ 25 - 4
src/app/app.html

@@ -1,8 +1,29 @@
 <!-- Login Modal -->
-<app-login-modal [visible]="needLogin && !isLauncherPage" (loginSuccess)="onLoginSuccess($event)"></app-login-modal>
+<app-login-modal
+  [visible]="needLogin && !isLauncherPage"
+  [skillInstall]="isSkillInstallPage || isQiweiSkillPage"
+  [qiweiSkill]="isQiweiSkillPage"
+  (loginSuccess)="onLoginSuccess($event)">
+</app-login-modal>
+
+<app-skill-install
+  *ngIf="isSkillInstallPage && !needLogin && sessionToken && userId"
+  [sessionToken]="sessionToken"
+  [userId]="userId"
+  [displayName]="loggedInUser"
+  (logout)="logout()">
+</app-skill-install>
+
+<app-qiwei-skill-install
+  *ngIf="isQiweiSkillPage && !needLogin && sessionToken && userId"
+  [sessionToken]="sessionToken"
+  [userId]="userId"
+  [displayName]="loggedInUser"
+  (logout)="logout()">
+</app-qiwei-skill-install>
 
 <!-- Header -->
-<div class="page-header">
+<div class="page-header" *ngIf="!isSkillInstallPage && !isQiweiSkillPage">
   <div class="logo" *ngIf="!isLauncherPage">BRAIN<span>HACK</span></div>
   <div class="logo" *ngIf="isLauncherPage">FMODE<span>STUDIO</span></div>
   <div class="subtitle">{{pageSubtitle}}</div>
@@ -105,7 +126,7 @@
 </div>
 
 <!-- Main -->
-<div class="main-container" *ngIf="!isLauncherPage">
+<div class="main-container" *ngIf="!isLauncherPage && !isSkillInstallPage && !isQiweiSkillPage">
 
   <!-- Workshop Entry -->
   <div class="workshop-entry neu-raised" *ngIf="!isWorkshopMode && !showSuccess && !needLogin">
@@ -496,7 +517,7 @@
 </div>
 
 <!-- QR Modal -->
-<div class="modal-overlay" [class.show]="showQrModal">
+<div class="modal-overlay" [class.show]="showQrModal" *ngIf="!isSkillInstallPage && !isQiweiSkillPage">
   <div class="modal-box neu-raised">
     <button class="modal-close" (click)="cancelPayment()">&times;</button>
     <div class="modal-title">微信扫码支付</div>

+ 19 - 5
src/app/app.ts

@@ -1,6 +1,8 @@
 import { Component, OnInit, ViewChild, ElementRef, ChangeDetectorRef } from '@angular/core';
 import { CommonModule } from '@angular/common';
 import { LoginModalComponent } from './login-modal.component';
+import { SkillInstallComponent } from './skill-install.component';
+import { QiweiSkillInstallComponent } from './qiwei-skill-install.component';
 
 import QRCode from 'qrcode';
 
@@ -284,7 +286,7 @@ const LAUNCHER_URL_LIST = 'https://repos.fmode.cn/x/launcher/url.txt';
 
 @Component({
   selector: 'app-root',
-  imports: [CommonModule, LoginModalComponent],
+  imports: [CommonModule, LoginModalComponent, SkillInstallComponent, QiweiSkillInstallComponent],
   templateUrl: './app.html',
   styleUrl: './app.scss'
 })
@@ -303,6 +305,8 @@ export class App implements OnInit {
   workshopPortal = false;
   workshopPreparing = false;
   isLauncherPage = false;
+  isSkillInstallPage = false;
+  isQiweiSkillPage = false;
 
   // Login state
   needLogin = false;
@@ -447,6 +451,16 @@ export class App implements OnInit {
 
   ngOnInit(): void {
     this.isLauncherPage = window.location.pathname.includes('/launcher') || window.location.hash === '#/launcher';
+    this.isSkillInstallPage = window.location.pathname.includes('/skill-install') || window.location.hash.startsWith('#/skill-install');
+    this.isQiweiSkillPage = window.location.pathname.includes('/qiwei-skill') ||
+      window.location.pathname.includes('/qiwei-trial') ||
+      window.location.hash.startsWith('#/qiwei-skill') ||
+      window.location.hash.startsWith('#/qiwei-trial');
+    if (this.isSkillInstallPage) {
+      document.title = '开启 VOC 数据洞察 | Fmode';
+    } else if (this.isQiweiSkillPage) {
+      document.title = '领取 7 天企微数字员工试用 | Fmode';
+    }
     if (this.isLauncherPage) {
       this.loadLauncherDownloads();
       return;
@@ -472,7 +486,7 @@ export class App implements OnInit {
     // 1. URL 已提供足够参数 → 直接加载
     if (this.authId || (this.userId && this.apigId)) {
       console.log('[AUTH] URL 参数充足,直接加载');
-      this.loadApig();
+      if (!this.isSkillInstallPage && !this.isQiweiSkillPage) this.loadApig();
       return;
     }
 
@@ -592,7 +606,7 @@ export class App implements OnInit {
       localStorage.setItem('apig_display_name', this.loggedInUser);
 
       this.cdr.detectChanges();
-      this.loadApig();
+      if (!this.isSkillInstallPage && !this.isQiweiSkillPage) this.loadApig();
     } catch (e: any) {
       console.error('[AUTH] become 请求异常:', e);
       this.errorMsg = 'Token 登录失败: ' + e.message;
@@ -635,11 +649,11 @@ export class App implements OnInit {
       this.companyId = data.company?.objectId || '';
       console.log('[AUTH] token 验证通过, user:', data.objectId, data.username || '', 'company:', this.companyId || '(用户无 Company,仅用 user 指针扣费)');
       this.cdr.detectChanges();
-      this.loadApig();
+      if (!this.isSkillInstallPage && !this.isQiweiSkillPage) this.loadApig();
     } catch (e) {
       console.warn('[AUTH] 验证 token 失败:', e);
       // 网络错误时仍然尝试加载(可能离线缓存能用)
-      this.loadApig();
+      if (!this.isSkillInstallPage && !this.isQiweiSkillPage) this.loadApig();
     }
   }
 

+ 34 - 7
src/app/login-modal.component.ts

@@ -20,7 +20,19 @@ const APP_ID = 'ncloudmaster';
             </svg>
           </div>
           <h1 class="logo-title">BRAIN<span>HACK</span></h1>
-          <p class="logo-subtitle">登录以使用 API 充值服务</p>
+          <p class="logo-subtitle">{{ qiweiSkill ? '登录后领取 7 天企微数字员工试用' : skillInstall ? '登录后开启 VOC 数据洞察' : '登录后继续' }}</p>
+        </div>
+
+        <div
+          class="purchase-summary"
+          *ngIf="skillInstall"
+          [attr.aria-label]="qiweiSkill ? '企业微信智能助手价格' : 'VOC 数据洞察体验价格'"
+        >
+          <div>
+            <span>{{ qiweiSkill ? '企业微信智能助手' : 'VOC 数据洞察体验' }}</span>
+            <p>{{ qiweiSkill ? '手机号登录后接收验证码,领取 1 个试用席位' : '支付金额全部成为可用数据额度' }}</p>
+          </div>
+          <strong>{{ qiweiSkill ? '7 天免费' : '¥29.9' }}</strong>
         </div>
 
         <!-- 登录表单 -->
@@ -83,7 +95,7 @@ const APP_ID = 'ncloudmaster';
           <button type="submit" class="btn-submit" [disabled]="!canSubmit() || isLoading()">
             <span *ngIf="isLoading()" class="loading-spinner"></span>
             <span *ngIf="isLoading()">登录中...</span>
-            <span *ngIf="!isLoading()">🔐 立即登录</span>
+            <span *ngIf="!isLoading()">{{ qiweiSkill ? '登录并领取试用' : skillInstall ? '登录并继续开通' : '立即登录' }}</span>
           </button>
         </form>
 
@@ -100,6 +112,9 @@ const APP_ID = 'ncloudmaster';
       display: flex;
       align-items: center;
       justify-content: center;
+      padding: 24px 0;
+      box-sizing: border-box;
+      overflow-y: auto;
       z-index: 200;
       animation: fadeIn 0.3s ease-out;
     }
@@ -144,6 +159,17 @@ const APP_ID = 'ncloudmaster';
     .logo-title span { color: #00D4FF; }
     .logo-subtitle { margin: 0; font-size: 14px; color: #888; }
 
+    .purchase-summary {
+      display: flex; align-items: center; justify-content: space-between; gap: 20px;
+      padding: 16px 18px;
+      background: rgba(0,212,255,0.08);
+      border: 1px solid rgba(0,212,255,0.22);
+      border-radius: 12px;
+    }
+    .purchase-summary span { color: #fff; font-size: 14px; font-weight: 800; }
+    .purchase-summary p { margin: 5px 0 0; color: rgba(224,224,224,0.58); font-size: 12px; }
+    .purchase-summary strong { color: #00D4FF; font-size: 26px; white-space: nowrap; }
+
     .login-form { margin-top: 28px; }
     .form-group { margin-bottom: 20px; }
     .form-group label {
@@ -231,12 +257,17 @@ const APP_ID = 'ncloudmaster';
     }
 
     @media (max-width: 480px) {
-      .login-modal { padding: 28px 20px; }
+      .login-overlay { align-items: flex-start; padding: 16px 0; }
+      .login-modal { padding: 28px 20px; margin: auto 0; }
+      .logo-section { margin-bottom: 22px; }
+      .purchase-summary { padding: 14px; }
     }
   `]
 })
 export class LoginModalComponent {
   @Input() visible = false;
+  @Input() skillInstall = false;
+  @Input() qiweiSkill = false;
   @Output() loginSuccess = new EventEmitter<{ userId: string; sessionToken: string; displayName: string }>();
   phone = '';
   code = '';
@@ -310,8 +341,6 @@ export class LoginModalComponent {
         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.mess || result.message || result.error || '验证码错误或已过期');
       }
@@ -327,8 +356,6 @@ export class LoginModalComponent {
         }
       });
       const userData = await meResp.json();
-      console.log('[Login] 用户信息:', userData);
-
       if (!userData.objectId) {
         throw new Error('登录成功但无法获取用户信息');
       }

+ 973 - 0
src/app/qiwei-skill-install.component.ts

@@ -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;
+    }
+  }
+}

+ 726 - 0
src/app/skill-install.component.ts

@@ -0,0 +1,726 @@
+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 AUTO_NEWAPI_RECHARGE_CALLBACK_FUN_ID = 'WbyPrzMI3A';
+const TRIAL_PRICE = 29.9;
+const PAYMENT_TEST_USER_ID = 'DpPsnlBm0K';
+const PAYMENT_TEST_PRICE = 0.01;
+const PENDING_ORDER_KEY = 'voc_skill_install_pending_order';
+
+type InstallChannel = 'claude-code' | 'codex' | 'workbuddy' | 'fmode-studio';
+type InstallScope = 'project' | 'global';
+type FlowStage = 'checking' | 'ready' | 'paying' | 'crediting' | 'install';
+
+interface Eligibility {
+  hasToken: boolean;
+  newApiBalance: number;
+  canInstallDirectly: boolean;
+  needsPayment: boolean;
+  trialPrice: number;
+  paymentTestMode?: boolean;
+}
+
+interface PendingOrder {
+  tradeNo: string;
+  nonceStr: string;
+  channel: InstallChannel;
+  scope: InstallScope;
+}
+
+const CHANNELS: Array<{ id: InstallChannel; name: string; short: string }> = [
+  { id: 'claude-code', name: 'Claude Code', short: 'CC' },
+  { id: 'codex', name: 'Codex', short: 'CX' },
+  { id: 'workbuddy', name: 'WorkBuddy', short: 'WB' },
+  { id: 'fmode-studio', name: 'FmodeStudio', short: 'FM' }
+];
+
+@Component({
+  selector: 'app-skill-install',
+  standalone: true,
+  imports: [CommonModule],
+  changeDetection: ChangeDetectionStrategy.OnPush,
+  template: `
+    <div class="skill-page">
+      <header class="skill-header">
+        <a class="skill-brand" href="https://voc.market/zh/skill-install/" aria-label="VOC Market">
+          <span class="brand-mark">V</span>
+          <span>VOC<span class="brand-accent">.market</span></span>
+        </a>
+        <div class="skill-user">
+          <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="skill-main">
+        <section class="skill-config" aria-labelledby="skill-title">
+          <h1 id="skill-title">选择 AI,开启 VOC 数据洞察</h1>
+          <p class="skill-lead">开通后,页面会立即准备与你所选工具匹配的安装内容。</p>
+
+          <div class="field-group">
+            <div class="field-label">AI 工具</div>
+            <div class="channel-grid" role="radiogroup" aria-label="选择 AI 工具">
+              @for (item of channels; track item.id) {
+                <button
+                  type="button"
+                  class="channel-option"
+                  role="radio"
+                  [class.selected]="channel() === item.id"
+                  [attr.aria-checked]="channel() === item.id"
+                  (click)="selectChannel(item.id)">
+                  <span class="channel-symbol">{{ item.short }}</span>
+                  <span>{{ item.name }}</span>
+                  <svg class="channel-check" viewBox="0 0 24 24" aria-hidden="true"><path d="m5 12 4 4L19 6"/></svg>
+                </button>
+              }
+            </div>
+          </div>
+
+          @if (channel() === 'codex') {
+            <div class="field-group scope-group">
+              <div class="field-label">安装范围</div>
+              <div class="scope-control" role="radiogroup" aria-label="Codex 安装范围">
+                <button type="button" role="radio" [class.selected]="scope() === 'project'" [attr.aria-checked]="scope() === 'project'" (click)="selectScope('project')">当前项目</button>
+                <button type="button" role="radio" [class.selected]="scope() === 'global'" [attr.aria-checked]="scope() === 'global'" (click)="selectScope('global')">用户全局</button>
+              </div>
+            </div>
+          }
+
+          <ol class="flow-line" aria-label="开通流程">
+            <li class="active"><span>1</span>选择工具</li>
+            <li [class.active]="stage() !== 'ready' && stage() !== 'checking'"><span>2</span>开通体验</li>
+            <li [class.active]="stage() === 'install'"><span>3</span>复制给 AI</li>
+          </ol>
+        </section>
+
+        <aside class="checkout-panel" aria-live="polite">
+          <div class="checkout-top">
+            <div>
+              <div class="checkout-label">{{ paymentTestMode() ? 'VOC 数据体验(测试价)' : 'VOC 数据洞察体验' }}</div>
+              <div class="checkout-price"><span>¥</span>{{ trialPrice() | number:'1.0-2' }}</div>
+            </div>
+            <span class="balance-tag">{{ paymentTestMode() ? '仅限指定测试账号' : '支付金额全部成为可用数据额度' }}</span>
+          </div>
+
+          <div class="checkout-rule"></div>
+
+          @if (stage() === 'checking') {
+            <div class="status-row"><span class="spinner"></span>正在检查当前账号...</div>
+          } @else {
+            <div class="balance-row">
+              <span>当前可用数据额度</span>
+              <strong>¥{{ eligibility()?.newApiBalance || 0 | number:'1.2-2' }}</strong>
+            </div>
+
+            @if (eligibility()?.canInstallDirectly) {
+              <div class="direct-ready">
+                <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M20 6 9 17l-5-5"/></svg>
+                当前账号已有额度,可直接开始
+              </div>
+            }
+
+            @if (errorMessage()) {
+              <div class="flow-error">{{ errorMessage() }}</div>
+            }
+
+            <button type="button" class="primary-button" [disabled]="busy()" (click)="continueFlow()">
+              @if (busy()) { <span class="spinner light"></span> }
+              <span>{{ actionLabel() }}</span>
+              @if (!busy()) {
+                <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12h14m-6-6 6 6-6 6"/></svg>
+              }
+            </button>
+          <p class="billing-note">{{ paymentTestMode() ? '本账号使用临时测试价,支付金额仍会加入可用数据额度。' : '安装与基础连接不消耗额度,获取数据时按实际用量扣减。' }}</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)="cancelPayment()">
+            <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 6l12 12M18 6 6 18"/></svg>
+          </button>
+          @if (stage() === 'paying') {
+            <h2 id="payment-title">微信扫码支付</h2>
+            <div class="payment-amount">¥{{ trialPrice() | number:'1.0-2' }}</div>
+            <div class="qr-frame">
+              @if (qrDataUrl()) {
+                <img [src]="qrDataUrl()" width="244" height="244" alt="微信支付二维码" />
+              } @else {
+                <div class="qr-loading"><span class="spinner"></span><span>{{ recoveredOrder() ? '正在恢复订单状态' : '正在生成支付码' }}</span></div>
+              }
+            </div>
+            <div class="payment-status"><span class="pulse-dot"></span>等待支付结果</div>
+          } @else {
+            <div class="arrival-icon"><span class="spinner"></span></div>
+            <h2 id="payment-title">支付成功,数据额度正在到账</h2>
+            <p>到账后将自动准备安装内容,请不要重复支付。</p>
+            <button type="button" class="secondary-button" [disabled]="busy()" (click)="retryArrival()">刷新到账状态</button>
+          }
+        </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>
+              <div class="success-symbol">
+                <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M20 6 9 17l-5-5"/></svg>
+              </div>
+              <h2 id="prompt-title">专属安装内容已准备好</h2>
+              <p>{{ selectedChannelName() }}<span *ngIf="channel() === 'codex'"> · {{ scope() === 'global' ? '用户全局' : '当前项目' }}</span></p>
+            </div>
+            <button type="button" class="icon-button" title="关闭" aria-label="关闭安装内容" (click)="closePrompt()">
+              <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 6l12 12M18 6 6 18"/></svg>
+            </button>
+          </div>
+          <div class="prompt-code" tabindex="0"><pre>{{ maskedPrompt() }}</pre></div>
+          <div class="prompt-security">
+            <svg viewBox="0 0 24 24" aria-hidden="true"><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()) {
+              <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M20 6 9 17l-5-5"/></svg>
+              已复制,可发送给 AI
+            } @else {
+              <svg viewBox="0 0 24 24" aria-hidden="true"><rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
+              复制给 AI 自动安装
+            }
+          </button>
+        </section>
+      </div>
+    }
+  `,
+  styles: [`
+    :host{display:block;min-height:100vh;color:#f5f7f8;background:#090b0d;font-family:Inter,"PingFang SC","Microsoft YaHei",sans-serif}
+    *{box-sizing:border-box;letter-spacing:0}
+    button,a{font:inherit}
+    button:focus-visible,a:focus-visible,.prompt-code:focus-visible{outline:2px solid #48d6c7;outline-offset:3px}
+    .skill-page{min-height:100vh;background-image:linear-gradient(rgba(255,255,255,.025) 1px,transparent 1px),linear-gradient(90deg,rgba(255,255,255,.025) 1px,transparent 1px);background-size:48px 48px}
+    .skill-header{height:72px;padding:0 clamp(20px,5vw,72px);display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid #252b30;background:rgba(9,11,13,.94)}
+    .skill-brand{display:flex;align-items:center;gap:10px;color:#fff;text-decoration:none;font-size:17px;font-weight:760}
+    .brand-mark{width:30px;height:30px;display:grid;place-items:center;background:#48d6c7;color:#07110f;font-weight:900;border-radius:6px}
+    .brand-accent{color:#48d6c7}
+    .skill-user{display:flex;align-items:center;gap:10px;color:#9aa5ad;font-size:13px}
+    .icon-button{width:36px;height:36px;padding:0;display:grid;place-items:center;border:1px solid #30373d;border-radius:6px;background:#111519;color:#aeb8bf;cursor:pointer}
+    .icon-button:hover{color:#fff;border-color:#4b565f;background:#171c20}
+    .icon-button svg,.primary-button svg,.copy-button svg,.direct-ready svg,.prompt-security svg,.success-symbol svg{width:18px;height:18px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
+    .skill-main{width:min(1180px,calc(100% - 40px));min-height:calc(100vh - 72px);margin:0 auto;display:grid;grid-template-columns:minmax(0,1fr) 400px;gap:clamp(52px,8vw,112px);align-items:center;padding:56px 0 72px}
+    .skill-config{max-width:680px}
+    h1{margin:0;max-width:650px;font-size:clamp(38px,5vw,62px);line-height:1.08;font-weight:760;color:#f8fafb}
+    .skill-lead{margin:20px 0 42px;color:#9aa5ad;font-size:17px;line-height:1.7}
+    .field-group{margin-top:28px}
+    .field-label{margin-bottom:12px;color:#c9d0d5;font-size:13px;font-weight:700}
+    .channel-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}
+    .channel-option{position:relative;min-height:64px;padding:10px 42px 10px 12px;display:flex;align-items:center;gap:12px;border:1px solid #30373d;border-radius:8px;background:#111519;color:#cbd3d8;font-size:14px;font-weight:650;cursor:pointer;text-align:left;transition:border-color .2s,background .2s,transform .12s}
+    .channel-option:hover{border-color:#536069;background:#151a1e}
+    .channel-option:active{transform:scale(.99)}
+    .channel-option.selected{border-color:#48d6c7;background:#10201f;color:#fff}
+    .channel-symbol{width:34px;height:34px;display:grid;place-items:center;border-radius:6px;background:#22292e;color:#eaf0f2;font-size:11px;font-weight:850}
+    .selected .channel-symbol{background:#48d6c7;color:#07110f}
+    .channel-check{position:absolute;right:14px;width:18px;height:18px;opacity:0;fill:none;stroke:#48d6c7;stroke-width:2.5;stroke-linecap:round;stroke-linejoin:round}
+    .selected .channel-check{opacity:1}
+    .scope-group{display:flex;align-items:center;gap:18px}
+    .scope-group .field-label{margin:0;white-space:nowrap}
+    .scope-control{display:grid;grid-template-columns:1fr 1fr;width:280px;padding:3px;border:1px solid #30373d;border-radius:8px;background:#0c0f12}
+    .scope-control button{height:36px;border:0;border-radius:5px;background:transparent;color:#8f9aa2;font-size:13px;cursor:pointer}
+    .scope-control button.selected{background:#252c31;color:#fff}
+    .flow-line{display:flex;align-items:center;gap:0;margin:44px 0 0;padding:0;list-style:none;color:#69747c;font-size:12px}
+    .flow-line li{display:flex;align-items:center;gap:7px;white-space:nowrap}
+    .flow-line li:not(:last-child)::after{content:"";width:42px;height:1px;margin:0 12px;background:#30373d}
+    .flow-line span{width:22px;height:22px;display:grid;place-items:center;border:1px solid #394149;border-radius:50%;font-size:11px}
+    .flow-line li.active{color:#dfe5e8}.flow-line li.active span{border-color:#48d6c7;color:#48d6c7}
+    .checkout-panel{min-height:410px;padding:28px;border:1px solid #30373d;border-radius:8px;background:#111519;box-shadow:0 28px 80px rgba(0,0,0,.32)}
+    .checkout-top{display:flex;align-items:flex-start;justify-content:space-between;gap:16px}
+    .checkout-label{color:#c7d0d5;font-size:14px;font-weight:700}
+    .checkout-price{margin-top:8px;color:#fff;font-size:44px;line-height:1;font-weight:780}
+    .checkout-price span{margin-right:3px;color:#9aa5ad;font-size:18px;font-weight:600}
+    .balance-tag{max-width:132px;padding:6px 8px;border:1px solid #2d615c;border-radius:6px;color:#65d8cc;background:#10201f;font-size:11px;line-height:1.4;text-align:center}
+    .checkout-rule{height:1px;margin:26px 0;background:#2b3237}
+    .status-row{min-height:210px;display:flex;align-items:center;justify-content:center;gap:10px;color:#9ca7ae;font-size:14px}
+    .balance-row{display:flex;align-items:center;justify-content:space-between;color:#929da4;font-size:13px}.balance-row strong{color:#e8edef;font-size:14px}
+    .direct-ready{margin-top:22px;padding:12px;display:flex;align-items:center;gap:9px;border:1px solid #255f4a;border-radius:7px;background:#0f211a;color:#64d6a3;font-size:13px}
+    .flow-error{margin-top:18px;padding:11px 12px;border-left:3px solid #ec6f65;background:#231515;color:#f1aaa5;font-size:12px;line-height:1.55}
+    .primary-button,.copy-button{width:100%;min-height:50px;margin-top:26px;padding:0 18px;display:flex;align-items:center;justify-content:center;gap:10px;border:1px solid #48d6c7;border-radius:7px;background:#48d6c7;color:#07110f;font-size:14px;font-weight:780;cursor:pointer;transition:transform .12s,background .2s}
+    .primary-button:hover,.copy-button:hover{background:#70e1d6}.primary-button:active,.copy-button:active{transform:scale(.99)}
+    button:disabled{cursor:not-allowed;opacity:.58}
+    .billing-note{margin:14px 0 0;color:#6f7a82;font-size:11px;line-height:1.6;text-align:center}
+    .spinner{width:18px;height:18px;display:inline-block;border:2px solid #344047;border-top-color:#48d6c7;border-radius:50%;animation:spin .75s linear infinite}.spinner.light{border-color:rgba(7,17,15,.25);border-top-color:#07110f}
+    @keyframes spin{to{transform:rotate(360deg)}}
+    .modal-backdrop{position:fixed;inset:0;z-index:1000;padding:20px;display:grid;place-items:center;background:rgba(2,4,5,.8);backdrop-filter:blur(8px);animation:fade .2s ease-out}
+    @keyframes fade{from{opacity:0}to{opacity:1}}
+    .payment-dialog,.prompt-dialog{position:relative;width:min(440px,100%);padding:30px;border:1px solid #343c42;border-radius:8px;background:#111519;box-shadow:0 30px 100px rgba(0,0,0,.55);animation:rise .24s ease-out}
+    @keyframes rise{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}
+    .dialog-close{position:absolute;right:18px;top:18px}
+    .payment-dialog h2{margin:0;text-align:center;font-size:20px}.payment-amount{margin:10px 0 20px;text-align:center;color:#fff;font-size:34px;font-weight:760}
+    .qr-frame{width:264px;height:264px;margin:0 auto;padding:10px;display:grid;place-items:center;border:1px solid #3c464d;border-radius:8px;background:#fff}.qr-frame img{display:block}.qr-loading{display:flex;flex-direction:column;align-items:center;gap:12px;color:#657078;font-size:12px}
+    .payment-status{margin-top:18px;display:flex;align-items:center;justify-content:center;gap:8px;color:#a6b0b6;font-size:13px}.pulse-dot{width:8px;height:8px;border-radius:50%;background:#e2b84d;box-shadow:0 0 0 5px rgba(226,184,77,.12)}
+    .arrival-icon{width:60px;height:60px;margin:8px auto 20px;display:grid;place-items:center;border:1px solid #35504d;border-radius:50%;background:#10201f}.arrival-icon .spinner{width:26px;height:26px}
+    .payment-dialog p{color:#8f9aa2;font-size:13px;line-height:1.7;text-align:center}
+    .secondary-button{min-height:42px;width:100%;margin-top:16px;border:1px solid #3b454c;border-radius:7px;background:#171c20;color:#e6ebed;cursor:pointer}
+    .prompt-backdrop{align-items:center}.prompt-dialog{width:min(760px,100%);padding:28px}
+    .prompt-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:20px}.prompt-heading>div{display:grid;grid-template-columns:42px 1fr;column-gap:12px}.success-symbol{grid-row:1/3;width:42px;height:42px;display:grid;place-items:center;border-radius:7px;background:#123023;color:#60d99d}.prompt-heading h2{margin:0;font-size:21px}.prompt-heading p{grid-column:2;margin:5px 0 0;color:#8e999f;font-size:13px}
+    .prompt-code{height:320px;margin-top:24px;padding:18px;overflow:auto;border:1px solid #2d353b;border-radius:7px;background:#090c0e}.prompt-code pre{margin:0;color:#bfc8cd;font:12px/1.75 "SFMono-Regular",Consolas,monospace;white-space:pre-wrap;overflow-wrap:anywhere}
+    .prompt-security{margin-top:14px;display:flex;align-items:flex-start;gap:9px;color:#d2ad59;font-size:12px;line-height:1.55}.prompt-security svg{flex:0 0 auto}
+    .copy-button{margin-top:18px}
+    @media (prefers-reduced-motion:reduce){*,*::before,*::after{animation-duration:.01ms!important;animation-iteration-count:1!important;transition-duration:.01ms!important}}
+    @media (max-width:860px){.skill-main{grid-template-columns:1fr;gap:36px;align-items:start;padding-top:42px}.skill-config{max-width:none}.checkout-panel{min-height:0}.flow-line{margin-top:34px}}
+    @media (max-width:540px){.skill-header{height:64px;padding:0 16px}.skill-user>span{display:none}.skill-main{width:calc(100% - 28px);min-height:calc(100vh - 64px);padding:30px 0 48px}h1{font-size:36px}.skill-lead{margin:14px 0 28px;font-size:15px}.channel-grid{grid-template-columns:1fr}.scope-group{align-items:flex-start;flex-direction:column;gap:10px}.scope-control{width:100%}.flow-line{font-size:11px}.flow-line li:not(:last-child)::after{width:14px;margin:0 7px}.checkout-panel{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:24px 18px}.prompt-dialog{height:100vh;border-radius:0;display:flex;flex-direction:column}.prompt-code{height:auto;min-height:0;flex:1}.qr-frame{width:244px;height:244px;padding:0}.qr-frame img{width:220px;height:220px}}
+  `]
+})
+export class SkillInstallComponent implements OnInit, OnDestroy {
+  @Input({ required: true }) sessionToken = '';
+  @Input({ required: true }) userId = '';
+  @Input() displayName = '';
+  @Output() logout = new EventEmitter<void>();
+
+  readonly channels = CHANNELS;
+  readonly channel = signal<InstallChannel>('codex');
+  readonly scope = signal<InstallScope>('project');
+  readonly stage = signal<FlowStage>('checking');
+  readonly eligibility = signal<Eligibility | null>(null);
+  readonly errorMessage = signal('');
+  readonly busy = signal(false);
+  readonly showPayment = signal(false);
+  readonly showPrompt = signal(false);
+  readonly qrDataUrl = signal('');
+  readonly prompt = signal('');
+  readonly maskedPrompt = signal('');
+  readonly copied = signal(false);
+  readonly recoveredOrder = signal(false);
+
+  private tradeNo = '';
+  private nonceStr = '';
+  private pollTimer: ReturnType<typeof setInterval> | null = null;
+  private source = 'apig-pay';
+
+  ngOnInit(): void {
+    const hashQuery = window.location.hash.includes('?') ? window.location.hash.split('?')[1] : '';
+    const query = new URLSearchParams(hashQuery || window.location.search);
+    const requestedChannel = query.get('channel') as InstallChannel | null;
+    const requestedScope = query.get('scope') as InstallScope | null;
+    if (CHANNELS.some(item => item.id === requestedChannel)) this.channel.set(requestedChannel!);
+    if (requestedScope === 'global' || requestedScope === 'project') this.scope.set(requestedScope);
+    this.source = String(query.get('source') || 'apig-pay').slice(0, 64);
+    void this.initialize();
+  }
+
+  ngOnDestroy(): void {
+    this.clearPolling();
+  }
+
+  trialPrice(): number {
+    return this.eligibility()?.trialPrice || TRIAL_PRICE;
+  }
+
+  paymentTestMode(): boolean {
+    return this.eligibility()?.paymentTestMode === true;
+  }
+
+  formatPrice(value: number): string {
+    return value < 1 ? value.toFixed(2) : value.toFixed(1);
+  }
+
+  selectedChannelName(): string {
+    return CHANNELS.find(item => item.id === this.channel())?.name || 'VOC';
+  }
+
+  actionLabel(): string {
+    if (this.stage() === 'install') return '查看安装内容';
+    if (!this.eligibility()) return '重新检查账号';
+    if (this.eligibility()?.canInstallDirectly) return '获取一键安装内容';
+    return `微信扫码支付 ¥${this.formatPrice(this.trialPrice())}`;
+  }
+
+  selectChannel(channel: InstallChannel): void {
+    this.channel.set(channel);
+    if (channel !== 'codex') this.scope.set('project');
+    this.resetPrompt();
+  }
+
+  selectScope(scope: InstallScope): void {
+    this.scope.set(scope);
+    this.resetPrompt();
+  }
+
+  async continueFlow(): Promise<void> {
+    this.errorMessage.set('');
+    if (this.stage() === 'install' && this.prompt()) {
+      this.showPrompt.set(true);
+      return;
+    }
+    if (!this.eligibility()) {
+      await this.loadEligibility();
+      return;
+    }
+    if (this.eligibility()?.canInstallDirectly) {
+      await this.requestInstallPrompt();
+      return;
+    }
+    await this.startPayment();
+  }
+
+  async retryArrival(): Promise<void> {
+    if (!this.tradeNo) return;
+    await this.waitForInstallPrompt();
+  }
+
+  cancelPayment(): void {
+    this.clearPolling();
+    this.showPayment.set(false);
+    this.qrDataUrl.set('');
+    if (this.stage() === 'paying') this.stage.set('ready');
+  }
+
+  closePrompt(): void {
+    this.showPrompt.set(false);
+  }
+
+  async copyPrompt(): Promise<void> {
+    const value = this.prompt();
+    if (!value) return;
+    try {
+      await navigator.clipboard.writeText(value);
+      this.copied.set(true);
+      window.setTimeout(() => this.copied.set(false), 2200);
+    } catch {
+      this.errorMessage.set('浏览器未允许复制,请在安全上下文中重试。');
+    }
+  }
+
+  private async initialize(): Promise<void> {
+    await this.loadEligibility();
+    const pending = this.readPendingOrder();
+    if (pending && !this.eligibility()?.canInstallDirectly) {
+      this.tradeNo = pending.tradeNo;
+      this.nonceStr = pending.nonceStr;
+      this.channel.set(pending.channel);
+      this.scope.set(pending.scope);
+      this.recoveredOrder.set(true);
+      this.stage.set('paying');
+      this.showPayment.set(true);
+      this.startPolling();
+    } else if (pending) {
+      this.clearPendingOrder();
+    }
+  }
+
+  private async loadEligibility(): Promise<void> {
+    this.stage.set('checking');
+    this.eligibility.set(null);
+    this.errorMessage.set('');
+    try {
+      const result = await this.postJson(`${API_BASE}/api/fmode/voc-skill/eligibility`, {
+        sessionToken: this.sessionToken
+      });
+      if (result.code !== 200) throw new Error(result.assistantMessage || result.mess || '账号状态检查失败');
+      this.eligibility.set(this.applyPaymentTestMode(result.data));
+      this.stage.set('ready');
+    } catch (error: any) {
+      this.stage.set('ready');
+      this.errorMessage.set(error?.message || '账号状态检查失败,请稍后重试。');
+    }
+  }
+
+  private async requestInstallPrompt(): Promise<boolean> {
+    this.busy.set(true);
+    this.errorMessage.set('');
+    try {
+      const result = await this.postJson(`${API_BASE}/api/fmode/voc-skill/install-prompt`, {
+        sessionToken: this.sessionToken,
+        channel: this.channel(),
+        scope: this.scope(),
+        source: this.source,
+        tradeNo: this.tradeNo || undefined
+      });
+      if (result.code !== 200 || !result.data?.prompt) {
+        throw new Error(result.assistantMessage || result.mess || '安装内容生成失败');
+      }
+      this.prompt.set(result.data.prompt);
+      this.maskedPrompt.set(result.data.maskedPrompt || '安装凭证已安全生成,复制时将写入完整指令。');
+      this.stage.set('install');
+      this.showPayment.set(false);
+      this.showPrompt.set(true);
+      this.clearPendingOrder();
+      this.eligibility.update(current => current ? {
+        ...current,
+        hasToken: true,
+        canInstallDirectly: true,
+        needsPayment: false,
+        newApiBalance: Number(result.data.balance ?? current.newApiBalance)
+      } : current);
+      return true;
+    } catch (error: any) {
+      this.errorMessage.set(error?.message || '安装内容生成失败,请稍后重试。');
+      return false;
+    } finally {
+      this.busy.set(false);
+    }
+  }
+
+  private async startPayment(): Promise<void> {
+    if (this.busy()) return;
+    if (!this.eligibility()?.needsPayment) {
+      this.errorMessage.set('账号状态尚未确认,请重新检查后再继续。');
+      return;
+    }
+    this.busy.set(true);
+    this.errorMessage.set('');
+    this.qrDataUrl.set('');
+    try {
+      this.tradeNo = this.createTradeNo();
+      const accountId = await this.ensureAccount();
+      await this.createAccountLog(accountId);
+      const payResult = await this.runCloudFunction('pay_code2', {
+        company: PAY_COMPANY,
+        out_trade_no: this.tradeNo,
+        total_fee: this.trialPrice(),
+        body: `VOC 数据洞察体验 ¥${this.formatPrice(this.trialPrice())}`,
+        fun_id: AUTO_NEWAPI_RECHARGE_CALLBACK_FUN_ID
+      });
+      const codeUrl = Array.isArray(payResult?.code_url) ? payResult.code_url[0] : payResult?.code_url;
+      if (!codeUrl) throw new Error('支付码生成失败,请稍后重试。');
+      this.nonceStr = String(payResult.nonce_str || '');
+      this.qrDataUrl.set(await QRCode.toDataURL(codeUrl, { width: 244, margin: 1, errorCorrectionLevel: 'M' }));
+      this.stage.set('paying');
+      this.showPayment.set(true);
+      this.writePendingOrder();
+      this.startPolling();
+    } catch (error: any) {
+      this.stage.set('ready');
+      this.showPayment.set(false);
+      this.errorMessage.set(error?.message || '支付发起失败,请稍后重试。');
+    } finally {
+      this.busy.set(false);
+    }
+  }
+
+  private startPolling(): void {
+    this.clearPolling();
+    const check = async () => {
+      try {
+        const result = await this.runCloudFunction('order_status2', {
+          out_trade_no: this.tradeNo,
+          nonce_str: this.nonceStr,
+          company: PAY_COMPANY
+        });
+        if (result?.status?.[0] === 'SUCCESS') {
+          this.clearPolling();
+          this.stage.set('crediting');
+          await this.waitForInstallPrompt();
+        }
+      } catch {
+        // Polling is retried. Do not surface transient provider messages or credentials.
+      }
+    };
+    void check();
+    this.pollTimer = setInterval(() => void check(), 3000);
+  }
+
+  private async waitForInstallPrompt(): Promise<void> {
+    if (this.busy()) return;
+    this.busy.set(true);
+    this.stage.set('crediting');
+    this.showPayment.set(true);
+    this.errorMessage.set('');
+    try {
+      for (let attempt = 0; attempt < 12; attempt++) {
+        if (attempt > 0) await new Promise(resolve => window.setTimeout(resolve, 1500));
+        const result = await this.postJson(`${API_BASE}/api/fmode/voc-skill/install-prompt`, {
+          sessionToken: this.sessionToken,
+          channel: this.channel(),
+          scope: this.scope(),
+          source: this.source,
+          tradeNo: this.tradeNo
+        });
+        if (result.code === 200 && result.data?.prompt) {
+          this.prompt.set(result.data.prompt);
+          this.maskedPrompt.set(result.data.maskedPrompt || '安装内容已安全准备,复制时将包含完整授权信息。');
+          this.eligibility.set({
+            hasToken: true,
+            newApiBalance: Number(result.data.balance || this.trialPrice()),
+            canInstallDirectly: true,
+            needsPayment: false,
+            trialPrice: this.trialPrice(),
+            paymentTestMode: this.paymentTestMode()
+          });
+          this.stage.set('install');
+          this.showPayment.set(false);
+          this.showPrompt.set(true);
+          this.clearPendingOrder();
+          return;
+        }
+      }
+      throw new Error('支付已成功,数据额度仍在同步,请点击刷新到账状态。');
+    } catch (error: any) {
+      this.errorMessage.set(error?.message || '数据额度同步中,请稍后刷新。');
+    } finally {
+      this.busy.set(false);
+    }
+  }
+
+  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): 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: this.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: 'recharge-wxpay',
+      assetType: 'balance',
+      isVerified: false,
+      orderNumber: this.tradeNo,
+      targetName: 'system',
+      payType: 'wxpay',
+      assetCount: this.trialPrice(),
+      detail: {
+        product: 'voc-skill-trial',
+        channel: this.channel(),
+        scope: this.scope(),
+        source: this.source,
+        paymentTestMode: this.paymentTestMode()
+      }
+    });
+  }
+
+  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 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 applyPaymentTestMode(eligibility: Eligibility): Eligibility {
+    if (this.userId !== PAYMENT_TEST_USER_ID) return eligibility;
+    return {
+      ...eligibility,
+      canInstallDirectly: false,
+      needsPayment: true,
+      trialPrice: PAYMENT_TEST_PRICE,
+      paymentTestMode: true
+    };
+  }
+
+  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(): 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 `VOC${this.userId}${stamp}`;
+  }
+
+  private writePendingOrder(): void {
+    const order: PendingOrder = {
+      tradeNo: this.tradeNo,
+      nonceStr: this.nonceStr,
+      channel: this.channel(),
+      scope: this.scope()
+    };
+    sessionStorage.setItem(PENDING_ORDER_KEY, JSON.stringify(order));
+  }
+
+  private readPendingOrder(): PendingOrder | null {
+    try {
+      const raw = sessionStorage.getItem(PENDING_ORDER_KEY);
+      if (!raw) return null;
+      const value = JSON.parse(raw);
+      if (!value.tradeNo || !value.nonceStr || !CHANNELS.some(item => item.id === value.channel)) return null;
+      return value;
+    } catch {
+      return null;
+    }
+  }
+
+  private clearPendingOrder(): void {
+    sessionStorage.removeItem(PENDING_ORDER_KEY);
+  }
+
+  private clearPolling(): void {
+    if (this.pollTimer) {
+      clearInterval(this.pollTimer);
+      this.pollTimer = null;
+    }
+  }
+
+  private resetPrompt(): void {
+    this.prompt.set('');
+    this.maskedPrompt.set('');
+    this.showPrompt.set(false);
+    this.copied.set(false);
+    if (this.stage() === 'install') this.stage.set('ready');
+  }
+}

+ 10 - 1
src/styles.scss

@@ -1 +1,10 @@
-/* You can add global styles to this file, and also import other style files */
+html,
+body {
+  min-height: 100%;
+  margin: 0;
+  background: #090b0d;
+}
+
+body {
+  overflow-x: hidden;
+}