gangvy 3 månader sedan
förälder
incheckning
e2fd9557f3
6 ändrade filer med 1338 tillägg och 44 borttagningar
  1. BIN
      public/launcher/fmode-studio-logo.png
  2. 14 0
      public/launcher/index.html
  3. 108 0
      scripts/browser-real-pay.mjs
  4. 212 16
      src/app/app.html
  5. 641 2
      src/app/app.scss
  6. 363 26
      src/app/app.ts

BIN
public/launcher/fmode-studio-logo.png


+ 14 - 0
public/launcher/index.html

@@ -0,0 +1,14 @@
+<!doctype html>
+<html lang="zh-CN">
+  <head>
+    <meta charset="utf-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1">
+    <title>FmodeStudio Launcher</title>
+    <script>
+      window.location.replace('../#/launcher');
+    </script>
+  </head>
+  <body>
+    <a href="../#/launcher">打开 FmodeStudio Launcher 下载页</a>
+  </body>
+</html>

+ 108 - 0
scripts/browser-real-pay.mjs

@@ -0,0 +1,108 @@
+#!/usr/bin/env node
+
+import { chromium } from 'playwright';
+
+const API_BASE = 'https://server.fmode.cn';
+const PARSE_BASE = API_BASE + '/parse/functions';
+const APP_ID = 'ncloudmaster';
+const PAY_COMPANY = '1AiWpTEDH9';
+
+function getArg(name, fallback = '') {
+  const prefix = `--${name}=`;
+  const match = process.argv.find(arg => arg.startsWith(prefix));
+  return match ? match.slice(prefix.length) : fallback;
+}
+
+function makeTradeNo(user) {
+  const now = new Date();
+  const pad = value => String(value).padStart(2, '0');
+  return 'Cbr' + user.replace(/[^a-zA-Z0-9]/g, '').slice(0, 8) +
+    now.getFullYear() +
+    pad(now.getMonth() + 1) +
+    pad(now.getDate()) +
+    pad(now.getHours()) +
+    pad(now.getMinutes()) +
+    pad(now.getSeconds()) +
+    String(now.getMilliseconds()).padStart(3, '0');
+}
+
+async function main() {
+  const user = getArg('user', '2luFP8J1H1');
+  const authid = getArg('authid', 'd1DOPlTNCb');
+  const fcompany = getArg('fcompany', PAY_COMPANY);
+  const apigid = getArg('apigid', 'G0vmsUI44d');
+  // fun_id 必须是充值云函数 apigRechargeOnPay 的 objectId;voc-e-commerce 是 APIG 的 path,传它会导致回调找不到云函数、不入账
+  const funId = getArg('fun_id', 'HOkkX72PMF');
+  const price = Number(getArg('price', '0.01'));
+  const count = Number(getArg('count', '1'));
+  const oldCount = Number(getArg('oldCount', '1003'));
+  const tradeNo = getArg('trade', makeTradeNo(user));
+  const bodyText = `国内电商数据中台 接口充值(${count} 次)`;
+
+  const browser = await chromium.launch({ headless: true });
+  const page = await browser.newPage();
+  await page.goto('https://app.fmode.cn/dev/apig-pay/', { waitUntil: 'domcontentloaded' });
+
+  const result = await page.evaluate(async ({ API_BASE, PARSE_BASE, APP_ID, PAY_COMPANY, user, authid, fcompany, apigid, funId, price, count, oldCount, tradeNo, bodyText }) => {
+    async function postJSON(url, body) {
+      const resp = await fetch(url, {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify(body)
+      });
+      const text = await resp.text();
+      let data;
+      try {
+        data = JSON.parse(text);
+      } catch {
+        data = text;
+      }
+      return { ok: resp.ok, status: resp.status, data };
+    }
+
+    const order = await postJSON(API_BASE + '/api/apig/created-apigorder', {
+      type: 'wxpay',
+      user,
+      authid,
+      fcompany,
+      apigid,
+      oldCount,
+      params: {
+        out_trade_no: tradeNo,
+        total_fee: price,
+        body: bodyText
+      },
+      count
+    });
+
+    const pay = await postJSON(PARSE_BASE + '/pay_code2', {
+      _ApplicationId: APP_ID,
+      company: PAY_COMPANY,
+      out_trade_no: tradeNo,
+      total_fee: price,
+      body: bodyText,
+      fun_id: funId
+    });
+
+    const payResult = pay.data?.result || {};
+    const nonceStr = Array.isArray(payResult.nonce_str) ? payResult.nonce_str[0] : payResult.nonce_str;
+    const status = nonceStr
+      ? await postJSON(PARSE_BASE + '/order_status2', {
+        _ApplicationId: APP_ID,
+        out_trade_no: tradeNo,
+        nonce_str: nonceStr,
+        company: PAY_COMPANY
+      })
+      : null;
+
+    return { tradeNo, order, pay, status };
+  }, { API_BASE, PARSE_BASE, APP_ID, PAY_COMPANY, user, authid, fcompany, apigid, funId, price, count, oldCount, tradeNo, bodyText });
+
+  await browser.close();
+  console.log(JSON.stringify(result, null, 2));
+}
+
+main().catch(err => {
+  console.error('[failed]', err.message);
+  process.exit(1);
+});

+ 212 - 16
src/app/app.html

@@ -1,9 +1,10 @@
 <!-- Login Modal -->
 <!-- Login Modal -->
-<app-login-modal [visible]="needLogin" (loginSuccess)="onLoginSuccess($event)"></app-login-modal>
+<app-login-modal [visible]="needLogin && !isLauncherPage" (loginSuccess)="onLoginSuccess($event)"></app-login-modal>
 
 
 <!-- Header -->
 <!-- Header -->
 <div class="page-header">
 <div class="page-header">
-  <div class="logo">BRAIN<span>HACK</span></div>
+  <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>
   <div class="subtitle">{{pageSubtitle}}</div>
   <div class="user-bar" *ngIf="loggedInUser">
   <div class="user-bar" *ngIf="loggedInUser">
     <span class="user-badge">
     <span class="user-badge">
@@ -14,20 +15,97 @@
       {{loggedInUser}}
       {{loggedInUser}}
     </span>
     </span>
     <button class="token-copy-btn" (click)="copyToken()" *ngIf="sessionToken"
     <button class="token-copy-btn" (click)="copyToken()" *ngIf="sessionToken"
-            title="复制到终端执行,自动将 token 写入 OpenClaw"
+            [title]="tokenCopyTitle"
             [class.copied]="tokenCopied">
             [class.copied]="tokenCopied">
       <svg class="header-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
       <svg class="header-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
         <rect x="9" y="9" width="13" height="13" rx="2"/>
         <rect x="9" y="9" width="13" height="13" rx="2"/>
         <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
         <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
       </svg>
       </svg>
-      {{tokenCopied ? '已复制命令' : '复制 Token 授权命令'}}
+      {{tokenCopyButtonText}}
     </button>
     </button>
     <button class="logout-btn" (click)="logout()">退出</button>
     <button class="logout-btn" (click)="logout()">退出</button>
   </div>
   </div>
 </div>
 </div>
 
 
 <!-- Main -->
 <!-- Main -->
-<div class="main-container">
+<div class="launcher-page" *ngIf="isLauncherPage">
+  <section class="launcher-hero">
+    <div class="launcher-brand">
+      <img src="launcher/fmode-studio-logo.png" alt="FmodeStudio" />
+      <div>
+        <div class="launcher-kicker">FMODE STUDIO LAUNCHER</div>
+        <h1>FmodeStudio</h1>
+      </div>
+    </div>
+    <p class="launcher-subtitle">从 Demo 到产品的桥梁。启动器会自动检测 Node.js 环境,必要时下载便携运行时,并一键启动 FmodeStudio。</p>
+    <div class="launcher-actions">
+      <a class="launcher-primary-btn" href="https://repos.fmode.cn/x/launcher/FmodeStudioLauncher-win-x64.exe" download>
+        下载 Windows x64
+      </a>
+      <a class="launcher-secondary-btn" href="#downloads">选择其它系统</a>
+      <a class="launcher-secondary-btn" [href]="launcherUrlList" target="_blank" rel="noopener">链接清单</a>
+    </div>
+  </section>
+
+  <section class="launcher-feature-row">
+    <div class="launcher-feature">
+      <span>01</span>
+      <strong>一键启动</strong>
+      <p>无需复杂安装,自动执行 `npx -y @fmode/studio`。</p>
+    </div>
+    <div class="launcher-feature">
+      <span>02</span>
+      <strong>跨平台</strong>
+      <p>覆盖 Windows、macOS、Linux 与 x64/ARM64 架构。</p>
+    </div>
+    <div class="launcher-feature">
+      <span>03</span>
+      <strong>自带环境处理</strong>
+      <p>优先复用系统 Node.js,缺失时使用便携 Node。</p>
+    </div>
+  </section>
+
+  <section class="launcher-download-section" id="downloads">
+    <div class="launcher-section-head">
+      <div>
+        <div class="launcher-kicker">DOWNLOADS</div>
+        <h2>选择你的系统</h2>
+      </div>
+      <p>Windows 版本为 `.exe`,macOS / Linux 下载后请在终端赋予执行权限。</p>
+    </div>
+
+    <div class="launcher-download-grid">
+      <a
+        class="launcher-download-card"
+        *ngFor="let item of launcherDownloads"
+        [href]="item.url"
+        download
+        [class.primary]="item.primary">
+        <div class="launcher-download-top">
+          <div class="launcher-os-icon" [class.windows]="item.os === 'Windows'" [class.macos]="item.os === 'macOS'" [class.linux]="item.os === 'Linux'">
+            <svg *ngIf="item.os === 'Windows'" viewBox="0 0 24 24" fill="currentColor">
+              <path d="M3 5.1 10.8 4v7.3H3V5.1Zm8.7-1.2L21 2.6v8.7h-9.3V3.9ZM3 12.7h7.8V20L3 18.9v-6.2Zm8.7 0H21v8.7l-9.3-1.3v-7.4Z"/>
+            </svg>
+            <svg *ngIf="item.os === 'macOS'" viewBox="0 0 24 24" fill="currentColor">
+              <path d="M16.7 12.8c0-2.1 1.7-3.1 1.8-3.2-1-1.4-2.5-1.6-3-1.7-1.3-.1-2.5.8-3.1.8-.7 0-1.7-.8-2.8-.7-1.4 0-2.7.8-3.4 2-1.5 2.6-.4 6.4 1.1 8.5.7 1 1.5 2.2 2.6 2.1 1.1 0 1.5-.7 2.8-.7s1.7.7 2.8.7 1.9-1 2.6-2c.8-1.2 1.1-2.3 1.2-2.4 0 0-2.6-1-2.6-3.4ZM14.7 6.6c.6-.7 1-1.7.9-2.6-.9 0-1.9.6-2.5 1.3-.6.7-1.1 1.7-.9 2.6.9.1 1.9-.5 2.5-1.3Z"/>
+            </svg>
+            <svg *ngIf="item.os === 'Linux'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
+              <path d="M7 20h10M8 17c-1.2-1.8-1.5-4.2-.8-6.6C7 6.5 9 3 12 3s5 3.5 4.8 7.4c.7 2.4.4 4.8-.8 6.6"/>
+              <path d="M9 17c1 .7 5 .7 6 0M10 9h.01M14 9h.01M10 12c1.1.7 2.9.7 4 0"/>
+            </svg>
+          </div>
+          <span class="launcher-chip" *ngIf="item.primary">推荐</span>
+        </div>
+        <div class="launcher-download-title">{{item.label}}</div>
+        <div class="launcher-download-meta">{{item.os}} · {{item.arch}}</div>
+        <div class="launcher-download-cta">下载安装器</div>
+      </a>
+    </div>
+  </section>
+</div>
+
+<!-- Main -->
+<div class="main-container" *ngIf="!isLauncherPage">
 
 
   <!-- Workshop Entry -->
   <!-- Workshop Entry -->
   <div class="workshop-entry neu-raised" *ngIf="!isWorkshopMode && !showSuccess && !needLogin">
   <div class="workshop-entry neu-raised" *ngIf="!isWorkshopMode && !showSuccess && !needLogin">
