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 QRCode from 'qrcode'; // ═══════════════════════════════════════════════════ // APIG Payment Page — Angular Component // ═══════════════════════════════════════════════════ const API_BASE = 'https://server.fmode.cn'; const PARSE_BASE = API_BASE + '/parse/functions'; const PAY_COMPANY = '1AiWpTEDH9'; const APP_ID = 'ncloudmaster'; const DEFAULT_FUN_ID = 'HOkkX72PMF'; const ORDER_PAGE_SIZE = 10; interface PriceTier { count: number; price: number; isTest?: boolean; } 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; content?: string; count?: number; priceStep?: PriceTier[]; userBalance?: number; // 当前登录用户在该 APIG 的余额(合并 APIGAuth) authId?: string; // 当前用户的 APIGAuth objectId [key: string]: any; } interface TokenCopyProfile { title: string; buttonText: string; copiedText: string; checkoutNote: string; successHint: string; buildCopyText: (token: string, companyId?: string) => string; } // VOC 技能体系相关的 APIG — 一键切换 const SOCIAL_APIG_ID = 'Vo3ROWEvDy'; const ECOMMERCE_APIG_ID = '7HwdQZk55B'; 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 = { '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 = { [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 = { '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: true, primaryApigId: SOCIAL_APIG_ID, allocations: [ { apigId: SOCIAL_APIG_ID, title: '国内外社媒数据中台', count: 300 } ], 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; createdAt: string; isPay?: boolean; price?: number; count?: number; detail?: { addCount?: number; oldCount?: number }; [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({ selector: 'app-root', imports: [CommonModule, RouterOutlet, LoginModalComponent, SkillInstallComponent, QiweiSkillInstallComponent], templateUrl: './app.html', styleUrl: './app.scss' }) export class App implements OnInit { @ViewChild('qrCanvas', { static: false }) qrCanvasRef!: ElementRef; constructor(private cdr: ChangeDetectorRef) {} private readonly routeChangeHandler = () => { this.detectPageFlags(); this.syncWorkshopRouteFromUrl(); }; private readonly logoutHandler = () => this.logout(); // URL params authId = ''; userId = ''; apigId = ''; funId = DEFAULT_FUN_ID; workshopPackage: WorkshopPackage | null = null; workshopPortal = false; workshopPreparing = false; isLauncherPage = false; isSkillInstallPage = false; isQiweiSkillPage = false; isEnterpriseTrialPage = false; // Login state needLogin = false; loggedInUser: string = ''; sessionToken: string = ''; companyId: string = ''; apigList: ApigData[] = []; apigListLoading = false; tokenCopied = false; // State apig: ApigData | null = null; selectedIndex = 0; errorMsg = ''; paying = false; showQrModal = false; showSuccess = false; paySuccess = false; successDetailHtml = ''; // Order state order: any = null; tradeNo = ''; nonceStr = ''; pollTimer: any = null; // Order history orders: OrderData[] = []; ordersLoading = false; orderError = ''; hasMoreOrders = false; orderSkip = 0; launcherDownloads = LAUNCHER_DOWNLOADS; launcherUrlList = LAUNCHER_URL_LIST; 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 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[] { return Object.values(WORKSHOP_PACKAGES).filter(pkg => pkg.visible); } get isWorkshopMode(): boolean { return !!this.workshopPackage || this.workshopPortal; } get pageSubtitle(): string { if (this.isLauncherPage) return 'Studio 启动器下载'; return this.isWorkshopMode ? '情报官技能套餐' : 'API 服务充值'; } get unitLabel(): string { if (!this.apig) return '次'; 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 { window.addEventListener('fmode-logout', this.logoutHandler); this.detectPageFlags(); if (this.isLauncherPage) { this.loadLauncherDownloads(); return; } const params = this.urlParams(); 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 = this.normalizeApigId(params.get('apigid') || this.workshopPackage?.primaryApigId || SOCIAL_APIG_ID); this.funId = this.resolveFunId(params.get('fun_id')); const urlToken = params.get('token') || ''; // 0. URL 携带 token → 使用 Parse become 登录 if (urlToken) { console.log('[AUTH] URL 携带 token,使用 become 登录...'); this.becomeWithToken(urlToken); return; } // 1. URL 已提供足够参数 → 直接加载 if (this.authId || (this.userId && this.apigId)) { console.log('[AUTH] URL 参数充足,直接加载'); if (!this.isSkillInstallPage && !this.isQiweiSkillPage && !this.isEnterpriseTrialPage) this.loadApig(); return; } // 2. URL 没有 user → 检查 localStorage 缓存 const cachedUserId = localStorage.getItem('apig_user_id'); const cachedToken = localStorage.getItem('apig_session_token'); const cachedName = localStorage.getItem('apig_display_name'); if (cachedUserId && cachedToken) { console.log('[AUTH] 从 localStorage 恢复用户:', cachedUserId); this.userId = cachedUserId; this.loggedInUser = cachedName || cachedUserId; this.sessionToken = cachedToken; // 先验证 token,验证通过再加载数据 this.validateAndLoad(cachedToken, cachedUserId); return; } // 3. 什么都没有 → 显示登录弹窗 console.log('[AUTH] 未检测到用户,显示登录弹窗'); this.needLogin = true; } 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 { 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)完成登录 */ async becomeWithToken(token: string): Promise { try { const resp = await fetch(`${API_BASE}/parse/users/me?include=company`, { headers: { 'X-Parse-Application-Id': APP_ID, 'X-Parse-Session-Token': token } }); const data = await resp.json(); if (!data.objectId) { console.warn('[AUTH] become 失败,token 无效:', data); this.errorMsg = 'Token 无效或已过期,请重新登录。'; this.needLogin = true; this.cdr.detectChanges(); return; } // become 成功 — 写入状态 this.userId = data.objectId; this.sessionToken = token; this.loggedInUser = data.nickname || data.username || data.mobile || data.mobilePhoneNumber || data.objectId; this.companyId = data.company?.objectId || ''; const phone = String(data.mobile || data.mobilePhoneNumber || '').trim(); if (phone) localStorage.setItem('apig_phone', phone); console.log('[AUTH] become 成功, user:', data.objectId, data.username || '', 'company:', this.companyId || '(无)'); // 持久化到 localStorage localStorage.setItem('apig_user_id', this.userId); 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) { console.error('[AUTH] become 请求异常:', e); this.errorMsg = 'Token 登录失败: ' + e.message; this.needLogin = true; this.cdr.detectChanges(); } } async validateAndLoad(token: string, userId: string): Promise { try { const resp = await fetch(`${API_BASE}/parse/users/me?include=company`, { headers: { 'X-Parse-Application-Id': APP_ID, 'X-Parse-Session-Token': token } }); const data = await resp.json(); if (!data.objectId) { console.warn('[AUTH] 缓存 token 已失效,需要重新登录'); localStorage.removeItem('apig_user_id'); localStorage.removeItem('apig_session_token'); localStorage.removeItem('apig_display_name'); this.needLogin = true; this.cdr.detectChanges(); return; } // token 有效 — 更新 displayName(可能 localStorage 里是旧的) const serverName = data.nickname || data.username || data.mobile || data.mobilePhoneNumber || userId; if (serverName && serverName !== this.loggedInUser) { this.loggedInUser = serverName; localStorage.setItem('apig_display_name', serverName); } // userId 可能变化(极端情况:session 对应不同用户) if (data.objectId !== userId) { console.warn('[AUTH] session 对应用户不匹配,纠正:', userId, '→', data.objectId); this.userId = data.objectId; localStorage.setItem('apig_user_id', data.objectId); } // 提取 companyId(可选:后端扣费只要 user 指针即可,Company 存在时才附带) this.companyId = data.company?.objectId || ''; 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) { console.warn('[AUTH] 验证 token 失败:', e); // 网络错误时仍然尝试加载(可能离线缓存能用) if (!this.isSkillInstallPage && !this.isQiweiSkillPage && !this.isEnterpriseTrialPage) this.loadApig(); } } async onLoginSuccess(event: { userId: string; sessionToken: string; displayName: string }): Promise { console.log('[AUTH] 登录成功, userId:', event.userId, 'display:', event.displayName); this.userId = event.userId; this.loggedInUser = event.displayName || event.userId; 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); } logout(): void { console.log('[AUTH] 退出登录'); localStorage.removeItem('apig_user_id'); localStorage.removeItem('apig_session_token'); localStorage.removeItem('apig_display_name'); localStorage.removeItem('apig_phone'); this.userId = ''; this.loggedInUser = ''; this.sessionToken = ''; this.companyId = ''; this.authId = ''; this.apig = null; this.orders = []; this.errorMsg = ''; this.showSuccess = false; this.needLogin = true; this.cdr.detectChanges(); } // ─── Load available APIG list + merge user's APIGAuth balance ─── async loadApigList(): Promise { this.apigListLoading = true; try { // 1. 拉 VOC 相关 APIG 基础信息 const query: any = { where: JSON.stringify({ objectId: { $in: VOC_APIG_IDS } }), keys: 'title,content,priceStep', limit: '10' }; const resp = await fetch(API_BASE + '/parse/classes/APIG?' + new URLSearchParams(query), { headers: { 'X-Parse-Application-Id': APP_ID } }); const data = await resp.json(); 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(如果已登录) 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 = {}; 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; this.syncWorkshopAllocationsFromList(); 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.normalizeApigId(this.apigId) === this.normalizeApigId(apigId); } navigateToApig(apigId: string): void { if (this.isCurrentApig(apigId)) return; const url = new URL(window.location.href); url.searchParams.set('apigid', this.normalizeApigId(apigId)); // 切换 APIG 时清空 authid(因为换了 APIG 就要重新解析新的 APIGAuth) url.searchParams.delete('authid'); window.location.href = url.toString(); } onApigSelect(value: string): void { this.navigateToApig(value); } copyToken(): void { if (!this.sessionToken) return; const tokenText = this.tokenCopyProfile.buildCopyText(this.sessionToken, this.companyId); navigator.clipboard.writeText(tokenText).then(() => { this.tokenCopied = true; this.cdr.detectChanges(); setTimeout(() => { this.tokenCopied = false; this.cdr.detectChanges(); }, 2500); }).catch(() => { this.errorMsg = '复制失败,请手动复制:' + tokenText; this.cdr.detectChanges(); }); } // ─── Load APIG Info ─── async loadApig(): Promise { try { // 如果没有 authId 但有 user+apigid,先查询/创建 APIGAuth if (!this.authId && this.userId && this.apigId) { console.log('[INIT] 无 authId,通过 user+apigid 查询 APIGAuth...'); const resolved = await this.resolveAuthId(this.userId, this.apigId); if (!resolved) { this.errorMsg = '无法获取或创建计费账套,请联系管理员。'; this.cdr.detectChanges(); return; } this.authId = resolved; console.log('[INIT] 获取到 authId:', this.authId); } // 1. Try getApig REST endpoint const resp = await this.postJSON(API_BASE + '/api/apig/getApig', { authid: this.authId }); console.log('[getApig] response:', JSON.stringify(resp).substring(0, 500)); if (resp.code === 200 && resp.data) { this.apig = this.decorateApig(resp.data); this.applyTestTier(); this.selectTier(0); this.cdr.detectChanges(); this.loadOrderHistory(); // 同时加载其它 VOC APIG 余额以显示切换器 this.loadApigList(); return; } // 2. Fallback: cloud function console.log('getApig returned no data, trying cloud function...'); const params = this.urlParams(); const cfFuncName = params.get('cfName') || 'getApigInfo'; try { const cfResp = await this.parseCloudCall(cfFuncName, { apigId: this.authId }); if (cfResp?.result) { const cfData = cfResp.result.data || cfResp.result; if (cfData?.objectId) { this.apig = this.decorateApig(cfData); this.applyTestTier(); this.selectTier(0); this.cdr.detectChanges(); this.loadOrderHistory(); this.loadApigList(); return; } } } catch (e: any) { console.warn('cloud function error:', e); } this.errorMsg = '无法加载接口信息。请确认参数正确。'; this.cdr.detectChanges(); } catch (e: any) { this.errorMsg = '网络错误: ' + e.message; this.cdr.detectChanges(); } } // ─── 通过 user+apigid 查询或创建 APIGAuth ─── async resolveAuthId(user: string, apig: string): Promise { apig = this.normalizeApigId(apig); const authHeaders: any = { 'X-Parse-Application-Id': APP_ID }; if (this.sessionToken) authHeaders['X-Parse-Session-Token'] = this.sessionToken; // 1. 精确查询:按 user + api 指针 try { const query: any = { where: JSON.stringify({ api: { __type: 'Pointer', className: 'APIG', objectId: apig }, user: { __type: 'Pointer', className: '_User', objectId: user } }), limit: '1' }; const resp = await fetch(API_BASE + '/parse/classes/APIGAuth?' + new URLSearchParams(query), { headers: authHeaders }); const data = await resp.json(); if (data.results?.length > 0) { console.log('[resolveAuthId] 找到已有 APIGAuth:', data.results[0].objectId, 'count:', data.results[0].count); return data.results[0].objectId; } } catch (e: any) { console.warn('[resolveAuthId] 查询失败:', e.message); } // 2. 未找到 → 为当前用户创建新 APIGAuth(余额=0,需充值) try { console.log('[resolveAuthId] 未找到该用户的 APIGAuth,创建新记录(余额=0)...'); const body: any = { api: { __type: 'Pointer', className: 'APIG', objectId: apig }, user: { __type: 'Pointer', className: '_User', objectId: user }, count: 0, used: 0 }; const createResp = await fetch(API_BASE + '/parse/classes/APIGAuth', { method: 'POST', headers: { ...authHeaders, 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); const created = await createResp.json(); if (created.objectId) { console.log('[resolveAuthId] 创建成功:', created.objectId); return created.objectId; } console.error('[resolveAuthId] 创建失败:', JSON.stringify(created)); } catch (e: any) { console.error('[resolveAuthId] 创建异常:', e.message); } return null; } applyTestTier(): void { // 仅本地开发时允许 URL 带 test=1 追加测试套餐(¥0.01 / 1 次),线上不开放。 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)) { this.apig.priceStep.unshift({ count: 1, price: 0.01, isTest: true }); } } selectTier(idx: number): void { 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 = 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; } /** 兼容两种带参方式:?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); 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(); } 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 { 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 + '(' + this.workshopQuotaLabel + ')'; } 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 { 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 { 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 { 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') + now.getFullYear() + String(now.getMonth() + 1).padStart(2, '0') + String(now.getDate()).padStart(2, '0') + String(now.getHours()).padStart(2, '0') + String(now.getMinutes()).padStart(2, '0') + String(now.getSeconds()).padStart(2, '0') + now.getMilliseconds(); // 2. Create order console.log('[PAY] Creating order...', { tradeNo: this.tradeNo, tier: tier.price, funId: this.funId }); await this.createOrder(tier); console.log('[PAY] Order created, order=', this.order); // 3. Call pay_code2 to get QR URL const payParams: any = { _ApplicationId: APP_ID, company: PAY_COMPANY, out_trade_no: this.tradeNo, total_fee: +tier.price, body: this.getPaymentBody() }; if (this.funId) { payParams.fun_id = this.funId; } console.log('[PAY] Calling pay_code2 with params:', JSON.stringify(payParams)); let payResp: any = null; try { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 15000); const rawResp = await fetch(PARSE_BASE + '/pay_code2', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payParams), signal: controller.signal }); clearTimeout(timeout); payResp = await rawResp.json(); console.log('[PAY] pay_code2 response:', JSON.stringify(payResp)); } catch (fetchErr: any) { console.error('[PAY] pay_code2 fetch error:', fetchErr.message); if (payParams.fun_id) { console.log('[PAY] Retrying pay_code2 WITHOUT fun_id...'); delete payParams.fun_id; payResp = await this.postJSON(PARSE_BASE + '/pay_code2', payParams); console.log('[PAY] pay_code2 retry response:', JSON.stringify(payResp)); } else { throw fetchErr; } } if (!payResp?.result?.code_url) { throw new Error('获取支付码失败: ' + JSON.stringify(payResp)); } const codeUrl = payResp.result.code_url[0]; this.nonceStr = payResp.result.nonce_str; console.log('[PAY] QR code URL:', codeUrl); // 4. Show QR modal this.showQrModal = true; this.paySuccess = false; this.cdr.detectChanges(); // Wait for DOM update, then render QR setTimeout(() => this.renderQR(codeUrl), 100); // 5. Start polling this.startPolling(); } catch (e: any) { this.errorMsg = '支付发起失败: ' + e.message; } finally { this.paying = false; } } async createOrder(tier: PriceTier): Promise { 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: bodyText }, apigid: this.apig!.objectId, oldCount, count: orderCount, 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; console.log('Order created:', this.order); } } catch (e: any) { console.warn('Order creation error (non-blocking):', e.message); } } startPolling(): void { if (this.pollTimer) clearInterval(this.pollTimer); this.pollTimer = setInterval(async () => { try { const resp = await this.postJSON(PARSE_BASE + '/order_status2', { _ApplicationId: APP_ID, out_trade_no: this.tradeNo, nonce_str: this.nonceStr, company: PAY_COMPANY }); if (resp.result?.status?.[0] === 'SUCCESS') { clearInterval(this.pollTimer); this.pollTimer = null; this.paySuccess = true; await this.doRecharge(); } } catch (e: any) { console.warn('Poll error:', e.message); } }, 3000); } async doRecharge(): Promise { if (this.workshopPackage) { await this.doWorkshopRecharge(); return; } this.showQrModal = false; this.showSuccess = true; this.cdr.detectChanges(); const oldCount = this.apig!.count || 0; const tier = this.apig!.priceStep![this.selectedIndex]; 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: 等待后端微信回调自动执行云函数 for (let i = 1; i <= 8; i++) { console.log('[RECHARGE] 步骤1: 等待后端回调... 第' + i + '/8次 (3秒后)'); await this.sleep(3000); await this.refreshBalance(); if (this.apig!.count! > oldCount) { console.log('[RECHARGE] ✅ 后端回调充值成功! 余额:', oldCount, '→', this.apig!.count); this.cdr.detectChanges(); this.loadOrderHistory(); return; } } if (this.isStrictCallbackTest()) { console.warn('[RECHARGE] 严格回调测试模式:后端回调未自动加余额,不调用 saveRecharge 兜底。'); this.successDetailHtml = '支付已成功,但严格回调测试未观察到后端自动充值。
' + '当前余额仍为 ' + this.apig!.count + ' ' + this.unitLabel + ',请检查支付回调/订单充值逻辑。'; this.cdr.detectChanges(); this.loadOrderHistory(); return; } // 步骤2: 后端回调未生效,调用 saveRecharge 保底 console.log('[RECHARGE] 步骤2: 后端回调未生效,调用 saveRecharge 保底...'); await this.saveRechargeFallback(oldCount, tier); // 步骤3: 再轮询确认 await this.confirmRechargeResult(oldCount); } async saveRechargeFallback(oldCount: number, tier: PriceTier): Promise { try { 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)); } catch (e: any) { console.warn('[RECHARGE] saveRecharge 失败:', e.message); } } async confirmRechargeResult(oldCount: number): Promise { for (let i = 1; i <= 3; i++) { await this.sleep(2000); await this.refreshBalance(); if (this.apig!.count! > oldCount) { console.log('[RECHARGE] ✅ saveRecharge 充值成功! 余额:', oldCount, '→', this.apig!.count); break; } } if (this.apig!.count! <= oldCount) { console.warn('[RECHARGE] ⚠️ 充值可能延迟,请稍后刷新页面查看'); } console.log('═══ [RECHARGE] 结束 ═══ 最终余额:', this.apig!.count); this.cdr.detectChanges(); this.loadOrderHistory(); } async doWorkshopRecharge(): Promise { if (!this.workshopPackage) return; this.showQrModal = false; this.showSuccess = true; this.successDetailHtml = '正在开通 ' + this.workshopPackage.title + ',请稍候...'; this.cdr.detectChanges(); try { await this.prepareWorkshopPackage(false); } catch (e: any) { this.successDetailHtml = '支付已完成,但' + this.workshopQuotaLabel + '开通需要人工确认。
' + '原因:' + (e.message || '账套确认失败') + ''; 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 { 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 '
' + '' + item.title + ' +' + item.count + ' 次' + ',当前余额 ' + balance + ' 次,' + status + '' + '
'; }).join(''); return '已开通 ' + this.workshopPackage.title + '
' + rows + '有效期 730 天,情报官训练服务请按工作坊流程交付。'; } async refreshBalance(): Promise { try { const resp = await this.postJSON(API_BASE + '/api/apig/getApig', { authid: this.authId }); if (resp.code === 200 && resp.data?.count != null) { this.apig!.count = resp.data.count; const tier = this.apig!.priceStep![this.selectedIndex]; this.successDetailHtml = '已充值 ' + this.apig!.title + ' 接口
' + '获得 ' + tier.count + ' ' + this.unitLabel + ',有效期 730 天
' + '当前余额: ' + this.apig!.count + ' ' + this.unitLabel + ''; console.log('Balance refreshed:', this.apig!.count); } } catch (e: any) { console.warn('Balance refresh failed:', e.message); } } async renderQR(url: string): Promise { const canvas = this.qrCanvasRef?.nativeElement; if (!canvas) { console.warn('QR canvas not found'); return; } canvas.width = 200; canvas.height = 200; try { await QRCode.toCanvas(canvas, url, { width: 200, margin: 0, color: { dark: '#000000', light: '#ffffff' } }); console.log('QR rendered via canvas'); } catch (e) { console.warn('QRCode.toCanvas failed, using img fallback:', e); canvas.style.display = 'none'; const wrapper = canvas.parentElement; if (wrapper) { const img = document.createElement('img'); img.src = 'https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=' + encodeURIComponent(url); img.width = 200; img.height = 200; img.alt = 'Payment QR Code'; img.style.display = 'block'; wrapper.appendChild(img); } } } cancelPayment(): void { if (this.pollTimer) { clearInterval(this.pollTimer); this.pollTimer = null; } this.showQrModal = false; } handleDone(): void { try { const msg = { type: 'apig-payment-done', authid: this.authId, user: this.userId }; if (window.opener) (window.opener as any).postMessage(msg, '*'); if (window.parent !== window) window.parent.postMessage(msg, '*'); } catch (e) { console.warn('postMessage failed:', e); } try { window.close(); } catch (e) {} setTimeout(() => { window.location.reload(); }, 500); } // ─── Order History ─── async loadOrderHistory(append = false): Promise { this.ordersLoading = true; this.orderError = ''; if (!append) { this.orderSkip = 0; this.orders = []; } // 在 detectChanges 之后触发异步工作,避免在父级 CD 周期内改变 [disabled] 绑定 this.cdr.detectChanges(); try { 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', limit: String(ORDER_PAGE_SIZE), skip: String(this.orderSkip) }); const resp = await fetch(API_BASE + '/parse/classes/APIGOrder?' + qs.toString(), { headers: { 'X-Parse-Application-Id': APP_ID } }); if (!resp.ok) throw new Error('HTTP ' + resp.status); const data = await resp.json(); const newOrders: OrderData[] = data.results || []; console.log('Order history loaded:', newOrders.length, 'skip:', this.orderSkip); this.orders = append ? [...this.orders, ...newOrders] : newOrders; this.hasMoreOrders = newOrders.length >= ORDER_PAGE_SIZE; if (this.hasMoreOrders) this.orderSkip += ORDER_PAGE_SIZE; } catch (e: any) { console.warn('Order history error:', e); this.orderError = e.message; } this.ordersLoading = false; this.cdr.detectChanges(); } // ─── Order formatting helpers ─── formatTime(createdAt: string): string { if (!createdAt) return '—'; const dt = new Date(createdAt); return dt.getFullYear() + '-' + String(dt.getMonth() + 1).padStart(2, '0') + '-' + String(dt.getDate()).padStart(2, '0') + ' ' + String(dt.getHours()).padStart(2, '0') + ':' + String(dt.getMinutes()).padStart(2, '0'); } formatCount(o: OrderData): string { const count = o.detail?.addCount || o.count || null; if (count == null) return '—'; return count + ' ' + this.unitLabel; } formatAmount(o: OrderData): string { return o.price != null ? '¥' + o.price : '—'; } getStatusClass(o: OrderData): string { if (o.isPay === true) { if (o.detail?.addCount && o.detail?.oldCount != null) return 'status-recharged'; return 'status-paid'; } if (o.isPay === false) { const age = Date.now() - new Date(o.createdAt).getTime(); return age > 30 * 60 * 1000 ? 'status-failed' : 'status-pending'; } return 'status-pending'; } getStatusText(o: OrderData): string { if (o.isPay === true) { if (o.detail?.addCount && o.detail?.oldCount != null) return '已充值'; return '已支付'; } if (o.isPay === false) { const age = Date.now() - new Date(o.createdAt).getTime(); return age > 30 * 60 * 1000 ? '已过期' : '待支付'; } return '未知'; } // ─── Helpers ─── async postJSON(url: string, data: any): Promise { const resp = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }); return resp.json(); } async parseCloudCall(funcId: string, params: any): Promise { const url = PARSE_BASE + '/' + funcId; const body = { _ApplicationId: APP_ID, ...params }; const resp = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Parse-Application-Id': APP_ID }, body: JSON.stringify(body) }); if (!resp.ok) { console.warn('Cloud function ' + funcId + ' returned ' + resp.status); return null; } return resp.json(); } private sleep(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)); } }