gangvy hace 4 meses
padre
commit
3a5b69356b
Se han modificado 3 ficheros con 809 adiciones y 15 borrados
  1. 99 5
      src/app/app.html
  2. 272 1
      src/app/app.scss
  3. 438 9
      src/app/app.ts

+ 99 - 5
src/app/app.html

@@ -4,7 +4,7 @@
 <!-- Header -->
 <div class="page-header">
   <div class="logo">BRAIN<span>HACK</span></div>
-  <div class="subtitle">API 服务充值</div>
+  <div class="subtitle">{{pageSubtitle}}</div>
   <div class="user-bar" *ngIf="loggedInUser">
     <span class="user-badge">👤 {{loggedInUser}}</span>
     <button class="token-copy-btn" (click)="copyToken()" *ngIf="sessionToken"
@@ -18,8 +18,50 @@
 <!-- Main -->
 <div class="main-container">
 
+  <!-- Workshop Entry -->
+  <div class="workshop-entry neu-raised" *ngIf="!isWorkshopMode && !showSuccess && !needLogin">
+    <div>
+      <div class="workshop-entry-title">情报官技能套餐</div>
+      <div class="workshop-entry-desc">适合课程练习、线下转化和企业情报官训练,额度会分别进入两个数据中台。</div>
+    </div>
+    <button class="workshop-entry-btn" (click)="openWorkshopPortal()">查看套餐</button>
+  </div>
+
+  <!-- Workshop Portal -->
+  <div class="workshop-portal" *ngIf="workshopPortal && !showSuccess && !needLogin">
+    <div class="workshop-portal-head">
+      <div>
+        <div class="workshop-kicker">情报官技能套餐</div>
+        <div class="workshop-portal-title">选择适合本场景的套餐</div>
+      </div>
+      <div class="workshop-portal-note">公开入口仅展示常规套餐</div>
+    </div>
+
+    <div class="workshop-package-grid">
+      <button
+        type="button"
+        class="workshop-package-card neu-raised"
+        *ngFor="let pkg of visibleWorkshopPackages"
+        (click)="navigateToWorkshopPackage(pkg.slug)">
+        <div class="package-card-top">
+          <span class="package-scene">{{pkg.scene}}</span>
+          <span class="package-badge">{{pkg.badge}}</span>
+        </div>
+        <div class="package-title">{{pkg.title}}</div>
+        <div class="package-subtitle">{{pkg.subtitle}}</div>
+        <div class="package-card-bottom">
+          <div>
+            <div class="package-price"><span>¥</span>{{pkg.price}}</div>
+            <div class="package-quota">合计 {{getPackageTotalCount(pkg)}} 次</div>
+          </div>
+          <div class="package-action">进入</div>
+        </div>
+      </button>
+    </div>
+  </div>
+
   <!-- API Info Card -->
-  <div class="api-info-card neu-raised" *ngIf="!showSuccess && !needLogin && apigId">
+  <div class="api-info-card neu-raised" *ngIf="!isWorkshopMode && !showSuccess && !needLogin && apigId">
     <div class="api-title" *ngIf="!apig">
       <span class="skeleton" style="display:inline-block;width:200px;height:24px;">&nbsp;</span>
     </div>
@@ -45,7 +87,7 @@
   </div>
 
   <!-- APIG Switcher (always visible after login) -->
-  <div class="apig-list-section" *ngIf="loggedInUser && !needLogin && !showSuccess">
+  <div class="apig-list-section" *ngIf="!isWorkshopMode && loggedInUser && !needLogin && !showSuccess">
     <h3 class="apig-list-title">选择要充值的 API 服务</h3>
 
     <!-- Loading -->
@@ -92,8 +134,60 @@
   <!-- Error -->
   <div class="error-bar" *ngIf="errorMsg">{{errorMsg}}</div>
 