@@ -41,6 +119,13 @@
   <!-- Workshop Portal -->
   <!-- Workshop Portal -->
   <div class="workshop-portal" *ngIf="workshopPortal && !showSuccess && !needLogin">
   <div class="workshop-portal" *ngIf="workshopPortal && !showSuccess && !needLogin">
     <div class="workshop-portal-head">
     <div class="workshop-portal-head">
+      <button class="route-back-btn" type="button" (click)="closeWorkshopPortal()">
+        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+          <path d="M19 12H5"/>
+          <path d="m12 19-7-7 7-7"/>
+        </svg>
+        返回
+      </button>
       <div>
       <div>
         <div class="workshop-kicker">情报官技能套餐</div>
         <div class="workshop-kicker">情报官技能套餐</div>
         <div class="workshop-portal-title">选择适合本场景的套餐</div>
         <div class="workshop-portal-title">选择适合本场景的套餐</div>
@@ -72,7 +157,7 @@
   </div>
   </div>
 
 
   <!-- API Info Card -->
   <!-- API Info Card -->
-  <div class="api-info-card neu-raised" *ngIf="!isWorkshopMode && !showSuccess && !needLogin && apigId">
+  <div class="api-info-card neu-raised legacy-checkout-hidden" *ngIf="false && !isWorkshopMode && !showSuccess && !needLogin && apigId">
     <div class="api-title" *ngIf="!apig">
     <div class="api-title" *ngIf="!apig">
       <span class="skeleton" style="display:inline-block;width:200px;height:24px;">&nbsp;</span>
       <span class="skeleton" style="display:inline-block;width:200px;height:24px;">&nbsp;</span>
     </div>
     </div>
@@ -98,7 +183,7 @@
   </div>
   </div>
 
 
   <!-- APIG Switcher (always visible after login) -->
   <!-- APIG Switcher (always visible after login) -->
-  <div class="apig-list-section" *ngIf="!isWorkshopMode && loggedInUser && !needLogin && !showSuccess">
+  <div class="apig-list-section legacy-checkout-hidden" *ngIf="false && !isWorkshopMode && loggedInUser && !needLogin && !showSuccess">
     <h3 class="apig-list-title">选择要充值的 API 服务</h3>
     <h3 class="apig-list-title">选择要充值的 API 服务</h3>
 
 
     <!-- Loading -->
     <!-- Loading -->
@@ -145,8 +230,113 @@
   <!-- Error -->
   <!-- Error -->
   <div class="error-bar" *ngIf="errorMsg">{{errorMsg}}</div>
   <div class="error-bar" *ngIf="errorMsg">{{errorMsg}}</div>
 
 
+  <!-- Checkout -->
+  <section class="checkout-shell" *ngIf="!isWorkshopMode && !showSuccess && !needLogin && apigId">
+    <div class="checkout-main">
+      <div class="checkout-kicker">API SERVICE</div>
+      <div class="checkout-heading">
+        <div>
+          <h1>{{apig?.title || 'API 服务充值'}}</h1>
+          <p>{{apig?.content || '专业 API 数据服务'}}</p>
+        </div>
+        <span class="checkout-badge">HTTPS</span>
+      </div>
+
+      <div class="service-select-block">
+        <label for="apigSelect">充值服务</label>
+        <div class="service-select-wrap" [class.loading]="apigListLoading && apigList.length === 0">
+          <select id="apigSelect" [value]="apigId" (change)="onApigSelect($any($event.target).value)" [disabled]="apigListLoading && apigList.length === 0">
+            <option *ngIf="apigList.length === 0" [value]="apigId">{{apig?.title || '正在加载服务列表'}}</option>
+            <option *ngFor="let item of apigList" [value]="item.objectId" [selected]="isCurrentApig(item.objectId)">
+              {{item.title}} · 余额 {{item.userBalance != null ? item.userBalance : 0}} 次
+            </option>
+          </select>
+          <svg class="select-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+            <path d="m6 9 6 6 6-6"/>
+          </svg>
+        </div>
+        <div class="service-meta" *ngIf="currentApigFromList as current">
+          <span>当前余额 <strong>{{current.userBalance != null ? current.userBalance : (apig?.count || 0)}} {{unitLabel}}</strong></span>
+          <span>服务状态 <strong>正常</strong></span>
+        </div>
+      </div>
+
+      <div class="checkout-section-title">选择套餐</div>
+      <div class="tier-grid checkout-tiers" *ngIf="!apig || !apig.priceStep">
+        <div class="tier-card neu-raised skeleton" style="height:116px;"></div>
+        <div class="tier-card neu-raised skeleton" style="height:116px;"></div>
+        <div class="tier-card neu-raised skeleton" style="height:116px;"></div>
+      </div>
+      <div class="tier-grid checkout-tiers" *ngIf="apig && apig.priceStep">
+        <button
+          type="button"
+          *ngFor="let tier of apig.priceStep; let i = index"
+          class="tier-card"
+          [class.neu-raised]="selectedIndex !== i"
+          [class.neu-pressed]="selectedIndex === i"
+          [class.active]="selectedIndex === i"
+          [class.test-tier]="tier.isTest"
+          (click)="selectTier(i)">
+          <span class="test-tier-badge" *ngIf="tier.isTest">测试入口</span>
+          <div class="tier-card-head">
+            <div class="tier-count">{{tier.count}} {{unitLabel}}</div>
+            <span class="tier-choice-mark" *ngIf="selectedIndex === i">已选</span>
+          </div>
+          <div class="tier-price"><span class="symbol">¥</span>{{tier.price}}</div>
+          <div class="tier-unit-price">约 ¥{{(tier.price / tier.count).toFixed(3)}} / {{unitLabel}}</div>
+        </button>
+      </div>
+    </div>
+
+    <aside class="checkout-summary" *ngIf="selectedTier">
+      <div class="summary-title">订单摘要</div>
+      <div class="summary-service">{{apig?.title || 'API 服务'}}</div>
+      <div class="summary-line">
+        <span>套餐额度</span>
+        <strong>{{selectedTier.count}} {{unitLabel}}</strong>
+      </div>
+      <div class="summary-line">
+        <span>单次均价</span>
+        <strong>¥{{selectedUnitPrice}}</strong>
+      </div>
+      <div class="summary-line">
+        <span>有效期</span>
+        <strong>730 天</strong>
+      </div>
+      <div class="summary-total">
+        <span>应付金额</span>
+        <strong><span class="symbol">¥</span>{{selectedTier.price}}</strong>
+      </div>
+      <div class="payment-method">
+        <span class="method-icon">
+          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+            <rect x="3" y="5" width="18" height="14" rx="3"/>
+            <path d="M3 10h18"/>
+            <path d="M7 15h4"/>
+          </svg>
+        </span>
+        <div>
+          <div>微信支付</div>
+          <small>扫码完成支付后自动入账</small>
+        </div>
+      </div>
+      <button class="pay-btn checkout-pay-btn" [disabled]="!apig || paying" (click)="startPayment()">
+        <span *ngIf="!paying">微信支付 ¥{{selectedTier.price}}</span>
+        <span *ngIf="paying">正在生成支付码...</span>
+      </button>
+      <div class="checkout-note">{{tokenCopyCheckoutNote}}</div>
+    </aside>
+  </section>
+
   <!-- Workshop Package -->
   <!-- Workshop Package -->
   <div class="workshop-package neu-raised" *ngIf="!showSuccess && !needLogin && workshopPackage as pkg">
   <div class="workshop-package neu-raised" *ngIf="!showSuccess && !needLogin && workshopPackage as pkg">
+    <button class="route-back-btn workshop-detail-back" type="button" (click)="backToWorkshopPortal()">
+      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+        <path d="M19 12H5"/>
+        <path d="m12 19-7-7 7-7"/>
+      </svg>
+      返回套餐列表
+    </button>
     <div class="workshop-kicker">龙虾业务情报官工作坊</div>
     <div class="workshop-kicker">龙虾业务情报官工作坊</div>
     <div class="workshop-heading-row">
     <div class="workshop-heading-row">
       <div>
       <div>
