Преглед изворни кода

Merge branch 'master' of https://git.fmode.cn/18307996893/apig-pay

gangvy пре 1 недеља
родитељ
комит
a6eff19f22
4 измењених фајлова са 108 додато и 38 уклоњено
  1. 17 2
      src/app/app.config.ts
  2. 1 7
      src/app/app.html
  3. 56 23
      src/app/app.ts
  4. 34 6
      src/app/enterprise-trial.component.ts

+ 17 - 2
src/app/app.config.ts

@@ -1,9 +1,24 @@
-import { ApplicationConfig, provideBrowserGlobalErrorListeners, provideZonelessChangeDetection } from '@angular/core';
+import { Component, ApplicationConfig, provideBrowserGlobalErrorListeners, provideZonelessChangeDetection } from '@angular/core';
+import { provideRouter, Routes, withHashLocation } from '@angular/router';
+import { EnterpriseTrialComponent } from './enterprise-trial.component';
+
+/** 兜底路由:非 enterprise-trial 的 hash 页面仍由 App 的页面标志渲染,这里仅静默 Router 的 NoMatch 报错 */
+@Component({
+  selector: 'app-empty-route',
+  standalone: true,
+  template: ''
+})
+export class EmptyRouteComponent {}
+
+const routes: Routes = [
+  { path: 'enterprise-trial', component: EnterpriseTrialComponent },
+  { path: '**', component: EmptyRouteComponent }
+];
 
 export const appConfig: ApplicationConfig = {
   providers: [
     provideBrowserGlobalErrorListeners(),
     provideZonelessChangeDetection(),
-    
+    provideRouter(routes, withHashLocation())
   ]
 };

+ 1 - 7
src/app/app.html

@@ -23,13 +23,7 @@
   (logout)="logout()">
 </app-qiwei-skill-install>
 
-<app-enterprise-trial
-  *ngIf="isEnterpriseTrialPage && !needLogin && sessionToken && userId"
-  [sessionToken]="sessionToken"
-  [userId]="userId"
-  [displayName]="loggedInUser"
-  (logout)="logout()">
-</app-enterprise-trial>
+<router-outlet></router-outlet>
 
 <!-- Header -->
 <div class="page-header" *ngIf="!isSkillInstallPage && !isQiweiSkillPage && !isEnterpriseTrialPage">

+ 56 - 23
src/app/app.ts

@@ -1,9 +1,9 @@
 import { Component, OnInit, ViewChild, ElementRef, ChangeDetectorRef } from '@angular/core';
 import { CommonModule } from '@angular/common';
+import { RouterOutlet } from '@angular/router';
 import { LoginModalComponent } from './login-modal.component';
 import { SkillInstallComponent } from './skill-install.component';
 import { QiweiSkillInstallComponent } from './qiwei-skill-install.component';
-import { EnterpriseTrialComponent } from './enterprise-trial.component';
 
 import QRCode from 'qrcode';
 
@@ -287,7 +287,7 @@ const LAUNCHER_URL_LIST = 'https://repos.fmode.cn/x/launcher/url.txt';
 
 @Component({
   selector: 'app-root',
-  imports: [CommonModule, LoginModalComponent, SkillInstallComponent, QiweiSkillInstallComponent, EnterpriseTrialComponent],
+  imports: [CommonModule, RouterOutlet, LoginModalComponent, SkillInstallComponent, QiweiSkillInstallComponent],
   templateUrl: './app.html',
   styleUrl: './app.scss'
 })