+  <!-- Workshop Package -->
+  <div class="workshop-package neu-raised" *ngIf="!showSuccess && !needLogin && workshopPackage as pkg">
+    <div class="workshop-kicker">龙虾业务情报官工作坊</div>
+    <div class="workshop-heading-row">
+      <div>
+        <div class="workshop-title">{{pkg.title}}</div>
+        <div class="workshop-subtitle">{{pkg.subtitle}}</div>
+      </div>
+      <div class="workshop-badge">{{pkg.badge}}</div>
+    </div>
+
+    <div class="workshop-price-row">
+      <div>
+        <div class="workshop-price"><span class="symbol">¥</span>{{pkg.price}}</div>
+        <div class="workshop-price-note">技能训练服务 + 双数据中台运行额度</div>
+      </div>
+      <div class="workshop-total-quota">
+        <span>合计额度</span>
+        <strong>{{workshopTotalCount}} 次</strong>
+      </div>
+    </div>
+
+    <div class="workshop-quota-grid">
+      <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>
+    </div>
+
+    <div class="workshop-includes">
+      <div class="include-chip" *ngFor="let item of pkg.includes">{{item}}</div>
+    </div>
+
+    <div class="summary-bar neu-groove workshop-summary" *ngIf="selectedTier">
+      <div>
+        <div class="label">套餐内容</div>
+        <div class="workshop-summary-main">两个数据中台分别到账,有效期 730 天</div>
+      </div>
+      <div style="text-align:right;">
+        <div class="label">应付金额</div>
+        <div class="total"><span class="symbol">¥</span>{{selectedTier.price}}</div>
+      </div>
+    </div>
+
+    <button class="pay-btn" [disabled]="!apig || paying || workshopPreparing" (click)="startPayment()">
+      <span *ngIf="!paying && !workshopPreparing"><span class="btn-icon">💳</span> 微信支付</span>
+      <span *ngIf="workshopPreparing">正在确认双中台账套...</span>
+      <span *ngIf="paying && !workshopPreparing">正在生成支付码...</span>
+    </button>
+  </div>
+
   <!-- Tier Selection -->
-  <ng-container *ngIf="!showSuccess && !needLogin && apigId">
+  <ng-container *ngIf="!isWorkshopMode && !showSuccess && !needLogin && apigId">
     <div class="section-title">选择套餐</div>
     <div class="tier-grid" *ngIf="!apig || !apig.priceStep">
       <div class="tier-card neu-raised skeleton" style="height:100px;"></div>
@@ -137,7 +231,7 @@
   </ng-container>
 
   <!-- Order History -->
-  <div class="order-history" *ngIf="apig && !showSuccess">
+  <div class="order-history" *ngIf="!isWorkshopMode && apig && !showSuccess">
     <div class="section-header">
       <div class="section-title">历史订单</div>
       <button class="refresh-btn" [disabled]="ordersLoading" (click)="loadOrderHistory()">刷新</button>

+ 272 - 1
src/app/app.scss

@@ -427,6 +427,259 @@
   margin-top: 8px;
 }
 