@@ -159,7 +349,7 @@
     <div class="workshop-price-row">
     <div class="workshop-price-row">
       <div>
       <div>
         <div class="workshop-price"><span class="symbol">¥</span>{{pkg.price}}</div>
         <div class="workshop-price"><span class="symbol">¥</span>{{pkg.price}}</div>
-        <div class="workshop-price-note">技能训练服务 + 双数据中台运行额度</div>
+        <div class="workshop-price-note">技能训练服务 + {{workshopQuotaLabel}}</div>
       </div>
       </div>
       <div class="workshop-total-quota">
       <div class="workshop-total-quota">
         <span>合计额度</span>
         <span>合计额度</span>
@@ -169,9 +359,14 @@
 
 
     <div class="workshop-quota-grid">
     <div class="workshop-quota-grid">
       <div class="workshop-quota-item" *ngFor="let allocation of pkg.allocations">
       <div class="workshop-quota-item" *ngFor="let allocation of pkg.allocations">
-        <div class="quota-title">{{allocation.title}}</div>
-        <div class="quota-count">+{{allocation.count}} 次</div>
-        <div class="quota-balance">当前余额 {{getWorkshopBalance(allocation)}} 次</div>
+        <div class="quota-main">
+          <div class="quota-title">{{allocation.title}}</div>
+          <div class="quota-count">+{{allocation.count}} 次</div>
+        </div>
+        <div class="quota-balance">
+          <span>当前余额</span>
+          <strong>{{getWorkshopBalance(allocation)}} 次</strong>
+        </div>
       </div>
       </div>
     </div>
     </div>
 
 
@@ -182,7 +377,7 @@
     <div class="summary-bar neu-groove workshop-summary" *ngIf="selectedTier">
     <div class="summary-bar neu-groove workshop-summary" *ngIf="selectedTier">
       <div>
       <div>
         <div class="label">套餐内容</div>
         <div class="label">套餐内容</div>
-        <div class="workshop-summary-main">两个数据中台分别到账,有效期 730 天</div>
+        <div class="workshop-summary-main">{{workshopArrivalLabel}},有效期 730 天</div>
       </div>
       </div>
       <div style="text-align:right;">
       <div style="text-align:right;">
         <div class="label">应付金额</div>
         <div class="label">应付金额</div>
@@ -191,14 +386,14 @@
     </div>
     </div>
 
 
     <button class="pay-btn" [disabled]="!apig || paying || workshopPreparing" (click)="startPayment()">
     <button class="pay-btn" [disabled]="!apig || paying || workshopPreparing" (click)="startPayment()">
-      <span *ngIf="!paying && !workshopPreparing"><span class="btn-icon">💳</span> 微信支付</span>
+      <span *ngIf="!paying && !workshopPreparing">微信支付</span>
       <span *ngIf="workshopPreparing">正在确认双中台账套...</span>
       <span *ngIf="workshopPreparing">正在确认双中台账套...</span>
       <span *ngIf="paying && !workshopPreparing">正在生成支付码...</span>
       <span *ngIf="paying && !workshopPreparing">正在生成支付码...</span>
     </button>
     </button>
   </div>
   </div>
 
 
   <!-- Tier Selection -->
   <!-- Tier Selection -->
-  <ng-container *ngIf="!isWorkshopMode && !showSuccess && !needLogin && apigId">
+  <ng-container *ngIf="false && !isWorkshopMode && !showSuccess && !needLogin && apigId">
     <div class="section-title">选择套餐</div>
     <div class="section-title">选择套餐</div>
     <div class="tier-grid" *ngIf="!apig || !apig.priceStep">
     <div class="tier-grid" *ngIf="!apig || !apig.priceStep">
       <div class="tier-card neu-raised skeleton" style="height:100px;"></div>
       <div class="tier-card neu-raised skeleton" style="height:100px;"></div>
@@ -238,7 +433,7 @@
 
 
     <!-- Pay Button -->
     <!-- Pay Button -->
     <button class="pay-btn" [disabled]="!apig || paying" (click)="startPayment()">
     <button class="pay-btn" [disabled]="!apig || paying" (click)="startPayment()">
-      <span *ngIf="!paying"><span class="btn-icon">💳</span> 微信支付</span>
+      <span *ngIf="!paying">微信支付</span>
       <span *ngIf="paying">正在生成支付码...</span>
       <span *ngIf="paying">正在生成支付码...</span>
     </button>
     </button>
   </ng-container>
   </ng-container>
@@ -293,7 +488,8 @@
     <div class="success-title">充值成功</div>
     <div class="success-title">充值成功</div>
     <div class="success-detail" [innerHTML]="successDetailHtml"></div>
     <div class="success-detail" [innerHTML]="successDetailHtml"></div>
     <div class="success-copy-hint" *ngIf="sessionToken">
     <div class="success-copy-hint" *ngIf="sessionToken">
-      下一步:请点击右上角「复制 Token 授权命令」按钮,将授权命令复制后粘贴到 OpenClaw 终端执行。
+      {{tokenCopySuccessHint}}
+      <button class="success-copy-btn" type="button" (click)="copyToken()">{{tokenCopyButtonText}}</button>
     </div>
     </div>
     <button class="back-btn" (click)="handleDone()">完成</button>
     <button class="back-btn" (click)="handleDone()">完成</button>
   </div>
   </div>

+ 641 - 2
src/app/app.scss

@@ -169,11 +169,499 @@
   position: relative;
   position: relative;
   z-index: 1;
   z-index: 1;
   width: 100%;
   width: 100%;
-  max-width: 720px;
+  max-width: 1080px;
   padding: 32px 24px;
   padding: 32px 24px;
   flex: 1;
   flex: 1;
 }
 }
 
 
