enterprise-trial.component.ts 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586
  1. import { ChangeDetectionStrategy, Component, Input, OnDestroy, OnInit, signal } from '@angular/core';
  2. import { CommonModule } from '@angular/common';
  3. import { buildEnterpriseAgentPrompt, maskEnterpriseAgentPrompt } from './enterprise-agent-prompt';
  4. const APP_ID = 'ncloudmaster';
  5. const API_BASE = 'https://server.fmode.cn';
  6. const SUBSCRIPTION_BASE = API_BASE + '/api/fmode/enterprise/subscription';
  7. interface BackendConfig {
  8. appId?: string;
  9. masterKey?: string;
  10. serverURL?: string;
  11. functionsURL?: string;
  12. status?: string;
  13. }
  14. interface SubscriptionView {
  15. active: boolean;
  16. status: string;
  17. planCode: string;
  18. planName: string;
  19. expireAt: string | null;
  20. remainingDays: number;
  21. provisionStatus: string;
  22. provisionError?: string;
  23. backend: BackendConfig | null;
  24. }
  25. interface TrialView {
  26. available: boolean;
  27. active: boolean;
  28. claimedAt: string | null;
  29. expireAt: string | null;
  30. remainingDays: number;
  31. planName: string;
  32. durationDays: number;
  33. bannerTitle: string;
  34. bannerText: string;
  35. }
  36. interface TrialResult {
  37. company?: { objectId: string; name: string };
  38. subscription?: SubscriptionView;
  39. trial?: TrialView;
  40. backend?: BackendConfig;
  41. }
  42. interface AgentPromptResult {
  43. prompt: string;
  44. maskedPrompt: string;
  45. rulesUrl?: string;
  46. expireAt?: string | null;
  47. }
  48. @Component({
  49. selector: 'app-enterprise-trial',
  50. standalone: true,
  51. imports: [CommonModule],
  52. changeDetection: ChangeDetectionStrategy.OnPush,
  53. template: `
  54. <div class="ent-page">
  55. <header class="page-header">
  56. <a class="brand" href="https://fmode.cn/" aria-label="Fmode 未来飞马">
  57. <span class="brand-mark">F</span>
  58. <span>Fmode<span> · 企业微服务</span></span>
  59. </a>
  60. <div class="user-area">
  61. <span>{{ displayName || userId }}</span>
  62. <button type="button" class="icon-button" title="退出登录" aria-label="退出登录" (click)="logout()">
  63. <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M10 17l5-5-5-5M15 12H3M21 3v18h-7"/></svg>
  64. </button>
  65. </div>
  66. </header>
  67. <main class="purchase-layout">
  68. <section class="configuration" aria-labelledby="trial-title">
  69. <div class="product-name">企业微服务 · 云上跑</div>
  70. <h1 id="trial-title">领取 7 天企业微服务试用</h1>
  71. <p class="lead">填写姓名与手机号即可自动开通。领取后立即获得独立的 Parse 数据库、专属服务端与云函数地址,7 天免费体验,结束可无缝续费。</p>
  72. @if (statusLoading()) {
  73. <div class="loading-state"><span class="spinner"></span>正在读取试用状态...</div>
  74. } @else if (alreadyActive() || claimed()) {
  75. <section class="active-subscription">
  76. <div>
  77. <span class="status-dot"></span>
  78. <strong>{{ alreadyActive() ? '企业微服务已开通' : '试用已领取' }}</strong>
  79. </div>
  80. <dl>
  81. <div><dt>有效期至</dt><dd>{{ formatDate(expireAt()) }}</dd></div>
  82. <div><dt>剩余天数</dt><dd>{{ remainingDays() }} 天</dd></div>
  83. </dl>
  84. </section>
  85. } @else {
  86. <section class="trial-panel" aria-labelledby="trial-form-title">
  87. <div class="trial-copy">
  88. <span class="trial-kicker">新人专享</span>
  89. <h2 id="trial-form-title">填写信息,立即开通</h2>
  90. <p>7 天内可体验完整云上跑专属微服务能力,每位用户限领一次。</p>
  91. </div>
  92. <div class="trial-form">
  93. <label class="trial-field">
  94. <span>姓名</span>
  95. <input type="text" placeholder="请输入姓名" maxlength="120" autocomplete="name" [value]="name()" (input)="name.set($any($event.target).value)" />
  96. </label>
  97. <label class="trial-field">
  98. <span>手机号</span>
  99. <input type="tel" inputmode="numeric" placeholder="请输入手机号" maxlength="11" autocomplete="tel" [value]="phone()" (input)="phone.set($any($event.target).value)" />
  100. </label>
  101. <button type="button" class="primary-button" [disabled]="submitting() || !validForm" (click)="submitTrial()">
  102. @if (submitting()) { <span class="spinner light"></span> }
  103. {{ submitting() ? '正在开通,请稍候...' : '免费领取 7 天试用' }}
  104. </button>
  105. </div>
  106. @if (errorMessage()) { <div class="trial-message trial-message-error">{{ errorMessage() }}</div> }
  107. </section>
  108. }
  109. </section>
  110. <aside class="order-summary" aria-live="polite">
  111. <div class="summary-heading">
  112. <span>试用权益</span>
  113. <b>7 天免费</b>
  114. </div>
  115. @if (alreadyActive()) {
  116. <div class="result-block">
  117. <div class="result-icon"><svg viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5"/></svg></div>
  118. <div class="price-main">已开通</div>
  119. <p class="price-caption">有效期至 {{ formatDate(expireAt()) }}</p>
  120. @if (backend()) {
  121. <dl class="config-lines">
  122. <div><dt>Server URL</dt><dd class="mono">{{ backend()!.serverURL }}</dd></div>
  123. <div><dt>Functions URL</dt><dd class="mono">{{ backend()!.functionsURL }}</dd></div>
  124. <div><dt>App ID</dt><dd class="mono">{{ backend()!.appId }}</dd></div>
  125. <div><dt>Master Key</dt><dd class="mono">{{ maskRuntimeSecret(backend()!.masterKey) }}</dd></div>
  126. </dl>
  127. } @else if (provisionFailed()) {
  128. <div class="flow-error">资源开通失败{{ provisionError() ? ':' + provisionError() : '' }}。</div>
  129. <button type="button" class="primary-button" [disabled]="retryBusy()" (click)="retryProvision()">
  130. @if (retryBusy()) { <span class="spinner light"></span> }
  131. {{ retryBusy() ? '正在重试开通...' : '重试开通' }}
  132. </button>
  133. } @else {
  134. <div class="pending-notice">资源开通处理中,可稍后刷新查看连接配置。</div>
  135. }
  136. @if (agentPrompt()) {
  137. <div class="agent-prompt-block">
  138. <div class="agent-prompt-heading">
  139. <strong>交给 Agent 的闭环提示词</strong>
  140. <span>已脱敏预览</span>
  141. </div>
  142. <div class="agent-prompt-preview"><pre>{{ maskedAgentPrompt() }}</pre></div>
  143. <button type="button" class="copy-button" (click)="copyAgentPrompt()">
  144. @if (promptCopied()) { 已复制,可发送给 Agent } @else { 复制提示词给 Agent }
  145. </button>
  146. </div>
  147. } @else {
  148. <button type="button" class="secondary-button full" [disabled]="promptBusy()" (click)="requestAgentPrompt()">
  149. @if (promptBusy()) { <span class="spinner light"></span> }
  150. {{ promptBusy() ? '正在生成提示词...' : '生成 Agent 闭环提示词' }}
  151. </button>
  152. }
  153. <button type="button" class="console-button" (click)="openMicroserviceConsole()">
  154. 进入微服控制台
  155. <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12h14M13 6l6 6-6 6"/></svg>
  156. </button>
  157. <button type="button" class="secondary-button full" [disabled]="refreshBusy() || retryBusy()" (click)="refreshStatus()">
  158. @if (refreshBusy()) { <span class="spinner light"></span> }
  159. 刷新状态
  160. </button>
  161. </div>
  162. } @else if (claimed()) {
  163. <div class="result-block">
  164. <div class="result-icon"><svg viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5"/></svg></div>
  165. <div class="price-main">试用已领取</div>
  166. <p class="price-caption">该企业已领取过 7 天试用,请在 Fmode Studio 企业端控制台查看开通状态。</p>
  167. <button type="button" class="secondary-button full" [disabled]="refreshBusy()" (click)="refreshStatus()">
  168. @if (refreshBusy()) { <span class="spinner light"></span> }
  169. 刷新状态
  170. </button>
  171. </div>
  172. } @else {
  173. <div class="benefit-list">
  174. <div class="benefit-item">
  175. <span class="benefit-icon"><svg viewBox="0 0 24 24"><path d="M4 7v10M8 7v10M12 7v10M16 7v10M20 7v10"/></svg></span>
  176. <div><strong>独立 Parse 数据库</strong><small>专属数据表与多租户隔离</small></div>
  177. </div>
  178. <div class="benefit-item">
  179. <span class="benefit-icon"><svg viewBox="0 0 24 24"><path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/></svg></span>
  180. <div><strong>专属服务端与云函数</strong><small>Server / Functions 地址开箱即用</small></div>
  181. </div>
  182. <div class="benefit-item">
  183. <span class="benefit-icon"><svg viewBox="0 0 24 24"><path d="M18 20V10M12 20V4M6 20v-6"/></svg></span>
  184. <div><strong>一键进入云上跑</strong><small>配置直达,无缝衔接项目开发</small></div>
  185. </div>
  186. </div>
  187. <p class="billing-note">领取即代表同意开通企业微服务。试用结束后可选择包月 / 包季 / 包年续费。</p>
  188. }
  189. </aside>
  190. </main>
  191. </div>
  192. `,
  193. styles: [`
  194. :host{display:block;min-height:100vh;color:#edf2f3;background:#0b0e10;font-family:Inter,"PingFang SC","Microsoft YaHei",sans-serif}
  195. *{box-sizing:border-box;letter-spacing:0}button,input{font:inherit}button{cursor:pointer}svg{fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
  196. button:focus-visible,input:focus-visible{outline:2px solid #25b983;outline-offset:3px}
  197. .ent-page{min-height:100vh;background:linear-gradient(180deg,#0b0e10 0,#101619 68%,#0b0e10 100%)}
  198. .page-header{height:70px;padding:0 clamp(18px,5vw,72px);display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid #283035;background:rgba(11,14,16,.94)}
  199. .brand{display:flex;align-items:center;gap:10px;color:#f7fbfa;text-decoration:none;font-size:17px;font-weight:800}.brand>span:last-child>span{color:#55d6a8;font-weight:650}.brand-mark{width:31px;height:31px;display:grid;place-items:center;border-radius:6px;background:#25b983;color:#07130f;font-size:15px}
  200. .user-area{display:flex;align-items:center;gap:12px;color:#9ba7aa;font-size:13px}.icon-button{width:36px;height:36px;padding:0;display:grid;place-items:center;border:1px solid #354047;border-radius:6px;background:#14191c;color:#aeb8bc}.icon-button:hover{color:#fff;border-color:#536068}.icon-button svg{width:18px;height:18px}
  201. .purchase-layout{width:min(1180px,calc(100% - 40px));min-height:calc(100vh - 70px);margin:0 auto;padding:58px 0 72px;display:grid;grid-template-columns:minmax(0,1fr) 390px;gap:clamp(52px,8vw,110px);align-items:center}
  202. .configuration{max-width:680px}.product-name{margin-bottom:15px;color:#55d6a8;font-size:14px;font-weight:750}.configuration h1{margin:0;max-width:690px;color:#f8fbfa;font-size:clamp(38px,5vw,60px);line-height:1.08;font-weight:760}.lead{margin:20px 0 34px;max-width:660px;color:#9ca8ac;font-size:16px;line-height:1.75}
  203. .trial-panel{margin:0 0 30px;padding:18px;border:1px solid #2f5d4d;border-radius:8px;background:#10211b}.trial-copy h2{margin:5px 0 6px;color:#eafff5;font-size:17px}.trial-copy p{margin:0;color:#9fc8b9;font-size:12px;line-height:1.6}.trial-kicker{color:#69dda9;font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.08em}.trial-form{margin-top:16px;display:flex;flex-direction:column;gap:12px}.trial-field{display:flex;flex-direction:column;gap:7px}.trial-field>span{color:#9fc8b9;font-size:11px}.trial-field input{height:40px;padding:0 11px;border:1px solid #3a554a;border-radius:6px;background:#0d1713;color:#ecfff6;font-size:13px}.trial-field input:focus{border-color:#25b983}.trial-message{margin-top:12px;padding:9px 11px;border-left:3px solid #ef7359;background:#291713;color:#ffbaa9;font-size:11px;line-height:1.5}
  204. .loading-state{min-height:220px;display:flex;align-items:center;justify-content:center;gap:9px;color:#829094;font-size:13px}
  205. .active-subscription{margin:0 0 30px;padding:16px 18px;border-left:3px solid #25b983;background:#111a17}.active-subscription>div{display:flex;align-items:center;gap:9px}.status-dot{width:8px;height:8px;border-radius:50%;background:#35d79b;box-shadow:0 0 0 5px rgba(53,215,155,.1)}.active-subscription dl{margin:14px 0 10px;display:flex;gap:32px}.active-subscription dl div{display:flex;gap:8px}.active-subscription dt{color:#788589}.active-subscription dd{margin:0;color:#dfe7e5}
  206. .primary-button{width:100%;min-height:46px;margin-top:6px;border:1px solid #25b983;border-radius:6px;background:#25b983;color:#06140f;font-size:13px;font-weight:800;display:flex;align-items:center;justify-content:center;gap:8px}.primary-button:hover{background:#35ca94}.primary-button:disabled{opacity:.55;cursor:not-allowed}
  207. .secondary-button{min-height:40px;margin-top:10px;padding:0 14px;border:1px solid #39444a;border-radius:6px;background:#171d20;color:#c4cdcf;font-size:12px}.secondary-button.full{width:100%;margin-top:18px}.secondary-button:disabled{opacity:.55;cursor:not-allowed}
  208. .order-summary{min-height:500px;padding:28px;border:1px solid #303a40;border-radius:8px;background:#12171a;box-shadow:0 28px 80px rgba(0,0,0,.32)}.summary-heading{display:flex;align-items:center;justify-content:space-between;color:#a4afb2;font-size:12px;font-weight:700}.summary-heading b{padding:3px 7px;border-radius:4px;background:#173426;color:#69dda9;font-size:11px}
  209. .benefit-list{margin-top:24px}.benefit-item{display:flex;gap:12px;padding:14px 0;border-bottom:1px solid #252e33}.benefit-icon{width:36px;height:36px;flex:0 0 auto;display:grid;place-items:center;border:1px solid #2c493f;border-radius:7px;background:#10211b;color:#55d6a8}.benefit-icon svg{width:17px}.benefit-item strong{display:block;font-size:13px}.benefit-item small{margin-top:4px;display:block;color:#778488;font-size:11px;line-height:1.5}
  210. .billing-note{margin:16px 0 0;color:#6f7b7f;font-size:10px;line-height:1.6;text-align:center}
  211. .result-block{text-align:center}.result-icon{width:44px;height:44px;margin:20px auto 12px;display:grid;place-items:center;border-radius:8px;background:#123024;color:#60dda5}.result-icon svg{width:20px}.price-main{margin-top:6px;color:#fff;font-size:26px;line-height:1;font-weight:780}.price-caption{margin-top:8px;color:#7f8c90;font-size:12px;line-height:1.6}
  212. .config-lines{margin:24px 0 0;text-align:left;border-top:1px solid #2a3338}.config-lines>div{padding:12px 0;border-bottom:1px solid #252e33}.config-lines dt{color:#849196;font-size:11px;margin-bottom:5px}.config-lines dd{margin:0;color:#dbe3e1;font-size:12px}.mono{font-family:Consolas,Menlo,monospace;overflow-wrap:anywhere;word-break:break-all}
  213. .agent-prompt-block{margin-top:20px;padding-top:18px;border-top:1px solid #2a3338;text-align:left}.agent-prompt-heading{display:flex;align-items:center;justify-content:space-between;gap:10px}.agent-prompt-heading strong{color:#e7f4ef;font-size:12px}.agent-prompt-heading span{color:#7f9a90;font-size:10px}.agent-prompt-preview{height:150px;margin-top:10px;padding:10px;overflow:auto;border:1px solid #2e383d;border-radius:6px;background:#090c0e}.agent-prompt-preview pre{margin:0;color:#aebcba;font:10px/1.55 Consolas,Menlo,monospace;white-space:pre-wrap;overflow-wrap:anywhere}.copy-button,.console-button{width:100%;min-height:40px;margin-top:10px;border-radius:6px;font-size:12px;font-weight:800;display:flex;align-items:center;justify-content:center;gap:8px}.copy-button{border:1px solid #25b983;background:#25b983;color:#06140f}.copy-button:hover{background:#35ca94}.console-button{border:1px solid #3d5259;background:#182126;color:#dbe8e4}.console-button:hover{border-color:#55d6a8;color:#fff;background:#1d2b29}.console-button svg{width:15px;height:15px}
  214. .flow-error{margin-top:18px;padding:10px 11px;border-left:3px solid #ef7359;background:#291713;color:#ffbaa9;font-size:12px;line-height:1.5;text-align:left}.pending-notice{margin-top:18px;padding:10px 12px;border-left:3px solid #55d6a8;background:#10211b;color:#9fc8b9;font-size:12px;line-height:1.6;text-align:left}
  215. .spinner{width:17px;height:17px;display:inline-block;border:2px solid #3c484d;border-top-color:#55d6a8;border-radius:50%;animation:spin .75s linear infinite}.spinner.light{border-color:rgba(0,0,0,.25);border-top-color:#06140f}@keyframes spin{to{transform:rotate(360deg)}}
  216. @media(max-width:880px){.purchase-layout{grid-template-columns:1fr;gap:38px;align-items:start}.configuration{max-width:none}.order-summary{min-height:0}}
  217. @media(max-width:560px){.page-header{height:62px;padding:0 15px}.user-area>span{display:none}.purchase-layout{width:calc(100% - 28px);padding:32px 0 48px}.configuration h1{font-size:36px}.lead{font-size:14px}.order-summary{padding:22px}}
  218. @media(prefers-reduced-motion:reduce){*{animation-duration:.01ms!important;transition-duration:.01ms!important}}
  219. `]
  220. })
  221. export class EnterpriseTrialComponent implements OnInit, OnDestroy {
  222. @Input() sessionToken = '';
  223. @Input() userId = '';
  224. @Input() displayName = '';
  225. readonly name = signal('');
  226. readonly phone = signal('');
  227. readonly errorMessage = signal('');
  228. readonly submitting = signal(false);
  229. readonly statusLoading = signal(true);
  230. readonly refreshBusy = signal(false);
  231. readonly retryBusy = signal(false);
  232. readonly result = signal<TrialResult | null>(null);
  233. readonly agentPrompt = signal<AgentPromptResult | null>(null);
  234. readonly maskedAgentPrompt = signal('');
  235. readonly promptBusy = signal(false);
  236. readonly promptCopied = signal(false);
  237. private runtimeUser: any | null = null;
  238. private readonly pollTimer = signal<ReturnType<typeof setInterval> | null>(null);
  239. private readonly authHandler = (event: Event) => {
  240. const detail = (event as CustomEvent<{ userId: string; sessionToken: string; displayName: string }>).detail;
  241. if (!detail?.sessionToken) return;
  242. this.userId = detail.userId;
  243. this.sessionToken = detail.sessionToken;
  244. this.displayName = detail.displayName || detail.userId;
  245. this.needRouteAuthRefresh();
  246. };
  247. ngOnInit(): void {
  248. this.sessionToken ||= localStorage.getItem('apig_session_token') || '';
  249. this.userId ||= localStorage.getItem('apig_user_id') || '';
  250. this.displayName ||= localStorage.getItem('apig_display_name') || '';
  251. window.addEventListener('fmode-auth-success', this.authHandler);
  252. void this.initialize();
  253. }
  254. ngOnDestroy(): void {
  255. window.removeEventListener('fmode-auth-success', this.authHandler);
  256. this.stopPolling();
  257. }
  258. logout(): void {
  259. // 组件由 Router 独立渲染,App 侧通过 fmode-logout 事件接收并清理登录态
  260. window.dispatchEvent(new CustomEvent('fmode-logout'));
  261. }
  262. private needRouteAuthRefresh(): void {
  263. this.errorMessage.set('');
  264. void this.initialize();
  265. }
  266. get validForm(): boolean {
  267. return this.name().trim().length > 0 && /^1[3-9]\d{9}$/.test(this.phone().trim());
  268. }
  269. alreadyActive(): boolean {
  270. return Boolean(this.result()?.trial?.active || this.result()?.subscription?.active);
  271. }
  272. claimed(): boolean {
  273. return Boolean(this.result()?.trial?.claimedAt) && !this.alreadyActive();
  274. }
  275. expireAt(): string | null {
  276. return this.result()?.trial?.expireAt || this.result()?.subscription?.expireAt || null;
  277. }
  278. remainingDays(): number {
  279. return this.result()?.trial?.remainingDays ?? this.result()?.subscription?.remainingDays ?? 0;
  280. }
  281. backend(): BackendConfig | null {
  282. return this.result()?.backend || this.result()?.subscription?.backend || null;
  283. }
  284. provisionFailed(): boolean {
  285. return this.result()?.subscription?.provisionStatus === 'failed';
  286. }
  287. provisionError(): string {
  288. return this.result()?.subscription?.provisionError || '';
  289. }
  290. formatDate(value?: string | null): string {
  291. if (!value) return '待开通';
  292. const date = new Date(value);
  293. return Number.isNaN(date.getTime()) ? '待确认' : date.toLocaleDateString('zh-CN');
  294. }
  295. maskRuntimeSecret(value?: string): string {
  296. const text = String(value || '').trim();
  297. if (!text) return '未提供';
  298. if (text.length <= 10) return `${text.slice(0, 2)}***`;
  299. return `${text.slice(0, 6)}***${text.slice(-4)}`;
  300. }
  301. async refreshStatus(): Promise<void> {
  302. this.refreshBusy.set(true);
  303. this.errorMessage.set('');
  304. try {
  305. const data = await this.loadStatus();
  306. if (data) this.setResult(data);
  307. } catch (error: any) {
  308. this.errorMessage.set(error?.message || '状态刷新失败,请稍后重试。');
  309. } finally {
  310. this.refreshBusy.set(false);
  311. }
  312. }
  313. async retryProvision(): Promise<void> {
  314. if (this.retryBusy()) return;
  315. this.retryBusy.set(true);
  316. this.errorMessage.set('');
  317. try {
  318. const result = await this.authJson('/api/fmode/enterprise/subscription/provision/retry', 'POST', {});
  319. if (result.code !== 200) throw new Error(result.mess || '重试开通失败');
  320. if (result.data) this.setResult(result.data);
  321. this.startPolling();
  322. } catch (error: any) {
  323. this.errorMessage.set(error?.message || '重试开通失败,请稍后重试。');
  324. } finally {
  325. this.retryBusy.set(false);
  326. }
  327. }
  328. async submitTrial(): Promise<void> {
  329. if (this.submitting() || !this.validForm) return;
  330. this.submitting.set(true);
  331. this.errorMessage.set('');
  332. try {
  333. const result = await this.authJson('/api/fmode/enterprise/subscription/trial-standalone', 'POST', {
  334. name: this.name().trim(),
  335. mobile: this.phone().trim()
  336. });
  337. if (result.code !== 200 || !result.data) throw new Error(result.mess || '开通失败,请稍后重试。');
  338. this.setResult(result.data);
  339. this.startPolling();
  340. } catch (error: any) {
  341. this.errorMessage.set(error?.message || '开通失败,请稍后重试。');
  342. } finally {
  343. this.submitting.set(false);
  344. }
  345. }
  346. private initializeSeq = 0;
  347. private async initialize(): Promise<void> {
  348. const seq = ++this.initializeSeq;
  349. this.statusLoading.set(true);
  350. try {
  351. const cachedPhone = localStorage.getItem('apig_phone')?.trim() || '';
  352. if (cachedPhone) this.phone.set(cachedPhone);
  353. const me = await this.fetchMe();
  354. if (seq !== this.initializeSeq) return;
  355. this.runtimeUser = me;
  356. const parsePhone = this.userPhone(me);
  357. if (!cachedPhone && parsePhone) {
  358. this.phone.set(parsePhone);
  359. localStorage.setItem('apig_phone', parsePhone);
  360. }
  361. const data = await this.loadStatus();
  362. if (seq !== this.initializeSeq) return;
  363. if (data) this.setResult(data);
  364. } catch (error: any) {
  365. console.warn('[EnterpriseTrial] status init failed:', error?.message);
  366. } finally {
  367. // 仅最新一次 initialize 负责收尾,避免并发初始化时旧请求覆盖新状态
  368. if (seq === this.initializeSeq) this.statusLoading.set(false);
  369. }
  370. }
  371. private async loadStatus(): Promise<TrialResult | null> {
  372. try {
  373. const result = await this.authJson('/api/fmode/enterprise/subscription/status', 'POST', {});
  374. if (result.code === 200 && result.data) return result.data as TrialResult;
  375. return null;
  376. } catch (error: any) {
  377. if (Number(error?.status) === 400 || Number(error?.status) === 403) return null;
  378. throw error;
  379. }
  380. }
  381. private async fetchMe(): Promise<any | null> {
  382. try {
  383. const response = await fetch(`${API_BASE}/parse/users/me?include=company`, {
  384. headers: this.parseHeaders()
  385. });
  386. if (!response.ok) return null;
  387. return await response.json();
  388. } catch {
  389. return null;
  390. }
  391. }
  392. private userPhone(user: any): string {
  393. const value = String(user?.mobile || user?.mobilePhoneNumber || '').trim();
  394. return /^1[3-9]\d{9}$/.test(value) ? value : '';
  395. }
  396. private startPolling(): void {
  397. this.stopPolling();
  398. let attempts = 0;
  399. const maxAttempts = 45; // 4s/次,最长约 3 分钟,兜底防止无限轮询
  400. const timer = setInterval(() => {
  401. attempts += 1;
  402. void this.loadStatus().then(data => {
  403. if (!data) return;
  404. this.setResult(data);
  405. const status = data.subscription?.provisionStatus;
  406. const hasBackend = Boolean(data.backend || data.subscription?.backend);
  407. // 开通成功(拿到 backend)、开通失败、或达到轮询上限时终止
  408. if (status === 'failed' || hasBackend || attempts >= maxAttempts) {
  409. this.stopPolling();
  410. }
  411. }).catch(() => {
  412. // 状态查询异常也兜底终止,避免无限轮询
  413. if (attempts >= maxAttempts) this.stopPolling();
  414. });
  415. }, 4000);
  416. this.pollTimer.set(timer);
  417. }
  418. private stopPolling(): void {
  419. const timer = this.pollTimer();
  420. if (timer) {
  421. clearInterval(timer);
  422. this.pollTimer.set(null);
  423. }
  424. }
  425. async requestAgentPrompt(): Promise<void> {
  426. await this.loadAgentPrompt();
  427. }
  428. async copyAgentPrompt(): Promise<void> {
  429. const prompt = this.agentPrompt()?.prompt;
  430. if (!prompt) return;
  431. try {
  432. if (navigator.clipboard?.writeText) {
  433. await navigator.clipboard.writeText(prompt);
  434. } else {
  435. const textarea = document.createElement('textarea');
  436. textarea.value = prompt;
  437. textarea.setAttribute('readonly', '');
  438. textarea.style.position = 'fixed';
  439. textarea.style.opacity = '0';
  440. document.body.appendChild(textarea);
  441. textarea.select();
  442. if (!document.execCommand('copy')) throw new Error('copy failed');
  443. textarea.remove();
  444. }
  445. this.promptCopied.set(true);
  446. window.setTimeout(() => this.promptCopied.set(false), 2200);
  447. } catch {
  448. this.errorMessage.set('浏览器未允许复制,请在 HTTPS 或 localhost 页面中重试。');
  449. }
  450. }
  451. openMicroserviceConsole(): void {
  452. const backend = this.backend();
  453. if (!backend?.serverURL || !backend.appId || !backend.masterKey || !this.sessionToken) {
  454. this.errorMessage.set('微服务配置或登录凭据尚未就绪,请刷新状态后重试。');
  455. return;
  456. }
  457. const url = new URL('https://app.fmode.cn/dev/studio/dashboard');
  458. url.searchParams.set('enterprise', '1');
  459. url.searchParams.set('serverURL', backend.serverURL);
  460. url.searchParams.set('appId', backend.appId);
  461. url.searchParams.set('masterKey', backend.masterKey);
  462. url.searchParams.set('name', this.result()?.company?.name || `企业服务 · ${backend.appId}`);
  463. if (backend.functionsURL) url.searchParams.set('functionsURL', backend.functionsURL);
  464. url.searchParams.set('session', this.sessionToken);
  465. window.location.assign(url.toString());
  466. }
  467. private setResult(data: TrialResult): void {
  468. this.result.set(data);
  469. const backend = data.backend || data.subscription?.backend;
  470. if (backend && data.subscription?.provisionStatus === 'success' && !this.agentPrompt() && !this.promptBusy()) {
  471. void this.loadAgentPrompt();
  472. }
  473. }
  474. private async loadAgentPrompt(): Promise<void> {
  475. if (this.promptBusy() || this.agentPrompt()) return;
  476. this.promptBusy.set(true);
  477. this.errorMessage.set('');
  478. try {
  479. const backend = this.backend();
  480. if (!backend) throw new Error('企业 Parse 后端尚未就绪');
  481. const newApiToken = String(
  482. localStorage.getItem('fmode_newapi_token')
  483. || localStorage.getItem('newapiToken')
  484. || this.runtimeUser?.fmodeApiToken
  485. || this.runtimeUser?.newapiToken
  486. || ''
  487. ).trim();
  488. if (!newApiToken) throw new Error('未找到 Fmode New API token,请先完成账户同步');
  489. if (!this.sessionToken) throw new Error('未找到当前登录 sessionToken,请重新登录后重试');
  490. const prompt = buildEnterpriseAgentPrompt({
  491. backend,
  492. newApiToken,
  493. sessionToken: this.sessionToken,
  494. companyId: this.result()?.company?.objectId,
  495. companyName: this.result()?.company?.name,
  496. expireAt: this.expireAt()
  497. });
  498. this.agentPrompt.set({ prompt, maskedPrompt: maskEnterpriseAgentPrompt(prompt) });
  499. this.maskedAgentPrompt.set(maskEnterpriseAgentPrompt(prompt));
  500. } catch (error: any) {
  501. this.errorMessage.set(error?.message || '提示词生成失败,请稍后重试。');
  502. } finally {
  503. this.promptBusy.set(false);
  504. }
  505. }
  506. private async authJson(path: string, method: 'GET' | 'POST', body?: unknown): Promise<any> {
  507. // 后端 /enterprise/* 通过 body.sessionToken 鉴权
  508. // (enterprise-service.sessionUserFromRequest 优先读 req.body.sessionToken),
  509. // 因此无需在 header 重复携带 Authorization / X-Parse-Session-Token。
  510. const headers: Record<string, string> = {
  511. 'Content-Type': 'application/json'
  512. };
  513. const response = await fetch(`${API_BASE}${path}`, {
  514. method,
  515. headers,
  516. cache: 'no-store',
  517. // 明确不带 cookie/凭据:接口使用 body 的 sessionToken 鉴权;
  518. // 若携带 credentials: "include",服务端返回 Access-Control-Allow-Origin: * 会被浏览器拦截(CORS 跨域报错)。
  519. credentials: 'omit',
  520. body: method === 'GET' ? undefined : JSON.stringify({ ...(body || {}), sessionToken: this.sessionToken })
  521. });
  522. const result = await response.json().catch(() => ({}));
  523. if (!response.ok || (result.code && result.code !== 200)) {
  524. const error = new Error(result.mess || result.message || '请求失败') as Error & { status?: number };
  525. error.status = response.status || result.code;
  526. throw error;
  527. }
  528. return result;
  529. }
  530. private parseHeaders(): Record<string, string> {
  531. return {
  532. 'Content-Type': 'application/json',
  533. 'X-Parse-Application-Id': APP_ID,
  534. 'X-Parse-Session-Token': this.sessionToken
  535. };
  536. }
  537. }