+/* ─── Workshop Package ─── */
+.workshop-entry {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  gap: 18px;
+  padding: 18px 22px;
+  margin-bottom: 24px;
+  border: 1px solid rgba(0,212,255,0.12);
+}
+.workshop-entry-title {
+  font-size: 17px;
+  font-weight: 800;
+  color: var(--text-bright);
+}
+.workshop-entry-desc {
+  margin-top: 4px;
+  font-size: 13px;
+  line-height: 1.5;
+  color: var(--text-dim);
+}
+.workshop-entry-btn {
+  flex: 0 0 auto;
+  padding: 9px 14px;
+  border: 1px solid rgba(0,212,255,0.32);
+  border-radius: 8px;
+  color: var(--neon);
+  background: rgba(0,212,255,0.08);
+  font-size: 13px;
+  font-weight: 700;
+  cursor: pointer;
+}
+.workshop-entry-btn:hover {
+  border-color: var(--neon);
+  background: rgba(0,212,255,0.14);
+}
+.workshop-portal {
+  margin-bottom: 28px;
+}
+.workshop-portal-head {
+  display: flex;
+  justify-content: space-between;
+  align-items: flex-end;
+  gap: 16px;
+  margin-bottom: 16px;
+}
+.workshop-portal-title {
+  font-size: 24px;
+  font-weight: 800;
+  color: var(--text-bright);
+}
+.workshop-portal-note {
+  font-size: 12px;
+  color: var(--text-dim);
+}
+.workshop-package-grid {
+  display: grid;
+  grid-template-columns: repeat(3, minmax(0, 1fr));
+  gap: 14px;
+}
+.workshop-package-card {
+  min-height: 230px;
+  padding: 18px;
+  border: 1px solid transparent;
+  text-align: left;
+  color: inherit;
+  cursor: pointer;
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+}
+.workshop-package-card:hover {
+  border-color: var(--neon);
+  transform: translateY(-1px);
+}
+.package-card-top,
+.package-card-bottom {
+  display: flex;
+  justify-content: space-between;
+  align-items: flex-start;
+  gap: 10px;
+}
+.package-scene {
+  font-size: 11px;
+  color: var(--text-dim);
+}
+.package-badge {
+  padding: 2px 7px;
+  border-radius: 5px;
+  font-size: 11px;
+  font-weight: 700;
+  color: var(--success);
+  border: 1px solid rgba(0,255,136,0.26);
+  background: rgba(0,255,136,0.08);
+  white-space: nowrap;
+}
+.package-title {
+  font-size: 17px;
+  font-weight: 800;
+  color: var(--text-bright);
+}
+.package-subtitle {
+  flex: 1;
+  font-size: 12px;
+  line-height: 1.55;
+  color: var(--text-dim);
+}
+.package-price {
+  font-size: 30px;
+  line-height: 1;
+  color: var(--neon);
+  font-weight: 800;
+}
+.package-price span {
+  font-size: 16px;
+}
+.package-quota {
+  margin-top: 6px;
+  color: var(--text-dim);
+  font-size: 12px;
+}
+.package-action {
+  align-self: flex-end;
+  color: var(--neon);
+  font-size: 13px;
+  font-weight: 800;
+}
+.workshop-package {
+  padding: 28px;
+  margin-bottom: 24px;
+  border: 1px solid rgba(0,212,255,0.12);
+}
+.workshop-kicker {
+  font-size: 12px;
+  font-weight: 700;
+  color: var(--neon);
+  letter-spacing: 1.5px;
+  margin-bottom: 10px;
+}
+.workshop-heading-row {
+  display: flex;
+  justify-content: space-between;
+  align-items: flex-start;
+  gap: 18px;
+}
+.workshop-title {
+  font-size: 24px;
+  font-weight: 800;
+  color: var(--text-bright);
+  margin-bottom: 8px;
+}
+.workshop-subtitle {
+  font-size: 14px;
+  color: var(--text-dim);
+  line-height: 1.6;
+}
+.workshop-badge {
+  flex: 0 0 auto;
+  padding: 4px 10px;
+  font-size: 12px;
+  font-weight: 700;
+  color: var(--success);
+  border: 1px solid rgba(0,255,136,0.28);
+  border-radius: 6px;
+  background: rgba(0,255,136,0.08);
+}
+.workshop-price-row {
+  display: flex;
+  justify-content: space-between;
+  align-items: flex-end;
+  gap: 18px;
+  margin-top: 24px;
+  padding-top: 20px;
+  border-top: 1px solid rgba(255,255,255,0.06);
+}
+.workshop-price {
+  font-size: 42px;
+  line-height: 1;
+  font-weight: 800;
+  color: var(--neon);
+}
+.workshop-price .symbol {
+  font-size: 20px;
+}
+.workshop-price-note {
+  font-size: 12px;
+  color: var(--text-dim);
+  margin-top: 8px;
+}
+.workshop-total-quota {
+  text-align: right;
+  color: var(--text-dim);
+  font-size: 12px;
+}
+.workshop-total-quota strong {
+  display: block;
+  margin-top: 4px;
+  font-size: 22px;
+  color: var(--text-bright);
+}
+.workshop-quota-grid {
+  display: grid;
+  grid-template-columns: repeat(2, minmax(0, 1fr));
+  gap: 12px;
+  margin: 22px 0 18px;
+}
+.workshop-quota-item {
+  padding: 16px;
+  border: 1px solid rgba(255,255,255,0.07);
+  border-radius: 8px;
+  background: rgba(0,0,0,0.18);
+}
+.quota-title {
+  font-size: 14px;
+  font-weight: 700;
+  color: var(--text-bright);
+  line-height: 1.4;
+}
+.quota-count {
+  margin-top: 8px;
+  font-size: 24px;
+  font-weight: 800;
+  color: var(--success);
+}
+.quota-balance {
+  margin-top: 4px;
+  font-size: 12px;
+  color: var(--text-dim);
+}
+.workshop-includes {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px;
+  margin-bottom: 18px;
+}
+.include-chip {
+  padding: 5px 10px;
+  border-radius: 6px;
+  border: 1px solid rgba(0,212,255,0.18);
+  background: rgba(0,212,255,0.05);
+  color: var(--text);
+  font-size: 12px;
+}
+.workshop-summary {
+  margin: 0 0 18px;
+}
+.workshop-summary-main {
+  font-size: 15px;
+  font-weight: 700;
+  color: var(--text-bright);
+  margin-top: 4px;
+}
+
 /* ─── Summary ─── */
 .summary-bar {
   padding: 20px 24px;
@@ -840,8 +1093,26 @@
   .main-container { padding: 20px 16px; }
   .api-info-card { padding: 20px; }
   .tier-grid { grid-template-columns: 1fr; }
+  .workshop-entry,
+  .workshop-portal-head {
+    flex-direction: column;
+    align-items: flex-start;
+  }
+  .workshop-entry-btn { width: 100%; }
+  .workshop-package-grid { grid-template-columns: 1fr; }
+  .workshop-package-card { min-height: 0; }
+  .workshop-package { padding: 20px; }
+  .workshop-heading-row,
+  .workshop-price-row {
+    flex-direction: column;
+    align-items: flex-start;
+  }
+  .workshop-title { font-size: 21px; }
+  .workshop-price { font-size: 36px; }
+  .workshop-total-quota { text-align: left; }
+  .workshop-quota-grid { grid-template-columns: 1fr; }
   .modal-box { padding: 24px 20px; }
   .order-table { font-size: 12px; }
   .order-table thead th,
   .order-table tbody td { padding: 8px 6px; }
-}
+}