+/* Checkout */
+.checkout-shell {
+  display: grid;
+  grid-template-columns: minmax(0, 1fr) 340px;
+  gap: 22px;
+  align-items: start;
+  margin-bottom: 32px;
+}
+.checkout-main,
+.checkout-summary {
+  border: 1px solid rgba(255,255,255,0.08);
+  border-radius: 10px;
+  background:
+    linear-gradient(145deg, rgba(255,255,255,0.045), rgba(0,0,0,0.28)),
+    rgba(20,20,20,0.88);
+  box-shadow:
+    6px 6px 14px rgba(0,0,0,0.58),
+    -4px -4px 10px rgba(255,255,255,0.035);
+}
+.checkout-main {
+  padding: 26px;
+}
+.checkout-summary {
+  position: sticky;
+  top: 18px;
+  padding: 22px;
+  border-color: rgba(0,212,255,0.18);
+}
+.checkout-kicker,
+.checkout-section-title {
+  color: var(--neon);
+  font-size: 12px;
+  font-weight: 850;
+  letter-spacing: 1.6px;
+}
+.checkout-heading {
+  display: flex;
+  justify-content: space-between;
+  gap: 18px;
+  margin-top: 8px;
+  padding-bottom: 22px;
+  border-bottom: 1px solid rgba(255,255,255,0.07);
+}
+.checkout-heading h1 {
+  margin: 0;
+  color: var(--text-bright);
+  font-size: 30px;
+  line-height: 1.18;
+  font-weight: 850;
+}
+.checkout-heading p {
+  max-width: 620px;
+  margin: 10px 0 0;
+  color: var(--text-dim);
+  font-size: 14px;
+  line-height: 1.65;
+}
+.checkout-badge {
+  align-self: flex-start;
+  padding: 4px 9px;
+  border: 1px solid rgba(0,212,255,0.34);
+  border-radius: 6px;
+  color: var(--neon);
+  background: rgba(0,212,255,0.07);
+  font-size: 11px;
+  font-weight: 800;
+}
+.service-select-block {
+  margin-top: 22px;
+}
+.service-select-block label {
+  display: block;
+  margin-bottom: 8px;
+  color: var(--text);
+  font-size: 13px;
+  font-weight: 750;
+}
+.service-select-wrap {
+  position: relative;
+}
+.service-select-wrap select {
+  width: 100%;
+  min-height: 50px;
+  padding: 0 42px 0 14px;
+  border: 1px solid rgba(0,212,255,0.22);
+  border-radius: 8px;
+  outline: none;
+  color: var(--text-bright);
+  background: rgba(8,8,8,0.82);
+  box-shadow: inset 2px 2px 5px rgba(0,0,0,0.45);
+  font: inherit;
+  font-size: 14px;
+  cursor: pointer;
+  appearance: none;
+}
+.service-select-wrap select:focus {
+  border-color: var(--neon);
+  box-shadow:
+    inset 2px 2px 5px rgba(0,0,0,0.45),
+    0 0 0 3px rgba(0,212,255,0.1);
+}
+.select-chevron {
+  position: absolute;
+  right: 14px;
+  top: 50%;
+  width: 18px;
+  height: 18px;
+  color: var(--neon);
+  pointer-events: none;
+  transform: translateY(-50%);
+}
+.service-meta {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 10px;
+  margin-top: 10px;
+  color: var(--text-dim);
+  font-size: 12px;
+}
+.service-meta span {
+  padding: 5px 8px;
+  border: 1px solid rgba(255,255,255,0.07);
+  border-radius: 6px;
+  background: rgba(255,255,255,0.035);
+}
+.service-meta strong {
+  color: var(--neon);
+}
+.checkout-section-title {
+  margin-top: 24px;
+  margin-bottom: 12px;
+}
+.checkout-tiers {
+  grid-template-columns: repeat(3, minmax(0, 1fr));
+  margin-bottom: 0;
+}
+.checkout-tiers .tier-card {
+  min-height: 136px;
+  text-align: left;
+  color: inherit;
+  font-family: inherit;
+  border-radius: 10px;
+}
+.checkout-tiers .tier-card.active,
+.checkout-tiers .tier-card.active.neu-pressed {
+  color: var(--text-bright);
+  border-color: var(--neon);
+  background:
+    linear-gradient(145deg, rgba(0,212,255,0.13), rgba(0,0,0,0.34)),
+    #111;
+  box-shadow:
+    inset 0 0 0 1px rgba(0,212,255,0.22),
+    4px 4px 10px rgba(0,0,0,0.68),
+    -3px -3px 8px rgba(255,255,255,0.035),
+    0 0 22px rgba(0,212,255,0.18);
+}
+.checkout-tiers .tier-card.active .tier-count {
+  color: var(--text-bright);
+}
+.checkout-tiers .tier-card.active .tier-unit-price {
+  color: var(--text);
+  background: rgba(0,0,0,0.24);
+}
+.tier-card-head {
+  display: flex;
+  justify-content: space-between;
+  align-items: flex-start;
+  gap: 8px;
+}
+.tier-choice-mark {
+  padding: 2px 7px;
+  border-radius: 5px;
+  color: #001216;
+  background: var(--neon);
+  font-size: 11px;
+  font-weight: 850;
+}
+.summary-title {
+  color: var(--text-bright);
+  font-size: 18px;
+  font-weight: 850;
+}
+.summary-service {
+  margin: 8px 0 18px;
+  color: var(--text-dim);
+  font-size: 13px;
+  line-height: 1.5;
+}
+.summary-line,
+.summary-total {
+  display: flex;
+  justify-content: space-between;
+  gap: 16px;
+  padding: 12px 0;
+  border-top: 1px solid rgba(255,255,255,0.07);
+  color: var(--text-dim);
+  font-size: 13px;
+}
+.summary-line strong {
+  color: var(--text-bright);
+  font-weight: 750;
+  white-space: nowrap;
+}
+.summary-total {
+  align-items: flex-end;
+  margin-top: 6px;
+}
+.summary-total strong {
+  color: var(--neon);
+  font-size: 34px;
+  line-height: 1;
+  font-weight: 900;
+}
+.summary-total .symbol {
+  font-size: 18px;
+}
+.payment-method {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  margin: 18px 0;
+  padding: 13px;
+  border: 1px solid rgba(0,255,136,0.18);
+  border-radius: 8px;
+  background: rgba(0,255,136,0.055);
+  color: var(--text-bright);
+  font-size: 13px;
+  font-weight: 750;
+}
+.payment-method small {
+  display: block;
+  margin-top: 3px;
+  color: var(--text-dim);
+  font-size: 12px;
+  font-weight: 500;
+}
+.method-icon {
+  width: 34px;
+  height: 34px;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  border-radius: 8px;
+  color: var(--success);
+  background: rgba(0,255,136,0.09);
+}
+.method-icon svg {
+  width: 19px;
+  height: 19px;
+}
+.checkout-pay-btn {
+  margin-top: 4px;
+  color: #001216;
+  background: linear-gradient(135deg, var(--neon), #66eeff);
+  box-shadow: 0 0 26px rgba(0,212,255,0.24);
+  font-weight: 850;
+}
+.checkout-note {
+  margin-top: 12px;
+  color: var(--text-dim);
+  font-size: 12px;
+  line-height: 1.55;
+}
+
+/* ─── Launcher Download Page ─── */
+.launcher-page {
+  position: relative;
+  z-index: 1;
+  width: 100%;
+  max-width: 1120px;
+  padding: 54px 28px 72px;
+}
+.launcher-hero {
+  min-height: 430px;
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  border-bottom: 1px solid rgba(0,212,255,0.12);
+}
+.launcher-brand {
+  display: flex;
+  align-items: center;
+  gap: 22px;
+}
+.launcher-brand img {
+  width: 86px;
+  height: 86px;
+  border-radius: 22px;
+  box-shadow: 0 0 34px rgba(0,212,255,0.18);
+}
+.launcher-kicker {
+  color: var(--neon);
+  font-size: 12px;
+  font-weight: 800;
+  letter-spacing: 2px;
+}
+.launcher-hero h1 {
+  margin: 6px 0 0;
+  color: var(--text-bright);
+  font-size: 64px;
+  line-height: 1;
+  font-weight: 850;
+}
+.launcher-subtitle {
+  max-width: 760px;
+  margin: 28px 0 0;
+  color: var(--text);
+  font-size: 19px;
+  line-height: 1.8;
+}
+.launcher-actions {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 14px;
+  margin-top: 34px;
+}
+.launcher-primary-btn,
+.launcher-secondary-btn {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  min-height: 46px;
+  padding: 0 20px;
+  border-radius: 8px;
+  font-size: 14px;
+  font-weight: 800;
+  text-decoration: none;
+  transition: transform 0.2s, box-shadow 0.2s, background 0.2s;
+}
+.launcher-primary-btn {
+  color: #001216;
+  background: linear-gradient(135deg, var(--neon), #66eeff);
+  box-shadow: 0 0 24px rgba(0,212,255,0.28);
+}
+.launcher-secondary-btn {
+  color: var(--neon);
+  border: 1px solid rgba(0,212,255,0.3);
+  background: rgba(0,212,255,0.06);
+}
+.launcher-primary-btn:hover,
+.launcher-secondary-btn:hover {
+  transform: translateY(-1px);
+  box-shadow: 0 0 28px rgba(0,212,255,0.22);
+}
+.launcher-feature-row {
+  display: grid;
+  grid-template-columns: repeat(3, minmax(0, 1fr));
+  gap: 14px;
+  margin: 28px 0 44px;
+}
+.launcher-feature {
+  padding: 18px;
+  border: 1px solid rgba(255,255,255,0.08);
+  border-radius: 8px;
+  background: rgba(20,20,20,0.72);
+  box-shadow:
+    4px 4px 8px var(--shadow-dark),
+    -4px -4px 8px var(--shadow-light);
+}
+.launcher-feature span {
+  display: block;
+  color: var(--neon);
+  font-size: 12px;
+  font-weight: 800;
+  margin-bottom: 10px;
+}
+.launcher-feature strong {
+  display: block;
+  color: var(--text-bright);
+  font-size: 17px;
+  margin-bottom: 8px;
+}
+.launcher-feature p {
+  margin: 0;
+  color: var(--text-dim);
+  font-size: 13px;
+  line-height: 1.6;
+}
+.launcher-download-section {
+  padding-top: 10px;
+}
+.launcher-section-head {
+  display: flex;
+  align-items: flex-end;
+  justify-content: space-between;
+  gap: 28px;
+  margin-bottom: 18px;
+}
+.launcher-section-head h2 {
+  margin: 4px 0 0;
+  color: var(--text-bright);
+  font-size: 30px;
+}
+.launcher-section-head p {
+  max-width: 430px;
+  margin: 0;
+  color: var(--text-dim);
+  font-size: 13px;
+  line-height: 1.7;
+}
+.launcher-download-grid {
+  display: grid;
+  grid-template-columns: repeat(3, minmax(0, 1fr));
+  gap: 16px;
+}
+.launcher-download-card {
+  min-height: 190px;
+  padding: 18px;
+  border: 1px solid rgba(255,255,255,0.08);
+  border-radius: 8px;
+  color: inherit;
+  text-decoration: none;
+  background: rgba(20,20,20,0.82);
+  background-image: linear-gradient(145deg, rgba(255,255,255,0.04), rgba(0,0,0,0.24));
+  box-shadow:
+    4px 4px 8px var(--shadow-dark),
+    -4px -4px 8px var(--shadow-light);
+  transition: transform 0.2s, border-color 0.2s, box-shadow 0.2s;
+}
+.launcher-download-card:hover,
+.launcher-download-card.primary {
+  border-color: rgba(0,212,255,0.58);
+  box-shadow:
+    4px 4px 8px var(--shadow-dark),
+    -4px -4px 8px var(--shadow-light),
+    0 0 24px rgba(0,212,255,0.12);
+}
+.launcher-download-card:hover {
+  transform: translateY(-2px);
+}
+.launcher-download-top {
+  display: flex;
+  align-items: flex-start;
+  justify-content: space-between;
+  gap: 12px;
+}
+.launcher-os-icon {
+  width: 48px;
+  height: 48px;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  border-radius: 8px;
+  background: rgba(0,212,255,0.08);
+  color: var(--neon);
+}
+.launcher-os-icon svg {
+  width: 28px;
+  height: 28px;
+}
+.launcher-os-icon.windows {
+  color: #45b9ff;
+  background: rgba(69,185,255,0.1);
+}
+.launcher-os-icon.macos {
+  color: #f6f7fb;
+  background: rgba(255,255,255,0.09);
+}
+.launcher-os-icon.linux {
+  color: var(--success);
+  background: rgba(0,255,136,0.09);
+}
+.launcher-chip {
+  padding: 3px 8px;
+  border-radius: 6px;
+  color: #001216;
+  background: var(--neon);
+  font-size: 11px;
+  font-weight: 800;
+}
+.launcher-download-title {
+  margin-top: 24px;
+  color: var(--text-bright);
+  font-size: 20px;
+  font-weight: 800;
+}
+.launcher-download-meta {
+  margin-top: 5px;
+  color: var(--text-dim);
+  font-size: 13px;
+}
+.launcher-download-cta {
+  margin-top: 22px;
+  color: var(--neon);
+  font-size: 13px;
+  font-weight: 800;
+}
+
 /* ─── API Service List ─── */
 /* ─── API Service List ─── */
 .apig-list-section {
 .apig-list-section {
   margin-bottom: 24px;
   margin-bottom: 24px;
@@ -497,6 +985,31 @@
   border-color: var(--neon);
   border-color: var(--neon);
   background: rgba(0,212,255,0.14);
   background: rgba(0,212,255,0.14);
 }
 }
+.route-back-btn {
+  flex: 0 0 auto;
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+  min-height: 34px;
+  padding: 0 12px;
+  border: 1px solid rgba(0,212,255,0.24);
+  border-radius: 8px;
+  color: var(--neon);
+  background: rgba(0,212,255,0.06);
+  font-size: 13px;
+  font-weight: 700;
+  cursor: pointer;
+  transition: border-color 0.2s, background 0.2s, transform 0.2s;
+}
+.route-back-btn svg {
+  width: 15px;
+  height: 15px;
+}
+.route-back-btn:hover {
+  border-color: var(--neon);
+  background: rgba(0,212,255,0.12);
+  transform: translateY(-1px);
+}
 .workshop-portal {
 .workshop-portal {
   margin-bottom: 28px;
   margin-bottom: 28px;
 }
 }
@@ -593,6 +1106,9 @@
   margin-bottom: 24px;
   margin-bottom: 24px;
   border: 1px solid rgba(0,212,255,0.12);
   border: 1px solid rgba(0,212,255,0.12);
 }
 }
