gangvy пре 5 месеци
родитељ
комит
9fb883a519
4 измењених фајлова са 230 додато и 99 уклоњено
  1. 31 12
      src/app/app.html
  2. 98 0
      src/app/app.scss
  3. 100 45
      src/app/app.ts
  4. 1 42
      src/app/login-modal.component.ts

+ 31 - 12
src/app/app.html

@@ -7,8 +7,9 @@
   <div class="subtitle">API 服务充值</div>
   <div class="user-bar" *ngIf="loggedInUser">
     <span class="user-badge">👤 {{loggedInUser}}</span>
-    <button class="token-copy-btn" (click)="copyToken()" *ngIf="sessionToken">
-      {{tokenCopied ? '✅ 已复制' : '📋 复制Token'}}
+    <button class="token-copy-btn" (click)="copyToken()" *ngIf="sessionToken"
+            title="复制到终端执行,自动将 token 写入 OpenClaw">
+      {{tokenCopied ? '✅ 已复制命令' : '📋 复制 OpenClaw 授权命令'}}
     </button>
     <button class="logout-btn" (click)="logout()">退出</button>
   </div>
@@ -43,24 +44,42 @@
     </div>
   </div>
 
-  <!-- API Service List (after login, no apigId) -->
-  <div class="apig-list-section" *ngIf="loggedInUser && !apigId && !needLogin">
+  <!-- APIG Switcher (always visible after login) -->
+  <div class="apig-list-section" *ngIf="loggedInUser && !needLogin && !showSuccess">
     <h3 class="apig-list-title">选择要充值的 API 服务</h3>
 
     <!-- Loading -->
-    <div class="apig-list-loading" *ngIf="apigListLoading">
+    <div class="apig-list-loading" *ngIf="apigListLoading && apigList.length === 0">
       <span class="polling-dot"></span> 加载服务列表...
     </div>
 
     <!-- List -->
-    <div class="apig-list-grid" *ngIf="!apigListLoading && apigList.length > 0">
-      <div class="apig-list-card neu-raised" *ngFor="let item of apigList" (click)="navigateToApig(item.objectId)">
-        <div class="apig-list-card-title">{{item.title}}</div>
-        <div class="apig-list-card-desc">{{item.content || '专业API数据服务'}}</div>
-        <div class="apig-list-card-price" *ngIf="item.priceStep && item.priceStep.length > 0">
-          <span class="symbol">¥</span>{{item.priceStep[0].price * 2}} 起
+    <div class="apig-list-grid" [class.compact]="!!apigId" *ngIf="apigList.length > 0">
+      <div class="apig-list-card neu-raised"
+           *ngFor="let item of apigList"
+           [class.current]="isCurrentApig(item.objectId)"
+           (click)="navigateToApig(item.objectId)">
+        <div class="apig-list-card-row">
+          <div class="apig-list-card-title">
+            {{item.title}}
+            <span class="current-badge" *ngIf="isCurrentApig(item.objectId)">当前</span>
+          </div>
+          <div class="apig-list-card-balance"
+               [class.zero]="(item.userBalance || 0) === 0"
+               [class.low]="(item.userBalance || 0) > 0 && (item.userBalance || 0) < 100">
+            <span class="balance-label">余额</span>
+            <span class="balance-value">{{item.userBalance != null ? item.userBalance : '—'}}</span>
+            <span class="balance-unit">次</span>
+          </div>
+        </div>
+        <div class="apig-list-card-desc" *ngIf="!apigId">{{item.content || '专业API数据服务'}}</div>
+        <div class="apig-list-card-footer" *ngIf="item.priceStep && item.priceStep.length > 0">
+          <span class="apig-list-card-price">
+            <span class="symbol">¥</span>{{item.priceStep[0].price}}
+            <span class="price-suffix">起</span>
+          </span>
+          <span class="apig-list-card-action" *ngIf="!isCurrentApig(item.objectId)">点击切换 →</span>
         </div>
-        <div class="apig-list-card-arrow">→</div>
       </div>
     </div>
 

+ 98 - 0
src/app/app.scss

@@ -219,6 +219,104 @@
   color: var(--text-dim); font-size: 14px;
 }
 