+ 438 - 9
src/app/app.ts

@@ -20,6 +20,28 @@ interface PriceTier {
   price: number;
 }
 
+interface WorkshopAllocation {
+  apigId: string;
+  title: string;
+  count: number;
+  authId?: string;
+  oldCount?: number;
+  balance?: number;
+}
+
+interface WorkshopPackage {
+  slug: string;
+  title: string;
+  badge: string;
+  subtitle: string;
+  scene: string;
+  price: number;
+  visible: boolean;
+  primaryApigId: string;
+  allocations: WorkshopAllocation[];
+  includes: string[];
+}
+
 interface ApigData {
   objectId: string;
   title: string;
@@ -32,7 +54,102 @@ interface ApigData {
 }
 
 // VOC 技能体系相关的 APIG — 一键切换
-const VOC_APIG_IDS = ['Vo3ROWEvDy', '7HwdQZk55B'];
+const SOCIAL_APIG_ID = 'Vo3ROWEvDy';
+const ECOMMERCE_APIG_ID = '7HwdQZk55B';
+const VOC_APIG_IDS = [SOCIAL_APIG_ID, ECOMMERCE_APIG_ID];
+
+const WORKSHOP_PACKAGES: Record<string, WorkshopPackage> = {
+  '19-9': {
+    slug: '19-9',
+    title: '课程特惠体验包',
+    badge: '课程专享',
+    subtitle: '给课程学员使用的课堂练习包,适合跟着老师完成基础实操。',
+    scene: '课程学习',
+    price: 19.9,
+    visible: false,
+    primaryApigId: SOCIAL_APIG_ID,
+    allocations: [
+      { apigId: SOCIAL_APIG_ID, title: '国内外社媒数据中台', count: 100 },
+      { apigId: ECOMMERCE_APIG_ID, title: '电商数据中台', count: 100 }
+    ],
+    includes: ['课堂练习额度', '基础情报官模板', '不含复盘服务']
+  },
+  '29-9': {
+    slug: '29-9',
+    title: '课程加量体验包',
+    badge: '课程专享',
+    subtitle: '给课程学员使用的加量练习包,适合课上多跑几轮真实案例。',
+    scene: '课程学习',
+    price: 29.9,
+    visible: false,
+    primaryApigId: SOCIAL_APIG_ID,
+    allocations: [
+      { apigId: SOCIAL_APIG_ID, title: '国内外社媒数据中台', count: 150 },
+      { apigId: ECOMMERCE_APIG_ID, title: '电商数据中台', count: 150 }
+    ],
+    includes: ['课堂练习额度', '基础情报官模板', '不含复盘服务']
+  },
+  '99': {
+    slug: '99',
+    title: '纯享练习包',
+    badge: '低门槛',
+    subtitle: '适合公域课程和社群用户自己练习,不附加人工服务。',
+    scene: '自助学习',
+    price: 99,
+    visible: true,
+    primaryApigId: SOCIAL_APIG_ID,
+    allocations: [
+      { apigId: SOCIAL_APIG_ID, title: '国内外社媒数据中台', count: 500 },
+      { apigId: ECOMMERCE_APIG_ID, title: '电商数据中台', count: 500 }
+    ],
+    includes: ['自助练习额度', '情报官模板', '不含答疑/复盘']
+  },
+  '299': {
+    slug: '299',
+    title: '现场实训包',
+    badge: '现场实操',
+    subtitle: '适合线下课程后继续实操,现场开通额度并训练基础情报官。',
+    scene: '线下课程',
+    price: 299,
+    visible: true,
+    primaryApigId: SOCIAL_APIG_ID,
+    allocations: [
+      { apigId: SOCIAL_APIG_ID, title: '国内外社媒数据中台', count: 1000 },
+      { apigId: ECOMMERCE_APIG_ID, title: '电商数据中台', count: 1000 }
+    ],
+    includes: ['基础情报官训练', '龙虾场景案例测试', '使用模板']
+  },
+  '499': {
+    slug: '499',
+    title: '情报官实战包',
+    badge: '实战训练',
+    subtitle: '基于龙虾业务经验训练 1 个情报官角色,现场完成真实任务测试。',
+    scene: '企业实训',
+    price: 499,
+    visible: false,
+    primaryApigId: SOCIAL_APIG_ID,
+    allocations: [
+      { apigId: SOCIAL_APIG_ID, title: '国内外社媒数据中台', count: 2000 },
+      { apigId: ECOMMERCE_APIG_ID, title: '电商数据中台', count: 2000 }
+    ],
+    includes: ['情报官角色设定', '龙虾业务场景调优', '3 个真实任务测试', '使用 SOP 模板']
+  },
+  '699': {
+    slug: '699',
+    title: '情报官团队包',
+    badge: '交付服务',
+    subtitle: '适合想直接解决问题的重度用户,含复盘或辅助产出一份专业报告。',
+    scene: '企业/重度用户',
+    price: 699,
+    visible: true,
+    primaryApigId: SOCIAL_APIG_ID,
+    allocations: [
+      { apigId: SOCIAL_APIG_ID, title: '国内外社媒数据中台', count: 3000 },
+      { apigId: ECOMMERCE_APIG_ID, title: '电商数据中台', count: 3000 }
+    ],
+    includes: ['情报官角色设定', '龙虾业务场景调优', '团队共用额度', '1 次线上复盘', '情报官模板二次优化']
+  }
+};
 
 interface OrderData {
   objectId: string;
@@ -54,12 +171,16 @@ export class App implements OnInit {
   @ViewChild('qrCanvas', { static: false }) qrCanvasRef!: ElementRef<HTMLCanvasElement>;
 
   constructor(private cdr: ChangeDetectorRef) {}
+  private readonly routeChangeHandler = () => this.syncWorkshopRouteFromUrl();
 
   // URL params
   authId = '';
   userId = '';
   apigId = '';
   funId = DEFAULT_FUN_ID;
+  workshopPackage: WorkshopPackage | null = null;
+  workshopPortal = false;
+  workshopPreparing = false;
 
   // Login state
   needLogin = false;
@@ -94,10 +215,29 @@ export class App implements OnInit {
   orderSkip = 0;
 
   get selectedTier(): PriceTier | null {
+    if (this.workshopPackage) {
+      return { price: this.workshopPackage.price, count: this.workshopTotalCount };
+    }
     if (!this.apig?.priceStep || this.apig.priceStep.length === 0) return null;
     return this.apig.priceStep[this.selectedIndex] || null;
   }
 
+  get workshopTotalCount(): number {
+    return this.workshopPackage?.allocations.reduce((sum, item) => sum + item.count, 0) || 0;
+  }
+
+  get visibleWorkshopPackages(): WorkshopPackage[] {
+    return Object.values(WORKSHOP_PACKAGES).filter(pkg => pkg.visible);
+  }
+
+  get isWorkshopMode(): boolean {
+    return !!this.workshopPackage || this.workshopPortal;
+  }
+
+  get pageSubtitle(): string {
+    return this.isWorkshopMode ? '情报官技能套餐' : 'API 服务充值';
+  }
+
   get unitLabel(): string {
     if (!this.apig) return '次';
     return (this.apig.objectId === 'MYM5zJBKgw' || this.apig.objectId === 'FQtTgjcqIZ') ? 'token' : '次';
@@ -105,9 +245,12 @@ export class App implements OnInit {
 
   ngOnInit(): void {
     const params = new URLSearchParams(window.location.search);
+    this.syncWorkshopRouteFromUrl(false);
+    window.addEventListener('hashchange', this.routeChangeHandler);
+    window.addEventListener('popstate', this.routeChangeHandler);
     this.authId = params.get('authid') || '';
     this.userId = params.get('user') || params.get('userid') || '';
-    this.apigId = params.get('apigid') || 'Vo3ROWEvDy';
+    this.apigId = params.get('apigid') || this.workshopPackage?.primaryApigId || SOCIAL_APIG_ID;
     this.funId = params.get('fun_id') || DEFAULT_FUN_ID;
     const urlToken = params.get('token') || '';
 
@@ -144,6 +287,11 @@ export class App implements OnInit {
     this.needLogin = true;
   }
 
+  ngOnDestroy(): void {
+    window.removeEventListener('hashchange', this.routeChangeHandler);
+    window.removeEventListener('popstate', this.routeChangeHandler);
+  }
+
   /**
    * 通过 URL 中的 token 调用 Parse Server 的 become(fetch /parse/users/me)完成登录
    */
@@ -315,6 +463,7 @@ export class App implements OnInit {
       }
 
       this.apigList = apigs;
+      this.syncWorkshopAllocationsFromList();
       console.log('[APIG] 获取到', apigs.length, '个可用 API');
       this.cdr.detectChanges();
     } catch (e: any) {
@@ -397,6 +546,7 @@ export class App implements OnInit {
             this.selectTier(0);
             this.cdr.detectChanges();
             this.loadOrderHistory();
+            this.loadApigList();
             return;
           }
         }
@@ -478,13 +628,177 @@ export class App implements OnInit {
     this.selectedIndex = idx;
   }
 
+  syncWorkshopRouteFromUrl(shouldDetectChanges = true): void {
+    const params = new URLSearchParams(window.location.search);
+    const nextPackage = this.resolveWorkshopPackage(params);
+    this.workshopPackage = nextPackage;
+    this.workshopPortal = !nextPackage && this.isWorkshopPortal(params);
+
+    if (nextPackage) {
+      this.apigId = nextPackage.primaryApigId;
+      this.authId = '';
+      this.selectedIndex = 0;
+      this.showSuccess = false;
+      this.errorMsg = '';
+      this.syncWorkshopAllocationsFromList();
+    } else if (this.workshopPortal) {
+      this.showSuccess = false;
+      this.errorMsg = '';
+    }
+
+    if (shouldDetectChanges) this.cdr.detectChanges();
+  }
+
+  getRouteParts(): string[] {
+    const pathParts = window.location.pathname.toLowerCase().split('/').filter(Boolean);
+    const hashParts = window.location.hash.replace(/^#\/?/, '').toLowerCase().split('/').filter(Boolean);
+    return [...pathParts, ...hashParts];
+  }
+
+  isWorkshopPortal(params: URLSearchParams): boolean {
+    const parts = this.getRouteParts();
+    return parts.includes('workshop') ||
+      parts.includes('packages') ||
+      params.get('workshop') === '1' ||
+      params.get('packages') === '1';
+  }
+
+  resolveWorkshopPackage(params: URLSearchParams): WorkshopPackage | null {
+    const candidates = [
+      params.get('workshop'),
+      params.get('package'),
+      params.get('pkg'),
+      ...this.getRouteParts()
+    ].filter((item): item is string => !!item);
+    const slug = candidates.find(item => !!WORKSHOP_PACKAGES[item]);
+    if (!slug) return null;
+    const pkg = WORKSHOP_PACKAGES[slug];
+    if (!pkg) return null;
+    return {
+      ...pkg,
+      allocations: pkg.allocations.map(item => ({ ...item }))
+    };
+  }
+
+  getPackageTotalCount(pkg: WorkshopPackage): number {
+    return pkg.allocations.reduce((sum, item) => sum + item.count, 0);
+  }
+
+  navigateToWorkshopPackage(slug: string): 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/' + slug;
+    window.history.pushState({}, '', url.toString());
+    this.syncWorkshopRouteFromUrl();
+  }
+
+  openWorkshopPortal(): 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 {
+    if (!this.workshopPackage || this.apigList.length === 0) return;
+    for (const allocation of this.workshopPackage.allocations) {
+      const apig = this.apigList.find(item => item.objectId === allocation.apigId);
+      if (!apig) continue;
+      allocation.title = apig.title || allocation.title;
+      allocation.authId = apig.authId || allocation.authId;
+      if (apig.userBalance != null) allocation.balance = apig.userBalance;
+    }
+  }
+
+  getWorkshopBalance(allocation: WorkshopAllocation): string {
+    return allocation.balance != null ? String(allocation.balance) : '—';
+  }
+
+  getPaymentBody(): string {
+    if (this.workshopPackage) {
+      return this.workshopPackage.title + '(双数据中台额度)';
+    }
+    return this.apig!.title + ' 接口充值';
+  }
+
+  getPrimaryWorkshopAllocation(): WorkshopAllocation | null {
+    if (!this.workshopPackage) return null;
+    return this.workshopPackage.allocations.find(item => item.apigId === this.workshopPackage!.primaryApigId) || null;
+  }
+
+  getOrderRechargeCount(): number {
+    const primary = this.getPrimaryWorkshopAllocation();
+    if (this.workshopPackage && primary) return primary.count;
+    return this.selectedTier?.count || 0;
+  }
+
+  getOrderOldCount(): number {
+    const primary = this.getPrimaryWorkshopAllocation();
+    if (this.workshopPackage && primary) return primary.oldCount ?? primary.balance ?? this.apig!.count ?? 0;
+    return this.apig!.count || 0;
+  }
+
+  async prepareWorkshopPackage(captureOldCounts = true): Promise<void> {
+    if (!this.workshopPackage) return;
+    if (!this.userId) throw new Error('请先登录后再购买技能套餐。');
+
+    this.workshopPreparing = true;
+    try {
+      for (const allocation of this.workshopPackage.allocations) {
+        const authId = await this.resolveAuthId(this.userId, allocation.apigId);
+        if (!authId) throw new Error('无法创建 ' + allocation.title + ' 计费账套');
+        allocation.authId = authId;
+
+        const data = await this.getApigByAuthId(authId);
+        if (data) {
+          allocation.title = data.title || allocation.title;
+          allocation.balance = data.count || 0;
+          if (captureOldCounts || allocation.oldCount == null) allocation.oldCount = allocation.balance;
+          if (allocation.apigId === this.workshopPackage.primaryApigId) {
+            this.authId = authId;
+            this.apigId = allocation.apigId;
+            this.apig = data;
+          }
+        } else {
+          if (captureOldCounts || allocation.oldCount == null) allocation.oldCount = allocation.balance || 0;
+        }
+      }
+      this.cdr.detectChanges();
+    } finally {
+      this.workshopPreparing = false;
+      this.cdr.detectChanges();
+    }
+  }
+
+  async getApigByAuthId(authId: string): Promise<ApigData | null> {
+    try {
+      const resp = await this.postJSON(API_BASE + '/api/apig/getApig', { authid: authId });
+      if (resp.code === 200 && resp.data) return resp.data;
+    } catch (e: any) {
+      console.warn('[APIG] 获取账套信息失败:', e.message);
+    }
+    return null;
+  }
+
   // ─── Payment Flow ───
   async startPayment(): Promise<void> {
-    if (!this.apig?.priceStep || this.paying) return;
+    const tier = this.selectedTier;
+    if (!this.apig || !tier || this.paying || this.workshopPreparing) return;
     this.paying = true;
     this.errorMsg = '';
 
     try {
+      if (this.workshopPackage) {
+        await this.prepareWorkshopPackage();
+      }
+
       // 1. Generate trade number
       const now = new Date();
       this.tradeNo = 'C' + (this.userId || 'U') +
@@ -496,8 +810,6 @@ export class App implements OnInit {
         String(now.getSeconds()).padStart(2, '0') +
         now.getMilliseconds();
 
-      const tier = this.apig.priceStep[this.selectedIndex];
-
       // 2. Create order
       console.log('[PAY] Creating order...', { tradeNo: this.tradeNo, tier: tier.price, funId: this.funId });
       await this.createOrder(tier);
@@ -509,7 +821,7 @@ export class App implements OnInit {
         company: PAY_COMPANY,
         out_trade_no: this.tradeNo,
         total_fee: +tier.price,
-        body: this.apig.title + ' 接口充值'
+        body: this.getPaymentBody()
       };
       if (this.funId) {
         payParams.fun_id = this.funId;
@@ -568,17 +880,20 @@ export class App implements OnInit {
 
   async createOrder(tier: PriceTier): Promise<void> {
     try {
+      const orderCount = this.getOrderRechargeCount();
+      const oldCount = this.getOrderOldCount();
+      const bodyText = this.getPaymentBody();
       const body: any = {
         type: 'wxpay',
         authid: this.authId,
         params: {
           out_trade_no: this.tradeNo,
           total_fee: +tier.price,
-          body: this.apig!.title + ' 接口充值'
+          body: bodyText
         },
         apigid: this.apig!.objectId,
-        oldCount: this.apig!.count || 0,
-        count: tier.count,
+        oldCount,
+        count: orderCount,
         user: this.userId
       };
       // 仅在拿到真实 Company objectId 时才传 fcompany,避免把 userId 误写成 Company 指针
@@ -616,6 +931,11 @@ export class App implements OnInit {
   }
 
   async doRecharge(): Promise<void> {
+    if (this.workshopPackage) {
+      await this.doWorkshopRecharge();
+      return;
+    }
+
     this.showQrModal = false;
     this.showSuccess = true;
     this.cdr.detectChanges();
@@ -673,6 +993,115 @@ export class App implements OnInit {
     this.loadOrderHistory();
   }
 
+  async doWorkshopRecharge(): Promise<void> {
+    if (!this.workshopPackage) return;
+
+    this.showQrModal = false;
+    this.showSuccess = true;
+    this.successDetailHtml = '正在开通 <strong style="color:var(--neon);">' + this.workshopPackage.title + '</strong>,请稍候...';
+    this.cdr.detectChanges();
+
+    try {
+      await this.prepareWorkshopPackage(false);
+    } catch (e: any) {
+      this.successDetailHtml =
+        '支付已完成,但双数据中台额度开通需要人工确认。<br>' +
+        '<span style="color:var(--text-dim);">原因:' + (e.message || '账套确认失败') + '</span>';
+      this.cdr.detectChanges();
+      return;
+    }
+    const primary = this.getPrimaryWorkshopAllocation();
+    const primaryTarget = primary ? (primary.oldCount || 0) + primary.count : 0;
+
+    if (primary) {
+      for (let i = 1; i <= 8; i++) {
+        await this.sleep(3000);
+        await this.refreshWorkshopBalances();
+        if ((primary.balance || 0) >= primaryTarget) break;
+      }
+    }
+
+    for (const allocation of this.workshopPackage.allocations) {
+      const oldCount = allocation.oldCount || 0;
+      const current = allocation.balance ?? oldCount;
+      const target = oldCount + allocation.count;
+      if (current >= target) continue;
+
+      try {
+        const rechargeBody: any = {
+          user: this.userId,
+          authid: allocation.authId,
+          apigid: allocation.apigId,
+          oldCount: current,
+          count: target - current,
+          orderid: this.order?.objectId || ''
+        };
+        if (this.companyId) rechargeBody.authComp = this.companyId;
+        console.log('[WORKSHOP_RECHARGE] saveRecharge 参数:', JSON.stringify(rechargeBody));
+        const rechargeResp = await this.postJSON(API_BASE + '/api/apig/saveRecharge', rechargeBody);
+        console.log('[WORKSHOP_RECHARGE] saveRecharge 响应:', JSON.stringify(rechargeResp));
+      } catch (e: any) {
+        console.warn('[WORKSHOP_RECHARGE] ' + allocation.title + ' 充值失败:', e.message);
+      }
+    }
+
+    for (let i = 1; i <= 3; i++) {
+      await this.sleep(2000);
+      await this.refreshWorkshopBalances();
+      const done = this.workshopPackage.allocations.every(item => {
+        const target = (item.oldCount || 0) + item.count;
+        return (item.balance || 0) >= target;
+      });
+      if (done) break;
+    }
+
+    this.successDetailHtml = this.buildWorkshopSuccessHtml();
+    this.cdr.detectChanges();
+    this.loadOrderHistory();
+  }
+
+  async refreshWorkshopBalances(): Promise<void> {
+    if (!this.workshopPackage) return;
+    for (const allocation of this.workshopPackage.allocations) {
+      if (!allocation.authId) continue;
+      const data = await this.getApigByAuthId(allocation.authId);
+      if (!data) continue;
+      allocation.title = data.title || allocation.title;
+      allocation.balance = data.count || 0;
+      if (allocation.apigId === this.workshopPackage.primaryApigId) {
+        this.apig = data;
+      }
+    }
+    this.syncApigListBalancesFromWorkshop();
+    this.cdr.detectChanges();
+  }
+
+  syncApigListBalancesFromWorkshop(): void {
+    if (!this.workshopPackage || this.apigList.length === 0) return;
+    for (const allocation of this.workshopPackage.allocations) {
+      const apig = this.apigList.find(item => item.objectId === allocation.apigId);
+      if (!apig) continue;
+      apig.authId = allocation.authId || apig.authId;
+      apig.userBalance = allocation.balance ?? apig.userBalance;
+    }
+  }
+
+  buildWorkshopSuccessHtml(): string {
+    if (!this.workshopPackage) return '';
+    const rows = this.workshopPackage.allocations.map(item => {
+      const target = (item.oldCount || 0) + item.count;
+      const balance = item.balance ?? 0;
+      const status = balance >= target ? '已到账' : '处理中';
+      return '<div style="margin-top:6px;">' +
+        '<strong style="color:var(--success);">' + item.title + '</strong> +' + item.count + ' 次' +
+        '<span style="color:var(--text-dim);">,当前余额 ' + balance + ' 次,' + status + '</span>' +
+        '</div>';
+    }).join('');
+    return '已开通 <strong style="color:var(--neon);">' + this.workshopPackage.title + '</strong><br>' +
+      rows +
+      '<span style="color:var(--text-dim);margin-top:8px;display:inline-block;">有效期 730 天,情报官训练服务请按工作坊流程交付。</span>';
+  }
+
   async refreshBalance(): Promise<void> {
     try {
       const resp = await this.postJSON(API_BASE + '/api/apig/getApig', { authid: this.authId });