+.workshop-detail-back {
+  margin-bottom: 18px;
+}
 .workshop-kicker {
 .workshop-kicker {
   font-size: 12px;
   font-size: 12px;
   font-weight: 700;
   font-weight: 700;
@@ -672,6 +1188,13 @@
   border: 1px solid rgba(255,255,255,0.07);
   border: 1px solid rgba(255,255,255,0.07);
   border-radius: 8px;
   border-radius: 8px;
   background: rgba(0,0,0,0.18);
   background: rgba(0,0,0,0.18);
+  display: flex;
+  align-items: flex-start;
+  justify-content: space-between;
+  gap: 14px;
+}
+.quota-main {
+  min-width: 0;
 }
 }
 .quota-title {
 .quota-title {
   font-size: 14px;
   font-size: 14px;
@@ -686,10 +1209,26 @@
   color: var(--success);
   color: var(--success);
 }
 }
 .quota-balance {
 .quota-balance {
-  margin-top: 4px;
+  flex: 0 0 auto;
+  min-width: 84px;
+  padding: 6px 8px;
+  text-align: right;
+  border: 1px solid rgba(0,212,255,0.14);
+  border-radius: 6px;
+  background: rgba(0,212,255,0.04);
   font-size: 12px;
   font-size: 12px;
   color: var(--text-dim);
   color: var(--text-dim);
 }
 }
+.quota-balance span {
+  display: block;
+  margin-bottom: 3px;
+}
+.quota-balance strong {
+  display: block;
+  color: var(--neon);
+  font-size: 16px;
+  line-height: 1.2;
+}
 .workshop-includes {
 .workshop-includes {
   display: flex;
   display: flex;
   flex-wrap: wrap;
   flex-wrap: wrap;
@@ -926,6 +1465,28 @@
   font-size: 14px;
   font-size: 14px;
   line-height: 1.7;
   line-height: 1.7;
 }
 }
+.success-screen .success-copy-btn {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  min-height: 34px;
+  margin-top: 12px;
+  padding: 0 14px;
+  border: 1px solid rgba(102,238,255,0.72);
+  border-radius: 7px;
+  color: #001216;
+  background: linear-gradient(135deg, var(--neon), #66eeff);
+  box-shadow: 0 0 18px rgba(0,212,255,0.22);
+  font-size: 13px;
+  font-weight: 850;
+  cursor: pointer;
+  transition: transform 0.2s, box-shadow 0.2s, filter 0.2s;
+}
+.success-screen .success-copy-btn:hover {
+  filter: brightness(1.08);
+  transform: translateY(-1px);
+  box-shadow: 0 0 24px rgba(0,212,255,0.36);
+}
 .success-screen .back-btn {
 .success-screen .back-btn {
   display: inline-block;
   display: inline-block;
   padding: 12px 40px;
   padding: 12px 40px;
@@ -1135,8 +1696,79 @@
 }
 }
 
 
 /* ─── Responsive ─── */
 /* ─── Responsive ─── */
+@media (max-width: 860px) {
+  .checkout-shell {
+    grid-template-columns: 1fr;
+  }
+  .checkout-summary {
+    position: static;
+  }
+  .checkout-tiers {
+    grid-template-columns: 1fr;
+  }
+}
+
 @media (max-width: 480px) {
 @media (max-width: 480px) {
+  .page-header {
+    padding: 16px;
+    align-items: flex-start;
+    flex-wrap: wrap;
+  }
+  .launcher-page { padding: 34px 16px 52px; }
+  .launcher-hero { min-height: 0; padding-bottom: 36px; }
+  .launcher-brand {
+    align-items: flex-start;
+    gap: 14px;
+  }
+  .launcher-brand img {
+    width: 58px;
+    height: 58px;
+    border-radius: 16px;
+  }
+  .launcher-hero h1 { font-size: 36px; }
+  .launcher-subtitle {
+    font-size: 16px;
+    line-height: 1.7;
+  }
+  .launcher-actions,
+  .launcher-primary-btn,
+  .launcher-secondary-btn {
+    width: 100%;
+  }
+  .launcher-feature-row,
+  .launcher-download-grid {
+    grid-template-columns: 1fr;
+  }
+  .launcher-section-head {
+    flex-direction: column;
+    align-items: flex-start;
+  }
+  .launcher-section-head h2 { font-size: 24px; }
   .main-container { padding: 20px 16px; }
   .main-container { padding: 20px 16px; }
+  .checkout-shell {
+    grid-template-columns: 1fr;
+    gap: 16px;
+  }
+  .checkout-main,
+  .checkout-summary {
+    padding: 18px;
+  }
+  .checkout-summary {
+    position: static;
+  }
+  .checkout-heading {
+    flex-direction: column;
+    gap: 12px;
+  }
+  .checkout-heading h1 {
+    font-size: 24px;
+  }
+  .checkout-tiers {
+    grid-template-columns: 1fr;
+  }
+  .summary-total strong {
+    font-size: 30px;
+  }
   .api-info-card { padding: 20px; }
   .api-info-card { padding: 20px; }
   .tier-grid { grid-template-columns: 1fr; }
   .tier-grid { grid-template-columns: 1fr; }
   .workshop-entry,
   .workshop-entry,
@@ -1144,6 +1776,7 @@
     flex-direction: column;
     flex-direction: column;
     align-items: flex-start;
     align-items: flex-start;
   }
   }
+  .route-back-btn { width: 100%; justify-content: center; }
   .workshop-entry-btn { width: 100%; }
   .workshop-entry-btn { width: 100%; }
   .workshop-package-grid { grid-template-columns: 1fr; }
   .workshop-package-grid { grid-template-columns: 1fr; }
   .workshop-package-card { min-height: 0; }
   .workshop-package-card { min-height: 0; }
@@ -1157,6 +1790,12 @@
   .workshop-price { font-size: 36px; }
   .workshop-price { font-size: 36px; }
   .workshop-total-quota { text-align: left; }
   .workshop-total-quota { text-align: left; }
   .workshop-quota-grid { grid-template-columns: 1fr; }
   .workshop-quota-grid { grid-template-columns: 1fr; }
+  .workshop-quota-item {
+    align-items: stretch;
+  }
+  .quota-balance {
+    min-width: 92px;
+  }
   .modal-box { padding: 24px 20px; }
   .modal-box { padding: 24px 20px; }
   .order-table { font-size: 12px; }
   .order-table { font-size: 12px; }
   .order-table thead th,
   .order-table thead th,

+ 363 - 26
src/app/app.ts

@@ -54,10 +54,81 @@ interface ApigData {
   [key: string]: any;
   [key: string]: any;
 }
 }
 
 
+interface TokenCopyProfile {
+  title: string;
+  buttonText: string;
+  copiedText: string;
+  checkoutNote: string;
+  successHint: string;
+  buildCopyText: (token: string, companyId?: string) => string;
+}
+
 // VOC 技能体系相关的 APIG — 一键切换
 // VOC 技能体系相关的 APIG — 一键切换
 const SOCIAL_APIG_ID = 'Vo3ROWEvDy';
 const SOCIAL_APIG_ID = 'Vo3ROWEvDy';
 const ECOMMERCE_APIG_ID = '7HwdQZk55B';
 const ECOMMERCE_APIG_ID = '7HwdQZk55B';
