| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689 |
- 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<string, string> = {
- 'voc-e-commerce': TIHAO_ECOMMERCE_APIG_ID,
- '/apig/voc-e-commerce': TIHAO_ECOMMERCE_APIG_ID
- };
- const VOC_APIG_IDS = [SOCIAL_APIG_ID, ECOMMERCE_APIG_ID, TIHAO_ECOMMERCE_APIG_ID];
- const SOCIAL_TOKEN_COPY_PROFILE: TokenCopyProfile = {
- title: '复制社媒数据中台配置话术',
- buttonText: '复制社媒配置话术',
- copiedText: '已复制社媒话术',
- checkoutNote: '支付成功后,点击“复制社媒配置话术”完成社媒技能包工作区配置。',
- successHint: '下一步:点击右上角「复制社媒配置话术」,将 VOC_TOKEN 写入当前工作区 .env.local 后,再运行小红书或抖音 live 采集。',
- buildCopyText: (token: string) => [
- '请把下面内容保存到当前工作区的 .env.local,用于社媒数据中台技能包的小红书/抖音 live 采集。',
- '完成后重启或刷新 Claude Code,再运行 live 采集任务。',
- '不要提交到 Git,也不要放进截图、报告或聊天记录。',
- '',
- `VOC_TOKEN=${token}`,
- `VOC_SOCIAL_TOKEN=${token}`
- ].join('\n')
- };
- const TIHAO_TOKEN_COPY_PROFILE: TokenCopyProfile = {
- title: '复制提号技能包配置话术',
- buttonText: '复制提号配置话术',
- copiedText: '已复制提号话术',
- checkoutNote: '支付成功后,点击“复制提号配置话术”完成提号技能包工作区配置。',
- successHint: '下一步:点击右上角「复制提号配置话术」,将 TIHAO_SESSION_TOKEN 写入提号项目工作区 .env.local 后,再运行提号或找博主任务。',
- buildCopyText: (token: string, companyId?: string) => [
- '请把下面内容保存到当前工作区的 .env.local,用于提号技能包调用国内电商数据中台 live 模式。',
- '完成后重启或刷新 Claude Code,再运行“提号 / 找博主 / 读取 brief”的 live 任务。',
- '不要提交到 Git,也不要放进截图、报告或聊天记录。',
- '',
- `TIHAO_SESSION_TOKEN=${token}`,
- ...(companyId ? [`TIHAO_COMPANY=${companyId}`] : [])
- ].join('\n')
- };
- const DEFAULT_TOKEN_COPY_PROFILE: TokenCopyProfile = {
- title: '复制 API 服务配置话术',
- buttonText: '复制服务配置话术',
- copiedText: '已复制配置话术',
- checkoutNote: '支付成功后,点击“复制服务配置话术”完成对应工作区配置。',
- successHint: '下一步:点击右上角「复制服务配置话术」,按对应技能包要求写入当前工作区 .env.local。',
- buildCopyText: (token: string) => [
- '请把下面内容保存到当前工作区的 .env.local,并按对应技能包要求使用这个 APIG 授权 token。',
- '不要提交到 Git,也不要放进截图、报告或聊天记录。',
- '',
- `FMODE_APIG_SESSION_TOKEN=${token}`
- ].join('\n')
- };
- const TOKEN_COPY_PROFILES: Record<string, TokenCopyProfile> = {
- [SOCIAL_APIG_ID]: SOCIAL_TOKEN_COPY_PROFILE,
- [ECOMMERCE_APIG_ID]: TIHAO_TOKEN_COPY_PROFILE,
- [TIHAO_ECOMMERCE_APIG_ID]: TIHAO_TOKEN_COPY_PROFILE
- };
- const WORKSHOP_PACKAGES: Record<string, WorkshopPackage> = {
- '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<HTMLCanvasElement>;
- 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<void> {
- try {
- const resp = await fetch(LAUNCHER_URL_LIST, { cache: 'no-store' });
- if (!resp.ok) return;
- const text = await resp.text();
- const parsed = this.parseLauncherDownloads(text);
- if (parsed.length > 0) {
- this.launcherDownloads = parsed;
- this.cdr.detectChanges();
- }
- } catch (e: any) {
- console.warn('[LAUNCHER] 下载链接清单加载失败,使用内置链接:', e.message);
- }
- }
- parseLauncherDownloads(text: string): LauncherDownload[] {
- const downloads = text
- .split(/\r?\n/)
- .map(line => line.trim())
- .filter(line => line.startsWith('http'))
- .map(url => this.toLauncherDownload(url))
- .filter((item): item is LauncherDownload => !!item);
- const rank = (item: LauncherDownload): number => {
- const key = item.os + '-' + item.arch;
- return {
- 'Windows-x64': 1,
- 'Windows-ARM64': 2,
- 'macOS-Apple Silicon': 3,
- 'macOS-Intel': 4,
- 'Linux-x64': 5,
- 'Linux-ARM64': 6
- }[key] || 99;
- };
- return downloads.sort((a, b) => rank(a) - rank(b));
- }
- toLauncherDownload(url: string): LauncherDownload | null {
- const lower = url.toLowerCase();
- if (lower.includes('win-x64')) {
- return { os: 'Windows', arch: 'x64', label: 'Windows x64', url, primary: true };
- }
- if (lower.includes('win-arm64')) {
- return { os: 'Windows', arch: 'ARM64', label: 'Windows ARM64', url };
- }
- if (lower.includes('macos-arm64')) {
- return { os: 'macOS', arch: 'Apple Silicon', label: 'macOS Apple Silicon', url, primary: true };
- }
- if (lower.includes('macos-x64')) {
- return { os: 'macOS', arch: 'Intel', label: 'macOS Intel', url };
- }
- if (lower.includes('linux-x64')) {
- return { os: 'Linux', arch: 'x64', label: 'Linux x64', url };
- }
- if (lower.includes('linux-arm64')) {
- return { os: 'Linux', arch: 'ARM64', label: 'Linux ARM64', url };
- }
- return null;
- }
- /**
- * 通过 URL 中的 token 调用 Parse Server 的 become(fetch /parse/users/me)完成登录
- */
- async becomeWithToken(token: string): Promise<void> {
- 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<void> {
- 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<void> {
- 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<void> {
- 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<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;
- 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<void> {
- 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<string | null> {
- 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<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> {
- 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<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: 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<void> {
- 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 =
- '支付已成功,但严格回调测试未观察到后端自动充值。<br>' +
- '<span style="color:var(--text-dim);margin-top:8px;display:inline-block;">当前余额仍为 <strong style="color:var(--neon);">' +
- this.apig!.count + ' ' + this.unitLabel + '</strong>,请检查支付回调/订单充值逻辑。</span>';
- this.cdr.detectChanges();
- this.loadOrderHistory();
- return;
- }
- // 步骤2: 后端回调未生效,调用 saveRecharge 保底
- console.log('[RECHARGE] 步骤2: 后端回调未生效,调用 saveRecharge 保底...');
- await this.saveRechargeFallback(oldCount, tier);
- // 步骤3: 再轮询确认
- await this.confirmRechargeResult(oldCount);
- }
- async saveRechargeFallback(oldCount: number, tier: PriceTier): Promise<void> {
- 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<void> {
- 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<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 =
- '支付已完成,但' + this.workshopQuotaLabel + '开通需要人工确认。<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 });
- if (resp.code === 200 && resp.data?.count != null) {
- this.apig!.count = resp.data.count;
- const tier = this.apig!.priceStep![this.selectedIndex];
- this.successDetailHtml =
- '已充值 <strong style="color:var(--neon);">' + this.apig!.title + '</strong> 接口<br>' +
- '获得 <strong style="color:var(--success);">' + tier.count + ' ' + this.unitLabel + '</strong>,有效期 730 天<br>' +
- '<span style="color:var(--text-dim);margin-top:8px;display:inline-block;">当前余额: <strong style="color:var(--neon);">' + this.apig!.count + ' ' + this.unitLabel + '</strong></span>';
- console.log('Balance refreshed:', this.apig!.count);
- }
- } catch (e: any) {
- console.warn('Balance refresh failed:', e.message);
- }
- }
- async renderQR(url: string): Promise<void> {
- 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<void> {
- 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<any> {
- 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<any> {
- 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<void> {
- return new Promise(resolve => setTimeout(resolve, ms));
- }
- }
|