+/* ─── APIG Switcher Extensions ─── */
+.apig-list-grid.compact {
+  flex-direction: row;
+  flex-wrap: wrap;
+  gap: 12px;
+}
+.apig-list-grid.compact .apig-list-card {
+  flex: 1 1 calc(50% - 6px);
+  min-width: 260px;
+  padding: 14px 18px;
+  gap: 4px;
+}
+.apig-list-card.current {
+  border-color: var(--neon);
+  background: linear-gradient(145deg, rgba(0,212,255,0.08), rgba(0,0,0,0.2)), var(--surface);
+  box-shadow:
+    inset 3px 3px 6px var(--shadow-dark),
+    inset -3px -3px 6px var(--shadow-light),
+    0 0 18px var(--neon-glow);
+  cursor: default;
+  transform: none;
+}
+.apig-list-card.current:hover {
+  transform: none;
+  border-color: var(--neon);
+}
+.apig-list-card-row {
+  display: flex;
+  justify-content: space-between;
+  align-items: flex-start;
+  gap: 12px;
+}
+.current-badge {
+  display: inline-block;
+  margin-left: 8px;
+  padding: 1px 8px;
+  font-size: 10px;
+  font-weight: 600;
+  letter-spacing: 1px;
+  color: var(--neon);
+  border: 1px solid var(--neon);
+  border-radius: 4px;
+  vertical-align: middle;
+}
+.apig-list-card-balance {
+  text-align: right;
+  white-space: nowrap;
+  display: flex;
+  flex-direction: column;
+  align-items: flex-end;
+}
+.apig-list-card-balance .balance-label {
+  font-size: 11px;
+  color: var(--text-dim);
+  letter-spacing: 1px;
+}
+.apig-list-card-balance .balance-value {
+  font-size: 20px;
+  font-weight: 700;
+  color: var(--success);
+  line-height: 1.1;
+  margin-top: 2px;
+  font-family: 'Menlo', 'Consolas', monospace;
+}
+.apig-list-card-balance .balance-unit {
+  font-size: 11px;
+  color: var(--text-dim);
+  margin-left: 2px;
+}
+.apig-list-card-balance.zero .balance-value { color: var(--danger); }
+.apig-list-card-balance.low  .balance-value { color: #FFB300; }
+
+.apig-list-card-footer {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-top: 8px;
+  font-size: 13px;
+}
+.apig-list-card-footer .apig-list-card-price {
+  font-size: 16px;
+  font-weight: 700;
+  color: var(--neon);
+  margin: 0;
+}
+.apig-list-card-footer .apig-list-card-price .symbol { font-size: 11px; }
+.apig-list-card-footer .apig-list-card-price .price-suffix {
+  font-size: 11px; font-weight: 400; color: var(--text-dim); margin-left: 2px;
+}
+.apig-list-card-footer .apig-list-card-action {
+  color: var(--neon-dim);
+  font-size: 12px;
+  letter-spacing: 0.5px;
+}
+.apig-list-card:hover:not(.current) .apig-list-card-action {
+  color: var(--neon);
+}
+
 /* ─── API Info Card ─── */
 .api-info-card {
   padding: 28px;

+ 100 - 45
src/app/app.ts

@@ -26,9 +26,14 @@ interface ApigData {
   content?: string;
   count?: number;
   priceStep?: PriceTier[];
+  userBalance?: number;   // 当前登录用户在该 APIG 的余额(合并 APIGAuth)
+  authId?: string;        // 当前用户的 APIGAuth objectId
   [key: string]: any;
 }
 
+// VOC 技能体系相关的 APIG — 一键切换
+const VOC_APIG_IDS = ['Vo3ROWEvDy', '7HwdQZk55B'];
+
 interface OrderData {
   objectId: string;
   createdAt: string;
@@ -60,6 +65,7 @@ export class App implements OnInit {
   needLogin = false;
   loggedInUser: string = '';
   sessionToken: string = '';
+  companyId: string = '';
   apigList: ApigData[] = [];
   apigListLoading = false;
   tokenCopied = false;
@@ -132,7 +138,7 @@ export class App implements OnInit {
 
   async validateAndLoad(token: string, userId: string): Promise<void> {
     try {
-      const resp = await fetch(`${API_BASE}/parse/users/me`, {
+      const resp = await fetch(`${API_BASE}/parse/users/me?include=company`, {
         headers: {
           'X-Parse-Application-Id': APP_ID,
           'X-Parse-Session-Token': token
@@ -160,7 +166,9 @@ export class App implements OnInit {
         this.userId = data.objectId;
         localStorage.setItem('apig_user_id', data.objectId);
       }
-      console.log('[AUTH] token 验证通过, user:', data.objectId, data.username || '');
+      // 提取 companyId(可选:后端扣费只要 user 指针即可,Company 存在时才附带)
+      this.companyId = data.company?.objectId || '';
+      console.log('[AUTH] token 验证通过, user:', data.objectId, data.username || '', 'company:', this.companyId || '(用户无 Company,仅用 user 指针扣费)');
       this.cdr.detectChanges();
       this.loadApig();
     } catch (e) {
@@ -170,7 +178,7 @@ export class App implements OnInit {
     }
   }
 
-  onLoginSuccess(event: { userId: string; sessionToken: string; displayName: string }): void {
+  async onLoginSuccess(event: { userId: string; sessionToken: string; displayName: string }): Promise<void> {
     console.log('[AUTH] 登录成功, userId:', event.userId, 'display:', event.displayName);
     this.userId = event.userId;
     this.loggedInUser = event.displayName || event.userId;
@@ -178,7 +186,8 @@ export class App implements OnInit {
     this.needLogin = false;
     this.errorMsg = '';
 
-    this.loadApig();
+    // 通过 validateAndLoad 统一处理 Company 解析 + 数据加载
+    await this.validateAndLoad(event.sessionToken, event.userId);
   }
 
   logout(): void {
@@ -189,6 +198,7 @@ export class App implements OnInit {
     this.userId = '';
     this.loggedInUser = '';
     this.sessionToken = '';
+    this.companyId = '';
     this.authId = '';
     this.apig = null;
     this.orders = [];
@@ -198,13 +208,14 @@ export class App implements OnInit {
     this.cdr.detectChanges();
   }
 
-  // ─── Load available APIG list (when no apigId) ───
+  // ─── Load available APIG list + merge user's APIGAuth balance ───
   async loadApigList(): Promise<void> {
     this.apigListLoading = true;
     try {
+      // 1. 拉 VOC 相关 APIG 基础信息
       const query: any = {
         where: JSON.stringify({
-          objectId: { $in: ['Vo3ROWEvDy'] }
+          objectId: { $in: VOC_APIG_IDS }
         }),
         keys: 'title,content,priceStep',
         limit: '10'
@@ -213,32 +224,83 @@ export class App implements OnInit {
         headers: { 'X-Parse-Application-Id': APP_ID }
       });
       const data = await resp.json();
-      if (data.results?.length > 0) {
-        this.apigList = data.results;
-        console.log('[APIG] 获取到', data.results.length, '个可用 API');
-      } else {
-        console.log('[APIG] 未查询到可用 API');
+      const apigs: ApigData[] = data.results || [];
+
+      // 2. 合并当前用户的 APIGAuth(如果已登录)
+      if (this.userId && this.sessionToken) {
+        try {
+          const authQuery = {
+            where: JSON.stringify({
+              user: { __type: 'Pointer', className: '_User', objectId: this.userId },
+              api: {
+                $inQuery: {
+                  where: { objectId: { $in: VOC_APIG_IDS } },
+                  className: 'APIG'
+                }
+              }
+            }),
+            keys: 'api,count,objectId',
+            limit: '10'
+          };
+          const authResp = await fetch(API_BASE + '/parse/classes/APIGAuth?' + new URLSearchParams(authQuery as any), {
+            headers: {
+              'X-Parse-Application-Id': APP_ID,
+              'X-Parse-Session-Token': this.sessionToken
+            }
+          });
+          const authData = await authResp.json();
+          const authMap: Record<string, { count: number; objectId: string }> = {};
+          for (const a of (authData.results || [])) {
+            const apigPtrId = a.api?.objectId;
+            if (apigPtrId) authMap[apigPtrId] = { count: a.count || 0, objectId: a.objectId };
+          }
+          for (const apig of apigs) {
+            const rec = authMap[apig.objectId];
+            apig.userBalance = rec ? rec.count : 0;
+            apig.authId = rec ? rec.objectId : '';
+          }
+          console.log('[APIG] 用户余额合并完成:', authMap);
+        } catch (e: any) {
+          console.warn('[APIG] 合并用户 APIGAuth 失败:', e.message);
+        }
       }
+
+      this.apigList = apigs;
+      console.log('[APIG] 获取到', apigs.length, '个可用 API');
       this.cdr.detectChanges();
     } catch (e: any) {
       console.warn('[APIG] 查询 API 列表失败:', e.message);
     } finally {
       this.apigListLoading = false;
+      this.cdr.detectChanges();
     }
   }
 
+  isCurrentApig(apigId: string): boolean {
+    return !!this.apigId && this.apigId === apigId;
+  }
+
   navigateToApig(apigId: string): void {
+    if (this.isCurrentApig(apigId)) return;
     const url = new URL(window.location.href);
     url.searchParams.set('apigid', apigId);
+    // 切换 APIG 时清空 authid(因为换了 APIG 就要重新解析新的 APIGAuth)
+    url.searchParams.delete('authid');
     window.location.href = url.toString();
   }
 
   copyToken(): void {
     if (!this.sessionToken) return;
-    navigator.clipboard.writeText(this.sessionToken).then(() => {
+    // 复制一整条可直接粘贴到终端执行的命令,把 token 写入 OpenClaw 凭证文件
+    const cmd = `node set-voc-token.js ${this.sessionToken}`;
+    navigator.clipboard.writeText(cmd).then(() => {
       this.tokenCopied = true;
       this.cdr.detectChanges();
-      setTimeout(() => { this.tokenCopied = false; this.cdr.detectChanges(); }, 2000);
+      setTimeout(() => { this.tokenCopied = false; this.cdr.detectChanges(); }, 2500);
+    }).catch(() => {
+      // 剪贴板失败时兜底:把命令显示出来让用户手动复制
+      this.errorMsg = '复制失败,请手动复制命令:' + cmd;
+      this.cdr.detectChanges();
     });
   }
 
@@ -267,6 +329,8 @@ export class App implements OnInit {
         this.selectTier(0);
         this.cdr.detectChanges();
         this.loadOrderHistory();
+        // 同时加载其它 VOC APIG 余额以显示切换器
+        this.loadApigList();
         return;
       }
 
@@ -329,25 +393,12 @@ export class App implements OnInit {
     try {
       console.log('[resolveAuthId] 未找到该用户的 APIGAuth,创建新记录(余额=0)...');
 
-      // 获取用户的 company 指针(ensureCompany 已在登录时创建)
-      let companyPointer: any = undefined;
-      try {
-        const meResp = await fetch(API_BASE + '/parse/users/me', { headers: authHeaders });
-        const meData = await meResp.json();
-        if (meData.company?.objectId) {
-          companyPointer = { __type: 'Pointer', className: 'Company', objectId: meData.company.objectId };
-        }
-      } catch (e: any) {
-        console.warn('[resolveAuthId] 获取 company 失败:', e.message);
-      }
-
       const body: any = {
         api: { __type: 'Pointer', className: 'APIG', objectId: apig },
         user: { __type: 'Pointer', className: '_User', objectId: user },
         count: 0,
         used: 0
       };
-      if (companyPointer) body.company = companyPointer;
 
       const createResp = await fetch(API_BASE + '/parse/classes/APIGAuth', {
         method: 'POST',
@@ -367,13 +418,7 @@ export class App implements OnInit {
   }
 
   applyTestTier(): void {
-    if (this.apig?.priceStep) {
-      // 套餐价格翻倍
-      this.apig.priceStep = this.apig.priceStep.map(t => ({
-        ...t,
-        price: t.price * 2
-      }));
-    }
+    // 仅在 URL 带 test=1 时追加测试套餐(¥0.01 / 1 次),不再对正式价格做任何改写
     const params = new URLSearchParams(window.location.search);
     if (params.get('test') === '1' && this.apig?.priceStep) {
       this.apig.priceStep.unshift({ count: 1, price: 0.01 });
@@ -485,9 +530,10 @@ export class App implements OnInit {
         apigid: this.apig!.objectId,
         oldCount: this.apig!.count || 0,
         count: tier.count,
-        user: this.userId || this.authId,
-        fcompany: this.userId || this.authId
+        user: this.userId
       };
+      // 仅在拿到真实 Company objectId 时才传 fcompany,避免把 userId 误写成 Company 指针
+      if (this.companyId) body.fcompany = this.companyId;
       const resp = await this.postJSON(API_BASE + '/api/apig/created-apigorder', body);
       if (resp.code === 200 && resp.data) {
         this.order = resp.data;
@@ -544,15 +590,15 @@ export class App implements OnInit {
     // 步骤2: 后端回调未生效,调用 saveRecharge 保底
     console.log('[RECHARGE] 步骤2: 后端回调未生效,调用 saveRecharge 保底...');
     try {
-      const rechargeBody = {
-        user: this.userId || this.authId,
-        authComp: this.userId || this.authId,
+      const rechargeBody: any = {
+        user: this.userId,
         authid: this.authId,
         apigid: this.apig!.objectId,
         oldCount: oldCount,
         count: tier.count,
         orderid: this.order?.objectId || ''
       };
+      if (this.companyId) rechargeBody.authComp = this.companyId;
       console.log('[RECHARGE] saveRecharge 参数:', JSON.stringify(rechargeBody));
       const rechargeResp = await this.postJSON(API_BASE + '/api/apig/saveRecharge', rechargeBody);
       console.log('[RECHARGE] saveRecharge 响应:', JSON.stringify(rechargeResp));
@@ -647,15 +693,24 @@ export class App implements OnInit {
       this.orderSkip = 0;
       this.orders = [];
     }
+    // 在 detectChanges 之后触发异步工作,避免在父级 CD 周期内改变 [disabled] 绑定
+    this.cdr.detectChanges();
 
     try {
-      const userVal = this.userId || this.authId;
-      const where = JSON.stringify({
-        '$or': [
-          { fromCompany: { __type: 'Pointer', className: 'Company', objectId: userVal } },
-          { fromUser: { __type: 'Pointer', className: '_User', objectId: userVal } }
-        ]
-      });
+      const orClauses: any[] = [];
+      if (this.userId) {
+        orClauses.push({ fromUser: { __type: 'Pointer', className: '_User', objectId: this.userId } });
+      }
+      if (this.companyId) {
+        orClauses.push({ fromCompany: { __type: 'Pointer', className: 'Company', objectId: this.companyId } });
+      }
+      if (orClauses.length === 0) {
+        this.orders = [];
+        this.ordersLoading = false;
+        this.cdr.detectChanges();
+        return;
+      }
+      const where = JSON.stringify(orClauses.length === 1 ? orClauses[0] : { '$or': orClauses });
       const qs = new URLSearchParams({
         where,
         order: '-createdAt',

+ 1 - 42
src/app/login-modal.component.ts

@@ -333,10 +333,7 @@ export class LoginModalComponent {
         throw new Error('登录成功但无法获取用户信息');
       }
 
-      // 3. 自动补全 Company(后端 voc-ecom/forward 强制要求)
-      await this.ensureCompany(userData, sessionToken);
-
-      // 4. 缓存登录态
+      // 3. 缓存登录态(company 不再是必须的,后端已改为 user+APIGAuth 扣费)
       const displayName = this.phone ? this.phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2') : (userData.username || userData.objectId);
       localStorage.setItem('apig_session_token', sessionToken);
       localStorage.setItem('apig_user_id', userData.objectId);
@@ -353,44 +350,6 @@ export class LoginModalComponent {
     }
   }
 
-  // ─── 自动创建 Company(后端 forward 接口强制要求 user.company 指针)───
-  async ensureCompany(userData: any, sessionToken: string): Promise<void> {
-    if (userData.company && userData.company.objectId) {
-      console.log('[Login] Company 已存在:', userData.company.objectId);
-      return;
-    }
-    console.log('[Login] 用户无 Company,自动创建...');
-    try {
-      // 创建 Company
-      const companyName = (userData.username || 'user') + '_company';
-      const createResp = await fetch(`${API_HOST}/parse/classes/Company`, {
-        method: 'POST',
-        headers: { 'Content-Type': 'application/json', 'X-Parse-Application-Id': APP_ID, 'X-Parse-Session-Token': sessionToken },
-        body: JSON.stringify({ name: companyName })
-      });
-      const created = await createResp.json();
-      if (!created.objectId) {
-        console.warn('[Login] Company 创建失败:', created);
-        return;
-      }
-      console.log('[Login] Company 创建成功:', created.objectId);
-
-      // 绑定到用户
-      const bindResp = await fetch(`${API_HOST}/parse/users/${userData.objectId}`, {
-        method: 'PUT',
-        headers: { 'Content-Type': 'application/json', 'X-Parse-Application-Id': APP_ID, 'X-Parse-Session-Token': sessionToken },
-        body: JSON.stringify({ company: { __type: 'Pointer', className: 'Company', objectId: created.objectId } })
-      });
-      const bindData = await bindResp.json();
-      if (bindResp.ok) {
-        console.log('[Login] 用户已绑定 Company');
-      } else {
-        console.warn('[Login] 用户绑定 Company 失败:', bindData);
-      }
-    } catch (e: any) {
-      console.warn('[Login] ensureCompany 异常:', e.message);
-    }
-  }
 
   // 检查是否已登录(从 localStorage 恢复)
   async checkExistingLogin(): Promise<{ userId: string; sessionToken: string } | null> {