-const VOC_APIG_IDS = [SOCIAL_APIG_ID, ECOMMERCE_APIG_ID];
+const TIHAO_ECOMMERCE_APIG_ID = 'G0vmsUI44d';
+const TIHAO_ECOMMERCE_APIG_PRICE_STEP: PriceTier[] = [
+  { count: 1000, price: 300 },
+  { count: 3000, price: 750 },
+  { count: 10000, price: 2000 }
+];
+const APIG_ALIASES: Record<string, string> = {
+  'voc-e-commerce': TIHAO_ECOMMERCE_APIG_ID,
+  '/apig/voc-e-commerce': TIHAO_ECOMMERCE_APIG_ID
+};
+const VOC_APIG_IDS = [SOCIAL_APIG_ID, ECOMMERCE_APIG_ID, TIHAO_ECOMMERCE_APIG_ID];
+
+const SOCIAL_TOKEN_COPY_PROFILE: TokenCopyProfile = {
+  title: '复制社媒数据中台配置话术',
+  buttonText: '复制社媒配置话术',
+  copiedText: '已复制社媒话术',
+  checkoutNote: '支付成功后,点击“复制社媒配置话术”完成社媒技能包工作区配置。',
+  successHint: '下一步:点击右上角「复制社媒配置话术」,将 VOC_TOKEN 写入当前工作区 .env.local 后,再运行小红书或抖音 live 采集。',
+  buildCopyText: (token: string) => [
+    '请把下面内容保存到当前工作区的 .env.local,用于社媒数据中台技能包的小红书/抖音 live 采集。',
+    '完成后重启或刷新 Claude Code,再运行 live 采集任务。',
+    '不要提交到 Git,也不要放进截图、报告或聊天记录。',
+    '',
+    `VOC_TOKEN=${token}`,
+    `VOC_SOCIAL_TOKEN=${token}`
+  ].join('\n')
+};
+
+const TIHAO_TOKEN_COPY_PROFILE: TokenCopyProfile = {
+  title: '复制提号技能包配置话术',
+  buttonText: '复制提号配置话术',
+  copiedText: '已复制提号话术',
+  checkoutNote: '支付成功后,点击“复制提号配置话术”完成提号技能包工作区配置。',
+  successHint: '下一步:点击右上角「复制提号配置话术」,将 TIHAO_SESSION_TOKEN 写入提号项目工作区 .env.local 后,再运行提号或找博主任务。',
+  buildCopyText: (token: string, companyId?: string) => [
+    '请把下面内容保存到当前工作区的 .env.local,用于提号技能包调用国内电商数据中台 live 模式。',
+    '完成后重启或刷新 Claude Code,再运行“提号 / 找博主 / 读取 brief”的 live 任务。',
+    '不要提交到 Git,也不要放进截图、报告或聊天记录。',
+    '',
+    `TIHAO_SESSION_TOKEN=${token}`,
+    ...(companyId ? [`TIHAO_COMPANY=${companyId}`] : [])
+  ].join('\n')
+};
+
+const DEFAULT_TOKEN_COPY_PROFILE: TokenCopyProfile = {
+  title: '复制 API 服务配置话术',
+  buttonText: '复制服务配置话术',
+  copiedText: '已复制配置话术',
+  checkoutNote: '支付成功后,点击“复制服务配置话术”完成对应工作区配置。',
+  successHint: '下一步:点击右上角「复制服务配置话术」,按对应技能包要求写入当前工作区 .env.local。',
+  buildCopyText: (token: string) => [
+    '请把下面内容保存到当前工作区的 .env.local,并按对应技能包要求使用这个 APIG 授权 token。',
+    '不要提交到 Git,也不要放进截图、报告或聊天记录。',
+    '',
+    `FMODE_APIG_SESSION_TOKEN=${token}`
+  ].join('\n')
+};
+
+const TOKEN_COPY_PROFILES: Record<string, TokenCopyProfile> = {
+  [SOCIAL_APIG_ID]: SOCIAL_TOKEN_COPY_PROFILE,
+  [ECOMMERCE_APIG_ID]: TIHAO_TOKEN_COPY_PROFILE,
+  [TIHAO_ECOMMERCE_APIG_ID]: TIHAO_TOKEN_COPY_PROFILE
+};
 
 
 const WORKSHOP_PACKAGES: Record<string, WorkshopPackage> = {
 const WORKSHOP_PACKAGES: Record<string, WorkshopPackage> = {
   '19-9': {
   '19-9': {
@@ -77,18 +148,17 @@ const WORKSHOP_PACKAGES: Record<string, WorkshopPackage> = {
   },
   },
   '29-9': {
   '29-9': {
     slug: '29-9',
     slug: '29-9',
-    title: '课程加量体验包',
-    badge: '课程专享',
-    subtitle: '给课程学员使用的加量练习包,适合课上多跑几轮真实案例。',
-    scene: '课程学习',
+    title: '轻量级工作坊',
+    badge: '轻量入门',
+    subtitle: '适合轻量级工作坊和短课实操,只开通社媒数据中台额度。',
+    scene: '轻量工作坊',
     price: 29.9,
     price: 29.9,
-    visible: false,
+    visible: true,
     primaryApigId: SOCIAL_APIG_ID,
     primaryApigId: SOCIAL_APIG_ID,
     allocations: [
     allocations: [
-      { apigId: SOCIAL_APIG_ID, title: '国内外社媒数据中台', count: 150 },
-      { apigId: ECOMMERCE_APIG_ID, title: '电商数据中台', count: 150 }
+      { apigId: SOCIAL_APIG_ID, title: '国内外社媒数据中台', count: 300 }
     ],
     ],
-    includes: ['课堂练习额度', '基础情报官模板', '不含复盘服务']
+    includes: ['社媒数据额度', '工作坊轻量练习', '不含电商数据中台额度']
   },
   },
   '99': {
   '99': {
     slug: '99',
     slug: '99',
@@ -162,6 +232,56 @@ interface OrderData {
   [key: string]: any;
   [key: string]: any;
 }
 }
 
 
+interface LauncherDownload {
+  os: 'Windows' | 'macOS' | 'Linux';
+  arch: string;
+  label: string;
+  url: string;
+  primary?: boolean;
+}
+
+const LAUNCHER_DOWNLOADS: LauncherDownload[] = [
+  {
+    os: 'Windows',
+    arch: 'x64',
+    label: 'Windows x64',
+    url: 'https://repos.fmode.cn/x/launcher/FmodeStudioLauncher-win-x64.exe',
+    primary: true
+  },
+  {
+    os: 'Windows',
+    arch: 'ARM64',
+    label: 'Windows ARM64',
+    url: 'https://repos.fmode.cn/x/launcher/FmodeStudioLauncher-win-arm64.exe'
+  },
+  {
+    os: 'macOS',
+    arch: 'Apple Silicon',
+    label: 'macOS Apple Silicon',
+    url: 'https://repos.fmode.cn/x/launcher/fmode-studio-launcher-macos-arm64',
+    primary: true
+  },
+  {
+    os: 'macOS',
+    arch: 'Intel',
+    label: 'macOS Intel',
+    url: 'https://repos.fmode.cn/x/launcher/fmode-studio-launcher-macos-x64'
+  },
+  {
+    os: 'Linux',
+    arch: 'x64',
+    label: 'Linux x64',
+    url: 'https://repos.fmode.cn/x/launcher/fmode-studio-launcher-linux-x64'
+  },
+  {
+    os: 'Linux',
+    arch: 'ARM64',
+    label: 'Linux ARM64',
+    url: 'https://repos.fmode.cn/x/launcher/fmode-studio-launcher-linux-arm64'
+  }
+];
+const LAUNCHER_URL_LIST = 'https://repos.fmode.cn/x/launcher/url.txt';
+
 @Component({
 @Component({
   selector: 'app-root',
   selector: 'app-root',
   imports: [CommonModule, LoginModalComponent],
   imports: [CommonModule, LoginModalComponent],
@@ -182,6 +302,7 @@ export class App implements OnInit {
   workshopPackage: WorkshopPackage | null = null;
   workshopPackage: WorkshopPackage | null = null;
   workshopPortal = false;
   workshopPortal = false;
   workshopPreparing = false;
   workshopPreparing = false;
+  isLauncherPage = false;
 
 
   // Login state
   // Login state
   needLogin = false;
   needLogin = false;
@@ -214,6 +335,8 @@ export class App implements OnInit {
   orderError = '';
   orderError = '';
   hasMoreOrders = false;
   hasMoreOrders = false;
   orderSkip = 0;
   orderSkip = 0;
+  launcherDownloads = LAUNCHER_DOWNLOADS;
+  launcherUrlList = LAUNCHER_URL_LIST;
 
 
   get selectedTier(): PriceTier | null {
   get selectedTier(): PriceTier | null {
     if (this.workshopPackage) {
     if (this.workshopPackage) {
@@ -227,6 +350,16 @@ export class App implements OnInit {
     return this.workshopPackage?.allocations.reduce((sum, item) => sum + item.count, 0) || 0;
     return this.workshopPackage?.allocations.reduce((sum, item) => sum + item.count, 0) || 0;
   }
   }
 
 
+  get workshopQuotaLabel(): string {
+    if (!this.workshopPackage) return '数据中台运行额度';
+    return this.workshopPackage.allocations.length > 1 ? '双数据中台运行额度' : this.workshopPackage.allocations[0].title + '额度';
+  }
+
+  get workshopArrivalLabel(): string {
+    if (!this.workshopPackage) return '数据中台到账';
+    return this.workshopPackage.allocations.length > 1 ? '两个数据中台分别到账' : this.workshopPackage.allocations[0].title + '到账';
+  }
+
   get visibleWorkshopPackages(): WorkshopPackage[] {
   get visibleWorkshopPackages(): WorkshopPackage[] {
     return Object.values(WORKSHOP_PACKAGES).filter(pkg => pkg.visible);
     return Object.values(WORKSHOP_PACKAGES).filter(pkg => pkg.visible);
   }
   }
@@ -236,6 +369,7 @@ export class App implements OnInit {
   }
   }
 
 
   get pageSubtitle(): string {
   get pageSubtitle(): string {
+    if (this.isLauncherPage) return 'Studio 启动器下载';
     return this.isWorkshopMode ? '情报官技能套餐' : 'API 服务充值';
     return this.isWorkshopMode ? '情报官技能套餐' : 'API 服务充值';
   }
   }
 
 
@@ -244,15 +378,88 @@ export class App implements OnInit {
     return (this.apig.objectId === 'MYM5zJBKgw' || this.apig.objectId === 'FQtTgjcqIZ') ? 'token' : '次';
     return (this.apig.objectId === 'MYM5zJBKgw' || this.apig.objectId === 'FQtTgjcqIZ') ? 'token' : '次';
   }
   }
 
 
+  get currentApigFromList(): ApigData | null {
+    return this.apigList.find(item => this.isCurrentApig(item.objectId)) || this.apig;
+  }
+
+  get selectedUnitPrice(): string {
+    const tier = this.selectedTier;
+    if (!tier?.count) return '0.000';
+    return (tier.price / tier.count).toFixed(3);
+  }
+
+  get tokenCopyProfile(): TokenCopyProfile {
+    const apigId = this.normalizeApigId(this.apig?.objectId || this.apigId || this.workshopPackage?.primaryApigId || '');
+    return TOKEN_COPY_PROFILES[apigId] || DEFAULT_TOKEN_COPY_PROFILE;
+  }
+
+  get tokenCopyTitle(): string {
+    return this.tokenCopyProfile.title;
+  }
+
+  get tokenCopyButtonText(): string {
+    return this.tokenCopied ? this.tokenCopyProfile.copiedText : this.tokenCopyProfile.buttonText;
+  }
+
+  get tokenCopyCheckoutNote(): string {
+    return this.tokenCopyProfile.checkoutNote;
+  }
+
+  get tokenCopySuccessHint(): string {
+    return this.tokenCopyProfile.successHint;
+  }
+
+  normalizeApigId(apigId: string): string {
+    return APIG_ALIASES[apigId] || apigId;
+  }
+
+  // 支付回调云函数 id(fun_id)必须是充值云函数 apigRechargeOnPay 的 Parse objectId
+  // (HOkkX72PMF)。历史链接曾把 APIG 的别名 / path / objectId(如 voc-e-commerce)误当作
+  // fun_id 传入,导致微信回调按 fun_id 找不到云函数、静默不入账。统一在此回退默认值。
+  resolveFunId(rawFunId: string | null): string {
+    const value = (rawFunId || '').trim();
+    if (!value) {
+      return DEFAULT_FUN_ID;
+    }
+    const isApigAlias = Object.prototype.hasOwnProperty.call(APIG_ALIASES, value);
+    const isApigId = VOC_APIG_IDS.includes(value);
+    const looksLikeApigPath = value.startsWith('/apig/') || value.startsWith('apig/') || value.includes('voc-');
+    if (isApigAlias || isApigId || looksLikeApigPath) {
+      console.warn('[PAY] 非法 fun_id(疑似 APIG 别名/path/objectId),回退默认充值云函数:', value, '->', DEFAULT_FUN_ID);
+      return DEFAULT_FUN_ID;
+    }
+    return value;
+  }
+
+  decorateApig(apig: ApigData): ApigData {
+    const normalizedId = this.normalizeApigId(apig.objectId);
+    if (normalizedId === TIHAO_ECOMMERCE_APIG_ID) {
+      return {
+        ...apig,
+        objectId: TIHAO_ECOMMERCE_APIG_ID,
+        title: '国内电商数据中台',
+        content: '电商与达人数据接口,适合提号选品、达人筛选和电商情报检索。',
+        priceStep: TIHAO_ECOMMERCE_APIG_PRICE_STEP.map(item => ({ ...item }))
+      };
+    }
+    return apig;
+  }
+
   ngOnInit(): void {
   ngOnInit(): void {
+    this.isLauncherPage = window.location.pathname.includes('/launcher') || window.location.hash === '#/launcher';
+    if (this.isLauncherPage) {
+      this.loadLauncherDownloads();
+      return;
+    }
+
     const params = new URLSearchParams(window.location.search);
     const params = new URLSearchParams(window.location.search);
     this.syncWorkshopRouteFromUrl(false);
     this.syncWorkshopRouteFromUrl(false);
     window.addEventListener('hashchange', this.routeChangeHandler);
     window.addEventListener('hashchange', this.routeChangeHandler);
     window.addEventListener('popstate', this.routeChangeHandler);
     window.addEventListener('popstate', this.routeChangeHandler);
     this.authId = params.get('authid') || '';
     this.authId = params.get('authid') || '';
     this.userId = params.get('user') || params.get('userid') || '';
     this.userId = params.get('user') || params.get('userid') || '';
-    this.apigId = params.get('apigid') || this.workshopPackage?.primaryApigId || SOCIAL_APIG_ID;
-    this.funId = params.get('fun_id') || DEFAULT_FUN_ID;
+    this.apigId = this.normalizeApigId(params.get('apigid') || this.workshopPackage?.primaryApigId || SOCIAL_APIG_ID);
+    this.funId = this.resolveFunId(params.get('fun_id'));
     const urlToken = params.get('token') || '';
     const urlToken = params.get('token') || '';
 
 
     // 0. URL 携带 token → 使用 Parse become 登录
     // 0. URL 携带 token → 使用 Parse become 登录
@@ -293,6 +500,66 @@ export class App implements OnInit {
     window.removeEventListener('popstate', this.routeChangeHandler);
     window.removeEventListener('popstate', this.routeChangeHandler);
   }
   }
 
 
+  async loadLauncherDownloads(): Promise<void> {
+    try {
+      const resp = await fetch(LAUNCHER_URL_LIST, { cache: 'no-store' });
+      if (!resp.ok) return;
+      const text = await resp.text();
+      const parsed = this.parseLauncherDownloads(text);
+      if (parsed.length > 0) {
+        this.launcherDownloads = parsed;
+        this.cdr.detectChanges();
+      }
+    } catch (e: any) {
+      console.warn('[LAUNCHER] 下载链接清单加载失败,使用内置链接:', e.message);
+    }
+  }
+
+  parseLauncherDownloads(text: string): LauncherDownload[] {
+    const downloads = text
+      .split(/\r?\n/)
+      .map(line => line.trim())
+      .filter(line => line.startsWith('http'))
+      .map(url => this.toLauncherDownload(url))
+      .filter((item): item is LauncherDownload => !!item);
+
+    const rank = (item: LauncherDownload): number => {
+      const key = item.os + '-' + item.arch;
+      return {
+        'Windows-x64': 1,
+        'Windows-ARM64': 2,
+        'macOS-Apple Silicon': 3,
+        'macOS-Intel': 4,
+        'Linux-x64': 5,
+        'Linux-ARM64': 6
+      }[key] || 99;
+    };
+    return downloads.sort((a, b) => rank(a) - rank(b));
+  }
+
+  toLauncherDownload(url: string): LauncherDownload | null {
+    const lower = url.toLowerCase();
+    if (lower.includes('win-x64')) {
+      return { os: 'Windows', arch: 'x64', label: 'Windows x64', url, primary: true };
+    }
+    if (lower.includes('win-arm64')) {
+      return { os: 'Windows', arch: 'ARM64', label: 'Windows ARM64', url };
+    }
+    if (lower.includes('macos-arm64')) {
+      return { os: 'macOS', arch: 'Apple Silicon', label: 'macOS Apple Silicon', url, primary: true };
+    }
+    if (lower.includes('macos-x64')) {
+      return { os: 'macOS', arch: 'Intel', label: 'macOS Intel', url };
+    }
+    if (lower.includes('linux-x64')) {
+      return { os: 'Linux', arch: 'x64', label: 'Linux x64', url };
+    }
+    if (lower.includes('linux-arm64')) {
+      return { os: 'Linux', arch: 'ARM64', label: 'Linux ARM64', url };
+    }
+    return null;
+  }
+
   /**
   /**
    * 通过 URL 中的 token 调用 Parse Server 的 become(fetch /parse/users/me)完成登录
    * 通过 URL 中的 token 调用 Parse Server 的 become(fetch /parse/users/me)完成登录
    */
    */
@@ -422,7 +689,9 @@ export class App implements OnInit {
         headers: { 'X-Parse-Application-Id': APP_ID }
         headers: { 'X-Parse-Application-Id': APP_ID }
       });
       });
       const data = await resp.json();
       const data = await resp.json();
-      const apigs: ApigData[] = data.results || [];
+      const apigs: ApigData[] = (data.results || [])
+        .map((item: ApigData) => this.decorateApig(item))
+        .sort((a: ApigData, b: ApigData) => VOC_APIG_IDS.indexOf(a.objectId) - VOC_APIG_IDS.indexOf(b.objectId));
 
 
       // 2. 合并当前用户的 APIGAuth(如果已登录)
       // 2. 合并当前用户的 APIGAuth(如果已登录)
       if (this.userId && this.sessionToken) {
       if (this.userId && this.sessionToken) {
@@ -476,29 +745,31 @@ export class App implements OnInit {
   }
   }
 
 
   isCurrentApig(apigId: string): boolean {
   isCurrentApig(apigId: string): boolean {
-    return !!this.apigId && this.apigId === apigId;
+    return !!this.apigId && this.normalizeApigId(this.apigId) === this.normalizeApigId(apigId);
   }
   }
 
 
   navigateToApig(apigId: string): void {
   navigateToApig(apigId: string): void {
     if (this.isCurrentApig(apigId)) return;
     if (this.isCurrentApig(apigId)) return;
     const url = new URL(window.location.href);
     const url = new URL(window.location.href);
-    url.searchParams.set('apigid', apigId);
+    url.searchParams.set('apigid', this.normalizeApigId(apigId));
     // 切换 APIG 时清空 authid(因为换了 APIG 就要重新解析新的 APIGAuth)
     // 切换 APIG 时清空 authid(因为换了 APIG 就要重新解析新的 APIGAuth)
     url.searchParams.delete('authid');
     url.searchParams.delete('authid');
     window.location.href = url.toString();
     window.location.href = url.toString();
   }
   }
 
 
+  onApigSelect(value: string): void {
+    this.navigateToApig(value);
+  }
+
   copyToken(): void {
   copyToken(): void {
     if (!this.sessionToken) return;
     if (!this.sessionToken) return;
-    // 复制一整条可直接粘贴到终端执行的命令,把 token 写入 OpenClaw 凭证文件
-    const cmd = `node set-voc-token.js ${this.sessionToken}`;
-    navigator.clipboard.writeText(cmd).then(() => {
+    const tokenText = this.tokenCopyProfile.buildCopyText(this.sessionToken, this.companyId);
+    navigator.clipboard.writeText(tokenText).then(() => {
       this.tokenCopied = true;
       this.tokenCopied = true;
       this.cdr.detectChanges();
       this.cdr.detectChanges();
       setTimeout(() => { this.tokenCopied = false; this.cdr.detectChanges(); }, 2500);
       setTimeout(() => { this.tokenCopied = false; this.cdr.detectChanges(); }, 2500);
     }).catch(() => {
     }).catch(() => {
-      // 剪贴板失败时兜底:把命令显示出来让用户手动复制
-      this.errorMsg = '复制失败,请手动复制命令:' + cmd;
+      this.errorMsg = '复制失败,请手动复制:' + tokenText;
       this.cdr.detectChanges();
       this.cdr.detectChanges();
     });
     });
   }
   }
@@ -523,7 +794,7 @@ export class App implements OnInit {
       const resp = await this.postJSON(API_BASE + '/api/apig/getApig', { authid: this.authId });
       const resp = await this.postJSON(API_BASE + '/api/apig/getApig', { authid: this.authId });
       console.log('[getApig] response:', JSON.stringify(resp).substring(0, 500));
       console.log('[getApig] response:', JSON.stringify(resp).substring(0, 500));
       if (resp.code === 200 && resp.data) {
       if (resp.code === 200 && resp.data) {
-        this.apig = resp.data;
+        this.apig = this.decorateApig(resp.data);
         this.applyTestTier();
         this.applyTestTier();
         this.selectTier(0);
         this.selectTier(0);
         this.cdr.detectChanges();
         this.cdr.detectChanges();
@@ -542,7 +813,7 @@ export class App implements OnInit {
         if (cfResp?.result) {
         if (cfResp?.result) {
           const cfData = cfResp.result.data || cfResp.result;
           const cfData = cfResp.result.data || cfResp.result;
           if (cfData?.objectId) {
           if (cfData?.objectId) {
-            this.apig = cfData;
+            this.apig = this.decorateApig(cfData);
             this.applyTestTier();
             this.applyTestTier();
             this.selectTier(0);
             this.selectTier(0);
             this.cdr.detectChanges();
             this.cdr.detectChanges();
@@ -565,6 +836,7 @@ export class App implements OnInit {
 
 
   // ─── 通过 user+apigid 查询或创建 APIGAuth ───
   // ─── 通过 user+apigid 查询或创建 APIGAuth ───
   async resolveAuthId(user: string, apig: string): Promise<string | null> {
   async resolveAuthId(user: string, apig: string): Promise<string | null> {
+    apig = this.normalizeApigId(apig);
     const authHeaders: any = { 'X-Parse-Application-Id': APP_ID };
     const authHeaders: any = { 'X-Parse-Application-Id': APP_ID };
     if (this.sessionToken) authHeaders['X-Parse-Session-Token'] = this.sessionToken;
     if (this.sessionToken) authHeaders['X-Parse-Session-Token'] = this.sessionToken;
 
 
@@ -618,9 +890,11 @@ export class App implements OnInit {
   }
   }
 
 
   applyTestTier(): void {
   applyTestTier(): void {
-    // 仅在 URL 带 test=1 时追加测试套餐(¥0.01 / 1 次),不再对正式价格做任何改写
+    // 仅本地开发时允许 URL 带 test=1 追加测试套餐(¥0.01 / 1 次),线上不开放
     const params = new URLSearchParams(window.location.search);
     const params = new URLSearchParams(window.location.search);
-    if (params.get('test') === '1' && this.apig?.priceStep && !this.apig.priceStep.some(tier => tier.isTest)) {
+    const isLocalhost = ['localhost', '127.0.0.1'].includes(window.location.hostname);
+    const isTihaoEcommerce = this.apig?.objectId === TIHAO_ECOMMERCE_APIG_ID;
+    if (isLocalhost && isTihaoEcommerce && params.get('test') === '1' && this.apig?.priceStep && !this.apig.priceStep.some(tier => tier.isTest)) {
       this.apig.priceStep.unshift({ count: 1, price: 0.01, isTest: true });
       this.apig.priceStep.unshift({ count: 1, price: 0.01, isTest: true });
     }
     }
   }
   }
@@ -629,6 +903,17 @@ export class App implements OnInit {
     this.selectedIndex = idx;
     this.selectedIndex = idx;
   }
   }
 
 
+  isLocalTestTier(tier: PriceTier | null = this.selectedTier): boolean {
+    const isLocalhost = ['localhost', '127.0.0.1'].includes(window.location.hostname);
+    return !!tier?.isTest && isLocalhost && this.apig?.objectId === TIHAO_ECOMMERCE_APIG_ID;
+  }
+
+  isStrictCallbackTest(): boolean {
+    const params = new URLSearchParams(window.location.search);
+    const isLocalhost = ['localhost', '127.0.0.1'].includes(window.location.hostname);
+    return isLocalhost && params.get('callback_test') === '1' && this.apig?.objectId === TIHAO_ECOMMERCE_APIG_ID;
+  }
+
   syncWorkshopRouteFromUrl(shouldDetectChanges = true): void {
   syncWorkshopRouteFromUrl(shouldDetectChanges = true): void {
     const params = new URLSearchParams(window.location.search);
     const params = new URLSearchParams(window.location.search);
     const nextPackage = this.resolveWorkshopPackage(params);
     const nextPackage = this.resolveWorkshopPackage(params);
@@ -707,6 +992,32 @@ export class App implements OnInit {
     this.syncWorkshopRouteFromUrl();
     this.syncWorkshopRouteFromUrl();
   }
   }
 
 
+  closeWorkshopPortal(): void {
+    const url = new URL(window.location.href);
+    url.searchParams.delete('workshop');
+    url.searchParams.delete('packages');
+    url.searchParams.delete('package');
+    url.searchParams.delete('pkg');
+    url.hash = '';
+    window.history.pushState({}, '', url.toString());
+    this.workshopPackage = null;
+    this.workshopPortal = false;
+    this.showSuccess = false;
+    this.errorMsg = '';
+    this.cdr.detectChanges();
+  }
+
+  backToWorkshopPortal(): void {
+    const url = new URL(window.location.href);
+    url.searchParams.delete('workshop');
+    url.searchParams.delete('packages');
+    url.searchParams.delete('package');
+    url.searchParams.delete('pkg');
+    url.hash = '/workshop';
+    window.history.pushState({}, '', url.toString());
+    this.syncWorkshopRouteFromUrl();
+  }
+
   syncWorkshopAllocationsFromList(): void {
   syncWorkshopAllocationsFromList(): void {
     if (!this.workshopPackage || this.apigList.length === 0) return;
     if (!this.workshopPackage || this.apigList.length === 0) return;
     for (const allocation of this.workshopPackage.allocations) {
     for (const allocation of this.workshopPackage.allocations) {
@@ -724,7 +1035,7 @@ export class App implements OnInit {
 
 
   getPaymentBody(): string {
   getPaymentBody(): string {
     if (this.workshopPackage) {
     if (this.workshopPackage) {
-      return this.workshopPackage.title + '(双数据中台额度)';
+      return this.workshopPackage.title + '(' + this.workshopQuotaLabel + ')';
     }
     }
     return this.apig!.title + ' 接口充值';
     return this.apig!.title + ' 接口充值';
   }
   }
@@ -944,6 +1255,13 @@ export class App implements OnInit {
     const tier = this.apig!.priceStep![this.selectedIndex];
     const tier = this.apig!.priceStep![this.selectedIndex];
     console.log('═══ [RECHARGE] 开始 ═══ oldCount:', oldCount, 'addCount:', tier.count, 'fun_id:', this.funId);
     console.log('═══ [RECHARGE] 开始 ═══ oldCount:', oldCount, 'addCount:', tier.count, 'fun_id:', this.funId);
 
 
+    if (this.isLocalTestTier(tier) && !this.isStrictCallbackTest()) {
+      console.log('[RECHARGE] 本地 0.01 测试套餐,跳过后端回调等待,直接调用 saveRecharge。');
+      await this.saveRechargeFallback(oldCount, tier);
+      await this.confirmRechargeResult(oldCount);
+      return;
+    }
+
     // 步骤1: 等待后端微信回调自动执行云函数
     // 步骤1: 等待后端微信回调自动执行云函数
     for (let i = 1; i <= 8; i++) {
     for (let i = 1; i <= 8; i++) {
       console.log('[RECHARGE] 步骤1: 等待后端回调... 第' + i + '/8次 (3秒后)');
       console.log('[RECHARGE] 步骤1: 等待后端回调... 第' + i + '/8次 (3秒后)');
@@ -957,8 +1275,26 @@ export class App implements OnInit {
       }
       }
     }
     }
 
 
+    if (this.isStrictCallbackTest()) {
+      console.warn('[RECHARGE] 严格回调测试模式:后端回调未自动加余额,不调用 saveRecharge 兜底。');
+      this.successDetailHtml =
+        '支付已成功,但严格回调测试未观察到后端自动充值。<br>' +
+        '<span style="color:var(--text-dim);margin-top:8px;display:inline-block;">当前余额仍为 <strong style="color:var(--neon);">' +
+        this.apig!.count + ' ' + this.unitLabel + '</strong>,请检查支付回调/订单充值逻辑。</span>';
+      this.cdr.detectChanges();
+      this.loadOrderHistory();
+      return;
+    }
+
     // 步骤2: 后端回调未生效,调用 saveRecharge 保底
     // 步骤2: 后端回调未生效,调用 saveRecharge 保底
     console.log('[RECHARGE] 步骤2: 后端回调未生效,调用 saveRecharge 保底...');
     console.log('[RECHARGE] 步骤2: 后端回调未生效,调用 saveRecharge 保底...');
+    await this.saveRechargeFallback(oldCount, tier);
+
+    // 步骤3: 再轮询确认
+    await this.confirmRechargeResult(oldCount);
+  }
+
+  async saveRechargeFallback(oldCount: number, tier: PriceTier): Promise<void> {
     try {
     try {
       const rechargeBody: any = {
       const rechargeBody: any = {
         user: this.userId,
         user: this.userId,
@@ -975,8 +1311,9 @@ export class App implements OnInit {
     } catch (e: any) {
     } catch (e: any) {
       console.warn('[RECHARGE] saveRecharge 失败:', e.message);
       console.warn('[RECHARGE] saveRecharge 失败:', e.message);
     }
     }
+  }
 
 
-    // 步骤3: 再轮询确认
+  async confirmRechargeResult(oldCount: number): Promise<void> {
     for (let i = 1; i <= 3; i++) {
     for (let i = 1; i <= 3; i++) {
       await this.sleep(2000);
       await this.sleep(2000);
       await this.refreshBalance();
       await this.refreshBalance();
@@ -1006,7 +1343,7 @@ export class App implements OnInit {
       await this.prepareWorkshopPackage(false);
       await this.prepareWorkshopPackage(false);
     } catch (e: any) {
     } catch (e: any) {
       this.successDetailHtml =
       this.successDetailHtml =
-        '支付已完成,但双数据中台额度开通需要人工确认。<br>' +
+        '支付已完成,但' + this.workshopQuotaLabel + '开通需要人工确认。<br>' +
         '<span style="color:var(--text-dim);">原因:' + (e.message || '账套确认失败') + '</span>';
         '<span style="color:var(--text-dim);">原因:' + (e.message || '账套确认失败') + '</span>';
       this.cdr.detectChanges();
       this.cdr.detectChanges();
       return;
       return;