app.ts 61 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689
  1. import { Component, OnInit, ViewChild, ElementRef, ChangeDetectorRef } from '@angular/core';
  2. import { CommonModule } from '@angular/common';
  3. import { RouterOutlet } from '@angular/router';
  4. import { LoginModalComponent } from './login-modal.component';
  5. import { SkillInstallComponent } from './skill-install.component';
  6. import { QiweiSkillInstallComponent } from './qiwei-skill-install.component';
  7. import QRCode from 'qrcode';
  8. // ═══════════════════════════════════════════════════
  9. // APIG Payment Page — Angular Component
  10. // ═══════════════════════════════════════════════════
  11. const API_BASE = 'https://server.fmode.cn';
  12. const PARSE_BASE = API_BASE + '/parse/functions';
  13. const PAY_COMPANY = '1AiWpTEDH9';
  14. const APP_ID = 'ncloudmaster';
  15. const DEFAULT_FUN_ID = 'HOkkX72PMF';
  16. const ORDER_PAGE_SIZE = 10;
  17. interface PriceTier {
  18. count: number;
  19. price: number;
  20. isTest?: boolean;
  21. }
  22. interface WorkshopAllocation {
  23. apigId: string;
  24. title: string;
  25. count: number;
  26. authId?: string;
  27. oldCount?: number;
  28. balance?: number;
  29. }
  30. interface WorkshopPackage {
  31. slug: string;
  32. title: string;
  33. badge: string;
  34. subtitle: string;
  35. scene: string;
  36. price: number;
  37. visible: boolean;
  38. primaryApigId: string;
  39. allocations: WorkshopAllocation[];
  40. includes: string[];
  41. }
  42. interface ApigData {
  43. objectId: string;
  44. title: string;
  45. content?: string;
  46. count?: number;
  47. priceStep?: PriceTier[];
  48. userBalance?: number; // 当前登录用户在该 APIG 的余额(合并 APIGAuth)
  49. authId?: string; // 当前用户的 APIGAuth objectId
  50. [key: string]: any;
  51. }
  52. interface TokenCopyProfile {
  53. title: string;
  54. buttonText: string;
  55. copiedText: string;
  56. checkoutNote: string;
  57. successHint: string;
  58. buildCopyText: (token: string, companyId?: string) => string;
  59. }
  60. // VOC 技能体系相关的 APIG — 一键切换
  61. const SOCIAL_APIG_ID = 'Vo3ROWEvDy';
  62. const ECOMMERCE_APIG_ID = '7HwdQZk55B';
  63. const TIHAO_ECOMMERCE_APIG_ID = 'G0vmsUI44d';
  64. const TIHAO_ECOMMERCE_APIG_PRICE_STEP: PriceTier[] = [
  65. { count: 1000, price: 300 },
  66. { count: 3000, price: 750 },
  67. { count: 10000, price: 2000 }
  68. ];
  69. const APIG_ALIASES: Record<string, string> = {
  70. 'voc-e-commerce': TIHAO_ECOMMERCE_APIG_ID,
  71. '/apig/voc-e-commerce': TIHAO_ECOMMERCE_APIG_ID
  72. };
  73. const VOC_APIG_IDS = [SOCIAL_APIG_ID, ECOMMERCE_APIG_ID, TIHAO_ECOMMERCE_APIG_ID];
  74. const SOCIAL_TOKEN_COPY_PROFILE: TokenCopyProfile = {
  75. title: '复制社媒数据中台配置话术',
  76. buttonText: '复制社媒配置话术',
  77. copiedText: '已复制社媒话术',
  78. checkoutNote: '支付成功后,点击“复制社媒配置话术”完成社媒技能包工作区配置。',
  79. successHint: '下一步:点击右上角「复制社媒配置话术」,将 VOC_TOKEN 写入当前工作区 .env.local 后,再运行小红书或抖音 live 采集。',
  80. buildCopyText: (token: string) => [
  81. '请把下面内容保存到当前工作区的 .env.local,用于社媒数据中台技能包的小红书/抖音 live 采集。',
  82. '完成后重启或刷新 Claude Code,再运行 live 采集任务。',
  83. '不要提交到 Git,也不要放进截图、报告或聊天记录。',
  84. '',
  85. `VOC_TOKEN=${token}`,
  86. `VOC_SOCIAL_TOKEN=${token}`
  87. ].join('\n')
  88. };
  89. const TIHAO_TOKEN_COPY_PROFILE: TokenCopyProfile = {
  90. title: '复制提号技能包配置话术',
  91. buttonText: '复制提号配置话术',
  92. copiedText: '已复制提号话术',
  93. checkoutNote: '支付成功后,点击“复制提号配置话术”完成提号技能包工作区配置。',
  94. successHint: '下一步:点击右上角「复制提号配置话术」,将 TIHAO_SESSION_TOKEN 写入提号项目工作区 .env.local 后,再运行提号或找博主任务。',
  95. buildCopyText: (token: string, companyId?: string) => [
  96. '请把下面内容保存到当前工作区的 .env.local,用于提号技能包调用国内电商数据中台 live 模式。',
  97. '完成后重启或刷新 Claude Code,再运行“提号 / 找博主 / 读取 brief”的 live 任务。',
  98. '不要提交到 Git,也不要放进截图、报告或聊天记录。',
  99. '',
  100. `TIHAO_SESSION_TOKEN=${token}`,
  101. ...(companyId ? [`TIHAO_COMPANY=${companyId}`] : [])
  102. ].join('\n')
  103. };
  104. const DEFAULT_TOKEN_COPY_PROFILE: TokenCopyProfile = {
  105. title: '复制 API 服务配置话术',
  106. buttonText: '复制服务配置话术',
  107. copiedText: '已复制配置话术',
  108. checkoutNote: '支付成功后,点击“复制服务配置话术”完成对应工作区配置。',
  109. successHint: '下一步:点击右上角「复制服务配置话术」,按对应技能包要求写入当前工作区 .env.local。',
  110. buildCopyText: (token: string) => [
  111. '请把下面内容保存到当前工作区的 .env.local,并按对应技能包要求使用这个 APIG 授权 token。',
  112. '不要提交到 Git,也不要放进截图、报告或聊天记录。',
  113. '',
  114. `FMODE_APIG_SESSION_TOKEN=${token}`
  115. ].join('\n')
  116. };
  117. const TOKEN_COPY_PROFILES: Record<string, TokenCopyProfile> = {
  118. [SOCIAL_APIG_ID]: SOCIAL_TOKEN_COPY_PROFILE,
  119. [ECOMMERCE_APIG_ID]: TIHAO_TOKEN_COPY_PROFILE,
  120. [TIHAO_ECOMMERCE_APIG_ID]: TIHAO_TOKEN_COPY_PROFILE
  121. };
  122. const WORKSHOP_PACKAGES: Record<string, WorkshopPackage> = {
  123. '19-9': {
  124. slug: '19-9',
  125. title: '课程特惠体验包',
  126. badge: '课程专享',
  127. subtitle: '给课程学员使用的课堂练习包,适合跟着老师完成基础实操。',
  128. scene: '课程学习',
  129. price: 19.9,
  130. visible: false,
  131. primaryApigId: SOCIAL_APIG_ID,
  132. allocations: [
  133. { apigId: SOCIAL_APIG_ID, title: '国内外社媒数据中台', count: 100 },
  134. { apigId: ECOMMERCE_APIG_ID, title: '电商数据中台', count: 100 }
  135. ],
  136. includes: ['课堂练习额度', '基础情报官模板', '不含复盘服务']
  137. },
  138. '29-9': {
  139. slug: '29-9',
  140. title: '轻量级工作坊',
  141. badge: '轻量入门',
  142. subtitle: '适合轻量级工作坊和短课实操,只开通社媒数据中台额度。',
  143. scene: '轻量工作坊',
  144. price: 29.9,
  145. visible: true,
  146. primaryApigId: SOCIAL_APIG_ID,
  147. allocations: [
  148. { apigId: SOCIAL_APIG_ID, title: '国内外社媒数据中台', count: 300 }
  149. ],
  150. includes: ['社媒数据额度', '工作坊轻量练习', '不含电商数据中台额度']
  151. },
  152. '99': {
  153. slug: '99',
  154. title: '纯享练习包',
  155. badge: '低门槛',
  156. subtitle: '适合公域课程和社群用户自己练习,不附加人工服务。',
  157. scene: '自助学习',
  158. price: 99,
  159. visible: true,
  160. primaryApigId: SOCIAL_APIG_ID,
  161. allocations: [
  162. { apigId: SOCIAL_APIG_ID, title: '国内外社媒数据中台', count: 500 },
  163. { apigId: ECOMMERCE_APIG_ID, title: '电商数据中台', count: 500 }
  164. ],
  165. includes: ['自助练习额度', '情报官模板', '不含答疑/复盘']
  166. },
  167. '299': {
  168. slug: '299',
  169. title: '现场实训包',
  170. badge: '现场实操',
  171. subtitle: '适合线下课程后继续实操,现场开通额度并训练基础情报官。',
  172. scene: '线下课程',
  173. price: 299,
  174. visible: true,
  175. primaryApigId: SOCIAL_APIG_ID,
  176. allocations: [
  177. { apigId: SOCIAL_APIG_ID, title: '国内外社媒数据中台', count: 1000 },
  178. { apigId: ECOMMERCE_APIG_ID, title: '电商数据中台', count: 1000 }
  179. ],
  180. includes: ['基础情报官训练', '龙虾场景案例测试', '使用模板']
  181. },
  182. '499': {
  183. slug: '499',
  184. title: '情报官实战包',
  185. badge: '实战训练',
  186. subtitle: '基于龙虾业务经验训练 1 个情报官角色,现场完成真实任务测试。',
  187. scene: '企业实训',
  188. price: 499,
  189. visible: false,
  190. primaryApigId: SOCIAL_APIG_ID,
  191. allocations: [
  192. { apigId: SOCIAL_APIG_ID, title: '国内外社媒数据中台', count: 2000 },
  193. { apigId: ECOMMERCE_APIG_ID, title: '电商数据中台', count: 2000 }
  194. ],
  195. includes: ['情报官角色设定', '龙虾业务场景调优', '3 个真实任务测试', '使用 SOP 模板']
  196. },
  197. '699': {
  198. slug: '699',
  199. title: '情报官团队包',
  200. badge: '交付服务',
  201. subtitle: '适合想直接解决问题的重度用户,含复盘或辅助产出一份专业报告。',
  202. scene: '企业/重度用户',
  203. price: 699,
  204. visible: true,
  205. primaryApigId: SOCIAL_APIG_ID,
  206. allocations: [
  207. { apigId: SOCIAL_APIG_ID, title: '国内外社媒数据中台', count: 3000 },
  208. { apigId: ECOMMERCE_APIG_ID, title: '电商数据中台', count: 3000 }
  209. ],
  210. includes: ['情报官角色设定', '龙虾业务场景调优', '团队共用额度', '1 次线上复盘', '情报官模板二次优化']
  211. }
  212. };
  213. interface OrderData {
  214. objectId: string;
  215. createdAt: string;
  216. isPay?: boolean;
  217. price?: number;
  218. count?: number;
  219. detail?: { addCount?: number; oldCount?: number };
  220. [key: string]: any;
  221. }
  222. interface LauncherDownload {
  223. os: 'Windows' | 'macOS' | 'Linux';
  224. arch: string;
  225. label: string;
  226. url: string;
  227. primary?: boolean;
  228. }
  229. const LAUNCHER_DOWNLOADS: LauncherDownload[] = [
  230. {
  231. os: 'Windows',
  232. arch: 'x64',
  233. label: 'Windows x64',
  234. url: 'https://repos.fmode.cn/x/launcher/FmodeStudioLauncher-win-x64.exe',
  235. primary: true
  236. },
  237. {
  238. os: 'Windows',
  239. arch: 'ARM64',
  240. label: 'Windows ARM64',
  241. url: 'https://repos.fmode.cn/x/launcher/FmodeStudioLauncher-win-arm64.exe'
  242. },
  243. {
  244. os: 'macOS',
  245. arch: 'Apple Silicon',
  246. label: 'macOS Apple Silicon',
  247. url: 'https://repos.fmode.cn/x/launcher/fmode-studio-launcher-macos-arm64',
  248. primary: true
  249. },
  250. {
  251. os: 'macOS',
  252. arch: 'Intel',
  253. label: 'macOS Intel',
  254. url: 'https://repos.fmode.cn/x/launcher/fmode-studio-launcher-macos-x64'
  255. },
  256. {
  257. os: 'Linux',
  258. arch: 'x64',
  259. label: 'Linux x64',
  260. url: 'https://repos.fmode.cn/x/launcher/fmode-studio-launcher-linux-x64'
  261. },
  262. {
  263. os: 'Linux',
  264. arch: 'ARM64',
  265. label: 'Linux ARM64',
  266. url: 'https://repos.fmode.cn/x/launcher/fmode-studio-launcher-linux-arm64'
  267. }
  268. ];
  269. const LAUNCHER_URL_LIST = 'https://repos.fmode.cn/x/launcher/url.txt';
  270. @Component({
  271. selector: 'app-root',
  272. imports: [CommonModule, RouterOutlet, LoginModalComponent, SkillInstallComponent, QiweiSkillInstallComponent],
  273. templateUrl: './app.html',
  274. styleUrl: './app.scss'
  275. })
  276. export class App implements OnInit {
  277. @ViewChild('qrCanvas', { static: false }) qrCanvasRef!: ElementRef<HTMLCanvasElement>;
  278. constructor(private cdr: ChangeDetectorRef) {}
  279. private readonly routeChangeHandler = () => {
  280. this.detectPageFlags();
  281. this.syncWorkshopRouteFromUrl();
  282. };
  283. private readonly logoutHandler = () => this.logout();
  284. // URL params
  285. authId = '';
  286. userId = '';
  287. apigId = '';
  288. funId = DEFAULT_FUN_ID;
  289. workshopPackage: WorkshopPackage | null = null;
  290. workshopPortal = false;
  291. workshopPreparing = false;
  292. isLauncherPage = false;
  293. isSkillInstallPage = false;
  294. isQiweiSkillPage = false;
  295. isEnterpriseTrialPage = false;
  296. // Login state
  297. needLogin = false;
  298. loggedInUser: string = '';
  299. sessionToken: string = '';
  300. companyId: string = '';
  301. apigList: ApigData[] = [];
  302. apigListLoading = false;
  303. tokenCopied = false;
  304. // State
  305. apig: ApigData | null = null;
  306. selectedIndex = 0;
  307. errorMsg = '';
  308. paying = false;
  309. showQrModal = false;
  310. showSuccess = false;
  311. paySuccess = false;
  312. successDetailHtml = '';
  313. // Order state
  314. order: any = null;
  315. tradeNo = '';
  316. nonceStr = '';
  317. pollTimer: any = null;
  318. // Order history
  319. orders: OrderData[] = [];
  320. ordersLoading = false;
  321. orderError = '';
  322. hasMoreOrders = false;
  323. orderSkip = 0;
  324. launcherDownloads = LAUNCHER_DOWNLOADS;
  325. launcherUrlList = LAUNCHER_URL_LIST;
  326. get selectedTier(): PriceTier | null {
  327. if (this.workshopPackage) {
  328. return { price: this.workshopPackage.price, count: this.workshopTotalCount };
  329. }
  330. if (!this.apig?.priceStep || this.apig.priceStep.length === 0) return null;
  331. return this.apig.priceStep[this.selectedIndex] || null;
  332. }
  333. get workshopTotalCount(): number {
  334. return this.workshopPackage?.allocations.reduce((sum, item) => sum + item.count, 0) || 0;
  335. }
  336. get workshopQuotaLabel(): string {
  337. if (!this.workshopPackage) return '数据中台运行额度';
  338. return this.workshopPackage.allocations.length > 1 ? '双数据中台运行额度' : this.workshopPackage.allocations[0].title + '额度';
  339. }
  340. get workshopArrivalLabel(): string {
  341. if (!this.workshopPackage) return '数据中台到账';
  342. return this.workshopPackage.allocations.length > 1 ? '两个数据中台分别到账' : this.workshopPackage.allocations[0].title + '到账';
  343. }
  344. get visibleWorkshopPackages(): WorkshopPackage[] {
  345. return Object.values(WORKSHOP_PACKAGES).filter(pkg => pkg.visible);
  346. }
  347. get isWorkshopMode(): boolean {
  348. return !!this.workshopPackage || this.workshopPortal;
  349. }
  350. get pageSubtitle(): string {
  351. if (this.isLauncherPage) return 'Studio 启动器下载';
  352. return this.isWorkshopMode ? '情报官技能套餐' : 'API 服务充值';
  353. }
  354. get unitLabel(): string {
  355. if (!this.apig) return '次';
  356. return (this.apig.objectId === 'MYM5zJBKgw' || this.apig.objectId === 'FQtTgjcqIZ') ? 'token' : '次';
  357. }
  358. get currentApigFromList(): ApigData | null {
  359. return this.apigList.find(item => this.isCurrentApig(item.objectId)) || this.apig;
  360. }
  361. get selectedUnitPrice(): string {
  362. const tier = this.selectedTier;
  363. if (!tier?.count) return '0.000';
  364. return (tier.price / tier.count).toFixed(3);
  365. }
  366. get tokenCopyProfile(): TokenCopyProfile {
  367. const apigId = this.normalizeApigId(this.apig?.objectId || this.apigId || this.workshopPackage?.primaryApigId || '');
  368. return TOKEN_COPY_PROFILES[apigId] || DEFAULT_TOKEN_COPY_PROFILE;
  369. }
  370. get tokenCopyTitle(): string {
  371. return this.tokenCopyProfile.title;
  372. }
  373. get tokenCopyButtonText(): string {
  374. return this.tokenCopied ? this.tokenCopyProfile.copiedText : this.tokenCopyProfile.buttonText;
  375. }
  376. get tokenCopyCheckoutNote(): string {
  377. return this.tokenCopyProfile.checkoutNote;
  378. }
  379. get tokenCopySuccessHint(): string {
  380. return this.tokenCopyProfile.successHint;
  381. }
  382. normalizeApigId(apigId: string): string {
  383. return APIG_ALIASES[apigId] || apigId;
  384. }
  385. // 支付回调云函数 id(fun_id)必须是充值云函数 apigRechargeOnPay 的 Parse objectId
  386. // (HOkkX72PMF)。历史链接曾把 APIG 的别名 / path / objectId(如 voc-e-commerce)误当作
  387. // fun_id 传入,导致微信回调按 fun_id 找不到云函数、静默不入账。统一在此回退默认值。
  388. resolveFunId(rawFunId: string | null): string {
  389. const value = (rawFunId || '').trim();
  390. if (!value) {
  391. return DEFAULT_FUN_ID;
  392. }
  393. const isApigAlias = Object.prototype.hasOwnProperty.call(APIG_ALIASES, value);
  394. const isApigId = VOC_APIG_IDS.includes(value);
  395. const looksLikeApigPath = value.startsWith('/apig/') || value.startsWith('apig/') || value.includes('voc-');
  396. if (isApigAlias || isApigId || looksLikeApigPath) {
  397. console.warn('[PAY] 非法 fun_id(疑似 APIG 别名/path/objectId),回退默认充值云函数:', value, '->', DEFAULT_FUN_ID);
  398. return DEFAULT_FUN_ID;
  399. }
  400. return value;
  401. }
  402. decorateApig(apig: ApigData): ApigData {
  403. const normalizedId = this.normalizeApigId(apig.objectId);
  404. if (normalizedId === TIHAO_ECOMMERCE_APIG_ID) {
  405. return {
  406. ...apig,
  407. objectId: TIHAO_ECOMMERCE_APIG_ID,
  408. title: '国内电商数据中台',
  409. content: '电商与达人数据接口,适合提号选品、达人筛选和电商情报检索。',
  410. priceStep: TIHAO_ECOMMERCE_APIG_PRICE_STEP.map(item => ({ ...item }))
  411. };
  412. }
  413. return apig;
  414. }
  415. ngOnInit(): void {
  416. window.addEventListener('fmode-logout', this.logoutHandler);
  417. this.detectPageFlags();
  418. if (this.isLauncherPage) {
  419. this.loadLauncherDownloads();
  420. return;
  421. }
  422. const params = this.urlParams();
  423. this.syncWorkshopRouteFromUrl(false);
  424. window.addEventListener('hashchange', this.routeChangeHandler);
  425. window.addEventListener('popstate', this.routeChangeHandler);
  426. this.authId = params.get('authid') || '';
  427. this.userId = params.get('user') || params.get('userid') || '';
  428. this.apigId = this.normalizeApigId(params.get('apigid') || this.workshopPackage?.primaryApigId || SOCIAL_APIG_ID);
  429. this.funId = this.resolveFunId(params.get('fun_id'));
  430. const urlToken = params.get('token') || '';
  431. // 0. URL 携带 token → 使用 Parse become 登录
  432. if (urlToken) {
  433. console.log('[AUTH] URL 携带 token,使用 become 登录...');
  434. this.becomeWithToken(urlToken);
  435. return;
  436. }
  437. // 1. URL 已提供足够参数 → 直接加载
  438. if (this.authId || (this.userId && this.apigId)) {
  439. console.log('[AUTH] URL 参数充足,直接加载');
  440. if (!this.isSkillInstallPage && !this.isQiweiSkillPage && !this.isEnterpriseTrialPage) this.loadApig();
  441. return;
  442. }
  443. // 2. URL 没有 user → 检查 localStorage 缓存
  444. const cachedUserId = localStorage.getItem('apig_user_id');
  445. const cachedToken = localStorage.getItem('apig_session_token');
  446. const cachedName = localStorage.getItem('apig_display_name');
  447. if (cachedUserId && cachedToken) {
  448. console.log('[AUTH] 从 localStorage 恢复用户:', cachedUserId);
  449. this.userId = cachedUserId;
  450. this.loggedInUser = cachedName || cachedUserId;
  451. this.sessionToken = cachedToken;
  452. // 先验证 token,验证通过再加载数据
  453. this.validateAndLoad(cachedToken, cachedUserId);
  454. return;
  455. }
  456. // 3. 什么都没有 → 显示登录弹窗
  457. console.log('[AUTH] 未检测到用户,显示登录弹窗');
  458. this.needLogin = true;
  459. }
  460. ngOnDestroy(): void {
  461. window.removeEventListener('hashchange', this.routeChangeHandler);
  462. window.removeEventListener('popstate', this.routeChangeHandler);
  463. window.removeEventListener('fmode-logout', this.logoutHandler);
  464. }
  465. /** 从当前 URL 重算各页面类型标志与标题(初始加载与 hashchange/popstate 共用) */
  466. private detectPageFlags(): void {
  467. this.isLauncherPage = window.location.pathname.includes('/launcher') || window.location.hash === '#/launcher';
  468. this.isSkillInstallPage = window.location.pathname.includes('/skill-install') || window.location.hash.startsWith('#/skill-install');
  469. this.isQiweiSkillPage = window.location.pathname.includes('/qiwei-skill') ||
  470. window.location.pathname.includes('/qiwei-trial') ||
  471. window.location.hash.startsWith('#/qiwei-skill') ||
  472. window.location.hash.startsWith('#/qiwei-trial');
  473. this.isEnterpriseTrialPage = window.location.pathname.includes('/enterprise-trial') ||
  474. window.location.hash.startsWith('#/enterprise-trial');
  475. if (this.isEnterpriseTrialPage) {
  476. document.title = '领取 7 天企业微服务试用 | Fmode';
  477. } else if (this.isQiweiSkillPage) {
  478. document.title = '领取 7 天企微数字员工试用 | Fmode';
  479. } else if (this.isSkillInstallPage) {
  480. document.title = '开启 VOC 数据洞察 | Fmode';
  481. }
  482. }
  483. async loadLauncherDownloads(): Promise<void> {
  484. try {
  485. const resp = await fetch(LAUNCHER_URL_LIST, { cache: 'no-store' });
  486. if (!resp.ok) return;
  487. const text = await resp.text();
  488. const parsed = this.parseLauncherDownloads(text);
  489. if (parsed.length > 0) {
  490. this.launcherDownloads = parsed;
  491. this.cdr.detectChanges();
  492. }
  493. } catch (e: any) {
  494. console.warn('[LAUNCHER] 下载链接清单加载失败,使用内置链接:', e.message);
  495. }
  496. }
  497. parseLauncherDownloads(text: string): LauncherDownload[] {
  498. const downloads = text
  499. .split(/\r?\n/)
  500. .map(line => line.trim())
  501. .filter(line => line.startsWith('http'))
  502. .map(url => this.toLauncherDownload(url))
  503. .filter((item): item is LauncherDownload => !!item);
  504. const rank = (item: LauncherDownload): number => {
  505. const key = item.os + '-' + item.arch;
  506. return {
  507. 'Windows-x64': 1,
  508. 'Windows-ARM64': 2,
  509. 'macOS-Apple Silicon': 3,
  510. 'macOS-Intel': 4,
  511. 'Linux-x64': 5,
  512. 'Linux-ARM64': 6
  513. }[key] || 99;
  514. };
  515. return downloads.sort((a, b) => rank(a) - rank(b));
  516. }
  517. toLauncherDownload(url: string): LauncherDownload | null {
  518. const lower = url.toLowerCase();
  519. if (lower.includes('win-x64')) {
  520. return { os: 'Windows', arch: 'x64', label: 'Windows x64', url, primary: true };
  521. }
  522. if (lower.includes('win-arm64')) {
  523. return { os: 'Windows', arch: 'ARM64', label: 'Windows ARM64', url };
  524. }
  525. if (lower.includes('macos-arm64')) {
  526. return { os: 'macOS', arch: 'Apple Silicon', label: 'macOS Apple Silicon', url, primary: true };
  527. }
  528. if (lower.includes('macos-x64')) {
  529. return { os: 'macOS', arch: 'Intel', label: 'macOS Intel', url };
  530. }
  531. if (lower.includes('linux-x64')) {
  532. return { os: 'Linux', arch: 'x64', label: 'Linux x64', url };
  533. }
  534. if (lower.includes('linux-arm64')) {
  535. return { os: 'Linux', arch: 'ARM64', label: 'Linux ARM64', url };
  536. }
  537. return null;
  538. }
  539. /**
  540. * 通过 URL 中的 token 调用 Parse Server 的 become(fetch /parse/users/me)完成登录
  541. */
  542. async becomeWithToken(token: string): Promise<void> {
  543. try {
  544. const resp = await fetch(`${API_BASE}/parse/users/me?include=company`, {
  545. headers: {
  546. 'X-Parse-Application-Id': APP_ID,
  547. 'X-Parse-Session-Token': token
  548. }
  549. });
  550. const data = await resp.json();
  551. if (!data.objectId) {
  552. console.warn('[AUTH] become 失败,token 无效:', data);
  553. this.errorMsg = 'Token 无效或已过期,请重新登录。';
  554. this.needLogin = true;
  555. this.cdr.detectChanges();
  556. return;
  557. }
  558. // become 成功 — 写入状态
  559. this.userId = data.objectId;
  560. this.sessionToken = token;
  561. this.loggedInUser = data.nickname || data.username || data.mobile || data.mobilePhoneNumber || data.objectId;
  562. this.companyId = data.company?.objectId || '';
  563. const phone = String(data.mobile || data.mobilePhoneNumber || '').trim();
  564. if (phone) localStorage.setItem('apig_phone', phone);
  565. console.log('[AUTH] become 成功, user:', data.objectId, data.username || '', 'company:', this.companyId || '(无)');
  566. // 持久化到 localStorage
  567. localStorage.setItem('apig_user_id', this.userId);
  568. localStorage.setItem('apig_session_token', token);
  569. localStorage.setItem('apig_display_name', this.loggedInUser);
  570. // 广播登录事件,供路由内独立渲染的 enterprise-trial 组件同步登录态
  571. window.dispatchEvent(new CustomEvent('fmode-auth-success', {
  572. detail: { userId: this.userId, sessionToken: token, displayName: this.loggedInUser }
  573. }));
  574. this.cdr.detectChanges();
  575. if (!this.isSkillInstallPage && !this.isQiweiSkillPage && !this.isEnterpriseTrialPage) this.loadApig();
  576. } catch (e: any) {
  577. console.error('[AUTH] become 请求异常:', e);
  578. this.errorMsg = 'Token 登录失败: ' + e.message;
  579. this.needLogin = true;
  580. this.cdr.detectChanges();
  581. }
  582. }
  583. async validateAndLoad(token: string, userId: string): Promise<void> {
  584. try {
  585. const resp = await fetch(`${API_BASE}/parse/users/me?include=company`, {
  586. headers: {
  587. 'X-Parse-Application-Id': APP_ID,
  588. 'X-Parse-Session-Token': token
  589. }
  590. });
  591. const data = await resp.json();
  592. if (!data.objectId) {
  593. console.warn('[AUTH] 缓存 token 已失效,需要重新登录');
  594. localStorage.removeItem('apig_user_id');
  595. localStorage.removeItem('apig_session_token');
  596. localStorage.removeItem('apig_display_name');
  597. this.needLogin = true;
  598. this.cdr.detectChanges();
  599. return;
  600. }
  601. // token 有效 — 更新 displayName(可能 localStorage 里是旧的)
  602. const serverName = data.nickname || data.username || data.mobile || data.mobilePhoneNumber || userId;
  603. if (serverName && serverName !== this.loggedInUser) {
  604. this.loggedInUser = serverName;
  605. localStorage.setItem('apig_display_name', serverName);
  606. }
  607. // userId 可能变化(极端情况:session 对应不同用户)
  608. if (data.objectId !== userId) {
  609. console.warn('[AUTH] session 对应用户不匹配,纠正:', userId, '→', data.objectId);
  610. this.userId = data.objectId;
  611. localStorage.setItem('apig_user_id', data.objectId);
  612. }
  613. // 提取 companyId(可选:后端扣费只要 user 指针即可,Company 存在时才附带)
  614. this.companyId = data.company?.objectId || '';
  615. const phone = String(data.mobile || data.mobilePhoneNumber || '').trim();
  616. if (phone) localStorage.setItem('apig_phone', phone);
  617. console.log('[AUTH] token 验证通过, user:', data.objectId, data.username || '', 'company:', this.companyId || '(用户无 Company,仅用 user 指针扣费)');
  618. // 广播登录事件,供路由内独立渲染的 enterprise-trial 组件刷新(服务端可能返回了更新的 displayName)
  619. window.dispatchEvent(new CustomEvent('fmode-auth-success', {
  620. detail: { userId: this.userId, sessionToken: token, displayName: this.loggedInUser }
  621. }));
  622. this.cdr.detectChanges();
  623. if (!this.isSkillInstallPage && !this.isQiweiSkillPage && !this.isEnterpriseTrialPage) this.loadApig();
  624. } catch (e) {
  625. console.warn('[AUTH] 验证 token 失败:', e);
  626. // 网络错误时仍然尝试加载(可能离线缓存能用)
  627. if (!this.isSkillInstallPage && !this.isQiweiSkillPage && !this.isEnterpriseTrialPage) this.loadApig();
  628. }
  629. }
  630. async onLoginSuccess(event: { userId: string; sessionToken: string; displayName: string }): Promise<void> {
  631. console.log('[AUTH] 登录成功, userId:', event.userId, 'display:', event.displayName);
  632. this.userId = event.userId;
  633. this.loggedInUser = event.displayName || event.userId;
  634. this.sessionToken = event.sessionToken;
  635. this.needLogin = false;
  636. this.errorMsg = '';
  637. window.dispatchEvent(new CustomEvent('fmode-auth-success', { detail: event }));
  638. // 通过 validateAndLoad 统一处理 Company 解析 + 数据加载
  639. await this.validateAndLoad(event.sessionToken, event.userId);
  640. }
  641. logout(): void {
  642. console.log('[AUTH] 退出登录');
  643. localStorage.removeItem('apig_user_id');
  644. localStorage.removeItem('apig_session_token');
  645. localStorage.removeItem('apig_display_name');
  646. localStorage.removeItem('apig_phone');
  647. this.userId = '';
  648. this.loggedInUser = '';
  649. this.sessionToken = '';
  650. this.companyId = '';
  651. this.authId = '';
  652. this.apig = null;
  653. this.orders = [];
  654. this.errorMsg = '';
  655. this.showSuccess = false;
  656. this.needLogin = true;
  657. this.cdr.detectChanges();
  658. }
  659. // ─── Load available APIG list + merge user's APIGAuth balance ───
  660. async loadApigList(): Promise<void> {
  661. this.apigListLoading = true;
  662. try {
  663. // 1. 拉 VOC 相关 APIG 基础信息
  664. const query: any = {
  665. where: JSON.stringify({
  666. objectId: { $in: VOC_APIG_IDS }
  667. }),
  668. keys: 'title,content,priceStep',
  669. limit: '10'
  670. };
  671. const resp = await fetch(API_BASE + '/parse/classes/APIG?' + new URLSearchParams(query), {
  672. headers: { 'X-Parse-Application-Id': APP_ID }
  673. });
  674. const data = await resp.json();
  675. const apigs: ApigData[] = (data.results || [])
  676. .map((item: ApigData) => this.decorateApig(item))
  677. .sort((a: ApigData, b: ApigData) => VOC_APIG_IDS.indexOf(a.objectId) - VOC_APIG_IDS.indexOf(b.objectId));
  678. // 2. 合并当前用户的 APIGAuth(如果已登录)
  679. if (this.userId && this.sessionToken) {
  680. try {
  681. const authQuery = {
  682. where: JSON.stringify({
  683. user: { __type: 'Pointer', className: '_User', objectId: this.userId },
  684. api: {
  685. $inQuery: {
  686. where: { objectId: { $in: VOC_APIG_IDS } },
  687. className: 'APIG'
  688. }
  689. }
  690. }),
  691. keys: 'api,count,objectId',
  692. limit: '10'
  693. };
  694. const authResp = await fetch(API_BASE + '/parse/classes/APIGAuth?' + new URLSearchParams(authQuery as any), {
  695. headers: {
  696. 'X-Parse-Application-Id': APP_ID,
  697. 'X-Parse-Session-Token': this.sessionToken
  698. }
  699. });
  700. const authData = await authResp.json();
  701. const authMap: Record<string, { count: number; objectId: string }> = {};
  702. for (const a of (authData.results || [])) {
  703. const apigPtrId = a.api?.objectId;
  704. if (apigPtrId) authMap[apigPtrId] = { count: a.count || 0, objectId: a.objectId };
  705. }
  706. for (const apig of apigs) {
  707. const rec = authMap[apig.objectId];
  708. apig.userBalance = rec ? rec.count : 0;
  709. apig.authId = rec ? rec.objectId : '';
  710. }
  711. console.log('[APIG] 用户余额合并完成:', authMap);
  712. } catch (e: any) {
  713. console.warn('[APIG] 合并用户 APIGAuth 失败:', e.message);
  714. }
  715. }
  716. this.apigList = apigs;
  717. this.syncWorkshopAllocationsFromList();
  718. console.log('[APIG] 获取到', apigs.length, '个可用 API');
  719. this.cdr.detectChanges();
  720. } catch (e: any) {
  721. console.warn('[APIG] 查询 API 列表失败:', e.message);
  722. } finally {
  723. this.apigListLoading = false;
  724. this.cdr.detectChanges();
  725. }
  726. }
  727. isCurrentApig(apigId: string): boolean {
  728. return !!this.apigId && this.normalizeApigId(this.apigId) === this.normalizeApigId(apigId);
  729. }
  730. navigateToApig(apigId: string): void {
  731. if (this.isCurrentApig(apigId)) return;
  732. const url = new URL(window.location.href);
  733. url.searchParams.set('apigid', this.normalizeApigId(apigId));
  734. // 切换 APIG 时清空 authid(因为换了 APIG 就要重新解析新的 APIGAuth)
  735. url.searchParams.delete('authid');
  736. window.location.href = url.toString();
  737. }
  738. onApigSelect(value: string): void {
  739. this.navigateToApig(value);
  740. }
  741. copyToken(): void {
  742. if (!this.sessionToken) return;
  743. const tokenText = this.tokenCopyProfile.buildCopyText(this.sessionToken, this.companyId);
  744. navigator.clipboard.writeText(tokenText).then(() => {
  745. this.tokenCopied = true;
  746. this.cdr.detectChanges();
  747. setTimeout(() => { this.tokenCopied = false; this.cdr.detectChanges(); }, 2500);
  748. }).catch(() => {
  749. this.errorMsg = '复制失败,请手动复制:' + tokenText;
  750. this.cdr.detectChanges();
  751. });
  752. }
  753. // ─── Load APIG Info ───
  754. async loadApig(): Promise<void> {
  755. try {
  756. // 如果没有 authId 但有 user+apigid,先查询/创建 APIGAuth
  757. if (!this.authId && this.userId && this.apigId) {
  758. console.log('[INIT] 无 authId,通过 user+apigid 查询 APIGAuth...');
  759. const resolved = await this.resolveAuthId(this.userId, this.apigId);
  760. if (!resolved) {
  761. this.errorMsg = '无法获取或创建计费账套,请联系管理员。';
  762. this.cdr.detectChanges();
  763. return;
  764. }
  765. this.authId = resolved;
  766. console.log('[INIT] 获取到 authId:', this.authId);
  767. }
  768. // 1. Try getApig REST endpoint
  769. const resp = await this.postJSON(API_BASE + '/api/apig/getApig', { authid: this.authId });
  770. console.log('[getApig] response:', JSON.stringify(resp).substring(0, 500));
  771. if (resp.code === 200 && resp.data) {
  772. this.apig = this.decorateApig(resp.data);
  773. this.applyTestTier();
  774. this.selectTier(0);
  775. this.cdr.detectChanges();
  776. this.loadOrderHistory();
  777. // 同时加载其它 VOC APIG 余额以显示切换器
  778. this.loadApigList();
  779. return;
  780. }
  781. // 2. Fallback: cloud function
  782. console.log('getApig returned no data, trying cloud function...');
  783. const params = this.urlParams();
  784. const cfFuncName = params.get('cfName') || 'getApigInfo';
  785. try {
  786. const cfResp = await this.parseCloudCall(cfFuncName, { apigId: this.authId });
  787. if (cfResp?.result) {
  788. const cfData = cfResp.result.data || cfResp.result;
  789. if (cfData?.objectId) {
  790. this.apig = this.decorateApig(cfData);
  791. this.applyTestTier();
  792. this.selectTier(0);
  793. this.cdr.detectChanges();
  794. this.loadOrderHistory();
  795. this.loadApigList();
  796. return;
  797. }
  798. }
  799. } catch (e: any) {
  800. console.warn('cloud function error:', e);
  801. }
  802. this.errorMsg = '无法加载接口信息。请确认参数正确。';
  803. this.cdr.detectChanges();
  804. } catch (e: any) {
  805. this.errorMsg = '网络错误: ' + e.message;
  806. this.cdr.detectChanges();
  807. }
  808. }
  809. // ─── 通过 user+apigid 查询或创建 APIGAuth ───
  810. async resolveAuthId(user: string, apig: string): Promise<string | null> {
  811. apig = this.normalizeApigId(apig);
  812. const authHeaders: any = { 'X-Parse-Application-Id': APP_ID };
  813. if (this.sessionToken) authHeaders['X-Parse-Session-Token'] = this.sessionToken;
  814. // 1. 精确查询:按 user + api 指针
  815. try {
  816. const query: any = {
  817. where: JSON.stringify({
  818. api: { __type: 'Pointer', className: 'APIG', objectId: apig },
  819. user: { __type: 'Pointer', className: '_User', objectId: user }
  820. }),
  821. limit: '1'
  822. };
  823. const resp = await fetch(API_BASE + '/parse/classes/APIGAuth?' + new URLSearchParams(query), {
  824. headers: authHeaders
  825. });
  826. const data = await resp.json();
  827. if (data.results?.length > 0) {
  828. console.log('[resolveAuthId] 找到已有 APIGAuth:', data.results[0].objectId, 'count:', data.results[0].count);
  829. return data.results[0].objectId;
  830. }
  831. } catch (e: any) {
  832. console.warn('[resolveAuthId] 查询失败:', e.message);
  833. }
  834. // 2. 未找到 → 为当前用户创建新 APIGAuth(余额=0,需充值)
  835. try {
  836. console.log('[resolveAuthId] 未找到该用户的 APIGAuth,创建新记录(余额=0)...');
  837. const body: any = {
  838. api: { __type: 'Pointer', className: 'APIG', objectId: apig },
  839. user: { __type: 'Pointer', className: '_User', objectId: user },
  840. count: 0,
  841. used: 0
  842. };
  843. const createResp = await fetch(API_BASE + '/parse/classes/APIGAuth', {
  844. method: 'POST',
  845. headers: { ...authHeaders, 'Content-Type': 'application/json' },
  846. body: JSON.stringify(body)
  847. });
  848. const created = await createResp.json();
  849. if (created.objectId) {
  850. console.log('[resolveAuthId] 创建成功:', created.objectId);
  851. return created.objectId;
  852. }
  853. console.error('[resolveAuthId] 创建失败:', JSON.stringify(created));
  854. } catch (e: any) {
  855. console.error('[resolveAuthId] 创建异常:', e.message);
  856. }
  857. return null;
  858. }
  859. applyTestTier(): void {
  860. // 仅本地开发时允许 URL 带 test=1 追加测试套餐(¥0.01 / 1 次),线上不开放。
  861. const params = this.urlParams();
  862. const isLocalhost = ['localhost', '127.0.0.1'].includes(window.location.hostname);
  863. const isTihaoEcommerce = this.apig?.objectId === TIHAO_ECOMMERCE_APIG_ID;
  864. if (isLocalhost && isTihaoEcommerce && params.get('test') === '1' && this.apig?.priceStep && !this.apig.priceStep.some(tier => tier.isTest)) {
  865. this.apig.priceStep.unshift({ count: 1, price: 0.01, isTest: true });
  866. }
  867. }
  868. selectTier(idx: number): void {
  869. this.selectedIndex = idx;
  870. }
  871. isLocalTestTier(tier: PriceTier | null = this.selectedTier): boolean {
  872. const isLocalhost = ['localhost', '127.0.0.1'].includes(window.location.hostname);
  873. return !!tier?.isTest && isLocalhost && this.apig?.objectId === TIHAO_ECOMMERCE_APIG_ID;
  874. }
  875. isStrictCallbackTest(): boolean {
  876. const params = this.urlParams();
  877. const isLocalhost = ['localhost', '127.0.0.1'].includes(window.location.hostname);
  878. return isLocalhost && params.get('callback_test') === '1' && this.apig?.objectId === TIHAO_ECOMMERCE_APIG_ID;
  879. }
  880. /** 兼容两种带参方式:?a=1#/route 与 #/route?a=1;hash 内参数仅作补充,不覆盖 search 已有键 */
  881. private urlParams(): URLSearchParams {
  882. const params = new URLSearchParams(window.location.search);
  883. const hashQuery = window.location.hash.includes('?') ? window.location.hash.split('?')[1] : '';
  884. if (hashQuery) {
  885. for (const [key, value] of new URLSearchParams(hashQuery)) {
  886. if (!params.has(key)) params.set(key, value);
  887. }
  888. }
  889. return params;
  890. }
  891. syncWorkshopRouteFromUrl(shouldDetectChanges = true): void {
  892. const params = this.urlParams();
  893. const nextPackage = this.resolveWorkshopPackage(params);
  894. this.workshopPackage = nextPackage;
  895. this.workshopPortal = !nextPackage && this.isWorkshopPortal(params);
  896. if (nextPackage) {
  897. this.apigId = nextPackage.primaryApigId;
  898. this.authId = '';
  899. this.selectedIndex = 0;
  900. this.showSuccess = false;
  901. this.errorMsg = '';
  902. this.syncWorkshopAllocationsFromList();
  903. } else if (this.workshopPortal) {
  904. this.showSuccess = false;
  905. this.errorMsg = '';
  906. }
  907. if (shouldDetectChanges) this.cdr.detectChanges();
  908. }
  909. getRouteParts(): string[] {
  910. const pathParts = window.location.pathname.toLowerCase().split('/').filter(Boolean);
  911. const hashParts = window.location.hash.replace(/^#\/?/, '').toLowerCase().split('/').filter(Boolean);
  912. return [...pathParts, ...hashParts];
  913. }
  914. isWorkshopPortal(params: URLSearchParams): boolean {
  915. const parts = this.getRouteParts();
  916. return parts.includes('workshop') ||
  917. parts.includes('packages') ||
  918. params.get('workshop') === '1' ||
  919. params.get('packages') === '1';
  920. }
  921. resolveWorkshopPackage(params: URLSearchParams): WorkshopPackage | null {
  922. const candidates = [
  923. params.get('workshop'),
  924. params.get('package'),
  925. params.get('pkg'),
  926. ...this.getRouteParts()
  927. ].filter((item): item is string => !!item);
  928. const slug = candidates.find(item => !!WORKSHOP_PACKAGES[item]);
  929. if (!slug) return null;
  930. const pkg = WORKSHOP_PACKAGES[slug];
  931. if (!pkg) return null;
  932. return {
  933. ...pkg,
  934. allocations: pkg.allocations.map(item => ({ ...item }))
  935. };
  936. }
  937. getPackageTotalCount(pkg: WorkshopPackage): number {
  938. return pkg.allocations.reduce((sum, item) => sum + item.count, 0);
  939. }
  940. navigateToWorkshopPackage(slug: string): void {
  941. const url = new URL(window.location.href);
  942. url.searchParams.delete('workshop');
  943. url.searchParams.delete('packages');
  944. url.searchParams.delete('package');
  945. url.searchParams.delete('pkg');
  946. url.hash = '/workshop/' + slug;
  947. window.history.pushState({}, '', url.toString());
  948. this.syncWorkshopRouteFromUrl();
  949. }
  950. openWorkshopPortal(): void {
  951. const url = new URL(window.location.href);
  952. url.searchParams.delete('workshop');
  953. url.searchParams.delete('packages');
  954. url.searchParams.delete('package');
  955. url.searchParams.delete('pkg');
  956. url.hash = '/workshop';
  957. window.history.pushState({}, '', url.toString());
  958. this.syncWorkshopRouteFromUrl();
  959. }
  960. closeWorkshopPortal(): void {
  961. const url = new URL(window.location.href);
  962. url.searchParams.delete('workshop');
  963. url.searchParams.delete('packages');
  964. url.searchParams.delete('package');
  965. url.searchParams.delete('pkg');
  966. url.hash = '';
  967. window.history.pushState({}, '', url.toString());
  968. this.workshopPackage = null;
  969. this.workshopPortal = false;
  970. this.showSuccess = false;
  971. this.errorMsg = '';
  972. this.cdr.detectChanges();
  973. }
  974. backToWorkshopPortal(): void {
  975. const url = new URL(window.location.href);
  976. url.searchParams.delete('workshop');
  977. url.searchParams.delete('packages');
  978. url.searchParams.delete('package');
  979. url.searchParams.delete('pkg');
  980. url.hash = '/workshop';
  981. window.history.pushState({}, '', url.toString());
  982. this.syncWorkshopRouteFromUrl();
  983. }
  984. syncWorkshopAllocationsFromList(): void {
  985. if (!this.workshopPackage || this.apigList.length === 0) return;
  986. for (const allocation of this.workshopPackage.allocations) {
  987. const apig = this.apigList.find(item => item.objectId === allocation.apigId);
  988. if (!apig) continue;
  989. allocation.title = apig.title || allocation.title;
  990. allocation.authId = apig.authId || allocation.authId;
  991. if (apig.userBalance != null) allocation.balance = apig.userBalance;
  992. }
  993. }
  994. getWorkshopBalance(allocation: WorkshopAllocation): string {
  995. return allocation.balance != null ? String(allocation.balance) : '—';
  996. }
  997. getPaymentBody(): string {
  998. if (this.workshopPackage) {
  999. return this.workshopPackage.title + '(' + this.workshopQuotaLabel + ')';
  1000. }
  1001. return this.apig!.title + ' 接口充值';
  1002. }
  1003. getPrimaryWorkshopAllocation(): WorkshopAllocation | null {
  1004. if (!this.workshopPackage) return null;
  1005. return this.workshopPackage.allocations.find(item => item.apigId === this.workshopPackage!.primaryApigId) || null;
  1006. }
  1007. getOrderRechargeCount(): number {
  1008. const primary = this.getPrimaryWorkshopAllocation();
  1009. if (this.workshopPackage && primary) return primary.count;
  1010. return this.selectedTier?.count || 0;
  1011. }
  1012. getOrderOldCount(): number {
  1013. const primary = this.getPrimaryWorkshopAllocation();
  1014. if (this.workshopPackage && primary) return primary.oldCount ?? primary.balance ?? this.apig!.count ?? 0;
  1015. return this.apig!.count || 0;
  1016. }
  1017. async prepareWorkshopPackage(captureOldCounts = true): Promise<void> {
  1018. if (!this.workshopPackage) return;
  1019. if (!this.userId) throw new Error('请先登录后再购买技能套餐。');
  1020. this.workshopPreparing = true;
  1021. try {
  1022. for (const allocation of this.workshopPackage.allocations) {
  1023. const authId = await this.resolveAuthId(this.userId, allocation.apigId);
  1024. if (!authId) throw new Error('无法创建 ' + allocation.title + ' 计费账套');
  1025. allocation.authId = authId;
  1026. const data = await this.getApigByAuthId(authId);
  1027. if (data) {
  1028. allocation.title = data.title || allocation.title;
  1029. allocation.balance = data.count || 0;
  1030. if (captureOldCounts || allocation.oldCount == null) allocation.oldCount = allocation.balance;
  1031. if (allocation.apigId === this.workshopPackage.primaryApigId) {
  1032. this.authId = authId;
  1033. this.apigId = allocation.apigId;
  1034. this.apig = data;
  1035. }
  1036. } else {
  1037. if (captureOldCounts || allocation.oldCount == null) allocation.oldCount = allocation.balance || 0;
  1038. }
  1039. }
  1040. this.cdr.detectChanges();
  1041. } finally {
  1042. this.workshopPreparing = false;
  1043. this.cdr.detectChanges();
  1044. }
  1045. }
  1046. async getApigByAuthId(authId: string): Promise<ApigData | null> {
  1047. try {
  1048. const resp = await this.postJSON(API_BASE + '/api/apig/getApig', { authid: authId });
  1049. if (resp.code === 200 && resp.data) return resp.data;
  1050. } catch (e: any) {
  1051. console.warn('[APIG] 获取账套信息失败:', e.message);
  1052. }
  1053. return null;
  1054. }
  1055. // ─── Payment Flow ───
  1056. async startPayment(): Promise<void> {
  1057. const tier = this.selectedTier;
  1058. if (!this.apig || !tier || this.paying || this.workshopPreparing) return;
  1059. this.paying = true;
  1060. this.errorMsg = '';
  1061. try {
  1062. if (this.workshopPackage) {
  1063. await this.prepareWorkshopPackage();
  1064. }
  1065. // 1. Generate trade number
  1066. const now = new Date();
  1067. this.tradeNo = 'C' + (this.userId || 'U') +
  1068. now.getFullYear() +
  1069. String(now.getMonth() + 1).padStart(2, '0') +
  1070. String(now.getDate()).padStart(2, '0') +
  1071. String(now.getHours()).padStart(2, '0') +
  1072. String(now.getMinutes()).padStart(2, '0') +
  1073. String(now.getSeconds()).padStart(2, '0') +
  1074. now.getMilliseconds();
  1075. // 2. Create order
  1076. console.log('[PAY] Creating order...', { tradeNo: this.tradeNo, tier: tier.price, funId: this.funId });
  1077. await this.createOrder(tier);
  1078. console.log('[PAY] Order created, order=', this.order);
  1079. // 3. Call pay_code2 to get QR URL
  1080. const payParams: any = {
  1081. _ApplicationId: APP_ID,
  1082. company: PAY_COMPANY,
  1083. out_trade_no: this.tradeNo,
  1084. total_fee: +tier.price,
  1085. body: this.getPaymentBody()
  1086. };
  1087. if (this.funId) {
  1088. payParams.fun_id = this.funId;
  1089. }
  1090. console.log('[PAY] Calling pay_code2 with params:', JSON.stringify(payParams));
  1091. let payResp: any = null;
  1092. try {
  1093. const controller = new AbortController();
  1094. const timeout = setTimeout(() => controller.abort(), 15000);
  1095. const rawResp = await fetch(PARSE_BASE + '/pay_code2', {
  1096. method: 'POST',
  1097. headers: { 'Content-Type': 'application/json' },
  1098. body: JSON.stringify(payParams),
  1099. signal: controller.signal
  1100. });
  1101. clearTimeout(timeout);
  1102. payResp = await rawResp.json();
  1103. console.log('[PAY] pay_code2 response:', JSON.stringify(payResp));
  1104. } catch (fetchErr: any) {
  1105. console.error('[PAY] pay_code2 fetch error:', fetchErr.message);
  1106. if (payParams.fun_id) {
  1107. console.log('[PAY] Retrying pay_code2 WITHOUT fun_id...');
  1108. delete payParams.fun_id;
  1109. payResp = await this.postJSON(PARSE_BASE + '/pay_code2', payParams);
  1110. console.log('[PAY] pay_code2 retry response:', JSON.stringify(payResp));
  1111. } else {
  1112. throw fetchErr;
  1113. }
  1114. }
  1115. if (!payResp?.result?.code_url) {
  1116. throw new Error('获取支付码失败: ' + JSON.stringify(payResp));
  1117. }
  1118. const codeUrl = payResp.result.code_url[0];
  1119. this.nonceStr = payResp.result.nonce_str;
  1120. console.log('[PAY] QR code URL:', codeUrl);
  1121. // 4. Show QR modal
  1122. this.showQrModal = true;
  1123. this.paySuccess = false;
  1124. this.cdr.detectChanges();
  1125. // Wait for DOM update, then render QR
  1126. setTimeout(() => this.renderQR(codeUrl), 100);
  1127. // 5. Start polling
  1128. this.startPolling();
  1129. } catch (e: any) {
  1130. this.errorMsg = '支付发起失败: ' + e.message;
  1131. } finally {
  1132. this.paying = false;
  1133. }
  1134. }
  1135. async createOrder(tier: PriceTier): Promise<void> {
  1136. try {
  1137. const orderCount = this.getOrderRechargeCount();
  1138. const oldCount = this.getOrderOldCount();
  1139. const bodyText = this.getPaymentBody();
  1140. const body: any = {
  1141. type: 'wxpay',
  1142. authid: this.authId,
  1143. params: {
  1144. out_trade_no: this.tradeNo,
  1145. total_fee: +tier.price,
  1146. body: bodyText
  1147. },
  1148. apigid: this.apig!.objectId,
  1149. oldCount,
  1150. count: orderCount,
  1151. user: this.userId
  1152. };
  1153. // 仅在拿到真实 Company objectId 时才传 fcompany,避免把 userId 误写成 Company 指针
  1154. if (this.companyId) body.fcompany = this.companyId;
  1155. const resp = await this.postJSON(API_BASE + '/api/apig/created-apigorder', body);
  1156. if (resp.code === 200 && resp.data) {
  1157. this.order = resp.data;
  1158. console.log('Order created:', this.order);
  1159. }
  1160. } catch (e: any) {
  1161. console.warn('Order creation error (non-blocking):', e.message);
  1162. }
  1163. }
  1164. startPolling(): void {
  1165. if (this.pollTimer) clearInterval(this.pollTimer);
  1166. this.pollTimer = setInterval(async () => {
  1167. try {
  1168. const resp = await this.postJSON(PARSE_BASE + '/order_status2', {
  1169. _ApplicationId: APP_ID,
  1170. out_trade_no: this.tradeNo,
  1171. nonce_str: this.nonceStr,
  1172. company: PAY_COMPANY
  1173. });
  1174. if (resp.result?.status?.[0] === 'SUCCESS') {
  1175. clearInterval(this.pollTimer);
  1176. this.pollTimer = null;
  1177. this.paySuccess = true;
  1178. await this.doRecharge();
  1179. }
  1180. } catch (e: any) {
  1181. console.warn('Poll error:', e.message);
  1182. }
  1183. }, 3000);
  1184. }
  1185. async doRecharge(): Promise<void> {
  1186. if (this.workshopPackage) {
  1187. await this.doWorkshopRecharge();
  1188. return;
  1189. }
  1190. this.showQrModal = false;
  1191. this.showSuccess = true;
  1192. this.cdr.detectChanges();
  1193. const oldCount = this.apig!.count || 0;
  1194. const tier = this.apig!.priceStep![this.selectedIndex];
  1195. console.log('═══ [RECHARGE] 开始 ═══ oldCount:', oldCount, 'addCount:', tier.count, 'fun_id:', this.funId);
  1196. if (this.isLocalTestTier(tier) && !this.isStrictCallbackTest()) {
  1197. console.log('[RECHARGE] 本地 0.01 测试套餐,跳过后端回调等待,直接调用 saveRecharge。');
  1198. await this.saveRechargeFallback(oldCount, tier);
  1199. await this.confirmRechargeResult(oldCount);
  1200. return;
  1201. }
  1202. // 步骤1: 等待后端微信回调自动执行云函数
  1203. for (let i = 1; i <= 8; i++) {
  1204. console.log('[RECHARGE] 步骤1: 等待后端回调... 第' + i + '/8次 (3秒后)');
  1205. await this.sleep(3000);
  1206. await this.refreshBalance();
  1207. if (this.apig!.count! > oldCount) {
  1208. console.log('[RECHARGE] ✅ 后端回调充值成功! 余额:', oldCount, '→', this.apig!.count);
  1209. this.cdr.detectChanges();
  1210. this.loadOrderHistory();
  1211. return;
  1212. }
  1213. }
  1214. if (this.isStrictCallbackTest()) {
  1215. console.warn('[RECHARGE] 严格回调测试模式:后端回调未自动加余额,不调用 saveRecharge 兜底。');
  1216. this.successDetailHtml =
  1217. '支付已成功,但严格回调测试未观察到后端自动充值。<br>' +
  1218. '<span style="color:var(--text-dim);margin-top:8px;display:inline-block;">当前余额仍为 <strong style="color:var(--neon);">' +
  1219. this.apig!.count + ' ' + this.unitLabel + '</strong>,请检查支付回调/订单充值逻辑。</span>';
  1220. this.cdr.detectChanges();
  1221. this.loadOrderHistory();
  1222. return;
  1223. }
  1224. // 步骤2: 后端回调未生效,调用 saveRecharge 保底
  1225. console.log('[RECHARGE] 步骤2: 后端回调未生效,调用 saveRecharge 保底...');
  1226. await this.saveRechargeFallback(oldCount, tier);
  1227. // 步骤3: 再轮询确认
  1228. await this.confirmRechargeResult(oldCount);
  1229. }
  1230. async saveRechargeFallback(oldCount: number, tier: PriceTier): Promise<void> {
  1231. try {
  1232. const rechargeBody: any = {
  1233. user: this.userId,
  1234. authid: this.authId,
  1235. apigid: this.apig!.objectId,
  1236. oldCount: oldCount,
  1237. count: tier.count,
  1238. orderid: this.order?.objectId || ''
  1239. };
  1240. if (this.companyId) rechargeBody.authComp = this.companyId;
  1241. console.log('[RECHARGE] saveRecharge 参数:', JSON.stringify(rechargeBody));
  1242. const rechargeResp = await this.postJSON(API_BASE + '/api/apig/saveRecharge', rechargeBody);
  1243. console.log('[RECHARGE] saveRecharge 响应:', JSON.stringify(rechargeResp));
  1244. } catch (e: any) {
  1245. console.warn('[RECHARGE] saveRecharge 失败:', e.message);
  1246. }
  1247. }
  1248. async confirmRechargeResult(oldCount: number): Promise<void> {
  1249. for (let i = 1; i <= 3; i++) {
  1250. await this.sleep(2000);
  1251. await this.refreshBalance();
  1252. if (this.apig!.count! > oldCount) {
  1253. console.log('[RECHARGE] ✅ saveRecharge 充值成功! 余额:', oldCount, '→', this.apig!.count);
  1254. break;
  1255. }
  1256. }
  1257. if (this.apig!.count! <= oldCount) {
  1258. console.warn('[RECHARGE] ⚠️ 充值可能延迟,请稍后刷新页面查看');
  1259. }
  1260. console.log('═══ [RECHARGE] 结束 ═══ 最终余额:', this.apig!.count);
  1261. this.cdr.detectChanges();
  1262. this.loadOrderHistory();
  1263. }
  1264. async doWorkshopRecharge(): Promise<void> {
  1265. if (!this.workshopPackage) return;
  1266. this.showQrModal = false;
  1267. this.showSuccess = true;
  1268. this.successDetailHtml = '正在开通 <strong style="color:var(--neon);">' + this.workshopPackage.title + '</strong>,请稍候...';
  1269. this.cdr.detectChanges();
  1270. try {
  1271. await this.prepareWorkshopPackage(false);
  1272. } catch (e: any) {
  1273. this.successDetailHtml =
  1274. '支付已完成,但' + this.workshopQuotaLabel + '开通需要人工确认。<br>' +
  1275. '<span style="color:var(--text-dim);">原因:' + (e.message || '账套确认失败') + '</span>';
  1276. this.cdr.detectChanges();
  1277. return;
  1278. }
  1279. const primary = this.getPrimaryWorkshopAllocation();
  1280. const primaryTarget = primary ? (primary.oldCount || 0) + primary.count : 0;
  1281. if (primary) {
  1282. for (let i = 1; i <= 8; i++) {
  1283. await this.sleep(3000);
  1284. await this.refreshWorkshopBalances();
  1285. if ((primary.balance || 0) >= primaryTarget) break;
  1286. }
  1287. }
  1288. for (const allocation of this.workshopPackage.allocations) {
  1289. const oldCount = allocation.oldCount || 0;
  1290. const current = allocation.balance ?? oldCount;
  1291. const target = oldCount + allocation.count;
  1292. if (current >= target) continue;
  1293. try {
  1294. const rechargeBody: any = {
  1295. user: this.userId,
  1296. authid: allocation.authId,
  1297. apigid: allocation.apigId,
  1298. oldCount: current,
  1299. count: target - current,
  1300. orderid: this.order?.objectId || ''
  1301. };
  1302. if (this.companyId) rechargeBody.authComp = this.companyId;
  1303. console.log('[WORKSHOP_RECHARGE] saveRecharge 参数:', JSON.stringify(rechargeBody));
  1304. const rechargeResp = await this.postJSON(API_BASE + '/api/apig/saveRecharge', rechargeBody);
  1305. console.log('[WORKSHOP_RECHARGE] saveRecharge 响应:', JSON.stringify(rechargeResp));
  1306. } catch (e: any) {
  1307. console.warn('[WORKSHOP_RECHARGE] ' + allocation.title + ' 充值失败:', e.message);
  1308. }
  1309. }
  1310. for (let i = 1; i <= 3; i++) {
  1311. await this.sleep(2000);
  1312. await this.refreshWorkshopBalances();
  1313. const done = this.workshopPackage.allocations.every(item => {
  1314. const target = (item.oldCount || 0) + item.count;
  1315. return (item.balance || 0) >= target;
  1316. });
  1317. if (done) break;
  1318. }
  1319. this.successDetailHtml = this.buildWorkshopSuccessHtml();
  1320. this.cdr.detectChanges();
  1321. this.loadOrderHistory();
  1322. }
  1323. async refreshWorkshopBalances(): Promise<void> {
  1324. if (!this.workshopPackage) return;
  1325. for (const allocation of this.workshopPackage.allocations) {
  1326. if (!allocation.authId) continue;
  1327. const data = await this.getApigByAuthId(allocation.authId);
  1328. if (!data) continue;
  1329. allocation.title = data.title || allocation.title;
  1330. allocation.balance = data.count || 0;
  1331. if (allocation.apigId === this.workshopPackage.primaryApigId) {
  1332. this.apig = data;
  1333. }
  1334. }
  1335. this.syncApigListBalancesFromWorkshop();
  1336. this.cdr.detectChanges();
  1337. }
  1338. syncApigListBalancesFromWorkshop(): void {
  1339. if (!this.workshopPackage || this.apigList.length === 0) return;
  1340. for (const allocation of this.workshopPackage.allocations) {
  1341. const apig = this.apigList.find(item => item.objectId === allocation.apigId);
  1342. if (!apig) continue;
  1343. apig.authId = allocation.authId || apig.authId;
  1344. apig.userBalance = allocation.balance ?? apig.userBalance;
  1345. }
  1346. }
  1347. buildWorkshopSuccessHtml(): string {
  1348. if (!this.workshopPackage) return '';
  1349. const rows = this.workshopPackage.allocations.map(item => {
  1350. const target = (item.oldCount || 0) + item.count;
  1351. const balance = item.balance ?? 0;
  1352. const status = balance >= target ? '已到账' : '处理中';
  1353. return '<div style="margin-top:6px;">' +
  1354. '<strong style="color:var(--success);">' + item.title + '</strong> +' + item.count + ' 次' +
  1355. '<span style="color:var(--text-dim);">,当前余额 ' + balance + ' 次,' + status + '</span>' +
  1356. '</div>';
  1357. }).join('');
  1358. return '已开通 <strong style="color:var(--neon);">' + this.workshopPackage.title + '</strong><br>' +
  1359. rows +
  1360. '<span style="color:var(--text-dim);margin-top:8px;display:inline-block;">有效期 730 天,情报官训练服务请按工作坊流程交付。</span>';
  1361. }
  1362. async refreshBalance(): Promise<void> {
  1363. try {
  1364. const resp = await this.postJSON(API_BASE + '/api/apig/getApig', { authid: this.authId });
  1365. if (resp.code === 200 && resp.data?.count != null) {
  1366. this.apig!.count = resp.data.count;
  1367. const tier = this.apig!.priceStep![this.selectedIndex];
  1368. this.successDetailHtml =
  1369. '已充值 <strong style="color:var(--neon);">' + this.apig!.title + '</strong> 接口<br>' +
  1370. '获得 <strong style="color:var(--success);">' + tier.count + ' ' + this.unitLabel + '</strong>,有效期 730 天<br>' +
  1371. '<span style="color:var(--text-dim);margin-top:8px;display:inline-block;">当前余额: <strong style="color:var(--neon);">' + this.apig!.count + ' ' + this.unitLabel + '</strong></span>';
  1372. console.log('Balance refreshed:', this.apig!.count);
  1373. }
  1374. } catch (e: any) {
  1375. console.warn('Balance refresh failed:', e.message);
  1376. }
  1377. }
  1378. async renderQR(url: string): Promise<void> {
  1379. const canvas = this.qrCanvasRef?.nativeElement;
  1380. if (!canvas) { console.warn('QR canvas not found'); return; }
  1381. canvas.width = 200;
  1382. canvas.height = 200;
  1383. try {
  1384. await QRCode.toCanvas(canvas, url, {
  1385. width: 200,
  1386. margin: 0,
  1387. color: { dark: '#000000', light: '#ffffff' }
  1388. });
  1389. console.log('QR rendered via canvas');
  1390. } catch (e) {
  1391. console.warn('QRCode.toCanvas failed, using img fallback:', e);
  1392. canvas.style.display = 'none';
  1393. const wrapper = canvas.parentElement;
  1394. if (wrapper) {
  1395. const img = document.createElement('img');
  1396. img.src = 'https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=' + encodeURIComponent(url);
  1397. img.width = 200;
  1398. img.height = 200;
  1399. img.alt = 'Payment QR Code';
  1400. img.style.display = 'block';
  1401. wrapper.appendChild(img);
  1402. }
  1403. }
  1404. }
  1405. cancelPayment(): void {
  1406. if (this.pollTimer) { clearInterval(this.pollTimer); this.pollTimer = null; }
  1407. this.showQrModal = false;
  1408. }
  1409. handleDone(): void {
  1410. try {
  1411. const msg = { type: 'apig-payment-done', authid: this.authId, user: this.userId };
  1412. if (window.opener) (window.opener as any).postMessage(msg, '*');
  1413. if (window.parent !== window) window.parent.postMessage(msg, '*');
  1414. } catch (e) { console.warn('postMessage failed:', e); }
  1415. try { window.close(); } catch (e) {}
  1416. setTimeout(() => { window.location.reload(); }, 500);
  1417. }
  1418. // ─── Order History ───
  1419. async loadOrderHistory(append = false): Promise<void> {
  1420. this.ordersLoading = true;
  1421. this.orderError = '';
  1422. if (!append) {
  1423. this.orderSkip = 0;
  1424. this.orders = [];
  1425. }
  1426. // 在 detectChanges 之后触发异步工作,避免在父级 CD 周期内改变 [disabled] 绑定
  1427. this.cdr.detectChanges();
  1428. try {
  1429. const orClauses: any[] = [];
  1430. if (this.userId) {
  1431. orClauses.push({ fromUser: { __type: 'Pointer', className: '_User', objectId: this.userId } });
  1432. }
  1433. if (this.companyId) {
  1434. orClauses.push({ fromCompany: { __type: 'Pointer', className: 'Company', objectId: this.companyId } });
  1435. }
  1436. if (orClauses.length === 0) {
  1437. this.orders = [];
  1438. this.ordersLoading = false;
  1439. this.cdr.detectChanges();
  1440. return;
  1441. }
  1442. const where = JSON.stringify(orClauses.length === 1 ? orClauses[0] : { '$or': orClauses });
  1443. const qs = new URLSearchParams({
  1444. where,
  1445. order: '-createdAt',
  1446. limit: String(ORDER_PAGE_SIZE),
  1447. skip: String(this.orderSkip)
  1448. });
  1449. const resp = await fetch(API_BASE + '/parse/classes/APIGOrder?' + qs.toString(), {
  1450. headers: { 'X-Parse-Application-Id': APP_ID }
  1451. });
  1452. if (!resp.ok) throw new Error('HTTP ' + resp.status);
  1453. const data = await resp.json();
  1454. const newOrders: OrderData[] = data.results || [];
  1455. console.log('Order history loaded:', newOrders.length, 'skip:', this.orderSkip);
  1456. this.orders = append ? [...this.orders, ...newOrders] : newOrders;
  1457. this.hasMoreOrders = newOrders.length >= ORDER_PAGE_SIZE;
  1458. if (this.hasMoreOrders) this.orderSkip += ORDER_PAGE_SIZE;
  1459. } catch (e: any) {
  1460. console.warn('Order history error:', e);
  1461. this.orderError = e.message;
  1462. }
  1463. this.ordersLoading = false;
  1464. this.cdr.detectChanges();
  1465. }
  1466. // ─── Order formatting helpers ───
  1467. formatTime(createdAt: string): string {
  1468. if (!createdAt) return '—';
  1469. const dt = new Date(createdAt);
  1470. return dt.getFullYear() + '-' +
  1471. String(dt.getMonth() + 1).padStart(2, '0') + '-' +
  1472. String(dt.getDate()).padStart(2, '0') + ' ' +
  1473. String(dt.getHours()).padStart(2, '0') + ':' +
  1474. String(dt.getMinutes()).padStart(2, '0');
  1475. }
  1476. formatCount(o: OrderData): string {
  1477. const count = o.detail?.addCount || o.count || null;
  1478. if (count == null) return '—';
  1479. return count + ' ' + this.unitLabel;
  1480. }
  1481. formatAmount(o: OrderData): string {
  1482. return o.price != null ? '¥' + o.price : '—';
  1483. }
  1484. getStatusClass(o: OrderData): string {
  1485. if (o.isPay === true) {
  1486. if (o.detail?.addCount && o.detail?.oldCount != null) return 'status-recharged';
  1487. return 'status-paid';
  1488. }
  1489. if (o.isPay === false) {
  1490. const age = Date.now() - new Date(o.createdAt).getTime();
  1491. return age > 30 * 60 * 1000 ? 'status-failed' : 'status-pending';
  1492. }
  1493. return 'status-pending';
  1494. }
  1495. getStatusText(o: OrderData): string {
  1496. if (o.isPay === true) {
  1497. if (o.detail?.addCount && o.detail?.oldCount != null) return '已充值';
  1498. return '已支付';
  1499. }
  1500. if (o.isPay === false) {
  1501. const age = Date.now() - new Date(o.createdAt).getTime();
  1502. return age > 30 * 60 * 1000 ? '已过期' : '待支付';
  1503. }
  1504. return '未知';
  1505. }
  1506. // ─── Helpers ───
  1507. async postJSON(url: string, data: any): Promise<any> {
  1508. const resp = await fetch(url, {
  1509. method: 'POST',
  1510. headers: { 'Content-Type': 'application/json' },
  1511. body: JSON.stringify(data)
  1512. });
  1513. return resp.json();
  1514. }
  1515. async parseCloudCall(funcId: string, params: any): Promise<any> {
  1516. const url = PARSE_BASE + '/' + funcId;
  1517. const body = { _ApplicationId: APP_ID, ...params };
  1518. const resp = await fetch(url, {
  1519. method: 'POST',
  1520. headers: {
  1521. 'Content-Type': 'application/json',
  1522. 'X-Parse-Application-Id': APP_ID
  1523. },
  1524. body: JSON.stringify(body)
  1525. });
  1526. if (!resp.ok) {
  1527. console.warn('Cloud function ' + funcId + ' returned ' + resp.status);
  1528. return null;
  1529. }
  1530. return resp.json();
  1531. }
  1532. private sleep(ms: number): Promise<void> {
  1533. return new Promise(resolve => setTimeout(resolve, ms));
  1534. }
  1535. }