@@ -295,7 +295,11 @@ export class App implements OnInit {
   @ViewChild('qrCanvas', { static: false }) qrCanvasRef!: ElementRef<HTMLCanvasElement>;
 
   constructor(private cdr: ChangeDetectorRef) {}
-  private readonly routeChangeHandler = () => this.syncWorkshopRouteFromUrl();
+  private readonly routeChangeHandler = () => {
+    this.detectPageFlags();
+    this.syncWorkshopRouteFromUrl();
+  };
+  private readonly logoutHandler = () => this.logout();
 
   // URL params
   authId = '';
@@ -452,27 +456,14 @@ export class App implements OnInit {
   }
 
   ngOnInit(): void {
-    this.isLauncherPage = window.location.pathname.includes('/launcher') || window.location.hash === '#/launcher';
-    this.isSkillInstallPage = window.location.pathname.includes('/skill-install') || window.location.hash.startsWith('#/skill-install');
-    this.isQiweiSkillPage = window.location.pathname.includes('/qiwei-skill') ||
-      window.location.pathname.includes('/qiwei-trial') ||
-      window.location.hash.startsWith('#/qiwei-skill') ||
-      window.location.hash.startsWith('#/qiwei-trial');
-    this.isEnterpriseTrialPage = window.location.pathname.includes('/enterprise-trial') ||
-      window.location.hash.startsWith('#/enterprise-trial');
-    if (this.isEnterpriseTrialPage) {
-      document.title = '领取 7 天企业微服务试用 | Fmode';
-    } else if (this.isQiweiSkillPage) {
-      document.title = '领取 7 天企微数字员工试用 | Fmode';
-    } else if (this.isSkillInstallPage) {
-      document.title = '开启 VOC 数据洞察 | Fmode';
-    }
+    window.addEventListener('fmode-logout', this.logoutHandler);
+    this.detectPageFlags();
     if (this.isLauncherPage) {
       this.loadLauncherDownloads();
       return;
     }
 
-    const params = new URLSearchParams(window.location.search);
+    const params = this.urlParams();
     this.syncWorkshopRouteFromUrl(false);
     window.addEventListener('hashchange', this.routeChangeHandler);
     window.addEventListener('popstate', this.routeChangeHandler);
@@ -518,6 +509,26 @@ export class App implements OnInit {
   ngOnDestroy(): void {
     window.removeEventListener('hashchange', this.routeChangeHandler);
     window.removeEventListener('popstate', this.routeChangeHandler);
+    window.removeEventListener('fmode-logout', this.logoutHandler);
+  }
+
+  /** 从当前 URL 重算各页面类型标志与标题(初始加载与 hashchange/popstate 共用) */
+  private detectPageFlags(): void {
+    this.isLauncherPage = window.location.pathname.includes('/launcher') || window.location.hash === '#/launcher';
+    this.isSkillInstallPage = window.location.pathname.includes('/skill-install') || window.location.hash.startsWith('#/skill-install');
+    this.isQiweiSkillPage = window.location.pathname.includes('/qiwei-skill') ||
+      window.location.pathname.includes('/qiwei-trial') ||
+      window.location.hash.startsWith('#/qiwei-skill') ||
+      window.location.hash.startsWith('#/qiwei-trial');
+    this.isEnterpriseTrialPage = window.location.pathname.includes('/enterprise-trial') ||
+      window.location.hash.startsWith('#/enterprise-trial');
+    if (this.isEnterpriseTrialPage) {
+      document.title = '领取 7 天企业微服务试用 | Fmode';
+    } else if (this.isQiweiSkillPage) {
+      document.title = '领取 7 天企微数字员工试用 | Fmode';
+    } else if (this.isSkillInstallPage) {
+      document.title = '开启 VOC 数据洞察 | Fmode';
+    }
   }
 
   async loadLauncherDownloads(): Promise<void> {
@@ -613,6 +624,11 @@ export class App implements OnInit {
       localStorage.setItem('apig_session_token', token);
       localStorage.setItem('apig_display_name', this.loggedInUser);
 
+      // 广播登录事件,供路由内独立渲染的 enterprise-trial 组件同步登录态
+      window.dispatchEvent(new CustomEvent('fmode-auth-success', {
+        detail: { userId: this.userId, sessionToken: token, displayName: this.loggedInUser }
+      }));
+
       this.cdr.detectChanges();
       if (!this.isSkillInstallPage && !this.isQiweiSkillPage && !this.isEnterpriseTrialPage) this.loadApig();
     } catch (e: any) {
@@ -658,6 +674,10 @@ export class App implements OnInit {
       const phone = String(data.mobile || data.mobilePhoneNumber || '').trim();
       if (phone) localStorage.setItem('apig_phone', phone);
       console.log('[AUTH] token 验证通过, user:', data.objectId, data.username || '', 'company:', this.companyId || '(用户无 Company,仅用 user 指针扣费)');
+      // 广播登录事件,供路由内独立渲染的 enterprise-trial 组件刷新(服务端可能返回了更新的 displayName)
+      window.dispatchEvent(new CustomEvent('fmode-auth-success', {
+        detail: { userId: this.userId, sessionToken: token, displayName: this.loggedInUser }
+      }));
       this.cdr.detectChanges();
       if (!this.isSkillInstallPage && !this.isQiweiSkillPage && !this.isEnterpriseTrialPage) this.loadApig();
     } catch (e) {
@@ -674,6 +694,7 @@ export class App implements OnInit {
     this.sessionToken = event.sessionToken;
     this.needLogin = false;
     this.errorMsg = '';
+    window.dispatchEvent(new CustomEvent('fmode-auth-success', { detail: event }));
 
     // 通过 validateAndLoad 统一处理 Company 解析 + 数据加载
     await this.validateAndLoad(event.sessionToken, event.userId);
@@ -831,7 +852,7 @@ export class App implements OnInit {
 
       // 2. Fallback: cloud function
       console.log('getApig returned no data, trying cloud function...');
-      const params = new URLSearchParams(window.location.search);
+      const params = this.urlParams();
       const cfFuncName = params.get('cfName') || 'getApigInfo';
       try {
         const cfResp = await this.parseCloudCall(cfFuncName, { apigId: this.authId });
@@ -916,7 +937,7 @@ export class App implements OnInit {
 
   applyTestTier(): void {
     // 仅本地开发时允许 URL 带 test=1 追加测试套餐(¥0.01 / 1 次),线上不开放。
-    const params = new URLSearchParams(window.location.search);
+    const params = this.urlParams();
     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)) {
@@ -934,13 +955,25 @@ export class App implements OnInit {
   }
 
   isStrictCallbackTest(): boolean {
-    const params = new URLSearchParams(window.location.search);
+    const params = this.urlParams();
     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 {
+  /** 兼容两种带参方式:?a=1#/route 与 #/route?a=1;hash 内参数仅作补充,不覆盖 search 已有键 */
+  private urlParams(): URLSearchParams {
     const params = new URLSearchParams(window.location.search);
+    const hashQuery = window.location.hash.includes('?') ? window.location.hash.split('?')[1] : '';
+    if (hashQuery) {
+      for (const [key, value] of new URLSearchParams(hashQuery)) {
+        if (!params.has(key)) params.set(key, value);
+      }
+    }
+    return params;
+  }
+
+  syncWorkshopRouteFromUrl(shouldDetectChanges = true): void {
+    const params = this.urlParams();
     const nextPackage = this.resolveWorkshopPackage(params);
     this.workshopPackage = nextPackage;
     this.workshopPortal = !nextPackage && this.isWorkshopPortal(params);

+ 34 - 6
src/app/enterprise-trial.component.ts

@@ -1,4 +1,4 @@
-import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnDestroy, OnInit, Output, signal } from '@angular/core';
+import { ChangeDetectionStrategy, Component, Input, OnDestroy, OnInit, signal } from '@angular/core';
 import { CommonModule } from '@angular/common';
 import { buildEnterpriseAgentPrompt, maskEnterpriseAgentPrompt } from './enterprise-agent-prompt';
 
@@ -66,7 +66,7 @@ interface AgentPromptResult {
         </a>
         <div class="user-area">
           <span>{{ displayName || userId }}</span>
-          <button type="button" class="icon-button" title="退出登录" aria-label="退出登录" (click)="logout.emit()">
+          <button type="button" class="icon-button" title="退出登录" aria-label="退出登录" (click)="logout()">
             <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M10 17l5-5-5-5M15 12H3M21 3v18h-7"/></svg>
           </button>
         </div>
@@ -234,10 +234,9 @@ interface AgentPromptResult {
   `]
 })
 export class EnterpriseTrialComponent implements OnInit, OnDestroy {
-  @Input({ required: true }) sessionToken = '';
-  @Input({ required: true }) userId = '';
+  @Input() sessionToken = '';
+  @Input() userId = '';
   @Input() displayName = '';
-  @Output() logout = new EventEmitter<void>();
 
   readonly name = signal('');
   readonly phone = signal('');
@@ -254,15 +253,38 @@ export class EnterpriseTrialComponent implements OnInit, OnDestroy {
   private runtimeUser: any | null = null;
 
   private readonly pollTimer = signal<ReturnType<typeof setInterval> | null>(null);
+  private readonly authHandler = (event: Event) => {
+    const detail = (event as CustomEvent<{ userId: string; sessionToken: string; displayName: string }>).detail;
+    if (!detail?.sessionToken) return;
+    this.userId = detail.userId;
+    this.sessionToken = detail.sessionToken;
+    this.displayName = detail.displayName || detail.userId;
+    this.needRouteAuthRefresh();
+  };
 
   ngOnInit(): void {
+    this.sessionToken ||= localStorage.getItem('apig_session_token') || '';
+    this.userId ||= localStorage.getItem('apig_user_id') || '';
+    this.displayName ||= localStorage.getItem('apig_display_name') || '';
+    window.addEventListener('fmode-auth-success', this.authHandler);
     void this.initialize();
   }
 
   ngOnDestroy(): void {
+    window.removeEventListener('fmode-auth-success', this.authHandler);
     this.stopPolling();
   }
 
+  logout(): void {
+    // 组件由 Router 独立渲染,App 侧通过 fmode-logout 事件接收并清理登录态
+    window.dispatchEvent(new CustomEvent('fmode-logout'));
+  }
+
+  private needRouteAuthRefresh(): void {
+    this.errorMessage.set('');
+    void this.initialize();
+  }
+
   get validForm(): boolean {
     return this.name().trim().length > 0 && /^1[3-9]\d{9}$/.test(this.phone().trim());
   }
@@ -356,12 +378,16 @@ export class EnterpriseTrialComponent implements OnInit, OnDestroy {
     }
   }
 
+  private initializeSeq = 0;
+
   private async initialize(): Promise<void> {
+    const seq = ++this.initializeSeq;
     this.statusLoading.set(true);
     try {
       const cachedPhone = localStorage.getItem('apig_phone')?.trim() || '';
       if (cachedPhone) this.phone.set(cachedPhone);
       const me = await this.fetchMe();
+      if (seq !== this.initializeSeq) return;
       this.runtimeUser = me;
       const parsePhone = this.userPhone(me);
       if (!cachedPhone && parsePhone) {
@@ -369,11 +395,13 @@ export class EnterpriseTrialComponent implements OnInit, OnDestroy {
         localStorage.setItem('apig_phone', parsePhone);
       }
       const data = await this.loadStatus();
+      if (seq !== this.initializeSeq) return;
       if (data) this.setResult(data);
     } catch (error: any) {
       console.warn('[EnterpriseTrial] status init failed:', error?.message);
     } finally {
-      this.statusLoading.set(false);
+      // 仅最新一次 initialize 负责收尾,避免并发初始化时旧请求覆盖新状态
+      if (seq === this.initializeSeq) this.statusLoading.set(false);
     }
   }